tumwater 0.2.0 → 0.3.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 (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,24 +1,25 @@
1
- // vocabulary: plain
2
1
  /** The worktree pool's lease machinery (plans/worktree-pool.md, "Leases"; part 2a/5): a fixed set
3
- * of `.tumwater/worktrees/_slot-<n>` checkouts that ticks and verifies lease and switch commits in
2
+ * of `.tumwater/worktrees/_slot-<n>` checkouts that runs and verifies lease and switch commits in
4
3
  * place, instead of keeping one `_land-<role>` checkout per role. The persisted layout and its
5
4
  * cross-process lock live in slots-state.ts, which other processes (`tumwater diff`, `retire`) also
6
5
  * read; this module owns choosing a slot, holding it through the disk-floor registry, and handing
7
- * it back. Nothing leases a slot yet — part 4/5 wires the loops to it.
6
+ * it back. Since part 4/5 landed, a role's run (runner-run.ts) and a verify (merge-batch.ts's
7
+ * verifyRequest) both acquire one through leaseSlot.
8
8
  *
9
9
  * The lease decision is synchronous: `updateSlotsState` writes slots.json under the slots lock, and
10
- * one Node process runs every tick and merge, so two concurrent `leaseSlot` calls claim distinct
10
+ * one Node process runs every run and merge, so two concurrent `leaseSlot` calls claim distinct
11
11
  * free slots and a call with none free registers an in-process waiter. A release wakes the waiters,
12
12
  * which re-decide; `signal` aborts a waiter. */
13
13
  import path from "node:path";
14
14
  import { loadConfig, slotCount } from "../config/config.js";
15
15
  import { worktreesDir, slotWorktreePath } from "../paths.js";
16
- import { loadLoopState } from "../loop/loop-state.js";
16
+ import { loadRunnerState } from "../runner/runner-state-load.js";
17
17
  import { DIRECTOR_ROLE } from "../roles/roles.js";
18
18
  import { readSlotsState, slotForDir, updateSlotsState } from "./slots-state.js";
19
+ import { salvageSlotLeftovers } from "./slot-salvage.js";
19
20
  import { beginWorktreeUse } from "./worktree-use.js";
20
21
  import { ensureDetachedWorktree, removeWorktreeDir } from "./worktree.js";
21
- import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
22
+ import { logEventBestEffort, warnEventBestEffort, tryOrWarn, TUMWATER_SOURCE } from "../events/events.js";
22
23
  import { errorMessage } from "../text/text.js";
23
24
  import { subdirectoryNames } from "../files/files.js";
24
25
  import { dropProviderLeaseKey } from "../concurrency/provider-lease.js";
@@ -84,11 +85,16 @@ function raceAbort(wait, signal) {
84
85
  });
85
86
  }
86
87
  /** Clear leases owned by any process other than this one: their runs are dead (design "Leases").
87
- * Pins are kept, and the freed slots become candidates for the current claim. */
88
+ * Pins are kept, and the freed slots become candidates for the current claim. A dead lease never
89
+ * released, so its role is recorded as the slot's last holder here, and a dead run as unsettled:
90
+ * whatever it left in the slot is that role's unfinished work (slot-salvage.ts). */
88
91
  function clearDeadLeases(state, pid) {
89
92
  for (const slot of state.slots) {
90
- if (slot.lease !== null && slot.lease.pid !== pid)
91
- slot.lease = null;
93
+ if (slot.lease === null || slot.lease.pid === pid)
94
+ continue;
95
+ slot.lastRole = slot.lease.role;
96
+ slot.unsettledRun = slot.lease.purpose === "run" ? slot.lease.role : null;
97
+ slot.lease = null;
92
98
  }
93
99
  }
