@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.
Files changed (281) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/DOCS.md +27 -1
  3. package/FULL.md +4 -3
  4. package/HOOKS.md +38 -4
  5. package/QUICK.md +1 -1
  6. package/README.md +19 -11
  7. package/RUNTIME.md +5 -3
  8. package/WORKFLOW.md +11 -4
  9. package/package.json +1 -1
  10. package/scripts/_common.py +76 -2
  11. package/scripts/archive_milestone.py +129 -7
  12. package/scripts/build_recovery.py +25 -2
  13. package/scripts/build_state.py +333 -8
  14. package/scripts/check_handoffs.py +437 -30
  15. package/scripts/check_task_briefs.py +135 -11
  16. package/scripts/discussion_validate.py +46 -10
  17. package/scripts/dispatch_driver.py +33 -3
  18. package/scripts/git_guard.py +44 -4
  19. package/scripts/guard_hook.py +456 -9
  20. package/scripts/install.py +34 -4
  21. package/scripts/integration.py +196 -39
  22. package/scripts/isolation.py +1000 -38
  23. package/scripts/lean_verification.py +28 -13
  24. package/scripts/loop_run.py +9 -4
  25. package/scripts/members.py +17 -3
  26. package/scripts/pipeline_diagnose.py +104 -1
  27. package/scripts/pipeline_state.py +10 -2
  28. package/scripts/runtime_store.py +88 -3
  29. package/scripts/state_checkpoint.py +15 -0
  30. package/scripts/status_runtime.py +73 -2
  31. package/scripts/workflow_run.py +10 -3
  32. package/scripts/worktree_paths.py +45 -0
  33. package/skills/gsd-path/BUILD.md +4 -1
  34. package/skills/gsd-path/DEFINE.md +2 -1
  35. package/skills/gsd-path/PLAN.md +13 -3
  36. package/skills/gsd-path/SHIP.md +41 -8
  37. package/skills/gsd-path/SKILL.md +16 -1
  38. package/skills/gsd-path/references/build-native.md +28 -7
  39. package/skills/gsd-path/references/coder.md +6 -3
  40. package/skills/gsd-path/references/planner.md +4 -3
  41. package/skills/gsd-path/references/reviewer-final.md +10 -0
  42. package/skills/gsd-path/references/reviewer-wave.md +71 -27
  43. package/skills/gsd-path/references/reviewer.md +21 -8
  44. package/skills/gsd-path/references/runtime-selection.md +36 -0
  45. package/skills/gsd-path/references/spec-probes.md +19 -1
  46. package/skills/gsd-path/scripts/_common.py +76 -2
  47. package/skills/gsd-path/scripts/archive_milestone.py +129 -7
  48. package/skills/gsd-path/scripts/build_recovery.py +25 -2
  49. package/skills/gsd-path/scripts/build_state.py +333 -8
  50. package/skills/gsd-path/scripts/check_handoffs.py +437 -30
  51. package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
  52. package/skills/gsd-path/scripts/discussion_validate.py +46 -10
  53. package/skills/gsd-path/scripts/dispatch_driver.py +33 -3
  54. package/skills/gsd-path/scripts/git_guard.py +44 -4
  55. package/skills/gsd-path/scripts/integration.py +196 -39
  56. package/skills/gsd-path/scripts/isolation.py +1000 -38
  57. package/skills/gsd-path/scripts/lean_verification.py +28 -13
  58. package/skills/gsd-path/scripts/loop_run.py +9 -4
  59. package/skills/gsd-path/scripts/members.py +17 -3
  60. package/skills/gsd-path/scripts/pipeline_state.py +10 -2
  61. package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
  62. package/skills/gsd-path/scripts/status_runtime.py +73 -2
  63. package/skills/gsd-path/scripts/workflow_run.py +10 -3
  64. package/skills/gsd-path/scripts/worktree_paths.py +45 -0
  65. package/skills/gsd-path/templates/intent.md +5 -3
  66. package/skills/gsd-path/templates/plan.md +3 -2
  67. package/skills/gsd-path/templates/task.md +8 -1
  68. package/skills/gsd-path/templates/wave-review.md +24 -8
  69. package/skills/gsd-path-build/SKILL.md +4 -1
  70. package/skills/gsd-path-build/references/build-native.md +28 -7
  71. package/skills/gsd-path-build/references/coder.md +6 -3
  72. package/skills/gsd-path-build/references/reviewer-final.md +10 -0
  73. package/skills/gsd-path-build/references/reviewer-wave.md +71 -27
  74. package/skills/gsd-path-build/references/reviewer.md +21 -8
  75. package/skills/gsd-path-build/references/runtime-selection.md +36 -0
  76. package/skills/gsd-path-build/references/spec-probes.md +19 -1
  77. package/skills/gsd-path-build/scripts/_common.py +76 -2
  78. package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
  79. package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
  80. package/skills/gsd-path-build/scripts/build_state.py +333 -8
  81. package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
  82. package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
  83. package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
  84. package/skills/gsd-path-build/scripts/dispatch_driver.py +33 -3
  85. package/skills/gsd-path-build/scripts/integration.py +196 -39
  86. package/skills/gsd-path-build/scripts/isolation.py +1000 -38
  87. package/skills/gsd-path-build/scripts/loop_run.py +9 -4
  88. package/skills/gsd-path-build/scripts/members.py +17 -3
  89. package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
  90. package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
  91. package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
  92. package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
  93. package/skills/gsd-path-build/templates/task.md +8 -1
  94. package/skills/gsd-path-build/templates/wave-review.md +24 -8
  95. package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
  96. package/skills/gsd-path-decide/scripts/_common.py +76 -2
  97. package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
  98. package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
  99. package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
  100. package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
  101. package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
  102. package/skills/gsd-path-decide/scripts/integration.py +196 -39
  103. package/skills/gsd-path-decide/scripts/isolation.py +1000 -38
  104. package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
  105. package/skills/gsd-path-decide/scripts/members.py +17 -3
  106. package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
  107. package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
  108. package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
  109. package/skills/gsd-path-define/SKILL.md +2 -1
  110. package/skills/gsd-path-define/references/runtime-selection.md +36 -0
  111. package/skills/gsd-path-define/references/spec-probes.md +19 -1
  112. package/skills/gsd-path-define/scripts/_common.py +76 -2
  113. package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
  114. package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
  115. package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
  116. package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
  117. package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
  118. package/skills/gsd-path-define/scripts/integration.py +196 -39
  119. package/skills/gsd-path-define/scripts/isolation.py +1000 -38
  120. package/skills/gsd-path-define/scripts/loop_run.py +9 -4
  121. package/skills/gsd-path-define/scripts/members.py +17 -3
  122. package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
  123. package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
  124. package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
  125. package/skills/gsd-path-define/templates/intent.md +5 -3
  126. package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
  127. package/skills/gsd-path-discuss/scripts/_common.py +76 -2
  128. package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
  129. package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
  130. package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
  131. package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
  132. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
  133. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
  134. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
  135. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
  136. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
  137. package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
  138. package/skills/gsd-path-docs-audit/scripts/isolation.py +1000 -38
  139. package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
  140. package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
  141. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
  142. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
  143. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
  144. package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
  145. package/skills/gsd-path-forensics/scripts/_common.py +76 -2
  146. package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
  147. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
  148. package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
  149. package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
  150. package/skills/gsd-path-inspect/scripts/_common.py +76 -2
  151. package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
  152. package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
  153. package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
  154. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
  155. package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
  156. package/skills/gsd-path-inspect/scripts/integration.py +196 -39
  157. package/skills/gsd-path-inspect/scripts/isolation.py +1000 -38
  158. package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
  159. package/skills/gsd-path-inspect/scripts/members.py +17 -3
  160. package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
  161. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
  162. package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
  163. package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
  164. package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
  165. package/skills/gsd-path-loop/scripts/_common.py +76 -2
  166. package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
  167. package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
  168. package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
  169. package/skills/gsd-path-migrate/scripts/_common.py +76 -2
  170. package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
  171. package/skills/gsd-path-plan/SKILL.md +13 -3
  172. package/skills/gsd-path-plan/references/planner.md +4 -3
  173. package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
  174. package/skills/gsd-path-plan/references/spec-probes.md +19 -1
  175. package/skills/gsd-path-plan/scripts/_common.py +76 -2
  176. package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
  177. package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
  178. package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
  179. package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
  180. package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
  181. package/skills/gsd-path-plan/scripts/integration.py +196 -39
  182. package/skills/gsd-path-plan/scripts/isolation.py +1000 -38
  183. package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
  184. package/skills/gsd-path-plan/scripts/members.py +17 -3
  185. package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
  186. package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
  187. package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
  188. package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
  189. package/skills/gsd-path-plan/templates/plan.md +3 -2
  190. package/skills/gsd-path-plan/templates/task.md +8 -1
  191. package/skills/gsd-path-research/references/runtime-selection.md +36 -0
  192. package/skills/gsd-path-research/scripts/_common.py +76 -2
  193. package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
  194. package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
  195. package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
  196. package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
  197. package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
  198. package/skills/gsd-path-research/scripts/integration.py +196 -39
  199. package/skills/gsd-path-research/scripts/isolation.py +1000 -38
  200. package/skills/gsd-path-research/scripts/loop_run.py +9 -4
  201. package/skills/gsd-path-research/scripts/members.py +17 -3
  202. package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
  203. package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
  204. package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
  205. package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
  206. package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
  207. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
  208. package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
  209. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
  210. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
  211. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
  212. package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
  213. package/skills/gsd-path-roadmap/scripts/isolation.py +1000 -38
  214. package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
  215. package/skills/gsd-path-roadmap/scripts/members.py +17 -3
  216. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
  217. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
  218. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
  219. package/skills/gsd-path-ship/SKILL.md +41 -8
  220. package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
  221. package/skills/gsd-path-ship/references/reviewer-wave.md +71 -27
  222. package/skills/gsd-path-ship/references/reviewer.md +21 -8
  223. package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
  224. package/skills/gsd-path-ship/references/spec-probes.md +19 -1
  225. package/skills/gsd-path-ship/scripts/_common.py +76 -2
  226. package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
  227. package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
  228. package/skills/gsd-path-ship/scripts/build_state.py +333 -8
  229. package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
  230. package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
  231. package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
  232. package/skills/gsd-path-ship/scripts/integration.py +196 -39
  233. package/skills/gsd-path-ship/scripts/isolation.py +1000 -38
  234. package/skills/gsd-path-ship/scripts/lean_verification.py +28 -13
  235. package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
  236. package/skills/gsd-path-ship/scripts/members.py +17 -3
  237. package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
  238. package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
  239. package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
  240. package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
  241. package/skills/gsd-path-ship/templates/wave-review.md +24 -8
  242. package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
  243. package/skills/gsd-path-undo/scripts/_common.py +76 -2
  244. package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
  245. package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
  246. package/skills/path/BUILD.md +4 -1
  247. package/skills/path/DEFINE.md +2 -1
  248. package/skills/path/PLAN.md +13 -3
  249. package/skills/path/SHIP.md +41 -8
  250. package/skills/path/SKILL.md +16 -1
  251. package/skills/path/references/build-native.md +28 -7
  252. package/skills/path/references/coder.md +6 -3
  253. package/skills/path/references/planner.md +4 -3
  254. package/skills/path/references/reviewer-final.md +10 -0
  255. package/skills/path/references/reviewer-wave.md +71 -27
  256. package/skills/path/references/reviewer.md +21 -8
  257. package/skills/path/references/runtime-selection.md +36 -0
  258. package/skills/path/references/spec-probes.md +19 -1
  259. package/skills/path/scripts/_common.py +76 -2
  260. package/skills/path/scripts/archive_milestone.py +129 -7
  261. package/skills/path/scripts/build_recovery.py +25 -2
  262. package/skills/path/scripts/build_state.py +333 -8
  263. package/skills/path/scripts/check_handoffs.py +437 -30
  264. package/skills/path/scripts/check_task_briefs.py +135 -11
  265. package/skills/path/scripts/discussion_validate.py +46 -10
  266. package/skills/path/scripts/dispatch_driver.py +33 -3
  267. package/skills/path/scripts/git_guard.py +44 -4
  268. package/skills/path/scripts/integration.py +196 -39
  269. package/skills/path/scripts/isolation.py +1000 -38
  270. package/skills/path/scripts/lean_verification.py +28 -13
  271. package/skills/path/scripts/loop_run.py +9 -4
  272. package/skills/path/scripts/members.py +17 -3
  273. package/skills/path/scripts/pipeline_state.py +10 -2
  274. package/skills/path/scripts/state_checkpoint.py +15 -0
  275. package/skills/path/scripts/status_runtime.py +73 -2
  276. package/skills/path/scripts/workflow_run.py +10 -3
  277. package/skills/path/scripts/worktree_paths.py +45 -0
  278. package/skills/path/templates/intent.md +5 -3
  279. package/skills/path/templates/plan.md +3 -2
  280. package/skills/path/templates/task.md +8 -1
  281. package/skills/path/templates/wave-review.md +24 -8
@@ -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
- lock = root / ".project/build/members.json"
654
- if not lock.is_file():
653
+ shown = isolation.run_git(root, "show", "HEAD:.project/build/members.json")
654
+ if shown.returncode != 0:
655
655
  return {}
656
- tips: Dict[str, str] = {}
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
- for item in json.loads(lock.read_text(encoding="utf-8"))["members"]:
659
- checkout, _, entry = isolation._member_context(root, item["name"])
660
- tips[entry["name"]] = isolation.git_output(
661
- checkout, "rev-parse", "--verify", f"refs/heads/{entry['branch']}^{{commit}}")
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
- landed = _task_scalar(text, task_id, "status") == "done"
1533
- _require_task_structure(task_id, text, initial=initial and not landed)
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
- if _task_wave(text, task_id) == wave:
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") != "done":
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
- review_path = PurePosixPath(review)
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.strip())) is not None
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.group(1)
2186
- heading_text, separator, verdict = match.group(2).rpartition(":")
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 sorted(verdicts, key=lambda n: int(n[2:])) != owned:
2220
- raise HandoffError(f"{name} Intent coverage must cover exactly {', '.join(owned)}")
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
- _require_state(root, "ship", "blocked", project_dir)
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,