profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Document the full product flow

commit 938b181

13 changed files with +145 and −70

Jump to a changed file
  1. .env.example +3 −3
  2. README.md +61 −27
  3. client/src/App.css +11 −1
  4. client/src/App.tsx +3 −3
  5. client/src/components/FolderBrowser.tsx +30 −11
  6. server/providers/llmAnthropic.ts +2 −2
  7. server/routes/export.test.ts +1 −1
  8. shared/transcript.test.ts +1 −1
  9. shared/transcript.ts +1 −1
  10. spec/PLAN.md +1 −1
  11. spec/SPEC.md +2 −2
  12. spec/TASKS.md +1 −1
  13. spec/VERIFICATION.md +28 −16
modified .env.example +3 −3
@@ -1,6 +1,6 @@
1 -# Easiest: run "npm run setup" and it writes .env for you.
2 -# Manual: copy this file to .env and replace the @insert-...@ placeholders with your real keys.
3 -# With MOCK_PROVIDERS=1 no keys are required at all.
1 +# Try the app without keys by running "npm run demo". It overrides this file for that run.
2 +# For real providers, run "npm run setup" and it writes .env for you.
3 +# To configure manually, copy this file to .env and replace the key placeholders.
4 4
5 5 # 1 = deterministic offline mocks for STT and LLM (used by tests and CI)
6 6 MOCK_PROVIDERS=
modified README.md +61 −27
@@ -1,49 +1,83 @@
1 1 # VoiceTask
2 2
3 -Talk through what you want to build. An AI interviewer asks one targeted question at a time (voice in, voice out), tracks coverage across nine spec categories, and writes a Claude-Code-ready spec pack (SPEC, PLAN, TASKS, VERIFICATION, HANDOFF) into your target project. Every requirement carries `[S<n>]` provenance markers pointing to the exact transcript segment you said it in.
3 +VoiceTask turns a conversation about an idea into a build brief that a developer, agency, or coding assistant can use.
4 4
5 -## Quick start
5 +You speak or type. VoiceTask asks one focused question at a time, tracks which parts of the idea are clear, and creates a zip with the requirements, plan, tasks, checks, and handoff notes. Each requirement points back to the exact part of the interview it came from.
6 +
7 +VoiceTask runs on your computer. It needs no account and saves each interview locally.
8 +
9 +## Try it without API keys
6 10
7 11 ```
8 12 npm install
9 -npm run setup # interactive: offline demo (no keys) or real APIs, writes .env
13 +npm run demo
14 +```
15 +
16 +Open the printed Vite URL in Chrome or Edge.
17 +
18 +Demo mode uses deterministic mock speech and interview providers. It does not need API keys and does not call a provider, even if an existing `.env` selects real providers. It is intended for trying the complete flow safely.
19 +
20 +## Use it with real speech and interview responses
21 +
22 +```
23 +npm run setup
10 24 npm run dev
11 25 ```
12 26
13 -`npm run setup` asks whether you want the offline demo (deterministic mock providers, no API keys) or real APIs, and writes a local `.env` for you. It works the same on Windows, macOS, and Linux.
27 +`npm run setup` asks you to choose real APIs or the offline demo. For real mode, it asks for an Anthropic key for the interview and an OpenAI key for speech-to-text, then writes a local `.env`.
28 +
29 +You can also copy `.env.example` to `.env` and fill in the values yourself.
14 30
15 -To configure by hand instead, copy `.env.example` to `.env` and replace the `@insert-anthropic-api-key@` and `@insert-openai-api-key@` placeholders with your real keys. The server refuses to start while a placeholder is still in place, so a half-finished `.env` fails loudly instead of failing on the first API call.
31 +## How a session works
16 32
17 -Open the printed Vite URL in Chrome or Edge. Hold the mic button (or space) to talk, or type. Say "done" or click the done link to finish, then generate the spec pack.
33 +1. Give the idea a short name and choose the project folder.
34 +2. Click `Start talking`, speak, then click `Send recording`. You can type instead, or hold Space while you talk.
35 +3. Answer one question at a time. The progress panel shows what is ready, in progress, and still to discuss.
36 +4. Select `I'm done, wrap it up` when you have said enough.
37 +5. Create the build brief and download the zip.
38 +6. Copy the handoff message and send both to whoever will build the project.
18 39
19 -## APIs and models
40 +## What the build brief contains
20 41
21 -| Purpose | Provider | Env vars | Default model | Alternatives |
22 -|---|---|---|---|---|
23 -| Interview + spec generation | Anthropic Messages API | `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` | `claude-opus-4-8` | `claude-sonnet-4-6` (cheaper, faster) |
24 -| Speech-to-text | OpenAI audio transcriptions | `OPENAI_API_KEY`, `STT_MODEL` | `gpt-4o-mini-transcribe` | `gpt-4o-transcribe` (higher accuracy), `whisper-1` |
25 -| Text-to-speech | Browser `speechSynthesis` | none | best installed voice | none (free, local) |
42 +| File | Purpose |
43 +|---|---|
44 +| `SPEC.md` | Goals, users, requirements, limits, and source markers |
45 +| `PLAN.md` | Technical approach, structure, and implementation decisions |
46 +| `TASKS.md` | Ordered work items with verification commands |
47 +| `VERIFICATION.md` | Checks that define when the project is finished |
48 +| `HANDOFF.md` | The starting instructions for the person or coding assistant doing the work |
49 +| `sources.json` | The interview text and timestamps referenced by source markers |
26 50
27 -Notes:
51 +A source marker such as `[S7]` means the requirement came from interview segment S7. This makes it clear which decisions came from the user and which still need confirmation.
28 52
29 -- The interviewer uses structured outputs (`output_config.format` with a zod schema) with adaptive thinking, so every turn comes back as a validated `InterviewTurn`. A schema-mismatch is retried once, then surfaced as an error.
30 -- Spec pack files are generated one Anthropic call per file, streamed, with adaptive thinking.
31 -- `MOCK_PROVIDERS=1` swaps both providers for deterministic mocks; all tests run this way and need no network.
32 -- Keys are read from the environment (a local `.env` is loaded if present) and never logged or written to disk.
53 +## Local data and privacy
33 54
34 -## Environment variables
55 +- VoiceTask has no accounts and no hosted sessions.
56 +- Interview sessions are stored under `data/sessions/` on this computer.
57 +- Generated files are written to the chosen project folder under `spec/`.
58 +- The completed brief and transcript can be downloaded from the app.
59 +- In real mode, audio is sent to OpenAI for transcription and interview text is sent to Anthropic for interview and file generation.
60 +- API keys are read from environment variables or the local `.env`. They are not written to sessions, generated files, or logs.
35 61
36 -See `.env.example`. Summary: `MOCK_PROVIDERS`, `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL`, `OPENAI_API_KEY`, `STT_MODEL`, `PORT` (default 3001).
62 +## Providers and models
37 63
38 -## Commands
64 +| Purpose | Provider | Environment variables | Default model |
65 +|---|---|---|---|
66 +| Interview and file generation | Anthropic Messages API | `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` | `claude-opus-4-8` |
67 +| Speech-to-text | OpenAI audio transcriptions | `OPENAI_API_KEY`, `STT_MODEL` | `gpt-4o-mini-transcribe` |
68 +| Spoken questions | Browser `speechSynthesis` | none | Installed browser voice |
69 +
70 +`claude-sonnet-4-6` is a lower-cost Anthropic alternative. `gpt-4o-transcribe` and `whisper-1` are supported speech-to-text alternatives.
71 +
72 +## Development commands
39 73
40 74 ```
41 -npm run dev # server (3001) + client (Vite) with proxy
42 -npm run typecheck # tsc --noEmit over shared, server, client
43 -npm test # vitest, mock providers enforced
44 -npm run build # production build to dist/
75 +npm run demo # start the full app with offline mock providers
76 +npm run dev # start the app with the mode selected in .env
77 +npm run typecheck # check TypeScript across shared, server, and client
78 +npm test # run tests with mock providers and no network
79 +npm run build # build the production client and server
80 +npm run check # run typecheck, tests, and build
45 81 ```
46 82
47 -## Project docs
48 -
49 -Product spec and implementation plan live in `spec/`. Guardrails for coding-agent sessions are in `CLAUDE.md`, blocker protocol in `BLOCKED.md`.
83 +Product requirements and implementation decisions live in `spec/`. Coding-session rules are in `AGENTS.md` and `CLAUDE.md`. The blocker format is in `BLOCKED.md`.
modified client/src/App.css +11 −1
@@ -714,7 +714,8 @@
714 714 border: none;
715 715 background: none;
716 716 color: var(--ink-soft);
717 - font-size: 19px;
717 + font-size: 11px;
718 + font-weight: 700;
718 719 line-height: 1;
719 720 padding: 9px;
720 721 cursor: pointer;
@@ -1968,6 +1969,15 @@
1968 1969 }
1969 1970
1970 1971 @media (prefers-reduced-motion: reduce) {
1972 + *,
1973 + *::before,
1974 + *::after {
1975 + scroll-behavior: auto !important;
1976 + transition-duration: 0.01ms !important;
1977 + animation-duration: 0.01ms !important;
1978 + animation-iteration-count: 1 !important;
1979 + }
1980 +
1971 1981 .orb,
1972 1982 .orb-eye,
1973 1983 .orb-ripple,
modified client/src/App.tsx +3 −3
@@ -242,7 +242,7 @@function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
242 242 aria-label={`Delete ${session.name}`}
243 243 onClick={() => void handleDelete(session.id)}
244 244 >
245 - <span aria-hidden="true">&times;</span>
245 + Delete
246 246 </button>
247 247 </li>
248 248 ))}
@@ -353,7 +353,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
353 353 <div className="screen interview-screen">
354 354 <header className="topbar">
355 355 <button type="button" className="back-link" onClick={onBack}>
356 - &larr; Sessions
356 + All interviews
357 357 </button>
358 358 </header>
359 359 {error ? <p className="error">{error}</p> : <p className="muted">Loading&hellip;</p>}
@@ -376,7 +376,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
376 376 <div className="screen interview-screen">
377 377 <header className="topbar">
378 378 <button type="button" className="back-link" onClick={onBack}>
379 - &larr; Sessions
379 + All interviews
380 380 </button>
381 381 <span className="topbar-title">{session.name}</span>
382 382 <div className="topbar-actions">
modified client/src/components/FolderBrowser.tsx +30 −11
@@ -22,6 +22,14 @@export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) {
22 22 load(undefined)
23 23 }, [])
24 24
25 + useEffect(() => {
26 + function handleKeyDown(event: KeyboardEvent) {
27 + if (event.key === 'Escape') onClose()
28 + }
29 + window.addEventListener('keydown', handleKeyDown)
30 + return () => window.removeEventListener('keydown', handleKeyDown)
31 + }, [onClose])
32 +
25 33 async function handleCreateFolder() {
26 34 if (!listing || !newFolderName.trim()) return
27 35 setCreating(true)
@@ -39,24 +47,30 @@export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) {
39 47
40 48 return (
41 49 <div className="folder-browser-overlay" onClick={onClose}>
42 - <div className="folder-browser" onClick={(e) => e.stopPropagation()}>
43 - <h2>Choose a folder</h2>
44 - <p className="folder-browser-path">{listing?.path ?? 'Loading…'}</p>
50 + <div
51 + className="folder-browser"
52 + role="dialog"
53 + aria-modal="true"
54 + aria-labelledby="folder-browser-title"
55 + onClick={(event) => event.stopPropagation()}
56 + >
57 + <h2 id="folder-browser-title">Choose a folder</h2>
58 + <p className="folder-browser-path">{listing?.path ?? 'Loading...'}</p>
45 59
46 60 <div className="folder-browser-list">
47 61 {listing?.parent && (
48 62 <button type="button" className="folder-browser-entry" onClick={() => load(listing.parent!)}>
49 - ⬆ Up one level
63 + Up one level
50 64 </button>
51 65 )}
52 - {listing?.directories.map((dir) => (
66 + {listing?.directories.map((directory) => (
53 67 <button
54 68 type="button"
55 - key={dir.path}
69 + key={directory.path}
56 70 className="folder-browser-entry"
57 - onClick={() => load(dir.path)}
71 + onClick={() => load(directory.path)}
58 72 >
59 - 📁 {dir.name}
73 + {directory.name}
60 74 </button>
61 75 ))}
62 76 {listing && listing.directories.length === 0 && !listing.parent && (
@@ -70,20 +84,25 @@export function FolderBrowser({ onSelect, onClose }: FolderBrowserProps) {
70 84 <div className="folder-browser-new">
71 85 <input
72 86 value={newFolderName}
73 - onChange={(e) => setNewFolderName(e.target.value)}
87 + onChange={(event) => setNewFolderName(event.target.value)}
74 88 placeholder="New folder name"
89 + aria-label="New folder name"
75 90 disabled={creating}
76 91 />
77 92 <button
78 93 type="button"
79 94 onClick={() => void handleCreateFolder()}
80 95 disabled={creating || !newFolderName.trim()}
81 96 >
82 - Create
97 + {creating ? 'Creating...' : 'Create folder'}
83 98 </button>
84 99 </div>
85 100
86 - {error && <p className="error">{error}</p>}
101 + {error && (
102 + <p className="error" role="alert">
103 + {error}
104 + </p>
105 + )}
87 106
88 107 <div className="folder-browser-actions">
89 108 <button type="button" onClick={onClose}>
modified server/providers/llmAnthropic.ts +2 −2
@@ -12,13 +12,13 @@export function buildInterviewSystemPrompt(context: InterviewContext): string {
12 12 const weakest = weakestCategory(context.coverage)
13 13 return [
14 14 'You are a Socratic spec interviewer talking through a project idea with someone who may not be a developer or ever have written a spec before. Assume no technical background unless they use technical terms themselves.',
15 - 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec. Use plain, everyday language — short sentences, no jargon, no acronyms, no words like "requirements", "schema", "edge case", or "non-goal" in the question itself. Ask about the underlying idea instead (e.g. ask what should happen when something goes wrong, not "what are the edge cases").',
15 + 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec. Use plain, everyday language, short sentences, no jargon, no acronyms, no words like "requirements", "schema", "edge case", or "non-goal" in the question itself. Ask about the underlying idea instead (e.g. ask what should happen when something goes wrong, not "what are the edge cases").',
16 16 `Coverage categories, in tie-break priority order: ${CATEGORY_IDS.join(', ')}.`,
17 17 weakest
18 18 ? `The weakest category right now is "${weakest}". Your next question must target it, unless a contradiction takes priority.`
19 19 : 'Every category is already "clear".',
20 20 'If the latest user answer conflicts with an earlier statement, do not ask a coverage question: set "contradiction" to describe both statements in plain language, referencing their segment ids, instead.',
21 - 'The interview ends only when the user answer is exactly "done" (case-insensitive, trimmed) — never for any other reason, even if "done" appears inside a longer answer.',
21 + 'The interview ends only when the user answer is exactly "done" (case-insensitive, trimmed), never for any other reason, even if "done" appears inside a longer answer.',
22 22 'Reply with only the InterviewTurn structure described by the output schema.',
23 23 ].join('\n')
24 24 }
modified server/routes/export.test.ts +1 −1
@@ -83,7 +83,7 @@describe('export routes', () => {
83 83 expect(res.statusCode).toBe(200)
84 84 expect(res.headers['content-type']).toContain('text/markdown')
85 85 expect(res.headers['content-disposition']).toContain('family-recipes-transcript.md')
86 - expect(res.body).toContain('# Family Recipes — Interview Transcript')
86 + expect(res.body).toContain('# Family Recipes: Interview Transcript')
87 87 expect(res.body).toContain('**You:** a recipe app for my family')
88 88 })
89 89
modified shared/transcript.test.ts +1 −1
@@ -13,7 +13,7 @@describe('formatTranscript', () => {
13 13 segment('S2', 'interviewer', 'who will use it?'),
14 14 ]
15 15 const text = formatTranscript(segments, 'Family Recipes')
16 - expect(text).toContain('# Family Recipes — Interview Transcript')
16 + expect(text).toContain('# Family Recipes: Interview Transcript')
17 17 expect(text).toContain('**You:** a recipe app for my family')
18 18 expect(text).toContain('**Interviewer:** who will use it?')
19 19 expect(text.indexOf('You:')).toBeLessThan(text.indexOf('Interviewer:'))
modified shared/transcript.ts +1 −1
@@ -1,7 +1,7 @@
1 1 import type { Segment } from './types'
2 2
3 3 export function formatTranscript(segments: Segment[], projectName?: string): string {
4 - const lines: string[] = [projectName ? `# ${projectName} — Interview Transcript` : '# Interview Transcript', '']
4 + const lines: string[] = [projectName ? `# ${projectName}: Interview Transcript` : '# Interview Transcript', '']
5 5
6 6 for (const segment of segments) {
7 7 const speakerLabel = segment.speaker === 'user' ? 'You' : 'Interviewer'
modified spec/PLAN.md +1 −1
@@ -16,7 +16,7 @@No other runtime dependencies without a BLOCKED.md entry.
16 16
17 17 ```
18 18 shared/ types + zod schemas (Session, Segment, Coverage, API payloads)
19 - transcript.ts: formatTranscript(segments) — plain-text rendering shared by
19 + transcript.ts: formatTranscript(segments), renders plain text shared by
20 20 the transcript download route and the client's copy-to-clipboard button
21 21 server/
22 22 index.ts Fastify bootstrap, serves client build in prod
modified spec/SPEC.md +2 −2
@@ -10,7 +10,7 @@Differentiators over existing tools (verified 2026-07): ChatPRD interviews but h
10 10
11 11 ## Users
12 12
13 -Single user on their own machine. No auth, no multi-tenancy, no accounts. The primary user is not assumed to be a developer — they may be a non-technical founder, product person, or stakeholder describing an idea that someone else (a developer, an agency, a sandboxed coding agent) will build. Anything shown to the user — coverage category names, interviewer tone, empty states — is written in plain language, not developer jargon. A technical user who wants direct control (typing an exact path, editing files by hand afterward) is never blocked from doing so.
13 +Single user on their own machine. No auth, no multi-tenancy, no accounts. The primary user is not assumed to be a developer. They may be a non-technical founder, product person, or stakeholder describing an idea that someone else (a developer, an agency, a sandboxed coding agent) will build. Anything shown to the user, including coverage category names, interviewer tone, and empty states, is written in plain language, not developer jargon. A technical user who wants direct control (typing an exact path, editing files by hand afterward) is never blocked from doing so.
14 14
15 15 ## User stories
16 16
@@ -94,7 +94,7 @@Modes and safety:
94 94
95 95 ## Out of scope (v1)
96 96
97 -- Multi-user support, auth, or any cloud deployment. Local only. Sharing means the user downloads a file and sends it themselves (email, Slack, upload) — not a hosted link to a session or spec pack.
97 +- Multi-user support, auth, or any cloud deployment. Local only. Sharing means the user downloads a file and sends it themselves (email, Slack, upload), not a hosted link to a session or spec pack.
98 98 - Real-time streaming conversation (barge-in, VAD). Push-to-talk only.
99 99 - Paid TTS voices. Browser `speechSynthesis` only.
100 100 - Running or supervising Claude Code from inside the app. The app only writes files and prints the handoff command.
modified spec/TASKS.md +1 −1
@@ -97,7 +97,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
97 97 - Depends: T18
98 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 99
100 -- [ ] T20 Mainstream documentation and final product check
100 +- [x] T20 Mainstream documentation and final product check
101 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 102 - Depends: T19
103 103 - Verify: `npm run check` exits 0 and `git diff --check` reports no errors.
modified spec/VERIFICATION.md +28 −16
@@ -1,30 +1,42 @@
1 1 # VERIFICATION: VoiceTask
2 2
3 -## Commands
3 +## Automated gate
4 4
5 -All of these must exit 0 for the project to be considered healthy:
5 +From the repository root:
6 6
7 7 ```
8 -npm run typecheck # tsc --noEmit over shared, server, client
9 -npm test # vitest run, MOCK_PROVIDERS=1 enforced by vitest config
10 -npm run build # vite build + server tsc build
8 +npm run check
11 9 ```
12 10
13 -Tests must not require network access or API keys. If a test needs a provider, it uses the mocks from `server/providers/`.
11 +This must complete typechecking, all tests, and the production build with exit code 0. Tests use mock providers, require no API keys or network, and must work on Windows, macOS, and Linux without external archive tools.
14 12
15 -## Definition of done (whole project)
13 +## Definition of done
16 14
17 -1. All checkboxes in `spec/TASKS.md` checked.
18 -2. The three commands above exit 0 on a clean `npm install`.
19 -3. The T12 smoke test passes: full session lifecycle through the HTTP API with mocks, generated pack valid per FR-011..FR-014.
20 -4. `BLOCKED.md` contains either no entries or only entries genuinely requiring a product decision.
15 +1. Every checkbox in `spec/TASKS.md` is checked.
16 +2. `npm run check` exits 0 after a clean `npm install`.
17 +3. The T12 smoke test covers a full session through generation.
18 +4. Export tests prove that the zip contains all six required files and that HEAD reports readiness.
19 +5. `BLOCKED.md` has no unanswered entry that prevents a required feature.
20 +6. `git diff --check` reports no whitespace errors.
21 21
22 -## Manual smoke check (human, after the sandboxed run)
22 +## Human smoke check
23 23
24 -1. `MOCK_PROVIDERS=1 npm run dev`, open the printed URL in Chrome.
25 -2. Create a session with a temp directory as target. Type three answers, watch coverage advance, click Done, generate, confirm files appear in `<target>/spec/`.
26 -3. With real keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY` set, `MOCK_PROVIDERS` unset): hold the record button, say a sentence, confirm it transcribes and a sensible question comes back.
24 +1. Run `npm run demo` and open the printed URL in current Chrome or Edge.
25 +2. Confirm the home screen explains the three-part flow, local storage, and the source-backed result before the form.
26 +3. Create a session with a temporary target folder.
27 +4. Click `Start talking`, grant microphone access, speak, then click `Send recording`. Confirm one answer is added.
28 +5. Hold Space while speaking and release it. Confirm one answer is added and no second recording starts.
29 +6. Type an answer. Confirm the topic counts and each topic status update in plain text.
30 +7. Finish early, accept the open-topic confirmation, and create the build brief.
31 +8. Confirm the primary zip download, handoff copy, file disclosure, and optional recommendation action appear.
32 +9. Refresh the completed session and confirm the zip download is still available.
33 +10. Open the zip and confirm `SPEC.md`, `PLAN.md`, `TASKS.md`, `VERIFICATION.md`, `HANDOFF.md`, and `sources.json` are present.
34 +11. Check the home, interview, and completion screens at a narrow and wide browser width. Confirm focus is visible when using Tab and motion stops when reduced motion is enabled.
35 +
36 +## Optional real-provider check
37 +
38 +With real keys selected by `npm run setup`, run `npm run dev`. Record one sentence and confirm it is transcribed, then confirm the next question is relevant. This check can call paid provider APIs and is not part of automated verification.
27 39
28 40 ## Per-task verification
29 41
30 -Each task in `spec/TASKS.md` carries its own Verify line; those are the gate for checking the task off. This file is the gate for finishing the session.
42 +Each task in `spec/TASKS.md` has its own verification line. Run it before checking that task off.