dorfl 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/dist/advance-drivers.d.ts +1 -1
  2. package/dist/advance-drivers.d.ts.map +1 -1
  3. package/dist/advance-drivers.js +15 -3
  4. package/dist/advance-drivers.js.map +1 -1
  5. package/dist/advance-lifecycle-template.d.ts.map +1 -1
  6. package/dist/advance-lifecycle-template.js +76 -4
  7. package/dist/advance-lifecycle-template.js.map +1 -1
  8. package/dist/advance-treeless-publish.d.ts +23 -0
  9. package/dist/advance-treeless-publish.d.ts.map +1 -1
  10. package/dist/advance-treeless-publish.js +41 -0
  11. package/dist/advance-treeless-publish.js.map +1 -1
  12. package/dist/advance.d.ts +59 -7
  13. package/dist/advance.d.ts.map +1 -1
  14. package/dist/advance.js +389 -82
  15. package/dist/advance.js.map +1 -1
  16. package/dist/advancing-lock.d.ts +30 -2
  17. package/dist/advancing-lock.d.ts.map +1 -1
  18. package/dist/advancing-lock.js +50 -0
  19. package/dist/advancing-lock.js.map +1 -1
  20. package/dist/agent-launch.d.ts +12 -0
  21. package/dist/agent-launch.d.ts.map +1 -1
  22. package/dist/agent-launch.js +22 -12
  23. package/dist/agent-launch.js.map +1 -1
  24. package/dist/agent-stop.d.ts +40 -2
  25. package/dist/agent-stop.d.ts.map +1 -1
  26. package/dist/agent-stop.js +30 -2
  27. package/dist/agent-stop.js.map +1 -1
  28. package/dist/apply-decide.d.ts +17 -3
  29. package/dist/apply-decide.d.ts.map +1 -1
  30. package/dist/apply-decide.js +36 -7
  31. package/dist/apply-decide.js.map +1 -1
  32. package/dist/apply-merge-action.d.ts +17 -5
  33. package/dist/apply-merge-action.d.ts.map +1 -1
  34. package/dist/apply-merge-action.js +43 -8
  35. package/dist/apply-merge-action.js.map +1 -1
  36. package/dist/apply-persist.d.ts +57 -26
  37. package/dist/apply-persist.d.ts.map +1 -1
  38. package/dist/apply-persist.js +166 -37
  39. package/dist/apply-persist.js.map +1 -1
  40. package/dist/apply-stuck-action.d.ts +151 -0
  41. package/dist/apply-stuck-action.d.ts.map +1 -0
  42. package/dist/apply-stuck-action.js +125 -0
  43. package/dist/apply-stuck-action.js.map +1 -0
  44. package/dist/brand.d.ts +12 -1
  45. package/dist/brand.d.ts.map +1 -1
  46. package/dist/brand.js +2 -1
  47. package/dist/brand.js.map +1 -1
  48. package/dist/cli-spinner.d.ts +1 -1
  49. package/dist/cli-spinner.js +1 -1
  50. package/dist/cli.d.ts +10 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +320 -63
  53. package/dist/cli.js.map +1 -1
  54. package/dist/close-job-template.js +1 -1
  55. package/dist/complete.d.ts +6 -3
  56. package/dist/complete.d.ts.map +1 -1
  57. package/dist/complete.js +91 -37
  58. package/dist/complete.js.map +1 -1
  59. package/dist/config.d.ts +55 -9
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/config.js +56 -4
  62. package/dist/config.js.map +1 -1
  63. package/dist/continue-branch.d.ts.map +1 -1
  64. package/dist/continue-branch.js +22 -0
  65. package/dist/continue-branch.js.map +1 -1
  66. package/dist/decision-engine.d.ts +38 -10
  67. package/dist/decision-engine.d.ts.map +1 -1
  68. package/dist/decision-engine.js +10 -6
  69. package/dist/decision-engine.js.map +1 -1
  70. package/dist/do-autopick.d.ts.map +1 -1
  71. package/dist/do-autopick.js +11 -1
  72. package/dist/do-autopick.js.map +1 -1
  73. package/dist/do-config.d.ts +1 -1
  74. package/dist/do.d.ts +49 -56
  75. package/dist/do.d.ts.map +1 -1
  76. package/dist/do.js +419 -30
  77. package/dist/do.js.map +1 -1
  78. package/dist/env-config.d.ts.map +1 -1
  79. package/dist/env-config.js +12 -4
  80. package/dist/env-config.js.map +1 -1
  81. package/dist/failure-cause.d.ts +3 -2
  82. package/dist/failure-cause.d.ts.map +1 -1
  83. package/dist/failure-cause.js +28 -1
  84. package/dist/failure-cause.js.map +1 -1
  85. package/dist/format.d.ts +6 -6
  86. package/dist/format.d.ts.map +1 -1
  87. package/dist/format.js +10 -27
  88. package/dist/format.js.map +1 -1
  89. package/dist/frontmatter.d.ts +16 -4
  90. package/dist/frontmatter.d.ts.map +1 -1
  91. package/dist/frontmatter.js +27 -1
  92. package/dist/frontmatter.js.map +1 -1
  93. package/dist/gc.d.ts +65 -6
  94. package/dist/gc.d.ts.map +1 -1
  95. package/dist/gc.js +126 -9
  96. package/dist/gc.js.map +1 -1
  97. package/dist/github.d.ts +14 -0
  98. package/dist/github.d.ts.map +1 -1
  99. package/dist/github.js +73 -0
  100. package/dist/github.js.map +1 -1
  101. package/dist/harness.d.ts +25 -0
  102. package/dist/harness.d.ts.map +1 -1
  103. package/dist/harness.js.map +1 -1
  104. package/dist/index.d.ts +4 -1
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +2 -1
  107. package/dist/index.js.map +1 -1
  108. package/dist/install-ci-branch-protection.d.ts +132 -38
  109. package/dist/install-ci-branch-protection.d.ts.map +1 -1
  110. package/dist/install-ci-branch-protection.js +190 -48
  111. package/dist/install-ci-branch-protection.js.map +1 -1
  112. package/dist/install-ci-core.d.ts +21 -1
  113. package/dist/install-ci-core.d.ts.map +1 -1
  114. package/dist/install-ci-core.js +10 -3
  115. package/dist/install-ci-core.js.map +1 -1
  116. package/dist/install-ci-github.d.ts +32 -0
  117. package/dist/install-ci-github.d.ts.map +1 -1
  118. package/dist/install-ci-github.js +73 -0
  119. package/dist/install-ci-github.js.map +1 -1
  120. package/dist/install-skills.d.ts +123 -0
  121. package/dist/install-skills.d.ts.map +1 -0
  122. package/dist/install-skills.js +100 -0
  123. package/dist/install-skills.js.map +1 -0
  124. package/dist/intake-trigger-template.js +1 -1
  125. package/dist/intake.d.ts +17 -4
  126. package/dist/intake.d.ts.map +1 -1
  127. package/dist/intake.js +23 -7
  128. package/dist/intake.js.map +1 -1
  129. package/dist/integration-core.d.ts +10 -7
  130. package/dist/integration-core.d.ts.map +1 -1
  131. package/dist/integration-core.js +174 -60
  132. package/dist/integration-core.js.map +1 -1
  133. package/dist/integrator.d.ts +1 -1
  134. package/dist/integrator.d.ts.map +1 -1
  135. package/dist/integrator.js +19 -3
  136. package/dist/integrator.js.map +1 -1
  137. package/dist/isolation.d.ts +2 -2
  138. package/dist/isolation.d.ts.map +1 -1
  139. package/dist/isolation.js +9 -0
  140. package/dist/isolation.js.map +1 -1
  141. package/dist/item-lock.d.ts +196 -129
  142. package/dist/item-lock.d.ts.map +1 -1
  143. package/dist/item-lock.js +329 -254
  144. package/dist/item-lock.js.map +1 -1
  145. package/dist/ledger-write.d.ts +31 -26
  146. package/dist/ledger-write.d.ts.map +1 -1
  147. package/dist/ledger-write.js +100 -122
  148. package/dist/ledger-write.js.map +1 -1
  149. package/dist/lifecycle-gather.d.ts +17 -0
  150. package/dist/lifecycle-gather.d.ts.map +1 -1
  151. package/dist/lifecycle-gather.js +7 -1
  152. package/dist/lifecycle-gather.js.map +1 -1
  153. package/dist/lifecycle-pools.d.ts +40 -3
  154. package/dist/lifecycle-pools.d.ts.map +1 -1
  155. package/dist/lifecycle-pools.js +26 -6
  156. package/dist/lifecycle-pools.js.map +1 -1
  157. package/dist/merge-question-surfacer.d.ts +15 -1
  158. package/dist/merge-question-surfacer.d.ts.map +1 -1
  159. package/dist/merge-question-surfacer.js +18 -3
  160. package/dist/merge-question-surfacer.js.map +1 -1
  161. package/dist/migrate-stuck-locks.d.ts +129 -0
  162. package/dist/migrate-stuck-locks.d.ts.map +1 -0
  163. package/dist/migrate-stuck-locks.js +355 -0
  164. package/dist/migrate-stuck-locks.js.map +1 -0
  165. package/dist/needs-attention.d.ts +293 -16
  166. package/dist/needs-attention.d.ts.map +1 -1
  167. package/dist/needs-attention.js +532 -57
  168. package/dist/needs-attention.js.map +1 -1
  169. package/dist/orphan-sidecar.d.ts +8 -4
  170. package/dist/orphan-sidecar.d.ts.map +1 -1
  171. package/dist/orphan-sidecar.js +35 -2
  172. package/dist/orphan-sidecar.js.map +1 -1
  173. package/dist/pi-harness.d.ts +16 -0
  174. package/dist/pi-harness.d.ts.map +1 -1
  175. package/dist/pi-harness.js +82 -2
  176. package/dist/pi-harness.js.map +1 -1
  177. package/dist/placement.d.ts +3 -3
  178. package/dist/placement.js +1 -1
  179. package/dist/prd-to-spec.d.ts.map +1 -1
  180. package/dist/prd-to-spec.js +9 -5
  181. package/dist/prd-to-spec.js.map +1 -1
  182. package/dist/protocol/CLAIM-PROTOCOL.md +17 -10
  183. package/dist/protocol/REVIEW-PROTOCOL.md +4 -1
  184. package/dist/protocol/SURFACE-PROTOCOL.md +16 -2
  185. package/dist/protocol/TASKING-PROTOCOL.md +3 -1
  186. package/dist/protocol/WORK-CONTRACT.md +22 -18
  187. package/dist/protocol/task-template.md +1 -1
  188. package/dist/reap-branches.d.ts +12 -9
  189. package/dist/reap-branches.d.ts.map +1 -1
  190. package/dist/reap-branches.js +25 -7
  191. package/dist/reap-branches.js.map +1 -1
  192. package/dist/recover-isolated.d.ts +12 -0
  193. package/dist/recover-isolated.d.ts.map +1 -1
  194. package/dist/recover-isolated.js +5 -0
  195. package/dist/recover-isolated.js.map +1 -1
  196. package/dist/repo-config.d.ts +23 -2
  197. package/dist/repo-config.d.ts.map +1 -1
  198. package/dist/repo-config.js +53 -7
  199. package/dist/repo-config.js.map +1 -1
  200. package/dist/repo-mirror.d.ts.map +1 -1
  201. package/dist/repo-mirror.js +18 -2
  202. package/dist/repo-mirror.js.map +1 -1
  203. package/dist/review-verdict.js +1 -1
  204. package/dist/review-verdict.js.map +1 -1
  205. package/dist/run.d.ts +1 -1
  206. package/dist/run.d.ts.map +1 -1
  207. package/dist/run.js +54 -19
  208. package/dist/run.js.map +1 -1
  209. package/dist/scan.d.ts +17 -8
  210. package/dist/scan.d.ts.map +1 -1
  211. package/dist/scan.js +22 -8
  212. package/dist/scan.js.map +1 -1
  213. package/dist/select-priority.d.ts +2 -2
  214. package/dist/select-priority.js +1 -1
  215. package/dist/select-priority.js.map +1 -1
  216. package/dist/sidecar-apply.js +1 -1
  217. package/dist/sidecar-apply.js.map +1 -1
  218. package/dist/sidecar.d.ts +45 -10
  219. package/dist/sidecar.d.ts.map +1 -1
  220. package/dist/sidecar.js +82 -2
  221. package/dist/sidecar.js.map +1 -1
  222. package/dist/skills/answer-questions/SKILL.md +89 -0
  223. package/dist/skills/capture-signal/SKILL.md +52 -0
  224. package/dist/skills/convert-from-prd-to-spec/SKILL.md +90 -0
  225. package/dist/skills/drive-tasks/SKILL.md +218 -0
  226. package/dist/skills/from-idea/SKILL.md +83 -0
  227. package/dist/skills/merge-prs/SKILL.md +70 -0
  228. package/dist/skills/orchestrate/SKILL.md +101 -0
  229. package/dist/skills/promote/SKILL.md +35 -0
  230. package/dist/skills/review/SKILL.md +16 -0
  231. package/dist/skills/setup/SKILL.md +258 -0
  232. package/dist/skills/setup/protocol/ADR-FORMAT.md +47 -0
  233. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +224 -0
  234. package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +122 -0
  235. package/dist/skills/setup/protocol/SURFACE-PROTOCOL.md +135 -0
  236. package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +124 -0
  237. package/dist/skills/setup/protocol/WORK-CONTRACT.md +280 -0
  238. package/dist/skills/setup/protocol/spec-template.md +71 -0
  239. package/dist/skills/setup/protocol/task-template.md +65 -0
  240. package/dist/skills/surface-questions/SKILL.md +16 -0
  241. package/dist/skills/to-spec/SKILL.md +34 -0
  242. package/dist/skills/to-task/SKILL.md +19 -0
  243. package/dist/skills/triage-observations/SKILL.md +78 -0
  244. package/dist/skills/work/SKILL.md +51 -0
  245. package/dist/slug-namespace.d.ts +5 -5
  246. package/dist/slug-namespace.js +5 -5
  247. package/dist/slug-namespace.js.map +1 -1
  248. package/dist/spec-complete.d.ts.map +1 -1
  249. package/dist/spec-complete.js +2 -3
  250. package/dist/spec-complete.js.map +1 -1
  251. package/dist/start.d.ts.map +1 -1
  252. package/dist/start.js +51 -57
  253. package/dist/start.js.map +1 -1
  254. package/dist/surface-gate.d.ts.map +1 -1
  255. package/dist/surface-gate.js +10 -3
  256. package/dist/surface-gate.js.map +1 -1
  257. package/dist/surface-persist.js +1 -1
  258. package/dist/surface-persist.js.map +1 -1
  259. package/dist/tasker-review-loop.d.ts +4 -3
  260. package/dist/tasker-review-loop.d.ts.map +1 -1
  261. package/dist/tasker-review-loop.js.map +1 -1
  262. package/dist/tasking-lock.d.ts +2 -2
  263. package/dist/tasking-lock.d.ts.map +1 -1
  264. package/dist/tasking-lock.js +53 -32
  265. package/dist/tasking-lock.js.map +1 -1
  266. package/dist/tasking.d.ts +40 -6
  267. package/dist/tasking.d.ts.map +1 -1
  268. package/dist/tasking.js +224 -12
  269. package/dist/tasking.js.map +1 -1
  270. package/dist/triage-persist.d.ts +10 -6
  271. package/dist/triage-persist.d.ts.map +1 -1
  272. package/dist/triage-persist.js +34 -4
  273. package/dist/triage-persist.js.map +1 -1
  274. package/dist/vendor/incur/agents.d.ts +58 -0
  275. package/dist/vendor/incur/agents.d.ts.map +1 -0
  276. package/dist/vendor/incur/agents.js +343 -0
  277. package/dist/vendor/incur/agents.js.map +1 -0
  278. package/dist/verify-workflow-template.js +1 -1
  279. package/dist/watch-session.d.ts +11 -3
  280. package/dist/watch-session.d.ts.map +1 -1
  281. package/dist/watch-session.js +94 -7
  282. package/dist/watch-session.js.map +1 -1
  283. package/dist/work-layout.d.ts +7 -3
  284. package/dist/work-layout.d.ts.map +1 -1
  285. package/dist/work-layout.js +6 -3
  286. package/dist/work-layout.js.map +1 -1
  287. package/dist/workspace.d.ts +1 -1
  288. package/package.json +2 -2
  289. package/src/advance-drivers.ts +16 -3
  290. package/src/advance-lifecycle-template.ts +89 -4
  291. package/src/advance-treeless-publish.ts +45 -0
  292. package/src/advance.ts +467 -87
  293. package/src/advancing-lock.ts +96 -1
  294. package/src/agent-launch.ts +37 -12
  295. package/src/agent-stop.ts +60 -2
  296. package/src/apply-decide.ts +36 -7
  297. package/src/apply-merge-action.ts +45 -7
  298. package/src/apply-persist.ts +228 -54
  299. package/src/apply-stuck-action.ts +260 -0
  300. package/src/brand.ts +14 -2
  301. package/src/cli-spinner.ts +1 -1
  302. package/src/cli.ts +401 -70
  303. package/src/close-job-template.ts +1 -1
  304. package/src/complete.ts +110 -52
  305. package/src/config.ts +107 -13
  306. package/src/continue-branch.ts +22 -0
  307. package/src/decision-engine.ts +55 -17
  308. package/src/do-autopick.ts +12 -1
  309. package/src/do-config.ts +1 -1
  310. package/src/do.ts +511 -38
  311. package/src/env-config.ts +12 -4
  312. package/src/failure-cause.ts +30 -1
  313. package/src/format.ts +10 -30
  314. package/src/frontmatter.ts +35 -5
  315. package/src/gc.ts +172 -9
  316. package/src/github.ts +78 -0
  317. package/src/harness.ts +25 -0
  318. package/src/index.ts +10 -0
  319. package/src/install-ci-branch-protection.ts +282 -57
  320. package/src/install-ci-core.ts +30 -3
  321. package/src/install-ci-github.ts +86 -0
  322. package/src/install-skills.ts +166 -0
  323. package/src/intake-trigger-template.ts +1 -1
  324. package/src/intake.ts +46 -11
  325. package/src/integration-core.ts +199 -65
  326. package/src/integrator.ts +22 -9
  327. package/src/isolation.ts +11 -2
  328. package/src/item-lock.ts +485 -341
  329. package/src/ledger-write.ts +135 -150
  330. package/src/lifecycle-gather.ts +29 -6
  331. package/src/lifecycle-pools.ts +67 -9
  332. package/src/merge-question-surfacer.ts +28 -4
  333. package/src/migrate-stuck-locks.ts +451 -0
  334. package/src/needs-attention.ts +857 -84
  335. package/src/orphan-sidecar.ts +47 -6
  336. package/src/pi-harness.ts +82 -2
  337. package/src/placement.ts +3 -3
  338. package/src/prd-to-spec.ts +13 -5
  339. package/src/reap-branches.ts +38 -14
  340. package/src/recover-isolated.ts +17 -0
  341. package/src/repo-config.ts +55 -6
  342. package/src/repo-mirror.ts +23 -1
  343. package/src/review-verdict.ts +1 -1
  344. package/src/run.ts +64 -18
  345. package/src/scan.ts +22 -8
  346. package/src/select-priority.ts +3 -3
  347. package/src/sidecar-apply.ts +1 -1
  348. package/src/sidecar.ts +138 -11
  349. package/src/slug-namespace.ts +7 -7
  350. package/src/spec-complete.ts +2 -3
  351. package/src/start.ts +53 -59
  352. package/src/surface-gate.ts +10 -3
  353. package/src/surface-persist.ts +1 -1
  354. package/src/tasker-review-loop.ts +4 -3
  355. package/src/tasking-lock.ts +52 -36
  356. package/src/tasking.ts +261 -17
  357. package/src/triage-persist.ts +56 -12
  358. package/src/vendor/incur/LICENSE +21 -0
  359. package/src/vendor/incur/README.md +19 -0
  360. package/src/vendor/incur/agents.ts +392 -0
  361. package/src/verify-workflow-template.ts +1 -1
  362. package/src/watch-session.ts +102 -7
  363. package/src/work-layout.ts +6 -3
  364. package/src/workspace.ts +1 -1
