pan-wizard 3.22.0 → 3.24.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 (134) hide show
  1. package/README.md +38 -24
  2. package/agents/pan-conductor.md +3 -3
  3. package/agents/pan-design-checker.md +83 -0
  4. package/agents/pan-designer.md +67 -0
  5. package/agents/pan-document_code.md +3 -2
  6. package/agents/pan-executor.md +2 -2
  7. package/agents/pan-plan-checker.md +19 -6
  8. package/agents/pan-planner.md +4 -1
  9. package/agents/pan-previewer.md +1 -1
  10. package/agents/pan-roadmapper.md +3 -1
  11. package/agents/pan-verifier.md +10 -10
  12. package/bin/install-lib.cjs +128 -17
  13. package/bin/install.js +480 -74
  14. package/commands/pan/army.md +20 -14
  15. package/commands/pan/audit-deployment.md +17 -14
  16. package/commands/pan/cost.md +2 -2
  17. package/commands/pan/debug.md +1 -1
  18. package/commands/pan/design-phase.md +77 -0
  19. package/commands/pan/exec-phase.md +2 -2
  20. package/commands/pan/experiment.md +18 -14
  21. package/commands/pan/focus-auto.md +5 -5
  22. package/commands/pan/focus-design.md +13 -1
  23. package/commands/pan/focus-exec.md +1 -1
  24. package/commands/pan/learn.md +1 -1
  25. package/commands/pan/links.md +3 -1
  26. package/commands/pan/map-codebase.md +4 -4
  27. package/commands/pan/mcp-bridge.md +1 -1
  28. package/commands/pan/milestone-new.md +3 -1
  29. package/commands/pan/optimize.md +2 -2
  30. package/commands/pan/patches.md +1 -1
  31. package/commands/pan/plan-phase.md +3 -1
  32. package/commands/pan/preview.md +3 -3
  33. package/commands/pan/profile.md +3 -3
  34. package/commands/pan/research-phase.md +1 -1
  35. package/commands/pan/retro.md +4 -1
  36. package/commands/pan/review-deep.md +1 -1
  37. package/commands/pan/settings.md +2 -2
  38. package/commands/pan/what-if.md +1 -1
  39. package/hooks/dist/pan-check-update.js +149 -38
  40. package/hooks/dist/pan-context-monitor.js +155 -81
  41. package/hooks/dist/pan-cost-logger.js +288 -13
  42. package/hooks/dist/pan-statusline.js +24 -8
  43. package/hooks/dist/pan-stop-guard.js +160 -0
  44. package/hooks/dist/pan-trace-logger.js +261 -19
  45. package/package.json +2 -2
  46. package/pan-wizard-core/bin/lib/bridge.cjs +1 -1
  47. package/pan-wizard-core/bin/lib/bus.cjs +19 -4
  48. package/pan-wizard-core/bin/lib/campaign.cjs +8 -3
  49. package/pan-wizard-core/bin/lib/codebase.cjs +15 -5
  50. package/pan-wizard-core/bin/lib/commands.cjs +88 -6
  51. package/pan-wizard-core/bin/lib/config.cjs +27 -2
  52. package/pan-wizard-core/bin/lib/constants.cjs +1 -0
  53. package/pan-wizard-core/bin/lib/core.cjs +113 -8
  54. package/pan-wizard-core/bin/lib/cost.cjs +61 -24
  55. package/pan-wizard-core/bin/lib/distill.cjs +22 -4
  56. package/pan-wizard-core/bin/lib/doc-lint.cjs +25 -7
  57. package/pan-wizard-core/bin/lib/experiment.cjs +25 -1
  58. package/pan-wizard-core/bin/lib/focus.cjs +42 -7
  59. package/pan-wizard-core/bin/lib/frontmatter.cjs +62 -31
  60. package/pan-wizard-core/bin/lib/git.cjs +51 -16
  61. package/pan-wizard-core/bin/lib/hud.cjs +17 -2
  62. package/pan-wizard-core/bin/lib/init.cjs +2 -5
  63. package/pan-wizard-core/bin/lib/knowledge.cjs +20 -1
  64. package/pan-wizard-core/bin/lib/learn-lint.cjs +50 -0
  65. package/pan-wizard-core/bin/lib/links.cjs +9 -6
  66. package/pan-wizard-core/bin/lib/lock.cjs +23 -4
  67. package/pan-wizard-core/bin/lib/memory-optimize.cjs +11 -6
  68. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  69. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  70. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  71. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  72. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  73. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  74. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  75. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  76. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  77. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  78. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  79. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  80. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  81. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  82. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  83. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  84. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  85. package/pan-wizard-core/learnings/index.json +7 -7
  86. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  87. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  88. package/pan-wizard-core/references/design-methodology.md +94 -0
  89. package/pan-wizard-core/references/git-integration.md +9 -9
  90. package/pan-wizard-core/references/guardrails.md +4 -1
  91. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  92. package/pan-wizard-core/references/model-profiles.md +44 -34
  93. package/pan-wizard-core/references/planning-config.md +5 -5
  94. package/pan-wizard-core/references/verification-patterns.md +67 -0
  95. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  96. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  97. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  98. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  99. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  100. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  101. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  102. package/pan-wizard-core/templates/design.md +146 -0
  103. package/pan-wizard-core/templates/discovery.md +3 -3
  104. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  105. package/pan-wizard-core/templates/playbook.md +1 -1
  106. package/pan-wizard-core/templates/uat.md +1 -1
  107. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  108. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  109. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  110. package/pan-wizard-core/workflows/health.md +24 -1
  111. package/pan-wizard-core/workflows/help.md +5 -4
  112. package/pan-wizard-core/workflows/learn.md +5 -5
  113. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  114. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  116. package/pan-wizard-core/workflows/new-project.md +47 -32
  117. package/pan-wizard-core/workflows/optimize.md +8 -8
  118. package/pan-wizard-core/workflows/pause.md +1 -1
  119. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  120. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  121. package/pan-wizard-core/workflows/profile.md +13 -10
  122. package/pan-wizard-core/workflows/research-phase.md +1 -1
  123. package/pan-wizard-core/workflows/resume-project.md +1 -1
  124. package/pan-wizard-core/workflows/settings.md +9 -9
  125. package/pan-wizard-core/workflows/transition.md +24 -8
  126. package/pan-wizard-core/workflows/update.md +23 -20
  127. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  128. package/pan-zcode/README.md +12 -2
  129. package/pan-zcode/bin/install-zcode.js +1 -1
  130. package/pan-zcode/mcp/native-tools.cjs +1 -1
  131. package/pan-zcode/mcp/server.cjs +76 -14
  132. package/scripts/build-hooks.js +2 -1
  133. package/scripts/generate-skills-docs.py +0 -4
  134. package/scripts/run-tests.cjs +11 -4
