Commit
T11: blocker import
commit
0791dd9
11 changed files with +379 and −4
Jump to a changed file
- client/src/App.css +27 −0
- client/src/App.tsx +2 −0
- client/src/api.ts +8 −0
- client/src/components/BlockerImport.tsx +46 −0
- server/app.ts +2 −0
- server/blockers/parse.test.ts +63 −0
- server/blockers/parse.ts +50 −0
- server/engine/turn.ts +20 −3
- server/routes/blockers.test.ts +118 −0
- server/routes/blockers.ts +42 −0
- 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). |