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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Mutation report on a phase's diff (program V5): would a test fail if the code the phase
|
|
4
|
+
* wrote were wrong? Coverage says a line ran; a surviving mutant says no test pins what
|
|
5
|
+
* the line does.
|
|
6
|
+
*
|
|
7
|
+
* lines the lines the phase's plan commits changed (git diff from the phase's
|
|
8
|
+
* base, the same base `verify red` uses), in source files that are not tests
|
|
9
|
+
* mutants blunt textual swaps per language (a comparison flipped, && for ||, a
|
|
10
|
+
* boolean return inverted, + 1 for - 1), on a changed line only, outside a
|
|
11
|
+
* comment or string; a seeded sample of them (`--max`, `--seed`)
|
|
12
|
+
* where a throwaway worktree at HEAD, never the user's tree
|
|
13
|
+
* flake the unmutated suite runs twice first; if it fails, or the two runs
|
|
14
|
+
* disagree, nothing a mutant does could be read, so the report aborts
|
|
15
|
+
* result each mutant is killed (the suite fails or times out) or survives;
|
|
16
|
+
* survivors are listed with file, line and the swap, and written to
|
|
17
|
+
* `{NN}-mutation.json`
|
|
18
|
+
*
|
|
19
|
+
* `workflow.mutation_gate`: `off` · `report` (the default: a report, never a refusal) ·
|
|
20
|
+
* `gate` (reserved for V5G, which turns survivors into gaps). No coverage is collected:
|
|
21
|
+
* the full suite runs per mutant, so the sample is kept small.
|
|
22
|
+
*
|
|
23
|
+
* The operator table started as scripts/mutation-probe.cjs's, which PAN runs on itself;
|
|
24
|
+
* that probe now imports it from here.
|
|
25
|
+
*/
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const { execGit, findPhaseInternal, output, error, toPosix } = require('./core.cjs');
|
|
29
|
+
|
|
30
|
+
const MODES = Object.freeze(['off', 'report', 'gate']);
|
|
31
|
+
const DEFAULT_MAX = 8;
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
|
33
|
+
|
|
34
|
+
const C_FAMILY_COMPARE = [
|
|
35
|
+
{ id: 'gte→gt', find: ' >= ', replace: ' > ' },
|
|
36
|
+
{ id: 'gt→gte', find: ' > ', replace: ' >= ' },
|
|
37
|
+
{ id: 'lte→lt', find: ' <= ', replace: ' < ' },
|
|
38
|
+
{ id: 'lt→lte', find: ' < ', replace: ' <= ' },
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
/** The operators, per language family. Each is a plain textual swap, first occurrence on a line. */
|
|
42
|
+
const OPERATORS = Object.freeze({
|
|
43
|
+
js: [
|
|
44
|
+
...C_FAMILY_COMPARE,
|
|
45
|
+
{ id: 'eq→neq', find: ' === ', replace: ' !== ' },
|
|
46
|
+
{ id: 'neq→eq', find: ' !== ', replace: ' === ' },
|
|
47
|
+
{ id: 'and→or', find: ' && ', replace: ' || ' },
|
|
48
|
+
{ id: 'or→and', find: ' || ', replace: ' && ' },
|
|
49
|
+
{ id: 'true→false', find: 'return true', replace: 'return false' },
|
|
50
|
+
{ id: 'false→true', find: 'return false', replace: 'return true' },
|
|
51
|
+
{ id: 'plus→minus', find: ' + 1', replace: ' - 1' },
|
|
52
|
+
],
|
|
53
|
+
c: [
|
|
54
|
+
...C_FAMILY_COMPARE,
|
|
55
|
+
{ id: 'eq→neq', find: ' == ', replace: ' != ' },
|
|
56
|
+
{ id: 'neq→eq', find: ' != ', replace: ' == ' },
|
|
57
|
+
{ id: 'and→or', find: ' && ', replace: ' || ' },
|
|
58
|
+
{ id: 'or→and', find: ' || ', replace: ' && ' },
|
|
59
|
+
{ id: 'true→false', find: 'return true', replace: 'return false' },
|
|
60
|
+
{ id: 'false→true', find: 'return false', replace: 'return true' },
|
|
61
|
+
{ id: 'plus→minus', find: ' + 1', replace: ' - 1' },
|
|
62
|
+
],
|
|
63
|
+
py: [
|
|
64
|
+
...C_FAMILY_COMPARE,
|
|
65
|
+
{ id: 'eq→neq', find: ' == ', replace: ' != ' },
|
|
66
|
+
{ id: 'neq→eq', find: ' != ', replace: ' == ' },
|
|
67
|
+
{ id: 'and→or', find: ' and ', replace: ' or ' },
|
|
68
|
+
{ id: 'or→and', find: ' or ', replace: ' and ' },
|
|
69
|
+
{ id: 'true→false', find: 'return True', replace: 'return False' },
|
|
70
|
+
{ id: 'false→true', find: 'return False', replace: 'return True' },
|
|
71
|
+
{ id: 'plus→minus', find: ' + 1', replace: ' - 1' },
|
|
72
|
+
],
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const LANGUAGES = [
|
|
76
|
+
{ family: 'js', re: /\.(c|m)?(j|t)sx?$/i, comments: ['//', '/*', '*'] },
|
|
77
|
+
{ family: 'py', re: /\.py$/i, comments: ['#'] },
|
|
78
|
+
{ family: 'c', re: /\.(go|rs|java|kt|kts|cs|c|cc|cpp|h|hpp|swift|scala)$/i, comments: ['//', '/*', '*'] },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
/** The language family of a file, or null when PAN has no operators for it. */
|
|
82
|
+
function languageOf(file) {
|
|
83
|
+
return LANGUAGES.find((l) => l.re.test(file)) || null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Is this line worth mutating? Skips comments, and a line whose only occurrence of the
|
|
88
|
+
* token is inside a string: a mutated message changes nothing a test should pin. Crude
|
|
89
|
+
* but conservative: on a line with a quote, the token must come before the first quote.
|
|
90
|
+
*/
|
|
91
|
+
function mutableLine(line, token, comments = ['//', '/*', '*']) {
|
|
92
|
+
const trimmed = line.trim();
|
|
93
|
+
if (!trimmed || comments.some((c) => trimmed.startsWith(c))) return false;
|
|
94
|
+
const at = line.indexOf(token);
|
|
95
|
+
if (at === -1) return false;
|
|
96
|
+
const firstQuote = Math.min(...["'", '"', '`'].map((q) => { const i = line.indexOf(q); return i === -1 ? Infinity : i; }));
|
|
97
|
+
return at < firstQuote;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Every mutation in a source text: one per (line, operator), first occurrence on the line.
|
|
102
|
+
* @param {string} src
|
|
103
|
+
* @param {{family?: string, comments?: string[], lines?: Set<number>}} [opts] - `lines` limits it to those line numbers
|
|
104
|
+
*/
|
|
105
|
+
function mutationsFor(src, opts = {}) {
|
|
106
|
+
const ops = OPERATORS[opts.family || 'js'];
|
|
107
|
+
const out = [];
|
|
108
|
+
src.split(/\r?\n/).forEach((line, i) => {
|
|
109
|
+
if (opts.lines && !opts.lines.has(i + 1)) return;
|
|
110
|
+
for (const op of ops) {
|
|
111
|
+
if (!mutableLine(line, op.find, opts.comments)) continue;
|
|
112
|
+
out.push({ line: i + 1, op: op.id, before: line, after: line.replace(op.find, op.replace) });
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Apply one mutation to a source string, by line number. Throws if the line moved. */
|
|
119
|
+
function applyMutation(src, mutation) {
|
|
120
|
+
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
121
|
+
const lines = src.split(/\r?\n/);
|
|
122
|
+
if (lines[mutation.line - 1] !== mutation.before) throw new Error(`line ${mutation.line} is not what the mutation was built from`);
|
|
123
|
+
lines[mutation.line - 1] = mutation.after;
|
|
124
|
+
return lines.join(eol);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Deterministic shuffle-and-take, so a report is reproducible from its seed. */
|
|
128
|
+
function sample(items, n, seed) {
|
|
129
|
+
let s = seed >>> 0;
|
|
130
|
+
const rand = () => { s = (s * 1664525 + 1013904223) >>> 0; return s / 0x100000000; };
|
|
131
|
+
const copy = items.slice();
|
|
132
|
+
for (let i = copy.length - 1; i > 0; i--) {
|
|
133
|
+
const j = Math.floor(rand() * (i + 1));
|
|
134
|
+
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
135
|
+
}
|
|
136
|
+
return copy.slice(0, n);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The line numbers each file gained or changed between two revisions (`git diff -U0`).
|
|
141
|
+
* @returns {Map<string, Set<number>>}
|
|
142
|
+
*/
|
|
143
|
+
function changedLines(cwd, base, head = 'HEAD') {
|
|
144
|
+
const r = execGit(cwd, ['-c', 'core.quotepath=off', 'diff', '-U0', '--no-color', '--relative', base, head]);
|
|
145
|
+
const out = new Map();
|
|
146
|
+
if (r.exitCode !== 0) return out;
|
|
147
|
+
let file = null;
|
|
148
|
+
for (const line of r.stdout.split('\n')) {
|
|
149
|
+
const f = line.match(/^\+\+\+ (?:b\/)?(.+)$/);
|
|
150
|
+
if (f) { file = f[1] === '/dev/null' ? null : f[1].trim(); continue; }
|
|
151
|
+
const h = line.match(/^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/);
|
|
152
|
+
if (h && file) {
|
|
153
|
+
const start = Number(h[1]);
|
|
154
|
+
const count = h[2] === undefined ? 1 : Number(h[2]);
|
|
155
|
+
if (!out.has(file)) out.set(file, new Set());
|
|
156
|
+
for (let n = start; n < start + count; n++) out.get(file).add(n);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return out;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** `workflow.mutation_gate` from config.json; `report` unless set. */
|
|
163
|
+
function mutationMode(cwd) {
|
|
164
|
+
const v = (require('./test-gate.cjs').rawConfig(cwd).workflow || {}).mutation_gate;
|
|
165
|
+
return MODES.includes(v) ? v : 'report';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The mutation report for one phase.
|
|
170
|
+
* @param {string} cwd
|
|
171
|
+
* @param {string} phase
|
|
172
|
+
* @param {{max?: number, seed?: number, timeoutMs?: number}} [opts]
|
|
173
|
+
*/
|
|
174
|
+
function mutatePhase(cwd, phase, opts = {}) {
|
|
175
|
+
const TG = require('./test-gate.cjs');
|
|
176
|
+
const { phaseBase, withWorktree } = require('./verify-red.cjs');
|
|
177
|
+
const { isTestFile } = require('./verify-integrity.cjs');
|
|
178
|
+
const info = findPhaseInternal(cwd, phase);
|
|
179
|
+
if (!info) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
|
|
180
|
+
const phaseNumber = info.phase_number || String(phase);
|
|
181
|
+
const mode = mutationMode(cwd);
|
|
182
|
+
const seed = Number.isFinite(opts.seed) ? opts.seed : 1;
|
|
183
|
+
const max = opts.max || DEFAULT_MAX;
|
|
184
|
+
const base = { phase: phaseNumber, mode, seed, checked: false };
|
|
185
|
+
if (mode === 'off' && !opts.force) return { ...base, reason: 'workflow.mutation_gate is off' };
|
|
186
|
+
const b = phaseBase(cwd, phaseNumber);
|
|
187
|
+
if (!b.base) return { ...base, reason: b.reason };
|
|
188
|
+
const suite = TG.detectTestCommand(cwd);
|
|
189
|
+
if (!suite.command) return { ...base, base: b.base, reason: suite.reason };
|
|
190
|
+
|
|
191
|
+
// Candidates: changed lines of the phase's own source files, per language.
|
|
192
|
+
const changed = changedLines(cwd, b.base);
|
|
193
|
+
const candidates = [];
|
|
194
|
+
for (const [file, lines] of changed) {
|
|
195
|
+
const lang = languageOf(file);
|
|
196
|
+
if (!lang || isTestFile(file) || !b.files.has(file) || /^\.planning\//.test(file)) continue;
|
|
197
|
+
let src;
|
|
198
|
+
try { src = fs.readFileSync(path.join(cwd, file), 'utf8'); } catch { continue; }
|
|
199
|
+
for (const m of mutationsFor(src, { family: lang.family, comments: lang.comments, lines })) candidates.push({ file, ...m });
|
|
200
|
+
}
|
|
201
|
+
if (!candidates.length) return { ...base, base: b.base, command: suite.command, candidates: 0, reason: 'no mutable line in the source the phase changed' };
|
|
202
|
+
const picked = sample(candidates, max, seed);
|
|
203
|
+
const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
204
|
+
const killedBy = (r) => r.timed_out || r.exit !== 0;
|
|
205
|
+
|
|
206
|
+
const wt = withWorktree(cwd, 'HEAD', (dir) => {
|
|
207
|
+
// Flake pre-check: the unmutated suite must pass, twice, or no mutant can be read.
|
|
208
|
+
const first = TG.runCommand(dir, suite.command, timeoutMs);
|
|
209
|
+
const second = TG.runCommand(dir, suite.command, timeoutMs);
|
|
210
|
+
if (killedBy(first) || killedBy(second)) {
|
|
211
|
+
return { aborted: killedBy(first) !== killedBy(second) ? 'flaky' : 'suite_fails', reason: killedBy(first) !== killedBy(second) ? 'the unmutated suite passed once and failed once: a flaky suite would make every mutant unreadable' : 'the unmutated suite fails at HEAD: fix it before measuring its tests' };
|
|
212
|
+
}
|
|
213
|
+
const results = [];
|
|
214
|
+
for (const m of picked) {
|
|
215
|
+
const file = path.join(dir, m.file);
|
|
216
|
+
const original = fs.readFileSync(file, 'utf8');
|
|
217
|
+
let mutated;
|
|
218
|
+
try { mutated = applyMutation(original, m); } catch { continue; }
|
|
219
|
+
fs.writeFileSync(file, mutated);
|
|
220
|
+
try {
|
|
221
|
+
results.push({ ...m, killed: killedBy(TG.runCommand(dir, suite.command, timeoutMs)) });
|
|
222
|
+
} finally {
|
|
223
|
+
fs.writeFileSync(file, original);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return { results };
|
|
227
|
+
});
|
|
228
|
+
if (wt.error) return { ...base, base: b.base, reason: wt.error };
|
|
229
|
+
const head = (execGit(cwd, ['rev-parse', 'HEAD']).stdout || '').trim() || null;
|
|
230
|
+
if (wt.value.aborted) return { ...base, base: b.base, head, command: suite.command, candidates: candidates.length, aborted: wt.value.aborted, reason: wt.value.reason };
|
|
231
|
+
const results = wt.value.results;
|
|
232
|
+
const killed = results.filter((r) => r.killed).length;
|
|
233
|
+
const survivors = results.filter((r) => !r.killed).map(({ file, line, op, before, after }) => ({ file: toPosix(file), line, op, before: before.trim(), after: after.trim() }));
|
|
234
|
+
return {
|
|
235
|
+
...base, checked: results.length > 0, base: b.base, head, command: suite.command,
|
|
236
|
+
candidates: candidates.length, sampled: results.length, killed, survived: survivors.length,
|
|
237
|
+
score: results.length ? Number((killed / results.length).toFixed(2)) : null, survivors,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* `pan-tools verify mutate --phase N [--max n] [--seed n] [--timeout ms]`: writes
|
|
243
|
+
* `{NN}-mutation.json` and exits 0 (report mode; V5G adds the gate).
|
|
244
|
+
*/
|
|
245
|
+
function cmdVerifyMutate(cwd, opts, raw) {
|
|
246
|
+
if (!opts.phase) error('Usage: verify mutate --phase <N> [--max <n>] [--seed <n>] [--timeout <ms>]');
|
|
247
|
+
const r = mutatePhase(cwd, opts.phase, { max: opts.max ? Number(opts.max) : undefined, seed: opts.seed !== undefined && opts.seed !== null ? Number(opts.seed) : undefined, timeoutMs: opts.timeout ? Number(opts.timeout) : undefined });
|
|
248
|
+
if (r.error) error(r.reason);
|
|
249
|
+
const info = findPhaseInternal(cwd, opts.phase);
|
|
250
|
+
const prefix = String(info.phase_number).split('.').map((p, i) => (i === 0 ? p.padStart(2, '0') : p)).join('.');
|
|
251
|
+
const file = path.join(cwd, info.directory, `${prefix}-mutation.json`);
|
|
252
|
+
fs.writeFileSync(file, JSON.stringify(r, null, 2) + '\n');
|
|
253
|
+
r.record = toPosix(path.relative(cwd, file));
|
|
254
|
+
output(r, raw, r.aborted ? `aborted:${r.aborted}` : (r.checked ? `${r.killed}/${r.sampled} killed` : 'not_checked'));
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = { OPERATORS, MODES, languageOf, mutableLine, mutationsFor, applyMutation, sample, changedLines, mutationMode, mutatePhase, cmdVerifyMutate };
|
|
@@ -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,180 @@
|
|
|
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 `enforce` (the default since V1E: refuse
|
|
21
|
+
* unless `--force --reason`, which records a decision) or `warn` (report and proceed).
|
|
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: `warn`, or (anything else, absent included) `enforce`. */
|
|
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 : 'enforce';
|
|
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
|
+
const TG = require('./test-gate.cjs');
|
|
55
|
+
|
|
56
|
+
const mode = MODES.includes(opts.mode) ? opts.mode : gateMode(cwd);
|
|
57
|
+
const reasons = [];
|
|
58
|
+
const notChecked = [];
|
|
59
|
+
const facts = {};
|
|
60
|
+
const refuse = (code, detail, extra = {}) => reasons.push({ code, detail, ...extra });
|
|
61
|
+
const skip = (check, reason) => notChecked.push({ check, reason });
|
|
62
|
+
const phaseNumber = info.phase_number || String(phase);
|
|
63
|
+
const dir = path.join(cwd, info.directory);
|
|
64
|
+
|
|
65
|
+
// 1. Every plan has a summary.
|
|
66
|
+
const plans = info.plans || [];
|
|
67
|
+
const incomplete = info.incomplete_plans || [];
|
|
68
|
+
facts.plans = `${plans.length - incomplete.length}/${plans.length}`;
|
|
69
|
+
if (!plans.length) refuse('no_plans', 'the phase has no plans');
|
|
70
|
+
else if (incomplete.length) refuse('missing_summary', `${incomplete.length} plan(s) have no summary: ${incomplete.join(', ')}`, { plans: incomplete });
|
|
71
|
+
|
|
72
|
+
// 2. A passing verification (with code facts applied, V9).
|
|
73
|
+
let names = [];
|
|
74
|
+
try { names = fs.readdirSync(dir); } catch { /* unreadable reads as empty */ }
|
|
75
|
+
const verFile = names.filter(isVerificationFile).sort().pop();
|
|
76
|
+
const verifierOn = loadConfig(cwd).verifier !== false;
|
|
77
|
+
if (!verFile) {
|
|
78
|
+
if (verifierOn) refuse('no_verification', 'the phase has no verification; run /pan:verify-phase');
|
|
79
|
+
else skip('verification', 'workflow.verifier is off');
|
|
80
|
+
} else {
|
|
81
|
+
// The engine's test run (V2) is a code fact: a failed run lowers a written pass.
|
|
82
|
+
const run = TG.readTestRun(cwd, phaseNumber);
|
|
83
|
+
const parsed = V.parseVerdictText(safeReadFile(path.join(dir, verFile)) || '', { agent: 'pan-verifier', phase: phaseNumber, facts: TG.testRunFacts(cwd, phaseNumber) });
|
|
84
|
+
if (!parsed.ok) {
|
|
85
|
+
refuse('verification_unreadable', `${verFile}: ${parsed.reason}`);
|
|
86
|
+
} else {
|
|
87
|
+
const v = parsed.verdict;
|
|
88
|
+
const fm = V.verdictFromVerificationFrontmatter(safeReadFile(path.join(dir, verFile)) || '') || {};
|
|
89
|
+
facts.verification = v.verdict;
|
|
90
|
+
// The verification's own word, else the engine's record of the run.
|
|
91
|
+
const gate = fm.test_gate || (run && run.status) || null;
|
|
92
|
+
facts.test_gate = gate;
|
|
93
|
+
if (run) facts.test_run = run.status;
|
|
94
|
+
if (parsed.lowered_by && parsed.lowered_by.length) facts.lowered_by = parsed.lowered_by;
|
|
95
|
+
if (gate === 'failed') refuse('test_failed', fm.test_gate === 'failed' ? 'the verification records test_gate: failed' : `the engine's test run failed: ${run.reason || 'see the test-run record'}`);
|
|
96
|
+
else if (gate === 'skipped') skip('tests', fm.test_gate ? 'the test gate was skipped' : (run.reason || 'the test gate was skipped'));
|
|
97
|
+
else if (!gate) skip('tests', 'the verification does not record its test gate');
|
|
98
|
+
if (v.outcome === 'fail') {
|
|
99
|
+
refuse('verification_failed', `the verification is ${v.verdict}${v.agent_verdict ? ` (the verifier wrote ${v.agent_verdict})` : ''}`);
|
|
100
|
+
} else if (v.outcome === 'needs_human') {
|
|
101
|
+
const human = F.listFindings(cwd, { phase: phaseNumber, agent: 'pan-verifier', class: 'human' }).findings;
|
|
102
|
+
const unapproved = human.filter((f) => f.status !== 'decision');
|
|
103
|
+
if (unapproved.length || !human.length) {
|
|
104
|
+
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) });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 3. The verification still describes the code.
|
|
111
|
+
if (verFile) {
|
|
112
|
+
const fresh = verificationFreshness(cwd, phaseNumber, info);
|
|
113
|
+
facts.freshness = fresh.state;
|
|
114
|
+
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 });
|
|
115
|
+
else if (fresh.state === 'unknown') skip('freshness', fresh.reason || 'unknown');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 4. The written verdict agrees with the mechanical checks.
|
|
119
|
+
if (verFile) {
|
|
120
|
+
const rec = reconcilePhase(cwd, phaseNumber);
|
|
121
|
+
facts.reconciled = rec.reconciled;
|
|
122
|
+
if (!rec.reconciled) refuse('reconcile_contradiction', rec.contradictions.join('; '), { contradictions: rec.contradictions });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 5. No critical or high finding left open without a disposition.
|
|
126
|
+
const open = F.listFindings(cwd, { phase: phaseNumber, status: 'open' }).findings
|
|
127
|
+
.filter((f) => SERIOUS.has(f.severity) && f.class !== 'human');
|
|
128
|
+
facts.open_serious_findings = open.length;
|
|
129
|
+
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) });
|
|
130
|
+
|
|
131
|
+
// 6. No stub in the files the phase committed.
|
|
132
|
+
const committed = committedFiles(cwd, phaseNumber);
|
|
133
|
+
if (!committed.git) {
|
|
134
|
+
skip('stubs', 'git is unavailable');
|
|
135
|
+
} else {
|
|
136
|
+
const files = [...committed.files].filter((f) => fs.existsSync(path.join(cwd, f)));
|
|
137
|
+
const stubs = scanStubs(cwd, { files });
|
|
138
|
+
facts.stubs_blocking = stubs.blocking;
|
|
139
|
+
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(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 7. The phase did not weaken its own tests.
|
|
143
|
+
const integrity = checkTestIntegrity(cwd, phaseNumber);
|
|
144
|
+
if (integrity.error || !integrity.checked) {
|
|
145
|
+
skip('test_integrity', integrity.reason || integrity.error);
|
|
146
|
+
} else {
|
|
147
|
+
facts.test_integrity = integrity.ok ? 'clean' : 'violated';
|
|
148
|
+
if (!integrity.ok) refuse('integrity_violation', integrity.violations.slice(0, 3).map((x) => `${x.code} ${x.file}`).join('; '), { violations: integrity.violations });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { phase: phaseNumber, mode, ok: reasons.length === 0, reasons, facts, not_checked: notChecked };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* `--force --reason`: complete past the gate, on the record. Every open finding the
|
|
156
|
+
* gate counted gets a `decision` disposition with the reason, and the trace logs it.
|
|
157
|
+
*/
|
|
158
|
+
function recordGateOverride(cwd, gate, reason) {
|
|
159
|
+
const F = require('./findings.cjs');
|
|
160
|
+
const ids = [];
|
|
161
|
+
for (const r of gate.reasons) for (const id of r.ids || []) if (!ids.includes(id)) ids.push(id);
|
|
162
|
+
const disposed = ids.length ? F.disposeFindings(cwd, { ids, as: 'decision', reason: `phase completed past the gate: ${reason}` }) : { disposed: [] };
|
|
163
|
+
try {
|
|
164
|
+
require('./optimize.cjs').logTraceEvent(cwd, {
|
|
165
|
+
type: 'decision', category: 'phase_gate_forced', agent: 'orchestrator', phase: gate.phase,
|
|
166
|
+
description: `phase ${gate.phase} completed past the gate (${gate.reasons.map((r) => r.code).join(', ')}): ${reason}`,
|
|
167
|
+
impact: 'major', context: { reasons: gate.reasons.map((r) => r.code), reason },
|
|
168
|
+
});
|
|
169
|
+
} catch { /* telemetry is best effort */ }
|
|
170
|
+
return { reason, refused_by: gate.reasons.map((r) => r.code), disposed: disposed.disposed || [] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function cmdPhaseGate(cwd, phase, raw, opts = {}) {
|
|
174
|
+
if (!phase) error('Usage: phase gate <N> [--mode warn|enforce]');
|
|
175
|
+
const r = phaseGate(cwd, phase, opts);
|
|
176
|
+
if (r.error) { output(r, raw); return; }
|
|
177
|
+
output(r, raw, r.ok ? 'pass' : 'refuse', r.ok ? 0 : 1);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
module.exports = { phaseGate, recordGateOverride, gateMode, cmdPhaseGate, MODES };
|
|
@@ -12,6 +12,21 @@ const { writeStateMd, readStateSafe } = require('./state.cjs');
|
|
|
12
12
|
const { ROADMAP_FILE, STATE_FILE, isSummaryFile } = require('./constants.cjs');
|
|
13
13
|
const { planningPath, phasesPath, fileAccessible } = require('./utils.cjs');
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* A regex source matching a phase number as any roadmap writes it: `phase insert` writes
|
|
17
|
+
* padded decimals (`Phase 01.1`) and `phase add` unpadded integers (`Phase 3`), so "1.1"
|
|
18
|
+
* must match both `1.1` and `01.1` (found by H7's property test: removing 1.1 deleted its
|
|
19
|
+
* directory and left its roadmap section behind).
|
|
20
|
+
* @param {string} num - a phase number, padded or not ("1.1", "01.1", "3")
|
|
21
|
+
*/
|
|
22
|
+
function phaseNumPattern(num) {
|
|
23
|
+
const [int, ...rest] = String(num).split('.');
|
|
24
|
+
return `0*${escapeRegex(String(parseInt(int, 10)))}${rest.map((d) => `\\.${escapeRegex(d)}`).join('')}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Write `next` in the style `matched` was written in: padded to two digits, or not. */
|
|
28
|
+
const samePadding = (matched, next) => (/^0\d/.test(matched) ? String(next).padStart(2, '0') : String(next));
|
|
29
|
+
|
|
15
30
|
/**
|
|
16
31
|
* Delete a phase directory from disk.
|
|
17
32
|
* @param {string} phaseDir - Absolute path to the phase directory to remove
|
|
@@ -200,7 +215,7 @@ function removePhaseSection(content, phaseNum) {
|
|
|
200
215
|
fenced.fill(true, top.open, i + 1);
|
|
201
216
|
} else if (!top || f[2].trim()) stack.push({ fence: f[1], open: i });
|
|
202
217
|
}
|
|
203
|
-
const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${
|
|
218
|
+
const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${phaseNumPattern(phaseNum)}\\s*:`, 'i');
|
|
204
219
|
const start = lines.findIndex((l, i) => !fenced[i] && headRe.test(l));
|
|
205
220
|
if (start === -1) return content;
|
|
206
221
|
const levelEndRe = new RegExp(`^#{1,${lines[start].match(headRe)[1].length}}\\s`);
|
|
@@ -258,7 +273,7 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
|
|
|
258
273
|
} catch { return; }
|
|
259
274
|
|
|
260
275
|
roadmapContent = removePhaseSection(roadmapContent, phaseNum);
|
|
261
|
-
const targetEscaped =
|
|
276
|
+
const targetEscaped = phaseNumPattern(phaseNum);
|
|
262
277
|
|
|
263
278
|
// Remove checkbox list items referencing this phase
|
|
264
279
|
const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
|
|
@@ -291,14 +306,14 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
|
|
|
291
306
|
|
|
292
307
|
// Phase headings: ## Phase 18: or ### Phase 18.1: -> ## Phase 17: / ### Phase 17.1:
|
|
293
308
|
roadmapContent = roadmapContent.replace(
|
|
294
|
-
new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}((?:\\.\\d+)?\\s*:)`, 'gi'),
|
|
295
|
-
`$
|
|
309
|
+
new RegExp(`(#{2,4}\\s*Phase\\s+)(0*)${oldStr}((?:\\.\\d+)?\\s*:)`, 'gi'),
|
|
310
|
+
(_, head, zeros, tail) => `${head}${samePadding(zeros + oldStr, newNum)}${tail}`
|
|
296
311
|
);
|
|
297
312
|
|
|
298
313
|
// Inline phase references: "Phase 18:", "Phase 18 " or "Phase 18.1:" -> "Phase 17..."
|
|
299
314
|
roadmapContent = roadmapContent.replace(
|
|
300
|
-
new RegExp(`(Phase\\s+)${oldStr}((?:\\.\\d+)?[:\\s])`, 'g'),
|
|
301
|
-
`$
|
|
315
|
+
new RegExp(`(Phase\\s+)(0*)${oldStr}((?:\\.\\d+)?[:\\s])`, 'g'),
|
|
316
|
+
(_, head, zeros, tail) => `${head}${samePadding(zeros + oldStr, newNum)}${tail}`
|
|
302
317
|
);
|
|
303
318
|
|
|
304
319
|
// Plan references in padded form: 18-01 / 18.1-01 -> 17-01 / 17.1-01, never inside a date (2026-10-01)
|
|
@@ -309,26 +324,65 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
|
|
|
309
324
|
|
|
310
325
|
// Progress table row numbers: | 18. or | 18.1. -> | 17. / | 17.1.
|
|
311
326
|
roadmapContent = roadmapContent.replace(
|
|
312
|
-
new RegExp(`(\\|\\s*)${oldStr}((?:\\.\\d+)?)\\.\\s`, 'g'),
|
|
313
|
-
`$
|
|
327
|
+
new RegExp(`(\\|\\s*)(0*)${oldStr}((?:\\.\\d+)?)\\.\\s`, 'g'),
|
|
328
|
+
(_, head, zeros, dec) => `${head}${samePadding(zeros + oldStr, newNum)}${dec}. `
|
|
314
329
|
);
|
|
315
330
|
|
|
316
331
|
// Depends-on references: "Depends on:** Phase 18" -> "Phase 17"
|
|
317
332
|
roadmapContent = roadmapContent.replace(
|
|
318
|
-
new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
|
|
319
|
-
`$
|
|
333
|
+
new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)(0*)${oldStr}\\b`, 'gi'),
|
|
334
|
+
(_, head, zeros) => `${head}${samePadding(zeros + oldStr, newNum)}`
|
|
320
335
|
);
|
|
321
336
|
}
|
|
337
|
+
} else {
|
|
338
|
+
// Decimal removal: the siblings above it move down one (06.3 -> 06.2), as their
|
|
339
|
+
// directories already did. Upward, so a renamed reference is never renamed twice.
|
|
340
|
+
const [baseStr, removedStr] = normalized.split('.');
|
|
341
|
+
const base = escapeRegex(String(parseInt(baseStr, 10)));
|
|
342
|
+
for (let dec = parseInt(removedStr, 10) + 1; dec <= 99; dec++) {
|
|
343
|
+
const was = `(0*${base})\\.${dec}`;
|
|
344
|
+
roadmapContent = roadmapContent
|
|
345
|
+
.replace(new RegExp(`(Phase\\s+)${was}([:\\s])`, 'gi'), (_, head, int, tail) => `${head}${int}.${dec - 1}${tail}`)
|
|
346
|
+
.replace(new RegExp(`(\\|\\s*)${was}\\.\\s`, 'g'), (_, head, int) => `${head}${int}.${dec - 1}. `)
|
|
347
|
+
.replace(new RegExp(`(?<![\\d.-])${was}-(\\d{2})(?!\\d)`, 'g'), (_, int, plan) => `${int}.${dec - 1}-${plan}`);
|
|
348
|
+
}
|
|
322
349
|
}
|
|
323
350
|
|
|
324
351
|
try { fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8'); } catch { /* best-effort */ }
|
|
325
352
|
}
|
|
326
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The decimal phases inserted after an integer phase, from its directories and its
|
|
356
|
+
* roadmap headings (a phase can be in the roadmap before it has a directory).
|
|
357
|
+
* @param {string} phasesDir - the phases directory
|
|
358
|
+
* @param {string} roadmapPath - roadmap.md
|
|
359
|
+
* @param {number} baseInt - the integer phase
|
|
360
|
+
* @returns {{num: string, dir: string|null}[]} in numeric order, e.g. [{num: '3.1', dir: '03.1-fix'}]
|
|
361
|
+
*/
|
|
362
|
+
function findDecimalPhases(phasesDir, roadmapPath, baseInt) {
|
|
363
|
+
const found = new Map();
|
|
364
|
+
const dirRe = new RegExp(`^0*${baseInt}\\.(\\d+)(?:-|$)`);
|
|
365
|
+
try {
|
|
366
|
+
for (const entry of fs.readdirSync(phasesDir, { withFileTypes: true })) {
|
|
367
|
+
const m = entry.isDirectory() && entry.name.match(dirRe);
|
|
368
|
+
if (m) found.set(Number(m[1]), entry.name);
|
|
369
|
+
}
|
|
370
|
+
} catch { /* no phases directory: the roadmap alone decides */ }
|
|
371
|
+
let text = '';
|
|
372
|
+
try { text = fs.readFileSync(roadmapPath, 'utf8'); } catch { /* unreadable: directories alone */ }
|
|
373
|
+
for (const m of text.matchAll(new RegExp(`^#{2,4}\\s*Phase\\s+0*${baseInt}\\.(\\d+)\\s*:`, 'gim'))) {
|
|
374
|
+
if (!found.has(Number(m[1]))) found.set(Number(m[1]), null);
|
|
375
|
+
}
|
|
376
|
+
return [...found.entries()].sort((a, b) => a[0] - b[0]).map(([dec, dir]) => ({ num: `${baseInt}.${dec}`, dir }));
|
|
377
|
+
}
|
|
378
|
+
|
|
327
379
|
/**
|
|
328
380
|
* Remove a phase directory, renumber subsequent phases, and update ROADMAP/STATE.
|
|
381
|
+
* An integer phase that still has decimal phases is refused unless `withDecimals`.
|
|
329
382
|
* @param {string} cwd - Working directory path
|
|
330
383
|
* @param {string} targetPhase - Phase number to remove
|
|
331
|
-
* @param {Object} options - Options (force: skip executed-work check
|
|
384
|
+
* @param {Object} options - Options (force: skip executed-work check; withDecimals: remove
|
|
385
|
+
* the integer phase's decimal phases with it)
|
|
332
386
|
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
333
387
|
* @returns {void}
|
|
334
388
|
*/
|
|
@@ -393,6 +447,21 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
393
447
|
}
|
|
394
448
|
}
|
|
395
449
|
|
|
450
|
+
// An integer phase's decimal phases (3.1, 3.2 …) were inserted after it. Removing the
|
|
451
|
+
// integer alone orphaned them: they stayed on disk and in the roadmap and, once the
|
|
452
|
+
// phases above were renumbered, read as decimals of a different phase, listed before
|
|
453
|
+
// their new base. So they go with it only when asked; otherwise refuse, naming them.
|
|
454
|
+
const decimals = isDecimal ? [] : findDecimalPhases(phasesDir, roadmapPath, parseInt(normalized, 10));
|
|
455
|
+
if (decimals.length && !options.withDecimals) {
|
|
456
|
+
error(`Phase ${targetPhase} has decimal phases ${decimals.map((d) => d.num).join(', ')} inserted after it. Remove them first, or pass --with-decimals to remove them with it.`);
|
|
457
|
+
}
|
|
458
|
+
if (!force) {
|
|
459
|
+
for (const d of decimals) {
|
|
460
|
+
const executed = d.dir ? fs.readdirSync(path.join(phasesDir, d.dir)).filter(isSummaryFile).length : 0;
|
|
461
|
+
if (executed) error(`Phase ${d.num} has ${executed} executed plan(s). Use --force to remove anyway.`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
396
465
|
// Check for executed work (summary.md files)
|
|
397
466
|
if (targetDir && !force) {
|
|
398
467
|
const targetPath = path.join(phasesDir, targetDir);
|
|
@@ -403,6 +472,16 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
403
472
|
}
|
|
404
473
|
}
|
|
405
474
|
|
|
475
|
+
// --with-decimals: every check has passed, so remove the decimals first, highest first,
|
|
476
|
+
// which leaves no sibling above any of them to renumber.
|
|
477
|
+
const removedDecimals = [];
|
|
478
|
+
for (const d of decimals.slice().reverse()) {
|
|
479
|
+
if (d.dir) removePhaseFromDisk(path.join(phasesDir, d.dir));
|
|
480
|
+
updateRoadmapAfterRemoval(cwd, d.num, true, normalizePhaseName(d.num));
|
|
481
|
+
updateStateAfterPhaseRemoval(cwd);
|
|
482
|
+
removedDecimals.unshift(d.num);
|
|
483
|
+
}
|
|
484
|
+
|
|
406
485
|
// Delete target directory
|
|
407
486
|
let removeWarning = null;
|
|
408
487
|
if (targetDir) {
|
|
@@ -449,6 +528,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
|
|
449
528
|
roadmap_updated: true,
|
|
450
529
|
state_updated: stateUpdated,
|
|
451
530
|
};
|
|
531
|
+
if (removedDecimals.length) result.removed_decimals = removedDecimals;
|
|
452
532
|
if (renameError) result.rename_warning = renameError;
|
|
453
533
|
if (removeWarning) result.remove_warning = removeWarning;
|
|
454
534
|
|