@opengsd/gsd-path 1.6.2 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (275) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/DOCS.md +27 -1
  3. package/FULL.md +2 -1
  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 +9 -2
  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 +28 -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 +996 -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 +3 -1
  34. package/skills/gsd-path/DEFINE.md +2 -1
  35. package/skills/gsd-path/PLAN.md +10 -0
  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 +20 -3
  39. package/skills/gsd-path/references/coder.md +6 -3
  40. package/skills/gsd-path/references/reviewer-final.md +10 -0
  41. package/skills/gsd-path/references/reviewer-wave.md +61 -22
  42. package/skills/gsd-path/references/reviewer.md +8 -6
  43. package/skills/gsd-path/references/runtime-selection.md +36 -0
  44. package/skills/gsd-path/references/spec-probes.md +19 -1
  45. package/skills/gsd-path/scripts/_common.py +76 -2
  46. package/skills/gsd-path/scripts/archive_milestone.py +129 -7
  47. package/skills/gsd-path/scripts/build_recovery.py +25 -2
  48. package/skills/gsd-path/scripts/build_state.py +333 -8
  49. package/skills/gsd-path/scripts/check_handoffs.py +437 -30
  50. package/skills/gsd-path/scripts/check_task_briefs.py +135 -11
  51. package/skills/gsd-path/scripts/discussion_validate.py +46 -10
  52. package/skills/gsd-path/scripts/dispatch_driver.py +28 -3
  53. package/skills/gsd-path/scripts/git_guard.py +44 -4
  54. package/skills/gsd-path/scripts/integration.py +196 -39
  55. package/skills/gsd-path/scripts/isolation.py +996 -38
  56. package/skills/gsd-path/scripts/lean_verification.py +28 -13
  57. package/skills/gsd-path/scripts/loop_run.py +9 -4
  58. package/skills/gsd-path/scripts/members.py +17 -3
  59. package/skills/gsd-path/scripts/pipeline_state.py +10 -2
  60. package/skills/gsd-path/scripts/state_checkpoint.py +15 -0
  61. package/skills/gsd-path/scripts/status_runtime.py +73 -2
  62. package/skills/gsd-path/scripts/workflow_run.py +10 -3
  63. package/skills/gsd-path/scripts/worktree_paths.py +45 -0
  64. package/skills/gsd-path/templates/intent.md +5 -3
  65. package/skills/gsd-path/templates/task.md +8 -1
  66. package/skills/gsd-path/templates/wave-review.md +24 -8
  67. package/skills/gsd-path-build/SKILL.md +3 -1
  68. package/skills/gsd-path-build/references/build-native.md +20 -3
  69. package/skills/gsd-path-build/references/coder.md +6 -3
  70. package/skills/gsd-path-build/references/reviewer-final.md +10 -0
  71. package/skills/gsd-path-build/references/reviewer-wave.md +61 -22
  72. package/skills/gsd-path-build/references/reviewer.md +8 -6
  73. package/skills/gsd-path-build/references/runtime-selection.md +36 -0
  74. package/skills/gsd-path-build/references/spec-probes.md +19 -1
  75. package/skills/gsd-path-build/scripts/_common.py +76 -2
  76. package/skills/gsd-path-build/scripts/archive_milestone.py +129 -7
  77. package/skills/gsd-path-build/scripts/build_recovery.py +25 -2
  78. package/skills/gsd-path-build/scripts/build_state.py +333 -8
  79. package/skills/gsd-path-build/scripts/check_handoffs.py +437 -30
  80. package/skills/gsd-path-build/scripts/check_task_briefs.py +135 -11
  81. package/skills/gsd-path-build/scripts/discussion_validate.py +46 -10
  82. package/skills/gsd-path-build/scripts/dispatch_driver.py +28 -3
  83. package/skills/gsd-path-build/scripts/integration.py +196 -39
  84. package/skills/gsd-path-build/scripts/isolation.py +996 -38
  85. package/skills/gsd-path-build/scripts/loop_run.py +9 -4
  86. package/skills/gsd-path-build/scripts/members.py +17 -3
  87. package/skills/gsd-path-build/scripts/pipeline_state.py +10 -2
  88. package/skills/gsd-path-build/scripts/state_checkpoint.py +15 -0
  89. package/skills/gsd-path-build/scripts/workflow_run.py +10 -3
  90. package/skills/gsd-path-build/scripts/worktree_paths.py +45 -0
  91. package/skills/gsd-path-build/templates/task.md +8 -1
  92. package/skills/gsd-path-build/templates/wave-review.md +24 -8
  93. package/skills/gsd-path-decide/references/runtime-selection.md +36 -0
  94. package/skills/gsd-path-decide/scripts/_common.py +76 -2
  95. package/skills/gsd-path-decide/scripts/archive_milestone.py +129 -7
  96. package/skills/gsd-path-decide/scripts/build_recovery.py +25 -2
  97. package/skills/gsd-path-decide/scripts/check_handoffs.py +437 -30
  98. package/skills/gsd-path-decide/scripts/check_task_briefs.py +135 -11
  99. package/skills/gsd-path-decide/scripts/discussion_validate.py +46 -10
  100. package/skills/gsd-path-decide/scripts/integration.py +196 -39
  101. package/skills/gsd-path-decide/scripts/isolation.py +996 -38
  102. package/skills/gsd-path-decide/scripts/loop_run.py +9 -4
  103. package/skills/gsd-path-decide/scripts/members.py +17 -3
  104. package/skills/gsd-path-decide/scripts/pipeline_state.py +10 -2
  105. package/skills/gsd-path-decide/scripts/state_checkpoint.py +15 -0
  106. package/skills/gsd-path-decide/scripts/worktree_paths.py +45 -0
  107. package/skills/gsd-path-define/SKILL.md +2 -1
  108. package/skills/gsd-path-define/references/runtime-selection.md +36 -0
  109. package/skills/gsd-path-define/references/spec-probes.md +19 -1
  110. package/skills/gsd-path-define/scripts/_common.py +76 -2
  111. package/skills/gsd-path-define/scripts/archive_milestone.py +129 -7
  112. package/skills/gsd-path-define/scripts/build_recovery.py +25 -2
  113. package/skills/gsd-path-define/scripts/check_handoffs.py +437 -30
  114. package/skills/gsd-path-define/scripts/check_task_briefs.py +135 -11
  115. package/skills/gsd-path-define/scripts/discussion_validate.py +46 -10
  116. package/skills/gsd-path-define/scripts/integration.py +196 -39
  117. package/skills/gsd-path-define/scripts/isolation.py +996 -38
  118. package/skills/gsd-path-define/scripts/loop_run.py +9 -4
  119. package/skills/gsd-path-define/scripts/members.py +17 -3
  120. package/skills/gsd-path-define/scripts/pipeline_state.py +10 -2
  121. package/skills/gsd-path-define/scripts/state_checkpoint.py +15 -0
  122. package/skills/gsd-path-define/scripts/worktree_paths.py +45 -0
  123. package/skills/gsd-path-define/templates/intent.md +5 -3
  124. package/skills/gsd-path-discuss/references/runtime-selection.md +36 -0
  125. package/skills/gsd-path-discuss/scripts/_common.py +76 -2
  126. package/skills/gsd-path-discuss/scripts/loop_run.py +9 -4
  127. package/skills/gsd-path-discuss/scripts/worktree_paths.py +45 -0
  128. package/skills/gsd-path-docs-audit/references/runtime-selection.md +36 -0
  129. package/skills/gsd-path-docs-audit/scripts/_common.py +76 -2
  130. package/skills/gsd-path-docs-audit/scripts/archive_milestone.py +129 -7
  131. package/skills/gsd-path-docs-audit/scripts/build_recovery.py +25 -2
  132. package/skills/gsd-path-docs-audit/scripts/check_handoffs.py +437 -30
  133. package/skills/gsd-path-docs-audit/scripts/check_task_briefs.py +135 -11
  134. package/skills/gsd-path-docs-audit/scripts/discussion_validate.py +46 -10
  135. package/skills/gsd-path-docs-audit/scripts/integration.py +196 -39
  136. package/skills/gsd-path-docs-audit/scripts/isolation.py +996 -38
  137. package/skills/gsd-path-docs-audit/scripts/loop_run.py +9 -4
  138. package/skills/gsd-path-docs-audit/scripts/members.py +17 -3
  139. package/skills/gsd-path-docs-audit/scripts/pipeline_state.py +10 -2
  140. package/skills/gsd-path-docs-audit/scripts/state_checkpoint.py +15 -0
  141. package/skills/gsd-path-docs-audit/scripts/worktree_paths.py +45 -0
  142. package/skills/gsd-path-forensics/references/runtime-selection.md +36 -0
  143. package/skills/gsd-path-forensics/scripts/_common.py +76 -2
  144. package/skills/gsd-path-forensics/scripts/loop_run.py +9 -4
  145. package/skills/gsd-path-forensics/scripts/pipeline_diagnose.py +104 -1
  146. package/skills/gsd-path-forensics/scripts/worktree_paths.py +45 -0
  147. package/skills/gsd-path-inspect/references/runtime-selection.md +36 -0
  148. package/skills/gsd-path-inspect/scripts/_common.py +76 -2
  149. package/skills/gsd-path-inspect/scripts/archive_milestone.py +129 -7
  150. package/skills/gsd-path-inspect/scripts/build_recovery.py +25 -2
  151. package/skills/gsd-path-inspect/scripts/check_handoffs.py +437 -30
  152. package/skills/gsd-path-inspect/scripts/check_task_briefs.py +135 -11
  153. package/skills/gsd-path-inspect/scripts/discussion_validate.py +46 -10
  154. package/skills/gsd-path-inspect/scripts/integration.py +196 -39
  155. package/skills/gsd-path-inspect/scripts/isolation.py +996 -38
  156. package/skills/gsd-path-inspect/scripts/loop_run.py +9 -4
  157. package/skills/gsd-path-inspect/scripts/members.py +17 -3
  158. package/skills/gsd-path-inspect/scripts/pipeline_state.py +10 -2
  159. package/skills/gsd-path-inspect/scripts/state_checkpoint.py +15 -0
  160. package/skills/gsd-path-inspect/scripts/workflow_run.py +10 -3
  161. package/skills/gsd-path-inspect/scripts/worktree_paths.py +45 -0
  162. package/skills/gsd-path-loop/references/runtime-selection.md +36 -0
  163. package/skills/gsd-path-loop/scripts/_common.py +76 -2
  164. package/skills/gsd-path-loop/scripts/loop_run.py +9 -4
  165. package/skills/gsd-path-loop/scripts/worktree_paths.py +45 -0
  166. package/skills/gsd-path-migrate/references/runtime-selection.md +36 -0
  167. package/skills/gsd-path-migrate/scripts/_common.py +76 -2
  168. package/skills/gsd-path-migrate/scripts/loop_run.py +9 -4
  169. package/skills/gsd-path-plan/SKILL.md +10 -0
  170. package/skills/gsd-path-plan/references/runtime-selection.md +36 -0
  171. package/skills/gsd-path-plan/references/spec-probes.md +19 -1
  172. package/skills/gsd-path-plan/scripts/_common.py +76 -2
  173. package/skills/gsd-path-plan/scripts/archive_milestone.py +129 -7
  174. package/skills/gsd-path-plan/scripts/build_recovery.py +25 -2
  175. package/skills/gsd-path-plan/scripts/check_handoffs.py +437 -30
  176. package/skills/gsd-path-plan/scripts/check_task_briefs.py +135 -11
  177. package/skills/gsd-path-plan/scripts/discussion_validate.py +46 -10
  178. package/skills/gsd-path-plan/scripts/integration.py +196 -39
  179. package/skills/gsd-path-plan/scripts/isolation.py +996 -38
  180. package/skills/gsd-path-plan/scripts/loop_run.py +9 -4
  181. package/skills/gsd-path-plan/scripts/members.py +17 -3
  182. package/skills/gsd-path-plan/scripts/pipeline_state.py +10 -2
  183. package/skills/gsd-path-plan/scripts/state_checkpoint.py +15 -0
  184. package/skills/gsd-path-plan/scripts/workflow_run.py +10 -3
  185. package/skills/gsd-path-plan/scripts/worktree_paths.py +45 -0
  186. package/skills/gsd-path-plan/templates/task.md +8 -1
  187. package/skills/gsd-path-research/references/runtime-selection.md +36 -0
  188. package/skills/gsd-path-research/scripts/_common.py +76 -2
  189. package/skills/gsd-path-research/scripts/archive_milestone.py +129 -7
  190. package/skills/gsd-path-research/scripts/build_recovery.py +25 -2
  191. package/skills/gsd-path-research/scripts/check_handoffs.py +437 -30
  192. package/skills/gsd-path-research/scripts/check_task_briefs.py +135 -11
  193. package/skills/gsd-path-research/scripts/discussion_validate.py +46 -10
  194. package/skills/gsd-path-research/scripts/integration.py +196 -39
  195. package/skills/gsd-path-research/scripts/isolation.py +996 -38
  196. package/skills/gsd-path-research/scripts/loop_run.py +9 -4
  197. package/skills/gsd-path-research/scripts/members.py +17 -3
  198. package/skills/gsd-path-research/scripts/pipeline_state.py +10 -2
  199. package/skills/gsd-path-research/scripts/state_checkpoint.py +15 -0
  200. package/skills/gsd-path-research/scripts/worktree_paths.py +45 -0
  201. package/skills/gsd-path-roadmap/references/runtime-selection.md +36 -0
  202. package/skills/gsd-path-roadmap/scripts/_common.py +76 -2
  203. package/skills/gsd-path-roadmap/scripts/archive_milestone.py +129 -7
  204. package/skills/gsd-path-roadmap/scripts/build_recovery.py +25 -2
  205. package/skills/gsd-path-roadmap/scripts/check_handoffs.py +437 -30
  206. package/skills/gsd-path-roadmap/scripts/check_task_briefs.py +135 -11
  207. package/skills/gsd-path-roadmap/scripts/discussion_validate.py +46 -10
  208. package/skills/gsd-path-roadmap/scripts/integration.py +196 -39
  209. package/skills/gsd-path-roadmap/scripts/isolation.py +996 -38
  210. package/skills/gsd-path-roadmap/scripts/loop_run.py +9 -4
  211. package/skills/gsd-path-roadmap/scripts/members.py +17 -3
  212. package/skills/gsd-path-roadmap/scripts/pipeline_state.py +10 -2
  213. package/skills/gsd-path-roadmap/scripts/state_checkpoint.py +15 -0
  214. package/skills/gsd-path-roadmap/scripts/worktree_paths.py +45 -0
  215. package/skills/gsd-path-ship/SKILL.md +41 -8
  216. package/skills/gsd-path-ship/references/reviewer-final.md +10 -0
  217. package/skills/gsd-path-ship/references/reviewer-wave.md +61 -22
  218. package/skills/gsd-path-ship/references/reviewer.md +8 -6
  219. package/skills/gsd-path-ship/references/runtime-selection.md +36 -0
  220. package/skills/gsd-path-ship/references/spec-probes.md +19 -1
  221. package/skills/gsd-path-ship/scripts/_common.py +76 -2
  222. package/skills/gsd-path-ship/scripts/archive_milestone.py +129 -7
  223. package/skills/gsd-path-ship/scripts/build_recovery.py +25 -2
  224. package/skills/gsd-path-ship/scripts/build_state.py +333 -8
  225. package/skills/gsd-path-ship/scripts/check_handoffs.py +437 -30
  226. package/skills/gsd-path-ship/scripts/check_task_briefs.py +135 -11
  227. package/skills/gsd-path-ship/scripts/discussion_validate.py +46 -10
  228. package/skills/gsd-path-ship/scripts/integration.py +196 -39
  229. package/skills/gsd-path-ship/scripts/isolation.py +996 -38
  230. package/skills/gsd-path-ship/scripts/lean_verification.py +28 -13
  231. package/skills/gsd-path-ship/scripts/loop_run.py +9 -4
  232. package/skills/gsd-path-ship/scripts/members.py +17 -3
  233. package/skills/gsd-path-ship/scripts/pipeline_state.py +10 -2
  234. package/skills/gsd-path-ship/scripts/state_checkpoint.py +15 -0
  235. package/skills/gsd-path-ship/scripts/workflow_run.py +10 -3
  236. package/skills/gsd-path-ship/scripts/worktree_paths.py +45 -0
  237. package/skills/gsd-path-ship/templates/wave-review.md +24 -8
  238. package/skills/gsd-path-undo/references/runtime-selection.md +36 -0
  239. package/skills/gsd-path-undo/scripts/_common.py +76 -2
  240. package/skills/gsd-path-undo/scripts/loop_run.py +9 -4
  241. package/skills/gsd-path-undo/scripts/worktree_paths.py +45 -0
  242. package/skills/path/BUILD.md +3 -1
  243. package/skills/path/DEFINE.md +2 -1
  244. package/skills/path/PLAN.md +10 -0
  245. package/skills/path/SHIP.md +41 -8
  246. package/skills/path/SKILL.md +16 -1
  247. package/skills/path/references/build-native.md +20 -3
  248. package/skills/path/references/coder.md +6 -3
  249. package/skills/path/references/reviewer-final.md +10 -0
  250. package/skills/path/references/reviewer-wave.md +61 -22
  251. package/skills/path/references/reviewer.md +8 -6
  252. package/skills/path/references/runtime-selection.md +36 -0
  253. package/skills/path/references/spec-probes.md +19 -1
  254. package/skills/path/scripts/_common.py +76 -2
  255. package/skills/path/scripts/archive_milestone.py +129 -7
  256. package/skills/path/scripts/build_recovery.py +25 -2
  257. package/skills/path/scripts/build_state.py +333 -8
  258. package/skills/path/scripts/check_handoffs.py +437 -30
  259. package/skills/path/scripts/check_task_briefs.py +135 -11
  260. package/skills/path/scripts/discussion_validate.py +46 -10
  261. package/skills/path/scripts/dispatch_driver.py +28 -3
  262. package/skills/path/scripts/git_guard.py +44 -4
  263. package/skills/path/scripts/integration.py +196 -39
  264. package/skills/path/scripts/isolation.py +996 -38
  265. package/skills/path/scripts/lean_verification.py +28 -13
  266. package/skills/path/scripts/loop_run.py +9 -4
  267. package/skills/path/scripts/members.py +17 -3
  268. package/skills/path/scripts/pipeline_state.py +10 -2
  269. package/skills/path/scripts/state_checkpoint.py +15 -0
  270. package/skills/path/scripts/status_runtime.py +73 -2
  271. package/skills/path/scripts/workflow_run.py +10 -3
  272. package/skills/path/scripts/worktree_paths.py +45 -0
  273. package/skills/path/templates/intent.md +5 -3
  274. package/skills/path/templates/task.md +8 -1
  275. package/skills/path/templates/wave-review.md +24 -8
