dorfl 0.1.2 → 0.2.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 (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/dist/item-lock.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { runAsync } from './git.js';
3
- import { resolveSidecarIdentity } from './sidecar.js';
3
+ import { resolveSidecarIdentity, sidecarPathFor, } from './sidecar.js';
4
+ import { parseFrontmatter } from './frontmatter.js';
4
5
  import { workItemRel } from './work-layout.js';
5
6
  /**
6
7
  * The **unified item-lock module** (spec `ledger-status-per-item-lock-refs`, ADR
@@ -72,6 +73,21 @@ export function lockEntryFor(item) {
72
73
  export function itemLockRef(entry) {
73
74
  return `${LOCK_REF_PREFIX}/${entry}`;
74
75
  }
76
+ /**
77
+ * The character class a LITERAL lock-entry name must match to be addressable
78
+ * through `release-lock --entry <literal>` (task
79
+ * `release-lock-entry-escape-hatch-and-literal-entry-reporting`): a NON-EMPTY run
80
+ * of `[A-Za-z0-9._-]` — the SAME shape the minting side produces (`<type>-<slug>`,
81
+ * and the pre-cutover `slice-<slug>` / `prd-<slug>` entries it must be able to
82
+ * name). It deliberately EXCLUDES `/` and whitespace so a literal can never escape
83
+ * the `refs/dorfl/lock/` namespace (a `/` would address a different ref path; a
84
+ * space would break the push refspec). This is the ONLY validation the `--entry`
85
+ * escape hatch performs before the git operation — the entry name is taken
86
+ * literally otherwise, bypassing the namespace mapping the item-form path uses.
87
+ */
88
+ export function isValidLockEntryName(entry) {
89
+ return /^[A-Za-z0-9._-]+$/.test(entry);
90
+ }
75
91
  function gitSoft(args, cwd, env) {
76
92
  return runAsync('git', args, cwd, { env });
77
93
  }
@@ -82,22 +98,16 @@ async function gitHard(args, cwd, env) {
82
98
  }
83
99
  return r;
84
100
  }
85
- /** The body heading that opens the (possibly multi-line) stuck reason prose. */
86
- const LOCK_REASON_HEADING = '## Reason';
87
- /** The body heading that opens the agent-surfaced questions list. */
88
- const LOCK_QUESTIONS_HEADING = '## Questions';
89
101
  /**
90
102
  * Serialise a lock entry to the ref's blob body (markdown frontmatter, like the
91
- * advancing marker, so it round-trips and is previewable). The two-axis state
92
- * (`entry`/`action`/`state`/`holder`/`since`) lives in the frontmatter; a stuck
93
- * entry's FULL reason prose + any surfaced questions live in the BODY (under
94
- * `## Reason` / `## Questions`) so they round-trip RICHLY (multi-line reason,
95
- * bulleted questions) the lock entry is the SOLE stuck record now, in a shape a
96
- * future advance-surface rung can render. {@link parseLockEntry} is the exact
97
- * inverse.
103
+ * advancing marker, so it round-trips and is previewable). Post-CONTRACT step
104
+ * (task `retire-stuck-lock-state`) the body carries ONLY the identity block
105
+ * no `## Reason` / `## Questions` sections, because the retired `stuck` state
106
+ * was the only state that populated them. {@link parseLockEntry} is the inverse
107
+ * and tolerates a legacy body with those headings by ignoring them.
98
108
  */
