profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

Merge pull request #1 from rasmusjy/spec-pack-implementation

spec-pack-implementation
commit b046747

66 changed files with +7495 and −12

Jump to a changed file
  1. .gitignore +10 −0
  2. client/.gitignore +24 −0
  3. client/index.html +12 −0
  4. client/src/App.css +393 −0
  5. client/src/App.tsx +236 −0
  6. client/src/api.ts +68 −0
  7. client/src/audio.ts +40 −0
  8. client/src/components/BlockerImport.tsx +46 −0
  9. client/src/components/CoveragePanel.tsx +30 −0
  10. client/src/components/GeneratePanel.tsx +112 −0
  11. client/src/components/PushToTalkButton.tsx +83 −0
  12. client/src/components/QuestionCard.tsx +14 −0
  13. client/src/components/Transcript.tsx +22 −0
  14. client/src/index.css +50 −0
  15. client/src/main.tsx +10 −0
  16. client/src/tts.ts +9 −0
  17. client/tsconfig.app.json +29 −0
  18. client/tsconfig.json +4 −0
  19. client/vite.config.ts +26 −0
  20. package-lock.json +4026 −0
  21. package.json +37 −0
  22. server/app.ts +36 −0
  23. server/blockers/parse.test.ts +63 −0
  24. server/blockers/parse.ts +50 −0
  25. server/engine/coverage.test.ts +37 −0
  26. server/engine/coverage.ts +18 −0
  27. server/engine/interview.test.ts +91 −0
  28. server/engine/interview.ts +28 −0
  29. server/engine/summary.test.ts +31 −0
  30. server/engine/summary.ts +19 −0
  31. server/engine/turn.ts +49 −0
  32. server/generator/generate.test.ts +93 −0
  33. server/generator/generate.ts +92 −0
  34. server/generator/prompts.ts +61 −0
  35. server/generator/provenance.test.ts +31 −0
  36. server/generator/provenance.ts +33 −0
  37. server/index.ts +13 −0
  38. server/placeholder.test.ts +5 −0
  39. server/providers/factory.test.ts +20 −0
  40. server/providers/factory.ts +19 −0
  41. server/providers/llmAnthropic.test.ts +53 −0
  42. server/providers/llmAnthropic.ts +76 −0
  43. server/providers/llmMock.test.ts +54 −0
  44. server/providers/llmMock.ts +91 −0
  45. server/providers/sttMock.test.ts +17 −0
  46. server/providers/sttMock.ts +21 −0
  47. server/providers/sttOpenai.ts +33 −0
  48. server/providers/types.ts +31 −0
  49. server/routes/audio.test.ts +106 −0
  50. server/routes/audio.ts +44 −0
  51. server/routes/blockers.test.ts +118 −0
  52. server/routes/blockers.ts +42 −0
  53. server/routes/generate.test.ts +85 −0
  54. server/routes/generate.ts +34 −0
  55. server/routes/sessions.test.ts +110 −0
  56. server/routes/sessions.ts +50 −0
  57. server/smoke.test.ts +81 −0
  58. server/store/sessionStore.test.ts +82 −0
  59. server/store/sessionStore.ts +122 −0
  60. server/tsconfig.json +14 −0
  61. shared/index.ts +1 −0
  62. shared/tsconfig.json +10 −0
  63. shared/types.ts +114 −0
  64. spec/TASKS.md +12 −12
  65. tsconfig.base.json +14 −0
  66. vitest.config.ts +10 −0
