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
@@ -9,7 +9,10 @@ import {
9
9
  isWorkItemFile,
10
10
  } from './work-layout.js';
11
11
  import {run, runAsync, type RunResult} from './git.js';
12
- import {branchAheadOf} from './continue-branch.js';
12
+ import {
13
+ branchAheadOf,
14
+ rebaseContinuedBranchOntoMain,
15
+ } from './continue-branch.js';
13
16
  import {
14
17
  acquireItemLock,
15
18
  releaseItemLock,
@@ -21,6 +24,17 @@ import {
21
24
  } from './item-lock.js';
22
25
  import {ledgerWrite, type LedgerTransitionKind} from './ledger-write.js';
23
26
  import {workBranchRef} from './slug-namespace.js';
27
+ import {
28
+ appendQuestions,
29
+ newSidecar,
30
+ parseSidecar,
31
+ resolveSidecarIdentity,
32
+ serialiseSidecar,
33
+ sidecarPathFor,
34
+ type NewQuestion,
35
+ type SidecarType,
36
+ } from './sidecar.js';
37
+ import {parseFrontmatter, setNeedsAnswersMarker} from './frontmatter.js';
24
38
  import {
25
39
  retryWithBackoff,
26
40
  realSleep,
@@ -140,11 +154,12 @@ export interface RouteToNeedsAttentionResult {
140
154
  /** When the branch push FAILED after retries, the last git error (for the report). */
141
155
  pushError?: string;
142
156
  /**
143
- * When `moved`, the sha of the **move-only** commit the tip of `work/<slug>`
144
- * that carries PURELY the `git mv needs-attention/` + the reason (the wip
145
- * commit holding the aborted agent work sits BELOW it). A surfacing strategy
146
- * cherry-picks THIS commit to make the stuck state observable, so the wip never
147
- * reaches the ledger.
157
+ * When `moved`, the sha of the `work/<slug>` tip after the RECOVERABLE-half
158
+ * save. Post lock-cutover this is the **wip** commit holding the aborted agent
159
+ * work (`git add -A`); there is no separate `git mv needs-attention/`
160
+ * move-only commit anymore (that folder is retired). The OBSERVABLE stuck
161
+ * state rides on the per-item lock amend (`state: stuck` + reason), not on this
162
+ * commit.
148
163
  */
149
164
  moveCommit?: string;
150
165
  /** When NOT moved, why (e.g. the slug was not in-progress). */
@@ -155,8 +170,9 @@ export interface ReturnToBacklogOptions {
155
170
  /** The working clone the `work/` tree lives in. */
156
171
  cwd: string;
157
172
  /**
158
- * The slug of the stuck item to re-queue — recovered from `needs-attention/`
159
- * OR `in-progress/` (the actual current folder is resolved on the arbiter).
173
+ * The slug of the stuck item to re-queue — recovered via its per-item lock on
174
+ * the arbiter (post lock-cutover the body never moves into a status folder; it
175
+ * rests in `backlog/` and stuck is the lock `state: stuck`).
160
176
  */
161
177
  slug: string;
162
178
  /** The arbiter remote to push the transition to. Optional (see above). */
@@ -176,6 +192,29 @@ export interface ReturnToBacklogOptions {
176
192
  * path.
177
193
  */
178
194
  reset?: boolean;
195
+ /**
196
+ * `requeue --reconcile` (the NON-DESTRUCTIVE recovery verb — the middle rung
197
+ * of the escalation ladder between the default keep+continue and the
198
+ * destructive `--reset`). When the item's per-item lock is held stuck AND the
199
+ * arbiter's `work/<slug>` branch EXISTS and is ahead of main, re-sync the
200
+ * mirror to the arbiter (a prune-fetch that clears the stale-ref residue that
201
+ * historically silently resurrected a supposedly-`--reset`-ed branch) and
202
+ * RETRY the rebase of the kept branch onto latest `<arbiter>/main` in a
203
+ * SCRATCH worktree (never touching the caller's tree, `--force-with-lease`
204
+ * only, NEVER a bare force). On a clean rebase, the reconciled tip is
205
+ * pushed back to the arbiter with a lease + the reconcile completes as a
206
+ * standard keep+continue requeue (lock released, branch preserved). On a
207
+ * genuine content conflict after the clean mirror re-sync, the lock is LEFT
208
+ * HELD (item stays stuck), the branch is LEFT UNTOUCHED on the arbiter
209
+ * (NEVER deleted), and the caller is told the retry happened + pointed at the
210
+ * deferred mirror-side resolve follow-on + `--reset` as the destructive last
211
+ * resort. If the branch is ABSENT (never pushed, or a prior `--reset` already
212
+ * deleted it), reconcile falls through to the default keep+continue path
213
+ * (which itself degrades gracefully to a fresh-claim move). Incompatible
214
+ * with `--reset` (destructive vs non-destructive are exclusive verbs on the
215
+ * same escalation).
216
+ */
217
+ reconcile?: boolean;
179
218
  /**
180
219
  * `requeue -m "<note>"` (the handoff note): an optional human steer for the
181
220
  * NEXT agent. APPENDED (never overwritten) as a dated `## Requeue YYYY-MM-DD`
@@ -198,7 +237,15 @@ export interface ReturnToBacklogResult {
198
237
  commitMessage?: string;
199
238
  /** True iff `--reset` deleted the remote `work/<slug>` branch on the arbiter. */
200
239
  deletedRemoteBranch?: boolean;
201
- /** When NOT moved, why (e.g. the slug was in neither needs-attention/ nor in-progress/, or a failed --reset delete). */
240
+ /**
241
+ * True iff `--reconcile` re-synced the mirror + rebased the kept branch onto
242
+ * latest `<arbiter>/main` and pushed the reconciled tip back to the arbiter
243
+ * (before the lock release). Absent on the default path / on `--reset` / on
244
+ * a reconcile that fell through (branch absent) — for those, the ordinary
245
+ * keep+continue path ran with no rebase.
246
+ */
247
+ reconciled?: boolean;
248
+ /** When NOT moved, why (e.g. the slug held no recoverable per-item lock on the arbiter, or a failed --reset delete). */
202
249
  reasonNotMoved?: string;
203
250
  }
204
251
 
@@ -358,7 +405,7 @@ export async function routeToNeedsAttention(
358
405
 
359
406
  /**
360
407
  * The clean re-queue (ADR §12 / WORK-CONTRACT return path): once the human has
361
- * resolved the cause, move the stuck item back to `work/backlog/<slug>.md` and
408
+ * resolved the cause, move the stuck item back to `work/tasks/ready/<slug>.md` and
362
409
  * commit it so the item can be re-claimed (it must not rot stuck). It recovers a
363
410
  * task stuck in EITHER `work/needs-attention/<slug>.md` (the resolved-surface
364
411
  * path) OR `work/in-progress/<slug>.md` (a claim that never surfaced — an
@@ -374,12 +421,26 @@ export async function routeToNeedsAttention(
374
421
  * it is the durable artifact the next claim CONTINUES from (the continue-
375
422
  * detection in `continue-branch.ts` feeds both onboarding paths). This
376
423
  * function only does the ledger move.
377
- * - **`--reset` = DISCARD + FRESH.** When `reset` is set, DELETE the remote
378
- * `work/<slug>` branch on `arbiter` FIRST (+ drop any stale local branch),
379
- * THEN the backlog move. Delete-before-move closes the claim-race window; a
380
- * FAILED delete ABORTS (no backlog move) so the item stays in
381
- * needs-attention. The next claim then finds NO arbiter branch and cuts
382
- * fresh no special claim-time logic.
424
+ * - **`--reconcile` = NON-DESTRUCTIVE RECOVERY (middle rung).** When
425
+ * `reconcile` is set AND the arbiter's `work/<slug>` exists + is ahead of
426
+ * main, re-sync the hub mirror to the arbiter (a prune-fetch that clears
427
+ * the stale-ref residue that historically resurrected a supposedly
428
+ * `--reset`-ed branch) and RETRY the rebase of the kept branch onto latest
429
+ * `<arbiter>/main` in a SCRATCH worktree (never touching the caller's
430
+ * tree, `--force-with-lease` only, NEVER a bare force). Clean rebase =>
431
+ * push the reconciled tip back to the arbiter and fall through to the
432
+ * standard keep+continue path (lock released, branch preserved). Genuine
433
+ * content conflict AFTER the clean mirror re-sync => item stays stuck,
434
+ * branch UNTOUCHED (NEVER deleted), message references the deferred
435
+ * mirror-side resolve follow-on and mentions `--reset` LAST as the
436
+ * destructive last resort. Branch absent / not-ahead => fall through to
437
+ * the default keep+continue path (which handles the fresh-claim case).
438
+ * - **`--reset` = DISCARD + FRESH (destructive last resort).** When `reset`
439
+ * is set, DELETE the remote `work/<slug>` branch on `arbiter` FIRST (+ drop
440
+ * any stale local branch), THEN the backlog move. Delete-before-move
441
+ * closes the claim-race window; a FAILED delete ABORTS (no backlog move)
442
+ * so the item stays in needs-attention. The next claim then finds NO
443
+ * arbiter branch and cuts fresh — no special claim-time logic.
383
444
  * - **`-m "<note>"` = HANDOFF NOTE.** When `message` is set, APPEND a dated
384
445
  * `## Requeue YYYY-MM-DD` section to the item BODY (append-only; accumulates
385
446
  * over repeated requeues) for the next agent. Applies to BOTH modes.
@@ -406,6 +467,92 @@ async function readLocalItemLock(
406
467
  return parseLockEntry(show.stdout);
407
468
  }
408
469
 
470
+ /**
471
+ * Options the shared {@link deleteRemoteWorkBranchIfPresent} primitive consumes.
472
+ */
473
+ export interface DeleteRemoteWorkBranchOptions {
474
+ /** Working clone whose remote `arbiter` points at the real arbiter. */
475
+ cwd: string;
476
+ /** The arbiter remote NAME in `cwd`. */
477
+ arbiter: string;
478
+ /** Bare slug (the branch is `work/task-<slug>`). */
479
+ slug: string;
480
+ /** Environment for child git processes. */
481
+ env?: NodeJS.ProcessEnv;
482
+ }
483
+
484
+ /** The three terminal states {@link deleteRemoteWorkBranchIfPresent} can report. */
485
+ export type DeleteRemoteWorkBranchStatus =
486
+ /** The push --delete succeeded; the arbiter branch is gone. */
487
+ | 'deleted'
488
+ /**
489
+ * The branch was already absent from the arbiter (`remote ref does not
490
+ * exist` / `unable to delete`). Local refs may still have been cleaned up.
491
+ * SEMANTICALLY equivalent to `deleted` for callers who only care that the
492
+ * branch is not on the arbiter afterwards — idempotent no-op.
493
+ */
494
+ | 'already-gone'
495
+ /** The push --delete failed for a reason OTHER than already-gone; caller decides. */
496
+ | 'failed';
497
+
498
+ /** The outcome {@link deleteRemoteWorkBranchIfPresent} reports. */
499
+ export interface DeleteRemoteWorkBranchResult {
500
+ /** The `work/<type>-<slug>` branch ref name that was targeted. */
501
+ branch: string;
502
+ /** See {@link DeleteRemoteWorkBranchStatus}. */
503
+ status: DeleteRemoteWorkBranchStatus;
504
+ /** The stderr from the failed `push --delete` (`''` on success/already-gone). */
505
+ stderr: string;
506
+ }
507
+
508
+ /**
509
+ * Delete the remote `work/task-<slug>` branch on the arbiter using the SAME
510
+ * write-through ordering the `requeue --reset` recovery verb uses:
511
+ *
512
+ * 1. Delete the LOCAL tracking ref (`refs/remotes/<arbiter>/work/<...>`)
513
+ * — the ref the continue-detection path READS; if we skip this the
514
+ * staleness silently resurrects a supposedly-discarded branch (verified in
515
+ * `work/notes/observations/requeue-reset-does-not-prune-hub-mirror-stale-branch-ref.md`).
516
+ * 2. Delete any LOCAL head `work/<...>` (best-effort).
517
+ * 3. `git push <arbiter> --delete work/<...>` — the ARBITER delete, the
518
+ * source of truth. A `remote ref does not exist` / `unable to delete`
519
+ * stderr is TOLERATED as `already-gone` so the primitive is IDEMPOTENT
520
+ * and safely callable on an item with no work branch (an observation, or
521
+ * a task never built).
522
+ *
523
+ * Extracted from {@link returnToBacklog}'s `--reset` path so the apply-rung
524
+ * `kind: 'stuck'` answered `reset` verb (task
525
+ * `apply-resolve-reset-flag-discards-work-branch`) can dispatch through the
526
+ * SAME primitive without re-implementing branch deletion — the two callers
527
+ * MUST stay behaviourally identical (delete-before-move / delete-before-clear;
528
+ * local-first write-through; already-gone tolerance).
529
+ */
530
+ export async function deleteRemoteWorkBranchIfPresent(
531
+ options: DeleteRemoteWorkBranchOptions,
532
+ ): Promise<DeleteRemoteWorkBranchResult> {
533
+ const {cwd, arbiter, slug, env} = options;
534
+ const branch = workBranchRef('task', slug);
535
+ await gitSoftAsync(
536
+ ['update-ref', '-d', `refs/remotes/${arbiter}/${branch}`],
537
+ cwd,
538
+ env,
539
+ );
540
+ await gitSoftAsync(['branch', '-D', branch], cwd, env);
541
+ const del = await gitSoftAsync(
542
+ ['push', arbiter, '--delete', branch],
543
+ cwd,
544
+ env,
545
+ );
546
+ if (del.status === 0) {
547
+ return {branch, status: 'deleted', stderr: ''};
548
+ }
549
+ const stderr = del.stderr.trim();
550
+ if (/remote ref does not exist|unable to delete/i.test(stderr)) {
551
+ return {branch, status: 'already-gone', stderr};
552
+ }
553
+ return {branch, status: 'failed', stderr};
554
+ }
555
+
409
556
  export async function returnToBacklog(
410
557
  options: ReturnToBacklogOptions,
411
558
  ): Promise<ReturnToBacklogResult> {
@@ -476,6 +623,67 @@ export async function returnToBacklog(
476
623
  };
477
624
  }
478
625
 
626
+ // `--reconcile`: the NON-DESTRUCTIVE recovery rung (task
627
+ // `requeue-reconcile-nondestructive-recovery-verb`, parent observation
628
+ // `rebase-conflict-on-continue-needs-nondestructive-recovery-not-reset`).
629
+ // When the kept `work/<slug>` exists + is ahead of `<arbiter>/main`, re-sync
630
+ // the mirror (prune-fetch — the exact step whose absence let
631
+ // `requeue-reset-does-not-prune-hub-mirror-stale-branch-ref` silently
632
+ // resurrect a stale branch) and RETRY the rebase in a SCRATCH worktree. Clean
633
+ // rebase => push the reconciled tip back (`--force-with-lease`, never bare
634
+ // force, WORK branch only — ADR §11) and fall through to the standard
635
+ // keep+continue path. Genuine content conflict AFTER the clean re-sync =>
636
+ // return with a message that LEADS with what happened, references the
637
+ // deferred mirror-side resolve path, and mentions `--reset` LAST as the
638
+ // destructive last resort. Branch absent / not-ahead => fall through to the
639
+ // default keep+continue path (which handles the fresh-claim case). NEVER
640
+ // deletes the remote branch — this verb's contract is "keep the work".
641
+ let reconciled: boolean | undefined;
642
+ if (options.reconcile) {
643
+ if (options.reset) {
644
+ return {
645
+ moved: false,
646
+ reasonNotMoved:
647
+ `requeue for '${slug}': --reconcile and --reset are mutually ` +
648
+ 'exclusive (non-destructive recovery vs destructive last resort). ' +
649
+ 'Pick one.',
650
+ };
651
+ }
652
+ const attempt = await attemptReconcile({
653
+ cwd,
654
+ slug,
655
+ arbiter,
656
+ env,
657
+ note,
658
+ });
659
+ if (attempt.kind === 'conflict') {
660
+ const branch = workBranchRef('task', slug);
661
+ const message =
662
+ `requeue --reconcile for '${slug}': re-synced the ${arbiter} mirror ` +
663
+ `and RETRIED the rebase of ${branch} onto latest ${arbiter}/main, but ` +
664
+ `the rebase still conflicts on genuine content (${attempt.detail}). The ` +
665
+ 'kept branch is left UNTOUCHED on the arbiter (nothing deleted) and the ' +
666
+ 'item is left stuck. A supported mirror-side "resolve against latest ' +
667
+ 'main" command that fetches the kept branch into a scratch worktree, ' +
668
+ 'rebases, and re-pushes is planned but not yet built (see observation ' +
669
+ '`rebase-conflict-on-continue-needs-nondestructive-recovery-not-reset`, ' +
670
+ 'point 2 of its LIVE residue). LAST RESORT: `requeue --reset` ' +
671
+ 'DESTRUCTIVELY discards the branch and starts fresh.';
672
+ note(message);
673
+ return {moved: false, reasonNotMoved: message};
674
+ }
675
+ if (attempt.kind === 'reconciled') {
676
+ reconciled = true;
677
+ note(
678
+ `Reconciled '${slug}': re-synced the ${arbiter} mirror, rebased the ` +
679
+ `kept ${workBranchRef('task', slug)} onto latest ${arbiter}/main, and ` +
680
+ 'pushed the reconciled tip back (non-destructive; branch preserved).',
681
+ );
682
+ }
683
+ // 'no-branch' => fall through to the default keep+continue path (branch
684
+ // absent / not ahead — the existing default guard handles both cases).
685
+ }
686
+
479
687
  // `--reset`: DELETE the remote work branch (before the backlog move). The
480
688
  // deletion is WRITE-THROUGH: the LOCAL refs that drive continue-detection
481
689
  // (`refs/remotes/<arbiter>/work/<slug>` AND any local head `work/<slug>`) are
@@ -489,49 +697,29 @@ export async function returnToBacklog(
489
697
  // continue. Delete-before-move also closes the claim-race window.
490
698
  let deletedRemoteBranch = false;
491
699
  if (options.reset) {
492
- const branch = workBranchRef('task', slug);
493
- // LOCAL-FIRST: the tracking ref `branchAheadOf` reads (the one whose
494
- // staleness today silently turns `--reset` into a no-op — verified live in
495
- // `work/notes/observations/requeue-reset-does-not-prune-hub-mirror-stale-branch-ref.md`,
496
- // where `--reset` deleted the arbiter branch but the local tracking ref
497
- // survived and resurrected a "continue" on the next `do`). Both deletes
498
- // are best-effort — their absence is fine, what matters is they are not
499
- // LEFT BEHIND when the arbiter delete succeeds.
500
- await gitSoftAsync(
501
- ['update-ref', '-d', `refs/remotes/${arbiter}/${branch}`],
502
- cwd,
503
- env,
504
- );
505
- await gitSoftAsync(['branch', '-D', branch], cwd, env);
506
- // THEN the arbiter delete (explicit/guarded departure from the "never delete
507
- // the remote branch" invariant; only on the `--reset` path, never the
508
- // default).
509
- const del = await gitSoftAsync(
510
- ['push', arbiter, '--delete', branch],
700
+ const dropped = await deleteRemoteWorkBranchIfPresent({
511
701
  cwd,
702
+ arbiter,
703
+ slug,
512
704
  env,
513
- );
514
- if (del.status !== 0) {
515
- const stderr = del.stderr.trim();
516
- // Tolerate "remote ref does not exist" (already gone): treat as deleted.
517
- const alreadyGone = /remote ref does not exist|unable to delete/i.test(
518
- stderr,
519
- );
520
- if (!alreadyGone) {
521
- const message =
522
- `requeue --reset for '${slug}': failed to delete the remote branch ` +
523
- `${branch} on ${arbiter} (${stderr || 'unknown error'}); ` +
524
- 'aborting the requeue — item left in needs-attention (no backlog move). ' +
525
- 'The local tracking ref was already cleared (write-through ordering); ' +
526
- 'a subsequent fetch will restore it from the arbiter — the local store ' +
527
- 'is BEHIND the arbiter (self-healing), never AHEAD (which would drive a ' +
528
- 'stale continue).';
529
- note(message);
530
- return {moved: false, reasonNotMoved: message};
531
- }
705
+ });
706
+ if (dropped.status === 'failed') {
707
+ const stderr = dropped.stderr;
708
+ const message =
709
+ `requeue --reset for '${slug}': failed to delete the remote branch ` +
710
+ `${dropped.branch} on ${arbiter} (${stderr || 'unknown error'}); ` +
711
+ 'aborting the requeue — item left in needs-attention (no backlog move). ' +
712
+ 'The local tracking ref was already cleared (write-through ordering); ' +
713
+ 'a subsequent fetch will restore it from the arbiter — the local store ' +
714
+ 'is BEHIND the arbiter (self-healing), never AHEAD (which would drive a ' +
715
+ 'stale continue).';
716
+ note(message);
717
+ return {moved: false, reasonNotMoved: message};
532
718
  }
533
719
  deletedRemoteBranch = true;
534
- note(`Deleted the remote branch ${branch} on ${arbiter} (--reset).`);
720
+ note(
721
+ `Deleted the remote branch ${dropped.branch} on ${arbiter} (--reset).`,
722
+ );
535
723
  }
536
724
 
537
725
  // DEFAULT (keep+continue) REQUEUE-SAFETY GUARD: a claimable item's continue-
@@ -543,20 +731,45 @@ export async function returnToBacklog(
543
731
  // discards the branch by design).
544
732
  if (!options.reset) {
545
733
  const branch = workBranchRef('task', slug);
546
- const onArbiter = branchAheadOf(
734
+ // Split the guard into TWO cases (task
735
+ // `default-requeue-succeeds-when-no-work-branch-exists`):
736
+ // (a) the arbiter branch does NOT EXIST at all (never pushed, or a prior
737
+ // `--reset` already deleted it) — there is NO continue-branch a future
738
+ // worker would resume from, so the guard's precondition is vacuously
739
+ // satisfied. Degrade gracefully to the same effective outcome as
740
+ // `--reset` (nothing to discard) and proceed with the keep+continue
741
+ // backlog move: no arbiter delete (there is nothing to delete), no
742
+ // forcing the caller into the destructive `--reset` verb.
743
+ // (b) the arbiter branch EXISTS but is NOT ahead of `<arbiter>/main` — a
744
+ // real anomaly (the continue-branch would resume from a state already
745
+ // reachable from main). Preserve today's refusal so the case surfaces.
746
+ const tip = gitSoftRun(
747
+ ['rev-parse', '--verify', '--quiet', `${arbiter}/${branch}^{commit}`],
547
748
  cwd,
548
- `${arbiter}/${branch}`,
549
- `${arbiter}/main`,
550
749
  env,
551
750
  );
552
- if (!onArbiter) {
553
- const message =
554
- `the work branch ${branch} isn't on ${arbiter} (the continue ` +
555
- `branch a cross-machine worker would resume from)push it first, or ` +
556
- '`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
557
- 'released).';
558
- note(message);
559
- return {moved: false, reasonNotMoved: message};
751
+ const arbiterBranchExists = tip.status === 0 && tip.stdout.trim() !== '';
752
+ if (!arbiterBranchExists) {
753
+ note(
754
+ `'${slug}' has no work branch on ${arbiter}requeueing to backlog ` +
755
+ 'for a FRESH claim (nothing to continue from; no --reset needed).',
756
+ );
757
+ } else {
758
+ const onArbiter = branchAheadOf(
759
+ cwd,
760
+ `${arbiter}/${branch}`,
761
+ `${arbiter}/main`,
762
+ env,
763
+ );
764
+ if (!onArbiter) {
765
+ const message =
766
+ `the work branch ${branch} isn't on ${arbiter} (the continue ` +
767
+ `branch a cross-machine worker would resume from) — push it first, or ` +
768
+ '`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
769
+ 'released).';
770
+ note(message);
771
+ return {moved: false, reasonNotMoved: message};
772
+ }
560
773
  }
561
774
  }
562
775
 
@@ -648,7 +861,130 @@ export async function returnToBacklog(
648
861
  note(
649
862
  `Returned '${slug}' to backlog (released the lock; body rests in pool).`,
650
863
  );
651
- return {moved: true, commitMessage, deletedRemoteBranch};
864
+ return {moved: true, commitMessage, deletedRemoteBranch, reconciled};
865
+ }
866
+
867
+ /**
868
+ * The outcome of an in-progress `--reconcile` attempt (task
869
+ * `requeue-reconcile-nondestructive-recovery-verb`):
870
+ * - `'reconciled'` — mirror re-synced, rebase onto latest `<arbiter>/main`
871
+ * was clean AND the reconciled tip was pushed back to the arbiter. The
872
+ * caller falls through to the default keep+continue path.
873
+ * - `'no-branch'` — the arbiter's `work/<slug>` is absent or not ahead of
874
+ * main after the re-sync. Nothing to reconcile; the caller falls through
875
+ * to the default keep+continue path (which itself handles the fresh-claim
876
+ * case gracefully).
877
+ * - `'conflict'` — the rebase after the CLEAN mirror re-sync still
878
+ * conflicted on genuine content, or the reconciled-tip push failed. The
879
+ * branch is left untouched on the arbiter; the caller returns a stuck
880
+ * message that leads with what was tried and mentions `--reset` LAST.
881
+ */
882
+ type ReconcileAttempt =
883
+ | {kind: 'reconciled'}
884
+ | {kind: 'no-branch'}
885
+ | {kind: 'conflict'; detail: string};
886
+
887
+ /**
888
+ * The `--reconcile` recovery attempt — the non-destructive middle rung of the
889
+ * `requeue` escalation ladder. Runs in a SCRATCH worktree so the caller's cwd
890
+ * tree/HEAD/index is NEVER touched (parity with the tree-less move machinery):
891
+ *
892
+ * 1. **Re-sync the mirror.** `git fetch --prune <arbiter>` on the caller's
893
+ * cwd — the exact prune step whose absence let
894
+ * `requeue-reset-does-not-prune-hub-mirror-stale-branch-ref` silently
895
+ * resurrect a supposedly-`--reset`-ed branch. This clears the stale
896
+ * remote-tracking residue that historically fooled the retry.
897
+ * 2. **Guard.** The arbiter's `work/<slug>` must EXIST + be ahead of main
898
+ * (via {@link branchAheadOf} on the freshly-fetched
899
+ * `<arbiter>/work/<slug>` vs `<arbiter>/main`). Absent / not-ahead =>
900
+ * `'no-branch'` and the caller falls through to the default keep+continue
901
+ * path.
902
+ * 3. **Rebase in a scratch worktree.** `git worktree add --detach <scratch>
903
+ * <arbiter>/work/<slug>` and run {@link rebaseContinuedBranchOntoMain}
904
+ * against `<arbiter>/main`. A CLEAN rebase is a full non-destructive fix;
905
+ * a CONFLICT is `--abort`ed (never auto-resolved) => `'conflict'`.
906
+ * 4. **Push the reconciled tip back.** `git push <arbiter> HEAD:work/<slug>
907
+ * --force-with-lease=work/<slug>:<observed-arbiter-tip>` from the scratch
908
+ * worktree. `--force-with-lease` ONLY, NEVER bare `--force`, NEVER
909
+ * `:main`, the WORK branch ONLY (ADR §11). A rejected push (stale lease
910
+ * or otherwise) => `'conflict'` — non-destructive by construction, the
911
+ * user can retry.
912
+ *
913
+ * Cleanup of the scratch worktree is best-effort in a `finally` (never fails
914
+ * the reconcile on cleanup).
915
+ */
916
+ async function attemptReconcile(params: {
917
+ cwd: string;
918
+ slug: string;
919
+ arbiter: string;
920
+ env: NodeJS.ProcessEnv | undefined;
921
+ note: (message: string) => void;
922
+ }): Promise<ReconcileAttempt> {
923
+ const {cwd, slug, arbiter, env, note} = params;
924
+ const branch = workBranchRef('task', slug);
925
+ const arbBranchRef = `refs/remotes/${arbiter}/${branch}`;
926
+ const arbMainRef = `refs/remotes/${arbiter}/main`;
927
+
928
+ // 1. Re-sync the mirror to the arbiter (prune-fetch clears stale refs).
929
+ await gitSoftAsync(['fetch', '--prune', '--quiet', arbiter], cwd, env);
930
+
931
+ // 2. Guard: branch must exist + be ahead of main.
932
+ if (!branchAheadOf(cwd, arbBranchRef, arbMainRef, env)) {
933
+ return {kind: 'no-branch'};
934
+ }
935
+
936
+ // 3. Scratch worktree — the caller's tree is NEVER touched.
937
+ const worktree = join(
938
+ tmpdir(),
939
+ `dorfl-reconcile-${slug}-${process.pid}-${Date.now()}`,
940
+ );
941
+ const wtCreate = gitSoftRun(
942
+ ['worktree', 'add', '--quiet', '--detach', worktree, arbBranchRef],
943
+ cwd,
944
+ env,
945
+ );
946
+ if (wtCreate.status !== 0) {
947
+ return {
948
+ kind: 'conflict',
949
+ detail: `could not create scratch worktree (${wtCreate.stderr.trim() || `exit ${wtCreate.status}`})`,
950
+ };
951
+ }
952
+ try {
953
+ const rebase = rebaseContinuedBranchOntoMain(worktree, arbMainRef, env);
954
+ if (rebase.kind === 'conflict') {
955
+ return {kind: 'conflict', detail: 'rebase conflicted after re-sync'};
956
+ }
957
+ // 4. Push the reconciled tip back (`--force-with-lease`, WORK branch only).
958
+ const observedTip = gitSoftRun(
959
+ ['rev-parse', '--verify', '--quiet', `${arbBranchRef}^{commit}`],
960
+ cwd,
961
+ env,
962
+ ).stdout.trim();
963
+ const lease =
964
+ observedTip === '' ? `${branch}:` : `${branch}:${observedTip}`;
965
+ const push = gitSoftRun(
966
+ [
967
+ 'push',
968
+ arbiter,
969
+ `HEAD:refs/heads/${branch}`,
970
+ `--force-with-lease=${lease}`,
971
+ ],
972
+ worktree,
973
+ env,
974
+ );
975
+ if (push.status !== 0) {
976
+ return {
977
+ kind: 'conflict',
978
+ detail: `push of reconciled tip rejected (${push.stderr.trim() || `exit ${push.status}`})`,
979
+ };
980
+ }
981
+ // Advance the local remote-tracking ref so subsequent reads see the truth.
982
+ await gitSoftAsync(['fetch', '--quiet', arbiter], cwd, env);
983
+ void note;
984
+ return {kind: 'reconciled'};
985
+ } finally {
986
+ await gitSoftAsync(['worktree', 'remove', '--force', worktree], cwd, env);
987
+ }
652
988
  }
653
989
 
654
990
  /**
@@ -656,13 +992,13 @@ export async function returnToBacklog(
656
992
  * `staging-pool-position-gate-and-trust-model`, task
657
993
  * `pre-backlog-staging-folder-and-promote-step-a`, governing ADR
658
994
  * `placement-is-runner-deterministic-humanonly-is-agent-judgement`). Moves
659
- * `work/pre-backlog/<slug>.md → work/backlog/<slug>.md` as a durable `main`
995
+ * `work/tasks/backlog/<slug>.md → work/tasks/ready/<slug>.md` as a durable `main`
660
996
  * move, the same category as {@link returnToBacklog} (tree-less CAS via
661
997
  * {@link runTreelessLedgerMove}). After this transition the task is in the
662
998
  * pool and claimable.
663
999
  *
664
1000
  * **RUNNER/human-owned.** There is no agent-facing path that performs this:
665
- * the agent's tasking output lands STAGED in `work/pre-backlog/` (the runner's
1001
+ * the agent's tasking output lands STAGED in `work/tasks/backlog/` (the runner's
666
1002
  * deterministic placement decision), and only a runner/human invocation moves
667
1003
  * it into the pool. The agent does no git here, as everywhere.
668
1004
  *
@@ -670,7 +1006,7 @@ export async function returnToBacklog(
670
1006
  * lands; the sole strategy publishes to `<arbiter>/main`. Like
671
1007
  * {@link returnToBacklog} the tree-less CAS needs a ref to push to, so an
672
1008
  * `arbiter` is REQUIRED. NEVER throws for the expected
673
- * "not in pre-backlog/" / contention-exhausted cases — it returns
1009
+ * "not in tasks/backlog/" / contention-exhausted cases — it returns
674
1010
  * `{moved: false, reasonNotMoved}` so callers can branch cleanly.
675
1011
  */
676
1012
  export interface PromoteFromPreBacklogOptions {
@@ -699,7 +1035,7 @@ export interface PromoteFromPreBacklogResult {
699
1035
  moved: boolean;
700
1036
  /** When `moved`, the committed transition message. */
701
1037
  commitMessage?: string;
702
- /** When NOT moved, why (no such pre-backlog item, already in backlog, contention). */
1038
+ /** When NOT moved, why (no such staged item, already in the pool, contention). */
703
1039
  reasonNotMoved?: string;
704
1040
  }
705
1041
 
@@ -789,14 +1125,16 @@ export async function promoteFromPreBacklog(
789
1125
  ).status === 0;
790
1126
  if (!hasSource && !hasDest) {
791
1127
  const message =
792
- `'${slug}' is not staged in work/pre-backlog/ on ${arbiter}/main (and not ` +
793
- 'already in work/backlog/) — nothing to promote (wrong slug, or never ' +
794
- 'staged?).';
1128
+ `'${slug}' is not staged in ${workFolderPrefix('tasks-backlog')} on ${arbiter}/main ` +
1129
+ `(and not already in ${workFolderPrefix('tasks-ready')}) — nothing to promote ` +
1130
+ '(wrong slug, or never staged?).';
795
1131
  note(message);
796
1132
  return {moved: false, reasonNotMoved: message};
797
1133
  }
798
1134
 
799
- const commitMessage = `chore(${slug}): promote work/pre-backlog/ -> work/backlog/`;
1135
+ const commitMessage = `chore(${slug}): promote ${workFolderPrefix(
1136
+ 'tasks-backlog',
1137
+ )} -> ${workFolderPrefix('tasks-ready')}`;
800
1138
  const moved = await runTreelessLedgerMove({
801
1139
  cwd,
802
1140
  slug,
@@ -824,7 +1162,7 @@ export async function promoteFromPreBacklog(
824
1162
  base,
825
1163
  sourceRel,
826
1164
  destRel,
827
- // The body is carried byte-for-byte from pre-backlog into the
1165
+ // The body is carried byte-for-byte from tasks/backlog into the
828
1166
  // pool — promotion is a placement decision, not a content transform.
829
1167
  transformBody: (body) => body,
830
1168
  commitMessage,
@@ -834,14 +1172,14 @@ export async function promoteFromPreBacklog(
834
1172
  },
835
1173
  });
836
1174
  if (moved) {
837
- note(`Promoted '${slug}' from pre-backlog to backlog (claimable).`);
1175
+ note(`Promoted '${slug}' from tasks/backlog to tasks/ready (claimable).`);
838
1176
  return {moved: true, commitMessage};
839
1177
  }
840
1178
 
841
1179
  const message =
842
1180
  `promote for '${slug}': the arbiter's main kept moving (contended) after ` +
843
- `${TREELESS_CONTENTION_ATTEMPTS} attempts — item left in pre-backlog (no ` +
844
- 'move). Try again shortly.';
1181
+ `${TREELESS_CONTENTION_ATTEMPTS} attempts — item left in tasks/backlog ` +
1182
+ '(no move). Try again shortly.';
845
1183
  note(message);
846
1184
  return {moved: false, reasonNotMoved: message};
847
1185
  } finally {
@@ -863,7 +1201,7 @@ export async function promoteFromPreBacklog(
863
1201
  * the auto-task pool, not the gates).
864
1202
  *
865
1203
  * **RUNNER/human-owned.** There is no agent-facing path that performs this:
866
- * `intake`'s `prd` dispatch lands the spec STAGED in `work/specs/proposed/` (the
1204
+ * `intake`'s `spec` dispatch lands the spec STAGED in `work/specs/proposed/` (the
867
1205
  * runner's deterministic placement decision), and only a runner/human
868
1206
  * invocation moves it into the pool. The agent does no git here, as
869
1207
  * everywhere; this function is not reachable from any agent surface.
@@ -937,7 +1275,7 @@ export async function promoteFromPreSpec(
937
1275
  // (spec `prd-to-spec-vocabulary-cutover-and-migration-command`): the lock
938
1276
  // identity is `spec:${slug}` to match the `spec-<slug>` entry the tasking/apply
939
1277
  // path now acquires (`tasking.ts` releases under `spec:${slug}`); a stale
940
- // `prd:${slug}` here would key a DIFFERENT ref and break the mutual exclusion.
1278
+ // ''prd:${slug}'' here would key a DIFFERENT ref and break the mutual exclusion.
941
1279
  // Loss / crash semantics mirror the task case.
942
1280
  const item = `spec:${slug}`;
943
1281
  const acquired = await acquireItemLock({
@@ -1036,9 +1374,9 @@ export async function promoteFromPreSpec(
1036
1374
  }
1037
1375
  }
1038
1376
 
1039
- /** One staged item awaiting promotion (a task in `pre-backlog/` or a spec in `specs/proposed/`). */
1377
+ /** One staged item awaiting promotion (a task in `tasks/backlog/` or a spec in `specs/proposed/`). */
1040
1378
  export interface PromotableItem {
1041
- /** `'task'` (staged in `work/pre-backlog/`) or `'spec'` (staged in `work/specs/proposed/`). */
1379
+ /** `'task'` (staged in `work/tasks/backlog/`) or `'spec'` (staged in `work/specs/proposed/`). */
1042
1380
  namespace: 'task' | 'spec';
1043
1381
  /** The slug (filename minus `.md`). */
1044
1382
  slug: string;
@@ -1062,7 +1400,7 @@ export interface ListPromotableResult {
1062
1400
 
1063
1401
  /**
1064
1402
  * LIST every staged item awaiting a runner/human promotion — the tasks in
1065
- * `work/pre-backlog/` and the prds in `work/specs/proposed/` on `<arbiter>/main` (the
1403
+ * `work/tasks/backlog/` and the prds in `work/specs/proposed/` on `<arbiter>/main` (the
1066
1404
  * discovery half of the `promote` verb, so `promote` with no argument answers
1067
1405
  * "what is staged waiting for me?"). It reads the ARBITER's truth (a fetch + a
1068
1406
  * tree read), NOT the local working tree (which may be stale) — the same source
@@ -1341,6 +1679,441 @@ function prepareTreelessMoveCommit(params: {
1341
1679
  /** The heading that opens an appended requeue handoff note in the item body. */
1342
1680
  const REQUEUE_HEADING_PREFIX = '## Requeue';
1343
1681
 
1682
+ // --- Tree-less SURFACE primitive (PR-1, spec
1683
+ // `surface-stuck-as-questions-and-retire-stuck-lock-state`, task
1684
+ // `bounce-surfaces-stuck-sidecar-and-releases-lock`) --------------------
1685
+
1686
+ /**
1687
+ * PR-1 ADDITIVE primitive (task `bounce-surfaces-stuck-sidecar-and-releases-lock`).
1688
+ * The 2-file SIBLING of {@link prepareTreelessMoveCommit}: pure git plumbing
1689
+ * (`hash-object` / scratch-index `update-index` / `write-tree` /
1690
+ * `commit-tree`, NEVER touches the caller's index/HEAD/working tree) that in
1691
+ * ONE commit off {@link base} both
1692
+ *
1693
+ * 1. writes or appends to the item's `work/questions/<type>-<slug>.md`
1694
+ * sidecar (see {@link sidecarPathFor}) a `stuck`-kind entry carrying the
1695
+ * bounce {@link reason} plus any agent-surfaced {@link questions}, and
1696
+ * 2. sets `needsAnswers: true` on the item body at {@link itemPath} (via
1697
+ * {@link setNeedsAnswersMarker}).
1698
+ *
1699
+ * The current sidecar (if any) and the current item body are read as BLOBS off
1700
+ * `<arbiter>/main` (via {@link catBlob}) — no working tree required and no
1701
+ * dependency on the cwd's `HEAD` matching `main`. That is what makes this the
1702
+ * tree-less path's surface primitive: a `applyTreelessNeedsAttentionTransition`
1703
+ * caller (`continue-push-failure` / rebase-conflict) has NO writable-`main`
1704
+ * checkout, so `persistSurfacedQuestions` (working-tree bound) cannot be reused;
1705
+ * the pure CONTENT builders (`newSidecar` / `appendQuestions` /
1706
+ * `serialiseSidecar` / `setNeedsAnswersMarker`) ARE reused, only the commit
1707
+ * mechanism differs (spec decision #7).
1708
+ *
1709
+ * Returns the throwaway ref + commit sha, exactly like {@link
1710
+ * prepareTreelessMoveCommit}, so the SAME {@link runTreelessLedgerMove} CAS loop
1711
+ * publishes the surface commit through the shared write seam. The
1712
+ * surface-first / release-second ordering + `main`-authoritative crash-safety
1713
+ * (spec decision #4) come FREE from routing through that loop — the caller wires
1714
+ * the release into a `finally` AFTER a successful publish (see the harness
1715
+ * {@link surfaceStuckToNeedsAttention}).
1716
+ *
1717
+ * DECISION — sidecar entry shape for a reason-only bounce (build-time, PR-1):
1718
+ * every bounce always appends ONE engine-authored `stuck`-kind envelope entry
1719
+ * whose `question` names the item and whose `context` is the {@link reason}
1720
+ * prose, THEN any {@link questions} the agent surfaced. So a reason-only bounce
1721
+ * (no agent questions) still surfaces exactly ONE entry a human can answer, and
1722
+ * the agent's own questions (when present) are appended AFTER it, verbatim. The
1723
+ * envelope entry is what turns a raw exit reason into a human-drainable
1724
+ * question; the extra entries are the LLM prose the spec's decision #2 keeps
1725
+ * untouched. Alternative considered — treating an empty `questions` array as a
1726
+ * NO-OP surface — REJECTED because that is the spec's `stuck` retirement
1727
+ * problem all over again (a bounced item with no on-`main` outcome).
1728
+ * Alternative considered — dropping the envelope when agent questions are given
1729
+ * — REJECTED because the reason is engine-authored ground truth; the agent's
1730
+ * questions are advisory prose ABOVE it, not a replacement for it.
1731
+ *
1732
+ * PR-1 boundary: this primitive is EXERCISED BY TESTS ONLY; it is NOT yet
1733
+ * called from any bounce seam. Wiring `applyNeedsAttentionTransition` /
1734
+ * `applyTreelessNeedsAttentionTransition` to it — and migrating the existing
1735
+ * `stuckLockOnArbiter(...).toBe(true)` assertions — is the follow-up PR-2 task
1736
+ * `bounce-atomic-cutover-retire-stuck-lock`.
1737
+ */
1738
+ export function prepareTreelessSurfaceCommit(params: {
1739
+ /** The origin cwd whose object store the plumbing writes into (never a target). */
1740
+ cwd: string;
1741
+ /** The slug (used only to name the throwaway ref). */
1742
+ slug: string;
1743
+ /** The namespaced item identity (e.g. `task:foo`); drives the sidecar path. */
1744
+ item: string;
1745
+ /** The item body's on-`main` path (e.g. `work/tasks/ready/foo.md`). */
1746
+ itemPath: string;
1747
+ /** The base commit (`<arbiter>/main`) the surface commit parents on. */
1748
+ base: string;
1749
+ /** The bounce reason (envelope entry's context). */
1750
+ reason: string;
1751
+ /** Any agent-surfaced questions to append after the envelope. */
1752
+ questions?: NewQuestion[];
1753
+ /**
1754
+ * OPTIONAL engine-authored envelope entry OVERRIDE. When provided, replaces
1755
+ * the built-in generic `"<item> was bounced — how should we proceed?"`
1756
+ * envelope with a caller-supplied one — the seam the empty-diff bounce path
1757
+ * uses to guarantee a DISPOSE-DEFAULTED disposition question exists on the
1758
+ * surfaced sidecar (spec `surface-stuck-as-questions-and-retire-stuck-lock-state`
1759
+ * resolved decision #2, task
1760
+ * `empty-diff-bounce-surfaces-dispose-defaulted-question`). The override still
1761
+ * defaults its `kind` to `'stuck'` when unset. `reason` (envelope context)
1762
+ * remains the caller-owned prose; the override lets the caller set the
1763
+ * envelope's `question` + `default` so the human sees a one-glance
1764
+ * dispose/cancel prompt instead of the generic "how should we proceed?"
1765
+ * catch-all. When absent, the built-in envelope is used (the reason-only
1766
+ * bounce shape).
1767
+ */
1768
+ envelope?: NewQuestion;
1769
+ /** The commit subject for the surface commit. */
1770
+ commitMessage: string;
1771
+ /** The throwaway ref namespace (`refs/dorfl/<refNamespace>/<slug>`). */
1772
+ refNamespace: string;
1773
+ env: NodeJS.ProcessEnv | undefined;
1774
+ }): {ref: string; commit: string} {
1775
+ const {
1776
+ cwd,
1777
+ slug,
1778
+ item,
1779
+ itemPath,
1780
+ base,
1781
+ reason,
1782
+ questions,
1783
+ envelope: envelopeOverride,
1784
+ commitMessage,
1785
+ refNamespace,
1786
+ env,
1787
+ } = params;
1788
+
1789
+ const sidecarPath = sidecarPathFor(item);
1790
+
1791
+ // Read the item body off `main` as a BLOB — never off the cwd working tree,
1792
+ // which the tree-less caller does not have on `main`. `catBlob` throws when
1793
+ // the path is not tracked, which is the honest signal: the tree-less bounce
1794
+ // only fires against an item whose body already rests on `main` (the caller's
1795
+ // plan should short-circuit `missing` beforehand).
1796
+ const itemBody = catBlob(`${base}:${itemPath}`, cwd, env);
1797
+ const flagged = setNeedsAnswersMarker(itemBody, true);
1798
+
1799
+ // Defense-in-depth (the `sidecar-without-needsAnswers` guard, mirrored from
1800
+ // `persistSurfacedQuestions`): if the marker did not actually parse back as
1801
+ // `true`, refuse to write the sidecar rather than tear the
1802
+ // `needsAnswers ⟺ sidecar` invariant.
1803
+ if (parseFrontmatter(flagged).needsAnswers !== true) {
1804
+ throw new Error(
1805
+ `prepareTreelessSurfaceCommit: could not set needsAnswers:true on '${itemPath}' ` +
1806
+ `for '${item}' — refusing to surface without the flag.`,
1807
+ );
1808
+ }
1809
+
1810
+ // Compose the entries: an engine-authored envelope carrying the reason, then
1811
+ // any agent-surfaced questions (stamped `stuck`-kind if the caller left the
1812
+ // kind unset — this IS the stuck-surface path). Callers may OVERRIDE the
1813
+ // envelope (e.g. the empty-diff path swaps in a dispose-defaulted question);
1814
+ // the override still defaults `kind` to `stuck` and `context` to the bounce
1815
+ // reason when the override leaves them unset (the caller can restate the
1816
+ // reason in the envelope prose without duplicating it in `context`).
1817
+ const envelope: NewQuestion = envelopeOverride
1818
+ ? {
1819
+ kind: envelopeOverride.kind ?? 'stuck',
1820
+ question: envelopeOverride.question,
1821
+ context: envelopeOverride.context ?? reason,
1822
+ ...(envelopeOverride.default !== undefined
1823
+ ? {default: envelopeOverride.default}
1824
+ : {}),
1825
+ }
1826
+ : {
1827
+ question: `'${item}' was bounced — how should we proceed?`,
1828
+ context: reason,
1829
+ kind: 'stuck',
1830
+ };
1831
+ const surfaced: NewQuestion[] = (questions ?? []).map((q) => ({
1832
+ ...q,
1833
+ kind: q.kind ?? 'stuck',
1834
+ }));
1835
+ const additions: NewQuestion[] = [envelope, ...surfaced];
1836
+
1837
+ // APPEND to an existing sidecar on `main` (never overwrite) or CREATE it
1838
+ // first-pass — the same append-never-overwrite rule the working-tree surface
1839
+ // path enforces. Read the current sidecar off `main` as a BLOB.
1840
+ const sidecarExists = pathInCommit(base, sidecarPath, cwd, env);
1841
+ const model = sidecarExists
1842
+ ? appendQuestions(
1843
+ parseSidecar(catBlob(`${base}:${sidecarPath}`, cwd, env)),
1844
+ additions,
1845
+ )
1846
+ : newSidecar(item, additions);
1847
+ const sidecarContent = serialiseSidecar(model);
1848
+
1849
+ // Hash both blobs INTO the cwd's object store (no working tree write).
1850
+ const itemBlob = hashObject(flagged, cwd, env);
1851
+ const sidecarBlob = hashObject(sidecarContent, cwd, env);
1852
+
1853
+ // A scratch index so `read-tree` / `update-index` never touch the caller's
1854
+ // index. `--add --cacheinfo` both ADDS a new entry and REPLACES an existing
1855
+ // one (so a re-surface that rewrites the sidecar path is a no-op replace).
1856
+ const scratchIndex = join(
1857
+ tmpdir(),
1858
+ `dorfl-${refNamespace}-${process.pid}-${Date.now()}.index`,
1859
+ );
1860
+ const withIndex: NodeJS.ProcessEnv = {
1861
+ ...(env ?? process.env),
1862
+ GIT_INDEX_FILE: scratchIndex,
1863
+ };
1864
+ try {
1865
+ gitHard(['read-tree', base], cwd, withIndex);
1866
+ gitHard(
1867
+ [
1868
+ 'update-index',
1869
+ '--add',
1870
+ '--cacheinfo',
1871
+ `100644,${itemBlob},${itemPath}`,
1872
+ ],
1873
+ cwd,
1874
+ withIndex,
1875
+ );
1876
+ gitHard(
1877
+ [
1878
+ 'update-index',
1879
+ '--add',
1880
+ '--cacheinfo',
1881
+ `100644,${sidecarBlob},${sidecarPath}`,
1882
+ ],
1883
+ cwd,
1884
+ withIndex,
1885
+ );
1886
+ const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
1887
+ const commit = runHard(
1888
+ ['commit-tree', tree, '-p', base, '-m', commitMessage],
1889
+ cwd,
1890
+ env,
1891
+ ).stdout.trim();
1892
+ const ref = `refs/dorfl/${refNamespace}/${slug}`;
1893
+ gitHard(['update-ref', ref, commit], cwd, env);
1894
+ return {ref, commit};
1895
+ } finally {
1896
+ rmSync(scratchIndex, {force: true});
1897
+ }
1898
+ }
1899
+
1900
+ export interface SurfaceStuckToNeedsAttentionOptions {
1901
+ /**
1902
+ * The working clone the move is ORIGINATED from — purely the ORIGIN SOURCE
1903
+ * (it resolves the arbiter remote + holds the object store the plumbing
1904
+ * writes into), NEVER a write TARGET. Tree-less: the cwd index/HEAD/working
1905
+ * tree are never touched.
1906
+ */
1907
+ cwd: string;
1908
+ /** The slug of the item to surface as a stuck question. */
1909
+ slug: string;
1910
+ /**
1911
+ * The item body's on-`main` path (e.g. `work/tasks/ready/foo.md`). Provided
1912
+ * by the caller (parity with `persistSurfacedQuestions`). PR-2a made this
1913
+ * OPTIONAL: when absent the harness invokes {@link resolveBounceItemBodyPathOnMain}
1914
+ * to PROBE `<arbiter>/main` in the D1 order for the item's namespace
1915
+ * (task/spec/observation). A body-absent probe (item body never landed on
1916
+ * `main` — e.g. a claim that lost/raced) is a CLEAN NO-OP surface that STILL
1917
+ * RELEASES the lock: never throw, never leave a held lock, never drop the
1918
+ * bounce silently to a dead end.
1919
+ */
1920
+ itemPath?: string;
1921
+ /** The namespaced item identity. Defaults to `task:${slug}`. */
1922
+ item?: string;
1923
+ /** Why the item is stuck (bounce reason — envelope entry's context). */
1924
+ reason: string;
1925
+ /** Any questions the agent surfaced (appended after the envelope). */
1926
+ questions?: NewQuestion[];
1927
+ /**
1928
+ * OPTIONAL engine-authored envelope override, forwarded to
1929
+ * {@link prepareTreelessSurfaceCommit}. The empty-diff bounce path uses this
1930
+ * to guarantee the first surfaced entry is a DISPOSE-DEFAULTED disposition
1931
+ * question (task `empty-diff-bounce-surfaces-dispose-defaulted-question`,
1932
+ * spec resolved decision #2). Absent ⇒ the built-in generic
1933
+ * "how should we proceed?" envelope (a reason-only bounce).
1934
+ */
1935
+ envelope?: NewQuestion;
1936
+ /** The arbiter remote the surface commit is CAS-published to. REQUIRED. */
1937
+ arbiter: string;
1938
+ env?: NodeJS.ProcessEnv;
1939
+ note?: (message: string) => void;
1940
+ }
1941
+
1942
+ export interface SurfaceStuckToNeedsAttentionResult {
1943
+ /** True iff the surface commit landed on `<arbiter>/main`. */
1944
+ surfaced: boolean;
1945
+ /** True iff the per-item lock ref was released. On a body-absent probe
1946
+ * (see {@link bodyAbsent}) the lock is STILL released — the bounce cannot
1947
+ * silently strand a held lock over an item with no `main` body. */
1948
+ released: boolean;
1949
+ /** When NOT surfaced, why (missing item, contention exhausted, body-absent probe). */
1950
+ reasonNotSurfaced?: string;
1951
+ /** True iff the D1 probe found no body on `<arbiter>/main` for this item
1952
+ * (task: `tasks/ready` then `tasks/backlog`; spec: `specs/ready` then
1953
+ * `specs/proposed`; observation: `notes/observations`). In this case
1954
+ * {@link surfaced} is false but {@link released} is true — the lock is
1955
+ * always released to prevent a dead-end held lock. */
1956
+ bodyAbsent?: boolean;
1957
+ }
1958
+
1959
+ /**
1960
+ * The PR-1 thin HARNESS around {@link prepareTreelessSurfaceCommit}: run it
1961
+ * through the EXISTING {@link runTreelessLedgerMove} CAS loop, then release the
1962
+ * per-item lock. Ordering is LOAD-BEARING and INHERITED from that harness —
1963
+ * the surface commit lands on `<arbiter>/main` FIRST, and the lock release only
1964
+ * fires on a successful publish. `main` is authoritative on crash recovery
1965
+ * (spec decision #4).
1966
+ *
1967
+ * PR-1 boundary: this is EXERCISED BY TESTS ONLY. Wiring the seams to call it
1968
+ * is the PR-2 task `bounce-atomic-cutover-retire-stuck-lock`.
1969
+ */
1970
+ export async function surfaceStuckToNeedsAttention(
1971
+ options: SurfaceStuckToNeedsAttentionOptions,
1972
+ ): Promise<SurfaceStuckToNeedsAttentionResult> {
1973
+ const note = options.note ?? (() => {});
1974
+ // Merge of #364 (envelope) + PR-2a (itemPath is re-declared as `let` below for
1975
+ // the D1 probe fallback, so it must NOT be in this const destructure).
1976
+ const {cwd, slug, reason, questions, envelope, arbiter, env} = options;
1977
+ const item = options.item ?? `task:${slug}`;
1978
+
1979
+ // PR-2a D1 body-path probe: when the caller does not name an on-`main`
1980
+ // body path, PROBE `<arbiter>/main` in a fixed order per namespace
1981
+ // (task/spec/observation). A body-absent probe is a CLEAN NO-OP surface
1982
+ // that STILL releases the lock — never leave a bounce as a dead-end held
1983
+ // lock.
1984
+ let itemPath = options.itemPath;
1985
+ if (itemPath === undefined) {
1986
+ const probed = await resolveBounceItemBodyPathOnMain({
1987
+ cwd,
1988
+ item,
1989
+ arbiter,
1990
+ env,
1991
+ });
1992
+ if (probed === undefined) {
1993
+ // Body-absent: skip the surface commit entirely and STILL release the
1994
+ // lock (idempotent). Signal the distinction on the result so a caller
1995
+ // can tell a body-absent no-op apart from a `missing` plan on a
1996
+ // caller-provided itemPath.
1997
+ const rel = await releaseItemLock({item, cwd, arbiter, env});
1998
+ const released = rel.outcome === 'released' || rel.outcome === 'not-held';
1999
+ return {
2000
+ surfaced: false,
2001
+ released,
2002
+ bodyAbsent: true,
2003
+ reasonNotSurfaced:
2004
+ `no body for '${item}' on ${arbiter}/main (probed the D1 ` +
2005
+ 'candidates in order) — surface skipped as a clean no-op; the ' +
2006
+ 'lock was still released to avoid a dead-end held lock.',
2007
+ };
2008
+ }
2009
+ itemPath = probed;
2010
+ }
2011
+
2012
+ const resolvedItemPath = itemPath;
2013
+ const surfaced = await runTreelessLedgerMove({
2014
+ cwd,
2015
+ slug,
2016
+ arbiter,
2017
+ kind: 'needs-attention',
2018
+ onContended: 'surface',
2019
+ explicitMainRefspec: true,
2020
+ env,
2021
+ note,
2022
+ plan: (base) => {
2023
+ if (!pathInCommit(base, resolvedItemPath, cwd, env)) {
2024
+ return 'missing';
2025
+ }
2026
+ return prepareTreelessSurfaceCommit({
2027
+ cwd,
2028
+ slug,
2029
+ item,
2030
+ itemPath: resolvedItemPath,
2031
+ base,
2032
+ reason,
2033
+ questions,
2034
+ envelope,
2035
+ commitMessage: `surface ${item} (stuck): ${reason}`,
2036
+ refNamespace: 'surface-stuck',
2037
+ env,
2038
+ });
2039
+ },
2040
+ });
2041
+
2042
+ if (!surfaced) {
2043
+ return {
2044
+ surfaced: false,
2045
+ released: false,
2046
+ reasonNotSurfaced:
2047
+ `surface for '${item}' did not land on ${arbiter}/main ` +
2048
+ '(item missing on main, or contention exhausted after retries).',
2049
+ };
2050
+ }
2051
+
2052
+ // Surface-first / release-second: only reach here on a successful publish.
2053
+ const rel = await releaseItemLock({item, cwd, arbiter, env});
2054
+ const released = rel.outcome === 'released' || rel.outcome === 'not-held';
2055
+ return {surfaced: true, released};
2056
+ }
2057
+
2058
+ /**
2059
+ * The D1 body-path probe (PR-2a task `bounce-atomic-cutover-retire-stuck-lock`,
2060
+ * spec `surface-stuck-as-questions-and-retire-stuck-lock-state`, decision D1):
2061
+ * resolve the item's on-`main` body path by probing `<arbiter>/main` in a
2062
+ * FIXED order per namespace:
2063
+ * - task: `work/tasks/ready/<slug>.md` then `work/tasks/backlog/<slug>.md`.
2064
+ * - spec: `work/specs/ready/<slug>.md` then `work/specs/proposed/<slug>.md`.
2065
+ * - observation: `work/notes/observations/<slug>.md`.
2066
+ *
2067
+ * Returns the first candidate that EXISTS on `<arbiter>/main`, or `undefined`
2068
+ * when no candidate exists (a bounce for an item whose body never landed on
2069
+ * `main`). The caller MUST handle `undefined` by STILL releasing the lock —
2070
+ * never leave a held lock over a body-absent item.
2071
+ *
2072
+ * The probe fetches `<arbiter>/main` with an EXPLICIT refspec (the
2073
+ * `runTreelessLedgerMove` pattern) so a job-worktree with a narrower default
2074
+ * fetch refspec still resolves `<arbiter>/main` reliably.
2075
+ */
2076
+ export async function resolveBounceItemBodyPathOnMain(params: {
2077
+ cwd: string;
2078
+ item: string;
2079
+ arbiter: string;
2080
+ env?: NodeJS.ProcessEnv;
2081
+ }): Promise<string | undefined> {
2082
+ const {cwd, item, arbiter, env} = params;
2083
+ const {type, slug} = resolveSidecarIdentity(item);
2084
+ await gitSoftAsync(
2085
+ [
2086
+ 'fetch',
2087
+ '--quiet',
2088
+ arbiter,
2089
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
2090
+ ],
2091
+ cwd,
2092
+ env,
2093
+ );
2094
+ const base = `${arbiter}/main`;
2095
+ const folders = BOUNCE_BODY_PROBE_ORDER[type];
2096
+ for (const folder of folders) {
2097
+ const rel = workItemRel(folder, `${slug}.md`);
2098
+ if (pathInCommit(base, rel, cwd, env)) {
2099
+ return rel;
2100
+ }
2101
+ }
2102
+ return undefined;
2103
+ }
2104
+
2105
+ /** The FIXED per-namespace probe order for {@link resolveBounceItemBodyPathOnMain}
2106
+ * (D1): the FIRST candidate that exists on `<arbiter>/main` is the item's body
2107
+ * path. The ordering encodes the working assumption that a claimed/in-flight
2108
+ * item is in `ready/` and a not-yet-promoted item is in `backlog/`
2109
+ * (spec-tasked/proposed for a spec); an observation has one location.
2110
+ * Callers with an explicit `itemPath` bypass this probe entirely. */
2111
+ const BOUNCE_BODY_PROBE_ORDER: Record<SidecarType, readonly WorkFolderKey[]> = {
2112
+ task: ['tasks-ready', 'tasks-backlog'],
2113
+ spec: ['specs-ready', 'specs-proposed'],
2114
+ observation: ['observations'],
2115
+ };
2116
+
1344
2117
  /**
1345
2118
  * Append a dated `## Requeue YYYY-MM-DD` handoff section to an item body's TEXT
1346
2119
  * (append-only — never overwrites; repeated requeues accumulate a handoff log).