package/src/item-lock.ts CHANGED
@@ -1,6 +1,11 @@
1
1
  import {randomUUID} from 'node:crypto';
2
2
  import {runAsync, type RunResult} from './git.js';
3
- import {resolveSidecarIdentity, type SidecarType} from './sidecar.js';
3
+ import {
4
+ resolveSidecarIdentity,
5
+ sidecarPathFor,
6
+ type SidecarType,
7
+ } from './sidecar.js';
8
+ import {parseFrontmatter} from './frontmatter.js';
4
9
  import {workItemRel} from './work-layout.js';
5
10
 
6
11
  /**
@@ -77,28 +82,45 @@ export function itemLockRef(entry: string): string {
77
82
  return `${LOCK_REF_PREFIX}/${entry}`;
78
83
  }
79
84
 
85
+ /**
86
+ * The character class a LITERAL lock-entry name must match to be addressable
87
+ * through `release-lock --entry <literal>` (task
88
+ * `release-lock-entry-escape-hatch-and-literal-entry-reporting`): a NON-EMPTY run
89
+ * of `[A-Za-z0-9._-]` — the SAME shape the minting side produces (`<type>-<slug>`,
90
+ * and the pre-cutover `slice-<slug>` / `prd-<slug>` entries it must be able to
91
+ * name). It deliberately EXCLUDES `/` and whitespace so a literal can never escape
92
+ * the `refs/dorfl/lock/` namespace (a `/` would address a different ref path; a
93
+ * space would break the push refspec). This is the ONLY validation the `--entry`
94
+ * escape hatch performs before the git operation — the entry name is taken
95
+ * literally otherwise, bypassing the namespace mapping the item-form path uses.
96
+ */
97
+ export function isValidLockEntryName(entry: string): boolean {
98
+ return /^[A-Za-z0-9._-]+$/.test(entry);
99
+ }
100
+
80
101
  /** WHAT holds the lock — the three mutually-exclusive actions over one item. */
