profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

main default branch 105 files Expires Sep 13, 2026, 9:06 AM

Commit

Add cross-platform demo mode

commit bcbae8d

9 changed files with +145 and −36

Jump to a changed file
  1. package.json +2 −0
  2. server/exporter/zip.test.ts +56 −26
  3. server/index.ts +3 −0
  4. server/routes/export.test.ts +4 −9
  5. server/runtimeMode.test.ts +18 −0
  6. server/runtimeMode.ts +5 −0
  7. spec/PLAN.md +14 −1
  8. spec/SPEC.md +18 −0
  9. spec/TASKS.md +25 −0
modified package.json +2 −0
@@ -11,6 +11,8 @@
11 11 "dev": "concurrently -k -n server,client -c blue,green \"npm:dev:server\" \"npm:dev:client\"",
12 12 "dev:server": "tsx watch server/index.ts",
13 13 "dev:client": "vite --config client/vite.config.ts",
14 + "demo": "concurrently -k -n server,client -c blue,green \"npm:demo:server\" \"npm:dev:client\"",
15 + "demo:server": "tsx watch server/index.ts --demo",
14 16 "build": "npm run build:client && npm run build:server",
15 17 "build:client": "vite build --config client/vite.config.ts",
16 18 "build:server": "tsc -p server/tsconfig.json",
modified server/exporter/zip.test.ts +56 −26
@@ -1,43 +1,73 @@
1 -import { execFileSync } from 'node:child_process'
2 -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
3 -import { tmpdir } from 'node:os'
4 -import path from 'node:path'
5 -import { afterEach, beforeEach, describe, expect, it } from 'vitest'
1 +import { describe, expect, it } from 'vitest'
6 2 import { createZip } from './zip'
7 3
8 -describe('createZip', () => {
9 - let workDir: string
4 +interface StoredEntry {
5 + content: Buffer
6 + localOffset: number
7 +}
10 8
11 - beforeEach(async () => {
12 - workDir = await mkdtemp(path.join(tmpdir(), 'voicetask-zip-'))
13 - })
9 +function readStoredEntries(zip: Buffer): Map<string, StoredEntry> {
10 + const eocdOffset = zip.length - 22
11 + expect(zip.readUInt32LE(eocdOffset)).toBe(0x06054b50)
14 12
15 - afterEach(async () => {
16 - await rm(workDir, { recursive: true, force: true })
17 - })
13 + const entryCount = zip.readUInt16LE(eocdOffset + 10)
14 + const centralSize = zip.readUInt32LE(eocdOffset + 12)
15 + const centralOffset = zip.readUInt32LE(eocdOffset + 16)
16 + expect(centralOffset + centralSize).toBe(eocdOffset)
17 +
18 + const entries = new Map<string, StoredEntry>()
19 + let cursor = centralOffset
20 +
21 + for (let index = 0; index < entryCount; index++) {
22 + expect(zip.readUInt32LE(cursor)).toBe(0x02014b50)
23 + expect(zip.readUInt16LE(cursor + 10)).toBe(0)
24 +
25 + const storedSize = zip.readUInt32LE(cursor + 24)
26 + const nameLength = zip.readUInt16LE(cursor + 28)
27 + const extraLength = zip.readUInt16LE(cursor + 30)
28 + const commentLength = zip.readUInt16LE(cursor + 32)
29 + const localOffset = zip.readUInt32LE(cursor + 42)
30 + const name = zip.subarray(cursor + 46, cursor + 46 + nameLength).toString('utf8')
31 +
32 + expect(zip.readUInt32LE(localOffset)).toBe(0x04034b50)
33 + expect(zip.readUInt16LE(localOffset + 8)).toBe(0)
34 +
35 + const localSize = zip.readUInt32LE(localOffset + 22)
36 + const localNameLength = zip.readUInt16LE(localOffset + 26)
37 + const localExtraLength = zip.readUInt16LE(localOffset + 28)
38 + const localName = zip
39 + .subarray(localOffset + 30, localOffset + 30 + localNameLength)
40 + .toString('utf8')
41 + const contentOffset = localOffset + 30 + localNameLength + localExtraLength
18 42
19 - it('produces a zip that a real unzip tool can extract byte-for-byte', async () => {
43 + expect(localName).toBe(name)
44 + expect(localSize).toBe(storedSize)
45 + entries.set(name, {
46 + content: zip.subarray(contentOffset, contentOffset + storedSize),
47 + localOffset,
48 + })
49 +
50 + cursor += 46 + nameLength + extraLength + commentLength
51 + }
52 +
53 + expect(cursor).toBe(eocdOffset)
54 + return entries
55 +}
56 +
57 +describe('createZip', () => {
58 + it('produces a valid stored zip with byte-for-byte content', () => {
20 59 const entries = [
21 60 { name: 'SPEC.md', content: Buffer.from('# SPEC\n\nsome content [S1]\n', 'utf8') },
22 61 { name: 'nested/HANDOFF.md', content: Buffer.from('claude "do the thing"\n', 'utf8') },
23 62 { name: 'empty.txt', content: Buffer.alloc(0) },
24 63 ]
25 64
26 65 const zipBuffer = createZip(entries, new Date('2026-01-15T10:30:00'))
27 - const zipPath = path.join(workDir, 'pack.zip')
28 - await writeFile(zipPath, zipBuffer)
29 -
30 - const extractDir = path.join(workDir, 'extracted')
31 - execFileSync('unzip', ['-o', zipPath, '-d', extractDir])
32 -
33 - for (const entry of entries) {
34 - const extracted = await readFile(path.join(extractDir, entry.name))
35 - expect(extracted.equals(entry.content)).toBe(true)
36 - }
66 + const stored = readStoredEntries(zipBuffer)
37 67
38 - const listing = execFileSync('unzip', ['-l', zipPath]).toString('utf8')
39 68 for (const entry of entries) {
40 - expect(listing).toContain(entry.name)
69 + expect(stored.get(entry.name)?.content.equals(entry.content)).toBe(true)
41 70 }
71 + expect([...stored.keys()]).toEqual(entries.map((entry) => entry.name))
42 72 })
43 73 })
modified server/index.ts +3 −0
@@ -1,5 +1,6 @@
1 1 import { existsSync, readFileSync } from 'node:fs'
2 2 import { buildApp } from './app'
3 +import { applyDemoMode } from './runtimeMode'
3 4
4 5 // .env wins over inherited shell values, so a stale MOCK_PROVIDERS=1 in some
5 6 // old terminal cannot silently force mock mode.
@@ -13,6 +14,8 @@if (existsSync('.env')) {
13 14 }
14 15 }
15 16
17 +applyDemoMode(process.argv, process.env)
18 +
16 19 if (process.env.MOCK_PROVIDERS !== '1') {
17 20 const missing = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'].filter((key) => {
18 21 const value = process.env[key]
modified server/routes/export.test.ts +4 −9
@@ -1,5 +1,4 @@
1 -import { execFileSync } from 'node:child_process'
2 -import { mkdtemp, rm, writeFile } from 'node:fs/promises'
1 +import { mkdtemp, rm } from 'node:fs/promises'
3 2 import { tmpdir } from 'node:os'
4 3 import path from 'node:path'
5 4 import type { FastifyInstance } from 'fastify'
@@ -12,21 +11,18 @@import { SessionStore } from '../store/sessionStore'
12 11 describe('export routes', () => {
13 12 let storeDir: string
14 13 let targetDir: string
15 - let workDir: string
16 14 let app: FastifyInstance
17 15
18 16 beforeEach(async () => {
19 17 storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
20 18 targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-'))
21 - workDir = await mkdtemp(path.join(tmpdir(), 'voicetask-workdir-'))
22 19 app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() })
23 20 })
24 21
25 22 afterEach(async () => {
26 23 await app.close()
27 24 await rm(storeDir, { recursive: true, force: true })
28 25 await rm(targetDir, { recursive: true, force: true })
29 - await rm(workDir, { recursive: true, force: true })
30 26 })
31 27
32 28 async function createSessionWithAnAnswer(): Promise<Session> {
@@ -56,11 +52,10 @@describe('export routes', () => {
56 52 expect(res.headers['content-type']).toBe('application/zip')
57 53 expect(res.headers['content-disposition']).toContain('family-recipes-spec-pack.zip')
58 54
59 - const zipPath = path.join(workDir, 'pack.zip')
60 - await writeFile(zipPath, res.rawPayload)
61 - const listing = execFileSync('unzip', ['-l', zipPath]).toString('utf8')
55 + expect(res.rawPayload.readUInt32LE(0)).toBe(0x04034b50)
56 + expect(res.rawPayload.readUInt32LE(res.rawPayload.length - 22)).toBe(0x06054b50)
62 57 for (const file of ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md', 'sources.json']) {
63 - expect(listing).toContain(file)
58 + expect(res.rawPayload.includes(Buffer.from(file, 'utf8'))).toBe(true)
64 59 }
65 60 })
66 61
added server/runtimeMode.test.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { applyDemoMode } from './runtimeMode'
3 +
4 +describe('applyDemoMode', () => {
5 + it('forces mock providers when the demo flag is present', () => {
6 + const env: NodeJS.ProcessEnv = { MOCK_PROVIDERS: '' }
7 +
8 + expect(applyDemoMode(['node', 'server/index.ts', '--demo'], env)).toBe(true)
9 + expect(env.MOCK_PROVIDERS).toBe('1')
10 + })
11 +
12 + it('does not change provider mode without the demo flag', () => {
13 + const env: NodeJS.ProcessEnv = { MOCK_PROVIDERS: '' }
14 +
15 + expect(applyDemoMode(['node', 'server/index.ts'], env)).toBe(false)
16 + expect(env.MOCK_PROVIDERS).toBe('')
17 + })
18 +})
added server/runtimeMode.ts +5 −0
@@ -0,0 +1,5 @@
1 +export function applyDemoMode(argv: readonly string[], env: NodeJS.ProcessEnv): boolean {
2 + const enabled = argv.includes('--demo')
3 + if (enabled) env.MOCK_PROVIDERS = '1'
4 + return enabled
5 +}
modified spec/PLAN.md +14 −1
@@ -8,6 +8,7 @@
8 8 - LLM: `@anthropic-ai/sdk`. STT: OpenAI REST API via `fetch` inside the provider only (no OpenAI SDK dependency).
9 9 - Validation/schemas: `zod` (shared between API validation and LLM structured output).
10 10 - Tests: `vitest`. Typecheck: `tsc --noEmit`.
11 +- Local demo: `npm run demo` passes an explicit demo flag to the server. The flag is applied after `.env` is read, so it always forces deterministic mock providers.
11 12
12 13 No other runtime dependencies without a BLOCKED.md entry.
13 14
@@ -42,12 +43,21 @@client/
42 43 src/App.tsx session list/create, interview view
43 44 src/api.ts typed fetch wrappers over shared types
44 45 src/audio.ts MediaRecorder push-to-talk
46 + src/share.ts deterministic handoff and recommendation copy
45 47 src/tts.ts speechSynthesis wrapper
46 48 src/labels.ts plain-language copy for coverage category ids
47 49 src/components/ Transcript, CoveragePanel, QuestionCard, GeneratePanel, FolderBrowser,
48 - TranscriptExport
50 + TranscriptExport, OutcomePreview
49 51 ```
50 52
53 +## Product experience direction
54 +
55 +- Audience: a non-technical founder, product person, or stakeholder who has an idea but not a written build brief.
56 +- Home screen job: explain the outcome, remove setup anxiety, and start one interview.
57 +- Visual system: cool cloud canvas, white surfaces, dark navy type, signal indigo actions, coral voice accents, and green completion states. Display type is Sora, body type is DM Sans, and transcript source markers use a system monospace face.
58 +- Layout: a clear two-column first screen pairs the product promise with the create form. A voice-to-requirement preview is the signature element and shows a spoken thought becoming a source-backed requirement.
59 +- Interaction: familiar cards, labeled actions, visible focus, restrained motion, and reduced-motion support. Progress and recording state never rely on color alone.
60 +
51 61 ## Coverage categories (fixed, order matters for tie-breaking)
52 62
53 63 1. `goal` (what and why), 2. `users`, 3. `core-flow`, 4. `data`, 5. `integrations`,
@@ -104,6 +114,7 @@POST /api/sessions/:id/blockers {} -> {questions: string[]} (reads <targetDir
104 114 GET /api/fs/browse ?path=<abs path, default home dir> -> {path, parent, directories: [{name, path}]}
105 115 POST /api/fs/mkdir {path, name} -> {path} (creates one subdirectory)
106 116 GET /api/sessions/:id/export/spec-pack.zip -> application/zip download of the generated spec pack
117 +HEAD /api/sessions/:id/export/spec-pack.zip -> 200 when a generated pack exists, otherwise the same 404 as GET
107 118 GET /api/sessions/:id/export/transcript.md -> text/markdown download of formatTranscript(session.segments)
108 119 ```
109 120
@@ -119,3 +130,5 @@All request/response bodies validated with the shared zod schemas. Errors: `{err
119 130 - Zip export is hand-rolled (`exporter/zip.ts`, STORE method only, no compression) instead of adding a zip dependency: the format is small and well-specified, and it keeps the "no dependency without a BLOCKED.md entry" rule intact for a feature this contained.
120 131 - `/api/fs/browse` and `/api/fs/mkdir` expose the local filesystem over HTTP with no auth check beyond what the rest of the app already assumes (single local user, no auth by design). This is acceptable only because the app is local-only per the Out of scope section; it must never ship if that decision changes.
121 132 - Coverage category labels shown in the UI are looked up from `client/src/labels.ts`, kept separate from the `CategoryId` values in `shared/types.ts` so the wire format/category ids never change, only the display text.
133 +- Product sharing remains user-initiated. The handoff message contains only project-specific guidance. The separate recommendation action contains the public VoiceTask link and never modifies generated files.
134 +- Pack readiness is derived from the existing zip export route with a HEAD request, rather than adding duplicate persisted state to the session.
modified spec/SPEC.md +18 −0
@@ -4,6 +4,8 @@
4 4
5 5 A local tool for anyone who thinks out loud about something they want built, technical or not. The user talks about their idea. An AI interviewer conducts a spoken Socratic interview in plain language: one targeted question at a time, driven by a coverage model of what a good spec needs, with no jargon assumed. When coverage is sufficient (or the user says done), the tool writes a spec pack (SPEC.md, PLAN.md, TASKS.md, VERIFICATION.md, HANDOFF.md) into a target project directory, ready to hand to a sandboxed Claude Code session, and lets the user download a zip of that pack (or the raw transcript) to send to whoever is going to build it. Every requirement in the generated spec carries provenance markers pointing to the exact transcript segments it came from, so the user can always tell "I said this" apart from "the AI invented this".
6 6
7 +The first-run experience explains the outcome before asking for setup details. The interview uses familiar controls, shows progress in words as well as color, and ends with an obvious handoff action for a developer, agency, or coding assistant.
8 +
7 9 Differentiators over existing tools (verified 2026-07): ChatPRD interviews but has no voice; WhisperCode takes voice but does not interview; Spec Kit / Kiro clarify in text only; no tool provides requirement-to-utterance provenance or closes the loop from a coding agent's blockers back into a voice interview.
8 10
9 11 ## Users
@@ -19,11 +21,16 @@P1 (must have):
19 21 - US-4: As a user, I can generate a spec pack into a project folder and every requirement shows where in my own words it came from.
20 22 - US-9: As a user, I can pick or create the project folder by browsing, without knowing what a file path is.
21 23 - US-10: As a user, once the spec pack is ready, I can download it as a single zip file, or copy/download the full transcript, so I can send it to whoever is going to build it.
24 +- US-11: As a first-time user, I can understand what VoiceTask does, how the interview works, what I receive, and that my work stays on my computer before I start.
25 +- US-12: As a user, I can click once to start recording and click again to send, while still having the Space key as a hold-to-talk shortcut.
26 +- US-13: As a user, progress is explained in plain words and does not depend on color alone.
27 +- US-14: As a user, when my build brief is ready, I can download it, copy a ready-to-send handoff message, and understand who to send it to.
22 28
23 29 P2 (should have):
24 30 - US-5: As a user, I hear the interviewer's question spoken aloud so I can keep my eyes off the screen.
25 31 - US-6: As a user, when I contradict something I said earlier, the interviewer points at both statements and asks which one holds.
26 32 - US-7: As a user, I can close the tool and resume the same session later.
33 +- US-15: As a user who found VoiceTask useful, I can copy a short recommendation with the public project link after my own work is complete.
27 34
28 35 P3 (nice to have):
29 36 - US-8: As a user, after a sandboxed Claude Code run leaves questions in BLOCKED.md, I can import them and answer them in a new voice round, and the spec pack is regenerated with the answers.
@@ -51,6 +58,14 @@Spec pack generation:
51 58 Sharing and export:
52 59 - FR-019: Once a spec pack has been generated, the user can download it as a single zip file (SPEC.md, PLAN.md, TASKS.md, VERIFICATION.md, HANDOFF.md, sources.json) without needing filesystem access to the target directory.
53 60 - FR-020: At any point in a session, the user can copy the full transcript to the clipboard or download it as a text file, formatted as a readable back-and-forth (not raw JSON).
61 +- FR-021: The home screen explains the product in plain language before the create form. It shows the three-part flow (talk, answer focused questions, send the build brief), a representative requirement with its source marker, what the downloaded pack is for, and that the session stays on the user's computer.
62 +- FR-022: The create form explains why a project name and folder are needed. Folder browsing is the primary path, while direct path entry remains available.
63 +- FR-023: The microphone button supports click to start and click to send. Holding Space outside a form control remains push-to-talk. Visible text and accessible labels always state whether the microphone is idle, recording, or sending.
64 +- FR-024: Coverage progress states how many topics are ready, in progress, and still to discuss. Every category exposes its status in visible text or an accessible label, not color alone.
65 +- FR-025: The completion screen leads with "Your build brief is ready", explains that the zip can be sent to a developer, agency, or coding assistant, and makes the zip download the primary action. The generated file list is secondary detail.
66 +- FR-026: A completed session checks whether a spec pack already exists when opened, so the download action remains available after a refresh. It also offers a copyable handoff message that names the project and points the recipient to HANDOFF.md.
67 +- FR-027: After a successful result, the UI offers a separate, optional action that copies a short VoiceTask recommendation with `https://github.com/rasmusjy/voicetask`. This recommendation is never inserted into the user's transcript or spec pack.
68 +- FR-028: `npm run demo` starts the client and server with deterministic mock providers even when `.env` selects real providers. It never requires or calls a provider API. Automated verification must work on Windows, macOS, and Linux without relying on an OS-specific `unzip` executable.
54 69
55 70 Persistence and resume:
56 71 - FR-015: Sessions are persisted to disk after every turn. Opening the app lists existing sessions and lets the user resume one, with full transcript and coverage state restored.
@@ -73,6 +88,9 @@Modes and safety:
73 88 - BLOCKED.md missing or empty on import: informative message, nothing changes.
74 89 - Folder browser: a directory the user can't read (permissions) shows an inline error and keeps the browser at the last folder that worked; it never crashes the picker.
75 90 - Zip download requested before the spec pack has been generated: a clear "generate the spec pack first" error, no partial download.
91 +- Microphone click while already recording: stop the recording and send exactly one audio blob. A repeated click while the audio is being submitted does nothing.
92 +- Clipboard access denied: keep the download actions available and show a clear copy failure message.
93 +- A completed session opened after a refresh: detect an existing spec pack and show the ready state without regenerating it.
76 94
77 95 ## Out of scope (v1)
78 96
modified spec/TASKS.md +25 −0
@@ -76,3 +76,28 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
76 76 - `shared/transcript.ts`: `formatTranscript(segments)` plain-text renderer. `server/exporter/zip.ts`: dependency-free ZIP (STORE method) writer. `GET /api/sessions/:id/export/spec-pack.zip` (404 with a clear error if `spec/` doesn't exist yet in the target directory) and `GET /api/sessions/:id/export/transcript.md`. Client: a transcript "Copy" / "Download" control usable at any time, and a "Download spec pack (.zip)" button in `GeneratePanel` that appears once a pack exists.
77 77 - Depends: T9, T10
78 78 - Verify: `npm test` (zip writer roundtrips through Node's own unzip via a temp-file check or a byte-level structural check; export route: 404 before generate, 200 with correct `Content-Type`/`Content-Disposition` after) and `npm run typecheck`. Manual: after generating, the zip downloads and contains all six files; transcript copy/download works mid-interview.
79 +
80 +- [x] T16 Cross-platform offline demo and verification
81 + - Add `npm run demo`, with an explicit server flag that forces mock providers after `.env` is loaded. Replace test calls to the external `unzip` command with dependency-free byte-level ZIP checks so the full suite passes on Windows, macOS, and Linux.
82 + - Depends: T15
83 + - Verify: `npm run typecheck`, `npm test`, and `npm run build` exit 0. Manual: with a real-provider `.env` present, `npm run demo` reports mock providers and opens no provider connection.
84 +
85 +- [ ] T17 Mainstream first-run explanation
86 + - Redesign the home screen around a direct outcome statement, the three-part flow, local privacy, and a representative source-backed requirement. Make folder browsing the obvious path, add plain helper copy to both fields, and keep earlier sessions easy to resume. Implement the visual system and responsive layout in PLAN.md without a UI dependency.
87 + - Depends: T16
88 + - Verify: `npm run typecheck` and `npm run build` exit 0. Static audit: every first-run claim maps to an existing requirement and all interactive controls have visible keyboard focus.
89 +
90 +- [ ] T18 Easier interview controls and progress
91 + - Change the microphone to click once to record and click again to send, while keeping Space as hold-to-talk outside controls. Add explicit idle, recording, sending, and error copy. Replace the coverage count and color-only dots with plain progress language and per-category status labels.
92 + - Depends: T17
93 + - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: mouse click toggles one recording, Space still records only while held, and coverage statuses are understandable without color.
94 +
95 +- [ ] T19 Clear completion and user-driven sharing
96 + - Add a HEAD readiness check for the existing spec-pack export. Redesign the completed state around the primary zip download, recipient guidance, a copyable project handoff message, and a secondary generated-file disclosure. Add a separate optional VoiceTask recommendation action using the canonical repository link. Clipboard failures must be visible.
97 + - Depends: T18
98 + - Verify: `npm test`, `npm run typecheck`, and `npm run build` exit 0. Tests cover the readiness check plus deterministic handoff and recommendation copy. Manual: refresh a completed session with an existing pack and confirm the download remains visible.
99 +
100 +- [ ] T20 Mainstream documentation and final product check
101 + - Rewrite README.md for a first-time evaluator: outcome, audience, what the pack contains, one-command offline demo, real-provider setup, privacy, and the send-to-someone flow. Update VERIFICATION.md to use cross-platform commands and run the complete product gate.
102 + - Depends: T19
103 + - Verify: `npm run check` exits 0 and `git diff --check` reports no errors.