dorfl 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (364) hide show
  1. package/dist/advance-drivers.d.ts +1 -1
  2. package/dist/advance-drivers.d.ts.map +1 -1
  3. package/dist/advance-drivers.js +15 -3
  4. package/dist/advance-drivers.js.map +1 -1
  5. package/dist/advance-lifecycle-template.d.ts.map +1 -1
  6. package/dist/advance-lifecycle-template.js +76 -4
  7. package/dist/advance-lifecycle-template.js.map +1 -1
  8. package/dist/advance-treeless-publish.d.ts +23 -0
  9. package/dist/advance-treeless-publish.d.ts.map +1 -1
  10. package/dist/advance-treeless-publish.js +41 -0
  11. package/dist/advance-treeless-publish.js.map +1 -1
  12. package/dist/advance.d.ts +59 -7
  13. package/dist/advance.d.ts.map +1 -1
  14. package/dist/advance.js +389 -82
  15. package/dist/advance.js.map +1 -1
  16. package/dist/advancing-lock.d.ts +30 -2
  17. package/dist/advancing-lock.d.ts.map +1 -1
  18. package/dist/advancing-lock.js +50 -0
  19. package/dist/advancing-lock.js.map +1 -1
  20. package/dist/agent-launch.d.ts +12 -0
  21. package/dist/agent-launch.d.ts.map +1 -1
  22. package/dist/agent-launch.js +22 -12
  23. package/dist/agent-launch.js.map +1 -1
  24. package/dist/agent-stop.d.ts +40 -2
  25. package/dist/agent-stop.d.ts.map +1 -1
  26. package/dist/agent-stop.js +30 -2
  27. package/dist/agent-stop.js.map +1 -1
  28. package/dist/apply-decide.d.ts +17 -3
  29. package/dist/apply-decide.d.ts.map +1 -1
  30. package/dist/apply-decide.js +36 -7
  31. package/dist/apply-decide.js.map +1 -1
  32. package/dist/apply-merge-action.d.ts +17 -5
  33. package/dist/apply-merge-action.d.ts.map +1 -1
  34. package/dist/apply-merge-action.js +43 -8
  35. package/dist/apply-merge-action.js.map +1 -1
  36. package/dist/apply-persist.d.ts +57 -26
  37. package/dist/apply-persist.d.ts.map +1 -1
  38. package/dist/apply-persist.js +166 -37
  39. package/dist/apply-persist.js.map +1 -1
  40. package/dist/apply-stuck-action.d.ts +151 -0
  41. package/dist/apply-stuck-action.d.ts.map +1 -0
  42. package/dist/apply-stuck-action.js +125 -0
  43. package/dist/apply-stuck-action.js.map +1 -0
  44. package/dist/brand.d.ts +12 -1
  45. package/dist/brand.d.ts.map +1 -1
  46. package/dist/brand.js +2 -1
  47. package/dist/brand.js.map +1 -1
  48. package/dist/cli-spinner.d.ts +1 -1
  49. package/dist/cli-spinner.js +1 -1
  50. package/dist/cli.d.ts +10 -0
  51. package/dist/cli.d.ts.map +1 -1
  52. package/dist/cli.js +320 -63
  53. package/dist/cli.js.map +1 -1
  54. package/dist/close-job-template.js +1 -1
  55. package/dist/complete.d.ts +6 -3
  56. package/dist/complete.d.ts.map +1 -1
  57. package/dist/complete.js +91 -37
  58. package/dist/complete.js.map +1 -1
  59. package/dist/config.d.ts +55 -9
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/config.js +56 -4
  62. package/dist/config.js.map +1 -1
  63. package/dist/continue-branch.d.ts.map +1 -1
  64. package/dist/continue-branch.js +22 -0
  65. package/dist/continue-branch.js.map +1 -1
  66. package/dist/decision-engine.d.ts +38 -10
  67. package/dist/decision-engine.d.ts.map +1 -1
  68. package/dist/decision-engine.js +10 -6
  69. package/dist/decision-engine.js.map +1 -1
  70. package/dist/do-autopick.d.ts.map +1 -1
  71. package/dist/do-autopick.js +11 -1
  72. package/dist/do-autopick.js.map +1 -1
  73. package/dist/do-config.d.ts +1 -1
  74. package/dist/do.d.ts +49 -56
  75. package/dist/do.d.ts.map +1 -1
  76. package/dist/do.js +419 -30
  77. package/dist/do.js.map +1 -1
  78. package/dist/env-config.d.ts.map +1 -1
  79. package/dist/env-config.js +12 -4
  80. package/dist/env-config.js.map +1 -1
  81. package/dist/failure-cause.d.ts +3 -2
  82. package/dist/failure-cause.d.ts.map +1 -1
  83. package/dist/failure-cause.js +28 -1
  84. package/dist/failure-cause.js.map +1 -1
  85. package/dist/format.d.ts +6 -6
  86. package/dist/format.d.ts.map +1 -1
  87. package/dist/format.js +10 -27
  88. package/dist/format.js.map +1 -1
  89. package/dist/frontmatter.d.ts +16 -4
  90. package/dist/frontmatter.d.ts.map +1 -1
  91. package/dist/frontmatter.js +27 -1
  92. package/dist/frontmatter.js.map +1 -1
  93. package/dist/gc.d.ts +65 -6
  94. package/dist/gc.d.ts.map +1 -1
  95. package/dist/gc.js +126 -9
  96. package/dist/gc.js.map +1 -1
  97. package/dist/github.d.ts +14 -0
  98. package/dist/github.d.ts.map +1 -1
  99. package/dist/github.js +73 -0
  100. package/dist/github.js.map +1 -1
  101. package/dist/harness.d.ts +25 -0
  102. package/dist/harness.d.ts.map +1 -1
  103. package/dist/harness.js.map +1 -1
  104. package/dist/index.d.ts +4 -1
  105. package/dist/index.d.ts.map +1 -1
  106. package/dist/index.js +2 -1
  107. package/dist/index.js.map +1 -1
  108. package/dist/install-ci-branch-protection.d.ts +132 -38
  109. package/dist/install-ci-branch-protection.d.ts.map +1 -1
  110. package/dist/install-ci-branch-protection.js +190 -48
  111. package/dist/install-ci-branch-protection.js.map +1 -1
  112. package/dist/install-ci-core.d.ts +21 -1
  113. package/dist/install-ci-core.d.ts.map +1 -1
  114. package/dist/install-ci-core.js +10 -3
  115. package/dist/install-ci-core.js.map +1 -1
  116. package/dist/install-ci-github.d.ts +32 -0
  117. package/dist/install-ci-github.d.ts.map +1 -1
  118. package/dist/install-ci-github.js +73 -0
  119. package/dist/install-ci-github.js.map +1 -1
  120. package/dist/install-skills.d.ts +123 -0
  121. package/dist/install-skills.d.ts.map +1 -0
  122. package/dist/install-skills.js +100 -0
  123. package/dist/install-skills.js.map +1 -0
  124. package/dist/intake-trigger-template.js +1 -1
  125. package/dist/intake.d.ts +17 -4
  126. package/dist/intake.d.ts.map +1 -1
  127. package/dist/intake.js +23 -7
  128. package/dist/intake.js.map +1 -1
  129. package/dist/integration-core.d.ts +10 -7
  130. package/dist/integration-core.d.ts.map +1 -1
  131. package/dist/integration-core.js +174 -60
  132. package/dist/integration-core.js.map +1 -1
  133. package/dist/integrator.d.ts +1 -1
  134. package/dist/integrator.d.ts.map +1 -1
  135. package/dist/integrator.js +19 -3
  136. package/dist/integrator.js.map +1 -1
  137. package/dist/isolation.d.ts +2 -2
  138. package/dist/isolation.d.ts.map +1 -1
  139. package/dist/isolation.js +9 -0
  140. package/dist/isolation.js.map +1 -1
  141. package/dist/item-lock.d.ts +196 -129
  142. package/dist/item-lock.d.ts.map +1 -1
  143. package/dist/item-lock.js +329 -254
  144. package/dist/item-lock.js.map +1 -1
  145. package/dist/ledger-write.d.ts +31 -26
  146. package/dist/ledger-write.d.ts.map +1 -1
  147. package/dist/ledger-write.js +100 -122
  148. package/dist/ledger-write.js.map +1 -1
  149. package/dist/lifecycle-gather.d.ts +17 -0
  150. package/dist/lifecycle-gather.d.ts.map +1 -1
  151. package/dist/lifecycle-gather.js +7 -1
  152. package/dist/lifecycle-gather.js.map +1 -1
  153. package/dist/lifecycle-pools.d.ts +40 -3
  154. package/dist/lifecycle-pools.d.ts.map +1 -1
  155. package/dist/lifecycle-pools.js +26 -6
  156. package/dist/lifecycle-pools.js.map +1 -1
  157. package/dist/merge-question-surfacer.d.ts +15 -1
  158. package/dist/merge-question-surfacer.d.ts.map +1 -1
  159. package/dist/merge-question-surfacer.js +18 -3
  160. package/dist/merge-question-surfacer.js.map +1 -1
  161. package/dist/migrate-stuck-locks.d.ts +129 -0
  162. package/dist/migrate-stuck-locks.d.ts.map +1 -0
  163. package/dist/migrate-stuck-locks.js +355 -0
  164. package/dist/migrate-stuck-locks.js.map +1 -0
  165. package/dist/needs-attention.d.ts +293 -16
  166. package/dist/needs-attention.d.ts.map +1 -1
  167. package/dist/needs-attention.js +532 -57
  168. package/dist/needs-attention.js.map +1 -1
  169. package/dist/orphan-sidecar.d.ts +8 -4
  170. package/dist/orphan-sidecar.d.ts.map +1 -1
  171. package/dist/orphan-sidecar.js +35 -2
  172. package/dist/orphan-sidecar.js.map +1 -1
  173. package/dist/pi-harness.d.ts +16 -0
  174. package/dist/pi-harness.d.ts.map +1 -1
  175. package/dist/pi-harness.js +82 -2
  176. package/dist/pi-harness.js.map +1 -1
  177. package/dist/placement.d.ts +3 -3
  178. package/dist/placement.js +1 -1
  179. package/dist/prd-to-spec.d.ts.map +1 -1
  180. package/dist/prd-to-spec.js +9 -5
  181. package/dist/prd-to-spec.js.map +1 -1
  182. package/dist/protocol/CLAIM-PROTOCOL.md +17 -10
  183. package/dist/protocol/REVIEW-PROTOCOL.md +4 -1
  184. package/dist/protocol/SURFACE-PROTOCOL.md +16 -2
  185. package/dist/protocol/TASKING-PROTOCOL.md +3 -1
  186. package/dist/protocol/WORK-CONTRACT.md +22 -18
  187. package/dist/protocol/task-template.md +1 -1
  188. package/dist/reap-branches.d.ts +12 -9
  189. package/dist/reap-branches.d.ts.map +1 -1
  190. package/dist/reap-branches.js +25 -7
  191. package/dist/reap-branches.js.map +1 -1
  192. package/dist/recover-isolated.d.ts +12 -0
  193. package/dist/recover-isolated.d.ts.map +1 -1
  194. package/dist/recover-isolated.js +5 -0
  195. package/dist/recover-isolated.js.map +1 -1
  196. package/dist/repo-config.d.ts +23 -2
  197. package/dist/repo-config.d.ts.map +1 -1
  198. package/dist/repo-config.js +53 -7
  199. package/dist/repo-config.js.map +1 -1
  200. package/dist/repo-mirror.d.ts.map +1 -1
  201. package/dist/repo-mirror.js +18 -2
  202. package/dist/repo-mirror.js.map +1 -1
  203. package/dist/review-verdict.js +1 -1
  204. package/dist/review-verdict.js.map +1 -1
  205. package/dist/run.d.ts +1 -1
  206. package/dist/run.d.ts.map +1 -1
  207. package/dist/run.js +54 -19
  208. package/dist/run.js.map +1 -1
  209. package/dist/scan.d.ts +17 -8
  210. package/dist/scan.d.ts.map +1 -1
  211. package/dist/scan.js +22 -8
  212. package/dist/scan.js.map +1 -1
  213. package/dist/select-priority.d.ts +2 -2
  214. package/dist/select-priority.js +1 -1
  215. package/dist/select-priority.js.map +1 -1
  216. package/dist/sidecar-apply.js +1 -1
  217. package/dist/sidecar-apply.js.map +1 -1
  218. package/dist/sidecar.d.ts +45 -10
  219. package/dist/sidecar.d.ts.map +1 -1
  220. package/dist/sidecar.js +82 -2
  221. package/dist/sidecar.js.map +1 -1
  222. package/dist/skills/answer-questions/SKILL.md +89 -0
  223. package/dist/skills/capture-signal/SKILL.md +52 -0
  224. package/dist/skills/convert-from-prd-to-spec/SKILL.md +90 -0
  225. package/dist/skills/drive-tasks/SKILL.md +218 -0
  226. package/dist/skills/from-idea/SKILL.md +83 -0
  227. package/dist/skills/merge-prs/SKILL.md +70 -0
  228. package/dist/skills/orchestrate/SKILL.md +101 -0
  229. package/dist/skills/promote/SKILL.md +35 -0
  230. package/dist/skills/review/SKILL.md +16 -0
  231. package/dist/skills/setup/SKILL.md +258 -0
  232. package/dist/skills/setup/protocol/ADR-FORMAT.md +47 -0
  233. package/dist/skills/setup/protocol/CLAIM-PROTOCOL.md +224 -0
  234. package/dist/skills/setup/protocol/REVIEW-PROTOCOL.md +122 -0
  235. package/dist/skills/setup/protocol/SURFACE-PROTOCOL.md +135 -0
  236. package/dist/skills/setup/protocol/TASKING-PROTOCOL.md +124 -0
  237. package/dist/skills/setup/protocol/WORK-CONTRACT.md +280 -0
  238. package/dist/skills/setup/protocol/spec-template.md +71 -0
  239. package/dist/skills/setup/protocol/task-template.md +65 -0
  240. package/dist/skills/surface-questions/SKILL.md +16 -0
  241. package/dist/skills/to-spec/SKILL.md +34 -0
  242. package/dist/skills/to-task/SKILL.md +19 -0
  243. package/dist/skills/triage-observations/SKILL.md +78 -0
  244. package/dist/skills/work/SKILL.md +51 -0
  245. package/dist/slug-namespace.d.ts +5 -5
  246. package/dist/slug-namespace.js +5 -5
  247. package/dist/slug-namespace.js.map +1 -1
  248. package/dist/spec-complete.d.ts.map +1 -1
  249. package/dist/spec-complete.js +2 -3
  250. package/dist/spec-complete.js.map +1 -1
  251. package/dist/start.d.ts.map +1 -1
  252. package/dist/start.js +51 -57
  253. package/dist/start.js.map +1 -1
  254. package/dist/surface-gate.d.ts.map +1 -1
  255. package/dist/surface-gate.js +10 -3
  256. package/dist/surface-gate.js.map +1 -1
  257. package/dist/surface-persist.js +1 -1
  258. package/dist/surface-persist.js.map +1 -1
  259. package/dist/tasker-review-loop.d.ts +4 -3
  260. package/dist/tasker-review-loop.d.ts.map +1 -1
  261. package/dist/tasker-review-loop.js.map +1 -1
  262. package/dist/tasking-lock.d.ts +2 -2
  263. package/dist/tasking-lock.d.ts.map +1 -1
  264. package/dist/tasking-lock.js +53 -32
  265. package/dist/tasking-lock.js.map +1 -1
  266. package/dist/tasking.d.ts +40 -6
  267. package/dist/tasking.d.ts.map +1 -1
  268. package/dist/tasking.js +224 -12
  269. package/dist/tasking.js.map +1 -1
  270. package/dist/triage-persist.d.ts +10 -6
  271. package/dist/triage-persist.d.ts.map +1 -1
  272. package/dist/triage-persist.js +34 -4
  273. package/dist/triage-persist.js.map +1 -1
  274. package/dist/vendor/incur/agents.d.ts +58 -0
  275. package/dist/vendor/incur/agents.d.ts.map +1 -0
  276. package/dist/vendor/incur/agents.js +343 -0
  277. package/dist/vendor/incur/agents.js.map +1 -0
  278. package/dist/verify-workflow-template.js +1 -1
  279. package/dist/watch-session.d.ts +11 -3
  280. package/dist/watch-session.d.ts.map +1 -1
  281. package/dist/watch-session.js +94 -7
  282. package/dist/watch-session.js.map +1 -1
  283. package/dist/work-layout.d.ts +7 -3
  284. package/dist/work-layout.d.ts.map +1 -1
  285. package/dist/work-layout.js +6 -3
  286. package/dist/work-layout.js.map +1 -1
  287. package/dist/workspace.d.ts +1 -1
  288. package/package.json +2 -2
  289. package/src/advance-drivers.ts +16 -3
  290. package/src/advance-lifecycle-template.ts +89 -4
  291. package/src/advance-treeless-publish.ts +45 -0
  292. package/src/advance.ts +467 -87
  293. package/src/advancing-lock.ts +96 -1
  294. package/src/agent-launch.ts +37 -12
  295. package/src/agent-stop.ts +60 -2
  296. package/src/apply-decide.ts +36 -7
  297. package/src/apply-merge-action.ts +45 -7
  298. package/src/apply-persist.ts +228 -54
  299. package/src/apply-stuck-action.ts +260 -0
  300. package/src/brand.ts +14 -2
  301. package/src/cli-spinner.ts +1 -1
  302. package/src/cli.ts +401 -70
  303. package/src/close-job-template.ts +1 -1
  304. package/src/complete.ts +110 -52
  305. package/src/config.ts +107 -13
  306. package/src/continue-branch.ts +22 -0
  307. package/src/decision-engine.ts +55 -17
  308. package/src/do-autopick.ts +12 -1
  309. package/src/do-config.ts +1 -1
  310. package/src/do.ts +511 -38
  311. package/src/env-config.ts +12 -4
  312. package/src/failure-cause.ts +30 -1
  313. package/src/format.ts +10 -30
  314. package/src/frontmatter.ts +35 -5
  315. package/src/gc.ts +172 -9
  316. package/src/github.ts +78 -0
  317. package/src/harness.ts +25 -0
  318. package/src/index.ts +10 -0
  319. package/src/install-ci-branch-protection.ts +282 -57
  320. package/src/install-ci-core.ts +30 -3
  321. package/src/install-ci-github.ts +86 -0
  322. package/src/install-skills.ts +166 -0
  323. package/src/intake-trigger-template.ts +1 -1
  324. package/src/intake.ts +46 -11
  325. package/src/integration-core.ts +199 -65
  326. package/src/integrator.ts +22 -9
  327. package/src/isolation.ts +11 -2
  328. package/src/item-lock.ts +485 -341
  329. package/src/ledger-write.ts +135 -150
  330. package/src/lifecycle-gather.ts +29 -6
  331. package/src/lifecycle-pools.ts +67 -9
  332. package/src/merge-question-surfacer.ts +28 -4
  333. package/src/migrate-stuck-locks.ts +451 -0
  334. package/src/needs-attention.ts +857 -84
  335. package/src/orphan-sidecar.ts +47 -6
  336. package/src/pi-harness.ts +82 -2
  337. package/src/placement.ts +3 -3
  338. package/src/prd-to-spec.ts +13 -5
  339. package/src/reap-branches.ts +38 -14
  340. package/src/recover-isolated.ts +17 -0
  341. package/src/repo-config.ts +55 -6
  342. package/src/repo-mirror.ts +23 -1
  343. package/src/review-verdict.ts +1 -1
  344. package/src/run.ts +64 -18
  345. package/src/scan.ts +22 -8
  346. package/src/select-priority.ts +3 -3
  347. package/src/sidecar-apply.ts +1 -1
  348. package/src/sidecar.ts +138 -11
  349. package/src/slug-namespace.ts +7 -7
  350. package/src/spec-complete.ts +2 -3
  351. package/src/start.ts +53 -59
  352. package/src/surface-gate.ts +10 -3
  353. package/src/surface-persist.ts +1 -1
  354. package/src/tasker-review-loop.ts +4 -3
  355. package/src/tasking-lock.ts +52 -36
  356. package/src/tasking.ts +261 -17
  357. package/src/triage-persist.ts +56 -12
  358. package/src/vendor/incur/LICENSE +21 -0
  359. package/src/vendor/incur/README.md +19 -0
  360. package/src/vendor/incur/agents.ts +392 -0
  361. package/src/verify-workflow-template.ts +1 -1
  362. package/src/watch-session.ts +102 -7
  363. package/src/work-layout.ts +6 -3
  364. package/src/workspace.ts +1 -1
