tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -38,38 +38,30 @@ export function readBranchHead(root, branch) {
38
38
  catch {
39
39
  return null; // Neither store has it: the branch does not exist (or no repo state).
40
40
  }
41
- const suffix = ` refs/heads/${branch}`;
42
- for (const line of packed.split("\n")) {
43
- if (!line.endsWith(suffix))
44
- continue; // Skips comments, peel lines, and other refs.
45
- const sha = line.slice(0, -suffix.length);
46
- if (isSha(sha))
47
- return sha;
48
- }
49
- return null;
41
+ return shaFromPackedRefs(packed, ` refs/heads/${branch}`) ?? null;
50
42
  }
51
43
  /** Resolve a branch's head without paying for a spawn when the ref files suffice:
52
44
  * readBranchHead (microsecond-scale) first, then `git rev-parse` as the fallback that covers
53
45
  * what file reads cannot (a worktree-pointer .git, anything unusual). Returns null when
54
46
  * neither resolves it. Shared by the orchestrator's per-poll main watch, each tick's
55
- * end-of-tick head update, the red-main baseline check, and the stack lander, so the
47
+ * end-of-tick head update, the red-main baseline check, and the stack merger, so the
56
48
  * file-first/spawn-fallback strategy lives in one place. */
57
49
  export async function branchHead(root, branch) {
58
50
  return readBranchHead(root, branch) ?? (await gitTry(root, "rev-parse", branch));
59
51
  }
60
52
  // ── Ref reads from files ───────────────────────────────────────────────────────────────────
61
53
  // The read-only probes below (isGitRepo, hasCommits, headOf, refSha, branchExists) each used to
62
- // spawn a `git rev-parse`. The harness asks them constantly: a landing alone re-reads HEAD a
63
- // dozen times, and the test suite, which process creation bounds (DEVELOPMENT.md), spawned
64
- // ~8,000 of them a run. Each probe now answers from the repository's files when the files
65
- // settle it POSITIVELY: a resolvable git dir, or a ref whose loose file or packed-refs line
66
- // names a SHA. Everything else spawns git exactly as before: a miss, a symref other than HEAD,
67
- // a name git's DWIM rules could read another way, a reftable repo, an environment that
68
- // redirects git (GIT_DIR and friends), or a repository another user owns, which git itself
69
- // refuses (safe.directory). A hit is the SHA `rev-parse --verify` prints. It resolves the name
70
- // and does not check that the object exists, and neither does this. The one negative answered
71
- // from files is a plain full `refs/…` name with no loose file and no packed-refs line
72
- // (refAbsentFromFiles): in a files-backend repository that is everywhere git looks.
54
+ // spawn a `git rev-parse`. The harness asks them constantly: a merge alone re-reads HEAD a dozen
55
+ // times, and the test suite, which process creation bounds (DEVELOPMENT.md), spawned ~8,000 of them
56
+ // a run. Each probe now answers from the repository's files when the files settle it POSITIVELY: a
57
+ // resolvable git dir, or a ref whose loose file or packed-refs line names a SHA. Everything else
58
+ // spawns git exactly as before: a miss, a symref other than HEAD, a name git's DWIM rules could
59
+ // read another way, a reftable repo, an environment that redirects git (GIT_DIR and friends), or a
60
+ // repository another user owns, which git itself refuses (safe.directory). A hit is the SHA
61
+ // `rev-parse --verify` prints. It resolves the name and does not check that the object exists, and
62
+ // neither does this. The one negative answered from files is a plain full `refs/…` name with no
63
+ // loose file and no packed-refs line (refAbsentFromFiles): in a files-backend repository that is
64
+ // everywhere git looks.
73
65
  /** Environment variables that make git look somewhere other than the files `dir` holds. While
74
66
  * any is set, every probe spawns git. */
75
67
  const GIT_REDIRECT_ENV = ["GIT_DIR", "GIT_COMMON_DIR", "GIT_WORK_TREE", "GIT_NAMESPACE"];
@@ -120,6 +112,24 @@ function isPlainRefName(ref) {
120
112
  return false;
121
113
  return ref.split("/").every((part) => part !== "" && !part.startsWith(".") && !part.endsWith(".lock"));
122
114
  }
115
+ /** The SHA a packed-refs line whose refname ends with `suffix` (e.g. " refs/heads/main")
116
+ * names, else null when no line matches. undefined when a line matches the refname but holds
117
+ * a non-SHA — the "the files say something git would read differently" signal storedRef's
118
+ * callers distinguish from a plain miss. Both packed-refs readers (readBranchHead and
119
+ * storedRef) scan the file this way: git writes every packed-refs line as ``<sha> <refname>``
120
+ * (or ``<sha> <refname>^{}`` peel lines), so skipping lines that do not end in the suffix and
121
+ * slicing that suffix off the first match is the whole format. */
122
+ function shaFromPackedRefs(packed, suffix) {
123
+ for (const line of packed.split("\n")) {
124
+ if (!line.endsWith(suffix))
125
+ continue; // Skips the header, comments, peel lines and other refs.
126
+ const sha = line.slice(0, -suffix.length);
127
+ if (isSha(sha))
128
+ return sha;
129
+ return undefined;
130
+ }
131
+ return null;
132
+ }
123
133
  /** The SHA a full ref name holds in `commondir`: its loose file (which wins in git too), else
124
134
  * its packed-refs line. null when neither has it, and undefined when the files hold something
125
135
  * git would read differently (a symref, unreadable content). */
