@opengsd/gsd-path 1.6.2 → 1.7.1
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 +30 -0
- package/DOCS.md +27 -1
- package/FULL.md +4 -3
- package/HOOKS.md +38 -4
- package/QUICK.md +1 -1
- package/README.md +19 -11
- package/RUNTIME.md +5 -3
- package/WORKFLOW.md +11 -4
- package/package.json +1 -1
- package/scripts/_common.py +76 -2
- package/scripts/archive_milestone.py +129 -7
- package/scripts/build_recovery.py +25 -2
- package/scripts/build_state.py +333 -8
- package/scripts/check_handoffs.py +437 -30
- package/scripts/check_task_briefs.py +135 -11
- package/scripts/discussion_validate.py +46 -10
- package/scripts/dispatch_driver.py +33 -3
- package/scripts/git_guard.py +44 -4
- package/scripts/guard_hook.py +456 -9
- package/scripts/install.py +34 -4
- package/scripts/integration.py +196 -39
- package/scripts/isolation.py +1000 -38
- package/scripts/lean_verification.py +28 -13
- package/scripts/loop_run.py +9 -4
- package/scripts/members.py +17 -3
- package/scripts/pipeline_diagnose.py +104 -1
- package/scripts/pipeline_state.py +10 -2
- package/scripts/runtime_store.py +88 -3
- package/scripts/state_checkpoint.py +15 -0
- package/scripts/status_runtime.py +73 -2
- package/scripts/workflow_run.py +10 -3
- package/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/BUILD.md +4 -1
- package/skills/gsd-path/DEFINE.md +2 -1
- package/skills/gsd-path/PLAN.md +13 -3
- package/skills/gsd-path/SHIP.md +41 -8
- package/skills/gsd-path/SKILL.md +16 -1
- package/skills/gsd-path/references/build-native.md +28 -7
- package/skills/gsd-path/references/coder.md +6 -3
- package/skills/gsd-path/references/planner.md +4 -3
- package/skills/gsd-path/references/reviewer-final.md +10 -0
- package/skills/gsd-path/references/reviewer-wave.md +71 -27
- package/skills/gsd-path/references/reviewer.md +21 -8
- package/skills/gsd-path/references/runtime-selection.md +36 -0
- package/skills/gsd-path/references/spec-probes.md +19 -1
- package/skills/gsd-path/scripts/_common.py +76 -2
- package/skills/gsd-path/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path/scripts/build_recovery.py +25 -2
- package/skills/gsd-path/scripts/build_state.py +333 -8
- package/skills/gsd-path/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path/scripts/dispatch_driver.py +33 -3
- package/skills/gsd-path/scripts/git_guard.py +44 -4
- package/skills/gsd-path/scripts/integration.py +196 -39
- package/skills/gsd-path/scripts/isolation.py +1000 -38
- package/skills/gsd-path/scripts/lean_verification.py +28 -13
- package/skills/gsd-path/scripts/loop_run.py +9 -4
- package/skills/gsd-path/scripts/members.py +17 -3
- package/skills/gsd-path/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path/scripts/status_runtime.py +73 -2
- package/skills/gsd-path/scripts/workflow_run.py +10 -3
- package/skills/gsd-path/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path/templates/intent.md +5 -3
- package/skills/gsd-path/templates/plan.md +3 -2
- package/skills/gsd-path/templates/task.md +8 -1
- package/skills/gsd-path/templates/wave-review.md +24 -8
- package/skills/gsd-path-build/SKILL.md +4 -1
- package/skills/gsd-path-build/references/build-native.md +28 -7
- package/skills/gsd-path-build/references/coder.md +6 -3
- package/skills/gsd-path-build/references/reviewer-final.md +10 -0
- package/skills/gsd-path-build/references/reviewer-wave.md +71 -27
- package/skills/gsd-path-build/references/reviewer.md +21 -8
- package/skills/gsd-path-build/references/runtime-selection.md +36 -0
- package/skills/gsd-path-build/references/spec-probes.md +19 -1
- package/skills/gsd-path-build/scripts/_common.py +76 -2
- package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-build/scripts/build_state.py +333 -8
- package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-build/scripts/dispatch_driver.py +33 -3
- package/skills/gsd-path-build/scripts/integration.py +196 -39
- package/skills/gsd-path-build/scripts/isolation.py +1000 -38
- package/skills/gsd-path-build/scripts/loop_run.py +9 -4
- package/skills/gsd-path-build/scripts/members.py +17 -3
- package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-build/templates/task.md +8 -1
- package/skills/gsd-path-build/templates/wave-review.md +24 -8
- package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
- package/skills/gsd-path-decide/scripts/_common.py +76 -2
- package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-decide/scripts/integration.py +196 -39
- package/skills/gsd-path-decide/scripts/isolation.py +1000 -38
- package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
- package/skills/gsd-path-decide/scripts/members.py +17 -3
- package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/SKILL.md +2 -1
- package/skills/gsd-path-define/references/runtime-selection.md +36 -0
- package/skills/gsd-path-define/references/spec-probes.md +19 -1
- package/skills/gsd-path-define/scripts/_common.py +76 -2
- package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-define/scripts/integration.py +196 -39
- package/skills/gsd-path-define/scripts/isolation.py +1000 -38
- package/skills/gsd-path-define/scripts/loop_run.py +9 -4
- package/skills/gsd-path-define/scripts/members.py +17 -3
- package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-define/templates/intent.md +5 -3
- package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
- package/skills/gsd-path-discuss/scripts/_common.py +76 -2
- package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
- package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
- package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
- package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
- package/skills/gsd-path-docs-audit/scripts/isolation.py +1000 -38
- package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
- package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
- package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
- package/skills/gsd-path-forensics/scripts/_common.py +76 -2
- package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
- package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
- package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
- package/skills/gsd-path-inspect/scripts/_common.py +76 -2
- package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-inspect/scripts/integration.py +196 -39
- package/skills/gsd-path-inspect/scripts/isolation.py +1000 -38
- package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
- package/skills/gsd-path-inspect/scripts/members.py +17 -3
- package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
- package/skills/gsd-path-loop/scripts/_common.py +76 -2
- package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
- package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
- package/skills/gsd-path-migrate/scripts/_common.py +76 -2
- package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/SKILL.md +13 -3
- package/skills/gsd-path-plan/references/planner.md +4 -3
- package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
- package/skills/gsd-path-plan/references/spec-probes.md +19 -1
- package/skills/gsd-path-plan/scripts/_common.py +76 -2
- package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-plan/scripts/integration.py +196 -39
- package/skills/gsd-path-plan/scripts/isolation.py +1000 -38
- package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
- package/skills/gsd-path-plan/scripts/members.py +17 -3
- package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-plan/templates/plan.md +3 -2
- package/skills/gsd-path-plan/templates/task.md +8 -1
- package/skills/gsd-path-research/references/runtime-selection.md +36 -0
- package/skills/gsd-path-research/scripts/_common.py +76 -2
- package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-research/scripts/integration.py +196 -39
- package/skills/gsd-path-research/scripts/isolation.py +1000 -38
- package/skills/gsd-path-research/scripts/loop_run.py +9 -4
- package/skills/gsd-path-research/scripts/members.py +17 -3
- package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
- package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
- package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
- package/skills/gsd-path-roadmap/scripts/isolation.py +1000 -38
- package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
- package/skills/gsd-path-roadmap/scripts/members.py +17 -3
- package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/SKILL.md +41 -8
- package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
- package/skills/gsd-path-ship/references/reviewer-wave.md +71 -27
- package/skills/gsd-path-ship/references/reviewer.md +21 -8
- package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
- package/skills/gsd-path-ship/references/spec-probes.md +19 -1
- package/skills/gsd-path-ship/scripts/_common.py +76 -2
- package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
- package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
- package/skills/gsd-path-ship/scripts/build_state.py +333 -8
- package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
- package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
- package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
- package/skills/gsd-path-ship/scripts/integration.py +196 -39
- package/skills/gsd-path-ship/scripts/isolation.py +1000 -38
- package/skills/gsd-path-ship/scripts/lean_verification.py +28 -13
- package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
- package/skills/gsd-path-ship/scripts/members.py +17 -3
- package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
- package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
- package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
- package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
- package/skills/gsd-path-ship/templates/wave-review.md +24 -8
- package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
- package/skills/gsd-path-undo/scripts/_common.py +76 -2
- package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
- package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
- package/skills/path/BUILD.md +4 -1
- package/skills/path/DEFINE.md +2 -1
- package/skills/path/PLAN.md +13 -3
- package/skills/path/SHIP.md +41 -8
- package/skills/path/SKILL.md +16 -1
- package/skills/path/references/build-native.md +28 -7
- package/skills/path/references/coder.md +6 -3
- package/skills/path/references/planner.md +4 -3
- package/skills/path/references/reviewer-final.md +10 -0
- package/skills/path/references/reviewer-wave.md +71 -27
- package/skills/path/references/reviewer.md +21 -8
- package/skills/path/references/runtime-selection.md +36 -0
- package/skills/path/references/spec-probes.md +19 -1
- package/skills/path/scripts/_common.py +76 -2
- package/skills/path/scripts/archive_milestone.py +129 -7
- package/skills/path/scripts/build_recovery.py +25 -2
- package/skills/path/scripts/build_state.py +333 -8
- package/skills/path/scripts/check_handoffs.py +437 -30
- package/skills/path/scripts/check_task_briefs.py +135 -11
- package/skills/path/scripts/discussion_validate.py +46 -10
- package/skills/path/scripts/dispatch_driver.py +33 -3
- package/skills/path/scripts/git_guard.py +44 -4
- package/skills/path/scripts/integration.py +196 -39
- package/skills/path/scripts/isolation.py +1000 -38
- package/skills/path/scripts/lean_verification.py +28 -13
- package/skills/path/scripts/loop_run.py +9 -4
- package/skills/path/scripts/members.py +17 -3
- package/skills/path/scripts/pipeline_state.py +10 -2
- package/skills/path/scripts/state_checkpoint.py +15 -0
- package/skills/path/scripts/status_runtime.py +73 -2
- package/skills/path/scripts/workflow_run.py +10 -3
- package/skills/path/scripts/worktree_paths.py +45 -0
- package/skills/path/templates/intent.md +5 -3
- package/skills/path/templates/plan.md +3 -2
- package/skills/path/templates/task.md +8 -1
- package/skills/path/templates/wave-review.md +24 -8
|
@@ -144,7 +144,13 @@ def settled_tasks(repo: Path) -> dict[str, tuple[str, dict]]:
|
|
|
144
144
|
raise state.PipelineStateError(f"invalid recovery task path: {path.name}")
|
|
145
145
|
text = state._read_real_file(path, path.name)
|
|
146
146
|
fields = task_fields(text, path.name)
|
|
147
|
-
if fields.get("status")
|
|
147
|
+
if fields.get("status") == "withdrawn":
|
|
148
|
+
# A cited tombstone is settled inventory; recovery skips it (#483).
|
|
149
|
+
if not state._common.withdrawn_citations(text):
|
|
150
|
+
raise state.PipelineStateError(
|
|
151
|
+
f"withdrawn task lacks an owner citation: {path.name}"
|
|
152
|
+
)
|
|
153
|
+
elif fields.get("status") not in {"pending", "done"}:
|
|
148
154
|
raise state.PipelineStateError(f"settle task ownership before recovery: {path.name}")
|
|
149
155
|
if fields["status"] == "pending" and any(
|
|
150
156
|
fields.get(key) != "null" for key in ("agent", "base", "worktree", "task_branch")
|
|
@@ -250,7 +256,24 @@ def validate_review_backup(repo: Path, recovery: dict, directory: Path) -> None:
|
|
|
250
256
|
for path in directory.rglob("*")
|
|
251
257
|
if path.is_file() and not state._common.is_ignored_junk(path)} if directory.exists() else {}
|
|
252
258
|
if actual != expected:
|
|
253
|
-
|
|
259
|
+
extra = sorted(set(actual) - set(expected))
|
|
260
|
+
changed = sorted(
|
|
261
|
+
name for name in set(actual) & set(expected) if actual[name] != expected[name]
|
|
262
|
+
)
|
|
263
|
+
missing = sorted(set(expected) - set(actual))
|
|
264
|
+
detail = ""
|
|
265
|
+
if extra:
|
|
266
|
+
detail += f" files in the review backup that the recovery base does not have: {', '.join(extra)}."
|
|
267
|
+
if changed:
|
|
268
|
+
detail += f" files differing from the recovery base: {', '.join(changed)}."
|
|
269
|
+
if missing:
|
|
270
|
+
detail += f" files at the recovery base missing from the review backup: {', '.join(missing)}."
|
|
271
|
+
raise state.PipelineStateError(
|
|
272
|
+
"review backup differs from the recovery base." + detail
|
|
273
|
+
+ " prepare-build-recovery must run before re-planning touches any recovery"
|
|
274
|
+
" artifact; remove the extra files and restore the missing or changed ones"
|
|
275
|
+
" to the recovery base's content, then rerun prepare"
|
|
276
|
+
)
|
|
254
277
|
|
|
255
278
|
|
|
256
279
|
def prepare(repo: Path) -> dict:
|
|
@@ -75,7 +75,7 @@ def _max_wave_cycle(root: Path, project_dir: str, wave: int) -> int:
|
|
|
75
75
|
return maximum
|
|
76
76
|
|
|
77
77
|
|
|
78
|
-
WAVE_SC_HEADING = re.compile(r"^### (SC[1-9]\d*) — (.+)$")
|
|
78
|
+
WAVE_SC_HEADING = re.compile(r"^### (SC[1-9]\d*) — (.+): (pass|fail)$")
|
|
79
79
|
WAVE_TASK_HEADING = re.compile(
|
|
80
80
|
r"^## (?P<task>T\d{3}) — (?P<title>.+): (?P<verdict>pass|fail)$"
|
|
81
81
|
)
|
|
@@ -650,20 +650,61 @@ def _member_tips(root: Path) -> Dict[str, str]:
|
|
|
650
650
|
import isolation
|
|
651
651
|
except ImportError: # pragma: no cover - package import used by tests
|
|
652
652
|
from scripts import isolation
|
|
653
|
-
|
|
654
|
-
if
|
|
653
|
+
shown = isolation.run_git(root, "show", "HEAD:.project/build/members.json")
|
|
654
|
+
if shown.returncode != 0:
|
|
655
655
|
return {}
|
|
656
|
-
|
|
656
|
+
# A review sidecar of the coordinator validates against the same member
|
|
657
|
+
# markers (#467): the coordinator is the one the member marker names, so
|
|
658
|
+
# repair advice never points at a disposable sidecar. Both locks are read
|
|
659
|
+
# from committed state, and the coordinator's must agree with the reviewed
|
|
660
|
+
# tree's, so a mutable primary cannot swap the branch under a reviewed final.
|
|
657
661
|
try:
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
+
members = json.loads(shown.stdout)["members"]
|
|
663
|
+
if not members:
|
|
664
|
+
return {}
|
|
665
|
+
coordinator = _coordinator_for_member_markers(root, members[0]["name"])
|
|
666
|
+
tips: Dict[str, str] = {}
|
|
667
|
+
for item in members:
|
|
668
|
+
checkout, _, entry = isolation._member_context(coordinator, item["name"], lock_at="HEAD")
|
|
669
|
+
if entry != item:
|
|
670
|
+
raise HandoffError(
|
|
671
|
+
"member lock differs between the reviewed tree and its "
|
|
672
|
+
f"coordinator for {item['name']}")
|
|
673
|
+
tips[item["name"]] = isolation.git_output(
|
|
674
|
+
checkout, "rev-parse", "--verify", f"refs/heads/{item['branch']}^{{commit}}")
|
|
662
675
|
except isolation.IsolationError as error:
|
|
663
676
|
raise HandoffError(str(error)) from error
|
|
664
677
|
return tips
|
|
665
678
|
|
|
666
679
|
|
|
680
|
+
def _coordinator_for_member_markers(root: Path, name: str) -> Path:
|
|
681
|
+
"""The coordinator that the marker of member `name`, as listed in root's
|
|
682
|
+
committed MEMBERS.md, names and verifies; it must share root's repository."""
|
|
683
|
+
try:
|
|
684
|
+
import isolation
|
|
685
|
+
import members
|
|
686
|
+
except ImportError: # pragma: no cover - package import used by tests
|
|
687
|
+
from scripts import isolation, members
|
|
688
|
+
stale = f"member marker for {name} is missing or stale; run members.py repair --repo <coordinator>"
|
|
689
|
+
shown = isolation.run_git(root, "show", "HEAD:.project/MEMBERS.md")
|
|
690
|
+
if shown.returncode != 0:
|
|
691
|
+
raise HandoffError(f"member {name} is not in MEMBERS.md")
|
|
692
|
+
try:
|
|
693
|
+
listed = {item["name"]: item for item in members.parse_members(shown.stdout)}
|
|
694
|
+
if name not in listed:
|
|
695
|
+
raise HandoffError(f"member {name} is not in MEMBERS.md")
|
|
696
|
+
role = members.member_role(Path(listed[name]["checkout"]).resolve())
|
|
697
|
+
except members.MembersError as error:
|
|
698
|
+
raise HandoffError(str(error)) from error
|
|
699
|
+
if role is None or role["name"] != name:
|
|
700
|
+
raise HandoffError(stale)
|
|
701
|
+
coordinator = Path(role["coordinator"])
|
|
702
|
+
if isolation.common_git_dir(coordinator) != isolation.common_git_dir(root):
|
|
703
|
+
raise HandoffError(
|
|
704
|
+
f"member marker for {name} names a coordinator outside this repository: {coordinator}")
|
|
705
|
+
return coordinator
|
|
706
|
+
|
|
707
|
+
|
|
667
708
|
def _strip_comments(text: str) -> str:
|
|
668
709
|
return COMMENT_PATTERN.sub("", text)
|
|
669
710
|
|
|
@@ -686,6 +727,66 @@ def _numbered_entries(section: str, *, continuations: bool = False) -> List[Tupl
|
|
|
686
727
|
return entries
|
|
687
728
|
|
|
688
729
|
|
|
730
|
+
CLAIM_ID_IN_REVIEW = re.compile(r"\b(?P<task>T\d{3})/(?P<kind>AC|IF)(?P<number>[1-9]\d*)\b")
|
|
731
|
+
CLAIM_DISPOSITION_LINE = re.compile(
|
|
732
|
+
r"^- (?P<ids>[^—]+?) — disposition: "
|
|
733
|
+
r"(?P<authority>[^:]*\b\d{4}-\d{2}-\d{2}\b[^:]*): (?P<reason>\S.*)$"
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
def _interface_bullets(task_text: str) -> Dict[int, str]:
|
|
738
|
+
"""The task's interface contract bullets, numbered by position."""
|
|
739
|
+
try:
|
|
740
|
+
section = _section(task_text, "Interface contract")
|
|
741
|
+
except HandoffError:
|
|
742
|
+
return {}
|
|
743
|
+
bullets: Dict[int, str] = {}
|
|
744
|
+
for line in _strip_comments(section).splitlines():
|
|
745
|
+
stripped = line.strip()
|
|
746
|
+
if stripped.startswith("- ") and stripped not in {"- None"}:
|
|
747
|
+
bullets[len(bullets) + 1] = stripped[2:].strip()
|
|
748
|
+
elif stripped and not stripped.startswith("- ") and bullets:
|
|
749
|
+
# A continuation of the previous bullet joins it with one space.
|
|
750
|
+
bullets[len(bullets)] += " " + stripped
|
|
751
|
+
return bullets
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def task_claims(task_text: str, task_id: str) -> Dict[str, str]:
|
|
755
|
+
"""Every acceptance and interface claim of a task under a stable id.
|
|
756
|
+
|
|
757
|
+
`T012/AC3` names the third numbered item of `## Acceptance criteria`;
|
|
758
|
+
`T012/IF2` the second bullet of `## Interface contract` (#474). The ids
|
|
759
|
+
are stable for the life of the task file: numbering is position-derived.
|
|
760
|
+
"""
|
|
761
|
+
claims: Dict[str, str] = {}
|
|
762
|
+
try:
|
|
763
|
+
section = _section(task_text, "Acceptance criteria")
|
|
764
|
+
except HandoffError:
|
|
765
|
+
section = None
|
|
766
|
+
if section is not None:
|
|
767
|
+
# Ids are positional: a frozen landed task may repeat a criterion
|
|
768
|
+
# number (#374), and the third numbered item is still AC3.
|
|
769
|
+
position = 0
|
|
770
|
+
for line in _strip_comments(section).splitlines():
|
|
771
|
+
match = NUMBERED_ITEM_PATTERN.fullmatch(line.strip())
|
|
772
|
+
if match is None:
|
|
773
|
+
continue
|
|
774
|
+
position += 1
|
|
775
|
+
claims[f"{task_id}/AC{position}"] = match.group(2).strip()
|
|
776
|
+
try:
|
|
777
|
+
joined = _numbered_items(section, continuations=True)
|
|
778
|
+
except HandoffError:
|
|
779
|
+
joined = {}
|
|
780
|
+
if joined and sorted(joined) == list(range(1, len(joined) + 1)) and len(joined) == position:
|
|
781
|
+
# Continuation lines below a numbered item join it (#374 wraps);
|
|
782
|
+
# a repeated-number frozen task keeps its positional claims.
|
|
783
|
+
for number, body in joined.items():
|
|
784
|
+
claims[f"{task_id}/AC{number}"] = body
|
|
785
|
+
for number, body in _interface_bullets(task_text).items():
|
|
786
|
+
claims[f"{task_id}/IF{number}"] = body
|
|
787
|
+
return claims
|
|
788
|
+
|
|
789
|
+
|
|
689
790
|
def _numbered_items(section: str, *, continuations: bool = False) -> Dict[int, str]:
|
|
690
791
|
items: Dict[int, str] = {}
|
|
691
792
|
for number, text in _numbered_entries(section, continuations=continuations):
|
|
@@ -695,6 +796,48 @@ def _numbered_items(section: str, *, continuations: bool = False) -> Dict[int, s
|
|
|
695
796
|
return items
|
|
696
797
|
|
|
697
798
|
|
|
799
|
+
CORRECTION_ENTRY = re.compile(r"^- (?P<id>C[1-9]\d*) (?P<date>\d{4}-\d{2}-\d{2}): (?P<text>\S.*)$")
|
|
800
|
+
CORRECTION_CITATION = re.compile(r"\b(?P<id>C[1-9]\d*)\b")
|
|
801
|
+
EXCLUDED_BY_LINE = re.compile(
|
|
802
|
+
r"^- Excluded by (?P<correction>C[1-9]\d*): (?P<subclaim>\S.*)$"
|
|
803
|
+
)
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
def _corrections(intent: str) -> Dict[str, str]:
|
|
807
|
+
"""The numbered, append-only owner corrections of INTENT.md (#477).
|
|
808
|
+
|
|
809
|
+
Each entry is `- C1 <YYYY-MM-DD>: <what the user said>` under
|
|
810
|
+
`## Corrections`. Ids run contiguously from 1; the section is
|
|
811
|
+
append-only, so a landed id never changes meaning.
|
|
812
|
+
"""
|
|
813
|
+
body = _common.section_body(intent, "Corrections")
|
|
814
|
+
corrections: Dict[str, str] = {}
|
|
815
|
+
if body is None:
|
|
816
|
+
return corrections
|
|
817
|
+
for line in _strip_comments(body).splitlines():
|
|
818
|
+
if not line.strip():
|
|
819
|
+
continue
|
|
820
|
+
if line.strip().casefold() in {"- none", "none"}:
|
|
821
|
+
return {}
|
|
822
|
+
match = CORRECTION_ENTRY.fullmatch(line.strip())
|
|
823
|
+
if match is None:
|
|
824
|
+
raise HandoffError(
|
|
825
|
+
f"INTENT.md Corrections entry is not numbered: {line.strip()!r}; "
|
|
826
|
+
"renumber entries as `- C1 <YYYY-MM-DD>: <ruling>` — a correction "
|
|
827
|
+
"needs citable identity (#477)"
|
|
828
|
+
)
|
|
829
|
+
if match.group("id") in corrections:
|
|
830
|
+
raise HandoffError(
|
|
831
|
+
f"INTENT.md Corrections repeats {match.group('id')}; ids are "
|
|
832
|
+
"append-only and never rewritten"
|
|
833
|
+
)
|
|
834
|
+
corrections[match.group("id")] = f"{match.group('date')}: {match.group('text')}"
|
|
835
|
+
expected = [f"C{number}" for number in range(1, len(corrections) + 1)]
|
|
836
|
+
if list(corrections) != expected:
|
|
837
|
+
raise HandoffError("INTENT.md Corrections ids must be contiguous from 1")
|
|
838
|
+
return corrections
|
|
839
|
+
|
|
840
|
+
|
|
698
841
|
def _success_criteria(intent: str) -> Dict[str, str]:
|
|
699
842
|
items = _numbered_items(_section(intent, "Success criteria"), continuations=True)
|
|
700
843
|
if not items:
|
|
@@ -1222,6 +1365,7 @@ def validate_intent(
|
|
|
1222
1365
|
intent = _read(root, _intent_path(project_dir))
|
|
1223
1366
|
criteria = _success_criteria(intent)
|
|
1224
1367
|
_surfaces(intent, "INTENT.md")
|
|
1368
|
+
_corrections(intent)
|
|
1225
1369
|
edges = edge_coverage(intent, criteria)
|
|
1226
1370
|
rows = prohibitions(intent, criteria)
|
|
1227
1371
|
return {
|
|
@@ -1529,8 +1673,14 @@ def _validate_task_graph(
|
|
|
1529
1673
|
for task_id, text in tasks.items():
|
|
1530
1674
|
task_repos[task_id] = str(_strict_frontmatter(text, task_id).get("repo") or "")
|
|
1531
1675
|
# Patch mode reopens planning beside landed tasks; only new tasks start clean.
|
|
1532
|
-
|
|
1533
|
-
|
|
1676
|
+
status = _task_scalar(text, task_id, "status")
|
|
1677
|
+
settled = status in ("done", "withdrawn")
|
|
1678
|
+
_require_task_structure(task_id, text, initial=initial and not settled)
|
|
1679
|
+
if status == "withdrawn" and not _common.withdrawn_citations(text):
|
|
1680
|
+
raise HandoffError(
|
|
1681
|
+
f"{task_id} is withdrawn without an owner citation; append a "
|
|
1682
|
+
"`- Withdrawn — <YYYY-MM-DD>: <owner ruling verbatim>` Log entry"
|
|
1683
|
+
)
|
|
1534
1684
|
wave = _task_wave(text, task_id)
|
|
1535
1685
|
deps = _task_deps(text, task_id)
|
|
1536
1686
|
files = _frontmatter_files(text)
|
|
@@ -1544,6 +1694,14 @@ def _validate_task_graph(
|
|
|
1544
1694
|
raise HandoffError(f"{task_id} names unknown dependency {dependency}")
|
|
1545
1695
|
if _task_wave(tasks[dependency], dependency) > wave:
|
|
1546
1696
|
raise HandoffError(f"{task_id} depends on later-wave {dependency}")
|
|
1697
|
+
if (
|
|
1698
|
+
_task_scalar(tasks[dependency], dependency, "status") == "withdrawn"
|
|
1699
|
+
and status not in ("done", "withdrawn")
|
|
1700
|
+
):
|
|
1701
|
+
raise HandoffError(
|
|
1702
|
+
f"{task_id} depends on withdrawn {dependency}; "
|
|
1703
|
+
"re-point the dependency or withdraw this task too"
|
|
1704
|
+
)
|
|
1547
1705
|
|
|
1548
1706
|
# `supersedes` retires the regression Verify of an earlier task: the same overlap rule selects both.
|
|
1549
1707
|
for task_id, text in tasks.items():
|
|
@@ -1631,7 +1789,10 @@ def _owned_by_wave(
|
|
|
1631
1789
|
) -> List[str]:
|
|
1632
1790
|
owned: Set[str] = set()
|
|
1633
1791
|
for task_id, text in tasks.items():
|
|
1634
|
-
|
|
1792
|
+
# A withdrawn tombstone owns no criteria to review (#483).
|
|
1793
|
+
if _task_wave(text, task_id) == wave and (
|
|
1794
|
+
_task_scalar(text, task_id, "status") != "withdrawn"
|
|
1795
|
+
):
|
|
1635
1796
|
owned.update(assigned[task_id])
|
|
1636
1797
|
return sorted(owned, key=lambda name: int(name[2:]))
|
|
1637
1798
|
|
|
@@ -1922,7 +2083,10 @@ def validate_plan(
|
|
|
1922
2083
|
integrated = _integrated_members(root, state)
|
|
1923
2084
|
for task_id, text in tasks.items():
|
|
1924
2085
|
repo = str(_strict_frontmatter(text, task_id).get("repo") or "")
|
|
1925
|
-
if repo in integrated and _task_scalar(text, task_id, "status")
|
|
2086
|
+
if repo in integrated and _task_scalar(text, task_id, "status") not in (
|
|
2087
|
+
"done",
|
|
2088
|
+
"withdrawn",
|
|
2089
|
+
):
|
|
1926
2090
|
raise HandoffError(
|
|
1927
2091
|
f"{task_id} changes member {repo}, which is already integrated in this milestone; "
|
|
1928
2092
|
"plan that change in a new milestone"
|
|
@@ -1934,6 +2098,12 @@ def validate_plan(
|
|
|
1934
2098
|
raise HandoffError(f"Intent coverage names unknown {criterion}")
|
|
1935
2099
|
if task_id not in tasks:
|
|
1936
2100
|
raise HandoffError(f"Intent coverage names unknown {task_id}")
|
|
2101
|
+
if _task_scalar(tasks[task_id], task_id, "status") == "withdrawn":
|
|
2102
|
+
raise HandoffError(
|
|
2103
|
+
f"{criterion} is still owned by withdrawn {task_id}; re-own it "
|
|
2104
|
+
"from a remaining task or remove the criterion through a "
|
|
2105
|
+
"define-phase INTENT Correction"
|
|
2106
|
+
)
|
|
1937
2107
|
items = _acceptance_items(tasks[task_id], task_id)
|
|
1938
2108
|
number = int(acceptance[2:])
|
|
1939
2109
|
if number not in items:
|
|
@@ -2016,6 +2186,81 @@ def validate_wave(
|
|
|
2016
2186
|
return validate_wave_evidence(root, project_dir, review)
|
|
2017
2187
|
|
|
2018
2188
|
|
|
2189
|
+
def _uncovered_claims(
|
|
2190
|
+
section_lines: Sequence[str],
|
|
2191
|
+
task_text: str,
|
|
2192
|
+
task_id: str,
|
|
2193
|
+
corrections: Optional[Dict[str, str]] = None,
|
|
2194
|
+
):
|
|
2195
|
+
"""Claims a task review section leaves unaccounted for (#474).
|
|
2196
|
+
|
|
2197
|
+
A claim is accounted for when a `- ✅`/`- ❌` marker line cites its
|
|
2198
|
+
stable id or copies it verbatim before an evidence separator, or when
|
|
2199
|
+
an authorized disposition line names it. One observation may cover
|
|
2200
|
+
several claims when each is identified. A disposition is authorized
|
|
2201
|
+
only by a numbered INTENT Correction it cites (#477): a disposition
|
|
2202
|
+
without a correction citation fails the review, and so does a
|
|
2203
|
+
citation of a correction that does not exist — a reviewer cannot mint
|
|
2204
|
+
a deferral.
|
|
2205
|
+
"""
|
|
2206
|
+
claims = task_claims(task_text, task_id)
|
|
2207
|
+
if not claims:
|
|
2208
|
+
return []
|
|
2209
|
+
# Issue #392: a quote may drop the `- ` markers of the criterion's
|
|
2210
|
+
# sub-bullet lines, so a criterion is also read without them.
|
|
2211
|
+
bare_claims = task_claims(_common.without_sub_bullet_markers(task_text), task_id)
|
|
2212
|
+
covered: set = set()
|
|
2213
|
+
for line in section_lines:
|
|
2214
|
+
stripped = line.strip()
|
|
2215
|
+
disposition = CLAIM_DISPOSITION_LINE.fullmatch(stripped)
|
|
2216
|
+
if disposition is not None and re.search(r"[A-Za-z]", disposition.group("authority")):
|
|
2217
|
+
cited = disposition.group("ids")
|
|
2218
|
+
if corrections is not None:
|
|
2219
|
+
authority = " ".join(
|
|
2220
|
+
(disposition.group("authority"), disposition.group("reason"))
|
|
2221
|
+
)
|
|
2222
|
+
referenced = [
|
|
2223
|
+
match.group("id")
|
|
2224
|
+
for match in CORRECTION_CITATION.finditer(authority)
|
|
2225
|
+
]
|
|
2226
|
+
if not referenced:
|
|
2227
|
+
raise HandoffError(
|
|
2228
|
+
f"disposition of {cited.strip()} cites no INTENT Correction; "
|
|
2229
|
+
"an authorized disposition names one, e.g. "
|
|
2230
|
+
"`- <ids> — disposition: owner ruling 2026-10-26 (C1): <reason>`"
|
|
2231
|
+
)
|
|
2232
|
+
missing = [
|
|
2233
|
+
correction for correction in referenced
|
|
2234
|
+
if correction not in corrections
|
|
2235
|
+
]
|
|
2236
|
+
if missing:
|
|
2237
|
+
raise HandoffError(
|
|
2238
|
+
f"disposition of {cited.strip()} cites "
|
|
2239
|
+
+ ", ".join(missing)
|
|
2240
|
+
+ ", which INTENT.md Corrections does not define; "
|
|
2241
|
+
"a disposition cannot create its own authority"
|
|
2242
|
+
)
|
|
2243
|
+
elif stripped.startswith(("- ✅ ", "- ❌ ")):
|
|
2244
|
+
cited = stripped
|
|
2245
|
+
else:
|
|
2246
|
+
cited = ""
|
|
2247
|
+
for match in CLAIM_ID_IN_REVIEW.finditer(cited):
|
|
2248
|
+
covered.add(f"{match.group('task')}/{match.group('kind')}{match.group('number')}")
|
|
2249
|
+
if stripped.startswith(("- ✅ ", "- ❌ ")):
|
|
2250
|
+
# Reuse the canonical quote matcher: a criterion may itself
|
|
2251
|
+
# contain em dashes and may be quoted with or without its
|
|
2252
|
+
# sub-bullet markers (#374, #392).
|
|
2253
|
+
item = stripped[4:]
|
|
2254
|
+
for claim_id, body in claims.items():
|
|
2255
|
+
bare = bare_claims.get(claim_id, body)
|
|
2256
|
+
if _common.quoted_criterion(item, [body, bare]) is not None:
|
|
2257
|
+
# A verbatim quote identifies the claim it quotes;
|
|
2258
|
+
# supporting several claims requires naming each id.
|
|
2259
|
+
covered.add(claim_id)
|
|
2260
|
+
break
|
|
2261
|
+
return [(claim_id, body) for claim_id, body in claims.items() if claim_id not in covered]
|
|
2262
|
+
|
|
2263
|
+
|
|
2019
2264
|
def validate_wave_evidence(
|
|
2020
2265
|
root: Path, project_dir: str = DEFAULT_PROJECT_DIR, review: str = ""
|
|
2021
2266
|
) -> Dict[str, object]:
|
|
@@ -2023,7 +2268,11 @@ def validate_wave_evidence(
|
|
|
2023
2268
|
|
|
2024
2269
|
if not review:
|
|
2025
2270
|
raise HandoffError("wave validation requires --review")
|
|
2026
|
-
|
|
2271
|
+
# Native Windows separators are accepted at the CLI boundary and
|
|
2272
|
+
# normalized before interpretation; every other restriction applies
|
|
2273
|
+
# unchanged (#470).
|
|
2274
|
+
normalized_review = review.replace("\\", "/")
|
|
2275
|
+
review_path = PurePosixPath(normalized_review)
|
|
2027
2276
|
name = review_path.name
|
|
2028
2277
|
named = WAVE_REVIEW_NAME.fullmatch(name)
|
|
2029
2278
|
if not named:
|
|
@@ -2038,6 +2287,7 @@ def validate_wave_evidence(
|
|
|
2038
2287
|
lens = named.group("lens")
|
|
2039
2288
|
intent = _read(root, _intent_path(project_dir))
|
|
2040
2289
|
criteria = _success_criteria(intent)
|
|
2290
|
+
corrections = _corrections(intent)
|
|
2041
2291
|
plan = _read(root, f"{project_dir}/plan/PLAN.md")
|
|
2042
2292
|
rows = _coverage_rows(plan)
|
|
2043
2293
|
wave_depths, _waves = _plan_waves(plan)
|
|
@@ -2049,6 +2299,24 @@ def validate_wave_evidence(
|
|
|
2049
2299
|
]
|
|
2050
2300
|
if not expected_tasks:
|
|
2051
2301
|
raise HandoffError(f"PLAN.md has no Wave {wave} tasks")
|
|
2302
|
+
# A withdrawn tombstone produced no live work; the review covers the rest.
|
|
2303
|
+
# But exclusion is earned: an uncited tombstone is a dropped obligation and
|
|
2304
|
+
# fails this gate like every other one (#483).
|
|
2305
|
+
live_tasks: List[str] = []
|
|
2306
|
+
for task_id in expected_tasks:
|
|
2307
|
+
if _task_scalar(tasks[task_id], task_id, "status") == "withdrawn":
|
|
2308
|
+
if not _common.withdrawn_citations(tasks[task_id]):
|
|
2309
|
+
raise HandoffError(
|
|
2310
|
+
f"{task_id} is withdrawn without an owner citation; append a "
|
|
2311
|
+
"`- Withdrawn — <YYYY-MM-DD>: <owner ruling verbatim>` Log entry"
|
|
2312
|
+
)
|
|
2313
|
+
continue
|
|
2314
|
+
live_tasks.append(task_id)
|
|
2315
|
+
expected_tasks = live_tasks
|
|
2316
|
+
if not expected_tasks:
|
|
2317
|
+
raise HandoffError(
|
|
2318
|
+
f"Wave {wave} has only withdrawn tasks; it has no live work to review"
|
|
2319
|
+
)
|
|
2052
2320
|
if wave not in wave_depths:
|
|
2053
2321
|
raise HandoffError(f"PLAN.md has no Wave {wave}")
|
|
2054
2322
|
expected_depth = wave_depths[wave]
|
|
@@ -2143,6 +2411,17 @@ def validate_wave_evidence(
|
|
|
2143
2411
|
for item in evidence:
|
|
2144
2412
|
item = task_review_observation(item, tasks[task_id])
|
|
2145
2413
|
_non_placeholder(item, f"{name} task {task_id} {verdict} evidence")
|
|
2414
|
+
uncovered = _uncovered_claims(
|
|
2415
|
+
lines[heading_index + 1 : end], tasks[task_id], task_id, corrections
|
|
2416
|
+
)
|
|
2417
|
+
if uncovered:
|
|
2418
|
+
raise HandoffError(
|
|
2419
|
+
f"{name} task {task_id} does not account for "
|
|
2420
|
+
+ "; ".join(f"{claim_id} ({body[:80]})" for claim_id, body in uncovered)
|
|
2421
|
+
+ "; account for every claim with an evidence reference, a "
|
|
2422
|
+
"finding, or an authorized disposition "
|
|
2423
|
+
"(`- <ids> — disposition: <authority> <YYYY-MM-DD>: <reason>`)"
|
|
2424
|
+
)
|
|
2146
2425
|
task_verdicts.append(verdict)
|
|
2147
2426
|
|
|
2148
2427
|
if not is_last_cycle:
|
|
@@ -2176,24 +2455,20 @@ def validate_wave_evidence(
|
|
|
2176
2455
|
raise HandoffError(f"{name} is missing ## Intent coverage")
|
|
2177
2456
|
verdicts: Dict[str, str] = {}
|
|
2178
2457
|
coverage_lines = _strip_comments(coverage).splitlines()
|
|
2458
|
+
for line in coverage_lines:
|
|
2459
|
+
if line.strip().startswith("### ") and not WAVE_SC_HEADING.fullmatch(line):
|
|
2460
|
+
raise HandoffError(
|
|
2461
|
+
f"{name} has an invalid Intent coverage heading: {line.strip()}; "
|
|
2462
|
+
"use `### SCn — <criterion>: pass` or `: fail` with an em dash (—)"
|
|
2463
|
+
)
|
|
2179
2464
|
sc_headings = [
|
|
2180
2465
|
(index, match)
|
|
2181
2466
|
for index, line in enumerate(coverage_lines)
|
|
2182
|
-
if (match := WAVE_SC_HEADING.fullmatch(line
|
|
2467
|
+
if (match := WAVE_SC_HEADING.fullmatch(line)) is not None
|
|
2183
2468
|
]
|
|
2184
2469
|
for position, (heading_index, match) in enumerate(sc_headings):
|
|
2185
|
-
sc_id = match.
|
|
2186
|
-
|
|
2187
|
-
verdict = verdict.strip()
|
|
2188
|
-
if verdict not in {"pass", "fail"}:
|
|
2189
|
-
raise HandoffError(f"{name} {sc_id} heading must end with `: pass` or `: fail`")
|
|
2190
|
-
if (
|
|
2191
|
-
sc_id in criteria
|
|
2192
|
-
and (
|
|
2193
|
-
not separator
|
|
2194
|
-
or not _common.quotes_criterion(heading_text, criteria[sc_id])
|
|
2195
|
-
)
|
|
2196
|
-
):
|
|
2470
|
+
sc_id, heading_text, verdict = match.groups()
|
|
2471
|
+
if sc_id in criteria and not _common.quotes_criterion(heading_text, criteria[sc_id]):
|
|
2197
2472
|
raise HandoffError(f"{name} {sc_id} heading text differs from INTENT.md")
|
|
2198
2473
|
if sc_id in verdicts:
|
|
2199
2474
|
raise HandoffError(f"{name} Intent coverage repeats {sc_id}")
|
|
@@ -2215,9 +2490,29 @@ def validate_wave_evidence(
|
|
|
2215
2490
|
)
|
|
2216
2491
|
for item in evidence:
|
|
2217
2492
|
_non_placeholder(item, f"{name} {sc_id} {verdict} evidence")
|
|
2493
|
+
for line in coverage_lines[heading_index + 1 : end]:
|
|
2494
|
+
excluded = EXCLUDED_BY_LINE.fullmatch(line.strip())
|
|
2495
|
+
if excluded is None:
|
|
2496
|
+
continue
|
|
2497
|
+
if excluded.group("correction") not in corrections:
|
|
2498
|
+
raise HandoffError(
|
|
2499
|
+
f"{name} {sc_id} excludes a subclaim by "
|
|
2500
|
+
f"{excluded.group('correction')}, which INTENT.md "
|
|
2501
|
+
"Corrections does not define; an exclusion cannot create "
|
|
2502
|
+
"its own authority (#477)"
|
|
2503
|
+
)
|
|
2504
|
+
if verdict != "pass":
|
|
2505
|
+
raise HandoffError(
|
|
2506
|
+
f"{name} {sc_id} excludes a subclaim but its verdict is "
|
|
2507
|
+
f"{verdict}; an exclusion records retained scope, not a "
|
|
2508
|
+
"failure"
|
|
2509
|
+
)
|
|
2218
2510
|
verdicts[sc_id] = verdict
|
|
2219
|
-
if
|
|
2220
|
-
raise HandoffError(
|
|
2511
|
+
if list(verdicts) != owned:
|
|
2512
|
+
raise HandoffError(
|
|
2513
|
+
f"{name} Intent coverage must cover exactly {', '.join(owned)} "
|
|
2514
|
+
"in ascending SC order"
|
|
2515
|
+
)
|
|
2221
2516
|
if overall == "pass" and "fail" in verdicts.values():
|
|
2222
2517
|
raise HandoffError(f"{name} Wave verdict is pass while an owned SC failed")
|
|
2223
2518
|
return {
|
|
@@ -2418,12 +2713,43 @@ def validate_final(
|
|
|
2418
2713
|
}
|
|
2419
2714
|
|
|
2420
2715
|
|
|
2716
|
+
def _reopened_patch_plan(root: Path, project_dir: str) -> bool:
|
|
2717
|
+
"""Whether the current stay in plan began with `patch plan reopened`.
|
|
2718
|
+
|
|
2719
|
+
Every event is logged under the phase it leaves the track in, so the
|
|
2720
|
+
trailing run of `plan` log entries is the current stay in plan; its first
|
|
2721
|
+
entry is the transition into plan. `planning resumed` after a blocked
|
|
2722
|
+
patch planner stays inside that run.
|
|
2723
|
+
"""
|
|
2724
|
+
|
|
2725
|
+
try:
|
|
2726
|
+
_state, text, _path = load_state(root.resolve(), project_dir)
|
|
2727
|
+
except PipelineStateError as error:
|
|
2728
|
+
raise HandoffError(str(error)) from error
|
|
2729
|
+
entries = []
|
|
2730
|
+
for line in (_common.section_body(text, "Log") or "").splitlines():
|
|
2731
|
+
parts = line.split(" — ", 2)
|
|
2732
|
+
if line.startswith("- ") and len(parts) == 3:
|
|
2733
|
+
entries.append((parts[1].strip(), " ".join(parts[2].split())))
|
|
2734
|
+
stay = []
|
|
2735
|
+
for phase, event in reversed(entries):
|
|
2736
|
+
if phase != "plan":
|
|
2737
|
+
break
|
|
2738
|
+
stay.append(event)
|
|
2739
|
+
return bool(stay) and stay[-1] == "patch plan reopened"
|
|
2740
|
+
|
|
2741
|
+
|
|
2421
2742
|
def validate_patch_findings(
|
|
2422
2743
|
root: Path, project_dir: str = DEFAULT_PROJECT_DIR
|
|
2423
2744
|
) -> Dict[str, object]:
|
|
2424
2745
|
"""Validate the review-to-patch hand-off and return its summary."""
|
|
2425
2746
|
|
|
2426
|
-
|
|
2747
|
+
accepted = {("ship", "blocked")}
|
|
2748
|
+
if _reopened_patch_plan(root, project_dir):
|
|
2749
|
+
# Patch step 1 moves ship/blocked to plan/active before step 2 runs
|
|
2750
|
+
# this check, and a blocked patch planner resumes into plan/active.
|
|
2751
|
+
accepted.add(("plan", "active"))
|
|
2752
|
+
_require_state_one_of(root, accepted, project_dir)
|
|
2427
2753
|
handoff = _read(root, f"{project_dir}/review/PATCH-FINDINGS.md")
|
|
2428
2754
|
reviewed_head = _reviewed_head(handoff, "PATCH-FINDINGS.md")
|
|
2429
2755
|
if _line_value(handoff, "State:") != "ship/blocked":
|
|
@@ -2487,7 +2813,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
2487
2813
|
argument_parser = argparse.ArgumentParser(description=__doc__)
|
|
2488
2814
|
argument_parser.add_argument(
|
|
2489
2815
|
"phase",
|
|
2490
|
-
choices=("intent", "research", "decide", "roadmap", "patch", "plan", "wave", "final"),
|
|
2816
|
+
choices=("intent", "research", "decide", "roadmap", "patch", "plan", "wave", "final", "effective-contract"),
|
|
2491
2817
|
)
|
|
2492
2818
|
argument_parser.add_argument("--repo", type=Path, required=True)
|
|
2493
2819
|
argument_parser.add_argument(
|
|
@@ -2507,12 +2833,93 @@ def parser() -> argparse.ArgumentParser:
|
|
|
2507
2833
|
return argument_parser
|
|
2508
2834
|
|
|
2509
2835
|
|
|
2836
|
+
def effective_contract(root: Path, project_dir: str = DEFAULT_PROJECT_DIR) -> Dict[str, object]:
|
|
2837
|
+
"""The currently effective obligations, separated from deferrals (#477).
|
|
2838
|
+
|
|
2839
|
+
Per owned success criterion: the original text, the subclaims current
|
|
2840
|
+
reviews exclude under approved corrections, and the resulting state —
|
|
2841
|
+
`original-claim` (nothing excluded) or `retained-scope` (at least one
|
|
2842
|
+
cited exclusion). Claim dispositions from current reviews list the
|
|
2843
|
+
claims, the cited correction, and `deferred`. The view derives only
|
|
2844
|
+
from the current INTENT Corrections and review files; superseded task
|
|
2845
|
+
history stays untouched.
|
|
2846
|
+
"""
|
|
2847
|
+
intent = _read(root, _intent_path(project_dir))
|
|
2848
|
+
criteria = _success_criteria(intent)
|
|
2849
|
+
corrections = _corrections(intent)
|
|
2850
|
+
excluded_under: Dict[str, list] = {sc_id: [] for sc_id in criteria}
|
|
2851
|
+
dispositions: list = []
|
|
2852
|
+
review_dir = root / project_dir / "review"
|
|
2853
|
+
if review_dir.is_dir() and not review_dir.is_symlink():
|
|
2854
|
+
named = [
|
|
2855
|
+
(match.group("wave"), match.group("lens") or "", int(match.group("cycle")), path)
|
|
2856
|
+
for path in sorted(review_dir.iterdir())
|
|
2857
|
+
if path.is_file()
|
|
2858
|
+
and (match := WAVE_REVIEW_NAME.fullmatch(path.name)) is not None
|
|
2859
|
+
]
|
|
2860
|
+
latest = {}
|
|
2861
|
+
for wave, lens, cycle, path in named:
|
|
2862
|
+
key = (wave, lens)
|
|
2863
|
+
if key not in latest or cycle > latest[key][0]:
|
|
2864
|
+
latest[key] = (cycle, path)
|
|
2865
|
+
for path in sorted((entry[1] for entry in latest.values()), key=lambda item: item.name):
|
|
2866
|
+
current_sc = None
|
|
2867
|
+
for line in _strip_comments(path.read_text(encoding="utf-8")).splitlines():
|
|
2868
|
+
heading = WAVE_SC_HEADING.fullmatch(line.strip())
|
|
2869
|
+
if heading is not None:
|
|
2870
|
+
current_sc = heading.group(1)
|
|
2871
|
+
continue
|
|
2872
|
+
excluded = EXCLUDED_BY_LINE.fullmatch(line.strip())
|
|
2873
|
+
if excluded is not None:
|
|
2874
|
+
if current_sc in excluded_under and excluded.group("correction") in corrections:
|
|
2875
|
+
excluded_under[current_sc].append({
|
|
2876
|
+
"review": path.name,
|
|
2877
|
+
"correction": excluded.group("correction"),
|
|
2878
|
+
"subclaim": excluded.group("subclaim"),
|
|
2879
|
+
})
|
|
2880
|
+
continue
|
|
2881
|
+
disposition = CLAIM_DISPOSITION_LINE.fullmatch(line.strip())
|
|
2882
|
+
if disposition is not None and re.search(r"[A-Za-z]", disposition.group("authority")):
|
|
2883
|
+
authority = " ".join((disposition.group("authority"), disposition.group("reason")))
|
|
2884
|
+
cited = CORRECTION_CITATION.findall(authority)
|
|
2885
|
+
if not cited or not all(correction in corrections for correction in cited):
|
|
2886
|
+
continue
|
|
2887
|
+
dispositions.append({
|
|
2888
|
+
"state": "deferred",
|
|
2889
|
+
"review": path.name,
|
|
2890
|
+
"claims": [
|
|
2891
|
+
f"{task}/{kind}{number}"
|
|
2892
|
+
for task, kind, number
|
|
2893
|
+
in CLAIM_ID_IN_REVIEW.findall(disposition.group("ids"))
|
|
2894
|
+
],
|
|
2895
|
+
"corrections": cited,
|
|
2896
|
+
"reason": disposition.group("reason"),
|
|
2897
|
+
})
|
|
2898
|
+
effective = [
|
|
2899
|
+
{
|
|
2900
|
+
"criterion": sc_id,
|
|
2901
|
+
"original": text,
|
|
2902
|
+
"state": "retained-scope" if excluded_under[sc_id] else "original-claim",
|
|
2903
|
+
"excluded": excluded_under[sc_id],
|
|
2904
|
+
}
|
|
2905
|
+
for sc_id, text in criteria.items()
|
|
2906
|
+
]
|
|
2907
|
+
return {
|
|
2908
|
+
"command": "effective-contract",
|
|
2909
|
+
"corrections": corrections,
|
|
2910
|
+
"criteria": effective,
|
|
2911
|
+
"dispositions": dispositions,
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
|
|
2510
2915
|
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
2511
2916
|
arguments = parser().parse_args(argv)
|
|
2512
2917
|
try:
|
|
2513
2918
|
repo = arguments.repo.resolve()
|
|
2514
2919
|
if arguments.phase == "wave":
|
|
2515
2920
|
result = validate_wave(repo, arguments.project_dir, arguments.review)
|
|
2921
|
+
elif arguments.phase == "effective-contract":
|
|
2922
|
+
result = effective_contract(repo, arguments.project_dir)
|
|
2516
2923
|
else:
|
|
2517
2924
|
validators = {
|
|
2518
2925
|
"intent": validate_intent,
|