tumwater 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (361) hide show
  1. package/README.md +74 -108
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +24 -0
  4. package/dist/src/agent/agent-backend.js +2 -2
  5. package/dist/src/agent/backend-traits.js +60 -0
  6. package/dist/src/agent/claude/claude-account.js +61 -8
  7. package/dist/src/agent/claude/claude-args.js +12 -15
  8. package/dist/src/agent/claude/claude-backend.js +51 -41
  9. package/dist/src/agent/claude/claude-env.js +7 -3
  10. package/dist/src/agent/claude/claude-stream.js +7 -16
  11. package/dist/src/agent/image-input.js +107 -0
  12. package/dist/src/agent/notes-shim.js +41 -0
  13. package/dist/src/agent/opencode/opencode-args.js +61 -0
  14. package/dist/src/agent/opencode/opencode-backend.js +255 -0
  15. package/dist/src/agent/opencode/opencode-catalog.js +184 -0
  16. package/dist/src/agent/opencode/opencode-config.js +188 -0
  17. package/dist/src/agent/opencode/opencode-env.js +36 -0
  18. package/dist/src/agent/opencode/opencode-session-id.js +37 -0
  19. package/dist/src/agent/opencode/opencode-stream.js +278 -0
  20. package/dist/src/agent/role-notes-write.js +16 -32
  21. package/dist/src/agent/run-agent.js +95 -18
  22. package/dist/src/agent/session-ledger.js +57 -1
  23. package/dist/src/backlog/backlog-archive.js +296 -0
  24. package/dist/src/backlog/backlog-cli.js +573 -0
  25. package/dist/src/backlog/backlog-commit.js +31 -0
  26. package/dist/src/backlog/backlog-eligibility.js +357 -33
  27. package/dist/src/backlog/backlog-md.js +98 -18
  28. package/dist/src/backlog/backlog-render.js +62 -11
  29. package/dist/src/backlog/backlog-structure.js +261 -58
  30. package/dist/src/backlog/backlog-write.js +197 -319
  31. package/dist/src/backlog/backlog.js +113 -16
  32. package/dist/src/backlog/question-answer.js +339 -0
  33. package/dist/src/baseline/main-baseline.js +75 -88
  34. package/dist/src/baseline/main-red.js +38 -30
  35. package/dist/src/brief/brief-edit.js +43 -0
  36. package/dist/src/{brief.js → brief/brief.js} +66 -10
  37. package/dist/src/budget/budget.js +32 -15
  38. package/dist/src/budget/fallback-breaker.js +8 -7
  39. package/dist/src/build/build-check-detect.js +8 -7
  40. package/dist/src/build/build-check-events.js +68 -22
  41. package/dist/src/build/build-check-report.js +22 -23
  42. package/dist/src/build/build-check-scoped.js +51 -62
  43. package/dist/src/build/build-check.js +15 -14
  44. package/dist/src/build/build-info.js +2 -1
  45. package/dist/src/build/build-stage.js +23 -22
  46. package/dist/src/build/dep-install.js +22 -21
  47. package/dist/src/build/host-sleep.js +1 -1
  48. package/dist/src/change/change-data.js +25 -14
  49. package/dist/src/change/change-render.js +11 -10
  50. package/dist/src/cli/brief-command.js +58 -0
  51. package/dist/src/cli/cli-args.js +25 -5
  52. package/dist/src/cli/cli-command-args.js +46 -14
  53. package/dist/src/cli/cli-editor.js +64 -0
  54. package/dist/src/cli/cli-flag-specs.js +54 -16
  55. package/dist/src/cli/cli-marker-commands.js +13 -10
  56. package/dist/src/cli/cli-output.js +9 -0
  57. package/dist/src/cli/cli-query-commands.js +458 -49
  58. package/dist/src/cli/cli-repo-gate.js +1 -1
  59. package/dist/src/cli/cli-run.js +208 -59
  60. package/dist/src/cli/completion.js +207 -0
  61. package/dist/src/cli/config-commands.js +14 -11
  62. package/dist/src/cli/help.js +305 -110
  63. package/dist/src/cli/log-commands.js +127 -21
  64. package/dist/src/cli/notify-command.js +33 -18
  65. package/dist/src/cli/question-commands.js +29 -224
  66. package/dist/src/cli.js +121 -40
  67. package/dist/src/collections.js +66 -16
  68. package/dist/src/concurrency/check-permit.js +16 -17
  69. package/dist/src/concurrency/lock.js +6 -5
  70. package/dist/src/concurrency/provider-lease.js +69 -0
  71. package/dist/src/concurrency/semaphore.js +258 -36
  72. package/dist/src/config/config-aliases.js +106 -0
  73. package/dist/src/config/config-editable-keys.js +3 -3
  74. package/dist/src/config/config-fallbacks.js +379 -0
  75. package/dist/src/config/config-field-checks.js +19 -10
  76. package/dist/src/config/config-keys.js +160 -0
  77. package/dist/src/config/config-live.js +64 -12
  78. package/dist/src/config/config-schema.js +34 -22
  79. package/dist/src/config/config-validation.js +132 -48
  80. package/dist/src/config/config-views.js +119 -337
  81. package/dist/src/config/config-write.js +138 -177
  82. package/dist/src/config/config.js +60 -49
  83. package/dist/src/config/model-selector.js +28 -2
  84. package/dist/src/doctor/doctor-backlog.js +546 -49
  85. package/dist/src/doctor/doctor-checks.js +534 -56
  86. package/dist/src/doctor/doctor-holds.js +193 -53
  87. package/dist/src/doctor/doctor-install.js +30 -0
  88. package/dist/src/doctor/doctor-launch-services.js +2 -2
  89. package/dist/src/doctor/doctor-model-checks.js +549 -157
  90. package/dist/src/doctor/doctor-notify.js +27 -0
  91. package/dist/src/doctor/doctor-orphans.js +10 -9
  92. package/dist/src/doctor/doctor-render.js +36 -9
  93. package/dist/src/doctor/doctor.js +80 -22
  94. package/dist/src/doctor/image-probe.js +244 -0
  95. package/dist/src/doctor/probe.js +64 -0
  96. package/dist/src/events/event-format.js +226 -102
  97. package/dist/src/events/event-labels.js +116 -0
  98. package/dist/src/events/event-legacy.js +140 -0
  99. package/dist/src/events/event-read.js +58 -22
  100. package/dist/src/events/event-window.js +27 -7
  101. package/dist/src/events/events.js +18 -14
  102. package/dist/src/events/notify.js +137 -38
  103. package/dist/src/failure/failure-cluster.js +12 -9
  104. package/dist/src/failure/failure-data.js +96 -48
  105. package/dist/src/failure/failure-render.js +69 -58
  106. package/dist/src/failure/failure-state-change.js +33 -26
  107. package/dist/src/failure/time-spend.js +79 -59
  108. package/dist/src/files/file-queue.js +16 -4
  109. package/dist/src/files/files.js +18 -6
  110. package/dist/src/files/json-object.js +18 -3
  111. package/dist/src/files/tail.js +1 -1
  112. package/dist/src/gates/bootstrap-gates.js +5 -4
  113. package/dist/src/gates/budget-gates.js +250 -34
  114. package/dist/src/gates/disk-gate.js +18 -18
  115. package/dist/src/gates/gate-polls.js +117 -69
  116. package/dist/src/gates/maintenance-quota.js +29 -29
  117. package/dist/src/gates/pause-gates.js +11 -11
  118. package/dist/src/gates/readiness.js +2 -1
  119. package/dist/src/gates/role-cap-gates.js +16 -21
  120. package/dist/src/gates/startup-gate.js +75 -31
  121. package/dist/src/gates/streak-gate.js +18 -16
  122. package/dist/src/git/commit-message.js +12 -5
  123. package/dist/src/git/git-diff.js +16 -16
  124. package/dist/src/git/git-run.js +8 -8
  125. package/dist/src/git/git.js +86 -55
  126. package/dist/src/git/slots-state.js +14 -5
  127. package/dist/src/git/worktree-pool.js +25 -18
  128. package/dist/src/git/worktree-use.js +15 -14
  129. package/dist/src/git/worktree.js +18 -18
  130. package/dist/src/gui/gui-args.js +10 -14
  131. package/dist/src/gui/gui-backlog-commands.js +273 -0
  132. package/dist/src/gui/gui-command.js +1 -1
  133. package/dist/src/gui/gui-config-commands.js +440 -0
  134. package/dist/src/gui/gui-endpoint-commands.js +16 -669
  135. package/dist/src/gui/gui-endpoint-common.js +49 -0
  136. package/dist/src/gui/gui-endpoints.js +116 -56
  137. package/dist/src/gui/gui-operator-commands.js +145 -0
  138. package/dist/src/gui/gui-prompt-commands.js +234 -0
  139. package/dist/src/gui/gui-server.js +28 -8
  140. package/dist/src/gui/gui-team-commands.js +164 -0
  141. package/dist/src/gui/http-body.js +17 -1
  142. package/dist/src/history/history-data.js +194 -98
  143. package/dist/src/history/history-render.js +76 -0
  144. package/dist/src/history/history.js +37 -58
  145. package/dist/src/inbox/inbox-attachments.js +75 -14
  146. package/dist/src/inbox/inbox-cancel.js +7 -5
  147. package/dist/src/inbox/inbox-edit.js +1 -1
  148. package/dist/src/inbox/inbox-submit.js +44 -4
  149. package/dist/src/inbox/inbox-top.js +83 -0
  150. package/dist/src/inbox/inbox.js +8 -2
  151. package/dist/src/inbox/pending-prompt.js +1 -1
  152. package/dist/src/inbox/prompt-commands.js +170 -80
  153. package/dist/src/init/init-templates.js +16 -14
  154. package/dist/src/init/init.js +12 -11
  155. package/dist/src/loop/leftover.js +28 -27
  156. package/dist/src/loop/loop-fallback.js +2 -2
  157. package/dist/src/loop/loop-pi.js +106 -52
  158. package/dist/src/loop/loop-recovery.js +15 -15
  159. package/dist/src/loop/loop-state.js +139 -13
  160. package/dist/src/loop/loop-tick.js +194 -86
  161. package/dist/src/loop/loop.js +72 -36
  162. package/dist/src/loop/model-fallback.js +3 -2
  163. package/dist/src/loop/revision.js +16 -7
  164. package/dist/src/merge/backlog-conflicts.js +155 -0
  165. package/dist/src/{landing/landing-batch.js → merge/merge-batch.js} +90 -89
  166. package/dist/src/{landing/landing-check-failures.js → merge/merge-check-failures.js} +87 -41
  167. package/dist/src/{landing/landing-core.js → merge/merge-core.js} +119 -95
  168. package/dist/src/{landing/landing-diff.js → merge/merge-diff.js} +1 -1
  169. package/dist/src/merge/merge-drain.js +212 -0
  170. package/dist/src/{landing/landing-git.js → merge/merge-git.js} +94 -21
  171. package/dist/src/merge/merge-pipeline.js +99 -0
  172. package/dist/src/{landing/landing-questions.js → merge/merge-questions.js} +9 -6
  173. package/dist/src/merge/merge-queue-render.js +75 -0
  174. package/dist/src/{landing/landing-queue.js → merge/merge-queue.js} +95 -36
  175. package/dist/src/merge/merge-slot.js +340 -0
  176. package/dist/src/{landing/landing-stack.js → merge/merge-stack.js} +66 -62
  177. package/dist/src/{landing/landing-merge.js → merge/merge-to-main.js} +72 -62
  178. package/dist/src/merge/merge-verify.js +212 -0
  179. package/dist/src/notes-cli.js +97 -0
  180. package/dist/src/operator/operator-commands.js +154 -47
  181. package/dist/src/operator/operator-intent.js +87 -49
  182. package/dist/src/operator/operator-requests.js +52 -52
  183. package/dist/src/operator/retire.js +62 -27
  184. package/dist/src/operator/revert.js +71 -0
  185. package/dist/src/orchestrator/orchestrator-launch.js +74 -14
  186. package/dist/src/orchestrator/orchestrator-scheduling.js +129 -44
  187. package/dist/src/orchestrator/orchestrator.js +158 -100
  188. package/dist/src/orchestrator/retention.js +29 -12
  189. package/dist/src/orchestrator/startup-migration-steps.js +51 -0
  190. package/dist/src/orchestrator/startup-migrations.js +40 -0
  191. package/dist/src/paths.js +128 -37
  192. package/dist/src/pi/pi-args.js +26 -3
  193. package/dist/src/pi/pi-backend.js +15 -23
  194. package/dist/src/pi/pi-bin.js +18 -12
  195. package/dist/src/pi/pi-event-line.js +17 -1
  196. package/dist/src/pi/pi-failure.js +87 -0
  197. package/dist/src/pi/pi-models.js +126 -38
  198. package/dist/src/pi/pi-run-result.js +1 -1
  199. package/dist/src/pi/pi-settings.js +86 -0
  200. package/dist/src/pi/pi-stream.js +131 -107
  201. package/dist/src/pi/pi-watchdogs.js +47 -43
  202. package/dist/src/pi-extension/bounded-output.js +20 -5
  203. package/dist/src/pi-extension/context-budget.js +3 -1
  204. package/dist/src/pi-extension/context-shake.js +54 -21
  205. package/dist/src/pi-extension/context-window.js +37 -0
  206. package/dist/src/pi-extension/full-output.js +2 -2
  207. package/dist/src/pi-extension/strip-thinking.js +80 -0
  208. package/dist/src/pi-extension/trim-system-prompt.js +38 -0
  209. package/dist/src/process/launch-services.js +11 -10
  210. package/dist/src/process/process-table.js +1 -1
  211. package/dist/src/process/process.js +7 -7
  212. package/dist/src/process/run-marker.js +29 -6
  213. package/dist/src/process/supervisor.js +47 -25
  214. package/dist/src/prompt/dialect.js +30 -10
  215. package/dist/src/prompt/gate-prompts.js +170 -67
  216. package/dist/src/prompt/principles.js +1 -0
  217. package/dist/src/prompt/prompt-followup.js +35 -20
  218. package/dist/src/prompt/prompt.js +249 -91
  219. package/dist/src/redeploy/redeploy-policy.js +10 -10
  220. package/dist/src/redeploy/redeploy.js +6 -4
  221. package/dist/src/redeploy/redeployer.js +63 -55
  222. package/dist/src/redeploy/self-reload.js +1 -1
  223. package/dist/src/report/report-data.js +264 -255
  224. package/dist/src/report/report-fold.js +353 -0
  225. package/dist/src/report/report-render.js +270 -33
  226. package/dist/src/request-timeouts.js +1 -1
  227. package/dist/src/review/known-flakes.js +34 -14
  228. package/dist/src/review/protected-edits.js +33 -0
  229. package/dist/src/review/review-followup.js +16 -4
  230. package/dist/src/review/review-precheck.js +14 -13
  231. package/dist/src/review/review-verdict.js +14 -0
  232. package/dist/src/review/review.js +81 -39
  233. package/dist/src/review/secret-leaks.js +127 -0
  234. package/dist/src/review/suite-rerun.js +4 -3
  235. package/dist/src/roles/legacy-role-ids.js +21 -0
  236. package/dist/src/roles/legacy-role-migration.js +62 -0
  237. package/dist/src/roles/loop-ids.js +19 -2
  238. package/dist/src/roles/role-catalog.js +125 -135
  239. package/dist/src/roles/role-guidance.js +64 -31
  240. package/dist/src/roles/role-labels.js +10 -0
  241. package/dist/src/roles/role-render.js +194 -18
  242. package/dist/src/roles/role-view.js +216 -17
  243. package/dist/src/roles/roles.js +18 -15
  244. package/dist/src/scheduling/backoff.js +29 -19
  245. package/dist/src/scheduling/claims.js +25 -9
  246. package/dist/src/scheduling/once-round.js +11 -11
  247. package/dist/src/scheduling/quiet-hours.js +25 -32
  248. package/dist/src/scheduling/scheduling.js +43 -24
  249. package/dist/src/scheduling/{work-landed-cache.js → work-merged-cache.js} +14 -14
  250. package/dist/src/status/status-budget.js +115 -0
  251. package/dist/src/status/status-data.js +77 -184
  252. package/dist/src/status/status-inbox.js +47 -0
  253. package/dist/src/status/status-install.js +27 -0
  254. package/dist/src/status/status-polls.js +68 -33
  255. package/dist/src/status/status-roles.js +105 -0
  256. package/dist/src/{fleet → team}/error-storm.js +10 -10
  257. package/dist/src/{fleet → team}/failure-spread.js +8 -8
  258. package/dist/src/{fleet → team}/orchestrator-info.js +86 -18
  259. package/dist/src/{fleet → team}/reclaim.js +5 -4
  260. package/dist/src/{fleet/fleet-hold.js → team/team-hold.js} +55 -21
  261. package/dist/src/{fleet/fleet-polls.js → team/team-polls.js} +116 -65
  262. package/dist/src/{fleet/fleet-state.js → team/team-state.js} +62 -37
  263. package/dist/src/text/datetime.js +42 -2
  264. package/dist/src/text/format.js +10 -1
  265. package/dist/src/text/markdown.js +1 -1
  266. package/dist/src/text/phrases.js +35 -34
  267. package/dist/src/text/suggest.js +3 -2
  268. package/dist/src/text/text.js +2 -2
  269. package/dist/src/tick/qa-coverage.js +2 -1
  270. package/dist/src/tick/stage-check.js +147 -26
  271. package/dist/src/tick/telemetry-digest.js +10 -9
  272. package/dist/src/tick/tick-apply.js +101 -72
  273. package/dist/src/tick/tick-detail-data.js +32 -18
  274. package/dist/src/tick/tick-detail.js +62 -21
  275. package/dist/src/tick/tick-finalize.js +29 -22
  276. package/dist/src/tick/tick-outcome.js +2 -1
  277. package/dist/src/tick/tick-prompt.js +94 -26
  278. package/dist/src/tick/tick-resume.js +29 -13
  279. package/dist/src/tick/tick-stage.js +91 -47
  280. package/dist/src/tick/tick-timing.js +60 -59
  281. package/dist/src/tick/tick-usage.js +53 -23
  282. package/dist/src/tick/tick-verdict.js +76 -10
  283. package/dist/src/ui/badges.js +118 -67
  284. package/dist/src/ui/gui/gui-client-boot.js +209 -30
  285. package/dist/src/ui/gui/gui-client-composer.js +102 -23
  286. package/dist/src/ui/gui/gui-client-drawer.js +330 -75
  287. package/dist/src/ui/gui/gui-client-history.js +96 -38
  288. package/dist/src/ui/gui/gui-client-labels.js +24 -0
  289. package/dist/src/ui/gui/gui-client-loops.js +94 -52
  290. package/dist/src/ui/gui/gui-client-markdown.js +1 -0
  291. package/dist/src/ui/gui/gui-client-model.js +63 -43
  292. package/dist/src/ui/gui/gui-client-operator.js +129 -39
  293. package/dist/src/ui/gui/gui-client-pending.js +36 -32
  294. package/dist/src/ui/gui/gui-client-report.js +339 -46
  295. package/dist/src/ui/gui/gui-client-settings.js +148 -14
  296. package/dist/src/ui/gui/gui-client-sound.js +2 -1
  297. package/dist/src/ui/gui/{gui-client-fleet.js → gui-client-team.js} +196 -72
  298. package/dist/src/ui/gui/gui-client.js +26 -20
  299. package/dist/src/ui/gui/gui-page.js +19 -19
  300. package/dist/src/ui/gui/gui-styles.js +12 -4
  301. package/dist/src/ui/progress-data.js +5 -3
  302. package/dist/src/ui/status-model.js +184 -122
  303. package/dist/src/ui/status-payload.js +119 -32
  304. package/dist/src/ui/status-render.js +122 -52
  305. package/dist/src/ui/team-alerts.js +341 -0
  306. package/dist/src/ui/tick-progress-model.js +14 -14
  307. package/dist/src/ui/tone.js +11 -8
  308. package/dist/src/ui/transcript-tail.js +1 -0
  309. package/dist/src/ui/transcript.js +23 -16
  310. package/dist/src/ui/tui/tui-app.js +1 -0
  311. package/dist/src/ui/tui/tui-backlog.js +85 -11
  312. package/dist/src/ui/tui/tui-editor-answer.js +97 -0
  313. package/dist/src/ui/tui/tui-editor-attach.js +107 -0
  314. package/dist/src/ui/tui/tui-editor-brief.js +107 -0
  315. package/dist/src/ui/tui/tui-editor-budget.js +155 -0
  316. package/dist/src/ui/tui/tui-editor-ctx.js +1 -0
  317. package/dist/src/ui/tui/tui-editor-entry.js +230 -0
  318. package/dist/src/ui/tui/tui-editor-interval.js +122 -0
  319. package/dist/src/ui/tui/tui-editor-notes.js +114 -0
  320. package/dist/src/ui/tui/tui-editor-pause.js +266 -0
  321. package/dist/src/ui/tui/tui-editor-prompt.js +95 -0
  322. package/dist/src/ui/tui/tui-editor-queued.js +98 -0
  323. package/dist/src/ui/tui/tui-editor-quiet.js +106 -0
  324. package/dist/src/ui/tui/tui-editor-retire.js +83 -0
  325. package/dist/src/ui/tui/tui-editor-search.js +73 -0
  326. package/dist/src/ui/tui/tui-editor-session.js +97 -0
  327. package/dist/src/ui/tui/tui-editors.js +152 -0
  328. package/dist/src/ui/tui/tui-frame.js +67 -55
  329. package/dist/src/ui/tui/tui-history.js +12 -7
  330. package/dist/src/ui/tui/tui-input.js +49 -44
  331. package/dist/src/ui/tui/tui-keymap.js +1 -0
  332. package/dist/src/ui/tui/tui-keys.js +91 -1704
  333. package/dist/src/ui/tui/tui-pane-activity.js +99 -0
  334. package/dist/src/ui/tui/tui-pane-backlog.js +224 -0
  335. package/dist/src/ui/tui/tui-pane-brief.js +30 -0
  336. package/dist/src/ui/tui/tui-pane-ctx.js +11 -0
  337. package/dist/src/ui/tui/tui-pane-health.js +124 -0
  338. package/dist/src/ui/tui/tui-pane-history.js +316 -0
  339. package/dist/src/ui/tui/tui-pane-pending.js +74 -0
  340. package/dist/src/ui/tui/tui-pane-queued.js +143 -0
  341. package/dist/src/ui/tui/tui-pane-report.js +122 -0
  342. package/dist/src/ui/tui/tui-pane-session.js +101 -0
  343. package/dist/src/ui/tui/tui-pane-transcript.js +150 -0
  344. package/dist/src/ui/tui/tui-pane.js +66 -28
  345. package/dist/src/ui/tui/tui-panes.js +294 -0
  346. package/dist/src/ui/tui/tui.js +45 -25
  347. package/dist/src/verdict/fix-claim.js +141 -38
  348. package/dist/src/verdict/no-change.js +19 -0
  349. package/dist/src/verdict/refusal.js +92 -8
  350. package/dist/src/verdict/reply-contract.js +17 -5
  351. package/dist/src/version.js +3 -2
  352. package/package.json +4 -2
  353. package/dist/src/agent/mcp/role-notes-server.js +0 -160
  354. package/dist/src/landing/backlog-conflicts.js +0 -116
  355. package/dist/src/landing/landing-drain.js +0 -204
  356. package/dist/src/landing/landing-pipeline.js +0 -96
  357. package/dist/src/landing/landing-queue-render.js +0 -31
  358. package/dist/src/landing/landing-slot.js +0 -246
  359. package/dist/src/landing/landing-vetting.js +0 -196
  360. package/dist/src/pi-extension/role-notes.js +0 -40
  361. package/dist/src/ui/fleet-alerts.js +0 -174
