tumwater 0.2.0 → 0.4.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 (464) 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 +55 -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 +6 -4
  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 +404 -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 +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -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 +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -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-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -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 +65 -3
  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 +181 -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 +183 -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 +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,16 +1,16 @@
1
- // vocabulary: plain
2
1
  /** The renderer behind `tumwater queue` — the operator-facing text for the merge queue view the
3
2
  * reader gathers (merge-queue.ts's readMergeQueue, beside it): one line per queued merge
4
3
  * in execution order, plus the count of queue files the reader skipped. Pure function of the
5
4
  * collected view and a `now` instant — no I/O, no clock reads — so the age it prints can be
6
5
  * pinned by a test. */
7
- import { humanSeconds, secondsSince } from "../text/datetime.js";
6
+ import { humanAgo, humanSeconds, secondsSince, secondsUntil } from "../text/datetime.js";
8
7
  import { roleLabel } from "../roles/role-labels.js";
9
8
  import { shortSha } from "../text/format.js";
9
+ import { filterKeepPositions } from "../collections.js";
10
10
  import { plural } from "../text/phrases.js";
11
11
  import { mergeChanges, MERGE_STAGE_LABELS } from "./merge-slot.js";
12
- /** The bracketed-label text for one queue entry, in the wording the loop rows use (status-model's
13
- * loopPhase): `merge <elapsed> · <stage>` while a verify or the merge holds the change,
12
+ /** The bracketed-label text for one queue entry, in the wording the role rows use (status-model's
13
+ * rolePhase): `merge <elapsed> · <stage>` while a verify or the merge holds the change,
14
14
  * `approved, awaiting merge` once approved, and "" when the marker is absent, names no record for
15
15
  * `sha`, or the record is `done`. Reads the marker through mergeChanges, so an older-generation
16
16
  * single-change marker counts as one `merge` record. */
@@ -44,28 +44,65 @@ export function revisionNote(entry) {
44
44
  note += " · " + plural(reasons.length, "prior objection");
45
45
  return note;
46
46
  }