94
100
  function claim(slot, role, purpose, now, pid) {
@@ -107,7 +113,9 @@ function mostRecentlyReleased(slots) {
107
113
  function chooseSlot(root, state, role, purpose, count, now, pid) {
108
114
  clearDeadLeases(state, pid);
109
115
  const free = state.slots.filter((slot) => slot.lease === null);
110
- const eligible = free.filter((slot) => slot.pinnedFor === null || slot.pinnedFor === role);
116
+ // A check slot is a scratch checkout: it never takes a slot pinned for any role, the leasing
117
+ // role's own included (that pin holds the author's resumable work).
118
+ const eligible = free.filter((slot) => purpose === "verify-check" ? slot.pinnedFor === null : slot.pinnedFor === null || slot.pinnedFor === role);
111
119
  const pinned = eligible.find((slot) => slot.pinnedFor === role);
112
120
  if (pinned !== undefined)
113
121
  return claim(pinned, role, purpose, now, pid);
@@ -159,15 +167,23 @@ function logSlotWait(root, role, purpose, waitedMs) {
159
167
  }
160
168
  function tryClaim(root, role, purpose, count) {
161
169
  let chosen = null;
170
+ const key = rootKey(root);
171
+ const unsaved = unsavedReleases.get(key);
162
172
  updateSlotsState(root, (state) => {
173
+ for (const slot of state.slots) {
174
+ if (unsaved?.has(slot.dir) && slot.lease?.pid === process.pid)
175
+ slot.lease = null;
176
+ }
163
177
  chosen = chooseSlot(root, state, role, purpose, count, Date.now(), process.pid);
164
178
  });
179
+ // The write landed, so the reclaim of every unsaved release is on disk.
180
+ unsavedReleases.delete(key);
165
181
  return chosen;
166
182
  }
167
183
  async function acquire(root, role, purpose, signal, slotBudget) {
168
184
  // The caller's live budget when given; else liveCount's tolerance — a broken or missing
169
185
  // tumwater.json degrades to the default budget rather than erroring the lease with a raw
170
- // load failure (2026-10-08: a mid-run broken config errored every tick via this read even
186
+ // load failure (2026-10-08: a mid-run broken config errored every run via this read even
171
187
  // while the orchestrator held last-known-good).
172
188
  const count = slotBudget ?? liveCount(root);
173
189
  const startedAt = Date.now();
@@ -181,7 +197,7 @@ async function acquire(root, role, purpose, signal, slotBudget) {
181
197
  }
182
198
  // The pool-slot wait rule (plans/provider-concurrency.md §5 "No deadlock"): a lease never holds
183
199
  // a provider key while it waits for a pool slot. Drop the calling lease's key (which never
184
- // waits) before parking here, so this tick or verify holds no provider slot whose in-use a
200
+ // waits) before parking here, so this run or verify holds no provider slot whose in-use a
185
201
  // re-key waiter is waiting to drain; the next run re-takes the key as an ordinary re-key.
186
202
  await dropProviderLeaseKey();
187
203
  const waiter = registerWaiter(root);
@@ -194,6 +210,15 @@ async function acquire(root, role, purpose, signal, slotBudget) {
194
210
  }
195
211
  }
196
212
  }
213
+ /** The pool's live state for observers (status, TUI, dashboard): slots leased by this process out
214
+ * of the configured budget, and the leases waiting for one. A lease held by another pid is dead
215
+ * (see `SlotLease`), so it is not counted. `wakeWaiters` empties the waiter set before woken
216
+ * waiters re-register, so `waiting` can read low for one turn; observers poll, so the next poll
217
+ * corrects it. */
218
+ export function slotPoolSnapshot(root) {
219
+ const leased = readSlotsState(root).slots.filter((s) => s.lease !== null && s.lease.pid === process.pid).length;
220
+ return { leased, limit: liveCount(root), waiting: waiters.get(rootKey(root))?.size ?? 0 };
221
+ }
197
222
  /** The live slot budget, or an unbounded one when the config is unreadable: an invalid config
198
223
  * must not stop a held slot from being freed. */
199
224
  function liveCount(root) {
@@ -210,12 +235,33 @@ function liveCount(root) {
210
235
  function isCanonicalSlotDir(dir) {
211
236
  return /^_slot-\d+$/.test(path.basename(dir));
212
237
  }
213
- /** Free `dir` in slots.json, record `lastRole`/`lastReleasedAt`, and return the idle unpinned
214
- * slot dirs to remove because the live slot budget shrank below them (oldest-released first).
215
- * `pin` keeps the slot pinned for `role`; otherwise a pin this role held is cleared. A
216
- * non-canonical (migrated legacy) slot is dropped the moment it is unpinned, so the one resume
217
- * it was kept for runs in it and the dir is removed on that first release. */
218
- function releaseSlot(root, dir, role, count, pin) {
238
+ /** Slot dirs per root whose release write to slots.json failed in this process; the next claim
239
+ * clears their stale lease. */
240
+ const unsavedReleases = new Map();
241
+ /** `releaseSlot` that never throws: a failed slots.json write (ENOSPC, EACCES, a lock timeout)
242
+ * warns and remembers the slot so the next `tryClaim` here reclaims it, instead of masking the
243
+ * caller's own outcome and leaving the slot leased by this live pid. The pin and `lastRole` the
244
+ * failed release would have recorded are not replayed; a lost pin only means the next resume
245
+ * gets a fresh checkout. */
246
+ function freeSlot(root, dir, role, count, pin, unsettledRun = null) {
247
+ try {
248
+ return releaseSlot(root, dir, role, count, pin, unsettledRun);
249
+ }
250
+ catch (err) {
251
+ const key = rootKey(root);
252
+ const dirs = unsavedReleases.get(key) ?? new Set();
253
+ dirs.add(dir);
254
+ unsavedReleases.set(key, dirs);
255
+ warnEventBestEffort(root, TUMWATER_SOURCE, `could not free slot ${path.basename(dir)}: ${errorMessage(err)}; the next lease reclaims it`);
256
+ return [];
257
+ }
258
+ }
259
+ /** Free `dir` in slots.json, record `lastRole`/`lastReleasedAt`/`unsettledRun`, and return the
260
+ * idle unpinned slot dirs to remove because the live slot budget shrank below them
261
+ * (oldest-released first). `pin` keeps the slot pinned for `role`; otherwise a pin this role held
262
+ * is cleared. A non-canonical (migrated legacy) slot is dropped the moment it is unpinned, so the
263
+ * one resume it was kept for runs in it and the dir is removed on that first release. */
264
+ function releaseSlot(root, dir, role, count, pin, unsettledRun) {
219
265
  const toRemove = [];
220
266
  updateSlotsState(root, (state) => {
221
267
  const slot = state.slots.find((s) => s.dir === dir);
@@ -223,6 +269,7 @@ function releaseSlot(root, dir, role, count, pin) {
223
269
  slot.lease = null;
224
270
  slot.lastRole = role;
225
271
  slot.lastReleasedAt = Date.now();
272
+ slot.unsettledRun = unsettledRun;
226
273
  if (pin) {
227
274
  if (slot.pinnedFor !== role)
228
275
  slot.pinnedAt = Date.now();
@@ -270,15 +317,15 @@ export async function removeDroppedSlots(root, dirs, remove = removeWorktreeDir)
270
317
  }
271
318
  }
272
319
  /** One-time startup migration (plans/worktree-pool.md "Legacy role worktrees", part 4c/5):
273
- * retire the pre-pool `.tumwater/worktrees/<role>` checkouts now that ticks lease pooled slots.
320
+ * retire the pre-pool `.tumwater/worktrees/<role>` checkouts now that runs lease pooled slots.
274
321
  * A resumable enabled role keeps its legacy dir as a pinned slot so its pending resume runs in
275
322
  * the same cwd; every other legacy dir is removed (its branch keeps any commit), and pins for
276
- * roles outside `configured` are cleared. `configured` is every loop id the config's roles
277
- * enable (`loopIds`), never a `--once --role` filter: a scoped run must leave the other roles'
323
+ * roles outside `configured` are cleared. `configured` is every role id the config's roles
324
+ * enable (`runnerIds`), never a `--once --role` filter: a scoped run must leave the other roles'
278
325
  * legacy dirs and pins alone, or it silently loses their pending resumes. It runs after
279
326
  * `runOrchestrator` constructs every
280
- * `LoopRunner` but before any tick can call `leaseSlot`: the constructor infers `resumePending`
281
- * from the on-disk `running` flag only in memory (it does not save it), so `loadLoopState` here
327
+ * `RoleRunner` but before any run can call `leaseSlot`: the constructor infers `resumePending`
328
+ * from the on-disk `running` flag only in memory (it does not save it), so `loadRunnerState` here
282
329
  * still reports the on-disk flags. *Removing* a dir is best-effort and logged: the slot records
283
330
  * are already updated, so a leaked directory is only a leak the next prune collects. */
284
331
  export async function retireLegacyRoleWorktrees(root, configured) {
@@ -291,7 +338,7 @@ export async function retireLegacyRoleWorktrees(root, configured) {
291
338
  updateSlotsState(root, (state) => {
292
339
  for (const role of names) {
293
340
  const legacyDir = path.join(dir, role);
294
- const legacy = loadLoopState(root, role);
341
+ const legacy = loadRunnerState(root, role);
295
342
  const resumable = legacy.resumePending === true || legacy.running === true;
296
343
  const ownRecord = state.slots.find((slot) => slot.dir === legacyDir);
297
344
  if (resumable && configuredSet.has(role)) {
@@ -354,35 +401,81 @@ export async function retireLegacyRoleWorktrees(root, configured) {
354
401
  }
355
402
  }
356
403
  }
404
+ /** Startup pass for runs the previous process died in (crash, kill -9, power loss): such a run's
405
+ * lease is still in slots.json with the dead pid, and its RoleRunner reads the persisted
406
+ * `running` flag as a resume that must run in the same checkout, the cwd its session used. Pin
407
+ * each such slot for its role, as the graceful shutdown's abort pin would have, unless the role
408
+ * already holds a pin; the next claim then clears the dead lease (marking the run unsettled) and
409
+ * the resume leases the pinned slot with its edits in place. Only configured roles count, as in
410
+ * retireLegacyRoleWorktrees, so a removed role never leaks a pin. Like that pass, it runs before
411
+ * any run can lease, while loadRunnerState still reports the on-disk flags. */
412
+ export function pinCrashedRuns(root, configured) {
413
+ const configuredSet = new Set(configured);
414
+ updateSlotsState(root, (state) => {
415
+ for (const slot of state.slots) {
416
+ const lease = slot.lease;
417
+ if (lease === null || lease.pid === process.pid || lease.purpose !== "run")
418
+ continue;
419
+ if (!configuredSet.has(lease.role) || state.slots.some((s) => s.pinnedFor === lease.role))
420
+ continue;
421
+ const runner = loadRunnerState(root, lease.role);
422
+ if (runner.running !== true && runner.resumePending !== true)
423
+ continue;
424
+ slot.pinnedFor = lease.role;
425
+ slot.pinnedAt = Date.now();
426
+ }
427
+ });
428
+ }
357
429
  /** Lease a pooled slot for `role`, prepared at `ref`. Resolves once a slot is held and reset to
358
- * `ref`; rejects on a `signal` abort while waiting. The returned `release` is idempotent. */
430
+ * `ref`, any uncommitted edits its last holder left saved first (salvageSlotLeftovers), as work
431
+ * to hand back when that holder was a run that never settled them; rejects on a `signal` abort
432
+ * while waiting. The returned `release` is idempotent. */
359
433
  export async function leaseSlot(root, options) {
360
- const { role, purpose, ref, signal, keep, slotBudget } = options;
361
- const dir = await acquire(root, role, purpose, signal, slotBudget);
362
- const preserved = keep === true && slotForDir(root, dir)?.pinnedFor === role;
434
+ const dir = await acquire(root, options.role, options.purpose, options.signal, options.slotBudget);
435
+ return prepareLeased(root, dir, options);
436
+ }
437
+ /** Lease a pooled slot only when one is free right now: resolves null instead of waiting, so a
438
+ * caller that can do without a second checkout (a verify's check slot) never queues behind
439
+ * the pool. Otherwise as leaseSlot. */
440
+ export async function tryLeaseSlot(root, options) {
441
+ const dir = tryClaim(root, options.role, options.purpose, options.slotBudget ?? liveCount(root));
442
+ return dir === null ? null : prepareLeased(root, dir, options);
443
+ }
444
+ /** Prepare the slot `dir` this process just claimed at `options.ref` and hand back its handle. */
445
+ async function prepareLeased(root, dir, options) {
446
+ const { role, purpose, ref, keep } = options;
447
+ const record = slotForDir(root, dir);
448
+ // A run that gave the slot up unsettled is the one its leftovers belong to, even when a
449
+ // failed prepare has since recorded another role as the last holder.
450
+ const unsettledRun = record?.unsettledRun ?? null;
451
+ const preserved = keep === true && record?.pinnedFor === role;
363
452
  const useRelease = await beginWorktreeUse(root, dir);
364
453
  // The use registry is LRU bookkeeping; freeing the slot is the real work of a release, so a
365
454
  // failed registry write here (ENOSPC, a lock timeout) must not escape: the dead-lease reaper
366
455
  // only clears another process's lease, so the slot would stay leased by this live pid and the
367
456
  // pool would shrink by one every time. Warn so the failure stays visible, then free anyway.
368
457
  const releaseUse = () => {
369
- try {
370
- useRelease();
371
- }
372
- catch (err) {
373
- warnEventBestEffort(root, TUMWATER_SOURCE, `could not record use release for ${path.basename(dir)}: ${errorMessage(err)}`);
374
- }
458
+ tryOrWarn(root, TUMWATER_SOURCE, `record use release for ${path.basename(dir)}`, useRelease);
375
459
  };
376
460
  if (!preserved) {
377
461
  try {
462
+ // A run the harness could not clean up after may have left uncommitted edits here; save
463
+ // them before the reset discards them, for that role's next run when it left them
464
+ // unsettled (slot-salvage.ts).
465
+ await salvageSlotLeftovers(root, dir, {
466
+ leftBy: unsettledRun ?? record?.lastRole ?? null,
467
+ recoverFor: unsettledRun,
468
+ forRole: role,
469
+ });
378
470
  await ensureDetachedWorktree(root, dir, ref);
379
471
  }
380
472
  catch (err) {
381
473
  releaseUse();
382
474
  // Free the claim without shrinking. releaseSlot retires a migrated legacy slot on this
383
475
  // unpinned release, so hand its removal to removeDroppedSlots; a canonical slot survives
384
- // for reuse on the next lease.
385
- const toRemove = releaseSlot(root, dir, role, Number.MAX_SAFE_INTEGER, false);
476
+ // for reuse on the next lease. This lease never used the slot, so an unsettled run's
477
+ // claim on what is left there stands.
478
+ const toRemove = freeSlot(root, dir, role, Number.MAX_SAFE_INTEGER, false, unsettledRun);
386
479
  wakeWaiters(root);
387
480
  void removeDroppedSlots(root, toRemove);
388
481
  throw err;
@@ -392,12 +485,13 @@ export async function leaseSlot(root, options) {
392
485
  return {
393
486
  dir,
394
487
  preserved,
395
- release: (options) => {
488
+ release: (how) => {
396
489
  if (released)
397
490
  return;
398
491
  released = true;
399
492
  releaseUse();
400
- const toRemove = releaseSlot(root, dir, role, liveCount(root), options?.pin === true);
493
+ const unsettled = how?.unsettled === true && purpose === "run" ? role : null;
494
+ const toRemove = freeSlot(root, dir, role, liveCount(root), how?.pin === true, unsettled);
401
495
  wakeWaiters(root);
402
496
  // Best-effort: a slot whose directory is already gone is unregistered by the prune.
403
497
  void removeDroppedSlots(root, toRemove);
@@ -1,7 +1,6 @@
1
- // vocabulary: plain
2
1
  /** The worktree-use registry (plans/disk-floor.md, "Reclaiming build outputs", part 2/4): which
3
2
  * harness worktrees are in use right now, and when each was last used. The orchestrator is the one
4
- * process that runs ticks and merges, so a plain in-process map is enough for the "in use" half;
3
+ * process that launches runs and merges, so a plain in-process map is enough for the "in use" half;
5
4
  * the "last used" half is persisted to `.tumwater/state/worktree-use.json` so least-recently-used
6
5
  * reclaim ordering survives a restart.
7
6
  *
@@ -13,8 +12,7 @@ import path from "node:path";
13
12
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
14
13
  import { finiteNumber, isJsonObject } from "../files/json-object.js";
15
14
  import { withStateLock } from "../concurrency/lock.js";
16
- import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
17
- import { errorMessage } from "../text/text.js";
15
+ import { tryOrWarn, TUMWATER_SOURCE } from "../events/events.js";
18
16
  import { worktreeUseLockPath, worktreeUsePath } from "../paths.js";
19
17
  /** In-process state, keyed by the resolved directory so two roots' same-named worktrees never
20
18
  * collide. */
@@ -89,15 +87,10 @@ export function updateWorktreeUse(root, change) {
89
87
  * volume is full — is warned and dropped rather than thrown. Both release paths use it:
90
88
  * `beginWorktreeUse`'s release and `releaseReclaim`, the latter in a `finally` after a reclaim.
91
89
  * Their bookkeeping must not become the caller's failure — a throw there would mask a successful
92
- * tick, merge, or clean with a registry error. The in-process `uses`/`reclaiming` state is updated
90
+ * run, merge, or clean with a registry error. The in-process `uses`/`reclaiming` state is updated
93
91
  * before this runs, so only reclaim ordering degrades. */
94
92
  function updateWorktreeUseBestEffort(root, change) {
95
- try {
96
- updateWorktreeUse(root, change);
97
- }
98
- catch (err) {
99
- warnEventBestEffort(root, TUMWATER_SOURCE, `could not update the worktree-use registry: ${errorMessage(err)}`);
100
- }
93
+ tryOrWarn(root, TUMWATER_SOURCE, "update the worktree-use registry", () => updateWorktreeUse(root, change));
101
94
  }
102
95
  /** True while a `useWorktree` body holds this worktree. */
103
96
  export function isWorktreeInUse(dir) {
@@ -1,19 +1,21 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { GIT_TIMEOUT_MS, git, gitTry } from "./git-run.js";
4
- import { branchExists, hasWorktreeRegistrations, isGitRepo, resolveGitDir } from "./git.js";
5
- import { pruneOldDirectory, removeTree, subdirectoryNames } from "../files/files.js";
4
+ import { branchExists, commitExists, deleteRef, hasWorktreeRegistrations, isGitRepo, refShaFromFiles, refsUnder, resolveGitDir } from "./git.js";
5
+ import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
6
+ import { isDirectory, pruneOldDirectory, removeTree, subdirectoryNames } from "../files/files.js";
6
7
  import { KILL_GRACE_MS } from "../process/process-group.js";
7
8
  import { branchName, worktreePath, worktreesDir } from "../paths.js";
8
- /** Persistent-worktree lifecycle for the harness: role worktrees (one per loop, reset to main
9
- * on every fresh tick) and the detached mirror worktree redeploy.ts verifies and compiles.
9
+ import { shortSha } from "../text/format.js";
10
+ /** Persistent-worktree lifecycle for the harness: role worktrees (one per role, reset to main
11
+ * on every fresh run) and the detached mirror worktree redeploy.ts verifies and compiles.
10
12
  * The generic git plumbing these build on lives in git/git.ts; this module owns creating,
11
13
  * self-healing, resetting, and abort-syncing those worktrees. */
12
14
  /** True when `dir` exists and is still a usable git worktree — its .git pointer file present
13
15
  * and resolvable via rev-parse. The shared usability probe of both ensure* functions below;
14
16
  * a directory that fails it (pointer lost, or admin-side registration under <root>/.git/
15
17
  * worktrees/ pruned by outside git maintenance) is rebuilt from scratch instead of failing
16
- * every tick. Also exported for read-only viewers that must classify a worktree as absent
18
+ * every run. Also exported for read-only viewers that must classify a worktree as absent
17
19
  * rather than fail (change/change-data.ts's collectRoleChange), so the probe's exact
18
20
  * conditions live in one place. */
19
21
  export async function isUsableWorktree(dir) {
@@ -30,7 +32,7 @@ async function pruneWorktrees(root) {
30
32
  /** Clear the way for `worktree add` at `dir`: prune stale registrations first (a registration
31
33
  * whose directory was deleted still blocks the add), then remove a leftover unusable directory
32
34
  * and prune again so no registration points at it. The removed content is harness scratch only
33
- * — role worktrees are reset to main on every fresh tick, and branch commits survive in
35
+ * — role worktrees are reset to main on every fresh run, and branch commits survive in
34
36
  * refs/heads either way. Call only inside serializeSetup: a prune deletes any registration
35
37
  * another `worktree add` is still creating. */
36
38
  async function clearStaleWorktree(root, dir) {
@@ -75,8 +77,8 @@ async function serializeSetup(root, setup) {
75
77
  }
76
78
  /** Ensure a persistent worktree + branch exists for a role. Returns the worktree path. Self-heals
77
79
  * when the directory exists but is no longer a usable worktree: it removes and re-adds the
78
- * directory instead of failing every tick (see clearStaleWorktree). Since the worktree pool merged
79
- * (plans/worktree-pool.md, "Role ticks lease slots"), this serves the director only; every other
80
+ * directory instead of failing every run (see clearStaleWorktree). Since the worktree pool merged
81
+ * (plans/worktree-pool.md, "Role runs lease slots"), this serves the director only; every other
80
82
  * role leases a pooled `_slot-<n>` via leaseSlot instead. */
81
83
  export async function ensureWorktree(root, role, mainBranch) {
82
84
  const wt = worktreePath(root, role);
@@ -130,9 +132,9 @@ export async function removeWorktreeDir(root, dir) {
130
132
  export async function removeWorktree(root, role) {
131
133
  await removeWorktreeDir(root, worktreePath(root, role));
132
134
  }
133
- /** Remove the `_land-<role>` checkouts left by a pre-pool build: the pool's ticks keep their
135
+ /** Remove the `_land-<role>` checkouts left by a pre-pool build: the pool's runs keep their
134
136
  * commit on the role branch and a queued merge re-verifies from its pinned ref, so the checkout
135
- * holds nothing to keep. Called once at orchestrator startup, before any tick; each removal goes
137
+ * holds nothing to keep. Called once at orchestrator startup, before any run; each removal goes
136
138
  * through removeWorktreeDir (force `worktree remove`, with the shared dead-registration fallback),
137
139
  * then one prune registers the vanished checkouts. */
138
140
  export async function removeLegacyLandWorktrees(root) {
@@ -144,6 +146,22 @@ export async function removeLegacyLandWorktrees(root) {
144
146
  if (names.length > 0)
145
147
  await gitTry(root, "worktree", "prune");
146
148
  }
149
+ /** Delete every `refs/tumwater/` ref whose commit is missing, loose or packed, logging a warning
150
+ * for each, and return the dropped names in refsUnder order: a dangling pin makes `git gc` exit
151
+ * 128 until someone deletes it. Uses the unguarded deleteRef, as checkoutDetached does, so
152
+ * the delete does not depend on the sha read. Runs at team start, before any run, while nothing
153
+ * else writes pins; refs outside `refs/tumwater/` are never touched. */
154
+ export async function dropDanglingTumwaterPins(root) {
155
+ const dropped = [];
156
+ for (const { ref, sha } of await refsUnder(root, "refs/tumwater/")) {
157
+ if (await commitExists(root, sha))
158
+ continue;
159
+ logDroppedRef(root, ref, "at team start");
160
+ await deleteRef(root, ref);
161
+ dropped.push(ref);
162
+ }
163
+ return dropped;
164
+ }
147
165
  /** Cheap file-existence shape probe for removeWorktreeDir: resolveGitDir needs the `.git` pointer,
148
166
  * and probing it for a directory that does not exist would only produce a caught throw — the
149
167
  * existence check keeps the common absent case allocation-free. Not a usability check; that
@@ -156,7 +174,7 @@ function isUsableWorktreeSyncPath(dir) {
156
174
  * exactly `ref`'s tree. A stale index.lock left by a killed git is cleared first, or the
157
175
  * checkout/reset below would fail on it forever. Used by redeploy.ts as the pristine copy of main
158
176
  * it verifies and compiles — the primary checkout may be dirty or on another branch, a role
159
- * worktree is never pristine while its loop works. */
177
+ * worktree is never pristine while its role works. */
160
178
  export async function ensureDetachedWorktree(root, dir, ref) {
161
179
  if (!(await isUsableWorktree(dir))) {
162
180
  const created = await serializeSetup(root, async () => {
@@ -169,7 +187,7 @@ export async function ensureDetachedWorktree(root, dir, ref) {
169
187
  if (created)
170
188
  return dir;
171
189
  }
172
- await hardResetWorktree(dir, ref, true);
190
+ await hardResetWorktree(dir, ref, root);
173
191
  return dir;
174
192
  }
175
193
  /** True when no merge and no rebase is in progress in `wt`, checked from the state files
@@ -177,19 +195,14 @@ export async function ensureDetachedWorktree(root, dir, ref) {
177
195
  * rebase — instead of spawning two aborts that can only fail. Returns false ("run the
178
196
  * aborts anyway") whenever either is running OR the gitdir cannot be resolved from files:
179
197
  * false is always safe, it just means "do exactly what the old spawn-based check did".
180
- * Synchronous and microsecond-scale: this exists because abortSync runs on every fresh tick
198
+ * Synchronous and microsecond-scale: this exists because abortSync runs on every fresh run
181
199
  * and the common case used to cost two ~10ms subprocess spawns that were guaranteed no-ops. */
182
200
  function syncStateClear(wt) {
183
201
  const gitdir = resolveGitDir(wt);
184
202
  if (gitdir === undefined)
185
203
  return false; // Not a repo we can inspect — fall back to the spawns.
186
- try {
187
- if (!fs.statSync(gitdir).isDirectory())
188
- return false; // Pointer target gone: uncertain.
189
- }
190
- catch {
191
- return false;
192
- }
204
+ if (!isDirectory(gitdir))
205
+ return false; // Pointer target gone: uncertain.
193
206
  try {
194
207
  fs.statSync(path.join(gitdir, "MERGE_HEAD"));
195
208
  return false;
@@ -198,13 +211,8 @@ function syncStateClear(wt) {
198
211
  // No merge in progress — rebase state next.
199
212
  }
200
213
  for (const dir of ["rebase-merge", "rebase-apply"]) {
201
- try {
202
- if (fs.statSync(path.join(gitdir, dir)).isDirectory())
203
- return false;
204
- }
205
- catch {
206
- // This backend's rebase state is absent.
207
- }
214
+ if (isDirectory(path.join(gitdir, dir)))
215
+ return false;
208
216
  }
209
217
  return true;
210
218
  }
@@ -225,7 +233,7 @@ const STALE_GIT_LOCK_MS = GIT_TIMEOUT_MS + KILL_GRACE_MS;
225
233
  /** Remove a stale `index.lock` from `wt`'s gitdir. A git killed mid-write — our own group
226
234
  * deadline's SIGKILL after a SIGTERM it could not handle, a crashed harness, an operator kill —
227
235
  * leaves index.lock behind, and every later `git reset --hard` then fails "Unable to create
228
- * '…/index.lock': File exists" with no self-heal, wedging the loop on every tick. A *fresh*
236
+ * '…/index.lock': File exists" with no self-heal, wedging the role on every run. A *fresh*
229
237
  * lock is left alone: it may belong to a git still running, so this clears wreckage, never a
230
238
  * live writer. */
231
239
  function clearStaleIndexLock(wt) {
@@ -241,23 +249,72 @@ function clearStaleIndexLock(wt) {
241
249
  // No lock file (the common case), or an unreadable gitdir: nothing to clear.
242
250
  }
243
251
  }
252
+ /** `git checkout --detach --force ref`, tolerating a dangling tumwater pin. `--force` because every
253
+ * caller hard-resets to `ref` right after: without it, uncommitted edits a killed run left in the
254
+ * worktree failed the checkout ("Your local changes … would be overwritten") on every later reset,
255
+ * so a pooled slot holding them failed each role it was handed (2026-10-21). A pooled slot's
256
+ * edits are saved first (slot-salvage.ts); the harness's own scratch worktrees hold nothing to
257
+ * keep. Leaving a detached HEAD makes
258
+ * git walk every ref to report orphaned commits, so one `refs/tumwater/…` pin (merge, rejected,
259
+ * legacy landing) whose object is gone fails the checkout "fatal: bad object <ref>" (128) and
260
+ * errors the role's run. The pin is unusable either way, so the named ref is deleted and the
261
+ * checkout retried, after a `warning` event records its name, sha and file age as evidence; any
262
+ * other failure, or a bad ref outside refs/tumwater/, still throws. */
263
+ async function checkoutDetached(root, wt, ref) {
264
+ for (;;) {
265
+ try {
266
+ await git(wt, "checkout", "--detach", "--force", ref);
267
+ return;
268
+ }
269
+ catch (err) {
270
+ const dangling = /fatal: bad object (refs\/tumwater\/\S+)/.exec(String(err instanceof Error ? err.message : err));
271
+ if (dangling === null)
272
+ throw err;
273
+ const bad = dangling[1];
274
+ logDroppedRef(root, bad, "so a worktree checkout could proceed");
275
+ await git(wt, "update-ref", "-d", bad);
276
+ }
277
+ }
278
+ }
279
+ /** Record a dangling pin about to be deleted: its ref, the sha it named and, for a loose ref
280
+ * file, when it was last written (omitted when the ref was packed). */
281
+ function logDroppedRef(root, ref, why) {
282
+ const sha = refShaFromFiles(root, ref);
283
+ const gitDir = resolveGitDir(root);
284
+ let refWrittenAt;
285
+ try {
286
+ if (gitDir !== undefined)
287
+ refWrittenAt = new Date(fs.statSync(path.join(gitDir, ref)).mtimeMs).toISOString();
288
+ }
289
+ catch {
290
+ // Packed ref, or the file is already gone: no age to report.
291
+ }
292
+ logEventBestEffort(root, {
293
+ source: TUMWATER_SOURCE,
294
+ type: "warning",
295
+ message: `dropped dangling ref ${ref} (${sha ? shortSha(sha) : "unknown sha"}, object missing) ${why}`,
296
+ ref,
297
+ sha,
298
+ ...(refWrittenAt === undefined ? {} : { refWrittenAt }),
299
+ });
300
+ }
244
301
  /** Hard-reset a worktree to `ref`, self-healing the two wedges a killed git leaves behind: a
245
302
  * stale index.lock is cleared first (or the reset fails "Unable to create '…/index.lock'"
246
- * forever), and an interrupted merge or rebase is aborted (or the tick wedges on "you are
247
- * already rebasing"). With `detach`, the worktree is checked out detached at `ref` first —
248
- * ensureDetachedWorktree's repair path for a worktree left on a branch; resetWorktreeToMain
249
- * leaves its branch checked out. Untracked files are dropped (ignored files survive; the stale
250
- * `dist/` build dir is reaped by age). */
251
- async function hardResetWorktree(wt, ref, detach = false) {
303
+ * forever), and an interrupted merge or rebase is aborted (or the run wedges on "you are
304
+ * already rebasing"). With `detachRoot` (the repository root), the worktree is checked out
305
+ * detached at `ref` first — ensureDetachedWorktree's repair path for a worktree left on a
306
+ * branch; resetWorktreeToMain leaves its branch checked out. Untracked files are
307
+ * dropped (ignored files survive; the stale `dist/` build dir is reaped by age). */
308
+ async function hardResetWorktree(wt, ref, detachRoot) {
252
309
  clearStaleIndexLock(wt);
253
310
  await abortSync(wt);
254
- if (detach)
255
- await git(wt, "checkout", "--detach", ref);
311
+ if (detachRoot !== undefined)
312
+ await checkoutDetached(detachRoot, wt, ref);
256
313
  await git(wt, "reset", "--hard", ref);
257
314
  await git(wt, "clean", "-fd");
258
315
  pruneStaleBuildDir(wt);
259
316
  }
260
- /** Hard-reset a worktree's branch to main — the fresh-tick reset. */
317
+ /** Hard-reset a worktree's branch to main — the fresh-run reset. */
261
318
  export async function resetWorktreeToMain(wt, mainBranch) {
262
319
  await hardResetWorktree(wt, mainBranch);
263
320
  }