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'); }