- 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
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
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<PrdDocument> {
|
|
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<PrdDocument> {
|
|
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<PrdDocument[]> {
|
|
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<PrdDocument> {
|
|
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<PrdDocument> {
|
|
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<PrdMissionLinkage[]> {
|
|
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<PrdDocument> {
|
|
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/<id>.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<PrdExportResult> {
|
|
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<PrdImportResult> {
|
|
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<PrdDocument> {
|
|
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<PrdDocument> {
|
|
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<PrdDocument> {
|
|
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[] = [
|
|
'<!--',
|
|
`${PRD_GENERATED_VIEW_LABEL}`,
|
|
`source-of-truth: docs/prdy/${doc.id}.yaml (YAML authority)`,
|
|
`prd-id: ${doc.id}`,
|
|
`prd-version: ${doc.version}`,
|
|
`generated-at: ${new Date().toISOString()}`,
|
|
'-->',
|
|
'',
|
|
`# ${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');
|
|
}
|