tumwater 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (464) hide show
  1. package/README.md +27 -13
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-args.js +8 -9
  4. package/dist/src/agent/agent-backend.js +8 -6
  5. package/dist/src/agent/backend-traits.js +55 -25
  6. package/dist/src/agent/claude/claude-account.js +71 -204
  7. package/dist/src/agent/claude/claude-args.js +9 -6
  8. package/dist/src/agent/claude/claude-backend.js +10 -10
  9. package/dist/src/agent/claude/claude-env.js +1 -1
  10. package/dist/src/agent/claude/claude-stream.js +6 -4
  11. package/dist/src/agent/claude/claude-write-guard.js +49 -0
  12. package/dist/src/agent/claude/write-guard-hook.js +13 -0
  13. package/dist/src/agent/codex/codex-account.js +119 -0
  14. package/dist/src/agent/codex/codex-args.js +181 -0
  15. package/dist/src/agent/codex/codex-backend.js +279 -0
  16. package/dist/src/agent/codex/codex-catalog.js +97 -0
  17. package/dist/src/agent/codex/codex-env.js +18 -0
  18. package/dist/src/agent/codex/codex-hook-trust.js +246 -0
  19. package/dist/src/agent/codex/codex-patch-paths.js +36 -0
  20. package/dist/src/agent/codex/codex-rollout.js +179 -0
  21. package/dist/src/agent/codex/codex-stream.js +404 -0
  22. package/dist/src/agent/codex/codex-write-guard.js +86 -0
  23. package/dist/src/agent/codex/write-guard-hook.js +12 -0
  24. package/dist/src/agent/image-input.js +29 -24
  25. package/dist/src/agent/notes-shim.js +11 -12
  26. package/dist/src/agent/opencode/opencode-args.js +0 -1
  27. package/dist/src/agent/opencode/opencode-backend.js +12 -9
  28. package/dist/src/agent/opencode/opencode-catalog.js +55 -29
  29. package/dist/src/agent/opencode/opencode-config.js +19 -14
  30. package/dist/src/agent/opencode/opencode-env.js +0 -1
  31. package/dist/src/agent/opencode/opencode-session-id.js +1 -2
  32. package/dist/src/agent/opencode/opencode-stream.js +1 -1
  33. package/dist/src/agent/opencode/write-guard-plugin/index.js +48 -0
  34. package/dist/src/agent/role-notes-write.js +5 -6
  35. package/dist/src/agent/run-agent.js +465 -448
  36. package/dist/src/agent/run-settle.js +214 -0
  37. package/dist/src/agent/session-ledger.js +86 -16
  38. package/dist/src/agent/subscription-account.js +220 -0
  39. package/dist/src/agent/write-guard.js +148 -0
  40. package/dist/src/backlog/backlog-archive.js +281 -55
  41. package/dist/src/backlog/backlog-cli-view.js +255 -0
  42. package/dist/src/backlog/backlog-cli.js +91 -335
  43. package/dist/src/backlog/backlog-commit.js +20 -13
  44. package/dist/src/backlog/backlog-eligibility.js +193 -242
  45. package/dist/src/backlog/backlog-index.js +149 -0
  46. package/dist/src/backlog/backlog-md.js +54 -6
  47. package/dist/src/backlog/backlog-parts.js +264 -0
  48. package/dist/src/backlog/backlog-release.js +134 -0
  49. package/dist/src/backlog/backlog-render.js +27 -0
  50. package/dist/src/backlog/backlog-structure.js +99 -156
  51. package/dist/src/backlog/backlog-write.js +40 -127
  52. package/dist/src/backlog/backlog.js +21 -6
  53. package/dist/src/backlog/question-answer.js +21 -15
  54. package/dist/src/baseline/main-baseline.js +89 -20
  55. package/dist/src/baseline/main-guardrails.js +136 -0
  56. package/dist/src/baseline/main-red.js +153 -36
  57. package/dist/src/baseline/main-watch.js +232 -0
  58. package/dist/src/brief/brief-edit.js +12 -12
  59. package/dist/src/brief/brief.js +31 -51
  60. package/dist/src/budget/budget-fallback-view.js +288 -0
  61. package/dist/src/budget/budget-wiring.js +158 -0
  62. package/dist/src/budget/budget.js +31 -20
  63. package/dist/src/budget/fallback-breaker.js +26 -27
  64. package/dist/src/build/build-check-detect.js +23 -15
  65. package/dist/src/build/build-check-events.js +25 -6
  66. package/dist/src/build/build-check-report.js +49 -9
  67. package/dist/src/build/build-check-scoped.js +18 -11
  68. package/dist/src/build/build-check.js +2 -1
  69. package/dist/src/build/build-info.js +2 -3
  70. package/dist/src/build/build-stage.js +9 -14
  71. package/dist/src/build/dep-install.js +65 -3
  72. package/dist/src/change/change-data.js +123 -31
  73. package/dist/src/change/change-render.js +46 -5
  74. package/dist/src/cli/brief-command.js +3 -4
  75. package/dist/src/cli/cli-args.js +181 -33
  76. package/dist/src/cli/cli-command-args.js +46 -18
  77. package/dist/src/cli/cli-editor.js +2 -1
  78. package/dist/src/cli/cli-flag-specs.js +183 -45
  79. package/dist/src/cli/cli-init.js +51 -0
  80. package/dist/src/cli/cli-marker-commands.js +4 -4
  81. package/dist/src/cli/cli-query-commands.js +291 -598
  82. package/dist/src/cli/cli-repo-gate.js +11 -1
  83. package/dist/src/cli/cli-review-commands.js +113 -0
  84. package/dist/src/cli/cli-role-command.js +525 -0
  85. package/dist/src/cli/cli-start-summary.js +167 -0
  86. package/dist/src/cli/{cli-run.js → cli-start.js} +193 -237
  87. package/dist/src/cli/completion.js +169 -22
  88. package/dist/src/cli/config-commands.js +58 -26
  89. package/dist/src/cli/follow-view.js +75 -0
  90. package/dist/src/cli/help.js +258 -82
  91. package/dist/src/cli/log-commands.js +164 -85
  92. package/dist/src/cli/question-commands.js +211 -10
  93. package/dist/src/cli.js +173 -45
  94. package/dist/src/collections.js +56 -13
  95. package/dist/src/concurrency/check-permit.js +20 -3
  96. package/dist/src/concurrency/lock.js +17 -13
  97. package/dist/src/concurrency/provider-lease.js +44 -8
  98. package/dist/src/concurrency/semaphore.js +102 -26
  99. package/dist/src/config/config-aliases.js +0 -1
  100. package/dist/src/config/config-editable-keys.js +16 -1
  101. package/dist/src/config/config-fallbacks.js +94 -56
  102. package/dist/src/config/config-field-checks.js +40 -10
  103. package/dist/src/config/config-keys.js +30 -8
  104. package/dist/src/config/config-live.js +63 -24
  105. package/dist/src/config/config-request.js +137 -0
  106. package/dist/src/config/config-schema.js +31 -24
  107. package/dist/src/config/config-selector-checks.js +123 -0
  108. package/dist/src/config/config-validation-agents.js +80 -0
  109. package/dist/src/config/config-validation-model.js +116 -0
  110. package/dist/src/config/config-validation.js +169 -222
  111. package/dist/src/config/config-views.js +136 -54
  112. package/dist/src/config/config-write-core.js +135 -0
  113. package/dist/src/config/config-write.js +164 -265
  114. package/dist/src/config/config.js +53 -38
  115. package/dist/src/config/model-selector.js +34 -34
  116. package/dist/src/doctor/doctor-backlog-bugs.js +330 -0
  117. package/dist/src/doctor/doctor-backlog-plans.js +398 -0
  118. package/dist/src/doctor/doctor-backlog.js +249 -512
  119. package/dist/src/doctor/doctor-check-names.js +85 -0
  120. package/dist/src/doctor/doctor-checks.js +61 -593
  121. package/dist/src/doctor/doctor-claude-checks.js +142 -0
  122. package/dist/src/doctor/doctor-codex-checks.js +276 -0
  123. package/dist/src/doctor/doctor-contention-checks.js +506 -0
  124. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  125. package/dist/src/doctor/doctor-holds.js +80 -66
  126. package/dist/src/doctor/doctor-install.js +2 -1
  127. package/dist/src/doctor/doctor-model-checks.js +216 -537
  128. package/dist/src/doctor/doctor-notify.js +9 -5
  129. package/dist/src/doctor/doctor-opencode-checks.js +164 -0
  130. package/dist/src/doctor/doctor-orphans.js +182 -63
  131. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  132. package/dist/src/doctor/doctor-project-checks.js +397 -0
  133. package/dist/src/doctor/doctor.js +126 -77
  134. package/dist/src/doctor/image-probe.js +21 -8
  135. package/dist/src/doctor/outcome.js +3 -2
  136. package/dist/src/doctor/probe.js +21 -12
  137. package/dist/src/events/event-format.js +185 -89
  138. package/dist/src/events/event-labels.js +11 -5
  139. package/dist/src/events/event-legacy.js +39 -15
  140. package/dist/src/events/event-read.js +52 -39
  141. package/dist/src/events/event-tally.js +16 -0
  142. package/dist/src/events/event-window.js +80 -26
  143. package/dist/src/events/events.js +30 -13
  144. package/dist/src/events/notify.js +7 -7
  145. package/dist/src/failure/failure-cluster.js +12 -12
  146. package/dist/src/failure/failure-data.js +65 -60
  147. package/dist/src/failure/failure-render.js +29 -21
  148. package/dist/src/failure/failure-state-change.js +17 -12
  149. package/dist/src/failure/time-spend.js +35 -35
  150. package/dist/src/files/file-queue.js +17 -0
  151. package/dist/src/files/files.js +111 -35
  152. package/dist/src/files/json-files.js +3 -3
  153. package/dist/src/files/json-object.js +28 -7
  154. package/dist/src/files/stat-cache.js +2 -2
  155. package/dist/src/files/tail.js +1 -1
  156. package/dist/src/gates/bootstrap-gates.js +4 -5
  157. package/dist/src/gates/budget-gates.js +88 -225
  158. package/dist/src/gates/disk-gate.js +1 -1
  159. package/dist/src/gates/gate-polls.js +101 -143
  160. package/dist/src/gates/maintenance-quota.js +37 -30
  161. package/dist/src/gates/pause-gates.js +28 -10
  162. package/dist/src/gates/readiness.js +7 -3
  163. package/dist/src/gates/role-cap-gates.js +8 -8
  164. package/dist/src/gates/startup-gate.js +94 -32
  165. package/dist/src/gates/streak-gate.js +54 -30
  166. package/dist/src/git/commit-message.js +89 -46
  167. package/dist/src/git/git-diff.js +16 -10
  168. package/dist/src/git/git-run.js +79 -38
  169. package/dist/src/git/git.js +54 -34
  170. package/dist/src/git/slot-salvage.js +253 -0
  171. package/dist/src/git/slots-state.js +6 -6
  172. package/dist/src/git/worktree-pool.js +132 -38
  173. package/dist/src/git/worktree-use.js +4 -11
  174. package/dist/src/git/worktree.js +94 -37
  175. package/dist/src/gui/gui-admin-endpoints.js +96 -0
  176. package/dist/src/gui/gui-args.js +79 -8
  177. package/dist/src/gui/gui-backlog-commands.js +43 -19
  178. package/dist/src/gui/gui-backlog-endpoints.js +156 -0
  179. package/dist/src/gui/gui-command.js +26 -13
  180. package/dist/src/gui/gui-config-commands.js +168 -80
  181. package/dist/src/gui/gui-endpoint-commands.js +5 -4
  182. package/dist/src/gui/gui-endpoint-common.js +4 -4
  183. package/dist/src/gui/gui-endpoints.js +11 -380
  184. package/dist/src/gui/gui-history-endpoints.js +301 -0
  185. package/dist/src/gui/gui-operator-commands.js +235 -13
  186. package/dist/src/gui/gui-prompt-commands.js +26 -20
  187. package/dist/src/gui/gui-report-endpoints.js +97 -0
  188. package/dist/src/gui/gui-server.js +26 -5
  189. package/dist/src/gui/gui-team-commands.js +7 -7
  190. package/dist/src/gui/http-body.js +1 -1
  191. package/dist/src/history/history-data.js +122 -234
  192. package/dist/src/history/history-queued.js +155 -0
  193. package/dist/src/history/history-render.js +42 -22
  194. package/dist/src/history/history.js +190 -49
  195. package/dist/src/inbox/inbox-attachments.js +29 -30
  196. package/dist/src/inbox/inbox-cancel.js +10 -11
  197. package/dist/src/inbox/inbox-edit.js +9 -9
  198. package/dist/src/inbox/inbox-submit.js +18 -18
  199. package/dist/src/inbox/inbox-top.js +28 -17
  200. package/dist/src/inbox/inbox.js +42 -43
  201. package/dist/src/inbox/pending-prompt.js +26 -26
  202. package/dist/src/inbox/prompt-commands.js +62 -49
  203. package/dist/src/inbox/prompt-not-before.js +2 -2
  204. package/dist/src/init/init-templates.js +1 -2
  205. package/dist/src/init/init.js +28 -23
  206. package/dist/src/merge/backlog-conflicts.js +215 -35
  207. package/dist/src/merge/merge-batch.js +14 -8
  208. package/dist/src/merge/merge-cancel.js +53 -0
  209. package/dist/src/merge/merge-check-failures.js +44 -22
  210. package/dist/src/merge/merge-core.js +58 -32
  211. package/dist/src/merge/merge-diff.js +5 -4
  212. package/dist/src/merge/merge-drain.js +45 -19
  213. package/dist/src/merge/merge-git.js +177 -38
  214. package/dist/src/merge/merge-pipeline.js +15 -3
  215. package/dist/src/merge/merge-post-review.js +214 -0
  216. package/dist/src/merge/merge-queue-render.js +48 -11
  217. package/dist/src/merge/merge-queue.js +52 -15
  218. package/dist/src/merge/merge-requeue.js +103 -0
  219. package/dist/src/merge/merge-slot.js +61 -16
  220. package/dist/src/merge/merge-stack.js +27 -11
  221. package/dist/src/merge/merge-to-main.js +77 -26
  222. package/dist/src/merge/merge-top.js +83 -0
  223. package/dist/src/merge/merge-verify.js +75 -18
  224. package/dist/src/notes-cli.js +35 -15
  225. package/dist/src/operator/operator-commands.js +192 -81
  226. package/dist/src/operator/operator-intent.js +69 -42
  227. package/dist/src/operator/operator-requests.js +35 -40
  228. package/dist/src/operator/retire.js +5 -6
  229. package/dist/src/operator/revert.js +142 -34
  230. package/dist/src/orchestrator/orchestrator-launch.js +60 -52
  231. package/dist/src/orchestrator/orchestrator-poll.js +352 -0
  232. package/dist/src/orchestrator/orchestrator-scheduling.js +71 -370
  233. package/dist/src/orchestrator/orchestrator.js +129 -369
  234. package/dist/src/orchestrator/retention.js +42 -8
  235. package/dist/src/orchestrator/scheduling-admit.js +116 -0
  236. package/dist/src/orchestrator/scheduling-scope.js +245 -0
  237. package/dist/src/orchestrator/scheduling-skip.js +195 -0
  238. package/dist/src/orchestrator/startup-migration-steps.js +8 -7
  239. package/dist/src/orchestrator/startup-migrations.js +11 -7
  240. package/dist/src/paths.js +126 -49
  241. package/dist/src/pi/command-shape.js +12 -1
  242. package/dist/src/pi/pi-args.js +6 -4
  243. package/dist/src/pi/pi-backend.js +6 -6
  244. package/dist/src/pi/pi-bin.js +54 -63
  245. package/dist/src/pi/pi-listed-models.js +53 -0
  246. package/dist/src/pi/pi-models.js +63 -30
  247. package/dist/src/pi/pi-run-result.js +1 -1
  248. package/dist/src/pi/pi-settings.js +1 -1
  249. package/dist/src/pi/pi-stream.js +6 -6
  250. package/dist/src/pi/pi-watchdogs.js +18 -19
  251. package/dist/src/pi-extension/bounded-output.js +5 -3
  252. package/dist/src/pi-extension/context-budget.js +2 -2
  253. package/dist/src/pi-extension/context-shake.js +1 -1
  254. package/dist/src/pi-extension/context-window.js +0 -1
  255. package/dist/src/pi-extension/full-output.js +3 -10
  256. package/dist/src/pi-extension/strip-thinking.js +0 -1
  257. package/dist/src/pi-extension/trim-system-prompt.js +3 -4
  258. package/dist/src/pi-extension/write-guard.js +37 -0
  259. package/dist/src/process/launch-services.js +1 -2
  260. package/dist/src/process/node-bin.js +16 -0
  261. package/dist/src/process/process-group.js +8 -18
  262. package/dist/src/process/process.js +41 -2
  263. package/dist/src/process/run-marker.js +26 -6
  264. package/dist/src/process/supervisor.js +22 -21
  265. package/dist/src/prompt/dialect.js +17 -2
  266. package/dist/src/prompt/gate-notes.js +203 -0
  267. package/dist/src/prompt/gate-prompts.js +150 -217
  268. package/dist/src/prompt/notes-backlog.js +184 -0
  269. package/dist/src/prompt/principles.js +1 -2
  270. package/dist/src/prompt/prompt-followup.js +19 -14
  271. package/dist/src/prompt/prompt.js +131 -204
  272. package/dist/src/redeploy/redeploy-policy.js +24 -19
  273. package/dist/src/redeploy/redeploy-probes.js +1 -1
  274. package/dist/src/redeploy/redeploy.js +2 -3
  275. package/dist/src/redeploy/redeployer.js +20 -20
  276. package/dist/src/redeploy/self-reload.js +2 -1
  277. package/dist/src/report/report-data.js +188 -124
  278. package/dist/src/report/report-fold.js +130 -54
  279. package/dist/src/report/report-main-red.js +55 -0
  280. package/dist/src/report/report-render.js +136 -65
  281. package/dist/src/report/report-types.js +1 -0
  282. package/dist/src/report/report.js +23 -9
  283. package/dist/src/request-timeouts.js +2 -2
  284. package/dist/src/review/diff-rules.js +53 -0
  285. package/dist/src/review/known-flakes.js +112 -1
  286. package/dist/src/review/post-merge-review-accept.js +51 -0
  287. package/dist/src/review/post-merge-review-render.js +112 -0
  288. package/dist/src/review/post-merge-review.js +239 -0
  289. package/dist/src/review/protected-edits.js +24 -10
  290. package/dist/src/review/quarantine-render.js +65 -0
  291. package/dist/src/review/review-followup.js +8 -8
  292. package/dist/src/review/review-precheck.js +66 -23
  293. package/dist/src/review/review-run.js +395 -0
  294. package/dist/src/review/review-verdict.js +2 -2
  295. package/dist/src/review/review.js +180 -401
  296. package/dist/src/review/secret-leaks.js +36 -16
  297. package/dist/src/review/suite-rerun.js +0 -1
  298. package/dist/src/roles/legacy-role-migration.js +80 -7
  299. package/dist/src/roles/role-catalog.js +127 -12
  300. package/dist/src/roles/role-guidance.js +65 -20
  301. package/dist/src/roles/role-labels.js +7 -5
  302. package/dist/src/roles/role-render.js +108 -56
  303. package/dist/src/roles/role-view.js +95 -56
  304. package/dist/src/roles/roles.js +33 -33
  305. package/dist/src/roles/runner-ids.js +97 -0
  306. package/dist/src/run/acceptance-greps.js +119 -0
  307. package/dist/src/run/ci-parity.js +121 -0
  308. package/dist/src/run/feedback-filings.js +51 -0
  309. package/dist/src/run/qa-coverage.js +125 -0
  310. package/dist/src/{tick/tick-apply.js → run/run-apply.js} +95 -94
  311. package/dist/src/run/run-detail-data.js +175 -0
  312. package/dist/src/run/run-detail-render.js +91 -0
  313. package/dist/src/run/run-detail.js +168 -0
  314. package/dist/src/{tick/tick-finalize.js → run/run-finalize.js} +73 -60
  315. package/dist/src/{tick/tick-outcome.js → run/run-outcome.js} +6 -7
  316. package/dist/src/{tick/tick-prompt.js → run/run-prompt.js} +122 -73
  317. package/dist/src/{tick/tick-resume.js → run/run-resume.js} +55 -26
  318. package/dist/src/{tick/tick-stage.js → run/run-stage.js} +170 -83
  319. package/dist/src/{tick/tick-timing.js → run/run-timing.js} +43 -41
  320. package/dist/src/{tick/tick-usage.js → run/run-usage.js} +45 -37
  321. package/dist/src/{tick/tick-verdict.js → run/run-verdict.js} +105 -57
  322. package/dist/src/run/selfcheck-digest.js +40 -0
  323. package/dist/src/run/stage-backlog-checks.js +249 -0
  324. package/dist/src/run/stage-check.js +178 -0
  325. package/dist/src/run/stage-checks-shared.js +65 -0
  326. package/dist/src/run/stage-name-checks.js +204 -0
  327. package/dist/src/run/stage-path-checks.js +231 -0
  328. package/dist/src/run/stage-prose-checks.js +261 -0
  329. package/dist/src/runner/leftover.js +216 -0
  330. package/dist/src/{loop → runner}/model-fallback.js +12 -12
  331. package/dist/src/runner/revision.js +191 -0
  332. package/dist/src/{loop/loop-fallback.js → runner/runner-fallback.js} +8 -8
  333. package/dist/src/runner/runner-outcome.js +109 -0
  334. package/dist/src/{loop/loop-pi.js → runner/runner-pi.js} +140 -131
  335. package/dist/src/{loop/loop-recovery.js → runner/runner-recovery.js} +39 -18
  336. package/dist/src/{loop/loop-tick.js → runner/runner-run.js} +236 -245
  337. package/dist/src/{loop/loop-state.js → runner/runner-state-load.js} +61 -114
  338. package/dist/src/runner/runner-state.js +103 -0
  339. package/dist/src/{loop/loop.js → runner/runner.js} +123 -120
  340. package/dist/src/scheduling/backoff.js +38 -38
  341. package/dist/src/scheduling/claim-entries.js +49 -0
  342. package/dist/src/scheduling/claims.js +35 -22
  343. package/dist/src/scheduling/once-round.js +22 -22
  344. package/dist/src/scheduling/quiet-hours.js +5 -6
  345. package/dist/src/scheduling/scheduling.js +74 -64
  346. package/dist/src/scheduling/work-merged-cache.js +2 -2
  347. package/dist/src/status/status-budget.js +55 -47
  348. package/dist/src/status/status-data.js +49 -17
  349. package/dist/src/status/status-inbox.js +2 -2
  350. package/dist/src/status/status-install.js +0 -1
  351. package/dist/src/status/status-polls.js +43 -28
  352. package/dist/src/status/status-roles.js +50 -13
  353. package/dist/src/team/error-storm.js +8 -8
  354. package/dist/src/team/failure-spread.js +2 -2
  355. package/dist/src/team/orchestrator-info.js +15 -9
  356. package/dist/src/team/reclaim.js +7 -8
  357. package/dist/src/team/team-hold.js +7 -7
  358. package/dist/src/team/team-polls.js +91 -50
  359. package/dist/src/team/team-state.js +8 -2
  360. package/dist/src/text/datetime.js +65 -18
  361. package/dist/src/text/format.js +48 -11
  362. package/dist/src/text/markdown.js +8 -5
  363. package/dist/src/text/phrases.js +60 -23
  364. package/dist/src/text/suggest.js +1 -1
  365. package/dist/src/text/text.js +78 -13
  366. package/dist/src/ui/badges.js +99 -38
  367. package/dist/src/ui/gui/gui-client-backlog.js +314 -0
  368. package/dist/src/ui/gui/gui-client-boot.js +89 -61
  369. package/dist/src/ui/gui/gui-client-composer.js +8 -9
  370. package/dist/src/ui/gui/gui-client-drawer-config.js +254 -0
  371. package/dist/src/ui/gui/gui-client-drawer.js +155 -289
  372. package/dist/src/ui/gui/gui-client-feed.js +271 -0
  373. package/dist/src/ui/gui/gui-client-history.js +437 -68
  374. package/dist/src/ui/gui/gui-client-labels.js +6 -3
  375. package/dist/src/ui/gui/gui-client-markdown.js +1 -2
  376. package/dist/src/ui/gui/gui-client-model.js +27 -36
  377. package/dist/src/ui/gui/gui-client-operator.js +60 -52
  378. package/dist/src/ui/gui/gui-client-pending.js +405 -24
  379. package/dist/src/ui/gui/gui-client-report-chart.js +492 -0
  380. package/dist/src/ui/gui/gui-client-report.js +105 -430
  381. package/dist/src/ui/gui/{gui-client-loops.js → gui-client-roles.js} +43 -44
  382. package/dist/src/ui/gui/gui-client-settings.js +163 -5
  383. package/dist/src/ui/gui/gui-client-team.js +36 -465
  384. package/dist/src/ui/gui/gui-client.js +38 -26
  385. package/dist/src/ui/gui/gui-icons.js +0 -2
  386. package/dist/src/ui/gui/gui-page.js +10 -7
  387. package/dist/src/ui/gui/gui-styles.js +38 -34
  388. package/dist/src/ui/progress-data.js +9 -11
  389. package/dist/src/ui/{tick-progress-model.js → run-progress-model.js} +33 -32
  390. package/dist/src/ui/status-model.js +133 -125
  391. package/dist/src/ui/status-payload.js +364 -49
  392. package/dist/src/ui/status-render.js +109 -82
  393. package/dist/src/ui/team-alerts.js +728 -62
  394. package/dist/src/ui/tone.js +12 -6
  395. package/dist/src/ui/transcript-run.js +58 -0
  396. package/dist/src/ui/transcript-tail.js +28 -20
  397. package/dist/src/ui/transcript.js +63 -23
  398. package/dist/src/ui/tui/tui-app.js +0 -1
  399. package/dist/src/ui/tui/tui-backlog.js +53 -14
  400. package/dist/src/ui/tui/tui-editor-answer.js +27 -31
  401. package/dist/src/ui/tui/tui-editor-attach.js +4 -12
  402. package/dist/src/ui/tui/tui-editor-backends.js +127 -0
  403. package/dist/src/ui/tui/tui-editor-brief.js +14 -36
  404. package/dist/src/ui/tui/tui-editor-budget.js +36 -39
  405. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  406. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  407. package/dist/src/ui/tui/tui-editor-claude-reserve.js +24 -0
  408. package/dist/src/ui/tui/tui-editor-concurrency.js +25 -0
  409. package/dist/src/ui/tui/tui-editor-config.js +99 -0
  410. package/dist/src/ui/tui/tui-editor-entry.js +39 -64
  411. package/dist/src/ui/tui/tui-editor-fallback.js +31 -0
  412. package/dist/src/ui/tui/tui-editor-instances.js +46 -0
  413. package/dist/src/ui/tui/tui-editor-instructions.js +117 -0
  414. package/dist/src/ui/tui/tui-editor-interval.js +29 -16
  415. package/dist/src/ui/tui/tui-editor-model.js +29 -0
  416. package/dist/src/ui/tui/tui-editor-notes.js +9 -29
  417. package/dist/src/ui/tui/tui-editor-notify.js +25 -0
  418. package/dist/src/ui/tui/tui-editor-pause.js +48 -59
  419. package/dist/src/ui/tui/tui-editor-prompt.js +11 -16
  420. package/dist/src/ui/tui/tui-editor-provider-caps.js +27 -0
  421. package/dist/src/ui/tui/tui-editor-queued.js +5 -9
  422. package/dist/src/ui/tui/tui-editor-quiet.js +95 -41
  423. package/dist/src/ui/tui/tui-editor-retire.js +5 -13
  424. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  425. package/dist/src/ui/tui/tui-editor-role-key.js +71 -0
  426. package/dist/src/ui/tui/tui-editor-role-model.js +28 -0
  427. package/dist/src/ui/tui/tui-editor-search.js +45 -33
  428. package/dist/src/ui/tui/tui-editor-session.js +101 -34
  429. package/dist/src/ui/tui/tui-editor-slots.js +24 -0
  430. package/dist/src/ui/tui/tui-editors.js +44 -2
  431. package/dist/src/ui/tui/tui-frame.js +68 -11
  432. package/dist/src/ui/tui/tui-history.js +8 -9
  433. package/dist/src/ui/tui/tui-input.js +13 -11
  434. package/dist/src/ui/tui/tui-keymap.js +0 -1
  435. package/dist/src/ui/tui/tui-keys.js +33 -9
  436. package/dist/src/ui/tui/tui-pane-activity.js +35 -19
  437. package/dist/src/ui/tui/tui-pane-backlog.js +198 -126
  438. package/dist/src/ui/tui/tui-pane-brief.js +0 -1
  439. package/dist/src/ui/tui/tui-pane-ctx.js +2 -2
  440. package/dist/src/ui/tui/tui-pane-health.js +168 -8
  441. package/dist/src/ui/tui/tui-pane-history.js +446 -82
  442. package/dist/src/ui/tui/tui-pane-pending.js +539 -15
  443. package/dist/src/ui/tui/tui-pane-queued.js +78 -57
  444. package/dist/src/ui/tui/tui-pane-report.js +54 -16
  445. package/dist/src/ui/tui/tui-pane-session.js +34 -6
  446. package/dist/src/ui/tui/tui-pane-transcript.js +111 -37
  447. package/dist/src/ui/tui/tui-pane.js +91 -28
  448. package/dist/src/ui/tui/tui-panes.js +153 -34
  449. package/dist/src/ui/tui/tui.js +18 -15
  450. package/dist/src/verdict/fix-claim.js +129 -33
  451. package/dist/src/verdict/no-change.js +4 -4
  452. package/dist/src/verdict/refusal.js +40 -16
  453. package/dist/src/verdict/reply-contract.js +77 -31
  454. package/dist/src/version.js +0 -1
  455. package/package.json +2 -1
  456. package/dist/src/loop/leftover.js +0 -130
  457. package/dist/src/loop/revision.js +0 -70
  458. package/dist/src/roles/loop-ids.js +0 -77
  459. package/dist/src/tick/qa-coverage.js +0 -108
  460. package/dist/src/tick/stage-check.js +0 -419
  461. package/dist/src/tick/telemetry-digest.js +0 -25
  462. package/dist/src/tick/tick-detail-data.js +0 -108
  463. package/dist/src/tick/tick-detail.js +0 -163
  464. package/dist/src/ui/gui/gui-client-sound.js +0 -53