@@ -1,32 +1,50 @@
1
+ // vocabulary: plain
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
- import { enabledRoleIds, loadConfig, loadConfigSafe, slotCount, } from "../config/config.js";
5
+ import { enabledRoleIds, knownRoleIds, parseJsonConfig, loadConfig, loadConfigSafe, slotCount, } from "../config/config.js";
5
6
  import { exampleConfigProblem, exampleDrift } from "../config/config-example.js";
6
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";
7
10
  import { bootstrapStatus } from "../gates/bootstrap-gates.js";
8
11
  import { buildStaleness, isSelfHosted, readBuildInfo, STALE_INPUTS_LABEL } from "../build/build-info.js";
9
12
  import { envPath, findOnPath, subdirectoryNames } from "../files/files.js";
13
+ import { readJsonFile } from "../files/json-files.js";
10
14
  import { PACKAGE_JSON, belowNodeFloor, packageEnginesNode } from "../version.js";
11
15
  import { GIT_MISSING_MESSAGE } from "../git/git-run.js";
12
- import { branchExists, currentBranch, hasCommits, branchesPhrase, isGitRepo, refSha, repoToplevel, } from "../git/git.js";
16
+ import { branchExists, currentBranch, hasCommits, branchesPhrase, isGitRepo, listBranches, refSha, repoToplevel, } from "../git/git.js";
13
17
  import { DETACHED_HEAD_MESSAGE, NOT_A_REPO_MESSAGE, NOT_INITIALIZED_MESSAGE, NO_COMMITS_MESSAGE, } from "../gates/readiness.js";
