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
@@ -2,10 +2,44 @@
2
2
  * identity the harness authors commits with. The query helpers built on top of it (heads,
3
3
  * refs, branches, diffs) live in git.ts and its siblings — this module knows git's argv,
4
4
  * not the repository's shape. */
5
+ /** The author every harness-authored commit carries: tumwater, always, so blame-based
6
+ * classification (docs/code-metrics.md "Who wrote it", keyed on author and trailer) and
7
+ * INV-H1's "tumwater-stamped commit" wording stay true regardless of who invokes the team. */
8
+ const COMMIT_AUTHOR = "tumwater <tumwater@localhost>";
9
+ /** The ambient git identity of whoever invokes the team — `git config user.name` and
10
+ * `user.email` from the repo's local config and then global/system (git's own lookup path),
11
+ * read through gitTry so a key that is unset (git exits nonzero) reads as blank. When the
12
+ * repo and machine configure none (or only one of the two), fall back to tumwater on both
13
+ * sides so runs still work with no global git config (the long-standing fallback). No
14
+ * process-global cache: identity is resolved per call against the command's `cwd`, so a
15
+ * repo (or a test) with its own local identity reads correctly and no cross-repo bleed is
16
+ * possible. */
17
+ export async function committerIdent(cwd) {
18
+ const name = (await gitTry(cwd, "config", "user.name"))?.trim() ?? "";
19
+ const email = (await gitTry(cwd, "config", "user.email"))?.trim() ?? "";
20
+ if (name !== "" && email !== "")
21
+ return { name, email };
22
+ return { name: "tumwater", email: "tumwater@localhost" };
23
+ }
24
+ /** The `--author` argv fragment every `git commit` call appends right after its `commit`
25
+ * subcommand, so the tumwater author is pinned: a commit would otherwise inherit the previous
26
+ * commit's author once committer and author stop being the same identity. `--author` is a
27
+ * commit option, not a global one, so it belongs after the subcommand, unlike the `-c` pairs. */
28
+ export function commitAuthorArgs() {
29
+ return ["--author", COMMIT_AUTHOR];
30
+ }
31
+ /** Committer-only identity argv: the two `-c` pairs alone. For a rebase/cherry-pick these
32
+ * preserve each source commit's original (tumwater) author while rewriting the committer to
33
+ * the account running the team; a `git commit` call adds `commitAuthorArgs()` after its
34
+ * subcommand to pin the author too. */
35
+ export async function committerIdentArgs(cwd) {
36
+ const { name, email } = await committerIdent(cwd);
37
+ return ["-c", `user.name=${name}`, "-c", `user.email=${email}`];
38
+ }
5
39
  import { spawn } from "node:child_process";
6
40
  import { findOnPath } from "../files/files.js";
7
41
  import { realGitFromXcrun } from "./xcrun-git.js";
8
- import { EXEC_MAX_BUFFER, childEnv, releaseChildHandles, signalTree } from "../process/process.js";
42
+ import { EXEC_MAX_BUFFER, captureCappedOutput, childEnv, releaseChildHandles, signalTree } from "../process/process.js";
9
43
  import { KILL_GRACE_MS, armGroupDeadline } from "../process/process-group.js";
10
44
  import { errorMessage } from "../text/text.js";
11
45
  /** The wall-clock bound on one git subprocess. Generous enough for a large rebase or diff on a slow
@@ -15,13 +49,6 @@ import { errorMessage } from "../text/text.js";
15
49
  * SIGTERM → SIGKILL escalation then runs for KILL_GRACE_MS, so one call is bounded at timeoutMs +
16
50
  * KILL_GRACE_MS whatever the child does. */
17
51
  export const GIT_TIMEOUT_MS = 600_000;
18
- /** Identity used for harness-authored commits so ticks work without global git config. */
19
- export const COMMIT_IDENT = [
20
- "-c",
21
- "user.name=tumwater",
22
- "-c",
23
- "user.email=tumwater@localhost",
24
- ];
25
52
  /** The error every failed git subprocess reports: the argv, the cause text (git's stderr or the
26
53
  * spawn error's message), and git's exit code or the spawn errno. */
