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
@@ -0,0 +1,148 @@
1
+ /** The write guard on a run's file-editing tools: pi's `edit`/`write` (src/pi-extension/
2
+ * write-guard.ts), claude's `Edit`/`Write` (src/agent/claude/write-guard-hook.ts) and OpenCode's
3
+ * `edit` permission (src/agent/opencode/write-guard-plugin/index.ts) refuse a write that would
4
+ * land under the team root but outside the run's own checkout.
5
+ *
6
+ * Why a guard and not only the prompt rule: every run's checkout sits inside the primary checkout
7
+ * (`<root>/.tumwater/worktrees/<name>`). An absolute path that drops that infix, or a relative one
8
+ * that climbs out with `../../..`, names a real file with the same contents in the primary
9
+ * checkout or in another run's slot, so the edit succeeds and nothing says it went astray. The run
10
+ * then commits its unchanged checkout. runner-run.ts's stray-write warning only notices afterwards
11
+ * and cannot say which run wrote. Refusing at the tool lets the model retry at the path the
12
+ * refusal names.
13
+ *
14
+ * Writes outside the team root (scratch files under `$TMPDIR`, `/tmp`) are not this guard's
15
+ * business and pass, as does every write from a run that is not in a team worktree. Bash is not
16
+ * covered: a shell command's writes cannot be read from its text. The codex backend is not
17
+ * covered yet.
18
+ *
19
+ * Pure apart from realpath lookups, so the rule is testable without pi or claude. */
20
+ import fs from "node:fs";
21
+ import os from "node:os";
22
+ import path from "node:path";
23
+ import { roleNotesPath, STATE_DIR, worktreesDir } from "../paths.js";
24
+ /** True when `p` is `dir` or inside it. Both are absolute and normalized. */
25
+ function isWithin(p, dir) {
26
+ const rel = path.relative(dir, p);
27
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
28
+ }
29
+ /** `p` with its longest existing prefix resolved through realpath, so a symlink, a `/tmp` that is
30
+ * `/private/tmp`, or a path typed in the wrong case on a case-insensitive disk compares as the
31
+ * file it reaches. The part that does not exist yet (a file a write would create) is appended
32
+ * unchanged. */
33
+ function realish(p) {
34
+ let existing = p;
35
+ const rest = [];
36
+ for (;;) {
37
+ try {
38
+ return path.join(fs.realpathSync.native(existing), ...rest);
39
+ }
40
+ catch {
41
+ const parent = path.dirname(existing);
42
+ if (parent === existing)
43
+ return p;
44
+ rest.unshift(path.basename(existing));
45
+ existing = parent;
46
+ }
47
+ }
48
+ }
49
+ /** A tool's path argument as pi's tools resolve it (pi `resolveToCwd`): a leading `@` dropped,
50
+ * `~` expanded to the home directory, and a relative path taken from `base`. */
51
+ function resolveToolPath(target, base) {
52
+ let p = target.startsWith("@") ? target.slice(1) : target;
53
+ if (p === "~")
54
+ p = os.homedir();
55
+ else if (p.startsWith("~/"))
56
+ p = path.join(os.homedir(), p.slice(2));
57
+ return path.resolve(base, p);
58
+ }
59
+ /** The team checkout `dir` is in, or null when it is in none. Read from the path's shape: the
60
+ * innermost `<root>/.tumwater/worktrees/<name>` that holds `dir`. The nearest ancestor holding a
61
+ * `.tumwater` directory would not do, because a run's own tests can leave one inside its checkout
62
+ * (one sat in `_slot-3` on 2026-10-23), and then the primary checkout would read as outside the
63
+ * team and every write to it would pass. */
64
+ function runCheckoutOf(dir) {
65
+ const real = realish(path.resolve(dir));
66
+ const marker = `${path.sep}${STATE_DIR}${path.sep}worktrees${path.sep}`;
67
+ const at = real.lastIndexOf(marker);
68
+ if (at < 0)
69
+ return null;
70
+ const root = real.slice(0, at) || path.sep;
71
+ const name = real.slice(at + marker.length).split(path.sep)[0];
72
+ if (!name)
73
+ return null;
74
+ return { root, checkout: path.join(worktreesDir(root), name) };
75
+ }
76
+ /** `s` single-quoted for sh (the `'…'` quoting every hook command in both backends embeds a path
77
+ * in). The one home of a POSIX-shell single-quote, shared by the claude and codex write-guard
78
+ * settings builders (src/agent/claude/claude-write-guard.ts, src/agent/codex/codex-write-guard.ts)
79
+ * so the escape cannot drift between the backends. A path with a single quote becomes
80
+ * `'…'\''…'`, which sh assembles back into the original. */
81
+ export function shellQuote(s) {
82
+ return `'${s.replace(/'/g, `'\\''`)}'`;
83
+ }
84
+ /** The refusal text for a write to `target` from a run whose checkout holds `runDir`, or null when
85
+ * the write may go ahead. A relative `target` resolves from `resolveFrom` (default `runDir`): pi
86
+ * resolves from the run's directory, while claude resolves from its shell's current directory,
87
+ * which the hook receives separately. The text names the corrected path inside the checkout
88
+ * whenever the stray path has a twin there. */
89
+ export function strayWriteRefusal(target, runDir, resolveFrom = runDir) {
90
+ const run = runCheckoutOf(runDir);
91
+ if (!run)
92
+ return null;
93
+ const abs = realish(resolveToolPath(target, resolveFrom));
94
+ if (isWithin(abs, run.checkout) || !isWithin(abs, run.root))
95
+ return null;
96
+ const { root, checkout } = run;
97
+ let where;
98
+ let rest = [];
99
+ let instead = null;
100
+ if (isWithin(abs, worktreesDir(root))) {
101
+ const [other, ...inside] = path.relative(worktreesDir(root), abs).split(path.sep);
102
+ where = `another checkout (${other ?? ""})`;
103
+ rest = inside;
104
+ }
105
+ else if (isWithin(abs, path.join(root, STATE_DIR))) {
106
+ where = "tumwater's own state";
107
+ if (isWithin(abs, path.dirname(roleNotesPath(root, "role")))) {
108
+ instead = "Save role notes with `tumwater-notes write` instead.";
109
+ }
110
+ }
111
+ else {
112
+ where = "the primary checkout";
113
+ rest = path.relative(root, abs).split(path.sep).filter(Boolean);
114
+ // The worktree borrows the primary's dependencies; a twin path would create a node_modules
115
+ // inside the worktree and break that borrowing.
116
+ if (rest[0] === "node_modules")
117
+ rest = [];
118
+ }
119
+ if (rest.length > 0) {
120
+ instead = `Write ${path.join(checkout, ...rest)} instead: your worktree has the same files.`;
121
+ }
122
+ return `Refused: ${abs} is in ${where}, outside this run's worktree ${checkout}. Only changes `
123
+ + `inside the worktree are committed, so this write would be lost. `
124
+ + (instead ?? "Leave it alone and work only inside the worktree.");
125
+ }
126
+ /** The stdin-driven guard-hook driver shared by the claude and codex PreToolUse hooks
127
+ * (src/agent/claude/write-guard-hook.ts, src/agent/codex/write-guard-hook.ts). Each hook runs as
128
+ * `node write-guard-hook.js` with the backend's hook payload JSON on stdin; this reads it in one
129
+ * place, asks `computeRefusal` for a refusal string (null lets the edit/patch run), and prints the
130
+ * `deny` decision only when there is a refusal. Any failure — unreadable stdin, malformed JSON, a
131
+ * throw — reads as "no refusal" (null), so a broken hook cannot stop every edit of a run; the
132
+ * stray-write warning in runner-run.ts still sees a write that slips through. The deny payload's
133
+ * shape (`hookSpecificOutput` with a `PreToolUse` deny and the refusal as its reason) lives only
134
+ * here so the two hooks cannot drift it apart. */
135
+ export function runGuardHook(computeRefusal) {
136
+ let refusal = null;
137
+ try {
138
+ refusal = computeRefusal(JSON.parse(fs.readFileSync(0, "utf8")));
139
+ }
140
+ catch {
141
+ refusal = null;
142
+ }
143
+ if (refusal !== null) {
144
+ process.stdout.write(`${JSON.stringify({
145
+ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: refusal },
146
+ })}\n`);
147
+ }
148
+ }
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** The backlog archive (plans/backlog-archive.md): finished entries leave the live backlog files.
3
2
  *
