feat(prdy): migrate @mosaic/prdy from v0 to v1 #101

Merged
jason.woltje merged 1 commits from feat/p6-prdy into main 2026-03-15 00:44:03 +00:00
8 changed files with 572 additions and 16 deletions

View File

@@ -15,7 +15,15 @@
"typecheck": "tsc --noEmit",
"test": "vitest run --passWithNoTests"
},
"dependencies": {
"@clack/prompts": "^0.9.0",
"commander": "^12.0.0",
"js-yaml": "^4.1.0",
"zod": "^3.22.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.0.0",
"typescript": "^5.8.0",
"vitest": "^2.0.0"
}

100
packages/prdy/src/cli.ts Normal file
View File

@@ -0,0 +1,100 @@
import { Command } from 'commander';
import { createPrd, listPrds, loadPrd } from './prd.js';
import { runPrdWizard } from './wizard.js';
interface InitCommandOptions {
readonly name: string;
readonly project: string;
readonly template?: 'software' | 'feature' | 'spike';
}
interface ListCommandOptions {
readonly project: string;
}
interface ShowCommandOptions {
readonly project: string;
readonly id?: string;
}
export function buildPrdyCli(): Command {
const program = new Command();
program.name('mosaic').description('Mosaic CLI').exitOverride();
const prdy = program.command('prdy').description('PRD wizard commands');
prdy
.command('init')
.description('Create a PRD document')
.requiredOption('--name <name>', 'PRD name')
.requiredOption('--project <path>', 'Project path')
.option('--template <template>', 'Template (software|feature|spike)')
.action(async (options: InitCommandOptions) => {
const doc = process.stdout.isTTY
? await runPrdWizard({
name: options.name,
projectPath: options.project,
template: options.template,
interactive: true,
})
: await createPrd({
name: options.name,
projectPath: options.project,
template: options.template,
interactive: false,
});
console.log(
JSON.stringify(
{
ok: true,
id: doc.id,
title: doc.title,
status: doc.status,
projectPath: doc.projectPath,
},
null,
2,
),
);
});
prdy
.command('list')
.description('List PRD documents for a project')
.requiredOption('--project <path>', 'Project path')
.action(async (options: ListCommandOptions) => {
const docs = await listPrds(options.project);
console.log(JSON.stringify(docs, null, 2));
});
prdy
.command('show')
.description('Show a PRD document')
.requiredOption('--project <path>', 'Project path')
.option('--id <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);
if (match === undefined) {
throw new Error(`PRD id not found: ${options.id}`);
}
console.log(JSON.stringify(match, null, 2));
return;
}
const doc = await loadPrd(options.project);
console.log(JSON.stringify(doc, null, 2));
});
return program;
}
export async function runPrdyCli(argv: readonly string[] = process.argv): Promise<void> {
const program = buildPrdyCli();
await program.parseAsync(argv);
}

View File

@@ -1 +1,12 @@
export const VERSION = '0.0.0';
export { createPrd, loadPrd, savePrd, listPrds } from './prd.js';
export { runPrdWizard } from './wizard.js';
export { buildPrdyCli, runPrdyCli } from './cli.js';
export { BUILTIN_PRD_TEMPLATES, resolveTemplate } from './templates.js';
export type {
PrdStatus,
PrdTemplate,
PrdTemplateSection,
PrdSection,
PrdDocument,
CreatePrdOptions,
} from './types.js';

199
packages/prdy/src/prd.ts Normal file
View File

