pan-wizard 3.21.1 → 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 (136) 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 +101 -17
  68. package/pan-wizard-core/bin/lib/memory-rebuild.cjs +26 -2
  69. package/pan-wizard-core/bin/lib/milestone.cjs +11 -5
  70. package/pan-wizard-core/bin/lib/optimize.cjs +18 -7
  71. package/pan-wizard-core/bin/lib/phase-remove.cjs +41 -2
  72. package/pan-wizard-core/bin/lib/phase.cjs +82 -11
  73. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  74. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -6
  75. package/pan-wizard-core/bin/lib/roadmap.cjs +6 -2
  76. package/pan-wizard-core/bin/lib/runner.cjs +17 -7
  77. package/pan-wizard-core/bin/lib/squads.cjs +43 -11
  78. package/pan-wizard-core/bin/lib/state.cjs +32 -11
  79. package/pan-wizard-core/bin/lib/template.cjs +8 -3
  80. package/pan-wizard-core/bin/lib/verify-deploy.cjs +14 -2
  81. package/pan-wizard-core/bin/lib/verify-drift.cjs +15 -1
  82. package/pan-wizard-core/bin/lib/verify-preflight.cjs +9 -3
  83. package/pan-wizard-core/bin/lib/verify.cjs +84 -39
  84. package/pan-wizard-core/bin/lib/whatif.cjs +8 -1
  85. package/pan-wizard-core/bin/pan-tools.cjs +42 -19
  86. package/pan-wizard-core/learnings/index.json +7 -7
  87. package/pan-wizard-core/learnings/universal/autonomous-loop.md +5 -3
  88. package/pan-wizard-core/learnings/universal/concurrency.md +1 -1
  89. package/pan-wizard-core/references/design-methodology.md +94 -0
  90. package/pan-wizard-core/references/git-integration.md +9 -9
  91. package/pan-wizard-core/references/guardrails.md +4 -1
  92. package/pan-wizard-core/references/model-profile-resolution.md +1 -1
  93. package/pan-wizard-core/references/model-profiles.md +44 -34
  94. package/pan-wizard-core/references/planning-config.md +5 -5
  95. package/pan-wizard-core/references/verification-patterns.md +67 -0
  96. package/pan-wizard-core/templates/codebase/architecture.md +1 -1
  97. package/pan-wizard-core/templates/codebase/concerns.md +1 -1
  98. package/pan-wizard-core/templates/codebase/conventions.md +1 -1
  99. package/pan-wizard-core/templates/codebase/integrations.md +1 -1
  100. package/pan-wizard-core/templates/codebase/stack.md +1 -1
  101. package/pan-wizard-core/templates/codebase/structure.md +1 -1
  102. package/pan-wizard-core/templates/codebase/testing.md +1 -1
  103. package/pan-wizard-core/templates/design.md +146 -0
  104. package/pan-wizard-core/templates/discovery.md +3 -3
  105. package/pan-wizard-core/templates/milestone-archive.md +2 -2
  106. package/pan-wizard-core/templates/playbook.md +1 -1
  107. package/pan-wizard-core/templates/uat.md +1 -1
  108. package/pan-wizard-core/workflows/diagnose-issues.md +9 -7
  109. package/pan-wizard-core/workflows/exec-phase.md +28 -8
  110. package/pan-wizard-core/workflows/execute-plan.md +2 -3
  111. package/pan-wizard-core/workflows/health.md +24 -1
  112. package/pan-wizard-core/workflows/help.md +5 -4
  113. package/pan-wizard-core/workflows/learn.md +5 -5
  114. package/pan-wizard-core/workflows/map-codebase.md +1 -1
  115. package/pan-wizard-core/workflows/milestone-audit.md +1 -1
  116. package/pan-wizard-core/workflows/milestone-new.md +4 -4
  117. package/pan-wizard-core/workflows/new-project.md +47 -32
  118. package/pan-wizard-core/workflows/optimize.md +8 -8
  119. package/pan-wizard-core/workflows/pause.md +1 -1
  120. package/pan-wizard-core/workflows/phase-tests.md +2 -2
  121. package/pan-wizard-core/workflows/plan-phase.md +4 -0
  122. package/pan-wizard-core/workflows/profile.md +13 -10
  123. package/pan-wizard-core/workflows/research-phase.md +1 -1
  124. package/pan-wizard-core/workflows/resume-project.md +1 -1
  125. package/pan-wizard-core/workflows/settings.md +9 -9
  126. package/pan-wizard-core/workflows/transition.md +24 -8
  127. package/pan-wizard-core/workflows/update.md +23 -20
  128. package/pan-wizard-core/workflows/verify-phase.md +7 -2
  129. package/pan-zcode/KNOWN-BETA-RISKS.md +8 -0
  130. package/pan-zcode/README.md +14 -2
  131. package/pan-zcode/bin/install-zcode.js +1 -1
  132. package/pan-zcode/mcp/native-tools.cjs +1 -1
  133. package/pan-zcode/mcp/server.cjs +76 -14
  134. package/scripts/build-hooks.js +2 -1
  135. package/scripts/generate-skills-docs.py +0 -4
  136. package/scripts/run-tests.cjs +11 -4