added .gitignore +10 −0
@@ -0,0 +1,10 @@
1 +node_modules
2 +dist
3 +dist-ssr
4 +*.local
5 +data/sessions
6 +.vscode/*
7 +!.vscode/extensions.json
8 +.idea
9 +.DS_Store
10 +*.log
added client/.gitignore +24 −0
@@ -0,0 +1,24 @@
1 +# Logs
2 +logs
3 +*.log
4 +npm-debug.log*
5 +yarn-debug.log*
6 +yarn-error.log*
7 +pnpm-debug.log*
8 +lerna-debug.log*
9 +
10 +node_modules
11 +dist
12 +dist-ssr
13 +*.local
14 +
15 +# Editor directories and files
16 +.vscode/*
17 +!.vscode/extensions.json
18 +.idea
19 +.DS_Store
20 +*.suo
21 +*.ntvs*
22 +*.njsproj
23 +*.sln
24 +*.sw?
added client/index.html +12 −0
@@ -0,0 +1,12 @@
1 +<!doctype html>
2 +<html lang="en">
3 + <head>
4 + <meta charset="UTF-8" />
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6 + <title>VoiceTask</title>
7 + </head>
8 + <body>
9 + <div id="root"></div>
10 + <script type="module" src="/src/main.tsx"></script>
11 + </body>
12 +</html>
added client/src/App.css +393 −0
@@ -0,0 +1,393 @@
1 +.screen {
2 + max-width: 960px;
3 + margin: 0 auto;
4 + padding: 32px 20px 64px;
5 +}
6 +
7 +.subtitle {
8 + color: var(--text);
9 + margin-bottom: 32px;
10 +}
11 +
12 +.muted {
13 + color: var(--text);
14 + opacity: 0.7;
15 +}
16 +
17 +.error {
18 + color: var(--danger);
19 + margin-top: 12px;
20 +}
21 +
22 +/* Session list screen */
23 +
24 +.create-session-form {
25 + display: flex;
26 + flex-direction: column;
27 + gap: 12px;
28 + padding: 20px;
29 + border: 1px solid var(--border);
30 + border-radius: 10px;
31 + margin-bottom: 32px;
32 + background: var(--bg-alt);
33 +}
34 +
35 +.create-session-form label {
36 + display: flex;
37 + flex-direction: column;
38 + gap: 4px;
39 + font-size: 14px;
40 + color: var(--text);
41 +}
42 +
43 +.create-session-form input {
44 + padding: 8px 10px;
45 + border: 1px solid var(--border);
46 + border-radius: 6px;
47 + background: var(--bg);
48 + color: var(--text-h);
49 +}
50 +
51 +.create-session-form button {
52 + align-self: flex-start;
53 + padding: 8px 16px;
54 + border: none;
55 + border-radius: 6px;
56 + background: var(--accent);
57 + color: white;
58 + cursor: pointer;
59 +}
60 +
61 +.create-session-form button:disabled {
62 + opacity: 0.6;
63 + cursor: default;
64 +}
65 +
66 +.session-list ul {
67 + list-style: none;
68 + padding: 0;
69 + margin: 12px 0 0;
70 + display: flex;
71 + flex-direction: column;
72 + gap: 8px;
73 +}
74 +
75 +.session-item {
76 + width: 100%;
77 + display: flex;
78 + justify-content: space-between;
79 + align-items: center;
80 + padding: 12px 16px;
81 + border: 1px solid var(--border);
82 + border-radius: 8px;
83 + background: var(--bg);
84 + cursor: pointer;
85 + color: var(--text-h);
86 + text-align: left;
87 +}
88 +
89 +.session-item:hover {
90 + border-color: var(--accent);
91 +}
92 +
93 +.session-status {
94 + font-size: 12px;
95 + padding: 2px 8px;
96 + border-radius: 999px;
97 + background: var(--bg-alt);
98 + text-transform: uppercase;
99 + letter-spacing: 0.04em;
100 +}
101 +
102 +.session-status.status-done {
103 + color: var(--accent);
104 +}
105 +
106 +/* Interview screen */
107 +
108 +.interview-header {
109 + display: flex;
110 + justify-content: space-between;
111 + align-items: center;
112 + gap: 16px;
113 +}
114 +
115 +.tts-toggle {
116 + display: flex;
117 + align-items: center;
118 + gap: 6px;
119 + font-size: 14px;
120 + color: var(--text);
121 + white-space: nowrap;
122 +}
123 +
124 +.push-to-talk {
125 + margin-bottom: 12px;
126 +}
127 +
128 +.record-button {
129 + width: 100%;
130 + padding: 14px;
131 + border-radius: 8px;
132 + border: 1px solid var(--border);
133 + background: var(--bg-alt);
134 + color: var(--text-h);
135 + cursor: pointer;
136 + font-size: 15px;
137 +}
138 +
139 +.record-button.recording {
140 + background: var(--danger);
141 + color: white;
142 + border-color: var(--danger);
143 +}
144 +
145 +.record-button:disabled {
146 + opacity: 0.6;
147 + cursor: default;
148 +}
149 +
150 +.back-link {
151 + background: none;
152 + border: none;
153 + color: var(--accent);
154 + cursor: pointer;
155 + padding: 0;
156 + margin-bottom: 16px;
157 + font-size: 14px;
158 +}
159 +
160 +.interview-layout {
161 + display: grid;
162 + grid-template-columns: 1fr 220px;
163 + gap: 24px;
164 + align-items: start;
165 +}
166 +
167 +@media (max-width: 720px) {
168 + .interview-layout {
169 + grid-template-columns: 1fr;
170 + }
171 +}
172 +
173 +.question-card {
174 + padding: 16px 20px;
175 + border-radius: 10px;
176 + background: var(--accent-bg);
177 + margin-bottom: 20px;
178 +}
179 +
180 +.question-label {
181 + display: block;
182 + font-size: 12px;
183 + text-transform: uppercase;
184 + letter-spacing: 0.06em;
185 + color: var(--accent);
186 + margin-bottom: 6px;
187 +}
188 +
189 +.question-text {
190 + font-size: 18px;
191 + color: var(--text-h);
192 +}
193 +
194 +.transcript {
195 + display: flex;
196 + flex-direction: column;
197 + gap: 12px;
198 + margin-bottom: 20px;
199 +}
200 +
201 +.transcript-empty {
202 + color: var(--text);
203 + opacity: 0.7;
204 + margin-bottom: 20px;
205 +}
206 +
207 +.segment {
208 + padding: 10px 14px;
209 + border-radius: 8px;
210 + border: 1px solid var(--border);
211 +}
212 +
213 +.segment-user {
214 + background: var(--bg);
215 +}
216 +
217 +.segment-interviewer {
218 + background: var(--bg-alt);
219 +}
220 +
221 +.segment-speaker {
222 + display: block;
223 + font-size: 12px;
224 + color: var(--text);
225 + opacity: 0.7;
226 + margin-bottom: 4px;
227 +}
228 +
229 +.answer-form {
230 + display: flex;
231 + gap: 8px;
232 +}
233 +
234 +.answer-form input {
235 + flex: 1;
236 + padding: 10px 12px;
237 + border: 1px solid var(--border);
238 + border-radius: 6px;
239 + background: var(--bg);
240 + color: var(--text-h);
241 +}
242 +
243 +.answer-form button {
244 + padding: 10px 16px;
245 + border: none;
246 + border-radius: 6px;
247 + cursor: pointer;
248 +}
249 +
250 +.answer-form button[type='submit'] {
251 + background: var(--accent);
252 + color: white;
253 +}
254 +
255 +.answer-form .done-button {
256 + background: var(--bg-alt);
257 + color: var(--text-h);
258 + border: 1px solid var(--border);
259 +}
260 +
261 +.answer-form button:disabled {
262 + opacity: 0.6;
263 + cursor: default;
264 +}
265 +
266 +.generate-panel {
267 + margin-top: 20px;
268 + padding: 16px 20px;
269 + border: 1px solid var(--border);
270 + border-radius: 10px;
271 +}
272 +
273 +.generate-panel h2 {
274 + font-size: 16px;
275 + margin-bottom: 12px;
276 +}
277 +
278 +.generate-panel button {
279 + padding: 8px 14px;
280 + border-radius: 6px;
281 + border: none;
282 + background: var(--accent);
283 + color: white;
284 + cursor: pointer;
285 + margin-right: 8px;
286 +}
287 +
288 +.generate-panel button:disabled {
289 + opacity: 0.6;
290 + cursor: default;
291 +}
292 +
293 +.generate-confirm,
294 +.generate-overwrite {
295 + margin-bottom: 12px;
296 +}
297 +
298 +.generate-confirm button:last-child,
299 +.generate-overwrite button {
300 + background: var(--bg-alt);
301 + color: var(--text-h);
302 + border: 1px solid var(--border);
303 +}
304 +
305 +.generate-result ul {
306 + margin: 8px 0 0;
307 + padding-left: 20px;
308 +}
309 +
310 +.generate-warnings {
311 + margin-top: 8px;
312 + color: var(--text);
313 + opacity: 0.85;
314 +}
315 +
316 +.blocker-import {
317 + margin-top: 20px;
318 + padding: 16px 20px;
319 + border: 1px solid var(--border);
320 + border-radius: 10px;
321 +}
322 +
323 +.blocker-import h2 {
324 + font-size: 16px;
325 + margin-bottom: 8px;
326 +}
327 +
328 +.blocker-import button {
329 + padding: 8px 14px;
330 + border-radius: 6px;
331 + border: 1px solid var(--border);
332 + background: var(--bg-alt);
333 + color: var(--text-h);
334 + cursor: pointer;
335 + margin-top: 4px;
336 +}
337 +
338 +.blocker-import button:disabled {
339 + opacity: 0.6;
340 + cursor: default;
341 +}
342 +
343 +.interview-done {
344 + padding: 12px 16px;
345 + border-radius: 8px;
346 + background: var(--accent-bg);
347 + color: var(--text-h);
348 +}
349 +
350 +.interview-sidebar h2 {
351 + font-size: 14px;
352 + text-transform: uppercase;
353 + letter-spacing: 0.06em;
354 + color: var(--text);
355 + opacity: 0.8;
356 +}
357 +
358 +.coverage-panel {
359 + list-style: none;
360 + padding: 0;
361 + margin: 0;
362 + display: flex;
363 + flex-direction: column;
364 + gap: 8px;
365 +}
366 +
367 +.coverage-item {
368 + display: flex;
369 + align-items: center;
370 + gap: 8px;
371 + font-size: 14px;
372 + color: var(--text-h);
373 +}
374 +
375 +.coverage-dot {
376 + width: 10px;
377 + height: 10px;
378 + border-radius: 50%;
379 + background: var(--border);
380 + flex-shrink: 0;
381 +}
382 +
383 +.coverage-missing .coverage-dot {
384 + background: var(--border);
385 +}
386 +
387 +.coverage-partial .coverage-dot {
388 + background: #e0a83f;
389 +}
390 +
391 +.coverage-clear .coverage-dot {
392 + background: #2fa869;
393 +}
added client/src/App.tsx +236 −0
@@ -0,0 +1,236 @@
1 +import { useEffect, useRef, useState } from 'react'
2 +import type { Session, SessionSummary } from 'shared/types'
3 +import * as api from './api'
4 +import './App.css'
5 +import { BlockerImport } from './components/BlockerImport'
6 +import { CoveragePanel } from './components/CoveragePanel'
7 +import { GeneratePanel } from './components/GeneratePanel'
8 +import { PushToTalkButton } from './components/PushToTalkButton'
9 +import { QuestionCard } from './components/QuestionCard'
10 +import { Transcript } from './components/Transcript'
11 +import { speak } from './tts'
12 +
13 +function latestQuestion(session: Session): string | null {
14 + for (let i = session.segments.length - 1; i >= 0; i--) {
15 + const segment = session.segments[i]
16 + if (segment.speaker === 'interviewer') return segment.text
17 + }
18 + return null
19 +}
20 +
21 +function SessionListScreen({ onOpen }: { onOpen: (id: string) => void }) {
22 + const [sessions, setSessions] = useState<SessionSummary[]>([])
23 + const [name, setName] = useState('')
24 + const [targetDir, setTargetDir] = useState('')
25 + const [error, setError] = useState<string | null>(null)
26 + const [creating, setCreating] = useState(false)
27 +
28 + useEffect(() => {
29 + api.listSessions().then(setSessions).catch((err: Error) => setError(err.message))
30 + }, [])
31 +
32 + async function handleCreate(event: React.FormEvent) {
33 + event.preventDefault()
34 + setError(null)
35 + setCreating(true)
36 + try {
37 + const session = await api.createSession({ name, targetDir })
38 + onOpen(session.id)
39 + } catch (err) {
40 + setError(err instanceof Error ? err.message : String(err))
41 + } finally {
42 + setCreating(false)
43 + }
44 + }
45 +
46 + return (
47 + <div className="screen session-list-screen">
48 + <h1>VoiceTask</h1>
49 + <p className="subtitle">Talk through what you want to build. VoiceTask interviews you and writes the spec.</p>
50 +
51 + <form className="create-session-form" onSubmit={handleCreate}>
52 + <h2>Start a new interview</h2>
53 + <label>
54 + Project name
55 + <input value={name} onChange={(e) => setName(e.target.value)} required />
56 + </label>
57 + <label>
58 + Target directory
59 + <input
60 + value={targetDir}
61 + onChange={(e) => setTargetDir(e.target.value)}
62 + placeholder="/path/to/project"
63 + required
64 + />
65 + </label>
66 + <button type="submit" disabled={creating}>
67 + {creating ? 'Starting…' : 'Start interview'}
68 + </button>
69 + </form>
70 +
71 + {error && <p className="error">{error}</p>}
72 +
73 + <div className="session-list">
74 + <h2>Existing sessions</h2>
75 + {sessions.length === 0 && <p className="muted">No sessions yet.</p>}
76 + <ul>
77 + {sessions.map((session) => (
78 + <li key={session.id}>
79 + <button type="button" className="session-item" onClick={() => onOpen(session.id)}>
80 + <span className="session-name">{session.name}</span>
81 + <span className={`session-status status-${session.status}`}>{session.status}</span>
82 + </button>
83 + </li>
84 + ))}
85 + </ul>
86 + </div>
87 + </div>
88 + )
89 +}
90 +
91 +function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: () => void }) {
92 + const [session, setSession] = useState<Session | null>(null)
93 + const [answerText, setAnswerText] = useState('')
94 + const [error, setError] = useState<string | null>(null)
95 + const [submitting, setSubmitting] = useState(false)
96 + const [ttsEnabled, setTtsEnabled] = useState(false)
97 + const lastSpokenQuestion = useRef<string | null>(null)
98 +
99 + function refresh() {
100 + return api.getSession(sessionId).then(setSession)
101 + }
102 +
103 + useEffect(() => {
104 + api.getSession(sessionId).then(setSession).catch((err: Error) => setError(err.message))
105 + }, [sessionId])
106 +
107 + useEffect(() => {
108 + if (!ttsEnabled || !session) return
109 + const question = latestQuestion(session)
110 + if (question && question !== lastSpokenQuestion.current) {
111 + lastSpokenQuestion.current = question
112 + speak(question)
113 + }
114 + }, [ttsEnabled, session])
115 +
116 + async function submit(text: string) {
117 + if (!text.trim()) return
118 + setError(null)
119 + setSubmitting(true)
120 + try {
121 + await api.submitAnswer(sessionId, { text })
122 + setAnswerText('')
123 + await refresh()
124 + } catch (err) {
125 + setError(err instanceof Error ? err.message : String(err))
126 + } finally {
127 + setSubmitting(false)
128 + }
129 + }
130 +
131 + async function submitAudio(blob: Blob) {
132 + setError(null)
133 + setSubmitting(true)
134 + try {
135 + await api.uploadAudio(sessionId, blob)
136 + await refresh()
137 + } catch (err) {
138 + setError(err instanceof Error ? err.message : String(err))
139 + } finally {
140 + setSubmitting(false)
141 + }
142 + }
143 +
144 + async function handleSubmit(event: React.FormEvent) {
145 + event.preventDefault()
146 + await submit(answerText)
147 + }
148 +
149 + if (!session) {
150 + return (
151 + <div className="screen interview-screen">
152 + <button type="button" className="back-link" onClick={onBack}>
153 + ← Back to sessions
154 + </button>
155 + {error ? <p className="error">{error}</p> : <p>Loading…</p>}
156 + </div>
157 + )
158 + }
159 +
160 + return (
161 + <div className="screen interview-screen">
162 + <button type="button" className="back-link" onClick={onBack}>
163 + ← Back to sessions
164 + </button>
165 + <div className="interview-header">
166 + <h1>{session.name}</h1>
167 + <label className="tts-toggle">
168 + <input
169 + type="checkbox"
170 + checked={ttsEnabled}
171 + onChange={(e) => setTtsEnabled(e.target.checked)}
172 + />
173 + Read questions aloud
174 + </label>
175 + </div>
176 +
177 + <div className="interview-layout">
178 + <div className="interview-main">
179 + <QuestionCard question={latestQuestion(session)} />
180 + <Transcript segments={session.segments} />
181 +
182 + {session.status === 'interviewing' ? (
183 + <>
184 + <PushToTalkButton disabled={submitting} onRecorded={(blob) => void submitAudio(blob)} />
185 + <form className="answer-form" onSubmit={handleSubmit}>
186 + <input
187 + value={answerText}
188 + onChange={(e) => setAnswerText(e.target.value)}
189 + placeholder="Type your answer…"
190 + disabled={submitting}
191 + autoFocus
192 + />
193 + <button type="submit" disabled={submitting || !answerText.trim()}>
194 + Send
195 + </button>
196 + <button
197 + type="button"
198 + className="done-button"
199 + disabled={submitting}
200 + onClick={() => submit('done')}
201 + >
202 + Done
203 + </button>
204 + </form>
205 + </>
206 + ) : (
207 + <>
208 + <p className="interview-done">Interview complete.</p>
209 + <GeneratePanel sessionId={session.id} coverage={session.coverage} />
210 + <BlockerImport sessionId={session.id} onImported={refresh} />
211 + </>
212 + )}
213 +
214 + {error && <p className="error">{error}</p>}
215 + </div>
216 +
217 + <aside className="interview-sidebar">
218 + <h2>Coverage</h2>
219 + <CoveragePanel coverage={session.coverage} />
220 + </aside>
221 + </div>
222 + </div>
223 + )
224 +}
225 +
226 +function App() {
227 + const [activeSessionId, setActiveSessionId] = useState<string | null>(null)
228 +
229 + if (activeSessionId) {
230 + return <InterviewScreen sessionId={activeSessionId} onBack={() => setActiveSessionId(null)} />
231 + }
232 +
233 + return <SessionListScreen onOpen={setActiveSessionId} />
234 +}
235 +
236 +export default App
added client/src/api.ts +68 −0
@@ -0,0 +1,68 @@
1 +import type {
2 + AnswerRequest,
3 + AnswerResponse,
4 + BlockersResponse,
5 + CreateSessionRequest,
6 + GenerateRequest,
7 + GenerateResponse,
8 + Session,
9 + SessionSummary,
10 +} from 'shared/types'
11 +
12 +const BASE = '/api'
13 +
14 +async function readJsonOrThrow<T>(res: Response): Promise<T> {
15 + if (!res.ok) {
16 + const body = (await res.json().catch(() => null)) as { error?: string } | null
17 + throw new Error(body?.error ?? `request failed with status ${res.status}`)
18 + }
19 + return res.json() as Promise<T>
20 +}
21 +
22 +async function requestJson<T>(url: string, init?: RequestInit): Promise<T> {
23 + const res = await fetch(url, {
24 + ...init,
25 + headers: { 'Content-Type': 'application/json', ...init?.headers },
26 + })
27 + return readJsonOrThrow<T>(res)
28 +}
29 +
30 +export function createSession(req: CreateSessionRequest): Promise<Session> {
31 + return requestJson<Session>(`${BASE}/sessions`, { method: 'POST', body: JSON.stringify(req) })
32 +}
33 +
34 +export function listSessions(): Promise<SessionSummary[]> {
35 + return requestJson<SessionSummary[]>(`${BASE}/sessions`)
36 +}
37 +
38 +export function getSession(id: string): Promise<Session> {
39 + return requestJson<Session>(`${BASE}/sessions/${id}`)
40 +}
41 +
42 +export function submitAnswer(id: string, req: AnswerRequest): Promise<AnswerResponse> {
43 + return requestJson<AnswerResponse>(`${BASE}/sessions/${id}/answer`, {
44 + method: 'POST',
45 + body: JSON.stringify(req),
46 + })
47 +}
48 +
49 +export async function uploadAudio(id: string, audio: Blob): Promise<AnswerResponse> {
50 + const form = new FormData()
51 + form.append('audio', audio, 'clip.webm')
52 + const res = await fetch(`${BASE}/sessions/${id}/audio`, { method: 'POST', body: form })
53 + return readJsonOrThrow<AnswerResponse>(res)
54 +}
55 +
56 +export function generateSpecPack(id: string, req: GenerateRequest = {}): Promise<GenerateResponse> {
57 + return requestJson<GenerateResponse>(`${BASE}/sessions/${id}/generate`, {
58 + method: 'POST',
59 + body: JSON.stringify(req),
60 + })
61 +}
62 +
63 +export function importBlockers(id: string): Promise<BlockersResponse> {
64 + return requestJson<BlockersResponse>(`${BASE}/sessions/${id}/blockers`, {
65 + method: 'POST',
66 + body: JSON.stringify({}),
67 + })
68 +}
added client/src/audio.ts +40 −0
@@ -0,0 +1,40 @@
1 +export interface PushToTalkRecorder {
2 + start(): Promise<void>
3 + stop(): Promise<Blob>
4 +}
5 +
6 +export function createPushToTalkRecorder(): PushToTalkRecorder {
7 + let mediaRecorder: MediaRecorder | null = null
8 + let stream: MediaStream | null = null
9 + let chunks: BlobPart[] = []
10 +
11 + return {
12 + async start() {
13 + stream = await navigator.mediaDevices.getUserMedia({ audio: true })
14 + chunks = []
15 + mediaRecorder = new MediaRecorder(stream)
16 + mediaRecorder.ondataavailable = (event) => {
17 + if (event.data.size > 0) chunks.push(event.data)
18 + }
19 + mediaRecorder.start()
20 + },
21 +
22 + stop() {
23 + return new Promise<Blob>((resolve, reject) => {
24 + if (!mediaRecorder) {
25 + reject(new Error('recording was not started'))
26 + return
27 + }
28 + const recorder = mediaRecorder
29 + const activeStream = stream
30 + recorder.onstop = () => {
31 + resolve(new Blob(chunks, { type: recorder.mimeType || 'audio/webm' }))
32 + activeStream?.getTracks().forEach((track) => track.stop())
33 + }
34 + recorder.stop()
35 + mediaRecorder = null
36 + stream = null
37 + })
38 + },
39 + }
40 +}
added client/src/components/BlockerImport.tsx +46 −0
@@ -0,0 +1,46 @@
1 +import { useState } from 'react'
2 +import * as api from '../api'
3 +
4 +interface BlockerImportProps {
5 + sessionId: string
6 + onImported: () => void
7 +}
8 +
9 +export function BlockerImport({ sessionId, onImported }: BlockerImportProps) {
10 + const [importing, setImporting] = useState(false)
11 + const [message, setMessage] = useState<string | null>(null)
12 + const [error, setError] = useState<string | null>(null)
13 +
14 + async function handleImport() {
15 + setImporting(true)
16 + setError(null)
17 + setMessage(null)
18 + try {
19 + const { questions } = await api.importBlockers(sessionId)
20 + if (questions.length === 0) {
21 + setMessage('No blockers found in BLOCKED.md.')
22 + } else {
23 + setMessage(`Imported ${questions.length} blocker question(s). Resuming the interview.`)
24 + onImported()
25 + }
26 + } catch (err) {
27 + setError(err instanceof Error ? err.message : String(err))
28 + } finally {
29 + setImporting(false)
30 + }
31 + }
32 +
33 + return (
34 + <div className="blocker-import">
35 + <h2>Import blockers</h2>
36 + <p className="muted">
37 + Reads BLOCKED.md from the target directory and resumes the interview with its open questions.
38 + </p>
39 + <button type="button" onClick={() => void handleImport()} disabled={importing}>
40 + {importing ? 'Importing…' : 'Import BLOCKED.md'}
41 + </button>
42 + {message && <p>{message}</p>}
43 + {error && <p className="error">{error}</p>}
44 + </div>
45 + )
46 +}
added client/src/components/CoveragePanel.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import { CATEGORY_IDS, type CategoryId, type Coverage } from 'shared/types'
2 +
3 +interface CoveragePanelProps {
4 + coverage: Coverage
5 +}
6 +
7 +const LABELS: Record<CategoryId, string> = {
8 + goal: 'Goal',
9 + users: 'Users',
10 + 'core-flow': 'Core flow',
11 + data: 'Data',
12 + integrations: 'Integrations',
13 + 'edge-cases': 'Edge cases',
14 + constraints: 'Constraints',
15 + 'non-goals': 'Non-goals',
16 + verification: 'Verification',
17 +}
18 +
19 +export function CoveragePanel({ coverage }: CoveragePanelProps) {
20 + return (
21 + <ul className="coverage-panel">
22 + {CATEGORY_IDS.map((category) => (
23 + <li key={category} className={`coverage-item coverage-${coverage[category]}`}>
24 + <span className="coverage-dot" aria-hidden="true" />
25 + {LABELS[category]}
26 + </li>
27 + ))}
28 + </ul>
29 + )
30 +}
added client/src/components/GeneratePanel.tsx +112 −0
@@ -0,0 +1,112 @@
1 +import { useState } from 'react'
2 +import { CATEGORY_IDS, type CategoryId, type Coverage, type GenerateResponse } from 'shared/types'
3 +import * as api from '../api'
4 +
5 +interface GeneratePanelProps {
6 + sessionId: string
7 + coverage: Coverage
8 +}
9 +
10 +const LABELS: Record<CategoryId, string> = {
11 + goal: 'Goal',
12 + users: 'Users',
13 + 'core-flow': 'Core flow',
14 + data: 'Data',
15 + integrations: 'Integrations',
16 + 'edge-cases': 'Edge cases',
17 + constraints: 'Constraints',
18 + 'non-goals': 'Non-goals',
19 + verification: 'Verification',
20 +}
21 +
22 +export function GeneratePanel({ sessionId, coverage }: GeneratePanelProps) {
23 + const [confirming, setConfirming] = useState(false)
24 + const [generating, setGenerating] = useState(false)
25 + const [result, setResult] = useState<GenerateResponse | null>(null)
26 + const [error, setError] = useState<string | null>(null)
27 + const [needsOverwrite, setNeedsOverwrite] = useState(false)
28 +
29 + const missingCategories = CATEGORY_IDS.filter((category) => coverage[category] === 'missing')
30 +
31 + async function runGenerate(overwrite: boolean) {
32 + setGenerating(true)
33 + setError(null)
34 + setConfirming(false)
35 + try {
36 + const response = await api.generateSpecPack(sessionId, { overwrite })
37 + setResult(response)
38 + setNeedsOverwrite(false)
39 + } catch (err) {
40 + const message = err instanceof Error ? err.message : String(err)
41 + setError(message)
42 + setNeedsOverwrite(message.includes('already exist'))
43 + } finally {
44 + setGenerating(false)
45 + }
46 + }
47 +
48 + function handleGenerateClick() {
49 + if (missingCategories.length > 0) {
50 + setConfirming(true)
51 + return
52 + }
53 + void runGenerate(false)
54 + }
55 +
56 + return (
57 + <div className="generate-panel">
58 + <h2>Generate spec pack</h2>
59 +
60 + {confirming ? (
61 + <div className="generate-confirm">
62 + <p>
63 + These categories are still missing: {missingCategories.map((c) => LABELS[c]).join(', ')}. Generate
64 + anyway?
65 + </p>
66 + <button type="button" onClick={() => void runGenerate(false)} disabled={generating}>
67 + Generate anyway
68 + </button>
69 + <button type="button" onClick={() => setConfirming(false)} disabled={generating}>
70 + Cancel
71 + </button>
72 + </div>
73 + ) : (
74 + <button type="button" onClick={handleGenerateClick} disabled={generating}>
75 + {generating ? 'Generating…' : 'Generate spec pack'}
76 + </button>
77 + )}
78 +
79 + {needsOverwrite && (
80 + <div className="generate-overwrite">
81 + <p>A spec pack already exists in the target directory.</p>
82 + <button type="button" onClick={() => void runGenerate(true)} disabled={generating}>
83 + Overwrite and regenerate
84 + </button>
85 + </div>
86 + )}
87 +
88 + {error && <p className="error">{error}</p>}
89 +
90 + {result && (
91 + <div className="generate-result">
92 + <p>Wrote {result.files.length} files:</p>
93 + <ul>
94 + {result.files.map((file) => (
95 + <li key={file}>{file}</li>
96 + ))}
97 + </ul>
98 + {result.warnings.length > 0 && (
99 + <div className="generate-warnings">
100 + <p>Warnings:</p>
101 + <ul>
102 + {result.warnings.map((warning) => (
103 + <li key={warning}>{warning}</li>
104 + ))}
105 + </ul>
106 + </div>
107 + )}
108 + </div>
109 + )}
110 + </div>
111 + )
112 +}
added client/src/components/PushToTalkButton.tsx +83 −0
@@ -0,0 +1,83 @@
1 +import { useCallback, useEffect, useRef, useState } from 'react'
2 +import { createPushToTalkRecorder } from '../audio'
3 +
4 +interface PushToTalkButtonProps {
5 + disabled?: boolean
6 + onRecorded: (blob: Blob) => void
7 +}
8 +
9 +function isTypingTarget(target: EventTarget | null): boolean {
10 + if (!(target instanceof HTMLElement)) return false
11 + return target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable
12 +}
13 +
14 +export function PushToTalkButton({ disabled, onRecorded }: PushToTalkButtonProps) {
15 + const [recording, setRecording] = useState(false)
16 + const [error, setError] = useState<string | null>(null)
17 + const recorderRef = useRef(createPushToTalkRecorder())
18 + const activeRef = useRef(false)
19 +
20 + const startRecording = useCallback(async () => {
21 + if (disabled || activeRef.current) return
22 + activeRef.current = true
23 + setError(null)
24 + try {
25 + await recorderRef.current.start()
26 + setRecording(true)
27 + } catch (err) {
28 + activeRef.current = false
29 + setError(err instanceof Error ? err.message : 'could not access the microphone')
30 + }
31 + }, [disabled])
32 +
33 + const stopRecording = useCallback(async () => {
34 + if (!activeRef.current) return
35 + activeRef.current = false
36 + setRecording(false)
37 + try {
38 + const blob = await recorderRef.current.stop()
39 + onRecorded(blob)
40 + } catch (err) {
41 + setError(err instanceof Error ? err.message : 'recording failed')
42 + }
43 + }, [onRecorded])
44 +
45 + useEffect(() => {
46 + function handleKeyDown(event: KeyboardEvent) {
47 + if (event.code !== 'Space' || event.repeat || isTypingTarget(event.target)) return
48 + event.preventDefault()
49 + void startRecording()
50 + }
51 +
52 + function handleKeyUp(event: KeyboardEvent) {
53 + if (event.code !== 'Space' || isTypingTarget(event.target)) return
54 + event.preventDefault()
55 + void stopRecording()
56 + }
57 +
58 + window.addEventListener('keydown', handleKeyDown)
59 + window.addEventListener('keyup', handleKeyUp)
60 + return () => {
61 + window.removeEventListener('keydown', handleKeyDown)
62 + window.removeEventListener('keyup', handleKeyUp)
63 + }
64 + }, [startRecording, stopRecording])
65 +
66 + return (
67 + <div className="push-to-talk">
68 + <button
69 + type="button"
70 + className={`record-button ${recording ? 'recording' : ''}`}
71 + disabled={disabled}
72 + onMouseDown={() => void startRecording()}
73 + onMouseUp={() => void stopRecording()}
74 + onMouseLeave={() => {
75 + if (recording) void stopRecording()
76 + }}
77 + >
78 + {recording ? 'Recording… release to send' : 'Hold to talk (or press space)'}
79 + </button>
80 + {error && <p className="error">{error}</p>}
81 + </div>
82 + )
83 +}
added client/src/components/QuestionCard.tsx +14 −0
@@ -0,0 +1,14 @@
1 +interface QuestionCardProps {
2 + question: string | null
3 +}
4 +
5 +export function QuestionCard({ question }: QuestionCardProps) {
6 + if (!question) return null
7 +
8 + return (
9 + <div className="question-card">
10 + <span className="question-label">Interviewer asks</span>
11 + <p className="question-text">{question}</p>
12 + </div>
13 + )
14 +}
added client/src/components/Transcript.tsx +22 −0
@@ -0,0 +1,22 @@
1 +import type { Segment } from 'shared/types'
2 +
3 +interface TranscriptProps {
4 + segments: Segment[]
5 +}
6 +
7 +export function Transcript({ segments }: TranscriptProps) {
8 + if (segments.length === 0) {
9 + return <p className="transcript-empty">No answers yet. Say or type what you want to build.</p>
10 + }
11 +
12 + return (
13 + <div className="transcript">
14 + {segments.map((segment) => (
15 + <div key={segment.id} className={`segment segment-${segment.speaker}`}>
16 + <span className="segment-speaker">{segment.speaker === 'user' ? 'You' : 'Interviewer'}</span>
17 + <p className="segment-text">{segment.text}</p>
18 + </div>
19 + ))}
20 + </div>
21 + )
22 +}
added client/src/index.css +50 −0
@@ -0,0 +1,50 @@
1 +:root {
2 + --text: #3c3a41;
3 + --text-h: #08060d;
4 + --bg: #fff;
5 + --bg-alt: #f6f5f8;
6 + --border: #e5e4e7;
7 + --accent: #7c3aed;
8 + --accent-bg: rgba(124, 58, 237, 0.1);
9 + --danger: #b3261e;
10 + --sans: system-ui, 'Segoe UI', Roboto, sans-serif;
11 +
12 + font: 16px/145% var(--sans);
13 + color-scheme: light dark;
14 + color: var(--text);
15 + background: var(--bg);
16 + -webkit-font-smoothing: antialiased;
17 +}
18 +
19 +@media (prefers-color-scheme: dark) {
20 + :root {
21 + --text: #d1d0d6;
22 + --text-h: #f3f4f6;
23 + --bg: #16171d;
24 + --bg-alt: #1c1d24;
25 + --border: #2e303a;
26 + --accent: #c084fc;
27 + --accent-bg: rgba(192, 132, 252, 0.15);
28 + --danger: #f2a29c;
29 + }
30 +}
31 +
32 +* {
33 + box-sizing: border-box;
34 +}
35 +
36 +body {
37 + margin: 0;
38 +}
39 +
40 +h1,
41 +h2 {
42 + font-family: var(--sans);
43 + font-weight: 600;
44 + color: var(--text-h);
45 + margin: 0 0 8px;
46 +}
47 +
48 +p {
49 + margin: 0;
50 +}
added client/src/main.tsx +10 −0
@@ -0,0 +1,10 @@
1 +import { StrictMode } from 'react'
2 +import { createRoot } from 'react-dom/client'
3 +import './index.css'
4 +import App from './App.tsx'
5 +
6 +createRoot(document.getElementById('root')!).render(
7 + <StrictMode>
8 + <App />
9 + </StrictMode>,
10 +)
added client/src/tts.ts +9 −0
@@ -0,0 +1,9 @@
1 +export function speak(text: string): void {
2 + if (!('speechSynthesis' in window)) return
3 + window.speechSynthesis.cancel()
4 + window.speechSynthesis.speak(new SpeechSynthesisUtterance(text))
5 +}
6 +
7 +export function stopSpeaking(): void {
8 + if ('speechSynthesis' in window) window.speechSynthesis.cancel()
9 +}
added client/tsconfig.app.json +29 −0
@@ -0,0 +1,29 @@
1 +{
2 + "compilerOptions": {
3 + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 + "target": "es2023",
5 + "lib": ["ES2023", "DOM"],
6 + "module": "esnext",
7 + "types": ["vite/client"],
8 + "allowArbitraryExtensions": true,
9 + "skipLibCheck": true,
10 + "strict": true,
11 + "baseUrl": "..",
12 + "paths": { "shared/*": ["shared/*"] },
13 +
14 + /* Bundler mode */
15 + "moduleResolution": "bundler",
16 + "allowImportingTsExtensions": true,
17 + "verbatimModuleSyntax": true,
18 + "moduleDetection": "force",
19 + "noEmit": true,
20 + "jsx": "react-jsx",
21 +
22 + /* Linting */
23 + "noUnusedLocals": true,
24 + "noUnusedParameters": true,
25 + "noFallthroughCasesInSwitch": true
26 + },
27 + "include": ["src", "../shared/**/*.ts"],
28 + "exclude": ["../shared/**/*.test.ts"]
29 +}
added client/tsconfig.json +4 −0
@@ -0,0 +1,4 @@
1 +{
2 + "files": [],
3 + "references": [{ "path": "./tsconfig.app.json" }]
4 +}
added client/vite.config.ts +26 −0
@@ -0,0 +1,26 @@
1 +import { fileURLToPath } from 'node:url'
2 +import { defineConfig } from 'vite'
3 +import react from '@vitejs/plugin-react'
4 +
5 +const clientDir = fileURLToPath(new URL('.', import.meta.url))
6 +const sharedDir = fileURLToPath(new URL('../shared', import.meta.url))
7 +
8 +export default defineConfig({
9 + root: clientDir,
10 + plugins: [react()],
11 + resolve: {
12 + alias: {
13 + shared: sharedDir,
14 + },
15 + },
16 + server: {
17 + fs: { allow: ['..'] },
18 + proxy: {
19 + '/api': 'http://localhost:3001',
20 + },
21 + },
22 + build: {
23 + outDir: '../dist/client',
24 + emptyOutDir: true,
25 + },
26 +})
added package-lock.json +4026 −0