47
+ /** One re-queued entry's what-brought-it-back note:
48
+ * `re-queued after <result>`, plus ` · ×<n>` while it has bounced more than once, plus
49
+ * ` · eligible in <duration>` while a `review_error` 60 s deferral still holds (its `notBefore`
50
+ * in the future). "" for a fresh entry with no `retry` field, so a first submission's queue
51
+ * line is byte-identical. */
52
+ export function retryNote(entry, now) {
53
+ if (entry.retry === undefined)
54
+ return "";
55
+ let note = `re-queued after ${entry.retry}`;
56
+ if ((entry.retryCount ?? 0) > 1)
57
+ note += ` · ×${entry.retryCount}`;
58
+ if (entry.notBefore !== undefined && entry.notBefore > now) {
59
+ note += ` · eligible in ${humanSeconds(secondsUntil(entry.notBefore, now))}`;
60
+ }
61
+ return note;
62
+ }
63
+ /** Narrows a whole-queue view to the entries whose role `keep` accepts, and returns the
64
+ * `scope` renderMergeQueue takes: the kept roles, each kept entry's 1-based place in the whole
65
+ * queue, and the whole queue's entry count. `unreadable` stays whole because an unreadable file
66
+ * has no role to attribute it to. */
67
+ export function scopeMergeQueue(whole, roles, keep) {
68
+ const { kept, positions } = filterKeepPositions(whole.entries, (e) => keep(e.role));
69
+ return { view: { ...whole, entries: kept }, scope: { roles, positions, total: whole.entries.length } };
70
+ }
47
71
  /** Render the queue as the text `tumwater queue` prints: a `merge queue (<depth>):` header then
48
- * one 1-based line per readable entry, oldest first, carrying the loop, tick number, short
72
+ * one 1-based line per readable entry, oldest first, carrying the role, run number, short
49
73
  * pinned sha, age, and summary. An empty queue (no entries and nothing skipped) prints
50
74
  * `merge queue empty`; a non-zero skip count appends one `(+N unreadable queue file(s) skipped)`
51
75
  * line so a torn or foreign file the reader dropped is visible rather than silent. An entry the
52
76
  * optional in-flight marker has a live record for carries ` · [<label>]` after its age
53
- * (queueEntryMergeLabel). */
54
- export function renderMergeQueue(view, now, inFlight) {
77
+ * (queueEntryMergeLabel). With the optional `scope` (`queue --role`: `view` holds only the
78
+ * listed roles' entries, `positions` their 1-based places in the whole queue, `total` the whole
79
+ * queue's entry count) each line keeps its whole-queue number, the header reads
80
+ * `merge queue (<kept> of <whole depth>, --role <ids>):`, and a scope that keeps nothing of a
81
+ * non-empty queue prints `no queued merges for <ids> (merge queue holds <depth>)`. Without
82
+ * `scope` the output is the whole queue's. */
83
+ export function renderMergeQueue(view, now, inFlight, scope) {
55
84
  const depth = view.entries.length + view.unreadable;
85
+ const wholeDepth = scope ? scope.total + view.unreadable : depth;
86
+ const ids = scope ? scope.roles.join(",") : "";
56
87
  const lines = [];
57
- if (depth === 0) {
88
+ if (wholeDepth === 0) {
58
89
  lines.push("merge queue empty");
59
90
  }
91
+ else if (scope && view.entries.length === 0) {
92
+ lines.push(`no queued merges for ${ids} (merge queue holds ${wholeDepth})`);
93
+ }
60
94
  else {
61
- lines.push(`merge queue (${depth}):`);
95
+ lines.push(scope ? `merge queue (${view.entries.length} of ${wholeDepth}, --role ${ids}):` : `merge queue (${depth}):`);
62
96
  view.entries.forEach((e, i) => {
63
- const age = humanSeconds(secondsSince(e.enqueuedAt, now));
97
+ const age = humanAgo(e.enqueuedAt, now);
64
98
  const label = queueEntryMergeLabel(e.sha, inFlight, now);
65
99
  const tag = label ? ` · [${label}]` : "";
66
100
  const note = revisionNote(e);
67
101
  const noteTag = note ? ` · ${note}` : "";
68
- lines.push(` ${i + 1}. ${roleLabel(e.role)} run #${e.run} · ${shortSha(e.sha)} · ${age} ago${tag}${noteTag} · ${e.summary}`);
102
+ const retry = retryNote(e, now);
103
+ const retryTag = retry ? ` · ${retry}` : "";
104
+ const frictionTag = e.highFriction === true ? " · high friction" : "";
105
+ lines.push(` ${scope ? scope.positions[i] : i + 1}. ${roleLabel(e.role)} run #${e.run} · ${shortSha(e.sha)} · ${age}${tag}${frictionTag}${retryTag}${noteTag} · ${e.summary}`);
69
106
  });
70
107
  }
71
108
  if (view.unreadable > 0) {
@@ -1,3 +1,4 @@
1
+ import { errCode } from "../errno.js";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { listQueueFiles, queueFileName, removeQueueFile } from "../files/file-queue.js";
@@ -10,7 +11,7 @@ let seq = 0;
10
11
  /** Append one merge to the queue as a single timestamped file (creating the queue dir if
11
12
  * needed). The filename orders entries across processes by wall-clock time; the per-process
12
13
  * counter and pid break ties within one process. Written atomically (tmp + rename): the
13
- * enqueue runs while the process can be hard-killed mid-write (the tick deadline's
14
+ * enqueue runs while the process can be hard-killed mid-write (the run deadline's
14
15
  * process-group kill, a shutdown), and a torn entry the crash left behind is not always
15
16
  * recoverable — the drain repairs only an unreadable HEAD (staleHeadFile), so a torn file
16
17
  * that sorts after a healthy entry would sit in the queue dir unread forever. */
@@ -20,7 +21,7 @@ export function enqueueMerge(root, entry) {
20
21
  writeJsonAtomic(path.join(dir, name), entry);
21
22
  }
22
23
  /** The revision fields of a queue entry, serialized only when present — the shape merge
23
- * surfaces (drain, verify) and the tick-stage enqueue re-emit when they build their own
24
+ * surfaces (drain, verify) and the run-stage enqueue re-emit when they build their own
24
25
  * entry/request objects, so the `revisionRound`/`priorReview` names live in one place and
25
26
  * cannot drift. */
26
27
  export function entryRevisionFields(e) {
@@ -45,7 +46,7 @@ function queueFiles(root) {
45
46
  export function legacyMigrateLandQueueDir(root) {
46
47
  const from = legacyLandQueueDir(root);
47
48
  const to = mergeQueueDir(root);
48
- const notes = [];
49
+ const notes = { moved: [], kept: [], failed: [] };
49
50
  const files = listQueueFiles(from, ".json");
50
51
  if (files.length > 0)
51
52
  fs.mkdirSync(to, { recursive: true });
@@ -53,15 +54,15 @@ export function legacyMigrateLandQueueDir(root) {
53
54
  const name = path.basename(file);
54
55
  const dest = path.join(to, name);
55
56
  if (fs.existsSync(dest)) {
56
- notes.push(`${name} already in merge-queue, left in place`);
57
+ notes.kept.push(`${name} already in merge-queue, left in place`);
57
58
  continue;
58
59
  }
59
60
  try {
60
61
  fs.renameSync(file, dest);
61
- notes.push(`moved ${name}`);
62
+ notes.moved.push(`moved ${name}`);
62
63
  }
63
64
  catch (err) {
64
- if (err.code !== "ENOENT")
65
+ if (errCode(err) !== "ENOENT")
65
66
  throw err;
66
67
  }
67
68
  }
@@ -86,12 +87,12 @@ const entryCache = new Map();
86
87
  * queue is one JSON object per file, so a file that parses to anything else — a scalar, an
87
88
  * array, an object missing a required field — is foreign or torn and reads as no entry.
88
89
  * Checking only `role` and `sha` left the `as MergeEntry` cast unsound: a foreign file that
89
- * happened to carry those two fields was handed downstream with `tick` and `summary`
90
+ * happened to carry those two fields was handed downstream with `run` and `summary`
90
91
  * undefined, which surface as bogus session names (`tumwater-<role>-undefined-review`) and
91
92
  * `undefined` in the reviewer's prompt. Optional fields are checked only when present.
92
93
  *
93
94
  * The three numeric fields go through nonNegativeNumber, not a bare `typeof === "number"`:
94
- * JSON's `1e999` parses to Infinity, so a hand-edited file could carry a non-finite tick or
95
+ * JSON's `1e999` parses to Infinity, so a hand-edited file could carry a non-finite run or
95
96
  * enqueuedAt past a typeof check, and both feed plumbing downstream (session names, queue
96
97
  * ordering, the status payload). */
97
98
  function isPriorReview(v) {
@@ -114,7 +115,10 @@ function isMergeEntry(v) {
114
115
  (e.body === undefined || typeof e.body === "string") &&
115
116
  (e.highFriction === undefined || typeof e.highFriction === "boolean") &&
116
117
  (e.revisionRound === undefined || nonNegativeNumber(e.revisionRound, null) !== null) &&
117
- (e.priorReview === undefined || isPriorReview(e.priorReview)));
118
+ (e.priorReview === undefined || isPriorReview(e.priorReview)) &&
119
+ (e.notBefore === undefined || nonNegativeNumber(e.notBefore, null) !== null) &&
120
+ (e.retry === undefined || typeof e.retry === "string") &&
121
+ (e.retryCount === undefined || nonNegativeNumber(e.retryCount, null) !== null));
118
122
  }
119
123
  /** Read one queue file through the stat-keyed cache: null when it vanishes mid-listing (a
120
124
  * concurrent drop — the inbox.ts race policy), is unreadable, or fails to parse or to carry
@@ -127,9 +131,9 @@ function readEntry(file) {
127
131
  // is still rejected by isMergeEntry.
128
132
  let parsed = readJsonFile(file);
129
133
  // An entry an old build wrote carries `tick` where the current shape has `run`.
130
- if (isJsonObject(parsed) && parsed.run === undefined && parsed.tick !== undefined) {
131
- const { tick, ...rest } = parsed;
132
- parsed = { ...rest, run: tick };
134
+ if (isJsonObject(parsed) && parsed.run === undefined && parsed.tick !== undefined) { // retired-name-ok: legacy name
135
+ const { tick, ...rest } = parsed; // retired-name-ok: legacy name
136
+ parsed = { ...rest, run: tick }; // retired-name-ok: legacy name
133
137
  }
134
138
  if (!isMergeEntry(parsed))
135
139
  return null;
@@ -176,6 +180,19 @@ export function readMergeQueue(root) {
176
180
  }
177
181
  return { entries, unreadable };
178
182
  }
183
+ /** The full path of every queue file `readMergeQueue` skips — torn, foreign, or vanished
184
+ * mid-listing — in filename order. This names what `readMergeQueue.unreadable` counts, through
185
+ * the same skip policy, so the count and the list cannot drift: nothing else ages a skipped
186
+ * file (the drain repairs only an unreadable head), so each here is a stranded entry the
187
+ * queue reader silently passes over. A missing queue directory reads as an empty list. */
188
+ export function unreadableMergeQueueFiles(root) {
189
+ const out = [];
190
+ for (const file of queueFiles(root)) {
191
+ if (readEntry(file) === null)
192
+ out.push(file);
193
+ }
194
+ return out;
195
+ }
179
196
  /** The head of the queue — its oldest entry — plus the file to drop on completion, or null when
180
197
  * the queue is empty or its oldest file is a vanished/torn entry. */
181
198
  export function headMerge(root) {
@@ -190,8 +207,8 @@ export function headMerge(root) {
190
207
  * reads null while the queue holds files; null when the queue is empty or its head is healthy.
191
208
  * The drain drops this file with a warning so the queue can drain: headMerge reads null for
192
209
  * it forever and nothing else will remove it, stranding every live entry behind it (each
193
- * author's interlock, the per-poll queued-roles set, pins its ticks along with them). The
194
- * crashed entry's commit, if any, still lives in its merge ref — next-tick leftover recovery
210
+ * author's interlock, the per-poll queued-roles set, pins its runs along with them). The
211
+ * crashed entry's commit, if any, still lives in its merge ref — next-run leftover recovery
195
212
  * re-merges it — so the drop loses nothing recoverable from the file (BUGS.md 2026-09-16). */
196
213
  export function staleHeadFile(root) {
197
214
  const file = queueFiles(root)[0];
@@ -199,8 +216,28 @@ export function staleHeadFile(root) {
199
216
  return null;
200
217
  return readEntry(file) === null ? file : null;
201
218
  }
219
+ /** The role a torn queue file was being written for, recovered from its raw bytes: MergeEntry
220
+ * serializes `role` first, so even a head a crash cut mid-write keeps a `"role":"<id>"`
221
+ * substring the drain can read back without a JSON parse. Tolerant by design — the regex, not
222
+ * a JSON.parse — because the file's tail is exactly the part the tear may have lost. Null when
223
+ * the file has no readable role: a foreign file, or a tear that cut the write before the role
224
+ * landed. Best-effort: an unreadable file reads as null, never a throw. The drain uses it to
225
+ * name the torn head's owner and re-queue its pinned change before dropping the file. */
226
+ export function tornHeadRole(file) {
227
+ let raw;
228
+ try {
229
+ raw = fs.readFileSync(file, "utf8");
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ const m = /"role"\s*:\s*"([^"]+)"/.exec(raw);
235
+ if (!m)
236
+ return null;
237
+ return canonicalRoleId(m[1]);
238
+ }
202
239
  /** Remove one queued merge's file after its merge outcome — terminal or not (a
203
- * non-terminal outcome keeps the merge ref; the retry is the role's next fresh tick, not a
240
+ * non-terminal outcome keeps the merge ref; the retry is the role's next fresh run, not a
204
241
  * queue re-drain). ENOENT is a no-op: a concurrent drop between listing and removal is a
205
242
  * normal race (the inbox.ts policy), not an error. */
206
243
  export function dropMerge(file) {
@@ -0,0 +1,103 @@
1
+ /** Re-queuing a settled merge entry (plans/fast-merge.md "Lever 1"), split out of
2
+ * merge-slot.ts so the kept-change policy reads on its own screen: merge-slot.ts owns the
3
+ * in-flight marker, the per-merge usage accounting, and the write-back that folds an outcome
4
+ * into the authoring role's state and drops its queue entry; this module owns the other half
5
+ * of a settled entry — putting a kept change back on the queue itself, either at once (a
6
+ * retriable result) or once main's head stops being red (a `main_red` pin). */
7
+ import { enqueueMerge, queuedMerges } from "./merge-queue.js";
8
+ import { RETRIABLE_MERGE_RESULTS } from "./merge-core.js";
9
+ import { pinnedRetrySha } from "../runner/leftover.js";
10
+ import { branchHead } from "../git/git.js";
11
+ import { cachedBaselineVerdict } from "../baseline/main-baseline.js";
12
+ import { saveRunnerStateBestEffort } from "../runner/runner-state.js";
13
+ import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
14
+ import { errorMessage } from "../text/text.js";
15
+ /** How long a re-queued `review_error` change waits before its verification may start. */
16
+ const REVIEW_ERROR_RETRY_MS = 60_000;
17
+ /** The queue entry that re-queues a settled one: its run, summary, body, friction flag and
18
+ * revision fields unchanged (so the summary is not a recovery label), the pin's current `sha`,
19
+ * and `retry` naming the result it retries. */
20
+ function retryEntry(from, sha, result) {
21
+ const { notBefore: _notBefore, retry: _retry, ...rest } = from;
22
+ return {
23
+ ...rest,
24
+ sha,
25
+ retry: result,
26
+ retryCount: (from.retryCount ?? 0) + 1,
27
+ enqueuedAt: Date.now(),
28
+ ...(result === "review_error" ? { notBefore: Date.now() + REVIEW_ERROR_RETRY_MS } : {}),
29
+ };
30
+ }
31
+ function enqueueRetry(root, entry) {
32
+ enqueueMerge(root, entry);
33
+ logEventBestEffort(root, {
34
+ source: entry.role,
35
+ type: "merge_queued",
36
+ commit: entry.sha,
37
+ summary: entry.summary,
38
+ retry: entry.retry,
39
+ });
40
+ }
41
+ /** After a settled merge dropped its entry, put a kept change back on the queue itself, so a
42
+ * retry needs no author run (plans/fast-merge.md "Lever 1"). A `merge_blocked`, `review_error`
43
+ * or `merge_conflict` result re-queues at once when its pin survives and pinnedRetrySha allows
44
+ * it (not stale, not held, under MERGE_CONFLICT_LIMIT); a `main_red` pin is recorded on the
45
+ * state with main's head, for requeueMainRedPin to re-queue once main moves to a head whose
46
+ * verdict is not red. Anything else, or a pin that is gone, changes nothing — leftover recovery
47
+ * stays the backstop. */
48
+ export async function requeueKeptChange(root, mainBranch, entry, state, result) {
49
+ const isMainRed = result === "main_red";
50
+ if (!isMainRed && !RETRIABLE_MERGE_RESULTS.has(result))
51
+ return;
52
+ const sha = await pinnedRetrySha(root, entry.role, mainBranch, state);
53
+ if (!sha)
54
+ return;
55
+ if (isMainRed) {
56
+ const { sha: _sha, notBefore: _nb, retry: _r, ...rest } = entry;
57
+ state.mainRedRetry = { head: (await branchHead(root, mainBranch)) ?? "", entry: rest };
58
+ saveRunnerStateBestEffort(root, state);
59
+ return;
60
+ }
61
+ if (queuedMerges(root).some((e) => e.role === entry.role))
62
+ return;
63
+ enqueueRetry(root, retryEntry(entry, sha, result));
64
+ }
65
+ /** The longest a kept `main_red` pin waits for the main watch's verdict on a moved head before it
66
+ * is re-queued anyway. This bounds the wait when no verdict ever arrives, for example after a
67
+ * watch check that timed out or a restart's cold cache. */
68
+ export const MAIN_RED_VERDICT_WAIT_MS = 15 * 60_000;
69
+ /** Re-queue a `main_red` pin once main's head differs from the one recorded when its attempt
70
+ * settled (the orchestrator poll calls this for every runner), by the head's cached verdict: red
71
+ * leaves the record untouched, so a later poll asks again; no verdict starts (and saves) a wait
72
+ * at `now`, and the pin re-queues once MAIN_RED_VERDICT_WAIT_MS has passed; green re-queues at
73
+ * once. Re-queuing clears the record; no entry is queued when the pin is gone or held back by
74
+ * pinnedRetrySha, or the role already has one queued. It never throws: the poll
75
+ * loop calls it with no catch. */
76
+ export async function requeueMainRedPin(root, mainBranch, state, mainHead, now = Date.now()) {
77
+ const kept = state.mainRedRetry;
78
+ if (!kept || mainHead === "" || mainHead === kept.head)
79
+ return;
80
+ const verdict = cachedBaselineVerdict(mainHead);
81
+ if (verdict === "red")
82
+ return;
83
+ if (verdict === undefined) {
84
+ if (kept.waitingSince === undefined) {
85
+ state.mainRedRetry = { ...kept, waitingSince: now };
86
+ saveRunnerStateBestEffort(root, state);
87
+ return;
88
+ }
89
+ if (now - kept.waitingSince < MAIN_RED_VERDICT_WAIT_MS)
90
+ return;
91
+ }
92
+ state.mainRedRetry = undefined;
93
+ saveRunnerStateBestEffort(root, state);
94
+ const sha = await pinnedRetrySha(root, kept.entry.role, mainBranch, state);
95
+ if (!sha || queuedMerges(root).some((e) => e.role === kept.entry.role))
96
+ return;
97
+ try {
98
+ enqueueRetry(root, retryEntry(kept.entry, sha, "main_red"));
99
+ }
100
+ catch (err) {
101
+ warnEventBestEffort(root, kept.entry.role, `the kept main_red change could not be re-queued: ${errorMessage(err)}`);
102
+ }
103
+ }
@@ -1,16 +1,16 @@
1
- // vocabulary: plain
2
1
  import { dropMerge } from "./merge-queue.js";
3
2
  import { runCostIsReal } from "../pi/pi-run-result.js";
4
3
  import { reviewRunConfig, seamHoldKey, seamPairName } from "../config/config-views.js";
5
- import { saveLoopState } from "../loop/loop-state.js";
6
- import { applyMergeOutcome, ERROR_STREAK_WARN } from "../tick/tick-apply.js";
7
- import { observeClaudeAccount } from "../agent/claude/claude-account.js";
4
+ import { saveRunnerStateBestEffort } from "../runner/runner-state.js";
5
+ import { applyMergeOutcome, ERROR_STREAK_WARN } from "../run/run-apply.js";
6
+ import { observeAccount } from "../agent/subscription-account.js";
8
7
  import { logEventBestEffort, usageFragment, warnEventBestEffort } from "../events/events.js";
9
8
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
10
9
  import { isJsonObject, nonNegativeNumber } from "../files/json-object.js";
11
10
  import { removeQuiet } from "../files/files.js";
12
11
  import { mergeStatePath } from "../paths.js";
13
12
  import { consecutiveFailuresWarning } from "../text/phrases.js";
13
+ import { truncate } from "../text/text.js";
14
14
  import { canonicalRoleId } from "../roles/legacy-role-ids.js";
15
15
  /** How each merge stage names itself in the merge cell. */
16
16
  export const MERGE_STAGE_LABELS = {
@@ -18,6 +18,7 @@ export const MERGE_STAGE_LABELS = {
18
18
  "check-wait": "waiting to check",
19
19
  "build-check": "checking",
20
20
  reviewing: "reviewing",
21
+ "reviewing-check": "reviewing, checking",
21
22
  "resolve-wait": "waiting to resolve conflicts",
22
23
  resolving: "resolving conflicts",
23
24
  "provider-wait": "waiting for the provider",
@@ -232,22 +233,48 @@ export function isMergeChange(value) {
232
233
  (value.status === "merging" || value.status === "approved" || value.status === "done") &&
233
234
  (value.stageSince === undefined || nonNegativeNumber(value.stageSince, null) !== null));
234
235
  }
236
+ /** Why a merge ended without landing, for the `merge_failed` event, or undefined when the
237
+ * state names none. `rejected` reads the review's first reason, only when that review was
238
+ * recorded during this merge (`at >= startedAt`; an older one is stale). Every other failure
239
+ * reads `lastError`, minus its `merge failed: ` and `<result> — ` prefixes; a bare result
240
+ * (the catch-all `merge failed: merge_conflict`) names no reason. */
241
+ export function mergeFailureReason(state, result, startedAt) {
242
+ if (result === "changed" || result === "aborted")
243
+ return undefined;
244
+ if (result === "rejected") {
245
+ const review = state.lastReview;
246
+ if (!review || review.at < startedAt)
247
+ return undefined;
248
+ return review.reasons[0]?.trim() || undefined;
249
+ }
250
+ let reason = (state.lastError ?? "").trim();
251
+ if (reason.startsWith("merge failed: "))
252
+ reason = reason.slice("merge failed: ".length);
253
+ if (reason.startsWith(`${result} — `))
254
+ reason = reason.slice(`${result} — `.length);
255
+ reason = reason.trim();
256
+ return reason === "" || reason === result ? undefined : reason;
257
+ }
235
258
  /** Fold one merge's outcome into its role's state and drop its queue entry — the 3/5
236
259
  * write-back every pipeline outcome goes through (a verification's final verdict, a merged change,
237
260
  * an aborted approved change): apply the result to the live state object (paired with the entry's
238
261
  * change for the last-result cell — applyMergeOutcome), persist it, log merge_done/merge_failed
262
+ * (the failed event carries the reason mergeFailureReason derives)
239
263
  * with the merge's own duration (from its verification's start) and usage (omitted when zero — the
240
264
  * 4/5 idiom, so review-exempt merges render bare), and drop the entry: EVERY defined result
241
265
  * drops. Both event writes are best-effort: an unwritable feed (ENOSPC, EACCES) must not
242
266
  * skip the drop, or the settled entry would stay queued and be re-verified and re-merged on
243
- * the next drain — the queue's "drop after EVERY outcome" contract (merge-queue.ts). The
244
- * change's marker record is the caller's concern (removeMergeChange). */
267
+ * the next drain — the queue's "drop after EVERY outcome" contract (merge-queue.ts). A failed
268
+ * state save warns and does not skip the drop either: the in-memory state already holds the
269
+ * outcome and the runner's next save writes it. The change's marker record is the caller's
270
+ * concern (removeMergeChange). */
245
271
  export function writeMergeOutcome(root, entry, state, result, durationMs, usage, file) {
246
272
  applyMergeOutcome(state, result, entry);
247
- saveLoopState(root, state);
273
+ saveRunnerStateBestEffort(root, state);
274
+ const reason = result === "changed" ? undefined : mergeFailureReason(state, result, Date.now() - durationMs);
248
275
  // One warning per error episode, merge side (BUGS.md 2026-09-30): a review rejection
249
- // feeds the same streak the tick-side warn reads (src/tick/tick-finalize.ts), but it resolves
250
- // AFTER the tick's end-save, so the crossing warn belongs here — same shape, same bar.
276
+ // feeds the same streak the run-side warn reads (src/run/run-finalize.ts), but it resolves
277
+ // AFTER the run's end-save, so the crossing warn belongs here — same shape, same bar.
251
278
  if (result === "rejected" && (state.consecutiveErrors ?? 0) === ERROR_STREAK_WARN) {
252
279
  warnEventBestEffort(root, entry.role, consecutiveFailuresWarning(state.consecutiveErrors ?? 0, state.lastError ?? "review rejected"));
253
280
  }
@@ -260,6 +287,7 @@ export function writeMergeOutcome(root, entry, state, result, durationMs, usage,
260
287
  commit: entry.sha,
261
288
  result,
262
289
  summary: entry.summary,
290
+ ...(reason !== undefined ? { reason: truncate(reason, 300) } : {}),
263
291
  durationMs,
264
292
  // Enqueue-to-outcome span, including the slot's own durationMs. A leftover-recovery
265
293
  // re-enqueue writes a new entry with its own enqueuedAt, so a retried change counts from
@@ -271,6 +299,13 @@ export function writeMergeOutcome(root, entry, state, result, durationMs, usage,
271
299
  // sees a merge's spend under its reviewer's provider. Omitted when empty (a merge
272
300
  // whose runs carried no provider), the same omit-when-empty idiom the usage fields use.
273
301
  ...(Object.keys(usage.providerCost).length > 0 ? { providerCost: usage.providerCost } : {}),
302
+ // The merge's notional spend per backend (plans/codex-backend.md part 3d/9): the
303
+ // subscription seam whose runs made the merge's API-equivalent cost, so a codex merge's
304
+ // subscription spend names codex, not a generic claude line. Omitted when the merge spent
305
+ // no notional, the same omit-when-empty idiom the usage fields use.
306
+ ...(Object.keys(usage.notionalByBackend ?? {}).length > 0
307
+ ? { notionalByBackend: usage.notionalByBackend }
308
+ : {}),
274
309
  });
275
310
  dropMerge(file);
276
311
  }
@@ -283,7 +318,7 @@ export function writeMergeOutcome(root, entry, state, result, durationMs, usage,
283
318
  * folded onto the rate-limit and backend-failure stamps this fold writes: a 429 on a claude
284
319
  * reviewer must hold `claude`, not the pi author's provider (PLANS.md "Mixed claude + pi
285
320
  * fleets, part 2/4"). The reviewer's final runs are folded HERE (merge-core.ts's
286
- * reviewPinnedChange), not inside LoopPi — loop-pi.ts stamps the retried first attempt; this
321
+ * reviewPinnedChange), not inside RunnerPi — runner-pi.ts stamps the retried first attempt; this
287
322
  * stamps the final run, which ends on a different seam when the reviewer runs on a
288
323
  * non-pi backend. */
289
324
  /** Fetch-or-create a provider's accumulator entry in `usage.providerCost` and add `amount`
@@ -298,8 +333,16 @@ function addProviderCost(usage, provider, amount, notional) {
298
333
  e.costUsd += amount;
299
334
  usage.providerCost[provider] = e;
300
335
  }
336
+ /** Add a subscription run's notional spend to the merge's per-backend tally, keyed by the
337
+ * run's agent (falling back to the claude default, like the run-end fold), so the merge event
338
+ * names the backend whose runs made its API-equivalent cost (plans/codex-backend.md part 3d/9). */
339
+ function addBackendNotional(usage, agent, amount) {
340
+ const backend = agent && agent !== "" ? agent : "claude";
341
+ usage.notionalByBackend = usage.notionalByBackend ?? {};
342
+ usage.notionalByBackend[backend] = (usage.notionalByBackend[backend] ?? 0) + amount;
343
+ }
301
344
  export function mergeUsage(author, root) {
302
- const usage = { tokens: 0, cost: 0, providerCost: {} };
345
+ const usage = { tokens: 0, cost: 0, providerCost: {}, notionalByBackend: {} };
303
346
  const reviewConfig = reviewRunConfig(author.config);
304
347
  const reviewProvider = seamHoldKey(reviewConfig);
305
348
  const reviewPair = seamPairName(reviewConfig);
@@ -308,13 +351,14 @@ export function mergeUsage(author, root) {
308
351
  foldUsage: (run) => {
309
352
  author.foldMergeUsage(run, reviewProvider, reviewPair);
310
353
  // A claude reviewer's or resolver's billing/window readings reach the account file the
311
- // moment this run folds, not at the author's next tick end — so a pi-author team's
312
- // claude reviewer still updates the five-hour window before the author's next tick
313
- // starts (PLANS.md "Mixed claude + pi fleets, part 3/4"). Best-effort like tick-finalize.ts's
314
- // call; the file is a cache and an unwritable state dir must not fail the merge.
354
+ // moment this run folds, not at the author's next run end — so a pi-author team's
355
+ // claude reviewer still updates the five-hour window before the author's next run
356
+ // starts (PLANS.md "Mixed claude + pi fleets, part 3/4"). The run's agent names the
357
+ // backend's own file. Best-effort like run-finalize.ts's call; the file is a cache and
358
+ // an unwritable state dir must not fail the merge.
315
359
  if (run.billing !== undefined || run.rateWindows !== undefined) {
316
360
  try {
317
- observeClaudeAccount(root, { billing: run.billing, rateWindows: run.rateWindows });
361
+ observeAccount(root, run.agent ?? "pi", run);
318
362
  }
319
363
  catch {
320
364
  // The next reporting run rewrites it; nothing this merge depends on is lost.
@@ -331,6 +375,7 @@ export function mergeUsage(author, root) {
331
375
  }
332
376
  else {
333
377
  usage.notionalCost = (usage.notionalCost ?? 0) + run.costUsd;
378
+ addBackendNotional(usage, run.agent, run.costUsd);
334
379
  if (reviewProvider !== undefined) {
335
380
  addProviderCost(usage, reviewProvider, run.costUsd, true);
336
381
  }
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** The stack merger — the merge-half machinery of a multi-change batch (extracted from
3
2
  * merge-batch.ts, whose header scopes that file to the verify and merge halves): assemble the
4
3
  * approved changes as a cherry-picked stack on main's current tip, run ONE scope-`batch` build
@@ -6,8 +5,9 @@
6
5
  * re-stack loop for a main that moves mid-check, the doc-only exemption and its cross-checks,
7
6
  * and the per-attempt abort observation. The bisect over a red stack (mergeApproved, in
8
7
  * merge-batch.ts) drives this file's mergeStack one prefix at a time. */
9
- import { COMMIT_IDENT, gitTry } from "../git/git-run.js";
10
- import { branchHead, deleteRef, gitLines, headOf } from "../git/git.js";
8
+ import { committerIdentArgs, gitTry } from "../git/git-run.js";
9
+ import { branchHead, deleteRef, headOf } from "../git/git.js";
10
+ import { treeDiffFiles } from "../git/git-diff.js";
11
11
  import { mergeRefName, mergeLockDir } from "../paths.js";
12
12
  import { ensureDetachedWorktree } from "../git/worktree.js";
13
13
  import { useWorktree } from "../git/worktree-use.js";
@@ -15,11 +15,14 @@ import { exemptSkipBlockReason } from "./merge-to-main.js";
15
15
  import { logNewQuestions } from "./merge-questions.js";
16
16
  import { openQuestions } from "../backlog/backlog.js";
17
17
  import { logEventBestEffort, warnEventBestEffort } from "../events/events.js";
18
- import { ffMainToOrWhy } from "./merge-git.js";
18
+ import { ffMainToOrWhy, overwriteBlockedReason, primaryCheckoutBlockers } from "./merge-git.js";
19
19
  import { syncRootInstall } from "../build/dep-install.js";
20
20
  import { withLock } from "../concurrency/lock.js";
21
21
  import { runScopedBuildCheck } from "../build/build-check-scoped.js";
22
+ import { mergeCommandOf } from "../build/build-check-detect.js";
22
23
  import { noteGreenBaseline } from "../baseline/main-baseline.js";
24
+ import { onlyKnownMainFailures } from "../baseline/main-watch.js";
25
+ import { unverifiedTreeOutcome } from "../build/build-check-events.js";
23
26
  import { isExemptDiff } from "../review/exemptions.js";
24
27
  import { checkWaitStage, setMergeStage } from "./merge-slot.js";
25
28
  /** How many times a batch whose fast-forward lost the race to a moved main re-stacks onto the
@@ -62,7 +65,7 @@ async function assembleStack(root, mainBranch, wtPath, entries) {
62
65
  // from merging only its last diff and orphaning the work beneath it. (Not a
63
66
  // rebase: after 2/5 the role branches sit at main and each merge lives only in its
64
67
  // pinned ref — there is nothing to rebase.)
65
- const pick = await gitTry(wt, ...COMMIT_IDENT, "cherry-pick", `${base}..${entry.sha}`);
68
+ const pick = await gitTry(wt, ...(await committerIdentArgs(wt)), "cherry-pick", `${base}..${entry.sha}`);
66
69
  if (pick === null) {
67
70
  // A conflict (or an already-applied patch — the crash-window re-drain): abort the
68
71
  // pick and abandon to one-at-a-time. The worktree may be left mid-state; its next
@@ -84,8 +87,8 @@ async function assembleStack(root, mainBranch, wtPath, entries) {
84
87
  * --no-renames so a rename out of a code path lists the deleted source, not only its exempt
85
88
  * destination. An unreadable diff is not exempt: the caller runs the check. */
86
89
  async function exemptTreeDelta(root, from, to, exemptPaths) {
87
- const out = await gitTry(root, "diff", "--no-renames", "--name-only", from, to);
88
- return out !== null && isExemptDiff(gitLines(out), exemptPaths);
90
+ const files = await treeDiffFiles(root, from, to);
91
+ return files !== null && isExemptDiff(files, exemptPaths);
89
92
  }
90
93
  /** Merge `entries` — the whole stack, or one bisect prefix of it — as ONE fast-forward on its
91
94
  * own green check: assemble them on main's current tip in `wtPath`, run ONE scope-`batch`
@@ -97,6 +100,8 @@ async function exemptTreeDelta(root, from, to, exemptPaths) {
97
100
  * build (the reasoning verifyMerge's exempt arm applies) — and runs the same cross-checks
98
101
  * that arm runs, the backlog heading structure and the false-fix claim; one that fails either
99
102
  * returns `conflict`, so the per-change fallback applies the gate's own verdict per change.
103
+ * A primary checkout on main that holds uncommitted or untracked files the stack changes
104
+ * returns `blocked` before any check runs, with no re-stack.
100
105
  * On `landed` every entry's ref is deleted and the red-main baseline is seeded with the tip
101
106
  * when a check PASSED on exactly it — a skipped check seeds nothing, like verifyMerge's
102
107
  * exempt arm. The check's events log under the first entry's role. */
@@ -121,6 +126,12 @@ async function mergeStackIn(ctx, wtPath, entries) {
121
126
  return { kind: "conflict" }; // main unreadable or a pick conflicted
122
127
  const { base, mergeDone } = assembled;
123
128
  const tip = mergeDone.at(-1).sha;
129
+ // A dirty primary checkout is one `git status` away: block before the check, not after it.
130
+ const dirty = await primaryCheckoutBlockers(ctx.root, ctx.mainBranch, tip);
131
+ if (dirty !== null) {
132
+ warnEventBestEffort(ctx.root, mergeDone.at(-1).role, `merge blocked: ${overwriteBlockedReason(dirty)}`);
133
+ return { kind: "blocked" };
134
+ }
124
135
  // The expensive deterministic half, shared: ONE run over the combined tree. Outcome
125
136
  // routing — null: no declared check, merge directly; "failed": red or a merge-scope
126
137
  // timeout, the caller bisects; "skipped": no npm / broken toolchain (the helper warned),
@@ -133,8 +144,7 @@ async function mergeStackIn(ctx, wtPath, entries) {
133
144
  // stacked on — decides like verifyMerge does: doc-only cannot break the build, so no
134
145
  // check runs (a doc-only stack, or a bisect's size-1 doc-only remainder, merges on the
135
146
  // exemption instead of paying a suite run no code change asked for).
136
- const stackDiff = await gitTry(ctx.root, "diff", "--no-renames", "--name-only", base, tip);
137
- const stackFiles = stackDiff === null ? null : gitLines(stackDiff);
147
+ const stackFiles = await treeDiffFiles(ctx.root, base, tip);
138
148
  if (stackFiles !== null && isExemptDiff(stackFiles, exemptPaths)) {
139
149
  // The same cross-checks verifyMerge's exempt arm runs (merge-to-main.ts's
140
150
  // exemptSkipBlockReason): the skip must not wave through an md-only edit the gate would
@@ -153,10 +163,16 @@ async function mergeStackIn(ctx, wtPath, entries) {
153
163
  const check = await runScopedBuildCheck(ctx.root, roles[0], "batch", wtPath, ctx.config, undefined, undefined, undefined, checkWaitStage(ctx.root, roles));
154
164
  for (const e of entries)
155
165
  setMergeStage(ctx.root, e.role, "merging");
156
- if (check !== null && check.outcome.status === "failed")
166
+ // Failing only main's known failures is not a red stack (verifyMerge's rule): the ff goes
167
+ // ahead, and no green seeds for a tree that did not pass.
168
+ const knownOnly = check !== null && check.outcome.status === "failed" &&
169
+ !unverifiedTreeOutcome(check.outcome) &&
170
+ onlyKnownMainFailures(ctx.root, check.outcome.failingTests) !== null;
171
+ if (check !== null && check.outcome.status === "failed" && !knownOnly)
157
172
  return { kind: "red", ...check };
158
173
  checkedTip = tip;
159
- if (check !== null && check.outcome.status === "passed")
174
+ // A mergeCommand green seeds nothing: main's verdict comes from the main watch.
175
+ if (check !== null && check.outcome.status === "passed" && mergeCommandOf(ctx.config) === undefined)
160
176
  seed = tip;
161
177
  }
162
178
  }