tumwater 0.4.2 → 0.4.3

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 (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -1,13 +1,16 @@
1
1
  import { childEnv } from "../process/process.js";
2
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
2
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
3
3
  import { defaultConfig, loadConfigSafe } from "../config/config.js";
4
4
  import { seamsInUse } from "../config/config-fallbacks.js";
5
5
  import { cacheReadUnpriced, piModelsPath, readPiProviders } from "../pi/pi-models.js";
6
+ import { roleImageInfo } from "../agent/image-input.js";
7
+ import { readPiListedModels } from "../pi/pi-listed-models.js";
8
+ import { runnerIds } from "../roles/runner-ids.js";
6
9
  import { envPath } from "../files/files.js";
7
10
  import { piListedModels, piProviderAuth } from "./doctor-pi-checks.js";
8
11
  import { agree } from "../text/phrases.js";
9
12
  import { joinParts } from "../text/text.js";
10
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
13
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
11
14
  import { belowNodeFloor } from "../version.js";
12
15
  /** The one phrase a configured-models check uses when the config declares no models at all:
13
16
  * informational (capability falls to pi's own defaults), not a warning. Shared by
@@ -30,8 +33,9 @@ const PHRASE_CANNOT_READ_MODELS = "cannot check — could not read pi's model de
30
33
  * modules do. The claude, opencode, codex and pi provider checks live in their own per-backend
31
34
  * modules (doctor-claude-checks.ts, doctor-opencode-checks.ts, doctor-codex-checks.ts,
32
35
  * doctor-pi-checks.ts), each importing this file's shared seam framework and the AUTH_CHECK_*
33
- * probe budgets from probe.ts rather than duplicating them. The shared report contract
34
- * (CheckOutcome) is imported from outcome.ts, exactly as the other doctor-* modules do. Together
36
+ * probe budgets from doctor-probe.ts rather than duplicating them. The shared report contract
37
+ * (CheckOutcome) is imported from doctor-outcome.ts, exactly as the other doctor-*
38
+ * modules do. Together
35
39
  * these are the doctor's only checks that read pi's model definitions and, for the probes, spawn
36
40
  * an agent binary — a distinct concern from the filesystem/git checks, kept together so a change
37
41
  * to how doctor reads models does not touch the environment checks. */
@@ -131,30 +135,8 @@ export function codexSeamsInUse(config) {
131
135
  export function piSeamsInUse(config) {
132
136
  return seamsInUse(config).some((s) => s.agent === "pi");
133
137
  }
134
- /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
135
- * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
136
- * agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
137
- * every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
138
- * collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
139
- * default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
140
- * resolve through configForRole), and the reviewer's while review is on. A pair with no model is
141
- * pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
142
- * prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
143
- * (credentials are fixable without a config edit), a probe that cannot run warns rather than
144
- * guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
145
- * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
146
- * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
147
- * ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
148
- * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
149
- * tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
150
- * need no pi on PATH; `env` so the PI_* branches stay hermetic. */
151
- export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
152
- const config = loadConfigForCheck(root);
153
- if ("level" in config)
154
- return config;
155
- // Asked lazily and at most once: only a pair models.json does not declare needs the listing.
156
- let listing;
157
- const listed = () => (listing ??= listModels());
138
+ /** One job: sweep the config's seam pairs into the pair map and the routed-models map. */
139
+ function collectTierPairs(config) {
158
140
  const pairs = new Map();
159
141
  // Routed (non-pi) models keyed by the backend they resolved to, so the routing note names
160
142
  // each backend correctly; opencode is not claude.
@@ -175,75 +157,100 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
175
157
  };
176
158
  for (const pair of seamPairs(config))
177
159
  add(pair.agent, pair.provider, pair.model);
160
+ return { pairs, routedModels };
161
+ }
162
+ /** One job: compute the oh-my-pi variables note, and whether any is set. */
163
+ function ompTierNote(env) {
178
164
  const ompVars = ["PI_SMOL_MODEL", "PI_SLOW_MODEL", "PI_PLAN_MODEL"].filter((v) => env[v] !== undefined && env[v] !== "");
179
- const ompNote = ompVars.length === 0
180
- ? ""
181
- : ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`;
182
- if (pairs.size === 0 && routedModels.size === 0)
183
- return (ompVars.length === 0 ? okOutcome : warnOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
165
+ return {
166
+ set: ompVars.length > 0,
167
+ note: ompVars.length === 0
168
+ ? ""
169
+ : ` — ${ompVars.join(" and ")} ${agree(ompVars.length, "is", "are")} set: tumwater does not read ${agree(ompVars.length, "it", "them")} (oh-my-pi's variables; pi ignores them; with omp as agentBin they still reach omp through the inherited environment) — set model.small / model.strong instead`,
170
+ };
171
+ }
172
+ /** One job: read pi's provider definitions, or the empty map when the file is unreadable but
173
+ * every pair names a provider the listing can back (the unreadable case the caller warns on). */
174
+ async function readTierProviders(modelsPath, pairs, listed) {
184
175
  let providers = readPiProviders(modelsPath);
185
176
  if (!providers && [...pairs.values()].some((p) => p.provider !== undefined) && (await listed()))
186
177
  providers = new Map();
187
- const fails = [];
178
+ return providers;
179
+ }
180
+ /** One job: the fail string for a pair neither pi's definitions nor (when reachable) its
181
+ * listing resolve, or undefined when the listing does contain it. */
182
+ function unresolvedModelFail(provider, model, defs, listedModels, modelsPath) {
183
+ if (listedModels?.has(`${provider}/${model}`))
184
+ return undefined;
185
+ const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
186
+ if (!defs && !providerListed) {
187
+ return listedModels === null
188
+ ? `provider ${provider} is not in pi's definitions (${modelsPath})`
189
+ : `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`;
190
+ }
191
+ if (listedModels === null) {
192
+ // No exact entry: pi clones the provider's default model for the id, inheriting that
193
+ // default's price and context window (BUGS.md 2026-10-06) — name that consequence
194
+ // instead of only the failed lookup.
195
+ return `${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`;
196
+ }
197
+ return `${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``;
198
+ }
199
+ /** One job: the warn string for a priced model whose cache reads declare $0, or undefined. */
200
+ function cacheReadWarning(providers, provider, model, def) {
201
+ if (def && cacheReadUnpriced(providers, provider, model)) {
202
+ return `${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`;
203
+ }
204
+ return undefined;
205
+ }
206
+ /** One job: probe one provider's auth once, reporting ready or the warn when it is not. */
207
+ async function probeTierProvider(provider, auth, checkedProviders) {
208
+ if (checkedProviders.has(provider))
209
+ return {};
210
+ checkedProviders.add(provider);
211
+ const verdict = await auth(provider);
212
+ if (verdict === "ready")
213
+ return { ready: true };
214
+ if (verdict === "not-ready")
215
+ return { warn: `provider ${provider} reports not ready — check its credentials` };
216
+ return { warn: `could not verify provider ${provider} — the auth check did not run or its output was unreadable` };
217
+ }
218
+ /** One job: resolve one pi pair — the per-token claude warn, the definitions lookup and its
219
+ * unresolved fail, the free-cache-reads warn, and the once-per-provider auth probe. */
220
+ async function resolveTierPair(provider, model, providers, listed, auth, checkedProviders, modelsPath) {
188
221
  const warns = [];
189
- if (pairs.size > 0 && !providers)
190
- return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
191
- const readyProviders = new Set();
192
- const checkedProviders = new Set();
193
- for (const { provider, model } of pairs.values()) {
194
- if (provider === undefined) {
195
- warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
196
- continue;
197
- }
198
- // A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
199
- // to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
200
- // third-party-harness change a Claude plan's limits cover only first-party tools, so it is
201
- // billed per token even under a Claude subscription login — the bare `claude-…` form on the
202
- // claude CLI uses the plan's included usage instead.
203
- if (model?.startsWith("claude-")) {
204
- warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
205
- }
206
- const defs = providers?.get(provider);
207
- const def = defs?.find((d) => d.id === model);
208
- if (!def) {
209
- const listedModels = await listed();
210
- if (!listedModels?.has(`${provider}/${model}`)) {
211
- const providerListed = listedModels != null && [...listedModels].some((k) => k.startsWith(`${provider}/`));
212
- if (!defs && !providerListed) {
213
- fails.push(listedModels === null
214
- ? `provider ${provider} is not in pi's definitions (${modelsPath})`
215
- : `provider ${provider} is not in pi's definitions (${modelsPath}) and \`pi --list-models\` lists none of its models — pi lists only providers it has credentials for`);
216
- }
217
- else if (listedModels === null) {
218
- // No exact entry: pi clones the provider's default model for the id, inheriting that
219
- // default's price and context window (BUGS.md 2026-10-06) — name that consequence
220
- // instead of only the failed lookup.
221
- fails.push(`${provider}/${model} does not resolve in pi's definitions (${modelsPath}) — pi would price it at the provider default's rates and context window`);
222
- }
223
- else {
224
- fails.push(`${provider}/${model} is neither in pi's definitions (${modelsPath}) nor listed by \`pi --list-models\``);
225
- }
226
- continue;
227
- }
228
- }
229
- if (def && providers && cacheReadUnpriced(providers, provider, model)) {
230
- // A priced model whose cache reads declare $0: pi bills cache-read tokens at zero and
231
- // the daily budget undercounts the real spend (BUGS.md 2026-10-06).
232
- warns.push(`${provider}/${model} prices cache reads at $0 — the daily budget will undercount its spend`);
233
- }
234
- if (!checkedProviders.has(provider)) {
235
- checkedProviders.add(provider);
236
- const verdict = await auth(provider);
237
- if (verdict === "ready")
238
- readyProviders.add(provider);
239
- else if (verdict === "not-ready")
240
- warns.push(`provider ${provider} reports not ready — check its credentials`);
241
- else
242
- warns.push(`could not verify provider ${provider} — the auth check did not run or its output was unreadable`);
243
- }
222
+ if (provider === undefined) {
223
+ warns.push(`${model} names no provider — pi resolves it with its own default, tumwater cannot verify it`);
224
+ return { warns };
244
225
  }