Line changes are not available for this file.

added package.json +37 −0
@@ -0,0 +1,37 @@
1 +{
2 + "name": "voicetask",
3 + "private": true,
4 + "version": "0.1.0",
5 + "type": "module",
6 + "scripts": {
7 + "dev": "concurrently -k -n server,client -c blue,green \"npm:dev:server\" \"npm:dev:client\"",
8 + "dev:server": "tsx watch server/index.ts",
9 + "dev:client": "vite --config client/vite.config.ts",
10 + "build": "npm run build:client && npm run build:server",
11 + "build:client": "vite build --config client/vite.config.ts",
12 + "build:server": "tsc -p server/tsconfig.json",
13 + "typecheck": "tsc -p server/tsconfig.json --noEmit && tsc -p client/tsconfig.app.json --noEmit && tsc -p shared/tsconfig.json --noEmit",
14 + "test": "vitest run"
15 + },
16 + "dependencies": {
17 + "@anthropic-ai/sdk": "^0.71.0",
18 + "@fastify/cors": "^10.0.2",
19 + "@fastify/multipart": "^9.2.1",
20 + "@fastify/static": "^8.2.0",
21 + "fastify": "^5.6.1",
22 + "react": "^19.2.7",
23 + "react-dom": "^19.2.7",
24 + "zod": "^3.25.76"
25 + },
26 + "devDependencies": {
27 + "@types/node": "^24.13.2",
28 + "@types/react": "^19.2.17",
29 + "@types/react-dom": "^19.2.3",
30 + "@vitejs/plugin-react": "^6.0.3",
31 + "concurrently": "^9.2.1",
32 + "tsx": "^4.20.6",
33 + "typescript": "~5.9.3",
34 + "vite": "^8.1.1",
35 + "vitest": "^3.2.4"
36 + }
37 +}
added server/app.ts +36 −0
@@ -0,0 +1,36 @@
1 +import cors from '@fastify/cors'
2 +import multipart from '@fastify/multipart'
3 +import Fastify, { type FastifyInstance } from 'fastify'
4 +import { createInterviewLlm, createSttProvider } from './providers/factory'
5 +import type { InterviewLlm, SttProvider } from './providers/types'
6 +import { registerAudioRoutes } from './routes/audio'
7 +import { registerBlockerRoutes } from './routes/blockers'
8 +import { registerGenerateRoutes } from './routes/generate'
9 +import { registerSessionRoutes } from './routes/sessions'
10 +import { createDefaultSessionStore, SessionStore } from './store/sessionStore'
11 +
12 +export interface AppDeps {
13 + store: SessionStore
14 + llm: InterviewLlm
15 + stt: SttProvider
16 +}
17 +
18 +export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
19 + const resolved: AppDeps = {
20 + store: deps.store ?? createDefaultSessionStore(),
21 + llm: deps.llm ?? createInterviewLlm(),
22 + stt: deps.stt ?? createSttProvider(),
23 + }
24 +
25 + const app = Fastify({ logger: false })
26 + void app.register(cors, { origin: true })
27 + void app.register(multipart)
28 +
29 + app.get('/api/health', async () => ({ ok: true }))
30 + registerSessionRoutes(app, resolved)
31 + registerAudioRoutes(app, resolved)
32 + registerGenerateRoutes(app, resolved)
33 + registerBlockerRoutes(app, resolved)
34 +
35 + return app
36 +}
added server/blockers/parse.test.ts +63 −0
@@ -0,0 +1,63 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { parseBlockedFile } from './parse'
3 +
4 +const TEMPLATE_EMPTY = `# BLOCKED
5 +
6 +Entries the coding agent could not resolve without a product decision. The agent appends entries; the human answers by editing the entry and adding an \`ANSWER:\` line, then relaunches per spec/HANDOFF.md.
7 +
8 +Entry format:
9 +
10 +\`\`\`
11 +## B<n>: <one-line summary>
12 +- Task: T<n>
13 +- Question: <what decision is needed and why the spec does not answer it>
14 +- Options considered: <a>, <b>
15 +- Continued with: <what the agent did instead, or "skipped task">
16 +\`\`\`
17 +
18 +No entries yet.
19 +`
20 +
21 +const TEMPLATE_WITH_ENTRIES = `# BLOCKED
22 +
23 +Entries the coding agent could not resolve without a product decision.
24 +
25 +## B1: which auth provider to use
26 +- Task: T4
27 +- Question: Should sign-in use email/password or an OAuth provider?
28 +- Options considered: email/password, Google OAuth
29 +- Continued with: implemented email/password, left OAuth for later
30 +
31 +## B2: rate limit thresholds
32 +- Task: T6
33 +- Question: What is the max requests per minute per user?
34 +- Options considered: 60, 120
35 +- Continued with: skipped task
36 +`
37 +
38 +describe('parseBlockedFile', () => {
39 + it('returns no entries for the template placeholder text', () => {
40 + expect(parseBlockedFile(TEMPLATE_EMPTY)).toEqual([])
41 + })
42 +
43 + it('returns no entries for an empty string', () => {
44 + expect(parseBlockedFile('')).toEqual([])
45 + })
46 +
47 + it('parses each blocker entry into its fields', () => {
48 + const entries = parseBlockedFile(TEMPLATE_WITH_ENTRIES)
49 + expect(entries).toHaveLength(2)
50 + expect(entries[0]).toEqual({
51 + id: 'B1',
52 + summary: 'which auth provider to use',
53 + task: 'T4',
54 + question: 'Should sign-in use email/password or an OAuth provider?',
55 + optionsConsidered: 'email/password, Google OAuth',
56 + continuedWith: 'implemented email/password, left OAuth for later',
57 + })
58 + expect(entries[1]).toMatchObject({
59 + id: 'B2',
60 + question: 'What is the max requests per minute per user?',
61 + })
62 + })
63 +})
added server/blockers/parse.ts +50 −0
@@ -0,0 +1,50 @@
1 +export interface BlockerEntry {
2 + id: string
3 + summary: string
4 + task: string | null
5 + question: string
6 + optionsConsidered: string | null
7 + continuedWith: string | null
8 +}
9 +
10 +const ENTRY_HEADER = /^(B\d+):\s*(.*)$/
11 +const FIELD_PATTERNS: Array<{ key: keyof Omit<BlockerEntry, 'id' | 'summary'>; pattern: RegExp }> = [
12 + { key: 'task', pattern: /^-\s*Task:\s*(.*)$/ },
13 + { key: 'question', pattern: /^-\s*Question:\s*(.*)$/ },
14 + { key: 'optionsConsidered', pattern: /^-\s*Options considered:\s*(.*)$/ },
15 + { key: 'continuedWith', pattern: /^-\s*Continued with:\s*(.*)$/ },
16 +]
17 +
18 +export function parseBlockedFile(content: string): BlockerEntry[] {
19 + const blocks = content.split(/^##\s+/m).slice(1)
20 + const entries: BlockerEntry[] = []
21 +
22 + for (const block of blocks) {
23 + const lines = block.split('\n')
24 + const headerMatch = lines[0]?.match(ENTRY_HEADER)
25 + if (!headerMatch) continue
26 +
27 + const entry: BlockerEntry = {
28 + id: headerMatch[1],
29 + summary: headerMatch[2].trim(),
30 + task: null,
31 + question: '',
32 + optionsConsidered: null,
33 + continuedWith: null,
34 + }
35 +
36 + for (const line of lines.slice(1)) {
37 + for (const { key, pattern } of FIELD_PATTERNS) {
38 + const match = line.match(pattern)
39 + if (match) {
40 + entry[key] = match[1].trim()
41 + break
42 + }
43 + }
44 + }
45 +
46 + entries.push(entry)
47 + }
48 +
49 + return entries
50 +}
added server/engine/coverage.test.ts +37 −0
@@ -0,0 +1,37 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { CATEGORY_IDS, initialCoverage, type Coverage } from '../../shared/types'
3 +import { isFullyCovered, weakestCategory } from './coverage'
4 +
5 +function allClear(): Coverage {
6 + return Object.fromEntries(CATEGORY_IDS.map((category) => [category, 'clear'])) as Coverage
7 +}
8 +
9 +describe('weakestCategory', () => {
10 + it('returns the first category in fixed order when all are missing', () => {
11 + expect(weakestCategory(initialCoverage())).toBe(CATEGORY_IDS[0])
12 + })
13 +
14 + it('prefers missing over partial, and partial over clear, tie-broken by fixed order', () => {
15 + const coverage: Coverage = {
16 + ...initialCoverage(),
17 + goal: 'clear',
18 + users: 'partial',
19 + 'core-flow': 'missing',
20 + }
21 + expect(weakestCategory(coverage)).toBe('core-flow')
22 + })
23 +
24 + it('returns null when every category is clear', () => {
25 + expect(weakestCategory(allClear())).toBeNull()
26 + })
27 +})
28 +
29 +describe('isFullyCovered', () => {
30 + it('is false until every category is clear', () => {
31 + expect(isFullyCovered(initialCoverage())).toBe(false)
32 + })
33 +
34 + it('is true once every category is clear', () => {
35 + expect(isFullyCovered(allClear())).toBe(true)
36 + })
37 +})
added server/engine/coverage.ts +18 −0
@@ -0,0 +1,18 @@
1 +import { CATEGORY_IDS, type CategoryId, type Coverage, type CoverageLevel } from '../../shared/types'
2 +
3 +const LEVEL_PRIORITY: Record<CoverageLevel, number> = { missing: 0, partial: 1, clear: 2 }
4 +
5 +export function weakestCategory(coverage: Coverage): CategoryId | null {
6 + let weakest: CategoryId | null = null
7 + for (const category of CATEGORY_IDS) {
8 + if (coverage[category] === 'clear') continue
9 + if (weakest === null || LEVEL_PRIORITY[coverage[category]] < LEVEL_PRIORITY[coverage[weakest]]) {
10 + weakest = category
11 + }
12 + }
13 + return weakest
14 +}
15 +
16 +export function isFullyCovered(coverage: Coverage): boolean {
17 + return CATEGORY_IDS.every((category) => coverage[category] === 'clear')
18 +}
added server/engine/interview.test.ts +91 −0
@@ -0,0 +1,91 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { initialCoverage, type InterviewTurn, type Segment, type Session } from '../../shared/types'
3 +import type { InterviewContext, InterviewLlm } from '../providers/types'
4 +import { runInterviewTurn } from './interview'
5 +import { RECENT_SEGMENT_WINDOW } from './summary'
6 +
7 +function makeSession(segments: Segment[]): Session {
8 + return {
9 + id: 'sess-1',
10 + name: 'proj',
11 + targetDir: '/tmp/proj',
12 + createdAt: new Date().toISOString(),
13 + segments,
14 + coverage: initialCoverage(),
15 + summary: 'earlier summary',
16 + status: 'interviewing',
17 + openBlockers: [],
18 + }
19 +}
20 +
21 +function segment(id: string, speaker: Segment['speaker'], text: string): Segment {
22 + return { id, ts: new Date().toISOString(), speaker, text }
23 +}
24 +
25 +function stubLlm(turn: InterviewTurn, capture?: (ctx: InterviewContext) => void): InterviewLlm {
26 + return {
27 + async nextTurn(context) {
28 + capture?.(context)
29 + return turn
30 + },
31 + async generateFile() {
32 + throw new Error('not used in this test')
33 + },
34 + }
35 +}
36 +
37 +describe('runInterviewTurn', () => {
38 + it('short-circuits with done:true when the last user answer is exactly "done"', async () => {
39 + const session = makeSession([segment('S1', 'user', 'Done')])
40 + const llm = stubLlm({
41 + coverage: initialCoverage(),
42 + nextQuestion: 'should not be used',
43 + contradiction: null,
44 + done: false,
45 + summaryUpdate: null,
46 + })
47 + const turn = await runInterviewTurn(llm, session)
48 + expect(turn.done).toBe(true)
49 + expect(turn.coverage).toEqual(session.coverage)
50 + })
51 +
52 + it('passes a contradiction from the LLM straight through unchanged', async () => {
53 + const session = makeSession([segment('S1', 'user', 'we only support web')])
54 + const contradiction = { segmentIds: ['S1', 'S3'], description: 'web-only vs mobile-only' }
55 + const llm = stubLlm({
56 + coverage: initialCoverage(),
57 + nextQuestion: 'Which is it, web or mobile?',
58 + contradiction,
59 + done: false,
60 + summaryUpdate: null,
61 + })
62 + const turn = await runInterviewTurn(llm, session)
63 + expect(turn.contradiction).toEqual(contradiction)
64 + })
65 +
66 + it('sends only the last 40 segments plus the running summary once the transcript grows past the window', async () => {
67 + const segments = Array.from({ length: RECENT_SEGMENT_WINDOW + 7 }, (_, i) =>
68 + segment(`S${i + 1}`, i % 2 === 0 ? 'user' : 'interviewer', `text ${i + 1}`),
69 + )
70 + const session = makeSession(segments)
71 + let seenContext: InterviewContext | undefined
72 + const llm = stubLlm(
73 + {
74 + coverage: initialCoverage(),
75 + nextQuestion: 'next?',
76 + contradiction: null,
77 + done: false,
78 + summaryUpdate: null,
79 + },
80 + (ctx) => {
81 + seenContext = ctx
82 + },
83 + )
84 +
85 + await runInterviewTurn(llm, session)
86 +
87 + expect(seenContext?.segments).toHaveLength(RECENT_SEGMENT_WINDOW)
88 + expect(seenContext?.segments[0].id).toBe('S8')
89 + expect(seenContext?.summary).toBe('earlier summary')
90 + })
91 +})
added server/engine/interview.ts +28 −0
@@ -0,0 +1,28 @@
1 +import type { InterviewTurn, Session } from '../../shared/types'
2 +import type { InterviewLlm } from '../providers/types'
3 +import { splitTranscriptWindow } from './summary'
4 +
5 +export function isDoneAnswer(text: string): boolean {
6 + return text.trim().toLowerCase() === 'done'
7 +}
8 +
9 +export async function runInterviewTurn(llm: InterviewLlm, session: Session): Promise<InterviewTurn> {
10 + const lastSegment = session.segments[session.segments.length - 1]
11 + if (lastSegment?.speaker === 'user' && isDoneAnswer(lastSegment.text)) {
12 + return {
13 + coverage: session.coverage,
14 + nextQuestion: 'Interview complete.',
15 + contradiction: null,
16 + done: true,
17 + summaryUpdate: null,
18 + }
19 + }
20 +
21 + const { recentSegments } = splitTranscriptWindow(session.segments)
22 +
23 + return llm.nextTurn({
24 + summary: session.summary,
25 + segments: recentSegments,
26 + coverage: session.coverage,
27 + })
28 +}
added server/engine/summary.test.ts +31 −0
@@ -0,0 +1,31 @@
1 +import { describe, expect, it } from 'vitest'
2 +import type { Segment } from '../../shared/types'
3 +import { RECENT_SEGMENT_WINDOW, splitTranscriptWindow } from './summary'
4 +
5 +function makeSegments(count: number): Segment[] {
6 + return Array.from({ length: count }, (_, i) => ({
7 + id: `S${i + 1}`,
8 + ts: new Date().toISOString(),
9 + speaker: i % 2 === 0 ? 'user' : 'interviewer',
10 + text: `segment ${i + 1}`,
11 + }))
12 +}
13 +
14 +describe('splitTranscriptWindow', () => {
15 + it('keeps everything in the recent window when at or under the limit', () => {
16 + const segments = makeSegments(RECENT_SEGMENT_WINDOW)
17 + const { recentSegments, olderSegments } = splitTranscriptWindow(segments)
18 + expect(recentSegments).toEqual(segments)
19 + expect(olderSegments).toEqual([])
20 + })
21 +
22 + it('keeps only the last 40 segments and puts the rest in olderSegments', () => {
23 + const segments = makeSegments(RECENT_SEGMENT_WINDOW + 5)
24 + const { recentSegments, olderSegments } = splitTranscriptWindow(segments)
25 + expect(recentSegments).toHaveLength(RECENT_SEGMENT_WINDOW)
26 + expect(recentSegments[0].id).toBe('S6')
27 + expect(recentSegments[recentSegments.length - 1].id).toBe(`S${segments.length}`)
28 + expect(olderSegments).toHaveLength(5)
29 + expect(olderSegments.map((s) => s.id)).toEqual(['S1', 'S2', 'S3', 'S4', 'S5'])
30 + })
31 +})
added server/engine/summary.ts +19 −0
@@ -0,0 +1,19 @@
1 +import type { Segment } from '../../shared/types'
2 +
3 +export const RECENT_SEGMENT_WINDOW = 40
4 +
5 +export interface TranscriptWindow {
6 + recentSegments: Segment[]
7 + olderSegments: Segment[]
8 +}
9 +
10 +export function splitTranscriptWindow(segments: Segment[]): TranscriptWindow {
11 + if (segments.length <= RECENT_SEGMENT_WINDOW) {
12 + return { recentSegments: segments, olderSegments: [] }
13 + }
14 + const splitIndex = segments.length - RECENT_SEGMENT_WINDOW
15 + return {
16 + recentSegments: segments.slice(splitIndex),
17 + olderSegments: segments.slice(0, splitIndex),
18 + }
19 +}
added server/engine/turn.ts +49 −0
@@ -0,0 +1,49 @@
1 +import type { AnswerResponse, InterviewTurn } from '../../shared/types'
2 +import type { InterviewLlm } from '../providers/types'
3 +import type { SessionStore } from '../store/sessionStore'
4 +import { isDoneAnswer, runInterviewTurn } from './interview'
5 +
6 +export class SessionNotFoundError extends Error {
7 + constructor(sessionId: string) {
8 + super(`session not found: ${sessionId}`)
9 + }
10 +}
11 +
12 +export async function submitAnswer(
13 + store: SessionStore,
14 + llm: InterviewLlm,
15 + sessionId: string,
16 + text: string,
17 +): Promise<AnswerResponse> {
18 + const existing = await store.getSession(sessionId)
19 + if (!existing) throw new SessionNotFoundError(sessionId)
20 +
21 + const { session: afterAnswer, segment } = await store.appendSegment(sessionId, 'user', text)
22 +
23 + let turn: InterviewTurn
24 + let remainingBlockers = afterAnswer.openBlockers
25 +
26 + if (!isDoneAnswer(text) && afterAnswer.openBlockers.length > 0) {
27 + const [nextQuestion, ...rest] = afterAnswer.openBlockers
28 + remainingBlockers = rest
29 + turn = {
30 + coverage: afterAnswer.coverage,
31 + nextQuestion,
32 + contradiction: null,
33 + done: false,
34 + summaryUpdate: null,
35 + }
36 + } else {
37 + turn = await runInterviewTurn(llm, afterAnswer)
38 + }
39 +
40 + await store.appendSegment(sessionId, 'interviewer', turn.nextQuestion)
41 + await store.updateTurn(sessionId, {
42 + coverage: turn.coverage,
43 + summary: turn.summaryUpdate ?? afterAnswer.summary,
44 + status: turn.done ? 'done' : 'interviewing',
45 + openBlockers: remainingBlockers,
46 + })
47 +
48 + return { segment, turn }
49 +}
added server/generator/generate.test.ts +93 −0
@@ -0,0 +1,93 @@
1 +import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5 +import { initialCoverage, type Session } from '../../shared/types'
6 +import { createLlmMock } from '../providers/llmMock'
7 +import { GenerateFilesExistError, generateSpecPack } from './generate'
8 +
9 +function makeSession(targetDir: string): Session {
10 + return {
11 + id: 'sess-1',
12 + name: 'my project',
13 + targetDir,
14 + createdAt: new Date().toISOString(),
15 + segments: [{ id: 'S1', ts: new Date().toISOString(), speaker: 'user', text: 'we are building a todo app' }],
16 + coverage: initialCoverage(),
17 + summary: '',
18 + status: 'done',
19 + openBlockers: [],
20 + }
21 +}
22 +
23 +describe('generateSpecPack', () => {
24 + let targetDir: string
25 +
26 + beforeEach(async () => {
27 + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-'))
28 + })
29 +
30 + afterEach(async () => {
31 + await rm(targetDir, { recursive: true, force: true })
32 + })
33 +
34 + it('writes all six spec pack files', async () => {
35 + const session = makeSession(targetDir)
36 + const result = await generateSpecPack(createLlmMock(), session)
37 +
38 + expect(result.files.sort()).toEqual(
39 + [
40 + 'spec/SPEC.md',
41 + 'spec/PLAN.md',
42 + 'spec/TASKS.md',
43 + 'spec/VERIFICATION.md',
44 + 'spec/HANDOFF.md',
45 + 'spec/sources.json',
46 + ].sort(),
47 + )
48 +
49 + for (const file of result.files) {
50 + const content = await readFile(path.join(targetDir, file), 'utf8')
51 + expect(content.length).toBeGreaterThan(0)
52 + }
53 + })
54 +
55 + it('emits sources.json mapping segment ids to text and timestamp', async () => {
56 + const session = makeSession(targetDir)
57 + await generateSpecPack(createLlmMock(), session)
58 + const sources = JSON.parse(await readFile(path.join(targetDir, 'spec/sources.json'), 'utf8'))
59 + expect(sources.S1).toEqual({ text: 'we are building a todo app', ts: session.segments[0].ts })
60 + })
61 +
62 + it('removes the bogus [S999] marker and appends [unverified], keeping the valid [S1] marker', async () => {
63 + const session = makeSession(targetDir)
64 + const result = await generateSpecPack(createLlmMock(), session)
65 + const specContent = await readFile(path.join(targetDir, 'spec/SPEC.md'), 'utf8')
66 +
67 + expect(specContent).toContain('[S1]')
68 + expect(specContent).not.toContain('[S999]')
69 + expect(specContent).toContain('[unverified]')
70 + expect(result.warnings.some((w) => w.includes('SPEC.md'))).toBe(true)
71 + })
72 +
73 + it('fails on a second run without overwrite, and stores nothing new', async () => {
74 + const session = makeSession(targetDir)
75 + await generateSpecPack(createLlmMock(), session)
76 + await expect(generateSpecPack(createLlmMock(), session)).rejects.toBeInstanceOf(GenerateFilesExistError)
77 + })
78 +
79 + it('creates a backup dir and regenerates when overwrite is set', async () => {
80 + const session = makeSession(targetDir)
81 + await generateSpecPack(createLlmMock(), session)
82 + const result = await generateSpecPack(createLlmMock(), session, { overwrite: true })
83 + expect(result.files.length).toBe(6)
84 +
85 + const specDirEntries = await readdir(path.join(targetDir, 'spec'))
86 + expect(specDirEntries.some((entry) => entry.startsWith('backup-'))).toBe(true)
87 + })
88 +
89 + it('fails when the target directory does not exist', async () => {
90 + const session = makeSession(path.join(targetDir, 'does-not-exist'))
91 + await expect(generateSpecPack(createLlmMock(), session)).rejects.toThrow()
92 + })
93 +})
added server/generator/generate.ts +92 −0
@@ -0,0 +1,92 @@
1 +import { mkdir, rename, stat, writeFile } from 'node:fs/promises'
2 +import path from 'node:path'
3 +import type { Session } from '../../shared/types'
4 +import { SPEC_PACK_FILES, type InterviewLlm } from '../providers/types'
5 +import { buildGeneratePrompt } from './prompts'
6 +import { validateProvenance } from './provenance'
7 +
8 +const SOURCES_FILE = 'sources.json'
9 +
10 +export interface GenerateOptions {
11 + overwrite?: boolean
12 +}
13 +
14 +export interface GenerateResult {
15 + files: string[]
16 + warnings: string[]
17 +}
18 +
19 +export class GenerateFilesExistError extends Error {
20 + readonly existing: string[]
21 +
22 + constructor(existing: string[]) {
23 + super(`spec pack files already exist: ${existing.join(', ')}`)
24 + this.existing = existing
25 + }
26 +}
27 +
28 +async function pathExists(target: string): Promise<boolean> {
29 + try {
30 + await stat(target)
31 + return true
32 + } catch {
33 + return false
34 + }
35 +}
36 +
37 +async function backupExistingFiles(specDir: string, existing: string[]): Promise<void> {
38 + const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
39 + const backupDir = path.join(specDir, `backup-${timestamp}`)
40 + await mkdir(backupDir, { recursive: true })
41 + for (const file of existing) {
42 + await rename(path.join(specDir, file), path.join(backupDir, file))
43 + }
44 +}
45 +
46 +export async function generateSpecPack(
47 + llm: InterviewLlm,
48 + session: Session,
49 + options: GenerateOptions = {},
50 +): Promise<GenerateResult> {
51 + const targetStat = await stat(session.targetDir).catch(() => null)
52 + if (!targetStat || !targetStat.isDirectory()) {
53 + throw new Error(`target directory does not exist: ${session.targetDir}`)
54 + }
55 +
56 + const specDir = path.join(session.targetDir, 'spec')
57 + await mkdir(specDir, { recursive: true })
58 +
59 + const candidateFiles: string[] = [...SPEC_PACK_FILES, SOURCES_FILE]
60 + const existing: string[] = []
61 + for (const file of candidateFiles) {
62 + if (await pathExists(path.join(specDir, file))) existing.push(file)
63 + }
64 +
65 + if (existing.length > 0) {
66 + if (!options.overwrite) throw new GenerateFilesExistError(existing)
67 + await backupExistingFiles(specDir, existing)
68 + }
69 +
70 + const validSegmentIds = new Set(session.segments.map((segment) => segment.id))
71 + const warnings: string[] = []
72 + const writtenFiles: string[] = []
73 +
74 + for (const file of SPEC_PACK_FILES) {
75 + const prompt = buildGeneratePrompt(file, session.name, session.segments)
76 + const rawContent = await llm.generateFile({ file, prompt, segments: session.segments })
77 + const { content, invalidMarkerCount } = validateProvenance(rawContent, validSegmentIds)
78 + if (invalidMarkerCount > 0) {
79 + warnings.push(`${file}: removed ${invalidMarkerCount} unverified provenance marker(s)`)
80 + }
81 + await writeFile(path.join(specDir, file), content, 'utf8')
82 + writtenFiles.push(`spec/${file}`)
83 + }
84 +
85 + const sources = Object.fromEntries(
86 + session.segments.map((segment) => [segment.id, { text: segment.text, ts: segment.ts }]),
87 + )
88 + await writeFile(path.join(specDir, SOURCES_FILE), JSON.stringify(sources, null, 2), 'utf8')
89 + writtenFiles.push(`spec/${SOURCES_FILE}`)
90 +
91 + return { files: writtenFiles, warnings }
92 +}
added server/generator/prompts.ts +61 −0
@@ -0,0 +1,61 @@
1 +import type { Segment } from '../../shared/types'
2 +import type { SpecPackFile } from '../providers/types'
3 +
4 +function renderTranscript(segments: Segment[]): string {
5 + return segments.map((segment) => `[${segment.id}] ${segment.speaker}: ${segment.text}`).join('\n')
6 +}
7 +
8 +export function buildGeneratePrompt(file: SpecPackFile, projectName: string, segments: Segment[]): string {
9 + const transcript = renderTranscript(segments)
10 + const header = `You are writing ${file} for a project called "${projectName}", based on a transcript of a Socratic spec interview with its developer. Write only what the transcript supports; do not invent requirements. Output English Markdown only, no commentary.`
11 +
12 + switch (file) {
13 + case 'SPEC.md':
14 + return [
15 + header,
16 + 'Mirror the structure of a typical SPEC.md: Goal, Users, User stories, Functional requirements (FR-001, FR-002, ...), Edge cases, Out of scope, Assumptions.',
17 + 'Every functional requirement line MUST end with one or more provenance markers, e.g. "[S3]" or "[S5][S9]", citing the transcript segment ids that justify it.',
18 + '',
19 + 'Transcript:',
20 + transcript,
21 + ].join('\n')
22 +
23 + case 'PLAN.md':
24 + return [
25 + header,
26 + 'Mirror the structure of a typical PLAN.md: Stack, Layout, key interfaces, API routes, and decisions already made, all derived from the transcript.',
27 + '',
28 + 'Transcript:',
29 + transcript,
30 + ].join('\n')
31 +
32 + case 'TASKS.md':
33 + return [
34 + header,
35 + 'Mirror the structure of a typical TASKS.md: an ordered checklist of implementation tasks, each with a Depends line and a Verify line.',
36 + 'Every task MUST include at least one concrete verification command (e.g. `npm test`).',
37 + '',
38 + 'Transcript:',
39 + transcript,
40 + ].join('\n')
41 +
42 + case 'VERIFICATION.md':
43 + return [
44 + header,
45 + 'Describe what "done" looks like for this project and how a coding agent should verify it (commands to run, behaviors to check).',
46 + '',
47 + 'Transcript:',
48 + transcript,
49 + ].join('\n')
50 +
51 + case 'HANDOFF.md':
52 + return [
53 + header,
54 + 'Explain how to hand this spec pack to a sandboxed Claude Code session.',
55 + 'It MUST include a ready-to-copy shell command line that launches such a session against this spec pack, using the `claude` CLI, e.g.: `claude "Work through spec/TASKS.md in order."`',
56 + '',
57 + 'Transcript:',
58 + transcript,
59 + ].join('\n')
60 + }
61 +}
added server/generator/provenance.test.ts +31 −0
@@ -0,0 +1,31 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { validateProvenance } from './provenance'
3 +
4 +describe('validateProvenance', () => {
5 + it('leaves lines with only valid markers untouched', () => {
6 + const content = '- FR-001: does a thing. [S1][S2]'
7 + const { content: result, invalidMarkerCount } = validateProvenance(content, new Set(['S1', 'S2']))
8 + expect(result).toBe(content)
9 + expect(invalidMarkerCount).toBe(0)
10 + })
11 +
12 + it('removes an invalid marker and appends [unverified]', () => {
13 + const content = '- FR-002: does another thing. [S999]'
14 + const { content: result, invalidMarkerCount } = validateProvenance(content, new Set(['S1']))
15 + expect(result).toBe('- FR-002: does another thing. [unverified]')
16 + expect(invalidMarkerCount).toBe(1)
17 + })
18 +
19 + it('keeps valid markers on a line while removing only the invalid ones', () => {
20 + const content = '- FR-003: mixed evidence. [S1][S999]'
21 + const { content: result, invalidMarkerCount } = validateProvenance(content, new Set(['S1']))
22 + expect(result).toBe('- FR-003: mixed evidence. [S1] [unverified]')
23 + expect(invalidMarkerCount).toBe(1)
24 + })
25 +
26 + it('does not duplicate [unverified] if already present', () => {
27 + const content = '- FR-004: already flagged. [S999] [unverified]'
28 + const { content: result } = validateProvenance(content, new Set())
29 + expect(result).toBe('- FR-004: already flagged. [unverified]')
30 + })
31 +})
added server/generator/provenance.ts +33 −0
@@ -0,0 +1,33 @@
1 +const MARKER_PATTERN = /\[S(\d+)\]/g
2 +
3 +export interface ProvenanceResult {
4 + content: string
5 + invalidMarkerCount: number
6 +}
7 +
8 +export function validateProvenance(content: string, validSegmentIds: ReadonlySet<string>): ProvenanceResult {
9 + let invalidMarkerCount = 0
10 +
11 + const lines = content.split('\n').map((line) => {
12 + const markers = [...line.matchAll(MARKER_PATTERN)]
13 + const invalidMarkers = markers.filter((match) => !validSegmentIds.has(`S${match[1]}`))
14 + if (invalidMarkers.length === 0) return line
15 +
16 + invalidMarkerCount += invalidMarkers.length
17 + let updated = line
18 + for (const match of invalidMarkers) {
19 + updated = updated.replace(match[0], '')
20 + }
21 +
22 + const leading = updated.match(/^\s*/)?.[0] ?? ''
23 + const rest = updated.slice(leading.length).replace(/ {2,}/g, ' ').trimEnd()
24 + updated = leading + rest
25 +
26 + if (!updated.includes('[unverified]')) {
27 + updated = `${updated} [unverified]`
28 + }
29 + return updated
30 + })
31 +
32 + return { content: lines.join('\n'), invalidMarkerCount }
33 +}
added server/index.ts +13 −0
@@ -0,0 +1,13 @@
1 +import { buildApp } from './app'
2 +
3 +const PORT = Number(process.env.PORT ?? 3001)
4 +
5 +async function main() {
6 + const app = buildApp()
7 + await app.listen({ port: PORT, host: '0.0.0.0' })
8 +}
9 +
10 +main().catch((err) => {
11 + console.error(err)
12 + process.exit(1)
13 +})
added server/placeholder.test.ts +5 −0
@@ -0,0 +1,5 @@
1 +import { expect, test } from 'vitest'
2 +
3 +test('placeholder', () => {
4 + expect(true).toBe(true)
5 +})
added server/providers/factory.test.ts +20 −0
@@ -0,0 +1,20 @@
1 +import { afterEach, describe, expect, it, vi } from 'vitest'
2 +import { createInterviewLlm, createSttProvider } from './factory'
3 +
4 +describe('provider factory', () => {
5 + afterEach(() => {
6 + vi.unstubAllEnvs()
7 + })
8 +
9 + it('returns mock providers when MOCK_PROVIDERS=1', () => {
10 + vi.stubEnv('MOCK_PROVIDERS', '1')
11 + expect(createSttProvider()).toHaveProperty('transcribe')
12 + expect(createInterviewLlm()).toHaveProperty('nextTurn')
13 + })
14 +
15 + it('refuses to create real providers when MOCK_PROVIDERS is not set', () => {
16 + vi.stubEnv('MOCK_PROVIDERS', '')
17 + expect(() => createSttProvider()).toThrow()
18 + expect(() => createInterviewLlm()).toThrow()
19 + })
20 +})
added server/providers/factory.ts +19 −0
@@ -0,0 +1,19 @@
1 +import { createLlmAnthropic } from './llmAnthropic'
2 +import { createLlmMock } from './llmMock'
3 +import { createSttMock } from './sttMock'
4 +import { createSttOpenai } from './sttOpenai'
5 +import type { InterviewLlm, SttProvider } from './types'
6 +
7 +function isMockMode(): boolean {
8 + return process.env.MOCK_PROVIDERS === '1'
9 +}
10 +
11 +export function createSttProvider(): SttProvider {
12 + if (isMockMode()) return createSttMock()
13 + return createSttOpenai()
14 +}
15 +
16 +export function createInterviewLlm(): InterviewLlm {
17 + if (isMockMode()) return createLlmMock()
18 + return createLlmAnthropic()
19 +}
added server/providers/llmAnthropic.test.ts +53 −0
@@ -0,0 +1,53 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { initialCoverage, type Segment } from '../../shared/types'
3 +import { buildInterviewSystemPrompt, buildInterviewUserMessage } from './llmAnthropic'
4 +import type { InterviewContext } from './types'
5 +
6 +function segment(id: string, speaker: Segment['speaker'], text: string): Segment {
7 + return { id, ts: new Date().toISOString(), speaker, text }
8 +}
9 +
10 +describe('buildInterviewSystemPrompt', () => {
11 + it('names the weakest category as the required target of the next question', () => {
12 + const context: InterviewContext = {
13 + summary: '',
14 + segments: [],
15 + coverage: { ...initialCoverage(), goal: 'clear' },
16 + }
17 + const prompt = buildInterviewSystemPrompt(context)
18 + expect(prompt).toContain('weakest category right now is "users"')
19 + })
20 +
21 + it('states every category is covered when nothing is weak', () => {
22 + const allClear = Object.fromEntries(
23 + Object.keys(initialCoverage()).map((c) => [c, 'clear']),
24 + ) as ReturnType<typeof initialCoverage>
25 + const context: InterviewContext = { summary: '', segments: [], coverage: allClear }
26 + expect(buildInterviewSystemPrompt(context)).toContain('Every category is already "clear"')
27 + })
28 +
29 + it('instructs the exact-match "done" rule and the contradiction rule', () => {
30 + const context: InterviewContext = { summary: '', segments: [], coverage: initialCoverage() }
31 + const prompt = buildInterviewSystemPrompt(context)
32 + expect(prompt).toContain('exactly "done"')
33 + expect(prompt).toContain('contradiction')
34 + })
35 +})
36 +
37 +describe('buildInterviewUserMessage', () => {
38 + it('includes the running summary and every recent segment with its id and speaker', () => {
39 + const context: InterviewContext = {
40 + summary: 'earlier: building a todo app',
41 + segments: [segment('S1', 'user', 'it should sync across devices')],
42 + coverage: initialCoverage(),
43 + }
44 + const message = buildInterviewUserMessage(context)
45 + expect(message).toContain('earlier: building a todo app')
46 + expect(message).toContain('[S1] user: it should sync across devices')
47 + })
48 +
49 + it('omits the summary section when there is no summary yet', () => {
50 + const context: InterviewContext = { summary: '', segments: [], coverage: initialCoverage() }
51 + expect(buildInterviewUserMessage(context)).not.toContain('Running summary')
52 + })
53 +})
added server/providers/llmAnthropic.ts +76 −0
@@ -0,0 +1,76 @@
1 +import Anthropic from '@anthropic-ai/sdk'
2 +import { betaZodOutputFormat } from '@anthropic-ai/sdk/helpers/beta/zod'
3 +import { CATEGORY_IDS, InterviewTurnSchema, type InterviewTurn } from '../../shared/types'
4 +import { weakestCategory } from '../engine/coverage'
5 +import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types'
6 +
7 +const DEFAULT_MODEL = 'claude-opus-4-8'
8 +const INTERVIEW_MAX_TOKENS = 4096
9 +const INTERVIEW_THINKING_BUDGET_TOKENS = 2048
10 +const GENERATE_MAX_TOKENS = 32000
11 +
12 +export function buildInterviewSystemPrompt(context: InterviewContext): string {
13 + const weakest = weakestCategory(context.coverage)
14 + return [
15 + 'You are a Socratic spec interviewer for a solo developer talking through a project idea.',
16 + 'Ask exactly one targeted question per turn, chosen to fill the biggest gap in the spec.',
17 + `Coverage categories, in tie-break priority order: ${CATEGORY_IDS.join(', ')}.`,
18 + weakest
19 + ? `The weakest category right now is "${weakest}". Your next question must target it, unless a contradiction takes priority.`
20 + : 'Every category is already "clear".',
21 + 'If the latest user answer conflicts with an earlier statement, do not ask a coverage question: set "contradiction" to describe both statements, referencing their segment ids, instead.',
22 + '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.',
23 + 'Reply with only the InterviewTurn structure described by the output schema.',
24 + ].join('\n')
25 +}
26 +
27 +export function buildInterviewUserMessage(context: InterviewContext): string {
28 + const lines: string[] = []
29 + if (context.summary) {
30 + lines.push('Running summary of earlier segments:', context.summary, '')
31 + }
32 + lines.push('Recent transcript segments:')
33 + for (const segment of context.segments) {
34 + lines.push(`[${segment.id}] ${segment.speaker}: ${segment.text}`)
35 + }
36 + lines.push('', `Current coverage: ${JSON.stringify(context.coverage)}`)
37 + return lines.join('\n')
38 +}
39 +
40 +export function createLlmAnthropic(): InterviewLlm {
41 + const apiKey = process.env.ANTHROPIC_API_KEY
42 + if (!apiKey) throw new Error('ANTHROPIC_API_KEY is not set')
43 + const model = process.env.ANTHROPIC_MODEL ?? DEFAULT_MODEL
44 + const client = new Anthropic({ apiKey })
45 +
46 + return {
47 + async nextTurn(context: InterviewContext): Promise<InterviewTurn> {
48 + const message = await client.beta.messages.parse({
49 + model,
50 + max_tokens: INTERVIEW_MAX_TOKENS,
51 + thinking: { type: 'enabled', budget_tokens: INTERVIEW_THINKING_BUDGET_TOKENS },
52 + system: buildInterviewSystemPrompt(context),
53 + messages: [{ role: 'user', content: buildInterviewUserMessage(context) }],
54 + output_format: betaZodOutputFormat(InterviewTurnSchema),
55 + })
56 + if (!message.parsed_output) {
57 + throw new Error('Anthropic response did not include a parsed InterviewTurn')
58 + }
59 + return message.parsed_output
60 + },
61 +
62 + async generateFile(request: GenerateFileRequest): Promise<string> {
63 + const stream = client.beta.messages.stream({
64 + model,
65 + max_tokens: GENERATE_MAX_TOKENS,
66 + messages: [{ role: 'user', content: request.prompt }],
67 + })
68 + const final = await stream.finalMessage()
69 + const textBlock = final.content.find((block) => block.type === 'text')
70 + if (!textBlock) {
71 + throw new Error(`Anthropic response for ${request.file} contained no text block`)
72 + }
73 + return textBlock.text
74 + },
75 + }
76 +}
added server/providers/llmMock.test.ts +54 −0
@@ -0,0 +1,54 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { CATEGORY_IDS, initialCoverage, type Segment } from '../../shared/types'
3 +import { createLlmMock } from './llmMock'
4 +
5 +function segment(id: string, speaker: Segment['speaker'], text: string): Segment {
6 + return { id, ts: new Date().toISOString(), speaker, text }
7 +}
8 +
9 +describe('llmMock interview script', () => {
10 + it('clears one category per turn in fixed order and eventually reaches done', async () => {
11 + const llm = createLlmMock()
12 + let coverage = initialCoverage()
13 + const segments: Segment[] = []
14 +
15 + for (let i = 0; i < CATEGORY_IDS.length; i++) {
16 + segments.push(segment(`S${i + 1}`, 'user', `answer ${i}`))
17 + const turn = await llm.nextTurn({ summary: '', segments, coverage })
18 + expect(turn.coverage[CATEGORY_IDS[i]]).toBe('clear')
19 + coverage = turn.coverage
20 + if (i < CATEGORY_IDS.length - 1) {
21 + expect(turn.done).toBe(false)
22 + expect(turn.nextQuestion).toContain(CATEGORY_IDS[i + 1])
23 + } else {
24 + expect(turn.done).toBe(true)
25 + }
26 + }
27 +
28 + expect(Object.values(coverage).every((level) => level === 'clear')).toBe(true)
29 + })
30 +
31 + it('ends the interview when the last user segment is exactly "done"', async () => {
32 + const llm = createLlmMock()
33 + const coverage = initialCoverage()
34 + const segments: Segment[] = [segment('S1', 'user', ' Done ')]
35 + const turn = await llm.nextTurn({ summary: '', segments, coverage })
36 + expect(turn.done).toBe(true)
37 + expect(turn.coverage).toEqual(coverage)
38 + })
39 +})
40 +
41 +describe('llmMock generation', () => {
42 + it('emits a SPEC.md with a valid marker and a bogus marker', async () => {
43 + const llm = createLlmMock()
44 + const content = await llm.generateFile({ file: 'SPEC.md', prompt: '', segments: [] })
45 + expect(content).toContain('[S1]')
46 + expect(content).toContain('[S999]')
47 + })
48 +
49 + it('emits a HANDOFF.md with a claude launch command', async () => {
50 + const llm = createLlmMock()
51 + const content = await llm.generateFile({ file: 'HANDOFF.md', prompt: '', segments: [] })
52 + expect(content).toContain('claude')
53 + })
54 +})
added server/providers/llmMock.ts +91 −0
@@ -0,0 +1,91 @@
1 +import { CATEGORY_IDS, type Coverage, type InterviewTurn } from '../../shared/types'
2 +import type { GenerateFileRequest, InterviewContext, InterviewLlm } from './types'
3 +
4 +export function createLlmMock(): InterviewLlm {
5 + return {
6 + async nextTurn(context: InterviewContext): Promise<InterviewTurn> {
7 + return mockNextTurn(context)
8 + },
9 + async generateFile(request: GenerateFileRequest): Promise<string> {
10 + return mockFileContent(request.file)
11 + },
12 + }
13 +}
14 +
15 +function mockNextTurn(context: InterviewContext): InterviewTurn {
16 + const lastUserSegment = [...context.segments].reverse().find((s) => s.speaker === 'user')
17 + if (lastUserSegment && lastUserSegment.text.trim().toLowerCase() === 'done') {
18 + return {
19 + coverage: context.coverage,
20 + nextQuestion: 'Understood, wrapping up the interview.',
21 + contradiction: null,
22 + done: true,
23 + summaryUpdate: null,
24 + }
25 + }
26 +
27 + const clearedCount = CATEGORY_IDS.filter((category) => context.coverage[category] === 'clear').length
28 + const categoryToClear = CATEGORY_IDS[clearedCount]
29 +
30 + if (!categoryToClear) {
31 + return {
32 + coverage: context.coverage,
33 + nextQuestion: 'All categories covered, ready to generate the spec pack.',
34 + contradiction: null,
35 + done: true,
36 + summaryUpdate: null,
37 + }
38 + }
39 +
40 + const coverage: Coverage = { ...context.coverage, [categoryToClear]: 'clear' }
41 + const nextCategory = CATEGORY_IDS[clearedCount + 1]
42 + const done = nextCategory === undefined
43 +
44 + return {
45 + coverage,
46 + nextQuestion: done
47 + ? 'All categories covered, ready to generate the spec pack.'
48 + : `[mock] Tell me about ${nextCategory}.`,
49 + contradiction: null,
50 + done,
51 + summaryUpdate: null,
52 + }
53 +}
54 +
55 +function mockFileContent(file: GenerateFileRequest['file']): string {
56 + switch (file) {
57 + case 'SPEC.md':
58 + return [
59 + '# SPEC: Mock',
60 + '',
61 + '## Functional requirements',
62 + '',
63 + '- FR-001: The mock system does the first thing. [S1]',
64 + '- FR-002: The mock system does the second thing. [S999]',
65 + '',
66 + ].join('\n')
67 + case 'PLAN.md':
68 + return '# PLAN: Mock\n\nMinimal mock plan.\n'
69 + case 'TASKS.md':
70 + return [
71 + '# TASKS: Mock',
72 + '',
73 + '- [ ] T1 Mock task',
74 + ' - Verify: `npm test` exits 0.',
75 + '',
76 + ].join('\n')
77 + case 'VERIFICATION.md':
78 + return '# VERIFICATION: Mock\n\nRun `npm test`.\n'
79 + case 'HANDOFF.md':
80 + return [
81 + '# HANDOFF: Mock',
82 + '',
83 + 'Launch a sandboxed session with:',
84 + '',
85 + '```',
86 + 'claude "Work through spec/TASKS.md in order."',
87 + '```',
88 + '',
89 + ].join('\n')
90 + }
91 +}
added server/providers/sttMock.test.ts +17 −0
@@ -0,0 +1,17 @@
1 +import { describe, expect, it } from 'vitest'
2 +import { createSttMock } from './sttMock'
3 +
4 +describe('sttMock', () => {
5 + it('echoes valid UTF-8 text buffers back as the transcript', async () => {
6 + const stt = createSttMock()
7 + const result = await stt.transcribe(Buffer.from('hello from a test', 'utf8'), 'text/plain')
8 + expect(result).toBe('hello from a test')
9 + })
10 +
11 + it('returns an incrementing mock transcript for non-text buffers', async () => {
12 + const stt = createSttMock()
13 + const binary = Buffer.from([0xff, 0xfe, 0x00, 0x01, 0x80, 0x80])
14 + expect(await stt.transcribe(binary, 'audio/webm')).toBe('mock transcript 1')
15 + expect(await stt.transcribe(binary, 'audio/webm')).toBe('mock transcript 2')
16 + })
17 +})
added server/providers/sttMock.ts +21 −0
@@ -0,0 +1,21 @@
1 +import type { SttProvider } from './types'
2 +
3 +export function createSttMock(): SttProvider {
4 + let counter = 0
5 +
6 + return {
7 + async transcribe(audio: Buffer): Promise<string> {
8 + const echoed = decodeIfUtf8Text(audio)
9 + if (echoed !== null) return echoed
10 + counter += 1
11 + return `mock transcript ${counter}`
12 + },
13 + }
14 +}
15 +
16 +function decodeIfUtf8Text(buffer: Buffer): string | null {
17 + if (buffer.length === 0) return null
18 + const decoded = buffer.toString('utf8')
19 + const roundTrip = Buffer.from(decoded, 'utf8')
20 + return roundTrip.equals(buffer) ? decoded : null
21 +}
added server/providers/sttOpenai.ts +33 −0
@@ -0,0 +1,33 @@
1 +import { EmptyTranscriptError, type SttProvider } from './types'
2 +
3 +const DEFAULT_STT_MODEL = 'gpt-4o-mini-transcribe'
4 +
5 +export function createSttOpenai(): SttProvider {
6 + const apiKey = process.env.OPENAI_API_KEY
7 + if (!apiKey) throw new Error('OPENAI_API_KEY is not set')
8 + const model = process.env.STT_MODEL ?? DEFAULT_STT_MODEL
9 +
10 + return {
11 + async transcribe(audio: Buffer, mimeType: string): Promise<string> {
12 + const form = new FormData()
13 + form.append('model', model)
14 + form.append('file', new Blob([audio], { type: mimeType }), 'audio.webm')
15 +
16 + const res = await fetch('https://api.openai.com/v1/audio/transcriptions', {
17 + method: 'POST',
18 + headers: { Authorization: `Bearer ${apiKey}` },
19 + body: form,
20 + })
21 +
22 + if (!res.ok) {
23 + const body = await res.text()
24 + throw new Error(`OpenAI STT request failed (${res.status}): ${body}`)
25 + }
26 +
27 + const data = (await res.json()) as { text?: string }
28 + const text = (data.text ?? '').trim()
29 + if (text.length === 0) throw new EmptyTranscriptError()
30 + return text
31 + },
32 + }
33 +}
added server/providers/types.ts +31 −0
@@ -0,0 +1,31 @@
1 +import type { Coverage, InterviewTurn, Segment } from '../../shared/types'
2 +
3 +export class EmptyTranscriptError extends Error {
4 + constructor() {
5 + super('transcript is empty after trimming')
6 + }
7 +}
8 +
9 +export interface SttProvider {
10 + transcribe(audio: Buffer, mimeType: string): Promise<string>
11 +}
12 +
13 +export interface InterviewContext {
14 + summary: string
15 + segments: Segment[]
16 + coverage: Coverage
17 +}
18 +
19 +export const SPEC_PACK_FILES = ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md'] as const
20 +export type SpecPackFile = (typeof SPEC_PACK_FILES)[number]
21 +
22 +export interface GenerateFileRequest {
23 + file: SpecPackFile
24 + prompt: string
25 + segments: Segment[]
26 +}
27 +
28 +export interface InterviewLlm {
29 + nextTurn(context: InterviewContext): Promise<InterviewTurn>
30 + generateFile(request: GenerateFileRequest): Promise<string>
31 +}
added server/routes/audio.test.ts +106 −0
@@ -0,0 +1,106 @@
1 +import { mkdtemp, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import { CATEGORY_IDS, type AnswerResponse, type Session } from '../../shared/types'
7 +import { buildApp } from '../app'
8 +import { createLlmMock } from '../providers/llmMock'
9 +import { createSttMock } from '../providers/sttMock'
10 +import { SessionStore } from '../store/sessionStore'
11 +
12 +function buildMultipartPayload(
13 + fileBuffer: Buffer,
14 + filename: string,
15 + mimeType: string,
16 +): { body: Buffer; contentType: string } {
17 + const boundary = '----voicetaskTestBoundary1234567890'
18 + const parts = [
19 + Buffer.from(`--${boundary}\r\n`),
20 + Buffer.from(`Content-Disposition: form-data; name="audio"; filename="${filename}"\r\n`),
21 + Buffer.from(`Content-Type: ${mimeType}\r\n\r\n`),
22 + fileBuffer,
23 + Buffer.from(`\r\n--${boundary}--\r\n`),
24 + ]
25 + return { body: Buffer.concat(parts), contentType: `multipart/form-data; boundary=${boundary}` }
26 +}
27 +
28 +describe('audio route', () => {
29 + let baseDir: string
30 + let app: FastifyInstance
31 +
32 + beforeEach(async () => {
33 + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-audio-'))
34 + app = buildApp({ store: new SessionStore(baseDir), llm: createLlmMock(), stt: createSttMock() })
35 + })
36 +
37 + afterEach(async () => {
38 + await app.close()
39 + await rm(baseDir, { recursive: true, force: true })
40 + })
41 +
42 + async function createSession(): Promise<Session> {
43 + return app
44 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
45 + .then((r) => r.json<Session>())
46 + }
47 +
48 + it('transcribes an uploaded clip, stores a segment, and triggers a turn', async () => {
49 + const session = await createSession()
50 + const { body, contentType } = buildMultipartPayload(
51 + Buffer.from('we want push notifications', 'utf8'),
52 + 'clip.webm',
53 + 'text/plain',
54 + )
55 +
56 + const res = await app.inject({
57 + method: 'POST',
58 + url: `/api/sessions/${session.id}/audio`,
59 + headers: { 'content-type': contentType },
60 + payload: body,
61 + })
62 +
63 + expect(res.statusCode).toBe(200)
64 + const responseBody = res.json<AnswerResponse>()
65 + expect(responseBody.segment.speaker).toBe('user')
66 + expect(responseBody.segment.text).toBe('we want push notifications')
67 + expect(responseBody.turn.coverage[CATEGORY_IDS[0]]).toBe('clear')
68 +
69 + const reloaded = await app
70 + .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
71 + .then((r) => r.json<Session>())
72 + expect(reloaded.segments).toHaveLength(2)
73 + expect(reloaded.segments[0]).toMatchObject({ speaker: 'user', text: 'we want push notifications' })
74 + })
75 +
76 + it('rejects an empty audio upload with a 4xx and stores nothing', async () => {
77 + const session = await createSession()
78 + const { body, contentType } = buildMultipartPayload(Buffer.alloc(0), 'clip.webm', 'audio/webm')
79 +
80 + const res = await app.inject({
81 + method: 'POST',
82 + url: `/api/sessions/${session.id}/audio`,
83 + headers: { 'content-type': contentType },
84 + payload: body,
85 + })
86 +
87 + expect(res.statusCode).toBeGreaterThanOrEqual(400)
88 + expect(res.statusCode).toBeLessThan(500)
89 +
90 + const reloaded = await app
91 + .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
92 + .then((r) => r.json<Session>())
93 + expect(reloaded.segments).toHaveLength(0)
94 + })
95 +
96 + it('returns 404 for an unknown session', async () => {
97 + const { body, contentType } = buildMultipartPayload(Buffer.from('hello'), 'clip.webm', 'text/plain')
98 + const res = await app.inject({
99 + method: 'POST',
100 + url: '/api/sessions/nonexistent/audio',
101 + headers: { 'content-type': contentType },
102 + payload: body,
103 + })
104 + expect(res.statusCode).toBe(404)
105 + })
106 +})
added server/routes/audio.ts +44 −0
@@ -0,0 +1,44 @@
1 +import type { FastifyInstance } from 'fastify'
2 +import { SessionNotFoundError, submitAnswer } from '../engine/turn'
3 +import { EmptyTranscriptError, type InterviewLlm, type SttProvider } from '../providers/types'
4 +import type { SessionStore } from '../store/sessionStore'
5 +
6 +export interface AudioRouteDeps {
7 + store: SessionStore
8 + llm: InterviewLlm
9 + stt: SttProvider
10 +}
11 +
12 +export function registerAudioRoutes(app: FastifyInstance, deps: AudioRouteDeps): void {
13 + const { store, llm, stt } = deps
14 +
15 + app.post<{ Params: { id: string } }>('/api/sessions/:id/audio', async (request, reply) => {
16 + const file = await request.file()
17 + if (!file) return reply.code(400).send({ error: 'no audio file uploaded' })
18 +
19 + const buffer = await file.toBuffer()
20 + if (buffer.length === 0) {
21 + return reply.code(400).send({ error: 'empty audio upload' })
22 + }
23 +
24 + let text: string
25 + try {
26 + text = await stt.transcribe(buffer, file.mimetype)
27 + } catch (err) {
28 + if (err instanceof EmptyTranscriptError) {
29 + return reply.code(400).send({ error: 'empty transcript' })
30 + }
31 + return reply.code(502).send({ error: err instanceof Error ? err.message : 'STT provider error' })
32 + }
33 +
34 + try {
35 + const response = await submitAnswer(store, llm, request.params.id, text)
36 + return reply.send(response)
37 + } catch (err) {
38 + if (err instanceof SessionNotFoundError) {
39 + return reply.code(404).send({ error: 'session not found' })
40 + }
41 + throw err
42 + }
43 + })
44 +}
added server/routes/blockers.test.ts +118 −0
@@ -0,0 +1,118 @@
1 +import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import type { AnswerResponse, BlockersResponse, Session } from '../../shared/types'
7 +import { buildApp } from '../app'
8 +import { createLlmMock } from '../providers/llmMock'
9 +import { SessionStore } from '../store/sessionStore'
10 +
11 +const BLOCKED_MD = `# BLOCKED
12 +
13 +## B1: which auth provider
14 +- Task: T4
15 +- Question: Should sign-in use email/password or an OAuth provider?
16 +- Options considered: email/password, Google OAuth
17 +- Continued with: implemented email/password
18 +
19 +## B2: rate limit thresholds
20 +- Task: T6
21 +- Question: What is the max requests per minute per user?
22 +- Options considered: 60, 120
23 +- Continued with: skipped task
24 +`
25 +
26 +describe('blockers route', () => {
27 + let storeDir: string
28 + let targetDir: string
29 + let app: FastifyInstance
30 +
31 + beforeEach(async () => {
32 + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
33 + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-'))
34 + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() })
35 + })
36 +
37 + afterEach(async () => {
38 + await app.close()
39 + await rm(storeDir, { recursive: true, force: true })
40 + await rm(targetDir, { recursive: true, force: true })
41 + })
42 +
43 + async function createSession(): Promise<Session> {
44 + return app
45 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir } })
46 + .then((r) => r.json<Session>())
47 + }
48 +
49 + it('reports no questions and changes nothing when BLOCKED.md is missing', async () => {
50 + const session = await createSession()
51 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} })
52 + expect(res.statusCode).toBe(200)
53 + expect(res.json<BlockersResponse>()).toEqual({ questions: [] })
54 +
55 + const reloaded = await app
56 + .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
57 + .then((r) => r.json<Session>())
58 + expect(reloaded.segments).toHaveLength(0)
59 + expect(reloaded.openBlockers).toEqual([])
60 + })
61 +
62 + it('reports no questions when BLOCKED.md has no entries', async () => {
63 + const session = await createSession()
64 + await writeFile(path.join(targetDir, 'BLOCKED.md'), '# BLOCKED\n\nNo entries yet.\n', 'utf8')
65 +
66 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} })
67 + expect(res.json<BlockersResponse>()).toEqual({ questions: [] })
68 + })
69 +
70 + it('imports blockers, asks the first immediately, and queues the rest', async () => {
71 + const session = await createSession()
72 + await writeFile(path.join(targetDir, 'BLOCKED.md'), BLOCKED_MD, 'utf8')
73 +
74 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} })
75 + expect(res.statusCode).toBe(200)
76 + const body = res.json<BlockersResponse>()
77 + expect(body.questions).toEqual([
78 + 'Should sign-in use email/password or an OAuth provider?',
79 + 'What is the max requests per minute per user?',
80 + ])
81 +
82 + const afterImport = await app
83 + .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
84 + .then((r) => r.json<Session>())
85 + expect(afterImport.segments).toHaveLength(1)
86 + expect(afterImport.segments[0]).toMatchObject({
87 + speaker: 'interviewer',
88 + text: 'Should sign-in use email/password or an OAuth provider?',
89 + })
90 + expect(afterImport.openBlockers).toEqual(['What is the max requests per minute per user?'])
91 + expect(afterImport.status).toBe('interviewing')
92 + })
93 +
94 + it('asks the next queued blocker question instead of a normal coverage question', async () => {
95 + const session = await createSession()
96 + await writeFile(path.join(targetDir, 'BLOCKED.md'), BLOCKED_MD, 'utf8')
97 + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/blockers`, payload: {} })
98 +
99 + const res = await app.inject({
100 + method: 'POST',
101 + url: `/api/sessions/${session.id}/answer`,
102 + payload: { text: 'email/password' },
103 + })
104 + const body = res.json<AnswerResponse>()
105 + expect(body.turn.nextQuestion).toBe('What is the max requests per minute per user?')
106 + expect(body.turn.done).toBe(false)
107 +
108 + const reloaded = await app
109 + .inject({ method: 'GET', url: `/api/sessions/${session.id}` })
110 + .then((r) => r.json<Session>())
111 + expect(reloaded.openBlockers).toEqual([])
112 + })
113 +
114 + it('returns 404 for an unknown session', async () => {
115 + const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/blockers', payload: {} })
116 + expect(res.statusCode).toBe(404)
117 + })
118 +})
added server/routes/blockers.ts +42 −0
@@ -0,0 +1,42 @@
1 +import { readFile } from 'node:fs/promises'
2 +import path from 'node:path'
3 +import type { FastifyInstance } from 'fastify'
4 +import type { BlockersResponse } from '../../shared/types'
5 +import { parseBlockedFile } from '../blockers/parse'
6 +import type { SessionStore } from '../store/sessionStore'
7 +
8 +export interface BlockerRouteDeps {
9 + store: SessionStore
10 +}
11 +
12 +export function registerBlockerRoutes(app: FastifyInstance, deps: BlockerRouteDeps): void {
13 + const { store } = deps
14 +
15 + app.post<{ Params: { id: string } }>('/api/sessions/:id/blockers', async (request, reply) => {
16 + const session = await store.getSession(request.params.id)
17 + if (!session) return reply.code(404).send({ error: 'session not found' })
18 +
19 + const filePath = path.join(session.targetDir, 'BLOCKED.md')
20 + const content = await readFile(filePath, 'utf8').catch(() => null)
21 + if (content === null) {
22 + const response: BlockersResponse = { questions: [] }
23 + return reply.send(response)
24 + }
25 +
26 + const questions = parseBlockedFile(content)
27 + .map((entry) => entry.question)
28 + .filter((question) => question.length > 0)
29 +
30 + if (questions.length === 0) {
31 + const response: BlockersResponse = { questions: [] }
32 + return reply.send(response)
33 + }
34 +
35 + const [firstQuestion, ...remaining] = questions
36 + await store.appendSegment(request.params.id, 'interviewer', firstQuestion)
37 + await store.updateTurn(request.params.id, { openBlockers: remaining, status: 'interviewing' })
38 +
39 + const response: BlockersResponse = { questions }
40 + return reply.send(response)
41 + })
42 +}
added server/routes/generate.test.ts +85 −0
@@ -0,0 +1,85 @@
1 +import { mkdtemp, readdir, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import type { GenerateResponse, Session } from '../../shared/types'
7 +import { buildApp } from '../app'
8 +import { createLlmMock } from '../providers/llmMock'
9 +import { SessionStore } from '../store/sessionStore'
10 +
11 +describe('generate route', () => {
12 + let storeDir: string
13 + let targetDir: string
14 + let app: FastifyInstance
15 +
16 + beforeEach(async () => {
17 + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
18 + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-target-'))
19 + app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() })
20 + })
21 +
22 + afterEach(async () => {
23 + await app.close()
24 + await rm(storeDir, { recursive: true, force: true })
25 + await rm(targetDir, { recursive: true, force: true })
26 + })
27 +
28 + async function createSessionWithAnAnswer(): Promise<Session> {
29 + const created = await app
30 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir } })
31 + .then((r) => r.json<Session>())
32 + await app.inject({
33 + method: 'POST',
34 + url: `/api/sessions/${created.id}/answer`,
35 + payload: { text: 'we are building a todo app' },
36 + })
37 + return app.inject({ method: 'GET', url: `/api/sessions/${created.id}` }).then((r) => r.json<Session>())
38 + }
39 +
40 + it('generates the six spec pack files', async () => {
41 + const session = await createSessionWithAnAnswer()
42 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
43 + expect(res.statusCode).toBe(200)
44 + const body = res.json<GenerateResponse>()
45 + expect(body.files).toHaveLength(6)
46 + expect(body.files).toContain('spec/SPEC.md')
47 + })
48 +
49 + it('returns 409 on a second generate without overwrite', async () => {
50 + const session = await createSessionWithAnAnswer()
51 + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
52 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
53 + expect(res.statusCode).toBe(409)
54 + })
55 +
56 + it('regenerates and creates a backup dir when overwrite is set', async () => {
57 + const session = await createSessionWithAnAnswer()
58 + await app.inject({ method: 'POST', url: `/api/sessions/${session.id}/generate`, payload: {} })
59 + const res = await app.inject({
60 + method: 'POST',
61 + url: `/api/sessions/${session.id}/generate`,
62 + payload: { overwrite: true },
63 + })
64 + expect(res.statusCode).toBe(200)
65 + const entries = await readdir(path.join(targetDir, 'spec'))
66 + expect(entries.some((e) => e.startsWith('backup-'))).toBe(true)
67 + })
68 +
69 + it('returns 404 for an unknown session', async () => {
70 + const res = await app.inject({ method: 'POST', url: '/api/sessions/nonexistent/generate', payload: {} })
71 + expect(res.statusCode).toBe(404)
72 + })
73 +
74 + it('returns 400 when the target directory does not exist', async () => {
75 + const created = await app
76 + .inject({
77 + method: 'POST',
78 + url: '/api/sessions',
79 + payload: { name: 'p', targetDir: path.join(targetDir, 'does-not-exist') },
80 + })
81 + .then((r) => r.json<Session>())
82 + const res = await app.inject({ method: 'POST', url: `/api/sessions/${created.id}/generate`, payload: {} })
83 + expect(res.statusCode).toBe(400)
84 + })
85 +})
added server/routes/generate.ts +34 −0
@@ -0,0 +1,34 @@
1 +import type { FastifyInstance } from 'fastify'
2 +import { GenerateRequestSchema } from '../../shared/types'
3 +import { GenerateFilesExistError, generateSpecPack } from '../generator/generate'
4 +import type { InterviewLlm } from '../providers/types'
5 +import type { SessionStore } from '../store/sessionStore'
6 +
7 +export interface GenerateRouteDeps {
8 + store: SessionStore
9 + llm: InterviewLlm
10 +}
11 +
12 +export function registerGenerateRoutes(app: FastifyInstance, deps: GenerateRouteDeps): void {
13 + const { store, llm } = deps
14 +
15 + app.post<{ Params: { id: string } }>('/api/sessions/:id/generate', async (request, reply) => {
16 + const parsed = GenerateRequestSchema.safeParse(request.body ?? {})
17 + if (!parsed.success) {
18 + return reply.code(400).send({ error: parsed.error.message })
19 + }
20 +
21 + const session = await store.getSession(request.params.id)
22 + if (!session) return reply.code(404).send({ error: 'session not found' })
23 +
24 + try {
25 + const result = await generateSpecPack(llm, session, { overwrite: parsed.data.overwrite })
26 + return reply.send(result)
27 + } catch (err) {
28 + if (err instanceof GenerateFilesExistError) {
29 + return reply.code(409).send({ error: err.message })
30 + }
31 + return reply.code(400).send({ error: err instanceof Error ? err.message : 'generation failed' })
32 + }
33 + })
34 +}
added server/routes/sessions.test.ts +110 −0
@@ -0,0 +1,110 @@
1 +import { mkdtemp, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import type { FastifyInstance } from 'fastify'
5 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
6 +import { CATEGORY_IDS, type AnswerResponse, type Session } from '../../shared/types'
7 +import { createLlmMock } from '../providers/llmMock'
8 +import { SessionStore } from '../store/sessionStore'
9 +import { buildApp } from '../app'
10 +
11 +describe('session routes', () => {
12 + let baseDir: string
13 + let app: FastifyInstance
14 +
15 + beforeEach(async () => {
16 + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-routes-'))
17 + app = buildApp({ store: new SessionStore(baseDir), llm: createLlmMock() })
18 + })
19 +
20 + afterEach(async () => {
21 + await app.close()
22 + await rm(baseDir, { recursive: true, force: true })
23 + })
24 +
25 + it('creates a session', async () => {
26 + const res = await app.inject({
27 + method: 'POST',
28 + url: '/api/sessions',
29 + payload: { name: 'my project', targetDir: '/tmp/target' },
30 + })
31 + expect(res.statusCode).toBe(201)
32 + const session = res.json<Session>()
33 + expect(session.name).toBe('my project')
34 + expect(session.status).toBe('interviewing')
35 + })
36 +
37 + it('lists and fetches sessions', async () => {
38 + const created = await app
39 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
40 + .then((r) => r.json<Session>())
41 +
42 + const list = await app.inject({ method: 'GET', url: '/api/sessions' })
43 + expect(list.json()).toEqual([{ id: created.id, name: 'p', status: 'interviewing' }])
44 +
45 + const fetched = await app.inject({ method: 'GET', url: `/api/sessions/${created.id}` })
46 + expect(fetched.json<Session>()).toEqual(created)
47 +
48 + const missing = await app.inject({ method: 'GET', url: '/api/sessions/nonexistent' })
49 + expect(missing.statusCode).toBe(404)
50 + })
51 +
52 + it('answering produces a user segment, an interviewer segment, and a coverage update', async () => {
53 + const created = await app
54 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
55 + .then((r) => r.json<Session>())
56 +
57 + const res = await app.inject({
58 + method: 'POST',
59 + url: `/api/sessions/${created.id}/answer`,
60 + payload: { text: 'we are building a todo app' },
61 + })
62 + expect(res.statusCode).toBe(200)
63 + const body = res.json<AnswerResponse>()
64 + expect(body.segment.speaker).toBe('user')
65 + expect(body.segment.text).toBe('we are building a todo app')
66 + expect(body.turn.coverage[CATEGORY_IDS[0]]).toBe('clear')
67 + expect(body.turn.done).toBe(false)
68 +
69 + const session = await app
70 + .inject({ method: 'GET', url: `/api/sessions/${created.id}` })
71 + .then((r) => r.json<Session>())
72 + expect(session.segments).toHaveLength(2)
73 + expect(session.segments[0]).toMatchObject({ id: 'S1', speaker: 'user' })
74 + expect(session.segments[1]).toMatchObject({ id: 'S2', speaker: 'interviewer' })
75 + expect(session.coverage).toEqual(body.turn.coverage)
76 + expect(session.status).toBe('interviewing')
77 + })
78 +
79 + it('ends the session when the answer is exactly "done"', async () => {
80 + const created = await app
81 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
82 + .then((r) => r.json<Session>())
83 +
84 + const res = await app.inject({
85 + method: 'POST',
86 + url: `/api/sessions/${created.id}/answer`,
87 + payload: { text: 'done' },
88 + })
89 + const body = res.json<AnswerResponse>()
90 + expect(body.turn.done).toBe(true)
91 +
92 + const session = await app
93 + .inject({ method: 'GET', url: `/api/sessions/${created.id}` })
94 + .then((r) => r.json<Session>())
95 + expect(session.status).toBe('done')
96 + })
97 +
98 + it('rejects invalid answer payloads', async () => {
99 + const created = await app
100 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'p', targetDir: '/tmp/p' } })
101 + .then((r) => r.json<Session>())
102 +
103 + const res = await app.inject({
104 + method: 'POST',
105 + url: `/api/sessions/${created.id}/answer`,
106 + payload: {},
107 + })
108 + expect(res.statusCode).toBe(400)
109 + })
110 +})
added server/routes/sessions.ts +50 −0
@@ -0,0 +1,50 @@
1 +import type { FastifyInstance } from 'fastify'
2 +import { AnswerRequestSchema, CreateSessionRequestSchema } from '../../shared/types'
3 +import { SessionNotFoundError, submitAnswer } from '../engine/turn'
4 +import type { InterviewLlm } from '../providers/types'
5 +import type { SessionStore } from '../store/sessionStore'
6 +
7 +export interface SessionRouteDeps {
8 + store: SessionStore
9 + llm: InterviewLlm
10 +}
11 +
12 +export function registerSessionRoutes(app: FastifyInstance, deps: SessionRouteDeps): void {
13 + const { store, llm } = deps
14 +
15 + app.post('/api/sessions', async (request, reply) => {
16 + const parsed = CreateSessionRequestSchema.safeParse(request.body)
17 + if (!parsed.success) {
18 + return reply.code(400).send({ error: parsed.error.message })
19 + }
20 + const session = await store.createSession(parsed.data.name, parsed.data.targetDir)
21 + return reply.code(201).send(session)
22 + })
23 +
24 + app.get('/api/sessions', async () => {
25 + return store.listSessions()
26 + })
27 +
28 + app.get<{ Params: { id: string } }>('/api/sessions/:id', async (request, reply) => {
29 + const session = await store.getSession(request.params.id)
30 + if (!session) return reply.code(404).send({ error: 'session not found' })
31 + return session
32 + })
33 +
34 + app.post<{ Params: { id: string } }>('/api/sessions/:id/answer', async (request, reply) => {
35 + const parsed = AnswerRequestSchema.safeParse(request.body)
36 + if (!parsed.success) {
37 + return reply.code(400).send({ error: parsed.error.message })
38 + }
39 +
40 + try {
41 + const response = await submitAnswer(store, llm, request.params.id, parsed.data.text)
42 + return reply.send(response)
43 + } catch (err) {
44 + if (err instanceof SessionNotFoundError) {
45 + return reply.code(404).send({ error: 'session not found' })
46 + }
47 + throw err
48 + }
49 + })
50 +}
added server/smoke.test.ts +81 −0
@@ -0,0 +1,81 @@
1 +import { mkdtemp, readFile, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import { afterAll, beforeAll, describe, expect, it } from 'vitest'
5 +import { CATEGORY_IDS, type AnswerResponse, type GenerateResponse, type Session } from '../shared/types'
6 +import { buildApp } from './app'
7 +import { createLlmMock } from './providers/llmMock'
8 +import { SessionStore } from './store/sessionStore'
9 +
10 +describe('end-to-end smoke test', () => {
11 + let storeDir: string
12 + let targetDir: string
13 +
14 + beforeAll(async () => {
15 + storeDir = await mkdtemp(path.join(tmpdir(), 'voicetask-smoke-store-'))
16 + targetDir = await mkdtemp(path.join(tmpdir(), 'voicetask-smoke-target-'))
17 + })
18 +
19 + afterAll(async () => {
20 + await rm(storeDir, { recursive: true, force: true })
21 + await rm(targetDir, { recursive: true, force: true })
22 + })
23 +
24 + it('interviews to completion with mocks and generates a valid spec pack', async () => {
25 + const app = buildApp({ store: new SessionStore(storeDir), llm: createLlmMock() })
26 +
27 + const session = await app
28 + .inject({ method: 'POST', url: '/api/sessions', payload: { name: 'smoke project', targetDir } })
29 + .then((r) => r.json<Session>())
30 +
31 + let done = false
32 + for (let i = 0; i < CATEGORY_IDS.length && !done; i++) {
33 + const res = await app.inject({
34 + method: 'POST',
35 + url: `/api/sessions/${session.id}/answer`,
36 + payload: { text: `answer number ${i + 1}` },
37 + })
38 + expect(res.statusCode).toBe(200)
39 + done = res.json<AnswerResponse>().turn.done
40 + }
41 + expect(done).toBe(true)
42 +
43 + const generateRes = await app.inject({
44 + method: 'POST',
45 + url: `/api/sessions/${session.id}/generate`,
46 + payload: {},
47 + })
48 + expect(generateRes.statusCode).toBe(200)
49 + const generated = generateRes.json<GenerateResponse>()
50 +
51 + const expectedFiles = [
52 + 'spec/SPEC.md',
53 + 'spec/PLAN.md',
54 + 'spec/TASKS.md',
55 + 'spec/VERIFICATION.md',
56 + 'spec/HANDOFF.md',
57 + 'spec/sources.json',
58 + ]
59 + expect(generated.files.sort()).toEqual([...expectedFiles].sort())
60 +
61 + const sources = JSON.parse(await readFile(path.join(targetDir, 'spec/sources.json'), 'utf8')) as Record<
62 + string,
63 + { text: string; ts: string }
64 + >
65 +
66 + const markerFiles = ['SPEC.md', 'PLAN.md', 'TASKS.md', 'VERIFICATION.md', 'HANDOFF.md']
67 + for (const file of markerFiles) {
68 + const content = await readFile(path.join(targetDir, 'spec', file), 'utf8')
69 + const markers = [...content.matchAll(/\[S(\d+)\]/g)]
70 + for (const marker of markers) {
71 + const segmentId = `S${marker[1]}`
72 + expect(sources).toHaveProperty(segmentId)
73 + }
74 + }
75 +
76 + const handoff = await readFile(path.join(targetDir, 'spec/HANDOFF.md'), 'utf8')
77 + expect(handoff).toContain('claude')
78 +
79 + await app.close()
80 + })
81 +})
added server/store/sessionStore.test.ts +82 −0
@@ -0,0 +1,82 @@
1 +import { mkdtemp, rm } from 'node:fs/promises'
2 +import { tmpdir } from 'node:os'
3 +import path from 'node:path'
4 +import { afterEach, beforeEach, describe, expect, it } from 'vitest'
5 +import { initialCoverage } from '../../shared/types'
6 +import { SessionStore } from './sessionStore'
7 +
8 +describe('SessionStore', () => {
9 + let baseDir: string
10 + let store: SessionStore
11 +
12 + beforeEach(async () => {
13 + baseDir = await mkdtemp(path.join(tmpdir(), 'voicetask-store-'))
14 + store = new SessionStore(baseDir)
15 + })
16 +
17 + afterEach(async () => {
18 + await rm(baseDir, { recursive: true, force: true })
19 + })
20 +
21 + it('creates a session and reloads it with matching data', async () => {
22 + const created = await store.createSession('my project', '/tmp/target')
23 + expect(created.name).toBe('my project')
24 + expect(created.targetDir).toBe('/tmp/target')
25 + expect(created.status).toBe('interviewing')
26 + expect(created.coverage).toEqual(initialCoverage())
27 +
28 + const reloaded = await store.getSession(created.id)
29 + expect(reloaded).toEqual(created)
30 + })
31 +
32 + it('returns null for an unknown session id', async () => {
33 + expect(await store.getSession('does-not-exist')).toBeNull()
34 + })
35 +
36 + it('assigns sequential segment ids S1..Sn', async () => {
37 + const session = await store.createSession('proj', '/tmp/target')
38 + const first = await store.appendSegment(session.id, 'user', 'hello')
39 + expect(first.segment.id).toBe('S1')
40 + const second = await store.appendSegment(session.id, 'interviewer', 'what next?')
41 + expect(second.segment.id).toBe('S2')
42 + const third = await store.appendSegment(session.id, 'user', 'more')
43 + expect(third.segment.id).toBe('S3')
44 +
45 + const reloaded = await store.getSession(session.id)
46 + expect(reloaded?.segments.map((s) => s.id)).toEqual(['S1', 'S2', 'S3'])
47 + expect(reloaded?.segments.map((s) => s.text)).toEqual(['hello', 'what next?', 'more'])
48 + })
49 +
50 + it('updates coverage, summary, and status', async () => {
51 + const session = await store.createSession('proj', '/tmp/target')
52 + const coverage = { ...initialCoverage(), goal: 'clear' as const }
53 + const updated = await store.updateTurn(session.id, {
54 + coverage,
55 + summary: 'a running summary',
56 + status: 'done',
57 + })
58 + expect(updated.coverage).toEqual(coverage)
59 + expect(updated.summary).toBe('a running summary')
60 + expect(updated.status).toBe('done')
61 + })
62 +
63 + it('lists sessions with id, name, status only', async () => {
64 + const a = await store.createSession('alpha', '/tmp/a')
65 + const b = await store.createSession('beta', '/tmp/b')
66 + await store.updateTurn(b.id, { status: 'done' })
67 +
68 + const list = await store.listSessions()
69 + expect(list).toHaveLength(2)
70 + expect(list).toEqual(
71 + expect.arrayContaining([
72 + { id: a.id, name: 'alpha', status: 'interviewing' },
73 + { id: b.id, name: 'beta', status: 'done' },
74 + ]),
75 + )
76 + })
77 +
78 + it('lists no sessions when the base directory does not exist yet', async () => {
79 + const emptyStore = new SessionStore(path.join(baseDir, 'nonexistent'))
80 + expect(await emptyStore.listSessions()).toEqual([])
81 + })
82 +})
added server/store/sessionStore.ts +122 −0
@@ -0,0 +1,122 @@
1 +import { randomUUID } from 'node:crypto'
2 +import { mkdir, readdir, readFile, rename, writeFile } from 'node:fs/promises'
3 +import path from 'node:path'
4 +import {
5 + SessionSchema,
6 + initialCoverage,
7 + type Coverage,
8 + type Segment,
9 + type Session,
10 + type SessionStatus,
11 + type SessionSummary,
12 + type Speaker,
13 +} from '../../shared/types'
14 +
15 +export class SessionStore {
16 + constructor(private readonly baseDir: string) {}
17 +
18 + private sessionDir(id: string): string {
19 + return path.join(this.baseDir, id)
20 + }
21 +
22 + private sessionFile(id: string): string {
23 + return path.join(this.sessionDir(id), 'session.json')
24 + }
25 +
26 + private async writeSession(session: Session): Promise<void> {
27 + const dir = this.sessionDir(session.id)
28 + await mkdir(dir, { recursive: true })
29 + const file = this.sessionFile(session.id)
30 + const tmp = path.join(dir, `.session-${randomUUID()}.tmp`)
31 + await writeFile(tmp, JSON.stringify(session, null, 2), 'utf8')
32 + await rename(tmp, file)
33 + }
34 +
35 + async createSession(name: string, targetDir: string): Promise<Session> {
36 + const session: Session = {
37 + id: randomUUID(),
38 + name,
39 + targetDir,
40 + createdAt: new Date().toISOString(),
41 + segments: [],
42 + coverage: initialCoverage(),
43 + summary: '',
44 + status: 'interviewing',
45 + openBlockers: [],
46 + }
47 + await this.writeSession(session)
48 + return session
49 + }
50 +
51 + async getSession(id: string): Promise<Session | null> {
52 + try {
53 + const raw = await readFile(this.sessionFile(id), 'utf8')
54 + return SessionSchema.parse(JSON.parse(raw))
55 + } catch (err) {
56 + if (isNotFound(err)) return null
57 + throw err
58 + }
59 + }
60 +
61 + async listSessions(): Promise<SessionSummary[]> {
62 + let entries: string[]
63 + try {
64 + entries = await readdir(this.baseDir)
65 + } catch (err) {
66 + if (isNotFound(err)) return []
67 + throw err
68 + }
69 + const summaries: SessionSummary[] = []
70 + for (const id of entries) {
71 + const session = await this.getSession(id)
72 + if (session) summaries.push({ id: session.id, name: session.name, status: session.status })
73 + }
74 + return summaries
75 + }
76 +
77 + async appendSegment(
78 + id: string,
79 + speaker: Speaker,
80 + text: string,
81 + ): Promise<{ session: Session; segment: Segment }> {
82 + const session = await this.getSession(id)
83 + if (!session) throw new Error(`session not found: ${id}`)
84 + const segment: Segment = {
85 + id: `S${session.segments.length + 1}`,
86 + ts: new Date().toISOString(),
87 + speaker,
88 + text,
89 + }
90 + session.segments.push(segment)
91 + await this.writeSession(session)
92 + return { session, segment }
93 + }
94 +
95 + async updateTurn(
96 + id: string,
97 + update: {
98 + coverage?: Coverage
99 + summary?: string
100 + status?: SessionStatus
101 + openBlockers?: string[]
102 + },
103 + ): Promise<Session> {
104 + const session = await this.getSession(id)
105 + if (!session) throw new Error(`session not found: ${id}`)
106 + if (update.coverage) session.coverage = update.coverage
107 + if (update.summary !== undefined) session.summary = update.summary
108 + if (update.status) session.status = update.status
109 + if (update.openBlockers) session.openBlockers = update.openBlockers
110 + await this.writeSession(session)
111 + return session
112 + }
113 +}
114 +
115 +function isNotFound(err: unknown): boolean {
116 + return typeof err === 'object' && err !== null && 'code' in err && (err as { code?: string }).code === 'ENOENT'
117 +}
118 +
119 +export function createDefaultSessionStore(): SessionStore {
120 + const baseDir = process.env.DATA_DIR ?? path.join(process.cwd(), 'data', 'sessions')
121 + return new SessionStore(baseDir)
122 +}
added server/tsconfig.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "extends": "../tsconfig.base.json",
3 + "compilerOptions": {
4 + "module": "ESNext",
5 + "moduleResolution": "bundler",
6 + "lib": ["ES2022"],
7 + "types": ["node"],
8 + "outDir": "../dist/server",
9 + "rootDir": "..",
10 + "noEmit": false
11 + },
12 + "include": ["**/*.ts", "../shared/**/*.ts"],
13 + "exclude": ["**/*.test.ts", "../shared/**/*.test.ts"]
14 +}
added shared/index.ts +1 −0
@@ -0,0 +1 @@
1 +export * from './types'
added shared/tsconfig.json +10 −0
@@ -0,0 +1,10 @@
1 +{
2 + "extends": "../tsconfig.base.json",
3 + "compilerOptions": {
4 + "module": "ESNext",
5 + "lib": ["ES2022"],
6 + "noEmit": true,
7 + "types": ["node"]
8 + },
9 + "include": ["**/*.ts"]
10 +}
added shared/types.ts +114 −0
@@ -0,0 +1,114 @@
1 +import { z } from 'zod'
2 +
3 +export const CATEGORY_IDS = [
4 + 'goal',
5 + 'users',
6 + 'core-flow',
7 + 'data',
8 + 'integrations',
9 + 'edge-cases',
10 + 'constraints',
11 + 'non-goals',
12 + 'verification',
13 +] as const
14 +
15 +export type CategoryId = (typeof CATEGORY_IDS)[number]
16 +
17 +export const CoverageLevelSchema = z.enum(['missing', 'partial', 'clear'])
18 +export type CoverageLevel = z.infer<typeof CoverageLevelSchema>
19 +
20 +const coverageShape = Object.fromEntries(
21 + CATEGORY_IDS.map((category) => [category, CoverageLevelSchema]),
22 +) as Record<CategoryId, typeof CoverageLevelSchema>
23 +
24 +export const CoverageSchema = z.object(coverageShape)
25 +export type Coverage = z.infer<typeof CoverageSchema>
26 +
27 +export function initialCoverage(): Coverage {
28 + return Object.fromEntries(CATEGORY_IDS.map((category) => [category, 'missing'])) as Coverage
29 +}
30 +
31 +export const SpeakerSchema = z.enum(['user', 'interviewer'])
32 +export type Speaker = z.infer<typeof SpeakerSchema>
33 +
34 +export const SegmentSchema = z.object({
35 + id: z.string(),
36 + ts: z.string(),
37 + speaker: SpeakerSchema,
38 + text: z.string(),
39 +})
40 +export type Segment = z.infer<typeof SegmentSchema>
41 +
42 +export const SessionStatusSchema = z.enum(['interviewing', 'done'])
43 +export type SessionStatus = z.infer<typeof SessionStatusSchema>
44 +
45 +export const SessionSchema = z.object({
46 + id: z.string(),
47 + name: z.string(),
48 + targetDir: z.string(),
49 + createdAt: z.string(),
50 + segments: z.array(SegmentSchema),
51 + coverage: CoverageSchema,
52 + summary: z.string(),
53 + status: SessionStatusSchema,
54 + openBlockers: z.array(z.string()),
55 +})
56 +export type Session = z.infer<typeof SessionSchema>
57 +
58 +export const SessionSummarySchema = SessionSchema.pick({ id: true, name: true, status: true })
59 +export type SessionSummary = z.infer<typeof SessionSummarySchema>
60 +
61 +export const ContradictionSchema = z.object({
62 + segmentIds: z.array(z.string()),
63 + description: z.string(),
64 +})
65 +export type Contradiction = z.infer<typeof ContradictionSchema>
66 +
67 +export const InterviewTurnSchema = z.object({
68 + coverage: CoverageSchema,
69 + nextQuestion: z.string(),
70 + contradiction: ContradictionSchema.nullable(),
71 + done: z.boolean(),
72 + summaryUpdate: z.string().nullable(),
73 +})
74 +export type InterviewTurn = z.infer<typeof InterviewTurnSchema>
75 +
76 +// API payloads
77 +
78 +export const CreateSessionRequestSchema = z.object({
79 + name: z.string().min(1),
80 + targetDir: z.string().min(1),
81 +})
82 +export type CreateSessionRequest = z.infer<typeof CreateSessionRequestSchema>
83 +
84 +export const AnswerRequestSchema = z.object({
85 + text: z.string().min(1),
86 +})
87 +export type AnswerRequest = z.infer<typeof AnswerRequestSchema>
88 +
89 +export const AnswerResponseSchema = z.object({
90 + segment: SegmentSchema,
91 + turn: InterviewTurnSchema,
92 +})
93 +export type AnswerResponse = z.infer<typeof AnswerResponseSchema>
94 +
95 +export const GenerateRequestSchema = z.object({
96 + overwrite: z.boolean().optional(),
97 +})
98 +export type GenerateRequest = z.infer<typeof GenerateRequestSchema>
99 +
100 +export const GenerateResponseSchema = z.object({
101 + files: z.array(z.string()),
102 + warnings: z.array(z.string()),
103 +})
104 +export type GenerateResponse = z.infer<typeof GenerateResponseSchema>
105 +
106 +export const BlockersResponseSchema = z.object({
107 + questions: z.array(z.string()),
108 +})
109 +export type BlockersResponse = z.infer<typeof BlockersResponseSchema>
110 +
111 +export const ErrorResponseSchema = z.object({
112 + error: z.string(),
113 +})
114 +export type ErrorResponse = z.infer<typeof ErrorResponseSchema>
modified spec/TASKS.md +12 −12
@@ -2,62 +2,62 @@
2 2
3 3 Work strictly in order unless a task's Depends line allows otherwise. One task at a time. Run the Verify commands before checking a task off. All tests run with `MOCK_PROVIDERS=1` (vitest config sets it globally).
4 4
5 -- [ ] T1 Scaffold
5 +- [x] T1 Scaffold
6 6 - npm package with `server/`, `client/` (Vite React TS template), `shared/`. Scripts: `dev` (server + Vite concurrently), `build`, `typecheck` (tsc --noEmit over all three), `test` (vitest run). Add zod, fastify + plugins, @anthropic-ai/sdk, vitest. One placeholder test that asserts true.
7 7 - Depends: nothing
8 8 - Verify: `npm run typecheck` and `npm test` and `npm run build` all exit 0.
9 9
10 -- [ ] T2 Shared types and session store
10 +- [x] T2 Shared types and session store
11 11 - Implement `shared/` types + zod schemas from PLAN.md. Implement `server/store/sessionStore.ts`: create, get, list, append segment, update coverage/summary/status, persisted to `data/sessions/<id>/session.json` atomically (write temp file, rename).
12 12 - Depends: T1
13 13 - Verify: `npm test` (store unit tests: create/reload roundtrip, sequential segment ids S1..Sn, list).
14 14
15 -- [ ] T3 Provider interfaces, factory, mocks
15 +- [x] T3 Provider interfaces, factory, mocks
16 16 - `server/providers/types.ts`, `factory.ts`, `sttMock.ts`, `llmMock.ts` exactly as specified in PLAN.md (including the echo-text behavior of sttMock and the deterministic interview script and bad-marker SPEC.md of llmMock).
17 17 - Depends: T2
18 18 - Verify: `npm test` (mock behavior tests: echo, turn script reaches done, factory returns mocks under MOCK_PROVIDERS=1).
19 19
20 -- [ ] T4 Session and answer routes
20 +- [x] T4 Session and answer routes
21 21 - `POST /api/sessions`, `GET /api/sessions`, `GET /api/sessions/:id`, `POST /api/sessions/:id/answer` wired to store + interview engine stub that calls the InterviewLlm provider. "done" detection per SPEC (exact match, case-insensitive, trimmed).
22 22 - Depends: T3
23 23 - Verify: `npm test` (route tests via fastify.inject: create, answer produces interviewer segment + coverage update, done ends session).
24 24
25 -- [ ] T5 Interview engine
25 +- [x] T5 Interview engine
26 26 - `server/engine/`: coverage state handling, prompt construction (summary + last 40 segments), summary maintenance, contradiction passthrough from the LLM response, weakest-category question selection enforced in the prompt. Anthropic implementation `llmAnthropic.ts` per PLAN.md (compiles and is unit-tested for prompt construction only; no network in tests).
27 27 - Depends: T4
28 28 - Verify: `npm test` (engine tests with llmMock: coverage progresses in category order, >40 segments triggers summary path, contradiction from mock is surfaced).
29 29
30 -- [ ] T6 Audio route and OpenAI STT
30 +- [x] T6 Audio route and OpenAI STT
31 31 - `@fastify/multipart` upload route `POST /api/sessions/:id/audio`, `sttOpenai.ts` per PLAN.md, empty-transcript rejection, provider errors mapped to 502 with `{error}`.
32 32 - Depends: T4
33 33 - Verify: `npm test` (audio route with sttMock: uploaded text buffer becomes a segment and triggers a turn; empty buffer returns 4xx and stores nothing).
34 34
35 -- [ ] T7 Client: interview UI
35 +- [x] T7 Client: interview UI
36 36 - Session list/create screen, interview screen with Transcript, QuestionCard, CoveragePanel, text input, Done button. Typed api.ts wrappers. Vite dev proxy to the server.
37 37 - Depends: T4 (T5 makes it meaningful, but the API contract is enough to build against)
38 38 - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: `npm run dev` with MOCK_PROVIDERS=1, typing answers advances coverage in the panel.
39 39
40 -- [ ] T8 Client: push-to-talk and TTS
40 +- [x] T8 Client: push-to-talk and TTS
41 41 - MediaRecorder hold-to-record button (spacebar and mouse), upload to the audio route, recording state indicator. TTS toggle speaking each new interviewer question via speechSynthesis.
42 42 - Depends: T6, T7
43 43 - Verify: `npm run typecheck` and `npm run build` exit 0. Manual: recording in Chrome produces a segment (mock mode).
44 44
45 -- [ ] T9 Spec pack generator
45 +- [x] T9 Spec pack generator
46 46 - `server/generator/`: five per-file prompts, generation orchestration through the InterviewLlm provider, `sources.json` emission, provenance validation per FR-013, existing-file refusal + `overwrite` flag + backup to `spec/backup-<timestamp>/` on regenerate.
47 47 - Depends: T5
48 48 - Verify: `npm test` (with llmMock into a temp dir: all six files written; bogus `[S999]` marker removed and `[unverified]` appended; second run without overwrite fails; with overwrite creates backup dir).
49 49
50 -- [ ] T10 Generate route and UI
50 +- [x] T10 Generate route and UI
51 51 - `POST /api/sessions/:id/generate`, GeneratePanel with missing-category confirmation dialog per FR-008, result/warnings display.
52 52 - Depends: T7, T9
53 53 - Verify: `npm test` (route test) and `npm run typecheck`.
54 54
55 -- [ ] T11 Blocker import
55 +- [x] T11 Blocker import
56 56 - `server/blockers/parse.ts` for the BLOCKED.md format defined in the repo root `BLOCKED.md` template, `POST /api/sessions/:id/blockers`, engine mode that asks only imported questions, UI entry point. Missing/empty file handled per SPEC edge case.
57 57 - Depends: T5, T7
58 58 - Verify: `npm test` (parser tests incl. empty file; route test: import then next turn asks a blocker question).
59 59
60 -- [ ] T12 End-to-end smoke test
60 +- [x] T12 End-to-end smoke test
61 61 - Single vitest test: boot the server with mocks, create session (temp target dir), answer until done via the answer route, generate, assert all six files exist, all remaining `[S<n>]` markers resolve against sources.json, and HANDOFF.md contains the string `claude`.
62 62 - Depends: T9, T10
63 63 - Verify: `npm test` runs it green; then `npm run typecheck`, `npm test`, `npm run build` all exit 0 as the final full check.
added tsconfig.base.json +14 −0
@@ -0,0 +1,14 @@
1 +{
2 + "compilerOptions": {
3 + "strict": true,
4 + "target": "ES2022",
5 + "moduleResolution": "bundler",
6 + "esModuleInterop": true,
7 + "forceConsistentCasingInFileNames": true,
8 + "skipLibCheck": true,
9 + "resolveJsonModule": true,
10 + "noUnusedLocals": true,
11 + "noUnusedParameters": true,
12 + "noFallthroughCasesInSwitch": true
13 + }
14 +}
added vitest.config.ts +10 −0
@@ -0,0 +1,10 @@
1 +import { defineConfig } from 'vitest/config'
2 +
3 +process.env.MOCK_PROVIDERS = '1'
4 +
5 +export default defineConfig({
6 + test: {
7 + environment: 'node',
8 + include: ['server/**/*.test.ts', 'shared/**/*.test.ts'],
9 + },
10 +})