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,1086 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import os
6
+ import sys
7
+ import threading
8
+ import time
9
+ import uuid
10
+ from dataclasses import asdict, dataclass, field
11
+ from datetime import datetime, timedelta, timezone
12
+ from http import HTTPStatus
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from socketserver import TCPServer
15
+ from typing import Any
16
+ from urllib import error, request
17
+ from urllib.parse import urlparse
18
+ import ipaddress
19
+
20
+
21
+ ACK_POLICY_TOOL_REQUIRED = "tool_ack_required"
22
+ CLAUDE_ROUTE_NAMES = {"claude_channel", "claude_agent_view", "claude_print"}
23
+ CLAUDE_ROUTE_STATUSES = {"received", "working", "needs_permission", "needs_input", "blocked", "done", "error", "stale"}
24
+ CLAUDE_AUTH_POSTURES = {"subscription_local", "api_key", "unknown"}
25
+ CLAUDE_BILLING_POSTURES = {"subscription_included", "api_key_billed", "usage_credit", "unknown"}
26
+ REPLY_STATUSES = {"received", "working", "blocked", "done", "error"}
27
+ FORBIDDEN_ATTACHMENT_FIELDS = {
28
+ "content",
29
+ "data",
30
+ "text",
31
+ "payload",
32
+ "secret",
33
+ "token",
34
+ "api_key",
35
+ "private_key",
36
+ "oauth",
37
+ "keychain",
38
+ "local_env",
39
+ "provider_payload",
40
+ "terminal_transcript",
41
+ }
42
+ FORBIDDEN_ROUTE_RESULT_VALUE_MARKERS = (
43
+ "authorization:",
44
+ "bearer ",
45
+ "api_key=",
46
+ "secret=",
47
+ "password=",
48
+ "oauth",
49
+ "keychain",
50
+ "local env value",
51
+ "provider payload",
52
+ "raw provider payload",
53
+ "terminal transcript",
54
+ "raw terminal",
55
+ "raw-terminal",
56
+ "raw prompt",
57
+ "raw tool input",
58
+ "sk-ant-",
59
+ "ghp_",
60
+ "github_pat_",
61
+ )
62
+ FORBIDDEN_PERMISSION_FIELDS = {
63
+ "args",
64
+ "arguments",
65
+ "content",
66
+ "data",
67
+ "input",
68
+ "payload",
69
+ "raw_input",
70
+ "secret",
71
+ "token",
72
+ "api_key",
73
+ "private_key",
74
+ "oauth",
75
+ "keychain",
76
+ "local_env",
77
+ "provider_payload",
78
+ "terminal_transcript",
79
+ }
80
+ PERMISSION_VERDICTS = {"allow", "deny"}
81
+ SESSION_STATES = {"disconnected", "starting", "ready", "needs_permission", "needs_input", "blocked", "stopped", "stale"}
82
+ CHANNEL_HEALTH_STATES = {"disconnected", "starting", "ready", "needs_permission", "needs_input", "blocked", "stopped"}
83
+ CLIENT_READY_STATES = {"starting", "ready", "needs_permission", "needs_input"}
84
+ CLIENT_FINAL_REPLY_STATUSES = {"blocked", "done", "error"}
85
+
86
+
87
+ class LoopbackThreadingHTTPServer(ThreadingHTTPServer):
88
+ daemon_threads = True
89
+ block_on_close = False
90
+
91
+ def server_bind(self) -> None:
92
+ TCPServer.server_bind(self)
93
+ host, port = self.server_address[:2]
94
+ self.server_name = str(host)
95
+ self.server_port = int(port)
96
+
97
+
98
+ @dataclass(frozen=True)
99
+ class ChannelEventEnvelope:
100
+ event_id: str
101
+ session_id: str
102
+ sender: str
103
+ content: str
104
+ attachments: tuple[dict[str, Any], ...]
105
+ created_at: str
106
+ ack_policy: str = ACK_POLICY_TOOL_REQUIRED
107
+
108
+ def to_json(self) -> dict[str, Any]:
109
+ data = asdict(self)
110
+ data["attachments"] = list(self.attachments)
111
+ return data
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class ChannelReplyPayload:
116
+ event_id: str
117
+ status: str
118
+ text: str = ""
119
+ artifacts: tuple[dict[str, Any], ...] = ()
120
+ error: str | None = None
121
+ final: bool = False
122
+
123
+ def to_json(self) -> dict[str, Any]:
124
+ data = asdict(self)
125
+ data["artifacts"] = list(self.artifacts)
126
+ return data
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class ClaudeRouteResult:
131
+ route: str
132
+ session_id: str
133
+ event_id: str
134
+ status: str
135
+ text: str = ""
136
+ artifacts: tuple[dict[str, Any], ...] = ()
137
+ auth_posture: str = "unknown"
138
+ billing_posture: str = "unknown"
139
+ trust_state: dict[str, Any] = field(default_factory=dict)
140
+ permission_state: dict[str, Any] = field(default_factory=dict)
141
+ warnings: tuple[str, ...] = ()
142
+ evidence_refs: tuple[dict[str, Any], ...] = ()
143
+
144
+ def __post_init__(self) -> None:
145
+ _require_literal(self.route, CLAUDE_ROUTE_NAMES, "Claude route")
146
+ _require_literal(self.status, CLAUDE_ROUTE_STATUSES, "Claude route status")
147
+ _require_literal(self.auth_posture, CLAUDE_AUTH_POSTURES, "Claude auth posture")
148
+ _require_literal(self.billing_posture, CLAUDE_BILLING_POSTURES, "Claude billing posture")
149
+ for field_name in ("session_id", "event_id"):
150
+ value = getattr(self, field_name)
151
+ if not isinstance(value, str) or not value:
152
+ raise ValueError(f"{field_name} is required")
153
+ object.__setattr__(self, "text", _metadata_only_text(self.text, "text"))
154
+ object.__setattr__(self, "artifacts", tuple(_metadata_only_route_objects(self.artifacts, "artifacts")))
155
+ object.__setattr__(self, "trust_state", _metadata_summary(self.trust_state))
156
+ object.__setattr__(self, "permission_state", _metadata_summary(self.permission_state))
157
+ object.__setattr__(self, "warnings", tuple(_metadata_only_warnings(self.warnings)))
158
+ object.__setattr__(self, "evidence_refs", tuple(_metadata_only_route_objects(self.evidence_refs, "evidence_refs")))
159
+
160
+ def to_json(self) -> dict[str, Any]:
161
+ data = asdict(self)
162
+ data["artifacts"] = list(self.artifacts)
163
+ data["warnings"] = list(self.warnings)
164
+ data["evidence_refs"] = list(self.evidence_refs)
165
+ return data
166
+
167
+
168
+ @dataclass(frozen=True)
169
+ class PermissionRequestEnvelope:
170
+ request_id: str
171
+ session_id: str
172
+ event_id: str | None
173
+ tool_name: str
174
+ description: str
175
+ input_preview: str
176
+ risk_class: str
177
+ audit_ref: str
178
+ requested_at: str
179
+
180
+ def to_json(self) -> dict[str, Any]:
181
+ return asdict(self)
182
+
183
+
184
+ @dataclass(frozen=True)
185
+ class PermissionVerdictPayload:
186
+ request_id: str
187
+ verdict: str
188
+ actor: str
189
+ reason: str
190
+ decided_at: str
191
+
192
+ def to_json(self) -> dict[str, Any]:
193
+ return asdict(self)
194
+
195
+
196
+ @dataclass(frozen=True)
197
+ class PermissionAuditEntry:
198
+ request_id: str
199
+ session_id: str
200
+ event_id: str | None
201
+ verdict: str
202
+ actor: str
203
+ reason: str
204
+ audit_ref: str
205
+ decided_at: str
206
+
207
+ def to_json(self) -> dict[str, Any]:
208
+ return asdict(self)
209
+
210
+
211
+ @dataclass
212
+ class SessionRegistryRecord:
213
+ session_id: str
214
+ adapter: str
215
+ cwd: str
216
+ state: str = "starting"
217
+ auth_posture: dict[str, Any] = field(default_factory=dict)
218
+ trust_state: dict[str, Any] = field(default_factory=dict)
219
+ channel_health: str = "starting"
220
+ last_event_id: str | None = None
221
+ last_reply_at: str | None = None
222
+ permission_state: dict[str, Any] = field(default_factory=lambda: {"pending": 0, "last_verdict": None})
223
+ updated_at: str = field(default_factory=lambda: _utc_now())
224
+ process_pid: int | None = None
225
+
226
+ def to_json(self) -> dict[str, Any]:
227
+ return {
228
+ "session_id": self.session_id,
229
+ "adapter": self.adapter,
230
+ "cwd": self.cwd,
231
+ "state": self.state,
232
+ "auth_posture": dict(self.auth_posture),
233
+ "trust_state": dict(self.trust_state),
234
+ "channel_health": self.channel_health,
235
+ "last_event_id": self.last_event_id,
236
+ "last_reply_at": self.last_reply_at,
237
+ "permission_state": dict(self.permission_state),
238
+ }
239
+
240
+
241
+ @dataclass
242
+ class ChannelEventState:
243
+ envelope: ChannelEventEnvelope
244
+ replies: list[ChannelReplyPayload] = field(default_factory=list)
245
+ acknowledged: bool = False
246
+ acknowledged_at: str | None = None
247
+
248
+ def to_json(self) -> dict[str, Any]:
249
+ return {
250
+ **self.envelope.to_json(),
251
+ "acknowledged": self.acknowledged,
252
+ "acknowledged_at": self.acknowledged_at,
253
+ "replies": [reply.to_json() for reply in self.replies],
254
+ }
255
+
256
+
257
+ class ChannelSidecar:
258
+ def __init__(
259
+ self,
260
+ *,
261
+ bearer_token: str | None = None,
262
+ allowed_senders: set[str] | None = None,
263
+ allowed_verdict_actors: set[str] | None = None,
264
+ ) -> None:
265
+ if bearer_token == "":
266
+ raise ValueError("bearer_token must not be empty")
267
+ self._bearer_token = bearer_token
268
+ self._allowed_senders = set(allowed_senders or set())
269
+ self._allowed_verdict_actors = set(allowed_verdict_actors or set())
270
+ self._events_by_session: dict[str, list[str]] = {}
271
+ self._events_by_id: dict[str, ChannelEventState] = {}
272
+ self._permission_requests_by_session: dict[str, list[str]] = {}
273
+ self._permission_requests_by_id: dict[str, PermissionRequestEnvelope] = {}
274
+ self._permission_audit_by_session: dict[str, list[PermissionAuditEntry]] = {}
275
+ self._sessions_by_id: dict[str, SessionRegistryRecord] = {}
276
+ self._hook_events_by_session: dict[str, list[dict[str, Any]]] = {}
277
+ self._lock = threading.RLock()
278
+
279
+ @property
280
+ def bearer_token_configured(self) -> bool:
281
+ return self._bearer_token is not None
282
+
283
+ def authenticate(self, authorization: str | None) -> HTTPStatus | None:
284
+ if self._bearer_token is None:
285
+ return None
286
+ if not authorization:
287
+ return HTTPStatus.UNAUTHORIZED
288
+ if authorization != f"Bearer {self._bearer_token}":
289
+ return HTTPStatus.FORBIDDEN
290
+ return None
291
+
292
+ def create_message(
293
+ self,
294
+ session_id: str,
295
+ *,
296
+ sender: str,
297
+ content: str,
298
+ attachments: list[dict[str, Any]] | None = None,
299
+ ) -> ChannelEventEnvelope:
300
+ if not session_id:
301
+ raise ValueError("session_id is required")
302
+ if not isinstance(sender, str) or not sender:
303
+ raise ValueError("sender is required")
304
+ if self._allowed_senders and sender not in self._allowed_senders:
305
+ raise PermissionError("sender is not allowed")
306
+ if not isinstance(content, str):
307
+ raise ValueError("content must be a string")
308
+ clean_attachments = tuple(_metadata_only_attachments(attachments or []))
309
+ envelope = ChannelEventEnvelope(
310
+ event_id=str(uuid.uuid4()),
311
+ session_id=session_id,
312
+ sender=sender,
313
+ content=content,
314
+ attachments=clean_attachments,
315
+ created_at=_utc_now(),
316
+ )
317
+ with self._lock:
318
+ self._events_by_id[envelope.event_id] = ChannelEventState(envelope=envelope)
319
+ self._events_by_session.setdefault(session_id, []).append(envelope.event_id)
320
+ if session_id in self._sessions_by_id:
321
+ record = self._sessions_by_id[session_id]
322
+ record.last_event_id = envelope.event_id
323
+ record.channel_health = "starting" if record.channel_health == "disconnected" else record.channel_health
324
+ record.updated_at = _utc_now()
325
+ return envelope
326
+
327
+ def list_events(self, session_id: str) -> list[dict[str, Any]]:
328
+ with self._lock:
329
+ event_ids = list(self._events_by_session.get(session_id, []))
330
+ return [self._events_by_id[event_id].to_json() for event_id in event_ids]
331
+
332
+ def get_event(self, event_id: str) -> dict[str, Any] | None:
333
+ with self._lock:
334
+ state = self._events_by_id.get(event_id)
335
+ return state.to_json() if state else None
336
+
337
+ def record_reply(self, payload: ChannelReplyPayload | dict[str, Any]) -> dict[str, Any]:
338
+ reply = payload if isinstance(payload, ChannelReplyPayload) else _reply_payload(payload)
339
+ with self._lock:
340
+ state = self._events_by_id.get(reply.event_id)
341
+ if state is None:
342
+ raise KeyError(f"unknown event_id: {reply.event_id}")
343
+ state.replies.append(reply)
344
+ self._update_session_from_reply(state.envelope.session_id, reply)
345
+ if reply.final:
346
+ state.acknowledged = True
347
+ state.acknowledged_at = _utc_now()
348
+ return state.to_json()
349
+
350
+ def record_status(self, payload: ChannelReplyPayload | dict[str, Any]) -> dict[str, Any]:
351
+ return self.record_reply(payload)
352
+
353
+ def create_permission_request(self, session_id: str, payload: dict[str, Any]) -> PermissionRequestEnvelope:
354
+ with self._lock:
355
+ event_id = self._sessions_by_id.get(session_id).last_event_id if session_id in self._sessions_by_id else None
356
+ request_payload = _permission_request_payload(session_id, payload, event_id=event_id)
357
+ self._permission_requests_by_id[request_payload.request_id] = request_payload
358
+ self._permission_requests_by_session.setdefault(session_id, []).append(request_payload.request_id)
359
+ self._update_permission_state(session_id)
360
+ return request_payload
361
+
362
+ def list_permission_requests(self, session_id: str) -> list[dict[str, Any]]:
363
+ with self._lock:
364
+ request_ids = list(self._permission_requests_by_session.get(session_id, []))
365
+ return [self._permission_requests_by_id[request_id].to_json() for request_id in request_ids]
366
+
367
+ def record_permission_verdict(self, session_id: str, payload: dict[str, Any]) -> dict[str, Any]:
368
+ verdict = _permission_verdict_payload(payload)
369
+ with self._lock:
370
+ request_payload = self._permission_requests_by_id.get(verdict.request_id)
371
+ if request_payload is None or request_payload.session_id != session_id:
372
+ raise KeyError(f"unknown request_id: {verdict.request_id}")
373
+ if self._allowed_verdict_actors and verdict.actor not in self._allowed_verdict_actors:
374
+ raise PermissionError("verdict actor is not allowed")
375
+ audit_ref = f"claude_permission:{session_id}:{verdict.request_id}"
376
+ audit_entry = PermissionAuditEntry(
377
+ request_id=verdict.request_id,
378
+ session_id=session_id,
379
+ event_id=request_payload.event_id,
380
+ verdict=verdict.verdict,
381
+ actor=verdict.actor,
382
+ reason=verdict.reason,
383
+ audit_ref=audit_ref,
384
+ decided_at=verdict.decided_at,
385
+ )
386
+ self._permission_audit_by_session.setdefault(session_id, []).append(audit_entry)
387
+ self._update_permission_state(session_id)
388
+ if session_id in self._sessions_by_id:
389
+ if verdict.verdict == "deny":
390
+ self._sessions_by_id[session_id].state = "blocked"
391
+ self._sessions_by_id[session_id].channel_health = "blocked"
392
+ elif self._sessions_by_id[session_id].permission_state.get("pending") == 0:
393
+ self._sessions_by_id[session_id].state = "ready"
394
+ self._sessions_by_id[session_id].channel_health = "ready"
395
+ return audit_entry.to_json()
396
+
397
+ def list_permission_audit(self, session_id: str) -> list[dict[str, Any]]:
398
+ with self._lock:
399
+ return [entry.to_json() for entry in self._permission_audit_by_session.get(session_id, [])]
400
+
401
+ def register_session(self, payload: dict[str, Any]) -> dict[str, Any]:
402
+ session_id = _required_string(payload, "session_id")
403
+ adapter = _required_string(payload, "adapter")
404
+ cwd = _required_string(payload, "cwd")
405
+ state = str(payload.get("state") or "starting")
406
+ channel_health = str(payload.get("channel_health") or "starting")
407
+ if state not in SESSION_STATES:
408
+ raise ValueError("state must be one of: " + ", ".join(sorted(SESSION_STATES)))
409
+ if channel_health not in CHANNEL_HEALTH_STATES:
410
+ raise ValueError("channel_health must be one of: " + ", ".join(sorted(CHANNEL_HEALTH_STATES)))
411
+ record = SessionRegistryRecord(
412
+ session_id=session_id,
413
+ adapter=adapter,
414
+ cwd=cwd,
415
+ state=state,
416
+ auth_posture=_metadata_summary(payload.get("auth_posture") or {}),
417
+ trust_state=_metadata_summary(payload.get("trust_state") or {}),
418
+ channel_health=channel_health,
419
+ process_pid=payload.get("process_pid") if isinstance(payload.get("process_pid"), int) else None,
420
+ )
421
+ with self._lock:
422
+ self._sessions_by_id[session_id] = record
423
+ self._update_permission_state(session_id)
424
+ return record.to_json()
425
+
426
+ def list_sessions(self) -> list[dict[str, Any]]:
427
+ with self._lock:
428
+ return [record.to_json() for record in self._sessions_by_id.values()]
429
+
430
+ def get_session(self, session_id: str) -> dict[str, Any] | None:
431
+ with self._lock:
432
+ record = self._sessions_by_id.get(session_id)
433
+ return record.to_json() if record else None
434
+
435
+ def update_session_state(self, session_id: str, *, state: str | None = None, channel_health: str | None = None) -> dict[str, Any]:
436
+ with self._lock:
437
+ record = self._sessions_by_id.get(session_id)
438
+ if record is None:
439
+ raise KeyError(f"unknown session_id: {session_id}")
440
+ if state is not None:
441
+ if state not in SESSION_STATES:
442
+ raise ValueError("state must be one of: " + ", ".join(sorted(SESSION_STATES)))
443
+ record.state = state
444
+ if channel_health is not None:
445
+ if channel_health not in CHANNEL_HEALTH_STATES:
446
+ raise ValueError("channel_health must be one of: " + ", ".join(sorted(CHANNEL_HEALTH_STATES)))
447
+ record.channel_health = channel_health
448
+ record.updated_at = _utc_now()
449
+ return record.to_json()
450
+
451
+ def mark_stale_sessions(self, *, older_than_seconds: int, live_pids: set[int] | None = None) -> list[dict[str, Any]]:
452
+ cutoff = datetime.now(timezone.utc) - timedelta(seconds=older_than_seconds)
453
+ stale: list[dict[str, Any]] = []
454
+ with self._lock:
455
+ for record in self._sessions_by_id.values():
456
+ updated_at = _parse_utc(record.updated_at)
457
+ pid_missing = record.process_pid is not None and live_pids is not None and record.process_pid not in live_pids
458
+ if record.state not in {"stopped", "stale"} and (updated_at < cutoff or pid_missing):
459
+ record.state = "stale"
460
+ record.channel_health = "stopped"
461
+ record.updated_at = _utc_now()
462
+ stale.append(record.to_json())
463
+ return stale
464
+
465
+ def record_hook_event(self, session_id: str, payload: dict[str, Any]) -> dict[str, Any]:
466
+ hook_event = {
467
+ "event_id": str(uuid.uuid4()),
468
+ "session_id": session_id,
469
+ "hook": _required_string(payload, "hook"),
470
+ "cwd": str(payload.get("cwd") or ""),
471
+ "permission_mode": str(payload.get("permission_mode") or ""),
472
+ "received_at": _utc_now(),
473
+ }
474
+ with self._lock:
475
+ self._hook_events_by_session.setdefault(session_id, []).append(hook_event)
476
+ if session_id in self._sessions_by_id:
477
+ record = self._sessions_by_id[session_id]
478
+ if hook_event["hook"] == "Notification":
479
+ if record.permission_state.get("pending", 0) > 0:
480
+ record.state = "needs_permission"
481
+ record.channel_health = "needs_permission"
482
+ else:
483
+ record.state = "needs_input"
484
+ record.channel_health = "needs_input"
485
+ record.updated_at = hook_event["received_at"]
486
+ return hook_event
487
+
488
+ def _update_session_from_reply(self, session_id: str, reply: ChannelReplyPayload) -> None:
489
+ record = self._sessions_by_id.get(session_id)
490
+ if record is None:
491
+ return
492
+ record.last_event_id = reply.event_id
493
+ record.last_reply_at = _utc_now()
494
+ if reply.status in {"blocked", "error"}:
495
+ record.state = "blocked"
496
+ record.channel_health = "blocked"
497
+ elif reply.status in {"received", "working", "done"}:
498
+ record.state = "ready"
499
+ record.channel_health = "ready"
500
+ record.updated_at = record.last_reply_at
501
+
502
+ def _update_permission_state(self, session_id: str) -> None:
503
+ record = self._sessions_by_id.get(session_id)
504
+ if record is None:
505
+ return
506
+ request_ids = self._permission_requests_by_session.get(session_id, [])
507
+ audit = self._permission_audit_by_session.get(session_id, [])
508
+ decided = {entry.request_id for entry in audit}
509
+ record.permission_state = {
510
+ "pending": len([request_id for request_id in request_ids if request_id not in decided]),
511
+ "last_verdict": audit[-1].verdict if audit else None,
512
+ "last_request_id": request_ids[-1] if request_ids else None,
513
+ "last_audit_ref": audit[-1].audit_ref if audit else None,
514
+ }
515
+ if record.permission_state["pending"] > 0 and record.state not in {"blocked", "stopped", "stale"}:
516
+ record.state = "needs_permission"
517
+ record.channel_health = "needs_permission"
518
+ record.updated_at = _utc_now()
519
+
520
+
521
+ class ChannelSidecarClientError(RuntimeError):
522
+ def __init__(self, reason: str, *, status_code: int | None = None) -> None:
523
+ super().__init__(reason)
524
+ self.reason = reason
525
+ self.status_code = status_code
526
+
527
+
528
+ class ChannelSidecarClient:
529
+ def __init__(
530
+ self,
531
+ *,
532
+ base_url: str,
533
+ session_id: str,
534
+ sender: str = "phase-loop",
535
+ bearer_token: str | None = None,
536
+ timeout_seconds: float = 60,
537
+ poll_interval_seconds: float = 0.25,
538
+ opener: Any | None = None,
539
+ ) -> None:
540
+ if not is_loopback_http_url(base_url):
541
+ raise ValueError("claude channel client requires a loopback http base_url")
542
+ if not session_id:
543
+ raise ValueError("session_id is required")
544
+ if not sender:
545
+ raise ValueError("sender is required")
546
+ self.base_url = base_url.rstrip("/")
547
+ self.session_id = session_id
548
+ self.sender = sender
549
+ self._bearer_token = bearer_token
550
+ self.timeout_seconds = timeout_seconds
551
+ self.poll_interval_seconds = poll_interval_seconds
552
+ self._opener = opener or request.urlopen
553
+
554
+ def preflight(self) -> dict[str, Any]:
555
+ session = self._request_json("GET", f"/sessions/{self.session_id}")
556
+ state = str(session.get("state") or "")
557
+ health = str(session.get("channel_health") or "")
558
+ if state not in CLIENT_READY_STATES or health not in CLIENT_READY_STATES:
559
+ raise ChannelSidecarClientError(f"channel session is not ready: state={state or 'unknown'} health={health or 'unknown'}")
560
+ return session
561
+
562
+ def send_and_wait(self, content: str, *, attachments: list[dict[str, Any]] | None = None) -> ClaudeRouteResult:
563
+ session = self.preflight()
564
+ envelope = self._request_json(
565
+ "POST",
566
+ f"/sessions/{self.session_id}/message",
567
+ {"sender": self.sender, "content": content, "attachments": attachments or []},
568
+ )
569
+ event_id = str(envelope.get("event_id") or "")
570
+ if not event_id:
571
+ raise ChannelSidecarClientError("channel sidecar returned no event id")
572
+ final_event = self._wait_for_final_event(event_id)
573
+ latest_session = session
574
+ if final_event is None:
575
+ try:
576
+ latest_session = self._request_json("GET", f"/sessions/{self.session_id}")
577
+ except ChannelSidecarClientError:
578
+ latest_session = session
579
+ return self._event_result(latest_session, final_event, event_id)
580
+
581
+ def _wait_for_final_event(self, event_id: str) -> dict[str, Any] | None:
582
+ deadline = time.monotonic() + self.timeout_seconds
583
+ while time.monotonic() <= deadline:
584
+ events_payload = self._request_json("GET", f"/sessions/{self.session_id}/events")
585
+ for event_payload in events_payload.get("events", []):
586
+ if event_payload.get("event_id") != event_id:
587
+ continue
588
+ replies = event_payload.get("replies") or []
589
+ if event_payload.get("acknowledged") or any(reply.get("final") for reply in replies):
590
+ return event_payload
591
+ time.sleep(self.poll_interval_seconds)
592
+ return None
593
+
594
+ def _event_result(self, session: dict[str, Any], event_payload: dict[str, Any] | None, event_id: str) -> ClaudeRouteResult:
595
+ if event_payload is None:
596
+ pending = (session.get("permission_state") or {}).get("pending")
597
+ session_state = str(session.get("state") or "")
598
+ if isinstance(pending, (int, float)) and pending > 0:
599
+ status = "needs_permission"
600
+ text = "channel permission required"
601
+ warnings = ("channel permission request is pending",)
602
+ elif session_state == "needs_input":
603
+ status = "needs_input"
604
+ text = "channel session needs input"
605
+ warnings = ("channel hook reported needs input",)
606
+ else:
607
+ status = "stale"
608
+ text = "channel reply timed out"
609
+ warnings = ("channel reply/status acknowledgement timed out",)
610
+ return ClaudeRouteResult(
611
+ route="claude_channel",
612
+ session_id=self.session_id,
613
+ event_id=event_id,
614
+ status=status,
615
+ text=text,
616
+ auth_posture=_client_auth_posture(session),
617
+ billing_posture=_client_billing_posture(session),
618
+ trust_state=session.get("trust_state") or {},
619
+ permission_state=session.get("permission_state") or {},
620
+ warnings=warnings,
621
+ evidence_refs=({"kind": "claude_channel_event", "event_id": event_id},),
622
+ )
623
+ final_reply = _final_reply(event_payload)
624
+ status = str(final_reply.get("status") or "working") if final_reply else "working"
625
+ if status not in CLAUDE_ROUTE_STATUSES:
626
+ status = "error"
627
+ return ClaudeRouteResult(
628
+ route="claude_channel",
629
+ session_id=self.session_id,
630
+ event_id=event_id,
631
+ status=status,
632
+ text=str(final_reply.get("text") or "") if final_reply else "",
633
+ artifacts=tuple(final_reply.get("artifacts") or []) if final_reply else (),
634
+ auth_posture=_client_auth_posture(session),
635
+ billing_posture=_client_billing_posture(session),
636
+ trust_state=session.get("trust_state") or {},
637
+ permission_state=session.get("permission_state") or {},
638
+ warnings=tuple(_client_warnings(event_payload)),
639
+ evidence_refs=({"kind": "claude_channel_event", "event_id": event_id},),
640
+ )
641
+
642
+ def _request_json(self, method: str, path: str, payload: dict[str, Any] | None = None) -> dict[str, Any]:
643
+ body = None if payload is None else json.dumps(payload, sort_keys=True).encode("utf-8")
644
+ headers = {"Accept": "application/json"}
645
+ if body is not None:
646
+ headers["Content-Type"] = "application/json"
647
+ if self._bearer_token:
648
+ headers["Authorization"] = f"Bearer {self._bearer_token}"
649
+ req = request.Request(f"{self.base_url}{path}", data=body, headers=headers, method=method)
650
+ try:
651
+ with self._opener(req, timeout=max(self.timeout_seconds, 1)) as response:
652
+ decoded = response.read().decode("utf-8")
653
+ except error.HTTPError as exc:
654
+ raise ChannelSidecarClientError(_client_http_reason(exc), status_code=exc.code) from exc
655
+ except error.URLError as exc:
656
+ raise ChannelSidecarClientError(f"channel sidecar unreachable: {exc.reason.__class__.__name__}") from exc
657
+ data = json.loads(decoded or "{}")
658
+ if not isinstance(data, dict):
659
+ raise ChannelSidecarClientError("channel sidecar returned non-object JSON")
660
+ return data
661
+
662
+
663
+ def is_loopback_host(host: str) -> bool:
664
+ normalized = host.strip().lower()
665
+ if normalized == "localhost":
666
+ return True
667
+ # Accept the full loopback ranges (127.0.0.0/8, ::1), not just 127.0.0.1, so a
668
+ # sidecar bound to e.g. 127.0.0.2 is correctly classified as loopback. Anything
669
+ # that is not a parseable loopback IP (or `localhost`) is non-loopback.
670
+ try:
671
+ return ipaddress.ip_address(normalized).is_loopback
672
+ except ValueError:
673
+ return False
674
+
675
+
676
+ def is_loopback_http_url(url: str) -> bool:
677
+ """True iff `url` is an http URL bound to a loopback host.
678
+
679
+ Single source of truth for the Channel sidecar transport posture: a Channel
680
+ route must point at a loopback sidecar (no remote/non-loopback transport).
681
+ Used by both the build-time route preflight (launcher) and the sidecar client.
682
+ """
683
+
684
+ if not url:
685
+ return False
686
+ parsed = urlparse(url)
687
+ return parsed.scheme == "http" and bool(parsed.hostname) and is_loopback_host(parsed.hostname)
688
+
689
+
690
+ def make_handler(sidecar: ChannelSidecar) -> type[BaseHTTPRequestHandler]:
691
+ class ChannelSidecarHandler(BaseHTTPRequestHandler):
692
+ server_version = "PhaseLoopChannelSidecar/0.1"
693
+
694
+ def do_POST(self) -> None: # noqa: N802
695
+ try:
696
+ self._handle_post()
697
+ except ValueError as exc:
698
+ self._write_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST)
699
+ except KeyError as exc:
700
+ self._write_json({"error": str(exc)}, HTTPStatus.NOT_FOUND)
701
+ except PermissionError as exc:
702
+ self._write_json({"error": str(exc)}, HTTPStatus.FORBIDDEN)
703
+
704
+ def do_GET(self) -> None: # noqa: N802
705
+ if self._path_parts()[:1] != ["sessions"]:
706
+ self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
707
+ return
708
+ parts = self._path_parts()
709
+ if len(parts) == 1:
710
+ self._write_json({"sessions": sidecar.list_sessions()})
711
+ return
712
+ if len(parts) == 2:
713
+ session = sidecar.get_session(parts[1])
714
+ if session is None:
715
+ self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
716
+ return
717
+ self._write_json(session)
718
+ return
719
+ if len(parts) == 4 and parts[2] == "permission" and parts[3] in {"requests", "audit"}:
720
+ payload_key = "requests" if parts[3] == "requests" else "audit"
721
+ payload = (
722
+ sidecar.list_permission_requests(parts[1])
723
+ if parts[3] == "requests"
724
+ else sidecar.list_permission_audit(parts[1])
725
+ )
726
+ self._write_json({payload_key: payload})
727
+ return
728
+ if len(parts) != 3 or parts[2] != "events":
729
+ self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
730
+ return
731
+ events = sidecar.list_events(parts[1])
732
+ if "text/event-stream" in self.headers.get("Accept", ""):
733
+ self.send_response(HTTPStatus.OK)
734
+ self.send_header("Content-Type", "text/event-stream")
735
+ self.end_headers()
736
+ for event in events:
737
+ self.wfile.write(f"data: {json.dumps(event, sort_keys=True)}\n\n".encode("utf-8"))
738
+ return
739
+ self._write_json({"events": events})
740
+
741
+ def log_message(self, format: str, *args: Any) -> None:
742
+ return
743
+
744
+ def _handle_post(self) -> None:
745
+ parts = self._path_parts()
746
+ if len(parts) not in {3, 4} or parts[0] != "sessions":
747
+ self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
748
+ return
749
+ payload = self._read_json()
750
+ session_id = parts[1]
751
+ if parts[2] == "register":
752
+ payload.setdefault("session_id", session_id)
753
+ self._write_json(sidecar.register_session(payload), HTTPStatus.CREATED)
754
+ return
755
+ if parts[2] == "state":
756
+ self._write_json(sidecar.update_session_state(session_id, state=payload.get("state"), channel_health=payload.get("channel_health")))
757
+ return
758
+ if parts[2] == "hook":
759
+ self._write_json(sidecar.record_hook_event(session_id, payload), HTTPStatus.CREATED)
760
+ return
761
+ if parts[2] == "message":
762
+ if auth_status := sidecar.authenticate(self.headers.get("Authorization")):
763
+ self._write_json({"error": "authentication required"}, auth_status)
764
+ return
765
+ envelope = sidecar.create_message(
766
+ session_id,
767
+ sender=payload.get("sender", ""),
768
+ content=payload.get("content", ""),
769
+ attachments=payload.get("attachments", []),
770
+ )
771
+ self._write_json(envelope.to_json(), HTTPStatus.CREATED)
772
+ return
773
+ if len(parts) == 4 and parts[2] == "permission" and parts[3] == "request":
774
+ request_payload = sidecar.create_permission_request(session_id, payload)
775
+ self._write_json(request_payload.to_json(), HTTPStatus.CREATED)
776
+ return
777
+ if len(parts) == 4 and parts[2] == "permission" and parts[3] == "verdict":
778
+ if auth_status := sidecar.authenticate(self.headers.get("Authorization")):
779
+ self._write_json({"error": "authentication required"}, auth_status)
780
+ return
781
+ audit_entry = sidecar.record_permission_verdict(session_id, payload)
782
+ self._write_json(audit_entry, HTTPStatus.CREATED)
783
+ return
784
+ if parts[2] in {"reply", "status"}:
785
+ if auth_status := sidecar.authenticate(self.headers.get("Authorization")):
786
+ self._write_json({"error": "authentication required"}, auth_status)
787
+ return
788
+ payload.setdefault("event_id", payload.get("eventId"))
789
+ state = sidecar.record_reply(payload) if parts[2] == "reply" else sidecar.record_status(payload)
790
+ self._write_json(state)
791
+ return
792
+ self._write_json({"error": "not found"}, HTTPStatus.NOT_FOUND)
793
+
794
+ def _path_parts(self) -> list[str]:
795
+ return [part for part in urlparse(self.path).path.split("/") if part]
796
+
797
+ def _read_json(self) -> dict[str, Any]:
798
+ length = int(self.headers.get("Content-Length", "0"))
799
+ if length <= 0:
800
+ return {}
801
+ data = json.loads(self.rfile.read(length).decode("utf-8"))
802
+ if not isinstance(data, dict):
803
+ raise ValueError("payload must be a JSON object")
804
+ return data
805
+
806
+ def _write_json(self, payload: dict[str, Any], status: HTTPStatus = HTTPStatus.OK) -> None:
807
+ body = json.dumps(payload, sort_keys=True).encode("utf-8")
808
+ self.send_response(status)
809
+ self.send_header("Content-Type", "application/json")
810
+ self.send_header("Content-Length", str(len(body)))
811
+ self.send_header("Connection", "close")
812
+ self.end_headers()
813
+ self.wfile.write(body)
814
+ self.close_connection = True
815
+
816
+ return ChannelSidecarHandler
817
+
818
+
819
+ def build_server(
820
+ host: str = "127.0.0.1",
821
+ port: int = 0,
822
+ sidecar: ChannelSidecar | None = None,
823
+ *,
824
+ bearer_token: str | None = None,
825
+ allowed_senders: set[str] | None = None,
826
+ allowed_verdict_actors: set[str] | None = None,
827
+ ) -> ThreadingHTTPServer:
828
+ if not is_loopback_host(host):
829
+ raise ValueError("claude channel sidecar binds to loopback hosts only")
830
+ return LoopbackThreadingHTTPServer(
831
+ (host, port),
832
+ make_handler(sidecar or ChannelSidecar(bearer_token=bearer_token, allowed_senders=allowed_senders, allowed_verdict_actors=allowed_verdict_actors)),
833
+ )
834
+
835
+
836
+ def main(argv: list[str] | None = None) -> int:
837
+ parser = argparse.ArgumentParser(description="Run the local phase-loop Claude Channel sidecar.")
838
+ parser.add_argument("--host", default="127.0.0.1")
839
+ parser.add_argument("--port", type=int, default=8765)
840
+ args = parser.parse_args(argv)
841
+ server = build_server(args.host, args.port)
842
+ sys.stderr.write(f"phase-loop channel sidecar listening on http://{args.host}:{args.port}\n")
843
+ try:
844
+ server.serve_forever()
845
+ except KeyboardInterrupt:
846
+ return 130
847
+ finally:
848
+ server.server_close()
849
+ return 0
850
+
851
+
852
+ def _metadata_only_attachments(attachments: list[dict[str, Any]]) -> list[dict[str, Any]]:
853
+ if not isinstance(attachments, list):
854
+ raise ValueError("attachments must be a list")
855
+ clean: list[dict[str, Any]] = []
856
+ for attachment in attachments:
857
+ if not isinstance(attachment, dict):
858
+ raise ValueError("attachments must contain metadata objects")
859
+ forbidden = FORBIDDEN_ATTACHMENT_FIELDS.intersection({str(key).lower() for key in attachment})
860
+ if forbidden:
861
+ raise ValueError(f"attachment contains non-metadata fields: {', '.join(sorted(forbidden))}")
862
+ clean.append(dict(attachment))
863
+ return clean
864
+
865
+
866
+ def _reply_payload(payload: dict[str, Any]) -> ChannelReplyPayload:
867
+ event_id = payload.get("event_id")
868
+ status = payload.get("status")
869
+ if not isinstance(event_id, str) or not event_id:
870
+ raise ValueError("event_id is required")
871
+ if status not in REPLY_STATUSES:
872
+ raise ValueError("status must be one of: " + ", ".join(sorted(REPLY_STATUSES)))
873
+ artifacts = payload.get("artifacts") or []
874
+ if not isinstance(artifacts, list):
875
+ raise ValueError("artifacts must be a list")
876
+ return ChannelReplyPayload(
877
+ event_id=event_id,
878
+ status=status,
879
+ text=str(payload.get("text") or ""),
880
+ artifacts=tuple(_metadata_only_attachments(artifacts)),
881
+ error=payload.get("error"),
882
+ final=bool(payload.get("final", False)),
883
+ )
884
+
885
+
886
+ def _final_reply(event_payload: dict[str, Any]) -> dict[str, Any] | None:
887
+ replies = event_payload.get("replies") or []
888
+ for reply in reversed(replies):
889
+ if reply.get("final") or reply.get("status") in CLIENT_FINAL_REPLY_STATUSES:
890
+ return reply
891
+ return replies[-1] if replies else None
892
+
893
+
894
+ def _client_warnings(event_payload: dict[str, Any]) -> list[str]:
895
+ warnings = []
896
+ if event_payload.get("ack_policy") == ACK_POLICY_TOOL_REQUIRED and not event_payload.get("acknowledged"):
897
+ warnings.append("channel reply tool acknowledgement missing")
898
+ return warnings
899
+
900
+
901
+ def _client_auth_posture(session: dict[str, Any]) -> str:
902
+ posture = session.get("auth_posture") or {}
903
+ method = str(posture.get("method") or posture.get("authMethod") or "").lower()
904
+ status = str(posture.get("status") or "").lower()
905
+ if method == "subscription" and status in {"authenticated", "ok", "ready"}:
906
+ return "subscription_local"
907
+ if method in {"api_key", "apikey", "key"}:
908
+ return "api_key"
909
+ return "unknown"
910
+
911
+
912
+ def _client_billing_posture(session: dict[str, Any]) -> str:
913
+ auth_posture = _client_auth_posture(session)
914
+ if auth_posture == "subscription_local":
915
+ return "subscription_included"
916
+ if auth_posture == "api_key":
917
+ return "api_key_billed"
918
+ return "unknown"
919
+
920
+
921
+ def _client_http_reason(exc: error.HTTPError) -> str:
922
+ if exc.code in {HTTPStatus.UNAUTHORIZED.value, HTTPStatus.FORBIDDEN.value}:
923
+ return "channel sidecar authentication failed"
924
+ if exc.code == HTTPStatus.NOT_FOUND.value:
925
+ return "channel session not found"
926
+ return f"channel sidecar http error: {exc.code}"
927
+
928
+
929
+ def _permission_request_payload(session_id: str, payload: dict[str, Any], *, event_id: str | None = None) -> PermissionRequestEnvelope:
930
+ if not session_id:
931
+ raise ValueError("session_id is required")
932
+ forbidden = FORBIDDEN_PERMISSION_FIELDS.intersection({str(key).lower() for key in payload})
933
+ if forbidden:
934
+ raise ValueError(f"permission request contains raw or secret-like fields: {', '.join(sorted(forbidden))}")
935
+ allowed = {"tool_name", "description", "input_preview", "risk_class"}
936
+ extra = set(payload) - allowed
937
+ if extra:
938
+ raise ValueError(f"permission request contains unsupported fields: {', '.join(sorted(extra))}")
939
+ tool_name = payload.get("tool_name")
940
+ description = payload.get("description")
941
+ input_preview = payload.get("input_preview")
942
+ risk_class = payload.get("risk_class")
943
+ for field_name, value in (
944
+ ("tool_name", tool_name),
945
+ ("description", description),
946
+ ("input_preview", input_preview),
947
+ ("risk_class", risk_class),
948
+ ):
949
+ if not isinstance(value, str) or not value:
950
+ raise ValueError(f"{field_name} is required")
951
+ _raise_if_secret_like_value(value, field_name)
952
+ request_id = str(uuid.uuid4())
953
+ return PermissionRequestEnvelope(
954
+ request_id=request_id,
955
+ session_id=session_id,
956
+ event_id=event_id,
957
+ tool_name=tool_name,
958
+ description=description,
959
+ input_preview=input_preview,
960
+ risk_class=risk_class,
961
+ audit_ref=f"claude_permission:{session_id}:{request_id}",
962
+ requested_at=_utc_now(),
963
+ )
964
+
965
+
966
+ def _permission_verdict_payload(payload: dict[str, Any]) -> PermissionVerdictPayload:
967
+ request_id = payload.get("request_id")
968
+ verdict = payload.get("verdict")
969
+ actor = payload.get("actor")
970
+ reason = payload.get("reason")
971
+ if not isinstance(request_id, str) or not request_id:
972
+ raise ValueError("request_id is required")
973
+ if verdict not in PERMISSION_VERDICTS:
974
+ raise ValueError("verdict must be one of: " + ", ".join(sorted(PERMISSION_VERDICTS)))
975
+ if not isinstance(actor, str) or not actor:
976
+ raise ValueError("actor is required")
977
+ if not isinstance(reason, str) or not reason:
978
+ raise ValueError("reason is required")
979
+ return PermissionVerdictPayload(
980
+ request_id=request_id,
981
+ verdict=verdict,
982
+ actor=actor,
983
+ reason=reason,
984
+ decided_at=_utc_now(),
985
+ )
986
+
987
+
988
+ def _require_literal(value: str, allowed: set[str], label: str) -> None:
989
+ if value not in allowed:
990
+ raise ValueError(f"{label} must be one of: " + ", ".join(sorted(allowed)))
991
+
992
+
993
+ def _required_string(payload: dict[str, Any], field_name: str) -> str:
994
+ value = payload.get(field_name)
995
+ if not isinstance(value, str) or not value:
996
+ raise ValueError(f"{field_name} is required")
997
+ return value
998
+
999
+
1000
+ def _metadata_only_text(value: str, field_name: str) -> str:
1001
+ if not isinstance(value, str):
1002
+ raise ValueError(f"{field_name} must be a string")
1003
+ _raise_if_secret_like_value(value, field_name)
1004
+ return value
1005
+
1006
+
1007
+ def _metadata_only_warnings(warnings: tuple[str, ...] | list[str]) -> list[str]:
1008
+ if not isinstance(warnings, (tuple, list)):
1009
+ raise ValueError("warnings must be a list")
1010
+ clean: list[str] = []
1011
+ for warning in warnings:
1012
+ if not isinstance(warning, str):
1013
+ raise ValueError("warnings must contain strings")
1014
+ _raise_if_secret_like_value(warning, "warnings")
1015
+ clean.append(warning)
1016
+ return clean
1017
+
1018
+
1019
+ def _metadata_only_route_objects(items: tuple[dict[str, Any], ...] | list[dict[str, Any]], field_name: str) -> list[dict[str, Any]]:
1020
+ if not isinstance(items, (tuple, list)):
1021
+ raise ValueError(f"{field_name} must be a list")
1022
+ clean: list[dict[str, Any]] = []
1023
+ for item in items:
1024
+ if not isinstance(item, dict):
1025
+ raise ValueError(f"{field_name} must contain metadata objects")
1026
+ clean.append(_metadata_summary(item))
1027
+ return clean
1028
+
1029
+
1030
+ def _metadata_summary(value: Any) -> dict[str, Any]:
1031
+ if not isinstance(value, dict):
1032
+ raise ValueError("metadata summaries must be objects")
1033
+ forbidden = FORBIDDEN_ATTACHMENT_FIELDS.union(FORBIDDEN_PERMISSION_FIELDS)
1034
+ clean: dict[str, Any] = {}
1035
+ for key, item in value.items():
1036
+ normalized = str(key).lower()
1037
+ if normalized in forbidden:
1038
+ raise ValueError(f"metadata summary contains secret-like field: {key}")
1039
+ if isinstance(item, (str, int, float, bool)) or item is None:
1040
+ if isinstance(item, str):
1041
+ _raise_if_secret_like_value(item, str(key))
1042
+ clean[str(key)] = item
1043
+ elif isinstance(item, list):
1044
+ clean[str(key)] = [
1045
+ _metadata_list_value(entry, str(key))
1046
+ for entry in item
1047
+ if isinstance(entry, (str, int, float, bool)) or entry is None
1048
+ ]
1049
+ else:
1050
+ rendered = str(item)
1051
+ _raise_if_secret_like_value(rendered, str(key))
1052
+ clean[str(key)] = rendered
1053
+ return clean
1054
+
1055
+
1056
+ def _metadata_list_value(value: str | int | float | bool | None, field_name: str) -> str | int | float | bool | None:
1057
+ if isinstance(value, str):
1058
+ _raise_if_secret_like_value(value, field_name)
1059
+ return value
1060
+
1061
+
1062
+ def _raise_if_secret_like_value(value: str, field_name: str) -> None:
1063
+ normalized = value.lower()
1064
+ for marker in FORBIDDEN_ROUTE_RESULT_VALUE_MARKERS:
1065
+ if marker in normalized:
1066
+ raise ValueError(f"{field_name} contains secret-like or raw metadata")
1067
+
1068
+
1069
+ def _parse_utc(value: str) -> datetime:
1070
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
1071
+
1072
+
1073
+ def process_exists(pid: int) -> bool:
1074
+ try:
1075
+ os.kill(pid, 0)
1076
+ except OSError:
1077
+ return False
1078
+ return True
1079
+
1080
+
1081
+ def _utc_now() -> str:
1082
+ return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
1083
+
1084
+
1085
+ if __name__ == "__main__":
1086
+ raise SystemExit(main())