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
@@ -0,0 +1,27 @@
1
+ import { eventsSince } from "../events/event-window.js";
2
+ import { isNotifyFailure } from "../events/notify.js";
3
+ import { humanSeconds } from "../text/datetime.js";
4
+ import { plural } from "../text/phrases.js";
5
+ import { errorMessage, squash } from "../text/text.js";
6
+ import { okOutcome, warnOutcome } from "./outcome.js";
7
+ /** How far back the `notify` check looks for failed pages. */
8
+ const NOTIFY_CHECK_WINDOW_MS = 24 * 3600_000;
9
+ /** The `notify` check: whether a notify command is configured, and a warning when it failed to
10
+ * page within the last day (a nonzero exit, a kill, or a failed start — the warnings
11
+ * spawnNotifyCommand logs). Warns but never fails, like the hold rows: a dead webhook must not
12
+ * fail a scripted pre-flight. Never throws. `now` is the seam for the window's far edge. */
13
+ export function checkNotify(root, config, now = Date.now()) {
14
+ if (!config?.notify)
15
+ return okOutcome("no notify command configured — pages are off");
16
+ try {
17
+ const failures = eventsSince(root, NOTIFY_CHECK_WINDOW_MS, now, isNotifyFailure);
18
+ if (failures.length === 0)
19
+ return okOutcome("configured — no failed pages in the last 24h");
20
+ const latest = failures[failures.length - 1];
21
+ const age = humanSeconds(Math.max(0, Math.round((now - latest.ts) / 1000)));
22
+ return warnOutcome(`${plural(failures.length, "failed page")} in the last 24h; latest ${age} ago: ${squash(String(latest.message), 200)}`);
23
+ }
24
+ catch (err) {
25
+ return warnOutcome(`could not read the event log for failed pages: ${errorMessage(err)}`);
26
+ }
27
+ }
@@ -1,3 +1,4 @@
1
+ // vocabulary: plain
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { worktreesDir } from "../paths.js";
@@ -50,12 +51,12 @@ function argvNamesDir(command, dirs) {
50
51
  return false;
51
52
  });
52
53
  }
