tumwater 0.4.2 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (305) hide show
  1. package/README.md +6 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +6 -6
  4. package/dist/src/agent/{run-agent.js → agent-run.js} +25 -12
  5. package/dist/src/agent/{run-settle.js → agent-settle.js} +48 -24
  6. package/dist/src/agent/{run-spawn.js → agent-spawn.js} +21 -12
  7. package/dist/src/agent/{run-stream.js → agent-stream.js} +4 -4
  8. package/dist/src/agent/backend-traits.js +2 -2
  9. package/dist/src/agent/claude/claude-account.js +1 -8
  10. package/dist/src/agent/claude/claude-backend.js +3 -3
  11. package/dist/src/agent/claude/claude-env.js +27 -31
  12. package/dist/src/agent/claude/claude-stream.js +111 -54
  13. package/dist/src/agent/codex/codex-account.js +9 -0
  14. package/dist/src/agent/codex/codex-backend.js +2 -2
  15. package/dist/src/agent/codex/codex-env.js +28 -15
  16. package/dist/src/agent/codex/codex-hook-trust.js +39 -20
  17. package/dist/src/agent/codex/codex-rollout.js +93 -56
  18. package/dist/src/agent/codex/codex-stream.js +1 -1
  19. package/dist/src/agent/env-scrub.js +18 -0
  20. package/dist/src/agent/image-input.js +68 -11
  21. package/dist/src/agent/notes-shim.js +2 -2
  22. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  23. package/dist/src/agent/opencode/opencode-catalog.js +50 -27
  24. package/dist/src/agent/opencode/opencode-config.js +32 -14
  25. package/dist/src/agent/opencode/opencode-env.js +12 -13
  26. package/dist/src/agent/opencode/opencode-stream.js +47 -25
  27. package/dist/src/agent/session-ledger.js +1 -1
  28. package/dist/src/agent/subscription-account.js +3 -3
  29. package/dist/src/backlog/backlog-cli-view.js +81 -41
  30. package/dist/src/backlog/backlog-cli.js +57 -26
  31. package/dist/src/backlog/backlog-eligibility.js +58 -102
  32. package/dist/src/backlog/backlog-md.js +5 -5
  33. package/dist/src/backlog/backlog-parts.js +65 -27
  34. package/dist/src/backlog/backlog-release.js +12 -12
  35. package/dist/src/backlog/backlog-structure.js +114 -61
  36. package/dist/src/backlog/backlog.js +1 -1
  37. package/dist/src/baseline/main-baseline.js +148 -84
  38. package/dist/src/budget/budget-fallback-view.js +100 -243
  39. package/dist/src/budget/budget-wiring.js +29 -65
  40. package/dist/src/budget/fallback-breaker.js +2 -2
  41. package/dist/src/build/build-check-detect.js +43 -14
  42. package/dist/src/build/build-check-events.js +2 -2
  43. package/dist/src/build/build-check-scoped.js +40 -16
  44. package/dist/src/build/build-check.js +13 -5
  45. package/dist/src/cli/cli-command-args.js +89 -52
  46. package/dist/src/cli/cli-flag-specs.js +42 -22
  47. package/dist/src/cli/cli-init.js +34 -12
  48. package/dist/src/cli/cli-query-commands.js +1 -1
  49. package/dist/src/cli/cli-repo-gate.js +2 -2
  50. package/dist/src/cli/cli-start.js +91 -58
  51. package/dist/src/cli/completion.js +82 -46
  52. package/dist/src/cli/config-commands.js +82 -71
  53. package/dist/src/cli/help.js +4 -6
  54. package/dist/src/cli/log-commands.js +43 -23
  55. package/dist/src/cli/notify-command.js +2 -2
  56. package/dist/src/cli/question-commands.js +86 -60
  57. package/dist/src/cli.js +181 -135
  58. package/dist/src/collections.js +31 -16
  59. package/dist/src/concurrency/check-slots.js +70 -0
  60. package/dist/src/concurrency/lock.js +22 -0
  61. package/dist/src/concurrency/run-permit.js +49 -0
  62. package/dist/src/concurrency/semaphore.js +46 -250
  63. package/dist/src/config/config-editable-keys.js +4 -7
  64. package/dist/src/config/config-fallbacks.js +81 -101
  65. package/dist/src/config/config-field-checks.js +66 -37
  66. package/dist/src/config/config-keys.js +5 -20
  67. package/dist/src/config/config-live.js +4 -28
  68. package/dist/src/config/{model-selector.js → config-model-selector.js} +4 -4
  69. package/dist/src/config/config-request.js +153 -102
  70. package/dist/src/config/config-schema.js +9 -11
  71. package/dist/src/config/config-selector-checks.js +1 -1
  72. package/dist/src/config/config-validation-agents.js +2 -2
  73. package/dist/src/config/config-validation-roles.js +4 -52
  74. package/dist/src/config/config-validation.js +4 -38
  75. package/dist/src/config/config-views.js +9 -34
  76. package/dist/src/config/config-write.js +6 -23
  77. package/dist/src/config/config.js +4 -5
  78. package/dist/src/doctor/doctor-backlog-bugs.js +7 -6
  79. package/dist/src/doctor/doctor-backlog-plans.js +24 -24
  80. package/dist/src/doctor/doctor-backlog.js +7 -5
  81. package/dist/src/doctor/doctor-check-names.js +3 -6
  82. package/dist/src/doctor/doctor-checks.js +5 -5
  83. package/dist/src/doctor/doctor-claude-checks.js +44 -28
  84. package/dist/src/doctor/doctor-codex-checks.js +6 -6
  85. package/dist/src/doctor/doctor-contention-checks.js +44 -112
  86. package/dist/src/doctor/doctor-fallback-checks.js +2 -2
  87. package/dist/src/doctor/doctor-holds.js +6 -45
  88. package/dist/src/doctor/doctor-install.js +1 -1
  89. package/dist/src/doctor/doctor-launch-services.js +1 -1
  90. package/dist/src/doctor/doctor-model-checks.js +178 -94
  91. package/dist/src/doctor/doctor-notify.js +2 -2
  92. package/dist/src/doctor/doctor-opencode-checks.js +5 -4
  93. package/dist/src/doctor/doctor-orphans.js +120 -20
  94. package/dist/src/doctor/{outcome.js → doctor-outcome.js} +7 -0
  95. package/dist/src/doctor/doctor-pi-checks.js +2 -2
  96. package/dist/src/doctor/doctor-principles.js +3 -3
  97. package/dist/src/doctor/doctor-project-checks.js +3 -3
  98. package/dist/src/doctor/doctor-role-notes.js +1 -1
  99. package/dist/src/doctor/doctor.js +8 -11
  100. package/dist/src/events/event-format.js +30 -54
  101. package/dist/src/events/event-labels.js +1 -5
  102. package/dist/src/events/event-legacy.js +1 -3
  103. package/dist/src/events/{notify.js → event-notify.js} +2 -2
  104. package/dist/src/events/event-read.js +1 -1
  105. package/dist/src/failure/failure-cluster.js +6 -6
  106. package/dist/src/failure/failure-data.js +8 -4
  107. package/dist/src/failure/failure-render.js +44 -34
  108. package/dist/src/failure/failure-state-change.js +4 -34
  109. package/dist/src/failure/{time-spend.js → failure-time-spend.js} +117 -84
  110. package/dist/src/files/files.js +30 -0
  111. package/dist/src/files/json-object.js +8 -8
  112. package/dist/src/files/stat-cache.js +12 -8
  113. package/dist/src/gates/budget-gates.js +85 -84
  114. package/dist/src/gates/gate-polls.js +27 -68
  115. package/dist/src/gates/pause-gates.js +16 -2
  116. package/dist/src/gates/readiness.js +1 -1
  117. package/dist/src/gates/role-cap-gates.js +44 -35
  118. package/dist/src/gates/{startup-gate.js → startup-gates.js} +7 -7
  119. package/dist/src/gates/{streak-gate.js → streak-gates.js} +75 -52
  120. package/dist/src/git/git.js +75 -45
  121. package/dist/src/git/worktree-pool.js +3 -6
  122. package/dist/src/gui/gui-admin-endpoints.js +3 -3
  123. package/dist/src/gui/gui-backlog-commands.js +4 -4
  124. package/dist/src/gui/gui-backlog-endpoints.js +61 -41
  125. package/dist/src/gui/gui-command.js +43 -24
  126. package/dist/src/gui/gui-history-endpoints.js +100 -52
  127. package/dist/src/gui/gui-operator-commands.js +22 -23
  128. package/dist/src/gui/gui-prompt-commands.js +23 -23
  129. package/dist/src/gui/gui-report-endpoints.js +1 -1
  130. package/dist/src/history/history-data.js +71 -44
  131. package/dist/src/history/history-queued.js +49 -27
  132. package/dist/src/history/history.js +51 -33
  133. package/dist/src/inbox/prompt-commands.js +59 -42
  134. package/dist/src/init/init.js +88 -40
  135. package/dist/src/merge/merge-backlog-conflicts.js +1 -1
  136. package/dist/src/merge/merge-core.js +1 -1
  137. package/dist/src/merge/merge-drain.js +8 -11
  138. package/dist/src/merge/merge-pipeline.js +6 -7
  139. package/dist/src/merge/merge-post-review-drain.js +10 -13
  140. package/dist/src/merge/merge-requeue.js +1 -1
  141. package/dist/src/merge/merge-slot.js +0 -8
  142. package/dist/src/merge/merge-verify.js +8 -18
  143. package/dist/src/notes-cli.js +52 -42
  144. package/dist/src/operator/operator-commands.js +76 -44
  145. package/dist/src/operator/operator-intent.js +3 -3
  146. package/dist/src/orchestrator/orchestrator-launch.js +31 -63
  147. package/dist/src/orchestrator/orchestrator-poll.js +27 -32
  148. package/dist/src/orchestrator/orchestrator.js +8 -21
  149. package/dist/src/orchestrator/scheduling-admit.js +101 -68
  150. package/dist/src/orchestrator/scheduling-scope.js +1 -1
  151. package/dist/src/orchestrator/scheduling-skip.js +1 -1
  152. package/dist/src/orchestrator/startup-migrations.js +1 -1
  153. package/dist/src/paths.js +8 -7
  154. package/dist/src/pi/pi-args.js +5 -2
  155. package/dist/src/pi/pi-backend.js +2 -2
  156. package/dist/src/pi/pi-bin.js +3 -3
  157. package/dist/src/pi/pi-event-line.js +1 -1
  158. package/dist/src/pi/pi-models.js +43 -29
  159. package/dist/src/pi/pi-settings.js +43 -20
  160. package/dist/src/pi/pi-stream.js +32 -6
  161. package/dist/src/pi/pi-watchdogs.js +12 -5
  162. package/dist/src/pi/pi.js +2 -2
  163. package/dist/src/pi-extension/context-shake.js +32 -20
  164. package/dist/src/pi-extension/kill-guard.js +69 -0
  165. package/dist/src/process/process-group.js +49 -5
  166. package/dist/src/process/process.js +8 -8
  167. package/dist/src/process/run-marker.js +31 -13
  168. package/dist/src/process/supervisor.js +40 -28
  169. package/dist/src/prompt/dialect.js +1 -1
  170. package/dist/src/prompt/gate-notes.js +2 -2
  171. package/dist/src/prompt/notes-backlog.js +66 -37
  172. package/dist/src/prompt/principles.js +6 -4
  173. package/dist/src/prompt/prompt-followup.js +12 -4
  174. package/dist/src/prompt/prompt.js +88 -38
  175. package/dist/src/report/report-data.js +53 -37
  176. package/dist/src/report/report-fold.js +16 -9
  177. package/dist/src/report/report-render.js +21 -194
  178. package/dist/src/report/report-since-data.js +4 -3
  179. package/dist/src/report/report-since-render.js +173 -0
  180. package/dist/src/report/report-usage-fold.js +8 -8
  181. package/dist/src/report/report.js +46 -22
  182. package/dist/src/review/post-merge-review-render.js +9 -3
  183. package/dist/src/review/post-merge-review.js +1 -1
  184. package/dist/src/review/review-precheck.js +11 -1
  185. package/dist/src/review/review.js +40 -153
  186. package/dist/src/review/secret-leaks.js +26 -16
  187. package/dist/src/roles/role-catalog.js +11 -3
  188. package/dist/src/roles/role-guidance.js +16 -12
  189. package/dist/src/roles/role-render.js +1 -14
  190. package/dist/src/roles/role-view.js +9 -21
  191. package/dist/src/run/acceptance-greps.js +88 -45
  192. package/dist/src/run/run-apply.js +12 -6
  193. package/dist/src/run/run-detail-data.js +121 -68
  194. package/dist/src/run/run-detail-render.js +92 -35
  195. package/dist/src/run/run-detail.js +72 -41
  196. package/dist/src/run/run-prompt.js +63 -50
  197. package/dist/src/run/run-resume.js +76 -29
  198. package/dist/src/run/run-stage.js +135 -84
  199. package/dist/src/run/run-timing.js +8 -2
  200. package/dist/src/run/run-usage.js +31 -1
  201. package/dist/src/run/run-verdict.js +119 -50
  202. package/dist/src/run/stage-check.js +49 -32
  203. package/dist/src/run/stage-checks-shared.js +28 -0
  204. package/dist/src/run/stage-name-checks.js +111 -92
  205. package/dist/src/run/stage-path-checks.js +69 -42
  206. package/dist/src/run/stage-prose-checks.js +55 -40
  207. package/dist/src/runner/runner-outcome.js +6 -1
  208. package/dist/src/runner/runner-pi.js +3 -10
  209. package/dist/src/runner/runner-recovery.js +1 -1
  210. package/dist/src/runner/{revision.js → runner-revision.js} +1 -1
  211. package/dist/src/runner/runner-run.js +97 -35
  212. package/dist/src/runner/runner-state-load.js +12 -9
  213. package/dist/src/runner/runner.js +41 -25
  214. package/dist/src/scheduling/claims.js +0 -4
  215. package/dist/src/scheduling/scheduling.js +53 -66
  216. package/dist/src/status/status-data.js +2 -2
  217. package/dist/src/status/status-role-notes.js +2 -2
  218. package/dist/src/status/status-roles.js +9 -17
  219. package/dist/src/team/{error-storm.js → team-error-storm.js} +1 -1
  220. package/dist/src/team/{failure-spread.js → team-failure-spread.js} +5 -5
  221. package/dist/src/team/team-hold.js +98 -65
  222. package/dist/src/team/{orchestrator-info.js → team-orchestrator-info.js} +2 -15
  223. package/dist/src/team/team-polls.js +28 -126
  224. package/dist/src/team/{reclaim.js → team-reclaim.js} +61 -31
  225. package/dist/src/team/team-state.js +4 -13
  226. package/dist/src/text/markdown.js +1 -1
  227. package/dist/src/text/phrases.js +20 -15
  228. package/dist/src/text/text.js +3 -3
  229. package/dist/src/ui/alert-feeds.js +49 -73
  230. package/dist/src/ui/badges.js +3 -13
  231. package/dist/src/ui/gui/gui-client-boot.js +2 -2
  232. package/dist/src/ui/gui/gui-client-composer.js +1 -1
  233. package/dist/src/ui/gui/gui-client-drawer-config.js +7 -17
  234. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  235. package/dist/src/ui/gui/gui-client-history.js +28 -14
  236. package/dist/src/ui/gui/gui-client-model.js +0 -12
  237. package/dist/src/ui/gui/gui-client-pending.js +7 -6
  238. package/dist/src/ui/gui/gui-client-report-chart.js +5 -5
  239. package/dist/src/ui/gui/gui-client-report.js +1 -1
  240. package/dist/src/ui/gui/gui-client-roles.js +0 -1
  241. package/dist/src/ui/gui/gui-client-settings.js +43 -84
  242. package/dist/src/ui/gui/gui-client-team.js +4 -4
  243. package/dist/src/ui/gui/gui-client.js +7 -1
  244. package/dist/src/ui/progress-data.js +53 -40
  245. package/dist/src/ui/run-progress-model.js +9 -2
  246. package/dist/src/ui/status-model.js +2 -24
  247. package/dist/src/ui/status-payload.js +3 -7
  248. package/dist/src/ui/status-render.js +5 -9
  249. package/dist/src/ui/team-alert-backlog.js +6 -6
  250. package/dist/src/ui/team-alert-status.js +9 -170
  251. package/dist/src/ui/team-alert-tail.js +19 -215
  252. package/dist/src/ui/team-alerts.js +7 -8
  253. package/dist/src/ui/transcript-run.js +27 -16
  254. package/dist/src/ui/tui/tui-editor-answer.js +12 -10
  255. package/dist/src/ui/tui/tui-editor-attach.js +4 -3
  256. package/dist/src/ui/tui/tui-editor-backends.js +4 -3
  257. package/dist/src/ui/tui/tui-editor-brief.js +6 -9
  258. package/dist/src/ui/tui/tui-editor-budget.js +4 -3
  259. package/dist/src/ui/tui/tui-editor-config.js +80 -68
  260. package/dist/src/ui/tui/tui-editor-entry.js +12 -20
  261. package/dist/src/ui/tui/tui-editor-pause.js +51 -57
  262. package/dist/src/ui/tui/tui-editor-prompt.js +9 -13
  263. package/dist/src/ui/tui/tui-editor-queued.js +11 -14
  264. package/dist/src/ui/tui/tui-editor-quiet.js +4 -3
  265. package/dist/src/ui/tui/tui-editor-release.js +4 -3
  266. package/dist/src/ui/tui/tui-editor-retire.js +7 -7
  267. package/dist/src/ui/tui/tui-editor-role-enabled.js +4 -3
  268. package/dist/src/ui/tui/tui-editor-role-file.js +4 -4
  269. package/dist/src/ui/tui/tui-editor-role-key.js +4 -3
  270. package/dist/src/ui/tui/tui-editor-role-model.js +97 -15
  271. package/dist/src/ui/tui/tui-editor-search.js +4 -3
  272. package/dist/src/ui/tui/tui-editor-session.js +17 -4
  273. package/dist/src/ui/tui/tui-editors.js +1 -7
  274. package/dist/src/ui/tui/tui-frame.js +182 -134
  275. package/dist/src/ui/tui/tui-guard.js +20 -0
  276. package/dist/src/ui/tui/tui-input.js +159 -92
  277. package/dist/src/ui/tui/tui-keymap.js +51 -29
  278. package/dist/src/ui/tui/tui-keys.js +1 -1
  279. package/dist/src/ui/tui/tui-pane-activity.js +3 -3
  280. package/dist/src/ui/tui/tui-pane-backlog.js +74 -36
  281. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  282. package/dist/src/ui/tui/tui-pane-health.js +252 -228
  283. package/dist/src/ui/tui/tui-pane-history.js +61 -70
  284. package/dist/src/ui/tui/tui-pane-pending.js +284 -247
  285. package/dist/src/ui/tui/tui-pane-queued.js +25 -27
  286. package/dist/src/ui/tui/tui-pane-report.js +2 -1
  287. package/dist/src/ui/tui/tui-pane-session.js +11 -7
  288. package/dist/src/ui/tui/tui-pane-transcript.js +19 -31
  289. package/dist/src/ui/tui/tui-pane.js +14 -11
  290. package/dist/src/ui/tui/tui-panes.js +29 -32
  291. package/dist/src/ui/tui/tui.js +3 -4
  292. package/dist/src/verdict/fix-claim.js +76 -54
  293. package/dist/src/verdict/refusal.js +1 -1
  294. package/package.json +5 -5
  295. package/dist/src/concurrency/provider-lease.js +0 -105
  296. package/dist/src/runner/model-fallback.js +0 -76
  297. package/dist/src/runner/runner-fallback.js +0 -66
  298. package/dist/src/ui/tui/tui-editor-claude-reserve.js +0 -24
  299. package/dist/src/ui/tui/tui-editor-provider-caps.js +0 -27
  300. /package/dist/src/backlog/{question-answer.js → backlog-question-answer.js} +0 -0
  301. /package/dist/src/doctor/{probe.js → doctor-probe.js} +0 -0
  302. /package/dist/src/gates/{disk-gate.js → disk-gates.js} +0 -0
  303. /package/dist/src/operator/{retire.js → operator-retire.js} +0 -0
  304. /package/dist/src/operator/{revert.js → operator-revert.js} +0 -0
  305. /package/dist/src/runner/{leftover.js → runner-leftover.js} +0 -0
@@ -1,12 +1,14 @@
1
1
  /** The seam's answer to "who in this team can read images" (plans/image-input.md
2
- * "Capability"). One resolver, `seamImageInput`, is the only place that rule lives, so the
3
- * answer is never reached two ways. It lives at runtime level because it needs the backend
4
- * registry: a claude seam is always capable through the AgentBackend.imageInput hook, a pi
5
- * seam reads the model's `input` in models.json, and any other backend answers null unless it
6
- * declares a hook. */
7
- import { configForRole } from "../config/config-views.js";
8
- import { seamAgent } from "../config/model-selector.js";
9
- import { piModelImageInput, piModelsPath, readPiProviders } from "../pi/pi-models.js";
2
+ * "Capability"). One resolver, `seamImageInput`, is the only place that rule lives; the reroute
3
+ * target (imageReroute) and the per-role surfaces (roleImageInfo) both read it, so the answer
4
+ * is never reached two ways. It lives at runtime level because it needs the backend registry:
5
+ * a claude seam is always capable through the AgentBackend.imageInput hook, a pi seam reads the
6
+ * model's `input` in models.json, and any other backend answers null unless it declares a hook. */
7
+ import { configForRole, roleSeamTier, withModelOverrides } from "../config/config-views.js";
8
+ import { BORROW_ORDER, resolveTierFallbacks } from "../config/config-fallbacks.js";
9
+ import { seamAgent } from "../config/config-model-selector.js";
10
+ import { modelLabel } from "./backend-traits.js";
11
+ import { piModelImageInput, piPairFree, piModelsPath, readPiProviders } from "../pi/pi-models.js";
10
12
  import { piListedImageInput } from "../pi/pi-listed-models.js";
11
13
  import { backendById } from "./agent-backend.js";
12
14
  /** One seam's image-input capability (plans/image-input.md "Capability"): `true` when the
@@ -32,12 +34,67 @@ export function seamImageInput(seam, providers, listed = null) {
32
34
  return null;
33
35
  return piModelImageInput(providers, seam.provider, seam.model) ?? piListedImageInput(listed, seam.provider, seam.model);
34
36
  }
37
+ /** The image-capable reroute target for `role`, or null when no peer tier can read images
38
+ * (plans/image-input.md "Reroute target"). Walks the role's seam tier through BORROW_ORDER,
39
+ * skipping the tier itself — small → default → strong, default → strong → small, strong →
40
+ * default (a strong-tier seam never drops to small, the same rule as the budget fallback) —
41
+ * and takes the first tier whose model reads images. `regime` picks the pair set (see
42
+ * RerouteRegime); `modelsPath` and `account` are injectable so tests need no real pi install
43
+ * or account. */
44
+ export function imageReroute(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, providers = null, listed = null) {
45
+ const tier = roleSeamTier(config, role);
46
+ const candidates = BORROW_ORDER[tier].filter((t) => t !== tier);
47
+ if (candidates.length === 0)
48
+ return null;
49
+ const providerMap = providers ?? readPiProviders(modelsPath);
50
+ const capable = (rc) => seamImageInput(rc, providerMap, listed) === true;
51
+ if (regime === "primary") {
52
+ for (const t of candidates) {
53
+ const rc = withModelOverrides(config, t, {});
54
+ if (capable(rc))
55
+ return { tier: t, config: rc };
56
+ }
57
+ return null;
58
+ }
59
+ // Fallback regime: only the tiers' free fallback pairs qualify (resolveTierFallbacks over the
60
+ // price check), never a priced primary model — a reroute must not spend past the cap.
61
+ const resolved = resolveTierFallbacks(config, (p) =>
62
+ // The free-ness lookup reads the one claude account held here (part 2a/9).
63
+ piPairFree(providerMap, p, (b) => (b === "claude" ? account : null)));
64
+ for (const t of candidates) {
65
+ const pair = resolved[t].pair;
66
+ if (!pair)
67
+ continue;
68
+ const rc = withModelOverrides(config, t, {
69
+ provider: pair.provider,
70
+ model: pair.model,
71
+ thinking: pair.thinking,
72
+ });
73
+ if (capable(rc))
74
+ return { tier: t, config: rc };
75
+ }
76
+ return null;
77
+ }
78
+ /** The config of imageReroute, for callers that want only the target model. */
79
+ export function imageRerouteTarget(config, role, regime = "primary", modelsPath = piModelsPath(), account = null, providers = null, listed = null) {
80
+ return imageReroute(config, role, regime, modelsPath, account, providers, listed)?.config ?? null;
81
+ }
82
+ /** A seam's model as one readable token, the same label the doctor's `image input` check used
83
+ * (`claude/<id>` for a claude seam, `provider/model` for pi), delegated to backend-traits'
84
+ * modelLabel. Used by roleImageInfo alone. */
85
+ function seamImageLabel(backend, provider, model) {
86
+ return modelLabel(backend, provider, model);
87
+ }
35
88
  /** Compute RoleImageInfo for one role. `modelsPath` and `listed` are injectable; the providers
36
- * catalog is read (stat-cached) here. The reroute field is a literal null — the peer-tier image
37
- * reroute was subtracted (part 5g/6), so no role names a reroute target; 5h removes the field. */
89
+ * catalog is read (stat-cached) here and also handed to imageReroute so a poll that asks many
90
+ * roles still parses the catalog once. */
38
91
  export function roleImageInfo(config, role, modelsPath = piModelsPath(), listed = null) {
39
92
  const providers = readPiProviders(modelsPath);
40
93
  const rc = configForRole(config, role);
41
94
  const imageInput = seamImageInput(rc, providers, listed);
42
- return { imageInput, imageReroute: null };
95
+ const rr = imageReroute(config, role, "primary", modelsPath, null, providers, listed);
96
+ const imageRerouteStr = rr
97
+ ? `${rr.tier} (${seamImageLabel(rr.config.agent, rr.config.provider, rr.config.model)})`
98
+ : null;
99
+ return { imageInput, imageReroute: imageRerouteStr };
43
100
  }
