Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 51 additions & 3 deletions scripts/construct.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,44 @@ function renderFunctional(srd) {
}
return out.join("\n");
}
function renderFeaturePRD(fr, srd) {
const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``];
out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``);
out.push(`## Context`, ``, srd.product.problem, ``);
out.push(`## Feature`, ``, fr.description, ``);
out.push(`## Acceptance criteria`, ``);
for (const a of fr.acceptance) {
out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`);
}
out.push(``, `## Non-functional requirements`, ``);
if (!fr.nfrs.length) out.push(`_None linked._`);
for (const id of fr.nfrs) {
const nfr = srd.nonFunctional.find((n) => n.id === id);
out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`);
}
out.push(``, `## Data & interfaces`, ``);
out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`);
out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`);
out.push(``, `## Grounding`, ``);
out.push(
fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._`
);
out.push(``);
return out.join("\n");
}
function renderPRDIndex(srd) {
const out = [`# PRDs \u2014 one per functional requirement`, ``];
out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`);
out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``);
out.push(`| PRD | Priority | Title |`);
out.push(`|---|---|---|`);
for (const fr of srd.functional) {
const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`;
out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`);
}
out.push(``);
return out.join("\n");
}
function renderNonFunctional(srd) {
const out = [`# Non-functional requirements`, ``];
if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``);
Expand Down Expand Up @@ -2696,6 +2734,13 @@ function renderSRD(brief, evidence, opts) {
writeFile(out, "00-overview/VISION.md", renderVision(srd), files);
writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files);
writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files);
rmSync2(join9(out, "requirements", "prd"), { recursive: true, force: true });
if (opts.prd) {
for (const fr of srd.functional) {
writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files);
}
writeFile(out, "requirements/prd/README.md", renderPRDIndex(srd), files);
}
writeFile(out, "requirements/NON-FUNCTIONAL.md", renderNonFunctional(srd), files);
writeFile(out, "architecture/SYSTEM-CONTEXT.md", renderSystemContext(srd), files);
writeFile(out, "architecture/DATA-MODEL.md", renderDataModel(srd), files);
Expand Down Expand Up @@ -3500,7 +3545,7 @@ Usage:
construct research --out <run> [--angles market,oss,tech,semantic] [--q "<focus>"] [--semantic]
construct analyze --out <run> [--json]
construct web|oss|tech|so --out <run> [--q "<focus>"] [--url <u,...>] [--seeds <u,...>]
construct render --out <run> [--level light|complex] [--merge] [--no-design]
construct render --out <run> [--level light|complex] [--merge] [--no-design] [--prd]
construct check --out <run> [--min-grounding <0-100>] [--semantic] [--json]
construct review --out <run> [--apply <verdicts.json>] [--max-review N] [--json]
construct verify --out <run> [--app <dir>] [--run-tests] [--strict] [--json]
Expand All @@ -3516,7 +3561,8 @@ Commands:
render Render the SRD tree + SRD.json from brief.json + the dossier.
At --level complex this also renders a design-system subtree
(design/: principles, tokens, components, screens, accessibility);
--no-design opts out.
--no-design opts out. --prd also emits requirements/prd/ \u2014 one
standalone PRD per functional requirement + an index.
check Hard structural gate + advisory grounding-coverage report.
--semantic also folds in the review verdicts (fails on a claim its
cited evidence does not support).
Expand Down Expand Up @@ -3547,6 +3593,7 @@ Options:
--per-source <n> Max evidence items kept per source (default: 6)
--merge Also emit a single-file SRD.md bundle
--no-design For 'render': skip the design-system subtree (complex only)
--prd For 'render': also emit one PRD file per FR (requirements/prd/)
--semantic Rescore evidence with the local embedding model
--refresh Force re-clone of mined OSS repos
--json Machine-readable output
Expand Down Expand Up @@ -3579,7 +3626,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
"apply",
"max-review"
]);
var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design"]);
var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]);
function fail(message) {
process.stderr.write(`construct: ${message}
`);
Expand Down Expand Up @@ -3792,6 +3839,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`);
out,
merge: p.bools.has("merge"),
noDesign: p.bools.has("no-design"),
prd: p.bools.has("prd"),
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
});
const design = r.srd.design;
Expand Down
8 changes: 6 additions & 2 deletions skills/construct/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ No `npm install`, no API keys. Run `--help` for the full surface. Key commands:
- `web|oss|tech|so --out <run> [--q "<focus>"] [--url ...] [--seeds ...]
[--docs-url <u,...>]` — drill ONE angle to stdout (no dossier). Use these to
dig deeper on a thin thread; `--docs-url` grounds known docs pages directly.
- `render --out <run> [--level light|complex] [--merge]` — render the SRD tree +
`SRD.json` from `brief.json` + the dossier.
- `render --out <run> [--level light|complex] [--merge] [--prd]` — render the
SRD tree + `SRD.json` from `brief.json` + the dossier. `--prd` also emits
`requirements/prd/` — one standalone PRD file per functional requirement
(context, acceptance criteria, linked NFRs resolved, [E#] citations) plus an
index, for handing single features to a tracker or an implementation agent.
- `check --out <run> [--min-grounding <0-100>] [--semantic] [--json]` — the HARD
structural gate (exit ≠ 0 on an incomplete SRD) plus the ADVISORY grounding-
coverage report. `--min-grounding N` opts into a second gate that fails below
Expand Down Expand Up @@ -182,6 +185,7 @@ loop to completion; only pause to ask the user a real decision.
00-overview/ VISION.md · SCOPE.md (+ 🧠 open decisions)
requirements/ FUNCTIONAL.md (FR-NNN · priority · Given/When/Then · [E#])
NON-FUNCTIONAL.md (NFR-NNN by category · metric · [E#])
prd/PRD-FR-NNN-*.md + README.md index (--prd only)
architecture/ SYSTEM-CONTEXT.md · DATA-MODEL.md · INTERFACES.md
decisions/NNNN-*.md (ADRs)
design/ PRINCIPLES.md · DESIGN-TOKENS.md (+ design-tokens.json) · COMPONENTS.md
Expand Down
54 changes: 51 additions & 3 deletions skills/construct/scripts/construct.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2446,6 +2446,44 @@ function renderFunctional(srd) {
}
return out.join("\n");
}
function renderFeaturePRD(fr, srd) {
const out = [`# PRD ${fr.id} \u2014 ${fr.title}${cite(fr.rationaleEvidence)}`, ``];
out.push(`_Priority: ${fr.priority}_ \xB7 _Product: ${srd.product.name}_`, ``);
out.push(`## Context`, ``, srd.product.problem, ``);
out.push(`## Feature`, ``, fr.description, ``);
out.push(`## Acceptance criteria`, ``);
for (const a of fr.acceptance) {
out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`);
}
out.push(``, `## Non-functional requirements`, ``);
if (!fr.nfrs.length) out.push(`_None linked._`);
for (const id of fr.nfrs) {
const nfr = srd.nonFunctional.find((n) => n.id === id);
out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` \u2014 metric: ${nfr.metric}` : ""}` : `- **${id}**`);
}
out.push(``, `## Data & interfaces`, ``);
out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "\u2014"}`);
out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "\u2014"}`);
out.push(``, `## Grounding`, ``);
out.push(
fr.rationaleEvidence.length ? `Evidence:${cite(fr.rationaleEvidence)} \u2014 see ../../evidence/EVIDENCE.md.` : `_Ungrounded \u2014 see the grounding report (construct check)._`
);
out.push(``);
return out.join("\n");
}
function renderPRDIndex(srd) {
const out = [`# PRDs \u2014 one per functional requirement`, ``];
out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`);
out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``);
out.push(`| PRD | Priority | Title |`);
out.push(`|---|---|---|`);
for (const fr of srd.functional) {
const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`;
out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`);
}
out.push(``);
return out.join("\n");
}
function renderNonFunctional(srd) {
const out = [`# Non-functional requirements`, ``];
if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``);
Expand Down Expand Up @@ -2696,6 +2734,13 @@ function renderSRD(brief, evidence, opts) {
writeFile(out, "00-overview/VISION.md", renderVision(srd), files);
writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files);
writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files);
rmSync2(join9(out, "requirements", "prd"), { recursive: true, force: true });
if (opts.prd) {
for (const fr of srd.functional) {
writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files);
}
writeFile(out, "requirements/prd/README.md", renderPRDIndex(srd), files);
}
writeFile(out, "requirements/NON-FUNCTIONAL.md", renderNonFunctional(srd), files);
writeFile(out, "architecture/SYSTEM-CONTEXT.md", renderSystemContext(srd), files);
writeFile(out, "architecture/DATA-MODEL.md", renderDataModel(srd), files);
Expand Down Expand Up @@ -3500,7 +3545,7 @@ Usage:
construct research --out <run> [--angles market,oss,tech,semantic] [--q "<focus>"] [--semantic]
construct analyze --out <run> [--json]
construct web|oss|tech|so --out <run> [--q "<focus>"] [--url <u,...>] [--seeds <u,...>]
construct render --out <run> [--level light|complex] [--merge] [--no-design]
construct render --out <run> [--level light|complex] [--merge] [--no-design] [--prd]
construct check --out <run> [--min-grounding <0-100>] [--semantic] [--json]
construct review --out <run> [--apply <verdicts.json>] [--max-review N] [--json]
construct verify --out <run> [--app <dir>] [--run-tests] [--strict] [--json]
Expand All @@ -3516,7 +3561,8 @@ Commands:
render Render the SRD tree + SRD.json from brief.json + the dossier.
At --level complex this also renders a design-system subtree
(design/: principles, tokens, components, screens, accessibility);
--no-design opts out.
--no-design opts out. --prd also emits requirements/prd/ \u2014 one
standalone PRD per functional requirement + an index.
check Hard structural gate + advisory grounding-coverage report.
--semantic also folds in the review verdicts (fails on a claim its
cited evidence does not support).
Expand Down Expand Up @@ -3547,6 +3593,7 @@ Options:
--per-source <n> Max evidence items kept per source (default: 6)
--merge Also emit a single-file SRD.md bundle
--no-design For 'render': skip the design-system subtree (complex only)
--prd For 'render': also emit one PRD file per FR (requirements/prd/)
--semantic Rescore evidence with the local embedding model
--refresh Force re-clone of mined OSS repos
--json Machine-readable output
Expand Down Expand Up @@ -3579,7 +3626,7 @@ var VALUE_FLAGS = /* @__PURE__ */ new Set([
"apply",
"max-review"
]);
var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design"]);
var BOOL_FLAGS = /* @__PURE__ */ new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]);
function fail(message) {
process.stderr.write(`construct: ${message}
`);
Expand Down Expand Up @@ -3792,6 +3839,7 @@ ${v.errors.map((e) => " - " + e).join("\n")}`);
out,
merge: p.bools.has("merge"),
noDesign: p.bools.has("no-design"),
prd: p.bools.has("prd"),
generatedAt: (/* @__PURE__ */ new Date()).toISOString()
});
const design = r.srd.design;
Expand Down
9 changes: 6 additions & 3 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Usage:
construct research --out <run> [--angles market,oss,tech,semantic] [--q "<focus>"] [--semantic]
construct analyze --out <run> [--json]
construct web|oss|tech|so --out <run> [--q "<focus>"] [--url <u,...>] [--seeds <u,...>]
construct render --out <run> [--level light|complex] [--merge] [--no-design]
construct render --out <run> [--level light|complex] [--merge] [--no-design] [--prd]
construct check --out <run> [--min-grounding <0-100>] [--semantic] [--json]
construct review --out <run> [--apply <verdicts.json>] [--max-review N] [--json]
construct verify --out <run> [--app <dir>] [--run-tests] [--strict] [--json]
Expand All @@ -47,7 +47,8 @@ Commands:
render Render the SRD tree + SRD.json from brief.json + the dossier.
At --level complex this also renders a design-system subtree
(design/: principles, tokens, components, screens, accessibility);
--no-design opts out.
--no-design opts out. --prd also emits requirements/prd/ — one
standalone PRD per functional requirement + an index.
check Hard structural gate + advisory grounding-coverage report.
--semantic also folds in the review verdicts (fails on a claim its
cited evidence does not support).
Expand Down Expand Up @@ -78,6 +79,7 @@ Options:
--per-source <n> Max evidence items kept per source (default: 6)
--merge Also emit a single-file SRD.md bundle
--no-design For 'render': skip the design-system subtree (complex only)
--prd For 'render': also emit one PRD file per FR (requirements/prd/)
--semantic Rescore evidence with the local embedding model
--refresh Force re-clone of mined OSS repos
--json Machine-readable output
Expand Down Expand Up @@ -111,7 +113,7 @@ const VALUE_FLAGS = new Set([
"apply",
"max-review",
]);
const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design"]);
const BOOL_FLAGS = new Set(["semantic", "merge", "json", "refresh", "run-tests", "strict", "no-design", "prd"]);

function fail(message: string): never {
process.stderr.write(`construct: ${message}\n`);
Expand Down Expand Up @@ -354,6 +356,7 @@ async function main(): Promise<void> {
out,
merge: p.bools.has("merge"),
noDesign: p.bools.has("no-design"),
prd: p.bools.has("prd"),
generatedAt: new Date().toISOString(),
});
const design = r.srd.design;
Expand Down
15 changes: 15 additions & 0 deletions src/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
renderBuildPlan,
renderTraceability,
renderMergeBundle,
renderFeaturePRD,
renderPRDIndex,
renderDesignPrinciples,
renderDesignTokens,
renderDesignTokensJson,
Expand All @@ -39,6 +41,9 @@ export interface RenderOptions {
// The design-system subtree renders at `complex` unless opted out. Light never
// renders it. Default false (off) when unset.
noDesign?: boolean;
// Also emit requirements/prd/ — one standalone PRD per FR + an index. Default
// false (off) when unset.
prd?: boolean;
}

function writeFile(out: string, rel: string, content: string, files: string[]): void {
Expand Down Expand Up @@ -71,6 +76,16 @@ export function renderSRD(brief: Brief, evidence: EvidenceItem[], opts: RenderOp
writeFile(out, "00-overview/VISION.md", renderVision(srd), files);
writeFile(out, "00-overview/SCOPE.md", renderScope(srd), files);
writeFile(out, "requirements/FUNCTIONAL.md", renderFunctional(srd), files);
// PRD filenames are id+title-derived and the subtree can toggle off. Clear it
// first — same hygiene as the decisions and design dirs — so a re-render never
// leaves a stale per-feature PRD behind.
rmSync(join(out, "requirements", "prd"), { recursive: true, force: true });
if (opts.prd) {
for (const fr of srd.functional) {
writeFile(out, `requirements/prd/PRD-${fr.id}-${slugTitle(fr.title)}.md`, renderFeaturePRD(fr, srd), files);
}
writeFile(out, "requirements/prd/README.md", renderPRDIndex(srd), files);
}
writeFile(out, "requirements/NON-FUNCTIONAL.md", renderNonFunctional(srd), files);
writeFile(out, "architecture/SYSTEM-CONTEXT.md", renderSystemContext(srd), files);
writeFile(out, "architecture/DATA-MODEL.md", renderDataModel(srd), files);
Expand Down
49 changes: 48 additions & 1 deletion src/templates.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { DESIGN_TOKENS_SEEDED_BANNER } from "./types.js";
import type { SRD, ADR, DesignSystem } from "./types.js";
import type { SRD, ADR, DesignSystem, FR } from "./types.js";

// Markdown rendering for every SRD section. Each function is pure (model slice →
// string) so it is trivially golden-testable offline.
Expand Down Expand Up @@ -104,6 +104,53 @@ export function renderFunctional(srd: SRD): string {
return out.join("\n");
}

// One standalone PRD per functional requirement (`render --prd`). A per-feature
// cut of the SAME SRD.json data — FUNCTIONAL.md stays the canonical list — so a
// single feature can be handed to a tracker or an implementation agent whole:
// product context, linked NFRs resolved to their statements, touched entities/
// interfaces, and the grounding citations.
export function renderFeaturePRD(fr: FR, srd: SRD): string {
const out = [`# PRD ${fr.id} — ${fr.title}${cite(fr.rationaleEvidence)}`, ``];
out.push(`_Priority: ${fr.priority}_ · _Product: ${srd.product.name}_`, ``);
out.push(`## Context`, ``, srd.product.problem, ``);
out.push(`## Feature`, ``, fr.description, ``);
out.push(`## Acceptance criteria`, ``);
for (const a of fr.acceptance) {
out.push(`- **Given** ${a.given} **When** ${a.when} **Then** ${a.then}`);
}
out.push(``, `## Non-functional requirements`, ``);
if (!fr.nfrs.length) out.push(`_None linked._`);
for (const id of fr.nfrs) {
const nfr = srd.nonFunctional.find((n) => n.id === id);
out.push(nfr ? `- **${nfr.id}** (${nfr.category}): ${nfr.statement}${nfr.metric ? ` — metric: ${nfr.metric}` : ""}` : `- **${id}**`);
}
out.push(``, `## Data & interfaces`, ``);
out.push(`- Entities: ${fr.entities.length ? fr.entities.join(", ") : "—"}`);
out.push(`- Interfaces: ${fr.interfaces.length ? fr.interfaces.join(", ") : "—"}`);
out.push(``, `## Grounding`, ``);
out.push(
fr.rationaleEvidence.length
? `Evidence:${cite(fr.rationaleEvidence)} — see ../../evidence/EVIDENCE.md.`
: `_Ungrounded — see the grounding report (construct check)._`,
);
out.push(``);
return out.join("\n");
}

export function renderPRDIndex(srd: SRD): string {
const out = [`# PRDs — one per functional requirement`, ``];
out.push(`Rendered from SRD.json by \`construct render --prd\`. The canonical, always-current`);
out.push(`requirement list is [../FUNCTIONAL.md](../FUNCTIONAL.md); re-render after editing.`, ``);
out.push(`| PRD | Priority | Title |`);
out.push(`|---|---|---|`);
for (const fr of srd.functional) {
const file = `PRD-${fr.id}-${slugTitle(fr.title)}.md`;
out.push(`| [${file}](${file}) | ${cell(fr.priority)} | ${cell(fr.title)} |`);
}
out.push(``);
return out.join("\n");
}

export function renderNonFunctional(srd: SRD): string {
const out = [`# Non-functional requirements`, ``];
if (!srd.nonFunctional.length) out.push(`_No non-functional requirements defined._`, ``);
Expand Down
Loading