4
3
  * Every role run reads PLANS.md and BUGS.md to find work, and by 2026-10-18 nine tenths of both
@@ -16,21 +15,25 @@
16
15
  * - archiveFinishedOnMain, at team start and from `tumwater archive`: the whole live section —
17
16
  * legacy entries, compressed one-line records, a hand commit's move — goes to the archive in
18
17
  * one commit on main.
18
+ * Neither archives an entry twice: one whose heading the archive already holds is dropped from
19
+ * the live section when its text matches, and left there when it does not (archiveFinishedText).
19
20
  * Readers of finished entries read the live section first, then the archive (backlog.ts). */
20
21
  import fs from "node:fs";
21
22
  import path from "node:path";
22
- import { withLock, withStateLockAsync } from "../concurrency/lock.js";
23
+ import { withLock, withPrimaryWorktreeLockAsync } from "../concurrency/lock.js";
23
24
  import { loadConfigSafe } from "../config/config.js";
24
25
  import { logEventBestEffort, TUMWATER_SOURCE, warnEventBestEffort } from "../events/events.js";
25
- import { readTextOrNull, writeTextAtomic } from "../files/files.js";
26
+ import { readTextIfExists, readTextOrNull, writeTextAtomic } from "../files/files.js";
26
27
  import { changeBaseRev, currentBranch, fileContentAt, headOf, targetBranch } from "../git/git.js";