14
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";
15
24
  import { classifyLock, readLockPid } from "../concurrency/lock.js";
16
- import { EXAMPLE_CONFIG_BASENAME, STATE_DIR, configPath, eventsLogPath, mergeLockDir, worktreesDir } from "../paths.js";
25
+ import { CONFIG_BASENAME, EXAMPLE_CONFIG_BASENAME, branchName, STATE_DIR, configPath, eventsLogPath, mergeLockDir, worktreesDir } from "../paths.js";
17
26
  import { BYTES_PER_GB, diskVolumePath, sampleFreeBytes } from "../gates/disk-gate.js";
18
27
  import { errorMessage, textOr } from "../text/text.js";
28
+ import { lowerMedian } from "../collections.js";
19
29
  import { formatGB, shortSha } from "../text/format.js";
20
- import { briefFile } from "../brief.js";
21
- import { eventLogState } from "../events/event-window.js";
30
+ 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";
22
34
  import { DIRECTOR_ROLE } from "../roles/roles.js";
23
35
  import { readSlotsState } from "../git/slots-state.js";
24
- import { agentsInUse } from "../gates/startup-gate.js";
25
- import { claudeSeamsInUse } from "./doctor-model-checks.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";
38
+ import { claudeConfigDir } from "../agent/claude/claude-backend.js";
39
+ import { boundedProbe } from "./probe.js";
40
+ import { childEnv } from "../process/process.js";
41
+ import { opencodeBackend, OPENCODE_MIN_VERSION } from "../agent/opencode/opencode-backend.js";
26
42
  import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
