Commit
T9: spec pack generator
commit
f6f0adc
6 changed files with +311 and −1
Jump to a changed file
- server/generator/generate.test.ts +93 −0
- server/generator/generate.ts +92 −0
- server/generator/prompts.ts +61 −0
- server/generator/provenance.test.ts +31 −0
- server/generator/provenance.ts +33 −0
- spec/TASKS.md +1 −1
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 | +} |
modified spec/TASKS.md +1 −1
| @@ -42,7 +42,7 @@Work strictly in order unless a task's Depends line allows otherwise. One task a | ||
| 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). |