From d92de5339941ee9a292b5555c98f5d469d0d24ed Mon Sep 17 00:00:00 2001 From: fargo Date: Tue, 18 Aug 2026 05:56:51 +0000 Subject: [PATCH] =?UTF-8?q?feat(prd):=20one=20transitional=20PRD=20authori?= =?UTF-8?q?ty=20=E2=80=94=20RI-4-001=20(#1275)=20(#1294)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: fargo --- .../mosaic/src/commands/mission-prd.spec.ts | 149 ++++++ packages/mosaic/src/commands/mission.ts | 37 +- packages/mosaic/src/commands/prdy.spec.ts | 204 +++++++++ packages/mosaic/src/commands/prdy.ts | 71 ++- packages/prdy/src/cli.ts | 88 +++- packages/prdy/src/index.ts | 25 +- packages/prdy/src/prd.ts | 38 +- packages/prdy/src/service.spec.ts | 433 ++++++++++++++++++ packages/prdy/src/service.ts | 379 +++++++++++++++ packages/prdy/src/types.ts | 75 +++ packages/prdy/src/wizard.ts | 75 +-- scratchpads/ri-4-001-prd-authority.md | 37 ++ 12 files changed, 1552 insertions(+), 59 deletions(-) create mode 100644 packages/mosaic/src/commands/mission-prd.spec.ts create mode 100644 packages/mosaic/src/commands/prdy.spec.ts create mode 100644 packages/prdy/src/service.spec.ts create mode 100644 packages/prdy/src/service.ts create mode 100644 scratchpads/ri-4-001-prd-authority.md diff --git a/packages/mosaic/src/commands/mission-prd.spec.ts b/packages/mosaic/src/commands/mission-prd.spec.ts new file mode 100644 index 00000000..a5dcabe1 --- /dev/null +++ b/packages/mosaic/src/commands/mission-prd.spec.ts @@ -0,0 +1,149 @@ +import { mkdtemp, readFile, readdir } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { parse as parseYaml } from 'yaml'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerMissionCommand } from './mission.js'; +import { PrdService } from '@mosaicstack/prdy'; +import type { MissionInfo } from '../tui/gateway-api.js'; + +// ── Mocks: the gateway is not available in adapter tests ────────────────────── + +// vi.hoisted: the mock factory is hoisted above imports, so the fixture must +// be initialized there too. +const MISSION = vi.hoisted( + (): MissionInfo => ({ + id: 'mission-plan-1', + name: 'Plan Mission Alpha', + description: null, + status: 'planning', + projectId: null, + userId: null, + phase: null, + milestones: null, + config: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-03-04T05:06:07.000Z', + }), +); + +vi.mock('./with-auth.js', () => ({ + withAuth: vi.fn().mockResolvedValue({ + gateway: 'http://localhost:14242', + cookie: 'better-auth.session_token=test', + session: {}, + }), +})); + +vi.mock('../tui/gateway-api.js', () => ({ + fetchMissions: vi.fn().mockResolvedValue([MISSION]), + fetchMission: vi.fn(), + createMission: vi.fn(), + updateMission: vi.fn(), + fetchMissionTasks: vi.fn().mockResolvedValue([]), + createMissionTask: vi.fn(), + updateMissionTask: vi.fn(), + fetchProjects: vi.fn().mockResolvedValue([]), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +const originalCwd = process.cwd(); +let projectDir: string; +let logSpy: ReturnType; +let consoleStub: ReturnType[] = []; + +function buildTestProgram(): Command { + const program = new Command('mosaic').exitOverride(); + registerMissionCommand(program); + return program; +} + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-mission-plan-')); + process.chdir(projectDir); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + consoleStub.push(logSpy); +}); + +afterEach(() => { + // Restore only the per-test spies; module factory mocks keep their + // implementations across tests. + for (const stub of consoleStub) stub.mockRestore(); + consoleStub = []; + process.chdir(originalCwd); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('mosaic mission --plan (thin adapter over PrdService)', () => { + it('creates the PRD in the shared docs/prdy authority store and persists the mission linkage', async () => { + await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], { + from: 'user', + }); + + // PRD landed in the same store `mosaic prdy` uses. + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.yaml$/); + + // Fresh service instance (new-process equivalent) reads the linkage back. + const service = new PrdService({ projectPath: projectDir }); + const docs = await service.list(); + expect(docs).toHaveLength(1); + + const prd = docs[0]!; + expect(prd.title).toBe('Plan Mission Alpha'); + expect(prd.version).toBe(1); + + const links = await service.listMissionLinks(prd.id); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + missionId: MISSION.id, + missionVersion: MISSION.updatedAt, // mission version marker + prdVersion: 1, + }); + + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created and linked')); + }); + + it('linkage is persisted in the YAML authority document itself (survives restart)', async () => { + await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], { + from: 'user', + }); + + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + const raw = await readFile(path.join(projectDir, 'docs', 'prdy', files[0]!), 'utf8'); + const persisted = parseYaml(raw) as { missions: Array> }; + + expect(persisted.missions).toHaveLength(1); + expect(persisted.missions[0]).toMatchObject({ missionId: 'mission-plan-1' }); + }); + + it('the mission path and the prdy path resolve to the same store with stable ids/versions', async () => { + // Mission path. + await buildTestProgram().parseAsync(['mission', '--plan', 'Plan Mission Alpha'], { + from: 'user', + }); + + // prdy path (service, non-interactive entry). + const service = new PrdService({ projectPath: projectDir }); + const direct = await service.create({ name: 'Directly Created' }); + + const all = await service.list(); + expect(all.map((doc) => doc.id).sort()).toEqual([...all.map((doc) => doc.id)].sort()); + expect(all).toHaveLength(2); + + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + expect(files).toContain(`${direct.id}.yaml`); + + // Both are v1 in the same store with distinct stable ids. + for (const doc of all) { + expect(doc.version).toBe(1); + expect(files).toContain(`${doc.id}.yaml`); + } + }); +}); diff --git a/packages/mosaic/src/commands/mission.ts b/packages/mosaic/src/commands/mission.ts index 9ee1539b..7a35fead 100644 --- a/packages/mosaic/src/commands/mission.ts +++ b/packages/mosaic/src/commands/mission.ts @@ -256,14 +256,41 @@ async function planMission( console.log(`Planning mission: ${mission.name}\n`); try { - const { runPrdWizard } = await import('@mosaicstack/prdy'); - await runPrdWizard({ + // Thin adapter: the PRD authority (create + mission↔PRD linkage) lives in + // PrdService — no second writer path. The mission's updatedAt serves as + // its version marker (the gateway exposes no numeric mission version). + const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy'); + const service = new PrdService({ projectPath: process.cwd() }); + + if (process.stdout.isTTY) { + const created = await runPrdWizard({ + name: mission.name, + projectPath: process.cwd(), + interactive: true, + }); + const linked = await service.linkMission({ + prdId: created.id, + missionId: mission.id, + missionVersion: mission.updatedAt, + requirementIds: [], + }); + console.log( + `\nMission ${mission.id} linked to PRD ${linked.id} v${linked.version} (docs/prdy/).`, + ); + return; + } + + const doc = await service.planForMission({ name: mission.name, - projectPath: process.cwd(), - interactive: true, + missionId: mission.id, + missionVersion: mission.updatedAt, + requirementIds: [], }); + console.log( + `PRD created and linked: ${doc.id} v${doc.version} — mission ${mission.id} (docs/prdy/).`, + ); } catch (err) { - console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`); + console.error(`PRD planning failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } } diff --git a/packages/mosaic/src/commands/prdy.spec.ts b/packages/mosaic/src/commands/prdy.spec.ts new file mode 100644 index 00000000..7bbfb5e4 --- /dev/null +++ b/packages/mosaic/src/commands/prdy.spec.ts @@ -0,0 +1,204 @@ +import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { stringify as stringifyYaml } from 'yaml'; +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { registerPrdyCommand } from './prdy.js'; +import { PrdService } from '@mosaicstack/prdy'; + +// ── Mocks: keep the adapter test offline (no gateway, no disk side effects +// outside the tmp project dir) ────────────────────────────────────────────── + +vi.mock('./with-auth.js', () => ({ + withAuth: vi.fn().mockResolvedValue({ + gateway: 'http://localhost:14242', + cookie: 'better-auth.session_token=test', + session: {}, + }), +})); + +vi.mock('../tui/gateway-api.js', () => ({ + fetchProjects: vi.fn().mockResolvedValue([]), +})); + +// ── Helpers ────────────────────────────────────────────────────────────────── + +class ProcessExitError extends Error { + constructor(readonly code: number) { + super(`process.exit(${code})`); + } +} + +function stubProcessExit() { + return vi.spyOn(process, 'exit').mockImplementation(((code?: number) => { + throw new ProcessExitError(code ?? 0); + }) as never); +} + +const originalCwd = process.cwd(); +let projectDir: string; +let errorSpy: ReturnType; +let logSpy: ReturnType; +let exitStub: ReturnType; + +function buildTestProgram(): Command { + const program = new Command('mosaic').exitOverride(); + registerPrdyCommand(program); + return program; +} + +function runPrdy(args: string[]): Promise { + return buildTestProgram().parseAsync(['prdy', ...args], { from: 'user' }); +} + +function importableDocument(overrides: Record = {}): Record { + return { + id: 'cmd-import-prd', + title: 'Command Import PRD', + status: 'approved', // must be forced to draft: validity is not approval + projectPath: '/tmp/elsewhere', + template: 'software', + version: 1, + sections: [ + { id: 'introduction', title: 'Introduction', fields: { context: 'x', objective: 'y' } }, + ], + missions: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +beforeEach(async () => { + projectDir = await mkdtemp(path.join(os.tmpdir(), 'mosaic-prdy-')); + process.chdir(projectDir); + exitStub = stubProcessExit(); + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + // Restore only the per-test spies: module factory mocks must keep their + // implementations for the next test. + exitStub.mockRestore(); + errorSpy.mockRestore(); + logSpy.mockRestore(); + process.chdir(originalCwd); +}); + +// ── Tests ──────────────────────────────────────────────────────────────────── + +describe('mosaic prdy (thin adapter over PrdService)', () => { + it('non-interactive --init creates a PRD in the docs/prdy authority store', async () => { + await runPrdy(['--init', 'Adapter Created']); + + const files = await readdir(path.join(projectDir, 'docs', 'prdy')); + expect(files).toHaveLength(1); + expect(files[0]).toMatch(/\.yaml$/); + + const docs = await new PrdService({ projectPath: projectDir }).list(); + expect(docs).toHaveLength(1); + expect(docs[0]?.title).toBe('Adapter Created'); + expect(docs[0]?.version).toBe(1); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('PRD created')); + }); + + it('--import creates a valid import through the service', async () => { + const filePath = path.join(projectDir, 'incoming.yaml'); + await writeFile(filePath, stringifyYaml(importableDocument()), 'utf8'); + + await runPrdy(['--import', filePath]); + + const docs = await new PrdService({ projectPath: projectDir }).list(); + expect(docs).toHaveLength(1); + expect(docs[0]?.id).toBe('cmd-import-prd'); + expect(docs[0]?.status).toBe('draft'); // import ≠ approval + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Imported PRD cmd-import-prd')); + }); + + it('--import of a structurally-invalid file is a typed refusal that creates nothing', async () => { + const filePath = path.join(projectDir, 'broken.yaml'); + await writeFile(filePath, stringifyYaml({ id: 'incomplete', no: 'structure' }), 'utf8'); + + await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError); + + // Typed refusal surfaced to the user, nothing created. + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('PRD wizard failed')); + await expect(readdir(path.join(projectDir, 'docs'))).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('--import on conflict refuses with a successor proposal and leaves bytes untouched', async () => { + const service = new PrdService({ projectPath: projectDir }); + const existing = await service.create({ name: 'Conflict Target' }); + const storeFile = path.join(projectDir, 'docs', 'prdy', `${existing.id}.yaml`); + const beforeBytes = await readFile(storeFile, 'utf8'); + + const filePath = path.join(projectDir, 'divergent.yaml'); + await writeFile( + filePath, + stringifyYaml( + importableDocument({ + ...existing, + title: 'Divergent Command Import', + }), + ), + 'utf8', + ); + + await expect(runPrdy(['--import', filePath])).rejects.toBeInstanceOf(ProcessExitError); + + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('refusing to overwrite')); + expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining('--accept-successor')); + + // Original authority document is byte-identical on disk. + expect(await readFile(storeFile, 'utf8')).toBe(beforeBytes); + }); + + it('--import --accept-successor persists the successor version explicitly', async () => { + const service = new PrdService({ projectPath: projectDir }); + const existing = await service.create({ name: 'Successor Target' }); + + const filePath = path.join(projectDir, 'divergent2.yaml'); + await writeFile( + filePath, + stringifyYaml( + importableDocument({ + ...existing, + title: 'Accepted Via CLI', + }), + ), + 'utf8', + ); + + await runPrdy(['--import', filePath, '--accept-successor']); + + const doc = await service.get(existing.id); + expect(doc.version).toBe(2); + expect(doc.title).toBe('Accepted Via CLI'); + expect(doc.status).toBe('draft'); + expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('successor')); + }); + + it('--export writes a labeled generated view and never touches authority', async () => { + const service = new PrdService({ projectPath: projectDir }); + const created = await service.create({ name: 'Export Via CLI' }); + const before = await service.get(created.id); + + await runPrdy(['--export', created.id]); + + const mdPath = path.join(projectDir, 'docs', 'prdy', `${created.id}.md`); + const md = await readFile(mdPath, 'utf8'); + expect(md).toContain('generated view — do not edit'); + expect(md).toContain(`prd-id: ${created.id}`); + expect(md).toContain('prd-version: 1'); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining(`Generated view written: ${mdPath}`), + ); + + // Authority unchanged by the export. + expect(await service.get(created.id)).toEqual(before); + }); +}); diff --git a/packages/mosaic/src/commands/prdy.ts b/packages/mosaic/src/commands/prdy.ts index 47e55024..86a61c21 100644 --- a/packages/mosaic/src/commands/prdy.ts +++ b/packages/mosaic/src/commands/prdy.ts @@ -2,6 +2,10 @@ import type { Command } from 'commander'; import { withAuth } from './with-auth.js'; import { fetchProjects } from '../tui/gateway-api.js'; +/** + * `mosaic prdy` — thin adapter over PrdService (@mosaicstack/prdy). + * All reads/writes go through the service; there is no local writer path. + */ export function registerPrdyCommand(program: Command) { const cmd = program .command('prdy') @@ -9,12 +13,18 @@ export function registerPrdyCommand(program: Command) { .option('-g, --gateway ', 'Gateway URL', 'http://localhost:14242') .option('--init [name]', 'Create a new PRD') .option('--update [name]', 'Update an existing PRD') + .option('--import ', 'Import a YAML PRD document (validated, conflict-aware)') + .option('--accept-successor', 'With --import: accept a conflicted import as next version') + .option('--export [id]', 'Export a PRD as a labeled generated-view Markdown file') .option('--project ', 'Scope to project') .action( async (opts: { gateway: string; init?: string | boolean; update?: string | boolean; + import?: string; + acceptSuccessor?: boolean; + export?: string | boolean; project?: string; }) => { // Detect project context when --project flag is provided @@ -31,20 +41,69 @@ export function registerPrdyCommand(program: Command) { } } + const { PrdService, runPrdWizard } = await import('@mosaicstack/prdy'); + const service = new PrdService({ projectPath: process.cwd() }); + try { - const { runPrdWizard } = await import('@mosaicstack/prdy'); + if (opts.import !== undefined) { + const input = { filePath: opts.import }; + + if (opts.acceptSuccessor) { + const successor = await service.acceptSuccessor(input); + console.log( + `Import accepted as successor: ${successor.id} v${successor.version} (status: ${successor.status})`, + ); + return; + } + + const result = await service.importDocument(input); + console.log( + result.kind === 'created' + ? `Imported PRD ${result.document.id} v${result.document.version} (status: ${result.document.status})` + : `PRD ${result.document.id} already present with identical content — nothing to do.`, + ); + return; + } + + if (opts.export !== undefined) { + const id = + typeof opts.export === 'string' && opts.export.length > 0 ? opts.export : undefined; + const result = await service.exportMarkdown({ id }); + console.log( + `Generated view written: ${result.filePath} (source authority: YAML under docs/prdy/ — do not edit the Markdown)`, + ); + return; + } + const name = typeof opts.init === 'string' ? opts.init : typeof opts.update === 'string' ? opts.update : 'untitled'; - await runPrdWizard({ - name, - projectPath: process.cwd(), - interactive: true, - }); + + if (process.stdout.isTTY) { + await runPrdWizard({ + name, + projectPath: process.cwd(), + interactive: true, + }); + return; + } + + // Non-interactive fallback routes through the service directly. + const doc = await service.create({ name }); + console.log(`PRD created: ${doc.id} v${doc.version} (status: ${doc.status})`); } catch (err) { + if (err instanceof Error && err.name === 'PrdImportConflictError') { + const conflict = err as { proposal?: { version?: number } }; + console.error(`${err.message}`); + console.error( + `Original PRD left untouched. To accept the proposed successor (v${conflict.proposal?.version}), re-run with --accept-successor.`, + ); + process.exit(1); + } + console.error(`PRD wizard failed: ${err instanceof Error ? err.message : String(err)}`); process.exit(1); } diff --git a/packages/prdy/src/cli.ts b/packages/prdy/src/cli.ts index 1850e392..d49bc692 100644 --- a/packages/prdy/src/cli.ts +++ b/packages/prdy/src/cli.ts @@ -1,6 +1,6 @@ import { Command } from 'commander'; -import { createPrd, listPrds, loadPrd } from './prd.js'; +import { PrdService } from './service.js'; import { runPrdWizard } from './wizard.js'; interface InitCommandOptions { @@ -18,6 +18,22 @@ interface ShowCommandOptions { readonly id?: string; } +interface ImportCommandOptions { + readonly project: string; + readonly file: string; + readonly acceptSuccessor?: boolean; +} + +interface ExportCommandOptions { + readonly project: string; + readonly id?: string; + readonly out?: string; +} + +function serviceFor(project: string): PrdService { + return new PrdService({ projectPath: project }); +} + export function buildPrdyCli(): Command { const program = new Command(); program.name('mosaic').description('Mosaic CLI').exitOverride(); @@ -38,11 +54,9 @@ export function buildPrdyCli(): Command { template: options.template, interactive: true, }) - : await createPrd({ + : await serviceFor(options.project).create({ name: options.name, - projectPath: options.project, template: options.template, - interactive: false, }); console.log( @@ -52,6 +66,7 @@ export function buildPrdyCli(): Command { id: doc.id, title: doc.title, status: doc.status, + version: doc.version, projectPath: doc.projectPath, }, null, @@ -65,7 +80,7 @@ export function buildPrdyCli(): Command { .description('List PRD documents for a project') .requiredOption('--project ', 'Project path') .action(async (options: ListCommandOptions) => { - const docs = await listPrds(options.project); + const docs = await serviceFor(options.project).list(); console.log(JSON.stringify(docs, null, 2)); }); @@ -75,20 +90,65 @@ export function buildPrdyCli(): Command { .requiredOption('--project ', 'Project path') .option('--id ', 'PRD document id') .action(async (options: ShowCommandOptions) => { - if (options.id !== undefined) { - const docs = await listPrds(options.project); - const match = docs.find((doc) => doc.id === options.id); + const doc = await serviceFor(options.project).get(options.id); + console.log(JSON.stringify(doc, null, 2)); + }); - if (match === undefined) { - throw new Error(`PRD id not found: ${options.id}`); - } + prdy + .command('import') + .description('Import a YAML PRD document (validated; conflicts propose a successor)') + .requiredOption('--project ', 'Project path') + .requiredOption('--file ', 'Path to YAML PRD document') + .option('--accept-successor', 'Accept a conflicted import as the next version') + .action(async (options: ImportCommandOptions) => { + const service = serviceFor(options.project); + const input = { filePath: options.file }; - console.log(JSON.stringify(match, null, 2)); + if (options.acceptSuccessor) { + const successor = await service.acceptSuccessor(input); + console.log( + JSON.stringify( + { + ok: true, + outcome: 'successor-accepted', + id: successor.id, + version: successor.version, + }, + null, + 2, + ), + ); return; } - const doc = await loadPrd(options.project); - console.log(JSON.stringify(doc, null, 2)); + const result = await service.importDocument(input); + console.log( + JSON.stringify( + { + ok: true, + outcome: result.kind, + id: result.document.id, + version: result.document.version, + status: result.document.status, + }, + null, + 2, + ), + ); + }); + + prdy + .command('export') + .description('Render a PRD to a labeled generated-view Markdown file') + .requiredOption('--project ', 'Project path') + .option('--id ', 'PRD document id') + .option('--out ', 'Output path (default docs/prdy/.md)') + .action(async (options: ExportCommandOptions) => { + const result = await serviceFor(options.project).exportMarkdown({ + id: options.id, + outPath: options.out, + }); + console.log(JSON.stringify({ ok: true, filePath: result.filePath }, null, 2)); }); return program; diff --git a/packages/prdy/src/index.ts b/packages/prdy/src/index.ts index 5613c9b5..b7c710e5 100644 --- a/packages/prdy/src/index.ts +++ b/packages/prdy/src/index.ts @@ -1,12 +1,35 @@ -export { createPrd, loadPrd, savePrd, listPrds } from './prd.js'; +// PrdService is the single authority surface for PRD documents. The raw store +// writers (createPrd/savePrd) are deliberately NOT exported: every mutation +// goes through the service so there is no second writer path. +export { loadPrd, listPrds, parsePrdDocument } from './prd.js'; export { runPrdWizard } from './wizard.js'; export { buildPrdyCli, runPrdyCli } from './cli.js'; export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js'; +export { + PrdService, + PRD_GENERATED_VIEW_LABEL, + PrdError, + PrdNotFoundError, + PrdUpdateError, + PrdImportInvalidError, + PrdImportConflictError, +} from './service.js'; export type { PrdStatus, PrdTemplate, PrdTemplateSection, PrdSection, + PrdMissionLinkage, PrdDocument, CreatePrdOptions, + PrdServiceOptions, + PrdCreateInput, + PrdSectionPatch, + PrdUpdateInput, + PrdLinkMissionInput, + PrdPlanForMissionInput, + PrdExportInput, + PrdExportResult, + PrdImportInput, + PrdImportResult, } from './types.js'; diff --git a/packages/prdy/src/prd.ts b/packages/prdy/src/prd.ts index e6701d71..eca8f5c3 100644 --- a/packages/prdy/src/prd.ts +++ b/packages/prdy/src/prd.ts @@ -17,17 +17,49 @@ const prdSectionSchema = z.object({ fields: z.record(z.string(), z.string()), }); +const prdMissionLinkageSchema = z.object({ + missionId: z.string().min(1), + missionVersion: z.string().min(1), + prdVersion: z.number().int().min(1), + requirementIds: z.array(z.string()), + linkedAt: z.string().datetime(), +}); + const prdDocumentSchema = z.object({ id: z.string().min(1), title: z.string().min(1), status: z.enum(['draft', 'review', 'approved', 'archived']), projectPath: z.string().min(1), template: z.string().min(1), + // Defaults keep documents written by older prdy versions loadable. + version: z.number().int().min(1).default(1), sections: z.array(prdSectionSchema), + missions: z.array(prdMissionLinkageSchema).default([]), createdAt: z.string().datetime(), updatedAt: z.string().datetime(), }); +/** YAML timestamp scalars are parsed as Date by some emitters — normalize to ISO strings. */ +function coerceTimestamps(value: unknown): unknown { + if (value instanceof Date) { + return value.toISOString(); + } + if (Array.isArray(value)) { + return value.map(coerceTimestamps); + } + if (typeof value === 'object' && value !== null) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, coerceTimestamps(entry)]), + ); + } + return value; +} + +/** Validate an unknown value as a PRD document (throws zod errors on failure). */ +export function parsePrdDocument(value: unknown): PrdDocument { + return prdDocumentSchema.parse(coerceTimestamps(value)) as PrdDocument; +} + function expandHome(projectPath: string): string { if (!projectPath.startsWith('~')) { return projectPath; @@ -74,6 +106,8 @@ function prdDirectory(projectPath: string): string { return path.join(projectPath, PRD_DIRECTORY); } +export { prdDirectory }; + function prdFilePath(projectPath: string, id: string): string { return path.join(prdDirectory(projectPath), `${id}.yaml`); } @@ -113,11 +147,13 @@ export async function createPrd(options: CreatePrdOptions): Promise status: 'draft', projectPath: resolvedProjectPath, template: template.id, + version: 1, sections: template.sections.map((section) => ({ id: section.id, title: section.title, fields: Object.fromEntries(section.fields.map((field) => [field, ''])), })), + missions: [], createdAt: now, updatedAt: now, }; @@ -190,7 +226,7 @@ export async function listPrds(projectPath: string): Promise { throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`); } - const document = prdDocumentSchema.parse(parsed); + const document = parsePrdDocument(parsed); documents.push(document); } diff --git a/packages/prdy/src/service.spec.ts b/packages/prdy/src/service.spec.ts new file mode 100644 index 00000000..18b8978c --- /dev/null +++ b/packages/prdy/src/service.spec.ts @@ -0,0 +1,433 @@ +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import yaml from 'js-yaml'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import { + PRD_GENERATED_VIEW_LABEL, + PrdImportConflictError, + PrdImportInvalidError, + PrdNotFoundError, + PrdService, + PrdUpdateError, +} from './index.js'; +import type { PrdDocument } from './index.js'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +let projectDir: string; + +async function makeProject(): Promise { + return mkdtemp(path.join(os.tmpdir(), 'prdy-service-')); +} + +function service(): PrdService { + return new PrdService({ projectPath: projectDir }); +} + +function storeDir(): string { + return path.join(projectDir, 'docs', 'prdy'); +} + +/** Handcraft a full, schema-valid PRD document for import scenarios. */ +function importFixture(overrides: Partial = {}): PrdDocument { + return { + id: 'imported-prd-20260101-000000', + title: 'Imported PRD', + status: 'draft', + projectPath: '/tmp/elsewhere', + template: 'software', + version: 1, + sections: [ + { id: 'introduction', title: 'Introduction', fields: { context: '', objective: '' } }, + { + id: 'scope-non-goals', + title: 'Scope / Non-Goals', + fields: { inScope: '', outOfScope: '' }, + }, + ], + missions: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +async function writeImportFile(doc: PrdDocument): Promise { + const filePath = path.join(projectDir, `${doc.id}.import.yaml`); + await writeFile(filePath, yaml.dump(doc), 'utf8'); + return filePath; +} + +beforeEach(async () => { + projectDir = await makeProject(); +}); + +// ── Single authority store (AC: prdy path and mission path resolve to the +// SAME store under docs/prdy/ with stable ids/versions) ──────────────────── + +describe('PrdService single authority store', () => { + it('persists PRDs from the prdy path and the mission path into the same docs/prdy store', async () => { + const direct = await service().create({ name: 'Direct PRD' }); + const viaMission = await service().planForMission({ + name: 'Mission PRD', + missionId: 'mission-1', + missionVersion: '2026-01-01T00:00:00.000Z', + }); + + const files = await readdir(storeDir()); + expect(files).toContain(`${direct.id}.yaml`); + expect(files).toContain(`${viaMission.id}.yaml`); + + // A fresh service instance (new process equivalent) resolves both. + const all = await service().list(); + expect(all.map((doc) => doc.id).sort()).toEqual([direct.id, viaMission.id].sort()); + + // Stable versions: creation is v1; linkage writes do not bump content version. + expect((await service().get(direct.id)).version).toBe(1); + expect((await service().get(viaMission.id)).version).toBe(1); + }); + + it('round-trips documents through the store with identity intact', async () => { + const created = await service().create({ name: 'Round Trip', template: 'feature' }); + const fresh = await service().get(created.id); + + expect(fresh).toEqual(created); + expect(fresh.id).toBe(created.id); + expect(fresh.template).toBe('feature'); + expect(fresh.status).toBe('draft'); + }); + + it('throws a typed error for unknown ids and empty stores', async () => { + await expect(service().get('nope')).rejects.toBeInstanceOf(PrdNotFoundError); + await expect(service().get()).rejects.toBeInstanceOf(PrdNotFoundError); + }); +}); + +// ── Mission linkage persistence (AC: linkage survives restart via fresh +// service instances) ──────────────────────────────────────────────────────── + +describe('PrdService mission linkage', () => { + it('persists linkage and reads it back from a fresh service instance', async () => { + const created = await service().planForMission({ + name: 'Linked PRD', + missionId: 'mission-42', + missionVersion: '2026-02-03T04:05:06.000Z', + requirementIds: ['FR-1', 'FR-2'], + }); + + // Fresh instance — nothing in memory from the creating call. + const links = await service().listMissionLinks(created.id); + expect(links).toHaveLength(1); + expect(links[0]).toMatchObject({ + missionId: 'mission-42', + missionVersion: '2026-02-03T04:05:06.000Z', + prdVersion: 1, + requirementIds: ['FR-1', 'FR-2'], + }); + + // Linkage is carried in the YAML authority file itself. + const raw = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8'); + const persisted = yaml.load(raw) as PrdDocument; + expect(persisted.missions[0]?.missionId).toBe('mission-42'); + expect(persisted.missions[0]?.requirementIds).toEqual(['FR-1', 'FR-2']); + }); + + it('refreshes an existing linkage entry in place instead of duplicating', async () => { + const created = await service().planForMission({ + name: 'Relink PRD', + missionId: 'mission-7', + missionVersion: 'v1', + }); + + await service().update({ + id: created.id, + sections: [{ id: 'introduction', fields: { objective: 'Ship it' } }], + }); + + const relinked = await service().linkMission({ + prdId: created.id, + missionId: 'mission-7', + missionVersion: 'v2', + requirementIds: ['NFR-1'], + }); + + expect(relinked.missions).toHaveLength(1); + expect(relinked.missions[0]).toMatchObject({ missionVersion: 'v2', prdVersion: 2 }); + }); + + it('does not bump the content version when writing linkage', async () => { + const created = await service().create({ name: 'Stable Version' }); + const linked = await service().linkMission({ + prdId: created.id, + missionId: 'm', + missionVersion: 'v1', + }); + expect(linked.version).toBe(1); + }); +}); + +// ── Update semantics ────────────────────────────────────────────────────────── + +describe('PrdService update', () => { + it('applies section patches and bumps the content version', async () => { + const created = await service().create({ name: 'Updatable' }); + const updated = await service().update({ + id: created.id, + sections: [{ id: 'introduction', fields: { context: 'Some context', objective: 'Goal' } }], + }); + + expect(updated.version).toBe(2); + expect(updated.sections[0]?.fields).toMatchObject({ + context: 'Some context', + objective: 'Goal', + }); + expect((await service().get(created.id)).version).toBe(2); + }); + + it('refuses unknown section ids with a typed error', async () => { + const created = await service().create({ name: 'Strict' }); + await expect( + service().update({ id: created.id, sections: [{ id: 'nope', fields: {} }] }), + ).rejects.toBeInstanceOf(PrdUpdateError); + }); +}); + +// ── Markdown export is a labeled generated view, never authority ────────────── + +describe('PrdService exportMarkdown', () => { + it('writes a generated view carrying the label and source identity', async () => { + const created = await service().create({ name: 'Exported PRD' }); + const result = await service().exportMarkdown({ id: created.id }); + + expect(result.filePath).toBe(path.join(storeDir(), `${created.id}.md`)); + expect(result.content).toContain(PRD_GENERATED_VIEW_LABEL); + expect(result.content).toContain(`prd-id: ${created.id}`); + expect(result.content).toContain('prd-version: 1'); + expect(result.content).toContain(`source-of-truth: docs/prdy/${created.id}.yaml`); + }); + + it('reflects the current version after updates', async () => { + const created = await service().create({ name: 'Versioned Export' }); + await service().update({ + id: created.id, + sections: [{ id: 'introduction', fields: { objective: 'v2 goal' } }], + }); + const result = await service().exportMarkdown({ id: created.id }); + expect(result.content).toContain('prd-version: 2'); + }); + + it('NEGATIVE CONTROL: mutating the exported Markdown cannot change the authority', async () => { + const created = await service().create({ name: 'Guarded PRD' }); + const before = structuredClone(await service().get(created.id)); + + const result = await service().exportMarkdown({ id: created.id }); + await writeFile( + result.filePath, + `\n# FAKE\nprd-id: fake-id\nprd-version: 99\n`, + 'utf8', + ); + + const after = await service().get(created.id); + expect(after).toEqual(before); + expect(after.version).toBe(1); + expect(after.title).toBe(before.title); + }); + + it('never parses Markdown files that sit in the store directory', async () => { + const created = await service().create({ name: 'Decoy Guard' }); + + // A decoy .md file with invalid YAML must be invisible to the store. + await writeFile(path.join(storeDir(), 'decoy.md'), 'not: [valid: yaml', 'utf8'); + // And a decoy .yaml-named Markdown body must not silently validate either. + await service().exportMarkdown({ id: created.id }); + + const listed = await service().list(); + expect(listed.map((doc) => doc.id)).toEqual([created.id]); + await expect(service().get(created.id)).resolves.toBeTruthy(); + }); +}); + +// ── Import: validated, conflict-aware, never silently merging ───────────────── + +describe('PrdService importDocument', () => { + it('creates a valid import through the service, as draft — validity is not approval', async () => { + const filePath = await writeImportFile(importFixture({ status: 'approved' })); + + const result = await service().importDocument({ filePath }); + + expect(result.kind).toBe('created'); + expect(result.document.id).toBe('imported-prd-20260101-000000'); + expect(result.document.status).toBe('draft'); // structural validity ≠ approval + expect(result.document.version).toBe(1); + + const persisted = await service().get('imported-prd-20260101-000000'); + expect(persisted.status).toBe('draft'); + + const files = await readdir(storeDir()); + expect(files).toContain('imported-prd-20260101-000000.yaml'); + }); + + it('reports identical content as a no-op without writing', async () => { + const created = await service().create({ name: 'Existing PRD' }); + const before = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8'); + + const filePath = await writeImportFile(importFixture({ ...created })); + const result = await service().importDocument({ filePath }); + + expect(result.kind).toBe('identical'); + const after = await readFile(path.join(storeDir(), `${created.id}.yaml`), 'utf8'); + expect(after).toBe(before); + }); + + it('refuses a conflicting import with a typed error, a proposed successor, and untouched bytes', async () => { + const existing = await service().create({ name: 'Authority PRD' }); + await service().linkMission({ + prdId: existing.id, + missionId: 'mission-keep', + missionVersion: 'v1', + requirementIds: ['FR-0'], + }); + const beforeBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8'); + + const divergent = importFixture({ + ...existing, + title: 'Divergent Title', + sections: [ + { + id: 'introduction', + title: 'Introduction', + fields: { context: 'changed', objective: '' }, + }, + ], + }); + const filePath = await writeImportFile(divergent); + + const attempt = service().importDocument({ filePath }); + let caught: unknown; + try { + await attempt; + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(PrdImportConflictError); + + const error = caught as PrdImportConflictError; + expect(error.code).toBe('PRD_IMPORT_CONFLICT'); + expect(error.existing.id).toBe(existing.id); + expect(error.proposal.version).toBe(existing.version + 1); // successor proposal + expect(error.proposal.status).toBe('draft'); + + // Original authority content untouched on disk. + const afterBytes = await readFile(path.join(storeDir(), `${existing.id}.yaml`), 'utf8'); + expect(afterBytes).toBe(beforeBytes); + }); + + it('acceptSuccessor persists the proposal explicitly, carrying linkages forward', async () => { + const existing = await service().create({ name: 'Successor Base' }); + await service().linkMission({ + prdId: existing.id, + missionId: 'mission-keep', + missionVersion: 'v1', + }); + + const divergent = importFixture({ + ...existing, + title: 'Accepted Successor Title', + }); + const filePath = await writeImportFile(divergent); + + const successor = await service().acceptSuccessor({ filePath }); + expect(successor.id).toBe(existing.id); + expect(successor.version).toBe(existing.version + 1); + expect(successor.title).toBe('Accepted Successor Title'); + expect(successor.status).toBe('draft'); + expect(successor.missions.map((m) => m.missionId)).toEqual(['mission-keep']); + + // Persisted for a fresh reader. + const fresh = await service().get(existing.id); + expect(fresh.version).toBe(2); + expect(fresh.title).toBe('Accepted Successor Title'); + }); + + it('refuses structurally-invalid imports with a typed error and creates nothing', async () => { + const cases: Array<{ name: string; body: string }> = [ + { name: 'missing-title.yaml', body: yaml.dump({ id: 'x', status: 'draft' }) }, + { + name: 'bad-status.yaml', + body: yaml.dump(importFixture({ status: 'not-a-status' as PrdDocument['status'] })), + }, + { + name: 'bad-version.yaml', + body: yaml.dump(importFixture({ version: 0 })), + }, + { name: 'not-yaml.yaml', body: '::: not yaml [\n - {' }, + ]; + + for (const fixture of cases) { + const filePath = path.join(projectDir, fixture.name); + await writeFile(filePath, fixture.body, 'utf8'); + + await expect(service().importDocument({ filePath })).rejects.toBeInstanceOf( + PrdImportInvalidError, + ); + } + + // Nothing was created: the authority store does not even exist yet. + await expect(readdir(storeDir())).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('acceptSuccessor refuses when there is no existing document to succeed', async () => { + const filePath = await writeImportFile(importFixture()); + await expect(service().acceptSuccessor({ filePath })).rejects.toBeInstanceOf(PrdNotFoundError); + }); +}); + +// ── No second writer: no code path reads exported Markdown back into authority ─ + +describe('no-second-writer invariant (source-level)', () => { + // Resolve the package source dir whether vitest runs from the package root + // (turbo/pnpm test) or from the worktree root. + function resolveSrcDir(): string { + const candidates = [path.resolve('src'), path.resolve('packages/prdy/src')]; + return candidates.find((dir) => existsSync(path.join(dir, 'service.ts'))) ?? candidates[0]!; + } + + const srcDir = resolveSrcDir(); + const sourceFiles = [ + 'cli.ts', + 'index.ts', + 'prd.ts', + 'service.ts', + 'templates.ts', + 'types.ts', + 'wizard.ts', + ]; + + it('no source file in @mosaicstack/prdy reads a .md file', async () => { + for (const file of sourceFiles) { + const text = await readFile(path.join(srcDir, file), 'utf8'); + const readLines = text + .split('\n') + .map((line) => line.trim()) + .filter((line) => /readFile|readFileSync|createReadStream/.test(line)); + + for (const line of readLines) { + expect(line.includes('.md'), `${file} reads a Markdown file: ${line}`).toBe(false); + } + } + }); + + it('the mosaic prdy/mission adapters never read a .md file', async () => { + const adapterDir = path.resolve(srcDir, '..', '..', 'mosaic', 'src', 'commands'); + for (const file of ['prdy.ts', 'mission.ts']) { + const text = await readFile(path.join(adapterDir, file), 'utf8'); + expect(text.includes("'.md'") || text.includes('.md`'), `${file} references a .md path`).toBe( + false, + ); + } + }); +}); diff --git a/packages/prdy/src/service.ts b/packages/prdy/src/service.ts new file mode 100644 index 00000000..f796a41b --- /dev/null +++ b/packages/prdy/src/service.ts @@ -0,0 +1,379 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; + +import yaml from 'js-yaml'; + +import { createPrd, listPrds, parsePrdDocument, prdDirectory, savePrd } from './prd.js'; +import type { + PrdCreateInput, + PrdDocument, + PrdExportInput, + PrdExportResult, + PrdImportInput, + PrdImportResult, + PrdLinkMissionInput, + PrdMissionLinkage, + PrdPlanForMissionInput, + PrdServiceOptions, + PrdUpdateInput, +} from './types.js'; + +/** + * PrdService is the SINGLE authority surface for PRD documents. + * + * Every mutation path (CLI wizard, `mosaic mission --plan`, import) routes + * through this service; the YAML store under `docs/prdy/` is the authority and + * exported Markdown is a generated view that no code path reads back. + */ + +// ── Typed errors ─────────────────────────────────────────────────────────────── + +export class PrdError extends Error { + constructor( + message: string, + readonly code: string, + ) { + super(message); + this.name = 'PrdError'; + } +} + +export class PrdNotFoundError extends PrdError { + constructor(message: string) { + super(message, 'PRD_NOT_FOUND'); + this.name = 'PrdNotFoundError'; + } +} + +export class PrdUpdateError extends PrdError { + constructor(message: string) { + super(message, 'PRD_UPDATE_INVALID'); + this.name = 'PrdUpdateError'; + } +} + +/** Structural refusal: the import payload failed schema validation. Nothing is written. */ +export class PrdImportInvalidError extends PrdError { + constructor( + message: string, + readonly issues?: string, + ) { + super(message, 'PRD_IMPORT_INVALID'); + this.name = 'PrdImportInvalidError'; + } +} + +/** + * Conflict refusal: an existing PRD shares the imported id but the content + * diverges. Carries a PROPOSED successor (existing version + 1) that is only + * persisted via an explicit {@link PrdService.acceptSuccessor} call — import + * never overwrites and never merges. + */ +export class PrdImportConflictError extends PrdError { + constructor( + message: string, + readonly existing: PrdDocument, + readonly proposal: PrdDocument, + ) { + super(message, 'PRD_IMPORT_CONFLICT'); + this.name = 'PrdImportConflictError'; + } +} + +// ── Service ──────────────────────────────────────────────────────────────────── + +/** The generated-view label carried by every Markdown export. */ +export const PRD_GENERATED_VIEW_LABEL = 'generated view — do not edit'; + +export class PrdService { + private readonly projectPath: string; + + constructor(options: PrdServiceOptions) { + this.projectPath = options.projectPath; + } + + /** Create a new PRD (version 1, draft) in the authority store. */ + async create(input: PrdCreateInput): Promise { + return createPrd({ + name: input.name, + projectPath: this.projectPath, + template: input.template, + interactive: false, + }); + } + + /** Read a PRD by id, or the most recently updated one. */ + async get(id?: string): Promise { + const documents = await listPrds(this.projectPath); + + if (id === undefined) { + const latest = documents[0]; + if (latest === undefined) { + throw new PrdNotFoundError(`No PRD documents found under docs/prdy/ for this project`); + } + return latest; + } + + const match = documents.find((doc) => doc.id === id); + if (match === undefined) { + throw new PrdNotFoundError(`PRD id not found: ${id}`); + } + return match; + } + + /** List all PRDs in the authority store (most recently updated first). */ + async list(): Promise { + return listPrds(this.projectPath); + } + + /** + * Apply section field patches and bump the content version. + * Linkage entries are preserved; linkage writes do NOT bump the version. + */ + async update(input: PrdUpdateInput): Promise { + const doc = await this.get(input.id); + + for (const patch of input.sections) { + const section = doc.sections.find((candidate) => candidate.id === patch.id); + if (section === undefined) { + throw new PrdUpdateError(`Unknown section id: ${patch.id}`); + } + for (const [field, value] of Object.entries(patch.fields)) { + if (!(field in section.fields)) { + throw new PrdUpdateError(`Unknown field "${field}" on section "${patch.id}"`); + } + section.fields[field] = value; + } + } + + doc.version += 1; + doc.updatedAt = new Date().toISOString(); + await savePrd(doc); + return doc; + } + + /** + * Record (or refresh) a mission ↔ PRD linkage on the PRD document. + * Persisted in the YAML authority, so it survives restarts. + */ + async linkMission(input: PrdLinkMissionInput): Promise { + const doc = await this.get(input.prdId); + return this.applyLinkage(doc, input); + } + + /** Read back the mission linkages recorded on a PRD. */ + async listMissionLinks(prdId?: string): Promise { + const doc = await this.get(prdId); + return doc.missions; + } + + /** + * Mission planning path: create a PRD for a mission AND persist the + * mission↔PRD linkage in a single authority write. + */ + async planForMission(input: PrdPlanForMissionInput): Promise { + const doc = await this.create({ name: input.name, template: input.template }); + return this.applyLinkage(doc, { + prdId: doc.id, + missionId: input.missionId, + missionVersion: input.missionVersion, + requirementIds: input.requirementIds, + }); + } + + /** + * Render the PRD to a Markdown GENERATED VIEW. + * + * The output carries source identity (PRD id + version + generated-view + * label). It is written under `docs/prdy/.md` and is NEVER read back: + * the authority store only loads `.yaml`/`.yml` files, and no code path in + * this package parses the exported Markdown. + */ + async exportMarkdown(input?: PrdExportInput): Promise { + const doc = await this.get(input?.id); + const content = renderMarkdown(doc); + const filePath = input?.outPath ?? path.join(prdDirectory(doc.projectPath), `${doc.id}.md`); + + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content, 'utf8'); + return { filePath, content }; + } + + /** + * Import a YAML PRD document. + * + * Structural validation (zod) happens BEFORE anything is proposed or + * written. A structurally-valid import is persisted as `draft` — validity is + * NOT approval. If an existing PRD shares the id with divergent content, a + * typed {@link PrdImportConflictError} is thrown carrying a proposed + * successor; the original authority document is left byte-identical on disk. + */ + async importDocument(input: PrdImportInput): Promise { + const incoming = await this.readImportFile(input.filePath); + + const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id); + if (existing === undefined) { + const document = this.buildImportedDocument(incoming); + await savePrd(document); + return { kind: 'created', document }; + } + + if (canonicalCore(existing) === canonicalCore(incoming)) { + return { kind: 'identical', document: existing }; + } + + throw new PrdImportConflictError( + `PRD id "${incoming.id}" already exists with divergent content — refusing to overwrite. ` + + `Proposed successor: version ${existing.version + 1} (draft). ` + + `Accept explicitly with acceptSuccessor().`, + existing, + this.buildSuccessor(existing, incoming), + ); + } + + /** + * Explicitly accept a conflicted import as a successor version of the + * existing PRD. Re-validates the source file before writing; the successor + * is persisted with status `draft` (acceptance of the import is not approval + * of the PRD) and the existing mission linkages are carried forward. + */ + async acceptSuccessor(input: PrdImportInput): Promise { + const incoming = await this.readImportFile(input.filePath); + + const existing = (await listPrds(this.projectPath)).find((doc) => doc.id === incoming.id); + if (existing === undefined) { + throw new PrdNotFoundError( + `No existing PRD with id "${incoming.id}" — use importDocument to create it`, + ); + } + + const successor = this.buildSuccessor(existing, incoming); + await savePrd(successor); + return successor; + } + + // ── internals ────────────────────────────────────────────────────────────── + + private async applyLinkage(doc: PrdDocument, input: PrdLinkMissionInput): Promise { + const entry: PrdMissionLinkage = { + missionId: input.missionId, + missionVersion: input.missionVersion, + prdVersion: doc.version, + requirementIds: input.requirementIds ?? [], + linkedAt: new Date().toISOString(), + }; + + // One entry per mission: refresh in place if the mission is already linked. + const index = doc.missions.findIndex((m) => m.missionId === entry.missionId); + if (index === -1) { + doc.missions.push(entry); + } else { + doc.missions[index] = entry; + } + + // Linkage is mission-side metadata, not a content revision: bump the + // timestamp only so ids/versions stay stable for consumers. + doc.updatedAt = new Date().toISOString(); + await savePrd(doc); + return doc; + } + + private async readImportFile(filePath: string): Promise { + let raw: string; + try { + raw = await fs.readFile(filePath, 'utf8'); + } catch (error) { + throw new PrdImportInvalidError(`Cannot read import file ${filePath}: ${String(error)}`); + } + + let parsed: unknown; + try { + parsed = yaml.load(raw); + } catch (error) { + throw new PrdImportInvalidError(`Import file is not valid YAML: ${String(error)}`); + } + + try { + return parsePrdDocument(parsed); + } catch (error) { + throw new PrdImportInvalidError( + `Import file failed PRD schema validation: ${filePath}`, + error instanceof Error ? error.message : String(error), + ); + } + } + + private buildImportedDocument(incoming: PrdDocument): PrdDocument { + const now = new Date().toISOString(); + return { + ...incoming, + // The import lands in THIS project's authority store. + projectPath: this.projectPath, + // A structurally-valid import is not thereby approved. + status: 'draft', + version: 1, + missions: [], + createdAt: now, + updatedAt: now, + }; + } + + private buildSuccessor(existing: PrdDocument, incoming: PrdDocument): PrdDocument { + return { + ...incoming, + id: existing.id, + projectPath: existing.projectPath, + status: 'draft', + version: existing.version + 1, + missions: existing.missions, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }; + } +} + +// ── Markdown rendering (generated view) ─────────────────────────────────────── + +function canonicalCore(doc: PrdDocument): string { + return JSON.stringify([doc.title, doc.template, doc.sections]); +} + +function renderMarkdown(doc: PrdDocument): string { + const lines: string[] = [ + '', + '', + `# ${doc.title}`, + '', + `**Status:** ${doc.status} · **Version:** ${doc.version} · **Template:** ${doc.template}`, + '', + ]; + + if (doc.missions.length > 0) { + lines.push('## Mission Linkage', ''); + for (const mission of doc.missions) { + const requirements = + mission.requirementIds.length > 0 ? mission.requirementIds.join(', ') : 'none selected'; + lines.push( + `- mission \`${mission.missionId}\` @ version \`${mission.missionVersion}\`` + + ` (linked at PRD v${mission.prdVersion}) — requirements: ${requirements}`, + ); + } + lines.push(''); + } + + for (const section of doc.sections) { + lines.push(`## ${section.title}`, ''); + for (const [field, value] of Object.entries(section.fields)) { + lines.push(`### ${field}`, '', value.trim().length > 0 ? value : '_Not set_.', ''); + } + } + + lines.push('---', '', `_End of generated view for ${doc.id} v${doc.version}._`, ''); + return lines.join('\n'); +} diff --git a/packages/prdy/src/types.ts b/packages/prdy/src/types.ts index 55c3b1a0..7914f79e 100644 --- a/packages/prdy/src/types.ts +++ b/packages/prdy/src/types.ts @@ -19,13 +19,31 @@ export interface PrdSection { fields: Record; } +/** + * Mission ↔ PRD linkage recorded on the PRD document (the YAML authority). + * + * `missionVersion` is the mission-side revision marker available to the CLI + * (the gateway exposes `updatedAt` for missions — there is no numeric mission + * version yet). `prdVersion` snapshots the PRD content version at link time. + */ +export interface PrdMissionLinkage { + missionId: string; + missionVersion: string; + prdVersion: number; + requirementIds: string[]; + linkedAt: string; +} + export interface PrdDocument { id: string; title: string; status: PrdStatus; projectPath: string; template: string; + /** Content revision counter. Bumped by updates and accepted imports. */ + version: number; sections: PrdSection[]; + missions: PrdMissionLinkage[]; createdAt: string; updatedAt: string; } @@ -36,3 +54,60 @@ export interface CreatePrdOptions { template?: string; interactive?: boolean; } + +// ── PrdService surface (single authority entry point) ───────────────────────── + +export interface PrdServiceOptions { + projectPath: string; +} + +export interface PrdCreateInput { + name: string; + template?: string; +} + +export interface PrdSectionPatch { + id: string; + fields: Record; +} + +export interface PrdUpdateInput { + /** Defaults to the most recently updated PRD. */ + id?: string; + sections: PrdSectionPatch[]; +} + +export interface PrdLinkMissionInput { + /** Defaults to the most recently updated PRD. */ + prdId?: string; + missionId: string; + missionVersion: string; + requirementIds?: string[]; +} + +export interface PrdPlanForMissionInput extends PrdLinkMissionInput { + name: string; + template?: string; +} + +export interface PrdExportInput { + /** Defaults to the most recently updated PRD. */ + id?: string; + /** Override the generated-view output path. */ + outPath?: string; +} + +export interface PrdExportResult { + filePath: string; + content: string; +} + +/** Discriminated result of a non-conflicting import. */ +export type PrdImportResult = + | { kind: 'created'; document: PrdDocument } + | { kind: 'identical'; document: PrdDocument }; + +export interface PrdImportInput { + /** Path to a YAML-serialized PRD document (NOT the generated Markdown view). */ + filePath: string; +} diff --git a/packages/prdy/src/wizard.ts b/packages/prdy/src/wizard.ts index 30167478..afa4791a 100644 --- a/packages/prdy/src/wizard.ts +++ b/packages/prdy/src/wizard.ts @@ -2,8 +2,8 @@ import path from 'node:path'; import { cancel, intro, isCancel, outro, select, text } from '@clack/prompts'; -import { createPrd, savePrd } from './prd.js'; -import type { CreatePrdOptions, PrdDocument } from './types.js'; +import { PrdService } from './service.js'; +import type { CreatePrdOptions, PrdDocument, PrdSectionPatch } from './types.js'; interface WizardAnswers { goals: string; @@ -11,20 +11,41 @@ interface WizardAnswers { milestones: string; } -function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void { - const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword)); +/** + * Translate wizard answers into section patches using the same keyword + * matching the wizard always used (first section whose id contains the + * keyword, then first field whose name contains it, else first field). + */ +function buildWizardPatches(doc: PrdDocument, answers: WizardAnswers): PrdSectionPatch[] { + const bySection = new Map(); - if (section === undefined) { - return; - } + const add = (keyword: string, value: string): void => { + const section = doc.sections.find((candidate) => candidate.id.includes(keyword)); + if (section === undefined) { + return; + } - const fieldName = - Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ?? - Object.keys(section.fields)[0]; + const fieldName = + Object.keys(section.fields).find((field) => field.toLowerCase().includes(keyword)) ?? + Object.keys(section.fields)[0]; - if (fieldName !== undefined) { - section.fields[fieldName] = value; - } + if (fieldName === undefined || section.fields[fieldName] === value) { + return; + } + + const existing = bySection.get(section.id); + if (existing === undefined) { + bySection.set(section.id, { id: section.id, fields: { [fieldName]: value } }); + } else { + existing.fields[fieldName] = value; + } + }; + + add('goal', answers.goals); + add('constraint', answers.constraints); + add('milestone', answers.milestones); + + return [...bySection.values()]; } async function promptText(message: string, initialValue = ''): Promise { @@ -63,15 +84,10 @@ async function promptTemplate(template?: string): Promise { return choice; } -function applyWizardAnswers(doc: PrdDocument, answers: WizardAnswers): PrdDocument { - updateSectionField(doc, 'goal', answers.goals); - updateSectionField(doc, 'constraint', answers.constraints); - updateSectionField(doc, 'milestone', answers.milestones); - - doc.updatedAt = new Date().toISOString(); - return doc; -} - +/** + * Interactive PRD wizard. All writes go through PrdService — the wizard is a + * prompt layer, never a second writer path. + */ export async function runPrdWizard(options: CreatePrdOptions): Promise { intro('Mosaic PRD wizard'); @@ -82,20 +98,15 @@ export async function runPrdWizard(options: CreatePrdOptions): Promise 0 ? await service.update({ id: doc.id, sections: patches }) : doc; outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`); diff --git a/scratchpads/ri-4-001-prd-authority.md b/scratchpads/ri-4-001-prd-authority.md new file mode 100644 index 00000000..167d7267 --- /dev/null +++ b/scratchpads/ri-4-001-prd-authority.md @@ -0,0 +1,37 @@ +# Scratchpad — RI-4-001 One transitional PRD authority (RI-N3, #1275) + +- Objective: single PrdService authority in `@mosaicstack/prdy`; `mosaic prdy` and + `mission --plan` become thin adapters; mission↔PRD linkage persisted on disk; + Markdown export is a labeled generated view (never read back); import is + validated/conflict-aware with typed refusals. +- Budget: ~35K tokens (card cap). Baselines: prdy build/lint rc=0, 0 tests; + mosaic build rc=0 (after root turbo build), lint rc=0, 1548 tests pass; + root build rc=0. +- Plan: (1) extend store schema (version, missions linkage) (2) PrdService + + typed errors (3) wizard/cli route through service (4) mosaic adapters + (5) contract specs both packages (6) gates (7) sabotage control (8) report + to /var/tmp/ri-050/ri-4-001-report.md. +- Decisions: + - Linkage lives ON the PRD document (`missions` array) — one authority file, + survives restart, no sidecar sync problems. + - `version` = content revision of sections/status (bumped by update/import + accept). Linkage writes bump `updatedAt` only, so ids/versions stay stable + for the card's "stable ids/versions" contract. + - Mission version marker = `mission.updatedAt` (gateway MissionInfo has no + numeric version field). + - Import reads YAML documents only — never the exported Markdown (keeps the + "no code path reads exported Markdown" invariant). + - Import of an existing id with identical core content → `identical` no-op; + divergent → typed `PrdImportConflictError` carrying proposed successor + (existing.version + 1, status draft, linkages preserved). Original bytes + untouched until explicit `acceptSuccessor`. + - `requirementIds` default `[]` at the mission command (no requirement + selection UI yet) — service accepts ids when a caller has them. +- Progress log: + - [16:35] baselines captured (prdy 0 tests; mosaic 1548 after root build; root build rc=0) + - [16:38] store schema v2 + PrdService + wizard/cli rerouted; prdy build/lint green + - [16:40] mosaic adapters done; prdy spec 20/20 (found+fixed: import project-path leak, empty-store typed error, YAML timestamp coercion) + - [16:44] mosaic specs 9/9 (fixed commander from:'user' argv, vi.mock hoisting, restoreAllMocks wiping factory mocks) + - [16:45] all gates green; 4 commits (e291bfb, 2c5d208, a23826c, 540d6f1) + - [16:46] sabotage: linkage write removed → prdy 3 fail / mosaic 2 fail, 1548/1548 pre-existing pass; restored byte-identically; re-green 20/20 + 1557/1557 + - [16:47] report written to /var/tmp/ri-050/ri-4-001-report.md — card complete