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
@@ -1,20 +1,24 @@
1
- import {existsSync, readFileSync} from 'node:fs';
2
- import {join} from 'node:path';
1
+ import {existsSync, mkdirSync, readFileSync, writeFileSync} from 'node:fs';
2
+ import {dirname, join} from 'node:path';
3
3
  import {run, type RunResult} from './git.js';
4
+ import {setFrontmatterMarker} from './frontmatter.js';
4
5
  import {applyAtomic, type ApplyAtomicResult} from './sidecar-apply.js';
5
6
  import {
6
7
  allAnswered,
7
8
  appendQuestions,
8
9
  parseSidecar,
10
+ resolveSidecarIdentity,
9
11
  sidecarPathFor,
10
12
  type NewQuestion,
11
13
  type SidecarEntry,
12
14
  type SidecarModel,
15
+ type SidecarType,
13
16
  } from './sidecar.js';
14
17
  import {
15
18
  APPLY_LIFECYCLE_FOLDERS,
16
19
  resolveItemPathByIdentity,
17
20
  } from './item-path.js';
21
+ import {workItemRel} from './work-layout.js';
18
22
 
19
23
  /**
20
24
  * The engine-owned APPLY PERSIST (spec `advance-loop`, task `advance-rung-apply`;
@@ -27,10 +31,15 @@ import {
27
31
  * - **append / re-pause** — when the apply has NEW questions to ask: append
28
32
  * `qN+1…`, stay `needsAnswers:true`, re-pause (the "all answered?" flips back
29
33
  * to false); OR
30
- * - **discharge by deletion** — when the caller decided the SOURCE should leave
31
- * by deletion (`discharge` set): `git rm` the source + sidecar in a STANDALONE
32
- * revertible commit, the reason in the commit message (git history is the
33
- * archive); OR
34
+ * - **dispose** — when the caller decided the SOURCE should be DISPOSED
35
+ * (`dispose` set): REGIME-POLYMORPHIC on the source's type (task
36
+ * `apply-disposition-delete-to-dispose-regime-polymorphic`, spec
37
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state` decision #5):
38
+ * an OBSERVATION is `git rm`-ed with its sidecar in one revertible commit
39
+ * (reason in the message, git history = archive); a TASK is `git mv`-ed to
40
+ * `tasks/cancelled/` (RETAINED, `reason:` written into the moved body); a
41
+ * SPEC is `git mv`-ed to `specs/dropped/` (RETAINED). A task can NEVER be
42
+ * hard-deleted from here — dispose is the only path off the board; OR
34
43
  * - **resolve fully** (the default) — clear `needsAnswers` + DELETE the sidecar
35
44
  * in the SAME atomic commit (the invariant `needsAnswers:false ⟺ no active
36
45
  * sidecar`); the item advances toward build by its normal lifecycle.
@@ -41,19 +50,19 @@ import {
41
50
  * no `keep`/`triaged:keep` resting state. A sidecar entry is BINARY (no-answer |
42
51
  * answered); what to DO with a fully-answered OBSERVATION is decided by the
43
52
  * AGENTIC apply decision in the advance tick (`advance.ts` `applyRung` over the
44
- * shared `decide` engine), which then routes here (re-pause / discharge / via
53
+ * shared `decide` engine), which then routes here (re-pause / dispose / via
45
54
  * `promoteObservation` for a mint). A signal is still-open, acted-on, or deleted
46
55
  * — there is no \"retain as resolved\" state.
47
56
  *
48
57
  * The work-item (task/spec) terminal MOVES (`tasks/cancelled`, `specs/dropped`) and
49
- * the `needs-attention/` LIFECYCLE state are a SEPARATE lifecycle concern (a
58
+ * the stuck (lock `state: stuck`) LIFECYCLE state are a SEPARATE lifecycle concern (a
50
59
  * task/spec is dropped by its own lifecycle, not by a question answer) — they are
51
60
  * NOT routed from here any more (they were the removed disposition vocabulary).
52
61
  *
53
62
  * It is the SIBLING of {@link import('./surface-persist.js').persistSurfacedQuestions}:
54
63
  * that is the SURFACE rung's one-commit primitive (append-or-create + set
55
64
  * `needsAnswers`); this is the APPLY rung's (apply answers + resolve / re-pause /
56
- * discharge). Kept file-orthogonal so the rung bodies land in different tasks.
65
+ * dispose). Kept file-orthogonal so the rung bodies land in different tasks.
57
66
  *
58
67
  * **NEVER invents an answer (US #4).** The apply rung applies ONLY what the human
59
68
  * authored — the recorded `answer:` text. It does NOT fill, guess, or author an
@@ -93,7 +102,7 @@ const APPLIED_HEADING = '## Applied answers';
93
102
  * (backward compat — no marker ⇒ nothing to strip ⇒ identical bytes).
94
103
  *
95
104
  * The sibling task `templates-mark-transient-open-questions-block` introduces
96
- * the markers in the prd/task templates; this task exports the constants so
105
+ * the markers in the spec/task templates; this task exports the constants so
97
106
  * the two tasks agree on the literal byte sequence.
98
107
  */
