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,1092 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
validate_plan_doc.py — mechanical lint for phase-plan docs.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
validate_plan_doc.py <plan-path>
|
|
7
|
+
|
|
8
|
+
Checks the plan doc produced by /claude-plan-phase before it's handed off to
|
|
9
|
+
/claude-execute-phase. Exit 0 on clean pass; non-zero with a human-readable
|
|
10
|
+
failure list on stderr.
|
|
11
|
+
|
|
12
|
+
Checks (in order; all run even if earlier ones fail so the author sees
|
|
13
|
+
every issue at once):
|
|
14
|
+
|
|
15
|
+
(A) Required top-level headings present:
|
|
16
|
+
# <anything>
|
|
17
|
+
## Context
|
|
18
|
+
## Interface Freeze Gates
|
|
19
|
+
## Spec Closeout Plan
|
|
20
|
+
## Lane Index & Dependencies
|
|
21
|
+
## Lanes
|
|
22
|
+
## Execution Notes
|
|
23
|
+
## Acceptance Criteria
|
|
24
|
+
## Verification
|
|
25
|
+
Optional: ## Cross-Repo Gates
|
|
26
|
+
|
|
27
|
+
(B) Lane Index stanzas parse cleanly: each SL-N has
|
|
28
|
+
Depends on: comma-separated SL-IDs or (none)
|
|
29
|
+
Blocks: comma-separated SL-IDs or (none)
|
|
30
|
+
Parallel-safe: yes | no | mixed
|
|
31
|
+
|
|
32
|
+
(C) DAG has no cycles (topological sort succeeds).
|
|
33
|
+
|
|
34
|
+
(D) `Owned files` globs across ### SL-N sections are disjoint.
|
|
35
|
+
If run inside a git repo, expand each glob via `git ls-files`
|
|
36
|
+
and check for overlap. Otherwise fall back to textual prefix
|
|
37
|
+
comparison — flag any exact glob duplication and warn about
|
|
38
|
+
unresolvable cases.
|
|
39
|
+
|
|
40
|
+
(E) Every `impl` task row in a lane's task table has a preceding
|
|
41
|
+
`test` task in the same lane.
|
|
42
|
+
|
|
43
|
+
(F) Every `Interfaces consumed` entry either appears in some upstream
|
|
44
|
+
lane's `Interfaces provided` or is tagged "(pre-existing)".
|
|
45
|
+
|
|
46
|
+
(I) `Spec Closeout Plan` declares `spec_delta_closeout.v1`, a valid decision,
|
|
47
|
+
target surfaces, evidence paths, and `metadata_only` redaction.
|
|
48
|
+
|
|
49
|
+
Design: zero external deps (stdlib only). Parses markdown by regex on
|
|
50
|
+
stable headings produced by the claude-plan-phase template — not a full
|
|
51
|
+
Markdown parser, which would be overkill.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
from __future__ import annotations
|
|
55
|
+
|
|
56
|
+
import hashlib
|
|
57
|
+
import re
|
|
58
|
+
import subprocess
|
|
59
|
+
import sys
|
|
60
|
+
from dataclasses import dataclass, field
|
|
61
|
+
from pathlib import Path
|
|
62
|
+
from typing import Dict, List, Optional, Set, Tuple
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Data model
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Lane:
|
|
70
|
+
sl_id: str # e.g. "SL-1"
|
|
71
|
+
name: str # free-text after —
|
|
72
|
+
depends_on: List[str] = field(default_factory=list)
|
|
73
|
+
blocks: List[str] = field(default_factory=list)
|
|
74
|
+
parallel_safe: str = "" # yes | no | mixed
|
|
75
|
+
owned_globs: List[str] = field(default_factory=list)
|
|
76
|
+
interfaces_provided: List[str] = field(default_factory=list)
|
|
77
|
+
interfaces_consumed: List[str] = field(default_factory=list)
|
|
78
|
+
task_rows: List[dict] = field(default_factory=list) # parsed from the markdown table
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
# Helpers
|
|
83
|
+
|
|
84
|
+
def _read(path: Path) -> str:
|
|
85
|
+
try:
|
|
86
|
+
return path.read_text(encoding="utf-8")
|
|
87
|
+
except Exception as exc:
|
|
88
|
+
_fail(f"cannot read {path}: {exc}")
|
|
89
|
+
sys.exit(2)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _top_headings(src: str) -> Set[str]:
|
|
93
|
+
return {
|
|
94
|
+
line[3:].strip()
|
|
95
|
+
for line in src.splitlines()
|
|
96
|
+
if line.startswith("## ")
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _has_h1(src: str) -> bool:
|
|
101
|
+
for line in src.splitlines():
|
|
102
|
+
if line.startswith("# ") and not line.startswith("## "):
|
|
103
|
+
return True
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _parse_frontmatter(src: str) -> dict:
|
|
108
|
+
lines = src.splitlines()
|
|
109
|
+
if not lines or lines[0].strip() != "---":
|
|
110
|
+
return {}
|
|
111
|
+
data: dict[str, str] = {}
|
|
112
|
+
for line in lines[1:]:
|
|
113
|
+
if line.strip() == "---":
|
|
114
|
+
return data
|
|
115
|
+
if ":" not in line:
|
|
116
|
+
continue
|
|
117
|
+
key, value = line.split(":", 1)
|
|
118
|
+
data[key.strip()] = value.strip().strip('"').strip("'")
|
|
119
|
+
return {}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _sha256_file(path: Path) -> str:
|
|
123
|
+
digest = hashlib.sha256()
|
|
124
|
+
with path.open("rb") as fh:
|
|
125
|
+
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
|
|
126
|
+
digest.update(chunk)
|
|
127
|
+
return digest.hexdigest()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _extract_section(src: str, heading: str) -> str:
|
|
131
|
+
"""Return the body of a ## heading up to the next ## heading or EOF."""
|
|
132
|
+
lines = src.splitlines()
|
|
133
|
+
want = f"## {heading}"
|
|
134
|
+
in_section = False
|
|
135
|
+
out: List[str] = []
|
|
136
|
+
for line in lines:
|
|
137
|
+
if line.startswith("## ") and line.strip() == want:
|
|
138
|
+
in_section = True
|
|
139
|
+
continue
|
|
140
|
+
if in_section and line.startswith("## "):
|
|
141
|
+
break
|
|
142
|
+
if in_section:
|
|
143
|
+
out.append(line)
|
|
144
|
+
return "\n".join(out)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
_SL_HEADER_RE = re.compile(r"^(SL-\d+)\s*—\s*(.+?)\s*$")
|
|
148
|
+
_KEY_VALUE_RE = re.compile(r"^\s*(Depends on|Blocks|Parallel-safe)\s*:\s*(.*?)\s*$")
|
|
149
|
+
_COMMA_SPLIT_RE = re.compile(r"[,\s]+")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _parse_lane_index(body: str) -> List[Lane]:
|
|
153
|
+
"""Parse stanzas of the form:
|
|
154
|
+
SL-1 — name
|
|
155
|
+
Depends on: (none)
|
|
156
|
+
Blocks: SL-3, SL-4
|
|
157
|
+
Parallel-safe: yes
|
|
158
|
+
"""
|
|
159
|
+
lanes: List[Lane] = []
|
|
160
|
+
current: Optional[Lane] = None
|
|
161
|
+
for line in body.splitlines():
|
|
162
|
+
if not line.strip():
|
|
163
|
+
continue
|
|
164
|
+
m_header = _SL_HEADER_RE.match(line)
|
|
165
|
+
if m_header:
|
|
166
|
+
if current is not None:
|
|
167
|
+
lanes.append(current)
|
|
168
|
+
current = Lane(sl_id=m_header.group(1), name=m_header.group(2))
|
|
169
|
+
continue
|
|
170
|
+
if current is None:
|
|
171
|
+
continue
|
|
172
|
+
m_kv = _KEY_VALUE_RE.match(line)
|
|
173
|
+
if not m_kv:
|
|
174
|
+
continue
|
|
175
|
+
key, value = m_kv.group(1), m_kv.group(2)
|
|
176
|
+
if key == "Depends on":
|
|
177
|
+
current.depends_on = _parse_sl_list(value)
|
|
178
|
+
elif key == "Blocks":
|
|
179
|
+
current.blocks = _parse_sl_list(value)
|
|
180
|
+
elif key == "Parallel-safe":
|
|
181
|
+
current.parallel_safe = value.split()[0] if value.strip() else ""
|
|
182
|
+
if current is not None:
|
|
183
|
+
lanes.append(current)
|
|
184
|
+
return lanes
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _parse_sl_list(value: str) -> List[str]:
|
|
188
|
+
value = value.strip()
|
|
189
|
+
if not value or value.lower() == "(none)":
|
|
190
|
+
return []
|
|
191
|
+
return [tok.strip() for tok in re.split(r",\s*", value) if tok.strip()]
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# Regex for per-lane ### SL-N sections inside ## Lanes
|
|
195
|
+
_LANE_SECTION_RE = re.compile(r"^###\s+(SL-\d+)\b.*$", re.MULTILINE)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _split_lane_sections(lanes_body: str) -> Dict[str, str]:
|
|
199
|
+
"""Map SL-N -> section body (text between this ### heading and the next)."""
|
|
200
|
+
matches = list(_LANE_SECTION_RE.finditer(lanes_body))
|
|
201
|
+
out: Dict[str, str] = {}
|
|
202
|
+
for idx, m in enumerate(matches):
|
|
203
|
+
sl_id = m.group(1)
|
|
204
|
+
start = m.end()
|
|
205
|
+
end = matches[idx + 1].start() if idx + 1 < len(matches) else len(lanes_body)
|
|
206
|
+
out[sl_id] = lanes_body[start:end]
|
|
207
|
+
return out
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_BULLET_VALUE_RE = re.compile(
|
|
211
|
+
r"^\s*-\s*\*\*(Scope|Owned files|Interfaces provided|Interfaces consumed)\*\*\s*:\s*(.*?)\s*$",
|
|
212
|
+
re.IGNORECASE,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _parse_lane_section(body: str) -> dict:
|
|
217
|
+
"""Extract Scope, Owned files, Interfaces provided, Interfaces consumed,
|
|
218
|
+
and task rows from a single ### SL-N section body."""
|
|
219
|
+
out = {
|
|
220
|
+
"owned_globs": [],
|
|
221
|
+
"interfaces_provided": [],
|
|
222
|
+
"interfaces_consumed": [],
|
|
223
|
+
"task_rows": [],
|
|
224
|
+
}
|
|
225
|
+
# Collect bullet values (may span multiple lines if formatted oddly;
|
|
226
|
+
# stick to single-line bullets per the template).
|
|
227
|
+
for line in body.splitlines():
|
|
228
|
+
m = _BULLET_VALUE_RE.match(line)
|
|
229
|
+
if not m:
|
|
230
|
+
continue
|
|
231
|
+
key = m.group(1).lower()
|
|
232
|
+
val = m.group(2)
|
|
233
|
+
items = _split_inline_items(val)
|
|
234
|
+
if key == "owned files":
|
|
235
|
+
out["owned_globs"] = items
|
|
236
|
+
elif key == "interfaces provided":
|
|
237
|
+
out["interfaces_provided"] = items
|
|
238
|
+
elif key == "interfaces consumed":
|
|
239
|
+
out["interfaces_consumed"] = items
|
|
240
|
+
# Parse task table rows. The plan template uses:
|
|
241
|
+
# | Task ID | Type | Depends on | Files in scope | Tests owned | Test command |
|
|
242
|
+
out["task_rows"] = _parse_task_table(body)
|
|
243
|
+
return out
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
_CODE_TOKEN_RE = re.compile(r"`([^`]+)`")
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _split_inline_items(raw: str) -> List[str]:
|
|
250
|
+
"""Extract items from an inline list. Prefer backtick-quoted tokens; else
|
|
251
|
+
fall back to comma-splitting. Normalizes '(none)' to [].
|
|
252
|
+
|
|
253
|
+
Propagates an inline '(pre-existing)' annotation onto EVERY extracted
|
|
254
|
+
token so downstream checks (e.g., _check_f_interfaces_trace) can see
|
|
255
|
+
the annotation even when the user wrote it outside the backticks,
|
|
256
|
+
e.g. ``- **Interfaces consumed**: `IPlugin` (pre-existing)``.
|
|
257
|
+
"""
|
|
258
|
+
s = raw.strip()
|
|
259
|
+
if not s or s.lower() in {"(none)", "none", "—"}:
|
|
260
|
+
return []
|
|
261
|
+
has_pre_existing = "(pre-existing)" in s.lower()
|
|
262
|
+
tokens = _CODE_TOKEN_RE.findall(s)
|
|
263
|
+
if tokens:
|
|
264
|
+
items = [t.strip() for t in tokens if t.strip()]
|
|
265
|
+
else:
|
|
266
|
+
items = [tok.strip().strip("`") for tok in re.split(r",\s*", s) if tok.strip()]
|
|
267
|
+
if has_pre_existing:
|
|
268
|
+
items = [f"{t} (pre-existing)" for t in items]
|
|
269
|
+
return items
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _parse_task_table(body: str) -> List[dict]:
|
|
273
|
+
"""Parse the markdown task table out of a lane body.
|
|
274
|
+
|
|
275
|
+
Expected header (case-insensitive):
|
|
276
|
+
| Task ID | Type | Depends on | Files in scope | Tests owned | Test command |
|
|
277
|
+
"""
|
|
278
|
+
rows: List[dict] = []
|
|
279
|
+
lines = body.splitlines()
|
|
280
|
+
header_idx: Optional[int] = None
|
|
281
|
+
for idx, line in enumerate(lines):
|
|
282
|
+
if (
|
|
283
|
+
line.startswith("|")
|
|
284
|
+
and "Task ID" in line
|
|
285
|
+
and "Type" in line
|
|
286
|
+
and "Depends" in line
|
|
287
|
+
):
|
|
288
|
+
header_idx = idx
|
|
289
|
+
break
|
|
290
|
+
if header_idx is None:
|
|
291
|
+
return rows
|
|
292
|
+
# Skip the separator row (---|---|).
|
|
293
|
+
for line in lines[header_idx + 2:]:
|
|
294
|
+
if not line.startswith("|"):
|
|
295
|
+
break
|
|
296
|
+
cells = [c.strip() for c in line.split("|")[1:-1]]
|
|
297
|
+
if len(cells) < 2:
|
|
298
|
+
continue
|
|
299
|
+
task_id = cells[0].strip("`")
|
|
300
|
+
task_type = cells[1].strip("`").lower()
|
|
301
|
+
rows.append({"task_id": task_id, "type": task_type})
|
|
302
|
+
return rows
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
# ---------------------------------------------------------------------------
|
|
306
|
+
# Checks
|
|
307
|
+
|
|
308
|
+
Findings = List[str] # human-readable error strings
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _check_a_required_headings(src: str) -> Findings:
|
|
312
|
+
out: Findings = []
|
|
313
|
+
if not _has_h1(src):
|
|
314
|
+
out.append("(A) missing top-level `# <PHASE_ID>:` heading")
|
|
315
|
+
required = [
|
|
316
|
+
"Context",
|
|
317
|
+
"Interface Freeze Gates",
|
|
318
|
+
"Lane Index & Dependencies",
|
|
319
|
+
"Lanes",
|
|
320
|
+
"Execution Notes",
|
|
321
|
+
"Acceptance Criteria",
|
|
322
|
+
"Verification",
|
|
323
|
+
]
|
|
324
|
+
found = _top_headings(src)
|
|
325
|
+
for heading in required:
|
|
326
|
+
if heading not in found:
|
|
327
|
+
out.append(f"(A) missing required heading: `## {heading}`")
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _check_frontmatter(src: str, path: Path, repo_root: Optional[Path]) -> Findings:
|
|
332
|
+
out: Findings = []
|
|
333
|
+
metadata = _parse_frontmatter(src)
|
|
334
|
+
required = ("phase_loop_plan_version", "phase", "roadmap", "roadmap_sha256")
|
|
335
|
+
for key in required:
|
|
336
|
+
if not metadata.get(key):
|
|
337
|
+
out.append(f"(FM) missing required frontmatter field: `{key}`")
|
|
338
|
+
if out:
|
|
339
|
+
return out
|
|
340
|
+
if metadata.get("phase_loop_plan_version") != "1":
|
|
341
|
+
out.append("(FM) `phase_loop_plan_version` must be `1`")
|
|
342
|
+
roadmap_value = metadata.get("roadmap", "")
|
|
343
|
+
roadmap_path = Path(roadmap_value)
|
|
344
|
+
if repo_root is not None:
|
|
345
|
+
resolved_roadmap = roadmap_path if roadmap_path.is_absolute() else repo_root / roadmap_path
|
|
346
|
+
if not resolved_roadmap.exists():
|
|
347
|
+
out.append(f"(FM) `roadmap` does not resolve from repo root: `{roadmap_value}`")
|
|
348
|
+
else:
|
|
349
|
+
expected_sha = _sha256_file(resolved_roadmap)
|
|
350
|
+
if metadata.get("roadmap_sha256") != expected_sha:
|
|
351
|
+
out.append("(FM) `roadmap_sha256` does not match the referenced roadmap")
|
|
352
|
+
try:
|
|
353
|
+
expected_rel = str(resolved_roadmap.resolve().relative_to(repo_root.resolve()))
|
|
354
|
+
except ValueError:
|
|
355
|
+
expected_rel = str(resolved_roadmap.resolve())
|
|
356
|
+
if roadmap_value != expected_rel:
|
|
357
|
+
out.append(f"(FM) `roadmap` must be repo-relative canonical path `{expected_rel}`")
|
|
358
|
+
elif not roadmap_value:
|
|
359
|
+
out.append("(FM) `roadmap` must name the roadmap path")
|
|
360
|
+
if path.name:
|
|
361
|
+
phase = metadata.get("phase", "").upper()
|
|
362
|
+
if phase and not path.name.upper().endswith(f"-{phase}.MD"):
|
|
363
|
+
out.append(f"(FM) `phase` `{phase}` does not match plan filename `{path.name}`")
|
|
364
|
+
return out
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _check_b_lane_index_parses(lanes: List[Lane]) -> Findings:
|
|
368
|
+
out: Findings = []
|
|
369
|
+
if not lanes:
|
|
370
|
+
out.append("(B) `## Lane Index & Dependencies` has no parseable `SL-N — name` stanzas")
|
|
371
|
+
return out
|
|
372
|
+
for lane in lanes:
|
|
373
|
+
if not lane.parallel_safe:
|
|
374
|
+
out.append(f"(B) {lane.sl_id}: missing `Parallel-safe:` line")
|
|
375
|
+
elif lane.parallel_safe not in {"yes", "no", "mixed"}:
|
|
376
|
+
out.append(
|
|
377
|
+
f"(B) {lane.sl_id}: `Parallel-safe: {lane.parallel_safe}` not in {{yes, no, mixed}}"
|
|
378
|
+
)
|
|
379
|
+
return out
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _check_c_dag_acyclic(lanes: List[Lane]) -> Findings:
|
|
383
|
+
"""Kahn's algorithm. Nodes with no incoming edges go first; remove them
|
|
384
|
+
and their outgoing edges; repeat. If we can't drain the graph, there's
|
|
385
|
+
a cycle."""
|
|
386
|
+
out: Findings = []
|
|
387
|
+
if not lanes:
|
|
388
|
+
return out
|
|
389
|
+
# Validate referenced IDs exist.
|
|
390
|
+
known_ids = {lane.sl_id for lane in lanes}
|
|
391
|
+
for lane in lanes:
|
|
392
|
+
for dep in lane.depends_on:
|
|
393
|
+
if dep not in known_ids:
|
|
394
|
+
out.append(f"(C) {lane.sl_id}: depends on unknown lane `{dep}`")
|
|
395
|
+
if out:
|
|
396
|
+
return out
|
|
397
|
+
in_deg: Dict[str, int] = {lane.sl_id: len(lane.depends_on) for lane in lanes}
|
|
398
|
+
reverse_edges: Dict[str, List[str]] = {lane.sl_id: [] for lane in lanes}
|
|
399
|
+
for lane in lanes:
|
|
400
|
+
for dep in lane.depends_on:
|
|
401
|
+
reverse_edges[dep].append(lane.sl_id)
|
|
402
|
+
queue = [sl for sl, d in in_deg.items() if d == 0]
|
|
403
|
+
visited = 0
|
|
404
|
+
while queue:
|
|
405
|
+
node = queue.pop(0)
|
|
406
|
+
visited += 1
|
|
407
|
+
for dependent in reverse_edges[node]:
|
|
408
|
+
in_deg[dependent] -= 1
|
|
409
|
+
if in_deg[dependent] == 0:
|
|
410
|
+
queue.append(dependent)
|
|
411
|
+
if visited != len(lanes):
|
|
412
|
+
unresolved = [sl for sl, d in in_deg.items() if d > 0]
|
|
413
|
+
out.append(f"(C) lane DAG has a cycle; unresolved after topological sort: {unresolved}")
|
|
414
|
+
return out
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _git_ls_files(repo_root: Path) -> Optional[List[str]]:
|
|
418
|
+
try:
|
|
419
|
+
r = subprocess.run(
|
|
420
|
+
["git", "ls-files"],
|
|
421
|
+
cwd=repo_root,
|
|
422
|
+
capture_output=True,
|
|
423
|
+
text=True,
|
|
424
|
+
check=True,
|
|
425
|
+
)
|
|
426
|
+
except Exception:
|
|
427
|
+
return None
|
|
428
|
+
return [line for line in r.stdout.splitlines() if line]
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _glob_to_regex(pattern: str) -> re.Pattern:
|
|
432
|
+
"""Minimal glob to regex: ** → any depth; * → any chars not /; everything else literal."""
|
|
433
|
+
s = pattern.strip("`").strip()
|
|
434
|
+
# Escape regex specials, then un-escape our own glob metacharacters.
|
|
435
|
+
esc = re.escape(s)
|
|
436
|
+
esc = esc.replace(r"\*\*", ".*").replace(r"\*", "[^/]*").replace(r"\?", ".")
|
|
437
|
+
return re.compile("^" + esc + "$")
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def _check_d_owned_files_disjoint(
|
|
441
|
+
lane_sections: Dict[str, dict],
|
|
442
|
+
repo_root: Optional[Path],
|
|
443
|
+
) -> Findings:
|
|
444
|
+
out: Findings = []
|
|
445
|
+
tracked: Optional[List[str]] = None
|
|
446
|
+
if repo_root is not None:
|
|
447
|
+
tracked = _git_ls_files(repo_root)
|
|
448
|
+
|
|
449
|
+
# First: exact glob duplication check.
|
|
450
|
+
glob_origin: Dict[str, str] = {}
|
|
451
|
+
for sl_id, parsed in lane_sections.items():
|
|
452
|
+
for glob in parsed["owned_globs"]:
|
|
453
|
+
norm = glob.strip().strip("`")
|
|
454
|
+
if not norm:
|
|
455
|
+
continue
|
|
456
|
+
if norm in glob_origin and glob_origin[norm] != sl_id:
|
|
457
|
+
out.append(
|
|
458
|
+
f"(D) duplicate owned glob `{norm}` in {glob_origin[norm]} and {sl_id}"
|
|
459
|
+
)
|
|
460
|
+
else:
|
|
461
|
+
glob_origin[norm] = sl_id
|
|
462
|
+
|
|
463
|
+
if tracked is None:
|
|
464
|
+
# No repo context — skip expansion, only the exact-duplicate check ran.
|
|
465
|
+
out.append(
|
|
466
|
+
"(D) WARN: not running inside a git repo; owned-file disjointness only checked for exact duplicates"
|
|
467
|
+
)
|
|
468
|
+
return out
|
|
469
|
+
|
|
470
|
+
# Expand each lane's globs to a concrete file set, then intersect.
|
|
471
|
+
lane_file_sets: Dict[str, Set[str]] = {}
|
|
472
|
+
for sl_id, parsed in lane_sections.items():
|
|
473
|
+
matched: Set[str] = set()
|
|
474
|
+
for glob in parsed["owned_globs"]:
|
|
475
|
+
norm = glob.strip().strip("`")
|
|
476
|
+
if not norm:
|
|
477
|
+
continue
|
|
478
|
+
rex = _glob_to_regex(norm)
|
|
479
|
+
matched |= {p for p in tracked if rex.match(p)}
|
|
480
|
+
lane_file_sets[sl_id] = matched
|
|
481
|
+
|
|
482
|
+
sl_ids = sorted(lane_file_sets.keys())
|
|
483
|
+
for i in range(len(sl_ids)):
|
|
484
|
+
for j in range(i + 1, len(sl_ids)):
|
|
485
|
+
a, b = sl_ids[i], sl_ids[j]
|
|
486
|
+
inter = lane_file_sets[a] & lane_file_sets[b]
|
|
487
|
+
if inter:
|
|
488
|
+
sample = sorted(inter)[:5]
|
|
489
|
+
out.append(
|
|
490
|
+
f"(D) file-ownership overlap between {a} and {b}: {len(inter)} files "
|
|
491
|
+
f"(first few: {sample})"
|
|
492
|
+
)
|
|
493
|
+
return out
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _check_owned_files_are_concrete(lane_sections_raw: Dict[str, str]) -> Findings:
|
|
497
|
+
out: Findings = []
|
|
498
|
+
for sl_id, body in lane_sections_raw.items():
|
|
499
|
+
for line in body.splitlines():
|
|
500
|
+
m = _BULLET_VALUE_RE.match(line)
|
|
501
|
+
if not m or m.group(1).lower() != "owned files":
|
|
502
|
+
continue
|
|
503
|
+
raw = m.group(2).strip()
|
|
504
|
+
if raw.lower() in {"(none)", "none", "—"}:
|
|
505
|
+
continue
|
|
506
|
+
for item in [part.strip() for part in raw.split(",") if part.strip()]:
|
|
507
|
+
if not re.fullmatch(r"`[^`]+`", item):
|
|
508
|
+
out.append(
|
|
509
|
+
f"(D) {sl_id}: `Owned files` must be comma-separated backticked concrete paths/globs; "
|
|
510
|
+
f"found prose or unquoted entry `{item}`"
|
|
511
|
+
)
|
|
512
|
+
return out
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _check_e_test_before_impl(lane_sections: Dict[str, dict]) -> Findings:
|
|
516
|
+
out: Findings = []
|
|
517
|
+
for sl_id, parsed in lane_sections.items():
|
|
518
|
+
seen_test = False
|
|
519
|
+
for row in parsed["task_rows"]:
|
|
520
|
+
tt = row["type"]
|
|
521
|
+
if tt == "test":
|
|
522
|
+
seen_test = True
|
|
523
|
+
elif tt == "impl" and not seen_test:
|
|
524
|
+
out.append(
|
|
525
|
+
f"(E) {sl_id} task {row['task_id']}: `impl` appears before any `test` in the same lane"
|
|
526
|
+
)
|
|
527
|
+
return out
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _check_f_interfaces_trace(
|
|
531
|
+
lane_sections: Dict[str, dict],
|
|
532
|
+
lane_sections_raw: Optional[Dict[str, str]] = None,
|
|
533
|
+
) -> Findings:
|
|
534
|
+
out: Findings = []
|
|
535
|
+
provided: Set[str] = set()
|
|
536
|
+
for parsed in lane_sections.values():
|
|
537
|
+
for sym in parsed["interfaces_provided"]:
|
|
538
|
+
provided.add(_normalize_interface(sym))
|
|
539
|
+
for sl_id, parsed in lane_sections.items():
|
|
540
|
+
# Fall back to raw body scan when the token's annotation was
|
|
541
|
+
# separated from it during parsing (e.g. ``backtick`` (pre-existing)).
|
|
542
|
+
raw_body = (lane_sections_raw or {}).get(sl_id, "")
|
|
543
|
+
for sym in parsed["interfaces_consumed"]:
|
|
544
|
+
norm = _normalize_interface(sym)
|
|
545
|
+
if not norm:
|
|
546
|
+
continue
|
|
547
|
+
if "pre-existing" in sym.lower():
|
|
548
|
+
continue
|
|
549
|
+
# Raw-body fallback: the token itself appears with a (pre-existing)
|
|
550
|
+
# annotation somewhere in the lane body (maybe on a different line).
|
|
551
|
+
if raw_body:
|
|
552
|
+
pattern = rf"`?{re.escape(norm)}`?\s*\(pre-existing\)"
|
|
553
|
+
if re.search(pattern, raw_body, re.IGNORECASE):
|
|
554
|
+
continue
|
|
555
|
+
if norm not in provided:
|
|
556
|
+
out.append(
|
|
557
|
+
f"(F) WARN: {sl_id} consumes `{sym}` but no upstream lane provides it "
|
|
558
|
+
f"(mark '(pre-existing)' if it exists outside P-this-phase)"
|
|
559
|
+
)
|
|
560
|
+
return out
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _check_g_grep_paired_with_tests(src: str) -> Findings:
|
|
564
|
+
"""Every acceptance criterion that uses `rg`/`grep` as its sole assertion
|
|
565
|
+
must cite a test file in the same bullet. A bare grep is defeatable by
|
|
566
|
+
renaming a symbol to pass the regex — pair it with a behavioral test."""
|
|
567
|
+
out: Findings = []
|
|
568
|
+
body = _extract_section(src, "Acceptance Criteria")
|
|
569
|
+
if not body:
|
|
570
|
+
return out
|
|
571
|
+
# Iterate top-level bullets. A bullet is a line starting with "- [ ]" or "- [x]".
|
|
572
|
+
# Multi-line bullets continue until the next top-level bullet or blank line.
|
|
573
|
+
lines = body.splitlines()
|
|
574
|
+
current: List[str] = []
|
|
575
|
+
bullets: List[str] = []
|
|
576
|
+
for line in lines:
|
|
577
|
+
if line.lstrip().startswith("- ["):
|
|
578
|
+
if current:
|
|
579
|
+
bullets.append("\n".join(current))
|
|
580
|
+
current = [line]
|
|
581
|
+
elif current:
|
|
582
|
+
if not line.strip():
|
|
583
|
+
bullets.append("\n".join(current))
|
|
584
|
+
current = []
|
|
585
|
+
else:
|
|
586
|
+
current.append(line)
|
|
587
|
+
if current:
|
|
588
|
+
bullets.append("\n".join(current))
|
|
589
|
+
for bullet in bullets:
|
|
590
|
+
low = bullet.lower()
|
|
591
|
+
has_grep = (
|
|
592
|
+
re.search(r"\brg\s+-?\w*\b", bullet) is not None
|
|
593
|
+
or re.search(r"\bgrep\s+-?\w*\b", bullet) is not None
|
|
594
|
+
)
|
|
595
|
+
if not has_grep:
|
|
596
|
+
continue
|
|
597
|
+
# Cite a test file if anything in the bullet looks like a test path:
|
|
598
|
+
# `tests/` or `test_` or a `.py` with a test-ish stem.
|
|
599
|
+
cites_test = (
|
|
600
|
+
"tests/" in low
|
|
601
|
+
or re.search(r"\btest_\w+\.py\b", low) is not None
|
|
602
|
+
or re.search(r"\.py\b", low) is not None
|
|
603
|
+
and ("test" in low or "pytest" in low)
|
|
604
|
+
)
|
|
605
|
+
if not cites_test:
|
|
606
|
+
first_line = bullet.strip().splitlines()[0][:90]
|
|
607
|
+
out.append(
|
|
608
|
+
f"(G) WARN: acceptance criterion uses grep/rg without citing a paired test "
|
|
609
|
+
f"file — rename-defeat risk: {first_line!r}"
|
|
610
|
+
)
|
|
611
|
+
return out
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def _check_h_eager_reexport(src: str) -> Findings:
|
|
615
|
+
"""If Execution Notes says SL-0 adds re-exports to a __init__.py, require
|
|
616
|
+
the doc to also specify the __getattr__ lazy pattern. Eager top-level
|
|
617
|
+
re-exports break package load when a later lane drops or renames the
|
|
618
|
+
exported symbol."""
|
|
619
|
+
out: Findings = []
|
|
620
|
+
body = _extract_section(src, "Execution Notes")
|
|
621
|
+
if not body:
|
|
622
|
+
return out
|
|
623
|
+
low = body.lower()
|
|
624
|
+
mentions_reexport = ("re-export" in low) or ("reexport" in low)
|
|
625
|
+
mentions_init_py = "__init__.py" in low
|
|
626
|
+
if mentions_reexport and mentions_init_py and "__getattr__" not in low:
|
|
627
|
+
out.append(
|
|
628
|
+
"(H) WARN: Execution Notes mentions re-exports in __init__.py but does not "
|
|
629
|
+
"specify the `__getattr__` lazy pattern — eager top-level imports break "
|
|
630
|
+
"package load when a later lane drops or renames the exported symbol"
|
|
631
|
+
)
|
|
632
|
+
return out
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
_SPEC_DELTA_DECISIONS = {
|
|
636
|
+
"no_spec_delta",
|
|
637
|
+
"roadmap_amendment",
|
|
638
|
+
"canonical_spec_update",
|
|
639
|
+
"governed_pipeline_refresh",
|
|
640
|
+
"mirror_cutover_required",
|
|
641
|
+
"dotfiles_skill_source_update",
|
|
642
|
+
"human_source_judgment_required",
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _check_i_spec_closeout_plan(src: str) -> Findings:
|
|
647
|
+
out: Findings = []
|
|
648
|
+
body = _extract_section(src, "Spec Closeout Plan")
|
|
649
|
+
if not body.strip():
|
|
650
|
+
return ["(I) missing required `## Spec Closeout Plan` section"]
|
|
651
|
+
for token in ("spec_delta_closeout.v1", "target surfaces", "evidence paths", "metadata_only"):
|
|
652
|
+
if token not in body:
|
|
653
|
+
out.append(f"(I) Spec Closeout Plan missing `{token}`")
|
|
654
|
+
decision_match = re.search(r"(?im)^\s*-\s*decision\s*:\s*`?([a-z_]+)`?\s*$", body)
|
|
655
|
+
if not decision_match:
|
|
656
|
+
out.append("(I) Spec Closeout Plan missing `decision: <literal>`")
|
|
657
|
+
elif decision_match.group(1) not in _SPEC_DELTA_DECISIONS:
|
|
658
|
+
allowed = ", ".join(sorted(_SPEC_DELTA_DECISIONS))
|
|
659
|
+
out.append(
|
|
660
|
+
f"(I) invalid Spec Closeout Plan decision `{decision_match.group(1)}`; allowed: {allowed}"
|
|
661
|
+
)
|
|
662
|
+
return out
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
def _normalize_interface(sym: str) -> str:
|
|
666
|
+
"""Strip backticks, parens, and leading/trailing whitespace. Drop annotations like '(pre-existing)'."""
|
|
667
|
+
s = sym.strip().strip("`").strip()
|
|
668
|
+
# Drop any trailing parenthesized annotation.
|
|
669
|
+
s = re.sub(r"\s*\([^)]*\)\s*$", "", s)
|
|
670
|
+
return s.strip()
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
# ---------------------------------------------------------------------------
|
|
674
|
+
# Main
|
|
675
|
+
|
|
676
|
+
def _fail(msg: str) -> None:
|
|
677
|
+
print(f"validate_plan_doc: {msg}", file=sys.stderr)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _check_j_docs_lane(src: str) -> Findings:
|
|
681
|
+
"""rigor-v1 P2: warn when no terminal docs-sweep lane is present.
|
|
682
|
+
|
|
683
|
+
plan-phase requires a no-opt-out docs lane so a public-surface change cannot
|
|
684
|
+
silently skip its doc footprint. Autonomy-first: this is a WARN (recorded,
|
|
685
|
+
non-blocking); promote to an error once adopted across the fleet.
|
|
686
|
+
"""
|
|
687
|
+
for m in re.finditer(r"^###\s+SL-\d+\s*[—-]\s*(.+?)\s*$", src, re.MULTILINE):
|
|
688
|
+
# Word-bounded so "Docker"/"Docusaurus" don't masquerade as a docs lane.
|
|
689
|
+
if re.search(r"\bdocs?\b|\bdocumentation\b", m.group(1), re.IGNORECASE):
|
|
690
|
+
return []
|
|
691
|
+
if re.search(r"SL-docs|docs[-\s]sweep", src, re.IGNORECASE):
|
|
692
|
+
return []
|
|
693
|
+
return [
|
|
694
|
+
"(J) WARN: no terminal docs-sweep lane found (a `### SL-N — …docs…` lane). "
|
|
695
|
+
"plan-phase requires a no-opt-out docs lane; add a terminal "
|
|
696
|
+
"`SL-N — Documentation sweep` lane or promote this check to an error once adopted."
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def _acceptance_bullets(src: str) -> List[str]:
|
|
701
|
+
body = _extract_section(src, "Acceptance Criteria")
|
|
702
|
+
if not body:
|
|
703
|
+
return []
|
|
704
|
+
bullets: List[str] = []
|
|
705
|
+
current: List[str] = []
|
|
706
|
+
for line in body.splitlines():
|
|
707
|
+
if line.lstrip().startswith("- ["):
|
|
708
|
+
if current:
|
|
709
|
+
bullets.append("\n".join(current))
|
|
710
|
+
current = [line]
|
|
711
|
+
elif current:
|
|
712
|
+
if not line.strip():
|
|
713
|
+
bullets.append("\n".join(current))
|
|
714
|
+
current = []
|
|
715
|
+
else:
|
|
716
|
+
current.append(line)
|
|
717
|
+
if current:
|
|
718
|
+
bullets.append("\n".join(current))
|
|
719
|
+
return bullets
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _check_k_acceptance_testable(src: str) -> Findings:
|
|
723
|
+
"""rigor-v1 P5: warn on acceptance criteria that name no proving command.
|
|
724
|
+
|
|
725
|
+
A testable bullet cites something checkable — a command/path/symbol in
|
|
726
|
+
backticks, a path, an HTTP verb+status, or a comparison/return assertion.
|
|
727
|
+
A prose bullet ("users can log in") is not mechanically checkable.
|
|
728
|
+
Autonomy-first WARN; promotion to error is opt-in.
|
|
729
|
+
"""
|
|
730
|
+
out: Findings = []
|
|
731
|
+
# Case-sensitive on purpose: uppercase HTTP verbs are real assertions, but
|
|
732
|
+
# lowercase "get"/"post"/"put" are ordinary English. No bare "/\w" (it matched
|
|
733
|
+
# "and/or", "TCP/IP") and no bare "returns" (matched "user returns home").
|
|
734
|
+
testable = re.compile(
|
|
735
|
+
r"`[^`]+`" # backticked command/path/symbol
|
|
736
|
+
r"|\b(GET|POST|PUT|PATCH|DELETE)\b.*?\b\d{3}\b" # HTTP verb (uppercase) + status code
|
|
737
|
+
r"|==|!=|>=|<=|->" # comparison / return arrow
|
|
738
|
+
r"|\bexit code\b|\bstatus code\b"
|
|
739
|
+
r"|\btests?/|\btest_\w|\bpytest\b" # cites a test
|
|
740
|
+
)
|
|
741
|
+
for bullet in _acceptance_bullets(src):
|
|
742
|
+
if not testable.search(bullet):
|
|
743
|
+
first = bullet.strip().splitlines()[0][:90]
|
|
744
|
+
out.append(
|
|
745
|
+
f"(K) WARN: acceptance criterion is not mechanically testable — "
|
|
746
|
+
f"name the proving command/path/assertion: {first!r}"
|
|
747
|
+
)
|
|
748
|
+
return out
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _check_l_ui_visual_verification(src: str) -> Findings:
|
|
752
|
+
"""rigor-v1 P6: warn when a plan touches UI/visual files but the Verification
|
|
753
|
+
section names no browser/screenshot step. Autonomy-first WARN."""
|
|
754
|
+
if not re.search(r"\.(tsx|jsx|vue|svelte|css|scss)\b|/components/", src, re.IGNORECASE):
|
|
755
|
+
return []
|
|
756
|
+
verif = _extract_section(src, "Verification") or ""
|
|
757
|
+
if re.search(r"playwright|screenshot|browser|in-chrome|visual", verif, re.IGNORECASE):
|
|
758
|
+
return []
|
|
759
|
+
return [
|
|
760
|
+
"(L) WARN: plan touches UI/visual surfaces but `## Verification` names no "
|
|
761
|
+
"browser/screenshot/Playwright step — add a visual check (and a visually "
|
|
762
|
+
"observable acceptance criterion) for UI changes."
|
|
763
|
+
]
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
# Release/package signal (issue #18). Either the explicit release-dispatch
|
|
767
|
+
# mutation in frontmatter, OR a lane owning a release artifact (manifest /
|
|
768
|
+
# version file / changelog / release workflow).
|
|
769
|
+
_RELEASE_ARTIFACT_RE = re.compile(
|
|
770
|
+
r"package\.json|Cargo\.toml|pyproject\.toml|setup\.cfg|"
|
|
771
|
+
r"(?:^|/)VERSION$|version\.txt|__version__\.py|CHANGELOG|"
|
|
772
|
+
r"\.github/workflows/(?:release|publish)",
|
|
773
|
+
re.IGNORECASE,
|
|
774
|
+
)
|
|
775
|
+
# Public-doc surfaces a release/package phase's docs lane must own (or record a
|
|
776
|
+
# no-doc-change decision for).
|
|
777
|
+
_RELEASE_DOC_SURFACE_RE = re.compile(r"README\.md|CHANGELOG|RELEASE", re.IGNORECASE)
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _is_explicit_release_plan(src: str) -> bool:
|
|
781
|
+
"""True only when the plan frontmatter *explicitly* declares a release.
|
|
782
|
+
|
|
783
|
+
This is the authoritative marker and the ONLY signal that escalates the
|
|
784
|
+
docs-coverage gap to an ERROR. The artifact-glob heuristic deliberately does
|
|
785
|
+
not satisfy it (an ordinary changelog/dep bump must not become an ERROR).
|
|
786
|
+
"""
|
|
787
|
+
fm = _parse_frontmatter_block(src)
|
|
788
|
+
if str(fm.get("phase_loop_mutation", "")).strip().lower() == "release_dispatch":
|
|
789
|
+
return True
|
|
790
|
+
return str(fm.get("phase_type", "")).strip().lower() in (
|
|
791
|
+
"release",
|
|
792
|
+
"package",
|
|
793
|
+
"roadmap_completion",
|
|
794
|
+
"roadmap-completion",
|
|
795
|
+
)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _is_release_plan(src: str, lane_sections_parsed: Dict[str, dict]) -> bool:
|
|
799
|
+
"""Release/package *shape* — explicit frontmatter OR a lane owning a release
|
|
800
|
+
artifact. Governs whether the docs-coverage check fires at all; the
|
|
801
|
+
explicit-vs-heuristic distinction (ERROR vs WARN) is made by callers via
|
|
802
|
+
:func:`_is_explicit_release_plan`."""
|
|
803
|
+
if _is_explicit_release_plan(src):
|
|
804
|
+
return True
|
|
805
|
+
for parsed in lane_sections_parsed.values():
|
|
806
|
+
for glob in parsed.get("owned_globs", []):
|
|
807
|
+
if _RELEASE_ARTIFACT_RE.search(glob):
|
|
808
|
+
return True
|
|
809
|
+
return False
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def _parse_frontmatter_block(src: str) -> Dict[str, str]:
|
|
813
|
+
if not src.startswith("---\n"):
|
|
814
|
+
return {}
|
|
815
|
+
end = src.find("\n---", 4)
|
|
816
|
+
if end == -1:
|
|
817
|
+
return {}
|
|
818
|
+
data: Dict[str, str] = {}
|
|
819
|
+
for line in src[4:end].splitlines():
|
|
820
|
+
if ":" not in line:
|
|
821
|
+
continue
|
|
822
|
+
key, value = line.split(":", 1)
|
|
823
|
+
data[key.strip()] = value.strip().strip("'\"")
|
|
824
|
+
return data
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _docs_lane_ids(lanes: List[Lane], lane_sections_parsed: Dict[str, dict]) -> List[str]:
|
|
828
|
+
ids: List[str] = []
|
|
829
|
+
for lane in lanes:
|
|
830
|
+
if re.search(r"\bdocs?\b|\bdocumentation\b", lane.name, re.IGNORECASE):
|
|
831
|
+
ids.append(lane.sl_id)
|
|
832
|
+
return ids
|
|
833
|
+
|
|
834
|
+
|
|
835
|
+
def _check_m_release_docs_coverage(
|
|
836
|
+
src: str,
|
|
837
|
+
lanes: List[Lane],
|
|
838
|
+
lane_sections_parsed: Dict[str, dict],
|
|
839
|
+
) -> Findings:
|
|
840
|
+
"""issue #18: release/package phases must own public-doc surfaces and the
|
|
841
|
+
docs reducer must depend on every producer lane.
|
|
842
|
+
|
|
843
|
+
For phases that **explicitly** declare a release (frontmatter
|
|
844
|
+
``phase_loop_mutation: release_dispatch`` / a release ``phase_type``) this is
|
|
845
|
+
an ERROR (the issue asks for a blocker). For a heuristic-only release shape
|
|
846
|
+
(a lane owns a release artifact but the plan carries no explicit release
|
|
847
|
+
frontmatter) — and for ordinary phases — the same coverage gap is recorded
|
|
848
|
+
as a WARN to preserve autonomy-first: an ordinary changelog/dep bump must
|
|
849
|
+
not be escalated to a blocking ERROR. Skips entirely when the phase is
|
|
850
|
+
neither (no docs lane is a separate concern handled by check (J)).
|
|
851
|
+
"""
|
|
852
|
+
release = _is_release_plan(src, lane_sections_parsed)
|
|
853
|
+
explicit = _is_explicit_release_plan(src)
|
|
854
|
+
docs_ids = _docs_lane_ids(lanes, lane_sections_parsed)
|
|
855
|
+
if not docs_ids:
|
|
856
|
+
# check (J) already covers a wholly-missing docs lane; for explicit
|
|
857
|
+
# release phases restate it as an error here. A heuristic-only release
|
|
858
|
+
# shape stays inert here (its gap is WARN-tier, handled below only when a
|
|
859
|
+
# docs lane exists).
|
|
860
|
+
if explicit:
|
|
861
|
+
return [
|
|
862
|
+
"(M) release/package phase has no docs lane that owns README/"
|
|
863
|
+
"CHANGELOG/release-notes — add a terminal docs lane owning the "
|
|
864
|
+
"public-doc surfaces or record a per-surface no-doc-change decision."
|
|
865
|
+
]
|
|
866
|
+
return []
|
|
867
|
+
|
|
868
|
+
tag = "" if explicit else "WARN: "
|
|
869
|
+
out: Findings = []
|
|
870
|
+
|
|
871
|
+
# 1) The docs lane(s) must own at least one public-doc surface, OR the plan
|
|
872
|
+
# records an explicit no-doc-change decision (`no_doc_delta` /
|
|
873
|
+
# `freshness-ok` / "no doc change").
|
|
874
|
+
owns_doc_surface = False
|
|
875
|
+
for sl_id in docs_ids:
|
|
876
|
+
for glob in lane_sections_parsed.get(sl_id, {}).get("owned_globs", []):
|
|
877
|
+
if _RELEASE_DOC_SURFACE_RE.search(glob):
|
|
878
|
+
owns_doc_surface = True
|
|
879
|
+
break
|
|
880
|
+
records_no_doc_decision = bool(
|
|
881
|
+
re.search(r"no_doc_delta|no doc change|no-doc-change|freshness-ok", src, re.IGNORECASE)
|
|
882
|
+
)
|
|
883
|
+
if not owns_doc_surface and not records_no_doc_decision:
|
|
884
|
+
out.append(
|
|
885
|
+
f"(M) {tag}docs lane(s) {', '.join(docs_ids)} do not own README/"
|
|
886
|
+
"CHANGELOG/release-notes and the plan records no explicit "
|
|
887
|
+
"no-doc-change decision; a release/package phase must own its public-"
|
|
888
|
+
"doc surfaces or justify why each is already current."
|
|
889
|
+
)
|
|
890
|
+
|
|
891
|
+
# 2) The docs reducer must depend on every producer lane (the SKILL.md:523
|
|
892
|
+
# rule). Producers = every non-docs lane.
|
|
893
|
+
producers = {lane.sl_id for lane in lanes if lane.sl_id not in docs_ids}
|
|
894
|
+
by_id = {lane.sl_id: lane for lane in lanes}
|
|
895
|
+
for sl_id in docs_ids:
|
|
896
|
+
lane = by_id.get(sl_id)
|
|
897
|
+
if lane is None:
|
|
898
|
+
continue
|
|
899
|
+
missing = sorted(producers - set(lane.depends_on), key=_sl_sort_key)
|
|
900
|
+
if missing:
|
|
901
|
+
out.append(
|
|
902
|
+
f"(M) {tag}docs reducer {sl_id} does not depend on every producer "
|
|
903
|
+
f"lane; missing `Depends on`: {', '.join(missing)} — a synthesized "
|
|
904
|
+
"docs/release summary must list every producer lane it summarizes."
|
|
905
|
+
)
|
|
906
|
+
return out
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _sl_sort_key(sl_id: str) -> tuple:
|
|
910
|
+
m = re.match(r"SL-(\d+)", sl_id)
|
|
911
|
+
return (int(m.group(1)),) if m else (10**9, sl_id)
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# issue #18 F4 — post-dispatch evidence-reducer lane.
|
|
915
|
+
#
|
|
916
|
+
# A release-DISPATCH phase cuts a tag / runs an external release workflow whose
|
|
917
|
+
# commit SHA and workflow result are NOT KNOWABLE until *after* dispatch. Any
|
|
918
|
+
# evidence doc a pre-dispatch reducer writes therefore carries a placeholder
|
|
919
|
+
# (e.g. `recovery commit pending`). F1's docs-freshness scan BLOCKS a release
|
|
920
|
+
# closeout if those placeholders survive — that is the backstop. F4 makes the
|
|
921
|
+
# back-fill an explicit, *required* planning step: a release-dispatch plan must
|
|
922
|
+
# include a post-dispatch lane that re-opens the pre-dispatch evidence docs and
|
|
923
|
+
# back-fills the now-known SHA / workflow result, so the placeholder is resolved
|
|
924
|
+
# by design rather than tripping the closeout gate.
|
|
925
|
+
#
|
|
926
|
+
# Tokens that mark a lane as the post-dispatch reducer. Word/phrase signals the
|
|
927
|
+
# planner is told to emit (see plan-phase SKILL.md). Matched against the lane
|
|
928
|
+
# NAME and its section body.
|
|
929
|
+
_POST_DISPATCH_LANE_RE = re.compile(
|
|
930
|
+
r"post[-\s]?dispatch|back[-\s]?fill|backfill|"
|
|
931
|
+
r"reconcile.*(?:sha|commit|workflow|release)|"
|
|
932
|
+
r"(?:sha|commit|workflow|tag).*back[-\s]?fill",
|
|
933
|
+
re.IGNORECASE,
|
|
934
|
+
)
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _is_release_dispatch_plan(src: str) -> bool:
|
|
938
|
+
"""True only for an explicit ``phase_loop_mutation: release_dispatch`` plan.
|
|
939
|
+
|
|
940
|
+
F4 is scoped to the *dispatch* case specifically (not every explicit
|
|
941
|
+
release ``phase_type``): only a dispatch cuts a tag / runs a workflow whose
|
|
942
|
+
SHA + result are unknowable pre-dispatch, which is what creates the
|
|
943
|
+
placeholder a post-dispatch reducer must back-fill.
|
|
944
|
+
"""
|
|
945
|
+
fm = _parse_frontmatter_block(src)
|
|
946
|
+
return str(fm.get("phase_loop_mutation", "")).strip().lower() == "release_dispatch"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
def _has_post_dispatch_reducer_lane(
|
|
950
|
+
lanes: List[Lane],
|
|
951
|
+
lane_sections_raw: Dict[str, str],
|
|
952
|
+
) -> bool:
|
|
953
|
+
"""True if some lane is shaped as a post-dispatch evidence-reducer lane.
|
|
954
|
+
|
|
955
|
+
Detection is signal-based (mirrors how check (J)/(M) detect a docs lane):
|
|
956
|
+
a lane whose name or body names the post-dispatch back-fill of the
|
|
957
|
+
now-known SHA / workflow result.
|
|
958
|
+
"""
|
|
959
|
+
for lane in lanes:
|
|
960
|
+
if _POST_DISPATCH_LANE_RE.search(lane.name):
|
|
961
|
+
return True
|
|
962
|
+
for body in lane_sections_raw.values():
|
|
963
|
+
if _POST_DISPATCH_LANE_RE.search(body):
|
|
964
|
+
return True
|
|
965
|
+
return False
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def _check_n_post_dispatch_reducer(
|
|
969
|
+
src: str,
|
|
970
|
+
lanes: List[Lane],
|
|
971
|
+
lane_sections_raw: Dict[str, str],
|
|
972
|
+
lane_sections_parsed: Dict[str, dict],
|
|
973
|
+
) -> Findings:
|
|
974
|
+
"""issue #18 F4: a release-dispatch phase must include a post-dispatch
|
|
975
|
+
evidence-reducer lane.
|
|
976
|
+
|
|
977
|
+
A release-dispatch phase writes evidence docs that reference a commit SHA /
|
|
978
|
+
workflow result unknowable before the tag is cut; a pre-dispatch reducer
|
|
979
|
+
necessarily leaves a placeholder. The plan must therefore carry a
|
|
980
|
+
post-dispatch lane that re-opens those evidence docs and back-fills the
|
|
981
|
+
now-known values (otherwise F1's placeholder scan blocks the closeout).
|
|
982
|
+
|
|
983
|
+
ERROR for explicit ``release_dispatch`` phases missing the lane (mirrors the
|
|
984
|
+
F2 explicit-release ERROR posture). WARN otherwise — for a non-dispatch
|
|
985
|
+
release shape we record the advisory but never escalate, preserving
|
|
986
|
+
autonomy-first. A non-release plan is inert (no finding)."""
|
|
987
|
+
if _has_post_dispatch_reducer_lane(lanes, lane_sections_raw):
|
|
988
|
+
return []
|
|
989
|
+
if _is_release_dispatch_plan(src):
|
|
990
|
+
return [
|
|
991
|
+
"(N) release-dispatch phase has no post-dispatch evidence-reducer "
|
|
992
|
+
"lane; a dispatch references a commit SHA / workflow result that is "
|
|
993
|
+
"unknowable before the tag is cut, so the plan must include a "
|
|
994
|
+
"terminal lane that re-opens the pre-dispatch evidence docs and "
|
|
995
|
+
"back-fills the now-known SHA / workflow result (otherwise the "
|
|
996
|
+
"docs-freshness placeholder scan blocks the closeout)."
|
|
997
|
+
]
|
|
998
|
+
# A non-dispatch release *shape* gets a WARN; ordinary phases stay inert.
|
|
999
|
+
if _is_release_plan(src, lane_sections_parsed):
|
|
1000
|
+
return [
|
|
1001
|
+
"(N) WARN: release-shaped plan has no post-dispatch evidence-reducer "
|
|
1002
|
+
"lane; if this phase dispatches an external release (tag/workflow), "
|
|
1003
|
+
"add a lane that back-fills the now-known SHA / workflow result into "
|
|
1004
|
+
"the pre-dispatch evidence docs."
|
|
1005
|
+
]
|
|
1006
|
+
return []
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def main(argv: List[str]) -> int:
|
|
1010
|
+
if len(argv) != 2:
|
|
1011
|
+
_fail("usage: validate_plan_doc.py <plan-path>")
|
|
1012
|
+
return 2
|
|
1013
|
+
path = Path(argv[1])
|
|
1014
|
+
if not path.exists():
|
|
1015
|
+
_fail(f"plan doc not found: {path}")
|
|
1016
|
+
return 2
|
|
1017
|
+
|
|
1018
|
+
src = _read(path)
|
|
1019
|
+
|
|
1020
|
+
# Work out if we're inside a git repo for the disjointness expansion.
|
|
1021
|
+
repo_root: Optional[Path] = None
|
|
1022
|
+
try:
|
|
1023
|
+
r = subprocess.run(
|
|
1024
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
1025
|
+
cwd=path.parent,
|
|
1026
|
+
capture_output=True,
|
|
1027
|
+
text=True,
|
|
1028
|
+
check=True,
|
|
1029
|
+
)
|
|
1030
|
+
repo_root = Path(r.stdout.strip())
|
|
1031
|
+
except Exception:
|
|
1032
|
+
repo_root = None
|
|
1033
|
+
|
|
1034
|
+
findings: Findings = []
|
|
1035
|
+
findings.extend(_check_frontmatter(src, path, repo_root))
|
|
1036
|
+
findings.extend(_check_a_required_headings(src))
|
|
1037
|
+
|
|
1038
|
+
lane_index_body = _extract_section(src, "Lane Index & Dependencies")
|
|
1039
|
+
lanes = _parse_lane_index(lane_index_body)
|
|
1040
|
+
findings.extend(_check_b_lane_index_parses(lanes))
|
|
1041
|
+
findings.extend(_check_c_dag_acyclic(lanes))
|
|
1042
|
+
|
|
1043
|
+
lanes_body = _extract_section(src, "Lanes")
|
|
1044
|
+
lane_sections_raw = _split_lane_sections(lanes_body)
|
|
1045
|
+
lane_sections_parsed = {
|
|
1046
|
+
sl_id: _parse_lane_section(body) for sl_id, body in lane_sections_raw.items()
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
# Every lane listed in the Lane Index should also have a ### section.
|
|
1050
|
+
missing_sections = [
|
|
1051
|
+
lane.sl_id for lane in lanes if lane.sl_id not in lane_sections_parsed
|
|
1052
|
+
]
|
|
1053
|
+
for sl_id in missing_sections:
|
|
1054
|
+
findings.append(f"(B) {sl_id}: no matching `### {sl_id}` section under `## Lanes`")
|
|
1055
|
+
|
|
1056
|
+
findings.extend(_check_owned_files_are_concrete(lane_sections_raw))
|
|
1057
|
+
findings.extend(_check_d_owned_files_disjoint(lane_sections_parsed, repo_root))
|
|
1058
|
+
findings.extend(_check_e_test_before_impl(lane_sections_parsed))
|
|
1059
|
+
findings.extend(_check_f_interfaces_trace(lane_sections_parsed, lane_sections_raw))
|
|
1060
|
+
findings.extend(_check_g_grep_paired_with_tests(src))
|
|
1061
|
+
findings.extend(_check_h_eager_reexport(src))
|
|
1062
|
+
findings.extend(_check_i_spec_closeout_plan(src))
|
|
1063
|
+
findings.extend(_check_j_docs_lane(src))
|
|
1064
|
+
findings.extend(_check_k_acceptance_testable(src))
|
|
1065
|
+
findings.extend(_check_l_ui_visual_verification(src))
|
|
1066
|
+
findings.extend(_check_m_release_docs_coverage(src, lanes, lane_sections_parsed))
|
|
1067
|
+
findings.extend(_check_n_post_dispatch_reducer(src, lanes, lane_sections_raw, lane_sections_parsed))
|
|
1068
|
+
|
|
1069
|
+
# Partition findings into errors vs warnings.
|
|
1070
|
+
errors = [f for f in findings if "WARN" not in f]
|
|
1071
|
+
warnings = [f for f in findings if "WARN" in f]
|
|
1072
|
+
|
|
1073
|
+
if warnings:
|
|
1074
|
+
for w in warnings:
|
|
1075
|
+
print(w, file=sys.stderr)
|
|
1076
|
+
if errors:
|
|
1077
|
+
for e in errors:
|
|
1078
|
+
print(e, file=sys.stderr)
|
|
1079
|
+
print(
|
|
1080
|
+
f"validate_plan_doc: {len(errors)} error(s), {len(warnings)} warning(s)",
|
|
1081
|
+
file=sys.stderr,
|
|
1082
|
+
)
|
|
1083
|
+
return 1
|
|
1084
|
+
|
|
1085
|
+
print(
|
|
1086
|
+
f"validate_plan_doc: OK — {len(lanes)} lanes, {len(warnings)} warning(s)"
|
|
1087
|
+
)
|
|
1088
|
+
return 0
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
if __name__ == "__main__":
|
|
1092
|
+
sys.exit(main(sys.argv))
|