@@ -231,8 +231,14 @@ def _retire_execution(repo, head, execution):
231
231
  isolation.retire(repo, worktree, execution["branch"], force=False)
232
232
 
233
233
 
234
- def verify_project(repo, expected_head):
235
- """Execute project Verify in isolation, retaining output for retry reuse."""
234
+ def verify_project(repo, expected_head, *, rerun: bool = False):
235
+ """Execute project Verify in isolation, retaining output for retry reuse.
236
+
237
+ With ``rerun`` (the owner's explicit ``prepare-final --rerun`` after a
238
+ host-caused failure diagnosed through the forensics contract), a recorded
239
+ execution at the same command and commit is retired and the command runs
240
+ again, appending a second ledger entry; the failed entry stays as history.
241
+ """
236
242
  repo = Path(repo).resolve()
237
243
  _require_ship_inputs(repo, expected_head)
238
244
  plan = contracts._read(repo, ".project/plan/PLAN.md")
@@ -247,7 +253,6 @@ def verify_project(repo, expected_head):
247
253
  destination = repo / relative
248
254
  if destination.is_symlink():
249
255
  raise contracts.HandoffError("project verification evidence must be a real file")
250
- expected_destination = hashlib.sha256(destination.read_bytes()).hexdigest() if destination.exists() else None
251
256
  execution = entry.get("execution") if entry else None