245
- const found = [...fails, ...warns].join("; ");
246
- const resolvedPi = `${pairs.size} declared ${agree(pairs.size, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
226
+ // A Claude model routed to pi (plans/provider-concurrency.md "Routing a bare Claude model id
227
+ // to claude"): a named provider keeps it on pi, but since Anthropic's 2026-04-04
228
+ // third-party-harness change a Claude plan's limits cover only first-party tools, so it is
229
+ // billed per token even under a Claude subscription login — the bare `claude-…` form on the
230
+ // claude CLI uses the plan's included usage instead.
231
+ if (model?.startsWith("claude-")) {
232
+ warns.push(`${provider}/${model} runs on pi and is billed per token, not the Claude plan's included usage — use a bare claude-… id to route it to the claude CLI`);
233
+ }
234
+ const defs = providers?.get(provider);
235
+ const def = defs?.find((d) => d.id === model);
236
+ if (!def) {
237
+ const listedModels = await listed();
238
+ const fail = unresolvedModelFail(provider, model, defs, listedModels, modelsPath);
239
+ if (fail !== undefined)
240
+ return { warns, fail };
241
+ }
242
+ if (providers) {
243
+ const warning = cacheReadWarning(providers, provider, model, def);
244
+ if (warning !== undefined)
245
+ warns.push(warning);
246
+ }
247
+ const probe = await probeTierProvider(provider, auth, checkedProviders);
248
+ if (probe.warn !== undefined)
249
+ warns.push(probe.warn);
250
+ return { warns, ready: probe.ready };
251
+ }
252
+ /** One job: one routing note per non-pi backend in use, joined. */
253
+ function routedTierNotes(routedModels) {
247
254
  // One routing note per non-pi backend in use, so an opencode-routed model is never labeled
248
255
  // as claude's. The opencode note also carries the design §2 caveat that a bare `opencode/…`
249
256
  // id always means the OpenCode CLI, never pi's built-in `opencode` gateway. Every note opens
@@ -260,13 +267,67 @@ export async function checkTierModels(root, modelsPath = piModelsPath(), auth =
260
267
  ? routedNote(agent, n, "resolved by the codex backend, not pi's definitions")
261
268
  : routedNote(agent, n, "resolved by the claude backend, not pi's definitions"));
262
269
  }