99
108
  export const OPEN_QUESTIONS_MARKER_OPEN = '<!-- open-questions -->';
@@ -116,19 +125,30 @@ export interface ApplyAnsweredQuestionsOptions {
116
125
  * a caller-supplied batch). When non-empty, the apply APPENDS them (`qN+1…`,
117
126
  * never mutating an answered entry) and RE-PAUSES (`needsAnswers:true` stays) —
118
127
  * the "all answered?" flips back to false. Empty/omitted ⇒ resolve the item (or
119
- * discharge it, when `discharge` is set).
128
+ * dispose it, when `dispose` is set).
120
129
  */
121
130
  appendQuestions?: NewQuestion[];
122
131
  /**
123
- * DISCHARGE the SOURCE by DELETION (the agentic `delete-source` verdict, or a
124
- * direct discharge): instead of resolving in place, `git rm` the source + its
125
- * answered sidecar in a STANDALONE revertible commit, the `reason` recorded in
126
- * the commit message (git history is the archive). Fires DIRECT (no
127
- * preview/confirm decision 12). Mutually exclusive with `appendQuestions`
128
- * (re-pause and discharge cannot both happen on one apply); when both are given
129
- * the re-pause wins (you cannot discharge a source you are still asking about).
132
+ * DISPOSE the SOURCE (the agentic `dispose` verdict, or a direct disposal):
133
+ * REGIME-POLYMORPHIC on the source's type (task
134
+ * `apply-disposition-delete-to-dispose-regime-polymorphic`, spec
135
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state` decision #5):
136
+ * - OBSERVATION `git rm` the source + its answered sidecar in a STANDALONE
137
+ * revertible commit, the `reason` recorded in the commit message (git
138
+ * history is the archive; notes leave by deletion, decision 12);
139
+ * - TASK → `git mv` the source to the task regime's won't-proceed terminal
140
+ * `tasks/cancelled/` (RETAINED, `reason:` written into the moved body's
141
+ * frontmatter); the sidecar is `git rm`-ed in the same commit;
142
+ * - SPEC → `git mv` the source to `specs/dropped/` (RETAINED); sidecar
143
+ * `git rm`-ed in the same commit.
144
+ * A TASK can NEVER be hard-deleted through this option — dispose is the only
145
+ * path off the board, true by construction (there is no `delete: true` escape
146
+ * hatch here). Fires DIRECT (no preview/confirm — decision 12). Mutually
147
+ * exclusive with `appendQuestions` (re-pause and dispose cannot both happen on
148
+ * one apply); when both are given the re-pause wins (you cannot dispose a
149
+ * source you are still asking about).
130
150
  */
131
- discharge?: {reason: string};
151
+ dispose?: {reason: string};
132
152
  /** Advisory committer id for the commit subject. Defaults to git user.name. */
133
153
  by?: string;
134
154
  /** Environment for child git processes. */
@@ -144,14 +164,26 @@ export type ApplyTerminal =
144
164
  /** New questions appended; stayed needsAnswers:true and re-paused. */
145
165
  | 'repaused'
146
166
  /**
147
- * The SOURCE was DISCHARGED BY DELETION (the agentic `delete-source` verdict, or
148
- * a direct discharge): the source (+ its answered sidecar) were `git rm`-ed in a
149
- * STANDALONE revertible commit, the reason recorded in the commit message (git
150
- * history = archive). A discharged item leaves by being GONE — there is no
151
- * resting marker. This is the apply rung applying the human's RATIFIED answer
152
- * (human-authored, not a unilateral agent destruction of a live signal).
167
+ * The SOURCE (an OBSERVATION) was disposed BY DELETION (the agentic `dispose`
168
+ * verdict on an observation, or a direct disposal): the source (+ its answered
169
+ * sidecar) were `git rm`-ed in a STANDALONE revertible commit, the reason in
170
+ * the commit message (git history = archive). Notes leave by being GONE —
171
+ * there is no resting marker. This is the apply rung applying the human's
172
+ * RATIFIED answer (human-authored, not a unilateral agent destruction of a
173
+ * live signal). Preserved verbatim as `'deleted'` for observation disposals;
174
+ * see {@link ApplyTerminal | 'disposed'} for the task/spec branches.
153
175
  */
154
176
  | 'deleted'