27
28
  import { stampedSubject } from "../git/commit-message.js";
28
- import { COMMIT_IDENT, git, gitTry } from "../git/git-run.js";
29
- import { mergeLockDir, primaryWorktreeLockPath } from "../paths.js";
29
+ import { commitAuthorArgs, committerIdentArgs, git, gitTry } from "../git/git-run.js";
30
+ import { mergeLockDir } from "../paths.js";
30
31
  import { plural } from "../text/phrases.js";
31
- import { collapseWhitespace, errorMessage } from "../text/text.js";
32
- import { FINISHED_ARCHIVES, fenceTracker, fencedHeadingTitle } from "./backlog-md.js";
33
- import { actionableEntryRanges } from "./backlog-structure.js";
32
+ import { collapseWhitespace, errorFirstLine, errorMessage } from "../text/text.js";
33
+ import { FINISHED_ARCHIVES, fenceTracker, fencedHeadingTitle, parseEntryDetails } from "./backlog-md.js";
34
+ import { actionableEntryRanges, strandedPlanEntries } from "./backlog-structure.js";
35
+ import { appendBugEntryLocked } from "./backlog-write.js";
36
+ import { formatDate } from "../text/datetime.js";
34
37
  /** The placeholder a role leaves under an emptied section: never archived content. */
35
38
  const PLACEHOLDER = /^_None yet\._$/;
36
39
  /** The text a new archive file starts with: a title, how to read the file, and the section
@@ -94,9 +97,46 @@ function withSectionBody(lines, span, body) {
94
97
  function entriesIn(md, span, section) {
95
98
  return actionableEntryRanges(md, section).filter((e) => e.start - 1 > span.heading && e.start - 1 < span.end);
96
99
  }
100
+ /** An entry's text for the already-archived comparison: its lines without the blank lines around
101
+ * them, each without trailing whitespace. */
102
+ function entryText(lines, e) {
103
+ return trimBlank(lines.slice(e.start - 1, e.end)).map((l) => l.trimEnd()).join("\n");
104
+ }
105
+ /** The fate of each of `selected` (entries of `lines`), in order, against the archive's entries:
106
+ * every text the archive holds under each heading key. */
107
+ function entryFates(lines, selected, archiveMd, archiveSpan, section) {
108
+ if (selected.length === 0)
109
+ return [];
110
+ const wanted = new Set(selected.map((e) => archiveKey(e.title)));
111
+ const archiveLines = archiveMd.split("\n");
112
+ const archived = new Map();
113
+ for (const e of entriesIn(archiveMd, archiveSpan, section)) {
114
+ const key = archiveKey(e.title);
115
+ if (wanted.has(key))
116
+ archived.set(key, [...(archived.get(key) ?? []), entryText(archiveLines, e)]);
117
+ }
118
+ return selected.map((e) => {
119
+ const held = archived.get(archiveKey(e.title));
120
+ if (held === undefined)
121
+ return "move";
122
+ return held.includes(entryText(lines, e)) ? "drop" : "stay";
123
+ });
124
+ }
125
+ /** The trimmed lines of `entries`, one blank line between entries. */
126
+ function entryBlocks(lines, entries) {
127
+ const out = [];
128
+ for (const e of entries) {
129
+ if (out.length > 0)
130
+ out.push("");
131
+ out.push(...trimBlank(lines.slice(e.start - 1, e.end)));
132
+ }
133
+ return out;
134
+ }
97
135
  /** Move the selected part of `liveMd`'s finished section to the top of `archiveMd`'s (a new
98
- * archive starts from archiveTemplate), keeping the moved lines' order. null when nothing moves,
99
- * when either file lacks the section heading, or — for the commit-time sweep — when there is no
136
+ * archive starts from archiveTemplate), keeping the moved lines' order. A selected entry whose
137
+ * heading the archive already holds never moves (EntryFate): with the same text it is dropped
138
+ * from the live section, with other text it stays there. null when nothing moves or drops, when
139
+ * either file lacks the section heading, or — for the commit-time sweep — when there is no
100
140
  * archive yet and the live section holds other content: a repository that has not been archived
101
141
  * keeps today's layout until `tumwater archive` (or the next team start) moves its history, so an
102
142
  * archive never sits above older live entries. Pure. */
