From e291bfb8377bb7c7922cdd29ce049c6b8ba1f96c Mon Sep 17 00:00:00 2001 From: fargo Date: Mon, 17 Aug 2026 16:46:00 -0500 Subject: [PATCH] feat(prdy): add PrdService as the single PRD authority surface (#1275) - PrdService owns create/read/update/link/import/export; wizard and package CLI become prompt layers over the service (no second writer path) - PRD documents gain a content version and a missions linkage array persisted in the YAML authority store (survives restart); linkage writes do not bump the content version - exportMarkdown renders a labeled generated view (id + version + do-not-edit header) that no code path reads back; the store loads .yaml/.yml only - importDocument validates structure with zod before anything else, persists valid imports as draft (validity is not approval), and refuses conflicts with a typed PrdImportConflictError carrying a proposed successor; the original document stays byte-identical until acceptSuccessor - raw store writers (createPrd/savePrd) are no longer exported from the package entry point --- 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 +++--- 7 files changed, 1065 insertions(+), 48 deletions(-) create mode 100644 packages/prdy/src/service.spec.ts create mode 100644 packages/prdy/src/service.ts 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`)}`);