177
+ /**
178
+ * The SOURCE (a TASK or SPEC) was DISPOSED to its regime's won't-proceed
179
+ * terminal (task `apply-disposition-delete-to-dispose-regime-polymorphic`,
180
+ * spec `surface-stuck-as-questions-and-retire-stuck-lock-state` decision #5):
181
+ * a task `git mv`-ed to `tasks/cancelled/`, a spec `git mv`-ed to
182
+ * `specs/dropped/`. The file is RETAINED (git history + terminal folder is
183
+ * the archive), with the human's `reason:` written into the moved body's
184
+ * frontmatter and the answered sidecar `git rm`-ed in the same commit.
185
+ */
186
+ | 'disposed'
155
187
  /**
156
188
  * The item file was GONE by the time apply tried to write (a concurrent
157
189
  * promote/terminal-move/delete between capture and write). Apply exited
@@ -324,9 +356,12 @@ function stripOpenQuestionsBlocks(body: string): string {
324
356
  *
325
357
  * 1. **append / re-pause** — `appendQuestions` is non-empty: append `qN+1…`,
326
358
  * keep `needsAnswers:true`, re-pause (one commit, body + sidecar).
327
- * 2. **discharge by deletion** — `discharge` is set (the `delete-source`
328
- * verdict): `git rm` the source + sidecar in a standalone revertible commit,
329
- * the reason in the commit message.
359
+ * 2. **dispose** — `dispose` is set (the `dispose` verdict): REGIME-POLYMORPHIC
360
+ * on the source's type — an OBSERVATION is `git rm`-ed with its sidecar in
361
+ * a standalone revertible commit (reason in the message); a TASK is `git
362
+ * mv`-ed to `tasks/cancelled/` (`reason:` written into the moved body,
363
+ * sidecar `git rm`-ed in the same commit); a SPEC is `git mv`-ed to
364
+ * `specs/dropped/` (same shape as the task branch).
330
365
  * 3. **resolve fully** (the default) — clear `needsAnswers` + delete the sidecar
331
366
  * in ONE commit; the item advances toward build by its normal lifecycle.
332
367
  */