@@ -106,52 +146,71 @@ export function archiveFinishedText(liveMd, archiveMd, spec, select) {
106
146
  if (span === null)
107
147
  return null;
108
148
  const entries = entriesIn(liveMd, span, spec.section);
149
+ const selected = "keep" in select
150
+ ? entries.slice(Math.max(0, select.keep))
151
+ : entries.filter((e) => !select.baseKeys.has(archiveKey(e.title)) && !(select.park?.has(archiveKey(e.title)) ?? false));
152
+ if (!("keep" in select) && selected.length === 0)
153
+ return null;
154
+ const archiveText = archiveMd ?? archiveTemplate(spec);
155
+ const archiveLines = archiveText.split("\n");
156
+ const archiveSpan = sectionSpan(archiveLines, spec.section);
157
+ if (archiveSpan === null)
158
+ return null;
159
+ const fates = entryFates(lines, selected, archiveText, archiveSpan, spec.section);
160
+ const withFate = (fate) => selected.filter((_, i) => fates[i] === fate);
161
+ // The 0-based live lines of the entries that leave the live section without moving.
162
+ const droppedLine = new Set();
163
+ for (const e of withFate("drop"))
164
+ for (let i = e.start - 1; i < e.end; i++)
165
+ droppedLine.add(i);
109
166
  let moved;
110
167
  let kept;
111
- let titles;
112
168
  if ("keep" in select) {
113
169
  const from = select.keep <= 0 ? span.heading + 1 : (entries[select.keep]?.start ?? 0) - 1;
114
170
  if (from < 0)
115
171
  return null; // no entry past the kept ones
116
- // A `_None yet._` placeholder stays in the live section, never in the archive.
117
- const tail = lines.slice(from, span.end);
172
+ // The tail moves but for the dropped and staying entries; a `_None yet._` placeholder stays
173
+ // in the live section, never in the archive. Staying entries follow the kept ones.
174
+ const stayLine = new Set();
175
+ for (const e of withFate("stay"))
176
+ for (let i = e.start - 1; i < e.end; i++)
177
+ stayLine.add(i);
178
+ const tail = [];
179
+ for (let i = from; i < span.end; i++)
180
+ if (!droppedLine.has(i) && !stayLine.has(i))
181
+ tail.push(lines[i] ?? "");
118
182
  moved = trimBlank(tail.filter((l) => !PLACEHOLDER.test(l.trim())));
119
- kept = select.keep <= 0 ? [] : trimBlank(lines.slice(span.heading + 1, from));
183
+ const staying = entryBlocks(lines, withFate("stay"));
184
+ const head = select.keep <= 0 ? [] : trimBlank(lines.slice(span.heading + 1, from));
185
+ kept = head.length > 0 && staying.length > 0 ? [...head, "", ...staying] : [...head, ...staying];
120
186
  if (kept.length === 0)
121
187
  kept = tail.filter((l) => PLACEHOLDER.test(l.trim())).slice(0, 1);
122
- titles = entries.slice(Math.max(0, select.keep)).map((e) => e.title);
123
188
  }
124
189
  else {
125
- const fresh = entries.filter((e) => !select.baseKeys.has(archiveKey(e.title)));
126
- if (fresh.length === 0)
127
- return null;
128
- const inFresh = (i) => fresh.some((e) => i >= e.start - 1 && i < e.end);
190
+ const leaves = new Set(droppedLine);
191
+ for (const e of withFate("move"))
192
+ for (let i = e.start - 1; i < e.end; i++)
193
+ leaves.add(i);
129
194
  kept = [];
130
195
  for (let i = span.heading + 1; i < span.end; i++)
131
- if (!inFresh(i))
196
+ if (!leaves.has(i))
132
197
  kept.push(lines[i] ?? "");
133
198
  kept = trimBlank(kept);
134
199
  if (archiveMd === null && kept.some((l) => l.trim() !== "" && !PLACEHOLDER.test(l.trim())))
135
200
  return null;
136
- moved = [];
137
- for (const e of fresh) {
138
- if (moved.length > 0)
139
- moved.push("");
140
- moved.push(...trimBlank(lines.slice(e.start - 1, e.end)));
141
- }
142
- titles = fresh.map((e) => e.title);
201
+ moved = entryBlocks(lines, withFate("move"));
143
202
  }
144
- if (moved.length === 0)
145
- return null;
146
- const archiveLines = (archiveMd ?? archiveTemplate(spec)).split("\n");
147
- const archiveSpan = sectionSpan(archiveLines, spec.section);
148
- if (archiveSpan === null)
203
+ const dropped = withFate("drop").map((e) => e.title);
204
+ if (moved.length === 0 && dropped.length === 0)
149
205
  return null;
150
206
  const existing = trimBlank(archiveLines.slice(archiveSpan.heading + 1, archiveSpan.end));
151
207
  return {
152
208
  live: withSectionBody(lines, span, kept),
153
- archive: withSectionBody(archiveLines, archiveSpan, existing.length > 0 ? [...moved, "", ...existing] : moved),
154
- titles,
209
+ archive: moved.length === 0
210
+ ? archiveText
211
+ : withSectionBody(archiveLines, archiveSpan, existing.length > 0 ? [...moved, "", ...existing] : moved),
212
+ titles: withFate("move").map((e) => e.title),
213
+ dropped,
155
214
  };
156
215
  }
