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
79import fs from "node:fs" ;
810import path from "node:path" ;
11+ import { execSync } from "node:child_process" ;
912
1013export 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. */
1723export function slugify ( text ) {
1824 const s = String ( text || "" )
1925 . toLowerCase ( )
2026 . replace ( / [ ^ a - z 0 - 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
3338function 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 `---
41111openprd: "${ OPENPRD . version } "
42- id: ${ slug }
112+ id: " ${ id } "
43113title: ${ title }
44- status: draft
114+ status: Draft
115+ authors:
116+ - ${ author }
45117created: ${ 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) . */
112177export 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 } ) - ( .+ ) \. m d $ / ) ;
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 ( / ^ t i t l e : \s * ( .+ ) $ / ) ; if ( t ) title = t [ 1 ] . trim ( ) ;
125191 const s = l . match ( / ^ s t a t u s : \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