@opengsd/gsd-path 1.0.0
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.
- package/AGENTS.md +397 -0
- package/DOCS.md +341 -0
- package/FULL.md +475 -0
- package/GUIDE.md +23 -0
- package/HOOKS.md +273 -0
- package/LICENSE +21 -0
- package/MIGRATE.md +100 -0
- package/QUICK.md +146 -0
- package/README.md +280 -0
- package/RUNTIME.md +229 -0
- package/UPDATE.md +196 -0
- package/WORKFLOW.md +683 -0
- package/package.json +80 -0
- package/platforms/claude/dispatch.md +63 -0
- package/platforms/copilot/dispatch.md +61 -0
- package/platforms/cursor/agent.md +17 -0
- package/platforms/cursor/dispatch.md +63 -0
- package/platforms/grok/dispatch.md +61 -0
- package/platforms/kimi/dispatch.md +64 -0
- package/platforms/kiro/dispatch.md +58 -0
- package/platforms/opencode/dispatch.md +62 -0
- package/platforms/qwen/dispatch.md +60 -0
- package/platforms/shared-agents/dispatch.md +131 -0
- package/plugin.json +14 -0
- package/scripts/_common.py +180 -0
- package/scripts/archive_milestone.py +2265 -0
- package/scripts/bootstrap_repository.py +710 -0
- package/scripts/build_state.py +854 -0
- package/scripts/check_docs_audit.py +463 -0
- package/scripts/check_handoffs.py +1855 -0
- package/scripts/check_task_briefs.py +376 -0
- package/scripts/check_update.py +93 -0
- package/scripts/core_hook_gate.py +34 -0
- package/scripts/core_hook_settings.py +119 -0
- package/scripts/detect_project.py +1517 -0
- package/scripts/discussion_records.py +619 -0
- package/scripts/discussion_validate.py +1321 -0
- package/scripts/dispatch_driver.py +1812 -0
- package/scripts/git_guard.py +759 -0
- package/scripts/guard_hook.py +2124 -0
- package/scripts/install.mjs +3239 -0
- package/scripts/install.py +3008 -0
- package/scripts/integration.py +1433 -0
- package/scripts/isolation.py +3538 -0
- package/scripts/lean_verification.py +256 -0
- package/scripts/loop_run.py +837 -0
- package/scripts/migrate_core.py +169 -0
- package/scripts/pipeline_diagnose.py +715 -0
- package/scripts/pipeline_git.py +940 -0
- package/scripts/pipeline_state.py +2291 -0
- package/scripts/pipeline_undo.py +1039 -0
- package/scripts/promote_lookahead.py +415 -0
- package/scripts/review_findings.py +714 -0
- package/scripts/review_panel.py +553 -0
- package/scripts/skill-resources.json +340 -0
- package/scripts/state_checkpoint.py +1039 -0
- package/scripts/state_promote.py +743 -0
- package/scripts/status_runtime.py +122 -0
- package/scripts/sync_skill_resources.py +259 -0
- package/scripts/token_budget.py +146 -0
- package/scripts/wizard.mjs +179 -0
- package/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/BUILD.md +700 -0
- package/skills/gsd-path/DECIDE.md +115 -0
- package/skills/gsd-path/DEFINE.md +297 -0
- package/skills/gsd-path/DOCS-AUDIT.md +190 -0
- package/skills/gsd-path/INSPECT.md +167 -0
- package/skills/gsd-path/PLAN.md +411 -0
- package/skills/gsd-path/RESEARCH.md +159 -0
- package/skills/gsd-path/ROADMAP.md +215 -0
- package/skills/gsd-path/SHIP.md +412 -0
- package/skills/gsd-path/SKILL.md +471 -0
- package/skills/gsd-path/agents/openai.yaml +6 -0
- package/skills/gsd-path/references/codebase-mapper.md +52 -0
- package/skills/gsd-path/references/coder.md +90 -0
- package/skills/gsd-path/references/decider.md +36 -0
- package/skills/gsd-path/references/dispatch.md +131 -0
- package/skills/gsd-path/references/docs-auditor.md +60 -0
- package/skills/gsd-path/references/planner.md +104 -0
- package/skills/gsd-path/references/researcher.md +42 -0
- package/skills/gsd-path/references/reviewer.md +188 -0
- package/skills/gsd-path/references/roadmapper.md +48 -0
- package/skills/gsd-path/scripts/_common.py +180 -0
- package/skills/gsd-path/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path/scripts/bootstrap_repository.py +710 -0
- package/skills/gsd-path/scripts/build_state.py +854 -0
- package/skills/gsd-path/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path/scripts/check_update.py +93 -0
- package/skills/gsd-path/scripts/detect_project.py +1517 -0
- package/skills/gsd-path/scripts/discussion_records.py +619 -0
- package/skills/gsd-path/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path/scripts/integration.py +1433 -0
- package/skills/gsd-path/scripts/isolation.py +3538 -0
- package/skills/gsd-path/scripts/lean_verification.py +256 -0
- package/skills/gsd-path/scripts/loop_run.py +837 -0
- package/skills/gsd-path/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path/scripts/review_findings.py +714 -0
- package/skills/gsd-path/scripts/review_panel.py +553 -0
- package/skills/gsd-path/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path/scripts/state_promote.py +743 -0
- package/skills/gsd-path/scripts/token_budget.py +146 -0
- package/skills/gsd-path/scripts/workflow_run.py +117 -0
- package/skills/gsd-path/templates/answers.md +29 -0
- package/skills/gsd-path/templates/archive-manifest.md +37 -0
- package/skills/gsd-path/templates/charter.md +50 -0
- package/skills/gsd-path/templates/codebase.md +46 -0
- package/skills/gsd-path/templates/dialogue.md +21 -0
- package/skills/gsd-path/templates/docs-audit.md +69 -0
- package/skills/gsd-path/templates/evidence.md +24 -0
- package/skills/gsd-path/templates/final-review.md +27 -0
- package/skills/gsd-path/templates/gap-review.md +20 -0
- package/skills/gsd-path/templates/intent.md +98 -0
- package/skills/gsd-path/templates/loop.md +59 -0
- package/skills/gsd-path/templates/patch-findings.md +16 -0
- package/skills/gsd-path/templates/plan-panel.md +25 -0
- package/skills/gsd-path/templates/plan.md +89 -0
- package/skills/gsd-path/templates/repository.md +16 -0
- package/skills/gsd-path/templates/research-handoff.md +25 -0
- package/skills/gsd-path/templates/roadmap.md +38 -0
- package/skills/gsd-path/templates/skeptic.md +36 -0
- package/skills/gsd-path/templates/state.md +27 -0
- package/skills/gsd-path/templates/synthesis.md +36 -0
- package/skills/gsd-path/templates/task.md +74 -0
- package/skills/gsd-path/templates/wave-panel.md +25 -0
- package/skills/gsd-path/templates/wave-review.md +64 -0
- package/skills/gsd-path-build/SKILL.md +700 -0
- package/skills/gsd-path-build/agents/openai.yaml +6 -0
- package/skills/gsd-path-build/references/coder.md +90 -0
- package/skills/gsd-path-build/references/dispatch.md +131 -0
- package/skills/gsd-path-build/references/reviewer.md +188 -0
- package/skills/gsd-path-build/scripts/_common.py +180 -0
- package/skills/gsd-path-build/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-build/scripts/build_state.py +854 -0
- package/skills/gsd-path-build/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-build/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-build/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-build/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-build/scripts/dispatch_driver.py +1812 -0
- package/skills/gsd-path-build/scripts/integration.py +1433 -0
- package/skills/gsd-path-build/scripts/isolation.py +3538 -0
- package/skills/gsd-path-build/scripts/loop_run.py +837 -0
- package/skills/gsd-path-build/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-build/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-build/scripts/review_findings.py +714 -0
- package/skills/gsd-path-build/scripts/review_panel.py +553 -0
- package/skills/gsd-path-build/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-build/scripts/state_promote.py +743 -0
- package/skills/gsd-path-build/scripts/token_budget.py +146 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-build/templates/skeptic.md +36 -0
- package/skills/gsd-path-build/templates/task.md +74 -0
- package/skills/gsd-path-build/templates/wave-panel.md +25 -0
- package/skills/gsd-path-build/templates/wave-review.md +64 -0
- package/skills/gsd-path-decide/SKILL.md +115 -0
- package/skills/gsd-path-decide/agents/openai.yaml +6 -0
- package/skills/gsd-path-decide/references/decider.md +36 -0
- package/skills/gsd-path-decide/references/dispatch.md +131 -0
- package/skills/gsd-path-decide/scripts/_common.py +180 -0
- package/skills/gsd-path-decide/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-decide/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-decide/scripts/isolation.py +3538 -0
- package/skills/gsd-path-decide/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-decide/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-decide/scripts/state_promote.py +743 -0
- package/skills/gsd-path-decide/templates/synthesis.md +36 -0
- package/skills/gsd-path-define/SKILL.md +297 -0
- package/skills/gsd-path-define/agents/openai.yaml +6 -0
- package/skills/gsd-path-define/scripts/_common.py +180 -0
- package/skills/gsd-path-define/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-define/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-define/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-define/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-define/scripts/isolation.py +3538 -0
- package/skills/gsd-path-define/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-define/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-define/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-define/scripts/state_promote.py +743 -0
- package/skills/gsd-path-define/templates/charter.md +50 -0
- package/skills/gsd-path-define/templates/intent.md +98 -0
- package/skills/gsd-path-define/templates/state.md +27 -0
- package/skills/gsd-path-discuss/SKILL.md +162 -0
- package/skills/gsd-path-discuss/agents/openai.yaml +6 -0
- package/skills/gsd-path-discuss/scripts/_common.py +180 -0
- package/skills/gsd-path-discuss/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-discuss/templates/answers.md +29 -0
- package/skills/gsd-path-discuss/templates/dialogue.md +21 -0
- package/skills/gsd-path-docs-audit/SKILL.md +190 -0
- package/skills/gsd-path-docs-audit/agents/openai.yaml +6 -0
- package/skills/gsd-path-docs-audit/references/dispatch.md +131 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +60 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +180 -0
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-docs-audit/scripts/isolation.py +3538 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +743 -0
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +69 -0
- package/skills/gsd-path-forensics/SKILL.md +39 -0
- package/skills/gsd-path-forensics/agents/openai.yaml +6 -0
- package/skills/gsd-path-forensics/scripts/_common.py +180 -0
- package/skills/gsd-path-forensics/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +715 -0
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +1039 -0
- package/skills/gsd-path-inspect/SKILL.md +167 -0
- package/skills/gsd-path-inspect/agents/openai.yaml +6 -0
- package/skills/gsd-path-inspect/references/codebase-mapper.md +52 -0
- package/skills/gsd-path-inspect/references/dispatch.md +131 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +60 -0
- package/skills/gsd-path-inspect/scripts/_common.py +180 -0
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +463 -0
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-inspect/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-inspect/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-inspect/scripts/isolation.py +3538 -0
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-inspect/scripts/state_promote.py +743 -0
- package/skills/gsd-path-inspect/templates/codebase.md +46 -0
- package/skills/gsd-path-inspect/templates/docs-audit.md +69 -0
- package/skills/gsd-path-inspect/templates/state.md +27 -0
- package/skills/gsd-path-loop/SKILL.md +98 -0
- package/skills/gsd-path-loop/agents/openai.yaml +6 -0
- package/skills/gsd-path-loop/examples/ci-repair.LOOP.md +61 -0
- package/skills/gsd-path-loop/scripts/_common.py +180 -0
- package/skills/gsd-path-loop/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-loop/scripts/loop_run.py +837 -0
- package/skills/gsd-path-loop/templates/loop.md +59 -0
- package/skills/gsd-path-migrate/SKILL.md +134 -0
- package/skills/gsd-path-migrate/agents/openai.yaml +6 -0
- package/skills/gsd-path-migrate/scripts/core_hook_gate.py +34 -0
- package/skills/gsd-path-migrate/scripts/core_hook_settings.py +119 -0
- package/skills/gsd-path-migrate/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-migrate/scripts/migrate_core.py +169 -0
- package/skills/gsd-path-plan/SKILL.md +411 -0
- package/skills/gsd-path-plan/agents/openai.yaml +6 -0
- package/skills/gsd-path-plan/references/dispatch.md +131 -0
- package/skills/gsd-path-plan/references/planner.md +104 -0
- package/skills/gsd-path-plan/scripts/_common.py +180 -0
- package/skills/gsd-path-plan/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-plan/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-plan/scripts/isolation.py +3538 -0
- package/skills/gsd-path-plan/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-plan/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-plan/scripts/review_panel.py +553 -0
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-plan/scripts/state_promote.py +743 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-plan/templates/patch-findings.md +16 -0
- package/skills/gsd-path-plan/templates/plan-panel.md +25 -0
- package/skills/gsd-path-plan/templates/plan.md +89 -0
- package/skills/gsd-path-plan/templates/task.md +74 -0
- package/skills/gsd-path-research/SKILL.md +159 -0
- package/skills/gsd-path-research/agents/openai.yaml +6 -0
- package/skills/gsd-path-research/references/dispatch.md +131 -0
- package/skills/gsd-path-research/references/researcher.md +42 -0
- package/skills/gsd-path-research/scripts/_common.py +180 -0
- package/skills/gsd-path-research/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-research/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-research/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-research/scripts/isolation.py +3538 -0
- package/skills/gsd-path-research/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-research/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-research/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-research/scripts/state_promote.py +743 -0
- package/skills/gsd-path-research/templates/evidence.md +24 -0
- package/skills/gsd-path-research/templates/research-handoff.md +25 -0
- package/skills/gsd-path-roadmap/SKILL.md +215 -0
- package/skills/gsd-path-roadmap/agents/openai.yaml +6 -0
- package/skills/gsd-path-roadmap/references/dispatch.md +131 -0
- package/skills/gsd-path-roadmap/references/roadmapper.md +48 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +180 -0
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-roadmap/scripts/detect_project.py +1517 -0
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-roadmap/scripts/integration.py +1433 -0
- package/skills/gsd-path-roadmap/scripts/isolation.py +3538 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +415 -0
- package/skills/gsd-path-roadmap/scripts/review_panel.py +553 -0
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-roadmap/scripts/state_promote.py +743 -0
- package/skills/gsd-path-roadmap/templates/roadmap.md +38 -0
- package/skills/gsd-path-ship/SKILL.md +412 -0
- package/skills/gsd-path-ship/agents/openai.yaml +6 -0
- package/skills/gsd-path-ship/references/dispatch.md +131 -0
- package/skills/gsd-path-ship/references/reviewer.md +188 -0
- package/skills/gsd-path-ship/scripts/_common.py +180 -0
- package/skills/gsd-path-ship/scripts/archive_milestone.py +2265 -0
- package/skills/gsd-path-ship/scripts/build_state.py +854 -0
- package/skills/gsd-path-ship/scripts/check_handoffs.py +1855 -0
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +376 -0
- package/skills/gsd-path-ship/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-ship/scripts/discussion_validate.py +1321 -0
- package/skills/gsd-path-ship/scripts/integration.py +1433 -0
- package/skills/gsd-path-ship/scripts/isolation.py +3538 -0
- package/skills/gsd-path-ship/scripts/lean_verification.py +256 -0
- package/skills/gsd-path-ship/scripts/pipeline_git.py +940 -0
- package/skills/gsd-path-ship/scripts/pipeline_state.py +2291 -0
- package/skills/gsd-path-ship/scripts/review_panel.py +553 -0
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +1039 -0
- package/skills/gsd-path-ship/scripts/state_promote.py +743 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +117 -0
- package/skills/gsd-path-ship/templates/archive-manifest.md +37 -0
- package/skills/gsd-path-ship/templates/final-review.md +27 -0
- package/skills/gsd-path-ship/templates/gap-review.md +20 -0
- package/skills/gsd-path-ship/templates/patch-findings.md +16 -0
- package/skills/gsd-path-ship/templates/wave-review.md +64 -0
- package/skills/gsd-path-undo/SKILL.md +45 -0
- package/skills/gsd-path-undo/agents/openai.yaml +6 -0
- package/skills/gsd-path-undo/scripts/_common.py +180 -0
- package/skills/gsd-path-undo/scripts/discussion_records.py +619 -0
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +1039 -0
- package/skills/path/BUILD.md +700 -0
- package/skills/path/DECIDE.md +115 -0
- package/skills/path/DEFINE.md +297 -0
- package/skills/path/DOCS-AUDIT.md +190 -0
- package/skills/path/INSPECT.md +167 -0
- package/skills/path/PLAN.md +411 -0
- package/skills/path/RESEARCH.md +159 -0
- package/skills/path/ROADMAP.md +215 -0
- package/skills/path/SHIP.md +412 -0
- package/skills/path/SKILL.md +471 -0
- package/skills/path/agents/openai.yaml +6 -0
- package/skills/path/references/codebase-mapper.md +52 -0
- package/skills/path/references/coder.md +90 -0
- package/skills/path/references/decider.md +36 -0
- package/skills/path/references/dispatch.md +131 -0
- package/skills/path/references/docs-auditor.md +60 -0
- package/skills/path/references/planner.md +104 -0
- package/skills/path/references/researcher.md +42 -0
- package/skills/path/references/reviewer.md +188 -0
- package/skills/path/references/roadmapper.md +48 -0
- package/skills/path/scripts/_common.py +180 -0
- package/skills/path/scripts/archive_milestone.py +2265 -0
- package/skills/path/scripts/bootstrap_repository.py +710 -0
- package/skills/path/scripts/build_state.py +854 -0
- package/skills/path/scripts/check_docs_audit.py +463 -0
- package/skills/path/scripts/check_handoffs.py +1855 -0
- package/skills/path/scripts/check_task_briefs.py +376 -0
- package/skills/path/scripts/check_update.py +93 -0
- package/skills/path/scripts/detect_project.py +1517 -0
- package/skills/path/scripts/discussion_records.py +619 -0
- package/skills/path/scripts/discussion_validate.py +1321 -0
- package/skills/path/scripts/dispatch_driver.py +1812 -0
- package/skills/path/scripts/integration.py +1433 -0
- package/skills/path/scripts/isolation.py +3538 -0
- package/skills/path/scripts/lean_verification.py +256 -0
- package/skills/path/scripts/loop_run.py +837 -0
- package/skills/path/scripts/pipeline_git.py +940 -0
- package/skills/path/scripts/pipeline_state.py +2291 -0
- package/skills/path/scripts/promote_lookahead.py +415 -0
- package/skills/path/scripts/review_findings.py +714 -0
- package/skills/path/scripts/review_panel.py +553 -0
- package/skills/path/scripts/state_checkpoint.py +1039 -0
- package/skills/path/scripts/state_promote.py +743 -0
- package/skills/path/scripts/token_budget.py +146 -0
- package/skills/path/scripts/workflow_run.py +117 -0
- package/skills/path/templates/answers.md +29 -0
- package/skills/path/templates/archive-manifest.md +37 -0
- package/skills/path/templates/charter.md +50 -0
- package/skills/path/templates/codebase.md +46 -0
- package/skills/path/templates/dialogue.md +21 -0
- package/skills/path/templates/docs-audit.md +69 -0
- package/skills/path/templates/evidence.md +24 -0
- package/skills/path/templates/final-review.md +27 -0
- package/skills/path/templates/gap-review.md +20 -0
- package/skills/path/templates/intent.md +98 -0
- package/skills/path/templates/loop.md +59 -0
- package/skills/path/templates/patch-findings.md +16 -0
- package/skills/path/templates/plan-panel.md +25 -0
- package/skills/path/templates/plan.md +89 -0
- package/skills/path/templates/repository.md +16 -0
- package/skills/path/templates/research-handoff.md +25 -0
- package/skills/path/templates/roadmap.md +38 -0
- package/skills/path/templates/skeptic.md +36 -0
- package/skills/path/templates/state.md +27 -0
- package/skills/path/templates/synthesis.md +36 -0
- package/skills/path/templates/task.md +74 -0
- package/skills/path/templates/wave-panel.md +25 -0
- package/skills/path/templates/wave-review.md +64 -0
|
@@ -0,0 +1,1855 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Validate the durable hand-off packets that bridge GSD Path phases."""
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import re
|
|
8
|
+
import subprocess
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path, PurePosixPath
|
|
11
|
+
from typing import Dict, List, Optional, Sequence, Set, Tuple
|
|
12
|
+
|
|
13
|
+
try:
|
|
14
|
+
from pipeline_state import PipelineStateError, load_state
|
|
15
|
+
import _common
|
|
16
|
+
except ImportError: # pragma: no cover - package imports used by tests
|
|
17
|
+
from scripts.pipeline_state import PipelineStateError, load_state
|
|
18
|
+
from scripts import _common
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
PIPELINE = "gsd-path/v2"
|
|
22
|
+
DEFAULT_PROJECT_DIR = ".project"
|
|
23
|
+
STANDARD_DIMENSIONS = ("domain", "stack", "pitfalls", "similar")
|
|
24
|
+
SHA_PATTERN = re.compile(r"^[0-9a-f]{40}$")
|
|
25
|
+
DISPATCH_PATTERN = re.compile(
|
|
26
|
+
r"^- `(?P<dimension>[^`]+)` — (?P<status>dispatched|skipped) "
|
|
27
|
+
r"→ (?P<output>[^—]+) — (?P<reason>.+)$"
|
|
28
|
+
)
|
|
29
|
+
QUESTION_PATTERN = re.compile(r"^- `\[RESEARCH\] (?P<question>[^`]+)` → `(?P<dimension>[^`]+)`$")
|
|
30
|
+
FINDING_PATTERN = re.compile(r"^### (?P<id>P\d{3}) — (?P<title>.+)$")
|
|
31
|
+
CRITERION_LOCATOR_PATTERN = re.compile(r"^SC[1-9]\d*$")
|
|
32
|
+
COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
33
|
+
NUMBERED_ITEM_PATTERN = re.compile(r"^(\d+)\.\s+(\S.*)$")
|
|
34
|
+
COVERAGE_ROW_PATTERN = re.compile(
|
|
35
|
+
r"^\|\s*(?P<criterion>SC[1-9]\d*)\s*\|\s*(?P<task>T\d{3})\s*"
|
|
36
|
+
r"\|\s*(?P<acceptance>AC[1-9]\d*)\s*\|"
|
|
37
|
+
)
|
|
38
|
+
TASK_ID_PATTERN = re.compile(r"(?m)^id:\s*(T\d{3})\s*(?:#.*)?$")
|
|
39
|
+
TASK_FILE_NAME = re.compile(r"^(?P<id>T\d{3})-[a-z0-9][a-z0-9-]*\.md$")
|
|
40
|
+
OWNED_CRITERION_PATTERN = re.compile(r"^- (None|SC[1-9]\d*)$")
|
|
41
|
+
VERIFY_BLOCK_PATTERN = _common.VERIFY_BLOCK_PATTERN
|
|
42
|
+
FILES_FIELD_PATTERN = re.compile(r"^files:\s*(?P<value>[^#]*?)(?:\s+#.*)?$")
|
|
43
|
+
INLINE_LIST_PATTERN = _common.INLINE_LIST_PATTERN
|
|
44
|
+
LIST_ITEM_PATTERN = _common.LIST_ITEM_PATTERN
|
|
45
|
+
PLACEHOLDER_PATTERN = re.compile(r"(?<!\w)<[a-zA-Z][^<>\n]*>")
|
|
46
|
+
CODE_SPAN_PATTERN = re.compile(r"(?<!`)(`+)(?!`)[\s\S]*?(?<!`)\1(?!`)")
|
|
47
|
+
VERIFY_PATH_SPLIT = re.compile(r"[=,:]")
|
|
48
|
+
WAVE_REVIEW_NAME = re.compile(
|
|
49
|
+
r"wave-(?P<wave>[1-9]\d*)\.cycle(?P<cycle>[1-9]\d*)"
|
|
50
|
+
r"(?:\.(?P<lens>contract|adversarial))?\.md$"
|
|
51
|
+
)
|
|
52
|
+
WAVE_SC_HEADING = re.compile(r"^### (SC[1-9]\d*) — (.+)$")
|
|
53
|
+
WAVE_TASK_HEADING = re.compile(
|
|
54
|
+
r"^## (?P<task>T\d{3}) — (?P<title>.+): (?P<verdict>pass|fail)$"
|
|
55
|
+
)
|
|
56
|
+
WAVE_FIELD_PATTERN = re.compile(r"(?m)^wave:\s*(\d+)\s*(?:#.*)?$")
|
|
57
|
+
PLAN_WAVE_HEADING = re.compile(r"(?m)^## Wave (?P<wave>\d+) — (?P<title>.+)$")
|
|
58
|
+
PLAN_TASK_ROW = re.compile(
|
|
59
|
+
r"^\|\s*(?P<task>T\d{3})\s*\|\s*(?P<title>[^|]+?)\s*\|"
|
|
60
|
+
r"\s*(?P<deps>[^|]+?)\s*\|\s*(?P<files>[^|]+?)\s*\|\s*$"
|
|
61
|
+
)
|
|
62
|
+
MILESTONE_HEADING = re.compile(
|
|
63
|
+
r"(?m)^### (?P<id>M\d{3}) — (?P<slug>\S.*?)\s*$"
|
|
64
|
+
)
|
|
65
|
+
SURFACE_HEADING = re.compile(
|
|
66
|
+
r"(?m)^### (?P<surface>\S.*?) — (?P<task>T\d{3})\s*$"
|
|
67
|
+
)
|
|
68
|
+
SURFACE_CONTRACT_HEADING = re.compile(r"(?m)^## Surface contract\s*$")
|
|
69
|
+
GAP_NAME_PATTERN = re.compile(r"^final-gap-(?P<number>[1-9]\d*)\.md$")
|
|
70
|
+
GAP_HEADING_PATTERN = re.compile(
|
|
71
|
+
r"^# Gap Review — (?P<number>[1-9]\d*): (?P<risk>\S.*)$"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _source_pattern(project_dir: str) -> "re.Pattern[str]":
|
|
76
|
+
return re.compile(
|
|
77
|
+
rf"^{re.escape(project_dir)}/review/(?:FINAL\.md|final-gap-\d+\.md)$"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _project_dir(value: str) -> str:
|
|
82
|
+
"""Argparse type for --project-dir: a relative POSIX-style path."""
|
|
83
|
+
|
|
84
|
+
path = PurePosixPath(value)
|
|
85
|
+
if path.is_absolute():
|
|
86
|
+
raise argparse.ArgumentTypeError(
|
|
87
|
+
f"--project-dir must be a relative path, found absolute: {value}"
|
|
88
|
+
)
|
|
89
|
+
if ".." in path.parts:
|
|
90
|
+
raise argparse.ArgumentTypeError(
|
|
91
|
+
f"--project-dir must not contain '..', found: {value}"
|
|
92
|
+
)
|
|
93
|
+
return str(path)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class HandoffError(RuntimeError):
|
|
97
|
+
"""Raised when a phase hand-off is absent, stale, or incomplete."""
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
_strip_yaml_comment = _common.strip_yaml_comment
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _strip_quotes(value: str) -> str:
|
|
104
|
+
cleaned = value.strip()
|
|
105
|
+
if (
|
|
106
|
+
len(cleaned) >= 2
|
|
107
|
+
and cleaned[0] == cleaned[-1]
|
|
108
|
+
and cleaned[0] in {"'", '"'}
|
|
109
|
+
):
|
|
110
|
+
return cleaned[1:-1]
|
|
111
|
+
return cleaned
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reject_placeholder(value: str, label: str) -> None:
|
|
115
|
+
placeholder = PLACEHOLDER_PATTERN.fullmatch(value.strip().strip("`"))
|
|
116
|
+
if placeholder is None:
|
|
117
|
+
placeholder = PLACEHOLDER_PATTERN.search(CODE_SPAN_PATTERN.sub(" ", value))
|
|
118
|
+
if placeholder is not None:
|
|
119
|
+
raise HandoffError(
|
|
120
|
+
f"{label} still contains the placeholder {placeholder.group(0)}; "
|
|
121
|
+
"replace it with the real value"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _strict_frontmatter(text: str, label: str) -> Dict[str, object]:
|
|
126
|
+
lines = text.splitlines()
|
|
127
|
+
if not lines or lines[0] != "---":
|
|
128
|
+
raise HandoffError(f"{label} is missing YAML frontmatter")
|
|
129
|
+
values: Dict[str, object] = {}
|
|
130
|
+
index = 1
|
|
131
|
+
while index < len(lines):
|
|
132
|
+
line = lines[index]
|
|
133
|
+
if line == "---":
|
|
134
|
+
return values
|
|
135
|
+
if not line.strip() or line.lstrip().startswith("#"):
|
|
136
|
+
index += 1
|
|
137
|
+
continue
|
|
138
|
+
match = re.fullmatch(r"([a-z_]+):\s*(.*)", line)
|
|
139
|
+
if match is None:
|
|
140
|
+
raise HandoffError(f"{label} has malformed frontmatter: {line}")
|
|
141
|
+
key, raw = match.groups()
|
|
142
|
+
if key in values:
|
|
143
|
+
raise HandoffError(f"{label} repeats frontmatter field: {key}")
|
|
144
|
+
raw = _strip_yaml_comment(raw)
|
|
145
|
+
inline = INLINE_LIST_PATTERN.fullmatch(raw)
|
|
146
|
+
if inline is not None:
|
|
147
|
+
values[key] = [
|
|
148
|
+
_strip_quotes(item)
|
|
149
|
+
for item in inline.group("body").split(",")
|
|
150
|
+
if item.strip()
|
|
151
|
+
]
|
|
152
|
+
index += 1
|
|
153
|
+
continue
|
|
154
|
+
if raw:
|
|
155
|
+
values[key] = _strip_quotes(raw)
|
|
156
|
+
index += 1
|
|
157
|
+
continue
|
|
158
|
+
items: List[str] = []
|
|
159
|
+
index += 1
|
|
160
|
+
while index < len(lines):
|
|
161
|
+
item = LIST_ITEM_PATTERN.fullmatch(lines[index])
|
|
162
|
+
if item is None:
|
|
163
|
+
break
|
|
164
|
+
items.append(_strip_quotes(_strip_yaml_comment(item.group("value"))))
|
|
165
|
+
index += 1
|
|
166
|
+
values[key] = items
|
|
167
|
+
raise HandoffError(f"{label} frontmatter is not closed")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _read(root: Path, relative: str) -> str:
|
|
171
|
+
path = root / relative
|
|
172
|
+
if not path.is_file() or path.is_symlink():
|
|
173
|
+
raise HandoffError(f"missing real hand-off file: {relative}")
|
|
174
|
+
return path.read_text(encoding="utf-8")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _require_pipeline(root: Path, project_dir: str) -> Dict[str, str]:
|
|
178
|
+
try:
|
|
179
|
+
state, _text, _path = load_state(root.resolve(), project_dir)
|
|
180
|
+
except PipelineStateError as error:
|
|
181
|
+
raise HandoffError(str(error)) from error
|
|
182
|
+
return {
|
|
183
|
+
key: "null" if value is None else value
|
|
184
|
+
for key, value in state.json().items()
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _require_state(root: Path, phase: str, status: str, project_dir: str) -> None:
|
|
189
|
+
values = _require_pipeline(root, project_dir)
|
|
190
|
+
if values.get("phase") != phase or values.get("status") != status:
|
|
191
|
+
raise HandoffError(
|
|
192
|
+
f"STATE.md must be {phase}/{status}, found "
|
|
193
|
+
f"{values.get('phase', '<missing>')}/{values.get('status', '<missing>')}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _require_state_one_of(
|
|
198
|
+
root: Path,
|
|
199
|
+
expected: Set[Tuple[str, str]],
|
|
200
|
+
project_dir: str,
|
|
201
|
+
) -> None:
|
|
202
|
+
values = _require_pipeline(root, project_dir)
|
|
203
|
+
actual = (values["phase"], values["status"])
|
|
204
|
+
if actual not in expected:
|
|
205
|
+
rendered = ", ".join(f"{phase}/{status}" for phase, status in sorted(expected))
|
|
206
|
+
raise HandoffError(
|
|
207
|
+
f"STATE.md must be one of {rendered}, found {actual[0]}/{actual[1]}"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _line_value(text: str, label: str) -> str:
|
|
212
|
+
match = re.search(rf"(?m)^{re.escape(label)}\s*(.+)$", text)
|
|
213
|
+
if not match:
|
|
214
|
+
raise HandoffError(f"missing {label.strip()}")
|
|
215
|
+
# Bundled templates keep an inline HTML comment after some values
|
|
216
|
+
# (`Lane: quick <!-- ... -->`); the comment is not part of the value.
|
|
217
|
+
value = match.group(1).split("<!--", 1)[0].strip().strip("`")
|
|
218
|
+
if not value or value.startswith("<") or value.endswith(">"):
|
|
219
|
+
raise HandoffError(f"{label.strip()} is empty or still a placeholder")
|
|
220
|
+
return value
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _section(text: str, heading: str) -> str:
|
|
224
|
+
body = _common.section_body(text, heading)
|
|
225
|
+
if body is None:
|
|
226
|
+
raise HandoffError(f"missing ## {heading} section")
|
|
227
|
+
return body
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _non_placeholder(value: str, label: str) -> str:
|
|
231
|
+
text = value.strip()
|
|
232
|
+
cleaned = text.strip("`")
|
|
233
|
+
if not cleaned or cleaned.casefold() in {"none", "n/a", "null"}:
|
|
234
|
+
raise HandoffError(f"{label} is empty")
|
|
235
|
+
# Scan the text with its code spans intact. Stripping a backtick from a value
|
|
236
|
+
# that merely begins or ends with a code span unbalances every span after it,
|
|
237
|
+
# which would make quoted code look unquoted.
|
|
238
|
+
_reject_placeholder(text, label)
|
|
239
|
+
return cleaned
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _unquoted(value: str) -> str:
|
|
243
|
+
cleaned = value.strip()
|
|
244
|
+
while (
|
|
245
|
+
len(cleaned) >= 2
|
|
246
|
+
and cleaned[0] == cleaned[-1]
|
|
247
|
+
and cleaned[0] in "`\"'"
|
|
248
|
+
):
|
|
249
|
+
cleaned = cleaned[1:-1].strip()
|
|
250
|
+
return cleaned
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _surface_value(value: str, label: str) -> str:
|
|
254
|
+
cleaned = _unquoted(value)
|
|
255
|
+
if not cleaned or cleaned.casefold() in {"none", "n/a", "null"}:
|
|
256
|
+
raise HandoffError(f"{label} is empty")
|
|
257
|
+
if re.fullmatch(r"<[^<>]*>", cleaned):
|
|
258
|
+
raise HandoffError(f"{label} is still a placeholder")
|
|
259
|
+
return cleaned
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _evidence_field(text: str, field: str, relative: str) -> str:
|
|
263
|
+
matches = re.findall(
|
|
264
|
+
rf"(?m)^- \*\*{re.escape(field)}\*\*:\s*(.*)$", text
|
|
265
|
+
)
|
|
266
|
+
if len(matches) != 1:
|
|
267
|
+
raise HandoffError(
|
|
268
|
+
f"{relative} finding must contain exactly one {field} field"
|
|
269
|
+
)
|
|
270
|
+
return _non_placeholder(matches[0], f"{relative} finding {field}")
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _validate_evidence(
|
|
274
|
+
root: Path,
|
|
275
|
+
relative: str,
|
|
276
|
+
dimension: str,
|
|
277
|
+
assigned_questions: Sequence[str],
|
|
278
|
+
) -> None:
|
|
279
|
+
text = _read(root, relative)
|
|
280
|
+
if len(re.findall(rf"(?m)^# Evidence — {re.escape(dimension)}\s*$", text)) != 1:
|
|
281
|
+
raise HandoffError(f"{relative} must name evidence dimension {dimension}")
|
|
282
|
+
dimension_values = re.findall(r"(?m)^Dimension:\s*(.*)$", text)
|
|
283
|
+
if len(dimension_values) != 1 or _non_placeholder(
|
|
284
|
+
dimension_values[0], f"{relative} Dimension"
|
|
285
|
+
) != dimension:
|
|
286
|
+
raise HandoffError(f"{relative} Dimension must be {dimension}")
|
|
287
|
+
question_values = re.findall(r"(?m)^Questions assigned:\s*(.*)$", text)
|
|
288
|
+
expected_questions = "; ".join(assigned_questions) if assigned_questions else "none"
|
|
289
|
+
if len(question_values) != 1 or question_values[0].strip() != expected_questions:
|
|
290
|
+
raise HandoffError(
|
|
291
|
+
f"{relative} Questions assigned must be {expected_questions!r}"
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
headings = list(re.finditer(r"(?m)^## (?P<title>.+?)\s*$", text))
|
|
295
|
+
findings = []
|
|
296
|
+
for index, heading in enumerate(headings):
|
|
297
|
+
title = heading.group("title")
|
|
298
|
+
if not title.startswith("Finding"):
|
|
299
|
+
continue
|
|
300
|
+
if not title.startswith("Finding: "):
|
|
301
|
+
raise HandoffError(f"{relative} has a malformed Finding heading")
|
|
302
|
+
_non_placeholder(title[len("Finding: ") :], f"{relative} finding heading")
|
|
303
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
|
|
304
|
+
findings.append(text[heading.end() : end])
|
|
305
|
+
if not findings:
|
|
306
|
+
raise HandoffError(f"{relative} has no findings")
|
|
307
|
+
for finding in findings:
|
|
308
|
+
_evidence_field(finding, "Claim", relative)
|
|
309
|
+
_evidence_field(finding, "Source", relative)
|
|
310
|
+
confidence = _evidence_field(finding, "Confidence", relative)
|
|
311
|
+
if confidence not in {"high", "medium", "low"}:
|
|
312
|
+
raise HandoffError(f"{relative} finding has invalid Confidence: {confidence}")
|
|
313
|
+
_evidence_field(finding, "Why it matters here", relative)
|
|
314
|
+
|
|
315
|
+
answer_headings = [
|
|
316
|
+
(index, heading)
|
|
317
|
+
for index, heading in enumerate(headings)
|
|
318
|
+
if heading.group("title") == "Assigned questions — answers"
|
|
319
|
+
]
|
|
320
|
+
if len(answer_headings) != 1:
|
|
321
|
+
raise HandoffError(
|
|
322
|
+
f"{relative} must contain one ## Assigned questions — answers section"
|
|
323
|
+
)
|
|
324
|
+
index, heading = answer_headings[0]
|
|
325
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(text)
|
|
326
|
+
answer_lines = [line.strip() for line in text[heading.end() : end].splitlines() if line.strip()]
|
|
327
|
+
if not assigned_questions:
|
|
328
|
+
if answer_lines != ["- none"]:
|
|
329
|
+
raise HandoffError(f"{relative} must record no assigned-question answers")
|
|
330
|
+
return
|
|
331
|
+
answers = []
|
|
332
|
+
for line in answer_lines:
|
|
333
|
+
match = re.fullmatch(r"- (?P<question>.+?) → (?P<answer>.+)", line)
|
|
334
|
+
if match is None:
|
|
335
|
+
raise HandoffError(f"{relative} has a malformed assigned-question answer")
|
|
336
|
+
question = match.group("question").strip()
|
|
337
|
+
answer = _non_placeholder(
|
|
338
|
+
match.group("answer"), f"{relative} answer for {question}"
|
|
339
|
+
)
|
|
340
|
+
answers.append((question, answer))
|
|
341
|
+
if [question for question, _answer in answers] != list(assigned_questions):
|
|
342
|
+
raise HandoffError(
|
|
343
|
+
f"{relative} assigned-question answers do not match RESEARCH.md"
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _research_questions(intent: str) -> List[str]:
|
|
348
|
+
return [
|
|
349
|
+
match.group(1).strip()
|
|
350
|
+
for match in re.finditer(r"(?m)^\s*- \[RESEARCH\] (.+?)\s*$", intent)
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _research_assignments(section: str) -> List[Tuple[str, str]]:
|
|
355
|
+
assignments = []
|
|
356
|
+
lines = [line.strip() for line in section.splitlines() if line.strip()]
|
|
357
|
+
if lines == ["- none"]:
|
|
358
|
+
return assignments
|
|
359
|
+
for line in lines:
|
|
360
|
+
if line == "- none":
|
|
361
|
+
raise HandoffError("RESEARCH.md mixes none with question assignments")
|
|
362
|
+
match = QUESTION_PATTERN.fullmatch(line)
|
|
363
|
+
if match is None:
|
|
364
|
+
raise HandoffError("RESEARCH.md has a malformed question assignment")
|
|
365
|
+
assignments.append((match.group("question").strip(), match.group("dimension")))
|
|
366
|
+
return assignments
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def validate_research(
|
|
370
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
371
|
+
) -> Dict[str, object]:
|
|
372
|
+
"""Validate the research-to-synthesis hand-off and return its summary."""
|
|
373
|
+
|
|
374
|
+
_require_state(root, "research", "active", project_dir)
|
|
375
|
+
return validate_research_artifacts(root, project_dir)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def validate_research_artifacts(
|
|
379
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
380
|
+
) -> Dict[str, object]:
|
|
381
|
+
"""Check research contents independently of the caller's state edge."""
|
|
382
|
+
intent_path = f"{project_dir}/intent/INTENT.md"
|
|
383
|
+
if not (root / intent_path).is_file():
|
|
384
|
+
# CHARTER.md is program-level: it stays at the real .project/ top level.
|
|
385
|
+
intent_path = f"{DEFAULT_PROJECT_DIR}/CHARTER.md"
|
|
386
|
+
intent = _read(root, intent_path)
|
|
387
|
+
handoff = _read(root, f"{project_dir}/research/RESEARCH.md")
|
|
388
|
+
if _line_value(handoff, "Phase:") != "research":
|
|
389
|
+
raise HandoffError("RESEARCH.md names the wrong phase")
|
|
390
|
+
if _line_value(handoff, "Status:") != "complete":
|
|
391
|
+
raise HandoffError("RESEARCH.md is not complete")
|
|
392
|
+
if _line_value(handoff, "Intent:") != intent_path:
|
|
393
|
+
raise HandoffError(f"RESEARCH.md must point to {intent_path}")
|
|
394
|
+
|
|
395
|
+
dispatch_rows = []
|
|
396
|
+
for line in _section(handoff, "Dispatch").splitlines():
|
|
397
|
+
match = DISPATCH_PATTERN.fullmatch(line.strip())
|
|
398
|
+
if match:
|
|
399
|
+
dispatch_rows.append(
|
|
400
|
+
(
|
|
401
|
+
match.group("dimension"),
|
|
402
|
+
match.group("status"),
|
|
403
|
+
match.group("output").strip().strip("`"),
|
|
404
|
+
_non_placeholder(match.group("reason"), "research reason"),
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
if not dispatch_rows:
|
|
408
|
+
raise HandoffError("RESEARCH.md has no dispatch rows")
|
|
409
|
+
|
|
410
|
+
dimensions = [row[0] for row in dispatch_rows]
|
|
411
|
+
if len(dimensions) != len(set(dimensions)):
|
|
412
|
+
raise HandoffError("RESEARCH.md repeats a dimension")
|
|
413
|
+
missing = sorted(set(STANDARD_DIMENSIONS) - set(dimensions))
|
|
414
|
+
if missing:
|
|
415
|
+
raise HandoffError("RESEARCH.md omits dimensions: " + ", ".join(missing))
|
|
416
|
+
|
|
417
|
+
dispatched: List[str] = []
|
|
418
|
+
skipped: List[str] = []
|
|
419
|
+
evidence_outputs: Dict[str, str] = {}
|
|
420
|
+
for dimension, status, output, _reason in dispatch_rows:
|
|
421
|
+
if status == "dispatched":
|
|
422
|
+
expected = f"{project_dir}/research/evidence-{dimension}.md"
|
|
423
|
+
if output != expected:
|
|
424
|
+
raise HandoffError(
|
|
425
|
+
f"{dimension} output must be {expected}, found {output}"
|
|
426
|
+
)
|
|
427
|
+
evidence_outputs[dimension] = output
|
|
428
|
+
dispatched.append(dimension)
|
|
429
|
+
else:
|
|
430
|
+
if output.casefold() != "none":
|
|
431
|
+
raise HandoffError(f"skipped {dimension} must not name an output")
|
|
432
|
+
skipped.append(dimension)
|
|
433
|
+
|
|
434
|
+
assignments = _research_assignments(_section(handoff, "Question assignments"))
|
|
435
|
+
questions = _research_questions(intent)
|
|
436
|
+
assigned_questions = [question for question, _dimension in assignments]
|
|
437
|
+
if sorted(assigned_questions) != sorted(questions):
|
|
438
|
+
raise HandoffError("RESEARCH.md question assignments do not match the intent source")
|
|
439
|
+
if len(assigned_questions) != len(set(assigned_questions)):
|
|
440
|
+
raise HandoffError("RESEARCH.md repeats a question assignment")
|
|
441
|
+
dispatched_set = set(dispatched)
|
|
442
|
+
if any(dimension not in dispatched_set for _question, dimension in assignments):
|
|
443
|
+
raise HandoffError("every assigned question must target a dispatched dimension")
|
|
444
|
+
for dimension in dispatched:
|
|
445
|
+
dimension_questions = [
|
|
446
|
+
question
|
|
447
|
+
for question, assigned_dimension in assignments
|
|
448
|
+
if assigned_dimension == dimension
|
|
449
|
+
]
|
|
450
|
+
if not dimension_questions:
|
|
451
|
+
raise HandoffError(f"dispatched {dimension} has no assigned research question")
|
|
452
|
+
_validate_evidence(
|
|
453
|
+
root,
|
|
454
|
+
evidence_outputs[dimension],
|
|
455
|
+
dimension,
|
|
456
|
+
dimension_questions,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
return {
|
|
460
|
+
"phase": "research",
|
|
461
|
+
"dispatched": sorted(dispatched),
|
|
462
|
+
"skipped": sorted(skipped),
|
|
463
|
+
"questions": len(questions),
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _finding_blocks(text: str) -> List[Tuple[str, str]]:
|
|
468
|
+
# Match any `### P` heading so a malformed finding id becomes its own
|
|
469
|
+
# block and fails FINDING_PATTERN instead of vanishing into the
|
|
470
|
+
# previous block unvalidated.
|
|
471
|
+
matches = list(re.finditer(r"(?m)^### P.*$", text))
|
|
472
|
+
return [
|
|
473
|
+
(match.group(0), text[match.end() : next_match.start() if next_match else len(text)])
|
|
474
|
+
for match, next_match in zip(matches, matches[1:] + [None])
|
|
475
|
+
]
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _field(block: str, field: str) -> str:
|
|
479
|
+
match = re.search(rf"(?m)^- \*\*{re.escape(field)}\*\*:\s*(.+)$", block)
|
|
480
|
+
if not match:
|
|
481
|
+
raise HandoffError(f"patch finding is missing {field}")
|
|
482
|
+
return _non_placeholder(match.group(1), f"patch finding {field}")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _raw_source_field(block: str, field: str, source: str) -> str:
|
|
486
|
+
matches = re.findall(rf"(?m)^- \*\*{re.escape(field)}\*\*:\s*(.*)$", block)
|
|
487
|
+
if not matches:
|
|
488
|
+
raise HandoffError(f"{source} is missing {field}")
|
|
489
|
+
if len(matches) != 1:
|
|
490
|
+
raise HandoffError(f"{source} repeats {field}")
|
|
491
|
+
return matches[0]
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def _source_field(block: str, field: str, source: str) -> str:
|
|
495
|
+
value = _raw_source_field(block, field, source).strip()
|
|
496
|
+
if not value.strip("`"):
|
|
497
|
+
raise HandoffError(f"{source} has an empty {field}; fill it in")
|
|
498
|
+
_reject_placeholder(value, f"{source} {field}")
|
|
499
|
+
return value.strip("`")
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _heading_block(text: str, heading: re.Match[str]) -> str:
|
|
503
|
+
body = text[heading.end() :]
|
|
504
|
+
next_heading = re.search(r"(?m)^#{2,3} ", body)
|
|
505
|
+
return body[: next_heading.start() if next_heading else len(body)]
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _source_repair_fields(source: str, text: str, locator: str) -> Tuple[str, str]:
|
|
509
|
+
if source.endswith("FINAL.md"):
|
|
510
|
+
if not CRITERION_LOCATOR_PATTERN.fullmatch(locator):
|
|
511
|
+
raise HandoffError(f"{source} locator must name an SC criterion")
|
|
512
|
+
heading = re.search(rf"(?m)^### {re.escape(locator)} — .+$", text)
|
|
513
|
+
if not heading:
|
|
514
|
+
raise HandoffError(f"{source} does not contain locator {locator}")
|
|
515
|
+
block = _heading_block(text, heading)
|
|
516
|
+
verdict = _source_field(block, "Verdict", source)
|
|
517
|
+
if verdict not in {"not-met", "unverifiable"}:
|
|
518
|
+
raise HandoffError(f"{source} locator {locator} is not a failed criterion")
|
|
519
|
+
evidence = _source_field(block, "Finding", source)
|
|
520
|
+
fix_direction = _source_field(block, "Fix direction", source)
|
|
521
|
+
else:
|
|
522
|
+
if not locator.startswith("Risk: ") or locator not in text.splitlines():
|
|
523
|
+
raise HandoffError(f"{source} does not contain locator {locator}")
|
|
524
|
+
block = _section(text, "Finding")
|
|
525
|
+
evidence = _source_field(block, "Found", source)
|
|
526
|
+
fix_direction = _source_field(block, "Fix direction", source)
|
|
527
|
+
|
|
528
|
+
if evidence.casefold() == "none" or fix_direction.casefold() == "none":
|
|
529
|
+
raise HandoffError(f"{source} locator {locator} has no repair evidence")
|
|
530
|
+
return evidence, fix_direction
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def _reviewed_head(text: str, source: str) -> str:
|
|
534
|
+
if len(re.findall(r"(?m)^Reviewed HEAD:", text)) > 1:
|
|
535
|
+
raise HandoffError(f"{source} repeats Reviewed HEAD")
|
|
536
|
+
value = _line_value(text, "Reviewed HEAD:")
|
|
537
|
+
if not SHA_PATTERN.fullmatch(value):
|
|
538
|
+
raise HandoffError(
|
|
539
|
+
f"{source} Reviewed HEAD must be the full 40-character commit SHA "
|
|
540
|
+
f"from git rev-parse HEAD, not {value or 'empty'}"
|
|
541
|
+
)
|
|
542
|
+
return value
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _strip_comments(text: str) -> str:
|
|
546
|
+
return COMMENT_PATTERN.sub("", text)
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _numbered_items(section: str, *, continuations: bool = False) -> Dict[int, str]:
|
|
550
|
+
items: Dict[int, str] = {}
|
|
551
|
+
current = None
|
|
552
|
+
for line in _strip_comments(section).splitlines():
|
|
553
|
+
match = NUMBERED_ITEM_PATTERN.fullmatch(line.strip())
|
|
554
|
+
if not match:
|
|
555
|
+
if continuations and current is not None and line[:1].isspace() and line.strip():
|
|
556
|
+
items[current] += " " + line.strip()
|
|
557
|
+
elif line.strip():
|
|
558
|
+
current = None
|
|
559
|
+
continue
|
|
560
|
+
number = int(match.group(1))
|
|
561
|
+
if number in items:
|
|
562
|
+
raise HandoffError(f"repeated numbered item {number}")
|
|
563
|
+
items[number] = match.group(2).strip()
|
|
564
|
+
current = number
|
|
565
|
+
return items
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def _success_criteria(intent: str) -> Dict[str, str]:
|
|
569
|
+
items = _numbered_items(_section(intent, "Success criteria"), continuations=True)
|
|
570
|
+
if not items:
|
|
571
|
+
raise HandoffError("INTENT.md has no success criteria")
|
|
572
|
+
expected = list(range(1, max(items) + 1))
|
|
573
|
+
if sorted(items) != expected:
|
|
574
|
+
raise HandoffError("INTENT.md success criteria must be contiguous from 1")
|
|
575
|
+
return {f"SC{number}": text for number, text in items.items()}
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _surfaces(text: str, label: str) -> List[str]:
|
|
579
|
+
"""Human-facing surfaces one milestone delivers; empty when `none`."""
|
|
580
|
+
|
|
581
|
+
matches = re.findall(
|
|
582
|
+
r"(?m)^Surfaces:\s*([^#]*?)(?:\s+#.*)?$", _strip_comments(text)
|
|
583
|
+
)
|
|
584
|
+
if not matches:
|
|
585
|
+
raise HandoffError(f"{label} is missing Surfaces")
|
|
586
|
+
if len(matches) != 1:
|
|
587
|
+
raise HandoffError(f"{label} repeats Surfaces")
|
|
588
|
+
value = _unquoted(matches[0])
|
|
589
|
+
if value.casefold() == "none":
|
|
590
|
+
return []
|
|
591
|
+
_non_placeholder(value, f"{label} Surfaces")
|
|
592
|
+
named: List[str] = []
|
|
593
|
+
seen = set()
|
|
594
|
+
for item in value.split(","):
|
|
595
|
+
if not item.strip():
|
|
596
|
+
continue
|
|
597
|
+
surface = _unquoted(item)
|
|
598
|
+
if surface.casefold() == "none":
|
|
599
|
+
raise HandoffError(f"{label} Surfaces includes reserved value {surface}")
|
|
600
|
+
_non_placeholder(surface, f"{label} Surfaces")
|
|
601
|
+
key = surface.casefold()
|
|
602
|
+
if key in seen:
|
|
603
|
+
raise HandoffError(f"{label} Surfaces repeats {surface}")
|
|
604
|
+
seen.add(key)
|
|
605
|
+
named.append(surface)
|
|
606
|
+
if not named:
|
|
607
|
+
raise HandoffError(f"{label} Surfaces names no surface and is not none")
|
|
608
|
+
return named
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _surface_keys(surfaces: Sequence[str]) -> List[str]:
|
|
612
|
+
return sorted(surface.casefold() for surface in surfaces)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _roadmap_surfaces(root: Path, milestone: str) -> Optional[List[str]]:
|
|
616
|
+
"""Surfaces the program roadmap declares for one milestone, when it has an entry."""
|
|
617
|
+
|
|
618
|
+
# The program roadmap always lives at `.project/`; a lookahead track reads
|
|
619
|
+
# program inputs from there, not from its own root.
|
|
620
|
+
relative = f"{DEFAULT_PROJECT_DIR}/ROADMAP.md"
|
|
621
|
+
if not (root / relative).is_file():
|
|
622
|
+
return None
|
|
623
|
+
matches = [
|
|
624
|
+
block
|
|
625
|
+
for _entry, slug, block, _raw in _milestone_blocks(_read(root, relative))
|
|
626
|
+
if slug == milestone
|
|
627
|
+
]
|
|
628
|
+
if not matches:
|
|
629
|
+
raise HandoffError(f"ROADMAP.md is missing milestone slug {milestone}")
|
|
630
|
+
if len(matches) > 1:
|
|
631
|
+
raise HandoffError(f"ROADMAP.md repeats milestone slug {milestone}")
|
|
632
|
+
return _surfaces(matches[0], f"ROADMAP.md {milestone}")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _criterion_ids(value: str, label: str) -> List[str]:
|
|
636
|
+
ids = [item.strip().strip("`") for item in value.split(",") if item.strip()]
|
|
637
|
+
if not ids or any(not CRITERION_LOCATOR_PATTERN.fullmatch(item) for item in ids):
|
|
638
|
+
raise HandoffError(f"{label} Criteria must name success criteria: {value}")
|
|
639
|
+
if len(ids) != len(set(ids)):
|
|
640
|
+
raise HandoffError(f"{label} Criteria repeats an id")
|
|
641
|
+
return ids
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _surface_contract(
|
|
645
|
+
plan: str, surfaces: Sequence[str]
|
|
646
|
+
) -> Dict[str, Tuple[str, List[str]]]:
|
|
647
|
+
"""Map each declared surface to the task that delivers it and its criteria."""
|
|
648
|
+
|
|
649
|
+
if len(SURFACE_CONTRACT_HEADING.findall(plan)) > 1:
|
|
650
|
+
raise HandoffError("PLAN.md repeats ## Surface contract")
|
|
651
|
+
body = _strip_comments(_section(plan, "Surface contract"))
|
|
652
|
+
headings = list(SURFACE_HEADING.finditer(body))
|
|
653
|
+
if not surfaces:
|
|
654
|
+
names = ", ".join(heading.group("surface") for heading in headings)
|
|
655
|
+
if names:
|
|
656
|
+
raise HandoffError(
|
|
657
|
+
f"Surface contract names {names}, but INTENT.md declares no surfaces"
|
|
658
|
+
)
|
|
659
|
+
raise HandoffError(
|
|
660
|
+
"PLAN.md has ## Surface contract but INTENT.md declares no surfaces"
|
|
661
|
+
)
|
|
662
|
+
blocks: Dict[str, Tuple[str, str]] = {}
|
|
663
|
+
for heading in headings:
|
|
664
|
+
heading_surface = heading.group("surface")
|
|
665
|
+
key = heading_surface.casefold()
|
|
666
|
+
if key in blocks:
|
|
667
|
+
raise HandoffError(
|
|
668
|
+
f"Surface contract repeats the {heading_surface} surface"
|
|
669
|
+
)
|
|
670
|
+
blocks[key] = (
|
|
671
|
+
heading.group("task"),
|
|
672
|
+
_heading_block(body, heading),
|
|
673
|
+
)
|
|
674
|
+
owners: Dict[str, Tuple[str, List[str]]] = {}
|
|
675
|
+
surface_by_criterion: Dict[str, str] = {}
|
|
676
|
+
for surface in surfaces:
|
|
677
|
+
key = surface.casefold()
|
|
678
|
+
if key not in blocks:
|
|
679
|
+
raise HandoffError(f"Surface contract is missing the {surface} surface")
|
|
680
|
+
task_id, block = blocks.pop(key)
|
|
681
|
+
label = f"{surface} surface"
|
|
682
|
+
for field in ("Entry", "States"):
|
|
683
|
+
# Read the whole line: `#` is legal inside a route or a command.
|
|
684
|
+
values = re.findall(rf"(?m)^{re.escape(field)}:\s*(.*)$", block)
|
|
685
|
+
if len(values) > 1:
|
|
686
|
+
raise HandoffError(f"{label} repeats {field}")
|
|
687
|
+
if not values:
|
|
688
|
+
raise HandoffError(f"missing {field}:")
|
|
689
|
+
_surface_value(values[0], f"{label} {field}")
|
|
690
|
+
walkthrough = _numbered_items(
|
|
691
|
+
_roadmap_segment(block, "Walkthrough:", None, label)
|
|
692
|
+
)
|
|
693
|
+
if not walkthrough:
|
|
694
|
+
raise HandoffError(f"{label} Walkthrough has no steps")
|
|
695
|
+
for number, step in walkthrough.items():
|
|
696
|
+
_surface_value(step, f"{label} Walkthrough step {number}")
|
|
697
|
+
if len(re.findall(r"(?m)^Criteria:", block)) > 1:
|
|
698
|
+
raise HandoffError(f"{label} repeats Criteria")
|
|
699
|
+
criteria = _criterion_ids(_roadmap_field(block, "Criteria", label), label)
|
|
700
|
+
for criterion in criteria:
|
|
701
|
+
previous_surface = surface_by_criterion.get(criterion)
|
|
702
|
+
if previous_surface is not None:
|
|
703
|
+
raise HandoffError(
|
|
704
|
+
f"Surface contract {criterion} is shared by surfaces "
|
|
705
|
+
f"{previous_surface} and {surface}"
|
|
706
|
+
)
|
|
707
|
+
surface_by_criterion[criterion] = surface
|
|
708
|
+
owners[surface] = (task_id, criteria)
|
|
709
|
+
if blocks:
|
|
710
|
+
raise HandoffError(
|
|
711
|
+
"Surface contract names undeclared surfaces: "
|
|
712
|
+
+ ", ".join(sorted(blocks))
|
|
713
|
+
)
|
|
714
|
+
return owners
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def _coverage_rows(plan: str) -> List[Tuple[str, str, str]]:
|
|
718
|
+
body = _section(plan, "Intent coverage")
|
|
719
|
+
rows: List[Tuple[str, str, str]] = []
|
|
720
|
+
seen = set()
|
|
721
|
+
for line in _strip_comments(body).splitlines():
|
|
722
|
+
match = COVERAGE_ROW_PATTERN.match(line.strip())
|
|
723
|
+
if not match:
|
|
724
|
+
continue
|
|
725
|
+
row = (
|
|
726
|
+
match.group("criterion"),
|
|
727
|
+
match.group("task"),
|
|
728
|
+
match.group("acceptance"),
|
|
729
|
+
)
|
|
730
|
+
if row in seen:
|
|
731
|
+
raise HandoffError(
|
|
732
|
+
f"Intent coverage repeats {row[0]} {row[1]} {row[2]}"
|
|
733
|
+
)
|
|
734
|
+
seen.add(row)
|
|
735
|
+
rows.append(row)
|
|
736
|
+
if not rows:
|
|
737
|
+
raise HandoffError("PLAN.md Intent coverage has no rows")
|
|
738
|
+
return rows
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _task_texts(root: Path, project_dir: str) -> Dict[str, str]:
|
|
742
|
+
tasks_dir = root / project_dir / "tasks"
|
|
743
|
+
if not tasks_dir.is_dir():
|
|
744
|
+
raise HandoffError(f"missing {project_dir}/tasks")
|
|
745
|
+
tasks: Dict[str, str] = {}
|
|
746
|
+
for path in sorted(tasks_dir.iterdir()):
|
|
747
|
+
named = TASK_FILE_NAME.fullmatch(path.name)
|
|
748
|
+
if named is None or not path.is_file() or path.is_symlink():
|
|
749
|
+
raise HandoffError(f"non-canonical task artifact: {path.name}")
|
|
750
|
+
text = path.read_text(encoding="utf-8")
|
|
751
|
+
task_id = _strict_frontmatter(text, path.name).get("id")
|
|
752
|
+
if not isinstance(task_id, str) or not re.fullmatch(r"T\d{3}", task_id):
|
|
753
|
+
raise HandoffError(f"{path.name} is missing an id")
|
|
754
|
+
if named.group("id") != task_id:
|
|
755
|
+
raise HandoffError(f"{path.name} does not match task id {task_id}")
|
|
756
|
+
if task_id in tasks:
|
|
757
|
+
raise HandoffError(f"duplicate task id {task_id}")
|
|
758
|
+
tasks[task_id] = text
|
|
759
|
+
if not tasks:
|
|
760
|
+
raise HandoffError(f"no task files in {project_dir}/tasks")
|
|
761
|
+
return tasks
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _owned_criteria(task_text: str, task_id: str) -> List[str]:
|
|
765
|
+
body = _section(task_text, "Intent coverage")
|
|
766
|
+
owned: List[str] = []
|
|
767
|
+
saw_none = False
|
|
768
|
+
for line in _strip_comments(body).splitlines():
|
|
769
|
+
stripped = line.strip()
|
|
770
|
+
if not stripped:
|
|
771
|
+
continue
|
|
772
|
+
match = OWNED_CRITERION_PATTERN.fullmatch(stripped)
|
|
773
|
+
if not match:
|
|
774
|
+
raise HandoffError(f"{task_id} Intent coverage has an invalid line")
|
|
775
|
+
value = match.group(1)
|
|
776
|
+
if value == "None":
|
|
777
|
+
saw_none = True
|
|
778
|
+
continue
|
|
779
|
+
owned.append(value)
|
|
780
|
+
if saw_none and owned:
|
|
781
|
+
raise HandoffError(f"{task_id} Intent coverage mixes None with SCs")
|
|
782
|
+
if saw_none:
|
|
783
|
+
return []
|
|
784
|
+
if not owned:
|
|
785
|
+
raise HandoffError(f"{task_id} Intent coverage is empty")
|
|
786
|
+
if len(owned) != len(set(owned)):
|
|
787
|
+
raise HandoffError(f"{task_id} repeats an owned criterion")
|
|
788
|
+
return owned
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def _acceptance_ids(task_text: str, task_id: str) -> Set[str]:
|
|
792
|
+
items = _numbered_items(_section(task_text, "Acceptance criteria"))
|
|
793
|
+
if not items:
|
|
794
|
+
raise HandoffError(f"{task_id} has no acceptance criteria")
|
|
795
|
+
return {f"AC{number}" for number in items}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _has_verify(task_text: str) -> bool:
|
|
799
|
+
body = _section(task_text, "Verify")
|
|
800
|
+
block = VERIFY_BLOCK_PATTERN.search(body)
|
|
801
|
+
return bool(block and block.group("block").strip())
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _normalize_ws(value: str) -> str:
|
|
805
|
+
return " ".join(value.split())
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
def task_review_observation(item: str, task_text: str) -> str:
|
|
809
|
+
"""Separate an exact quoted acceptance criterion from its observed evidence."""
|
|
810
|
+
item = _normalize_ws(item)
|
|
811
|
+
section = _common.section_body(task_text, "Acceptance criteria")
|
|
812
|
+
if section is not None:
|
|
813
|
+
for criterion in _numbered_items(section, continuations=True).values():
|
|
814
|
+
quoted = _normalize_ws(criterion)
|
|
815
|
+
prefix = quoted + " — "
|
|
816
|
+
if item.startswith(prefix):
|
|
817
|
+
return item[len(prefix) :]
|
|
818
|
+
if item in {quoted, quoted + " —"}:
|
|
819
|
+
return ""
|
|
820
|
+
return item
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
def _verify_command(task_text: str) -> str:
|
|
824
|
+
body = _section(task_text, "Verify")
|
|
825
|
+
block = VERIFY_BLOCK_PATTERN.search(body)
|
|
826
|
+
if block is None:
|
|
827
|
+
return ""
|
|
828
|
+
return _normalize_ws(block.group("block"))
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _frontmatter_files(task_text: str) -> List[str]:
|
|
832
|
+
values = _strict_frontmatter(task_text, "task")
|
|
833
|
+
raw = values.get("files")
|
|
834
|
+
if raw is None:
|
|
835
|
+
raise HandoffError("task is missing files")
|
|
836
|
+
files = raw if isinstance(raw, list) else [raw]
|
|
837
|
+
return [_canonical_repo_path(str(path), "task files") for path in files]
|
|
838
|
+
|
|
839
|
+
|
|
840
|
+
def _posix_path(value: str) -> str:
|
|
841
|
+
stripped = value.strip().strip("/")
|
|
842
|
+
if not stripped:
|
|
843
|
+
return ""
|
|
844
|
+
return str(PurePosixPath(stripped))
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _verify_mentions_a_file(command: str, files: Sequence[str]) -> bool:
|
|
848
|
+
tokens: List[str] = []
|
|
849
|
+
for raw in command.split():
|
|
850
|
+
for piece in VERIFY_PATH_SPLIT.split(raw.strip("\"'")):
|
|
851
|
+
normalized = _posix_path(piece)
|
|
852
|
+
if normalized and normalized != ".":
|
|
853
|
+
tokens.append(normalized)
|
|
854
|
+
for path in files:
|
|
855
|
+
needle = _posix_path(path)
|
|
856
|
+
if not needle or needle == ".":
|
|
857
|
+
continue
|
|
858
|
+
for token in tokens:
|
|
859
|
+
if token == needle or token.startswith(needle + "/"):
|
|
860
|
+
return True
|
|
861
|
+
return False
|
|
862
|
+
|
|
863
|
+
|
|
864
|
+
def _acceptance_items(task_text: str, task_id: str) -> Dict[int, str]:
|
|
865
|
+
items = _numbered_items(_section(task_text, "Acceptance criteria"))
|
|
866
|
+
if not items:
|
|
867
|
+
raise HandoffError(f"{task_id} has no acceptance criteria")
|
|
868
|
+
return items
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
def _task_wave(task_text: str, task_id: str) -> int:
|
|
872
|
+
value = _task_scalar(task_text, task_id, "wave")
|
|
873
|
+
if not value.isdigit() or int(value) < 1:
|
|
874
|
+
raise HandoffError(f"{task_id} is missing a wave")
|
|
875
|
+
return int(value)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _task_scalar(task_text: str, task_id: str, field: str) -> str:
|
|
879
|
+
value = _strict_frontmatter(task_text, task_id).get(field)
|
|
880
|
+
if value is None:
|
|
881
|
+
raise HandoffError(f"{task_id} is missing {field}")
|
|
882
|
+
if not isinstance(value, str):
|
|
883
|
+
raise HandoffError(f"{task_id} {field} must be a scalar")
|
|
884
|
+
return value
|
|
885
|
+
|
|
886
|
+
|
|
887
|
+
def _inline_ids(value: str, prefix: str, label: str) -> List[str]:
|
|
888
|
+
cleaned = value.strip().strip("`")
|
|
889
|
+
if cleaned in {"", "—", "-", "[]"} or cleaned.casefold() == "none":
|
|
890
|
+
return []
|
|
891
|
+
inline = INLINE_LIST_PATTERN.fullmatch(cleaned)
|
|
892
|
+
if inline:
|
|
893
|
+
cleaned = inline.group("body")
|
|
894
|
+
values = [item.strip().strip("`\"'") for item in cleaned.split(",")]
|
|
895
|
+
pattern = re.compile(rf"^{re.escape(prefix)}\d{{3}}$")
|
|
896
|
+
if not values or any(not pattern.fullmatch(item) for item in values):
|
|
897
|
+
raise HandoffError(f"{label} has an invalid id list: {value}")
|
|
898
|
+
if len(values) != len(set(values)):
|
|
899
|
+
raise HandoffError(f"{label} repeats an id")
|
|
900
|
+
return values
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
def _task_deps(task_text: str, task_id: str) -> List[str]:
|
|
904
|
+
value = _strict_frontmatter(task_text, task_id).get("deps")
|
|
905
|
+
if value is None:
|
|
906
|
+
raise HandoffError(f"{task_id} is missing deps")
|
|
907
|
+
if isinstance(value, list):
|
|
908
|
+
rendered = ",".join(str(item) for item in value)
|
|
909
|
+
else:
|
|
910
|
+
rendered = str(value)
|
|
911
|
+
return _inline_ids(rendered, "T", f"{task_id} deps")
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
def _canonical_repo_path(value: str, label: str) -> str:
|
|
915
|
+
cleaned = value.strip().strip("`\"'")
|
|
916
|
+
path = PurePosixPath(cleaned)
|
|
917
|
+
if (
|
|
918
|
+
not cleaned
|
|
919
|
+
or cleaned == "."
|
|
920
|
+
or "\\" in cleaned
|
|
921
|
+
or path.is_absolute()
|
|
922
|
+
or ".." in path.parts
|
|
923
|
+
or path.as_posix() != cleaned
|
|
924
|
+
):
|
|
925
|
+
raise HandoffError(f"{label} has an unsafe or non-canonical path: {value}")
|
|
926
|
+
return cleaned
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def _plan_files(value: str, task_id: str) -> List[str]:
|
|
930
|
+
cleaned = value.strip().strip("`")
|
|
931
|
+
files = [
|
|
932
|
+
item.strip().strip("`\"'")
|
|
933
|
+
for item in re.split(r"\s*(?:,|<br\s*/?>)\s*", cleaned)
|
|
934
|
+
if item.strip()
|
|
935
|
+
]
|
|
936
|
+
if not files or any("<" in item or ">" in item for item in files):
|
|
937
|
+
raise HandoffError(f"PLAN.md {task_id} has an invalid Files cell")
|
|
938
|
+
files = [
|
|
939
|
+
_canonical_repo_path(item, f"PLAN.md {task_id} Files") for item in files
|
|
940
|
+
]
|
|
941
|
+
if len(files) != len(set(files)):
|
|
942
|
+
raise HandoffError(f"PLAN.md {task_id} repeats a file")
|
|
943
|
+
return files
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _plan_waves(plan: str) -> Tuple[Dict[int, str], List[int]]:
|
|
947
|
+
headings = list(PLAN_WAVE_HEADING.finditer(plan))
|
|
948
|
+
if not headings:
|
|
949
|
+
raise HandoffError("PLAN.md has no waves")
|
|
950
|
+
waves = [int(heading.group("wave")) for heading in headings]
|
|
951
|
+
if waves != list(range(1, len(waves) + 1)):
|
|
952
|
+
raise HandoffError("PLAN.md wave numbers must be unique, ordered, and contiguous")
|
|
953
|
+
|
|
954
|
+
depths: Dict[int, str] = {}
|
|
955
|
+
for index, heading in enumerate(headings):
|
|
956
|
+
wave = int(heading.group("wave"))
|
|
957
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(plan)
|
|
958
|
+
block = plan[heading.end() : end]
|
|
959
|
+
goal = re.search(r"(?m)^Goal:\s*(.+)$", block)
|
|
960
|
+
depth = re.search(r"(?m)^Review depth:\s*(\S+)", block)
|
|
961
|
+
if not goal:
|
|
962
|
+
raise HandoffError(f"PLAN.md Wave {wave} is missing Goal")
|
|
963
|
+
_non_placeholder(goal.group(1), f"PLAN.md Wave {wave} Goal")
|
|
964
|
+
if not depth or depth.group(1) not in {"full", "deep", "verify-only"}:
|
|
965
|
+
raise HandoffError(f"PLAN.md Wave {wave} has an invalid Review depth")
|
|
966
|
+
if wave == 1 and depth.group(1) == "verify-only":
|
|
967
|
+
raise HandoffError("PLAN.md Wave 1 Review depth must be full or deep")
|
|
968
|
+
depths[wave] = depth.group(1)
|
|
969
|
+
return depths, waves
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def _require_task_structure(task_id: str, text: str, *, initial: bool) -> None:
|
|
973
|
+
expected = {
|
|
974
|
+
"status": "pending",
|
|
975
|
+
"agent": "null",
|
|
976
|
+
"base": "null",
|
|
977
|
+
"worktree": "null",
|
|
978
|
+
"task_branch": "null",
|
|
979
|
+
}
|
|
980
|
+
for field, value in expected.items():
|
|
981
|
+
actual = _task_scalar(text, task_id, field)
|
|
982
|
+
if initial and actual != value:
|
|
983
|
+
raise HandoffError(f"{task_id} {field} must initially be {value}")
|
|
984
|
+
for heading in ("Context", "Approach", "Interface contract", "Log"):
|
|
985
|
+
body = _strip_comments(_section(text, heading)).strip()
|
|
986
|
+
if not body:
|
|
987
|
+
raise HandoffError(f"{task_id} {heading} is empty or still a placeholder")
|
|
988
|
+
# Log is append-only narrative; editing landed tasks breaks their landing proof.
|
|
989
|
+
# Key formats in that narrative may legitimately contain <name> notation.
|
|
990
|
+
if heading != "Log":
|
|
991
|
+
_reject_placeholder(body, f"{task_id} {heading}")
|
|
992
|
+
|
|
993
|
+
|
|
994
|
+
def _validate_task_graph(
|
|
995
|
+
wave_depths: Dict[int, str], tasks: Dict[str, str], *, initial: bool
|
|
996
|
+
) -> Dict[str, Set[str]]:
|
|
997
|
+
task_ids = list(tasks)
|
|
998
|
+
expected = [f"T{number:03d}" for number in range(1, len(task_ids) + 1)]
|
|
999
|
+
if task_ids != expected:
|
|
1000
|
+
raise HandoffError("task ids must be unique, ordered, and contiguous")
|
|
1001
|
+
|
|
1002
|
+
graph: Dict[str, List[str]] = {}
|
|
1003
|
+
task_waves: Dict[str, int] = {}
|
|
1004
|
+
task_files: Dict[str, List[str]] = {}
|
|
1005
|
+
for task_id, text in tasks.items():
|
|
1006
|
+
# Patch mode reopens planning beside landed tasks; only new tasks start clean.
|
|
1007
|
+
landed = _task_scalar(text, task_id, "status") == "done"
|
|
1008
|
+
_require_task_structure(task_id, text, initial=initial and not landed)
|
|
1009
|
+
wave = _task_wave(text, task_id)
|
|
1010
|
+
deps = _task_deps(text, task_id)
|
|
1011
|
+
files = _frontmatter_files(text)
|
|
1012
|
+
if wave not in wave_depths:
|
|
1013
|
+
raise HandoffError(f"{task_id} names unknown Wave {wave}")
|
|
1014
|
+
graph[task_id] = deps
|
|
1015
|
+
task_waves[task_id] = wave
|
|
1016
|
+
task_files[task_id] = files
|
|
1017
|
+
for dependency in deps:
|
|
1018
|
+
if dependency not in tasks:
|
|
1019
|
+
raise HandoffError(f"{task_id} names unknown dependency {dependency}")
|
|
1020
|
+
if _task_wave(tasks[dependency], dependency) > wave:
|
|
1021
|
+
raise HandoffError(f"{task_id} depends on later-wave {dependency}")
|
|
1022
|
+
|
|
1023
|
+
for index, left in enumerate(task_ids):
|
|
1024
|
+
for right in task_ids[index + 1 :]:
|
|
1025
|
+
if task_waves[left] != task_waves[right]:
|
|
1026
|
+
continue
|
|
1027
|
+
overlap = sorted(set(task_files[left]) & set(task_files[right]))
|
|
1028
|
+
if overlap:
|
|
1029
|
+
raise HandoffError(
|
|
1030
|
+
f"same-wave file overlap between {left} and {right}: {', '.join(overlap)}"
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
visiting: Set[str] = set()
|
|
1034
|
+
visited: Set[str] = set()
|
|
1035
|
+
dependency_files: Dict[str, Set[str]] = {}
|
|
1036
|
+
|
|
1037
|
+
def visit(task_id: str) -> None:
|
|
1038
|
+
if task_id in visiting:
|
|
1039
|
+
raise HandoffError(f"dependency cycle includes {task_id}")
|
|
1040
|
+
if task_id in visited:
|
|
1041
|
+
return
|
|
1042
|
+
visiting.add(task_id)
|
|
1043
|
+
dependency_files[task_id] = set()
|
|
1044
|
+
for dependency in graph[task_id]:
|
|
1045
|
+
visit(dependency)
|
|
1046
|
+
dependency_files[task_id].update(task_files[dependency])
|
|
1047
|
+
dependency_files[task_id].update(dependency_files[dependency])
|
|
1048
|
+
visiting.remove(task_id)
|
|
1049
|
+
visited.add(task_id)
|
|
1050
|
+
|
|
1051
|
+
for task_id in graph:
|
|
1052
|
+
visit(task_id)
|
|
1053
|
+
return dependency_files
|
|
1054
|
+
|
|
1055
|
+
|
|
1056
|
+
def plan_brief_inputs(
|
|
1057
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1058
|
+
) -> Tuple[Dict[str, str], Dict[str, Set[str]]]:
|
|
1059
|
+
tasks = _task_texts(root, project_dir)
|
|
1060
|
+
wave_depths, _ = _plan_waves(_read(root, f"{project_dir}/plan/PLAN.md"))
|
|
1061
|
+
return tasks, _validate_task_graph(wave_depths, tasks, initial=True)
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _intent_path(project_dir: str) -> str:
|
|
1065
|
+
return f"{project_dir}/intent/INTENT.md"
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _owned_by_wave(
|
|
1069
|
+
tasks: Dict[str, str], assigned: Dict[str, Set[str]], wave: int
|
|
1070
|
+
) -> List[str]:
|
|
1071
|
+
owned: Set[str] = set()
|
|
1072
|
+
for task_id, text in tasks.items():
|
|
1073
|
+
if _task_wave(text, task_id) == wave:
|
|
1074
|
+
owned.update(assigned[task_id])
|
|
1075
|
+
return sorted(owned, key=lambda name: int(name[2:]))
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
def _named_field(block: str, field: str, label: str) -> str:
|
|
1079
|
+
match = re.search(rf"(?m)^- \*\*{re.escape(field)}\*\*:\s*(.+)$", block)
|
|
1080
|
+
if not match:
|
|
1081
|
+
raise HandoffError(f"{label} is missing {field}")
|
|
1082
|
+
return _non_placeholder(match.group(1), f"{label} {field}")
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def validate_decide(
|
|
1086
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1087
|
+
) -> Dict[str, object]:
|
|
1088
|
+
"""Validate the structural synthesis contract without judging decisions."""
|
|
1089
|
+
|
|
1090
|
+
_require_state(root, "decide", "active", project_dir)
|
|
1091
|
+
return validate_decide_artifacts(root, project_dir)
|
|
1092
|
+
|
|
1093
|
+
|
|
1094
|
+
def validate_decide_artifacts(
|
|
1095
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1096
|
+
) -> Dict[str, object]:
|
|
1097
|
+
"""Check synthesis contents independently of the caller's state edge."""
|
|
1098
|
+
program_scope = (
|
|
1099
|
+
project_dir == DEFAULT_PROJECT_DIR
|
|
1100
|
+
and (root / project_dir / "CHARTER.md").is_file()
|
|
1101
|
+
and not (root / project_dir / "ROADMAP.md").exists()
|
|
1102
|
+
)
|
|
1103
|
+
relative = (
|
|
1104
|
+
f"{project_dir}/SYNTHESIS.md"
|
|
1105
|
+
if program_scope
|
|
1106
|
+
else f"{project_dir}/research/SYNTHESIS.md"
|
|
1107
|
+
)
|
|
1108
|
+
text = _read(root, relative)
|
|
1109
|
+
for heading in ("Settled", "Decisions", "For the planner", "User rulings", "Still unknown"):
|
|
1110
|
+
_section(text, heading)
|
|
1111
|
+
|
|
1112
|
+
decision_section = _strip_comments(_section(text, "Decisions"))
|
|
1113
|
+
headings = list(re.finditer(r"(?m)^### (.+?)\s*$", decision_section))
|
|
1114
|
+
decisions: List[str] = []
|
|
1115
|
+
if not headings and not re.search(r"(?m)^- None\s*$", decision_section):
|
|
1116
|
+
raise HandoffError("SYNTHESIS.md Decisions must contain decision blocks or - None")
|
|
1117
|
+
for index, heading in enumerate(headings):
|
|
1118
|
+
title = _non_placeholder(heading.group(1), "SYNTHESIS.md decision heading")
|
|
1119
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(decision_section)
|
|
1120
|
+
block = decision_section[heading.end() : end]
|
|
1121
|
+
label = f"SYNTHESIS.md decision {title}"
|
|
1122
|
+
_named_field(block, "Decision", label)
|
|
1123
|
+
_named_field(block, "Runner-up", label)
|
|
1124
|
+
_named_field(block, "Evidence", label)
|
|
1125
|
+
confidence = _named_field(block, "Confidence", label).split("—", 1)[0].strip()
|
|
1126
|
+
if confidence not in {"high", "medium", "low"}:
|
|
1127
|
+
raise HandoffError(f"{label} Confidence is invalid")
|
|
1128
|
+
decisions.append(title)
|
|
1129
|
+
|
|
1130
|
+
planner = _section(text, "For the planner")
|
|
1131
|
+
for field in ("Wave-1 blockers", "Walking skeleton", "Pitfalls → tasks"):
|
|
1132
|
+
_named_field(planner, field, "SYNTHESIS.md For the planner")
|
|
1133
|
+
if re.search(r"<[^>\n]+>", _strip_comments(text)):
|
|
1134
|
+
raise HandoffError("SYNTHESIS.md still contains a placeholder")
|
|
1135
|
+
return {"phase": "decide", "synthesis": relative, "decisions": decisions}
|
|
1136
|
+
|
|
1137
|
+
|
|
1138
|
+
def _roadmap_field(
|
|
1139
|
+
block: str,
|
|
1140
|
+
field: str,
|
|
1141
|
+
milestone: str,
|
|
1142
|
+
*,
|
|
1143
|
+
allow_null: bool = False,
|
|
1144
|
+
allow_none: bool = False,
|
|
1145
|
+
) -> str:
|
|
1146
|
+
match = re.search(rf"(?m)^{re.escape(field)}:\s*([^#]*?)(?:\s+#.*)?$", block)
|
|
1147
|
+
if not match:
|
|
1148
|
+
raise HandoffError(f"{milestone} is missing {field}")
|
|
1149
|
+
value = match.group(1).strip().strip("`\"'")
|
|
1150
|
+
if allow_null and value == "null":
|
|
1151
|
+
return value
|
|
1152
|
+
if allow_none and value.casefold() == "none":
|
|
1153
|
+
return value
|
|
1154
|
+
return _non_placeholder(value, f"{milestone} {field}")
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _roadmap_segment(block: str, start: str, end: Optional[str], milestone: str) -> str:
|
|
1158
|
+
terminator = rf"(?=^{re.escape(end)}\s*$)" if end else r"\Z"
|
|
1159
|
+
match = re.search(
|
|
1160
|
+
rf"(?ms)^{re.escape(start)}\s*$\n(?P<body>.*?){terminator}", block
|
|
1161
|
+
)
|
|
1162
|
+
if not match:
|
|
1163
|
+
raise HandoffError(f"{milestone} is missing {start}")
|
|
1164
|
+
return match.group("body")
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _roadmap_bullets(body: str, label: str, *, allow_none: bool) -> List[str]:
|
|
1168
|
+
values = [
|
|
1169
|
+
line.strip()[2:].strip()
|
|
1170
|
+
for line in body.splitlines()
|
|
1171
|
+
if line.strip().startswith("- ")
|
|
1172
|
+
]
|
|
1173
|
+
if not values:
|
|
1174
|
+
raise HandoffError(f"{label} has no entries")
|
|
1175
|
+
for value in values:
|
|
1176
|
+
if "<" in value or ">" in value or (not allow_none and value.casefold() == "none"):
|
|
1177
|
+
raise HandoffError(f"{label} has an empty or placeholder entry")
|
|
1178
|
+
return values
|
|
1179
|
+
|
|
1180
|
+
|
|
1181
|
+
def _milestone_blocks(text: str) -> List[Tuple[str, str, str, str]]:
|
|
1182
|
+
section = _section(text, "Milestones")
|
|
1183
|
+
searchable = COMMENT_PATTERN.sub(
|
|
1184
|
+
lambda match: re.sub(r"[^\n]", " ", match.group(0)),
|
|
1185
|
+
section,
|
|
1186
|
+
)
|
|
1187
|
+
headings = list(MILESTONE_HEADING.finditer(searchable))
|
|
1188
|
+
if not headings:
|
|
1189
|
+
raise HandoffError("ROADMAP.md has no milestones")
|
|
1190
|
+
blocks = []
|
|
1191
|
+
for index, heading in enumerate(headings):
|
|
1192
|
+
end = headings[index + 1].start() if index + 1 < len(headings) else len(section)
|
|
1193
|
+
blocks.append(
|
|
1194
|
+
(
|
|
1195
|
+
heading.group("id"),
|
|
1196
|
+
_non_placeholder(heading.group("slug"), f"{heading.group('id')} slug"),
|
|
1197
|
+
section[heading.end() : end],
|
|
1198
|
+
section[heading.start() : end],
|
|
1199
|
+
)
|
|
1200
|
+
)
|
|
1201
|
+
return blocks
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def _head_file(root: Path, relative: str) -> Optional[str]:
|
|
1205
|
+
result = subprocess.run(
|
|
1206
|
+
["git", "show", f"HEAD:{relative}"],
|
|
1207
|
+
cwd=root,
|
|
1208
|
+
capture_output=True,
|
|
1209
|
+
text=True,
|
|
1210
|
+
check=False,
|
|
1211
|
+
)
|
|
1212
|
+
return result.stdout if result.returncode == 0 else None
|
|
1213
|
+
|
|
1214
|
+
|
|
1215
|
+
def _without_roadmap_mutable_fields(block: str) -> str:
|
|
1216
|
+
return re.sub(r"(?m)^(?:Status|Archive|Integrated):.*\n?", "", block).strip()
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def validate_roadmap(
|
|
1220
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1221
|
+
) -> Dict[str, object]:
|
|
1222
|
+
"""Validate roadmap structure and dependency ordering."""
|
|
1223
|
+
|
|
1224
|
+
_require_state(root, "roadmap", "active", project_dir)
|
|
1225
|
+
relative = f"{project_dir}/ROADMAP.md"
|
|
1226
|
+
text = _read(root, relative)
|
|
1227
|
+
if re.search(r"(?m)^## Wave |^\|\s*Task\s*\||^Files:\s*", text):
|
|
1228
|
+
raise HandoffError("ROADMAP.md must not contain waves, tasks, or file lists")
|
|
1229
|
+
if re.search(r"<[^>\n]+>", _strip_comments(text)):
|
|
1230
|
+
raise HandoffError("ROADMAP.md still contains a placeholder")
|
|
1231
|
+
|
|
1232
|
+
blocks = _milestone_blocks(text)
|
|
1233
|
+
ids = [milestone for milestone, _, _, _ in blocks]
|
|
1234
|
+
expected = [f"M{number:03d}" for number in range(1, len(ids) + 1)]
|
|
1235
|
+
if ids != expected:
|
|
1236
|
+
raise HandoffError("ROADMAP.md milestone ids must be unique, ordered, and contiguous")
|
|
1237
|
+
|
|
1238
|
+
dependencies: Dict[str, List[str]] = {}
|
|
1239
|
+
raw_by_id: Dict[str, str] = {}
|
|
1240
|
+
for index, (milestone, _slug, block, raw) in enumerate(blocks):
|
|
1241
|
+
raw_by_id[milestone] = raw
|
|
1242
|
+
_roadmap_field(block, "Goal", milestone)
|
|
1243
|
+
deps = _inline_ids(
|
|
1244
|
+
_roadmap_field(block, "Depends on", milestone),
|
|
1245
|
+
"M",
|
|
1246
|
+
f"{milestone} Depends on",
|
|
1247
|
+
)
|
|
1248
|
+
earlier = set(ids[:index])
|
|
1249
|
+
invalid = [dependency for dependency in deps if dependency not in earlier]
|
|
1250
|
+
if invalid:
|
|
1251
|
+
raise HandoffError(
|
|
1252
|
+
f"{milestone} dependencies must name earlier milestones: {', '.join(invalid)}"
|
|
1253
|
+
)
|
|
1254
|
+
dependencies[milestone] = deps
|
|
1255
|
+
status = _roadmap_field(block, "Status", milestone)
|
|
1256
|
+
if status not in {"pending", "active", "shipped", "abandoned"}:
|
|
1257
|
+
raise HandoffError(f"{milestone} Status is invalid")
|
|
1258
|
+
if status in {"pending", "active"}:
|
|
1259
|
+
_surfaces(block, milestone)
|
|
1260
|
+
_roadmap_field(block, "Archive", milestone, allow_null=True)
|
|
1261
|
+
_roadmap_field(block, "Integrated", milestone, allow_null=True)
|
|
1262
|
+
_roadmap_bullets(
|
|
1263
|
+
_roadmap_segment(block, "Scope: in", "Scope: out", milestone),
|
|
1264
|
+
f"{milestone} Scope: in",
|
|
1265
|
+
allow_none=False,
|
|
1266
|
+
)
|
|
1267
|
+
_roadmap_bullets(
|
|
1268
|
+
_roadmap_segment(block, "Scope: out", "Success criteria", milestone),
|
|
1269
|
+
f"{milestone} Scope: out",
|
|
1270
|
+
allow_none=True,
|
|
1271
|
+
)
|
|
1272
|
+
criteria = _numbered_items(
|
|
1273
|
+
_roadmap_segment(block, "Success criteria", "Risks", milestone)
|
|
1274
|
+
)
|
|
1275
|
+
if not criteria or sorted(criteria) != list(range(1, len(criteria) + 1)):
|
|
1276
|
+
raise HandoffError(f"{milestone} Success criteria must be contiguous from 1")
|
|
1277
|
+
_roadmap_bullets(
|
|
1278
|
+
_roadmap_segment(block, "Risks", "Open questions", milestone),
|
|
1279
|
+
f"{milestone} Risks",
|
|
1280
|
+
allow_none=False,
|
|
1281
|
+
)
|
|
1282
|
+
_roadmap_bullets(
|
|
1283
|
+
_roadmap_segment(block, "Open questions", None, milestone),
|
|
1284
|
+
f"{milestone} Open questions",
|
|
1285
|
+
allow_none=True,
|
|
1286
|
+
)
|
|
1287
|
+
|
|
1288
|
+
previous = _head_file(root, relative)
|
|
1289
|
+
if previous and previous != text:
|
|
1290
|
+
previous_blocks = {
|
|
1291
|
+
milestone: raw
|
|
1292
|
+
for milestone, _slug, _block, raw in _milestone_blocks(previous)
|
|
1293
|
+
}
|
|
1294
|
+
for milestone, old_block in previous_blocks.items():
|
|
1295
|
+
status = _roadmap_field(old_block, "Status", milestone)
|
|
1296
|
+
if milestone not in raw_by_id:
|
|
1297
|
+
raise HandoffError(f"ROADMAP.md removed existing {milestone}")
|
|
1298
|
+
new_block = raw_by_id[milestone]
|
|
1299
|
+
if status == "abandoned" and new_block != old_block:
|
|
1300
|
+
raise HandoffError(f"ROADMAP.md changed abandoned {milestone}")
|
|
1301
|
+
shipped_changed = _without_roadmap_mutable_fields(
|
|
1302
|
+
new_block
|
|
1303
|
+
) != _without_roadmap_mutable_fields(old_block)
|
|
1304
|
+
if status == "shipped" and shipped_changed:
|
|
1305
|
+
raise HandoffError(f"ROADMAP.md changed immutable content in shipped {milestone}")
|
|
1306
|
+
|
|
1307
|
+
return {"phase": "roadmap", "milestones": ids, "dependencies": dependencies}
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def validate_plan(
|
|
1311
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1312
|
+
) -> Dict[str, object]:
|
|
1313
|
+
"""Validate INTENT success criteria against PLAN.md coverage and tasks."""
|
|
1314
|
+
|
|
1315
|
+
_require_state_one_of(
|
|
1316
|
+
root,
|
|
1317
|
+
{("plan", "active"), ("build", "active")},
|
|
1318
|
+
project_dir,
|
|
1319
|
+
)
|
|
1320
|
+
intent = _read(root, _intent_path(project_dir))
|
|
1321
|
+
plan = _read(root, f"{project_dir}/plan/PLAN.md")
|
|
1322
|
+
criteria = _success_criteria(intent)
|
|
1323
|
+
rows = _coverage_rows(plan)
|
|
1324
|
+
tasks = _task_texts(root, project_dir)
|
|
1325
|
+
wave_depths, waves = _plan_waves(plan)
|
|
1326
|
+
# Build readiness owns task lifecycle validation; coverage must survive landings.
|
|
1327
|
+
state = _require_pipeline(root, project_dir)
|
|
1328
|
+
_validate_task_graph(wave_depths, tasks, initial=state["phase"] == "plan")
|
|
1329
|
+
assigned: Dict[str, Set[str]] = {task_id: set() for task_id in tasks}
|
|
1330
|
+
covered = set()
|
|
1331
|
+
for criterion, task_id, acceptance in rows:
|
|
1332
|
+
if criterion not in criteria:
|
|
1333
|
+
raise HandoffError(f"Intent coverage names unknown {criterion}")
|
|
1334
|
+
if task_id not in tasks:
|
|
1335
|
+
raise HandoffError(f"Intent coverage names unknown {task_id}")
|
|
1336
|
+
items = _acceptance_items(tasks[task_id], task_id)
|
|
1337
|
+
number = int(acceptance[2:])
|
|
1338
|
+
if number not in items:
|
|
1339
|
+
raise HandoffError(f"{task_id} has no {acceptance}")
|
|
1340
|
+
if not _has_verify(tasks[task_id]):
|
|
1341
|
+
raise HandoffError(f"{task_id} Verify is empty")
|
|
1342
|
+
assigned[task_id].add(criterion)
|
|
1343
|
+
covered.add(criterion)
|
|
1344
|
+
missing = [criterion for criterion in criteria if criterion not in covered]
|
|
1345
|
+
if missing:
|
|
1346
|
+
raise HandoffError("Intent coverage omits " + ", ".join(missing))
|
|
1347
|
+
for task_id, text in tasks.items():
|
|
1348
|
+
owned = set(_owned_criteria(text, task_id))
|
|
1349
|
+
if owned != assigned[task_id]:
|
|
1350
|
+
raise HandoffError(f"{task_id} Intent coverage does not match PLAN.md")
|
|
1351
|
+
surfaces = _surfaces(intent, "INTENT.md")
|
|
1352
|
+
milestone = state.get("milestone", "null")
|
|
1353
|
+
if milestone != "null":
|
|
1354
|
+
declared = _roadmap_surfaces(root, milestone)
|
|
1355
|
+
if declared is not None and _surface_keys(declared) != _surface_keys(surfaces):
|
|
1356
|
+
raise HandoffError(
|
|
1357
|
+
f"INTENT.md Surfaces do not match ROADMAP.md {milestone}"
|
|
1358
|
+
)
|
|
1359
|
+
owners = (
|
|
1360
|
+
_surface_contract(plan, surfaces)
|
|
1361
|
+
if surfaces or SURFACE_CONTRACT_HEADING.search(plan)
|
|
1362
|
+
else {}
|
|
1363
|
+
)
|
|
1364
|
+
for surface, (task_id, owned_criteria) in owners.items():
|
|
1365
|
+
if task_id not in tasks:
|
|
1366
|
+
raise HandoffError(f"{surface} surface names unknown {task_id}")
|
|
1367
|
+
unowned = [
|
|
1368
|
+
criterion
|
|
1369
|
+
for criterion in owned_criteria
|
|
1370
|
+
if criterion not in assigned[task_id]
|
|
1371
|
+
]
|
|
1372
|
+
if unowned:
|
|
1373
|
+
raise HandoffError(
|
|
1374
|
+
f"{surface} surface criteria are not owned by {task_id}: "
|
|
1375
|
+
+ ", ".join(unowned)
|
|
1376
|
+
)
|
|
1377
|
+
project_verify = _normalize_ws(_line_value(plan, "Project verify:"))
|
|
1378
|
+
for task_id, text in tasks.items():
|
|
1379
|
+
command = _verify_command(text)
|
|
1380
|
+
if not command:
|
|
1381
|
+
continue
|
|
1382
|
+
if command == project_verify:
|
|
1383
|
+
named = any(
|
|
1384
|
+
project_verify in _normalize_ws(criteria[sc_id])
|
|
1385
|
+
for sc_id in assigned[task_id]
|
|
1386
|
+
if sc_id in criteria
|
|
1387
|
+
)
|
|
1388
|
+
if not named:
|
|
1389
|
+
raise HandoffError(f"{task_id} Verify must not copy Project verify")
|
|
1390
|
+
continue
|
|
1391
|
+
if not _verify_mentions_a_file(command, _frontmatter_files(text)):
|
|
1392
|
+
raise HandoffError(f"{task_id} Verify must name a path from files")
|
|
1393
|
+
return {
|
|
1394
|
+
"phase": "plan",
|
|
1395
|
+
"criteria": sorted(criteria),
|
|
1396
|
+
"rows": len(rows),
|
|
1397
|
+
"tasks": len(tasks),
|
|
1398
|
+
"waves": waves,
|
|
1399
|
+
"surfaces": {surface: task for surface, (task, _) in owners.items()},
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
|
|
1403
|
+
def validate_wave(
|
|
1404
|
+
root: Path,
|
|
1405
|
+
project_dir: str = DEFAULT_PROJECT_DIR,
|
|
1406
|
+
review: str = "",
|
|
1407
|
+
) -> Dict[str, object]:
|
|
1408
|
+
"""Validate one canonical wave review and its owned INTENT verdicts."""
|
|
1409
|
+
|
|
1410
|
+
_require_state(root, "build", "active", project_dir)
|
|
1411
|
+
return validate_wave_evidence(root, project_dir, review)
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def validate_wave_evidence(
|
|
1415
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR, review: str = ""
|
|
1416
|
+
) -> Dict[str, object]:
|
|
1417
|
+
"""Check a wave artifact independently of the caller's current phase."""
|
|
1418
|
+
|
|
1419
|
+
if not review:
|
|
1420
|
+
raise HandoffError("wave validation requires --review")
|
|
1421
|
+
review_path = PurePosixPath(review)
|
|
1422
|
+
name = review_path.name
|
|
1423
|
+
named = WAVE_REVIEW_NAME.fullmatch(name)
|
|
1424
|
+
if not named:
|
|
1425
|
+
raise HandoffError(f"unrecognized wave review file: {name}")
|
|
1426
|
+
expected_review = PurePosixPath(project_dir) / "review" / name
|
|
1427
|
+
if review_path.is_absolute() or ".." in review_path.parts or review_path != expected_review:
|
|
1428
|
+
raise HandoffError(
|
|
1429
|
+
f"wave review must be the canonical path {expected_review.as_posix()}"
|
|
1430
|
+
)
|
|
1431
|
+
wave = int(named.group("wave"))
|
|
1432
|
+
cycle = int(named.group("cycle"))
|
|
1433
|
+
lens = named.group("lens")
|
|
1434
|
+
intent = _read(root, _intent_path(project_dir))
|
|
1435
|
+
criteria = _success_criteria(intent)
|
|
1436
|
+
plan = _read(root, f"{project_dir}/plan/PLAN.md")
|
|
1437
|
+
rows = _coverage_rows(plan)
|
|
1438
|
+
wave_depths, _waves = _plan_waves(plan)
|
|
1439
|
+
tasks = _task_texts(root, project_dir)
|
|
1440
|
+
expected_tasks = [
|
|
1441
|
+
task_id
|
|
1442
|
+
for task_id, task_text in tasks.items()
|
|
1443
|
+
if _task_wave(task_text, task_id) == wave
|
|
1444
|
+
]
|
|
1445
|
+
if not expected_tasks:
|
|
1446
|
+
raise HandoffError(f"PLAN.md has no Wave {wave} tasks")
|
|
1447
|
+
if wave not in wave_depths:
|
|
1448
|
+
raise HandoffError(f"PLAN.md has no Wave {wave}")
|
|
1449
|
+
expected_depth = wave_depths[wave]
|
|
1450
|
+
if lens is None and expected_depth == "deep":
|
|
1451
|
+
raise HandoffError(f"{name} must name its contract or adversarial lens")
|
|
1452
|
+
if lens is not None and expected_depth != "deep":
|
|
1453
|
+
raise HandoffError(f"{name} has a lens but PLAN.md depth is {expected_depth}")
|
|
1454
|
+
|
|
1455
|
+
assigned: Dict[str, Set[str]] = {task_id: set() for task_id in tasks}
|
|
1456
|
+
for criterion, task_id, _acceptance in rows:
|
|
1457
|
+
if task_id in assigned and criterion in criteria:
|
|
1458
|
+
assigned[task_id].add(criterion)
|
|
1459
|
+
owned = _owned_by_wave(tasks, assigned, wave)
|
|
1460
|
+
text = _read(root, review_path.as_posix())
|
|
1461
|
+
if _line_value(text, "Cycle:") != str(cycle):
|
|
1462
|
+
raise HandoffError(f"{name} Cycle field does not match its filename")
|
|
1463
|
+
if _line_value(text, "Depth:") != expected_depth:
|
|
1464
|
+
raise HandoffError(f"{name} Depth does not match PLAN.md")
|
|
1465
|
+
lens_fields = re.findall(r"(?m)^Lens:\s*(\S.*?)\s*$", text)
|
|
1466
|
+
if lens is None and lens_fields:
|
|
1467
|
+
raise HandoffError(f"{name} must not declare a review lens")
|
|
1468
|
+
if lens is not None and lens_fields != [lens]:
|
|
1469
|
+
raise HandoffError(f"{name} Lens field does not match its filename")
|
|
1470
|
+
reviewed = _line_value(text, "Tasks reviewed:")
|
|
1471
|
+
if not reviewed.isdigit() or int(reviewed) != len(expected_tasks):
|
|
1472
|
+
raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
|
|
1473
|
+
|
|
1474
|
+
lines = text.splitlines()
|
|
1475
|
+
task_headings = []
|
|
1476
|
+
for index, line in enumerate(lines):
|
|
1477
|
+
match = WAVE_TASK_HEADING.fullmatch(line)
|
|
1478
|
+
if match:
|
|
1479
|
+
task_headings.append((index, match))
|
|
1480
|
+
reviewed_tasks = [match.group("task") for _, match in task_headings]
|
|
1481
|
+
if reviewed_tasks != expected_tasks:
|
|
1482
|
+
raise HandoffError(
|
|
1483
|
+
f"{name} must review exactly {', '.join(expected_tasks)} in plan order"
|
|
1484
|
+
)
|
|
1485
|
+
task_verdicts = []
|
|
1486
|
+
for heading_index, heading in task_headings:
|
|
1487
|
+
task_id = heading.group("task")
|
|
1488
|
+
expected_title = _normalize_ws(_task_scalar(tasks[task_id], task_id, "title"))
|
|
1489
|
+
if _normalize_ws(heading.group("title")) != expected_title:
|
|
1490
|
+
raise HandoffError(f"{name} title for {task_id} differs from task file")
|
|
1491
|
+
end = next(
|
|
1492
|
+
(
|
|
1493
|
+
index
|
|
1494
|
+
for index in range(heading_index + 1, len(lines))
|
|
1495
|
+
if lines[index].startswith("## ")
|
|
1496
|
+
),
|
|
1497
|
+
len(lines),
|
|
1498
|
+
)
|
|
1499
|
+
verdict = heading.group("verdict")
|
|
1500
|
+
marker = "✅" if verdict == "pass" else "❌"
|
|
1501
|
+
evidence = [
|
|
1502
|
+
line.strip()[len(f"- {marker} ") :]
|
|
1503
|
+
for line in lines[heading_index + 1 : end]
|
|
1504
|
+
if line.strip().startswith(f"- {marker} ")
|
|
1505
|
+
]
|
|
1506
|
+
if not evidence:
|
|
1507
|
+
raise HandoffError(f"{name} task {task_id} lacks {verdict} evidence")
|
|
1508
|
+
for item in evidence:
|
|
1509
|
+
item = task_review_observation(item, tasks[task_id])
|
|
1510
|
+
_non_placeholder(item, f"{name} task {task_id} {verdict} evidence")
|
|
1511
|
+
task_verdicts.append(verdict)
|
|
1512
|
+
|
|
1513
|
+
overall = _line_value(text, "Wave verdict:")
|
|
1514
|
+
if overall not in {"pass", "blocked"}:
|
|
1515
|
+
raise HandoffError(f"{name} Wave verdict is invalid")
|
|
1516
|
+
if overall == "pass" and "fail" in task_verdicts:
|
|
1517
|
+
raise HandoffError(f"{name} Wave verdict is pass while a task failed")
|
|
1518
|
+
try:
|
|
1519
|
+
coverage = _section(text, "Intent coverage")
|
|
1520
|
+
except HandoffError:
|
|
1521
|
+
coverage = None
|
|
1522
|
+
if not owned:
|
|
1523
|
+
if coverage is not None and WAVE_SC_HEADING.search(_strip_comments(coverage)):
|
|
1524
|
+
raise HandoffError(f"{name} Intent coverage names SCs this wave does not own")
|
|
1525
|
+
return {
|
|
1526
|
+
"phase": "wave",
|
|
1527
|
+
"wave": wave,
|
|
1528
|
+
"cycle": cycle,
|
|
1529
|
+
"owned": [],
|
|
1530
|
+
"review": review,
|
|
1531
|
+
"verdict": overall,
|
|
1532
|
+
}
|
|
1533
|
+
if coverage is None:
|
|
1534
|
+
raise HandoffError(f"{name} is missing ## Intent coverage")
|
|
1535
|
+
verdicts: Dict[str, str] = {}
|
|
1536
|
+
coverage_lines = _strip_comments(coverage).splitlines()
|
|
1537
|
+
sc_headings = [
|
|
1538
|
+
(index, match)
|
|
1539
|
+
for index, line in enumerate(coverage_lines)
|
|
1540
|
+
if (match := WAVE_SC_HEADING.fullmatch(line.strip())) is not None
|
|
1541
|
+
]
|
|
1542
|
+
for position, (heading_index, match) in enumerate(sc_headings):
|
|
1543
|
+
sc_id = match.group(1)
|
|
1544
|
+
heading_text, separator, verdict = match.group(2).rpartition(":")
|
|
1545
|
+
verdict = verdict.strip()
|
|
1546
|
+
if verdict not in {"pass", "fail"}:
|
|
1547
|
+
raise HandoffError(f"{name} {sc_id} heading must end with `: pass` or `: fail`")
|
|
1548
|
+
if (
|
|
1549
|
+
sc_id in criteria
|
|
1550
|
+
and (
|
|
1551
|
+
not separator
|
|
1552
|
+
or _normalize_ws(heading_text) != _normalize_ws(criteria[sc_id])
|
|
1553
|
+
)
|
|
1554
|
+
):
|
|
1555
|
+
raise HandoffError(f"{name} {sc_id} heading text differs from INTENT.md")
|
|
1556
|
+
if sc_id in verdicts:
|
|
1557
|
+
raise HandoffError(f"{name} Intent coverage repeats {sc_id}")
|
|
1558
|
+
end = (
|
|
1559
|
+
sc_headings[position + 1][0]
|
|
1560
|
+
if position + 1 < len(sc_headings)
|
|
1561
|
+
else len(coverage_lines)
|
|
1562
|
+
)
|
|
1563
|
+
marker = "✅" if verdict == "pass" else "❌"
|
|
1564
|
+
evidence = [
|
|
1565
|
+
line.strip()[len(f"- {marker} ") :]
|
|
1566
|
+
for line in coverage_lines[heading_index + 1 : end]
|
|
1567
|
+
if line.strip().startswith(f"- {marker} ")
|
|
1568
|
+
]
|
|
1569
|
+
if not evidence:
|
|
1570
|
+
raise HandoffError(f"{name} {sc_id} lacks {verdict} evidence")
|
|
1571
|
+
for item in evidence:
|
|
1572
|
+
_non_placeholder(item, f"{name} {sc_id} {verdict} evidence")
|
|
1573
|
+
verdicts[sc_id] = verdict
|
|
1574
|
+
if sorted(verdicts, key=lambda n: int(n[2:])) != owned:
|
|
1575
|
+
raise HandoffError(f"{name} Intent coverage must cover exactly {', '.join(owned)}")
|
|
1576
|
+
if overall == "pass" and "fail" in verdicts.values():
|
|
1577
|
+
raise HandoffError(f"{name} Wave verdict is pass while an owned SC failed")
|
|
1578
|
+
return {
|
|
1579
|
+
"phase": "wave",
|
|
1580
|
+
"wave": wave,
|
|
1581
|
+
"cycle": cycle,
|
|
1582
|
+
"owned": owned,
|
|
1583
|
+
"review": review,
|
|
1584
|
+
"verdict": overall,
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
|
|
1588
|
+
def validate_final(
|
|
1589
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR, *, final_text: Optional[str] = None
|
|
1590
|
+
) -> Dict[str, object]:
|
|
1591
|
+
"""Require FINAL.md to give an evidenced verdict for every INTENT SC."""
|
|
1592
|
+
|
|
1593
|
+
_require_state(root, "ship", "active", project_dir)
|
|
1594
|
+
intent = _read(root, _intent_path(project_dir))
|
|
1595
|
+
criteria = _success_criteria(intent)
|
|
1596
|
+
surface_of: Dict[str, str] = {}
|
|
1597
|
+
surfaces = _surfaces(intent, "INTENT.md")
|
|
1598
|
+
if surfaces:
|
|
1599
|
+
plan = _read(root, f"{project_dir}/plan/PLAN.md")
|
|
1600
|
+
for surface, (_task, owned) in _surface_contract(plan, surfaces).items():
|
|
1601
|
+
for criterion in owned:
|
|
1602
|
+
surface_of[criterion] = surface
|
|
1603
|
+
relative = f"{project_dir}/review/FINAL.md"
|
|
1604
|
+
text = _read(root, relative) if final_text is None else final_text
|
|
1605
|
+
reviewed_head = _reviewed_head(text, relative)
|
|
1606
|
+
head_result = subprocess.run(
|
|
1607
|
+
("git", "-C", str(root), "rev-parse", "--verify", "HEAD"),
|
|
1608
|
+
capture_output=True,
|
|
1609
|
+
text=True,
|
|
1610
|
+
check=False,
|
|
1611
|
+
)
|
|
1612
|
+
if head_result.returncode != 0:
|
|
1613
|
+
raise HandoffError("FINAL.md Reviewed HEAD cannot be checked because HEAD is missing")
|
|
1614
|
+
current_head = head_result.stdout.strip()
|
|
1615
|
+
if reviewed_head != current_head:
|
|
1616
|
+
raise HandoffError(
|
|
1617
|
+
f"FINAL.md Reviewed HEAD {reviewed_head} is not the current HEAD "
|
|
1618
|
+
f"{current_head}; re-run the final review on the current commit"
|
|
1619
|
+
)
|
|
1620
|
+
overall = _line_value(text, "Overall verdict:")
|
|
1621
|
+
if overall not in {"pass", "blocked"}:
|
|
1622
|
+
raise HandoffError("FINAL.md Overall verdict is invalid")
|
|
1623
|
+
text = _strip_comments(_section(text, "Success criteria"))
|
|
1624
|
+
blocks = list(
|
|
1625
|
+
re.finditer(r"(?m)^### (?P<id>SC[1-9]\d*) — (?P<title>.+)$", text)
|
|
1626
|
+
)
|
|
1627
|
+
expected = list(criteria)
|
|
1628
|
+
found = [match.group("id") for match in blocks]
|
|
1629
|
+
if sorted(found, key=lambda n: int(n[2:])) != expected:
|
|
1630
|
+
raise HandoffError("FINAL.md Success criteria must cover exactly " + ", ".join(expected))
|
|
1631
|
+
verdicts: Dict[str, str] = {}
|
|
1632
|
+
for index, heading in enumerate(blocks):
|
|
1633
|
+
sc_id = heading.group("id")
|
|
1634
|
+
if _normalize_ws(heading.group("title")) != _normalize_ws(criteria[sc_id]):
|
|
1635
|
+
raise HandoffError(f"FINAL.md {sc_id} heading text differs from INTENT.md")
|
|
1636
|
+
end = blocks[index + 1].start() if index + 1 < len(blocks) else len(text)
|
|
1637
|
+
block = text[heading.end() : end]
|
|
1638
|
+
label = f"FINAL.md {sc_id}"
|
|
1639
|
+
verdict = _source_field(block, "Verdict", label)
|
|
1640
|
+
if verdict not in {"met", "not-met", "unverifiable"}:
|
|
1641
|
+
raise HandoffError(f"FINAL.md {sc_id} Verdict is invalid")
|
|
1642
|
+
verdicts[sc_id] = verdict
|
|
1643
|
+
surface = surface_of.get(sc_id)
|
|
1644
|
+
if surface is None:
|
|
1645
|
+
check = _source_field(block, "Check", label)
|
|
1646
|
+
observed = _source_field(block, "Observed", label)
|
|
1647
|
+
else:
|
|
1648
|
+
check = _unquoted(_raw_source_field(block, "Check", label))
|
|
1649
|
+
observed = _unquoted(_raw_source_field(block, "Observed", label))
|
|
1650
|
+
reference = _source_field(block, "Reference", label)
|
|
1651
|
+
finding = _source_field(block, "Finding", label)
|
|
1652
|
+
fix_direction = _source_field(block, "Fix direction", label)
|
|
1653
|
+
if surface is not None:
|
|
1654
|
+
named = _unquoted(_raw_source_field(block, "Surface", label))
|
|
1655
|
+
if _normalize_ws(named).casefold() != _normalize_ws(surface).casefold():
|
|
1656
|
+
raise HandoffError(f"FINAL.md {sc_id} Surface must name {surface}")
|
|
1657
|
+
if check.casefold() == "none":
|
|
1658
|
+
raise HandoffError(
|
|
1659
|
+
f"FINAL.md {sc_id} lacks the walked Check for the {surface} surface"
|
|
1660
|
+
)
|
|
1661
|
+
_surface_value(check, f"FINAL.md {sc_id} surface Check")
|
|
1662
|
+
_surface_value(observed, f"FINAL.md {sc_id} surface Observed")
|
|
1663
|
+
if check.casefold() == "none" and reference.casefold() == "none":
|
|
1664
|
+
raise HandoffError(f"FINAL.md {sc_id} lacks a Check or Reference")
|
|
1665
|
+
if observed.casefold() == "none":
|
|
1666
|
+
raise HandoffError(f"FINAL.md {sc_id} lacks an Observed result")
|
|
1667
|
+
if verdict == "met":
|
|
1668
|
+
if finding.casefold() != "none" or fix_direction.casefold() != "none":
|
|
1669
|
+
raise HandoffError(
|
|
1670
|
+
f"FINAL.md {sc_id} met verdict must have no Finding or Fix direction"
|
|
1671
|
+
)
|
|
1672
|
+
elif finding.casefold() == "none" or fix_direction.casefold() == "none":
|
|
1673
|
+
raise HandoffError(
|
|
1674
|
+
f"FINAL.md {sc_id} {verdict} verdict requires a Finding and Fix direction"
|
|
1675
|
+
)
|
|
1676
|
+
if overall == "pass" and verdict != "met":
|
|
1677
|
+
raise HandoffError(f"FINAL.md Overall verdict is pass while {sc_id} is {verdict}")
|
|
1678
|
+
review_dir = root / project_dir / "review"
|
|
1679
|
+
gaps: Dict[int, str] = {}
|
|
1680
|
+
for path in sorted(review_dir.glob("final-gap-*.md")):
|
|
1681
|
+
match = GAP_NAME_PATTERN.fullmatch(path.name)
|
|
1682
|
+
if not match or not path.is_file() or path.is_symlink():
|
|
1683
|
+
raise HandoffError(f"invalid final gap artifact: {path.name}")
|
|
1684
|
+
number = int(match.group("number"))
|
|
1685
|
+
if number in gaps:
|
|
1686
|
+
raise HandoffError(f"duplicate final gap number {number}")
|
|
1687
|
+
gap_relative = f"{project_dir}/review/{path.name}"
|
|
1688
|
+
gap = _read(root, gap_relative)
|
|
1689
|
+
heading = GAP_HEADING_PATTERN.fullmatch(gap.splitlines()[0] if gap else "")
|
|
1690
|
+
if not heading or int(heading.group("number")) != number:
|
|
1691
|
+
raise HandoffError(f"{gap_relative} heading does not match its file number")
|
|
1692
|
+
if _reviewed_head(gap, gap_relative) != reviewed_head:
|
|
1693
|
+
raise HandoffError(f"{gap_relative} Reviewed HEAD differs from FINAL.md")
|
|
1694
|
+
verdict = _line_value(gap, "Gap verdict:")
|
|
1695
|
+
if verdict not in {"pass", "blocked"}:
|
|
1696
|
+
raise HandoffError(f"{gap_relative} Gap verdict is invalid")
|
|
1697
|
+
risk = _line_value(gap, "Risk:")
|
|
1698
|
+
if (
|
|
1699
|
+
_normalize_ws(heading.group("risk")).casefold()
|
|
1700
|
+
!= _normalize_ws(risk).casefold()
|
|
1701
|
+
):
|
|
1702
|
+
raise HandoffError(
|
|
1703
|
+
f"{gap_relative} heading risk does not match Risk field"
|
|
1704
|
+
)
|
|
1705
|
+
_line_value(gap, "Waves checked:")
|
|
1706
|
+
checked = _section(gap, "Checked evidence")
|
|
1707
|
+
check = _source_field(checked, "Check", gap_relative)
|
|
1708
|
+
observed = _source_field(checked, "Observed", gap_relative)
|
|
1709
|
+
reference = _source_field(checked, "Reference", gap_relative)
|
|
1710
|
+
if check.casefold() == "none" and reference.casefold() == "none":
|
|
1711
|
+
raise HandoffError(f"{gap_relative} lacks a Check or Reference")
|
|
1712
|
+
if observed.casefold() == "none":
|
|
1713
|
+
raise HandoffError(f"{gap_relative} lacks an Observed result")
|
|
1714
|
+
finding = _section(gap, "Finding")
|
|
1715
|
+
found_value = _source_field(finding, "Found", gap_relative)
|
|
1716
|
+
fix_direction = _source_field(finding, "Fix direction", gap_relative)
|
|
1717
|
+
if found_value.casefold() == "none":
|
|
1718
|
+
raise HandoffError(f"{gap_relative} lacks a Finding")
|
|
1719
|
+
if verdict == "pass" and fix_direction.casefold() != "none":
|
|
1720
|
+
raise HandoffError(f"{gap_relative} pass must use Fix direction: none")
|
|
1721
|
+
if verdict == "blocked" and fix_direction.casefold() == "none":
|
|
1722
|
+
raise HandoffError(f"{gap_relative} blocked verdict lacks a fix direction")
|
|
1723
|
+
gaps[number] = verdict
|
|
1724
|
+
|
|
1725
|
+
expected_gaps = list(range(1, len(gaps) + 1))
|
|
1726
|
+
if sorted(gaps) != expected_gaps or not gaps:
|
|
1727
|
+
raise HandoffError("final gap artifacts must be present and contiguous from 1")
|
|
1728
|
+
if overall == "pass" and any(verdict != "pass" for verdict in gaps.values()):
|
|
1729
|
+
raise HandoffError("FINAL.md Overall verdict is pass while a final gap is blocked")
|
|
1730
|
+
all_evidence_passes = (
|
|
1731
|
+
all(verdict == "met" for verdict in verdicts.values())
|
|
1732
|
+
and all(verdict == "pass" for verdict in gaps.values())
|
|
1733
|
+
)
|
|
1734
|
+
expected_overall = "pass" if all_evidence_passes else "blocked"
|
|
1735
|
+
if overall != expected_overall:
|
|
1736
|
+
raise HandoffError(
|
|
1737
|
+
f"FINAL.md Overall verdict must be {expected_overall} for its SC and gap verdicts"
|
|
1738
|
+
)
|
|
1739
|
+
return {
|
|
1740
|
+
"phase": "ship",
|
|
1741
|
+
"criteria": expected,
|
|
1742
|
+
"verdict": overall,
|
|
1743
|
+
"reviewed_head": reviewed_head,
|
|
1744
|
+
"gaps": sorted(gaps),
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
|
|
1748
|
+
def validate_patch_findings(
|
|
1749
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1750
|
+
) -> Dict[str, object]:
|
|
1751
|
+
"""Validate the review-to-patch hand-off and return its summary."""
|
|
1752
|
+
|
|
1753
|
+
_require_state(root, "ship", "blocked", project_dir)
|
|
1754
|
+
handoff = _read(root, f"{project_dir}/review/PATCH-FINDINGS.md")
|
|
1755
|
+
reviewed_head = _reviewed_head(handoff, "PATCH-FINDINGS.md")
|
|
1756
|
+
if _line_value(handoff, "State:") != "ship/blocked":
|
|
1757
|
+
raise HandoffError("PATCH-FINDINGS.md names the wrong state")
|
|
1758
|
+
|
|
1759
|
+
blocks = _finding_blocks(handoff)
|
|
1760
|
+
if not blocks:
|
|
1761
|
+
raise HandoffError("PATCH-FINDINGS.md has no findings")
|
|
1762
|
+
source_pattern = _source_pattern(project_dir)
|
|
1763
|
+
ids = []
|
|
1764
|
+
sources = []
|
|
1765
|
+
for heading, block in blocks:
|
|
1766
|
+
match = FINDING_PATTERN.fullmatch(heading)
|
|
1767
|
+
if not match:
|
|
1768
|
+
raise HandoffError("patch finding headings must use P### ids")
|
|
1769
|
+
finding_id = match.group("id")
|
|
1770
|
+
ids.append(finding_id)
|
|
1771
|
+
source = _field(block, "Source").strip("`")
|
|
1772
|
+
if not source_pattern.fullmatch(source):
|
|
1773
|
+
raise HandoffError(f"invalid patch finding source: {source}")
|
|
1774
|
+
source_text = _read(root, source)
|
|
1775
|
+
source_head = _reviewed_head(source_text, source)
|
|
1776
|
+
if source_head != reviewed_head:
|
|
1777
|
+
raise HandoffError(f"{source} Reviewed HEAD differs from PATCH-FINDINGS.md")
|
|
1778
|
+
if source.endswith("FINAL.md"):
|
|
1779
|
+
if _line_value(source_text, "Overall verdict:") != "blocked":
|
|
1780
|
+
raise HandoffError("FINAL.md is not a blocked finding source")
|
|
1781
|
+
elif _line_value(source_text, "Gap verdict:") != "blocked":
|
|
1782
|
+
raise HandoffError(f"{source} is not a blocked finding source")
|
|
1783
|
+
locator = _field(block, "Locator")
|
|
1784
|
+
evidence = _field(block, "Evidence")
|
|
1785
|
+
fix_direction = _field(block, "Fix direction")
|
|
1786
|
+
source_evidence, source_fix_direction = _source_repair_fields(
|
|
1787
|
+
source, source_text, locator
|
|
1788
|
+
)
|
|
1789
|
+
if evidence != source_evidence:
|
|
1790
|
+
raise HandoffError(f"{source} locator {locator} evidence does not match its source")
|
|
1791
|
+
if fix_direction != source_fix_direction:
|
|
1792
|
+
raise HandoffError(
|
|
1793
|
+
f"{source} locator {locator} fix direction does not match its source"
|
|
1794
|
+
)
|
|
1795
|
+
sources.append(source)
|
|
1796
|
+
|
|
1797
|
+
expected_ids = [f"P{number:03d}" for number in range(1, len(ids) + 1)]
|
|
1798
|
+
if ids != expected_ids:
|
|
1799
|
+
raise HandoffError("patch finding ids must be contiguous and ordered")
|
|
1800
|
+
return {
|
|
1801
|
+
"phase": "ship",
|
|
1802
|
+
"reviewed_head": reviewed_head,
|
|
1803
|
+
"findings": ids,
|
|
1804
|
+
"sources": sorted(set(sources)),
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
|
|
1808
|
+
def parser() -> argparse.ArgumentParser:
|
|
1809
|
+
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
1810
|
+
argument_parser.add_argument(
|
|
1811
|
+
"phase", choices=("research", "decide", "roadmap", "patch", "plan", "wave", "final")
|
|
1812
|
+
)
|
|
1813
|
+
argument_parser.add_argument("--repo", type=Path, required=True)
|
|
1814
|
+
argument_parser.add_argument(
|
|
1815
|
+
"--project-dir",
|
|
1816
|
+
type=_project_dir,
|
|
1817
|
+
default=DEFAULT_PROJECT_DIR,
|
|
1818
|
+
help=(
|
|
1819
|
+
"relative POSIX-style project directory under --repo that holds the "
|
|
1820
|
+
"per-milestone artifacts (default: .project)"
|
|
1821
|
+
),
|
|
1822
|
+
)
|
|
1823
|
+
argument_parser.add_argument(
|
|
1824
|
+
"--review",
|
|
1825
|
+
default="",
|
|
1826
|
+
help="repo-relative wave review path (required for phase wave)",
|
|
1827
|
+
)
|
|
1828
|
+
return argument_parser
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
1832
|
+
arguments = parser().parse_args(argv)
|
|
1833
|
+
try:
|
|
1834
|
+
repo = arguments.repo.resolve()
|
|
1835
|
+
if arguments.phase == "wave":
|
|
1836
|
+
result = validate_wave(repo, arguments.project_dir, arguments.review)
|
|
1837
|
+
else:
|
|
1838
|
+
validators = {
|
|
1839
|
+
"research": validate_research,
|
|
1840
|
+
"decide": validate_decide,
|
|
1841
|
+
"roadmap": validate_roadmap,
|
|
1842
|
+
"patch": validate_patch_findings,
|
|
1843
|
+
"plan": validate_plan,
|
|
1844
|
+
"final": validate_final,
|
|
1845
|
+
}
|
|
1846
|
+
result = validators[arguments.phase](repo, arguments.project_dir)
|
|
1847
|
+
except HandoffError as error:
|
|
1848
|
+
print(f"handoff validation failed: {error}", file=sys.stderr)
|
|
1849
|
+
return 1
|
|
1850
|
+
print(json.dumps(result, sort_keys=True))
|
|
1851
|
+
return 0
|
|
1852
|
+
|
|
1853
|
+
|
|
1854
|
+
if __name__ == "__main__":
|
|
1855
|
+
raise SystemExit(main())
|