@@ -0,0 +1,86 @@
1
+ /** codex's half of the write guard (src/agent/write-guard.ts holds the rule): a PreToolUse hook on
2
+ * `apply_patch`, installed by part 6/9, refuses an `apply_patch` that writes under the team root
3
+ * but outside the run's worktree. The entry point is write-guard-hook.ts beside this file.
4
+ *
5
+ * Codex's hook takes no argument naming the worktree, unlike claude's: codex trusts a hook by a
6
+ * hash of its whole definition, so a command constant per build needs one trust lookup, while a
7
+ * per-worktree argument would need one per slot (plans/codex-write-guard.md finding 3). The run's
8
+ * directory is the payload's `cwd`, which is codex's session cwd and cannot be moved by a shell
9
+ * `cd`, since every shell call is its own `zsh -lc`; with no `cwd` the hook returns null and lets
10
+ * the patch run. */
11
+ import { fileURLToPath } from "node:url";
12
+ import { shellQuote, strayWriteRefusal } from "../write-guard.js";
13
+ import { patchWritePaths } from "./codex-patch-paths.js";
14
+ /** The tool a codex PreToolUse hook sees for file edits; anything else is not this hook's
15
+ * business. */
16
+ const APPLY_PATCH = "apply_patch";
17
+ /** The refusal for one codex PreToolUse payload (the hook's stdin, parsed), or null to let the
18
+ * patch run. It acts only when `tool_name` is `"apply_patch"` and `tool_input.command` is a
19
+ * string; each path the patch writes is refused with `strayWriteRefusal(path, cwd)` in order if it
20
+ * is under the team root but outside the worktree, a relative path resolving from `cwd` as
21
+ * codex resolves it. A payload naming no `cwd`, or too broken to read, returns null and the patch
22
+ * runs. */
23
+ export function codexWriteRefusal(payload) {
24
+ if (typeof payload !== "object" || payload === null)
25
+ return null;
26
+ const { tool_name: tool, tool_input: input, cwd } = payload;
27
+ if (tool !== APPLY_PATCH)
28
+ return null;
29
+ if (typeof cwd !== "string" || cwd === "")
30
+ return null;
31
+ if (typeof input !== "object" || input === null)
32
+ return null;
33
+ const command = input.command;
34
+ if (typeof command !== "string")
35
+ return null;
36
+ for (const target of patchWritePaths(command)) {
37
+ const refusal = strayWriteRefusal(target, cwd);
38
+ if (refusal !== null)
39
+ return refusal;
40
+ }
41
+ return null;
42
+ }
43
+ /** Seconds codex waits for the hook before running the tool anyway (the same timeout claude's
44
+ * hook gets). */
45
+ const HOOK_TIMEOUT_SECONDS = 30;
46
+ /** The matcher the PreToolUse hook is registered with; codex sends only `apply_patch` here. */
47
+ const HOOK_MATCHER = "^apply_patch$";
48
+ /** `s` as a TOML basic string, escaping `\`, `"` and every control character (U+0000–U+001F
49
+ * and DEL), so a nodeBin or hook path with any of them still round-trips through a `-c` value. */
50
+ export function tomlString(s) {
51
+ let out = '"';
52
+ for (const ch of s) {
53
+ const code = ch.codePointAt(0);
54
+ if (ch === "\\")
55
+ out += "\\\\";
56
+ else if (ch === '"')
57
+ out += '\\"';
58
+ else if (code < 0x20 || code === 0x7f)
59
+ out += `\\u${code.toString(16).padStart(4, "0")}`;
60
+ else
61
+ out += ch;
62
+ }
63
+ return out + '"';
64
+ }
65
+ /** The codex write guard's definition: the PreToolUse hook on `apply_patch` that runs the
66
+ * compiled write-guard-hook.js with this process's node, falling back to `node` on PATH when
67
+ * that binary has moved (as claude's and the notes shim do). The hook path resolves from this
68
+ * module, so a staged build (.tumwater/build/<sha>) runs its own copy and gets its own hash. */
69
+ export function codexWriteGuardDefinition(nodeBin = process.execPath) {
70
+ const hook = fileURLToPath(new URL("./write-guard-hook.js", import.meta.url));
71
+ const command = `n=${shellQuote(nodeBin)}; [ -x "$n" ] || n=node; exec "$n" ${shellQuote(hook)}`;
72
+ return { matcher: HOOK_MATCHER, command, timeout: HOOK_TIMEOUT_SECONDS };
73
+ }
74
+ /** The `-c` value that installs `definition` as the session's PreToolUse hook, in codex's
75
+ * `hooks.PreToolUse=[{matcher=…,hooks=[{type="command",command=…,timeout=…}]}]` inline-table
76
+ * shape (finding 1). */
77
+ export function codexHookArg(definition) {
78
+ return `hooks.PreToolUse=[{matcher=${tomlString(definition.matcher)},hooks=[{type="command",command=${tomlString(definition.command)},timeout=${definition.timeout}}]}]`;
79
+ }
80
+ /** The `-c` value that trusts the hook identified by `trust.key` at `trust.hash`, in the
81
+ * inline-table spelling `hooks.state={<key>={trusted_hash=<hash>}}`. The dotted
82
+ * `hooks.state."<key>".trusted_hash=` spelling is ignored by codex because the key contains
83
+ * dots (finding 2). */
84
+ export function codexHookTrustArg(trust) {
85
+ return `hooks.state={${tomlString(trust.key)}={trusted_hash=${tomlString(trust.hash)}}}`;
86
+ }
@@ -0,0 +1,12 @@
1
+ /** The PreToolUse hook codex runs before each `apply_patch` in a team run (part 6/9 installs it
2
+ * through `-c`): `node write-guard-hook.js` with codex's hook payload on stdin. To block the patch
3
+ * it prints a `deny` decision with the refusal as its reason, in the shape finding 5 showed codex
4
+ * accepts: the model reads only the reason, so a denial by exit code would not carry it. It prints
5
+ * nothing to let the patch run, and exits 0 either way. Any failure of its own (bad JSON, a throw)
6
+ * prints nothing, so a broken hook lets the patch run and the stray-write warning in runner-run.ts
7
+ * still sees a stray write. The stdin read and the deny-decision printing are runGuardHook's
8
+ * (src/agent/write-guard.ts), shared with the claude PreToolUse hook; only the refusal computation
9
+ * differs. */
10
+ import { runGuardHook } from "../write-guard.js";
11
+ import { codexWriteRefusal } from "./codex-write-guard.js";
12
+ runGuardHook((payload) => codexWriteRefusal(payload));
@@ -9,34 +9,37 @@ import { BORROW_ORDER, resolveTierFallbacks } from "../config/config-fallbacks.j
9
9
  import { seamAgent } from "../config/model-selector.js";
