pan-wizard 3.36.0 → 3.37.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 (314) hide show
  1. package/CHANGELOG.md +3645 -0
  2. package/README.md +36 -48
  3. package/agents/pan-conductor.md +2 -3
  4. package/agents/pan-counterfactual.md +1 -2
  5. package/agents/pan-debugger.md +14 -3
  6. package/agents/pan-design-checker.md +1 -2
  7. package/agents/pan-designer.md +1 -2
  8. package/agents/pan-document_code.md +1 -2
  9. package/agents/pan-hardener.md +2 -3
  10. package/agents/pan-integration-checker.md +1 -2
  11. package/agents/pan-knowledge.md +1 -2
  12. package/agents/pan-meta-reviewer.md +3 -3
  13. package/agents/pan-optimizer.md +4 -4
  14. package/agents/pan-phase-researcher.md +79 -488
  15. package/agents/pan-plan-checker.md +12 -2
  16. package/agents/pan-planner.md +10 -6
  17. package/agents/pan-previewer.md +1 -2
  18. package/agents/pan-project-researcher.md +70 -499
  19. package/agents/pan-release.md +1 -1
  20. package/agents/pan-reviewer.md +9 -3
  21. package/agents/pan-roadmapper.md +12 -7
  22. package/agents/pan-verifier.md +7 -2
  23. package/bin/install-lib.cjs +134 -14
  24. package/bin/install.js +59 -12
  25. package/commands/pan/army.md +1 -1
  26. package/commands/pan/autodev.md +76 -0
  27. package/commands/pan/exec-phase.md +3 -4
  28. package/commands/pan/focus-auto.md +14 -7
  29. package/commands/pan/focus-design.md +4 -5
  30. package/commands/pan/focus-docs.md +24 -0
  31. package/commands/pan/learn.md +13 -6
  32. package/commands/pan/milestone-audit.md +8 -1
  33. package/commands/pan/milestone-done.md +10 -5
  34. package/commands/pan/milestone-new.md +2 -2
  35. package/commands/pan/new-project.md +15 -12
  36. package/commands/pan/plan-phase.md +15 -9
  37. package/commands/pan/quick.md +1 -1
  38. package/commands/pan/review-deep.md +11 -5
  39. package/commands/pan/settings.md +6 -0
  40. package/commands/pan/verify-phase.md +6 -2
  41. package/docs/AGENTS.md +917 -0
  42. package/docs/ARCHITECTURE.md +1054 -0
  43. package/docs/CLI-REFERENCE.md +4582 -0
  44. package/docs/COMPARISON.md +116 -0
  45. package/docs/DEVELOPMENT.md +490 -0
  46. package/docs/ECOSYSTEM-REVIEW-2026-06.md +107 -0
  47. package/docs/ECOSYSTEM-REVIEW-2026-08.md +349 -0
  48. package/docs/EXAMPLES.md +584 -0
  49. package/docs/FAQ.md +254 -0
  50. package/docs/FIELD-HARVEST-2026-07.md +88 -0
  51. package/docs/FIELD-REPORT-army-2026-06.md +121 -0
  52. package/docs/HOOKS.md +638 -0
  53. package/docs/IMPROVEMENT-TODO.md +246 -0
  54. package/docs/INTERNALS.md +485 -0
  55. package/docs/MIGRATION-v2-to-v3.md +258 -0
  56. package/docs/SECURITY-HARDENING.md +230 -0
  57. package/docs/SKILLS-ENHANCEMENT-PLAN-V2.md +122 -0
  58. package/docs/SKILLS-FULL-TEXT.md +9685 -0
  59. package/docs/SKILLS-REFERENCE.md +1037 -0
  60. package/docs/TROUBLESHOOTING.md +1084 -0
  61. package/docs/USER-GUIDE.md +1437 -0
  62. package/docs/archive/SKILLS-ENHANCEMENT-PLAN.md +230 -0
  63. package/docs/audits/AUDIT-2026-08-e2e-ROADMAP.md +117 -0
  64. package/docs/audits/AUDIT-2026-08-e2e-VERIFICATION.md +693 -0
  65. package/docs/audits/AUDIT-2026-08-e2e.md +1546 -0
  66. package/docs/audits/OUTSTANDING-2026-08.md +275 -0
  67. package/docs/audits/STABILITY-TEST-2026-08.md +640 -0
  68. package/docs/branding/README-brand-draft.md +119 -0
  69. package/docs/branding/handoff/IMPLEMENTATION.md +205 -0
  70. package/docs/branding/handoff/README-brand-draft.md +119 -0
  71. package/docs/branding/handoff/image-prompts.md +46 -0
  72. package/docs/branding/image-prompts.md +46 -0
  73. package/docs/branding/nano-banana-prompts.txt +60 -0
  74. package/docs/context-monitor.md +13 -0
  75. package/docs/decisions/ADR-0001-strategic-feature-prioritization.md +83 -0
  76. package/docs/decisions/ADR-0002-copilot-cli-runtime.md +71 -0
  77. package/docs/decisions/ADR-0003-smart-execution.md +84 -0
  78. package/docs/decisions/ADR-0004-copilot-cli-interaction.md +89 -0
  79. package/docs/decisions/ADR-0005-command-naming.md +80 -0
  80. package/docs/decisions/ADR-0006-focus-commands.md +101 -0
  81. package/docs/decisions/ADR-0007-ship-v1.md +73 -0
  82. package/docs/decisions/ADR-0008-map-codebase-mermaid-togaf.md +80 -0
  83. package/docs/decisions/ADR-0009-production-deployment-checklist.md +68 -0
  84. package/docs/decisions/ADR-0010-standards-integration.md +79 -0
  85. package/docs/decisions/ADR-0011-help-commands-rewrite.md +64 -0
  86. package/docs/decisions/ADR-0012-contributors-attribution-index.md +42 -0
  87. package/docs/decisions/ADR-0013-strategic-feature-additions.md +53 -0
  88. package/docs/decisions/ADR-0014-internal-cleanup-code-quality.md +53 -0
  89. package/docs/decisions/ADR-0015-focus-auto-runner.md +296 -0
  90. package/docs/decisions/ADR-0016-enhanced-doc-sync.md +54 -0
  91. package/docs/decisions/ADR-0017-enhanced-focus-design.md +69 -0
  92. package/docs/decisions/ADR-0018-user-reality-testing.md +84 -0
  93. package/docs/decisions/ADR-0019-end-to-end-user-acceptance-testing.md +113 -0
  94. package/docs/decisions/ADR-0020-ai-drift-prevention.md +55 -0
  95. package/docs/decisions/ADR-0021-codebase-mapper-v2.md +76 -0
  96. package/docs/decisions/ADR-0022-lifecycle-completeness.md +63 -0
  97. package/docs/decisions/ADR-0023-opus-4-7-adoption.md +89 -0
  98. package/docs/decisions/ADR-0024-spec-b-v2-completion.md +137 -0
  99. package/docs/decisions/ADR-0025-behavioral-guardrails-layer.md +129 -0
  100. package/docs/decisions/ADR-0026-self-improvement-loop.md +171 -0
  101. package/docs/decisions/ADR-0027-doc-code-link-graph.md +164 -0
  102. package/docs/decisions/ADR-0028-unified-agents-skills-target.md +46 -0
  103. package/docs/decisions/ADR-0029-experiment-evaluator.md +76 -0
  104. package/docs/decisions/ADR-0030-planning-concurrency.md +37 -0
  105. package/docs/decisions/ADR-0031-focus-auto-backlog-source.md +47 -0
  106. package/docs/decisions/ADR-0032-squad-model.md +64 -0
  107. package/docs/decisions/ADR-0033-army-campaign.md +40 -0
  108. package/docs/decisions/ADR-0034-scheduled-campaigns.md +57 -0
  109. package/docs/decisions/ADR-0035-army-hud-dashboard.md +40 -0
  110. package/docs/decisions/ADR-0036-agentic-memory-architecture.md +106 -0
  111. package/docs/decisions/ADR-0037-pin-security-agents-to-opus.md +51 -0
  112. package/docs/decisions/ADR-0038-skill-aligned-decomposition.md +41 -0
  113. package/docs/decisions/ADR-0039-worktree-as-project-variant.md +37 -0
  114. package/docs/decisions/ADR-0040-memory-injection-defense.md +55 -0
  115. package/docs/decisions/ADR-0041-mcp-dual-era-bridge.md +47 -0
  116. package/docs/decisions/ADR-0042-design-parity-both-flows.md +45 -0
  117. package/docs/decisions/ADR-0043-addressable-planning-roots.md +56 -0
  118. package/docs/decisions/ADR-0044-bound-the-cached-context.md +104 -0
  119. package/docs/decisions/ADR-0045-agent-plugins-bundle.md +76 -0
  120. package/docs/decisions/ADR-0046-subagent-frontmatter-decisions.md +39 -0
  121. package/docs/decisions/ADR-0047-pan-harness.md +76 -0
  122. package/docs/decisions/ADR-0048-one-core-per-project.md +43 -0
  123. package/docs/decisions/ADR-0049-machine-verdicts-and-findings-ledger.md +116 -0
  124. package/docs/specs/ai_drift_prevention_featureai.md +286 -0
  125. package/docs/specs/codebase_mapper_v2_featureai.md +930 -0
  126. package/docs/specs/command_naming_optimization_featureai.md +930 -0
  127. package/docs/specs/comp_analysis_markdown_v1.md +149 -0
  128. package/docs/specs/continuous_autonomous_execution_featureai.md +304 -0
  129. package/docs/specs/contributors_attribution_index_featureai.md +476 -0
  130. package/docs/specs/copilot_cli_interaction_featureai.md +868 -0
  131. package/docs/specs/copilot_cli_runtime_featureai.md +647 -0
  132. package/docs/specs/custom-skills-assessment.md +461 -0
  133. package/docs/specs/deployment_audit_findings_featureai.md +243 -0
  134. package/docs/specs/doc_code_link_graph_featureai.md +253 -0
  135. package/docs/specs/documentation_alignment_cleanup_featureai.md +69 -0
  136. package/docs/specs/e2e_visual_testing_featureai.md +615 -0
  137. package/docs/specs/end_to_end_user_acceptance_testing_featureai.md +810 -0
  138. package/docs/specs/evidence-loop-plan.md +76 -0
  139. package/docs/specs/evidence_loop_featureai.md +497 -0
  140. package/docs/specs/focus-auto-optimize-category.md +449 -0
  141. package/docs/specs/focus-auto-optimize-superplan.md +188 -0
  142. package/docs/specs/focus_auto_runner_featureai.md +1102 -0
  143. package/docs/specs/focus_design_v2_featureai.md +197 -0
  144. package/docs/specs/focus_sync_v2_featureai.md +202 -0
  145. package/docs/specs/git_integration_overhaul_featureai.md +326 -0
  146. package/docs/specs/glm_zai_provider_featureai.md +200 -0
  147. package/docs/specs/googlecli_adoption_featureai.md +661 -0
  148. package/docs/specs/help_commands_rewrite_featureai.md +860 -0
  149. package/docs/specs/industry_analysis_strategic_additions_featureai.md +231 -0
  150. package/docs/specs/industry_comparison_and_feature_gaps_featureai.md +674 -0
  151. package/docs/specs/internal_cleanup_code_quality_featureai.md +486 -0
  152. package/docs/specs/investigations/README.md +31 -0
  153. package/docs/specs/investigations/digests/2026-09-28.md +47 -0
  154. package/docs/specs/investigations/digests/2026-10-08.md +62 -0
  155. package/docs/specs/investigations/digests/2026-10-10.md +61 -0
  156. package/docs/specs/investigations/features/debugger-red-loop-first.md +55 -0
  157. package/docs/specs/investigations/features/harness-cross-host-behaviour.md +93 -0
  158. package/docs/specs/investigations/features/optimize-apply-regression-gate.md +66 -0
  159. package/docs/specs/investigations/features/reviewer-smell-baseline.md +57 -0
  160. package/docs/specs/investigations/features/trace-failure-capture.md +69 -0
  161. package/docs/specs/lifecycle_completeness_featureai.md +754 -0
  162. package/docs/specs/local-first-ci.md +42 -0
  163. package/docs/specs/map_codebase_mermaid_togaf_featureai.md +805 -0
  164. package/docs/specs/market-delta-2026-09-superplan.md +310 -0
  165. package/docs/specs/market-ideas-2026-09.md +131 -0
  166. package/docs/specs/market-ideas-2026-10.md +428 -0
  167. package/docs/specs/market-ideas-ledger.md +177 -0
  168. package/docs/specs/memory-optimization-2026-10.md +285 -0
  169. package/docs/specs/multi-track-planning-roots.md +143 -0
  170. package/docs/specs/multi_model_routing_featureai.md +687 -0
  171. package/docs/specs/multi_model_routing_superplan.md +197 -0
  172. package/docs/specs/opus_47_existing_enhancements_featureai.md +247 -0
  173. package/docs/specs/opus_47_extended_features_featureai.md +363 -0
  174. package/docs/specs/opus_47_extended_features_v2_featureai.md +397 -0
  175. package/docs/specs/pan_focus_commands_featureai.md +2336 -0
  176. package/docs/specs/pan_zcode_mcp_bridge_featureai.md +120 -0
  177. package/docs/specs/product-excellence-superplan.md +315 -0
  178. package/docs/specs/product_excellence_program_2026_q4_featureai.md +365 -0
  179. package/docs/specs/production_deployment_readiness_featureai.md +433 -0
  180. package/docs/specs/production_readiness_ship_v1_featureai.md +698 -0
  181. package/docs/specs/real_test_enforcement_featureai.md +213 -0
  182. package/docs/specs/reality-check-2026-09-superplan.md +362 -0
  183. package/docs/specs/right-sized-ceremony.md +74 -0
  184. package/docs/specs/roadmap_and_competition_2026_q4_featureai.md +390 -0
  185. package/docs/specs/self_improvement_loop_featureai.md +875 -0
  186. package/docs/specs/skill-aligned-decomposition.md +155 -0
  187. package/docs/specs/standards_integration_featureai.md +833 -0
  188. package/docs/specs/testing-system-redesign-2026-09.md +267 -0
  189. package/docs/specs/user_reality_testing_system_featureai.md +788 -0
  190. package/docs/specs/watch-triggers.json +22 -0
  191. package/docs/specs/workflow-integration-redesign.md +742 -0
  192. package/docs/specs/workflow_state_consistency_featureai.md +183 -0
  193. package/hooks/dist/pan-context-monitor.js +9 -2
  194. package/hooks/dist/pan-cost-logger.js +5 -0
  195. package/hooks/dist/pan-guard.js +28 -5
  196. package/hooks/dist/pan-stop-guard.js +9 -9
  197. package/hooks/dist/pan-trace-logger.js +5 -0
  198. package/package.json +10 -2
  199. package/pan-wizard-core/bin/lib/agents-md.cjs +57 -8
  200. package/pan-wizard-core/bin/lib/autodev-report.cjs +180 -0
  201. package/pan-wizard-core/bin/lib/autodev.cjs +642 -0
  202. package/pan-wizard-core/bin/lib/ci.cjs +143 -0
  203. package/pan-wizard-core/bin/lib/commands.cjs +1 -1
  204. package/pan-wizard-core/bin/lib/config.cjs +31 -2
  205. package/pan-wizard-core/bin/lib/constants.cjs +27 -1
  206. package/pan-wizard-core/bin/lib/core.cjs +22 -10
  207. package/pan-wizard-core/bin/lib/cost.cjs +14 -4
  208. package/pan-wizard-core/bin/lib/cross-family.cjs +164 -0
  209. package/pan-wizard-core/bin/lib/debug-cases.cjs +104 -0
  210. package/pan-wizard-core/bin/lib/failure-class.cjs +81 -0
  211. package/pan-wizard-core/bin/lib/findings.cjs +34 -4
  212. package/pan-wizard-core/bin/lib/focus.cjs +1 -1
  213. package/pan-wizard-core/bin/lib/hosts.cjs +88 -0
  214. package/pan-wizard-core/bin/lib/init.cjs +7 -2
  215. package/pan-wizard-core/bin/lib/knowledge.cjs +2 -0
  216. package/pan-wizard-core/bin/lib/merge.cjs +50 -0
  217. package/pan-wizard-core/bin/lib/mutate.cjs +16 -5
  218. package/pan-wizard-core/bin/lib/next-action.cjs +112 -0
  219. package/pan-wizard-core/bin/lib/optimize.cjs +2 -2
  220. package/pan-wizard-core/bin/lib/phase.cjs +48 -4
  221. package/pan-wizard-core/bin/lib/plan-lint.cjs +150 -1
  222. package/pan-wizard-core/bin/lib/preview.cjs +23 -4
  223. package/pan-wizard-core/bin/lib/requirements-lint.cjs +130 -0
  224. package/pan-wizard-core/bin/lib/research-lint.cjs +150 -3
  225. package/pan-wizard-core/bin/lib/review-deep.cjs +14 -22
  226. package/pan-wizard-core/bin/lib/review-vote.cjs +105 -0
  227. package/pan-wizard-core/bin/lib/runner.cjs +2 -16
  228. package/pan-wizard-core/bin/lib/sizing.cjs +356 -0
  229. package/pan-wizard-core/bin/lib/squads.cjs +1 -1
  230. package/pan-wizard-core/bin/lib/state-write.cjs +247 -0
  231. package/pan-wizard-core/bin/lib/uat.cjs +149 -0
  232. package/pan-wizard-core/bin/lib/verdict.cjs +26 -3
  233. package/pan-wizard-core/bin/lib/verify-plan-set.cjs +30 -1
  234. package/pan-wizard-core/bin/lib/verify-preflight.cjs +2 -2
  235. package/pan-wizard-core/bin/lib/verify-red.cjs +37 -1
  236. package/pan-wizard-core/bin/lib/verify-retro.cjs +3 -2
  237. package/pan-wizard-core/bin/lib/verify.cjs +59 -4
  238. package/pan-wizard-core/bin/lib/worktree.cjs +70 -0
  239. package/pan-wizard-core/bin/pan-tools.cjs +168 -27
  240. package/pan-wizard-core/learnings/universal/autonomous-loop.md +2 -2
  241. package/pan-wizard-core/references/continuation-format.md +3 -3
  242. package/pan-wizard-core/references/guardrails.md +1 -1
  243. package/pan-wizard-core/references/model-profiles.md +2 -3
  244. package/pan-wizard-core/references/questioning.md +24 -0
  245. package/pan-wizard-core/references/ui-brand.md +4 -6
  246. package/pan-wizard-core/references/verdict-contract.md +2 -1
  247. package/pan-wizard-core/templates/config.json +5 -1
  248. package/pan-wizard-core/templates/debug.md +1 -0
  249. package/pan-wizard-core/templates/discovery.md +2 -2
  250. package/pan-wizard-core/templates/requirements.md +9 -0
  251. package/pan-wizard-core/workflows/add-phase.md +4 -6
  252. package/pan-wizard-core/workflows/assumptions.md +19 -1
  253. package/pan-wizard-core/workflows/diagnose-issues.md +2 -0
  254. package/pan-wizard-core/workflows/discuss-phase.md +4 -2
  255. package/pan-wizard-core/workflows/exec-lean.md +69 -0
  256. package/pan-wizard-core/workflows/exec-phase.md +36 -8
  257. package/{commands/pan → pan-wizard-core/workflows}/focus-doc-audit.md +13 -29
  258. package/{commands/pan → pan-wizard-core/workflows}/focus-drift-walking.md +3 -18
  259. package/{commands/pan → pan-wizard-core/workflows}/focus-exec.md +8 -23
  260. package/{commands/pan → pan-wizard-core/workflows}/focus-plan.md +6 -20
  261. package/{commands/pan → pan-wizard-core/workflows}/focus-scan.md +3 -16
  262. package/{commands/pan → pan-wizard-core/workflows}/focus-sync.md +7 -21
  263. package/pan-wizard-core/workflows/health.md +6 -0
  264. package/pan-wizard-core/workflows/help.md +31 -53
  265. package/pan-wizard-core/workflows/insert-phase.md +4 -6
  266. package/pan-wizard-core/workflows/learn.md +3 -3
  267. package/pan-wizard-core/workflows/milestone-audit.md +2 -2
  268. package/pan-wizard-core/workflows/milestone-new.md +38 -95
  269. package/pan-wizard-core/workflows/new-project.md +96 -258
  270. package/pan-wizard-core/workflows/optimize.md +107 -5
  271. package/pan-wizard-core/workflows/phase-tests.md +10 -0
  272. package/pan-wizard-core/workflows/plan-phase.md +86 -43
  273. package/pan-wizard-core/workflows/profile.md +50 -0
  274. package/pan-wizard-core/workflows/progress.md +10 -0
  275. package/pan-wizard-core/workflows/quick.md +34 -43
  276. package/pan-wizard-core/workflows/research-phase.md +118 -40
  277. package/pan-wizard-core/workflows/resume-project.md +18 -2
  278. package/pan-wizard-core/workflows/settings.md +21 -24
  279. package/pan-wizard-core/workflows/todo-check.md +6 -2
  280. package/pan-wizard-core/workflows/transition.md +22 -4
  281. package/pan-wizard-core/workflows/uat-walkthrough.md +90 -0
  282. package/pan-wizard-core/workflows/update.md +1 -1
  283. package/pan-wizard-core/workflows/verify-phase.md +2 -2
  284. package/scripts/build-agent-plugin.js +35 -15
  285. package/scripts/build-plugin.js +10 -1
  286. package/scripts/deprecate-old-versions.js +70 -3
  287. package/scripts/generate-skills-docs.py +1 -1
  288. package/scripts/git-hooks/pre-push +27 -0
  289. package/scripts/install-git-hooks.js +7 -5
  290. package/scripts/install-pre-push.cjs +38 -0
  291. package/scripts/land.cjs +151 -0
  292. package/scripts/local-ci.cjs +501 -0
  293. package/scripts/local-schedule.cjs +277 -0
  294. package/scripts/prompt-lint.cjs +32 -0
  295. package/scripts/prompt-rules.cjs +156 -0
  296. package/scripts/run-tests.cjs +7 -1
  297. package/scripts/test-quality-lint.cjs +3 -3
  298. package/scripts/test-surface.cjs +9 -2
  299. package/agents/pan-research-synthesizer.md +0 -246
  300. package/commands/pan/audit-deployment.md +0 -386
  301. package/commands/pan/dashboard.md +0 -22
  302. package/commands/pan/discord.md +0 -19
  303. package/commands/pan/mcp-bridge.md +0 -143
  304. package/commands/pan/milestone-cleanup.md +0 -19
  305. package/commands/pan/milestone-gaps.md +0 -35
  306. package/commands/pan/optimize.md +0 -115
  307. package/commands/pan/profile.md +0 -76
  308. package/commands/pan/research-phase.md +0 -189
  309. package/pan-wizard-core/bin/lib/bridge.cjs +0 -269
  310. package/pan-wizard-core/templates/research-project/architecture.md +0 -210
  311. package/pan-wizard-core/templates/research-project/features.md +0 -153
  312. package/pan-wizard-core/templates/research-project/pitfalls.md +0 -206
  313. package/pan-wizard-core/templates/research-project/stack.md +0 -126
  314. package/pan-wizard-core/templates/research-project/summary.md +0 -170
