@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
@@ -11,7 +11,6 @@ from __future__ import annotations
11
11
 
12
12
  import argparse
13
13
  import contextlib
14
- import functools
15
14
  import hashlib
16
15
  import io
17
16
  import json
@@ -27,7 +26,7 @@ import sys
27
26
  sys.dont_write_bytecode = True
28
27
  import tempfile
29
28
  from pathlib import Path, PurePosixPath
30
- from typing import Dict, Iterator, Optional, Sequence, Set, Tuple
29
+ from typing import Dict, Iterator, List, Optional, Sequence, Set, Tuple
31
30
 
32
31
  if __package__:
33
32
  from .pipeline_git import (
@@ -77,6 +76,8 @@ DISCUSSION_PATHS = frozenset(
77
76
  VERIFY_LEDGER_PATH = _common.VERIFY_LEDGER_PATH
78
77
  # recover verdicts that count as a landed task
79
78
  LANDED_VERDICTS = frozenset({"recovered", "attested"})
79
+ # The withdrawn-tombstone citation rule is shared through _common (#483).
80
+ withdrawn_citations = _common.withdrawn_citations
80
81
  # Orchestrator bookkeeping that may stay uncommitted in the primary while a
81
82
  # parallel task lands; the next `.project` checkpoint commits it.
82
83
  BOOKKEEPING_PATHS = DISCUSSION_PATHS | {
@@ -98,6 +99,13 @@ INLINE_LIST_PATTERN = _common.INLINE_LIST_PATTERN
98
99
  LIST_ITEM_PATTERN = _common.LIST_ITEM_PATTERN
99
100
  COLLECT_JOURNAL_SCHEMA = "gsd-path/collect-artifact/v1"
100
101
  NULL_SHA = "0" * 40
102
+ RETIRE_HINT = (
103
+ " A stale isolate or sidecar from an earlier attempt may be there; retire it first:"
104
+ " `python3 -B <absolute isolation.py> retire --repo <absolute primary>"
105
+ " --worktree <path> --branch <branch> --force` (retire --force exists for exactly"
106
+ " this; a stale review sidecar must be hash-compared against its archived copy"
107
+ " before retiring)"
108
+ )
101
109
 
102
110
 
103
111
  class IsolationError(RuntimeError):
@@ -174,8 +182,20 @@ def common_git_dir(repo: Path) -> Path:
174
182
 
175
183
 
176
184
  def sidecar_root(primary: Path, kind: str, name: str, *, pin: bool = False) -> Path:
185
+ # Recovery resolves the same placements per task; each resolution lists
186
+ # every worktree. Within one read-only run the answer cannot change, so
187
+ # the entrypoint-cleared memo answers repeats (#469).
188
+ key = ("sidecar-root", str(primary), kind, name)
189
+ run_active = ("head-sha", str(primary)) in _RECOVERY_MEMO
177
190
  try:
178
- return worktree_paths.worktree_path(primary, kind, name, pin=pin)
191
+ if run_active and not pin and key in _RECOVERY_MEMO:
192
+ return _RECOVERY_MEMO[key] # type: ignore[return-value]
193
+ resolved = worktree_paths.worktree_path(primary, kind, name, pin=pin)
194
+ if run_active and not pin:
195
+ # Only a read-only recovery run memoizes; pinning writes a
196
+ # placement receipt and must always run for real (#469).
197
+ _RECOVERY_MEMO[key] = resolved
198
+ return resolved
179
199
  except (ValueError, OSError) as error:
180
200
  raise IsolationError(str(error)) from error
181
201
 
@@ -500,6 +520,105 @@ def _long_path(absolute: str) -> str:
500
520
  return "\\\\?\\" + absolute
501
521
 
502
522
 
523
+ def _is_link_like(status: os.stat_result) -> bool:
524
+ """A symlink, or on Windows any name-surrogate reparse point such as a junction.
525
+
526
+ Path.is_symlink() is false for a junction, which still redirects the
527
+ path; the reparse name-surrogate bit covers junctions and symlinks on
528
+ every supported Python (3.9+) without os.path.isjunction (3.12+). The
529
+ same classification is used by guard_hook and detect_project.
530
+ """
531
+ return stat.S_ISLNK(status.st_mode) or bool(
532
+ getattr(status, "st_reparse_tag", 0) & 0x20000000 # IO_REPARSE_TAG name surrogate
533
+ )
534
+
535
+
536
+ def _tracked_symlinks(tree: Path, when: str) -> Set[str]:
537
+ """Paths, relative to `tree`, that its index (and submodules') tracks as symlinks."""
538
+ tracked_links: Set[str] = set()
539
+ if os.path.lexists(os.path.join(tree, ".git")):
540
+ listed = run_git(tree, "ls-files", "-s", "-z", "--recurse-submodules")
541
+ if listed.returncode != 0:
542
+ raise IsolationError(
543
+ f"could not list tracked files in {tree} {when}: "
544
+ + (listed.stderr or listed.stdout).strip())
545
+ for record in listed.stdout.split("\0"):
546
+ info, _, path = record.partition("\t")
547
+ if info.startswith("120000 "):
548
+ tracked_links.add(path)
549
+ return tracked_links
550
+
551
+
552
+ def _unlink_link_like(tree: Path) -> None:
553
+ """Unlink every link-like entry under a disposable tree before git removes it.
554
+
555
+ `git worktree remove` on Git for Windows older than 2.54 recurses through
556
+ NTFS junctions and deletes the target's contents (#462); untracked or
557
+ gitignored junctions pass the cleanliness checks that guard removal.
558
+ Unlinking each link — never traversing it — leaves git nothing to follow
559
+ and keeps POSIX symlinks on the same single code path. A link that cannot
560
+ be removed is a hard error: removal never proceeds with links present.
561
+ A symlink git tracks as a symlink is left for git, which removes it as an
562
+ index entry without traversing it; unlinking it would make a clean tree
563
+ dirty. A junction, or a symlink replacing a tracked non-link, is unlinked.
564
+ """
565
+ remaining: List[str] = []
566
+ tracked_links = _tracked_symlinks(tree, "before removal")
567
+
568
+ def _unlink_one(entry: str) -> bool:
569
+ try:
570
+ os.unlink(entry)
571
+ return True
572
+ except OSError:
573
+ try:
574
+ os.rmdir(entry) # an empty link-like dir on some platforms
575
+ return True
576
+ except OSError:
577
+ remaining.append(entry)
578
+ return False
579
+
580
+ def _scan_failed(error: OSError) -> None:
581
+ # A directory that cannot be scanned leaves an unaudited subtree, and
582
+ # an incomplete sweep must never hand git a tree that may still hold
583
+ # a junction (#462).
584
+ raise IsolationError(f"could not scan {tree} before removal: {error}")
585
+
586
+ root = Path(_long_path(os.path.abspath(tree))) if os.name == "nt" else tree
587
+
588
+ def _spared(entry: str, status: os.stat_result) -> bool:
589
+ return stat.S_ISLNK(status.st_mode) and (
590
+ os.path.relpath(entry, root).replace(os.sep, "/") in tracked_links)
591
+ for directory, dirnames, _files in os.walk(root, topdown=True, onerror=_scan_failed):
592
+ for base in dirnames[:]:
593
+ entry = os.path.join(directory, base)
594
+ try:
595
+ status = os.lstat(entry)
596
+ except OSError as error:
597
+ _scan_failed(error)
598
+ return # unreachable; _scan_failed raises
599
+ if not _is_link_like(status):
600
+ continue
601
+ # Prune BEFORE unlinking: os.walk(followlinks=False) still
602
+ # descends Windows junctions — they are directories, not
603
+ # symlinks, at the stat level (CPython issue 88174) — so a
604
+ # link whose unlink fails must already be out of the walk.
605
+ dirnames.remove(base)
606
+ if not _spared(entry, status):
607
+ _unlink_one(entry)
608
+ for name in _files:
609
+ entry = os.path.join(directory, name)
610
+ try:
611
+ status = os.lstat(entry)
612
+ except OSError as error:
613
+ _scan_failed(error)
614
+ return # unreachable; _scan_failed raises
615
+ if _is_link_like(status) and not _spared(entry, status):
616
+ _unlink_one(entry)
617
+ if remaining:
618
+ raise IsolationError(
619
+ "could not unlink link-like entries before removal: " + ", ".join(remaining))
620
+
621
+
503
622
  def _force_rmtree(path: Path) -> None:
504
623
  """Delete a sidecar tree directly, tolerating read-only files and long paths."""
505
624
  def _writable(function, target, _error):
@@ -526,6 +645,10 @@ def _remove_worktree(primary: Path, destination: Path, force: bool) -> None:
526
645
  """
527
646
  tree_delete_failure = ("failed to delete", "directory not empty", "permission denied")
528
647
 
648
+ if destination.is_dir():
649
+ # Defuse first: never let git traverse a junction or symlink (#462).
650
+ _unlink_link_like(destination)
651
+
529
652
  def failure(*flags: str) -> str:
530
653
  result = run_git(primary, "worktree", "remove", *flags, str(destination))
531
654
  return (result.stderr or result.stdout).strip() if result.returncode else ""
@@ -567,6 +690,8 @@ def _cleanup_reserved_worktree(
567
690
  records = _registered_worktrees(primary)
568
691
  registered = records.get(destination.resolve())
569
692
  if registered == ref and _same_directory(destination, reserved_directory):
693
+ if destination.is_dir():
694
+ _unlink_link_like(destination)
570
695
  run_git(primary, "worktree", "remove", "--force", str(destination))
571
696
  records = _registered_worktrees(primary)
572
697
  elif registered is None and _same_directory(destination, reserved_directory):
@@ -588,7 +713,7 @@ def create_named_worktree(
588
713
  f"refusing to check out the bound branch {bound} in a second worktree"
589
714
  )
590
715
  if os.path.lexists(destination):
591
- raise IsolationError(f"worktree path already exists: {destination}")
716
+ raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT)
592
717
  destination.parent.mkdir(parents=True, exist_ok=True)
593
718
  ref = f"refs/heads/{branch}"
594
719
  reserved = run_git(primary, "update-ref", ref, base, NULL_SHA)
@@ -598,7 +723,7 @@ def create_named_worktree(
598
723
  destination.mkdir()
599
724
  except FileExistsError as error:
600
725
  run_git(primary, "update-ref", "-d", ref, base)
601
- raise IsolationError(f"worktree path already exists: {destination}") from error
726
+ raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT) from error
602
727
  except OSError as error:
603
728
  run_git(primary, "update-ref", "-d", ref, base)
604
729
  raise IsolationError(f"cannot reserve worktree path {destination}: {error}") from error
@@ -752,7 +877,7 @@ def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
752
877
  raise IsolationError(f"bound checkout is missing or invalid: {destination}")
753
878
  if not holders:
754
879
  if os.path.lexists(destination):
755
- raise IsolationError(f"worktree path already exists: {destination}")
880
+ raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT)
756
881
  destination.parent.mkdir(parents=True, exist_ok=True)
757
882
  added = run_git(checkout, "worktree", "add", str(destination), branch)
758
883
  if added.returncode != 0:
@@ -760,6 +885,106 @@ def member_bound_checkout(coordinator: Path, member: str) -> Dict[str, object]:
760
885
  return {"branch": branch, "checkout": str(destination.resolve()), "member": member}
761
886
 
762
887
 
888
+ def _member_prepare_command(coordinator: Path, member: str) -> Optional[str]:
889
+ """The member's declared Prepare command from MEMBERS.md, if any (#463)."""
890
+ try:
891
+ import members
892
+ except ImportError: # pragma: no cover - package import used by tests
893
+ from scripts import members
894
+ try:
895
+ rows = members.read_members(coordinator)
896
+ except members.MembersError as error:
897
+ raise IsolationError(str(error)) from error
898
+ for row in rows:
899
+ if row["name"] == member:
900
+ return row.get("prepare") or None
901
+ return None
902
+
903
+
904
+ def _refuse_link_like(tree: Path) -> None:
905
+ """Refuse an isolate that provisioning left link-like entries in (#463).
906
+
907
+ Prepare must copy, never link: a link into the primary checkout would let
908
+ the isolate read and mutate shared inputs, and retirement would silently
909
+ defuse it (#462). Checked at handout, after the command exits zero. A
910
+ symlink the member repo tracks as a symlink is checked-out content, not
911
+ provisioning, and is spared; a junction, or a symlink replacing a tracked
912
+ non-link, is refused.
913
+ """
914
+ found: List[str] = []
915
+ tracked_links = _tracked_symlinks(tree, "after Prepare")
916
+ root = Path(_long_path(os.path.abspath(tree))) if os.name == "nt" else tree
917
+
918
+ def _scan_failed(error: OSError) -> None:
919
+ raise IsolationError(f"could not scan {tree} after Prepare: {error}")
920
+
921
+ def _refused(entry: str, status: os.stat_result) -> bool:
922
+ return _is_link_like(status) and not (stat.S_ISLNK(status.st_mode) and (
923
+ os.path.relpath(entry, root).replace(os.sep, "/") in tracked_links))
924
+
925
+ for directory, dirnames, files in os.walk(root, topdown=True, onerror=_scan_failed):
926
+ for base in dirnames[:]:
927
+ entry = os.path.join(directory, base)
928
+ try:
929
+ status = os.lstat(entry)
930
+ except OSError as error:
931
+ _scan_failed(error)
932
+ return
933
+ if _is_link_like(status):
934
+ dirnames.remove(base) # never descend a link
935
+ if _refused(entry, status):
936
+ found.append(entry)
937
+ for name in files:
938
+ entry = os.path.join(directory, name)
939
+ try:
940
+ status = os.lstat(entry)
941
+ except OSError as error:
942
+ _scan_failed(error)
943
+ return
944
+ if _refused(entry, status):
945
+ found.append(entry)
946
+ if found:
947
+ raise IsolationError(
948
+ "member Prepare left link-like entries; provisioning must copy, "
949
+ "never link: " + ", ".join(found))
950
+
951
+
952
+ def run_member_prepare(coordinator: Path, member: str, sidecar: Path) -> Optional[Dict[str, object]]:
953
+ """Run the member's declared Prepare command inside a fresh isolate (#463).
954
+
955
+ A bare member worktree lacks uninitialized submodules and gitignored build
956
+ inputs; the committed MEMBERS.md `Prepare:` command provisions them before
957
+ any Verify runs. The command runs with the isolate as cwd, through the
958
+ same bash argv Verify uses (host-neutral, and a script file on Windows);
959
+ a non-zero exit is a loud IsolationError carrying the output so the
960
+ attempt fails before a Verify burns time against a half-provisioned
961
+ tree. Provisioning must copy, never link, outside the isolate (#462) —
962
+ retirement defuses links, and a link would let the isolate mutate shared
963
+ inputs. Retained isolates keep their provisioning (#461): this runs only
964
+ where a fresh isolate is created.
965
+ """
966
+ command = _member_prepare_command(coordinator, member)
967
+ if command is None:
968
+ return None
969
+ completed = subprocess.run(
970
+ _common.bash_argv(command), cwd=sidecar,
971
+ capture_output=True, encoding="utf-8", errors="replace",
972
+ )
973
+ receipt = {
974
+ "command": command,
975
+ "exit_code": completed.returncode,
976
+ "stdout": completed.stdout,
977
+ "stderr": completed.stderr,
978
+ }
979
+ if completed.returncode != 0:
980
+ raise IsolationError(
981
+ f"member {member} Prepare failed in {sidecar}: "
982
+ f"{completed.stderr.strip() or completed.stdout.strip() or completed.returncode}"
983
+ )
984
+ _refuse_link_like(sidecar)
985
+ return receipt
986
+
987
+
763
988
  def isolate_member_task(coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
764
989
  """Member tasks always run in a sidecar at the member bound branch tip."""
765
990
  validate_task_id(task_id)
@@ -776,7 +1001,22 @@ def isolate_member_task(coordinator: Path, member: str, task_id: str) -> Dict[st
776
1001
  branch = f"{TASK_BRANCH_PREFIX}{name}"
777
1002
  destination = sidecar_root(checkout, "task", name, pin=True)
778
1003
  create_named_worktree(checkout, branch, destination, member_base, bound=str(bound["branch"]))
1004
+ try:
1005
+ prepared = run_member_prepare(coordinator, member, destination)
1006
+ except BaseException as error:
1007
+ # A fresh tree with a failed Prepare has nothing to land; retire it so
1008
+ # the retry starts clean instead of refusing on the existing path.
1009
+ try:
1010
+ _remove_worktree(checkout, destination, force=True)
1011
+ run_git(checkout, "update-ref", "-d", f"refs/heads/{branch}", member_base)
1012
+ except IsolationError as cleanup_error:
1013
+ raise IsolationError(
1014
+ f"{error}; retiring the fresh isolate also failed: {cleanup_error}") from error
1015
+ finally:
1016
+ release_workspace_quietly(checkout)
1017
+ raise
779
1018
  return {
1019
+ "prepare": prepared,
780
1020
  "bound_branch": bound["branch"],
781
1021
  "bound_checkout": bound["checkout"],
782
1022
  "kind": "task",
@@ -1021,6 +1261,15 @@ def activate_member_task(
1021
1261
  elif not copy.is_file():
1022
1262
  copy.parent.mkdir(parents=True, exist_ok=True)
1023
1263
  _common.atomic_write(copy, activated)
1264
+ # Pin the sibling preflight inputs (#484): the coder's role requires the
1265
+ # Interface-contract-sharing sibling task files; a member sidecar receives
1266
+ # them from the immutable coordinator base beside the live task copy.
1267
+ for sibling_file in _member_sibling_contract_files(coordinator, resolved_base, task_file):
1268
+ sibling_copy = _safe_member_task_copy(sidecar, sibling_file)
1269
+ sibling_copy.parent.mkdir(parents=True, exist_ok=True)
1270
+ _common.atomic_write(
1271
+ sibling_copy, git_text(coordinator, "show", f"{resolved_base}:{sibling_file}")
1272
+ )
1024
1273
  ref = member_task_authorization_ref(project, task_id)
1025
1274
  created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
1026
1275
  if created.returncode != 0 and run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip() != member_base:
@@ -1032,6 +1281,137 @@ def activate_member_task(
1032
1281
  return result
1033
1282
 
1034
1283
 
1284
+ def reauthorize_member_task(coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
1285
+ """Re-arm the member task authorization a failed finish cleared (#461).
1286
+
1287
+ A failed finish clears the authorization ref so that attempt cannot land.
1288
+ When the failure was environmental, the sidecar still stands at the same
1289
+ member base with the same live copy, and this explicit command re-records
1290
+ the authorization at that base so a rerunning finish can land without a
1291
+ full re-isolate. The copy is coder-writable, so every binding comes from
1292
+ orchestrator-owned records instead: the dispatch ledger's native member
1293
+ activation names the agent, base, member base, task file, and branch the
1294
+ live copy must still match, the coordinator task must still be pending,
1295
+ the contract at the recorded base must be unchanged with an append-only
1296
+ Log, the member base must stand unchanged and unlanded, and no member
1297
+ landing journal may be pending. Nothing inside the failed attempt's own
1298
+ machinery invokes this; it is an owner decision, under the driver lock.
1299
+ """
1300
+ coordinator = require_directory(coordinator, "coordinator")
1301
+ try:
1302
+ if __package__:
1303
+ from . import dispatch_driver
1304
+ else:
1305
+ import dispatch_driver
1306
+ except ImportError: # pragma: no cover - package import used by tests
1307
+ from scripts import dispatch_driver
1308
+ try:
1309
+ lock = dispatch_driver.acquire_lock(coordinator)
1310
+ except dispatch_driver.DriverStop as error:
1311
+ raise IsolationError(str(error)) from error
1312
+ with lock:
1313
+ return _reauthorize_member_task_locked(dispatch_driver, coordinator, member, task_id)
1314
+
1315
+
1316
+ def _reauthorize_member_task_locked(
1317
+ dispatch_driver, coordinator: Path, member: str, task_id: str) -> Dict[str, object]:
1318
+ checkout, project, entry = _member_context(coordinator, member)
1319
+ task_id = validate_task_id(task_id)
1320
+ # The orchestrator-recorded activation is the source of truth for every
1321
+ # binding; the live copy only proves continuity against it.
1322
+ record = None
1323
+ for state in dispatch_driver.latest_states(dispatch_driver.records_root(coordinator)):
1324
+ if (state.get("task_id") == task_id and state.get("mode") == "member"
1325
+ and state.get("member") == member):
1326
+ record = state
1327
+ break
1328
+ if record is None:
1329
+ raise IsolationError(f"member task {task_id} has no recorded member activation")
1330
+ branch = f"{TASK_BRANCH_PREFIX}{project}-{task_id}"
1331
+ if record.get("task_branch") not in (None, branch):
1332
+ raise IsolationError(f"member task {task_id} activation names another task branch")
1333
+ recorded_member_base = require_full_sha(str(record["member_base"]))
1334
+ recorded_base = require_full_sha(str(record["base"]))
1335
+ # The native record's task_file names the sidecar copy path (including
1336
+ # the copy-directory prefix); the contract file is the coordinator path.
1337
+ task_file = str(record.get("contract_file") or record["task_file"])
1338
+ if task_file.startswith(MEMBER_TASK_COPY_DIR + "/"):
1339
+ task_file = task_file[len(MEMBER_TASK_COPY_DIR) + 1:]
1340
+ sidecar = sidecar_root(checkout, "task", f"{project}-{task_id}")
1341
+ if require_attached(sidecar) != branch:
1342
+ raise IsolationError(f"member task sidecar is not on its task branch: {sidecar}")
1343
+ copy = _safe_member_task_copy(sidecar, task_file)
1344
+ if not copy.is_file():
1345
+ raise IsolationError(f"member task {task_id} live copy is missing: {copy}")
1346
+ copy_text = copy.read_text(encoding="utf-8")
1347
+ fields, error = task_frontmatter(copy_text)
1348
+ if error or fields is None or fields.get("id") != task_id:
1349
+ raise IsolationError(error or "unreadable member task live copy")
1350
+ if fields.get("status") != "in-progress":
1351
+ raise IsolationError(f"member task {task_id} live copy is not in-progress")
1352
+ agent = fields.get("agent")
1353
+ if not agent:
1354
+ raise IsolationError(f"member task {task_id} live copy lacks its agent")
1355
+ recorded_agent = record.get("agent")
1356
+ if recorded_agent and agent != recorded_agent:
1357
+ raise IsolationError(
1358
+ f"member task {task_id} live copy activation fields differ from its "
1359
+ "recorded activation; re-isolate instead")
1360
+ if not recorded_agent:
1361
+ # Activations recorded before the agent field existed name no agent;
1362
+ # the field is informational today (no authorization decision
1363
+ # consumes it), so a legacy record accepts the copy's non-empty one.
1364
+ pass
1365
+ if (fields.get("base") != recorded_base
1366
+ or fields.get("member_base") != recorded_member_base):
1367
+ raise IsolationError(
1368
+ f"member task {task_id} live copy activation fields differ from its "
1369
+ "recorded activation; re-isolate instead")
1370
+ # A settled coordinator task has nothing left to authorize.
1371
+ coordinator_task = coordinator / task_file
1372
+ coordinator_fields, coordinator_error = task_frontmatter(
1373
+ coordinator_task.read_text(encoding="utf-8"))
1374
+ if coordinator_error or coordinator_fields is None:
1375
+ raise IsolationError(coordinator_error or f"unreadable coordinator task: {task_file}")
1376
+ if coordinator_fields.get("status") != "pending":
1377
+ raise IsolationError(
1378
+ f"member task {task_id} is {coordinator_fields.get('status')} in the "
1379
+ "coordinator; there is nothing to reauthorize")
1380
+ member_base = recorded_member_base
1381
+ # The coder's uncommitted work may sit in the sidecar; the task branch and
1382
+ # the member bound branch must still stand at the recorded member base,
1383
+ # and nothing may have landed yet.
1384
+ tip = run_git(checkout, "rev-parse", "--verify", "--quiet", f"refs/heads/{branch}").stdout.strip()
1385
+ if tip != member_base:
1386
+ raise IsolationError(f"member task branch {branch} moved off its member base")
1387
+ if run_git(checkout, "merge-base", "--is-ancestor", member_base,
1388
+ f"refs/heads/{entry['branch']}").returncode != 0:
1389
+ raise IsolationError("member task member base is not on the member bound branch")
1390
+ resolved_base = require_commit(coordinator, recorded_base)
1391
+ _member_contract(coordinator, resolved_base, task_file, member)
1392
+ contract = git_text(coordinator, "show", f"{resolved_base}:{task_file}")
1393
+ member_log_delta(contract, copy_text)
1394
+ path = _member_journal_path(coordinator, task_id)
1395
+ if os.path.lexists(path):
1396
+ raise IsolationError(
1397
+ f"a member landing is pending for {task_id}; run recover_member_landing first")
1398
+ for pending in sorted(path.parent.glob("*.json")):
1399
+ if json.loads(pending.read_text(encoding="utf-8"))["member"] == member:
1400
+ raise IsolationError(
1401
+ f"member {member} has pending landing {pending.stem}; run recover_member_landing first")
1402
+ ref = member_task_authorization_ref(project, task_id)
1403
+ recorded = run_git(checkout, "rev-parse", "--verify", "--quiet", ref).stdout.strip()
1404
+ if recorded and recorded != member_base:
1405
+ raise IsolationError("member task authorization names another member base")
1406
+ if not recorded:
1407
+ created = run_git(checkout, "update-ref", ref, member_base, "0" * 40)
1408
+ if created.returncode != 0:
1409
+ raise IsolationError("could not record member task authorization")
1410
+ return {"agent": agent, "base": resolved_base, "copy": str(copy), "member": member,
1411
+ "member_base": member_base, "status": "in-progress", "task_branch": branch,
1412
+ "task_file": task_file, "worktree": str(sidecar), "reauthorized": not recorded}
1413
+
1414
+
1035
1415
  def retained_member_task(
1036
1416
  coordinator: Path, member: str, task_id: str, task_file: str, base: str
1037
1417
  ) -> Optional[Dict[str, object]]:
@@ -1164,6 +1544,36 @@ def _record_member_journal(path: Path, journal: Dict[str, object]) -> None:
1164
1544
  _common.atomic_write(path, json.dumps(journal, indent=2, sort_keys=True) + "\n")
1165
1545
 
1166
1546
 
1547
+ def _member_sibling_contract_files(
1548
+ coordinator: Path, base: str, task_file: str
1549
+ ) -> List[str]:
1550
+ """Sibling task files sharing this task's Interface contract, at the base.
1551
+
1552
+ The coder's sibling-interface preflight needs each sibling's task file;
1553
+ a member activation projects only the live task, so the preflight had no
1554
+ sanctioned read (#484). Siblings are recognized by their Interface
1555
+ contract text: every other task at `base` that shares at least one
1556
+ Interface-contract bullet with this task. The copies land beside the live task under the
1557
+ member copy directory, read from the immutable `base` tree.
1558
+ """
1559
+ def contract_bullets(text: str) -> Set[str]:
1560
+ body = _common.section_body(text, "Interface contract")
1561
+ return set() if body is None else _common.contract_shapes(body)
1562
+
1563
+ live = contract_bullets(git_text(coordinator, "show", f"{base}:{task_file}"))
1564
+ if not live:
1565
+ return []
1566
+ listing = git_output(
1567
+ coordinator, "ls-tree", "-r", "--name-only", base, "--", ".project/tasks")
1568
+ siblings: List[str] = []
1569
+ for sibling_file in listing.splitlines():
1570
+ if not sibling_file.endswith(".md") or sibling_file == task_file:
1571
+ continue
1572
+ if contract_bullets(git_text(coordinator, "show", f"{base}:{sibling_file}")) & live:
1573
+ siblings.append(sibling_file)
1574
+ return siblings
1575
+
1576
+
1167
1577
  def _member_contract(
1168
1578
  coordinator: Path, base: str, task_file: str, member: str,
1169
1579
  current_text: Optional[str] = None,
@@ -1468,8 +1878,11 @@ def isolate_member_verify(coordinator: Path, sidecar: Path) -> Dict[str, Dict[st
1468
1878
  branch = f"{VERIFY_BRANCH_PREFIX}{project}-{entry['name']}"
1469
1879
  destination = sidecar.parent / entry["name"]
1470
1880
  create_named_worktree(checkout, branch, destination, tip, bound=entry["branch"])
1881
+ # Registered before Prepare so a failed provisioning retires with
1882
+ # the loop's existing cleanup (#463).
1471
1883
  created[entry["name"]] = {"checkout": str(checkout), "worktree": str(destination),
1472
1884
  "branch": branch, "tip": tip}
1885
+ run_member_prepare(coordinator, entry["name"], destination)
1473
1886
  except BaseException:
1474
1887
  retire_member_verify(created)
1475
1888
  raise
@@ -1545,7 +1958,7 @@ def ensure_verify_sidecar(primary: Path, base: str, branch: str) -> Dict[str, ob
1545
1958
  ref = f"refs/heads/{branch}"
1546
1959
  registered = _registered_worktrees(primary)
1547
1960
  if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
1548
- raise IsolationError(f"worktree path already exists: {destination}")
1961
+ raise IsolationError(f"worktree path already exists: {destination}." + RETIRE_HINT)
1549
1962
  if destination.is_dir():
1550
1963
  try:
1551
1964
  # A leftover empty directory (a half-deleted sidecar) clears;
@@ -1578,6 +1991,8 @@ def ensure_verify_sidecar(primary: Path, base: str, branch: str) -> Dict[str, ob
1578
1991
  "worktree": str(destination),
1579
1992
  }
1580
1993
  if destination.resolve() in registered:
1994
+ if destination.is_dir():
1995
+ _unlink_link_like(destination)
1581
1996
  cleared = run_git(primary, "worktree", "remove", str(destination))
1582
1997
  if cleared.returncode != 0:
1583
1998
  run_git(primary, "worktree", "prune")
@@ -1748,6 +2163,16 @@ def _commit_pending(
1748
2163
  return current_sha(repo)
1749
2164
 
1750
2165
 
2166
+ # Run-scoped memo for read-only recovery queries (#469): recovery re-proves
2167
+ # every task against the same immutable commits, so shared-base lookups (tree
2168
+ # kinds, blob listings, serial bookkeeping, the primary worktree report) answer
2169
+ # once per run. Each public recovery entrypoint clears it, so a process that
2170
+ # mutates between runs never reads a stale entry, and only immutable inputs —
2171
+ # full commit shas or per-run constants — are ever keyed.
2172
+ _RECOVERY_MEMO: Dict[tuple, object] = {}
2173
+ _FULL_SHA = re.compile(r"[0-9a-f]{40}")
2174
+
2175
+
1751
2176
  def undeclared_paths(
1752
2177
  repo: Path, base: str, paths: Set[str], declared: Set[str]
1753
2178
  ) -> list[str]:
@@ -1757,11 +2182,17 @@ def undeclared_paths(
1757
2182
  An entry that is a file at `base` covers only itself: the task-brief lint
1758
2183
  rejects a path below a base file.
1759
2184
  """
2185
+ cacheable = _FULL_SHA.fullmatch(base) is not None
1760
2186
 
1761
- @functools.lru_cache(maxsize=None)
1762
2187
  def directory(entry: str) -> bool:
2188
+ key = ("tree-kind", str(repo), base, entry)
2189
+ if cacheable and key in _RECOVERY_MEMO:
2190
+ return _RECOVERY_MEMO[key] # type: ignore[index]
1763
2191
  kind = run_git(repo, "cat-file", "-t", f"{base}:{entry}")
1764
- return kind.returncode != 0 or kind.stdout.strip() == "tree"
2192
+ covering = kind.returncode != 0 or kind.stdout.strip() == "tree"
2193
+ if cacheable:
2194
+ _RECOVERY_MEMO[key] = covering
2195
+ return covering
1765
2196
 
1766
2197
  return sorted(
1767
2198
  path
@@ -1774,6 +2205,124 @@ def undeclared_paths(
1774
2205
  )
1775
2206
 
1776
2207
 
2208
+ RUNTIME_HOTFIX_MARKER = " — runtime-hotfix: "
2209
+ RUNTIME_PIN = ".gsd-path/runtime.json"
2210
+
2211
+
2212
+ def _runtime_hotfix_receipts(task_text: str, task_file: str) -> List[Dict[str, str]]:
2213
+ """The runtime-hotfix receipts recorded in a task Log (#480).
2214
+
2215
+ Each receipt line is
2216
+ `- <date> — runtime-hotfix: task <id> base <sha> runtime <old> -> <new> authorized by <ruling>`.
2217
+ The orchestrator's own command writes it; a hand-written line without
2218
+ matching digests authorizes nothing.
2219
+ """
2220
+ receipts: List[Dict[str, str]] = []
2221
+ for line in task_text.splitlines():
2222
+ stripped = line.strip()
2223
+ if not stripped.startswith("- ") or RUNTIME_HOTFIX_MARKER not in stripped:
2224
+ continue
2225
+ match = re.fullmatch(
2226
+ r"- (?P<date>\d{4}-\d{2}-\d{2}) — runtime-hotfix: task (?P<task>T\d{3}) "
2227
+ r"base (?P<base>[0-9a-f]{40}) runtime (?P<old>[0-9a-f]{16,64}) "
2228
+ r"-> (?P<new>[0-9a-f]{16,64}) authorized by (?P<authority>.+)",
2229
+ stripped,
2230
+ )
2231
+ if match is not None:
2232
+ receipts.append(match.groupdict())
2233
+ return receipts
2234
+
2235
+
2236
+ def runtime_hotfix_ref(task_id: str, base: str) -> str:
2237
+ """The orchestrator-owned ref recording a runtime-hotfix receipt (#480).
2238
+
2239
+ The task Log is coder-writable, so a Log line alone proves nothing: the
2240
+ runtime-hotfix command also stores the receipt as a blob behind this ref,
2241
+ and refs/gsd-path/ is guard-owned — an agent cannot write it. A landing
2242
+ admits the pin path only when the Log line and the ref record match
2243
+ verbatim.
2244
+ """
2245
+ return f"refs/gsd-path/runtime-hotfix/{task_id}-{base}"
2246
+
2247
+
2248
+ RUNTIME_SCHEMA_VALUE = "gsd-path/runtime/v1"
2249
+
2250
+
2251
+ def runtime_hotfix_allowed_paths(
2252
+ repo: Path, task_text: str, task_file: str, base: str,
2253
+ pin_text: Optional[str] = None,
2254
+ ) -> Set[str]:
2255
+ """The runtime pin path a task's receipts authorize at this base (#480).
2256
+
2257
+ Authorization needs BOTH records: the receipt line in the task Log —
2258
+ matched against the task's own frontmatter id and this landing's base —
2259
+ and the same line stored verbatim behind the orchestrator-owned
2260
+ runtime-hotfix ref. A Log line without the ref is a bare mention and
2261
+ authorizes nothing; a receipt for any other task or base authorizes
2262
+ nothing.
2263
+ """
2264
+ fields, error = task_frontmatter(task_text)
2265
+ if error or fields is None:
2266
+ return set()
2267
+ task_id = str(fields.get("id", ""))
2268
+ if not task_id:
2269
+ return set()
2270
+ ref = runtime_hotfix_ref(task_id, base)
2271
+ recorded = run_git(repo, "cat-file", "blob", ref)
2272
+ if recorded.returncode != 0:
2273
+ return set()
2274
+ recorded_lines = recorded.stdout.splitlines()
2275
+ # The exact pin bytes under admission — from the working tree, the index,
2276
+ # or a commit — supplied by the caller that knows which state it gates.
2277
+ if pin_text is None:
2278
+ try:
2279
+ pin_text = (repo / RUNTIME_PIN).read_text(encoding="utf-8")
2280
+ except OSError:
2281
+ return set()
2282
+ try:
2283
+ landed_pin = json.loads(pin_text)
2284
+ except json.JSONDecodeError:
2285
+ return set()
2286
+ if not isinstance(landed_pin, dict):
2287
+ return set()
2288
+ allowed: Set[str] = set()
2289
+ for receipt in _runtime_hotfix_receipts(task_text, task_file):
2290
+ if (receipt["task"] == task_id and receipt["base"] == base
2291
+ and receipt_line(receipt) in recorded_lines
2292
+ and _pin_is_receipt_runtime(landed_pin, receipt["new"])):
2293
+ allowed.add(RUNTIME_PIN)
2294
+ return allowed
2295
+
2296
+
2297
+ def _pin_is_receipt_runtime(pin: Dict[str, object], digest: str) -> bool:
2298
+ """Whether a pin declaration names exactly the receipt's replacement runtime.
2299
+
2300
+ Digest alone is not enough: the guard's loader needs schema and version
2301
+ too, so a receipt must not admit a declaration those checks would refuse.
2302
+ """
2303
+ if pin.get("digest") != digest or str(pin.get("schema", "")) != RUNTIME_SCHEMA_VALUE:
2304
+ return False
2305
+ try:
2306
+ import status_runtime
2307
+ except ImportError: # pragma: no cover - package import used by tests
2308
+ from scripts import status_runtime
2309
+ manifest_path = status_runtime.runtime_home() / digest / "manifest.json"
2310
+ try:
2311
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
2312
+ except (OSError, json.JSONDecodeError):
2313
+ return False
2314
+ return pin.get("version") == manifest.get("version")
2315
+
2316
+
2317
+ def receipt_line(receipt: Dict[str, str]) -> str:
2318
+ """The canonical Log text of a parsed receipt, without its list marker."""
2319
+ return (
2320
+ f"{receipt['date']} — runtime-hotfix: task {receipt['task']} base "
2321
+ f"{receipt['base']} runtime {receipt['old']} -> {receipt['new']} "
2322
+ f"authorized by {receipt['authority']}"
2323
+ )
2324
+
2325
+
1777
2326
  def validate_allowed_changes(
1778
2327
  repo: Path,
1779
2328
  base: str,
@@ -2046,6 +2595,160 @@ def _write_native_member_dispatch_record(coordinator: Path, activation: Dict[str
2046
2595
  dispatch_driver.record_native_member_activation(coordinator, activation)
2047
2596
 
2048
2597
 
2598
+ def _hotfix_snapshot_tree(primary: Path) -> str:
2599
+ """Tree of the primary's complete working tree via a temporary index."""
2600
+ with tempfile.TemporaryDirectory() as temporary:
2601
+ env = dict(os.environ, GIT_INDEX_FILE=str(Path(temporary) / "index"))
2602
+ for arguments in (("read-tree", "HEAD"), ("add", "-A"), ("write-tree",)):
2603
+ completed = subprocess.run(("git", "-C", str(primary), *arguments), env=env,
2604
+ encoding="utf-8", errors="replace", capture_output=True)
2605
+ if completed.returncode:
2606
+ raise IsolationError(
2607
+ f"git {' '.join(arguments)} failed: {completed.stderr.strip()}")
2608
+ return completed.stdout.strip()
2609
+
2610
+
2611
+ def _hotfix_task_file(primary: Path, task_id: str) -> str:
2612
+ tasks_dir = primary / ".project" / "tasks"
2613
+ matches = sorted(tasks_dir.glob(f"{task_id}-*.md")) if tasks_dir.is_dir() else []
2614
+ exact = tasks_dir / f"{task_id}.md"
2615
+ if exact.is_file() and not exact.is_symlink():
2616
+ matches = [exact]
2617
+ if len(matches) != 1 or not matches[0].is_file() or matches[0].is_symlink():
2618
+ raise IsolationError(f"task file for {task_id} is missing or ambiguous")
2619
+ return matches[0].relative_to(primary).as_posix()
2620
+
2621
+
2622
+ def _append_task_log(primary: Path, task_file: str, line: str) -> None:
2623
+ path = primary / task_file
2624
+ text = path.read_text(encoding="utf-8")
2625
+ if not text.endswith("\n"):
2626
+ text += "\n"
2627
+ _common.atomic_write(path, text + line + "\n")
2628
+
2629
+
2630
+ def runtime_hotfix(
2631
+ primary: Path,
2632
+ task_id: str,
2633
+ runtime_digest: str,
2634
+ authority: str,
2635
+ ) -> Dict[str, object]:
2636
+ """Authorize a runtime hotfix for one active serial task and re-verify it (#480).
2637
+
2638
+ The replacement runtime must be published in the shared store; the
2639
+ authorization, the exact task/base/digest identities, and the re-run
2640
+ Verify all stay explicit, and no unrelated change may slip into the
2641
+ landing. This command validates the replacement runtime, re-runs the
2642
+ task's Verify in a fresh verify sidecar at the recorded base, and on
2643
+ success records the receipt twice: as a blob behind the guard-owned
2644
+ runtime-hotfix ref (the authority an agent cannot forge) and as a Log
2645
+ line in the task file (the provenance a reader sees). Only a Log line
2646
+ whose identical twin the ref holds later admits `.gsd-path/runtime.json`
2647
+ into that task's landing; a failing re-run refuses the hotfix before
2648
+ either record is written.
2649
+ """
2650
+ primary = require_directory(primary, "primary worktree")
2651
+ task_id = validate_task_id(task_id)
2652
+ authority = authority.strip()
2653
+ if not authority or authority.splitlines() != [authority]:
2654
+ raise IsolationError("runtime-hotfix requires the authorizing ruling as one line of text")
2655
+ try:
2656
+ import runtime_store
2657
+ import status_runtime
2658
+ except ImportError: # pragma: no cover - package import used by tests
2659
+ from scripts import runtime_store, status_runtime
2660
+ from datetime import date as _date
2661
+ task_file = _hotfix_task_file(primary, task_id)
2662
+ task_text, _ = _read_task_text(primary / task_file)
2663
+ fields, error = task_frontmatter(task_text)
2664
+ if error or fields is None:
2665
+ raise IsolationError(error or f"unreadable task frontmatter: {task_file}")
2666
+ if fields.get("status") != "in-progress":
2667
+ raise IsolationError(
2668
+ f"runtime-hotfix applies to an active task; {task_id} is {fields.get('status')}")
2669
+ if str(fields.get("worktree", "")) not in ("", "null") and Path(
2670
+ str(fields.get("worktree"))).resolve() != primary.resolve():
2671
+ raise IsolationError(
2672
+ "runtime-hotfix applies to a serial task working in the primary; "
2673
+ f"{task_id} records another worktree")
2674
+ recorded_base = fields.get("base")
2675
+ if not isinstance(recorded_base, str) or not re.fullmatch(r"[0-9a-f]{40}", recorded_base):
2676
+ raise IsolationError(f"{task_id} records no full base; the hotfix cannot be bound")
2677
+ command = _common.task_verify_command(task_text)
2678
+ if not command:
2679
+ raise IsolationError(f"{task_id} has no Verify command to re-run")
2680
+ new_digest = runtime_digest.strip().lower()
2681
+ if re.fullmatch(r"[0-9a-f]{64}", new_digest) is None:
2682
+ raise IsolationError("--runtime must be the 64-hex digest of a published runtime")
2683
+ manifest_path = status_runtime.runtime_home() / new_digest / "manifest.json"
2684
+ try:
2685
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
2686
+ except (OSError, json.JSONDecodeError) as error:
2687
+ raise IsolationError(
2688
+ f"runtime {new_digest} is not published in the store: {error}"
2689
+ ) from error
2690
+ try:
2691
+ status_runtime.validate_runtime({
2692
+ "schema": status_runtime.RUNTIME_SCHEMA,
2693
+ "version": str(manifest.get("version", "")),
2694
+ "digest": new_digest,
2695
+ })
2696
+ declaration = status_runtime.declaration(primary)
2697
+ except (OSError, ValueError) as error:
2698
+ raise IsolationError(f"runtime-hotfix refused: {error}") from error
2699
+ old_digest = str(declaration.get("digest", ""))
2700
+ if old_digest == new_digest:
2701
+ raise IsolationError("runtime-hotfix requires a different runtime than the pinned one")
2702
+ sidecar = ensure_verify_sidecar(
2703
+ primary, recorded_base, verify_branch_name(f"runtime-hotfix-{task_id}")
2704
+ )
2705
+ sidecar_path = Path(str(sidecar["worktree"]))
2706
+ reproduce_verify_sidecar(sidecar_path, _hotfix_snapshot_tree(primary))
2707
+ # The re-run must prove the task under the REPLACEMENT runtime: write the
2708
+ # new declaration into the sidecar's pin before Verify runs (#480). The
2709
+ # primary's pin stays untouched; sidecar effects are the point of the
2710
+ # sidecar.
2711
+ sidecar_pin = sidecar_path / RUNTIME_PIN
2712
+ sidecar_pin.parent.mkdir(parents=True, exist_ok=True)
2713
+ _common.atomic_write(sidecar_pin, json.dumps({
2714
+ "schema": status_runtime.RUNTIME_SCHEMA,
2715
+ "version": str(manifest.get("version", "")),
2716
+ "digest": new_digest,
2717
+ }, indent=2, sort_keys=True) + "\n")
2718
+ completed = subprocess.run(
2719
+ ("sh", "-c", command), cwd=str(sidecar_path),
2720
+ encoding="utf-8", errors="replace", capture_output=True,
2721
+ )
2722
+ clean_verify(primary, sidecar_path, recorded_base, str(sidecar["branch"]))
2723
+ retire(primary, sidecar_path, str(sidecar["branch"]), False)
2724
+ if completed.returncode != 0:
2725
+ raise IsolationError(
2726
+ f"{task_id} Verify failed in the hotfix sidecar; the hotfix is refused")
2727
+ receipt = (
2728
+ f"- {_date.today().isoformat()} — runtime-hotfix: task {task_id} "
2729
+ f"base {recorded_base} runtime {old_digest} -> {new_digest} "
2730
+ f"authorized by {authority}"
2731
+ )
2732
+ blob = run_git(primary, "hash-object", "-w", "--stdin", input=receipt[2:])
2733
+ if blob.returncode != 0:
2734
+ raise IsolationError("runtime-hotfix could not record its receipt blob")
2735
+ created = run_git(
2736
+ primary, "update-ref", runtime_hotfix_ref(task_id, recorded_base),
2737
+ blob.stdout.strip(),
2738
+ )
2739
+ if created.returncode != 0:
2740
+ raise IsolationError("runtime-hotfix could not record its authorization ref")
2741
+ _append_task_log(primary, task_file, receipt)
2742
+ return {
2743
+ "task": task_id,
2744
+ "base": recorded_base,
2745
+ "old_runtime": old_digest,
2746
+ "new_runtime": new_digest,
2747
+ "receipt": receipt,
2748
+ "authorization_ref": runtime_hotfix_ref(task_id, recorded_base),
2749
+ }
2750
+
2751
+
2049
2752
  def deactivate_task(
2050
2753
  worktree: Path, task_id: str, task_branch: str
2051
2754
  ) -> Dict[str, object]:
@@ -2275,7 +2978,10 @@ def _stamp_and_commit(
2275
2978
  pending = uncommitted_paths(worktree)
2276
2979
  if stamped != task_bytes:
2277
2980
  pending.add(task_file)
2278
- validate_allowed_changes(worktree, base, pending, allowed, tolerated)
2981
+ # A runtime hotfix receipt for exactly this task and base admits the
2982
+ # runtime pin path into this landing — nothing else does (#480).
2983
+ hotfix_allowed = runtime_hotfix_allowed_paths(worktree, task_text, task_file, base)
2984
+ validate_allowed_changes(worktree, base, pending, allowed | hotfix_allowed, tolerated)
2279
2985
  original_head = current_sha(worktree)
2280
2986
  index_tree = git_output(worktree, "write-tree")
2281
2987
  try:
@@ -2348,6 +3054,11 @@ def land(
2348
3054
  allowed = {relative_posix(task_file)}
2349
3055
  for path in allow_paths:
2350
3056
  allowed.add(relative_posix(path))
3057
+ # A runtime-hotfix receipt for exactly this task and base admits the
3058
+ # runtime pin path into this landing — nothing else does (#480).
3059
+ task_text, _ = _read_task_text(primary / relative_posix(task_file))
3060
+ allowed |= runtime_hotfix_allowed_paths(
3061
+ primary, task_text, relative_posix(task_file), resolved_base)
2351
3062
  serial = source == primary
2352
3063
  if serial:
2353
3064
  if source_branch != bound:
@@ -2552,13 +3263,52 @@ def _declared_task_paths(
2552
3263
  return None, str(error)
2553
3264
 
2554
3265
 
3266
+ def _tree_blob_map(repo: Path, revision: str) -> Dict[str, tuple[str, str, str]]:
3267
+ """{path: (mode, type, oid)} for one commit's full tree."""
3268
+ mapping: Dict[str, tuple[str, str, str]] = {}
3269
+ for entry in git_output(
3270
+ repo, "ls-tree", "-r", "-z", revision
3271
+ ).split("\0"):
3272
+ if not entry:
3273
+ continue
3274
+ meta, separator, name = entry.partition("\t")
3275
+ if not separator:
3276
+ continue
3277
+ parts = meta.split()
3278
+ if len(parts) == 3:
3279
+ mapping[name] = (parts[0], parts[1], parts[2])
3280
+ return mapping
3281
+
3282
+
2555
3283
  def _regular_blob_oid(repo: Path, revision: str, path: str) -> str:
3284
+ # A full-sha revision addresses immutable content: the listing answers
3285
+ # once per run (#469). Moving names are never cached, but within one
3286
+ # read-only run HEAD is the entrypoint's recorded head, so resolve it.
3287
+ if revision == "HEAD":
3288
+ recorded = _RECOVERY_MEMO.get(("head-sha", str(repo)))
3289
+ if isinstance(recorded, str):
3290
+ # Every task compares against the same run head: one full listing
3291
+ # answers all of them (#469).
3292
+ map_key = ("head-tree", str(repo), recorded)
3293
+ if map_key not in _RECOVERY_MEMO:
3294
+ _RECOVERY_MEMO[map_key] = _tree_blob_map(repo, recorded)
3295
+ mapping = _RECOVERY_MEMO[map_key]
3296
+ assert isinstance(mapping, dict)
3297
+ entry = mapping.get(path)
3298
+ if entry is None or entry[1] != "blob" or entry[0] not in {"100644", "100755"}:
3299
+ raise IsolationError(f"task path is not a regular file in HEAD: {path}")
3300
+ return entry[2]
3301
+ key = ("blob-oid", str(repo), revision, path)
3302
+ if _FULL_SHA.fullmatch(revision) is not None and key in _RECOVERY_MEMO:
3303
+ return str(_RECOVERY_MEMO[key])
2556
3304
  entries = git_output(repo, "ls-tree", revision, "--", path).splitlines()
2557
3305
  if len(entries) != 1:
2558
3306
  raise IsolationError(f"task path is not a regular file in {revision}: {path}")
2559
3307
  parts = entries[0].split(maxsplit=3)
2560
3308
  if len(parts) != 4 or parts[0] not in {"100644", "100755"} or parts[1] != "blob":
2561
3309
  raise IsolationError(f"task path is not a regular file in {revision}: {path}")
3310
+ if _FULL_SHA.fullmatch(revision) is not None:
3311
+ _RECOVERY_MEMO[key] = parts[2]
2562
3312
  return parts[2]
2563
3313
 
2564
3314
 
@@ -2619,9 +3369,12 @@ def _landing_state(
2619
3369
  contract_paths,
2620
3370
  f"source commit subject is {subject!r}, expected {expected_subject!r}",
2621
3371
  )
2622
- if allowed is not None and allowed != contract_paths:
2623
- missing = sorted(contract_paths - allowed)
2624
- extra = sorted(allowed - contract_paths)
3372
+ # The runtime pin path joins only with a matching hotfix receipt (#480).
3373
+ receipt_allowed = runtime_hotfix_allowed_paths(repo, after_text, task_file, base)
3374
+ comparable = allowed - receipt_allowed if allowed is not None else None
3375
+ if comparable is not None and comparable != contract_paths:
3376
+ missing = sorted(contract_paths - comparable)
3377
+ extra = sorted(comparable - contract_paths)
2625
3378
  details = []
2626
3379
  if missing:
2627
3380
  details.append("missing " + ", ".join(missing))
@@ -2700,7 +3453,14 @@ def _prove_task_commit(
2700
3453
  )
2701
3454
  if task_file not in changed:
2702
3455
  return None, "commit does not touch the task file"
2703
- stray = undeclared_paths(repo, base, changed, contract_paths)
3456
+ committed_pin = None
3457
+ if RUNTIME_PIN in changed:
3458
+ shown = run_git(repo, "show", f"{sha}:{RUNTIME_PIN}")
3459
+ committed_pin = shown.stdout if shown.returncode == 0 else ""
3460
+ stray = undeclared_paths(
3461
+ repo, base, changed,
3462
+ contract_paths | runtime_hotfix_allowed_paths(
3463
+ repo, after_text, task_file, base, pin_text=committed_pin))
2704
3464
  if stray:
2705
3465
  return None, f"commit touches undeclared paths: {', '.join(stray)}"
2706
3466
  if landing_error:
@@ -2868,6 +3628,23 @@ def _worktree_report(
2868
3628
  return None, f"invalid sidecar worktree {path}: {error}"
2869
3629
 
2870
3630
 
3631
+ def _primary_worktree_report(
3632
+ primary: Path, bound: str, head: str
3633
+ ) -> tuple[Optional[Dict[str, object]], Optional[str]]:
3634
+ """The primary's own worktree report, answered once per recovery run.
3635
+
3636
+ Recovery is read-only and the run's head is fixed, so every serial task
3637
+ sees the same answer; the entrypoint-cleared memo scopes it to one run
3638
+ (#469).
3639
+ """
3640
+ key = ("primary-report", str(primary), bound, head)
3641
+ if key in _RECOVERY_MEMO:
3642
+ return _RECOVERY_MEMO[key] # type: ignore[return-value]
3643
+ report = _worktree_report(primary, primary, bound)
3644
+ _RECOVERY_MEMO[key] = report
3645
+ return report
3646
+
3647
+
2871
3648
  def _retained_task_contract_error(
2872
3649
  current_text: str, isolate_text: str
2873
3650
  ) -> Optional[str]:
@@ -2932,6 +3709,15 @@ def _serial_bookkeeping_error(primary: Path, base: str, head: str) -> Optional[s
2932
3709
  """Prove that serial task history advanced only through linear .project commits."""
2933
3710
  if base == head:
2934
3711
  return None
3712
+ key = ("serial-bookkeeping", str(primary), base, head)
3713
+ if key in _RECOVERY_MEMO:
3714
+ return _RECOVERY_MEMO[key] # type: ignore[return-value]
3715
+ error = _serial_bookkeeping_proof(primary, base, head)
3716
+ _RECOVERY_MEMO[key] = error
3717
+ return error
3718
+
3719
+
3720
+ def _serial_bookkeeping_proof(primary: Path, base: str, head: str) -> Optional[str]:
2935
3721
  if run_git(primary, "merge-base", "--is-ancestor", base, head).returncode != 0:
2936
3722
  return "recorded base is not an ancestor of the current branch"
2937
3723
  commits = git_output(
@@ -3104,6 +3890,18 @@ def _recover_task(
3104
3890
  return result("block", reason=str(error))
3105
3891
  except IsolationError as exc:
3106
3892
  return result("block", reason=str(exc))
3893
+ if status == "withdrawn":
3894
+ # A cited tombstone is inventory, not recoverable work: no isolate,
3895
+ # no landing, nothing to resume — coordinator or member alike (#483).
3896
+ # Without the owner citation the tombstone is an obligation someone
3897
+ # dropped: block it.
3898
+ if not withdrawn_citations(task_text):
3899
+ return result(
3900
+ "block",
3901
+ reason="withdrawn task lacks a `- Withdrawn — <YYYY-MM-DD>: "
3902
+ "<owner ruling verbatim>` Log citation",
3903
+ )
3904
+ return result("none", reason="withdrawn task is a tombstone")
3107
3905
  if fields.get("repo"):
3108
3906
  # Only the landing record proves member task completion.
3109
3907
  if status in ("pending", "in-progress"):
@@ -3132,7 +3930,7 @@ def _recover_task(
3132
3930
  return leftover(landed["commit"], landed["base"])
3133
3931
  return result("block", reason=worktree_error)
3134
3932
 
3135
- valid_statuses = {"pending", "in-progress", "done", "failed", "blocked"}
3933
+ valid_statuses = {"pending", "in-progress", "done", "failed", "blocked", "withdrawn"}
3136
3934
  if status not in valid_statuses:
3137
3935
  return result("block", reason=f"unknown task status: {status!r}")
3138
3936
 
@@ -3271,7 +4069,7 @@ def _recover_task(
3271
4069
  "block",
3272
4070
  reason=f"{status} serial task advanced past its base: {advance_error}",
3273
4071
  )
3274
- primary_worktree, primary_error = _worktree_report(primary, primary, bound)
4072
+ primary_worktree, primary_error = _primary_worktree_report(primary, bound, head)
3275
4073
  if primary_error:
3276
4074
  return result("block", reason=primary_error)
3277
4075
  report["worktree"] = primary_worktree
@@ -3327,11 +4125,14 @@ def _recover_task(
3327
4125
  "block",
3328
4126
  reason=f"{status} serial task advanced past its base: {advance_error}",
3329
4127
  )
3330
- primary_worktree, primary_error = _worktree_report(primary, primary, bound)
4128
+ primary_worktree, primary_error = _primary_worktree_report(primary, bound, head)
3331
4129
  if primary_error:
3332
4130
  return result("block", reason=primary_error)
3333
4131
  report["worktree"] = primary_worktree
3334
- return result("reconcile", base=base)
4132
+ return result(
4133
+ "reconcile", base=base,
4134
+ reason=f"{status} task needs recovery; run reconcile for its classification",
4135
+ )
3335
4136
 
3336
4137
  recorded_base = str(fields.get("base", ""))
3337
4138
  try:
@@ -3414,9 +4215,7 @@ def _recover_task(
3414
4215
  primary, recorded_base, task_file, task_text, subject
3415
4216
  )
3416
4217
  if retry_error is None:
3417
- primary_worktree, primary_error = _worktree_report(
3418
- primary, primary, bound
3419
- )
4218
+ primary_worktree, primary_error = _primary_worktree_report(primary, bound, head)
3420
4219
  if primary_error:
3421
4220
  return result("block", reason=primary_error)
3422
4221
  report["worktree"] = primary_worktree
@@ -3521,6 +4320,20 @@ def recover(primary: Path, tasks_dir: Path) -> Dict[str, object]:
3521
4320
  raise IsolationError(f"primary is not its Git root: {primary}")
3522
4321
  bound = require_attached(primary)
3523
4322
  head = current_sha(primary)
4323
+ _RECOVERY_MEMO.clear()
4324
+ _RECOVERY_MEMO[("head-sha", str(primary))] = head
4325
+ try:
4326
+ with worktree_paths.readonly_placement_cache():
4327
+ return _recover_all(primary, tasks_dir, bound, head)
4328
+ finally:
4329
+ # HEAD resumes its moving-name meaning the moment the read-only run
4330
+ # ends: landing flows in the same process must never resolve it to a
4331
+ # stale recorded head.
4332
+ _RECOVERY_MEMO.pop(("head-sha", str(primary)), None)
4333
+ _RECOVERY_MEMO.pop(("head-tree", str(primary), head), None)
4334
+
4335
+
4336
+ def _recover_all(primary: Path, tasks_dir: Path, bound: str, head: str) -> Dict[str, object]:
3524
4337
  task_paths, inventory_error = _recovery_task_inventory(primary, tasks_dir, head)
3525
4338
  if inventory_error:
3526
4339
  return {
@@ -3534,10 +4347,11 @@ def recover(primary: Path, tasks_dir: Path) -> Dict[str, object]:
3534
4347
  branches = set(
3535
4348
  git_output(primary, "for-each-ref", "--format=%(refname:short)", f"refs/heads/{TASK_BRANCH_PREFIX}").splitlines()
3536
4349
  )
3537
- tasks = [
3538
- _recover_task(primary, path, history, branches, head, bound)
3539
- for path in task_paths
3540
- ]
4350
+ tasks = []
4351
+ for number, path in enumerate(task_paths, start=1):
4352
+ if number % 20 == 0:
4353
+ print(f"recover: {number}/{len(task_paths)} tasks", file=sys.stderr, flush=True)
4354
+ tasks.append(_recover_task(primary, path, history, branches, head, bound))
3541
4355
  return {
3542
4356
  "bound_branch": bound,
3543
4357
  "tasks": tasks,
@@ -3558,7 +4372,6 @@ def verify_landed_task_files(
3558
4372
  raise IsolationError(f"primary is not its Git root: {primary}")
3559
4373
  bound = require_attached(primary)
3560
4374
  resolved_head = require_commit(primary, require_full_sha(head))
3561
- canonical_dir = relative_posix(canonical_tasks_dir)
3562
4375
  history = _first_parent_history(primary, resolved_head)
3563
4376
  branches = set(
3564
4377
  git_output(
@@ -3568,8 +4381,32 @@ def verify_landed_task_files(
3568
4381
  f"refs/heads/{TASK_BRANCH_PREFIX}",
3569
4382
  ).splitlines()
3570
4383
  )
4384
+ _RECOVERY_MEMO.clear()
4385
+ _RECOVERY_MEMO[("head-sha", str(primary))] = resolved_head
4386
+ try:
4387
+ with worktree_paths.readonly_placement_cache():
4388
+ return _verify_landed_all(
4389
+ primary, list(task_paths), relative_posix(canonical_tasks_dir),
4390
+ resolved_head, history, branches, bound,
4391
+ )
4392
+ finally:
4393
+ _RECOVERY_MEMO.pop(("head-sha", str(primary)), None)
4394
+ _RECOVERY_MEMO.pop(("head-tree", str(primary), resolved_head), None)
4395
+
4396
+
4397
+ def _verify_landed_all(
4398
+ primary: Path,
4399
+ task_paths: Sequence[Path],
4400
+ canonical_dir: str,
4401
+ resolved_head: str,
4402
+ history: Dict[str, list[tuple[str, str]]],
4403
+ branches: Set[str],
4404
+ bound: str,
4405
+ ) -> Dict[str, object]:
3571
4406
  tasks = []
3572
- for path in task_paths:
4407
+ for number, path in enumerate(task_paths, start=1):
4408
+ if number % 20 == 0:
4409
+ print(f"recover: {number}/{len(task_paths)} tasks", file=sys.stderr, flush=True)
3573
4410
  resolved = path.resolve()
3574
4411
  if path.is_symlink() or not path.is_file():
3575
4412
  raise IsolationError(f"task artifact is missing or unsafe: {path}")
@@ -3582,6 +4419,22 @@ def verify_landed_task_files(
3582
4419
  fields, error = task_frontmatter(task_text)
3583
4420
  if error or fields is None:
3584
4421
  raise IsolationError(error or f"unreadable task frontmatter: {path.name}")
4422
+ if fields.get("status") == "withdrawn":
4423
+ # A cited tombstone is shippable inventory with no landing to
4424
+ # prove; an uncited one is an obligation someone dropped (#483).
4425
+ if not withdrawn_citations(task_text):
4426
+ raise IsolationError(
4427
+ f"{fields.get('id', path.name)}: withdrawn task needs a "
4428
+ "`- Withdrawn — <YYYY-MM-DD>: <owner ruling verbatim>` Log citation"
4429
+ )
4430
+ tasks.append({
4431
+ "task": canonical,
4432
+ "task_id": str(fields.get("id", "")),
4433
+ "status": "withdrawn",
4434
+ "verdict": "withdrawn",
4435
+ "reason": "withdrawn task is a tombstone",
4436
+ })
4437
+ continue
3585
4438
  if fields.get("status") != "done":
3586
4439
  raise IsolationError(
3587
4440
  f"{fields.get('id', path.name)}: task must have status: done"
@@ -3597,7 +4450,10 @@ def verify_landed_task_files(
3597
4450
  canonical,
3598
4451
  )
3599
4452
  )
3600
- blocked = [task for task in tasks if task["verdict"] not in LANDED_VERDICTS]
4453
+ blocked = [
4454
+ task for task in tasks
4455
+ if task["verdict"] not in LANDED_VERDICTS and task["verdict"] != "withdrawn"
4456
+ ]
3601
4457
  if blocked:
3602
4458
  def failure_reason(task: Dict[str, object]) -> str:
3603
4459
  rejected = task.get("rejected")
@@ -4502,6 +5358,64 @@ def attest(
4502
5358
  }
4503
5359
 
4504
5360
 
5361
+ def _committed_ship_active(repo: Path) -> bool:
5362
+ """True when the STATE.md committed at HEAD is ship/active."""
5363
+
5364
+ result = run_git(repo, "show", "HEAD:.project/STATE.md")
5365
+ if result.returncode != 0:
5366
+ return False
5367
+ frontmatter = result.stdout.split("\n---", 1)[0]
5368
+ fields = {
5369
+ key: _unquote(value)
5370
+ for key, value in re.findall(r"(?m)^(phase|status):(.*)$", frontmatter)
5371
+ }
5372
+ return fields.get("phase") == "ship" and fields.get("status") == "active"
5373
+
5374
+
5375
+ # The STATE Log tail that sanctions a build checkpoint past a HEAD that is
5376
+ # still ship/active: the blocked final review went through the patch plan
5377
+ # (reopened, then approved) and build started again (#526).
5378
+ _PATCH_REENTRY_LOG = (
5379
+ ("plan", "patch plan reopened"),
5380
+ ("plan", "patch plan approved"),
5381
+ ("build", "build started"),
5382
+ )
5383
+
5384
+
5385
+ def _patch_reentry_build_active(repo: Path) -> bool:
5386
+ """True when the worktree STATE is the sanctioned patch-re-entry build.
5387
+
5388
+ A blocked final review leaves HEAD at `build: complete milestone`
5389
+ (ship/active) on purpose: the patch plan runs uncommitted and the patch
5390
+ build's own checkpoint commits the artifacts (SHIP.md). This reads the
5391
+ worktree STATE the checkpoint is about to commit and accepts only the
5392
+ documented sequence — a hand-flipped build/active during final review
5393
+ carries no such Log tail and stays refused (#429, #526).
5394
+ """
5395
+
5396
+ state = repo / ".project" / "STATE.md"
5397
+ try:
5398
+ text = state.read_text(encoding="utf-8")
5399
+ except (OSError, UnicodeDecodeError):
5400
+ return False
5401
+ frontmatter = text.split("\n---", 1)[0]
5402
+ fields = {
5403
+ key: _unquote(value)
5404
+ for key, value in re.findall(r"(?m)^(phase|status):(.*)$", frontmatter)
5405
+ }
5406
+ if not (fields.get("phase") == "build" and fields.get("status") == "active"):
5407
+ return False
5408
+ events: List[Tuple[str, str]] = []
5409
+ for line in text.splitlines():
5410
+ match = re.fullmatch(r"- \d{4}-\d{2}-\d{2} — (\S+) — (.+)", line)
5411
+ if match:
5412
+ events.append((match.group(1), match.group(2)))
5413
+ # The sanctioned sequence appears in order, as the Log tail of this
5414
+ # milestone's patch loop; the final event must be the build restart.
5415
+ last = len(_PATCH_REENTRY_LOG)
5416
+ return len(events) >= last and tuple(events[-last:]) == _PATCH_REENTRY_LOG
5417
+
5418
+
4505
5419
  def checkpoint(
4506
5420
  repo: Path,
4507
5421
  expected_head: str,
@@ -4551,6 +5465,16 @@ def checkpoint(
4551
5465
  "subject": subject.strip(),
4552
5466
  }
4553
5467
  raise IsolationError("primary HEAD differs from --expected-head")
5468
+ if (
5469
+ subject.strip().startswith("build:")
5470
+ and _committed_ship_active(repo)
5471
+ and not _patch_reentry_build_active(repo)
5472
+ ):
5473
+ raise IsolationError(
5474
+ "STATE is ship/active: final review creates no pre-ship commit; leave its "
5475
+ "outputs uncommitted for archive_milestone.py, and send findings through "
5476
+ "the patch plan (see SHIP.md)"
5477
+ )
4554
5478
  pending = uncommitted_paths(repo)
4555
5479
  forced: Set[str] = set()
4556
5480
  # Build only: an approval undo resets to a parent without the ledger and would drop it.
@@ -4580,10 +5504,18 @@ def checkpoint(
4580
5504
  )
4581
5505
  if unexpected:
4582
5506
  raise IsolationError("unexpected paths: " + ", ".join(unexpected))
5507
+ # An unsupported entry violates only if it will exist after this
5508
+ # checkpoint commits: _commit_pending stages exactly the pending paths
5509
+ # from the worktree, so a staged or completed removal (git rm / git mv)
5510
+ # passes and the removal lands here instead of forcing a plain commit
5511
+ # (#466). A kept worktree file (rm --cached, untracked junk) would be
5512
+ # re-staged and still blocks.
4583
5513
  unsupported = sorted({
4584
5514
  path.split("/")[1]
4585
5515
  for path in pending
4586
- if path.startswith(".project/") and path.split("/")[1] not in PROJECT_ENTRIES
5516
+ if path.startswith(".project/")
5517
+ and path.split("/")[1] not in PROJECT_ENTRIES
5518
+ and ((repo / path).exists() or (repo / path).is_symlink())
4587
5519
  })
4588
5520
  if unsupported:
4589
5521
  raise IsolationError(
@@ -4915,9 +5847,16 @@ def parser() -> argparse.ArgumentParser:
4915
5847
  activate_member_parser.add_argument("--repo", type=Path, required=True, help="coordinator root")
4916
5848
  activate_member_parser.add_argument("--member", required=True)
4917
5849
  activate_member_parser.add_argument("--task-id", required=True)
4918
- activate_member_parser.add_argument("--agent", required=True)
4919
- activate_member_parser.add_argument("--task-file", required=True)
4920
- activate_member_parser.add_argument("--base", required=True)
5850
+ activate_member_parser.add_argument("--agent")
5851
+ activate_member_parser.add_argument("--task-file")
5852
+ activate_member_parser.add_argument("--base")
5853
+ activate_member_parser.add_argument(
5854
+ "--reauthorize", action="store_true",
5855
+ help="re-record the authorization a failed finish cleared, for the same "
5856
+ "live copy at the same member base, without re-isolating (#461); "
5857
+ "--task-file/--base come from the recorded activation, which the "
5858
+ "live copy must match"
5859
+ )
4921
5860
 
4922
5861
  retire_member_parser = subparsers.add_parser(
4923
5862
  "retire-member-task", help="remove a member task sidecar and branch before a retry"
@@ -4929,12 +5868,31 @@ def parser() -> argparse.ArgumentParser:
4929
5868
  help="discard a rejected attempt whose Log the coordinator task holds")
4930
5869
  retire_member_parser.add_argument("--task-file")
4931
5870
 
5871
+ runtime_hotfix_parser = subparsers.add_parser(
5872
+ "runtime-hotfix",
5873
+ help="authorize a runtime hotfix for one active task and re-verify it (#480)",
5874
+ )
5875
+ runtime_hotfix_parser.add_argument("--repo", type=Path, required=True)
5876
+ runtime_hotfix_parser.add_argument("--task-id", required=True)
5877
+ runtime_hotfix_parser.add_argument(
5878
+ "--runtime", required=True,
5879
+ help="64-hex runtime digest printed by runtime_store.py publish, validated before recording",
5880
+ )
5881
+ runtime_hotfix_parser.add_argument(
5882
+ "--authorized-by", required=True,
5883
+ help="the authorizing ruling, recorded verbatim in the task Log receipt",
5884
+ )
4932
5885
  deactivate_task_parser = subparsers.add_parser(
4933
- "deactivate-task", help="revoke one helper-owned task dispatch"
5886
+ "deactivate-task",
5887
+ help="revoke one helper-owned task dispatch (parallel tasks only: a serial "
5888
+ "task has no task branch and no authorization to deactivate)",
4934
5889
  )
4935
5890
  deactivate_task_parser.add_argument("--repo", type=Path, required=True)
4936
5891
  deactivate_task_parser.add_argument("--task-id", required=True)
4937
- deactivate_task_parser.add_argument("--task-branch", required=True)
5892
+ deactivate_task_parser.add_argument(
5893
+ "--task-branch", required=True,
5894
+ help="the parallel isolate's branch gsd-path-task/<id>",
5895
+ )
4938
5896
 
4939
5897
  isolate_verify_parser = subparsers.add_parser(
4940
5898
  "isolate-verify", help="create a named verify sidecar"
@@ -5011,8 +5969,18 @@ def parser() -> argparse.ArgumentParser:
5011
5969
 
5012
5970
 
5013
5971
  def main(argv: Optional[Sequence[str]] = None) -> int:
5014
- arguments = parser().parse_args(argv)
5972
+ argument_parser = parser()
5973
+ arguments = argument_parser.parse_args(argv)
5015
5974
  try:
5975
+ if arguments.command == "activate-member-task" and (
5976
+ (arguments.reauthorize and (arguments.agent or arguments.task_file or arguments.base))
5977
+ or (not arguments.reauthorize
5978
+ and not (arguments.agent and arguments.task_file and arguments.base))
5979
+ ):
5980
+ argument_parser.error(
5981
+ "activate-member-task requires --agent, --task-file, and --base, "
5982
+ "or --reauthorize alone (which uses the recorded activation)"
5983
+ )
5016
5984
  if arguments.command == "isolate-task":
5017
5985
  result = isolate_task(
5018
5986
  arguments.repo,
@@ -5032,14 +6000,24 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
5032
6000
  elif arguments.command == "isolate-member-task":
5033
6001
  result = isolate_member_task(arguments.repo, arguments.member, arguments.task_id)
5034
6002
  elif arguments.command == "activate-member-task":
5035
- result = activate_member_task(arguments.repo, arguments.member, arguments.task_id,
5036
- arguments.agent, arguments.task_file, arguments.base)
6003
+ if arguments.reauthorize:
6004
+ result = reauthorize_member_task(arguments.repo, arguments.member, arguments.task_id)
6005
+ else:
6006
+ result = activate_member_task(arguments.repo, arguments.member, arguments.task_id,
6007
+ arguments.agent, arguments.task_file, arguments.base)
5037
6008
  elif arguments.command == "retire-member-task":
5038
6009
  copy_rejected = retire_member_task(arguments.repo, arguments.member, arguments.task_id,
5039
6010
  arguments.force, arguments.task_file)
5040
6011
  result = {"member": arguments.member, "retired": True, "task_id": arguments.task_id}
5041
6012
  if copy_rejected:
5042
6013
  result["copy_rejected"] = copy_rejected
6014
+ elif arguments.command == "runtime-hotfix":
6015
+ result = runtime_hotfix(
6016
+ arguments.repo,
6017
+ arguments.task_id,
6018
+ arguments.runtime,
6019
+ arguments.authorized_by,
6020
+ )
5043
6021
  elif arguments.command == "deactivate-task":
5044
6022
  result = deactivate_task(
5045
6023
  arguments.repo,