tumwater 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (446) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +42 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +4 -3
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +402 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +246 -55
  41. package/dist/src/backlog/backlog-cli-view.js +280 -0
  42. package/dist/src/backlog/backlog-cli.js +85 -274
  43. package/dist/src/backlog/backlog-commit.js +4 -5
  44. package/dist/src/backlog/backlog-eligibility.js +175 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +254 -0
  48. package/dist/src/backlog/backlog-release.js +117 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-culprit.js +94 -0
  56. package/dist/src/baseline/main-guardrails.js +209 -0
  57. package/dist/src/baseline/main-red.js +139 -32
  58. package/dist/src/baseline/main-revert.js +133 -0
  59. package/dist/src/baseline/main-watch.js +184 -0
  60. package/dist/src/brief/brief-edit.js +9 -11
  61. package/dist/src/brief/brief.js +31 -51
  62. package/dist/src/budget/budget.js +30 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +1 -2
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +118 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +150 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +308 -590
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-role-command.js +420 -0
  84. package/dist/src/cli/cli-start-summary.js +160 -0
  85. package/dist/src/cli/{cli-run.js → cli-start.js} +165 -237
  86. package/dist/src/cli/completion.js +149 -22
  87. package/dist/src/cli/config-commands.js +41 -18
  88. package/dist/src/cli/follow-view.js +75 -0
  89. package/dist/src/cli/help.js +189 -73
  90. package/dist/src/cli/log-commands.js +148 -82
  91. package/dist/src/cli/question-commands.js +205 -10
  92. package/dist/src/cli.js +106 -43
  93. package/dist/src/collections.js +26 -10
  94. package/dist/src/concurrency/check-permit.js +20 -3
  95. package/dist/src/concurrency/lock.js +17 -13
  96. package/dist/src/concurrency/provider-lease.js +44 -8
  97. package/dist/src/concurrency/semaphore.js +102 -26
  98. package/dist/src/config/config-aliases.js +0 -1
  99. package/dist/src/config/config-editable-keys.js +13 -1
  100. package/dist/src/config/config-fallbacks.js +93 -56
  101. package/dist/src/config/config-field-checks.js +40 -10
  102. package/dist/src/config/config-keys.js +30 -8
  103. package/dist/src/config/config-live.js +63 -24
  104. package/dist/src/config/config-request.js +137 -0
  105. package/dist/src/config/config-schema.js +31 -24
  106. package/dist/src/config/config-selector-checks.js +123 -0
  107. package/dist/src/config/config-validation-agents.js +80 -0
  108. package/dist/src/config/config-validation-model.js +116 -0
  109. package/dist/src/config/config-validation.js +141 -199
  110. package/dist/src/config/config-views.js +127 -53
  111. package/dist/src/config/config-write-core.js +135 -0
  112. package/dist/src/config/config-write.js +131 -264
  113. package/dist/src/config/config.js +48 -38
  114. package/dist/src/config/model-selector.js +34 -34
  115. package/dist/src/doctor/doctor-backlog-bugs.js +287 -0
  116. package/dist/src/doctor/doctor-backlog-plans.js +263 -0
  117. package/dist/src/doctor/doctor-backlog.js +100 -496
  118. package/dist/src/doctor/doctor-check-names.js +81 -0
  119. package/dist/src/doctor/doctor-checks.js +52 -579
  120. package/dist/src/doctor/doctor-claude-checks.js +145 -0
  121. package/dist/src/doctor/doctor-codex-checks.js +279 -0
  122. package/dist/src/doctor/doctor-contention-checks.js +380 -0
  123. package/dist/src/doctor/doctor-holds.js +82 -66
  124. package/dist/src/doctor/doctor-install.js +2 -1
  125. package/dist/src/doctor/doctor-model-checks.js +236 -402
  126. package/dist/src/doctor/doctor-notify.js +3 -4
  127. package/dist/src/doctor/doctor-opencode-checks.js +162 -0
  128. package/dist/src/doctor/doctor-orphans.js +182 -63
  129. package/dist/src/doctor/doctor-project-checks.js +303 -0
  130. package/dist/src/doctor/doctor.js +121 -77
  131. package/dist/src/doctor/image-probe.js +21 -8
  132. package/dist/src/doctor/outcome.js +3 -2
  133. package/dist/src/doctor/probe.js +7 -11
  134. package/dist/src/events/event-format.js +181 -86
  135. package/dist/src/events/event-labels.js +12 -5
  136. package/dist/src/events/event-legacy.js +39 -15
  137. package/dist/src/events/event-read.js +39 -39
  138. package/dist/src/events/event-tally.js +16 -0
  139. package/dist/src/events/event-window.js +60 -24
  140. package/dist/src/events/events.js +30 -13
  141. package/dist/src/events/notify.js +7 -7
  142. package/dist/src/failure/failure-cluster.js +12 -12
  143. package/dist/src/failure/failure-data.js +56 -56
  144. package/dist/src/failure/failure-render.js +31 -23
  145. package/dist/src/failure/failure-state-change.js +17 -12
  146. package/dist/src/failure/time-spend.js +99 -35
  147. package/dist/src/files/files.js +111 -35
  148. package/dist/src/files/json-files.js +3 -3
  149. package/dist/src/files/json-object.js +28 -7
  150. package/dist/src/files/stat-cache.js +2 -2
  151. package/dist/src/files/tail.js +1 -1
  152. package/dist/src/gates/bootstrap-gates.js +4 -5
  153. package/dist/src/gates/budget-gates.js +177 -84
  154. package/dist/src/gates/disk-gate.js +1 -1
  155. package/dist/src/gates/gate-polls.js +108 -58
  156. package/dist/src/gates/maintenance-quota.js +10 -13
  157. package/dist/src/gates/pause-gates.js +28 -10
  158. package/dist/src/gates/readiness.js +7 -3
  159. package/dist/src/gates/role-cap-gates.js +8 -8
  160. package/dist/src/gates/startup-gate.js +94 -32
  161. package/dist/src/gates/streak-gate.js +54 -30
  162. package/dist/src/git/commit-message.js +77 -46
  163. package/dist/src/git/git-diff.js +16 -10
  164. package/dist/src/git/git-run.js +79 -38
  165. package/dist/src/git/git.js +36 -34
  166. package/dist/src/git/slot-salvage.js +253 -0
  167. package/dist/src/git/slots-state.js +6 -6
  168. package/dist/src/git/worktree-pool.js +132 -38
  169. package/dist/src/git/worktree-use.js +4 -11
  170. package/dist/src/git/worktree.js +94 -37
  171. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  172. package/dist/src/gui/gui-args.js +79 -8
  173. package/dist/src/gui/gui-backlog-commands.js +42 -18
  174. package/dist/src/gui/gui-backlog-endpoints.js +155 -0
  175. package/dist/src/gui/gui-command.js +8 -13
  176. package/dist/src/gui/gui-config-commands.js +94 -72
  177. package/dist/src/gui/gui-endpoint-commands.js +4 -3
  178. package/dist/src/gui/gui-endpoint-common.js +4 -4
  179. package/dist/src/gui/gui-endpoints.js +11 -380
  180. package/dist/src/gui/gui-history-endpoints.js +301 -0
  181. package/dist/src/gui/gui-operator-commands.js +108 -13
  182. package/dist/src/gui/gui-prompt-commands.js +11 -14
  183. package/dist/src/gui/gui-report-endpoints.js +90 -0
  184. package/dist/src/gui/gui-server.js +11 -4
  185. package/dist/src/gui/gui-team-commands.js +7 -7
  186. package/dist/src/gui/http-body.js +1 -1
  187. package/dist/src/history/history-data.js +122 -234
  188. package/dist/src/history/history-queued.js +147 -0
  189. package/dist/src/history/history-render.js +42 -22
  190. package/dist/src/history/history.js +193 -49
  191. package/dist/src/inbox/inbox-attachments.js +29 -30
  192. package/dist/src/inbox/inbox-cancel.js +10 -11
  193. package/dist/src/inbox/inbox-edit.js +9 -9
  194. package/dist/src/inbox/inbox-submit.js +18 -18
  195. package/dist/src/inbox/inbox-top.js +25 -9
  196. package/dist/src/inbox/inbox.js +42 -43
  197. package/dist/src/inbox/pending-prompt.js +26 -26
  198. package/dist/src/inbox/prompt-commands.js +62 -49
  199. package/dist/src/inbox/prompt-not-before.js +2 -2
  200. package/dist/src/init/init-templates.js +1 -2
  201. package/dist/src/init/init.js +28 -23
  202. package/dist/src/merge/backlog-conflicts.js +215 -35
  203. package/dist/src/merge/merge-batch.js +14 -8
  204. package/dist/src/merge/merge-cancel.js +53 -0
  205. package/dist/src/merge/merge-check-failures.js +44 -22
  206. package/dist/src/merge/merge-core.js +81 -32
  207. package/dist/src/merge/merge-diff.js +5 -4
  208. package/dist/src/merge/merge-drain.js +150 -11
  209. package/dist/src/merge/merge-git.js +177 -38
  210. package/dist/src/merge/merge-pipeline.js +15 -3
  211. package/dist/src/merge/merge-post-review.js +90 -0
  212. package/dist/src/merge/merge-queue-render.js +49 -11
  213. package/dist/src/merge/merge-queue.js +31 -15
  214. package/dist/src/merge/merge-requeue.js +102 -0
  215. package/dist/src/merge/merge-slot.js +61 -16
  216. package/dist/src/merge/merge-stack.js +33 -11
  217. package/dist/src/merge/merge-to-main.js +86 -27
  218. package/dist/src/merge/merge-top.js +69 -0
  219. package/dist/src/merge/merge-verify.js +12 -10
  220. package/dist/src/notes-cli.js +30 -15
  221. package/dist/src/operator/operator-commands.js +192 -81
  222. package/dist/src/operator/operator-intent.js +69 -42
  223. package/dist/src/operator/operator-requests.js +35 -40
  224. package/dist/src/operator/retire.js +5 -6
  225. package/dist/src/operator/revert.js +142 -34
  226. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  227. package/dist/src/orchestrator/orchestrator-poll.js +320 -0
  228. package/dist/src/orchestrator/orchestrator-scheduling.js +68 -369
  229. package/dist/src/orchestrator/orchestrator.js +128 -369
  230. package/dist/src/orchestrator/retention.js +42 -8
  231. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  232. package/dist/src/orchestrator/scheduling-scope.js +201 -0
  233. package/dist/src/orchestrator/scheduling-skip.js +185 -0
  234. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  235. package/dist/src/orchestrator/startup-migrations.js +11 -7
  236. package/dist/src/paths.js +116 -49
  237. package/dist/src/pi/command-shape.js +12 -1
  238. package/dist/src/pi/pi-args.js +6 -4
  239. package/dist/src/pi/pi-backend.js +6 -6
  240. package/dist/src/pi/pi-bin.js +54 -63
  241. package/dist/src/pi/pi-listed-models.js +53 -0
  242. package/dist/src/pi/pi-models.js +63 -30
  243. package/dist/src/pi/pi-run-result.js +1 -1
  244. package/dist/src/pi/pi-settings.js +1 -1
  245. package/dist/src/pi/pi-stream.js +6 -6
  246. package/dist/src/pi/pi-watchdogs.js +18 -19
  247. package/dist/src/pi-extension/bounded-output.js +5 -3
  248. package/dist/src/pi-extension/context-budget.js +2 -2
  249. package/dist/src/pi-extension/context-shake.js +1 -1
  250. package/dist/src/pi-extension/context-window.js +0 -1
  251. package/dist/src/pi-extension/full-output.js +3 -10
  252. package/dist/src/pi-extension/strip-thinking.js +0 -1
  253. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  254. package/dist/src/pi-extension/write-guard.js +37 -0
  255. package/dist/src/process/launch-services.js +1 -2
  256. package/dist/src/process/node-bin.js +16 -0
  257. package/dist/src/process/process-group.js +8 -18
  258. package/dist/src/process/process.js +41 -2
  259. package/dist/src/process/run-marker.js +18 -6
  260. package/dist/src/process/supervisor.js +22 -21
  261. package/dist/src/prompt/dialect.js +17 -2
  262. package/dist/src/prompt/gate-notes.js +203 -0
  263. package/dist/src/prompt/gate-prompts.js +121 -211
  264. package/dist/src/prompt/notes-backlog.js +181 -0
  265. package/dist/src/prompt/principles.js +1 -2
  266. package/dist/src/prompt/prompt-followup.js +19 -14
  267. package/dist/src/prompt/prompt.js +129 -204
  268. package/dist/src/redeploy/redeploy-policy.js +24 -19
  269. package/dist/src/redeploy/redeploy-probes.js +1 -1
  270. package/dist/src/redeploy/redeploy.js +2 -3
  271. package/dist/src/redeploy/redeployer.js +20 -20
  272. package/dist/src/redeploy/self-reload.js +2 -1
  273. package/dist/src/report/report-data.js +161 -77
  274. package/dist/src/report/report-fold.js +129 -52
  275. package/dist/src/report/report-main-red.js +55 -0
  276. package/dist/src/report/report-render.js +133 -64
  277. package/dist/src/report/report-types.js +1 -0
  278. package/dist/src/report/report.js +26 -9
  279. package/dist/src/request-timeouts.js +2 -2
  280. package/dist/src/review/known-flakes.js +100 -1
  281. package/dist/src/review/post-merge-review.js +106 -0
  282. package/dist/src/review/protected-edits.js +4 -5
  283. package/dist/src/review/review-followup.js +8 -8
  284. package/dist/src/review/review-precheck.js +66 -23
  285. package/dist/src/review/review-run.js +395 -0
  286. package/dist/src/review/review-verdict.js +2 -2
  287. package/dist/src/review/review.js +155 -360
  288. package/dist/src/review/secret-leaks.js +36 -16
  289. package/dist/src/review/suite-rerun.js +0 -1
  290. package/dist/src/roles/legacy-role-migration.js +80 -7
  291. package/dist/src/roles/role-catalog.js +69 -12
  292. package/dist/src/roles/role-guidance.js +55 -20
  293. package/dist/src/roles/role-labels.js +1 -2
  294. package/dist/src/roles/role-render.js +67 -50
  295. package/dist/src/roles/role-view.js +59 -47
  296. package/dist/src/roles/roles.js +31 -31
  297. package/dist/src/roles/runner-ids.js +97 -0
  298. package/dist/src/run/acceptance-greps.js +119 -0
  299. package/dist/src/run/feedback-filings.js +51 -0
  300. package/dist/src/{tick → run}/qa-coverage.js +9 -27
  301. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  302. package/dist/src/run/run-detail-data.js +179 -0
  303. package/dist/src/run/run-detail-render.js +98 -0
  304. package/dist/src/run/run-detail.js +168 -0
  305. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +72 -60
  306. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  307. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +108 -73
  308. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  309. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +108 -50
  310. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  311. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  312. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +91 -56
  313. package/dist/src/run/selfcheck-digest.js +40 -0
  314. package/dist/src/run/stage-backlog-checks.js +249 -0
  315. package/dist/src/run/stage-check.js +172 -0
  316. package/dist/src/run/stage-path-checks.js +730 -0
  317. package/dist/src/{loop → runner}/leftover.js +80 -25
  318. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  319. package/dist/src/runner/revision.js +202 -0
  320. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  321. package/dist/src/runner/runner-outcome.js +107 -0
  322. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  323. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +21 -15
  324. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +218 -240
  325. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  326. package/dist/src/runner/runner-state.js +102 -0
  327. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  328. package/dist/src/scheduling/backoff.js +38 -38
  329. package/dist/src/scheduling/claims.js +35 -22
  330. package/dist/src/scheduling/once-round.js +22 -22
  331. package/dist/src/scheduling/quiet-hours.js +5 -6
  332. package/dist/src/scheduling/scheduling.js +74 -64
  333. package/dist/src/scheduling/work-merged-cache.js +2 -2
  334. package/dist/src/status/status-budget.js +55 -47
  335. package/dist/src/status/status-data.js +43 -16
  336. package/dist/src/status/status-inbox.js +2 -2
  337. package/dist/src/status/status-install.js +0 -1
  338. package/dist/src/status/status-polls.js +28 -28
  339. package/dist/src/status/status-roles.js +39 -13
  340. package/dist/src/team/error-storm.js +8 -8
  341. package/dist/src/team/failure-spread.js +2 -2
  342. package/dist/src/team/orchestrator-info.js +15 -9
  343. package/dist/src/team/reclaim.js +5 -6
  344. package/dist/src/team/team-hold.js +7 -7
  345. package/dist/src/team/team-polls.js +91 -50
  346. package/dist/src/team/team-state.js +8 -2
  347. package/dist/src/text/datetime.js +63 -17
  348. package/dist/src/text/format.js +48 -11
  349. package/dist/src/text/markdown.js +8 -5
  350. package/dist/src/text/phrases.js +57 -23
  351. package/dist/src/text/suggest.js +1 -1
  352. package/dist/src/text/text.js +67 -13
  353. package/dist/src/ui/badges.js +65 -37
  354. package/dist/src/ui/gui/gui-client-backlog.js +292 -0
  355. package/dist/src/ui/gui/gui-client-boot.js +32 -26
  356. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  357. package/dist/src/ui/gui/gui-client-drawer-config.js +212 -0
  358. package/dist/src/ui/gui/gui-client-drawer.js +126 -285
  359. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  360. package/dist/src/ui/gui/gui-client-history.js +434 -67
  361. package/dist/src/ui/gui/gui-client-labels.js +1 -2
  362. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  363. package/dist/src/ui/gui/gui-client-model.js +27 -36
  364. package/dist/src/ui/gui/gui-client-operator.js +55 -51
  365. package/dist/src/ui/gui/gui-client-pending.js +227 -23
  366. package/dist/src/ui/gui/gui-client-report-chart.js +464 -0
  367. package/dist/src/ui/gui/gui-client-report.js +87 -427
  368. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  369. package/dist/src/ui/gui/gui-client-settings.js +56 -4
  370. package/dist/src/ui/gui/gui-client-team.js +25 -465
  371. package/dist/src/ui/gui/gui-client.js +27 -25
  372. package/dist/src/ui/gui/gui-icons.js +0 -2
  373. package/dist/src/ui/gui/gui-page.js +9 -7
  374. package/dist/src/ui/gui/gui-styles.js +34 -34
  375. package/dist/src/ui/progress-data.js +9 -11
  376. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +31 -31
  377. package/dist/src/ui/status-model.js +133 -125
  378. package/dist/src/ui/status-payload.js +136 -49
  379. package/dist/src/ui/status-render.js +101 -82
  380. package/dist/src/ui/team-alerts.js +248 -57
  381. package/dist/src/ui/tone.js +5 -6
  382. package/dist/src/ui/transcript-run.js +58 -0
  383. package/dist/src/ui/transcript-tail.js +28 -20
  384. package/dist/src/ui/transcript.js +63 -23
  385. package/dist/src/ui/tui/tui-app.js +0 -1
  386. package/dist/src/ui/tui/tui-backlog.js +51 -14
  387. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  388. package/dist/src/ui/tui/tui-editor-attach.js +3 -5
  389. package/dist/src/ui/tui/tui-editor-backends.js +132 -0
  390. package/dist/src/ui/tui/tui-editor-brief.js +10 -12
  391. package/dist/src/ui/tui/tui-editor-budget.js +35 -31
  392. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  393. package/dist/src/ui/tui/tui-editor-concurrency.js +24 -0
  394. package/dist/src/ui/tui/tui-editor-config.js +86 -0
  395. package/dist/src/ui/tui/tui-editor-entry.js +36 -47
  396. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  397. package/dist/src/ui/tui/tui-editor-instances.js +37 -0
  398. package/dist/src/ui/tui/tui-editor-instructions.js +136 -0
  399. package/dist/src/ui/tui/tui-editor-interval.js +11 -9
  400. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  401. package/dist/src/ui/tui/tui-editor-notes.js +3 -5
  402. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  403. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  404. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  405. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  406. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  407. package/dist/src/ui/tui/tui-editor-quiet.js +99 -35
  408. package/dist/src/ui/tui/tui-editor-retire.js +4 -6
  409. package/dist/src/ui/tui/tui-editor-role-key.js +76 -0
  410. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  411. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  412. package/dist/src/ui/tui/tui-editor-session.js +60 -34
  413. package/dist/src/ui/tui/tui-editor-slots.js +23 -0
  414. package/dist/src/ui/tui/tui-editors.js +35 -2
  415. package/dist/src/ui/tui/tui-frame.js +56 -11
  416. package/dist/src/ui/tui/tui-history.js +8 -9
  417. package/dist/src/ui/tui/tui-input.js +13 -11
  418. package/dist/src/ui/tui/tui-keymap.js +0 -1
  419. package/dist/src/ui/tui/tui-keys.js +24 -9
  420. package/dist/src/ui/tui/tui-pane-activity.js +14 -11
  421. package/dist/src/ui/tui/tui-pane-backlog.js +171 -126
  422. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  423. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  424. package/dist/src/ui/tui/tui-pane-health.js +58 -8
  425. package/dist/src/ui/tui/tui-pane-history.js +402 -82
  426. package/dist/src/ui/tui/tui-pane-pending.js +308 -13
  427. package/dist/src/ui/tui/tui-pane-queued.js +71 -55
  428. package/dist/src/ui/tui/tui-pane-report.js +46 -14
  429. package/dist/src/ui/tui/tui-pane-session.js +25 -6
  430. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  431. package/dist/src/ui/tui/tui-pane.js +80 -23
  432. package/dist/src/ui/tui/tui-panes.js +100 -30
  433. package/dist/src/ui/tui/tui.js +14 -14
  434. package/dist/src/verdict/fix-claim.js +82 -32
  435. package/dist/src/verdict/no-change.js +4 -4
  436. package/dist/src/verdict/refusal.js +40 -16
  437. package/dist/src/verdict/reply-contract.js +42 -31
  438. package/dist/src/version.js +0 -1
  439. package/package.json +2 -1
  440. package/dist/src/loop/revision.js +0 -70
  441. package/dist/src/roles/loop-ids.js +0 -77
  442. package/dist/src/tick/stage-check.js +0 -419
  443. package/dist/src/tick/telemetry-digest.js +0 -25
  444. package/dist/src/tick/tick-detail-data.js +0 -108
  445. package/dist/src/tick/tick-detail.js +0 -163
  446. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -1,50 +1,30 @@