263
- const routedText = routedNotes.length > 0 ? routedNotes.join("; ") : "";
270
+ return routedNotes.length > 0 ? routedNotes.join("; ") : "";
271
+ }
272
+ /** One job: assemble the final fail/warn/ok outcome from the collected verdicts. */
273
+ function tierOutcome(fails, warns, pairsCount, readyProviders, routedText, ompNote, ompSet) {
274
+ const found = [...fails, ...warns].join("; ");
275
+ const resolvedPi = `${pairsCount} declared ${agree(pairsCount, "model", "models")} resolve${readyProviders.size > 0 ? ` and ${agree(readyProviders.size, "provider", "all providers")} ${[...readyProviders].sort().join(", ")} report ready` : ""}`;
264
276
  const base = fails.length === 0 && warns.length === 0
265
- ? joinParts([pairs.size > 0 ? resolvedPi : "", routedText])
277
+ ? joinParts([pairsCount > 0 ? resolvedPi : "", routedText])
266
278
  : found;
267
279
  if (fails.length > 0)
268
280
  return failOutcome(`${base}${ompNote}`);
269
- return (warns.length > 0 || ompVars.length > 0 ? warnOutcome : okOutcome)(`${base}${ompNote}`);
281
+ return (warns.length > 0 || ompSet ? warnOutcome : okOutcome)(`${base}${ompNote}`);
282
+ }
283
+ /** Declared model readiness — plans/model-tiers.md "Doctor" (part 7c/8): every model the config can
284
+ * put on a seam must resolve in pi's definitions, and its provider must report `ready` from the
285
+ * agent binary's own auth check. A strong tier pi cannot resolve would fail every review, and so
286
+ * every merge — the check exists so the operator finds out before the team does, mid-review. Pairs
287
+ * collected in the same sweep teamModelsFree prices: each tier's top-level selector (a string is
288
+ * default's), every enabled role's resolved pair (per-role overrides included; tier-name overrides
289
+ * resolve through configForRole), and the reviewer's while review is on. A pair with no model is
290
+ * pi's own default and out of scope; fallback pairs are the sibling check's (checkFallbackModel
291
+ * prices the cap pair). Levels: an unresolvable pair fails, a provider that is not ready warns
292
+ * (credentials are fixable without a config edit), a probe that cannot run warns rather than
293
+ * guesses, and a priced model whose cache reads declare $0 warns — the daily budget would
294
+ * undercount it (BUGS.md 2026-10-06). When one of PI_SMOL_MODEL / PI_SLOW_MODEL / PI_PLAN_MODEL is
295
+ * set the check also reports that tumwater does not read it — the variables are oh-my-pi's, pi
296
+ * ignores them, and with omp as `agentBin` they still reach omp through the inherited environment
297
+ * (src/pi/pi-backend.ts, built on src/process/run-marker.ts's backendBaseEnv) — pointing at the
298
+ * tier keys that do the same job (`model.small` / `model.strong`). `auth` is injectable so tests
299
+ * need no pi on PATH; `env` so the PI_* branches stay hermetic. The inline jobs are split into
300
+ * the module-private helpers `collectTierPairs`, `ompTierNote`, `readTierProviders`,
301
+ * `resolveTierPair`, `unresolvedModelFail`, `cacheReadWarning`, `probeTierProvider`,
302
+ * `routedTierNotes` and `tierOutcome` so each is found by name. */
303
+ export async function checkTierModels(root, modelsPath = piModelsPath(), auth = (provider) => piProviderAuth(loadConfigSafe(root).config ?? defaultConfig(), provider), env = process.env, listModels = () => piListedModels(loadConfigSafe(root).config ?? defaultConfig(), envPath(), AUTH_CHECK_TIMEOUT_MS, root)) {
304
+ const config = loadConfigForCheck(root);
305
+ if ("level" in config)
306
+ return config;
307
+ // Asked lazily and at most once: only a pair models.json does not declare needs the listing.
308
+ let listing;
309
+ const listed = () => (listing ??= listModels());
310
+ const { pairs, routedModels } = collectTierPairs(config);
311
+ const { set: ompSet, note: ompNote } = ompTierNote(env);
312
+ if (pairs.size === 0 && routedModels.size === 0)
313
+ return (ompSet ? warnOutcome : okOutcome)(`${PHRASE_NO_MODELS_DECLARED}${ompNote}`);
314
+ const providers = await readTierProviders(modelsPath, pairs, listed);
315
+ const fails = [];
316
+ const warns = [];
317
+ if (pairs.size > 0 && !providers)
318
+ return warnOutcome(`${PHRASE_CANNOT_READ_MODELS}${modelsPath}${ompNote}`);
319
+ const readyProviders = new Set();
320
+ const checkedProviders = new Set();
321
+ for (const { provider, model } of pairs.values()) {
322
+ const result = await resolveTierPair(provider, model, providers, listed, auth, checkedProviders, modelsPath);
323
+ warns.push(...result.warns);
324
+ if (result.fail !== undefined)
325
+ fails.push(result.fail);
326
+ if (result.ready === true)
327
+ readyProviders.add(provider);
328
+ }
329
+ const routedText = routedTierNotes(routedModels);
330
+ return tierOutcome(fails, warns, pairs.size, readyProviders, routedText, ompNote, ompSet);
270
331
  }