@@ -0,0 +1,642 @@
1
+ 'use strict';
2
+ /**
3
+ * Autodev: the deterministic outer loop (programs A1a and A1c).
4
+ *
5
+ * `pan-tools autodev run` is a foreground Node process, not a daemon. Each turn it reads
6
+ * the project's state from disk, asks the next-action router (the MCP orchestrator's
7
+ * state machine) for the next unit, decides in code whether to stop, and otherwise runs
8
+ * the unit: a fresh headless host session per plan, execute or verify unit (argv from
9
+ * hosts.cjs), and `phase complete` in-process for a verified phase. Prose chains drop a
10
+ * phase boundary; this loop has no model in it.
11
+ *
12
+ * The run ledger, `.planning/autodev/<run>/ledger.jsonl`, has a `started` row before a
13
+ * unit and a `finished` row after it (argv, exit, cost, start and end SHA, the disk
14
+ * snapshot after). A run killed mid-unit ends on a `started` row, and `autodev resume`
15
+ * relaunches that unit. The run facts the state machine needs (verified, merged) are
16
+ * persisted in `.planning/autodev/state.json`.
17
+ *
18
+ * Stop reasons, in code: done · human_needed · gate_failed · forecast_exceeded ·
19
+ * budget_usd · turns · no_progress.
20
+ *
21
+ * A failed session unit goes through the failure-class router (A4, failure-class.cjs):
22
+ * a retry with the error tail, a fresh restart, a tier escalation, quarantine, or a stop
23
+ * for a human. Spend is governed between units (A6): a run cap (`--max-usd` or
24
+ * `budget.max_usd_run`, required: it is the run's approval) and a day cap (`--max-usd-day`
25
+ * or `budget.max_usd_day`) over every run's ledger; Claude gets the remainder as
26
+ * `--max-budget-usd`; an execute unit that costs more than the P6 p90 forecast stops the run.
27
+ */
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const crypto = require('crypto');
32
+ const { spawnSync } = require('child_process');
33
+ const { output } = require('./core.cjs');
34
+ const { planningPath } = require('./utils.cjs');
35
+ const { HOSTS } = require('./hosts.cjs');
36
+ const { routeNextAction, diskSnapshot } = require('./next-action.cjs');
37
+
38
+ const STOP_REASONS = Object.freeze(['done', 'human_needed', 'gate_failed', 'ci_failed', 'forecast_exceeded', 'budget_usd', 'turns', 'no_progress']);
39
+ const FC = require('./failure-class.cjs');
40
+ // The tier an escalation climbs to, as the alias Claude Code's --model takes.
41
+ const CLAUDE_MODEL_FOR_TIER = { reasoning: 'opus', mid: 'sonnet', fast: 'haiku' };
42
+ const DEFAULT_MAX_TURNS = 20;
43
+ const UNIT_TIMEOUT_MS = 60 * 60 * 1000;
44
+ const SESSION_ACTIONS = { plan: 'plan-phase', execute: 'exec-phase', verify: 'verify-phase', plan_gaps: 'plan-phase' };
45
+ const TOOLS = path.join(__dirname, '..', 'pan-tools.cjs');
46
+
47
+ const autodevDir = (cwd) => planningPath(cwd, 'autodev');
48
+ const unitKey = (u) => (u ? `${u.action}:${u.phase == null ? '' : u.phase}` : '');
49
+
50
+ function readJson(p, fallback) {
51
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return fallback; }
52
+ }
53
+
54
+ function readRows(file) {
55
+ try {
56
+ return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
57
+ } catch { return []; }
58
+ }
59
+
60
+ /** A hash of what the units change on disk: the phase directories, roadmap and requirements. */
61
+ function snapshotHash(cwd) {
62
+ const h = crypto.createHash('sha1');
63
+ const planning = planningPath(cwd);
64
+ for (const f of ['roadmap.md', 'requirements.md']) {
65
+ try { h.update(f + '\0' + fs.readFileSync(path.join(planning, f), 'utf8')); } catch { h.update(f + '\0-'); }
66
+ }
67
+ const phases = path.join(planning, 'phases');
68
+ const walk = (dir, rel) => {
69
+ let entries = [];
70
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); } catch { return; }
71
+ for (const e of entries) {
72
+ const p = path.join(dir, e.name);
73
+ if (e.isDirectory()) walk(p, `${rel}/${e.name}`);
74
+ else { try { const body = fs.readFileSync(p); h.update(`${rel}/${e.name}\0${body.length}\0`); h.update(body); } catch {} }
75
+ }
76
+ };
77
+ walk(phases, 'phases');
78
+ return h.digest('hex').slice(0, 16);
79
+ }
80
+
81
+ function verificationStatus(cwd, phase) {
82
+ if (phase == null) return null;
83
+ const root = planningPath(cwd, 'phases');
84
+ const num = String(phase);
85
+ const padded = num.includes('.') ? num : num.padStart(2, '0');
86
+ try {
87
+ const d = fs.readdirSync(root).find((n) => n === padded || n.startsWith(`${padded}-`) || n.startsWith(`${num}-`));
88
+ if (!d) return null;
89
+ const f = fs.readdirSync(path.join(root, d)).find((n) => n === 'verification.md' || n.endsWith('-verification.md'));
90
+ if (!f) return null;
91
+ const m = fs.readFileSync(path.join(root, d, f), 'utf8').match(/^status:\s*["']?([a-z_]+)/m);
92
+ return m ? m[1] : null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ /** The next unit, from the router, with whether a human is needed for it. */
97
+ function selectUnit(cwd) {
98
+ const r = routeNextAction(cwd);
99
+ const status = verificationStatus(cwd, r.phase);
100
+ return { action: r.action, phase: r.phase, command: r.command, reason: r.reason, human_needed: status === 'human_needed' };
101
+ }
102
+
103
+ /** The last started unit with no finished row after it, or null. */
104
+ function pendingUnit(rows) {
105
+ const last = rows.filter((r) => r.status === 'started' || r.status === 'finished').pop();
106
+ return last && last.status === 'started' ? last : null;
107
+ }
108
+
109
+ /**
110
+ * Whether to stop before running `unit`. Pure: the unit, the ledger rows so far, the
111
+ * limits and the current disk snapshot decide it.
112
+ * @returns {{reason: string, detail: string}|null}
113
+ */
114
+ function decideStop({ unit, rows = [], limits = {}, snapshot, daySpent = 0 }) {
115
+ const done = rows.filter((r) => r.status === 'finished');
116
+ const stop = (reason, detail) => ({ reason, detail });
117
+ if (!unit) return stop('human_needed', 'no unit could be selected');
118
+ if (unit.action === 'audit_milestone' || unit.action === 'new_milestone') return stop('done', `every phase is finished; next: ${unit.command}`);
119
+ if (unit.action === 'stop') return unit.reason === 'all_complete' ? stop('done', 'all phases complete') : stop('human_needed', `the state machine stopped: ${unit.reason}`);
120
+ if (unit.action === 'new_project') return stop('human_needed', 'no roadmap: start the project with /pan:new-project');
121
+ if (unit.human_needed) return stop('human_needed', `phase ${unit.phase}: the verifier asked for a human check`);
122
+ const last = done[done.length - 1];
123
+ if (last && last.unit && last.unit.action === 'complete_phase' && last.exit !== 0) {
124
+ return stop('gate_failed', `phase ${last.unit.phase}: ${last.error || 'phase complete refused'}${last.reason ? ` (${last.reason})` : ''}`);
125
+ }
126
+ if (last && last.forecast_p90 != null && Number(last.cost_usd) > last.forecast_p90) {
127
+ return stop('forecast_exceeded', `${unitKey(last.unit)} cost $${Number(last.cost_usd).toFixed(2)}, past the p90 forecast of $${Number(last.forecast_p90).toFixed(2)}`);
128
+ }
129
+ const spent = done.reduce((n, r) => n + (Number(r.cost_usd) || 0), 0);
130
+ if (limits.maxUsd != null && spent >= limits.maxUsd) return stop('budget_usd', `spent $${spent.toFixed(2)} of $${Number(limits.maxUsd).toFixed(2)}`);
131
+ if (limits.maxUsdDay != null && daySpent >= limits.maxUsdDay) return stop('budget_usd', `spent $${Number(daySpent).toFixed(2)} today, the day cap is $${Number(limits.maxUsdDay).toFixed(2)}`);
132
+ const maxTurns = limits.maxTurns == null ? DEFAULT_MAX_TURNS : limits.maxTurns;
133
+ if (done.length >= maxTurns) return stop('turns', `${done.length} units run (max ${maxTurns})`);
134
+ if (last && unitKey(last.unit) === unitKey(unit) && last.snapshot_after === snapshot) {
135
+ return stop('no_progress', `${unitKey(unit)} ran and changed nothing on disk`);
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /** One shell-safe line: the command to run, unattended, and nothing else. */
141
+ /** The lean-phase workflow in the installed core: one session executes and verifies (exec-lean.md). */
142
+ const LEAN_WORKFLOW = path.join(__dirname, '..', '..', 'workflows', 'exec-lean.md').split(path.sep).join('/');
143
+
144
+ function unitPrompt(host, unit, note) {
145
+ const clean = (t) => String(t || '').replace(/[\r\n&|<>%^"]/g, ' ').replace(/\s+/g, ' ').trim();
146
+ if (unit.action === 'execute' && unit.lean) {
147
+ // A lean phase runs in one session (2026-10-25: the orchestrator, a separate executor and
148
+ // the verifier cost about $1.40 a phase, the orchestrator's 11k-token workflow most of it).
149
+ const base = `PAN autodev unit, lean phase ${unit.phase}: read ${LEAN_WORKFLOW} and follow it for phase ${unit.phase}${unit.plan ? ` (plan ${unit.plan} only)` : ''}. Unattended: do not ask questions, take the recommended default and record what you assumed; do not start another phase or command.`;
150
+ return note ? `${base} ${clean(note)}` : base;
151
+ }
152
+ if (unit.action === 'ci_fix') {
153
+ return `PAN autodev CI fix round ${unit.round} for phase ${unit.phase}: the pull request checks failed. ${clean(note)} Fix the code so they pass and commit the fix. Do not ask questions. Do not start another phase or command.`;
154
+ }
155
+ const name = SESSION_ACTIONS[unit.action];
156
+ // --quality (the full ceremony, sizing.cjs) is passed to every phase command the loop runs.
157
+ const args = `${unit.phase}${unit.action === 'plan_gaps' ? ' --gaps' : ''}${unit.plan ? ` --plan ${unit.plan}` : ''}${unit.quality ? ' --quality' : ''}`;
158
+ const cmd = host === 'codex' ? `$pan-${name} ${args}` : host === 'opencode' ? `/pan-${name} ${args}` : `/pan:${name} ${args}`;
159
+ // The command comes first so the host expands it as a command (2026-10-24: inside a
160
+ // sentence it was not expanded, and each session spent turns finding and reading the
161
+ // workflow file). What follows reaches the workflow as part of its arguments.
162
+ const base = `${cmd} (PAN autodev unit, unattended: do not ask questions, take the recommended default and record what you assumed; do not start another phase or command when it ends.)`;
163
+ return note ? `${base} ${String(note).replace(/[\r\n&|<>%^"]/g, ' ').replace(/\s+/g, ' ').trim()}` : base;
164
+ }
165
+
166
+ function gitHead(cwd) {
167
+ try {
168
+ const r = spawnSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' });
169
+ return r.status === 0 ? r.stdout.trim() : null;
170
+ } catch { return null; }
171
+ }
172
+
173
+ function parseCost(stdout) {
174
+ const lines = String(stdout || '').trim().split(/\r?\n/);
175
+ for (let i = lines.length - 1; i >= 0; i--) {
176
+ if (!lines[i].trim().startsWith('{')) continue;
177
+ try { const o = JSON.parse(lines.slice(i).join('\n')); if (o && o.total_cost_usd != null) return Number(o.total_cost_usd); } catch {}
178
+ }
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Whether a session reported that it is blocked on a ruling only a person can give: an
184
+ * executor's BLOCKED_SPEC_CONFLICT (a test the plan does not declare asserts behaviour the
185
+ * task changes). Read from the session's result text. Pure.
186
+ * @returns {string|null} a short excerpt, or null
187
+ */
188
+ function blockedReport(stdout) {
189
+ const text = String(stdout || '');
190
+ let result = text;
191
+ const lines = text.trim().split(/\r?\n/);
192
+ for (let i = lines.length - 1; i >= 0; i--) {
193
+ if (!lines[i].trim().startsWith('{')) continue;
194
+ try { const o = JSON.parse(lines.slice(i).join('\n')); if (o && typeof o.result === 'string') { result = o.result; break; } } catch {}
195
+ }
196
+ const m = result.match(/BLOCKED_SPEC_CONFLICT|BLOCKED:\s*SPEC CONFLICT|EXECUTION HALTED|changed after they passed the plan check/i);
197
+ if (!m) return null;
198
+ return result.slice(m.index, m.index + 400).replace(/\s+/g, ' ').trim();
199
+ }
200
+
201
+ /**
202
+ * Whether a session stopped itself because its budget would not cover the rest of the
203
+ * unit (2026-10-25: "I stopped before 03-02 because the run's cost budget would not cover
204
+ * them" was logged as no_progress). Read from the session's result text. Pure.
205
+ * @returns {string|null} a short excerpt, or null
206
+ */
207
+ function budgetStopReport(stdout) {
208
+ const text = String(stdout || '');
209
+ let result = '';
210
+ const lines = text.trim().split(/\r?\n/);
211
+ for (let i = lines.length - 1; i >= 0; i--) {
212
+ if (!lines[i].trim().startsWith('{')) continue;
213
+ try { const o = JSON.parse(lines.slice(i).join('\n')); if (o && typeof o.result === 'string') { result = o.result; break; } } catch {}
214
+ }
215
+ const m = result.match(/budget (?:would|will|could) not (?:cover|allow|stretch)|(?:cost|run's|remaining|spend) budget (?:would|will|could|did) not|out of budget|budget (?:is|was|has been) (?:spent|exhausted|used up)|stopp(?:ed|ing) [^.\n]{0,60}\bbudget\b/i);
216
+ if (!m) return null;
217
+ return result.slice(Math.max(0, m.index - 120), m.index + 200).replace(/\s+/g, ' ').trim();
218
+ }
219
+
220
+ /**
221
+ * Test safety: with PAN_STANDIN_DIR set, a host CLI must be a stand-in in that directory.
222
+ * A test whose stand-in went missing would otherwise reach the real CLI, and spend.
223
+ */
224
+ function standinMissing(bin) {
225
+ const dir = process.env.PAN_STANDIN_DIR;
226
+ if (!dir) return null;
227
+ const names = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, bin] : [bin];
228
+ return names.some((n) => fs.existsSync(path.join(dir, n))) ? null : `PAN_STANDIN_DIR is set and has no stand-in for ${bin}: refusing to run the real CLI`;
229
+ }
230
+
231
+ /** The host argv for a session: hosts.cjs's, plus Claude's spend ceiling and an escalated model. */
232
+ function sessionArgv(host, prompt, opts = {}) {
233
+ const argv = HOSTS[host].runner.buildArgs(prompt, { captureMetrics: host === 'claude' });
234
+ if (host !== 'claude') return argv;
235
+ const extra = [];
236
+ if (opts.model) extra.push('--model', opts.model);
237
+ if (opts.remainingUsd != null && Number.isFinite(opts.remainingUsd)) extra.push('--max-budget-usd', Math.max(0, opts.remainingUsd).toFixed(2));
238
+ return [...argv.slice(0, -1), ...extra, argv[argv.length - 1]];
239
+ }
240
+
241
+ /** Run one unit. A session unit spawns the host; complete_phase runs pan-tools. */
242
+ function executeUnit(cwd, unit, host, opts = {}) {
243
+ if (typeof opts.execute === 'function') return opts.execute(cwd, unit, host, opts);
244
+ const t0 = Date.now();
245
+ // Right-sized ceremony, step 2: a lean phase is planned by the engine (sizing.cjs
246
+ // planLeanPhase: plan written from the requirements, checked, recorded, committed), with
247
+ // no session. When the engine declines (not lean, plans already there, the check failed),
248
+ // the unit falls through to a planning session as before.
249
+ if (unit.action === 'plan' && !unit.quality) {
250
+ const r = spawnSync(process.execPath, [TOOLS, 'size', 'phase-plan', String(unit.phase)], { cwd, encoding: 'utf8' });
251
+ if (r.status === 0) return { exit: 0, cost_usd: 0, argv: ['size', 'phase-plan', String(unit.phase)], engine: true, error: null, duration_ms: Date.now() - t0 };
252
+ }
253
+ // A lean phase's plans are checked in code, so a plan check that went stale (a session
254
+ // revised the plan after the check was recorded) is re-run here rather than stopping the
255
+ // phase (2026-10-25). The full path's checker is a model and is not replaced.
256
+ if (unit.action === 'execute' && !unit.quality) {
257
+ try {
258
+ const st = require('./verify-plan-set.cjs').planCheckStatus(cwd, String(unit.phase)).status;
259
+ if (['stale', 'missing', 'failed'].includes(st) && require('./sizing.cjs').sizePhase(cwd, String(unit.phase)).path === 'lean') {
260
+ spawnSync(process.execPath, [TOOLS, 'size', 'plan-check', String(unit.phase)], { cwd, encoding: 'utf8' });
261
+ }
262
+ } catch { /* the session's own gate decides */ }
263
+ }
264
+ if (unit.action === 'complete_phase') {
265
+ const r = spawnSync(process.execPath, [TOOLS, 'phase', 'complete', String(unit.phase)], { cwd, encoding: 'utf8' });
266
+ let res = null;
267
+ try { res = JSON.parse(r.stdout); } catch {}
268
+ const failed = r.status !== 0 || !res || res.error;
269
+ return { exit: failed ? (r.status || 1) : 0, cost_usd: 0, argv: ['phase', 'complete', String(unit.phase)], error: failed ? ((res && res.error) || 'phase_complete_failed') : null, reason: res && res.reason ? String(res.reason).slice(0, 300) : null, duration_ms: Date.now() - t0 };
270
+ }
271
+ const h = HOSTS[host];
272
+ const refused = standinMissing(h.runner.bin);
273
+ if (refused) return { exit: 127, cost_usd: 0, argv: [], error: refused, stderr_tail: refused, duration_ms: 0 };
274
+ // A lean phase executes through exec-lean.md in this one session (sizing.cjs decides).
275
+ let lean = false;
276
+ if (unit.action === 'execute' && !unit.quality) {
277
+ try { lean = require('./sizing.cjs').sizePhase(cwd, String(unit.phase)).path === 'lean'; } catch { lean = false; }
278
+ }
279
+ const prompt = unitPrompt(host, lean ? { ...unit, lean: true } : unit, opts.note);
280
+ const argv = sessionArgv(host, prompt, { remainingUsd: opts.remainingUsd, model: opts.model });
281
+ const useShell = h.runner.shell === 'win32' && process.platform === 'win32';
282
+ const quoted = useShell ? argv.map((a) => (/\s/.test(a) ? `"${String(a).replace(/"/g, '""')}"` : a)) : argv;
283
+ const r = spawnSync(h.runner.bin, quoted, {
284
+ cwd, encoding: 'utf8', shell: useShell, stdio: ['inherit', 'pipe', 'pipe'],
285
+ timeout: opts.timeoutMs || UNIT_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024,
286
+ });
287
+ const timedOut = !!(r.error && r.error.code === 'ETIMEDOUT') || r.signal === 'SIGTERM';
288
+ if (r.error && !timedOut) return { exit: 127, cost_usd: 0, argv, error: `spawn_failed: ${r.error.message}`, stderr_tail: r.error.message, duration_ms: Date.now() - t0 };
289
+ return {
290
+ exit: timedOut ? 124 : (r.status == null ? 1 : r.status), cost_usd: parseCost(r.stdout) || 0, argv, duration_ms: Date.now() - t0,
291
+ blocked: blockedReport(r.stdout) || undefined,
292
+ budget_stopped: budgetStopReport(r.stdout) || undefined,
293
+ timed_out: timedOut || undefined, stderr_tail: (r.stderr || '').slice(-500) || undefined, stdout_tail: r.status ? (r.stdout || '').slice(-500) || undefined : undefined,
294
+ };
295
+ }
296
+
297
+ function persistRunFacts(cwd) {
298
+ const p = path.join(autodevDir(cwd), 'state.json');
299
+ const st = readJson(p, { phases: {} });
300
+ for (const ph of diskSnapshot(cwd).phases) st.phases[String(ph.number)] = { verified: !!ph.verified, merged: !!ph.merged };
301
+ st.updated = new Date().toISOString();
302
+ fs.mkdirSync(path.dirname(p), { recursive: true });
303
+ fs.writeFileSync(p, JSON.stringify(st, null, 2) + '\n');
304
+ }
305
+
306
+ /** Spend recorded today (UTC) across every run's ledger. */
307
+ function daySpend(cwd, day = new Date().toISOString().slice(0, 10)) {
308
+ let total = 0;
309
+ let runs = [];
310
+ try { runs = fs.readdirSync(autodevDir(cwd)).filter((n) => n.startsWith('run-')); } catch { return 0; }
311
+ for (const r of runs) {
312
+ for (const row of readRows(path.join(autodevDir(cwd), r, 'ledger.jsonl'))) {
313
+ if (row.status === 'finished' && String(row.ts || '').startsWith(day)) total += Number(row.cost_usd) || 0;
314
+ }
315
+ }
316
+ return total;
317
+ }
318
+
319
+ /** The p90 forecast for an execute unit (per-plan p90 × the phase's plans), or null. */
320
+ function unitForecast(cwd, unit) {
321
+ if (unit.action !== 'execute') return null;
322
+ const plans = diskSnapshot(cwd).phases.length ? countPlans(cwd, unit.phase) : 0;
323
+ try {
324
+ const f = require('./cost.cjs').costForecast(cwd, { plans: plans || 1, excludePhase: unit.phase });
325
+ return f && f.per_plan && f.per_plan.status === 'ok' ? Math.round(f.per_plan.p90 * (plans || 1) * 10000) / 10000 : null;
326
+ } catch { return null; }
327
+ }
328
+
329
+ function countPlans(cwd, phase) {
330
+ const root = planningPath(cwd, 'phases');
331
+ const num = String(phase);
332
+ const padded = num.includes('.') ? num : num.padStart(2, '0');
333
+ try {
334
+ const d = fs.readdirSync(root).find((n) => n === padded || n.startsWith(`${padded}-`) || n.startsWith(`${num}-`));
335
+ return d ? fs.readdirSync(path.join(root, d)).filter((n) => /-plan\.md$/i.test(n)).length : 0;
336
+ } catch { return 0; }
337
+ }
338
+
339
+ /** The fields of a finished row the stop logic reads. */
340
+ function recorded(r) {
341
+ const out = { status: r.status, unit: r.unit, exit: r.exit, cost_usd: r.cost_usd, snapshot_after: r.snapshot_after };
342
+ for (const k of ['error', 'reason', 'forecast_p90']) if (r[k] != null) out[k] = r[k];
343
+ return out;
344
+ }
345
+
346
+ // ─── Worktrees (program A3) ────────────────────────────────────────────────
347
+ // With --worktrees, an execute unit whose phase has two or more pending plans runs each
348
+ // plan in its own git worktree and session (`exec-phase N --plan <id>`), sub-wave by
349
+ // sub-wave (plans of a wave that share a file are in different sub-waves). After each
350
+ // sub-wave its branches are shadow-merged onto HEAD and the oracle run (worktree
351
+ // preflight); only then are they merged into the checkout.
352
+
353
+ function git(cwd, args) {
354
+ const r = spawnSync('git', ['-c', 'user.name=PAN autodev', '-c', 'user.email=autodev@pan.local', '-c', 'commit.gpgsign=false', ...args], { cwd, encoding: 'utf8' });
355
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
356
+ }
357
+
358
+ function pendingPlanGroups(cwd, phase) {
359
+ const r = spawnSync(process.execPath, [TOOLS, 'phase-plan-index', String(phase)], { cwd, encoding: 'utf8' });
360
+ let idx = null;
361
+ try { idx = JSON.parse(r.stdout); } catch { return []; }
362
+ const pending = (idx.plans || []).filter((p) => !p.has_summary);
363
+ const groups = [];
364
+ for (const w of [...new Set(pending.map((p) => p.wave))].sort((a, b) => a - b)) {
365
+ const inWave = pending.filter((p) => p.wave === w);
366
+ for (const sw of [...new Set(inWave.map((p) => p.sub_wave || 0))].sort((a, b) => a - b)) groups.push(inWave.filter((p) => (p.sub_wave || 0) === sw).map((p) => p.id));
367
+ }
368
+ return groups;
369
+ }
370
+
371
+ /** @returns {{stop?: {reason, detail}}} */
372
+ function runPlansInWorktrees(cwd, unit, host, groups, ctx) {
373
+ const os = require('os');
374
+ const { shadowMergePreflight } = require('./worktree.cjs');
375
+ for (const group of groups) {
376
+ const made = [];
377
+ try {
378
+ for (const plan of group) {
379
+ const branch = `pan/autodev-p${unit.phase}-${plan}-${crypto.randomBytes(2).toString('hex')}`;
380
+ const dir = path.join(os.tmpdir(), `pan-autodev-wt-${crypto.randomBytes(4).toString('hex')}`);
381
+ const add = git(cwd, ['worktree', 'add', '-b', branch, dir, 'HEAD']);
382
+ if (!add.ok) return { stop: { reason: 'human_needed', detail: `could not create a worktree for plan ${plan}: ${add.err}` } };
383
+ made.push({ plan, branch, dir });
384
+ const u = { action: 'execute', phase: unit.phase, plan, command: `/pan:exec-phase ${unit.phase} --plan ${plan}`, quality: !!ctx.opts.quality };
385
+ const seq = ctx.nextSeq();
386
+ ctx.append({ seq, attempt: 1, status: 'started', unit: u, host, worktree: branch, start_sha: gitHead(dir) });
387
+ const res = executeUnit(dir, u, host, ctx.opts);
388
+ // Whatever the session left uncommitted belongs to its plan.
389
+ if (git(dir, ['status', '--porcelain']).out) { git(dir, ['add', '-A']); git(dir, ['commit', '-q', '-m', `autodev: plan ${plan} (uncommitted work)`]); }
390
+ ctx.append({ seq, attempt: 1, status: 'finished', unit: u, host, worktree: branch, ...res, end_sha: gitHead(dir), snapshot_after: snapshotHash(dir) });
391
+ if (res.exit !== 0) return { stop: { reason: 'human_needed', detail: `plan ${plan} failed in its worktree (exit ${res.exit}); the branch ${branch} keeps its work` }, keep: branch };
392
+ }
393
+ const pf = shadowMergePreflight(cwd, { base: 'HEAD', branches: made.map((m) => m.branch) });
394
+ if (pf.verdict && !['clean', 'no_test'].includes(pf.verdict)) {
395
+ return { stop: { reason: 'human_needed', detail: `the merge preflight for plans ${group.join(', ')} says ${pf.verdict}${pf.conflict ? ` (${pf.conflict.files.join(', ')})` : ''}` } };
396
+ }
397
+ for (const m of made) {
398
+ const merged = git(cwd, ['merge', '--no-ff', '--no-edit', m.branch]);
399
+ if (!merged.ok) { git(cwd, ['merge', '--abort']); return { stop: { reason: 'human_needed', detail: `merging plan ${m.plan} back conflicted: ${merged.err.slice(0, 200)}` } }; }
400
+ }
401
+ } finally {
402
+ for (const m of made) {
403
+ git(cwd, ['worktree', 'remove', '--force', m.dir]);
404
+ try { fs.rmSync(m.dir, { recursive: true, force: true }); } catch {}
405
+ git(cwd, ['branch', '-D', m.branch]);
406
+ }
407
+ git(cwd, ['worktree', 'prune']);
408
+ }
409
+ }
410
+ return {};
411
+ }
412
+
413
+ // ─── CI in the loop (program A2) ────────────────────────────────────────────
414
+ // With --ci, each completed phase is checked against the pull request's checks (ci.cjs);
415
+ // with --ci local, against the project's own gate run here (`npm run ci:local`, else its
416
+ // test script), with no pull request and no push.
417
+ // A failure starts a fix round, a fresh session given the failing names and log tail;
418
+ // the breaker stops the run after --ci-rounds rounds (3). The session's own account of
419
+ // its tests never decides: the checks do.
420
+
421
+ function ciBase(cwd, opts) {
422
+ if (opts.ciBase) return opts.ciBase;
423
+ const r = git(cwd, ['rev-parse', '--abbrev-ref', 'origin/HEAD']);
424
+ return r.ok && r.out ? r.out.replace(/^origin\//, '') : 'main';
425
+ }
426
+
427
+ function runCiRounds(cwd, unit, host, ctx) {
428
+ const { waitForCi, localOracle, breakerTripped, DEFAULT_CI_ROUNDS } = require('./ci.cjs');
429
+ const local = ctx.opts.ci === 'local';
430
+ const max = Number(ctx.opts.ciRounds) > 0 ? Number(ctx.opts.ciRounds) : DEFAULT_CI_ROUNDS;
431
+ for (let round = 0; ; round++) {
432
+ if (ctx.opts.ciPush) {
433
+ const p = git(cwd, ['push']);
434
+ if (!p.ok) return { stop: { reason: 'human_needed', detail: `--ci-push: git push failed: ${p.err.slice(0, 200)}` } };
435
+ }
436
+ const ci = local
437
+ ? localOracle(cwd, { timeoutMs: ctx.opts.ciTimeoutMs })
438
+ : waitForCi(cwd, { head: gitHead(cwd) || undefined, base: ciBase(cwd, ctx.opts), timeoutMs: ctx.opts.ciTimeoutMs, pollMs: ctx.opts.ciPollMs });
439
+ ctx.append({ status: 'ci', phase: unit.phase, round, ci: { status: ci.status, failed: ci.failed, ignored: ci.ignored, reason: ci.reason } });
440
+ if (ci.status === 'passed') return {};
441
+ if (ci.status !== 'failed') return { stop: { reason: 'human_needed', detail: `CI for phase ${unit.phase} is ${ci.status}${ci.reason ? `: ${ci.reason}` : ''}` } };
442
+ if (breakerTripped(round, max)) return { stop: { reason: 'ci_failed', detail: `phase ${unit.phase}: CI still fails after ${round} fix round(s): ${ci.failed.join(', ')}` } };
443
+ const u = { action: 'ci_fix', phase: unit.phase, round: round + 1, command: `CI fix round ${round + 1}` };
444
+ const seq = ctx.nextSeq();
445
+ ctx.append({ seq, attempt: 1, status: 'started', unit: u, host, start_sha: gitHead(cwd) });
446
+ const note = `Failing checks: ${ci.failed.join(', ')}. Failing log: ${String(ci.tail || '').split(/\r?\n/).slice(-6).join(' / ').slice(-600)}`;
447
+ const res = executeUnit(cwd, u, host, { ...ctx.opts, note });
448
+ ctx.append({ seq, attempt: 1, status: 'finished', unit: u, host, ...res, end_sha: gitHead(cwd), snapshot_after: snapshotHash(cwd) });
449
+ }
450
+ }
451
+
452
+ function newRunId() {
453
+ const d = new Date().toISOString().replace(/[-:]/g, '').replace('T', '-').slice(0, 15);
454
+ return `run-${d}-${crypto.randomBytes(3).toString('hex')}`;
455
+ }
456
+
457
+ function latestRun(cwd) {
458
+ try {
459
+ const runs = fs.readdirSync(autodevDir(cwd)).filter((n) => n.startsWith('run-') && fs.existsSync(path.join(autodevDir(cwd), n, 'run.json')));
460
+ return runs.sort((a, b) => fs.statSync(path.join(autodevDir(cwd), b)).mtimeMs - fs.statSync(path.join(autodevDir(cwd), a)).mtimeMs)[0] || null;
461
+ } catch { return null; }
462
+ }
463
+
464
+ /**
465
+ * The loop. opts: { host, maxUsd, maxTurns, runId (resume), resume }
466
+ * @returns {{run, ledger, stop, units}}
467
+ */
468
+ function runAutodev(cwd, opts = {}) {
469
+ let runId = opts.runId || (opts.resume ? latestRun(cwd) : null);
470
+ if (opts.resume && !runId) return { error: 'no_run', reason: 'no autodev run to resume' };
471
+ const runDir = path.join(autodevDir(cwd), runId || (runId = newRunId()));
472
+ const runFile = path.join(runDir, 'run.json');
473
+ const ledger = path.join(runDir, 'ledger.jsonl');
474
+ let run = readJson(runFile, null);
475
+ if (!run) {
476
+ run = { id: runId, started: new Date().toISOString(), host: opts.host, limits: { maxUsd: opts.maxUsd ?? null, maxUsdDay: opts.maxUsdDay ?? null, maxTurns: opts.maxTurns ?? null } };
477
+ fs.mkdirSync(runDir, { recursive: true });
478
+ fs.writeFileSync(runFile, JSON.stringify(run, null, 2) + '\n');
479
+ }
480
+ const host = opts.host || run.host;
481
+ if (!HOSTS[host] || !HOSTS[host].runner) return { error: 'unsupported_host', reason: `no autonomous adapter for host "${host}"`, hosts: Object.keys(HOSTS).filter((k) => HOSTS[k].runner) };
482
+ const budgetCfg = (readJson(planningPath(cwd, 'config.json'), {}).budget) || {};
483
+ const cfgNum = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
484
+ const limits = {
485
+ maxUsd: opts.maxUsd ?? run.limits.maxUsd ?? cfgNum(budgetCfg.max_usd_run),
486
+ maxUsdDay: opts.maxUsdDay ?? run.limits.maxUsdDay ?? cfgNum(budgetCfg.max_usd_day),
487
+ maxTurns: opts.maxTurns ?? run.limits.maxTurns,
488
+ };
489
+ if (limits.maxUsd == null && !opts.execute) {
490
+ return { error: 'budget_required', reason: 'an unattended run spends on the host: pass --max-usd <N> or set budget.max_usd_run in .planning/config.json' };
491
+ }
492
+ if (opts.maxUsd != null || opts.maxUsdDay != null) {
493
+ run.limits = { ...run.limits, maxUsd: limits.maxUsd, maxUsdDay: limits.maxUsdDay, maxTurns: limits.maxTurns };
494
+ fs.writeFileSync(runFile, JSON.stringify(run, null, 2) + '\n');
495
+ }
496
+ const append = (row) => fs.appendFileSync(ledger, JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n');
497
+ const units = [];
498
+ let iterations = 0;
499
+
500
+ for (;;) {
501
+ // Simulation only (H8): a hard cap, so a loop with its stop guard disabled ends.
502
+ if (opts.maxIterations && ++iterations > opts.maxIterations) {
503
+ append({ status: 'stopped', reason: 'hard_cap', detail: `${opts.maxIterations} iterations without a stop` });
504
+ return finish();
505
+ }
506
+ const rows = readRows(ledger);
507
+ const pending = pendingUnit(rows);
508
+ const unit = pending ? pending.unit : selectUnit(cwd);
509
+ const snapshot = snapshotHash(cwd);
510
+ const spentDay = opts.execute ? 0 : daySpend(cwd);
511
+ const inputs = { unit, rows: rows.filter((r) => r.status === 'finished').map(recorded), limits, snapshot, daySpent: spentDay };
512
+ const stop = pending || opts.stopGuard === false ? null : decideStop(inputs);
513
+ // A5: the flight recorder. Every decision with exactly the inputs it was made from,
514
+ // so `autodev replay` can re-run the stop logic and show it decides the same.
515
+ if (!pending) fs.appendFileSync(path.join(runDir, 'blackbox.jsonl'), JSON.stringify({ ts: new Date().toISOString(), inputs, decision: stop }) + '\n');
516
+ if (stop) {
517
+ append({ status: 'stopped', ...stop });
518
+ persistRunFacts(cwd);
519
+ return { run: runId, ledger: path.relative(cwd, ledger).replace(/\\/g, '/'), host, stop, units: units.map((u) => ({ action: u.action, phase: u.phase })), spent_usd: readRows(ledger).filter((r) => r.status === 'finished').reduce((n, r) => n + (Number(r.cost_usd) || 0), 0) };
520
+ }
521
+ if (opts.worktrees && !pending && unit.action === 'execute') {
522
+ const groups = pendingPlanGroups(cwd, unit.phase);
523
+ if (groups.flat().length >= 2) {
524
+ let next = new Set(rows.filter((r) => r.status === 'started').map((r) => r.seq)).size;
525
+ const wt = runPlansInWorktrees(cwd, unit, host, groups, { append, opts, nextSeq: () => ++next });
526
+ if (wt.stop) { append({ status: 'stopped', ...wt.stop }); persistRunFacts(cwd); return finish(); }
527
+ persistRunFacts(cwd);
528
+ units.push(unit);
529
+ continue;
530
+ }
531
+ }
532
+ const seq = pending ? pending.seq : new Set(rows.filter((r) => r.status === 'started').map((r) => r.seq)).size + 1;
533
+ const u = { action: unit.action, phase: unit.phase, command: unit.command, quality: !!opts.quality };
534
+ let note = null;
535
+ let model = null;
536
+ let routed = null;
537
+ for (let attempt = 1; ; attempt++) {
538
+ const spentRun = readRows(ledger).filter((r) => r.status === 'finished').reduce((n, r) => n + (Number(r.cost_usd) || 0), 0);
539
+ const remainingUsd = Math.min(limits.maxUsd != null ? limits.maxUsd - spentRun : Infinity, limits.maxUsdDay != null ? limits.maxUsdDay - daySpend(cwd) : Infinity);
540
+ // A retry can find the cap already spent (the first attempt is gated by decideStop):
541
+ // stop here rather than start a session with
542
+ // --max-budget-usd 0.00, which Claude Code refuses (2026-10-24).
543
+ if (attempt > 1 && remainingUsd < 0.01) {
544
+ append({ status: 'stopped', reason: 'budget_usd', detail: `the cap was spent before ${unitKey(unit)} attempt ${attempt}` });
545
+ persistRunFacts(cwd);
546
+ return finish('budget_usd');
547
+ }
548
+ if (!(pending && attempt === 1)) append({ seq, attempt, status: 'started', unit: u, host, start_sha: gitHead(cwd) });
549
+ const res = executeUnit(cwd, unit, host, { ...opts, note, model, remainingUsd: Number.isFinite(remainingUsd) ? remainingUsd : undefined });
550
+ const row = { seq, attempt, status: 'finished', unit: u, host, ...res, end_sha: gitHead(cwd), snapshot_after: snapshotHash(cwd), resumed: pending && attempt === 1 ? true : undefined };
551
+ if (res.exit === 0) {
552
+ row.forecast_p90 = unitForecast(cwd, unit);
553
+ if (routed) row.recovered_from = routed.cls;
554
+ append(row);
555
+ // A session that stopped for a person's ruling is not "no progress" (2026-10-24: an
556
+ // executor's spec conflict was reported as execute:5 changed nothing on disk).
557
+ if (res.blocked) {
558
+ append({ status: 'stopped', reason: 'human_needed', detail: `${unitKey(unit)} is blocked on a ruling: ${res.blocked}` });
559
+ persistRunFacts(cwd);
560
+ return finish('human_needed');
561
+ }
562
+ if (res.budget_stopped) {
563
+ append({ status: 'stopped', reason: 'budget_usd', detail: `${unitKey(unit)} stopped on its budget: ${res.budget_stopped}` });
564
+ persistRunFacts(cwd);
565
+ return finish('budget_usd');
566
+ }
567
+ if (opts.ci && unit.action === 'complete_phase') {
568
+ const ci = runCiRounds(cwd, unit, host, { append, opts, nextSeq: () => new Set(readRows(ledger).filter((r) => r.status === 'started').map((r) => r.seq)).size + 1 });
569
+ if (ci.stop) { append({ status: 'stopped', ...ci.stop }); persistRunFacts(cwd); return finish(); }
570
+ }
571
+ break;
572
+ }
573
+ if (unit.action === 'complete_phase') { append(row); break; }
574
+ // A4: classify the failure and follow its class's policy.
575
+ const failure = { exit: res.exit, stderr: res.stderr_tail || res.error, stdout: res.stdout_tail, timedOut: res.timed_out };
576
+ const cls = FC.classifyFailure(failure);
577
+ const policy = FC.routeFailure(cls, attempt);
578
+ routed = { cls, policy: policy.action };
579
+ append({ ...row, failure_class: cls, policy: policy.action, ...(policy.tier ? { tier: policy.tier } : {}) });
580
+ if (policy.action === 'ask') {
581
+ append({ status: 'stopped', reason: 'human_needed', detail: `${unitKey(unit)} failed (${cls}): ${FC.errorTail(failure, 200)}` });
582
+ persistRunFacts(cwd);
583
+ return finish('human_needed');
584
+ }
585
+ if (policy.action === 'stop_budget') {
586
+ append({ status: 'stopped', reason: 'budget_usd', detail: `${unitKey(unit)} hit the host's budget: ${FC.errorTail(failure, 200)}` });
587
+ persistRunFacts(cwd);
588
+ return finish('budget_usd');
589
+ }
590
+ if (policy.action === 'quarantine') break;
591
+ note = policy.action === 'retry_with_tail' ? `The previous attempt failed: ${FC.errorTail(failure)}. Fix that first.`
592
+ : policy.action === 'escalate_tier' ? `Earlier attempts failed (${cls}): ${FC.errorTail(failure, 160)}. Work more carefully.`
593
+ : null;
594
+ model = policy.action === 'escalate_tier' && host === 'claude' ? CLAUDE_MODEL_FOR_TIER[policy.tier] || null : model;
595
+ }
596
+ persistRunFacts(cwd);
597
+ units.push(unit);
598
+ }
599
+
600
+ function finish() {
601
+ const rows = readRows(ledger);
602
+ const stopped = rows.filter((r) => r.status === 'stopped').pop();
603
+ return { run: runId, ledger: path.relative(cwd, ledger).replace(/\\/g, '/'), host, stop: { reason: stopped.reason, detail: stopped.detail }, units: units.map((x) => ({ action: x.action, phase: x.phase })), spent_usd: rows.filter((r) => r.status === 'finished').reduce((n, r) => n + (Number(r.cost_usd) || 0), 0) };
604
+ }
605
+ }
606
+
607
+ function runStatus(cwd, runId) {
608
+ const id = runId || latestRun(cwd);
609
+ if (!id) return { error: 'no_run', reason: 'no autodev run yet' };
610
+ const dir = path.join(autodevDir(cwd), id);
611
+ const rows = readRows(path.join(dir, 'ledger.jsonl'));
612
+ const stopped = rows.filter((r) => r.status === 'stopped').pop() || null;
613
+ const pending = pendingUnit(rows);
614
+ return {
615
+ run: id, ...readJson(path.join(dir, 'run.json'), {}),
616
+ units: rows.filter((r) => r.status === 'finished').length,
617
+ spent_usd: rows.filter((r) => r.status === 'finished').reduce((n, r) => n + (Number(r.cost_usd) || 0), 0),
618
+ in_flight: pending ? pending.unit : null,
619
+ stop: stopped ? { reason: stopped.reason, detail: stopped.detail } : null,
620
+ };
621
+ }
622
+
623
+ function numOrNull(v) {
624
+ if (v == null) return undefined;
625
+ const n = Number(v);
626
+ return Number.isFinite(n) ? n : undefined;
627
+ }
628
+
629
+ function cmdAutodev(cwd, sub, flags, raw) {
630
+ if (sub === 'status') { const r = runStatus(cwd, flags.run); output(r, raw, r.stop ? r.stop.reason : r.error); return; }
631
+ if (sub === 'report') { require('./autodev-report.cjs').cmdAutodevReport(cwd, flags.run, raw); return; }
632
+ if (sub === 'replay') { require('./autodev-report.cjs').cmdAutodevReplay(cwd, flags.run, raw); return; }
633
+ if (sub !== 'run' && sub !== 'resume') { output({ error: 'unknown_subcommand', reason: 'Usage: autodev run|resume|status|report|replay [--host H] [--max-usd N] [--max-usd-day N] [--max-turns N] [--run ID]' }, raw); return; }
634
+ const r = runAutodev(cwd, {
635
+ host: flags.host || (sub === 'run' ? 'claude' : undefined), maxUsd: numOrNull(flags.maxUsd), maxUsdDay: numOrNull(flags.maxUsdDay), maxTurns: numOrNull(flags.maxTurns),
636
+ runId: flags.run, resume: sub === 'resume', worktrees: !!flags.worktrees, quality: !!flags.quality,
637
+ ci: flags.ci === 'local' ? 'local' : !!flags.ci, ciRounds: numOrNull(flags.ciRounds), ciPush: !!flags.ciPush, ciBase: flags.ciBase || undefined,
638
+ });
639
+ output(r, raw, r.stop ? r.stop.reason : r.error);
640
+ }
641
+
642
+ module.exports = { LEAN_WORKFLOW, budgetStopReport, blockedReport, standinMissing, STOP_REASONS, selectUnit, decideStop, pendingUnit, snapshotHash, unitPrompt, sessionArgv, executeUnit, runAutodev, runStatus, cmdAutodev, verificationStatus, daySpend, unitForecast };