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,1290 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import time
8
+ import uuid
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from .capability_registry import describe_dispatch_decision
14
+ from .injection import claude_hook_guardrail_inventory, materialize_claude_plugin_bundle
15
+ from .models import (
16
+ TERMINAL_SUMMARY_FIELDS,
17
+ WORK_UNIT_METRIC_SCHEMA_VERSION,
18
+ WorkUnitMetric,
19
+ metadata_command,
20
+ utc_now,
21
+ )
22
+ from .runtime_paths import (
23
+ ensure_phase_loop_excluded,
24
+ phase_loop_run_context_file,
25
+ phase_loop_runs_dir,
26
+ phase_loop_stop_file,
27
+ phase_loop_stop_files,
28
+ )
29
+
30
+
31
+ NOTIFICATION_PAYLOAD_FIELDS = (
32
+ "timestamp",
33
+ "repo",
34
+ "roadmap",
35
+ "event_kind",
36
+ "monitor_status",
37
+ "current_phase",
38
+ "current_status",
39
+ "human_required",
40
+ "blocker_class",
41
+ "blocker_summary",
42
+ "required_human_inputs",
43
+ "latest_heartbeat",
44
+ "terminal_summary",
45
+ "state_path",
46
+ "event_path",
47
+ "tui_handoff_path",
48
+ "run_log_path",
49
+ "recommended_action",
50
+ "not_run_ratio",
51
+ "not_run_count",
52
+ "sample_size",
53
+ "threshold",
54
+ )
55
+
56
+ NOT_RUN_ALERT_THRESHOLD = 0.2
57
+ NOT_RUN_ALERT_SAMPLE_SIZE = 50
58
+ CPU_ACTIVE_THRESHOLD_PERCENT = 1.0
59
+
60
+
61
+ def stop_file(repo: Path) -> Path:
62
+ return phase_loop_stop_file(repo)
63
+
64
+
65
+ def stop_requested(repo: Path) -> bool:
66
+ return any(path.exists() for path in phase_loop_stop_files(repo))
67
+
68
+
69
+ def phase_loop_metrics_file(repo: Path) -> Path:
70
+ return repo / ".phase-loop" / "metrics.jsonl"
71
+
72
+
73
+ def phase_loop_metrics_path(repo: Path) -> Path:
74
+ return phase_loop_metrics_file(repo)
75
+
76
+
77
+ def run_artifacts(repo: Path, phase: str, action: str, index: int, command_or_spec) -> dict[str, Path]:
78
+ ensure_phase_loop_excluded(repo)
79
+ run_id = f"{utc_now().replace(':', '').replace('-', '').replace('Z', 'Z')}-{index:02d}-{_slug(phase)}-{action}"
80
+ root = phase_loop_runs_dir(repo) / run_id
81
+ root.mkdir(parents=True, exist_ok=True)
82
+ context_path: Path | None = None
83
+ if isinstance(command_or_spec, list):
84
+ metadata = {
85
+ "timestamp": utc_now(),
86
+ "phase": phase,
87
+ "action": action,
88
+ "cwd": str(repo),
89
+ "run_root": str(root),
90
+ "command": command_or_spec,
91
+ "log_path": str(root / "output.log"),
92
+ "heartbeat_path": str(root / "heartbeat.json"),
93
+ "terminal_path": str(root / "terminal-summary.json"),
94
+ "stop_file": str(stop_file(repo)),
95
+ }
96
+ else:
97
+ context_path, context_sha256 = _write_context_artifact(root, command_or_spec)
98
+ injection = command_or_spec.injection_metadata.to_json()
99
+ dispatch = command_or_spec.dispatch_decision.to_json() if command_or_spec.dispatch_decision else None
100
+ plugin_bundle_artifacts = _materialize_launch_bundle(repo, root, command_or_spec)
101
+ task_ledger_artifacts = _materialize_task_ledger(root, phase, command_or_spec)
102
+ metadata = {
103
+ "timestamp": utc_now(),
104
+ "phase": phase,
105
+ "action": action,
106
+ "cwd": command_or_spec.wrapped_cwd or str(repo),
107
+ "run_root": str(root),
108
+ "executor": command_or_spec.executor,
109
+ "command": metadata_command(command_or_spec.command, command_or_spec.prompt_bundle.render_prompt()),
110
+ "available": command_or_spec.available,
111
+ "dry_run_only": command_or_spec.dry_run_only,
112
+ "unavailable_reason": command_or_spec.reason,
113
+ "live_proof_gate": command_or_spec.live_proof_gate,
114
+ "promotion_status": command_or_spec.promotion_status,
115
+ "promotion_requirements": list(command_or_spec.promotion_requirements),
116
+ "auth_preflight_mode": command_or_spec.auth_preflight_mode,
117
+ "auth_preflight_probes": list(command_or_spec.auth_preflight_probes),
118
+ "timeout_posture": command_or_spec.timeout_posture,
119
+ "output_capture_format": command_or_spec.output_capture_format,
120
+ "terminal_summary_artifact": command_or_spec.terminal_summary_artifact,
121
+ "permission_posture": command_or_spec.permission_posture,
122
+ "selected_agent": command_or_spec.selected_agent,
123
+ "selected_model": command_or_spec.selected_model,
124
+ "selected_effort": command_or_spec.selected_effort,
125
+ "profile_source": command_or_spec.profile_source,
126
+ "override_reason": command_or_spec.override_reason,
127
+ "selected_variant": command_or_spec.selected_variant,
128
+ "command_adapter_name": command_or_spec.command_adapter_name,
129
+ "command_template": command_or_spec.command_template,
130
+ "wrapped_cwd": command_or_spec.wrapped_cwd,
131
+ "launch_timeout_seconds": command_or_spec.launch_timeout_seconds,
132
+ "claude_execution_mode": command_or_spec.claude_execution_mode,
133
+ "claude_team_policy": (
134
+ command_or_spec.claude_team_policy.to_json() if command_or_spec.claude_team_policy else None
135
+ ),
136
+ "phase_team_eligibility": (
137
+ command_or_spec.phase_team_eligibility.to_json() if command_or_spec.phase_team_eligibility else None
138
+ ),
139
+ "harness_target": injection.get("harness_target"),
140
+ "injection_mode": injection.get("injection_mode"),
141
+ "context_sha256": context_sha256 or injection.get("context_sha256"),
142
+ "context_line_count": injection.get("context_line_count"),
143
+ "context_char_count": injection.get("context_char_count"),
144
+ "expected_skill_pack": injection.get("expected_skill_pack", []),
145
+ "skill_bundle_id": injection.get("skill_bundle_id"),
146
+ "skill_bundle_sha256": injection.get("skill_bundle_sha256"),
147
+ "fallback_mode": injection.get("fallback_mode"),
148
+ "context_path": str(context_path) if context_path is not None else injection.get("context_path"),
149
+ "recommended_installed_roots": injection.get("recommended_installed_roots", []),
150
+ "installed_skill_roots": injection.get("installed_skill_roots", []),
151
+ "installed_skill_warnings": injection.get("installed_skill_warnings", []),
152
+ "bridge_skill_inventory": injection.get("bridge_skill_inventory", []),
153
+ "plugin_bundle_artifacts": plugin_bundle_artifacts,
154
+ "task_ledger_artifacts": task_ledger_artifacts,
155
+ "dispatch_decision": dispatch,
156
+ "dispatch_summary": describe_dispatch_decision(command_or_spec.dispatch_decision) if command_or_spec.dispatch_decision else None,
157
+ "harness_lane_assignment": (
158
+ command_or_spec.harness_lane_assignment.to_json()
159
+ if command_or_spec.harness_lane_assignment
160
+ else None
161
+ ),
162
+ "lane_id": (
163
+ command_or_spec.harness_lane_assignment.lane_id
164
+ if command_or_spec.harness_lane_assignment
165
+ else None
166
+ ),
167
+ "work_unit_kind": (
168
+ command_or_spec.harness_lane_assignment.work_unit_kind
169
+ if command_or_spec.harness_lane_assignment
170
+ else None
171
+ ),
172
+ "log_path": str(root / "output.log"),
173
+ "heartbeat_path": str(root / "heartbeat.json"),
174
+ "terminal_path": str(root / "terminal-summary.json"),
175
+ "stop_file": str(stop_file(repo)),
176
+ }
177
+ (root / "launch.json").write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
178
+ return {
179
+ "root": root,
180
+ "metadata": root / "launch.json",
181
+ "log": root / "output.log",
182
+ "heartbeat": root / "heartbeat.json",
183
+ "terminal": root / "terminal-summary.json",
184
+ **(
185
+ {
186
+ "task_snapshot": root / "task-snapshot.json",
187
+ "hook_manifest": root / "hook-guardrails.json",
188
+ }
189
+ if (root / "task-snapshot.json").exists()
190
+ else {}
191
+ ),
192
+ **({"context": context_path} if context_path is not None else {}),
193
+ }
194
+
195
+
196
+ def hotfix_run_artifacts(repo: Path, reason: str, plan_stub: Path) -> dict[str, Path]:
197
+ ensure_phase_loop_excluded(repo)
198
+ plan_path = plan_stub if plan_stub.is_absolute() else repo / plan_stub
199
+ run_id = f"{utc_now().replace(':', '').replace('-', '').replace('Z', 'Z')}-hotfix-{_slug(plan_path.stem)}"
200
+ root = phase_loop_runs_dir(repo) / run_id
201
+ root.mkdir(parents=True, exist_ok=True)
202
+ metadata = {
203
+ "timestamp": utc_now(),
204
+ "phase": "HOTFIX",
205
+ "action": "hotfix",
206
+ "work_unit": "hotfix",
207
+ "cwd": str(repo),
208
+ "run_root": str(root),
209
+ "reason": _redact_hotfix_reason(reason),
210
+ "plan_stub": str(plan_path),
211
+ "log_path": str(root / "output.log"),
212
+ "heartbeat_path": str(root / "heartbeat.json"),
213
+ "terminal_path": str(root / "terminal-summary.json"),
214
+ "verification_artifact_path": str(root / "verification.json"),
215
+ "verification_log_path": str(root / "verification.log"),
216
+ "stop_file": str(stop_file(repo)),
217
+ }
218
+ (root / "launch.json").write_text(json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8")
219
+ return {
220
+ "root": root,
221
+ "metadata": root / "launch.json",
222
+ "log": root / "output.log",
223
+ "heartbeat": root / "heartbeat.json",
224
+ "terminal": root / "terminal-summary.json",
225
+ "verification_artifact": root / "verification.json",
226
+ "verification_log": root / "verification.log",
227
+ }
228
+
229
+
230
+ def build_work_unit_metric(
231
+ *,
232
+ repo: Path,
233
+ phase: str,
234
+ action: str,
235
+ launch_metadata: dict[str, Any] | None = None,
236
+ launch_result: Any | None = None,
237
+ terminal_summary: dict[str, Any] | None = None,
238
+ artifact_paths: dict[str, str] | None = None,
239
+ lane_id: str | None = None,
240
+ ) -> WorkUnitMetric:
241
+ launch_metadata = launch_metadata or {}
242
+ terminal_summary = terminal_summary or {}
243
+ launch = launch_metadata.get("launch") if isinstance(launch_metadata.get("launch"), dict) else {}
244
+ harness_lane_assignment = (
245
+ launch_metadata.get("harness_lane_assignment")
246
+ if isinstance(launch_metadata.get("harness_lane_assignment"), dict)
247
+ else {}
248
+ )
249
+ execution_policy = (
250
+ launch_metadata.get("execution_policy") if isinstance(launch_metadata.get("execution_policy"), dict) else {}
251
+ )
252
+ selected_policy = _selected_execution_policy(execution_policy, launch_metadata)
253
+ dispatch_decision = (
254
+ launch_metadata.get("dispatch_decision") if isinstance(launch_metadata.get("dispatch_decision"), dict) else {}
255
+ )
256
+ executor = str(
257
+ launch_metadata.get("executor")
258
+ or launch.get("executor")
259
+ or selected_policy.get("executor")
260
+ or dispatch_decision.get("selected_executor")
261
+ or "manual"
262
+ )
263
+ model = str(
264
+ launch_metadata.get("selected_model")
265
+ or launch.get("selected_model")
266
+ or selected_policy.get("model")
267
+ or launch_metadata.get("model")
268
+ or "unknown"
269
+ )
270
+ effort = launch_metadata.get("selected_variant") or selected_policy.get("effort") or launch_metadata.get("selected_effort")
271
+ if effort is not None:
272
+ effort = str(effort)
273
+ started_at = getattr(launch_result, "started_at", None) or launch.get("started_at") or launch_metadata.get("started_at")
274
+ finished_at = getattr(launch_result, "finished_at", None) or launch.get("finished_at") or launch_metadata.get("finished_at")
275
+ returncode = getattr(launch_result, "returncode", None)
276
+ if returncode is None:
277
+ returncode = launch.get("returncode")
278
+ terminal_blocker = terminal_summary.get("terminal_blocker")
279
+ blocker_class = terminal_blocker.get("blocker_class") if isinstance(terminal_blocker, dict) else None
280
+ if blocker_class == "blocked_by_external_setup":
281
+ blocker_class = "admin_approval"
282
+ if blocker_class == "blocked_by_implementation":
283
+ blocker_class = "repeated_verification_failure"
284
+ metric_id = str(launch_metadata.get("metric_id") or f"wum-{uuid.uuid4().hex}")
285
+ metric = WorkUnitMetric(
286
+ metric_id=metric_id,
287
+ schema_version=WORK_UNIT_METRIC_SCHEMA_VERSION,
288
+ timestamp=utc_now(),
289
+ work_unit_id=_work_unit_id(terminal_summary, launch_metadata),
290
+ work_unit_kind=str(selected_policy.get("work_unit_kind") or _work_unit_kind_for_action(action)),
291
+ phase=phase,
292
+ lane_id=lane_id or harness_lane_assignment.get("lane_id"),
293
+ wave_id=_wave_id(terminal_summary, launch_metadata),
294
+ action=action,
295
+ executor=executor,
296
+ provider=str(_provider_for_executor(executor)),
297
+ model=model,
298
+ effort=effort,
299
+ thinking_level=selected_policy.get("thinking_level"),
300
+ policy_source=selected_policy.get("execution_policy_source") or selected_policy.get("source"),
301
+ policy_override_reason=selected_policy.get("execution_policy_override_reason")
302
+ or selected_policy.get("override_reason"),
303
+ profile_source=selected_policy.get("model_source") or selected_policy.get("source") or launch_metadata.get("profile_source"),
304
+ fallback_applied=bool(
305
+ selected_policy.get("fallback_applied")
306
+ or launch_metadata.get("fallback_applied")
307
+ or dispatch_decision.get("fallback_applied")
308
+ ),
309
+ fallback=selected_policy.get("fallback") or launch_metadata.get("fallback"),
310
+ fallback_reason=(
311
+ selected_policy.get("fallback_reason")
312
+ or launch_metadata.get("fallback_reason")
313
+ or dispatch_decision.get("fallback_reason")
314
+ or dispatch_decision.get("blocked_reason")
315
+ ),
316
+ duration_seconds=_duration_seconds(started_at, finished_at),
317
+ returncode=returncode,
318
+ terminal_status=terminal_summary.get("terminal_status"),
319
+ verification_status=terminal_summary.get("verification_status"),
320
+ blocker_class=str(blocker_class) if blocker_class else None,
321
+ artifact_paths=dict(artifact_paths or terminal_summary.get("artifact_paths") or {}),
322
+ )
323
+ ensure_phase_loop_excluded(repo)
324
+ return metric
325
+
326
+
327
+ def append_work_unit_metric(repo: Path, metric: WorkUnitMetric) -> Path:
328
+ path = phase_loop_metrics_path(repo)
329
+ ensure_phase_loop_excluded(repo)
330
+ path.parent.mkdir(parents=True, exist_ok=True)
331
+ with path.open("a", encoding="utf-8") as handle:
332
+ handle.write(json.dumps(metric.to_json(), sort_keys=True) + "\n")
333
+ return path
334
+
335
+
336
+ def read_work_unit_metrics(repo: Path, limit: int | None = None) -> list[dict[str, Any]]:
337
+ path = phase_loop_metrics_path(repo)
338
+ if not path.exists():
339
+ return []
340
+ records: list[dict[str, Any]] = []
341
+ try:
342
+ lines = path.read_text(encoding="utf-8").splitlines()
343
+ except OSError:
344
+ return []
345
+ for line in lines:
346
+ if not line.strip():
347
+ continue
348
+ try:
349
+ data = json.loads(line)
350
+ except json.JSONDecodeError:
351
+ continue
352
+ if isinstance(data, dict):
353
+ records.append(data)
354
+ return records[-limit:] if limit is not None else records
355
+
356
+
357
+ def summarize_work_unit_metrics(metrics: list[dict[str, Any]]) -> dict[str, Any]:
358
+ trailing = metrics[-NOT_RUN_ALERT_SAMPLE_SIZE:]
359
+ not_run_count = sum(1 for metric in trailing if metric.get("verification_status") == "not_run")
360
+ sample_size = len(trailing)
361
+ not_run_ratio = round(not_run_count / sample_size, 3) if sample_size else 0.0
362
+ summary: dict[str, Any] = {
363
+ "total": len(metrics),
364
+ "by_executor": {},
365
+ "by_model": {},
366
+ "by_effort": {},
367
+ "by_terminal_status": {},
368
+ "by_verification_status": {},
369
+ "by_blocker_class": {},
370
+ "not_run_ratio": not_run_ratio,
371
+ "not_run_count": not_run_count,
372
+ "sample_size": sample_size,
373
+ "threshold": NOT_RUN_ALERT_THRESHOLD,
374
+ "not_run_alert": bool(sample_size and not_run_ratio > NOT_RUN_ALERT_THRESHOLD),
375
+ }
376
+ for metric in metrics:
377
+ _bump(summary["by_executor"], metric.get("executor"))
378
+ _bump(summary["by_model"], metric.get("model"))
379
+ _bump(summary["by_effort"], metric.get("effort"))
380
+ _bump(summary["by_terminal_status"], metric.get("terminal_status"))
381
+ _bump(summary["by_verification_status"], metric.get("verification_status"))
382
+ _bump(summary["by_blocker_class"], metric.get("blocker_class"))
383
+ return summary
384
+
385
+
386
+ def _write_context_artifact(root: Path, spec) -> tuple[Path | None, str | None]:
387
+ context_sha256 = spec.prompt_bundle.context_sha256()
388
+ if spec.delivery_mode != "context_file":
389
+ return None, context_sha256
390
+ context_path = phase_loop_run_context_file(root)
391
+ context_path.write_text(spec.prompt_bundle.render_context().rstrip() + "\n", encoding="utf-8")
392
+ return context_path, context_sha256
393
+
394
+
395
+ def _selected_execution_policy(execution_policy: dict[str, Any], launch_metadata: dict[str, Any]) -> dict[str, Any]:
396
+ if isinstance(execution_policy.get("selected"), dict):
397
+ return execution_policy["selected"]
398
+ if isinstance(execution_policy.get("resolved"), dict):
399
+ return execution_policy["resolved"]
400
+ if execution_policy.get("work_unit_kind") or execution_policy.get("model") or execution_policy.get("effort"):
401
+ return execution_policy
402
+ if launch_metadata.get("selected_model") or launch_metadata.get("selected_effort"):
403
+ return {
404
+ "executor": launch_metadata.get("executor"),
405
+ "model": launch_metadata.get("selected_model"),
406
+ "effort": launch_metadata.get("selected_effort"),
407
+ "source": launch_metadata.get("profile_source"),
408
+ "override_reason": launch_metadata.get("override_reason"),
409
+ }
410
+ request = launch_metadata.get("launch_request")
411
+ if isinstance(request, dict):
412
+ model_selection = request.get("model_selection")
413
+ if isinstance(model_selection, dict):
414
+ return {
415
+ "executor": request.get("executor"),
416
+ "model": model_selection.get("model"),
417
+ "effort": model_selection.get("effort"),
418
+ "source": model_selection.get("source"),
419
+ "override_reason": model_selection.get("override_reason"),
420
+ }
421
+ return {}
422
+
423
+
424
+ def _work_unit_kind_for_action(action: str) -> str:
425
+ return {
426
+ "roadmap": "roadmap_build",
427
+ "plan": "phase_plan",
428
+ "execute": "lane_execute",
429
+ "repair": "repair",
430
+ "review": "lane_review",
431
+ "maintain-skills": "closeout",
432
+ }.get(action, "lane_execute")
433
+
434
+
435
+ def _work_unit_id(terminal_summary: dict[str, Any], launch_metadata: dict[str, Any]) -> str | None:
436
+ work_unit = terminal_summary.get("work_unit")
437
+ if isinstance(work_unit, dict) and work_unit.get("work_unit_id"):
438
+ return str(work_unit["work_unit_id"])
439
+ work_unit = launch_metadata.get("work_unit")
440
+ if isinstance(work_unit, dict) and work_unit.get("work_unit_id"):
441
+ return str(work_unit["work_unit_id"])
442
+ return None
443
+
444
+
445
+ def _wave_id(terminal_summary: dict[str, Any], launch_metadata: dict[str, Any]) -> str | None:
446
+ if terminal_summary.get("wave_id"):
447
+ return str(terminal_summary["wave_id"])
448
+ if launch_metadata.get("wave_id"):
449
+ return str(launch_metadata["wave_id"])
450
+ work_unit = terminal_summary.get("work_unit")
451
+ if isinstance(work_unit, dict) and work_unit.get("wave_id"):
452
+ return str(work_unit["wave_id"])
453
+ work_unit = launch_metadata.get("work_unit")
454
+ if isinstance(work_unit, dict) and work_unit.get("wave_id"):
455
+ return str(work_unit["wave_id"])
456
+ return None
457
+
458
+
459
+ def _provider_for_executor(executor: str) -> str:
460
+ return {
461
+ "codex": "openai",
462
+ "claude": "anthropic",
463
+ "gemini": "google",
464
+ "opencode": "opencode",
465
+ "pi": "pi-agent",
466
+ "command": "command",
467
+ "manual": "manual",
468
+ }.get(executor, executor)
469
+
470
+
471
+ def _duration_seconds(started_at: str | None, finished_at: str | None) -> float | None:
472
+ if not started_at or not finished_at:
473
+ return None
474
+ try:
475
+ started = datetime.fromisoformat(started_at.replace("Z", "+00:00"))
476
+ finished = datetime.fromisoformat(finished_at.replace("Z", "+00:00"))
477
+ except ValueError:
478
+ return None
479
+ return max(0.0, round((finished - started).total_seconds(), 3))
480
+
481
+
482
+ def _bump(bucket: dict[str, int], raw_value: Any) -> None:
483
+ value = str(raw_value) if raw_value not in (None, "") else "unknown"
484
+ bucket[value] = bucket.get(value, 0) + 1
485
+
486
+
487
+ def _materialize_launch_bundle(repo: Path, run_root: Path, spec) -> dict[str, Any] | None:
488
+ if spec.executor != "claude":
489
+ return None
490
+ bundle = materialize_claude_plugin_bundle(repo=repo, run_root=run_root, prompt_bundle=spec.prompt_bundle)
491
+ return bundle or None
492
+
493
+
494
+ def _materialize_task_ledger(run_root: Path, phase: str, spec) -> dict[str, Any] | None:
495
+ if getattr(spec, "executor", None) != "claude":
496
+ return None
497
+ generated_at = utc_now()
498
+ execution_mode = getattr(spec, "claude_execution_mode", None) or "solo"
499
+ eligibility = getattr(spec, "phase_team_eligibility", None)
500
+ team_policy = getattr(spec, "claude_team_policy", None)
501
+ lane_summaries = list(getattr(eligibility, "lane_summaries", ()) or ())
502
+ snapshot_path = run_root / "task-snapshot.json"
503
+ snapshot = _build_task_snapshot(
504
+ phase=phase,
505
+ generated_at=generated_at,
506
+ execution_mode=execution_mode,
507
+ lane_summaries=lane_summaries,
508
+ )
509
+ snapshot_path.write_text(json.dumps(snapshot, indent=2, sort_keys=True) + "\n", encoding="utf-8")
510
+
511
+ hook_dir = run_root / "hook-policies"
512
+ hook_dir.mkdir(parents=True, exist_ok=True)
513
+ inventory = []
514
+ for event_name in claude_hook_guardrail_inventory():
515
+ policy = _hook_policy_record(
516
+ event_name=event_name,
517
+ generated_at=generated_at,
518
+ execution_mode=execution_mode,
519
+ allowed_worktree_root=(
520
+ str(Path(getattr(spec, "wrapped_cwd", ""))) if getattr(spec, "wrapped_cwd", None) else None
521
+ ),
522
+ )
523
+ policy_path = hook_dir / f"{event_name}.json"
524
+ policy_path.write_text(json.dumps(policy, indent=2, sort_keys=True) + "\n", encoding="utf-8")
525
+ inventory.append(
526
+ {
527
+ "event_name": event_name,
528
+ "path": str(policy_path),
529
+ "blocker_posture": policy["blocker_posture"],
530
+ "guardrail": policy["guardrail"],
531
+ }
532
+ )
533
+ hook_manifest_path = run_root / "hook-guardrails.json"
534
+ hook_manifest = {
535
+ "schema_version": "taskledger-hook-guardrails.v1",
536
+ "generated_at": generated_at,
537
+ "activity_source": "documented_hook_events",
538
+ "execution_mode": execution_mode,
539
+ "hook_policy_inventory": inventory,
540
+ "team_policy_inventory": (
541
+ {
542
+ "maturity_label": getattr(team_policy, "maturity_label", None),
543
+ "max_teammates": getattr(team_policy, "max_teammates", None),
544
+ "max_native_tasks": getattr(team_policy, "max_native_tasks", None),
545
+ "max_fanout": getattr(team_policy, "max_fanout", None),
546
+ }
547
+ if team_policy is not None
548
+ else {}
549
+ ),
550
+ }
551
+ hook_manifest_path.write_text(json.dumps(hook_manifest, indent=2, sort_keys=True) + "\n", encoding="utf-8")
552
+ return {
553
+ "snapshot_path": str(snapshot_path),
554
+ "snapshot_generated_at": generated_at,
555
+ "snapshot_freshness_timestamp": generated_at,
556
+ "snapshot_schema_version": snapshot["schema_version"],
557
+ "activity_source": snapshot["activity_source"],
558
+ "latest_activity": snapshot["latest_activity"],
559
+ "hook_manifest_path": str(hook_manifest_path),
560
+ "hook_policy_dir": str(hook_dir),
561
+ "hook_policy_inventory": inventory,
562
+ }
563
+
564
+
565
+ def _build_task_snapshot(
566
+ *,
567
+ phase: str,
568
+ generated_at: str,
569
+ execution_mode: str,
570
+ lane_summaries: list[dict[str, Any]],
571
+ ) -> dict[str, Any]:
572
+ if execution_mode == "agent_team":
573
+ teammates = [
574
+ {
575
+ "teammate_id": f"teammate-{index + 1}",
576
+ "teammate_label": str(lane.get("heading") or f"Lane {index + 1}").lstrip("# ").strip(),
577
+ "current_assignee": f"teammate-{index + 1}",
578
+ "latest_update_timestamp": generated_at,
579
+ "terminal_outcome": None,
580
+ "provenance_source": "launch_contract",
581
+ }
582
+ for index, lane in enumerate(lane_summaries or [{"heading": "Lead Claude"}])
583
+ ]
584
+ tasks = [
585
+ {
586
+ "task_id": f"{phase.lower()}-lane-{index + 1}",
587
+ "task_status": "pending",
588
+ "dependency_ids": [],
589
+ "ownership_claims": list(lane.get("owned_patterns") or ()),
590
+ "current_assignee": teammates[index]["teammate_id"],
591
+ "latest_update_timestamp": generated_at,
592
+ "terminal_outcome": None,
593
+ "provenance_source": "launch_contract",
594
+ }
595
+ for index, lane in enumerate(lane_summaries or [{"owned_patterns": []}])
596
+ ]
597
+ classification = "claude_agent_team_active"
598
+ summary = "Native Claude agent-team launch initialized with lane-scoped tasks."
599
+ elif execution_mode == "subagent":
600
+ teammates = [
601
+ {
602
+ "teammate_id": "subagent-1",
603
+ "teammate_label": "Claude subagent",
604
+ "current_assignee": "subagent-1",
605
+ "latest_update_timestamp": generated_at,
606
+ "terminal_outcome": None,
607
+ "provenance_source": "launch_contract",
608
+ }
609
+ ]
610
+ tasks = [
611
+ {
612
+ "task_id": f"{phase.lower()}-subagent-task",
613
+ "task_status": "waiting",
614
+ "dependency_ids": [],
615
+ "ownership_claims": list((lane_summaries[0].get("owned_patterns") if lane_summaries else []) or ()),
616
+ "current_assignee": "subagent-1",
617
+ "latest_update_timestamp": generated_at,
618
+ "terminal_outcome": None,
619
+ "provenance_source": "launch_contract",
620
+ }
621
+ ]
622
+ classification = "claude_subagent_wait"
623
+ summary = "Claude subagent launch initialized and waiting on delegated child progress."
624
+ else:
625
+ teammates = [
626
+ {
627
+ "teammate_id": "lead",
628
+ "teammate_label": "Lead Claude",
629
+ "current_assignee": "lead",
630
+ "latest_update_timestamp": generated_at,
631
+ "terminal_outcome": None,
632
+ "provenance_source": "launch_contract",
633
+ }
634
+ ]
635
+ tasks = [
636
+ {
637
+ "task_id": f"{phase.lower()}-solo-task",
638
+ "task_status": "running",
639
+ "dependency_ids": [],
640
+ "ownership_claims": [],
641
+ "current_assignee": "lead",
642
+ "latest_update_timestamp": generated_at,
643
+ "terminal_outcome": None,
644
+ "provenance_source": "launch_contract",
645
+ }
646
+ ]
647
+ classification = "claude_solo"
648
+ summary = "Solo Claude launch initialized without native teammates."
649
+ return {
650
+ "schema_version": "taskledger.v1",
651
+ "phase": phase,
652
+ "execution_mode": execution_mode,
653
+ "generated_at": generated_at,
654
+ "freshness_timestamp": generated_at,
655
+ "activity_source": "launch_contract",
656
+ "teammates": teammates,
657
+ "tasks": tasks,
658
+ "latest_activity": {
659
+ "classification": classification,
660
+ "summary": summary,
661
+ "latest_update_timestamp": generated_at,
662
+ "provenance_source": "launch_contract",
663
+ },
664
+ }
665
+
666
+
667
+ def _hook_policy_record(
668
+ *,
669
+ event_name: str,
670
+ generated_at: str,
671
+ execution_mode: str,
672
+ allowed_worktree_root: str | None,
673
+ ) -> dict[str, Any]:
674
+ default_exit_code = 1 if event_name == "WorktreeCreate" else 2
675
+ guardrail_map = {
676
+ "TaskCreated": "block_out_of_bounds_task_creation",
677
+ "TaskCompleted": "block_premature_task_completion",
678
+ "TeammateIdle": "block_unresolved_teammate_idle",
679
+ "SubagentStop": "block_missing_terminal_outcome",
680
+ "PostToolBatch": "block_missing_verification_evidence",
681
+ "WorktreeCreate": "block_forbidden_worktree_location",
682
+ }
683
+ return {
684
+ "schema_version": "taskledger-hook-policy.v1",
685
+ "event_name": event_name,
686
+ "generated_at": generated_at,
687
+ "execution_mode": execution_mode,
688
+ "blocker_posture": "hard_fail",
689
+ "blocking_exit_code": default_exit_code,
690
+ "guardrail": guardrail_map[event_name],
691
+ "allowed_worktree_root": allowed_worktree_root,
692
+ "provenance_source": "documented_hook_events",
693
+ }
694
+
695
+
696
+ def read_launch_metadata(path: Path | str | None) -> dict[str, Any] | None:
697
+ if not path:
698
+ return None
699
+ launch = Path(path)
700
+ if not launch.exists():
701
+ return None
702
+ try:
703
+ data = json.loads(launch.read_text(encoding="utf-8"))
704
+ except (OSError, json.JSONDecodeError):
705
+ return None
706
+ return data if isinstance(data, dict) else None
707
+
708
+
709
+ def merge_launch_metadata(path: Path | str | None, extra: dict[str, Any]) -> dict[str, Any]:
710
+ if not path:
711
+ return {}
712
+ launch = Path(path)
713
+ data = read_launch_metadata(launch) or {}
714
+ data.update(extra)
715
+ launch.parent.mkdir(parents=True, exist_ok=True)
716
+ launch.write_text(json.dumps(data, indent=2, sort_keys=True) + "\n", encoding="utf-8")
717
+ return data
718
+
719
+
720
+ def heartbeat_path_for_log(log_path: Path) -> Path:
721
+ return log_path.parent / "heartbeat.json"
722
+
723
+
724
+ def read_run_heartbeat(path: Path | str | None) -> dict[str, Any] | None:
725
+ if not path:
726
+ return None
727
+ heartbeat = Path(path)
728
+ if not heartbeat.exists():
729
+ return None
730
+ try:
731
+ data = json.loads(heartbeat.read_text(encoding="utf-8"))
732
+ except (OSError, json.JSONDecodeError):
733
+ return None
734
+ return data if isinstance(data, dict) else None
735
+
736
+
737
+ def read_terminal_summary(path: Path | str | None) -> dict[str, Any] | None:
738
+ if not path:
739
+ return None
740
+ terminal = Path(path)
741
+ if not terminal.exists():
742
+ return None
743
+ try:
744
+ data = json.loads(terminal.read_text(encoding="utf-8"))
745
+ except (OSError, json.JSONDecodeError):
746
+ return None
747
+ return data if isinstance(data, dict) else None
748
+
749
+
750
+ def read_task_snapshot(path: Path | str | None) -> dict[str, Any] | None:
751
+ if not path:
752
+ return None
753
+ snapshot = Path(path)
754
+ if not snapshot.exists():
755
+ return None
756
+ try:
757
+ data = json.loads(snapshot.read_text(encoding="utf-8"))
758
+ except (OSError, json.JSONDecodeError):
759
+ return None
760
+ return data if isinstance(data, dict) else None
761
+
762
+
763
+ def write_terminal_summary(path: Path | str | None, summary: dict[str, Any]) -> None:
764
+ if not path:
765
+ return
766
+ target = Path(path)
767
+ target.parent.mkdir(parents=True, exist_ok=True)
768
+ target.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
769
+
770
+
771
+ def build_terminal_summary(
772
+ *,
773
+ terminal_status: str,
774
+ terminal_blocker: dict[str, Any] | None,
775
+ verification_status: str,
776
+ next_action: str,
777
+ metric_id: str | None = None,
778
+ dirty_paths: list[str] | tuple[str, ...] = (),
779
+ phase_owned_dirty: bool = False,
780
+ phase_owned_dirty_paths: list[str] | tuple[str, ...] = (),
781
+ previous_phase_owned_paths: list[str] | tuple[str, ...] = (),
782
+ unowned_dirty_paths: list[str] | tuple[str, ...] = (),
783
+ pre_existing_dirty_paths: list[str] | tuple[str, ...] = (),
784
+ artifact_paths: dict[str, str] | None = None,
785
+ evidence_refs: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
786
+ work_unit: dict[str, Any] | None = None,
787
+ verification_commands: list[dict[str, Any]] | tuple[dict[str, Any], ...] = (),
788
+ latest_verification_unit: dict[str, Any] | None = None,
789
+ phase_loop_closeout: dict[str, Any] | None = None,
790
+ child_baml_closeout: dict[str, Any] | None = None,
791
+ extraction_failure: dict[str, Any] | None = None,
792
+ ) -> dict[str, Any]:
793
+ summary = {
794
+ "metric_id": metric_id,
795
+ "terminal_status": terminal_status,
796
+ "terminal_blocker": terminal_blocker,
797
+ "verification_status": verification_status,
798
+ "next_action": next_action,
799
+ "dirty_paths": list(dirty_paths),
800
+ "phase_owned_dirty": phase_owned_dirty,
801
+ "phase_owned_dirty_paths": list(phase_owned_dirty_paths),
802
+ "previous_phase_owned_paths": list(previous_phase_owned_paths),
803
+ "unowned_dirty_paths": list(unowned_dirty_paths),
804
+ "pre_existing_dirty_paths": list(pre_existing_dirty_paths),
805
+ "artifact_paths": artifact_paths or {},
806
+ }
807
+ summary = apply_child_terminal_summary_overlay(
808
+ summary,
809
+ child_baml_closeout=child_baml_closeout,
810
+ extraction_failure=extraction_failure,
811
+ )
812
+ return {
813
+ **({"metric_id": metric_id} if metric_id else {}),
814
+ **{field: summary[field] for field in TERMINAL_SUMMARY_FIELDS},
815
+ **({"produced_if_gates": summary["produced_if_gates"]} if "produced_if_gates" in summary else {}),
816
+ **({"extraction_failure": summary["extraction_failure"]} if "extraction_failure" in summary else {}),
817
+ **({"evidence_refs": list(evidence_refs)} if evidence_refs else {}),
818
+ **({"work_unit": work_unit} if work_unit else {}),
819
+ **({"verification_commands": list(verification_commands)} if verification_commands else {}),
820
+ **({"latest_verification_unit": latest_verification_unit} if latest_verification_unit else {}),
821
+ **({"phase_loop_closeout": phase_loop_closeout} if phase_loop_closeout else {}),
822
+ }
823
+
824
+
825
+ def apply_child_terminal_summary_overlay(
826
+ summary: dict[str, Any],
827
+ *,
828
+ child_baml_closeout: dict[str, Any] | None = None,
829
+ extraction_failure: dict[str, Any] | None = None,
830
+ ) -> dict[str, Any]:
831
+ updated = dict(summary)
832
+ # #38: the runner's verdict is authoritative. When the runner has already rejected
833
+ # the child's closeout (it set a blocking terminal_status / terminal_blocker — e.g.
834
+ # a produced_if_gates contract_bug), the child's self-reported BAML closeout must
835
+ # NOT be overlaid back onto terminal_status / verification_status / terminal_blocker.
836
+ # Otherwise the persisted terminal-summary.json surfaces "complete"/"passed" to the
837
+ # next execute run and the executor reconcile-skips instead of redoing the work. The
838
+ # child's claim remains in the event ledger (child_automation) for forensics.
839
+ # (Narrowly scoped to runner *rejections*; planned/executed downgrade siblings are
840
+ # not yet covered — see issue #38.)
841
+ runner_blocker = summary.get("terminal_blocker")
842
+ runner_blocked = summary.get("terminal_status") == "blocked" or (
843
+ isinstance(runner_blocker, dict) and runner_blocker.get("blocker_class")
844
+ )
845
+ if isinstance(child_baml_closeout, dict):
846
+ if not runner_blocked:
847
+ terminal_status = _nonempty_text(child_baml_closeout.get("terminal_status"))
848
+ if terminal_status is not None:
849
+ updated["terminal_status"] = terminal_status
850
+ verification_status = _nonempty_text(child_baml_closeout.get("verification_status"))
851
+ if verification_status is not None:
852
+ updated["verification_status"] = verification_status
853
+ next_action = _nonempty_text(child_baml_closeout.get("next_action"))
854
+ if next_action is not None:
855
+ updated["next_action"] = next_action
856
+ produced = _string_list(child_baml_closeout.get("produced_if_gates"))
857
+ if produced is not None:
858
+ updated["produced_if_gates"] = produced
859
+ dirty_paths = _string_list(child_baml_closeout.get("dirty_paths"))
860
+ if dirty_paths is not None and not updated.get("dirty_paths"):
861
+ updated["dirty_paths"] = dirty_paths
862
+
863
+ if not runner_blocked:
864
+ blocker_class = _optional_child_literal(child_baml_closeout.get("blocker_class"))
865
+ blocker_summary = _optional_child_literal(child_baml_closeout.get("blocker_summary"))
866
+ human_required = bool(child_baml_closeout.get("human_required", False))
867
+ required_inputs = _string_list(child_baml_closeout.get("required_human_inputs")) or []
868
+ if blocker_class or blocker_summary or human_required:
869
+ updated["terminal_blocker"] = {
870
+ "human_required": human_required,
871
+ "blocker_class": blocker_class,
872
+ "blocker_summary": blocker_summary,
873
+ "required_human_inputs": required_inputs,
874
+ "access_attempts": (),
875
+ }
876
+ elif updated.get("terminal_blocker") is None:
877
+ updated["terminal_blocker"] = None
878
+
879
+ sanitized_failure = _sanitize_extraction_failure(extraction_failure)
880
+ if sanitized_failure is not None:
881
+ updated["extraction_failure"] = sanitized_failure
882
+ return updated
883
+
884
+
885
+ def _nonempty_text(value: Any) -> str | None:
886
+ if value is None:
887
+ return None
888
+ text = str(value).strip()
889
+ return text or None
890
+
891
+
892
+ def _optional_child_literal(value: Any) -> str | None:
893
+ text = _nonempty_text(value)
894
+ if text is None or text.lower() in {"none", "null"}:
895
+ return None
896
+ return text
897
+
898
+
899
+ def _string_list(value: Any) -> list[str] | None:
900
+ if isinstance(value, (list, tuple)):
901
+ return [str(item) for item in value]
902
+ return None
903
+
904
+
905
+ def _sanitize_extraction_failure(value: dict[str, Any] | None) -> dict[str, str] | None:
906
+ if not isinstance(value, dict):
907
+ return None
908
+ allowed = ("reason", "source", "classification", "detail")
909
+ sanitized = {
910
+ key: str(value[key])
911
+ for key in allowed
912
+ if value.get(key) is not None and "\n" not in str(value[key])
913
+ }
914
+ return sanitized or None
915
+
916
+
917
+ def run_heartbeat_summary(
918
+ *,
919
+ log_path: Path | str | None = None,
920
+ heartbeat_path: Path | str | None = None,
921
+ pid: int | None = None,
922
+ started_monotonic: float | None = None,
923
+ started_at: str | None = None,
924
+ heartbeat_interval_seconds: int = 30,
925
+ quiet_warning_seconds: int = 600,
926
+ quiet_blocker_seconds: int = 1800,
927
+ command: list[str] | None = None,
928
+ returncode: int | None = None,
929
+ process_group_id: int | None = None,
930
+ ) -> dict[str, Any]:
931
+ log = Path(log_path) if log_path else None
932
+ heartbeat = Path(heartbeat_path) if heartbeat_path else (heartbeat_path_for_log(log) if log else None)
933
+ now = time.time()
934
+ log_exists = bool(log and log.exists())
935
+ log_size = log.stat().st_size if log_exists and log else 0
936
+ log_mtime = log.stat().st_mtime if log_exists and log else None
937
+ seconds_since_log_update = int(max(0, now - log_mtime)) if log_mtime else None
938
+ quiet_level = _quiet_level(seconds_since_log_update, quiet_warning_seconds, quiet_blocker_seconds)
939
+ process_alive = _pid_is_live(pid) if pid else False
940
+ cpu_percent = _process_tree_cpu_percent(pid, process_group_id) if process_alive else None
941
+ liveness_class = _liveness_class(quiet_level, process_alive, returncode, pid, cpu_percent)
942
+ quiet_unknown_grace_seconds = _quiet_unknown_stale_grace_seconds(
943
+ quiet_level,
944
+ cpu_percent,
945
+ heartbeat_interval_seconds,
946
+ quiet_blocker_seconds,
947
+ )
948
+ stalled_suspect = _stalled_suspect(
949
+ liveness_class,
950
+ quiet_level,
951
+ seconds_since_log_update,
952
+ quiet_blocker_seconds,
953
+ quiet_unknown_grace_seconds,
954
+ )
955
+ elapsed_seconds = int(time.monotonic() - started_monotonic) if started_monotonic is not None else None
956
+ last_log_excerpt = _last_log_excerpt(log)
957
+ heartbeat_status = _heartbeat_status(quiet_level, process_alive, returncode, pid)
958
+ summary: dict[str, Any] = {
959
+ "timestamp": utc_now(),
960
+ "pid": pid,
961
+ "process_group_id": process_group_id,
962
+ "process_alive": process_alive,
963
+ "returncode": returncode,
964
+ "heartbeat_status": heartbeat_status,
965
+ "event_kind": "terminal_exit" if returncode is not None else ("stale_heartbeat" if heartbeat_status == "stale" else "heartbeat"),
966
+ "started_at": started_at,
967
+ "elapsed_seconds": elapsed_seconds,
968
+ "log_path": str(log) if log else None,
969
+ "log_exists": log_exists,
970
+ "log_size": log_size,
971
+ "log_mtime": _format_epoch(log_mtime),
972
+ "seconds_since_log_update": seconds_since_log_update,
973
+ "heartbeat_interval_seconds": heartbeat_interval_seconds,
974
+ "quiet_warning_seconds": quiet_warning_seconds,
975
+ "quiet_blocker_seconds": quiet_blocker_seconds,
976
+ "quiet_level": quiet_level,
977
+ "cpu_percent": cpu_percent,
978
+ "liveness_class": liveness_class,
979
+ "quiet_unknown_grace_seconds": quiet_unknown_grace_seconds,
980
+ "stalled_suspect": stalled_suspect,
981
+ "recommended_action": _recommended_action(quiet_level, process_alive),
982
+ "nudge_prompt": _nudge_prompt(log, seconds_since_log_update, elapsed_seconds),
983
+ "last_log_excerpt": last_log_excerpt,
984
+ "last_log_excerpt_hash": _stable_excerpt_hash(last_log_excerpt),
985
+ }
986
+ if heartbeat:
987
+ summary["heartbeat_path"] = str(heartbeat)
988
+ if command:
989
+ summary["command"] = command
990
+ return {key: value for key, value in summary.items() if value is not None}
991
+
992
+
993
+ def write_run_heartbeat(path: Path | str | None, summary: dict[str, Any]) -> None:
994
+ if not path:
995
+ return
996
+ target = Path(path)
997
+ target.parent.mkdir(parents=True, exist_ok=True)
998
+ target.write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n", encoding="utf-8")
999
+
1000
+
1001
+ def build_notification_payload(
1002
+ *,
1003
+ repo: Path,
1004
+ roadmap: Path,
1005
+ monitor_status: dict[str, Any],
1006
+ state_summary: dict[str, Any],
1007
+ ) -> dict[str, Any]:
1008
+ terminal = state_summary.get("terminal_summary") or state_summary.get("latest_terminal_summary") or {}
1009
+ heartbeat = state_summary.get("latest_heartbeat") or {}
1010
+ metrics_summary = monitor_status.get("metrics_summary") if isinstance(monitor_status.get("metrics_summary"), dict) else {}
1011
+ payload = {
1012
+ "timestamp": utc_now(),
1013
+ "repo": str(repo),
1014
+ "roadmap": str(roadmap),
1015
+ "event_kind": monitor_status.get("event_kind"),
1016
+ "monitor_status": monitor_status,
1017
+ "current_phase": state_summary.get("current_phase"),
1018
+ "current_status": monitor_status.get("current_status"),
1019
+ "human_required": state_summary.get("human_required", False),
1020
+ "blocker_class": state_summary.get("blocker_class"),
1021
+ "blocker_summary": state_summary.get("blocker_summary"),
1022
+ "required_human_inputs": list(state_summary.get("required_human_inputs") or ()),
1023
+ "latest_heartbeat": _compact_heartbeat(heartbeat if isinstance(heartbeat, dict) else {}),
1024
+ "terminal_summary": terminal if isinstance(terminal, dict) else {},
1025
+ "state_path": state_summary.get("state_path"),
1026
+ "event_path": state_summary.get("event_path"),
1027
+ "tui_handoff_path": state_summary.get("tui_handoff_path"),
1028
+ "run_log_path": heartbeat.get("log_path") if isinstance(heartbeat, dict) else None,
1029
+ "recommended_action": monitor_status.get("recommended_action"),
1030
+ "not_run_ratio": metrics_summary.get("not_run_ratio"),
1031
+ "not_run_count": metrics_summary.get("not_run_count"),
1032
+ "sample_size": metrics_summary.get("sample_size"),
1033
+ "threshold": metrics_summary.get("threshold"),
1034
+ }
1035
+ return {field: payload[field] for field in NOTIFICATION_PAYLOAD_FIELDS}
1036
+
1037
+
1038
+ def run_notification_command(command: str | None, payload: dict[str, Any]) -> dict[str, Any] | None:
1039
+ if not command:
1040
+ return None
1041
+ try:
1042
+ result = subprocess.run(
1043
+ command,
1044
+ input=json.dumps(payload, indent=2, sort_keys=True) + "\n",
1045
+ text=True,
1046
+ capture_output=True,
1047
+ shell=True,
1048
+ timeout=30,
1049
+ )
1050
+ except Exception as exc:
1051
+ return {"command": command, "status": "failed", "error": str(exc)}
1052
+ return {
1053
+ "command": command,
1054
+ "status": "ok" if result.returncode == 0 else "failed",
1055
+ "returncode": result.returncode,
1056
+ }
1057
+
1058
+
1059
+ def operator_halt_metadata(repo: Path) -> dict[str, object]:
1060
+ path = stop_file(repo)
1061
+ reason = None
1062
+ try:
1063
+ text = path.read_text(encoding="utf-8").strip()
1064
+ reason = text or None
1065
+ except OSError:
1066
+ reason = None
1067
+ return {"operator_halt": True, "stop_file": str(path), "reason": reason}
1068
+
1069
+
1070
+ def _quiet_level(seconds_since_log_update: int | None, warning: int, blocker: int) -> str:
1071
+ if seconds_since_log_update is None:
1072
+ return "quiet"
1073
+ if seconds_since_log_update >= blocker:
1074
+ return "stale"
1075
+ if seconds_since_log_update >= warning:
1076
+ return "quiet"
1077
+ return "active"
1078
+
1079
+
1080
+ def _recommended_action(quiet_level: str, process_alive: bool) -> str:
1081
+ if not process_alive:
1082
+ return "Inspect the final log and event ledger before resuming."
1083
+ if quiet_level == "stale":
1084
+ return "Inspect the log and paste the nudge prompt into the owning TUI or child session if available."
1085
+ if quiet_level == "quiet":
1086
+ return "Continue observing; paste the nudge prompt only if operator judgment says the child may be wedged."
1087
+ return "No action needed."
1088
+
1089
+
1090
+ def _heartbeat_status(quiet_level: str, process_alive: bool, returncode: int | None, pid: int | None) -> str:
1091
+ if returncode is not None:
1092
+ return "exited"
1093
+ if pid and not process_alive:
1094
+ return "exited"
1095
+ return quiet_level
1096
+
1097
+
1098
+ def _liveness_class(
1099
+ quiet_level: str,
1100
+ process_alive: bool,
1101
+ returncode: int | None,
1102
+ pid: int | None,
1103
+ cpu_percent: float | None,
1104
+ ) -> str:
1105
+ if returncode is not None:
1106
+ return "exited"
1107
+ if pid and not process_alive:
1108
+ return "exited"
1109
+ if quiet_level == "active":
1110
+ return "active_output"
1111
+ if cpu_percent is not None and cpu_percent > CPU_ACTIVE_THRESHOLD_PERCENT:
1112
+ return "cpu_active_quiet"
1113
+ if quiet_level == "stale" and cpu_percent is not None:
1114
+ return "suspect_stalled"
1115
+ if quiet_level == "stale":
1116
+ return "quiet_unknown"
1117
+ return "quiet_unknown"
1118
+
1119
+
1120
+ def _quiet_unknown_stale_grace_seconds(
1121
+ quiet_level: str,
1122
+ cpu_percent: float | None,
1123
+ heartbeat_interval_seconds: int,
1124
+ quiet_blocker_seconds: int,
1125
+ ) -> int | None:
1126
+ if quiet_level != "stale" or cpu_percent is not None:
1127
+ return None
1128
+ return max(heartbeat_interval_seconds, min(quiet_blocker_seconds, 300))
1129
+
1130
+
1131
+ def _stalled_suspect(
1132
+ liveness_class: str,
1133
+ quiet_level: str,
1134
+ seconds_since_log_update: int | None,
1135
+ quiet_blocker_seconds: int,
1136
+ quiet_unknown_grace_seconds: int | None,
1137
+ ) -> bool:
1138
+ if liveness_class == "suspect_stalled":
1139
+ return True
1140
+ if quiet_level != "stale" or quiet_unknown_grace_seconds is None:
1141
+ return False
1142
+ if seconds_since_log_update is None:
1143
+ return False
1144
+ return seconds_since_log_update >= quiet_blocker_seconds + quiet_unknown_grace_seconds
1145
+
1146
+
1147
+ def _nudge_prompt(log: Path | None, quiet_seconds: int | None, elapsed_seconds: int | None) -> str:
1148
+ prompt = (
1149
+ "Status check: the supervisor has not observed child log output"
1150
+ f" for {quiet_seconds if quiet_seconds is not None else 'unknown'} seconds"
1151
+ f" after {elapsed_seconds if elapsed_seconds is not None else 'unknown'} seconds elapsed."
1152
+ " Please report current status, last tool/action, whether you are waiting on a subagent/tool,"
1153
+ " and whether human action is required."
1154
+ )
1155
+ if log:
1156
+ prompt += f" Latest log: {log}"
1157
+ return prompt
1158
+
1159
+
1160
+ def _last_log_excerpt(log: Path | None, max_chars: int = 1200) -> str | None:
1161
+ if not log or not log.exists():
1162
+ return None
1163
+ try:
1164
+ text = log.read_text(encoding="utf-8", errors="replace")
1165
+ except OSError:
1166
+ return None
1167
+ return text[-max_chars:] if text else None
1168
+
1169
+
1170
+ def _stable_excerpt_hash(text: str | None) -> str | None:
1171
+ if not text:
1172
+ return None
1173
+ import hashlib
1174
+
1175
+ return hashlib.sha256(text.encode("utf-8", errors="replace")).hexdigest()
1176
+
1177
+
1178
+ def _compact_heartbeat(heartbeat: dict[str, Any]) -> dict[str, Any]:
1179
+ keys = (
1180
+ "timestamp",
1181
+ "pid",
1182
+ "process_alive",
1183
+ "returncode",
1184
+ "heartbeat_status",
1185
+ "event_kind",
1186
+ "elapsed_seconds",
1187
+ "seconds_since_log_update",
1188
+ "quiet_level",
1189
+ "cpu_percent",
1190
+ "liveness_class",
1191
+ "stalled_suspect",
1192
+ "recommended_action",
1193
+ "log_path",
1194
+ "heartbeat_path",
1195
+ "last_log_excerpt_hash",
1196
+ )
1197
+ return {key: heartbeat[key] for key in keys if key in heartbeat}
1198
+
1199
+
1200
+ def _format_epoch(value: float | None) -> str | None:
1201
+ if value is None:
1202
+ return None
1203
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(value))
1204
+
1205
+
1206
+ def _pid_is_live(pid: int | None) -> bool:
1207
+ if not pid or pid <= 0:
1208
+ return False
1209
+ try:
1210
+ os.kill(pid, 0)
1211
+ except ProcessLookupError:
1212
+ return False
1213
+ except PermissionError:
1214
+ return True
1215
+ return True
1216
+
1217
+
1218
+ def _process_tree_cpu_percent(pid: int | None, process_group_id: int | None) -> float | None:
1219
+ values = [
1220
+ value
1221
+ for value in (
1222
+ _process_cpu_percent(pid),
1223
+ _process_group_cpu_percent(process_group_id),
1224
+ )
1225
+ if value is not None
1226
+ ]
1227
+ if not values:
1228
+ return None
1229
+ return max(values)
1230
+
1231
+
1232
+ def _process_cpu_percent(pid: int | None) -> float | None:
1233
+ if not pid or pid <= 0:
1234
+ return None
1235
+ try:
1236
+ result = subprocess.run(
1237
+ ["ps", "-o", "%cpu=", "-p", str(pid)],
1238
+ capture_output=True,
1239
+ text=True,
1240
+ timeout=1,
1241
+ check=False,
1242
+ )
1243
+ except (OSError, subprocess.TimeoutExpired):
1244
+ return None
1245
+ if result.returncode != 0:
1246
+ return None
1247
+ first = (result.stdout or "").strip().splitlines()
1248
+ if not first:
1249
+ return None
1250
+ try:
1251
+ return float(first[0].strip())
1252
+ except ValueError:
1253
+ return None
1254
+
1255
+
1256
+ def _process_group_cpu_percent(process_group_id: int | None) -> float | None:
1257
+ if not process_group_id or process_group_id <= 0:
1258
+ return None
1259
+ try:
1260
+ result = subprocess.run(
1261
+ ["ps", "-o", "%cpu=", "-g", str(process_group_id)],
1262
+ capture_output=True,
1263
+ text=True,
1264
+ timeout=1,
1265
+ check=False,
1266
+ )
1267
+ except (OSError, subprocess.TimeoutExpired):
1268
+ return None
1269
+ if result.returncode != 0:
1270
+ return None
1271
+ total = 0.0
1272
+ seen = False
1273
+ for line in (result.stdout or "").strip().splitlines():
1274
+ if not line.strip():
1275
+ continue
1276
+ try:
1277
+ total += float(line.strip())
1278
+ except ValueError:
1279
+ continue
1280
+ seen = True
1281
+ return total if seen else None
1282
+
1283
+
1284
+ def _slug(value: str) -> str:
1285
+ slug = re.sub(r"[^A-Za-z0-9._-]+", "-", value).strip("-").lower()
1286
+ return slug or "unknown"
1287
+
1288
+
1289
+ def _redact_hotfix_reason(reason: str) -> str:
1290
+ return re.sub(r"\s+", " ", reason).strip()[:200]