271
332
  /** `gh` readiness for the opt-in `feedback` observer (plans/feedback-role.md "Shape" → "Part 1"):
272
333
  * the role files issues as the operator's GitHub login through `gh`, so doctor confirms `gh`
@@ -289,3 +350,26 @@ export async function checkFeedbackFiling(root, pathEnv = envPath(), probe = (ar
289
350
  return failOutcome("gh is not authenticated — run `gh auth login` — the feedback role files issues as your GitHub login");
290
351
  return okOutcome("gh present and authenticated — the feedback role can file issues on znation/tumwater");
291
352
  }
353
+ /** The `image input` row (plans/image-input.md "Surfaces", part 5/5): which enabled roles run
354
+ * on a model that cannot read images, and where each reroutes an image prompt — or that no
355
+ * usable reroute exists. Re-added probe-free by Subtract part 1d/6 after part 5f/6 deleted the
356
+ * probe-backed row: it reads roleImageInfo (the same resolver the status rows, role page, and
357
+ * submit gate share) rather than spawning a backend, so doctor and the surfaces cannot drift on
358
+ * who is text-only. No alert when none is (imageInput true or null — an unknown pair is not
359
+ * counted as text-only, mirroring the submit gate and the `models for each tier` stance). */
360
+ export function checkImageInput(root, modelsPath = piModelsPath(), listed = readPiListedModels(root)) {
361
+ const config = loadConfigForCheck(root);
362
+ if ("level" in config)
363
+ return config;
364
+ const textOnly = runnerIds(config).filter((role) => roleImageInfo(config, role, modelsPath, listed).imageInput === false);
365
+ if (textOnly.length === 0)
366
+ return okOutcome("every enabled role reads images");
367
+ const noTarget = textOnly.filter((role) => {
368
+ const rr = roleImageInfo(config, role, modelsPath, listed).imageReroute;
369
+ return rr === null || rr === "";
370
+ });
371
+ if (noTarget.length > 0)
372
+ return warnOutcome(`${noTarget.join(", ")} ${agree(noTarget.length, "runs", "run")} on models that can't read images and ${agree(noTarget.length, "has", "have")} no reroute target — a run that reads an image fails, and image attachments to these roles are refused. Declare image input on a vision model in pi's models.json, or point a tier at one.`);
373
+ const targets = new Set(textOnly.map((role) => roleImageInfo(config, role, modelsPath, listed).imageReroute ?? ""));
374
+ return okOutcome(`${textOnly.join(", ")} ${agree(textOnly.length, "runs", "run")} on models that can't read images — image prompts rerun on ${[...targets].join(", ")}.`);
375
+ }
@@ -1,9 +1,9 @@
1
1
  import { eventsSince } from "../events/event-window.js";
