pan-wizard 3.33.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 (214) hide show
  1. package/README.md +29 -26
  2. package/agents/pan-conductor.md +18 -17
  3. package/agents/pan-counterfactual.md +2 -2
  4. package/agents/pan-debugger.md +30 -9
  5. package/agents/pan-design-checker.md +2 -1
  6. package/agents/pan-designer.md +2 -2
  7. package/agents/pan-distiller.md +3 -3
  8. package/agents/pan-document_code.md +14 -14
  9. package/agents/pan-executor.md +30 -9
  10. package/agents/pan-experiment-runner.md +9 -8
  11. package/agents/pan-hardener.md +1 -1
  12. package/agents/pan-integration-checker.md +2 -2
  13. package/agents/pan-knowledge.md +1 -1
  14. package/agents/pan-meta-reviewer.md +4 -4
  15. package/agents/pan-optimizer.md +39 -43
  16. package/agents/pan-phase-researcher.md +10 -10
  17. package/agents/pan-plan-checker.md +37 -34
  18. package/agents/pan-planner.md +57 -44
  19. package/agents/pan-previewer.md +1 -1
  20. package/agents/pan-project-researcher.md +12 -12
  21. package/agents/pan-release.md +1 -1
  22. package/agents/pan-research-synthesizer.md +3 -3
  23. package/agents/pan-reviewer.md +26 -3
  24. package/agents/pan-roadmapper.md +15 -14
  25. package/agents/pan-verifier.md +33 -25
  26. package/bin/install-lib.cjs +176 -38
  27. package/bin/install.js +81 -63
  28. package/commands/pan/army.md +15 -11
  29. package/commands/pan/assumptions.md +2 -2
  30. package/commands/pan/audit-deployment.md +11 -12
  31. package/commands/pan/cost.md +8 -8
  32. package/commands/pan/dashboard.md +0 -3
  33. package/commands/pan/debug.md +10 -7
  34. package/commands/pan/design-phase.md +5 -5
  35. package/commands/pan/discuss-phase.md +2 -0
  36. package/commands/pan/exec-phase.md +21 -37
  37. package/commands/pan/experiment.md +9 -8
  38. package/commands/pan/focus-auto.md +22 -20
  39. package/commands/pan/focus-design.md +7 -6
  40. package/commands/pan/focus-doc-audit.md +4 -3
  41. package/commands/pan/focus-drift-walking.md +4 -3
  42. package/commands/pan/focus-exec.md +6 -6
  43. package/commands/pan/focus-plan.md +12 -11
  44. package/commands/pan/focus-scan.md +5 -3
  45. package/commands/pan/focus-sync.md +3 -2
  46. package/commands/pan/git.md +8 -8
  47. package/commands/pan/health.md +2 -2
  48. package/commands/pan/hud.md +4 -7
  49. package/commands/pan/hygiene.md +14 -7
  50. package/commands/pan/knowledge.md +9 -10
  51. package/commands/pan/learn.md +10 -7
  52. package/commands/pan/links.md +4 -1
  53. package/commands/pan/map-codebase.md +1 -1
  54. package/commands/pan/mcp-bridge.md +19 -21
  55. package/commands/pan/milestone-done.md +6 -3
  56. package/commands/pan/milestone-new.md +1 -1
  57. package/commands/pan/new-project.md +10 -10
  58. package/commands/pan/optimize.md +18 -14
  59. package/commands/pan/patches.md +2 -6
  60. package/commands/pan/pause.md +1 -1
  61. package/commands/pan/phase-tests.md +3 -2
  62. package/commands/pan/plan-phase.md +9 -19
  63. package/commands/pan/preview.md +2 -3
  64. package/commands/pan/profile.md +2 -2
  65. package/commands/pan/quick.md +1 -1
  66. package/commands/pan/remove-phase.md +1 -1
  67. package/commands/pan/report.md +3 -7
  68. package/commands/pan/research-phase.md +16 -15
  69. package/commands/pan/resume.md +9 -11
  70. package/commands/pan/retro.md +5 -3
  71. package/commands/pan/review-deep.md +7 -9
  72. package/commands/pan/settings.md +1 -1
  73. package/commands/pan/verify-phase.md +0 -11
  74. package/commands/pan/what-if.md +3 -4
  75. package/hooks/dist/pan-context-monitor.js +352 -44
  76. package/hooks/dist/pan-cost-logger.js +30 -0
  77. package/hooks/dist/pan-guard.js +244 -0
  78. package/hooks/dist/pan-statusline.js +73 -24
  79. package/hooks/dist/pan-stop-guard.js +7 -2
  80. package/package.json +1 -1
  81. package/pan-wizard-core/bin/lib/agents-md.cjs +44 -6
  82. package/pan-wizard-core/bin/lib/bridge.cjs +6 -5
  83. package/pan-wizard-core/bin/lib/campaign.cjs +4 -2
  84. package/pan-wizard-core/bin/lib/commands-learnings.cjs +19 -2
  85. package/pan-wizard-core/bin/lib/commands.cjs +57 -6
  86. package/pan-wizard-core/bin/lib/config.cjs +7 -1
  87. package/pan-wizard-core/bin/lib/constants.cjs +18 -6
  88. package/pan-wizard-core/bin/lib/context-budget.cjs +12 -9
  89. package/pan-wizard-core/bin/lib/core.cjs +53 -19
  90. package/pan-wizard-core/bin/lib/cost-rebuild.cjs +13 -1
  91. package/pan-wizard-core/bin/lib/cost.cjs +142 -13
  92. package/pan-wizard-core/bin/lib/distill.cjs +6 -3
  93. package/pan-wizard-core/bin/lib/doc-lint.cjs +30 -11
  94. package/pan-wizard-core/bin/lib/findings.cjs +135 -21
  95. package/pan-wizard-core/bin/lib/focus.cjs +13 -4
  96. package/pan-wizard-core/bin/lib/frontmatter.cjs +105 -26
  97. package/pan-wizard-core/bin/lib/hygiene.cjs +184 -17
  98. package/pan-wizard-core/bin/lib/init.cjs +13 -4
  99. package/pan-wizard-core/bin/lib/knowledge.cjs +17 -1
  100. package/pan-wizard-core/bin/lib/learn-index.cjs +111 -10
  101. package/pan-wizard-core/bin/lib/learn-lint.cjs +30 -3
  102. package/pan-wizard-core/bin/lib/memory-optimize.cjs +8 -4
  103. package/pan-wizard-core/bin/lib/memory.cjs +508 -54
  104. package/pan-wizard-core/bin/lib/milestone.cjs +8 -3
  105. package/pan-wizard-core/bin/lib/optimize.cjs +97 -17
  106. package/pan-wizard-core/bin/lib/phase-gate.cjs +174 -0
  107. package/pan-wizard-core/bin/lib/phase-remove.cjs +85 -21
  108. package/pan-wizard-core/bin/lib/phase-report.cjs +8 -2
  109. package/pan-wizard-core/bin/lib/phase.cjs +39 -13
  110. package/pan-wizard-core/bin/lib/preview.cjs +34 -31
  111. package/pan-wizard-core/bin/lib/roadmap-compact.cjs +267 -0
  112. package/pan-wizard-core/bin/lib/roadmap.cjs +243 -9
  113. package/pan-wizard-core/bin/lib/runner.cjs +2 -1
  114. package/pan-wizard-core/bin/lib/state-compact.cjs +11 -6
  115. package/pan-wizard-core/bin/lib/state.cjs +43 -28
  116. package/pan-wizard-core/bin/lib/template.cjs +11 -4
  117. package/pan-wizard-core/bin/lib/verdict.cjs +140 -10
  118. package/pan-wizard-core/bin/lib/verify-integrity.cjs +181 -0
  119. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +230 -0
  120. package/pan-wizard-core/bin/lib/verify-preflight.cjs +4 -13
  121. package/pan-wizard-core/bin/lib/verify-retro.cjs +5 -3
  122. package/pan-wizard-core/bin/lib/verify-scope.cjs +11 -7
  123. package/pan-wizard-core/bin/lib/verify.cjs +30 -28
  124. package/pan-wizard-core/bin/lib/whatif.cjs +53 -9
  125. package/pan-wizard-core/bin/pan-tools.cjs +114 -19
  126. package/pan-wizard-core/learnings/index.json +5 -5
  127. package/pan-wizard-core/mcp/merge-gate.cjs +14 -3
  128. package/pan-wizard-core/mcp/native-tools.cjs +3 -3
  129. package/pan-wizard-core/opencode/pan-wizard.js +139 -0
  130. package/pan-wizard-core/references/checkpoints.md +3 -3
  131. package/pan-wizard-core/references/continuation-format.md +3 -3
  132. package/pan-wizard-core/references/decimal-phase-calculation.md +2 -3
  133. package/pan-wizard-core/references/design-methodology.md +1 -1
  134. package/pan-wizard-core/references/git-integration.md +4 -6
  135. package/pan-wizard-core/references/git-planning-commit.md +2 -2
  136. package/pan-wizard-core/references/guardrails.md +2 -2
  137. package/pan-wizard-core/references/handoff-decisions.md +3 -3
  138. package/pan-wizard-core/references/model-profile-resolution.md +5 -5
  139. package/pan-wizard-core/references/model-profiles.md +15 -10
  140. package/pan-wizard-core/references/phase-argument-parsing.md +7 -7
  141. package/pan-wizard-core/references/planning-config.md +7 -7
  142. package/pan-wizard-core/references/tdd.md +10 -1
  143. package/pan-wizard-core/references/verdict-contract.md +6 -1
  144. package/pan-wizard-core/references/verification-patterns.md +5 -5
  145. package/pan-wizard-core/templates/codebase/architecture.md +2 -2
  146. package/pan-wizard-core/templates/codebase/best-practices.md +2 -2
  147. package/pan-wizard-core/templates/codebase/concerns.md +3 -3
  148. package/pan-wizard-core/templates/codebase/integrations.md +6 -6
  149. package/pan-wizard-core/templates/codebase/relationships.md +2 -2
  150. package/pan-wizard-core/templates/codebase/stack.md +2 -2
  151. package/pan-wizard-core/templates/codebase/structure.md +4 -4
  152. package/pan-wizard-core/templates/codebase/testing.md +4 -4
  153. package/pan-wizard-core/templates/config.json +2 -6
  154. package/pan-wizard-core/templates/continue-here.md +1 -1
  155. package/pan-wizard-core/templates/debug-subagent-prompt.md +5 -3
  156. package/pan-wizard-core/templates/debug.md +15 -1
  157. package/pan-wizard-core/templates/milestone-archive.md +6 -6
  158. package/pan-wizard-core/templates/milestone.md +1 -1
  159. package/pan-wizard-core/templates/phase-prompt.md +31 -17
  160. package/pan-wizard-core/templates/planner-subagent-prompt.md +5 -5
  161. package/pan-wizard-core/templates/preview-report.md +1 -1
  162. package/pan-wizard-core/templates/research-project/architecture.md +3 -3
  163. package/pan-wizard-core/templates/research-project/features.md +1 -1
  164. package/pan-wizard-core/templates/research-project/pitfalls.md +1 -1
  165. package/pan-wizard-core/templates/research-project/stack.md +3 -3
  166. package/pan-wizard-core/templates/research.md +5 -5
  167. package/pan-wizard-core/templates/retrospective.md +1 -1
  168. package/pan-wizard-core/templates/roadmap.md +15 -15
  169. package/pan-wizard-core/templates/state.md +4 -4
  170. package/pan-wizard-core/templates/summary-complex.md +1 -0
  171. package/pan-wizard-core/templates/summary-minimal.md +1 -0
  172. package/pan-wizard-core/templates/summary-standard.md +2 -1
  173. package/pan-wizard-core/templates/summary.md +18 -2
  174. package/pan-wizard-core/templates/uat.md +3 -3
  175. package/pan-wizard-core/templates/user-setup.md +8 -8
  176. package/pan-wizard-core/templates/verification-report.md +53 -8
  177. package/pan-wizard-core/workflows/add-phase.md +1 -1
  178. package/pan-wizard-core/workflows/assumptions.md +1 -1
  179. package/pan-wizard-core/workflows/diagnose-issues.md +4 -4
  180. package/pan-wizard-core/workflows/discuss-phase.md +10 -8
  181. package/pan-wizard-core/workflows/exec-phase.md +75 -95
  182. package/pan-wizard-core/workflows/execute-plan.md +8 -7
  183. package/pan-wizard-core/workflows/health.md +13 -12
  184. package/pan-wizard-core/workflows/help.md +8 -9
  185. package/pan-wizard-core/workflows/learn.md +13 -11
  186. package/pan-wizard-core/workflows/map-codebase.md +10 -10
  187. package/pan-wizard-core/workflows/milestone-audit.md +10 -10
  188. package/pan-wizard-core/workflows/milestone-gaps.md +18 -25
  189. package/pan-wizard-core/workflows/milestone-new.md +7 -6
  190. package/pan-wizard-core/workflows/new-project.md +17 -25
  191. package/pan-wizard-core/workflows/optimize.md +11 -9
  192. package/pan-wizard-core/workflows/pause.md +6 -1
  193. package/pan-wizard-core/workflows/phase-tests.md +4 -4
  194. package/pan-wizard-core/workflows/plan-phase.md +53 -42
  195. package/pan-wizard-core/workflows/profile.md +2 -2
  196. package/pan-wizard-core/workflows/progress.md +6 -8
  197. package/pan-wizard-core/workflows/quick.md +7 -5
  198. package/pan-wizard-core/workflows/remove-phase.md +1 -1
  199. package/pan-wizard-core/workflows/research-phase.md +9 -5
  200. package/pan-wizard-core/workflows/resume-project.md +4 -6
  201. package/pan-wizard-core/workflows/retro.md +3 -1
  202. package/pan-wizard-core/workflows/settings.md +16 -24
  203. package/pan-wizard-core/workflows/todo-check.md +4 -3
  204. package/pan-wizard-core/workflows/transition.md +16 -12
  205. package/pan-wizard-core/workflows/update.md +25 -17
  206. package/pan-wizard-core/workflows/verify-phase.md +22 -20
  207. package/pan-zcode/KNOWN-BETA-RISKS.md +2 -2
  208. package/pan-zcode/README.md +7 -7
  209. package/pan-zcode/bin/install-zcode.js +1 -1
  210. package/scripts/build-hooks.js +1 -0
  211. package/scripts/build-plugin.js +3 -1
  212. package/scripts/generate-skills-docs.py +15 -6
  213. package/scripts/prompt-lint.cjs +167 -0
  214. package/scripts/test-surface.cjs +1 -0