1
- // vocabulary: plain
2
1
  import fs from "node:fs";
3
2
  import os from "node:os";
4
3
  import path from "node:path";
5
- import { enabledRoleIds, knownRoleIds, parseJsonConfig, loadConfig, loadConfigSafe, slotCount, } from "../config/config.js";
4
+ import { enabledRoleIds, parseJsonConfig, loadConfig, loadConfigSafe, } from "../config/config.js";
6
5
  import { exampleConfigProblem, exampleDrift } from "../config/config-example.js";
7
- import { detectBuildCheck } from "../build/build-check-detect.js";
8
- import { isPassingFullCheck } from "../build/build-check-events.js";
9
- import { CUSTOM_NAME_RE } from "../config/config-schema.js";
10
6
  import { bootstrapStatus } from "../gates/bootstrap-gates.js";
11
- import { buildStaleness, isSelfHosted, readBuildInfo, STALE_INPUTS_LABEL } from "../build/build-info.js";
12
- import { envPath, findOnPath, subdirectoryNames } from "../files/files.js";
7
+ import { envPath, findOnPath, readTextOrNull, treeBytes } from "../files/files.js";
13
8
  import { readJsonFile } from "../files/json-files.js";
14
9
  import { PACKAGE_JSON, belowNodeFloor, packageEnginesNode } from "../version.js";