157
216
  /** Write one move: the archive first, so a crash in between leaves the entries in both files —
@@ -163,22 +222,38 @@ function writeMove(root, spec, move) {
163
222
  /** The commit-time sweep: move the entries this change added to a live finished section of the
164
223
  * worktree `wt` — those whose heading its merge-base with `mainBranch` did not hold there — to
165
224
  * the top of the archive file, writing both files. Runs right before a run's commit, so the
166
- * commit, the review and the merge see the archived form. Returns what moved; a file whose
167
- * section is missing, or a repository not yet archived (archiveFinishedText), moves nothing. */
225
+ * commit, the review and the merge see the archived form. An added entry the archive already
226
+ * holds is dropped or left in place instead (archiveFinishedText), never archived twice. Returns
227
+ * what moved; a file whose section is missing, or a repository not yet archived, moves nothing. */
168
228
  export async function archiveNewFinishedEntries(wt, mainBranch) {
169
229
  const baseRev = await changeBaseRev(wt, mainBranch);
170
230
  const out = [];
171
231
  for (const spec of FINISHED_ARCHIVES) {
172
- const live = readTextOrNull(path.join(wt, spec.file));
232
+ const live = readTextIfExists(path.join(wt, spec.file));
173
233
  if (live === null)
174
234
  continue;
175
235
  const base = await fileContentAt(wt, baseRev, spec.file);
176
236
  const baseKeys = new Set(actionableEntryRanges(base, spec.section).map((e) => archiveKey(e.title)));
177
- const move = archiveFinishedText(live, readTextOrNull(path.join(wt, spec.archive)), spec, { baseKeys });
237
+ // The gate's new-plan-under-Done rule (backlog-structure.ts part 4/4) rejects a PLANS.md
238
+ // ### entry under ## Done that carries `(planned …)` but no `done YYYY-MM-DD`; archiving such
239
+ // an entry would name only PLANS-DONE.md — a file the role may not edit — for a fault the
240
+ // gate judges against its archive form. Park those stranded entries in the live file instead,
241
+ // so the stage self-check, the fix-up turn and the gate all name PLANS.md and the author can
242
+ // fix the missing done stamp (BUGS.md 2026-10-21).
243
+ const park = new Set((spec.file === "PLANS.md" ? strandedPlanEntries(live) : [])
244
+ .filter((s) => s.section === spec.section)
245
+ .map((s) => archiveKey(s.title)));
246
+ const move = archiveFinishedText(live, readTextIfExists(path.join(wt, spec.archive)), spec, {
247
+ baseKeys,
248
+ park,
249
+ });
250
+ if (park.size > 0) {
251
+ warnEventBestEffort(wt, TUMWATER_SOURCE, `the commit-time sweep left ${plural(park.size, "Done plan without a done date", "Done plans without a done date")} in ${spec.file} instead of archiving: the review gate would reject the archived form; add the missing done date to its heading`);
252
+ }
178
253
  if (move === null)
179
254
  continue;
180
255
  writeMove(wt, spec, move);
181
- out.push({ file: spec.file, archive: spec.archive, titles: move.titles });
256
+ out.push({ file: spec.file, archive: spec.archive, titles: move.titles, dropped: move.dropped });
182
257
  }
183
258
  return out;
184
259
  }