@@ -3,10 +3,12 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { workFolderPrefix, workFolderRel, workItemRel, isWorkItemFile, } from './work-layout.js';
5
5
  import { run, runAsync } from './git.js';
6
- import { branchAheadOf } from './continue-branch.js';
6
+ import { branchAheadOf, rebaseContinuedBranchOntoMain, } from './continue-branch.js';
7
7
  import { acquireItemLock, releaseItemLock, readItemLock, itemLockRef, lockEntryFor, parseLockEntry, } from './item-lock.js';
8
8
  import { ledgerWrite } from './ledger-write.js';
9
9
  import { workBranchRef } from './slug-namespace.js';
10
+ import { appendQuestions, newSidecar, parseSidecar, resolveSidecarIdentity, serialiseSidecar, sidecarPathFor, } from './sidecar.js';
11
+ import { parseFrontmatter, setNeedsAnswersMarker } from './frontmatter.js';
10
12
  import { retryWithBackoff, realSleep, } from './retry-backoff.js';
11
13
  /**
12
14
  * The **needs-attention mechanism** (ADR `ledger-status-on-per-item-lock-refs`;
@@ -146,7 +148,7 @@ export async function routeToNeedsAttention(options) {
146
148
  }
147
149
  /**
148
150
  * The clean re-queue (ADR §12 / WORK-CONTRACT return path): once the human has
149
- * resolved the cause, move the stuck item back to `work/backlog/<slug>.md` and
151
+ * resolved the cause, move the stuck item back to `work/tasks/ready/<slug>.md` and
150
152
  * commit it so the item can be re-claimed (it must not rot stuck). It recovers a
151
153
  * task stuck in EITHER `work/needs-attention/<slug>.md` (the resolved-surface
152
154
  * path) OR `work/in-progress/<slug>.md` (a claim that never surfaced — an
@@ -162,12 +164,26 @@ export async function routeToNeedsAttention(options) {
162
164
  * it is the durable artifact the next claim CONTINUES from (the continue-
163
165
  * detection in `continue-branch.ts` feeds both onboarding paths). This
164
166
  * function only does the ledger move.
165
- * - **`--reset` = DISCARD + FRESH.** When `reset` is set, DELETE the remote
166
- * `work/<slug>` branch on `arbiter` FIRST (+ drop any stale local branch),
167
- * THEN the backlog move. Delete-before-move closes the claim-race window; a
168
- * FAILED delete ABORTS (no backlog move) so the item stays in
169
- * needs-attention. The next claim then finds NO arbiter branch and cuts
170
- * fresh no special claim-time logic.
167
+ * - **`--reconcile` = NON-DESTRUCTIVE RECOVERY (middle rung).** When
168
+ * `reconcile` is set AND the arbiter's `work/<slug>` exists + is ahead of
169
+ * main, re-sync the hub mirror to the arbiter (a prune-fetch that clears
170
+ * the stale-ref residue that historically resurrected a supposedly
171
+ * `--reset`-ed branch) and RETRY the rebase of the kept branch onto latest
172
+ * `<arbiter>/main` in a SCRATCH worktree (never touching the caller's
173
+ * tree, `--force-with-lease` only, NEVER a bare force). Clean rebase =>
174
+ * push the reconciled tip back to the arbiter and fall through to the
175
+ * standard keep+continue path (lock released, branch preserved). Genuine
176
+ * content conflict AFTER the clean mirror re-sync => item stays stuck,
177
+ * branch UNTOUCHED (NEVER deleted), message references the deferred
178
+ * mirror-side resolve follow-on and mentions `--reset` LAST as the
179
+ * destructive last resort. Branch absent / not-ahead => fall through to
180
+ * the default keep+continue path (which handles the fresh-claim case).
181
+ * - **`--reset` = DISCARD + FRESH (destructive last resort).** When `reset`
182
+ * is set, DELETE the remote `work/<slug>` branch on `arbiter` FIRST (+ drop
183
+ * any stale local branch), THEN the backlog move. Delete-before-move
184
+ * closes the claim-race window; a FAILED delete ABORTS (no backlog move)
185
+ * so the item stays in needs-attention. The next claim then finds NO
186
+ * arbiter branch and cuts fresh — no special claim-time logic.
171
187
  * - **`-m "<note>"` = HANDOFF NOTE.** When `message` is set, APPEND a dated
172
188
  * `## Requeue YYYY-MM-DD` section to the item BODY (append-only; accumulates
173
189
  * over repeated requeues) for the next agent. Applies to BOTH modes.
@@ -189,6 +205,43 @@ async function readLocalItemLock(slug, cwd, env) {
189
205
  }
190
206
  return parseLockEntry(show.stdout);
191
207
  }
208
+ /**
209
+ * Delete the remote `work/task-<slug>` branch on the arbiter using the SAME
210
+ * write-through ordering the `requeue --reset` recovery verb uses:
211
+ *
212
+ * 1. Delete the LOCAL tracking ref (`refs/remotes/<arbiter>/work/<...>`)
213
+ * — the ref the continue-detection path READS; if we skip this the
214
+ * staleness silently resurrects a supposedly-discarded branch (verified in
215
+ * `work/notes/observations/requeue-reset-does-not-prune-hub-mirror-stale-branch-ref.md`).
216
+ * 2. Delete any LOCAL head `work/<...>` (best-effort).
217
+ * 3. `git push <arbiter> --delete work/<...>` — the ARBITER delete, the
218
+ * source of truth. A `remote ref does not exist` / `unable to delete`
219
+ * stderr is TOLERATED as `already-gone` so the primitive is IDEMPOTENT
220
+ * and safely callable on an item with no work branch (an observation, or
221
+ * a task never built).
222
+ *
223
+ * Extracted from {@link returnToBacklog}'s `--reset` path so the apply-rung
224
+ * `kind: 'stuck'` answered `reset` verb (task
225
+ * `apply-resolve-reset-flag-discards-work-branch`) can dispatch through the
226
+ * SAME primitive without re-implementing branch deletion — the two callers
227
+ * MUST stay behaviourally identical (delete-before-move / delete-before-clear;
228
+ * local-first write-through; already-gone tolerance).
229
+ */
230
+ export async function deleteRemoteWorkBranchIfPresent(options) {
231
+ const { cwd, arbiter, slug, env } = options;
232
+ const branch = workBranchRef('task', slug);
233
+ await gitSoftAsync(['update-ref', '-d', `refs/remotes/${arbiter}/${branch}`], cwd, env);
234
+ await gitSoftAsync(['branch', '-D', branch], cwd, env);
235
+ const del = await gitSoftAsync(['push', arbiter, '--delete', branch], cwd, env);
236
+ if (del.status === 0) {
237
+ return { branch, status: 'deleted', stderr: '' };
238
+ }
239
+ const stderr = del.stderr.trim();
240
+ if (/remote ref does not exist|unable to delete/i.test(stderr)) {
241
+ return { branch, status: 'already-gone', stderr };
242
+ }
243
+ return { branch, status: 'failed', stderr };
244
+ }
192
245
  export async function returnToBacklog(options) {
193
246
  const note = options.note ?? (() => { });
194
247
  const { cwd, slug, env } = options;
@@ -249,6 +302,62 @@ export async function returnToBacklog(options) {
249
302
  'in-progress run).',
250
303
  };
251
304
  }
