Skip to content

Commit 8a5228b

Browse files
ralyodioclaude
andcommitted
feat(prd): DIP-style numbered PRDs (v0.4.0)
/prd now publishes a numbered, committed proposal collection (OpenPRD 0.2) into the local repo instead of a private single-file doc: - bootstraps prd/README.md (index) + prd/0000-template.md on first run - assigns the next 4-digit number, writes prd/NNNN-slug.md (status: Draft) - author pulled from git config user.email; README index auto-maintained - committed to the repo (no gitignore); lifecycle in front-matter - conforms to the OpenPRD standard published in logicsrc Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 5f00f53 commit 8a5228b

5 files changed

Lines changed: 221 additions & 88 deletions

File tree

README.md

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,27 @@ moshcode install codex # npm i -g @openai/codex
2525
## PRD — plan before you mosh
2626

2727
Write a product requirements doc *first*, then let your coding agents build to it.
28-
`moshcode prd` uses [OpenPRD](https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md)
29-
a lightweight, **single-file** PRD standard: one `prd/<slug>/prd.md` per decision
30-
(problem, goals, users, requirements, metrics). No multi-file ceremony.
28+
`moshcode prd` publishes PRDs per [OpenPRD](https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md)
29+
a **DIP-style** standard: a numbered, committed proposal collection in your repo
30+
(like a BIP/EIP process), one file per decision.
3131

3232
```sh
33-
moshcode prd "parked-domain service expansion" # scaffold a PRD, then hand it to an engine to author
33+
moshcode prd "parked-domain service expansion" # publish the next numbered PRD, then hand it to an engine
3434
moshcode prd # list existing PRDs
3535
```
3636

37-
`moshcode prd <idea>` writes a private `prd/<slug>/prd.md` and hands it to a coding
38-
engine (Claude Code by default) to fill in. **PRDs are private** — the `prd/` folder
39-
is gitignored automatically; only the OpenPRD *standard* itself is public.
37+
`moshcode prd <idea>` bootstraps `prd/` on first use (a `README.md` index +
38+
`0000-template.md`), assigns the next four-digit number, writes
39+
`prd/NNNN-slug.md` (status `Draft`), and hands it to a coding engine (Claude Code
40+
by default) to author. PRDs are **committed** to the repo — they carry a lifecycle
41+
(Draft → Review → Accepted → Final) in their front-matter.
42+
43+
```txt
44+
prd/
45+
README.md # index of PRDs
46+
0000-template.md # the OpenPRD template
47+
0001-parked-domain-expansion.md
48+
```
4049

4150
In the TUI shell it's `/prd [idea]`.
4251

