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
@@ -6,7 +6,7 @@ const fs = require('fs');
6
6
  const path = require('path');
7
7
  const { PHASES_DIR, MILESTONES_DIR, ROADMAP_FILE, REQUIREMENTS_FILE, STATE_FILE, isPlanFile } = require('./constants.cjs');
8
8
  const { planningPath, phasesPath, filterPlanFiles, filterSummaryFiles, fileAccessible, planningRel } = require('./utils.cjs');
9
- const { output, error, isGitRepo, execGit, escapeRegex } = require('./core.cjs');
9
+ const { output, error, isGitRepo, isGitIgnored, execGit, escapeRegex, loadConfig } = require('./core.cjs');
10
10
  const { extractFrontmatter } = require('./frontmatter.cjs');
11
11
  const { writeStateMd } = require('./state.cjs');
12
12
 
@@ -258,8 +258,13 @@ function cmdMilestoneComplete(cwd, version, options, raw) {
258
258
  result.archive_warnings = archiveResult.warnings;
259
259
  }
260
260
 
261
- // Auto-commit + tag unless --no-commit or not a git repo
262
- if (!options.noCommit && isGitRepo(cwd)) {
261
+ // Auto-commit + tag unless --no-commit, not a git repo, or the project keeps planning
262
+ // files out of git (`commit_docs: false` or a gitignored planning tree), the rules
263
+ // `phase complete` and `pan-tools commit` follow (this commit used to ignore both).
264
+ const noCommit = options.noCommit
265
+ || loadConfig(cwd).commit_docs === false
266
+ || isGitIgnored(cwd, planningRel());
267
+ if (!noCommit && isGitRepo(cwd)) {
263
268
  execGit(cwd, ['add', planningRel() + '/']);
264
269
  const commitMsg = `docs: milestone ${version} complete`;
265
270
  const commitResult = execGit(cwd, ['commit', '-m', commitMsg]);
@@ -8,7 +8,7 @@
8
8
 
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
- const { output, escapeRegex, execGit } = require('./core.cjs');
11
+ const { output, escapeRegex, execGit, toLf } = require('./core.cjs');
12
12
  const { planningPath, planningRel } = require('./utils.cjs');
13
13
 
14
14
  // ─── Storage layout ──────────────────────────────────────────────────────────
@@ -793,6 +793,27 @@ function revertedIndices(rows) {
793
793
  return out;
794
794
  }
795
795
 
796
+ /** The trace session a report belongs to, from its name (`<session>-opt-report.md`, `<session>-analysis.json`). */
797
+ function reportSessionId(reportPath) {
798
+ const m = path.basename(String(reportPath || '')).match(/^(.+?)-(?:opt-report\.md|analysis\.json)$/);
799
+ return m ? m[1] : null;
800
+ }
801
+
802
+ /**
803
+ * An older report's memory actions (`memory`, `memory_append`, `memory_entry`) still
804
+ * write into `.planning/memory/`, but PAN's workflows no longer load that folder into
805
+ * agents (ADR-0036, amended 2026-10-04). Say so in the result rather than let the
806
+ * apply look like it taught anyone anything.
807
+ */
808
+ const MEMORY_NOT_LOADED_WARNING = "stored in .planning/memory/, which PAN's workflows do not load into agents (ADR-0036): "
809
+ + "write a lesson where the agents read — the project's instructions, a test, or the code";
810
+
811
+ function notLoadedWarning(cwd, relPath) {
812
+ const abs = path.resolve(cwd, relPath || '');
813
+ if (path.dirname(abs) !== path.resolve(planningPath(cwd), 'memory')) return {};
814
+ return { warning: MEMORY_NOT_LOADED_WARNING };
815
+ }
816
+
796
817
  function applyReportRecommendations(cwd, reportPath) {
797
818
  let reportContent;
798
819
  try {
@@ -842,15 +863,23 @@ function applyReportRecommendations(cwd, reportPath) {
842
863
  const resolvedCwd = path.resolve(cwd);
843
864
  actions.forEach((action, i) => {
844
865
  try {
845
- // Containment: action.path on memory writes is report/agent-authored and
846
- // must stay inside the project. A `../` (or absolute) path otherwise
847
- // escapes and writes anywhere the process can (M23, ADR audit 2026-08).
866
+ // Containment: action.path on memory writes is report/agent-authored. A `../`
867
+ // (or absolute) path escaped the project (M23, ADR audit 2026-08), and a path
868
+ // inside it reached any file: a report's `memory_append` to CLAUDE.md made
869
+ // agent-written text a standing instruction with no review (ADR-0040), and
870
+ // `/pan:learn --apply` runs this on the optimizer's own report. A memory action
871
+ // writes memory: `.planning/memory/` and nothing else.
848
872
  if (action.type === 'memory' || action.type === 'memory_append') {
849
873
  const abs = path.resolve(cwd, action.path || '');
874
+ const memoryDir = path.resolve(planningPath(cwd), 'memory');
850
875
  if (abs !== resolvedCwd && !abs.startsWith(resolvedCwd + path.sep)) {
851
876
  skipped.push({ action, reason: 'path escapes project root — skipped' });
852
877
  return;
853
878
  }
879
+ if (!abs.startsWith(memoryDir + path.sep)) {
880
+ skipped.push({ action, reason: 'a memory action writes only into .planning/memory/ — skipped; make a change to the project\'s instructions yourself' });
881
+ return;
882
+ }
854
883
  }
855
884
  const earlier = live.get(actionSig(action));
856
885
  if (earlier) {
@@ -867,11 +896,29 @@ function applyReportRecommendations(cwd, reportPath) {
867
896
  fs.mkdirSync(path.dirname(memPath), { recursive: true });
868
897
  fs.writeFileSync(memPath, action.content, 'utf-8');
869
898
  records.push({ i, type: action.type, path: rel(memPath), kind: 'created', sha256_after: normalisedHash(fs.readFileSync(memPath, 'utf-8')), action_sig: actionSig(action) });
870
- applied.push({ action, result: `Written to ${action.path}` });
899
+ applied.push({ action, result: `Written to ${action.path}`, ...notLoadedWarning(cwd, action.path) });
871
900
  }
872
901
  } else if (action.type === 'memory_append') {
873
902
  appendRecorded(path.join(cwd, action.path), '\n' + action.content, action, i);
874
- applied.push({ action, result: `Appended to ${action.path}` });
903
+ applied.push({ action, result: `Appended to ${action.path}`, ...notLoadedWarning(cwd, action.path) });
904
+ } else if (action.type === 'memory_entry') {
905
+ // The gated write path (O6): a lesson drawn from this report's trace session,
906
+ // recorded only if it carries citations that hold and passes `memory record`'s
907
+ // other rules. The optimizer no longer proposes these; an older report's are
908
+ // still recorded, as created/appended so revert works.
909
+ const session = reportSessionId(reportPath);
910
+ if (!session) { skipped.push({ action, reason: 'a memory entry needs the trace session the report came from, and this report\'s name does not carry one' }); return; }
911
+ const { recordLesson } = require('./memory.cjs');
912
+ const logPath = path.join(planningPath(cwd), 'memory', `${String(action.agent || '')}.md`);
913
+ let before = null;
914
+ try { before = fs.readFileSync(logPath, 'utf-8'); } catch { /* a new log */ }
915
+ const r = recordLesson(cwd, String(action.agent || ''), { lesson: action.lesson, trace: session, cites: action.cites });
916
+ if (r.error || !r.recorded) { skipped.push({ action, reason: r.error || `memory record refused it: ${r.reason}` }); return; }
917
+ const after = fs.readFileSync(logPath, 'utf-8');
918
+ records.push(before === null
919
+ ? { i, type: action.type, path: rel(logPath), kind: 'created', sha256_after: normalisedHash(after), action_sig: actionSig(action) }
920
+ : { i, type: action.type, path: rel(logPath), kind: 'appended', text: toLf(after).slice(toLf(before).length), sha256_after: normalisedHash(after), action_sig: actionSig(action) });
921
+ applied.push({ action, result: `Recorded for ${r.agent} from ${r.evidence}`, warning: MEMORY_NOT_LOADED_WARNING });
875
922
  } else if (action.type === 'note') {
876
923
  // Write a human-readable suggestion note
877
924
  const entry = `\n## ${new Date().toISOString()}: ${action.description || 'Suggestion'}\n\n${action.content || action.suggestion || ''}\n\n**Target:** ${action.target || 'unspecified'}\n`;
@@ -992,7 +1039,11 @@ function revertApply(cwd, which) {
992
1039
  return { apply_id: apply.apply_id, reverted: out.sort((x, y) => x - y), refused, status: remaining === 0 ? 'reverted' : (out.length ? 'partial' : 'refused') };
993
1040
  }
994
1041
 
995
- // Derive basic memory actions from a raw JSON analysis when no optimizer agent
1042
+ // Where a lesson reaches the next run's agents: they read the project's
1043
+ // instructions, and PAN's workflows no longer load `.planning/memory/` (ADR-0036).
1044
+ const LESSON_TARGET = "the project's instructions (CLAUDE.md or AGENTS.md, outside PAN's section)";
1045
+
1046
+ // Derive suggestion notes from a raw JSON analysis when no optimizer agent
996
1047
  // report is available (fallback for /pan:optimize apply on a JSON file).
997
1048
  function deriveActionsFromAnalysis(analysis) {
998
1049
  const actions = [];
@@ -1002,9 +1053,9 @@ function deriveActionsFromAnalysis(analysis) {
1002
1053
  memory_miss_patterns.slice(0, 3).forEach(p => {
1003
1054
  actions.push({
1004
1055
  type: 'note',
1005
- description: `Memory miss: ${p.pattern}`,
1006
- content: `This topic was missing from memory ${p.count} time(s) during the traced session. Consider adding a memory entry for it.`,
1007
- target: '.planning/memory/',
1056
+ description: `Missing knowledge: ${p.pattern}`,
1057
+ content: `An agent logged this as missing ${p.count} time(s) during the traced session. Write the answer where the agents read: ${LESSON_TARGET}, or a comment at the code it concerns.`,
1058
+ target: LESSON_TARGET,
1008
1059
  });
1009
1060
  });
1010
1061
  }
@@ -1016,8 +1067,8 @@ function deriveActionsFromAnalysis(analysis) {
1016
1067
  actions.push({
1017
1068
  type: 'note',
1018
1069
  description: `Recurring tool failure: ${p.agent} ${p.tool} (${p.error_class})`,
1019
- content: `${p.agent}'s ${p.tool} call failed with "${p.message}" in ${p.spawns} spawns across ${p.sessions} session(s) (${p.occurrences} times in all). Record the working form of the call, or the precondition it needs, as a memory entry for ${p.agent}.`,
1020
- target: '.planning/memory/',
1070
+ content: `${p.agent}'s ${p.tool} call failed with "${p.message}" in ${p.spawns} spawns across ${p.sessions} session(s) (${p.occurrences} times in all). Write the working form of the call, or the precondition it needs, where ${p.agent} reads it: ${LESSON_TARGET}.`,
1071
+ target: LESSON_TARGET,
1021
1072
  });
1022
1073
  });
1023
1074
  Object.entries(analysis.verdict_stats || {}).filter(([, s]) => s.fail >= 2).forEach(([agent, s]) => {
@@ -1025,7 +1076,7 @@ function deriveActionsFromAnalysis(analysis) {
1025
1076
  type: 'note',
1026
1077
  description: `Repeated judge failures: ${agent}`,
1027
1078
  content: `${agent} failed ${s.fail} time(s) (${s.retries} retr${s.retries === 1 ? 'y' : 'ies'}, ${s.resolved_by_retry} resolved by the retry). Read the recorded findings (\`pan-tools findings list --agent ${agent}\`) for the class that keeps recurring before the next phase.`,
1028
- target: '.planning/memory/',
1079
+ target: '.planning/findings.jsonl',
1029
1080
  });
1030
1081
  });
1031
1082
 
@@ -1034,8 +1085,8 @@ function deriveActionsFromAnalysis(analysis) {
1034
1085
  actions.push({
1035
1086
  type: 'note',
1036
1087
  description: `Knowledge gap: ${p.pattern}`,
1037
- content: `The agent had to infer this ${p.count} time(s). Research and cache the answer.`,
1038
- target: '.planning/memory/',
1088
+ content: `The agent had to infer this ${p.count} time(s). Research the answer and write it where the agents read: ${LESSON_TARGET}, or a comment at the code it concerns.`,
1089
+ target: LESSON_TARGET,
1039
1090
  });
1040
1091
  });
1041
1092
  }
@@ -1218,6 +1269,18 @@ function cmdOptimizeLearn(cwd, opts, raw) {
1218
1269
  }, raw);
1219
1270
  }
1220
1271
 
1272
+ /**
1273
+ * `--report` as given resolves against the project (`--cwd`), not the shell's
1274
+ * directory; a bare file name that is not there is looked up in the reports dir.
1275
+ */
1276
+ function resolveReportPath(cwd, given) {
1277
+ const abs = path.resolve(cwd, given);
1278
+ if (fs.existsSync(abs)) return abs;
1279
+ if (path.basename(given) !== given) return abs; // a path with directories is taken as given
1280
+ const inReports = path.join(getReportsDir(cwd), given);
1281
+ return fs.existsSync(inReports) ? inReports : abs;
1282
+ }
1283
+
1221
1284
  function cmdOptimizeApply(cwd, opts, raw) {
1222
1285
  const reports = listOptimizationReports(cwd);
1223
1286
  if (reports.error || reports.count === 0) {
@@ -1225,7 +1288,7 @@ function cmdOptimizeApply(cwd, opts, raw) {
1225
1288
  return;
1226
1289
  }
1227
1290
 
1228
- const reportPath = opts.reportPath || reports.reports[0].path;
1291
+ const reportPath = opts.reportPath ? resolveReportPath(cwd, opts.reportPath) : reports.reports[0].path;
1229
1292
  const result = applyReportRecommendations(cwd, reportPath);
1230
1293
  output({ report: path.basename(reportPath), ...result }, raw);
1231
1294
  }
@@ -1286,6 +1349,14 @@ function validatePromoteInputs(pattern, opts) {
1286
1349
  if (!pattern.id) return 'pattern.id is required';
1287
1350
  if (!pattern.summary) return 'pattern.summary is required';
1288
1351
  if (!pattern.rule) return 'pattern.rule is required';
1352
+ // A citation names the code the pattern rests on, relative to the tree that
1353
+ // holds the store; it must hold now, and learn lint L-007 keeps checking it.
1354
+ if (pattern.cites !== undefined && !Array.isArray(pattern.cites)) return 'pattern.cites must be a list';
1355
+ const { citationProblem } = require('./memory.cjs');
1356
+ for (const c of pattern.cites || []) {
1357
+ const problem = citationProblem(opts.sourceRoot, c);
1358
+ if (problem) return `citation ${c}: ${problem}`;
1359
+ }
1289
1360
  return null;
1290
1361
  }
1291
1362
 
@@ -1366,7 +1437,8 @@ function classifyPatternKind(pattern) {
1366
1437
  * always written by us — no general YAML dependency needed).
1367
1438
  */
1368
1439
  function readTopicFile(filePath) {
1369
- const content = fs.readFileSync(filePath, 'utf-8');
1440
+ // LF: an install committed with core.autocrlf=true holds these files as CRLF.
1441
+ const content = toLf(fs.readFileSync(filePath, 'utf-8'));
1370
1442
  const fmMatch = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
1371
1443
  if (!fmMatch) {
1372
1444
  return { frontmatter: { topic: '', patterns: [] }, body: content, _raw: content };
@@ -1429,6 +1501,13 @@ function serializeTopicFile(topic, patterns, body) {
1429
1501
  fm += ` promoted_at: ${p.promoted_at || ts}\n`;
1430
1502
  const srcExps = Array.isArray(p.source_experiments) ? p.source_experiments : [];
1431
1503
  fm += ` source_experiments: [${srcExps.join(', ')}]\n`;
1504
+ // Optional fields survive a re-serialisation: promoting into an existing topic
1505
+ // rewrites every pattern's frontmatter, and dropping these lost the supersession
1506
+ // learn lint L-005 reads, and the citations L-007 checks.
1507
+ const cites = Array.isArray(p.cites) ? p.cites : [];
1508
+ if (cites.length) fm += ` cites: [${cites.join(', ')}]\n`;
1509
+ if (p.superseded_by) fm += ` superseded_by: ${p.superseded_by}\n`;
1510
+ if (p.superseded_id) fm += ` superseded_id: ${p.superseded_id}\n`;
1432
1511
  }
1433
1512
  return `---\n${fm}---\n${body}`;
1434
1513
  }
@@ -1519,6 +1598,7 @@ function promotePattern(pattern, opts) {
1519
1598
  summary: pattern.summary,
1520
1599
  promoted_at: promotedAt,
1521
1600
  source_experiments: pattern.source_experiments || [],
1601
+ ...(pattern.cites && pattern.cites.length ? { cites: pattern.cites } : {}),
1522
1602
  });
1523
1603
 
1524
1604
  const serialized = serializeTopicFile(topic, frontmatter.patterns, body);
@@ -0,0 +1,174 @@
1
+ 'use strict';
2
+ /**
3
+ * The phase gate (program V1): may this phase be marked complete?
4
+ *
5
+ * Every guarantee at the end of PAN's loop used to be a sentence in a workflow:
6
+ * "do not advance unless the verification passed", "reconcile before advancing".
7
+ * The model skipped one (exec-phase's `--no-transition` path never ran reconcile at
8
+ * all), and `phase complete` marked the phase done regardless. The gate puts those
9
+ * rules in code, in one function every caller uses: `phase complete`, the native
10
+ * exec-waves script, and the MCP merge request.
11
+ *
12
+ * Refusal reasons, one code per class:
13
+ * no_plans · missing_summary · no_verification · verification_unreadable ·
14
+ * verification_failed (V9 lowering included) · human_needed_unapproved ·
15
+ * verification_stale · reconcile_contradiction · test_failed · open_findings ·
16
+ * stubs_found · integrity_violation
17
+ *
18
+ * What the gate could not check is listed in `not_checked`, never refused on:
19
+ * a skipped test gate, an unknown freshness, no git, no plan commits, the verifier
20
+ * switched off. `workflow.phase_gate` is `warn` (default: report and proceed) or
21
+ * `enforce` (refuse unless `--force --reason`, which records a decision).
22
+ */
23
+ const fs = require('fs');
24
+ const path = require('path');
25
+ const { findPhaseInternal, safeReadFile, loadConfig, output, error } = require('./core.cjs');
26
+ const { isVerificationFile } = require('./constants.cjs');
27
+ const { planningPath } = require('./utils.cjs');
28
+
29
+ const MODES = Object.freeze(['warn', 'enforce']);
30
+ const SERIOUS = new Set(['critical', 'high']);
31
+
32
+ /** `workflow.phase_gate` from config.json: `enforce` or (anything else) `warn`. */
33
+ function gateMode(cwd) {
34
+ let cfg = null;
35
+ try { cfg = JSON.parse(fs.readFileSync(planningPath(cwd, 'config.json'), 'utf-8')); } catch { /* defaults */ }
36
+ const v = cfg && cfg.workflow && cfg.workflow.phase_gate;
37
+ return MODES.includes(v) ? v : 'warn';
38
+ }
39
+
40
+ /**
41
+ * @param {string} cwd
42
+ * @param {string|number} phase
43
+ * @param {{mode?: 'warn'|'enforce'}} [opts]
44
+ * @returns {{phase, mode, ok, reasons: Array<{code, detail}>, facts: Object, not_checked: Array<{check, reason}>}|{error, reason}}
45
+ */
46
+ function phaseGate(cwd, phase, opts = {}) {
47
+ const info = findPhaseInternal(cwd, phase);
48
+ if (!info || !info.found) return { error: 'phase_not_found', reason: `no phase directory for phase ${phase}` };
49
+ const V = require('./verdict.cjs');
50
+ const F = require('./findings.cjs');
51
+ const { reconcilePhase, verificationFreshness, scanStubs } = require('./verify.cjs');
52
+ const { committedFiles } = require('./verify-scope.cjs');
53
+ const { checkTestIntegrity } = require('./verify-integrity.cjs');
54
+
55
+ const mode = MODES.includes(opts.mode) ? opts.mode : gateMode(cwd);
56
+ const reasons = [];
57
+ const notChecked = [];
58
+ const facts = {};
59
+ const refuse = (code, detail, extra = {}) => reasons.push({ code, detail, ...extra });
60
+ const skip = (check, reason) => notChecked.push({ check, reason });
61
+ const phaseNumber = info.phase_number || String(phase);
62
+ const dir = path.join(cwd, info.directory);
63
+
64
+ // 1. Every plan has a summary.
65
+ const plans = info.plans || [];
66
+ const incomplete = info.incomplete_plans || [];
67
+ facts.plans = `${plans.length - incomplete.length}/${plans.length}`;
68
+ if (!plans.length) refuse('no_plans', 'the phase has no plans');
69
+ else if (incomplete.length) refuse('missing_summary', `${incomplete.length} plan(s) have no summary: ${incomplete.join(', ')}`, { plans: incomplete });
70
+
71
+ // 2. A passing verification (with code facts applied, V9).
72
+ let names = [];
73
+ try { names = fs.readdirSync(dir); } catch { /* unreadable reads as empty */ }
74
+ const verFile = names.filter(isVerificationFile).sort().pop();
75
+ const verifierOn = loadConfig(cwd).verifier !== false;
76
+ if (!verFile) {
77
+ if (verifierOn) refuse('no_verification', 'the phase has no verification; run /pan:verify-phase');
78
+ else skip('verification', 'workflow.verifier is off');
79
+ } else {
80
+ const parsed = V.parseVerdictText(safeReadFile(path.join(dir, verFile)) || '', { agent: 'pan-verifier', phase: phaseNumber });
81
+ if (!parsed.ok) {
82
+ refuse('verification_unreadable', `${verFile}: ${parsed.reason}`);
83
+ } else {
84
+ const v = parsed.verdict;
85
+ const fm = V.verdictFromVerificationFrontmatter(safeReadFile(path.join(dir, verFile)) || '') || {};
86
+ facts.verification = v.verdict;
87
+ facts.test_gate = fm.test_gate || null;
88
+ if (parsed.lowered_by && parsed.lowered_by.length) facts.lowered_by = parsed.lowered_by;
89
+ if (fm.test_gate === 'failed') refuse('test_failed', 'the verification records test_gate: failed');
90
+ else if (fm.test_gate === 'skipped') skip('tests', 'the test gate was skipped');
91
+ else if (!fm.test_gate) skip('tests', 'the verification does not record its test gate');
92
+ if (v.outcome === 'fail') {
93
+ refuse('verification_failed', `the verification is ${v.verdict}${v.agent_verdict ? ` (the verifier wrote ${v.agent_verdict})` : ''}`);
94
+ } else if (v.outcome === 'needs_human') {
95
+ const human = F.listFindings(cwd, { phase: phaseNumber, agent: 'pan-verifier', class: 'human' }).findings;
96
+ const unapproved = human.filter((f) => f.status !== 'decision');
97
+ if (unapproved.length || !human.length) {
98
+ refuse('human_needed_unapproved', `${unapproved.length || 'the'} human verification item(s) have no recorded approval; record it with \`findings dispose <id> --as decision --reason "<what was checked>"\``, { ids: unapproved.map((f) => f.id) });
99
+ }
100
+ }
101
+ }
102
+ }
103
+
104
+ // 3. The verification still describes the code.
105
+ if (verFile) {
106
+ const fresh = verificationFreshness(cwd, phaseNumber, info);
107
+ facts.freshness = fresh.state;
108
+ if (fresh.state === 'stale') refuse('verification_stale', `files changed since the verified commit: ${(fresh.changed_since || []).slice(0, 5).join(', ')}`, { changed_since: fresh.changed_since });
109
+ else if (fresh.state === 'unknown') skip('freshness', fresh.reason || 'unknown');
110
+ }
111
+
112
+ // 4. The written verdict agrees with the mechanical checks.
113
+ if (verFile) {
114
+ const rec = reconcilePhase(cwd, phaseNumber);
115
+ facts.reconciled = rec.reconciled;
116
+ if (!rec.reconciled) refuse('reconcile_contradiction', rec.contradictions.join('; '), { contradictions: rec.contradictions });
117
+ }
118
+
119
+ // 5. No critical or high finding left open without a disposition.
120
+ const open = F.listFindings(cwd, { phase: phaseNumber, status: 'open' }).findings
121
+ .filter((f) => SERIOUS.has(f.severity) && f.class !== 'human');
122
+ facts.open_serious_findings = open.length;
123
+ if (open.length) refuse('open_findings', `${open.length} critical/high finding(s) are open: dispose each (\`findings dispose <id> --as fixed|deferred|dismissed|decision --reason "…"\`) or fix it`, { ids: open.map((f) => f.id) });
124
+
125
+ // 6. No stub in the files the phase committed.
126
+ const committed = committedFiles(cwd, phaseNumber);
127
+ if (!committed.git) {
128
+ skip('stubs', 'git is unavailable');
129
+ } else {
130
+ const files = [...committed.files].filter((f) => fs.existsSync(path.join(cwd, f)));
131
+ const stubs = scanStubs(cwd, { files });
132
+ facts.stubs_blocking = stubs.blocking;
133
+ if (stubs.blocking) refuse('stubs_found', `${stubs.blocking} stub marker(s) in committed files: ${stubs.findings.filter((s) => s.severity === 'high').slice(0, 3).map((s) => `${s.file}:${s.line}`).join(', ')}`);
134
+ }
135
+
136
+ // 7. The phase did not weaken its own tests.
137
+ const integrity = checkTestIntegrity(cwd, phaseNumber);
138
+ if (integrity.error || !integrity.checked) {
139
+ skip('test_integrity', integrity.reason || integrity.error);
140
+ } else {
141
+ facts.test_integrity = integrity.ok ? 'clean' : 'violated';
142
+ if (!integrity.ok) refuse('integrity_violation', integrity.violations.slice(0, 3).map((x) => `${x.code} ${x.file}`).join('; '), { violations: integrity.violations });
143
+ }
144
+
145
+ return { phase: phaseNumber, mode, ok: reasons.length === 0, reasons, facts, not_checked: notChecked };
146
+ }
147
+
148
+ /**
149
+ * `--force --reason`: complete past the gate, on the record. Every open finding the
150
+ * gate counted gets a `decision` disposition with the reason, and the trace logs it.
151
+ */
152
+ function recordGateOverride(cwd, gate, reason) {
153
+ const F = require('./findings.cjs');
154
+ const ids = [];
155
+ for (const r of gate.reasons) for (const id of r.ids || []) if (!ids.includes(id)) ids.push(id);
156
+ const disposed = ids.length ? F.disposeFindings(cwd, { ids, as: 'decision', reason: `phase completed past the gate: ${reason}` }) : { disposed: [] };
157
+ try {
158
+ require('./optimize.cjs').logTraceEvent(cwd, {
159
+ type: 'decision', category: 'phase_gate_forced', agent: 'orchestrator', phase: gate.phase,
160
+ description: `phase ${gate.phase} completed past the gate (${gate.reasons.map((r) => r.code).join(', ')}): ${reason}`,
161
+ impact: 'major', context: { reasons: gate.reasons.map((r) => r.code), reason },
162
+ });
163
+ } catch { /* telemetry is best effort */ }
164
+ return { reason, refused_by: gate.reasons.map((r) => r.code), disposed: disposed.disposed || [] };
165
+ }
166
+
167
+ function cmdPhaseGate(cwd, phase, raw, opts = {}) {
168
+ if (!phase) error('Usage: phase gate <N> [--mode warn|enforce]');
169
+ const r = phaseGate(cwd, phase, opts);
170
+ if (r.error) { output(r, raw); return; }
171
+ output(r, raw, r.ok ? 'pass' : 'refuse', r.ok ? 0 : 1);
172
+ }
173
+
174
+ module.exports = { phaseGate, recordGateOverride, gateMode, cmdPhaseGate, MODES };
@@ -173,6 +173,74 @@ function renumberIntegerPhases(phasesDir, removedInt) {
173
173
  return { renamedDirs, renamedFiles };
174
174
  }
175
175
 
176
+ /**
177
+ * Remove one phase's section from roadmap text. The section runs from its `Phase N:` heading
178
+ * to the first line outside a code fence that is another phase heading, a heading at the
179
+ * section's own level or above (`## Progress`, a milestone group), a `</details>` that closes
180
+ * an enclosing block, or a collapsed milestone's `<details>` (its `<summary>` names a version
181
+ * or a phase range). Any other `<details>` block the section opens and closes itself is part
182
+ * of it. Running to the end of the file deleted `## Progress` with the last phase and the next
183
+ * milestone group's heading with a group's last phase; a line-blind bound stopped at a
184
+ * `# comment` or `## Usage` inside a fenced block.
185
+ * @param {string} content - roadmap.md text (LF or CRLF; line endings are kept)
186
+ * @param {string} phaseNum - the phase number as written in its heading
187
+ * @returns {string} the text without that section (unchanged when no heading matches)
188
+ */
189
+ function removePhaseSection(content, phaseNum) {
190
+ const lines = content.split('\n');
191
+ const fenced = new Array(lines.length).fill(false);
192
+ // A fence line with an info string opens a block, even inside another (a nested example);
193
+ // a bare one closes the innermost block it can close. A block never closed stays unfenced.
194
+ for (let i = 0, stack = []; i < lines.length; i++) {
195
+ const f = lines[i].match(/^\s{0,3}(`{3,}|~{3,})(.*)/);
196
+ if (!f) continue;
197
+ const top = stack[stack.length - 1];
198
+ if (top && !f[2].trim() && f[1][0] === top.fence[0] && f[1].length >= top.fence.length) {
199
+ stack.pop();
200
+ fenced.fill(true, top.open, i + 1);
201
+ } else if (!top || f[2].trim()) stack.push({ fence: f[1], open: i });
202
+ }
203
+ const headRe = new RegExp(`^(#{2,4})\\s*Phase\\s+${escapeRegex(phaseNum)}\\s*:`, 'i');
204
+ const start = lines.findIndex((l, i) => !fenced[i] && headRe.test(l));
205
+ if (start === -1) return content;
206
+ const levelEndRe = new RegExp(`^#{1,${lines[start].match(headRe)[1].length}}\\s`);
207
+ const endsSection = (i) => !fenced[i] && (/^#{2,4}\s*Phase\s+\d/i.test(lines[i]) || levelEndRe.test(lines[i]));
208
+ const opens = (i) => !fenced[i] && /^\s*<details\b/i.test(lines[i]);
209
+ const closes = (i) => !fenced[i] && /^\s*<\/details\b/i.test(lines[i]);
210
+ const oneLine = (i) => /<\/details\s*>/i.test(lines[i]);
211
+ // A collapsed milestone's <summary> names its version or phase range ("v1.0 MVP (Phases 1-4)").
212
+ const milestone = (i) => /<summary>[^<]*\b(?:v\d+\.\d+|phases\s+\d)/i.test(`${lines[i]} ${lines[i + 1] || ''}`);
213
+ // A <details> closed before anything that ends the section is the section's own block.
214
+ const ownBlock = (at) => {
215
+ for (let i = at + 1, d = 0; i < lines.length; i++) {
216
+ if (endsSection(i)) return false;
217
+ if (opens(i) && !oneLine(i)) d++;
218
+ else if (closes(i)) { if (d === 0) return true; d--; }
219
+ }
220
+ return false;
221
+ };
222
+ let end = start + 1;
223
+ for (let depth = 0; end < lines.length; end++) {
224
+ if (endsSection(end)) break;
225
+ if (opens(end)) {
226
+ if (depth === 0 && milestone(end)) break;
227
+ if (oneLine(end)) continue;
228
+ if (depth > 0 || ownBlock(end)) { depth++; continue; }
229
+ break;
230
+ }
231
+ if (closes(end)) { if (depth > 0) { depth--; continue; } break; }
232
+ }
233
+ const toEof = end === lines.length;
234
+ lines.splice(start, end - start);
235
+ let out = lines.join('\n');
236
+ // A section that ran to the end of the file took the final line ending with it.
237
+ if (toEof && content.endsWith('\n')) {
238
+ out = out.replace(/\r$/, '');
239
+ if (!out.endsWith('\n')) out += content.endsWith('\r\n') ? '\r\n' : '\n';
240
+ }
241
+ return out;
242
+ }
243
+
176
244
  /**
177
245
  * Rewrite roadmap.md after a phase is removed: delete the target section,
178
246
  * remove checkbox/table references, and renumber subsequent phase references.
@@ -189,14 +257,8 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
189
257
  roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
190
258
  } catch { return; }
191
259
 
192
- // Remove the target phase section from roadmap.md.
193
- // Matches from the phase heading to the next phase heading (or end of file).
260
+ roadmapContent = removePhaseSection(roadmapContent, phaseNum);
194
261
  const targetEscaped = escapeRegex(phaseNum);
195
- const sectionPattern = new RegExp(
196
- `\\n?#{2,4}\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`,
197
- 'i'
198
- );
199
- roadmapContent = roadmapContent.replace(sectionPattern, '');
200
262
 
201
263
  // Remove checkbox list items referencing this phase
202
264
  const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
@@ -207,8 +269,9 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
207
269
  roadmapContent = roadmapContent.replace(tableRowPattern, '');
208
270
 
209
271
  // For integer phase removal, renumber all references to subsequent phases.
210
- // Walk from highest phase number down to removedInt+1, decrementing each by 1.
211
- // This avoids double-renaming (e.g. 8->7 then 7->6 would break if done ascending).
272
+ // Walk UP from removedInt+1, decrementing each by 1. A downward walk re-hits its
273
+ // own output (10->9, then that 9 and the real 9 ->8), so every later phase
274
+ // collapsed onto the removed number.
212
275
  if (!isDecimal) {
213
276
  const removedInt = parseInt(normalized, 10);
214
277
 
@@ -219,35 +282,35 @@ function updateRoadmapAfterRemoval(cwd, phaseNum, isDecimal, normalized) {
219
282
  // and an unbounded lower end is how `phase remove -1234567890` turned into
220
283
  // ~1.2 billion regex passes. The loop can now never exceed maxPhase steps.
221
284
  const stopAt = Math.max(removedInt, 0);
222
- for (let oldNum = maxPhase; oldNum > stopAt; oldNum--) {
285
+ for (let oldNum = stopAt + 1; oldNum <= maxPhase; oldNum++) {
223
286
  const newNum = oldNum - 1;
224
287
  const oldStr = String(oldNum);
225
288
  const newStr = String(newNum);
226
289
  const oldPad = oldStr.padStart(2, '0');
227
290
  const newPad = newStr.padStart(2, '0');
228
291
 
229
- // Phase headings: ## Phase 18: or ### Phase 18: -> ## Phase 17:
292
+ // Phase headings: ## Phase 18: or ### Phase 18.1: -> ## Phase 17: / ### Phase 17.1:
230
293
  roadmapContent = roadmapContent.replace(
231
- new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
294
+ new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}((?:\\.\\d+)?\\s*:)`, 'gi'),
232
295
  `$1${newStr}$2`
233
296
  );
234
297
 
235
- // Inline phase references: "Phase 18:" or "Phase 18 " -> "Phase 17:"
298
+ // Inline phase references: "Phase 18:", "Phase 18 " or "Phase 18.1:" -> "Phase 17..."
236
299
  roadmapContent = roadmapContent.replace(
237
- new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
300
+ new RegExp(`(Phase\\s+)${oldStr}((?:\\.\\d+)?[:\\s])`, 'g'),
238
301
  `$1${newStr}$2`
239
302
  );
240
303
 
241
- // Plan references in padded form: 18-01 -> 17-01
304
+ // Plan references in padded form: 18-01 / 18.1-01 -> 17-01 / 17.1-01, never inside a date (2026-10-01)
242
305
  roadmapContent = roadmapContent.replace(
243
- new RegExp(`${oldPad}-(\\d{2})`, 'g'),
244
- `${newPad}-$1`
306
+ new RegExp(`(?<![\\d.-])${oldPad}((?:\\.\\d+)?)-(\\d{2})(?!\\d)`, 'g'),
307
+ `${newPad}$1-$2`
245
308
  );
246
309
 
247
- // Progress table row numbers: | 18. -> | 17.
310
+ // Progress table row numbers: | 18. or | 18.1. -> | 17. / | 17.1.
248
311
  roadmapContent = roadmapContent.replace(
249
- new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
250
- `$1${newStr}. `
312
+ new RegExp(`(\\|\\s*)${oldStr}((?:\\.\\d+)?)\\.\\s`, 'g'),
313
+ `$1${newStr}$2. `
251
314
  );
252
315
 
253
316
  // Depends-on references: "Depends on:** Phase 18" -> "Phase 17"
@@ -279,7 +342,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
279
342
  // PHASE_NUM_RE does not match — so a bad argument used to reach the renumber
280
343
  // logic and do real damage, in both cases reporting success and exiting 0:
281
344
  // `phase remove 0` renumbered EVERY roadmap heading down to "Phase 0"
282
- // (the descending renumber loop runs 99..1 and re-hits
345
+ // (the renumber loop then ran down from 99 and re-hit
283
346
  // its own output, collapsing 3->2->1->0) and renamed
284
347
  // every phase directory one lower.
285
348
  // `phase remove -1e9` spun that same loop ~1.2 billion times, each pass
@@ -425,6 +488,7 @@ module.exports = {
425
488
  collectDirsToRenumber,
426
489
  renamePhaseDir,
427
490
  renumberIntegerPhases,
491
+ removePhaseSection,
428
492
  updateRoadmapAfterRemoval,
429
493
  cmdPhaseRemove,
430
494
  updateStateAfterPhaseRemoval,
@@ -96,14 +96,20 @@ function resolvePhase(cwd, phaseNumber) {
96
96
  function extractPhaseGoal(cwd, phaseNumber) {
97
97
  const content = safeReadFile(path.join(planningPath(cwd), ROADMAP_FILE));
98
98
  if (!content) return { objective: null, success_criteria: [] };
99
- const re = new RegExp('#{2,4}\\s*Phase\\s+' + escapeRegex(String(phaseNumber)) + ':\\s*([^\\n]+)', 'i');
99
+ // Padded or not: the number comes from the zero-padded phase directory ("02"),
100
+ // while PAN writes `### Phase 2:` headings, so the report found no goal.
101
+ const unpadded = String(phaseNumber).trim().replace(/^0+(?=\d)/, '');
102
+ const re = new RegExp('#{2,4}\\s*Phase\\s+0*' + escapeRegex(unpadded) + ':\\s*([^\\n]+)', 'i');
100
103
  const m = content.match(re);
101
104
  if (!m) return { objective: null, success_criteria: [] };
102
105
  const start = m.index;
103
106
  const rest = content.slice(start + 1);
104
107
  const nextH = rest.match(/\n#{2,4}\s+Phase\s+\d/i);
105
108
  const end = nextH ? start + 1 + nextH.index : content.length;
106
- const section = content.slice(start, end);
109
+ let section = content.slice(start, end);
110
+ // A phase moved out by `roadmap compact` keeps a stub; its criteria are in the history.
111
+ const compacted = require('./roadmap-compact.cjs').compactedPhaseSection(cwd, phaseNumber, section);
112
+ if (compacted && compacted.section) section = compacted.section;
107
113
  const gm = section.match(/(?:\*\*Goal:\*\*|\*\*Goal\*\*:)\s*([^\n]+)/i);
108
114
  const objective = gm ? gm[1].trim() : null;
109
115
  const cm = section.match(/\*\*Success Criteria\*\*[^\n]*:\s*\n((?:\s*\d+\.\s*[^\n]+\n?)+)/i);