@@ -121,7 +121,7 @@ function initTraceSession(cwd, opts = {}) {
121
121
 
122
122
  return { session_id: sessionId, started_at: meta.started_at, directory: sessionDir, reused: false };
123
123
  } catch (e) {
124
- return { error: e.message };
124
+ return { error: e.message || 'trace_init_failed' };
125
125
  }
126
126
  }
127
127
 
@@ -278,7 +278,7 @@ function endTraceSession(cwd, sessionId) {
278
278
  ended_at: meta.ended_at,
279
279
  };
280
280
  } catch (e) {
281
- return { error: e.message };
281
+ return { error: e.message || 'trace_read_failed' };
282
282
  }
283
283
  }
284
284
 
@@ -303,7 +303,7 @@ function reconcileTraceSession(cwd, sessionId) {
303
303
  fs.writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
304
304
  return { session_id: sid, reconciled: true, event_count: counts.event_count, malformed_count: counts.malformed_count };
305
305
  } catch (e) {
306
- return { error: e.message };
306
+ return { error: e.message || 'trace_write_failed' };
307
307
  }
308
308
  }
309
309
 
@@ -349,7 +349,7 @@ function readTraceSession(cwd, sessionId) {
349
349
 
350
350
  return { session_id: sessionId, metadata, events, event_count: events.length, malformed_count: malformed };
351
351
  } catch (e) {
352
- return { error: e.message };
352
+ return { error: e.message || 'trace_end_failed' };
353
353
  }
354
354
  }
355
355
 
@@ -380,7 +380,7 @@ function listTraceSessions(cwd) {
380
380
 
381
381
  return { sessions, count: sessions.length };
382
382
  } catch (e) {
383
- return { error: e.message };
383
+ return { error: e.message || 'trace_reconcile_failed' };
384
384
  }
385
385
  }
386
386
 
@@ -585,7 +585,7 @@ function listOptimizationReports(cwd) {
585
585
 
586
586
  return { reports, count: reports.length };
587
587
  } catch (e) {
588
- return { error: e.message };
588
+ return { error: e.message || 'report_write_failed' };
589
589
  }
590
590
  }
591
591
 
