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
package/dist/src/cli.js CHANGED
@@ -125,77 +125,88 @@ async function cmdReportCase(root, args) {
125
125
  // trailing window vs a series over whole days), not a modifier of either.
126
126
  await (await import("./report/report.js")).cmdReport(root, args);
127
127
  }
128
- async function cmdDoctorCase(root, args) {
129
- // No requireReadyRepo gate: doctor's job is to report WHY the environment isn't ready,
130
- // so it must run outside a git repo and print fail lines rather than throwing.
131
- const { positionals: checkNames, rest: doctorFlags } = peelPositionals(args);
132
- rejectUnknownArgs("doctor", doctorFlags, [JSON_FLAG, PROBLEMS_FLAG, KILL_ORPHANS_FLAG, COMMIT_BACKLOG_FLAG, RESYNC_INSTALL_FLAG, DROP_BROKEN_PINS_FLAG, RETIRE_LEFTOVERS_FLAG]);
133
- const only = parseDoctorChecks(checkNames);
134
- const { runDoctor } = await import("./doctor/doctor.js");
135
- let orphanKill;
136
- if (args.includes("--kill-orphans")) {
137
- const { killOrphans, orphanKillLines } = await import("./doctor/doctor-orphans.js");
138
- orphanKill = await killOrphans(root);
139
- // The text form prints the kill before the report; --json keeps stdout one document.
140
- if (!args.includes("--json")) {
141
- for (const line of orphanKillLines(orphanKill))
142
- say(line);
128
+ /** Run one doctor side-effect (`--kill-orphans`, `--commit-backlog`, `--resync-install`,
129
+ * `--drop-broken-pins`, `--retire-leftovers`) and return its report payload for the optional
130
+ * `--json` run — the shape every doctor*Case side-effect wrapper follows: import the effect's
131
+ * module and run it only when its flag is present, then (text form only) print the effect's
132
+ * own lines before the report with a separating blank line when there is anything to print;
133
+ * `--json` keeps stdout one document, carrying the result on the payload instead. The lazy
134
+ * per-effect `load` keeps each module imported only when its flag is asked for. */
135
+ async function doctorSideEffect(args, flag, load) {
136
+ if (!args.includes(flag))
137
+ return undefined;
138
+ const { run, lines } = await load();
139
+ const payload = await run();
140
+ // The text form prints the effect before the report; --json keeps stdout one document.
141
+ if (!args.includes("--json")) {
142
+ const out = lines(payload);
143
+ for (const line of out)
144
+ say(line);
145
+ if (out.length > 0)
143
146
  say("");
144
- }
145
147
  }
146
- let backlogCommit;
147
- if (args.includes("--commit-backlog")) {
148
+ return payload;
149
+ }
150
+ async function doctorKillOrphans(root, args) {
151
+ return doctorSideEffect(args, "--kill-orphans", async () => {
152
+ const { killOrphans, orphanKillLines } = await import("./doctor/doctor-orphans.js");
153
+ return { run: () => killOrphans(root), lines: orphanKillLines };
154
+ });
155
+ }
156
+ async function doctorCommitBacklog(root, args) {
157
+ return doctorSideEffect(args, "--commit-backlog", async () => {
148
158
  const { commitUncommittedBacklog, backlogCommitLines } = await import("./doctor/doctor-backlog.js");
149
159
  const { config } = loadConfigSafe(root); // the report's own config, so base resolution and the off-base skip match the `uncommitted backlog` row's
150
- backlogCommit = await commitUncommittedBacklog(root, config);
151
- // The text form prints the commits before the report; --json keeps stdout one document.
152
- if (!args.includes("--json")) {
153
- for (const line of backlogCommitLines(backlogCommit))
154
- say(line);
155
- say("");
156
- }
157
- }
158
- let resyncInstall;
159
- if (args.includes("--resync-install")) {
160
+ return { run: () => commitUncommittedBacklog(root, config), lines: backlogCommitLines };
161
+ });
162
+ }
163
+ async function doctorResyncInstall(root, args) {
164
+ return doctorSideEffect(args, "--resync-install", async () => {
160
165
  const { resyncRootInstall, rootInstallResyncLines } = await import("./build/dep-install.js");
161
- resyncInstall = await resyncRootInstall(root);
162
- // The text form prints the re-sync outcome before the report; --json keeps stdout one
163
- // document, carrying the result as `resyncInstall` on the payload.
164
- if (!args.includes("--json")) {
165
- for (const line of rootInstallResyncLines(resyncInstall))
166
- say(line);
167
- say("");
168
- }
169
- }
170
- let brokenPinDrop;
171
- if (args.includes("--drop-broken-pins")) {
166
+ return { run: () => resyncRootInstall(root), lines: rootInstallResyncLines };
167
+ });
168
+ }
169
+ async function doctorDropBrokenPins(root, args) {
170
+ return doctorSideEffect(args, "--drop-broken-pins", async () => {
172
171
  const { dropBrokenPins, brokenPinDropLines } = await import("./doctor/doctor-contention-checks.js");
173
- brokenPinDrop = await dropBrokenPins(root);
174
- // The text form prints the drop before the report; --json keeps stdout one document,
175
- // carrying the result as `brokenPinDrop` on the payload.
176
- if (!args.includes("--json")) {
177
- const lines = brokenPinDropLines(brokenPinDrop);
178
- for (const line of lines)
179
- say(line);
180
- if (lines.length > 0)
181
- say("");
182
- }
183
- }
184
- let leftoverRetire;
185
- if (args.includes("--retire-leftovers")) {
172
+ return { run: () => dropBrokenPins(root), lines: brokenPinDropLines };
173
+ });
174
+ }
175
+ async function doctorRetireLeftovers(root, args) {
176
+ return doctorSideEffect(args, "--retire-leftovers", async () => {
186
177
  const { retireLeftovers, leftoverRetireLines } = await import("./doctor/doctor-contention-checks.js");
187
178
  const { config } = loadConfigSafe(root); // the row's own config, so the reap and the report agree on what is retirable
188
- leftoverRetire = await retireLeftovers(root, config);
189
- // The text form prints the retire lines before the report; --json keeps stdout one
190
- // document, carrying the result as `leftoverRetire` on the payload.
191
- if (!args.includes("--json")) {
192
- const lines = leftoverRetireLines(leftoverRetire);
193
- for (const line of lines)
194
- say(line);
195
- if (lines.length > 0)
196
- say("");
197
- }
198
- }
179
+ return { run: () => retireLeftovers(root, config), lines: leftoverRetireLines };
180
+ });
181
+ }
182
+ function doctorPayload(payload, parts) {
183
+ const out = { ...payload };
184
+ if (parts.leftoverRetire !== undefined)
185
+ out.leftoverRetire = parts.leftoverRetire;
186
+ if (parts.brokenPinDrop !== undefined)
187
+ out.brokenPinDrop = parts.brokenPinDrop;
188
+ if (parts.resyncInstall !== undefined)
189
+ out.resyncInstall = parts.resyncInstall;
190
+ if (parts.backlogCommit !== undefined)
191
+ out.backlogCommit = parts.backlogCommit;
192
+ if (parts.orphanKill !== undefined)
193
+ out.orphanKill = parts.orphanKill;
194
+ return out;
195
+ }
196
+ async function cmdDoctorCase(root, args) {
197
+ // No requireReadyRepo gate: doctor's job is to report WHY the environment isn't ready,
198
+ // so it must run outside a git repo and print fail lines rather than throwing.
199
+ const { positionals: checkNames, rest: doctorFlags } = peelPositionals(args);
200
+ rejectUnknownArgs("doctor", doctorFlags, [JSON_FLAG, PROBLEMS_FLAG, KILL_ORPHANS_FLAG, COMMIT_BACKLOG_FLAG, RESYNC_INSTALL_FLAG, DROP_BROKEN_PINS_FLAG, RETIRE_LEFTOVERS_FLAG]);
201
+ const only = parseDoctorChecks(checkNames);
202
+ const { runDoctor } = await import("./doctor/doctor.js");
203
+ const parts = {
204
+ orphanKill: await doctorKillOrphans(root, args),
205
+ backlogCommit: await doctorCommitBacklog(root, args),
206
+ resyncInstall: await doctorResyncInstall(root, args),
207
+ brokenPinDrop: await doctorDropBrokenPins(root, args),
208
+ leftoverRetire: await doctorRetireLeftovers(root, args),
209
+ };
199
210
  const { renderDoctor, renderDoctorProblems, problemsOnly } = await import("./doctor/doctor-render.js");
200
211
  const report = await runDoctor(root, undefined, undefined, only);
201
212
  // --json prints the collector's own payload (the DoctorReport object), not a re-parse of
@@ -204,15 +215,10 @@ async function cmdDoctorCase(root, args) {
204
215
  // --problems keeps the full report for the render (it counts the hidden ok rows) and
205
216
  // hands --json the filtered one; the exit code below reads the full report either way.
206
217
  // --kill-orphans adds the kill result to the JSON object as `orphanKill`.
207
- const withKill = (payload) => (orphanKill === undefined ? payload : { ...payload, orphanKill });
208
- const withBacklogCommit = (payload) => backlogCommit === undefined ? withKill(payload) : withKill({ ...payload, backlogCommit });
209
- const withResyncInstall = (payload) => resyncInstall === undefined ? withBacklogCommit(payload) : withBacklogCommit({ ...payload, resyncInstall });
210
- const withBrokenPinDrop = (payload) => brokenPinDrop === undefined ? withResyncInstall(payload) : withResyncInstall({ ...payload, brokenPinDrop });
211
- const withLeftoverRetire = (payload) => leftoverRetire === undefined ? withBrokenPinDrop(payload) : withBrokenPinDrop({ ...payload, leftoverRetire });
212
218
  if (args.includes("--problems"))
213
- sayJsonOrRender(args, args.includes("--json") ? withLeftoverRetire(problemsOnly(report)) : report, renderDoctorProblems);
219
+ sayJsonOrRender(args, args.includes("--json") ? doctorPayload(problemsOnly(report), parts) : report, renderDoctorProblems);
214
220
  else
215
- sayJsonOrRender(args, args.includes("--json") ? withLeftoverRetire(report) : report, renderDoctor);
221
+ sayJsonOrRender(args, args.includes("--json") ? doctorPayload(report, parts) : report, renderDoctor);
216
222
  if (report.checks.some((c) => c.level === "fail"))
217
223
  process.exitCode = 1; // Warnings never fail the exit.
218
224
  }
@@ -267,69 +273,81 @@ async function cmdArchiveCase(root, args) {
267
273
  await requireReadyRepo(root);
268
274
  await (await import("./backlog/backlog-cli.js")).cmdArchive(root, args);
269
275
  }
270
- async function cmdBugCase(root, args) {
271
- const { OPERATOR_FILE_KINDS, bugWakeText, cancelBug, editBug, editBugFromText, fileBug, fileBugFromText } = await import("./backlog/backlog-write.js");
272
- const { moveEntryToTop, releaseEntry } = await import("./backlog/backlog-release.js");
273
- const { bugTitleOf, cancelAndAnnounce, releaseAndAnnounce, topAndAnnounce, editAndAnnounce, fileAndAnnounce } = await import("./backlog/backlog-cli.js");
276
+ /** The one dispatch `bug` and `plan` both run through (see BacklogCommandSpec): route on the
277
+ * leading flag token and hand off to the matching shared AndAnnounce shell, or file a text
278
+ * entry by default. */
279
+ async function runBacklogCommand(root, args, s) {
280
+ const { cancelAndAnnounce, editAndAnnounce, fileAndAnnounce, releaseAndAnnounce, topAndAnnounce } = await import("./backlog/backlog-cli.js");
274
281
  const { listAndAnnounce, showAndAnnounce } = await import("./backlog/backlog-cli-view.js");
275
- const usage = 'tumwater bug "<symptom>"';
276
282
  if (args.includes("--show")) {
277
- showAndAnnounce(root, args, "bug");
283
+ showAndAnnounce(root, args, s.command);
278
284
  return;
279
285
  }
280
286
  if (args.includes("--list")) {
281
- listAndAnnounce(root, args, "bug", "BUGS.md", "Open");
287
+ listAndAnnounce(root, args, s.command, s.file, s.section);
282
288
  return;
283
289
  }
284
290
  if (args.includes("--edit")) {
285
- await editAndAnnounce(root, args, "bug", (n, positionals) => editBug(root, n, positionals.join(" "), usage), (n, text) => editBugFromText(root, n, text, usage));
291
+ await editAndAnnounce(root, args, s.command, s.edit, s.editText);
286
292
  return;
287
293
  }
288
294
  if (args.includes("--cancel")) {
289
- await cancelAndAnnounce(root, args, "bug", (n) => cancelBug(root, n));
295
+ await cancelAndAnnounce(root, args, s.command, s.cancel);
290
296
  return;
291
297
  }
292
298
  if (args.includes("--top")) {
293
- await topAndAnnounce(root, args, "bug", (n) => moveEntryToTop(root, "bug", n));
299
+ await topAndAnnounce(root, args, s.command, s.top);
294
300
  return;
295
301
  }
296
302
  if (args.includes("--release")) {
297
- await releaseAndAnnounce(root, args, "bug", (n) => releaseEntry(root, "bug", n), OPERATOR_FILE_KINDS.bug.role);
303
+ await releaseAndAnnounce(root, args, s.command, s.release, s.releaseRole);
298
304
  return;
299
305
  }
300
- await fileAndAnnounce(root, args, "bug", (positionals) => fileBug(root, positionals.join(" "), usage), OPERATOR_FILE_KINDS.bug.role, bugWakeText, bugTitleOf, (text) => fileBugFromText(root, text, usage));
306
+ await fileAndAnnounce(root, args, s.command, s.fileOp, s.releaseRole, s.wakeText, s.titleOf, s.fileText);
307
+ }
308
+ async function cmdBugCase(root, args) {
309
+ const { OPERATOR_FILE_KINDS, bugWakeText, cancelBug, editBug, editBugFromText, fileBug, fileBugFromText } = await import("./backlog/backlog-write.js");
310
+ const { moveEntryToTop, releaseEntry } = await import("./backlog/backlog-release.js");
311
+ const { bugTitleOf } = await import("./backlog/backlog-cli.js");
312
+ const usage = 'tumwater bug "<symptom>"';
313
+ await runBacklogCommand(root, args, {
314
+ command: "bug",
315
+ usage,
316
+ file: "BUGS.md",
317
+ section: "Open",
318
+ edit: (n, positionals) => editBug(root, n, positionals.join(" "), usage),
319
+ editText: (n, text) => editBugFromText(root, n, text, usage),
320
+ cancel: (n) => cancelBug(root, n),
321
+ top: (n) => moveEntryToTop(root, "bug", n),
322
+ release: (n) => releaseEntry(root, "bug", n),
323
+ releaseRole: OPERATOR_FILE_KINDS.bug.role,
324
+ fileOp: (positionals) => fileBug(root, positionals.join(" "), usage),
325
+ fileText: (text) => fileBugFromText(root, text, usage),
326
+ wakeText: bugWakeText,
327
+ titleOf: bugTitleOf,
328
+ });
301
329
  }
302
330
  async function cmdPlanCase(root, args) {
303
331
  const { OPERATOR_FILE_KINDS, cancelPlan, editPlan, editPlanFromText, filePlan, filePlanFromText, planWakeText } = await import("./backlog/backlog-write.js");
304
332
  const { moveEntryToTop, releaseEntry } = await import("./backlog/backlog-release.js");
305
- const { cancelAndAnnounce, releaseAndAnnounce, topAndAnnounce, editAndAnnounce, fileAndAnnounce, planTitleOf } = await import("./backlog/backlog-cli.js");
306
- const { listAndAnnounce, showAndAnnounce } = await import("./backlog/backlog-cli-view.js");
333
+ const { planTitleOf } = await import("./backlog/backlog-cli.js");
307
334
  const usage = 'tumwater plan "<title>" [body...]';
308
- if (args.includes("--show")) {
309
- showAndAnnounce(root, args, "plan");
310
- return;
311
- }
312
- if (args.includes("--list")) {
313
- listAndAnnounce(root, args, "plan", "PLANS.md", "Planned");
314
- return;
315
- }
316
- if (args.includes("--edit")) {
317
- await editAndAnnounce(root, args, "plan", (n, positionals) => editPlan(root, n, positionals[0] ?? "", positionals.slice(1).join(" "), usage), (n, text) => editPlanFromText(root, n, text, usage));
318
- return;
319
- }
320
- if (args.includes("--cancel")) {
321
- await cancelAndAnnounce(root, args, "plan", (n) => cancelPlan(root, n));
322
- return;
323
- }
324
- if (args.includes("--top")) {
325
- await topAndAnnounce(root, args, "plan", (n) => moveEntryToTop(root, "plan", n));
326
- return;
327
- }
328
- if (args.includes("--release")) {
329
- await releaseAndAnnounce(root, args, "plan", (n) => releaseEntry(root, "plan", n), OPERATOR_FILE_KINDS.plan.role);
330
- return;
331
- }
332
- await fileAndAnnounce(root, args, "plan", (positionals) => filePlan(root, positionals[0] ?? "", positionals.slice(1).join(" "), usage), OPERATOR_FILE_KINDS.plan.role, planWakeText, planTitleOf, (text) => filePlanFromText(root, text, usage));
335
+ await runBacklogCommand(root, args, {
336
+ command: "plan",
337
+ usage,
338
+ file: "PLANS.md",
339
+ section: "Planned",
340
+ edit: (n, positionals) => editPlan(root, n, positionals[0] ?? "", positionals.slice(1).join(" "), usage),
341
+ editText: (n, text) => editPlanFromText(root, n, text, usage),
342
+ cancel: (n) => cancelPlan(root, n),
343
+ top: (n) => moveEntryToTop(root, "plan", n),
344
+ release: (n) => releaseEntry(root, "plan", n),
345
+ releaseRole: OPERATOR_FILE_KINDS.plan.role,
346
+ fileOp: (positionals) => filePlan(root, positionals[0] ?? "", positionals.slice(1).join(" "), usage),
347
+ fileText: (text) => filePlanFromText(root, text, usage),
348
+ wakeText: planWakeText,
349
+ titleOf: planTitleOf,
350
+ });
333
351
  }
334
352
  async function cmdPromptCase(root, args) {
335
353
  // Parse before the gate: prompt's positionals are free-form, so rejectUnknownArgs
@@ -364,31 +382,38 @@ async function cmdStopCase(root, args) {
364
382
  await requireReadyRepo(root);
365
383
  await (await import("./operator/operator-commands.js")).cmdStop(root, args);
366
384
  }
367
- async function cmdCompletionCase(root, args) {
368
- // Works outside a repo: no requireReadyRepo.
369
- if (args.length === 1 && args[0] === "--roles") {
370
- for (const id of completionRoleIds(root))
371
- say(id);
372
- return;
373
- }
374
- if (args.length === 1 && args[0] === "--config-keys") {
375
- for (const key of completionConfigKeys(root))
376
- say(key);
377
- return;
378
- }
379
- if (args.length === 2 && args[0] === "--config-value") {
380
- // The vocabulary `config set <key> <value>` completes for a discrete-valued key
381
- // (boolean/backend/model/thinking); free-form keys print nothing, so a `set` on one
382
- // completes nothing instead of offering a wrong vocabulary.
383
- for (const word of configValueWords(args[1]))
384
- say(word);
385
- return;
386
- }
387
- if (args.length === 1 && args[0] === "--models") {
388
- for (const m of completionModels(root))
389
- say(m);
390
- return;
391
- }
385
+ function printCompletionRoles(root) {
386
+ for (const id of completionRoleIds(root))
387
+ say(id);
388
+ }
389
+ function printCompletionConfigKeys(root) {
390
+ for (const key of completionConfigKeys(root))
391
+ say(key);
392
+ }
393
+ function printCompletionConfigValue(key) {
394
+ // The vocabulary `config set <key> <value>` completes for a discrete-valued key
395
+ // (boolean/backend/model/thinking); free-form keys print nothing, so a `set` on one
396
+ // completes nothing instead of offering a wrong vocabulary.
397
+ for (const word of configValueWords(key))
398
+ say(word);
399
+ }
400
+ function printCompletionModels(root) {
401
+ for (const m of completionModels(root))
402
+ say(m);
403
+ }
404
+ const COMPLETION_LISTING_SHAPES = {
405
+ "--roles": "roles",
406
+ "--config-keys": "config-keys",
407
+ "--models": "models",
408
+ };
409
+ function completionListShape(args) {
410
+ if (args.length === 1 && args[0] in COMPLETION_LISTING_SHAPES)
411
+ return COMPLETION_LISTING_SHAPES[args[0]];
412
+ if (args.length === 2 && args[0] === "--config-value")
413
+ return "config-value";
414
+ return null;
415
+ }
416
+ function completionShell(args) {
392
417
  const shell = args[0];
393
418
  // A wrong word count names the count, not a bad shell. A single near-miss shell word gets
394
419
  // the same typoSuffix `logs --type`/`history --result` give, so `completion zs` reads
@@ -399,7 +424,28 @@ async function cmdCompletionCase(root, args) {
399
424
  fail(`completion takes one shell: bash, zsh, or fish (got ${args.length} words: ${args.join(" ")})`);
400
425
  if (!SUPPORTED_SHELLS.includes(shell))
401
426
  fail(`completion takes one shell: bash, zsh, or fish${gotSuffix(shell)}${typoSuffix(shell ?? "", SUPPORTED_SHELLS)}`);
402
- say(completionScript(shell));
427
+ return shell;
428
+ }
429
+ async function cmdCompletionCase(root, args) {
430
+ // Works outside a repo: no requireReadyRepo.
431
+ const shape = completionListShape(args);
432
+ if (shape === "roles") {
433
+ printCompletionRoles(root);
434
+ return;
435
+ }
436
+ if (shape === "config-keys") {
437
+ printCompletionConfigKeys(root);
438
+ return;
439
+ }
440
+ if (shape === "config-value") {
441
+ printCompletionConfigValue(args[1]);
442
+ return;
443
+ }
444
+ if (shape === "models") {
445
+ printCompletionModels(root);
446
+ return;
447
+ }
448
+ say(completionScript(completionShell(args)));
403
449
  }
404
450
  async function cmdVersionCase(args) {
405
451
  // A stray flag fails like every other command's: `tumwater version --json` (a plausible
@@ -4,10 +4,10 @@
4
4
  * utilities module. */
5
5
  /** Group items by a key, preserving first-seen key order and input order within each group.
6
6
  * The single home of the "append to a Map of arrays" step, shared by the event bucketers
7
- * (history-queued.ts's bucketMergeEvents, failure/time-spend.ts's rejected-by-role pass) and
8
- * the team-hold poll's observations-by-provider grouping (team-polls.ts's pollTeamHold), so
9
- * those sites cannot drift on how a missing key is seeded. Returns a fresh Map; the input is
10
- * never mutated. */
7
+ * (history-queued.ts's bucketMergeEvents and failure/failure-time-spend.ts's rejected-by-role
8
+ * pass) and the team-hold poll's observations-by-provider grouping (team-polls.ts's
9
+ * pollTeamHold), so those sites cannot drift on how a missing key is seeded. Returns a fresh
10
+ * Map; the input is never mutated. */
11
11
  export function groupBy(items, key) {
12
12
  const groups = new Map();
13
13
  for (const item of items) {
@@ -15,11 +15,26 @@ export function groupBy(items, key) {
15
15
  }
16
16
  return groups;
17
17
  }
18
+ /** The distinct items of an iterable, in first-seen order — the single home of the
19
+ * `seen.includes -> push` dedupe, shared by the pending pane's three scope lists
20
+ * (mergeQueueRoles, postReviewRoles, overlapHolders in ui/tui/tui-pane-pending.ts), the
21
+ * session pane's scope cycle (ui/tui/tui-pane-session.ts's scopeCycle), and the report
22
+ * scope fold (report/report-fold.ts's scopeBases), so those sites cannot drift on how a
23
+ * repeat is skipped. Returns a fresh array; the input is never mutated. (The dashboard's
24
+ * role-scope builder in ui/gui/gui-client-report.ts keeps its own copy by design: it runs
25
+ * in the browser, where harness modules cannot be imported.) */
26
+ export function distinctOrdered(items) {
27
+ const seen = [];
28
+ for (const item of items)
29
+ if (!seen.includes(item))
30
+ seen.push(item);
31
+ return seen;
32
+ }
18
33
  /** Return `map`'s value for `key`, creating and storing `make()`'s result first when the key is
19
34
  * absent — the single home of the `map.get(key) ?? fresh; …; map.set(key, value)` step, shared
20
35
  * by the failure digest's per-role stats, outcome, and prompt-token accumulators (failure-data.ts),
21
- * its time-and-spend per-role row (time-spend.ts), the team hold's provider+kind grouping
22
- * (team-hold.ts), the error-storm reducer's roles-by-key set (src/team/error-storm.ts), and
36
+ * its time-and-spend per-role row (failure-time-spend.ts), the team hold's provider+kind grouping
37
+ * (team-hold.ts), the error-storm reducer's roles-by-key set (src/team/team-error-storm.ts), and
23
38
  * `groupBy` above, so those sites cannot drift on how a missing key is seeded. `make` runs only
24
39
  * when the key is absent. A stored value of `undefined` reads as absent, matching the `??` idiom
25
40
  * this replaces; the callers above never store one. */
@@ -50,7 +65,7 @@ export function filterKeepPositions(items, keep) {
50
65
  }
51
66
  /** Increment a Map counter, seeding a missing key at 1 — the single home of the
52
67
  * `map.set(k, (map.get(k) ?? 0) + 1)` step, shared by the failure-spread kind tally
53
- * (src/team/failure-spread.ts), the diff multiset's line tally (merge/merge-diff.ts), the
68
+ * (src/team/team-failure-spread.ts), the diff multiset's line tally (merge/merge-diff.ts), the
54
69
  * section-title tally (backlog/backlog-structure.ts), the logs' per-type tally
55
70
  * (cli/log-commands.ts), the flaky-check tally
56
71
  * (doctor/doctor-project-checks.ts), and the multi-runner base tally
@@ -125,11 +140,11 @@ export function trimToLast(arr, max) {
125
140
  * order the same way. These two helpers are the single home of that rule; every surface that
126
141
  * ranks counters by count calls one of them rather than re-implementing the comparator —
127
142
  * `rankCountEntries` serves the `[key, count]` entry shape (src/report/report-render.ts's
128
- * rankedRoleMap, src/team/failure-spread.ts's strongest-kind pick) and `rankByCount` serves
129
- * callers whose counts are fields or computations on other shapes
130
- * (src/failure/failure-cluster.ts's clusters, src/team/error-storm.ts's strongest-cause pick,
131
- * failure-data.ts's outcome table, and time-spend.ts's time-and-spend table). `sortByValueDesc`
132
- * is the deliberate sibling for the same value-descending shape that wants a code-unit tiebreak
143
+ * rankedRoleMap, src/team/team-failure-spread.ts's strongest-kind pick) and `rankByCount` serves
144
+ * callers whose counts are fields or computations on other shapes (src/failure/failure-cluster.ts's
145
+ * clusters and src/team/team-error-storm.ts's strongest-cause pick; failure-data.ts's outcome
146
+ * table and failure-time-spend.ts's time-and-spend table). `sortByValueDesc` is the deliberate
147
+ * sibling for the same value-descending shape that wants a code-unit tiebreak
133
148
  * instead of localeCompare, and unlike these two it sorts its input array in place. The one
134
149
  * deliberate exception to both orderings is ui/gui/gui-client-report-chart.ts's reportRoleOrder,
135
150
  * which keeps its own copy by design: it runs in the browser, where harness modules cannot be
@@ -143,9 +158,9 @@ export function rankByCount(items, count, key) {
143
158
  * aliases `items` when it already fits; `hidden` is `max(0, items.length - top)`, which the
144
159
  * slice-length arithmetic yields without the explicit clamp (a slice never returns more than
145
160
  * `top` even when the list is shorter). Shared by the merge-intent and conflict-hunk caps
146
- * (merge-git.ts), the failure digest's loss-cause and message-cluster tops
147
- * (time-spend.ts, failure-cluster.ts), and the answered-questions cap (question-answer.ts),
148
- * so those cuts cannot drift on how the remainder is counted. */
161
+ * (merge-git.ts), the failure digest's loss-cause and message-cluster tops (failure-time-spend.ts
162
+ * and failure-cluster.ts), and the answered-questions cap (backlog-question-answer.ts), so those
163
+ * cuts cannot drift on how the remainder is counted. */
149
164
  export function topAndHidden(items, top) {
150
165
  const visible = items.slice(0, top);
151
166
  return { visible, hidden: items.length - visible.length };
@@ -200,7 +215,7 @@ export function sortEntriesByKey(entries) {
200
215
  return [...entries].sort(([a], [b]) => (a ?? "").localeCompare(b ?? ""));
201
216
  }
202
217
  /** rankByCount for `[key, count]` entry pairs (count first): src/report/report-render.ts's
203
- * rankedRoleMap, src/team/failure-spread.ts's strongest-kind pick, and
218
+ * rankedRoleMap, src/team/team-failure-spread.ts's strongest-kind pick, and
204
219
  * src/doctor/doctor-contention-checks.ts's check-wait and provider-wait rankings. */
205
220
  export function rankCountEntries(entries) {
206
221
  return rankByCount(entries, (e) => e[1], (e) => e[0]);
@@ -0,0 +1,70 @@
1
+ import path from "node:path";
2
+ import { classifyLock, releaseOwnedLockDir, tryClaimLockDir } from "./lock.js";
3
+ import { ensureParentDir, isDirectory, removeTree, walkUp } from "../files/files.js";
4
+ /** The host-wide suite-slot ledger that bounds how many project check scripts (an agent's bare
5
+ * `npm run test:gate` and the harness's own spawned check alike) run their suite step at once,
6
+ * sized to the same `config.maxConcurrentChecks` the in-process check permit enforces. Where
7
+ * withCheckPermit is one process-wide semaphore (every suite spawned by THIS process shares it),
8
+ * the ledger is files on disk so suites started by DIFFERENT processes — the harness and the pi
9
+ * agent's bash tools, or two agents at once — still contend for the same cap. It exists to close
10
+ * the gap where an agent's own suite run takes no team check permit at all (BUGS.md 2026-10-28),
11
+ * so the two mechanisms coexist: withCheckPermit keeps its in-process tier queue, reentrancy and
12
+ * callers exactly as they are, and the npm check scripts additionally claim a slot here.
13
+ *
14
+ * A slot is a lock dir under `.tumwater/state/check-permits/slot-<n>` using lock.ts's directory-
15
+ * and-pid protocol unchanged (tryClaimLockDir / releaseOwnedLockDir / classifyLock), so a killed
16
+ * suite's slot is reclaimed by lock.ts's stale rules (dead pid, no-pid grace, then age) on a later
17
+ * claim — no separate sweep. A waiter gives up after a bounded wait and runs the suite anyway with
18
+ * a warning rather than hanging past the check deadline or the bash tool's 600 s cap: the ledger
19
+ * is a scheduling throttle, not a correctness mutex, so a full ledger must degrade to "run
20
+ * anyway", never to "hang forever". */
21
+ /** How long acquireCheckSlot keeps trying for a free slot before giving up and returning null (the
22
+ * callers then run the suite unpermitted with a warning). Bounded well under the bash tool's 600 s
23
+ * cap so a full ledger never prices a run's own timeout. */
24
+ const CHECK_SLOT_WAIT_TIMEOUT_MS = 60_000;
25
+ /** The retry cadence between full passes over a full ledger — a slot frees quickly (a suite's
26
+ * release is the moment its check process exits), so a 200 ms repoll finds it almost immediately
27
+ * without hammering the filesystem. */
28
+ const CHECK_SLOT_RETRY_MS = 200;
29
+ /** The ancestor of `startDir` that holds the team's `.tumwater/state`, or null when none within the
30
+ * walk's bound does. The slot ledger lives under that state dir, so `scripts/check-permit.mjs`
31
+ * locates the root the same way the harness's own root lookup does (shared walkUp climb), and a
32
+ * suite run outside any team root has no ledger to contend for. Exported for tests. */
33
+ export function findTeamRoot(startDir = process.cwd()) {
34
+ return walkUp(startDir, 63, (dir) => (isDirectory(path.join(dir, ".tumwater", "state")) ? dir : null));
35
+ }
36
+ /** The slot directory for `index` of a ledger rooted at `root` (under `root/.tumwater/state`). One
37
+ * function owns the path so the acquirer, the releaser and the reclaim all name the same dir. */
38
+ export function checkSlotDir(root, index) {
39
+ return path.join(root, ".tumwater", "state", "check-permits", `slot-${index}`);
40
+ }
41
+ /** Claim one of the `cap` slots of `root`'s ledger, waiting up to `waitMs` for one to free. Walks
42
+ * slots `0..cap-1`, breaking any it finds lock.ts's classifyLock calls "stale" (a dead holder, a
43
+ * leftover no-pid dir past its grace, or an old dir), then trying to claim each by atomic publish;
44
+ * when none is free it sleeps and retries until the budget is spent, returning null. A stale slot
45
+ * is broken as encountered because a dead holder's dir would otherwise block that index forever. */
46
+ export async function acquireCheckSlot(root, cap, waitMs = CHECK_SLOT_WAIT_TIMEOUT_MS) {
47
+ // The ledger's parent must exist before any slot's atomic publish can create it: lock.ts's
48
+ // publishLockDir never makes its own parent (withStateLock is where ensureParentDir runs), so a
49
+ // first claim on a root with no check-permits dir would fail its mkdirSync and never acquire.
50
+ ensureParentDir(checkSlotDir(root, 0));
51
+ const startedAt = Date.now();
52
+ for (;;) {
53
+ for (let index = 0; index < cap; index++) {
54
+ const dir = checkSlotDir(root, index);
55
+ if (classifyLock(dir) === "stale")
56
+ removeTree(dir);
57
+ if (tryClaimLockDir(dir))
58
+ return dir;
59
+ }
60
+ if (Date.now() - startedAt >= waitMs)
61
+ return null;
62
+ await new Promise((resolve) => setTimeout(resolve, CHECK_SLOT_RETRY_MS));
63
+ }
64
+ }
65
+ /** Return a slot claimed by THIS process. Deleting a dir that another pid now owns must not
66
+ * happen (releaseOwnedLockDir checks the pid first); a slot stolen from under us stays for its
67
+ * new owner or for the stale rules. */
68
+ export function releaseCheckSlot(dir) {
69
+ releaseOwnedLockDir(dir);
70
+ }
@@ -93,6 +93,10 @@ function releaseOwnedLock(dir) {
93
93
  return;
94
94
  rmLockDir(dir);
95
95
  }
96
+ /** The ledger-facing release primitive: the same releaseOwnedLock every mutex exits with, but
97
+ * exported so src/concurrency/check-slots.ts can hand a suite's slot back without re-implementing
98
+ * the owned-pid guard (a holder whose lock was stolen must not delete its successor's dir). */
99
+ export const releaseOwnedLockDir = releaseOwnedLock;
96
100
  function acquireStep(dir, timeoutMs, waitedMs) {
97
101
  // An existing path is a held lock to wait on (and maybe break); publishLockDir is only for
98
102
  // an absent path, so a read-only parent that already holds a stale lock reports contention
@@ -149,6 +153,24 @@ function publishLockDir(dir) {
149
153
  throw err;
150
154
  }
151
155
  }
156
+ /** The one-shot claim step of the lock protocol, exported for a caller that wants a single
157
+ * best-effort claim rather than withLock's retry loop: claim `dir` by atomic publish, returning
158
+ * true on success and false when `dir` already exists (a live holder) or contention — never
159
+ * throwing, unlike publishLockDir. The suite-slot ledger (check-slots.ts) calls it per index as
160
+ * it walks a ledger, so it must not throw on a held slot to inspect the others. A genuine
161
+ * filesystem failure (ENOSPC, a read-only parent) also returns false; the ledger degrades to
162
+ * "run unpermitted" on an unable-to-create ledger, which suits a best-effort scheduling cap. */
163
+ export function tryClaimLockDir(dir) {
164
+ try {
165
+ if (fs.existsSync(dir))
166
+ return false; // A held slot (live or stale) — the caller walks on.
167
+ publishLockDir(dir);
168
+ return true;
169
+ }
170
+ catch {
171
+ return false;
172
+ }
173
+ }
152
174
  /** Remove `.acquiring-*` temp dirs left beside a lock by a creator that crashed before its
153
175
  * rename — the one remnant atomic publication can leave. Only dirs older than STALE_MS are
154
176
  * touched, far beyond any scheduling stall, so a live creator's in-flight temp is never
@@ -0,0 +1,49 @@
1
+ /** The AsyncLocalStorage store that carries a run's lease across its work: a lease rides here
2
+ * for the length of a run, so an idle stretch inside it (a failed review's in-place retry
3
+ * pause, called via idleWithoutPermit) can hand the team permit back and re-take it, letting a
4
+ * parked run use the slot meanwhile. The check-permit.ts `holdingPermit` store is the pattern:
5
+ * nothing here is a separate permit, only the single lease the run path already holds. */
6
+ import { AsyncLocalStorage } from "node:async_hooks";
7
+ const runPermitStore = new AsyncLocalStorage();
8
+ /** Run `run` with `lease` in the run-permit store, so an idle wait inside it can pause and
9
+ * resume the same team permit. The same lease object is released by the caller after the store
10
+ * is unwrapped. */
11
+ export function withRunPermit(lease, run) {
12
+ return runPermitStore.run({ lease }, run);
13
+ }
14
+ /** Run `wait`, an idle stretch inside the calling lease's work, with the lease's team permit
15
+ * handed back (SemaphoreLease.pause), so a parked run can use it meanwhile, then take it back
16
+ * (resume) before returning. A failed review's in-place retry pause is the caller: with one
17
+ * team permit, a reviewer that kept failing held it through two 30 s pauses and starved every
18
+ * other role. Once `signal` has fired, nothing is taken back and the caller's release() settles
19
+ * a still-paused lease. No-op without a lease in context. */
20
+ export async function idleWithoutPermit(wait, signal) {
21
+ const ctx = runPermitStore.getStore();
22
+ if (!ctx)
23
+ return wait();
24
+ ctx.lease.pause();
25
+ try {
26
+ await wait();
27
+ }
28
+ finally {
29
+ if (!signal?.aborted)
30
+ await untilAborted(ctx.lease.resume(), signal);
31
+ }
32
+ }
33
+ /** `p`, or an earlier return once `signal` fires: a resume parked on a full team must not keep an
34
+ * aborted verification waiting for a permit it no longer needs. */
35
+ async function untilAborted(p, signal) {
36
+ if (!signal)
37
+ return p;
38
+ let onAbort = () => { };
39
+ const aborted = new Promise((resolve) => {
40
+ onAbort = resolve;
41
+ signal.addEventListener("abort", onAbort, { once: true });
42
+ });
43
+ try {
44
+ await Promise.race([p, aborted]);
45
+ }
46
+ finally {
47
+ signal.removeEventListener("abort", onAbort);
48
+ }
49
+ }