27
54
  export class GitError extends Error {
@@ -35,12 +62,26 @@ export class GitError extends Error {
35
62
  /** git's exit code (a number) — or a spawn errno like "ENOENT" (a string) when the
36
63
  * binary itself could not be started, which is what execFile puts in `err.code` then. */
37
64
  code) {
38
- super(`git ${args.join(" ")} failed${code !== undefined ? ` (${code})` : ""}${stderr ? `: ${stderr}` : ""}`);
65
+ // Quote any argv a space or quote would make ambiguous (a multi-word commit message, a
66
+ // path), so the reported command reads unambiguously instead of "commit -m some message"
67
+ // reading as a stray subcommand plus arguments. Single-token argv stays bare, keeping the
68
+ // pinned "git status failed (1)" shape. JSON.stringify is the codebase's shared value
69
+ // quoting idiom (gotSuffix, cli/error text use it for the same reason).
70
+ const argv = args.map((a) => (/[\s"\\]/.test(a) ? JSON.stringify(a) : a)).join(" ");
71
+ super(`git ${argv} failed${code !== undefined ? ` (${code})` : ""}${stderr ? `: ${stderr}` : ""}`);
39
72
  this.args = args;
40
73
  this.stderr = stderr;
41
74
  this.code = code;
42
75
  }
43
76
  }
77
+ /** True when `err` is a git call whose process could not be started at all: a spawn errno such
78
+ * as EAGAIN or ENOMEM, which is what every git call reported while the 2026-10-21 process-table
79
+ * wedge lasted. That says nothing about the repository and passes with the condition, so a
80
+ * caller about to act on the answer (discard edits, reset a workspace) waits and retries rather
81
+ * than treating it as "nothing there". */
82
+ export function gitCouldNotStart(err) {
83
+ return err instanceof GitError && typeof err.code === "string";
84
+ }
44
85
  /** The one error every entry point that shells out to git reports when the binary itself is
45
86
  * missing from PATH. Without a preflight check, `isGitRepo`'s failed probe reads as "not a
46
87
  * git repository (run `git init` first)" — pointing at the wrong fix for a machine with no
@@ -129,17 +170,25 @@ export function execGitBounded(args, opts) {
129
170
  stdio: ["pipe", "pipe", "pipe"],
130
171
  ...(opts.extraEnv ? { env: childEnv(opts.extraEnv) } : {}),
131
172
  });
132
- let stdout = "";
133
- let stderr = "";
134
173
  const promise = new Promise((resolve, reject) => {
135
174
  let settled = false;
136
- let stdoutSize = 0;
137
- let stderrSize = 0;
138
175
  const deadline = armGroupDeadline(child, {
139
176
  timeoutMs,
140
177
  killGraceMs,
141
178
  onTimedOut: () => finish(new GitTimeoutError(timeoutMs)),
142
179
  });
180
+ // setEncoding gives the stream a StringDecoder, so a multibyte UTF-8 character split
181
+ // across chunk boundaries is reassembled rather than corrupted — what execFile's utf8
182
+ // decoding did before this rewrite. It must precede captureCappedOutput so the receiver
183
+ // sees strings, not raw buffers.
184
+ child.stdout?.setEncoding("utf8");
185
+ child.stderr?.setEncoding("utf8");
186
+ const cap = captureCappedOutput(child, EXEC_MAX_BUFFER, (stream) => {
187
+ // Output past the ceiling is a hard failure, not silent truncation: killing the group
188
+ // stops a git that would otherwise emit unbounded output, and the error names the stream.
189
+ signalTree(child, "SIGKILL");
190
+ finish(new GitExecError(cap.stderr, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", `${stream} maxBuffer length exceeded`));
191
+ });
143
192
  const finish = (err) => {
144
193
  if (settled)
145
194
  return;
@@ -152,32 +201,11 @@ export function execGitBounded(args, opts) {
152
201
  reject(err);
153
202
  }
154
203
  else {
155
- resolve({ stdout, stderr });
204
+ // Read cap's fields at settle time (they accumulate as data arrives) rather than
205
+ // destructuring a snapshot that stays empty.
206
+ resolve({ stdout: cap.stdout, stderr: cap.stderr });
156
207
  }
157
208
  };
158
- const onOverflow = (stream) => {
159
- signalTree(child, "SIGKILL");
160
- finish(new GitExecError(stderr, "ERR_CHILD_PROCESS_STDIO_MAXBUFFER", `${stream} maxBuffer length exceeded`));
161
- };
162
- // setEncoding gives the stream a StringDecoder, so a multibyte UTF-8 character split
163
- // across chunk boundaries is reassembled rather than corrupted — what execFile's utf8
164
- // decoding did before this rewrite.
165
- child.stdout?.setEncoding("utf8");
166
- child.stderr?.setEncoding("utf8");
167
- child.stdout?.on("data", (chunk) => {
168
- stdoutSize += Buffer.byteLength(chunk);
169
- if (stdoutSize > EXEC_MAX_BUFFER)
170
- onOverflow("stdout");
171
- else
172
- stdout += chunk;
173
- });
174
- child.stderr?.on("data", (chunk) => {
175
- stderrSize += Buffer.byteLength(chunk);
176
- if (stderrSize > EXEC_MAX_BUFFER)
177
- onOverflow("stderr");
178
- else
179
- stderr += chunk;
180
- });
181
209
  child.on("error", (err) => {
182
210
  // Before the deadline this is the spawn failing (git missing from PATH); after it, it can
183
211
  // only be a teardown signal that could not be delivered — the run is a timeout either
@@ -194,7 +222,7 @@ export function execGitBounded(args, opts) {
194
222
  else if (code === 0)
195
223
  finish();
196
224
  else
197
- finish(new GitExecError(stderr, code ?? undefined));
225
+ finish(new GitExecError(cap.stderr, code ?? undefined));
198
226
  });
199
227
  });
200
228
  return Object.assign(promise, { child });
@@ -235,3 +263,16 @@ export async function gitTry(cwd, ...args) {
235
263
  return null;
236
264
  }
237
265
  }
266
+ /** gitTry for a call whose failure is itself an answer the caller acts on (no merge-base, a
267
+ * cherry-pick that stopped), when acting on it drops work: null only when git ran and failed,
268
+ * and a throw when git could not start (gitCouldNotStart), which answers nothing. */
269
+ export async function gitTryStarted(cwd, ...args) {
270
+ try {
271
+ return await runGit(cwd, args);
272
+ }
273
+ catch (err) {
274
+ if (gitCouldNotStart(err))
275
+ throw err;
276
+ return null;
277
+ }
278
+ }
@@ -1,6 +1,8 @@
1
+ import { errCode } from "../errno.js";
2
+ import { isDirectory } from "../files/files.js";
1
3
  import fs from "node:fs";
2
4
  import path from "node:path";
3
- import { COMMIT_IDENT, GIT_TIMEOUT_MS, execGitBounded, git, gitTry } from "./git-run.js";
5
+ import { GIT_TIMEOUT_MS, commitAuthorArgs, committerIdentArgs, execGitBounded, git, gitCouldNotStart, gitTry } from "./git-run.js";
4
6
  /** A valid object id (SHA-1 or SHA-256). */
5
7
  function isSha(s) {
6
8
  return /^[0-9a-f]{40,64}$/.test(s);
@@ -12,17 +14,11 @@ function isSha(s) {
12
14
  * when the ref cannot be resolved from files — no repo here, `.git` is a worktree pointer
13
15
  * file, the branch does not exist, or content fails validation — so callers can fall back to
14
16
  * `git rev-parse`. Synchronous and microsecond-scale: this exists for poll loops that would
15
- * otherwise pay a ~10ms subprocess spawn per tick just to watch main. */
17
+ * otherwise pay a ~10ms subprocess spawn per run just to watch main. */
16
18
  export function readBranchHead(root, branch) {
17
- let st;
18
- try {
19
- st = fs.statSync(path.join(root, ".git"));
20
- }
21
- catch {
22
- return null; // Not a repo (or .git missing).
23
- }
24
- if (!st.isDirectory())
25
- return null; // Worktree pointer file — the spawn fallback knows better.
19
+ // Not a repo (or .git missing), or a worktree pointer file — the spawn fallback knows better.
20
+ if (!isDirectory(path.join(root, ".git")))
21
+ return null;
26
22
  try {
27
23
  const sha = fs.readFileSync(path.join(root, ".git", "refs", "heads", branch), "utf8").trim();
28
24
  if (isSha(sha))
@@ -43,8 +39,8 @@ export function readBranchHead(root, branch) {
43
39
  /** Resolve a branch's head without paying for a spawn when the ref files suffice:
44
40
  * readBranchHead (microsecond-scale) first, then `git rev-parse` as the fallback that covers
45
41
  * what file reads cannot (a worktree-pointer .git, anything unusual). Returns null when
46
- * neither resolves it. Shared by the orchestrator's per-poll main watch, each tick's
47
- * end-of-tick head update, the red-main baseline check, and the stack merger, so the
42
+ * neither resolves it. Shared by the orchestrator's per-poll main watch, each run's
43
+ * end-of-run head update, the red-main baseline check, and the stack merger, so the
48
44
  * file-first/spawn-fallback strategy lives in one place. */
49
45
  export async function branchHead(root, branch) {
50
46
  return readBranchHead(root, branch) ?? (await gitTry(root, "rev-parse", branch));
@@ -87,7 +83,7 @@ function repoFiles(dir) {
87
83
  commondir = path.resolve(gitdir, pointer);
88
84
  }
89
85
  catch (err) {
90
- if (err.code !== "ENOENT")
86
+ if (errCode(err) !== "ENOENT")
91
87
  return undefined; // A primary checkout has none.
92
88
  }
93
89
  try {
@@ -139,7 +135,7 @@ function storedRef(commondir, ref) {
139
135
  return isSha(loose) ? loose : undefined;
140
136
  }
141
137
  catch (err) {
142
- if (err.code !== "ENOENT")
138
+ if (errCode(err) !== "ENOENT")
143
139
  return undefined;
144
140
  }
145
141
  let packed;
@@ -147,7 +143,7 @@ function storedRef(commondir, ref) {
147
143
  packed = fs.readFileSync(path.join(commondir, "packed-refs"), "utf8");
148
144
  }
149
145
  catch (err) {
150
- return err.code === "ENOENT" ? null : undefined;
146
+ return errCode(err) === "ENOENT" ? null : undefined;
151
147
  }
152
148
  return shaFromPackedRefs(packed, ` ${ref}`);
153
149
  }
@@ -204,7 +200,7 @@ export function hasWorktreeRegistrations(root) {
204
200
  return fs.readdirSync(path.join(repo.commondir, "worktrees")).length > 0;
205
201
  }
206
202
  catch (err) {
207
- return err.code === "ENOENT" ? false : undefined;
203
+ return errCode(err) === "ENOENT" ? false : undefined;
208
204
  }
209
205
  }
210
206
  /** True when the files settle that the full ref name `ref` does NOT exist in `dir`'s repository:
@@ -249,6 +245,10 @@ export async function refsUnder(root, prefix) {
249
245
  return { ref, sha };
250
246
  });
251
247
  }
248
+ /** Whether `sha` names a commit present in the repository. */
249
+ export async function commitExists(root, sha) {
250
+ return (await gitTry(root, "cat-file", "-e", `${sha}^{commit}`)) !== null;
251
+ }
252
252
  /** Delete `ref` only while it still names `sha` (git update-ref -d guarded by the old value). */
253
253
  export async function deleteRefIfAt(root, ref, sha) {
254
254
  await gitTry(root, "update-ref", "-d", ref, sha);
@@ -294,7 +294,7 @@ function toplevelFromFiles(dir) {
294
294
  text = fs.readFileSync(config, "utf8");
295
295
  }
296
296
  catch (err) {
297
- if (err.code === "ENOENT")
297
+ if (errCode(err) === "ENOENT")
298
298
  continue;
299
299
  return undefined;
300
300
  }
@@ -311,12 +311,7 @@ function toplevelFromFiles(dir) {
311
311
  }
312
312
  /** True when the local branch `<branch>` exists in the repo. */
313
313
  export async function branchExists(root, branch) {
314
- const ref = `refs/heads/${branch}`;
315
- if (refShaFromFiles(root, ref) !== undefined)
316
- return true;
317
- if (refAbsentFromFiles(root, ref))
318
- return false;
319
- return (await gitTry(root, "rev-parse", "--verify", ref)) !== null;
314
+ return (await refSha(root, `refs/heads/${branch}`)) !== null;
320
315
  }
321
316
  /** The revision a worktree's change should be judged against: the merge-base of its HEAD with
322
317
  * `mainBranch`, falling back to main's tip when the merge-base fails (no common ancestor, or no
@@ -430,17 +425,23 @@ export async function isDirty(cwd) {
430
425
  * HEAD by the time the reset runs) and untracked files via clean -fd. Returns the new commit hash,
431
426
  * or null when nothing was stageable under those paths (the caller then decides what to do with the
432
427
  * rest). The refusal path uses this so a refusing run's objection note is kept while its half-done
433
- * code work is not. */
428
+ * code work is not. A git that could not start throws instead (gitCouldNotStart): read as
429
+ * "nothing stageable", it would send the caller on to discard everything, notes included. */
434
430
  export async function commitPathsAndDiscardRest(wt, message, paths) {
435
431
  if (paths.length === 0)
436
432
  return null;
437
- const added = await gitTry(wt, "add", "--", ...paths);
438
- if (added === null)
433
+ try {
434
+ await git(wt, "add", "--", ...paths);
435
+ }
436
+ catch (err) {
437
+ if (gitCouldNotStart(err))
438
+ throw err;
439
439
  return null; // Pathspec matched nothing — treat as nothing stageable.
440
- const staged = await gitTry(wt, "diff", "--cached", "--name-only");
440
+ }
441
+ const staged = await git(wt, "diff", "--cached", "--name-only");
441
442
  if (!staged)
442
443
  return null; // Nothing actually changed under those paths.
443
- await git(wt, ...COMMIT_IDENT, "commit", "-m", message);
444
+ await git(wt, ...(await committerIdentArgs(wt)), "commit", ...commitAuthorArgs(), "-m", message);
444
445
  await git(wt, "reset", "--hard", "HEAD");
445
446
  await git(wt, "clean", "-fd");
446
447
  return headOf(wt, "HEAD");
@@ -473,9 +474,10 @@ export async function aheadOfMain(wt, mainBranch) {
473
474
  }
474
475
  /** The patch-id of the change `head` makes on top of `base`: `git diff` over `base...head` (from
475
476
  * their merge-base, the same range aheadOfMainDiff hands the reviewer) piped into
476
- * `git patch-id`. Two shas carrying the same diff — an approved head and its clean rebase onto
477
- * a moved main — share it, since patch-id ignores hunk line numbers; a rebase that changed any
478
- * line of the patch, context included, does not. `--verbatim` over `--stable`: `--stable` also
477
+ * `git patch-id`. The diff is `-U0`: the change's own lines only, no context. Two shas carrying
478
+ * the same change — an approved head and its rebase onto a main that moved next to a hunk —
479
+ * share it, since patch-id ignores hunk line numbers; a rebase that changed any added or removed
480
+ * line does not. `--verbatim` over `--stable`: `--stable` also
479
481
  * strips whitespace, so a whitespace-only difference would reuse an approval. `--binary` puts
480
482
  * binary content in the hash (without it every binary change reads "Binary files differ"), and
481
483
  * `--no-ext-diff --no-textconv` keep user diff config out of it. The raw stdout goes to
@@ -486,7 +488,7 @@ export async function patchId(wt, base, head) {
486
488
  try {
487
489
  // Both children run through execGitBounded, so a hung diff or patch-id is SIGTERMed group-wide
488
490
  // and SIGKILLed after the grace instead of stalling merge's approval check.
489
- const { stdout: diff } = await execGitBounded(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--binary", `${base}...${head}`], { cwd: wt, timeoutMs: GIT_TIMEOUT_MS });
491
+ const { stdout: diff } = await execGitBounded(["diff", "--no-color", "--no-ext-diff", "--no-textconv", "--binary", "-U0", `${base}...${head}`], { cwd: wt, timeoutMs: GIT_TIMEOUT_MS });
490
492
  if (diff === "")
491
493
  return null;
492
494
  const run = execGitBounded(["patch-id", "--verbatim"], { cwd: wt, timeoutMs: GIT_TIMEOUT_MS });
@@ -514,6 +516,6 @@ export async function foldCommitsSince(wt, base) {
514
516
  /** Stage and commit everything in the worktree. Returns the new commit hash. */
515
517
  export async function commitAll(wt, message) {
516
518
  await git(wt, "add", "-A");
517
- await git(wt, ...COMMIT_IDENT, "commit", "-m", message);
519
+ await git(wt, ...(await committerIdentArgs(wt)), "commit", ...commitAuthorArgs(), "-m", message);
518
520
  return headOf(wt, "HEAD");
519
521
  }
@@ -0,0 +1,253 @@
1
+ /** Saving what a pooled slot still holds before it is reset for another lease, and handing a
2
+ * role's unfinished work back to its next run. A run the harness could not clean up after
3
+ * (killed, or its own git calls failing, as every spawn did during the 2026-10-21 process-table
4
+ * wedge) can release its slot with uncommitted edits still in it. The next lease resets the slot
5
+ * to main, which used to fail on those edits for every role it was handed ("Your local changes …
6
+ * would be overwritten by checkout"), and a role branch's forced checkout dropped them without a
7
+ * trace. The reset now always succeeds (worktree.ts checks out with --force), so this is the step
8
+ * that keeps the work: the edits become a patch and a warning names the slot, the role that left
9
+ * them and the file.
10
+ *
11
+ * A patch whose run gave its slot up unsettled (the slot record's `unsettledRun`: an exception
12
+ * escaped the run, or its process died holding the lease) waits under salvagePendingDir until
13
+ * that role's next run, which restores it into its fresh checkout (restoreSalvagedWork) and
14
+ * runs as a handoff over it. A patch the role's branch has moved under still goes back: files
15
+ * that conflict keep their markers, and hunks git cannot place at all reach the run as text to
16
+ * apply by hand. Every other patch is edits a settled run chose to discard, or a verification's
17
+ * scratch output: those stay directly under salvageDir for the operator. Session retention
18
+ * prunes both kinds (src/orchestrator/retention.ts).
19
+ *
20
+ * Neither half throws work away on a git that could not start (gitCouldNotStart, the wedge's
21
+ * EAGAIN): the salvage leaves the slot as it is and fails the lease, and the restore leaves the
22
+ * patch waiting, so the next attempt finds everything where it was. */
23
+ import fs from "node:fs";
24
+ import os from "node:os";
25
+ import path from "node:path";
26
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
27
+ import { writeTextAtomic } from "../files/files.js";
28
+ import { conflictedFiles } from "../merge/merge-git.js";
29
+ import { salvageDir, salvagePendingDir } from "../paths.js";
30
+ import { errorMessage } from "../text/text.js";
31
+ import { git, gitCouldNotStart, gitTry, runGit } from "./git-run.js";
32
+ import { isUsableWorktree } from "./worktree.js";
33
+ /** The patch file's name: a sortable local timestamp, the slot's directory name and the role that
34
+ * last held it, so a listing reads in order and names whose work each file is. */
35
+ function salvageFileName(now, slotDir, leftBy) {
36
+ const pad = (n) => String(n).padStart(2, "0");
37
+ const stamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}` +
38
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
39
+ return `${stamp}-${path.basename(slotDir)}-${leftBy ?? "unknown"}.patch`;
40
+ }
41
+ /** Save the uncommitted changes in `dir` (modified, deleted and untracked files, ignored ones
42
+ * excepted) as a patch against its HEAD, and log a warning naming the slot, the role that left
43
+ * them and the file, before `forRole`'s lease resets it. The patch waits for `recoverFor`'s next
44
+ * run under salvagePendingDir when it is set, and is kept directly under salvageDir otherwise.
45
+ * Returns the patch path, or null when there was nothing to save or `dir` is not a worktree git
46
+ * can read (a slot about to be recreated). The patch is built through a temporary index, so the
47
+ * slot's own index is untouched. Throws only when git could not start (gitCouldNotStart), which
48
+ * fails the lease with the slot untouched: the lease that finds git running again saves the
49
+ * edits. Any other failure to save is logged as a warning that the edits are being discarded
50
+ * unsaved, and the reset goes ahead, since a slot that no lease can reset would fail every role
51
+ * the pool hands it. */
52
+ export async function salvageSlotLeftovers(root, dir, { leftBy, recoverFor, forRole }, now = new Date()) {
53
+ const slot = path.basename(dir);
54
+ let status;
55
+ try {
56
+ status = await git(dir, "status", "--porcelain", "--untracked-files=all");
57
+ }
58
+ catch (err) {
59
+ if (gitCouldNotStart(err) && (await isUsableWorktree(dir)))
60
+ throw err;
61
+ return null;
62
+ }
63
+ if (status === "")
64
+ return null;
65
+ const files = status.split("\n").filter((line) => line.trim() !== "").length;
66
+ const by = leftBy ?? "an unknown role";
67
+ let tmp;
68
+ try {
69
+ tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-salvage-"));
70
+ const env = { GIT_INDEX_FILE: path.join(tmp, "index") };
71
+ await runGit(dir, ["read-tree", "HEAD"], env);
72
+ await runGit(dir, ["add", "-A"], env);
73
+ const patch = await runGit(dir, ["diff", "--cached", "--binary", "HEAD"], env);
74
+ if (patch === "")
75
+ return null;
76
+ const base = await git(dir, "rev-parse", "HEAD");
77
+ const home = recoverFor === null ? salvageDir(root) : salvagePendingDir(root, recoverFor);
78
+ const file = path.join(home, salvageFileName(now, dir, leftBy));
79
+ fs.mkdirSync(home, { recursive: true });
80
+ // Atomic: the patch is the only copy left once the lease resets the workspace, so a kill
81
+ // mid-write must never leave a truncated .patch that a later restore reads and partially
82
+ // applies. Written to a temp name and renamed, a reader (restoreSalvagedWork) sees either
83
+ // no file or the complete patch, never a prefix.
84
+ writeTextAtomic(file, `# ${files} uncommitted file(s) ${by} left in workspace ${slot}, saved before ${forRole} reused it\n` +
85
+ `# base ${base}; apply with: git apply --index <this file> on a checkout of that commit\n` +
86
+ `${patch}\n`);
87
+ const saved = path.relative(root, file);
88
+ warnEventBestEffort(root, TUMWATER_SOURCE, recoverFor === null
89
+ ? `workspace ${slot} still held ${files} uncommitted file(s) ${by} left behind; saved them to ${saved} and reset it for ${forRole}`
90
+ : `workspace ${slot} still held ${files} uncommitted file(s) from ${recoverFor}'s unfinished run; saved them to ${saved} for its next run and reset it for ${forRole}`);
91
+ return file;
92
+ }
93
+ catch (err) {
94
+ if (gitCouldNotStart(err))
95
+ throw err;
96
+ warnEventBestEffort(root, TUMWATER_SOURCE, `workspace ${slot} still held ${files} uncommitted file(s) ${by} left behind, and saving them failed (${errorMessage(err)}); resetting it for ${forRole} discards them`);
97
+ return null;
98
+ }
99
+ finally {
100
+ if (tmp !== undefined)
101
+ fs.rmSync(tmp, { recursive: true, force: true });
102
+ }
103
+ }
104
+ /** The patches waiting for `role`'s next run, newest first (the timestamped names sort in time
105
+ * order). Empty when none wait or the directory cannot be read. */
106
+ function pendingPatches(root, role) {
107
+ const dir = salvagePendingDir(root, role);
108
+ let names;
109
+ try {
110
+ names = fs.readdirSync(dir);
111
+ }
112
+ catch {
113
+ return [];
114
+ }
115
+ return names
116
+ .filter((name) => name.endsWith(".patch"))
117
+ .sort()
118
+ .reverse()
119
+ .map((name) => path.join(dir, name));
120
+ }
121
+ /** Move a pending patch out to salvageDir, where it is kept for the operator until retention
122
+ * prunes it, and return its new path (the old one when the move fails, so the warning that
123
+ * names it still points at the file). */
124
+ function settlePatch(root, file) {
125
+ const kept = path.join(salvageDir(root), path.basename(file));
126
+ try {
127
+ fs.renameSync(file, kept);
128
+ return kept;
129
+ }
130
+ catch {
131
+ return file;
132
+ }
133
+ }
134
+ /** Restore the newest patch of `role`'s unfinished work waiting under salvagePendingDir into `dir`,
135
+ * the fresh checkout of the role's branch its run is about to work in, as uncommitted edits. The
136
+ * patch applies exactly when the checkout is still at the patch's base. When the branch has moved,
137
+ * each file goes back on its own (placeSalvagedFile): by a three-way merge, which may leave
138
+ * conflict markers, else hunk by hunk, with the hunks that fit nowhere returned as text. The
139
+ * result is unstaged, so the run finds the worktree an interrupted run would have kept. The
140
+ * patch, and any older one it supersedes, then moves out to salvageDir. Returns null when
141
+ * nothing was pending. When git could not start or the patch could not be read, the checkout is
142
+ * put back as it was, the patch stays waiting for the role's next run, and a warning says so.
143
+ * Never throws. */
144
+ export async function restoreSalvagedWork(root, role, dir) {
145
+ const [newest, ...older] = pendingPatches(root, role);
146
+ if (newest === undefined)
147
+ return null;
148
+ let placed;
149
+ try {
150
+ placed = await applySalvage(dir, newest);
151
+ }
152
+ catch (err) {
153
+ await gitTry(dir, "reset", "-q", "--hard");
154
+ await gitTry(dir, "clean", "-fdq");
155
+ warnEventBestEffort(root, role, `could not restore the uncommitted work an earlier run left unfinished (${errorMessage(err)}); it stays waiting at ${path.relative(root, newest)} for the next run`);
156
+ return null;
157
+ }
158
+ for (const file of older)
159
+ settlePatch(root, file);
160
+ return { patch: settlePatch(root, newest), ...placed };
161
+ }
162
+ /** Apply `patch` into the clean checkout `dir`: exactly when it fits, else file by file. Throws
163
+ * only when git could not start or the patch cannot be read. */
164
+ async function applySalvage(dir, patch) {
165
+ try {
166
+ await git(dir, "apply", "--index", patch);
167
+ await git(dir, "reset", "-q");
168
+ return { conflicted: [], unplaced: [] };
169
+ }
170
+ catch (err) {
171
+ if (gitCouldNotStart(err))
172
+ throw err;
173
+ }
174
+ const unplaced = [];
175
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-restore-"));
176
+ try {
177
+ const sections = fs.readFileSync(patch, "utf8").split(/^(?=diff --git )/m).filter((p) => p.startsWith("diff --git "));
178
+ for (const [i, section] of sections.entries()) {
179
+ const file = path.join(tmp, `${i}.patch`);
180
+ fs.writeFileSync(file, section);
181
+ unplaced.push(...(await placeSalvagedFile(dir, file, section)));
182
+ }
183
+ }
184
+ finally {
185
+ fs.rmSync(tmp, { recursive: true, force: true });
186
+ }
187
+ // Read before the reset drops the unmerged index entries; the markers stay in the files.
188
+ const conflicted = await conflictedFiles(dir);
189
+ await git(dir, "reset", "-q");
190
+ return { conflicted, unplaced };
191
+ }
192
+ /** The paths one file's section of a patch touches (`git apply --numstat -z`: a rename names its
193
+ * new path), without decoding git's quoting by hand. */
194
+ async function sectionPaths(dir, file) {
195
+ const fields = (await git(dir, "apply", "--numstat", "-z", file)).split("\0");
196
+ const paths = [];
197
+ for (let i = 0; i < fields.length; i++) {
198
+ const m = /^[-\d]+\t[-\d]+\t(.*)$/s.exec(fields[i]);
199
+ if (m === null)
200
+ continue;
201
+ if (m[1] !== "")
202
+ paths.push(m[1]);
203
+ else if (fields[i + 2] !== undefined) {
204
+ paths.push(fields[i + 2]); // a rename: the empty path is followed by its old and new path
205
+ i += 2;
206
+ }
207
+ }
208
+ return paths;
209
+ }
210
+ /** Put one file's section of a salvaged patch back into `dir`: by a three-way merge, which leaves
211
+ * conflict markers where both sides changed the same lines, else by direct application hunk by
212
+ * hunk. Returns the hunks that fit nowhere (git's `.rej` text, read and removed so it is never
213
+ * committed), or the whole section when git could place none of it; empty when all of it went
214
+ * back. */
215
+ async function placeSalvagedFile(dir, file, section) {
216
+ const paths = await sectionPaths(dir, file).catch((err) => {
217
+ if (gitCouldNotStart(err))
218
+ throw err;
219
+ return [];
220
+ });
221
+ const label = paths.join(", ") || (/^diff --git a\/(.*?) b\//.exec(section)?.[1] ?? "a file");
222
+ if (await applied(dir, ["apply", "--3way", file]))
223
+ return [];
224
+ if (paths.length > 0 && (await git(dir, "--literal-pathspecs", "ls-files", "-u", "--", ...paths)) !== "")
225
+ return [];
226
+ // A deletion whose file the branch has already removed is done; there is nothing to hand back.
227
+ if (/^deleted file mode /m.test(section) && paths.length > 0 && paths.every((p) => !fs.existsSync(path.join(dir, p)))) {
228
+ return [];
229
+ }
230
+ if (await applied(dir, ["apply", "--reject", file]))
231
+ return [];
232
+ const rejected = [];
233
+ for (const p of paths) {
234
+ const rej = path.join(dir, `${p}.rej`);
235
+ if (!fs.existsSync(rej))
236
+ continue;
237
+ rejected.push({ path: p, diff: fs.readFileSync(rej, "utf8") });
238
+ fs.rmSync(rej, { force: true });
239
+ }
240
+ return rejected.length > 0 ? rejected : [{ path: label, diff: section }];
241
+ }
242
+ /** Run one `git apply` and say whether it succeeded; a git that could not start throws. */
243
+ async function applied(dir, args) {
244
+ try {
245
+ await git(dir, ...args);
246
+ return true;
247
+ }
248
+ catch (err) {
249
+ if (gitCouldNotStart(err))
250
+ throw err;
251
+ return false;
252
+ }
253
+ }
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** The persisted slot layout of the worktree pool (plans/worktree-pool.md): which
3
2
  * `.tumwater/worktrees/_slot-<n>` checkouts exist, who leases each, and which role each is pinned
4
3
  * for. Kept in its own module, apart from the pool's lease machinery, so the readers that run in
@@ -26,8 +25,8 @@ function withSlotsLock(root, fn) {
26
25
  * entries follows readJsonFile's no-data policy for the file as a whole: the good slots stay,
27
26
  * the reader and the operator view degrade instead of crashing. Each readable entry's known
28
27
  * fields are defaulted by readSlotRecord (a `lease` that is not a complete claim reads as no
29
- * lease, and pinnedFor/lastRole/lastReleasedAt as null), while any field it does not know
30
- * rides along so an update's read-modify-write still round-trips it. */
28
+ * lease, and pinnedFor/lastRole/lastReleasedAt/unsettledRun as null), while any field it does
29
+ * not know rides along so an update's read-modify-write still round-trips it. */
31
30
  export function readSlotsState(root) {
32
31
  const state = readJsonFile(slotsStatePath(root));
33
32
  if (state === null || !Array.isArray(state.slots))
@@ -46,7 +45,7 @@ export function readSlotsState(root) {
46
45
  function isSlotLease(value) {
47
46
  return (isJsonObject(value) &&
48
47
  typeof value.role === "string" &&
49
- (value.purpose === "run" || value.purpose === "verify" || value.purpose === "tick" || value.purpose === "vet") && // vocabulary-ok: legacy value read from old files
48
+ (value.purpose === "run" || value.purpose === "verify" || value.purpose === "verify-check" || value.purpose === "tick" || value.purpose === "vet") && // vocabulary-ok: legacy value read from old files
50
49
  nonNegativeNumber(value.since, null) !== null &&
51
50
  nonNegativeNumber(value.pid, null) !== null);
52
51
  }
@@ -78,6 +77,7 @@ function readSlotRecord(slot) {
78
77
  pinnedAt: slot.pinnedAt === undefined ? undefined : finiteNumber(slot.pinnedAt, null),
79
78
  lastRole: typeof slot.lastRole === "string" ? canonicalRoleId(slot.lastRole) : null,
80
79
  lastReleasedAt: finiteNumber(slot.lastReleasedAt, null),
80
+ unsettledRun: typeof slot.unsettledRun === "string" ? canonicalRoleId(slot.unsettledRun) : null,
81
81
  };
82
82
  }
83
83
  function writeSlotsStateUnlocked(root, state) {
@@ -102,9 +102,9 @@ export function slotForDir(root, dir) {
102
102
  }
103
103
  /** The directory holding a role's checkout, resolved from other processes' point of view
104
104
  * (plans/worktree-pool.md, "Readers find a role's checkout"): a slot listed as leased by the role
105
- * for a tick, or pinned for the role, wins; otherwise the legacy `worktreePath` when it is a
105
+ * for a run, or pinned for the role, wins; otherwise the legacy `worktreePath` when it is a
106
106
  * usable worktree; otherwise null (the reader's "absent" state). Changes no behavior until role
107
- * ticks lease slots (part 4/5), because nothing writes those leases yet. */
107
+ * runs lease slots (part 4/5), because nothing writes those leases yet. */
108
108
  export async function roleWorktreeDir(root, role) {
109
109
  const slots = readSlotsState(root).slots;
110
110
  const leased = slots.find((slot) => slot.lease?.role === role && slot.lease.purpose === "run");