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