@@ -0,0 +1,199 @@
import { type Dirent, promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import yaml from 'js-yaml';
import { z } from 'zod';
import { resolveTemplate } from './templates.js';
import type { CreatePrdOptions, PrdDocument } from './types.js';
const PRD_DIRECTORY = path.join('docs', 'prdy');
const PRD_FILE_EXTENSIONS = new Set(['.yaml', '.yml']);
const prdSectionSchema = z.object({
id: z.string().min(1),
title: z.string().min(1),
fields: z.record(z.string(), z.string()),
});
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),
sections: z.array(prdSectionSchema),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
});
function expandHome(projectPath: string): string {
if (!projectPath.startsWith('~')) {
return projectPath;
}
if (projectPath === '~') {
return os.homedir();
}
if (projectPath.startsWith('~/')) {
return path.join(os.homedir(), projectPath.slice(2));
}
return projectPath;
}
function resolveProjectPath(projectPath: string): string {
return path.resolve(expandHome(projectPath));
}
function toSlug(value: string): string {
return value
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
function buildTimestamp(date: Date): { datePart: string; timePart: string } {
const iso = date.toISOString();
return {
datePart: iso.slice(0, 10).replace(/-/g, ''),
timePart: iso.slice(11, 19).replace(/:/g, ''),
};
}
function buildPrdId(name: string): string {
const slug = toSlug(name);
const { datePart, timePart } = buildTimestamp(new Date());
return `${slug || 'prd'}-${datePart}-${timePart}`;
}
function prdDirectory(projectPath: string): string {
return path.join(projectPath, PRD_DIRECTORY);
}
function prdFilePath(projectPath: string, id: string): string {
return path.join(prdDirectory(projectPath), `${id}.yaml`);
}
function isNodeErrorWithCode(error: unknown, code: string): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: string }).code === code
);
}
async function writeFileAtomic(filePath: string, content: string): Promise<void> {
const directory = path.dirname(filePath);
await fs.mkdir(directory, { recursive: true });
const tempPath = path.join(
directory,
`.${path.basename(filePath)}.tmp-${process.pid}-${Date.now()}-${Math.random()
.toString(16)
.slice(2)}`,
);
await fs.writeFile(tempPath, content, 'utf8');
await fs.rename(tempPath, filePath);
}
export async function createPrd(options: CreatePrdOptions): Promise<PrdDocument> {
const resolvedProjectPath = resolveProjectPath(options.projectPath);
const template = resolveTemplate(options.template);
const now = new Date().toISOString();
const document: PrdDocument = {
id: buildPrdId(options.name),
title: options.name.trim(),
status: 'draft',
projectPath: resolvedProjectPath,
template: template.id,
sections: template.sections.map((section) => ({
id: section.id,
title: section.title,
fields: Object.fromEntries(section.fields.map((field) => [field, ''])),
})),
createdAt: now,
updatedAt: now,
};
await savePrd(document);
return document;
}
export async function loadPrd(projectPath: string): Promise<PrdDocument> {
const documents = await listPrds(projectPath);
if (documents.length === 0) {
const resolvedProjectPath = resolveProjectPath(projectPath);
throw new Error(`No PRD documents found in ${prdDirectory(resolvedProjectPath)}`);
}
return documents[0]!;
}
export async function savePrd(doc: PrdDocument): Promise<void> {
const normalized = prdDocumentSchema.parse({
...doc,
projectPath: resolveProjectPath(doc.projectPath),
});
const filePath = prdFilePath(normalized.projectPath, normalized.id);
const serialized = yaml.dump(normalized, {
noRefs: true,
sortKeys: false,
lineWidth: 120,
});
const content = serialized.endsWith('\n') ? serialized : `${serialized}\n`;
await writeFileAtomic(filePath, content);
}
export async function listPrds(projectPath: string): Promise<PrdDocument[]> {
const resolvedProjectPath = resolveProjectPath(projectPath);
const directory = prdDirectory(resolvedProjectPath);
let entries: Dirent[];
try {
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
} catch (error) {
if (isNodeErrorWithCode(error, 'ENOENT')) {
return [];
}
throw error;
}
const documents: PrdDocument[] = [];
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const ext = path.extname(entry.name);
if (!PRD_FILE_EXTENSIONS.has(ext)) {
continue;
}
const filePath = path.join(directory, entry.name);
const raw = await fs.readFile(filePath, 'utf8');
let parsed: unknown;
try {
parsed = yaml.load(raw);
} catch (error) {
throw new Error(`Failed to parse PRD file ${filePath}: ${String(error)}`);
}
const document = prdDocumentSchema.parse(parsed);
documents.push(document);
}
documents.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
return documents;
}

View File

