phase-loop-runtime 0.2.0__py3-none-any.whl

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 (370) hide show
  1. phase_loop_runtime/__init__.py +20 -0
  2. phase_loop_runtime/_contract_docs/phase-loop/extraction-readiness.md +90 -0
  3. phase_loop_runtime/_contract_docs/phase-loop/granular-execution-policy.md +137 -0
  4. phase_loop_runtime/_contract_docs/phase-loop/harness-capability-matrix.md +510 -0
  5. phase_loop_runtime/_contract_docs/phase-loop/harness-skill-matrix.md +125 -0
  6. phase_loop_runtime/_contract_docs/phase-loop/harness-substrate-manifest.md +232 -0
  7. phase_loop_runtime/_contract_docs/phase-loop/pi-loop-control.md +247 -0
  8. phase_loop_runtime/_contract_docs/phase-loop/protocol.md +2759 -0
  9. phase_loop_runtime/_contract_docs/phase-loop/runtime-boundary.md +289 -0
  10. phase_loop_runtime/_contract_docs/phase-loop/spec-discovery-roots.md +47 -0
  11. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-governed-pipeline.md +42 -0
  12. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-portal-projection.md +34 -0
  13. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-regenesis.md +29 -0
  14. phase_loop_runtime/_contract_docs/phase-loop/substrate-soak-report.md +32 -0
  15. phase_loop_runtime/_contract_docs/runtime/verification-evidence-contract.md +52 -0
  16. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/commands.txt +2 -0
  17. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/manifest.json +16 -0
  18. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-mixed-signals/mixed.json +5 -0
  19. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/manifest.json +16 -0
  20. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-provider-timeout/timeout-note.txt +2 -0
  21. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/manifest.json +16 -0
  22. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-redacted-summary/redacted-summary.json +6 -0
  23. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/manifest.json +16 -0
  24. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-small-sample/sample.json +7 -0
  25. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/edge.txt +2 -0
  26. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/borderline/borderline-threshold-edge/manifest.json +16 -0
  27. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/manifest.json +16 -0
  28. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-a.txt +1 -0
  29. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-b.txt +1 -0
  30. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-duplicate-png/shot-c.txt +1 -0
  31. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/claimed-results.json +5 -0
  32. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-missing-references/manifest.json +16 -0
  33. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/manifest.json +16 -0
  34. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-subtle-low-variance/metrics.json +9 -0
  35. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/findings.md +10 -0
  36. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-templated-prose/manifest.json +16 -0
  37. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/manifest.json +16 -0
  38. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/scores.json +4 -0
  39. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-fake/fake-uniform-scores/summary.txt +1 -0
  40. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/bundle-check.json +8 -0
  41. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/manifest.json +16 -0
  42. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-adoption-bundle/operator-note.txt +2 -0
  43. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/events-sample.jsonl +3 -0
  44. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/manifest.json +16 -0
  45. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-handoff-ledger/receipt.txt +1 -0
  46. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/evidence.json +13 -0
  47. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/manifest.json +16 -0
  48. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-runner-diff/summary.txt +2 -0
  49. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/manifest.json +16 -0
  50. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/matrix.json +9 -0
  51. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-test-matrix/notes.md +4 -0
  52. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/console-summary.txt +2 -0
  53. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/manifest.json +16 -0
  54. phase_loop_runtime/_test_fixtures/evidence-audit-calibration/known-real/real-ui-screenshot-standin/screenshot-standin.ppm +5 -0
  55. phase_loop_runtime/adoption_bundle.py +273 -0
  56. phase_loop_runtime/advisor_board/CONTRACTS.md +257 -0
  57. phase_loop_runtime/advisor_board/__init__.py +288 -0
  58. phase_loop_runtime/advisor_board/backing.py +137 -0
  59. phase_loop_runtime/advisor_board/backing_omnigent.py +498 -0
  60. phase_loop_runtime/advisor_board/config.py +225 -0
  61. phase_loop_runtime/advisor_board/events.py +115 -0
  62. phase_loop_runtime/advisor_board/fixtures/advisor-boards.example.toml +59 -0
  63. phase_loop_runtime/advisor_board/fixtures.py +91 -0
  64. phase_loop_runtime/advisor_board/harness_mapping.py +125 -0
  65. phase_loop_runtime/advisor_board/matrix.py +146 -0
  66. phase_loop_runtime/advisor_board/observability.py +481 -0
  67. phase_loop_runtime/advisor_board/presets.py +188 -0
  68. phase_loop_runtime/advisor_board/registries.py +309 -0
  69. phase_loop_runtime/advisor_board/resolver.py +253 -0
  70. phase_loop_runtime/advisor_board/schema.py +269 -0
  71. phase_loop_runtime/advisor_board/standin.py +166 -0
  72. phase_loop_runtime/advisor_board/validation.py +159 -0
  73. phase_loop_runtime/agent_runtime_provider.py +676 -0
  74. phase_loop_runtime/baml_modular.py +656 -0
  75. phase_loop_runtime/baml_src/dotfiles_adoption_manifest.baml +37 -0
  76. phase_loop_runtime/baml_src/dotfiles_c4_document.baml +14 -0
  77. phase_loop_runtime/baml_src/dotfiles_plan_manifest.baml +50 -0
  78. phase_loop_runtime/baml_src/dotfiles_runtime_projection.baml +16 -0
  79. phase_loop_runtime/baml_src/dotfiles_task_catalog.baml +20 -0
  80. phase_loop_runtime/baml_src/emit_phase_closeout.baml +86 -0
  81. phase_loop_runtime/baml_src/evaluate_suspected_fake_evidence.baml +36 -0
  82. phase_loop_runtime/baml_src/verification_evidence.baml +40 -0
  83. phase_loop_runtime/broker.py +181 -0
  84. phase_loop_runtime/build_bundle.py +294 -0
  85. phase_loop_runtime/capability_registry.py +835 -0
  86. phase_loop_runtime/classifier.py +87 -0
  87. phase_loop_runtime/claude_agent_view.py +625 -0
  88. phase_loop_runtime/claude_channel_sidecar.py +1086 -0
  89. phase_loop_runtime/cli.py +2599 -0
  90. phase_loop_runtime/closeout.py +865 -0
  91. phase_loop_runtime/closeout_classifier.py +120 -0
  92. phase_loop_runtime/closeout_evidence_audit.py +112 -0
  93. phase_loop_runtime/closeout_validation.py +144 -0
  94. phase_loop_runtime/closeout_validators.py +203 -0
  95. phase_loop_runtime/consiliency_gates.py +374 -0
  96. phase_loop_runtime/consiliency_ingest.py +399 -0
  97. phase_loop_runtime/consiliency_layout.py +176 -0
  98. phase_loop_runtime/consiliency_scaffold.py +305 -0
  99. phase_loop_runtime/cross_repo_channel.py +331 -0
  100. phase_loop_runtime/discovery.py +2368 -0
  101. phase_loop_runtime/dispatch_lock.py +167 -0
  102. phase_loop_runtime/doc_delta_validator.py +98 -0
  103. phase_loop_runtime/docs_audit.py +285 -0
  104. phase_loop_runtime/docs_freshness.py +468 -0
  105. phase_loop_runtime/docs_surfaces.py +150 -0
  106. phase_loop_runtime/dotfiles_profile_plugin.py +96 -0
  107. phase_loop_runtime/events.py +154 -0
  108. phase_loop_runtime/events_migration.py +96 -0
  109. phase_loop_runtime/evidence_audit.py +1082 -0
  110. phase_loop_runtime/evidence_audit_config.py +172 -0
  111. phase_loop_runtime/fleet_map.py +426 -0
  112. phase_loop_runtime/fleet_metrics.py +298 -0
  113. phase_loop_runtime/fleet_metrics_export.py +196 -0
  114. phase_loop_runtime/git_discipline.py +479 -0
  115. phase_loop_runtime/git_ops.py +122 -0
  116. phase_loop_runtime/git_topology.py +252 -0
  117. phase_loop_runtime/governed_bundle.py +137 -0
  118. phase_loop_runtime/governed_premerge.py +219 -0
  119. phase_loop_runtime/governed_review.py +264 -0
  120. phase_loop_runtime/handoff.py +932 -0
  121. phase_loop_runtime/handoff_path.py +14 -0
  122. phase_loop_runtime/injection.py +740 -0
  123. phase_loop_runtime/install_status.py +88 -0
  124. phase_loop_runtime/lane_scheduler.py +317 -0
  125. phase_loop_runtime/launcher.py +2760 -0
  126. phase_loop_runtime/lease_store.py +553 -0
  127. phase_loop_runtime/maintenance.py +401 -0
  128. phase_loop_runtime/migrate_handoffs.py +201 -0
  129. phase_loop_runtime/models.py +2144 -0
  130. phase_loop_runtime/observability.py +1290 -0
  131. phase_loop_runtime/panel_invoker.py +1638 -0
  132. phase_loop_runtime/phase_loop_drift_audit.py +388 -0
  133. phase_loop_runtime/phase_worktree_executor.py +433 -0
  134. phase_loop_runtime/pipeline_adapter/__init__.py +12 -0
  135. phase_loop_runtime/pipeline_adapter/branch_ops.py +274 -0
  136. phase_loop_runtime/pipeline_adapter/flag.py +55 -0
  137. phase_loop_runtime/pipeline_adapter/markers.py +13 -0
  138. phase_loop_runtime/pipeline_adapter/merge_policy.py +72 -0
  139. phase_loop_runtime/pipeline_adapter/ratification.py +100 -0
  140. phase_loop_runtime/pipeline_adapter/sibling_matcher.py +123 -0
  141. phase_loop_runtime/plan_ir.py +523 -0
  142. phase_loop_runtime/plan_manifest.py +415 -0
  143. phase_loop_runtime/planner_validation.py +176 -0
  144. phase_loop_runtime/profiles.py +462 -0
  145. phase_loop_runtime/prompts.py +365 -0
  146. phase_loop_runtime/provenance.py +165 -0
  147. phase_loop_runtime/publishing.py +339 -0
  148. phase_loop_runtime/py.typed +1 -0
  149. phase_loop_runtime/reconcile.py +1550 -0
  150. phase_loop_runtime/redaction.py +184 -0
  151. phase_loop_runtime/reflection_sync.py +293 -0
  152. phase_loop_runtime/release_guard.py +186 -0
  153. phase_loop_runtime/render.py +432 -0
  154. phase_loop_runtime/repo_validation.py +427 -0
  155. phase_loop_runtime/review_summary.py +150 -0
  156. phase_loop_runtime/roadmap_lint.py +425 -0
  157. phase_loop_runtime/route_log.py +59 -0
  158. phase_loop_runtime/route_policy/__init__.py +11 -0
  159. phase_loop_runtime/route_policy/fixtures/route_selection.golden.json +263 -0
  160. phase_loop_runtime/runner.py +8400 -0
  161. phase_loop_runtime/runtime_paths.py +225 -0
  162. phase_loop_runtime/runtime_projection.py +113 -0
  163. phase_loop_runtime/runtime_resources.py +36 -0
  164. phase_loop_runtime/schema_export.py +292 -0
  165. phase_loop_runtime/schemas/phase_loop_closeout.schema.json +366 -0
  166. phase_loop_runtime/skill_install.py +176 -0
  167. phase_loop_runtime/skill_inventory.py +662 -0
  168. phase_loop_runtime/skill_paths.py +42 -0
  169. phase_loop_runtime/skill_sources_plugin.py +37 -0
  170. phase_loop_runtime/skills_bundle/claude-advisor-board/README.md +1 -0
  171. phase_loop_runtime/skills_bundle/claude-advisor-board/SKILL.md +38 -0
  172. phase_loop_runtime/skills_bundle/claude-advisor-panel/README.md +1 -0
  173. phase_loop_runtime/skills_bundle/claude-advisor-panel/SKILL.md +38 -0
  174. phase_loop_runtime/skills_bundle/claude-execute-detailed/README.md +1 -0
  175. phase_loop_runtime/skills_bundle/claude-execute-detailed/SKILL.md +152 -0
  176. phase_loop_runtime/skills_bundle/claude-execute-phase/README.md +1 -0
  177. phase_loop_runtime/skills_bundle/claude-execute-phase/SKILL.md +745 -0
  178. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  179. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  180. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  181. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  182. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  183. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  184. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  185. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/state.py +274 -0
  186. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  187. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/team_teardown.sh +32 -0
  188. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/validate_plan_doc.py +642 -0
  189. phase_loop_runtime/skills_bundle/claude-execute-phase/scripts/verify_harness.sh +96 -0
  190. phase_loop_runtime/skills_bundle/claude-phase-loop/README.md +1 -0
  191. phase_loop_runtime/skills_bundle/claude-phase-loop/SKILL.md +115 -0
  192. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/README.md +1 -0
  193. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/SKILL.md +439 -0
  194. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/assets/review_prompt.md +19 -0
  195. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  196. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/references/roadmap-template.md +193 -0
  197. phase_loop_runtime/skills_bundle/claude-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  198. phase_loop_runtime/skills_bundle/claude-plan-detailed/README.md +1 -0
  199. phase_loop_runtime/skills_bundle/claude-plan-detailed/SKILL.md +334 -0
  200. phase_loop_runtime/skills_bundle/claude-plan-detailed/assets/review_prompt.md +25 -0
  201. phase_loop_runtime/skills_bundle/claude-plan-phase/README.md +1 -0
  202. phase_loop_runtime/skills_bundle/claude-plan-phase/SKILL.md +776 -0
  203. phase_loop_runtime/skills_bundle/claude-plan-phase/assets/review_prompt.md +17 -0
  204. phase_loop_runtime/skills_bundle/claude-plan-phase/scripts/validate_plan_doc.py +1092 -0
  205. phase_loop_runtime/skills_bundle/claude-run-train/README.md +1 -0
  206. phase_loop_runtime/skills_bundle/claude-run-train/SKILL.md +75 -0
  207. phase_loop_runtime/skills_bundle/claude-skill-editor/README.md +1 -0
  208. phase_loop_runtime/skills_bundle/claude-skill-editor/SKILL.md +243 -0
  209. phase_loop_runtime/skills_bundle/claude-skill-editor/assets/editor_prompt.md +72 -0
  210. phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/README.md +1 -0
  211. phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/SKILL.md +249 -0
  212. phase_loop_runtime/skills_bundle/claude-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  213. phase_loop_runtime/skills_bundle/claude-task-contextualizer/README.md +1 -0
  214. phase_loop_runtime/skills_bundle/claude-task-contextualizer/SKILL.md +74 -0
  215. phase_loop_runtime/skills_bundle/claude-task-contextualizer/references/task-templates.md +103 -0
  216. phase_loop_runtime/skills_bundle/codex-advisor-board/README.md +1 -0
  217. phase_loop_runtime/skills_bundle/codex-advisor-board/SKILL.md +38 -0
  218. phase_loop_runtime/skills_bundle/codex-advisor-panel/README.md +1 -0
  219. phase_loop_runtime/skills_bundle/codex-advisor-panel/SKILL.md +38 -0
  220. phase_loop_runtime/skills_bundle/codex-execute-detailed/README.md +1 -0
  221. phase_loop_runtime/skills_bundle/codex-execute-detailed/SKILL.md +154 -0
  222. phase_loop_runtime/skills_bundle/codex-execute-phase/README.md +1 -0
  223. phase_loop_runtime/skills_bundle/codex-execute-phase/SKILL.md +231 -0
  224. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  225. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  226. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  227. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  228. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  229. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  230. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  231. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/state.py +274 -0
  232. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  233. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/team_teardown.sh +32 -0
  234. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/validate_plan_doc.py +642 -0
  235. phase_loop_runtime/skills_bundle/codex-execute-phase/scripts/verify_harness.sh +96 -0
  236. phase_loop_runtime/skills_bundle/codex-phase-loop/README.md +1 -0
  237. phase_loop_runtime/skills_bundle/codex-phase-loop/SKILL.md +248 -0
  238. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/README.md +1 -0
  239. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/SKILL.md +150 -0
  240. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/assets/review_prompt.md +19 -0
  241. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  242. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/references/roadmap-template.md +193 -0
  243. phase_loop_runtime/skills_bundle/codex-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  244. phase_loop_runtime/skills_bundle/codex-plan-detailed/README.md +1 -0
  245. phase_loop_runtime/skills_bundle/codex-plan-detailed/SKILL.md +119 -0
  246. phase_loop_runtime/skills_bundle/codex-plan-detailed/assets/review_prompt.md +25 -0
  247. phase_loop_runtime/skills_bundle/codex-plan-phase/README.md +1 -0
  248. phase_loop_runtime/skills_bundle/codex-plan-phase/SKILL.md +307 -0
  249. phase_loop_runtime/skills_bundle/codex-plan-phase/assets/review_prompt.md +17 -0
  250. phase_loop_runtime/skills_bundle/codex-plan-phase/scripts/validate_plan_doc.py +1092 -0
  251. phase_loop_runtime/skills_bundle/codex-run-train/README.md +1 -0
  252. phase_loop_runtime/skills_bundle/codex-run-train/SKILL.md +75 -0
  253. phase_loop_runtime/skills_bundle/codex-skill-editor/README.md +1 -0
  254. phase_loop_runtime/skills_bundle/codex-skill-editor/SKILL.md +94 -0
  255. phase_loop_runtime/skills_bundle/codex-skill-editor/assets/editor_prompt.md +72 -0
  256. phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/README.md +1 -0
  257. phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/SKILL.md +114 -0
  258. phase_loop_runtime/skills_bundle/codex-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  259. phase_loop_runtime/skills_bundle/codex-task-contextualizer/README.md +1 -0
  260. phase_loop_runtime/skills_bundle/codex-task-contextualizer/SKILL.md +102 -0
  261. phase_loop_runtime/skills_bundle/codex-task-contextualizer/references/task-templates.md +103 -0
  262. phase_loop_runtime/skills_bundle/gemini-advisor-board/README.md +1 -0
  263. phase_loop_runtime/skills_bundle/gemini-advisor-board/SKILL.md +38 -0
  264. phase_loop_runtime/skills_bundle/gemini-advisor-panel/README.md +1 -0
  265. phase_loop_runtime/skills_bundle/gemini-advisor-panel/SKILL.md +38 -0
  266. phase_loop_runtime/skills_bundle/gemini-execute-detailed/README.md +1 -0
  267. phase_loop_runtime/skills_bundle/gemini-execute-detailed/SKILL.md +144 -0
  268. phase_loop_runtime/skills_bundle/gemini-execute-phase/README.md +1 -0
  269. phase_loop_runtime/skills_bundle/gemini-execute-phase/SKILL.md +198 -0
  270. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  271. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  272. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  273. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  274. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  275. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  276. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  277. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/state.py +274 -0
  278. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  279. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/team_teardown.sh +32 -0
  280. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/validate_plan_doc.py +642 -0
  281. phase_loop_runtime/skills_bundle/gemini-execute-phase/scripts/verify_harness.sh +96 -0
  282. phase_loop_runtime/skills_bundle/gemini-phase-loop/README.md +1 -0
  283. phase_loop_runtime/skills_bundle/gemini-phase-loop/SKILL.md +111 -0
  284. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/README.md +1 -0
  285. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/SKILL.md +147 -0
  286. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/assets/review_prompt.md +19 -0
  287. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  288. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/references/roadmap-template.md +193 -0
  289. phase_loop_runtime/skills_bundle/gemini-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  290. phase_loop_runtime/skills_bundle/gemini-plan-detailed/README.md +1 -0
  291. phase_loop_runtime/skills_bundle/gemini-plan-detailed/SKILL.md +119 -0
  292. phase_loop_runtime/skills_bundle/gemini-plan-detailed/assets/review_prompt.md +25 -0
  293. phase_loop_runtime/skills_bundle/gemini-plan-phase/README.md +1 -0
  294. phase_loop_runtime/skills_bundle/gemini-plan-phase/SKILL.md +217 -0
  295. phase_loop_runtime/skills_bundle/gemini-plan-phase/assets/review_prompt.md +17 -0
  296. phase_loop_runtime/skills_bundle/gemini-plan-phase/scripts/validate_plan_doc.py +1092 -0
  297. phase_loop_runtime/skills_bundle/gemini-run-train/README.md +1 -0
  298. phase_loop_runtime/skills_bundle/gemini-run-train/SKILL.md +75 -0
  299. phase_loop_runtime/skills_bundle/gemini-skill-editor/README.md +1 -0
  300. phase_loop_runtime/skills_bundle/gemini-skill-editor/SKILL.md +87 -0
  301. phase_loop_runtime/skills_bundle/gemini-skill-editor/assets/editor_prompt.md +72 -0
  302. phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/README.md +1 -0
  303. phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/SKILL.md +109 -0
  304. phase_loop_runtime/skills_bundle/gemini-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  305. phase_loop_runtime/skills_bundle/gemini-task-contextualizer/README.md +1 -0
  306. phase_loop_runtime/skills_bundle/gemini-task-contextualizer/SKILL.md +98 -0
  307. phase_loop_runtime/skills_bundle/gemini-task-contextualizer/references/task-templates.md +103 -0
  308. phase_loop_runtime/skills_bundle/opencode-advisor-board/README.md +1 -0
  309. phase_loop_runtime/skills_bundle/opencode-advisor-board/SKILL.md +38 -0
  310. phase_loop_runtime/skills_bundle/opencode-advisor-panel/README.md +1 -0
  311. phase_loop_runtime/skills_bundle/opencode-advisor-panel/SKILL.md +38 -0
  312. phase_loop_runtime/skills_bundle/opencode-execute-detailed/README.md +1 -0
  313. phase_loop_runtime/skills_bundle/opencode-execute-detailed/SKILL.md +144 -0
  314. phase_loop_runtime/skills_bundle/opencode-execute-phase/README.md +1 -0
  315. phase_loop_runtime/skills_bundle/opencode-execute-phase/SKILL.md +196 -0
  316. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/allocate_worktree_name.sh +33 -0
  317. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/audit_lane_file_touches.py +197 -0
  318. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/cleanup_lane_worktrees.sh +103 -0
  319. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/parent_tree_leakage_check.sh +125 -0
  320. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/post_merge_import_smoke.sh +62 -0
  321. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/pre_merge_destructiveness_check.sh +127 -0
  322. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/resolve_branch_from_sha.sh +51 -0
  323. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/state.py +274 -0
  324. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/sweep_stale_worktrees.sh +62 -0
  325. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/team_teardown.sh +32 -0
  326. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/validate_plan_doc.py +642 -0
  327. phase_loop_runtime/skills_bundle/opencode-execute-phase/scripts/verify_harness.sh +96 -0
  328. phase_loop_runtime/skills_bundle/opencode-phase-loop/README.md +1 -0
  329. phase_loop_runtime/skills_bundle/opencode-phase-loop/SKILL.md +113 -0
  330. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/README.md +1 -0
  331. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/SKILL.md +147 -0
  332. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/assets/review_prompt.md +19 -0
  333. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/parallelization-heuristics.md +111 -0
  334. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/references/roadmap-template.md +193 -0
  335. phase_loop_runtime/skills_bundle/opencode-phase-roadmap-builder/scripts/validate_roadmap.py +32 -0
  336. phase_loop_runtime/skills_bundle/opencode-plan-detailed/README.md +1 -0
  337. phase_loop_runtime/skills_bundle/opencode-plan-detailed/SKILL.md +119 -0
  338. phase_loop_runtime/skills_bundle/opencode-plan-detailed/assets/review_prompt.md +25 -0
  339. phase_loop_runtime/skills_bundle/opencode-plan-phase/README.md +1 -0
  340. phase_loop_runtime/skills_bundle/opencode-plan-phase/SKILL.md +211 -0
  341. phase_loop_runtime/skills_bundle/opencode-plan-phase/assets/review_prompt.md +17 -0
  342. phase_loop_runtime/skills_bundle/opencode-plan-phase/scripts/validate_plan_doc.py +1092 -0
  343. phase_loop_runtime/skills_bundle/opencode-run-train/README.md +1 -0
  344. phase_loop_runtime/skills_bundle/opencode-run-train/SKILL.md +75 -0
  345. phase_loop_runtime/skills_bundle/opencode-skill-editor/README.md +1 -0
  346. phase_loop_runtime/skills_bundle/opencode-skill-editor/SKILL.md +87 -0
  347. phase_loop_runtime/skills_bundle/opencode-skill-editor/assets/editor_prompt.md +72 -0
  348. phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/README.md +1 -0
  349. phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/SKILL.md +109 -0
  350. phase_loop_runtime/skills_bundle/opencode-skill-improvement-planner/assets/aggregator_prompt.md +79 -0
  351. phase_loop_runtime/skills_bundle/opencode-task-contextualizer/README.md +1 -0
  352. phase_loop_runtime/skills_bundle/opencode-task-contextualizer/SKILL.md +98 -0
  353. phase_loop_runtime/skills_bundle/opencode-task-contextualizer/references/task-templates.md +103 -0
  354. phase_loop_runtime/state.py +156 -0
  355. phase_loop_runtime/state_degradation.py +136 -0
  356. phase_loop_runtime/state_ops.py +676 -0
  357. phase_loop_runtime/train_ledger.py +215 -0
  358. phase_loop_runtime/train_roadmap.py +339 -0
  359. phase_loop_runtime/train_runner.py +1197 -0
  360. phase_loop_runtime/verification_evidence.py +650 -0
  361. phase_loop_runtime/verification_evidence_validator.py +75 -0
  362. phase_loop_runtime/visual_evidence_validator.py +36 -0
  363. phase_loop_runtime/worker_pool.py +123 -0
  364. phase_loop_runtime/worktree_index.py +270 -0
  365. phase_loop_runtime-0.2.0.data/data/share/phase-loop-runtime/protocol/protocol.md +2475 -0
  366. phase_loop_runtime-0.2.0.dist-info/METADATA +106 -0
  367. phase_loop_runtime-0.2.0.dist-info/RECORD +370 -0
  368. phase_loop_runtime-0.2.0.dist-info/WHEEL +5 -0
  369. phase_loop_runtime-0.2.0.dist-info/entry_points.txt +9 -0
  370. phase_loop_runtime-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,2760 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import signal
