dorfl 0.0.0 → 0.1.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 (619) hide show
  1. package/dist/advance-ci-template.d.ts +73 -0
  2. package/dist/advance-ci-template.d.ts.map +1 -0
  3. package/dist/advance-ci-template.js +104 -0
  4. package/dist/advance-ci-template.js.map +1 -0
  5. package/dist/advance-classify.d.ts +132 -0
  6. package/dist/advance-classify.d.ts.map +1 -0
  7. package/dist/advance-classify.js +120 -0
  8. package/dist/advance-classify.js.map +1 -0
  9. package/dist/advance-drivers.d.ts +182 -0
  10. package/dist/advance-drivers.d.ts.map +1 -0
  11. package/dist/advance-drivers.js +231 -0
  12. package/dist/advance-drivers.js.map +1 -0
  13. package/dist/advance-isolated.d.ts +156 -0
  14. package/dist/advance-isolated.d.ts.map +1 -0
  15. package/dist/advance-isolated.js +256 -0
  16. package/dist/advance-isolated.js.map +1 -0
  17. package/dist/advance-lifecycle-template.d.ts +107 -0
  18. package/dist/advance-lifecycle-template.d.ts.map +1 -0
  19. package/dist/advance-lifecycle-template.js +668 -0
  20. package/dist/advance-lifecycle-template.js.map +1 -0
  21. package/dist/advance-loop-driver.d.ts +325 -0
  22. package/dist/advance-loop-driver.d.ts.map +1 -0
  23. package/dist/advance-loop-driver.js +437 -0
  24. package/dist/advance-loop-driver.js.map +1 -0
  25. package/dist/advance-treeless-publish.d.ts +108 -0
  26. package/dist/advance-treeless-publish.d.ts.map +1 -0
  27. package/dist/advance-treeless-publish.js +71 -0
  28. package/dist/advance-treeless-publish.js.map +1 -0
  29. package/dist/advance.d.ts +340 -0
  30. package/dist/advance.d.ts.map +1 -0
  31. package/dist/advance.js +1122 -0
  32. package/dist/advance.js.map +1 -0
  33. package/dist/advancing-lock.d.ts +294 -0
  34. package/dist/advancing-lock.d.ts.map +1 -0
  35. package/dist/advancing-lock.js +594 -0
  36. package/dist/advancing-lock.js.map +1 -0
  37. package/dist/agent-launch.d.ts +79 -0
  38. package/dist/agent-launch.d.ts.map +1 -0
  39. package/dist/agent-launch.js +61 -0
  40. package/dist/agent-launch.js.map +1 -0
  41. package/dist/agent-stop.d.ts +149 -0
  42. package/dist/agent-stop.d.ts.map +1 -0
  43. package/dist/agent-stop.js +307 -0
  44. package/dist/agent-stop.js.map +1 -0
  45. package/dist/apply-decide.d.ts +127 -0
  46. package/dist/apply-decide.d.ts.map +1 -0
  47. package/dist/apply-decide.js +176 -0
  48. package/dist/apply-decide.js.map +1 -0
  49. package/dist/apply-merge-action.d.ts +206 -0
  50. package/dist/apply-merge-action.d.ts.map +1 -0
  51. package/dist/apply-merge-action.js +307 -0
  52. package/dist/apply-merge-action.js.map +1 -0
  53. package/dist/apply-persist.d.ts +174 -0
  54. package/dist/apply-persist.d.ts.map +1 -0
  55. package/dist/apply-persist.js +359 -0
  56. package/dist/apply-persist.js.map +1 -0
  57. package/dist/arbiter.d.ts +120 -0
  58. package/dist/arbiter.d.ts.map +1 -0
  59. package/dist/arbiter.js +255 -0
  60. package/dist/arbiter.js.map +1 -0
  61. package/dist/brand.d.ts +70 -0
  62. package/dist/brand.d.ts.map +1 -0
  63. package/dist/brand.js +84 -0
  64. package/dist/brand.js.map +1 -0
  65. package/dist/buildable-body.d.ts +132 -0
  66. package/dist/buildable-body.d.ts.map +1 -0
  67. package/dist/buildable-body.js +131 -0
  68. package/dist/buildable-body.js.map +1 -0
  69. package/dist/categorise.d.ts +66 -0
  70. package/dist/categorise.d.ts.map +1 -0
  71. package/dist/categorise.js +106 -0
  72. package/dist/categorise.js.map +1 -0
  73. package/dist/claim-cas.d.ts +117 -0
  74. package/dist/claim-cas.d.ts.map +1 -0
  75. package/dist/claim-cas.js +312 -0
  76. package/dist/claim-cas.js.map +1 -0
  77. package/dist/cli-spinner.d.ts +112 -0
  78. package/dist/cli-spinner.d.ts.map +1 -0
  79. package/dist/cli-spinner.js +157 -0
  80. package/dist/cli-spinner.js.map +1 -0
  81. package/dist/cli.d.ts +11 -0
  82. package/dist/cli.d.ts.map +1 -0
  83. package/dist/cli.js +3094 -0
  84. package/dist/cli.js.map +1 -0
  85. package/dist/close-job-template.d.ts +70 -0
  86. package/dist/close-job-template.d.ts.map +1 -0
  87. package/dist/close-job-template.js +180 -0
  88. package/dist/close-job-template.js.map +1 -0
  89. package/dist/close-job.d.ts +95 -0
  90. package/dist/close-job.d.ts.map +1 -0
  91. package/dist/close-job.js +226 -0
  92. package/dist/close-job.js.map +1 -0
  93. package/dist/complete.d.ts +361 -0
  94. package/dist/complete.d.ts.map +1 -0
  95. package/dist/complete.js +885 -0
  96. package/dist/complete.js.map +1 -0
  97. package/dist/concurrency.d.ts +68 -0
  98. package/dist/concurrency.d.ts.map +1 -0
  99. package/dist/concurrency.js +112 -0
  100. package/dist/concurrency.js.map +1 -0
  101. package/dist/config-override.d.ts +76 -0
  102. package/dist/config-override.d.ts.map +1 -0
  103. package/dist/config-override.js +50 -0
  104. package/dist/config-override.js.map +1 -0
  105. package/dist/config.d.ts +668 -0
  106. package/dist/config.d.ts.map +1 -0
  107. package/dist/config.js +241 -0
  108. package/dist/config.js.map +1 -0
  109. package/dist/continue-branch.d.ts +249 -0
  110. package/dist/continue-branch.d.ts.map +1 -0
  111. package/dist/continue-branch.js +389 -0
  112. package/dist/continue-branch.js.map +1 -0
  113. package/dist/cwd-section.d.ts +186 -0
  114. package/dist/cwd-section.d.ts.map +1 -0
  115. package/dist/cwd-section.js +209 -0
  116. package/dist/cwd-section.js.map +1 -0
  117. package/dist/decision-engine.d.ts +170 -0
  118. package/dist/decision-engine.d.ts.map +1 -0
  119. package/dist/decision-engine.js +136 -0
  120. package/dist/decision-engine.js.map +1 -0
  121. package/dist/detect.d.ts +17 -0
  122. package/dist/detect.d.ts.map +1 -0
  123. package/dist/detect.js +118 -0
  124. package/dist/detect.js.map +1 -0
  125. package/dist/do-autopick.d.ts +85 -0
  126. package/dist/do-autopick.d.ts.map +1 -0
  127. package/dist/do-autopick.js +112 -0
  128. package/dist/do-autopick.js.map +1 -0
  129. package/dist/do-config.d.ts +312 -0
  130. package/dist/do-config.d.ts.map +1 -0
  131. package/dist/do-config.js +358 -0
  132. package/dist/do-config.js.map +1 -0
  133. package/dist/do-remote-auto.d.ts +75 -0
  134. package/dist/do-remote-auto.d.ts.map +1 -0
  135. package/dist/do-remote-auto.js +111 -0
  136. package/dist/do-remote-auto.js.map +1 -0
  137. package/dist/do.d.ts +621 -0
  138. package/dist/do.d.ts.map +1 -0
  139. package/dist/do.js +1882 -0
  140. package/dist/do.js.map +1 -0
  141. package/dist/drop-source.d.ts +96 -0
  142. package/dist/drop-source.d.ts.map +1 -0
  143. package/dist/drop-source.js +91 -0
  144. package/dist/drop-source.js.map +1 -0
  145. package/dist/eligibility.d.ts +46 -0
  146. package/dist/eligibility.d.ts.map +1 -0
  147. package/dist/eligibility.js +34 -0
  148. package/dist/eligibility.js.map +1 -0
  149. package/dist/env-config.d.ts +51 -0
  150. package/dist/env-config.d.ts.map +1 -0
  151. package/dist/env-config.js +272 -0
  152. package/dist/env-config.js.map +1 -0
  153. package/dist/failure-cause.d.ts +70 -0
  154. package/dist/failure-cause.d.ts.map +1 -0
  155. package/dist/failure-cause.js +126 -0
  156. package/dist/failure-cause.js.map +1 -0
  157. package/dist/format.d.ts +43 -0
  158. package/dist/format.d.ts.map +1 -0
  159. package/dist/format.js +256 -0
  160. package/dist/format.js.map +1 -0
  161. package/dist/frontmatter.d.ts +215 -0
  162. package/dist/frontmatter.d.ts.map +1 -0
  163. package/dist/frontmatter.js +345 -0
  164. package/dist/frontmatter.js.map +1 -0
  165. package/dist/gate-readiness.d.ts +84 -0
  166. package/dist/gate-readiness.d.ts.map +1 -0
  167. package/dist/gate-readiness.js +103 -0
  168. package/dist/gate-readiness.js.map +1 -0
  169. package/dist/gc.d.ts +165 -0
  170. package/dist/gc.d.ts.map +1 -0
  171. package/dist/gc.js +313 -0
  172. package/dist/gc.js.map +1 -0
  173. package/dist/gh-failure.d.ts +42 -0
  174. package/dist/gh-failure.d.ts.map +1 -0
  175. package/dist/gh-failure.js +49 -0
  176. package/dist/gh-failure.js.map +1 -0
  177. package/dist/git.d.ts +75 -0
  178. package/dist/git.d.ts.map +1 -0
  179. package/dist/git.js +130 -0
  180. package/dist/git.js.map +1 -0
  181. package/dist/github.d.ts +187 -0
  182. package/dist/github.d.ts.map +1 -0
  183. package/dist/github.js +343 -0
  184. package/dist/github.js.map +1 -0
  185. package/dist/harness.d.ts +242 -0
  186. package/dist/harness.d.ts.map +1 -0
  187. package/dist/harness.js +157 -0
  188. package/dist/harness.js.map +1 -0
  189. package/dist/identity.d.ts +167 -0
  190. package/dist/identity.d.ts.map +1 -0
  191. package/dist/identity.js +231 -0
  192. package/dist/identity.js.map +1 -0
  193. package/dist/index.d.ts +147 -0
  194. package/dist/index.d.ts.map +1 -0
  195. package/dist/index.js +76 -0
  196. package/dist/index.js.map +1 -0
  197. package/dist/install-ci-branch-protection.d.ts +147 -0
  198. package/dist/install-ci-branch-protection.d.ts.map +1 -0
  199. package/dist/install-ci-branch-protection.js +166 -0
  200. package/dist/install-ci-branch-protection.js.map +1 -0
  201. package/dist/install-ci-capabilities/advance-lifecycle.d.ts +15 -0
  202. package/dist/install-ci-capabilities/advance-lifecycle.d.ts.map +1 -0
  203. package/dist/install-ci-capabilities/advance-lifecycle.js +28 -0
  204. package/dist/install-ci-capabilities/advance-lifecycle.js.map +1 -0
  205. package/dist/install-ci-capabilities/close-job.d.ts +13 -0
  206. package/dist/install-ci-capabilities/close-job.d.ts.map +1 -0
  207. package/dist/install-ci-capabilities/close-job.js +26 -0
  208. package/dist/install-ci-capabilities/close-job.js.map +1 -0
  209. package/dist/install-ci-capabilities/example-noop.d.ts +16 -0
  210. package/dist/install-ci-capabilities/example-noop.d.ts.map +1 -0
  211. package/dist/install-ci-capabilities/example-noop.js +23 -0
  212. package/dist/install-ci-capabilities/example-noop.js.map +1 -0
  213. package/dist/install-ci-capabilities/intake.d.ts +15 -0
  214. package/dist/install-ci-capabilities/intake.d.ts.map +1 -0
  215. package/dist/install-ci-capabilities/intake.js +28 -0
  216. package/dist/install-ci-capabilities/intake.js.map +1 -0
  217. package/dist/install-ci-capabilities/verify.d.ts +14 -0
  218. package/dist/install-ci-capabilities/verify.d.ts.map +1 -0
  219. package/dist/install-ci-capabilities/verify.js +27 -0
  220. package/dist/install-ci-capabilities/verify.js.map +1 -0
  221. package/dist/install-ci-core.d.ts +446 -0
  222. package/dist/install-ci-core.d.ts.map +1 -0
  223. package/dist/install-ci-core.js +760 -0
  224. package/dist/install-ci-core.js.map +1 -0
  225. package/dist/install-ci-github.d.ts +167 -0
  226. package/dist/install-ci-github.d.ts.map +1 -0
  227. package/dist/install-ci-github.js +315 -0
  228. package/dist/install-ci-github.js.map +1 -0
  229. package/dist/install-ci.d.ts +105 -0
  230. package/dist/install-ci.d.ts.map +1 -0
  231. package/dist/install-ci.js +363 -0
  232. package/dist/install-ci.js.map +1 -0
  233. package/dist/intake-event.d.ts +88 -0
  234. package/dist/intake-event.d.ts.map +1 -0
  235. package/dist/intake-event.js +66 -0
  236. package/dist/intake-event.js.map +1 -0
  237. package/dist/intake-marker.d.ts +95 -0
  238. package/dist/intake-marker.d.ts.map +1 -0
  239. package/dist/intake-marker.js +127 -0
  240. package/dist/intake-marker.js.map +1 -0
  241. package/dist/intake-triage.d.ts +48 -0
  242. package/dist/intake-triage.d.ts.map +1 -0
  243. package/dist/intake-triage.js +95 -0
  244. package/dist/intake-triage.js.map +1 -0
  245. package/dist/intake-trigger-template.d.ts +185 -0
  246. package/dist/intake-trigger-template.d.ts.map +1 -0
  247. package/dist/intake-trigger-template.js +449 -0
  248. package/dist/intake-trigger-template.js.map +1 -0
  249. package/dist/intake.d.ts +569 -0
  250. package/dist/intake.d.ts.map +1 -0
  251. package/dist/intake.js +1628 -0
  252. package/dist/intake.js.map +1 -0
  253. package/dist/integration-core.d.ts +539 -0
  254. package/dist/integration-core.d.ts.map +1 -0
  255. package/dist/integration-core.js +2195 -0
  256. package/dist/integration-core.js.map +1 -0
  257. package/dist/integrator.d.ts +343 -0
  258. package/dist/integrator.d.ts.map +1 -0
  259. package/dist/integrator.js +400 -0
  260. package/dist/integrator.js.map +1 -0
  261. package/dist/isolation.d.ts +219 -0
  262. package/dist/isolation.d.ts.map +1 -0
  263. package/dist/isolation.js +261 -0
  264. package/dist/isolation.js.map +1 -0
  265. package/dist/issue-provider.d.ts +349 -0
  266. package/dist/issue-provider.d.ts.map +1 -0
  267. package/dist/issue-provider.js +360 -0
  268. package/dist/issue-provider.js.map +1 -0
  269. package/dist/item-lock.d.ts +626 -0
  270. package/dist/item-lock.d.ts.map +1 -0
  271. package/dist/item-lock.js +1381 -0
  272. package/dist/item-lock.js.map +1 -0
  273. package/dist/item-path.d.ts +49 -0
  274. package/dist/item-path.d.ts.map +1 -0
  275. package/dist/item-path.js +66 -0
  276. package/dist/item-path.js.map +1 -0
  277. package/dist/ledger-lint.d.ts +129 -0
  278. package/dist/ledger-lint.d.ts.map +1 -0
  279. package/dist/ledger-lint.js +249 -0
  280. package/dist/ledger-lint.js.map +1 -0
  281. package/dist/ledger-read.d.ts +357 -0
  282. package/dist/ledger-read.d.ts.map +1 -0
  283. package/dist/ledger-read.js +442 -0
  284. package/dist/ledger-read.js.map +1 -0
  285. package/dist/ledger-write.d.ts +330 -0
  286. package/dist/ledger-write.d.ts.map +1 -0
  287. package/dist/ledger-write.js +411 -0
  288. package/dist/ledger-write.js.map +1 -0
  289. package/dist/lifecycle-gather.d.ts +30 -0
  290. package/dist/lifecycle-gather.d.ts.map +1 -0
  291. package/dist/lifecycle-gather.js +205 -0
  292. package/dist/lifecycle-gather.js.map +1 -0
  293. package/dist/lifecycle-pools.d.ts +180 -0
  294. package/dist/lifecycle-pools.d.ts.map +1 -0
  295. package/dist/lifecycle-pools.js +78 -0
  296. package/dist/lifecycle-pools.js.map +1 -0
  297. package/dist/merge-question-surfacer.d.ts +166 -0
  298. package/dist/merge-question-surfacer.d.ts.map +1 -0
  299. package/dist/merge-question-surfacer.js +297 -0
  300. package/dist/merge-question-surfacer.js.map +1 -0
  301. package/dist/mint-adr.d.ts +126 -0
  302. package/dist/mint-adr.d.ts.map +1 -0
  303. package/dist/mint-adr.js +257 -0
  304. package/dist/mint-adr.js.map +1 -0
  305. package/dist/mirror-pool-scan.d.ts +125 -0
  306. package/dist/mirror-pool-scan.d.ts.map +1 -0
  307. package/dist/mirror-pool-scan.js +104 -0
  308. package/dist/mirror-pool-scan.js.map +1 -0
  309. package/dist/needs-attention.d.ts +341 -0
  310. package/dist/needs-attention.d.ts.map +1 -0
  311. package/dist/needs-attention.js +900 -0
  312. package/dist/needs-attention.js.map +1 -0
  313. package/dist/orphan-sidecar.d.ts +79 -0
  314. package/dist/orphan-sidecar.d.ts.map +1 -0
  315. package/dist/orphan-sidecar.js +71 -0
  316. package/dist/orphan-sidecar.js.map +1 -0
  317. package/dist/output.d.ts +48 -0
  318. package/dist/output.d.ts.map +1 -0
  319. package/dist/output.js +66 -0
  320. package/dist/output.js.map +1 -0
  321. package/dist/pi-harness.d.ts +179 -0
  322. package/dist/pi-harness.d.ts.map +1 -0
  323. package/dist/pi-harness.js +342 -0
  324. package/dist/pi-harness.js.map +1 -0
  325. package/dist/placement.d.ts +99 -0
  326. package/dist/placement.d.ts.map +1 -0
  327. package/dist/placement.js +67 -0
  328. package/dist/placement.js.map +1 -0
  329. package/dist/prd-to-spec.d.ts +315 -0
  330. package/dist/prd-to-spec.d.ts.map +1 -0
  331. package/dist/prd-to-spec.js +684 -0
  332. package/dist/prd-to-spec.js.map +1 -0
  333. package/dist/prepare.d.ts +121 -0
  334. package/dist/prepare.d.ts.map +1 -0
  335. package/dist/prepare.js +140 -0
  336. package/dist/prepare.js.map +1 -0
  337. package/dist/prompt.d.ts +360 -0
  338. package/dist/prompt.d.ts.map +1 -0
  339. package/dist/prompt.js +499 -0
  340. package/dist/prompt.js.map +1 -0
  341. package/dist/protocol/ADR-FORMAT.md +47 -0
  342. package/dist/protocol/CLAIM-PROTOCOL.md +217 -0
  343. package/dist/protocol/REVIEW-PROTOCOL.md +119 -0
  344. package/dist/protocol/SURFACE-PROTOCOL.md +121 -0
  345. package/dist/protocol/TASKING-PROTOCOL.md +122 -0
  346. package/dist/protocol/WORK-CONTRACT.md +276 -0
  347. package/dist/protocol/spec-template.md +71 -0
  348. package/dist/protocol/task-template.md +65 -0
  349. package/dist/readiness.d.ts +66 -0
  350. package/dist/readiness.d.ts.map +1 -0
  351. package/dist/readiness.js +36 -0
  352. package/dist/readiness.js.map +1 -0
  353. package/dist/reap-branches.d.ts +102 -0
  354. package/dist/reap-branches.d.ts.map +1 -0
  355. package/dist/reap-branches.js +149 -0
  356. package/dist/reap-branches.js.map +1 -0
  357. package/dist/recover-isolated.d.ts +72 -0
  358. package/dist/recover-isolated.d.ts.map +1 -0
  359. package/dist/recover-isolated.js +188 -0
  360. package/dist/recover-isolated.js.map +1 -0
  361. package/dist/registry.d.ts +172 -0
  362. package/dist/registry.d.ts.map +1 -0
  363. package/dist/registry.js +296 -0
  364. package/dist/registry.js.map +1 -0
  365. package/dist/repo-config.d.ts +201 -0
  366. package/dist/repo-config.d.ts.map +1 -0
  367. package/dist/repo-config.js +414 -0
  368. package/dist/repo-config.js.map +1 -0
  369. package/dist/repo-key.d.ts +20 -0
  370. package/dist/repo-key.d.ts.map +1 -0
  371. package/dist/repo-key.js +68 -0
  372. package/dist/repo-key.js.map +1 -0
  373. package/dist/repo-mirror.d.ts +177 -0
  374. package/dist/repo-mirror.d.ts.map +1 -0
  375. package/dist/repo-mirror.js +271 -0
  376. package/dist/repo-mirror.js.map +1 -0
  377. package/dist/retry-backoff.d.ts +90 -0
  378. package/dist/retry-backoff.d.ts.map +1 -0
  379. package/dist/retry-backoff.js +98 -0
  380. package/dist/retry-backoff.js.map +1 -0
  381. package/dist/review-gate.d.ts +173 -0
  382. package/dist/review-gate.d.ts.map +1 -0
  383. package/dist/review-gate.js +261 -0
  384. package/dist/review-gate.js.map +1 -0
  385. package/dist/review-verdict.d.ts +149 -0
  386. package/dist/review-verdict.d.ts.map +1 -0
  387. package/dist/review-verdict.js +332 -0
  388. package/dist/review-verdict.js.map +1 -0
  389. package/dist/run.d.ts +221 -0
  390. package/dist/run.d.ts.map +1 -0
  391. package/dist/run.js +963 -0
  392. package/dist/run.js.map +1 -0
  393. package/dist/scan.d.ts +308 -0
  394. package/dist/scan.d.ts.map +1 -0
  395. package/dist/scan.js +374 -0
  396. package/dist/scan.js.map +1 -0
  397. package/dist/select-order.d.ts +75 -0
  398. package/dist/select-order.d.ts.map +1 -0
  399. package/dist/select-order.js +108 -0
  400. package/dist/select-order.js.map +1 -0
  401. package/dist/select-priority.d.ts +188 -0
  402. package/dist/select-priority.d.ts.map +1 -0
  403. package/dist/select-priority.js +80 -0
  404. package/dist/select-priority.js.map +1 -0
  405. package/dist/select.d.ts +25 -0
  406. package/dist/select.d.ts.map +1 -0
  407. package/dist/select.js +43 -0
  408. package/dist/select.js.map +1 -0
  409. package/dist/session-path.d.ts +36 -0
  410. package/dist/session-path.d.ts.map +1 -0
  411. package/dist/session-path.js +129 -0
  412. package/dist/session-path.js.map +1 -0
  413. package/dist/sidecar-apply.d.ts +83 -0
  414. package/dist/sidecar-apply.d.ts.map +1 -0
  415. package/dist/sidecar-apply.js +111 -0
  416. package/dist/sidecar-apply.js.map +1 -0
  417. package/dist/sidecar.d.ts +245 -0
  418. package/dist/sidecar.d.ts.map +1 -0
  419. package/dist/sidecar.js +481 -0
  420. package/dist/sidecar.js.map +1 -0
  421. package/dist/slug-namespace.d.ts +204 -0
  422. package/dist/slug-namespace.d.ts.map +1 -0
  423. package/dist/slug-namespace.js +229 -0
  424. package/dist/slug-namespace.js.map +1 -0
  425. package/dist/spec-complete.d.ts +44 -0
  426. package/dist/spec-complete.d.ts.map +1 -0
  427. package/dist/spec-complete.js +69 -0
  428. package/dist/spec-complete.js.map +1 -0
  429. package/dist/start.d.ts +97 -0
  430. package/dist/start.d.ts.map +1 -0
  431. package/dist/start.js +633 -0
  432. package/dist/start.js.map +1 -0
  433. package/dist/status.d.ts +199 -0
  434. package/dist/status.d.ts.map +1 -0
  435. package/dist/status.js +228 -0
  436. package/dist/status.js.map +1 -0
  437. package/dist/surface-gate.d.ts +162 -0
  438. package/dist/surface-gate.d.ts.map +1 -0
  439. package/dist/surface-gate.js +206 -0
  440. package/dist/surface-gate.js.map +1 -0
  441. package/dist/surface-persist.d.ts +86 -0
  442. package/dist/surface-persist.d.ts.map +1 -0
  443. package/dist/surface-persist.js +129 -0
  444. package/dist/surface-persist.js.map +1 -0
  445. package/dist/tasker-review-loop.d.ts +249 -0
  446. package/dist/tasker-review-loop.d.ts.map +1 -0
  447. package/dist/tasker-review-loop.js +369 -0
  448. package/dist/tasker-review-loop.js.map +1 -0
  449. package/dist/tasking-eligibility.d.ts +74 -0
  450. package/dist/tasking-eligibility.d.ts.map +1 -0
  451. package/dist/tasking-eligibility.js +52 -0
  452. package/dist/tasking-eligibility.js.map +1 -0
  453. package/dist/tasking-lock.d.ts +111 -0
  454. package/dist/tasking-lock.d.ts.map +1 -0
  455. package/dist/tasking-lock.js +256 -0
  456. package/dist/tasking-lock.js.map +1 -0
  457. package/dist/tasking.d.ts +275 -0
  458. package/dist/tasking.d.ts.map +1 -0
  459. package/dist/tasking.js +952 -0
  460. package/dist/tasking.js.map +1 -0
  461. package/dist/triage-gate.d.ts +127 -0
  462. package/dist/triage-gate.d.ts.map +1 -0
  463. package/dist/triage-gate.js +139 -0
  464. package/dist/triage-gate.js.map +1 -0
  465. package/dist/triage-persist.d.ts +163 -0
  466. package/dist/triage-persist.d.ts.map +1 -0
  467. package/dist/triage-persist.js +387 -0
  468. package/dist/triage-persist.js.map +1 -0
  469. package/dist/verdict-json.d.ts +32 -0
  470. package/dist/verdict-json.d.ts.map +1 -0
  471. package/dist/verdict-json.js +74 -0
  472. package/dist/verdict-json.js.map +1 -0
  473. package/dist/verify-workflow-template.d.ts +60 -0
  474. package/dist/verify-workflow-template.d.ts.map +1 -0
  475. package/dist/verify-workflow-template.js +126 -0
  476. package/dist/verify-workflow-template.js.map +1 -0
  477. package/dist/verify.d.ts +60 -0
  478. package/dist/verify.d.ts.map +1 -0
  479. package/dist/verify.js +62 -0
  480. package/dist/verify.js.map +1 -0
  481. package/dist/watch-session.d.ts +112 -0
  482. package/dist/watch-session.d.ts.map +1 -0
  483. package/dist/watch-session.js +347 -0
  484. package/dist/watch-session.js.map +1 -0
  485. package/dist/work-layout.d.ts +198 -0
  486. package/dist/work-layout.d.ts.map +1 -0
  487. package/dist/work-layout.js +217 -0
  488. package/dist/work-layout.js.map +1 -0
  489. package/dist/work-on.d.ts +154 -0
  490. package/dist/work-on.d.ts.map +1 -0
  491. package/dist/work-on.js +387 -0
  492. package/dist/work-on.js.map +1 -0
  493. package/dist/workspace.d.ts +224 -0
  494. package/dist/workspace.d.ts.map +1 -0
  495. package/dist/workspace.js +325 -0
  496. package/dist/workspace.js.map +1 -0
  497. package/package.json +46 -2
  498. package/src/advance-ci-template.ts +203 -0
  499. package/src/advance-classify.ts +197 -0
  500. package/src/advance-drivers.ts +414 -0
  501. package/src/advance-isolated.ts +432 -0
  502. package/src/advance-lifecycle-template.ts +791 -0
  503. package/src/advance-loop-driver.ts +745 -0
  504. package/src/advance-treeless-publish.ts +177 -0
  505. package/src/advance.ts +1564 -0
  506. package/src/advancing-lock.ts +988 -0
  507. package/src/agent-launch.ts +137 -0
  508. package/src/agent-stop.ts +361 -0
  509. package/src/apply-decide.ts +242 -0
  510. package/src/apply-merge-action.ts +502 -0
  511. package/src/apply-persist.ts +518 -0
  512. package/src/arbiter.ts +372 -0
  513. package/src/brand.ts +111 -0
  514. package/src/buildable-body.ts +196 -0
  515. package/src/categorise.ts +158 -0
  516. package/src/claim-cas.ts +513 -0
  517. package/src/cli-spinner.ts +225 -0
  518. package/src/cli.ts +4369 -0
  519. package/src/close-job-template.ts +236 -0
  520. package/src/close-job.ts +319 -0
  521. package/src/complete.ts +1379 -0
  522. package/src/concurrency.ts +151 -0
  523. package/src/config-override.ts +116 -0
  524. package/src/config.ts +883 -0
  525. package/src/continue-branch.ts +542 -0
  526. package/src/cwd-section.ts +392 -0
  527. package/src/decision-engine.ts +272 -0
  528. package/src/detect.ts +124 -0
  529. package/src/do-autopick.ts +223 -0
  530. package/src/do-config.ts +589 -0
  531. package/src/do-remote-auto.ts +197 -0
  532. package/src/do.ts +2623 -0
  533. package/src/drop-source.ts +194 -0
  534. package/src/eligibility.ts +79 -0
  535. package/src/env-config.ts +305 -0
  536. package/src/failure-cause.ts +142 -0
  537. package/src/format.ts +313 -0
  538. package/src/frontmatter.ts +485 -0
  539. package/src/gate-readiness.ts +147 -0
  540. package/src/gc.ts +510 -0
  541. package/src/gh-failure.ts +53 -0
  542. package/src/git.ts +186 -0
  543. package/src/github.ts +468 -0
  544. package/src/harness.ts +355 -0
  545. package/src/identity.ts +322 -0
  546. package/src/index.ts +785 -0
  547. package/src/install-ci-branch-protection.ts +255 -0
  548. package/src/install-ci-capabilities/advance-lifecycle.ts +34 -0
  549. package/src/install-ci-capabilities/close-job.ts +32 -0
  550. package/src/install-ci-capabilities/example-noop.ts +24 -0
  551. package/src/install-ci-capabilities/intake.ts +34 -0
  552. package/src/install-ci-capabilities/verify.ts +33 -0
  553. package/src/install-ci-core.ts +1088 -0
  554. package/src/install-ci-github.ts +376 -0
  555. package/src/install-ci.ts +552 -0
  556. package/src/intake-event.ts +102 -0
  557. package/src/intake-marker.ts +195 -0
  558. package/src/intake-triage.ts +138 -0
  559. package/src/intake-trigger-template.ts +591 -0
  560. package/src/intake.ts +2445 -0
  561. package/src/integration-core.ts +3065 -0
  562. package/src/integrator.ts +771 -0
  563. package/src/isolation.ts +484 -0
  564. package/src/issue-provider.ts +733 -0
  565. package/src/item-lock.ts +1858 -0
  566. package/src/item-path.ts +75 -0
  567. package/src/ledger-lint.ts +332 -0
  568. package/src/ledger-read.ts +924 -0
  569. package/src/ledger-write.ts +865 -0
  570. package/src/lifecycle-gather.ts +298 -0
  571. package/src/lifecycle-pools.ts +250 -0
  572. package/src/merge-question-surfacer.ts +496 -0
  573. package/src/mint-adr.ts +362 -0
  574. package/src/mirror-pool-scan.ts +240 -0
  575. package/src/needs-attention.ts +1506 -0
  576. package/src/orphan-sidecar.ts +150 -0
  577. package/src/output.ts +89 -0
  578. package/src/pi-harness.ts +403 -0
  579. package/src/placement.ts +131 -0
  580. package/src/prd-to-spec.ts +1023 -0
  581. package/src/prepare.ts +230 -0
  582. package/src/prompt.ts +760 -0
  583. package/src/readiness.ts +98 -0
  584. package/src/reap-branches.ts +278 -0
  585. package/src/recover-isolated.ts +276 -0
  586. package/src/registry.ts +475 -0
  587. package/src/repo-config.ts +550 -0
  588. package/src/repo-key.ts +74 -0
  589. package/src/repo-mirror.ts +367 -0
  590. package/src/retry-backoff.ts +130 -0
  591. package/src/review-gate.ts +389 -0
  592. package/src/review-verdict.ts +422 -0
  593. package/src/run.ts +1430 -0
  594. package/src/scan.ts +611 -0
  595. package/src/select-order.ts +143 -0
  596. package/src/select-priority.ts +266 -0
  597. package/src/select.ts +62 -0
  598. package/src/session-path.ts +153 -0
  599. package/src/sidecar-apply.ts +216 -0
  600. package/src/sidecar.ts +700 -0
  601. package/src/slug-namespace.ts +367 -0
  602. package/src/spec-complete.ts +118 -0
  603. package/src/start.ts +974 -0
  604. package/src/status.ts +441 -0
  605. package/src/surface-gate.ts +337 -0
  606. package/src/surface-persist.ts +241 -0
  607. package/src/tasker-review-loop.ts +671 -0
  608. package/src/tasking-eligibility.ts +114 -0
  609. package/src/tasking-lock.ts +416 -0
  610. package/src/tasking.ts +1438 -0
  611. package/src/triage-gate.ts +248 -0
  612. package/src/triage-persist.ts +570 -0
  613. package/src/verdict-json.ts +73 -0
  614. package/src/verify-workflow-template.ts +159 -0
  615. package/src/verify.ts +123 -0
  616. package/src/watch-session.ts +397 -0
  617. package/src/work-layout.ts +262 -0
  618. package/src/work-on.ts +660 -0
  619. package/src/workspace.ts +502 -0