@@ -187,10 +262,10 @@ export async function archiveNewFinishedEntries(wt, mainBranch) {
187
262
  function planArchiveAll(root, keep) {
188
263
  const plan = [];
189
264
  for (const spec of FINISHED_ARCHIVES) {
190
- const live = readTextOrNull(path.join(root, spec.file));
265
+ const live = readTextIfExists(path.join(root, spec.file));
191
266
  if (live === null)
192
267
  continue;
193
- const move = archiveFinishedText(live, readTextOrNull(path.join(root, spec.archive)), spec, { keep });
268
+ const move = archiveFinishedText(live, readTextIfExists(path.join(root, spec.archive)), spec, { keep });
194
269
  if (move !== null)
195
270
  plan.push({ spec, move });
196
271
  }
@@ -207,24 +282,56 @@ export function liveFinishedHistory(root) {
207
282
  file: spec.file,
208
283
  section: spec.section,
209
284
  archive: spec.archive,
210
- entries: move.titles.length,
285
+ entries: move.titles.length + move.dropped.length,
211
286
  bytes: Buffer.byteLength(live) - Buffer.byteLength(move.live),
212
287
  });
213
288
  }
214
289
  return out;
215
290
  }
291
+ /** The bulk archive's commit subject after its `tumwater(operator):` prefix: how many entries
292
+ * moved to which archives, and how many already-archived ones left the live files (ArchiveMove). */
293
+ function archiveCommitWhat(plan) {
294
+ const count = plan.reduce((n, { move }) => n + move.titles.length, 0);
295
+ const dropped = plan.reduce((n, { move }) => n + move.dropped.length, 0);
296
+ const drop = plural(dropped, "already-archived entry", "already-archived entries");
297
+ if (count === 0 && dropped > 0)
298
+ return `Drop ${drop} from ${plan.map(({ spec }) => spec.file).join(" and ")}`;
299
+ // A file that only dropped entries left its archive untouched.
300
+ const archives = plan.filter(({ move }) => move.titles.length > 0 || move.dropped.length === 0).map(({ spec }) => spec.archive);
301
+ const archived = `Archive ${plural(count, "finished entry", "finished entries")} to ${archives.join(" and ")}`;
302
+ return dropped > 0 ? `${archived}; drop ${drop}` : archived;
303
+ }
304
+ /** One archived file as a line of `tumwater archive`'s output and the team-start event: what
305
+ * moved where, and what was dropped because the archive already held it. */
306
+ export function describeArchivedFile(f) {
307
+ const dropped = f.dropped.length > 0 ? ` (${plural(f.dropped.length, "already-archived entry", "already-archived entries")} dropped)` : "";
308
+ return `${f.file} → ${f.archive}: ${plural(f.titles.length, "entry", "entries")}${dropped}`;
309
+ }
310
+ /** planArchiveAll, or `skipped` when a live or archive file exists but cannot be read. */
311
+ function planOrSkip(root, keep) {
312
+ try {
313
+ return planArchiveAll(root, keep);
314
+ }
315
+ catch (err) {
316
+ return { status: "skipped", reason: `a backlog file could not be read: ${errorFirstLine(err)}` };
317
+ }
318
+ }
216
319
  /** The bulk archive, committed: move every live finished section of the primary checkout at
217
320
  * `root` (past its newest `keep` entries) to its archive file and commit the touched files on the
218
321
  * base branch as one `tumwater(operator):` commit. Holds the team's merge lock, so a merge
219
322
  * cannot lose its fast-forward to this commit, and the primary-worktree lock the CLI backlog
220
323
  * writers and the fast-forward share. Writes nothing when there is nothing to move, when the
221
- * primary checkout is not on the base branch, or when any of the files has uncommitted changes;
222
- * a failed commit restores the files. `tumwater archive` and team start call it. */
324
+ * primary checkout is not on the base branch, when any of the files has uncommitted changes, or
325
+ * when one exists but cannot be read; a failed write or commit restores the files.
326
+ * `tumwater archive` and team start call it. */
223
327
  export async function archiveFinishedOnMain(root, opts = {}) {
224
328
  const keep = opts.keep ?? 0;
225
- if (planArchiveAll(root, keep).length === 0)
329
+ const pre = planOrSkip(root, keep);
330
+ if ("status" in pre)
331
+ return pre;
332
+ if (pre.length === 0)
226
333
  return { status: "nothing" };
227
- return withLock(mergeLockDir(root), () => withStateLockAsync(primaryWorktreeLockPath(root), async () => {
334
+ return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
228
335
  const primary = await currentBranch(root);
229
336
  const base = targetBranch(loadConfigSafe(root).config?.baseBranch, primary);
230
337
  if (primary !== base) {
@@ -239,19 +346,20 @@ export async function archiveFinishedOnMain(root, opts = {}) {
239
346
  return { status: "skipped", reason: `${dirty.join(", ")} ${dirty.length > 1 ? "have" : "has"} uncommitted changes` };
240
347
  }
241
348
  // Re-planned under the locks: a merge may have moved main since the pre-check.
242
- const plan = planArchiveAll(root, keep);
349
+ const plan = planOrSkip(root, keep);
350
+ if ("status" in plan)
351
+ return plan;
243
352
  if (plan.length === 0)
244
353
  return { status: "nothing" };
245
354
  const touched = plan.flatMap(({ spec }) => [spec.file, spec.archive]);
246
355
  const created = plan.map(({ spec }) => spec.archive).filter((f) => !fs.existsSync(path.join(root, f)));
247
- for (const { spec, move } of plan)
248
- writeMove(root, spec, move);
249
- const count = plan.reduce((n, { move }) => n + move.titles.length, 0);
250
356
  const subject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
251
- `Archive ${plural(count, "finished entry", "finished entries")} to ${plan.map(({ spec }) => spec.archive).join(" and ")}`);
357
+ archiveCommitWhat(plan));
252
358
  try {
359
+ for (const { spec, move } of plan)
360
+ writeMove(root, spec, move);
253
361
  await git(root, "add", "--", ...touched);
254
- await git(root, ...COMMIT_IDENT, "commit", "--only", "-m", subject, "--", ...touched);
362
+ await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", subject, "--", ...touched);
255
363
  }
256
364
  catch (err) {
257
365
  await gitTry(root, "reset", "-q", "--", ...touched);
@@ -259,12 +367,12 @@ export async function archiveFinishedOnMain(root, opts = {}) {
259
367
  await gitTry(root, "checkout", "--", ...tracked);
260
368
  for (const f of created)
261
369
  fs.rmSync(path.join(root, f), { force: true });
262
- return { status: "skipped", reason: `the commit failed: ${errorMessage(err).trim().split("\n")[0]}` };
370
+ return { status: "skipped", reason: `the move could not be written or committed: ${errorFirstLine(err)}` };
263
371
  }
264
372
  return {
265
373
  status: "archived",
266
374
  commit: await headOf(root, "HEAD"),
267
- moved: plan.map(({ spec, move }) => ({ file: spec.file, archive: spec.archive, titles: move.titles })),
375
+ moved: plan.map(({ spec, move }) => ({ file: spec.file, archive: spec.archive, titles: move.titles, dropped: move.dropped })),
268
376
  };
269
377
  }), opts.lockTimeoutMs);
270
378
  }