43
+ import { spawnSync } from "node:child_process";
27
44
  import { loadLoopState } from "../loop/loop-state.js";
28
- import { INSTANCE_ROLES, loopIds } from "../roles/loop-ids.js";
45
+ import { INSTANCE_ROLES, loopEnabled, loopIds } from "../roles/loop-ids.js";
29
46
  import { CLAIM_IDLE_MAX_MS, listedKeys } from "../scheduling/claims.js";
47
+ import { flakeCounts, KNOWN_FLAKE_WINDOW_MS } from "../review/known-flakes.js";
30
48
  /** The floor doctor falls back to when package.json — the one source of the declared
31
49
  * engines.node spec, read below — cannot be read (a broken install, the version command's
32
50
  * story). The CLI's startup gate enforces the spec itself; this check stays a warning so a
@@ -62,7 +80,7 @@ export function checkGitBinary(pathEnv = envPath()) {
62
80
  /** Repo ready — the git/git.ts predicates in requireReadyRepo's order, so doctor and the
63
81
  * readiness gate cannot drift: not a git repo → no commits yet → detached HEAD. Reports the
64
82
  * resolved toplevel (not the cwd — doctor must say where .tumwater/ lives) and the branch the
65
- * fleet would target: a configured `baseBranch` wins (`run --branch` is per-invocation), and
83
+ * team would target: a configured `baseBranch` wins (`run --branch` is per-invocation), and
66
84
  * one that does not exist fails here instead of at the first tick. `config` is optional so
67
85
  * the check stands alone; runDoctor passes the loaded config behind a guard. */
68
86
  export async function checkRepo(root, config) {
@@ -117,6 +135,18 @@ export function checkInit(root) {
117
135
  return failOutcome(errorMessage(err));
118
136
  }
119
137
  }
