@opengsd/gsd-path 1.4.0 → 1.6.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/CHANGELOG.md +460 -341
- package/DOCS.md +16 -5
- package/FULL.md +67 -6
- package/HOOKS.md +158 -10
- package/MIGRATE.md +12 -0
- package/QUICK.md +4 -2
- package/README.md +136 -116
- package/RUNTIME.md +24 -6
- package/UPDATE.md +39 -3
- package/WORKFLOW.md +80 -4
- package/package.json +5 -2
- package/platforms/shared-agents/dispatch.md +13 -0
- package/scripts/_common.py +256 -2
- package/scripts/archive_milestone.py +14 -13
- package/scripts/build_recovery.py +155 -58
- package/scripts/build_state.py +274 -8
- package/scripts/check_docs_audit.py +77 -4
- package/scripts/check_handoffs.py +635 -57
- package/scripts/check_task_briefs.py +293 -32
- package/scripts/detect_project.py +260 -20
- package/scripts/discussion_records.py +9 -1
- package/scripts/discussion_validate.py +93 -35
- package/scripts/dispatch_driver.py +549 -53
- package/scripts/git_guard.py +27 -8
- package/scripts/guard_hook.py +2630 -210
- package/scripts/install.mjs +99 -11
- package/scripts/install.py +231 -46
- package/scripts/integration.py +244 -65
- package/scripts/isolation.py +539 -70
- package/scripts/loop_run.py +16 -4
- package/scripts/members.py +77 -32
- package/scripts/migrate_core.py +2 -0
- package/scripts/model_policy.py +5 -1
- package/scripts/path_config.py +3 -3
- package/scripts/pipeline_diagnose.py +17 -2
- package/scripts/pipeline_git.py +131 -42
- package/scripts/pipeline_state.py +139 -39
- package/scripts/pipeline_undo.py +118 -16
- package/scripts/promote_lookahead.py +3 -3
- package/scripts/review_findings.py +180 -44
- package/scripts/roadmap.py +9 -1
- package/scripts/runtime_store.py +21 -2
- package/scripts/skill-resources.json +9 -2
- package/scripts/state_checkpoint.py +39 -36
- package/scripts/state_promote.py +15 -4
- package/scripts/status_runtime.py +22 -4
- package/scripts/task_context.py +14 -3
- package/scripts/wizard.mjs +1 -0
- package/scripts/workflow_run.py +14 -4
- package/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/DEFINE.md +34 -5
- package/skills/gsd-path/PLAN.md +14 -3
- package/skills/gsd-path/RESEARCH.md +11 -0
- package/skills/gsd-path/ROADMAP.md +16 -6
- package/skills/gsd-path/SHIP.md +32 -5
- package/skills/gsd-path/SKILL.md +52 -24
- package/skills/gsd-path/references/build-native.md +112 -10
- package/skills/gsd-path/references/build-recovery.md +13 -0
- package/skills/gsd-path/references/coder.md +7 -3
- package/skills/gsd-path/references/dispatch.md +13 -0
- package/skills/gsd-path/references/docs-auditor.md +5 -0
- package/skills/gsd-path/references/operating-rules.md +11 -3
- package/skills/gsd-path/references/planner.md +26 -4
- package/skills/gsd-path/references/reviewer-final.md +20 -2
- package/skills/gsd-path/references/reviewer-wave.md +56 -12
- package/skills/gsd-path/references/reviewer.md +3 -1
- package/skills/gsd-path/references/runtime-selection.md +9 -0
- package/skills/gsd-path/references/spec-probes.md +150 -0
- package/skills/gsd-path/scripts/_common.py +256 -2
- package/skills/gsd-path/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path/scripts/build_recovery.py +155 -58
- package/skills/gsd-path/scripts/build_state.py +274 -8
- package/skills/gsd-path/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path/scripts/detect_project.py +260 -20
- package/skills/gsd-path/scripts/discussion_records.py +9 -1
- package/skills/gsd-path/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path/scripts/dispatch_driver.py +549 -53
- package/skills/gsd-path/scripts/git_guard.py +27 -8
- package/skills/gsd-path/scripts/integration.py +244 -65
- package/skills/gsd-path/scripts/isolation.py +539 -70
- package/skills/gsd-path/scripts/loop_run.py +16 -4
- package/skills/gsd-path/scripts/members.py +77 -32
- package/skills/gsd-path/scripts/model_policy.py +5 -1
- package/skills/gsd-path/scripts/path_config.py +3 -3
- package/skills/gsd-path/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path/scripts/review_findings.py +180 -44
- package/skills/gsd-path/scripts/roadmap.py +9 -1
- package/skills/gsd-path/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path/scripts/state_promote.py +15 -4
- package/skills/gsd-path/scripts/status_runtime.py +22 -4
- package/skills/gsd-path/scripts/task_context.py +14 -3
- package/skills/gsd-path/scripts/workflow_run.py +14 -4
- package/skills/gsd-path/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path/templates/docs-audit.md +5 -1
- package/skills/gsd-path/templates/final-review.md +11 -2
- package/skills/gsd-path/templates/intent.md +30 -0
- package/skills/gsd-path/templates/patch-findings.md +3 -3
- package/skills/gsd-path/templates/plan.md +17 -2
- package/skills/gsd-path/templates/research-handoff.md +14 -3
- package/skills/gsd-path/templates/state.md +2 -1
- package/skills/gsd-path/templates/task.md +1 -0
- package/skills/gsd-path/templates/wave-review.md +28 -9
- package/skills/gsd-path-build/references/build-native.md +112 -10
- package/skills/gsd-path-build/references/build-recovery.md +13 -0
- package/skills/gsd-path-build/references/coder.md +7 -3
- package/skills/gsd-path-build/references/dispatch.md +13 -0
- package/skills/gsd-path-build/references/operating-rules.md +11 -3
- package/skills/gsd-path-build/references/reviewer-final.md +20 -2
- package/skills/gsd-path-build/references/reviewer-wave.md +56 -12
- package/skills/gsd-path-build/references/reviewer.md +3 -1
- package/skills/gsd-path-build/references/runtime-selection.md +9 -0
- package/skills/gsd-path-build/references/spec-probes.md +150 -0
- package/skills/gsd-path-build/scripts/_common.py +256 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-build/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-build/scripts/build_state.py +274 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-build/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-build/scripts/detect_project.py +260 -20
- package/skills/gsd-path-build/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-build/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-build/scripts/dispatch_driver.py +549 -53
- package/skills/gsd-path-build/scripts/integration.py +244 -65
- package/skills/gsd-path-build/scripts/isolation.py +539 -70
- package/skills/gsd-path-build/scripts/loop_run.py +16 -4
- package/skills/gsd-path-build/scripts/members.py +77 -32
- package/skills/gsd-path-build/scripts/model_policy.py +5 -1
- package/skills/gsd-path-build/scripts/path_config.py +3 -3
- package/skills/gsd-path-build/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-build/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-build/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-build/scripts/review_findings.py +180 -44
- package/skills/gsd-path-build/scripts/roadmap.py +9 -1
- package/skills/gsd-path-build/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-build/scripts/state_promote.py +15 -4
- package/skills/gsd-path-build/scripts/task_context.py +14 -3
- package/skills/gsd-path-build/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-build/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-build/templates/task.md +1 -0
- package/skills/gsd-path-build/templates/wave-review.md +28 -9
- package/skills/gsd-path-decide/references/dispatch.md +13 -0
- package/skills/gsd-path-decide/references/operating-rules.md +11 -3
- package/skills/gsd-path-decide/references/runtime-selection.md +9 -0
- package/skills/gsd-path-decide/scripts/_common.py +256 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-decide/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-decide/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-decide/scripts/detect_project.py +260 -20
- package/skills/gsd-path-decide/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-decide/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-decide/scripts/integration.py +244 -65
- package/skills/gsd-path-decide/scripts/isolation.py +539 -70
- package/skills/gsd-path-decide/scripts/loop_run.py +16 -4
- package/skills/gsd-path-decide/scripts/members.py +77 -32
- package/skills/gsd-path-decide/scripts/model_policy.py +5 -1
- package/skills/gsd-path-decide/scripts/path_config.py +3 -3
- package/skills/gsd-path-decide/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-decide/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-decide/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-decide/scripts/roadmap.py +9 -1
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-decide/scripts/state_promote.py +15 -4
- package/skills/gsd-path-decide/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/SKILL.md +34 -5
- package/skills/gsd-path-define/references/build-recovery.md +13 -0
- package/skills/gsd-path-define/references/operating-rules.md +11 -3
- package/skills/gsd-path-define/references/runtime-selection.md +9 -0
- package/skills/gsd-path-define/references/spec-probes.md +150 -0
- package/skills/gsd-path-define/scripts/_common.py +256 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-define/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-define/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-define/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-define/scripts/detect_project.py +260 -20
- package/skills/gsd-path-define/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-define/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-define/scripts/integration.py +244 -65
- package/skills/gsd-path-define/scripts/isolation.py +539 -70
- package/skills/gsd-path-define/scripts/loop_run.py +16 -4
- package/skills/gsd-path-define/scripts/members.py +77 -32
- package/skills/gsd-path-define/scripts/model_policy.py +5 -1
- package/skills/gsd-path-define/scripts/path_config.py +3 -3
- package/skills/gsd-path-define/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-define/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-define/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-define/scripts/roadmap.py +9 -1
- package/skills/gsd-path-define/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-define/scripts/state_promote.py +15 -4
- package/skills/gsd-path-define/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-define/templates/intent.md +30 -0
- package/skills/gsd-path-define/templates/state.md +2 -1
- package/skills/gsd-path-discuss/references/operating-rules.md +11 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +9 -0
- package/skills/gsd-path-discuss/scripts/_common.py +256 -2
- package/skills/gsd-path-discuss/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-discuss/scripts/loop_run.py +16 -4
- package/skills/gsd-path-discuss/scripts/model_policy.py +5 -1
- package/skills/gsd-path-discuss/scripts/path_config.py +3 -3
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-docs-audit/references/dispatch.md +13 -0
- package/skills/gsd-path-docs-audit/references/docs-auditor.md +5 -0
- package/skills/gsd-path-docs-audit/references/operating-rules.md +11 -3
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +9 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +256 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-docs-audit/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-docs-audit/scripts/detect_project.py +260 -20
- package/skills/gsd-path-docs-audit/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-docs-audit/scripts/integration.py +244 -65
- package/skills/gsd-path-docs-audit/scripts/isolation.py +539 -70
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +16 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +77 -32
- package/skills/gsd-path-docs-audit/scripts/model_policy.py +5 -1
- package/skills/gsd-path-docs-audit/scripts/path_config.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-docs-audit/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-docs-audit/scripts/roadmap.py +9 -1
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-docs-audit/scripts/state_promote.py +15 -4
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-docs-audit/templates/docs-audit.md +5 -1
- package/skills/gsd-path-forensics/references/operating-rules.md +11 -3
- package/skills/gsd-path-forensics/references/runtime-selection.md +9 -0
- package/skills/gsd-path-forensics/scripts/_common.py +256 -2
- package/skills/gsd-path-forensics/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-forensics/scripts/loop_run.py +16 -4
- package/skills/gsd-path-forensics/scripts/model_policy.py +5 -1
- package/skills/gsd-path-forensics/scripts/path_config.py +3 -3
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +17 -2
- package/skills/gsd-path-forensics/scripts/pipeline_undo.py +118 -16
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-inspect/references/dispatch.md +13 -0
- package/skills/gsd-path-inspect/references/docs-auditor.md +5 -0
- package/skills/gsd-path-inspect/references/operating-rules.md +11 -3
- package/skills/gsd-path-inspect/references/runtime-selection.md +9 -0
- package/skills/gsd-path-inspect/scripts/_common.py +256 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-inspect/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-inspect/scripts/check_docs_audit.py +77 -4
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-inspect/scripts/detect_project.py +260 -20
- package/skills/gsd-path-inspect/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-inspect/scripts/integration.py +244 -65
- package/skills/gsd-path-inspect/scripts/isolation.py +539 -70
- package/skills/gsd-path-inspect/scripts/loop_run.py +16 -4
- package/skills/gsd-path-inspect/scripts/members.py +77 -32
- package/skills/gsd-path-inspect/scripts/model_policy.py +5 -1
- package/skills/gsd-path-inspect/scripts/path_config.py +3 -3
- package/skills/gsd-path-inspect/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-inspect/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-inspect/scripts/roadmap.py +9 -1
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-inspect/scripts/state_promote.py +15 -4
- package/skills/gsd-path-inspect/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-inspect/templates/docs-audit.md +5 -1
- package/skills/gsd-path-inspect/templates/state.md +2 -1
- package/skills/gsd-path-loop/references/operating-rules.md +11 -3
- package/skills/gsd-path-loop/references/runtime-selection.md +9 -0
- package/skills/gsd-path-loop/scripts/_common.py +256 -2
- package/skills/gsd-path-loop/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-loop/scripts/loop_run.py +16 -4
- package/skills/gsd-path-loop/scripts/model_policy.py +5 -1
- package/skills/gsd-path-loop/scripts/path_config.py +3 -3
- package/skills/gsd-path-loop/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-migrate/SKILL.md +8 -0
- package/skills/gsd-path-migrate/references/operating-rules.md +11 -3
- package/skills/gsd-path-migrate/references/runtime-selection.md +9 -0
- package/skills/gsd-path-migrate/scripts/_common.py +256 -2
- package/skills/gsd-path-migrate/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-migrate/scripts/loop_run.py +16 -4
- package/skills/gsd-path-migrate/scripts/migrate_core.py +2 -0
- package/skills/gsd-path-migrate/scripts/model_policy.py +5 -1
- package/skills/gsd-path-migrate/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/SKILL.md +14 -3
- package/skills/gsd-path-plan/references/build-recovery.md +13 -0
- package/skills/gsd-path-plan/references/dispatch.md +13 -0
- package/skills/gsd-path-plan/references/operating-rules.md +11 -3
- package/skills/gsd-path-plan/references/planner.md +26 -4
- package/skills/gsd-path-plan/references/runtime-selection.md +9 -0
- package/skills/gsd-path-plan/references/spec-probes.md +150 -0
- package/skills/gsd-path-plan/scripts/_common.py +256 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-plan/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-plan/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-plan/scripts/detect_project.py +260 -20
- package/skills/gsd-path-plan/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-plan/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-plan/scripts/integration.py +244 -65
- package/skills/gsd-path-plan/scripts/isolation.py +539 -70
- package/skills/gsd-path-plan/scripts/loop_run.py +16 -4
- package/skills/gsd-path-plan/scripts/members.py +77 -32
- package/skills/gsd-path-plan/scripts/model_policy.py +5 -1
- package/skills/gsd-path-plan/scripts/path_config.py +3 -3
- package/skills/gsd-path-plan/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-plan/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-plan/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-plan/scripts/roadmap.py +9 -1
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-plan/scripts/state_promote.py +15 -4
- package/skills/gsd-path-plan/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-plan/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-plan/templates/patch-findings.md +3 -3
- package/skills/gsd-path-plan/templates/plan.md +17 -2
- package/skills/gsd-path-plan/templates/task.md +1 -0
- package/skills/gsd-path-research/SKILL.md +11 -0
- package/skills/gsd-path-research/references/dispatch.md +13 -0
- package/skills/gsd-path-research/references/operating-rules.md +11 -3
- package/skills/gsd-path-research/references/runtime-selection.md +9 -0
- package/skills/gsd-path-research/scripts/_common.py +256 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-research/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-research/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-research/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-research/scripts/detect_project.py +260 -20
- package/skills/gsd-path-research/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-research/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-research/scripts/integration.py +244 -65
- package/skills/gsd-path-research/scripts/isolation.py +539 -70
- package/skills/gsd-path-research/scripts/loop_run.py +16 -4
- package/skills/gsd-path-research/scripts/members.py +77 -32
- package/skills/gsd-path-research/scripts/model_policy.py +5 -1
- package/skills/gsd-path-research/scripts/path_config.py +3 -3
- package/skills/gsd-path-research/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-research/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-research/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-research/scripts/roadmap.py +9 -1
- package/skills/gsd-path-research/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-research/scripts/state_promote.py +15 -4
- package/skills/gsd-path-research/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-research/templates/research-handoff.md +14 -3
- package/skills/gsd-path-roadmap/SKILL.md +16 -6
- package/skills/gsd-path-roadmap/references/dispatch.md +13 -0
- package/skills/gsd-path-roadmap/references/operating-rules.md +11 -3
- package/skills/gsd-path-roadmap/references/runtime-selection.md +9 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +256 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-roadmap/scripts/detect_project.py +260 -20
- package/skills/gsd-path-roadmap/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-roadmap/scripts/integration.py +244 -65
- package/skills/gsd-path-roadmap/scripts/isolation.py +539 -70
- package/skills/gsd-path-roadmap/scripts/loop_run.py +16 -4
- package/skills/gsd-path-roadmap/scripts/members.py +77 -32
- package/skills/gsd-path-roadmap/scripts/model_policy.py +5 -1
- package/skills/gsd-path-roadmap/scripts/path_config.py +3 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-roadmap/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-roadmap/scripts/roadmap.py +9 -1
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-roadmap/scripts/state_promote.py +15 -4
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/SKILL.md +32 -5
- package/skills/gsd-path-ship/references/dispatch.md +13 -0
- package/skills/gsd-path-ship/references/operating-rules.md +11 -3
- package/skills/gsd-path-ship/references/reviewer-final.md +20 -2
- package/skills/gsd-path-ship/references/reviewer-wave.md +56 -12
- package/skills/gsd-path-ship/references/reviewer.md +3 -1
- package/skills/gsd-path-ship/references/runtime-selection.md +9 -0
- package/skills/gsd-path-ship/references/spec-probes.md +150 -0
- package/skills/gsd-path-ship/scripts/_common.py +256 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +14 -13
- package/skills/gsd-path-ship/scripts/build_recovery.py +155 -58
- package/skills/gsd-path-ship/scripts/build_state.py +274 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +635 -57
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +293 -32
- package/skills/gsd-path-ship/scripts/detect_project.py +260 -20
- package/skills/gsd-path-ship/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-ship/scripts/discussion_validate.py +93 -35
- package/skills/gsd-path-ship/scripts/integration.py +244 -65
- package/skills/gsd-path-ship/scripts/isolation.py +539 -70
- package/skills/gsd-path-ship/scripts/loop_run.py +16 -4
- package/skills/gsd-path-ship/scripts/members.py +77 -32
- package/skills/gsd-path-ship/scripts/model_policy.py +5 -1
- package/skills/gsd-path-ship/scripts/path_config.py +3 -3
- package/skills/gsd-path-ship/scripts/pipeline_git.py +131 -42
- package/skills/gsd-path-ship/scripts/pipeline_state.py +139 -39
- package/skills/gsd-path-ship/scripts/promote_lookahead.py +3 -3
- package/skills/gsd-path-ship/scripts/roadmap.py +9 -1
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +39 -36
- package/skills/gsd-path-ship/scripts/state_promote.py +15 -4
- package/skills/gsd-path-ship/scripts/workflow_run.py +14 -4
- package/skills/gsd-path-ship/scripts/worktree_paths.py +54 -2
- package/skills/gsd-path-ship/templates/final-review.md +11 -2
- package/skills/gsd-path-ship/templates/patch-findings.md +3 -3
- package/skills/gsd-path-ship/templates/wave-review.md +28 -9
- package/skills/gsd-path-undo/references/operating-rules.md +11 -3
- package/skills/gsd-path-undo/references/runtime-selection.md +9 -0
- package/skills/gsd-path-undo/scripts/_common.py +256 -2
- package/skills/gsd-path-undo/scripts/discussion_records.py +9 -1
- package/skills/gsd-path-undo/scripts/loop_run.py +16 -4
- package/skills/gsd-path-undo/scripts/model_policy.py +5 -1
- package/skills/gsd-path-undo/scripts/path_config.py +3 -3
- package/skills/gsd-path-undo/scripts/pipeline_undo.py +118 -16
- package/skills/gsd-path-undo/scripts/worktree_paths.py +54 -2
- package/skills/path/DEFINE.md +34 -5
- package/skills/path/PLAN.md +14 -3
- package/skills/path/RESEARCH.md +11 -0
- package/skills/path/ROADMAP.md +16 -6
- package/skills/path/SHIP.md +32 -5
- package/skills/path/SKILL.md +52 -24
- package/skills/path/references/build-native.md +112 -10
- package/skills/path/references/build-recovery.md +13 -0
- package/skills/path/references/coder.md +7 -3
- package/skills/path/references/dispatch.md +13 -0
- package/skills/path/references/docs-auditor.md +5 -0
- package/skills/path/references/operating-rules.md +11 -3
- package/skills/path/references/planner.md +26 -4
- package/skills/path/references/reviewer-final.md +20 -2
- package/skills/path/references/reviewer-wave.md +56 -12
- package/skills/path/references/reviewer.md +3 -1
- package/skills/path/references/runtime-selection.md +9 -0
- package/skills/path/references/spec-probes.md +150 -0
- package/skills/path/scripts/_common.py +256 -2
- package/skills/path/scripts/archive_milestone.py +14 -13
- package/skills/path/scripts/build_recovery.py +155 -58
- package/skills/path/scripts/build_state.py +274 -8
- package/skills/path/scripts/check_docs_audit.py +77 -4
- package/skills/path/scripts/check_handoffs.py +635 -57
- package/skills/path/scripts/check_task_briefs.py +293 -32
- package/skills/path/scripts/detect_project.py +260 -20
- package/skills/path/scripts/discussion_records.py +9 -1
- package/skills/path/scripts/discussion_validate.py +93 -35
- package/skills/path/scripts/dispatch_driver.py +549 -53
- package/skills/path/scripts/git_guard.py +27 -8
- package/skills/path/scripts/integration.py +244 -65
- package/skills/path/scripts/isolation.py +539 -70
- package/skills/path/scripts/loop_run.py +16 -4
- package/skills/path/scripts/members.py +77 -32
- package/skills/path/scripts/model_policy.py +5 -1
- package/skills/path/scripts/path_config.py +3 -3
- package/skills/path/scripts/pipeline_git.py +131 -42
- package/skills/path/scripts/pipeline_state.py +139 -39
- package/skills/path/scripts/promote_lookahead.py +3 -3
- package/skills/path/scripts/review_findings.py +180 -44
- package/skills/path/scripts/roadmap.py +9 -1
- package/skills/path/scripts/state_checkpoint.py +39 -36
- package/skills/path/scripts/state_promote.py +15 -4
- package/skills/path/scripts/status_runtime.py +22 -4
- package/skills/path/scripts/task_context.py +14 -3
- package/skills/path/scripts/workflow_run.py +14 -4
- package/skills/path/scripts/worktree_paths.py +54 -2
- package/skills/path/templates/docs-audit.md +5 -1
- package/skills/path/templates/final-review.md +11 -2
- package/skills/path/templates/intent.md +30 -0
- package/skills/path/templates/patch-findings.md +3 -3
- package/skills/path/templates/plan.md +17 -2
- package/skills/path/templates/research-handoff.md +14 -3
- package/skills/path/templates/state.md +2 -1
- package/skills/path/templates/task.md +1 -0
- package/skills/path/templates/wave-review.md +28 -9
- package/skills/gsd-path/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-build/scripts/__pycache__/check_handoffs.cpython-312.pyc +0 -0
- package/skills/gsd-path-inspect/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-plan/scripts/__pycache__/_common.cpython-312.pyc +0 -0
- package/skills/gsd-path-ship/scripts/__pycache__/_common.cpython-312.pyc +0 -0
|
@@ -33,7 +33,11 @@ DISPATCH_PATTERN = re.compile(
|
|
|
33
33
|
r"^- `(?P<dimension>[^`]+)` — (?P<status>dispatched|skipped) "
|
|
34
34
|
r"→ (?P<output>[^—]+) — (?P<reason>.+)$"
|
|
35
35
|
)
|
|
36
|
-
QUESTION_PATTERN = re.compile(
|
|
36
|
+
QUESTION_PATTERN = re.compile(
|
|
37
|
+
r"^- (?P<fence>`+)(?!`)(?P<body>.+?)(?<!`)(?P=fence)(?!`) → "
|
|
38
|
+
r"(?P<dimensions>`[^`,\s]+`(?:,\s*`[^`,\s]+`)*)$"
|
|
39
|
+
)
|
|
40
|
+
QUESTION_PREFIX = "[RESEARCH] "
|
|
37
41
|
FINDING_PATTERN = re.compile(r"^### (?P<id>P\d{3}) — (?P<title>.+)$")
|
|
38
42
|
CRITERION_LOCATOR_PATTERN = re.compile(r"^SC[1-9]\d*$")
|
|
39
43
|
COMMENT_PATTERN = re.compile(r"<!--.*?-->", re.DOTALL)
|
|
@@ -56,6 +60,21 @@ WAVE_REVIEW_NAME = re.compile(
|
|
|
56
60
|
r"wave-(?P<wave>[1-9]\d*)\.cycle(?P<cycle>[1-9]\d*)"
|
|
57
61
|
r"(?:\.(?P<lens>contract|adversarial))?\.md$"
|
|
58
62
|
)
|
|
63
|
+
VALID_REVIEW_DEPTHS = frozenset({"full", "deep", "verify-only"})
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _max_wave_cycle(root: Path, project_dir: str, wave: int) -> int:
|
|
67
|
+
review_dir = root / PurePosixPath(project_dir) / "review"
|
|
68
|
+
if not review_dir.is_dir():
|
|
69
|
+
return 0
|
|
70
|
+
maximum = 0
|
|
71
|
+
for path in review_dir.iterdir():
|
|
72
|
+
named = WAVE_REVIEW_NAME.fullmatch(path.name)
|
|
73
|
+
if named and int(named.group("wave")) == wave:
|
|
74
|
+
maximum = max(maximum, int(named.group("cycle")))
|
|
75
|
+
return maximum
|
|
76
|
+
|
|
77
|
+
|
|
59
78
|
WAVE_SC_HEADING = re.compile(r"^### (SC[1-9]\d*) — (.+)$")
|
|
60
79
|
WAVE_TASK_HEADING = re.compile(
|
|
61
80
|
r"^## (?P<task>T\d{3}) — (?P<title>.+): (?P<verdict>pass|fail)$"
|
|
@@ -73,6 +92,23 @@ SURFACE_HEADING = re.compile(
|
|
|
73
92
|
r"(?m)^### (?P<surface>\S.*?) — (?P<task>T\d{3})\s*$"
|
|
74
93
|
)
|
|
75
94
|
SURFACE_CONTRACT_HEADING = re.compile(r"(?m)^## Surface contract\s*$")
|
|
95
|
+
# Spec-reach probes (see references/spec-probes.md). The closed edge taxonomy
|
|
96
|
+
# names the classic black-box categories; `none` records a criterion with no
|
|
97
|
+
# data-shape edge and must be dismissed with a reason.
|
|
98
|
+
EDGE_CATEGORIES = (
|
|
99
|
+
"boundary",
|
|
100
|
+
"adjacency",
|
|
101
|
+
"empty",
|
|
102
|
+
"encoding",
|
|
103
|
+
"ordering",
|
|
104
|
+
"precision",
|
|
105
|
+
"idempotency",
|
|
106
|
+
"concurrency",
|
|
107
|
+
)
|
|
108
|
+
EDGE_ID_PATTERN = re.compile(r"^E[1-9]\d*$")
|
|
109
|
+
PROHIBITION_ID_PATTERN = re.compile(r"^N[1-9]\d*$")
|
|
110
|
+
CRITERION_DISPOSITION_PATTERN = re.compile(r"^criterion\s+(SC[1-9]\d*)$")
|
|
111
|
+
TABLE_SEPARATOR_PATTERN = re.compile(r"^:?-+:?$")
|
|
76
112
|
GAP_NAME_PATTERN = re.compile(r"^final-gap-(?P<number>[1-9]\d*)\.md$")
|
|
77
113
|
GAP_HEADING_PATTERN = re.compile(
|
|
78
114
|
r"^# Gap Review — (?P<number>[1-9]\d*): (?P<risk>\S.*)$"
|
|
@@ -125,7 +161,8 @@ def _reject_placeholder(value: str, label: str) -> None:
|
|
|
125
161
|
if placeholder is not None:
|
|
126
162
|
raise HandoffError(
|
|
127
163
|
f"{label} still contains the placeholder {placeholder.group(0)}; "
|
|
128
|
-
"replace it with the real value"
|
|
164
|
+
"replace it with the real value. A value cannot be only a placeholder; "
|
|
165
|
+
"literal text such as an HTML tag must be inside a sentence, in backticks"
|
|
129
166
|
)
|
|
130
167
|
|
|
131
168
|
|
|
@@ -181,7 +218,7 @@ def _read(root: Path, relative: str) -> str:
|
|
|
181
218
|
path = root / relative
|
|
182
219
|
if not path.is_file() or path.is_symlink():
|
|
183
220
|
raise HandoffError(f"missing real hand-off file: {relative}")
|
|
184
|
-
return
|
|
221
|
+
return _common.read_user_text(path)
|
|
185
222
|
|
|
186
223
|
|
|
187
224
|
def _require_pipeline(root: Path, project_dir: str) -> Dict[str, str]:
|
|
@@ -361,7 +398,7 @@ def _research_questions(intent: str) -> List[str]:
|
|
|
361
398
|
]
|
|
362
399
|
|
|
363
400
|
|
|
364
|
-
def _research_assignments(section: str) -> List[Tuple[str, str]]:
|
|
401
|
+
def _research_assignments(section: str) -> List[Tuple[str, List[str]]]:
|
|
365
402
|
assignments = []
|
|
366
403
|
lines = [line.strip() for line in section.splitlines() if line.strip()]
|
|
367
404
|
if lines == ["- none"]:
|
|
@@ -372,7 +409,25 @@ def _research_assignments(section: str) -> List[Tuple[str, str]]:
|
|
|
372
409
|
match = QUESTION_PATTERN.fullmatch(line)
|
|
373
410
|
if match is None:
|
|
374
411
|
raise HandoffError("RESEARCH.md has a malformed question assignment")
|
|
375
|
-
|
|
412
|
+
body = match.group("body")
|
|
413
|
+
fence_length = len(match.group("fence"))
|
|
414
|
+
if any(len(run) == fence_length for run in re.findall(r"`+", body)):
|
|
415
|
+
raise HandoffError("RESEARCH.md has a malformed question assignment")
|
|
416
|
+
if (
|
|
417
|
+
body.startswith(" ")
|
|
418
|
+
and body.endswith(" ")
|
|
419
|
+
and body.strip(" ")
|
|
420
|
+
):
|
|
421
|
+
body = body[1:-1]
|
|
422
|
+
if not body.startswith(QUESTION_PREFIX):
|
|
423
|
+
raise HandoffError("RESEARCH.md has a malformed question assignment")
|
|
424
|
+
question = body[len(QUESTION_PREFIX) :]
|
|
425
|
+
if not question:
|
|
426
|
+
raise HandoffError("RESEARCH.md has a malformed question assignment")
|
|
427
|
+
dimensions = re.findall(r"`([^`]+)`", match.group("dimensions"))
|
|
428
|
+
if len(dimensions) != len(set(dimensions)):
|
|
429
|
+
raise HandoffError("RESEARCH.md repeats a target in a question assignment")
|
|
430
|
+
assignments.append((question, dimensions))
|
|
376
431
|
return assignments
|
|
377
432
|
|
|
378
433
|
|
|
@@ -446,19 +501,23 @@ def validate_research_artifacts(
|
|
|
446
501
|
|
|
447
502
|
assignments = _research_assignments(_section(handoff, "Question assignments"))
|
|
448
503
|
questions = _research_questions(intent)
|
|
449
|
-
assigned_questions = [question for question,
|
|
450
|
-
if sorted(assigned_questions) != sorted(questions):
|
|
451
|
-
raise HandoffError("RESEARCH.md question assignments do not match the intent source")
|
|
504
|
+
assigned_questions = [question for question, _dimensions in assignments]
|
|
452
505
|
if len(assigned_questions) != len(set(assigned_questions)):
|
|
453
506
|
raise HandoffError("RESEARCH.md repeats a question assignment")
|
|
507
|
+
if sorted(assigned_questions) != sorted(questions):
|
|
508
|
+
raise HandoffError("RESEARCH.md question assignments do not match the intent source")
|
|
454
509
|
dispatched_set = set(dispatched)
|
|
455
|
-
if any(
|
|
510
|
+
if any(
|
|
511
|
+
dimension not in dispatched_set
|
|
512
|
+
for _question, assigned_dimensions in assignments
|
|
513
|
+
for dimension in assigned_dimensions
|
|
514
|
+
):
|
|
456
515
|
raise HandoffError("every assigned question must target a dispatched dimension")
|
|
457
516
|
for dimension in dispatched:
|
|
458
517
|
dimension_questions = [
|
|
459
518
|
question
|
|
460
|
-
for question,
|
|
461
|
-
if
|
|
519
|
+
for question, assigned_dimensions in assignments
|
|
520
|
+
if dimension in assigned_dimensions
|
|
462
521
|
]
|
|
463
522
|
if not dimension_questions:
|
|
464
523
|
raise HandoffError(f"dispatched {dimension} has no assigned research question")
|
|
@@ -496,7 +555,7 @@ def _field(block: str, field: str) -> str:
|
|
|
496
555
|
|
|
497
556
|
|
|
498
557
|
def _raw_source_field(block: str, field: str, source: str) -> str:
|
|
499
|
-
matches =
|
|
558
|
+
matches = _common.find_review_bullet_field_values(block.splitlines(), field)
|
|
500
559
|
if not matches:
|
|
501
560
|
raise HandoffError(f"{source} is missing {field}")
|
|
502
561
|
if len(matches) != 1:
|
|
@@ -521,10 +580,16 @@ def _heading_block(text: str, heading: re.Match[str]) -> str:
|
|
|
521
580
|
def _source_repair_fields(source: str, text: str, locator: str) -> Tuple[str, str]:
|
|
522
581
|
if source.endswith("FINAL.md"):
|
|
523
582
|
if not CRITERION_LOCATOR_PATTERN.fullmatch(locator):
|
|
524
|
-
raise HandoffError(
|
|
583
|
+
raise HandoffError(
|
|
584
|
+
f"{source} locator must be an SC<n> id; copy the id from a "
|
|
585
|
+
"criterion heading"
|
|
586
|
+
)
|
|
525
587
|
heading = re.search(rf"(?m)^### {re.escape(locator)} — .+$", text)
|
|
526
588
|
if not heading:
|
|
527
|
-
raise HandoffError(
|
|
589
|
+
raise HandoffError(
|
|
590
|
+
f"{source} has no criterion heading for locator {locator}; "
|
|
591
|
+
"copy an SC<n> id from a heading"
|
|
592
|
+
)
|
|
528
593
|
block = _heading_block(text, heading)
|
|
529
594
|
verdict = _source_field(block, "Verdict", source)
|
|
530
595
|
if verdict not in {"not-met", "unverifiable"}:
|
|
@@ -532,8 +597,13 @@ def _source_repair_fields(source: str, text: str, locator: str) -> Tuple[str, st
|
|
|
532
597
|
evidence = _source_field(block, "Finding", source)
|
|
533
598
|
fix_direction = _source_field(block, "Fix direction", source)
|
|
534
599
|
else:
|
|
535
|
-
|
|
536
|
-
|
|
600
|
+
risk_lines = [line for line in text.splitlines() if line.startswith("Risk: ")]
|
|
601
|
+
if not locator.startswith("Risk: ") or locator not in risk_lines:
|
|
602
|
+
candidates = ", ".join(repr(line) for line in risk_lines) or "none found"
|
|
603
|
+
raise HandoffError(
|
|
604
|
+
f"{source} locator must equal a full 'Risk: ...' line from the "
|
|
605
|
+
f"gap review; copy one of {candidates}"
|
|
606
|
+
)
|
|
537
607
|
block = _section(text, "Finding")
|
|
538
608
|
evidence = _source_field(block, "Found", source)
|
|
539
609
|
fix_direction = _source_field(block, "Fix direction", source)
|
|
@@ -598,22 +668,30 @@ def _strip_comments(text: str) -> str:
|
|
|
598
668
|
return COMMENT_PATTERN.sub("", text)
|
|
599
669
|
|
|
600
670
|
|
|
601
|
-
def
|
|
602
|
-
|
|
603
|
-
|
|
671
|
+
def _numbered_entries(section: str, *, continuations: bool = False) -> List[Tuple[int, str]]:
|
|
672
|
+
"""Each numbered item in document order; a repeated number stays a separate entry."""
|
|
673
|
+
entries: List[Tuple[int, str]] = []
|
|
674
|
+
open_item = False
|
|
604
675
|
for line in _strip_comments(section).splitlines():
|
|
605
676
|
match = NUMBERED_ITEM_PATTERN.fullmatch(line.strip())
|
|
606
677
|
if not match:
|
|
607
|
-
if continuations and
|
|
608
|
-
|
|
678
|
+
if continuations and open_item and line[:1].isspace() and line.strip():
|
|
679
|
+
number, text = entries[-1]
|
|
680
|
+
entries[-1] = (number, text + " " + line.strip())
|
|
609
681
|
elif line.strip():
|
|
610
|
-
|
|
682
|
+
open_item = False
|
|
611
683
|
continue
|
|
612
|
-
|
|
684
|
+
entries.append((int(match.group(1)), match.group(2).strip()))
|
|
685
|
+
open_item = True
|
|
686
|
+
return entries
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _numbered_items(section: str, *, continuations: bool = False) -> Dict[int, str]:
|
|
690
|
+
items: Dict[int, str] = {}
|
|
691
|
+
for number, text in _numbered_entries(section, continuations=continuations):
|
|
613
692
|
if number in items:
|
|
614
693
|
raise HandoffError(f"repeated numbered item {number}")
|
|
615
|
-
items[number] =
|
|
616
|
-
current = number
|
|
694
|
+
items[number] = text
|
|
617
695
|
return items
|
|
618
696
|
|
|
619
697
|
|
|
@@ -624,7 +702,47 @@ def _success_criteria(intent: str) -> Dict[str, str]:
|
|
|
624
702
|
expected = list(range(1, max(items) + 1))
|
|
625
703
|
if sorted(items) != expected:
|
|
626
704
|
raise HandoffError("INTENT.md success criteria must be contiguous from 1")
|
|
627
|
-
|
|
705
|
+
# Issue #392: a review can quote a criterion without the markers of its sub-bullet lines.
|
|
706
|
+
bare = _numbered_items(
|
|
707
|
+
_section(_common.without_sub_bullet_markers(intent), "Success criteria"), continuations=True
|
|
708
|
+
)
|
|
709
|
+
return {f"SC{number}": _common.Criterion(text, bare[number]) for number, text in items.items()}
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _split_top_level_commas(value: str) -> List[str]:
|
|
713
|
+
"""Split on commas outside parentheses, brackets, and simple quoted strings."""
|
|
714
|
+
|
|
715
|
+
parts: List[str] = []
|
|
716
|
+
start = 0
|
|
717
|
+
depth_paren = 0
|
|
718
|
+
depth_bracket = 0
|
|
719
|
+
quote: Optional[str] = None
|
|
720
|
+
index = 0
|
|
721
|
+
while index < len(value):
|
|
722
|
+
character = value[index]
|
|
723
|
+
if quote is not None:
|
|
724
|
+
if character == quote:
|
|
725
|
+
quote = None
|
|
726
|
+
index += 1
|
|
727
|
+
continue
|
|
728
|
+
if character in {"'", '"'}:
|
|
729
|
+
quote = character
|
|
730
|
+
index += 1
|
|
731
|
+
continue
|
|
732
|
+
if character == "(":
|
|
733
|
+
depth_paren += 1
|
|
734
|
+
elif character == ")":
|
|
735
|
+
depth_paren = max(0, depth_paren - 1)
|
|
736
|
+
elif character == "[":
|
|
737
|
+
depth_bracket += 1
|
|
738
|
+
elif character == "]":
|
|
739
|
+
depth_bracket = max(0, depth_bracket - 1)
|
|
740
|
+
elif character == "," and depth_paren == 0 and depth_bracket == 0:
|
|
741
|
+
parts.append(value[start:index])
|
|
742
|
+
start = index + 1
|
|
743
|
+
index += 1
|
|
744
|
+
parts.append(value[start:])
|
|
745
|
+
return parts
|
|
628
746
|
|
|
629
747
|
|
|
630
748
|
def _surfaces(text: str, label: str) -> List[str]:
|
|
@@ -643,7 +761,7 @@ def _surfaces(text: str, label: str) -> List[str]:
|
|
|
643
761
|
_non_placeholder(value, f"{label} Surfaces")
|
|
644
762
|
named: List[str] = []
|
|
645
763
|
seen = set()
|
|
646
|
-
for item in value
|
|
764
|
+
for item in _split_top_level_commas(value):
|
|
647
765
|
if not item.strip():
|
|
648
766
|
continue
|
|
649
767
|
surface = _unquoted(item)
|
|
@@ -790,6 +908,338 @@ def _coverage_rows(plan: str) -> List[Tuple[str, str, str]]:
|
|
|
790
908
|
return rows
|
|
791
909
|
|
|
792
910
|
|
|
911
|
+
def _probe_table(
|
|
912
|
+
body: str, label: str, header: str, id_pattern: "re.Pattern[str]", width: int
|
|
913
|
+
) -> List[List[str]]:
|
|
914
|
+
"""Rows of one fixed-width probe table; the last cell keeps any `|` it holds."""
|
|
915
|
+
|
|
916
|
+
rows: List[List[str]] = []
|
|
917
|
+
for line in _strip_comments(body).splitlines():
|
|
918
|
+
stripped = line.strip()
|
|
919
|
+
if not stripped.startswith("|"):
|
|
920
|
+
# A row that lost its leading pipe would otherwise vanish silently.
|
|
921
|
+
if "|" in stripped and id_pattern.fullmatch(_unquoted(stripped.split("|", 1)[0])):
|
|
922
|
+
raise HandoffError(f"{label} row must start with |: {stripped}")
|
|
923
|
+
continue
|
|
924
|
+
cells = [_unquoted(cell) for cell in stripped.strip("|").split("|")]
|
|
925
|
+
first = cells[0] if cells else ""
|
|
926
|
+
if first.casefold() == header.casefold() or all(
|
|
927
|
+
TABLE_SEPARATOR_PATTERN.fullmatch(cell) for cell in cells if cell
|
|
928
|
+
):
|
|
929
|
+
continue
|
|
930
|
+
if not id_pattern.fullmatch(first):
|
|
931
|
+
raise HandoffError(f"{label} has an invalid row: {stripped}")
|
|
932
|
+
if len(cells) < width:
|
|
933
|
+
raise HandoffError(f"{label} {first} needs {width} cells")
|
|
934
|
+
rows.append(cells[: width - 1] + ["|".join(cells[width - 1 :]).strip()])
|
|
935
|
+
ids = [row[0] for row in rows]
|
|
936
|
+
repeated = sorted({value for value in ids if ids.count(value) > 1})
|
|
937
|
+
if repeated:
|
|
938
|
+
raise HandoffError(f"{label} repeats " + ", ".join(repeated))
|
|
939
|
+
# id_pattern fixes a one-letter prefix, so the ids sort by their number.
|
|
940
|
+
expected = [f"{ids[0][0]}{number}" for number in range(1, len(ids) + 1)] if ids else []
|
|
941
|
+
if sorted(ids, key=lambda value: int(value[1:])) != expected:
|
|
942
|
+
raise HandoffError(f"{label} ids must be contiguous from 1")
|
|
943
|
+
return rows
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
def _probe_section(text: str, heading: str, label: str) -> Optional[str]:
|
|
947
|
+
"""Body of one exact `## <heading>` section; a near-miss heading is an error.
|
|
948
|
+
|
|
949
|
+
The probe gates key on this heading, so a misspelled or re-levelled one
|
|
950
|
+
must fail loudly instead of reading as an intent written before the probe.
|
|
951
|
+
"""
|
|
952
|
+
|
|
953
|
+
live = _strip_comments(text)
|
|
954
|
+
exact = re.findall(rf"(?m)^## {re.escape(heading)}[ \t]*$", live)
|
|
955
|
+
near = [
|
|
956
|
+
line.strip()
|
|
957
|
+
for line in re.findall(rf"(?im)^[ \t]*#{{1,6}}[ \t]*{re.escape(heading)}\b.*$", live)
|
|
958
|
+
if not re.fullmatch(rf"## {re.escape(heading)}", line.strip())
|
|
959
|
+
]
|
|
960
|
+
if near:
|
|
961
|
+
raise HandoffError(f"{label} heading {near[0]!r} must be exactly '## {heading}'")
|
|
962
|
+
if len(exact) > 1:
|
|
963
|
+
raise HandoffError(f"{label} repeats ## {heading}")
|
|
964
|
+
if not exact:
|
|
965
|
+
return None
|
|
966
|
+
return _common.section_body(live, heading)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
def _criterion_disposition(
|
|
970
|
+
value: str, criteria: Dict[str, str], label: str
|
|
971
|
+
) -> Optional[str]:
|
|
972
|
+
match = CRITERION_DISPOSITION_PATTERN.fullmatch(" ".join(value.split()))
|
|
973
|
+
if match is None:
|
|
974
|
+
return None
|
|
975
|
+
target = match.group(1)
|
|
976
|
+
if target not in criteria:
|
|
977
|
+
raise HandoffError(f"{label} names unknown {target}")
|
|
978
|
+
return target
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def edge_coverage(
|
|
982
|
+
intent: str, criteria: Dict[str, str]
|
|
983
|
+
) -> Optional[Dict[str, Dict[str, str]]]:
|
|
984
|
+
"""INTENT.md `## Edge coverage`, or None for an intent written before the probe.
|
|
985
|
+
|
|
986
|
+
Each row rules on one applicable edge of one success criterion:
|
|
987
|
+
`criterion SCn` (the edge is stated in a success criterion), `held-out`
|
|
988
|
+
(a named test must pin the ruling in Detail), or `dismissed` (Detail says
|
|
989
|
+
why it does not apply). Every criterion appears at least once, so a
|
|
990
|
+
skipped walk is visible rather than silent.
|
|
991
|
+
"""
|
|
992
|
+
|
|
993
|
+
label = "INTENT.md Edge coverage"
|
|
994
|
+
body = _probe_section(intent, "Edge coverage", label)
|
|
995
|
+
if body is None:
|
|
996
|
+
return None
|
|
997
|
+
edges: Dict[str, Dict[str, str]] = {}
|
|
998
|
+
seen: Set[Tuple[str, str, str, str]] = set()
|
|
999
|
+
categories: Dict[str, Set[str]] = {}
|
|
1000
|
+
for edge_id, criterion, category, disposition, detail in _probe_table(
|
|
1001
|
+
body, label, "Edge", EDGE_ID_PATTERN, 5
|
|
1002
|
+
):
|
|
1003
|
+
row_label = f"{label} {edge_id}"
|
|
1004
|
+
if criterion not in criteria:
|
|
1005
|
+
raise HandoffError(f"{row_label} names unknown criterion {criterion or 'none'}")
|
|
1006
|
+
category = category.casefold()
|
|
1007
|
+
if category not in EDGE_CATEGORIES + ("none",):
|
|
1008
|
+
raise HandoffError(f"{row_label} has unknown category {category or 'none'}")
|
|
1009
|
+
disposition = " ".join(disposition.split())
|
|
1010
|
+
key = (criterion, category, disposition, _normalize_ws(detail))
|
|
1011
|
+
if key in seen:
|
|
1012
|
+
raise HandoffError(f"{row_label} repeats an earlier row")
|
|
1013
|
+
seen.add(key)
|
|
1014
|
+
categories.setdefault(criterion, set()).add(category)
|
|
1015
|
+
target = _criterion_disposition(disposition, criteria, row_label)
|
|
1016
|
+
if target is None and disposition not in {"held-out", "dismissed"}:
|
|
1017
|
+
raise HandoffError(
|
|
1018
|
+
f"{row_label} disposition must be criterion SCn, held-out, or dismissed"
|
|
1019
|
+
)
|
|
1020
|
+
if category == "none" and disposition != "dismissed":
|
|
1021
|
+
raise HandoffError(f"{row_label} category none must be dismissed")
|
|
1022
|
+
if target is None:
|
|
1023
|
+
detail = _non_placeholder(detail, f"{row_label} Detail")
|
|
1024
|
+
elif detail:
|
|
1025
|
+
_reject_placeholder(detail, f"{row_label} Detail")
|
|
1026
|
+
edges[edge_id] = {
|
|
1027
|
+
"criterion": criterion,
|
|
1028
|
+
"category": category,
|
|
1029
|
+
"disposition": "criterion" if target else disposition,
|
|
1030
|
+
"target": target or "",
|
|
1031
|
+
"detail": detail,
|
|
1032
|
+
}
|
|
1033
|
+
if not edges:
|
|
1034
|
+
raise HandoffError(f"{label} has no rows")
|
|
1035
|
+
for criterion, raised in categories.items():
|
|
1036
|
+
if "none" in raised and len(raised) > 1:
|
|
1037
|
+
raise HandoffError(f"{label} {criterion} mixes category none with edges")
|
|
1038
|
+
walked = {edge["criterion"] for edge in edges.values()}
|
|
1039
|
+
unwalked = [criterion for criterion in criteria if criterion not in walked]
|
|
1040
|
+
if unwalked:
|
|
1041
|
+
raise HandoffError(f"{label} does not walk " + ", ".join(unwalked))
|
|
1042
|
+
return edges
|
|
1043
|
+
|
|
1044
|
+
|
|
1045
|
+
def prohibitions(
|
|
1046
|
+
intent: str, criteria: Dict[str, str]
|
|
1047
|
+
) -> Optional[Dict[str, Dict[str, str]]]:
|
|
1048
|
+
"""INTENT.md `## Prohibitions`, or None for an intent written before the probe.
|
|
1049
|
+
|
|
1050
|
+
Each row names what one criterion must never silently become:
|
|
1051
|
+
`criterion SCn` (stated as a checkable success criterion), `judgment`
|
|
1052
|
+
(the final reviewer judges it; Detail says what to look for), or
|
|
1053
|
+
`dismissed`. Criterion `all` is allowed only for a dismissed row, so work
|
|
1054
|
+
no person touches needs one row instead of one per criterion.
|
|
1055
|
+
"""
|
|
1056
|
+
|
|
1057
|
+
label = "INTENT.md Prohibitions"
|
|
1058
|
+
body = _probe_section(intent, "Prohibitions", label)
|
|
1059
|
+
if body is None:
|
|
1060
|
+
return None
|
|
1061
|
+
rows: Dict[str, Dict[str, str]] = {}
|
|
1062
|
+
for prohibition_id, criterion, must_not, disposition, detail in _probe_table(
|
|
1063
|
+
body, label, "Prohibition", PROHIBITION_ID_PATTERN, 5
|
|
1064
|
+
):
|
|
1065
|
+
row_label = f"{label} {prohibition_id}"
|
|
1066
|
+
disposition = " ".join(disposition.split())
|
|
1067
|
+
if criterion.casefold() == "all":
|
|
1068
|
+
if disposition != "dismissed":
|
|
1069
|
+
raise HandoffError(f"{row_label} criterion all must be dismissed")
|
|
1070
|
+
criterion = "all"
|
|
1071
|
+
elif criterion not in criteria:
|
|
1072
|
+
raise HandoffError(f"{row_label} names unknown criterion {criterion or 'none'}")
|
|
1073
|
+
target = _criterion_disposition(disposition, criteria, row_label)
|
|
1074
|
+
if target is None and disposition not in {"judgment", "dismissed"}:
|
|
1075
|
+
raise HandoffError(
|
|
1076
|
+
f"{row_label} disposition must be criterion SCn, judgment, or dismissed"
|
|
1077
|
+
)
|
|
1078
|
+
if disposition != "dismissed":
|
|
1079
|
+
must_not = _non_placeholder(must_not, f"{row_label} Must not")
|
|
1080
|
+
elif must_not:
|
|
1081
|
+
_reject_placeholder(must_not, f"{row_label} Must not")
|
|
1082
|
+
if target is None:
|
|
1083
|
+
detail = _non_placeholder(detail, f"{row_label} Detail")
|
|
1084
|
+
elif detail:
|
|
1085
|
+
_reject_placeholder(detail, f"{row_label} Detail")
|
|
1086
|
+
rows[prohibition_id] = {
|
|
1087
|
+
"criterion": criterion,
|
|
1088
|
+
"must_not": must_not,
|
|
1089
|
+
"disposition": "criterion" if target else disposition,
|
|
1090
|
+
"target": target or "",
|
|
1091
|
+
"detail": detail,
|
|
1092
|
+
}
|
|
1093
|
+
if not rows:
|
|
1094
|
+
raise HandoffError(f"{label} has no rows")
|
|
1095
|
+
walked = {row["criterion"] for row in rows.values()}
|
|
1096
|
+
if "all" in walked:
|
|
1097
|
+
# `all` records that no person is affected at all, so it stands alone.
|
|
1098
|
+
if len(rows) != 1 or next(iter(rows.values()))["must_not"].casefold() != "none":
|
|
1099
|
+
raise HandoffError(f"{label} criterion all must be the only row, with Must not none")
|
|
1100
|
+
return rows
|
|
1101
|
+
unwalked = [criterion for criterion in criteria if criterion not in walked]
|
|
1102
|
+
if unwalked:
|
|
1103
|
+
raise HandoffError(f"{label} does not walk " + ", ".join(unwalked))
|
|
1104
|
+
return rows
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
def evidence_tags(intent: str, criteria: Dict[str, str]) -> Dict[str, List[str]]:
|
|
1108
|
+
"""Per criterion, the held-out edges and judgment prohibitions a `met` verdict must cite."""
|
|
1109
|
+
|
|
1110
|
+
tags: Dict[str, List[str]] = {}
|
|
1111
|
+
for edge_id, edge in (edge_coverage(intent, criteria) or {}).items():
|
|
1112
|
+
if edge["disposition"] == "held-out":
|
|
1113
|
+
tags.setdefault(edge["criterion"], []).append(edge_id)
|
|
1114
|
+
for prohibition_id, row in (prohibitions(intent, criteria) or {}).items():
|
|
1115
|
+
if row["disposition"] == "judgment":
|
|
1116
|
+
tags.setdefault(row["criterion"], []).append(prohibition_id)
|
|
1117
|
+
return tags
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
def _cites(text: str, tag: str) -> bool:
|
|
1121
|
+
"""A citation of an E#/N# id as a word, not inside a path, name, or longer id."""
|
|
1122
|
+
|
|
1123
|
+
return re.search(rf"(?<![A-Za-z0-9_./\\-]){re.escape(tag)}(?![A-Za-z0-9_])", text) is not None
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def _held_out_checks(plan: str) -> Dict[str, Tuple[str, str]]:
|
|
1127
|
+
body = _common.section_body(plan, "Held-out checks")
|
|
1128
|
+
if body is None:
|
|
1129
|
+
return {}
|
|
1130
|
+
label = "PLAN.md Held-out checks"
|
|
1131
|
+
checks: Dict[str, Tuple[str, str]] = {}
|
|
1132
|
+
for line in _strip_comments(body).splitlines():
|
|
1133
|
+
stripped = line.strip()
|
|
1134
|
+
if not stripped.startswith("|"):
|
|
1135
|
+
continue
|
|
1136
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
1137
|
+
if cells[0].casefold() == "edge" or all(
|
|
1138
|
+
TABLE_SEPARATOR_PATTERN.fullmatch(cell) for cell in cells if cell
|
|
1139
|
+
):
|
|
1140
|
+
continue
|
|
1141
|
+
if len(cells) != 3 or not EDGE_ID_PATTERN.fullmatch(cells[0]):
|
|
1142
|
+
raise HandoffError(f"{label} has an invalid row: {stripped}")
|
|
1143
|
+
edge_id, task_id, test = (_unquoted(cell) for cell in cells)
|
|
1144
|
+
if edge_id in checks:
|
|
1145
|
+
raise HandoffError(f"{label} repeats {edge_id}")
|
|
1146
|
+
# A pytest-style node id (`tests/x.py::test_y`) names its file before `::`.
|
|
1147
|
+
test = _canonical_repo_path(test.split("::", 1)[0], f"{label} {edge_id} Test")
|
|
1148
|
+
checks[edge_id] = (task_id, test)
|
|
1149
|
+
return checks
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _verify_without_comments(task_text: str) -> str:
|
|
1153
|
+
body = _section(task_text, "Verify")
|
|
1154
|
+
block = VERIFY_BLOCK_PATTERN.search(body)
|
|
1155
|
+
if block is None:
|
|
1156
|
+
return ""
|
|
1157
|
+
return _normalize_ws(re.sub(r"(?m)(?:^|\s)#.*$", "", block.group("block")))
|
|
1158
|
+
|
|
1159
|
+
|
|
1160
|
+
def _validate_held_out_checks(
|
|
1161
|
+
plan: str,
|
|
1162
|
+
edges: Optional[Dict[str, Dict[str, str]]],
|
|
1163
|
+
tasks: Dict[str, str],
|
|
1164
|
+
) -> Dict[str, str]:
|
|
1165
|
+
"""Each held-out edge maps to one task whose Verify runs the named test."""
|
|
1166
|
+
|
|
1167
|
+
held_out = sorted(
|
|
1168
|
+
(edge_id for edge_id, edge in (edges or {}).items() if edge["disposition"] == "held-out"),
|
|
1169
|
+
key=lambda value: int(value[1:]),
|
|
1170
|
+
)
|
|
1171
|
+
checks = _held_out_checks(plan)
|
|
1172
|
+
label = "PLAN.md Held-out checks"
|
|
1173
|
+
missing = [edge_id for edge_id in held_out if edge_id not in checks]
|
|
1174
|
+
if missing:
|
|
1175
|
+
raise HandoffError(f"{label} omits held-out " + ", ".join(missing))
|
|
1176
|
+
extra = sorted(set(checks) - set(held_out), key=lambda value: int(value[1:]))
|
|
1177
|
+
if extra:
|
|
1178
|
+
raise HandoffError(f"{label} names edges that are not held-out: " + ", ".join(extra))
|
|
1179
|
+
for edge_id in held_out:
|
|
1180
|
+
task_id, test = checks[edge_id]
|
|
1181
|
+
if task_id not in tasks:
|
|
1182
|
+
raise HandoffError(f"{label} {edge_id} names unknown {task_id}")
|
|
1183
|
+
text = tasks[task_id]
|
|
1184
|
+
criterion = (edges or {})[edge_id]["criterion"]
|
|
1185
|
+
if criterion not in _owned_criteria(text, task_id):
|
|
1186
|
+
raise HandoffError(
|
|
1187
|
+
f"{label} {edge_id} belongs to {criterion}, which {task_id} does not own"
|
|
1188
|
+
)
|
|
1189
|
+
files = [_posix_path(path) for path in _frontmatter_files(text)]
|
|
1190
|
+
if test not in files:
|
|
1191
|
+
raise HandoffError(f"{label} {edge_id} test {test} is not listed in {task_id} files")
|
|
1192
|
+
if any(other != test and other.startswith(test + "/") for other in files):
|
|
1193
|
+
raise HandoffError(f"{label} {edge_id} test {test} names a directory, not a test file")
|
|
1194
|
+
if not _verify_mentions_a_file(_verify_without_comments(text), [test]):
|
|
1195
|
+
raise HandoffError(f"{task_id} Verify must run held-out test {test} for {edge_id}")
|
|
1196
|
+
acceptance = " ".join(_acceptance_items(text, task_id).values())
|
|
1197
|
+
if not _cites(acceptance, edge_id):
|
|
1198
|
+
raise HandoffError(f"{task_id} Acceptance criteria must state held-out {edge_id}")
|
|
1199
|
+
return {edge_id: checks[edge_id][0] for edge_id in held_out}
|
|
1200
|
+
|
|
1201
|
+
|
|
1202
|
+
def validate_intent_probes(root: Path, project_dir: str = DEFAULT_PROJECT_DIR) -> None:
|
|
1203
|
+
"""Probe-table check for leaving define; a no-op without INTENT.md or its tables."""
|
|
1204
|
+
|
|
1205
|
+
path = root / _intent_path(project_dir)
|
|
1206
|
+
if not path.is_file():
|
|
1207
|
+
return
|
|
1208
|
+
intent = _read(root, _intent_path(project_dir))
|
|
1209
|
+
live = _strip_comments(intent)
|
|
1210
|
+
if not re.search(r"(?im)^[ \t]*#{1,6}[ \t]*(edge coverage|prohibitions)\b", live):
|
|
1211
|
+
return
|
|
1212
|
+
criteria = _success_criteria(intent)
|
|
1213
|
+
edge_coverage(intent, criteria)
|
|
1214
|
+
prohibitions(intent, criteria)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def validate_intent(
|
|
1218
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1219
|
+
) -> Dict[str, object]:
|
|
1220
|
+
"""Check INTENT.md success criteria and its spec-reach probe tables before approval."""
|
|
1221
|
+
|
|
1222
|
+
intent = _read(root, _intent_path(project_dir))
|
|
1223
|
+
criteria = _success_criteria(intent)
|
|
1224
|
+
_surfaces(intent, "INTENT.md")
|
|
1225
|
+
edges = edge_coverage(intent, criteria)
|
|
1226
|
+
rows = prohibitions(intent, criteria)
|
|
1227
|
+
return {
|
|
1228
|
+
"phase": "define",
|
|
1229
|
+
"criteria": sorted(criteria, key=lambda value: int(value[2:])),
|
|
1230
|
+
"edge_coverage": "absent" if edges is None else len(edges),
|
|
1231
|
+
"held_out": sorted(
|
|
1232
|
+
(edge_id for edge_id, edge in (edges or {}).items() if edge["disposition"] == "held-out"),
|
|
1233
|
+
key=lambda value: int(value[1:]),
|
|
1234
|
+
),
|
|
1235
|
+
"prohibitions": "absent" if rows is None else len(rows),
|
|
1236
|
+
"judgment": sorted(
|
|
1237
|
+
(row_id for row_id, row in (rows or {}).items() if row["disposition"] == "judgment"),
|
|
1238
|
+
key=lambda value: int(value[1:]),
|
|
1239
|
+
),
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
|
|
793
1243
|
def _task_texts(root: Path, project_dir: str) -> Dict[str, str]:
|
|
794
1244
|
tasks_dir = root / project_dir / "tasks"
|
|
795
1245
|
if not tasks_dir.is_dir():
|
|
@@ -860,18 +1310,22 @@ def _normalize_ws(value: str) -> str:
|
|
|
860
1310
|
|
|
861
1311
|
|
|
862
1312
|
def task_review_observation(item: str, task_text: str) -> str:
|
|
863
|
-
"""Separate
|
|
1313
|
+
"""Separate a quoted acceptance criterion from its observed evidence."""
|
|
864
1314
|
item = _normalize_ws(item)
|
|
865
1315
|
section = _common.section_body(task_text, "Acceptance criteria")
|
|
866
1316
|
if section is not None:
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
1317
|
+
# Issue #374: frozen landed bytes may repeat a criterion number. Only the
|
|
1318
|
+
# criterion text matters here, so each repeated entry is still split
|
|
1319
|
+
# from its evidence and the evidence gets the same checks.
|
|
1320
|
+
criteria = [criterion for _number, criterion in _numbered_entries(section, continuations=True)]
|
|
1321
|
+
quoted = _common.quoted_criterion(item, criteria)
|
|
1322
|
+
if quoted is not None:
|
|
1323
|
+
return item[quoted[1] + len(" — ") :]
|
|
1324
|
+
# Issue #392: a quote can leave out the `- ` markers of the criterion's
|
|
1325
|
+
# sub-bullet lines, so read the criteria again without them.
|
|
1326
|
+
bare = _common.without_sub_bullet_markers(task_text)
|
|
1327
|
+
return item if bare == task_text else task_review_observation(item, bare)
|
|
1328
|
+
|
|
875
1329
|
|
|
876
1330
|
|
|
877
1331
|
def _verify_command(task_text: str) -> str:
|
|
@@ -965,6 +1419,20 @@ def _task_deps(task_text: str, task_id: str) -> List[str]:
|
|
|
965
1419
|
return _inline_ids(rendered, "T", f"{task_id} deps")
|
|
966
1420
|
|
|
967
1421
|
|
|
1422
|
+
def _dependency_reachable(graph: Dict[str, List[str]], start: str, target: str) -> bool:
|
|
1423
|
+
seen: Set[str] = set()
|
|
1424
|
+
pending = list(graph[start])
|
|
1425
|
+
while pending:
|
|
1426
|
+
task_id = pending.pop()
|
|
1427
|
+
if task_id in seen:
|
|
1428
|
+
continue
|
|
1429
|
+
if task_id == target:
|
|
1430
|
+
return True
|
|
1431
|
+
seen.add(task_id)
|
|
1432
|
+
pending.extend(graph[task_id])
|
|
1433
|
+
return False
|
|
1434
|
+
|
|
1435
|
+
|
|
968
1436
|
def _canonical_repo_path(value: str, label: str) -> str:
|
|
969
1437
|
cleaned = value.strip().strip("`\"'")
|
|
970
1438
|
path = PurePosixPath(cleaned)
|
|
@@ -1077,12 +1545,42 @@ def _validate_task_graph(
|
|
|
1077
1545
|
if _task_wave(tasks[dependency], dependency) > wave:
|
|
1078
1546
|
raise HandoffError(f"{task_id} depends on later-wave {dependency}")
|
|
1079
1547
|
|
|
1548
|
+
# `supersedes` retires the regression Verify of an earlier task: the same overlap rule selects both.
|
|
1549
|
+
for task_id, text in tasks.items():
|
|
1550
|
+
superseded = _strict_frontmatter(text, task_id).get("supersedes", [])
|
|
1551
|
+
if (
|
|
1552
|
+
not isinstance(superseded, list)
|
|
1553
|
+
or any(not re.fullmatch(r"T\d{3}", item) for item in superseded)
|
|
1554
|
+
or len(superseded) != len(set(superseded))
|
|
1555
|
+
):
|
|
1556
|
+
raise HandoffError(f"{task_id} supersedes must be a list of different task ids, for example [T001]")
|
|
1557
|
+
for earlier in superseded:
|
|
1558
|
+
if earlier not in tasks:
|
|
1559
|
+
raise HandoffError(f"{task_id} supersedes unknown task {earlier}")
|
|
1560
|
+
if task_waves[earlier] >= task_waves[task_id]:
|
|
1561
|
+
raise HandoffError(f"{task_id} supersedes {earlier}, which is not in an earlier wave")
|
|
1562
|
+
if task_repos[earlier] != task_repos[task_id]:
|
|
1563
|
+
raise HandoffError(f"{task_id} supersedes {earlier}, which is in another repo")
|
|
1564
|
+
if not _common.overlapping_paths(task_files[earlier], task_files[task_id]):
|
|
1565
|
+
raise HandoffError(f"{task_id} supersedes {earlier}, which shares no declared file with it")
|
|
1566
|
+
|
|
1080
1567
|
for index, left in enumerate(task_ids):
|
|
1081
1568
|
for right in task_ids[index + 1 :]:
|
|
1082
1569
|
if task_waves[left] != task_waves[right] or task_repos[left] != task_repos[right]:
|
|
1083
1570
|
continue
|
|
1084
|
-
overlap =
|
|
1571
|
+
overlap = _common.overlapping_paths(task_files[left], task_files[right])
|
|
1085
1572
|
if overlap:
|
|
1573
|
+
if (
|
|
1574
|
+
_task_scalar(tasks[left], left, "status") == "done"
|
|
1575
|
+
and _task_scalar(tasks[right], right, "status") == "done"
|
|
1576
|
+
):
|
|
1577
|
+
continue
|
|
1578
|
+
# Dependency-ordered tasks serialize safely; only unordered
|
|
1579
|
+
# same-wave overlap races on a shared file.
|
|
1580
|
+
if _dependency_reachable(graph, left, right) or _dependency_reachable(
|
|
1581
|
+
graph, right, left
|
|
1582
|
+
):
|
|
1583
|
+
continue
|
|
1086
1584
|
raise HandoffError(
|
|
1087
1585
|
f"same-wave file overlap between {left} and {right}: {', '.join(overlap)}"
|
|
1088
1586
|
)
|
|
@@ -1116,11 +1614,12 @@ def _validate_task_graph(
|
|
|
1116
1614
|
|
|
1117
1615
|
|
|
1118
1616
|
def plan_brief_inputs(
|
|
1119
|
-
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
1617
|
+
root: Path, project_dir: str = DEFAULT_PROJECT_DIR, *, initial: bool = True
|
|
1120
1618
|
) -> Tuple[Dict[str, str], Dict[str, Set[str]]]:
|
|
1619
|
+
"""Validate the plan graph, requiring clean unlanded tasks only at approval."""
|
|
1121
1620
|
tasks = _task_texts(root, project_dir)
|
|
1122
1621
|
wave_depths, _ = _plan_waves(_read(root, f"{project_dir}/plan/PLAN.md"))
|
|
1123
|
-
return tasks, _validate_task_graph(wave_depths, tasks, initial=
|
|
1622
|
+
return tasks, _validate_task_graph(wave_depths, tasks, initial=initial)
|
|
1124
1623
|
|
|
1125
1624
|
|
|
1126
1625
|
def _intent_path(project_dir: str) -> str:
|
|
@@ -1283,7 +1782,24 @@ def validate_roadmap(
|
|
|
1283
1782
|
) -> Dict[str, object]:
|
|
1284
1783
|
"""Validate roadmap structure and dependency ordering."""
|
|
1285
1784
|
|
|
1286
|
-
|
|
1785
|
+
# A milestone-boundary re-slice keeps its entering inspect or define state.
|
|
1786
|
+
_require_state_one_of(
|
|
1787
|
+
root,
|
|
1788
|
+
{("roadmap", "active"), ("inspect", "active"), ("define", "active")},
|
|
1789
|
+
project_dir,
|
|
1790
|
+
)
|
|
1791
|
+
if _require_pipeline(root, project_dir)["phase"] == "define":
|
|
1792
|
+
if __package__:
|
|
1793
|
+
from . import build_recovery
|
|
1794
|
+
else:
|
|
1795
|
+
import build_recovery
|
|
1796
|
+
try:
|
|
1797
|
+
# Define entered again after intent approval is not a boundary.
|
|
1798
|
+
reopened = (build_recovery.context(root.resolve()) or {}).get("active")
|
|
1799
|
+
except PipelineStateError as error:
|
|
1800
|
+
raise HandoffError(str(error)) from error
|
|
1801
|
+
if reopened:
|
|
1802
|
+
raise HandoffError("roadmap re-slice is not legal after intent approval")
|
|
1287
1803
|
relative = f"{project_dir}/ROADMAP.md"
|
|
1288
1804
|
text = _read(root, relative)
|
|
1289
1805
|
if re.search(r"(?m)^## Wave |^\|\s*Task\s*\||^Files:\s*", text):
|
|
@@ -1475,6 +1991,9 @@ def validate_plan(
|
|
|
1475
1991
|
continue
|
|
1476
1992
|
if not _verify_mentions_a_file(command, _frontmatter_files(text)):
|
|
1477
1993
|
raise HandoffError(f"{task_id} Verify must name a path from files")
|
|
1994
|
+
edges = edge_coverage(intent, criteria)
|
|
1995
|
+
prohibitions(intent, criteria)
|
|
1996
|
+
held_out = _validate_held_out_checks(plan, edges, tasks)
|
|
1478
1997
|
return {
|
|
1479
1998
|
"phase": "plan",
|
|
1480
1999
|
"criteria": sorted(criteria),
|
|
@@ -1482,6 +2001,7 @@ def validate_plan(
|
|
|
1482
2001
|
"tasks": len(tasks),
|
|
1483
2002
|
"waves": waves,
|
|
1484
2003
|
"surfaces": {surface: task for surface, (task, _) in owners.items()},
|
|
2004
|
+
"held_out": held_out,
|
|
1485
2005
|
}
|
|
1486
2006
|
|
|
1487
2007
|
|
|
@@ -1543,17 +2063,27 @@ def validate_wave_evidence(
|
|
|
1543
2063
|
assigned[task_id].add(criterion)
|
|
1544
2064
|
owned = _owned_by_wave(tasks, assigned, wave)
|
|
1545
2065
|
text = _read(root, review_path.as_posix())
|
|
2066
|
+
is_last_cycle = cycle >= _max_wave_cycle(root, project_dir, wave)
|
|
1546
2067
|
if _line_value(text, "Cycle:") != str(cycle):
|
|
1547
2068
|
raise HandoffError(f"{name} Cycle field does not match its filename")
|
|
1548
|
-
|
|
2069
|
+
recorded_depth = _line_value(text, "Depth:")
|
|
2070
|
+
if recorded_depth not in VALID_REVIEW_DEPTHS:
|
|
2071
|
+
raise HandoffError(f"{name} Depth does not match PLAN.md")
|
|
2072
|
+
if is_last_cycle and recorded_depth != expected_depth:
|
|
1549
2073
|
raise HandoffError(f"{name} Depth does not match PLAN.md")
|
|
1550
2074
|
lens_fields = re.findall(r"(?m)^Lens:[ \t]*(\S.*?)[ \t]*$", text)
|
|
1551
2075
|
if lens is None and lens_fields:
|
|
1552
2076
|
raise HandoffError(f"{name} must not declare a review lens")
|
|
1553
2077
|
if lens is not None and lens_fields != [lens]:
|
|
1554
2078
|
raise HandoffError(f"{name} Lens field does not match its filename")
|
|
2079
|
+
overall = _line_value(text, "Wave verdict:")
|
|
2080
|
+
if overall not in {"pass", "blocked"}:
|
|
2081
|
+
raise HandoffError(f"{name} Wave verdict is invalid")
|
|
1555
2082
|
reviewed = _line_value(text, "Tasks reviewed:")
|
|
1556
|
-
|
|
2083
|
+
enforce_plan_tasks = is_last_cycle and overall != "blocked"
|
|
2084
|
+
if not reviewed.isdigit() or int(reviewed) < 1:
|
|
2085
|
+
raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
|
|
2086
|
+
if enforce_plan_tasks and int(reviewed) != len(expected_tasks):
|
|
1557
2087
|
raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
|
|
1558
2088
|
|
|
1559
2089
|
lines = text.splitlines()
|
|
@@ -1562,17 +2092,34 @@ def validate_wave_evidence(
|
|
|
1562
2092
|
match = WAVE_TASK_HEADING.fullmatch(line)
|
|
1563
2093
|
if match:
|
|
1564
2094
|
task_headings.append((index, match))
|
|
2095
|
+
if len(task_headings) != int(reviewed):
|
|
2096
|
+
raise HandoffError(f"{name} Tasks reviewed does not match Wave {wave}")
|
|
1565
2097
|
reviewed_tasks = [match.group("task") for _, match in task_headings]
|
|
1566
|
-
if
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
2098
|
+
if enforce_plan_tasks:
|
|
2099
|
+
if reviewed_tasks != expected_tasks:
|
|
2100
|
+
raise HandoffError(
|
|
2101
|
+
f"{name} must review exactly {', '.join(expected_tasks)} in task id order"
|
|
2102
|
+
)
|
|
2103
|
+
else:
|
|
2104
|
+
unknown = [task_id for task_id in reviewed_tasks if task_id not in expected_tasks]
|
|
2105
|
+
if unknown:
|
|
2106
|
+
raise HandoffError(
|
|
2107
|
+
f"{name} must review exactly {', '.join(expected_tasks)} in task id order"
|
|
2108
|
+
)
|
|
2109
|
+
plan_order = [task_id for task_id in expected_tasks if task_id in reviewed_tasks]
|
|
2110
|
+
if reviewed_tasks != plan_order:
|
|
2111
|
+
raise HandoffError(
|
|
2112
|
+
f"{name} must review exactly {', '.join(expected_tasks)} in task id order"
|
|
2113
|
+
)
|
|
1570
2114
|
task_verdicts = []
|
|
1571
2115
|
for heading_index, heading in task_headings:
|
|
1572
2116
|
task_id = heading.group("task")
|
|
1573
2117
|
expected_title = _normalize_ws(_task_scalar(tasks[task_id], task_id, "title"))
|
|
1574
2118
|
if _normalize_ws(heading.group("title")) != expected_title:
|
|
1575
|
-
raise HandoffError(
|
|
2119
|
+
raise HandoffError(
|
|
2120
|
+
f"{name} title for {task_id} differs from task file; "
|
|
2121
|
+
f"expected the exact `title:` value: {expected_title}"
|
|
2122
|
+
)
|
|
1576
2123
|
end = next(
|
|
1577
2124
|
(
|
|
1578
2125
|
index
|
|
@@ -1589,15 +2136,25 @@ def validate_wave_evidence(
|
|
|
1589
2136
|
if line.strip().startswith(f"- {marker} ")
|
|
1590
2137
|
]
|
|
1591
2138
|
if not evidence:
|
|
1592
|
-
raise HandoffError(
|
|
2139
|
+
raise HandoffError(
|
|
2140
|
+
f"{name} task {task_id} lacks {verdict} evidence; "
|
|
2141
|
+
f"add a line that starts with `- {marker} ` under its heading"
|
|
2142
|
+
)
|
|
1593
2143
|
for item in evidence:
|
|
1594
2144
|
item = task_review_observation(item, tasks[task_id])
|
|
1595
2145
|
_non_placeholder(item, f"{name} task {task_id} {verdict} evidence")
|
|
1596
2146
|
task_verdicts.append(verdict)
|
|
1597
2147
|
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2148
|
+
if not is_last_cycle:
|
|
2149
|
+
return {
|
|
2150
|
+
"phase": "wave",
|
|
2151
|
+
"wave": wave,
|
|
2152
|
+
"cycle": cycle,
|
|
2153
|
+
"owned": owned if owned else [],
|
|
2154
|
+
"review": review,
|
|
2155
|
+
"verdict": overall,
|
|
2156
|
+
}
|
|
2157
|
+
|
|
1601
2158
|
if overall == "pass" and "fail" in task_verdicts:
|
|
1602
2159
|
raise HandoffError(f"{name} Wave verdict is pass while a task failed")
|
|
1603
2160
|
try:
|
|
@@ -1634,7 +2191,7 @@ def validate_wave_evidence(
|
|
|
1634
2191
|
sc_id in criteria
|
|
1635
2192
|
and (
|
|
1636
2193
|
not separator
|
|
1637
|
-
or
|
|
2194
|
+
or not _common.quotes_criterion(heading_text, criteria[sc_id])
|
|
1638
2195
|
)
|
|
1639
2196
|
):
|
|
1640
2197
|
raise HandoffError(f"{name} {sc_id} heading text differs from INTENT.md")
|
|
@@ -1652,7 +2209,10 @@ def validate_wave_evidence(
|
|
|
1652
2209
|
if line.strip().startswith(f"- {marker} ")
|
|
1653
2210
|
]
|
|
1654
2211
|
if not evidence:
|
|
1655
|
-
raise HandoffError(
|
|
2212
|
+
raise HandoffError(
|
|
2213
|
+
f"{name} {sc_id} lacks {verdict} evidence; "
|
|
2214
|
+
f"add a line that starts with `- {marker} ` under its heading"
|
|
2215
|
+
)
|
|
1656
2216
|
for item in evidence:
|
|
1657
2217
|
_non_placeholder(item, f"{name} {sc_id} {verdict} evidence")
|
|
1658
2218
|
verdicts[sc_id] = verdict
|
|
@@ -1692,6 +2252,7 @@ def validate_final(
|
|
|
1692
2252
|
intent = _read(root, _intent_path(project_dir))
|
|
1693
2253
|
criteria = _success_criteria(intent)
|
|
1694
2254
|
surface_of = criterion_surfaces(root, project_dir)
|
|
2255
|
+
tags = evidence_tags(intent, criteria)
|
|
1695
2256
|
relative = f"{project_dir}/review/FINAL.md"
|
|
1696
2257
|
text = _read(root, relative) if final_text is None else final_text
|
|
1697
2258
|
reviewed_head = _reviewed_head(text, relative)
|
|
@@ -1731,7 +2292,7 @@ def validate_final(
|
|
|
1731
2292
|
verdicts: Dict[str, str] = {}
|
|
1732
2293
|
for index, heading in enumerate(blocks):
|
|
1733
2294
|
sc_id = heading.group("id")
|
|
1734
|
-
if
|
|
2295
|
+
if not _common.quotes_criterion(heading.group("title"), criteria[sc_id]):
|
|
1735
2296
|
raise HandoffError(f"FINAL.md {sc_id} heading text differs from INTENT.md")
|
|
1736
2297
|
end = blocks[index + 1].start() if index + 1 < len(blocks) else len(text)
|
|
1737
2298
|
block = text[heading.end() : end]
|
|
@@ -1769,6 +2330,16 @@ def validate_final(
|
|
|
1769
2330
|
raise HandoffError(
|
|
1770
2331
|
f"FINAL.md {sc_id} met verdict must have no Finding or Fix direction"
|
|
1771
2332
|
)
|
|
2333
|
+
# Honest verifier: a spec-tagged criterion is met only on cited
|
|
2334
|
+
# evidence for each tag; otherwise the reviewer records unverifiable.
|
|
2335
|
+
cited = " ".join((check, observed, reference))
|
|
2336
|
+
uncited = [tag for tag in tags.get(sc_id, []) if not _cites(cited, tag)]
|
|
2337
|
+
if uncited:
|
|
2338
|
+
raise HandoffError(
|
|
2339
|
+
f"FINAL.md {sc_id} is met without cited evidence for "
|
|
2340
|
+
+ ", ".join(uncited)
|
|
2341
|
+
+ "; cite each held-out check or judgment, or record unverifiable"
|
|
2342
|
+
)
|
|
1772
2343
|
elif finding.casefold() == "none" or fix_direction.casefold() == "none":
|
|
1773
2344
|
raise HandoffError(
|
|
1774
2345
|
f"FINAL.md {sc_id} {verdict} verdict requires a Finding and Fix direction"
|
|
@@ -1889,10 +2460,15 @@ def validate_patch_findings(
|
|
|
1889
2460
|
source, source_text, locator
|
|
1890
2461
|
)
|
|
1891
2462
|
if evidence != source_evidence:
|
|
1892
|
-
|
|
2463
|
+
evidence_field = "Finding" if source.endswith("FINAL.md") else "Found"
|
|
2464
|
+
raise HandoffError(
|
|
2465
|
+
f"{source} locator {locator} Evidence must equal the source's "
|
|
2466
|
+
f"{evidence_field} field; copy it verbatim: {source_evidence!r}"
|
|
2467
|
+
)
|
|
1893
2468
|
if fix_direction != source_fix_direction:
|
|
1894
2469
|
raise HandoffError(
|
|
1895
|
-
f"{source} locator {locator}
|
|
2470
|
+
f"{source} locator {locator} Fix direction must equal the source's "
|
|
2471
|
+
f"Fix direction field; copy it verbatim: {source_fix_direction!r}"
|
|
1896
2472
|
)
|
|
1897
2473
|
sources.append(source)
|
|
1898
2474
|
|
|
@@ -1910,7 +2486,8 @@ def validate_patch_findings(
|
|
|
1910
2486
|
def parser() -> argparse.ArgumentParser:
|
|
1911
2487
|
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
1912
2488
|
argument_parser.add_argument(
|
|
1913
|
-
"phase",
|
|
2489
|
+
"phase",
|
|
2490
|
+
choices=("intent", "research", "decide", "roadmap", "patch", "plan", "wave", "final"),
|
|
1914
2491
|
)
|
|
1915
2492
|
argument_parser.add_argument("--repo", type=Path, required=True)
|
|
1916
2493
|
argument_parser.add_argument(
|
|
@@ -1938,6 +2515,7 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
|
1938
2515
|
result = validate_wave(repo, arguments.project_dir, arguments.review)
|
|
1939
2516
|
else:
|
|
1940
2517
|
validators = {
|
|
2518
|
+
"intent": validate_intent,
|
|
1941
2519
|
"research": validate_research,
|
|
1942
2520
|
"decide": validate_decide,
|
|
1943
2521
|
"roadmap": validate_roadmap,
|