7
+ import shlex
8
+ import string
9
+ import subprocess
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass, replace
14
+ from pathlib import Path
15
+ from queue import Empty, Queue
16
+ from typing import Any
17
+
18
+ from .capability_registry import capability_registry
19
+ from .claude_agent_view import ClaudeAgentViewAdapter, AgentViewLifecycleResult, workspace_trust_state
20
+ from .claude_channel_sidecar import ChannelSidecarClient, ChannelSidecarClientError, ClaudeRouteResult, is_loopback_http_url
21
+ from .discovery import classify_phase_team_eligibility
22
+ from .injection import materialize_claude_plugin_bundle
23
+ from .models import (
24
+ ClaudeTeamPolicy,
25
+ DelegationRequest,
26
+ DispatchDecision,
27
+ HarnessLaneAssignment,
28
+ PhaseTeamEligibility,
29
+ InjectionMetadata,
30
+ LaunchRequest,
31
+ ModelSelection,
32
+ metadata_command,
33
+ ParentChildRunMetadata,
34
+ PermissionPolicy,
35
+ PromptBundle,
36
+ )
37
+ from .observability import heartbeat_path_for_log, run_heartbeat_summary, write_run_heartbeat
38
+
39
+
40
+ STUB_EXECUTOR_REASONS = {
41
+ "manual": "Manual executor is metadata-only in ADAPTER; resume through a TUI after INJECT/CAPREG wire manual handoff execution.",
42
+ }
43
+
44
+ CLAUDE_CONTEXT_PLACEHOLDER = "__PHASE_LOOP_CONTEXT_FILE__"
45
+ CLAUDE_PLUGIN_DIR_PLACEHOLDER = "__PHASE_LOOP_CLAUDE_PLUGIN_DIR__"
46
+ CLAUDE_SETTINGS_PLACEHOLDER = "__PHASE_LOOP_CLAUDE_SETTINGS__"
47
+ CLAUDE_AGENTS_PLACEHOLDER = "__PHASE_LOOP_CLAUDE_AGENTS__"
48
+ CLAUDE_MCP_CONFIG_PLACEHOLDER = "__PHASE_LOOP_CLAUDE_MCP_CONFIG__"
49
+ # Build-time sentinel for the Codex `--output-schema` path. The real file is
50
+ # materialized at LAUNCH time inside launch_with_spec's try/finally (see #63), so
51
+ # build-without-launch never creates a temp file and there is nothing to leak.
52
+ CODEX_OUTPUT_SCHEMA_PLACEHOLDER = "__PHASE_LOOP_CODEX_OUTPUT_SCHEMA__"
53
+ CLAUDE_ADAPTER_ALLOWED_TOOLS = "Bash,Read,Edit,MultiEdit,Write,Glob,Grep,LS"
54
+ CLAUDE_ADAPTER_DISALLOWED_TOOLS = (
55
+ "Agent,TaskCreate,TaskUpdate,TaskList,TeamCreate,TeamDelete,SendMessage,"
56
+ "EnterWorktree,ExitWorktree,AskUserQuestion,ExitPlanMode,ToolSearch,advisor"
57
+ )
58
+ GEMINI_CONTEXT_PLACEHOLDER = "__PHASE_LOOP_CONTEXT_FILE__"
59
+ OPENCODE_CONTEXT_PLACEHOLDER = "__PHASE_LOOP_CONTEXT_FILE__"
60
+ PI_CONTEXT_PLACEHOLDER = "__PHASE_LOOP_CONTEXT_FILE__"
61
+ COMMAND_CONTEXT_PLACEHOLDER = "__PHASE_LOOP_CONTEXT_FILE__"
62
+ COMMAND_TEMPLATE_ALLOWED_FIELDS = frozenset({"action", "repo", "roadmap", "phase", "plan", "context_file", "model", "effort", "cwd"})
63
+ CLAUDE_ROUTE_ALIASES = {
64
+ "channel": "claude_channel",
65
+ "claude_channel": "claude_channel",
66
+ "agent_view": "claude_agent_view",
67
+ "claude_agent_view": "claude_agent_view",
68
+ "print": "claude_print",
69
+ "claude_print": "claude_print",
70
+ }
71
+ CLAUDE_CHANNEL_SIDECAR_URL_ENV = "PHASE_LOOP_CLAUDE_CHANNEL_URL"
72
+ # Single source of truth for the default loopback sidecar URL (#66 CR): the
73
+ # build-time preflight gate and the URL actually launched must not drift apart.
74
+ DEFAULT_CLAUDE_CHANNEL_SIDECAR_URL = "http://127.0.0.1:8765"
75
+ CLAUDE_CHANNEL_SESSION_ID_ENV = "PHASE_LOOP_CHANNEL_SESSION_ID"
76
+ CLAUDE_CHANNEL_SESSION_ID_ALT_ENV = "PHASE_LOOP_CLAUDE_CHANNEL_SESSION_ID"
77
+ CLAUDE_CHANNEL_BEARER_TOKEN_ENV = "PHASE_LOOP_CLAUDE_CHANNEL_BEARER_TOKEN"
78
+
79
+
80
+ @dataclass(frozen=True)
81
+ class LaunchSpec:
82
+ executor: str
83
+ command: list[str]
84
+ prompt_bundle: PromptBundle
85
+ injection_metadata: InjectionMetadata
86
+ delivery_mode: str
87
+ dispatch_decision: DispatchDecision | None
88
+ available: bool
89
+ harness_lane_assignment: HarnessLaneAssignment | None = None
90
+ dry_run_only: bool = False
91
+ reason: str | None = None
92
+ live_proof_gate: str = "disposable_proof_required"
93
+ promotion_status: str = "proof_gated"
94
+ promotion_requirements: tuple[str, ...] = ()
95
+ auth_preflight_mode: str = "none"
96
+ auth_preflight_probes: tuple[str, ...] = ()
97
+ timeout_posture: str = "runner_managed"
98
+ output_capture_format: str = "combined_output"
99
+ terminal_summary_artifact: str = "terminal-summary.json"
100
+ permission_posture: str = "unknown"
101
+ selected_agent: str | None = None
102
+ selected_model: str | None = None
103
+ selected_effort: str | None = None
104
+ profile_source: str | None = None
105
+ override_reason: str | None = None
106
+ selected_variant: str | None = None
107
+ command_adapter_name: str | None = None
108
+ command_template: str | None = None
109
+ wrapped_cwd: str | None = None
110
+ launch_timeout_seconds: int | None = None
111
+ claude_execution_mode: str | None = None
112
+ claude_team_policy: ClaudeTeamPolicy | None = None
113
+ phase_team_eligibility: PhaseTeamEligibility | None = None
114
+ claude_route: str | None = None
115
+ claude_route_reason: str | None = None
116
+ # DFCHROUTE: billing-sensitive (and other) route warnings recorded on the spec
117
+ # so they reach the launch event metadata (e.g. the explicit-print billing alert).
118
+ claude_route_warnings: tuple[str, ...] = ()
119
+ claude_sidecar_url: str | None = None
120
+ claude_channel_session_id: str | None = None
121
+ cleanup_paths: tuple[str, ...] = ()
122
+ # Codex closeout schema carried for LAUNCH-time materialization (#63). The
123
+ # command holds CODEX_OUTPUT_SCHEMA_PLACEHOLDER until launch substitutes a
124
+ # run-scoped path. Never serialized raw (would dump the schema body).
125
+ codex_output_schema: dict[str, Any] | None = None
126
+
127
+ def to_json(self) -> dict[str, Any]:
128
+ return {
129
+ "executor": self.executor,
130
+ "command": metadata_command(self.command, self.prompt_bundle.render_prompt()),
131
+ "prompt_bundle": self.prompt_bundle.to_json(),
132
+ "injection_metadata": self.injection_metadata.to_json(),
133
+ "delivery_mode": self.delivery_mode,
134
+ "dispatch_decision": self.dispatch_decision.to_json() if self.dispatch_decision else None,
135
+ "harness_lane_assignment": self.harness_lane_assignment.to_json() if self.harness_lane_assignment else None,
136
+ "available": self.available,
137
+ "dry_run_only": self.dry_run_only,
138
+ "reason": self.reason,
139
+ "live_proof_gate": self.live_proof_gate,
140
+ "promotion_status": self.promotion_status,
141
+ "promotion_requirements": list(self.promotion_requirements),
142
+ "auth_preflight_mode": self.auth_preflight_mode,
143
+ "auth_preflight_probes": list(self.auth_preflight_probes),
144
+ "timeout_posture": self.timeout_posture,
145
+ "output_capture_format": self.output_capture_format,
146
+ "terminal_summary_artifact": self.terminal_summary_artifact,
147
+ "permission_posture": self.permission_posture,
148
+ "selected_agent": self.selected_agent,
149
+ "selected_model": self.selected_model,
150
+ "selected_effort": self.selected_effort,
151
+ "profile_source": self.profile_source,
152
+ "override_reason": self.override_reason,
153
+ "selected_variant": self.selected_variant,
154
+ "command_adapter_name": self.command_adapter_name,
155
+ "command_template": self.command_template,
156
+ "wrapped_cwd": self.wrapped_cwd,
157
+ "launch_timeout_seconds": self.launch_timeout_seconds,
158
+ "claude_execution_mode": self.claude_execution_mode,
159
+ "claude_team_policy": self.claude_team_policy.to_json() if self.claude_team_policy else None,
160
+ "phase_team_eligibility": self.phase_team_eligibility.to_json() if self.phase_team_eligibility else None,
161
+ "claude_route": self.claude_route,
162
+ "claude_route_reason": self.claude_route_reason,
163
+ "claude_route_warnings": list(self.claude_route_warnings),
164
+ # DFCHTELEMETRY: record billing + fallback posture for every Claude route
165
+ # (derived from route/reason, so no per-branch plumbing).
166
+ "claude_billing_posture": claude_route_billing_posture(self.claude_route, self.claude_route_reason) if self.claude_route else None,
167
+ "claude_fallback_posture": claude_route_fallback_posture(self.claude_route, self.claude_route_reason) if self.claude_route else None,
168
+ "claude_sidecar_url": self.claude_sidecar_url,
169
+ "claude_channel_session_id": self.claude_channel_session_id,
170
+ "cleanup_paths": list(self.cleanup_paths),
171
+ }
172
+
173
+ def delivery_payload(self) -> str | None:
174
+ if self.delivery_mode == "prompt_only":
175
+ return self.prompt_bundle.render_prompt()
176
+ if self.delivery_mode in {"inline", "stdin", "context_file"}:
177
+ return self.prompt_bundle.render_context()
178
+ return None
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class LaunchResult:
183
+ command: list[str]
184
+ returncode: int | None
185
+ output: str = ""
186
+ dry_run: bool = False
187
+ log_path: str | None = None
188
+ heartbeat_path: str | None = None
189
+ terminal_path: str | None = None
190
+ heartbeat_summary: dict[str, Any] | None = None
191
+ executor: str | None = None
192
+ injection_mode: str | None = None
193
+ context_sha256: str | None = None
194
+ expected_skill_pack: tuple[str, ...] = ()
195
+ available: bool = True
196
+ dry_run_only: bool = False
197
+ unavailable_reason: str | None = None
198
+ live_proof_gate: str = "disposable_proof_required"
199
+ promotion_status: str = "proof_gated"
200
+ promotion_requirements: tuple[str, ...] = ()
201
+ auth_preflight_mode: str = "none"
202
+ auth_preflight_probes: tuple[str, ...] = ()
203
+ timeout_posture: str = "runner_managed"
204
+ output_capture_format: str = "combined_output"
205
+ terminal_summary_artifact: str = "terminal-summary.json"
206
+ permission_posture: str = "unknown"
207
+ selected_agent: str | None = None
208
+ selected_model: str | None = None
209
+ selected_variant: str | None = None
210
+ process_pid: int | None = None
211
+ process_group_id: int | None = None
212
+ started_at: str | None = None
213
+ finished_at: str | None = None
214
+ timed_out: bool = False
215
+ interrupted: bool = False
216
+ stalled: bool = False
217
+ claude_route: str | None = None
218
+ claude_route_result: dict[str, Any] | None = None
219
+ cleanup_evidence: dict[str, Any] | None = None
220
+
221
+ @property
222
+ def failed(self) -> bool:
223
+ return self.returncode not in (None, 0)
224
+
225
+ def event_metadata(self) -> dict[str, object]:
226
+ data: dict[str, object] = {
227
+ "returncode": self.returncode,
228
+ "dry_run": self.dry_run,
229
+ "executor": self.executor,
230
+ "injection_mode": self.injection_mode,
231
+ "context_sha256": self.context_sha256,
232
+ "expected_skill_pack": list(self.expected_skill_pack),
233
+ "available": self.available,
234
+ "dry_run_only": self.dry_run_only,
235
+ "unavailable_reason": self.unavailable_reason,
236
+ "live_proof_gate": self.live_proof_gate,
237
+ "promotion_status": self.promotion_status,
238
+ "promotion_requirements": list(self.promotion_requirements),
239
+ "auth_preflight_mode": self.auth_preflight_mode,
240
+ "auth_preflight_probes": list(self.auth_preflight_probes),
241
+ "timeout_posture": self.timeout_posture,
242
+ "output_capture_format": self.output_capture_format,
243
+ "terminal_summary_artifact": self.terminal_summary_artifact,
244
+ "permission_posture": self.permission_posture,
245
+ "selected_agent": self.selected_agent,
246
+ "selected_model": self.selected_model,
247
+ "selected_variant": self.selected_variant,
248
+ }
249
+ if self.log_path:
250
+ data["log_path"] = self.log_path
251
+ if self.heartbeat_path:
252
+ data["heartbeat_path"] = self.heartbeat_path
253
+ if self.terminal_path:
254
+ data["terminal_path"] = self.terminal_path
255
+ if self.heartbeat_summary:
256
+ data["heartbeat"] = self.heartbeat_summary
257
+ if self.process_pid is not None:
258
+ data["process_pid"] = self.process_pid
259
+ if self.process_group_id is not None:
260
+ data["process_group_id"] = self.process_group_id
261
+ if self.started_at is not None:
262
+ data["started_at"] = self.started_at
263
+ if self.finished_at is not None:
264
+ data["finished_at"] = self.finished_at
265
+ if self.timed_out:
266
+ data["timed_out"] = self.timed_out
267
+ if self.interrupted:
268
+ data["interrupted"] = self.interrupted
269
+ if self.stalled:
270
+ data["stalled"] = self.stalled
271
+ if self.claude_route is not None:
272
+ data["claude_route"] = self.claude_route
273
+ if self.claude_route_result is not None:
274
+ data["claude_route_result"] = self.claude_route_result
275
+ if self.cleanup_evidence:
276
+ data["cleanup_evidence"] = self.cleanup_evidence
277
+ return {key: value for key, value in data.items() if value not in (None, [])}
278
+
279
+
280
+ @dataclass(frozen=True)
281
+ class AuthPreflightResult:
282
+ ok: bool
283
+ blocker_class: str | None = None
284
+ blocker_summary: str | None = None
285
+ metadata: dict[str, Any] | None = None
286
+ suggested_ttl_seconds: int | None = None
287
+ demoted_to: str | None = None
288
+
289
+
290
+ # Codex CLI reasoning-effort ceiling is 'xhigh'. The internal 'max' tier (codex's
291
+ # top tier, used by the max-effort planner of record) must be emitted to the CLI
292
+ # as 'xhigh' — emitting 'max' verbatim is rejected ("Invalid value: 'max'. Supported
293
+ # values are: 'none', 'minimal', 'low', 'medium', 'high', and 'xhigh'"). #49.
294
+ # Translating here (the CLI boundary) keeps codex max-ELIGIBLE in the policy/tier
295
+ # layer: a 'max' planner request is honored, at the codex CLI's real ceiling.
296
+ _CODEX_CLI_EFFORT_OVERRIDES = {"max": "xhigh"}
297
+
298
+
299
+ def _codex_cli_effort(effort: str) -> str:
300
+ """Map an internal effort tier to a codex-CLI-supported value (#49).
301
+
302
+ The codex CLI accepts none/minimal/low/medium/high/xhigh; the internal 'max'
303
+ tier maps to 'xhigh' (codex's ceiling). Every other tier passes through.
304
+ """
305
+ return _CODEX_CLI_EFFORT_OVERRIDES.get(effort, effort)
306
+
307
+
308
+ def build_codex_command(
309
+ repo: Path,
310
+ selection: ModelSelection,
311
+ prompt: str,
312
+ json_output: bool = False,
313
+ bypass_approvals: bool = False,
314
+ closeout_schema: dict[str, Any] | None = None,
315
+ ) -> list[str]:
316
+ command = [
317
+ "codex",
318
+ "exec",
319
+ "--cd",
320
+ str(repo),
321
+ "--model",
322
+ selection.model,
323
+ "-c",
324
+ f'model_reasoning_effort="{_codex_cli_effort(selection.effort)}"',
325
+ ]
326
+ if bypass_approvals:
327
+ command.append("--dangerously-bypass-approvals-and-sandbox")
328
+ else:
329
+ command.extend(["--sandbox", "danger-full-access"])
330
+ if json_output:
331
+ command.append("--json")
332
+ if closeout_schema is not None:
333
+ # Defer materialization to launch time (#63): emit a placeholder here; the
334
+ # real run-scoped file is written + substituted in _resolve_command_context
335
+ # and cleaned in launch_with_spec's finally. No temp is created at build.
336
+ command.extend(["--output-schema", CODEX_OUTPUT_SCHEMA_PLACEHOLDER])
337
+ command.append(prompt)
338
+ return command
339
+
340
+
341
+ def build_claude_command(
342
+ repo: Path,
343
+ selection: ModelSelection,
344
+ prompt: str,
345
+ *,
346
+ permission_mode: str,
347
+ allowed_tools: str = CLAUDE_ADAPTER_ALLOWED_TOOLS,
348
+ disallowed_tools: str = CLAUDE_ADAPTER_DISALLOWED_TOOLS,
349
+ bypass_approvals: bool = False,
350
+ closeout_schema: dict[str, Any] | None = None,
351
+ ) -> list[str]:
352
+ command = [
353
+ "claude",
354
+ "-p",
355
+ "--verbose",
356
+ "--output-format",
357
+ "stream-json",
358
+ "--plugin-dir",
359
+ CLAUDE_PLUGIN_DIR_PLACEHOLDER,
360
+ "--settings",
361
+ CLAUDE_SETTINGS_PLACEHOLDER,
362
+ "--agents",
363
+ CLAUDE_AGENTS_PLACEHOLDER,
364
+ "--mcp-config",
365
+ CLAUDE_MCP_CONFIG_PLACEHOLDER,
366
+ "--tools",
367
+ allowed_tools,
368
+ "--disallowedTools",
369
+ disallowed_tools,
370
+ "--permission-mode",
371
+ permission_mode,
372
+ "--add-dir",
373
+ str(repo),
374
+ "--model",
375
+ selection.model,
376
+ "--effort",
377
+ selection.effort,
378
+ ]
379
+ if closeout_schema is not None:
380
+ command.extend(["--json-schema", json.dumps(closeout_schema, separators=(",", ":"), sort_keys=True)])
381
+ if bypass_approvals:
382
+ command.append("--dangerously-skip-permissions")
383
+ command.append(prompt)
384
+ return command
385
+
386
+
387
+ @dataclass(frozen=True)
388
+ class ClaudeRouteSelection:
389
+ route: str
390
+ reason: str
391
+ sidecar_url: str | None = None
392
+ session_id: str | None = None
393
+ error: str | None = None
394
+ warnings: tuple[str, ...] = ()
395
+
396
+
397
+ # DFCHROUTE (IF-0-DFCHROUTE-1): claude_print is an explicit, billing-sensitive
398
+ # compatibility route — never an automatic fallback. Selecting it records this.
399
+ CLAUDE_PRINT_BILLING_WARNING = (
400
+ "claude_print is a billing-sensitive compatibility route: it runs `claude -p` "
401
+ "and spends API/usage credit. It is an explicit operator/CI selection, not a "
402
+ "fallback from Channel or Agent View failure."
403
+ )
404
+
405
+ # DFCHTELEMETRY (IF-0-DFCHTELEMETRY-1): the flags that make `claude` a real print
406
+ # (billing-sensitive) execution, as opposed to Agent View (`--bg`) or a probe.
407
+ _CLAUDE_PRINT_FLAGS = ("-p", "--print", "--bare")
408
+ # Wrapper binaries that may prefix the real `claude` invocation.
409
+ _COMMAND_WRAPPERS = frozenset({"npx", "env", "sudo", "command", "exec", "time", "nohup", "stdbuf", "doas"})
410
+ # Route billing posture (CLAUDE_BILLING_POSTURES vocabulary).
411
+ _CLAUDE_ROUTE_BILLING_POSTURE = {
412
+ "claude_channel": "subscription_included",
413
+ "claude_agent_view": "subscription_included",
414
+ "claude_print": "usage_credit",
415
+ }
416
+
417
+
418
+ def command_runs_claude_print(command) -> bool:
419
+ """True iff `command` is a real `claude` print/bare invocation (`claude -p` /
420
+ `--print` / `--bare`) — no-hidden-print telemetry (IF-0-DFCHTELEMETRY-1).
421
+
422
+ Distinguishes real print execution from benign probe strings (`claude --help`,
423
+ `claude --version`), Agent View (`claude --bg`), and the `claude-channel`
424
+ transport. Robust to quoting (shlex), path-qualified / wrapper-prefixed claude
425
+ (`/usr/bin/claude`, `npx claude`), env-var prefixes, and `--` end-of-options.
426
+ Accepts a token list or a string. Mirrors schema.commandRunsClaudePrint.
427
+ """
428
+ if not command:
429
+ return False
430
+ tokens = shlex.split(command) if isinstance(command, str) else [str(token) for token in command]
431
+ index = 0
432
+ while index < len(tokens) and (
433
+ ("=" in tokens[index] and tokens[index].split("=", 1)[0].isidentifier())
434
+ or os.path.basename(tokens[index]) in _COMMAND_WRAPPERS
435
+ ):
436
+ index += 1
437
+ if index >= len(tokens) or os.path.basename(tokens[index]) != "claude":
438
+ return False
439
+ for token in tokens[index + 1:]:
440
+ if token == "--":
441
+ break # end-of-options: subsequent tokens are positional, not flags
442
+ if token in _CLAUDE_PRINT_FLAGS:
443
+ return True
444
+ return False
445
+
446
+
447
+ def claude_route_billing_posture(route: str | None, reason: str | None = None) -> str:
448
+ # A refused/blocked route never executes, so it carries no billing posture — a
449
+ # blocked claude_print (e.g. invalid_route) must not report usage_credit spend.
450
+ if reason in {"invalid_route", "ci_requires_explicit_route"}:
451
+ return "unknown"
452
+ return _CLAUDE_ROUTE_BILLING_POSTURE.get(route or "", "unknown")
453
+
454
+
455
+ def claude_route_fallback_posture(route: str | None, reason: str | None) -> str:
456
+ """Fallback marker aligned with the validation evidence schema FALLBACK_MARKERS
457
+ (none / explicit_compatibility / explicit_fallback / blocked). The runtime never
458
+ auto-falls-back: print is explicit compatibility, primary routes are `none`, and
459
+ route-resolution errors are `blocked`."""
460
+ if reason in {"invalid_route", "ci_requires_explicit_route"}:
461
+ return "blocked"
462
+ if route == "claude_print":
463
+ return "explicit_compatibility"
464
+ return "none"
465
+
466
+
467
+ def _claude_route_is_ci(environment) -> bool:
468
+ """True in a CI/non-interactive context. Per the route-default contract, CI
469
+ must select a route explicitly rather than inherit the interactive Channel
470
+ default. Matches the `CI` env-var convention (e.g. GitHub Actions `CI=true`)."""
471
+ value = environment.get("CI")
472
+ return value is not None and str(value).strip().lower() not in {"", "0", "false", "no"}
473
+
474
+
475
+ def resolve_claude_route(value: str | None = None, *, env: dict[str, str] | None = None) -> ClaudeRouteSelection:
476
+ environment = env if env is not None else os.environ
477
+ raw_value = value if value is not None else environment.get("PHASE_LOOP_CLAUDE_ROUTE")
478
+ if raw_value is None or not str(raw_value).strip():
479
+ # DFCHROUTE: the default flip. An unset interactive route defaults to
480
+ # Channel (the v47-validated default); a CI/script context must select a
481
+ # route explicitly and blocks otherwise — never a silent billing-sensitive
482
+ # print default.
483
+ if _claude_route_is_ci(environment):
484
+ return ClaudeRouteSelection(
485
+ route="claude_channel",
486
+ reason="ci_requires_explicit_route",
487
+ error=(
488
+ "CI/script context requires an explicit PHASE_LOOP_CLAUDE_ROUTE "
489
+ "(channel, agent_view, or print); refusing to default to a "
490
+ "billing-sensitive print route."
491
+ ),
492
+ )
493
+ return ClaudeRouteSelection(
494
+ route="claude_channel",
495
+ reason="default_channel",
496
+ sidecar_url=environment.get(CLAUDE_CHANNEL_SIDECAR_URL_ENV, DEFAULT_CLAUDE_CHANNEL_SIDECAR_URL),
497
+ session_id=environment.get(CLAUDE_CHANNEL_SESSION_ID_ENV) or environment.get(CLAUDE_CHANNEL_SESSION_ID_ALT_ENV),
498
+ )
499
+ normalized = re.sub(r"[-\s]+", "_", str(raw_value).strip().lower())
500
+ route = CLAUDE_ROUTE_ALIASES.get(normalized)
501
+ if route is None:
502
+ return ClaudeRouteSelection(route="claude_print", reason="invalid_route", error=f"unsupported Claude route `{raw_value}`")
503
+ if route == "claude_channel":
504
+ return ClaudeRouteSelection(
505
+ route=route,
506
+ reason="explicit_channel",
507
+ sidecar_url=environment.get(CLAUDE_CHANNEL_SIDECAR_URL_ENV, DEFAULT_CLAUDE_CHANNEL_SIDECAR_URL),
508
+ session_id=environment.get(CLAUDE_CHANNEL_SESSION_ID_ENV) or environment.get(CLAUDE_CHANNEL_SESSION_ID_ALT_ENV),
509
+ )
510
+ if route == "claude_agent_view":
511
+ return ClaudeRouteSelection(route=route, reason="explicit_agent_view")
512
+ return ClaudeRouteSelection(
513
+ route=route,
514
+ reason="explicit_print_compatibility",
515
+ warnings=(CLAUDE_PRINT_BILLING_WARNING,),
516
+ )
517
+
518
+
519
+ def _write_temp_schema(schema: dict[str, Any]) -> Path:
520
+ handle = tempfile.NamedTemporaryFile("w", encoding="utf-8", suffix="-phase-loop-closeout-schema.json", delete=False)
521
+ with handle:
522
+ json.dump(schema, handle, sort_keys=True)
523
+ handle.write("\n")
524
+ return Path(handle.name)
525
+
526
+
527
+ def _schema_cleanup_paths(command: list[str]) -> tuple[str, ...]:
528
+ paths: list[str] = []
529
+ for index, part in enumerate(command):
530
+ if part == "--output-schema" and index + 1 < len(command):
531
+ paths.append(command[index + 1])
532
+ return tuple(paths)
533
+
534
+
535
+ def _closeout_schema_for_request(request: LaunchRequest) -> dict[str, Any] | None:
536
+ # Only the actions whose FINAL model response is the closeout JSON itself.
537
+ # Plan/roadmap/maintain-skills produce markdown artifacts as the primary
538
+ # output; constraining their response to CLOSEOUT_SCHEMA prevents them
539
+ # from emitting the file contents at all (the provider's structured-output
540
+ # mode forces the whole final response into the schema shape). Limit to
541
+ # actions where the provider's response *is* the closeout.
542
+ if request.action not in {"execute", "repair", "review"}:
543
+ return None
544
+ from .models import CLOSEOUT_SCHEMA
545
+
546
+ return CLOSEOUT_SCHEMA
547
+
548
+
549
+ def _prompt_bundle_with_closeout_schema(
550
+ executor: str,
551
+ prompt_bundle: PromptBundle,
552
+ closeout_schema: dict[str, Any] | None,
553
+ ) -> PromptBundle:
554
+ if closeout_schema is None or executor not in {"gemini", "opencode", "pi"}:
555
+ return prompt_bundle
556
+ from .baml_modular import inject_schema_description
557
+
558
+ return replace(
559
+ prompt_bundle,
560
+ body=inject_schema_description(prompt_bundle.body, closeout_schema),
561
+ context_body=(
562
+ inject_schema_description(prompt_bundle.context_body, closeout_schema)
563
+ if prompt_bundle.context_body is not None
564
+ else None
565
+ ),
566
+ )
567
+
568
+
569
+ def _injection_metadata_for_prompt_bundle(metadata: InjectionMetadata, prompt_bundle: PromptBundle) -> InjectionMetadata:
570
+ return replace(
571
+ metadata,
572
+ context_sha256=prompt_bundle.context_sha256(),
573
+ context_line_count=prompt_bundle.context_line_count(),
574
+ context_char_count=prompt_bundle.context_char_count(),
575
+ )
576
+
577
+
578
+ def build_gemini_command(
579
+ repo: Path,
580
+ selection: ModelSelection,
581
+ *,
582
+ action: str,
583
+ context_file: str,
584
+ ) -> list[str]:
585
+ # v46 EXEC (amended remove→rebuild): the gemini executor now drives the
586
+ # Antigravity CLI (`agy`) — the standalone gemini CLI was sunset
587
+ # (IneligibleTierError). agy runs headless with --add-dir (workspace). agy has no
588
+ # --output-format, so the closeout is injected into the prompt
589
+ # (_prompt_bundle_with_closeout_schema, which still targets "gemini") and parsed
590
+ # from agy's plain-text output — the same path opencode/pi use. A spike on agy
591
+ # 1.0.10 confirmed headless write + a parseable `automation:` closeout.
592
+ #
593
+ # Approval posture: agy has NO granular approval mode (and `--sandbox` still
594
+ # permits file writes), so write actions auto-approve with
595
+ # --dangerously-skip-permissions while `review` STAYS READ-ONLY by omitting it —
596
+ # an analysis-only review prompt needs no tool approvals, mirroring the
597
+ # gemini-cli-runner skill's read-only `agy -p`. (`bypass_approvals` is moot for
598
+ # agy's all-or-nothing model.)
599
+ command = ["agy", "--model", _gemini_cli_model(selection.model)]
600
+ if action != "review":
601
+ command.append("--dangerously-skip-permissions")
602
+ command += [
603
+ "--add-dir",
604
+ str(repo),
605
+ "-p",
606
+ (
607
+ f"Read and follow the workflow instructions in `{context_file}` exactly. "
608
+ "Use that file as the authoritative workflow bundle, do not try to read installed skill files outside the workspace, "
609
+ "and emit the required shared automation closeout."
610
+ ),
611
+ ]
612
+ return command
613
+
614
+
615
+ def _gemini_cli_model(model: str) -> str:
616
+ # Map the phase-loop routing aliases and legacy gemini ids (pro/auto/gemini-*) onto
617
+ # agy's default Pro model. Any OTHER value — a valid agy model name
618
+ # ("Gemini 3.5 Flash (...)", "Claude ...", "GPT-OSS ...") or an explicit operator
619
+ # override — passes through verbatim for agy to validate, rather than being
620
+ # silently coerced to the default.
621
+ candidate = (model or "").strip()
622
+ if candidate in {"", "auto", "pro"} or candidate.startswith("gemini-"):
623
+ return "Gemini 3.1 Pro (High)"
624
+ return candidate
625
+
626
+
627
+ def build_opencode_command(
628
+ repo: Path,
629
+ selection: ModelSelection,
630
+ *,
631
+ action: str,
632
+ agent: str,
633
+ context_file: str,
634
+ bypass_approvals: bool = False,
635
+ ) -> tuple[list[str], str | None]:
636
+ model = _opencode_model(selection.model)
637
+ variant = _opencode_variant(action, selection.effort)
638
+ command = [
639
+ "opencode",
640
+ "run",
641
+ (
642
+ f"Read and follow the workflow instructions in `{context_file}` exactly. "
643
+ "Use that file as the authoritative workflow bundle, do not try to read installed skill files outside the workspace, "
644
+ "and emit the required shared automation closeout."
645
+ ),
646
+ "--dir",
647
+ str(repo),
648
+ "--agent",
649
+ agent,
650
+ "--model",
651
+ model,
652
+ "--format",
653
+ "json",
654
+ ]
655
+ if variant:
656
+ command.extend(["--variant", variant])
657
+ if bypass_approvals:
658
+ command.append("--dangerously-skip-permissions")
659
+ return command, variant
660
+
661
+
662
+ def build_pi_command(
663
+ repo: Path,
664
+ selection: ModelSelection,
665
+ *,
666
+ action: str,
667
+ context_file: str,
668
+ plan: Path | None = None,
669
+ bypass_approvals: bool = False,
670
+ ) -> list[str]:
671
+ command = [
672
+ "pi-agent-watch",
673
+ "--repo",
674
+ str(repo),
675
+ "--prompt-file",
676
+ context_file,
677
+ "--model",
678
+ selection.model,
679
+ "--thinking",
680
+ selection.effort,
681
+ "--closeout-mode",
682
+ "manual",
683
+ "--json",
684
+ ]
685
+ if plan is not None:
686
+ command.extend(["--phase-model", selection.model])
687
+ return command
688
+
689
+
690
+ def build_launch_request(
691
+ *,
692
+ executor: str,
693
+ action: str,
694
+ repo: Path,
695
+ roadmap: Path,
696
+ phase: str | None,
697
+ plan: Path | None,
698
+ model_selection: ModelSelection,
699
+ prompt_bundle: PromptBundle,
700
+ json_output: bool,
701
+ bypass_approvals: bool,
702
+ command_adapter=None,
703
+ launch_timeout_seconds: int | None = None,
704
+ dispatch_decision: DispatchDecision | None = None,
705
+ harness_lane_assignment: HarnessLaneAssignment | None = None,
706
+ delegation_request: DelegationRequest | None = None,
707
+ parent_child_metadata: ParentChildRunMetadata | None = None,
708
+ claude_execution_mode: str | None = None,
709
+ claude_team_policy: ClaudeTeamPolicy | None = None,
710
+ phase_team_eligibility: PhaseTeamEligibility | None = None,
711
+ ) -> LaunchRequest:
712
+ resolved_mode = None
713
+ resolved_policy = None
714
+ resolved_eligibility = None
715
+ if executor == "claude":
716
+ capability = capability_registry()["claude"]
717
+ resolved_mode = claude_execution_mode or capability.default_claude_execution_mode or "solo"
718
+ resolved_policy = claude_team_policy or _claude_policy_for_mode(capability, resolved_mode)
719
+ resolved_eligibility = phase_team_eligibility or classify_phase_team_eligibility(repo, roadmap, plan)
720
+ return LaunchRequest(
721
+ executor=executor,
722
+ action=action,
723
+ repo=repo,
724
+ roadmap=roadmap,
725
+ phase=phase,
726
+ plan=plan,
727
+ model_selection=model_selection,
728
+ prompt_bundle=prompt_bundle,
729
+ injection_metadata=InjectionMetadata(
730
+ harness_target=executor,
731
+ injection_mode=prompt_bundle.injection_mode,
732
+ context_sha256=prompt_bundle.context_sha256(),
733
+ context_line_count=prompt_bundle.context_line_count(),
734
+ context_char_count=prompt_bundle.context_char_count(),
735
+ expected_skill_pack=prompt_bundle.expected_skill_pack,
736
+ skill_bundle_id=prompt_bundle.skill_bundle_id,
737
+ skill_bundle_sha256=prompt_bundle.skill_bundle_sha256,
738
+ context_path=prompt_bundle.context_path,
739
+ fallback_mode=prompt_bundle.fallback_mode,
740
+ recommended_installed_roots=prompt_bundle.recommended_installed_roots,
741
+ installed_skill_roots=prompt_bundle.installed_skill_roots,
742
+ installed_skill_warnings=prompt_bundle.installed_skill_warnings,
743
+ bridge_skill_inventory=prompt_bundle.bridge_skill_inventory,
744
+ ),
745
+ permission_policy=PermissionPolicy(
746
+ sandbox_mode="danger-full-access",
747
+ bypass_approvals=bypass_approvals,
748
+ ),
749
+ command_adapter=command_adapter,
750
+ dispatch_decision=dispatch_decision,
751
+ harness_lane_assignment=harness_lane_assignment,
752
+ delegation_request=delegation_request,
753
+ parent_child_metadata=parent_child_metadata,
754
+ claude_execution_mode=resolved_mode,
755
+ claude_team_policy=resolved_policy,
756
+ phase_team_eligibility=resolved_eligibility,
757
+ json_output=json_output,
758
+ bypass_approvals=bypass_approvals,
759
+ launch_timeout_seconds=launch_timeout_seconds,
760
+ )
761
+
762
+
763
+ def build_launch_spec(request: LaunchRequest) -> LaunchSpec:
764
+ capability = capability_registry()[request.executor]
765
+ closeout_schema = _closeout_schema_for_request(request)
766
+ prompt_bundle = _prompt_bundle_with_closeout_schema(request.executor, request.prompt_bundle, closeout_schema)
767
+ injection_metadata = _injection_metadata_for_prompt_bundle(request.injection_metadata, prompt_bundle)
768
+ if request.executor == "codex":
769
+ command = build_codex_command(
770
+ request.repo,
771
+ request.model_selection,
772
+ prompt_bundle.render_prompt(),
773
+ json_output=request.json_output,
774
+ bypass_approvals=request.bypass_approvals,
775
+ closeout_schema=closeout_schema,
776
+ )
777
+ return LaunchSpec(
778
+ executor="codex",
779
+ command=command,
780
+ prompt_bundle=prompt_bundle,
781
+ injection_metadata=injection_metadata,
782
+ delivery_mode=injection_metadata.injection_mode,
783
+ dispatch_decision=request.dispatch_decision,
784
+ available=True,
785
+ harness_lane_assignment=request.harness_lane_assignment,
786
+ live_proof_gate=capability.live_proof_gate,
787
+ promotion_status=capability.promotion_status,
788
+ promotion_requirements=capability.promotion_requirements,
789
+ auth_preflight_mode=capability.auth_preflight_mode,
790
+ auth_preflight_probes=capability.auth_preflight_probes,
791
+ timeout_posture=capability.timeout_posture,
792
+ output_capture_format=capability.output_capture_format,
793
+ terminal_summary_artifact=capability.terminal_summary_artifact,
794
+ permission_posture=capability.permission_posture,
795
+ selected_model=request.model_selection.model,
796
+ selected_effort=request.model_selection.effort,
797
+ profile_source=request.model_selection.source,
798
+ override_reason=request.model_selection.override_reason,
799
+ wrapped_cwd=str(request.repo),
800
+ launch_timeout_seconds=request.launch_timeout_seconds,
801
+ # No build-time temp to clean (#63): the command holds a placeholder;
802
+ # the real path is materialized + cleaned at launch from the resolved
803
+ # command. cleanup_paths stays empty here.
804
+ cleanup_paths=(),
805
+ codex_output_schema=closeout_schema,
806
+ )
807
+ if request.executor == "claude":
808
+ delivery_mode = "context_file" if _claude_uses_context_file(prompt_bundle) else injection_metadata.injection_mode
809
+ route_selection = resolve_claude_route()
810
+ claude_policy = request.claude_team_policy or _claude_policy_for_mode(
811
+ capability,
812
+ request.claude_execution_mode or capability.default_claude_execution_mode or "solo",
813
+ )
814
+ eligibility = request.phase_team_eligibility or classify_phase_team_eligibility(request.repo, request.roadmap, request.plan)
815
+ policy_error = _claude_team_policy_error(
816
+ action=request.action,
817
+ execution_mode=request.claude_execution_mode or "solo",
818
+ policy=claude_policy,
819
+ eligibility=eligibility,
820
+ )
821
+ route_error = route_selection.error
822
+ if route_error is None and route_selection.route == "claude_channel":
823
+ # DFCHPREFLIGHT (IF-0-DFCHPREFLIGHT-1): a Channel route launch requires
824
+ # a session id AND a loopback sidecar URL. Missing/blocked prerequisites
825
+ # reduce to a metadata-only route blocker here (no claude -p built), not
826
+ # a deferred failure at sidecar-client construction.
827
+ # Guard on `route_error is None` so a route-resolution error (e.g. the
828
+ # DFCHROUTE CI-requires-explicit-route block) is not overwritten by the
829
+ # more-specific channel prerequisite message.
830
+ if not route_selection.session_id:
831
+ route_error = "Claude Channel route requires PHASE_LOOP_CHANNEL_SESSION_ID or PHASE_LOOP_CLAUDE_CHANNEL_SESSION_ID."
832
+ elif not is_loopback_http_url(route_selection.sidecar_url or ""):
833
+ # NB: resolve_claude_route already defaults an UNSET URL to the
834
+ # loopback default, so an empty value here means the operator
835
+ # explicitly set PHASE_LOOP_CLAUDE_CHANNEL_URL= (blank) — that is a
836
+ # misconfiguration and must block, not silently use the default.
837
+ route_error = (
838
+ "Claude Channel route requires a loopback sidecar URL "
839
+ f"({route_selection.sidecar_url or '<empty>'}); remote/non-loopback transport is blocked."
840
+ )
841
+ if policy_error is not None:
842
+ return LaunchSpec(
843
+ executor="claude",
844
+ command=_stub_command(request, policy_error),
845
+ prompt_bundle=prompt_bundle,
846
+ injection_metadata=injection_metadata,
847
+ delivery_mode=delivery_mode,
848
+ dispatch_decision=request.dispatch_decision,
849
+ available=False,
850
+ harness_lane_assignment=request.harness_lane_assignment,
851
+ dry_run_only=False,
852
+ reason=policy_error,
853
+ live_proof_gate=capability.live_proof_gate,
854
+ promotion_status=capability.promotion_status,
855
+ promotion_requirements=capability.promotion_requirements,
856
+ auth_preflight_mode=capability.auth_preflight_mode,
857
+ auth_preflight_probes=capability.auth_preflight_probes,
858
+ timeout_posture=capability.timeout_posture,
859
+ output_capture_format=capability.output_capture_format,
860
+ terminal_summary_artifact=capability.terminal_summary_artifact,
861
+ permission_posture=capability.permission_posture,
862
+ selected_model=request.model_selection.model,
863
+ selected_effort=request.model_selection.effort,
864
+ profile_source=request.model_selection.source,
865
+ override_reason=request.model_selection.override_reason,
866
+ wrapped_cwd=str(request.repo),
867
+ launch_timeout_seconds=request.launch_timeout_seconds,
868
+ claude_execution_mode=request.claude_execution_mode or "solo",
869
+ claude_team_policy=claude_policy,
870
+ phase_team_eligibility=eligibility,
871
+ claude_route=route_selection.route,
872
+ claude_route_reason=route_selection.reason,
873
+ claude_sidecar_url=route_selection.sidecar_url,
874
+ claude_channel_session_id=route_selection.session_id,
875
+ )
876
+ if route_error is not None:
877
+ return LaunchSpec(
878
+ executor="claude",
879
+ command=_stub_command(request, route_error),
880
+ prompt_bundle=prompt_bundle,
881
+ injection_metadata=injection_metadata,
882
+ delivery_mode=delivery_mode,
883
+ dispatch_decision=request.dispatch_decision,
884
+ available=False,
885
+ harness_lane_assignment=request.harness_lane_assignment,
886
+ dry_run_only=False,
887
+ reason=route_error,
888
+ live_proof_gate=capability.live_proof_gate,
889
+ promotion_status=capability.promotion_status,
890
+ promotion_requirements=capability.promotion_requirements,
891
+ auth_preflight_mode=capability.auth_preflight_mode,
892
+ auth_preflight_probes=capability.auth_preflight_probes,
893
+ timeout_posture=capability.timeout_posture,
894
+ output_capture_format=capability.output_capture_format,
895
+ terminal_summary_artifact=capability.terminal_summary_artifact,
896
+ permission_posture=capability.permission_posture,
897
+ selected_model=request.model_selection.model,
898
+ selected_effort=request.model_selection.effort,
899
+ profile_source=request.model_selection.source,
900
+ override_reason=request.model_selection.override_reason,
901
+ wrapped_cwd=str(request.repo),
902
+ launch_timeout_seconds=request.launch_timeout_seconds,
903
+ claude_execution_mode=request.claude_execution_mode or "solo",
904
+ claude_team_policy=claude_policy,
905
+ phase_team_eligibility=eligibility,
906
+ claude_route=route_selection.route,
907
+ claude_route_reason=route_selection.reason,
908
+ claude_sidecar_url=route_selection.sidecar_url,
909
+ claude_channel_session_id=route_selection.session_id,
910
+ )
911
+ if route_selection.route == "claude_channel":
912
+ return LaunchSpec(
913
+ executor="claude",
914
+ command=[
915
+ "claude-channel",
916
+ "send",
917
+ "--sidecar-url",
918
+ route_selection.sidecar_url or DEFAULT_CLAUDE_CHANNEL_SIDECAR_URL,
919
+ "--session-id",
920
+ route_selection.session_id or "",
921
+ ],
922
+ prompt_bundle=prompt_bundle,
923
+ injection_metadata=injection_metadata,
924
+ delivery_mode="channel",
925
+ dispatch_decision=request.dispatch_decision,
926
+ available=True,
927
+ harness_lane_assignment=request.harness_lane_assignment,
928
+ live_proof_gate=capability.live_proof_gate,
929
+ promotion_status=capability.promotion_status,
930
+ promotion_requirements=capability.promotion_requirements,
931
+ auth_preflight_mode=capability.auth_preflight_mode,
932
+ auth_preflight_probes=capability.auth_preflight_probes,
933
+ timeout_posture=capability.timeout_posture,
934
+ output_capture_format=capability.output_capture_format,
935
+ terminal_summary_artifact=capability.terminal_summary_artifact,
936
+ permission_posture=capability.permission_posture,
937
+ selected_model=request.model_selection.model,
938
+ selected_effort=request.model_selection.effort,
939
+ profile_source=request.model_selection.source,
940
+ override_reason=request.model_selection.override_reason,
941
+ wrapped_cwd=str(request.repo),
942
+ launch_timeout_seconds=request.launch_timeout_seconds,
943
+ claude_execution_mode=request.claude_execution_mode or "solo",
944
+ claude_team_policy=claude_policy,
945
+ phase_team_eligibility=eligibility,
946
+ claude_route=route_selection.route,
947
+ claude_route_reason=route_selection.reason,
948
+ claude_sidecar_url=route_selection.sidecar_url,
949
+ claude_channel_session_id=route_selection.session_id,
950
+ )
951
+ if route_selection.route == "claude_agent_view":
952
+ permission_mode = _claude_permission_mode(request.action, request.bypass_approvals)
953
+ return LaunchSpec(
954
+ executor="claude",
955
+ command=ClaudeAgentViewAdapter().launch_command(
956
+ CLAUDE_CONTEXT_PLACEHOLDER if delivery_mode == "context_file" else prompt_bundle.render_context(),
957
+ cwd=request.repo,
958
+ model=request.model_selection.model,
959
+ effort=request.model_selection.effort,
960
+ permission=permission_mode,
961
+ ),
962
+ prompt_bundle=prompt_bundle,
963
+ injection_metadata=injection_metadata,
964
+ delivery_mode="agent_view",
965
+ dispatch_decision=request.dispatch_decision,
966
+ available=True,
967
+ harness_lane_assignment=request.harness_lane_assignment,
968
+ live_proof_gate=capability.live_proof_gate,
969
+ promotion_status=capability.promotion_status,
970
+ promotion_requirements=capability.promotion_requirements,
971
+ auth_preflight_mode=capability.auth_preflight_mode,
972
+ auth_preflight_probes=capability.auth_preflight_probes,
973
+ timeout_posture=capability.timeout_posture,
974
+ output_capture_format=capability.output_capture_format,
975
+ terminal_summary_artifact=capability.terminal_summary_artifact,
976
+ permission_posture=capability.permission_posture,
977
+ selected_model=request.model_selection.model,
978
+ selected_effort=request.model_selection.effort,
979
+ profile_source=request.model_selection.source,
980
+ override_reason=request.model_selection.override_reason,
981
+ wrapped_cwd=str(request.repo),
982
+ launch_timeout_seconds=request.launch_timeout_seconds,
983
+ claude_execution_mode=request.claude_execution_mode or "solo",
984
+ claude_team_policy=claude_policy,
985
+ phase_team_eligibility=eligibility,
986
+ claude_route=route_selection.route,
987
+ claude_route_reason=route_selection.reason,
988
+ )
989
+ return LaunchSpec(
990
+ executor="claude",
991
+ command=build_claude_command(
992
+ request.repo,
993
+ request.model_selection,
994
+ CLAUDE_CONTEXT_PLACEHOLDER if delivery_mode == "context_file" else prompt_bundle.render_context(),
995
+ permission_mode=_claude_permission_mode(request.action, request.bypass_approvals),
996
+ allowed_tools=",".join(claude_policy.allowed_tools) if claude_policy.allowed_tools else CLAUDE_ADAPTER_ALLOWED_TOOLS,
997
+ disallowed_tools=",".join(claude_policy.disallowed_tools) if claude_policy.disallowed_tools else CLAUDE_ADAPTER_DISALLOWED_TOOLS,
998
+ bypass_approvals=request.bypass_approvals,
999
+ closeout_schema=closeout_schema,
1000
+ ),
1001
+ prompt_bundle=prompt_bundle,
1002
+ injection_metadata=injection_metadata,
1003
+ delivery_mode=delivery_mode,
1004
+ dispatch_decision=request.dispatch_decision,
1005
+ available=True,
1006
+ harness_lane_assignment=request.harness_lane_assignment,
1007
+ live_proof_gate=capability.live_proof_gate,
1008
+ promotion_status=capability.promotion_status,
1009
+ promotion_requirements=capability.promotion_requirements,
1010
+ auth_preflight_mode=capability.auth_preflight_mode,
1011
+ auth_preflight_probes=capability.auth_preflight_probes,
1012
+ timeout_posture=capability.timeout_posture,
1013
+ output_capture_format=capability.output_capture_format,
1014
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1015
+ permission_posture=capability.permission_posture,
1016
+ selected_model=request.model_selection.model,
1017
+ selected_effort=request.model_selection.effort,
1018
+ profile_source=request.model_selection.source,
1019
+ override_reason=request.model_selection.override_reason,
1020
+ wrapped_cwd=str(request.repo),
1021
+ launch_timeout_seconds=request.launch_timeout_seconds,
1022
+ claude_execution_mode=request.claude_execution_mode or "solo",
1023
+ claude_team_policy=claude_policy,
1024
+ phase_team_eligibility=eligibility,
1025
+ claude_route=route_selection.route,
1026
+ claude_route_reason=route_selection.reason,
1027
+ claude_route_warnings=route_selection.warnings,
1028
+ claude_sidecar_url=route_selection.sidecar_url,
1029
+ claude_channel_session_id=route_selection.session_id,
1030
+ )
1031
+ if request.executor == "gemini":
1032
+ return LaunchSpec(
1033
+ executor="gemini",
1034
+ command=build_gemini_command(
1035
+ request.repo,
1036
+ request.model_selection,
1037
+ action=request.action,
1038
+ context_file=GEMINI_CONTEXT_PLACEHOLDER,
1039
+ ),
1040
+ prompt_bundle=prompt_bundle,
1041
+ injection_metadata=injection_metadata,
1042
+ delivery_mode=injection_metadata.injection_mode,
1043
+ dispatch_decision=request.dispatch_decision,
1044
+ available=True,
1045
+ harness_lane_assignment=request.harness_lane_assignment,
1046
+ live_proof_gate=capability.live_proof_gate,
1047
+ promotion_status=capability.promotion_status,
1048
+ promotion_requirements=capability.promotion_requirements,
1049
+ auth_preflight_mode=capability.auth_preflight_mode,
1050
+ auth_preflight_probes=capability.auth_preflight_probes,
1051
+ timeout_posture=capability.timeout_posture,
1052
+ output_capture_format=capability.output_capture_format,
1053
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1054
+ permission_posture=capability.permission_posture,
1055
+ selected_model=request.model_selection.model,
1056
+ selected_effort=request.model_selection.effort,
1057
+ profile_source=request.model_selection.source,
1058
+ override_reason=request.model_selection.override_reason,
1059
+ launch_timeout_seconds=request.launch_timeout_seconds,
1060
+ )
1061
+ if request.executor == "opencode":
1062
+ selected_agent = _opencode_agent(request.action)
1063
+ selected_model = _opencode_model(request.model_selection.model)
1064
+ command, selected_variant = build_opencode_command(
1065
+ request.repo,
1066
+ request.model_selection,
1067
+ action=request.action,
1068
+ agent=selected_agent,
1069
+ context_file=OPENCODE_CONTEXT_PLACEHOLDER,
1070
+ bypass_approvals=request.bypass_approvals,
1071
+ )
1072
+ return LaunchSpec(
1073
+ executor="opencode",
1074
+ command=command,
1075
+ prompt_bundle=prompt_bundle,
1076
+ injection_metadata=injection_metadata,
1077
+ delivery_mode=injection_metadata.injection_mode,
1078
+ dispatch_decision=request.dispatch_decision,
1079
+ available=True,
1080
+ harness_lane_assignment=request.harness_lane_assignment,
1081
+ live_proof_gate=capability.live_proof_gate,
1082
+ promotion_status=capability.promotion_status,
1083
+ promotion_requirements=capability.promotion_requirements,
1084
+ auth_preflight_mode=capability.auth_preflight_mode,
1085
+ auth_preflight_probes=capability.auth_preflight_probes,
1086
+ timeout_posture=capability.timeout_posture,
1087
+ output_capture_format=capability.output_capture_format,
1088
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1089
+ permission_posture=capability.permission_posture,
1090
+ selected_agent=selected_agent,
1091
+ selected_model=selected_model,
1092
+ selected_effort=request.model_selection.effort,
1093
+ profile_source=request.model_selection.source,
1094
+ override_reason=request.model_selection.override_reason,
1095
+ selected_variant=selected_variant,
1096
+ launch_timeout_seconds=request.launch_timeout_seconds,
1097
+ )
1098
+ if request.executor == "pi":
1099
+ return LaunchSpec(
1100
+ executor="pi",
1101
+ command=build_pi_command(
1102
+ request.repo,
1103
+ request.model_selection,
1104
+ action=request.action,
1105
+ context_file=PI_CONTEXT_PLACEHOLDER,
1106
+ plan=request.plan,
1107
+ bypass_approvals=request.bypass_approvals,
1108
+ ),
1109
+ prompt_bundle=prompt_bundle,
1110
+ injection_metadata=injection_metadata,
1111
+ delivery_mode=injection_metadata.injection_mode,
1112
+ dispatch_decision=request.dispatch_decision,
1113
+ available=True,
1114
+ harness_lane_assignment=request.harness_lane_assignment,
1115
+ live_proof_gate=capability.live_proof_gate,
1116
+ promotion_status=capability.promotion_status,
1117
+ promotion_requirements=capability.promotion_requirements,
1118
+ auth_preflight_mode=capability.auth_preflight_mode,
1119
+ auth_preflight_probes=capability.auth_preflight_probes,
1120
+ timeout_posture=capability.timeout_posture,
1121
+ output_capture_format=capability.output_capture_format,
1122
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1123
+ permission_posture=capability.permission_posture,
1124
+ selected_model=request.model_selection.model,
1125
+ selected_effort=request.model_selection.effort,
1126
+ profile_source=request.model_selection.source,
1127
+ override_reason=request.model_selection.override_reason,
1128
+ wrapped_cwd=str(request.repo),
1129
+ launch_timeout_seconds=request.launch_timeout_seconds,
1130
+ )
1131
+ if request.executor == "command":
1132
+ return _build_command_launch_spec(request, capability)
1133
+ reason = STUB_EXECUTOR_REASONS[request.executor]
1134
+ return LaunchSpec(
1135
+ executor=request.executor,
1136
+ command=_stub_command(request, reason),
1137
+ prompt_bundle=prompt_bundle,
1138
+ injection_metadata=injection_metadata,
1139
+ delivery_mode=injection_metadata.injection_mode,
1140
+ dispatch_decision=request.dispatch_decision,
1141
+ available=False,
1142
+ harness_lane_assignment=request.harness_lane_assignment,
1143
+ dry_run_only=True,
1144
+ reason=reason,
1145
+ live_proof_gate=capability.live_proof_gate,
1146
+ promotion_status=capability.promotion_status,
1147
+ promotion_requirements=capability.promotion_requirements,
1148
+ auth_preflight_mode=capability.auth_preflight_mode,
1149
+ auth_preflight_probes=capability.auth_preflight_probes,
1150
+ timeout_posture=capability.timeout_posture,
1151
+ output_capture_format=capability.output_capture_format,
1152
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1153
+ permission_posture=capability.permission_posture,
1154
+ launch_timeout_seconds=request.launch_timeout_seconds,
1155
+ )
1156
+
1157
+
1158
+ def _build_command_launch_spec(request: LaunchRequest, capability) -> LaunchSpec:
1159
+ config = request.command_adapter
1160
+ if config is None:
1161
+ return _unavailable_command_spec(
1162
+ request,
1163
+ capability,
1164
+ "Command executor requires explicit adapter inputs: pass `--command-name` and `--command-template`.",
1165
+ )
1166
+ if request.action not in config.supported_actions:
1167
+ return _unavailable_command_spec(
1168
+ request,
1169
+ capability,
1170
+ f"Command adapter `{config.name}` does not allow the `{request.action}` action.",
1171
+ )
1172
+ if config.delivery_mode != "context_file":
1173
+ return _unavailable_command_spec(
1174
+ request,
1175
+ capability,
1176
+ f"Command adapter `{config.name}` delivery mode `{config.delivery_mode}` is unsupported; use `context_file`.",
1177
+ )
1178
+ template_error = _validate_command_template(config.template, request)
1179
+ if template_error:
1180
+ return _unavailable_command_spec(request, capability, template_error, command_adapter_name=config.name, command_template=config.template)
1181
+ command = _render_command_template(config.template, request)
1182
+ return LaunchSpec(
1183
+ executor="command",
1184
+ command=command,
1185
+ prompt_bundle=request.prompt_bundle,
1186
+ injection_metadata=request.injection_metadata,
1187
+ delivery_mode=config.delivery_mode,
1188
+ dispatch_decision=request.dispatch_decision,
1189
+ available=True,
1190
+ harness_lane_assignment=request.harness_lane_assignment,
1191
+ live_proof_gate=capability.live_proof_gate,
1192
+ promotion_status=capability.promotion_status,
1193
+ promotion_requirements=capability.promotion_requirements,
1194
+ auth_preflight_mode=capability.auth_preflight_mode,
1195
+ auth_preflight_probes=capability.auth_preflight_probes,
1196
+ timeout_posture=capability.timeout_posture,
1197
+ output_capture_format=capability.output_capture_format,
1198
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1199
+ permission_posture=capability.permission_posture,
1200
+ selected_model=request.model_selection.model,
1201
+ selected_effort=request.model_selection.effort,
1202
+ profile_source=request.model_selection.source,
1203
+ override_reason=request.model_selection.override_reason,
1204
+ command_adapter_name=config.name,
1205
+ command_template=config.template,
1206
+ wrapped_cwd=str(request.repo),
1207
+ launch_timeout_seconds=request.launch_timeout_seconds,
1208
+ claude_execution_mode=request.claude_execution_mode,
1209
+ claude_team_policy=request.claude_team_policy,
1210
+ phase_team_eligibility=request.phase_team_eligibility,
1211
+ )
1212
+
1213
+
1214
+ def _unavailable_command_spec(
1215
+ request: LaunchRequest,
1216
+ capability,
1217
+ reason: str,
1218
+ *,
1219
+ command_adapter_name: str | None = None,
1220
+ command_template: str | None = None,
1221
+ ) -> LaunchSpec:
1222
+ return LaunchSpec(
1223
+ executor="command",
1224
+ command=_stub_command(request, reason),
1225
+ prompt_bundle=request.prompt_bundle,
1226
+ injection_metadata=request.injection_metadata,
1227
+ delivery_mode=request.injection_metadata.injection_mode,
1228
+ dispatch_decision=request.dispatch_decision,
1229
+ available=False,
1230
+ harness_lane_assignment=request.harness_lane_assignment,
1231
+ dry_run_only=False,
1232
+ reason=reason,
1233
+ live_proof_gate=capability.live_proof_gate,
1234
+ promotion_status=capability.promotion_status,
1235
+ promotion_requirements=capability.promotion_requirements,
1236
+ auth_preflight_mode=capability.auth_preflight_mode,
1237
+ auth_preflight_probes=capability.auth_preflight_probes,
1238
+ timeout_posture=capability.timeout_posture,
1239
+ output_capture_format=capability.output_capture_format,
1240
+ terminal_summary_artifact=capability.terminal_summary_artifact,
1241
+ permission_posture=capability.permission_posture,
1242
+ selected_model=request.model_selection.model,
1243
+ selected_effort=request.model_selection.effort,
1244
+ profile_source=request.model_selection.source,
1245
+ override_reason=request.model_selection.override_reason,
1246
+ command_adapter_name=command_adapter_name,
1247
+ command_template=command_template,
1248
+ wrapped_cwd=str(request.repo),
1249
+ launch_timeout_seconds=request.launch_timeout_seconds,
1250
+ claude_execution_mode=request.claude_execution_mode,
1251
+ claude_team_policy=request.claude_team_policy,
1252
+ phase_team_eligibility=request.phase_team_eligibility,
1253
+ )
1254
+
1255
+
1256
+ def _claude_policy_for_mode(capability, execution_mode: str) -> ClaudeTeamPolicy:
1257
+ for policy in capability.claude_execution_policies:
1258
+ if policy.execution_mode == execution_mode:
1259
+ return policy
1260
+ raise ValueError(f"missing Claude team policy for mode `{execution_mode}`")
1261
+
1262
+
1263
+ def _claude_team_policy_error(
1264
+ *,
1265
+ action: str,
1266
+ execution_mode: str,
1267
+ policy: ClaudeTeamPolicy,
1268
+ eligibility: PhaseTeamEligibility,
1269
+ ) -> str | None:
1270
+ if action in policy.disallowed_actions:
1271
+ return f"Claude {execution_mode} mode is denied for `{action}` by TEAMGOV policy."
1272
+ if execution_mode == "solo":
1273
+ return None
1274
+ if not eligibility.eligible_for_native_team:
1275
+ return (
1276
+ f"Claude {execution_mode} mode is denied because the active phase plan is not team-safe: "
1277
+ f"{eligibility.reason}."
1278
+ )
1279
+ if policy.requires_disjoint_owned_files and not (eligibility.has_disjoint_write_lanes or eligibility.has_only_read_only_lanes):
1280
+ return (
1281
+ f"Claude {execution_mode} mode requires disjoint write ownership or true read-only lanes; "
1282
+ "the active phase plan does not satisfy that contract."
1283
+ )
1284
+ return None
1285
+
1286
+
1287
+ def _validate_command_template(template: str, request: LaunchRequest) -> str | None:
1288
+ if not template.strip():
1289
+ return "Command executor requires a non-empty command template."
1290
+ formatter = string.Formatter()
1291
+ field_names: set[str] = set()
1292
+ for _literal_text, field_name, _format_spec, _conversion in formatter.parse(template):
1293
+ if field_name is None:
1294
+ continue
1295
+ field_names.add(field_name)
1296
+ if field_name not in COMMAND_TEMPLATE_ALLOWED_FIELDS:
1297
+ allowed = ", ".join(sorted(COMMAND_TEMPLATE_ALLOWED_FIELDS))
1298
+ return f"Command template uses unsupported placeholder `{field_name}`. Allowed placeholders: {allowed}."
1299
+ if "context_file" not in field_names:
1300
+ return "Command template must include `{context_file}` so the runner can deliver the repo-sourced workflow bundle."
1301
+ if "plan" in field_names and request.plan is None:
1302
+ return "Command template requires `{plan}`, but the selected phase has no current plan artifact."
1303
+ if "phase" in field_names and request.phase is None:
1304
+ return "Command template requires `{phase}`, but no phase alias was selected."
1305
+ return None
1306
+
1307
+
1308
+ def _render_command_template(template: str, request: LaunchRequest) -> list[str]:
1309
+ rendered = template.format(
1310
+ action=request.action,
1311
+ repo=str(request.repo),
1312
+ roadmap=str(request.roadmap),
1313
+ phase=request.phase or "",
1314
+ plan=str(request.plan) if request.plan is not None else "",
1315
+ context_file=COMMAND_CONTEXT_PLACEHOLDER,
1316
+ model=request.model_selection.model,
1317
+ effort=request.model_selection.effort,
1318
+ cwd=str(request.repo),
1319
+ )
1320
+ return shlex.split(rendered)
1321
+
1322
+
1323
+ def run_auth_preflight(spec: LaunchSpec) -> AuthPreflightResult:
1324
+ if spec.auth_preflight_mode != "metadata_only":
1325
+ return AuthPreflightResult(ok=True, metadata={})
1326
+
1327
+ metadata: dict[str, Any] = {"executor": spec.executor, "probes": []}
1328
+ probe_outputs: dict[str, str] = {}
1329
+ for probe in spec.auth_preflight_probes:
1330
+ completed = subprocess.run(probe, shell=True, text=True, capture_output=True, check=False)
1331
+ stdout = completed.stdout.strip()
1332
+ stderr = completed.stderr.strip()
1333
+ probe_outputs[probe] = " ".join(part for part in (stdout, stderr) if part)
1334
+ probe_metadata = {
1335
+ "probe": probe,
1336
+ "returncode": completed.returncode,
1337
+ "command_available": completed.returncode == 0,
1338
+ "stdout_present": bool(stdout),
1339
+ "stderr_present": bool(stderr),
1340
+ "stdout_bytes": len(completed.stdout or ""),
1341
+ "stderr_bytes": len(completed.stderr or ""),
1342
+ }
1343
+ if _looks_like_help_surface(stdout) or _looks_like_help_surface(stderr):
1344
+ probe_metadata["help_surface_present"] = True
1345
+ if probe.endswith("--version") and completed.returncode == 0:
1346
+ probe_metadata["version_surface_present"] = True
1347
+ if "auth status" in probe or "login status" in probe:
1348
+ probe_metadata["auth_surface_present"] = completed.returncode == 0
1349
+ metadata["probes"].append(probe_metadata)
1350
+ if completed.returncode != 0:
1351
+ label = {
1352
+ "claude": "Claude",
1353
+ "codex": "Codex",
1354
+ "gemini": "Gemini",
1355
+ "opencode": "OpenCode",
1356
+ "pi": "Pi Agent",
1357
+ "command": "command adapter",
1358
+ "manual": "manual handoff",
1359
+ }.get(spec.executor, spec.executor)
1360
+ if _looks_like_capacity_exhaustion(probe_outputs[probe]):
1361
+ summary = f"{label} CLI metadata preflight for `{probe}` reported temporary provider capacity or quota exhaustion."
1362
+ return AuthPreflightResult(
1363
+ ok=False,
1364
+ blocker_class="unretryable_external_outage",
1365
+ blocker_summary=summary,
1366
+ metadata=metadata,
1367
+ suggested_ttl_seconds=1800,
1368
+ demoted_to="manual_only",
1369
+ )
1370
+ summary = f"{label} CLI metadata preflight failed for `{probe}`."
1371
+ return AuthPreflightResult(
1372
+ ok=False,
1373
+ blocker_class="account_or_billing_setup",
1374
+ blocker_summary=summary,
1375
+ metadata=metadata,
1376
+ suggested_ttl_seconds=300,
1377
+ demoted_to="proof_gated",
1378
+ )
1379
+
1380
+ if spec.executor == "codex":
1381
+ status_text = probe_outputs.get("codex login status", "").lower()
1382
+ metadata["auth_status"] = {"logged_in": "logged in" in status_text, "source": "codex login status"}
1383
+ if "logged in" not in status_text:
1384
+ return AuthPreflightResult(
1385
+ ok=False,
1386
+ blocker_class="account_or_billing_setup",
1387
+ blocker_summary="Codex CLI is installed but not logged in to an authenticated local subscription session.",
1388
+ metadata=metadata,
1389
+ suggested_ttl_seconds=300,
1390
+ demoted_to="proof_gated",
1391
+ )
1392
+ return AuthPreflightResult(ok=True, metadata=metadata)
1393
+
1394
+ if spec.executor == "claude":
1395
+ status_payload = _parse_json_object(probe_outputs.get("claude auth status", ""))
1396
+ metadata["auth_status"] = {
1397
+ "logged_in": bool(status_payload.get("loggedIn")) if status_payload else False,
1398
+ "subscription_present": bool(status_payload.get("subscriptionType")) if status_payload else False,
1399
+ "quota_signal_present": _claude_status_has_quota_signal(status_payload),
1400
+ "source": "claude auth status",
1401
+ }
1402
+ if _claude_status_has_quota_signal(status_payload) or _looks_like_capacity_exhaustion(probe_outputs.get("claude auth status", "")):
1403
+ return AuthPreflightResult(
1404
+ ok=False,
1405
+ blocker_class="unretryable_external_outage",
1406
+ blocker_summary="Claude CLI auth metadata reported temporary provider capacity or quota exhaustion.",
1407
+ metadata=metadata,
1408
+ suggested_ttl_seconds=1800,
1409
+ demoted_to="manual_only",
1410
+ )
1411
+ if not status_payload or not status_payload.get("loggedIn"):
1412
+ return AuthPreflightResult(
1413
+ ok=False,
1414
+ blocker_class="account_or_billing_setup",
1415
+ blocker_summary="Claude CLI is installed but not logged in to an authenticated local subscription session.",
1416
+ metadata=metadata,
1417
+ suggested_ttl_seconds=300,
1418
+ demoted_to="proof_gated",
1419
+ )
1420
+ if not status_payload.get("subscriptionType"):
1421
+ return AuthPreflightResult(
1422
+ ok=False,
1423
+ blocker_class="account_or_billing_setup",
1424
+ blocker_summary="Claude CLI auth is present but the subscription state is missing from `claude auth status`.",
1425
+ metadata=metadata,
1426
+ suggested_ttl_seconds=300,
1427
+ demoted_to="proof_gated",
1428
+ )
1429
+ return AuthPreflightResult(ok=True, metadata=metadata)
1430
+
1431
+ if spec.executor == "opencode":
1432
+ metadata["selected_agent"] = spec.selected_agent
1433
+ metadata["selected_model"] = spec.selected_model
1434
+ metadata["selected_variant"] = spec.selected_variant
1435
+ metadata["permission_posture"] = spec.permission_posture
1436
+ if not spec.selected_agent:
1437
+ return AuthPreflightResult(
1438
+ ok=False,
1439
+ blocker_class="repeated_verification_failure",
1440
+ blocker_summary="OpenCode live launch requires an explicit agent selection, but none was recorded in launch metadata.",
1441
+ metadata=metadata,
1442
+ )
1443
+ if not spec.permission_posture or spec.permission_posture == "permissive":
1444
+ return AuthPreflightResult(
1445
+ ok=False,
1446
+ blocker_class="product_decision_missing",
1447
+ blocker_summary="OpenCode live launch detected a permissive agent posture without an explicit runner opt-in.",
1448
+ metadata=metadata,
1449
+ )
1450
+ return AuthPreflightResult(ok=True, metadata=metadata)
1451
+
1452
+ return AuthPreflightResult(ok=True, metadata=metadata)
1453
+
1454
+
1455
+ def _looks_like_capacity_exhaustion(text: str) -> bool:
1456
+ normalized = re.sub(r"[\s_-]+", ".", text.lower())
1457
+ return bool(re.search(r"capacity|exhausted|rate\.?limit|503|temporarily\.?unavailable", normalized))
1458
+
1459
+
1460
+ def _claude_status_has_quota_signal(payload: dict[str, Any]) -> bool:
1461
+ if not payload:
1462
+ return False
1463
+ return _looks_like_capacity_exhaustion(json.dumps(payload, sort_keys=True))
1464
+
1465
+
1466
+ def launch_with_spec(
1467
+ spec: LaunchSpec,
1468
+ *,
1469
+ dry_run: bool = False,
1470
+ log_path: Path | None = None,
1471
+ stream_output: bool = False,
1472
+ heartbeat_path: Path | None = None,
1473
+ heartbeat_interval_seconds: int = 30,
1474
+ quiet_warning_seconds: int = 600,
1475
+ quiet_blocker_seconds: int = 1800,
1476
+ ) -> LaunchResult:
1477
+ if not spec.available and not dry_run:
1478
+ raise ValueError("live launch requested for unavailable executor")
1479
+ if spec.executor == "claude" and spec.claude_route == "claude_channel" and not dry_run:
1480
+ return _result_with_spec(_launch_claude_channel(spec, log_path=log_path), spec)
1481
+ if spec.executor == "claude" and spec.claude_route == "claude_agent_view" and not dry_run:
1482
+ return _result_with_spec(_launch_claude_agent_view(spec, log_path=log_path), spec)
1483
+ command = _resolve_command_context(spec, log_path)
1484
+ # DFCHTELEMETRY (IF-0-DFCHTELEMETRY-1): runtime no-hidden-print guard. A primary
1485
+ # Claude route (Channel / Agent View) must never resolve to a real `claude -p`
1486
+ # invocation; the build path guarantees this structurally, so a violation is a
1487
+ # contract bug, not an operator error — fail loudly rather than silently spend
1488
+ # API/usage credit under a primary route.
1489
+ if spec.executor == "claude" and spec.claude_route in {"claude_channel", "claude_agent_view"} and command_runs_claude_print(command):
1490
+ raise RuntimeError(
1491
+ f"no-hidden-print violation: {spec.claude_route} resolved to a real claude print command"
1492
+ )
1493
+ try:
1494
+ result = launch(
1495
+ command,
1496
+ dry_run=dry_run,
1497
+ log_path=log_path,
1498
+ stdin_text=spec.delivery_payload() if spec.delivery_mode == "stdin" else None,
1499
+ stream_output=stream_output,
1500
+ heartbeat_path=heartbeat_path,
1501
+ heartbeat_interval_seconds=heartbeat_interval_seconds,
1502
+ quiet_warning_seconds=quiet_warning_seconds,
1503
+ quiet_blocker_seconds=quiet_blocker_seconds,
1504
+ timeout_seconds=spec.launch_timeout_seconds,
1505
+ cwd=spec.wrapped_cwd,
1506
+ )
1507
+ finally:
1508
+ # Clean from the RESOLVED command so the launch-time materialized codex
1509
+ # schema path (run-scoped or fallback) is removed here (#63), alongside any
1510
+ # build-time cleanup paths. Build-without-launch never reaches this.
1511
+ cleanup_evidence = _cleanup_paths(
1512
+ tuple(dict.fromkeys((*spec.cleanup_paths, *_schema_cleanup_paths(command))))
1513
+ )
1514
+ if cleanup_evidence:
1515
+ result = replace(
1516
+ result,
1517
+ cleanup_evidence={
1518
+ **(result.cleanup_evidence or {}),
1519
+ "schema_cleanup": cleanup_evidence,
1520
+ },
1521
+ )
1522
+ return _result_with_spec(result, spec)
1523
+
1524
+
1525
+ def _launch_claude_channel(spec: LaunchSpec, *, log_path: Path | None) -> LaunchResult:
1526
+ started_at = _utc_now()
1527
+ session_id = spec.claude_channel_session_id or "unknown"
1528
+ try:
1529
+ route_result = ChannelSidecarClient(
1530
+ base_url=spec.claude_sidecar_url or DEFAULT_CLAUDE_CHANNEL_SIDECAR_URL,
1531
+ session_id=session_id,
1532
+ bearer_token=os.environ.get(CLAUDE_CHANNEL_BEARER_TOKEN_ENV),
1533
+ timeout_seconds=float(spec.launch_timeout_seconds or 1800),
1534
+ ).send_and_wait(spec.prompt_bundle.render_context())
1535
+ except (ChannelSidecarClientError, ValueError) as exc:
1536
+ reason = exc.reason if isinstance(exc, ChannelSidecarClientError) else str(exc)
1537
+ route_result = ClaudeRouteResult(
1538
+ route="claude_channel",
1539
+ session_id=session_id,
1540
+ event_id="preflight",
1541
+ status="blocked",
1542
+ text=reason,
1543
+ warnings=(reason,),
1544
+ evidence_refs=({"kind": "claude_channel_preflight", "status": "blocked"},),
1545
+ )
1546
+ payload = route_result.to_json()
1547
+ output = route_result.text
1548
+ if log_path is not None:
1549
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1550
+ log_path.write_text(output + ("\n" if output else ""), encoding="utf-8")
1551
+ return LaunchResult(
1552
+ command=spec.command,
1553
+ returncode=0 if route_result.status == "done" else 1,
1554
+ output=output,
1555
+ log_path=str(log_path) if log_path else None,
1556
+ terminal_path=str(log_path.parent / "terminal-summary.json") if log_path else None,
1557
+ started_at=started_at,
1558
+ finished_at=_utc_now(),
1559
+ claude_route="claude_channel",
1560
+ claude_route_result=payload,
1561
+ )
1562
+
1563
+
1564
+ def _launch_claude_agent_view(spec: LaunchSpec, *, log_path: Path | None) -> LaunchResult:
1565
+ started_at = _utc_now()
1566
+ cwd = Path(spec.wrapped_cwd or os.getcwd())
1567
+ adapter = ClaudeAgentViewAdapter()
1568
+ lifecycle = adapter.launch_background(
1569
+ spec.prompt_bundle.render_context(),
1570
+ cwd=cwd,
1571
+ model=spec.selected_model,
1572
+ effort=spec.selected_effort,
1573
+ permission=_command_option(spec.command, "--permission-mode"),
1574
+ )
1575
+ route_status = _agent_view_route_status(lifecycle.state)
1576
+ route_text = _agent_view_route_text(lifecycle)
1577
+ route_result = ClaudeRouteResult(
1578
+ route="claude_agent_view",
1579
+ session_id=lifecycle.session_id,
1580
+ event_id=f"agent-view:{lifecycle.session_id}",
1581
+ status=route_status,
1582
+ text=route_text,
1583
+ artifacts=({"kind": "claude_agent_view_lifecycle", **lifecycle.to_json()},),
1584
+ auth_posture=lifecycle.auth_posture,
1585
+ billing_posture=lifecycle.billing_posture,
1586
+ trust_state=workspace_trust_state(cwd),
1587
+ permission_state={"pending": 0},
1588
+ warnings=tuple([lifecycle.blocker.summary] if lifecycle.blocker else []),
1589
+ evidence_refs=tuple(_agent_view_evidence_refs(lifecycle)),
1590
+ )
1591
+ output = route_result.text
1592
+ if log_path is not None:
1593
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1594
+ log_path.write_text(output + ("\n" if output else ""), encoding="utf-8")
1595
+ return LaunchResult(
1596
+ command=spec.command,
1597
+ returncode=0 if route_status in {"working", "done"} else 1,
1598
+ output=output,
1599
+ log_path=str(log_path) if log_path else None,
1600
+ terminal_path=str(log_path.parent / "terminal-summary.json") if log_path else None,
1601
+ started_at=started_at,
1602
+ finished_at=_utc_now(),
1603
+ claude_route="claude_agent_view",
1604
+ claude_route_result=route_result.to_json(),
1605
+ )
1606
+
1607
+
1608
+ def _agent_view_route_status(lifecycle_state: str) -> str:
1609
+ if lifecycle_state == "done":
1610
+ return "done"
1611
+ if lifecycle_state == "running":
1612
+ return "working"
1613
+ if lifecycle_state in {"blocked", "stopped", "failed"}:
1614
+ return "blocked"
1615
+ return "stale"
1616
+
1617
+
1618
+ def _agent_view_route_text(lifecycle: AgentViewLifecycleResult) -> str:
1619
+ if lifecycle.blocker:
1620
+ return lifecycle.blocker.summary
1621
+ return f"agent view session {lifecycle.session_id} is {lifecycle.state}"
1622
+
1623
+
1624
+ def _agent_view_evidence_refs(lifecycle: AgentViewLifecycleResult) -> list[dict[str, Any]]:
1625
+ refs = [{"kind": "claude_agent_view_lifecycle", "session_id": lifecycle.session_id}]
1626
+ if lifecycle.logs_ref:
1627
+ refs.append({"kind": "claude_agent_view_logs", "ref": lifecycle.logs_ref})
1628
+ refs.append({"kind": "claude_agent_view_attach", "ref": f"claude attach {lifecycle.session_id}"})
1629
+ refs.append({"kind": "claude_agent_view_stop", "ref": f"claude stop {lifecycle.session_id}"})
1630
+ return refs
1631
+
1632
+
1633
+ def _command_option(command: list[str], option: str) -> str | None:
1634
+ try:
1635
+ index = command.index(option)
1636
+ except ValueError:
1637
+ return None
1638
+ if index + 1 >= len(command):
1639
+ return None
1640
+ return command[index + 1]
1641
+
1642
+
1643
+ def _cleanup_paths(paths: tuple[str, ...]) -> dict[str, Any] | None:
1644
+ if not paths:
1645
+ return None
1646
+ removed: list[str] = []
1647
+ missing: list[str] = []
1648
+ errors: list[dict[str, str]] = []
1649
+ for raw_path in paths:
1650
+ path = Path(raw_path)
1651
+ try:
1652
+ if path.exists():
1653
+ path.unlink()
1654
+ removed.append(str(path))
1655
+ else:
1656
+ missing.append(str(path))
1657
+ except OSError as exc:
1658
+ errors.append({"path": str(path), "error": exc.__class__.__name__})
1659
+ return {"removed": removed, "missing": missing, "errors": errors}
1660
+
1661
+
1662
+ def extract_executor_output_text(result: LaunchResult, spec: LaunchSpec) -> str:
1663
+ if spec.executor == "codex":
1664
+ text = _extract_codex_stream_json_text(result.output)
1665
+ return text if text else result.output
1666
+ if spec.executor == "claude":
1667
+ stream_text = _extract_claude_stream_json_text(result.output)
1668
+ if stream_text:
1669
+ return _normalize_claude_output_text(stream_text)
1670
+ payload = _find_json_object(result.output)
1671
+ if not payload:
1672
+ return result.output
1673
+ text = payload.get("result")
1674
+ return _normalize_claude_output_text(text) if isinstance(text, str) else result.output
1675
+ if spec.executor == "gemini":
1676
+ text = _extract_gemini_output_text(result.output)
1677
+ return text if text else result.output
1678
+ if spec.executor == "opencode":
1679
+ text = _extract_opencode_output_text(result.output)
1680
+ return text if text else result.output
1681
+ if spec.executor == "pi":
1682
+ text = _extract_pi_output_text(result.output)
1683
+ return text if text else result.output
1684
+ return result.output
1685
+
1686
+
1687
+ def _extract_gemini_output_text(raw: str) -> str:
1688
+ stream_text = _extract_gemini_stream_json_text(raw)
1689
+ if stream_text:
1690
+ return stream_text
1691
+ payload = _find_json_object_with_any_key(raw, ("response", "result"))
1692
+ if not payload:
1693
+ payload = _find_json_object(raw)
1694
+ if not payload:
1695
+ return raw
1696
+ text = payload.get("response")
1697
+ if isinstance(text, str):
1698
+ return text
1699
+ text = payload.get("result")
1700
+ return text if isinstance(text, str) else raw
1701
+
1702
+
1703
+ def _extract_opencode_output_text(raw: str) -> str:
1704
+ stream_text = _extract_opencode_json_lines(raw)
1705
+ if stream_text:
1706
+ return stream_text
1707
+ payload = _find_json_object(raw)
1708
+ if not payload:
1709
+ return raw
1710
+ for key in ("response", "result", "content", "message"):
1711
+ value = payload.get(key)
1712
+ if isinstance(value, str):
1713
+ return value
1714
+ if isinstance(value, dict):
1715
+ content = value.get("content")
1716
+ if isinstance(content, str):
1717
+ return content
1718
+ return raw
1719
+
1720
+
1721
+ def _extract_pi_output_text(raw: str) -> str:
1722
+ payload = _find_json_object(raw)
1723
+ if not payload:
1724
+ return raw
1725
+ for key in ("automation", "response", "result", "content", "message"):
1726
+ value = payload.get(key)
1727
+ if isinstance(value, str):
1728
+ return value
1729
+ if isinstance(value, dict):
1730
+ text = value.get("text") or value.get("content")
1731
+ if isinstance(text, str):
1732
+ return text
1733
+ return raw
1734
+
1735
+
1736
+ def launch(
1737
+ command: list[str],
1738
+ dry_run: bool = False,
1739
+ log_path: Path | None = None,
1740
+ stdin_text: str | None = None,
1741
+ stream_output: bool = False,
1742
+ heartbeat_path: Path | None = None,
1743
+ heartbeat_interval_seconds: int = 30,
1744
+ quiet_warning_seconds: int = 600,
1745
+ quiet_blocker_seconds: int = 1800,
1746
+ timeout_seconds: int | None = None,
1747
+ cwd: str | Path | None = None,
1748
+ ) -> LaunchResult:
1749
+ if log_path is not None and heartbeat_path is None:
1750
+ heartbeat_path = heartbeat_path_for_log(log_path)
1751
+ if dry_run:
1752
+ if log_path is not None:
1753
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1754
+ log_path.write_text("dry run: command not executed\n", encoding="utf-8")
1755
+ heartbeat_summary = None
1756
+ if heartbeat_path is not None:
1757
+ heartbeat_summary = run_heartbeat_summary(
1758
+ log_path=log_path,
1759
+ heartbeat_path=heartbeat_path,
1760
+ heartbeat_interval_seconds=heartbeat_interval_seconds,
1761
+ quiet_warning_seconds=quiet_warning_seconds,
1762
+ quiet_blocker_seconds=quiet_blocker_seconds,
1763
+ command=command,
1764
+ returncode=None,
1765
+ )
1766
+ write_run_heartbeat(heartbeat_path, heartbeat_summary)
1767
+ return LaunchResult(
1768
+ command=command,
1769
+ returncode=None,
1770
+ dry_run=True,
1771
+ log_path=str(log_path) if log_path else None,
1772
+ heartbeat_path=str(heartbeat_path) if heartbeat_path else None,
1773
+ terminal_path=str(log_path.parent / "terminal-summary.json") if log_path else None,
1774
+ heartbeat_summary=heartbeat_summary,
1775
+ started_at=_utc_now(),
1776
+ finished_at=_utc_now(),
1777
+ )
1778
+ if log_path is None:
1779
+ run_kwargs: dict[str, Any] = {
1780
+ "text": True,
1781
+ "capture_output": True,
1782
+ "check": False,
1783
+ "cwd": str(cwd) if cwd else None,
1784
+ }
1785
+ if stdin_text is None:
1786
+ run_kwargs["stdin"] = subprocess.DEVNULL
1787
+ else:
1788
+ run_kwargs["input"] = stdin_text
1789
+ completed = subprocess.run(command, **run_kwargs)
1790
+ return LaunchResult(command=command, returncode=completed.returncode, output=completed.stdout + completed.stderr)
1791
+
1792
+ log_path.parent.mkdir(parents=True, exist_ok=True)
1793
+ output_parts: list[str] = []
1794
+ line_queue: Queue[str | None] = Queue()
1795
+ started_at = _utc_now()
1796
+ started_monotonic = time.monotonic()
1797
+
1798
+ with log_path.open("w", encoding="utf-8") as log:
1799
+ process = subprocess.Popen(
1800
+ command,
1801
+ text=True,
1802
+ stdin=subprocess.PIPE if stdin_text is not None else subprocess.DEVNULL,
1803
+ stdout=subprocess.PIPE,
1804
+ stderr=subprocess.STDOUT,
1805
+ start_new_session=True,
1806
+ cwd=str(cwd) if cwd else None,
1807
+ )
1808
+ process_group_id = _process_group_id(process.pid)
1809
+ assert process.stdout is not None
1810
+ if stdin_text is not None and process.stdin is not None:
1811
+ process.stdin.write(stdin_text)
1812
+ process.stdin.close()
1813
+ reader = threading.Thread(target=_read_lines, args=(process.stdout, line_queue), daemon=True)
1814
+ reader.start()
1815
+ last_heartbeat = 0.0
1816
+ timed_out = False
1817
+ interrupted = False
1818
+ stalled = False
1819
+ cleanup_evidence: dict[str, Any] | None = None
1820
+ last_heartbeat_summary: dict[str, Any] | None = None
1821
+ while True:
1822
+ try:
1823
+ try:
1824
+ line = line_queue.get(timeout=1)
1825
+ except Empty:
1826
+ line = None
1827
+ if line:
1828
+ output_parts.append(line)
1829
+ log.write(line)
1830
+ log.flush()
1831
+ if stream_output:
1832
+ print(line, end="", flush=True)
1833
+ now = time.monotonic()
1834
+ if heartbeat_path is not None and now - last_heartbeat >= heartbeat_interval_seconds:
1835
+ heartbeat = run_heartbeat_summary(
1836
+ log_path=log_path,
1837
+ heartbeat_path=heartbeat_path,
1838
+ pid=process.pid,
1839
+ started_monotonic=started_monotonic,
1840
+ started_at=started_at,
1841
+ heartbeat_interval_seconds=heartbeat_interval_seconds,
1842
+ quiet_warning_seconds=quiet_warning_seconds,
1843
+ quiet_blocker_seconds=quiet_blocker_seconds,
1844
+ command=command,
1845
+ returncode=process.poll(),
1846
+ process_group_id=process_group_id,
1847
+ )
1848
+ last_heartbeat_summary = heartbeat
1849
+ write_run_heartbeat(heartbeat_path, heartbeat)
1850
+ if stream_output and heartbeat.get("quiet_level") != "active":
1851
+ print(
1852
+ "heartbeat:",
1853
+ f"pid={heartbeat.get('pid')}",
1854
+ f"elapsed={heartbeat.get('elapsed_seconds')}s",
1855
+ f"quiet={heartbeat.get('seconds_since_log_update')}s",
1856
+ f"level={heartbeat.get('quiet_level')}",
1857
+ flush=True,
1858
+ )
1859
+ last_heartbeat = now
1860
+ if heartbeat.get("stalled_suspect") and process.poll() is None:
1861
+ stalled = True
1862
+ salvage = _salvage_snapshot(
1863
+ command=command,
1864
+ cwd=cwd,
1865
+ log_path=log_path,
1866
+ heartbeat=heartbeat,
1867
+ process=process,
1868
+ process_group_id=process_group_id,
1869
+ reason="stalled",
1870
+ )
1871
+ cleanup_evidence = _cleanup_process_group(process, process_group_id, reason="stalled")
1872
+ cleanup_evidence["salvage_snapshot"] = salvage
1873
+ if timeout_seconds is not None and now - started_monotonic >= timeout_seconds and process.poll() is None:
1874
+ timed_out = True
1875
+ salvage = _salvage_snapshot(
1876
+ command=command,
1877
+ cwd=cwd,
1878
+ log_path=log_path,
1879
+ heartbeat=last_heartbeat_summary,
1880
+ process=process,
1881
+ process_group_id=process_group_id,
1882
+ reason="timeout",
1883
+ )
1884
+ cleanup_evidence = _cleanup_process_group(process, process_group_id, reason="timeout")
1885
+ cleanup_evidence["salvage_snapshot"] = salvage
1886
+ if process.poll() is not None:
1887
+ while True:
1888
+ try:
1889
+ line = line_queue.get_nowait()
1890
+ except Empty:
1891
+ break
1892
+ if line:
1893
+ output_parts.append(line)
1894
+ log.write(line)
1895
+ log.flush()
1896
+ if stream_output:
1897
+ print(line, end="", flush=True)
1898
+ break
1899
+ except KeyboardInterrupt:
1900
+ interrupted = True
1901
+ cleanup_evidence = _cleanup_process_group(process, process_group_id, reason="interrupt")
1902
+ break
1903
+ returncode = process.wait()
1904
+ reader.join(timeout=1)
1905
+ try:
1906
+ process.stdout.close()
1907
+ except OSError:
1908
+ pass
1909
+ heartbeat_summary = None
1910
+ if heartbeat_path is not None:
1911
+ heartbeat_summary = run_heartbeat_summary(
1912
+ log_path=log_path,
1913
+ heartbeat_path=heartbeat_path,
1914
+ pid=process.pid,
1915
+ started_monotonic=started_monotonic,
1916
+ started_at=started_at,
1917
+ heartbeat_interval_seconds=heartbeat_interval_seconds,
1918
+ quiet_warning_seconds=quiet_warning_seconds,
1919
+ quiet_blocker_seconds=quiet_blocker_seconds,
1920
+ command=command,
1921
+ returncode=returncode,
1922
+ process_group_id=process_group_id,
1923
+ )
1924
+ write_run_heartbeat(heartbeat_path, heartbeat_summary)
1925
+ return LaunchResult(
1926
+ command=command,
1927
+ returncode=returncode,
1928
+ output="".join(output_parts),
1929
+ log_path=str(log_path),
1930
+ heartbeat_path=str(heartbeat_path) if heartbeat_path else None,
1931
+ terminal_path=str(log_path.parent / "terminal-summary.json"),
1932
+ heartbeat_summary=heartbeat_summary,
1933
+ process_pid=process.pid,
1934
+ process_group_id=process_group_id,
1935
+ started_at=started_at,
1936
+ finished_at=_utc_now(),
1937
+ timed_out=timed_out,
1938
+ interrupted=interrupted,
1939
+ stalled=stalled,
1940
+ cleanup_evidence=cleanup_evidence,
1941
+ )
1942
+
1943
+
1944
+ def _stub_command(request: LaunchRequest, reason: str) -> list[str]:
1945
+ phase = request.phase or "UNKNOWN"
1946
+ return [
1947
+ request.executor,
1948
+ "--phase-loop-stub",
1949
+ request.action,
1950
+ phase,
1951
+ request.prompt_bundle.workflow_command,
1952
+ reason,
1953
+ ]
1954
+
1955
+
1956
+ def _claude_uses_context_file(prompt_bundle: PromptBundle) -> bool:
1957
+ return prompt_bundle.injection_mode == "inline" and prompt_bundle.fallback_mode == "context_file" and bool(prompt_bundle.expected_skill_pack)
1958
+
1959
+
1960
+ def _claude_permission_mode(action: str, bypass_approvals: bool) -> str:
1961
+ if bypass_approvals:
1962
+ return "bypassPermissions"
1963
+ if action == "review":
1964
+ return "plan"
1965
+ if action in {"execute", "repair"}:
1966
+ return "bypassPermissions"
1967
+ return "acceptEdits"
1968
+
1969
+
1970
+ def _parse_json_object(raw: str | None) -> dict[str, Any] | None:
1971
+ if not raw:
1972
+ return None
1973
+ try:
1974
+ data = json.loads(raw)
1975
+ except json.JSONDecodeError:
1976
+ return None
1977
+ return data if isinstance(data, dict) else None
1978
+
1979
+
1980
+ def _looks_like_help_surface(text: str) -> bool:
1981
+ lowered = text.lower()
1982
+ return "usage:" in lowered or "options:" in lowered or "--help" in lowered
1983
+
1984
+
1985
+ def _find_json_object(raw: str | None) -> dict[str, Any] | None:
1986
+ if not raw:
1987
+ return None
1988
+ decoder = json.JSONDecoder()
1989
+ for index, char in enumerate(raw):
1990
+ if char != "{":
1991
+ continue
1992
+ try:
1993
+ data, _end = decoder.raw_decode(raw[index:])
1994
+ except json.JSONDecodeError:
1995
+ continue
1996
+ if isinstance(data, dict):
1997
+ return data
1998
+ return None
1999
+
2000
+
2001
+ def _find_json_object_with_any_key(raw: str | None, keys: tuple[str, ...]) -> dict[str, Any] | None:
2002
+ if not raw:
2003
+ return None
2004
+ decoder = json.JSONDecoder()
2005
+ for index, char in enumerate(raw):
2006
+ if char != "{":
2007
+ continue
2008
+ try:
2009
+ data, _end = decoder.raw_decode(raw[index:])
2010
+ except json.JSONDecodeError:
2011
+ continue
2012
+ if isinstance(data, dict) and any(key in data for key in keys):
2013
+ return data
2014
+ return None
2015
+
2016
+
2017
+ def _extract_codex_stream_json_text(raw: str) -> str:
2018
+ agent_messages: list[str] = []
2019
+ for line in raw.splitlines():
2020
+ line = line.strip()
2021
+ if not line.startswith("{"):
2022
+ continue
2023
+ payload = _parse_json_object(line)
2024
+ if not payload:
2025
+ continue
2026
+ item = payload.get("item")
2027
+ if isinstance(item, dict) and item.get("type") == "agent_message":
2028
+ text = item.get("text")
2029
+ if isinstance(text, str) and text.strip():
2030
+ agent_messages.append(text.strip())
2031
+ continue
2032
+ if payload.get("type") in {"agent_message", "message"}:
2033
+ text = payload.get("text") or payload.get("content")
2034
+ if isinstance(text, str) and text.strip():
2035
+ agent_messages.append(text.strip())
2036
+ return "\n\n".join(agent_messages).strip()
2037
+
2038
+
2039
+ def _extract_gemini_stream_json_text(raw: str) -> str:
2040
+ assistant_chunks: list[str] = []
2041
+ assistant_messages: list[str] = []
2042
+ for line in raw.splitlines():
2043
+ line = line.strip()
2044
+ if not line.startswith("{"):
2045
+ continue
2046
+ payload = _parse_json_object(line)
2047
+ if not payload or payload.get("type") != "message" or payload.get("role") != "assistant":
2048
+ continue
2049
+ content = payload.get("content")
2050
+ if not isinstance(content, str):
2051
+ continue
2052
+ if payload.get("delta"):
2053
+ assistant_chunks.append(content)
2054
+ else:
2055
+ assistant_messages.append(content)
2056
+ if assistant_chunks:
2057
+ return "".join(assistant_chunks).strip()
2058
+ if assistant_messages:
2059
+ return assistant_messages[-1].strip()
2060
+ return ""
2061
+
2062
+
2063
+ def _extract_claude_stream_json_text(raw: str) -> str:
2064
+ result_text = ""
2065
+ assistant_chunks: list[str] = []
2066
+ assistant_messages: list[str] = []
2067
+ for line in raw.splitlines():
2068
+ line = line.strip()
2069
+ if not line.startswith("{"):
2070
+ continue
2071
+ payload = _parse_json_object(line)
2072
+ if not payload:
2073
+ continue
2074
+ if payload.get("type") == "result":
2075
+ text = payload.get("result")
2076
+ if isinstance(text, str):
2077
+ result_text = text.strip()
2078
+ continue
2079
+ if payload.get("type") != "assistant":
2080
+ continue
2081
+ message = payload.get("message")
2082
+ if isinstance(message, dict):
2083
+ content = message.get("content")
2084
+ if isinstance(content, str):
2085
+ assistant_messages.append(content)
2086
+ elif isinstance(content, list):
2087
+ message_text = "".join(
2088
+ item.get("text", "") for item in content if isinstance(item, dict) and isinstance(item.get("text"), str)
2089
+ )
2090
+ if message_text:
2091
+ assistant_messages.append(message_text)
2092
+ content = payload.get("content")
2093
+ if isinstance(content, str):
2094
+ if payload.get("delta"):
2095
+ assistant_chunks.append(content)
2096
+ else:
2097
+ assistant_messages.append(content)
2098
+ if result_text:
2099
+ return result_text
2100
+ if assistant_chunks:
2101
+ return "".join(assistant_chunks).strip()
2102
+ if assistant_messages:
2103
+ return assistant_messages[-1].strip()
2104
+ return ""
2105
+
2106
+
2107
+ def _extract_opencode_json_lines(raw: str) -> str:
2108
+ assistant_chunks: list[str] = []
2109
+ assistant_messages: list[str] = []
2110
+ for line in raw.splitlines():
2111
+ line = line.strip()
2112
+ if not line.startswith("{"):
2113
+ continue
2114
+ payload = _parse_json_object(line)
2115
+ if not payload:
2116
+ continue
2117
+ content = payload.get("content")
2118
+ if isinstance(content, str) and payload.get("role") == "assistant":
2119
+ if payload.get("delta"):
2120
+ assistant_chunks.append(content)
2121
+ else:
2122
+ assistant_messages.append(content)
2123
+ continue
2124
+ message = payload.get("message")
2125
+ if isinstance(message, dict):
2126
+ content = message.get("content")
2127
+ if isinstance(content, str):
2128
+ assistant_messages.append(content)
2129
+ if assistant_chunks:
2130
+ return "".join(assistant_chunks).strip()
2131
+ if assistant_messages:
2132
+ return assistant_messages[-1].strip()
2133
+ return ""
2134
+
2135
+
2136
+ def _materialize_codex_schema(schema: dict[str, Any], log_path: Path | None) -> str:
2137
+ """Write the Codex closeout schema at LAUNCH time (#63).
2138
+
2139
+ Mirrors `_phase_loop_context_path`: when a run-scoped `log_path` exists, write
2140
+ next to the run log (NOT global /tmp) so a SIGKILL leak stays inside the
2141
+ reclaimed run/worktree dir. Without a log dir, fall back to a temp file — but
2142
+ that path is only reached inside `launch_with_spec`'s try/finally, which cleans
2143
+ it, so build-without-launch never creates anything.
2144
+ """
2145
+
2146
+ if log_path is not None:
2147
+ target = log_path.parent / "codex-output-schema.json"
2148
+ target.parent.mkdir(parents=True, exist_ok=True)
2149
+ target.write_text(json.dumps(schema, sort_keys=True) + "\n", encoding="utf-8")
2150
+ return str(target)
2151
+ return str(_write_temp_schema(schema))
2152
+
2153
+
2154
+ def _drop_arg_pair(command: list[str], flag: str) -> list[str]:
2155
+ resolved: list[str] = []
2156
+ skip = False
2157
+ for part in command:
2158
+ if skip:
2159
+ skip = False
2160
+ continue
2161
+ if part == flag:
2162
+ skip = True
2163
+ continue
2164
+ resolved.append(part)
2165
+ return resolved
2166
+
2167
+
2168
+ def _resolve_command_context(spec: LaunchSpec, log_path: Path | None) -> list[str]:
2169
+ if spec.executor == "codex" and CODEX_OUTPUT_SCHEMA_PLACEHOLDER in " ".join(spec.command):
2170
+ if spec.codex_output_schema is None:
2171
+ # Inconsistent (placeholder without a schema): never hand codex a
2172
+ # literal placeholder — drop the --output-schema pair.
2173
+ return _drop_arg_pair(spec.command, "--output-schema")
2174
+ schema_path = _materialize_codex_schema(spec.codex_output_schema, log_path)
2175
+ return [part.replace(CODEX_OUTPUT_SCHEMA_PLACEHOLDER, schema_path) for part in spec.command]
2176
+ if spec.executor == "claude" and any(
2177
+ placeholder in " ".join(spec.command)
2178
+ for placeholder in (
2179
+ CLAUDE_CONTEXT_PLACEHOLDER,
2180
+ CLAUDE_PLUGIN_DIR_PLACEHOLDER,
2181
+ CLAUDE_SETTINGS_PLACEHOLDER,
2182
+ CLAUDE_AGENTS_PLACEHOLDER,
2183
+ CLAUDE_MCP_CONFIG_PLACEHOLDER,
2184
+ )
2185
+ ):
2186
+ context_path = _phase_loop_context_path(log_path, spec.prompt_bundle.render_context())
2187
+ if context_path is None:
2188
+ return spec.command
2189
+ bundle_paths = _claude_bundle_paths(spec, log_path)
2190
+ if bundle_paths is None:
2191
+ return spec.command
2192
+ resolved: list[str] = []
2193
+ for part in spec.command:
2194
+ resolved.append(
2195
+ part.replace(
2196
+ CLAUDE_CONTEXT_PLACEHOLDER,
2197
+ _claude_context_prompt(context_path, spec.claude_execution_mode or "solo"),
2198
+ )
2199
+ .replace(CLAUDE_PLUGIN_DIR_PLACEHOLDER, bundle_paths["plugin_dir"])
2200
+ .replace(CLAUDE_SETTINGS_PLACEHOLDER, bundle_paths["settings_path"])
2201
+ .replace(CLAUDE_AGENTS_PLACEHOLDER, bundle_paths["agents_json"])
2202
+ .replace(CLAUDE_MCP_CONFIG_PLACEHOLDER, bundle_paths["mcp_config_path"])
2203
+ )
2204
+ return resolved
2205
+ if spec.executor == "gemini" and GEMINI_CONTEXT_PLACEHOLDER in " ".join(spec.command):
2206
+ repo_path = _command_repo_path(spec.command, "--add-dir")
2207
+ if repo_path is None:
2208
+ return spec.command
2209
+ context_path = _gemini_workspace_context_path(repo_path, log_path, spec.prompt_bundle.render_context())
2210
+ if context_path is None:
2211
+ return spec.command
2212
+ command = [part.replace(GEMINI_CONTEXT_PLACEHOLDER, context_path) for part in spec.command]
2213
+ mirror_root = str(Path(context_path).parent)
2214
+ if mirror_root != str(repo_path) and mirror_root not in command:
2215
+ # agy --add-dir is repeatable (one dir each), so add the context mirror dir
2216
+ # as its own flag immediately before the positional `-p` prompt (always
2217
+ # present in a built gemini/agy command) rather than comma-appending.
2218
+ insert_at = command.index("-p")
2219
+ command[insert_at:insert_at] = ["--add-dir", mirror_root]
2220
+ return command
2221
+ if spec.executor == "opencode" and OPENCODE_CONTEXT_PLACEHOLDER in " ".join(spec.command):
2222
+ context_path = _phase_loop_context_path(log_path, spec.prompt_bundle.render_context())
2223
+ if context_path is None:
2224
+ return spec.command
2225
+ return [part.replace(OPENCODE_CONTEXT_PLACEHOLDER, context_path) for part in spec.command]
2226
+ if spec.executor == "pi" and PI_CONTEXT_PLACEHOLDER in " ".join(spec.command):
2227
+ context_path = _phase_loop_context_path(log_path, spec.prompt_bundle.render_context())
2228
+ if context_path is None:
2229
+ return spec.command
2230
+ return [part.replace(PI_CONTEXT_PLACEHOLDER, context_path) for part in spec.command]
2231
+ if spec.executor == "command" and COMMAND_CONTEXT_PLACEHOLDER in " ".join(spec.command):
2232
+ context_path = _phase_loop_context_path(log_path, spec.prompt_bundle.render_context())
2233
+ if context_path is None:
2234
+ return spec.command
2235
+ return [part.replace(COMMAND_CONTEXT_PLACEHOLDER, context_path) for part in spec.command]
2236
+ return spec.command
2237
+
2238
+
2239
+ def _command_repo_path(command: list[str], flag: str) -> Path | None:
2240
+ try:
2241
+ include_idx = command.index(flag) + 1
2242
+ except ValueError:
2243
+ return None
2244
+ raw = command[include_idx].split(",", 1)[0].strip()
2245
+ return Path(raw) if raw else None
2246
+
2247
+
2248
+ def _gemini_workspace_context_path(repo: Path, log_path: Path | None, context_text: str) -> str | None:
2249
+ if log_path is None:
2250
+ return None
2251
+ run_slug = log_path.parent.name
2252
+ target = Path.home() / ".gemini" / "tmp" / repo.name / "phase-loop" / run_slug / "context.md"
2253
+ target.parent.mkdir(parents=True, exist_ok=True)
2254
+ target.write_text(context_text.rstrip() + "\n", encoding="utf-8")
2255
+ return str(target)
2256
+
2257
+
2258
+ def _phase_loop_context_path(log_path: Path | None, context_text: str) -> str | None:
2259
+ if log_path is None:
2260
+ return None
2261
+ target = log_path.parent / "context.md"
2262
+ target.parent.mkdir(parents=True, exist_ok=True)
2263
+ target.write_text(context_text.rstrip() + "\n", encoding="utf-8")
2264
+ return str(target)
2265
+
2266
+
2267
+ def _claude_bundle_paths(spec: LaunchSpec, log_path: Path | None) -> dict[str, str] | None:
2268
+ if log_path is None or spec.wrapped_cwd is None:
2269
+ return None
2270
+ run_root = log_path.parent
2271
+ inventory = materialize_claude_plugin_bundle(
2272
+ repo=Path(spec.wrapped_cwd),
2273
+ run_root=run_root,
2274
+ prompt_bundle=spec.prompt_bundle,
2275
+ )
2276
+ if not inventory:
2277
+ return None
2278
+ agents_path = Path(str(inventory["agents_path"]))
2279
+ agents_json = json.dumps(json.loads(agents_path.read_text(encoding="utf-8")), separators=(",", ":"))
2280
+ return {
2281
+ "plugin_dir": str(inventory["plugin_dir"]),
2282
+ "settings_path": str(inventory["settings_path"]),
2283
+ "agents_json": agents_json,
2284
+ "mcp_config_path": str(inventory["mcp_config_path"]),
2285
+ }
2286
+
2287
+
2288
+ def _claude_context_prompt(context_path: str, execution_mode: str = "solo") -> str:
2289
+ if execution_mode == "solo":
2290
+ collaboration_guidance = (
2291
+ "ignore any embedded workflow step that asks for delegation, teams, advisor review, "
2292
+ "user questions, worktree entry, plan-mode approval, or slash-command reentry such as "
2293
+ "`/clear`, `/claude-plan-phase`, or `/claude-execute-phase`. Work solo inside the provided workspace."
2294
+ )
2295
+ else:
2296
+ collaboration_guidance = (
2297
+ f"the selected Claude execution mode is `{execution_mode}`. Native subagents, task lists, "
2298
+ "teammate messaging, and team/worktree coordination may be used only within the command-line "
2299
+ "tool allowlist and the generated TEAMGOV/task-ledger artifacts. Cross-harness delegation must "
2300
+ "still be reduced into the typed phase-loop delegation request contract."
2301
+ )
2302
+ return (
2303
+ f"Read the workflow command at the top of `{context_path}` first, then follow that file exactly. "
2304
+ f"This is a non-interactive phase-loop adapter launch: {collaboration_guidance} "
2305
+ "Write only repo-local phase artifacts or phase-owned files, never "
2306
+ "`~/.claude/**` reflection/handoff files or `.phase-loop/` or legacy `.codex/phase-loop/` runner artifacts. Run the applicable checks "
2307
+ "and finish by printing exactly one shared automation closeout that uses these keys: "
2308
+ "`status`, `next_skill`, `next_command`, `human_required`, `blocker_class`, `blocker_summary`, "
2309
+ "`required_human_inputs`, and `verification_status`."
2310
+ )
2311
+
2312
+
2313
+ def _normalize_claude_output_text(text: str) -> str:
2314
+ if not text or _has_canonical_shared_automation_fields(text):
2315
+ return text
2316
+ legacy_fields = _legacy_claude_automation_fields(text)
2317
+ if not legacy_fields or not _should_normalize_legacy_claude_closeout(legacy_fields):
2318
+ return text
2319
+ normalized = _legacy_claude_shared_automation_lines(legacy_fields)
2320
+ if not normalized:
2321
+ return text
2322
+ return "Normalized shared automation closeout:\n" + "\n".join(normalized) + "\n\nOriginal child output:\n" + text.rstrip() + "\n"
2323
+
2324
+
2325
+ def _has_canonical_shared_automation_fields(text: str) -> bool:
2326
+ text = _last_automation_scope(text)
2327
+ required = (
2328
+ "status",
2329
+ "next_skill",
2330
+ "next_command",
2331
+ "human_required",
2332
+ "blocker_class",
2333
+ "blocker_summary",
2334
+ "verification_status",
2335
+ )
2336
+ if not all(re.search(rf"^\s*{re.escape(key)}:\s*.+$", text, re.MULTILINE) for key in required):
2337
+ return False
2338
+ status_match = re.search(r"^\s*status:\s*(.+?)\s*$", text, re.MULTILINE)
2339
+ verification_match = re.search(r"^\s*verification_status:\s*(.+?)\s*$", text, re.MULTILINE)
2340
+ if status_match is None or verification_match is None:
2341
+ return False
2342
+ status = status_match.group(1).strip().strip("'\"").lower()
2343
+ verification_status = verification_match.group(1).strip().strip("'\"").lower()
2344
+ if status == "executed" and verification_status == "passed" and re.search(
2345
+ r"^\s*(?:next_skill|next_command):\s*.*phase-loop.*$", text, re.MULTILINE
2346
+ ):
2347
+ return False
2348
+ return status in {"unplanned", "planned", "executing", "executed", "awaiting_phase_closeout", "complete", "blocked", "unknown"} and verification_status in {"not_run", "passed", "failed", "blocked"}
2349
+
2350
+
2351
+ def _legacy_claude_automation_fields(text: str) -> dict[str, str]:
2352
+ text = _last_automation_scope(text)
2353
+ fields: dict[str, str] = {}
2354
+ for key in ("status", "skill", "next_skill", "phase", "phase_id", "next_phase", "next_command", "verification_status"):
2355
+ match = re.search(rf"^\s*{re.escape(key)}:\s*(.+?)\s*$", text, re.MULTILINE)
2356
+ if match:
2357
+ fields[key] = match.group(1).strip().strip("'\"")
2358
+ return fields
2359
+
2360
+
2361
+ def _last_automation_scope(text: str) -> str:
2362
+ matches = list(re.finditer(r"(?m)^(?P<indent>[ \t]*)automation:\s*$", text))
2363
+ if not matches:
2364
+ return text
2365
+ match = matches[-1]
2366
+ base_indent = len(match.group("indent"))
2367
+ selected: list[str] = []
2368
+ for index, line in enumerate(text[match.start() :].splitlines()):
2369
+ if index == 0 or not line.strip():
2370
+ selected.append(line)
2371
+ continue
2372
+ indent = len(line) - len(line.lstrip())
2373
+ if indent > base_indent:
2374
+ selected.append(line)
2375
+ continue
2376
+ break
2377
+ return "\n".join(selected)
2378
+
2379
+
2380
+ def _should_normalize_legacy_claude_closeout(fields: dict[str, str]) -> bool:
2381
+ if fields.get("skill") or fields.get("next_phase"):
2382
+ return True
2383
+ raw_status = fields.get("status", "").strip().lower()
2384
+ if raw_status in {"planned", "executed", "complete"}:
2385
+ return bool(fields.get("verification_status") or fields.get("next_command"))
2386
+ if raw_status in {
2387
+ "success",
2388
+ "ok",
2389
+ "pass",
2390
+ "passed",
2391
+ "done",
2392
+ "completed",
2393
+ "cleared",
2394
+ "ready_for_closeout",
2395
+ "repaired",
2396
+ "error",
2397
+ "failure",
2398
+ "fail",
2399
+ }:
2400
+ return bool(fields.get("verification_status") or fields.get("next_command"))
2401
+ if raw_status.endswith("_complete"):
2402
+ return bool(fields.get("verification_status") or fields.get("next_command"))
2403
+ return False
2404
+
2405
+
2406
+ def _legacy_claude_shared_automation_lines(fields: dict[str, str]) -> list[str]:
2407
+ skill = fields.get("skill", "").strip() or fields.get("next_skill", "").strip()
2408
+ next_phase = fields.get("next_phase", "").strip()
2409
+ next_command = fields.get("next_command", "none").strip() or "none"
2410
+ verification_status = _canonical_verification_status(fields.get("verification_status", "not_run"))
2411
+ status = _canonical_phase_status(
2412
+ raw_status=fields.get("status", ""),
2413
+ skill=skill,
2414
+ next_phase=next_phase,
2415
+ next_command=next_command,
2416
+ verification_status=verification_status,
2417
+ )
2418
+ next_skill = _legacy_next_skill(next_command)
2419
+
2420
+ blocker_summary = "none"
2421
+ blocker_class = "none"
2422
+ if status == "blocked":
2423
+ blocker_class = "repeated_verification_failure"
2424
+ blocker_summary = _legacy_blocker_summary(next_phase) or (
2425
+ "Legacy Claude closeout reported a blocked outcome without the shared blocker metadata."
2426
+ )
2427
+
2428
+ return [
2429
+ "automation:",
2430
+ f" status: {status}",
2431
+ f" next_skill: {next_skill}",
2432
+ f" next_command: {next_command}",
2433
+ " human_required: false",
2434
+ f" blocker_class: {blocker_class}",
2435
+ f" blocker_summary: {blocker_summary}",
2436
+ " required_human_inputs: []",
2437
+ f" verification_status: {verification_status}",
2438
+ ]
2439
+
2440
+
2441
+ def _legacy_next_skill(next_command: str) -> str:
2442
+ if next_command == "none":
2443
+ return "none"
2444
+ if next_command.startswith("/"):
2445
+ return next_command[1:].split()[0]
2446
+ return next_command.split()[0] if next_command.strip() else "none"
2447
+
2448
+
2449
+ def _legacy_blocker_summary(next_phase: str) -> str:
2450
+ blocked_marker = " - blocked:"
2451
+ if blocked_marker not in next_phase.lower():
2452
+ return ""
2453
+ prefix, _sep, suffix = next_phase.partition(" - ")
2454
+ details = suffix.split(":", 1)[1].strip() if ":" in suffix else suffix.strip()
2455
+ if prefix and details:
2456
+ return f"{prefix.strip()} blocked: {details}"
2457
+ return details
2458
+
2459
+
2460
+ def _canonical_verification_status(raw_status: str) -> str:
2461
+ lowered = raw_status.strip().lower()
2462
+ first_token = re.split(r"[\s:;,\-—–]+", lowered, maxsplit=1)[0] if lowered else ""
2463
+ if lowered in {"passed", "not_run", "failed", "blocked"}:
2464
+ return lowered
2465
+ if first_token in {"passed", "success", "ok", "pass"}:
2466
+ return "passed"
2467
+ if first_token in {"partial", "limited", "skipped"}:
2468
+ return "passed"
2469
+ if lowered.startswith("plan-only") or first_token in {"plan", "evidence_checked", "evidence-checked"}:
2470
+ return "passed"
2471
+ if first_token in {"not_run", "not-run", "notrun"}:
2472
+ return "not_run"
2473
+ if first_token in {"failed", "error", "failure", "fail"}:
2474
+ return "failed"
2475
+ if first_token == "blocked":
2476
+ return "blocked"
2477
+ if lowered in {"success", "ok", "pass"}:
2478
+ return "passed"
2479
+ if lowered in {"error", "failure", "fail"}:
2480
+ return "failed"
2481
+ return "not_run"
2482
+
2483
+
2484
+ def _canonical_phase_status(
2485
+ *,
2486
+ raw_status: str,
2487
+ skill: str,
2488
+ next_phase: str,
2489
+ next_command: str,
2490
+ verification_status: str,
2491
+ ) -> str:
2492
+ lowered = raw_status.strip().lower()
2493
+ next_phase_lower = next_phase.lower()
2494
+ next_command_lower = next_command.lower()
2495
+
2496
+ if lowered in {"unplanned", "planned", "executing", "executed", "awaiting_phase_closeout", "complete", "blocked", "unknown"}:
2497
+ if lowered == "complete" and verification_status == "passed" and _looks_like_phase_plan_next_command(next_command_lower):
2498
+ return "planned"
2499
+ if lowered == "executed" and verification_status == "passed" and (
2500
+ skill.endswith("phase-loop") or "phase-loop" in next_command_lower
2501
+ ):
2502
+ return "complete"
2503
+ return lowered
2504
+ if lowered in {"error", "failure", "fail"}:
2505
+ return "blocked"
2506
+ if "blocked" in next_phase_lower:
2507
+ return "blocked"
2508
+ if lowered in {"cleared", "ready_for_closeout"}:
2509
+ return "complete" if verification_status == "passed" else "blocked"
2510
+ if lowered in {"success", "ok", "pass", "passed", "done", "completed"}:
2511
+ if lowered != "completed" and skill.endswith("execute-phase") and _looks_like_phase_plan_next_command(next_command_lower):
2512
+ return "planned"
2513
+ if verification_status != "passed":
2514
+ return "blocked"
2515
+ if (
2516
+ "execution ready" in next_phase_lower
2517
+ or skill.endswith("plan-phase")
2518
+ or (
2519
+ lowered != "completed"
2520
+ and skill.endswith("execute-phase")
2521
+ and _looks_like_phase_plan_next_command(next_command_lower)
2522
+ )
2523
+ ):
2524
+ return "planned"
2525
+ if (
2526
+ "roadmap complete" in next_phase_lower
2527
+ or "planning ready" in next_phase_lower
2528
+ or skill.endswith("phase-roadmap-builder")
2529
+ or skill.endswith("phase-loop")
2530
+ or "phase-roadmap-builder" in next_command_lower
2531
+ or "phase-loop" in next_command_lower
2532
+ or next_command_lower == "none"
2533
+ or skill.endswith("execute-phase")
2534
+ ):
2535
+ return "complete"
2536
+ return "executed"
2537
+ if lowered.endswith("_complete"):
2538
+ return "complete" if verification_status == "passed" else "blocked"
2539
+ if lowered == "repaired":
2540
+ if verification_status != "passed":
2541
+ return "blocked"
2542
+ if next_command_lower == "none" or "roadmap complete" in next_phase_lower:
2543
+ return "complete"
2544
+ return "executed"
2545
+ if skill.endswith("plan-phase"):
2546
+ return "planned" if verification_status == "passed" else "blocked"
2547
+ if skill.endswith("execute-phase"):
2548
+ if verification_status == "passed" and (
2549
+ "roadmap complete" in next_phase_lower
2550
+ or "planning ready" in next_phase_lower
2551
+ or next_command_lower == "none"
2552
+ ):
2553
+ return "complete"
2554
+ return "executed" if verification_status == "passed" else "blocked"
2555
+ return "executed" if verification_status == "passed" else "blocked"
2556
+
2557
+
2558
+ def _looks_like_phase_plan_next_command(next_command_lower: str) -> bool:
2559
+ return "phase-plan" in next_command_lower and "execute-phase" in next_command_lower
2560
+
2561
+
2562
+ def _result_with_spec(result: LaunchResult, spec: LaunchSpec) -> LaunchResult:
2563
+ return LaunchResult(
2564
+ command=result.command,
2565
+ returncode=result.returncode,
2566
+ output=result.output,
2567
+ dry_run=result.dry_run,
2568
+ log_path=result.log_path,
2569
+ heartbeat_path=result.heartbeat_path,
2570
+ terminal_path=result.terminal_path,
2571
+ heartbeat_summary=result.heartbeat_summary,
2572
+ executor=spec.executor,
2573
+ injection_mode=spec.delivery_mode,
2574
+ context_sha256=spec.injection_metadata.context_sha256,
2575
+ expected_skill_pack=spec.injection_metadata.expected_skill_pack,
2576
+ available=spec.available,
2577
+ dry_run_only=spec.dry_run_only,
2578
+ unavailable_reason=spec.reason,
2579
+ live_proof_gate=spec.live_proof_gate,
2580
+ promotion_status=spec.promotion_status,
2581
+ promotion_requirements=spec.promotion_requirements,
2582
+ auth_preflight_mode=spec.auth_preflight_mode,
2583
+ auth_preflight_probes=spec.auth_preflight_probes,
2584
+ timeout_posture=spec.timeout_posture,
2585
+ output_capture_format=spec.output_capture_format,
2586
+ terminal_summary_artifact=spec.terminal_summary_artifact,
2587
+ permission_posture=spec.permission_posture,
2588
+ selected_agent=spec.selected_agent,
2589
+ selected_model=spec.selected_model,
2590
+ selected_variant=spec.selected_variant,
2591
+ process_pid=result.process_pid,
2592
+ process_group_id=result.process_group_id,
2593
+ started_at=result.started_at,
2594
+ finished_at=result.finished_at,
2595
+ timed_out=result.timed_out,
2596
+ interrupted=result.interrupted,
2597
+ stalled=result.stalled,
2598
+ claude_route=spec.claude_route,
2599
+ claude_route_result=result.claude_route_result,
2600
+ cleanup_evidence=result.cleanup_evidence,
2601
+ )
2602
+
2603
+
2604
+ def _read_lines(stdout, line_queue: Queue[str | None]) -> None:
2605
+ try:
2606
+ for line in stdout:
2607
+ line_queue.put(line)
2608
+ finally:
2609
+ line_queue.put(None)
2610
+
2611
+
2612
+ def _utc_now() -> str:
2613
+ from datetime import datetime, timezone
2614
+
2615
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
2616
+
2617
+
2618
+ def _process_group_id(pid: int) -> int | None:
2619
+ try:
2620
+ return os.getpgid(pid)
2621
+ except OSError:
2622
+ return None
2623
+
2624
+
2625
+ def _salvage_snapshot(
2626
+ *,
2627
+ command: list[str],
2628
+ cwd: str | Path | None,
2629
+ log_path: Path | None,
2630
+ heartbeat: dict[str, Any] | None,
2631
+ process: subprocess.Popen,
2632
+ process_group_id: int | None,
2633
+ reason: str,
2634
+ ) -> dict[str, Any]:
2635
+ snapshot: dict[str, Any] = {
2636
+ "reason": reason,
2637
+ "captured_at": _utc_now(),
2638
+ "process_pid": process.pid,
2639
+ "process_group_id": process_group_id,
2640
+ "command": metadata_command(command),
2641
+ }
2642
+ if cwd is not None:
2643
+ snapshot["cwd"] = str(cwd)
2644
+ if log_path is not None:
2645
+ snapshot["log_path"] = str(log_path)
2646
+ try:
2647
+ stat = log_path.stat()
2648
+ except OSError:
2649
+ snapshot["log_exists"] = False
2650
+ else:
2651
+ snapshot.update(
2652
+ {
2653
+ "log_exists": True,
2654
+ "log_size": stat.st_size,
2655
+ "log_mtime": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(stat.st_mtime)),
2656
+ }
2657
+ )
2658
+ if heartbeat:
2659
+ heartbeat_fields = (
2660
+ "heartbeat_path",
2661
+ "heartbeat_status",
2662
+ "quiet_level",
2663
+ "liveness_class",
2664
+ "stalled_suspect",
2665
+ "cpu_percent",
2666
+ "elapsed_seconds",
2667
+ "seconds_since_log_update",
2668
+ "last_log_excerpt",
2669
+ "last_log_excerpt_hash",
2670
+ )
2671
+ snapshot["heartbeat"] = {key: heartbeat[key] for key in heartbeat_fields if key in heartbeat}
2672
+ return snapshot
2673
+
2674
+
2675
+ def _cleanup_process_group(process: subprocess.Popen, process_group_id: int | None, *, reason: str) -> dict[str, Any]:
2676
+ evidence: dict[str, Any] = {
2677
+ "reason": reason,
2678
+ "requested_at": _utc_now(),
2679
+ "process_pid": process.pid,
2680
+ "process_group_id": process_group_id,
2681
+ "cleanup_scope": "process_group" if process_group_id is not None else "process_only",
2682
+ "signals_sent": [],
2683
+ }
2684
+ if process.poll() is not None:
2685
+ evidence["result"] = "already_exited"
2686
+ evidence["final_returncode"] = process.returncode
2687
+ evidence["process_alive_after_cleanup"] = False
2688
+ return evidence
2689
+ if process_group_id is not None:
2690
+ try:
2691
+ os.killpg(process_group_id, signal.SIGTERM)
2692
+ evidence["signals_sent"].append("SIGTERM")
2693
+ except OSError:
2694
+ evidence["result"] = "already_exited"
2695
+ evidence["final_returncode"] = process.poll()
2696
+ evidence["process_alive_after_cleanup"] = process.poll() is None
2697
+ return evidence
2698
+ else:
2699
+ process.terminate()
2700
+ evidence["signals_sent"].append("SIGTERM")
2701
+ try:
2702
+ final_returncode = process.wait(timeout=2)
2703
+ evidence["result"] = "terminated"
2704
+ evidence["final_returncode"] = final_returncode
2705
+ evidence["process_alive_after_cleanup"] = False
2706
+ return evidence
2707
+ except subprocess.TimeoutExpired:
2708
+ pass
2709
+ if process_group_id is not None:
2710
+ try:
2711
+ os.killpg(process_group_id, signal.SIGKILL)
2712
+ except OSError:
2713
+ evidence["result"] = "already_exited"
2714
+ evidence["final_returncode"] = process.poll()
2715
+ evidence["process_alive_after_cleanup"] = process.poll() is None
2716
+ return evidence
2717
+ else:
2718
+ process.kill()
2719
+ evidence["signals_sent"].append("SIGKILL")
2720
+ try:
2721
+ final_returncode = process.wait(timeout=2)
2722
+ evidence["result"] = "killed"
2723
+ evidence["final_returncode"] = final_returncode
2724
+ evidence["process_alive_after_cleanup"] = False
2725
+ except subprocess.TimeoutExpired:
2726
+ evidence["result"] = "cleanup_failed"
2727
+ evidence["final_returncode"] = process.poll()
2728
+ evidence["process_alive_after_cleanup"] = process.poll() is None
2729
+ return evidence
2730
+
2731
+
2732
+ def _opencode_agent(action: str) -> str:
2733
+ if action in {"roadmap", "plan", "review"}:
2734
+ return "build"
2735
+ return "build"
2736
+
2737
+
2738
+ def _opencode_model(model: str) -> str:
2739
+ if "/" in model:
2740
+ return model
2741
+ normalized = model.strip().lower()
2742
+ if normalized.startswith("gpt-"):
2743
+ return f"openai/{model}"
2744
+ if normalized.startswith("claude-") or normalized == "sonnet":
2745
+ return f"anthropic/{model}"
2746
+ if normalized.startswith("gemini-"):
2747
+ return f"google/{model}"
2748
+ return model
2749
+
2750
+
2751
+ def _opencode_variant(action: str, effort: str) -> str | None:
2752
+ if action == "review":
2753
+ return None
2754
+ if effort in {"xhigh", "max"}:
2755
+ return "max"
2756
+ if effort == "high":
2757
+ return "high"
2758
+ if effort == "low":
2759
+ return "minimal"
2760
+ return None