138
+ /** Whether tumwater.json still spells keys by an old name (config-aliases.ts). Both spellings
139
+ * load, so this only warns, naming each exact rename. Ok when the file is absent or broken
140
+ * (checkInit reports that) or nothing in it is renamed. */
141
+ export function checkConfigKeyNames(root) {
142
+ const file = configPath(root);
143
+ if (!fs.existsSync(file))
144
+ return okOutcome("no tumwater.json");
145
+ const { renamed } = parseJsonConfig(file, CONFIG_BASENAME);
146
+ if (renamed === undefined || renamed.length === 0)
147
+ return okOutcome("current key names");
148
+ return warnOutcome(`old key names: ${renamed.join(", ")} — rename them in tumwater.json; both spellings work until then`);
149
+ }
120
150
  /** Which file holds the project brief (the managed initial prompt + status sections,
121
151
  * plans/portability.md §7a/7): TUMWATER.md first, README.md as the compatibility path. A repo
122
152
  * whose README carries no markers — or that has no brief at all — still boots, but every loop
@@ -126,7 +156,7 @@ export function checkInit(root) {
126
156
  export function checkBrief(root) {
127
157
  // briefFile throws on a brief candidate that exists but cannot be read (a directory at
128
158
  // the README.md path, a permission-lost file) — report it as the check's failure instead
129
- // of crashing doctor: the fleet would run without its brief every tick.
159
+ // of crashing doctor: the team would run without its brief every tick.
130
160
  let owner;
131
161
  try {
132
162
  owner = briefFile(root);
@@ -137,7 +167,7 @@ export function checkBrief(root) {
137
167
  if (owner)
138
168
  return okOutcome(`brief in ${owner}`);
139
169
  if (fs.existsSync(path.join(root, "README.md"))) {
140
- return warnOutcome(`README.md carries no tumwater:prompt markers — the fleet would run without its project brief; add the block or re-run \`tumwater init\``);
170
+ return warnOutcome(`README.md carries no tumwater:prompt markers — the team would run without its project brief; add the block or re-run \`tumwater init\``);
141
171
  }
142
172
  return warnOutcome("no brief file — run `tumwater init <prompt>` to seed one");
143
173
  }
@@ -149,7 +179,7 @@ export function checkBootstrap(root, config) {
149
179
  const status = config ? bootstrapStatus(root, config) : null;
150
180
  if (!status?.active)
151
181
  return okOutcome("not bootstrapping");
152
- return okOutcome(`bootstrapping: ${status.plansDone}/${status.untilPlansDone} plans done — plan, feature and director tick`);
182
+ return okOutcome(`bootstrapping: ${status.plansDone}/${status.untilPlansDone} plans done — plan, feature and director runs`);
153
183
  }
154
184
  /** Resolve and test ONE agent's binary: the ok detail names the resolved path and its source
155
185
  * whenever the value is not the PATH default, so a configured binary is never mistaken for the
@@ -173,11 +203,18 @@ function agentBinaryCheck(config, agent, pathEnv) {
173
203
  * path-shaped accessSync(X_OK) rule, and null-on-unusable verdict the startup gate asks — so
174
204
  * doctor cannot pass a binary the gate would reject. resolveAgentBin normalizes path-shaped
175
205
  * values against the process cwd at resolution time, so what doctor tests is exactly what
176
- * spawns. A claude-only fleet checks claude, not pi. The check fails on the first missing
206
+ * spawns. A claude-only team checks claude, not pi. The check fails on the first missing
177
207
  * binary, naming its agent; an ok detail joins each agent's resolved path and source. */