@@ -139,14 +149,7 @@ function storedRef(commondir, ref) {
139
149
  catch (err) {
140
150
  return err.code === "ENOENT" ? null : undefined;
141
151
  }
142
- const suffix = ` ${ref}`;
143
- for (const line of packed.split("\n")) {
144
- if (!line.endsWith(suffix))
145
- continue; // Skips the header, peel lines and other refs.
146
- const sha = line.slice(0, -suffix.length);
147
- return isSha(sha) ? sha : undefined;
148
- }
149
- return null;
152
+ return shaFromPackedRefs(packed, ` ${ref}`);
150
153
  }
151
154
  /** The SHA `git rev-parse --verify <ref>` would print in `dir`, read from files, or undefined
152
155
  * when the files do not settle it (see the section comment): the caller then spawns git. `ref`
@@ -232,13 +235,24 @@ export async function hasCommits(root) {
232
235
  export async function headOf(cwd, ref) {
233
236
  return refShaFromFiles(cwd, ref) ?? git(cwd, "rev-parse", "--verify", ref);
234
237
  }
235
- /** Point `ref` at `sha`, creating the ref if needed (git update-ref). The landing pin of
238
+ /** Point `ref` at `sha`, creating the ref if needed (git update-ref). The merge pin of
236
239
  * plans/merge-queue.md invariant 4 uses this to keep a committed sha reachable across the role
237
- * branch's reset-to-main. Returns false instead of throwing when git fails — the caller then
238
- * keeps the commit on its branch (no reset) so leftover recovery can still reach it. */
240
+ * branch's reset-to-main. Returns false instead of throwing when git fails — the caller then keeps
241
+ * the commit on its branch (no reset) so leftover recovery can still reach it. */
239
242
  export async function setRef(root, ref, sha) {
240
243
  return (await gitTry(root, "update-ref", ref, sha)) !== null;
241
244
  }
245
+ /** Every ref under `prefix` with the sha it names (git for-each-ref). Empty when none match. */
246
+ export async function refsUnder(root, prefix) {
247
+ return gitLines(await gitTry(root, "for-each-ref", "--format=%(refname) %(objectname)", prefix)).map((line) => {
248
+ const [ref, sha] = line.split(" ");
249
+ return { ref, sha };
250
+ });
251
+ }
252
+ /** Delete `ref` only while it still names `sha` (git update-ref -d guarded by the old value). */
253
+ export async function deleteRefIfAt(root, ref, sha) {
254
+ await gitTry(root, "update-ref", "-d", ref, sha);
255
+ }
242
256
  /** Delete `ref`; a no-op when it is already gone (idempotent cleanup on terminal outcomes). */
