@opengsd/gsd-path 1.6.1 → 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 +34 -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 +20 -29
  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 +1015 -37
  23. package/scripts/lean_verification.py +33 -14
  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 +5 -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 +43 -9
  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 +1015 -37
  56. package/skills/gsd-path/scripts/lean_verification.py +33 -14
  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 +5 -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 +1015 -37
  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 +1015 -37
  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 +1015 -37
  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 +1015 -37
  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 +1015 -37
  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 +1015 -37
  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 +1015 -37
  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 +1015 -37
  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 +43 -9
  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 +1015 -37
  230. package/skills/gsd-path-ship/scripts/lean_verification.py +33 -14
  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 +5 -1
  243. package/skills/path/DEFINE.md +2 -1
  244. package/skills/path/PLAN.md +10 -0
  245. package/skills/path/SHIP.md +43 -9
  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 +1015 -37
  265. package/skills/path/scripts/lean_verification.py +33 -14
  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
@@ -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
@@ -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(
@@ -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:
@@ -7,11 +7,13 @@ import sys
7
7
  # Runtime helpers must not modify their immutable installation.
8
8
  sys.dont_write_bytecode = True
9
9
 
10
+ import contextlib
10
11
  import hashlib
11
12
  import json
12
13
  import os
13
14
  import tempfile
14
15
  from pathlib import Path
16
+ from typing import Optional
15
17
 
16
18
  if __package__:
17
19
  from . import _common
@@ -22,7 +24,39 @@ else:
22
24
  from scripts import _common
23
25
 
24
26
 
27
+ # Run-scoped cache for read-only consumers (#469): recovery resolves a
28
+ # placement per task, and each resolution lists the whole worktree registry
29
+ # and re-reads the placement receipt. Within one read-only run none of that
30
+ # can change, so the listing and the derived workspace answer once. Flows
31
+ # that create, move, or retire worktrees never enter this scope.
32
+ _PLACEMENT_CACHE: Optional[dict] = None
33
+
34
+
35
+ @contextlib.contextmanager
36
+ def readonly_placement_cache():
37
+ global _PLACEMENT_CACHE
38
+ if _PLACEMENT_CACHE is not None: # already inside a read-only run
39
+ yield
40
+ return
41
+ _PLACEMENT_CACHE = {"porcelain": {}, "workspace": {}}
42
+ try:
43
+ yield
44
+ finally:
45
+ _PLACEMENT_CACHE = None
46
+
47
+
25
48
  def _worktree_list_porcelain(primary: Path) -> tuple[str, bool]:
49
+ if _PLACEMENT_CACHE is not None:
50
+ key = str(primary)
51
+ if key in _PLACEMENT_CACHE["porcelain"]:
52
+ return _PLACEMENT_CACHE["porcelain"][key] # type: ignore[index]
53
+ output = _worktree_list_porcelain_uncached(primary)
54
+ _PLACEMENT_CACHE["porcelain"][key] = output
55
+ return output
56
+ return _worktree_list_porcelain_uncached(primary)
57
+
58
+
59
+ def _worktree_list_porcelain_uncached(primary: Path) -> tuple[str, bool]:
26
60
  try:
27
61
  if __package__:
28
62
  from . import pipeline_git as pg
@@ -58,6 +92,17 @@ def _identity(path: Path) -> str:
58
92
 
59
93
 
60
94
  def _workspace(primary: Path, pin: bool) -> Path:
95
+ if _PLACEMENT_CACHE is not None and not pin:
96
+ key = str(primary)
97
+ if key in _PLACEMENT_CACHE["workspace"]:
98
+ return _PLACEMENT_CACHE["workspace"][key] # type: ignore[index]
99
+ workspace = _workspace_uncached(primary, False)
100
+ _PLACEMENT_CACHE["workspace"][key] = workspace
101
+ return workspace
102
+ return _workspace_uncached(primary, pin)
103
+
104
+
105
+ def _workspace_uncached(primary: Path, pin: bool) -> Path:
61
106
  common = Path(_git(primary, "rev-parse", "--git-common-dir"))
62
107
  common = (primary / common).resolve()
63
108
  identity = _identity(primary)
@@ -20,6 +20,42 @@ references to bundled copies of those helpers. Other skill helpers, templates,
20
20
  and role briefs remain in the installed skill bundle. Pass `-B` to Python helper
21
21
  invocations so execution does not create bytecode in the runtime store.
22
22
 
23
+ When the runtime enforcing an active task cannot finish it, one narrowly
24
+ authorized recovery exists:
25
+ `python3 -B <absolute isolation.py> runtime-hotfix --repo <absolute primary>
26
+ --task-id <id> --runtime <64-hex runtime digest printed by runtime_store.py publish>
27
+ --authorized-by <the owner ruling>` validates the replacement runtime,
28
+ re-runs that task's Verify in a fresh sidecar at the recorded base under the
29
+ replacement pin, and records the receipt twice: a Log line in the task file
30
+ naming the task, base, and both runtime digests, and an identical blob behind
31
+ the local ref `refs/gsd-path/runtime-hotfix/<task>-<base>`. Only a Log line
32
+ whose exact twin that ref holds later admits `.gsd-path/runtime.json` into that
33
+ task's landing, and only when the pin names exactly the receipt's runtime; any
34
+ other task, base, a Log line alone, or a bare mention authorizes nothing, and a
35
+ failing re-run refuses the hotfix. Limits: the command serves only a serial
36
+ task working in the primary and refuses parallel or member tasks; the ref is
37
+ local repository state, so a clone without it cannot land the pin and the
38
+ proof fails closed. The guard applies two rules to agent shell commands. It
39
+ denies any command where `runtime-hotfix` directly follows `isolation.py`, stdin
40
+ `-`, or an `isolation` module operand (`-m isolation`, `-Bm isolation`,
41
+ `-misolation`). It also denies every Git command with a word, including global
42
+ `-c` options and inline variable assignments, that contains `refs/gsd-path/` or
43
+ `runtime-hotfix`. This deliberately blocks read-only uses such as
44
+ `git log refs/gsd-path/<ref>` or `git log --grep runtime-hotfix`, and commit
45
+ messages or branch names that contain `runtime-hotfix`. Limits: it cannot see a copied or renamed
46
+ `isolation.py`, a refspec stored in a Git config file, a ref write made from
47
+ inside another program, a direct edit of files under `.git/`, or shell
48
+ expansion that splits both protected words, such as
49
+ `refs/gs{d,}-path/task-authorizations/<task>`.
50
+
51
+ Publishing a runtime to the shared store is project-free:
52
+ `python3 -B <absolute runtime_store.py> publish --source <absolute package root>`
53
+ verifies and publishes the content-addressed runtime, printing its version,
54
+ digest, and store path as JSON; `--provenance` records a note in that output
55
+ only. Publication alone neither authorizes execution nor changes a project's
56
+ trusted runtime — a project runs the bytes only when its own pin names that
57
+ digest.
58
+
23
59
  The selected runtime is authoritative for each helper in its `manifest.json`;
24
60
  this includes `review_findings.py` and `task_context.py`. A selected runtime
25
61
  that was published before a helper became a runtime file does not list that
@@ -14,6 +14,7 @@ import sys
14
14
  # Runtime helpers must not modify their immutable installation.
15
15
  sys.dont_write_bytecode = True
16
16
 
17
+ import atexit
17
18
  import contextlib
18
19
  import errno
19
20
  import json
@@ -23,9 +24,10 @@ import shlex
23
24
  import shutil
24
25
  import signal
25
26
  import subprocess
27
+ import tempfile
26
28
  import time
27
29
  from pathlib import Path
28
- from typing import BinaryIO, Iterable, Iterator, List, Optional, Sequence, Tuple
30
+ from typing import BinaryIO, Iterable, Iterator, List, Optional, Sequence, Set, Tuple
29
31
 
30
32
 
31
33
  # Chosen by what imports, not os.name, so tests can emulate Windows on POSIX.
@@ -65,6 +67,23 @@ VERIFY_LEDGER_PATH = ".project/build/verify-ledger.jsonl"
65
67
  VERIFY_RESULTS = ("pass", "fail")
66
68
  VERIFY_LEDGER_SCHEMA = "gsd-path/verify-ledger/v2"
67
69
  VERIFY_BLOCK_PATTERN = re.compile(r"```bash[ \t]*\n(?P<block>.*?)```", re.DOTALL)
70
+ # The owner citation a withdrawn task tombstone must carry in its Log (#483).
71
+ # One definition shared by every gate that reads the representation.
72
+ WITHDRAWN_CITATION = re.compile(
73
+ r"^- Withdrawn — (?P<date>\d{4}-\d{2}-\d{2}): (?P<ruling>.+)$", re.MULTILINE)
74
+
75
+
76
+ def withdrawn_citations(task_text: str) -> list[str]:
77
+ """The owner citations in a withdrawn task's Log, as `date: ruling`.
78
+
79
+ The Log is the append-only record of rulings; a Withdrawn line in any
80
+ other section is not the citation the gates accept (#483).
81
+ """
82
+ body = section_body(task_text, "Log")
83
+ if body is None:
84
+ return []
85
+ return [f"{m.group('date')}: {m.group('ruling')}"
86
+ for m in WITHDRAWN_CITATION.finditer(body)]
68
87
 
69
88
  REVIEW_BULLET_HEADING_PATTERN = re.compile(r"^#{2,3} ")
70
89
  REVIEW_BULLET_FIELD_LABEL_PATTERN = re.compile(r"^- \*\*[^*]+\*\*:")
@@ -197,6 +216,19 @@ def section_body(text: str, heading: str) -> Optional[str]:
197
216
  return match.group("body") if match else None
198
217
 
199
218
 
219
+ def contract_shapes(body: str) -> Set[str]:
220
+ """The whitespace-normalized `- ` entries of an Interface contract body.
221
+
222
+ HTML comments are dropped and continuation lines join their entry. A
223
+ body that says only `None` has no shapes.
224
+ """
225
+ cleaned = re.sub(r"(?s)<!--.*?-->", "", body).strip()
226
+ if " ".join(cleaned.split()) in {"", "None", "- None"}:
227
+ return set()
228
+ entries = re.split(r"(?m)^-[ \t]+", cleaned)
229
+ return {shape for shape in (" ".join(entry.split()) for entry in entries) if shape}
230
+
231
+
200
232
  def overlapping_paths(left: Sequence[str], right: Sequence[str]) -> List[str]:
201
233
  """Return the sorted paths that two task `files` lists both cover.
202
234
 
@@ -875,8 +907,50 @@ def find_bash() -> str:
875
907
  )
876
908
 
877
909
 
910
+ def _remove_quietly(path: str) -> None:
911
+ try:
912
+ os.remove(path)
913
+ except OSError:
914
+ pass
915
+
916
+
917
+ def _command_script_dir() -> str:
918
+ """A temp directory for command scripts that cannot dirty the running tree.
919
+
920
+ TMPDIR/TEMP/TMP may point into the tree being verified, and a script file
921
+ there shows up in git status and blocks landings (#448), so fall back to a
922
+ system directory when the environment temp sits inside the current tree.
923
+ """
924
+ candidate = os.path.realpath(tempfile.gettempdir())
925
+ tree = os.path.realpath(os.getcwd())
926
+ if candidate != tree and not candidate.startswith(tree + os.sep):
927
+ return candidate
928
+ for fallback in (
929
+ os.path.join(os.environ.get("SystemRoot", r"C:\Windows"), "Temp"),
930
+ "/var/tmp",
931
+ "/tmp",
932
+ ):
933
+ resolved = os.path.realpath(fallback)
934
+ if os.path.isdir(resolved) and resolved != tree and not resolved.startswith(tree + os.sep):
935
+ return resolved
936
+ raise OSError("no temp directory outside the current worktree for a command script")
937
+
938
+
878
939
  def bash_argv(command: str) -> List[str]:
879
- return [find_bash(), "-c", command]
940
+ if os.name != "nt":
941
+ return [find_bash(), "-c", command]
942
+ # Git for Windows' bash (MSYS2) cuts an argument that carries a double
943
+ # quote near 8 KB before bash sees it, so a long Verify dies mid-script
944
+ # or silently passes when the cut lands in comments (#448). Run the exact
945
+ # bytes from a temp file outside every worktree instead.
946
+ bash = find_bash()
947
+ descriptor, path = tempfile.mkstemp(
948
+ prefix="gsd-path-command-", suffix=".sh", dir=_command_script_dir()
949
+ )
950
+ with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
951
+ handle.write(command)
952
+ atexit.register(_remove_quietly, path)
953
+ return [bash, path]
880
954
 
881
955
 
882
956
  # cmd.exe re-parses the arguments of a .cmd or .bat target, so these would
@@ -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")
@@ -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 = (
@@ -7,11 +7,13 @@ import sys
7
7
  # Runtime helpers must not modify their immutable installation.
8
8
  sys.dont_write_bytecode = True
9
9
 
10
+ import contextlib
10
11
  import hashlib
11
12
  import json
12
13
  import os
13
14
  import tempfile
14
15
  from pathlib import Path
16
+ from typing import Optional
15
17
 
16
18
  if __package__:
17
19
  from . import _common
@@ -22,7 +24,39 @@ else:
22
24
  from scripts import _common
23
25
 
24
26
 
27
+ # Run-scoped cache for read-only consumers (#469): recovery resolves a
28
+ # placement per task, and each resolution lists the whole worktree registry
29
+ # and re-reads the placement receipt. Within one read-only run none of that
30
+ # can change, so the listing and the derived workspace answer once. Flows
31
+ # that create, move, or retire worktrees never enter this scope.
32
+ _PLACEMENT_CACHE: Optional[dict] = None
33
+
34
+
35
+ @contextlib.contextmanager
36
+ def readonly_placement_cache():
37
+ global _PLACEMENT_CACHE
38
+ if _PLACEMENT_CACHE is not None: # already inside a read-only run
39
+ yield
40
+ return
41
+ _PLACEMENT_CACHE = {"porcelain": {}, "workspace": {}}
42
+ try:
43
+ yield
44
+ finally:
45
+ _PLACEMENT_CACHE = None
46
+
47
+
25
48
  def _worktree_list_porcelain(primary: Path) -> tuple[str, bool]:
49
+ if _PLACEMENT_CACHE is not None:
50
+ key = str(primary)
51
+ if key in _PLACEMENT_CACHE["porcelain"]:
52
+ return _PLACEMENT_CACHE["porcelain"][key] # type: ignore[index]
53
+ output = _worktree_list_porcelain_uncached(primary)
54
+ _PLACEMENT_CACHE["porcelain"][key] = output
55
+ return output
56
+ return _worktree_list_porcelain_uncached(primary)
57
+
58
+
59
+ def _worktree_list_porcelain_uncached(primary: Path) -> tuple[str, bool]:
26
60
  try:
27
61
  if __package__:
28
62
  from . import pipeline_git as pg
@@ -58,6 +92,17 @@ def _identity(path: Path) -> str:
58
92
 
59
93
 
60
94
  def _workspace(primary: Path, pin: bool) -> Path:
95
+ if _PLACEMENT_CACHE is not None and not pin:
96
+ key = str(primary)
97
+ if key in _PLACEMENT_CACHE["workspace"]:
98
+ return _PLACEMENT_CACHE["workspace"][key] # type: ignore[index]
99
+ workspace = _workspace_uncached(primary, False)
100
+ _PLACEMENT_CACHE["workspace"][key] = workspace
101
+ return workspace
102
+ return _workspace_uncached(primary, pin)
103
+
104
+
105
+ def _workspace_uncached(primary: Path, pin: bool) -> Path:
61
106
  common = Path(_git(primary, "rev-parse", "--git-common-dir"))
62
107
  common = (primary / common).resolve()
63
108
  identity = _identity(primary)