profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

T11: blocker import

blockers/parse.ts parses the BLOCKED.md entry format from the repo
root template. POST /api/sessions/:id/blockers reads BLOCKED.md from
the session's target dir, is a no-op when the file is missing or has
no entries, and otherwise immediately asks the first blocker question
and queues the rest in session.openBlockers. submitAnswer() now serves
queued blocker questions directly instead of calling the LLM, falling
back to the normal coverage-driven flow once they run out; the
exact-match "done" rule still takes priority. BlockerImport gives the
client an entry point once an interview is done, and resuming answers
flips status back to "interviewing".

Manually verified in a headless browser: importing a two-entry
BLOCKED.md resumes the interview with the first question, and
answering it surfaces the second.
commit 0791dd9

11 changed files with +379 and −4

Jump to a changed file
  1. client/src/App.css +27 −0
  2. client/src/App.tsx +2 −0
  3. client/src/api.ts +8 −0
  4. client/src/components/BlockerImport.tsx +46 −0
  5. server/app.ts +2 −0
  6. server/blockers/parse.test.ts +63 −0
  7. server/blockers/parse.ts +50 −0
  8. server/engine/turn.ts +20 −3
  9. server/routes/blockers.test.ts +118 −0
  10. server/routes/blockers.ts +42 −0
  11. spec/TASKS.md +1 −1
modified client/src/App.css +27 −0
@@ -313,6 +313,33 @@
313 313 opacity: 0.85;
314 314 }
315 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 +
316 343 .interview-done {
317 344 padding: 12px 16px;
318 345 border-radius: 8px;
modified client/src/App.tsx +2 −0
@@ -2,6 +2,7 @@import { useEffect, useRef, useState } from 'react'
2 2 import type { Session, SessionSummary } from 'shared/types'
3 3 import * as api from './api'
4 4 import './App.css'
5 +import { BlockerImport } from './components/BlockerImport'
5 6 import { CoveragePanel } from './components/CoveragePanel'
6 7 import { GeneratePanel } from './components/GeneratePanel'
7 8 import { PushToTalkButton } from './components/PushToTalkButton'
@@ -206,6 +207,7 @@function InterviewScreen({ sessionId, onBack }: { sessionId: string; onBack: ()
206 207 <>
207 208 <p className="interview-done">Interview complete.</p>
208 209 <GeneratePanel sessionId={session.id} coverage={session.coverage} />
210 + <BlockerImport sessionId={session.id} onImported={refresh} />
209 211 </>
210 212 )}
211 213
modified client/src/api.ts +8 −0
@@ -1,6 +1,7 @@
1 1 import type {
2 2 AnswerRequest,
3 3 AnswerResponse,
4 + BlockersResponse,
4 5 CreateSessionRequest,
5 6 GenerateRequest,
6 7 GenerateResponse,
@@ -58,3 +59,10 @@export function generateSpecPack(id: string, req: GenerateRequest = {}): Promise
58 59 body: JSON.stringify(req),
59 60 })
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/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 +}
modified server/app.ts +2 −0
@@ -4,6 +4,7 @@import Fastify, { type FastifyInstance } from 'fastify'
4 4 import { createInterviewLlm, createSttProvider } from './providers/factory'
5 5 import type { InterviewLlm, SttProvider } from './providers/types'
6 6 import { registerAudioRoutes } from './routes/audio'
7 +import { registerBlockerRoutes } from './routes/blockers'
7 8 import { registerGenerateRoutes } from './routes/generate'
8 9 import { registerSessionRoutes } from './routes/sessions'
9 10 import { createDefaultSessionStore, SessionStore } from './store/sessionStore'
@@ -29,6 +30,7 @@export function buildApp(deps: Partial<AppDeps> = {}): FastifyInstance {
29 30 registerSessionRoutes(app, resolved)
30 31 registerAudioRoutes(app, resolved)
31 32 registerGenerateRoutes(app, resolved)
33 + registerBlockerRoutes(app, resolved)
32 34
33 35 return app
34 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 +}
modified server/engine/turn.ts +20 −3
@@ -1,7 +1,7 @@
1 -import type { AnswerResponse } from '../../shared/types'
1 +import type { AnswerResponse, InterviewTurn } from '../../shared/types'
2 2 import type { InterviewLlm } from '../providers/types'
3 3 import type { SessionStore } from '../store/sessionStore'
4 -import { runInterviewTurn } from './interview'
4 +import { isDoneAnswer, runInterviewTurn } from './interview'
5 5
6 6 export class SessionNotFoundError extends Error {
7 7 constructor(sessionId: string) {
@@ -19,13 +19,30 @@export async function submitAnswer(
19 19 if (!existing) throw new SessionNotFoundError(sessionId)
20 20
21 21 const { session: afterAnswer, segment } = await store.appendSegment(sessionId, 'user', text)
22 - const turn = await runInterviewTurn(llm, afterAnswer)
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 + }
23 39
24 40 await store.appendSegment(sessionId, 'interviewer', turn.nextQuestion)
25 41 await store.updateTurn(sessionId, {
26 42 coverage: turn.coverage,
27 43 summary: turn.summaryUpdate ?? afterAnswer.summary,
28 44 status: turn.done ? 'done' : 'interviewing',
45 + openBlockers: remainingBlockers,
29 46 })
30 47
31 48 return { segment, turn }
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 +}
modified spec/TASKS.md +1 −1
@@ -52,7 +52,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a
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).