feat(wave3): add @mosaic/prdy — TypeScript PRD wizard

- PRD CRUD: createPrd, loadPrd, savePrd, listPrds
- Interactive wizard using @clack/prompts
- Built-in templates: software, feature, spike
- CLI: prdy init | list | show
- Depends on @mosaic/types workspace:*
This commit is contained in:
2026-03-06 20:21:39 -06:00
parent 7f7109fc09
commit 3106ca8cf8
11 changed files with 690 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
{
"name": "@mosaic/prdy",
"version": "0.1.0",
"type": "module",
"description": "Mosaic PRD wizard — TypeScript rewrite",
"scripts": {
"build": "tsc -p tsconfig.json",
"typecheck": "tsc --noEmit",
"lint": "eslint src/",
"test": "vitest run"
},
"dependencies": {
"@mosaic/types": "workspace:*",
"@clack/prompts": "^0.9",
"commander": "^13",
"js-yaml": "^4",
"zod": "^3.24"
},
"devDependencies": {
"@types/node": "^22",
"@types/js-yaml": "^4",
"typescript": "^5",
"vitest": "^2"
},
"publishConfig": {
"registry": "https://git.mosaicstack.dev/api/packages/mosaic/npm",
"access": "public"
}
}

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

@@ -0,0 +1,103 @@
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

@@ -0,0 +1,20 @@
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 { 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: import('node:fs').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,86 @@
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 {
if (templateName === undefined || templateName.trim().length === 0) {
return BUILTIN_PRD_TEMPLATES.software;
}
const template = BUILTIN_PRD_TEMPLATES[templateName];
if (template === undefined) {
throw new Error(
`Unknown PRD template: ${templateName}. 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;
}

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

@@ -0,0 +1,121 @@
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;
}

View File

@@ -0,0 +1,36 @@
import { promises as fs } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { createPrd, listPrds, loadPrd } from '../src/prd.js';
describe('prd document lifecycle', () => {
it('creates and loads PRD documents', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'prdy-project-'));
try {
const created = await createPrd({
name: 'User Authentication',
projectPath: projectDir,
template: 'feature',
});
expect(created.title).toBe('User Authentication');
expect(created.status).toBe('draft');
expect(created.id).toMatch(/^user-authentication-\d{8}-\d{6}$/);
const loaded = await loadPrd(projectDir);
expect(loaded.id).toBe(created.id);
expect(loaded.title).toBe(created.title);
expect(loaded.sections.length).toBeGreaterThan(0);
const listed = await listPrds(projectDir);
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(created.id);
} finally {
await fs.rm(projectDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { BUILTIN_PRD_TEMPLATES } from '../src/templates.js';
describe('built-in PRD templates', () => {
it('includes software, feature, and spike templates with required fields', () => {
expect(BUILTIN_PRD_TEMPLATES.software).toBeDefined();
expect(BUILTIN_PRD_TEMPLATES.feature).toBeDefined();
expect(BUILTIN_PRD_TEMPLATES.spike).toBeDefined();
for (const template of Object.values(BUILTIN_PRD_TEMPLATES)) {
expect(template.sections.length).toBeGreaterThan(0);
expect(template.fields.length).toBeGreaterThan(0);
for (const section of template.sections) {
expect(section.id.length).toBeGreaterThan(0);
expect(section.title.length).toBeGreaterThan(0);
expect(section.fields.length).toBeGreaterThan(0);
}
}
});
});

View File

@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
"include": ["src"]
}

31
pnpm-lock.yaml generated
View File

@@ -83,6 +83,37 @@ importers:
specifier: ^2
version: 2.1.9(@types/node@22.19.15)
packages/prdy:
dependencies:
'@clack/prompts':
specifier: ^0.9
version: 0.9.1
'@mosaic/types':
specifier: workspace:*
version: link:../types
commander:
specifier: ^13
version: 13.1.0
js-yaml:
specifier: ^4
version: 4.1.1
zod:
specifier: ^3.24
version: 3.25.76
devDependencies:
'@types/js-yaml':
specifier: ^4
version: 4.0.9
'@types/node':
specifier: ^22
version: 22.19.15
typescript:
specifier: ^5
version: 5.9.3
vitest:
specifier: ^2
version: 2.1.9(@types/node@22.19.15)
packages/queue:
dependencies:
'@modelcontextprotocol/sdk':