305
+ // `--reconcile`: the NON-DESTRUCTIVE recovery rung (task
306
+ // `requeue-reconcile-nondestructive-recovery-verb`, parent observation
307
+ // `rebase-conflict-on-continue-needs-nondestructive-recovery-not-reset`).
308
+ // When the kept `work/<slug>` exists + is ahead of `<arbiter>/main`, re-sync
309
+ // the mirror (prune-fetch — the exact step whose absence let
310
+ // `requeue-reset-does-not-prune-hub-mirror-stale-branch-ref` silently
311
+ // resurrect a stale branch) and RETRY the rebase in a SCRATCH worktree. Clean
312
+ // rebase => push the reconciled tip back (`--force-with-lease`, never bare
313
+ // force, WORK branch only — ADR §11) and fall through to the standard
314
+ // keep+continue path. Genuine content conflict AFTER the clean re-sync =>
315
+ // return with a message that LEADS with what happened, references the
316
+ // deferred mirror-side resolve path, and mentions `--reset` LAST as the
317
+ // destructive last resort. Branch absent / not-ahead => fall through to the
318
+ // default keep+continue path (which handles the fresh-claim case). NEVER
319
+ // deletes the remote branch — this verb's contract is "keep the work".
320
+ let reconciled;
321
+ if (options.reconcile) {
322
+ if (options.reset) {
323
+ return {
324
+ moved: false,
325
+ reasonNotMoved: `requeue for '${slug}': --reconcile and --reset are mutually ` +
326
+ 'exclusive (non-destructive recovery vs destructive last resort). ' +
327
+ 'Pick one.',
328
+ };
329
+ }
330
+ const attempt = await attemptReconcile({
331
+ cwd,
332
+ slug,
333
+ arbiter,
334
+ env,
335
+ note,
336
+ });
337
+ if (attempt.kind === 'conflict') {
338
+ const branch = workBranchRef('task', slug);
339
+ const message = `requeue --reconcile for '${slug}': re-synced the ${arbiter} mirror ` +
340
+ `and RETRIED the rebase of ${branch} onto latest ${arbiter}/main, but ` +
341
+ `the rebase still conflicts on genuine content (${attempt.detail}). The ` +
342
+ 'kept branch is left UNTOUCHED on the arbiter (nothing deleted) and the ' +
343
+ 'item is left stuck. A supported mirror-side "resolve against latest ' +
344
+ 'main" command that fetches the kept branch into a scratch worktree, ' +
345
+ 'rebases, and re-pushes is planned but not yet built (see observation ' +
346
+ '`rebase-conflict-on-continue-needs-nondestructive-recovery-not-reset`, ' +
347
+ 'point 2 of its LIVE residue). LAST RESORT: `requeue --reset` ' +
348
+ 'DESTRUCTIVELY discards the branch and starts fresh.';
349
+ note(message);
350
+ return { moved: false, reasonNotMoved: message };
351
+ }
352
+ if (attempt.kind === 'reconciled') {
353
+ reconciled = true;
354
+ note(`Reconciled '${slug}': re-synced the ${arbiter} mirror, rebased the ` +
355
+ `kept ${workBranchRef('task', slug)} onto latest ${arbiter}/main, and ` +
356
+ 'pushed the reconciled tip back (non-destructive; branch preserved).');
357
+ }
358
+ // 'no-branch' => fall through to the default keep+continue path (branch
359
+ // absent / not ahead — the existing default guard handles both cases).
360
+ }
252
361
  // `--reset`: DELETE the remote work branch (before the backlog move). The