178
208
  export function checkAgentBinary(root, pathEnv = envPath()) {
179
209
  const { config } = loadConfigSafe(root);
180
210
  const agents = config !== undefined ? agentsInUse(config) : ["pi"];
211
+ // A seam backend with no registered adapter in this build can never run; naming that beats
212
+ // 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.
215
+ const unregistered = config !== undefined ? unregisteredBackendInUse(config) : undefined;
216
+ if (unregistered !== undefined)
217
+ return failOutcome(`backend ${unregistered} is configured but this build has no adapter for it`);
181
218
  const checks = agents.map((agent) => ({
182
219
  agent,
183
220
  outcome: agentBinaryCheck(config ?? {}, agent, pathEnv),
@@ -229,18 +266,44 @@ function formatBytes(n) {
229
266
  return `${(n / 1024).toFixed(1)} KB`;
230
267
  return `${n} B`;
231
268
  }
232
- /** claude transcript disk — the total size of `~/.claude/projects` (plans/agent-backends.md
233
- * "Sessions" retention): claude ages transcripts out under its own cleanupPeriodDays, so an
234
- * operator asking where claude's disk went gets the number without `du`. Informational (ok) in
235
- * every case — a large transcript tree is a fact, not a misconfiguration. Stands down when no
236
- * seam routes to claude: a pi-only fleet reads no claude state, and the report must not walk a
237
- * host directory that has nothing to do with the fleet. `homeDir` is a test seam. */
238
- export function checkClaudeTranscripts(config, homeDir = os.homedir()) {
269
+ /** claude transcript disk — the total size of the claude config dir's `projects/`
270
+ * (plans/agent-backends.md "Sessions" retention): the perf adapter ages transcripts out under
271
+ * its own cleanupPeriodDays, so an operator asking where claude's disk went gets the number
272
+ * without `du`. Informational (ok) in every case — a large transcript tree is a fact, not a
273
+ * misconfiguration. Stands down when no seam routes to claude: a pi-only team reads no claude
274
+ * state, and the report must not walk a host directory that has nothing to do with the team.
275
+ * `homeDir` and `env` are test seams. */
276
+ /** The dotted version in `opencode --version` (prints `opencode v2.0.32`), or undefined when it
277
+ * names none. */
278
+ function parseOpencodeCliVersion(output) {
279
+ return /v(\d+\.\d+\.\d+)/.exec(output)?.[1];
280
+ }
281
+ /** OpenCode CLI-version readiness (plans/opencode-backend.md "Which OpenCode", part 7/7): the
282
+ * team targets V2 at 2.0.32 or later, the version H1 verified live. An installed CLI below that
283
+ * (a 1.x binary included) fails naming `brew install opencode`; at or above it reads the
284
+ * version. Stands down when no seam routes to opencode, so a pi-only team spawns no opencode. */
285
+ export async function checkOpencodeVersion(root, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
286
+ const { config } = loadConfigSafe(root);
287
+ if (config === undefined)
288
+ return okOutcome("no readable tumwater.json — nothing to verify");
289
+ if (!opencodeSeamsInUse(config))
290
+ return okOutcome("no model jobs run on opencode");
291
+ const resolved = opencodeBackend.resolveBin(config);
292
+ const result = await boundedProbe(resolved.bin, ["--version"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
293
+ 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})`);
299
+ }
300
+ export function checkClaudeTranscripts(config, homeDir = os.homedir(), env = process.env) {
239
301
  if (!config)
240
302
  return okOutcome("no readable tumwater.json — nothing to verify");
241
303
  if (!claudeSeamsInUse(config))
242
- return okOutcome("no claude seams configured");
243
- const dir = path.join(homeDir, ".claude", "projects");
304
+ return okOutcome("no model jobs run on claude");
305
+ const configDir = resolvedAgentConfig(config, "claude").configDir;
306
+ const dir = path.join(claudeConfigDir(env, homeDir, configDir), "projects");
244
307
  let slugs;
245
308
  try {
246
309
  slugs = fs
@@ -254,11 +317,158 @@ export function checkClaudeTranscripts(config, homeDir = os.homedir()) {
254
317
  let bytes = 0;
255
318
  for (const slug of slugs)
256
319
  bytes += treeBytes(path.join(dir, slug));
257
- const projects = slugs.length === 1 ? "1 project" : `${slugs.length} projects`;
320
+ const projects = plural(slugs.length, "project");
258
321
  return okOutcome(`${formatBytes(bytes)} across ${projects} in ${dir}`);
259
322
  }
323
+ /** Bounding for the `pi list` probe below: the packages a team run now skips are names, so a
324
+ * short deadline and a small output cap keep an unlucky host from stalling doctor. */
325
+ const PI_LIST_TIMEOUT_MS = 5_000;
326
+ const PI_LIST_MAX_BYTES = 64 * 1024;
327
+ /** MCP server names in `~/.pi/agent/mcp.json` (the file pi's built-in MCP extension reads),
328
+ * or `[]` for a missing/malformed file: the `mcpServers` keys, or `mcp.<name>`. Best-effort
329
+ * naming so a server the operator meant for the team is surfaced even when the file is odd. */
330
+ function piMcpServerNames(file) {
331
+ // The tolerant read-and-parse half (missing, torn, scalar, `null`, or array file is "no
332
+ // data", never an error) is src/files/json-files.ts's readJsonFile, the same shared
333
+ // convention every other state/marker/info file reader uses.
334
+ const parsed = readJsonFile(file);
335
+ if (parsed === null)
336
+ return [];
337
+ const names = new Set();
338
+ const serverMap = parsed["mcpServers"];
339
+ if (serverMap && typeof serverMap === "object")
340
+ for (const name of Object.keys(serverMap))
341
+ names.add(name);
342
+ const mcp = parsed["mcp"];
343
+ if (mcp && typeof mcp === "object")
344
+ for (const [name, value] of Object.entries(mcp))
345
+ if (name !== "servers" && value && typeof value === "object")
346
+ names.add(name);
347
+ return [...names];
348
+ }
349
+ /** The operator pi state a team run now skips (plans/notes-command.md "Keeping funes out of
350
+ * team runs"): installed packages (`pi list`, bounded), files in `~/.pi/agent/extensions`, and
351
+ * MCP servers in `~/.pi/agent/mcp.json`. Each is named, so a package, extension file, or server
352
+ * the operator meant for the team does not vanish silently when pi loads none of it. Shown only
353
+ * when a model job runs on pi; returns null otherwise so doctor omits the row and never reads
354
+ * a host `~/.pi` a pi-only-install team does not use. `homeDir` and `pathEnv` are test seams. */
355
+ export function checkPiExtensions(config, homeDir = os.homedir(), pathEnv = envPath()) {
356
+ if (!config)
357
+ return null;
358
+ if (!piSeamsInUse(config))
359
+ return null;
360
+ const parts = [];
361
+ // Installed pi packages (pi list, bounded): each stdout line a package name. A probe that
362
+ // 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);
365
+ if (piBin !== null) {
366
+ try {
367
+ const stdout = spawnSync(piBin, ["list"], {
368
+ encoding: "utf8",
369
+ timeout: PI_LIST_TIMEOUT_MS,
370
+ maxBuffer: PI_LIST_MAX_BYTES,
371
+ killSignal: "SIGKILL",
372
+ }).stdout;
373
+ // `pi list` is not line-per-package: each package is a two-space-indented source line
374
+ // (optionally followed by its installed path at four spaces), under the bold `User
375
+ // packages:` / `Project packages:` headers. Keep only the two-space source lines so the
376
+ // headers and installed paths are not mistaken for packages.
377
+ const packages = String(stdout)
378
+ .split(/\r?\n/)
379
+ .map((line) => line.replace(/\r$/, ""))
380
+ .map((line) => /^ (\S.*)$/.exec(line)?.[1]?.trim())
381
+ .filter((name) => name !== undefined && name.length > 0);
382
+ if (packages.length > 0)
383
+ parts.push(`packages: ${packages.join(", ")}`);
384
+ }
385
+ catch {
386
+ // A `pi list` that could not be read just names nothing; extensions/servers below still show.
387
+ }
388
+ }
389
+ // Files in ~/.pi/agent/extensions.
390
+ try {
391
+ const files = fs
392
+ .readdirSync(path.join(homeDir, ".pi", "agent", "extensions"), { withFileTypes: true })
393
+ .filter((entry) => entry.isFile())
394
+ .map((entry) => entry.name);
395
+ if (files.length > 0)
396
+ parts.push(`~/.pi/agent/extensions: ${files.join(", ")}`);
397
+ }
398
+ catch {
399
+ // Absent or unreadable; nothing to name.
400
+ }
401
+ // MCP servers named in ~/.pi/agent/mcp.json (the file pi's MCP built-in reads).
402
+ const mcpNames = piMcpServerNames(path.join(homeDir, ".pi", "agent", "mcp.json"));
403
+ if (mcpNames.length > 0)
404
+ parts.push(`mcp.json servers: ${mcpNames.join(", ")}`);
405
+ if (parts.length === 0)
406
+ return okOutcome("team pi runs skip discovered extensions; none found on this host");
407
+ return okOutcome(`team pi runs skip discovered extensions: ${parts.join("; ")}`);
408
+ }
409
+ /** The operator's global opencode config files, in XDG order — the same home `opencode-config.ts`
410
+ * reads for MCP names. Home is the operator's home; `$XDG_CONFIG_HOME`, when set, wins. */
411
+ function opencodeGlobalConfigPaths(homeDir) {
412
+ const configHome = process.env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
413
+ return ["config.json", "opencode.json", "opencode.jsonc"].map((file) => path.join(configHome, "opencode", file));
414
+ }
415
+ /** Report the operator's global opencode config file that mentions funes, or null.
416
+ * A best-effort note (the design's doctor `funes` row) so an operator who installed funes's
417
+ * OpenCode integration is told it could reach an OpenCode team; a malformed file reads nothing. */
418
+ function opencodePluginNamesFunes(homeDir, readPath) {
419
+ for (const file of opencodeGlobalConfigPaths(homeDir)) {
420
+ const text = readPath(file);
421
+ if (text === null)
422
+ continue;
423
+ // A global config that mentions funes in or near a plugin reference is worth naming; the
424
+ // exact JSON shape varies by version (array or object), so a substring scan over the text is
425
+ // the honest check.
426
+ if (/"plugin(s)?"\s*[:\[\{]/.test(text) && /funes/i.test(text))
427
+ return file;
428
+ }
429
+ return null;
430
+ }
431
+ /** The `funes` row (plans/notes-command.md "Keeping funes out of team runs"): whether the
432
+ * transcript-recall tool you use for your own sessions can reach the team. Shows only when
433
+ * `funes` is on PATH (else no row at all) and warns when a Claude Code team runs without a
434
+ * `backends.claude.configDir` while funes's Claude Code hook is installed (`~/.funes/
435
+ * integrations/claude-plugin`) — that hook indexes every session under `~/.claude/projects`,
436
+ * which is exactly what a team config dir moves out from under. It also notes a global
437
+ * 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
+ }) {
446
+ if (!config)
447
+ return null;
448
+ if (findOnPath("funes", pathEnv) === null)
449
+ return null;
450
+ const notes = [];
451
+ if (claudeSeamsInUse(config)) {
452
+ const configDir = resolvedAgentConfig(config, "claude").configDir;
453
+ const plugin = path.join(homeDir, ".funes", "integrations", "claude-plugin");
454
+ if (configDir === undefined && fs.existsSync(plugin)) {
455
+ 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`)");
456
+ }
457
+ notes.push(configDir === undefined
458
+ ? "Claude Code runs use the default config dir (no funes Claude Code hook installed)"
459
+ : "Claude Code runs use their own config dir");
460
+ }
461
+ const opencodePlugin = opencodePluginNamesFunes(homeDir, readPath);
462
+ if (opencodePlugin !== null)
463
+ notes.push(`global OpenCode config mentions funes (${opencodePlugin})`);
464
+ const detail = notes.length > 0 ? `funes on PATH, no team reach: ${notes.join("; ")}` : "funes on PATH, no team reach";
465
+ return okOutcome(detail);
466
+ }
260
467
  /** .tumwater writable — absent is fine (created on first run); present, prove it by writing
261
- * and deleting a temp file inside. The probe leaves the dir's listing unchanged after the run. */
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. */
262
472
  export function checkStateDir(root) {
263
473
  const dir = path.join(root, STATE_DIR);
264
474
  if (!fs.existsSync(dir))
@@ -266,12 +476,46 @@ export function checkStateDir(root) {
266
476
  const probe = path.join(dir, `.doctor-probe-${process.pid}`);
267
477
  try {
268
478
  fs.writeFileSync(probe, "");
269
- fs.rmSync(probe);
270
- return okOutcome("writable");
271
479
  }
272
480
  catch (err) {
481
+ // A partial write may already have created the file — sweep it like any other leftover.
482
+ sweepStaleProbes(dir);
273
483
  return failOutcome(`not writable: ${errorMessage(err)}`);
274
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
+ }
275
519
  }
276
520
  /** Event log readable — open and parse events.jsonl (and its rotated archive) so the one
277
521
  * failure the telemetry role is chartered to catch — a feed that hid itself — is named by the
@@ -288,20 +532,22 @@ export function checkEventLog(root) {
288
532
  return failOutcome(`could not read ${eventsLogPath(root)}: ${errorMessage(err)}`);
289
533
  }
290
534
  if (state === "unreadable")
291
- return failOutcome(`unreadable: ${eventsLogPath(root)} — the fleet's event feed cannot be read`);
535
+ return failOutcome(`unreadable: ${eventsLogPath(root)} — the team's event feed cannot be read`);
292
536
  if (state === "empty")
293
537
  return okOutcome("no events yet");
294
538
  return okOutcome(`readable: ${eventsLogPath(root)}`);
295
539
  }
296
540
  /** Disk floor (plans/disk-floor.md, parts 1/4 and 2/4) — is there room on the volume the
297
- * worktrees live on? Fail below `diskHoldGB`, the same floor the running fleet holds new work
541
+ * worktrees live on? Fail below `diskHoldGB`, the same floor the running team holds new work
298
542
  * at, so doctor and the gate agree on when the disk is a problem. Warn in the band between
299
543
  * `diskHoldGB` and `diskReclaimGB`: pressure reclaim will delete ignored build outputs from
300
- * idle worktrees but the fleet still runs. Ok at or above `diskReclaimGB` (or when reclaim is
544
+ * idle worktrees but the team still runs. Ok at or above `diskReclaimGB` (or when reclaim is
301
545
  * disabled and free space is above the hold). A statfs failure is a warning, not a failure: an
302
- * unmeasurable volume never holds the fleet, so it must not fail a scripted pre-flight. The
546
+ * unmeasurable volume never holds the team, so it must not fail a scripted pre-flight. The
303
547
  * detail names the measured path, the free GB to one decimal, and the floor. `holdGB` 0
304
- * disables the hold, so a free space above `reclaimGB`'s own band (or `reclaimGB` 0) passes. */
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. */
305
551
  export function checkDiskSpace(root, holdGB, reclaimGB, sample = sampleFreeBytes) {
306
552
  const where = diskVolumePath(root);
307
553
  const free = sample(root);
@@ -309,12 +555,27 @@ export function checkDiskSpace(root, holdGB, reclaimGB, sample = sampleFreeBytes
309
555
  return warnOutcome(`cannot measure free space at ${where} (statfs failed) — the disk hold is off for this process`);
310
556
  const freeGB = free / BYTES_PER_GB;
311
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);
312
560
  if (holdGB > 0 && freeGB < holdGB)
313
- return failOutcome(`${freeText} — below the ${holdGB} GB diskHoldGB floor; the fleet holds new work until space recovers`);
561
+ return failOutcome(withReclaim(`${freeText} — below the ${holdGB} GB diskHoldGB floor; the team waits to start new work until space recovers`));
314
562
  if (reclaimGB > 0 && freeGB < reclaimGB)
315
- return warnOutcome(`${freeText} — below the ${reclaimGB} GB diskReclaimGB threshold; a pressure reclaim pass deletes ignored build outputs from idle worktrees`);
563
+ return warnOutcome(withReclaim(`${freeText} — below the ${reclaimGB} GB diskReclaimGB threshold; a pressure reclaim pass deletes ignored build outputs from idle worktrees`));
316
564
  const floor = holdGB > 0 ? `above the ${holdGB} GB floor` : "diskHoldGB is 0 (hold disabled)";
317
- return okOutcome(`${freeText}, ${floor}`);
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;
318
579
  }
319
580
  /** Merge lock — read-only classification via classifyLock (the same three cases the breaker
320
581
  * uses). A stale lock is a warning, not a failure: it self-heals on the next merge. */
@@ -325,7 +586,7 @@ export function checkMergeLock(root) {
325
586
  return okOutcome("not held");
326
587
  case "live": {
327
588
  const pid = readLockPid(dir);
328
- return okOutcome(pid !== null ? `held by running loop (pid ${pid})` : "held (pid not yet written)");
589
+ return okOutcome(pid !== null ? `held by running role (pid ${pid})` : "held (pid not yet written)");
329
590
  }
330
591
  case "stale":
331
592
  return warnOutcome("stale — will be broken on next merge");
@@ -341,9 +602,15 @@ const PIN_STALE_MS = 24 * 60 * 60 * 1000;
341
602
  function leftoverWorktreeDirs(root) {
342
603
  return subdirectoryNames(worktreesDir(root), (name) => name !== DIRECTOR_ROLE && !(name.startsWith("_") && !name.startsWith("_land-")));
343
604
  }
344
- /** Worktree pool (plans/worktree-pool.md, part 5/5): report the pooled slots and warn on the two
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
345
611
  * states an operator should act on — a legacy `<role>` or `_land-*` checkout the pool migration
346
- * left behind, and a pin older than PIN_STALE_MS (a paused role holding a checkout). Read-only,
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,
347
614
  * like every doctor check; `now` is a test seam for the pin-age arithmetic. */
348
615
  export function checkWorktreePool(root, now = Date.now()) {
349
616
  const slots = readSlotsState(root).slots;
@@ -351,7 +618,7 @@ export function checkWorktreePool(root, now = Date.now()) {
351
618
  const pins = slots.filter((slot) => slot.pinnedFor !== null);
352
619
  const stale = pins.filter((slot) => slot.pinnedAt != null && now - slot.pinnedAt > PIN_STALE_MS);
353
620
  const leftover = leftoverWorktreeDirs(root);
354
- const parts = [`${slots.length} slots, ${leased.length} leased, ${pins.length} pinned`];
621
+ const parts = [`${plural(slots.length, "workspace")}, ${leased.length} leased, ${pins.length} pinned`];
355
622
  if (leftover.length > 0)
356
623
  parts.push(`legacy worktrees still present: ${leftover.join(", ")}`);
357
624
  if (stale.length > 0) {
@@ -359,15 +626,137 @@ export function checkWorktreePool(root, now = Date.now()) {
359
626
  .map((slot) => `${textOr(slot.pinnedFor)} pinned on ${path.basename(slot.dir)} for ${Math.round((now - (slot.pinnedAt ?? now)) / 3_600_000)}h`)
360
627
  .join("; "));
361
628
  }
362
- return (leftover.length > 0 || stale.length > 0 ? warnOutcome : okOutcome)(parts.join(" — "));
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("; "));
363
741
  }
364
- /** Parallel work instances (plans/parallel-work-instances.md "Observability", part 6/7): the
365
- * fleet's claims and the two states an operator should act on — a claim whose key is no longer
366
- * listed under its role's section (its instance is stuck on an entry that moved), and a claim
367
- * idle for more than CLAIM_IDLE_MAX_MS (the scheduler releases these, so seeing one means the
368
- * reader's view and the scheduler's disagree). An enabled multi-instance role configured with
369
- * fewer pooled slots than maxConcurrent also warns: instances then contend for the same
370
- * checkouts. A disabled role runs none of its configured instances, so it never contends and
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
371
760
  * stays out of that warning.
372
761
  * Read-only, like every check; `now` is the claim-age test seam, and an unreadable
373
762
  * tumwater.json (config undefined) skips the walk — checkInit reports the config itself. */
@@ -378,7 +767,7 @@ export function checkWorkInstances(root, config, now = Date.now()) {
378
767
  for (const id of loopIds(config)) {
379
768
  const claim = loadLoopState(root, id).claim;
380
769
  if (claim)
381
- claims.push({ id, title: claim.title, key: claim.key, at: claim.at });
770
+ claims.push({ id, title: claim.title, key: claim.key, at: claim.at, file: claim.file });
382
771
  }
383
772
  const gone = claims.filter((c) => !listedKeys(root, c.id).has(c.key));
384
773
  const stale = claims.filter((c) => !gone.includes(c) && now - c.at > CLAIM_IDLE_MAX_MS);
@@ -388,15 +777,80 @@ export function checkWorkInstances(root, config, now = Date.now()) {
388
777
  const slots = slotCount(config);
389
778
  const smallPool = multi.length > 0 && slots < config.maxConcurrent;
390
779
  if (gone.length === 0 && stale.length === 0 && !smallPool)
391
- return okOutcome(claims.length > 0 ? `${claims.length} claims held` : "no claims held");
780
+ return okOutcome(claimCounts(claims));
781
+ const noun = (c) => (c.file === "BUGS.md" ? "bug" : "plan");
392
782
  const parts = [
393
- ...gone.map((c) => `claim "${c.title}" (${c.id}) names an entry no longer listed`),
394
- ...stale.map((c) => `claim "${c.title}" (${c.id}) has been idle ${Math.round((now - c.at) / 3_600_000)}h`),
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`),
395
785
  ];
396
786
  if (smallPool)
397
787
  parts.push(`${multi.join(", ")} run instances but worktreeSlots ${slots} is below maxConcurrent ${config.maxConcurrent}`);
398
788
  return warnOutcome(parts.join("; "));
399
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
+ }
400
854
  /** Declared project check — names what the review gate's deterministic pre-check, the
401
855
  * red-main baseline, and redeploy's green check will run, without running it: a configured
402
856
  * `check.command` (plans/portability.md §6/7), else the npm auto-detection. None declared is
@@ -409,22 +863,46 @@ export function checkBuildCheck(root, config) {
409
863
  const check = detectBuildCheck(root, config ?? undefined);
410
864
  if (!check)
411
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);
412
868
  if (check.kind === "command") {
413
869
  const where = path.relative(root, check.cwd);
414
- return okOutcome(where ? `${check.command} (cwd ${where})` : check.command);
870
+ return withTiming(where ? `${check.command} (cwd ${where})` : check.command);
415
871
  }
416
872
  const where = path.relative(root, check.rootDir);
417
- return okOutcome(where ? `npm ${check.script} in ${where}` : `npm ${check.script}`);
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}`);
418
896
  }
419
897
  /** Build provenance — is the harness about to run (this process's dist/) the code main
420
898
  * describes? Only meaningful when this project IS the harness (isSelfHosted); elsewhere the
421
- * stamp is reported as-is. A stale build is a warning, not a failure: the fleet runs, just not
899
+ * stamp is reported as-is. A stale build is a warning, not a failure: the team runs, just not
422
900
  * the newest code, and auto-restart (or a rebuild + restart) resolves it. `info`, `head` and
423
901
  * `running` are injectable so tests can exercise every branch without compiling anything. */
424
902
  export async function checkBuild(root, info = readBuildInfo(), head = undefined,
425
903
  /** What the running orchestrator published about this build (orchestrator.json), when one is
426
904
  * up. A stale build whose restart was REFUSED must not be reported with the boilerplate
427
- * "auto-restart does this for a running fleet": nothing will happen until main moves, and
905
+ * "auto-restart does this for a running team": nothing will happen until main moves, and
428
906
  * doctor is where an operator goes to find out why. */
429
907
  running = null) {
430
908
  if (!info)
@@ -433,7 +911,7 @@ running = null) {
433
911
  if (!(await isSelfHosted(root, info)))
434
912
  // Not "dist/ from …": in someone else's project that names a dist/ directory the project
435
913
  // does not have — the stamp read here is the running harness install's own, so say that.
436
- return okOutcome(`harness build ${sha} (this project is not the harness itself)`);
914
+ return okOutcome(`tumwater version ${sha} (this project is not tumwater itself)`);
437
915
  const mainHead = head === undefined ? await currentHead(root) : head;
438
916
  if (!mainHead)
439
917
  return okOutcome(`dist/ from ${sha}`);
@@ -445,7 +923,7 @@ running = null) {
445
923
  ? `; auto-restart is BLOCKED (${running.restartBlocked}) and will not retry until main moves or the block clears`
446
924
  : running?.restartPending
447
925
  ? "; auto-restart is under way"
448
- : "; run `npm run build` and restart `tumwater run` (auto-restart does this for a running fleet)";
926
+ : "; run `npm run build` and restart `tumwater start` (auto-restart does this for a running team)";
449
927
  return warnOutcome(`dist/ from ${sha} is stale — main has ${stale.aheadCommits} later commit(s) touching ${STALE_INPUTS_LABEL}${next}`);
450
928
  }
451
929
  return okOutcome(`dist/ from ${sha}, matches main`);