81
102
  export type LockAction = 'implement' | 'task' | 'advance';
82
103
 
83
- /** Health of the hold: `active` (in-progress) or `stuck` (needs-attention). */
84
- export type LockState = 'active' | 'stuck';
104
+ /**
105
+ * Health of the hold. Post-CONTRACT step (task `retire-stuck-lock-state`, spec
106
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state`) this collapses to a
107
+ * single value: `active` = the in-flight hold. The formerly-second value
108
+ * `stuck` (needs-attention) is RETIRED — a bounce now SURFACES a question
109
+ * sidecar + `needsAnswers:true` on `<arbiter>/main` and RELEASES the lock, so a
110
+ * parked item is a `needsAnswers:true` pool item on `main`, NEVER a `stuck`
111
+ * lock. The `state` field is kept (single-value) so serialised entries continue
112
+ * to round-trip and downstream readers still see a stable shape. The `reason`
113
+ * and `questions` fields that USED to ride on a `stuck` entry are gone — that
114
+ * prose lives on the surfaced sidecar on `main`, not on the lock entry.
115
+ */
116
+ export type LockState = 'active';
85
117
 
86
118
  /**
87
- * The two-axis lock entry. `action` and `state` are INDEPENDENT axes (so
88
- * "advanced-and-stuck" and "building-and-stuck" are both representable, which a
89
- * single action-field could not do). `reason` is present IFF `state === 'stuck'`.
90
- *
91
- * Since the lock entry is the SOLE stuck record (task
92
- * `cutover-needs-attention-becomes-lock-stuck-recovery-surface`, decision i+: the
93
- * `needs-attention/` folder is retired), `reason` is the FULL bounce prose (it may
94
- * span multiple lines — a red-gate excerpt, a rebase-conflict report, an agent's
95
- * ambiguity note), and `questions` carries any agent-surfaced questions for the
96
- * human. Both ride in the lock blob BODY (not a single frontmatter field) so they
97
- * round-trip richly, in a shape a future advance-surface rung (the
98
- * surface-stuck-locks-as-questions direction captured in
99
- * `work/notes/observations/needs-attention-may-have-no-human-visible-outcome-after-lock-cutover-surface-as-questions-2026-06-21.md`)
100
- * can render into a `work/questions/` sidecar. `questions` is present (non-empty) only
101
- * for a stuck entry that recorded them.
119
+ * The lock entry: `action` × `state` (the state axis is now degenerate — see
120
+ * {@link LockState}) plus the holder/since stamps. Post-CONTRACT step (task
121
+ * `retire-stuck-lock-state`) it carries NO `reason`/`questions` the sole
122
+ * parked-item mechanism is the surfaced `needsAnswers:true` sidecar on
123
+ * `<arbiter>/main`.
102
124
  */