252
257
  if entry and execution is None:
253
258
  raise contracts.HandoffError("recorded verification lacks output; preserve and reconcile its evidence")
@@ -263,11 +268,16 @@ def verify_project(repo, expected_head):
263
268
  relative, relative, journal["expected_destination"],
264
269
  )
265
270
  _retire_execution(repo, expected_head, execution)
266
- view = _gap_view(command, expected_head, execution, waves)
267
- if not destination.exists() or destination.read_text(encoding="utf-8") != view:
268
- _common.atomic_write(destination, view)
269
- return {"passed": entry["result"] == "pass", "reused": True,
270
- "path": str(destination), "execution": execution}
271
+ if not rerun:
272
+ view = _gap_view(command, expected_head, execution, waves)
273
+ if not destination.exists() or destination.read_text(encoding="utf-8") != view:
274
+ _common.atomic_write(destination, view)
275
+ return {"passed": entry["result"] == "pass", "reused": True,
276
+ "path": str(destination), "execution": execution}
277
+ # Hash after any prior execution's recovery: a rerun's old-collection
278
+ # recovery can rewrite the gap view, and the fresh collection must expect
279
+ # the file as it stands now, not before the recovery (#454).
280
+ expected_destination = hashlib.sha256(destination.read_bytes()).hexdigest() if destination.exists() else None
271
281
  sidecar = isolation.isolate_verify(repo, expected_head, "project-verify")