15
10
  import { GIT_MISSING_MESSAGE } from "../git/git-run.js";
16
- import { branchExists, currentBranch, hasCommits, branchesPhrase, isGitRepo, listBranches, refSha, repoToplevel, } from "../git/git.js";
17
- import { DETACHED_HEAD_MESSAGE, NOT_A_REPO_MESSAGE, NOT_INITIALIZED_MESSAGE, NO_COMMITS_MESSAGE, } from "../gates/readiness.js";
18
- import { agentBinSourceLabelFor, agentMissingMessage, findAgentBinary, resolveAgentBin } from "../pi/pi-bin.js";
19
- import { piModelsPath } from "../pi/pi-models.js";
20
- import { piSettingsPath, readPiDefaultModel } from "../pi/pi-settings.js";
21
- import { seamConcurrencyKey } from "../config/config-views.js";
22
- import { resolvedAgentConfig } from "../config/config-views.js";
23
- import { seamsInUse } from "../config/config-fallbacks.js";
24
- import { classifyLock, readLockPid } from "../concurrency/lock.js";
25
- import { CONFIG_BASENAME, EXAMPLE_CONFIG_BASENAME, branchName, STATE_DIR, configPath, eventsLogPath, mergeLockDir, worktreesDir } from "../paths.js";
26
- import { BYTES_PER_GB, diskVolumePath, sampleFreeBytes } from "../gates/disk-gate.js";
27
- import { errorMessage, textOr } from "../text/text.js";
28
- import { lowerMedian } from "../collections.js";
29
- import { formatGB, shortSha } from "../text/format.js";
11
+ import { branchExists, currentBranch, hasCommits, branchesPhrase, isGitRepo, repoToplevel, } from "../git/git.js";
12
+ import { DETACHED_HEAD_MESSAGE, NOT_A_REPO_MESSAGE, NOT_INITIALIZED_MESSAGE, teamWorkspaceMessage, NO_COMMITS_MESSAGE, } from "../gates/readiness.js";
13
+ import { backendBinSourceLabelFor, backendMissingMessage, findBackendBinary, resolveBackendBin } from "../pi/pi-bin.js";
14
+ import { resolvedBackendConfig } from "../config/config-views.js";
15
+ import { CONFIG_BASENAME, EXAMPLE_CONFIG_BASENAME, configPath, owningTeamRoot } from "../paths.js";
16
+ import { errorMessage } from "../text/text.js";
17
+ import { formatBytes } from "../text/format.js";
30
18
  import { briefFile } from "../brief/brief.js";