@@ -283,7 +391,7 @@ export async function archiveAtTeamStart(root) {
283
391
  type: "state_migrated",
284
392
  step: "backlogArchive",
285
393
  moved: outcome.moved.reduce((n, f) => n + f.titles.length, 0),
286
- detail: outcome.moved.map((f) => `${f.file} → ${f.archive}: ${plural(f.titles.length, "entry", "entries")}`).join("; "),
394
+ detail: outcome.moved.map(describeArchivedFile).join("; "),
287
395
  });
288
396
  }
289
397
  else if (outcome.status === "skipped") {
@@ -294,3 +402,86 @@ export async function archiveAtTeamStart(root) {
294
402
  warnEventBestEffort(root, TUMWATER_SOURCE, `finished backlog entries were not archived: ${errorMessage(err)}`);
295
403
  }
296
404
  }
405
+ /** The start of a quarantine entry's title for `test`, up to the date. appendBugEntryLocked
406
+ * collapses whitespace in the title, so the test part is collapsed the same way; collapsing the
407
+ * whole prefix would trim its trailing space. */
408
+ function quarantineTitlePrefix(test) {
409
+ return collapseWhitespace(`\`${test}\` failed, then passed on the same tree`) + " (quarantined by tumwater ";
410
+ }
411
+ /** Output fenced into a bug body: a fence of its own length cannot be closed by the quoted text. */
412
+ function fencedTail(tail) {
413
+ return ["~~~~", ...tail.replaceAll("~~~~", "~~~").split("\n").slice(-30), "~~~~"].join("\n");
414
+ }
415
+ /** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
416
+ * branch as one `tumwater(operator):` commit, under the merge lock and the primary-worktree lock
417
+ * exactly as archiveFinishedOnMain does. Skipped, writing nothing, when the primary checkout is
418
+ * off the base branch, BUGS.md has uncommitted changes, or BUGS.md exists but cannot be read; a
419
+ * failed append or commit restores BUGS.md. A test whose quarantine entry is already under
420
+ * `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading back
421
+ * and is not filed again. */
422
+ export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
423
+ return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
424
+ const primary = await currentBranch(root);
425
+ const base = targetBranch(loadConfigSafe(root).config?.baseBranch, primary);
426
+ if (primary !== base) {
427
+ return { status: "skipped", reason: `your checkout is on ${primary ?? "a detached HEAD"}, not ${base}` };
428
+ }
429
+ const status = await gitTry(root, "status", "--porcelain", "--", "BUGS.md");
430
+ if (status === null)
431
+ return { status: "skipped", reason: "could not read git status" };
432
+ if (status.trim() !== "" && !status.startsWith("??")) {
433
+ return { status: "skipped", reason: "BUGS.md has uncommitted changes" };
434
+ }
435
+ const file = path.join(root, "BUGS.md");
436
+ let before;
437
+ try {
438
+ before = readTextIfExists(file);
439
+ }
440
+ catch (err) {
441
+ return { status: "skipped", reason: `BUGS.md could not be read: ${errorFirstLine(err)}` };
442
+ }
443
+ const date = formatDate(new Date());
444
+ const open = before === null ? [] : parseEntryDetails(before, "Open").map((e) => e.title);
445
+ const bugs = new Map();
446
+ let appended = 0;
447
+ const restoreBugs = async () => {
448
+ await gitTry(root, "reset", "-q", "--", "BUGS.md");
449
+ if (before === null)
450
+ fs.rmSync(file, { force: true });
451
+ else
452
+ writeTextAtomic(file, before);
453
+ };
454
+ try {
455
+ for (const { test, tail } of tests) {
456
+ const existing = open.find((t) => t.startsWith(quarantineTitlePrefix(test)));
457
+ if (existing !== undefined) {
458
+ bugs.set(test, existing);
459
+ continue;
460
+ }
461
+ const body = [
462
+ `The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
463
+ ...(tail ? ["", fencedTail(tail)] : []),
464
+ ].join("\n");
465
+ bugs.set(test, appendBugEntryLocked(root, `${quarantineTitlePrefix(test)}${date})`, body));
466
+ appended++;
467
+ }
468
+ }
469
+ catch (err) {
470
+ await restoreBugs();
471
+ return { status: "skipped", reason: `a BUGS.md entry could not be written: ${errorFirstLine(err)}` };
472
+ }
473
+ if (appended === 0)
474
+ return { status: "filed", bugs };
475
+ const subject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
476
+ `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`);
477
+ try {
478
+ await git(root, "add", "--", "BUGS.md");
479
+ await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", subject, "--", "BUGS.md");
480
+ }
481
+ catch (err) {
482
+ await restoreBugs();
483
+ return { status: "skipped", reason: `the commit failed: ${errorFirstLine(err)}` };
484
+ }
485
+ return { status: "filed", bugs };
486
+ }), opts.lockTimeoutMs);
487
+ }