272
282
  worktree = Path(sidecar["worktree"])
273
283
  try:
@@ -295,18 +305,20 @@ def verify_project(repo, expected_head):
295
305
  execution["members"] = {name: record["tip"] for name, record in sidecars.items()}
296
306
  else:
297
307
  build_state.verify_record(str(repo), command, expected_head,
298
- "pass" if passed else "fail", execution=execution)
308
+ "pass" if passed else "fail", execution=execution,
309
+ rerun=rerun)
299
310
  isolation.clean_verify(repo, worktree, expected_head, sidecar["branch"])
300
311
  _common.atomic_write(worktree / relative, _gap_view(command, expected_head, execution, waves))
301
312
  collection = isolation.collect_artifact(repo, worktree, expected_head, sidecar["branch"],
302
313
  relative, relative, expected_destination)
303
314
  isolation.retire(repo, worktree, sidecar["branch"], force=False)
304
315
  return {"passed": passed, "reused": False, "path": str(destination),
305
- "execution": execution, "collection": collection}
316
+ "execution": execution, "collection": collection,
317
+ **({"rerun": True} if rerun else {})}
306
318
 
307
319
 
308
- def prepare_final(repo, expected_head):
309
- verification = verify_project(repo, expected_head)
320
+ def prepare_final(repo, expected_head, *, rerun: bool = False):
321
+ verification = verify_project(repo, expected_head, rerun=rerun)
310
322
  if not verification["passed"]:
311
323
  return {"status": "blocked", "verification": verification,
312
324
  "reason": "project Verify failed; use its recorded output"}
@@ -327,9 +339,12 @@ def main(argv=None):
327
339
  parser = argparse.ArgumentParser(description=__doc__)
328
340
  parser.add_argument("--repo", type=Path, required=True)
329
341
  parser.add_argument("--expected-head", required=True)
342
+ parser.add_argument("--rerun", action="store_true",
343
+ help="re-execute project Verify at the same head after a host-caused "
344
+ "failure, appending a new ledger entry")
330
345
  args = parser.parse_args(argv)
331
346
  try:
332
- result = prepare_final(args.repo.resolve(), args.expected_head)
347
+ result = prepare_final(args.repo.resolve(), args.expected_head, rerun=args.rerun)
333
348
  except (contracts.HandoffError, isolation.IsolationError, build_state.BuildStateError,
334
349
  OSError, subprocess.CalledProcessError) as error:
335
350
  print(str(error), file=sys.stderr)
@@ -530,10 +530,15 @@ def run_shell(command: str, timeout: float) -> tuple[Optional[int], str]:
530
530
  except subprocess.TimeoutExpired as drained:
531
531
  stdout = drained.stdout or expired.stdout or b""
532
532
  stderr = drained.stderr or expired.stderr or b""
533
- if process.stdout:
534
- process.stdout.close()
535
- if process.stderr:
536
- process.stderr.close()
533
+ # Windows communicate() reads in threads that hold each pipe's lock,
534
+ # so close() would block until an orphaned grandchild (bash forks
535
+ # commands it runs from a script file) exits; leave them to the
536
+ # daemon reader threads there.
537
+ if os.name != "nt":
538
+ if process.stdout:
539
+ process.stdout.close()
540
+ if process.stderr:
541
+ process.stderr.close()
537
542
  process.kill()
538
543
  process.wait(timeout=5)
539
544
  output = stdout.decode("utf-8", errors="replace") + stderr.decode("utf-8", errors="replace")
@@ -34,9 +34,17 @@ MEMBERS_FILE = "MEMBERS.md"
34
34
  MARKER_SCHEMA = "gsd-path/member/v1"
35
35
  MARKER_KEYS = {"schema", "coordinator", "project", "name"}
36
36
  NAME_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
