pan-wizard 3.34.0 → 3.35.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 +6 -6
- 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-executor.md +18 -1
- package/agents/pan-experiment-runner.md +1 -1
- package/agents/pan-hardener.md +1 -1
- package/agents/pan-meta-reviewer.md +1 -1
- package/agents/pan-phase-researcher.md +2 -2
- package/agents/pan-plan-checker.md +9 -8
- package/agents/pan-planner.md +3 -1
- package/agents/pan-project-researcher.md +1 -1
- package/agents/pan-research-synthesizer.md +1 -1
- package/agents/pan-reviewer.md +3 -2
- package/agents/pan-verifier.md +1 -1
- package/bin/install-lib.cjs +119 -16
- package/bin/install.js +34 -62
- package/commands/pan/cost.md +0 -4
- 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 +1 -2
- 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 +5 -0
- package/pan-wizard-core/bin/lib/findings.cjs +126 -19
- 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/optimize.cjs +13 -1
- package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
- package/pan-wizard-core/bin/lib/phase.cjs +24 -0
- package/pan-wizard-core/bin/lib/preview.cjs +32 -31
- 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-scope.cjs +6 -5
- package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
- package/pan-wizard-core/bin/pan-tools.cjs +13 -4
- package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
- package/pan-wizard-core/mcp/native-tools.cjs +3 -3
- package/pan-wizard-core/opencode/pan-wizard.js +36 -8
- 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 +2 -6
- package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
- package/pan-wizard-core/templates/phase-prompt.md +2 -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/state.md +4 -4
- 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 +51 -40
- 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 +22 -8
- package/pan-wizard-core/workflows/progress.md +1 -1
- package/pan-wizard-core/workflows/quick.md +2 -2
- 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 +6 -6
- package/scripts/build-hooks.js +1 -0
- package/scripts/generate-skills-docs.py +2 -2
- package/scripts/prompt-lint.cjs +167 -0
- package/scripts/test-surface.cjs +1 -0
|
@@ -13,7 +13,7 @@ const os = require('os');
|
|
|
13
13
|
*
|
|
14
14
|
* @param {Object} data - Parsed stdin JSON from Claude Code.
|
|
15
15
|
* @param {Object} [deps] - Optional dep injection for testing.
|
|
16
|
-
* {fs, path, homeDir, tmpDir} — defaults to real modules + OS paths.
|
|
16
|
+
* {fs, path, homeDir, tmpDir, configDir} — defaults to real modules + OS paths (configDir: CLAUDE_CONFIG_DIR, ignored when homeDir is given).
|
|
17
17
|
* @returns {string} The statusline content.
|
|
18
18
|
*/
|
|
19
19
|
function buildStatuslineOutput(data, deps) {
|
|
@@ -155,7 +155,10 @@ function buildStatuslineOutput(data, deps) {
|
|
|
155
155
|
|
|
156
156
|
// Current task from todos
|
|
157
157
|
let task = '';
|
|
158
|
-
|
|
158
|
+
// Claude Code keeps its todos in CLAUDE_CONFIG_DIR when that is set (a given
|
|
159
|
+
// homeDir is a test seam and ignores it).
|
|
160
|
+
const configDir = d.configDir !== undefined ? d.configDir : (d.homeDir ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
|
|
161
|
+
const todosDir = configDir ? pathMod.join(configDir, 'todos') : pathMod.join(homeDir, '.claude', 'todos');
|
|
159
162
|
let todosExists = false;
|
|
160
163
|
try { todosExists = fsMod.statSync(todosDir).isDirectory(); } catch { /* missing */ }
|
|
161
164
|
if (session && todosExists) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pan-wizard",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.35.0",
|
|
4
4
|
"description": "Command a bot army for your codebase: a reasoning-tier Mission Control delegates whole-project goals to specialist squads and ships behind a human merge gate. Five AI CLIs, zero context rot.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"pan-wizard": "bin/install.js"
|
|
@@ -556,6 +556,21 @@ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
|
|
|
556
556
|
output(fullResult, raw);
|
|
557
557
|
}
|
|
558
558
|
|
|
559
|
+
/**
|
|
560
|
+
* Map a --freshness value to Brave's `freshness` parameter, which accepts only
|
|
561
|
+
* pd/pw/pm/py or a YYYY-MM-DDtoYYYY-MM-DD range. Returns null for anything else.
|
|
562
|
+
* @param {string} value - day|week|month|year, or a value Brave accepts as is
|
|
563
|
+
* @returns {string|null}
|
|
564
|
+
*/
|
|
565
|
+
function braveFreshness(value) {
|
|
566
|
+
const v = String(value || '').trim().toLowerCase();
|
|
567
|
+
const named = { day: 'pd', week: 'pw', month: 'pm', year: 'py' };
|
|
568
|
+
if (named[v]) return named[v];
|
|
569
|
+
if (/^p[dwmy]$/.test(v)) return v;
|
|
570
|
+
if (/^\d{4}-\d{2}-\d{2}to\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
|
|
559
574
|
/**
|
|
560
575
|
* Search the web using Brave Search API and return structured results.
|
|
561
576
|
*
|
|
@@ -588,6 +603,12 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
588
603
|
return;
|
|
589
604
|
}
|
|
590
605
|
|
|
606
|
+
const freshness = options.freshness ? braveFreshness(options.freshness) : null;
|
|
607
|
+
if (options.freshness && !freshness) {
|
|
608
|
+
output({ available: false, error: `Invalid --freshness "${options.freshness}": use day, week, month, year, pd, pw, pm, py or YYYY-MM-DDtoYYYY-MM-DD` }, raw, '');
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
591
612
|
const params = new URLSearchParams({
|
|
592
613
|
q: query,
|
|
593
614
|
count: String(options.limit || 10),
|
|
@@ -596,8 +617,8 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
596
617
|
text_decorations: 'false'
|
|
597
618
|
});
|
|
598
619
|
|
|
599
|
-
if (
|
|
600
|
-
params.set('freshness',
|
|
620
|
+
if (freshness) {
|
|
621
|
+
params.set('freshness', freshness);
|
|
601
622
|
}
|
|
602
623
|
|
|
603
624
|
try {
|
|
@@ -1161,6 +1182,7 @@ module.exports = {
|
|
|
1161
1182
|
cmdSummaryExtract,
|
|
1162
1183
|
summaryOneLiner,
|
|
1163
1184
|
cmdWebsearch,
|
|
1185
|
+
braveFreshness,
|
|
1164
1186
|
cmdProgressRender,
|
|
1165
1187
|
cmdTodoComplete,
|
|
1166
1188
|
cmdScaffold,
|
|
@@ -53,10 +53,15 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
53
53
|
verifier: true,
|
|
54
54
|
nyquist_validation: false,
|
|
55
55
|
phase_record_compact: false,
|
|
56
|
+
// V1: the phase gate. `warn` reports refusals and completes; `enforce` refuses.
|
|
57
|
+
phase_gate: 'warn',
|
|
56
58
|
// M2: opt-in per-phase HTML reports at the verify→complete gate.
|
|
57
59
|
phase_reports: { enabled: false, open: false, theme: 'auto', index: true },
|
|
58
60
|
},
|
|
59
61
|
parallelization: true,
|
|
62
|
+
// A7: the pre-tool guard. `report` logs to <planning>/guard.jsonl; `enforce` also
|
|
63
|
+
// denies a write that puts a credential in a file; `off` disables it.
|
|
64
|
+
hooks: { guard: 'report' },
|
|
60
65
|
brave_search: hasBraveSearch,
|
|
61
66
|
budget: {
|
|
62
67
|
default_points: 50,
|
|
@@ -16,11 +16,14 @@
|
|
|
16
16
|
* again (a regression);
|
|
17
17
|
* - with no disposition, a finding is `open`.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
19
|
+
* Rules applied when a verdict is recorded:
|
|
20
|
+
* - EVIDENCE CLOSES, SILENCE DOES NOT (V6): an earlier open finding closes only when
|
|
21
|
+
* the judge answers it `resolved` with evidence (a `resolutions` entry, or the
|
|
22
|
+
* verifier's `re_verification.gaps_closed`), or, for a `code-fact:<fact>` finding,
|
|
23
|
+
* when that fact passes. One the new verdict neither reports nor answers stays
|
|
24
|
+
* open and is listed as `unaddressed`. `still_present` counts as a re-report.
|
|
25
|
+
* - IDENTITY: a reworded report of an earlier finding (same class family, same
|
|
26
|
+
* file, same truth or a similar summary) keeps that finding's id.
|
|
24
27
|
* - IDEMPOTENCY: recording the same artifact twice is a no-op that returns the
|
|
25
28
|
* first verdict (record_sig). That is why exec-phase and verify-phase may both
|
|
26
29
|
* record one verification.md.
|
|
@@ -122,6 +125,69 @@ function samePhase(a, b) {
|
|
|
122
125
|
return V.comparablePhase(a) === V.comparablePhase(b);
|
|
123
126
|
}
|
|
124
127
|
|
|
128
|
+
// ─── Finding identity across reports (V6) ───────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/** The file a `where` names, without a line or column suffix. */
|
|
131
|
+
function fileOf(where) {
|
|
132
|
+
return where ? String(where).replace(/\\/g, '/').replace(/(:\d+){1,2}$/, '').replace(/#L?\d+.*$/, '') : '';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tokens(s) {
|
|
136
|
+
return new Set(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter((w) => w.length > 2));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Token overlap (Jaccard) of two summaries at or above one half. */
|
|
140
|
+
function similar(a, b) {
|
|
141
|
+
const A = tokens(a);
|
|
142
|
+
const B = tokens(b);
|
|
143
|
+
if (!A.size || !B.size) return false;
|
|
144
|
+
let both = 0;
|
|
145
|
+
for (const t of A) if (B.has(t)) both++;
|
|
146
|
+
return both / (A.size + B.size - both) >= 0.5;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The part of a summary before " — " (a verifier gap's truth), lower-cased. */
|
|
150
|
+
const headOf = (s) => String(s || '').split(' — ')[0].trim().toLowerCase();
|
|
151
|
+
/** A verifier gap moves between missing and partial as it is worked on: one family. */
|
|
152
|
+
const familyOf = (c) => (c === 'partial' ? 'missing' : c);
|
|
153
|
+
|
|
154
|
+
/** Same class family, same file, and the same truth or a similar summary. */
|
|
155
|
+
function sameFinding(e, f) {
|
|
156
|
+
return familyOf(e.class) === familyOf(f.class) && fileOf(e.where) === fileOf(f.where)
|
|
157
|
+
&& (headOf(e.summary) === headOf(f.summary) || similar(e.summary, f.summary));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The earlier finding a judge's resolution answers: by id, else by its truth or summary. */
|
|
161
|
+
function resolutionTarget(mine, r) {
|
|
162
|
+
if (r.id) return mine.find((f) => f.id === r.id) || null;
|
|
163
|
+
const head = headOf(r.summary);
|
|
164
|
+
return mine.find((f) => headOf(f.summary) === head) || mine.find((f) => similar(f.summary, r.summary)) || null;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function truncateReason(s) {
|
|
168
|
+
return String(s).replace(/\s+/g, ' ').trim().slice(0, REASON_MAX);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* How stable a judge's findings are across its reports on one phase (V6): between
|
|
173
|
+
* consecutive verdicts, how many ids appeared and how many disappeared. A judge whose
|
|
174
|
+
* findings churn while the code does not change is not measuring the code.
|
|
175
|
+
*/
|
|
176
|
+
function findingsChurn(rows, phase, agent) {
|
|
177
|
+
const seq = foldFindings(rows).verdicts.filter((v) => v.agent === agent && samePhase(v.phase, phase));
|
|
178
|
+
let added = 0;
|
|
179
|
+
let dropped = 0;
|
|
180
|
+
let union = 0;
|
|
181
|
+
for (let i = 1; i < seq.length; i++) {
|
|
182
|
+
const a = new Set(seq[i - 1].finding_ids || []);
|
|
183
|
+
const b = new Set(seq[i].finding_ids || []);
|
|
184
|
+
for (const id of b) if (!a.has(id)) added++;
|
|
185
|
+
for (const id of a) if (!b.has(id)) dropped++;
|
|
186
|
+
union += new Set([...a, ...b]).size;
|
|
187
|
+
}
|
|
188
|
+
return { reports: seq.length, transitions: Math.max(0, seq.length - 1), added, dropped, rate: union ? Math.round(((added + dropped) / union) * 1000) / 1000 : 0 };
|
|
189
|
+
}
|
|
190
|
+
|
|
125
191
|
/** `.planning/` must exist: the ledger fills a planning tree, it never creates one. */
|
|
126
192
|
function requirePlanningTree(cwd) {
|
|
127
193
|
try { return fs.statSync(planningPath(cwd)).isDirectory(); } catch { return false; }
|
|
@@ -163,11 +229,15 @@ function recordVerdict(cwd, input) {
|
|
|
163
229
|
// `not_checked` (M23): what the judge could not run. A pass with entries here is a
|
|
164
230
|
// pass over less than the whole phase, and the record says so.
|
|
165
231
|
const notChecked = verdict.not_checked || [];
|
|
166
|
-
|
|
232
|
+
// Two-key verdict (V9): when a code fact lowered the judge's word, the record keeps
|
|
233
|
+
// both, and `verdict` is the lowered word the workflow branches on.
|
|
234
|
+
const lowered = Array.isArray(parsed.lowered_by) && parsed.lowered_by.length
|
|
235
|
+
? { agent_verdict: verdict.agent_verdict, lowered_by: parsed.lowered_by } : {};
|
|
236
|
+
const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, ...lowered, not_checked: notChecked, source_kind: parsed.source_kind, warnings: parsed.warnings };
|
|
167
237
|
|
|
168
238
|
const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
|
|
169
239
|
if (dup) {
|
|
170
|
-
return { ...base, recorded: false, duplicate: true, verdict_id: dup.verdict_id, attempt: dup.attempt, phase, findings: (dup.finding_ids || []).length, new: 0,
|
|
240
|
+
return { ...base, recorded: false, duplicate: true, verdict_id: dup.verdict_id, attempt: dup.attempt, phase, findings: (dup.finding_ids || []).length, new: 0, rematched: 0, resolved: 0, unaddressed: [], reopened: 0 };
|
|
171
241
|
}
|
|
172
242
|
|
|
173
243
|
const prior = fold.verdicts.filter((r) => r.agent === agent && samePhase(r.phase, phase));
|
|
@@ -180,21 +250,52 @@ function recordVerdict(cwd, input) {
|
|
|
180
250
|
const ids = [];
|
|
181
251
|
const newRows = [];
|
|
182
252
|
const findingRows = [];
|
|
253
|
+
const mine = [...fold.findings.values()].filter((e) => e.agent === agent && samePhase(e.phase, phase));
|
|
254
|
+
let rematched = 0;
|
|
183
255
|
for (const f of verdict.findings) {
|
|
184
|
-
|
|
256
|
+
let id = V.findingId(phase, agent, f);
|
|
257
|
+
if (!fold.findings.has(id)) {
|
|
258
|
+
// A reworded report of an earlier finding keeps that finding's id (V6), so a
|
|
259
|
+
// rewording is a re-report, not a fix plus a new finding.
|
|
260
|
+
const earlier = mine.filter((e) => !ids.includes(e.id) && sameFinding(e, f)).sort((a, b) => (a.status === 'open' ? 0 : 1) - (b.status === 'open' ? 0 : 1))[0];
|
|
261
|
+
if (earlier) { id = earlier.id; rematched++; }
|
|
262
|
+
}
|
|
185
263
|
if (ids.includes(id)) continue; // the same finding twice in one report is one finding
|
|
186
264
|
ids.push(id);
|
|
187
265
|
if (!fold.findings.has(id)) {
|
|
188
266
|
findingRows.push({ v: LEDGER_V, kind: 'finding', ts, id, verdict_id: verdictId, phase, milestone, agent, class: f.class, severity: f.severity, where: f.where, summary: f.summary });
|
|
189
267
|
}
|
|
190
268
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
269
|
+
|
|
270
|
+
// Findings close only on evidence (V6). Silence is not evidence: an earlier open
|
|
271
|
+
// finding the new verdict neither reports nor answers stays open, and the record
|
|
272
|
+
// lists it as unaddressed. What closes one: the judge's `resolved` answer with its
|
|
273
|
+
// evidence, or, for a code-fact finding, the fact passing.
|
|
274
|
+
const resolveRows = [];
|
|
275
|
+
const closing = new Set();
|
|
276
|
+
const close = (f, reason) => {
|
|
277
|
+
if (closing.has(f.id) || ids.includes(f.id) || f.status !== 'open') return;
|
|
278
|
+
closing.add(f.id);
|
|
279
|
+
resolveRows.push({ v: LEDGER_V, kind: 'disposition', ts, id: f.id, disposition: 'fixed', reason: truncateReason(reason), auto: true, verdict_id: verdictId });
|
|
280
|
+
};
|
|
281
|
+
let contradicted = 0;
|
|
282
|
+
for (const r of verdict.resolutions || []) {
|
|
283
|
+
const target = resolutionTarget(mine, r);
|
|
284
|
+
if (!target) { parsed.warnings.push(`resolution_unmatched: ${r.id || r.summary}`); continue; }
|
|
285
|
+
if (r.status === 'resolved') {
|
|
286
|
+
if (ids.includes(target.id)) { contradicted++; continue; }
|
|
287
|
+
close(target, `resolved by ${agent} at attempt ${attempt}: ${r.evidence}`);
|
|
288
|
+
} else if (r.status === 'still_present' && !ids.includes(target.id)) {
|
|
289
|
+
ids.push(target.id); // a re-report: reopens a fixed finding, keeps an open one open
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
if (contradicted) parsed.warnings.push(`resolution_contradicted: ${contradicted} finding(s) answered resolved but reported again; they stay open`);
|
|
293
|
+
for (const fact of parsed.facts_ok || []) {
|
|
294
|
+
for (const f of mine) if (f.where === `code-fact:${fact}`) close(f, `${fact} passed at attempt ${attempt}`);
|
|
197
295
|
}
|
|
296
|
+
const reopened = ids.filter((id) => fold.findings.has(id) && fold.findings.get(id).status === 'fixed').length;
|
|
297
|
+
const unaddressed = mine.filter((f) => f.status === 'open' && !ids.includes(f.id) && !closing.has(f.id)).map((f) => f.id);
|
|
298
|
+
if (unaddressed.length) parsed.warnings.push(`open_findings_unaddressed: ${unaddressed.length} earlier finding(s) neither reported nor resolved; they stay open`);
|
|
198
299
|
|
|
199
300
|
const bySeverity = countBy(verdict.findings, 'severity');
|
|
200
301
|
const byClass = countBy(verdict.findings, 'class');
|
|
@@ -205,11 +306,14 @@ function recordVerdict(cwd, input) {
|
|
|
205
306
|
}
|
|
206
307
|
newRows.push({
|
|
207
308
|
v: LEDGER_V, kind: 'verdict', ts, verdict_id: verdictId, record_sig: recordSig, phase, milestone, agent,
|
|
208
|
-
verdict: verdict.verdict, outcome: verdict.outcome, attempt, source, finding_ids: ids,
|
|
309
|
+
verdict: verdict.verdict, outcome: verdict.outcome, ...lowered, attempt, source, finding_ids: ids,
|
|
209
310
|
by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
|
|
210
311
|
...(notChecked.length ? { not_checked: notChecked } : {}),
|
|
312
|
+
// A plan check is bound to the plans it checked (V7): exec-phase compares this
|
|
313
|
+
// hash with the plans it is about to run.
|
|
314
|
+
...(agent === 'pan-plan-checker' ? { plans_sha: require('./verify-plan-set.cjs').plansShaFor(cwd, phase) } : {}),
|
|
211
315
|
});
|
|
212
|
-
appendRows(cwd, [...findingRows, ...newRows, ...
|
|
316
|
+
appendRows(cwd, [...findingRows, ...newRows, ...resolveRows]);
|
|
213
317
|
|
|
214
318
|
const category = { pass: 'verdict_passed', fail: 'verdict_failed', needs_human: 'verdict_needs_human' }[verdict.outcome];
|
|
215
319
|
const serious = (bySeverity.critical || 0) + (bySeverity.high || 0) > 0;
|
|
@@ -228,13 +332,13 @@ function recordVerdict(cwd, input) {
|
|
|
228
332
|
category: 'verdict_retry',
|
|
229
333
|
agent,
|
|
230
334
|
phase,
|
|
231
|
-
description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${
|
|
335
|
+
description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${resolveRows.length} earlier finding(s) resolved, ${unaddressed.length} unaddressed`,
|
|
232
336
|
impact: 'minor',
|
|
233
|
-
context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome,
|
|
337
|
+
context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome, resolved: resolveRows.length, unaddressed: unaddressed.length, reopened, verdict_id: verdictId },
|
|
234
338
|
});
|
|
235
339
|
}
|
|
236
340
|
|
|
237
|
-
return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length,
|
|
341
|
+
return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length, rematched, resolved: resolveRows.length, unaddressed, reopened, ledger: planningRel(FINDINGS_FILE) };
|
|
238
342
|
}
|
|
239
343
|
|
|
240
344
|
/**
|
|
@@ -299,6 +403,7 @@ function listFindings(cwd, filters = {}) {
|
|
|
299
403
|
findings,
|
|
300
404
|
counts: { total: findings.length, by_status: countBy(findings, 'status'), by_class: countBy(findings, 'class'), by_severity: countBy(findings, 'severity') },
|
|
301
405
|
verdicts: fold.verdicts.length,
|
|
406
|
+
...(filters.phase && filters.agent ? { churn: findingsChurn(rows, filters.phase, filters.agent) } : {}),
|
|
302
407
|
malformed_rows: malformed,
|
|
303
408
|
};
|
|
304
409
|
}
|
|
@@ -384,6 +489,8 @@ module.exports = {
|
|
|
384
489
|
disposeFindings,
|
|
385
490
|
listFindings,
|
|
386
491
|
findingsDebt,
|
|
492
|
+
findingsChurn,
|
|
493
|
+
sameFinding,
|
|
387
494
|
findingIdsIn,
|
|
388
495
|
cmdFindingsRecord,
|
|
389
496
|
cmdFindingsDispose,
|
|
@@ -300,10 +300,14 @@ function hostMemoryProjectRoot(cwd) {
|
|
|
300
300
|
}
|
|
301
301
|
}
|
|
302
302
|
|
|
303
|
-
/**
|
|
304
|
-
|
|
303
|
+
/**
|
|
304
|
+
* Claude Code's auto-memory directory for a project, and how it was found.
|
|
305
|
+
* `configDir` is Claude Code's user directory when CLAUDE_CONFIG_DIR moves it.
|
|
306
|
+
*/
|
|
307
|
+
function hostMemoryDir(cwd, homeDir, configDir = '') {
|
|
305
308
|
const claudeDir = '.claude';
|
|
306
|
-
|
|
309
|
+
const userDir = configDir || path.join(homeDir, claudeDir);
|
|
310
|
+
for (const f of [path.join(cwd, claudeDir, 'settings.local.json'), path.join(cwd, claudeDir, 'settings.json'), path.join(userDir, 'settings.json')]) {
|
|
307
311
|
let s = null;
|
|
308
312
|
try { s = JSON.parse(safeReadFile(f) || 'null'); } catch { /* unreadable settings */ }
|
|
309
313
|
if (s && typeof s.autoMemoryDirectory === 'string' && s.autoMemoryDirectory.trim()) {
|
|
@@ -312,18 +316,20 @@ function hostMemoryDir(cwd, homeDir) {
|
|
|
312
316
|
}
|
|
313
317
|
}
|
|
314
318
|
const key = hostMemoryProjectRoot(cwd).replace(/[^A-Za-z0-9]/g, '-');
|
|
319
|
+
if (configDir) return { dir: path.join(configDir, 'projects', key, 'memory'), display: toPosix(path.join(configDir, 'projects', key, 'memory')), source: 'default' };
|
|
315
320
|
return { dir: path.join(homeDir, claudeDir, 'projects', key, 'memory'), display: `~/${claudeDir}/projects/${key}/memory`, source: 'default' };
|
|
316
321
|
}
|
|
317
322
|
|
|
318
323
|
/**
|
|
319
324
|
* H-11: Claude Code's auto-memory index. Read-only; never fixable by PAN.
|
|
320
325
|
* @param {string} cwd
|
|
321
|
-
* @param {{homeDir?: string}} [opts]
|
|
326
|
+
* @param {{homeDir?: string, configDir?: string}} [opts] - a given homeDir (a test seam) ignores CLAUDE_CONFIG_DIR
|
|
322
327
|
*/
|
|
323
328
|
function checkHostMemory(cwd, opts = {}) {
|
|
324
329
|
const findings = [];
|
|
325
330
|
const home = opts.homeDir || require('os').homedir();
|
|
326
|
-
const
|
|
331
|
+
const configDir = opts.configDir !== undefined ? opts.configDir : (opts.homeDir ? '' : (process.env.CLAUDE_CONFIG_DIR || ''));
|
|
332
|
+
const loc = hostMemoryDir(cwd, home, configDir);
|
|
327
333
|
const text = safeReadFile(path.join(loc.dir, 'MEMORY.md'));
|
|
328
334
|
if (text == null) return { findings, host_memory: { dir: loc.display, index: false } };
|
|
329
335
|
const lines = text.replace(/\r\n/g, '\n').replace(/\n$/, '').split('\n');
|
|
@@ -8,6 +8,7 @@ const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInte
|
|
|
8
8
|
const { PHASES_DIR, CODEBASE_DIR, QUICK_DIR, MILESTONES_DIR, STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PROJECT_FILE, REQUIREMENTS_FILE, isPlanFile, isSummaryFile, isResearchFile, isContextFile, isVerificationFile, PLAN_SUFFIX, SUMMARY_SUFFIX, CONTEXT_SUFFIX, RESEARCH_SUFFIX, VERIFICATION_SUFFIX, UAT_SUFFIX, MAX_SLUG_LENGTH } = require('./constants.cjs');
|
|
9
9
|
const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, classifyPhaseStatus, hasBraveSearchKey, parsePhaseDir, planningRel } = require('./utils.cjs');
|
|
10
10
|
const { classifyPlanTier } = require('./phase.cjs');
|
|
11
|
+
const { planCheckStatus, planDrift } = require('./verify-plan-set.cjs');
|
|
11
12
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
12
13
|
const { detectLanguages } = require('./codebase.cjs');
|
|
13
14
|
const { detectForeignPlanningTreeAt } = require('./foreign-planning.cjs');
|
|
@@ -205,6 +206,11 @@ function cmdInitExecutePhase(cwd, phase, raw, opts) {
|
|
|
205
206
|
execution_mode: (config.execution && config.execution.default_mode) || 'wave_order',
|
|
206
207
|
dry_run: dryRun,
|
|
207
208
|
rollback_tag: null,
|
|
209
|
+
|
|
210
|
+
// Plan checks in code (V7): whether these plans are the ones that passed the
|
|
211
|
+
// plan check, and which planned files changed on the branch since planning.
|
|
212
|
+
plan_check: phaseInfo ? planCheckStatus(cwd, phase) : null,
|
|
213
|
+
plan_drift: phaseInfo ? planDrift(cwd, phase) : [],
|
|
208
214
|
};
|
|
209
215
|
|
|
210
216
|
output(result, raw);
|
|
@@ -1269,6 +1269,18 @@ function cmdOptimizeLearn(cwd, opts, raw) {
|
|
|
1269
1269
|
}, raw);
|
|
1270
1270
|
}
|
|
1271
1271
|
|
|
1272
|
+
/**
|
|
1273
|
+
* `--report` as given resolves against the project (`--cwd`), not the shell's
|
|
1274
|
+
* directory; a bare file name that is not there is looked up in the reports dir.
|
|
1275
|
+
*/
|
|
1276
|
+
function resolveReportPath(cwd, given) {
|
|
1277
|
+
const abs = path.resolve(cwd, given);
|
|
1278
|
+
if (fs.existsSync(abs)) return abs;
|
|
1279
|
+
if (path.basename(given) !== given) return abs; // a path with directories is taken as given
|
|
1280
|
+
const inReports = path.join(getReportsDir(cwd), given);
|
|
1281
|
+
return fs.existsSync(inReports) ? inReports : abs;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1272
1284
|
function cmdOptimizeApply(cwd, opts, raw) {
|
|
1273
1285
|
const reports = listOptimizationReports(cwd);
|
|
1274
1286
|
if (reports.error || reports.count === 0) {
|
|
@@ -1276,7 +1288,7 @@ function cmdOptimizeApply(cwd, opts, raw) {
|
|
|
1276
1288
|
return;
|
|
1277
1289
|
}
|
|
1278
1290
|
|
|
1279
|
-
const reportPath = opts.reportPath
|
|
1291
|
+
const reportPath = opts.reportPath ? resolveReportPath(cwd, opts.reportPath) : reports.reports[0].path;
|
|
1280
1292
|
const result = applyReportRecommendations(cwd, reportPath);
|
|
1281
1293
|
output({ report: path.basename(reportPath), ...result }, raw);
|
|
1282
1294
|
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* The phase gate (program V1): may this phase be marked complete?
|
|
4
|
+
*
|
|
5
|
+
* Every guarantee at the end of PAN's loop used to be a sentence in a workflow:
|
|
6
|
+
* "do not advance unless the verification passed", "reconcile before advancing".
|
|
7
|
+
* The model skipped one (exec-phase's `--no-transition` path never ran reconcile at
|
|
8
|
+
* all), and `phase complete` marked the phase done regardless. The gate puts those
|
|
9
|
+
* rules in code, in one function every caller uses: `phase complete`, the native
|
|
10
|
+
* exec-waves script, and the MCP merge request.
|
|
11
|
+
*
|
|
12
|
+
* Refusal reasons, one code per class:
|
|
13
|
+
* no_plans · missing_summary · no_verification · verification_unreadable ·
|
|
14
|
+
* verification_failed (V9 lowering included) · human_needed_unapproved ·
|
|
15
|
+
* verification_stale · reconcile_contradiction · test_failed · open_findings ·
|
|
16
|
+
* stubs_found · integrity_violation
|
|
17
|
+
*
|
|
18
|
+
* What the gate could not check is listed in `not_checked`, never refused on:
|
|
19
|
+
* a skipped test gate, an unknown freshness, no git, no plan commits, the verifier
|
|
20
|
+
* switched off. `workflow.phase_gate` is `warn` (default: report and proceed) or
|
|
21
|
+
* `enforce` (refuse unless `--force --reason`, which records a decision).
|
|
22
|
+
*/
|
|
23
|
+
const fs = require('fs');
|
|
24
|
+
const path = require('path');
|
|
25
|
+
const { findPhaseInternal, safeReadFile, loadConfig, output, error } = require('./core.cjs');
|
|
26
|
+
const { isVerificationFile } = require('./constants.cjs');
|
|
27
|
+
const { planningPath } = require('./utils.cjs');
|
|
28
|
+
|
|
29
|
+
const MODES = Object.freeze(['warn', 'enforce']);
|
|
30
|
+
const SERIOUS = new Set(['critical', 'high']);
|
|
31
|
+
|
|
32
|
+
/** `workflow.phase_gate` from config.json: `enforce` or (anything else) `warn`. */
|
|
33
|
+
function gateMode(cwd) {
|
|
34
|
+
let cfg = null;
|
|
35
|
+
try { cfg = JSON.parse(fs.readFileSync(planningPath(cwd, 'config.json'), 'utf-8')); } catch { /* defaults */ }
|
|
36
|
+
const v = cfg && cfg.workflow && cfg.workflow.phase_gate;
|
|
37
|
+
return MODES.includes(v) ? v : 'warn';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @param {string} cwd
|
|
42
|
+
* @param {string|number} phase
|
|
43
|
+
* @param {{mode?: 'warn'|'enforce'}} [opts]
|
|
44
|
+
* @returns {{phase, mode, ok, reasons: Array<{code, detail}>, facts: Object, not_checked: Array<{check, reason}>}|{error, reason}}
|
|
45
|
+
*/
|
|
46
|
+
function phaseGate(cwd, phase, opts = {}) {
|
|
47
|
+
const info = findPhaseInternal(cwd, phase);
|
|
48
|
+
if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
|
|
49
|
+
const V = require('./verdict.cjs');
|
|
50
|
+
const F = require('./findings.cjs');
|
|
51
|
+
const { reconcilePhase, verificationFreshness, scanStubs } = require('./verify.cjs');
|
|
52
|
+
const { committedFiles } = require('./verify-scope.cjs');
|
|
53
|
+
const { checkTestIntegrity } = require('./verify-integrity.cjs');
|
|
54
|
+
|
|
55
|
+
const mode = MODES.includes(opts.mode) ? opts.mode : gateMode(cwd);
|
|
56
|
+
const reasons = [];
|
|
57
|
+
const notChecked = [];
|
|
58
|
+
const facts = {};
|
|
59
|
+
const refuse = (code, detail, extra = {}) => reasons.push({ code, detail, ...extra });
|
|
60
|
+
const skip = (check, reason) => notChecked.push({ check, reason });
|
|
61
|
+
const phaseNumber = info.phase_number || String(phase);
|
|
62
|
+
const dir = path.join(cwd, info.directory);
|
|
63
|
+
|
|
64
|
+
// 1. Every plan has a summary.
|
|
65
|
+
const plans = info.plans || [];
|
|
66
|
+
const incomplete = info.incomplete_plans || [];
|
|
67
|
+
facts.plans = `${plans.length - incomplete.length}/${plans.length}`;
|
|
68
|
+
if (!plans.length) refuse('no_plans', 'the phase has no plans');
|
|
69
|
+
else if (incomplete.length) refuse('missing_summary', `${incomplete.length} plan(s) have no summary: ${incomplete.join(', ')}`, { plans: incomplete });
|
|
70
|
+
|
|
71
|
+
// 2. A passing verification (with code facts applied, V9).
|
|
72
|
+
let names = [];
|
|
73
|
+
try { names = fs.readdirSync(dir); } catch { /* unreadable reads as empty */ }
|
|
74
|
+
const verFile = names.filter(isVerificationFile).sort().pop();
|
|
75
|
+
const verifierOn = loadConfig(cwd).verifier !== false;
|
|
76
|
+
if (!verFile) {
|
|
77
|
+
if (verifierOn) refuse('no_verification', 'the phase has no verification; run /pan:verify-phase');
|
|
78
|
+
else skip('verification', 'workflow.verifier is off');
|
|
79
|
+
} else {
|
|
80
|
+
const parsed = V.parseVerdictText(safeReadFile(path.join(dir, verFile)) || '', { agent: 'pan-verifier', phase: phaseNumber });
|
|
81
|
+
if (!parsed.ok) {
|
|
82
|
+
refuse('verification_unreadable', `${verFile}: ${parsed.reason}`);
|
|
83
|
+
} else {
|
|
84
|
+
const v = parsed.verdict;
|
|
85
|
+
const fm = V.verdictFromVerificationFrontmatter(safeReadFile(path.join(dir, verFile)) || '') || {};
|
|
86
|
+
facts.verification = v.verdict;
|
|
87
|
+
facts.test_gate = fm.test_gate || null;
|
|
88
|
+
if (parsed.lowered_by && parsed.lowered_by.length) facts.lowered_by = parsed.lowered_by;
|
|
89
|
+
if (fm.test_gate === 'failed') refuse('test_failed', 'the verification records test_gate: failed');
|
|
90
|
+
else if (fm.test_gate === 'skipped') skip('tests', 'the test gate was skipped');
|
|
91
|
+
else if (!fm.test_gate) skip('tests', 'the verification does not record its test gate');
|
|
92
|
+
if (v.outcome === 'fail') {
|
|
93
|
+
refuse('verification_failed', `the verification is ${v.verdict}${v.agent_verdict ? ` (the verifier wrote ${v.agent_verdict})` : ''}`);
|
|
94
|
+
} else if (v.outcome === 'needs_human') {
|
|
95
|
+
const human = F.listFindings(cwd, { phase: phaseNumber, agent: 'pan-verifier', class: 'human' }).findings;
|
|
96
|
+
const unapproved = human.filter((f) => f.status !== 'decision');
|
|
97
|
+
if (unapproved.length || !human.length) {
|
|
98
|
+
refuse('human_needed_unapproved', `${unapproved.length || 'the'} human verification item(s) have no recorded approval; record it with \`findings dispose <id> --as decision --reason "<what was checked>"\``, { ids: unapproved.map((f) => f.id) });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// 3. The verification still describes the code.
|
|
105
|
+
if (verFile) {
|
|
106
|
+
const fresh = verificationFreshness(cwd, phaseNumber, info);
|
|
107
|
+
facts.freshness = fresh.state;
|
|
108
|
+
if (fresh.state === 'stale') refuse('verification_stale', `files changed since the verified commit: ${(fresh.changed_since || []).slice(0, 5).join(', ')}`, { changed_since: fresh.changed_since });
|
|
109
|
+
else if (fresh.state === 'unknown') skip('freshness', fresh.reason || 'unknown');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 4. The written verdict agrees with the mechanical checks.
|
|
113
|
+
if (verFile) {
|
|
114
|
+
const rec = reconcilePhase(cwd, phaseNumber);
|
|
115
|
+
facts.reconciled = rec.reconciled;
|
|
116
|
+
if (!rec.reconciled) refuse('reconcile_contradiction', rec.contradictions.join('; '), { contradictions: rec.contradictions });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 5. No critical or high finding left open without a disposition.
|
|
120
|
+
const open = F.listFindings(cwd, { phase: phaseNumber, status: 'open' }).findings
|
|
121
|
+
.filter((f) => SERIOUS.has(f.severity) && f.class !== 'human');
|
|
122
|
+
facts.open_serious_findings = open.length;
|
|
123
|
+
if (open.length) refuse('open_findings', `${open.length} critical/high finding(s) are open: dispose each (\`findings dispose <id> --as fixed|deferred|dismissed|decision --reason "…"\`) or fix it`, { ids: open.map((f) => f.id) });
|
|
124
|
+
|
|
125
|
+
// 6. No stub in the files the phase committed.
|
|
126
|
+
const committed = committedFiles(cwd, phaseNumber);
|
|
127
|
+
if (!committed.git) {
|
|
128
|
+
skip('stubs', 'git is unavailable');
|
|
129
|
+
} else {
|
|
130
|
+
const files = [...committed.files].filter((f) => fs.existsSync(path.join(cwd, f)));
|
|
131
|
+
const stubs = scanStubs(cwd, { files });
|
|
132
|
+
facts.stubs_blocking = stubs.blocking;
|
|
133
|
+
if (stubs.blocking) refuse('stubs_found', `${stubs.blocking} stub marker(s) in committed files: ${stubs.findings.filter((s) => s.severity === 'high').slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', ')}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// 7. The phase did not weaken its own tests.
|
|
137
|
+
const integrity = checkTestIntegrity(cwd, phaseNumber);
|
|
138
|
+
if (integrity.error || !integrity.checked) {
|
|
139
|
+
skip('test_integrity', integrity.reason || integrity.error);
|
|
140
|
+
} else {
|
|
141
|
+
facts.test_integrity = integrity.ok ? 'clean' : 'violated';
|
|
142
|
+
if (!integrity.ok) refuse('integrity_violation', integrity.violations.slice(0, 3).map((x) => `${x.code} ${x.file}`).join('; '), { violations: integrity.violations });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return { phase: phaseNumber, mode, ok: reasons.length === 0, reasons, facts, not_checked: notChecked };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* `--force --reason`: complete past the gate, on the record. Every open finding the
|
|
150
|
+
* gate counted gets a `decision` disposition with the reason, and the trace logs it.
|
|
151
|
+
*/
|
|
152
|
+
function recordGateOverride(cwd, gate, reason) {
|
|
153
|
+
const F = require('./findings.cjs');
|
|
154
|
+
const ids = [];
|
|
155
|
+
for (const r of gate.reasons) for (const id of r.ids || []) if (!ids.includes(id)) ids.push(id);
|
|
156
|
+
const disposed = ids.length ? F.disposeFindings(cwd, { ids, as: 'decision', reason: `phase completed past the gate: ${reason}` }) : { disposed: [] };
|
|
157
|
+
try {
|
|
158
|
+
require('./optimize.cjs').logTraceEvent(cwd, {
|
|
159
|
+
type: 'decision', category: 'phase_gate_forced', agent: 'orchestrator', phase: gate.phase,
|
|
160
|
+
description: `phase ${gate.phase} completed past the gate (${gate.reasons.map((r) => r.code).join(', ')}): ${reason}`,
|
|
161
|
+
impact: 'major', context: { reasons: gate.reasons.map((r) => r.code), reason },
|
|
162
|
+
});
|
|
163
|
+
} catch { /* telemetry is best effort */ }
|
|
164
|
+
return { reason, refused_by: gate.reasons.map((r) => r.code), disposed: disposed.disposed || [] };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function cmdPhaseGate(cwd, phase, raw, opts = {}) {
|
|
168
|
+
if (!phase) error('Usage: phase gate <N> [--mode warn|enforce]');
|
|
169
|
+
const r = phaseGate(cwd, phase, opts);
|
|
170
|
+
if (r.error) { output(r, raw); return; }
|
|
171
|
+
output(r, raw, r.ok ? 'pass' : 'refuse', r.ok ? 0 : 1);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
module.exports = { phaseGate, recordGateOverride, gateMode, cmdPhaseGate, MODES };
|
|
@@ -823,6 +823,26 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
|
|
|
823
823
|
error(`Phase ${phaseNum} not found`);
|
|
824
824
|
}
|
|
825
825
|
|
|
826
|
+
// The phase gate (V1). In `enforce` mode a refusal stops here unless --force
|
|
827
|
+
// --reason, which records a decision; in `warn` mode the reasons travel with the
|
|
828
|
+
// result. A --force without a reason is refused in either mode.
|
|
829
|
+
const { phaseGate, recordGateOverride } = require('./phase-gate.cjs');
|
|
830
|
+
const force = !!(opts && opts.force);
|
|
831
|
+
const forceReason = opts && typeof opts.reason === 'string' ? opts.reason.trim() : '';
|
|
832
|
+
if (force && !forceReason) {
|
|
833
|
+
output({ error: 'reason_required', reason: 'phase complete --force needs --reason "<why the gate is being overridden>"' }, raw);
|
|
834
|
+
return;
|
|
835
|
+
}
|
|
836
|
+
const gate = phaseGate(cwd, phaseNum);
|
|
837
|
+
let gateOverride = null;
|
|
838
|
+
if (!gate.error && !gate.ok) {
|
|
839
|
+
if (gate.mode === 'enforce' && !force) {
|
|
840
|
+
output({ error: 'phase_gate_refused', reason: gate.reasons.map((r) => `${r.code}: ${r.detail}`).join(' | '), gate }, raw);
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
if (force) gateOverride = recordGateOverride(cwd, gate, forceReason);
|
|
844
|
+
}
|
|
845
|
+
|
|
826
846
|
const planCount = phaseInfo.plans.length;
|
|
827
847
|
const summaryCount = phaseInfo.summaries.length;
|
|
828
848
|
|
|
@@ -886,6 +906,8 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
|
|
|
886
906
|
date: today,
|
|
887
907
|
roadmap_updated: fileAccessible(roadmapPath),
|
|
888
908
|
state_updated: fileAccessible(statePath),
|
|
909
|
+
gate: gate.error ? { error: gate.error } : { ok: gate.ok, mode: gate.mode, reasons: gate.reasons, not_checked: gate.not_checked },
|
|
910
|
+
...(gateOverride ? { gate_override: gateOverride } : {}),
|
|
889
911
|
};
|
|
890
912
|
if (roadmapResult && roadmapResult.requirements_warning) {
|
|
891
913
|
result.requirements_warning = roadmapResult.requirements_warning;
|
|
@@ -982,6 +1004,8 @@ module.exports = {
|
|
|
982
1004
|
cmdFindPhase,
|
|
983
1005
|
cmdPhasePlanIndex,
|
|
984
1006
|
blockedPlans,
|
|
1007
|
+
buildPlanIndex,
|
|
1008
|
+
dependencyNames,
|
|
985
1009
|
cmdPhaseAdd,
|
|
986
1010
|
cmdPhaseInsert,
|
|
987
1011
|
cmdPhaseRemove,
|