@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,1321 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# gsd-path project runtime
|
|
3
|
+
"""Validate discussion records, reviews and the manifest for archive_milestone."""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
import shutil
|
|
8
|
+
from datetime import date
|
|
9
|
+
from pathlib import Path, PurePosixPath
|
|
10
|
+
from typing import Optional, Sequence
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from pipeline_state import PipelineState
|
|
14
|
+
import _common
|
|
15
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
16
|
+
from scripts.pipeline_state import PipelineState
|
|
17
|
+
from scripts import _common
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
atomic_replace = _common.atomic_replace
|
|
21
|
+
run_git = _common.run_git
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def record_sections(
|
|
25
|
+
lines: Sequence[str], pattern: re.Pattern, boundaries: Sequence[re.Pattern]
|
|
26
|
+
) -> Sequence[tuple]:
|
|
27
|
+
headings = []
|
|
28
|
+
for index, line in enumerate(lines):
|
|
29
|
+
match = pattern.fullmatch(line)
|
|
30
|
+
if match:
|
|
31
|
+
headings.append((index, match))
|
|
32
|
+
sections = []
|
|
33
|
+
for start, match in headings:
|
|
34
|
+
end = next(
|
|
35
|
+
(
|
|
36
|
+
index
|
|
37
|
+
for index in range(start + 1, len(lines))
|
|
38
|
+
if any(boundary.fullmatch(lines[index]) for boundary in boundaries)
|
|
39
|
+
),
|
|
40
|
+
len(lines),
|
|
41
|
+
)
|
|
42
|
+
sections.append((match, lines[start + 1 : end]))
|
|
43
|
+
return sections
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def required_dialogue_block(
|
|
47
|
+
lines: Sequence[str],
|
|
48
|
+
marker: str,
|
|
49
|
+
end_marker: str,
|
|
50
|
+
artifact: str,
|
|
51
|
+
quote: bool = False,
|
|
52
|
+
end_from_last: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
if lines.count(marker) != 1:
|
|
55
|
+
raise ArchiveError(f"{artifact} requires one {marker.removeprefix('- **').removesuffix('**:')} block")
|
|
56
|
+
start = lines.index(marker) + 1
|
|
57
|
+
boundaries = [
|
|
58
|
+
index for index in range(start, len(lines)) if lines[index] == end_marker
|
|
59
|
+
]
|
|
60
|
+
if not boundaries:
|
|
61
|
+
end = len(lines)
|
|
62
|
+
elif end_from_last:
|
|
63
|
+
end = boundaries[-1]
|
|
64
|
+
else:
|
|
65
|
+
end = boundaries[0]
|
|
66
|
+
content = "\n".join(lines[start:end]).strip()
|
|
67
|
+
if quote:
|
|
68
|
+
content = "\n".join(
|
|
69
|
+
line.lstrip().removeprefix(">").strip() for line in content.splitlines()
|
|
70
|
+
).strip()
|
|
71
|
+
if not content:
|
|
72
|
+
label = "verbatim user" if quote else "assistant"
|
|
73
|
+
raise ArchiveError(f"{artifact} requires a non-empty {label} block")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def require_contiguous_ids(values: Sequence[int], artifact: str) -> None:
|
|
77
|
+
if not values or list(values) != list(range(1, len(values) + 1)):
|
|
78
|
+
raise ArchiveError(f"{artifact} record ids must be contiguous from 001")
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def require_iso_date(value: str, artifact: str) -> None:
|
|
82
|
+
try:
|
|
83
|
+
parsed = date.fromisoformat(value)
|
|
84
|
+
except ValueError as error:
|
|
85
|
+
raise ArchiveError(f"{artifact} date must be YYYY-MM-DD") from error
|
|
86
|
+
if parsed.isoformat() != value:
|
|
87
|
+
raise ArchiveError(f"{artifact} date must be YYYY-MM-DD")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def require_bullet_fields(
|
|
91
|
+
lines: Sequence[str], artifact: str, fields: Sequence[str]
|
|
92
|
+
) -> dict:
|
|
93
|
+
return {
|
|
94
|
+
field: completed_bullet_field(lines, field, artifact) for field in fields
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def validate_discussion_directory(
|
|
99
|
+
directory: Path, require_dispositions: bool = True
|
|
100
|
+
) -> None:
|
|
101
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
102
|
+
raise ArchiveError("discussion archive must be a real directory")
|
|
103
|
+
entries = sorted(path.name for path in directory.iterdir())
|
|
104
|
+
if entries != sorted(DISCUSSION_FILES):
|
|
105
|
+
raise ArchiveError("discussion archive must contain exactly DIALOGUE.md and ANSWERS.md")
|
|
106
|
+
dialogue, answers = (directory / name for name in DISCUSSION_FILES)
|
|
107
|
+
if not archive_milestone.is_real_file(dialogue) or not archive_milestone.is_real_file(answers):
|
|
108
|
+
raise ArchiveError("discussion archive records must be real files")
|
|
109
|
+
|
|
110
|
+
dialogue_content = dialogue.read_text(encoding="utf-8")
|
|
111
|
+
answer_content = answers.read_text(encoding="utf-8")
|
|
112
|
+
dialogue_lines = dialogue_content.splitlines()
|
|
113
|
+
answer_lines = answer_content.splitlines()
|
|
114
|
+
if archive_milestone.contains_placeholder("\n".join((*dialogue_lines, *answer_lines))):
|
|
115
|
+
raise ArchiveError("discussion archive records contain placeholders")
|
|
116
|
+
|
|
117
|
+
dialogue_sections = record_sections(
|
|
118
|
+
dialogue_lines, DIALOGUE_HEADING_PATTERN, (DIALOGUE_HEADING_PATTERN,)
|
|
119
|
+
)
|
|
120
|
+
answer_boundaries = (ANSWER_HEADING_PATTERN, DISPOSITION_HEADING_PATTERN)
|
|
121
|
+
answer_sections = record_sections(answer_lines, ANSWER_HEADING_PATTERN, answer_boundaries)
|
|
122
|
+
dialogue_ids = [int(match.group(1)) for match, _ in dialogue_sections]
|
|
123
|
+
answer_ids = [int(match.group(1)) for match, _ in answer_sections]
|
|
124
|
+
if not dialogue_ids and not answer_ids:
|
|
125
|
+
actual = {
|
|
126
|
+
"DIALOGUE.md": dialogue_content,
|
|
127
|
+
"ANSWERS.md": answer_content,
|
|
128
|
+
}
|
|
129
|
+
if actual != EMPTY_DISCUSSION_FILES:
|
|
130
|
+
raise ArchiveError("empty discussion records must use canonical headers")
|
|
131
|
+
return
|
|
132
|
+
require_contiguous_ids(dialogue_ids, "DIALOGUE.md")
|
|
133
|
+
require_contiguous_ids(answer_ids, "ANSWERS.md")
|
|
134
|
+
if dialogue_ids != answer_ids:
|
|
135
|
+
raise ArchiveError("discussion dialogue and answer ids do not correspond")
|
|
136
|
+
|
|
137
|
+
dialogue_threads = {}
|
|
138
|
+
dialogue_phases = {}
|
|
139
|
+
dialogue_dates = {}
|
|
140
|
+
for match, section in dialogue_sections:
|
|
141
|
+
identifier = int(match.group(1))
|
|
142
|
+
require_iso_date(match.group(2), "DIALOGUE.md")
|
|
143
|
+
if match.group(3) not in DISCUSSION_PHASES:
|
|
144
|
+
raise ArchiveError("DIALOGUE.md has an invalid phase")
|
|
145
|
+
thread = completed_bullet_field(section, "Thread", "DIALOGUE.md")
|
|
146
|
+
if not re.fullmatch(r"T\d{3}", thread):
|
|
147
|
+
raise ArchiveError("DIALOGUE.md Thread must be T###")
|
|
148
|
+
reply_to = completed_bullet_field(section, "Reply to", "DIALOGUE.md")
|
|
149
|
+
if reply_to != "none" and not re.fullmatch(r"D\d{3}", reply_to):
|
|
150
|
+
raise ArchiveError("DIALOGUE.md Reply to must be none or D###")
|
|
151
|
+
if reply_to != "none" and int(reply_to[1:]) >= identifier:
|
|
152
|
+
raise ArchiveError("DIALOGUE.md Reply to must reference an earlier turn")
|
|
153
|
+
if reply_to != "none" and dialogue_threads[int(reply_to[1:])] != thread:
|
|
154
|
+
raise ArchiveError("DIALOGUE.md Reply to must stay in the same thread")
|
|
155
|
+
values = require_bullet_fields(
|
|
156
|
+
section,
|
|
157
|
+
"DIALOGUE.md",
|
|
158
|
+
("Evidence checked", "Research", "Thread status"),
|
|
159
|
+
)
|
|
160
|
+
thread_status = values["Thread status"]
|
|
161
|
+
if thread_status not in {"working", "NEEDS-USER", "final"}:
|
|
162
|
+
raise ArchiveError("DIALOGUE.md has an invalid Thread status")
|
|
163
|
+
required_dialogue_block(
|
|
164
|
+
section,
|
|
165
|
+
"- **User (verbatim)**:",
|
|
166
|
+
"- **Assistant**:",
|
|
167
|
+
"DIALOGUE.md",
|
|
168
|
+
quote=True,
|
|
169
|
+
)
|
|
170
|
+
required_dialogue_block(
|
|
171
|
+
section,
|
|
172
|
+
"- **Assistant**:",
|
|
173
|
+
"- **Evidence checked**:",
|
|
174
|
+
"DIALOGUE.md",
|
|
175
|
+
end_from_last=True,
|
|
176
|
+
)
|
|
177
|
+
dialogue_threads[identifier] = thread
|
|
178
|
+
dialogue_phases[identifier] = f"{match.group(3)}/{match.group(4)}"
|
|
179
|
+
dialogue_dates[identifier] = match.group(2)
|
|
180
|
+
|
|
181
|
+
answer_threads = {}
|
|
182
|
+
pending_answers = set()
|
|
183
|
+
answer_receipts = {}
|
|
184
|
+
for match, section in answer_sections:
|
|
185
|
+
identifier = int(match.group(1))
|
|
186
|
+
require_iso_date(match.group(2), "ANSWERS.md")
|
|
187
|
+
if match.group(2) != dialogue_dates[identifier]:
|
|
188
|
+
raise ArchiveError("discussion answer date does not match its dialogue turn")
|
|
189
|
+
thread = completed_bullet_field(section, "Thread", "ANSWERS.md")
|
|
190
|
+
if thread != dialogue_threads[identifier]:
|
|
191
|
+
raise ArchiveError("discussion answer Thread does not match its dialogue turn")
|
|
192
|
+
if completed_bullet_field(section, "Turn", "ANSWERS.md") != f"D{identifier:03d}":
|
|
193
|
+
raise ArchiveError("discussion answer Turn does not match its id")
|
|
194
|
+
supersedes = completed_bullet_field(section, "Supersedes", "ANSWERS.md")
|
|
195
|
+
if supersedes != "none" and not re.fullmatch(r"A\d{3}", supersedes):
|
|
196
|
+
raise ArchiveError("ANSWERS.md Supersedes must be none or A###")
|
|
197
|
+
if supersedes != "none" and int(supersedes[1:]) >= identifier:
|
|
198
|
+
raise ArchiveError("ANSWERS.md Supersedes must reference an earlier answer")
|
|
199
|
+
if supersedes != "none" and answer_threads[int(supersedes[1:])] != thread:
|
|
200
|
+
raise ArchiveError("ANSWERS.md Supersedes must stay in the same thread")
|
|
201
|
+
values = require_bullet_fields(
|
|
202
|
+
section,
|
|
203
|
+
"ANSWERS.md",
|
|
204
|
+
(
|
|
205
|
+
"Question",
|
|
206
|
+
"Status",
|
|
207
|
+
"Phase/status",
|
|
208
|
+
"Conclusion",
|
|
209
|
+
"Reasoning / pushback",
|
|
210
|
+
"Evidence",
|
|
211
|
+
"Research",
|
|
212
|
+
"Confidence",
|
|
213
|
+
"Unresolved",
|
|
214
|
+
"Next owner",
|
|
215
|
+
"Target artifact",
|
|
216
|
+
"Follow-up",
|
|
217
|
+
),
|
|
218
|
+
)
|
|
219
|
+
status = values["Status"]
|
|
220
|
+
if status not in {"working", "NEEDS-USER", "final"}:
|
|
221
|
+
raise ArchiveError("ANSWERS.md has an invalid Status")
|
|
222
|
+
phase_status = values["Phase/status"]
|
|
223
|
+
if not re.fullmatch(r"[a-z-]+/(active|blocked|done)", phase_status):
|
|
224
|
+
raise ArchiveError("ANSWERS.md has an invalid Phase/status")
|
|
225
|
+
if phase_status != dialogue_phases[identifier]:
|
|
226
|
+
raise ArchiveError("discussion answer Phase/status does not match its dialogue turn")
|
|
227
|
+
confidence = values["Confidence"]
|
|
228
|
+
if confidence not in {"high", "medium", "low", "unverifiable"}:
|
|
229
|
+
raise ArchiveError("ANSWERS.md has an invalid Confidence")
|
|
230
|
+
follow_up = values["Follow-up"]
|
|
231
|
+
if follow_up not in {"none", "required"}:
|
|
232
|
+
raise ArchiveError("ANSWERS.md has an invalid Follow-up")
|
|
233
|
+
next_owner = values["Next owner"]
|
|
234
|
+
target = values["Target artifact"]
|
|
235
|
+
answer_key = f"A{identifier:03d}"
|
|
236
|
+
answer_receipts[answer_key] = (next_owner, target)
|
|
237
|
+
if follow_up == "required":
|
|
238
|
+
if status not in {"final", "NEEDS-USER"}:
|
|
239
|
+
raise ArchiveError("required discussion follow-up must be final or NEEDS-USER")
|
|
240
|
+
if next_owner in {"none", "user"}:
|
|
241
|
+
raise ArchiveError("required discussion follow-up needs a phase owner")
|
|
242
|
+
if target == "none":
|
|
243
|
+
raise ArchiveError("required discussion follow-up needs a target artifact")
|
|
244
|
+
pending_answers.add(answer_key)
|
|
245
|
+
answer_threads[identifier] = thread
|
|
246
|
+
|
|
247
|
+
disposition_sections = record_sections(
|
|
248
|
+
answer_lines, DISPOSITION_HEADING_PATTERN, answer_boundaries
|
|
249
|
+
)
|
|
250
|
+
disposition_ids = [int(match.group(1)) for match, _ in disposition_sections]
|
|
251
|
+
if disposition_ids:
|
|
252
|
+
require_contiguous_ids(disposition_ids, "ANSWERS.md dispositions")
|
|
253
|
+
seen_answers = set()
|
|
254
|
+
for match, section in disposition_sections:
|
|
255
|
+
require_iso_date(match.group(2), "ANSWERS.md disposition")
|
|
256
|
+
answer = completed_bullet_field(section, "Answer", "ANSWERS.md")
|
|
257
|
+
if not re.fullmatch(r"A\d{3}", answer) or int(answer[1:]) not in answer_ids:
|
|
258
|
+
raise ArchiveError("discussion disposition references an unknown answer")
|
|
259
|
+
if answer in seen_answers:
|
|
260
|
+
raise ArchiveError("discussion answer has multiple dispositions")
|
|
261
|
+
seen_answers.add(answer)
|
|
262
|
+
disposition = completed_bullet_field(section, "Status", "ANSWERS.md")
|
|
263
|
+
if disposition not in {
|
|
264
|
+
"applied",
|
|
265
|
+
"acknowledged-no-change",
|
|
266
|
+
"superseded",
|
|
267
|
+
"rejected-by-user",
|
|
268
|
+
}:
|
|
269
|
+
raise ArchiveError("discussion disposition has an invalid Status")
|
|
270
|
+
receipt = require_bullet_fields(
|
|
271
|
+
section, "ANSWERS.md", ("Owner", "Artifact", "Evidence")
|
|
272
|
+
)
|
|
273
|
+
expected_owner, expected_artifact = answer_receipts[answer]
|
|
274
|
+
expected_owner = (
|
|
275
|
+
"user" if disposition == "rejected-by-user" else expected_owner
|
|
276
|
+
)
|
|
277
|
+
if receipt["Owner"] != expected_owner:
|
|
278
|
+
raise ArchiveError(
|
|
279
|
+
f"discussion disposition owner must be {expected_owner} for {answer}"
|
|
280
|
+
)
|
|
281
|
+
if receipt["Artifact"] != expected_artifact:
|
|
282
|
+
raise ArchiveError(
|
|
283
|
+
"discussion disposition artifact must match the answer target "
|
|
284
|
+
f"for {answer}"
|
|
285
|
+
)
|
|
286
|
+
unresolved = sorted(pending_answers - seen_answers)
|
|
287
|
+
if require_dispositions and unresolved:
|
|
288
|
+
raise ArchiveError(
|
|
289
|
+
f"discussion follow-up lacks a disposition receipt: {', '.join(unresolved)}"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def require_append_only_discussion(
|
|
294
|
+
source: Path,
|
|
295
|
+
destination: Path,
|
|
296
|
+
*,
|
|
297
|
+
require_dispositions: bool = True,
|
|
298
|
+
) -> None:
|
|
299
|
+
validate_discussion_directory(source, require_dispositions=require_dispositions)
|
|
300
|
+
validate_discussion_directory(destination, require_dispositions=require_dispositions)
|
|
301
|
+
for name in DISCUSSION_FILES:
|
|
302
|
+
active = (source / name).read_bytes()
|
|
303
|
+
archived = (destination / name).read_bytes()
|
|
304
|
+
if not active.startswith(archived):
|
|
305
|
+
raise ArchiveError(f"active discussion is not an append-only extension of archived {name}")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def reconcile_append_only_discussion(active_root: Path, archive: Path) -> None:
|
|
309
|
+
source = active_root / "discuss"
|
|
310
|
+
destination = archive / "discuss"
|
|
311
|
+
if not source.exists() or not destination.exists():
|
|
312
|
+
return
|
|
313
|
+
require_append_only_discussion(source, destination)
|
|
314
|
+
transaction = active_root / DISCUSSION_TRANSACTION_NAME
|
|
315
|
+
payload = {
|
|
316
|
+
"schema": "gsd-path/discussion-archive/v1",
|
|
317
|
+
"archive": str(archive.resolve()),
|
|
318
|
+
"files": {
|
|
319
|
+
name: (source / name).read_text(encoding="utf-8")
|
|
320
|
+
for name in DISCUSSION_FILES
|
|
321
|
+
},
|
|
322
|
+
}
|
|
323
|
+
archive_milestone.atomic_write(transaction, json.dumps(payload, sort_keys=True) + "\n")
|
|
324
|
+
finish_discussion_reconciliation(active_root, archive)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def require_safe_discussion_destination(
|
|
328
|
+
active_root: Path, archive: Path, destination: Path
|
|
329
|
+
) -> None:
|
|
330
|
+
archive_root = active_root / "archive"
|
|
331
|
+
if active_root.is_symlink() or not active_root.is_dir():
|
|
332
|
+
raise ArchiveError("discussion reconciliation requires a real .project directory")
|
|
333
|
+
if archive_root.is_symlink() or not archive_root.is_dir():
|
|
334
|
+
raise ArchiveError("discussion reconciliation requires a real archive root")
|
|
335
|
+
if archive.is_symlink() or not archive.is_dir():
|
|
336
|
+
raise ArchiveError("discussion reconciliation requires a real archive directory")
|
|
337
|
+
if archive.resolve().parent != archive_root.resolve():
|
|
338
|
+
raise ArchiveError("discussion reconciliation archive escapes the archive root")
|
|
339
|
+
if destination.is_symlink() or not destination.is_dir():
|
|
340
|
+
raise ArchiveError("discussion reconciliation destination must be a real directory")
|
|
341
|
+
if destination.resolve().parent != archive.resolve():
|
|
342
|
+
raise ArchiveError("discussion reconciliation destination escapes the archive")
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def finish_discussion_reconciliation(active_root: Path, archive: Path) -> None:
|
|
346
|
+
transaction = active_root / DISCUSSION_TRANSACTION_NAME
|
|
347
|
+
if not transaction.exists():
|
|
348
|
+
return
|
|
349
|
+
if transaction.is_symlink() or not transaction.is_file():
|
|
350
|
+
raise ArchiveError("discussion reconciliation journal must be a real file")
|
|
351
|
+
try:
|
|
352
|
+
payload = json.loads(transaction.read_text(encoding="utf-8"))
|
|
353
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
354
|
+
raise ArchiveError(f"discussion reconciliation journal is unreadable: {error}") from error
|
|
355
|
+
if payload.get("schema") != "gsd-path/discussion-archive/v1":
|
|
356
|
+
raise ArchiveError("discussion reconciliation journal has the wrong schema")
|
|
357
|
+
if payload.get("archive") != str(archive.resolve()):
|
|
358
|
+
raise ArchiveError("discussion reconciliation journal targets another archive")
|
|
359
|
+
files = payload.get("files")
|
|
360
|
+
if (
|
|
361
|
+
not isinstance(files, dict)
|
|
362
|
+
or set(files) != set(DISCUSSION_FILES)
|
|
363
|
+
or any(not isinstance(files[name], str) for name in DISCUSSION_FILES)
|
|
364
|
+
):
|
|
365
|
+
raise ArchiveError("discussion reconciliation journal has invalid files")
|
|
366
|
+
source = active_root / "discuss"
|
|
367
|
+
if source.exists():
|
|
368
|
+
validate_discussion_directory(source)
|
|
369
|
+
active_files = {
|
|
370
|
+
name: (source / name).read_text(encoding="utf-8")
|
|
371
|
+
for name in DISCUSSION_FILES
|
|
372
|
+
}
|
|
373
|
+
if any(not active_files[name].startswith(files[name]) for name in DISCUSSION_FILES):
|
|
374
|
+
raise ArchiveError("active discussion diverged during archive reconciliation")
|
|
375
|
+
if active_files != files:
|
|
376
|
+
files = active_files
|
|
377
|
+
payload["files"] = files
|
|
378
|
+
archive_milestone.atomic_write(transaction, json.dumps(payload, sort_keys=True) + "\n")
|
|
379
|
+
destination = archive / "discuss"
|
|
380
|
+
require_safe_discussion_destination(active_root, archive, destination)
|
|
381
|
+
for name in DISCUSSION_FILES:
|
|
382
|
+
temporary = destination / f".{name}.gsd-path-tmp"
|
|
383
|
+
atomic_replace(destination / name, temporary, files[name])
|
|
384
|
+
validate_discussion_directory(destination)
|
|
385
|
+
if source.exists():
|
|
386
|
+
shutil.rmtree(source)
|
|
387
|
+
transaction.unlink()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def remove_interrupted_discussion_copies(active_root: Path, archive: Path) -> None:
|
|
391
|
+
if not (active_root / "discuss").exists():
|
|
392
|
+
return
|
|
393
|
+
directory = archive / "discuss"
|
|
394
|
+
if directory.exists() or directory.is_symlink():
|
|
395
|
+
require_safe_discussion_destination(active_root, archive, directory)
|
|
396
|
+
for name in DISCUSSION_FILES:
|
|
397
|
+
temporary = directory / f".{name}.gsd-path-tmp"
|
|
398
|
+
if temporary.exists() or temporary.is_symlink():
|
|
399
|
+
temporary.unlink()
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def remove_interrupted_carry_copy(active_root: Path, archive: Path) -> None:
|
|
403
|
+
archived_audit = archive / "research" / "DOCS-AUDIT.md"
|
|
404
|
+
if archive_milestone.pending_ruling_count(archived_audit) == 0:
|
|
405
|
+
return
|
|
406
|
+
temporary_path = active_root / "research" / CARRY_TEMP_NAME
|
|
407
|
+
if temporary_path.exists() or temporary_path.is_symlink():
|
|
408
|
+
temporary_path.unlink()
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def require_transaction_context(
|
|
412
|
+
project: Path, state: PipelineState, phases: Sequence[str]
|
|
413
|
+
) -> None:
|
|
414
|
+
phase = state.phase
|
|
415
|
+
if phase not in phases:
|
|
416
|
+
raise ArchiveError(f"archive transaction is not valid in phase: {phase}")
|
|
417
|
+
|
|
418
|
+
repository_root = archive_milestone.require_git_success(
|
|
419
|
+
run_git(project, "rev-parse", "--show-toplevel"),
|
|
420
|
+
"resolve Git root",
|
|
421
|
+
)
|
|
422
|
+
if Path(repository_root).resolve() != project:
|
|
423
|
+
raise ArchiveError(f"--repo must be the Git worktree root: {repository_root}")
|
|
424
|
+
|
|
425
|
+
expected_branch = state.branch
|
|
426
|
+
current_branch = archive_milestone.require_git_success(
|
|
427
|
+
run_git(project, "branch", "--show-current"),
|
|
428
|
+
"resolve current branch",
|
|
429
|
+
)
|
|
430
|
+
if archive_milestone.is_unset(expected_branch):
|
|
431
|
+
raise ArchiveError("STATE.md does not name a bound build branch")
|
|
432
|
+
if current_branch != expected_branch:
|
|
433
|
+
raise ArchiveError(
|
|
434
|
+
f"current branch {current_branch!r} does not match STATE.branch {expected_branch!r}"
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
non_project_status = archive_milestone.require_git_success(
|
|
438
|
+
run_git(
|
|
439
|
+
project,
|
|
440
|
+
"status",
|
|
441
|
+
"--porcelain",
|
|
442
|
+
"--untracked-files=all",
|
|
443
|
+
"--",
|
|
444
|
+
".",
|
|
445
|
+
":(exclude).project",
|
|
446
|
+
),
|
|
447
|
+
"inspect non-project worktree status",
|
|
448
|
+
)
|
|
449
|
+
if non_project_status:
|
|
450
|
+
raise ArchiveError("non-.project worktree changes block the archive transaction")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def completed_field(lines: Sequence[str], field: str, artifact: str) -> str:
|
|
454
|
+
values = [line.removeprefix(field).strip() for line in lines if line.startswith(field)]
|
|
455
|
+
if len(values) != 1 or not values[0] or archive_milestone.contains_placeholder(values[0]):
|
|
456
|
+
raise ArchiveError(f"{artifact} requires one completed {field} field")
|
|
457
|
+
return values[0]
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def completed_bullet_field(lines: Sequence[str], field: str, artifact: str) -> str:
|
|
461
|
+
pattern = re.compile(rf"^- \*\*{re.escape(field)}\*\*:\s*(.*)$")
|
|
462
|
+
values = []
|
|
463
|
+
for line in lines:
|
|
464
|
+
match = pattern.fullmatch(line)
|
|
465
|
+
if match:
|
|
466
|
+
values.append(match.group(1).strip().strip("`"))
|
|
467
|
+
if len(values) != 1 or not values[0] or archive_milestone.contains_placeholder(values[0]):
|
|
468
|
+
raise ArchiveError(f"{artifact} requires one completed {field} field")
|
|
469
|
+
return values[0]
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def section_lines(lines: Sequence[str], heading: str, artifact: str) -> Sequence[str]:
|
|
473
|
+
if lines.count(heading) != 1:
|
|
474
|
+
raise ArchiveError(f"{artifact} requires one {heading} section")
|
|
475
|
+
start = lines.index(heading) + 1
|
|
476
|
+
end = next(
|
|
477
|
+
(index for index in range(start, len(lines)) if lines[index].startswith("## ")),
|
|
478
|
+
len(lines),
|
|
479
|
+
)
|
|
480
|
+
return lines[start:end]
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def archived_intent_criteria(archive: Path) -> dict[int, str]:
|
|
484
|
+
intent = archive / "intent" / "INTENT.md"
|
|
485
|
+
if not archive_milestone.is_real_file(intent):
|
|
486
|
+
raise ArchiveError("archived intent must be a real INTENT.md file")
|
|
487
|
+
text = HTML_COMMENT_PATTERN.sub("", intent.read_text(encoding="utf-8"))
|
|
488
|
+
try:
|
|
489
|
+
import check_handoffs
|
|
490
|
+
except ImportError:
|
|
491
|
+
from scripts import check_handoffs
|
|
492
|
+
try:
|
|
493
|
+
criteria = check_handoffs._success_criteria(text)
|
|
494
|
+
except check_handoffs.HandoffError as error:
|
|
495
|
+
raise ArchiveError(str(error)) from error
|
|
496
|
+
return {int(key[2:]): " ".join(value.split()) for key, value in criteria.items()}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def parse_final_review(archive: Path) -> tuple:
|
|
500
|
+
try:
|
|
501
|
+
import check_handoffs
|
|
502
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
503
|
+
from scripts import check_handoffs
|
|
504
|
+
|
|
505
|
+
final = archive / "review" / "FINAL.md"
|
|
506
|
+
if not archive_milestone.is_real_file(final):
|
|
507
|
+
raise ArchiveError("final review must be a real FINAL.md file")
|
|
508
|
+
lines = final.read_text(encoding="utf-8").splitlines()
|
|
509
|
+
reviewed_head = completed_field(lines, "Reviewed HEAD:", "FINAL.md")
|
|
510
|
+
if not re.fullmatch(r"[0-9a-fA-F]{40}", reviewed_head):
|
|
511
|
+
raise ArchiveError("FINAL.md Reviewed HEAD must be a full commit SHA")
|
|
512
|
+
if completed_field(lines, "Overall verdict:", "FINAL.md") != "pass":
|
|
513
|
+
raise ArchiveError("FINAL.md final review did not pass")
|
|
514
|
+
if lines.count("## Success criteria") != 1:
|
|
515
|
+
raise ArchiveError("FINAL.md requires one Success criteria section")
|
|
516
|
+
|
|
517
|
+
start = lines.index("## Success criteria") + 1
|
|
518
|
+
headings = []
|
|
519
|
+
criteria_end = len(lines)
|
|
520
|
+
for index, line in enumerate(lines[start:], start=start):
|
|
521
|
+
if line.startswith("## "):
|
|
522
|
+
criteria_end = index
|
|
523
|
+
break
|
|
524
|
+
if line.startswith("### "):
|
|
525
|
+
match = FINAL_CRITERION_PATTERN.fullmatch(line)
|
|
526
|
+
if not match:
|
|
527
|
+
raise ArchiveError("FINAL.md has an invalid success-criterion heading")
|
|
528
|
+
headings.append((index, int(match.group(1)), match.group(2).strip()))
|
|
529
|
+
if not headings or [number for _, number, _ in headings] != list(range(1, len(headings) + 1)):
|
|
530
|
+
raise ArchiveError("FINAL.md success criteria must be ordered and contiguous")
|
|
531
|
+
intent_criteria = archived_intent_criteria(archive)
|
|
532
|
+
intent_text = (archive / "intent" / "INTENT.md").read_text(encoding="utf-8")
|
|
533
|
+
try:
|
|
534
|
+
surfaces = (
|
|
535
|
+
check_handoffs._surfaces(intent_text, "INTENT.md")
|
|
536
|
+
if re.search(r"(?m)^Surfaces:", intent_text) else []
|
|
537
|
+
)
|
|
538
|
+
except check_handoffs.HandoffError as error:
|
|
539
|
+
raise ArchiveError(str(error)) from error
|
|
540
|
+
if len(headings) != len(intent_criteria):
|
|
541
|
+
raise ArchiveError("FINAL.md success criteria do not exactly cover INTENT.md")
|
|
542
|
+
for _, number, criterion in headings:
|
|
543
|
+
if " ".join(criterion.split()) != intent_criteria[number]:
|
|
544
|
+
raise ArchiveError(
|
|
545
|
+
f"FINAL.md SC{number} heading text differs from archived INTENT.md"
|
|
546
|
+
)
|
|
547
|
+
|
|
548
|
+
expected_fields = ("Verdict", "Check", "Observed", "Reference", "Finding", "Fix direction")
|
|
549
|
+
criteria = []
|
|
550
|
+
for position, (heading_index, _, criterion) in enumerate(headings):
|
|
551
|
+
section_start = heading_index + 1
|
|
552
|
+
section_end = headings[position + 1][0] if position + 1 < len(headings) else criteria_end
|
|
553
|
+
values = {}
|
|
554
|
+
for line in lines[section_start:section_end]:
|
|
555
|
+
match = re.fullmatch(r"- \*\*(Verdict|Check|Observed|Reference|Finding|Fix direction)\*\*:\s*(.*)", line)
|
|
556
|
+
if not match:
|
|
557
|
+
continue
|
|
558
|
+
key, value = match.groups()
|
|
559
|
+
if key in values:
|
|
560
|
+
raise ArchiveError(f"FINAL.md repeats {key} for {criterion}")
|
|
561
|
+
values[key] = value.strip().strip("`")
|
|
562
|
+
surface_values = [
|
|
563
|
+
line.removeprefix("- **Surface**:").strip().strip("`")
|
|
564
|
+
for line in lines[section_start:section_end]
|
|
565
|
+
if line.startswith("- **Surface**:")
|
|
566
|
+
]
|
|
567
|
+
surface_evidence = len(surface_values) == 1 and any(
|
|
568
|
+
# Older final gates stripped terminal Markdown code delimiters.
|
|
569
|
+
" ".join(surface_values[0].split()).casefold()
|
|
570
|
+
== " ".join(surface.split()).strip("`").casefold()
|
|
571
|
+
for surface in surfaces
|
|
572
|
+
)
|
|
573
|
+
if tuple(values) != expected_fields:
|
|
574
|
+
raise ArchiveError(f"FINAL.md criterion is incomplete: {criterion}")
|
|
575
|
+
for field, value in values.items():
|
|
576
|
+
if surface_evidence and field in {"Check", "Observed"}:
|
|
577
|
+
try:
|
|
578
|
+
check_handoffs._surface_value(value, f"FINAL.md surface {field}")
|
|
579
|
+
except check_handoffs.HandoffError as error:
|
|
580
|
+
raise ArchiveError(str(error)) from error
|
|
581
|
+
elif not value or archive_milestone.contains_placeholder(value):
|
|
582
|
+
raise ArchiveError(f"FINAL.md criterion is incomplete: {criterion}")
|
|
583
|
+
if values["Verdict"] != "met":
|
|
584
|
+
raise ArchiveError(f"FINAL.md criterion lacks passing evidence: {criterion}")
|
|
585
|
+
evidence = next(
|
|
586
|
+
(values[field] for field in ("Reference", "Check", "Observed") if values[field] != "none"),
|
|
587
|
+
None,
|
|
588
|
+
)
|
|
589
|
+
if evidence is None:
|
|
590
|
+
raise ArchiveError(f"FINAL.md criterion lacks passing evidence: {criterion}")
|
|
591
|
+
criteria.append((criterion, "met", evidence))
|
|
592
|
+
return reviewed_head.lower(), criteria
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def validate_gap_reviews(archive: Path, reviewed_head: str) -> None:
|
|
596
|
+
review = archive / "review"
|
|
597
|
+
candidates = sorted(path for path in review.iterdir() if path.name.startswith("final-gap-"))
|
|
598
|
+
if not candidates:
|
|
599
|
+
raise ArchiveError("final review requires at least one final-gap-N.md artifact")
|
|
600
|
+
|
|
601
|
+
numbered = []
|
|
602
|
+
for path in candidates:
|
|
603
|
+
match = FINAL_GAP_FILE_PATTERN.fullmatch(path.name)
|
|
604
|
+
if not match or not archive_milestone.is_real_file(path):
|
|
605
|
+
raise ArchiveError("final gap artifacts must be real final-gap-N.md files")
|
|
606
|
+
numbered.append((int(match.group(1)), path))
|
|
607
|
+
numbered.sort(key=lambda item: item[0])
|
|
608
|
+
if [number for number, _ in numbered] != list(range(1, len(numbered) + 1)):
|
|
609
|
+
raise ArchiveError("final gap artifacts must be numbered contiguously")
|
|
610
|
+
|
|
611
|
+
for number, path in numbered:
|
|
612
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
613
|
+
headings = [FINAL_GAP_HEADING_PATTERN.fullmatch(line) for line in lines]
|
|
614
|
+
headings = [match for match in headings if match]
|
|
615
|
+
if (
|
|
616
|
+
len(headings) != 1
|
|
617
|
+
or int(headings[0].group(1)) != number
|
|
618
|
+
or archive_milestone.contains_placeholder(headings[0].group(2))
|
|
619
|
+
):
|
|
620
|
+
raise ArchiveError(f"{path.name} heading does not match its number")
|
|
621
|
+
gap_head = completed_field(lines, "Reviewed HEAD:", path.name).lower()
|
|
622
|
+
if gap_head != reviewed_head:
|
|
623
|
+
raise ArchiveError(f"{path.name} Reviewed HEAD does not match FINAL.md")
|
|
624
|
+
if completed_field(lines, "Gap verdict:", path.name) != "pass":
|
|
625
|
+
raise ArchiveError(f"{path.name} gap review did not pass")
|
|
626
|
+
risk = completed_field(lines, "Risk:", path.name)
|
|
627
|
+
if risk.casefold() == "none":
|
|
628
|
+
raise ArchiveError(f"{path.name} does not name a completed risk")
|
|
629
|
+
if (
|
|
630
|
+
" ".join(headings[0].group(2).split()).casefold()
|
|
631
|
+
!= " ".join(risk.split()).casefold()
|
|
632
|
+
):
|
|
633
|
+
raise ArchiveError(f"{path.name} heading risk does not match Risk field")
|
|
634
|
+
completed_field(lines, "Waves checked:", path.name)
|
|
635
|
+
evidence = section_lines(lines, "## Checked evidence", path.name)
|
|
636
|
+
check = completed_bullet_field(evidence, "Check", path.name)
|
|
637
|
+
observed = completed_bullet_field(evidence, "Observed", path.name)
|
|
638
|
+
reference = completed_bullet_field(evidence, "Reference", path.name)
|
|
639
|
+
if observed.casefold() == "none":
|
|
640
|
+
raise ArchiveError(f"{path.name} checked evidence has no observed result")
|
|
641
|
+
if check.casefold() == "none" and reference.casefold() == "none":
|
|
642
|
+
raise ArchiveError(f"{path.name} checked evidence has no command or reference")
|
|
643
|
+
finding = section_lines(lines, "## Finding", path.name)
|
|
644
|
+
found = completed_bullet_field(finding, "Found", path.name)
|
|
645
|
+
fix_direction = completed_bullet_field(finding, "Fix direction", path.name)
|
|
646
|
+
if found.casefold() == "none":
|
|
647
|
+
raise ArchiveError(f"{path.name} finding has no observed result")
|
|
648
|
+
if fix_direction.casefold() != "none":
|
|
649
|
+
raise ArchiveError(f"{path.name} pass must use Fix direction: none")
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def meaningful_review_evidence(lines: Sequence[str], marker: str, task_text: str = "") -> bool:
|
|
653
|
+
try:
|
|
654
|
+
from check_handoffs import task_review_observation
|
|
655
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
656
|
+
from scripts.check_handoffs import task_review_observation
|
|
657
|
+
|
|
658
|
+
prefix = f"- {marker} "
|
|
659
|
+
evidence = []
|
|
660
|
+
for line in lines:
|
|
661
|
+
stripped = line.strip()
|
|
662
|
+
if not stripped.startswith(prefix):
|
|
663
|
+
continue
|
|
664
|
+
item = task_review_observation(stripped.removeprefix(prefix), task_text)
|
|
665
|
+
# Keep the code spans intact: unquoting first would drop a leading or
|
|
666
|
+
# trailing backtick and unbalance every span after it, so quoted code
|
|
667
|
+
# would read as an unfilled placeholder.
|
|
668
|
+
evidence.append(item.strip())
|
|
669
|
+
return bool(evidence) and all(
|
|
670
|
+
item.strip("`")
|
|
671
|
+
and item.strip("`").casefold() not in {"none", "n/a", "null"}
|
|
672
|
+
and archive_milestone.PLACEHOLDER_PATTERN.fullmatch(item.strip("`")) is None
|
|
673
|
+
# Blank the code spans before the scan, so a placeholder-shaped token
|
|
674
|
+
# that is quoted evidence is not read as an unfilled placeholder.
|
|
675
|
+
and re.search(
|
|
676
|
+
r"(?<!\w)<[a-zA-Z][^<>\n]*>",
|
|
677
|
+
archive_milestone.CODE_SPAN_PATTERN.sub(" ", item),
|
|
678
|
+
) is None
|
|
679
|
+
for item in evidence
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def archived_wave_tasks(
|
|
684
|
+
archive: Path, wave_numbers: Sequence[int]
|
|
685
|
+
) -> dict[int, Sequence[tuple[str, str]]]:
|
|
686
|
+
tasks: dict[int, list[tuple[str, str]]] = {
|
|
687
|
+
wave: [] for wave in wave_numbers
|
|
688
|
+
}
|
|
689
|
+
seen = set()
|
|
690
|
+
for path in archive_milestone.canonical_task_files(archive / "tasks"):
|
|
691
|
+
content = path.read_text(encoding="utf-8")
|
|
692
|
+
task_id = archive_milestone.frontmatter_value(content, "id")
|
|
693
|
+
title = archive_milestone.frontmatter_value(content, "title")
|
|
694
|
+
wave_text = archive_milestone.frontmatter_value(content, "wave")
|
|
695
|
+
if task_id is None or title is None or wave_text is None:
|
|
696
|
+
raise ArchiveError(f"{path.name} is missing id, title, or wave")
|
|
697
|
+
if task_id in seen or not path.name.startswith(f"{task_id}-"):
|
|
698
|
+
raise ArchiveError(f"{path.name} has an invalid or repeated task id")
|
|
699
|
+
seen.add(task_id)
|
|
700
|
+
try:
|
|
701
|
+
wave = int(wave_text)
|
|
702
|
+
except ValueError as error:
|
|
703
|
+
raise ArchiveError(f"{path.name} has an invalid wave") from error
|
|
704
|
+
if wave not in tasks:
|
|
705
|
+
raise ArchiveError(f"{path.name} names unknown Wave {wave}")
|
|
706
|
+
if archive_milestone.contains_placeholder(title):
|
|
707
|
+
raise ArchiveError(f"{path.name} has an incomplete title")
|
|
708
|
+
tasks[wave].append((task_id, title))
|
|
709
|
+
for wave, members in tasks.items():
|
|
710
|
+
if not members:
|
|
711
|
+
raise ArchiveError(f"plan Wave {wave} has no task files")
|
|
712
|
+
return tasks
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def plan_wave_criteria(
|
|
716
|
+
archive: Path,
|
|
717
|
+
plan_text: str,
|
|
718
|
+
wave_tasks: dict[int, Sequence[tuple[str, str]]],
|
|
719
|
+
) -> dict[int, Sequence[tuple[str, str]]]:
|
|
720
|
+
intent_path = archive / "intent" / "INTENT.md"
|
|
721
|
+
if not intent_path.exists() and not intent_path.is_symlink():
|
|
722
|
+
return {wave: () for wave in wave_tasks}
|
|
723
|
+
criteria = archived_intent_criteria(archive)
|
|
724
|
+
lines = plan_text.splitlines()
|
|
725
|
+
if lines.count("## Intent coverage") != 1:
|
|
726
|
+
raise ArchiveError("PLAN.md requires one Intent coverage section")
|
|
727
|
+
coverage = section_lines(lines, "## Intent coverage", "PLAN.md")
|
|
728
|
+
rows = []
|
|
729
|
+
for line in coverage:
|
|
730
|
+
if not re.match(r"^\|\s*SC\d", line.strip()):
|
|
731
|
+
continue
|
|
732
|
+
match = PLAN_COVERAGE_ROW_PATTERN.fullmatch(line.strip())
|
|
733
|
+
if match is None:
|
|
734
|
+
raise ArchiveError("PLAN.md has a malformed Intent coverage row")
|
|
735
|
+
rows.append((match.group(1), match.group(2), match.group(3).strip()))
|
|
736
|
+
if not rows:
|
|
737
|
+
raise ArchiveError("PLAN.md Intent coverage has no rows")
|
|
738
|
+
known_tasks = {
|
|
739
|
+
task_id for tasks in wave_tasks.values() for task_id, _title in tasks
|
|
740
|
+
}
|
|
741
|
+
seen = set()
|
|
742
|
+
for sc_id, task_id, acceptance in rows:
|
|
743
|
+
number = int(sc_id.removeprefix("SC"))
|
|
744
|
+
if number not in criteria:
|
|
745
|
+
raise ArchiveError(f"PLAN.md Intent coverage names unknown {sc_id}")
|
|
746
|
+
if task_id not in known_tasks:
|
|
747
|
+
raise ArchiveError(f"PLAN.md Intent coverage names unknown {task_id}")
|
|
748
|
+
if not acceptance or archive_milestone.contains_placeholder(acceptance):
|
|
749
|
+
raise ArchiveError("PLAN.md Intent coverage has incomplete acceptance evidence")
|
|
750
|
+
if (sc_id, task_id, acceptance) in seen:
|
|
751
|
+
raise ArchiveError("PLAN.md Intent coverage repeats a row")
|
|
752
|
+
seen.add((sc_id, task_id, acceptance))
|
|
753
|
+
result = {}
|
|
754
|
+
for wave, tasks in wave_tasks.items():
|
|
755
|
+
task_ids = {task_id for task_id, _title in tasks}
|
|
756
|
+
owned_numbers = sorted(
|
|
757
|
+
{
|
|
758
|
+
int(sc_id.removeprefix("SC"))
|
|
759
|
+
for sc_id, task_id, _acceptance in rows
|
|
760
|
+
if task_id in task_ids
|
|
761
|
+
}
|
|
762
|
+
)
|
|
763
|
+
result[wave] = tuple(
|
|
764
|
+
(f"SC{number}", criteria[number]) for number in owned_numbers
|
|
765
|
+
)
|
|
766
|
+
return result
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def validate_wave_review(
|
|
770
|
+
path: Path,
|
|
771
|
+
expected_tasks: Sequence[tuple[str, str]],
|
|
772
|
+
expected_criteria: Sequence[tuple[str, str]],
|
|
773
|
+
expected_depth: str,
|
|
774
|
+
expected_lens: Optional[str] = None,
|
|
775
|
+
task_texts: Optional[dict[str, str]] = None,
|
|
776
|
+
) -> Sequence[str]:
|
|
777
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
778
|
+
depth = completed_field(lines, "Depth:", path.name)
|
|
779
|
+
if depth != expected_depth:
|
|
780
|
+
raise ArchiveError(f"{path.name} has an invalid review depth")
|
|
781
|
+
if expected_lens is not None:
|
|
782
|
+
lens = completed_field(lines, "Lens:", path.name)
|
|
783
|
+
if lens != expected_lens:
|
|
784
|
+
raise ArchiveError(f"{path.name} Lens field does not match its filename")
|
|
785
|
+
wave_verdict = completed_field(lines, "Wave verdict:", path.name)
|
|
786
|
+
reviewed = completed_field(lines, "Tasks reviewed:", path.name)
|
|
787
|
+
if not reviewed.isdigit() or int(reviewed) < 1:
|
|
788
|
+
raise ArchiveError(f"{path.name} requires a positive task count")
|
|
789
|
+
|
|
790
|
+
headings = []
|
|
791
|
+
for index, line in enumerate(lines):
|
|
792
|
+
match = WAVE_TASK_HEADING_PATTERN.fullmatch(line)
|
|
793
|
+
if match:
|
|
794
|
+
headings.append((index, match.group(1), match.group(2), match.group(3)))
|
|
795
|
+
if len(headings) != int(reviewed):
|
|
796
|
+
raise ArchiveError(f"{path.name} task count does not match Tasks reviewed")
|
|
797
|
+
task_ids = [task_id for _, task_id, _, _ in headings]
|
|
798
|
+
if len(task_ids) != len(set(task_ids)):
|
|
799
|
+
raise ArchiveError(f"{path.name} repeats a reviewed task")
|
|
800
|
+
actual_tasks = [(task_id, " ".join(title.split())) for _, task_id, title, _ in headings]
|
|
801
|
+
normalized_expected = [
|
|
802
|
+
(task_id, " ".join(title.split())) for task_id, title in expected_tasks
|
|
803
|
+
]
|
|
804
|
+
if actual_tasks != normalized_expected:
|
|
805
|
+
raise ArchiveError(
|
|
806
|
+
f"{path.name} tasks and titles do not match its wave task files in order"
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
verdicts = []
|
|
810
|
+
for position, (heading_index, _task_id, _title, verdict) in enumerate(headings):
|
|
811
|
+
next_task = (
|
|
812
|
+
headings[position + 1][0]
|
|
813
|
+
if position + 1 < len(headings)
|
|
814
|
+
else len(lines)
|
|
815
|
+
)
|
|
816
|
+
next_section = next(
|
|
817
|
+
(
|
|
818
|
+
index
|
|
819
|
+
for index in range(heading_index + 1, next_task)
|
|
820
|
+
if lines[index].startswith("## ")
|
|
821
|
+
),
|
|
822
|
+
next_task,
|
|
823
|
+
)
|
|
824
|
+
task_lines = lines[heading_index + 1 : next_section]
|
|
825
|
+
marker = "✅" if verdict == "pass" else "❌"
|
|
826
|
+
if not meaningful_review_evidence(task_lines, marker, (task_texts or {}).get(_task_id, "")):
|
|
827
|
+
raise ArchiveError(
|
|
828
|
+
f"{path.name} task {task_ids[position]} lacks non-placeholder evidence"
|
|
829
|
+
)
|
|
830
|
+
verdicts.append(verdict)
|
|
831
|
+
|
|
832
|
+
coverage_indexes = [
|
|
833
|
+
index for index, line in enumerate(lines) if line == "## Intent coverage"
|
|
834
|
+
]
|
|
835
|
+
if not expected_criteria:
|
|
836
|
+
if coverage_indexes:
|
|
837
|
+
start = coverage_indexes[0] + 1
|
|
838
|
+
end = next(
|
|
839
|
+
(
|
|
840
|
+
index
|
|
841
|
+
for index in range(start, len(lines))
|
|
842
|
+
if lines[index].startswith("## ")
|
|
843
|
+
),
|
|
844
|
+
len(lines),
|
|
845
|
+
)
|
|
846
|
+
if any(WAVE_SC_HEADING_PATTERN.fullmatch(line) for line in lines[start:end]):
|
|
847
|
+
raise ArchiveError(f"{path.name} names unowned success criteria")
|
|
848
|
+
return verdicts
|
|
849
|
+
if len(coverage_indexes) != 1:
|
|
850
|
+
raise ArchiveError(f"{path.name} requires one Intent coverage section")
|
|
851
|
+
coverage_start = coverage_indexes[0] + 1
|
|
852
|
+
coverage_end = next(
|
|
853
|
+
(
|
|
854
|
+
index
|
|
855
|
+
for index in range(coverage_start, len(lines))
|
|
856
|
+
if lines[index].startswith("## ")
|
|
857
|
+
),
|
|
858
|
+
len(lines),
|
|
859
|
+
)
|
|
860
|
+
criterion_headings = []
|
|
861
|
+
for index in range(coverage_start, coverage_end):
|
|
862
|
+
line = lines[index]
|
|
863
|
+
if not line.startswith("### "):
|
|
864
|
+
continue
|
|
865
|
+
match = WAVE_SC_HEADING_PATTERN.fullmatch(line)
|
|
866
|
+
if match is None:
|
|
867
|
+
raise ArchiveError(f"{path.name} has an invalid Intent coverage heading")
|
|
868
|
+
criterion_headings.append(
|
|
869
|
+
(index, match.group(1), " ".join(match.group(2).split()), match.group(3))
|
|
870
|
+
)
|
|
871
|
+
actual = [(sc_id, text) for _, sc_id, text, _ in criterion_headings]
|
|
872
|
+
normalized_expected_criteria = [
|
|
873
|
+
(sc_id, " ".join(text.split())) for sc_id, text in expected_criteria
|
|
874
|
+
]
|
|
875
|
+
if actual != normalized_expected_criteria:
|
|
876
|
+
raise ArchiveError(
|
|
877
|
+
f"{path.name} Intent coverage does not match its owned success criteria"
|
|
878
|
+
)
|
|
879
|
+
for position, (heading_index, sc_id, _text, verdict) in enumerate(
|
|
880
|
+
criterion_headings
|
|
881
|
+
):
|
|
882
|
+
section_end = (
|
|
883
|
+
criterion_headings[position + 1][0]
|
|
884
|
+
if position + 1 < len(criterion_headings)
|
|
885
|
+
else coverage_end
|
|
886
|
+
)
|
|
887
|
+
marker = "✅" if verdict == "pass" else "❌"
|
|
888
|
+
if not meaningful_review_evidence(
|
|
889
|
+
lines[heading_index + 1 : section_end], marker
|
|
890
|
+
):
|
|
891
|
+
raise ArchiveError(f"{path.name} {sc_id} lacks non-placeholder evidence")
|
|
892
|
+
if verdict == "fail" and wave_verdict == "pass":
|
|
893
|
+
raise ArchiveError(
|
|
894
|
+
f"{path.name} passes while owned success criterion {sc_id} fails"
|
|
895
|
+
)
|
|
896
|
+
return verdicts
|
|
897
|
+
|
|
898
|
+
|
|
899
|
+
def plan_wave_depths(plan_text: str) -> tuple[Sequence[int], dict[int, str]]:
|
|
900
|
+
wave_matches = list(re.finditer(r"^## Wave (\d+)\b", plan_text, re.MULTILINE))
|
|
901
|
+
wave_numbers = [int(match.group(1)) for match in wave_matches]
|
|
902
|
+
if not wave_numbers or wave_numbers != list(range(1, len(wave_numbers) + 1)):
|
|
903
|
+
raise ArchiveError("plan wave numbers must be ordered and contiguous")
|
|
904
|
+
wave_depths = {}
|
|
905
|
+
for index, match in enumerate(wave_matches):
|
|
906
|
+
section_end = (
|
|
907
|
+
wave_matches[index + 1].start()
|
|
908
|
+
if index + 1 < len(wave_matches)
|
|
909
|
+
else len(plan_text)
|
|
910
|
+
)
|
|
911
|
+
section = plan_text[match.end() : section_end]
|
|
912
|
+
depths = re.findall(
|
|
913
|
+
r"^Review depth:\s*(\S+)",
|
|
914
|
+
section,
|
|
915
|
+
re.MULTILINE,
|
|
916
|
+
)
|
|
917
|
+
wave = int(match.group(1))
|
|
918
|
+
if len(depths) > 1:
|
|
919
|
+
raise ArchiveError(f"plan wave {wave} has multiple review depths")
|
|
920
|
+
depth = depths[0] if depths else "full"
|
|
921
|
+
if depth not in {"full", "deep", "verify-only"}:
|
|
922
|
+
raise ArchiveError(f"plan wave {wave} has an invalid review depth")
|
|
923
|
+
wave_depths[wave] = depth
|
|
924
|
+
return wave_numbers, wave_depths
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def review_cycle_counts(archive: Path) -> Sequence[int]:
|
|
928
|
+
plan = archive / "plan" / "PLAN.md"
|
|
929
|
+
plan_text = plan.read_text(encoding="utf-8")
|
|
930
|
+
wave_numbers, wave_depths = plan_wave_depths(plan_text)
|
|
931
|
+
wave_tasks = archived_wave_tasks(archive, wave_numbers)
|
|
932
|
+
task_texts = {
|
|
933
|
+
path.name.split("-", 1)[0]: path.read_text(encoding="utf-8")
|
|
934
|
+
for path in archive_milestone.canonical_task_files(archive / "tasks")
|
|
935
|
+
}
|
|
936
|
+
wave_criteria = plan_wave_criteria(archive, plan_text, wave_tasks)
|
|
937
|
+
|
|
938
|
+
artifacts = {}
|
|
939
|
+
panel_config = archive_milestone.plan_review_panel_config(plan_text)
|
|
940
|
+
panel_skip_receipts = archive_milestone.canonical_wave_panel_skip_files(archive / "review")
|
|
941
|
+
repair_receipts = archive_milestone.canonical_wave_repair_files(archive / "review")
|
|
942
|
+
repair_introductions = {}
|
|
943
|
+
for receipt in repair_receipts:
|
|
944
|
+
task_text = task_texts.get(receipt.task)
|
|
945
|
+
if task_text is None or "## Review findings" not in task_text:
|
|
946
|
+
raise ArchiveError(
|
|
947
|
+
f"{receipt.path.name} does not name an archived repair task"
|
|
948
|
+
)
|
|
949
|
+
if receipt.task in repair_introductions:
|
|
950
|
+
raise ArchiveError(f"multiple repair receipts name {receipt.task}")
|
|
951
|
+
repair_introductions[receipt.task] = (receipt.wave, receipt.cycle)
|
|
952
|
+
wave_artifacts = archive_milestone.canonical_wave_files(archive / "review")
|
|
953
|
+
archive_milestone.validate_wave_skeptic_files(archive / "review", wave_depths, wave_artifacts)
|
|
954
|
+
for path, wave, cycle, lens in wave_artifacts:
|
|
955
|
+
if lens == "panel":
|
|
956
|
+
artifacts.setdefault(wave, {}).setdefault(cycle, {})[lens] = path
|
|
957
|
+
continue
|
|
958
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
959
|
+
expected_heading = f"# Review — wave {wave}, cycle {cycle}"
|
|
960
|
+
if lines.count(expected_heading) != 1:
|
|
961
|
+
raise ArchiveError(f"{path.name} heading does not match its filename")
|
|
962
|
+
recorded_cycle = completed_field(lines, "Cycle:", path.name)
|
|
963
|
+
if recorded_cycle != str(cycle):
|
|
964
|
+
raise ArchiveError(f"{path.name} Cycle field does not match its filename")
|
|
965
|
+
verdict = completed_field(lines, "Wave verdict:", path.name)
|
|
966
|
+
if verdict not in {"pass", "blocked"}:
|
|
967
|
+
raise ArchiveError(f"{path.name} has an invalid wave verdict")
|
|
968
|
+
artifacts.setdefault(wave, {}).setdefault(cycle, {})[lens] = path
|
|
969
|
+
if sorted(artifacts) != wave_numbers:
|
|
970
|
+
raise ArchiveError("review cycle waves do not match PLAN.md")
|
|
971
|
+
for receipt in repair_receipts:
|
|
972
|
+
if receipt.wave not in artifacts or receipt.cycle not in artifacts[receipt.wave]:
|
|
973
|
+
raise ArchiveError(
|
|
974
|
+
f"{receipt.path.name} does not match an archived review cycle"
|
|
975
|
+
)
|
|
976
|
+
if (
|
|
977
|
+
any(task_id == receipt.task for task_id, _ in wave_tasks[receipt.wave])
|
|
978
|
+
and max(artifacts[receipt.wave]) <= receipt.cycle
|
|
979
|
+
):
|
|
980
|
+
raise ArchiveError(
|
|
981
|
+
f"{receipt.path.name} requires a later review cycle for its repair task"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
counts = []
|
|
985
|
+
for wave in wave_numbers:
|
|
986
|
+
cycles = artifacts[wave]
|
|
987
|
+
if sorted(cycles) != list(range(1, max(cycles) + 1)):
|
|
988
|
+
raise ArchiveError(f"review cycles for wave {wave} are not contiguous")
|
|
989
|
+
wave_verdicts = {}
|
|
990
|
+
task_verdicts = {}
|
|
991
|
+
for cycle, reviews in cycles.items():
|
|
992
|
+
depth = wave_depths[wave]
|
|
993
|
+
gate = {lens: path for lens, path in reviews.items() if lens != "panel"}
|
|
994
|
+
if depth == "deep":
|
|
995
|
+
if set(gate) != {"contract", "adversarial"}:
|
|
996
|
+
raise ArchiveError(
|
|
997
|
+
f"wave {wave} cycle {cycle} requires contract and adversarial "
|
|
998
|
+
"reviews for PLAN depth deep"
|
|
999
|
+
)
|
|
1000
|
+
expected = tuple(
|
|
1001
|
+
(gate[lens], depth, lens) for lens in ("contract", "adversarial")
|
|
1002
|
+
)
|
|
1003
|
+
else:
|
|
1004
|
+
if set(gate) != {None}:
|
|
1005
|
+
raise ArchiveError(
|
|
1006
|
+
f"wave {wave} cycle {cycle} requires one base review "
|
|
1007
|
+
f"for PLAN depth {depth}"
|
|
1008
|
+
)
|
|
1009
|
+
expected = ((gate[None], depth, None),)
|
|
1010
|
+
receipt = panel_skip_receipts.pop((wave, cycle), None)
|
|
1011
|
+
if panel_config["mode"] != "off" and depth in {"full", "deep"}:
|
|
1012
|
+
has_panel = "panel" in reviews
|
|
1013
|
+
if has_panel == (receipt is not None):
|
|
1014
|
+
raise ArchiveError(
|
|
1015
|
+
f"wave {wave} cycle {cycle} requires exactly one panel artifact "
|
|
1016
|
+
"or canonical skipped-panel receipt"
|
|
1017
|
+
)
|
|
1018
|
+
if receipt is not None:
|
|
1019
|
+
archive_milestone.validate_panel_skip_receipt(
|
|
1020
|
+
receipt, str(panel_config["mode"])
|
|
1021
|
+
)
|
|
1022
|
+
elif receipt is not None:
|
|
1023
|
+
raise ArchiveError(f"unexpected skipped-panel receipt: {receipt.name}")
|
|
1024
|
+
|
|
1025
|
+
wave_verdicts[cycle] = []
|
|
1026
|
+
task_verdicts[cycle] = []
|
|
1027
|
+
expected_tasks = [
|
|
1028
|
+
task
|
|
1029
|
+
for task in wave_tasks[wave]
|
|
1030
|
+
if task[0] not in repair_introductions
|
|
1031
|
+
or repair_introductions[task[0]][0] != wave
|
|
1032
|
+
or cycle > repair_introductions[task[0]][1]
|
|
1033
|
+
]
|
|
1034
|
+
for path, depth, lens in expected:
|
|
1035
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
1036
|
+
wave_verdicts[cycle].append(completed_field(lines, "Wave verdict:", path.name))
|
|
1037
|
+
task_verdicts[cycle].extend(
|
|
1038
|
+
validate_wave_review(
|
|
1039
|
+
path,
|
|
1040
|
+
expected_tasks,
|
|
1041
|
+
wave_criteria[wave],
|
|
1042
|
+
depth,
|
|
1043
|
+
lens,
|
|
1044
|
+
task_texts,
|
|
1045
|
+
)
|
|
1046
|
+
)
|
|
1047
|
+
|
|
1048
|
+
last_cycle = max(cycles)
|
|
1049
|
+
if any(verdict != "pass" for verdict in wave_verdicts[last_cycle]):
|
|
1050
|
+
raise ArchiveError(f"last review cycle for wave {wave} did not pass")
|
|
1051
|
+
if any(task_verdict != "pass" for task_verdict in task_verdicts[last_cycle]):
|
|
1052
|
+
raise ArchiveError(f"last review cycle for wave {wave} has a failed task")
|
|
1053
|
+
counts.append(last_cycle)
|
|
1054
|
+
if panel_skip_receipts:
|
|
1055
|
+
names = ", ".join(path.name for path in panel_skip_receipts.values())
|
|
1056
|
+
raise ArchiveError(f"skipped-panel receipts do not match review cycles: {names}")
|
|
1057
|
+
return counts
|
|
1058
|
+
|
|
1059
|
+
|
|
1060
|
+
def manifest_cell(value: str) -> str:
|
|
1061
|
+
return value.replace("\\|", "|").replace("|", "\\|")
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def archive_file_inventory(archive: Path) -> Sequence[str]:
|
|
1065
|
+
contents = []
|
|
1066
|
+
for path in archive.rglob("*"):
|
|
1067
|
+
if path.is_symlink():
|
|
1068
|
+
raise ArchiveError(f"archive contents must not be symlinks: {path}")
|
|
1069
|
+
if path.is_file() and path.name != "MANIFEST.md":
|
|
1070
|
+
contents.append(path.relative_to(archive).as_posix())
|
|
1071
|
+
return sorted(contents)
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def render_manifest(repo: Path) -> dict:
|
|
1075
|
+
project = repo.resolve()
|
|
1076
|
+
active_root = archive_milestone.require_project_layout(project)
|
|
1077
|
+
state, _, loaded_state_path = archive_milestone.strict_state(project)
|
|
1078
|
+
if loaded_state_path.resolve() != (active_root / "STATE.md").resolve():
|
|
1079
|
+
raise ArchiveError("strict STATE.md loader returned an unexpected path")
|
|
1080
|
+
require_transaction_context(project, state, ("ship", "shipped"))
|
|
1081
|
+
phase = state.phase
|
|
1082
|
+
status = state.status
|
|
1083
|
+
if (phase, status) not in {("ship", "active"), ("shipped", "done")}:
|
|
1084
|
+
raise ArchiveError(
|
|
1085
|
+
"manifest rendering requires ship/active or uncommitted shipped/done"
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
configured = state.archive
|
|
1089
|
+
if configured is None:
|
|
1090
|
+
raise ArchiveError("STATE.md does not name the archive transaction")
|
|
1091
|
+
archive = archive_milestone.archive_from_state(project, configured)
|
|
1092
|
+
archive_milestone.require_archive_milestone(archive.name, state)
|
|
1093
|
+
archive_milestone.require_uncommitted_archive(project, configured)
|
|
1094
|
+
if archive.is_symlink() or not archive.is_dir():
|
|
1095
|
+
raise ArchiveError("archive is missing")
|
|
1096
|
+
archive_milestone.require_canonical_archive(archive)
|
|
1097
|
+
archive_milestone.require_clean_active_root(active_root, archive)
|
|
1098
|
+
|
|
1099
|
+
reviewed_head, criteria = parse_final_review(archive)
|
|
1100
|
+
validate_gap_reviews(archive, reviewed_head)
|
|
1101
|
+
tasks, attested = archive_milestone.landed_task_evidence(project, archive / "tasks", reviewed_head)
|
|
1102
|
+
cycles = review_cycle_counts(archive)
|
|
1103
|
+
carried_forward = archive_milestone.pending_ruling_count(archive / "research" / "DOCS-AUDIT.md")
|
|
1104
|
+
carry_text = (
|
|
1105
|
+
"none"
|
|
1106
|
+
if carried_forward == 0
|
|
1107
|
+
else f"{carried_forward} DOCS-AUDIT ruling(s)"
|
|
1108
|
+
)
|
|
1109
|
+
criteria_rows = "\n".join(
|
|
1110
|
+
f"| {manifest_cell(criterion)} | {verdict} | {manifest_cell(evidence)} |"
|
|
1111
|
+
for criterion, verdict, evidence in criteria
|
|
1112
|
+
)
|
|
1113
|
+
listed_contents = "\n".join(f"- {path}" for path in archive_file_inventory(archive))
|
|
1114
|
+
cycle_text = "/".join(str(cycle) for cycle in cycles)
|
|
1115
|
+
count_text = (
|
|
1116
|
+
f"{len(cycles)} Tasks: {len(tasks)} done / {len(tasks)} total "
|
|
1117
|
+
f"Review cycles used: {cycle_text}"
|
|
1118
|
+
+ (f" Attested: {attested}" if attested else "")
|
|
1119
|
+
)
|
|
1120
|
+
content = f"""# Archive — {archive.name}
|
|
1121
|
+
|
|
1122
|
+
Milestone: {state.milestone}
|
|
1123
|
+
Shipped: {date.today().isoformat()}
|
|
1124
|
+
Final verdict: all criteria met; project verify passed
|
|
1125
|
+
Waves: {count_text}
|
|
1126
|
+
Carried forward: {carry_text}
|
|
1127
|
+
|
|
1128
|
+
## Success criteria at ship
|
|
1129
|
+
|
|
1130
|
+
| Criterion | Verdict | Evidence |
|
|
1131
|
+
|-----------|---------|----------|
|
|
1132
|
+
{criteria_rows}
|
|
1133
|
+
|
|
1134
|
+
## Contents
|
|
1135
|
+
|
|
1136
|
+
{listed_contents}
|
|
1137
|
+
|
|
1138
|
+
## Notes
|
|
1139
|
+
|
|
1140
|
+
- none
|
|
1141
|
+
"""
|
|
1142
|
+
atomic_replace(archive / "MANIFEST.md", archive / MANIFEST_TEMP_NAME, content)
|
|
1143
|
+
validate_manifest(project, archive, state)
|
|
1144
|
+
return {"archive": configured, "reviewed_head": reviewed_head}
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def validate_manifest(project: Path, archive: Path, state: PipelineState) -> tuple:
|
|
1148
|
+
manifest = archive / "MANIFEST.md"
|
|
1149
|
+
if manifest.is_symlink() or not manifest.is_file():
|
|
1150
|
+
raise ArchiveError("archive MANIFEST.md must be a real file")
|
|
1151
|
+
content = manifest.read_text(encoding="utf-8")
|
|
1152
|
+
lines = content.splitlines()
|
|
1153
|
+
|
|
1154
|
+
if lines.count(f"# Archive — {archive.name}") != 1:
|
|
1155
|
+
raise ArchiveError("manifest header does not match the archive name")
|
|
1156
|
+
fields = {field: completed_field(lines, field, "manifest") for field in MANIFEST_FIELDS}
|
|
1157
|
+
for heading in MANIFEST_HEADINGS:
|
|
1158
|
+
if lines.count(heading) != 1:
|
|
1159
|
+
raise ArchiveError(f"manifest requires one {heading} section")
|
|
1160
|
+
|
|
1161
|
+
milestone = state.milestone
|
|
1162
|
+
if fields["Milestone:"] != milestone:
|
|
1163
|
+
raise ArchiveError(
|
|
1164
|
+
f"manifest milestone {fields['Milestone:']!r} does not match STATE.milestone {milestone!r}"
|
|
1165
|
+
)
|
|
1166
|
+
try:
|
|
1167
|
+
date.fromisoformat(fields["Shipped:"])
|
|
1168
|
+
except ValueError:
|
|
1169
|
+
raise ArchiveError("manifest Shipped field must be an ISO date")
|
|
1170
|
+
if fields["Final verdict:"] != "all criteria met; project verify passed":
|
|
1171
|
+
raise ArchiveError("manifest final verdict does not record the passed final gate")
|
|
1172
|
+
|
|
1173
|
+
reviewed_head, final_criteria = parse_final_review(archive)
|
|
1174
|
+
validate_gap_reviews(archive, reviewed_head)
|
|
1175
|
+
task_files, attested = archive_milestone.landed_task_evidence(project, archive / "tasks", reviewed_head)
|
|
1176
|
+
cycle_counts = review_cycle_counts(archive)
|
|
1177
|
+
wave_count = len(cycle_counts)
|
|
1178
|
+
counts = re.fullmatch(
|
|
1179
|
+
r"(\d+)\s+Tasks:\s+(\d+) done / (\d+) total\s+Review cycles used:\s+([1-9]\d*(?:/[1-9]\d*)*)"
|
|
1180
|
+
r"(?:\s+Attested:\s+([1-9]\d*))?",
|
|
1181
|
+
fields["Waves:"],
|
|
1182
|
+
)
|
|
1183
|
+
if not counts:
|
|
1184
|
+
raise ArchiveError("manifest Waves field has an invalid format")
|
|
1185
|
+
recorded_waves, done_tasks, total_tasks = (int(counts.group(index)) for index in (1, 2, 3))
|
|
1186
|
+
cycles = [int(value) for value in counts.group(4).split("/")]
|
|
1187
|
+
recorded_attested = int(counts.group(5) or 0)
|
|
1188
|
+
if (
|
|
1189
|
+
recorded_waves != wave_count
|
|
1190
|
+
or done_tasks != len(task_files)
|
|
1191
|
+
or total_tasks != len(task_files)
|
|
1192
|
+
or cycles != list(cycle_counts)
|
|
1193
|
+
or recorded_attested != attested
|
|
1194
|
+
):
|
|
1195
|
+
raise ArchiveError("manifest wave, task, or review-cycle counts do not match the archive")
|
|
1196
|
+
|
|
1197
|
+
carried_forward = archive_milestone.pending_ruling_count(archive / "research" / "DOCS-AUDIT.md")
|
|
1198
|
+
expected_carry = (
|
|
1199
|
+
"none" if carried_forward == 0 else f"{carried_forward} DOCS-AUDIT ruling(s)"
|
|
1200
|
+
)
|
|
1201
|
+
if fields["Carried forward:"] != expected_carry:
|
|
1202
|
+
raise ArchiveError("manifest carried-forward count does not match DOCS-AUDIT.md")
|
|
1203
|
+
|
|
1204
|
+
criteria_start = lines.index("## Success criteria at ship") + 1
|
|
1205
|
+
criteria_rows = []
|
|
1206
|
+
criteria_line_indexes = set()
|
|
1207
|
+
for index, line in enumerate(lines[criteria_start:], criteria_start):
|
|
1208
|
+
if line.startswith("## "):
|
|
1209
|
+
break
|
|
1210
|
+
if not line.startswith("|"):
|
|
1211
|
+
continue
|
|
1212
|
+
cells = archive_milestone.split_manifest_row(line)
|
|
1213
|
+
if cells and (cells[0] == "Criterion" or set(cells[0]) <= {"-", ":"}):
|
|
1214
|
+
continue
|
|
1215
|
+
criteria_rows.append(cells)
|
|
1216
|
+
criteria_line_indexes.add(index)
|
|
1217
|
+
if not criteria_rows or any(
|
|
1218
|
+
len(row) != 3
|
|
1219
|
+
or not row[0]
|
|
1220
|
+
or row[1] != "met"
|
|
1221
|
+
or not row[2]
|
|
1222
|
+
or row[2] == "none"
|
|
1223
|
+
or any(archive_milestone.contains_placeholder(cell) for cell in row[1:])
|
|
1224
|
+
for row in criteria_rows
|
|
1225
|
+
):
|
|
1226
|
+
raise ArchiveError("manifest success-criteria rows are incomplete")
|
|
1227
|
+
if [tuple(row) for row in criteria_rows] != list(final_criteria):
|
|
1228
|
+
raise ArchiveError("manifest success criteria do not match FINAL.md evidence in order")
|
|
1229
|
+
|
|
1230
|
+
# Exact validated criterion rows may quote CLI notation such as <integer>.
|
|
1231
|
+
# Their other cells were checked above; every remaining line stays guarded.
|
|
1232
|
+
placeholder_content = "\n".join(
|
|
1233
|
+
line for index, line in enumerate(lines) if index not in criteria_line_indexes
|
|
1234
|
+
).replace("<!--", "").replace("-->", "")
|
|
1235
|
+
if archive_milestone.contains_placeholder(placeholder_content):
|
|
1236
|
+
raise ArchiveError("manifest contains an unfinished template placeholder")
|
|
1237
|
+
|
|
1238
|
+
contents_start = lines.index("## Contents") + 1
|
|
1239
|
+
listed = []
|
|
1240
|
+
for line in lines[contents_start:]:
|
|
1241
|
+
if line.startswith("## "):
|
|
1242
|
+
break
|
|
1243
|
+
if line.startswith("- "):
|
|
1244
|
+
listed.append(line[2:].strip().strip("`"))
|
|
1245
|
+
|
|
1246
|
+
if len(listed) != len(set(listed)):
|
|
1247
|
+
raise ArchiveError("manifest contents contain duplicate paths")
|
|
1248
|
+
for value in listed:
|
|
1249
|
+
path = PurePosixPath(value)
|
|
1250
|
+
if not value or path.is_absolute() or ".." in path.parts or value == "MANIFEST.md":
|
|
1251
|
+
raise ArchiveError(f"manifest contains an invalid path: {value}")
|
|
1252
|
+
|
|
1253
|
+
actual = archive_file_inventory(archive)
|
|
1254
|
+
if sorted(listed) != actual:
|
|
1255
|
+
missing = sorted(set(actual) - set(listed))
|
|
1256
|
+
extra = sorted(set(listed) - set(actual))
|
|
1257
|
+
raise ArchiveError(f"manifest contents mismatch; missing={missing}, extra={extra}")
|
|
1258
|
+
|
|
1259
|
+
notes_start = lines.index("## Notes") + 1
|
|
1260
|
+
notes = []
|
|
1261
|
+
for line in lines[notes_start:]:
|
|
1262
|
+
if line.startswith("## "):
|
|
1263
|
+
break
|
|
1264
|
+
if line.startswith("- "):
|
|
1265
|
+
notes.append(line[2:].strip())
|
|
1266
|
+
if not notes or any(not note or archive_milestone.contains_placeholder(note) for note in notes):
|
|
1267
|
+
raise ArchiveError("manifest Notes section is incomplete")
|
|
1268
|
+
return actual, reviewed_head
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
# archive_milestone imports this module, so the parent is resolved after the
|
|
1272
|
+
# definitions above. Parent functions are looked up on the module at call time
|
|
1273
|
+
# so patches applied to archive_milestone stay visible here.
|
|
1274
|
+
if __package__: # imported as scripts.discussion_validate
|
|
1275
|
+
from . import archive_milestone
|
|
1276
|
+
from .archive_milestone import (
|
|
1277
|
+
ANSWER_HEADING_PATTERN,
|
|
1278
|
+
CARRY_TEMP_NAME,
|
|
1279
|
+
DIALOGUE_HEADING_PATTERN,
|
|
1280
|
+
DISCUSSION_FILES,
|
|
1281
|
+
DISCUSSION_PHASES,
|
|
1282
|
+
DISCUSSION_TRANSACTION_NAME,
|
|
1283
|
+
DISPOSITION_HEADING_PATTERN,
|
|
1284
|
+
EMPTY_DISCUSSION_FILES,
|
|
1285
|
+
FINAL_CRITERION_PATTERN,
|
|
1286
|
+
FINAL_GAP_FILE_PATTERN,
|
|
1287
|
+
FINAL_GAP_HEADING_PATTERN,
|
|
1288
|
+
HTML_COMMENT_PATTERN,
|
|
1289
|
+
INTENT_CRITERION_PATTERN,
|
|
1290
|
+
MANIFEST_FIELDS,
|
|
1291
|
+
MANIFEST_HEADINGS,
|
|
1292
|
+
MANIFEST_TEMP_NAME,
|
|
1293
|
+
PLAN_COVERAGE_ROW_PATTERN,
|
|
1294
|
+
WAVE_SC_HEADING_PATTERN,
|
|
1295
|
+
WAVE_TASK_HEADING_PATTERN,
|
|
1296
|
+
ArchiveError,
|
|
1297
|
+
)
|
|
1298
|
+
else: # standalone script or sibling import
|
|
1299
|
+
import archive_milestone
|
|
1300
|
+
from archive_milestone import (
|
|
1301
|
+
ANSWER_HEADING_PATTERN,
|
|
1302
|
+
CARRY_TEMP_NAME,
|
|
1303
|
+
DIALOGUE_HEADING_PATTERN,
|
|
1304
|
+
DISCUSSION_FILES,
|
|
1305
|
+
DISCUSSION_PHASES,
|
|
1306
|
+
DISCUSSION_TRANSACTION_NAME,
|
|
1307
|
+
DISPOSITION_HEADING_PATTERN,
|
|
1308
|
+
EMPTY_DISCUSSION_FILES,
|
|
1309
|
+
FINAL_CRITERION_PATTERN,
|
|
1310
|
+
FINAL_GAP_FILE_PATTERN,
|
|
1311
|
+
FINAL_GAP_HEADING_PATTERN,
|
|
1312
|
+
HTML_COMMENT_PATTERN,
|
|
1313
|
+
INTENT_CRITERION_PATTERN,
|
|
1314
|
+
MANIFEST_FIELDS,
|
|
1315
|
+
MANIFEST_HEADINGS,
|
|
1316
|
+
MANIFEST_TEMP_NAME,
|
|
1317
|
+
PLAN_COVERAGE_ROW_PATTERN,
|
|
1318
|
+
WAVE_SC_HEADING_PATTERN,
|
|
1319
|
+
WAVE_TASK_HEADING_PATTERN,
|
|
1320
|
+
ArchiveError,
|
|
1321
|
+
)
|