@@ -0,0 +1,93 @@
import type { PrdTemplate } from './types.js';
export const BUILTIN_PRD_TEMPLATES: Record<string, PrdTemplate> = {
software: {
id: 'software',
name: 'Software Project',
fields: ['owner', 'status', 'scopeVersion', 'successMetrics'],
sections: [
{ id: 'introduction', title: 'Introduction', fields: ['context', 'objective'] },
{ id: 'problem-statement', title: 'Problem Statement', fields: ['painPoints'] },
{ id: 'scope-non-goals', title: 'Scope / Non-Goals', fields: ['inScope', 'outOfScope'] },
{ id: 'user-stories', title: 'User Stories / Requirements', fields: ['stories'] },
{ id: 'functional-requirements', title: 'Functional Requirements', fields: ['requirements'] },
{
id: 'non-functional-requirements',
title: 'Non-Functional Requirements',
fields: ['performance', 'reliability', 'security'],
},
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
{
id: 'technical-considerations',
title: 'Technical Considerations',
fields: ['constraints', 'dependencies'],
},
{
id: 'risks-open-questions',
title: 'Risks / Open Questions',
fields: ['risks', 'openQuestions'],
},
{
id: 'milestones-delivery',
title: 'Milestones / Delivery',
fields: ['milestones', 'timeline'],
},
],
},
feature: {
id: 'feature',
name: 'Feature PRD',
fields: ['owner', 'status', 'releaseTarget'],
sections: [
{ id: 'problem-statement', title: 'Problem Statement', fields: ['problem'] },
{ id: 'goals', title: 'Goals', fields: ['goals'] },
{ id: 'scope', title: 'Scope', fields: ['inScope', 'outOfScope'] },
{ id: 'user-stories', title: 'User Stories', fields: ['stories'] },
{ id: 'requirements', title: 'Requirements', fields: ['functional', 'nonFunctional'] },
{ id: 'acceptance-criteria', title: 'Acceptance Criteria', fields: ['criteria'] },
{
id: 'technical-considerations',
title: 'Technical Considerations',
fields: ['constraints'],
},
{
id: 'risks-open-questions',
title: 'Risks / Open Questions',
fields: ['risks', 'questions'],
},
{ id: 'milestones', title: 'Milestones', fields: ['milestones'] },
{ id: 'success-metrics', title: 'Success Metrics / Testing', fields: ['metrics', 'testing'] },
],
},
spike: {
id: 'spike',
name: 'Research Spike',
fields: ['owner', 'status', 'decisionDeadline'],
sections: [
{ id: 'background', title: 'Background', fields: ['context'] },
{ id: 'research-questions', title: 'Research Questions', fields: ['questions'] },
{ id: 'constraints', title: 'Constraints', fields: ['constraints'] },
{ id: 'options', title: 'Options Considered', fields: ['options'] },
{ id: 'evaluation', title: 'Evaluation Criteria', fields: ['criteria'] },
{ id: 'findings', title: 'Findings', fields: ['findings'] },
{ id: 'recommendation', title: 'Recommendation', fields: ['recommendation'] },
{ id: 'risks', title: 'Risks / Unknowns', fields: ['risks', 'unknowns'] },
{ id: 'next-steps', title: 'Next Steps', fields: ['nextSteps'] },
{ id: 'milestones', title: 'Milestones / Delivery', fields: ['milestones'] },
],
},
};
export function resolveTemplate(templateName?: string): PrdTemplate {
const name =
templateName === undefined || templateName.trim().length === 0 ? 'software' : templateName;
const template = BUILTIN_PRD_TEMPLATES[name];
if (template === undefined) {
throw new Error(
`Unknown PRD template: ${name}. Expected one of: ${Object.keys(BUILTIN_PRD_TEMPLATES).join(', ')}`,
);
}
return template;
}

View File

@@ -0,0 +1,38 @@
export type PrdStatus = 'draft' | 'review' | 'approved' | 'archived';
export interface PrdTemplateSection {
id: string;
title: string;
fields: string[];
}
export interface PrdTemplate {
id: string;
name: string;
sections: PrdTemplateSection[];
fields: string[];
}
export interface PrdSection {
id: string;
title: string;
fields: Record<string, string>;
}
export interface PrdDocument {
id: string;
title: string;
status: PrdStatus;
projectPath: string;
template: string;
sections: PrdSection[];
createdAt: string;
updatedAt: string;
}
export interface CreatePrdOptions {
name: string;
projectPath: string;
template?: string;
interactive?: boolean;
}

103
packages/prdy/src/wizard.ts Normal file
View File