2
- import { isNotifyFailure } from "../events/notify.js";
2
+ import { isNotifyFailure } from "../events/event-notify.js";
3
3
  import { DAY_MS, humanAgo } from "../text/datetime.js";
4
4
  import { plural } from "../text/phrases.js";
5
5
  import { errorMessage, squash } from "../text/text.js";
6
- import { okOutcome, warnOutcome } from "./outcome.js";
6
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
7
7
  /** How far back the `notify` check looks for failed pages: a day, fixed. An opinionated
8
8
  * default with no knob, like STALE_BUG_DAYS: long enough that a transient hook failure shows
9
9
  * up, short enough that a page that did not go through is still the operator's business. */
@@ -1,7 +1,7 @@
1
1
  import os from "node:os";
2
2
  import path from "node:path";
3
3
  import { childEnv } from "../process/process.js";
4
- import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./probe.js";
4
+ import { AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS, boundedProbe } from "./doctor-probe.js";
5
5
  import { opencodeBackend, opencodeSpillDir } from "../agent/opencode/opencode-backend.js";
6
6
  import { opencodeModelFree, opencodeModelKey, readOpencodeCache } from "../agent/opencode/opencode-catalog.js";
7
7
  import { formatDate } from "../text/datetime.js";
@@ -9,7 +9,7 @@ import { opencodeDbPath } from "../paths.js";
9
9
  import { envPath, isDirectory, readTextOrNull, statOrNull, treeBytes } from "../files/files.js";
10
10
  import { formatBytes, tokenWindowLabel } from "../text/format.js";
11
11
  import { escapeRegExp } from "../text/text.js";
12
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
12
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
13
13
  import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-model-checks.js";
14
14
  /** The doctor's opencode credential and leak readiness check, split out of doctor-model-checks.ts
15
15
  * (whose shared seam/model framework and the pi and tier-models checks stay there):
@@ -19,8 +19,9 @@ import { loadConfigForCheck, opencodeSeamsInUse, seamedModels, } from "./doctor-
19
19
  * filesystem/git checks beside it, kept together so a change to how doctor reads opencode does
20
20
  * not touch the other model checks. The shared report contract (CheckOutcome) and the
21
21
  * seam/model framework (loadConfigForCheck, opencodeSeamsInUse, seamPairs) come from
22
- * doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from probe.ts, and the shared report
23
- * contract (CheckOutcome) from outcome.ts, as the other doctor-* modules do. */
22
+ * doctor-model-checks.ts, the AUTH_CHECK_* probe budgets from doctor-probe.ts, and the
23
+ * shared report contract (CheckOutcome) from doctor-outcome.ts, as the other doctor-*
24
+ * modules do. */
24
25
  /** The provider of a non-gateway opencode model id (`opencode/<provider>/<model>`), or
25
26
  * undefined for a gateway id (a bare `opencode/<model>` or `<model>`, served by OpenCode's
26
27
  * own gateway with no credential). A model id with more than the `opencode/` namespace prefix
@@ -2,12 +2,13 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { worktreesDir } from "../paths.js";
4
4
  import { pidAlive } from "../process/process.js";
5
+ import { rootToken } from "../process/run-marker.js";
5
6
  import { GROUP_POLL_MS, KILL_GRACE_MS } from "../process/process-group.js";
6
7
  import { systemProcessProbe } from "../process/process-table.js";
7
8
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
8
9
  import { errorMessage, truncate } from "../text/text.js";
9
10
  import { plural } from "../text/phrases.js";
10
- import { failOutcome, okOutcome, warnOutcome } from "./outcome.js";
11
+ import { failOutcome, okOutcome, warnOutcome } from "./doctor-outcome.js";
11
12
  /** The orphan-process check of `tumwater doctor`, split out of doctor.ts: it is the one
12
13
  * doctor check that inspects the operating system's process table (argv, cwd, parent-child
13
14
  * tree) rather than the repo's own files, so it carries a different shape of logic — path
@@ -92,6 +93,36 @@ function harnessGone(marker) {
92
93
  const m = /^(\d+)-/.exec(marker);
93
94
  return m ? !pidAlive(Number(m[1])) : null;
94
95
  }
96
+ /** The repo-identity token in a run marker's third dash segment — the team root tag
97
+ * `makeRunMarker` stamps when it knows one — or null for a rootless two-segment mark
98
+ * (`<pid>-<hex>`, or a torn or foreign value with no third segment): there is no evidence of
99
+ * which checkout's harness minted it. */
100
+ export function markRootToken(mark) {
101
+ const third = mark.split("-")[2];
102
+ return third === undefined ? null : third;
103
+ }
104
+ /** The repo-identity token `makeRunMarker` stamps into a team-rooted mark — the same `rootToken`
105
+ * run-marker.ts mints, re-exported here so the orphan scan can spell this root's side of the
106
+ * comparison without importing process/run-marker. */
107
+ export { rootToken };
108
+ /** ps's elapsed-time field (`[[D-]HH:]MM:SS`, the string the doctor line already prints) in
109
+ * milliseconds, the minimum-age gate the automatic reap uses so a just-leaked — still
110
+ * possibly owned — process is not killed. Any shape that does not parse, or that carries an
111
+ * out-of-range minute or second, reads 0: the conservative reading that keeps the reap from
112
+ * acting on uncertain evidence. */
113
+ export function parseEtimeMs(etime) {
114
+ const m = /^(?:\d+-)?(?:(\d+):)?(\d{1,2}):(\d{2})$/.exec(etime.trim());
115
+ if (!m)
116
+ return 0;
117
+ const hh = Number(m[1] ?? 0);
118
+ const mm = Number(m[2]);
119
+ const ss = Number(m[3]);
120
+ if (mm > 59 || ss > 59)
121
+ return 0;
122
+ const days = /^(\d+)-/.exec(etime.trim());
123
+ const d = days ? Number(days[1]) : 0;
124
+ return ((d * 24 + hh) * 3600 + mm * 60 + ss) * 1000;
125
+ }
95
126
  /** A parent pid → child pids map of the table. */