10
10
  import { modelLabel } from "./backend-traits.js";
11
11
  import { piModelImageInput, piPairFree, piModelsPath, readPiProviders } from "../pi/pi-models.js";
12
- import { agentBackend } from "./agent-backend.js";
12
+ import { piListedImageInput } from "../pi/pi-listed-models.js";
13
+ import { backendById } from "./agent-backend.js";
13
14
  import { imageProbeEntryFresh, imageProbeKey } from "../doctor/image-probe.js";
14
15
  /** One seam's image-input capability (plans/image-input.md "Capability"): `true` when the
15
16
  * seam's backend declares image support, `false` when it is text-only, `null` when it cannot
16
- * be answered from what the team knows (an unknown pair, a backend with no hook and no
17
- * catalog entry — the doctor's `tier models` check already fails such pairs).
17
+ * be answered from what the team knows (a pair neither models.json nor the pi listing names, a
18
+ * backend with no hook and no catalog entry).
18
19
  *
19
20
  * The backend bookends: a backend with an `imageInput` hook (claude → true) owns the answer;
20
21
  * the default pi backend leaves it to models.json's `input` field, where absent means text-only
21
- * (pi's own default for a custom model). `probes` is the doctor's live-probe cache (part 2/5): a
22
- * claimed pi seam whose probe FAILED reads false — a claim is only as good as the verified
22
+ * (pi's own default for a custom model); a pair absent from models.json answers what
23
+ * `pi --list-models` last said (src/pi/pi-listed-models.ts), else null.
24
+ * `probes` is the doctor's live-probe cache (part 2/5): a claimed pi seam whose probe FAILED
25
+ * reads false — a claim is only as good as the verified
23
26
  * answer — while a cached pass (or an unprobed claim) stands on the catalog. claude seams are
24
27
  * never probed, so the hook answer is never overridden. */
