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,2368 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
import tempfile
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from fnmatch import fnmatchcase
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Any, Iterable
|
|
14
|
+
|
|
15
|
+
try: # Optional in stripped adapter runtimes; normal installs and tests provide it.
|
|
16
|
+
import yaml
|
|
17
|
+
except Exception: # pragma: no cover
|
|
18
|
+
yaml = None
|
|
19
|
+
|
|
20
|
+
from .events import read_events
|
|
21
|
+
from .models import (
|
|
22
|
+
BLOCKER_CLASSES,
|
|
23
|
+
DispatchHints,
|
|
24
|
+
ExecutionPolicyDocument,
|
|
25
|
+
ExecutionPolicyParseError,
|
|
26
|
+
ExecutionPolicyRule,
|
|
27
|
+
LaneIRDiagnostic,
|
|
28
|
+
PHASE_SCHEDULER_MODES,
|
|
29
|
+
PHASE_SOURCE_BUNDLE_SCHEMA,
|
|
30
|
+
PHASE_STATUSES,
|
|
31
|
+
PhaseSourceBundle,
|
|
32
|
+
PhaseTeamEligibility,
|
|
33
|
+
PipelineMetadataDiagnostic,
|
|
34
|
+
PipelinePlanMetadata,
|
|
35
|
+
PipelineProtectedSource,
|
|
36
|
+
PIPELINE_PROTECTED_SOURCE_ROLES,
|
|
37
|
+
PRODUCT_LOOP_ACTIONS,
|
|
38
|
+
require_literal,
|
|
39
|
+
)
|
|
40
|
+
from .pipeline_adapter.flag import reconcile_git_reality_enabled
|
|
41
|
+
from .provenance import phase_sha256, roadmap_sha256
|
|
42
|
+
from .runtime_paths import phase_loop_state_read_file
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
PHASE_RE = re.compile(
|
|
46
|
+
r"^###\s+Phase\s+\d+(?:\.\d+)?[A-Z]?\s+.*?\(([A-Z][A-Z0-9._-]*)(?:\s*,[^)]*)?\)[ \t]*(?:\S[^\n]*)?$",
|
|
47
|
+
re.MULTILINE,
|
|
48
|
+
)
|
|
49
|
+
PLAN_RE = re.compile(r"phase-plan-(v[\w.-]+?)-([A-Z][A-Za-z0-9._-]*?)\.md$")
|
|
50
|
+
LANE_SECTION_RE = re.compile(
|
|
51
|
+
r"^###\s+(?:SL-\d+[A-Z]?|[A-Z][A-Z0-9]*-\d+[A-Z]?|Lane\s+\d+|Swim\s*lane\s+\d+)\b.*$",
|
|
52
|
+
re.IGNORECASE | re.MULTILINE,
|
|
53
|
+
)
|
|
54
|
+
OWNED_FILES_RE = re.compile(r"^\s*(?:-\s+)?\*\*Owned files\*\*:\s*(.+?)\s*$", re.IGNORECASE | re.MULTILINE)
|
|
55
|
+
DISPATCH_SECTION_RE = re.compile(
|
|
56
|
+
r"^#{2,3}\s+Dispatch Hints\s*$\n(?P<body>.*?)(?=^#{1,3}\s+\S|\Z)",
|
|
57
|
+
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
58
|
+
)
|
|
59
|
+
DISPATCH_SUBSECTION_RE = re.compile(
|
|
60
|
+
r"^#{3,4}\s+(?P<label>default|roadmap|plan|roadmap default|plan default|"
|
|
61
|
+
r"roadmap|plan|execute|repair|review|maintain-skills)\s*$\n(?P<body>.*?)(?=^#{3,4}\s+\S|\Z)",
|
|
62
|
+
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
63
|
+
)
|
|
64
|
+
DISPATCH_LINE_RE = re.compile(
|
|
65
|
+
r"^\s*-\s*(?:(?P<action>roadmap|plan|execute|repair|review|maintain-skills)\s+)?"
|
|
66
|
+
r"(?P<label>preferred executors|allowed executors|fallback executors|disabled executors|required capabilities)\s*:\s*(?P<value>.+?)\s*$",
|
|
67
|
+
re.IGNORECASE | re.MULTILINE,
|
|
68
|
+
)
|
|
69
|
+
EXECUTION_POLICY_SECTION_RE = re.compile(
|
|
70
|
+
r"^#{2,3}\s+Execution Policy\s*$\n(?P<body>.*?)(?=^#{1,3}\s+\S|\Z)",
|
|
71
|
+
re.IGNORECASE | re.MULTILINE | re.DOTALL,
|
|
72
|
+
)
|
|
73
|
+
EXECUTION_POLICY_LINE_RE = re.compile(r"^\s*-\s*(?P<selector>[^:]+?)\s*:\s*(?P<value>.+?)\s*$", re.MULTILINE)
|
|
74
|
+
EXECUTION_POLICY_ASSIGNMENT_RE = re.compile(
|
|
75
|
+
r"(?P<key>executor|model|effort|work-unit|work_unit|unsupported|fallback|inherit-default|inherit_default|reason)"
|
|
76
|
+
r"\s*=\s*(?P<value>`[^`]+`|[^,;]+)",
|
|
77
|
+
re.IGNORECASE,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
WORKFLOW_PLAN_SKILLS = (
|
|
81
|
+
"codex-plan-phase",
|
|
82
|
+
"claude-plan-phase",
|
|
83
|
+
"gemini-plan-phase",
|
|
84
|
+
"opencode-plan-phase",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
WORKFLOW_EXECUTE_SKILLS = (
|
|
88
|
+
"codex-execute-phase",
|
|
89
|
+
"claude-execute-phase",
|
|
90
|
+
"gemini-execute-phase",
|
|
91
|
+
"opencode-execute-phase",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True)
|
|
96
|
+
class RepoIdentity:
|
|
97
|
+
root: Path
|
|
98
|
+
repo_hash: str
|
|
99
|
+
branch: str
|
|
100
|
+
branch_slug: str
|
|
101
|
+
commit: str
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass(frozen=True)
|
|
105
|
+
class PlanOwnership:
|
|
106
|
+
owned_patterns: tuple[str, ...]
|
|
107
|
+
control_paths: tuple[str, ...]
|
|
108
|
+
valid: bool
|
|
109
|
+
errors: tuple[str, ...] = ()
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def is_control_only(self) -> bool:
|
|
113
|
+
"""A legitimate control/backfill phase: a *valid* plan that owns no
|
|
114
|
+
files (all lanes read-only or declared ``(none)``). IF-0-FOUND-2.
|
|
115
|
+
|
|
116
|
+
Distinct from an *invalid* empty-owned plan (one whose lanes carry
|
|
117
|
+
``missing_owned_files``/``malformed_owned_files`` diagnostics), for which
|
|
118
|
+
``valid`` is False — those are genuinely misconfigured and must keep
|
|
119
|
+
refusing closeout.
|
|
120
|
+
"""
|
|
121
|
+
return self.valid and self.owned_patterns == ()
|
|
122
|
+
|
|
123
|
+
def matches(self, repo_path: str) -> bool:
|
|
124
|
+
return (
|
|
125
|
+
repo_path in self.control_paths
|
|
126
|
+
or repo_path in self.owned_patterns
|
|
127
|
+
or any(_owned_pattern_matches(repo_path, pattern) for pattern in self.owned_patterns)
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
def matches_dirty_output(self, repo_path: str) -> bool:
|
|
131
|
+
return self.matches(repo_path) or expanded_dirty_ownership_matches(self, repo_path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass(frozen=True)
|
|
135
|
+
class PlanLane:
|
|
136
|
+
heading: str
|
|
137
|
+
owned_patterns: tuple[str, ...]
|
|
138
|
+
read_only: bool
|
|
139
|
+
text: str
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
DIRTY_WORKTREE_METADATA_KEYS = (
|
|
143
|
+
"completion_dirty_worktree",
|
|
144
|
+
"plan_dirty_worktree",
|
|
145
|
+
"incomplete_execute_dirty_worktree",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def previous_phase_owned_dirty_paths(repo: Path, phase: str | None) -> tuple[str, ...]:
|
|
150
|
+
if not phase:
|
|
151
|
+
return ()
|
|
152
|
+
phase_alias = phase.upper()
|
|
153
|
+
for event in reversed(read_events(repo)):
|
|
154
|
+
if str(event.get("phase", "")).upper() != phase_alias:
|
|
155
|
+
continue
|
|
156
|
+
metadata = event.get("metadata")
|
|
157
|
+
if not isinstance(metadata, dict):
|
|
158
|
+
continue
|
|
159
|
+
for key in DIRTY_WORKTREE_METADATA_KEYS:
|
|
160
|
+
dirty = metadata.get(key)
|
|
161
|
+
if isinstance(dirty, dict) and "dirty_paths" in dirty:
|
|
162
|
+
return _normalized_path_tuple(dirty.get("phase_owned_dirty_paths"))
|
|
163
|
+
terminal = metadata.get("terminal_summary")
|
|
164
|
+
if isinstance(terminal, dict) and "dirty_paths" in terminal:
|
|
165
|
+
return _normalized_path_tuple(terminal.get("phase_owned_dirty_paths"))
|
|
166
|
+
return ()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _normalized_path_tuple(value: object) -> tuple[str, ...]:
|
|
170
|
+
if not isinstance(value, list):
|
|
171
|
+
return ()
|
|
172
|
+
paths: list[str] = []
|
|
173
|
+
seen: set[str] = set()
|
|
174
|
+
for item in value:
|
|
175
|
+
if not isinstance(item, str):
|
|
176
|
+
continue
|
|
177
|
+
path = item.strip()
|
|
178
|
+
if not path or path in seen:
|
|
179
|
+
continue
|
|
180
|
+
paths.append(path)
|
|
181
|
+
seen.add(path)
|
|
182
|
+
return tuple(paths)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class DispatchHintsParseError:
|
|
187
|
+
"""Surfaced when ``## Dispatch Hints`` emits a literal not in the runner's
|
|
188
|
+
DISPATCH_CAPABILITIES / EXECUTORS allowlist. Mirrors F3's
|
|
189
|
+
ExecutionPolicyParseError pattern: callers convert this to a
|
|
190
|
+
contract_bug blocker instead of crashing on raw ValueError."""
|
|
191
|
+
|
|
192
|
+
path: str
|
|
193
|
+
bucket: str # e.g., "default", "execute", "plan"
|
|
194
|
+
raw_message: str # the original ValueError from require_literal
|
|
195
|
+
invalid_literal: str | None = None # extracted from raw_message when possible
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
@dataclass(frozen=True)
|
|
199
|
+
class CloseoutParseError:
|
|
200
|
+
"""Native closeout literal drift surfaced before BAML schema parsing.
|
|
201
|
+
|
|
202
|
+
The runner converts these diagnostics to repairable non-human
|
|
203
|
+
``contract_bug`` blockers, preserving the original invalid literal.
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
source: str
|
|
207
|
+
field: str
|
|
208
|
+
raw_message: str
|
|
209
|
+
invalid_literal: str | None = None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
@dataclass(frozen=True)
|
|
213
|
+
class SuiteCommandFinding:
|
|
214
|
+
code: str
|
|
215
|
+
message: str
|
|
216
|
+
source: str
|
|
217
|
+
value: object = None
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _extract_invalid_literal(message: str) -> str | None:
|
|
221
|
+
"""Best-effort extraction of the offending literal from a require_literal
|
|
222
|
+
ValueError message. Format is typically: 'invalid {label}: {value}'."""
|
|
223
|
+
if ":" in message:
|
|
224
|
+
candidate = message.rsplit(":", 1)[-1].strip()
|
|
225
|
+
if candidate:
|
|
226
|
+
return candidate
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
CLOSEOUT_VERIFICATION_STATUSES = ("not_run", "passed", "failed", "blocked")
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def parse_closeout_payload(text: str, *, kind: str = "closeout") -> dict[str, Any] | None:
|
|
234
|
+
"""Backward-compatible payload-only closeout parser.
|
|
235
|
+
|
|
236
|
+
Literal drift returns ``None`` so existing callers can keep using a simple
|
|
237
|
+
truthy payload check. Schema/type errors still follow the BAML validation
|
|
238
|
+
path and raise ``BamlValidationError``.
|
|
239
|
+
"""
|
|
240
|
+
payload, errors = parse_closeout_payload_doc(text, kind=kind)
|
|
241
|
+
return None if errors else payload
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def parse_closeout_payload_doc(
|
|
245
|
+
text: str, *, kind: str
|
|
246
|
+
) -> tuple[dict[str, Any] | None, tuple[CloseoutParseError, ...]]:
|
|
247
|
+
"""Parse native closeout JSON with graceful soft-fail for enum drift.
|
|
248
|
+
|
|
249
|
+
Unknown ``terminal_status``, ``verification_status``, and
|
|
250
|
+
``blocker_class`` literals become structured diagnostics instead of raw
|
|
251
|
+
``ValueError`` crashes. Valid payloads still pass through the canonical
|
|
252
|
+
``EmitPhaseCloseout`` BAML validation path for schema and type checks.
|
|
253
|
+
"""
|
|
254
|
+
from .baml_modular import parse_baml_response
|
|
255
|
+
|
|
256
|
+
extracted = _find_closeout_payload_doc(text)
|
|
257
|
+
parse_text = json.dumps(extracted) if extracted is not None else str(text or "")
|
|
258
|
+
literal_source = extracted if extracted is not None else {}
|
|
259
|
+
errors = _closeout_literal_errors(literal_source, kind=kind) if isinstance(literal_source, dict) else []
|
|
260
|
+
if errors:
|
|
261
|
+
return None, tuple(errors)
|
|
262
|
+
return parse_baml_response("EmitPhaseCloseout", parse_text).payload, ()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _find_closeout_payload_doc(text: str) -> dict[str, Any] | None:
|
|
266
|
+
decoder = json.JSONDecoder()
|
|
267
|
+
raw = str(text or "")
|
|
268
|
+
try:
|
|
269
|
+
data = json.loads(raw)
|
|
270
|
+
except json.JSONDecodeError:
|
|
271
|
+
data = None
|
|
272
|
+
if isinstance(data, str):
|
|
273
|
+
return _find_closeout_payload_doc(data)
|
|
274
|
+
if isinstance(data, dict) and {"terminal_status", "verification_status", "dirty_paths"}.issubset(data):
|
|
275
|
+
return data
|
|
276
|
+
for index, char in enumerate(raw):
|
|
277
|
+
if char != "{":
|
|
278
|
+
continue
|
|
279
|
+
try:
|
|
280
|
+
data, _end = decoder.raw_decode(raw[index:])
|
|
281
|
+
except json.JSONDecodeError:
|
|
282
|
+
continue
|
|
283
|
+
if isinstance(data, dict) and {"terminal_status", "verification_status", "dirty_paths"}.issubset(data):
|
|
284
|
+
return data
|
|
285
|
+
return None
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _closeout_literal_errors(payload: dict[str, Any], *, kind: str) -> list[CloseoutParseError]:
|
|
289
|
+
errors: list[CloseoutParseError] = []
|
|
290
|
+
terminal_status = payload.get("terminal_status")
|
|
291
|
+
verification_status = payload.get("verification_status")
|
|
292
|
+
blocker_class = payload.get("blocker_class")
|
|
293
|
+
|
|
294
|
+
_append_literal_error(errors, kind=kind, field="terminal_status", value=terminal_status, allowed=PHASE_STATUSES)
|
|
295
|
+
_append_literal_error(
|
|
296
|
+
errors,
|
|
297
|
+
kind=kind,
|
|
298
|
+
field="verification_status",
|
|
299
|
+
value=verification_status,
|
|
300
|
+
allowed=CLOSEOUT_VERIFICATION_STATUSES,
|
|
301
|
+
)
|
|
302
|
+
if blocker_class is not None:
|
|
303
|
+
_append_literal_error(
|
|
304
|
+
errors,
|
|
305
|
+
kind=kind,
|
|
306
|
+
field="blocker_class",
|
|
307
|
+
value=blocker_class,
|
|
308
|
+
allowed=(*BLOCKER_CLASSES, "none"),
|
|
309
|
+
)
|
|
310
|
+
if errors:
|
|
311
|
+
return errors
|
|
312
|
+
|
|
313
|
+
terminal_text = str(terminal_status)
|
|
314
|
+
verification_text = str(verification_status)
|
|
315
|
+
if terminal_text == "complete" and verification_text != "passed":
|
|
316
|
+
errors.append(
|
|
317
|
+
CloseoutParseError(
|
|
318
|
+
source=kind,
|
|
319
|
+
field="terminal_status+verification_status",
|
|
320
|
+
raw_message="invalid closeout field-pair: terminal_status complete requires verification_status passed",
|
|
321
|
+
invalid_literal=f"{terminal_text}/{verification_text}",
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
elif terminal_text in {"planned", "unplanned"} and verification_text != "not_run":
|
|
325
|
+
errors.append(
|
|
326
|
+
CloseoutParseError(
|
|
327
|
+
source=kind,
|
|
328
|
+
field="terminal_status+verification_status",
|
|
329
|
+
raw_message=f"invalid closeout field-pair: terminal_status {terminal_text} requires verification_status not_run",
|
|
330
|
+
invalid_literal=f"{terminal_text}/{verification_text}",
|
|
331
|
+
)
|
|
332
|
+
)
|
|
333
|
+
elif terminal_text == "blocked" and verification_text not in {"failed", "blocked"}:
|
|
334
|
+
errors.append(
|
|
335
|
+
CloseoutParseError(
|
|
336
|
+
source=kind,
|
|
337
|
+
field="terminal_status+verification_status",
|
|
338
|
+
raw_message="invalid closeout field-pair: terminal_status blocked requires verification_status failed or blocked",
|
|
339
|
+
invalid_literal=f"{terminal_text}/{verification_text}",
|
|
340
|
+
)
|
|
341
|
+
)
|
|
342
|
+
return errors
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _append_literal_error(
|
|
346
|
+
errors: list[CloseoutParseError], *, kind: str, field: str, value: Any, allowed: tuple[str, ...]
|
|
347
|
+
) -> None:
|
|
348
|
+
try:
|
|
349
|
+
require_literal(str(value), allowed, field)
|
|
350
|
+
except ValueError as exc:
|
|
351
|
+
errors.append(
|
|
352
|
+
CloseoutParseError(
|
|
353
|
+
source=kind,
|
|
354
|
+
field=field,
|
|
355
|
+
raw_message=str(exc),
|
|
356
|
+
invalid_literal=_extract_invalid_literal(str(exc)),
|
|
357
|
+
)
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def resolve_repo(repo: str | Path | None = None) -> Path:
|
|
362
|
+
base = Path(repo or os.getcwd()).expanduser().resolve()
|
|
363
|
+
try:
|
|
364
|
+
out = subprocess.check_output(["git", "-C", str(base), "rev-parse", "--show-toplevel"], text=True)
|
|
365
|
+
return Path(out.strip()).resolve()
|
|
366
|
+
except Exception:
|
|
367
|
+
return base
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def slug_branch(branch: str) -> str:
|
|
371
|
+
slug = re.sub(r"[^A-Za-z0-9._-]+", "-", branch).strip("-").lower()
|
|
372
|
+
slug = re.sub(r"-+", "-", slug)
|
|
373
|
+
return slug or "unknown"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def repo_identity(repo: str | Path | None = None) -> RepoIdentity:
|
|
377
|
+
root = resolve_repo(repo)
|
|
378
|
+
repo_hash = hashlib.sha256(str(root).encode()).hexdigest()[:8]
|
|
379
|
+
branch = _git(root, "branch", "--show-current") or _git(root, "rev-parse", "--short", "HEAD")
|
|
380
|
+
commit = _git(root, "rev-parse", "--short", "HEAD") or "unknown"
|
|
381
|
+
branch_slug = slug_branch(branch if branch else f"detached-{commit}")
|
|
382
|
+
return RepoIdentity(root=root, repo_hash=repo_hash, branch=branch, branch_slug=branch_slug, commit=commit)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _git(root: Path, *args: str) -> str:
|
|
386
|
+
try:
|
|
387
|
+
return subprocess.check_output(["git", "-C", str(root), *args], text=True, stderr=subprocess.DEVNULL).strip()
|
|
388
|
+
except Exception:
|
|
389
|
+
return ""
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def select_roadmap(repo: Path, explicit: str | Path | None = None) -> Path:
|
|
393
|
+
if explicit:
|
|
394
|
+
path = Path(explicit).expanduser()
|
|
395
|
+
if not path.is_absolute():
|
|
396
|
+
path = repo / path
|
|
397
|
+
if not path.exists():
|
|
398
|
+
raise FileNotFoundError(f"roadmap not found: {path}")
|
|
399
|
+
return path.resolve()
|
|
400
|
+
|
|
401
|
+
state_roadmap = active_state_roadmap(repo)
|
|
402
|
+
if state_roadmap is not None:
|
|
403
|
+
return state_roadmap
|
|
404
|
+
|
|
405
|
+
manifest_roadmap = manifest_backed_roadmap(repo)
|
|
406
|
+
if manifest_roadmap is not None:
|
|
407
|
+
return manifest_roadmap
|
|
408
|
+
|
|
409
|
+
handoff = latest_handoff_roadmap(repo_identity(repo), "codex-phase-roadmap-builder")
|
|
410
|
+
if handoff is not None:
|
|
411
|
+
return handoff
|
|
412
|
+
|
|
413
|
+
candidates = sorted((repo / "specs").glob("phase-plans-v*.md"))
|
|
414
|
+
if not candidates:
|
|
415
|
+
raise FileNotFoundError("no specs/phase-plans-v*.md roadmap found")
|
|
416
|
+
if len(candidates) != 1:
|
|
417
|
+
raise RuntimeError("ambiguous roadmap selection")
|
|
418
|
+
return candidates[0].resolve()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def active_state_roadmap(repo: Path) -> Path | None:
|
|
422
|
+
path = phase_loop_state_read_file(repo)
|
|
423
|
+
if not path.exists():
|
|
424
|
+
return None
|
|
425
|
+
try:
|
|
426
|
+
data = json.loads(path.read_text())
|
|
427
|
+
except (OSError, json.JSONDecodeError):
|
|
428
|
+
return None
|
|
429
|
+
roadmap = data.get("roadmap")
|
|
430
|
+
if not isinstance(roadmap, str) or not roadmap:
|
|
431
|
+
return None
|
|
432
|
+
roadmap_path = Path(roadmap).expanduser()
|
|
433
|
+
if not roadmap_path.is_absolute():
|
|
434
|
+
roadmap_path = repo / roadmap_path
|
|
435
|
+
try:
|
|
436
|
+
resolved = roadmap_path.resolve()
|
|
437
|
+
resolved.relative_to(repo.resolve())
|
|
438
|
+
except (OSError, ValueError):
|
|
439
|
+
return None
|
|
440
|
+
return resolved if resolved.exists() else None
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def manifest_backed_roadmap(repo: Path) -> Path | None:
|
|
444
|
+
if _phase_manifest_disabled():
|
|
445
|
+
return None
|
|
446
|
+
candidates: list[Path] = []
|
|
447
|
+
for entry in _phase_manifest_entries(repo):
|
|
448
|
+
if entry.roadmap_ref is None or entry.status == "orphaned":
|
|
449
|
+
continue
|
|
450
|
+
path = repo / entry.roadmap_ref.file
|
|
451
|
+
try:
|
|
452
|
+
resolved = path.resolve()
|
|
453
|
+
resolved.relative_to(repo.resolve())
|
|
454
|
+
except (OSError, ValueError):
|
|
455
|
+
continue
|
|
456
|
+
if resolved.exists() and resolved not in candidates:
|
|
457
|
+
candidates.append(resolved)
|
|
458
|
+
return candidates[0] if len(candidates) == 1 else None
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def latest_handoff_roadmap(identity: RepoIdentity, predecessor: str) -> Path | None:
|
|
462
|
+
path = Path.home() / ".codex" / "skills" / predecessor / "handoffs" / identity.repo_hash / identity.branch_slug / "latest.md"
|
|
463
|
+
if not path.exists():
|
|
464
|
+
return None
|
|
465
|
+
frontmatter = parse_frontmatter(path.read_text())
|
|
466
|
+
if frontmatter.get("from") != predecessor:
|
|
467
|
+
return None
|
|
468
|
+
if frontmatter.get("repo") != identity.repo_hash:
|
|
469
|
+
return None
|
|
470
|
+
if Path(frontmatter.get("repo_root", "")).resolve() != identity.root:
|
|
471
|
+
return None
|
|
472
|
+
if frontmatter.get("branch_slug") != identity.branch_slug:
|
|
473
|
+
return None
|
|
474
|
+
artifact = frontmatter.get("artifact")
|
|
475
|
+
if not artifact:
|
|
476
|
+
return None
|
|
477
|
+
artifact_path = Path(artifact).expanduser().resolve()
|
|
478
|
+
try:
|
|
479
|
+
artifact_path.relative_to(identity.root)
|
|
480
|
+
except ValueError:
|
|
481
|
+
return None
|
|
482
|
+
return artifact_path if artifact_path.exists() else None
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def parse_frontmatter(text: str) -> dict[str, str]:
|
|
486
|
+
if not text.startswith("---\n"):
|
|
487
|
+
return {}
|
|
488
|
+
end = text.find("\n---", 4)
|
|
489
|
+
if end == -1:
|
|
490
|
+
return {}
|
|
491
|
+
data: dict[str, str] = {}
|
|
492
|
+
for line in text[4:end].splitlines():
|
|
493
|
+
if ":" not in line:
|
|
494
|
+
continue
|
|
495
|
+
key, value = line.split(":", 1)
|
|
496
|
+
data[key.strip()] = value.strip().strip("'\"")
|
|
497
|
+
return data
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def parse_frontmatter_document(text: str) -> dict[str, Any]:
|
|
501
|
+
if not text.startswith("---\n"):
|
|
502
|
+
return {}
|
|
503
|
+
end = text.find("\n---", 4)
|
|
504
|
+
if end == -1:
|
|
505
|
+
return {}
|
|
506
|
+
body = text[4:end]
|
|
507
|
+
if yaml is not None:
|
|
508
|
+
try:
|
|
509
|
+
data = yaml.safe_load(body)
|
|
510
|
+
except Exception:
|
|
511
|
+
return {}
|
|
512
|
+
return data if isinstance(data, dict) else {}
|
|
513
|
+
data: dict[str, Any] = {}
|
|
514
|
+
current_mapping: dict[str, Any] | None = None
|
|
515
|
+
for line in body.splitlines():
|
|
516
|
+
if not line.strip():
|
|
517
|
+
continue
|
|
518
|
+
if line.startswith((" ", "\t")) and current_mapping is not None and ":" in line:
|
|
519
|
+
key, value = line.split(":", 1)
|
|
520
|
+
current_mapping[key.strip()] = _plain_frontmatter_scalar(value.strip())
|
|
521
|
+
continue
|
|
522
|
+
current_mapping = None
|
|
523
|
+
if ":" not in line:
|
|
524
|
+
continue
|
|
525
|
+
key, value = line.split(":", 1)
|
|
526
|
+
key = key.strip()
|
|
527
|
+
value = value.strip()
|
|
528
|
+
if value:
|
|
529
|
+
data[key] = _plain_frontmatter_scalar(value)
|
|
530
|
+
else:
|
|
531
|
+
nested: dict[str, Any] = {}
|
|
532
|
+
data[key] = nested
|
|
533
|
+
current_mapping = nested
|
|
534
|
+
return data
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def resolve_suite_command(repo: Path, roadmap: Path, plan: Path | None = None) -> list[str] | None:
|
|
538
|
+
command, _findings = resolve_suite_command_doc(repo, roadmap, plan)
|
|
539
|
+
return command
|
|
540
|
+
|
|
541
|
+
|
|
542
|
+
def resolve_suite_command_doc(repo: Path, roadmap: Path, plan: Path | None = None) -> tuple[list[str] | None, tuple[SuiteCommandFinding, ...]]:
|
|
543
|
+
findings: list[SuiteCommandFinding] = []
|
|
544
|
+
roadmap_value = _automation_suite_command(roadmap)
|
|
545
|
+
plan_value = _automation_suite_command(plan) if plan is not None else None
|
|
546
|
+
source = str(plan) if plan_value is not None and plan is not None else str(roadmap)
|
|
547
|
+
raw_value = plan_value if plan_value is not None else roadmap_value
|
|
548
|
+
if raw_value is None:
|
|
549
|
+
return None, ()
|
|
550
|
+
command, finding = _normalize_suite_command(raw_value, source=source)
|
|
551
|
+
if finding is not None:
|
|
552
|
+
findings.append(finding)
|
|
553
|
+
return command, tuple(findings)
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def validate_plan_verification_commands_for_intake(repo: Path, plan: Path) -> list[Any]:
|
|
557
|
+
from .verification_evidence import ValidationFinding, validate_verification_commands
|
|
558
|
+
|
|
559
|
+
commands, _operational = verification_commands_from_plan(plan)
|
|
560
|
+
findings = list(validate_verification_commands(repo, commands))
|
|
561
|
+
_suite, suite_findings = resolve_suite_command_doc(repo, _roadmap_from_plan(repo, plan), plan)
|
|
562
|
+
for suite_finding in suite_findings:
|
|
563
|
+
findings.append(
|
|
564
|
+
ValidationFinding(
|
|
565
|
+
code=suite_finding.code,
|
|
566
|
+
message=suite_finding.message,
|
|
567
|
+
command_index=-1,
|
|
568
|
+
value=str(suite_finding.value) if suite_finding.value is not None else None,
|
|
569
|
+
)
|
|
570
|
+
)
|
|
571
|
+
return findings
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def verification_commands_from_plan(plan: Path) -> tuple[list[list[str]], list[dict[str, Any]]]:
|
|
575
|
+
try:
|
|
576
|
+
text = plan.read_text(encoding="utf-8")
|
|
577
|
+
except OSError:
|
|
578
|
+
return [], []
|
|
579
|
+
match = re.search(r"^##\s+Verification\s*$\n(?P<body>.*?)(?=^##\s+\S|\Z)", text, re.MULTILINE | re.DOTALL)
|
|
580
|
+
if not match:
|
|
581
|
+
return [], []
|
|
582
|
+
commands: list[list[str]] = []
|
|
583
|
+
operational: list[dict[str, Any]] = []
|
|
584
|
+
for line_number, line in enumerate(match.group("body").splitlines(), start=text[: match.start("body")].count("\n") + 1):
|
|
585
|
+
stripped = line.strip()
|
|
586
|
+
if not stripped.startswith("- "):
|
|
587
|
+
continue
|
|
588
|
+
raw = stripped[2:].strip()
|
|
589
|
+
parsed = _strip_markdown_command(raw)
|
|
590
|
+
if parsed is None:
|
|
591
|
+
continue
|
|
592
|
+
command_text, trailing_text = parsed
|
|
593
|
+
if not command_text:
|
|
594
|
+
continue
|
|
595
|
+
if re.search(r"\bevidence\s*:\s*operational\b", raw, re.IGNORECASE):
|
|
596
|
+
operational.append({"line": line_number, "command": command_text, "reason": "evidence: operational"})
|
|
597
|
+
continue
|
|
598
|
+
if trailing_text.strip():
|
|
599
|
+
continue
|
|
600
|
+
for chunk in _split_shell_and(command_text):
|
|
601
|
+
try:
|
|
602
|
+
argv = shlex.split(chunk)
|
|
603
|
+
except ValueError:
|
|
604
|
+
argv = []
|
|
605
|
+
if argv:
|
|
606
|
+
commands.append(argv)
|
|
607
|
+
return commands, operational
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _automation_suite_command(path: Path | None) -> object:
|
|
611
|
+
if path is None:
|
|
612
|
+
return None
|
|
613
|
+
try:
|
|
614
|
+
data = parse_frontmatter_document(path.read_text(encoding="utf-8"))
|
|
615
|
+
except OSError:
|
|
616
|
+
return None
|
|
617
|
+
automation = data.get("automation")
|
|
618
|
+
if not isinstance(automation, dict):
|
|
619
|
+
return None
|
|
620
|
+
return automation.get("suite_command")
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _normalize_suite_command(value: object, *, source: str) -> tuple[list[str] | None, SuiteCommandFinding | None]:
|
|
624
|
+
if isinstance(value, str):
|
|
625
|
+
try:
|
|
626
|
+
argv = shlex.split(value)
|
|
627
|
+
except ValueError as exc:
|
|
628
|
+
return None, SuiteCommandFinding("malformed_suite_command", str(exc), source, value)
|
|
629
|
+
elif isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
630
|
+
argv = list(value)
|
|
631
|
+
elif isinstance(value, list):
|
|
632
|
+
return None, SuiteCommandFinding(
|
|
633
|
+
"malformed_suite_command",
|
|
634
|
+
"automation.suite_command list entries must be strings",
|
|
635
|
+
source,
|
|
636
|
+
value,
|
|
637
|
+
)
|
|
638
|
+
else:
|
|
639
|
+
return None, SuiteCommandFinding(
|
|
640
|
+
"malformed_suite_command",
|
|
641
|
+
"automation.suite_command must be a shell string or list of strings",
|
|
642
|
+
source,
|
|
643
|
+
value,
|
|
644
|
+
)
|
|
645
|
+
if not argv or any(not part for part in argv):
|
|
646
|
+
return None, SuiteCommandFinding("empty_suite_command", "automation.suite_command must not be empty", source, value)
|
|
647
|
+
return argv, None
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def _strip_markdown_command(value: str) -> tuple[str, str] | None:
|
|
651
|
+
text = value.strip()
|
|
652
|
+
if text.startswith("`") and "`" in text[1:]:
|
|
653
|
+
end = text.find("`", 1)
|
|
654
|
+
return text[1:end].strip(), text[end + 1 :].strip()
|
|
655
|
+
return None
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def _split_shell_and(command: str) -> list[str]:
|
|
659
|
+
return [part.strip() for part in re.split(r"\s+&&\s+", command) if part.strip()]
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _plain_frontmatter_scalar(value: str) -> Any:
|
|
663
|
+
value = value.strip().strip("'\"")
|
|
664
|
+
if value.startswith("[") and value.endswith("]"):
|
|
665
|
+
try:
|
|
666
|
+
data = json.loads(value)
|
|
667
|
+
return data
|
|
668
|
+
except json.JSONDecodeError:
|
|
669
|
+
return [item.strip().strip("'\"") for item in value[1:-1].split(",") if item.strip()]
|
|
670
|
+
return value
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _roadmap_from_plan(repo: Path, plan: Path) -> Path:
|
|
674
|
+
metadata = plan_metadata(plan)
|
|
675
|
+
roadmap = metadata.get("roadmap")
|
|
676
|
+
return (repo / roadmap) if roadmap else repo / "specs" / "phase-plans-v1.md"
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def parse_roadmap_phases(roadmap: Path) -> list[str]:
|
|
680
|
+
text = roadmap.read_text()
|
|
681
|
+
aliases = [match.group(1).strip() for match in PHASE_RE.finditer(text)]
|
|
682
|
+
return aliases
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def roadmap_closeout_evidence_audit_enabled(roadmap: Path) -> bool:
|
|
686
|
+
text = roadmap.read_text(encoding="utf-8")
|
|
687
|
+
frontmatter = parse_frontmatter(text)
|
|
688
|
+
if _truthy_metadata_value(frontmatter.get("closeout_evidence_audit")):
|
|
689
|
+
return True
|
|
690
|
+
body_start = 0
|
|
691
|
+
if text.startswith("---\n"):
|
|
692
|
+
end = text.find("\n---", 4)
|
|
693
|
+
if end != -1:
|
|
694
|
+
body_start = end + len("\n---")
|
|
695
|
+
body = text[body_start:]
|
|
696
|
+
first_h2 = re.search(r"^##\s+.*$", body, re.MULTILINE)
|
|
697
|
+
if first_h2 is None:
|
|
698
|
+
return False
|
|
699
|
+
second_h2 = re.search(r"^##\s+.*$", body[first_h2.end() :], re.MULTILINE)
|
|
700
|
+
first_h2_body = (
|
|
701
|
+
body[first_h2.end() :]
|
|
702
|
+
if second_h2 is None
|
|
703
|
+
else body[first_h2.end() : first_h2.end() + second_h2.start()]
|
|
704
|
+
)
|
|
705
|
+
return bool(
|
|
706
|
+
re.search(
|
|
707
|
+
r"^\s*(?:-\s*)?closeout_evidence_audit\s*:\s*true\s*$",
|
|
708
|
+
first_h2_body,
|
|
709
|
+
re.IGNORECASE | re.MULTILINE,
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def _truthy_metadata_value(value: object) -> bool:
|
|
715
|
+
return str(value or "").strip().lower() in {"true", "yes", "1", "on"}
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def parse_dispatch_hints(path: Path, *, kind: str) -> dict[str, DispatchHints]:
|
|
719
|
+
"""Backward-compat entry: returns just the hints dict, silently dropping
|
|
720
|
+
buckets that contain unknown literals (which used to crash). For the
|
|
721
|
+
diagnostic-aware version that surfaces a parse_error for the runner to
|
|
722
|
+
convert to a contract_bug blocker, use ``parse_dispatch_hints_doc``."""
|
|
723
|
+
hints, _ = parse_dispatch_hints_doc(path, kind=kind)
|
|
724
|
+
return hints
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def parse_dispatch_hints_doc(
|
|
728
|
+
path: Path, *, kind: str
|
|
729
|
+
) -> tuple[dict[str, DispatchHints], tuple[DispatchHintsParseError, ...]]:
|
|
730
|
+
"""Parse ## Dispatch Hints with graceful soft-fail.
|
|
731
|
+
|
|
732
|
+
Per-bucket DispatchHints construction wrapped in try/except so a planner
|
|
733
|
+
that invented an unknown literal (e.g. ``browser_automation`` before it
|
|
734
|
+
was allowlisted) surfaces a structured DispatchHintsParseError rather
|
|
735
|
+
than crashing the whole runner. Mirrors F3's parse_execution_policy
|
|
736
|
+
pattern (parse_error field → runner converts to contract_bug blocker).
|
|
737
|
+
"""
|
|
738
|
+
try:
|
|
739
|
+
text = path.read_text(encoding="utf-8")
|
|
740
|
+
except OSError:
|
|
741
|
+
return {}, ()
|
|
742
|
+
section_match = DISPATCH_SECTION_RE.search(text)
|
|
743
|
+
if not section_match:
|
|
744
|
+
return {}, ()
|
|
745
|
+
section_body = section_match.group("body")
|
|
746
|
+
buckets: dict[str, dict[str, list[str]]] = {}
|
|
747
|
+
subsection_matches = list(DISPATCH_SUBSECTION_RE.finditer(section_body))
|
|
748
|
+
if subsection_matches:
|
|
749
|
+
for match in subsection_matches:
|
|
750
|
+
label = match.group("label").strip().lower()
|
|
751
|
+
key = "default" if label in {"default", "roadmap", "plan", "roadmap default", "plan default"} else label
|
|
752
|
+
_collect_dispatch_lines(match.group("body"), buckets, default_key=key)
|
|
753
|
+
else:
|
|
754
|
+
_collect_dispatch_lines(section_body, buckets, default_key="default")
|
|
755
|
+
result: dict[str, DispatchHints] = {}
|
|
756
|
+
errors: list[DispatchHintsParseError] = []
|
|
757
|
+
for action_key, values in buckets.items():
|
|
758
|
+
action = None if action_key == "default" else action_key
|
|
759
|
+
try:
|
|
760
|
+
result[action_key] = DispatchHints(
|
|
761
|
+
preferred_executors=tuple(values.get("preferred_executors", ())),
|
|
762
|
+
allowed_executors=tuple(values.get("allowed_executors", ())),
|
|
763
|
+
fallback_executors=tuple(values.get("fallback_executors", ())),
|
|
764
|
+
disabled_executors=tuple(values.get("disabled_executors", ())),
|
|
765
|
+
required_capabilities=tuple(values.get("required_capabilities", ())),
|
|
766
|
+
source=kind if action is None else f"{kind}:{action}",
|
|
767
|
+
action=action,
|
|
768
|
+
)
|
|
769
|
+
except ValueError as exc:
|
|
770
|
+
errors.append(
|
|
771
|
+
DispatchHintsParseError(
|
|
772
|
+
path=str(path),
|
|
773
|
+
bucket=action_key,
|
|
774
|
+
raw_message=str(exc),
|
|
775
|
+
invalid_literal=_extract_invalid_literal(str(exc)),
|
|
776
|
+
)
|
|
777
|
+
)
|
|
778
|
+
# Skip the bucket — downstream callers see no hints for this
|
|
779
|
+
# action_key, which is preferable to crashing the whole loop.
|
|
780
|
+
return result, tuple(errors)
|
|
781
|
+
|
|
782
|
+
|
|
783
|
+
def dispatch_hints_for_action(hints: dict[str, DispatchHints], action: str) -> DispatchHints | None:
|
|
784
|
+
action_key = action.lower()
|
|
785
|
+
specific = hints.get(action_key)
|
|
786
|
+
default = hints.get("default")
|
|
787
|
+
if specific and default:
|
|
788
|
+
return DispatchHints(
|
|
789
|
+
preferred_executors=specific.preferred_executors or default.preferred_executors,
|
|
790
|
+
allowed_executors=specific.allowed_executors or default.allowed_executors,
|
|
791
|
+
fallback_executors=specific.fallback_executors or default.fallback_executors,
|
|
792
|
+
disabled_executors=tuple(dict.fromkeys((*default.disabled_executors, *specific.disabled_executors))),
|
|
793
|
+
required_capabilities=tuple(dict.fromkeys((*default.required_capabilities, *specific.required_capabilities))),
|
|
794
|
+
source=specific.source,
|
|
795
|
+
action=action_key,
|
|
796
|
+
)
|
|
797
|
+
if specific:
|
|
798
|
+
return specific
|
|
799
|
+
if default:
|
|
800
|
+
return DispatchHints(
|
|
801
|
+
preferred_executors=default.preferred_executors,
|
|
802
|
+
allowed_executors=default.allowed_executors,
|
|
803
|
+
fallback_executors=default.fallback_executors,
|
|
804
|
+
disabled_executors=default.disabled_executors,
|
|
805
|
+
required_capabilities=default.required_capabilities,
|
|
806
|
+
source=default.source,
|
|
807
|
+
action=action_key,
|
|
808
|
+
)
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def parse_execution_policy(path: Path, *, kind: str) -> ExecutionPolicyDocument:
|
|
813
|
+
try:
|
|
814
|
+
text = path.read_text(encoding="utf-8")
|
|
815
|
+
except OSError:
|
|
816
|
+
return ExecutionPolicyDocument(source=kind)
|
|
817
|
+
section_match = EXECUTION_POLICY_SECTION_RE.search(text)
|
|
818
|
+
if not section_match:
|
|
819
|
+
return ExecutionPolicyDocument(source=kind)
|
|
820
|
+
body = section_match.group("body")
|
|
821
|
+
body_offset = section_match.start("body")
|
|
822
|
+
rules: list[ExecutionPolicyRule] = []
|
|
823
|
+
for match in EXECUTION_POLICY_LINE_RE.finditer(body):
|
|
824
|
+
selector = match.group("selector").strip()
|
|
825
|
+
try:
|
|
826
|
+
assignments = _execution_policy_assignments(match.group("value"))
|
|
827
|
+
rules.append(_execution_policy_rule(selector, assignments, source=kind))
|
|
828
|
+
except ValueError as exc:
|
|
829
|
+
raw_line = text[match.start() + body_offset : match.end() + body_offset]
|
|
830
|
+
line_number = text.count("\n", 0, match.start() + body_offset) + 1
|
|
831
|
+
return ExecutionPolicyDocument(
|
|
832
|
+
rules=(),
|
|
833
|
+
source=kind,
|
|
834
|
+
parse_error=ExecutionPolicyParseError(
|
|
835
|
+
path=str(path),
|
|
836
|
+
line_number=line_number,
|
|
837
|
+
raw_line=raw_line.strip(),
|
|
838
|
+
detail=str(exc),
|
|
839
|
+
),
|
|
840
|
+
)
|
|
841
|
+
return ExecutionPolicyDocument(rules=tuple(rules), source=kind)
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
def execution_policy_for_action(document: ExecutionPolicyDocument, action: str) -> ExecutionPolicyRule | None:
|
|
845
|
+
action_key = action.lower()
|
|
846
|
+
default_rule: ExecutionPolicyRule | None = None
|
|
847
|
+
specific_rule: ExecutionPolicyRule | None = None
|
|
848
|
+
for rule in document.rules:
|
|
849
|
+
if rule.lane is not None:
|
|
850
|
+
continue
|
|
851
|
+
if rule.action == action_key:
|
|
852
|
+
specific_rule = rule
|
|
853
|
+
elif rule.selector == "default":
|
|
854
|
+
default_rule = rule
|
|
855
|
+
if specific_rule and default_rule:
|
|
856
|
+
return _merge_execution_policy_rules(default_rule, specific_rule, selector=action_key, action=action_key)
|
|
857
|
+
if specific_rule:
|
|
858
|
+
return specific_rule
|
|
859
|
+
if default_rule:
|
|
860
|
+
return _merge_execution_policy_rules(default_rule, None, selector=action_key, action=action_key)
|
|
861
|
+
return None
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def execution_policy_for_lane(document: ExecutionPolicyDocument, action: str, lane: str) -> ExecutionPolicyRule | None:
|
|
865
|
+
action_rule = execution_policy_for_action(document, action)
|
|
866
|
+
lane_key = lane.strip().upper()
|
|
867
|
+
lane_rule = next((rule for rule in document.rules if rule.lane == lane_key), None)
|
|
868
|
+
if lane_rule and action_rule:
|
|
869
|
+
return _merge_execution_policy_rules(action_rule, lane_rule, selector=lane_key, action=action.lower(), lane=lane_key)
|
|
870
|
+
if lane_rule:
|
|
871
|
+
return _merge_execution_policy_rules(lane_rule, None, selector=lane_key, action=action.lower(), lane=lane_key)
|
|
872
|
+
return action_rule
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def execution_policy_dispatch_hints(rule: ExecutionPolicyRule | None) -> DispatchHints | None:
|
|
876
|
+
if rule is None or not rule.executor:
|
|
877
|
+
return None
|
|
878
|
+
fallbacks = (rule.fallback,) if rule.fallback in {"codex", "claude", "gemini", "opencode", "command", "manual"} else ()
|
|
879
|
+
return DispatchHints(
|
|
880
|
+
preferred_executors=(rule.executor,),
|
|
881
|
+
fallback_executors=fallbacks,
|
|
882
|
+
source=f"{rule.source}-policy",
|
|
883
|
+
action=rule.action,
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _execution_policy_assignments(value: str) -> dict[str, str]:
|
|
888
|
+
assignments: dict[str, str] = {}
|
|
889
|
+
for match in EXECUTION_POLICY_ASSIGNMENT_RE.finditer(value):
|
|
890
|
+
key = match.group("key").lower().replace("-", "_")
|
|
891
|
+
raw_value = match.group("value").strip().strip("`").strip()
|
|
892
|
+
assignments[key] = raw_value
|
|
893
|
+
if not assignments:
|
|
894
|
+
raise ValueError(f"malformed execution policy line: {value}")
|
|
895
|
+
return assignments
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _execution_policy_rule(selector: str, assignments: dict[str, str], *, source: str) -> ExecutionPolicyRule:
|
|
899
|
+
selector_key = selector.strip().lower()
|
|
900
|
+
action = selector_key if selector_key in PRODUCT_LOOP_ACTIONS else None
|
|
901
|
+
lane = None
|
|
902
|
+
normalized_selector = selector_key
|
|
903
|
+
if selector_key.startswith("lane "):
|
|
904
|
+
lane = selector_key.split(None, 1)[1].strip().upper()
|
|
905
|
+
normalized_selector = lane
|
|
906
|
+
elif re.match(r"^[a-z]+-\d+[a-z]?$", selector_key, re.IGNORECASE):
|
|
907
|
+
lane = selector_key.upper()
|
|
908
|
+
normalized_selector = lane
|
|
909
|
+
elif selector_key in {"default", "work-unit defaults", "work unit defaults"}:
|
|
910
|
+
normalized_selector = "default"
|
|
911
|
+
elif action is None:
|
|
912
|
+
raise ValueError(f"invalid execution policy selector: {selector}")
|
|
913
|
+
inherit_default = assignments.get("inherit_default", "false").lower() in {"true", "yes", "1"}
|
|
914
|
+
return ExecutionPolicyRule(
|
|
915
|
+
selector=normalized_selector,
|
|
916
|
+
action=action,
|
|
917
|
+
lane=lane,
|
|
918
|
+
executor=assignments.get("executor"),
|
|
919
|
+
model=assignments.get("model"),
|
|
920
|
+
effort=assignments.get("effort"),
|
|
921
|
+
work_unit_kind=assignments.get("work_unit"),
|
|
922
|
+
unsupported_policy_behavior=assignments.get("unsupported", "block"),
|
|
923
|
+
fallback=assignments.get("fallback"),
|
|
924
|
+
inherit_default=inherit_default,
|
|
925
|
+
source=source if action is None and lane is None else f"{source}:{normalized_selector}",
|
|
926
|
+
override_reason=assignments.get("reason"),
|
|
927
|
+
)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _merge_execution_policy_rules(
|
|
931
|
+
base: ExecutionPolicyRule,
|
|
932
|
+
override: ExecutionPolicyRule | None,
|
|
933
|
+
*,
|
|
934
|
+
selector: str,
|
|
935
|
+
action: str | None,
|
|
936
|
+
lane: str | None = None,
|
|
937
|
+
) -> ExecutionPolicyRule:
|
|
938
|
+
if override is None:
|
|
939
|
+
return ExecutionPolicyRule(
|
|
940
|
+
selector=selector,
|
|
941
|
+
action=action,
|
|
942
|
+
lane=lane,
|
|
943
|
+
executor=base.executor,
|
|
944
|
+
model=base.model,
|
|
945
|
+
effort=base.effort,
|
|
946
|
+
work_unit_kind=base.work_unit_kind,
|
|
947
|
+
unsupported_policy_behavior=base.unsupported_policy_behavior,
|
|
948
|
+
fallback=base.fallback,
|
|
949
|
+
inherit_default=base.inherit_default,
|
|
950
|
+
source=base.source,
|
|
951
|
+
override_reason=base.override_reason,
|
|
952
|
+
)
|
|
953
|
+
return ExecutionPolicyRule(
|
|
954
|
+
selector=selector,
|
|
955
|
+
action=action,
|
|
956
|
+
lane=lane,
|
|
957
|
+
executor=override.executor or base.executor,
|
|
958
|
+
model=override.model or base.model,
|
|
959
|
+
effort=override.effort or base.effort,
|
|
960
|
+
work_unit_kind=override.work_unit_kind or base.work_unit_kind,
|
|
961
|
+
unsupported_policy_behavior=override.unsupported_policy_behavior
|
|
962
|
+
if override.unsupported_policy_behavior != "block" or override.fallback or override.inherit_default
|
|
963
|
+
else base.unsupported_policy_behavior,
|
|
964
|
+
fallback=override.fallback or base.fallback,
|
|
965
|
+
inherit_default=override.inherit_default or base.inherit_default,
|
|
966
|
+
source=override.source,
|
|
967
|
+
override_reason=override.override_reason or base.override_reason,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def find_plan_artifact(repo: Path, phase: str, roadmap: Path | None = None) -> Path | None:
|
|
972
|
+
manifest_plan, regex_conflict = manifest_plan_artifact(repo, phase, roadmap=roadmap)
|
|
973
|
+
if manifest_plan is not None:
|
|
974
|
+
return manifest_plan
|
|
975
|
+
if regex_conflict is not None:
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
# Fast path: when we know the roadmap, construct the exact expected
|
|
979
|
+
# plan-doc filename and check it directly. This handles cases the legacy
|
|
980
|
+
# glob+regex iteration can't disambiguate, including hyphenated aliases
|
|
981
|
+
# (e.g. SL-1 in v32) and suffix-bearing spec names (e.g.
|
|
982
|
+
# phase-plans-v32-VISUALPARITY.md → phase-plan-v32-VISUALPARITY-SL-1.md).
|
|
983
|
+
# Surfaced by the regen 2026-05-22/23 v32-VISUALPARITY incident; see
|
|
984
|
+
# plans/detailed-phase-loop-plan-discovery-bugs-20260523-0224.md.
|
|
985
|
+
if roadmap is not None:
|
|
986
|
+
m = re.fullmatch(r"phase-plans-(v[\w.-]+)\.md", roadmap.name)
|
|
987
|
+
if m:
|
|
988
|
+
version = m.group(1)
|
|
989
|
+
expected = repo / "plans" / f"phase-plan-{version}-{phase}.md"
|
|
990
|
+
if expected.is_file():
|
|
991
|
+
if plan_matches_roadmap(repo, expected, roadmap, phase):
|
|
992
|
+
return expected.resolve()
|
|
993
|
+
# Fallback: glob+regex iteration (back-compat for case-folded matching
|
|
994
|
+
# or when roadmap is None). PLAN_RE captures (version, alias);
|
|
995
|
+
# group(2) is the alias.
|
|
996
|
+
phase_lower = phase.lower()
|
|
997
|
+
plans = sorted((repo / "plans").glob("phase-plan-v*-*.md"))
|
|
998
|
+
for plan in plans:
|
|
999
|
+
match = PLAN_RE.search(plan.name)
|
|
1000
|
+
if not match:
|
|
1001
|
+
continue
|
|
1002
|
+
if match.group(2).lower() != phase_lower:
|
|
1003
|
+
continue
|
|
1004
|
+
if roadmap is not None and not plan_matches_roadmap(repo, plan, roadmap, phase):
|
|
1005
|
+
continue
|
|
1006
|
+
return plan.resolve()
|
|
1007
|
+
return None
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def manifest_plan_artifact(repo: Path, phase: str, roadmap: Path | None = None) -> tuple[Path | None, dict[str, object] | None]:
|
|
1011
|
+
if _phase_manifest_disabled():
|
|
1012
|
+
return None, None
|
|
1013
|
+
phase_upper = phase.upper()
|
|
1014
|
+
candidates: list[tuple[object, Path]] = []
|
|
1015
|
+
for entry in _phase_manifest_entries(repo):
|
|
1016
|
+
if str(entry.phase_alias or "").upper() != phase_upper:
|
|
1017
|
+
continue
|
|
1018
|
+
if entry.status == "orphaned":
|
|
1019
|
+
continue
|
|
1020
|
+
plan = repo / entry.file
|
|
1021
|
+
if not plan.exists():
|
|
1022
|
+
return None, {
|
|
1023
|
+
"source": "manifest",
|
|
1024
|
+
"phase": phase_upper,
|
|
1025
|
+
"status": entry.status,
|
|
1026
|
+
"reason": "manifest_plan_file_missing",
|
|
1027
|
+
"file": entry.file,
|
|
1028
|
+
"slug": entry.slug,
|
|
1029
|
+
}
|
|
1030
|
+
if roadmap is not None:
|
|
1031
|
+
roadmap_ref = entry.roadmap_ref.file if entry.roadmap_ref else None
|
|
1032
|
+
if roadmap_ref != roadmap_repo_relative_path(repo, roadmap):
|
|
1033
|
+
continue
|
|
1034
|
+
if not plan_matches_roadmap(repo, plan, roadmap, phase):
|
|
1035
|
+
continue
|
|
1036
|
+
candidates.append((entry.updated_at, plan.resolve()))
|
|
1037
|
+
if not candidates:
|
|
1038
|
+
return None, None
|
|
1039
|
+
candidates.sort(key=lambda item: str(item[0]))
|
|
1040
|
+
manifest_plan = candidates[-1][1]
|
|
1041
|
+
regex_plan = _regex_plan_artifact(repo, phase, roadmap=roadmap)
|
|
1042
|
+
if regex_plan is not None and regex_plan.resolve() != manifest_plan.resolve():
|
|
1043
|
+
return manifest_plan, {
|
|
1044
|
+
"source": "manifest",
|
|
1045
|
+
"phase": phase_upper,
|
|
1046
|
+
"status": "conflict",
|
|
1047
|
+
"reason": "manifest_regex_plan_conflict",
|
|
1048
|
+
"manifest_file": str(manifest_plan.relative_to(repo.resolve())),
|
|
1049
|
+
"regex_file": str(regex_plan.relative_to(repo.resolve())),
|
|
1050
|
+
}
|
|
1051
|
+
return manifest_plan, None
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _regex_plan_artifact(repo: Path, phase: str, roadmap: Path | None = None) -> Path | None:
|
|
1055
|
+
if roadmap is not None:
|
|
1056
|
+
m = re.fullmatch(r"phase-plans-(v[\w.-]+)\.md", roadmap.name)
|
|
1057
|
+
if m:
|
|
1058
|
+
version = m.group(1)
|
|
1059
|
+
expected = repo / "plans" / f"phase-plan-{version}-{phase}.md"
|
|
1060
|
+
if expected.is_file() and plan_matches_roadmap(repo, expected, roadmap, phase):
|
|
1061
|
+
return expected.resolve()
|
|
1062
|
+
phase_lower = phase.lower()
|
|
1063
|
+
for plan in sorted((repo / "plans").glob("phase-plan-v*-*.md")):
|
|
1064
|
+
match = PLAN_RE.search(plan.name)
|
|
1065
|
+
if not match or match.group(2).lower() != phase_lower:
|
|
1066
|
+
continue
|
|
1067
|
+
if roadmap is not None and not plan_matches_roadmap(repo, plan, roadmap, phase):
|
|
1068
|
+
continue
|
|
1069
|
+
return plan.resolve()
|
|
1070
|
+
return None
|
|
1071
|
+
|
|
1072
|
+
|
|
1073
|
+
def _phase_manifest_disabled() -> bool:
|
|
1074
|
+
return os.environ.get("PHASE_LOOP_MANIFEST_DISABLED") == "1"
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
def _phase_manifest_entries(repo: Path) -> tuple[object, ...]:
|
|
1078
|
+
try:
|
|
1079
|
+
from .plan_manifest import read_manifest, validate_manifest
|
|
1080
|
+
except Exception:
|
|
1081
|
+
return ()
|
|
1082
|
+
manifest_path = repo / "plans" / "manifest.json"
|
|
1083
|
+
if manifest_path.exists() and not validate_manifest(manifest_path).valid:
|
|
1084
|
+
return ()
|
|
1085
|
+
try:
|
|
1086
|
+
manifest = read_manifest(repo)
|
|
1087
|
+
except Exception:
|
|
1088
|
+
return ()
|
|
1089
|
+
return tuple(entry for entry in manifest.plans if entry.type == "phase")
|
|
1090
|
+
|
|
1091
|
+
|
|
1092
|
+
def plan_is_stale(plan: Path, roadmap: Path) -> bool:
|
|
1093
|
+
repo = plan.resolve().parents[1] if plan.parent.name == "plans" else plan.parent
|
|
1094
|
+
return not plan_matches_roadmap(repo, plan, roadmap)
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def plan_metadata(plan: Path) -> dict[str, str]:
|
|
1098
|
+
try:
|
|
1099
|
+
return parse_frontmatter(plan.read_text())
|
|
1100
|
+
except OSError:
|
|
1101
|
+
return {}
|
|
1102
|
+
|
|
1103
|
+
|
|
1104
|
+
def roadmap_fingerprint(roadmap: Path) -> str:
|
|
1105
|
+
return roadmap_sha256(roadmap)
|
|
1106
|
+
|
|
1107
|
+
|
|
1108
|
+
def roadmap_repo_relative_path(repo: Path, roadmap: Path) -> str:
|
|
1109
|
+
resolved_repo = repo.resolve()
|
|
1110
|
+
resolved_roadmap = roadmap.resolve()
|
|
1111
|
+
try:
|
|
1112
|
+
return str(resolved_roadmap.relative_to(resolved_repo))
|
|
1113
|
+
except ValueError:
|
|
1114
|
+
return str(resolved_roadmap)
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
def plan_matches_roadmap(repo: Path, plan: Path, roadmap: Path, phase: str | None = None) -> bool:
|
|
1118
|
+
return plan_artifact_diagnostic(repo, plan, roadmap, phase) is None
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
def plan_artifact_diagnostic(repo: Path, plan: Path, roadmap: Path, phase: str | None = None) -> str | None:
|
|
1122
|
+
metadata = plan_metadata(plan)
|
|
1123
|
+
if metadata.get("phase_loop_plan_version") != "1":
|
|
1124
|
+
return "missing_or_invalid_phase_loop_plan_version"
|
|
1125
|
+
if phase is not None and metadata.get("phase", "").upper() != phase.upper():
|
|
1126
|
+
return "mismatched_phase"
|
|
1127
|
+
if metadata.get("roadmap") != roadmap_repo_relative_path(repo, roadmap):
|
|
1128
|
+
return "mismatched_roadmap_path"
|
|
1129
|
+
try:
|
|
1130
|
+
if metadata.get("roadmap_sha256") != roadmap_fingerprint(roadmap):
|
|
1131
|
+
return "mismatched_roadmap_sha256"
|
|
1132
|
+
except OSError:
|
|
1133
|
+
return "roadmap_unreadable"
|
|
1134
|
+
pipeline_diagnostic = pipeline_plan_metadata_diagnostic(repo, plan)
|
|
1135
|
+
if pipeline_diagnostic is not None:
|
|
1136
|
+
return f"pipeline_metadata:{pipeline_diagnostic.kind}"
|
|
1137
|
+
return None
|
|
1138
|
+
|
|
1139
|
+
|
|
1140
|
+
def parse_pipeline_plan_metadata(plan_or_frontmatter: Path | dict[str, str]) -> PipelinePlanMetadata:
|
|
1141
|
+
metadata = plan_metadata(plan_or_frontmatter) if isinstance(plan_or_frontmatter, Path) else dict(plan_or_frontmatter)
|
|
1142
|
+
return PipelinePlanMetadata(
|
|
1143
|
+
source_bundle=metadata.get("source_bundle") or None,
|
|
1144
|
+
source_bundle_sha256=metadata.get("source_bundle_sha256") or None,
|
|
1145
|
+
pipeline_phase_id=metadata.get("pipeline_phase_id") or None,
|
|
1146
|
+
pipeline_mode=metadata.get("pipeline_mode") or None,
|
|
1147
|
+
)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def pipeline_plan_metadata_diagnostic(
|
|
1151
|
+
repo: Path,
|
|
1152
|
+
plan: Path,
|
|
1153
|
+
*,
|
|
1154
|
+
pipeline_required: bool = False,
|
|
1155
|
+
) -> PipelineMetadataDiagnostic | None:
|
|
1156
|
+
try:
|
|
1157
|
+
metadata = parse_pipeline_plan_metadata(plan)
|
|
1158
|
+
except ValueError as exc:
|
|
1159
|
+
return PipelineMetadataDiagnostic(kind="invalid_pipeline_mode", message=str(exc))
|
|
1160
|
+
|
|
1161
|
+
if not pipeline_required and not metadata.required:
|
|
1162
|
+
return None
|
|
1163
|
+
if not metadata.source_bundle:
|
|
1164
|
+
return PipelineMetadataDiagnostic(
|
|
1165
|
+
kind="missing_source_bundle",
|
|
1166
|
+
message="pipeline-required plan is missing source_bundle frontmatter",
|
|
1167
|
+
metadata=metadata,
|
|
1168
|
+
)
|
|
1169
|
+
if not metadata.source_bundle_sha256:
|
|
1170
|
+
return PipelineMetadataDiagnostic(
|
|
1171
|
+
kind="missing_source_bundle_sha256",
|
|
1172
|
+
message="pipeline-required plan is missing source_bundle_sha256 frontmatter",
|
|
1173
|
+
metadata=metadata,
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
source_bundle = Path(metadata.source_bundle).expanduser()
|
|
1177
|
+
if not source_bundle.is_absolute():
|
|
1178
|
+
source_bundle = repo / source_bundle
|
|
1179
|
+
if not source_bundle.exists():
|
|
1180
|
+
return PipelineMetadataDiagnostic(
|
|
1181
|
+
kind="missing_source_bundle_file",
|
|
1182
|
+
message="pipeline-required source_bundle does not exist",
|
|
1183
|
+
metadata=metadata,
|
|
1184
|
+
expected_sha256=metadata.source_bundle_sha256,
|
|
1185
|
+
)
|
|
1186
|
+
actual_sha256 = hashlib.sha256(source_bundle.read_bytes()).hexdigest()
|
|
1187
|
+
if actual_sha256 != metadata.source_bundle_sha256:
|
|
1188
|
+
return PipelineMetadataDiagnostic(
|
|
1189
|
+
kind="mismatched_source_bundle_sha256",
|
|
1190
|
+
message="pipeline-required source_bundle_sha256 does not match the source bundle file",
|
|
1191
|
+
metadata=metadata,
|
|
1192
|
+
expected_sha256=metadata.source_bundle_sha256,
|
|
1193
|
+
actual_sha256=actual_sha256,
|
|
1194
|
+
)
|
|
1195
|
+
bundle_diagnostic = phase_source_bundle_diagnostic(
|
|
1196
|
+
repo,
|
|
1197
|
+
source_bundle,
|
|
1198
|
+
pipeline_mode=metadata.pipeline_mode,
|
|
1199
|
+
)
|
|
1200
|
+
if bundle_diagnostic is not None:
|
|
1201
|
+
return bundle_diagnostic
|
|
1202
|
+
return None
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def pipeline_execution_plan_diagnostic(
|
|
1206
|
+
repo: Path,
|
|
1207
|
+
plan: Path,
|
|
1208
|
+
*,
|
|
1209
|
+
phase: str | None = None,
|
|
1210
|
+
roadmap: Path | None = None,
|
|
1211
|
+
) -> PipelineMetadataDiagnostic | None:
|
|
1212
|
+
try:
|
|
1213
|
+
metadata = parse_pipeline_plan_metadata(plan)
|
|
1214
|
+
except ValueError as exc:
|
|
1215
|
+
return PipelineMetadataDiagnostic(kind="invalid_pipeline_mode", message=str(exc))
|
|
1216
|
+
|
|
1217
|
+
if metadata.empty:
|
|
1218
|
+
return None
|
|
1219
|
+
if not metadata.source_bundle:
|
|
1220
|
+
return PipelineMetadataDiagnostic(
|
|
1221
|
+
kind="missing_source_bundle",
|
|
1222
|
+
message="Pipeline-aware execution plan is missing source_bundle frontmatter",
|
|
1223
|
+
metadata=metadata,
|
|
1224
|
+
)
|
|
1225
|
+
if not metadata.source_bundle_sha256:
|
|
1226
|
+
return PipelineMetadataDiagnostic(
|
|
1227
|
+
kind="missing_source_bundle_sha256",
|
|
1228
|
+
message="Pipeline-aware execution plan is missing source_bundle_sha256 frontmatter",
|
|
1229
|
+
metadata=metadata,
|
|
1230
|
+
)
|
|
1231
|
+
|
|
1232
|
+
source_bundle = _resolve_bundle_path(repo, metadata.source_bundle)
|
|
1233
|
+
if not source_bundle.exists():
|
|
1234
|
+
return PipelineMetadataDiagnostic(
|
|
1235
|
+
kind="missing_source_bundle_file",
|
|
1236
|
+
message="Pipeline-aware execution source_bundle does not exist",
|
|
1237
|
+
metadata=metadata,
|
|
1238
|
+
expected_sha256=metadata.source_bundle_sha256,
|
|
1239
|
+
)
|
|
1240
|
+
actual_sha256 = hashlib.sha256(source_bundle.read_bytes()).hexdigest()
|
|
1241
|
+
if actual_sha256 != metadata.source_bundle_sha256:
|
|
1242
|
+
return PipelineMetadataDiagnostic(
|
|
1243
|
+
kind="mismatched_source_bundle_sha256",
|
|
1244
|
+
message="Pipeline-aware execution source_bundle_sha256 does not match the source bundle file",
|
|
1245
|
+
metadata=metadata,
|
|
1246
|
+
expected_sha256=metadata.source_bundle_sha256,
|
|
1247
|
+
actual_sha256=actual_sha256,
|
|
1248
|
+
)
|
|
1249
|
+
return phase_source_bundle_diagnostic(
|
|
1250
|
+
repo,
|
|
1251
|
+
source_bundle,
|
|
1252
|
+
phase=phase,
|
|
1253
|
+
roadmap=roadmap,
|
|
1254
|
+
pipeline_mode=metadata.pipeline_mode,
|
|
1255
|
+
)
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def pipeline_execution_blocker(diagnostic: PipelineMetadataDiagnostic) -> dict[str, object]:
|
|
1259
|
+
return {
|
|
1260
|
+
"human_required": diagnostic.human_required,
|
|
1261
|
+
"blocker_class": diagnostic.blocker_class,
|
|
1262
|
+
"blocker_summary": f"Pipeline execution freshness validation failed: {diagnostic.kind}",
|
|
1263
|
+
"required_human_inputs": (),
|
|
1264
|
+
"access_attempts": (),
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
|
|
1268
|
+
def load_execution_phase_source_bundle(
|
|
1269
|
+
repo: Path,
|
|
1270
|
+
plan: Path,
|
|
1271
|
+
*,
|
|
1272
|
+
phase: str | None = None,
|
|
1273
|
+
roadmap: Path | None = None,
|
|
1274
|
+
) -> PhaseSourceBundle | None:
|
|
1275
|
+
try:
|
|
1276
|
+
metadata = parse_pipeline_plan_metadata(plan)
|
|
1277
|
+
except ValueError:
|
|
1278
|
+
return None
|
|
1279
|
+
if metadata.empty or not metadata.source_bundle:
|
|
1280
|
+
return None
|
|
1281
|
+
return load_phase_source_bundle(
|
|
1282
|
+
repo,
|
|
1283
|
+
metadata.source_bundle,
|
|
1284
|
+
phase=phase,
|
|
1285
|
+
roadmap=roadmap,
|
|
1286
|
+
pipeline_mode=metadata.pipeline_mode,
|
|
1287
|
+
)
|
|
1288
|
+
|
|
1289
|
+
|
|
1290
|
+
def load_phase_source_bundle(
|
|
1291
|
+
repo: Path,
|
|
1292
|
+
bundle_path: str | Path | None,
|
|
1293
|
+
*,
|
|
1294
|
+
phase: str | None = None,
|
|
1295
|
+
roadmap: Path | None = None,
|
|
1296
|
+
pipeline_mode: str | None = None,
|
|
1297
|
+
) -> PhaseSourceBundle | None:
|
|
1298
|
+
bundle, diagnostic = _read_phase_source_bundle(
|
|
1299
|
+
repo,
|
|
1300
|
+
bundle_path,
|
|
1301
|
+
phase=phase,
|
|
1302
|
+
roadmap=roadmap,
|
|
1303
|
+
pipeline_mode=pipeline_mode,
|
|
1304
|
+
)
|
|
1305
|
+
return None if diagnostic is not None else bundle
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def phase_source_bundle_diagnostic(
|
|
1309
|
+
repo: Path,
|
|
1310
|
+
bundle_path: str | Path | None,
|
|
1311
|
+
*,
|
|
1312
|
+
phase: str | None = None,
|
|
1313
|
+
roadmap: Path | None = None,
|
|
1314
|
+
pipeline_mode: str | None = None,
|
|
1315
|
+
) -> PipelineMetadataDiagnostic | None:
|
|
1316
|
+
_bundle, diagnostic = _read_phase_source_bundle(
|
|
1317
|
+
repo,
|
|
1318
|
+
bundle_path,
|
|
1319
|
+
phase=phase,
|
|
1320
|
+
roadmap=roadmap,
|
|
1321
|
+
pipeline_mode=pipeline_mode,
|
|
1322
|
+
)
|
|
1323
|
+
return diagnostic
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def _read_phase_source_bundle(
|
|
1327
|
+
repo: Path,
|
|
1328
|
+
bundle_path: str | Path | None,
|
|
1329
|
+
*,
|
|
1330
|
+
phase: str | None,
|
|
1331
|
+
roadmap: Path | None,
|
|
1332
|
+
pipeline_mode: str | None,
|
|
1333
|
+
) -> tuple[PhaseSourceBundle | None, PipelineMetadataDiagnostic | None]:
|
|
1334
|
+
try:
|
|
1335
|
+
mode = pipeline_mode or ("pipeline_optional" if bundle_path else "standalone")
|
|
1336
|
+
PipelinePlanMetadata(pipeline_mode=mode)
|
|
1337
|
+
except ValueError as exc:
|
|
1338
|
+
return None, PipelineMetadataDiagnostic(kind="invalid_pipeline_mode", message=str(exc))
|
|
1339
|
+
|
|
1340
|
+
if not bundle_path:
|
|
1341
|
+
if mode == "pipeline_required":
|
|
1342
|
+
return None, PipelineMetadataDiagnostic(
|
|
1343
|
+
kind="missing_source_bundle",
|
|
1344
|
+
message="pipeline-required planning run is missing a source bundle",
|
|
1345
|
+
metadata=PipelinePlanMetadata(pipeline_mode=mode),
|
|
1346
|
+
)
|
|
1347
|
+
return None, None
|
|
1348
|
+
|
|
1349
|
+
source_bundle = _resolve_bundle_path(repo, bundle_path)
|
|
1350
|
+
metadata = PipelinePlanMetadata(source_bundle=_relative_repo_path(repo, source_bundle), pipeline_mode=mode)
|
|
1351
|
+
if not source_bundle.exists():
|
|
1352
|
+
return None, PipelineMetadataDiagnostic(
|
|
1353
|
+
kind="missing_source_bundle_file",
|
|
1354
|
+
message="Pipeline source bundle does not exist",
|
|
1355
|
+
metadata=metadata,
|
|
1356
|
+
)
|
|
1357
|
+
try:
|
|
1358
|
+
raw = source_bundle.read_bytes()
|
|
1359
|
+
data = json.loads(raw.decode("utf-8"))
|
|
1360
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
1361
|
+
return None, PipelineMetadataDiagnostic(
|
|
1362
|
+
kind="malformed_source_bundle",
|
|
1363
|
+
message=f"Pipeline source bundle is not valid JSON: {exc}",
|
|
1364
|
+
metadata=metadata,
|
|
1365
|
+
)
|
|
1366
|
+
actual_sha256 = hashlib.sha256(raw).hexdigest()
|
|
1367
|
+
if not isinstance(data, dict):
|
|
1368
|
+
return None, PipelineMetadataDiagnostic(
|
|
1369
|
+
kind="malformed_source_bundle",
|
|
1370
|
+
message="Pipeline source bundle root must be an object",
|
|
1371
|
+
metadata=metadata,
|
|
1372
|
+
actual_sha256=actual_sha256,
|
|
1373
|
+
)
|
|
1374
|
+
if data.get("schema") != PHASE_SOURCE_BUNDLE_SCHEMA:
|
|
1375
|
+
return None, PipelineMetadataDiagnostic(
|
|
1376
|
+
kind="malformed_source_bundle",
|
|
1377
|
+
message=f"Pipeline source bundle schema must be {PHASE_SOURCE_BUNDLE_SCHEMA}",
|
|
1378
|
+
metadata=metadata,
|
|
1379
|
+
actual_sha256=actual_sha256,
|
|
1380
|
+
)
|
|
1381
|
+
|
|
1382
|
+
required = ("phase", "roadmap", "protected_sources", "delegated_write_policy", "source_files", "artifact_target_root", "freshness")
|
|
1383
|
+
missing = [field for field in required if field not in data]
|
|
1384
|
+
if missing:
|
|
1385
|
+
return None, PipelineMetadataDiagnostic(
|
|
1386
|
+
kind="malformed_source_bundle",
|
|
1387
|
+
message=f"Pipeline source bundle is missing required fields: {', '.join(missing)}",
|
|
1388
|
+
metadata=metadata,
|
|
1389
|
+
actual_sha256=actual_sha256,
|
|
1390
|
+
)
|
|
1391
|
+
|
|
1392
|
+
phase_data = data.get("phase")
|
|
1393
|
+
roadmap_data = data.get("roadmap")
|
|
1394
|
+
protected_data = data.get("protected_sources")
|
|
1395
|
+
if not isinstance(phase_data, dict) or not isinstance(roadmap_data, dict) or not isinstance(protected_data, dict):
|
|
1396
|
+
return None, PipelineMetadataDiagnostic(
|
|
1397
|
+
kind="malformed_source_bundle",
|
|
1398
|
+
message="Pipeline source bundle phase, roadmap, and protected_sources fields must be objects",
|
|
1399
|
+
metadata=metadata,
|
|
1400
|
+
actual_sha256=actual_sha256,
|
|
1401
|
+
)
|
|
1402
|
+
phase_missing = [field for field in ("phase_id", "phase_alias", "phase_plan_path") if not phase_data.get(field)]
|
|
1403
|
+
roadmap_missing = [field for field in ("path", "sha256") if not roadmap_data.get(field)]
|
|
1404
|
+
if phase_missing or roadmap_missing:
|
|
1405
|
+
missing_text = ", ".join([*(f"phase.{field}" for field in phase_missing), *(f"roadmap.{field}" for field in roadmap_missing)])
|
|
1406
|
+
return None, PipelineMetadataDiagnostic(
|
|
1407
|
+
kind="malformed_source_bundle",
|
|
1408
|
+
message=f"Pipeline source bundle is missing required nested fields: {missing_text}",
|
|
1409
|
+
metadata=metadata,
|
|
1410
|
+
actual_sha256=actual_sha256,
|
|
1411
|
+
)
|
|
1412
|
+
|
|
1413
|
+
freshness = data.get("freshness") if isinstance(data.get("freshness"), dict) else {}
|
|
1414
|
+
expected_bundle_hash = freshness.get("source_bundle_hash")
|
|
1415
|
+
if isinstance(expected_bundle_hash, str) and _looks_like_sha256(expected_bundle_hash) and expected_bundle_hash != actual_sha256:
|
|
1416
|
+
return None, PipelineMetadataDiagnostic(
|
|
1417
|
+
kind="mismatched_source_bundle_sha256",
|
|
1418
|
+
message="Pipeline source bundle freshness.source_bundle_hash does not match the source bundle file",
|
|
1419
|
+
metadata=metadata,
|
|
1420
|
+
expected_sha256=expected_bundle_hash,
|
|
1421
|
+
actual_sha256=actual_sha256,
|
|
1422
|
+
)
|
|
1423
|
+
|
|
1424
|
+
phase_id = str(phase_data["phase_id"])
|
|
1425
|
+
phase_alias = str(phase_data["phase_alias"])
|
|
1426
|
+
if _phase_is_unknown(repo, roadmap, roadmap_data, phase, phase_id, phase_alias):
|
|
1427
|
+
return None, PipelineMetadataDiagnostic(
|
|
1428
|
+
kind="unknown_phase_id",
|
|
1429
|
+
message=f"Pipeline source bundle phase does not match the selected roadmap phase: {phase_id}/{phase_alias}",
|
|
1430
|
+
metadata=metadata,
|
|
1431
|
+
actual_sha256=actual_sha256,
|
|
1432
|
+
)
|
|
1433
|
+
|
|
1434
|
+
protected_entries = protected_data.get("entries")
|
|
1435
|
+
if not isinstance(protected_entries, list) or not protected_entries:
|
|
1436
|
+
return None, PipelineMetadataDiagnostic(
|
|
1437
|
+
kind="missing_protected_source_entries",
|
|
1438
|
+
message="Pipeline source bundle protected_sources.entries must contain at least one entry",
|
|
1439
|
+
metadata=metadata,
|
|
1440
|
+
actual_sha256=actual_sha256,
|
|
1441
|
+
)
|
|
1442
|
+
require_roles = bool(protected_data.get("requires_roles") or protected_data.get("role_required"))
|
|
1443
|
+
protected_sources: list[PipelineProtectedSource] = []
|
|
1444
|
+
for entry in protected_entries:
|
|
1445
|
+
if not isinstance(entry, dict):
|
|
1446
|
+
return None, PipelineMetadataDiagnostic(
|
|
1447
|
+
kind="malformed_source_bundle",
|
|
1448
|
+
message="Pipeline source bundle protected source entries must be objects",
|
|
1449
|
+
metadata=metadata,
|
|
1450
|
+
actual_sha256=actual_sha256,
|
|
1451
|
+
)
|
|
1452
|
+
if require_roles and not entry.get("role"):
|
|
1453
|
+
return None, PipelineMetadataDiagnostic(
|
|
1454
|
+
kind="malformed_source_bundle",
|
|
1455
|
+
message="Pipeline source bundle requires adoption-sensitive protected source role metadata",
|
|
1456
|
+
metadata=metadata,
|
|
1457
|
+
actual_sha256=actual_sha256,
|
|
1458
|
+
)
|
|
1459
|
+
try:
|
|
1460
|
+
protected_source = PipelineProtectedSource(
|
|
1461
|
+
path=str(entry.get("path", "")),
|
|
1462
|
+
category=str(entry.get("category", "")),
|
|
1463
|
+
sha256=str(entry["sha256"]) if entry.get("sha256") is not None else None,
|
|
1464
|
+
role=str(entry["role"]) if entry.get("role") is not None else None,
|
|
1465
|
+
)
|
|
1466
|
+
except ValueError as exc:
|
|
1467
|
+
return None, PipelineMetadataDiagnostic(
|
|
1468
|
+
kind="malformed_source_bundle",
|
|
1469
|
+
message=f"Pipeline source bundle has invalid protected source entry: {exc}",
|
|
1470
|
+
metadata=metadata,
|
|
1471
|
+
actual_sha256=actual_sha256,
|
|
1472
|
+
)
|
|
1473
|
+
if require_roles and protected_source.role not in PIPELINE_PROTECTED_SOURCE_ROLES:
|
|
1474
|
+
return None, PipelineMetadataDiagnostic(
|
|
1475
|
+
kind="malformed_source_bundle",
|
|
1476
|
+
message="Pipeline source bundle requires adoption-sensitive protected source role metadata",
|
|
1477
|
+
metadata=metadata,
|
|
1478
|
+
actual_sha256=actual_sha256,
|
|
1479
|
+
)
|
|
1480
|
+
protected_diagnostic = _protected_source_file_diagnostic(repo, protected_source, metadata, actual_sha256)
|
|
1481
|
+
if protected_diagnostic is not None:
|
|
1482
|
+
return None, protected_diagnostic
|
|
1483
|
+
protected_sources.append(protected_source)
|
|
1484
|
+
|
|
1485
|
+
source_files = data.get("source_files")
|
|
1486
|
+
if not isinstance(source_files, list):
|
|
1487
|
+
return None, PipelineMetadataDiagnostic(
|
|
1488
|
+
kind="malformed_source_bundle",
|
|
1489
|
+
message="Pipeline source bundle source_files must be a list",
|
|
1490
|
+
metadata=metadata,
|
|
1491
|
+
actual_sha256=actual_sha256,
|
|
1492
|
+
)
|
|
1493
|
+
|
|
1494
|
+
bundle = PhaseSourceBundle(
|
|
1495
|
+
path=_relative_repo_path(repo, source_bundle),
|
|
1496
|
+
sha256=actual_sha256,
|
|
1497
|
+
phase_id=phase_id,
|
|
1498
|
+
phase_alias=phase_alias,
|
|
1499
|
+
phase_plan_path=str(phase_data["phase_plan_path"]),
|
|
1500
|
+
roadmap_path=str(roadmap_data["path"]),
|
|
1501
|
+
roadmap_sha256=str(roadmap_data["sha256"]),
|
|
1502
|
+
protected_sources=tuple(protected_sources),
|
|
1503
|
+
delegated_write_policy=dict(data.get("delegated_write_policy") if isinstance(data.get("delegated_write_policy"), dict) else {}),
|
|
1504
|
+
source_files=tuple(item for item in source_files if isinstance(item, dict)),
|
|
1505
|
+
artifact_target_root=str(data.get("artifact_target_root") or ""),
|
|
1506
|
+
freshness=dict(freshness),
|
|
1507
|
+
pipeline_mode=mode if mode != "standalone" else "pipeline_optional",
|
|
1508
|
+
)
|
|
1509
|
+
return bundle, None
|
|
1510
|
+
|
|
1511
|
+
|
|
1512
|
+
def _resolve_bundle_path(repo: Path, bundle_path: str | Path) -> Path:
|
|
1513
|
+
path = Path(bundle_path).expanduser()
|
|
1514
|
+
if not path.is_absolute():
|
|
1515
|
+
path = repo / path
|
|
1516
|
+
return path.resolve()
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def _looks_like_sha256(value: str) -> bool:
|
|
1520
|
+
return bool(re.fullmatch(r"[0-9a-fA-F]{64}", value.strip()))
|
|
1521
|
+
|
|
1522
|
+
|
|
1523
|
+
def _phase_is_unknown(
|
|
1524
|
+
repo: Path,
|
|
1525
|
+
roadmap: Path | None,
|
|
1526
|
+
roadmap_data: dict[str, Any],
|
|
1527
|
+
selected_phase: str | None,
|
|
1528
|
+
phase_id: str,
|
|
1529
|
+
phase_alias: str,
|
|
1530
|
+
) -> bool:
|
|
1531
|
+
candidates = {phase_id.upper(), phase_alias.upper()}
|
|
1532
|
+
if selected_phase:
|
|
1533
|
+
return selected_phase.upper() not in candidates
|
|
1534
|
+
roadmap_path = roadmap
|
|
1535
|
+
if roadmap_path is None:
|
|
1536
|
+
raw_roadmap = roadmap_data.get("path")
|
|
1537
|
+
if isinstance(raw_roadmap, str) and raw_roadmap:
|
|
1538
|
+
roadmap_path = Path(raw_roadmap).expanduser()
|
|
1539
|
+
if not roadmap_path.is_absolute():
|
|
1540
|
+
roadmap_path = repo / roadmap_path
|
|
1541
|
+
if roadmap_path is None or not roadmap_path.exists():
|
|
1542
|
+
return False
|
|
1543
|
+
aliases = {alias.upper() for alias in parse_roadmap_phases(roadmap_path)}
|
|
1544
|
+
return not bool(candidates & aliases)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def _protected_source_file_diagnostic(
|
|
1548
|
+
repo: Path,
|
|
1549
|
+
source: PipelineProtectedSource,
|
|
1550
|
+
metadata: PipelinePlanMetadata,
|
|
1551
|
+
bundle_sha256: str,
|
|
1552
|
+
) -> PipelineMetadataDiagnostic | None:
|
|
1553
|
+
source_path = Path(source.path).expanduser()
|
|
1554
|
+
if not source_path.is_absolute():
|
|
1555
|
+
source_path = repo / source_path
|
|
1556
|
+
if not source_path.exists():
|
|
1557
|
+
return PipelineMetadataDiagnostic(
|
|
1558
|
+
kind="missing_protected_source_file",
|
|
1559
|
+
message=f"Pipeline protected source does not exist: {source.path}",
|
|
1560
|
+
metadata=metadata,
|
|
1561
|
+
actual_sha256=bundle_sha256,
|
|
1562
|
+
)
|
|
1563
|
+
if source.sha256 and _looks_like_sha256(source.sha256):
|
|
1564
|
+
actual = hashlib.sha256(source_path.read_bytes()).hexdigest()
|
|
1565
|
+
if actual != source.sha256:
|
|
1566
|
+
return PipelineMetadataDiagnostic(
|
|
1567
|
+
kind="mismatched_protected_source_sha256",
|
|
1568
|
+
message=f"Pipeline protected source sha256 does not match: {source.path}",
|
|
1569
|
+
metadata=metadata,
|
|
1570
|
+
expected_sha256=source.sha256,
|
|
1571
|
+
actual_sha256=actual,
|
|
1572
|
+
)
|
|
1573
|
+
return None
|
|
1574
|
+
|
|
1575
|
+
|
|
1576
|
+
def parse_plan_ownership(repo: Path, roadmap: Path, plan: Path | None) -> PlanOwnership:
|
|
1577
|
+
# plans/manifest.json is written by the runtime's own skill bundle and read
|
|
1578
|
+
# back for plan discovery in every repo, so the closeout classifier must
|
|
1579
|
+
# treat it as a control path (phase-owned) rather than an unowned dirty file.
|
|
1580
|
+
runtime_control = ("plans/manifest.json",)
|
|
1581
|
+
control_paths = (_relative_repo_path(repo, roadmap), *runtime_control)
|
|
1582
|
+
if plan is None:
|
|
1583
|
+
return PlanOwnership(owned_patterns=(), control_paths=control_paths, valid=False, errors=("missing_plan_artifact",))
|
|
1584
|
+
|
|
1585
|
+
control_paths = (_relative_repo_path(repo, roadmap), _relative_repo_path(repo, plan), *runtime_control)
|
|
1586
|
+
try:
|
|
1587
|
+
text = plan.read_text(encoding="utf-8")
|
|
1588
|
+
except OSError:
|
|
1589
|
+
return PlanOwnership(owned_patterns=(), control_paths=control_paths, valid=False, errors=("plan_unreadable",))
|
|
1590
|
+
|
|
1591
|
+
from .plan_ir import parse_phase_plan_ir
|
|
1592
|
+
|
|
1593
|
+
lane_ir = parse_phase_plan_ir(plan)
|
|
1594
|
+
if lane_ir.lanes:
|
|
1595
|
+
errors = []
|
|
1596
|
+
for diagnostic in lane_ir.diagnostics:
|
|
1597
|
+
if diagnostic.kind in {"missing_owned_files", "malformed_owned_files"}:
|
|
1598
|
+
errors.append(f"{diagnostic.kind}:{diagnostic.details.get('heading', diagnostic.lane_id or 'plan')}")
|
|
1599
|
+
else:
|
|
1600
|
+
errors.append(f"{diagnostic.kind}:{diagnostic.lane_id or 'plan'}")
|
|
1601
|
+
return PlanOwnership(
|
|
1602
|
+
owned_patterns=tuple(
|
|
1603
|
+
dict.fromkeys(
|
|
1604
|
+
pattern
|
|
1605
|
+
for lane in lane_ir.lanes
|
|
1606
|
+
if not lane.read_only
|
|
1607
|
+
for pattern in lane.owned_files
|
|
1608
|
+
)
|
|
1609
|
+
),
|
|
1610
|
+
control_paths=control_paths,
|
|
1611
|
+
valid=lane_ir.valid,
|
|
1612
|
+
errors=tuple(errors),
|
|
1613
|
+
)
|
|
1614
|
+
|
|
1615
|
+
lane_starts = list(LANE_SECTION_RE.finditer(text))
|
|
1616
|
+
if not lane_starts:
|
|
1617
|
+
return PlanOwnership(owned_patterns=(), control_paths=control_paths, valid=False, errors=("missing_lane_sections",))
|
|
1618
|
+
|
|
1619
|
+
owned_patterns: list[str] = []
|
|
1620
|
+
errors: list[str] = []
|
|
1621
|
+
for index, lane_start in enumerate(lane_starts):
|
|
1622
|
+
end = lane_starts[index + 1].start() if index + 1 < len(lane_starts) else len(text)
|
|
1623
|
+
section = text[lane_start.start() : end]
|
|
1624
|
+
raw = _owned_files_text(section)
|
|
1625
|
+
if raw is None:
|
|
1626
|
+
errors.append(f"missing_owned_files:{lane_start.group(0).strip()}")
|
|
1627
|
+
continue
|
|
1628
|
+
normalized = raw.strip("`").strip().lower()
|
|
1629
|
+
if normalized.startswith("none") or normalized.startswith("(none"):
|
|
1630
|
+
continue
|
|
1631
|
+
entries = [item.strip() for item in re.findall(r"`([^`]+)`", raw)]
|
|
1632
|
+
if not entries:
|
|
1633
|
+
errors.append(f"malformed_owned_files:{lane_start.group(0).strip()}")
|
|
1634
|
+
continue
|
|
1635
|
+
for entry in entries:
|
|
1636
|
+
if entry:
|
|
1637
|
+
owned_patterns.append(entry)
|
|
1638
|
+
|
|
1639
|
+
return PlanOwnership(
|
|
1640
|
+
owned_patterns=tuple(dict.fromkeys(owned_patterns)),
|
|
1641
|
+
control_paths=control_paths,
|
|
1642
|
+
valid=not errors,
|
|
1643
|
+
errors=tuple(errors),
|
|
1644
|
+
)
|
|
1645
|
+
|
|
1646
|
+
|
|
1647
|
+
# --- v45 FOUND: cross-phase scheduling, ownership, and reconciliation ----------
|
|
1648
|
+
# Frozen contracts for runner hardening (IF-0-FOUND-1..4). These are pure
|
|
1649
|
+
# functions plus the no-op reconcile hook; the runner main-loop wiring that
|
|
1650
|
+
# consumes select_ready_phase_wave lands in v45 Phase 3 (SCHED).
|
|
1651
|
+
|
|
1652
|
+
|
|
1653
|
+
def phase_owned_files(repo: Path, roadmap: Path, alias: str) -> tuple[str, ...]:
|
|
1654
|
+
"""IF-0-FOUND-2. Owned-file patterns a phase declares across its plan's
|
|
1655
|
+
writer lanes. Returns ``()`` when the phase is unplanned (no plan artifact)
|
|
1656
|
+
or genuinely owns nothing — the same empty-owned condition that makes a
|
|
1657
|
+
verified plan ``is_control_only``. Ownership is sourced from the resolved
|
|
1658
|
+
plan artifact via :func:`parse_plan_ownership`; roadmaps carry no lane
|
|
1659
|
+
ownership, so an unplanned phase is correctly empty-owned here."""
|
|
1660
|
+
plan = find_plan_artifact(repo, alias, roadmap)
|
|
1661
|
+
return parse_plan_ownership(repo, roadmap, plan).owned_patterns
|
|
1662
|
+
|
|
1663
|
+
|
|
1664
|
+
def compute_ready_phases(
|
|
1665
|
+
roadmap: Path,
|
|
1666
|
+
classifications: dict[str, str],
|
|
1667
|
+
completed: set[str],
|
|
1668
|
+
) -> tuple[str, ...]:
|
|
1669
|
+
"""IF-0-FOUND-3. Roadmap phases whose ``**Depends on**`` aliases are all in
|
|
1670
|
+
``completed`` and whose own status in ``classifications`` is neither
|
|
1671
|
+
``complete`` nor ``blocked``. Order follows the roadmap's phase order."""
|
|
1672
|
+
from .roadmap_lint import _extract_phases
|
|
1673
|
+
|
|
1674
|
+
try:
|
|
1675
|
+
text = roadmap.read_text(encoding="utf-8")
|
|
1676
|
+
except OSError:
|
|
1677
|
+
return ()
|
|
1678
|
+
ready: list[str] = []
|
|
1679
|
+
for phase in _extract_phases(text):
|
|
1680
|
+
if classifications.get(phase.alias) in {"complete", "blocked"}:
|
|
1681
|
+
continue
|
|
1682
|
+
if all(dependency in completed for dependency in phase.depends_on):
|
|
1683
|
+
ready.append(phase.alias)
|
|
1684
|
+
return tuple(ready)
|
|
1685
|
+
|
|
1686
|
+
|
|
1687
|
+
def select_ready_phase_wave(
|
|
1688
|
+
waves: tuple[tuple[str, ...], ...],
|
|
1689
|
+
classifications: dict[str, str],
|
|
1690
|
+
mode: str,
|
|
1691
|
+
) -> tuple[str, ...]:
|
|
1692
|
+
"""IF-0-FOUND-1. Given precomputed topological ``waves`` of phase aliases,
|
|
1693
|
+
return the aliases to dispatch next, skipping any already ``complete`` or
|
|
1694
|
+
``blocked``. ``off``/``serialized`` return at most one alias (today's
|
|
1695
|
+
one-phase-at-a-time behavior); ``concurrent`` returns the full ready wave.
|
|
1696
|
+
Selects from the first wave that still has ready phases."""
|
|
1697
|
+
for wave in waves:
|
|
1698
|
+
ready = tuple(
|
|
1699
|
+
alias
|
|
1700
|
+
for alias in wave
|
|
1701
|
+
if classifications.get(alias) not in {"complete", "blocked"}
|
|
1702
|
+
)
|
|
1703
|
+
if not ready:
|
|
1704
|
+
continue
|
|
1705
|
+
return ready if mode == "concurrent" else ready[:1]
|
|
1706
|
+
return ()
|
|
1707
|
+
|
|
1708
|
+
|
|
1709
|
+
def validate_concurrent_phase_ownership(
|
|
1710
|
+
repo: Path,
|
|
1711
|
+
roadmap: Path,
|
|
1712
|
+
aliases: tuple[str, ...],
|
|
1713
|
+
) -> tuple[LaneIRDiagnostic, ...]:
|
|
1714
|
+
"""IF-0-FOUND-2. Phase-level analog of
|
|
1715
|
+
``lane_scheduler.validate_concurrent_lane_ownership``: emit an
|
|
1716
|
+
``overlapping_write_ownership`` diagnostic for any pair of phases in
|
|
1717
|
+
``aliases`` whose declared owned files overlap, which would make them unsafe
|
|
1718
|
+
to execute concurrently."""
|
|
1719
|
+
owned = {alias: phase_owned_files(repo, roadmap, alias) for alias in aliases}
|
|
1720
|
+
diagnostics: list[LaneIRDiagnostic] = []
|
|
1721
|
+
ordered = list(aliases)
|
|
1722
|
+
for index, left in enumerate(ordered):
|
|
1723
|
+
for right in ordered[index + 1 :]:
|
|
1724
|
+
if _lane_patterns_overlap(owned[left], owned[right]):
|
|
1725
|
+
diagnostics.append(
|
|
1726
|
+
LaneIRDiagnostic(
|
|
1727
|
+
kind="overlapping_write_ownership",
|
|
1728
|
+
lane_id=right,
|
|
1729
|
+
message=(
|
|
1730
|
+
f"phases {left} and {right} cannot run concurrently "
|
|
1731
|
+
"with overlapping owned files"
|
|
1732
|
+
),
|
|
1733
|
+
details={"left": left, "right": right},
|
|
1734
|
+
)
|
|
1735
|
+
)
|
|
1736
|
+
return tuple(diagnostics)
|
|
1737
|
+
|
|
1738
|
+
|
|
1739
|
+
def reconcile_against_git_reality(
|
|
1740
|
+
repo: Path,
|
|
1741
|
+
roadmap: Path,
|
|
1742
|
+
classifications: dict[str, str],
|
|
1743
|
+
) -> dict[str, str]:
|
|
1744
|
+
"""IF-0-RECONCILE-1. Promote a phase to ``complete`` when merged git history
|
|
1745
|
+
proves it was completed against a roadmap section byte-identical to the
|
|
1746
|
+
current one (the roadmap was renamed/advanced, not the phase edited) AND the
|
|
1747
|
+
phase's own work still exists at HEAD.
|
|
1748
|
+
|
|
1749
|
+
Conservative by construction (criterion 4 dominates criterion 3): only
|
|
1750
|
+
``unplanned`` phases are candidates, the function never demotes, and any
|
|
1751
|
+
uncertainty — no completion-trailer commit, an *edited* section, or owned
|
|
1752
|
+
work that was reverted/modified since closeout — leaves the classification
|
|
1753
|
+
untouched so a phase that really needs to run is never falsely marked done.
|
|
1754
|
+
|
|
1755
|
+
Gated behind ``PHASE_LOOP_RECONCILE_GIT_REALITY`` (default off → identity
|
|
1756
|
+
no-op), the safe cutover point for landing on the live runtime."""
|
|
1757
|
+
if not reconcile_git_reality_enabled():
|
|
1758
|
+
return dict(classifications)
|
|
1759
|
+
result = dict(classifications)
|
|
1760
|
+
for alias, status in classifications.items():
|
|
1761
|
+
if status != "unplanned":
|
|
1762
|
+
continue
|
|
1763
|
+
if _phase_complete_in_git_reality(repo, roadmap, alias):
|
|
1764
|
+
result[alias] = "complete"
|
|
1765
|
+
return result
|
|
1766
|
+
|
|
1767
|
+
|
|
1768
|
+
def _phase_complete_in_git_reality(repo: Path, roadmap: Path, alias: str) -> bool:
|
|
1769
|
+
"""Shared predicate for both ``reconcile_against_git_reality`` and
|
|
1770
|
+
``classify_phase`` (single source of truth — the two entry points cannot
|
|
1771
|
+
drift into different safety postures).
|
|
1772
|
+
|
|
1773
|
+
True when HEAD-reachable history holds a completion commit for ``alias``
|
|
1774
|
+
whose (1) ``Terminal-Status`` parsed trailer is ``complete`` and ``Plan``
|
|
1775
|
+
trailer resolves to ``alias``, (2) roadmap section is byte-identical
|
|
1776
|
+
(``phase_sha256``) to the current roadmap's (rename, not edit), and (3) every
|
|
1777
|
+
owned file from that plan is unchanged between the completion commit and HEAD
|
|
1778
|
+
(the work still exists — a later revert/modify makes the diff non-empty)."""
|
|
1779
|
+
if not reconcile_git_reality_enabled():
|
|
1780
|
+
return False
|
|
1781
|
+
current_sha = phase_sha256(roadmap, alias)
|
|
1782
|
+
if current_sha is None:
|
|
1783
|
+
return False
|
|
1784
|
+
alias_upper = alias.upper()
|
|
1785
|
+
# Extract the Plan / Terminal-Status values with git's OWN trailer parser
|
|
1786
|
+
# (`%(trailers:key=...,valueonly)`), not a body scan — so a commit whose prose
|
|
1787
|
+
# merely mentions "Terminal-Status: complete" cannot forge a completion (B2).
|
|
1788
|
+
# Records: <sha> US <plan-values> US <status-values> RS; multi-valued trailers
|
|
1789
|
+
# are GS-separated (paths/statuses never contain these control bytes).
|
|
1790
|
+
log = _git(
|
|
1791
|
+
repo,
|
|
1792
|
+
"log",
|
|
1793
|
+
"--format=%H%x1f%(trailers:key=Plan,valueonly,separator=%x1d)"
|
|
1794
|
+
"%x1f%(trailers:key=Terminal-Status,valueonly,separator=%x1d)%x1e",
|
|
1795
|
+
)
|
|
1796
|
+
if not log:
|
|
1797
|
+
return False
|
|
1798
|
+
for record in log.split("\x1e"):
|
|
1799
|
+
parts = record.split("\x1f")
|
|
1800
|
+
if len(parts) != 3:
|
|
1801
|
+
continue
|
|
1802
|
+
commit = parts[0].strip()
|
|
1803
|
+
if not commit:
|
|
1804
|
+
continue
|
|
1805
|
+
statuses = {value.strip() for value in parts[2].split("\x1d") if value.strip()}
|
|
1806
|
+
if "complete" not in statuses:
|
|
1807
|
+
continue
|
|
1808
|
+
plan_rel = next((value.strip() for value in parts[1].split("\x1d") if value.strip()), "")
|
|
1809
|
+
if not plan_rel or not _plan_rel_matches_alias(plan_rel, alias_upper):
|
|
1810
|
+
continue
|
|
1811
|
+
historical_sha = _historical_phase_sha256(repo, commit, plan_rel, alias)
|
|
1812
|
+
if historical_sha is None or historical_sha != current_sha:
|
|
1813
|
+
continue
|
|
1814
|
+
# B1: the work must still exist at HEAD, not merely have existed once.
|
|
1815
|
+
if not _owned_work_persists_to_head(repo, commit, plan_rel, roadmap):
|
|
1816
|
+
continue
|
|
1817
|
+
return True
|
|
1818
|
+
return False
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
def _plan_rel_matches_alias(plan_rel: str, alias_upper: str) -> bool:
|
|
1822
|
+
match = PLAN_RE.search(plan_rel)
|
|
1823
|
+
return bool(match and match.group(2).upper() == alias_upper)
|
|
1824
|
+
|
|
1825
|
+
|
|
1826
|
+
def _historical_phase_sha256(repo: Path, commit: str, plan_rel: str, alias: str) -> str | None:
|
|
1827
|
+
"""Recompute the phase-section sha256 from the roadmap as it stood at the
|
|
1828
|
+
completion commit, so a rename (new filename, identical section) matches while
|
|
1829
|
+
an edit (changed section) does not."""
|
|
1830
|
+
plan_text = _git_show_blob(repo, commit, plan_rel)
|
|
1831
|
+
if not plan_text:
|
|
1832
|
+
return None
|
|
1833
|
+
roadmap_rel = parse_frontmatter(plan_text).get("roadmap")
|
|
1834
|
+
if not roadmap_rel:
|
|
1835
|
+
return None
|
|
1836
|
+
roadmap_text = _git_show_blob(repo, commit, roadmap_rel)
|
|
1837
|
+
if not roadmap_text:
|
|
1838
|
+
return None
|
|
1839
|
+
tmp = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
|
|
1840
|
+
try:
|
|
1841
|
+
tmp.write(roadmap_text)
|
|
1842
|
+
tmp.close()
|
|
1843
|
+
return phase_sha256(Path(tmp.name), alias)
|
|
1844
|
+
finally:
|
|
1845
|
+
os.unlink(tmp.name)
|
|
1846
|
+
|
|
1847
|
+
|
|
1848
|
+
def _owned_work_persists_to_head(repo: Path, commit: str, plan_rel: str, roadmap: Path) -> bool:
|
|
1849
|
+
"""True when every owned file declared in the plan at ``commit`` is unchanged
|
|
1850
|
+
between ``commit`` and HEAD. A reverted or rewritten owned file yields a
|
|
1851
|
+
non-empty diff → the work no longer matches the closeout → no promotion.
|
|
1852
|
+
|
|
1853
|
+
Scope: the *phase's own* artifacts persisting, not global repo consistency (a
|
|
1854
|
+
reverted dependency owned by another phase is verification's concern). A
|
|
1855
|
+
control-only plan (no owned files) has no artifacts to revert and passes."""
|
|
1856
|
+
plan_text = _git_show_blob(repo, commit, plan_rel)
|
|
1857
|
+
if not plan_text:
|
|
1858
|
+
return False
|
|
1859
|
+
tmp = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
|
|
1860
|
+
try:
|
|
1861
|
+
tmp.write(plan_text)
|
|
1862
|
+
tmp.close()
|
|
1863
|
+
ownership = parse_plan_ownership(repo, roadmap, Path(tmp.name))
|
|
1864
|
+
finally:
|
|
1865
|
+
os.unlink(tmp.name)
|
|
1866
|
+
if not ownership.owned_patterns:
|
|
1867
|
+
# A *valid* control-only plan owns no files → nothing to revert → persists.
|
|
1868
|
+
# An *invalid*/unparseable plan (valid=False, empty owned) is genuinely
|
|
1869
|
+
# misconfigured and must NOT promote — is_control_only is False for it.
|
|
1870
|
+
return ownership.is_control_only
|
|
1871
|
+
# Resolve owned patterns to the EXACT files present in the completion commit's
|
|
1872
|
+
# tree using the codebase's OWN ownership matcher (globstar-aware), then diff
|
|
1873
|
+
# those literal paths. We cannot hand the glob patterns to git pathspec: git's
|
|
1874
|
+
# default pathspec mis-handles `**` (e.g. `dir/**/*.ext` matches neither the
|
|
1875
|
+
# file in `ls-tree` nor a deletion in `diff`), which would silently miss a
|
|
1876
|
+
# reverted file. Literal paths diff reliably — a reverted/deleted/modified
|
|
1877
|
+
# owned file yields rc=1 → no promotion.
|
|
1878
|
+
listed = subprocess.run(
|
|
1879
|
+
["git", "-C", str(repo), "ls-tree", "-r", "--name-only", commit],
|
|
1880
|
+
capture_output=True,
|
|
1881
|
+
text=True,
|
|
1882
|
+
)
|
|
1883
|
+
if listed.returncode != 0:
|
|
1884
|
+
return False
|
|
1885
|
+
exact = [
|
|
1886
|
+
line.strip()
|
|
1887
|
+
for line in listed.stdout.splitlines()
|
|
1888
|
+
if line.strip() and ownership.matches_dirty_output(line.strip())
|
|
1889
|
+
]
|
|
1890
|
+
if not exact:
|
|
1891
|
+
# The plan declares owned files but none exist in the completion commit's
|
|
1892
|
+
# tree — the completion did not actually produce them → do not promote.
|
|
1893
|
+
return False
|
|
1894
|
+
proc = subprocess.run(
|
|
1895
|
+
["git", "-C", str(repo), "diff", "--quiet", commit, "HEAD", "--", *exact],
|
|
1896
|
+
stdout=subprocess.DEVNULL,
|
|
1897
|
+
stderr=subprocess.DEVNULL,
|
|
1898
|
+
)
|
|
1899
|
+
return proc.returncode == 0
|
|
1900
|
+
|
|
1901
|
+
|
|
1902
|
+
def _git_show_blob(repo: Path, ref: str, rel_path: str) -> str | None:
|
|
1903
|
+
"""``git show <ref>:<path>`` without ``_git``'s trailing-whitespace strip, so
|
|
1904
|
+
the recomputed ``phase_sha256`` matches the committed bytes exactly."""
|
|
1905
|
+
try:
|
|
1906
|
+
return subprocess.check_output(
|
|
1907
|
+
["git", "-C", str(repo), "show", f"{ref}:{rel_path}"],
|
|
1908
|
+
text=True,
|
|
1909
|
+
stderr=subprocess.DEVNULL,
|
|
1910
|
+
)
|
|
1911
|
+
except Exception:
|
|
1912
|
+
return None
|
|
1913
|
+
|
|
1914
|
+
|
|
1915
|
+
def classify_phase_team_eligibility(repo: Path, roadmap: Path, plan: Path | None) -> PhaseTeamEligibility:
|
|
1916
|
+
ownership = parse_plan_ownership(repo, roadmap, plan)
|
|
1917
|
+
if plan is None:
|
|
1918
|
+
return PhaseTeamEligibility(
|
|
1919
|
+
allowed_execution_modes=("solo",),
|
|
1920
|
+
default_execution_mode="solo",
|
|
1921
|
+
eligible_for_native_team=False,
|
|
1922
|
+
has_disjoint_write_lanes=False,
|
|
1923
|
+
has_only_read_only_lanes=False,
|
|
1924
|
+
unmanaged_write_risk=True,
|
|
1925
|
+
reason="missing_phase_plan",
|
|
1926
|
+
invalid_reasons=("missing_phase_plan",),
|
|
1927
|
+
)
|
|
1928
|
+
from .plan_ir import parse_phase_plan_ir
|
|
1929
|
+
|
|
1930
|
+
lane_ir = parse_phase_plan_ir(plan) if plan is not None else None
|
|
1931
|
+
if (
|
|
1932
|
+
lane_ir is not None
|
|
1933
|
+
and lane_ir.lanes
|
|
1934
|
+
and lane_ir.diagnostics
|
|
1935
|
+
and all(diagnostic.kind == "overlapping_write_ownership" for diagnostic in lane_ir.diagnostics)
|
|
1936
|
+
):
|
|
1937
|
+
lanes = tuple(
|
|
1938
|
+
PlanLane(
|
|
1939
|
+
heading=lane.heading,
|
|
1940
|
+
owned_patterns=lane.owned_files,
|
|
1941
|
+
read_only=lane.read_only,
|
|
1942
|
+
text="",
|
|
1943
|
+
)
|
|
1944
|
+
for lane in lane_ir.lanes
|
|
1945
|
+
)
|
|
1946
|
+
write_lanes = [lane for lane in lanes if not lane.read_only]
|
|
1947
|
+
overlaps = _overlapping_lane_pairs(write_lanes)
|
|
1948
|
+
return PhaseTeamEligibility(
|
|
1949
|
+
allowed_execution_modes=("solo",),
|
|
1950
|
+
default_execution_mode="solo",
|
|
1951
|
+
eligible_for_native_team=False,
|
|
1952
|
+
has_disjoint_write_lanes=False,
|
|
1953
|
+
has_only_read_only_lanes=False,
|
|
1954
|
+
unmanaged_write_risk=True,
|
|
1955
|
+
reason="overlapping_owned_files",
|
|
1956
|
+
invalid_reasons=tuple(f"overlap:{left}<->{right}" for left, right in overlaps),
|
|
1957
|
+
lane_summaries=tuple(
|
|
1958
|
+
{
|
|
1959
|
+
"heading": lane.heading,
|
|
1960
|
+
"read_only": lane.read_only,
|
|
1961
|
+
"owned_patterns": list(lane.owned_patterns),
|
|
1962
|
+
}
|
|
1963
|
+
for lane in lanes
|
|
1964
|
+
),
|
|
1965
|
+
)
|
|
1966
|
+
|
|
1967
|
+
if not ownership.valid:
|
|
1968
|
+
return PhaseTeamEligibility(
|
|
1969
|
+
allowed_execution_modes=("solo",),
|
|
1970
|
+
default_execution_mode="solo",
|
|
1971
|
+
eligible_for_native_team=False,
|
|
1972
|
+
has_disjoint_write_lanes=False,
|
|
1973
|
+
has_only_read_only_lanes=False,
|
|
1974
|
+
unmanaged_write_risk=True,
|
|
1975
|
+
reason="invalid_owned_files_contract",
|
|
1976
|
+
invalid_reasons=ownership.errors,
|
|
1977
|
+
)
|
|
1978
|
+
|
|
1979
|
+
if lane_ir is not None and lane_ir.lanes:
|
|
1980
|
+
lanes = tuple(
|
|
1981
|
+
PlanLane(
|
|
1982
|
+
heading=lane.heading,
|
|
1983
|
+
owned_patterns=lane.owned_files,
|
|
1984
|
+
read_only=lane.read_only,
|
|
1985
|
+
text="",
|
|
1986
|
+
)
|
|
1987
|
+
for lane in lane_ir.lanes
|
|
1988
|
+
)
|
|
1989
|
+
else:
|
|
1990
|
+
lanes = _parse_plan_lanes(plan)
|
|
1991
|
+
if not lanes:
|
|
1992
|
+
return PhaseTeamEligibility(
|
|
1993
|
+
allowed_execution_modes=("solo",),
|
|
1994
|
+
default_execution_mode="solo",
|
|
1995
|
+
eligible_for_native_team=False,
|
|
1996
|
+
has_disjoint_write_lanes=False,
|
|
1997
|
+
has_only_read_only_lanes=False,
|
|
1998
|
+
unmanaged_write_risk=True,
|
|
1999
|
+
reason="missing_lane_sections",
|
|
2000
|
+
invalid_reasons=("missing_lane_sections",),
|
|
2001
|
+
)
|
|
2002
|
+
|
|
2003
|
+
write_lanes = [lane for lane in lanes if not lane.read_only]
|
|
2004
|
+
read_only_lanes = [lane for lane in lanes if lane.read_only]
|
|
2005
|
+
overlaps = _overlapping_lane_pairs(write_lanes)
|
|
2006
|
+
unmanaged_write_risk = bool(overlaps)
|
|
2007
|
+
has_disjoint_write_lanes = bool(write_lanes) and not unmanaged_write_risk
|
|
2008
|
+
has_only_read_only_lanes = bool(lanes) and len(read_only_lanes) == len(lanes)
|
|
2009
|
+
eligible = has_only_read_only_lanes or has_disjoint_write_lanes
|
|
2010
|
+
allowed_modes = ("solo", "subagent", "agent_team") if eligible else ("solo",)
|
|
2011
|
+
reason = (
|
|
2012
|
+
"read_only_lanes_only"
|
|
2013
|
+
if has_only_read_only_lanes
|
|
2014
|
+
else "disjoint_write_lanes"
|
|
2015
|
+
if has_disjoint_write_lanes
|
|
2016
|
+
else "overlapping_owned_files"
|
|
2017
|
+
)
|
|
2018
|
+
invalid_reasons = tuple(f"overlap:{left}<->{right}" for left, right in overlaps)
|
|
2019
|
+
lane_summaries = tuple(
|
|
2020
|
+
{
|
|
2021
|
+
"heading": lane.heading,
|
|
2022
|
+
"read_only": lane.read_only,
|
|
2023
|
+
"owned_patterns": list(lane.owned_patterns),
|
|
2024
|
+
}
|
|
2025
|
+
for lane in lanes
|
|
2026
|
+
)
|
|
2027
|
+
return PhaseTeamEligibility(
|
|
2028
|
+
allowed_execution_modes=allowed_modes,
|
|
2029
|
+
default_execution_mode="solo",
|
|
2030
|
+
eligible_for_native_team=eligible,
|
|
2031
|
+
has_disjoint_write_lanes=has_disjoint_write_lanes,
|
|
2032
|
+
has_only_read_only_lanes=has_only_read_only_lanes,
|
|
2033
|
+
unmanaged_write_risk=unmanaged_write_risk,
|
|
2034
|
+
reason=reason,
|
|
2035
|
+
invalid_reasons=invalid_reasons,
|
|
2036
|
+
lane_summaries=lane_summaries,
|
|
2037
|
+
)
|
|
2038
|
+
|
|
2039
|
+
|
|
2040
|
+
def _owned_files_text(section: str) -> str | None:
|
|
2041
|
+
lines = section.splitlines()
|
|
2042
|
+
for index, line in enumerate(lines):
|
|
2043
|
+
owned_match = re.match(r"^\s*(?:-\s+)?\*\*Owned files\*\*:\s*(.+?)\s*$", line, re.IGNORECASE)
|
|
2044
|
+
if not owned_match:
|
|
2045
|
+
continue
|
|
2046
|
+
parts = [owned_match.group(1).strip()]
|
|
2047
|
+
for continuation in lines[index + 1 :]:
|
|
2048
|
+
stripped = continuation.strip()
|
|
2049
|
+
if not stripped:
|
|
2050
|
+
break
|
|
2051
|
+
if re.match(r"^(?:-\s+)?\*\*[^*]+\*\*:", stripped) or stripped.startswith("#"):
|
|
2052
|
+
break
|
|
2053
|
+
if "`" not in stripped:
|
|
2054
|
+
break
|
|
2055
|
+
parts.append(stripped)
|
|
2056
|
+
return " ".join(parts)
|
|
2057
|
+
return None
|
|
2058
|
+
|
|
2059
|
+
|
|
2060
|
+
def _parse_plan_lanes(plan: Path) -> tuple[PlanLane, ...]:
|
|
2061
|
+
try:
|
|
2062
|
+
text = plan.read_text(encoding="utf-8")
|
|
2063
|
+
except OSError:
|
|
2064
|
+
return ()
|
|
2065
|
+
lane_starts = list(LANE_SECTION_RE.finditer(text))
|
|
2066
|
+
lanes: list[PlanLane] = []
|
|
2067
|
+
for index, lane_start in enumerate(lane_starts):
|
|
2068
|
+
end = lane_starts[index + 1].start() if index + 1 < len(lane_starts) else len(text)
|
|
2069
|
+
section = text[lane_start.start() : end]
|
|
2070
|
+
raw = _owned_files_text(section)
|
|
2071
|
+
if raw is None:
|
|
2072
|
+
continue
|
|
2073
|
+
normalized = raw.strip().lower()
|
|
2074
|
+
if normalized.startswith("none"):
|
|
2075
|
+
lanes.append(
|
|
2076
|
+
PlanLane(
|
|
2077
|
+
heading=lane_start.group(0).strip(),
|
|
2078
|
+
owned_patterns=(),
|
|
2079
|
+
read_only=True,
|
|
2080
|
+
text=section,
|
|
2081
|
+
)
|
|
2082
|
+
)
|
|
2083
|
+
continue
|
|
2084
|
+
entries = tuple(item.strip() for item in re.findall(r"`([^`]+)`", raw) if item.strip())
|
|
2085
|
+
if not entries:
|
|
2086
|
+
continue
|
|
2087
|
+
lanes.append(
|
|
2088
|
+
PlanLane(
|
|
2089
|
+
heading=lane_start.group(0).strip(),
|
|
2090
|
+
owned_patterns=entries,
|
|
2091
|
+
read_only=False,
|
|
2092
|
+
text=section,
|
|
2093
|
+
)
|
|
2094
|
+
)
|
|
2095
|
+
return tuple(lanes)
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
def _overlapping_lane_pairs(lanes: list[PlanLane]) -> tuple[tuple[str, str], ...]:
|
|
2099
|
+
overlaps: list[tuple[str, str]] = []
|
|
2100
|
+
for index, left in enumerate(lanes):
|
|
2101
|
+
for right in lanes[index + 1 :]:
|
|
2102
|
+
if _lane_patterns_overlap(left.owned_patterns, right.owned_patterns):
|
|
2103
|
+
overlaps.append((left.heading, right.heading))
|
|
2104
|
+
return tuple(overlaps)
|
|
2105
|
+
|
|
2106
|
+
|
|
2107
|
+
def _lane_patterns_overlap(left_patterns: tuple[str, ...], right_patterns: tuple[str, ...]) -> bool:
|
|
2108
|
+
for left in left_patterns:
|
|
2109
|
+
for right in right_patterns:
|
|
2110
|
+
if _patterns_overlap(left, right):
|
|
2111
|
+
return True
|
|
2112
|
+
return False
|
|
2113
|
+
|
|
2114
|
+
|
|
2115
|
+
def _patterns_overlap(left: str, right: str) -> bool:
|
|
2116
|
+
if left == right:
|
|
2117
|
+
return True
|
|
2118
|
+
left_has_glob = _has_glob(left)
|
|
2119
|
+
right_has_glob = _has_glob(right)
|
|
2120
|
+
if not left_has_glob and fnmatchcase(left, right):
|
|
2121
|
+
return True
|
|
2122
|
+
if not right_has_glob and fnmatchcase(right, left):
|
|
2123
|
+
return True
|
|
2124
|
+
if left_has_glob and right_has_glob:
|
|
2125
|
+
left_prefix = _pattern_prefix(left)
|
|
2126
|
+
right_prefix = _pattern_prefix(right)
|
|
2127
|
+
return bool(left_prefix and right_prefix and (left_prefix.startswith(right_prefix) or right_prefix.startswith(left_prefix)))
|
|
2128
|
+
return False
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def _owned_pattern_matches(repo_path: str, pattern: str) -> bool:
|
|
2132
|
+
if pattern.endswith("/"):
|
|
2133
|
+
return repo_path.startswith(pattern)
|
|
2134
|
+
if "*" not in pattern and "?" not in pattern:
|
|
2135
|
+
return False
|
|
2136
|
+
return fnmatchcase(repo_path, pattern)
|
|
2137
|
+
|
|
2138
|
+
|
|
2139
|
+
def expanded_dirty_ownership_matches(ownership: PlanOwnership, repo_path: str) -> bool:
|
|
2140
|
+
if not _valid_relative_posix_path(repo_path):
|
|
2141
|
+
return False
|
|
2142
|
+
return any(_dirty_output_matches_owned_pattern(repo_path, pattern) for pattern in ownership.owned_patterns)
|
|
2143
|
+
|
|
2144
|
+
|
|
2145
|
+
def _dirty_output_matches_owned_pattern(repo_path: str, pattern: str) -> bool:
|
|
2146
|
+
if not _valid_relative_posix_path(pattern):
|
|
2147
|
+
return False
|
|
2148
|
+
return _matches_test_or_fixture_sibling(repo_path, pattern) or _matches_vendor_module_test(repo_path, pattern)
|
|
2149
|
+
|
|
2150
|
+
|
|
2151
|
+
def _valid_relative_posix_path(repo_path: str) -> bool:
|
|
2152
|
+
path = PurePosixPath(repo_path)
|
|
2153
|
+
return bool(repo_path) and not path.is_absolute() and ".." not in path.parts
|
|
2154
|
+
|
|
2155
|
+
|
|
2156
|
+
def _matches_test_or_fixture_sibling(repo_path: str, pattern: str) -> bool:
|
|
2157
|
+
if pattern.endswith("/") or _has_glob(pattern):
|
|
2158
|
+
return False
|
|
2159
|
+
owned = PurePosixPath(pattern)
|
|
2160
|
+
target = PurePosixPath(repo_path)
|
|
2161
|
+
if not owned.suffix or target.parent.parent != owned.parent:
|
|
2162
|
+
return False
|
|
2163
|
+
if target.parent.name == "__tests__":
|
|
2164
|
+
return target.name in {f"{owned.stem}.test{owned.suffix}", f"{owned.stem}.spec{owned.suffix}"}
|
|
2165
|
+
if target.parent.name == "__fixtures__":
|
|
2166
|
+
return target.name.startswith(f"{owned.stem}.")
|
|
2167
|
+
return False
|
|
2168
|
+
|
|
2169
|
+
|
|
2170
|
+
def _matches_vendor_module_test(repo_path: str, pattern: str) -> bool:
|
|
2171
|
+
owned = PurePosixPath(pattern)
|
|
2172
|
+
target = PurePosixPath(repo_path)
|
|
2173
|
+
if len(owned.parts) < 3 or owned.parts[0] != "vendor" or owned.parts[2] != "src":
|
|
2174
|
+
return False
|
|
2175
|
+
return (
|
|
2176
|
+
len(target.parts) == 4
|
|
2177
|
+
and target.parts[0] == "vendor"
|
|
2178
|
+
and target.parts[1] == owned.parts[1]
|
|
2179
|
+
and target.parts[2] == "tests"
|
|
2180
|
+
and target.parts[3].startswith("test_")
|
|
2181
|
+
and target.suffix == ".py"
|
|
2182
|
+
)
|
|
2183
|
+
|
|
2184
|
+
|
|
2185
|
+
def _has_glob(pattern: str) -> bool:
|
|
2186
|
+
return any(token in pattern for token in ("*", "?", "["))
|
|
2187
|
+
|
|
2188
|
+
|
|
2189
|
+
def _pattern_prefix(pattern: str) -> str:
|
|
2190
|
+
match = re.match(r"^[^*?\[]+", pattern)
|
|
2191
|
+
return match.group(0).rstrip("/") if match else ""
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def handoff_matches_roadmap(repo: Path, phase: str, roadmap: Path, handoff: dict[str, str] | None) -> bool:
|
|
2195
|
+
if not handoff:
|
|
2196
|
+
return False
|
|
2197
|
+
artifact = handoff.get("artifact")
|
|
2198
|
+
if not artifact:
|
|
2199
|
+
return False
|
|
2200
|
+
artifact_path = Path(artifact).expanduser().resolve()
|
|
2201
|
+
try:
|
|
2202
|
+
artifact_path.relative_to(repo.resolve())
|
|
2203
|
+
except ValueError:
|
|
2204
|
+
return False
|
|
2205
|
+
if not artifact_path.exists() or PLAN_RE.search(artifact_path.name) is None:
|
|
2206
|
+
return False
|
|
2207
|
+
return plan_matches_roadmap(repo, artifact_path, roadmap, phase)
|
|
2208
|
+
|
|
2209
|
+
|
|
2210
|
+
def list_plan_artifacts(repo: Path) -> dict[str, Path]:
|
|
2211
|
+
result: dict[str, Path] = {}
|
|
2212
|
+
for plan in sorted((repo / "plans").glob("phase-plan-v*-*.md")):
|
|
2213
|
+
match = PLAN_RE.search(plan.name)
|
|
2214
|
+
if match:
|
|
2215
|
+
result[match.group(2).upper()] = plan.resolve()
|
|
2216
|
+
return result
|
|
2217
|
+
|
|
2218
|
+
|
|
2219
|
+
def latest_skill_handoff(identity: RepoIdentity, skill: str) -> dict[str, str] | None:
|
|
2220
|
+
path = Path.home() / ".codex" / "skills" / skill / "handoffs" / identity.repo_hash / identity.branch_slug / "latest.md"
|
|
2221
|
+
if not path.exists():
|
|
2222
|
+
return None
|
|
2223
|
+
text = path.read_text()
|
|
2224
|
+
frontmatter = parse_frontmatter(text)
|
|
2225
|
+
if frontmatter.get("from") != skill:
|
|
2226
|
+
return None
|
|
2227
|
+
if frontmatter.get("repo") != identity.repo_hash:
|
|
2228
|
+
return None
|
|
2229
|
+
if Path(frontmatter.get("repo_root", "")).expanduser().resolve() != identity.root:
|
|
2230
|
+
return None
|
|
2231
|
+
if frontmatter.get("branch_slug") != identity.branch_slug:
|
|
2232
|
+
return None
|
|
2233
|
+
if frontmatter.get("branch") != identity.branch:
|
|
2234
|
+
return None
|
|
2235
|
+
if not frontmatter.get("commit"):
|
|
2236
|
+
return None
|
|
2237
|
+
artifact = frontmatter.get("artifact")
|
|
2238
|
+
if not artifact:
|
|
2239
|
+
return None
|
|
2240
|
+
artifact_path = Path(artifact).expanduser().resolve()
|
|
2241
|
+
try:
|
|
2242
|
+
artifact_path.relative_to(identity.root)
|
|
2243
|
+
except ValueError:
|
|
2244
|
+
return None
|
|
2245
|
+
if not artifact_path.exists():
|
|
2246
|
+
return None
|
|
2247
|
+
automation = parse_automation_status(text)
|
|
2248
|
+
return {
|
|
2249
|
+
**frontmatter,
|
|
2250
|
+
**automation,
|
|
2251
|
+
"workflow_skill": skill,
|
|
2252
|
+
"originating_harness": skill.split("-", 1)[0],
|
|
2253
|
+
}
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
def latest_workflow_handoff(
|
|
2257
|
+
identity: RepoIdentity,
|
|
2258
|
+
repo: Path,
|
|
2259
|
+
roadmap: Path,
|
|
2260
|
+
skills: Iterable[str],
|
|
2261
|
+
) -> dict[str, str] | None:
|
|
2262
|
+
candidates: list[tuple[str, int, dict[str, str]]] = []
|
|
2263
|
+
for index, skill in enumerate(skills):
|
|
2264
|
+
handoff = latest_skill_handoff(identity, skill)
|
|
2265
|
+
if not handoff:
|
|
2266
|
+
continue
|
|
2267
|
+
artifact = handoff.get("artifact")
|
|
2268
|
+
if not artifact:
|
|
2269
|
+
continue
|
|
2270
|
+
artifact_path = Path(artifact).expanduser().resolve()
|
|
2271
|
+
if not plan_matches_roadmap(repo, artifact_path, roadmap):
|
|
2272
|
+
continue
|
|
2273
|
+
candidates.append((handoff.get("timestamp", ""), -index, handoff))
|
|
2274
|
+
if not candidates:
|
|
2275
|
+
return None
|
|
2276
|
+
candidates.sort()
|
|
2277
|
+
return candidates[-1][2]
|
|
2278
|
+
|
|
2279
|
+
|
|
2280
|
+
def parse_automation_status(text: str) -> dict[str, object]:
|
|
2281
|
+
text = _last_automation_block(text)
|
|
2282
|
+
values: dict[str, object] = {}
|
|
2283
|
+
for key in ("status", "next_skill", "next_command", "human_required", "blocker_class", "blocker_summary", "verification_status"):
|
|
2284
|
+
match = re.search(rf"^\s*{re.escape(key)}:\s*(.+?)\s*$", text, re.MULTILINE)
|
|
2285
|
+
if match:
|
|
2286
|
+
values[f"automation_{key}"] = match.group(1).strip().strip("'\"")
|
|
2287
|
+
list_match = re.search(r"^\s*required_human_inputs:\s*(?:\n(?P<items>(?:\s+-\s+.+\n?)+)|(?P<inline>\[\]))", text, re.MULTILINE)
|
|
2288
|
+
if list_match:
|
|
2289
|
+
if list_match.group("inline") == "[]":
|
|
2290
|
+
values["automation_required_human_inputs"] = []
|
|
2291
|
+
else:
|
|
2292
|
+
items = []
|
|
2293
|
+
for line in (list_match.group("items") or "").splitlines():
|
|
2294
|
+
item = re.sub(r"^\s+-\s+", "", line).strip().strip("'\"")
|
|
2295
|
+
if item:
|
|
2296
|
+
items.append(item)
|
|
2297
|
+
values["automation_required_human_inputs"] = items
|
|
2298
|
+
return values
|
|
2299
|
+
|
|
2300
|
+
|
|
2301
|
+
def _last_automation_block(text: str) -> str:
|
|
2302
|
+
normalized_marker = "Normalized shared automation closeout:"
|
|
2303
|
+
normalized_index = text.rfind(normalized_marker)
|
|
2304
|
+
if normalized_index >= 0:
|
|
2305
|
+
original_index = text.find("\n\nOriginal child output:", normalized_index)
|
|
2306
|
+
if original_index >= 0:
|
|
2307
|
+
text = text[normalized_index:original_index]
|
|
2308
|
+
else:
|
|
2309
|
+
text = text[normalized_index:]
|
|
2310
|
+
matches = list(re.finditer(r"(?m)^(?P<indent>[ \t]*)automation:\s*$", text))
|
|
2311
|
+
if not matches:
|
|
2312
|
+
return text
|
|
2313
|
+
match = matches[-1]
|
|
2314
|
+
base_indent = len(match.group("indent"))
|
|
2315
|
+
selected: list[str] = []
|
|
2316
|
+
for index, line in enumerate(text[match.start() :].splitlines()):
|
|
2317
|
+
if index == 0 or not line.strip():
|
|
2318
|
+
selected.append(line)
|
|
2319
|
+
continue
|
|
2320
|
+
indent = len(line) - len(line.lstrip())
|
|
2321
|
+
if indent > base_indent:
|
|
2322
|
+
selected.append(line)
|
|
2323
|
+
continue
|
|
2324
|
+
break
|
|
2325
|
+
return "\n".join(selected)
|
|
2326
|
+
|
|
2327
|
+
|
|
2328
|
+
def phase_after(phases: Iterable[str], current: str) -> str | None:
|
|
2329
|
+
items = list(phases)
|
|
2330
|
+
for index, phase in enumerate(items):
|
|
2331
|
+
if phase.lower() == current.lower() and index + 1 < len(items):
|
|
2332
|
+
return items[index + 1]
|
|
2333
|
+
return None
|
|
2334
|
+
|
|
2335
|
+
|
|
2336
|
+
def _collect_dispatch_lines(section: str, buckets: dict[str, dict[str, list[str]]], *, default_key: str) -> None:
|
|
2337
|
+
for match in DISPATCH_LINE_RE.finditer(section):
|
|
2338
|
+
action = (match.group("action") or default_key).strip().lower()
|
|
2339
|
+
if action != "default":
|
|
2340
|
+
_validate_action(action)
|
|
2341
|
+
label = match.group("label").strip().lower().replace(" ", "_")
|
|
2342
|
+
values = _parse_dispatch_values(match.group("value"))
|
|
2343
|
+
bucket = buckets.setdefault(action, {})
|
|
2344
|
+
current = bucket.setdefault(label, [])
|
|
2345
|
+
for value in values:
|
|
2346
|
+
if value not in current:
|
|
2347
|
+
current.append(value)
|
|
2348
|
+
|
|
2349
|
+
|
|
2350
|
+
def _parse_dispatch_values(raw: str) -> list[str]:
|
|
2351
|
+
quoted = re.findall(r"`([^`]+)`", raw)
|
|
2352
|
+
values = quoted or [part.strip() for part in raw.split(",")]
|
|
2353
|
+
cleaned = [value.strip().lower() for value in values if value.strip()]
|
|
2354
|
+
return cleaned
|
|
2355
|
+
|
|
2356
|
+
|
|
2357
|
+
def _validate_action(action: str) -> None:
|
|
2358
|
+
if action not in PRODUCT_LOOP_ACTIONS:
|
|
2359
|
+
raise ValueError(f"invalid dispatch hint action: {action}")
|
|
2360
|
+
|
|
2361
|
+
|
|
2362
|
+
def _relative_repo_path(repo: Path, path: Path) -> str:
|
|
2363
|
+
resolved_repo = repo.resolve()
|
|
2364
|
+
resolved_path = path.resolve()
|
|
2365
|
+
try:
|
|
2366
|
+
return str(resolved_path.relative_to(resolved_repo))
|
|
2367
|
+
except ValueError:
|
|
2368
|
+
return str(resolved_path)
|