96
127
  function childrenOf(rows) {
97
128
  const children = new Map();
@@ -111,8 +142,10 @@ function shownCommand(command, root) {
111
142
  return truncate(cut.reduce((c, rt) => c.split(`${rt}/`).join(""), command), ORPHAN_COMMAND_MAX);
112
143
  }
113
144
  /** The detection half of checkOrphans, shared with killOrphans so the check and the kill cannot
114
- * disagree about the set. */
115
- async function findOrphans(root, probe) {
145
+ * disagree about the set. Exported so the always-on orphan alert feed (src/ui/alert-feeds.ts)
146
+ * runs the exact same scan the doctor row reads — the alert and the row cannot drift on the
147
+ * orphan set. */
148
+ export async function findOrphans(root, probe) {
116
149
  const roots = rootSpellings(root);
117
150
  let rows;
118
151
  try {
@@ -148,12 +181,24 @@ async function findOrphans(root, probe) {
148
181
  // Best-effort per the probe contract: argv and cwd still carry the check alone.
149
182
  }
150
183
  }
184
+ const ours = rootToken(root);
151
185
  const byMark = new Set();
152
186
  for (const r of parentless) {
153
187
  if (byArgv.has(r.pid))
154
188
  continue;
155
- const verdicts = (marks.get(r.pid) ?? []).map(harnessGone);
156
- if (verdicts.length > 0 && verdicts.every((v) => v === true))
189
+ const rMarks = marks.get(r.pid) ?? [];
190
+ const verdicts = rMarks.map(harnessGone);
191
+ // A dead-marked process is this checkout's only when no mark names a different root: a
192
+ // token that is present and not `ours` proves another checkout's harness stamped that
193
+ // process, and signalling it would break the "never signal a process not attributable"
194
+ // rule (BUGS.md, "Reap the doctor's orphans, part 1/3"). A rootless mark keeps today's
195
+ // report — whatever started it, nothing else will reap it.
196
+ if (verdicts.length > 0 &&
197
+ verdicts.every((v) => v === true) &&
198
+ rMarks.every((m) => {
199
+ const t = markRootToken(m);
200
+ return t === null || t === ours;
201
+ }))
157
202
  byMark.add(r.pid);
158
203
  }
159
204
  const orphans = parentless.filter((r) => {
@@ -199,16 +244,17 @@ async function findOrphans(root, probe) {
199
244
  * never a crashed doctor; unreadable cwds degrade to the argv match and say so. Any orphan is
200
245
  * a fail — the exit code is the point: a scripted doctor must notice. `probe` is injectable so
201
246
  * tests run against a fake table. */
202
- export async function checkOrphans(root, probe = systemProcessProbe) {
203
- const scan = await findOrphans(root, probe);
247
+ /** The orphan-problem sentence for the standing alert feed, shared with checkOrphans so the
248
+ * alert and the doctor row cannot drift on the wording: the count, each listed orphan's pid,
249
+ * age, cpu and command (with its descendant count and the run mark that caught it), and the
250
+ * nothing-reaps-these remediation. null when the scan errored or found no orphans. Public so
251
+ * the feed (src/ui/alert-feeds.ts) can stage a scan and get exactly the row's message. */
252
+ export function orphanFailDetail(root, scan) {
204
253
  if ("error" in scan)
205
- return warnOutcome(`could not scan the process table — ${scan.error}`);
254
+ return null;
206
255
  const { rows, orphans, marks, cwdProblem } = scan;
207
- if (orphans.length === 0) {
208
- if (cwdProblem !== null)
209
- return warnOutcome(`none named in argv, but process cwds are unreadable (${cwdProblem}) — an orphan started inside a worktree, like a leaked test runner, would be missed`);
210
- return okOutcome("none — no process reparented to PID 1 runs from .tumwater/worktrees/ or carries only dead runs' marks");
211
- }
256
+ if (orphans.length === 0)
257
+ return null;
212
258
  const children = childrenOf(rows);
213
259
  const listed = orphans.slice(0, ORPHANS_LISTED_MAX).map((r) => {
214
260
  const n = descendantPids(children, r.pid).length;
@@ -221,9 +267,22 @@ export async function checkOrphans(root, probe = systemProcessProbe) {
221
267
  });
222
268
  const more = orphans.length - listed.length;
223
269
  const count = `${plural(orphans.length, "orphaned process", "orphaned processes")} (PPID 1)`;
224
- return failOutcome(`${count}: ${listed.join("; ")}${more > 0 ? `; and ${more} more` : ""}` +
270
+ return `${count}: ${listed.join("; ")}${more > 0 ? `; and ${more} more` : ""}` +
225
271
  " — nothing reaps these; `tumwater doctor --kill-orphans` (or the dashboard Health card's Kill orphans, or the TUI Health tab's Ctrl+K pressed twice) kills each with its descendants" +
226
- (cwdProblem !== null ? ` (process cwds unreadable — ${cwdProblem}; argv matched only)` : ""));
272
+ (cwdProblem !== null ? ` (process cwds unreadable — ${cwdProblem}; argv matched only)` : "");
273
+ }
274
+ /** The doctor's `orphans` row. */
275
+ export async function checkOrphans(root, probe = systemProcessProbe) {
276
+ const scan = await findOrphans(root, probe);
277
+ if ("error" in scan)
278
+ return warnOutcome(`could not scan the process table — ${scan.error}`);
279
+ const { orphans, cwdProblem } = scan;
280
+ if (orphans.length === 0) {
281
+ if (cwdProblem !== null)
282
+ return warnOutcome(`none named in argv, but process cwds are unreadable (${cwdProblem}) — an orphan started inside a worktree, like a leaked test runner, would be missed`);
283
+ return okOutcome("none — no process reparented to PID 1 runs from .tumwater/worktrees/ or carries only dead runs' marks");
284
+ }
285
+ return failOutcome(orphanFailDetail(root, scan));
227
286
  }
228
287
  const systemSignals = {
229
288
  kill: (pid, signal) => void process.kill(pid, signal),
@@ -291,6 +350,20 @@ async function killTrees(root, rows, chosen, signals) {
291
350
  }
292
351
  return { killed, failed };
293
352
  }
353
+ /** The `pid N (+N descendant) command` line per killed tree and the `could not kill` line per
354
+ * refused tree, in the read the reapers' best-effort warning logs — the one shape both
355
+ * reapPreviousTeamOrphans and reapAttributableOrphans report, so the two cannot drift on the
356
+ * itemized wording. Callers join `[...items, ...refused]` with "; " behind their own verb
357
+ * phrase. */
358
+ function orphanKillReportLines(killed, failed) {
359
+ return {
360
+ items: killed.map((k) => {
361
+ const tree = k.descendants > 0 ? ` (+${plural(k.descendants, "descendant")})` : "";
362
+ return `pid ${k.pid}${tree} ${k.command}`;
363
+ }),
364
+ refused: failed.map((f) => `could not kill pid ${f.pid}: ${f.reason}`),
365
+ };
366
+ }
294
367
  /** Team start's reap of a crashed predecessor's leaks: kill the parentless processes whose run
295
368
  * marks all name dead harnesses and at least one names `previousPid`, the orchestrator the
296
369
  * info file recorded. Safe without an operator's judgment: a mark proves a tumwater start
@@ -314,11 +387,7 @@ export async function reapPreviousTeamOrphans(root, previousPid, probe = systemP
314
387
  if (chosen.length === 0)
315
388
  return 0;
316
389
  const { killed, failed } = await killTrees(root, scan.rows, chosen, signals);
317
- const items = killed.map((k) => {
318
- const tree = k.descendants > 0 ? ` (+${plural(k.descendants, "descendant")})` : "";
319
- return `pid ${k.pid}${tree} ${k.command}`;
320
- });
321
- const refused = failed.map((f) => `could not kill pid ${f.pid}: ${f.reason}`);
390
+ const { items, refused } = orphanKillReportLines(killed, failed);
322
391
  warnEventBestEffort(root, TUMWATER_SOURCE, `killed ${plural(killed.length, "orphaned process", "orphaned processes")} left by tumwater ` +
323
392
  `${previousPid}'s runs at team start: ${[...items, ...refused].join("; ")}`);
324
393
  return killed.length;
@@ -327,3 +396,34 @@ export async function reapPreviousTeamOrphans(root, previousPid, probe = systemP
327
396
  return 0;
328
397
  }
329
398
  }
399
+ /** How long an orphan must have been running before the automatic reap may kill it: a young
400
+ * orphan may still be a live run's process that findOrphans read as parentless mid-hand-off,
401
+ * and ten minutes is far longer than any such attribution would hold. The operator's
402
+ * `--kill-orphans`/dashboard/TUI click bypasses this — doctors judge with their eyes. */
403
+ const ORPHAN_REAP_MIN_AGE_MS = 10 * 60 * 1000;
404
+ /** The orchestrator's once-per-interval sweep of the orphans `checkOrphans` already reports:
405
+ * kill the same set the operator's `--kill-orphans` kills, once each is old enough that no
406
+ * live run can still own it. Reuses findOrphans' attribution and killTrees' signal-and-wait,
407
+ * so the automatic reap cannot drift from the doctor row or the operator kill and never
408
+ * signals a process findOrphans does not list. An unreadable table, or no old-enough orphan,
409
+ * kills nothing and logs nothing; it never throws. Returns the number of orphan trees killed. */
410
+ export async function reapAttributableOrphans(root, probe = systemProcessProbe, signals = systemSignals) {
411
+ try {
412
+ const scan = await findOrphans(root, probe);
413
+ if ("error" in scan)
414
+ return 0;
415
+ const chosen = scan.orphans.filter((r) => parseEtimeMs(r.etime) >= ORPHAN_REAP_MIN_AGE_MS);
416
+ if (chosen.length === 0)
417
+ return 0;
418
+ const { killed, failed } = await killTrees(root, scan.rows, chosen, signals);
419
+ if (killed.length === 0)
420
+ return 0;
421
+ const { items, refused } = orphanKillReportLines(killed, failed);
422
+ warnEventBestEffort(root, TUMWATER_SOURCE, `reaped ${plural(killed.length, "orphaned process", "orphaned processes")}: ` +
423
+ [...items, ...refused].join("; "));
424
+ return killed.length;
425
+ }
426
+ catch {
427
+ return 0;
428
+ }
429
+ }
@@ -9,6 +9,13 @@
9
9
  export function okOutcome(detail) {
10
10
  return { level: "ok", detail };
11
11
  }
12
+ /** The doctor's stand-down on a missing or unreadable tumwater.json: an ok outcome saying
13
+ * there is nothing to verify, because the config check reports the broken file itself. Shared
14
+ * by the four checks that skip their walk when config is unavailable, so the message reads
15
+ * identically across them. */
16
+ export function noConfigOutcome() {
17
+ return okOutcome("no readable tumwater.json — nothing to verify");
18
+ }
12
19
  export function warnOutcome(detail) {
13
20
  return { level: "warn", detail };
14
21
  }
@@ -1,5 +1,5 @@
1
1
  import { childEnv } from "../process/process.js";
2
- import { boundedProbe, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./probe.js";
2
+ import { boundedProbe, AUTH_CHECK_MAX_OUTPUT_BYTES, AUTH_CHECK_TIMEOUT_MS } from "./doctor-probe.js";
3
3
  import { parseJsonObject } from "../files/json-object.js";
4
4
  import { envPath } from "../files/files.js";
5
5
  import { resolveBackendBin } from "../pi/pi-bin.js";
@@ -12,7 +12,7 @@ import { parsePiModelImages, parsePiModelTable, writePiListedModels } from "../p
12
12
  * a crash, so readiness is read from the body alone and a reply that did not run or does not
13
13
  * parse is the only "unknown" (never not-ready, which would blame credentials). Never throws;
14
14
  * every failure mode ends on "unknown". The bounded subprocess is the shared boundedProbe
15
- * (probe.ts), also used by the claude probes. */
15
+ * (doctor-probe.ts), also used by the claude probes. */
16
16
  export async function piProviderAuth(config, provider, pathEnv = envPath(), timeoutMs = AUTH_CHECK_TIMEOUT_MS) {
17
17
  const resolved = resolveBackendBin(config);
18
18
  const result = await boundedProbe(resolved.bin, ["auth", "check", "--provider", provider, "--json"], childEnv({ PATH: pathEnv }), timeoutMs, AUTH_CHECK_MAX_OUTPUT_BYTES);
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
- import { readTextOrNull } from "../files/files.js";
2
+ import { readRegularTextOrNull } from "../files/files.js";
3
3
  import { PRINCIPLES_MAX_CHARS } from "../prompt/principles.js";
4
- import { okOutcome, warnOutcome } from "./outcome.js";
4
+ import { okOutcome, warnOutcome } from "./doctor-outcome.js";
5
5
  /** Doctor check for PRINCIPLES.md's injection cap. readPrinciples caps the trimmed text at
6
6
  * PRINCIPLES_MAX_CHARS for every prompt's <principles> block, and principleBullets keeps only
7
7
  * its leading bullets; a file over the cap loses its tail (and a long header eats bullet
@@ -9,7 +9,7 @@ import { okOutcome, warnOutcome } from "./outcome.js";
9
9
  * truncate, naming the trimmed count, the cap, the marker, and the fix. Read-only, like every
10
10
  * doctor check — it never edits PRINCIPLES.md. */
11
11
  export function checkPrinciples(root) {
12
- const raw = readTextOrNull(path.join(root, "PRINCIPLES.md"));
12
+ const raw = readRegularTextOrNull(path.join(root, "PRINCIPLES.md"));
13
13
  if (raw === null)
14
14
  return okOutcome("no PRINCIPLES.md");
15
15
  const length = raw.trim().length;