53
- /** True when an argv's RELATIVE worktree path (`node .tumwater/worktrees/qa/dist/src/cli.js
54
- * gui …`) is this repo's. Resolved against the process's cwd it must land here; one that lands
55
- * in a worktree that exists elsewhere belongs to another checkout's fleet and is not blamed on
56
- * this one. When the resolution lands nowhere — the cwd is unreadable, or is a scratch dir
57
- * since deleted (the leaked qa GUI's was), or the process chdir'd after exec — the worktree's
58
- * name is the evidence left: it counts only when this repo has a worktree by that name. */
54
+ /** True when an argv's RELATIVE worktree path (`node .tumwater/worktrees/qa/dist/src/cli.js gui …`)
55
+ * is this repo's. Resolved against the process's cwd it must resolve here; one that resolves in a
56
+ * worktree that exists elsewhere belongs to another checkout's team and is not blamed on this one.
57
+ * When the resolution points nowhere — the cwd is unreadable, or is a scratch dir since deleted
58
+ * (the leaked qa GUI's was), or the process chdir'd after exec — the worktree's name is the
59
+ * evidence left: it counts only when this repo has a worktree by that name. */
59
60
  function relativeArgvIsOurs(command, cwd, root, dirs) {
60
61
  for (const [, rel = "", name = ""] of command.matchAll(RELATIVE_WORKTREE_ARG)) {
61
62
  if (cwd !== undefined) {
@@ -93,7 +94,7 @@ function descendantCount(children, pid) {
93
94
  * (absolute; or relative, confirmed by cwd where it can be — relativeArgvIsOurs) or when its
94
95
  * cwd lies under it: a leaked `node dist/test/test-runner.js` names no worktree at all.
95
96
  *
96
- * PPID 1 is what keeps the live fleet out: a running orchestrator's pi runs and build checks
97
+ * PPID 1 is what keeps the live team out: a running orchestrator's pi runs and build checks
97
98
  * have the orchestrator as parent, and the orchestrator and its supervisor run from the repo
98
99
  * root, never from a worktree, even when a detached `nohup` launch leaves them parentless.
99
100
  * (macOS always reparents an orphan to launchd, PID 1; on Linux a process under a child
@@ -104,7 +105,7 @@ function descendantCount(children, pid) {
104
105
  * 2/2): every process a run starts carries `TUMWATER_RUN=<harness pid>-<nonce>` in its
105
106
  * environment (appended to any inherited mark), wherever its cwd is — exactly where the
106
107
  * prompts send agents (scratch dirs under the system temp). A parentless process whose
107
- * every mark names a harness pid that is no longer alive is this fleet's orphan: nothing
108
+ * every mark names a harness pid that is no longer alive is this team's orphan: nothing
108
109
  * will reap it, because the run sweep that would have dies with its harness. A mark naming
109
110
  * ANY live harness is not reported — that harness's own sweep kills everything carrying the
110
111
  * mark when its run folds. A pid that died and was recycled by an unrelated process reads
@@ -198,7 +199,7 @@ export async function checkOrphans(root, probe = systemProcessProbe) {
198
199
  // A mark-caught orphan says which run marked it: the operator killing it wants to know
199
200
  // nothing else will, and the dead harness's pid is the evidence argv and cwd lacked.
200
201
  const mark = (marks.get(r.pid) ?? []).find((m) => harnessGone(m) === true);
201
- const marked = mark !== undefined ? ` (run ${mark}; harness ${mark.split("-")[0]} exited)` : "";
202
+ const marked = mark !== undefined ? ` (run ${mark}; tumwater ${mark.split("-")[0]} exited)` : "";
202
203
  return `pid ${r.pid} (age ${r.etime}, cpu ${r.time}${tree}) ${truncate(command, ORPHAN_COMMAND_MAX)}${marked}`;
203
204
  });
204
205
  const more = orphans.length - listed.length;
@@ -1,13 +1,40 @@
1
- /** Render the report: a header line, one line per check (level, name, detail), and the
2
- * verdict. The name column is one width for every row — 12 columns by default, widened to
3
- * the longest name in this report ("backlog headings" is 16) so no row's detail starts a
4
- * few columns early — which keeps a report of short-named checks byte-identical to the
5
- * fixed-width output. Warnings never affect the exit code — only fails do. */
1
+ import { plural } from "../text/phrases.js";
2
+ /** One line per check (level, name, detail), the name column as wide as the longest name, at
3
+ * least 12. */
4
+ function checkLines(checks) {
5
+ const nameWidth = checks.reduce((w, c) => Math.max(w, c.name.length), 12);
6
+ return checks.map((c) => `${c.level.padEnd(5)} ${c.name.padEnd(nameWidth)} ${c.detail}`);
7
+ }
8
+ /** Render the report: a header line, one line per check (level, name, detail), the routing
9
+ * table (each seam's label, its agent-qualified selector, and its backend), and the verdict.
10
+ * The name column is one width for every row — 12 columns by default, widened to the longest
11
+ * name in this report ("backlog headings" is 16) so no row's detail starts a few columns early
12
+ * — which keeps a report of short-named checks byte-identical to the fixed-width output.
13
+ * Warnings never affect the exit code — only fails do. */
6
14
  export function renderDoctor(report) {
7
- const nameWidth = report.checks.reduce((w, c) => Math.max(w, c.name.length), 12);
8
- const lines = [report.header];
9
- for (const c of report.checks)
10
- lines.push(`${c.level.padEnd(5)} ${c.name.padEnd(nameWidth)} ${c.detail}`);
15
+ const lines = [report.header, ...checkLines(report.checks)];
16
+ if (report.routing.length > 0) {
17
+ const width = report.routing.reduce((w, s) => Math.max(w, s.label.length), "job".length);
18
+ lines.push("routing: job · model · backend");
19
+ for (const s of report.routing)
20
+ lines.push(` ${s.label.padEnd(width)} ${s.selector} (${s.backend})`);
21
+ }
22
+ lines.push(report.verdict);
23
+ return lines.join("\n");
24
+ }
25
+ /** The report with only its warn and fail checks; header, routing, and verdict stay. */
26
+ export function problemsOnly(report) {
27
+ return { ...report, checks: report.checks.filter((c) => c.level !== "ok") };
28
+ }
29
+ /** `--problems`: the header, the warn and fail rows (name column sized over those rows alone), a
30
+ * line counting the hidden ok rows (left out when none), and the verdict — never the routing
31
+ * table. Takes the full report so it can count what it hides. */
32
+ export function renderDoctorProblems(report) {
33
+ const shown = problemsOnly(report).checks;
34
+ const hidden = report.checks.length - shown.length;
35
+ const lines = [report.header, ...checkLines(shown)];
36
+ if (hidden > 0)
37
+ lines.push(`${plural(hidden, "ok check")} hidden — run tumwater doctor for the full report`);
11
38
  lines.push(report.verdict);
12
39
  return lines.join("\n");
13
40
  }
@@ -1,44 +1,67 @@
1
+ // vocabulary: plain
1
2
  import { loadConfigSafe } from "../config/config.js";
2
3
  import { envPath } from "../files/files.js";
3
- import { orchestratorAlive, readOrchestratorInfo } from "../fleet/orchestrator-info.js";
4
+ import { orchestratorAlive, readOrchestratorInfo } from "../team/orchestrator-info.js";
4
5
  import { systemProcessProbe } from "../process/process-table.js";
5
6
  import { checkOrphans } from "./doctor-orphans.js";
6
- import { checkBudget, checkMaintenanceQuota, checkPause, checkQuietHours } from "./doctor-holds.js";
7
+ import { checkBudget, checkClaudeWindow, checkErrorStreaks, checkMainRed, checkMaintenanceQuota, checkPause, checkProviderHolds, checkQuietHours } from "./doctor-holds.js";
8
+ import { checkRootInstall } from "./doctor-install.js";
9
+ import { checkNotify } from "./doctor-notify.js";
7
10
  import { checkLaunchServicesPorts } from "./doctor-launch-services.js";
8
11
  import { plural } from "../text/phrases.js";
9
12
  import { shortSha } from "../text/format.js";
10
- import { checkAgentBinary, checkBrief, checkBootstrap, checkBuild, checkBuildCheck, checkClaudeTranscripts, checkGitBinary, checkInit, checkDiskSpace, checkEventLog, checkMergeLock, checkNodeVersion, checkRepo, checkStateDir, checkWorktreePool, checkWorkInstances, } from "./doctor-checks.js";
11
- import { checkClaudeAuth, checkClaudeVersion, checkFallbackModel, checkTierModels } from "./doctor-model-checks.js";
12
- import { checkBacklogHeadings, checkFixClaims, checkStrandedPlans } from "./doctor-backlog.js";
13
+ import { checkAgentBinary, checkBrief, checkBootstrap, checkBuild, checkBuildCheck, checkClaudeTranscripts, checkFunes, checkGitBinary, checkConfigKeyNames, checkInit, checkDiskSpace, checkEventLog, checkFlakyChecks, checkOpencodeVersion, checkMergeLock, checkNodeVersion, checkPiExtensions, checkProviderConcurrency, checkProviderContention, checkRepo, checkStateDir, checkWorktreePool, checkLeftoverRoleBranches, checkWorkInstances, } from "./doctor-checks.js";
14
+ import { checkClaudeAuth, checkClaudeVersion, checkAnthroApiKey, checkFallbackChain, checkFallbackModel, checkImageInput, checkOpencode, checkTierModels } from "./doctor-model-checks.js";
15
+ import { agentQualifiedSelector } from "../config/config-views.js";
16
+ import { seamsInUse } from "../config/config-fallbacks.js";
17
+ import { checkBacklogHeadings, checkFixClaims, checkLiveFinished, checkPlanDependencies, checkRefusedEntries, checkRegressedFixes, checkStaleQueuedMerges, checkStaleBugs, checkStalePrompts, checkStaleQuestions, checkStalledPlans, checkStrandedPlans, checkUncommittedBacklog, checkValidationGaps, checkWaitingBugs } from "./doctor-backlog.js";
13
18
  /** Pre-flight environment check (`tumwater doctor`). The harness's preconditions are
14
19
  * scattered across fail-fast checks that each command re-runs on its own (requireReadyRepo in
15
20
  * cli.ts walks git-binary → repo → tumwater.json → commits and stops at the first failure;
16
21
  * cmdRun adds pi-on-PATH and orchestrator-alive), so a user facing a partially initialized or
17
22
  * drifted environment gets one error at a time. doctor runs every check, reports each result
18
23
  * individually instead of stopping at the first failure, and exits 0/1 so it can be scripted —
19
- * the pre-flight sibling of `status --json`, which queries live fleet state while doctor
24
+ * the pre-flight sibling of `status --json`, which queries live team state while doctor
20
25
  * verdicts on the environment. Every check is read-only against .tumwater/ (works with or
21
26
  * without a running harness) and none runs the project's build/test: that can take minutes and
22
27
  * belongs in the review gate / red-main check, not a pre-flight. The individual checks live in
23
28
  * doctor-checks.ts and doctor-model-checks.ts; this module composes them into the report. */
29
+ /** Wrap a possibly-absent doctor check into the report's `checks` spread: null omits the row
30
+ * entirely (the pi-extensions row for a team with no pi seam; the funes row when funes is not
31
+ * on PATH); an outcome contributes `{ name, ...outcome }`. */
32
+ function appendNamedOutcome(name, outcome) {
33
+ return outcome === null ? [] : [{ name, ...outcome }];
34
+ }
24
35
  /** Run every check in order and compose the report. Read-only against .tumwater/ by
25
36
  * construction — no check removes or repairs anything (the state-dir probe writes a temp file
26
37
  * and deletes it again; the orphan check reports processes, never signals them) — so doctor
27
38
  * works identically with or without a running harness. `probe` feeds the orphan and port
28
39
  * checks, so tests can pin the report without reading the host's process table. */
29
- export async function runDoctor(root, pathEnv = envPath(), probe = systemProcessProbe) {
40
+ export async function runDoctor(root, pathEnv = envPath(), probe = systemProcessProbe, probeImages = "never") {
30
41
  // Loaded once for the checks that read config (repo's baseBranch); a broken file stays
31
42
  // undefined — checkInit reports it verbatim — so doctor still runs every other check.
32
43
  const { config } = loadConfigSafe(root);
44
+ // The routing table: every seam the config can spawn, with the selector each one runs on and
45
+ // the backend it routes to — read from the same one list the checks sweep, so an operator
46
+ // sees (and the checks cover) a claude fallback exactly like a primary seam. A broken config
47
+ // keeps it empty (the init check reports that separately).
48
+ const routing = config
49
+ ? seamsInUse(config).map((s) => ({
50
+ label: s.label,
51
+ selector: s.model !== undefined ? agentQualifiedSelector({ ...s, model: s.model }) : "no model declared",
52
+ backend: s.agent ?? "pi",
53
+ }))
54
+ : [];
33
55
  const info = readOrchestratorInfo(root);
34
56
  const header = orchestratorAlive(root, info) && info
35
- ? `tumwater doctor — harness running (pid ${info.pid}${info.build ? `, build ${shortSha(info.build.sha)}${info.build.stale ? " — STALE" : ""}${info.build.restartBlocked ? " (restart blocked)" : ""}` : ""})`
36
- : "tumwater doctor — harness not running";
57
+ ? `tumwater doctor — running (pid ${info.pid}${info.build ? `, version ${shortSha(info.build.sha)}${info.build.stale ? " — update available" : ""}${info.build.restartBlocked ? " (restart blocked)" : ""}` : ""})`
58
+ : "tumwater doctor — not running";
37
59
  const checks = [
38
60
  { name: "node", ...checkNodeVersion() },
39
61
  { name: "git binary", ...checkGitBinary(pathEnv) },
40
62
  { name: "repo", ...(await checkRepo(root, config)) },
41
63
  { name: "init", ...checkInit(root) },
64
+ { name: "config key names", ...checkConfigKeyNames(root) },
42
65
  { name: "brief", ...checkBrief(root) },
43
66
  // New-project bootstrap (plans/work-ratio.md, part 2/2): an active hold reports as ok
44
67
  // information, never a warning — a young project is not misconfigured.
@@ -46,32 +69,67 @@ export async function runDoctor(root, pathEnv = envPath(), probe = systemProcess
46
69
  { name: "pause", ...checkPause(root) },
47
70
  { name: "quiet hours", ...checkQuietHours(config) },
48
71
  { name: "budget", ...checkBudget(root, config, (orchestratorAlive(root, info) && info?.budget) || null) },
49
- { name: "maintenance quota", ...checkMaintenanceQuota(root, config, orchestratorAlive(root, info)) },
50
- { name: "fallback", ...checkFallbackModel(root, undefined, (orchestratorAlive(root, info) && info?.fallbackDemoted) || null) },
51
- { name: "tier models", ...(await checkTierModels(root)) },
72
+ { name: "claude window", ...checkClaudeWindow(root, undefined, (orchestratorAlive(root, info) && info?.chain) || undefined, config) },
73
+ { name: "provider waits", ...checkProviderHolds(root) },
74
+ { name: "maintenance allowance", ...checkMaintenanceQuota(root, config, orchestratorAlive(root, info)) },
75
+ { name: "provider concurrency", ...checkProviderConcurrency(config, root) },
76
+ { name: "provider contention", ...checkProviderContention(root, config) },
77
+ { name: "repeated failures", ...checkErrorStreaks(root, config) },
78
+ { name: "main's check", ...checkMainRed(root, config) },
79
+ { name: "over-cap model", ...checkFallbackModel(root, undefined, (orchestratorAlive(root, info) && info?.fallbackDemoted) || null) },
80
+ { name: "fallback chain", ...(await checkFallbackChain(root)) },
81
+ { name: "models for each tier", ...(await checkTierModels(root)) },
82
+ { name: "image input", ...(await checkImageInput(root, undefined, undefined, undefined, probeImages, pathEnv)) },
52
83
  { name: "claude auth", ...(await checkClaudeAuth(root, pathEnv)) },
84
+ { name: "claude api key", ...(await checkAnthroApiKey(root, undefined, pathEnv)) },
53
85
  { name: "claude version", ...(await checkClaudeVersion(root, pathEnv)) },
54
- { name: "agent binary", ...checkAgentBinary(root, pathEnv) },
86
+ { name: "opencode version", ...(await checkOpencodeVersion(root, pathEnv)) },
87
+ { name: "opencode", ...(await checkOpencode(root, pathEnv)) },
88
+ { name: "backend binary", ...checkAgentBinary(root, pathEnv) },
55
89
  // The transcript total is informational and only read when a seam routes to claude.
56
90
  { name: "claude transcripts", ...checkClaudeTranscripts(config) },
91
+ // The receive-side guards name what a running team skips, so an operator tool meant for the
92
+ // team (a pi package, an extension file, an mcp.json server, or funes) does not vanish
93
+ // silently. Each check returns null to omit its row: the pi row for a team with no pi seam,
94
+ // the funes row when funes is not on PATH.
95
+ ...(appendNamedOutcome("pi extensions", checkPiExtensions(config, undefined, pathEnv))),
96
+ ...(appendNamedOutcome("funes", checkFunes(config, pathEnv))),
57
97
  { name: "state dir", ...checkStateDir(root) },
58
98
  { name: "event log", ...checkEventLog(root) },
99
+ { name: "notify", ...checkNotify(root, config) },
59
100
  { name: "merge lock", ...checkMergeLock(root) },
60
- { name: "worktree pool", ...checkWorktreePool(root) },
61
- { name: "work instances", ...checkWorkInstances(root, config) },
101
+ { name: "workspaces", ...checkWorktreePool(root) },
102
+ { name: "parallel instances", ...checkWorkInstances(root, config) },
103
+ { name: "leftover role branches", ...(await checkLeftoverRoleBranches(root, config)) },
62
104
  // The disk floor (plans/disk-floor.md, part 1/4): a broken config stays undefined (checkInit
63
105
  // reports it), so the check falls back to the 10 GB default rather than skipping itself.
64
106
  { name: "disk space", ...checkDiskSpace(root, config?.diskHoldGB ?? 10, config?.diskReclaimGB ?? 40) },
65
- { name: "project check", ...checkBuildCheck(root, config) },
66
- { name: "fix claims", ...checkFixClaims(root) },
67
- { name: "stranded plans", ...checkStrandedPlans(root) },
107
+ { name: "check", ...checkBuildCheck(root, config) },
108
+ { name: "root install", ...checkRootInstall(root) },
109
+ // The review gate's flake memory, surfaced read-only: naming which checks the gate kept
110
+ // excusing as flakes (ok/warn only, so a flaky suite never fails a scripted pre-flight).
111
+ { name: "flaky checks", ...checkFlakyChecks(root) },
112
+ { name: "claimed bugs", ...checkFixClaims(root) },
113
+ { name: "validation gaps", ...checkValidationGaps(root) },
114
+ { name: "plans stuck in Planned", ...checkStrandedPlans(root) },
68
115
  { name: "backlog headings", ...checkBacklogHeadings(root) },
69
- // The running fleet's own view of its build (staleness and what auto-restart made of it)
116
+ { name: "backlog archive", ...checkLiveFinished(root) },
117
+ { name: "refusals", ...checkRefusedEntries(root) },
118
+ { name: "stalled plans", ...checkStalledPlans(root) },
119
+ { name: "plan dependencies", ...checkPlanDependencies(root) },
120
+ { name: "stale questions", ...checkStaleQuestions(root) },
121
+ { name: "stale bugs", ...checkStaleBugs(root) },
122
+ { name: "waiting bugs", ...checkWaitingBugs(root) },
123
+ { name: "regressed fixes", ...checkRegressedFixes(root) },
124
+ { name: "stale merge-queue entries", ...checkStaleQueuedMerges(root, Date.now()) },
125
+ { name: "stale prompts", ...checkStalePrompts(root, Date.now()) },
126
+ { name: "uncommitted backlog", ...(await checkUncommittedBacklog(root, config)) },
127
+ // The running team's own view of its build (staleness and what auto-restart made of it)
70
128
  // when there is one; without it the check still stands on its own git comparison.
71
- { name: "build", ...(await checkBuild(root, undefined, undefined, (orchestratorAlive(root, info) && info?.build) || null)) },
129
+ { name: "update", ...(await checkBuild(root, undefined, undefined, (orchestratorAlive(root, info) && info?.build) || null)) },
72
130
  { name: "orphans", ...(await checkOrphans(root, probe)) },
73
- { name: "mach ports", ...(await checkLaunchServicesPorts(probe)) },
131
+ { name: "macOS port leak", ...(await checkLaunchServicesPorts(probe)) },
74
132
  ];
75
133
  const problems = checks.filter((c) => c.level === "fail").length;
76
- return { header, checks, verdict: problems === 0 ? "ready to run" : plural(problems, "problem") };
134
+ return { header, checks, routing, verdict: problems === 0 ? "ready to run" : plural(problems, "problem") };
77
135
  }
@@ -0,0 +1,244 @@
1
+ /** The doctor's live image-input probe (plans/image-input.md "Doctor", part 2/5): a claimed
2
+ * image-input capability in pi's models.json is only a claim — doctor shows each claimed model
3
+ * a real image and checks the answer. This module owns three things: the in-process PNG
4
+ * generator (a random 4-digit code drawn with a built-in 5×7 bitmap, encoded with
5
+ * `zlib.deflateSync` + `zlib.crc32` is newer than the `>=20.3` node floor, so the CRC-32 here is
6
+ * hand-rolled), the probe runner (the resolved pi binary in a run's environment, bounded like
7
+ * the doctor's other binary probes), and the `.tumwater/state/image-probes.json` cache the
8
+ * doctor check reads back. No new dependency; runs entirely offline against the operator's pi. */
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { randomInt } from "node:crypto";
13
+ import zlib from "node:zlib";
14
+ import { PiStreamParser } from "../pi/pi-stream.js";
15
+ import { resolveAgentBin } from "../pi/pi-bin.js";
16
+ import { imageProbesPath } from "../paths.js";
17
+ import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
18
+ import { boundedProbe } from "./probe.js";
19
+ /** How long a cached image-probe verdict stays valid while the claim is unchanged (7 days) —
20
+ * a model's endpoint can change underneath a stale pass, so the answer is dated. */
21
+ export const IMAGE_PROBE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
22
+ /** The probe's deadline (90 s): a local model may need to load, far beyond the quick credential
23
+ * probes, but a doctor run is still a pre-flight — a hung backend must lose, not stall the
24
+ * report. */
25
+ const PROBE_TIMEOUT_MS = 90_000;
26
+ /** Cap on the probe's captured stdout, the same guard as the auth probes: a doctor run must not
27
+ * grow an unbounded buffer off a runaway log. */
28
+ const PROBE_MAX_OUTPUT_BYTES = 1024 * 1024;
29
+ /** The cache-file key for one seam — agent/provider/model, the natural identity of a team
30
+ * model, joined on `/` (agent and provider never contain it and a model id is fully qualified,
31
+ * so the key cannot collide with another seam's). */
32
+ export function imageProbeKey(agent, provider, model) {
33
+ return [agent ?? "", provider ?? "", model ?? ""].join("/");
34
+ }
35
+ /** Whether a cached entry still stands for the current claim: it exists, it recorded a real
36
+ * verdict (never `could-not-verify`, which is never cached), it tested the image-input claim,
37
+ * and it is younger than the TTL. */
38
+ export function imageProbeEntryFresh(entry, now = Date.now()) {
39
+ return (entry !== undefined &&
40
+ (entry.verdict === "pass" || entry.verdict === "fail") &&
41
+ entry.claim &&
42
+ now - entry.at <= IMAGE_PROBE_TTL_MS);
43
+ }
44
+ /** Read the probe cache, or an empty cache when the file is missing or torn (the harness's
45
+ * no-data policy: a crashed writer never takes down a reader). */
46
+ export function readImageProbeCache(root) {
47
+ return readJsonFile(imageProbesPath(root)) ?? {};
48
+ }
49
+ /** Write the probe cache atomically (tmp+rename) so a concurrent reader never sees a torn
50
+ * file. */
51
+ export function writeImageProbeCache(root, cache) {
52
+ writeJsonAtomic(imageProbesPath(root), cache);
53
+ }
54
+ // ── PNG generator ─────────────────────────────────────────────────────────────────────────
55
+ /** Each digit as 7 rows of 5 bits, '1' = drawn (black); the standard 5×7 seven-segment-ish
56
+ * font, big enough for a vision model to read and small enough to stay a trivial payload. */
57
+ const DIGIT_BITMAP = {
58
+ "0": ["01110", "10001", "10011", "10101", "11001", "10001", "01110"],
59
+ "1": ["00100", "01100", "00100", "00100", "00100", "00100", "01110"],
60
+ "2": ["01110", "10001", "00001", "00110", "01000", "10000", "11111"],
61
+ "3": ["11111", "00010", "00100", "00010", "00001", "10001", "01110"],
62
+ "4": ["00010", "00110", "01010", "10010", "11111", "00010", "00010"],
63
+ "5": ["11111", "10000", "11110", "00001", "00001", "10001", "01110"],
64
+ "6": ["00110", "01000", "10000", "11110", "10001", "10001", "01110"],
65
+ "7": ["11111", "00001", "00010", "00100", "01000", "01000", "01000"],
66
+ "8": ["01110", "10001", "10001", "01110", "10001", "10001", "01110"],
67
+ "9": ["01110", "10001", "10001", "01111", "00001", "00010", "01100"],
68
+ };
69
+ /** A hand-rolled CRC-32 (the PNG chunk checksum): `zlib.crc32` is newer than the `>=20.3` node
70
+ * floor, so this bitwise table version is the one home of the polynomial. */
71
+ const CRC32_TABLE = (() => {
72
+ const table = new Uint32Array(256);
73
+ for (let n = 0; n < 256; n++) {
74
+ let c = n;
75
+ for (let k = 0; k < 8; k++)
76
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
77
+ table[n] = c >>> 0;
78
+ }
79
+ return table;
80
+ })();
81
+ function crc32(buf) {
82
+ let c = 0xffffffff;
83
+ for (let i = 0; i < buf.length; i++)
84
+ c = (CRC32_TABLE[(c ^ (buf[i] ?? 0)) & 0xff] ?? 0) ^ (c >>> 8);
85
+ return (c ^ 0xffffffff) >>> 0;
86
+ }
87
+ /** One PNG chunk: 4-byte big-endian length, the 4-byte ASCII type, the data, and the CRC over
88
+ * type+data. */
89
+ function pngChunk(type, data) {
90
+ const len = Buffer.alloc(4);
91
+ len.writeUInt32BE(data.length, 0);
92
+ const typeBuf = Buffer.from(type, "ascii");
93
+ const crc = Buffer.alloc(4);
94
+ crc.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
95
+ return Buffer.concat([len, typeBuf, data, crc]);
96
+ }
97
+ /** A PNG showing `code` black on white, drawn with the 5×7 bitmap scaled `scale`-fold (each
98
+ * font cell becomes a `scale`×`scale` block) and one `scale`-wide gap between digits. Truecolor
99
+ * (color type 2) so any reader handles it and the decode-check in the tests is trivial. Exported
100
+ * for tests (and the doctor's own validity check of a generated image). */
101
+ export function makeProbePng(code, scale = 8) {
102
+ const digits = [...code].map((d) => DIGIT_BITMAP[d] ?? DIGIT_BITMAP["0"]);
103
+ const width = digits.length * 5 * scale + (digits.length - 1) * scale;
104
+ const height = 7 * scale;
105
+ // Truecolor RGB scanlines, each prefixed with filter byte 0 (none).
106
+ const raw = Buffer.alloc(height * (1 + width * 3));
107
+ for (let y = 0; y < height; y++) {
108
+ const rowStart = y * (1 + width * 3);
109
+ raw[rowStart] = 0;
110
+ for (let x = 0; x < width; x++) {
111
+ // Which font cell does this pixel fall in?
112
+ const cell = Math.floor(x / (5 * scale + scale));
113
+ const inCellX = x - cell * (5 * scale + scale);
114
+ const cellDigit = digits[cell];
115
+ const row = Math.floor(y / scale);
116
+ const col = Math.floor(inCellX / scale);
117
+ const bit = inCellX < 5 * scale && cellDigit !== undefined
118
+ ? ((cellDigit[row] ?? "")[col] ?? "")
119
+ : "";
120
+ const on = bit === "1";
121
+ const p = rowStart + 1 + x * 3;
122
+ raw[p] = on ? 0 : 255;
123
+ raw[p + 1] = on ? 0 : 255;
124
+ raw[p + 2] = on ? 0 : 255;
125
+ }
126
+ }
127
+ const ihdr = Buffer.alloc(13);
128
+ ihdr.writeUInt32BE(width, 0);
129
+ ihdr.writeUInt32BE(height, 4);
130
+ ihdr[8] = 8; // bit depth
131
+ ihdr[9] = 2; // color type: truecolor RGB
132
+ const idat = zlib.deflateSync(raw, { level: 9 });
133
+ return Buffer.concat([
134
+ Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
135
+ pngChunk("IHDR", ihdr),
136
+ pngChunk("IDAT", idat),
137
+ pngChunk("IEND", Buffer.alloc(0)),
138
+ ]);
139
+ }
140
+ /** A random 4-digit code for one probe attempt. */
141
+ function randomCode() {
142
+ let out = "";
143
+ for (let i = 0; i < 4; i++)
144
+ out += String(randomInt(0, 10));
145
+ return out;
146
+ }
147
+ // ── Probe runner ──────────────────────────────────────────────────────────────────────────
148
+ /** Parse the resolved probe's stdout (pi's `--mode json` event stream) the same way a real run
149
+ * is parsed, so a live probe sees the same final text and error classification the harness
150
+ * would. */
151
+ function parseProbeOutput(stdout) {
152
+ const parser = new PiStreamParser();
153
+ parser.feed(stdout);
154
+ return {
155
+ finalText: parser.finalText,
156
+ configError: parser.configError,
157
+ transientRateLimit: parser.transientRateLimit,
158
+ transientBackend: parser.transientBackend,
159
+ };
160
+ }
161
+ /** Whether an answer looks like a (wrong) read of the 4-digit code rather than a refusal: the
162
+ * text contains a 4-digit run of digits (e.g. "The number is 5825."). A model that cannot see
163
+ * at all does not emit a number. */
164
+ function isFourDigitNumber(text) {
165
+ return /\b\d{4}\b/.test(text);
166
+ }
167
+ /** Probe one claimed pi seam with a real image (plans/image-input.md "Doctor"). Runs the
168
+ * resolved pi binary in a run's environment (the caller passes `env`, typically
169
+ * `childEnv({ PATH: pathEnv })` which inherits `PI_CODING_AGENT_DIR`, so the probe reads the
170
+ * same models.json a real tick would):
171
+ *
172
+ * `pi --print --mode json --no-session --no-tools --no-mcp --provider P --model M
173
+ * --thinking <level> @<png> "Reply with only the 4-digit number shown in the image."`
174
+ *
175
+ * Pass: the final assistant text contains the code. A wrong answer retries once with a fresh
176
+ * code (a misread is not blindness). Two wrong answers that are not both numbers fail (a
177
+ * refusal or empty answer is blindness), and so does a 4xx. Two wrong *numbers* — the model
178
+ * read a number, wrongly — cannot be told from a flaky misread, so it is `could-not-verify`
179
+ * rather than `fail` and is never cached: the next doctor run probes again instead of turning
180
+ * a capable seam text-only for a week. A timeout, spawn failure, 429, 5xx, or connection error
181
+ * is also `could-not-verify` — it says nothing about capability and is never cached. `code` is
182
+ * a test seam (a real probe always draws a fresh random code); `env` and `timeoutMs` are
183
+ * injectable so tests need no real pi. */
184
+ export async function runImageProbe(config, seam, env, timeoutMs = PROBE_TIMEOUT_MS, code) {
185
+ const envBin = env.TUMWATER_PI_BIN?.trim();
186
+ // Resolve from the run's own environment (env override → config), exactly as a real tick's
187
+ // spawn would rather than only the harness process env — so tests can point a fake pi there.
188
+ const bin = envBin
189
+ ? envBin.includes("/")
190
+ ? path.resolve(envBin)
191
+ : envBin
192
+ : resolveAgentBin(config).bin;
193
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "tumwater-image-probe-"));
194
+ const wrong = []; // the per-attempt "answered …" detail for a wrong answer
195
+ const misread = []; // whether each wrong answer was a (wrong) 4-digit number
196
+ try {
197
+ for (let attempt = 0; attempt < 2; attempt++) {
198
+ const thisCode = code ?? randomCode();
199
+ const png = makeProbePng(thisCode);
200
+ const pngPath = path.join(dir, `probe-${attempt}.png`);
201
+ fs.writeFileSync(pngPath, png);
202
+ const args = ["--print", "--mode", "json", "--no-session", "--no-tools", "--no-mcp"];
203
+ if (seam.provider)
204
+ args.push("--provider", seam.provider);
205
+ if (seam.model)
206
+ args.push("--model", seam.model);
207
+ if (seam.thinking)
208
+ args.push("--thinking", seam.thinking);
209
+ args.push(`@${pngPath}`, "Reply with only the 4-digit number shown in the image.");
210
+ const result = await boundedProbe(bin, args, env, timeoutMs, PROBE_MAX_OUTPUT_BYTES);
211
+ if (result === null)
212
+ return { verdict: "could-not-verify", detail: "the probe could not run or timed out" };
213
+ const parsed = parseProbeOutput(result.stdout);
214
+ if (parsed.configError)
215
+ return { verdict: "fail", detail: "the provider rejected the request (4xx)" };
216
+ if (parsed.transientRateLimit)
217
+ return { verdict: "could-not-verify", detail: "the provider rate-limited the request (429)" };
218
+ if (parsed.transientBackend)
219
+ return { verdict: "could-not-verify", detail: "the provider backend failed (5xx or connection)" };
220
+ const text = parsed.finalText.trim();
221
+ if (text.includes(thisCode))
222
+ return { verdict: "pass", detail: `answered "${text}"` };
223
+ // A wrong 4-digit number says "I looked and got the digits wrong"; a refusal or empty
224
+ // answer says "I cannot see". The drawn code is exactly 4 digits, so a 4-digit answer is
225
+ // a plausible (wrong) read.
226
+ misread.push(isFourDigitNumber(text));
227
+ wrong.push(text ? `answered "${text}"` : "answered nothing");
228
+ }
229
+ // A refusal or empty answer on either attempt is blindness — the two-strikes rule: fail,
230
+ // cached. Two wrong *numbers* cannot be told from a flaky misread, so it is could-not-verify
231
+ // and never cached: the next doctor run probes again instead of turning a capable seam
232
+ // text-only for a week. Either way the detail names what the model said, so the doctor
233
+ // line, the alert, and a bug report can tell blindness from a misread.
234
+ return misread[0] && misread[1]
235
+ ? {
236
+ verdict: "could-not-verify",
237
+ detail: `two misreads (${wrong.join(", ")}) — the model read a number, wrongly; not blindness, re-probe on the next doctor run`,
238
+ }
239
+ : { verdict: "fail", detail: `two wrong answers (${wrong[1]})` };
240
+ }
241
+ finally {
242
+ fs.rmSync(dir, { recursive: true, force: true });
243
+ }
244
+ }
@@ -0,0 +1,64 @@
1
+ import { spawn } from "node:child_process";
2
+ import { releaseChildHandles, signalTree } from "../process/process.js";
3
+ /** One bounded probe of a resolved binary: `null` when the spawn threw, the child errored,
4
+ * the deadline passed, or stdout overran `maxOutputBytes` — every case the caller must read as
5
+ * "could not check", never as a verdict. The child runs detached and at the deadline (or on
6
+ * overrun) its whole process group is SIGKILLed and the promise settles, because `'close'`
7
+ * waits for the stdio pipes and a grandchild holding them open would otherwise leave the
8
+ * promise pending past the deadline and hang the caller (the same trap git-run.ts's bounded
9
+ * spawn closes for git). stderr is ignored rather than piped, since nothing reads it and a
10
+ * full stderr pipe would block the child. `timeoutMs` and `maxOutputBytes` are test seams;
11
+ * production callers pass their own named constants. One home of the shape, shared by the
12
+ * model checks' credential/version probes, the tier-model probes, and the image-input probe —
13
+ * before the split each doctor module carried its own byte-for-byte copy. */
14
+ export function boundedProbe(bin, args, env, timeoutMs, maxOutputBytes) {
15
+ return new Promise((resolve) => {
16
+ let child;
17
+ try {
18
+ // Detached so the deadline's signalTree reaches a grandchild too; stderr is ignored
19
+ // rather than piped, since nothing reads it and a full stderr pipe would block the
20
+ // child until the deadline.
21
+ child = spawn(bin, args, {
22
+ cwd: process.cwd(),
23
+ detached: true,
24
+ stdio: ["ignore", "pipe", "ignore"],
25
+ env,
26
+ });
27
+ }
28
+ catch {
29
+ resolve(null);
30
+ return;
31
+ }
32
+ let out = "";
33
+ let bytes = 0;
34
+ let settled = false;
35
+ let timer;
36
+ const settle = (result) => {
37
+ if (settled)
38
+ return;
39
+ settled = true;
40
+ if (timer !== undefined)
41
+ clearTimeout(timer);
42
+ // Destroy the captured stdout and unref the process handle (releaseChildHandles): a
43
+ // grandchild that escaped the group and holds the pipe's write end would otherwise keep
44
+ // the process's event loop alive though the probe already has its answer.
45
+ releaseChildHandles(child);
46
+ resolve(result);
47
+ };
48
+ // Deadline or runaway output: kill the whole group, tear down the handles, and settle —
49
+ // do not wait for a `'close'` the group may never deliver.
50
+ const abort = () => {
51
+ signalTree(child, "SIGKILL");
52
+ settle(null);
53
+ };
54
+ timer = setTimeout(abort, timeoutMs);
55
+ child.stdout?.on("data", (chunk) => {
56
+ out += chunk.toString("utf8");
57
+ bytes += chunk.length;
58
+ if (bytes > maxOutputBytes)
59
+ abort();
60
+ });
61
+ child.on("error", () => settle(null));
62
+ child.on("close", (code) => settle({ code, stdout: out }));
63
+ });
64
+ }