@@ -0,0 +1,103 @@
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';
interface WizardAnswers {
goals: string;
constraints: string;
milestones: string;
}
function updateSectionField(doc: PrdDocument, sectionKeyword: string, value: string): void {
const section = doc.sections.find((candidate) => candidate.id.includes(sectionKeyword));
if (section === undefined) {
return;
}
const fieldName =
Object.keys(section.fields).find((field) => field.toLowerCase().includes(sectionKeyword)) ??
Object.keys(section.fields)[0];
if (fieldName !== undefined) {
section.fields[fieldName] = value;
}
}
async function promptText(message: string, initialValue = ''): Promise<string> {
const response = await text({
message,
initialValue,
});
if (isCancel(response)) {
cancel('PRD wizard cancelled.');
throw new Error('PRD wizard cancelled');
}
return response.trim();
}
async function promptTemplate(template?: string): Promise<string> {
if (template !== undefined && template.trim().length > 0) {
return template;
}
const choice = await select({
message: 'PRD type',
options: [
{ value: 'software', label: 'Software project' },
{ value: 'feature', label: 'Feature' },
{ value: 'spike', label: 'Research spike' },
],
});
if (isCancel(choice)) {
cancel('PRD wizard cancelled.');
throw new Error('PRD wizard cancelled');
}
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;
}
export async function runPrdWizard(options: CreatePrdOptions): Promise<PrdDocument> {
intro('Mosaic PRD wizard');
const name =
options.name.trim().length > 0 ? options.name.trim() : await promptText('Project name');
const template = await promptTemplate(options.template);
const goals = await promptText('Primary goals');
const constraints = await promptText('Key constraints');
const milestones = await promptText('Planned milestones');
const doc = await createPrd({
...options,
name,
template,
interactive: true,
});
const updated = applyWizardAnswers(doc, {
goals,
constraints,
milestones,
});
await savePrd(updated);
outro(`PRD created: ${path.join(updated.projectPath, 'docs', 'prdy', `${updated.id}.yaml`)}`);
return updated;
}

34
pnpm-lock.yaml generated
View File

@@ -46,10 +46,10 @@ importers:
version: 13.0.2
'@mariozechner/pi-ai':
specifier: ~0.57.1
version: 0.57.1(ws@8.19.0)(zod@3.25.76)
version: 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-coding-agent':
specifier: ~0.57.1
version: 0.57.1(ws@8.19.0)(zod@3.25.76)
version: 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mosaic/auth':
specifier: workspace:^
version: link:../../packages/auth
@@ -5203,11 +5203,11 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@anthropic-ai/sdk@0.73.0(zod@3.25.76)':
'@anthropic-ai/sdk@0.73.0(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 3.25.76
zod: 4.3.6
'@aws-crypto/crc32@5.2.0':
dependencies:
@@ -6305,9 +6305,9 @@ snapshots:
std-env: 3.10.0
yoctocolors: 2.1.2
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@3.25.76)':
'@mariozechner/pi-agent-core@0.57.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@3.25.76)
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@@ -6317,9 +6317,9 @@ snapshots:
- ws
- zod
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@3.25.76)':
'@mariozechner/pi-ai@0.57.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@anthropic-ai/sdk': 0.73.0(zod@3.25.76)
'@anthropic-ai/sdk': 0.73.0(zod@4.3.6)
'@aws-sdk/client-bedrock-runtime': 3.1008.0
'@google/genai': 1.45.0
'@mistralai/mistralai': 1.14.1
@@ -6327,11 +6327,11 @@ snapshots:
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
chalk: 5.6.2
openai: 6.26.0(ws@8.19.0)(zod@3.25.76)
openai: 6.26.0(ws@8.19.0)(zod@4.3.6)
partial-json: 0.1.7
proxy-agent: 6.5.0
undici: 7.24.0
zod-to-json-schema: 3.25.1(zod@3.25.76)
zod-to-json-schema: 3.25.1(zod@4.3.6)
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@@ -6341,11 +6341,11 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@3.25.76)':
'@mariozechner/pi-coding-agent@0.57.1(ws@8.19.0)(zod@4.3.6)':
dependencies:
'@mariozechner/jiti': 2.6.5
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@3.25.76)
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@3.25.76)
'@mariozechner/pi-agent-core': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-ai': 0.57.1(ws@8.19.0)(zod@4.3.6)
'@mariozechner/pi-tui': 0.57.1
'@silvia-odwyer/photon-node': 0.3.4
chalk: 5.6.2
@@ -9245,10 +9245,10 @@ snapshots:
dependencies:
mimic-function: 5.0.1
openai@6.26.0(ws@8.19.0)(zod@3.25.76):
openai@6.26.0(ws@8.19.0)(zod@4.3.6):
optionalDependencies:
ws: 8.19.0
zod: 3.25.76
zod: 4.3.6
optionator@0.9.4:
dependencies:
@@ -10087,6 +10087,10 @@ snapshots:
dependencies:
zod: 3.25.76
zod-to-json-schema@3.25.1(zod@4.3.6):
dependencies:
zod: 4.3.6
zod@3.25.76: {}
zod@4.3.6: {}