@@ -28,10 +28,6 @@ const FINDING_CLASSES = Object.freeze(['missing', 'partial', 'contradicts', 'unr
28
28
  const SEVERITIES = Object.freeze(['critical', 'high', 'medium', 'low', 'info']);
29
29
  const DISPOSITIONS = Object.freeze(['fixed', 'deferred', 'dismissed', 'decision']);
30
30
  const REASON_REQUIRED = Object.freeze(['deferred', 'dismissed', 'decision']);
31
- // A re-run does not re-check these, so their absence from a later verdict is not
32
- // evidence that they were resolved (D6).
33
- const AUTO_FIX_EXEMPT = Object.freeze(['human', 'unrequested']);
34
-
35
31
  /** Each judge's own verdict word → the normalised outcome it implies. */
36
32
  const KNOWN_VERDICTS = Object.freeze({
37
33
  'pan-verifier': Object.freeze({ passed: 'pass', gaps_found: 'fail', human_needed: 'needs_human' }),
@@ -43,6 +39,8 @@ const KNOWN_VERDICTS = Object.freeze({
43
39
  const LIMITS = Object.freeze({ findings: 100, where: 200, summary: 300, verdict: 40, score: 40, not_checked: 20, check: 80 });
44
40
  // What the verifier's test gate can report (verify-phase.md, run_test_suite).
45
41
  const TEST_GATE_VALUES = Object.freeze(['passed', 'failed', 'skipped']);
42
+ const RESOLUTION_STATUSES = Object.freeze(['resolved', 'still_present', 'cannot_tell']);
43
+ const FINDING_ID_RE = /^f_[0-9a-f]{10}$/;
46
44
  const AGENT_RE = /^[a-z][a-z0-9-]{0,63}$/;
47
45
  const OPEN_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*pan-verdict[ \t]*$/;
48
46
  const CLOSE_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[ \t]*$/;
@@ -198,7 +196,42 @@ function validateVerdict(obj, opts = {}) {
198
196
  });
199
197
 
200
198
  const notChecked = normaliseNotChecked(obj.not_checked, warnings);
201
- return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings, not_checked: notChecked }, warnings };
199
+ const resolutions = normaliseResolutions(obj.resolutions, warnings);
200
+ return { ok: true, verdict: { contract, agent, outcome, verdict, phase, score, findings, not_checked: notChecked, resolutions }, warnings };
201
+ }
202
+
203
+ /**
204
+ * A judge's answers about its earlier open findings (V6): each `{id|summary, status,
205
+ * evidence}` with status resolved | still_present | cannot_tell. Silence is not an
206
+ * answer: a finding the judge leaves out stays open. `resolved` without evidence is
207
+ * recorded as cannot_tell, so a bare "fixed" closes nothing.
208
+ */
209
+ function normaliseResolutions(value, warnings) {
210
+ if (value == null) return [];
211
+ if (!Array.isArray(value)) {
212
+ warnings.push('resolutions_not_an_array');
213
+ return [];
214
+ }
215
+ const out = [];
216
+ value.slice(0, LIMITS.findings).forEach((r, i) => {
217
+ if (!r || typeof r !== 'object' || Array.isArray(r)) { warnings.push(`resolution_dropped: #${i} is not an object`); return; }
218
+ const id = typeof r.id === 'string' && FINDING_ID_RE.test(r.id.trim()) ? r.id.trim() : null;
219
+ const named = r.summary ?? r.truth;
220
+ const summary = named == null ? null : (truncate(cleanText(String(named)), LIMITS.summary) || null);
221
+ if (!id && !summary) { warnings.push(`resolution_dropped: #${i} names no finding id or summary`); return; }
222
+ let status = typeof r.status === 'string' ? r.status.trim().toLowerCase() : '';
223
+ if (!RESOLUTION_STATUSES.includes(status)) {
224
+ warnings.push(`resolution_unknown_status: #${i} ${JSON.stringify(r.status === undefined ? null : r.status)} recorded as cannot_tell`);
225
+ status = 'cannot_tell';
226
+ }
227
+ const evidence = r.evidence == null ? '' : truncate(cleanText(String(r.evidence)), LIMITS.summary);
228
+ if (status === 'resolved' && !evidence) {
229
+ warnings.push(`resolution_without_evidence: #${i} recorded as cannot_tell`);
230
+ status = 'cannot_tell';
231
+ }
232
+ out.push({ id, summary, status, evidence: evidence || null });
233
+ });
234
+ return out;
202
235
  }
203
236
 
204
237
  /**
@@ -336,6 +369,49 @@ function listOfMappings(yaml, key) {
336
369
  return items;
337
370
  }
338
371
 
372
+ /**
373
+ * A list nested one level under a top-level key, as the verifier writes
374
+ * `re_verification.gaps_closed`. Items are bare strings (read as `{truth}`) or
375
+ * mappings (`- truth: "…"` with `evidence: "…"` below it).
376
+ */
377
+ function nestedList(yaml, parent, key) {
378
+ const lines = yaml.split('\n');
379
+ const start = lines.findIndex((l) => {
380
+ const m = KEY_VALUE_RE.exec(l);
381
+ return m && m[1] === parent && indentOf(l) === 0;
382
+ });
383
+ if (start === -1) return [];
384
+ const items = [];
385
+ let keyIndent = -1;
386
+ let cur = null;
387
+ for (let i = start + 1; i < lines.length; i++) {
388
+ const line = lines[i];
389
+ if (!line.trim()) continue;
390
+ const ind = indentOf(line);
391
+ if (ind === 0) break;
392
+ const body = line.trim();
393
+ if (keyIndent === -1) {
394
+ const kv = KEY_VALUE_RE.exec(body);
395
+ if (kv && kv[1] === key) {
396
+ if (stripComment(kv[2]) !== '') return []; // `gaps_closed: []`
397
+ keyIndent = ind;
398
+ }
399
+ continue;
400
+ }
401
+ if (ind <= keyIndent) break;
402
+ const it = ITEM_RE.exec(line);
403
+ if (it) {
404
+ const kv = KEY_VALUE_RE.exec(it[2]);
405
+ cur = kv ? { [kv[1]]: unquote(kv[2]) } : { truth: unquote(it[2]) };
406
+ items.push(cur);
407
+ } else if (cur) {
408
+ const kv = KEY_VALUE_RE.exec(body);
409
+ if (kv) cur[kv[1]] = unquote(kv[2]);
410
+ }
411
+ }
412
+ return items.filter((x) => x.truth || x.summary || x.id);
413
+ }
414
+
339
415
  /** A top-level scalar from the frontmatter (e.g. `status`), or null. */
340
416
  function frontmatterScalar(yaml, key) {
341
417
  for (const l of yaml.split('\n')) {
@@ -397,12 +473,57 @@ function verdictFromVerificationFrontmatter(text, agent = 'pan-verifier') {
397
473
  notChecked = notChecked.filter((n) => !isTests(n));
398
474
  contradicted = true;
399
475
  }
400
- return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings, not_checked: notChecked, test_gate: testGate, not_checked_contradicted: contradicted };
476
+ // Re-verification answers (V6): a gap the verifier re-checked and closed is
477
+ // resolved; a regression or a remaining gap is still present.
478
+ const resolutions = [
479
+ ...nestedList(yaml, 're_verification', 'gaps_closed').map((g) => ({
480
+ id: g.id, summary: g.truth || g.summary, status: 'resolved', evidence: g.evidence || 'closed by the verifier\'s re-verification',
481
+ })),
482
+ ...nestedList(yaml, 're_verification', 'regressions').map((g) => ({ id: g.id, summary: g.truth || g.summary, status: 'still_present', evidence: g.evidence || null })),
483
+ ...nestedList(yaml, 're_verification', 'gaps_remaining').map((g) => ({ id: g.id, summary: g.truth || g.summary, status: 'still_present', evidence: g.evidence || null })),
484
+ ];
485
+ return { contract: VERDICT_CONTRACT, agent, verdict: status, outcome, findings, not_checked: notChecked, resolutions, test_gate: testGate, not_checked_contradicted: contradicted };
486
+ }
487
+
488
+ /** Names of the code facts that passed: a passing fact closes its own code-fact finding (V6). */
489
+ function passingFacts(facts) {
490
+ return (Array.isArray(facts) ? facts : []).filter((f) => f && f.ok === true && f.fact).map((f) => f.fact);
491
+ }
492
+
493
+ /** The verdict word an agent uses for a fail, or null for an agent outside KNOWN_VERDICTS. */
494
+ function failWordFor(agent) {
495
+ const table = KNOWN_VERDICTS[agent];
496
+ return table ? Object.keys(table).find((k) => table[k] === 'fail') || null : null;
497
+ }
498
+
499
+ /**
500
+ * Two-key verdict (V9): a code fact can lower a judge's verdict, never raise it.
501
+ * Any failing fact turns a pass or needs_human into the agent's fail word, keeps
502
+ * the agent's own word as `agent_verdict`, and adds one high finding per fact
503
+ * (`where: code-fact:<fact>`) so the gap round has something to close.
504
+ *
505
+ * @param {Object} verdict - a validated verdict
506
+ * @param {Array<{fact: string, ok: boolean, summary: string}>} facts
507
+ * @returns {{verdict: Object, lowered_by: string[]}} the same verdict object when nothing lowers it
508
+ */
509
+ function lowerByCodeFacts(verdict, facts) {
510
+ const failing = (Array.isArray(facts) ? facts : []).filter((f) => f && f.ok === false && f.fact);
511
+ if (!failing.length || verdict.outcome === 'fail') return { verdict, lowered_by: [] };
512
+ const findings = [...verdict.findings];
513
+ for (const f of failing) {
514
+ findings.push({ class: 'defect', severity: 'high', where: `code-fact:${f.fact}`, summary: truncate(cleanText(f.summary || `${f.fact} failed`), LIMITS.summary) });
515
+ }
516
+ return {
517
+ verdict: { ...verdict, outcome: 'fail', verdict: failWordFor(verdict.agent) || 'fail', agent_verdict: verdict.verdict, findings },
518
+ lowered_by: failing.map((f) => f.fact),
519
+ };
401
520
  }
402
521
 
403
522
  /**
404
523
  * Read a judge's verdict from an artifact: the LAST `pan-verdict` block wins; with
405
524
  * no block, a verification.md frontmatter `status` is read through the adapter.
525
+ * Code facts (`opts.facts`, plus a verification's own `test_gate`) are applied
526
+ * after parsing through lowerByCodeFacts.
406
527
  * @param {string} text
407
528
  * @param {{agent?: string, phase?: string|number}} [opts]
408
529
  * @returns {{ok: true, verdict: Object, warnings: string[], source_kind: 'block'|'frontmatter'}
@@ -418,7 +539,9 @@ function parseVerdictText(text, opts = {}) {
418
539
  return { ok: false, error: 'invalid_json', reason: `the last pan-verdict block is not valid JSON: ${e.message}` };
419
540
  }
420
541
  const r = validateVerdict(obj, opts);
421
- return r.ok ? { ...r, source_kind: 'block' } : r;
542
+ if (!r.ok) return r;
543
+ const low = lowerByCodeFacts(r.verdict, opts.facts);
544
+ return { ...r, verdict: low.verdict, lowered_by: low.lowered_by, facts_ok: passingFacts(opts.facts), source_kind: 'block' };
422
545
  }
423
546
  const adapted = verdictFromVerificationFrontmatter(text, opts.agent || 'pan-verifier');
424
547
  if (adapted) {
@@ -431,9 +554,13 @@ function parseVerdictText(text, opts = {}) {
431
554
  // gate under a pass: both make "passed" claim more than the verifier checked.
432
555
  if (!adapted.test_gate) r.warnings.push('test_gate_unrecorded: the verification does not say whether the test suite ran');
433
556
  else if (!TEST_GATE_VALUES.includes(adapted.test_gate)) r.warnings.push(`test_gate_unknown: "${adapted.test_gate}" is not passed, failed or skipped`);
434
- else if (adapted.test_gate === 'failed' && adapted.outcome === 'pass') r.warnings.push('test_gate_failed_under_pass: the test gate failed but the status is passed');
557
+ else if (adapted.test_gate === 'failed' && adapted.outcome === 'pass') r.warnings.push('test_gate_failed_under_pass: the test gate failed but the status is passed, so it records as gaps_found');
435
558
  if (adapted.not_checked_contradicted) r.warnings.push(`not_checked_contradicted: test_gate is ${adapted.test_gate}, so the not_checked tests entry was dropped`);
436
- return { ...r, source_kind: 'frontmatter' };
559
+ const facts = [...(Array.isArray(opts.facts) ? opts.facts : [])];
560
+ if (adapted.test_gate === 'failed') facts.push({ fact: 'test_gate', ok: false, summary: 'The test gate failed: the suite reported failures or did not run' });
561
+ if (adapted.test_gate === 'passed') facts.push({ fact: 'test_gate', ok: true, summary: 'the test gate passed' });
562
+ const low = lowerByCodeFacts(r.verdict, facts);
563
+ return { ...r, verdict: low.verdict, lowered_by: low.lowered_by, facts_ok: passingFacts(facts), source_kind: 'frontmatter' };
437
564
  }
438
565
  return { ok: false, error: 'no_verdict', reason: 'no pan-verdict block and no verification frontmatter status' };
439
566
  }
@@ -466,13 +593,16 @@ module.exports = {
466
593
  SEVERITIES,
467
594
  DISPOSITIONS,
468
595
  REASON_REQUIRED,
469
- AUTO_FIX_EXEMPT,
470
596
  KNOWN_VERDICTS,
471
597
  LIMITS,
598
+ RESOLUTION_STATUSES,
599
+ FINDING_ID_RE,
472
600
  findVerdictBlocks,
473
601
  validateVerdict,
474
602
  verdictFromVerificationFrontmatter,
475
603
  parseVerdictText,
604
+ lowerByCodeFacts,
605
+ failWordFor,
476
606
  recordSig,
477
607
  findingId,
478
608
  outcomeForVerdict,
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+ /**
3
+ * Test integrity (program V3): did this phase weaken the tests that judge it?
4
+ *
5
+ * An agent that cannot make a test pass can make the test stop failing instead:
6
+ * delete it, skip it, drop its assertions, rewrite its expectation or snapshot, or
7
+ * add an assertion that cannot fail. ImpossibleBench measured agents doing this on a
8
+ * large share of impossible tasks, and a stated way out (stop and report the
9
+ * conflict) cut it sharply. This is the check in code; the executor's way out is
10
+ * the `BLOCKED_SPEC_CONFLICT` return.
11
+ *
12
+ * It walks the phase's own plan commits (the `{type}({phase}-{plan}):` subjects that
13
+ * verify-scope reads) one at a time and reads each test file's diff:
14
+ *
15
+ * errors test_deleted · skip_marker_added · assertions_dropped ·
16
+ * undeclared_test_edit (an existing assertion changed in a test no plan
17
+ * declared) · snapshot_rewritten (undeclared) · always_true_assert
18
+ * warnings test_renamed
19
+ *
20
+ * A file a plan declares in `files_modified` may be edited freely, except that an
21
+ * added skip marker or an always-true assertion is an error anywhere.
22
+ */
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const { execGit, findPhaseInternal, safeReadFile, output, error, toPosix } = require('./core.cjs');
26
+ const { extractFrontmatter } = require('./frontmatter.cjs');
27
+ const { isPlanFile } = require('./constants.cjs');
28
+ const { committedFiles, TEST_PATH_RE, TEST_FILE_RE } = require('./verify-scope.cjs');
29
+
30
+ const TEST_NAME_RE = /(^|\/)(test_[^/]+\.py|[^/]+_test\.(py|go)|[^/]+Tests?\.(java|kt|cs))$/;
31
+ // A snapshot directory anywhere in the path, or a `.snap` file: two patterns, so neither
32
+ // alternative's anchoring reads as the other's.
33
+ const SNAPSHOT_DIR_RE = /(?:^|\/)(?:__snapshots__|snapshots)\//;
34
+ const SNAPSHOT_EXT_RE = /\.snap$/;
35
+
36
+ /** Markers that stop a test from running, per ecosystem. */
37
+ const SKIP_RE = [
38
+ /\b(?:it|test|describe|suite|context)\.(?:skip|only|todo)\s*\(/, // jest / mocha / vitest / node:test
39
+ /\bx(?:it|test|describe)\s*\(/, // jasmine / mocha
40
+ /\b(?:skip|only|todo)\s*:\s*(?:true|['"`])/, // node:test options
41
+ /@pytest\.mark\.(?:skip|skipif|xfail)\b/,
42
+ /\bpytest\.skip\s*\(/,
43
+ /@unittest\.skip\w*\b/,
44
+ /\bt\.Skip(?:Now|f)?\s*\(/, // go
45
+ /#\[ignore\]/, // rust
46
+ /@(?:Disabled|Ignore)\b/, // junit
47
+ ];
48
+
49
+ /** Lines that assert, per ecosystem. */
50
+ const ASSERT_RE = [
51
+ /\bassert(?:\.\w+)?\s*\(/,
52
+ /\bassert(?:_eq|_ne)?!\s*\(/,
53
+ /\bexpect\s*\(/,
54
+ /\.should\b/,
55
+ /\bself\.assert\w+\s*\(/,
56
+ /^\s*assert\s+\S/,
57
+ /\bt\.(?:Error|Errorf|Fatal|Fatalf|Fail|FailNow)\s*\(/,
58
+ /\b(?:require|assert)\.\w+\s*\(\s*t\b/, // testify
59
+ /\bAssert\.\w+\s*\(/,
60
+ ];
61
+
62
+ /** Assertions that cannot fail. */
63
+ const ALWAYS_TRUE_RE = [
64
+ /\bassert(?:\.ok)?\s*\(\s*true\s*[,)]/,
65
+ /\bexpect\s*\(\s*true\s*\)\s*\.\s*(?:toBe|toEqual|toBeTruthy)\s*\(\s*(?:true)?\s*\)/,
66
+ /^\s*assert\s+True\s*(?:#.*)?$/,
67
+ /^\s*assert\s+(\d+)\s*==\s*\1\s*(?:#.*)?$/,
68
+ /\bself\.assertTrue\s*\(\s*True\s*\)/,
69
+ /\bassert!\s*\(\s*true\s*\)/,
70
+ /\bassertTrue\s*\(\s*true\s*\)/,
71
+ ];
72
+
73
+ const isTestFile = (f) => TEST_PATH_RE.test(f) || TEST_FILE_RE.test(f) || TEST_NAME_RE.test(f);
74
+ const isSnapshot = (f) => SNAPSHOT_DIR_RE.test(f) || SNAPSHOT_EXT_RE.test(f);
75
+ const any = (res, line) => res.some((re) => re.test(line));
76
+ const norm = (p) => toPosix(String(p || '').trim()).replace(/^\.\/+/, '');
77
+
78
+ /** A Rust source file that holds its own unit tests (`#[test]`) at this commit. */
79
+ function hasInlineTests(cwd, hash, file) {
80
+ if (!file.endsWith('.rs')) return false;
81
+ const r = execGit(cwd, ['show', `${hash}:${file}`]);
82
+ return r.exitCode === 0 && /#\[(?:test|cfg\(test\))\]/.test(r.stdout);
83
+ }
84
+
85
+ /** The union of the phase plans' `files_modified`. */
86
+ function declaredFiles(cwd, info) {
87
+ const dir = path.join(cwd, info.directory);
88
+ const out = new Set();
89
+ for (const f of info.plans.filter(isPlanFile)) {
90
+ const fm = extractFrontmatter(safeReadFile(path.join(dir, f)) || '');
91
+ const raw = fm.files_modified || fm['files-modified'];
92
+ for (const p of Array.isArray(raw) ? raw : (raw ? [raw] : [])) if (norm(p)) out.add(norm(p));
93
+ }
94
+ return out;
95
+ }
96
+
97
+ function isDeclared(file, declared) {
98
+ for (const d of declared) if (d === file || (d.endsWith('/') && file.startsWith(d))) return true;
99
+ return false;
100
+ }
101
+
102
+ /** One commit's file changes: `[{status, file, from?}]`, renames detected. */
103
+ function commitChanges(cwd, hash) {
104
+ const r = execGit(cwd, ['-c', 'core.quotepath=off', 'show', '--relative', '-M', '--name-status', '--format=', hash]);
105
+ if (r.exitCode !== 0) return [];
106
+ return r.stdout.split(/\r?\n/).filter(Boolean).map((line) => {
107
+ const parts = line.split('\t');
108
+ const status = parts[0][0];
109
+ return status === 'R' || status === 'C'
110
+ ? { status, from: norm(parts[1]), file: norm(parts[2]) }
111
+ : { status, file: norm(parts[1]) };
112
+ });
113
+ }
114
+
115
+ /** One file's added and removed lines in one commit. */
116
+ function fileDiff(cwd, hash, file) {
117
+ const r = execGit(cwd, ['-c', 'core.quotepath=off', 'show', '--relative', '--format=', '-U0', '--no-color', hash, '--', file]);
118
+ const added = [];
119
+ const removed = [];
120
+ if (r.exitCode !== 0) return { added, removed };
121
+ for (const line of r.stdout.split(/\r?\n/)) {
122
+ if (line.startsWith('+++') || line.startsWith('---')) continue;
123
+ if (line.startsWith('+')) added.push(line.slice(1));
124
+ else if (line.startsWith('-')) removed.push(line.slice(1));
125
+ }
126
+ return { added, removed };
127
+ }
128
+
129
+ /**
130
+ * The integrity check for one phase.
131
+ * @returns {{phase, checked: boolean, ok: boolean, reason?: string, commits: number, violations: Array, warnings: Array}|{error, reason}}
132
+ */
133
+ function checkTestIntegrity(cwd, phase) {
134
+ const info = findPhaseInternal(cwd, phase);
135
+ if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
136
+ const git = committedFiles(cwd, info.phase_number);
137
+ const base = { phase: info.phase_number, commits: git.commits, violations: [], warnings: [] };
138
+ if (!git.git) return { ...base, checked: false, ok: true, reason: 'git is unavailable or this is not a repository' };
139
+ if (!git.commits) return { ...base, checked: false, ok: true, reason: 'no plan commits for this phase (subjects like "feat(03-01): …")' };
140
+
141
+ const declared = declaredFiles(cwd, info);
142
+ const violations = [];
143
+ const warnings = [];
144
+ const add = (list, code, file, commit, detail) => list.push({ code, file, commit: commit.slice(0, 12), detail });
145
+
146
+ for (const hash of [...git.hashes].reverse()) { // oldest first
147
+ for (const ch of commitChanges(cwd, hash)) {
148
+ const { file } = ch;
149
+ if (ch.status === 'D' && isTestFile(file)) { add(violations, 'test_deleted', file, hash, `${file} was deleted`); continue; }
150
+ if (ch.status === 'R' && isTestFile(ch.from)) add(warnings, 'test_renamed', file, hash, `${ch.from} was renamed to ${file}`);
151
+ if (isSnapshot(file) && (ch.status === 'M' || ch.status === 'D') && !isDeclared(file, declared)) {
152
+ add(violations, 'snapshot_rewritten', file, hash, `snapshot ${file} was ${ch.status === 'D' ? 'deleted' : 'rewritten'} and no plan declares it`);
153
+ continue;
154
+ }
155
+ if (ch.status === 'D' || !(isTestFile(file) || hasInlineTests(cwd, hash, file))) continue;
156
+ const { added, removed } = fileDiff(cwd, hash, file);
157
+ const skipsAdded = added.filter((l) => any(SKIP_RE, l)).length - removed.filter((l) => any(SKIP_RE, l)).length;
158
+ if (skipsAdded > 0) add(violations, 'skip_marker_added', file, hash, `${skipsAdded} skip/only marker(s) added`);
159
+ const trivial = added.filter((l) => any(ALWAYS_TRUE_RE, l));
160
+ if (trivial.length) add(violations, 'always_true_assert', file, hash, `an assertion that cannot fail: ${trivial[0].trim().slice(0, 120)}`);
161
+ const assertsAdded = added.filter((l) => any(ASSERT_RE, l) && !any(ALWAYS_TRUE_RE, l)).length;
162
+ const assertsRemoved = removed.filter((l) => any(ASSERT_RE, l)).length;
163
+ if (assertsRemoved > assertsAdded) {
164
+ add(violations, 'assertions_dropped', file, hash, `${assertsRemoved} assertion line(s) removed, ${assertsAdded} added`);
165
+ } else if (assertsRemoved > 0 && ch.status === 'M' && !isDeclared(file, declared)) {
166
+ add(violations, 'undeclared_test_edit', file, hash, `${assertsRemoved} existing assertion(s) changed in a test no plan declares`);
167
+ }
168
+ }
169
+ }
170
+ return { ...base, checked: true, ok: violations.length === 0, violations, warnings };
171
+ }
172
+
173
+ function cmdVerifyTestIntegrity(cwd, phase, raw) {
174
+ if (!phase) error('Usage: verify test-integrity <phase>');
175
+ const r = checkTestIntegrity(cwd, phase);
176
+ if (r.error) { output(r, raw); return; }
177
+ const word = !r.checked ? 'not_checked' : (r.ok ? 'clean' : 'violated');
178
+ output(r, raw, word, r.ok ? 0 : 1);
179
+ }
180
+
181
+ module.exports = { checkTestIntegrity, cmdVerifyTestIntegrity, isTestFile, SKIP_RE, ASSERT_RE, ALWAYS_TRUE_RE };
@@ -0,0 +1,230 @@
1
+ 'use strict';
2
+ /**
3
+ * Plan checks in code (program V7).
4
+ *
5
+ * The plan-checker agent reads a phase's plans and judges them, but the parts of that
6
+ * judgement that are arithmetic were left to the model: whether every requirement is
7
+ * covered, whether two plans in one wave edit the same file, whether a dependency
8
+ * points at a plan that does not exist, at the same wave or a later one, or round in
9
+ * a circle. `verify plan-set` checks those in code, before the checker is spent.
10
+ *
11
+ * It also binds a passing plan check to the plans it checked. The recorder stores a
12
+ * hash of the phase's plan files with every plan-checker verdict (`plans_sha`), and
13
+ * `planCheckStatus` tells exec-phase whether the plans it is about to run are the
14
+ * plans that passed: `ok`, `stale` (edited since), `failed`, `missing`, `legacy`
15
+ * (a record from before hashing: a warning, never a refusal) or `disabled`.
16
+ *
17
+ * And it reports drift: a plan that records `planned_commit` lists the files it
18
+ * modifies that changed on the branch since it was planned.
19
+ */
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const crypto = require('crypto');
23
+ const { findPhaseInternal, output, error, execGit, loadConfig, toLf, toPosix } = require('./core.cjs');
24
+ const { extractFrontmatter } = require('./frontmatter.cjs');
25
+ const { roadmapRequirementIds } = require('./roadmap.cjs');
26
+ const { buildPlanIndex, dependencyNames } = require('./phase.cjs');
27
+ const { ROADMAP_FILE, getPlanId } = require('./constants.cjs');
28
+ const { planningPath, filterPlanFiles, filterSummaryFiles } = require('./utils.cjs');
29
+
30
+ const PLAN_CHECKER = 'pan-plan-checker';
31
+
32
+ const asList = (v) => (Array.isArray(v) ? v : (v == null || v === '' ? [] : [v])).map((x) => String(x).trim()).filter(Boolean);
33
+ const normFile = (f) => toPosix(String(f).trim()).replace(/^\.\//, '');
34
+
35
+ /** The phase directory and its plan and summary files, or `{error, reason}`. */
36
+ function phaseFiles(cwd, phase) {
37
+ const info = findPhaseInternal(cwd, phase);
38
+ if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
39
+ const dir = path.join(cwd, info.directory);
40
+ let files = [];
41
+ try { files = fs.readdirSync(dir); } catch (e) { return { error: 'unreadable_phase', reason: `cannot read ${info.directory}: ${e.code || e.message}` }; }
42
+ return { info, dir, planFiles: filterPlanFiles(files).sort(), summaryFiles: filterSummaryFiles(files) };
43
+ }
44
+
45
+ /**
46
+ * A hash of a phase's plan files (names and line-ending-normalised contents), so a
47
+ * plan check can be matched to exactly the plans it checked.
48
+ */
49
+ function plansSha(dir, planFiles) {
50
+ const h = crypto.createHash('sha256');
51
+ for (const f of [...planFiles].sort()) {
52
+ let text = '';
53
+ try { text = fs.readFileSync(path.join(dir, f), 'utf-8'); } catch { /* unreadable reads as empty */ }
54
+ h.update(f + '\u0000' + toLf(text) + '\u0000');
55
+ }
56
+ return h.digest('hex').slice(0, 16);
57
+ }
58
+
59
+ /** `plansSha` for a phase by number, or null when it has no plans. */
60
+ function plansShaFor(cwd, phase) {
61
+ const pf = phaseFiles(cwd, phase);
62
+ if (pf.error || !pf.planFiles.length) return null;
63
+ return plansSha(pf.dir, pf.planFiles);
64
+ }
65
+
66
+ /** The phase's plans, each with its requirements, planned_commit and file name. */
67
+ function loadPlans(pf) {
68
+ const { plans } = buildPlanIndex(pf.dir, pf.planFiles, pf.summaryFiles);
69
+ const byId = new Map(plans.map((p) => [p.id, p]));
70
+ for (const file of pf.planFiles) {
71
+ const p = byId.get(getPlanId(file));
72
+ if (!p) continue;
73
+ let fm = {};
74
+ try { fm = extractFrontmatter(fs.readFileSync(path.join(pf.dir, file), 'utf-8')) || {}; } catch { /* unreadable */ }
75
+ p.file = file;
76
+ p.requirements = asList(fm.requirements);
77
+ p.planned_commit = fm.planned_commit ? String(fm.planned_commit).trim() : null;
78
+ p.files_modified = p.files_modified.map(normFile).filter(Boolean);
79
+ }
80
+ return plans;
81
+ }
82
+
83
+ /** A dependency entry that names a plan of another phase (`02-01` from phase 03). */
84
+ function isOtherPhase(dep, phaseNumber) {
85
+ const m = /^(\d+(?:\.\d+)*)-\d+$/.exec(String(dep).trim());
86
+ if (!m) return false;
87
+ const strip = (s) => String(s).split('.').map((x) => String(parseInt(x, 10))).join('.');
88
+ return strip(m[1]) !== strip(phaseNumber);
89
+ }
90
+
91
+ /**
92
+ * Check a phase's plan set.
93
+ * @returns {{phase, ok, errors: Array, warnings: Array, plans: number}|{error, reason}}
94
+ */
95
+ function checkPlanSet(cwd, phase) {
96
+ const pf = phaseFiles(cwd, phase);
97
+ if (pf.error) return pf;
98
+ if (!pf.planFiles.length) return { error: 'no_plans', reason: `phase ${phase} has no plans` };
99
+ const phaseNumber = pf.info.phase_number || String(phase);
100
+ const plans = loadPlans(pf);
101
+ const errors = [];
102
+ const warnings = [];
103
+
104
+ // 1. Requirement coverage, against the roadmap's requirement ids for this phase.
105
+ let roadmap = '';
106
+ try { roadmap = fs.readFileSync(planningPath(cwd, ROADMAP_FILE), 'utf-8'); } catch { /* no roadmap: nothing to cover */ }
107
+ const required = roadmapRequirementIds(roadmap, phaseNumber);
108
+ const key = (id) => String(id).trim().toUpperCase();
109
+ const planned = new Set(plans.flatMap((p) => p.requirements.map(key)));
110
+ for (const id of required) {
111
+ if (!planned.has(key(id))) errors.push({ code: 'uncovered_requirement', requirement: id, detail: `requirement ${id} is in the roadmap for phase ${phaseNumber} but no plan lists it` });
112
+ }
113
+ if (required.length) {
114
+ const known = new Set(required.map(key));
115
+ for (const p of plans) for (const r of p.requirements) {
116
+ if (!known.has(key(r))) warnings.push({ code: 'unknown_requirement', plan: p.id, requirement: r, detail: `plan ${p.id} lists ${r}, which the roadmap does not give phase ${phaseNumber}` });
117
+ }
118
+ }
119
+
120
+ // 2. Dependencies: each must name a plan of this phase in an earlier wave.
121
+ const resolved = new Map(); // plan id → ids of the plans it depends on
122
+ for (const p of plans) {
123
+ const deps = [];
124
+ for (const d of p.depends_on) {
125
+ if (isOtherPhase(d, phaseNumber)) continue; // a finished phase's plan: not this set's to check
126
+ const target = plans.find((q) => q.id !== p.id && dependencyNames(d, q.id));
127
+ if (!target) {
128
+ if (dependencyNames(d, p.id)) errors.push({ code: 'self_reference', plan: p.id, detail: `plan ${p.id} depends on itself` });
129
+ else errors.push({ code: 'missing_reference', plan: p.id, depends_on: d, detail: `plan ${p.id} depends on "${d}", which names no plan in this phase` });
130
+ continue;
131
+ }
132
+ deps.push(target.id);
133
+ if (target.wave >= p.wave) {
134
+ errors.push({ code: 'future_reference', plan: p.id, depends_on: target.id, detail: `plan ${p.id} (wave ${p.wave}) depends on ${target.id} (wave ${target.wave}); a dependency must be in an earlier wave` });
135
+ }
136
+ }
137
+ resolved.set(p.id, deps);
138
+ const depWaves = deps.map((id) => plans.find((q) => q.id === id).wave);
139
+ const expected = depWaves.length ? Math.max(...depWaves) + 1 : 1;
140
+ if (p.wave > expected) {
141
+ warnings.push({ code: 'wave_gap', plan: p.id, detail: `plan ${p.id} is in wave ${p.wave} but its dependencies allow wave ${expected}` });
142
+ }
143
+ }
144
+
145
+ // 3. Cycles.
146
+ const state = new Map();
147
+ const reported = new Set();
148
+ const visit = (id, trail) => {
149
+ if (state.get(id) === 'done') return;
150
+ if (state.get(id) === 'active') {
151
+ const cycle = [...trail.slice(trail.indexOf(id)), id];
152
+ const sig = [...new Set(cycle)].sort().join(',');
153
+ if (!reported.has(sig)) { reported.add(sig); errors.push({ code: 'cycle', plans: cycle, detail: `dependency cycle: ${cycle.join(' → ')}` }); }
154
+ return;
155
+ }
156
+ state.set(id, 'active');
157
+ for (const d of resolved.get(id) || []) visit(d, [...trail, id]);
158
+ state.set(id, 'done');
159
+ };
160
+ for (const p of plans) visit(p.id, []);
161
+
162
+ // 4. Same-wave file overlap: two plans that run in parallel must not edit one file.
163
+ const dependsOn = (a, b, seen = new Set()) => {
164
+ if (seen.has(a)) return false;
165
+ seen.add(a);
166
+ return (resolved.get(a) || []).some((d) => d === b || dependsOn(d, b, seen));
167
+ };
168
+ for (let i = 0; i < plans.length; i++) {
169
+ for (let j = i + 1; j < plans.length; j++) {
170
+ const a = plans[i];
171
+ const b = plans[j];
172
+ if (a.wave !== b.wave) continue;
173
+ const shared = a.files_modified.filter((f) => b.files_modified.includes(f));
174
+ if (!shared.length || dependsOn(a.id, b.id) || dependsOn(b.id, a.id)) continue;
175
+ errors.push({ code: 'same_wave_overlap', plans: [a.id, b.id], files: shared, detail: `plans ${a.id} and ${b.id} both modify ${shared.join(', ')} in wave ${a.wave} with no dependency between them` });
176
+ }
177
+ }
178
+
179
+ return { phase: phaseNumber, ok: errors.length === 0, errors, warnings, plans: plans.length, plans_sha: plansSha(pf.dir, pf.planFiles) };
180
+ }
181
+
182
+ /**
183
+ * Whether the plans exec-phase is about to run are the plans that passed a plan check.
184
+ * @returns {{status: 'ok'|'stale'|'failed'|'missing'|'legacy'|'disabled', reason?: string, plans_sha?: string, checked_sha?: string, attempt?: number}}
185
+ */
186
+ function planCheckStatus(cwd, phase) {
187
+ const config = loadConfig(cwd);
188
+ if (config.plan_checker === false) return { status: 'disabled', reason: 'workflow.plan_check is off in config.json' };
189
+ const F = require('./findings.cjs');
190
+ const V = require('./verdict.cjs');
191
+ const { verdicts } = F.foldFindings(F.readLedger(cwd).rows);
192
+ const mine = verdicts.filter((v) => v.agent === PLAN_CHECKER && V.comparablePhase(v.phase) === V.comparablePhase(phase));
193
+ const last = mine[mine.length - 1];
194
+ const current = plansShaFor(cwd, phase);
195
+ if (!last) return { status: 'missing', reason: 'no plan check is recorded for this phase', plans_sha: current };
196
+ if (last.outcome !== 'pass') return { status: 'failed', reason: `the last plan check (attempt ${last.attempt}) found issues`, attempt: last.attempt, plans_sha: current };
197
+ if (!last.plans_sha) return { status: 'legacy', reason: 'the plan check was recorded before plans were hashed, so it cannot show the plans are unchanged', attempt: last.attempt, plans_sha: current };
198
+ if (last.plans_sha !== current) return { status: 'stale', reason: 'the plans changed after they passed the plan check', attempt: last.attempt, checked_sha: last.plans_sha, plans_sha: current };
199
+ return { status: 'ok', attempt: last.attempt, plans_sha: current };
200
+ }
201
+
202
+ /**
203
+ * Plans not yet executed whose modified files changed since they were planned.
204
+ * @returns {Array<{plan, planned_commit, files?: string[], error?: string}>}
205
+ */
206
+ function planDrift(cwd, phase) {
207
+ const pf = phaseFiles(cwd, phase);
208
+ if (pf.error || !pf.planFiles.length) return [];
209
+ const out = [];
210
+ for (const p of loadPlans(pf)) {
211
+ if (p.has_summary || !p.planned_commit || !p.files_modified.length) continue;
212
+ if (!/^[0-9a-f]{4,40}$/i.test(p.planned_commit)) { out.push({ plan: p.id, planned_commit: p.planned_commit, error: 'not a commit sha' }); continue; }
213
+ const r = execGit(cwd, ['diff', '--name-only', p.planned_commit, 'HEAD', '--']);
214
+ if (r.exitCode !== 0) { out.push({ plan: p.id, planned_commit: p.planned_commit, error: 'unknown commit' }); continue; }
215
+ const changed = new Set(r.stdout.split(/\r?\n/).map(normFile).filter(Boolean));
216
+ const files = p.files_modified.filter((f) => changed.has(f));
217
+ if (files.length) out.push({ plan: p.id, planned_commit: p.planned_commit, files });
218
+ }
219
+ return out;
220
+ }
221
+
222
+ function cmdVerifyPlanSet(cwd, phase, raw) {
223
+ if (!phase) error('Usage: verify plan-set <phase>');
224
+ const r = checkPlanSet(cwd, phase);
225
+ if (r.error) { output(r, raw); return; }
226
+ const result = { ...r, plan_check: planCheckStatus(cwd, phase), drift: planDrift(cwd, phase) };
227
+ output(result, raw, r.ok ? 'valid' : 'invalid', r.ok ? 0 : 1);
228
+ }
229
+
230
+ module.exports = { checkPlanSet, plansSha, plansShaFor, planCheckStatus, planDrift, cmdVerifyPlanSet, PLAN_CHECKER };
@@ -7,7 +7,7 @@
7
7
  const fs = require('fs');
8
8
  const path = require('path');
9
9
  const { safeReadFile, execGit, findPhaseInternal, output, normalizePhaseName } = require('./core.cjs');
10
- const { readStateSafe } = require('./state.cjs');
10
+ const { readStateSafe, activeBlockersFromState } = require('./state.cjs');
11
11
  const {
12
12
  STATE_FILE, ROADMAP_FILE, CONFIG_FILE, PATTERNS_FILE, PHASE_DIR_RE,
13
13
  } = require('./constants.cjs');
@@ -44,19 +44,10 @@ function cmdPreflight(cwd, target, raw) {
44
44
  blockers.push('state.md not found — run /pan:new-project');
45
45
  }
46
46
 
47
- // Check 3: no unresolved blockers in state.md
47
+ // Check 3: no unresolved blockers in state.md (the template's `### Blockers/Concerns`,
48
+ // where `state add-blocker` writes, or a legacy `## Blockers`)
48
49
  if (stateContent) {
49
- const blockersMatch = stateContent.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
50
- const activeBlockers = [];
51
- if (blockersMatch) {
52
- const items = blockersMatch[1].match(/^-\s+(.+)$/gm) || [];
53
- for (const item of items) {
54
- const text = item.replace(/^-\s+/, '').trim();
55
- if (text && !/^none$/i.test(text)) {
56
- activeBlockers.push(text);
57
- }
58
- }
59
- }
50
+ const activeBlockers = activeBlockersFromState(stateContent);
60
51
  if (activeBlockers.length === 0) {
61
52
  checks.push({ name: 'no_blockers', passed: true });
62
53
  } else {