pan-wizard 3.34.0 → 3.36.0
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/README.md +16 -10
- package/agents/pan-conductor.md +2 -2
- package/agents/pan-debugger.md +3 -1
- package/agents/pan-design-checker.md +1 -0
- package/agents/pan-designer.md +1 -1
- package/agents/pan-executor.md +43 -1
- package/agents/pan-experiment-runner.md +1 -1
- package/agents/pan-hardener.md +3 -1
- package/agents/pan-meta-reviewer.md +1 -1
- package/agents/pan-phase-researcher.md +16 -2
- package/agents/pan-plan-checker.md +37 -9
- package/agents/pan-planner.md +14 -2
- package/agents/pan-previewer.md +2 -0
- package/agents/pan-project-researcher.md +9 -1
- package/agents/pan-research-synthesizer.md +1 -1
- package/agents/pan-reviewer.md +8 -2
- package/agents/pan-verifier.md +3 -3
- package/bin/install-lib.cjs +119 -16
- package/bin/install.js +34 -62
- package/commands/pan/cost.md +21 -5
- package/commands/pan/dashboard.md +0 -3
- package/commands/pan/design-phase.md +2 -2
- package/commands/pan/hud.md +1 -5
- package/commands/pan/knowledge.md +0 -1
- package/commands/pan/mcp-bridge.md +1 -3
- package/commands/pan/optimize.md +2 -0
- package/commands/pan/phase-tests.md +1 -1
- package/commands/pan/preview.md +2 -3
- package/commands/pan/report.md +1 -5
- package/commands/pan/research-phase.md +4 -4
- package/commands/pan/review-deep.md +0 -2
- package/commands/pan/what-if.md +1 -2
- package/hooks/dist/pan-context-monitor.js +12 -3
- package/hooks/dist/pan-guard.js +244 -0
- package/hooks/dist/pan-statusline.js +5 -2
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/commands.cjs +24 -2
- package/pan-wizard-core/bin/lib/config.cjs +13 -1
- package/pan-wizard-core/bin/lib/core.cjs +9 -4
- package/pan-wizard-core/bin/lib/cost.cjs +146 -0
- package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
- package/pan-wizard-core/bin/lib/findings.cjs +239 -23
- package/pan-wizard-core/bin/lib/git.cjs +1 -1
- package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
- package/pan-wizard-core/bin/lib/init.cjs +6 -0
- package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
- package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
- package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
- package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
- package/pan-wizard-core/bin/lib/phase.cjs +61 -9
- package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
- package/pan-wizard-core/bin/lib/preview.cjs +35 -31
- package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
- package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
- package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
- package/pan-wizard-core/bin/lib/usage.cjs +104 -0
- package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
- package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
- package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
- package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
- package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
- package/pan-wizard-core/bin/lib/verify.cjs +27 -27
- package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
- package/pan-wizard-core/bin/pan-tools.cjs +106 -9
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
- package/pan-wizard-core/opencode/pan-wizard.js +36 -8
- package/pan-wizard-core/references/design-methodology.md +1 -1
- package/pan-wizard-core/references/handoff-decisions.md +1 -1
- package/pan-wizard-core/references/model-profiles.md +1 -1
- package/pan-wizard-core/references/planning-config.md +3 -3
- package/pan-wizard-core/references/verdict-contract.md +6 -1
- package/pan-wizard-core/templates/config.json +6 -6
- package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
- package/pan-wizard-core/templates/design.md +5 -4
- package/pan-wizard-core/templates/phase-prompt.md +9 -0
- package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
- package/pan-wizard-core/templates/preview-report.md +1 -1
- package/pan-wizard-core/templates/research-project/architecture.md +6 -0
- package/pan-wizard-core/templates/research-project/features.md +6 -0
- package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
- package/pan-wizard-core/templates/research-project/stack.md +6 -0
- package/pan-wizard-core/templates/research.md +10 -0
- package/pan-wizard-core/templates/state.md +4 -4
- package/pan-wizard-core/templates/summary-complex.md +5 -1
- package/pan-wizard-core/templates/summary-minimal.md +4 -0
- package/pan-wizard-core/templates/summary-standard.md +5 -1
- package/pan-wizard-core/templates/summary.md +8 -1
- package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
- package/pan-wizard-core/workflows/discuss-phase.md +2 -1
- package/pan-wizard-core/workflows/exec-phase.md +92 -45
- package/pan-wizard-core/workflows/execute-plan.md +3 -1
- package/pan-wizard-core/workflows/help.md +103 -44
- package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
- package/pan-wizard-core/workflows/new-project.md +8 -16
- package/pan-wizard-core/workflows/plan-phase.md +40 -8
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/quick.md +2 -2
- package/pan-wizard-core/workflows/remove-phase.md +11 -1
- package/pan-wizard-core/workflows/settings.md +17 -0
- package/pan-wizard-core/workflows/todo-check.md +3 -2
- package/pan-wizard-core/workflows/transition.md +4 -0
- package/pan-wizard-core/workflows/verify-phase.md +40 -47
- package/scripts/build-help.cjs +106 -0
- package/scripts/build-hooks.js +1 -0
- package/scripts/generate-skills-docs.py +2 -2
- package/scripts/mutation-probe.cjs +7 -80
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +169 -6
|
@@ -430,6 +430,30 @@ function appendPhaseChecklistEntry(content, phaseNum, name) {
|
|
|
430
430
|
return lines.join('\n');
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
/**
|
|
434
|
+
* Add an inserted phase's "## Phases" checklist entry after its base phase's entry and
|
|
435
|
+
* that phase's existing decimals, so the list stays in numeric order (appending put 3.1
|
|
436
|
+
* after Phase 4; found by H7's property test). Falls back to appending when the base
|
|
437
|
+
* phase has no checklist entry.
|
|
438
|
+
*
|
|
439
|
+
* @param {string} content - Full roadmap.md content
|
|
440
|
+
* @param {string} basePhase - The integer phase inserted after, without leading zeros
|
|
441
|
+
* @param {string} phaseNum - The inserted phase's number as it should appear
|
|
442
|
+
* @param {string} name - Phase name/description
|
|
443
|
+
* @returns {string} Updated content
|
|
444
|
+
*/
|
|
445
|
+
function insertPhaseChecklistEntry(content, basePhase, phaseNum, name) {
|
|
446
|
+
const lines = content.split('\n');
|
|
447
|
+
const own = new RegExp(`^\\s*- \\[[ x]\\]\\s*(?:\\*\\*)?Phase\\s+0*${escapeRegex(basePhase)}(?:\\.\\d+)*:`, 'i');
|
|
448
|
+
let lastIdx = -1;
|
|
449
|
+
for (let i = 0; i < lines.length; i++) {
|
|
450
|
+
if (own.test(lines[i])) lastIdx = i;
|
|
451
|
+
}
|
|
452
|
+
if (lastIdx === -1) return appendPhaseChecklistEntry(content, phaseNum, name);
|
|
453
|
+
lines.splice(lastIdx + 1, 0, `- [ ] **Phase ${phaseNum}: ${name}**`);
|
|
454
|
+
return lines.join('\n');
|
|
455
|
+
}
|
|
456
|
+
|
|
433
457
|
/**
|
|
434
458
|
* Append a new phase to the end of the roadmap and create its directory.
|
|
435
459
|
* @param {string} cwd - Working directory path
|
|
@@ -581,20 +605,24 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
|
|
581
605
|
}
|
|
582
606
|
|
|
583
607
|
const headerIdx = content.indexOf(headerMatch[0]);
|
|
584
|
-
const
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
608
|
+
const bodyStart = headerIdx + headerMatch[0].length;
|
|
609
|
+
const afterHeader = content.slice(bodyStart);
|
|
610
|
+
// The new section goes before the next phase that is not one of this phase's decimals:
|
|
611
|
+
// after 06.1 and 06.2, not straight after Phase 6, or the roadmap (and `roadmap analyze`,
|
|
612
|
+
// which lists phases in roadmap order) would read 6, 6.3, 6.2, 6.1 (found by H7's
|
|
613
|
+
// property test).
|
|
614
|
+
const ownDecimal = new RegExp(`^#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}\\.\\d`, 'i');
|
|
615
|
+
const headingRe = /\n(#{2,4}\s+Phase\s+\d[^\n]*)/gi;
|
|
616
|
+
let insertIdx = content.length;
|
|
617
|
+
let heading;
|
|
618
|
+
while ((heading = headingRe.exec(afterHeader)) !== null) {
|
|
619
|
+
if (!ownDecimal.test(heading[1])) { insertIdx = bodyStart + heading.index; break; }
|
|
592
620
|
}
|
|
593
621
|
|
|
594
622
|
// Insert the detail section, then the matching checklist entry — same reason as
|
|
595
623
|
// cmdPhaseAdd: without it the inserted phase never appears in the "## Phases" list.
|
|
596
624
|
const withEntry = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
|
|
597
|
-
const updatedContent =
|
|
625
|
+
const updatedContent = insertPhaseChecklistEntry(withEntry, unpadded, decimalPhase, description);
|
|
598
626
|
try {
|
|
599
627
|
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
|
600
628
|
} catch (e) {
|
|
@@ -823,6 +851,26 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
|
|
|
823
851
|
error(`Phase ${phaseNum} not found`);
|
|
824
852
|
}
|
|
825
853
|
|
|
854
|
+
// The phase gate (V1). In `enforce` mode a refusal stops here unless --force
|
|
855
|
+
// --reason, which records a decision; in `warn` mode the reasons travel with the
|
|
856
|
+
// result. A --force without a reason is refused in either mode.
|
|
857
|
+
const { phaseGate, recordGateOverride } = require('./phase-gate.cjs');
|
|
858
|
+
const force = !!(opts && opts.force);
|
|
859
|
+
const forceReason = opts && typeof opts.reason === 'string' ? opts.reason.trim() : '';
|
|
860
|
+
if (force && !forceReason) {
|
|
861
|
+
output({ error: 'reason_required', reason: 'phase complete --force needs --reason "<why the gate is being overridden>"' }, raw);
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
const gate = phaseGate(cwd, phaseNum);
|
|
865
|
+
let gateOverride = null;
|
|
866
|
+
if (!gate.error && !gate.ok) {
|
|
867
|
+
if (gate.mode === 'enforce' && !force) {
|
|
868
|
+
output({ error: 'phase_gate_refused', reason: gate.reasons.map((r) => `${r.code}: ${r.detail}`).join(' | '), gate }, raw);
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
if (force) gateOverride = recordGateOverride(cwd, gate, forceReason);
|
|
872
|
+
}
|
|
873
|
+
|
|
826
874
|
const planCount = phaseInfo.plans.length;
|
|
827
875
|
const summaryCount = phaseInfo.summaries.length;
|
|
828
876
|
|
|
@@ -886,6 +934,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
|
|
|
886
934
|
date: today,
|
|
887
935
|
roadmap_updated: fileAccessible(roadmapPath),
|
|
888
936
|
state_updated: fileAccessible(statePath),
|
|
937
|
+
gate: gate.error ? { error: gate.error } : { ok: gate.ok, mode: gate.mode, reasons: gate.reasons, not_checked: gate.not_checked },
|
|
938
|
+
...(gateOverride ? { gate_override: gateOverride } : {}),
|
|
889
939
|
};
|
|
890
940
|
if (roadmapResult && roadmapResult.requirements_warning) {
|
|
891
941
|
result.requirements_warning = roadmapResult.requirements_warning;
|
|
@@ -982,6 +1032,8 @@ module.exports = {
|
|
|
982
1032
|
cmdFindPhase,
|
|
983
1033
|
cmdPhasePlanIndex,
|
|
984
1034
|
blockedPlans,
|
|
1035
|
+
buildPlanIndex,
|
|
1036
|
+
dependencyNames,
|
|
985
1037
|
cmdPhaseAdd,
|
|
986
1038
|
cmdPhaseInsert,
|
|
987
1039
|
cmdPhaseRemove,
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Plan intent checked in code: the parts of a plan check that are reading, not
|
|
4
|
+
* judgement, so the plan-checker is not spent on them.
|
|
5
|
+
*
|
|
6
|
+
* `verify review-focus <phase>` (program P4, M41). A plan names, under
|
|
7
|
+
* `## Review Focus`, at most five inputs or failure modes its requirements imply and
|
|
8
|
+
* no test exercises yet, each pinned to the task that owns the code and the test that
|
|
9
|
+
* task adds. A plan with none says so on one `None — <reason>` line. The reviewer gets
|
|
10
|
+
* the section through `review scope` (R1).
|
|
11
|
+
*
|
|
12
|
+
* `verify decisions <phase>` (program P2). The phase design records its decisions with
|
|
13
|
+
* ids (`DD-N`), and only the ones that pass the three-question test (hard to reverse,
|
|
14
|
+
* a reasonable engineer could choose otherwise, more than one task depends on it).
|
|
15
|
+
* Plans cite the ids they implement. A plan citing an id the design does not record
|
|
16
|
+
* relies on a decision nobody approved; a recorded decision no plan cites is approved
|
|
17
|
+
* design nobody builds. A design from before ids is `legacy`: a warning, never a refusal.
|
|
18
|
+
*/
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const { output, error, toLf } = require('./core.cjs');
|
|
22
|
+
const { getPlanId } = require('./constants.cjs');
|
|
23
|
+
const { phaseFiles } = require('./verify-plan-set.cjs');
|
|
24
|
+
const { sectionBody } = require('./verify-scope.cjs');
|
|
25
|
+
|
|
26
|
+
const REVIEW_FOCUS_MAX = 5;
|
|
27
|
+
const DECISION_ID_RE = /\bDD-\d+\b/g;
|
|
28
|
+
|
|
29
|
+
function readText(file) {
|
|
30
|
+
try { return toLf(fs.readFileSync(file, 'utf-8')); } catch { return ''; }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The phase's plans as `{plan, file, text}`, or `{error, reason}`. */
|
|
34
|
+
function phasePlans(cwd, phase) {
|
|
35
|
+
const pf = phaseFiles(cwd, phase);
|
|
36
|
+
if (pf.error) return pf;
|
|
37
|
+
return { pf, plans: pf.planFiles.map((file) => ({ plan: getPlanId(file), file, text: readText(path.join(pf.dir, file)) })) };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Review Focus (P4) ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Text with HTML comments removed: repeated until nothing changes, since removing
|
|
44
|
+
* one can join the text around it into another, and an unclosed `<!--` hides the
|
|
45
|
+
* rest. A placeholder comment is not a Review Focus item.
|
|
46
|
+
*/
|
|
47
|
+
function stripComments(s) {
|
|
48
|
+
let prev;
|
|
49
|
+
do { prev = s; s = s.replace(/<!--[\s\S]*?-->/g, ''); } while (s !== prev);
|
|
50
|
+
const open = s.indexOf('<!--');
|
|
51
|
+
return open === -1 ? s : s.slice(0, open);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Check one plan's `## Review Focus`. */
|
|
55
|
+
function reviewFocusOf(p) {
|
|
56
|
+
const body = sectionBody(p.text, 'Review Focus');
|
|
57
|
+
const out = { plan: p.plan, file: p.file, status: 'ok', items: 0 };
|
|
58
|
+
const errors = [];
|
|
59
|
+
if (body == null) {
|
|
60
|
+
errors.push({ code: 'review_focus_missing', plan: p.plan, detail: 'no `## Review Focus` section' });
|
|
61
|
+
return { ...out, status: 'missing', errors };
|
|
62
|
+
}
|
|
63
|
+
const text = stripComments(body).trim();
|
|
64
|
+
const items = text.split('\n').map((l) => l.match(/^\s*(?:[-*]|\d+[.)])\s+(.*\S)/)).filter(Boolean).map((m) => m[1]);
|
|
65
|
+
if (!items.length) {
|
|
66
|
+
// `None — <reason>`: the reason is what tells the checker the planner looked.
|
|
67
|
+
const none = text.match(/^none\b[\s—–:-]*(.*)$/im);
|
|
68
|
+
if (none && none[1].trim().split(/\s+/).length >= 3) return { ...out, status: 'none', errors };
|
|
69
|
+
errors.push({ code: 'review_focus_empty', plan: p.plan, detail: 'no items, and no `None — <reason>` line' });
|
|
70
|
+
return { ...out, status: 'invalid', errors };
|
|
71
|
+
}
|
|
72
|
+
out.items = items.length;
|
|
73
|
+
if (items.length > REVIEW_FOCUS_MAX) {
|
|
74
|
+
errors.push({ code: 'review_focus_too_many', plan: p.plan, detail: `${items.length} items; at most ${REVIEW_FOCUS_MAX}` });
|
|
75
|
+
}
|
|
76
|
+
const taskCount = (p.text.match(/<task\b/g) || []).length;
|
|
77
|
+
for (const item of items) {
|
|
78
|
+
const short = item.slice(0, 80);
|
|
79
|
+
const task = item.match(/\bTask\s+(\d+)\b/i);
|
|
80
|
+
if (!task) errors.push({ code: 'review_focus_no_task', plan: p.plan, detail: `names no owning task: ${short}` });
|
|
81
|
+
else if (Number(task[1]) < 1 || Number(task[1]) > taskCount) errors.push({ code: 'review_focus_unknown_task', plan: p.plan, detail: `Task ${task[1]} is not one of the plan's ${taskCount} tasks: ${short}` });
|
|
82
|
+
if (!/\btests?\b/i.test(item)) errors.push({ code: 'review_focus_no_test', plan: p.plan, detail: `names no test: ${short}` });
|
|
83
|
+
}
|
|
84
|
+
return { ...out, status: errors.length ? 'invalid' : 'ok', errors };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @returns {{phase, ok, plans: Array<{plan, file, status, items}>, errors}} or `{error, reason}`
|
|
89
|
+
*/
|
|
90
|
+
function checkReviewFocus(cwd, phase) {
|
|
91
|
+
const pp = phasePlans(cwd, phase);
|
|
92
|
+
if (pp.error) return pp;
|
|
93
|
+
const results = pp.plans.map(reviewFocusOf);
|
|
94
|
+
const errors = results.flatMap((r) => r.errors);
|
|
95
|
+
return { phase: pp.pf.info.phase_number, ok: errors.length === 0, plans: results.map(({ errors: _e, ...r }) => r), errors };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ─── Decision spine (P2) ─────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
/** Decision ids a design records: the first cell of a table row, or the head of a list item. */
|
|
101
|
+
function recordedDecisions(text) {
|
|
102
|
+
const ids = [];
|
|
103
|
+
for (const line of text.split('\n')) {
|
|
104
|
+
const m = line.match(/^\s*\|\s*\**(DD-\d+)\**\s*\|/) || line.match(/^\s*(?:[-*]|\d+[.)])\s+\**(DD-\d+)\**\b/) || line.match(/^#{2,6}\s+(DD-\d+)\b/);
|
|
105
|
+
if (m) ids.push(m[1]);
|
|
106
|
+
}
|
|
107
|
+
return ids;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @returns {{phase, ok, status: 'checked'|'no_design'|'legacy', design, decisions, cited, errors, warnings}} or `{error, reason}`
|
|
112
|
+
*/
|
|
113
|
+
function checkDecisions(cwd, phase) {
|
|
114
|
+
const pp = phasePlans(cwd, phase);
|
|
115
|
+
if (pp.error) return pp;
|
|
116
|
+
const base = { phase: pp.pf.info.phase_number, ok: true, design: null, decisions: [], cited: {}, errors: [], warnings: [] };
|
|
117
|
+
let designFile = null;
|
|
118
|
+
try { designFile = fs.readdirSync(pp.pf.dir).filter((f) => /-design\.md$/i.test(f)).sort()[0] || null; } catch { /* phaseFiles read it */ }
|
|
119
|
+
if (!designFile) return { ...base, status: 'no_design' };
|
|
120
|
+
const recorded = recordedDecisions(readText(path.join(pp.pf.dir, designFile)));
|
|
121
|
+
const design = path.posix.join(pp.pf.info.directory.replace(/\\/g, '/'), designFile);
|
|
122
|
+
if (!recorded.length) {
|
|
123
|
+
return { ...base, status: 'legacy', design, warnings: [{ code: 'decisions_without_ids', detail: `${designFile} records no DD-N decision ids; plans cannot cite them` }] };
|
|
124
|
+
}
|
|
125
|
+
const errors = [];
|
|
126
|
+
const seen = new Set();
|
|
127
|
+
for (const id of recorded) {
|
|
128
|
+
if (seen.has(id)) errors.push({ code: 'decision_duplicate', id, detail: `${id} is recorded more than once in ${designFile}` });
|
|
129
|
+
seen.add(id);
|
|
130
|
+
}
|
|
131
|
+
const cited = {};
|
|
132
|
+
for (const p of pp.plans) {
|
|
133
|
+
for (const id of new Set(p.text.match(DECISION_ID_RE) || [])) {
|
|
134
|
+
if (!seen.has(id)) errors.push({ code: 'decision_unrecorded', plan: p.plan, id, detail: `${p.plan} cites ${id}, which ${designFile} does not record` });
|
|
135
|
+
else (cited[id] = cited[id] || []).push(p.plan);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const decisions = [...seen].sort((a, b) => Number(a.slice(3)) - Number(b.slice(3)));
|
|
139
|
+
for (const id of decisions) {
|
|
140
|
+
if (!cited[id]) errors.push({ code: 'decision_uncited', id, detail: `${id} is recorded in ${designFile} and cited by no plan` });
|
|
141
|
+
}
|
|
142
|
+
return { ...base, ok: errors.length === 0, status: 'checked', design, decisions, cited, errors };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
function cmdVerifyReviewFocus(cwd, phase, raw) {
|
|
148
|
+
if (!phase) error('Usage: verify review-focus <phase>');
|
|
149
|
+
const r = checkReviewFocus(cwd, phase);
|
|
150
|
+
if (r.error) { output(r, raw); return; }
|
|
151
|
+
output(r, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function cmdVerifyDecisions(cwd, phase, raw) {
|
|
155
|
+
if (!phase) error('Usage: verify decisions <phase>');
|
|
156
|
+
const r = checkDecisions(cwd, phase);
|
|
157
|
+
if (r.error) { output(r, raw); return; }
|
|
158
|
+
output(r, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = { REVIEW_FOCUS_MAX, checkReviewFocus, checkDecisions, recordedDecisions, cmdVerifyReviewFocus, cmdVerifyDecisions };
|
|
@@ -152,6 +152,9 @@ function buildPhasePreview(cwd, phaseNum) {
|
|
|
152
152
|
risk_signals,
|
|
153
153
|
risk_score,
|
|
154
154
|
plans: planContents.map(p => ({ file: p.file, bytes: Buffer.byteLength(p.content, 'utf-8') })),
|
|
155
|
+
// p10/p50/p90 dollars for this phase's plans, from the project's own cost ledger
|
|
156
|
+
// (P6); this phase's rows are left out of its own samples.
|
|
157
|
+
cost_forecast: require('./cost.cjs').costForecast(cwd, { plans: planFiles.length, excludePhase: phaseInfo.phase_number || phaseNum }),
|
|
155
158
|
};
|
|
156
159
|
}
|
|
157
160
|
|
|
@@ -161,7 +164,8 @@ function buildPhasePreview(cwd, phaseNum) {
|
|
|
161
164
|
* Produce a dependency graph + mermaid source + parallel-batch recommendation.
|
|
162
165
|
*
|
|
163
166
|
* Dependency detection:
|
|
164
|
-
* -
|
|
167
|
+
* - the roadmap's `**Depends on:** Phase N` line (explicit)
|
|
168
|
+
* - plan frontmatter `depends_on: [phase:NN, phase:MM]` (explicit; bare plan ids are not phases)
|
|
165
169
|
* - mentions of prior phases in plan text (heuristic, flagged as "hidden")
|
|
166
170
|
*
|
|
167
171
|
* @param {string} cwd - Project root
|
|
@@ -195,6 +199,13 @@ function buildPhaseDependencyGraph(cwd) {
|
|
|
195
199
|
}
|
|
196
200
|
}
|
|
197
201
|
|
|
202
|
+
// Graph keys are the roadmap checklist's numbers; a dependency written "01" or
|
|
203
|
+
// "Phase 1" resolves to the key whose unpadded form matches.
|
|
204
|
+
const unpad = (n) => String(n).trim().replace(/^0+(?=\d)/, '');
|
|
205
|
+
const keyByUnpadded = new Map(phaseList.map(p => [unpad(p.num), p.num]));
|
|
206
|
+
const keyOf = (n) => keyByUnpadded.get(unpad(n)) || unpad(n);
|
|
207
|
+
const { phaseDependencies } = require('./roadmap.cjs');
|
|
208
|
+
|
|
198
209
|
for (const p of phaseList) {
|
|
199
210
|
graph[p.num] = {
|
|
200
211
|
num: p.num,
|
|
@@ -204,6 +215,15 @@ function buildPhaseDependencyGraph(cwd) {
|
|
|
204
215
|
hidden_deps: [],
|
|
205
216
|
};
|
|
206
217
|
|
|
218
|
+
const addExplicit = (d) => {
|
|
219
|
+
const key = keyOf(d);
|
|
220
|
+
if (key !== keyOf(p.num) && !graph[p.num].explicit_deps.includes(key)) graph[p.num].explicit_deps.push(key);
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Explicit via the roadmap: the phase's `**Depends on:** Phase N` line.
|
|
224
|
+
const roadmapPhase = getRoadmapPhaseInternal(cwd, p.num);
|
|
225
|
+
for (const d of phaseDependencies(roadmapPhase && roadmapPhase.section)) addExplicit(d);
|
|
226
|
+
|
|
207
227
|
// Read plan files for depends_on frontmatter + prior-phase mentions.
|
|
208
228
|
const dir = phaseDirs[p.num];
|
|
209
229
|
if (!dir) continue;
|
|
@@ -212,35 +232,18 @@ function buildPhaseDependencyGraph(cwd) {
|
|
|
212
232
|
try { files = fs.readdirSync(fullDir).filter(isPlanFile); } catch { continue; }
|
|
213
233
|
|
|
214
234
|
// LF, so the frontmatter match below also finds a CRLF plan's depends_on.
|
|
215
|
-
const
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
// Also support block-list form:
|
|
231
|
-
// depends_on:
|
|
232
|
-
// - phase:1
|
|
233
|
-
// - phase:2
|
|
234
|
-
const blockMatch = fmMatch[1].match(/^depends_on:\s*\n((?:\s*-\s*.+\n?)*)/m);
|
|
235
|
-
if (blockMatch && !depsLine) {
|
|
236
|
-
const items = blockMatch[1].match(/^\s*-\s*(.+)$/gm) || [];
|
|
237
|
-
for (const item of items) {
|
|
238
|
-
const d = item.match(/\d+(?:\.\d+)?/);
|
|
239
|
-
if (d && d[0] !== p.num && !graph[p.num].explicit_deps.includes(d[0])) {
|
|
240
|
-
graph[p.num].explicit_deps.push(d[0]);
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
235
|
+
const planTexts = files.map(f => toLf(safeReadFile(path.join(fullDir, f)) || ''));
|
|
236
|
+
const phaseText = planTexts.join('\n');
|
|
237
|
+
|
|
238
|
+
// Explicit via each plan's frontmatter. A plan's `depends_on` names plan ids
|
|
239
|
+
// inside its own phase (`["01"]`), so only `phase:N` entries are phase
|
|
240
|
+
// dependencies, inline (`[phase:1, phase:2]`) or as a block list.
|
|
241
|
+
for (const text of planTexts) {
|
|
242
|
+
const fmMatch = text.match(/^---\n([\s\S]*?)\n---/);
|
|
243
|
+
if (!fmMatch) continue;
|
|
244
|
+
const depsBlock = fmMatch[1].match(/^depends_on:[^\n]*(?:\n[ \t]*-[^\n]*)*/m);
|
|
245
|
+
if (!depsBlock) continue;
|
|
246
|
+
for (const m of depsBlock[0].matchAll(/\bphase[:\s]*(\d+(?:\.\d+)?)/gi)) addExplicit(m[1]);
|
|
244
247
|
}
|
|
245
248
|
|
|
246
249
|
// Hidden via mentions: "phase N", "from phase N", "as in phase N".
|
|
@@ -248,7 +251,8 @@ function buildPhaseDependencyGraph(cwd) {
|
|
|
248
251
|
let mm;
|
|
249
252
|
const mentions = new Set();
|
|
250
253
|
while ((mm = mentionRe.exec(phaseText)) !== null) {
|
|
251
|
-
|
|
254
|
+
const k = keyOf(mm[1]);
|
|
255
|
+
if (k !== keyOf(p.num)) mentions.add(k);
|
|
252
256
|
}
|
|
253
257
|
graph[p.num].hidden_deps = [...mentions].filter(d => !graph[p.num].explicit_deps.includes(d));
|
|
254
258
|
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Research claims with provenance and freshness (program P3).
|
|
4
|
+
*
|
|
5
|
+
* A research file lists the claims a plan will rest on in a `## Claims` table:
|
|
6
|
+
*
|
|
7
|
+
* | Claim | Type | Source | Checked |
|
|
8
|
+
* | jose 6 supports EdDSA | version | https://github.com/panva/jose/releases | 2026-10-12 |
|
|
9
|
+
*
|
|
10
|
+
* `research lint <phase|path>` reads it. A claim with no source, or with no date (or
|
|
11
|
+
* a date that is not YYYY-MM-DD, or is in the future), is an error: nobody can tell
|
|
12
|
+
* where it came from or when it was true. A claim older than the freshness window for
|
|
13
|
+
* its type is `stale`, not an error: what a version or a price was two months ago
|
|
14
|
+
* says little about today, while a practice lasts for years. The plan-checker weighs
|
|
15
|
+
* stale claims against what the plans rest on.
|
|
16
|
+
*/
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const { output, error, toLf, toPosix, findPhaseInternal } = require('./core.cjs');
|
|
20
|
+
const { sectionBody } = require('./verify-scope.cjs');
|
|
21
|
+
|
|
22
|
+
// Days a claim of each type stays fresh. Versions, prices and security advisories
|
|
23
|
+
// move monthly; an API surface quarterly; an engineering practice yearly.
|
|
24
|
+
const FRESHNESS_DAYS = Object.freeze({ version: 30, pricing: 30, security: 30, api: 90, general: 180, practice: 365 });
|
|
25
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
26
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
27
|
+
// A template placeholder (`[Official docs URL]`, `<url>`) or a stand-in is no source.
|
|
28
|
+
const PLACEHOLDER_RE = /^(\[.*\]|<.*>|-+|n\/?a|none|tbd|todo|\?+|unknown)$/i;
|
|
29
|
+
|
|
30
|
+
function utcDay(s) {
|
|
31
|
+
if (!DATE_RE.test(s)) return null;
|
|
32
|
+
const t = Date.parse(s + 'T00:00:00Z');
|
|
33
|
+
return Number.isNaN(t) ? null : t;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The research file a phase number or a path names, or `{error, reason}`. */
|
|
37
|
+
function resolveResearch(cwd, target) {
|
|
38
|
+
const asPath = path.resolve(cwd, String(target));
|
|
39
|
+
if (/\.md$/i.test(String(target))) {
|
|
40
|
+
if (!fs.existsSync(asPath)) return { error: 'file_not_found', reason: `no file at ${target}` };
|
|
41
|
+
return { abs: asPath };
|
|
42
|
+
}
|
|
43
|
+
const info = findPhaseInternal(cwd, target);
|
|
44
|
+
if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${target}` };
|
|
45
|
+
const dir = path.join(cwd, info.directory);
|
|
46
|
+
let files = [];
|
|
47
|
+
try { files = fs.readdirSync(dir); } catch { /* read as empty */ }
|
|
48
|
+
const file = files.filter((f) => /-research\.md$/i.test(f) || /^research\.md$/i.test(f)).sort()[0];
|
|
49
|
+
if (!file) return { error: 'no_research', reason: `phase ${target} has no research file` };
|
|
50
|
+
return { abs: path.join(dir, file) };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Table rows of the `## Claims` section as arrays of cells, header and rule dropped. */
|
|
54
|
+
function claimRows(body) {
|
|
55
|
+
const rows = body.split('\n').map((l) => l.trim()).filter((l) => l.startsWith('|'))
|
|
56
|
+
.map((l) => l.replace(/^\|/, '').replace(/\|$/, '').split('|').map((c) => c.trim()));
|
|
57
|
+
if (!rows.length) return { header: null, rows: [] };
|
|
58
|
+
const header = rows[0].map((c) => c.toLowerCase());
|
|
59
|
+
return { header, rows: rows.slice(1).filter((r) => !r.every((c) => /^:?-+:?$/.test(c) || c === '')) };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* @param {string} cwd
|
|
64
|
+
* @param {string} target a phase number, or a path to a research markdown file
|
|
65
|
+
* @param {{now?: string}} [opts] `now` (YYYY-MM-DD) fixes "today" for a reproducible lint
|
|
66
|
+
*/
|
|
67
|
+
function lintResearch(cwd, target, opts = {}) {
|
|
68
|
+
const at = resolveResearch(cwd, target);
|
|
69
|
+
if (at.error) return at;
|
|
70
|
+
const rel = path.relative(path.resolve(cwd), at.abs);
|
|
71
|
+
const file = rel && !rel.startsWith('..') ? toPosix(rel) : toPosix(at.abs);
|
|
72
|
+
const now = opts.now ? utcDay(opts.now) : utcDay(new Date().toISOString().slice(0, 10));
|
|
73
|
+
if (now == null) return { error: 'invalid_now', reason: '--now must be YYYY-MM-DD' };
|
|
74
|
+
|
|
75
|
+
const text = toLf(fs.readFileSync(at.abs, 'utf-8'));
|
|
76
|
+
const base = { file, ok: true, claims: 0, errors: [], warnings: [], stale: [], windows: FRESHNESS_DAYS };
|
|
77
|
+
const body = sectionBody(text, 'Claims');
|
|
78
|
+
if (body == null) {
|
|
79
|
+
return { ...base, ok: false, errors: [{ code: 'claims_missing', detail: 'no `## Claims` table (claim · type · source · checked)' }] };
|
|
80
|
+
}
|
|
81
|
+
const { header, rows } = claimRows(body);
|
|
82
|
+
const col = (name, fallback) => {
|
|
83
|
+
const i = header ? header.findIndex((h) => h.startsWith(name)) : -1;
|
|
84
|
+
return i === -1 ? fallback : i;
|
|
85
|
+
};
|
|
86
|
+
const C = { claim: col('claim', 0), type: col('type', 1), source: col('source', 2), checked: col('checked', 3) };
|
|
87
|
+
const errors = [];
|
|
88
|
+
const warnings = [];
|
|
89
|
+
const stale = [];
|
|
90
|
+
rows.forEach((r, i) => {
|
|
91
|
+
const row = i + 1;
|
|
92
|
+
const claim = r[C.claim] || '';
|
|
93
|
+
const rawType = (r[C.type] || '').toLowerCase();
|
|
94
|
+
const source = r[C.source] || '';
|
|
95
|
+
const checked = r[C.checked] || '';
|
|
96
|
+
let type = rawType;
|
|
97
|
+
if (!Object.prototype.hasOwnProperty.call(FRESHNESS_DAYS, rawType)) {
|
|
98
|
+
warnings.push({ code: 'claim_unknown_type', row, claim, detail: `type "${rawType}" is not one of ${Object.keys(FRESHNESS_DAYS).join(', ')}; read as general` });
|
|
99
|
+
type = 'general';
|
|
100
|
+
}
|
|
101
|
+
if (!source || PLACEHOLDER_RE.test(source)) errors.push({ code: 'claim_no_source', row, claim, detail: 'no source' });
|
|
102
|
+
const day = utcDay(checked);
|
|
103
|
+
if (day == null) {
|
|
104
|
+
errors.push({ code: 'claim_no_date', row, claim, detail: checked ? `"${checked}" is not a YYYY-MM-DD date` : 'no checked date' });
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (day > now) { errors.push({ code: 'claim_future_date', row, claim, detail: `checked ${checked}, after today` }); return; }
|
|
108
|
+
const age = Math.round((now - day) / DAY_MS);
|
|
109
|
+
if (age > FRESHNESS_DAYS[type]) stale.push({ row, claim, type, checked, age_days: age, window_days: FRESHNESS_DAYS[type] });
|
|
110
|
+
});
|
|
111
|
+
return { ...base, ok: errors.length === 0, claims: rows.length, errors, warnings, stale };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cmdResearchLint(cwd, target, opts, raw) {
|
|
115
|
+
if (!target) error('Usage: research lint <phase|path> [--now YYYY-MM-DD]');
|
|
116
|
+
const r = lintResearch(cwd, target, opts);
|
|
117
|
+
if (r.error) { output(r, raw); return; }
|
|
118
|
+
output(r, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { FRESHNESS_DAYS, lintResearch, cmdResearchLint };
|