@@ -0,0 +1,1381 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { runAsync } from './git.js';
3
+ import { resolveSidecarIdentity } from './sidecar.js';
4
+ import { workItemRel } from './work-layout.js';
5
+ /**
6
+ * The **unified item-lock module** (prd `ledger-status-per-item-lock-refs`, ADR
7
+ * `ledger-status-on-per-item-lock-refs`). The runner's ONE lock primitive: ONE
8
+ * lock per item, on a PER-ITEM hidden ref `refs/dorfl/lock/<entry>`,
9
+ * acquired by an ATOMIC create-only push and released by DELETING the ref, with a
10
+ * two-axis (`action` × `state`) entry. It collapses the old transient status
11
+ * folders (`in-progress`, `needs-attention`, `tasking`, `advancing`) into ONE
12
+ * lock keyed by item identity; `in-progress` = lock held active for `implement`,
13
+ * `needs-attention` = lock held `stuck`.
14
+ *
15
+ * It GENERALISES the green tracer that proved the dangerous core end-to-end on a
16
+ * bare `file://` arbiter (the tracer is now this file). The one production
17
+ * difference from the tracer is the IDENTITY SEAM: callers pass a NAMESPACED item
18
+ * identity (`task:<slug>` / `prd:<slug>` / `observation:<slug>` / `obs:<slug>`,
19
+ * or a bare `<slug>` = task), and this module derives the type-encoded lock
20
+ * `<entry>` (`<type>-<slug>`) through {@link resolveSidecarIdentity} — the SAME
21
+ * single source of truth the sidecar (`work/questions/<type>-<slug>.md`) and the
22
+ * work branch (`work/<type>-<slug>`) already use. There is deliberately NO second
23
+ * identity scheme: a task, a prd,
24
+ * and an observation that share a slug get DISTINCT lock refs, and the SAME item
25
+ * under different actions shares ONE ref (so implement / task / advance on one
26
+ * item are mutually exclusive by construction).
27
+ *
28
+ * It is NOT yet wired into claim/task/advance — those are separate, dependent
29
+ * tasks — and deliberately does NOT touch `main`.
30
+ *
31
+ * WHY a per-item ref (not a marker on `main` or a tree on one shared ref):
32
+ * - **No false contention, no retry.** The ONLY writer that can contend on item
33
+ * X's lock is another writer FOR X — a GENUINE conflict the loser SHOULD lose.
34
+ * Two writers for DIFFERENT items touch DIFFERENT refs and never serialise. So
35
+ * acquire needs NO refetch-retry budget (contrast the shared-`main` CAS, which
36
+ * falsely-contends under parallelism and exhausts its retry cap → exit 3).
37
+ * - **Self-cleaning, no storage growth.** Acquire CREATES the ref; release
38
+ * DELETES it (not "empties" it), so the live ref set = currently-held items
39
+ * only. The lock commit is PARENTLESS (no `main` parent), so on ref-delete it
40
+ * is immediately unreachable and reclaimed by normal git gc — and the lock ref
41
+ * is fully decoupled from `main`'s object graph.
42
+ * - **Branch-inheritance impossible.** Nothing is in `main`'s tree, so a work
43
+ * branch cut from `main` inherits no lock state.
44
+ * - **Provider-agnostic.** A ref is a ref: the create-only / delete pushes work
45
+ * identically on a `--bare file://` arbiter and a real remote.
46
+ *
47
+ * ATOMICITY is the `--force-with-lease=<ref>:` (EMPTY expected) create-only push:
48
+ * the arbiter accepts it ONLY if the ref is still absent; a racer who lost finds
49
+ * the ref present and is rejected. No nonce gymnastics are needed here (unlike the
50
+ * shared-`main` CAS) because the ref NAME is the identity — two acquires for the
51
+ * same item race the SAME ref, and ref-level create-only is itself the mutex.
52
+ */
53
+ /** The ref namespace for per-item locks. HIDDEN (not `refs/heads/*`): invisible
54
+ * in the GitHub UI, not swept by branch automation, not fetched by a default
55
+ * clone. Deletion = "all locks released" (recoverable; work is on the work
56
+ * branches + `main`). */
57
+ export const LOCK_REF_PREFIX = 'refs/dorfl/lock';
58
+ /**
59
+ * The single IDENTITY SEAM for the lock: derive the type-encoded lock `<entry>`
60
+ * (`<type>-<slug>`) from a NAMESPACED item identity, through the shared
61
+ * {@link resolveSidecarIdentity} resolver (the single source of truth, which the
62
+ * sidecar filename + the advancing-lock marker also key onto). Accepts the same
63
+ * forms as that resolver: `task:<slug>` / `prd:<slug>` / `observation:<slug>` /
64
+ * `obs:<slug>`, or a bare `<slug>` (= task). Acquire/release/read ALL key
65
+ * through THIS function, so there is one — and only one — addressing scheme.
66
+ */
67
+ export function lockEntryFor(item) {
68
+ const { type, slug } = resolveSidecarIdentity(item);
69
+ return `${type}-${slug}`;
70
+ }
71
+ /** The lock ref for a type-encoded `<entry>` (`<type>-<slug>`). */
72
+ export function itemLockRef(entry) {
73
+ return `${LOCK_REF_PREFIX}/${entry}`;
74
+ }
75
+ function gitSoft(args, cwd, env) {
76
+ return runAsync('git', args, cwd, { env });
77
+ }
78
+ async function gitHard(args, cwd, env) {
79
+ const r = await runAsync('git', args, cwd, { env });
80
+ if (r.status !== 0) {
81
+ throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`);
82
+ }
83
+ return r;
84
+ }
85
+ /** The body heading that opens the (possibly multi-line) stuck reason prose. */
86
+ const LOCK_REASON_HEADING = '## Reason';
87
+ /** The body heading that opens the agent-surfaced questions list. */
88
+ const LOCK_QUESTIONS_HEADING = '## Questions';
89
+ /**
90
+ * Serialise a lock entry to the ref's blob body (markdown frontmatter, like the
91
+ * advancing marker, so it round-trips and is previewable). The two-axis state
92
+ * (`entry`/`action`/`state`/`holder`/`since`) lives in the frontmatter; a stuck
93
+ * entry's FULL reason prose + any surfaced questions live in the BODY (under
94
+ * `## Reason` / `## Questions`) so they round-trip RICHLY (multi-line reason,
95
+ * bulleted questions) — the lock entry is the SOLE stuck record now, in a shape a
96
+ * future advance-surface rung can render. {@link parseLockEntry} is the exact
97
+ * inverse.
98
+ */
99
+ export function serialiseLockEntry(e) {
100
+ const lines = [
101
+ '---',
102
+ `entry: ${e.entry}`,
103
+ `action: ${e.action}`,
104
+ `state: ${e.state}`,
105
+ `holder: ${e.holder}`,
106
+ `since: ${e.since}`,
107
+ '---',
108
+ '',
109
+ `Lock held for \`${e.entry}\` (${e.action}/${e.state}).`,
110
+ ];
111
+ if (e.state === 'stuck' && e.reason) {
112
+ lines.push('', LOCK_REASON_HEADING, '', ...e.reason.split('\n'));
113
+ }
114
+ if (e.state === 'stuck' && e.questions && e.questions.length > 0) {
115
+ lines.push('', LOCK_QUESTIONS_HEADING, '');
116
+ for (const q of e.questions) {
117
+ lines.push(`- ${q}`);
118
+ }
119
+ }
120
+ lines.push('');
121
+ return lines.join('\n');
122
+ }
123
+ /**
124
+ * Build a PARENTLESS commit whose tree contains the single lock-entry blob, and
125
+ * return its sha. Parentless (no `-p`) so it is decoupled from `main` and becomes
126
+ * unreachable the instant the ref is deleted (gc reclaims it). Uses plumbing
127
+ * (`hash-object` → `mktree` → `commit-tree`), never the working tree/index/HEAD,
128
+ * so it is safe to call from any worktree.
129
+ */
130
+ async function buildLockCommit(entry, cwd, env) {
131
+ const body = serialiseLockEntry(entry);
132
+ const blob = (await gitHardInput(['hash-object', '-w', '--stdin'], cwd, env, body)).stdout.trim();
133
+ // One tree entry: `lock.md` → the blob.
134
+ const treeInput = `100644 blob ${blob}\tlock.md\n`;
135
+ const tree = (await gitHardInput(['mktree'], cwd, env, treeInput)).stdout.trim();
136
+ const message = `lock: ${entry.entry} (${entry.action}/${entry.state})`;
137
+ // PARENTLESS commit (no -p): the lock graph never joins main's history.
138
+ const commit = (await gitHard(['commit-tree', tree, '-m', message], cwd, env)).stdout.trim();
139
+ return commit;
140
+ }
141
+ /** `gitHard` variant that pipes `input` to stdin (for hash-object/mktree). */
142
+ async function gitHardInput(args, cwd, env, input) {
143
+ const r = await runAsync('git', args, cwd, { env, input });
144
+ if (r.status !== 0) {
145
+ throw new Error(`git ${args.join(' ')} failed (exit ${r.status}): ${r.stderr.trim()}`);
146
+ }
147
+ return r;
148
+ }
149
+ /**
150
+ * Acquire the per-item lock atomically: build a parentless lock commit and push it
151
+ * to `refs/dorfl/lock/<entry>` with `--force-with-lease=<ref>:` (the EMPTY
152
+ * expected value = create-only). The arbiter accepts ONLY if the ref is still
153
+ * absent. A racer who lost finds the ref present → its lease fails → `lost`. No
154
+ * retry loop: a rejection here is a GENUINE same-item conflict, not false
155
+ * contention.
156
+ */
157
+ export async function acquireItemLock(opts) {
158
+ const arbiter = opts.arbiter ?? 'origin';
159
+ const env = opts.env;
160
+ const cwd = opts.cwd;
161
+ if (!opts.item) {
162
+ return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
163
+ }
164
+ const entry = lockEntryFor(opts.item);
165
+ const ref = itemLockRef(entry);
166
+ try {
167
+ // Fetch the current lock refs so the lease sees the real state.
168
+ await gitHard([
169
+ 'fetch',
170
+ '--quiet',
171
+ arbiter,
172
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
173
+ ], cwd, env);
174
+ const holder = opts.holder ?? (await resolveHolder(cwd, env));
175
+ const commit = await buildLockCommit({
176
+ entry,
177
+ action: opts.action,
178
+ state: 'active',
179
+ holder,
180
+ since: new Date().toISOString(),
181
+ }, cwd, env);
182
+ // CREATE-ONLY push: --force-with-lease=<ref>: (empty) succeeds iff ref absent.
183
+ const push = await gitSoft(['push', arbiter, `${commit}:${ref}`, `--force-with-lease=${ref}:`], cwd, env);
184
+ if (push.status === 0) {
185
+ return { outcome: 'acquired', entry, ref, message: `locked ${entry}` };
186
+ }
187
+ // Rejected: the ref already exists (held by someone for the SAME item).
188
+ return {
189
+ outcome: 'lost',
190
+ entry,
191
+ ref,
192
+ message: `'${entry}' is already locked (held by another). Back off.`,
193
+ };
194
+ }
195
+ catch (err) {
196
+ return {
197
+ outcome: 'error',
198
+ entry,
199
+ ref,
200
+ message: err instanceof Error ? err.message : String(err),
201
+ };
202
+ }
203
+ }
204
+ /**
205
+ * Release the per-item lock by DELETING the ref (`git push <arbiter>
206
+ * :refs/dorfl/lock/<entry>`). Deleting (not emptying) is what makes the
207
+ * lock set self-cleaning: a released item has NO ref, and its parentless commit
208
+ * becomes unreachable for gc. Idempotent: deleting an absent ref is `not-held`.
209
+ */
210
+ export async function releaseItemLock(opts) {
211
+ const arbiter = opts.arbiter ?? 'origin';
212
+ const env = opts.env;
213
+ const cwd = opts.cwd;
214
+ if (!opts.item) {
215
+ return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
216
+ }
217
+ const entry = lockEntryFor(opts.item);
218
+ const ref = itemLockRef(entry);
219
+ try {
220
+ await gitHard([
221
+ 'fetch',
222
+ '--quiet',
223
+ arbiter,
224
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
225
+ ], cwd, env);
226
+ const held = (await gitSoft(['rev-parse', '--verify', '--quiet', ref], cwd, env))
227
+ .status === 0;
228
+ if (!held) {
229
+ return {
230
+ outcome: 'not-held',
231
+ entry,
232
+ ref,
233
+ message: `'${entry}' not locked`,
234
+ };
235
+ }
236
+ // Delete the ref on the arbiter. Lease on the current value guards against a
237
+ // concurrent change between our fetch and the delete.
238
+ const cur = (await gitHard(['rev-parse', ref], cwd, env)).stdout.trim();
239
+ const del = await gitSoft(['push', arbiter, '--delete', ref, `--force-with-lease=${ref}:${cur}`], cwd, env);
240
+ if (del.status === 0) {
241
+ // Drop our local copy of the ref too (best-effort).
242
+ await gitSoft(['update-ref', '-d', ref], cwd, env);
243
+ return { outcome: 'released', entry, ref, message: `released ${entry}` };
244
+ }
245
+ return {
246
+ outcome: 'error',
247
+ entry,
248
+ ref,
249
+ message: `release push rejected: ${del.stderr.trim()}`,
250
+ };
251
+ }
252
+ catch (err) {
253
+ return {
254
+ outcome: 'error',
255
+ entry,
256
+ ref,
257
+ message: err instanceof Error ? err.message : String(err),
258
+ };
259
+ }
260
+ }
261
+ /**
262
+ * GUARDED release for a runner that KNOWS it acquired and HELD the lock (prd
263
+ * `ledger-status-per-item-lock-refs` US #13): unlike {@link releaseItemLock} —
264
+ * whose `not-held` is a BENIGN idempotent case the complete/tasking/needs-attention
265
+ * callers tolerate (the body may predate the lock, or a crash-recovery may have
266
+ * already cleared it) — here the absence of OUR ref is an ABORT SIGNAL. A held
267
+ * runner whose own lock VANISHED mid-build (someone `release-lock`-ed it, or a
268
+ * `gc`/recovery cleared it) must DETECT it on release and route to
269
+ * needs-attention rather than silently "clean-release" a lock it no longer holds:
270
+ * the work it just did was NOT protected by the exclusion it thought it had.
271
+ *
272
+ * So this maps {@link releaseItemLock}'s `not-held` to a DISTINCT `vanished`
273
+ * outcome the caller branches on (abort / needs-attention), while a genuine
274
+ * `released` is the happy path and `error` stays `error`. It is otherwise the
275
+ * SAME leased delete (no second mechanism): a clean `released` deletes the ref we
276
+ * held. Use this ONLY where the caller provably HELD the lock (the in-flight
277
+ * runner's own release); the tolerant idempotent callers keep using
278
+ * {@link releaseItemLock}.
279
+ */
280
+ export async function releaseHeldItemLock(opts) {
281
+ const rel = await releaseItemLock(opts);
282
+ if (rel.outcome === 'not-held') {
283
+ return {
284
+ outcome: 'vanished',
285
+ entry: rel.entry,
286
+ ref: rel.ref,
287
+ message: `'${rel.entry}' lock VANISHED before our release (the ref is gone) — our hold was lost mid-build; abort / route to needs-attention rather than clean-release.`,
288
+ };
289
+ }
290
+ return {
291
+ outcome: rel.outcome,
292
+ entry: rel.entry,
293
+ ref: rel.ref,
294
+ message: rel.message,
295
+ };
296
+ }
297
+ /**
298
+ * Fetch the lock refs and return the held entry + its current ref sha, or
299
+ * `undefined` when the item is at REST (no ref). Shared read-before-CAS step for
300
+ * the amend-style transitions (mark-stuck / resume / requeue): they all need BOTH
301
+ * the current entry (to check the state precondition + carry forward `action` /
302
+ * `holder` / `since`) and the current sha (to lease the CAS on it).
303
+ */
304
+ async function fetchHeldEntry(entry, ref, cwd, arbiter, env) {
305
+ await gitHard(['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`], cwd, env);
306
+ const rev = await gitSoft(['rev-parse', '--verify', '--quiet', ref], cwd, env);
307
+ if (rev.status !== 0 || rev.stdout.trim() === '') {
308
+ return undefined;
309
+ }
310
+ const sha = rev.stdout.trim();
311
+ const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
312
+ const lock = show.status === 0 ? parseLockEntry(show.stdout) : undefined;
313
+ if (!lock) {
314
+ return undefined;
315
+ }
316
+ return { lock, sha };
317
+ }
318
+ /**
319
+ * AMEND the held entry in place via a leased CAS: build a NEW parentless commit
320
+ * carrying `next` and push it to the SAME ref with `--force-with-lease=<ref>:<sha>`
321
+ * (the sha we just read). The arbiter accepts ONLY if the ref is unchanged since
322
+ * our read — a concurrent writer who moved it makes our lease fail (`lost`). No
323
+ * retry loop: a rejection is a genuine same-item race the caller should lose.
324
+ */
325
+ async function amendHeldEntry(next, ref, expectedSha, cwd, arbiter, env) {
326
+ const commit = await buildLockCommit(next, cwd, env);
327
+ const push = await gitSoft([
328
+ 'push',
329
+ arbiter,
330
+ `${commit}:${ref}`,
331
+ `--force-with-lease=${ref}:${expectedSha}`,
332
+ ], cwd, env);
333
+ if (push.status === 0) {
334
+ // Move our local copy to the new commit too (best-effort) so a subsequent
335
+ // read in the same clone sees the amended entry without a refetch.
336
+ await gitSoft(['update-ref', ref, commit], cwd, env);
337
+ return {
338
+ outcome: 'transitioned',
339
+ entry: next.entry,
340
+ ref,
341
+ message: `${next.entry} → ${next.action}/${next.state}`,
342
+ lock: next,
343
+ };
344
+ }
345
+ return {
346
+ outcome: 'lost',
347
+ entry: next.entry,
348
+ ref,
349
+ message: `'${next.entry}' lock changed concurrently (CAS lost). Back off.`,
350
+ };
351
+ }
352
+ /**
353
+ * The ONE leased-delete CLEAR path shared by every code path that removes a held
354
+ * lock ref by lease (the recovery {@link reconcileItemLockAgainstMain}, the
355
+ * human-invoked {@link reapStaleItemLocks} sweep, …): delete `ref` on the arbiter
356
+ * with `--force-with-lease=<ref>:<expectedSha>`, so the arbiter accepts ONLY if the
357
+ * ref is UNCHANGED since the caller read `expectedSha`. A concurrent writer who
358
+ * moved the ref (e.g. a racer who just marked it `stuck`) makes the lease FAIL →
359
+ * `lost` (reported, NEVER force-deleted). On success the local copy is dropped too
360
+ * (best-effort). It is the SAME leased delete `release-lock` / requeue use — there
361
+ * is no second clear mechanism.
362
+ */
363
+ async function leasedDeleteLockRef(ref, expectedSha, cwd, arbiter, env) {
364
+ const del = await gitSoft([
365
+ 'push',
366
+ arbiter,
367
+ '--delete',
368
+ ref,
369
+ `--force-with-lease=${ref}:${expectedSha}`,
370
+ ], cwd, env);
371
+ if (del.status !== 0) {
372
+ return 'lost';
373
+ }
374
+ // Drop our local copy of the ref too (best-effort) so a subsequent read in the
375
+ // same clone does not see the now-deleted lock.
376
+ await gitSoft(['update-ref', '-d', ref], cwd, env);
377
+ return 'deleted';
378
+ }
379
+ /**
380
+ * mark-stuck (transition 2): `[action, active] -> [action, stuck] + reason`. The
381
+ * runner bounces (red gate, agent failure, decomposition-unclear). A leased CAS
382
+ * amend of the SAME entry's `state` + `reason`, keeping `action`/`holder`/`since`.
383
+ * It is the source of the needs-attention SURFACE (now read from the lock ref,
384
+ * not a `work/needs-attention/` folder).
385
+ *
386
+ * PRECONDITIONS (the state machine + invariants):
387
+ * - the entry must be HELD and `active` (`not-held` / `wrong-state` otherwise) —
388
+ * stuck is reachable only FROM active, never from absent or already-stuck.
389
+ * - `reason` must be non-empty (the `reason` PRESENT iff `state: stuck` invariant).
390
+ */
391
+ export async function markStuckItemLock(opts) {
392
+ const arbiter = opts.arbiter ?? 'origin';
393
+ const env = opts.env;
394
+ const cwd = opts.cwd;
395
+ if (!opts.item) {
396
+ return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
397
+ }
398
+ const entry = lockEntryFor(opts.item);
399
+ const ref = itemLockRef(entry);
400
+ if (!opts.reason || opts.reason.trim() === '') {
401
+ return {
402
+ outcome: 'error',
403
+ entry,
404
+ ref,
405
+ message: 'mark-stuck requires a reason (reason iff stuck)',
406
+ };
407
+ }
408
+ try {
409
+ const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
410
+ if (!held) {
411
+ return {
412
+ outcome: 'not-held',
413
+ entry,
414
+ ref,
415
+ message: `'${entry}' not locked`,
416
+ };
417
+ }
418
+ if (held.lock.state !== 'active') {
419
+ return {
420
+ outcome: 'wrong-state',
421
+ entry,
422
+ ref,
423
+ message: `'${entry}' is ${held.lock.state}, not active; cannot mark-stuck`,
424
+ };
425
+ }
426
+ const next = {
427
+ ...held.lock,
428
+ state: 'stuck',
429
+ reason: opts.reason.trim(),
430
+ };
431
+ const questions = (opts.questions ?? [])
432
+ .map((q) => q.trim())
433
+ .filter((q) => q !== '');
434
+ if (questions.length > 0) {
435
+ next.questions = questions;
436
+ }
437
+ else {
438
+ delete next.questions;
439
+ }
440
+ return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
441
+ }
442
+ catch (err) {
443
+ return {
444
+ outcome: 'error',
445
+ entry,
446
+ ref,
447
+ message: err instanceof Error ? err.message : String(err),
448
+ };
449
+ }
450
+ }
451
+ /**
452
+ * resume (transition 3): `[action, stuck] -> [action, active]`. A human (or a
453
+ * `continue`) picks the stuck item up: amend `state` back to `active` and CLEAR
454
+ * `reason` (the `reason` iff `stuck` invariant — an active entry never carries a
455
+ * stuck reason). Keeps the same `action`; `holder` may be reassigned. The
456
+ * lock-entry analogue of the old `needs-attention -> in-progress` folder move.
457
+ *
458
+ * PRECONDITION: the entry must be HELD and `stuck` (`not-held` / `wrong-state`
459
+ * otherwise) — active is reachable from stuck only, not from absent.
460
+ */
461
+ export async function resumeItemLock(opts) {
462
+ const arbiter = opts.arbiter ?? 'origin';
463
+ const env = opts.env;
464
+ const cwd = opts.cwd;
465
+ if (!opts.item) {
466
+ return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
467
+ }
468
+ const entry = lockEntryFor(opts.item);
469
+ const ref = itemLockRef(entry);
470
+ try {
471
+ const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
472
+ if (!held) {
473
+ return {
474
+ outcome: 'not-held',
475
+ entry,
476
+ ref,
477
+ message: `'${entry}' not locked`,
478
+ };
479
+ }
480
+ if (held.lock.state !== 'stuck') {
481
+ return {
482
+ outcome: 'wrong-state',
483
+ entry,
484
+ ref,
485
+ message: `'${entry}' is ${held.lock.state}, not stuck; nothing to resume`,
486
+ };
487
+ }
488
+ const next = {
489
+ ...held.lock,
490
+ state: 'active',
491
+ holder: opts.holder ?? held.lock.holder,
492
+ };
493
+ // reason + questions are PRESENT iff stuck: drop them on the way to active.
494
+ delete next.reason;
495
+ delete next.questions;
496
+ return await amendHeldEntry(next, ref, held.sha, cwd, arbiter, env);
497
+ }
498
+ catch (err) {
499
+ return {
500
+ outcome: 'error',
501
+ entry,
502
+ ref,
503
+ message: err instanceof Error ? err.message : String(err),
504
+ };
505
+ }
506
+ }
507
+ /**
508
+ * requeue (transition 4): `[action, stuck] -> (absent)`. Give up on a STUCK hold
509
+ * and return the item to the pool by REMOVING the entry. The body never moved
510
+ * (Amendment 5 — it is already resting in `backlog/` on `main`), so requeue is
511
+ * purely "release the lock"; the kept `work/<slug>` branch remains for recovery.
512
+ *
513
+ * Distinct from {@link releaseItemLock} (transition 6, abort from ACTIVE): requeue
514
+ * is the GUARDED give-up from `stuck` only, so it rejects (`wrong-state`) an
515
+ * `active` entry — abandoning an in-flight active hold goes through `release`, not
516
+ * `requeue`. The removal itself is a leased delete (a concurrent change ⇒ `lost`).
517
+ */
518
+ export async function requeueItemLock(opts) {
519
+ const arbiter = opts.arbiter ?? 'origin';
520
+ const env = opts.env;
521
+ const cwd = opts.cwd;
522
+ if (!opts.item) {
523
+ return { outcome: 'error', entry: '', ref: '', message: 'missing item' };
524
+ }
525
+ const entry = lockEntryFor(opts.item);
526
+ const ref = itemLockRef(entry);
527
+ try {
528
+ const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
529
+ if (!held) {
530
+ return {
531
+ outcome: 'not-held',
532
+ entry,
533
+ ref,
534
+ message: `'${entry}' not locked`,
535
+ };
536
+ }
537
+ if (held.lock.state !== 'stuck') {
538
+ return {
539
+ outcome: 'wrong-state',
540
+ entry,
541
+ ref,
542
+ message: `'${entry}' is ${held.lock.state}, not stuck; use release to abort an active hold`,
543
+ };
544
+ }
545
+ const del = await gitSoft([
546
+ 'push',
547
+ arbiter,
548
+ '--delete',
549
+ ref,
550
+ `--force-with-lease=${ref}:${held.sha}`,
551
+ ], cwd, env);
552
+ if (del.status === 0) {
553
+ await gitSoft(['update-ref', '-d', ref], cwd, env);
554
+ return {
555
+ outcome: 'transitioned',
556
+ entry,
557
+ ref,
558
+ message: `requeued ${entry} (lock released, body still in pool)`,
559
+ };
560
+ }
561
+ return {
562
+ outcome: 'lost',
563
+ entry,
564
+ ref,
565
+ message: `'${entry}' lock changed concurrently (CAS lost). Back off.`,
566
+ };
567
+ }
568
+ catch (err) {
569
+ return {
570
+ outcome: 'error',
571
+ entry,
572
+ ref,
573
+ message: err instanceof Error ? err.message : String(err),
574
+ };
575
+ }
576
+ }
577
+ /**
578
+ * Read the current lock entry for an item from the arbiter (the `status`/`scan`
579
+ * read path): fetch the lock refs, read `lock.md` from the ref's tree. Returns
580
+ * `undefined` when the item is not locked.
581
+ */
582
+ export async function readItemLock(opts) {
583
+ const arbiter = opts.arbiter ?? 'origin';
584
+ const env = opts.env;
585
+ const cwd = opts.cwd;
586
+ const ref = itemLockRef(lockEntryFor(opts.item));
587
+ await gitHard(['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`], cwd, env);
588
+ const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
589
+ if (show.status !== 0) {
590
+ return undefined;
591
+ }
592
+ return parseLockEntry(show.stdout);
593
+ }
594
+ /**
595
+ * The DURABLE-`main` terminal record paths for an item, by its type — the
596
+ * authoritative resting records the cross-substrate reconciliation reads. An
597
+ * item is TERMINAL on `main` iff ANY of these paths exists on `<arbiter>/main`.
598
+ * The won't-proceed terminal is PER-REGIME (the slug-collision correctness fix:
599
+ * a dropped task and a dropped prd sharing a slug used to collide on one
600
+ * bare-slug `work/dropped/<slug>.md`):
601
+ * - a TASK: `work/tasks/done/<slug>.md` (completed) OR
602
+ * `work/tasks/cancelled/<slug>.md` (the task regime's won't-proceed terminal).
603
+ * - a PRD: `work/prds/tasked/<slug>.md` (tasked) OR `work/prds/dropped/<slug>.md`
604
+ * (the prd regime's won't-proceed terminal).
605
+ * - an OBSERVATION: NONE. A note has no durable terminal folder — it leaves by
606
+ * deletion (its absence, not a terminal record, is the end state). A promoted
607
+ * observation becomes a NEW task/prd with its own ref.
608
+ */
609
+ export function terminalMainPaths(type, slug) {
610
+ const file = `${slug}.md`;
611
+ switch (type) {
612
+ case 'task':
613
+ return [workItemRel('done', file), workItemRel('cancelled', file)];
614
+ // The parent-spec regime's durable terminals (`specs/tasked` tasked,
615
+ // `specs/dropped` won't-proceed) — a `spec:<slug>` lock resolves to these
616
+ // `work/specs/*` records.
617
+ case 'spec':
618
+ return [
619
+ workItemRel('specs-tasked', file),
620
+ workItemRel('specs-dropped', file),
621
+ ];
622
+ case 'observation':
623
+ return [];
624
+ }
625
+ }
626
+ /**
627
+ * Reconcile ONE item's per-item lock against the AUTHORITATIVE `main` durable
628
+ * record — the heart of complete's cross-substrate crash-safety (prd
629
+ * `ledger-status-per-item-lock-refs` US #9/#10; ADR
630
+ * `ledger-status-on-per-item-lock-refs`; the design trail's Amendment 6).
631
+ *
632
+ * complete's order is hold lock → land the DURABLE `main` move FIRST → release
633
+ * the lock SECOND. A crash BETWEEN the move and the release leaves a
634
+ * terminal-on-`main` item (a completed/cancelled task or a tasked/dropped prd,
635
+ * per {@link terminalMainPaths}) with a STILL-HELD lock — a stale lock with no
636
+ * in-flight work behind it. This is the recovery that converges it.
637
+ *
638
+ * THE RECOVERY RULE (the `main` record is authoritative over a stale lock):
639
+ * - `main` is TERMINAL + the held lock is `active` → the item is RESTED, the
640
+ * lock is STALE (the crash was after the move) → CLEAR it (`cleared-stale`).
641
+ * - `main` is TERMINAL + the held lock is `stuck` → KEEP it (`kept-stuck`).
642
+ * `done` + `stuck` may legitimately CO-EXIST (a rebase-conflict bounce of a
643
+ * just-completed item — US #10). The stuck lock wins the human's attention;
644
+ * the `main` record wins dependency resolution. NOT corruption, never cleared
645
+ * here (a human resolves it via `resume`/`requeue`/`release-lock`).
646
+ * - `main` is NOT terminal + a lock is held → the NORMAL in-flight
647
+ * state (`kept-in-flight`); the lock is doing its job, leave it.
648
+ * - no lock at all → `no-lock` (at rest).
649
+ *
650
+ * Best-effort + idempotent: it NEVER throws (a fetch/read fault degrades to
651
+ * `error`, leaving the lock untouched — the safe direction), and re-running it
652
+ * on an already-reconciled item is a clean `no-lock`. The clear is the SAME
653
+ * leased delete {@link releaseItemLock} uses, so it cannot race off a concurrent
654
+ * change.
655
+ *
656
+ * BROADENED CONTRACT (leased-delete-rejection arm, task
657
+ * `reaper-no-lock-outcome-benign-not-lost`, promote-slice follow-up
658
+ * `reconcile-item-lock-broadened-contract-audit`, review 2026-06-20): when the
659
+ * SHARED leased delete is REJECTED (the arbiter ref moved between our read and
660
+ * our write) this function performs an EXTRA `git ls-remote <arbiter> <ref>`
661
+ * round-trip to distinguish the two rejection sub-cases, and applies to ALL
662
+ * callers — NOT only the reaper:
663
+ * - REMOTE REF IS EMPTY (a concurrent reaper / release-lock / requeue cleared
664
+ * the SAME stale lock first): the desired end state — benign. This function
665
+ * ALSO `git update-ref -d`s the LOCAL stale tracking ref as a SIDE-EFFECT
666
+ * (so a subsequent read in this clone sees the correct deleted state; a
667
+ * non-pruning fetch would otherwise leave a dangling local ref), then
668
+ * returns `no-lock`. Note the outcome-shape change: this rejection USED TO
669
+ * surface as `error` — it now surfaces as `no-lock` for EVERY caller. A
670
+ * caller that keys recovery / surfacing off `error` on this arm must be
671
+ * audited (see the follow-up task's `## Decisions` block).
672
+ * - REMOTE REF STILL EXISTS AT A DIFFERENT SHA (a genuine concurrent mutation
673
+ * — e.g. a racer just marked it `stuck`): back off rather than force —
674
+ * `error`.
675
+ * Because the function name reads "reconcile" (read-style), the local
676
+ * `update-ref -d` is a deliberate SIDE-EFFECT on the local clone's refs — it
677
+ * only fires on this specific rejection arm and is otherwise invisible.
678
+ */
679
+ export async function reconcileItemLockAgainstMain(opts) {
680
+ const arbiter = opts.arbiter ?? 'origin';
681
+ const env = opts.env;
682
+ const cwd = opts.cwd;
683
+ if (!opts.item) {
684
+ return {
685
+ outcome: 'error',
686
+ entry: '',
687
+ ref: '',
688
+ terminalOnMain: false,
689
+ message: 'missing item',
690
+ };
691
+ }
692
+ const { type, slug } = resolveSidecarIdentity(opts.item);
693
+ const entry = lockEntryFor(opts.item);
694
+ const ref = itemLockRef(entry);
695
+ try {
696
+ // One fetch refreshes BOTH the lock refs and `<arbiter>/main` so the lock and
697
+ // the durable record are read from the SAME live arbiter snapshot.
698
+ await gitHard([
699
+ 'fetch',
700
+ '--quiet',
701
+ arbiter,
702
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
703
+ ], cwd, env);
704
+ await gitHard(['fetch', '--quiet', arbiter], cwd, env);
705
+ const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
706
+ const terminalOnMain = await isTerminalOnMain(type, slug, arbiter, cwd, env);
707
+ if (!held) {
708
+ return {
709
+ outcome: 'no-lock',
710
+ entry,
711
+ ref,
712
+ terminalOnMain,
713
+ message: `'${entry}' has no lock to reconcile`,
714
+ };
715
+ }
716
+ if (!terminalOnMain) {
717
+ // A held lock + a non-terminal `main` is the NORMAL in-flight state.
718
+ return {
719
+ outcome: 'kept-in-flight',
720
+ entry,
721
+ ref,
722
+ terminalOnMain,
723
+ message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
724
+ };
725
+ }
726
+ if (held.lock.state === 'stuck') {
727
+ // a terminal-on-main record + `stuck` co-exist legitimately (US #10) —
728
+ // NOT corruption. Keep the stuck lock (it wins the human's attention).
729
+ return {
730
+ outcome: 'kept-stuck',
731
+ entry,
732
+ ref,
733
+ terminalOnMain,
734
+ message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
735
+ };
736
+ }
737
+ // Terminal on `main` + an `active` lock = a STALE lock (the crash was after
738
+ // the durable move, before the release). The `main` record is authoritative:
739
+ // clear the stale lock with the SHARED leased delete (the SAME one
740
+ // `release-lock` / requeue / the reaper use).
741
+ const cleared = await leasedDeleteLockRef(ref, held.sha, cwd, arbiter, env);
742
+ if (cleared === 'deleted') {
743
+ return {
744
+ outcome: 'cleared-stale',
745
+ entry,
746
+ ref,
747
+ terminalOnMain,
748
+ message: `cleared the stale lock for '${entry}' (terminal on ${arbiter}/main; the durable record is authoritative)`,
749
+ };
750
+ }
751
+ // The leased delete was REJECTED. Distinguish two sub-cases at the recovery
752
+ // boundary so callers (the reaper) can route them differently:
753
+ // (a) the remote ref is ALREADY GONE — a concurrent reaper / release-lock /
754
+ // requeue cleared the SAME stale lock first. The desired end state;
755
+ // benign — we report `no-lock` (the existing "already at rest" verdict).
756
+ // (b) the remote ref still exists but at a DIFFERENT sha than we leased
757
+ // against — a genuine concurrent mutation (e.g. a racer just marked it
758
+ // `stuck`). Back off rather than force; this is the real `error`.
759
+ const remote = await gitSoft(['ls-remote', arbiter, ref], cwd, env);
760
+ const remoteEmpty = remote.status === 0 && remote.stdout.trim() === '';
761
+ if (remoteEmpty) {
762
+ // Drop our stale local copy too — a non-pruning fetch left it pointing at
763
+ // the now-gone sha; with it gone locally a subsequent read in this clone
764
+ // sees the correct (deleted) state.
765
+ await gitSoft(['update-ref', '-d', ref], cwd, env);
766
+ return {
767
+ outcome: 'no-lock',
768
+ entry,
769
+ ref,
770
+ terminalOnMain,
771
+ message: `'${entry}' has no lock to reconcile (already cleared by another reaper / release-lock / requeue)`,
772
+ };
773
+ }
774
+ return {
775
+ outcome: 'error',
776
+ entry,
777
+ ref,
778
+ terminalOnMain,
779
+ message: `stale-lock clear for '${entry}' rejected (changed concurrently to a different value); a racer may have moved the ref. Re-run after re-checking.`,
780
+ };
781
+ }
782
+ catch (err) {
783
+ return {
784
+ outcome: 'error',
785
+ entry,
786
+ ref,
787
+ terminalOnMain: false,
788
+ message: err instanceof Error ? err.message : String(err),
789
+ };
790
+ }
791
+ }
792
+ /**
793
+ * The READ-ONLY classifier behind {@link reconcileItemLockAgainstMain}: it derives
794
+ * the SAME {@link ReconcileOutcome} verdict, but NEVER mutates the arbiter — it
795
+ * does NOT clear a stale-active lock, it only REPORTS that the lock IS
796
+ * reconcilable. This is what the `gc --ledger` stuck-lock report keys off (the
797
+ * forward-pointer's wiring): per the no-auto-sweep ADR rule, the plain report
798
+ * surfaces a stale-active lock as `cleared-stale`-eligible WITHOUT clearing it —
799
+ * a human still asserts the clear via `release-lock` (or runs complete's recovery,
800
+ * which DOES clear via {@link reconcileItemLockAgainstMain}). The classification
801
+ * is identical so the report and the recovery never disagree about WHAT a lock is;
802
+ * only the ACTION differs (report vs clear).
803
+ *
804
+ * A `cleared-stale` outcome here therefore means "would be cleared by recovery /
805
+ * `release-lock`" (the lock is terminal-on-`main` + `active` = stale), NOT that
806
+ * anything was cleared. Best-effort + never throws (a fetch/read fault degrades to
807
+ * `error`).
808
+ */
809
+ export async function classifyItemLockAgainstMain(opts) {
810
+ const arbiter = opts.arbiter ?? 'origin';
811
+ const env = opts.env;
812
+ const cwd = opts.cwd;
813
+ if (!opts.item) {
814
+ return {
815
+ outcome: 'error',
816
+ entry: '',
817
+ ref: '',
818
+ terminalOnMain: false,
819
+ message: 'missing item',
820
+ };
821
+ }
822
+ const { type, slug } = resolveSidecarIdentity(opts.item);
823
+ const entry = lockEntryFor(opts.item);
824
+ const ref = itemLockRef(entry);
825
+ try {
826
+ await gitHard([
827
+ 'fetch',
828
+ '--quiet',
829
+ arbiter,
830
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
831
+ ], cwd, env);
832
+ await gitHard(['fetch', '--quiet', arbiter], cwd, env);
833
+ const held = await fetchHeldEntry(entry, ref, cwd, arbiter, env);
834
+ const terminalOnMain = await isTerminalOnMain(type, slug, arbiter, cwd, env);
835
+ if (!held) {
836
+ return {
837
+ outcome: 'no-lock',
838
+ entry,
839
+ ref,
840
+ terminalOnMain,
841
+ message: `'${entry}' has no lock to reconcile`,
842
+ };
843
+ }
844
+ if (!terminalOnMain) {
845
+ return {
846
+ outcome: 'kept-in-flight',
847
+ entry,
848
+ ref,
849
+ terminalOnMain,
850
+ message: `'${entry}' is in flight (held, not terminal on ${arbiter}/main)`,
851
+ };
852
+ }
853
+ if (held.lock.state === 'stuck') {
854
+ return {
855
+ outcome: 'kept-stuck',
856
+ entry,
857
+ ref,
858
+ terminalOnMain,
859
+ message: `'${entry}' is terminal on ${arbiter}/main but STUCK — kept for human attention (resume/requeue/release-lock)`,
860
+ };
861
+ }
862
+ // Terminal on `main` + an `active` lock = a STALE lock. Unlike
863
+ // `reconcileItemLockAgainstMain` we do NOT clear it here — the report only
864
+ // names it as reconcilable; the human asserts the clear (no auto-sweep).
865
+ return {
866
+ outcome: 'cleared-stale',
867
+ entry,
868
+ ref,
869
+ terminalOnMain,
870
+ message: `'${entry}' is terminal on ${arbiter}/main but the lock is ACTIVE — reconcilable (stale); clear via 'release-lock' (NOT auto-cleared by the report)`,
871
+ };
872
+ }
873
+ catch (err) {
874
+ return {
875
+ outcome: 'error',
876
+ entry,
877
+ ref,
878
+ terminalOnMain: false,
879
+ message: err instanceof Error ? err.message : String(err),
880
+ };
881
+ }
882
+ }
883
+ /**
884
+ * Build the `gc --ledger` stuck/orphaned-lock REPORT (prd
885
+ * `ledger-status-per-item-lock-refs` US #12/#13/#14; ADR
886
+ * `ledger-status-on-per-item-lock-refs`): enumerate every per-item lock currently
887
+ * held on the arbiter ({@link listItemLockEntries} — held active + stuck, with
888
+ * holder/since/reason) and classify EACH read-only against the authoritative
889
+ * `main` durable record via {@link classifyItemLockAgainstMain} (the wiring the
890
+ * `complete-lock-then-durable-main-move-crash-safe` task's
891
+ * `reconcileItemLockAgainstMain` had no production caller for). This is the
892
+ * generalisation of the (now-retired) advancing-marker report from advancing-only
893
+ * to the UNIFIED lock (the `gc --ledger` stuck-lock report).
894
+ *
895
+ * It is a REPORT, never a sweep: it CLEARS nothing (no liveness heartbeat, no
896
+ * auto-sweep — the same trust model as the advancing report; a human asserts a
897
+ * lock is dead via `release-lock`). A stale-active lock over a terminal-on-`main`
898
+ * item is surfaced as `cleared-stale`-eligible ("reconcilable") but left in place.
899
+ *
900
+ * Best-effort + degrades safely: an absent lock-ref namespace ⇒ an EMPTY report
901
+ * ({@link listItemLockEntries} returns `[]`) — so a deleted lock ref reads as "all
902
+ * locks released" (recoverable; work is safe on the `work/<slug>` branches +
903
+ * `main`).
904
+ */
905
+ export async function reportItemLocks(cwd, arbiter = 'origin', env) {
906
+ const entries = await listItemLockEntries(cwd, arbiter, env);
907
+ const locks = [];
908
+ for (const lock of entries) {
909
+ const ref = itemLockRef(lock.entry);
910
+ // Read-only classification against `main` — NEVER clears (the report is
911
+ // advisory; a human asserts the clear via `release-lock`).
912
+ const verdict = await classifyItemLockAgainstMain({
913
+ item: itemFromLockEntry(lock.entry),
914
+ cwd,
915
+ arbiter,
916
+ env,
917
+ });
918
+ locks.push({ lock, ref, reconcile: verdict.outcome });
919
+ }
920
+ return { locks };
921
+ }
922
+ /**
923
+ * Format the `gc --ledger` stuck/orphaned-lock REPORT for the terminal: one block
924
+ * per lingering lock (entry, action/state, holder, since, the stuck `reason`, the
925
+ * read-only `main`-reconciliation note, and a copy-pasteable `release-lock`
926
+ * hint). An EMPTY report yields NO lines (a clean lock set is silent here, like
927
+ * the duplicate-slug surface). It REPORTS only — the matching wording makes the
928
+ * no-auto-sweep contract explicit (a human asserts a lock is dead).
929
+ */
930
+ /**
931
+ * Does the `gc --ledger` lock report contain a lock that NEEDS HUMAN ATTENTION
932
+ * (prd US#14/#21; ADR `ledger-status-on-per-item-lock-refs`: this surface is the
933
+ * STUCK / crash-orphaned lock, NOT every held one)? TRUE iff some lock is
934
+ * `kept-stuck` (terminal-on-`main` + stuck) or `cleared-stale`-eligible
935
+ * (terminal-on-`main` + a stale active orphan). A `kept-in-flight` (active,
936
+ * non-terminal) lock is the NORMAL in-flight state of a healthy concurrent build
937
+ * (read by `status` as healthy) and does NOT count — so a routine `gc --ledger`
938
+ * health check whose only locks are healthy in-flight holds exits 0. This is the
939
+ * fail-loud EXIT predicate for the gc lock surface (the report itself still lists
940
+ * every held lock, in-flight ones informationally).
941
+ */
942
+ export function itemLockReportNeedsAttention(report) {
943
+ return report.locks.some((l) => l.reconcile === 'kept-stuck' || l.reconcile === 'cleared-stale');
944
+ }
945
+ export function formatItemLockReport(report) {
946
+ if (report.locks.length === 0) {
947
+ return [];
948
+ }
949
+ const lines = [
950
+ `Per-item locks: ${report.locks.length} lock(s) held on the arbiter ` +
951
+ '(REPORT only — no automatic sweep; the lock has no liveness heartbeat, so ' +
952
+ 'a human asserts a stuck/stale lock is dead via `release-lock`):',
953
+ ];
954
+ for (const { lock, reconcile } of report.locks) {
955
+ const item = itemFromLockEntry(lock.entry);
956
+ lines.push(` ${lock.entry} [${lock.action}/${lock.state}]`);
957
+ lines.push(` holder: ${lock.holder || '(unknown)'} since: ${lock.since || '(unknown)'}`);
958
+ if (lock.state === 'stuck' && lock.reason) {
959
+ lines.push(` reason: ${lock.reason}`);
960
+ }
961
+ lines.push(` ${reconcileNote(reconcile)}`);
962
+ lines.push(` resolve (if the lock is dead): \`dorfl release-lock ${item}\` (never --force).`);
963
+ }
964
+ return lines;
965
+ }
966
+ /** The human-readable line for a lock's read-only `main`-reconciliation verdict. */
967
+ function reconcileNote(reconcile) {
968
+ switch (reconcile) {
969
+ case 'kept-in-flight':
970
+ return 'in flight (held, not terminal on main) — normal; left untouched.';
971
+ case 'kept-stuck':
972
+ return 'terminal on main + STUCK (done+stuck co-exist) — kept for human attention.';
973
+ case 'cleared-stale':
974
+ return 'terminal on main + ACTIVE = STALE (reconcilable) — NOT auto-cleared; a human clears it.';
975
+ case 'no-lock':
976
+ return 'no lock (already at rest).';
977
+ case 'error':
978
+ return 'reconciliation against main could not be determined (left untouched — the safe direction).';
979
+ }
980
+ }
981
+ /**
982
+ * The OPT-IN `gc --ledger --reap-stale-locks` SWEEP (prd
983
+ * `ledger-status-per-item-lock-refs` US #14): a human asserting "clear the dead
984
+ * TERMINAL locks now", so one command sweeps every orphaned terminal lock instead
985
+ * of N hand-run `release-lock`s. It is the WRITE twin of {@link reportItemLocks}
986
+ * (the default report-only surface): it enumerates the SAME held locks, classifies
987
+ * each read-only, and for EXACTLY the `cleared-stale` class (terminal-on-`main` +
988
+ * `active` = stranded) performs the SHARED leased delete via
989
+ * {@link reconcileItemLockAgainstMain} (the recovery's clear, re-checked fresh per
990
+ * item) — there is NO parallel clear mechanism.
991
+ *
992
+ * SCOPE FENCE (the trust model the default preserves):
993
+ * - it clears ONLY `cleared-stale`. A `kept-stuck` (terminal + stuck — human
994
+ * attention) and a `kept-in-flight` (active + non-terminal — a healthy build)
995
+ * are NEVER reaped, even here. Because each clear goes through
996
+ * {@link reconcileItemLockAgainstMain}, which RE-reads + RE-classifies before
997
+ * deleting, a lock that turned stuck/in-flight between the report and the sweep
998
+ * is still safe (reconcile returns `kept-*`, not a delete).
999
+ * - the clear is a LEASED delete: a concurrent change to the ref makes it REJECT
1000
+ * (`lost`), reported — never a blind `--force`.
1001
+ *
1002
+ * The DEFAULT `gc --ledger` (no flag) never calls this: it stays report-only,
1003
+ * fail-loud, delete-nothing. This is gated behind the explicit `--reap-stale-locks`
1004
+ * flag (a human authorising the clear), exactly as `release-lock`'s trust model.
1005
+ */
1006
+ export async function reapStaleItemLocks(cwd, arbiter = 'origin', env) {
1007
+ const report = await reportItemLocks(cwd, arbiter, env);
1008
+ const entries = [];
1009
+ let reaped = 0;
1010
+ let alreadyReaped = 0;
1011
+ let kept = 0;
1012
+ let lost = 0;
1013
+ for (const { lock, ref, reconcile } of report.locks) {
1014
+ const item = itemFromLockEntry(lock.entry);
1015
+ if (reconcile === 'cleared-stale') {
1016
+ // Re-check + clear through the recovery's SHARED leased delete. Reconcile
1017
+ // re-reads the live ref, so a lock that turned stuck/in-flight since the
1018
+ // report is left alone; a concurrent change to the ref makes the lease lose.
1019
+ const rec = await reconcileItemLockAgainstMain({
1020
+ item,
1021
+ cwd,
1022
+ arbiter,
1023
+ env,
1024
+ });
1025
+ if (rec.outcome === 'cleared-stale') {
1026
+ reaped++;
1027
+ entries.push({ lock, ref, outcome: 'reaped', message: rec.message });
1028
+ }
1029
+ else if (rec.outcome === 'no-lock') {
1030
+ // BENIGN: the ref is already gone — the desired end state. The LOSER of
1031
+ // a concurrent double-reap (another reaper deleted the ref between our
1032
+ // report and our re-read), or a `release-lock`/`requeue` that cleared
1033
+ // the same stale lock in the meantime. NOT a lost lease (the lease was
1034
+ // not REJECTED; there was simply nothing left to delete), so this does
1035
+ // NOT count as needs-attention. Kept SEPARATE from `reaped` so the
1036
+ // summary does not lie about who did the deleting.
1037
+ alreadyReaped++;
1038
+ entries.push({
1039
+ lock,
1040
+ ref,
1041
+ outcome: 'already-reaped',
1042
+ message: rec.message,
1043
+ });
1044
+ }
1045
+ else if (rec.outcome === 'kept-stuck' ||
1046
+ rec.outcome === 'kept-in-flight') {
1047
+ // The lock changed between the report and the sweep — no longer stale.
1048
+ kept++;
1049
+ entries.push({ lock, ref, outcome: rec.outcome, message: rec.message });
1050
+ }
1051
+ else {
1052
+ // A lost lease (the ref was REJECTED because it changed concurrently to
1053
+ // a DIFFERENT value) or a per-item error — REPORTED, never forced.
1054
+ // Counts as needing attention after the sweep.
1055
+ lost++;
1056
+ entries.push({ lock, ref, outcome: 'lost', message: rec.message });
1057
+ }
1058
+ continue;
1059
+ }
1060
+ // NOT a cleared-stale candidate: a stuck or in-flight lock the reaper must
1061
+ // NEVER touch, even with the flag.
1062
+ if (reconcile === 'kept-stuck' || reconcile === 'kept-in-flight') {
1063
+ kept++;
1064
+ entries.push({
1065
+ lock,
1066
+ ref,
1067
+ outcome: reconcile,
1068
+ message: reconcileNote(reconcile),
1069
+ });
1070
+ }
1071
+ else {
1072
+ // no-lock / error from the classifier — nothing to reap; surface verbatim.
1073
+ lost++;
1074
+ entries.push({
1075
+ lock,
1076
+ ref,
1077
+ outcome: 'error',
1078
+ message: reconcileNote(reconcile),
1079
+ });
1080
+ }
1081
+ }
1082
+ return { entries, reaped, alreadyReaped, kept, lost };
1083
+ }
1084
+ /**
1085
+ * Does a {@link reapStaleItemLocks} sweep leave a lock that STILL needs human
1086
+ * attention? TRUE iff some entry is `kept-stuck` (a stuck lock the reaper rightly
1087
+ * left) or `lost`/`error` (a `cleared-stale` whose leased delete lost the race, or
1088
+ * an unresolvable fault). A `reaped` (successfully cleared) or a `kept-in-flight`
1089
+ * (healthy build) lock does NOT count — so a sweep that cleared every stale lock
1090
+ * and left only healthy in-flight holds exits 0. This is the post-sweep analogue of
1091
+ * {@link itemLockReportNeedsAttention}.
1092
+ */
1093
+ export function reapReportNeedsAttention(report) {
1094
+ // EXIT-CODE CONTRACT (recorded in this task's done record): the reaper exits 0
1095
+ // when all stale locks are reaped and only healthy in-flight locks remain;
1096
+ // exits 1 when a `kept-stuck` survives or a delete genuinely lost the race /
1097
+ // errored. An `already-reaped` (the loser of a concurrent double-reap saw the
1098
+ // ref already gone via `no-lock`) is BENIGN — the desired end state — and is
1099
+ // NOT in this set.
1100
+ return report.entries.some((e) => e.outcome === 'kept-stuck' ||
1101
+ e.outcome === 'lost' ||
1102
+ e.outcome === 'error');
1103
+ }
1104
+ /**
1105
+ * Format the `gc --ledger --reap-stale-locks` sweep for the terminal: a header
1106
+ * line with the reaped/kept counts, then one line per lock (what was reaped vs
1107
+ * kept vs lost). An EMPTY sweep (no locks held) yields NO lines (silent, like the
1108
+ * report). The wording keeps the no-blind-force contract explicit.
1109
+ */
1110
+ export function formatReapReport(report) {
1111
+ if (report.entries.length === 0) {
1112
+ return [];
1113
+ }
1114
+ const lines = [
1115
+ `Per-item lock sweep (--reap-stale-locks): reaped ${report.reaped} stale ` +
1116
+ `terminal lock(s), kept ${report.kept} (stuck/in-flight, never reaped)` +
1117
+ (report.alreadyReaped > 0
1118
+ ? `, ${report.alreadyReaped} already reaped by another sweep (no-lock — benign, the desired end state)`
1119
+ : '') +
1120
+ (report.lost > 0
1121
+ ? `, ${report.lost} could not be cleared (lease lost / error — reported, NEVER forced)`
1122
+ : '') +
1123
+ ':',
1124
+ ];
1125
+ for (const { lock, outcome, message } of report.entries) {
1126
+ const tag = outcome === 'reaped'
1127
+ ? '[reaped] '
1128
+ : outcome === 'already-reaped'
1129
+ ? '[already] '
1130
+ : outcome === 'lost'
1131
+ ? '[lost] '
1132
+ : outcome === 'error'
1133
+ ? '[error] '
1134
+ : '[kept] ';
1135
+ lines.push(` ${tag} ${lock.entry} [${lock.action}/${lock.state}] ${message}`);
1136
+ }
1137
+ return lines;
1138
+ }
1139
+ /**
1140
+ * Convert a type-encoded lock `<entry>` (`<type>-<slug>`) back into the namespaced
1141
+ * item form (`<namespace>:<slug>`) that {@link lockEntryFor} /
1142
+ * {@link resolveSidecarIdentity} accept — so the report's suggested `release-lock`
1143
+ * command is copy-pasteable and the classifier can re-key off the SAME identity.
1144
+ * Unknown prefixes fall back to the raw entry (still copyable). The inverse of
1145
+ * {@link lockEntryFor} for the three known namespaces.
1146
+ */
1147
+ export function itemFromLockEntry(entry) {
1148
+ // The lock-entry prefixes: `spec` is the parent-spec type token (the legacy
1149
+ // `prd` token is GONE after the hard cutover), so a `spec-<slug>` lock entry
1150
+ // round-trips back to its namespaced `spec:<slug>` form (the inverse of
1151
+ // `lockEntryFor('spec:<slug>')`).
1152
+ for (const prefix of ['task', 'spec', 'observation']) {
1153
+ const tag = `${prefix}-`;
1154
+ if (entry.startsWith(tag)) {
1155
+ return `${prefix}:${entry.slice(tag.length)}`;
1156
+ }
1157
+ }
1158
+ return entry;
1159
+ }
1160
+ /** True iff `<arbiter>/main` shows the item TERMINAL — any of
1161
+ * {@link terminalMainPaths} present in `<arbiter>/main`'s tree. */
1162
+ async function isTerminalOnMain(type, slug, arbiter, cwd, env) {
1163
+ for (const path of terminalMainPaths(type, slug)) {
1164
+ const exists = (await gitSoft(['cat-file', '-e', `${arbiter}/main:${path}`], cwd, env))
1165
+ .status === 0;
1166
+ if (exists) {
1167
+ return true;
1168
+ }
1169
+ }
1170
+ return false;
1171
+ }
1172
+ /** List the entries (`<type>-<slug>`) currently locked on the arbiter (the
1173
+ * stuck-lock report / `status` read path). */
1174
+ export async function listItemLocks(cwd, arbiter = 'origin', env) {
1175
+ await gitHard(['fetch', '--quiet', arbiter, `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`], cwd, env);
1176
+ const out = await gitSoft(['for-each-ref', '--format=%(refname)', `${LOCK_REF_PREFIX}/*`], cwd, env);
1177
+ if (out.status !== 0) {
1178
+ return [];
1179
+ }
1180
+ return out.stdout
1181
+ .split('\n')
1182
+ .map((l) => l.trim())
1183
+ .filter((l) => l.startsWith(`${LOCK_REF_PREFIX}/`))
1184
+ .map((l) => l.slice(`${LOCK_REF_PREFIX}/`.length))
1185
+ .sort();
1186
+ }
1187
+ /**
1188
+ * Read the FULL lock entries currently held on the arbiter — the `status`/`scan`
1189
+ * in-flight read path (prd `ledger-status-per-item-lock-refs` US #8; task
1190
+ * `needs-attention-as-stuck-lock-state`). One fetch of the lock refs, then read
1191
+ * each held entry's `lock.md` blob, returning the parsed {@link LockEntry} for
1192
+ * every ref (so a caller can surface `active` (in-progress) and `stuck`
1193
+ * (needs-attention) holds + their reasons WITHOUT N fetches). Sorted by `entry`.
1194
+ * Best-effort: a fetch/read fault yields an EMPTY list, so the read-only
1195
+ * `status`/`scan` views degrade to "no in-flight locks" rather than erroring
1196
+ * (parity with {@link heldTaskSlugs}).
1197
+ */
1198
+ export async function listItemLockEntries(cwd, arbiter = 'origin', env) {
1199
+ try {
1200
+ await gitHard([
1201
+ 'fetch',
1202
+ '--quiet',
1203
+ arbiter,
1204
+ `+${LOCK_REF_PREFIX}/*:${LOCK_REF_PREFIX}/*`,
1205
+ ], cwd, env);
1206
+ const out = await gitSoft(['for-each-ref', '--format=%(refname)', `${LOCK_REF_PREFIX}/*`], cwd, env);
1207
+ if (out.status !== 0) {
1208
+ return [];
1209
+ }
1210
+ const refs = out.stdout
1211
+ .split('\n')
1212
+ .map((l) => l.trim())
1213
+ .filter((l) => l.startsWith(`${LOCK_REF_PREFIX}/`))
1214
+ .sort();
1215
+ const entries = [];
1216
+ for (const ref of refs) {
1217
+ const show = await gitSoft(['show', `${ref}:lock.md`], cwd, env);
1218
+ if (show.status !== 0) {
1219
+ continue;
1220
+ }
1221
+ const lock = parseLockEntry(show.stdout);
1222
+ if (lock) {
1223
+ entries.push(lock);
1224
+ }
1225
+ }
1226
+ return entries;
1227
+ }
1228
+ catch {
1229
+ return [];
1230
+ }
1231
+ }
1232
+ /**
1233
+ * List the TASK slugs currently lock-held on the arbiter — the held-slug set the
1234
+ * `ready/` pool readers SUBTRACT (prd `ledger-status-per-item-lock-refs` US #15;
1235
+ * task `claim-acquires-unified-lock-no-body-move`). Enumerates {@link listItemLocks}
1236
+ * and keeps only the `task-<slug>` entries (a prd/observation lock does not gate
1237
+ * the TASK pool), mapping each to its bare `<slug>`.
1238
+ *
1239
+ * LOAD-BEARING since the lock cut-over: the claim NO LONGER moves the body to
1240
+ * `in-progress/` (it stays in the pool on `main`; the held lock IS the claim), so
1241
+ * this held-slug set is the ONLY signal that keeps a claimed / in-flight item out
1242
+ * of the eligible pool, NOT the redundant subtraction it was while the body-move
1243
+ * still removed claimed items.
1244
+ *
1245
+ * GRACEFUL (fail-OPEN) by design — this is the SURFACE reader: a fetch fault
1246
+ * yields an EMPTY set so the read-only `status`/`scan` views degrade to "no
1247
+ * in-flight locks" rather than erroring. That is WRONG for SELECTION (an empty set
1248
+ * on a read FAULT lets a continuously-held in-flight item leak back into the
1249
+ * eligible pool → re-claimed → empty diff → spurious `stuck`). The SELECTION path
1250
+ * must instead use {@link heldTaskSlugsStrict}, which THROWS on a read fault so
1251
+ * the caller can fail CLOSED (refuse to enumerate an untrusted pool) rather than
1252
+ * subtract nothing. This graceful variant delegates to the strict one and only
1253
+ * swallows the fault.
1254
+ */
1255
+ export async function heldTaskSlugs(cwd, arbiter = 'origin', env) {
1256
+ try {
1257
+ return await heldTaskSlugsStrict(cwd, arbiter, env);
1258
+ }
1259
+ catch {
1260
+ return new Set();
1261
+ }
1262
+ }
1263
+ /**
1264
+ * STRICT (fail-CLOSED) twin of {@link heldTaskSlugs} for the SELECTION path: read
1265
+ * the held TASK slugs from the arbiter and THROW on any read fault (offline / dead
1266
+ * arbiter / unreadable lock refs) instead of degrading to an empty set. The
1267
+ * held-lock set lives ONLY on the arbiter and is the LOAD-BEARING signal that
1268
+ * keeps a claimed / in-flight item out of the eligible pool, so a SELECTION read
1269
+ * that cannot reach the arbiter must NOT pretend "no locks held" — it must fail so
1270
+ * the caller refuses to enumerate a pool it cannot trust (the
1271
+ * `scan-cwd-selection-pool-read-local-skips-held-lock-subtraction-offline-must-fail`
1272
+ * decision: offline selection FAILS, there is no `--local` fallback). The
1273
+ * read-only surface keeps the graceful {@link heldTaskSlugs}.
1274
+ */
1275
+ export async function heldTaskSlugsStrict(cwd, arbiter = 'origin', env) {
1276
+ // `listItemLocks` fetches the lock refs with `gitHard` (throws on a failed
1277
+ // fetch) and returns the held entries; we do NOT swallow that throw here.
1278
+ const entries = await listItemLocks(cwd, arbiter, env);
1279
+ const prefix = 'task-';
1280
+ return new Set(entries
1281
+ .filter((e) => e.startsWith(prefix))
1282
+ .map((e) => e.slice(prefix.length)));
1283
+ }
1284
+ /**
1285
+ * Parse a serialised lock entry body back into a {@link LockEntry} — the exact
1286
+ * inverse of {@link serialiseLockEntry}. Reads the two-axis state from the
1287
+ * frontmatter and, for a stuck entry, the FULL reason prose + any questions from
1288
+ * the body (`## Reason` / `## Questions`). Tolerates a LEGACY entry whose reason
1289
+ * lived in a one-line frontmatter `reason:` field (the pre-cutover shape) so a
1290
+ * lock written by an older binary still reads.
1291
+ */
1292
+ export function parseLockEntry(body) {
1293
+ const normalized = body.replace(/\r\n/g, '\n');
1294
+ const fm = /^---\n([\s\S]*?)\n---/.exec(normalized);
1295
+ if (!fm) {
1296
+ return undefined;
1297
+ }
1298
+ const fields = {};
1299
+ for (const line of fm[1].split('\n')) {
1300
+ const m = /^([a-zA-Z]+):\s*(.*)$/.exec(line);
1301
+ if (m) {
1302
+ fields[m[1]] = m[2];
1303
+ }
1304
+ }
1305
+ if (!fields.entry || !fields.action || !fields.state) {
1306
+ return undefined;
1307
+ }
1308
+ const bodyText = normalized.slice(fm[0].length);
1309
+ const reason = extractBodyReason(bodyText) ?? fields.reason;
1310
+ const questions = extractBodyQuestions(bodyText);
1311
+ const entry = {
1312
+ entry: fields.entry,
1313
+ action: fields.action,
1314
+ state: fields.state,
1315
+ holder: fields.holder ?? '',
1316
+ since: fields.since ?? '',
1317
+ };
1318
+ if (reason !== undefined && reason !== '') {
1319
+ entry.reason = reason;
1320
+ }
1321
+ if (questions.length > 0) {
1322
+ entry.questions = questions;
1323
+ }
1324
+ return entry;
1325
+ }
1326
+ /** Extract the `## Reason` block's prose (joined multi-line), or undefined. */
1327
+ function extractBodyReason(bodyText) {
1328
+ const lines = bodyText.split('\n');
1329
+ const start = lines.findIndex((l) => l.trim() === LOCK_REASON_HEADING);
1330
+ if (start === -1) {
1331
+ return undefined;
1332
+ }
1333
+ const collected = [];
1334
+ for (let i = start + 1; i < lines.length; i++) {
1335
+ if (/^##\s/.test(lines[i])) {
1336
+ break;
1337
+ }
1338
+ collected.push(lines[i]);
1339
+ }
1340
+ // Trim leading/trailing blank lines but PRESERVE interior newlines (rich prose).
1341
+ const text = collected.join('\n').replace(/^\n+/, '').replace(/\n+$/, '');
1342
+ return text === '' ? undefined : text;
1343
+ }
1344
+ /** Extract the `## Questions` block's bulleted list, or [] when absent. */
1345
+ function extractBodyQuestions(bodyText) {
1346
+ const lines = bodyText.split('\n');
1347
+ const start = lines.findIndex((l) => l.trim() === LOCK_QUESTIONS_HEADING);
1348
+ if (start === -1) {
1349
+ return [];
1350
+ }
1351
+ const questions = [];
1352
+ for (let i = start + 1; i < lines.length; i++) {
1353
+ if (/^##\s/.test(lines[i])) {
1354
+ break;
1355
+ }
1356
+ const m = /^-\s+(.*)$/.exec(lines[i].trim());
1357
+ if (m) {
1358
+ questions.push(m[1]);
1359
+ }
1360
+ }
1361
+ return questions;
1362
+ }
1363
+ /**
1364
+ * Advisory holder id: git user.name, else $USER, else a uuid fragment. Exported
1365
+ * as {@link resolveLockHolder} so callers (e.g. claim's stale-lock self-heal) can
1366
+ * resolve THE SAME holder string the lock would stamp, to compare against a held
1367
+ * entry's `holder` without duplicating the resolution order.
1368
+ */
1369
+ export async function resolveLockHolder(cwd, env) {
1370
+ return resolveHolder(cwd, env);
1371
+ }
1372
+ /** Advisory holder id: git user.name, else $USER, else a uuid fragment. */
1373
+ async function resolveHolder(cwd, env) {
1374
+ const name = await gitSoft(['config', 'user.name'], cwd, env);
1375
+ if (name.status === 0 && name.stdout.trim() !== '') {
1376
+ return name.stdout.trim();
1377
+ }
1378
+ const e = env ?? process.env;
1379
+ return e.USER ?? e.USERNAME ?? randomUUID().slice(0, 8);
1380
+ }
1381
+ //# sourceMappingURL=item-lock.js.map