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,1550 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .classifier import classify_all
|
|
7
|
+
from .closeout_classifier import classify_unowned_path
|
|
8
|
+
from .discovery import PLAN_RE, find_plan_artifact, manifest_plan_artifact, parse_automation_status, plan_matches_roadmap
|
|
9
|
+
from .events import read_events
|
|
10
|
+
from .models import BLOCKER_CLASSES, StateSnapshot, utc_now
|
|
11
|
+
from .pipeline_adapter.branch_ops import REFUSE_DEFAULT_BRANCH_COMMIT_PREFIX
|
|
12
|
+
from .provenance import (
|
|
13
|
+
phase_provenance_map,
|
|
14
|
+
phase_sha256,
|
|
15
|
+
provenance_mismatch_reason,
|
|
16
|
+
roadmap_sha256,
|
|
17
|
+
snapshot_provenance,
|
|
18
|
+
status_provenance_matches,
|
|
19
|
+
)
|
|
20
|
+
from .state import load_state
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
RECONCILE_EVENT_STATUSES = {
|
|
24
|
+
"complete",
|
|
25
|
+
"blocked",
|
|
26
|
+
"unknown",
|
|
27
|
+
"executed",
|
|
28
|
+
"awaiting_phase_closeout",
|
|
29
|
+
"executing",
|
|
30
|
+
"planned",
|
|
31
|
+
"unplanned",
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def reconcile(repo: Path, roadmap: Path) -> StateSnapshot:
|
|
36
|
+
phases = classify_all(repo, roadmap)
|
|
37
|
+
current_roadmap_sha = roadmap_sha256(roadmap)
|
|
38
|
+
current_phase_sha = phase_provenance_map(roadmap)
|
|
39
|
+
snapshot = load_state(repo)
|
|
40
|
+
human_required = False
|
|
41
|
+
blocker_class = None
|
|
42
|
+
blocker_summary = None
|
|
43
|
+
required_human_inputs: tuple[str, ...] = ()
|
|
44
|
+
access_attempts: tuple[dict, ...] = ()
|
|
45
|
+
dirty_paths: tuple[str, ...] = ()
|
|
46
|
+
phase_owned_dirty_paths: tuple[str, ...] = ()
|
|
47
|
+
previous_phase_owned_paths: tuple[str, ...] = ()
|
|
48
|
+
unowned_dirty_paths: tuple[str, ...] = ()
|
|
49
|
+
pre_existing_dirty_paths: tuple[str, ...] = ()
|
|
50
|
+
phase_owned_dirty = False
|
|
51
|
+
terminal_summary = None
|
|
52
|
+
closeout_terminal_status = None
|
|
53
|
+
closeout_summary = None
|
|
54
|
+
latest_closeout_summary = None
|
|
55
|
+
latest_terminal_summary = None
|
|
56
|
+
ledger_warnings: list[dict] = []
|
|
57
|
+
ledger_duplicates_skipped: list[dict] = []
|
|
58
|
+
seen_event_keys: set[tuple[object, ...]] = set()
|
|
59
|
+
blocker_phase: str | None = None
|
|
60
|
+
dirty_summary_by_phase: dict[str, dict[str, object]] = {}
|
|
61
|
+
closeout_summary_by_phase: dict[str, dict[str, object]] = {}
|
|
62
|
+
terminal_summary_by_phase: dict[str, dict[str, object]] = {}
|
|
63
|
+
ledger_warnings.extend(_reconcile_plan_manifest(repo, roadmap, phases))
|
|
64
|
+
if snapshot:
|
|
65
|
+
same_roadmap = Path(snapshot.roadmap).expanduser().resolve() == roadmap.resolve()
|
|
66
|
+
if same_roadmap:
|
|
67
|
+
for phase, status in snapshot.phases.items():
|
|
68
|
+
if phase not in phases:
|
|
69
|
+
continue
|
|
70
|
+
if status == "unplanned":
|
|
71
|
+
continue
|
|
72
|
+
if not status_provenance_matches(status, snapshot.roadmap_sha256, snapshot.phase_sha256.get(phase), current_roadmap_sha, current_phase_sha.get(phase)):
|
|
73
|
+
ledger_warnings.append(_ledger_warning("state", phase, status, provenance_mismatch_reason(status, snapshot.roadmap_sha256, snapshot.phase_sha256.get(phase), current_roadmap_sha, current_phase_sha.get(phase))))
|
|
74
|
+
continue
|
|
75
|
+
if status == "planned" and find_plan_artifact(repo, phase, roadmap=roadmap) is None:
|
|
76
|
+
continue
|
|
77
|
+
if status == "executing":
|
|
78
|
+
phases[phase] = "unknown" if _dirty(repo) else "executing"
|
|
79
|
+
elif status in {"planned", "complete", "blocked", "unknown", "executed", "awaiting_phase_closeout"}:
|
|
80
|
+
phases[phase] = status
|
|
81
|
+
if (
|
|
82
|
+
snapshot.current_phase
|
|
83
|
+
and snapshot.phases.get(snapshot.current_phase) in {"blocked", "awaiting_phase_closeout"}
|
|
84
|
+
and status_provenance_matches(
|
|
85
|
+
snapshot.phases.get(snapshot.current_phase, "unknown"),
|
|
86
|
+
snapshot.roadmap_sha256,
|
|
87
|
+
snapshot.phase_sha256.get(snapshot.current_phase),
|
|
88
|
+
current_roadmap_sha,
|
|
89
|
+
current_phase_sha.get(snapshot.current_phase),
|
|
90
|
+
)
|
|
91
|
+
):
|
|
92
|
+
dirty_summary_by_phase[snapshot.current_phase] = {
|
|
93
|
+
"dirty_paths": list(snapshot.dirty_paths),
|
|
94
|
+
"phase_owned_dirty_paths": list(snapshot.phase_owned_dirty_paths),
|
|
95
|
+
"previous_phase_owned_paths": list(snapshot.previous_phase_owned_paths),
|
|
96
|
+
"unowned_dirty_paths": list(snapshot.unowned_dirty_paths),
|
|
97
|
+
"pre_existing_dirty_paths": list(snapshot.pre_existing_dirty_paths),
|
|
98
|
+
"phase_owned_dirty": snapshot.phase_owned_dirty,
|
|
99
|
+
"terminal_status": snapshot.closeout_terminal_status,
|
|
100
|
+
}
|
|
101
|
+
if snapshot.closeout_summary:
|
|
102
|
+
closeout_summary_by_phase[snapshot.current_phase] = dict(snapshot.closeout_summary)
|
|
103
|
+
if snapshot.terminal_summary:
|
|
104
|
+
terminal_summary_by_phase[snapshot.current_phase] = dict(snapshot.terminal_summary)
|
|
105
|
+
if same_roadmap and snapshot.human_required and _snapshot_blocker_trusted(snapshot, current_roadmap_sha, current_phase_sha):
|
|
106
|
+
human_required = snapshot.human_required
|
|
107
|
+
blocker_class = snapshot.blocker_class
|
|
108
|
+
blocker_summary = snapshot.blocker_summary
|
|
109
|
+
required_human_inputs = snapshot.required_human_inputs
|
|
110
|
+
access_attempts = snapshot.access_attempts
|
|
111
|
+
pending_event_warnings: dict[str, list[dict]] = {}
|
|
112
|
+
latest_untrusted_terminal_event: dict[str, dict] = {}
|
|
113
|
+
legacy_executor_closeout_action_warned = False
|
|
114
|
+
for raw_event in read_events(repo):
|
|
115
|
+
event = _normalize_automation_event(repo, roadmap, raw_event, current_roadmap_sha, current_phase_sha)
|
|
116
|
+
event, normalized_legacy_executor_closeout = _normalize_legacy_executor_closeout_action(event)
|
|
117
|
+
if Path(str(event.get("roadmap", ""))).expanduser().resolve() != roadmap.resolve():
|
|
118
|
+
continue
|
|
119
|
+
if normalized_legacy_executor_closeout and not legacy_executor_closeout_action_warned:
|
|
120
|
+
ledger_warnings.append(
|
|
121
|
+
_ledger_warning(
|
|
122
|
+
"event",
|
|
123
|
+
str(event.get("phase", "")).upper(),
|
|
124
|
+
str(event.get("status") or ""),
|
|
125
|
+
"legacy_executor_closeout_action_normalized",
|
|
126
|
+
raw_event=raw_event,
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
legacy_executor_closeout_action_warned = True
|
|
130
|
+
dedup_key = _event_dedup_key(event)
|
|
131
|
+
if event.get("action") == "phase_reopen":
|
|
132
|
+
seen_event_keys = {key for key in seen_event_keys if key[1] != dedup_key[1]}
|
|
133
|
+
dedup_identity = _event_dedup_identity(event, dedup_key)
|
|
134
|
+
if dedup_identity in seen_event_keys:
|
|
135
|
+
ledger_duplicates_skipped.append(_ledger_duplicate_record(event, dedup_key))
|
|
136
|
+
continue
|
|
137
|
+
seen_event_keys.add(dedup_identity)
|
|
138
|
+
phase = str(event.get("phase", "")).upper()
|
|
139
|
+
status = event.get("status")
|
|
140
|
+
if phase not in phases:
|
|
141
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status or ""), "phase_missing", raw_event=event))
|
|
142
|
+
continue
|
|
143
|
+
if status not in RECONCILE_EVENT_STATUSES:
|
|
144
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status or ""), "not_in_allowed_status_set", raw_event=event))
|
|
145
|
+
continue
|
|
146
|
+
if int(event.get("schema_version") or 1) < 2:
|
|
147
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status), "legacy_pre_schema_v2", raw_event=event))
|
|
148
|
+
continue
|
|
149
|
+
if event.get("action") == "state_transition" and _state_transition_metadata(event) is None:
|
|
150
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status), "malformed_state_transition", raw_event=event))
|
|
151
|
+
continue
|
|
152
|
+
if event.get("action") == "manual_recovery" and _manual_recovery_metadata(event) is None:
|
|
153
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status), "malformed_manual_recovery", raw_event=event))
|
|
154
|
+
continue
|
|
155
|
+
if phase in phases and status in RECONCILE_EVENT_STATUSES:
|
|
156
|
+
if not _event_status_provenance_matches(event, str(status), current_roadmap_sha, current_phase_sha.get(phase)):
|
|
157
|
+
pending_event_warnings.setdefault(phase, []).append(
|
|
158
|
+
_ledger_warning(
|
|
159
|
+
"event",
|
|
160
|
+
phase,
|
|
161
|
+
str(status),
|
|
162
|
+
provenance_mismatch_reason(
|
|
163
|
+
str(status),
|
|
164
|
+
event.get("roadmap_sha256"),
|
|
165
|
+
event.get("phase_sha256"),
|
|
166
|
+
current_roadmap_sha,
|
|
167
|
+
current_phase_sha.get(phase),
|
|
168
|
+
),
|
|
169
|
+
raw_event=event,
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
if status in {"blocked", "unknown", "executed", "awaiting_phase_closeout"}:
|
|
173
|
+
latest_untrusted_terminal_event[phase] = event
|
|
174
|
+
continue
|
|
175
|
+
if status == "planned" and event.get("action") != "phase_reopen" and find_plan_artifact(repo, phase, roadmap=roadmap) is None:
|
|
176
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status), "planned_without_plan_artifact", raw_event=event))
|
|
177
|
+
continue
|
|
178
|
+
latest_untrusted_terminal_event.pop(phase, None)
|
|
179
|
+
pending_event_warnings.pop(phase, None)
|
|
180
|
+
if phases.get(phase) == "blocked" and status in {"planned", "unplanned"} and not _planned_event_clears_blocker(event):
|
|
181
|
+
ledger_warnings.append(_ledger_warning("event", phase, str(status), "blocker_supersession", raw_event=event))
|
|
182
|
+
continue
|
|
183
|
+
closeout_summary = _event_closeout_summary(event)
|
|
184
|
+
if _closeout_completed(status, closeout_summary):
|
|
185
|
+
status = "complete"
|
|
186
|
+
phases[phase] = status
|
|
187
|
+
dirty_summary = _event_dirty_summary(event)
|
|
188
|
+
if dirty_summary:
|
|
189
|
+
dirty_summary_by_phase[phase] = dirty_summary
|
|
190
|
+
elif status in {"planned", "unplanned", "complete", "executed", "unknown"}:
|
|
191
|
+
dirty_summary_by_phase.pop(phase, None)
|
|
192
|
+
if closeout_summary:
|
|
193
|
+
closeout_summary_by_phase[phase] = closeout_summary
|
|
194
|
+
latest_closeout_summary = {"phase": phase, **closeout_summary}
|
|
195
|
+
elif status in {"planned", "unplanned", "unknown"}:
|
|
196
|
+
closeout_summary_by_phase.pop(phase, None)
|
|
197
|
+
terminal = _event_terminal_summary(event)
|
|
198
|
+
if terminal and _event_terminal_summary_is_event_only(event, terminal):
|
|
199
|
+
ledger_warnings.append(
|
|
200
|
+
_ledger_warning(
|
|
201
|
+
"event",
|
|
202
|
+
phase,
|
|
203
|
+
"dry_run",
|
|
204
|
+
"event_only_status",
|
|
205
|
+
raw_event=event,
|
|
206
|
+
value="dry_run",
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
elif terminal:
|
|
210
|
+
terminal_summary_by_phase[phase] = terminal
|
|
211
|
+
latest_terminal_summary = {"phase": phase, **terminal}
|
|
212
|
+
elif _event_clears_terminal_summary(event, status):
|
|
213
|
+
terminal_summary_by_phase.pop(phase, None)
|
|
214
|
+
if latest_terminal_summary and latest_terminal_summary.get("phase") == phase:
|
|
215
|
+
latest_terminal_summary = None
|
|
216
|
+
elif status in {"planned", "unplanned", "unknown"}:
|
|
217
|
+
terminal_summary_by_phase.pop(phase, None)
|
|
218
|
+
blocker = _event_blocker(event)
|
|
219
|
+
preserve_human_blocker = (
|
|
220
|
+
human_required
|
|
221
|
+
and blocker_phase == phase
|
|
222
|
+
and (
|
|
223
|
+
status == "executed"
|
|
224
|
+
or (status == "blocked" and _event_has_verified_dirty_closeout_recovery(event))
|
|
225
|
+
)
|
|
226
|
+
)
|
|
227
|
+
if not preserve_human_blocker and not (status == "blocked" and blocker):
|
|
228
|
+
human_required = False
|
|
229
|
+
blocker_class = None
|
|
230
|
+
blocker_summary = None
|
|
231
|
+
required_human_inputs = ()
|
|
232
|
+
access_attempts = ()
|
|
233
|
+
blocker_phase = None
|
|
234
|
+
blocker = _event_blocker(event)
|
|
235
|
+
if (
|
|
236
|
+
blocker
|
|
237
|
+
and phase in phases
|
|
238
|
+
and (phases.get(phase) == "blocked" or (status == "executed" and _truthy(blocker.get("human_required"))))
|
|
239
|
+
and _event_status_provenance_matches(event, str(status), current_roadmap_sha, current_phase_sha.get(phase))
|
|
240
|
+
):
|
|
241
|
+
human_required = _truthy(blocker.get("human_required"))
|
|
242
|
+
blocker_class = blocker.get("blocker_class")
|
|
243
|
+
blocker_summary = blocker.get("blocker_summary")
|
|
244
|
+
required_human_inputs = tuple(blocker.get("required_human_inputs", ()))
|
|
245
|
+
access_attempts = tuple(blocker.get("access_attempts", ()))
|
|
246
|
+
blocker_phase = phase
|
|
247
|
+
for warnings in pending_event_warnings.values():
|
|
248
|
+
ledger_warnings.extend(warnings)
|
|
249
|
+
for phase, event in latest_untrusted_terminal_event.items():
|
|
250
|
+
if phases.get(phase) == "complete":
|
|
251
|
+
phases[phase] = "unknown"
|
|
252
|
+
ledger_warnings.append(
|
|
253
|
+
_ledger_warning(
|
|
254
|
+
"event",
|
|
255
|
+
phase,
|
|
256
|
+
str(event.get("status")),
|
|
257
|
+
"newer_untrusted_terminal_event",
|
|
258
|
+
)
|
|
259
|
+
)
|
|
260
|
+
current = _current_phase(phases)
|
|
261
|
+
if current:
|
|
262
|
+
latest_dirty = dirty_summary_by_phase.get(current, {})
|
|
263
|
+
dirty_paths = tuple(latest_dirty.get("dirty_paths", ()))
|
|
264
|
+
phase_owned_dirty_paths = tuple(latest_dirty.get("phase_owned_dirty_paths", ()))
|
|
265
|
+
previous_phase_owned_paths = tuple(latest_dirty.get("previous_phase_owned_paths", ()))
|
|
266
|
+
unowned_dirty_paths = tuple(latest_dirty.get("unowned_dirty_paths", ()))
|
|
267
|
+
pre_existing_dirty_paths = tuple(latest_dirty.get("pre_existing_dirty_paths", ()))
|
|
268
|
+
phase_owned_dirty = bool(latest_dirty.get("phase_owned_dirty", False))
|
|
269
|
+
phase_terminal_summary = terminal_summary_by_phase.get(current)
|
|
270
|
+
terminal_summary = {"phase": current, **phase_terminal_summary} if phase_terminal_summary else None
|
|
271
|
+
closeout_terminal_status = _optional_text(latest_dirty.get("terminal_status"))
|
|
272
|
+
if closeout_terminal_status is None and terminal_summary:
|
|
273
|
+
closeout_terminal_status = _optional_text(terminal_summary.get("terminal_status"))
|
|
274
|
+
closeout_summary = closeout_summary_by_phase.get(current) or latest_closeout_summary
|
|
275
|
+
if not closeout_summary and phases.get(current) == "awaiting_phase_closeout":
|
|
276
|
+
closeout_summary = _default_closeout_summary(closeout_terminal_status)
|
|
277
|
+
else:
|
|
278
|
+
terminal_summary = None if phases and all(status == "complete" for status in phases.values()) else latest_terminal_summary
|
|
279
|
+
closeout_summary = latest_closeout_summary
|
|
280
|
+
if current and phases.get(current) == "blocked" and not human_required:
|
|
281
|
+
plan_blocker = _plan_blocker(repo, roadmap, current)
|
|
282
|
+
if plan_blocker:
|
|
283
|
+
human_required = _truthy(plan_blocker.get("human_required"))
|
|
284
|
+
blocker_class = plan_blocker.get("blocker_class")
|
|
285
|
+
blocker_summary = plan_blocker.get("blocker_summary")
|
|
286
|
+
required_human_inputs = tuple(plan_blocker.get("required_human_inputs", ()))
|
|
287
|
+
if (
|
|
288
|
+
current
|
|
289
|
+
and phases.get(current) == "blocked"
|
|
290
|
+
and not human_required
|
|
291
|
+
and _clean_planned_artifact_supersedes_blocker(repo, roadmap, current, pending_event_warnings.get(current, ()))
|
|
292
|
+
):
|
|
293
|
+
phases[current] = "planned"
|
|
294
|
+
blocker_class = None
|
|
295
|
+
blocker_summary = None
|
|
296
|
+
required_human_inputs = ()
|
|
297
|
+
access_attempts = ()
|
|
298
|
+
dirty_paths = ()
|
|
299
|
+
phase_owned_dirty_paths = ()
|
|
300
|
+
previous_phase_owned_paths = ()
|
|
301
|
+
unowned_dirty_paths = ()
|
|
302
|
+
pre_existing_dirty_paths = ()
|
|
303
|
+
phase_owned_dirty = False
|
|
304
|
+
terminal_summary = None
|
|
305
|
+
closeout_terminal_status = None
|
|
306
|
+
ledger_warnings.append(_ledger_warning("plan", current, "planned", "clean_plan_superseded_nonhuman_blocker"))
|
|
307
|
+
if current and phases.get(current) == "blocked" and not human_required:
|
|
308
|
+
recovery_closeout = _clean_verified_dirty_closeout_recovery_supersedes_blocker(
|
|
309
|
+
repo,
|
|
310
|
+
roadmap,
|
|
311
|
+
current,
|
|
312
|
+
current_roadmap_sha,
|
|
313
|
+
current_phase_sha,
|
|
314
|
+
)
|
|
315
|
+
if recovery_closeout is not None:
|
|
316
|
+
repaired_phase = current
|
|
317
|
+
phases[repaired_phase] = "complete"
|
|
318
|
+
current = _current_phase(phases)
|
|
319
|
+
blocker_class = None
|
|
320
|
+
blocker_summary = None
|
|
321
|
+
required_human_inputs = ()
|
|
322
|
+
access_attempts = ()
|
|
323
|
+
dirty_paths = ()
|
|
324
|
+
phase_owned_dirty_paths = ()
|
|
325
|
+
previous_phase_owned_paths = ()
|
|
326
|
+
unowned_dirty_paths = ()
|
|
327
|
+
pre_existing_dirty_paths = ()
|
|
328
|
+
phase_owned_dirty = False
|
|
329
|
+
terminal_summary = None
|
|
330
|
+
closeout_terminal_status = None
|
|
331
|
+
closeout = recovery_closeout.get("closeout_summary")
|
|
332
|
+
if isinstance(closeout, dict) and closeout:
|
|
333
|
+
closeout_summary = {"phase": repaired_phase, **closeout}
|
|
334
|
+
ledger_warnings.append(
|
|
335
|
+
_ledger_warning("event", repaired_phase, "complete", "clean_verified_dirty_closeout_recovery_superseded_nonhuman_blocker")
|
|
336
|
+
)
|
|
337
|
+
return StateSnapshot(
|
|
338
|
+
timestamp=utc_now(),
|
|
339
|
+
repo=str(repo),
|
|
340
|
+
roadmap=str(roadmap),
|
|
341
|
+
phases=phases,
|
|
342
|
+
current_phase=current,
|
|
343
|
+
last_action="reconcile",
|
|
344
|
+
human_required=human_required,
|
|
345
|
+
blocker_class=blocker_class,
|
|
346
|
+
blocker_summary=blocker_summary,
|
|
347
|
+
required_human_inputs=required_human_inputs,
|
|
348
|
+
access_attempts=access_attempts,
|
|
349
|
+
dirty_paths=dirty_paths,
|
|
350
|
+
phase_owned_dirty_paths=phase_owned_dirty_paths,
|
|
351
|
+
previous_phase_owned_paths=previous_phase_owned_paths,
|
|
352
|
+
unowned_dirty_paths=unowned_dirty_paths,
|
|
353
|
+
pre_existing_dirty_paths=pre_existing_dirty_paths,
|
|
354
|
+
phase_owned_dirty=phase_owned_dirty,
|
|
355
|
+
terminal_summary=terminal_summary,
|
|
356
|
+
closeout_terminal_status=closeout_terminal_status,
|
|
357
|
+
closeout_summary=closeout_summary,
|
|
358
|
+
ledger_warnings=tuple(ledger_warnings),
|
|
359
|
+
ledger_duplicates_skipped=tuple(ledger_duplicates_skipped),
|
|
360
|
+
**snapshot_provenance(roadmap),
|
|
361
|
+
)
|
|
362
|
+
repair_closeout = _clean_manual_repair_complete_supersedes_blocker(
|
|
363
|
+
repo,
|
|
364
|
+
roadmap,
|
|
365
|
+
current,
|
|
366
|
+
current_roadmap_sha,
|
|
367
|
+
current_phase_sha,
|
|
368
|
+
)
|
|
369
|
+
if repair_closeout is not None:
|
|
370
|
+
repaired_phase = current
|
|
371
|
+
phases[repaired_phase] = "complete"
|
|
372
|
+
current = _current_phase(phases)
|
|
373
|
+
blocker_class = None
|
|
374
|
+
blocker_summary = None
|
|
375
|
+
required_human_inputs = ()
|
|
376
|
+
access_attempts = ()
|
|
377
|
+
dirty_paths = ()
|
|
378
|
+
phase_owned_dirty_paths = ()
|
|
379
|
+
previous_phase_owned_paths = ()
|
|
380
|
+
unowned_dirty_paths = ()
|
|
381
|
+
pre_existing_dirty_paths = ()
|
|
382
|
+
phase_owned_dirty = False
|
|
383
|
+
terminal_summary = None
|
|
384
|
+
closeout_terminal_status = None
|
|
385
|
+
closeout = repair_closeout.get("closeout_summary")
|
|
386
|
+
if isinstance(closeout, dict) and closeout:
|
|
387
|
+
closeout_summary = {"phase": repaired_phase, **closeout}
|
|
388
|
+
ledger_warnings.append(
|
|
389
|
+
_ledger_warning("event", repaired_phase, "complete", "clean_manual_repair_superseded_nonhuman_blocker")
|
|
390
|
+
)
|
|
391
|
+
# Self-clearing stale blocker pass (regenesis v37 fix).
|
|
392
|
+
#
|
|
393
|
+
# state.json (or the latest event) can hold a cached blocker whose
|
|
394
|
+
# precondition was already resolved out-of-band (operator manually
|
|
395
|
+
# created the pipeline branch, resolved the merge, etc.). Without
|
|
396
|
+
# this pass reconcile would echo the stale blocker forever and the
|
|
397
|
+
# runner would refuse to advance.
|
|
398
|
+
#
|
|
399
|
+
# Narrow scope, intentionally:
|
|
400
|
+
# - human_required=True blockers NEVER self-clear -- the operator
|
|
401
|
+
# tagged it and the runner does not second-guess that.
|
|
402
|
+
# - dirty_worktree_conflict is intentionally NOT covered here. The
|
|
403
|
+
# runner already emits "repair_precondition_cleared" state
|
|
404
|
+
# transitions for that class via a richer event-level mechanism
|
|
405
|
+
# (see test_phase_loop_repair_skipped_when_blocker_cleared);
|
|
406
|
+
# clearing it in reconcile would preempt that flow and break the
|
|
407
|
+
# audit trail.
|
|
408
|
+
# - Human-action classes (admin_approval, missing_secret,
|
|
409
|
+
# product_decision_missing, ...) default-deny -- they cannot be
|
|
410
|
+
# detected from repo state.
|
|
411
|
+
if (
|
|
412
|
+
current
|
|
413
|
+
and phases.get(current) == "blocked"
|
|
414
|
+
and blocker_class
|
|
415
|
+
and not human_required
|
|
416
|
+
and _blocker_precondition_cleared(
|
|
417
|
+
repo, str(blocker_class), blocker_summary=str(blocker_summary or "")
|
|
418
|
+
)
|
|
419
|
+
):
|
|
420
|
+
cleared_class = str(blocker_class)
|
|
421
|
+
phases[current] = "planned" if find_plan_artifact(repo, current, roadmap=roadmap) else "unplanned"
|
|
422
|
+
human_required = False
|
|
423
|
+
blocker_class = None
|
|
424
|
+
blocker_summary = None
|
|
425
|
+
required_human_inputs = ()
|
|
426
|
+
access_attempts = ()
|
|
427
|
+
dirty_paths = ()
|
|
428
|
+
phase_owned_dirty_paths = ()
|
|
429
|
+
previous_phase_owned_paths = ()
|
|
430
|
+
unowned_dirty_paths = ()
|
|
431
|
+
pre_existing_dirty_paths = ()
|
|
432
|
+
phase_owned_dirty = False
|
|
433
|
+
terminal_summary = None
|
|
434
|
+
closeout_terminal_status = None
|
|
435
|
+
ledger_warnings.append(
|
|
436
|
+
_ledger_warning(
|
|
437
|
+
"blocker",
|
|
438
|
+
current,
|
|
439
|
+
phases[current],
|
|
440
|
+
"blocker_precondition_self_cleared",
|
|
441
|
+
value=cleared_class,
|
|
442
|
+
)
|
|
443
|
+
)
|
|
444
|
+
current = _current_phase(phases)
|
|
445
|
+
return StateSnapshot(
|
|
446
|
+
timestamp=utc_now(),
|
|
447
|
+
repo=str(repo),
|
|
448
|
+
roadmap=str(roadmap),
|
|
449
|
+
phases=phases,
|
|
450
|
+
current_phase=current,
|
|
451
|
+
last_action="reconcile",
|
|
452
|
+
human_required=human_required,
|
|
453
|
+
blocker_class=blocker_class,
|
|
454
|
+
blocker_summary=blocker_summary,
|
|
455
|
+
required_human_inputs=required_human_inputs,
|
|
456
|
+
access_attempts=access_attempts,
|
|
457
|
+
dirty_paths=dirty_paths,
|
|
458
|
+
phase_owned_dirty_paths=phase_owned_dirty_paths,
|
|
459
|
+
previous_phase_owned_paths=previous_phase_owned_paths,
|
|
460
|
+
unowned_dirty_paths=unowned_dirty_paths,
|
|
461
|
+
pre_existing_dirty_paths=pre_existing_dirty_paths,
|
|
462
|
+
phase_owned_dirty=phase_owned_dirty,
|
|
463
|
+
terminal_summary=terminal_summary,
|
|
464
|
+
closeout_terminal_status=closeout_terminal_status,
|
|
465
|
+
closeout_summary=closeout_summary,
|
|
466
|
+
ledger_warnings=tuple(ledger_warnings),
|
|
467
|
+
ledger_duplicates_skipped=tuple(ledger_duplicates_skipped),
|
|
468
|
+
**snapshot_provenance(roadmap),
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _manifest_file_phase_key(entry) -> tuple[str, str]:
|
|
473
|
+
"""#46: dedup identity for a manifest plan entry — normalized file path +
|
|
474
|
+
upper-cased phase alias. Matches entries pointing at the same phase plan
|
|
475
|
+
file/phase regardless of their (planner vs synthetic-import) slug, so
|
|
476
|
+
reconcile does not re-add a duplicate imported row."""
|
|
477
|
+
file_norm = str(getattr(entry, "file", "") or "").replace("\\", "/").strip().lower()
|
|
478
|
+
phase_norm = str(getattr(entry, "phase_alias", "") or "").strip().upper()
|
|
479
|
+
return (file_norm, phase_norm)
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
def _reconcile_plan_manifest(repo: Path, roadmap: Path, phases: dict[str, str]) -> list[dict]:
|
|
483
|
+
from .discovery import _phase_manifest_disabled
|
|
484
|
+
|
|
485
|
+
if _phase_manifest_disabled():
|
|
486
|
+
return []
|
|
487
|
+
if not (repo / "plans" / "manifest.json").exists():
|
|
488
|
+
return []
|
|
489
|
+
warnings: list[dict] = []
|
|
490
|
+
try:
|
|
491
|
+
from .plan_manifest import append_entry, import_existing_phase_plans, read_manifest, update_lifecycle
|
|
492
|
+
except Exception as exc:
|
|
493
|
+
return [_ledger_warning("manifest", "", "unknown", "manifest_import_unavailable", value=str(exc))]
|
|
494
|
+
try:
|
|
495
|
+
manifest = read_manifest(repo)
|
|
496
|
+
except Exception as exc:
|
|
497
|
+
return [_ledger_warning("manifest", "", "unknown", "manifest_read_failed", value=str(exc))]
|
|
498
|
+
known_slugs = {entry.slug for entry in manifest.plans}
|
|
499
|
+
# #46: also key by (normalized file, phase_alias) so an auto-import whose
|
|
500
|
+
# SYNTHETIC slug differs from a committed planner entry (e.g. import slug
|
|
501
|
+
# "v1-CORE" vs planner slug "phase-plan-v1-CORE") is not appended as a
|
|
502
|
+
# duplicate when the same phase-plan file + phase alias is already present.
|
|
503
|
+
# Dedup-by-slug-alone re-added a duplicate `imported` row (and, with it, a
|
|
504
|
+
# punctuation-variant IF gate that the planner entry never contained).
|
|
505
|
+
known_file_phase = {_manifest_file_phase_key(entry) for entry in manifest.plans}
|
|
506
|
+
for entry in manifest.plans:
|
|
507
|
+
if entry.type != "phase" or entry.status in {"orphaned", "completed", "failed"}:
|
|
508
|
+
continue
|
|
509
|
+
if not (repo / entry.file).exists():
|
|
510
|
+
try:
|
|
511
|
+
update_lifecycle(
|
|
512
|
+
repo,
|
|
513
|
+
entry.slug,
|
|
514
|
+
"orphaned",
|
|
515
|
+
"phase-loop-reconcile",
|
|
516
|
+
{"reason": "manifest_plan_file_missing", "file": entry.file},
|
|
517
|
+
)
|
|
518
|
+
except Exception as exc:
|
|
519
|
+
warnings.append(_ledger_warning("manifest", str(entry.phase_alias or ""), entry.status, "manifest_orphan_update_failed", value=str(exc)))
|
|
520
|
+
warnings.append(_ledger_warning("manifest", str(entry.phase_alias or ""), "orphaned", "manifest_plan_file_missing", value=entry.file))
|
|
521
|
+
try:
|
|
522
|
+
imported = import_existing_phase_plans(repo)
|
|
523
|
+
except Exception as exc:
|
|
524
|
+
warnings.append(_ledger_warning("manifest", "", "unknown", "manifest_auto_import_scan_failed", value=str(exc)))
|
|
525
|
+
return warnings
|
|
526
|
+
for entry in imported.plans:
|
|
527
|
+
if entry.slug in known_slugs:
|
|
528
|
+
continue
|
|
529
|
+
if _manifest_file_phase_key(entry) in known_file_phase:
|
|
530
|
+
# #46: same phase-plan file + phase alias already represented by a
|
|
531
|
+
# committed entry — do not append a second `imported` row.
|
|
532
|
+
continue
|
|
533
|
+
if entry.phase_alias and entry.phase_alias.upper() not in phases:
|
|
534
|
+
continue
|
|
535
|
+
try:
|
|
536
|
+
append_entry(repo, entry)
|
|
537
|
+
known_slugs.add(entry.slug)
|
|
538
|
+
known_file_phase.add(_manifest_file_phase_key(entry))
|
|
539
|
+
except Exception as exc:
|
|
540
|
+
warnings.append(_ledger_warning("manifest", str(entry.phase_alias or ""), "imported", "manifest_auto_import_failed", value=str(exc)))
|
|
541
|
+
else:
|
|
542
|
+
pass
|
|
543
|
+
for phase in phases:
|
|
544
|
+
_manifest_plan, conflict = manifest_plan_artifact(repo, phase, roadmap=roadmap)
|
|
545
|
+
if conflict:
|
|
546
|
+
warnings.append(
|
|
547
|
+
_ledger_warning(
|
|
548
|
+
"manifest",
|
|
549
|
+
str(conflict.get("phase") or phase),
|
|
550
|
+
str(conflict.get("status") or "unknown"),
|
|
551
|
+
str(conflict.get("reason") or "manifest_warning"),
|
|
552
|
+
value=conflict,
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
return warnings
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _blocker_precondition_cleared(repo: Path, blocker_class: str, *, blocker_summary: str = "") -> bool:
|
|
559
|
+
"""True when the current repo/git state no longer satisfies the cached
|
|
560
|
+
blocker's precondition. The runner should drop the cached blocker in
|
|
561
|
+
that case.
|
|
562
|
+
|
|
563
|
+
NOTE: ``dirty_worktree_conflict`` is intentionally NOT covered here.
|
|
564
|
+
The runner emits a richer ``repair_precondition_cleared`` state
|
|
565
|
+
transition for that class on its own (see
|
|
566
|
+
test_phase_loop_repair_skipped_when_blocker_cleared). Clearing it in
|
|
567
|
+
reconcile would preempt that flow and drop the audit trail.
|
|
568
|
+
|
|
569
|
+
Default-deny: human-action classes (admin_approval, missing_secret,
|
|
570
|
+
product_decision_missing, account_or_billing_setup, ...) and
|
|
571
|
+
branch_sync_conflict variants we cannot reliably detect (e.g.
|
|
572
|
+
``base_ref_unavailable`` from release_guard) stay cached.
|
|
573
|
+
"""
|
|
574
|
+
if blocker_class == "branch_sync_conflict":
|
|
575
|
+
# Narrow to the BranchGov default-branch refusal variant -- the
|
|
576
|
+
# v37 case. Identified by the blocker_summary text emitted in
|
|
577
|
+
# pipeline_adapter.branch_ops.refuse_default_branch_commit.
|
|
578
|
+
# release_guard's "base_ref_unavailable" variant and other
|
|
579
|
+
# branch_sync_conflict producers stay cached.
|
|
580
|
+
summary = blocker_summary or ""
|
|
581
|
+
if REFUSE_DEFAULT_BRANCH_COMMIT_PREFIX not in summary:
|
|
582
|
+
return False
|
|
583
|
+
try:
|
|
584
|
+
current_branch = subprocess.check_output(
|
|
585
|
+
["git", "-C", str(repo), "branch", "--show-current"],
|
|
586
|
+
text=True,
|
|
587
|
+
stderr=subprocess.DEVNULL,
|
|
588
|
+
).strip()
|
|
589
|
+
except Exception:
|
|
590
|
+
return False
|
|
591
|
+
if not current_branch:
|
|
592
|
+
return False
|
|
593
|
+
remote_head = ""
|
|
594
|
+
try:
|
|
595
|
+
remote_head = subprocess.check_output(
|
|
596
|
+
["git", "-C", str(repo), "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
|
|
597
|
+
text=True,
|
|
598
|
+
stderr=subprocess.DEVNULL,
|
|
599
|
+
).strip()
|
|
600
|
+
except Exception:
|
|
601
|
+
remote_head = ""
|
|
602
|
+
default_branch = remote_head.removeprefix("origin/") if remote_head.startswith("origin/") else ""
|
|
603
|
+
if not default_branch:
|
|
604
|
+
try:
|
|
605
|
+
ls_remote = subprocess.check_output(
|
|
606
|
+
["git", "-C", str(repo), "ls-remote", "--symref", "origin", "HEAD"],
|
|
607
|
+
text=True,
|
|
608
|
+
stderr=subprocess.DEVNULL,
|
|
609
|
+
)
|
|
610
|
+
except Exception:
|
|
611
|
+
ls_remote = ""
|
|
612
|
+
for line in ls_remote.splitlines():
|
|
613
|
+
if line.startswith("ref: refs/heads/"):
|
|
614
|
+
ref = line.split("\t", 1)[0].removeprefix("ref: refs/heads/").strip()
|
|
615
|
+
if ref:
|
|
616
|
+
default_branch = ref
|
|
617
|
+
break
|
|
618
|
+
if not default_branch:
|
|
619
|
+
# When the default branch cannot be discovered, default-deny
|
|
620
|
+
# rather than risk clearing a stale blocker that may still
|
|
621
|
+
# apply.
|
|
622
|
+
return False
|
|
623
|
+
return current_branch != default_branch
|
|
624
|
+
if blocker_class == "merge_conflict":
|
|
625
|
+
# Cached blocker was "merge in progress with conflicts". If
|
|
626
|
+
# `git status --porcelain` no longer reports unmerged paths
|
|
627
|
+
# (UU/AA/DD/UD/DU/AU/UA), the precondition is cleared.
|
|
628
|
+
try:
|
|
629
|
+
out = subprocess.check_output(
|
|
630
|
+
["git", "-C", str(repo), "status", "--porcelain"],
|
|
631
|
+
text=True,
|
|
632
|
+
stderr=subprocess.DEVNULL,
|
|
633
|
+
)
|
|
634
|
+
except Exception:
|
|
635
|
+
return False
|
|
636
|
+
unmerged_prefixes = ("UU", "AA", "DD", "UD", "DU", "AU", "UA")
|
|
637
|
+
return not any(line.startswith(unmerged_prefixes) for line in out.splitlines())
|
|
638
|
+
# Default-deny: dirty_worktree_conflict (handled by runner mechanism),
|
|
639
|
+
# human-action blocker classes (admin_approval, missing_secret,
|
|
640
|
+
# product_decision_missing, account_or_billing_setup,
|
|
641
|
+
# operator_override_missing_reason, etc.) and any unknown class do
|
|
642
|
+
# NOT self-clear from repo state alone.
|
|
643
|
+
return False
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _event_blocker(event: dict) -> dict:
|
|
647
|
+
blocker = event.get("blocker")
|
|
648
|
+
if isinstance(blocker, dict):
|
|
649
|
+
return _normalize_blocker(blocker)
|
|
650
|
+
if event.get("human_required") is not None or event.get("blocker_class") or event.get("blocker_summary"):
|
|
651
|
+
return _normalize_blocker({
|
|
652
|
+
"human_required": _truthy(event.get("human_required")),
|
|
653
|
+
"blocker_class": event.get("blocker_class"),
|
|
654
|
+
"blocker_summary": event.get("blocker_summary"),
|
|
655
|
+
"required_human_inputs": event.get("required_human_inputs", ()),
|
|
656
|
+
"access_attempts": event.get("access_attempts", ()),
|
|
657
|
+
})
|
|
658
|
+
return {}
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def _plan_blocker(repo: Path, roadmap: Path, phase: str) -> dict:
|
|
662
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
663
|
+
if not plan:
|
|
664
|
+
return {}
|
|
665
|
+
from .plan_ir import parse_phase_plan_ir
|
|
666
|
+
|
|
667
|
+
lane_ir = parse_phase_plan_ir(plan)
|
|
668
|
+
if lane_ir.lanes and lane_ir.diagnostics:
|
|
669
|
+
override = _lane_ir_override(repo, roadmap, phase, plan)
|
|
670
|
+
remaining = tuple(diagnostic for diagnostic in lane_ir.diagnostics if diagnostic.kind not in override)
|
|
671
|
+
if not remaining:
|
|
672
|
+
return {}
|
|
673
|
+
# #52: name the concrete failing diagnostic(s) and the plan file so the
|
|
674
|
+
# repo-local repair can fix the plan instead of guessing. Previously the
|
|
675
|
+
# summary was a generic "failed closed" string with no lane/kind/location,
|
|
676
|
+
# forcing the operator to guess which lane tripped which contract rule.
|
|
677
|
+
try:
|
|
678
|
+
plan_rel: object = plan.relative_to(repo)
|
|
679
|
+
except ValueError:
|
|
680
|
+
plan_rel = plan
|
|
681
|
+
detail = "; ".join(
|
|
682
|
+
f"{d.kind}@{d.lane_id or 'plan'}"
|
|
683
|
+
+ (f" ({d.message})" if getattr(d, "message", None) else "")
|
|
684
|
+
for d in remaining
|
|
685
|
+
)
|
|
686
|
+
return {
|
|
687
|
+
"human_required": False,
|
|
688
|
+
"blocker_class": "contract_bug",
|
|
689
|
+
"blocker_summary": (
|
|
690
|
+
f"Lane IR diagnostics failed closed for phase '{phase}' ({plan_rel}): "
|
|
691
|
+
f"{detail}. Fix the named lane(s) in the phase plan, then re-run."
|
|
692
|
+
),
|
|
693
|
+
"required_human_inputs": (),
|
|
694
|
+
"lane_ir_diagnostics": tuple(diagnostic.to_json() for diagnostic in remaining),
|
|
695
|
+
}
|
|
696
|
+
automation = parse_automation_status(plan.read_text(encoding="utf-8"))
|
|
697
|
+
if automation.get("automation_status") != "blocked":
|
|
698
|
+
return {}
|
|
699
|
+
human_required = automation.get("automation_human_required")
|
|
700
|
+
blocker_class = automation.get("automation_blocker_class")
|
|
701
|
+
if blocker_class in {None, "", "none"}:
|
|
702
|
+
blocker_class = None
|
|
703
|
+
return {
|
|
704
|
+
"human_required": _truthy(human_required),
|
|
705
|
+
"blocker_class": blocker_class,
|
|
706
|
+
"blocker_summary": automation.get("automation_blocker_summary"),
|
|
707
|
+
"required_human_inputs": automation.get("automation_required_human_inputs", ()),
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def _lane_ir_override(repo: Path, roadmap: Path, phase: str, plan: Path) -> tuple[str, ...]:
|
|
712
|
+
roadmap_path = roadmap.resolve()
|
|
713
|
+
plan_path = plan.resolve()
|
|
714
|
+
current_roadmap_sha = roadmap_sha256(roadmap)
|
|
715
|
+
current_phase_sha = phase_sha256(roadmap, phase)
|
|
716
|
+
override_kinds: tuple[str, ...] = ()
|
|
717
|
+
for event in read_events(repo):
|
|
718
|
+
if event.get("action") != "lane_ir_override":
|
|
719
|
+
continue
|
|
720
|
+
if event.get("roadmap_sha256") != current_roadmap_sha or event.get("phase_sha256") != current_phase_sha:
|
|
721
|
+
continue
|
|
722
|
+
if str(event.get("phase", "")).upper() != phase.upper():
|
|
723
|
+
continue
|
|
724
|
+
try:
|
|
725
|
+
event_roadmap = Path(str(event.get("roadmap", ""))).expanduser().resolve()
|
|
726
|
+
except OSError:
|
|
727
|
+
continue
|
|
728
|
+
if event_roadmap != roadmap_path:
|
|
729
|
+
continue
|
|
730
|
+
metadata = event.get("metadata")
|
|
731
|
+
payload = metadata.get("runner.lane_ir_override_invoked") if isinstance(metadata, dict) else None
|
|
732
|
+
if not isinstance(payload, dict):
|
|
733
|
+
continue
|
|
734
|
+
if not str(payload.get("operator_reason") or "").strip():
|
|
735
|
+
continue
|
|
736
|
+
event_plan = payload.get("plan_path")
|
|
737
|
+
if event_plan:
|
|
738
|
+
try:
|
|
739
|
+
if Path(str(event_plan)).expanduser().resolve() != plan_path:
|
|
740
|
+
continue
|
|
741
|
+
except OSError:
|
|
742
|
+
continue
|
|
743
|
+
kinds = payload.get("diagnostic_kinds_overridden")
|
|
744
|
+
if not isinstance(kinds, list):
|
|
745
|
+
continue
|
|
746
|
+
override_kinds = tuple(str(kind) for kind in kinds if kind)
|
|
747
|
+
return override_kinds
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _closeout_allow_unowned_attested(repo: Path, roadmap: Path, phase: str) -> bool:
|
|
751
|
+
"""BREAKGLASS SL-2: True when a non-stale ``closeout_allow_unowned`` operator
|
|
752
|
+
attestation is recorded for this exact phase content. Scoping mirrors
|
|
753
|
+
``_lane_ir_override``: roadmap_sha256 + phase_sha256 (content-bound freshness) +
|
|
754
|
+
phase + roadmap path + non-empty operator_reason. A stale attestation (content
|
|
755
|
+
drifted since it was written) no longer matches and does NOT authorize a later
|
|
756
|
+
closeout.
|
|
757
|
+
"""
|
|
758
|
+
roadmap_path = roadmap.resolve()
|
|
759
|
+
current_roadmap_sha = roadmap_sha256(roadmap)
|
|
760
|
+
current_phase_sha = phase_sha256(roadmap, phase)
|
|
761
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
762
|
+
plan_path = plan.resolve() if plan else None
|
|
763
|
+
for event in read_events(repo):
|
|
764
|
+
if event.get("action") != "closeout_allow_unowned":
|
|
765
|
+
continue
|
|
766
|
+
if event.get("roadmap_sha256") != current_roadmap_sha or event.get("phase_sha256") != current_phase_sha:
|
|
767
|
+
continue
|
|
768
|
+
if str(event.get("phase", "")).upper() != phase.upper():
|
|
769
|
+
continue
|
|
770
|
+
try:
|
|
771
|
+
event_roadmap = Path(str(event.get("roadmap", ""))).expanduser().resolve()
|
|
772
|
+
except OSError:
|
|
773
|
+
continue
|
|
774
|
+
if event_roadmap != roadmap_path:
|
|
775
|
+
continue
|
|
776
|
+
metadata = event.get("metadata")
|
|
777
|
+
payload = metadata.get("runner.closeout_allow_unowned_invoked") if isinstance(metadata, dict) else None
|
|
778
|
+
if not isinstance(payload, dict):
|
|
779
|
+
continue
|
|
780
|
+
if not str(payload.get("operator_reason") or "").strip():
|
|
781
|
+
continue
|
|
782
|
+
event_plan = payload.get("plan_path")
|
|
783
|
+
if event_plan and plan_path is not None:
|
|
784
|
+
try:
|
|
785
|
+
if Path(str(event_plan)).expanduser().resolve() != plan_path:
|
|
786
|
+
continue
|
|
787
|
+
except OSError:
|
|
788
|
+
continue
|
|
789
|
+
return True
|
|
790
|
+
return False
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def _clean_planned_artifact_supersedes_blocker(repo: Path, roadmap: Path, phase: str, warnings: list[dict] | tuple[dict, ...]) -> bool:
|
|
794
|
+
if not any(warning.get("source") == "event" and warning.get("status") == "planned" for warning in warnings):
|
|
795
|
+
return False
|
|
796
|
+
if _dirty_except_plan_manifest(repo):
|
|
797
|
+
return False
|
|
798
|
+
plan = find_plan_artifact(repo, phase, roadmap=roadmap)
|
|
799
|
+
if not plan:
|
|
800
|
+
return False
|
|
801
|
+
try:
|
|
802
|
+
automation = parse_automation_status(plan.read_text(encoding="utf-8"))
|
|
803
|
+
except OSError:
|
|
804
|
+
return False
|
|
805
|
+
return automation.get("automation_status") == "planned"
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def _dirty_except_plan_manifest(repo: Path) -> bool:
|
|
809
|
+
try:
|
|
810
|
+
lines = subprocess.check_output(["git", "-C", str(repo), "status", "--short"], text=True).splitlines()
|
|
811
|
+
except Exception:
|
|
812
|
+
return False
|
|
813
|
+
return any(line[3:] != "plans/manifest.json" for line in lines if len(line) > 3)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _clean_manual_repair_complete_supersedes_blocker(
|
|
817
|
+
repo: Path,
|
|
818
|
+
roadmap: Path,
|
|
819
|
+
phase: str,
|
|
820
|
+
current_roadmap_sha: str,
|
|
821
|
+
current_phase_sha: dict[str, str],
|
|
822
|
+
) -> dict[str, object] | None:
|
|
823
|
+
if _dirty(repo):
|
|
824
|
+
return None
|
|
825
|
+
complete_event: dict | None = None
|
|
826
|
+
stale_blocked_after_complete = False
|
|
827
|
+
for raw_event in read_events(repo):
|
|
828
|
+
event = _normalize_automation_event(repo, roadmap, raw_event, current_roadmap_sha, current_phase_sha)
|
|
829
|
+
if Path(str(event.get("roadmap", ""))).expanduser().resolve() != roadmap.resolve():
|
|
830
|
+
continue
|
|
831
|
+
event_phase = str(event.get("phase", "")).upper()
|
|
832
|
+
status = event.get("status")
|
|
833
|
+
if event_phase != phase:
|
|
834
|
+
continue
|
|
835
|
+
if not status_provenance_matches(
|
|
836
|
+
str(status),
|
|
837
|
+
event.get("roadmap_sha256"),
|
|
838
|
+
event.get("phase_sha256"),
|
|
839
|
+
current_roadmap_sha,
|
|
840
|
+
current_phase_sha.get(phase),
|
|
841
|
+
):
|
|
842
|
+
continue
|
|
843
|
+
if status == "complete" and event.get("action") == "manual_repair" and _planned_event_clears_blocker(event):
|
|
844
|
+
complete_event = event
|
|
845
|
+
stale_blocked_after_complete = False
|
|
846
|
+
continue
|
|
847
|
+
if complete_event and status == "blocked" and _supersedable_nonhuman_repair_blocker(_event_blocker(event)):
|
|
848
|
+
stale_blocked_after_complete = True
|
|
849
|
+
if not complete_event or not stale_blocked_after_complete:
|
|
850
|
+
return None
|
|
851
|
+
return {
|
|
852
|
+
"terminal_summary": _event_terminal_summary(complete_event),
|
|
853
|
+
"closeout_summary": _event_closeout_summary(complete_event),
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
def _clean_verified_dirty_closeout_recovery_supersedes_blocker(
|
|
858
|
+
repo: Path,
|
|
859
|
+
roadmap: Path,
|
|
860
|
+
phase: str,
|
|
861
|
+
current_roadmap_sha: str,
|
|
862
|
+
current_phase_sha: dict[str, str],
|
|
863
|
+
) -> dict[str, object] | None:
|
|
864
|
+
if _dirty(repo):
|
|
865
|
+
return None
|
|
866
|
+
latest_event: dict | None = None
|
|
867
|
+
for raw_event in read_events(repo):
|
|
868
|
+
event = _normalize_automation_event(repo, roadmap, raw_event, current_roadmap_sha, current_phase_sha)
|
|
869
|
+
if Path(str(event.get("roadmap", ""))).expanduser().resolve() != roadmap.resolve():
|
|
870
|
+
continue
|
|
871
|
+
# BREAKGLASS SL-2: operator attestation events are not closeout/terminal events;
|
|
872
|
+
# they must not shadow the verified-dirty-closeout recovery event as latest_event.
|
|
873
|
+
if event.get("action") == "closeout_allow_unowned":
|
|
874
|
+
continue
|
|
875
|
+
event_phase = str(event.get("phase", "")).upper()
|
|
876
|
+
status = event.get("status")
|
|
877
|
+
if event_phase != phase:
|
|
878
|
+
continue
|
|
879
|
+
if not status_provenance_matches(
|
|
880
|
+
str(status),
|
|
881
|
+
event.get("roadmap_sha256"),
|
|
882
|
+
event.get("phase_sha256"),
|
|
883
|
+
current_roadmap_sha,
|
|
884
|
+
current_phase_sha.get(phase),
|
|
885
|
+
):
|
|
886
|
+
continue
|
|
887
|
+
latest_event = event
|
|
888
|
+
if latest_event is None:
|
|
889
|
+
return None
|
|
890
|
+
metadata = latest_event.get("metadata")
|
|
891
|
+
if not isinstance(metadata, dict):
|
|
892
|
+
return None
|
|
893
|
+
dirty = metadata.get("completion_dirty_worktree")
|
|
894
|
+
if not isinstance(dirty, dict):
|
|
895
|
+
return None
|
|
896
|
+
if dirty.get("reason") != "verified_dirty_closeout_recovery":
|
|
897
|
+
return None
|
|
898
|
+
unowned = tuple(dirty.get("unowned_dirty_paths") or ())
|
|
899
|
+
if unowned:
|
|
900
|
+
# BREAKGLASS SL-2 (reconcile parity): a recorded operator attestation lifts the
|
|
901
|
+
# unowned-remainder bail — EXCEPT secrets, which are never break-glassable. The
|
|
902
|
+
# repo is already clean here (the closeout committed; the top-of-function _dirty
|
|
903
|
+
# guard is the real secrets backstop since SL-1 never commits a secret, leaving
|
|
904
|
+
# the worktree dirty); the explicit secrets check is defense-in-depth.
|
|
905
|
+
if not _closeout_allow_unowned_attested(repo, roadmap, phase):
|
|
906
|
+
return None
|
|
907
|
+
if any(classify_unowned_path(p).sensitivity_class == "secrets" for p in unowned):
|
|
908
|
+
return None
|
|
909
|
+
closeout_summary = _event_closeout_summary(latest_event)
|
|
910
|
+
if not closeout_summary.get("closeout_commit"):
|
|
911
|
+
return None
|
|
912
|
+
terminal_summary = _event_terminal_summary(latest_event)
|
|
913
|
+
if terminal_summary.get("verification_status") != "passed":
|
|
914
|
+
return None
|
|
915
|
+
return {
|
|
916
|
+
"terminal_summary": terminal_summary,
|
|
917
|
+
"closeout_summary": closeout_summary,
|
|
918
|
+
"auto_recovery": True,
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _event_has_verified_dirty_closeout_recovery(event: dict) -> bool:
|
|
923
|
+
metadata = event.get("metadata")
|
|
924
|
+
if not isinstance(metadata, dict):
|
|
925
|
+
return False
|
|
926
|
+
dirty = metadata.get("completion_dirty_worktree")
|
|
927
|
+
return isinstance(dirty, dict) and dirty.get("reason") == "verified_dirty_closeout_recovery"
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _supersedable_nonhuman_repair_blocker(blocker: dict) -> bool:
|
|
931
|
+
if not blocker or _truthy(blocker.get("human_required")):
|
|
932
|
+
return False
|
|
933
|
+
summary = str(blocker.get("blocker_summary") or "")
|
|
934
|
+
blocker_class = blocker.get("blocker_class")
|
|
935
|
+
if blocker_class == "repeated_verification_failure":
|
|
936
|
+
return (
|
|
937
|
+
"did not emit a valid shared automation closeout" in summary
|
|
938
|
+
or "blocked outcome without the shared blocker metadata" in summary
|
|
939
|
+
)
|
|
940
|
+
if blocker_class == "dirty_worktree_conflict":
|
|
941
|
+
return "left dirty paths that are not closeout-safe" in summary
|
|
942
|
+
return False
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def _dirty(repo: Path) -> bool:
|
|
946
|
+
try:
|
|
947
|
+
return bool(subprocess.check_output(["git", "-C", str(repo), "status", "--short"], text=True).strip())
|
|
948
|
+
except Exception:
|
|
949
|
+
return False
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def detect_downstream_plan_staleness(repo: Path, roadmap: Path, completed_phase: str) -> tuple[dict[str, object], ...]:
|
|
953
|
+
from .discovery import parse_frontmatter, parse_roadmap_phases
|
|
954
|
+
|
|
955
|
+
aliases = [str(alias).upper() for alias in parse_roadmap_phases(roadmap)]
|
|
956
|
+
current_roadmap_sha = roadmap_sha256(roadmap)
|
|
957
|
+
current_phase_sha = phase_provenance_map(roadmap)
|
|
958
|
+
try:
|
|
959
|
+
start = aliases.index(completed_phase.upper()) + 1
|
|
960
|
+
except ValueError:
|
|
961
|
+
start = 0
|
|
962
|
+
stale: list[dict[str, object]] = []
|
|
963
|
+
for alias in aliases[start:]:
|
|
964
|
+
for plan in sorted((repo / "plans").glob("phase-plan-v*-*.md")):
|
|
965
|
+
match = PLAN_RE.search(plan.name)
|
|
966
|
+
if not match or match.group(2).upper() != alias:
|
|
967
|
+
continue
|
|
968
|
+
metadata = parse_frontmatter(plan.read_text(encoding="utf-8"))
|
|
969
|
+
reasons: list[str] = []
|
|
970
|
+
if metadata.get("roadmap_sha256") != current_roadmap_sha:
|
|
971
|
+
reasons.append("roadmap_sha256")
|
|
972
|
+
plan_phase_sha = metadata.get("phase_sha256")
|
|
973
|
+
if plan_phase_sha and plan_phase_sha != current_phase_sha.get(alias):
|
|
974
|
+
reasons.append("phase_sha256")
|
|
975
|
+
if reasons:
|
|
976
|
+
stale.append({"phase": alias, "plan": str(plan), "reasons": tuple(reasons)})
|
|
977
|
+
return tuple(stale)
|
|
978
|
+
|
|
979
|
+
|
|
980
|
+
def invalidate_stale_downstream_plans(repo: Path, roadmap: Path, completed_phase: str) -> dict[str, object]:
|
|
981
|
+
stale = detect_downstream_plan_staleness(repo, roadmap, completed_phase)
|
|
982
|
+
if not stale:
|
|
983
|
+
return {"status": "unchanged", "stale_plans": ()}
|
|
984
|
+
next_phase = str(stale[0]["phase"])
|
|
985
|
+
return {
|
|
986
|
+
"status": "planning_required",
|
|
987
|
+
"blocker_class": "gold_record_amendment",
|
|
988
|
+
"next_skill": "codex-plan-phase",
|
|
989
|
+
"next_command": f"codex-plan-phase {roadmap} {next_phase}",
|
|
990
|
+
"stale_plans": stale,
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _ledger_warning(
|
|
995
|
+
source: str,
|
|
996
|
+
phase: str,
|
|
997
|
+
status: str,
|
|
998
|
+
reason: str,
|
|
999
|
+
*,
|
|
1000
|
+
raw_event: dict | None = None,
|
|
1001
|
+
value: object | None = None,
|
|
1002
|
+
) -> dict:
|
|
1003
|
+
return _ledger_warning_record(source, phase, status, reason, raw_event=raw_event, value=value)
|
|
1004
|
+
|
|
1005
|
+
|
|
1006
|
+
def _ledger_warning_record(
|
|
1007
|
+
source: str,
|
|
1008
|
+
phase: str,
|
|
1009
|
+
status: str,
|
|
1010
|
+
reason: str,
|
|
1011
|
+
*,
|
|
1012
|
+
raw_event: dict | None = None,
|
|
1013
|
+
value: object | None = None,
|
|
1014
|
+
) -> dict:
|
|
1015
|
+
warning = {
|
|
1016
|
+
"source": source,
|
|
1017
|
+
"phase": phase,
|
|
1018
|
+
"status": status,
|
|
1019
|
+
"reason": reason,
|
|
1020
|
+
"canonical_reason": _canonical_ledger_reason(reason),
|
|
1021
|
+
"value": value,
|
|
1022
|
+
}
|
|
1023
|
+
if raw_event is not None:
|
|
1024
|
+
warning["timestamp"] = _optional_text(raw_event.get("timestamp"))
|
|
1025
|
+
warning["action"] = _optional_text(raw_event.get("action"))
|
|
1026
|
+
warning["raw_event_summary"] = _raw_event_summary(raw_event)
|
|
1027
|
+
return {key: value for key, value in warning.items() if value is not None}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _event_dedup_key(event: dict) -> tuple[object, ...]:
|
|
1031
|
+
phase = str(event.get("phase", "")).upper()
|
|
1032
|
+
automation_status = _event_automation_status(event)
|
|
1033
|
+
blocker_class = _event_blocker_class(event)
|
|
1034
|
+
return (
|
|
1035
|
+
_optional_text(event.get("timestamp")),
|
|
1036
|
+
phase,
|
|
1037
|
+
_optional_text(event.get("action")),
|
|
1038
|
+
_optional_text(event.get("status")),
|
|
1039
|
+
automation_status,
|
|
1040
|
+
blocker_class,
|
|
1041
|
+
)
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def _event_content_signature(event: dict) -> tuple[object, ...]:
|
|
1045
|
+
"""Stable content signature drawn from the executor's terminal_summary.
|
|
1046
|
+
|
|
1047
|
+
DEF-4: the runner emits two events with identical (timestamp, phase,
|
|
1048
|
+
action, status, automation_status, blocker_class) keys during a single
|
|
1049
|
+
blocked-execute call — one carrying an empty terminal_summary, the other
|
|
1050
|
+
carrying the executor's dirty_paths / phase_owned_dirty_paths /
|
|
1051
|
+
produced_if_gates. Without a content signature they share a dedup
|
|
1052
|
+
identity; the first event wins and the rich one is silently dropped,
|
|
1053
|
+
leaving the snapshot's dirty_paths empty and the closeout auto-classifier
|
|
1054
|
+
unable to fire.
|
|
1055
|
+
|
|
1056
|
+
Including the content signature in the dedup identity lets both events
|
|
1057
|
+
survive reconcile. The reducer's existing overwrite-on-non-empty +
|
|
1058
|
+
no-pop-on-blocked semantics then naturally surface the rich event's data.
|
|
1059
|
+
|
|
1060
|
+
True byte-identical duplicates still collapse: their content signatures
|
|
1061
|
+
match exactly. The regression guard for that is intentional — replayed
|
|
1062
|
+
ledgers (e.g. after restoring `events.jsonl.bak-*`) must not double-count.
|
|
1063
|
+
"""
|
|
1064
|
+
metadata = event.get("metadata")
|
|
1065
|
+
if not isinstance(metadata, dict):
|
|
1066
|
+
return ()
|
|
1067
|
+
summary = metadata.get("terminal_summary")
|
|
1068
|
+
if not isinstance(summary, dict):
|
|
1069
|
+
return ()
|
|
1070
|
+
return (
|
|
1071
|
+
tuple(sorted(str(p) for p in (summary.get("dirty_paths") or ()))),
|
|
1072
|
+
tuple(sorted(str(p) for p in (summary.get("phase_owned_dirty_paths") or ()))),
|
|
1073
|
+
tuple(sorted(str(g) for g in (summary.get("produced_if_gates") or ()))),
|
|
1074
|
+
_optional_text(summary.get("terminal_status")),
|
|
1075
|
+
_optional_text(summary.get("verification_status")),
|
|
1076
|
+
)
|
|
1077
|
+
|
|
1078
|
+
|
|
1079
|
+
def _event_dedup_identity(event: dict, dedup_key: tuple[object, ...]) -> tuple[object, ...]:
|
|
1080
|
+
return (
|
|
1081
|
+
*dedup_key,
|
|
1082
|
+
_optional_text(event.get("roadmap_sha256")),
|
|
1083
|
+
_optional_text(event.get("phase_sha256")),
|
|
1084
|
+
event.get("schema_version"),
|
|
1085
|
+
_event_content_signature(event),
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def _ledger_duplicate_record(event: dict, dedup_key: tuple[object, ...]) -> dict:
|
|
1090
|
+
timestamp, phase, action, status, automation_status, blocker_class = dedup_key
|
|
1091
|
+
duplicate_key = {
|
|
1092
|
+
"timestamp": timestamp,
|
|
1093
|
+
"phase": phase,
|
|
1094
|
+
"action": action,
|
|
1095
|
+
"status": status,
|
|
1096
|
+
"automation_status": automation_status,
|
|
1097
|
+
"blocker_class": blocker_class,
|
|
1098
|
+
}
|
|
1099
|
+
return {
|
|
1100
|
+
"phase": phase,
|
|
1101
|
+
"timestamp": timestamp,
|
|
1102
|
+
"action": action,
|
|
1103
|
+
"status": status,
|
|
1104
|
+
"automation_status": automation_status,
|
|
1105
|
+
"blocker_class": blocker_class,
|
|
1106
|
+
"duplicate_key": {key: value for key, value in duplicate_key.items() if value is not None},
|
|
1107
|
+
"raw_event_summary": _raw_event_summary(event),
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def _event_status_provenance_matches(
|
|
1112
|
+
event: dict,
|
|
1113
|
+
status: str,
|
|
1114
|
+
current_roadmap_sha: str,
|
|
1115
|
+
current_phase_sha: str | None,
|
|
1116
|
+
) -> bool:
|
|
1117
|
+
if event.get("action") == "manual_recovery" and status == "unplanned":
|
|
1118
|
+
status = "planned"
|
|
1119
|
+
return status_provenance_matches(
|
|
1120
|
+
status,
|
|
1121
|
+
event.get("roadmap_sha256"),
|
|
1122
|
+
event.get("phase_sha256"),
|
|
1123
|
+
current_roadmap_sha,
|
|
1124
|
+
current_phase_sha,
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def _event_automation_status(event: dict) -> str | None:
|
|
1129
|
+
value = _optional_text(event.get("automation_status"))
|
|
1130
|
+
if value:
|
|
1131
|
+
return value
|
|
1132
|
+
automation = event.get("automation")
|
|
1133
|
+
if isinstance(automation, dict):
|
|
1134
|
+
value = _optional_text(automation.get("status") or automation.get("automation_status"))
|
|
1135
|
+
if value:
|
|
1136
|
+
return value
|
|
1137
|
+
metadata = event.get("metadata")
|
|
1138
|
+
if isinstance(metadata, dict):
|
|
1139
|
+
child_automation = metadata.get("child_automation")
|
|
1140
|
+
if isinstance(child_automation, dict):
|
|
1141
|
+
value = _optional_text(child_automation.get("automation_status") or child_automation.get("status"))
|
|
1142
|
+
if value:
|
|
1143
|
+
return value
|
|
1144
|
+
closeout = metadata.get("closeout")
|
|
1145
|
+
if isinstance(closeout, dict):
|
|
1146
|
+
value = _optional_text(closeout.get("status") or closeout.get("terminal_status"))
|
|
1147
|
+
if value:
|
|
1148
|
+
return value
|
|
1149
|
+
terminal = metadata.get("terminal_summary")
|
|
1150
|
+
if isinstance(terminal, dict):
|
|
1151
|
+
value = _optional_text(terminal.get("terminal_status"))
|
|
1152
|
+
if value:
|
|
1153
|
+
return value
|
|
1154
|
+
return None
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _event_blocker_class(event: dict) -> str | None:
|
|
1158
|
+
blocker = _event_blocker(event)
|
|
1159
|
+
value = _optional_text(blocker.get("blocker_class")) if blocker else None
|
|
1160
|
+
if value:
|
|
1161
|
+
return value
|
|
1162
|
+
value = _optional_text(event.get("blocker_class"))
|
|
1163
|
+
if value:
|
|
1164
|
+
return _normalize_blocker_class(value)
|
|
1165
|
+
automation = event.get("automation")
|
|
1166
|
+
if isinstance(automation, dict):
|
|
1167
|
+
value = _optional_text(automation.get("blocker_class") or automation.get("automation_blocker_class"))
|
|
1168
|
+
if value:
|
|
1169
|
+
return _normalize_blocker_class(value)
|
|
1170
|
+
metadata = event.get("metadata")
|
|
1171
|
+
if isinstance(metadata, dict):
|
|
1172
|
+
child_automation = metadata.get("child_automation")
|
|
1173
|
+
if isinstance(child_automation, dict):
|
|
1174
|
+
value = _optional_text(
|
|
1175
|
+
child_automation.get("automation_blocker_class") or child_automation.get("blocker_class")
|
|
1176
|
+
)
|
|
1177
|
+
if value:
|
|
1178
|
+
return _normalize_blocker_class(value)
|
|
1179
|
+
terminal = metadata.get("terminal_summary")
|
|
1180
|
+
if isinstance(terminal, dict):
|
|
1181
|
+
terminal_blocker = terminal.get("terminal_blocker")
|
|
1182
|
+
if isinstance(terminal_blocker, dict):
|
|
1183
|
+
value = _optional_text(terminal_blocker.get("blocker_class"))
|
|
1184
|
+
if value:
|
|
1185
|
+
return _normalize_blocker_class(value)
|
|
1186
|
+
return None
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def _canonical_ledger_reason(reason: str) -> str:
|
|
1190
|
+
if reason in {
|
|
1191
|
+
"phase_missing",
|
|
1192
|
+
"not_in_allowed_status_set",
|
|
1193
|
+
"legacy_pre_schema_v2",
|
|
1194
|
+
"planned_without_plan_artifact",
|
|
1195
|
+
"blocker_supersession",
|
|
1196
|
+
"event_only_status",
|
|
1197
|
+
"malformed_state_transition",
|
|
1198
|
+
"malformed_manual_recovery",
|
|
1199
|
+
"legacy_executor_closeout_action_normalized",
|
|
1200
|
+
}:
|
|
1201
|
+
return reason
|
|
1202
|
+
return "provenance_mismatch"
|
|
1203
|
+
|
|
1204
|
+
|
|
1205
|
+
def _raw_event_summary(event: dict) -> dict[str, object]:
|
|
1206
|
+
summary = {
|
|
1207
|
+
"schema_version": event.get("schema_version"),
|
|
1208
|
+
"source": event.get("source"),
|
|
1209
|
+
"phase": event.get("phase"),
|
|
1210
|
+
"action": event.get("action"),
|
|
1211
|
+
"status": event.get("status"),
|
|
1212
|
+
"timestamp": event.get("timestamp"),
|
|
1213
|
+
"roadmap_sha256_present": bool(event.get("roadmap_sha256")),
|
|
1214
|
+
"phase_sha256_present": bool(event.get("phase_sha256")),
|
|
1215
|
+
}
|
|
1216
|
+
transition = _state_transition_metadata(event)
|
|
1217
|
+
if transition is not None:
|
|
1218
|
+
summary["state_transition"] = transition
|
|
1219
|
+
return {key: value for key, value in summary.items() if value not in (None, "")}
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _normalize_automation_event(repo: Path, roadmap: Path, event: dict, current_roadmap_sha: str, current_phase_sha: dict[str, str]) -> dict:
|
|
1223
|
+
if event.get("phase") or not isinstance(event.get("automation"), dict):
|
|
1224
|
+
return event
|
|
1225
|
+
|
|
1226
|
+
automation = event["automation"]
|
|
1227
|
+
status = automation.get("status")
|
|
1228
|
+
artifact = automation.get("artifact")
|
|
1229
|
+
if status not in RECONCILE_EVENT_STATUSES or not artifact:
|
|
1230
|
+
return event
|
|
1231
|
+
|
|
1232
|
+
artifact_path = Path(str(artifact)).expanduser().resolve()
|
|
1233
|
+
try:
|
|
1234
|
+
artifact_path.relative_to(repo.resolve())
|
|
1235
|
+
except ValueError:
|
|
1236
|
+
return event
|
|
1237
|
+
match = PLAN_RE.search(artifact_path.name)
|
|
1238
|
+
if not match or not artifact_path.exists():
|
|
1239
|
+
return event
|
|
1240
|
+
phase = match.group(2).upper()
|
|
1241
|
+
if not plan_matches_roadmap(repo, artifact_path, roadmap, phase):
|
|
1242
|
+
return event
|
|
1243
|
+
|
|
1244
|
+
normalized = dict(event)
|
|
1245
|
+
normalized.update(
|
|
1246
|
+
{
|
|
1247
|
+
"repo": str(repo),
|
|
1248
|
+
"roadmap": str(roadmap),
|
|
1249
|
+
"phase": phase,
|
|
1250
|
+
"action": event.get("action", "automation"),
|
|
1251
|
+
"status": status,
|
|
1252
|
+
"source": event.get("source", "manual"),
|
|
1253
|
+
"schema_version": 2,
|
|
1254
|
+
"roadmap_sha256": current_roadmap_sha,
|
|
1255
|
+
"phase_sha256": current_phase_sha.get(phase),
|
|
1256
|
+
}
|
|
1257
|
+
)
|
|
1258
|
+
blocker = _normalize_blocker(
|
|
1259
|
+
{
|
|
1260
|
+
"human_required": _truthy(automation.get("human_required")),
|
|
1261
|
+
"blocker_class": automation.get("blocker_class"),
|
|
1262
|
+
"blocker_summary": automation.get("blocker_summary"),
|
|
1263
|
+
"required_human_inputs": automation.get("required_human_inputs", ()),
|
|
1264
|
+
}
|
|
1265
|
+
)
|
|
1266
|
+
if blocker:
|
|
1267
|
+
normalized["blocker"] = blocker
|
|
1268
|
+
return normalized
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def _normalize_legacy_executor_closeout_action(event: dict) -> tuple[dict, bool]:
|
|
1272
|
+
metadata = event.get("metadata")
|
|
1273
|
+
if event.get("action") != "run" or not isinstance(metadata, dict):
|
|
1274
|
+
return event, False
|
|
1275
|
+
if not isinstance(metadata.get("executor_closeout_event"), dict):
|
|
1276
|
+
return event, False
|
|
1277
|
+
normalized = dict(event)
|
|
1278
|
+
normalized["action"] = "executor.closeout"
|
|
1279
|
+
return normalized, True
|
|
1280
|
+
|
|
1281
|
+
|
|
1282
|
+
def _normalize_blocker(raw: dict) -> dict:
|
|
1283
|
+
human_required = _truthy(raw.get("human_required"))
|
|
1284
|
+
blocker_class = _normalize_blocker_class(_optional_text(raw.get("blocker_class")))
|
|
1285
|
+
blocker_summary = _optional_text(raw.get("blocker_summary"))
|
|
1286
|
+
required_human_inputs = tuple(raw.get("required_human_inputs") or ())
|
|
1287
|
+
access_attempts = tuple(raw.get("access_attempts") or ())
|
|
1288
|
+
if not human_required and not blocker_class and not blocker_summary and not required_human_inputs and not access_attempts:
|
|
1289
|
+
return {}
|
|
1290
|
+
return {
|
|
1291
|
+
"human_required": human_required,
|
|
1292
|
+
"blocker_class": blocker_class,
|
|
1293
|
+
"blocker_summary": blocker_summary,
|
|
1294
|
+
"required_human_inputs": required_human_inputs,
|
|
1295
|
+
"access_attempts": access_attempts,
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
|
|
1299
|
+
def _normalize_blocker_class(value: object | None) -> str | None:
|
|
1300
|
+
if value is None:
|
|
1301
|
+
return None
|
|
1302
|
+
text = str(value).strip()
|
|
1303
|
+
if text in BLOCKER_CLASSES:
|
|
1304
|
+
return text
|
|
1305
|
+
aliases = {
|
|
1306
|
+
"operator_auth_required": "account_or_billing_setup",
|
|
1307
|
+
"blocked_operator_unavailable": "account_or_billing_setup",
|
|
1308
|
+
"interactive_auth_required": "account_or_billing_setup",
|
|
1309
|
+
"blocked_by_external_setup": "admin_approval",
|
|
1310
|
+
"blocked_by_implementation": "repeated_verification_failure",
|
|
1311
|
+
}
|
|
1312
|
+
return aliases.get(text, "repeated_verification_failure")
|
|
1313
|
+
|
|
1314
|
+
|
|
1315
|
+
def _event_dirty_summary(event: dict) -> dict[str, object]:
|
|
1316
|
+
metadata = event.get("metadata")
|
|
1317
|
+
if not isinstance(metadata, dict):
|
|
1318
|
+
return {}
|
|
1319
|
+
for key in ("completion_dirty_worktree", "plan_dirty_worktree", "incomplete_execute_dirty_worktree"):
|
|
1320
|
+
value = metadata.get(key)
|
|
1321
|
+
if isinstance(value, dict) and "dirty_paths" in value:
|
|
1322
|
+
return {
|
|
1323
|
+
"dirty_paths": list(value.get("dirty_paths", ())),
|
|
1324
|
+
"phase_owned_dirty_paths": list(value.get("phase_owned_dirty_paths", ())),
|
|
1325
|
+
"previous_phase_owned_paths": list(value.get("previous_phase_owned_paths", ())),
|
|
1326
|
+
"unowned_dirty_paths": list(value.get("unowned_dirty_paths", ())),
|
|
1327
|
+
"pre_existing_dirty_paths": list(value.get("pre_existing_dirty_paths", ())),
|
|
1328
|
+
"phase_owned_dirty": bool(value.get("phase_owned_dirty", False)),
|
|
1329
|
+
"terminal_status": value.get("terminal_status"),
|
|
1330
|
+
}
|
|
1331
|
+
# Fallback: executor's terminal_summary carries dirty_paths directly when
|
|
1332
|
+
# the runner hasn't (yet) wrapped them into the completion_dirty_worktree /
|
|
1333
|
+
# plan_dirty_worktree / incomplete_execute_dirty_worktree shapes. Without
|
|
1334
|
+
# this read, the closeout fallback at runner.py:6207 sees empty
|
|
1335
|
+
# snapshot.dirty_paths and refuses, even though the executor accurately
|
|
1336
|
+
# reported its phase-owned output. Skip dry-run summaries so the
|
|
1337
|
+
# event_only_status warning path stays intact.
|
|
1338
|
+
terminal_summary = metadata.get("terminal_summary")
|
|
1339
|
+
# Only fall back when the executor actually reported dirty work AND it's
|
|
1340
|
+
# not a dry-run. Empty-dirty terminal_summaries from closeout-refused
|
|
1341
|
+
# follow-up events would otherwise overwrite the prior executor report.
|
|
1342
|
+
if (
|
|
1343
|
+
isinstance(terminal_summary, dict)
|
|
1344
|
+
and terminal_summary.get("dirty_paths")
|
|
1345
|
+
and terminal_summary.get("terminal_status") != "dry_run"
|
|
1346
|
+
and not metadata.get("dry_run_only")
|
|
1347
|
+
):
|
|
1348
|
+
return {
|
|
1349
|
+
"dirty_paths": list(terminal_summary.get("dirty_paths", ())),
|
|
1350
|
+
"phase_owned_dirty_paths": list(terminal_summary.get("phase_owned_dirty_paths", ())),
|
|
1351
|
+
"previous_phase_owned_paths": list(terminal_summary.get("previous_phase_owned_paths", ())),
|
|
1352
|
+
"unowned_dirty_paths": list(terminal_summary.get("unowned_dirty_paths", ())),
|
|
1353
|
+
"pre_existing_dirty_paths": list(terminal_summary.get("pre_existing_dirty_paths", ())),
|
|
1354
|
+
"phase_owned_dirty": bool(terminal_summary.get("phase_owned_dirty", False)),
|
|
1355
|
+
"terminal_status": terminal_summary.get("terminal_status"),
|
|
1356
|
+
}
|
|
1357
|
+
return {}
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
def _event_closeout_summary(event: dict) -> dict[str, object]:
|
|
1361
|
+
metadata = event.get("metadata")
|
|
1362
|
+
if not isinstance(metadata, dict):
|
|
1363
|
+
return {}
|
|
1364
|
+
closeout = metadata.get("closeout")
|
|
1365
|
+
if not isinstance(closeout, dict):
|
|
1366
|
+
manual_repair = metadata.get("manual_repair")
|
|
1367
|
+
closeout = manual_repair if isinstance(manual_repair, dict) and manual_repair.get("closeout_commit") else None
|
|
1368
|
+
if not isinstance(closeout, dict):
|
|
1369
|
+
return {}
|
|
1370
|
+
summary = {
|
|
1371
|
+
"closeout_mode": _optional_text(closeout.get("closeout_mode")) or ("commit" if closeout.get("closeout_commit") else None),
|
|
1372
|
+
"closeout_action": _optional_text(closeout.get("closeout_action")) or ("commit" if closeout.get("closeout_commit") else None),
|
|
1373
|
+
"closeout_commit": _optional_text(closeout.get("closeout_commit")),
|
|
1374
|
+
"closeout_push_ref": _optional_text(closeout.get("closeout_push_ref")),
|
|
1375
|
+
"closeout_refusal_reason": _optional_text(closeout.get("closeout_refusal_reason")),
|
|
1376
|
+
"verification_status": _optional_text(closeout.get("verification_status")) or ("passed" if closeout.get("closeout_commit") else None),
|
|
1377
|
+
}
|
|
1378
|
+
return {key: value for key, value in summary.items() if value is not None}
|
|
1379
|
+
|
|
1380
|
+
|
|
1381
|
+
def _closeout_completed(status: object, closeout: dict[str, object]) -> bool:
|
|
1382
|
+
if status == "complete":
|
|
1383
|
+
return True
|
|
1384
|
+
if status == "blocked":
|
|
1385
|
+
return False
|
|
1386
|
+
if status == "planned":
|
|
1387
|
+
return False
|
|
1388
|
+
if closeout.get("verification_status") in {"blocked", "failed"}:
|
|
1389
|
+
return False
|
|
1390
|
+
action = closeout.get("closeout_action")
|
|
1391
|
+
commit = closeout.get("closeout_commit")
|
|
1392
|
+
return bool(commit and action in {"commit", "push", "push_refused"})
|
|
1393
|
+
|
|
1394
|
+
|
|
1395
|
+
def _event_terminal_summary(event: dict) -> dict[str, object]:
|
|
1396
|
+
metadata = event.get("metadata")
|
|
1397
|
+
if not isinstance(metadata, dict):
|
|
1398
|
+
return {}
|
|
1399
|
+
terminal = metadata.get("terminal_summary")
|
|
1400
|
+
if not isinstance(terminal, dict):
|
|
1401
|
+
return {}
|
|
1402
|
+
summary = {
|
|
1403
|
+
"terminal_status": _optional_text(terminal.get("terminal_status")),
|
|
1404
|
+
"terminal_blocker": terminal.get("terminal_blocker"),
|
|
1405
|
+
"verification_status": _optional_text(terminal.get("verification_status")),
|
|
1406
|
+
"next_action": _optional_text(terminal.get("next_action")),
|
|
1407
|
+
"dirty_paths": list(terminal.get("dirty_paths", ())),
|
|
1408
|
+
"phase_owned_dirty": bool(terminal.get("phase_owned_dirty", False)),
|
|
1409
|
+
"phase_owned_dirty_paths": list(terminal.get("phase_owned_dirty_paths", ())),
|
|
1410
|
+
"previous_phase_owned_paths": list(terminal.get("previous_phase_owned_paths", ())),
|
|
1411
|
+
"unowned_dirty_paths": list(terminal.get("unowned_dirty_paths", ())),
|
|
1412
|
+
"pre_existing_dirty_paths": list(terminal.get("pre_existing_dirty_paths", ())),
|
|
1413
|
+
"artifact_paths": dict(terminal.get("artifact_paths", {})) if isinstance(terminal.get("artifact_paths"), dict) else {},
|
|
1414
|
+
}
|
|
1415
|
+
return {key: value for key, value in summary.items() if value is not None}
|
|
1416
|
+
|
|
1417
|
+
|
|
1418
|
+
def _event_terminal_summary_is_event_only(event: dict, terminal: dict[str, object]) -> bool:
|
|
1419
|
+
if terminal.get("terminal_status") != "dry_run":
|
|
1420
|
+
return False
|
|
1421
|
+
metadata = event.get("metadata")
|
|
1422
|
+
if not isinstance(metadata, dict):
|
|
1423
|
+
return False
|
|
1424
|
+
if metadata.get("dry_run_only") is True:
|
|
1425
|
+
return True
|
|
1426
|
+
launch = metadata.get("launch")
|
|
1427
|
+
return isinstance(launch, dict) and (launch.get("dry_run") is True or launch.get("dry_run_only") is True)
|
|
1428
|
+
|
|
1429
|
+
|
|
1430
|
+
def _default_closeout_summary(terminal_status: object) -> dict[str, object]:
|
|
1431
|
+
verification_status = "passed" if terminal_status == "complete" else "not_run"
|
|
1432
|
+
return {
|
|
1433
|
+
"closeout_mode": "manual",
|
|
1434
|
+
"closeout_action": "awaiting_phase_closeout",
|
|
1435
|
+
"verification_status": verification_status,
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
|
|
1439
|
+
def _planned_event_clears_blocker(event: dict) -> bool:
|
|
1440
|
+
transition = _state_transition_metadata(event)
|
|
1441
|
+
if transition is not None and event.get("status") in {"planned", "executing"}:
|
|
1442
|
+
return transition.get("reason") == "repair_precondition_cleared"
|
|
1443
|
+
recovery = _manual_recovery_metadata(event)
|
|
1444
|
+
if recovery is not None and event.get("status") in {"planned", "unplanned"}:
|
|
1445
|
+
return bool(recovery.get("clears_blocker"))
|
|
1446
|
+
# phase_reopen is explicit operator intent to re-dispatch from a stuck
|
|
1447
|
+
# status (typed event with --reason); always clears any prior blocker.
|
|
1448
|
+
if event.get("action") == "phase_reopen" and event.get("status") == "planned":
|
|
1449
|
+
return True
|
|
1450
|
+
if event.get("action") != "manual_repair":
|
|
1451
|
+
return False
|
|
1452
|
+
metadata = event.get("metadata")
|
|
1453
|
+
if not isinstance(metadata, dict):
|
|
1454
|
+
return False
|
|
1455
|
+
manual_repair = metadata.get("manual_repair")
|
|
1456
|
+
if isinstance(manual_repair, dict):
|
|
1457
|
+
return bool(manual_repair.get("clears_blocker"))
|
|
1458
|
+
return bool(metadata.get("clears_blocker"))
|
|
1459
|
+
|
|
1460
|
+
|
|
1461
|
+
def _manual_recovery_metadata(event: dict) -> dict[str, object] | None:
|
|
1462
|
+
if event.get("action") != "manual_recovery":
|
|
1463
|
+
return None
|
|
1464
|
+
metadata = event.get("metadata")
|
|
1465
|
+
if not isinstance(metadata, dict):
|
|
1466
|
+
return None
|
|
1467
|
+
recovery = metadata.get("manual_recovery")
|
|
1468
|
+
if not isinstance(recovery, dict):
|
|
1469
|
+
return None
|
|
1470
|
+
required = ("from", "to", "reason", "trigger")
|
|
1471
|
+
if any(not _optional_text(recovery.get(key)) for key in required):
|
|
1472
|
+
return None
|
|
1473
|
+
if recovery.get("from") != "blocked":
|
|
1474
|
+
return None
|
|
1475
|
+
if recovery.get("to") not in {"planned", "unplanned"}:
|
|
1476
|
+
return None
|
|
1477
|
+
if recovery.get("to") != event.get("status"):
|
|
1478
|
+
return None
|
|
1479
|
+
if recovery.get("trigger") != "cli":
|
|
1480
|
+
return None
|
|
1481
|
+
if recovery.get("verification_status") != "not_run":
|
|
1482
|
+
return None
|
|
1483
|
+
if not recovery.get("clears_blocker"):
|
|
1484
|
+
return None
|
|
1485
|
+
return recovery
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
def _state_transition_metadata(event: dict) -> dict[str, object] | None:
|
|
1489
|
+
if event.get("action") != "state_transition":
|
|
1490
|
+
return None
|
|
1491
|
+
metadata = event.get("metadata")
|
|
1492
|
+
if not isinstance(metadata, dict):
|
|
1493
|
+
return None
|
|
1494
|
+
transition = metadata.get("state_transition")
|
|
1495
|
+
if not isinstance(transition, dict):
|
|
1496
|
+
return None
|
|
1497
|
+
required = ("from", "to", "reason", "trigger")
|
|
1498
|
+
if any(not _optional_text(transition.get(key)) for key in required):
|
|
1499
|
+
return None
|
|
1500
|
+
return transition
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
def _event_clears_terminal_summary(event: dict, status: object) -> bool:
|
|
1504
|
+
if _manual_recovery_metadata(event) is not None and status in {"planned", "unplanned"}:
|
|
1505
|
+
return True
|
|
1506
|
+
if event.get("action") == "phase_reopen" and status == "planned":
|
|
1507
|
+
return True
|
|
1508
|
+
if event.get("action") == "manual_repair":
|
|
1509
|
+
metadata = event.get("metadata")
|
|
1510
|
+
if isinstance(metadata, dict):
|
|
1511
|
+
manual_repair = metadata.get("manual_repair")
|
|
1512
|
+
if isinstance(manual_repair, dict) and manual_repair.get("clears_blocker") and status in {"complete", "planned"}:
|
|
1513
|
+
return True
|
|
1514
|
+
if metadata.get("clears_blocker") and status in {"complete", "planned"}:
|
|
1515
|
+
return True
|
|
1516
|
+
if status != "complete":
|
|
1517
|
+
return False
|
|
1518
|
+
return event.get("action") in {"execute", "run", "manual"}
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
def _optional_text(value: object) -> object | None:
|
|
1522
|
+
if value is None:
|
|
1523
|
+
return None
|
|
1524
|
+
if isinstance(value, str):
|
|
1525
|
+
stripped = value.strip()
|
|
1526
|
+
if stripped.lower() in {"", "none", "null"}:
|
|
1527
|
+
return None
|
|
1528
|
+
if stripped.startswith("<") and stripped.endswith(">") and "none" in stripped.lower():
|
|
1529
|
+
return None
|
|
1530
|
+
return stripped
|
|
1531
|
+
return value
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def _snapshot_blocker_trusted(snapshot: StateSnapshot, current_roadmap_sha: str, current_phase_sha: dict[str, str]) -> bool:
|
|
1535
|
+
for phase, status in snapshot.phases.items():
|
|
1536
|
+
if status == "blocked" and status_provenance_matches(status, snapshot.roadmap_sha256, snapshot.phase_sha256.get(phase), current_roadmap_sha, current_phase_sha.get(phase)):
|
|
1537
|
+
return True
|
|
1538
|
+
return False
|
|
1539
|
+
|
|
1540
|
+
|
|
1541
|
+
def _current_phase(phases: dict[str, str]) -> str | None:
|
|
1542
|
+
for preferred_status in ("blocked", "awaiting_phase_closeout", "unknown", "executing", "executed", "planned", "unplanned"):
|
|
1543
|
+
for phase, status in phases.items():
|
|
1544
|
+
if status == preferred_status:
|
|
1545
|
+
return phase
|
|
1546
|
+
return None
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def _truthy(value: object) -> bool:
|
|
1550
|
+
return value is True or str(value).lower() == "true"
|