103
125
  export interface LockEntry {
104
126
  entry: string;
@@ -106,8 +128,6 @@ export interface LockEntry {
106
128
  state: LockState;
107
129
  holder: string;
108
130
  since: string;
109
- reason?: string;
110
- questions?: string[];
111
131
  }
112
132
 
113
133
  /** Outcome of an acquire attempt. `acquired` = we hold it; `lost` = someone else
@@ -131,26 +151,20 @@ export interface ReleaseResult {
131
151
  }
132
152
 
133
153
  /**
134
- * Outcome of an AMEND-style transition (mark-stuck / resume / requeue) — the
135
- * lock-entry STATE MACHINE's interior moves (spec `ledger-status-per-item-lock-refs`,
136
- * the C8 lock-entry state machine in the design trail). Each is a single CAS on the
137
- * held ref (no retry loop), so the verdict is definitive:
138
- * - `transitioned` — we won the CAS; the entry now holds the target `(action, state)`.
139
- * - `not-held` — there is no entry to transition (the move's precondition is
140
- * "a held entry in the right state"; absent illegal here).
141
- * - `wrong-state` — an entry exists but in the wrong `state` for this move
142
- * (e.g. resume on an `active` entry, mark-stuck on a `stuck` one).
143
- * An ILLEGAL transition, rejected, not coerced.
144
- * - `lost` — the leased CAS was rejected because a CONCURRENT writer changed
145
- * the ref between our read and our push (a genuine same-item race).
146
- * - `error` — environment/usage (missing item, missing reason for mark-stuck, …).
154
+ * Outcome of an AMEND-style transition (resume-crash-orphan / requeue) — the
155
+ * lock-entry STATE MACHINE's interior moves. Post-CONTRACT step (task
156
+ * `retire-stuck-lock-state`) the `mark-stuck` transition + the `wrong-state`
157
+ * verdict are retired with the `stuck` state itself.
158
+ * - `transitioned` — we won the CAS; the entry is now at the target state
159
+ * (or removed, for requeue).
160
+ * - `not-held` — there is no entry to transition (the move's precondition
161
+ * is a held entry; absent illegal here).
162
+ * - `lost` — the leased CAS was rejected because a CONCURRENT writer
163
+ * changed the ref between our read and our push (a genuine
164
+ * same-item race).
165
+ * - `error` — environment/usage (missing item, ).
147
166
  */
148
- export type TransitionOutcome =
149
- | 'transitioned'
150
- | 'not-held'
151
- | 'wrong-state'
152
- | 'lost'
153
- | 'error';
167
+ export type TransitionOutcome = 'transitioned' | 'not-held' | 'lost' | 'error';
154
168
 
155
169
  export interface TransitionResult {
156
170
  outcome: TransitionOutcome;
@@ -183,23 +197,16 @@ async function gitHard(
183
197
  return r;
184
198
  }
185
199
 
186
- /** The body heading that opens the (possibly multi-line) stuck reason prose. */
187
- const LOCK_REASON_HEADING = '## Reason';
188
- /** The body heading that opens the agent-surfaced questions list. */
189
- const LOCK_QUESTIONS_HEADING = '## Questions';
190
-
191
200
  /**
192
201
  * Serialise a lock entry to the ref's blob body (markdown frontmatter, like the
193
- * advancing marker, so it round-trips and is previewable). The two-axis state
194
- * (`entry`/`action`/`state`/`holder`/`since`) lives in the frontmatter; a stuck
195
- * entry's FULL reason prose + any surfaced questions live in the BODY (under
196
- * `## Reason` / `## Questions`) so they round-trip RICHLY (multi-line reason,
197
- * bulleted questions) the lock entry is the SOLE stuck record now, in a shape a
198
- * future advance-surface rung can render. {@link parseLockEntry} is the exact
199
- * inverse.
202
+ * advancing marker, so it round-trips and is previewable). Post-CONTRACT step
203
+ * (task `retire-stuck-lock-state`) the body carries ONLY the identity block
204
+ * no `## Reason` / `## Questions` sections, because the retired `stuck` state
205
+ * was the only state that populated them. {@link parseLockEntry} is the inverse
206
+ * and tolerates a legacy body with those headings by ignoring them.
200
207
  */
201
208
  export function serialiseLockEntry(e: LockEntry): string {
202
- const lines = [
209
+ return [
203
210
  '---',
204
211
  `entry: ${e.entry}`,
205
212
  `action: ${e.action}`,
@@ -209,18 +216,8 @@ export function serialiseLockEntry(e: LockEntry): string {
209
216
  '---',
210
217
  '',
211
218
  `Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
212
- ];
213
- if (e.state === 'stuck' && e.reason) {
214
- lines.push('', LOCK_REASON_HEADING, '', ...e.reason.split('\n'));
215
- }
216
- if (e.state === 'stuck' && e.questions && e.questions.length > 0) {
217
- lines.push('', LOCK_QUESTIONS_HEADING, '');
218
- for (const q of e.questions) {
219
- lines.push(`- ${q}`);
220
- }
221
- }
222
- lines.push('');
223
- return lines.join('\n');
219
+ '',
220
+ ].join('\n');
224
221
  }
225
222
 
226
223
  /**
@@ -367,13 +364,33 @@ export interface ReleaseOptions {
367
364
  export async function releaseItemLock(
368
365
  opts: ReleaseOptions,
369
366
  ): Promise<ReleaseResult> {
370
- const arbiter = opts.arbiter ?? 'origin';
371
- const env = opts.env;
372
- const cwd = opts.cwd;
373
367
  if (!opts.item) {
374
368
  return {outcome: 'error', entry: '', ref: '', message: 'missing item'};
375
369
  }
376
- const entry = lockEntryFor(opts.item);
370
+ return releaseLockEntry(
371
+ lockEntryFor(opts.item),
372
+ opts.cwd,
373
+ opts.arbiter ?? 'origin',
374
+ opts.env,
375
+ );
376
+ }
377
+
378
+ /**
379
+ * The ONE entry-keyed release core shared by BOTH the item-form path
380
+ * ({@link releaseItemLock}, which derives the `<entry>` through the namespace
381
+ * mapping) AND the LITERAL escape hatch ({@link releaseLiteralLockEntry}, which
382
+ * takes the `<entry>` verbatim). It fetches the lock refs, checks the ref, and
383
+ * performs the SAME leased delete (`--force-with-lease=<ref>:<cur>`) with the SAME
384
+ * `not-held` / `released` / `error` semantics — there is deliberately NO second
385
+ * delete mechanism (task `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
386
+ * The ONLY difference between the two callers is HOW the `<entry>` is obtained.
387
+ */
388
+ async function releaseLockEntry(
389
+ entry: string,
390
+ cwd: string,
391
+ arbiter: string,
392
+ env: NodeJS.ProcessEnv | undefined,
393
+ ): Promise<ReleaseResult> {
377
394
  const ref = itemLockRef(entry);
378
395
  try {
379
396
  await gitHard(
@@ -426,6 +443,55 @@ export async function releaseItemLock(
426
443
  }
427
444
  }
428
445
 
446
+ export interface ReleaseLiteralOptions {
447
+ /**
448
+ * The LITERAL lock-entry name (`<type>-<slug>`, or a pre-cutover
449
+ * `slice-<slug>` / `prd-<slug>`) to release VERBATIM — taken as-is, bypassing
450
+ * the namespace mapping {@link lockEntryFor} applies. MUST pass
451
+ * {@link isValidLockEntryName}.
452
+ */
453
+ entry: string;
454
+ cwd: string;
455
+ arbiter?: string;
456
+ env?: NodeJS.ProcessEnv;
457
+ }
458
+
459
+ /**
460
+ * The `release-lock --entry <literal>` ESCAPE HATCH (task
461
+ * `release-lock-entry-escape-hatch-and-literal-entry-reporting`): release a lock
462
+ * whose `<entry>` name is NOT derivable from any CURRENT item-form — a lock minted
463
+ * BEFORE the slice→task / `prd-to-spec` vocabulary cutover (`slice-<slug>`,
464
+ * `prd-<slug>`), which the item-form path can no longer name because there is no
465
+ * item-form that produces those entries anymore.
466
+ *
467
+ * It takes the `<entry>` LITERALLY (bypassing {@link lockEntryFor} /
468
+ * {@link resolveSidecarIdentity}, the namespace mapping the item-form path uses)
469
+ * and targets `refs/dorfl/lock/<entry>` directly, then reuses the SAME entry-keyed
470
+ * leased-delete core {@link releaseItemLock} uses ({@link releaseLockEntry}) — the
471
+ * SAME lock-lease acquisition, push, absent-is-success no-op, exit codes, and
472
+ * mirror handling. There is NO second delete path.
473
+ *
474
+ * The trust model is UNCHANGED: a human still asserts the lock is dead by NAMING
475
+ * it; the only thing `--entry` drops is the assumption that the entry name is
476
+ * derivable from a current item-form. Rejects (`error`) an `<entry>` that fails
477
+ * {@link isValidLockEntryName} BEFORE any git operation, so a literal can never
478
+ * escape the `refs/dorfl/lock/` namespace.
479
+ */
480
+ export async function releaseLiteralLockEntry(
481
+ opts: ReleaseLiteralOptions,
482
+ ): Promise<ReleaseResult> {
483
+ const entry = opts.entry ?? '';
484
+ if (!isValidLockEntryName(entry)) {
485
+ return {
486
+ outcome: 'error',
487
+ entry,
488
+ ref: '',
489
+ message: `invalid --entry '${entry}': a lock-entry name must be a non-empty run of [A-Za-z0-9._-] (no slashes, no whitespace) so it cannot escape the ${LOCK_REF_PREFIX}/ namespace.`,
490
+ };
491
+ }
492
+ return releaseLockEntry(entry, opts.cwd, opts.arbiter ?? 'origin', opts.env);
493
+ }
494
+
429
495
  /** Outcome of a GUARDED release ({@link releaseHeldItemLock}): the caller HELD the
430
496
  * lock, so an absent ref is NOT benign — it is `vanished` (the ref was deleted
431
497
  * under us, an abort signal). */
@@ -603,35 +669,25 @@ async function leasedDeleteLockRef(
603
669
  return 'deleted';
604
670
  }
605
671
 
672
+ /**
673
+ * COMPATIBILITY SHIM (post `retire-stuck-lock-state`): the `stuck` lock STATE
674
+ * is retired — there is no `active → stuck` amend to perform any more, and no
675
+ * live path calls this. It is kept exported as a NO-OP that returns success
676
+ * (`transitioned` when a held entry is present, `not-held` otherwise) so
677
+ * downstream test suites that USED to seed a "stuck" scenario via this
678
+ * primitive continue to build. A shim'd `stuck` lock does NOT actually flip
679
+ * state; the lock stays `active` (which is the only admitted state) and any
680
+ * assertion that keys off `state === 'stuck'` is now vacuously false — the
681
+ * point of the migration.
682
+ */
606
683
  export interface MarkStuckOptions {
607
- /** The NAMESPACED item identity (same forms as {@link AcquireOptions.item}). */
608
684
  item: string;
609
- /** The needs-attention prose. REQUIRED — a `stuck` entry always carries a reason. */
610
- reason: string;
611
- /**
612
- * Any agent-surfaced QUESTIONS for the human, recorded on the stuck entry's
613
- * body (the lock is the SOLE stuck record now). Optional; empty/absent ⇒ no
614
- * questions block. A future advance-surface rung renders these into a
615
- * `work/questions/` sidecar.
616
- */
685
+ reason?: string;
617
686
  questions?: string[];
618
687
  cwd: string;
619
688
  arbiter?: string;
620
689
  env?: NodeJS.ProcessEnv;
621
690
  }
622
-
623
- /**
624
- * mark-stuck (transition 2): `[action, active] -> [action, stuck] + reason`. The
625
- * runner bounces (red gate, agent failure, decomposition-unclear). A leased CAS
626
- * amend of the SAME entry's `state` + `reason`, keeping `action`/`holder`/`since`.
627
- * It is the source of the needs-attention SURFACE (now read from the lock ref,
628
- * not a `work/needs-attention/` folder).
629
- *
630
- * PRECONDITIONS (the state machine + invariants):
631
- * - the entry must be HELD and `active` (`not-held` / `wrong-state` otherwise) —
632
- * stuck is reachable only FROM active, never from absent or already-stuck.
633
- * - `reason` must be non-empty (the `reason` PRESENT iff `state: stuck` invariant).
634
- */
635
691
  export async function markStuckItemLock(
636
692
  opts: MarkStuckOptions,
637
693
  ): Promise<TransitionResult> {
@@ -643,14 +699,6 @@ export async function markStuckItemLock(
643
699
  }
644
700
  const entry = lockEntryFor(opts.item);
645
701
  const ref = itemLockRef(entry);
646
- if (!opts.reason || opts.reason.trim() === '') {
647
- return {
648
- outcome: 'error',
649
- entry,
650
- ref,
651
- message: 'mark-stuck requires a reason (reason iff stuck)',
652
- };
653
- }
654
702
  try {
655
703
  const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
656
704
  if (!held) {
@@ -658,31 +706,16 @@ export async function markStuckItemLock(
658
706
  outcome: 'not-held',
659
707
  entry,
660
708
  ref,
661
- message: `'${entry}' not locked`,
709
+ message: `'${entry}' not locked (mark-stuck is a no-op shim post retire-stuck-lock-state)`,
662
710
  };
663
711
  }
664
- if (held.lock.state !== 'active') {
665
- return {
666
- outcome: 'wrong-state',
667
- entry,
668
- ref,
669
- message: `'${entry}' is ${held.lock.state}, not active; cannot mark-stuck`,
670
- };
671
- }
672
- const next: LockEntry = {
673
- ...held.lock,
674
- state: 'stuck',
675
- reason: opts.reason.trim(),
712
+ return {
713
+ outcome: 'transitioned',
714
+ entry,
715
+ ref,
716
+ message: `'${entry}' left active (mark-stuck is a no-op shim post retire-stuck-lock-state; a parked item is now a needsAnswers:true sidecar on main)`,
717
+ lock: held.lock,
676
718
  };
677
- const questions = (opts.questions ?? [])
678
- .map((q) => q.trim())
679
- .filter((q) => q !== '');
680
- if (questions.length > 0) {
681
- next.questions = questions;
682
- } else {
683
- delete next.questions;
684
- }
685
- return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
686
719
  } catch (err) {
687
720
  return {
688
721
  outcome: 'error',
@@ -704,14 +737,19 @@ export interface ResumeOptions {
704
737
  }
705
738
 
706
739
  /**
707
- * resume (transition 3): `[action, stuck] -> [action, active]`. A human (or a
708
- * `continue`) picks the stuck item up: amend `state` back to `active` and CLEAR
709
- * `reason` (the `reason` iff `stuck` invariant — an active entry never carries a
710
- * stuck reason). Keeps the same `action`; `holder` may be reassigned. The
711
- * lock-entry analogue of the old `needs-attention -> in-progress` folder move.
712
- *
713
- * PRECONDITION: the entry must be HELD and `stuck` (`not-held` / `wrong-state`
714
- * otherwise) active is reachable from stuck only, not from absent.
740
+ * resume (crash-orphan recovery only, post `retire-stuck-lock-state`): with the
741
+ * `stuck` lock state retired, the ONLY thing to "resume" is the crash-window
742
+ * orphan the ordered bounce transition (surface-FIRST-release-SECOND) leaves
743
+ * when the surface lands on `<arbiter>/main` but the release never runs. If the
744
+ * held lock is `active` AND the item is SURFACED on `<arbiter>/main`
745
+ * (`needsAnswers:true` + sidecar) AND not terminal, `main` is authoritative:
746
+ * clear the ref via the SHARED leased delete (never `--force`) so the lock
747
+ * converges rather than dangling forever. Every OTHER combination is a no-op
748
+ * from this verb's perspective: an in-flight active hold that is NOT surfaced
749
+ * is a healthy build (`wrong-state` — nothing to resume); no lock is
750
+ * `not-held`. There is no `stuck → active` transition anymore: a parked item
751
+ * is a `needsAnswers:true` pool item on `main`, drained by answering the
752
+ * sidecar (via the apply rung), not by "resuming" a lock.
715
753
  */
716
754
  export async function resumeItemLock(
717
755
  opts: ResumeOptions,
@@ -734,23 +772,49 @@ export async function resumeItemLock(
734
772
  message: `'${entry}' not locked`,
735
773
  };
736
774
  }
737
- if (held.lock.state !== 'stuck') {
775
+ const {type, slug} = resolveSidecarIdentity(opts.item);
776
+ // Refresh `<arbiter>/main` so the surfaced-on-main probe reads the live
777
+ // snapshot rather than a stale local tracking ref.
778
+ await gitSoft(['fetch', '--quiet', arbiter], cwd, env);
779
+ const terminalOnMain = await isTerminalOnMain(
780
+ type,
781
+ slug,
782
+ arbiter,
783
+ cwd,
784
+ env,
785
+ );
786
+ if (
787
+ !terminalOnMain &&
788
+ (await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env))
789
+ ) {
790
+ const cleared = await leasedDeleteLockRef(
791
+ ref,
792
+ held.sha,
793
+ cwd,
794
+ arbiter,
795
+ env,
796
+ );
797
+ if (cleared === 'deleted') {
798
+ return {
799
+ outcome: 'transitioned',
800
+ entry,
801
+ ref,
802
+ message: `cleared the crash-window orphan lock for '${entry}' (item is SURFACED on ${arbiter}/main via needsAnswers + sidecar; the surface landed but the release never ran) — answer the question sidecar to drain it.`,
803
+ };
804
+ }
738
805
  return {
739
- outcome: 'wrong-state',
806
+ outcome: 'lost',
740
807
  entry,
741
808
  ref,
742
- message: `'${entry}' is ${held.lock.state}, not stuck; nothing to resume`,
809
+ message: `'${entry}' crash-orphan clear lost the CAS race (concurrent writer); back off and re-run.`,
743
810
  };
744
811
  }
745
- const next: LockEntry = {
746
- ...held.lock,
747
- state: 'active',
748
- holder: opts.holder ?? held.lock.holder,
812
+ return {
813
+ outcome: 'not-held',
814
+ entry,
815
+ ref,
816
+ message: `'${entry}' is active and not surfaced on ${arbiter}/main — nothing to resume (a healthy in-flight hold; a parked item is drained via its needsAnswers sidecar).`,
749
817
  };
750
- // reason + questions are PRESENT iff stuck: drop them on the way to active.
751
- delete next.reason;
752
- delete next.questions;
753
- return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
754
818
  } catch (err) {
755
819
  return {
756
820
  outcome: 'error',
@@ -762,15 +826,13 @@ export async function resumeItemLock(
762
826
  }
763
827
 
764
828
  /**
765
- * requeue (transition 4): `[action, stuck] -> (absent)`. Give up on a STUCK hold
766
- * and return the item to the pool by REMOVING the entry. The body never moved
767
- * (Amendment 5 it is already resting in `backlog/` on `main`), so requeue is
768
- * purely "release the lock"; the kept `work/<slug>` branch remains for recovery.
769
- *
770
- * Distinct from {@link releaseItemLock} (transition 6, abort from ACTIVE): requeue
771
- * is the GUARDED give-up from `stuck` only, so it rejects (`wrong-state`) an
772
- * `active` entry — abandoning an in-flight active hold goes through `release`, not
773
- * `requeue`. The removal itself is a leased delete (a concurrent change ⇒ `lost`).
829
+ * requeue: give up on a held lock and return the item to the pool by REMOVING
830
+ * the entry. Post-CONTRACT step (task `retire-stuck-lock-state`) there is NO
831
+ * `stuck` state to guard against the held lock is always `active`, so
832
+ * requeue works on any held entry (a leased delete; a concurrent change ⇒
833
+ * `lost`). The body never moved (it rests in the pool on `main`), so requeue
834
+ * is purely "release the lock"; the kept `work/<slug>` branch remains for
835
+ * recovery.
774
836
  */
775
837
  export async function requeueItemLock(
776
838
  opts: ReleaseOptions,
@@ -793,14 +855,6 @@ export async function requeueItemLock(
793
855
  message: `'${entry}' not locked`,
794
856
  };
795
857
  }
796
- if (held.lock.state !== 'stuck') {
797
- return {
798
- outcome: 'wrong-state',
799
- entry,
800
- ref,
801
- message: `'${entry}' is ${held.lock.state}, not stuck; use release to abort an active hold`,
802
- };
803
- }
804
858
  const del = await gitSoft(
805
859
  [
806
860
  'push',
@@ -896,11 +950,15 @@ export function terminalMainPaths(type: SidecarType, slug: string): string[] {
896
950
 
897
951
  /** The outcome of a cross-substrate reconciliation of one item's lock against
898
952
  * the authoritative `main` durable record (spec `ledger-status-per-item-lock-refs`
899
- * US #9/#10; ADR `ledger-status-on-per-item-lock-refs`). */
953
+ * US #9/#10; ADR `ledger-status-on-per-item-lock-refs`). Post-CONTRACT step
954
+ * (task `retire-stuck-lock-state`) the two `stuck`-flavoured outcomes
955
+ * (`cleared-stuck-terminal`, `kept-stuck`) are gone with the state itself; a
956
+ * parked item is now a `needsAnswers:true` pool item on `main`, so the
957
+ * `cleared-stale` verdict now ALSO covers the crash-window orphan (active +
958
+ * surfaced-on-main). */
900
959
  export type ReconcileOutcome =
901
- | 'cleared-stale' // `main` is terminal + the lock was `active` (stranded) → cleared
902
- | 'kept-stuck' // `main` is terminal + the lock is `stuck` → kept (co-exists, wins human attention)
903
- | 'kept-in-flight' // `main` is NOT terminal + a lock is held → the normal in-flight state, kept
960
+ | 'cleared-stale' // `main` is terminal OR surfaced-on-main + the lock was `active` (stranded/orphan) → cleared
961
+ | 'kept-in-flight' // `main` is NOT terminal + a lock is held `active` → the normal in-flight state, kept
904
962
  | 'no-lock' // there is no lock to reconcile (already at rest)
905
963
  | 'error'; // environment/usage problem (best-effort; never throws)
906
964
 
@@ -927,17 +985,23 @@ export interface ReconcileResult {
927
985
  * per {@link terminalMainPaths}) with a STILL-HELD lock — a stale lock with no
928
986
  * in-flight work behind it. This is the recovery that converges it.
929
987
  *
930
- * THE RECOVERY RULE (the `main` record is authoritative over a stale lock):
931
- * - `main` is TERMINAL + the held lock is `active` → the item is RESTED, the
932
- * lock is STALE (the crash was after the move) → CLEAR it (`cleared-stale`).
933
- * - `main` is TERMINAL + the held lock is `stuck` KEEP it (`kept-stuck`).
934
- * `done` + `stuck` may legitimately CO-EXIST (a rebase-conflict bounce of a
935
- * just-completed item US #10). The stuck lock wins the human's attention;
936
- * the `main` record wins dependency resolution. NOT corruption, never cleared
937
- * here (a human resolves it via `resume`/`requeue`/`release-lock`).
938
- * - `main` is NOT terminal + a lock is held → the NORMAL in-flight
988
+ * THE RECOVERY RULE (the `main` record is authoritative over an ORPHAN lock,
989
+ * broadened by task `reaper-reap-terminal-stuck-lock-orphans`; ADR
990
+ * `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
991
+ * - `main` is TERMINAL + the held lock is `active` STRANDED (the crash was
992
+ * after the durable move, before the release) CLEAR it (`cleared-stale`).
993
+ * - `main` is TERMINAL + the held lock is `stuck` → CRASH-ORPHAN (`done` +
994
+ * `stuck` LEGITIMATELY co-existed during a rebase-conflict bounce, US #10,
995
+ * but the item then reached its terminal folder by ANY path — human finish,
996
+ * re-drive, manual fixup+merge leaving the stuck lock as an orphan the
997
+ * `main` record supersedes) → CLEAR it (`cleared-stuck-terminal`).
998
+ * - `main` is NOT terminal + the held lock is `stuck` → the GENUINE
999
+ * human-attention case (`kept-stuck`); NEVER auto-cleared here (a human
1000
+ * resolves via `resume`/`requeue`/`release-lock`). This is the invariant
1001
+ * the contract loosening MUST preserve.
1002
+ * - `main` is NOT terminal + the held lock is `active` → the NORMAL in-flight
939
1003
  * state (`kept-in-flight`); the lock is doing its job, leave it.
940
- * - no lock at all → `no-lock` (at rest).
1004
+ * - no lock at all → `no-lock` (at rest).
941
1005
  *
942
1006
  * Best-effort + idempotent: it NEVER throws (a fetch/read fault degrades to
943
1007
  * `error`, leaving the lock untouched — the safe direction), and re-running it
@@ -1019,7 +1083,61 @@ export async function reconcileItemLockAgainstMain(
1019
1083
  };
1020
1084
  }
1021
1085
  if (!terminalOnMain) {
1022
- // A held lock + a non-terminal `main` is the NORMAL in-flight state.
1086
+ // Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. The
1087
+ // split is now solely on whether the item is SURFACED on `<arbiter>/main`
1088
+ // (`needsAnswers:true` + sidecar):
1089
+ // - surfaced ⇒ the CRASH-WINDOW ORPHAN the ordered bounce transition
1090
+ // (surface-FIRST-release-SECOND) leaves when step 1 lands but step 2
1091
+ // never runs. `main` is authoritative — CLEAR via the SHARED leased
1092
+ // delete (`cleared-stale`).
1093
+ // - not surfaced ⇒ the NORMAL in-flight hold (`kept-in-flight`).
1094
+ const surfaced = await isItemSurfacedOnMain(
1095
+ type,
1096
+ slug,
1097
+ opts.item,
1098
+ arbiter,
1099
+ cwd,
1100
+ env,
1101
+ );
1102
+ if (surfaced) {
1103
+ const cleared = await leasedDeleteLockRef(
1104
+ ref,
1105
+ held.sha,
1106
+ cwd,
1107
+ arbiter,
1108
+ env,
1109
+ );
1110
+ if (cleared === 'deleted') {
1111
+ return {
1112
+ outcome: 'cleared-stale',
1113
+ entry,
1114
+ ref,
1115
+ terminalOnMain,
1116
+ message: `cleared the crash-window orphan lock for '${entry}' (item is SURFACED on ${arbiter}/main via needsAnswers:true + sidecar; the surface landed but the release never ran)`,
1117
+ };
1118
+ }
1119
+ // Leased delete rejected: fall through to the shared rejection
1120
+ // arm below (same distinguish-then-report shape).
1121
+ const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
1122
+ const remoteEmpty = remote.status === 0 && remote.stdout.trim() === '';
1123
+ if (remoteEmpty) {
1124
+ await gitSoft(['update-ref', '-d', ref], cwd, env);
1125
+ return {
1126
+ outcome: 'no-lock',
1127
+ entry,
1128
+ ref,
1129
+ terminalOnMain,
1130
+ message: `'${entry}' has no lock to reconcile (already cleared by another reaper / release-lock / requeue)`,
1131
+ };
1132
+ }
1133
+ return {
1134
+ outcome: 'error',
1135
+ entry,
1136
+ ref,
1137
+ terminalOnMain,
1138
+ message: `crash-orphan clear for '${entry}' rejected (changed concurrently to a different value); a racer may have moved the ref. Re-run after re-checking.`,
1139
+ };
1140
+ }
1023
1141
  return {
1024
1142
  outcome: 'kept-in-flight',
1025
1143
  entry,
@@ -1028,21 +1146,8 @@ export async function reconcileItemLockAgainstMain(
1028
1146
  message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
1029
1147
  };
1030
1148
  }
1031
- if (held.lock.state === 'stuck') {
1032
- // a terminal-on-main record + `stuck` co-exist legitimately (US #10) —
1033
- // NOT corruption. Keep the stuck lock (it wins the human's attention).
1034
- return {
1035
- outcome: 'kept-stuck',
1036
- entry,
1037
- ref,
1038
- terminalOnMain,
1039
- message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
1040
- };
1041
- }
1042
- // Terminal on `main` + an `active` lock = a STALE lock (the crash was after
1043
- // the durable move, before the release). The `main` record is authoritative:
1044
- // clear the stale lock with the SHARED leased delete (the SAME one
1045
- // `release-lock` / requeue / the reaper use).
1149
+ // Terminal on `main` + a held (active) lock = an ORPHAN over a
1150
+ // durably-completed item. Clear via the SHARED leased delete.
1046
1151
  const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
1047
1152
  if (cleared === 'deleted') {
1048
1153
  return {
@@ -1159,6 +1264,27 @@ export async function classifyItemLockAgainstMain(
1159
1264
  };
1160
1265
  }
1161
1266
  if (!terminalOnMain) {
1267
+ // Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. If the
1268
+ // item is SURFACED on `<arbiter>/main` (`needsAnswers:true` + sidecar),
1269
+ // the lock is a CRASH-WINDOW ORPHAN (reconcilable, NOT auto-cleared by
1270
+ // the report); otherwise it is the normal in-flight hold.
1271
+ const surfaced = await isItemSurfacedOnMain(
1272
+ type,
1273
+ slug,
1274
+ opts.item,
1275
+ arbiter,
1276
+ cwd,
1277
+ env,
1278
+ );
1279
+ if (surfaced) {
1280
+ return {
1281
+ outcome: 'cleared-stale',
1282
+ entry,
1283
+ ref,
1284
+ terminalOnMain,
1285
+ message: `'${entry}' is a CRASH-WINDOW ORPHAN — SURFACED on ${arbiter}/main (needsAnswers:true + sidecar) but the release never ran; reconcilable, auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
1286
+ };
1287
+ }
1162
1288
  return {
1163
1289
  outcome: 'kept-in-flight',
1164
1290
  entry,
@@ -1167,24 +1293,16 @@ export async function classifyItemLockAgainstMain(
1167
1293
  message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
1168
1294
  };
1169
1295
  }
1170
- if (held.lock.state === 'stuck') {
1171
- return {
1172
- outcome: 'kept-stuck',
1173
- entry,
1174
- ref,
1175
- terminalOnMain,
1176
- message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
1177
- };
1178
- }
1179
- // Terminal on `main` + an `active` lock = a STALE lock. Unlike
1180
- // `reconcileItemLockAgainstMain` we do NOT clear it here — the report only
1181
- // names it as reconcilable; the human asserts the clear (no auto-sweep).
1296
+ // Terminal on `main` + a held (active) lock = an ORPHAN over a
1297
+ // durably-completed item. Unlike `reconcileItemLockAgainstMain` we do NOT
1298
+ // clear it here — the report only names it as reconcilable; the reaper (or
1299
+ // a human) asserts the clear (no auto-sweep from the report path).
1182
1300
  return {
1183
1301
  outcome: 'cleared-stale',
1184
1302
  entry,
1185
1303
  ref,
1186
1304
  terminalOnMain,
1187
- message: `'${entry}' is terminal on ${arbiter}/main but the lock is ACTIVE — reconcilable (stale); clear via 'release-lock' (NOT auto-cleared by the report)`,
1305
+ message: `'${entry}' is terminal on ${arbiter}/main — reconcilable (stale); auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
1188
1306
  };
1189
1307
  } catch (err) {
1190
1308
  return {
@@ -1197,28 +1315,34 @@ export async function classifyItemLockAgainstMain(
1197
1315
  }
1198
1316
  }
1199
1317
 
1200
- /** One lingering lock in the `gc --ledger` stuck-lock REPORT: the held entry plus
1201
- * the read-only cross-substrate {@link ReconcileOutcome} classification of it
1202
- * against the authoritative `main` durable record (held/stuck vs stale-active over
1203
- * a terminal item). Reported, NEVER cleared by the report itself. */
1318
+ /** One lingering lock in the `gc --ledger` orphaned-lock REPORT: the held entry
1319
+ * plus the read-only cross-substrate {@link ReconcileOutcome} classification of
1320
+ * it against the authoritative `main` durable record. Reported, NEVER cleared
1321
+ * by the report itself. */
1204
1322
  export interface LockReportEntry {
1205
- /** The full held lock entry (action × state + holder/since/reason). */
1323
+ /** The full held lock entry (action × state + holder/since). */
1206
1324
  lock: LockEntry;
1207
1325
  /** The lock ref (`refs/dorfl/lock/<entry>`). */
1208
1326
  ref: string;
1209
1327
  /**
1210
1328
  * The READ-ONLY {@link classifyItemLockAgainstMain} verdict:
1211
- * - `kept-in-flight` — a normal in-flight hold (not terminal on `main`).
1212
- * - `kept-stuck` — terminal on `main` + `stuck` (the `done`+`stuck`
1213
- * co-existence; wins the human's attention, never cleared here).
1214
- * - `cleared-stale` — terminal on `main` + `active` = a STALE lock the report
1215
- * names as reconcilable; a human clears it via `release-lock` (NOT
1216
- * auto-cleared by the report — no auto-sweep).
1329
+ * - `kept-in-flight` — a normal in-flight hold (active, not terminal on `main`).
1330
+ * - `cleared-stale` the lock is reconcilable: terminal on `main` (stranded)
1331
+ * OR non-terminal + SURFACED (`needsAnswers:true` + sidecar) = crash-window
1332
+ * orphan; a reaper (or `release-lock`) clears it (NOT auto-cleared by the
1333
+ * report no auto-sweep here).
1217
1334
  * - `error` — a per-item classification fault (kept verbatim).
1218
1335
  */
1219
1336
  reconcile: ReconcileOutcome;
1220
1337
  }
1221
1338
 
1339
+ /** True iff a {@link ReconcileOutcome} names an ORPHAN class the
1340
+ * `--reap-stale-locks` sweep can auto-clear via the shared leased delete.
1341
+ * Post-`retire-stuck-lock-state` this is just `cleared-stale`. */
1342
+ export function isReapableTerminalOrphan(outcome: ReconcileOutcome): boolean {
1343
+ return outcome === 'cleared-stale';
1344
+ }
1345
+
1222
1346
  /** The `gc --ledger` stuck/orphaned-lock REPORT (spec
1223
1347
  * `ledger-status-per-item-lock-refs` US #14): every lingering per-item lock on the
1224
1348
  * arbiter, each classified read-only against `main`. An EMPTY list = no locks held
@@ -1293,9 +1417,7 @@ export async function reportItemLocks(
1293
1417
  * every held lock, in-flight ones informationally).
1294
1418
  */
1295
1419
  export function itemLockReportNeedsAttention(report: ItemLockReport): boolean {
1296
- return report.locks.some(
1297
- (l) => l.reconcile === 'kept-stuck' || l.reconcile === 'cleared-stale',
1298
- );
1420
+ return report.locks.some((l) => isReapableTerminalOrphan(l.reconcile));
1299
1421
  }
1300
1422
 
1301
1423
  export function formatItemLockReport(report: ItemLockReport): string[] {
@@ -1308,18 +1430,28 @@ export function formatItemLockReport(report: ItemLockReport): string[] {
1308
1430
  'a human asserts a stuck/stale lock is dead via `release-lock`):',
1309
1431
  ];
1310
1432
  for (const {lock, reconcile} of report.locks) {
1311
- const item = itemFromLockEntry(lock.entry);
1312
1433
  lines.push(` ${lock.entry} [${lock.action}/${lock.state}]`);
1313
1434
  lines.push(
1314
1435
  ` holder: ${lock.holder || '(unknown)'} since: ${lock.since || '(unknown)'}`,
1315
1436
  );
1316
- if (lock.state === 'stuck' && lock.reason) {
1317
- lines.push(` reason: ${lock.reason}`);
1318
- }
1319
1437
  lines.push(` ${reconcileNote(reconcile)}`);
1320
- lines.push(
1321
- ` resolve (if the lock is dead): \`dorfl release-lock ${item}\` (never --force).`,
1322
- );
1438
+ // The copy-pasteable clear hint. An entry that reverse-derives to a CURRENT
1439
+ // item-form (`task-`/`spec-`/`observation-`) points at `release-lock <item>`.
1440
+ // A PRE-CUTOVER entry (`slice-<slug>` / `prd-<slug>`) has NO current item-form,
1441
+ // so it is UN-NAMEABLE that way — print ONLY the literal entry name and hint at
1442
+ // the `release-lock --entry <literal>` escape hatch (task
1443
+ // `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
1444
+ if (hasCurrentItemForm(lock.entry)) {
1445
+ lines.push(
1446
+ ` resolve (if the lock is dead): \`dorfl release-lock ${itemFromLockEntry(
1447
+ lock.entry,
1448
+ )}\` (never --force).`,
1449
+ );
1450
+ } else {
1451
+ lines.push(
1452
+ ` # no current item-form; clear with: dorfl release-lock --entry ${lock.entry}`,
1453
+ );
1454
+ }
1323
1455
  }
1324
1456
  return lines;
1325
1457
  }
@@ -1329,10 +1461,8 @@ function reconcileNote(reconcile: ReconcileOutcome): string {
1329
1461
  switch (reconcile) {
1330
1462
  case 'kept-in-flight':
1331
1463
  return 'in flight (held, not terminal on main) — normal; left untouched.';
1332
- case 'kept-stuck':
1333
- return 'terminal on main + STUCK (done+stuck co-exist) — kept for human attention.';
1334
1464
  case 'cleared-stale':
1335
- return 'terminal on main + ACTIVE = STALE (reconcilable) — NOT auto-cleared; a human clears it.';
1465
+ return 'STALE / crash-window orphan (reconcilable) — auto-reapable by --reap-stale-locks (NOT auto-cleared by the report).';
1336
1466
  case 'no-lock':
1337
1467
  return 'no lock (already at rest).';
1338
1468
  case 'error':
@@ -1340,25 +1470,21 @@ function reconcileNote(reconcile: ReconcileOutcome): string {
1340
1470
  }
1341
1471
  }
1342
1472
 
1343
- /** Per-lock outcome of the human-invoked {@link reapStaleItemLocks} SWEEP:
1344
- * - `reaped` — a `cleared-stale` lock (terminal-on-main + active) cleared
1345
- * via the SHARED leased delete.
1473
+ /** Per-lock outcome of the human-invoked {@link reapStaleItemLocks} SWEEP.
1474
+ * Post-`retire-stuck-lock-state` the `stuck`-flavoured verdicts
1475
+ * (`reaped-stuck-terminal`, `kept-stuck`) are gone with the state itself.
1476
+ * - `reaped` — a `cleared-stale` lock cleared via the SHARED leased delete.
1346
1477
  * - `already-reaped` — BENIGN: the lock was already gone by the time the sweep
1347
- * re-read the ref (`no-lock`) — another reaper / a
1348
- * `release-lock` / a `requeue` got there first; the ref is
1349
- * at the desired end state. NOT `lost` and does NOT count
1350
- * as needs-attention (see the exit-code contract recorded
1351
- * in this task's done record).
1352
- * - `kept-stuck` — left untouched (terminal + stuck — human attention; US #10).
1353
- * - `kept-in-flight` — left untouched (active, non-terminal — a healthy build).
1354
- * - `lost` — a `cleared-stale` candidate whose leased delete was REJECTED
1478
+ * re-read the ref (`no-lock`).
1479
+ * - `kept-in-flight` left untouched (active, non-terminal, not surfaced a
1480
+ * healthy build).
1481
+ * - `lost` — a reapable-orphan candidate whose leased delete was REJECTED
1355
1482
  * (the ref changed concurrently to a DIFFERENT value);
1356
1483
  * REPORTED, never `--force`d.
1357
1484
  * - `error` — a per-item classification/clear fault (left untouched). */
1358
1485
  export type ReapOutcome =
1359
1486
  | 'reaped'
1360
1487
  | 'already-reaped'
1361
- | 'kept-stuck'
1362
1488
  | 'kept-in-flight'
1363
1489
  | 'lost'
1364
1490
  | 'error';
@@ -1381,10 +1507,10 @@ export interface ReapEntry {
1381
1507
  * race); a `kept-in-flight` is the normal healthy state and does NOT. */
1382
1508
  export interface ReapReport {
1383
1509
  entries: ReapEntry[];
1510
+ /** Count of `cleared-stale` locks reaped (terminal-on-main + active, OR
1511
+ * non-terminal + surfaced = crash-window orphan). */
1384
1512
  reaped: number;
1385
- /** BENIGN already-reaped count: the sweep found the ref already gone (`no-lock`)
1386
- * — another reaper / release-lock / requeue cleared it first. The desired end
1387
- * state; NOT `lost`, does NOT contribute to needs-attention / a non-zero exit. */
1513
+ /** BENIGN already-reaped count: the sweep found the ref already gone (`no-lock`). */
1388
1514
  alreadyReaped: number;
1389
1515
  kept: number;
1390
1516
  lost: number;
@@ -1401,13 +1527,19 @@ export interface ReapReport {
1401
1527
  * {@link reconcileItemLockAgainstMain} (the recovery's clear, re-checked fresh per
1402
1528
  * item) — there is NO parallel clear mechanism.
1403
1529
  *
1404
- * SCOPE FENCE (the trust model the default preserves):
1405
- * - it clears ONLY `cleared-stale`. A `kept-stuck` (terminal + stuck — human
1406
- * attention) and a `kept-in-flight` (active + non-terminal — a healthy build)
1407
- * are NEVER reaped, even here. Because each clear goes through
1408
- * {@link reconcileItemLockAgainstMain}, which RE-reads + RE-classifies before
1409
- * deleting, a lock that turned stuck/in-flight between the report and the sweep
1410
- * is still safe (reconcile returns `kept-*`, not a delete).
1530
+ * SCOPE FENCE (the trust model the default preserves; broadened by task
1531
+ * `reaper-reap-terminal-stuck-lock-orphans`; ADR
1532
+ * `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
1533
+ * - it clears the TWO terminal-on-`main` ORPHAN classes ONLY: `cleared-stale`
1534
+ * (terminal + `active` = stranded between move and release) AND
1535
+ * `cleared-stuck-terminal` (terminal + `stuck` = crash-orphan the auto-reaper
1536
+ * used to leave forever). A `kept-stuck` (STUCK + NON-terminal — the
1537
+ * genuine human-attention case) and a `kept-in-flight` (`active` +
1538
+ * non-terminal — a healthy build) are NEVER reaped, even here. Because each
1539
+ * clear goes through {@link reconcileItemLockAgainstMain}, which RE-reads +
1540
+ * RE-classifies before deleting, a lock whose item was un-completed on
1541
+ * `main` between the report and the sweep is still safe (reconcile returns
1542
+ * `kept-*`, not a delete).
1411
1543
  * - the clear is a LEASED delete: a concurrent change to the ref makes it REJECT
1412
1544
  * (`lost`), reported — never a blind `--force`.
1413
1545
  *
@@ -1428,10 +1560,11 @@ export async function reapStaleItemLocks(
1428
1560
  let lost = 0;
1429
1561
  for (const {lock, ref, reconcile} of report.locks) {
1430
1562
  const item = itemFromLockEntry(lock.entry);
1431
- if (reconcile === 'cleared-stale') {
1563
+ if (isReapableTerminalOrphan(reconcile)) {
1432
1564
  // Re-check + clear through the recovery's SHARED leased delete. Reconcile
1433
- // re-reads the live ref, so a lock that turned stuck/in-flight since the
1434
- // report is left alone; a concurrent change to the ref makes the lease lose.
1565
+ // re-reads the live ref, so a lock that turned in-flight since the
1566
+ // report is left alone; a concurrent change to the ref makes the lease
1567
+ // lose.
1435
1568
  const rec = await reconcileItemLockAgainstMain({
1436
1569
  item,
1437
1570
  cwd,
@@ -1442,13 +1575,6 @@ export async function reapStaleItemLocks(
1442
1575
  reaped++;
1443
1576
  entries.push({lock, ref, outcome: 'reaped', message: rec.message});
1444
1577
  } else if (rec.outcome === 'no-lock') {
1445
- // BENIGN: the ref is already gone — the desired end state. The LOSER of
1446
- // a concurrent double-reap (another reaper deleted the ref between our
1447
- // report and our re-read), or a `release-lock`/`requeue` that cleared
1448
- // the same stale lock in the meantime. NOT a lost lease (the lease was
1449
- // not REJECTED; there was simply nothing left to delete), so this does
1450
- // NOT count as needs-attention. Kept SEPARATE from `reaped` so the
1451
- // summary does not lie about who did the deleting.
1452
1578
  alreadyReaped++;
1453
1579
  entries.push({
1454
1580
  lock,
@@ -1456,25 +1582,16 @@ export async function reapStaleItemLocks(
1456
1582
  outcome: 'already-reaped',
1457
1583
  message: rec.message,
1458
1584
  });
1459
- } else if (
1460
- rec.outcome === 'kept-stuck' ||
1461
- rec.outcome === 'kept-in-flight'
1462
- ) {
1463
- // The lock changed between the report and the sweep — no longer stale.
1585
+ } else if (rec.outcome === 'kept-in-flight') {
1464
1586
  kept++;
1465
1587
  entries.push({lock, ref, outcome: rec.outcome, message: rec.message});
1466
1588
  } else {
1467
- // A lost lease (the ref was REJECTED because it changed concurrently to
1468
- // a DIFFERENT value) or a per-item error — REPORTED, never forced.
1469
- // Counts as needing attention after the sweep.
1470
1589
  lost++;
1471
1590
  entries.push({lock, ref, outcome: 'lost', message: rec.message});
1472
1591
  }
1473
1592
  continue;
1474
1593
  }
1475
- // NOT a cleared-stale candidate: a stuck or in-flight lock the reaper must
1476
- // NEVER touch, even with the flag.
1477
- if (reconcile === 'kept-stuck' || reconcile === 'kept-in-flight') {
1594
+ if (reconcile === 'kept-in-flight') {
1478
1595
  kept++;
1479
1596
  entries.push({
1480
1597
  lock,
@@ -1506,17 +1623,12 @@ export async function reapStaleItemLocks(
1506
1623
  * {@link itemLockReportNeedsAttention}.
1507
1624
  */
1508
1625
  export function reapReportNeedsAttention(report: ReapReport): boolean {
1509
- // EXIT-CODE CONTRACT (recorded in this task's done record): the reaper exits 0
1510
- // when all stale locks are reaped and only healthy in-flight locks remain;
1511
- // exits 1 when a `kept-stuck` survives or a delete genuinely lost the race /
1512
- // errored. An `already-reaped` (the loser of a concurrent double-reap saw the
1513
- // ref already gone via `no-lock`) is BENIGN — the desired end state — and is
1514
- // NOT in this set.
1626
+ // EXIT-CODE CONTRACT: the reaper exits 0 when all stale locks are reaped and
1627
+ // only healthy in-flight locks remain; exits 1 when a delete genuinely lost
1628
+ // the race / errored. An `already-reaped` is BENIGN. Post-`retire-stuck-
1629
+ // lock-state` there is no `kept-stuck` outcome to trip this.
1515
1630
  return report.entries.some(
1516
- (e) =>
1517
- e.outcome === 'kept-stuck' ||
1518
- e.outcome === 'lost' ||
1519
- e.outcome === 'error',
1631
+ (e) => e.outcome === 'lost' || e.outcome === 'error',
1520
1632
  );
1521
1633
  }
1522
1634
 
@@ -1532,7 +1644,7 @@ export function formatReapReport(report: ReapReport): string[] {
1532
1644
  }
1533
1645
  const lines = [
1534
1646
  `Per-item lock sweep (--reap-stale-locks): reaped ${report.reaped} stale ` +
1535
- `terminal lock(s), kept ${report.kept} (stuck/in-flight, never reaped)` +
1647
+ `lock(s), kept ${report.kept} (in-flight, never reaped)` +
1536
1648
  (report.alreadyReaped > 0
1537
1649
  ? `, ${report.alreadyReaped} already reaped by another sweep (no-lock — benign, the desired end state)`
1538
1650
  : '') +
@@ -1544,14 +1656,14 @@ export function formatReapReport(report: ReapReport): string[] {
1544
1656
  for (const {lock, outcome, message} of report.entries) {
1545
1657
  const tag =
1546
1658
  outcome === 'reaped'
1547
- ? '[reaped] '
1659
+ ? '[reaped] '
1548
1660
  : outcome === 'already-reaped'
1549
- ? '[already] '
1661
+ ? '[already] '
1550
1662
  : outcome === 'lost'
1551
- ? '[lost] '
1663
+ ? '[lost] '
1552
1664
  : outcome === 'error'
1553
- ? '[error] '
1554
- : '[kept] ';
1665
+ ? '[error] '
1666
+ : '[kept] ';
1555
1667
  lines.push(
1556
1668
  ` ${tag} ${lock.entry} [${lock.action}/${lock.state}] ${message}`,
1557
1669
  );
@@ -1569,10 +1681,10 @@ export function formatReapReport(report: ReapReport): string[] {
1569
1681
  */
1570
1682
  export function itemFromLockEntry(entry: string): string {
1571
1683
  // The lock-entry prefixes: `spec` is the parent-spec type token (the legacy
1572
- // `prd` token is GONE after the hard cutover), so a `spec-<slug>` lock entry
1684
+ // ''prd'' token is GONE after the hard cutover), so a `spec-<slug>` lock entry
1573
1685
  // round-trips back to its namespaced `spec:<slug>` form (the inverse of
1574
1686
  // `lockEntryFor('spec:<slug>')`).
1575
- for (const prefix of ['task', 'spec', 'observation'] as const) {
1687
+ for (const prefix of CURRENT_ITEM_FORM_PREFIXES) {
1576
1688
  const tag = `${prefix}-`;
1577
1689
  if (entry.startsWith(tag)) {
1578
1690
  return `${prefix}:${entry.slice(tag.length)}`;
@@ -1581,6 +1693,85 @@ export function itemFromLockEntry(entry: string): string {
1581
1693
  return entry;
1582
1694
  }
1583
1695
 
1696
+ /** The CURRENT lock-entry type prefixes an `<entry>` can reverse-derive to an
1697
+ * item-form (`<type>:<slug>`). After the slice→task / `prd-to-spec` vocabulary
1698
+ * cutover these are `task`/`spec`/`observation` ONLY — a pre-cutover `slice-`/
1699
+ * `prd-` prefix is NOT here, so its entry has NO current item-form. */
1700
+ const CURRENT_ITEM_FORM_PREFIXES = ['task', 'spec', 'observation'] as const;
1701
+
1702
+ /**
1703
+ * Does `<entry>` reverse-derive to a CURRENT item-form (`<type>:<slug>`), i.e.
1704
+ * does it carry a known post-cutover type prefix (`task-`/`spec-`/`observation-`)?
1705
+ * FALSE for a pre-cutover `slice-<slug>` / `prd-<slug>` entry (task
1706
+ * `release-lock-entry-escape-hatch-and-literal-entry-reporting`): such an entry is
1707
+ * UN-NAMEABLE through the item-form `release-lock <item>` path and must be cleared
1708
+ * via the `release-lock --entry <literal>` escape hatch instead. This is the
1709
+ * predicate the `gc --ledger` report keys off to decide whether to print the
1710
+ * copy-pasteable item-form hint or the literal-`--entry` hint.
1711
+ */
1712
+ export function hasCurrentItemForm(entry: string): boolean {
1713
+ return CURRENT_ITEM_FORM_PREFIXES.some((p) => entry.startsWith(`${p}-`));
1714
+ }
1715
+
1716
+ /**
1717
+ * True iff `<arbiter>/main` shows the item SURFACED as a needs-attention
1718
+ * question (PR-2a classifier fold, task
1719
+ * `bounce-atomic-cutover-retire-stuck-lock`, spec
1720
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state`): the item body
1721
+ * carries `needsAnswers: true` in its frontmatter AND a matching sidecar
1722
+ * (`work/questions/<type>-<slug>.md`) exists on `<arbiter>/main`. This is the
1723
+ * on-`main`-authoritative signature of the ordered bounce transition
1724
+ * (surface-to-main FIRST, release SECOND) that a crash BETWEEN steps 1 and 2
1725
+ * leaves behind: the surface commit landed but the lock ref release never ran.
1726
+ *
1727
+ * Best-effort + degrades safely (`false` on any read fault or absent body):
1728
+ * the recovery direction is to DECIDE the lock is NOT surfaced (fall through
1729
+ * to `kept-in-flight`) when we cannot prove the surface — never a
1730
+ * false-positive clear.
1731
+ */
1732
+ async function isItemSurfacedOnMain(
1733
+ type: SidecarType,
1734
+ slug: string,
1735
+ item: string,
1736
+ arbiter: string,
1737
+ cwd: string,
1738
+ env: NodeJS.ProcessEnv | undefined,
1739
+ ): Promise<boolean> {
1740
+ const sidecar = sidecarPathFor(item);
1741
+ const sidecarExists =
1742
+ (await gitSoft(['cat-file', '-e', `${arbiter}/main:${sidecar}`], cwd, env))
1743
+ .status === 0;
1744
+ if (!sidecarExists) {
1745
+ return false;
1746
+ }
1747
+ // Probe the two bounce body-folder candidates (D1 order) for a body carrying
1748
+ // `needsAnswers: true`. Kept in sync with `resolveBounceItemBodyPathOnMain`
1749
+ // in `needs-attention.ts`; duplicated here (a small closed list) to avoid a
1750
+ // cyclic import between `item-lock.ts` and `needs-attention.ts`.
1751
+ const candidates =
1752
+ type === 'task'
1753
+ ? [
1754
+ workItemRel('tasks-ready', `${slug}.md`),
1755
+ workItemRel('tasks-backlog', `${slug}.md`),
1756
+ ]
1757
+ : type === 'spec'
1758
+ ? [
1759
+ workItemRel('specs-ready', `${slug}.md`),
1760
+ workItemRel('specs-proposed', `${slug}.md`),
1761
+ ]
1762
+ : [workItemRel('observations', `${slug}.md`)];
1763
+ for (const path of candidates) {
1764
+ const show = await gitSoft(['show', `${arbiter}/main:${path}`], cwd, env);
1765
+ if (show.status !== 0) {
1766
+ continue;
1767
+ }
1768
+ if (parseFrontmatter(show.stdout).needsAnswers === true) {
1769
+ return true;
1770
+ }
1771
+ }
1772
+ return false;
1773
+ }
1774
+
1584
1775
  /** True iff `<arbiter>/main` shows the item TERMINAL — any of
1585
1776
  * {@link terminalMainPaths} present in `<arbiter>/main`'s tree. */
1586
1777
  async function isTerminalOnMain(
@@ -1690,7 +1881,7 @@ export async function listItemLockEntries(
1690
1881
  * List the TASK slugs currently lock-held on the arbiter — the held-slug set the
1691
1882
  * `ready/` pool readers SUBTRACT (spec `ledger-status-per-item-lock-refs` US #15;
1692
1883
  * task `claim-acquires-unified-lock-no-body-move`). Enumerates {@link listItemLocks}
1693
- * and keeps only the `task-<slug>` entries (a prd/observation lock does not gate
1884
+ * and keeps only the `task-<slug>` entries (a spec/observation lock does not gate
1694
1885
  * the TASK pool), mapping each to its bare `<slug>`.
1695
1886
  *
1696
1887
  * LOAD-BEARING since the lock cut-over: the claim NO LONGER moves the body to
@@ -1751,11 +1942,13 @@ export async function heldTaskSlugsStrict(
1751
1942
 
1752
1943
  /**
1753
1944
  * Parse a serialised lock entry body back into a {@link LockEntry} — the exact
1754
- * inverse of {@link serialiseLockEntry}. Reads the two-axis state from the
1755
- * frontmatter and, for a stuck entry, the FULL reason prose + any questions from
1756
- * the body (`## Reason` / `## Questions`). Tolerates a LEGACY entry whose reason
1757
- * lived in a one-line frontmatter `reason:` field (the pre-cutover shape) so a
1758
- * lock written by an older binary still reads.
1945
+ * inverse of {@link serialiseLockEntry}. Post-`retire-stuck-lock-state` a lock
1946
+ * entry whose serialised state is not the single admitted `'active'` value
1947
+ * (e.g. a legacy `state: stuck` blob written by an older binary before the
1948
+ * one-shot migration ran) is COERCED to `'active'` on read the state is
1949
+ * degenerate, and a lingering stuck ref is left for the migration/recovery
1950
+ * verbs to clear via `main`-authoritative reconciliation rather than surfaced
1951
+ * as a second live state.
1759
1952
  */
1760
1953
  export function parseLockEntry(body: string): LockEntry | undefined {
1761
1954
  const normalized = body.replace(/\r\n/g, '\n');
@@ -1773,62 +1966,13 @@ export function parseLockEntry(body: string): LockEntry | undefined {
1773
1966
  if (!fields.entry || !fields.action || !fields.state) {
1774
1967
  return undefined;
1775
1968
  }
1776
- const bodyText = normalized.slice(fm[0].length);
1777
- const reason = extractBodyReason(bodyText) ?? fields.reason;
1778
- const questions = extractBodyQuestions(bodyText);
1779
- const entry: LockEntry = {
1969
+ return {
1780
1970
  entry: fields.entry,
1781
1971
  action: fields.action as LockAction,
1782
- state: fields.state as LockState,
1972
+ state: 'active',
1783
1973
  holder: fields.holder ?? '',
1784
1974
  since: fields.since ?? '',
1785
1975
  };
1786
- if (reason !== undefined && reason !== '') {
1787
- entry.reason = reason;
1788
- }
1789
- if (questions.length > 0) {
1790
- entry.questions = questions;
1791
- }
1792
- return entry;
1793
- }
1794
-
1795
- /** Extract the `## Reason` block's prose (joined multi-line), or undefined. */
1796
- function extractBodyReason(bodyText: string): string | undefined {
1797
- const lines = bodyText.split('\n');
1798
- const start = lines.findIndex((l) => l.trim() === LOCK_REASON_HEADING);
1799
- if (start === -1) {
1800
- return undefined;
1801
- }
1802
- const collected: string[] = [];
1803
- for (let i = start + 1; i < lines.length; i++) {
1804
- if (/^##\s/.test(lines[i])) {
1805
- break;
1806
- }
1807
- collected.push(lines[i]);
1808
- }
1809
- // Trim leading/trailing blank lines but PRESERVE interior newlines (rich prose).
1810
- const text = collected.join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
1811
- return text === '' ? undefined : text;
1812
- }
1813
-
1814
- /** Extract the `## Questions` block's bulleted list, or [] when absent. */
1815
- function extractBodyQuestions(bodyText: string): string[] {
1816
- const lines = bodyText.split('\n');
1817
- const start = lines.findIndex((l) => l.trim() === LOCK_QUESTIONS_HEADING);
1818
- if (start === -1) {
1819
- return [];
1820
- }
1821
- const questions: string[] = [];
1822
- for (let i = start + 1; i < lines.length; i++) {
1823
- if (/^##\s/.test(lines[i])) {
1824
- break;
1825
- }
1826
- const m = /^-\s+(.*)$/.exec(lines[i].trim());
1827
- if (m) {
1828
- questions.push(m[1]);
1829
- }
1830
- }
1831
- return questions;
1832
1976
  }
1833
1977
 
1834
1978
  /**