pan-wizard 3.34.0 → 3.35.0

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