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.
Files changed (110) hide show
  1. package/README.md +16 -10
  2. package/agents/pan-conductor.md +2 -2
  3. package/agents/pan-debugger.md +3 -1
  4. package/agents/pan-design-checker.md +1 -0
  5. package/agents/pan-designer.md +1 -1
  6. package/agents/pan-executor.md +43 -1
  7. package/agents/pan-experiment-runner.md +1 -1
  8. package/agents/pan-hardener.md +3 -1
  9. package/agents/pan-meta-reviewer.md +1 -1
  10. package/agents/pan-phase-researcher.md +16 -2
  11. package/agents/pan-plan-checker.md +37 -9
  12. package/agents/pan-planner.md +14 -2
  13. package/agents/pan-previewer.md +2 -0
  14. package/agents/pan-project-researcher.md +9 -1
  15. package/agents/pan-research-synthesizer.md +1 -1
  16. package/agents/pan-reviewer.md +8 -2
  17. package/agents/pan-verifier.md +3 -3
  18. package/bin/install-lib.cjs +119 -16
  19. package/bin/install.js +34 -62
  20. package/commands/pan/cost.md +21 -5
  21. package/commands/pan/dashboard.md +0 -3
  22. package/commands/pan/design-phase.md +2 -2
  23. package/commands/pan/hud.md +1 -5
  24. package/commands/pan/knowledge.md +0 -1
  25. package/commands/pan/mcp-bridge.md +1 -3
  26. package/commands/pan/optimize.md +2 -0
  27. package/commands/pan/phase-tests.md +1 -1
  28. package/commands/pan/preview.md +2 -3
  29. package/commands/pan/report.md +1 -5
  30. package/commands/pan/research-phase.md +4 -4
  31. package/commands/pan/review-deep.md +0 -2
  32. package/commands/pan/what-if.md +1 -2
  33. package/hooks/dist/pan-context-monitor.js +12 -3
  34. package/hooks/dist/pan-guard.js +244 -0
  35. package/hooks/dist/pan-statusline.js +5 -2
  36. package/package.json +1 -1
  37. package/pan-wizard-core/bin/lib/commands.cjs +24 -2
  38. package/pan-wizard-core/bin/lib/config.cjs +13 -1
  39. package/pan-wizard-core/bin/lib/core.cjs +9 -4
  40. package/pan-wizard-core/bin/lib/cost.cjs +146 -0
  41. package/pan-wizard-core/bin/lib/doc-lint.cjs +5 -3
  42. package/pan-wizard-core/bin/lib/findings.cjs +239 -23
  43. package/pan-wizard-core/bin/lib/git.cjs +1 -1
  44. package/pan-wizard-core/bin/lib/hygiene.cjs +11 -5
  45. package/pan-wizard-core/bin/lib/init.cjs +6 -0
  46. package/pan-wizard-core/bin/lib/mutate.cjs +257 -0
  47. package/pan-wizard-core/bin/lib/optimize.cjs +13 -1
  48. package/pan-wizard-core/bin/lib/phase-gate.cjs +180 -0
  49. package/pan-wizard-core/bin/lib/phase-remove.cjs +91 -11
  50. package/pan-wizard-core/bin/lib/phase.cjs +61 -9
  51. package/pan-wizard-core/bin/lib/plan-lint.cjs +161 -0
  52. package/pan-wizard-core/bin/lib/preview.cjs +35 -31
  53. package/pan-wizard-core/bin/lib/research-lint.cjs +121 -0
  54. package/pan-wizard-core/bin/lib/roadmap.cjs +1 -0
  55. package/pan-wizard-core/bin/lib/test-gate.cjs +433 -0
  56. package/pan-wizard-core/bin/lib/usage.cjs +104 -0
  57. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  58. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  59. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  60. package/pan-wizard-core/bin/lib/verify-red.cjs +138 -0
  61. package/pan-wizard-core/bin/lib/verify-scope.cjs +148 -6
  62. package/pan-wizard-core/bin/lib/verify.cjs +27 -27
  63. package/pan-wizard-core/bin/lib/whatif.cjs +48 -6
  64. package/pan-wizard-core/bin/pan-tools.cjs +106 -9
  65. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  66. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  67. package/pan-wizard-core/mcp/orchestrator.cjs +4 -1
  68. package/pan-wizard-core/opencode/pan-wizard.js +36 -8
  69. package/pan-wizard-core/references/design-methodology.md +1 -1
  70. package/pan-wizard-core/references/handoff-decisions.md +1 -1
  71. package/pan-wizard-core/references/model-profiles.md +1 -1
  72. package/pan-wizard-core/references/planning-config.md +3 -3
  73. package/pan-wizard-core/references/verdict-contract.md +6 -1
  74. package/pan-wizard-core/templates/config.json +6 -6
  75. package/pan-wizard-core/templates/debug-subagent-prompt.md +2 -1
  76. package/pan-wizard-core/templates/design.md +5 -4
  77. package/pan-wizard-core/templates/phase-prompt.md +9 -0
  78. package/pan-wizard-core/templates/planner-subagent-prompt.md +2 -0
  79. package/pan-wizard-core/templates/preview-report.md +1 -1
  80. package/pan-wizard-core/templates/research-project/architecture.md +6 -0
  81. package/pan-wizard-core/templates/research-project/features.md +6 -0
  82. package/pan-wizard-core/templates/research-project/pitfalls.md +6 -0
  83. package/pan-wizard-core/templates/research-project/stack.md +6 -0
  84. package/pan-wizard-core/templates/research.md +10 -0
  85. package/pan-wizard-core/templates/state.md +4 -4
  86. package/pan-wizard-core/templates/summary-complex.md +5 -1
  87. package/pan-wizard-core/templates/summary-minimal.md +4 -0
  88. package/pan-wizard-core/templates/summary-standard.md +5 -1
  89. package/pan-wizard-core/templates/summary.md +8 -1
  90. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  91. package/pan-wizard-core/workflows/discuss-phase.md +2 -1
  92. package/pan-wizard-core/workflows/exec-phase.md +92 -45
  93. package/pan-wizard-core/workflows/execute-plan.md +3 -1
  94. package/pan-wizard-core/workflows/help.md +103 -44
  95. package/pan-wizard-core/workflows/milestone-gaps.md +2 -2
  96. package/pan-wizard-core/workflows/new-project.md +8 -16
  97. package/pan-wizard-core/workflows/plan-phase.md +40 -8
  98. package/pan-wizard-core/workflows/progress.md +1 -1
  99. package/pan-wizard-core/workflows/quick.md +2 -2
  100. package/pan-wizard-core/workflows/remove-phase.md +11 -1
  101. package/pan-wizard-core/workflows/settings.md +17 -0
  102. package/pan-wizard-core/workflows/todo-check.md +3 -2
  103. package/pan-wizard-core/workflows/transition.md +4 -0
  104. package/pan-wizard-core/workflows/verify-phase.md +40 -47
  105. package/scripts/build-help.cjs +106 -0
  106. package/scripts/build-hooks.js +1 -0
  107. package/scripts/generate-skills-docs.py +2 -2
  108. package/scripts/mutation-probe.cjs +7 -80
  109. package/scripts/prompt-lint.cjs +167 -0
  110. 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
