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
|
@@ -16,14 +16,23 @@
|
|
|
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.
|
|
30
|
+
* - LEARNED SUPPRESSIONS (R3): a dismissed finding is noise for its file as it was
|
|
31
|
+
* when dismissed. The same judge raising it again (same signature, sameFinding)
|
|
32
|
+
* is filtered, in the dismissal's phase (candidate) or, once dismissed in two
|
|
33
|
+
* phases, in any phase (promoted). When the file changes the suppression retires
|
|
34
|
+
* and the finding comes back open. Security and logic findings, and findings
|
|
35
|
+
* with no file, are never suppressed: a re-report reopens them.
|
|
27
36
|
*/
|
|
28
37
|
const fs = require('fs');
|
|
29
38
|
const path = require('path');
|
|
@@ -37,6 +46,11 @@ const LEDGER_V = 1;
|
|
|
37
46
|
const REASON_MAX = 300;
|
|
38
47
|
const FINDING_ID_RE = /^f_[0-9a-f]{10}$/;
|
|
39
48
|
const STATUSES = Object.freeze(['open', ...V.DISPOSITIONS]);
|
|
49
|
+
// Logic findings are never suppressed (R3): what the code does wrong, or not at all.
|
|
50
|
+
const LOGIC_CLASSES = Object.freeze(['defect', 'contradicts', 'missing', 'partial']);
|
|
51
|
+
// Security findings are never suppressed either, whatever class a judge gave them.
|
|
52
|
+
const SECURITY_RE = /secur|inject|xss|csrf|ssrf|secret|credential|password|authenticat|authoriz|\bauth\b|token|crypt|traversal|privilege|sanitiz|escap|cve-|owasp|\brce\b|deserializ/i;
|
|
53
|
+
const SECURITY_AGENTS = Object.freeze(['pan-hardener']);
|
|
40
54
|
|
|
41
55
|
function ledgerPath(cwd) {
|
|
42
56
|
return planningPath(cwd, FINDINGS_FILE);
|
|
@@ -77,7 +91,7 @@ function foldFindings(rows) {
|
|
|
77
91
|
id: r.id, phase: r.phase, milestone: r.milestone || null, agent: r.agent, class: r.class,
|
|
78
92
|
severity: r.severity, where: r.where || null, summary: r.summary, first_seen: r.ts,
|
|
79
93
|
first_verdict_id: r.verdict_id || null, reports: 0, last_reported: null, _reportedSeq: -1,
|
|
80
|
-
disposition: null, reason: null, disposed_at: null, auto: false, _disposedSeq: -1,
|
|
94
|
+
disposition: null, reason: null, disposed_at: null, auto: false, file_sha: null, _disposedSeq: -1, _returnedSeq: -1,
|
|
81
95
|
});
|
|
82
96
|
} else if (r.kind === 'verdict') {
|
|
83
97
|
verdicts.push({ ...r, _seq: seq });
|
|
@@ -88,6 +102,11 @@ function foldFindings(rows) {
|
|
|
88
102
|
f.last_reported = r.ts;
|
|
89
103
|
f._reportedSeq = seq;
|
|
90
104
|
}
|
|
105
|
+
// A dismissed finding raised again once its suppression no longer holds (R3).
|
|
106
|
+
for (const id of Array.isArray(r.returned_ids) ? r.returned_ids : []) {
|
|
107
|
+
const f = findings.get(id);
|
|
108
|
+
if (f) f._returnedSeq = seq;
|
|
109
|
+
}
|
|
91
110
|
} else if (r.kind === 'disposition' && typeof r.id === 'string') {
|
|
92
111
|
const f = findings.get(r.id);
|
|
93
112
|
if (!f || !V.DISPOSITIONS.includes(r.disposition)) return;
|
|
@@ -95,12 +114,14 @@ function foldFindings(rows) {
|
|
|
95
114
|
f.reason = r.reason || null;
|
|
96
115
|
f.disposed_at = r.ts;
|
|
97
116
|
f.auto = r.auto === true;
|
|
117
|
+
f.file_sha = r.file_sha || null;
|
|
98
118
|
f._disposedSeq = seq;
|
|
99
119
|
}
|
|
100
120
|
});
|
|
101
121
|
for (const f of findings.values()) {
|
|
102
122
|
if (!f.disposition) f.status = 'open';
|
|
103
123
|
else if (f.disposition === 'fixed' && f._reportedSeq > f._disposedSeq) f.status = 'open';
|
|
124
|
+
else if (f._returnedSeq > f._disposedSeq) f.status = 'open';
|
|
104
125
|
else f.status = f.disposition;
|
|
105
126
|
}
|
|
106
127
|
return { verdicts, findings };
|
|
@@ -108,7 +129,7 @@ function foldFindings(rows) {
|
|
|
108
129
|
|
|
109
130
|
/** A finding as the CLI shows it (internal sequence fields removed). */
|
|
110
131
|
function publicFinding(f) {
|
|
111
|
-
const { _reportedSeq, _disposedSeq, ...rest } = f;
|
|
132
|
+
const { _reportedSeq, _disposedSeq, _returnedSeq, ...rest } = f;
|
|
112
133
|
return rest;
|
|
113
134
|
}
|
|
114
135
|
|
|
@@ -122,6 +143,115 @@ function samePhase(a, b) {
|
|
|
122
143
|
return V.comparablePhase(a) === V.comparablePhase(b);
|
|
123
144
|
}
|
|
124
145
|
|
|
146
|
+
// ─── Finding identity across reports (V6) ───────────────────────────────────
|
|
147
|
+
|
|
148
|
+
/** The file a `where` names, without a line or column suffix. */
|
|
149
|
+
function fileOf(where) {
|
|
150
|
+
return where ? String(where).replace(/\\/g, '/').replace(/(:\d+){1,2}$/, '').replace(/#L?\d+.*$/, '') : '';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function tokens(s) {
|
|
154
|
+
return new Set(String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter((w) => w.length > 2));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Token overlap (Jaccard) of two summaries at or above one half. */
|
|
158
|
+
function similar(a, b) {
|
|
159
|
+
const A = tokens(a);
|
|
160
|
+
const B = tokens(b);
|
|
161
|
+
if (!A.size || !B.size) return false;
|
|
162
|
+
let both = 0;
|
|
163
|
+
for (const t of A) if (B.has(t)) both++;
|
|
164
|
+
return both / (A.size + B.size - both) >= 0.5;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The part of a summary before " — " (a verifier gap's truth), lower-cased. */
|
|
168
|
+
const headOf = (s) => String(s || '').split(' — ')[0].trim().toLowerCase();
|
|
169
|
+
/** A verifier gap moves between missing and partial as it is worked on: one family. */
|
|
170
|
+
const familyOf = (c) => (c === 'partial' ? 'missing' : c);
|
|
171
|
+
|
|
172
|
+
/** Same class family, same file, and the same truth or a similar summary. */
|
|
173
|
+
function sameFinding(e, f) {
|
|
174
|
+
return familyOf(e.class) === familyOf(f.class) && fileOf(e.where) === fileOf(f.where)
|
|
175
|
+
&& (headOf(e.summary) === headOf(f.summary) || similar(e.summary, f.summary));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The earlier finding a judge's resolution answers: by id, else by its truth or summary. */
|
|
179
|
+
function resolutionTarget(mine, r) {
|
|
180
|
+
if (r.id) return mine.find((f) => f.id === r.id) || null;
|
|
181
|
+
const head = headOf(r.summary);
|
|
182
|
+
return mine.find((f) => headOf(f.summary) === head) || mine.find((f) => similar(f.summary, r.summary)) || null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function truncateReason(s) {
|
|
186
|
+
return String(s).replace(/\s+/g, ' ').trim().slice(0, REASON_MAX);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ─── Learned suppressions (R3) ──────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/** Security and logic findings are always reported, dismissed or not. */
|
|
192
|
+
function neverSuppressed(f, agent) {
|
|
193
|
+
return LOGIC_CLASSES.includes(f.class) || SECURITY_AGENTS.includes(agent) || SECURITY_RE.test(String(f.summary || ''));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The content hash of the project file a `where` names, or null (no file, outside the project, unreadable). */
|
|
197
|
+
function fileSha(cwd, where) {
|
|
198
|
+
const file = fileOf(where);
|
|
199
|
+
if (!file || file.startsWith('code-fact:')) return null;
|
|
200
|
+
const abs = path.resolve(cwd, file);
|
|
201
|
+
const rel = path.relative(path.resolve(cwd), abs);
|
|
202
|
+
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
203
|
+
try { return crypto.createHash('sha256').update(fs.readFileSync(abs)).digest('hex'); } catch { return null; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Every learned suppression: a dismissed finding scoped to a file, of a kind that may
|
|
208
|
+
* be suppressed. `active` while the file is as it was when dismissed; `promoted` once
|
|
209
|
+
* the same judge dismissed the same signature in two phases (active ones only).
|
|
210
|
+
*/
|
|
211
|
+
function learnedSuppressions(cwd, fold) {
|
|
212
|
+
const shas = new Map();
|
|
213
|
+
const current = (where) => {
|
|
214
|
+
const k = fileOf(where);
|
|
215
|
+
if (!shas.has(k)) shas.set(k, fileSha(cwd, where));
|
|
216
|
+
return shas.get(k);
|
|
217
|
+
};
|
|
218
|
+
const all = [...fold.findings.values()]
|
|
219
|
+
.filter((f) => f.status === 'dismissed' && f.file_sha && !neverSuppressed(f, f.agent))
|
|
220
|
+
.map((f) => ({ f, active: current(f.where) === f.file_sha }));
|
|
221
|
+
return all.map((s) => {
|
|
222
|
+
const phases = new Set(all.filter((o) => o.active && o.f.agent === s.f.agent && sameFinding(o.f, s.f)).map((o) => V.comparablePhase(o.f.phase)));
|
|
223
|
+
return { ...s, scope: phases.size >= 2 ? 'promoted' : 'candidate' };
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The suppression that filters this judge's finding in this phase, or null. */
|
|
228
|
+
function suppressionFor(supps, agent, phase, f) {
|
|
229
|
+
if (neverSuppressed(f, agent)) return null;
|
|
230
|
+
const hits = supps.filter((s) => s.active && s.f.agent === agent && sameFinding(s.f, f)
|
|
231
|
+
&& (s.scope === 'promoted' || samePhase(s.f.phase, phase)));
|
|
232
|
+
return hits.find((s) => samePhase(s.f.phase, phase)) || hits[0] || null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* How stable a judge's findings are across its reports on one phase (V6): between
|
|
237
|
+
* consecutive verdicts, how many ids appeared and how many disappeared. A judge whose
|
|
238
|
+
* findings churn while the code does not change is not measuring the code.
|
|
239
|
+
*/
|
|
240
|
+
function findingsChurn(rows, phase, agent) {
|
|
241
|
+
const seq = foldFindings(rows).verdicts.filter((v) => v.agent === agent && samePhase(v.phase, phase));
|
|
242
|
+
let added = 0;
|
|
243
|
+
let dropped = 0;
|
|
244
|
+
let union = 0;
|
|
245
|
+
for (let i = 1; i < seq.length; i++) {
|
|
246
|
+
const a = new Set(seq[i - 1].finding_ids || []);
|
|
247
|
+
const b = new Set(seq[i].finding_ids || []);
|
|
248
|
+
for (const id of b) if (!a.has(id)) added++;
|
|
249
|
+
for (const id of a) if (!b.has(id)) dropped++;
|
|
250
|
+
union += new Set([...a, ...b]).size;
|
|
251
|
+
}
|
|
252
|
+
return { reports: seq.length, transitions: Math.max(0, seq.length - 1), added, dropped, rate: union ? Math.round(((added + dropped) / union) * 1000) / 1000 : 0 };
|
|
253
|
+
}
|
|
254
|
+
|
|
125
255
|
/** `.planning/` must exist: the ledger fills a planning tree, it never creates one. */
|
|
126
256
|
function requirePlanningTree(cwd) {
|
|
127
257
|
try { return fs.statSync(planningPath(cwd)).isDirectory(); } catch { return false; }
|
|
@@ -152,8 +282,14 @@ function recordVerdict(cwd, input) {
|
|
|
152
282
|
const text = typeof input.text === 'string' ? input.text : '';
|
|
153
283
|
if (!text.trim()) return { error: 'empty_input', reason: 'nothing to record' };
|
|
154
284
|
|
|
155
|
-
|
|
285
|
+
let parsed = V.parseVerdictText(text, { agent: input.agent, phase });
|
|
156
286
|
if (!parsed.ok) return { error: parsed.error, reason: parsed.reason };
|
|
287
|
+
// A verification is read against the engine's test run (V2): a failed run is a code
|
|
288
|
+
// fact that lowers the verifier's pass, as a failed test_gate already does (V9).
|
|
289
|
+
if (parsed.verdict.agent === 'pan-verifier') {
|
|
290
|
+
const facts = require('./test-gate.cjs').testRunFacts(cwd, phase);
|
|
291
|
+
if (facts.length) parsed = V.parseVerdictText(text, { agent: input.agent, phase, facts });
|
|
292
|
+
}
|
|
157
293
|
const verdict = parsed.verdict;
|
|
158
294
|
const agent = verdict.agent;
|
|
159
295
|
const recordSig = V.recordSig(text);
|
|
@@ -163,11 +299,15 @@ function recordVerdict(cwd, input) {
|
|
|
163
299
|
// `not_checked` (M23): what the judge could not run. A pass with entries here is a
|
|
164
300
|
// pass over less than the whole phase, and the record says so.
|
|
165
301
|
const notChecked = verdict.not_checked || [];
|
|
166
|
-
|
|
302
|
+
// Two-key verdict (V9): when a code fact lowered the judge's word, the record keeps
|
|
303
|
+
// both, and `verdict` is the lowered word the workflow branches on.
|
|
304
|
+
const lowered = Array.isArray(parsed.lowered_by) && parsed.lowered_by.length
|
|
305
|
+
? { agent_verdict: verdict.agent_verdict, lowered_by: parsed.lowered_by } : {};
|
|
306
|
+
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
307
|
|
|
168
308
|
const dup = fold.verdicts.find((r) => r.record_sig === recordSig && r.agent === agent && samePhase(r.phase, phase));
|
|
169
309
|
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,
|
|
310
|
+
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, suppressed: dup.suppressed || [], returned: (dup.returned_ids || []).length };
|
|
171
311
|
}
|
|
172
312
|
|
|
173
313
|
const prior = fold.verdicts.filter((r) => r.agent === agent && samePhase(r.phase, phase));
|
|
@@ -180,21 +320,66 @@ function recordVerdict(cwd, input) {
|
|
|
180
320
|
const ids = [];
|
|
181
321
|
const newRows = [];
|
|
182
322
|
const findingRows = [];
|
|
323
|
+
const mine = [...fold.findings.values()].filter((e) => e.agent === agent && samePhase(e.phase, phase));
|
|
324
|
+
const supps = learnedSuppressions(cwd, fold);
|
|
325
|
+
const suppressed = [];
|
|
326
|
+
const returned = [];
|
|
327
|
+
let rematched = 0;
|
|
183
328
|
for (const f of verdict.findings) {
|
|
184
|
-
|
|
329
|
+
let id = V.findingId(phase, agent, f);
|
|
330
|
+
if (!fold.findings.has(id)) {
|
|
331
|
+
// A reworded report of an earlier finding keeps that finding's id (V6), so a
|
|
332
|
+
// rewording is a re-report, not a fix plus a new finding.
|
|
333
|
+
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];
|
|
334
|
+
if (earlier) { id = earlier.id; rematched++; }
|
|
335
|
+
}
|
|
185
336
|
if (ids.includes(id)) continue; // the same finding twice in one report is one finding
|
|
337
|
+
// A learned suppression (R3) filters it; a dismissed finding no suppression
|
|
338
|
+
// covers any more comes back open.
|
|
339
|
+
const existing = fold.findings.get(id);
|
|
340
|
+
if (!existing || existing.status === 'dismissed') {
|
|
341
|
+
const s = suppressionFor(supps, agent, phase, f);
|
|
342
|
+
if (s) {
|
|
343
|
+
suppressed.push({ id: s.f.id, scope: s.scope, summary: f.summary });
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
if (existing) returned.push(id);
|
|
347
|
+
}
|
|
186
348
|
ids.push(id);
|
|
187
349
|
if (!fold.findings.has(id)) {
|
|
188
350
|
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
351
|
}
|
|
190
352
|
}
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
353
|
+
|
|
354
|
+
// Findings close only on evidence (V6). Silence is not evidence: an earlier open
|
|
355
|
+
// finding the new verdict neither reports nor answers stays open, and the record
|
|
356
|
+
// lists it as unaddressed. What closes one: the judge's `resolved` answer with its
|
|
357
|
+
// evidence, or, for a code-fact finding, the fact passing.
|
|
358
|
+
const resolveRows = [];
|
|
359
|
+
const closing = new Set();
|
|
360
|
+
const close = (f, reason) => {
|
|
361
|
+
if (closing.has(f.id) || ids.includes(f.id) || f.status !== 'open') return;
|
|
362
|
+
closing.add(f.id);
|
|
363
|
+
resolveRows.push({ v: LEDGER_V, kind: 'disposition', ts, id: f.id, disposition: 'fixed', reason: truncateReason(reason), auto: true, verdict_id: verdictId });
|
|
364
|
+
};
|
|
365
|
+
let contradicted = 0;
|
|
366
|
+
for (const r of verdict.resolutions || []) {
|
|
367
|
+
const target = resolutionTarget(mine, r);
|
|
368
|
+
if (!target) { parsed.warnings.push(`resolution_unmatched: ${r.id || r.summary}`); continue; }
|
|
369
|
+
if (r.status === 'resolved') {
|
|
370
|
+
if (ids.includes(target.id)) { contradicted++; continue; }
|
|
371
|
+
close(target, `resolved by ${agent} at attempt ${attempt}: ${r.evidence}`);
|
|
372
|
+
} else if (r.status === 'still_present' && !ids.includes(target.id)) {
|
|
373
|
+
ids.push(target.id); // a re-report: reopens a fixed finding, keeps an open one open
|
|
374
|
+
}
|
|
197
375
|
}
|
|
376
|
+
if (contradicted) parsed.warnings.push(`resolution_contradicted: ${contradicted} finding(s) answered resolved but reported again; they stay open`);
|
|
377
|
+
for (const fact of parsed.facts_ok || []) {
|
|
378
|
+
for (const f of mine) if (f.where === `code-fact:${fact}`) close(f, `${fact} passed at attempt ${attempt}`);
|
|
379
|
+
}
|
|
380
|
+
const reopened = ids.filter((id) => fold.findings.has(id) && fold.findings.get(id).status === 'fixed').length;
|
|
381
|
+
const unaddressed = mine.filter((f) => f.status === 'open' && !ids.includes(f.id) && !closing.has(f.id)).map((f) => f.id);
|
|
382
|
+
if (unaddressed.length) parsed.warnings.push(`open_findings_unaddressed: ${unaddressed.length} earlier finding(s) neither reported nor resolved; they stay open`);
|
|
198
383
|
|
|
199
384
|
const bySeverity = countBy(verdict.findings, 'severity');
|
|
200
385
|
const byClass = countBy(verdict.findings, 'class');
|
|
@@ -205,11 +390,16 @@ function recordVerdict(cwd, input) {
|
|
|
205
390
|
}
|
|
206
391
|
newRows.push({
|
|
207
392
|
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,
|
|
393
|
+
verdict: verdict.verdict, outcome: verdict.outcome, ...lowered, attempt, source, finding_ids: ids,
|
|
394
|
+
...(returned.length ? { returned_ids: returned } : {}),
|
|
395
|
+
...(suppressed.length ? { suppressed } : {}),
|
|
209
396
|
by_severity: bySeverity, by_class: byClass, warnings: parsed.warnings,
|
|
210
397
|
...(notChecked.length ? { not_checked: notChecked } : {}),
|
|
398
|
+
// A plan check is bound to the plans it checked (V7): exec-phase compares this
|
|
399
|
+
// hash with the plans it is about to run.
|
|
400
|
+
...(agent === 'pan-plan-checker' ? { plans_sha: require('./verify-plan-set.cjs').plansShaFor(cwd, phase) } : {}),
|
|
211
401
|
});
|
|
212
|
-
appendRows(cwd, [...findingRows, ...newRows, ...
|
|
402
|
+
appendRows(cwd, [...findingRows, ...newRows, ...resolveRows]);
|
|
213
403
|
|
|
214
404
|
const category = { pass: 'verdict_passed', fail: 'verdict_failed', needs_human: 'verdict_needs_human' }[verdict.outcome];
|
|
215
405
|
const serious = (bySeverity.critical || 0) + (bySeverity.high || 0) > 0;
|
|
@@ -228,13 +418,13 @@ function recordVerdict(cwd, input) {
|
|
|
228
418
|
category: 'verdict_retry',
|
|
229
419
|
agent,
|
|
230
420
|
phase,
|
|
231
|
-
description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${
|
|
421
|
+
description: `${agent} judged phase ${phase} again (attempt ${attempt}): ${previous.outcome} → ${verdict.outcome}, ${resolveRows.length} earlier finding(s) resolved, ${unaddressed.length} unaddressed`,
|
|
232
422
|
impact: 'minor',
|
|
233
|
-
context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome,
|
|
423
|
+
context: { agent, phase, attempt, previous_outcome: previous.outcome, outcome: verdict.outcome, resolved: resolveRows.length, unaddressed: unaddressed.length, reopened, verdict_id: verdictId },
|
|
234
424
|
});
|
|
235
425
|
}
|
|
236
426
|
|
|
237
|
-
return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length,
|
|
427
|
+
return { ...base, recorded: true, duplicate: false, verdict_id: verdictId, attempt, phase, findings: ids.length, new: findingRows.length, rematched, resolved: resolveRows.length, unaddressed, reopened, suppressed, returned: returned.length, ledger: planningRel(FINDINGS_FILE) };
|
|
238
428
|
}
|
|
239
429
|
|
|
240
430
|
/**
|
|
@@ -269,7 +459,10 @@ function disposeFindings(cwd, input) {
|
|
|
269
459
|
const f = fold.findings.get(id);
|
|
270
460
|
if (!f) { skipped.push({ id, why: 'unknown id' }); continue; }
|
|
271
461
|
if (f.status === as) { skipped.push({ id, why: `already ${as}` }); continue; }
|
|
272
|
-
|
|
462
|
+
// A dismissal remembers its file as it was (R3): it suppresses the finding only
|
|
463
|
+
// until that file changes.
|
|
464
|
+
const fsha = as === 'dismissed' ? fileSha(cwd, f.where) : null;
|
|
465
|
+
out.push({ v: LEDGER_V, kind: 'disposition', ts, id, disposition: as, reason: reason || null, auto: false, ...(fsha ? { file_sha: fsha } : {}) });
|
|
273
466
|
disposed.push(id);
|
|
274
467
|
}
|
|
275
468
|
appendRows(cwd, out);
|
|
@@ -299,10 +492,24 @@ function listFindings(cwd, filters = {}) {
|
|
|
299
492
|
findings,
|
|
300
493
|
counts: { total: findings.length, by_status: countBy(findings, 'status'), by_class: countBy(findings, 'class'), by_severity: countBy(findings, 'severity') },
|
|
301
494
|
verdicts: fold.verdicts.length,
|
|
495
|
+
...(filters.phase && filters.agent ? { churn: findingsChurn(rows, filters.phase, filters.agent) } : {}),
|
|
302
496
|
malformed_rows: malformed,
|
|
303
497
|
};
|
|
304
498
|
}
|
|
305
499
|
|
|
500
|
+
/** Learned suppressions (R3), each with its scope and whether its file is unchanged. */
|
|
501
|
+
function listSuppressions(cwd, filters = {}) {
|
|
502
|
+
const fold = foldFindings(readLedger(cwd).rows);
|
|
503
|
+
let list = learnedSuppressions(cwd, fold);
|
|
504
|
+
if (filters.agent) list = list.filter((s) => s.f.agent === filters.agent);
|
|
505
|
+
if (filters.phase) list = list.filter((s) => s.scope === 'promoted' || samePhase(s.f.phase, filters.phase));
|
|
506
|
+
const suppressions = list.map((s) => ({
|
|
507
|
+
id: s.f.id, agent: s.f.agent, phase: s.f.phase, class: s.f.class, file: fileOf(s.f.where), summary: s.f.summary,
|
|
508
|
+
reason: s.f.reason, scope: s.scope, active: s.active,
|
|
509
|
+
}));
|
|
510
|
+
return { contract: V.VERDICT_CONTRACT, suppressions, active: suppressions.filter((s) => s.active).length };
|
|
511
|
+
}
|
|
512
|
+
|
|
306
513
|
/**
|
|
307
514
|
* Tech debt for a milestone audit: deferred findings (with the reason recorded at
|
|
308
515
|
* the time) and findings nobody disposed, grouped by phase.
|
|
@@ -365,6 +572,11 @@ function cmdFindingsList(cwd, opts, raw) {
|
|
|
365
572
|
output(r, raw, String(r.counts.total));
|
|
366
573
|
}
|
|
367
574
|
|
|
575
|
+
function cmdFindingsSuppressions(cwd, opts, raw) {
|
|
576
|
+
const r = listSuppressions(cwd, opts);
|
|
577
|
+
output(r, raw, String(r.active));
|
|
578
|
+
}
|
|
579
|
+
|
|
368
580
|
function cmdFindingsDebt(cwd, opts, raw) {
|
|
369
581
|
const r = findingsDebt(cwd, opts);
|
|
370
582
|
output(r, raw, `${r.deferred_count} deferred, ${r.open_count} open`);
|
|
@@ -383,10 +595,14 @@ module.exports = {
|
|
|
383
595
|
recordVerdict,
|
|
384
596
|
disposeFindings,
|
|
385
597
|
listFindings,
|
|
598
|
+
listSuppressions,
|
|
386
599
|
findingsDebt,
|
|
600
|
+
findingsChurn,
|
|
601
|
+
sameFinding,
|
|
387
602
|
findingIdsIn,
|
|
388
603
|
cmdFindingsRecord,
|
|
389
604
|
cmdFindingsDispose,
|
|
390
605
|
cmdFindingsList,
|
|
391
606
|
cmdFindingsDebt,
|
|
607
|
+
cmdFindingsSuppressions,
|
|
392
608
|
};
|
|
@@ -175,7 +175,7 @@ function cmdGitPush(cwd, opts, raw) {
|
|
|
175
175
|
function cmdGitStatus(cwd, opts, raw) {
|
|
176
176
|
if (!isGitRepo(cwd)) { _notGitRepo(raw); return; }
|
|
177
177
|
const branch = getCurrentBranch(cwd);
|
|
178
|
-
const r = execGit(cwd, ['status', '--porcelain']);
|
|
178
|
+
const r = execGit(cwd, ['status', '--porcelain'], { keepLeading: true });
|
|
179
179
|
if (r.exitCode !== 0) {
|
|
180
180
|
output({ error: 'status_failed', detail: r.stderr }, raw, 'status failed');
|
|
181
181
|
return;
|
|
@@ -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);
|