37
- FIELDS = ("Checkout", "Remote", "Integration")
37
+ FIELDS = ("Checkout", "Remote", "Integration", "Prepare")
38
+ # The first three are required; Prepare (isolate provisioning, #463) and
39
+ # Default branch are optional.
40
+ REQUIRED_FIELDS = ("Checkout", "Remote", "Integration")
38
41
  # Optional: `add` records it one time; a row without it means main.
39
42
  DEFAULT_BRANCH_FIELD = "Default branch"
43
+ # Optional: a shell command run inside each fresh member isolate before any
44
+ # Verify (#463). It provisions what a bare worktree lacks — submodules from
45
+ # the primary checkout's object store, gitignored build inputs — and must
46
+ # copy, never link, outside the isolate (#462).
47
+ PREPARE_FIELD = "Prepare"
40
48
  INTEGRATIONS = ("default", "direct", "pull-request")
41
49
  GITHUB_REMOTE_RE = re.compile(
42
50
  r"(?:https://github\.com/|git@github\.com:|ssh://git@github\.com/)([^/\s]+)/([^/\s]+)"
@@ -206,10 +214,15 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
206
214
  return []
207
215
  if path.is_symlink() or not path.is_file():
208
216
  raise MembersError("MEMBERS.md must be a regular file")
217
+ return parse_members(path.read_text(encoding="utf-8"))
218
+
219
+
220
+ def parse_members(text: str) -> list[dict[str, str]]:
221
+ """Parse MEMBERS.md text."""
209
222
  members: list[dict[str, str]] = []
210
223
  current: Optional[dict[str, str]] = None
211
224
  preamble: list[str] = []
212
- for number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
225
+ for number, line in enumerate(text.splitlines(), 1):
213
226
  if line.startswith("## "):
214
227
  name = line[3:].strip()
215
228
  if not NAME_RE.fullmatch(name):
@@ -241,7 +254,7 @@ def read_members(coordinator: Path) -> list[dict[str, str]]:
241
254
  if len(set(names)) != len(names):
242
255
  raise MembersError("MEMBERS.md repeats a member name")
243
256
  for member in members:
244
- missing = [field for field in FIELDS if not member.get(field.lower())]
257
+ missing = [field for field in REQUIRED_FIELDS if not member.get(field.lower())]
245
258
  if missing:
246
259
  raise MembersError(f"MEMBERS.md member {member['name']} lacks {', '.join(missing)}")
247
260
  if member["integration"] not in INTEGRATIONS:
@@ -510,6 +523,7 @@ def render(members: Sequence[dict[str, str]]) -> str:
510
523
  f"Checkout: {member['checkout']}\n"
511
524
  f"Remote: {member['remote']}\n"
512
525
  f"Integration: {member['integration']}\n"
526
+ + (f"{PREPARE_FIELD}: {member['prepare']}\n" if "prepare" in member else "")
513
527
  # A row that never had the field keeps none, so old rows are not rewritten.
514
528
  + (f"{DEFAULT_BRANCH_FIELD}: {member['default_branch']}\n" if "default_branch" in member else "")
515
529
  for member in members
@@ -24,16 +24,20 @@ from typing import Callable, Optional, Sequence
24
24
  def _load_diagnose_modules():
25
25
  if __package__:
26
26
  from . import archive_milestone
27
+ from . import build_state
27
28
  from . import detect_project
28
29
  from . import discussion_records
30
+ from . import discussion_validate
29
31
  from . import integration
30
32
  from . import isolation
31
33
  from . import pipeline_git
32
34
  from . import pipeline_state
33
35
  return (
34
36
  archive_milestone,
37
+ build_state,
35
38
  detect_project,
36
39
  discussion_records,
40
+ discussion_validate,
37
41
  integration,
38
42
  isolation,
39
43
  pipeline_git,
@@ -42,16 +46,20 @@ def _load_diagnose_modules():
42
46
  else:
43
47
  try:
44
48
  import archive_milestone
49
+ import build_state
45
50
  import detect_project
46
51
  import discussion_records
52
+ import discussion_validate
47
53
  import integration
48
54
  import isolation
49
55
  import pipeline_git
50
56
  import pipeline_state
51
57
  return (
52
58
  archive_milestone,
59
+ build_state,
53
60
  detect_project,
54
61
  discussion_records,
62
+ discussion_validate,
55
63
  integration,
56
64
  isolation,
57
65
  pipeline_git,
@@ -60,8 +68,10 @@ def _load_diagnose_modules():
60
68
  except ModuleNotFoundError as error:
61
69
  if error.name not in {
62
70
  "archive_milestone",
71
+ "build_state",
63
72
  "detect_project",
64
73
  "discussion_records",
74
+ "discussion_validate",
65
75
  "integration",
66
76
  "isolation",
67
77
  "pipeline_git",
@@ -71,8 +81,10 @@ def _load_diagnose_modules():
71
81
  try:
72
82
  from scripts import (
73
83
  archive_milestone,
84
+ build_state,
74
85
  detect_project,
75
86
  discussion_records,
87
+ discussion_validate,
76
88
  integration,
77
89
  isolation,
78
90
  pipeline_git,
@@ -80,8 +92,10 @@ def _load_diagnose_modules():
80
92
  )
81
93
  return (
82
94
  archive_milestone,
95
+ build_state,
83
96
  detect_project,
84
97
  discussion_records,
98
+ discussion_validate,
85
99
  integration,
86
100
  isolation,
87
101
  pipeline_git,
@@ -91,8 +105,10 @@ def _load_diagnose_modules():
91
105
  if error.name not in {
92
106
  "scripts",
93
107
  "scripts.archive_milestone",
108
+ "scripts.build_state",
94
109
  "scripts.detect_project",
95
110
  "scripts.discussion_records",
111
+ "scripts.discussion_validate",
96
112
  "scripts.integration",
97
113
  "scripts.isolation",
98
114
  "scripts.pipeline_git",
@@ -109,16 +125,20 @@ def _load_diagnose_modules():
109
125
  shared = Path(__file__).resolve().parents[2] / "gsd-path" / "scripts"
110
126
  sys.path.insert(0, str(shared))
111
127
  import archive_milestone
128
+ import build_state
112
129
  import detect_project
113
130
  import discussion_records
131
+ import discussion_validate
114
132
  import integration
115
133
  import isolation
116
134
  import pipeline_git
117
135
  import pipeline_state
118
136
  return (
119
137
  archive_milestone,
138
+ build_state,
120
139
  detect_project,
121
140
  discussion_records,
141
+ discussion_validate,
122
142
  integration,
123
143
  isolation,
124
144
  pipeline_git,
@@ -128,8 +148,10 @@ def _load_diagnose_modules():
128
148
 
129
149
  (
130
150
  archive_milestone,
151
+ build_state,
131
152
  detect_project,
132
153
  discussion_records,
154
+ discussion_validate,
133
155
  integration,
134
156
  isolation,
135
157
  pipeline_git,
@@ -137,6 +159,9 @@ def _load_diagnose_modules():
137
159
  ) = _load_diagnose_modules()
138
160
  DetectError = detect_project.DetectError
139
161
  classify = detect_project.classify
162
+ BuildStateError = build_state.BuildStateError
163
+ build_ready = build_state.ready
164
+ review_cycle_counts = discussion_validate.review_cycle_counts
140
165
  IsolationError = isolation.IsolationError
141
166
  _registered_worktrees = isolation._registered_worktrees
142
167
  _worktree_report = isolation._worktree_report
@@ -231,7 +256,15 @@ def _state_template() -> Path:
231
256
 
232
257
 
233
258
  def _command(module: object, *arguments: object) -> str:
234
- script = Path(str(getattr(module, "__file__"))).resolve()
259
+ return _script_command(Path(str(getattr(module, "__file__"))), *arguments)
260
+
261
+
262
+ def _dispatch_command(*arguments: object) -> str:
263
+ return _script_command(Path(str(build_state.__file__)).with_name("dispatch_driver.py"), *arguments)
264
+
265
+
266
+ def _script_command(path: Path, *arguments: object) -> str:
267
+ script = path.resolve()
235
268
  def text(value: object) -> str:
236
269
  # Agents paste this into Git Bash on Windows, where unquoted
237
270
  # backslashes are escapes; forward slashes need no quoting.
@@ -354,6 +387,74 @@ def _leftover_worktrees(repo: Path) -> list[dict[str, object]]:
354
387
  return leftovers
355
388
 
356
389
 
390
+ def _build_completion_findings(repo: Path) -> list[dict[str, str]]:
391
+ """Mirror dispatch complete's build gates so a completion refusal is diagnosable.
392
+
393
+ Runs the same checks `dispatch_driver.py complete` runs, under the same
394
+ precondition (every wave's tasks done, no blocked review holding them), and
395
+ names the review set or typed build state that complete would refuse on.
396
+ """
397
+ try:
398
+ ready_result = build_ready(str(repo), allow_done=True)
399
+ except BuildStateError as error:
400
+ return [
401
+ _finding(
402
+ "review-cycles",
403
+ "stuck",
404
+ f"{error.code}: {error}",
405
+ _dispatch_command("complete", "--repo", repo)
406
+ + " # reports the same typed blocker; follow its recovery (reconcile "
407
+ "failed or blocked tasks, resolve a dependency deadlock before new dispatch)",
408
+ )
409
+ ]
410
+ blocked = ready_result.get("blocked_review")
411
+ if blocked:
412
+ wave, cycle = blocked["wave"], blocked["cycle"]
413
+ waiting = ", ".join(
414
+ str(item) for item in blocked.get("waiting_waves") or []
415
+ )
416
+ if any((repo / ".project" / "tasks").glob(f"*-fix-wave-{wave}-cycle-{cycle}.md")):
417
+ retry = (
418
+ _dispatch_command("review", "--repo", repo, "--wave", wave, "--cycle", cycle + 1)
419
+ + " # add the owner's --child-command; the source wave's next review cycle "
420
+ "in full at the current HEAD; "
421
+ "pass --repair-evidence when review_findings.py repair-evidence accepts a landed repair"
422
+ )
423
+ else:
424
+ retry = (
425
+ _dispatch_command("fix-tasks", "--repo", repo, "--wave", wave, "--cycle", cycle)
426
+ + " # then land the repair wave; the source wave's next review cycle judges it"
427
+ )
428
+ return [
429
+ _finding(
430
+ "review-cycles",
431
+ "stuck",
432
+ f"wave {wave} review cycle {cycle} is blocked "
433
+ + (f"and holds waiting waves {waiting}" if waiting else "and holds completion"),
434
+ retry,
435
+ )
436
+ ]
437
+ if ready_result.get("current_wave") is not None:
438
+ return []
439
+ try:
440
+ review_cycle_counts(repo / ".project")
441
+ except archive_milestone.ArchiveError as error:
442
+ return [
443
+ _finding(
444
+ "review-cycles",
445
+ "stuck",
446
+ str(error),
447
+ "compare each PLAN.md wave with the .project/review/wave-N.cycleC.md "
448
+ "files, then run the missing cycle's review: "
449
+ + _dispatch_command("review", "--repo", repo)
450
+ + " with that wave's --wave and --cycle and the owner's --child-command; "
451
+ "a repair wave is judged by "
452
+ "its source wave's next cycle",
453
+ )
454
+ ]
455
+ return []
456
+
457
+
357
458
  def diagnose(repo: Path) -> dict[str, object]:
358
459
  resolved = repo.resolve()
359
460
  if not resolved.is_dir():
@@ -567,6 +668,8 @@ def diagnose(repo: Path) -> dict[str, object]:
567
668
  "$gsd-path-ship",
568
669
  )
569
670
  )
671
+ elif state.get("phase") == "build" and state.get("status") in {"active", "done"}:
672
+ findings.extend(_build_completion_findings(resolved))
570
673
 
571
674
  undo_transaction = by_name["undo-transaction"]
572
675
  route_action = (
@@ -1407,6 +1407,12 @@ def _route_state(repo: Path, project_dir: str = ".project") -> dict[str, object]
1407
1407
  mode="milestone" if status == "done" else "normal",
1408
1408
  reason=f"state is {phase}/{status}",
1409
1409
  )
1410
+ if phase == "plan" and status == "blocked":
1411
+ return _route_result(
1412
+ state,
1413
+ "block",
1414
+ reason="plan is blocked; resolve the correction or user decision named by the plan gate before resuming",
1415
+ )
1410
1416
  if phase == "plan" and status != "done":
1411
1417
  return _route_result(state, "run-phase", phase="plan", reason=f"state is {phase}/{status}")
1412
1418
  if phase == "plan":
@@ -2373,9 +2379,9 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
2373
2379
  args.project_dir,
2374
2380
  )
2375
2381
  elif args.command == "approve" and (args.defer_checkpoint or args.patch):
2376
- if args.expected_head is not None:
2382
+ if args.defer_checkpoint and args.expected_head is not None:
2377
2383
  raise PipelineStateError(
2378
- "--expected-head is not accepted with --defer-checkpoint or --patch"
2384
+ "--expected-head is not accepted with --defer-checkpoint"
2379
2385
  )
2380
2386
  result = state_checkpoint.defer_approval(
2381
2387
  args.repo,
@@ -2384,6 +2390,8 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
2384
2390
  args.milestone,
2385
2391
  args.patch,
2386
2392
  )
2393
+ if args.expected_head is not None:
2394
+ result["expected_head_accepted"] = args.expected_head
2387
2395
  elif args.command == "approve":
2388
2396
  if args.expected_head is None:
2389
2397
  raise PipelineStateError(
@@ -7,6 +7,7 @@ import os
7
7
  from pathlib import Path
8
8
  import shutil
9
9
  import shlex
10
+ import sys
10
11
  import tempfile
11
12
  import time
12
13
  from contextlib import contextmanager
@@ -74,7 +75,7 @@ def guard_launcher(name):
74
75
  f" raise SystemExit({2 if name == 'guard_hook.py' else 1})\n")
75
76
 
76
77
 
77
- def source_manifest(source):
78
+ def source_manifest(source, successors=()):
78
79
  version = json.loads((source / "package.json").read_text(encoding="utf-8"))["version"]
79
80
  files = {}
80
81
  for name in (*RUNTIME_FILES, *GUARDS, "status_runtime.py"):
@@ -83,6 +84,13 @@ def source_manifest(source):
83
84
  raise ValueError(f"symlinked runtime source: {path}")
84
85
  files[name] = hashlib.sha256(path.read_bytes()).hexdigest()
85
86
  manifest = {"version": version, "files": files}
87
+ if successors:
88
+ # #468 option (i): the successors list joins the manifest BEFORE the
89
+ # digest is taken, so the published runtime's own bytes bless exactly
90
+ # these successor digests. An unnamed publish keeps the stock
91
+ # manifest — identical sources still digest identically.
92
+ manifest["successors"] = list(successors)
93
+ status_runtime.manifest_successors(manifest)
86
94
  pin = {"schema": status_runtime.RUNTIME_SCHEMA, "version": version,
87
95
  "digest": status_runtime.manifest_digest(manifest)}
88
96
  return manifest, pin
@@ -114,9 +122,33 @@ def publication_lock(parent, digest):
114
122
  os.close(descriptor)
115
123
 
116
124
 
117
- def publish(source, *, expected=None, dry_run=False, repair=False):
118
- manifest, pin = source_manifest(source)
125
+ def _blessed_store_entry(expected, source_manifest_dict) -> bool:
126
+ """True when the expected runtime is in the store, hash-valid, stamped with
127
+ successors, and built from the SAME version and files as the supplied
128
+ source — only the stamp may differ (#468). A wrong package never restores,
129
+ even for a blessed pin."""
130
+ try:
131
+ root = status_runtime.validate_runtime(expected)
132
+ manifest = json.loads((root / "manifest.json").read_text(encoding="utf-8"))
133
+ return bool(
134
+ status_runtime.manifest_successors(manifest)
135
+ and manifest.get("version") == source_manifest_dict.get("version")
136
+ and manifest.get("files") == source_manifest_dict.get("files")
137
+ )
138
+ except (OSError, ValueError, TypeError):
139
+ return False
140
+
141
+
142
+ def publish(source, *, expected=None, dry_run=False, repair=False, successors=()):
143
+ manifest, pin = source_manifest(source, successors)
119
144
  if expected is not None and any(pin[key] != expected.get(key) for key in pin):
145
+ # A blessed pin's digest covers its successors stamp, so an unstamped
146
+ # source cannot reproduce it. When the blessed entry itself is in the
147
+ # store, hash-valid, stamped, and built from the same version and
148
+ # files as this source, restore returns those exact bytes; every
149
+ # other mismatch stays the caller's error.
150
+ if _blessed_store_entry(expected, manifest):
151
+ return expected
120
152
  raise ValueError(f"source does not match declared runtime {expected['version']} ({expected['digest']}); use its matching package")
121
153
  if dry_run:
122
154
  return pin
@@ -336,3 +368,56 @@ def operate(source, project, action, *, dry_run=False, provenance=None):
336
368
  raise
337
369
  journal.unlink()
338
370
  return {**pin, "backup": str(backup)} if backup else pin
371
+
372
+
373
+ def main(argv=None) -> int:
374
+ import argparse
375
+
376
+ parser = argparse.ArgumentParser(
377
+ description="Content-addressed runtime store operations. Publication only adds a "
378
+ "store entry: it never selects a runtime for, or writes to, any project.")
379
+ subcommands = parser.add_subparsers(dest="command", required=True)
380
+ publish_parser = subcommands.add_parser(
381
+ "publish", help="publish and verify a runtime without touching any project pin")
382
+ publish_parser.add_argument("--source", required=True,
383
+ help="runtime source root (holds package.json and scripts/)")
384
+ publish_parser.add_argument("--expected-version",
385
+ help="with --expected-digest: refuse a source that differs")
386
+ publish_parser.add_argument("--expected-digest",
387
+ help="with --expected-version: refuse a source that differs")
388
+ publish_parser.add_argument("--provenance", default=None,
389
+ help="provenance note echoed in the output; the store never keeps it")
390
+ publish_parser.add_argument("--dry-run", action="store_true",
391
+ help="compute the identity without writing to the store")
392
+ publish_parser.add_argument("--repair", action="store_true",
393
+ help="replace a store entry that fails verification")
394
+ publish_parser.add_argument("--name-successor", action="append", default=[], metavar="DIGEST",
395
+ help="record a successor runtime digest in this runtime's hashed "
396
+ "manifest (#468 option (i)); publish the successor first, then "
397
+ "name its digest here — repeatable for several successors")
398
+ arguments = parser.parse_args(argv)
399
+ source = Path(arguments.source).resolve()
400
+ if (arguments.expected_version is None) != (arguments.expected_digest is None):
401
+ parser.error("--expected-version and --expected-digest are given together or not at all")
402
+ expected = None
403
+ if arguments.expected_version is not None:
404
+ if not arguments.expected_version or not arguments.expected_digest:
405
+ parser.error("--expected-version and --expected-digest must be non-empty")
406
+ expected = {"schema": status_runtime.RUNTIME_SCHEMA,
407
+ "version": arguments.expected_version,
408
+ "digest": arguments.expected_digest}
409
+ try:
410
+ pin = publish(source, expected=expected,
411
+ dry_run=arguments.dry_run, repair=arguments.repair,
412
+ successors=arguments.name_successor)
413
+ path = None if arguments.dry_run else str(status_runtime.validate_runtime(pin))
414
+ except (ValueError, OSError) as error:
415
+ print(f"publish failed: {error}", file=sys.stderr)
416
+ return 1
417
+ result = {**pin, "path": path, "provenance": arguments.provenance}
418
+ print(json.dumps(result, indent=2, sort_keys=True))
419
+ return 0
420
+
421
+
422
+ if __name__ == "__main__":
423
+ sys.exit(main())
@@ -458,6 +458,21 @@ def _validate_plan_briefs(repo: Path, kind: str, project_dir: str) -> None:
458
458
  if head.returncode != 0:
459
459
  # Pre-Git approval defers base-dependent checks to build.
460
460
  return
461
+ if project_dir == ".project":
462
+ # Unsupported .project entries must surface at approval, before build,
463
+ # where removing them is cheapest (#466). Like the checkpoint, judge
464
+ # each git-visible entry by whether it still exists in the worktree.
465
+ project = repo / ".project"
466
+ unsupported = sorted(
467
+ name
468
+ for name in pipeline_state._common.git_visible_entries(project) - PROJECT_ENTRIES
469
+ if (project / name).exists() or (project / name).is_symlink()
470
+ )
471
+ if unsupported:
472
+ raise PipelineStateError(
473
+ "unsupported .project artifacts: " + ", ".join(unsupported)
474
+ + "; remove or relocate them before approving the plan"
475
+ )
461
476
  if __package__:
462
477
  from .check_task_briefs import BriefError, _member_bases, plan_validation_bases, validate_task_briefs
463
478
  else:
@@ -128,7 +128,76 @@ def manifest_digest(manifest: dict) -> str:
128
128
  return hashlib.sha256(json.dumps(manifest, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
129
129
 
130
130
 
131
+ def manifest_successors(manifest: dict) -> tuple:
132
+ """The validated successor digests a runtime's hashed manifest names (#468).
133
+
134
+ `successors` is an optional list of unique 64-hex lowercase store digests.
135
+ It lives inside the manifest, so the runtime's own digest covers it: a
136
+ runtime can only bless successors its hashed bytes name. A malformed
137
+ field makes the whole runtime invalid — acceptance never widens on a
138
+ shape the store cannot prove.
139
+ """
140
+ successors = manifest.get("successors", [])
141
+ if (not isinstance(successors, list)
142
+ or not all(
143
+ isinstance(item, str) and len(item) == 64
144
+ and all(char in "0123456789abcdef" for char in item)
145
+ for item in successors
146
+ )
147
+ or len(set(successors)) != len(successors)):
148
+ raise ValueError("invalid manifest successors")
149
+ return tuple(successors)
150
+
151
+
152
+ def accepted_runtime_dirs(repo: Path) -> set:
153
+ """The pinned runtime's directory plus every validated successor (#468).
154
+
155
+ Owner-ruled option (i) on #468: acceptance after ship is anchored in the
156
+ pinned runtime's own hashed manifest. It names successor digests; each
157
+ hop is validated exactly like the pin and may name further successors.
158
+ A named entry that is missing or fails validation simply does not widen
159
+ the set — the pinned runtime keeps working — and a visited set ends
160
+ blessed cycles. Nothing outside the pin's hashed reach is ever accepted.
161
+ """
162
+ pin = declaration(repo)
163
+ pin_root, pin_manifest = _validated_manifest(pin)
164
+ accepted = {pin_root.resolve()}
165
+ seen = {pin["digest"]}
166
+ pending = list(manifest_successors(pin_manifest))
167
+ while pending:
168
+ digest = pending.pop()
169
+ if digest in seen:
170
+ continue
171
+ seen.add(digest)
172
+ try:
173
+ # version None: the hop's trust anchor is the digest the pinned
174
+ # bytes named, never a version claim. The successors followed
175
+ # come from the same manifest read whose digest was proven —
176
+ # a mid-walk entry swap cannot smuggle a different list.
177
+ root, manifest = _validated_manifest(
178
+ {"schema": RUNTIME_SCHEMA, "version": None, "digest": digest}
179
+ )
180
+ named = manifest_successors(manifest)
181
+ except (OSError, ValueError, TypeError):
182
+ continue
183
+ pending.extend(named)
184
+ accepted.add(root.resolve())
185
+ return accepted
186
+
187
+
131
188
  def validate_runtime(data: dict) -> Path:
189
+ return _validated_manifest(data)[0]
190
+
191
+
192
+ def _validated_manifest(data: dict) -> tuple:
193
+ """validate_runtime, returning the exact manifest whose digest was proven.
194
+
195
+ Callers that act on manifest content (the #468 successor walk) must use
196
+ this returned object, never a second read of manifest.json: a concurrent
197
+ swap between reads could otherwise validate one manifest while following
198
+ successors from another. `version` None anchors on the digest alone —
199
+ used by the walk for hops whose version no declaration supplies.
200
+ """
132
201
  root = runtime_home() / data["digest"]
133
202
  hint = (f"Runtime {data['version']} ({data['digest']}) is unavailable or invalid at {root}. "
134
203
  "Run gsd-path --runtime-restore --project <project-root> "
@@ -141,7 +210,8 @@ def validate_runtime(data: dict) -> Path:
141
210
  raise ValueError("symlinked manifest")
142
211
  manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
143
212
  if (not isinstance(manifest, dict) or manifest_digest(manifest) != data["digest"]
144
- or manifest.get("version") != data["version"]):
213
+ or (data["version"] is not None
214
+ and manifest.get("version") != data["version"])):
145
215
  raise ValueError("manifest identity mismatch")
146
216
  files = manifest.get("files")
147
217
  if not isinstance(files, dict) or "pipeline_state.py" not in files:
@@ -164,9 +234,10 @@ def validate_runtime(data: dict) -> Path:
164
234
  runtime_entries.add(path.name)
165
235
  if runtime_entries != set(files) | {"manifest.json"}:
166
236
  raise ValueError("unexpected runtime files")
237
+ manifest_successors(manifest)
167
238
  except (OSError, ValueError, TypeError) as error:
168
239
  raise ValueError(hint + f" ({error})") from error
169
- return root
240
+ return root, manifest
170
241
 
171
242
 
172
243
  def resolve_runtime(repo: Path) -> Path: