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,279 @@
1
+ /** The codex adapter for the agent seam (plans/codex-backend.md §4, §5, §6): it shapes one
2
+ * run's argv/stdin/env, translates codex's `exec --json` stream, reads the authoritative
3
+ * rollout after the process exits, and classifies the exit's stderr — everything
4
+ * backend-specific. src/agent/run-agent.ts owns the spawn and records this run in the session
5
+ * ledger; codex mints its own session id (a thread id, or the resumed record's id) in the
6
+ * stream's `thread.started`, which the runner's stream-mint pass appends to the ledger and
7
+ * `invocation` returns so a later `exec resume` can reach it.
8
+ *
9
+ * Sessions resume by id, out of the team's `CODEX_HOME`. A continue takes the ledger's newest
10
+ * record of this run's kind and resumes only when it is codex's, has this cwd, carries a UUID
11
+ * id, and `findRollout` finds its rollout under the run's home — anything else mints a fresh
12
+ * thread and the existing `handoff` path takes over (design §4). Together with the `resumable`
13
+ * hook, that is every resume point's one rule.
14
+ *
15
+ * Registration lives in src/agent/run-agent.ts beside the pi, claude and opencode adapters; a
16
+ * test selects codex by setting `config.agent = "codex"` on a run. A run carrying a note gets
17
+ * the notes path in its environment (the `tumwater-notes` command on PATH reads it), as the
18
+ * pi and claude adapters do. */
19
+ import fs from "node:fs";
20
+ import { loadConfigSafe } from "../../config/config.js";
21
+ import { resolvedBackendConfig } from "../../config/config-views.js";
22
+ import { backendRunEnv } from "../../process/run-marker.js";
23
+ import { resolveBinForBackend } from "../../pi/pi-bin.js";
24
+ import { teamRootOf, nearestTumwaterRoot } from "../../paths.js";
25
+ import { warnEventBestEffort, TUMWATER_SOURCE } from "../../events/events.js";
26
+ import { errorMessage } from "../../text/text.js";
27
+ import { codexHome, readCodexCatalog } from "./codex-catalog.js";
28
+ import { resumableSession } from "../session-ledger.js";
29
+ import { codexArgs, codexEffort } from "./codex-args.js";
30
+ import { codexEnv } from "./codex-env.js";
31
+ import { codexHookTrust } from "./codex-hook-trust.js";
32
+ import { codexHookArg, codexHookTrustArg, codexWriteGuardDefinition } from "./codex-write-guard.js";
33
+ import { findRollout, rolloutLines } from "./codex-rollout.js";
34
+ import { codexTranslator } from "./codex-stream.js";
35
+ /** A codex thread id (a UUID format, e.g. `01a15c9d-6762-7931-814e-a3e6e64c1b51`). The
36
+ * stream mints it in `thread.started`; the pre-spawn ledger record carries the session name
37
+ * instead, so this regex is what tells a resume that a record holds a real, stream-minted id
38
+ * worth resuming (design §4). */
39
+ const THREAD_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
40
+ /** The oldest codex CLI the team targets, the sibling of `OPENCODE_MIN_VERSION`
41
+ * (src/agent/opencode/opencode-backend.ts) and `CLAUDE_MIN_VERSION`
42
+ * (src/agent/claude/claude-backend.ts): doctor's `codex version` row fails an installed CLI
43
+ * below this floor (plans/codex-backend.md "Hand steps", H1). */
44
+ export const CODEX_MIN_VERSION = "0.163.0";
45
+ /** The wall-clock bound `prepare` gives each trust lookup, mirroring lookupCodexHookTrust's
46
+ * own 15 s default (plans/codex-write-guard.md "Trust (parts 4–5)"). Settable so a test can
47
+ * shorten the bound (to 500 ms) without waiting out the real 15 s; a hand run keeps the
48
+ * default. */
49
+ let codexTrustLookupTimeoutMs = 15_000;
50
+ /** The test seam for the trust lookup bound: a test sets it to 500 ms and returns it to the
51
+ * 15 s default in a finally, so a hanging app-server case stays fast without a config knob. */
52
+ export function setCodexTrustLookupTimeoutMs(ms) {
53
+ codexTrustLookupTimeoutMs = ms;
54
+ }
55
+ /** The write-guard trust `prepare` has resolved, keyed by home and hook definition, so one
56
+ * process looks each up at most once and a run's invocation never spawns `app-server` again. */
57
+ const writeGuardTrust = new Map();
58
+ /** One in-flight resolution per (home, definition) key, so concurrent runs share a single
59
+ * `app-server` instead of starting one each (plans/codex-write-guard.md "Trust (parts 4–5)"). */
60
+ const writeGuardInFlight = new Map();
61
+ /** The (home, definition) keys whose `off` warning has already been logged, so a failed lookup
62
+ * warns once per process instead of once per run. */
63
+ const writeGuardWarned = new Set();
64
+ /** Warn once per process per (home, definition) that the write guard is off. The warning rides
65
+ * the best-effort event feed on the run's root, skipped when there is no root (a hand run's
66
+ * prepared look-up must not mint a `.tumwater`). A `trust` result logs nothing. */
67
+ function warnGuardOffOnce(key, root, state) {
68
+ if (state.kind !== "off")
69
+ return;
70
+ if (writeGuardWarned.has(key))
71
+ return;
72
+ writeGuardWarned.add(key);
73
+ if (root !== undefined) {
74
+ warnEventBestEffort(root, TUMWATER_SOURCE, `codex write guard off: ${state.reason}`);
75
+ }
76
+ }
77
+ /** The (home, definition) cache key shared by the single-flight and the resolved-trust map. */
78
+ function writeGuardKey(home, definition) {
79
+ return `${home ?? ""}|${JSON.stringify(definition)}`;
80
+ }
81
+ /** The session this run uses: for a continue, the ledger's newest record of this kind when it
82
+ * is codex's, has this cwd, carries a UUID thread id, and `findRollout` finds its rollout
83
+ * under the run's home; otherwise the run is fresh (no pre-minted id — the stream names it).
84
+ * Returns the id and whether this is a resume, so `invocation` can name it and build `exec
85
+ * resume`. */
86
+ function resolveCodexSession(run, home) {
87
+ if (run.continueSession && home !== undefined) {
88
+ const record = resumableSession(run.sessionDir, {
89
+ kind: run.kind,
90
+ agent: "codex",
91
+ cwd: run.cwd,
92
+ });
93
+ if (record !== null && THREAD_ID_RE.test(record.id) && findRollout(home, record.id) !== null) {
94
+ return { sessionId: record.id, resumed: true };
95
+ }
96
+ }
97
+ return { resumed: false };
98
+ }
99
+ /** Classify codex's stderr: the seam's stderr half for a CLI that died before emitting a
100
+ * `turn.failed` (the stream's own failures are classified by the translator). A run the
101
+ * harness killed has its own cause and is never classified here (design finding 12). The
102
+ * codex CLI surfaces login/model/usage failures as stream JSON, so a bare nonzero exit with
103
+ * no stream carries no signal this part has a verdict for — it stays unclassified and the
104
+ * parser's own event-derived flags stand. */
105
+ function classifyCodexExit(info) {
106
+ if (info.killed)
107
+ return {};
108
+ return {};
109
+ }
110
+ /** The agent-backend seam's codex adapter. */
111
+ export const codexBackend = {
112
+ id: "codex",
113
+ label: "Codex",
114
+ defaultBin: "codex",
115
+ binEnv: "TUMWATER_CODEX_BIN",
116
+ resolveBin(config) {
117
+ return resolveBinForBackend(config, this);
118
+ },
119
+ invocation(run) {
120
+ // The notes path rides beside the run marker, as the pi and claude adapters' envs do
121
+ // (withNotesPath); from part 2/4 the run saves its note through the `tumwater-notes`
122
+ // command on PATH, so codex configures no MCP server for notes.
123
+ const base = backendRunEnv(run);
124
+ // A team home of its own (`CODEX_HOME`) keeps team sessions and trust entries out of the
125
+ // operator's `~/.codex` (design §4, finding 10); unset leaves the operator's home.
126
+ const home = codexHome(resolvedBackendConfig(run.config, "codex"));
127
+ const env = codexEnv(base, { home });
128
+ const { sessionId, resumed } = resolveCodexSession(run, home);
129
+ const thinking = run.config.thinking;
130
+ // The effort (parts 5/9 and 8/9): ride the thinking level through the decoder, then gate
131
+ // it on the model's cataloged effort levels — a level the model does not list is never
132
+ // sent (codexEffort's `levels` half). No home (no catalog) leaves the value unclamped, as
133
+ // before part 8/9.
134
+ const catalog = home === undefined ? new Map() : readCodexCatalog(home);
135
+ const effort = thinking === undefined ? undefined : codexEffort(thinking, catalog.get(run.config.model ?? "")?.levels);
136
+ // The write guard's trust (part 6/9): `prepare` resolved it under this run's env and
137
+ // home and stored it keyed by (home, definition), so `invocation` reads it synchronously.
138
+ // With trust it rides the hook and its trust entry as two `-c` values (after the
139
+ // harness's own keys, before `-m`); with `off` or no state it passes neither, since an
140
+ // untrusted hook would be skipped silently anyway. tumwater never passes
141
+ // `--dangerously-bypass-hook-trust` (findings 1 and 4).
142
+ const definition = codexWriteGuardDefinition();
143
+ const trust = writeGuardTrust.get(writeGuardKey(home, definition));
144
+ const guardArgs = trust?.kind === "trust"
145
+ ? ["-c", codexHookArg(definition), "-c", codexHookTrustArg(trust.trust)]
146
+ : undefined;
147
+ const { args, stdin } = codexArgs({
148
+ model: run.config.model ?? "",
149
+ ...(effort !== undefined ? { effort } : {}),
150
+ ...(resumed && sessionId !== undefined ? { resumeId: sessionId } : {}),
151
+ ...(guardArgs !== undefined ? { guardArgs } : {}),
152
+ args: resolvedBackendConfig(run.config, "codex").args,
153
+ prompt: run.prompt,
154
+ });
155
+ // A resumed run returns its id so the runner's pre-spawn ledger record carries it; a fresh
156
+ // run has none until the stream names it (the runner's stream-mint pass appends it).
157
+ return { args, stdin, env, ...(resumed && sessionId !== undefined ? { sessionId } : {}) };
158
+ },
159
+ async prepare(run) {
160
+ // Resolve the write guard's trust under the same env and home invocation will use (the
161
+ // run's CODEX_HOME, plans/codex-write-guard.md "Trust (parts 4–5)"), so the trust an
162
+ // invocation later reads was looked up under the run's own config. It never throws a run
163
+ // away: a failed lookup records `off` (and warns once) and the run continues without the
164
+ // guard's trust.
165
+ const base = backendRunEnv(run);
166
+ const home = codexHome(resolvedBackendConfig(run.config, "codex"));
167
+ const env = codexEnv(base, { home });
168
+ const root = nearestTumwaterRoot(run.sessionDir);
169
+ const definition = codexWriteGuardDefinition();
170
+ const key = writeGuardKey(home, definition);
171
+ // Single-flight per (home, definition): a run whose key is being resolved (or already
172
+ // resolved) shares the one in-flight lookup, so concurrent runs start one `app-server`.
173
+ const settled = writeGuardTrust.get(key);
174
+ if (settled !== undefined) {
175
+ warnGuardOffOnce(key, root, settled);
176
+ return;
177
+ }
178
+ const inFlight = writeGuardInFlight.get(key);
179
+ if (inFlight !== undefined) {
180
+ await inFlight;
181
+ const after = writeGuardTrust.get(key);
182
+ if (after !== undefined)
183
+ warnGuardOffOnce(key, root, after);
184
+ return;
185
+ }
186
+ const promise = (async () => {
187
+ try {
188
+ const trust = await codexHookTrust(root, {
189
+ bin: run.agentBin,
190
+ env,
191
+ cwd: run.cwd,
192
+ definition,
193
+ timeoutMs: codexTrustLookupTimeoutMs,
194
+ });
195
+ writeGuardTrust.set(key, { kind: "trust", trust });
196
+ }
197
+ catch (error) {
198
+ writeGuardTrust.set(key, { kind: "off", reason: errorMessage(error) });
199
+ }
200
+ })();
201
+ writeGuardInFlight.set(key, promise);
202
+ await promise;
203
+ writeGuardInFlight.delete(key);
204
+ const state = writeGuardTrust.get(key);
205
+ if (state !== undefined)
206
+ warnGuardOffOnce(key, root, state);
207
+ },
208
+ translator() {
209
+ return codexTranslator();
210
+ },
211
+ imageInput(model) {
212
+ // Answer from the model catalog (part 8/9): a model reads images when its catalog entry's
213
+ // `input_modalities` say so; a model missing from the catalog — or a run with no home to
214
+ // read — answers false, the safe direction, so a blind image read is never silently
215
+ // assumed readable. The home is the team's `backends.codex.configDir`, found by walking
216
+ // up from the process's working directory to its `.tumwater` root and loading the config
217
+ // there, the same root the runs' home and account live under (like opencode's cache).
218
+ if (model === undefined)
219
+ return false;
220
+ const root = teamRootOf(process.cwd());
221
+ const { config } = loadConfigSafe(root);
222
+ const home = codexHome(resolvedBackendConfig(config ?? {}, "codex"));
223
+ if (home === undefined)
224
+ return false;
225
+ return readCodexCatalog(home).get(model)?.images === true;
226
+ },
227
+ resumable(dir, opts) {
228
+ const record = resumableSession(dir, { kind: opts.kind, agent: "codex", cwd: opts.cwd });
229
+ if (record === null || !THREAD_ID_RE.test(record.id))
230
+ return false;
231
+ const root = teamRootOf(dir);
232
+ const { config } = loadConfigSafe(root);
233
+ const home = codexHome(resolvedBackendConfig(config ?? {}, "codex"));
234
+ if (home === undefined)
235
+ return false;
236
+ return findRollout(home, record.id) !== null;
237
+ },
238
+ afterRun(run, invocation, translator) {
239
+ const ct = translator;
240
+ const home = codexHome(resolvedBackendConfig(run.config, "codex"));
241
+ const id = invocation.sessionId ?? ct.sessionId?.();
242
+ const fresh = invocation.sessionId === undefined;
243
+ // The rollout is authoritative, so its loss is worth one warning: a fresh run keeps the
244
+ // stream's own usage as a fallback, a resumed run records none (design §5, finding 4).
245
+ const fallback = () => {
246
+ ct.flagUsagePartial();
247
+ if (!fresh)
248
+ return [];
249
+ const usage = ct.turnCompletedUsage();
250
+ return usage !== undefined ? [JSON.stringify({ type: "run_usage", ...usage })] : [];
251
+ };
252
+ if (home === undefined || id === undefined)
253
+ return fallback();
254
+ const rollout = findRollout(home, id);
255
+ if (rollout === null)
256
+ return fallback();
257
+ let text;
258
+ try {
259
+ text = fs.readFileSync(rollout, "utf8");
260
+ }
261
+ catch {
262
+ return fallback();
263
+ }
264
+ // The stream never exposes the turn id — only the thread id that names the rollout file
265
+ // (real captures' `task_started.turn_id` is a different UUID), so the reader selects the
266
+ // last block, this run's, from the file the thread id located.
267
+ const { lines, switchedTo } = rolloutLines(text, {
268
+ requestedModel: run.config.model ?? "",
269
+ });
270
+ if (lines.length === 0)
271
+ return fallback();
272
+ // Codex can switch models on a usage limit by itself (finding 24): warn once and record
273
+ // the model that actually ran in the rollout's reading.
274
+ if (switchedTo !== undefined)
275
+ ct.flagModelSwitched(run.config.model ?? "", switchedTo);
276
+ return lines;
277
+ },
278
+ classifyExit: classifyCodexExit,
279
+ };
@@ -0,0 +1,97 @@
1
+ /** The Codex model catalog (plans/codex-backend.md design finding 14 — "Spend, free-ness,
2
+ * window, images" part 8/9): tumwater reads a Codex model's effort levels, default effort,
3
+ * context window and image input from `$CODEX_HOME/models_cache.json`, the file the CLI itself
4
+ * refreshes and `codex debug models` prints. Before part 8/9 the adapter assumed every Codex
5
+ * model had every effort and read every image; the catalog refines both, and seats the context
6
+ * window `seamContextWindow` reports for a codex seam. There are no prices in the catalog — the
7
+ * whole file knows nothing of cost — so free-ness never reads here; the codex account
8
+ * (codex-account.ts) owns billing.
9
+ *
10
+ * A missing, unreadable or malformed file reads as an empty catalog, the same "no data" policy
11
+ * pi's models.json reader uses: the adapter then answers blind (no effort gating, no images,
12
+ * no window), which is the safe direction for the window and image checks. The parse itself is
13
+ * pure and build-agnostic — no registry, config or subprocess — so the adapter, pi's
14
+ * seamContextWindow and the doctor can read one answer without dragging a runtime in. */
15
+ import os from "node:os";
16
+ import path from "node:path";
17
+ import { cachedByStat } from "../../files/stat-cache.js";
18
+ import { finiteNumber, isJsonObject } from "../../files/json-object.js";
19
+ import { readJsonFile } from "../../files/json-files.js";
20
+ import { expandHomeTilde } from "../../files/files.js";
21
+ /** The team's Codex home for a run: `backends.codex.configDir`, a leading `~` expanded, else
22
+ * the CLI's default `~/.codex` — a run with no configured home keeps the operator's
23
+ * `CODEX_HOME`, which is exactly Codex's own default home, so the catalog and the sessions
24
+ * live there too and a known home always exists. Pure. Shared by the adapter, pi's
25
+ * seamContextWindow and the codex-account seed, so all three read one home. */
26
+ export function codexHome(config) {
27
+ const dir = config.configDir;
28
+ if (dir === undefined)
29
+ return path.join(os.homedir(), ".codex");
30
+ return expandHomeTilde(dir, os.homedir());
31
+ }
32
+ /** Where a home's model catalog lives: `$CODEX_HOME/models_cache.json`. */
33
+ export function codexModelsPath(home) {
34
+ return path.join(home, "models_cache.json");
35
+ }
36
+ /** Per-catalog cache of the parsed entries, keyed by the models_cache path: the status
37
+ * surfaces read the window every poll while the file changes only when the CLI refreshes it —
38
+ * and it grows with the model catalog — so an unchanged file costs one stat per poll instead
39
+ * of a re-read plus a JSON.parse of the whole file. Any write invalidates via
40
+ * dev/ino/mtime/size (stat-cache.cachedByStat, the same freshness check pi's providers use). */
41
+ const catalogCache = new Map();
42
+ /** The parsed model catalog from `$CODEX_HOME/models_cache.json`, keyed by model slug, or an
43
+ * empty map when the file is missing, unreadable, malformed or not shaped like what the CLI
44
+ * writes. A model's entry is kept only when it names a usable slug and context_window; a
45
+ * missing `effective_context_window_percent` reads as no window (the model is dropped), since
46
+ * the window is the whole point of the entry. The returned map is a copy — callers may treat
47
+ * their result as their own. */
48
+ export function readCodexCatalog(home) {
49
+ const modelsPath = codexModelsPath(home);
50
+ return (cachedByStat(catalogCache, modelsPath, modelsPath, () => {
51
+ const out = new Map();
52
+ // readJsonFile is the shared "missing or torn reads as no data" policy for a JSON
53
+ // object file; the nested `models` array gets the same isJsonObject/Array guards.
54
+ const doc = readJsonFile(modelsPath);
55
+ if (doc === null)
56
+ return out;
57
+ const models = doc.models;
58
+ if (!Array.isArray(models))
59
+ return out;
60
+ for (const model of models) {
61
+ const entry = parseModelEntry(model);
62
+ if (entry !== undefined)
63
+ out.set(entry.slug, entry.entry);
64
+ }
65
+ return out;
66
+ }, (models) => new Map([...models].map(([slug, entry]) => [slug, { ...entry, levels: [...entry.levels] }]))) ?? new Map());
67
+ }
68
+ /** One catalog model entry as the map needs it: the slug to key by plus the parsed entry. The
69
+ * entry is kept only when the slug and context window are usable; any other missing field
70
+ * (levels, modalities, default level) just reads as absent on the entry. */
71
+ function parseModelEntry(model) {
72
+ if (!isJsonObject(model) || typeof model.slug !== "string")
73
+ return undefined;
74
+ const context = finiteNumber(model.context_window, Number.NaN);
75
+ const percent = finiteNumber(model.effective_context_window_percent, Number.NaN);
76
+ if (!Number.isFinite(context) || !Number.isFinite(percent))
77
+ return undefined;
78
+ const levels = [];
79
+ if (Array.isArray(model.supported_reasoning_levels)) {
80
+ for (const level of model.supported_reasoning_levels) {
81
+ if (isJsonObject(level) && typeof level.effort === "string")
82
+ levels.push(level.effort);
83
+ }
84
+ }
85
+ const modalities = model.input_modalities;
86
+ return {
87
+ slug: model.slug,
88
+ entry: {
89
+ levels,
90
+ ...(typeof model.default_reasoning_level === "string"
91
+ ? { defaultLevel: model.default_reasoning_level }
92
+ : {}),
93
+ contextWindow: Math.round((context * percent) / 100),
94
+ images: Array.isArray(modalities) && modalities.includes("image"),
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,18 @@
1
+ /** Build the codex process environment from `base`. Never mutates `base`. */
2
+ export function codexEnv(base, opts = {}) {
3
+ const env = {};
4
+ for (const [key, value] of Object.entries(base)) {
5
+ if (key === "CLAUDECODE")
6
+ continue;
7
+ if (key.startsWith("CODEX_"))
8
+ continue;
9
+ if (key.startsWith("CLAUDE_CODE_"))
10
+ continue;
11
+ if (key === "OPENAI_API_KEY" || key === "CODEX_API_KEY")
12
+ continue;
13
+ env[key] = value;
14
+ }
15
+ if (opts.home)
16
+ env.CODEX_HOME = opts.home;
17
+ return env;
18
+ }
@@ -0,0 +1,246 @@
1
+ /** How tumwater learns the write guard's trust from codex itself (plans/codex-write-guard.md
2
+ * "Trust (parts 4–5)", findings 2–3): codex runs a session-flag PreToolUse hook only when its
3
+ * `trusted_hash` matches the hash codex computed for the hook's definition, and that hash is
4
+ * opaque — no simple digest of any command or JSON spelling matched (finding 3). So this module
5
+ * asks codex's own `hooks/list` RPC for the hook's `key` and `currentHash`, and caches the
6
+ * answer per build and codex version so a run's argv never spawns `app-server` again. The
7
+ * answer is never hardcoded: the key (`/<session-flags>/config.toml:pre_tool_use:0:0` on
8
+ * 0.163.0) and the hash both come from this lookup.
9
+ *
10
+ * The lookup spawns `app-server` in its own process group under the run's env and a bounded
11
+ * deadline, kills the group whether it succeeds, times out, or fails, and makes no model
12
+ * request and needs no login (an empty `CODEX_HOME` is enough — finding 3). Part 5/9 wires it
13
+ * into a run before the codex adapter's synchronous invocation. */
14
+ import { spawn } from "node:child_process";
15
+ import { codexHookArg } from "./codex-write-guard.js";
16
+ import { readJsonFile, writeJsonAtomic } from "../../files/json-files.js";
17
+ import { releaseChildHandles, signalTree } from "../../process/process.js";
18
+ import { codexHookTrustPath } from "../../paths.js";
19
+ import { PACKAGE_JSON, packageVersion } from "../../version.js";
20
+ /** The JSON-RPC request/notification sequence one lookup sends over `app-server`'s stdio: the
21
+ * `initialize` handshake (carrying tumwater's own version), the `initialized` notification,
22
+ * then `hooks/list` for the one cwd. The reply to `id: 2` carries the hooks. */
23
+ function hookListMessages(cwd) {
24
+ const version = packageVersion(PACKAGE_JSON).version;
25
+ return [
26
+ {
27
+ jsonrpc: "2.0",
28
+ id: 1,
29
+ method: "initialize",
30
+ params: { clientInfo: { name: "tumwater", version: version ?? "unknown" } },
31
+ },
32
+ { jsonrpc: "2.0", method: "initialized" },
33
+ { jsonrpc: "2.0", id: 2, method: "hooks/list", params: { cwds: [cwd] } },
34
+ ];
35
+ }
36
+ /** The first hook in `hooks/list`'s `result.data[*].hooks` with `source: "sessionFlags"`,
37
+ * `eventName: "preToolUse"` and `command` exactly the guard definition's (finding 3), or null
38
+ * when none matches. The key and hash are returned as codex reports them. */
39
+ function findTrust(result, definition) {
40
+ if (typeof result !== "object" || result === null)
41
+ return null;
42
+ const data = result.data;
43
+ if (!Array.isArray(data))
44
+ return null;
45
+ for (const group of data) {
46
+ if (typeof group !== "object" || group === null)
47
+ continue;
48
+ const hooks = group.hooks;
49
+ if (!Array.isArray(hooks))
50
+ continue;
51
+ for (const hook of hooks) {
52
+ if (typeof hook !== "object" || hook === null)
53
+ continue;
54
+ const h = hook;
55
+ if (h.source !== "sessionFlags" || h.eventName !== "preToolUse")
56
+ continue;
57
+ if (h.command !== definition.command)
58
+ continue;
59
+ if (typeof h.key === "string" && typeof h.currentHash === "string") {
60
+ return { key: h.key, hash: h.currentHash };
61
+ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ /** A short name for a JSON-RPC `error` value, for the rejection message. */
67
+ function describeError(error) {
68
+ if (typeof error === "string")
69
+ return error;
70
+ if (typeof error === "object" && error !== null) {
71
+ const message = error.message;
72
+ if (typeof message === "string")
73
+ return message;
74
+ }
75
+ return JSON.stringify(error);
76
+ }
77
+ /** Ask codex for the guard hook's trust. Spawns `<bin> -c <codexHookArg(definition)>
78
+ * app-server` in `cwd` under `env`, in its own process group, sends the JSON-RPC sequence
79
+ * above over stdio, and resolves with the trust from the first matching `hooks/list` hook.
80
+ *
81
+ * It rejects on a timeout (`timeoutMs`), on the child exiting before the reply, on a JSON-RPC
82
+ * `error`, and on codex listing no session-flag PreToolUse hook with this command. The child's
83
+ * process group is SIGKILLed whether the lookup settles by success, failure, or timeout, so no
84
+ * `app-server` outlives the call. */
85
+ export function lookupCodexHookTrust(opts) {
86
+ const timeoutMs = opts.timeoutMs ?? 15_000;
87
+ return new Promise((resolve, reject) => {
88
+ const child = spawn(opts.bin, ["-c", codexHookArg(opts.definition), "app-server"], {
89
+ cwd: opts.cwd,
90
+ env: opts.env,
91
+ detached: true,
92
+ stdio: ["pipe", "pipe", "pipe"],
93
+ });
94
+ let settled = false;
95
+ let buffer = "";
96
+ const finish = (err, trust) => {
97
+ if (settled)
98
+ return;
99
+ settled = true;
100
+ clearTimeout(timer);
101
+ signalTree(child, "SIGKILL");
102
+ releaseChildHandles(child);
103
+ if (err !== null)
104
+ reject(err);
105
+ else
106
+ resolve(trust);
107
+ };
108
+ const timer = setTimeout(() => {
109
+ finish(new Error(`codex hook trust lookup timed out after ${timeoutMs} ms`));
110
+ }, timeoutMs);
111
+ timer.unref?.();
112
+ const onLine = (line) => {
113
+ let msg;
114
+ try {
115
+ msg = JSON.parse(line);
116
+ }
117
+ catch {
118
+ return; // A non-JSON-RPC line (a notice, a prompt) is not our reply.
119
+ }
120
+ if (typeof msg !== "object" || msg === null)
121
+ return;
122
+ const m = msg;
123
+ if (m.error !== undefined) {
124
+ finish(new Error(`codex hooks/list errored: ${describeError(m.error)}`));
125
+ return;
126
+ }
127
+ if (m.id !== 2)
128
+ return;
129
+ const trust = findTrust(m.result, opts.definition);
130
+ if (trust !== null)
131
+ finish(null, trust);
132
+ else
133
+ finish(new Error("codex listed no session-flag PreToolUse hook with this command"));
134
+ };
135
+ child.stdout?.setEncoding("utf8");
136
+ child.stdout?.on("data", (chunk) => {
137
+ buffer += chunk;
138
+ let nl = buffer.indexOf("\n");
139
+ while (nl >= 0) {
140
+ const line = buffer.slice(0, nl);
141
+ buffer = buffer.slice(nl + 1);
142
+ if (line.trim() !== "")
143
+ onLine(line);
144
+ nl = buffer.indexOf("\n");
145
+ }
146
+ });
147
+ child.on("error", (err) => finish(err instanceof Error ? err : new Error(String(err))));
148
+ child.on("close", (code) => {
149
+ if (!settled) {
150
+ finish(new Error(`codex app-server exited (code ${code ?? "signal"}) before answering hooks/list`));
151
+ }
152
+ });
153
+ for (const message of hookListMessages(opts.cwd)) {
154
+ try {
155
+ child.stdin?.write(JSON.stringify(message) + "\n");
156
+ }
157
+ catch {
158
+ // The pipe is gone (a failed spawn); the error handler settles the lookup.
159
+ }
160
+ }
161
+ });
162
+ }
163
+ /** The trimmed output of `<bin> --version`, or "" when the probe fails or prints nothing. It
164
+ * keys the trust cache, so a codex upgrade re-looks-up the guard's trust for the new build.
165
+ * Async (spawn, not spawnSync): a hung binary's probe must not block the event loop — every
166
+ * run's `prepare` awaits this, and a blocking probe would freeze the whole agent while waiting
167
+ * out its deadline. The child's group is killed whether it succeeds or times out. */
168
+ function codexVersion(bin, env, cwd, timeoutMs) {
169
+ return new Promise((resolve) => {
170
+ const child = spawn(bin, ["--version"], {
171
+ env,
172
+ cwd,
173
+ stdio: ["ignore", "pipe", "ignore"],
174
+ });
175
+ let out = "";
176
+ let done = false;
177
+ const finish = (value) => {
178
+ if (done)
179
+ return;
180
+ done = true;
181
+ clearTimeout(timer);
182
+ signalTree(child, "SIGKILL");
183
+ releaseChildHandles(child);
184
+ resolve(value);
185
+ };
186
+ const timer = setTimeout(() => finish(""), timeoutMs);
187
+ timer.unref?.();
188
+ child.stdout?.setEncoding("utf8");
189
+ child.stdout?.on("data", (chunk) => {
190
+ out += chunk;
191
+ });
192
+ child.on("error", () => finish(""));
193
+ child.on("close", () => finish(out.trim()));
194
+ });
195
+ }
196
+ /** Whether a cached value is a usable `{key, hash}` entry. */
197
+ function isTrust(value) {
198
+ return (typeof value === "object" &&
199
+ value !== null &&
200
+ typeof value.key === "string" &&
201
+ typeof value.hash === "string");
202
+ }
203
+ /** Resolve the guard hook's trust, cached per codex version and hook definition in
204
+ * `.tumwater/state/codex-hook-trust.json` so a run's argv never spawns `app-server` again.
205
+ * Returns the trust, rejecting on a failed lookup.
206
+ *
207
+ * The cache key is the trimmed `<bin> --version` output plus `JSON.stringify(definition)`, so
208
+ * a changed codex version, a changed hook command, or a changed `timeout` (which changes the
209
+ * hash, finding 3) each re-look-up; each entry is `{key, hash, checkedAt}`. A hit resolves
210
+ * without spawning `app-server`; a miss looks up and writes the entry atomically.
211
+ *
212
+ * `root` undefined (a hand run with no `.tumwater` root) looks up and caches nothing and never
213
+ * creates a `.tumwater` directory — a hand run once minted a phantom `$TMPDIR/.tumwater` and
214
+ * reddened every suite on the host (plans/codex-write-guard.md "Visibility"). */
215
+ export async function codexHookTrust(root, opts) {
216
+ const timeoutMs = opts.timeoutMs ?? 15_000;
217
+ if (root === undefined)
218
+ return lookupCodexHookTrust(opts);
219
+ const file = codexHookTrustPath(root);
220
+ // The version probe and the app-server lookup share one deadline (timeoutMs): a failed
221
+ // lookup — a hung binary, an app-server that never answers — never delays a run past the
222
+ // bound, and the probe never blocks the event loop (it is async, not spawnSync). The probe
223
+ // is a quick identity read, so its own slice is capped at a third of the deadline — a slow
224
+ // probe never starves the app-server lookup of its budget, and the sum of the two always
225
+ // stays within the one deadline.
226
+ const deadline = Date.now() + timeoutMs;
227
+ const version = await codexVersion(opts.bin, opts.env, opts.cwd, Math.floor(timeoutMs / 3));
228
+ const cacheKey = `${version}${JSON.stringify(opts.definition)}`;
229
+ const cache = readJsonFile(file);
230
+ const hit = cache?.[cacheKey];
231
+ if (isTrust(hit))
232
+ return { key: hit.key, hash: hit.hash };
233
+ const trust = await lookupCodexHookTrust({
234
+ ...opts,
235
+ timeoutMs: Math.max(1, deadline - Date.now()),
236
+ });
237
+ const next = { ...(cache ?? {}) };
238
+ next[cacheKey] = { key: trust.key, hash: trust.hash, checkedAt: Date.now() };
239
+ try {
240
+ writeJsonAtomic(file, next);
241
+ }
242
+ catch {
243
+ // A cache write failure must never fail the lookup whose result it is only persisting.
244
+ }
245
+ return trust;
246
+ }