25
- export function seamImageInput(seam, providers, probes) {
26
- const agent = seamAgent(seam);
27
- const backend = agentBackend(agent);
28
+ export function seamImageInput(seam, providers, probes, listed = null) {
29
+ const backendId = seamAgent(seam);
30
+ const backend = backendById(backendId);
28
31
  // A backend with an imageInput hook owns the answer outright (claude: true for every model).
29
32
  if (backend?.imageInput !== undefined)
30
33
  return backend.imageInput(seam.model);
31
34
  // An unknown backend without a hook has no catalog to consult — unknown, never assume.
32
- if (agent !== "pi")
35
+ if (backendId !== "pi")
33
36
  return null;
34
- const base = piModelImageInput(providers, seam.provider, seam.model);
37
+ const base = piModelImageInput(providers, seam.provider, seam.model) ?? piListedImageInput(listed, seam.provider, seam.model);
35
38
  if (base !== true)
36
39
  return base;
37
40
  // A claimed pi seam whose live probe failed (fresh, claim unchanged) reads false. Only pi is
38
- // probed, so the probe key carries the pi agent.
39
- const entry = probes?.[imageProbeKey(agent, seam.provider, seam.model)];
41
+ // probed, so the probe key carries the pi backend.
42
+ const entry = probes?.[imageProbeKey(backendId, seam.provider, seam.model)];
40
43
  if (entry?.verdict === "fail" && imageProbeEntryFresh(entry))
41
44
  return false;
42
45
  return true;
@@ -48,13 +51,13 @@ export function seamImageInput(seam, providers, probes) {
48
51
  * and takes the first tier whose model reads images. `regime` picks the pair set (see
49
52
  * RerouteRegime); `modelsPath` and `account` are injectable so tests need no real pi install
50
53
  * or account. */
51
- export function imageReroute(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null) {
54
+ export function imageReroute(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null, listed = null) {
52
55
  const tier = roleSeamTier(config, role);
53
56
  const candidates = BORROW_ORDER[tier].filter((t) => t !== tier);
54
57
  if (candidates.length === 0)
55
58
  return null;
56
59
  const providerMap = providers ?? readPiProviders(modelsPath);
57
- const capable = (rc) => seamImageInput(rc, providerMap, probes) === true;
60
+ const capable = (rc) => seamImageInput(rc, providerMap, probes, listed) === true;
58
61
  if (regime === "primary") {
59
62
  for (const t of candidates) {
60
63
  const rc = withModelOverrides(config, t, {});
@@ -65,7 +68,9 @@ export function imageReroute(config, role, regime = "primary", modelsPath = piMo
65
68
  }
66
69
  // Fallback regime: only the tiers' free fallback pairs qualify (resolveTierFallbacks over the
67
70
  // price check), never a priced primary model — a reroute must not spend past the cap.
68
- const resolved = resolveTierFallbacks(config, (p) => piPairFree(providerMap, p.provider, p.model, account));
71
+ const resolved = resolveTierFallbacks(config, (p) =>
72
+ // The free-ness lookup reads the one claude account held here (part 2a/9).
73
+ piPairFree(providerMap, p, (b) => (b === "claude" ? account : null)));
69
74
  for (const t of candidates) {
70
75
  const pair = resolved[t].pair;
71
76
  if (!pair)
@@ -81,25 +86,25 @@ export function imageReroute(config, role, regime = "primary", modelsPath = piMo
81
86
  return null;
82
87
  }
83
88
  /** The config of imageReroute, for callers that want only the target model. */
84
- export function imageRerouteTarget(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null) {
85
- return imageReroute(config, role, regime, modelsPath, account, probes, providers)?.config ?? null;
89
+ export function imageRerouteTarget(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, probes, providers = null, listed = null) {
90
+ return imageReroute(config, role, regime, modelsPath, account, probes, providers, listed)?.config ?? null;
86
91
  }
87
92
  /** A seam's model as one readable token, the same label the doctor's `image input` check uses
88
93
  * (`claude/<id>` for a claude seam, `provider/model` for pi). The one home of the label, so
89
94
  * the surface strings (the images alert, the role page, the status rows) and the doctor report
90
95
  * cannot drift, and delegates to backend-traits' modelLabel. */
91
- export function seamImageLabel(agent, provider, model) {
92
- return modelLabel(agent, provider, model);
96
+ export function seamImageLabel(backend, provider, model) {
97
+ return modelLabel(backend, provider, model);
93
98
  }
94
- /** Compute RoleImageInfo for one loop. `modelsPath` and `probes` are injectable; the providers
99
+ /** Compute RoleImageInfo for one role. `modelsPath` and `probes` are injectable; the providers
95
100
  * catalog is read (stat-cached) here and also handed to imageReroute so a poll that asks many
96
- * loops still parses the catalog once. All reads mirror the doctor's `image input` check, so
101
+ * roles still parses the catalog once. All reads mirror the doctor's `image input` check, so
97
102
  * the surface and the check agree about capability. */
98
- export function roleImageInfo(config, role, modelsPath = piModelsPath(), probes) {
103
+ export function roleImageInfo(config, role, modelsPath = piModelsPath(), probes, listed = null) {
99
104
  const providers = readPiProviders(modelsPath);
100
105
  const rc = configForRole(config, role);
101
- const imageInput = seamImageInput(rc, providers, probes);
102
- const rr = imageReroute(config, role, "primary", modelsPath, null, probes, providers);
106
+ const imageInput = seamImageInput(rc, providers, probes, listed);
107
+ const rr = imageReroute(config, role, "primary", modelsPath, null, probes, providers, listed);
103
108
  const imageRerouteStr = rr
104
109
  ? `${rr.tier} (${seamImageLabel(rr.config.agent, rr.config.provider, rr.config.model)})`
105
110
  : null;
@@ -1,8 +1,7 @@
1
- // vocabulary: plain
2
1
  /** Installing the per-team `tumwater-notes` shim every backend's run finds on its PATH
3
2
  * (plans/notes-command.md "Reaching it from a run"): run-agent calls `ensureNotesShim(root)`
4
3
  * before building each invocation, and the backend prepends the returned dir to the run's
5
- * PATH (agentBaseEnv's `binDir`, src/process/run-marker.ts). The shim is a two-line `sh`
4
+ * PATH (backendBaseEnv's `binDir`, src/process/run-marker.ts). The shim is a two-line `sh`
6
5
  * script that execs the compiled notes CLI, so a model's shell — pi's, Claude Code's or
7
6
  * OpenCode's — runs the same command on every backend.
8
7
  *
@@ -14,7 +13,7 @@
14
13
  import fs from "node:fs";
15
14
  import path from "node:path";
16
15
  import { distDir } from "../build/build-info.js";
17
- import { writeTextAtomic } from "../files/files.js";
16
+ import { readTextOrNull, writeTextAtomic } from "../files/files.js";
18
17
  import { notesShimDir } from "../paths.js";
19
18
  /** The shim's file name — the command name every run sees on its PATH. */
20
19
  export const NOTES_SHIM_NAME = "tumwater-notes";
@@ -22,17 +21,17 @@ export const NOTES_SHIM_NAME = "tumwater-notes";
22
21
  * The shim is written atomically with mode 0755, only when its content differs from what is
23
22
  * already there (a redeploy that changes `<dist>` rewrites it). Never throws: a failure to
24
23
  * write is the caller's to absorb — run-agent logs one warning and runs without the shim. */
25
- export function ensureNotesShim(root) {
24
+ export function ensureNotesShim(root, execPath = process.execPath) {
26
25
  const dir = notesShimDir(root);
27
26
  const target = path.join(dir, NOTES_SHIM_NAME);
28
- const content = `#!/bin/sh\nexec "${process.execPath}" "${path.join(distDir(), "src", "notes-cli.js")}" "$@"\n`;
29
- let needsWrite = true;
30
- try {
31
- needsWrite = fs.readFileSync(target, "utf8") !== content;
32
- }
33
- catch {
34
- // Absent or unreadable — write it.
35
- }
27
+ // Prefer the recorded node binary, falling back to `node` on PATH when it is gone — a
28
+ // Homebrew node upgrade under the running team deletes the old keg while the team keeps
29
+ // running on it, so `process.execPath` names a file that no longer exists (BUGS.md
30
+ // 2026-10-21). The `[ -x "$n" ]` check answers at run time, so a shim written long ago
31
+ // still survives an upgrade, and the running orchestrator's repeated rewrites stop
32
+ // restoring the broken path.
33
+ const content = `#!/bin/sh\nn="${execPath}"; [ -x "$n" ] || n=node; exec "$n" "${path.join(distDir(), "src", "notes-cli.js")}" "$@"\n`;
34
+ const needsWrite = readTextOrNull(target) !== content; // absent or unreadable — write it
36
35
  if (needsWrite) {
37
36
  writeTextAtomic(target, content);
38
37
  }
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** Building one OpenCode run's command line (plans/opencode-backend.md "The invocation") —
3
2
  * pure argv construction plus the prompt-on-stdin pairing, with no subprocess, backend
4
3
  * registration, or session database. Part 5/7 wires this into the adapter; this module turns
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** The opencode adapter for the agent seam (plans/opencode-backend.md "The invocation",
3
2
  * "Sessions", §6). It shapes one run's argv/stdin/env, translates opencode's `run --format
4
3
  * json` into the canonical stream, runs the authoritative session export after the process
@@ -15,16 +14,17 @@
15
14
  * fresh id (design §4).
16
15
  *
17
16
  * Registration lives in src/agent/run-agent.ts beside the pi and claude adapters; a test selects
18
- * opencode by setting `config.agent = "opencode"` on a run. A run carrying a notebook gets the
17
+ * opencode by setting `config.agent = "opencode"` on a run. A run carrying a note gets the
19
18
  * notes path in its environment (the `tumwater-notes` command on PATH reads it), as the pi and
20
19
  * claude adapters do. */
21
20
  import { spawnSync } from "node:child_process";
22
21
  import os from "node:os";
23
22
  import path from "node:path";
23
+ import { ensureParentDir } from "../../files/files.js";
24
24
  import { loadConfigSafe } from "../../config/config.js";
25
- import { resolvedAgentConfig } from "../../config/config-views.js";
26
- import { agentBaseEnv, withNotesPath } from "../../process/run-marker.js";
27
- import { resolveBackendBin } from "../../pi/pi-bin.js";
25
+ import { resolvedBackendConfig } from "../../config/config-views.js";
26
+ import { backendRunEnv } from "../../process/run-marker.js";
27
+ import { resolveBinForBackend } from "../../pi/pi-bin.js";
28
28
  import { opencodeDbPath, teamRootOf } from "../../paths.js";
29
29
  import { resumableSession } from "../session-ledger.js";
30
30
  import { opencodeArgs } from "./opencode-args.js";
@@ -164,13 +164,15 @@ export const opencodeBackend = {
164
164
  defaultBin: "opencode",
165
165
  binEnv: "TUMWATER_OPENCODE_BIN",
166
166
  resolveBin(config) {
167
- return resolveBackendBin(config, this);
167
+ return resolveBinForBackend(config, this);
168
168
  },
169
169
  invocation(run) {
170
170
  // The shared database and injected config are per-root, built the same way for every run of
171
171
  // this team so a resume's export check reads the same OPENCODE_DB the original run wrote.
172
172
  const root = teamRootOf(run.sessionDir);
173
173
  const dbPath = opencodeDbPath(root);
174
+ // opencode will not create the database's parent directory and its server dies without it.
175
+ ensureParentDir(dbPath);
174
176
  // Window-scaled tool-output caps (part 6f/7): OpenCode's `tool_output` default is about
175
177
  // three times pi's tuned budgets, so every turn would re-send far more tool output than a
176
178
  // pi run — critical on the 200K free model's window. Cap it at pi's `outputLimits(window)
@@ -184,10 +186,10 @@ export const opencodeBackend = {
184
186
  operatorMcpNames: operatorMcpNames(process.env, os.homedir()),
185
187
  toolOutput: { maxLines: Math.round(maxBytes / 40), maxBytes },
186
188
  });
187
- // The notebook path rides beside the run marker, as the pi and claude adapters' envs do
189
+ // The notes path rides beside the run marker, as the pi and claude adapters' envs do
188
190
  // (withNotesPath); from part 2/4 the run saves its note through the `tumwater-notes` command
189
191
  // on PATH, so OpenCode configures no tumwater MCP server for notes.
190
- const base = withNotesPath(agentBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
192
+ const base = backendRunEnv(run);
191
193
  const env = opencodeEnv(base, { dbPath, configContent });
192
194
  const { sessionId } = resolveOpencodeSession(run, env);
193
195
  // The thinking variant (part 6d/7): ride the catalog's effort levels for the resolved
@@ -202,7 +204,7 @@ export const opencodeBackend = {
202
204
  ...(variant !== undefined ? { variant } : {}),
203
205
  sessionId,
204
206
  title: run.sessionName,
205
- args: resolvedAgentConfig(run.config, "opencode").args,
207
+ args: resolvedBackendConfig(run.config, "opencode").args,
206
208
  prompt: run.prompt,
207
209
  });
208
210
  return { args, stdin, env, sessionId };
@@ -226,6 +228,7 @@ export const opencodeBackend = {
226
228
  return false;
227
229
  const root = teamRootOf(dir);
228
230
  const dbPath = opencodeDbPath(root);
231
+ ensureParentDir(dbPath);
229
232
  const env = { ...process.env, OPENCODE_DB: dbPath };
230
233
  // Resolve the binary exactly as the run's invocation does — same env → backends.opencode.bin
231
234
  // → default precedence — so a configured custom binary's resume guard agrees with the
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** The data half of OpenCode free-ness (plans/opencode-backend.md §7; part 6a/7): a bounded
3
2
  * fetch of models.dev's `api.json` — the catalog OpenCode itself caches (finding 10) — a parse
4
3
  * that keeps only the providers the config routes through OpenCode, a cache at
@@ -8,7 +7,10 @@
8
7
  * part 7/7 (a startup-cache seed and doctor), so this module's only callers today are its own
9
8
  * tests. */
10
9
  import { spawnSync } from "node:child_process";
10
+ import { isJsonObject, validEntries } from "../../files/json-object.js";
11
11
  import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
12
+ import { errorMessage } from "../../text/text.js";
13
+ import { DAY_MS } from "../../text/datetime.js";
12
14
  import { opencodeModelsPath } from "../../paths.js";
13
15
  import { logEventBestEffort } from "../../events/events.js";
14
16
  import { opencodeQualifiedModel } from "./opencode-args.js";
@@ -18,7 +20,7 @@ const CATALOG_URL = "https://models.dev/api.json";
18
20
  * most once a day, so a slow or wedged network must not hold the team up. */
19
21
  const CATALOG_FETCH_TIMEOUT_MS = 30_000;
20
22
  /** How long a fetched cache is trusted before it is refreshed. */
21
- const CATALOG_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
23
+ const CATALOG_CACHE_TTL_MS = DAY_MS;
22
24
  const defaultFetch = async (url, timeoutMs) => {
23
25
  const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
24
26
  if (!res.ok)
@@ -37,26 +39,25 @@ export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
37
39
  * the provider map at the top level; OpenCode's cached copy wraps that same map under a `body`
38
40
  * key (the 2.0.32 capture in test/fixtures), so both shapes are accepted. Each kept model is
39
41
  * keyed `<provider>/<model>`. A model never present yields no entry, so an unknown model is
40
- * automatically "not free" to opencodeModelFree. */
42
+ * automatically "not free" to opencodeModelFree. The body is untrusted network data: a malformed
43
+ * provider or model is skipped, never thrown on. */
41
44
  export function parseOpencodeCatalog(body, providers) {
42
45
  const out = {};
43
- const root = body;
46
+ const root = isJsonObject(body) ? body : {};
44
47
  // models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
45
- const wrapped = root?.body;
46
- const catalog = typeof wrapped === "object" && wrapped !== null && !Array.isArray(wrapped)
47
- ? wrapped
48
- : (body ?? {});
49
- for (const [provider, providerRaw] of Object.entries(catalog)) {
48
+ const wrapped = root.body;
49
+ const catalog = isJsonObject(wrapped) ? wrapped : root;
50
+ for (const [provider, providerInfo] of Object.entries(catalog)) {
50
51
  if (provider !== "opencode" && !providers.includes(provider))
51
52
  continue;
52
- const providerInfo = providerRaw;
53
- if (providerInfo?.models === undefined)
53
+ if (!isJsonObject(providerInfo) || !isJsonObject(providerInfo.models))
54
54
  continue;
55
- for (const [id, raw] of Object.entries(providerInfo.models)) {
56
- const m = raw;
55
+ for (const [id, m] of Object.entries(providerInfo.models)) {
56
+ if (!isJsonObject(m))
57
+ continue;
57
58
  // Only a present cost block marks a model free: a missing cost block is unknown price data,
58
59
  // and treating it as $0 is the unsafe direction for a budget gate.
59
- const cost = m?.cost;
60
+ const cost = isJsonObject(m.cost) ? m.cost : undefined;
60
61
  const input = cost?.input ?? 0;
61
62
  const output = cost?.output ?? 0;
62
63
  const model = { free: cost !== undefined && input === 0 && output === 0 };
@@ -64,16 +65,19 @@ export function parseOpencodeCatalog(body, providers) {
64
65
  model.inputPerM = input;
65
66
  model.outputPerM = output;
66
67
  }
67
- if (m?.limit?.context !== undefined)
68
+ if (isJsonObject(m.limit) && m.limit.context !== undefined)
68
69
  model.context = m.limit.context;
69
- if (m?.modalities?.input !== undefined)
70
+ if (isJsonObject(m.modalities) && Array.isArray(m.modalities.input)) {
70
71
  model.images = m.modalities.input.includes("image");
71
- const efforts = m?.reasoning_options
72
- ?.filter((r) => r.type === "effort")
73
- .flatMap((r) => r.values ?? []);
74
- if (efforts !== undefined && efforts.length > 0)
75
- model.efforts = efforts;
76
- out[`${provider}/${id}`] = model;
72
+ }
73
+ if (Array.isArray(m.reasoning_options)) {
74
+ const efforts = m.reasoning_options
75
+ .flatMap((r) => isJsonObject(r) && r.type === "effort" && Array.isArray(r.values) ? r.values : []);
76
+ if (efforts.length > 0)
77
+ model.efforts = efforts;
78
+ }
79
+ if (isOpencodeCatalogModel(model))
80
+ out[`${provider}/${id}`] = model;
77
81
  }
78
82
  }
79
83
  return out;
@@ -113,20 +117,32 @@ export function observeOpencodeRun(root, run, opts) {
113
117
  log({ source: opts.source, type: "opencode_free_model_billed", model: run.model, costUsd: run.costUsd });
114
118
  return cache;
115
119
  }
120
+ function isOpencodeCatalogModel(value) {
121
+ if (!isJsonObject(value) || typeof value.free !== "boolean")
122
+ return false;
123
+ const numbers = [value.inputPerM, value.outputPerM, value.context, value.billedAt];
124
+ if (!numbers.every((n) => n === undefined || (typeof n === "number" && Number.isFinite(n))))
125
+ return false;
126
+ if (value.images !== undefined && typeof value.images !== "boolean")
127
+ return false;
128
+ return (value.efforts === undefined ||
129
+ (Array.isArray(value.efforts) && value.efforts.every((e) => typeof e === "string")));
130
+ }
116
131
  /** Read the cached catalog, or an empty one (fetchedAt 0, no models) when the file is missing
117
132
  * or malformed — the harness's no-data policy, so a torn write reads as "needs a refresh".
118
133
  * Exported so the opencode backend's adapter (part 6d/7) and doctor can read the same cache
119
134
  * without triggering a network fetch. The synchronous read the sync poll surfaces
120
135
  * (pollBudgetGate, collectBudget) use to thread the catalog-cache into their free-ness reads
121
136
  * (part 6b/7); the async refresh (which runs once per process at the startup gate) is what
122
- * fills the file in. */
137
+ * fills the file in. Entries that are not well-formed catalog models are dropped, so a foreign
138
+ * entry reads as a model missing from the cache — not free. */
123
139
  export function readOpencodeCache(root) {
124
140
  const existing = readJsonFile(opencodeModelsPath(root));
125
- if (existing !== null && typeof existing.fetchedAt === "number" && existing.models !== undefined && typeof existing.models === "object" && !Array.isArray(existing.models)) {
141
+ if (existing !== null && typeof existing.fetchedAt === "number" && isJsonObject(existing.models)) {
126
142
  return {
127
143
  fetchedAt: existing.fetchedAt,
128
144
  cliVersion: typeof existing.cliVersion === "string" ? existing.cliVersion : undefined,
129
- models: existing.models,
145
+ models: validEntries(existing.models, isOpencodeCatalogModel),
130
146
  };
131
147
  }
132
148
  return { fetchedAt: 0, models: {} };
@@ -150,16 +166,21 @@ function readCliVersion(bin = "opencode") {
150
166
  return undefined;
151
167
  }
152
168
  }
169
+ /** Whether the cache is missing or older than CATALOG_CACHE_TTL_MS at `now`. The age rule
170
+ * refreshOpencodeCatalog and the poll-driven refresh (newOpencodeCatalogRefresh) share. */
171
+ export function opencodeCatalogStale(cache, now) {
172
+ return cache.fetchedAt === 0 || now - cache.fetchedAt >= CATALOG_CACHE_TTL_MS;
173
+ }
153
174
  /** Return a cache fresh enough to price by, fetching and persisting when the current one is
154
175
  * missing, stale (older than 24 h) or fetched by a different `opencode --version`. A failed
155
176
  * fetch keeps the old cache (logging one warning) so the team still prices by the last good
156
- * data; `billedAt` from the old cache survives a successful refresh (part 6c/7). */
177
+ * data; `billedAt` from the old cache survives a successful refresh (part 6c/7). A failed cache
178
+ * write warns and returns the fetched catalog, so the next start fetches again. */
157
179
  export async function refreshOpencodeCatalog(root, opts) {
158
180
  const now = opts.now !== undefined ? opts.now() : Date.now();
159
181
  const version = opts.cliVersion !== undefined ? opts.cliVersion : readCliVersion(opts.cliBin);
160
182
  const current = readOpencodeCache(root);
161
- const fresh = current.fetchedAt !== 0 &&
162
- now - current.fetchedAt < CATALOG_CACHE_TTL_MS &&
183
+ const fresh = !opencodeCatalogStale(current, now) &&
163
184
  (version === undefined || current.cliVersion === version);
164
185
  if (fresh)
165
186
  return current;
@@ -179,6 +200,11 @@ export async function refreshOpencodeCatalog(root, opts) {
179
200
  fresh.billedAt = old.billedAt;
180
201
  }
181
202
  const cache = { fetchedAt: now, cliVersion: version, models: parsed };
182
- writeOpencodeCache(root, cache);
203
+ try {
204
+ writeOpencodeCache(root, cache);
205
+ }
206
+ catch (err) {
207
+ opts.warn?.(`could not save the open code model catalog: ${errorMessage(err)}`);
208
+ }
183
209
  return cache;
184
210
  }
@@ -1,12 +1,12 @@
1
- // vocabulary: plain
2
1
  /** Building one OpenCode run's injected config (`OPENCODE_CONFIG_CONTENT`) (plans/opencode-
3
2
  * backend.md "The invocation") — pure JSON construction, with no subprocess. The config
4
3
  * applies after the operator's global and project files, so only managed config wins over it:
5
4
  * the gate-safe isolation switches (snapshots off, updates off, six denials), `enabled →
6
5
  * disabled: true` for every operator MCP server (config merges, so disabling is the only way
7
- * to remove one), and an `instructions` entry mirroring claude's AGENTS.md handling. No
8
- * tool is disabled beyond the six gate-safe denials — denying `read` trips the free-tier gate
9
- * (finding 11). Part 5/7 wires this string into the run's environment.
6
+ * to remove one), an `instructions` entry mirroring claude's AGENTS.md handling, and tumwater's
7
+ * write-guard plugin (write-guard-plugin/index.ts), which refuses a write outside the run's
8
+ * worktree. No tool is disabled beyond the six gate-safe denials — denying `read` trips the
9
+ * free-tier gate (finding 11). Part 5/7 wires this string into the run's environment.
10
10
  *
11
11
  * From part 3/4 tumwater no longer adds an MCP server of its own: run notes ride the
12
12
  * `tumwater-notes` command on PATH, so every operator MCP server is disabled alike — an
@@ -17,6 +17,9 @@
17
17
  * here, and doctor reports it in part 7/7. */
18
18
  import fs from "node:fs";
19
19
  import path from "node:path";
20
+ import { fileURLToPath, pathToFileURL } from "node:url";
21
+ import { readTextOrNull } from "../../files/files.js";
22
+ import { isJsonObject } from "../../files/json-object.js";
20
23
  /** The operator's global opencode config lives in `$XDG_CONFIG_HOME/opencode/` (or
21
24
  * `~/.config/opencode/`), in `config.json`, `opencode.json` or `opencode.jsonc`. */
22
25
  const OPERATOR_CONFIG_FILES = ["config.json", "opencode.json", "opencode.jsonc"];
@@ -123,13 +126,9 @@ export function operatorMcpNames(env, homeDir) {
123
126
  const configHome = env.XDG_CONFIG_HOME ?? path.join(homeDir, ".config");
124
127
  const dir = path.join(configHome, "opencode");
125
128
  for (const file of OPERATOR_CONFIG_FILES) {
126
- let text;
127
- try {
128
- text = fs.readFileSync(path.join(dir, file), "utf8");
129
- }
130
- catch {
129
+ const text = readTextOrNull(path.join(dir, file));
130
+ if (text === null)
131
131
  continue;
132
- }
133
132
  let parsed;
134
133
  try {
135
134
  parsed = JSON.parse(file.endsWith(".jsonc") ? stripJsonc(text) : text);
@@ -138,13 +137,11 @@ export function operatorMcpNames(env, homeDir) {
138
137
  return [];
139
138
  }
140
139
  const mcp = parsed?.mcp;
141
- if (mcp === undefined || mcp === null)
142
- return [];
143
- if (typeof mcp !== "object" || Array.isArray(mcp))
140
+ if (!isJsonObject(mcp))
144
141
  return [];
145
142
  const names = new Set();
146
143
  const servers = mcp.servers;
147
- if (typeof servers === "object" && servers !== null && !Array.isArray(servers)) {
144
+ if (isJsonObject(servers)) {
148
145
  for (const key of Object.keys(servers))
149
146
  names.add(key);
150
147
  }
@@ -163,6 +160,13 @@ export function operatorMcpNames(env, homeDir) {
163
160
  function shouldInjectClaudeMd(cwd) {
164
161
  return fs.existsSync(path.join(cwd, "CLAUDE.md")) && !fs.existsSync(path.join(cwd, "AGENTS.md"));
165
162
  }
163
+ /** The write-guard plugin's `plugins` entry: a `file://` URL of the directory holding its
164
+ * compiled `index.js`, since OpenCode 2.x loads a configured plugin path only as a directory.
165
+ * Resolved from this module, so a staged build (.tumwater/build/<sha>) loads its own copy. */
166
+ export function writeGuardPluginEntry() {
167
+ const entry = fileURLToPath(new URL("./write-guard-plugin/index.js", import.meta.url));
168
+ return pathToFileURL(path.dirname(entry)).href;
169
+ }
166
170
  /** Build the injected opencode config JSON string. Never reads the worktree beyond the
167
171
  * CLAUDE.md/AGENTS.md probe. */
168
172
  export function opencodeConfigContent(opts) {
@@ -170,6 +174,7 @@ export function opencodeConfigContent(opts) {
170
174
  snapshots: false,
171
175
  update: "disable",
172
176
  permissions: DENIED_PERMISSIONS,
177
+ plugins: [writeGuardPluginEntry()],
173
178
  };
174
179
  if (opts.toolOutput !== undefined) {
175
180
  config.tool_output = { max_lines: opts.toolOutput.maxLines, max_bytes: opts.toolOutput.maxBytes };
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** Building the environment for one opencode run (plans/opencode-backend.md "The invocation")
3
2
  * — pure scrub-and-set over a base environment, with no subprocess. The caller layers the
4
3
  * run-marker and the macOS preload on first (claude's adapter shows the pie pattern), then
@@ -1,4 +1,3 @@
1
- // vocabulary: plain
2
1
  /** Minting an OpenCode session id (plans/opencode-backend.md "Sessions", finding 3) — a pure
3
2
  * reproduction of OpenCode's `Identifier.descending()` (packages/schema/src/identifier.ts), so
4
3
  * a fresh run's `--session` id is accepted by the free-tier gate. A `ses_…` id in any other
@@ -12,7 +11,7 @@ import crypto from "node:crypto";
12
11
  const ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
13
12
  /** Per-process minting state, mirroring OpenCode's per-millisecond counter: the counter
14
13
  * restarts at 1 for each new millisecond, and increments within the same millisecond so two
15
- * ids minted in one tick still differ in their hex. */
14
+ * ids minted in one millisecond still differ in their hex. */
16
15
  let lastMintMs = -1;
17
16
  let counter = 0;
18
17
  /** Mint an OpenCode session id. `now` is a Unix-millisecond epoch (injected for tests);
@@ -251,7 +251,7 @@ export function opencodeTranslator(opts = {}) {
251
251
  * the run's real cost and token totals. Anything malformed returns `[]`; part 5/7 logs
252
252
  * `opencode_usage_partial` when this is empty. */
253
253
  export function opencodeExportLines(exportJson) {
254
- if (typeof exportJson !== "object" || exportJson === null || Array.isArray(exportJson))
254
+ if (!isJsonObject(exportJson))
255
255
  return [];
256
256
  const info = exportJson.info;
257
257
  if (typeof info !== "object" || info === null)