@@ -1,5 +1,5 @@
1
1
  /** Installing the per-team `tumwater-notes` shim every backend's run finds on its PATH
2
- * (plans/notes-command.md "Reaching it from a run"): run-spawn calls `ensureNotesShim(root)`
2
+ * (plans/notes-command.md "Reaching it from a run"): agent-spawn calls `ensureNotesShim(root)`
3
3
  * before building each invocation, and the backend prepends the returned dir to the run's
4
4
  * PATH (backendBaseEnv's `binDir`, src/process/run-marker.ts). The shim is a two-line `sh`
5
5
  * script that execs the compiled notes CLI, so a model's shell — pi's, Claude Code's or
@@ -20,7 +20,7 @@ export const NOTES_SHIM_NAME = "tumwater-notes";
20
20
  /** Install the `tumwater-notes` shim under `<root>/.tumwater/bin` and return that directory.
21
21
  * The shim is written atomically with mode 0755, only when its content differs from what is
22
22
  * already there (a redeploy that changes `<dist>` rewrites it). Never throws: a failure to
23
- * write is the caller's to absorb — run-spawn logs one warning and runs without the shim. */
23
+ * write is the caller's to absorb — agent-spawn logs one warning and runs without the shim. */
24
24
  export function ensureNotesShim(root, execPath = process.execPath) {
25
25
  const dir = notesShimDir(root);
26
26
  const target = path.join(dir, NOTES_SHIM_NAME);
@@ -1,7 +1,7 @@
1
1
  /** The opencode adapter for the agent seam (plans/opencode-backend.md "The invocation",
2
2
  * "Sessions", §6). It shapes one run's argv/stdin/env, translates opencode's `run --format
3
3
  * json` into the canonical stream, runs the authoritative session export after the process
4
- * exits, and classifies the exit's stderr — everything backend-specific. src/agent/run-spawn.ts
4
+ * exits, and classifies the exit's stderr — everything backend-specific. src/agent/agent-spawn.ts
5
5
  * spawns the run and records this run in the session ledger; opencode mints its own session id
6
6
  * (a `ses_…` id, or the resumed record's id), which `invocation` returns so the ledger can
7
7
  * carry it.
@@ -13,7 +13,7 @@
13
13
  * the only thing that stops a follow-up running against an empty session. Anything else mints a
14
14
  * fresh id (design §4).
15
15
  *
16
- * Registration lives in src/agent/run-agent.ts beside the pi and claude adapters; a test selects
16
+ * Registration lives in src/agent/agent-run.ts beside the pi and claude adapters; a test selects
17
17
  * opencode by setting `config.agent = "opencode"` on a run. A run carrying a note gets the
18
18
  * notes path in its environment (the `tumwater-notes` command on PATH reads it), as the pi and
19
19
  * claude adapters do. */
@@ -33,6 +33,53 @@ const defaultFetch = async (url, timeoutMs) => {
33
33
  export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
34
34
  return fetchFn(CATALOG_URL, CATALOG_FETCH_TIMEOUT_MS);
35
35
  }
36
+ /** The catalog root `parseOpencodeCatalog` iterates: models.dev's live api.json puts the provider
37
+ * map at the top level, OpenCode's cached copy wraps that same map under a `body` key. */
38
+ function catalogRoot(body) {
39
+ const root = isJsonObject(body) ? body : {};
40
+ // models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
41
+ const wrapped = root.body;
42
+ return isJsonObject(wrapped) ? wrapped : root;
43
+ }
44
+ /** The free/priced reads from a per-model cost block: whether it is present and yields two zero
45
+ * per-M prices. A missing cost block is unknown price data, and treating it as $0 is the unsafe
46
+ * direction for a budget gate. */
47
+ function modelPricing(m) {
48
+ const cost = isJsonObject(m.cost) ? m.cost : undefined;
49
+ const input = cost?.input ?? 0;
50
+ const output = cost?.output ?? 0;
51
+ return {
52
+ costed: cost !== undefined,
53
+ free: cost !== undefined && input === 0 && output === 0,
54
+ inputPerM: input,
55
+ outputPerM: output,
56
+ };
57
+ }
58
+ /** The effort levels from a per-model object: the `values` of the `reasoning_options` entries
59
+ * whose `type` is `"effort"`. */
60
+ function modelEfforts(m) {
61
+ if (!Array.isArray(m.reasoning_options))
62
+ return [];
63
+ return m.reasoning_options.flatMap((r) => isJsonObject(r) && r.type === "effort" && Array.isArray(r.values) ? r.values : []);
64
+ }
65
+ /** Build one catalog model from an untrusted per-model object, null when it is malformed. */
66
+ function catalogModelFrom(m) {
67
+ const pricing = modelPricing(m);
68
+ const model = { free: pricing.free };
69
+ if (pricing.costed) {
70
+ model.inputPerM = pricing.inputPerM;
71
+ model.outputPerM = pricing.outputPerM;
72
+ }
73
+ if (isJsonObject(m.limit) && m.limit.context !== undefined)
74
+ model.context = m.limit.context;
75
+ if (isJsonObject(m.modalities) && Array.isArray(m.modalities.input)) {
76
+ model.images = m.modalities.input.includes("image");
77
+ }
78
+ const efforts = modelEfforts(m);
79
+ if (efforts.length > 0)
80
+ model.efforts = efforts;
81
+ return isOpencodeCatalogModel(model) ? model : null;
82
+ }
36
83
  /** Parse models.dev's `api.json` into the models the config routes through OpenCode: always the
37
84
  * `opencode` provider, plus every provider named in `providers` (the config's
38
85
  * `opencode/<provider>/…` routes — `opencode-go` and so on). The live `GET /api.json` returns
@@ -43,11 +90,7 @@ export async function fetchOpencodeCatalog(fetchFn = defaultFetch) {
43
90
  * provider or model is skipped, never thrown on. */
44
91
  export function parseOpencodeCatalog(body, providers) {
45
92
  const out = {};
46
- const root = isJsonObject(body) ? body : {};
47
- // models.dev's api.json IS the provider map; OpenCode's cached copy wraps that map under `body`.
48
- const wrapped = root.body;
49
- const catalog = isJsonObject(wrapped) ? wrapped : root;
50
- for (const [provider, providerInfo] of Object.entries(catalog)) {
93
+ for (const [provider, providerInfo] of Object.entries(catalogRoot(body))) {
51
94
  if (provider !== "opencode" && !providers.includes(provider))
52
95
  continue;
53
96
  if (!isJsonObject(providerInfo) || !isJsonObject(providerInfo.models))
@@ -55,28 +98,8 @@ export function parseOpencodeCatalog(body, providers) {
55
98
  for (const [id, m] of Object.entries(providerInfo.models)) {
56
99
  if (!isJsonObject(m))
57
100
  continue;
58
- // Only a present cost block marks a model free: a missing cost block is unknown price data,
59
- // and treating it as $0 is the unsafe direction for a budget gate.
60
- const cost = isJsonObject(m.cost) ? m.cost : undefined;
61
- const input = cost?.input ?? 0;
62
- const output = cost?.output ?? 0;
63
- const model = { free: cost !== undefined && input === 0 && output === 0 };
64
- if (cost !== undefined) {
65
- model.inputPerM = input;
66
- model.outputPerM = output;
67
- }
68
- if (isJsonObject(m.limit) && m.limit.context !== undefined)
69
- model.context = m.limit.context;
70
- if (isJsonObject(m.modalities) && Array.isArray(m.modalities.input)) {
71
- model.images = m.modalities.input.includes("image");
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))
101
+ const model = catalogModelFrom(m);
102
+ if (model !== null)
80
103
  out[`${provider}/${id}`] = model;
81
104
  }
82
105
  }
@@ -38,29 +38,47 @@ const DENIED_PERMISSIONS = [
38
38
  function isCommentStart(src, i) {
39
39
  return src[i] === "/" && (src[i + 1] === "/" || src[i + 1] === "*");
40
40
  }
41
+ /** True when `c` is a JSONC whitespace character. */
42
+ function isWhitespace(c) {
43
+ return c === " " || c === "\t" || c === "\n" || c === "\r";
44
+ }
45
+ /** Advance `j` past a `//` line comment (a caller-supplied position where `src[j] === "/"`
46
+ * and `src[j + 1] === "/"`), not past the terminating newline. */
47
+ function skipLineComment(src, j) {
48
+ while (j < src.length && src[j] !== "\n")
49
+ j += 1;
50
+ return j;
51
+ }
52
+ /** Advance `j` past a `/*`-style block comment (a caller-supplied position where `src[j] ===
53
+ * "/"` and `src[j + 1] === "*"`), ending after its closing two characters. An unterminated
54
+ * comment leaves `j` at `src.length + 2`, overshooting past the end — today's behaviour, kept. */
55
+ function skipBlockComment(src, j) {
56
+ j += 2;
57
+ while (j < src.length && !(src[j] === "*" && src[j + 1] === "/"))
58
+ j += 1;
59
+ j += 2;
60
+ return j;
61
+ }
62
+ /** Advance `j` past one comment, dispatching on the second character: `//` to the line-comment
63
+ * scan, `/*` to the block-comment scan (a caller-supplied position where `isCommentStart`
64
+ * holds). */
65
+ function skipComment(src, j) {
66
+ return src[j + 1] === "/" ? skipLineComment(src, j) : skipBlockComment(src, j);
67
+ }
41
68
  /** Advance `j` past any whitespace and comments, for a position that is never inside a string.
42
69
  * Returns the index of the next significant (non-comment, non-whitespace) character, or
43
70
  * `src.length` when the input ends in comments/whitespace. */
44
71
  function skipWhitespaceAndComments(src, j) {
45
72
  while (j < src.length) {
46
- const c = src[j];
47
- if (c === " " || c === "\t" || c === "\n" || c === "\r") {
73
+ if (isWhitespace(src[j])) {
48
74
  j += 1;
49
- continue;
50
75
  }
51
- if (c === "/" && src[j + 1] === "/") {
52
- while (j < src.length && src[j] !== "\n")
53
- j += 1;
54
- continue;
76
+ else if (isCommentStart(src, j)) {
77
+ j = skipComment(src, j);
55
78
  }
56
- if (c === "/" && src[j + 1] === "*") {
57
- j += 2;
58
- while (j < src.length && !(src[j] === "*" && src[j + 1] === "/"))
59
- j += 1;
60
- j += 2;
61
- continue;
79
+ else {
80
+ break;
62
81
  }
63
- break;
64
82
  }
65
83
  return j;
66
84
  }
@@ -14,22 +14,21 @@
14
14
  * the model to pass `timeout` for the check (§5). `XDG_CONFIG_HOME` and its siblings are NOT
15
15
  * set: OpenCode's shell-tool children inherit the environment, and pointing XDG at a private
16
16
  * dir would stop `git` reading `~/.config/git/config`, losing commits' author identity (§3). */
17
+ import { scrubEnv } from "../env-scrub.js";
17
18
  /** Build the opencode process environment from `base`. Never mutates `base`. */
18
19
  export function opencodeEnv(base, opts) {
19
- const env = {};
20
- for (const [key, value] of Object.entries(base)) {
20
+ return scrubEnv(base, {
21
21
  // Everything an inherited OpenCode session set is scrubbed — server, config, database,
22
22
  // client and session id — except the operator's API key, which is operator intent and must
23
23
  // survive. `AI_AGENT` goes too, so the shell tool sets its own (finding 16).
24
- if (key === "OPENCODE" || (key.startsWith("OPENCODE_") && key !== "OPENCODE_API_KEY") || key === "AI_AGENT")
25
- continue;
26
- env[key] = value;
27
- }
28
- env.OPENCODE_DISABLE_CLAUDE_CODE = "1";
29
- env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
30
- env.OPENCODE_DISABLE_AUTOUPDATE = "1";
31
- env.OPENCODE_DISABLE_FILEWATCHER = "1";
32
- env.OPENCODE_DB = opts.dbPath;
33
- env.OPENCODE_CONFIG_CONTENT = opts.configContent;
34
- return env;
24
+ keep: (key) => !(key === "OPENCODE" || (key.startsWith("OPENCODE_") && key !== "OPENCODE_API_KEY") || key === "AI_AGENT"),
25
+ then: (env) => {
26
+ env.OPENCODE_DISABLE_CLAUDE_CODE = "1";
27
+ env.OPENCODE_DISABLE_CLAUDE_CODE_SKILLS = "1";
28
+ env.OPENCODE_DISABLE_AUTOUPDATE = "1";
29
+ env.OPENCODE_DISABLE_FILEWATCHER = "1";
30
+ env.OPENCODE_DB = opts.dbPath;
31
+ env.OPENCODE_CONFIG_CONTENT = opts.configContent;
32
+ },
33
+ });
35
34
  }
@@ -28,49 +28,71 @@ const TOOL_NAMES = {
28
28
  /** A Retry-After hint spelled out in a rate-limit error's message text ("retry after 30s" /
29
29
  * "retry in 30 seconds"). */
30
30
  const RETRY_AFTER = /retry (?:after|in) (\d+)\s*s(?:econds?)?\b/i;
31
- /** How an `error` line classifies into the canonical `tumwaterKind` (and, for a transient
32
- * backend, its kind), trusted by `PiStreamParser` before its text regexes. The error's
33
- * `type` — the provider signal the plan's table maps (provider.auth, provider.no-route,
34
- * unknown) — is consulted FIRST, the message text second (plans/opencode-backend.md §8 and
35
- * its classification table). */
36
- function classifyError(err) {
37
- const message = typeof err.message === "string" ? err.message : "";
38
- const type = typeof err.type === "string" ? err.type : "";
39
- const status = err.status;
40
- // The free tier refuses intermittently (a provider failure, not a config error): three
41
- // refusals in a row move the tier to its fallback through the existing model-failure
42
- // episode. Typed `provider.auth` WITH status 403 — the free tier's own refusal — or the
43
- // text naming the free tier. A `provider.auth` with any other status (a 401 bad
44
- // credential) is not a refusal and falls through to the config-error row below.
31
+ ;
32
+ /** Is this the free tier's intermittent refusal (a provider failure, not a config error)?
33
+ * Three refusals in a row move the tier to its fallback through the existing model-failure
34
+ * episode. Typed `provider.auth` WITH status 403 — the free tier's own refusal — or the
35
+ * text naming the free tier. A `provider.auth` with any other status (a 401 bad
36
+ * credential) is not a refusal and falls through to the config-error row. */
37
+ function freeTierClassification(type, status, message) {
45
38
  if ((type === "provider.auth" && status === 403) || /free\s?tier|FreeTierError/i.test(message))
46
39
  return { kind: "transientBackend", backendKind: "server", freeTier: true };
47
- // Rate limiting — 429 or rate-limit text — is the world saying "later", inherently
48
- // retryable; carry the echoed Retry-After hint when the message spells it.
40
+ return undefined;
41
+ }
42
+ /** Is this a rate limit (429 or rate-limit text) — the world saying "later", inherently
43
+ * retryable? Carry the echoed Retry-After hint when the message spells it. */
44
+ function rateLimitClassification(status, message) {
49
45
  if (status === 429 || /\b429\b|rate limit|too many requests/i.test(message)) {
50
46
  const retry = RETRY_AFTER.exec(message);
51
47
  return { kind: "transientRateLimit", ...(retry ? { retryAfterSeconds: Number(retry[1]) } : {}) };
52
48
  }
53
- // An unknown model or variant, and the malformed `ses_` id, are permanent config errors
54
- // (a misconfiguration, not a transient outage): typed `provider.no-route` / `unknown`, or
55
- // the model-not-found / 401 text.
49
+ return undefined;
50
+ }
51
+ /** Is this a permanent config error — an unknown model or variant, or the malformed `ses_`
52
+ * id (a misconfiguration, not a transient outage): typed `provider.no-route` / `unknown`,
53
+ * or the model-not-found / 401 text? */
54
+ function configErrorClassification(type, status, message) {
56
55
  if (type === "provider.no-route" ||
57
56
  type === "unknown" ||
58
57
  status === 401 ||
59
58
  /unknown model|unknown provider|model not found|provider not found|unauthorized|status\s*401|\b401\b/i.test(message))
60
59
  return { kind: "configError" };
61
- // Context overflow that compaction could not fix. Matched STRICTLY (the same shape pi's
62
- // own CONTEXT_ERROR matches), so a message that merely mentions "context" or "too long"
63
- // in passing — a plugin error, say — is never misread as an overflow that would then be
64
- // trusted by PiStreamParser over its own tighter regex.
60
+ return undefined;
61
+ }
62
+ /** Did context overflow in a way compaction could not fix? Matched STRICTLY (the same shape
63
+ * pi's own CONTEXT_ERROR matches), so a message that merely mentions "context" or "too long"
64
+ * in passing — a plugin error, say — is never misread as an overflow that would then be
65
+ * trusted by PiStreamParser over its own tighter regex. */
66
+ function contextOverflowClassification(message) {
65
67
  if (/context (size|length|window)?\s*(has been |was )?exceeded|exceeds? (the )?context|too (long|large) for .*context/i.test(message))
66
68
  return { kind: "contextExceeded" };
67
- // Transient server, connection and timeout failures.
69
+ return undefined;
70
+ }
71
+ /** Is this a transient server, connection or timeout failure? The server statuses and text
72
+ * first, then the shared connection/timeout prose vocabulary (see `transientProseKind`). */
73
+ function transientClassification(message) {
68
74
  if (/\b(500|501|502|503|504|529)\b|internal server error|bad gateway|service unavailable|gateway timeout|overloaded/i.test(message))
69
75
  return { kind: "transientBackend", backendKind: "server" };
70
76
  const prose = transientProseKind(message);
71
77
  if (prose)
72
78
  return prose;
73
- return {};
79
+ return undefined;
80
+ }
81
+ /** How an `error` line classifies into the canonical `tumwaterKind` (and, for a transient
82
+ * backend, its kind), trusted by `PiStreamParser` before its text regexes. The error's
83
+ * `type` — the provider signal the plan's table maps (provider.auth, provider.no-route,
84
+ * unknown) — is consulted FIRST, the message text second (plans/opencode-backend.md §8 and
85
+ * its classification table). */
86
+ function classifyError(err) {
87
+ const message = typeof err.message === "string" ? err.message : "";
88
+ const type = typeof err.type === "string" ? err.type : "";
89
+ const status = err.status;
90
+ return (freeTierClassification(type, status, message) ??
91
+ rateLimitClassification(status, message) ??
92
+ configErrorClassification(type, status, message) ??
93
+ contextOverflowClassification(message) ??
94
+ transientClassification(message) ??
95
+ {});
74
96
  }
75
97
  /** The step's token counts and cost → pi's canonical usage block: the four counts, their sum
76
98
  * as `totalTokens`, and `cost.total` = the step's USD cost (plans/opencode-backend.md §6).
@@ -39,7 +39,7 @@ export function recordSession(dir, record) {
39
39
  }
40
40
  }
41
41
  /** The one run's ledger record, minus the id the two recording sites mint differently — the
42
- * pre-spawn id in run-spawn.ts, or the stream-minted session id in run-stream.ts. One place
42
+ * pre-spawn id in agent-spawn.ts, or the stream-minted session id in agent-stream.ts. One place
43
43
  * for the record's non-id shape (agent, kind, name, cwd, the optional model and the stamp), so
44
44
  * the two appends cannot drift on the model spread or the required fields. `agent` is the
45
45
  * owning backend's id; the run options supply the rest. */
@@ -31,9 +31,9 @@ export function subscriptionWindowRejected(run) {
31
31
  /** The subscription account's used-up usage window, or null: a window reading `rejected` with
32
32
  * no overage, or (with `includeOverage`, the default) a window whose current reading shows
33
33
  * `isUsingOverage`, and a `resetsAt` still ahead of `now`; among several, the one that resets
34
- * last. With a `reserve` (config `backends.<id>.windowReserve`), a window whose utilization
35
- * has reached its type's fraction counts too, and comes back with `reserve: true`; a rejected
36
- * or overage window outranks a reserve one, so the flag is set only when no window is used up
34
+ * last. With a `reserve` (the subscription-window reserve), a window whose utilization has
35
+ * reached its type's fraction counts too and comes back with `reserve: true`; a rejected or
36
+ * overage window outranks a reserve one, so the flag is set only when no window is used up
37
37
  * for real. The budget gate's window move (pollBudgetGate) reads the default; the team hold
38
38
  * (windowObservation) passes `includeOverage: false`, because overage never holds a
39
39
  * seam. Only subscription billing produces one; a missing account reads null. */
@@ -10,6 +10,12 @@ import { groupBlockedSeries, partsLabel } from "./backlog-index-data.js";
10
10
  import { rejectUnknownArgs } from "../cli/cli-flag-specs.js";
11
11
  import { refuseTeamWorkspace } from "../cli/cli-repo-gate.js";
12
12
  import { sectionNoun, showEntry } from "./backlog-write.js";
13
+ /** `bug --list --done -n` / `plan --list --done -n` ceiling: bounds how many finished
14
+ * entries one list shows, so a typo'd huge count fails fast with the shared `must be between
15
+ * 1 and <max>` wording (the logs/history/report pattern) instead of silently meaning
16
+ * "everything". The finished lists are small (PLANS.md/BUGS.md), so the cap never meaningfully
17
+ * binds — it exists for the uniform count-flag contract and the clear over-range rebuke. */
18
+ const BACKLOG_MAX_LISTED = 100_000;
13
19
  /** Ascending positions as runs: consecutive ones join with an en dash (`13–17`), runs separate
14
20
  * with commas (`13–15, 18`). */
15
21
  function positionRuns(positions) {
@@ -59,7 +65,7 @@ function parseListArgs(args, command) {
59
65
  }
60
66
  if (arg === "-n") {
61
67
  rejectRepeatFlag(count !== null, "-n");
62
- count = parseCountFlag("-n", args[++i]);
68
+ count = parseCountFlag("-n", args[++i], BACKLOG_MAX_LISTED);
63
69
  continue;
64
70
  }
65
71
  if (arg === "--list") {
@@ -114,10 +120,10 @@ function announceFinishedList(root, fileName, bugSection, grep, count, json) {
114
120
  const header = pattern === null ? `${what} in ${fileName}, newest first:` : `${what} in ${fileName} matching "${pattern}", newest first:`;
115
121
  say(`${header}\n${lines.join("\n")}`);
116
122
  }
117
- /** The active half of the list shell: the open entries with their holds, in-progress claim
118
- * marks and age suffixes, optionally reduced to the ready ones, as `--json` or prose, grouping
119
- * a plan's blocked parts into one line (groupBlockedSeries). */
120
- function announceActiveList(root, fileName, command, bugSection, sectionTitle, grep, ready, json) {
123
+ /** The active list's read-and-filter phase: reads the section's entries and each position's
124
+ * hold and live claimer, numbers them unfiltered, keeps the `--grep` matches under those
125
+ * positions, drops to the `--ready` survivors, counts what `--ready` hid, and names the noun. */
126
+ function activeListView(root, bugSection, sectionTitle, grep, ready) {
121
127
  const entries = bugSection ? openBugEntries(root) : plannedPlanEntries(root);
122
128
  const holds = backlogHolds(root);
123
129
  const entryHolds = bugSection ? holds.bugs : holds.plans;
@@ -128,47 +134,62 @@ function announceActiveList(root, fileName, command, bugSection, sectionTitle, g
128
134
  const numbered = entries.map((entry, i) => ({ entry, position: i + 1 }));
129
135
  const matches = grep === null ? numbered : numbered.filter((m) => entryMatches(m.entry, grep));
130
136
  const kept = ready ? matches.filter((m) => (entryHolds[m.position - 1] ?? null) === null) : matches;
131
- const hidden = matches.length - kept.length;
132
- if (json) {
133
- sayJson({
134
- entries: kept.map(({ entry, position }) => {
135
- const since = filingDate(entry.title);
136
- return {
137
- position,
138
- title: entry.title,
139
- body: entry.body,
140
- hold: entryHolds[position - 1] ?? null,
141
- inProgress: entryRunners[position - 1] ?? null,
142
- // The filing stamp the entry's own heading carries, and how many days it has waited
143
- // since then (both present only when the heading names a stamp — an undated entry
144
- // keeps the pre-existing shape with neither key).
145
- ...(since === null ? {} : { since, ageDays: ageInDays(since) }),
146
- };
147
- }),
148
- });
149
- return;
150
- }
151
- const noun = sectionNoun(sectionTitle);
152
- if (grep === null && entries.length === 0) {
153
- say(`no ${noun}s`);
154
- return;
155
- }
156
- if (matches.length === 0) {
157
- say(`no ${noun}s match "${grep}"`);
158
- return;
159
- }
160
- if (ready && kept.length === 0) {
161
- say(`no ${noun}s ready (${hidden} held; run \`tumwater ${command} --list\` to see them)`);
162
- return;
163
- }
137
+ return {
138
+ entries,
139
+ entryHolds,
140
+ entryRunners,
141
+ matches,
142
+ kept,
143
+ hidden: matches.length - kept.length,
144
+ noun: sectionNoun(sectionTitle),
145
+ grep,
146
+ ready,
147
+ };
148
+ }
149
+ /** The `--json` body: each kept entry as its position, title, verbatim body, hold,
150
+ * in-progress claim and — only when the heading names a filing stamp — its `since`/`ageDays`. */
151
+ function activeListJson(view) {
152
+ const { kept, entryHolds, entryRunners } = view;
153
+ return {
154
+ entries: kept.map(({ entry, position }) => {
155
+ const since = filingDate(entry.title);
156
+ return {
157
+ position,
158
+ title: entry.title,
159
+ body: entry.body,
160
+ hold: entryHolds[position - 1] ?? null,
161
+ inProgress: entryRunners[position - 1] ?? null,
162
+ // The filing stamp the entry's own heading carries, and how many days it has waited
163
+ // since then (both present only when the heading names a stamp — an undated entry
164
+ // keeps the pre-existing shape with neither key).
165
+ ...(since === null ? {} : { since, ageDays: ageInDays(since) }),
166
+ };
167
+ }),
168
+ };
169
+ }
170
+ /** The prose `no …` line for an empty list, a `--grep` with no match, or a `--ready` list
171
+ * whose every match is held; null when `view.kept` has rows to print. */
172
+ function activeListEmptyLine(view, command) {
173
+ const { entries, matches, kept, hidden, noun, grep, ready } = view;
174
+ if (grep === null && entries.length === 0)
175
+ return `no ${noun}s`;
176
+ if (matches.length === 0)
177
+ return `no ${noun}s match "${grep}"`;
178
+ if (ready && kept.length === 0)
179
+ return `no ${noun}s ready (${hidden} held; run \`tumwater ${command} --list\` to see them)`;
180
+ return null;
181
+ }
182
+ /** The prose body of a non-empty active list: the bug list's numbered `line` rows, or the
183
+ * plan list's `groupBlockedSeries` rows, each with the hidden-held notice when `hidden > 0`. */
184
+ function activeListProse(view, fileName, command, bugSection) {
185
+ const { kept, entryHolds, entryRunners, hidden, noun } = view;
164
186
  const hiddenNote = `(${hidden} held entries hidden; run \`tumwater ${command} --list\` to see them)`;
165
187
  const line = (entry, i) => `${entry.title}${holdMark(entryHolds[i] ?? null)}${inProgressMark(entryRunners[i] ?? null)}${entryAgeSuffix(entry.title)}`;
166
188
  if (bugSection) {
167
189
  const bugLines = kept.map(({ entry, position }) => `${position}. ${line(entry, position - 1)}`);
168
190
  if (hidden > 0)
169
191
  bugLines.push(hiddenNote);
170
- say(`${noun}s in ${fileName}:\n${bugLines.join("\n")}`);
171
- return;
192
+ return `${noun}s in ${fileName}:\n${bugLines.join("\n")}`;
172
193
  }
173
194
  // Plans: a series' blocked parts show as one line naming their positions, as the prompt's
174
195
  // backlog index does; numbering is unchanged so --show/--edit/--cancel <n> reach every part.
@@ -189,7 +210,26 @@ function announceActiveList(root, fileName, command, bugSection, sectionTitle, g
189
210
  lines.push("(each blocked series is one line; --show <n> prints a part, --json lists every entry)");
190
211
  if (hidden > 0)
191
212
  lines.push(hiddenNote);
192
- say(`${noun}s in ${fileName}:\n${lines.join("\n")}`);
213
+ return `${noun}s in ${fileName}:\n${lines.join("\n")}`;
214
+ }
215
+ /** The active half of the list shell: the open entries with their holds, in-progress claim
216
+ * marks and age suffixes, optionally reduced to the ready ones, as `--json` or prose, grouping
217
+ * a plan's blocked parts into one line (groupBlockedSeries). Orchestrates the four named
218
+ * phases below — the read-and-filter view (activeListView), the `--json` body (activeListJson),
219
+ * the empty-list prose line (activeListEmptyLine) and the bug/plan prose body
220
+ * (activeListProse). */
221
+ function announceActiveList(root, fileName, command, bugSection, sectionTitle, grep, ready, json) {
222
+ const view = activeListView(root, bugSection, sectionTitle, grep, ready);
223
+ if (json) {
224
+ sayJson(activeListJson(view));
225
+ return;
226
+ }
227
+ const empty = activeListEmptyLine(view, command);
228
+ if (empty !== null) {
229
+ say(empty);
230
+ return;
231
+ }
232
+ say(activeListProse(view, fileName, command, bugSection));
193
233
  }
194
234
  /** The shared shell of the `bug --list` and `plan --list` CLI cases — the read half of the
195
235
  * operator queue whose `--edit`/`--cancel` flags address entries by position. Reads the same