profileShare

rasmusjy / voicetask

Read-only snapshot

No repository description.

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

Commit

T9: spec pack generator

generator/prompts.ts builds one prompt per output file, mirroring this
repo's own spec pack structure. generator/generate.ts orchestrates the
five LLM calls plus a code-generated sources.json, refuses to overwrite
an existing pack unless requested (backing up to spec/backup-<ts>/ when
it is), and requires the target directory to already exist.
generator/provenance.ts implements FR-013: invalid [S<n>] markers are
stripped per line and the line is suffixed with [unverified], collapsing
the resulting double space.
commit f6f0adc

6 changed files with +311 and −1

Jump to a changed file
  1. server/generator/generate.test.ts +93 −0
  2. server/generator/generate.ts +92 −0
  3. server/generator/prompts.ts +61 −0
  4. server/generator/provenance.test.ts +31 −0
  5. server/generator/provenance.ts +33 −0
  6. 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).