@@ -383,7 +418,7 @@ export function applyAnsweredQuestions(
383
418
 
384
419
  // (1) APPEND / RE-PAUSE: the apply has new questions. Append them (`qN+1…`,
385
420
  // never mutating an answered entry), stay needsAnswers:true, re-pause. Re-pause
386
- // WINS over a discharge — you cannot discharge a source you are still asking
421
+ // WINS over a dispose — you cannot dispose a source you are still asking
387
422
  // about.
388
423
  const followups = options.appendQuestions ?? [];
389
424
  if (followups.length > 0) {
@@ -409,17 +444,39 @@ export function applyAnsweredQuestions(
409
444
  };
410
445
  }
411
446
 
412
- // (2) DISCHARGE BY DELETION: the caller decided the source should leave by
413
- // deletion (the `delete-source` verdict). `git rm` the source + sidecar in a
414
- // STANDALONE revertible commit, the reason in the commit message (git history =
415
- // archive). DIRECT — no preview/confirm (decision 12); the human's answer is
416
- // the source of truth.
417
- if (options.discharge !== undefined) {
418
- return dischargeByDeletion({
447
+ // (2) DISPOSE: the caller decided the source should be DISPOSED (the `dispose`
448
+ // verdict). REGIME-POLYMORPHIC on the source type (task
449
+ // `apply-disposition-delete-to-dispose-regime-polymorphic`, spec
450
+ // `surface-stuck-as-questions-and-retire-stuck-lock-state` decision #5):
451
+ // - observation → `git rm` the note + sidecar in one revertible commit
452
+ // (reason in the message; notes leave by deletion, decision 12);
453
+ // - task → `git mv` to `tasks/cancelled/` (RETAINED; `reason:` written into
454
+ // the moved body; sidecar `git rm`-ed in the same commit);
455
+ // - spec → `git mv` to `specs/dropped/` (RETAINED, same shape).
456
+ // DIRECT — no preview/confirm (decision 12); the human's answer is the source
457
+ // of truth. A TASK is never `git rm`-ed here (true by construction: the
458
+ // task-branch calls `git mv`, no branch of this dispatcher hard-deletes a
459
+ // task).
460
+ if (options.dispose !== undefined) {
461
+ const {type} = resolveSidecarIdentity(item);
462
+ if (type === 'observation') {
463
+ return disposeObservationByDeletion({
464
+ cwd,
465
+ item,
466
+ itemPath,
467
+ reason: options.dispose.reason,
468
+ sidecarPath,
469
+ by,
470
+ env,
471
+ note,
472
+ });
473
+ }
474
+ return disposeToTerminal({
419
475
  cwd,
420
476
  item,
421
477
  itemPath,
422
- reason: options.discharge.reason,
478
+ type,
479
+ reason: options.dispose.reason,
423
480
  sidecarPath,
424
481
  by,
425
482
  env,
@@ -455,11 +512,11 @@ export function applyAnsweredQuestions(
455
512
  };
456
513
  }
457
514
 
458
- interface DischargeInput {
515
+ interface DisposeInput {
459
516
  cwd: string;
460
517
  item: string;
461
518
  itemPath: string;
462
- /** The human's discharge reason (their answer text), recorded in the commit message. */
519
+ /** The human's dispose reason (their answer text), recorded in the commit message. */
463
520
  reason: string;
464
521
  sidecarPath: string;
465
522
  by: string;
@@ -468,25 +525,27 @@ interface DischargeInput {
468
525
  }
469
526
 
470
527
  /**
471
- * DISCHARGE a source by DELETION (the `delete-source` verdict, US #5/#11): `git
472
- * rm` the source AND its answered sidecar in ONE STANDALONE commit, the human's
473
- * discharge reason recorded in the commit MESSAGE (git history is the archive).
474
- * There is no spawned artifact for a discharge, so the deletion is a standalone
475
- * commit (a `mint`, by contrast, rides the new artifact's create commit through
476
- * `promoteObservation`).
528
+ * DISPOSE an OBSERVATION by DELETION (task
529
+ * `apply-disposition-delete-to-dispose-regime-polymorphic`; US #5/#11): `git rm`
530
+ * the observation-note AND its answered sidecar in ONE STANDALONE commit, the
531
+ * human's dispose reason recorded in the commit MESSAGE (git history = archive).
532
+ * There is no spawned artifact for a dispose-by-deletion, so the deletion is a
533
+ * standalone commit (a `mint`, by contrast, rides the new artifact's create
534
+ * commit through `promoteObservation`).
477
535
  *
478
- * A discharged item leaves by being GONE — no resting body marker, no `triaged:`
479
- * stamp (the resting-state machinery is retired; an item is still-open, acted-on,
480
- * or deleted). This is the apply rung applying the human's RATIFIED answer (the
481
- * deletion is human-authored), and it is git-recoverable (a single revertible
482
- * commit) a wrong inference is never catastrophic.
536
+ * A disposed observation leaves by being GONE — notes have no terminal folder
537
+ * (decision 12: "notes leave by deletion"), unlike a task/spec which is `git
538
+ * mv`-ed to its regime's terminal by {@link disposeToTerminal}. This is the
539
+ * apply rung applying the human's RATIFIED answer (the deletion is
540
+ * human-authored), and it is git-recoverable (a single revertible commit) a
541
+ * wrong inference is never catastrophic.
483
542
  */
484
- function dischargeByDeletion(
485
- input: DischargeInput,
543
+ function disposeObservationByDeletion(
544
+ input: DisposeInput,
486
545
  ): ApplyAnsweredQuestionsResult {
487
546
  const {cwd, item, itemPath, reason, sidecarPath, by, env, note} = input;
488
547
  // `git rm` the source. The sidecar may not exist in every path; rm it too when
489
- // present, so the discharge leaves no answered-sidecar residue. Both ride ONE
548
+ // present, so the dispose leaves no answered-sidecar residue. Both ride ONE
490
549
  // commit.
491
550
  const rmPaths = [itemPath];
492
551
  if (existsSync(join(cwd, sidecarPath))) {
@@ -496,7 +555,7 @@ function dischargeByDeletion(
496
555
  const reasonLine = reason.trim() === '' ? '(no reason given)' : reason.trim();
497
556
  const subject = `advance: ${item} → deleted (by ${by})`;
498
557
  const messageBody =
499
- `Discharged by deletion (the human's ratified answer authors it; ` +
558
+ `Disposed by deletion (the human's ratified answer authors it; ` +
500
559
  `git history is the archive).\n\nreason: ${reasonLine}`;
501
560
  gitHard(['commit', '--quiet', '-m', subject, '-m', messageBody], cwd, env);
502
561
  const commit = gitHard(['rev-parse', 'HEAD'], cwd, env).stdout.trim();
@@ -513,6 +572,121 @@ function dischargeByDeletion(
513
572
  };
514
573
  }
515
574
 
575
+ /**
576
+ * The per-regime WON'T-PROCEED TERMINAL folder key {@link disposeToTerminal}
577
+ * moves a task/spec into. The folder WORDS are deliberately different
578
+ * (`cancelled` for tasks, `dropped` for specs) so a task and spec sharing a slug
579
+ * cannot collide on one terminal path — the two regimes have namespaced
580
+ * terminals by design (spec `surface-stuck-as-questions-and-retire-stuck-lock-
581
+ * state`, and see `work-layout.ts`). Do NOT rename the folders here; the token
582
+ * that changed is the verdict outcome (`dispose`), NOT the folder words.
583
+ */
584
+ const DISPOSE_TERMINAL_FOLDER = {
585
+ task: 'cancelled',
586
+ spec: 'specs-dropped',
587
+ } as const;
588
+
589
+ interface DisposeToTerminalInput extends DisposeInput {
590
+ type: Exclude<SidecarType, 'observation'>;
591
+ }
592
+
593
+ /**
594
+ * DISPOSE a TASK or SPEC to its regime's won't-proceed TERMINAL folder (task
595
+ * `apply-disposition-delete-to-dispose-regime-polymorphic`, spec
596
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state` decision #5): `git
597
+ * mv` the item to `tasks/cancelled/` (task) or `specs/dropped/` (spec), with the
598
+ * human's dispose reason written into the moved body's `reason:` frontmatter
599
+ * (durable, in-file archive of WHY the item won't proceed — symmetric across
600
+ * both regimes; the source of the moved item is the human's ratified answer),
601
+ * then `git rm` the answered sidecar, all in ONE commit.
602
+ *
603
+ * ## Decisions (recorded here per task etiquette)
604
+ *
605
+ * - **`reason:` frontmatter, symmetric across task AND spec.** The acceptance
606
+ * criteria explicitly require the reason for the task branch; the spec
607
+ * branch is only required to `git mv` to `specs/dropped/`. We nonetheless
608
+ * write the same `reason:` marker onto a disposed SPEC too, because (a) the
609
+ * two regimes are the same shape ("disposed to terminal"), so asymmetric
610
+ * behaviour would be a surprise; (b) the spec regime's dropped/ folder had
611
+ * no in-file WHY at all before, so any adjacent surface (an operator
612
+ * inspecting `specs/dropped/`) would otherwise have to grep the commit
613
+ * history for the reason. Setting it uses the same {@link
614
+ * setFrontmatterMarker} the surface rung already uses for `needsAnswers`.
615
+ * The commit message still carries the reason too (belt + braces — the
616
+ * frontmatter is the durable in-file record, the commit is the audit
617
+ * history). If a reviewer disagrees they can flip the spec branch back to a
618
+ * bare `git mv` in one line — the task branch is the load-bearing acceptance.
619
+ *
620
+ * - **The sidecar is `git rm`-ed in the same commit as the mv.** A disposed
621
+ * item is no longer in the question-loop (the answer settled it), so the
622
+ * sidecar has no reason to survive at the terminal folder. Same commit
623
+ * preserves the sidecar's `needsAnswers ⇔ active sidecar` invariant.
624
+ *
625
+ * A TASK is NEVER `git rm`-ed on this branch (that is the invariant this
626
+ * function exists to enforce): the disposal is a `git mv` to `tasks/cancelled/`,
627
+ * a folder-move that git can revert with a single `git revert`.
628
+ */
629
+ function disposeToTerminal(
630
+ input: DisposeToTerminalInput,
631
+ ): ApplyAnsweredQuestionsResult {
632
+ const {cwd, item, itemPath, type, reason, sidecarPath, by, env, note} = input;
633
+ const {slug} = resolveSidecarIdentity(item);
634
+ const folderKey = DISPOSE_TERMINAL_FOLDER[type];
635
+ const terminalPath = workItemRel(folderKey, `${slug}.md`);
636
+
637
+ // (a) Rewrite the item body with the `reason:` frontmatter marker BEFORE the
638
+ // mv so the marker rides the same commit as the terminal move. `git mv` reads
639
+ // the working-tree file, so a pre-mv rewrite is picked up by the subsequent
640
+ // stage of the moved path.
641
+ const reasonLine = reason.trim() === '' ? '(no reason given)' : reason.trim();
642
+ const itemAbs = join(cwd, itemPath);
643
+ const rewritten = setFrontmatterMarker(
644
+ readFileSync(itemAbs, 'utf8'),
645
+ 'reason',
646
+ reasonLine,
647
+ );
648
+ writeFileSync(itemAbs, rewritten);
649
+
650
+ // (b) Ensure the terminal folder exists (git mv fatals on a missing parent).
651
+ mkdirSync(dirname(join(cwd, terminalPath)), {recursive: true});
652
+
653
+ // (c) `git mv` the source to its regime terminal.
654
+ gitHard(['mv', '--', itemPath, terminalPath], cwd, env);
655
+
656
+ // (d) `git rm` the answered sidecar (the loop is settled) in the same commit.
657
+ const rmPaths: string[] = [];
658
+ if (existsSync(join(cwd, sidecarPath))) {
659
+ rmPaths.push(sidecarPath);
660
+ }
661
+ if (rmPaths.length > 0) {
662
+ gitHard(['rm', '--quiet', '--', ...rmPaths], cwd, env);
663
+ }
664
+
665
+ // (e) Stage the rewritten (now-moved) body so its `reason:` marker rides the
666
+ // same commit as the mv.
667
+ gitHard(['add', '--', terminalPath], cwd, env);
668
+
669
+ const subject = `advance: ${item} → disposed (by ${by})`;
670
+ const messageBody =
671
+ `Disposed to regime terminal '${folderKey}' (the human's ratified answer ` +
672
+ `authors it; git history + terminal folder are the archive).\n\n` +
673
+ `reason: ${reasonLine}`;
674
+ gitHard(['commit', '--quiet', '-m', subject, '-m', messageBody], cwd, env);
675
+ const commit = gitHard(['rev-parse', 'HEAD'], cwd, env).stdout.trim();
676
+ const message =
677
+ `applied ${item} → disposed (source git mv-ed to '${terminalPath}', ` +
678
+ `reason: written into the moved body's frontmatter, sidecar deleted in the ` +
679
+ `same commit).`;
680
+ note(message);
681
+ return {
682
+ outcome: 'disposed',
683
+ commit,
684
+ sidecarPath,
685
+ itemPath: terminalPath,
686
+ message,
687
+ };
688
+ }
689
+
516
690
  // Re-export the heading constant the tests assert against, so the byte-level
517
691
  // marker stays in one place.
518
692
  export {APPLIED_HEADING};
@@ -0,0 +1,260 @@
1
+ import {existsSync, readFileSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {
4
+ isEntryAnswered,
5
+ parseSidecar,
6
+ sidecarPathFor,
7
+ type SidecarEntry,
8
+ type SidecarModel,
9
+ } from './sidecar.js';
10
+ import {deleteRemoteWorkBranchIfPresent} from './needs-attention.js';
11
+
12
+ /**
13
+ * The **answered STUCK-QUESTION ACTION DISPATCH** (spec
14
+ * `surface-stuck-as-questions-and-retire-stuck-lock-state`, task
15
+ * `apply-resolve-reset-flag-discards-work-branch`; user story 2, resolved
16
+ * decision #6) — the deterministic, answer-driven RUNNER-ACTION layer that
17
+ * turns an answered `kind: 'stuck'` sidecar entry (the shape the bounce-surface
18
+ * path stamps when a task's build bounces) into one of three verbs:
19
+ *
20
+ * - `keep` -> today's continue-from-WIP: leave the `work/<slug>` branch
21
+ * alone; the apply rung falls through to the normal
22
+ * `applyAnsweredQuestions` resolve so the next claim continues
23
+ * from the branch tip.
24
+ * - `reset` -> discard the WIP branch, rebuild clean: DELETE the remote
25
+ * `work/<slug>` branch on the arbiter (via the SHARED
26
+ * {@link deleteRemoteWorkBranchIfPresent} primitive the
27
+ * `requeue --reset` recovery verb also uses) BEFORE the apply
28
+ * rung clears `needsAnswers`, so the next claim starts fresh.
29
+ * Safely IDEMPOTENT when no branch exists (an observation, or a
30
+ * task never built) — a `remote ref does not exist` push error
31
+ * is tolerated as an already-gone no-op.
32
+ * - `cancel` -> the existing `dispose` terminal: `git mv` the task to
33
+ * `tasks/cancelled/` via `applyAnsweredQuestions`' `dispose`
34
+ * option (the answer text is recorded as the human's reason).
35
+ *
36
+ * A direct SIBLING of the `apply-merge-action.ts` machinery: the apply rung
37
+ * KIND-CHECKS the sidecar BEFORE the fall-through persist, so an answered
38
+ * `kind: 'stuck'` entry dispatches HERE deterministically (mirroring how a
39
+ * `kind: 'merge'` entry dispatches through `apply-merge-action.ts`). There is
40
+ * NO agentic decider on the TASK apply-persist path (the `runAgenticDecision`
41
+ * gate at `advance.ts` fires only for `namespace === 'observation'`), so this
42
+ * MUST be a deterministic parse+dispatch — the flag SOURCE on the task path
43
+ * (see the task's `## Re-scope 2026-07-14`).
44
+ *
45
+ * # Ordering (delete-before-clear) and partial-failure decision
46
+ *
47
+ * The `reset` verb DELETES the remote work branch BEFORE the apply rung
48
+ * clears `needsAnswers`. On a partial failure (arbiter delete FAILS after the
49
+ * local tracking ref was cleared), we REFUSE the apply and leave the sidecar
50
+ * in place (`needsAnswers` stays `true`), so the human sees the failure and
51
+ * can re-answer. The alternative (clear-then-delete, or clear-anyway on a
52
+ * failed delete) would leave the item `needsAnswers:false` and CLAIMABLE while
53
+ * still carrying the WIP branch we meant to discard — exactly the stale-
54
+ * continue trap the `requeue --reset` code path spends a page of comment
55
+ * defending against. The two callers (this verb + `requeue --reset`) MUST
56
+ * stay behaviourally identical; both fail the whole recovery on a genuine
57
+ * push-delete failure. This is the `## Decisions` entry the done record
58
+ * links.
59
+ */
60
+
61
+ /** The three deterministic verbs a stuck-question answer encodes. */
62
+ export type StuckActionVerb = 'keep' | 'reset' | 'cancel';
63
+
64
+ /** A detected, answer-driven stuck-action keyed off ONE answered `kind: 'stuck'` entry. */
65
+ export interface DetectedStuckAction {
66
+ /** The deterministic verb parsed from the entry's answer text. */
67
+ verb: StuckActionVerb;
68
+ /** The answered `kind: 'stuck'` entry the verb came from. */
69
+ entry: SidecarEntry;
70
+ }
71
+
72
+ /**
73
+ * Parse the human's plain free-text answer into a {@link StuckActionVerb}.
74
+ * MIRRORS `parseMergeAnswer` EXACTLY: first whole ASCII word, case-insensitive,
75
+ * trailing commentary tolerated; `undefined` on anything else (empty / typo /
76
+ * narrative). NEVER default-guesses a destructive `reset` — an ambiguous
77
+ * answer falls through to today's plain persist (the safe "keep the branch,
78
+ * clear the flag" behaviour), never invents a branch discard.
79
+ */
80
+ export function parseStuckAnswer(text: string): StuckActionVerb | undefined {
81
+ const trimmed = text.trim().toLowerCase();
82
+ if (trimmed === '') return undefined;
83
+ const match = /^([a-z]+)/.exec(trimmed);
84
+ if (match === null) return undefined;
85
+ const word = match[1];
86
+ if (word === 'keep' || word === 'reset' || word === 'cancel') {
87
+ return word;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ /**
93
+ * Detect the `kind: 'stuck'` action verb that should drive the next apply run.
94
+ *
95
+ * ORDERING (mirrors `detectAnsweredMergeAction`'s re-stale re-surface rule):
96
+ *
97
+ * 1. If ANY `kind: 'stuck'` entry is UNANSWERED, return `undefined` —
98
+ * that is the re-paused state (a freshly-appended follow-up awaiting an
99
+ * answer), and the apply MUST NOT fire against a stale prior sibling.
100
+ * 2. Otherwise return the LATEST answered `kind: 'stuck'` entry — a fresh
101
+ * follow-up answer wins over the stale one.
102
+ *
103
+ * Returns `undefined` on a missing/unparseable sidecar or when no
104
+ * `kind: 'stuck'` entry parses to one of the three verbs (the apply rung then
105
+ * falls through to today's plain persist, which is the `keep` semantic — a
106
+ * malformed answer NEVER discards a branch).
107
+ */
108
+ export function detectAnsweredStuckAction(
109
+ cwd: string,
110
+ item: string,
111
+ ): DetectedStuckAction | undefined {
112
+ const abs = join(cwd, sidecarPathFor(item));
113
+ if (!existsSync(abs)) return undefined;
114
+ let model: SidecarModel;
115
+ try {
116
+ model = parseSidecar(readFileSync(abs, 'utf8'));
117
+ } catch {
118
+ return undefined;
119
+ }
120
+ for (const entry of model.entries) {
121
+ if (entry.kind === 'stuck' && !isEntryAnswered(entry)) return undefined;
122
+ }
123
+ for (let i = model.entries.length - 1; i >= 0; i--) {
124
+ const entry = model.entries[i];
125
+ if (entry.kind !== 'stuck') continue;
126
+ if (!isEntryAnswered(entry)) continue;
127
+ const verb = parseStuckAnswer(entry.answer);
128
+ if (verb !== undefined) {
129
+ return {verb, entry};
130
+ }
131
+ }
132
+ return undefined;
133
+ }
134
+
135
+ /** Input the production stuck-action handler consumes. */
136
+ export interface StuckActionInput {
137
+ /** The detected action (verb + source entry). */
138
+ action: DetectedStuckAction;
139
+ /** The namespaced item identity (`task:<slug>`). */
140
+ item: string;
141
+ /** The bare slug (the work branch is `work/task-<slug>`). */
142
+ slug: string;
143
+ /** The apply rung's working clone. */
144
+ cwd: string;
145
+ /** The arbiter remote NAME in `cwd` (defaults to `origin`). */
146
+ arbiter: string;
147
+ /** Environment for child git processes. */
148
+ env?: NodeJS.ProcessEnv;
149
+ /** Sink for human-readable progress notes. */
150
+ note?: (message: string) => void;
151
+ }
152
+
153
+ /** The terminal verbs the dispatcher reports to the apply rung. */
154
+ export type StuckActionOutcome =
155
+ /** `answer=keep` -> no action; fall through to the normal apply persist. */
156
+ | 'keep'
157
+ /**
158
+ * `answer=reset` -> the remote `work/<slug>` branch was DELETED (or was
159
+ * already gone); fall through to the normal apply persist so
160
+ * `needsAnswers` clears. The `already-gone` sub-case is the safe idempotent
161
+ * no-op for an item with no work branch.
162
+ */
163
+ | 'reset'
164
+ /**
165
+ * `answer=cancel` -> the apply rung dispatches through
166
+ * `applyAnsweredQuestions`' `dispose` option (task -> `tasks/cancelled/`).
167
+ * The answer text is the human's reason.
168
+ */
169
+ | 'cancel'
170
+ /**
171
+ * `answer=reset` -> the branch delete FAILED (a real push-delete error, not
172
+ * `already-gone`). The apply rung SHORT-CIRCUITS: the sidecar stays,
173
+ * `needsAnswers` stays `true`, no clear — the human sees the failure and
174
+ * re-answers. Matches the `requeue --reset` "abort the requeue on a failed
175
+ * arbiter delete" behaviour (delete-before-clear ordering, see the module
176
+ * docs' partial-failure decision).
177
+ */
178
+ | 'refused';
179
+
180
+ /** What the production handler returns to the apply rung. */
181
+ export interface StuckActionResult {
182
+ outcome: StuckActionOutcome;
183
+ /** Human-readable summary for the rung's message. */
184
+ message: string;
185
+ }
186
+
187
+ /**
188
+ * The injectable dispatch SEAM. Production wires {@link performStuckAction};
189
+ * tests inject a stub to assert on the apply-rung's routing WITHOUT touching a
190
+ * real arbiter.
191
+ */
192
+ export type StuckActionHandler = (
193
+ input: StuckActionInput,
194
+ ) => Promise<StuckActionResult>;
195
+
196
+ /**
197
+ * The PRODUCTION dispatcher: an answered `kind: 'stuck'` entry's verb drives
198
+ * one of the four terminals (`keep` / `reset` / `refused` / `cancel`). For
199
+ * `answer=reset` it dispatches through the SHARED
200
+ * {@link deleteRemoteWorkBranchIfPresent} primitive the `requeue --reset` verb
201
+ * also uses — delete-before-clear ordering, local-first write-through,
202
+ * already-gone tolerance (safe no-op when no branch exists). For `keep` and
203
+ * `cancel` there is nothing to do on the git side; the apply rung's normal
204
+ * fall-through handles the persist (`cancel` via the `dispose` option).
205
+ */
206
+ export async function performStuckAction(
207
+ input: StuckActionInput,
208
+ ): Promise<StuckActionResult> {
209
+ const note = input.note ?? (() => {});
210
+ const {verb} = input.action;
211
+
212
+ if (verb === 'keep') {
213
+ return {
214
+ outcome: 'keep',
215
+ message:
216
+ `stuck-question for ${input.item} answered KEEP — leaving ` +
217
+ `\`work/task-${input.slug}\` untouched (continue-from-WIP; the answer ` +
218
+ `is recorded in the item body via the normal apply path).`,
219
+ };
220
+ }
221
+
222
+ if (verb === 'cancel') {
223
+ return {
224
+ outcome: 'cancel',
225
+ message:
226
+ `stuck-question for ${input.item} answered CANCEL — dispatching ` +
227
+ `through the \`dispose\` terminal (\`git mv -> tasks/cancelled/\`).`,
228
+ };
229
+ }
230
+
231
+ // verb === 'reset'
232
+ const dropped = await deleteRemoteWorkBranchIfPresent({
233
+ cwd: input.cwd,
234
+ arbiter: input.arbiter,
235
+ slug: input.slug,
236
+ env: input.env,
237
+ });
238
+ if (dropped.status === 'failed') {
239
+ const stderr = dropped.stderr;
240
+ const message =
241
+ `stuck-question for ${input.item} answered RESET — but the arbiter ` +
242
+ `delete of ${dropped.branch} on ${input.arbiter} failed ` +
243
+ `(${stderr || 'unknown error'}). NOT clearing needsAnswers; the sidecar ` +
244
+ `stays surfaced for a re-answer. The local tracking ref was already ` +
245
+ `cleared (write-through ordering); a subsequent fetch will restore it ` +
246
+ `from the arbiter — the local store is BEHIND the arbiter ` +
247
+ `(self-healing), never AHEAD (which would drive a stale continue).`;
248
+ note(message);
249
+ return {outcome: 'refused', message};
250
+ }
251
+ const droppedLabel =
252
+ dropped.status === 'deleted'
253
+ ? `Deleted the remote branch ${dropped.branch} on ${input.arbiter}`
254
+ : `Remote branch ${dropped.branch} on ${input.arbiter} was already gone`;
255
+ const message =
256
+ `stuck-question for ${input.item} answered RESET — ${droppedLabel} ` +
257
+ `(next claim starts fresh).`;
258
+ note(message);
259
+ return {outcome: 'reset', message};
260
+ }