pan-wizard 3.32.0 → 3.33.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/agents/pan-conductor.md +2 -2
- package/agents/pan-document_code.md +2 -2
- package/agents/pan-executor.md +3 -3
- package/agents/pan-experiment-runner.md +7 -10
- package/agents/pan-hardener.md +15 -15
- package/agents/pan-knowledge.md +1 -1
- package/agents/pan-meta-reviewer.md +2 -2
- package/agents/pan-optimizer.md +2 -2
- package/agents/pan-phase-researcher.md +2 -2
- package/agents/pan-plan-checker.md +6 -2
- package/agents/pan-planner.md +23 -10
- package/agents/pan-project-researcher.md +10 -67
- package/agents/pan-roadmapper.md +5 -48
- package/agents/pan-verifier.md +26 -11
- package/bin/install-lib.cjs +170 -15
- package/bin/install.js +39 -14
- package/commands/pan/army.md +2 -2
- package/commands/pan/experiment.md +2 -2
- package/commands/pan/focus-auto.md +12 -8
- package/commands/pan/focus-design.md +2 -0
- package/commands/pan/focus-exec.md +1 -1
- package/commands/pan/focus-scan.md +2 -7
- package/commands/pan/map-codebase.md +4 -4
- package/commands/pan/mcp-bridge.md +1 -1
- package/commands/pan/milestone-done.md +1 -2
- package/commands/pan/phase-tests.md +2 -2
- package/commands/pan/profile.md +2 -2
- package/commands/pan/research-phase.md +2 -4
- package/commands/pan/review-deep.md +3 -3
- package/commands/pan/todo-add.md +2 -2
- package/commands/pan/todo-check.md +2 -2
- package/hooks/dist/pan-state-reinject.js +140 -15
- package/package.json +1 -1
- package/pan-wizard-core/bin/lib/agents-md.cjs +4 -1
- package/pan-wizard-core/bin/lib/commands.cjs +93 -1
- package/pan-wizard-core/bin/lib/config.cjs +110 -0
- package/pan-wizard-core/bin/lib/constants.cjs +11 -9
- package/pan-wizard-core/bin/lib/core.cjs +27 -7
- package/pan-wizard-core/bin/lib/cost.cjs +13 -3
- package/pan-wizard-core/bin/lib/findings.cjs +6 -2
- package/pan-wizard-core/bin/lib/init.cjs +5 -1
- package/pan-wizard-core/bin/lib/phase.cjs +52 -2
- package/pan-wizard-core/bin/lib/review-deep.cjs +2 -2
- package/pan-wizard-core/bin/lib/state.cjs +3 -2
- package/pan-wizard-core/bin/lib/verdict.cjs +54 -4
- package/pan-wizard-core/bin/lib/verify-drift.cjs +14 -5
- package/pan-wizard-core/bin/lib/verify-scope.cjs +69 -2
- package/pan-wizard-core/bin/lib/verify.cjs +87 -7
- package/pan-wizard-core/bin/pan-tools.cjs +10 -2
- package/pan-wizard-core/mcp/server.cjs +18 -9
- package/pan-wizard-core/references/checkpoints.md +4 -2
- package/pan-wizard-core/references/verdict-contract.md +2 -1
- package/pan-wizard-core/templates/discovery.md +1 -1
- package/pan-wizard-core/templates/phase-prompt.md +2 -1
- package/pan-wizard-core/templates/verification-report.md +5 -0
- package/pan-wizard-core/workflows/exec-phase.md +8 -3
- package/pan-wizard-core/workflows/health.md +1 -0
- package/pan-wizard-core/workflows/new-project.md +1 -1
- package/pan-wizard-core/workflows/quick.md +6 -6
- package/pan-wizard-core/workflows/verify-phase.md +7 -2
- package/scripts/build-agent-plugin.js +19 -5
- package/scripts/test-surface.cjs +4 -0
|
@@ -103,6 +103,102 @@ function buildConfigDefaults(hasBraveSearch, userDefaults) {
|
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// What PAN reads beyond the keys buildConfigDefaults writes (market-ideas M26). The
|
|
107
|
+
// rest of the known set is derived from the defaults, so a new default is known the
|
|
108
|
+
// day it is added. Each entry has a reader: loadConfig's flat aliases and alias
|
|
109
|
+
// sections (`planning.*`, `git.*`), the stop guard (`mode`, `workflow.stop_guard`),
|
|
110
|
+
// the roadmapper and new-project (`depth`), memory-optimize (`memory`), and the
|
|
111
|
+
// workflows that config-get `workflow.auto_advance`. tests/config-keys in
|
|
112
|
+
// tests/config.test.cjs pins this list against the keys shipped code and prose use.
|
|
113
|
+
const CONFIG_KEYS_BEYOND_DEFAULTS = Object.freeze({
|
|
114
|
+
top: Object.freeze(['research', 'plan_checker', 'verifier', 'nyquist_validation', 'mode', 'depth', 'planning', 'git',
|
|
115
|
+
'focus', 'model_overrides', 'effort_overrides', 'cost', 'cache', 'build', 'verification', 'concurrency', 'memory']),
|
|
116
|
+
sections: Object.freeze({
|
|
117
|
+
workflow: Object.freeze(['auto_advance', 'stop_guard']),
|
|
118
|
+
planning: Object.freeze(['commit_docs', 'search_gitignored']),
|
|
119
|
+
git: Object.freeze(['branching_strategy', 'phase_branch_template', 'milestone_branch_template']),
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
// Sections whose keys are the user's own (model ids, agent names, file lists,
|
|
123
|
+
// commands), so nothing below them is checked.
|
|
124
|
+
const OPEN_CONFIG_SECTIONS = Object.freeze(['focus', 'model_overrides', 'effort_overrides', 'cost', 'cache', 'build', 'verification', 'concurrency', 'memory']);
|
|
125
|
+
|
|
126
|
+
/** The known top-level keys, and the known keys one level inside each closed section. */
|
|
127
|
+
function knownConfigKeys() {
|
|
128
|
+
const defaults = buildConfigDefaults(false, {});
|
|
129
|
+
const top = new Set([...Object.keys(defaults), ...CONFIG_KEYS_BEYOND_DEFAULTS.top]);
|
|
130
|
+
const sections = new Map();
|
|
131
|
+
for (const [k, v] of Object.entries(defaults)) {
|
|
132
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) sections.set(k, new Set(Object.keys(v)));
|
|
133
|
+
}
|
|
134
|
+
for (const [k, list] of Object.entries(CONFIG_KEYS_BEYOND_DEFAULTS.sections)) {
|
|
135
|
+
sections.set(k, new Set([...(sections.get(k) || []), ...list]));
|
|
136
|
+
}
|
|
137
|
+
for (const k of OPEN_CONFIG_SECTIONS) sections.delete(k);
|
|
138
|
+
return { top, sections };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Whether PAN reads a dot-notation key path: the top-level key is known, and inside a
|
|
143
|
+
* closed section so is the second segment. Deeper segments are not checked.
|
|
144
|
+
* @param {string} keyPath
|
|
145
|
+
* @returns {boolean}
|
|
146
|
+
*/
|
|
147
|
+
function isKnownConfigPath(keyPath) {
|
|
148
|
+
const [head, sub] = String(keyPath).split('.');
|
|
149
|
+
const { top, sections } = knownConfigKeys();
|
|
150
|
+
if (!top.has(head)) return false;
|
|
151
|
+
return sub === undefined || !sections.has(head) || sections.get(head).has(sub);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The key paths in a parsed config.json that PAN does not read (market-ideas M26):
|
|
156
|
+
* unknown top-level keys, and unknown keys one level inside a closed section.
|
|
157
|
+
* @param {Object} config
|
|
158
|
+
* @returns {string[]}
|
|
159
|
+
*/
|
|
160
|
+
function unknownConfigKeys(config) {
|
|
161
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return [];
|
|
162
|
+
const { top, sections } = knownConfigKeys();
|
|
163
|
+
const unknown = [];
|
|
164
|
+
for (const [k, v] of Object.entries(config)) {
|
|
165
|
+
if (!top.has(k)) { unknown.push(k); continue; }
|
|
166
|
+
const known = sections.get(k);
|
|
167
|
+
if (!known || !v || typeof v !== 'object' || Array.isArray(v)) continue;
|
|
168
|
+
for (const sub of Object.keys(v)) if (!known.has(sub)) unknown.push(`${k}.${sub}`);
|
|
169
|
+
}
|
|
170
|
+
return unknown;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The known key path nearest an unknown one (edit distance 2 or less), or null. */
|
|
174
|
+
function nearestConfigKey(keyPath) {
|
|
175
|
+
const { top, sections } = knownConfigKeys();
|
|
176
|
+
const candidates = [...top];
|
|
177
|
+
for (const [k, subs] of sections) for (const s of subs) candidates.push(`${k}.${s}`);
|
|
178
|
+
const want = String(keyPath).split('.').slice(0, 2).join('.');
|
|
179
|
+
let best = null;
|
|
180
|
+
let bestD = 3;
|
|
181
|
+
for (const c of candidates) {
|
|
182
|
+
const d = editDistance(want, c);
|
|
183
|
+
if (d < bestD) { best = c; bestD = d; }
|
|
184
|
+
}
|
|
185
|
+
return best;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function editDistance(a, b) {
|
|
189
|
+
const row = Array.from({ length: b.length + 1 }, (_, j) => j);
|
|
190
|
+
for (let i = 1; i <= a.length; i++) {
|
|
191
|
+
let prev = row[0];
|
|
192
|
+
row[0] = i;
|
|
193
|
+
for (let j = 1; j <= b.length; j++) {
|
|
194
|
+
const tmp = row[j];
|
|
195
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
196
|
+
prev = tmp;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return row[b.length];
|
|
200
|
+
}
|
|
201
|
+
|
|
106
202
|
function cmdConfigEnsureSection(cwd, raw) {
|
|
107
203
|
const configPath = path.join(planningPath(cwd), CONFIG_FILE);
|
|
108
204
|
|
|
@@ -216,6 +312,14 @@ function cmdConfigSet(cwd, keyPath, value, raw) {
|
|
|
216
312
|
try {
|
|
217
313
|
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
|
|
218
314
|
const result = { updated: true, key: keyPath, value: parsedValue };
|
|
315
|
+
// A key PAN does not read is still written (a newer PAN, or the user's own
|
|
316
|
+
// note, may want it), but never silently: a typo used to look like a setting
|
|
317
|
+
// that took effect (market-ideas M26).
|
|
318
|
+
if (!isKnownConfigPath(keyPath)) {
|
|
319
|
+
const near = nearestConfigKey(keyPath);
|
|
320
|
+
result.warning = `unknown config key "${keyPath}": PAN does not read it` + (near ? `; did you mean "${near}"?` : '');
|
|
321
|
+
process.stderr.write(`pan-tools: ${result.warning}\n`);
|
|
322
|
+
}
|
|
219
323
|
output(result, raw, `${keyPath}=${parsedValue}`);
|
|
220
324
|
} catch (err) {
|
|
221
325
|
// Config file could not be written — disk full, permissions, etc.
|
|
@@ -664,6 +768,12 @@ function cmdStandardsTools(cwd, standardId, raw) {
|
|
|
664
768
|
|
|
665
769
|
module.exports = {
|
|
666
770
|
buildConfigDefaults,
|
|
771
|
+
CONFIG_KEYS_BEYOND_DEFAULTS,
|
|
772
|
+
OPEN_CONFIG_SECTIONS,
|
|
773
|
+
knownConfigKeys,
|
|
774
|
+
isKnownConfigPath,
|
|
775
|
+
unknownConfigKeys,
|
|
776
|
+
nearestConfigKey,
|
|
667
777
|
cmdConfigEnsureSection,
|
|
668
778
|
cmdConfigSet,
|
|
669
779
|
cmdConfigGet,
|
|
@@ -207,16 +207,18 @@ const STANDARDS_CATALOG = {
|
|
|
207
207
|
level: 'foundational',
|
|
208
208
|
url: 'https://owasp.org/www-project-top-ten/',
|
|
209
209
|
checklist: [
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
'
|
|
213
|
-
'
|
|
214
|
-
'
|
|
215
|
-
'
|
|
210
|
+
// The 2025 list (top10.owasp.org): until 2026-10-03 this held the 2021 list
|
|
211
|
+
// under the 2025 name (prompt audit, market-ideas M34). SSRF is now part of A01.
|
|
212
|
+
'A01: Broken Access Control — verify authorization checks on all endpoints, and allowlists for server-side requests (SSRF)',
|
|
213
|
+
'A02: Security Misconfiguration — verify default credentials removed, headers set',
|
|
214
|
+
'A03: Software Supply Chain Failures — verify dependency scanning, no known CVEs, pinned and verified packages and build tooling',
|
|
215
|
+
'A04: Cryptographic Failures — verify sensitive data encryption at rest and in transit',
|
|
216
|
+
'A05: Injection — verify input validation and parameterized queries',
|
|
217
|
+
'A06: Insecure Design — verify threat modeling and secure design patterns',
|
|
216
218
|
'A07: Authentication Failures — verify MFA support, session management, credential storage',
|
|
217
|
-
'A08: Software
|
|
218
|
-
'A09: Logging and
|
|
219
|
-
'A10:
|
|
219
|
+
'A08: Software or Data Integrity Failures — verify CI/CD pipeline integrity, signed updates',
|
|
220
|
+
'A09: Security Logging and Alerting Failures — verify security events logged, alerts configured',
|
|
221
|
+
'A10: Mishandling of Exceptional Conditions — verify errors fail closed, are handled, and release resources',
|
|
220
222
|
],
|
|
221
223
|
},
|
|
222
224
|
'owasp-asvs-l1': {
|
|
@@ -39,8 +39,13 @@ const { planningPath, planningRel } = require('./utils.cjs');
|
|
|
39
39
|
// budget-profile project on Codex or OpenCode was told to spawn a model called
|
|
40
40
|
// "mid" (reality check R28).
|
|
41
41
|
// - openai: Codex's subagent docs (learn.chatgpt.com/docs/agent-configuration/
|
|
42
|
-
// subagents, read 2026-
|
|
43
|
-
//
|
|
42
|
+
// subagents, re-read 2026-10-03) now start demanding agents on gpt-6.1-sol "when
|
|
43
|
+
// your signed-in account or workspace has access", and keep gpt-6-luna as the
|
|
44
|
+
// "faster, lower-cost option". The mid tier stays gpt-6-sol: GPT-6.1 Sol's launch
|
|
45
|
+
// keeps it off on Enterprise and Edu until an administrator enables it
|
|
46
|
+
// (learn.chatgpt.com/docs/models, read 2026-10-03), so pinning it would hand
|
|
47
|
+
// those users a model they cannot run. The inherit tier runs Codex's own
|
|
48
|
+
// default, which is gpt-6.1-sol where available. gpt-6-astra is the flagship.
|
|
44
49
|
// - google: the newest stable Flash and Flash-Lite on ai.google.dev's models page
|
|
45
50
|
// (read 2026-09-23). The 2.5 family these rows used is "not deprecated" but
|
|
46
51
|
// limited to users who have used it before, so a new project may not reach it.
|
|
@@ -58,12 +63,14 @@ const PROVIDER_MODELS = {
|
|
|
58
63
|
// packages/web/src/content/docs/models.mdx and models.dev/api.json, both read
|
|
59
64
|
// 2026-09-26; every id below exists there under that provider). Same tiers as
|
|
60
65
|
// PROVIDER_MODELS, provider-qualified; `default` is Anthropic as it is there.
|
|
61
|
-
// Reality check R41.
|
|
66
|
+
// Reality check R41. The Anthropic mid tier follows the `sonnet` alias, which
|
|
67
|
+
// resolves to Sonnet 5.5 on the Anthropic API from Claude Code 2.1.284;
|
|
68
|
+
// models.dev lists `anthropic/claude-sonnet-5-5` (api.json, read 2026-10-03).
|
|
62
69
|
const OPENCODE_MODELS = {
|
|
63
|
-
anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
|
|
64
|
-
openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol',
|
|
65
|
-
google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash',
|
|
66
|
-
default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5', fast: 'anthropic/claude-haiku-4-5' },
|
|
70
|
+
anthropic: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5-5', fast: 'anthropic/claude-haiku-4-5' },
|
|
71
|
+
openai: { reasoning: 'inherit', mid: 'openai/gpt-6-sol', fast: 'openai/gpt-6-luna' },
|
|
72
|
+
google: { reasoning: 'inherit', mid: 'google/gemini-3.8-flash', fast: 'google/gemini-3.5-flash-lite' },
|
|
73
|
+
default: { reasoning: 'inherit', mid: 'anthropic/claude-sonnet-5-5', fast: 'anthropic/claude-haiku-4-5' },
|
|
67
74
|
};
|
|
68
75
|
|
|
69
76
|
/**
|
|
@@ -552,6 +559,18 @@ function escapeRegex(value) {
|
|
|
552
559
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
553
560
|
}
|
|
554
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Make free text safe for one markdown table cell: escape backslashes and pipes,
|
|
564
|
+
* and fold every run of line breaks into one space. A raw `|` splits the row into
|
|
565
|
+
* an extra column, and state.md's tables are appended to for the life of a
|
|
566
|
+
* project, so one bad row breaks every later read of the table (market-ideas M21).
|
|
567
|
+
* @param {*} value - Cell text; null and undefined become ''
|
|
568
|
+
* @returns {string}
|
|
569
|
+
*/
|
|
570
|
+
function escapeTableCell(value) {
|
|
571
|
+
return String(value ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/[\r\n]+/g, ' ');
|
|
572
|
+
}
|
|
573
|
+
|
|
555
574
|
/**
|
|
556
575
|
* Normalize a phase identifier to zero-padded format.
|
|
557
576
|
* Examples: "1" → "01", "3A" → "03A", "12.1" → "12.1"
|
|
@@ -1454,6 +1473,7 @@ module.exports = {
|
|
|
1454
1473
|
isGitRepo,
|
|
1455
1474
|
execGit,
|
|
1456
1475
|
escapeRegex,
|
|
1476
|
+
escapeTableCell,
|
|
1457
1477
|
normalizePhaseName,
|
|
1458
1478
|
comparePhaseNum,
|
|
1459
1479
|
searchPhaseInDir,
|
|
@@ -91,6 +91,12 @@ const DEFAULT_RATES = {
|
|
|
91
91
|
// 2026-08-31 was made permanent and the scheduled rise to $3/$15 cancelled.
|
|
92
92
|
// Lesson: never write down a pre-announced price — this row carried the future
|
|
93
93
|
// rate for a month and over-billed by half.
|
|
94
|
+
// Sonnet 5.5 (claude-sonnet-5-5): the same prices as Sonnet 5 — $2/$10, 5-minute
|
|
95
|
+
// writes $2.50, 1-hour $4, hits $0.20 (pricing page, read 2026-10-03). Claude Code
|
|
96
|
+
// 2.1.284 points the `sonnet` alias at it on the Anthropic API (model-config, read
|
|
97
|
+
// 2026-10-03). The family prefix already priced it at Sonnet 5's identical row; its
|
|
98
|
+
// own row means a later price change to either model cannot misprice the other.
|
|
99
|
+
'claude-sonnet-5-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
|
|
94
100
|
'claude-sonnet-5': { input: 2.0, output: 10.0, cache_read: 0.20, cache_write: 2.50, cache_write_1h: 4.0 },
|
|
95
101
|
'claude-sonnet-4-6': { input: 3.0, output: 15.0, cache_read: 0.3, cache_write: 3.75, cache_write_1h: 6.0 },
|
|
96
102
|
// Sonnet 4.5 (dated id claude-sonnet-4-5-20250929): $3/$15/$0.30/$3.75 (R7).
|
|
@@ -105,10 +111,14 @@ const DEFAULT_RATES = {
|
|
|
105
111
|
// as plain input. Until 2026-09-22 this block said "no separate write charge" for
|
|
106
112
|
// every row, which had stopped being true — Terra and Luna writes were a fifth
|
|
107
113
|
// low (R27). Longest-prefix match lets each tier key win over a shorter one.
|
|
108
|
-
// GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22.
|
|
109
|
-
//
|
|
110
|
-
//
|
|
114
|
+
// GPT-6: Astra is the flagship; Sol and Luna were released 2026-09-22. GPT-6.1 Sol
|
|
115
|
+
// is Codex's default model from 0.159.1 (2026-09-29): `model = "gpt-6.1-sol"` in
|
|
116
|
+
// config-basic, and the subagent docs' starting model for demanding agents, with
|
|
117
|
+
// gpt-6-luna for lower-cost ones (learn.chatgpt.com/docs/config-file/config-basic,
|
|
118
|
+
// /agent-configuration/subagents, both read 2026-10-03). Its row was read from the
|
|
119
|
+
// pricing page on 2026-10-03: GPT-6 Sol's prices, except cached input at 0.05×.
|
|
111
120
|
'gpt-6-astra': { input: 10.0, output: 50.0, cache_read: 1.0, cache_write: 12.5 },
|
|
121
|
+
'gpt-6.1-sol': { input: 2.0, output: 10.0, cache_read: 0.1, cache_write: 2.5 },
|
|
112
122
|
'gpt-6-sol': { input: 2.0, output: 10.0, cache_read: 0.2, cache_write: 2.5 },
|
|
113
123
|
'gpt-6-luna': { input: 0.10, output: 0.50, cache_read: 0.01, cache_write: 0.125 },
|
|
114
124
|
// GPT-5.6. The bare `gpt-5.6` id is an alias that "routes requests to GPT-5.6 Sol"
|
|
@@ -160,7 +160,10 @@ function recordVerdict(cwd, input) {
|
|
|
160
160
|
|
|
161
161
|
const { rows } = readLedger(cwd);
|
|
162
162
|
const fold = foldFindings(rows);
|
|
163
|
-
|
|
163
|
+
// `not_checked` (M23): what the judge could not run. A pass with entries here is a
|
|
164
|
+
// pass over less than the whole phase, and the record says so.
|
|
165
|
+
const notChecked = verdict.not_checked || [];
|
|
166
|
+
const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, not_checked: notChecked, source_kind: parsed.source_kind, warnings: parsed.warnings };
|
|
164
167
|
|
|
165
168
|
const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
|
|
166
169
|
if (dup) {
|
|
@@ -204,6 +207,7 @@ function recordVerdict(cwd, input) {
|
|
|
204
207
|
v: LEDGER_V, kind: 'verdict', ts, verdict_id: verdictId, record_sig: recordSig, phase, milestone, agent,
|
|
205
208
|
verdict: verdict.verdict, outcome: verdict.outcome, attempt, source, finding_ids: ids,
|
|
206
209
|
by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
|
|
210
|
+
...(notChecked.length ? { not_checked: notChecked } : {}),
|
|
207
211
|
});
|
|
208
212
|
appendRows(cwd, [...findingRows, ...newRows, ...autoFixRows]);
|
|
209
213
|
|
|
@@ -216,7 +220,7 @@ function recordVerdict(cwd, input) {
|
|
|
216
220
|
phase,
|
|
217
221
|
description: `${agent} ${verdict.verdict || verdict.outcome} on phase ${phase} (attempt ${attempt}, ${ids.length} finding${ids.length === 1 ? '' : 's'})`,
|
|
218
222
|
impact: verdict.outcome === 'fail' ? (serious ? 'major' : 'minor') : 'minor',
|
|
219
|
-
context: { agent, phase, verdict: verdict.verdict, attempt, findings: ids.length, by_severity: bySeverity, by_class: byClass, verdict_id: verdictId },
|
|
223
|
+
context: { agent, phase, verdict: verdict.verdict, attempt, findings: ids.length, by_severity: bySeverity, by_class: byClass, verdict_id: verdictId, not_checked: notChecked.map((n) => n.check) },
|
|
220
224
|
});
|
|
221
225
|
if (previous && previous.outcome === 'fail') {
|
|
222
226
|
traceEvent(cwd, {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosix, output, error, scanPendingTodos, isGitRepo, execGit } = require('./core.cjs');
|
|
7
|
+
const { loadConfig, resolveModelInternal, findPhaseInternal, getRoadmapPhaseInternal, pathExistsInternal, generateSlugInternal, getMilestoneInfo, normalizePhaseName, toPosix, output, error, scanPendingTodos, isGitRepo, execGit, escapeTableCell } = require('./core.cjs');
|
|
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');
|
|
@@ -445,6 +445,10 @@ function cmdInitQuick(cwd, description, raw) {
|
|
|
445
445
|
next_num: nextNum,
|
|
446
446
|
slug: slug,
|
|
447
447
|
description: description || null,
|
|
448
|
+
// The description as one markdown table cell (pipes escaped, line breaks
|
|
449
|
+
// folded). The workflow writes this, never the raw text, into state.md's
|
|
450
|
+
// "Quick Tasks Completed" table, where a raw `|` would break the table.
|
|
451
|
+
description_cell: description ? escapeTableCell(description) : null,
|
|
448
452
|
|
|
449
453
|
// Timestamps
|
|
450
454
|
date: now.toISOString().split('T')[0],
|
|
@@ -239,7 +239,7 @@ function cmdFindPhase(cwd, phase, raw) {
|
|
|
239
239
|
* @param {boolean} raw - If true, output raw value instead of JSON
|
|
240
240
|
* @returns {void}
|
|
241
241
|
*/
|
|
242
|
-
function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
242
|
+
function cmdPhasePlanIndex(cwd, phase, raw, opts = {}) {
|
|
243
243
|
if (!phase) {
|
|
244
244
|
error('phase required for phase-plan-index');
|
|
245
245
|
}
|
|
@@ -280,7 +280,52 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
|
|
|
280
280
|
|
|
281
281
|
const { plans, waves, incomplete, hasCheckpoints } = buildPlanIndex(phaseDir, planFiles, summaryFiles);
|
|
282
282
|
|
|
283
|
-
|
|
283
|
+
const result = { phase: normalized, plans, waves, incomplete, has_checkpoints: hasCheckpoints };
|
|
284
|
+
// `--failed <ids>` (market-ideas M28): the plans that must not run after those
|
|
285
|
+
// failed, because a dependency, direct or transitive, is among them.
|
|
286
|
+
if (opts.failed) {
|
|
287
|
+
const failed = String(opts.failed).split(',').map((s) => s.trim()).filter(Boolean);
|
|
288
|
+
result.failed = failed;
|
|
289
|
+
result.blocked = blockedPlans(plans, failed);
|
|
290
|
+
}
|
|
291
|
+
output(result, raw);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Whether a `depends_on` entry names this plan: its full id ("03-02") or its plan number ("02", "2"). */
|
|
295
|
+
function dependencyNames(dep, planId) {
|
|
296
|
+
const d = String(dep).trim();
|
|
297
|
+
if (d === planId) return true;
|
|
298
|
+
const own = planId.split('-').pop();
|
|
299
|
+
const strip = (s) => s.replace(/^0+(?=\d)/, '');
|
|
300
|
+
return /^\d+$/.test(d) && /^\d+$/.test(own) && strip(d) === strip(own);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The plans that must not run because a plan they depend on failed (market-ideas
|
|
305
|
+
* M28). Transitive: a plan waiting on a blocked plan is blocked too. Plans that
|
|
306
|
+
* already have a summary are done and never blocked.
|
|
307
|
+
* @param {Array<{id: string, depends_on: string[], has_summary: boolean}>} plans
|
|
308
|
+
* @param {string[]} failedIds
|
|
309
|
+
* @returns {Array<{id: string, blocked_by: string[]}>} in plan order; blocked_by names the failed plans
|
|
310
|
+
*/
|
|
311
|
+
function blockedPlans(plans, failedIds) {
|
|
312
|
+
const failed = new Set(failedIds);
|
|
313
|
+
const blockedBy = new Map(); // plan id → Set of failed ids behind it
|
|
314
|
+
let changed = true;
|
|
315
|
+
while (changed) {
|
|
316
|
+
changed = false;
|
|
317
|
+
for (const p of plans) {
|
|
318
|
+
if (p.has_summary || failed.has(p.id)) continue;
|
|
319
|
+
const roots = blockedBy.get(p.id) || new Set();
|
|
320
|
+
const before = roots.size;
|
|
321
|
+
for (const dep of p.depends_on || []) {
|
|
322
|
+
for (const f of failed) if (dependencyNames(dep, f)) roots.add(f);
|
|
323
|
+
for (const [b, bRoots] of blockedBy) if (dependencyNames(dep, b)) for (const r of bRoots) roots.add(r);
|
|
324
|
+
}
|
|
325
|
+
if (roots.size > before) { blockedBy.set(p.id, roots); changed = true; }
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return plans.filter((p) => blockedBy.has(p.id)).map((p) => ({ id: p.id, blocked_by: [...blockedBy.get(p.id)].sort() }));
|
|
284
329
|
}
|
|
285
330
|
|
|
286
331
|
/**
|
|
@@ -339,10 +384,14 @@ function buildPlanIndex(phaseDir, planFiles, summaryFiles) {
|
|
|
339
384
|
const hasSummary = completedPlanIds.has(planId);
|
|
340
385
|
if (!hasSummary) incomplete.push(planId);
|
|
341
386
|
|
|
387
|
+
const rawDeps = frontmatter.depends_on || frontmatter['depends-on'];
|
|
388
|
+
const dependsOn = (Array.isArray(rawDeps) ? rawDeps : (rawDeps ? [rawDeps] : [])).map(String).filter((d) => d.trim());
|
|
389
|
+
|
|
342
390
|
plans.push({
|
|
343
391
|
id: planId, wave, autonomous,
|
|
344
392
|
objective,
|
|
345
393
|
files_modified: filesModified,
|
|
394
|
+
depends_on: dependsOn,
|
|
346
395
|
task_count: taskCount,
|
|
347
396
|
has_summary: hasSummary,
|
|
348
397
|
});
|
|
@@ -930,6 +979,7 @@ module.exports = {
|
|
|
930
979
|
cmdPhaseNextDecimal,
|
|
931
980
|
cmdFindPhase,
|
|
932
981
|
cmdPhasePlanIndex,
|
|
982
|
+
blockedPlans,
|
|
933
983
|
cmdPhaseAdd,
|
|
934
984
|
cmdPhaseInsert,
|
|
935
985
|
cmdPhaseRemove,
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
|
-
const { output, error, safeReadFile, toPosix } = require('./core.cjs');
|
|
22
|
+
const { output, error, safeReadFile, toPosix, escapeTableCell } = require('./core.cjs');
|
|
23
23
|
const { planningPath } = require('./utils.cjs');
|
|
24
24
|
const { publish } = require('./bus.cjs');
|
|
25
25
|
const { findVerdictBlocks, validateVerdict } = require('./verdict.cjs');
|
|
@@ -235,7 +235,7 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
|
|
|
235
235
|
const loc = f.file ? `\`${f.file}${f.line ? `:${f.line}` : ''}\`` : '—';
|
|
236
236
|
// Neutralize markdown-table-breaking chars: escape pipes and flatten any
|
|
237
237
|
// newlines so a finding description can't corrupt the table structure.
|
|
238
|
-
const desc =
|
|
238
|
+
const desc = escapeTableCell(f.description);
|
|
239
239
|
lines.push(`| ${f.severity} | ${f.source} | ${f.category} | ${desc} | ${loc} |`);
|
|
240
240
|
}
|
|
241
241
|
lines.push('');
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
const fs = require('fs');
|
|
6
6
|
const path = require('path');
|
|
7
|
-
const { loadConfig, getMilestoneInfo, escapeRegex, safeReadFile, output, error } = require('./core.cjs');
|
|
7
|
+
const { loadConfig, getMilestoneInfo, escapeRegex, escapeTableCell, safeReadFile, output, error } = require('./core.cjs');
|
|
8
8
|
const { extractFrontmatter, reconstructFrontmatter } = require('./frontmatter.cjs');
|
|
9
9
|
const { withFileLock, writeFileAtomic } = require('./lock.cjs');
|
|
10
10
|
const {
|
|
@@ -300,7 +300,8 @@ function cmdStateRecordMetric(cwd, options, raw) {
|
|
|
300
300
|
|
|
301
301
|
if (metricsMatch) {
|
|
302
302
|
let tableBody = metricsMatch[2].trimEnd();
|
|
303
|
-
const
|
|
303
|
+
const cell = escapeTableCell;
|
|
304
|
+
const newRow = `| Phase ${cell(phase)} P${cell(plan)} | ${cell(duration)} | ${cell(tasks || '-')} tasks | ${cell(files || '-')} files |`;
|
|
304
305
|
|
|
305
306
|
if (tableBody.trim() === '' || tableBody.includes('None yet')) {
|
|
306
307
|
tableBody = newRow;
|
|
@@ -40,7 +40,9 @@ const KNOWN_VERDICTS = Object.freeze({
|
|
|
40
40
|
'pan-design-checker': Object.freeze({ PASS: 'pass', GAPS: 'fail' }),
|
|
41
41
|
});
|
|
42
42
|
|
|
43
|
-
const LIMITS = Object.freeze({ findings: 100, where: 200, summary: 300, verdict: 40, score: 40 });
|
|
43
|
+
const LIMITS = Object.freeze({ findings: 100, where: 200, summary: 300, verdict: 40, score: 40, not_checked: 20, check: 80 });
|
|
44
|
+
// What the verifier's test gate can report (verify-phase.md, run_test_suite).
|
|
45
|
+
const TEST_GATE_VALUES = Object.freeze(['passed', 'failed', 'skipped']);
|
|
44
46
|
const AGENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
|
|
45
47
|
const OPEN_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*pan-verdict[ \t]*$/;
|
|
46
48
|
const CLOSE_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
|
|
@@ -195,7 +197,32 @@ function validateVerdict(obj, opts = {}) {
|
|
|
195
197
|
findings.push({ class: cls, severity, where, summary });
|
|
196
198
|
});
|
|
197
199
|
|
|
198
|
-
|
|
200
|
+
const notChecked = normaliseNotChecked(obj.not_checked, warnings);
|
|
201
|
+
return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings, not_checked: notChecked }, warnings };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The checks a judge could not run, each `{check, reason}` (market-ideas M23).
|
|
206
|
+
* A verdict that carries them says what it did not see: a `pass` with tests that
|
|
207
|
+
* never ran is recorded as exactly that, never as a plain pass. Accepts bare
|
|
208
|
+
* strings as checks with no reason; drops entries with no check.
|
|
209
|
+
*/
|
|
210
|
+
function normaliseNotChecked(value, warnings) {
|
|
211
|
+
if (value == null) return [];
|
|
212
|
+
if (!Array.isArray(value)) {
|
|
213
|
+
warnings.push('not_checked_not_an_array');
|
|
214
|
+
return [];
|
|
215
|
+
}
|
|
216
|
+
const out = [];
|
|
217
|
+
for (const entry of value.slice(0, LIMITS.not_checked)) {
|
|
218
|
+
const obj = typeof entry === 'string' ? { check: entry } : (entry && typeof entry === 'object' && !Array.isArray(entry) ? entry : null);
|
|
219
|
+
const check = obj ? truncate(cleanText(obj.check ?? obj.gate ?? ''), LIMITS.check) : '';
|
|
220
|
+
if (!check) continue;
|
|
221
|
+
const reason = obj.reason == null || cleanText(obj.reason) === '' ? null : truncate(cleanText(obj.reason), LIMITS.summary);
|
|
222
|
+
out.push({ check, reason });
|
|
223
|
+
}
|
|
224
|
+
if (value.length > LIMITS.not_checked) warnings.push(`not_checked_truncated: ${value.length} reported, ${LIMITS.not_checked} kept`);
|
|
225
|
+
return out;
|
|
199
226
|
}
|
|
200
227
|
|
|
201
228
|
// ─── verification.md frontmatter adapter ─────────────────────────────────────
|
|
@@ -354,7 +381,23 @@ function verdictFromVerificationFrontmatter(text, agent = 'pan-verifier') {
|
|
|
354
381
|
const summary = what || (path ? `unrequested change to ${path}` : '');
|
|
355
382
|
if (summary) findings.push({ class: 'unrequested', severity: 'low', where: path, summary });
|
|
356
383
|
}
|
|
357
|
-
|
|
384
|
+
// The test gate (M23). `not_checked` lists what the verifier could not run; a
|
|
385
|
+
// `test_gate: skipped` with no tests entry there still counts as one, so a model
|
|
386
|
+
// that records the gate but forgets the list cannot turn it into a plain pass.
|
|
387
|
+
let notChecked = listOfMappings(yaml, 'not_checked').map((n) => ({ check: n.check || n.gate || '', reason: n.reason || null }));
|
|
388
|
+
const testGateRaw = frontmatterScalar(yaml, 'test_gate');
|
|
389
|
+
const testGate = testGateRaw ? String(testGateRaw).toLowerCase() : null;
|
|
390
|
+
const isTests = (n) => /test/i.test(String(n.check));
|
|
391
|
+
let contradicted = false;
|
|
392
|
+
if (testGate === 'skipped' && !notChecked.some(isTests)) {
|
|
393
|
+
notChecked.push({ check: 'tests', reason: frontmatterScalar(yaml, 'test_gate_reason') || 'the test gate was skipped' });
|
|
394
|
+
} else if ((testGate === 'passed' || testGate === 'failed') && notChecked.some(isTests)) {
|
|
395
|
+
// The gate ran, so a `tests` entry is the template's example copied over: the
|
|
396
|
+
// recorded gate is the measurement, and the entry goes.
|
|
397
|
+
notChecked = notChecked.filter((n) => !isTests(n));
|
|
398
|
+
contradicted = true;
|
|
399
|
+
}
|
|
400
|
+
return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings, not_checked: notChecked, test_gate: testGate, not_checked_contradicted: contradicted };
|
|
358
401
|
}
|
|
359
402
|
|
|
360
403
|
/**
|
|
@@ -383,7 +426,14 @@ function parseVerdictText(text, opts = {}) {
|
|
|
383
426
|
return { ok: false, error: 'unknown_status', reason: `verification status "${adapted.verdict}" is not passed, gaps_found or human_needed` };
|
|
384
427
|
}
|
|
385
428
|
const r = validateVerdict(adapted, opts);
|
|
386
|
-
|
|
429
|
+
if (!r.ok) return r;
|
|
430
|
+
// Say when the verification does not record its test gate, or records a failed
|
|
431
|
+
// gate under a pass: both make "passed" claim more than the verifier checked.
|
|
432
|
+
if (!adapted.test_gate) r.warnings.push('test_gate_unrecorded: the verification does not say whether the test suite ran');
|
|
433
|
+
else if (!TEST_GATE_VALUES.includes(adapted.test_gate)) r.warnings.push(`test_gate_unknown: "${adapted.test_gate}" is not passed, failed or skipped`);
|
|
434
|
+
else if (adapted.test_gate === 'failed' && adapted.outcome === 'pass') r.warnings.push('test_gate_failed_under_pass: the test gate failed but the status is passed');
|
|
435
|
+
if (adapted.not_checked_contradicted) r.warnings.push(`not_checked_contradicted: test_gate is ${adapted.test_gate}, so the not_checked tests entry was dropped`);
|
|
436
|
+
return { ...r, source_kind: 'frontmatter' };
|
|
387
437
|
}
|
|
388
438
|
return { ok: false, error: 'no_verdict', reason: 'no pan-verdict block and no verification frontmatter status' };
|
|
389
439
|
}
|
|
@@ -13,14 +13,24 @@ const {
|
|
|
13
13
|
const { planningPath } = require('./utils.cjs');
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
*
|
|
16
|
+
* The codebase map's conventions document. map-codebase writes `conventions.md`;
|
|
17
|
+
* a map from an older PAN, or one written by hand, may be `CONVENTIONS.md`. Only a
|
|
18
|
+
* case-sensitive filesystem tells the two apart, which is why reading the uppercase
|
|
19
|
+
* name alone found nothing on Linux (prompt audit, market-ideas M34).
|
|
20
|
+
*/
|
|
21
|
+
function readConventions(cwd) {
|
|
22
|
+
const dir = path.join(planningPath(cwd), 'codebase');
|
|
23
|
+
return safeReadFile(path.join(dir, 'conventions.md')) || safeReadFile(path.join(dir, 'CONVENTIONS.md'));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Parse convention rules from the conventions document's markdown content.
|
|
17
28
|
* Extracts anti-pattern rules from prose containing "instead of", "not", "never".
|
|
18
29
|
* Run drift check internally and return result object (no output).
|
|
19
30
|
* Used by cmdValidateHealth --drift.
|
|
20
31
|
*/
|
|
21
32
|
function runDriftCheck(cwd) {
|
|
22
|
-
const
|
|
23
|
-
const conventionsContent = safeReadFile(conventionsPath);
|
|
33
|
+
const conventionsContent = readConventions(cwd);
|
|
24
34
|
const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
|
|
25
35
|
const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
|
|
26
36
|
const rules = parseConventionRules(combined || null);
|
|
@@ -198,8 +208,7 @@ function cmdDriftCheck(cwd, raw, args) {
|
|
|
198
208
|
}
|
|
199
209
|
|
|
200
210
|
// Load convention rules
|
|
201
|
-
const
|
|
202
|
-
const conventionsContent = safeReadFile(conventionsPath);
|
|
211
|
+
const conventionsContent = readConventions(cwd);
|
|
203
212
|
const claudeMdContent = safeReadFile(path.join(cwd, 'CLAUDE.md'));
|
|
204
213
|
const combined = [conventionsContent, claudeMdContent].filter(Boolean).join('\n');
|
|
205
214
|
const rules = parseConventionRules(combined || null);
|
|
@@ -18,7 +18,7 @@ const fs = require('fs');
|
|
|
18
18
|
const path = require('path');
|
|
19
19
|
const { safeReadFile, execGit, findPhaseInternal, toPosix, output, escapeRegex } = require('./core.cjs');
|
|
20
20
|
const { extractFrontmatter } = require('./frontmatter.cjs');
|
|
21
|
-
const { isPlanFile, isSummaryFile } = require('./constants.cjs');
|
|
21
|
+
const { isPlanFile, isSummaryFile, isVerificationFile } = require('./constants.cjs');
|
|
22
22
|
const { planningRel } = require('./utils.cjs');
|
|
23
23
|
|
|
24
24
|
const LOCKFILES = new Set([
|
|
@@ -153,4 +153,71 @@ function cmdVerifyScope(cwd, phase, raw) {
|
|
|
153
153
|
output(r, raw, r.error ? undefined : String(r.candidates.length));
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
|
|
156
|
+
// ─── Stale verification (market-ideas M27) ───────────────────────────────────
|
|
157
|
+
// A verification describes the code at one commit. Until 2026-10-03 nothing tied it
|
|
158
|
+
// to that commit, so a phase edited after it passed still read as verified. The
|
|
159
|
+
// verifier now records `verified_commit`; any file the phase covers that changed
|
|
160
|
+
// since then, committed or not, makes the verification stale.
|
|
161
|
+
|
|
162
|
+
const SHA_RE = /^[0-9a-f]{7,40}$/i;
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* The files a phase's verification covered: the plans' declared `files_modified` and
|
|
166
|
+
* the summaries' `key-files`, minus the planning tree, lockfiles and PAN's runtime
|
|
167
|
+
* directories. No git log scan, so `progress` can afford it for every phase.
|
|
168
|
+
*/
|
|
169
|
+
function coveredFiles(cwd, info) {
|
|
170
|
+
const dir = path.join(cwd, info.directory);
|
|
171
|
+
const files = new Set();
|
|
172
|
+
for (const f of info.plans.filter(isPlanFile)) {
|
|
173
|
+
const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
|
|
174
|
+
for (const p of asList(fm.files_modified || fm['files-modified'])) files.add(normalisePath(p));
|
|
175
|
+
}
|
|
176
|
+
for (const f of info.summaries.filter(isSummaryFile)) {
|
|
177
|
+
const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
|
|
178
|
+
const kf = fm['key-files'] || fm.key_files || {};
|
|
179
|
+
for (const p of [...asList(kf.created), ...asList(kf.modified)]) files.add(normalisePath(p));
|
|
180
|
+
}
|
|
181
|
+
const planningPrefix = normalisePath(planningRel());
|
|
182
|
+
return [...files].filter((f) => f && !exclusionReason(f, planningPrefix)).sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Whether a phase's verification still describes the code.
|
|
187
|
+
* @param {string} cwd
|
|
188
|
+
* @param {string} phase
|
|
189
|
+
* @param {Object} [info] - findPhaseInternal's result, when the caller already has it
|
|
190
|
+
* @returns {Object} `{phase, state, verified_commit, verification, changed_since, reason?}` or `{error, reason}`.
|
|
191
|
+
* state: `fresh` · `stale` (covered files changed since the verified commit) ·
|
|
192
|
+
* `unverified` (no verification file) · `unknown` (no usable verified_commit, or no git)
|
|
193
|
+
*/
|
|
194
|
+
function verificationFreshness(cwd, phase, info) {
|
|
195
|
+
info = info || findPhaseInternal(cwd, phase);
|
|
196
|
+
if (!info) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
|
|
197
|
+
const dir = path.join(cwd, info.directory);
|
|
198
|
+
let names = [];
|
|
199
|
+
try { names = fs.readdirSync(dir); } catch { /* unreadable: no verification */ }
|
|
200
|
+
const verification = names.filter(isVerificationFile).sort().pop() || null;
|
|
201
|
+
const base = { phase: info.phase_number, state: 'unverified', verified_commit: null, verification: verification ? toPosix(path.join(info.directory, verification)) : null, changed_since: [] };
|
|
202
|
+
if (!verification) return base;
|
|
203
|
+
const fm = extractFrontmatter(safeReadFile(path.join(dir, verification)) || '');
|
|
204
|
+
const sha = String(fm.verified_commit || '').trim();
|
|
205
|
+
if (!SHA_RE.test(sha)) return { ...base, state: 'unknown', reason: 'the verification records no verified_commit' };
|
|
206
|
+
const known = execGit(cwd, ['cat-file', '-e', `${sha}^{commit}`]);
|
|
207
|
+
if (known.exitCode !== 0) return { ...base, state: 'unknown', verified_commit: sha, reason: 'the verified commit is not in this repository (rebased, squashed, or no git)' };
|
|
208
|
+
const covered = coveredFiles(cwd, info);
|
|
209
|
+
if (!covered.length) return { ...base, state: 'unknown', verified_commit: sha, reason: 'the phase declares no files to compare' };
|
|
210
|
+
// Against the working tree, not HEAD: an uncommitted edit makes the pass stale too.
|
|
211
|
+
const diff = execGit(cwd, ['-c', 'core.quotepath=off', 'diff', '--name-only', '--relative', sha, '--', ...covered]);
|
|
212
|
+
if (diff.exitCode !== 0) return { ...base, state: 'unknown', verified_commit: sha, reason: 'git diff failed' };
|
|
213
|
+
const changed = diff.stdout.split('\n').map(normalisePath).filter(Boolean).sort();
|
|
214
|
+
return { ...base, state: changed.length ? 'stale' : 'fresh', verified_commit: sha, changed_since: changed };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function cmdVerifyStale(cwd, phase, raw) {
|
|
218
|
+
if (!phase) { output({ error: 'phase_required', reason: 'Usage: verify stale <phase>' }, raw); return; }
|
|
219
|
+
const r = verificationFreshness(cwd, phase);
|
|
220
|
+
output(r, raw, r.error ? undefined : r.state);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
module.exports = { scopePhase, cmdVerifyScope, verificationFreshness, cmdVerifyStale, coveredFiles, LOCKFILES };
|