bin/moshcode.mjs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,9 @@ usage:
5555
built-in loop if no file); --max bounds
5656
the while loop (default 3)
5757
moshcode install <engine> install an agentic-coding engine
58-
moshcode prd [idea] write a private PRD (OpenPRD) to prd/<slug>/
59-
and hand it to an engine to author; no arg
60-
lists existing PRDs
58+
moshcode prd [idea] publish the next numbered PRD (OpenPRD) to
59+
prd/NNNN-slug.md and hand it to an engine to
60+
author; no arg lists existing PRDs
6161
moshcode agents list engines + install status
6262
moshcode engines (alias of agents)
6363
moshcode commands list built-in moshscript commands
@@ -152,18 +152,19 @@ async function main() {
152152
if (!rest.length) {
153153
const prds = listPrds();
154154
if (!prds.length) { console.log("no PRDs yet — `moshcode prd <idea>` to start one."); return; }
155-
for (const p of prds) console.log(`${p.slug.padEnd(24)} ${p.status.padEnd(8)} ${p.title}`);
155+
for (const p of prds) console.log(`${p.id} ${p.status.padEnd(9)} ${p.title}`);
156156
return;
157157
}
158158
const idea = rest.join(" ");
159-
const { slug, path: file, existed, gitignored } = createPrd(idea);
159+
const { id, slug, path: file, existed, bootstrapped } = createPrd(idea);
160+
if (bootstrapped) console.log("bootstrapped prd/ — README + 0000-template.md");
160161
console.log(existed
161-
? `PRD ${slug} exists — ${file}`
162-
: `✓ scaffolded prd/${slug}/prd.md (private${gitignored ? ", gitignored" : ""})`);
162+
? `PRD ${id} exists — ${file}`
163+
: `✓ published prd/${id}-${slug}.md (committed — status: Draft)`);
163164
const st = engineStatus();
164165
const chosen = st.find((e) => e.key === "claude" && e.installed) || st.find((e) => e.installed);
165166
if (!chosen) { console.log("open an engine to author it — run: moshcode install claude"); return; }
166-
console.log(`handing ${slug} to ${chosen.key} to author…`);
167+
console.log(`handing ${id} to ${chosen.key} to author…`);
167168
const r = await openSession(ENGINES[chosen.key], [authoringPrompt({ path: file, idea: existed ? "" : idea })]);
168169
return backToPit(chosen.key, r.code, r.signal);
169170
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "moshcode",
3-
"version": "0.3.1",
3+
"version": "0.4.0",
44
"type": "module",
55
"description": "moshcode — a metal wrapper CLI for agentic coding (installs/drives opencode, claude, codex; spec-driven dev via OpenSpec) + moshscript",
66
"bin": {

src/prd.mjs

Lines changed: 187 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,132 @@
1-
// OpenPRD — lightweight product requirements docs, one file per decision.
2-
// moshcode stays a conductor: `/prd` scaffolds a private prd/<slug>/prd.md that
3-
// conforms to the OpenPRD standard published in LogicSRC, then hands the file to
4-
// a coding engine to author. The standard lives at:
5-
// https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md
6-
// PRDs are PRIVATE by convention — we gitignore prd/ so they never get published.
1+
// OpenPRD — DIP-style numbered product requirements docs, published in-repo.
2+
// moshcode is a conductor: `/prd` publishes a numbered proposal into the local
3+
// repo per the OpenPRD standard from LogicSRC, then hands it to a coding engine
4+
// to author. Layout mirrors a BIP/EIP/DIP process:
5+
// prd/README.md index (maintained by this tool)
6+
// prd/0000-template.md the template
7+
// prd/NNNN-slug.md one numbered PRD per file (committed, NOT gitignored)
8+
// Standard: https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md
79
import fs from "node:fs";
810
import path from "node:path";
11+
import { execSync } from "node:child_process";
912

1013
export const OPENPRD = {
11-
version: "0.1",
14+
version: "0.2",
1215
dir: "prd",
1316
standard: "https://github.com/profullstack/logicsrc/blob/master/docs/openprd.md",
1417
};
1518

16-
/** kebab-case slug from an idea/title. Falls back to a generic name. */
19+
const INDEX_START = "<!-- PRD-INDEX:START -->";
20+
const INDEX_END = "<!-- PRD-INDEX:END -->";
21+
22+
/** kebab-case slug from an idea/title. */
1723
export function slugify(text) {
1824
const s = String(text || "")
1925
.toLowerCase()
2026
.replace(/[^a-z0-9]+/g, "-")
2127
.replace(/^-+|-+$/g, "")
2228
.split("-").slice(0, 8).join("-");
23-
return s || "untitled-prd";
29+
return s || "untitled";
2430
}
2531

26-
/** Title-case-ish heading from a slug or idea. */
27-
function titleFrom(idea, slug) {
28-
const raw = String(idea || slug).trim();
32+
function titleFrom(idea) {
33+
const raw = String(idea || "").trim();
2934
const t = raw.length > 80 ? raw.slice(0, 80).trim() + "…" : raw;
30-
return t.charAt(0).toUpperCase() + t.slice(1);
35+
return t ? t.charAt(0).toUpperCase() + t.slice(1) : "Untitled PRD";
3136
}
3237

3338
function today() {
3439
return new Date().toISOString().slice(0, 10);
3540
}
3641

37-
/** Render an OpenPRD-conformant prd.md (front-matter + the 8 required sections). */
38-
export function renderPrd({ slug, title, idea }) {
39-
const seed = idea && idea !== title ? idea : "";
42+
function gitEmail(root) {
43+
try {
44+
return execSync("git config user.email", { cwd: root, stdio: ["ignore", "pipe", "ignore"] })
45+
.toString().trim() || "you@example.com";
46+
} catch { return "you@example.com"; }
47+
}
48+
49+
export function prdDir(root = process.cwd()) {
50+
return path.join(root, OPENPRD.dir);
51+
}
52+
53+
/** The canonical OpenPRD template (prd/0000-template.md), mirroring the standard. */
54+
export function templateFile() {
55+
return `---
56+
openprd: "${OPENPRD.version}"
57+
id: "0000"
58+
title: "Short imperative title — start with a verb if possible"
59+
status: Draft
60+
authors:
61+
- you@example.com
62+
created: 2026-01-01
63+
updated: 2026-01-01
64+
repo:
65+
discussion:
66+
implementation:
67+
tags:
68+
supersedes:
69+
superseded-by:
70+
---
71+
72+
## Problem
73+
74+
The user/business problem, and why it matters now.
75+
76+
## Goals
77+
78+
What success looks like, as outcomes (not features).
79+
80+
## Non-Goals
81+
82+
Explicitly out of scope, to bound the work.
83+
84+
## Users
85+
86+
Who this is for; personas or segments.
87+
88+
## Requirements
89+
90+
- R1 [P0] First required capability.
91+
- R2 [P1] Next capability.
92+
93+
## UX Notes
94+
95+
Flows, states, and constraints that shape the experience.
96+
97+
## Success Metrics
98+
99+
How the goals will be measured.
100+
101+
## Risks & Open Questions
102+
103+
- Known risk or decision still owed.
104+
`;
105+
}
106+
107+
/** A numbered PRD body (prd/NNNN-slug.md), seeded from an idea. */
108+
export function renderPrd({ id, title, idea, author }) {
109+
const seed = idea && idea !== title ? `\n<!-- seed: ${idea} -->` : "";
40110
return `---
41111
openprd: "${OPENPRD.version}"
42-
id: ${slug}
112+
id: "${id}"
43113
title: ${title}
44-
status: draft
114+
status: Draft
115+
authors:
116+
- ${author}
45117
created: ${today()}
118+
updated: ${today()}
119+
repo:
120+
discussion:
121+
implementation:
122+
tags:
123+
supersedes:
124+
superseded-by:
46125
---
47126
48127
## Problem
49-
${seed ? `<!-- seed: ${seed} -->\n` : ""}_Describe the user/business problem, and why it matters now._
128+
${seed}
129+
_Describe the user/business problem, and why it matters now._
50130
51131
## Goals
52132
_What success looks like, as outcomes (not features)._
@@ -72,62 +152,104 @@ _How the goals will be measured._
72152
`;
73153
}
74154

75-
/** Absolute path to prd/<slug>/prd.md under the given root (default cwd). */
76-
export function prdPath(slug, root = process.cwd()) {
77-
return path.join(root, OPENPRD.dir, slug, "prd.md");
78-
}
155+
/** Static README preamble + the auto-maintained index markers. */
156+
function readmeShell() {
157+
return `# PRDs
79158
80-
/** Ensure `prd/` is gitignored so PRDs stay private. Best-effort, idempotent. */
81-
export function ensureGitignored(root = process.cwd()) {
82-
const gi = path.join(root, ".gitignore");
83-
const line = `${OPENPRD.dir}/`;
84-
let body = "";
85-
try { body = fs.readFileSync(gi, "utf8"); } catch { /* no .gitignore yet */ }
86-
if (body.split(/\r?\n/).some((l) => l.trim() === line || l.trim() === OPENPRD.dir)) return false;
87-
const sep = body && !body.endsWith("\n") ? "\n" : "";
88-
try {
89-
fs.writeFileSync(gi, `${body}${sep}# OpenPRD documents are private\n${line}\n`);
90-
return true;
91-
} catch { return false; }
92-
}
159+
Product requirements documents for this repo, following the
160+
[OpenPRD](${OPENPRD.standard}) standard — a numbered, committed proposal
161+
collection (like a BIP/EIP/DIP process).
93162
94-
/**
95-
* Create a private PRD scaffold from an idea. Returns
96-
* { slug, path, existed, gitignored }. Never overwrites an existing PRD.
97-
*/
98-
export function createPrd(idea, root = process.cwd()) {
99-
const slug = slugify(idea);
100-
const title = titleFrom(idea, slug);
101-
const file = prdPath(slug, root);
102-
const existed = fs.existsSync(file);
103-
if (!existed) {
104-
fs.mkdirSync(path.dirname(file), { recursive: true });
105-
fs.writeFileSync(file, renderPrd({ slug, title, idea }));
106-
}
107-
const gitignored = ensureGitignored(root);
108-
return { slug, path: file, existed, gitignored };
163+
Each PRD is one file: \`NNNN-slug.md\`. \`0000-template.md\` is the template.
164+
Lifecycle: **Draft → Review → Accepted → Final** (or Rejected / Withdrawn /
165+
Superseded). Status lives in each file's front-matter.
166+
167+
Start one with \`moshcode prd "<idea>"\` (TUI: \`/prd\`).
168+
169+
## Index
170+
171+
${INDEX_START}
172+
${INDEX_END}
173+
`;
109174
}
110175

111-
/** List existing PRDs under prd/ with their title + status from front-matter. */
176+
/** List numbered PRDs (NNNN-slug.md, excluding the 0000 template). */
112177
export function listPrds(root = process.cwd()) {
113-
const base = path.join(root, OPENPRD.dir);
178+
const base = prdDir(root);
114179
let entries = [];
115-
try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch { return []; }
180+
try { entries = fs.readdirSync(base); } catch { return []; }
116181
const out = [];
117-
for (const e of entries) {
118-
if (!e.isDirectory()) continue;
119-
const file = path.join(base, e.name, "prd.md");
120-
let title = e.name, status = "?";
182+
for (const name of entries) {
183+
const m = name.match(/^(\d{4})-(.+)\.md$/);
184+
if (!m || m[1] === "0000") continue;
185+
const file = path.join(base, name);
186+
let title = m[2], status = "?";
121187
try {
122-
const head = fs.readFileSync(file, "utf8").split(/\r?\n/).slice(0, 12);
188+
const head = fs.readFileSync(file, "utf8").split(/\r?\n/).slice(0, 16);
123189
for (const l of head) {
124190
const t = l.match(/^title:\s*(.+)$/); if (t) title = t[1].trim();
125191
const s = l.match(/^status:\s*(.+)$/); if (s) status = s[1].trim();
126192
}
127193
} catch { continue; }
128-
out.push({ slug: e.name, title, status, path: file });
194+
out.push({ id: m[1], slug: m[2], title, status, file: name, path: file });
129195
}
130-
return out;
196+
return out.sort((a, b) => a.id.localeCompare(b.id));
197+
}
198+
199+
/** Next zero-padded 4-digit id (max existing + 1, min 0001). */
200+
export function nextId(root = process.cwd()) {
201+
const ids = listPrds(root).map((p) => parseInt(p.id, 10)).filter(Number.isFinite);
202+
const max = ids.length ? Math.max(...ids) : 0;
203+
return String(max + 1).padStart(4, "0");
204+
}
205+
206+
/** Rewrite the README index table from the current PRDs on disk. */
207+
export function regenerateIndex(root = process.cwd()) {
208+
const readme = path.join(prdDir(root), "README.md");
209+
let body;
210+
try { body = fs.readFileSync(readme, "utf8"); } catch { return false; }
211+
const prds = listPrds(root);
212+
const rows = prds.length
213+
? ["| # | Title | Status |", "|---|---|---|",
214+
...prds.map((p) => `| [${p.id}](${p.file}) | ${p.title} | ${p.status} |`)].join("\n")
215+
: "_No PRDs yet._";
216+
const next = body.replace(
217+
new RegExp(`${INDEX_START}[\\s\\S]*${INDEX_END}`),
218+
`${INDEX_START}\n${rows}\n${INDEX_END}`,
219+
);
220+
if (next === body) return false;
221+
fs.writeFileSync(readme, next);
222+
return true;
223+
}
224+
225+
/** Create prd/README.md + prd/0000-template.md if missing. Returns true if it bootstrapped. */
226+
export function ensureBootstrap(root = process.cwd()) {
227+
const base = prdDir(root);
228+
fs.mkdirSync(base, { recursive: true });
229+
let did = false;
230+
const tpl = path.join(base, "0000-template.md");
231+
if (!fs.existsSync(tpl)) { fs.writeFileSync(tpl, templateFile()); did = true; }
232+
const readme = path.join(base, "README.md");
233+
if (!fs.existsSync(readme)) { fs.writeFileSync(readme, readmeShell()); did = true; }
234+
return did;
235+
}
236+
237+
/**
238+
* Publish a numbered PRD into the local repo. Bootstraps prd/ on first use.
239+
* Returns { id, slug, path, existed, bootstrapped }.
240+
*/
241+
export function createPrd(idea, root = process.cwd()) {
242+
const bootstrapped = ensureBootstrap(root);
243+
const slug = slugify(idea);
244+
const title = titleFrom(idea);
245+
// Reuse an existing PRD if the same slug already has a number.
246+
const existing = listPrds(root).find((p) => p.slug === slug);
247+
const id = existing ? existing.id : nextId(root);
248+
const file = path.join(prdDir(root), `${id}-${slug}.md`);
249+
const existed = fs.existsSync(file);
250+
if (!existed) fs.writeFileSync(file, renderPrd({ id, title, idea, author: gitEmail(root) }));
251+
regenerateIndex(root);
252+
return { id, slug, path: file, existed, bootstrapped };
131253
}
132254

133255
/** Prompt handed to a coding engine to author the scaffolded PRD in place. */
@@ -136,9 +258,9 @@ export function authoringPrompt({ path: file, idea }) {
136258
`Author a product requirements document at ${file} following the OpenPRD standard`,
137259
`(${OPENPRD.standard}).`,
138260
idea ? `The idea: ${idea}.` : "",
139-
`Keep it a single file. Fill every one of the 8 sections (Problem, Goals, Non-Goals,`,
140-
`Users, Requirements, UX Notes, Success Metrics, Risks & Open Questions), replacing the`,
141-
`placeholder text. Keep the YAML front-matter and its keys. Number requirements R1, R2, …`,
142-
`each with a [P0]/[P1]/[P2] priority. Be concrete and specific to this codebase.`,
261+
`Fill every one of the 8 sections (Problem, Goals, Non-Goals, Users, Requirements,`,
262+
`UX Notes, Success Metrics, Risks & Open Questions), replacing the placeholder text.`,
263+
`Keep the YAML front-matter and its keys (leave status: Draft). Number requirements`,
264+
`R1, R2, … each with a [P0]/[P1]/[P2] priority. Be concrete and specific to this codebase.`,
143265
].filter(Boolean).join(" ");
144266
}

0 commit comments

Comments
 (0)