- * Two rules are applied when a verdict is recorded:
20
- * - AUTO-FIX: the same agent's earlier open findings on the phase that the new
21
- * verdict does not report become `fixed`. That is how a gap-closure round
22
- * closes its gaps. `human` and `unrequested` are exempt, because a re-run does
23
- * not re-check them.
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
- const parsed = V.parseVerdictText(text, { agent: input.agent, phase });
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
- const base = { contract: V.VERDICT_CONTRACT, agent, verdict: verdict.verdict, outcome: verdict.outcome, not_checked: notChecked, source_kind: parsed.source_kind, warnings: parsed.warnings };
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, auto_fixed: 0, reopened: 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
- const id = V.findingId(phase, agent, f);
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
- const reopened = ids.filter((id) => fold.findings.has(id) && fold.findings.get(id).status === 'fixed').length;
192
- const autoFixRows = [];
193
- for (const f of fold.findings.values()) {
194
- if (f.agent !== agent || !samePhase(f.phase, phase) || f.status !== 'open') continue;
195
- if (ids.includes(f.id) || V.AUTO_FIX_EXEMPT.includes(f.class)) continue;
196
- autoFixRows.push({ v: LEDGER_V, kind: 'disposition', ts, id: f.id, disposition: 'fixed', reason: `not reported by ${agent} at attempt ${attempt}`, auto: true, verdict_id: verdictId });
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, ...autoFixRows]);
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}, ${autoFixRows.length} earlier finding(s) no longer reported`,
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, auto_fixed: autoFixRows.length, reopened, verdict_id: verdictId },
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, auto_fixed: autoFixRows.length, reopened, ledger: planningRel(FINDINGS_FILE) };
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
- out.push({ v: LEDGER_V, kind: 'disposition', ts, id, disposition: as, reason: reason || null, auto: false });
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
- /** Claude Code's auto-memory directory for a project, and how it was found. */
304
- function hostMemoryDir(cwd, homeDir) {
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
- for (const f of [path.join(cwd, claudeDir, 'settings.local.json'), path.join(cwd, claudeDir, 'settings.json'), path.join(homeDir, claudeDir, 'settings.json')]) {
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 loc = hostMemoryDir(cwd, home);
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);