31
- import { eventLogState, eventsSince, readEventsSince } from "../events/event-window.js";
32
- import { finiteNumber } from "../files/json-object.js";
33
- import { agree, plural, shortSpanPhrase } from "../text/phrases.js";
34
- import { DIRECTOR_ROLE } from "../roles/roles.js";
35
- import { readSlotsState } from "../git/slots-state.js";
36
- import { agentsInUse, unregisteredBackendInUse } from "../gates/startup-gate.js";
37
- import { claudeSeamsInUse, opencodeSeamsInUse, piSeamsInUse, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./doctor-model-checks.js";
19
+ import { plural } from "../text/phrases.js";
20
+ import { backendsInUse, unregisteredBackendInUse } from "../gates/startup-gate.js";
21
+ import { claudeSeamsInUse, minVersionOutcome, opencodeSeamsInUse, piSeamsInUse, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./doctor-model-checks.js";
38
22
  import { claudeConfigDir } from "../agent/claude/claude-backend.js";
39
23
  import { boundedProbe } from "./probe.js";
40
24
  import { childEnv } from "../process/process.js";
41
25
  import { opencodeBackend, OPENCODE_MIN_VERSION } from "../agent/opencode/opencode-backend.js";
42
26
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
43
27
  import { spawnSync } from "node:child_process";
44
- import { loadLoopState } from "../loop/loop-state.js";
45
- import { INSTANCE_ROLES, loopEnabled, loopIds } from "../roles/loop-ids.js";
46
- import { CLAIM_IDLE_MAX_MS, listedKeys } from "../scheduling/claims.js";
47
- import { flakeCounts, KNOWN_FLAKE_WINDOW_MS } from "../review/known-flakes.js";
48
28
  /** The floor doctor falls back to when package.json — the one source of the declared
49
29
  * engines.node spec, read below — cannot be read (a broken install, the version command's
50
30
  * story). The CLI's startup gate enforces the spec itself; this check stays a warning so a
@@ -70,18 +50,29 @@ export function checkNodeVersion(version = process.versions.node, floor = packag
70
50
  /** git binary — fail with the shared GIT_MISSING_MESSAGE so every entry point reports the
71
51
  * same fix for a machine without git installed. Takes an explicit PATH so tests can exercise
72
52
  * the missing branch by passing "" (no PATH mutation, no spawning). The agent-binary check's
73
- * rule is richer (bare name vs path-shaped) and lives in pi/pi-bin.ts's findAgentBinary. */
53
+ * rule is richer (bare name vs path-shaped) and lives in pi/pi-bin.ts's findBackendBinary. */
74
54
  export function checkGitBinary(pathEnv = envPath()) {
75
55
  const found = findOnPath("git", pathEnv);
76
56
  if (!found)
77
57
  return failOutcome(GIT_MISSING_MESSAGE);
78
58
  return okOutcome(found);
79
59
  }
60
+ /** The running team's own node binary is still on disk — a Homebrew node upgrade under the
61
+ * team deletes the old keg while the team keeps running on it, leaving `process.execPath`
62
+ * naming a file that no longer exists (BUGS.md 2026-10-21). Every harness spawn that does not
63
+ * fall back to PATH's `node` errors until the operator restarts the team on the new node, so
64
+ * doctor surfaces it as a warn. Takes an explicit path so tests exercise the missing branch
65
+ * without touching this process. */
66
+ export function checkExecPath(execPath = process.execPath) {
67
+ if (fs.existsSync(execPath))
68
+ return okOutcome(execPath);
69
+ return warnOutcome(`the running team's node binary ${execPath} no longer exists — node was upgraded under it; restart the team on the new node`);
70
+ }
80
71
  /** Repo ready — the git/git.ts predicates in requireReadyRepo's order, so doctor and the
81
72
  * readiness gate cannot drift: not a git repo → no commits yet → detached HEAD. Reports the
82
73
  * resolved toplevel (not the cwd — doctor must say where .tumwater/ lives) and the branch the
83
74
  * team would target: a configured `baseBranch` wins (`run --branch` is per-invocation), and
84
- * one that does not exist fails here instead of at the first tick. `config` is optional so
75
+ * one that does not exist fails here instead of at the first run. `config` is optional so
85
76
  * the check stands alone; runDoctor passes the loaded config behind a guard. */
86
77
  export async function checkRepo(root, config) {
87
78
  if (!(await isGitRepo(root)))
@@ -115,8 +106,10 @@ export async function checkRepo(root, config) {
115
106
  * is watching). The template problem outranks drift: drift is computed from a parse that has
116
107
  * already failed. */
117
108
  export function checkInit(root) {
118
- if (!fs.existsSync(configPath(root)))
119
- return failOutcome(NOT_INITIALIZED_MESSAGE);
109
+ if (!fs.existsSync(configPath(root))) {
110
+ const owner = owningTeamRoot(root);
111
+ return failOutcome(owner === undefined ? NOT_INITIALIZED_MESSAGE : teamWorkspaceMessage(owner));
112
+ }
120
113
  try {
121
114
  const config = loadConfig(root);
122
115
  const templateProblem = exampleConfigProblem(root);
@@ -147,16 +140,16 @@ export function checkConfigKeyNames(root) {
147
140
  return okOutcome("current key names");
148
141
  return warnOutcome(`old key names: ${renamed.join(", ")} — rename them in tumwater.json; both spellings work until then`);
149
142
  }
150
- /** Which file holds the project brief (the managed initial prompt + status sections,
143
+ /** Which file holds the project brief (the managed brief text + status sections,
151
144
  * plans/portability.md §7a/7): TUMWATER.md first, README.md as the compatibility path. A repo
152
- * whose README carries no markers — or that has no brief at all — still boots, but every loop
145
+ * whose README carries no markers — or that has no brief at all — still boots, but every role
153
146
  * runs without its project prompt, so that state is a warning, not an error: it is exactly
154
147
  * what a repo looks like between `git clone` and `tumwater init` on purpose (e.g. 7b's
155
148
  * `--dry-run`). */
156
149
  export function checkBrief(root) {
157
150
  // briefFile throws on a brief candidate that exists but cannot be read (a directory at
158
151
  // the README.md path, a permission-lost file) — report it as the check's failure instead
159
- // of crashing doctor: the team would run without its brief every tick.
152
+ // of crashing doctor: the team would run without its brief every run.
160
153
  let owner;
161
154
  try {
162
155
  owner = briefFile(root);
@@ -172,7 +165,7 @@ export function checkBrief(root) {
172
165
  return warnOutcome("no brief file — run `tumwater init <prompt>` to seed one");
173
166
  }
174
167
  /** New-project bootstrap (plans/work-ratio.md, part 2/2): while active, report its progress as
175
- * an ok check — a young project's deliberate hold on the maintenance loops is not a fault. A
168
+ * an ok check — a young project's deliberate hold on the maintenance roles is not a fault. A
176
169
  * missing or unreadable config, or no `bootstrap` entry, reads as not bootstrapping. Read-only,
177
170
  * like every doctor check: it never writes the latch or runs the gate's poll. */
178
171
  export function checkBootstrap(root, config) {
@@ -185,39 +178,39 @@ export function checkBootstrap(root, config) {
185
178
  * whenever the value is not the PATH default, so a configured binary is never mistaken for the
186
179
  * ambient one; a missing one fails with that agent's own message, so a wrong claude path never
187
180
  * reads as a missing pi. */
188
- function agentBinaryCheck(config, agent, pathEnv) {
189
- const resolved = resolveAgentBin(config, agent);
190
- const found = findAgentBinary(resolved, pathEnv);
181
+ function backendBinaryCheck(config, agent, pathEnv) {
182
+ const resolved = resolveBackendBin(config, agent);
183
+ const found = findBackendBinary(resolved, pathEnv);
191
184
  if (!found)
192
- return failOutcome(agentMissingMessage(resolved, agent));
185
+ return failOutcome(backendMissingMessage(resolved, agent));
193
186
  if (resolved.source === "default")
194
187
  return okOutcome(found);
195
- const from = agentBinSourceLabelFor(agent, resolved.source);
188
+ const from = backendBinSourceLabelFor(agent, resolved.source);
196
189
  return okOutcome(`${found} — resolved from ${from}`);
197
190
  }
198
191
  /** Agent binary (plans/portability.md §5/7) — every agent an enabled seam resolves to
199
- * (agentsInUse, the startup gate's rule), resolved through the same resolveAgentBin the spawn
192
+ * (backendsInUse, the startup gate's rule), resolved through the same resolveBackendBin the spawn
200
193
  * uses, so doctor and the harness can never disagree about which binary runs (a malformed
201
194
  * tumwater.json resolves to the pi default; checkInit reports the config problem separately).
202
- * Executability is pi/pi-bin.ts's shared findAgentBinary — the same bare-name PATH lookup,
195
+ * Executability is pi/pi-bin.ts's shared findBackendBinary — the same bare-name PATH lookup,
203
196
  * path-shaped accessSync(X_OK) rule, and null-on-unusable verdict the startup gate asks — so
204
- * doctor cannot pass a binary the gate would reject. resolveAgentBin normalizes path-shaped
197
+ * doctor cannot pass a binary the gate would reject. resolveBackendBin normalizes path-shaped
205
198
  * values against the process cwd at resolution time, so what doctor tests is exactly what
206
199
  * spawns. A claude-only team checks claude, not pi. The check fails on the first missing
207
200
  * binary, naming its agent; an ok detail joins each agent's resolved path and source. */
208
- export function checkAgentBinary(root, pathEnv = envPath()) {
201
+ export function checkBackendBinary(root, pathEnv = envPath()) {
209
202
  const { config } = loadConfigSafe(root);
210
- const agents = config !== undefined ? agentsInUse(config) : ["pi"];
203
+ const agents = config !== undefined ? backendsInUse(config) : ["pi"];
211
204
  // A seam backend with no registered adapter in this build can never run; naming that beats
212
205
  // the binary check (which would pass an installed binary only to fall through to pi). The
213
- // same rule and wording as the startup gate's. After opencode's adapter lands (part 5/7)
214
- // this stays the general rule for any future id.
206
+ // same rule and wording as the startup gate's, and it generalizes the same way: opencode
207
+ // and codex now have adapters registered, so only a future id without one trips it.
215
208
  const unregistered = config !== undefined ? unregisteredBackendInUse(config) : undefined;
216
209
  if (unregistered !== undefined)
217
210
  return failOutcome(`backend ${unregistered} is configured but this build has no adapter for it`);
218
211
  const checks = agents.map((agent) => ({
219
212
  agent,
220
- outcome: agentBinaryCheck(config ?? {}, agent, pathEnv),
213
+ outcome: backendBinaryCheck(config ?? {}, agent, pathEnv),
221
214
  }));
222
215
  const failed = checks.find(({ outcome }) => outcome.level === "fail");
223
216
  if (failed)
@@ -226,46 +219,6 @@ export function checkAgentBinary(root, pathEnv = envPath()) {
226
219
  return checks[0].outcome;
227
220
  return okOutcome(checks.map(({ agent, outcome }) => `${agent}: ${outcome.detail}`).join("; "));
228
221
  }
229
- /** The total bytes under one directory tree, counted iteratively and skipping unreadable
230
- * entries: a permission-denied subdirectory must not fail doctor. */
231
- function treeBytes(dir) {
232
- let total = 0;
233
- const stack = [dir];
234
- while (stack.length > 0) {
235
- const current = stack.pop();
236
- let entries;
237
- try {
238
- entries = fs.readdirSync(current, { withFileTypes: true });
239
- }
240
- catch {
241
- continue;
242
- }
243
- for (const entry of entries) {
244
- const full = path.join(current, entry.name);
245
- if (entry.isDirectory())
246
- stack.push(full);
247
- else if (entry.isFile()) {
248
- try {
249
- total += fs.statSync(full).size;
250
- }
251
- catch {
252
- // A file that vanished mid-walk contributes nothing.
253
- }
254
- }
255
- }
256
- }
257
- return total;
258
- }
259
- /** A human-readable byte count for the transcript-size line. */
260
- function formatBytes(n) {
261
- if (n >= 1024 ** 3)
262
- return `${(n / 1024 ** 3).toFixed(1)} GB`;
263
- if (n >= 1024 ** 2)
264
- return `${(n / 1024 ** 2).toFixed(1)} MB`;
265
- if (n >= 1024)
266
- return `${(n / 1024).toFixed(1)} KB`;
267
- return `${n} B`;
268
- }
269
222
  /** claude transcript disk — the total size of the claude config dir's `projects/`
270
223
  * (plans/agent-backends.md "Sessions" retention): the perf adapter ages transcripts out under
271
224
  * its own cleanupPeriodDays, so an operator asking where claude's disk went gets the number
@@ -291,18 +244,14 @@ export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs
291
244
  const resolved = opencodeBackend.resolveBin(config);
292
245
  const result = await boundedProbe(resolved.bin, ["--version"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
293
246
  const found = result !== null && result.code === 0 ? parseOpencodeCliVersion(result.stdout) : undefined;
294
- if (found === undefined)
295
- return warnOutcome("could not check — `opencode --version` did not run or its output was unreadable");
296
- if (belowNodeFloor(found, `>=${OPENCODE_MIN_VERSION}`))
297
- return failOutcome(`opencode ${found} is older than ${OPENCODE_MIN_VERSION} — run \`brew install opencode\``);
298
- return okOutcome(`opencode ${found} (>= ${OPENCODE_MIN_VERSION})`);
247
+ return minVersionOutcome("opencode", found, OPENCODE_MIN_VERSION, "run `brew install opencode`");
299
248
  }
300
249
  export function checkClaudeTranscripts(config, homeDir = os.homedir(), env = process.env) {
301
250
  if (!config)
302
251
  return okOutcome("no readable tumwater.json — nothing to verify");
303
252
  if (!claudeSeamsInUse(config))
304
253
  return okOutcome("no model jobs run on claude");
305
- const configDir = resolvedAgentConfig(config, "claude").configDir;
254
+ const configDir = resolvedBackendConfig(config, "claude").configDir;
306
255
  const dir = path.join(claudeConfigDir(env, homeDir, configDir), "projects");
307
256
  let slugs;
308
257
  try {
@@ -360,8 +309,8 @@ export function checkPiExtensions(config, homeDir = os.homedir(), pathEnv = envP
360
309
  const parts = [];
361
310
  // Installed pi packages (pi list, bounded): each stdout line a package name. A probe that
362
311
  // cannot run or produces nothing contributes nothing; the rest still name what team runs skip.
363
- const resolved = resolveAgentBin(config, "pi");
364
- const piBin = findAgentBinary(resolved, pathEnv);
312
+ const resolved = resolveBackendBin(config, "pi");
313
+ const piBin = findBackendBinary(resolved, pathEnv);
365
314
  if (piBin !== null) {
366
315
  try {
367
316
  const stdout = spawnSync(piBin, ["list"], {
@@ -435,21 +384,14 @@ function opencodePluginNamesFunes(homeDir, readPath) {
435
384
  * integrations/claude-plugin`) — that hook indexes every session under `~/.claude/projects`,
436
385
  * which is exactly what a team config dir moves out from under. It also notes a global
437
386
  * OpenCode plugin naming funes. `pathEnv`, `homeDir`, and `readPath` are test seams. */
438
- export function checkFunes(config, pathEnv = envPath(), homeDir = os.homedir(), readPath = (file) => {
439
- try {
440
- return fs.readFileSync(file, "utf8");
441
- }
442
- catch {
443
- return null;
444
- }
445
- }) {
387
+ export function checkFunes(config, pathEnv = envPath(), homeDir = os.homedir(), readPath = readTextOrNull) {
446
388
  if (!config)
447
389
  return null;
448
390
  if (findOnPath("funes", pathEnv) === null)
449
391
  return null;
450
392
  const notes = [];
451
393
  if (claudeSeamsInUse(config)) {
452
- const configDir = resolvedAgentConfig(config, "claude").configDir;
394
+ const configDir = resolvedBackendConfig(config, "claude").configDir;
453
395
  const plugin = path.join(homeDir, ".funes", "integrations", "claude-plugin");
454
396
  if (configDir === undefined && fs.existsSync(plugin)) {
455
397
  return warnOutcome("funes's Claude Code hook indexes team transcripts into your personal memory — set backends.claude.configDir and log in there once (`CLAUDE_CONFIG_DIR=<dir> claude auth login`)");
@@ -464,472 +406,3 @@ export function checkFunes(config, pathEnv = envPath(), homeDir = os.homedir(),
464
406
  const detail = notes.length > 0 ? `funes on PATH, no team reach: ${notes.join("; ")}` : "funes on PATH, no team reach";
465
407
  return okOutcome(detail);
466
408
  }
467
- /** .tumwater writable — absent is fine (created on first run); present, prove it by writing
468
- * and deleting a temp file inside. The probe leaves the dir's listing unchanged after the run.
469
- * The write is the property under test: only a write that fails is "not writable", so the
470
- * cleanup below — deleting the probe, sweeping stales — never vetoes a dir the write just
471
- * proved writable, and a probe left over is swept rather than accumulated. */
472
- export function checkStateDir(root) {
473
- const dir = path.join(root, STATE_DIR);
474
- if (!fs.existsSync(dir))
475
- return okOutcome("absent — created on first run");
476
- const probe = path.join(dir, `.doctor-probe-${process.pid}`);
477
- try {
478
- fs.writeFileSync(probe, "");
479
- }
480
- catch (err) {
481
- // A partial write may already have created the file — sweep it like any other leftover.
482
- sweepStaleProbes(dir);
483
- return failOutcome(`not writable: ${errorMessage(err)}`);
484
- }
485
- // The write proved writability. Deleting the probe is cleanup, not measurement: a removal
486
- // failure must not turn a succeeded write into a false alarm, and the stray is collected by
487
- // the sweep below (or a later pass's sweep).
488
- try {
489
- fs.rmSync(probe);
490
- }
491
- catch {
492
- // Best-effort — the stray is swept in a moment.
493
- }
494
- sweepStaleProbes(dir);
495
- return okOutcome("writable");
496
- }
497
- /** Best-effort removal of every `.doctor-probe-*` file in `dir` — the only artifact
498
- * checkStateDir creates, so any present probe is a leftover: a prior run killed between its
499
- * write and delete, a partial write above, or a removal that failed. Removed one at a time so
500
- * a single unremovable probe cannot stop the sweep. */
501
- function sweepStaleProbes(dir) {
502
- let names;
503
- try {
504
- names = fs.readdirSync(dir);
505
- }
506
- catch {
507
- return; // Unreadable dir — nothing to sweep.
508
- }
509
- for (const name of names) {
510
- if (!name.startsWith(".doctor-probe-"))
511
- continue;
512
- try {
513
- fs.rmSync(path.join(dir, name));
514
- }
515
- catch {
516
- // Best-effort — leave it for the next pass.
517
- }
518
- }
519
- }
520
- /** Event log readable — open and parse events.jsonl (and its rotated archive) so the one
521
- * failure the telemetry role is chartered to catch — a feed that hid itself — is named by the
522
- * pre-flight too. An absent or empty log is fine: a young project writes its first event on the
523
- * first tick. A present-but-unparseable log (a directory at the path, a permission failure, or
524
- * every complete line corrupt) fails, because every observer — telemetry, the TUI/GUI feeds,
525
- * `logs`, `report` — reads that one feed. Read-only. */
526
- export function checkEventLog(root) {
527
- let state;
528
- try {
529
- state = eventLogState(root);
530
- }
531
- catch (err) {
532
- return failOutcome(`could not read ${eventsLogPath(root)}: ${errorMessage(err)}`);
533
- }
534
- if (state === "unreadable")
535
- return failOutcome(`unreadable: ${eventsLogPath(root)} — the team's event feed cannot be read`);
536
- if (state === "empty")
537
- return okOutcome("no events yet");
538
- return okOutcome(`readable: ${eventsLogPath(root)}`);
539
- }
540
- /** Disk floor (plans/disk-floor.md, parts 1/4 and 2/4) — is there room on the volume the
541
- * worktrees live on? Fail below `diskHoldGB`, the same floor the running team holds new work
542
- * at, so doctor and the gate agree on when the disk is a problem. Warn in the band between
543
- * `diskHoldGB` and `diskReclaimGB`: pressure reclaim will delete ignored build outputs from
544
- * idle worktrees but the team still runs. Ok at or above `diskReclaimGB` (or when reclaim is
545
- * disabled and free space is above the hold). A statfs failure is a warning, not a failure: an
546
- * unmeasurable volume never holds the team, so it must not fail a scripted pre-flight. The
547
- * detail names the measured path, the free GB to one decimal, and the floor. `holdGB` 0
548
- * disables the hold, so a free space above `reclaimGB`'s own band (or `reclaimGB` 0) passes.
549
- * After a successful sample the detail also carries `diskReclaimPhrase` (what `disk_reclaim`
550
- * passes freed over the last 24 h); it is informational and never changes the level. */
551
- export function checkDiskSpace(root, holdGB, reclaimGB, sample = sampleFreeBytes) {
552
- const where = diskVolumePath(root);
553
- const free = sample(root);
554
- if (free === null)
555
- return warnOutcome(`cannot measure free space at ${where} (statfs failed) — the disk hold is off for this process`);
556
- const freeGB = free / BYTES_PER_GB;
557
- const freeText = `${formatGB(freeGB)} free at ${where}`;
558
- const phrase = diskReclaimPhrase(readEventsSince(root, 24 * 60 * 60 * 1000).events);
559
- const withReclaim = (detail) => (phrase ? `${detail} · ${phrase}` : detail);
560
- if (holdGB > 0 && freeGB < holdGB)
561
- return failOutcome(withReclaim(`${freeText} — below the ${holdGB} GB diskHoldGB floor; the team waits to start new work until space recovers`));
562
- if (reclaimGB > 0 && freeGB < reclaimGB)
563
- return warnOutcome(withReclaim(`${freeText} — below the ${reclaimGB} GB diskReclaimGB threshold; a pressure reclaim pass deletes ignored build outputs from idle worktrees`));
564
- const floor = holdGB > 0 ? `above the ${holdGB} GB floor` : "diskHoldGB is 0 (hold disabled)";
565
- return okOutcome(withReclaim(`${freeText}, ${floor}`));
566
- }
567
- /** The `reclaim in the last 24h freed …` phrase for the `disk space` row: the summed
568
- * `freedGB` of the `disk_reclaim` passes, their count, and how many ran under pressure (an
569
- * event without a `mode` counts as pressure, like its feed line). Each pass is a point event,
570
- * so summing cannot straddle the window edge. Undefined when no pass ran. */
571
- export function diskReclaimPhrase(events) {
572
- const passes = events.filter((e) => e.type === "disk_reclaim");
573
- if (passes.length === 0)
574
- return undefined;
575
- const freed = passes.reduce((sum, e) => sum + finiteNumber(e.freedGB, 0), 0);
576
- const pressure = passes.filter((e) => (typeof e.mode === "string" && e.mode ? e.mode : "pressure") === "pressure").length;
577
- const base = `reclaim in the last 24h freed ${formatGB(freed)} over ${plural(passes.length, "pass", "passes")}`;
578
- return pressure > 0 ? `${base}, ${pressure} under pressure` : base;
579
- }
580
- /** Merge lock — read-only classification via classifyLock (the same three cases the breaker
581
- * uses). A stale lock is a warning, not a failure: it self-heals on the next merge. */
582
- export function checkMergeLock(root) {
583
- const dir = mergeLockDir(root);
584
- switch (classifyLock(dir)) {
585
- case "absent":
586
- return okOutcome("not held");
587
- case "live": {
588
- const pid = readLockPid(dir);
589
- return okOutcome(pid !== null ? `held by running role (pid ${pid})` : "held (pid not yet written)");
590
- }
591
- case "stale":
592
- return warnOutcome("stale — will be broken on next merge");
593
- }
594
- }
595
- /** A pin older than a day is the doctor's stuck-pin signal: a paused or retired role is holding
596
- * a pooled checkout nobody will reuse (plans/worktree-pool.md, "Observability"). */
597
- const PIN_STALE_MS = 24 * 60 * 60 * 1000;
598
- /** Directory names under the worktrees root that the pool no longer owns: a legacy per-role
599
- * `<role>` checkout (the pre-pool layout) or a `_land-<role>` checkout from the merge queue. The
600
- * dedicated `director`, `_slot-<n>`, `_merge`, `_main`, `_gate-main` and `_build` checkouts are
601
- * expected and never reported. An unreadable worktrees dir means nothing to report. */
602
- function leftoverWorktreeDirs(root) {
603
- return subdirectoryNames(worktreesDir(root), (name) => name !== DIRECTOR_ROLE && !(name.startsWith("_") && !name.startsWith("_land-")));
604
- }
605
- /** The window of `slot_wait` events the pool row sums, and the total wait inside it that warns. */
606
- const SLOT_WAIT_CHECK_WINDOW_MS = 24 * 3600_000;
607
- const SLOT_WAIT_WARN_TOTAL_MS = 10 * 60_000;
608
- const PROVIDER_WAIT_CHECK_WINDOW_MS = 24 * 3600_000;
609
- const PROVIDER_WAIT_WARN_TOTAL_MS = 10 * 60_000;
610
- /** Worktree pool (plans/worktree-pool.md, part 5/5): report the pooled slots and warn on the
611
- * states an operator should act on — a legacy `<role>` or `_land-*` checkout the pool migration
612
- * left behind, a pin older than PIN_STALE_MS (a paused role holding a checkout), and the last
613
- * 24 h of `slot_wait` events totalling SLOT_WAIT_WARN_TOTAL_MS (the pool is too small). Read-only,
614
- * like every doctor check; `now` is a test seam for the pin-age arithmetic. */
615
- export function checkWorktreePool(root, now = Date.now()) {
616
- const slots = readSlotsState(root).slots;
617
- const leased = slots.filter((slot) => slot.lease !== null);
618
- const pins = slots.filter((slot) => slot.pinnedFor !== null);
619
- const stale = pins.filter((slot) => slot.pinnedAt != null && now - slot.pinnedAt > PIN_STALE_MS);
620
- const leftover = leftoverWorktreeDirs(root);
621
- const parts = [`${plural(slots.length, "workspace")}, ${leased.length} leased, ${pins.length} pinned`];
622
- if (leftover.length > 0)
623
- parts.push(`legacy worktrees still present: ${leftover.join(", ")}`);
624
- if (stale.length > 0) {
625
- parts.push(stale
626
- .map((slot) => `${textOr(slot.pinnedFor)} pinned on ${path.basename(slot.dir)} for ${Math.round((now - (slot.pinnedAt ?? now)) / 3_600_000)}h`)
627
- .join("; "));
628
- }
629
- let contended = false;
630
- try {
631
- const waits = eventsSince(root, SLOT_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "slot_wait");
632
- if (waits.length > 0) {
633
- let total = 0;
634
- let longest = waits[0];
635
- for (const ev of waits) {
636
- const waited = finiteNumber(ev.waitedMs, 0);
637
- total += waited;
638
- if (waited > finiteNumber(longest.waitedMs, 0))
639
- longest = ev;
640
- }
641
- contended = total >= SLOT_WAIT_WARN_TOTAL_MS;
642
- const purpose = longest.purpose === "verify" ? "check" : "run";
643
- parts.push(`${plural(waits.length, "wait")} to start in the last 24h, ${shortSpanPhrase(total)} total, longest ${shortSpanPhrase(finiteNumber(longest.waitedMs, 0))} (${textOr(longest.role)} ${purpose})${contended ? ` — raise worktreeSlots above ${slots.length}` : ""}`);
644
- }
645
- }
646
- catch (err) {
647
- parts.push(`could not read the event log for waits to start: ${errorMessage(err)}`);
648
- }
649
- const warn = leftover.length > 0 || stale.length > 0 || contended;
650
- return (warn ? warnOutcome : okOutcome)(parts.join(" — "));
651
- }
652
- /** Provider-slot contention: sums the last 24 h of `provider_wait` events (each a role tick
653
- * whose semaphore admission waited PROVIDER_WAIT_EVENT_MS or more, recorded in
654
- * orchestrator-launch.ts) and warns when the total reaches PROVIDER_WAIT_WARN_TOTAL_MS — the
655
- * provider cap is too small. Names every key that waited, busiest first (the key-less
656
- * bucket reads "the run cap") and, on a warn, each key's maxConcurrentPerProvider cap to raise. A
657
- * counterpoint to checkWorktreePool's slot-wait half; read-only, like every doctor check.
658
- * `now` is a test seam for the window. */
659
- export function checkProviderContention(root, config, now = Date.now()) {
660
- let count = 0;
661
- let total = 0;
662
- let longestMs = 0;
663
- const perKey = new Map();
664
- try {
665
- const waits = eventsSince(root, PROVIDER_WAIT_CHECK_WINDOW_MS, now, (ev) => ev.type === "provider_wait");
666
- for (const ev of waits) {
667
- count += 1;
668
- const waited = finiteNumber(ev.waitedMs, 0);
669
- total += waited;
670
- if (waited > longestMs)
671
- longestMs = waited;
672
- const key = typeof ev.key === "string" && ev.key.length > 0 ? ev.key : "";
673
- perKey.set(key, (perKey.get(key) ?? 0) + waited);
674
- }
675
- }
676
- catch (err) {
677
- // A read error leaves the level ok, exactly like the slot-wait branch's own read: the
678
- // check cannot measure, so it says so rather than crying wolf.
679
- return okOutcome(`could not read the event log for provider waits: ${errorMessage(err)}`);
680
- }
681
- if (count === 0)
682
- return okOutcome("no provider waits");
683
- // Rank the buckets by wait total, ties breaking on key name so the detail is deterministic.
684
- // The key-less bucket reads "the run cap" — maxConcurrent, since no seam keys to a provider.
685
- const ranked = [...perKey.entries()]
686
- .map(([key, total]) => ({ key, total }))
687
- .sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
688
- const bucketLabel = (key) => (key === "" ? "the run cap" : `the ${key} slot`);
689
- const cells = ranked.map((b) => `${bucketLabel(b.key)} ${shortSpanPhrase(b.total)}`);
690
- // A single bucket keeps the pre-existing wording, a bare `(the X slot)` without the total
691
- // (the total is already named by the `shortSpanPhrase(total)` in the clause before it);
692
- // two or more name each bucket's total so the ranking reads at a glance.
693
- const parenthetical = ranked.length === 1
694
- ? `(${bucketLabel(ranked[0].key)})`
695
- : `(${cells[0]}, also ${cells.slice(1).join(", ")})`;
696
- let detail = `${plural(count, "wait")} for a provider slot in the last 24h, ${shortSpanPhrase(total)} total, longest ${shortSpanPhrase(longestMs)} ${parenthetical}`;
697
- const contended = total >= PROVIDER_WAIT_WARN_TOTAL_MS;
698
- if (contended) {
699
- const advice = ranked
700
- .filter((b) => b.key !== "" && config?.maxConcurrentPerProvider?.[b.key] !== undefined)
701
- .map((b) => `raise maxConcurrentPerProvider.${b.key} above ${config?.maxConcurrentPerProvider?.[b.key]}`)
702
- .join(" and ");
703
- if (advice)
704
- detail += ` — ${advice}`;
705
- }
706
- return (contended ? warnOutcome : okOutcome)(detail);
707
- }
708
- /** The "leftover role branches" row: `tumwater/<id>` branches whose role is no longer enabled
709
- * (disabled in tumwater.json, or an instance above the configured count). Retirable ids are
710
- * the ones `tumwater retire --role <id>` accepts (knownRoleIds, or any well-formed custom-role name
711
- * with a leftover branch); any other id is listed by
712
- * full branch name with a manual-delete hint. The director and enabled roles are skipped.
713
- * Read-only, like every check: it only lists refs; an unreadable tumwater.json skips it. */
714
- export async function checkLeftoverRoleBranches(root, config) {
715
- if (!config)
716
- return okOutcome("no readable tumwater.json — nothing to verify");
717
- const prefix = branchName("");
718
- const known = new Set(knownRoleIds(config));
719
- const retirable = [];
720
- const unknown = [];
721
- for (const branch of await listBranches(root)) {
722
- if (!branch.startsWith(prefix))
723
- continue;
724
- const id = branch.slice(prefix.length);
725
- if (!id || id.includes("/") || id === DIRECTOR_ROLE || loopEnabled(config, id))
726
- continue;
727
- if (known.has(id) || CUSTOM_NAME_RE.test(id))
728
- retirable.push(id);
729
- else
730
- unknown.push(branch);
731
- }
732
- const parts = [];
733
- if (retirable.length > 0) {
734
- const n = retirable.length;
735
- parts.push(`${retirable.join(", ")} ${agree(n, "is", "are")} disabled but still ${agree(n, "has", "have")} a branch — \`tumwater retire --role <id>\` removes each (\`--dry-run\` previews)`);
736
- }
737
- if (unknown.length > 0) {
738
- parts.push(`${unknown.join(", ")} ${agree(unknown.length, "names", "name")} no role retire accepts — delete with \`git branch -D\` once its commits are not needed`);
739
- }
740
- return parts.length === 0 ? okOutcome("no branches left by disabled roles") : warnOutcome(parts.join("; "));
741
- }
742
- /** The ok detail of the instance check: non-zero counts, plans first, each pluralized. */
743
- function claimCounts(claims) {
744
- const bugs = claims.filter((c) => c.file === "BUGS.md").length;
745
- const plans = claims.length - bugs;
746
- const parts = [
747
- plans > 0 ? plural(plans, "claimed plan") : "",
748
- bugs > 0 ? plural(bugs, "claimed bug") : "",
749
- ].filter(Boolean);
750
- return parts.length > 0 ? parts.join(", ") : "no claimed plans or bugs";
751
- }
752
- /** Parallel work instances (plans/parallel-work-instances.md "Observability", part 6/7):
753
- * each instance's claimed plan or bug and the two states an operator should act on — a claim
754
- * whose key is no longer listed under its role's section (its instance is stuck on an entry
755
- * that moved), and a claim idle for more than CLAIM_IDLE_MAX_MS (the scheduler releases
756
- * these, so seeing one means the reader's view and the scheduler's disagree). An enabled
757
- * multi-instance role configured with fewer pooled slots than maxConcurrent also warns:
758
- * instances then contend for the same checkouts. A disabled role runs none of its
759
- * configured instances, so it never contends and
760
- * stays out of that warning.
761
- * Read-only, like every check; `now` is the claim-age test seam, and an unreadable
762
- * tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
763
- export function checkWorkInstances(root, config, now = Date.now()) {
764
- if (!config)
765
- return okOutcome("no readable tumwater.json — nothing to verify");
766
- const claims = [];
767
- for (const id of loopIds(config)) {
768
- const claim = loadLoopState(root, id).claim;
769
- if (claim)
770
- claims.push({ id, title: claim.title, key: claim.key, at: claim.at, file: claim.file });
771
- }
772
- const gone = claims.filter((c) => !listedKeys(root, c.id).has(c.key));
773
- const stale = claims.filter((c) => !gone.includes(c) && now - c.at > CLAIM_IDLE_MAX_MS);
774
- const multi = Object.entries(config.roles)
775
- .filter(([id, role]) => INSTANCE_ROLES.has(id) && role.enabled && (role.instances ?? 1) > 1)
776
- .map(([id]) => id);
777
- const slots = slotCount(config);
778
- const smallPool = multi.length > 0 && slots < config.maxConcurrent;
779
- if (gone.length === 0 && stale.length === 0 && !smallPool)
780
- return okOutcome(claimCounts(claims));
781
- const noun = (c) => (c.file === "BUGS.md" ? "bug" : "plan");
782
- const parts = [
783
- ...gone.map((c) => `claimed ${noun(c)} "${c.title}" (${c.id}) names an entry no longer listed`),
784
- ...stale.map((c) => `claimed ${noun(c)} "${c.title}" (${c.id}) has been idle ${Math.round((now - c.at) / 3_600_000)}h`),
785
- ];
786
- if (smallPool)
787
- parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
788
- return warnOutcome(parts.join("; "));
789
- }
790
- /** Provider concurrency caps (plans/provider-concurrency.md §8): audit the
791
- * `maxConcurrentPerProvider` map against the seams the config can actually spawn — each role,
792
- * the review gate, the resolver, the director, and each tier's fallback — so the operator
793
- * sees whether every cap names a provider in use and whether any cap actually binds. Warns on
794
- * a cap key no seam resolves to (a typo, or a provider not in use), a cap at or above
795
- * `maxConcurrent` (which never binds — the team total is the real ceiling), and a capped
796
- * team whose seams include one with no key (pi's default provider is unreadable, so that
797
- * seam cannot be capped). A config with no per-provider map or no caps is fine, and so is a
798
- * well-formed map; a broken config is reported by the init check. Takes `root` to read pi's
799
- * own default model (piSettingsPath), the same seam the orchestrator's admission uses, so a
800
- * bare no-model pi seam keys exactly as the team would key it. */
801
- export function checkProviderConcurrency(config, root) {
802
- if (!config)
803
- return okOutcome("no config to audit");
804
- const caps = config.maxConcurrentPerProvider;
805
- const capEntries = Object.entries(caps ?? {}).filter(([, v]) => Number.isInteger(v) && v > 0);
806
- if (capEntries.length === 0)
807
- return okOutcome("no per-provider caps configured");
808
- // The pi default model the orchestrator's admission seam carries: a bare no-model pi seam
809
- // resolves its key only through it, so a team whose default is readable has keyed seams and
810
- // only an unreadable default leaves a key-less seam to warn about.
811
- const piDefault = readPiDefaultModel(piSettingsPath(piModelsPath()), root);
812
- const seams = seamsInUse(config);
813
- const keyed = [];
814
- for (const s of seams) {
815
- const k = seamConcurrencyKey(s, piDefault);
816
- if (k !== undefined && !keyed.includes(k))
817
- keyed.push(k);
818
- }
819
- const keySet = new Set(keyed);
820
- const capKeys = capEntries.map(([k]) => k);
821
- const warnings = [];
822
- for (const key of capKeys) {
823
- if (!keySet.has(key))
824
- warnings.push(`cap key "${key}" resolves to no model job (a typo, or a provider not in use)`);
825
- }
826
- for (const [key, cap] of capEntries) {
827
- if (cap >= config.maxConcurrent)
828
- warnings.push(`cap "${key}" ${cap} is at or above maxConcurrent ${config.maxConcurrent} — it never binds`);
829
- }
830
- const capSet = new Set(capKeys);
831
- const keyless = seams.filter((s) => seamConcurrencyKey(s, piDefault) === undefined);
832
- if (capSet.size > 0 && keyless.length > 0) {
833
- warnings.push(`${keyless.length} model job(s) resolve to no provider key (pi's default provider is unreadable, so they cannot be capped): ${keyless.map((s) => s.label).join(", ")}`);
834
- }
835
- if (warnings.length === 0)
836
- return okOutcome(`${plural(capEntries.length, "provider cap")} — all model jobs resolve`);
837
- return warnOutcome(warnings.join("; "));
838
- }
839
- /** The `passing merge checks …` phrase for the `check` row: median and slowest wall clock of
840
- * the passed full-suite runs (`merge`, `batch`, `baseline` scopes — a `gate` check can run a
841
- * subset, and failed or skipped runs do not measure a full pass). The lower median keeps the
842
- * figure a run that happened. Undefined when no event qualifies. */
843
- export function checkDurationPhrase(events) {
844
- const durations = events
845
- .filter(isPassingFullCheck)
846
- .map((e) => finiteNumber(e.durationMs, 0))
847
- .filter((ms) => ms > 0);
848
- if (durations.length === 0)
849
- return undefined;
850
- const median = lowerMedian(durations);
851
- const slowest = Math.max(...durations);
852
- return `passing merge checks in the last 24h took ${shortSpanPhrase(median)} median, ${shortSpanPhrase(slowest)} slowest (${plural(durations.length, "run")})`;
853
- }
854
- /** Declared project check — names what the review gate's deterministic pre-check, the
855
- * red-main baseline, and redeploy's green check will run, without running it: a configured
856
- * `check.command` (plans/portability.md §6/7), else the npm auto-detection. None declared is
857
- * a warn, not informational (the 2026-09-05 stance predates non-npm targets being supported,
858
- * when a warn would have been noise no operator could act on — with `check.command` available
859
- * the warning is actionable): the three gates degrade to "no check" and a silently absent
860
- * safety layer is the failure this check exists to surface. Warn does not fail the exit code
861
- * (the fallback check's precedent, doctor-model-checks.ts's checkFallbackModel). */
862
- export function checkBuildCheck(root, config) {
863
- const check = detectBuildCheck(root, config ?? undefined);
864
- if (!check)
865
- return warnOutcome("none declared — the review gate's build pre-check, the red-main baseline, and redeploy's green check are all off (set `check.command` in tumwater.json for a non-npm repo)");
866
- const phrase = checkDurationPhrase(readEventsSince(root, 24 * 60 * 60 * 1000).events);
867
- const withTiming = (detail) => okOutcome(phrase ? `${detail} · ${phrase}` : detail);
868
- if (check.kind === "command") {
869
- const where = path.relative(root, check.cwd);
870
- return withTiming(where ? `${check.command} (cwd ${where})` : check.command);
871
- }
872
- const where = path.relative(root, check.rootDir);
873
- return withTiming(where ? `npm ${check.script} in ${where}` : `npm ${check.script}`);
874
- }
875
- /** The review gate's flake memory surfaced read-only: names which checks the gate kept excusing as
876
- * flakes over its own KNOWN_FLAKE_WINDOW_MS window. The gate logs `gate check failed then passed on
877
- * retry — flaky: <headline>` for each failure that passed on its one immediate re-run, and the
878
- * in-lock merge check logs the same under MERGE_FLAKY_WARNING_PREFIX when its kept-pin retry passes
879
- * (src/review/known-flakes.ts); that record is internal to the gate's matching, so a genuinely
880
- * flaky check silently burns a re-run per merge and hides real instability — exactly the
881
- * information that explains intermittent rejections. This check reads the same window and folds the
882
- * same normalized headline the gate matches with (flakeCounts, so the prefix slice-offset and
883
- * cluster normalization cannot drift), and warns only when a normalized check flapped more than
884
- * once, so a routine one-off flake stays quiet. ok/warn only — never a fail — so a flaky suite
885
- * cannot fail a scripted doctor pre-flight (the checkStalledPlans precedent; the exit-code rule is
886
- * outcome.ts's). */
887
- export function checkFlakyChecks(root) {
888
- const { events } = readEventsSince(root, KNOWN_FLAKE_WINDOW_MS);
889
- const flapped = flakeCounts(events).filter(([, n]) => n >= 2);
890
- if (flapped.length === 0)
891
- return okOutcome("no check flapped more than once in the last 24h");
892
- const render = ([k, n]) => `"${k}" ×${n}`;
893
- const [first, ...rest] = flapped;
894
- const more = rest.length > 0 ? ` (and ${rest.length} more: ${rest.map(render).join(", ")})` : "";
895
- return warnOutcome(`${agree(flapped.length, "check", "checks")} flapped more than once in the last 24h: ${render(first)}${more}`);
896
- }
897
- /** Build provenance — is the harness about to run (this process's dist/) the code main
898
- * describes? Only meaningful when this project IS the harness (isSelfHosted); elsewhere the
899
- * stamp is reported as-is. A stale build is a warning, not a failure: the team runs, just not
900
- * the newest code, and auto-restart (or a rebuild + restart) resolves it. `info`, `head` and
901
- * `running` are injectable so tests can exercise every branch without compiling anything. */
902
- export async function checkBuild(root, info = readBuildInfo(), head = undefined,
903
- /** What the running orchestrator published about this build (orchestrator.json), when one is
904
- * up. A stale build whose restart was REFUSED must not be reported with the boilerplate
905
- * "auto-restart does this for a running team": nothing will happen until main moves, and
906
- * doctor is where an operator goes to find out why. */
907
- running = null) {
908
- if (!info)
909
- return okOutcome("no build stamp — dist/ compiled without `npm run build`");
910
- const sha = shortSha(info.sha);
911
- if (!(await isSelfHosted(root, info)))
912
- // Not "dist/ from …": in someone else's project that names a dist/ directory the project
913
- // does not have — the stamp read here is the running harness install's own, so say that.
914
- return okOutcome(`tumwater version ${sha} (this project is not tumwater itself)`);
915
- const mainHead = head === undefined ? await currentHead(root) : head;
916
- if (!mainHead)
917
- return okOutcome(`dist/ from ${sha}`);
918
- const stale = await buildStaleness(root, info.sha, mainHead);
919
- if (!stale)
920
- return okOutcome(`dist/ from ${sha}`);
921
- if (stale.stale) {
922
- const next = running?.restartBlocked
923
- ? `; auto-restart is BLOCKED (${running.restartBlocked}) and will not retry until main moves or the block clears`
924
- : running?.restartPending
925
- ? "; auto-restart is under way"
926
- : "; run `npm run build` and restart `tumwater start` (auto-restart does this for a running team)";
927
- return warnOutcome(`dist/ from ${sha} is stale — main has ${stale.aheadCommits} later commit(s) touching ${STALE_INPUTS_LABEL}${next}`);
928
- }
929
- return okOutcome(`dist/ from ${sha}, matches main`);
930
- }
931
- /** The primary checkout's HEAD sha, or null when it cannot be resolved (no repo) —
932
- * git/git.ts's refSha, the one home of the rev-parse probe. */
933
- function currentHead(root) {
934
- return refSha(root, "HEAD");
935
- }