@pugi/cli 0.1.0-beta.26 → 0.1.0-beta.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/core/prd-check/parser.js +215 -0
- package/dist/core/prd-check/reporter.js +127 -0
- package/dist/core/prd-check/verifiers.js +223 -0
- package/dist/core/repl/session.js +44 -0
- package/dist/core/repl/slash-commands.js +9 -0
- package/dist/runtime/cli.js +64 -0
- package/dist/runtime/commands/prd-check.js +235 -0
- package/dist/runtime/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD parser — Pugi α7 Wave 6 (`/prd-check`, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Reads a markdown PRD file and extracts the acceptance-criteria
|
|
5
|
+
* section into a list of verifiable criteria. The parser is
|
|
6
|
+
* intentionally narrow: it owns ONE function, accepts raw markdown
|
|
7
|
+
* source as a string (no fs I/O), and returns a structured list the
|
|
8
|
+
* verifier module can fan out over.
|
|
9
|
+
*
|
|
10
|
+
* Why two phases (parse → verify) instead of a single pass:
|
|
11
|
+
*
|
|
12
|
+
* - keeps the parser deterministic + fast (pure string in, JSON
|
|
13
|
+
* out — trivial to unit-test without touching the filesystem)
|
|
14
|
+
*
|
|
15
|
+
* - lets the reporter render the criterion list even when every
|
|
16
|
+
* verifier fails, so operators see WHAT failed before WHY
|
|
17
|
+
*
|
|
18
|
+
* - mirrors the L17 doctor split: `probe-runner` runs a set of
|
|
19
|
+
* probe descriptors → identical contract here, just for PRD
|
|
20
|
+
* acceptance items instead of environment probes
|
|
21
|
+
*
|
|
22
|
+
* Heading recognition is tolerant by design: PRD authors use both
|
|
23
|
+
* `## Acceptance Criteria` and `## Success Criteria`, sometimes with
|
|
24
|
+
* a trailing colon, sometimes inside an h3. We accept any h2/h3
|
|
25
|
+
* matching either label (case-insensitive). The first matching
|
|
26
|
+
* section wins; subsequent matches are ignored so a PRD with both
|
|
27
|
+
* sections does not double-count items.
|
|
28
|
+
*
|
|
29
|
+
* Item recognition supports two shapes documented in the wave-6
|
|
30
|
+
* spec:
|
|
31
|
+
*
|
|
32
|
+
* 1. numbered lists `1. <text>` / `1) <text>`
|
|
33
|
+
* 2. markdown checklists `- [ ] <text>` / `- [x] <text>`
|
|
34
|
+
*
|
|
35
|
+
* Either shape may include inline mentions the verifier extracts:
|
|
36
|
+
* file paths (`apps/foo/bar.ts`), test specs (`*.spec.ts`),
|
|
37
|
+
* route declarations (`GET /api/x`), CLI commands (`pugi prd-check`),
|
|
38
|
+
* and doc references (`docs/foo.md`). The parser captures these
|
|
39
|
+
* verbatim into `mentions` so the verifier module can fan checks
|
|
40
|
+
* without re-tokenising the prose.
|
|
41
|
+
*/
|
|
42
|
+
const ACCEPTANCE_HEADING_RE = /^(#{2,3})\s+(acceptance criteria|success criteria|deliverables)\b\s*:?\s*$/i;
|
|
43
|
+
const ANY_HEADING_RE = /^(#{1,6})\s+\S/;
|
|
44
|
+
const TITLE_HEADING_RE = /^#\s+(.+?)\s*$/;
|
|
45
|
+
const NUMBERED_ITEM_RE = /^(\s*)(\d+)[\.)]\s+(.+?)\s*$/;
|
|
46
|
+
const CHECKLIST_ITEM_RE = /^(\s*)-\s+\[([ xX])\]\s+(.+?)\s*$/;
|
|
47
|
+
/**
|
|
48
|
+
* Public entry: parse a markdown PRD source into `ParsedPrd`. Pure
|
|
49
|
+
* function — no filesystem, no logging. The CLI handler is
|
|
50
|
+
* responsible for reading the file and forwarding the contents.
|
|
51
|
+
*/
|
|
52
|
+
export function parsePrd(source) {
|
|
53
|
+
const lines = source.split(/\r?\n/);
|
|
54
|
+
const title = extractTitle(lines);
|
|
55
|
+
const range = findAcceptanceRange(lines);
|
|
56
|
+
if (!range) {
|
|
57
|
+
return { title, hasAcceptanceSection: false, criteria: [] };
|
|
58
|
+
}
|
|
59
|
+
const sectionLines = lines.slice(range.start, range.end);
|
|
60
|
+
const criteria = extractCriteria(sectionLines);
|
|
61
|
+
return { title, hasAcceptanceSection: true, criteria };
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Extract verifiable mentions from a criterion text. Exported for
|
|
65
|
+
* the verifier spec so tests can drive mention classification
|
|
66
|
+
* without running the full parser.
|
|
67
|
+
*/
|
|
68
|
+
export function extractMentions(text) {
|
|
69
|
+
const mentions = [];
|
|
70
|
+
const seen = new Set();
|
|
71
|
+
const push = (key, mention) => {
|
|
72
|
+
if (seen.has(key))
|
|
73
|
+
return;
|
|
74
|
+
seen.add(key);
|
|
75
|
+
mentions.push(mention);
|
|
76
|
+
};
|
|
77
|
+
// 1) Routes — `GET /api/path`, `POST /foo`, etc. Recognised
|
|
78
|
+
// BEFORE file paths because the trailing `/` could otherwise
|
|
79
|
+
// be mis-classified.
|
|
80
|
+
const routeRe = /\b(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/[A-Za-z0-9_./:\-]+)/g;
|
|
81
|
+
for (const match of text.matchAll(routeRe)) {
|
|
82
|
+
const method = match[1].toUpperCase();
|
|
83
|
+
const path = match[2];
|
|
84
|
+
push(`route:${method} ${path}`, { kind: 'route', method, path });
|
|
85
|
+
}
|
|
86
|
+
// 2) Backtick-wrapped tokens — most reliable signal. The parser
|
|
87
|
+
// inspects each token and decides whether it is a path, a test
|
|
88
|
+
// spec, a CLI command, or a route.
|
|
89
|
+
const backtickRe = /`([^`\n]+)`/g;
|
|
90
|
+
for (const match of text.matchAll(backtickRe)) {
|
|
91
|
+
const raw = match[1].trim();
|
|
92
|
+
classifyToken(raw, push);
|
|
93
|
+
}
|
|
94
|
+
// 3) Bare paths with at least one slash + an extension. Authors
|
|
95
|
+
// sometimes forget the backticks; we still surface the file
|
|
96
|
+
// so the verifier can attempt the check.
|
|
97
|
+
const barePathRe = /(?<![A-Za-z0-9])((?:[a-zA-Z0-9_.\-]+\/)+[a-zA-Z0-9_.\-]+\.[a-zA-Z0-9]{1,6})/g;
|
|
98
|
+
for (const match of text.matchAll(barePathRe)) {
|
|
99
|
+
const path = match[1];
|
|
100
|
+
classifyPath(path, push);
|
|
101
|
+
}
|
|
102
|
+
return mentions;
|
|
103
|
+
}
|
|
104
|
+
function classifyToken(raw, push) {
|
|
105
|
+
const trimmed = raw.replace(/[,;.]+$/u, '').trim();
|
|
106
|
+
if (trimmed.length === 0)
|
|
107
|
+
return;
|
|
108
|
+
// Route shape inside backticks (`GET /api/x`).
|
|
109
|
+
const routeMatch = trimmed.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\/[A-Za-z0-9_./:\-]+)$/u);
|
|
110
|
+
if (routeMatch) {
|
|
111
|
+
const method = routeMatch[1].toUpperCase();
|
|
112
|
+
const path = routeMatch[2];
|
|
113
|
+
push(`route:${method} ${path}`, { kind: 'route', method, path });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
// Pugi command — `pugi <name>` or `/<name>`. Slash form covers
|
|
117
|
+
// REPL slash commands; pugi form covers shell commands.
|
|
118
|
+
const pugiCmdMatch = trimmed.match(/^pugi\s+([a-z][a-z0-9-]*)(?:\s+.*)?$/u);
|
|
119
|
+
if (pugiCmdMatch) {
|
|
120
|
+
const name = pugiCmdMatch[1];
|
|
121
|
+
push(`command:${name}`, { kind: 'command', name });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const slashCmdMatch = trimmed.match(/^\/([a-z][a-z0-9-]*)$/u);
|
|
125
|
+
if (slashCmdMatch) {
|
|
126
|
+
const name = slashCmdMatch[1];
|
|
127
|
+
push(`command:${name}`, { kind: 'command', name });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Path shape — must contain at least one `/` AND an extension.
|
|
131
|
+
if (trimmed.includes('/') && /\.[a-zA-Z0-9]{1,6}$/.test(trimmed)) {
|
|
132
|
+
classifyPath(trimmed, push);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function classifyPath(path, push) {
|
|
136
|
+
if (/\.spec\.[a-z]+$|\.test\.[a-z]+$/u.test(path)) {
|
|
137
|
+
push(`test:${path}`, { kind: 'test', path });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (/^docs?\//u.test(path) || /\.md$/u.test(path)) {
|
|
141
|
+
push(`doc:${path}`, { kind: 'doc', path });
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
push(`file:${path}`, { kind: 'file', path });
|
|
145
|
+
}
|
|
146
|
+
function extractTitle(lines) {
|
|
147
|
+
for (const line of lines) {
|
|
148
|
+
const match = line.match(TITLE_HEADING_RE);
|
|
149
|
+
if (match) {
|
|
150
|
+
return match[1].trim();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
function findAcceptanceRange(lines) {
|
|
156
|
+
let startIdx = -1;
|
|
157
|
+
let startHeadingLevel = 0;
|
|
158
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
159
|
+
const line = lines[i];
|
|
160
|
+
const match = line.match(ACCEPTANCE_HEADING_RE);
|
|
161
|
+
if (match) {
|
|
162
|
+
startIdx = i + 1;
|
|
163
|
+
startHeadingLevel = match[1].length;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (startIdx === -1)
|
|
168
|
+
return null;
|
|
169
|
+
let endIdx = lines.length;
|
|
170
|
+
for (let i = startIdx; i < lines.length; i += 1) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
const headingMatch = line.match(ANY_HEADING_RE);
|
|
173
|
+
if (!headingMatch)
|
|
174
|
+
continue;
|
|
175
|
+
const level = headingMatch[1].length;
|
|
176
|
+
if (level <= startHeadingLevel) {
|
|
177
|
+
endIdx = i;
|
|
178
|
+
break;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return { start: startIdx, end: endIdx };
|
|
182
|
+
}
|
|
183
|
+
function extractCriteria(sectionLines) {
|
|
184
|
+
const out = [];
|
|
185
|
+
let index = 0;
|
|
186
|
+
for (const line of sectionLines) {
|
|
187
|
+
const checklistMatch = line.match(CHECKLIST_ITEM_RE);
|
|
188
|
+
if (checklistMatch) {
|
|
189
|
+
index += 1;
|
|
190
|
+
const marker = checklistMatch[2].toLowerCase();
|
|
191
|
+
const text = checklistMatch[3];
|
|
192
|
+
out.push({
|
|
193
|
+
index,
|
|
194
|
+
text,
|
|
195
|
+
preChecked: marker === 'x',
|
|
196
|
+
mentions: extractMentions(text),
|
|
197
|
+
});
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const numberedMatch = line.match(NUMBERED_ITEM_RE);
|
|
201
|
+
if (numberedMatch) {
|
|
202
|
+
index += 1;
|
|
203
|
+
const text = numberedMatch[3];
|
|
204
|
+
out.push({
|
|
205
|
+
index,
|
|
206
|
+
text,
|
|
207
|
+
preChecked: false,
|
|
208
|
+
mentions: extractMentions(text),
|
|
209
|
+
});
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
//# sourceMappingURL=parser.js.map
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD-check reporter — Pugi α7 Wave 6 (`/prd-check`, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Turns a list of `VerifiedCriterion` into either a plain-text
|
|
5
|
+
* table (matches the L17 doctor renderer layout for visual
|
|
6
|
+
* consistency) OR a structured JSON envelope for scripted callers.
|
|
7
|
+
*
|
|
8
|
+
* The reporter is intentionally render-only — it does not perform
|
|
9
|
+
* any verification work. The verifier module decides PASS / FAIL /
|
|
10
|
+
* SKIPPED; the reporter only formats those verdicts. This keeps
|
|
11
|
+
* the JSON envelope deterministic + diff-friendly between runs.
|
|
12
|
+
*
|
|
13
|
+
* Exit-code policy:
|
|
14
|
+
*
|
|
15
|
+
* - `healthy` -> every criterion PASS or SKIPPED. exit 0.
|
|
16
|
+
* - `failing` -> at least one FAIL. exit 1.
|
|
17
|
+
* - `unparsed` -> the PRD had no acceptance section. exit 2
|
|
18
|
+
* (operator authored a stub but never filled it
|
|
19
|
+
* in — distinct signal from "criteria don't
|
|
20
|
+
* verify yet" so CI can route differently).
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Build the JSON envelope. Pure transform — no fs, no clock. The
|
|
24
|
+
* CLI handler wraps this in the writeOutput sink.
|
|
25
|
+
*/
|
|
26
|
+
export function buildEnvelope(input) {
|
|
27
|
+
const counts = {
|
|
28
|
+
pass: 0,
|
|
29
|
+
fail: 0,
|
|
30
|
+
skipped: 0,
|
|
31
|
+
};
|
|
32
|
+
for (const v of input.verified) {
|
|
33
|
+
counts[v.status] += 1;
|
|
34
|
+
}
|
|
35
|
+
const overall = computeOverall(input.hasAcceptanceSection, counts);
|
|
36
|
+
return {
|
|
37
|
+
command: 'prd-check',
|
|
38
|
+
prdPath: input.prdPath,
|
|
39
|
+
title: input.title,
|
|
40
|
+
overall,
|
|
41
|
+
counts,
|
|
42
|
+
criteria: input.verified.map((v) => ({
|
|
43
|
+
index: v.criterion.index,
|
|
44
|
+
text: v.criterion.text,
|
|
45
|
+
status: v.status,
|
|
46
|
+
results: v.results.map((r) => ({
|
|
47
|
+
kind: r.mention.kind,
|
|
48
|
+
target: mentionTarget(r.mention),
|
|
49
|
+
status: r.status,
|
|
50
|
+
evidence: r.evidence,
|
|
51
|
+
})),
|
|
52
|
+
})),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
/** Exit code for the resolved overall verdict. */
|
|
56
|
+
export function exitCodeFor(overall) {
|
|
57
|
+
switch (overall) {
|
|
58
|
+
case 'healthy':
|
|
59
|
+
return 0;
|
|
60
|
+
case 'failing':
|
|
61
|
+
return 1;
|
|
62
|
+
case 'unparsed':
|
|
63
|
+
return 2;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Plain-text renderer. Mirrors the L17 doctor table for visual
|
|
68
|
+
* consistency — 4 columns: # / STATUS / CRITERION / EVIDENCE. The
|
|
69
|
+
* criterion column is truncated to 60 chars so narrow terminals
|
|
70
|
+
* stay readable; the full text lives in the JSON envelope for
|
|
71
|
+
* scripted callers that want every byte.
|
|
72
|
+
*/
|
|
73
|
+
export function renderTable(envelope) {
|
|
74
|
+
const lines = [];
|
|
75
|
+
const titlePart = envelope.title ? ` — ${envelope.title}` : '';
|
|
76
|
+
lines.push(`Pugi PRD-check${titlePart}`);
|
|
77
|
+
lines.push('='.repeat(50));
|
|
78
|
+
lines.push(`Source: ${envelope.prdPath}`);
|
|
79
|
+
lines.push('');
|
|
80
|
+
if (envelope.overall === 'unparsed') {
|
|
81
|
+
lines.push('No acceptance-criteria section found in PRD.');
|
|
82
|
+
lines.push('');
|
|
83
|
+
lines.push('Expected one of:');
|
|
84
|
+
lines.push(' ## Acceptance Criteria');
|
|
85
|
+
lines.push(' ## Success Criteria');
|
|
86
|
+
lines.push(' ## Deliverables');
|
|
87
|
+
return lines.join('\n');
|
|
88
|
+
}
|
|
89
|
+
if (envelope.criteria.length === 0) {
|
|
90
|
+
lines.push('Acceptance section present but contains 0 items.');
|
|
91
|
+
return lines.join('\n');
|
|
92
|
+
}
|
|
93
|
+
for (const c of envelope.criteria) {
|
|
94
|
+
const status = c.status.toUpperCase().padEnd(7, ' ');
|
|
95
|
+
const truncated = c.text.length > 60 ? `${c.text.slice(0, 57)}...` : c.text;
|
|
96
|
+
lines.push(`#${String(c.index).padStart(2, ' ')} ${status} ${truncated}`);
|
|
97
|
+
for (const r of c.results) {
|
|
98
|
+
const subStatus = r.status.toUpperCase().padEnd(7, ' ');
|
|
99
|
+
lines.push(` ${subStatus} ${r.evidence}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
lines.push('');
|
|
103
|
+
const { pass, fail, skipped } = envelope.counts;
|
|
104
|
+
const summary = envelope.overall === 'healthy' ? 'HEALTHY' : envelope.overall === 'failing' ? 'FAILING' : 'UNPARSED';
|
|
105
|
+
lines.push(`${fail} fail · ${pass} pass · ${skipped} skipped. Overall: ${summary}`);
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
function computeOverall(hasAcceptanceSection, counts) {
|
|
109
|
+
if (!hasAcceptanceSection)
|
|
110
|
+
return 'unparsed';
|
|
111
|
+
if (counts.fail > 0)
|
|
112
|
+
return 'failing';
|
|
113
|
+
return 'healthy';
|
|
114
|
+
}
|
|
115
|
+
function mentionTarget(mention) {
|
|
116
|
+
switch (mention.kind) {
|
|
117
|
+
case 'file':
|
|
118
|
+
case 'test':
|
|
119
|
+
case 'doc':
|
|
120
|
+
return mention.path;
|
|
121
|
+
case 'command':
|
|
122
|
+
return mention.name;
|
|
123
|
+
case 'route':
|
|
124
|
+
return `${mention.method} ${mention.path}`;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
//# sourceMappingURL=reporter.js.map
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PRD criterion verifiers — Pugi α7 Wave 6 (`/prd-check`, 2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Given a `ParsedCriterion` from `parser.ts`, run a set of built-in
|
|
5
|
+
* checks against the repository workspace and return a
|
|
6
|
+
* `VerifiedCriterion` with a per-criterion verdict + per-mention
|
|
7
|
+
* evidence trail. The module owns the file-system + grep surface
|
|
8
|
+
* the parser deliberately avoided.
|
|
9
|
+
*
|
|
10
|
+
* Verdict semantics (mirror the doctor probe contract so the
|
|
11
|
+
* reporter can render both with the same column layout):
|
|
12
|
+
*
|
|
13
|
+
* - PASS : at least one mention verified AND every attempted
|
|
14
|
+
* verifier passed
|
|
15
|
+
* - FAIL : at least one verifier failed (missing file, empty
|
|
16
|
+
* doc, command not in registry, etc.)
|
|
17
|
+
* - SKIPPED : no verifiable mentions in the criterion. The
|
|
18
|
+
* criterion is preserved in the report so the
|
|
19
|
+
* operator sees it, but it does not gate the verdict.
|
|
20
|
+
*
|
|
21
|
+
* Each `MentionResult` carries an evidence string the reporter
|
|
22
|
+
* shows next to the criterion. For PASS we render the resolved
|
|
23
|
+
* absolute path or matched line; for FAIL we render the missing
|
|
24
|
+
* artifact identifier so the operator can fix it directly.
|
|
25
|
+
*
|
|
26
|
+
* The verifier deps are injected (existsSync, readFileSync, …) so
|
|
27
|
+
* the spec can drive every branch without touching the real disk.
|
|
28
|
+
* The default-bound variant `runDefaultVerifiers` plugs the real
|
|
29
|
+
* Node fs helpers in for the CLI handler.
|
|
30
|
+
*/
|
|
31
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
32
|
+
import { isAbsolute, resolve } from 'node:path';
|
|
33
|
+
/**
|
|
34
|
+
* Top-level verify-one-criterion entry. Walks the mention list,
|
|
35
|
+
* dispatches each to the right verifier, then computes the
|
|
36
|
+
* roll-up. Pure with respect to `deps` — no globals touched.
|
|
37
|
+
*/
|
|
38
|
+
export function verifyCriterion(criterion, deps) {
|
|
39
|
+
if (criterion.mentions.length === 0) {
|
|
40
|
+
return {
|
|
41
|
+
criterion,
|
|
42
|
+
status: 'skipped',
|
|
43
|
+
results: [],
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
const results = [];
|
|
47
|
+
for (const mention of criterion.mentions) {
|
|
48
|
+
results.push(verifyMention(mention, deps));
|
|
49
|
+
}
|
|
50
|
+
const status = rollUp(results);
|
|
51
|
+
return { criterion, status, results };
|
|
52
|
+
}
|
|
53
|
+
/** Verify a whole PRD's worth of criteria. */
|
|
54
|
+
export function verifyAll(criteria, deps) {
|
|
55
|
+
return criteria.map((c) => verifyCriterion(c, deps));
|
|
56
|
+
}
|
|
57
|
+
function verifyMention(mention, deps) {
|
|
58
|
+
switch (mention.kind) {
|
|
59
|
+
case 'file':
|
|
60
|
+
return verifyFile(mention, deps);
|
|
61
|
+
case 'test':
|
|
62
|
+
return verifyTest(mention, deps);
|
|
63
|
+
case 'doc':
|
|
64
|
+
return verifyDoc(mention, deps);
|
|
65
|
+
case 'command':
|
|
66
|
+
return verifyCommand(mention, deps);
|
|
67
|
+
case 'route':
|
|
68
|
+
return verifyRoute(mention, deps);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function verifyFile(mention, deps) {
|
|
72
|
+
const absolute = deps.resolveWorkspacePath(mention.path);
|
|
73
|
+
if (deps.existsSync(absolute)) {
|
|
74
|
+
return {
|
|
75
|
+
mention,
|
|
76
|
+
status: 'pass',
|
|
77
|
+
evidence: `file present (${mention.path})`,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
return {
|
|
81
|
+
mention,
|
|
82
|
+
status: 'fail',
|
|
83
|
+
evidence: `file missing (${mention.path})`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
function verifyTest(mention, deps) {
|
|
87
|
+
const absolute = deps.resolveWorkspacePath(mention.path);
|
|
88
|
+
if (!deps.existsSync(absolute)) {
|
|
89
|
+
return {
|
|
90
|
+
mention,
|
|
91
|
+
status: 'fail',
|
|
92
|
+
evidence: `spec missing (${mention.path})`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
let body;
|
|
96
|
+
try {
|
|
97
|
+
body = deps.readFileSync(absolute);
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
101
|
+
return {
|
|
102
|
+
mention,
|
|
103
|
+
status: 'fail',
|
|
104
|
+
evidence: `spec unreadable: ${message}`,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
// Count `test(`, `it(`, and `describe(...).it(` blocks. The
|
|
108
|
+
// matcher is permissive — any of the three counts because the
|
|
109
|
+
// PRD only cares whether the spec asserts anything at all.
|
|
110
|
+
const matches = body.match(/\b(it|test)\s*\(/g);
|
|
111
|
+
if (!matches || matches.length === 0) {
|
|
112
|
+
return {
|
|
113
|
+
mention,
|
|
114
|
+
status: 'fail',
|
|
115
|
+
evidence: `spec present but has 0 test()/it() blocks`,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
mention,
|
|
120
|
+
status: 'pass',
|
|
121
|
+
evidence: `spec present with ${matches.length} block(s)`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function verifyDoc(mention, deps) {
|
|
125
|
+
const absolute = deps.resolveWorkspacePath(mention.path);
|
|
126
|
+
if (!deps.existsSync(absolute)) {
|
|
127
|
+
return {
|
|
128
|
+
mention,
|
|
129
|
+
status: 'fail',
|
|
130
|
+
evidence: `doc missing (${mention.path})`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
let body;
|
|
134
|
+
try {
|
|
135
|
+
body = deps.readFileSync(absolute);
|
|
136
|
+
}
|
|
137
|
+
catch (error) {
|
|
138
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
139
|
+
return {
|
|
140
|
+
mention,
|
|
141
|
+
status: 'fail',
|
|
142
|
+
evidence: `doc unreadable: ${message}`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
const trimmed = body.trim();
|
|
146
|
+
if (trimmed.length < 100) {
|
|
147
|
+
return {
|
|
148
|
+
mention,
|
|
149
|
+
status: 'fail',
|
|
150
|
+
evidence: `doc present but too short (${trimmed.length} chars, < 100)`,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
return {
|
|
154
|
+
mention,
|
|
155
|
+
status: 'pass',
|
|
156
|
+
evidence: `doc present (${trimmed.length} chars)`,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function verifyCommand(mention, deps) {
|
|
160
|
+
if (deps.isKnownCommand(mention.name)) {
|
|
161
|
+
return {
|
|
162
|
+
mention,
|
|
163
|
+
status: 'pass',
|
|
164
|
+
evidence: `command \`${mention.name}\` registered`,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
mention,
|
|
169
|
+
status: 'fail',
|
|
170
|
+
evidence: `command \`${mention.name}\` not found in CLI registry`,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
function verifyRoute(mention, deps) {
|
|
174
|
+
if (deps.hasRoute(mention.method, mention.path)) {
|
|
175
|
+
return {
|
|
176
|
+
mention,
|
|
177
|
+
status: 'pass',
|
|
178
|
+
evidence: `route ${mention.method} ${mention.path} registered`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return {
|
|
182
|
+
mention,
|
|
183
|
+
status: 'fail',
|
|
184
|
+
evidence: `route ${mention.method} ${mention.path} not found`,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function rollUp(results) {
|
|
188
|
+
if (results.length === 0)
|
|
189
|
+
return 'skipped';
|
|
190
|
+
const anyFail = results.some((r) => r.status === 'fail');
|
|
191
|
+
if (anyFail)
|
|
192
|
+
return 'fail';
|
|
193
|
+
const anyPass = results.some((r) => r.status === 'pass');
|
|
194
|
+
return anyPass ? 'pass' : 'skipped';
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Default verifier deps bound to real fs + a CLI-command predicate
|
|
198
|
+
* + a grep-based route locator. The CLI handler passes the
|
|
199
|
+
* workspace root + the known-command list at call time.
|
|
200
|
+
*/
|
|
201
|
+
export function createDefaultDeps(options) {
|
|
202
|
+
return {
|
|
203
|
+
resolveWorkspacePath: (relative) => {
|
|
204
|
+
if (isAbsolute(relative))
|
|
205
|
+
return relative;
|
|
206
|
+
return resolve(options.workspaceRoot, relative);
|
|
207
|
+
},
|
|
208
|
+
existsSync: (path) => existsSync(path),
|
|
209
|
+
readFileSync: (path) => readFileSync(path, 'utf8'),
|
|
210
|
+
isKnownCommand: (name) => options.knownCommands.has(name),
|
|
211
|
+
hasRoute: () => {
|
|
212
|
+
// Best-effort default: the wave-6 PRD says the route verifier
|
|
213
|
+
// is "best-effort grep of controllers". Since the workspace
|
|
214
|
+
// layout varies per repo, the CLI handler injects a project
|
|
215
|
+
// -aware implementation when one is available; the default
|
|
216
|
+
// stays conservative and reports `fail` so the reporter
|
|
217
|
+
// surfaces the missing-verifier signal instead of silently
|
|
218
|
+
// passing.
|
|
219
|
+
return false;
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
//# sourceMappingURL=verifiers.js.map
|
|
@@ -979,6 +979,50 @@ export class ReplSession {
|
|
|
979
979
|
}
|
|
980
980
|
return verdict;
|
|
981
981
|
}
|
|
982
|
+
case 'prd-check': {
|
|
983
|
+
// Wave 6 (2026-05-27): forward to the same handler the shell
|
|
984
|
+
// surface uses so the verdict is identical between
|
|
985
|
+
// `/prd-check` and `pugi prd-check`. Dynamic-import the
|
|
986
|
+
// module to keep the parser + verifier graph out of the
|
|
987
|
+
// REPL hot path.
|
|
988
|
+
try {
|
|
989
|
+
const { parsePrdCheckArgs, runPrdCheckCommand } = await import('../../runtime/commands/prd-check.js');
|
|
990
|
+
const parsed = parsePrdCheckArgs(verdict.args, { jsonDefault: false });
|
|
991
|
+
if (!parsed.ok) {
|
|
992
|
+
this.appendSystemLine(`/prd-check: ${parsed.error}`);
|
|
993
|
+
return verdict;
|
|
994
|
+
}
|
|
995
|
+
const lines = [];
|
|
996
|
+
await runPrdCheckCommand({
|
|
997
|
+
cwd: process.cwd(),
|
|
998
|
+
...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
|
|
999
|
+
flags: parsed.flags,
|
|
1000
|
+
// The REPL slash does not have a snapshot of the CLI
|
|
1001
|
+
// command registry, so we pass an empty set; the
|
|
1002
|
+
// command:<name> verifier will report FAIL for now.
|
|
1003
|
+
// This is a deliberate trade-off — the slash surface
|
|
1004
|
+
// primarily exists for quick eyeball checks during a
|
|
1005
|
+
// session; the shell surface (which DOES inject the
|
|
1006
|
+
// full registry) is the canonical gate.
|
|
1007
|
+
knownCommands: new Set(),
|
|
1008
|
+
writeOutput: (_payload, text) => {
|
|
1009
|
+
const trimmed = text.replace(/\n+$/u, '');
|
|
1010
|
+
if (trimmed.length > 0)
|
|
1011
|
+
lines.push(trimmed);
|
|
1012
|
+
},
|
|
1013
|
+
});
|
|
1014
|
+
for (const line of lines)
|
|
1015
|
+
this.appendSystemLine(line);
|
|
1016
|
+
if (lines.length === 0) {
|
|
1017
|
+
this.appendSystemLine('/prd-check: no output.');
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
catch (error) {
|
|
1021
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1022
|
+
this.appendSystemLine(`/prd-check failed: ${message}`);
|
|
1023
|
+
}
|
|
1024
|
+
return verdict;
|
|
1025
|
+
}
|
|
982
1026
|
case 'permissions': {
|
|
983
1027
|
// Leak L6: handle the `/permissions [mode] [--persist]` flow.
|
|
984
1028
|
// The session module forwards to the runtime helper so the
|
|
@@ -93,6 +93,7 @@ export const SLASH_COMMAND_HELP = Object.freeze([
|
|
|
93
93
|
{ name: 'help', args: '', gloss: 'Show this help overlay', group: 'Meta' },
|
|
94
94
|
{ name: 'version', args: '', gloss: 'Show CLI version', group: 'Meta' },
|
|
95
95
|
{ name: 'doctor', args: '', gloss: 'Environment health report (auth · API · Node · disk · MCP · …)', group: 'Meta' },
|
|
96
|
+
{ name: 'prd-check', args: '<prd-path | --all> [--json]', gloss: 'Verify PRD acceptance criteria against committed code/tests/docs (Wave 6)', group: 'Meta' },
|
|
96
97
|
{ name: 'stickers', args: '', gloss: 'show Pugi brand stickers (gimmick)', group: 'Meta' },
|
|
97
98
|
{ name: 'feedback', args: '', gloss: 'file a bug / feature / general comment without leaving the REPL', group: 'Meta' },
|
|
98
99
|
{ name: 'share', args: '[--gist|--pugi] [--redact] [--preview]', gloss: 'Export session transcript to gist / pugi.io (leak L20)', group: 'Meta' },
|
|
@@ -432,6 +433,14 @@ export function parseSlashCommand(input) {
|
|
|
432
433
|
// shell surface, not the slash one).
|
|
433
434
|
return { kind: 'doctor' };
|
|
434
435
|
}
|
|
436
|
+
case 'prd-check':
|
|
437
|
+
case 'prdcheck': {
|
|
438
|
+
// Wave 6 (2026-05-27): tokenise the tail and forward verbatim
|
|
439
|
+
// so the slash + shell surfaces share one `parsePrdCheckArgs`.
|
|
440
|
+
// Supports `<prd-path>`, `--all`, and `--json`.
|
|
441
|
+
const tokens = tail.length === 0 ? [] : tail.split(/\s+/).filter((s) => s.length > 0);
|
|
442
|
+
return { kind: 'prd-check', args: tokens };
|
|
443
|
+
}
|
|
435
444
|
case 'compact': {
|
|
436
445
|
// Leak L8 (2026-05-27): graduated from stub. The session module
|
|
437
446
|
// owns the summariser round-trip; tail args are ignored today
|
package/dist/runtime/cli.js
CHANGED
|
@@ -36,6 +36,7 @@ import { isOnboarded } from '../core/onboarding/marker.js';
|
|
|
36
36
|
import { runPrivacyCommand } from './commands/privacy.js';
|
|
37
37
|
import { runReport } from './commands/report.js';
|
|
38
38
|
import { runDoctorCommand, defaultHome as defaultDoctorHome } from './commands/doctor.js';
|
|
39
|
+
import { parsePrdCheckArgs, runPrdCheckCommand, } from './commands/prd-check.js';
|
|
39
40
|
import { runStatusCommand, defaultStatusHome, } from './commands/status.js';
|
|
40
41
|
import { runStickersCommand } from './commands/stickers.js';
|
|
41
42
|
import { runRepoMapCommand } from './commands/repo-map.js';
|
|
@@ -127,6 +128,11 @@ const handlers = {
|
|
|
127
128
|
perms: dispatchPermissions,
|
|
128
129
|
plan: dispatchPlan,
|
|
129
130
|
'plan-review': dispatchPlanReview,
|
|
131
|
+
// Wave 6 (2026-05-27): `pugi prd-check` verifies PRD acceptance
|
|
132
|
+
// criteria against committed code/tests/docs/commands BEFORE an
|
|
133
|
+
// operator (or autonomous agent) claims a feature done. Same
|
|
134
|
+
// handler powers the in-REPL `/prd-check` slash via session.ts.
|
|
135
|
+
'prd-check': dispatchPrdCheck,
|
|
130
136
|
privacy: dispatchPrivacy,
|
|
131
137
|
// L24 (2026-05-27): `pugi release-notes` shows the bundled CHANGELOG
|
|
132
138
|
// diff between the operator's last-seen version + installed version.
|
|
@@ -1641,6 +1647,22 @@ const COMMAND_HELP_BODIES = {
|
|
|
1641
1647
|
'event log, settings), permission mode, and the capability matrix per',
|
|
1642
1648
|
'engine adapter. Safe to run anywhere; no network calls.',
|
|
1643
1649
|
],
|
|
1650
|
+
'prd-check': [
|
|
1651
|
+
'pugi prd-check <prd-path> | --all — Wave 6 verified-deliverable gate.',
|
|
1652
|
+
'',
|
|
1653
|
+
'Reads a markdown PRD, parses the acceptance-criteria section, and',
|
|
1654
|
+
'runs verifiers against committed artifacts:',
|
|
1655
|
+
' file:<path> fs.existsSync',
|
|
1656
|
+
' test:<spec> spec file exists + has ≥1 test()/it() block',
|
|
1657
|
+
' doc:<path> doc exists + has > 100 chars',
|
|
1658
|
+
' command:<name> CLI registry contains the command',
|
|
1659
|
+
' route:METHOD /p best-effort grep of controllers',
|
|
1660
|
+
'',
|
|
1661
|
+
' --all Scan docs/prd/**.md instead of one file.',
|
|
1662
|
+
' --json Emit a structured envelope to stdout.',
|
|
1663
|
+
'',
|
|
1664
|
+
'Exit codes: 0 healthy · 1 failing · 2 unparsed / arg error.',
|
|
1665
|
+
],
|
|
1644
1666
|
status: [
|
|
1645
1667
|
'pugi status — concise session snapshot.',
|
|
1646
1668
|
'',
|
|
@@ -1864,6 +1886,48 @@ async function doctor(_args, flags, _session) {
|
|
|
1864
1886
|
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1865
1887
|
});
|
|
1866
1888
|
}
|
|
1889
|
+
/**
|
|
1890
|
+
* `pugi prd-check` — Wave 6 verified-deliverable gate (2026-05-27).
|
|
1891
|
+
*
|
|
1892
|
+
* Reads `docs/prd/<feature>.md` (or any explicit path), parses the
|
|
1893
|
+
* acceptance-criteria section, and runs file / test / doc / command
|
|
1894
|
+
* / route verifiers per criterion. Same handler powers the in-REPL
|
|
1895
|
+
* `/prd-check` slash via session.ts so the verdict is identical
|
|
1896
|
+
* between surfaces.
|
|
1897
|
+
*
|
|
1898
|
+
* The `knownCommands` set is sourced from the same `handlers` map
|
|
1899
|
+
* used by the CLI dispatcher (one source of truth), so a PRD that
|
|
1900
|
+
* mentions `pugi <name>` resolves against the EXACT registry the
|
|
1901
|
+
* shell exposes.
|
|
1902
|
+
*
|
|
1903
|
+
* Exit codes (from reporter.exitCodeFor):
|
|
1904
|
+
* 0 — healthy (every criterion PASS or SKIPPED)
|
|
1905
|
+
* 1 — failing (≥1 FAIL)
|
|
1906
|
+
* 2 — unparsed (PRD has no acceptance section) OR arg error
|
|
1907
|
+
*/
|
|
1908
|
+
async function dispatchPrdCheck(args, flags, _session) {
|
|
1909
|
+
const parsed = parsePrdCheckArgs(args, { jsonDefault: flags.json });
|
|
1910
|
+
if (!parsed.ok) {
|
|
1911
|
+
writeOutput(flags, { ok: false, error: parsed.error }, parsed.error);
|
|
1912
|
+
process.exitCode = 2;
|
|
1913
|
+
return;
|
|
1914
|
+
}
|
|
1915
|
+
await runPrdCheckCommand({
|
|
1916
|
+
cwd: process.cwd(),
|
|
1917
|
+
...(parsed.prdPath !== undefined ? { prdPath: parsed.prdPath } : {}),
|
|
1918
|
+
flags: parsed.flags,
|
|
1919
|
+
knownCommands: knownCommandNames(),
|
|
1920
|
+
writeOutput: (payload, text) => writeOutput(flags, payload, text),
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
/**
|
|
1924
|
+
* Snapshot the set of registered CLI command names — used by the
|
|
1925
|
+
* prd-check `command:` verifier so PRD mentions of `pugi <name>`
|
|
1926
|
+
* resolve against the exact same registry the shell exposes.
|
|
1927
|
+
*/
|
|
1928
|
+
function knownCommandNames() {
|
|
1929
|
+
return new Set(Object.keys(handlers));
|
|
1930
|
+
}
|
|
1867
1931
|
/**
|
|
1868
1932
|
* `pugi update` — Leak L27 (2026-05-27). Channel-aware npm registry
|
|
1869
1933
|
* probe + optional shell-out to `npm install -g @pugi/cli@<tag>`.
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `pugi prd-check` — Pugi α7 Wave 6 verified-deliverable gate (2026-05-27).
|
|
3
|
+
*
|
|
4
|
+
* Runs a PRD ↔ delivered-artifact verification sweep BEFORE the
|
|
5
|
+
* operator (or an autonomous agent) claims a feature is done.
|
|
6
|
+
* Reads `docs/prd/<feature>.md` (or any explicit path), parses the
|
|
7
|
+
* acceptance-criteria section, and runs file / test / doc / command
|
|
8
|
+
* / route verifiers per criterion. Output mirrors `pugi doctor`:
|
|
9
|
+
* either a plain-text table or a JSON envelope (`--json`).
|
|
10
|
+
*
|
|
11
|
+
* Module contract (mirrors L17 doctor split):
|
|
12
|
+
*
|
|
13
|
+
* - parser + verifiers + reporter are pure with respect to deps;
|
|
14
|
+
* this file is the THIN wiring that resolves cwd, glob-expands
|
|
15
|
+
* `--all`, loads each PRD, and forwards the structured result
|
|
16
|
+
* к the supplied writeOutput sink.
|
|
17
|
+
*
|
|
18
|
+
* - `runPrdCheckCommand` is the single entry point. Both the
|
|
19
|
+
* top-level `pugi prd-check` shell command AND the in-REPL
|
|
20
|
+
* `/prd-check` slash command call it. The function returns the
|
|
21
|
+
* `PrdCheckEnvelope[]` so the REPL can render via Ink without
|
|
22
|
+
* re-running the verification.
|
|
23
|
+
*
|
|
24
|
+
* - Exit codes follow `exitCodeFor` from the reporter:
|
|
25
|
+
* 0 — healthy (every criterion PASS or SKIPPED)
|
|
26
|
+
* 1 — failing (≥1 FAIL across the scanned PRDs)
|
|
27
|
+
* 2 — unparsed (≥1 PRD missing the acceptance section)
|
|
28
|
+
* When `--all` scans multiple PRDs the worst verdict wins (1 > 2 > 0).
|
|
29
|
+
*
|
|
30
|
+
* - The `knownCommands` set is sourced from the CLI registry — we
|
|
31
|
+
* accept it as an injected parameter so the spec can drive
|
|
32
|
+
* command-verification without importing the entire cli.ts
|
|
33
|
+
* module (which would pull the engine graph into the test).
|
|
34
|
+
*/
|
|
35
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
36
|
+
import { isAbsolute, join, relative, resolve } from 'node:path';
|
|
37
|
+
import { parsePrd } from '../../core/prd-check/parser.js';
|
|
38
|
+
import { buildEnvelope, exitCodeFor, renderTable, } from '../../core/prd-check/reporter.js';
|
|
39
|
+
import { createDefaultDeps, verifyAll, } from '../../core/prd-check/verifiers.js';
|
|
40
|
+
const DEFAULT_PRD_DIR = 'docs/prd';
|
|
41
|
+
/**
|
|
42
|
+
* Run the gate. Resolves which PRDs to inspect, runs the parser +
|
|
43
|
+
* verifiers + reporter chain per PRD, emits the combined output,
|
|
44
|
+
* and sets `process.exitCode` to the worst verdict across the set.
|
|
45
|
+
*/
|
|
46
|
+
export async function runPrdCheckCommand(ctx) {
|
|
47
|
+
const paths = resolveTargets(ctx);
|
|
48
|
+
if (paths.length === 0) {
|
|
49
|
+
const result = {
|
|
50
|
+
command: 'prd-check',
|
|
51
|
+
overall: 'unparsed',
|
|
52
|
+
envelopes: [],
|
|
53
|
+
};
|
|
54
|
+
ctx.writeOutput(result, 'No PRD files found.');
|
|
55
|
+
process.exitCode = exitCodeFor('unparsed');
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
const deps = ctx.deps ??
|
|
59
|
+
createDefaultDeps({
|
|
60
|
+
workspaceRoot: ctx.cwd,
|
|
61
|
+
knownCommands: ctx.knownCommands,
|
|
62
|
+
});
|
|
63
|
+
const envelopes = [];
|
|
64
|
+
for (const path of paths) {
|
|
65
|
+
envelopes.push(checkSinglePrd(path, ctx.cwd, deps));
|
|
66
|
+
}
|
|
67
|
+
const overall = combineOverall(envelopes.map((e) => e.overall));
|
|
68
|
+
const result = {
|
|
69
|
+
command: 'prd-check',
|
|
70
|
+
overall,
|
|
71
|
+
envelopes,
|
|
72
|
+
};
|
|
73
|
+
const text = renderRun(result, ctx.cwd);
|
|
74
|
+
ctx.writeOutput(result, text);
|
|
75
|
+
process.exitCode = exitCodeFor(overall);
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render the combined plain-text view. Multi-PRD runs print one
|
|
80
|
+
* table per envelope separated by a divider; single-PRD runs print
|
|
81
|
+
* just the table to keep the output narrow.
|
|
82
|
+
*/
|
|
83
|
+
function renderRun(result, cwd) {
|
|
84
|
+
if (result.envelopes.length === 0) {
|
|
85
|
+
return 'No PRD files found.';
|
|
86
|
+
}
|
|
87
|
+
if (result.envelopes.length === 1) {
|
|
88
|
+
return renderTable(result.envelopes[0]);
|
|
89
|
+
}
|
|
90
|
+
const parts = [];
|
|
91
|
+
for (const envelope of result.envelopes) {
|
|
92
|
+
const relPath = relative(cwd, envelope.prdPath) || envelope.prdPath;
|
|
93
|
+
parts.push(renderTable({ ...envelope, prdPath: relPath }));
|
|
94
|
+
parts.push('');
|
|
95
|
+
}
|
|
96
|
+
parts.push('-'.repeat(50));
|
|
97
|
+
const summary = `${result.envelopes.length} PRD(s) scanned. Overall: ${result.overall.toUpperCase()}`;
|
|
98
|
+
parts.push(summary);
|
|
99
|
+
return parts.join('\n');
|
|
100
|
+
}
|
|
101
|
+
function checkSinglePrd(prdPath, workspaceRoot, deps) {
|
|
102
|
+
let source;
|
|
103
|
+
try {
|
|
104
|
+
source = readFileSync(prdPath, 'utf8');
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
108
|
+
return {
|
|
109
|
+
command: 'prd-check',
|
|
110
|
+
prdPath: relative(workspaceRoot, prdPath) || prdPath,
|
|
111
|
+
title: null,
|
|
112
|
+
overall: 'unparsed',
|
|
113
|
+
counts: { pass: 0, fail: 0, skipped: 0 },
|
|
114
|
+
criteria: [
|
|
115
|
+
{
|
|
116
|
+
index: 0,
|
|
117
|
+
text: `PRD file unreadable: ${message}`,
|
|
118
|
+
status: 'fail',
|
|
119
|
+
results: [],
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const parsed = parsePrd(source);
|
|
125
|
+
const verified = verifyAll(parsed.criteria, deps);
|
|
126
|
+
return buildEnvelope({
|
|
127
|
+
prdPath: relative(workspaceRoot, prdPath) || prdPath,
|
|
128
|
+
title: parsed.title,
|
|
129
|
+
hasAcceptanceSection: parsed.hasAcceptanceSection,
|
|
130
|
+
verified,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
function resolveTargets(ctx) {
|
|
134
|
+
if (ctx.flags.all) {
|
|
135
|
+
const prdDir = resolve(ctx.cwd, DEFAULT_PRD_DIR);
|
|
136
|
+
return listMarkdownFiles(prdDir);
|
|
137
|
+
}
|
|
138
|
+
if (ctx.prdPath) {
|
|
139
|
+
const absolute = isAbsolute(ctx.prdPath)
|
|
140
|
+
? ctx.prdPath
|
|
141
|
+
: resolve(ctx.cwd, ctx.prdPath);
|
|
142
|
+
return [absolute];
|
|
143
|
+
}
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
function listMarkdownFiles(dir) {
|
|
147
|
+
let entries;
|
|
148
|
+
try {
|
|
149
|
+
entries = readdirSync(dir);
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
const out = [];
|
|
155
|
+
for (const entry of entries) {
|
|
156
|
+
const full = join(dir, entry);
|
|
157
|
+
let isDirectory = false;
|
|
158
|
+
try {
|
|
159
|
+
isDirectory = statSync(full).isDirectory();
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
if (isDirectory) {
|
|
165
|
+
out.push(...listMarkdownFiles(full));
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
if (entry.endsWith('.md')) {
|
|
169
|
+
out.push(full);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out.sort();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Combine per-PRD verdicts into the run-wide one. failing > unparsed > healthy.
|
|
176
|
+
* Exported for the spec.
|
|
177
|
+
*/
|
|
178
|
+
export function combineOverall(verdicts) {
|
|
179
|
+
if (verdicts.length === 0)
|
|
180
|
+
return 'unparsed';
|
|
181
|
+
if (verdicts.some((v) => v === 'failing'))
|
|
182
|
+
return 'failing';
|
|
183
|
+
if (verdicts.some((v) => v === 'unparsed'))
|
|
184
|
+
return 'unparsed';
|
|
185
|
+
return 'healthy';
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Parse the CLI argv tail. Accepts:
|
|
189
|
+
*
|
|
190
|
+
* pugi prd-check -> error (no target)
|
|
191
|
+
* pugi prd-check <path> -> single PRD
|
|
192
|
+
* pugi prd-check --all -> scan docs/prd/**.md
|
|
193
|
+
* pugi prd-check <path> --json -> single PRD, JSON envelope
|
|
194
|
+
*
|
|
195
|
+
* `--json` is also forwarded from the global flag set in cli.ts;
|
|
196
|
+
* the local parse re-honours it so the slash command can use the
|
|
197
|
+
* same parser without the global flag plumbing.
|
|
198
|
+
*/
|
|
199
|
+
export function parsePrdCheckArgs(args, options) {
|
|
200
|
+
let json = options.jsonDefault;
|
|
201
|
+
let all = false;
|
|
202
|
+
let prdPath;
|
|
203
|
+
for (const arg of args) {
|
|
204
|
+
if (arg === '--json') {
|
|
205
|
+
json = true;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (arg === '--all') {
|
|
209
|
+
all = true;
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (arg.startsWith('--')) {
|
|
213
|
+
return { ok: false, error: `unknown flag: ${arg}` };
|
|
214
|
+
}
|
|
215
|
+
if (prdPath !== undefined) {
|
|
216
|
+
return { ok: false, error: `unexpected extra argument: ${arg}` };
|
|
217
|
+
}
|
|
218
|
+
prdPath = arg;
|
|
219
|
+
}
|
|
220
|
+
if (!all && prdPath === undefined) {
|
|
221
|
+
return {
|
|
222
|
+
ok: false,
|
|
223
|
+
error: 'pugi prd-check <prd-path> | --all (pass a PRD path or --all to scan docs/prd/**.md)',
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
if (all && prdPath !== undefined) {
|
|
227
|
+
return { ok: false, error: 'cannot combine <path> with --all' };
|
|
228
|
+
}
|
|
229
|
+
return {
|
|
230
|
+
ok: true,
|
|
231
|
+
flags: { json, all },
|
|
232
|
+
...(prdPath !== undefined ? { prdPath } : {}),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
//# sourceMappingURL=prd-check.js.map
|
package/dist/runtime/version.js
CHANGED
|
@@ -44,7 +44,7 @@ export function sanitizeSemver(raw) {
|
|
|
44
44
|
* during import). When bumping the CLI version BOTH literals must be
|
|
45
45
|
* updated; the release smoke-test (`pack:smoke`) verifies they agree.
|
|
46
46
|
*/
|
|
47
|
-
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.
|
|
47
|
+
export const PUGI_CLI_VERSION = sanitizeSemver('0.1.0-beta.28');
|
|
48
48
|
/**
|
|
49
49
|
* Outbound: the CLI's installed semver. Read at request time by
|
|
50
50
|
* `version-interceptor.ts` and injected on every `fetch` call.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pugi/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.28",
|
|
4
4
|
"description": "Pugi CLI - terminal-native software execution system",
|
|
5
5
|
"homepage": "https://pugi.io",
|
|
6
6
|
"repository": {
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"undici": "^8.3.0",
|
|
55
55
|
"zod": "^3.23.0",
|
|
56
56
|
"@pugi/personas": "0.1.2",
|
|
57
|
-
"@pugi/sdk": "0.1.0-beta.
|
|
57
|
+
"@pugi/sdk": "0.1.0-beta.28"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
60
|
"@types/node": "^22.0.0",
|