@@ -626,8 +626,19 @@ function applyReportRecommendations(cwd, reportPath) {
626
626
  const applied = [];
627
627
  const skipped = [];
628
628
 
629
+ const resolvedCwd = path.resolve(cwd);
629
630
  for (const action of actions) {
630
631
  try {
632
+ // Containment: action.path on memory writes is report/agent-authored and
633
+ // must stay inside the project. A `../` (or absolute) path otherwise
634
+ // escapes and writes anywhere the process can (M23, ADR audit 2026-08).
635
+ if (action.type === 'memory' || action.type === 'memory_append') {
636
+ const abs = path.resolve(cwd, action.path || '');
637
+ if (abs !== resolvedCwd && !abs.startsWith(resolvedCwd + path.sep)) {
638
+ skipped.push({ action, reason: 'path escapes project root — skipped' });
639
+ continue;
640
+ }
641
+ }
631
642
  if (action.type === 'memory') {
632
643
  // Write new memory entry (skip if file exists to avoid overwriting manual edits)
633
644
  const memPath = path.join(cwd, action.path);
@@ -751,7 +762,7 @@ function getOptimizeStats(cwd) {
751
762
  current_session: getCurrentSessionId(cwd),
752
763
  };
753
764
  } catch (e) {
754
- return { error: e.message };
765
+ return { error: e.message || 'apply_failed' };
755
766
  }
756
767
  }
757
768
 
@@ -20,7 +20,7 @@ function removePhaseFromDisk(phaseDir) {
20
20
  try {
21
21
  fs.rmSync(phaseDir, { recursive: true, force: true });
22
22
  } catch (e) {
23
- return { removed: false, error: e.message };
23
+ return { removed: false, error: e.message || 'remove_failed' };
24
24
  }
25
25
  return { removed: true };
26
26
  }
@@ -214,7 +214,12 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
214
214
 
215
215
  // Reasonable upper bound for phase numbers
216
216
  const maxPhase = 99;
217
- for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
217
+ // Floor the loop independently of removedInt. cmdPhaseRemove rejects a phase
218
+ // below 1 before we get here, but this function is also reachable directly,
219
+ // and an unbounded lower end is how `phase remove -1234567890` turned into
220
+ // ~1.2 billion regex passes. The loop can now never exceed maxPhase steps.
221
+ const stopAt = Math.max(removedInt, 0);
222
+ for (let oldNum = maxPhase; oldNum > stopAt; oldNum--) {
218
223
  const newNum = oldNum - 1;
219
224
  const oldStr = String(oldNum);
220
225
  const newStr = String(newNum);
@@ -269,6 +274,23 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
269
274
  error('phase number required for phase remove');
270
275
  }
271
276
 
277
+ // Validate the identifier BEFORE touching the roadmap or any directory.
278
+ // normalizePhaseName does not validate — it returns its input unchanged when
279
+ // PHASE_NUM_RE does not match — so a bad argument used to reach the renumber
280
+ // logic and do real damage, in both cases reporting success and exiting 0:
281
+ // `phase remove 0` renumbered EVERY roadmap heading down to "Phase 0"
282
+ // (the descending renumber loop runs 99..1 and re-hits
283
+ // its own output, collapsing 3->2->1->0) and renamed
284
+ // every phase directory one lower.
285
+ // `phase remove -1e9` spun that same loop ~1.2 billion times, each pass
286
+ // running several regex replaces over the roadmap —
287
+ // hours of CPU, no output, no way to tell it was stuck.
288
+ // Phases are numbered from 1, so anything below that is a typo, never a target.
289
+ const phaseIdent = String(targetPhase).trim();
290
+ if (!/^\d+[A-Za-z]?(?:\.\d+)*$/.test(phaseIdent) || parseInt(phaseIdent, 10) < 1) {
291
+ error(`invalid phase number: ${targetPhase} (expected a phase numbered from 1, e.g. 3, 3A or 3.1)`);
292
+ }
293
+
272
294
  const roadmapPath = path.join(planningPath(cwd), ROADMAP_FILE);
273
295
  const phasesDir = phasesPath(cwd);
274
296
  const force = options.force || false;
@@ -291,6 +313,23 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
291
313
  // Phases directory does not exist; targetDir remains null
292
314
  }
293
315
 
316
+ // Refuse a phase that exists nowhere. A phase CAN legitimately have a roadmap
317
+ // entry and no directory yet (planned, not scaffolded), so only refuse when it
318
+ // is in neither place. Previously this reported {removed: <n>} with exit 0 for
319
+ // a phase that was never there, and claimed roadmap.md and state.md had been
320
+ // rewritten — an orchestrator reads that as "it is gone" and moves on.
321
+ if (!targetDir) {
322
+ let roadmapText = '';
323
+ try {
324
+ roadmapText = fs.readFileSync(roadmapPath, 'utf8');
325
+ } catch { /* unreadable roadmap is reported below as not-found */ }
326
+ const displayNum = escapeRegex(String(parseInt(phaseIdent, 10)) + (phaseIdent.includes('.') ? '.' + phaseIdent.split('.').slice(1).join('.') : ''));
327
+ const inRoadmap = new RegExp(`^#{2,4}\\s*Phase\\s+0*${displayNum}(\\D|$)`, 'im').test(roadmapText);
328
+ if (!inRoadmap) {
329
+ error(`Phase ${targetPhase} not found — no phase directory and no roadmap entry`);
330
+ }
331
+ }
332
+
294
333
  // Check for executed work (summary.md files)
295
334
  if (targetDir && !force) {
296
335
  const targetPath = path.join(phasesDir, targetDir);
@@ -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
  }),