@@ -41,11 +41,19 @@ function cmdPhasesList(cwd, options, raw) {
41
41
  // Get all phase directories
42
42
  let dirs = entries.filter(entry => entry.isDirectory()).map(entry => entry.name);
43
43
 
44
- // Include archived phases if requested
44
+ // Include archived phases if requested. Archived entries are DECORATED
45
+ // display strings ("<name> [<milestone>]") that do not exist under
46
+ // phasesDir — their real location is getArchivedPhaseDirs()'s fullPath
47
+ // (under .planning/milestones/). M24: track that real path so a later
48
+ // --type read opens the archive dir instead of crashing with ENOENT on the
49
+ // decorated name joined to phasesDir.
50
+ const archivedPaths = new Map();
45
51
  if (includeArchived) {
46
52
  const archived = getArchivedPhaseDirs(cwd);
47
53
  for (const arch of archived) {
48
- dirs.push(`${arch.name} [${arch.milestone}]`);
54
+ const decorated = `${arch.name} [${arch.milestone}]`;
55
+ dirs.push(decorated);
56
+ archivedPaths.set(decorated, arch.fullPath);
49
57
  }
50
58
  }
51
59
 
@@ -67,7 +75,7 @@ function cmdPhasesList(cwd, options, raw) {
67
75
  if (type) {
68
76
  const files = [];
69
77
  for (const dir of dirs) {
70
- const dirPath = path.join(phasesDir, dir);
78
+ const dirPath = archivedPaths.get(dir) || path.join(phasesDir, dir);
71
79
  const dirFiles = fs.readdirSync(dirPath);
72
80
 
73
81
  let filtered;
@@ -347,6 +355,32 @@ function buildPlanIndex(phaseDir, planFiles, summaryFiles) {
347
355
  return { plans, waves, incomplete, hasCheckpoints };
348
356
  }
349
357
 
358
+ /**
359
+ * Add a `- [ ] **Phase N: name**` entry to the roadmap's checklist, after the last
360
+ * existing entry.
361
+ *
362
+ * Returns the content unchanged when the roadmap has no checklist at all — some
363
+ * roadmaps only carry detail sections, and inventing a list there would be a bigger
364
+ * surprise than omitting one entry. Format matches templates/roadmap.md exactly, so
365
+ * the entry is picked up by the same parsers that read the template's own output.
366
+ *
367
+ * @param {string} content - Full roadmap.md content
368
+ * @param {string|number} phaseNum - Phase number as it should appear
369
+ * @param {string} name - Phase name/description
370
+ * @returns {string} Updated content
371
+ */
372
+ function appendPhaseChecklistEntry(content, phaseNum, name) {
373
+ const lines = content.split('\n');
374
+ const isChecklistLine = (l) => /^\s*- \[[ x]\]\s*(?:\*\*)?Phase\s+\d/i.test(l);
375
+ let lastIdx = -1;
376
+ for (let i = 0; i < lines.length; i++) {
377
+ if (isChecklistLine(lines[i])) lastIdx = i;
378
+ }
379
+ if (lastIdx === -1) return content; // no checklist to extend
380
+ lines.splice(lastIdx + 1, 0, `- [ ] **Phase ${phaseNum}: ${name}**`);
381
+ return lines.join('\n');
382
+ }
383
+
350
384
  /**
351
385
  * Append a new phase to the end of the roadmap and create its directory.
352
386
  * @param {string} cwd - Working directory path
@@ -402,6 +436,13 @@ function cmdPhaseAdd(cwd, description, raw) {
402
436
  updatedContent = content + phaseEntry;
403
437
  }
404
438
 
439
+ // Also add the checklist entry. Only the detail section was written before, so a
440
+ // phase created by `phase add` was permanently absent from the roadmap's "## Phases"
441
+ // list — the surface users read, and the one preview/progress consumers parse. Worse,
442
+ // `phase complete` then reported roadmap_updated:true having changed nothing, because
443
+ // it ticks a checklist line that was never there.
444
+ updatedContent = appendPhaseChecklistEntry(updatedContent, newPhaseNum, description);
445
+
405
446
  try {
406
447
  fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
407
448
  } catch (e) {
@@ -501,7 +542,10 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
501
542
  insertIdx = content.length;
502
543
  }
503
544
 
504
- const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
545
+ // Insert the detail section, then the matching checklist entry — same reason as
546
+ // cmdPhaseAdd: without it the inserted phase never appears in the "## Phases" list.
547
+ const withEntry = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
548
+ const updatedContent = appendPhaseChecklistEntry(withEntry, decimalPhase, description);
505
549
  try {
506
550
  fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
507
551
  } catch (e) {
@@ -542,20 +586,33 @@ function markPhaseCompleteInRoadmap(cwd, phaseNum, _phaseName, planCount, summar
542
586
  try {
543
587
  roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
544
588
  } catch {
545
- return;
589
+ return { roadmap_warning: `${ROADMAP_FILE} unreadable — phase completion was not recorded in the roadmap` };
546
590
  }
547
591
 
592
+ // Match the phase number the way the roadmap spells it, not the way the
593
+ // caller spelled it. Callers pass "01" (the dir/state spelling) as often as
594
+ // "1", while the template checklist says "Phase 1:" — matching on the raw
595
+ // argument made the tick a silent no-op for padded calls: state advanced,
596
+ // checkbox stayed unticked (PanLoop finding 8). `0*` on the unpadded form
597
+ // accepts both spellings; decimals ("1.1") survive the strip.
598
+ const unpadded = String(phaseNum).replace(/^0+(?=\d)/, '');
599
+ const numPat = `0*${escapeRegex(unpadded)}`;
600
+
601
+ // A tick that silently misses is indistinguishable from one that landed, so
602
+ // detect up front whether ANY checklist entry (ticked or not) names this
603
+ // phase — if none does, say so instead of best-effort-ing past it.
604
+ const hasChecklistEntry = new RegExp(`-\\s*\\[[ xX]\\]\\s*.*Phase\\s+${numPat}[:\\s]`, 'i').test(roadmapContent);
605
+
548
606
  // Checkbox: - [ ] Phase N: -> - [x] Phase N: (...completed DATE)
549
607
  const checkboxPattern = new RegExp(
550
- `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
608
+ `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${numPat}[:\\s][^\\n]*)`,
551
609
  'i'
552
610
  );
553
611
  roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
554
612
 
555
613
  // Progress table: update Plans Complete, Status, and Date columns
556
- const phaseEscaped = escapeRegex(phaseNum);
557
614
  const tablePattern = new RegExp(
558
- `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
615
+ `(\\|\\s*${numPat}\\.?\\s[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
559
616
  'i'
560
617
  );
561
618
  roadmapContent = roadmapContent.replace(
@@ -565,7 +622,7 @@ function markPhaseCompleteInRoadmap(cwd, phaseNum, _phaseName, planCount, summar
565
622
 
566
623
  // Update plan count in phase section
567
624
  const planCountPattern = new RegExp(
568
- `(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?(?:\\*\\*Plans:\\*\\*|\\*\\*Plans\\*\\*:)\\s*)[^\\n]+`,
625
+ `(#{2,4}\\s*Phase\\s+${numPat}[\\s\\S]*?(?:\\*\\*Plans:\\*\\*|\\*\\*Plans\\*\\*:)\\s*)[^\\n]+`,
569
626
  'i'
570
627
  );
571
628
  roadmapContent = roadmapContent.replace(
@@ -573,14 +630,21 @@ function markPhaseCompleteInRoadmap(cwd, phaseNum, _phaseName, planCount, summar
573
630
  `$1${summaryCount}/${planCount} plans complete`
574
631
  );
575
632
 
633
+ let roadmapWarning = hasChecklistEntry
634
+ ? null
635
+ : `no roadmap checklist entry found for Phase ${phaseNum} — checkbox not ticked; check the "- [ ] **Phase N:" line in ${ROADMAP_FILE}`;
636
+
576
637
  try {
577
638
  fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
578
639
  } catch {
579
- // Roadmap write is best-effort during phase completion
640
+ roadmapWarning = `${ROADMAP_FILE} could not be written — phase completion was not recorded in the roadmap`;
580
641
  }
581
642
 
582
643
  const reqResult = markRequirementsCompleteForPhase(cwd, phaseNum, roadmapContent);
583
- return { requirements_warning: reqResult && reqResult.error ? reqResult.error : null };
644
+ return {
645
+ requirements_warning: reqResult && reqResult.error ? reqResult.error : null,
646
+ roadmap_warning: roadmapWarning,
647
+ };
584
648
  }
585
649
 
586
650
  /**
@@ -779,6 +843,13 @@ function cmdPhaseComplete(cwd, phaseNum, raw, opts) {
779
843
  if (roadmapResult && roadmapResult.requirements_warning) {
780
844
  result.requirements_warning = roadmapResult.requirements_warning;
781
845
  }
846
+ if (roadmapResult && roadmapResult.roadmap_warning) {
847
+ // roadmap_updated previously meant "roadmap.md exists", which reads as
848
+ // "the tick landed" and masked a silently missed checkbox (finding 8).
849
+ // When the tick did not land, say so on both fields.
850
+ result.roadmap_warning = roadmapResult.roadmap_warning;
851
+ result.roadmap_updated = false;
852
+ }
782
853
 
783
854
  // Auto-commit .planning/ metadata unless --no-commit or not a git repo
784
855
  const noCommit = opts && opts.noCommit;
@@ -139,7 +139,7 @@ function buildPhasePreview(cwd, phaseNum) {
139
139
 
140
140
  return {
141
141
  phase: String(phaseNum),
142
- phase_name: phaseInfo.name || (roadmapPhase && roadmapPhase.phase_name) || null,
142
+ phase_name: phaseInfo.phase_name || (roadmapPhase && roadmapPhase.phase_name) || null,
143
143
  directory: toPosix(phaseInfo.directory),
144
144
  status,
145
145
  plan_count: planFiles.length,
@@ -269,11 +269,30 @@ function buildPhaseDependencyGraph(cwd) {
269
269
  };
270
270
  }
271
271
 
272
+ // Checklist entries come in two shapes. templates/roadmap.md prescribes the bold
273
+ // one WITH a trailing description:
274
+ // - [ ] **Phase 1: Foundation** - Core scaffolding
275
+ // and hand-written roadmaps often use the plain one:
276
+ // - [ ] Phase 1: Foundation
277
+ //
278
+ // A single regex handled these badly. The previous pattern
279
+ // (`([^\n*]+?)(?:\*\*)?\s*$`) required the line to END at the closing `**`, so
280
+ // every template-shaped line — the format PAN itself writes — failed to match and
281
+ // `preview phases` reported a phase_count with an EMPTY phases array, contradicting
282
+ // itself. Widening the name group to allow a trailing description then broke names
283
+ // containing a hyphen, because the lazy match stopped at the first one.
284
+ //
285
+ // Matching bold-first is what resolves it: `[^*\n]+` cannot cross `*`, so it runs
286
+ // to the closing `**` regardless of hyphens inside the name, and anything after it
287
+ // is description we discard.
288
+ const CHECKLIST_BOLD_RE = /^\s*- \[([ x])\]\s*\*\*Phase\s+(\d+(?:\.\d+)?)\s*[:\-—]?\s*([^*\n]+?)\s*\*\*/i;
289
+ const CHECKLIST_PLAIN_RE = /^\s*- \[([ x])\]\s*Phase\s+(\d+(?:\.\d+)?)\s*[:\-—]?\s*([^\n]+?)\s*$/i;
290
+
272
291
  function extractPhaseListFromRoadmap(content) {
273
292
  const phases = [];
274
- const re = /- \[([ x])\]\s*(?:\*\*)?Phase\s+(\d+(?:\.\d+)?)\s*[:\-—]?\s*([^\n*]+?)(?:\*\*)?\s*$/gim;
275
- let m;
276
- while ((m = re.exec(content)) !== null) {
293
+ for (const line of String(content).split('\n')) {
294
+ const m = line.match(CHECKLIST_BOLD_RE) || line.match(CHECKLIST_PLAIN_RE);
295
+ if (!m) continue;
277
296
  phases.push({
278
297
  num: m[2],
279
298
  name: m[3].trim(),
@@ -246,11 +246,19 @@ function writeDeepReview(cwd, phaseNum, payload, opts) {
246
246
 
247
247
  // ─── CLI wrappers ───────────────────────────────────────────────────────────
248
248
 
249
+ // M25: input files must resolve against the passed cwd, not process.cwd().
250
+ // A relative --reviewer-file/--hardener-file/--meta-file was read from the
251
+ // process working directory, silently missing the file when --cwd differed.
252
+ function resolveAgainstCwd(cwd, p) {
253
+ if (!p) return p;
254
+ return path.isAbsolute(p) ? p : path.join(cwd, p);
255
+ }
256
+
249
257
  function cmdReviewDeepMerge(cwd, phaseNum, opts, raw) {
250
258
  if (!phaseNum) error('Usage: review-deep merge <phase> --reviewer-file X --hardener-file Y [--meta-file Z]');
251
- const reviewerContent = opts.reviewerFile ? safeReadFile(opts.reviewerFile) : '';
252
- const hardenerContent = opts.hardenerFile ? safeReadFile(opts.hardenerFile) : '';
253
- const metaContent = opts.metaFile ? safeReadFile(opts.metaFile) : '';
259
+ const reviewerContent = opts.reviewerFile ? safeReadFile(resolveAgainstCwd(cwd, opts.reviewerFile)) : '';
260
+ const hardenerContent = opts.hardenerFile ? safeReadFile(resolveAgainstCwd(cwd, opts.hardenerFile)) : '';
261
+ const metaContent = opts.metaFile ? safeReadFile(resolveAgainstCwd(cwd, opts.metaFile)) : '';
254
262
  if (!reviewerContent && !hardenerContent && !metaContent) {
255
263
  output({ error: 'No input files provided or readable' }, raw);
256
264
  return;
@@ -264,9 +272,9 @@ function cmdReviewDeepMerge(cwd, phaseNum, opts, raw) {
264
272
  function cmdReviewDeepAnalyze(cwd, phaseNum, opts, raw) {
265
273
  // Returns the merged payload WITHOUT writing a file. Useful for piping.
266
274
  if (!phaseNum) error('Usage: review-deep analyze <phase> --reviewer-file X --hardener-file Y [--meta-file Z]');
267
- const reviewerContent = opts.reviewerFile ? safeReadFile(opts.reviewerFile) : '';
268
- const hardenerContent = opts.hardenerFile ? safeReadFile(opts.hardenerFile) : '';
269
- const metaContent = opts.metaFile ? safeReadFile(opts.metaFile) : '';
275
+ const reviewerContent = opts.reviewerFile ? safeReadFile(resolveAgainstCwd(cwd, opts.reviewerFile)) : '';
276
+ const hardenerContent = opts.hardenerFile ? safeReadFile(resolveAgainstCwd(cwd, opts.hardenerFile)) : '';
277
+ const metaContent = opts.metaFile ? safeReadFile(resolveAgainstCwd(cwd, opts.metaFile)) : '';
270
278
  output(mergeReviews(reviewerContent, hardenerContent, metaContent), raw);
271
279
  }
272
280
 
@@ -336,6 +336,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
336
336
  const summaryCount = phaseInfo.summaries.length;
337
337
 
338
338
  if (planCount === 0) {
339
+ // No error key, exit 0: a phase with no plans has nothing to sync. The roadmap
340
+ // already says what it should say.
339
341
  output({ updated: false, reason: 'No plans found', plan_count: 0, summary_count: 0 }, raw, 'no plans');
340
342
  return;
341
343
  }
@@ -348,7 +350,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
348
350
  try {
349
351
  roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
350
352
  } catch {
351
- output({ updated: false, reason: 'roadmap.md not found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
353
+ // error key => exit 1: there are plans to record and nowhere to record them.
354
+ output({ updated: false, reason: 'roadmap.md not found', error: 'roadmap_not_found', plan_count: planCount, summary_count: summaryCount }, raw, 'no roadmap');
352
355
  return;
353
356
  }
354
357
  const phaseEscaped = escapeRegex(phaseNum);
@@ -400,7 +403,8 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
400
403
  try {
401
404
  fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
402
405
  } catch (err) {
403
- output({ updated: false, reason: 'Failed to write roadmap.md: ' + err.message }, raw, 'write error');
406
+ // error key => exit 1: the write failed, so progress was computed and then lost.
407
+ output({ updated: false, reason: 'Failed to write roadmap.md: ' + err.message, error: err.message || 'roadmap_write_failed' }, raw, 'write error');
404
408
  return;
405
409
  }
406
410
 
@@ -13,7 +13,8 @@
13
13
  * Exports:
14
14
  * - runExperiment(slug, opts) — spawn + observe + return result
15
15
  * - tailExperimentState(slug, opts) — read run-state.json snapshot
16
- * - stopExperiment(slug, opts) — graceful halt of a running experiment
16
+ * - stopExperiment(slug, opts) — reconcile the run-state of an experiment
17
+ * (see its doc for the spawnSync limitation)
17
18
  * - RUNTIME_RUNNERS — adapter map (per-runtime headless invocation)
18
19
  */
19
20
 
@@ -170,7 +171,7 @@ function appendEvent(state, type, details) {
170
171
  * @param {string} [opts.root] - experiment root (default PAN_EXPERIMENTS_ROOT_DEFAULT)
171
172
  * @param {string} [opts.prompt] - prompt passed to the external runtime; default
172
173
  * is `/pan:new-project --auto @.planning/idea.md`
173
- * @param {number} [opts.timeoutMs] - hard timeout (default 30 min)
174
+ * @param {number} [opts.timeoutMs] - hard timeout (default 60 min)
174
175
  * @param {object} [opts.runtimeOverride] - { bin, buildArgs } to bypass the manifest's
175
176
  * runtime adapter (used by tests)
176
177
  * @param {function} [opts.onProgress] - callback invoked per line of stdout/stderr
@@ -415,13 +416,22 @@ function tailExperimentState(slug, opts = {}) {
415
416
  // ── stopExperiment ──────────────────────────────────────────────────────────
416
417
 
417
418
  /**
418
- * Stop a running experiment.
419
+ * Reconcile the run-state of an experiment.
419
420
  *
420
- * If the experiment is currently running (run-state.json shows status=running
421
- * and pid is alive), send SIGTERM. If still alive after a short grace period,
422
- * SIGKILL.
421
+ * IMPORTANT (M26): this CANNOT terminate a live, in-flight run. runExperiment()
422
+ * uses a synchronous spawnSync (see its comments — a real-time/async runner is
423
+ * deferred to W3), which blocks until the child exits and only learns the child
424
+ * pid FROM the return value. So while a run is in flight run-state.json still has
425
+ * pid=null, and by the time a pid is recorded the process has already exited.
426
+ * There is therefore no live child for this function to signal.
423
427
  *
424
- * If the experiment has already finished, return its current state (no error).
428
+ * Behavior:
429
+ * - status != 'running' → return the current state (already finished; no error).
430
+ * - status == 'running' but no pid recorded → return an error explaining that
431
+ * the synchronous runner does not expose a live pid to signal.
432
+ * - status == 'running' with a (stale) pid → best-effort SIGTERM and mark the
433
+ * run failed/manual. Note the pid is almost certainly already dead under the
434
+ * spawnSync model; live termination lands with the W3 async runner.
425
435
  */
426
436
  function stopExperiment(slug, opts = {}) {
427
437
  const root = opts.root || PAN_EXPERIMENTS_ROOT_DEFAULT;
@@ -1,10 +1,31 @@
1
1
  /**
2
2
  * Squads — agent groupings for the bot-army model (ADR-0032).
3
3
  *
4
- * A squad is a named, tool-scoped, model-tiered grouping of existing PAN
5
- * agents under the pan-conductor coordinator. This module is a registry +
6
- * resolver only — it modifies no agent and changes no execution path. The
7
- * army campaign command (ADR-0033) consumes it; until then it is inert.
4
+ * A squad is a named grouping of existing PAN agents under the pan-conductor
5
+ * coordinator, labelled with an intended model tier and an intended access
6
+ * contract. This module is a registry + resolver only — it modifies no agent
7
+ * and changes no execution path. `/pan:army` (ADR-0033) and `hud.cjs` read it;
8
+ * `grep -rn "squads.cjs')" pan-wizard-core/` enumerates its consumers.
9
+ *
10
+ * BOTH LABELS BELOW ARE ADVISORY METADATA, NOT ENFORCEMENT. Read this before
11
+ * writing any doc sentence about them, because the live docs now quote this
12
+ * header as the authority for exactly that (`grep -rn 'changes no execution
13
+ * path' docs commands` finds them):
14
+ *
15
+ * - `access` is the intended least-privilege contract the conductor's prompt is
16
+ * told to honour when it delegates. Nothing here strips a tool from an agent
17
+ * or narrows a grant at spawn time. The binding grant is each agent's own
18
+ * `tools:` frontmatter (`grep '^tools:' agents/*.md`), and it can be wider
19
+ * than the label: several members of a `read-only` squad hold `Write` so they
20
+ * can emit planning or verification artifacts, and the coordinator itself
21
+ * holds `Write` and `Bash`. So a squad agent doing more than its label says
22
+ * is the expected case, not a bug in this file.
23
+ * - `tier` is a squad grouping attribute, not the resolver of anybody's model.
24
+ * The active `model_profile` plus any per-agent `model:` pin decides that.
25
+ *
26
+ * The one rail the grants do enforce is delegation depth: only an agent granted
27
+ * `Task` can spawn another (`grep -l '^tools:.*Task' agents/*.md`), so squad
28
+ * members cannot fan out further.
8
29
  */
9
30
 
10
31
  'use strict';
@@ -14,13 +35,21 @@ const { output, error } = require('./core.cjs');
14
35
  /**
15
36
  * Coordinator + worker/utility agents that are NOT squad members.
16
37
  * - coordinator: the top of the hierarchy (Tier 0).
17
- * - workers: cheap narrow-job agents (Tier 2) + standalone utility agents
38
+ * - workers: narrow-job agents (Tier 2) + standalone utility agents
18
39
  * invoked directly by commands, not delegated through a squad.
40
+ *
41
+ * "Tier 2" is a hierarchy position, not a model tier. Post-COST-RESET (2026-07)
42
+ * every agent in MODEL_PROFILES resolves to `reasoning` under both quality and
43
+ * balanced (the default); `budget` is the only profile that down-tiers, and it is
44
+ * the profile — never this list — that decides what an agent drops to. Note it
45
+ * down-tiers per agent, not per group: `budget` sends some of the workers below
46
+ * to `fast` and others to `mid`. MODEL_PROFILES in core.cjs is the table; read it
47
+ * rather than assuming one tier covers a row of a doc.
19
48
  */
20
49
  const COORDINATOR = 'pan-conductor';
21
50
  const WORKERS = Object.freeze([
22
- 'pan-document_code', // Haiku-tier codebase mapper
23
- 'pan-distiller', // Haiku-tier code-bloat optimizer
51
+ 'pan-document_code', // codebase mapper
52
+ 'pan-distiller', // code-bloat optimizer
24
53
  'pan-optimizer', // optimization loop
25
54
  'pan-experiment-runner', // self-improvement loop
26
55
  'pan-knowledge', // retrieval/Q&A
@@ -29,9 +58,12 @@ const WORKERS = Object.freeze([
29
58
  ]);
30
59
 
31
60
  /**
32
- * The four squads, keyed by lifecycle role. `tier` is a PAN model tier
33
- * (resolve-model maps it to a provider model); `access` is the least-privilege
34
- * tool contract the conductor grants when delegating to the squad.
61
+ * The squads, keyed by lifecycle role — `squad list` enumerates them at runtime.
62
+ * `tier` is a PAN model tier label (resolve-model maps a tier to a provider
63
+ * model); `access` is the intended
64
+ * least-privilege contract the conductor is instructed to honour when delegating
65
+ * to the squad. Neither is applied by this module — see the advisory note in the
66
+ * file header before describing either one as a restriction.
35
67
  */
36
68
  const SQUADS = Object.freeze({
37
69
  architecture: Object.freeze({
@@ -40,7 +72,7 @@ const SQUADS = Object.freeze({
40
72
  access: 'read-only',
41
73
  summary: 'Designs the system before code — contract-first.',
42
74
  agents: Object.freeze([
43
- 'pan-roadmapper', 'pan-planner', 'pan-plan-checker',
75
+ 'pan-roadmapper', 'pan-planner', 'pan-designer', 'pan-plan-checker', 'pan-design-checker',
44
76
  'pan-project-researcher', 'pan-phase-researcher', 'pan-research-synthesizer',
45
77
  ]),
46
78
  }),
@@ -195,11 +195,16 @@ function cmdStateUpdate(cwd, field, value) {
195
195
  writeStateMd(statePath, content, cwd);
196
196
  output({ updated: true });
197
197
  } else {
198
- output({ updated: false, reason: `Field "${field}" not found in state.md` });
198
+ // error key ⇒ exit 1: the caller asked for a field update that did not
199
+ // happen, so state.md does not say what the caller now believes it says.
200
+ output({ updated: false, reason: `Field "${field}" not found in state.md`, error: 'field_not_found' });
199
201
  }
200
202
  } catch {
201
203
  // state.md does not exist or is unreadable -- report gracefully
202
- output({ updated: false, reason: 'state.md not found' });
204
+ // Same condition `state json` reports as {error:'state.md not found'} at exit 1.
205
+ // Reporting it as a bare `updated: false` was the identical defect wearing a
206
+ // different key — one file, one missing file, two exit codes.
207
+ output({ updated: false, reason: 'state.md not found', error: 'state_not_found' });
203
208
  }
204
209
  }
205
210
 
@@ -256,6 +261,9 @@ function cmdStateAdvancePlan(cwd, raw) {
256
261
  content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
257
262
  content = stateReplaceField(content, 'Last Activity', today) || content;
258
263
  writeStateMd(statePath, content, cwd);
264
+ // No error key, exit 0: `last_plan` is the NORMAL end-of-phase signal — the
265
+ // phase advanced to "ready for verification" and state.md was written. Gating on
266
+ // it would make every completed phase look like a failure.
259
267
  output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
260
268
  } else {
261
269
  const newPlan = currentPlan + 1;
@@ -303,7 +311,7 @@ function cmdStateRecordMetric(cwd, options, raw) {
303
311
  writeStateMd(statePath, content, cwd);
304
312
  output({ recorded: true, phase, plan, duration }, raw, 'true');
305
313
  } else {
306
- output({ recorded: false, reason: 'Performance Metrics section not found in state.md' }, raw, 'false');
314
+ output({ recorded: false, reason: 'Performance Metrics section not found in state.md', error: 'metrics_section_missing' }, raw, 'false');
307
315
  }
308
316
  }
309
317
 
@@ -345,7 +353,7 @@ function cmdStateUpdateProgress(cwd, raw) {
345
353
  writeStateMd(statePath, content, cwd);
346
354
  output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
347
355
  } else {
348
- output({ updated: false, reason: 'Progress field not found in state.md' }, raw, 'false');
356
+ output({ updated: false, reason: 'Progress field not found in state.md', error: 'progress_field_missing' }, raw, 'false');
349
357
  }
350
358
  }
351
359
 
@@ -369,7 +377,7 @@ function cmdStateAddDecision(cwd, options, raw) {
369
377
  summaryText = readTextArgOrFile(cwd, summary, summary_file, 'summary');
370
378
  rationaleText = readTextArgOrFile(cwd, rationale || '', rationale_file, 'rationale');
371
379
  } catch (err) {
372
- output({ added: false, reason: err.message }, raw, 'false');
380
+ output({ added: false, reason: err.message, error: err.message || 'decision_read_failed' }, raw, 'false');
373
381
  return;
374
382
  }
375
383
 
@@ -389,7 +397,7 @@ function cmdStateAddDecision(cwd, options, raw) {
389
397
  writeStateMd(statePath, content, cwd);
390
398
  output({ added: true, decision: entry }, raw, 'true');
391
399
  } else {
392
- output({ added: false, reason: 'Decisions section not found in state.md' }, raw, 'false');
400
+ output({ added: false, reason: 'Decisions section not found in state.md', error: 'decisions_section_missing' }, raw, 'false');
393
401
  }
394
402
  }
395
403
 
@@ -410,7 +418,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
410
418
  try {
411
419
  blockerText = readTextArgOrFile(cwd, blockerOptions.text, blockerOptions.text_file, 'blocker');
412
420
  } catch (err) {
413
- output({ added: false, reason: err.message }, raw, 'false');
421
+ output({ added: false, reason: err.message, error: err.message || 'blocker_read_failed' }, raw, 'false');
414
422
  return;
415
423
  }
416
424
 
@@ -428,7 +436,7 @@ function cmdStateAddBlocker(cwd, text, raw) {
428
436
  writeStateMd(statePath, content, cwd);
429
437
  output({ added: true, blocker: blockerText }, raw, 'true');
430
438
  } else {
431
- output({ added: false, reason: 'Blockers section not found in state.md' }, raw, 'false');
439
+ output({ added: false, reason: 'Blockers section not found in state.md', error: 'blockers_section_missing' }, raw, 'false');
432
440
  }
433
441
  }
434
442
 
@@ -456,6 +464,17 @@ function cmdStateResolveBlocker(cwd, text, raw) {
456
464
  return !line.toLowerCase().includes(text.toLowerCase());
457
465
  });
458
466
 
467
+ // M27: if nothing was removed, no blocker matched the given text. Report
468
+ // resolved:false rather than falsely claiming a resolution (and skip the
469
+ // write entirely — the file is unchanged).
470
+ if (filtered.length === lines.length) {
471
+ // error key ⇒ exit 1. M27 stopped this from falsely claiming a resolution;
472
+ // the exit code finishes the job. The blocker is STILL OPEN and the caller
473
+ // that asked to clear it would otherwise carry on believing it is gone.
474
+ output({ resolved: false, reason: 'no matching blocker', blocker: text, error: 'blocker_not_matched' }, raw, 'false');
475
+ return;
476
+ }
477
+
459
478
  let newBody = filtered.join('\n');
460
479
  // If section is now empty, add placeholder
461
480
  if (!newBody.trim() || !newBody.includes('- ')) {
@@ -466,7 +485,7 @@ function cmdStateResolveBlocker(cwd, text, raw) {
466
485
  writeStateMd(statePath, content, cwd);
467
486
  output({ resolved: true, blocker: text }, raw, 'true');
468
487
  } else {
469
- output({ resolved: false, reason: 'Blockers section not found in state.md' }, raw, 'false');
488
+ output({ resolved: false, reason: 'Blockers section not found in state.md', error: 'blockers_section_missing' }, raw, 'false');
470
489
  }
471
490
  }
472
491
 
@@ -510,7 +529,7 @@ function cmdStateRecordSession(cwd, options, raw) {
510
529
  try { require('./memory-optimize.cjs').maybeAutoOptimizeMemory(cwd); } catch { /* best-effort */ }
511
530
  output({ recorded: true, updated }, raw, 'true');
512
531
  } else {
513
- output({ recorded: false, reason: 'No session fields found in state.md' }, raw, 'false');
532
+ output({ recorded: false, reason: 'No session fields found in state.md', error: 'session_fields_missing' }, raw, 'false');
514
533
  }
515
534
  }
516
535
 
@@ -1012,7 +1031,9 @@ function cmdDashboard(cwd, raw) {
1012
1031
  lines.push(`Blockers: ${blockerCount}`);
1013
1032
  if (lastActivity) lines.push(`Last Activity: ${lastActivity}${lastActivityDesc ? ' — ' + lastActivityDesc : ''}`);
1014
1033
  if (nextPhase) lines.push(`Next Phase: ${nextPhase.number} — ${nextPhase.name}`);
1015
- output(result, false, lines.join('\n'));
1034
+ // raw=true so output() emits the human summary; passing false discarded it
1035
+ // and printed JSON, making the whole lines[] block dead (M28, ADR audit 2026-08).
1036
+ output(result, true, lines.join('\n'));
1016
1037
  return;
1017
1038
  }
1018
1039
 
@@ -9,7 +9,7 @@ const {
9
9
  SIMPLE_TASK_THRESHOLD, SIMPLE_FILE_THRESHOLD, COMPLEX_TASK_THRESHOLD, COMPLEX_FILE_THRESHOLD,
10
10
  } = require('./constants.cjs');
11
11
  const { planningPath, phasesPath } = require('./utils.cjs');
12
- const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosix, output, error } = require('./core.cjs');
12
+ const { normalizePhaseName, findPhaseInternal, generateSlugInternal, toPosix, output, EXIT_OK, error } = require('./core.cjs');
13
13
  const { reconstructFrontmatter } = require('./frontmatter.cjs');
14
14
 
15
15
  /**
@@ -64,8 +64,13 @@ function cmdTemplateSelect(cwd, planPath, raw) {
64
64
  const result = { template, type, taskCount, fileCount, hasDecisions };
65
65
  output(result, raw, template);
66
66
  } catch (err) {
67
- // Fallback to standard template on any read/parse error
68
- output({ template: 'templates/summary-standard.md', type: 'standard', error: err.message }, raw, 'templates/summary-standard.md');
67
+ // Fallback to standard template on any read/parse error.
68
+ // EXIT_OK: this is a RESULT, not a failure — the command answered the question
69
+ // it was asked ("which summary template?") with the documented default, and
70
+ // `error` here is only the reason detection fell back. Callers use it as
71
+ // `TEMPLATE=$(pan-tools template select … --raw)`; exiting non-zero would
72
+ // report a failure for a command that produced a usable answer.
73
+ output({ template: 'templates/summary-standard.md', type: 'standard', error: err.message }, raw, 'templates/summary-standard.md', EXIT_OK);
69
74
  }
70
75
  }
71
76
 
@@ -90,8 +90,20 @@ function validateRuntimeInstall(cwd, configDir, runtime) {
90
90
  if (hooks && typeof hooks === 'object') {
91
91
  for (const hookArr of Object.values(hooks)) {
92
92
  if (!Array.isArray(hookArr)) continue;
93
- for (const hook of hookArr) {
94
- if (hook.command) hookCommands.push(hook.command);
93
+ // M29: PAN installs hooks in the nested Claude shape — each event maps to
94
+ // an array of GROUPS, and each group holds the real commands under
95
+ // group.hooks[]: { matcher, hooks: [{ type, command }] }. The old code
96
+ // only read group.command (undefined in that shape), so it validated
97
+ // nothing for PAN's own hooks. Collect both the flat group.command (other
98
+ // runtimes) AND every command nested in group.hooks[].
99
+ for (const group of hookArr) {
100
+ if (!group || typeof group !== 'object') continue;
101
+ if (group.command) hookCommands.push(group.command);
102
+ if (Array.isArray(group.hooks)) {
103
+ for (const h of group.hooks) {
104
+ if (h && h.command) hookCommands.push(h.command);
105
+ }
106
+ }
95
107
  }
96
108
  }
97
109
  }
@@ -135,7 +135,7 @@ function calculateDriftScore(violations, filesChecked, rulesCount) {
135
135
  * @param {string} [sinceRef] - Git ref to diff against (default: HEAD)
136
136
  * @returns {string[]} Array of relative file paths
137
137
  */
138
- function getChangedFiles(cwd, sinceRef) {
138
+ function getChangedFiles(cwd, sinceRef, opts = {}) {
139
139
  const ref = sinceRef || 'HEAD';
140
140
  // Try staged + unstaged first, then diff against ref
141
141
  const result = execGit(cwd, ['diff', '--name-only', ref]);
@@ -150,6 +150,20 @@ function getChangedFiles(cwd, sinceRef) {
150
150
  if (line.trim()) allFiles.add(line.trim());
151
151
  }
152
152
  }
153
+ // Untracked files are OPT-IN because the two consumers want different things.
154
+ // Drift detection asks "what changed against the baseline", where an untracked
155
+ // scratch file is noise. The stub gate asks "is any code in this handoff a stub",
156
+ // and a brand-new file is precisely the normal shape of PAN execution — a plan
157
+ // creates files, a later step commits them — so during that window `git diff` lists
158
+ // nothing and a fully stubbed new module sailed through the gate.
159
+ if (opts.includeUntracked) {
160
+ const untracked = execGit(cwd, ['ls-files', '--others', '--exclude-standard']);
161
+ if (untracked.exitCode === 0) {
162
+ for (const line of untracked.stdout.split(/\r?\n/)) {
163
+ if (line.trim()) allFiles.add(line.trim());
164
+ }
165
+ }
166
+ }
153
167
  // Filter out binary extensions and limit
154
168
  const filtered = [];
155
169
  for (const f of allFiles) {