243
257
  export async function deleteRef(root, ref) {
244
258
  await gitTry(root, "update-ref", "-d", ref);
@@ -253,7 +267,7 @@ export async function refSha(root, ref) {
253
267
  return gitTry(root, "rev-parse", "--verify", ref);
254
268
  }
255
269
  /** True when `sha` is already contained in `branch` (git merge-base --is-ancestor; equality counts
256
- * as contained). The leftover-recovery entry condition uses this to tell a stale pin — landed but
270
+ * as contained). The leftover-recovery entry condition uses this to tell a stale pin — merged but
257
271
  * not yet un-pinned by a crash between the ff and the ref deletion — from real work. */
258
272
  export async function isMergedInto(root, sha, branch) {
259
273
  return (await gitTry(root, "merge-base", "--is-ancestor", sha, branch)) !== null;
@@ -304,13 +318,12 @@ export async function branchExists(root, branch) {
304
318
  return false;
305
319
  return (await gitTry(root, "rev-parse", "--verify", ref)) !== null;
306
320
  }
307
- /** The revision a worktree's change should be judged against: the merge-base of its HEAD
308
- * with `mainBranch`, falling back to main's tip when the merge-base fails (no common
309
- * ancestor, or no commits yet). The one home of the "diff against the merge-base, not
310
- * main's tip" policy shared by falseFixReason's BUGS.md base read and
311
- * backlogStructureReason's per-file base reads — both judge a backlog edit against the
312
- * tree the change builds on, so a stacked batch's earlier landing cannot make an
313
- * Open→Fixed restoration compare as already done (BUGS.md 2026-09-23). `rev` is the commit
321
+ /** The revision a worktree's change should be judged against: the merge-base of its HEAD with
322
+ * `mainBranch`, falling back to main's tip when the merge-base fails (no common ancestor, or no
323
+ * commits yet). The one home of the "diff against the merge-base, not main's tip" policy shared by
324
+ * falseFixReason's BUGS.md base read and backlogStructureReason's per-file base reads — both judge
325
+ * a backlog edit against the tree the change builds on, so a stacked batch's earlier merge cannot
326
+ * make an Open→Fixed restoration compare as already done (BUGS.md 2026-09-23). `rev` is the commit
314
327
  * whose merge-base with main is wanted; it defaults to the worktree's HEAD, and the rejected
315
328
  * change's revision interdiff asks for its own prior commit instead (git-diff.ts). */
316
329
  export async function changeBaseRev(wt, mainBranch, rev = "HEAD") {
@@ -395,7 +408,7 @@ export async function currentBranch(root) {
395
408
  const fromFile = currentBranchFromHeadFile(root);
396
409
  return fromFile !== undefined ? fromFile : gitTry(root, "symbolic-ref", "--short", "HEAD");
397
410
  }
398
- /** The branch a read-only surface treats as the fleet's main: a configured `baseBranch` wins (`run
411
+ /** The branch a read-only surface treats as the team's main: a configured `baseBranch` wins (`run
399
412
  * --branch` is per-invocation and invisible to a later query), else whatever the primary checkout
400
413
  * has checked out, else the literal "main". change/change-data.ts's collectRoleChange (async, via
401
414
  * currentBranch) and status-polls's mainCheckForPoll (sync, via currentBranchFromHeadFile — a
@@ -412,12 +425,12 @@ export async function isDirty(cwd) {
412
425
  const out = await git(cwd, "status", "--porcelain");
413
426
  return out.length > 0;
414
427
  }
415
- /** Stage ONLY the given repo-relative paths, commit them with `message`, then discard every
416
- * other change in the worktree — tracked edits via reset --hard HEAD (the committed content is
417
- * safe at HEAD by the time the reset runs) and untracked files via clean -fd. Returns the new
418
- * commit hash, or null when nothing was stageable under those paths (the caller then decides
419
- * what to do with the rest). The refusal path uses this so a refusing run's objection note
420
- * lands while its half-done code work does not. */
428
+ /** Stage ONLY the given repo-relative paths, commit them with `message`, then discard every other
429
+ * change in the worktree — tracked edits via reset --hard HEAD (the committed content is safe at
430
+ * HEAD by the time the reset runs) and untracked files via clean -fd. Returns the new commit hash,
431
+ * or null when nothing was stageable under those paths (the caller then decides what to do with the
432
+ * rest). The refusal path uses this so a refusing run's objection note is kept while its half-done
433
+ * code work is not. */
421
434
  export async function commitPathsAndDiscardRest(wt, message, paths) {
422
435
  if (paths.length === 0)
423
436
  return null;
@@ -432,10 +445,10 @@ export async function commitPathsAndDiscardRest(wt, message, paths) {
432
445
  await git(wt, "clean", "-fd");
433
446
  return headOf(wt, "HEAD");
434
447
  }
435
- /** Commit subjects landed on `mainBranch` since `sinceHead`, newest first — the range is
436
- * exclusive of its base, so a head that is an ancestor of main yields exactly what moved past
437
- * it (empty when nothing has). Null when the range cannot be resolved (unknown head, not a
438
- * repo), so callers fall back conservatively. */
448
+ /** Commit subjects merged on `mainBranch` since `sinceHead`, newest first — the range is exclusive
449
+ * of its base, so a head that is an ancestor of main yields exactly what moved past it (empty when
450
+ * nothing has). Null when the range cannot be resolved (unknown head, not a repo), so callers fall
451
+ * back conservatively. */
439
452
  export async function subjectsBetween(root, sinceHead, mainBranch) {
440
453
  const out = await gitTry(root, "log", "--format=%s", `${sinceHead}..${mainBranch}`);
441
454
  if (out === null)
@@ -446,10 +459,17 @@ export async function subjectsBetween(root, sinceHead, mainBranch) {
446
459
  export async function commitMessage(cwd, sha) {
447
460
  return gitTry(cwd, "log", "-1", "--format=%B", sha);
448
461
  }
462
+ /** The number of commits in a `A..B` range: `git rev-list --count <range>` parsed as an
463
+ * integer. The one home of the count idiom, so every surface that measures how far one ref
464
+ * is ahead of another (a branch over main, main over a build) reads identically instead of
465
+ * re-spelling the git call. Throws through `git` on failure; callers that must tolerate an
466
+ * unknown ref use `gitTry` directly (as build-info's buildStaleness does for its sentinel). */
467
+ export async function countCommitsInRange(cwd, range) {
468
+ return Number.parseInt(await git(cwd, "rev-list", "--count", range), 10);
469
+ }
449
470
  /** Commits ahead of main on the worktree's branch. */
450
471
  export async function aheadOfMain(wt, mainBranch) {
451
- const out = await git(wt, "rev-list", "--count", `${mainBranch}..HEAD`);
452
- return Number.parseInt(out, 10);
472
+ return countCommitsInRange(wt, `${mainBranch}..HEAD`);
453
473
  }
454
474
  /** The patch-id of the change `head` makes on top of `base`: `git diff` over `base...head` (from
455
475
  * their merge-base, the same range aheadOfMainDiff hands the reviewer) piped into
@@ -464,8 +484,8 @@ export async function aheadOfMain(wt, mainBranch) {
464
484
  * as "no match", never an error. */
465
485
  export async function patchId(wt, base, head) {
466
486
  try {
467
- // Both children run through execGitBounded, so a hung diff or patch-id is SIGTERMed
468
- // group-wide and SIGKILLed after the grace instead of stalling landing's approval check.
487
+ // Both children run through execGitBounded, so a hung diff or patch-id is SIGTERMed group-wide
488
+ // and SIGKILLed after the grace instead of stalling merge's approval check.
469
489
  const { stdout: diff } = await execGitBounded(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--binary", `${base}...${head}`], { cwd: wt, timeoutMs: GIT_TIMEOUT_MS });
470
490
  if (diff === "")
471
491
  return null;
@@ -480,6 +500,17 @@ export async function patchId(wt, base, head) {
480
500
  return null;
481
501
  }
482
502
  }
503
+ /** Undo commits the agent made on top of `base` (INV-H1): a soft reset keeps their content as
504
+ * staged edits, so the harness's own commitAll records it as one commit. Returns how many commits
505
+ * were folded — 0 when HEAD never moved. A count of 0 from rev-list with HEAD moved means the agent
506
+ * amended or reset away from `base`; that still folds, and counts as 1. */
507
+ export async function foldCommitsSince(wt, base) {
508
+ if ((await headOf(wt, "HEAD")) === base)
509
+ return 0;
510
+ const count = await countCommitsInRange(wt, `${base}..HEAD`);
511
+ await git(wt, "reset", "--soft", base);
512
+ return count > 0 ? count : 1;
513
+ }
483
514
  /** Stage and commit everything in the worktree. Returns the new commit hash. */
484
515
  export async function commitAll(wt, message) {
485
516
  await git(wt, "add", "-A");
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  /** The persisted slot layout of the worktree pool (plans/worktree-pool.md): which
2
3
  * `.tumwater/worktrees/_slot-<n>` checkouts exist, who leases each, and which role each is pinned
3
4
  * for. Kept in its own module, apart from the pool's lease machinery, so the readers that run in
@@ -11,6 +12,7 @@ import { finiteNumber, isJsonObject, nonNegativeNumber } from "../files/json-obj
11
12
  import { withStateLock } from "../concurrency/lock.js";
12
13
  import { slotsLockPath, slotsStatePath, worktreePath } from "../paths.js";
13
14
  import { isUsableWorktree } from "./worktree.js";
15
+ import { canonicalRoleId } from "../roles/legacy-role-ids.js";
14
16
  /** Run `fn` holding the cross-process slots lock. withStateLock creates the `.tumwater/state/`
15
17
  * parent first, since a reader or `retire` may run before any state write has made it. */
16
18
  function withSlotsLock(root, fn) {
@@ -44,10 +46,17 @@ export function readSlotsState(root) {
44
46
  function isSlotLease(value) {
45
47
  return (isJsonObject(value) &&
46
48
  typeof value.role === "string" &&
47
- (value.purpose === "tick" || value.purpose === "vet") &&
49
+ (value.purpose === "run" || value.purpose === "verify" || value.purpose === "tick" || value.purpose === "vet") && // vocabulary-ok: legacy value read from old files
48
50
  nonNegativeNumber(value.since, null) !== null &&
49
51
  nonNegativeNumber(value.pid, null) !== null);
50
52
  }
53
+ /** A lease an older build wrote carries the pre-rename purpose names; read them as
54
+ * `run`/`verify`. */
55
+ function readSlotLease(lease) {
56
+ const l = lease;
57
+ const purpose = l.purpose === "tick" ? "run" : l.purpose === "vet" ? "verify" : l.purpose; // vocabulary-ok: legacy value read from old files
58
+ return { ...l, role: canonicalRoleId(l.role), purpose };
59
+ }
51
60
  /** Coerce one persisted entry into a SlotRecord, defaulting the known fields a hand edit or an
52
61
  * older writer may omit. Each field is the value or its empty form, so a record missing
53
62
  * `pinnedFor` is not frozen as permanently pinned (chooseSlot treats `!== null` as pinned),
@@ -63,11 +72,11 @@ function readSlotRecord(slot) {
63
72
  return {
64
73
  ...slot,
65
74
  dir: slot.dir,
66
- lease: isSlotLease(slot.lease) ? slot.lease : null,
67
- pinnedFor: typeof slot.pinnedFor === "string" ? slot.pinnedFor : null,
75
+ lease: isSlotLease(slot.lease) ? readSlotLease(slot.lease) : null,
76
+ pinnedFor: typeof slot.pinnedFor === "string" ? canonicalRoleId(slot.pinnedFor) : null,
68
77
  // Absent stays absent (an older writer's shape is preserved); present-but-foreign is null.
69
78
  pinnedAt: slot.pinnedAt === undefined ? undefined : finiteNumber(slot.pinnedAt, null),
70
- lastRole: typeof slot.lastRole === "string" ? slot.lastRole : null,
79
+ lastRole: typeof slot.lastRole === "string" ? canonicalRoleId(slot.lastRole) : null,
71
80
  lastReleasedAt: finiteNumber(slot.lastReleasedAt, null),
72
81
  };
73
82
  }
@@ -98,7 +107,7 @@ export function slotForDir(root, dir) {
98
107
  * ticks lease slots (part 4/5), because nothing writes those leases yet. */
99
108
  export async function roleWorktreeDir(root, role) {
100
109
  const slots = readSlotsState(root).slots;
101
- const leased = slots.find((slot) => slot.lease?.role === role && slot.lease.purpose === "tick");
110
+ const leased = slots.find((slot) => slot.lease?.role === role && slot.lease.purpose === "run");
102
111
  if (leased !== undefined)
103
112
  return leased.dir;
104
113
  const pinned = slots.find((slot) => slot.pinnedFor === role);
@@ -1,14 +1,15 @@
1
- /** The worktree pool's lease machinery (plans/worktree-pool.md, "Leases"; part 2a/5): a fixed
2
- * set of `.tumwater/worktrees/_slot-<n>` checkouts that ticks and vets lease and switch commits
3
- * in place, instead of keeping one `_land-<role>` checkout per role. The persisted layout and
4
- * its cross-process lock live in slots-state.ts, which other processes (`tumwater diff`,
5
- * `retire`) also read; this module owns choosing a slot, holding it through the disk-floor
6
- * registry, and handing it back. Nothing leases a slot yet — part 4/5 wires the loops to it.
1
+ // vocabulary: plain
2
+ /** 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
4
+ * place, instead of keeping one `_land-<role>` checkout per role. The persisted layout and its
5
+ * cross-process lock live in slots-state.ts, which other processes (`tumwater diff`, `retire`) also
6
+ * 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.
7
8
  *
8
- * The lease decision is synchronous: `updateSlotsState` writes slots.json under the slots lock,
9
- * and one Node process runs every tick and landing, so two concurrent `leaseSlot` calls claim
10
- * distinct free slots and a call with none free registers an in-process waiter. A release wakes
11
- * the waiters, which re-decide; `signal` aborts a waiter. */
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
11
+ * free slots and a call with none free registers an in-process waiter. A release wakes the waiters,
12
+ * which re-decide; `signal` aborts a waiter. */
12
13
  import path from "node:path";
13
14
  import { loadConfig, slotCount } from "../config/config.js";
14
15
  import { worktreesDir, slotWorktreePath } from "../paths.js";
@@ -17,9 +18,10 @@ import { DIRECTOR_ROLE } from "../roles/roles.js";
17
18
  import { readSlotsState, slotForDir, updateSlotsState } from "./slots-state.js";
18
19
  import { beginWorktreeUse } from "./worktree-use.js";
19
20
  import { ensureDetachedWorktree, removeWorktreeDir } from "./worktree.js";
20
- import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
21
+ import { logEventBestEffort, warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
21
22
  import { errorMessage } from "../text/text.js";
22
23
  import { subdirectoryNames } from "../files/files.js";
24
+ import { dropProviderLeaseKey } from "../concurrency/provider-lease.js";
23
25
  /** In-process waiters per root, woken when any slot is released. Keyed by resolved root so two
24
26
  * roots never wake each other. */
25
27
  const waiters = new Map();
@@ -27,7 +29,7 @@ function rootKey(root) {
27
29
  return path.resolve(root);
28
30
  }
29
31
  function abortError() {
30
- const err = new Error("slot lease aborted before a slot was free");
32
+ const err = new Error("the workspace request was stopped before one was free");
31
33
  err.name = "AbortError";
32
34
  return err;
33
35
  }
@@ -146,7 +148,7 @@ function logSlotWait(root, role, purpose, waitedMs) {
146
148
  return;
147
149
  const slots = readSlotsState(root).slots;
148
150
  logEventBestEffort(root, {
149
- loop: role,
151
+ source: role,
150
152
  type: "slot_wait",
151
153
  role,
152
154
  purpose,
@@ -177,6 +179,11 @@ async function acquire(root, role, purpose, signal, slotBudget) {
177
179
  logSlotWait(root, role, purpose, Date.now() - startedAt);
178
180
  return chosen;
179
181
  }
182
+ // The pool-slot wait rule (plans/provider-concurrency.md §5 "No deadlock"): a lease never holds
183
+ // 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
185
+ // re-key waiter is waiting to drain; the next run re-takes the key as an ordinary re-key.
186
+ await dropProviderLeaseKey();
180
187
  const waiter = registerWaiter(root);
181
188
  try {
182
189
  await raceAbort(waiter.promise, signal);
@@ -258,7 +265,7 @@ export async function removeDroppedSlots(root, dirs, remove = removeWorktreeDir)
258
265
  await remove(root, dir);
259
266
  }
260
267
  catch (err) {
261
- warnEventBestEffort(root, "harness", `could not remove idle worktree slot ${dir}: ${errorMessage(err)}`);
268
+ warnEventBestEffort(root, TUMWATER_SOURCE, `could not remove idle workspace ${dir}: ${errorMessage(err)}`);
262
269
  }
263
270
  }
264
271
  }
@@ -276,7 +283,7 @@ export async function removeDroppedSlots(root, dirs, remove = removeWorktreeDir)
276
283
  * are already updated, so a leaked directory is only a leak the next prune collects. */
277
284
  export async function retireLegacyRoleWorktrees(root, configured) {
278
285
  const dir = worktreesDir(root);
279
- // A missing worktrees dir (a fleet that has not started) yields no names to migrate; the pin
286
+ // A missing worktrees dir (a team that has not started) yields no names to migrate; the pin
280
287
  // retirement pass below still runs over whatever slots.json holds.
281
288
  const names = subdirectoryNames(dir, (name) => name !== DIRECTOR_ROLE && !name.startsWith("_"));
282
289
  const configuredSet = new Set(configured);
@@ -289,7 +296,7 @@ export async function retireLegacyRoleWorktrees(root, configured) {
289
296
  const ownRecord = state.slots.find((slot) => slot.dir === legacyDir);
290
297
  if (resumable && configuredSet.has(role)) {
291
298
  const owned = state.slots.find((slot) => slot.pinnedFor === role ||
292
- (slot.lease?.role === role && slot.lease.purpose === "tick"));
299
+ (slot.lease?.role === role && slot.lease.purpose === "run"));
293
300
  if (owned !== undefined) {
294
301
  // The resume happens in the owned slot: drop the legacy record and remove its dir.
295
302
  if (owned.dir !== legacyDir) {
@@ -343,7 +350,7 @@ export async function retireLegacyRoleWorktrees(root, configured) {
343
350
  await removeWorktreeDir(root, victim);
344
351
  }
345
352
  catch (err) {
346
- warnEventBestEffort(root, "harness", `could not remove legacy role worktree ${victim}: ${errorMessage(err)}`);
353
+ warnEventBestEffort(root, TUMWATER_SOURCE, `could not remove legacy role workspace ${victim}: ${errorMessage(err)}`);
347
354
  }
348
355
  }
349
356
  }
@@ -363,7 +370,7 @@ export async function leaseSlot(root, options) {
363
370
  useRelease();
364
371
  }
365
372
  catch (err) {
366
- warnEventBestEffort(root, "harness", `could not record use release for ${path.basename(dir)}: ${errorMessage(err)}`);
373
+ warnEventBestEffort(root, TUMWATER_SOURCE, `could not record use release for ${path.basename(dir)}: ${errorMessage(err)}`);
367
374
  }
368
375
  };
369
376
  if (!preserved) {
@@ -1,8 +1,9 @@
1
- /** The worktree-use registry (plans/disk-floor.md, "Reclaiming build outputs", part 2/4):
2
- * which harness worktrees are in use right now, and when each was last used. The orchestrator
3
- * is the one process that runs ticks and landings, so a plain in-process map is enough for the
4
- * "in use" half; the "last used" half is persisted to `.tumwater/state/worktree-use.json` so
5
- * least-recently-used reclaim ordering survives a restart.
1
+ // vocabulary: plain
2
+ /** The worktree-use registry (plans/disk-floor.md, "Reclaiming build outputs", part 2/4): which
3
+ * 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;
5
+ * the "last used" half is persisted to `.tumwater/state/worktree-use.json` so least-recently-used
6
+ * reclaim ordering survives a restart.
6
7
  *
7
8
  * A reclaim claims a worktree before it cleans it, so a `useWorktree` that arrives mid-clean
8
9
  * waits for the clean to finish instead of starting work in a half-emptied tree. Every wrapped
@@ -12,7 +13,7 @@ import path from "node:path";
12
13
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
13
14
  import { finiteNumber, isJsonObject } from "../files/json-object.js";
14
15
  import { withStateLock } from "../concurrency/lock.js";
15
- import { warnEventBestEffort } from "../events/events.js";
16
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
16
17
  import { errorMessage } from "../text/text.js";
17
18
  import { worktreeUseLockPath, worktreeUsePath } from "../paths.js";
18
19
  /** In-process state, keyed by the resolved directory so two roots' same-named worktrees never
@@ -37,7 +38,7 @@ function liveEntry(dir) {
37
38
  * produces one). An Infinity `lastUsedAt` would sort a worktree as used forever —
38
39
  * `now - Infinity` is below any idle window — so the worktree could never be idle-reclaimed;
39
40
  * an Infinity `reclaimedAt` would read as reclaimed after every use, blocking the same
40
- * reclaim. Either reads as absent, the same finiteNumber rule fleet-state.ts's and
41
+ * reclaim. Either reads as absent, the same finiteNumber rule team-state.ts's and
41
42
  * operator-requests.ts's persisted deadlines read by. */
42
43
  function readWorktreeRecord(value) {
43
44
  if (!isJsonObject(value))
@@ -83,19 +84,19 @@ export function updateWorktreeUse(root, change) {
83
84
  return registry;
84
85
  });
85
86
  }
86
- /** `updateWorktreeUse`, but best-effort: the durable registry only orders reclaim (see the
87
- * module doc), so a write that fails — the lock times out behind a CLI `tumwater reclaim`, the
88
- * state volume is full — is warned and dropped rather than thrown. Both release paths use it:
87
+ /** `updateWorktreeUse`, but best-effort: the durable registry only orders reclaim (see the module
88
+ * doc), so a write that fails — the lock times out behind a CLI `tumwater reclaim`, the state
89
+ * volume is full — is warned and dropped rather than thrown. Both release paths use it:
89
90
  * `beginWorktreeUse`'s release and `releaseReclaim`, the latter in a `finally` after a reclaim.
90
- * Their bookkeeping must not become the caller's failure — a throw there would mask a
91
- * successful tick, landing, or clean with a registry error. The in-process `uses`/`reclaiming`
92
- * state is updated before this runs, so only reclaim ordering degrades. */
91
+ * 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
93
+ * before this runs, so only reclaim ordering degrades. */
93
94
  function updateWorktreeUseBestEffort(root, change) {
94
95
  try {
95
96
  updateWorktreeUse(root, change);
96
97
  }
97
98
  catch (err) {
98
- warnEventBestEffort(root, "harness", `could not update the worktree-use registry: ${errorMessage(err)}`);
99
+ warnEventBestEffort(root, TUMWATER_SOURCE, `could not update the worktree-use registry: ${errorMessage(err)}`);
99
100
  }
100
101
  }
101
102
  /** True while a `useWorktree` body holds this worktree. */
@@ -53,13 +53,13 @@ function pruneStaleBuildDir(wt) {
53
53
  const setupQueues = new Map();
54
54
  /** Run `setup` — a clear-and-add of one worktree — after every earlier setup for the same
55
55
  * repository has finished. `git worktree add` creates the new registration's directory under
56
- * .git/worktrees a moment before it writes the `locked` file that shields it from prune, and a
57
- * `git worktree prune` landing in between deletes the registration: the add then dies with
58
- * "could not open '.git/worktrees/<name>/locked' for writing". The landing pipeline vets its
59
- * changes concurrently, each vet ensuring its own leased pool slot, so two vets' clear-and-add
60
- * steps did interleave — and the loser's vet ended in a terminal "error" that dropped its queue
61
- * entry. Serializing the harness's own setups per repository closes that; the usable-worktree
62
- * fast path never takes the queue. */
56
+ * .git/worktrees a moment before it writes the `locked` file that shields it from prune, and a `git
57
+ * worktree prune` running in between deletes the registration: the add then dies with "could not
58
+ * open '.git/worktrees/<name>/locked' for writing". The merge pipeline verifies its changes
59
+ * concurrently, each verify ensuring its own leased pool slot, so two verifies' clear-and-add steps
60
+ * did interleave — and the loser's verify ended in a terminal "error" that dropped its queue entry.
61
+ * Serializing the harness's own setups per repository closes that; the usable-worktree fast path
62
+ * never takes the queue. */
63
63
  async function serializeSetup(root, setup) {
64
64
  const key = path.resolve(root);
65
65
  const run = (setupQueues.get(key) ?? Promise.resolve()).then(setup);
@@ -73,11 +73,11 @@ async function serializeSetup(root, setup) {
73
73
  setupQueues.delete(key);
74
74
  }
75
75
  }
76
- /** Ensure a persistent worktree + branch exists for a role. Returns the worktree path.
77
- * Self-heals when the directory exists but is no longer a usable worktree: it removes and
78
- * re-adds the directory instead of failing every tick (see clearStaleWorktree). Since the
79
- * worktree pool landed (plans/worktree-pool.md, "Role ticks lease slots"), this serves the
80
- * director only; every other role leases a pooled `_slot-<n>` via leaseSlot instead. */
76
+ /** Ensure a persistent worktree + branch exists for a role. Returns the worktree path. Self-heals
77
+ * 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
+ * role leases a pooled `_slot-<n>` via leaseSlot instead. */
81
81
  export async function ensureWorktree(root, role, mainBranch) {
82
82
  const wt = worktreePath(root, role);
83
83
  const branch = branchName(role);
@@ -130,14 +130,14 @@ export async function removeWorktreeDir(root, dir) {
130
130
  export async function removeWorktree(root, role) {
131
131
  await removeWorktreeDir(root, worktreePath(root, role));
132
132
  }
133
- /** Remove lander checkouts left by a pre-pool build (`_land-<role>`): the pool's ticks keep
134
- * their commit on the role branch and a queued landing re-vets from its pinned ref, so the
135
- * checkout holds nothing to keep. Called once at orchestrator startup, before any tick; each
136
- * removal goes through removeWorktreeDir (force `worktree remove`, with the shared
137
- * dead-registration fallback), then one prune registers the vanished checkouts. */
133
+ /** Remove the `_land-<role>` checkouts left by a pre-pool build: the pool's ticks keep their
134
+ * 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
136
+ * through removeWorktreeDir (force `worktree remove`, with the shared dead-registration fallback),
137
+ * then one prune registers the vanished checkouts. */
138
138
  export async function removeLegacyLandWorktrees(root) {
139
139
  const dir = worktreesDir(root);
140
- // A missing worktrees dir (a fleet that has not started) yields no names to remove.
140
+ // A missing worktrees dir (a team that has not started) yields no names to remove.
141
141
  const names = subdirectoryNames(dir, (name) => name.startsWith("_land-"));
142
142
  for (const name of names)
143
143
  await removeWorktreeDir(root, path.join(dir, name));
@@ -16,9 +16,9 @@ import { queueFileNameProblem } from "../inbox/inbox-cancel.js";
16
16
  import { DIRECTOR_ROLE } from "../roles/roles.js";
17
17
  import { gotSuffix, parseNonNegativeInt, parsePositiveInt } from "../text/text.js";
18
18
  import { typoSuffix } from "../text/suggest.js";
19
- import { sendJson } from "./http-body.js";
19
+ import { badRequest, sendJson } from "./http-body.js";
20
20
  /** The role ids a loop-targeting endpoint accepts: when tumwater.json parses, catalog +
21
- * customLoops (knownRoleIds); a transiently broken file falls back to the built-in catalog
21
+ * customRoles (knownRoleIds); a transiently broken file falls back to the built-in catalog
22
22
  * rather than refusing every id — the rule config.knownRoleIdsCached owns. Shared by
23
23
  * /api/transcript and the two operator endpoints so their validation and 400 wording cannot
24
24
  * drift. */
@@ -41,13 +41,11 @@ export function rejectBadRole(root, res, role, allowMissing = false) {
41
41
  return false;
42
42
  const validIds = validRoleIds(root);
43
43
  if (role === undefined || role === null) {
44
- sendJson(res, 400, { error: `role required (valid ids: ${validIds.join(", ")})` });
44
+ badRequest(res, `role required (valid ids: ${validIds.join(", ")})`);
45
45
  return true;
46
46
  }
47
47
  if (typeof role !== "string" || !validIds.includes(role)) {
48
- sendJson(res, 400, {
49
- error: `unknown role ${JSON.stringify(role)} (valid ids: ${validIds.join(", ")})${typeof role === "string" ? typoSuffix(role, validIds) : ""}`,
50
- });
48
+ badRequest(res, `unknown role ${JSON.stringify(role)} (valid ids: ${validIds.join(", ")})${typeof role === "string" ? typoSuffix(role, validIds) : ""}`);
51
49
  return true;
52
50
  }
53
51
  return false;
@@ -61,7 +59,7 @@ export function rejectBadQueueFileName(res, name) {
61
59
  const problem = queueFileNameProblem(name);
62
60
  if (problem === null)
63
61
  return false;
64
- sendJson(res, 400, { error: problem });
62
+ badRequest(res, problem);
65
63
  return true;
66
64
  }
67
65
  /** Read an integer query parameter with the GUI's established discipline for an explicit
@@ -77,7 +75,7 @@ export function intQuery(q, res, name, kind, fallback) {
77
75
  if (raw === null) {
78
76
  if (fallback !== undefined)
79
77
  return fallback;
80
- sendJson(res, 400, { error: `${name} required` });
78
+ badRequest(res, `${name} required`);
81
79
  return null;
82
80
  }
83
81
  const parsed = kind === "positive" ? parsePositiveInt(raw) : parseNonNegativeInt(raw);
@@ -113,12 +111,12 @@ export function requirePromptText(res, body, role = DIRECTOR_ROLE) {
113
111
  return null;
114
112
  }
115
113
  if (!text.trim()) {
116
- sendJson(res, 400, { error: "text required" });
114
+ badRequest(res, "text required");
117
115
  return null;
118
116
  }
119
117
  const tooLong = promptLengthProblem(text, role);
120
118
  if (tooLong) {
121
- sendJson(res, 400, { error: tooLong });
119
+ badRequest(res, tooLong);
122
120
  return null;
123
121
  }
124
122
  return text;
@@ -137,7 +135,7 @@ export function sinceMsQuery(q, res, maxMs, overCap) {
137
135
  if (sinceMs === null)
138
136
  return null;
139
137
  if (sinceMs > maxMs) {
140
- sendJson(res, 400, { error: overCap(sinceMs) });
138
+ badRequest(res, overCap(sinceMs));
141
139
  return null;
142
140
  }
143
141
  return sinceMs;
@@ -151,9 +149,7 @@ export function sinceMsQuery(q, res, maxMs, overCap) {
151
149
  export function requireConfigKey(res, body) {
152
150
  const key = body.key;
153
151
  if (typeof key !== "string" || !EDITABLE_CONFIG_KEYS.includes(key)) {
154
- sendJson(res, 400, {
155
- error: `key must be one of ${EDITABLE_CONFIG_KEYS.join(", ")}${key === undefined ? "" : gotSuffix(key)}`,
156
- });
152
+ badRequest(res, `key must be one of ${EDITABLE_CONFIG_KEYS.join(", ")}${key === undefined ? "" : gotSuffix(key)}${typeof key === "string" ? typoSuffix(key, EDITABLE_CONFIG_KEYS) : ""}`);
157
153
  return null;
158
154
  }
159
155
  return key;