253
362
  // deletion is WRITE-THROUGH: the LOCAL refs that drive continue-detection
254
363
  // (`refs/remotes/<arbiter>/work/<slug>` AND any local head `work/<slug>`) are
@@ -262,38 +371,26 @@ export async function returnToBacklog(options) {
262
371
  // continue. Delete-before-move also closes the claim-race window.
263
372
  let deletedRemoteBranch = false;
264
373
  if (options.reset) {
265
- const branch = workBranchRef('task', slug);
266
- // LOCAL-FIRST: the tracking ref `branchAheadOf` reads (the one whose
267
- // staleness today silently turns `--reset` into a no-op — verified live in
268
- // `work/notes/observations/requeue-reset-does-not-prune-hub-mirror-stale-branch-ref.md`,
269
- // where `--reset` deleted the arbiter branch but the local tracking ref
270
- // survived and resurrected a "continue" on the next `do`). Both deletes
271
- // are best-effort their absence is fine, what matters is they are not
272
- // LEFT BEHIND when the arbiter delete succeeds.
273
- await gitSoftAsync(['update-ref', '-d', `refs/remotes/${arbiter}/${branch}`], cwd, env);
274
- await gitSoftAsync(['branch', '-D', branch], cwd, env);
275
- // THEN the arbiter delete (explicit/guarded departure from the "never delete
276
- // the remote branch" invariant; only on the `--reset` path, never the
277
- // default).
278
- const del = await gitSoftAsync(['push', arbiter, '--delete', branch], cwd, env);
279
- if (del.status !== 0) {
280
- const stderr = del.stderr.trim();
281
- // Tolerate "remote ref does not exist" (already gone): treat as deleted.
282
- const alreadyGone = /remote ref does not exist|unable to delete/i.test(stderr);
283
- if (!alreadyGone) {
284
- const message = `requeue --reset for '${slug}': failed to delete the remote branch ` +
285
- `${branch} on ${arbiter} (${stderr || 'unknown error'}); ` +
286
- 'aborting the requeue — item left in needs-attention (no backlog move). ' +
287
- 'The local tracking ref was already cleared (write-through ordering); ' +
288
- 'a subsequent fetch will restore it from the arbiter — the local store ' +
289
- 'is BEHIND the arbiter (self-healing), never AHEAD (which would drive a ' +
290
- 'stale continue).';
291
- note(message);
292
- return { moved: false, reasonNotMoved: message };
293
- }
374
+ const dropped = await deleteRemoteWorkBranchIfPresent({
375
+ cwd,
376
+ arbiter,
377
+ slug,
378
+ env,
379
+ });
380
+ if (dropped.status === 'failed') {
381
+ const stderr = dropped.stderr;
382
+ const message = `requeue --reset for '${slug}': failed to delete the remote branch ` +
383
+ `${dropped.branch} on ${arbiter} (${stderr || 'unknown error'}); ` +
384
+ 'aborting the requeue item left in needs-attention (no backlog move). ' +
385
+ 'The local tracking ref was already cleared (write-through ordering); ' +
386
+ 'a subsequent fetch will restore it from the arbiter — the local store ' +
387
+ 'is BEHIND the arbiter (self-healing), never AHEAD (which would drive a ' +
388
+ 'stale continue).';
389
+ note(message);
390
+ return { moved: false, reasonNotMoved: message };
294
391
  }
295
392
  deletedRemoteBranch = true;
296
- note(`Deleted the remote branch ${branch} on ${arbiter} (--reset).`);
393
+ note(`Deleted the remote branch ${dropped.branch} on ${arbiter} (--reset).`);
297
394
  }
298
395
  // DEFAULT (keep+continue) REQUEUE-SAFETY GUARD: a claimable item's continue-
299
396
  // branch MUST be reachable by ANY worker, so before releasing the lock verify
@@ -304,14 +401,34 @@ export async function returnToBacklog(options) {
304
401
  // discards the branch by design).
305
402
  if (!options.reset) {
306
403
  const branch = workBranchRef('task', slug);
307
- const onArbiter = branchAheadOf(cwd, `${arbiter}/${branch}`, `${arbiter}/main`, env);
308
- if (!onArbiter) {
309
- const message = `the work branch ${branch} isn't on ${arbiter} (the continue ` +
310
- `branch a cross-machine worker would resume from) — push it first, or ` +
311
- '`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
312
- 'released).';
313
- note(message);
314
- return { moved: false, reasonNotMoved: message };
404
+ // Split the guard into TWO cases (task
405
+ // `default-requeue-succeeds-when-no-work-branch-exists`):
406
+ // (a) the arbiter branch does NOT EXIST at all (never pushed, or a prior
407
+ // `--reset` already deleted it) — there is NO continue-branch a future
408
+ // worker would resume from, so the guard's precondition is vacuously
409
+ // satisfied. Degrade gracefully to the same effective outcome as
410
+ // `--reset` (nothing to discard) and proceed with the keep+continue
411
+ // backlog move: no arbiter delete (there is nothing to delete), no
412
+ // forcing the caller into the destructive `--reset` verb.
413
+ // (b) the arbiter branch EXISTS but is NOT ahead of `<arbiter>/main` — a
414
+ // real anomaly (the continue-branch would resume from a state already
415
+ // reachable from main). Preserve today's refusal so the case surfaces.
416
+ const tip = gitSoftRun(['rev-parse', '--verify', '--quiet', `${arbiter}/${branch}^{commit}`], cwd, env);
417
+ const arbiterBranchExists = tip.status === 0 && tip.stdout.trim() !== '';
418
+ if (!arbiterBranchExists) {
419
+ note(`'${slug}' has no work branch on ${arbiter} — requeueing to backlog ` +
420
+ 'for a FRESH claim (nothing to continue from; no --reset needed).');
421
+ }
422
+ else {
423
+ const onArbiter = branchAheadOf(cwd, `${arbiter}/${branch}`, `${arbiter}/main`, env);
424
+ if (!onArbiter) {
425
+ const message = `the work branch ${branch} isn't on ${arbiter} (the continue ` +
426
+ `branch a cross-machine worker would resume from) — push it first, or ` +
427
+ '`requeue --reset` to discard and start fresh. Item left stuck (lock not ' +
428
+ 'released).';
429
+ note(message);
430
+ return { moved: false, reasonNotMoved: message };
431
+ }
315
432
  }
316
433
  }
317
434
  const commitMessage = `chore(${slug}): return to backlog for re-claiming`;
@@ -394,7 +511,85 @@ export async function returnToBacklog(options) {
394
511
  return { moved: false, reasonNotMoved: message };
395
512
  }
396
513
  note(`Returned '${slug}' to backlog (released the lock; body rests in pool).`);
397
- return { moved: true, commitMessage, deletedRemoteBranch };
514
+ return { moved: true, commitMessage, deletedRemoteBranch, reconciled };
515
+ }
516
+ /**
517
+ * The `--reconcile` recovery attempt — the non-destructive middle rung of the
518
+ * `requeue` escalation ladder. Runs in a SCRATCH worktree so the caller's cwd
519
+ * tree/HEAD/index is NEVER touched (parity with the tree-less move machinery):
520
+ *
521
+ * 1. **Re-sync the mirror.** `git fetch --prune <arbiter>` on the caller's
522
+ * cwd — the exact prune step whose absence let
523
+ * `requeue-reset-does-not-prune-hub-mirror-stale-branch-ref` silently
524
+ * resurrect a supposedly-`--reset`-ed branch. This clears the stale
525
+ * remote-tracking residue that historically fooled the retry.
526
+ * 2. **Guard.** The arbiter's `work/<slug>` must EXIST + be ahead of main
527
+ * (via {@link branchAheadOf} on the freshly-fetched
528
+ * `<arbiter>/work/<slug>` vs `<arbiter>/main`). Absent / not-ahead =>
529
+ * `'no-branch'` and the caller falls through to the default keep+continue
530
+ * path.
531
+ * 3. **Rebase in a scratch worktree.** `git worktree add --detach <scratch>
532
+ * <arbiter>/work/<slug>` and run {@link rebaseContinuedBranchOntoMain}
533
+ * against `<arbiter>/main`. A CLEAN rebase is a full non-destructive fix;
534
+ * a CONFLICT is `--abort`ed (never auto-resolved) => `'conflict'`.
535
+ * 4. **Push the reconciled tip back.** `git push <arbiter> HEAD:work/<slug>
536
+ * --force-with-lease=work/<slug>:<observed-arbiter-tip>` from the scratch
537
+ * worktree. `--force-with-lease` ONLY, NEVER bare `--force`, NEVER
538
+ * `:main`, the WORK branch ONLY (ADR §11). A rejected push (stale lease
539
+ * or otherwise) => `'conflict'` — non-destructive by construction, the
540
+ * user can retry.
541
+ *
542
+ * Cleanup of the scratch worktree is best-effort in a `finally` (never fails
543
+ * the reconcile on cleanup).
544
+ */
545
+ async function attemptReconcile(params) {
546
+ const { cwd, slug, arbiter, env, note } = params;
547
+ const branch = workBranchRef('task', slug);
548
+ const arbBranchRef = `refs/remotes/${arbiter}/${branch}`;
549
+ const arbMainRef = `refs/remotes/${arbiter}/main`;
550
+ // 1. Re-sync the mirror to the arbiter (prune-fetch clears stale refs).
551
+ await gitSoftAsync(['fetch', '--prune', '--quiet', arbiter], cwd, env);
552
+ // 2. Guard: branch must exist + be ahead of main.
553
+ if (!branchAheadOf(cwd, arbBranchRef, arbMainRef, env)) {
554
+ return { kind: 'no-branch' };
555
+ }
556
+ // 3. Scratch worktree — the caller's tree is NEVER touched.
557
+ const worktree = join(tmpdir(), `dorfl-reconcile-${slug}-${process.pid}-${Date.now()}`);
558
+ const wtCreate = gitSoftRun(['worktree', 'add', '--quiet', '--detach', worktree, arbBranchRef], cwd, env);
559
+ if (wtCreate.status !== 0) {
560
+ return {
561
+ kind: 'conflict',
562
+ detail: `could not create scratch worktree (${wtCreate.stderr.trim() || `exit ${wtCreate.status}`})`,
563
+ };
564
+ }
565
+ try {
566
+ const rebase = rebaseContinuedBranchOntoMain(worktree, arbMainRef, env);
567
+ if (rebase.kind === 'conflict') {
568
+ return { kind: 'conflict', detail: 'rebase conflicted after re-sync' };
569
+ }
570
+ // 4. Push the reconciled tip back (`--force-with-lease`, WORK branch only).
571
+ const observedTip = gitSoftRun(['rev-parse', '--verify', '--quiet', `${arbBranchRef}^{commit}`], cwd, env).stdout.trim();
572
+ const lease = observedTip === '' ? `${branch}:` : `${branch}:${observedTip}`;
573
+ const push = gitSoftRun([
574
+ 'push',
575
+ arbiter,
576
+ `HEAD:refs/heads/${branch}`,
577
+ `--force-with-lease=${lease}`,
578
+ ], worktree, env);
579
+ if (push.status !== 0) {
580
+ return {
581
+ kind: 'conflict',
582
+ detail: `push of reconciled tip rejected (${push.stderr.trim() || `exit ${push.status}`})`,
583
+ };
584
+ }
585
+ // Advance the local remote-tracking ref so subsequent reads see the truth.
586
+ await gitSoftAsync(['fetch', '--quiet', arbiter], cwd, env);
587
+ void note;
588
+ return { kind: 'reconciled' };
589
+ }
590
+ finally {
591
+ await gitSoftAsync(['worktree', 'remove', '--force', worktree], cwd, env);
592
+ }
398
593
  }
399
594
  export async function promoteFromPreBacklog(options) {
400
595
  const note = options.note ?? (() => { });
@@ -456,13 +651,13 @@ export async function promoteFromPreBacklog(options) {
456
651
  const hasSource = (await gitSoftAsync(['cat-file', '-e', `${arbiter}/main:${sourceRel}`], cwd, env)).status === 0;
457
652
  const hasDest = (await gitSoftAsync(['cat-file', '-e', `${arbiter}/main:${destRel}`], cwd, env)).status === 0;
458
653
  if (!hasSource && !hasDest) {
459
- const message = `'${slug}' is not staged in work/pre-backlog/ on ${arbiter}/main (and not ` +
460
- 'already in work/backlog/) — nothing to promote (wrong slug, or never ' +
461
- 'staged?).';
654
+ const message = `'${slug}' is not staged in ${workFolderPrefix('tasks-backlog')} on ${arbiter}/main ` +
655
+ `(and not already in ${workFolderPrefix('tasks-ready')}) — nothing to promote ` +
656
+ '(wrong slug, or never staged?).';
462
657
  note(message);
463
658
  return { moved: false, reasonNotMoved: message };
464
659
  }
465
- const commitMessage = `chore(${slug}): promote work/pre-backlog/ -> work/backlog/`;
660
+ const commitMessage = `chore(${slug}): promote ${workFolderPrefix('tasks-backlog')} -> ${workFolderPrefix('tasks-ready')}`;
466
661
  const moved = await runTreelessLedgerMove({
467
662
  cwd,
468
663
  slug,
@@ -490,7 +685,7 @@ export async function promoteFromPreBacklog(options) {
490
685
  base,
491
686
  sourceRel,
492
687
  destRel,
493
- // The body is carried byte-for-byte from pre-backlog into the
688
+ // The body is carried byte-for-byte from tasks/backlog into the
494
689
  // pool — promotion is a placement decision, not a content transform.
495
690
  transformBody: (body) => body,
496
691
  commitMessage,
@@ -500,12 +695,12 @@ export async function promoteFromPreBacklog(options) {
500
695
  },
501
696
  });
502
697
  if (moved) {
503
- note(`Promoted '${slug}' from pre-backlog to backlog (claimable).`);
698
+ note(`Promoted '${slug}' from tasks/backlog to tasks/ready (claimable).`);
504
699
  return { moved: true, commitMessage };
505
700
  }
506
701
  const message = `promote for '${slug}': the arbiter's main kept moving (contended) after ` +
507
- `${TREELESS_CONTENTION_ATTEMPTS} attempts — item left in pre-backlog (no ` +
508
- 'move). Try again shortly.';
702
+ `${TREELESS_CONTENTION_ATTEMPTS} attempts — item left in tasks/backlog ` +
703
+ '(no move). Try again shortly.';
509
704
  note(message);
510
705
  return { moved: false, reasonNotMoved: message };
511
706
  }
@@ -545,7 +740,7 @@ export async function promoteFromPreSpec(options) {
545
740
  // (spec `prd-to-spec-vocabulary-cutover-and-migration-command`): the lock
546
741
  // identity is `spec:${slug}` to match the `spec-<slug>` entry the tasking/apply
547
742
  // path now acquires (`tasking.ts` releases under `spec:${slug}`); a stale
548
- // `prd:${slug}` here would key a DIFFERENT ref and break the mutual exclusion.
743
+ // ''prd:${slug}'' here would key a DIFFERENT ref and break the mutual exclusion.
549
744
  // Loss / crash semantics mirror the task case.
550
745
  const item = `spec:${slug}`;
551
746
  const acquired = await acquireItemLock({
@@ -622,7 +817,7 @@ export async function promoteFromPreSpec(options) {
622
817
  }
623
818
  /**
624
819
  * LIST every staged item awaiting a runner/human promotion — the tasks in
625
- * `work/pre-backlog/` and the prds in `work/specs/proposed/` on `<arbiter>/main` (the
820
+ * `work/tasks/backlog/` and the prds in `work/specs/proposed/` on `<arbiter>/main` (the
626
821
  * discovery half of the `promote` verb, so `promote` with no argument answers
627
822
  * "what is staged waiting for me?"). It reads the ARBITER's truth (a fetch + a
628
823
  * tree read), NOT the local working tree (which may be stale) — the same source
@@ -785,6 +980,286 @@ function prepareTreelessMoveCommit(params) {
785
980
  }
786
981
  /** The heading that opens an appended requeue handoff note in the item body. */
787
982
  const REQUEUE_HEADING_PREFIX = '## Requeue';
983
+ // --- Tree-less SURFACE primitive (PR-1, spec
984
+ // `surface-stuck-as-questions-and-retire-stuck-lock-state`, task
985
+ // `bounce-surfaces-stuck-sidecar-and-releases-lock`) --------------------
986
+ /**
987
+ * PR-1 ADDITIVE primitive (task `bounce-surfaces-stuck-sidecar-and-releases-lock`).
988
+ * The 2-file SIBLING of {@link prepareTreelessMoveCommit}: pure git plumbing
989
+ * (`hash-object` / scratch-index `update-index` / `write-tree` /
990
+ * `commit-tree`, NEVER touches the caller's index/HEAD/working tree) that in
991
+ * ONE commit off {@link base} both
992
+ *
993
+ * 1. writes or appends to the item's `work/questions/<type>-<slug>.md`
994
+ * sidecar (see {@link sidecarPathFor}) a `stuck`-kind entry carrying the
995
+ * bounce {@link reason} plus any agent-surfaced {@link questions}, and
996
+ * 2. sets `needsAnswers: true` on the item body at {@link itemPath} (via
997
+ * {@link setNeedsAnswersMarker}).
998
+ *
999
+ * The current sidecar (if any) and the current item body are read as BLOBS off
1000
+ * `<arbiter>/main` (via {@link catBlob}) — no working tree required and no
1001
+ * dependency on the cwd's `HEAD` matching `main`. That is what makes this the
1002
+ * tree-less path's surface primitive: a `applyTreelessNeedsAttentionTransition`
1003
+ * caller (`continue-push-failure` / rebase-conflict) has NO writable-`main`
1004
+ * checkout, so `persistSurfacedQuestions` (working-tree bound) cannot be reused;
1005
+ * the pure CONTENT builders (`newSidecar` / `appendQuestions` /
1006
+ * `serialiseSidecar` / `setNeedsAnswersMarker`) ARE reused, only the commit
1007
+ * mechanism differs (spec decision #7).
1008
+ *
1009
+ * Returns the throwaway ref + commit sha, exactly like {@link
1010
+ * prepareTreelessMoveCommit}, so the SAME {@link runTreelessLedgerMove} CAS loop
1011
+ * publishes the surface commit through the shared write seam. The
1012
+ * surface-first / release-second ordering + `main`-authoritative crash-safety
1013
+ * (spec decision #4) come FREE from routing through that loop — the caller wires
1014
+ * the release into a `finally` AFTER a successful publish (see the harness
1015
+ * {@link surfaceStuckToNeedsAttention}).
1016
+ *
1017
+ * DECISION — sidecar entry shape for a reason-only bounce (build-time, PR-1):
1018
+ * every bounce always appends ONE engine-authored `stuck`-kind envelope entry
1019
+ * whose `question` names the item and whose `context` is the {@link reason}
1020
+ * prose, THEN any {@link questions} the agent surfaced. So a reason-only bounce
1021
+ * (no agent questions) still surfaces exactly ONE entry a human can answer, and
1022
+ * the agent's own questions (when present) are appended AFTER it, verbatim. The
1023
+ * envelope entry is what turns a raw exit reason into a human-drainable
1024
+ * question; the extra entries are the LLM prose the spec's decision #2 keeps
1025
+ * untouched. Alternative considered — treating an empty `questions` array as a
1026
+ * NO-OP surface — REJECTED because that is the spec's `stuck` retirement
1027
+ * problem all over again (a bounced item with no on-`main` outcome).
1028
+ * Alternative considered — dropping the envelope when agent questions are given
1029
+ * — REJECTED because the reason is engine-authored ground truth; the agent's
1030
+ * questions are advisory prose ABOVE it, not a replacement for it.
1031
+ *
1032
+ * PR-1 boundary: this primitive is EXERCISED BY TESTS ONLY; it is NOT yet
1033
+ * called from any bounce seam. Wiring `applyNeedsAttentionTransition` /
1034
+ * `applyTreelessNeedsAttentionTransition` to it — and migrating the existing
1035
+ * `stuckLockOnArbiter(...).toBe(true)` assertions — is the follow-up PR-2 task
1036
+ * `bounce-atomic-cutover-retire-stuck-lock`.
1037
+ */
1038
+ export function prepareTreelessSurfaceCommit(params) {
1039
+ const { cwd, slug, item, itemPath, base, reason, questions, envelope: envelopeOverride, commitMessage, refNamespace, env, } = params;
1040
+ const sidecarPath = sidecarPathFor(item);
1041
+ // Read the item body off `main` as a BLOB — never off the cwd working tree,
1042
+ // which the tree-less caller does not have on `main`. `catBlob` throws when
1043
+ // the path is not tracked, which is the honest signal: the tree-less bounce
1044
+ // only fires against an item whose body already rests on `main` (the caller's
1045
+ // plan should short-circuit `missing` beforehand).
1046
+ const itemBody = catBlob(`${base}:${itemPath}`, cwd, env);
1047
+ const flagged = setNeedsAnswersMarker(itemBody, true);
1048
+ // Defense-in-depth (the `sidecar-without-needsAnswers` guard, mirrored from
1049
+ // `persistSurfacedQuestions`): if the marker did not actually parse back as
1050
+ // `true`, refuse to write the sidecar rather than tear the
1051
+ // `needsAnswers ⟺ sidecar` invariant.
1052
+ if (parseFrontmatter(flagged).needsAnswers !== true) {
1053
+ throw new Error(`prepareTreelessSurfaceCommit: could not set needsAnswers:true on '${itemPath}' ` +
1054
+ `for '${item}' — refusing to surface without the flag.`);
1055
+ }
1056
+ // Compose the entries: an engine-authored envelope carrying the reason, then
1057
+ // any agent-surfaced questions (stamped `stuck`-kind if the caller left the
1058
+ // kind unset — this IS the stuck-surface path). Callers may OVERRIDE the
1059
+ // envelope (e.g. the empty-diff path swaps in a dispose-defaulted question);
1060
+ // the override still defaults `kind` to `stuck` and `context` to the bounce
1061
+ // reason when the override leaves them unset (the caller can restate the
1062
+ // reason in the envelope prose without duplicating it in `context`).
1063
+ const envelope = envelopeOverride
1064
+ ? {
1065
+ kind: envelopeOverride.kind ?? 'stuck',
1066
+ question: envelopeOverride.question,
1067
+ context: envelopeOverride.context ?? reason,
1068
+ ...(envelopeOverride.default !== undefined
1069
+ ? { default: envelopeOverride.default }
1070
+ : {}),
1071
+ }
1072
+ : {
1073
+ question: `'${item}' was bounced — how should we proceed?`,
1074
+ context: reason,
1075
+ kind: 'stuck',
1076
+ };
1077
+ const surfaced = (questions ?? []).map((q) => ({
1078
+ ...q,
1079
+ kind: q.kind ?? 'stuck',
1080
+ }));
1081
+ const additions = [envelope, ...surfaced];
1082
+ // APPEND to an existing sidecar on `main` (never overwrite) or CREATE it
1083
+ // first-pass — the same append-never-overwrite rule the working-tree surface
1084
+ // path enforces. Read the current sidecar off `main` as a BLOB.
1085
+ const sidecarExists = pathInCommit(base, sidecarPath, cwd, env);
1086
+ const model = sidecarExists
1087
+ ? appendQuestions(parseSidecar(catBlob(`${base}:${sidecarPath}`, cwd, env)), additions)
1088
+ : newSidecar(item, additions);
1089
+ const sidecarContent = serialiseSidecar(model);
1090
+ // Hash both blobs INTO the cwd's object store (no working tree write).
1091
+ const itemBlob = hashObject(flagged, cwd, env);
1092
+ const sidecarBlob = hashObject(sidecarContent, cwd, env);
1093
+ // A scratch index so `read-tree` / `update-index` never touch the caller's
1094
+ // index. `--add --cacheinfo` both ADDS a new entry and REPLACES an existing
1095
+ // one (so a re-surface that rewrites the sidecar path is a no-op replace).
1096
+ const scratchIndex = join(tmpdir(), `dorfl-${refNamespace}-${process.pid}-${Date.now()}.index`);
1097
+ const withIndex = {
1098
+ ...(env ?? process.env),
1099
+ GIT_INDEX_FILE: scratchIndex,
1100
+ };
1101
+ try {
1102
+ gitHard(['read-tree', base], cwd, withIndex);
1103
+ gitHard([
1104
+ 'update-index',
1105
+ '--add',
1106
+ '--cacheinfo',
1107
+ `100644,${itemBlob},${itemPath}`,
1108
+ ], cwd, withIndex);
1109
+ gitHard([
1110
+ 'update-index',
1111
+ '--add',
1112
+ '--cacheinfo',
1113
+ `100644,${sidecarBlob},${sidecarPath}`,
1114
+ ], cwd, withIndex);
1115
+ const tree = runHard(['write-tree'], cwd, withIndex).stdout.trim();
1116
+ const commit = runHard(['commit-tree', tree, '-p', base, '-m', commitMessage], cwd, env).stdout.trim();
1117
+ const ref = `refs/dorfl/${refNamespace}/${slug}`;
1118
+ gitHard(['update-ref', ref, commit], cwd, env);
1119
+ return { ref, commit };
1120
+ }
1121
+ finally {
1122
+ rmSync(scratchIndex, { force: true });
1123
+ }
1124
+ }
1125
+ /**
1126
+ * The PR-1 thin HARNESS around {@link prepareTreelessSurfaceCommit}: run it
1127
+ * through the EXISTING {@link runTreelessLedgerMove} CAS loop, then release the
1128
+ * per-item lock. Ordering is LOAD-BEARING and INHERITED from that harness —
1129
+ * the surface commit lands on `<arbiter>/main` FIRST, and the lock release only
1130
+ * fires on a successful publish. `main` is authoritative on crash recovery
1131
+ * (spec decision #4).
1132
+ *
1133
+ * PR-1 boundary: this is EXERCISED BY TESTS ONLY. Wiring the seams to call it
1134
+ * is the PR-2 task `bounce-atomic-cutover-retire-stuck-lock`.
1135
+ */
1136
+ export async function surfaceStuckToNeedsAttention(options) {
1137
+ const note = options.note ?? (() => { });
1138
+ // Merge of #364 (envelope) + PR-2a (itemPath is re-declared as `let` below for
1139
+ // the D1 probe fallback, so it must NOT be in this const destructure).
1140
+ const { cwd, slug, reason, questions, envelope, arbiter, env } = options;
1141
+ const item = options.item ?? `task:${slug}`;
1142
+ // PR-2a D1 body-path probe: when the caller does not name an on-`main`
1143
+ // body path, PROBE `<arbiter>/main` in a fixed order per namespace
1144
+ // (task/spec/observation). A body-absent probe is a CLEAN NO-OP surface
1145
+ // that STILL releases the lock — never leave a bounce as a dead-end held
1146
+ // lock.
1147
+ let itemPath = options.itemPath;
1148
+ if (itemPath === undefined) {
1149
+ const probed = await resolveBounceItemBodyPathOnMain({
1150
+ cwd,
1151
+ item,
1152
+ arbiter,
1153
+ env,
1154
+ });
1155
+ if (probed === undefined) {
1156
+ // Body-absent: skip the surface commit entirely and STILL release the
1157
+ // lock (idempotent). Signal the distinction on the result so a caller
1158
+ // can tell a body-absent no-op apart from a `missing` plan on a
1159
+ // caller-provided itemPath.
1160
+ const rel = await releaseItemLock({ item, cwd, arbiter, env });
1161
+ const released = rel.outcome === 'released' || rel.outcome === 'not-held';
1162
+ return {
1163
+ surfaced: false,
1164
+ released,
1165
+ bodyAbsent: true,
1166
+ reasonNotSurfaced: `no body for '${item}' on ${arbiter}/main (probed the D1 ` +
1167
+ 'candidates in order) — surface skipped as a clean no-op; the ' +
1168
+ 'lock was still released to avoid a dead-end held lock.',
1169
+ };
1170
+ }
1171
+ itemPath = probed;
1172
+ }
1173
+ const resolvedItemPath = itemPath;
1174
+ const surfaced = await runTreelessLedgerMove({
1175
+ cwd,
1176
+ slug,
1177
+ arbiter,
1178
+ kind: 'needs-attention',
1179
+ onContended: 'surface',
1180
+ explicitMainRefspec: true,
1181
+ env,
1182
+ note,
1183
+ plan: (base) => {
1184
+ if (!pathInCommit(base, resolvedItemPath, cwd, env)) {
1185
+ return 'missing';
1186
+ }
1187
+ return prepareTreelessSurfaceCommit({
1188
+ cwd,
1189
+ slug,
1190
+ item,
1191
+ itemPath: resolvedItemPath,
1192
+ base,
1193
+ reason,
1194
+ questions,
1195
+ envelope,
1196
+ commitMessage: `surface ${item} (stuck): ${reason}`,
1197
+ refNamespace: 'surface-stuck',
1198
+ env,
1199
+ });
1200
+ },
1201
+ });
1202
+ if (!surfaced) {
1203
+ return {
1204
+ surfaced: false,
1205
+ released: false,
1206
+ reasonNotSurfaced: `surface for '${item}' did not land on ${arbiter}/main ` +
1207
+ '(item missing on main, or contention exhausted after retries).',
1208
+ };
1209
+ }
1210
+ // Surface-first / release-second: only reach here on a successful publish.
1211
+ const rel = await releaseItemLock({ item, cwd, arbiter, env });
1212
+ const released = rel.outcome === 'released' || rel.outcome === 'not-held';
1213
+ return { surfaced: true, released };
1214
+ }
1215
+ /**
1216
+ * The D1 body-path probe (PR-2a task `bounce-atomic-cutover-retire-stuck-lock`,
1217
+ * spec `surface-stuck-as-questions-and-retire-stuck-lock-state`, decision D1):
1218
+ * resolve the item's on-`main` body path by probing `<arbiter>/main` in a
1219
+ * FIXED order per namespace:
1220
+ * - task: `work/tasks/ready/<slug>.md` then `work/tasks/backlog/<slug>.md`.
1221
+ * - spec: `work/specs/ready/<slug>.md` then `work/specs/proposed/<slug>.md`.
1222
+ * - observation: `work/notes/observations/<slug>.md`.
1223
+ *
1224
+ * Returns the first candidate that EXISTS on `<arbiter>/main`, or `undefined`
1225
+ * when no candidate exists (a bounce for an item whose body never landed on
1226
+ * `main`). The caller MUST handle `undefined` by STILL releasing the lock —
1227
+ * never leave a held lock over a body-absent item.
1228
+ *
1229
+ * The probe fetches `<arbiter>/main` with an EXPLICIT refspec (the
1230
+ * `runTreelessLedgerMove` pattern) so a job-worktree with a narrower default
1231
+ * fetch refspec still resolves `<arbiter>/main` reliably.
1232
+ */
1233
+ export async function resolveBounceItemBodyPathOnMain(params) {
1234
+ const { cwd, item, arbiter, env } = params;
1235
+ const { type, slug } = resolveSidecarIdentity(item);
1236
+ await gitSoftAsync([
1237
+ 'fetch',
1238
+ '--quiet',
1239
+ arbiter,
1240
+ `+refs/heads/main:refs/remotes/${arbiter}/main`,
1241
+ ], cwd, env);
1242
+ const base = `${arbiter}/main`;
1243
+ const folders = BOUNCE_BODY_PROBE_ORDER[type];
1244
+ for (const folder of folders) {
1245
+ const rel = workItemRel(folder, `${slug}.md`);
1246
+ if (pathInCommit(base, rel, cwd, env)) {
1247
+ return rel;
1248
+ }
1249
+ }
1250
+ return undefined;
1251
+ }
1252
+ /** The FIXED per-namespace probe order for {@link resolveBounceItemBodyPathOnMain}
1253
+ * (D1): the FIRST candidate that exists on `<arbiter>/main` is the item's body
1254
+ * path. The ordering encodes the working assumption that a claimed/in-flight
1255
+ * item is in `ready/` and a not-yet-promoted item is in `backlog/`
1256
+ * (spec-tasked/proposed for a spec); an observation has one location.
1257
+ * Callers with an explicit `itemPath` bypass this probe entirely. */
1258
+ const BOUNCE_BODY_PROBE_ORDER = {
1259
+ task: ['tasks-ready', 'tasks-backlog'],
1260
+ spec: ['specs-ready', 'specs-proposed'],
1261
+ observation: ['observations'],
1262
+ };
788
1263
  /**
789
1264
  * Append a dated `## Requeue YYYY-MM-DD` handoff section to an item body's TEXT
790
1265
  * (append-only — never overwrites; repeated requeues accumulate a handoff log).