99
109
  export function serialiseLockEntry(e) {
100
- const lines = [
110
+ return [
101
111
  '---',
102
112
  `entry: ${e.entry}`,
103
113
  `action: ${e.action}`,
@@ -107,18 +117,8 @@ export function serialiseLockEntry(e) {
107
117
  '---',
108
118
  '',
109
119
  `Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
110
- ];
111
- if (e.state === 'stuck' && e.reason) {
112
- lines.push('', LOCK_REASON_HEADING, '', ...e.reason.split('\n'));
113
- }
114
- if (e.state === 'stuck' && e.questions && e.questions.length > 0) {
115
- lines.push('', LOCK_QUESTIONS_HEADING, '');
116
- for (const q of e.questions) {
117
- lines.push(`- ${q}`);
118
- }
119
- }
120
- lines.push('');
121
- return lines.join('\n');
120
+ '',
121
+ ].join('\n');
122
122
  }
123
123
  /**
124
124
  * Build a PARENTLESS commit whose tree contains the single lock-entry blob, and
@@ -208,13 +208,22 @@ export async function acquireItemLock(opts) {
208
208
  * becomes unreachable for gc. Idempotent: deleting an absent ref is `not-held`.
209
209
  */
210
210
  export async function releaseItemLock(opts) {
211
- const arbiter = opts.arbiter ?? 'origin';
212
- const env = opts.env;
213
- const cwd = opts.cwd;
214
211
  if (!opts.item) {
215
212
  return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
216
213
  }
217
- const entry = lockEntryFor(opts.item);
214
+ return releaseLockEntry(lockEntryFor(opts.item), opts.cwd, opts.arbiter ?? 'origin', opts.env);
215
+ }
216
+ /**
217
+ * The ONE entry-keyed release core shared by BOTH the item-form path
218
+ * ({@link releaseItemLock}, which derives the `<entry>` through the namespace
219
+ * mapping) AND the LITERAL escape hatch ({@link releaseLiteralLockEntry}, which
220
+ * takes the `<entry>` verbatim). It fetches the lock refs, checks the ref, and
221
+ * performs the SAME leased delete (`--force-with-lease=<ref>:<cur>`) with the SAME
222
+ * `not-held` / `released` / `error` semantics — there is deliberately NO second
223
+ * delete mechanism (task `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
224
+ * The ONLY difference between the two callers is HOW the `<entry>` is obtained.
225
+ */
226
+ async function releaseLockEntry(entry, cwd, arbiter, env) {
218
227
  const ref = itemLockRef(entry);
219
228
  try {
220
229
  await gitHard([
@@ -258,6 +267,39 @@ export async function releaseItemLock(opts) {
258
267
  };
259
268
  }
260
269
  }
270
+ /**
271
+ * The `release-lock --entry <literal>` ESCAPE HATCH (task
272
+ * `release-lock-entry-escape-hatch-and-literal-entry-reporting`): release a lock
273
+ * whose `<entry>` name is NOT derivable from any CURRENT item-form — a lock minted
274
+ * BEFORE the slice→task / `prd-to-spec` vocabulary cutover (`slice-<slug>`,
275
+ * `prd-<slug>`), which the item-form path can no longer name because there is no
276
+ * item-form that produces those entries anymore.
277
+ *
278
+ * It takes the `<entry>` LITERALLY (bypassing {@link lockEntryFor} /
279
+ * {@link resolveSidecarIdentity}, the namespace mapping the item-form path uses)
280
+ * and targets `refs/dorfl/lock/<entry>` directly, then reuses the SAME entry-keyed
281
+ * leased-delete core {@link releaseItemLock} uses ({@link releaseLockEntry}) — the
282
+ * SAME lock-lease acquisition, push, absent-is-success no-op, exit codes, and
283
+ * mirror handling. There is NO second delete path.
284
+ *
285
+ * The trust model is UNCHANGED: a human still asserts the lock is dead by NAMING
286
+ * it; the only thing `--entry` drops is the assumption that the entry name is
287
+ * derivable from a current item-form. Rejects (`error`) an `<entry>` that fails
288
+ * {@link isValidLockEntryName} BEFORE any git operation, so a literal can never
289
+ * escape the `refs/dorfl/lock/` namespace.
290
+ */
291
+ export async function releaseLiteralLockEntry(opts) {
292
+ const entry = opts.entry ?? '';
293
+ if (!isValidLockEntryName(entry)) {
294
+ return {
295
+ outcome: 'error',
296
+ entry,
297
+ ref: '',
298
+ 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.`,
299
+ };
300
+ }
301
+ return releaseLockEntry(entry, opts.cwd, opts.arbiter ?? 'origin', opts.env);
302
+ }
261
303
  /**
262
304
  * GUARDED release for a runner that KNOWS it acquired and HELD the lock (spec
263
305
  * `ledger-status-per-item-lock-refs` US #13): unlike {@link releaseItemLock} —
@@ -376,18 +418,6 @@ async function leasedDeleteLockRef(ref, expectedSha, cwd, arbiter, env) {
376
418
  await gitSoft(['update-ref', '-d', ref], cwd, env);
377
419
  return 'deleted';
378
420
  }
379
- /**
380
- * mark-stuck (transition 2): `[action, active] -> [action, stuck] + reason`. The
381
- * runner bounces (red gate, agent failure, decomposition-unclear). A leased CAS
382
- * amend of the SAME entry's `state` + `reason`, keeping `action`/`holder`/`since`.
383
- * It is the source of the needs-attention SURFACE (now read from the lock ref,
384
- * not a `work/needs-attention/` folder).
385
- *
386
- * PRECONDITIONS (the state machine + invariants):
387
- * - the entry must be HELD and `active` (`not-held` / `wrong-state` otherwise) —
388
- * stuck is reachable only FROM active, never from absent or already-stuck.
389
- * - `reason` must be non-empty (the `reason` PRESENT iff `state: stuck` invariant).
390
- */
391
421
  export async function markStuckItemLock(opts) {
392
422
  const arbiter = opts.arbiter ?? 'origin';
393
423
  const env = opts.env;
@@ -397,14 +427,6 @@ export async function markStuckItemLock(opts) {
397
427
  }
398
428
  const entry = lockEntryFor(opts.item);
399
429
  const ref = itemLockRef(entry);
400
- if (!opts.reason || opts.reason.trim() === '') {
401
- return {
402
- outcome: 'error',
403
- entry,
404
- ref,
405
- message: 'mark-stuck requires a reason (reason iff stuck)',
406
- };
407
- }
408
430
  try {
409
431
  const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
410
432
  if (!held) {
@@ -412,32 +434,16 @@ export async function markStuckItemLock(opts) {
412
434
  outcome: 'not-held',
413
435
  entry,
414
436
  ref,
415
- message: `'${entry}' not locked`,
437
+ message: `'${entry}' not locked (mark-stuck is a no-op shim post retire-stuck-lock-state)`,
416
438
  };
417
439
  }
418
- if (held.lock.state !== 'active') {
419
- return {
420
- outcome: 'wrong-state',
421
- entry,
422
- ref,
423
- message: `'${entry}' is ${held.lock.state}, not active; cannot mark-stuck`,
424
- };
425
- }
426
- const next = {
427
- ...held.lock,
428
- state: 'stuck',
429
- reason: opts.reason.trim(),
440
+ return {
441
+ outcome: 'transitioned',
442
+ entry,
443
+ ref,
444
+ 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)`,
445
+ lock: held.lock,
430
446
  };
431
- const questions = (opts.questions ?? [])
432
- .map((q) => q.trim())
433
- .filter((q) => q !== '');
434
- if (questions.length > 0) {
435
- next.questions = questions;
436
- }
437
- else {
438
- delete next.questions;
439
- }
440
- return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
441
447
  }
442
448
  catch (err) {
443
449
  return {
@@ -449,14 +455,19 @@ export async function markStuckItemLock(opts) {
449
455
  }
450
456
  }
451
457
  /**
452
- * resume (transition 3): `[action, stuck] -> [action, active]`. A human (or a
453
- * `continue`) picks the stuck item up: amend `state` back to `active` and CLEAR
454
- * `reason` (the `reason` iff `stuck` invariant — an active entry never carries a
455
- * stuck reason). Keeps the same `action`; `holder` may be reassigned. The
456
- * lock-entry analogue of the old `needs-attention -> in-progress` folder move.
457
- *
458
- * PRECONDITION: the entry must be HELD and `stuck` (`not-held` / `wrong-state`
459
- * otherwise) active is reachable from stuck only, not from absent.
458
+ * resume (crash-orphan recovery only, post `retire-stuck-lock-state`): with the
459
+ * `stuck` lock state retired, the ONLY thing to "resume" is the crash-window
460
+ * orphan the ordered bounce transition (surface-FIRST-release-SECOND) leaves
461
+ * when the surface lands on `<arbiter>/main` but the release never runs. If the
462
+ * held lock is `active` AND the item is SURFACED on `<arbiter>/main`
463
+ * (`needsAnswers:true` + sidecar) AND not terminal, `main` is authoritative:
464
+ * clear the ref via the SHARED leased delete (never `--force`) so the lock
465
+ * converges rather than dangling forever. Every OTHER combination is a no-op
466
+ * from this verb's perspective: an in-flight active hold that is NOT surfaced
467
+ * is a healthy build (`wrong-state` — nothing to resume); no lock is
468
+ * `not-held`. There is no `stuck → active` transition anymore: a parked item
469
+ * is a `needsAnswers:true` pool item on `main`, drained by answering the
470
+ * sidecar (via the apply rung), not by "resuming" a lock.
460
471
  */
461
472
  export async function resumeItemLock(opts) {
462
473
  const arbiter = opts.arbiter ?? 'origin';
@@ -477,23 +488,35 @@ export async function resumeItemLock(opts) {
477
488
  message: `'${entry}' not locked`,
478
489
  };
479
490
  }
480
- if (held.lock.state !== 'stuck') {
491
+ const { type, slug } = resolveSidecarIdentity(opts.item);
492
+ // Refresh `<arbiter>/main` so the surfaced-on-main probe reads the live
493
+ // snapshot rather than a stale local tracking ref.
494
+ await gitSoft(['fetch', '--quiet', arbiter], cwd, env);
495
+ const terminalOnMain = await isTerminalOnMain(type, slug, arbiter, cwd, env);
496
+ if (!terminalOnMain &&
497
+ (await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env))) {
498
+ const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
499
+ if (cleared === 'deleted') {
500
+ return {
501
+ outcome: 'transitioned',
502
+ entry,
503
+ ref,
504
+ 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.`,
505
+ };
506
+ }
481
507
  return {
482
- outcome: 'wrong-state',
508
+ outcome: 'lost',
483
509
  entry,
484
510
  ref,
485
- message: `'${entry}' is ${held.lock.state}, not stuck; nothing to resume`,
511
+ message: `'${entry}' crash-orphan clear lost the CAS race (concurrent writer); back off and re-run.`,
486
512
  };
487
513
  }
488
- const next = {
489
- ...held.lock,
490
- state: 'active',
491
- holder: opts.holder ?? held.lock.holder,
514
+ return {
515
+ outcome: 'not-held',
516
+ entry,
517
+ ref,
518
+ 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).`,
492
519
  };
493
- // reason + questions are PRESENT iff stuck: drop them on the way to active.
494
- delete next.reason;
495
- delete next.questions;
496
- return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
497
520
  }
498
521
  catch (err) {
499
522
  return {
@@ -505,15 +528,13 @@ export async function resumeItemLock(opts) {
505
528
  }
506
529
  }
507
530
  /**
508
- * requeue (transition 4): `[action, stuck] -> (absent)`. Give up on a STUCK hold
509
- * and return the item to the pool by REMOVING the entry. The body never moved
510
- * (Amendment 5 it is already resting in `backlog/` on `main`), so requeue is
511
- * purely "release the lock"; the kept `work/<slug>` branch remains for recovery.
512
- *
513
- * Distinct from {@link releaseItemLock} (transition 6, abort from ACTIVE): requeue
514
- * is the GUARDED give-up from `stuck` only, so it rejects (`wrong-state`) an
515
- * `active` entry — abandoning an in-flight active hold goes through `release`, not
516
- * `requeue`. The removal itself is a leased delete (a concurrent change ⇒ `lost`).
531
+ * requeue: give up on a held lock and return the item to the pool by REMOVING
532
+ * the entry. Post-CONTRACT step (task `retire-stuck-lock-state`) there is NO
533
+ * `stuck` state to guard against the held lock is always `active`, so
534
+ * requeue works on any held entry (a leased delete; a concurrent change ⇒
535
+ * `lost`). The body never moved (it rests in the pool on `main`), so requeue
536
+ * is purely "release the lock"; the kept `work/<slug>` branch remains for
537
+ * recovery.
517
538
  */
518
539
  export async function requeueItemLock(opts) {
519
540
  const arbiter = opts.arbiter ?? 'origin';
@@ -534,14 +555,6 @@ export async function requeueItemLock(opts) {
534
555
  message: `'${entry}' not locked`,
535
556
  };
536
557
  }
537
- if (held.lock.state !== 'stuck') {
538
- return {
539
- outcome: 'wrong-state',
540
- entry,
541
- ref,
542
- message: `'${entry}' is ${held.lock.state}, not stuck; use release to abort an active hold`,
543
- };
544
- }
545
558
  const del = await gitSoft([
546
559
  'push',
547
560
  arbiter,
@@ -635,17 +648,23 @@ export function terminalMainPaths(type, slug) {
635
648
  * per {@link terminalMainPaths}) with a STILL-HELD lock — a stale lock with no
636
649
  * in-flight work behind it. This is the recovery that converges it.
637
650
  *
638
- * THE RECOVERY RULE (the `main` record is authoritative over a stale lock):
639
- * - `main` is TERMINAL + the held lock is `active` → the item is RESTED, the
640
- * lock is STALE (the crash was after the move) → CLEAR it (`cleared-stale`).
641
- * - `main` is TERMINAL + the held lock is `stuck` KEEP it (`kept-stuck`).
642
- * `done` + `stuck` may legitimately CO-EXIST (a rebase-conflict bounce of a
643
- * just-completed item US #10). The stuck lock wins the human's attention;
644
- * the `main` record wins dependency resolution. NOT corruption, never cleared
645
- * here (a human resolves it via `resume`/`requeue`/`release-lock`).
646
- * - `main` is NOT terminal + a lock is held → the NORMAL in-flight
651
+ * THE RECOVERY RULE (the `main` record is authoritative over an ORPHAN lock,
652
+ * broadened by task `reaper-reap-terminal-stuck-lock-orphans`; ADR
653
+ * `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
654
+ * - `main` is TERMINAL + the held lock is `active` STRANDED (the crash was
655
+ * after the durable move, before the release) CLEAR it (`cleared-stale`).
656
+ * - `main` is TERMINAL + the held lock is `stuck` → CRASH-ORPHAN (`done` +
657
+ * `stuck` LEGITIMATELY co-existed during a rebase-conflict bounce, US #10,
658
+ * but the item then reached its terminal folder by ANY path — human finish,
659
+ * re-drive, manual fixup+merge leaving the stuck lock as an orphan the
660
+ * `main` record supersedes) → CLEAR it (`cleared-stuck-terminal`).
661
+ * - `main` is NOT terminal + the held lock is `stuck` → the GENUINE
662
+ * human-attention case (`kept-stuck`); NEVER auto-cleared here (a human
663
+ * resolves via `resume`/`requeue`/`release-lock`). This is the invariant
664
+ * the contract loosening MUST preserve.
665
+ * - `main` is NOT terminal + the held lock is `active` → the NORMAL in-flight
647
666
  * state (`kept-in-flight`); the lock is doing its job, leave it.
648
- * - no lock at all → `no-lock` (at rest).
667
+ * - no lock at all → `no-lock` (at rest).
649
668
  *
650
669
  * Best-effort + idempotent: it NEVER throws (a fetch/read fault degrades to
651
670
  * `error`, leaving the lock untouched — the safe direction), and re-running it
@@ -714,7 +733,48 @@ export async function reconcileItemLockAgainstMain(opts) {
714
733
  };
715
734
  }
716
735
  if (!terminalOnMain) {
717
- // A held lock + a non-terminal `main` is the NORMAL in-flight state.
736
+ // Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. The
737
+ // split is now solely on whether the item is SURFACED on `<arbiter>/main`
738
+ // (`needsAnswers:true` + sidecar):
739
+ // - surfaced ⇒ the CRASH-WINDOW ORPHAN the ordered bounce transition
740
+ // (surface-FIRST-release-SECOND) leaves when step 1 lands but step 2
741
+ // never runs. `main` is authoritative — CLEAR via the SHARED leased
742
+ // delete (`cleared-stale`).
743
+ // - not surfaced ⇒ the NORMAL in-flight hold (`kept-in-flight`).
744
+ const surfaced = await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env);
745
+ if (surfaced) {
746
+ const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
747
+ if (cleared === 'deleted') {
748
+ return {
749
+ outcome: 'cleared-stale',
750
+ entry,
751
+ ref,
752
+ terminalOnMain,
753
+ 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)`,
754
+ };
755
+ }
756
+ // Leased delete rejected: fall through to the shared rejection
757
+ // arm below (same distinguish-then-report shape).
758
+ const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
759
+ const remoteEmpty = remote.status === 0 && remote.stdout.trim() === '';
760
+ if (remoteEmpty) {
761
+ await gitSoft(['update-ref', '-d', ref], cwd, env);
762
+ return {
763
+ outcome: 'no-lock',
764
+ entry,
765
+ ref,
766
+ terminalOnMain,
767
+ message: `'${entry}' has no lock to reconcile (already cleared by another reaper / release-lock / requeue)`,
768
+ };
769
+ }
770
+ return {
771
+ outcome: 'error',
772
+ entry,
773
+ ref,
774
+ terminalOnMain,
775
+ 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.`,
776
+ };
777
+ }
718
778
  return {
719
779
  outcome: 'kept-in-flight',
720
780
  entry,
@@ -723,21 +783,8 @@ export async function reconcileItemLockAgainstMain(opts) {
723
783
  message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
724
784
  };
725
785
  }
726
- if (held.lock.state === 'stuck') {
727
- // a terminal-on-main record + `stuck` co-exist legitimately (US #10) —
728
- // NOT corruption. Keep the stuck lock (it wins the human's attention).
729
- return {
730
- outcome: 'kept-stuck',
731
- entry,
732
- ref,
733
- terminalOnMain,
734
- message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
735
- };
736
- }
737
- // Terminal on `main` + an `active` lock = a STALE lock (the crash was after
738
- // the durable move, before the release). The `main` record is authoritative:
739
- // clear the stale lock with the SHARED leased delete (the SAME one
740
- // `release-lock` / requeue / the reaper use).
786
+ // Terminal on `main` + a held (active) lock = an ORPHAN over a
787
+ // durably-completed item. Clear via the SHARED leased delete.
741
788
  const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
742
789
  if (cleared === 'deleted') {
743
790
  return {
@@ -842,6 +889,20 @@ export async function classifyItemLockAgainstMain(opts) {
842
889
  };
843
890
  }
844
891
  if (!terminalOnMain) {
892
+ // Post-`retire-stuck-lock-state`: the held lock is ALWAYS `active`. If the
893
+ // item is SURFACED on `<arbiter>/main` (`needsAnswers:true` + sidecar),
894
+ // the lock is a CRASH-WINDOW ORPHAN (reconcilable, NOT auto-cleared by
895
+ // the report); otherwise it is the normal in-flight hold.
896
+ const surfaced = await isItemSurfacedOnMain(type, slug, opts.item, arbiter, cwd, env);
897
+ if (surfaced) {
898
+ return {
899
+ outcome: 'cleared-stale',
900
+ entry,
901
+ ref,
902
+ terminalOnMain,
903
+ 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)`,
904
+ };
905
+ }
845
906
  return {
846
907
  outcome: 'kept-in-flight',
847
908
  entry,
@@ -850,24 +911,16 @@ export async function classifyItemLockAgainstMain(opts) {
850
911
  message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
851
912
  };
852
913
  }
853
- if (held.lock.state === 'stuck') {
854
- return {
855
- outcome: 'kept-stuck',
856
- entry,
857
- ref,
858
- terminalOnMain,
859
- message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
860
- };
861
- }
862
- // Terminal on `main` + an `active` lock = a STALE lock. Unlike
863
- // `reconcileItemLockAgainstMain` we do NOT clear it here — the report only
864
- // names it as reconcilable; the human asserts the clear (no auto-sweep).
914
+ // Terminal on `main` + a held (active) lock = an ORPHAN over a
915
+ // durably-completed item. Unlike `reconcileItemLockAgainstMain` we do NOT
916
+ // clear it here — the report only names it as reconcilable; the reaper (or
917
+ // a human) asserts the clear (no auto-sweep from the report path).
865
918
  return {
866
919
  outcome: 'cleared-stale',
867
920
  entry,
868
921
  ref,
869
922
  terminalOnMain,
870
- message: `'${entry}' is terminal on ${arbiter}/main but the lock is ACTIVE — reconcilable (stale); clear via 'release-lock' (NOT auto-cleared by the report)`,
923
+ message: `'${entry}' is terminal on ${arbiter}/main — reconcilable (stale); auto-reapable by 'gc --ledger --reap-stale-locks' (NOT auto-cleared by the report)`,
871
924
  };
872
925
  }
873
926
  catch (err) {
@@ -880,6 +933,12 @@ export async function classifyItemLockAgainstMain(opts) {
880
933
  };
881
934
  }
882
935
  }
936
+ /** True iff a {@link ReconcileOutcome} names an ORPHAN class the
937
+ * `--reap-stale-locks` sweep can auto-clear via the shared leased delete.
938
+ * Post-`retire-stuck-lock-state` this is just `cleared-stale`. */
939
+ export function isReapableTerminalOrphan(outcome) {
940
+ return outcome === 'cleared-stale';
941
+ }
883
942
  /**
884
943
  * Build the `gc --ledger` stuck/orphaned-lock REPORT (spec
885
944
  * `ledger-status-per-item-lock-refs` US #12/#13/#14; ADR
@@ -940,7 +999,7 @@ export async function reportItemLocks(cwd, arbiter = 'origin', env) {
940
999
  * every held lock, in-flight ones informationally).
941
1000
  */
942
1001
  export function itemLockReportNeedsAttention(report) {
943
- return report.locks.some((l) => l.reconcile === 'kept-stuck' || l.reconcile === 'cleared-stale');
1002
+ return report.locks.some((l) => isReapableTerminalOrphan(l.reconcile));
944
1003
  }
945
1004
  export function formatItemLockReport(report) {
946
1005
  if (report.locks.length === 0) {
@@ -952,14 +1011,21 @@ export function formatItemLockReport(report) {
952
1011
  'a human asserts a stuck/stale lock is dead via `release-lock`):',
953
1012
  ];
954
1013
  for (const { lock, reconcile } of report.locks) {
955
- const item = itemFromLockEntry(lock.entry);
956
1014
  lines.push(` ${lock.entry} [${lock.action}/${lock.state}]`);
957
1015
  lines.push(` holder: ${lock.holder || '(unknown)'} since: ${lock.since || '(unknown)'}`);
958
- if (lock.state === 'stuck' && lock.reason) {
959
- lines.push(` reason: ${lock.reason}`);
960
- }
961
1016
  lines.push(` ${reconcileNote(reconcile)}`);
962
- lines.push(` resolve (if the lock is dead): \`dorfl release-lock ${item}\` (never --force).`);
1017
+ // The copy-pasteable clear hint. An entry that reverse-derives to a CURRENT
1018
+ // item-form (`task-`/`spec-`/`observation-`) points at `release-lock <item>`.
1019
+ // A PRE-CUTOVER entry (`slice-<slug>` / `prd-<slug>`) has NO current item-form,
1020
+ // so it is UN-NAMEABLE that way — print ONLY the literal entry name and hint at
1021
+ // the `release-lock --entry <literal>` escape hatch (task
1022
+ // `release-lock-entry-escape-hatch-and-literal-entry-reporting`).
1023
+ if (hasCurrentItemForm(lock.entry)) {
1024
+ lines.push(` resolve (if the lock is dead): \`dorfl release-lock ${itemFromLockEntry(lock.entry)}\` (never --force).`);
1025
+ }
1026
+ else {
1027
+ lines.push(` # no current item-form; clear with: dorfl release-lock --entry ${lock.entry}`);
1028
+ }
963
1029
  }
964
1030
  return lines;
965
1031
  }
@@ -968,10 +1034,8 @@ function reconcileNote(reconcile) {
968
1034
  switch (reconcile) {
969
1035
  case 'kept-in-flight':
970
1036
  return 'in flight (held, not terminal on main) — normal; left untouched.';
971
- case 'kept-stuck':
972
- return 'terminal on main + STUCK (done+stuck co-exist) — kept for human attention.';
973
1037
  case 'cleared-stale':
974
- return 'terminal on main + ACTIVE = STALE (reconcilable) — NOT auto-cleared; a human clears it.';
1038
+ return 'STALE / crash-window orphan (reconcilable) — auto-reapable by --reap-stale-locks (NOT auto-cleared by the report).';
975
1039
  case 'no-lock':
976
1040
  return 'no lock (already at rest).';
977
1041
  case 'error':
@@ -989,13 +1053,19 @@ function reconcileNote(reconcile) {
989
1053
  * {@link reconcileItemLockAgainstMain} (the recovery's clear, re-checked fresh per
990
1054
  * item) — there is NO parallel clear mechanism.
991
1055
  *
992
- * SCOPE FENCE (the trust model the default preserves):
993
- * - it clears ONLY `cleared-stale`. A `kept-stuck` (terminal + stuck — human
994
- * attention) and a `kept-in-flight` (active + non-terminal — a healthy build)
995
- * are NEVER reaped, even here. Because each clear goes through
996
- * {@link reconcileItemLockAgainstMain}, which RE-reads + RE-classifies before
997
- * deleting, a lock that turned stuck/in-flight between the report and the sweep
998
- * is still safe (reconcile returns `kept-*`, not a delete).
1056
+ * SCOPE FENCE (the trust model the default preserves; broadened by task
1057
+ * `reaper-reap-terminal-stuck-lock-orphans`; ADR
1058
+ * `ledger-status-on-per-item-lock-refs` § Addendum 2026-07-10):
1059
+ * - it clears the TWO terminal-on-`main` ORPHAN classes ONLY: `cleared-stale`
1060
+ * (terminal + `active` = stranded between move and release) AND
1061
+ * `cleared-stuck-terminal` (terminal + `stuck` = crash-orphan the auto-reaper
1062
+ * used to leave forever). A `kept-stuck` (STUCK + NON-terminal — the
1063
+ * genuine human-attention case) and a `kept-in-flight` (`active` +
1064
+ * non-terminal — a healthy build) are NEVER reaped, even here. Because each
1065
+ * clear goes through {@link reconcileItemLockAgainstMain}, which RE-reads +
1066
+ * RE-classifies before deleting, a lock whose item was un-completed on
1067
+ * `main` between the report and the sweep is still safe (reconcile returns
1068
+ * `kept-*`, not a delete).
999
1069
  * - the clear is a LEASED delete: a concurrent change to the ref makes it REJECT
1000
1070
  * (`lost`), reported — never a blind `--force`.
1001
1071
  *
@@ -1012,10 +1082,11 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
1012
1082
  let lost = 0;
1013
1083
  for (const { lock, ref, reconcile } of report.locks) {
1014
1084
  const item = itemFromLockEntry(lock.entry);
1015
- if (reconcile === 'cleared-stale') {
1085
+ if (isReapableTerminalOrphan(reconcile)) {
1016
1086
  // Re-check + clear through the recovery's SHARED leased delete. Reconcile
1017
- // re-reads the live ref, so a lock that turned stuck/in-flight since the
1018
- // report is left alone; a concurrent change to the ref makes the lease lose.
1087
+ // re-reads the live ref, so a lock that turned in-flight since the
1088
+ // report is left alone; a concurrent change to the ref makes the lease
1089
+ // lose.
1019
1090
  const rec = await reconcileItemLockAgainstMain({
1020
1091
  item,
1021
1092
  cwd,
@@ -1027,13 +1098,6 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
1027
1098
  entries.push({ lock, ref, outcome: 'reaped', message: rec.message });
1028
1099
  }
1029
1100
  else if (rec.outcome === 'no-lock') {
1030
- // BENIGN: the ref is already gone — the desired end state. The LOSER of
1031
- // a concurrent double-reap (another reaper deleted the ref between our
1032
- // report and our re-read), or a `release-lock`/`requeue` that cleared
1033
- // the same stale lock in the meantime. NOT a lost lease (the lease was
1034
- // not REJECTED; there was simply nothing left to delete), so this does
1035
- // NOT count as needs-attention. Kept SEPARATE from `reaped` so the
1036
- // summary does not lie about who did the deleting.
1037
1101
  alreadyReaped++;
1038
1102
  entries.push({
1039
1103
  lock,
@@ -1042,24 +1106,17 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
1042
1106
  message: rec.message,
1043
1107
  });
1044
1108
  }
1045
- else if (rec.outcome === 'kept-stuck' ||
1046
- rec.outcome === 'kept-in-flight') {
1047
- // The lock changed between the report and the sweep — no longer stale.
1109
+ else if (rec.outcome === 'kept-in-flight') {
1048
1110
  kept++;
1049
1111
  entries.push({ lock, ref, outcome: rec.outcome, message: rec.message });
1050
1112
  }
1051
1113
  else {
1052
- // A lost lease (the ref was REJECTED because it changed concurrently to
1053
- // a DIFFERENT value) or a per-item error — REPORTED, never forced.
1054
- // Counts as needing attention after the sweep.
1055
1114
  lost++;
1056
1115
  entries.push({ lock, ref, outcome: 'lost', message: rec.message });
1057
1116
  }
1058
1117
  continue;
1059
1118
  }
1060
- // NOT a cleared-stale candidate: a stuck or in-flight lock the reaper must
1061
- // NEVER touch, even with the flag.
1062
- if (reconcile === 'kept-stuck' || reconcile === 'kept-in-flight') {
1119
+ if (reconcile === 'kept-in-flight') {
1063
1120
  kept++;
1064
1121
  entries.push({
1065
1122
  lock,
@@ -1091,15 +1148,11 @@ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
1091
1148
  * {@link itemLockReportNeedsAttention}.
1092
1149
  */
1093
1150
  export function reapReportNeedsAttention(report) {
1094
- // EXIT-CODE CONTRACT (recorded in this task's done record): the reaper exits 0
1095
- // when all stale locks are reaped and only healthy in-flight locks remain;
1096
- // exits 1 when a `kept-stuck` survives or a delete genuinely lost the race /
1097
- // errored. An `already-reaped` (the loser of a concurrent double-reap saw the
1098
- // ref already gone via `no-lock`) is BENIGN the desired end state — and is
1099
- // NOT in this set.
1100
- return report.entries.some((e) => e.outcome === 'kept-stuck' ||
1101
- e.outcome === 'lost' ||
1102
- e.outcome === 'error');
1151
+ // EXIT-CODE CONTRACT: the reaper exits 0 when all stale locks are reaped and
1152
+ // only healthy in-flight locks remain; exits 1 when a delete genuinely lost
1153
+ // the race / errored. An `already-reaped` is BENIGN. Post-`retire-stuck-
1154
+ // lock-state` there is no `kept-stuck` outcome to trip this.
1155
+ return report.entries.some((e) => e.outcome === 'lost' || e.outcome === 'error');
1103
1156
  }
1104
1157
  /**
1105
1158
  * Format the `gc --ledger --reap-stale-locks` sweep for the terminal: a header
@@ -1113,7 +1166,7 @@ export function formatReapReport(report) {
1113
1166
  }
1114
1167
  const lines = [
1115
1168
  `Per-item lock sweep (--reap-stale-locks): reaped ${report.reaped} stale ` +
1116
- `terminal lock(s), kept ${report.kept} (stuck/in-flight, never reaped)` +
1169
+ `lock(s), kept ${report.kept} (in-flight, never reaped)` +
1117
1170
  (report.alreadyReaped > 0
1118
1171
  ? `, ${report.alreadyReaped} already reaped by another sweep (no-lock — benign, the desired end state)`
1119
1172
  : '') +
@@ -1124,14 +1177,14 @@ export function formatReapReport(report) {
1124
1177
  ];
1125
1178
  for (const { lock, outcome, message } of report.entries) {
1126
1179
  const tag = outcome === 'reaped'
1127
- ? '[reaped] '
1180
+ ? '[reaped] '
1128
1181
  : outcome === 'already-reaped'
1129
- ? '[already] '
1182
+ ? '[already] '
1130
1183
  : outcome === 'lost'
1131
- ? '[lost] '
1184
+ ? '[lost] '
1132
1185
  : outcome === 'error'
1133
- ? '[error] '
1134
- : '[kept] ';
1186
+ ? '[error] '
1187
+ : '[kept] ';
1135
1188
  lines.push(` ${tag} ${lock.entry} [${lock.action}/${lock.state}] ${message}`);
1136
1189
  }
1137
1190
  return lines;
@@ -1146,10 +1199,10 @@ export function formatReapReport(report) {
1146
1199
  */
1147
1200
  export function itemFromLockEntry(entry) {
1148
1201
  // The lock-entry prefixes: `spec` is the parent-spec type token (the legacy
1149
- // `prd` token is GONE after the hard cutover), so a `spec-<slug>` lock entry
1202
+ // ''prd'' token is GONE after the hard cutover), so a `spec-<slug>` lock entry
1150
1203
  // round-trips back to its namespaced `spec:<slug>` form (the inverse of
1151
1204
  // `lockEntryFor('spec:<slug>')`).
1152
- for (const prefix of ['task', 'spec', 'observation']) {
1205
+ for (const prefix of CURRENT_ITEM_FORM_PREFIXES) {
1153
1206
  const tag = `${prefix}-`;
1154
1207
  if (entry.startsWith(tag)) {
1155
1208
  return `${prefix}:${entry.slice(tag.length)}`;
@@ -1157,6 +1210,73 @@ export function itemFromLockEntry(entry) {
1157
1210
  }
1158
1211
  return entry;
1159
1212
  }
1213
+ /** The CURRENT lock-entry type prefixes an `<entry>` can reverse-derive to an
1214
+ * item-form (`<type>:<slug>`). After the slice→task / `prd-to-spec` vocabulary
1215
+ * cutover these are `task`/`spec`/`observation` ONLY — a pre-cutover `slice-`/
1216
+ * `prd-` prefix is NOT here, so its entry has NO current item-form. */
1217
+ const CURRENT_ITEM_FORM_PREFIXES = ['task', 'spec', 'observation'];
1218
+ /**
1219
+ * Does `<entry>` reverse-derive to a CURRENT item-form (`<type>:<slug>`), i.e.
1220
+ * does it carry a known post-cutover type prefix (`task-`/`spec-`/`observation-`)?
1221
+ * FALSE for a pre-cutover `slice-<slug>` / `prd-<slug>` entry (task
1222
+ * `release-lock-entry-escape-hatch-and-literal-entry-reporting`): such an entry is
1223
+ * UN-NAMEABLE through the item-form `release-lock <item>` path and must be cleared
1224
+ * via the `release-lock --entry <literal>` escape hatch instead. This is the
1225
+ * predicate the `gc --ledger` report keys off to decide whether to print the
1226
+ * copy-pasteable item-form hint or the literal-`--entry` hint.
1227
+ */
1228
+ export function hasCurrentItemForm(entry) {
1229
+ return CURRENT_ITEM_FORM_PREFIXES.some((p) => entry.startsWith(`${p}-`));
1230
+ }
1231
+ /**
1232
+ * True iff `<arbiter>/main` shows the item SURFACED as a needs-attention
1233
+ * question (PR-2a classifier fold, task
1234
+ * `bounce-atomic-cutover-retire-stuck-lock`, spec
1235
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state`): the item body
1236
+ * carries `needsAnswers: true` in its frontmatter AND a matching sidecar
1237
+ * (`work/questions/<type>-<slug>.md`) exists on `<arbiter>/main`. This is the
1238
+ * on-`main`-authoritative signature of the ordered bounce transition
1239
+ * (surface-to-main FIRST, release SECOND) that a crash BETWEEN steps 1 and 2
1240
+ * leaves behind: the surface commit landed but the lock ref release never ran.
1241
+ *
1242
+ * Best-effort + degrades safely (`false` on any read fault or absent body):
1243
+ * the recovery direction is to DECIDE the lock is NOT surfaced (fall through
1244
+ * to `kept-in-flight`) when we cannot prove the surface — never a
1245
+ * false-positive clear.
1246
+ */
1247
+ async function isItemSurfacedOnMain(type, slug, item, arbiter, cwd, env) {
1248
+ const sidecar = sidecarPathFor(item);
1249
+ const sidecarExists = (await gitSoft(['cat-file', '-e', `${arbiter}/main:${sidecar}`], cwd, env))
1250
+ .status === 0;
1251
+ if (!sidecarExists) {
1252
+ return false;
1253
+ }
1254
+ // Probe the two bounce body-folder candidates (D1 order) for a body carrying
1255
+ // `needsAnswers: true`. Kept in sync with `resolveBounceItemBodyPathOnMain`
1256
+ // in `needs-attention.ts`; duplicated here (a small closed list) to avoid a
1257
+ // cyclic import between `item-lock.ts` and `needs-attention.ts`.
1258
+ const candidates = type === 'task'
1259
+ ? [
1260
+ workItemRel('tasks-ready', `${slug}.md`),
1261
+ workItemRel('tasks-backlog', `${slug}.md`),
1262
+ ]
1263
+ : type === 'spec'
1264
+ ? [
1265
+ workItemRel('specs-ready', `${slug}.md`),
1266
+ workItemRel('specs-proposed', `${slug}.md`),
1267
+ ]
1268
+ : [workItemRel('observations', `${slug}.md`)];
1269
+ for (const path of candidates) {
1270
+ const show = await gitSoft(['show', `${arbiter}/main:${path}`], cwd, env);
1271
+ if (show.status !== 0) {
1272
+ continue;
1273
+ }
1274
+ if (parseFrontmatter(show.stdout).needsAnswers === true) {
1275
+ return true;
1276
+ }
1277
+ }
1278
+ return false;
1279
+ }
1160
1280
  /** True iff `<arbiter>/main` shows the item TERMINAL — any of
1161
1281
  * {@link terminalMainPaths} present in `<arbiter>/main`'s tree. */
1162
1282
  async function isTerminalOnMain(type, slug, arbiter, cwd, env) {
@@ -1233,7 +1353,7 @@ export async function listItemLockEntries(cwd, arbiter = 'origin', env) {
1233
1353
  * List the TASK slugs currently lock-held on the arbiter — the held-slug set the
1234
1354
  * `ready/` pool readers SUBTRACT (spec `ledger-status-per-item-lock-refs` US #15;
1235
1355
  * task `claim-acquires-unified-lock-no-body-move`). Enumerates {@link listItemLocks}
1236
- * and keeps only the `task-<slug>` entries (a prd/observation lock does not gate
1356
+ * and keeps only the `task-<slug>` entries (a spec/observation lock does not gate
1237
1357
  * the TASK pool), mapping each to its bare `<slug>`.
1238
1358
  *
1239
1359
  * LOAD-BEARING since the lock cut-over: the claim NO LONGER moves the body to
@@ -1283,11 +1403,13 @@ export async function heldTaskSlugsStrict(cwd, arbiter = 'origin', env) {
1283
1403
  }
1284
1404
  /**
1285
1405
  * Parse a serialised lock entry body back into a {@link LockEntry} — the exact
1286
- * inverse of {@link serialiseLockEntry}. Reads the two-axis state from the
1287
- * frontmatter and, for a stuck entry, the FULL reason prose + any questions from
1288
- * the body (`## Reason` / `## Questions`). Tolerates a LEGACY entry whose reason
1289
- * lived in a one-line frontmatter `reason:` field (the pre-cutover shape) so a
1290
- * lock written by an older binary still reads.
1406
+ * inverse of {@link serialiseLockEntry}. Post-`retire-stuck-lock-state` a lock
1407
+ * entry whose serialised state is not the single admitted `'active'` value
1408
+ * (e.g. a legacy `state: stuck` blob written by an older binary before the
1409
+ * one-shot migration ran) is COERCED to `'active'` on read the state is
1410
+ * degenerate, and a lingering stuck ref is left for the migration/recovery
1411
+ * verbs to clear via `main`-authoritative reconciliation rather than surfaced
1412
+ * as a second live state.
1291
1413
  */
1292
1414
  export function parseLockEntry(body) {
1293
1415
  const normalized = body.replace(/\r\n/g, '\n');
@@ -1305,60 +1427,13 @@ export function parseLockEntry(body) {
1305
1427
  if (!fields.entry || !fields.action || !fields.state) {
1306
1428
  return undefined;
1307
1429
  }
1308
- const bodyText = normalized.slice(fm[0].length);
1309
- const reason = extractBodyReason(bodyText) ?? fields.reason;
1310
- const questions = extractBodyQuestions(bodyText);
1311
- const entry = {
1430
+ return {
1312
1431
  entry: fields.entry,
1313
1432
  action: fields.action,
1314
- state: fields.state,
1433
+ state: 'active',
1315
1434
  holder: fields.holder ?? '',
1316
1435
  since: fields.since ?? '',
1317
1436
  };
1318
- if (reason !== undefined && reason !== '') {
1319
- entry.reason = reason;
1320
- }
1321
- if (questions.length > 0) {
1322
- entry.questions = questions;
1323
- }
1324
- return entry;
1325
- }
1326
- /** Extract the `## Reason` block's prose (joined multi-line), or undefined. */
1327
- function extractBodyReason(bodyText) {
1328
- const lines = bodyText.split('\n');
1329
- const start = lines.findIndex((l) => l.trim() === LOCK_REASON_HEADING);
1330
- if (start === -1) {
1331
- return undefined;
1332
- }
1333
- const collected = [];
1334
- for (let i = start + 1; i < lines.length; i++) {
1335
- if (/^##\s/.test(lines[i])) {
1336
- break;
1337
- }
1338
- collected.push(lines[i]);
1339
- }
1340
- // Trim leading/trailing blank lines but PRESERVE interior newlines (rich prose).
1341
- const text = collected.join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
1342
- return text === '' ? undefined : text;
1343
- }
1344
- /** Extract the `## Questions` block's bulleted list, or [] when absent. */
1345
- function extractBodyQuestions(bodyText) {
1346
- const lines = bodyText.split('\n');
1347
- const start = lines.findIndex((l) => l.trim() === LOCK_QUESTIONS_HEADING);
1348
- if (start === -1) {
1349
- return [];
1350
- }
1351
- const questions = [];
1352
- for (let i = start + 1; i < lines.length; i++) {
1353
- if (/^##\s/.test(lines[i])) {
1354
- break;
1355
- }
1356
- const m = /^-\s+(.*)$/.exec(lines[i].trim());
1357
- if (m) {
1358
- questions.push(m[1]);
1359
- }
1360
- }
1361
- return questions;
1362
1437
  }
1363
1438
  /**
1364
1439
  * Advisory holder id: git user.name, else $USER, else a uuid fragment. Exported