tumwater 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +13 -0
  4. package/dist/src/agent/claude/claude-backend.js +2 -2
  5. package/dist/src/agent/claude/claude-stream.js +3 -2
  6. package/dist/src/agent/codex/codex-backend.js +3 -3
  7. package/dist/src/agent/codex/codex-stream.js +3 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  9. package/dist/src/backlog/backlog-archive.js +60 -25
  10. package/dist/src/backlog/backlog-cli-view.js +3 -28
  11. package/dist/src/backlog/backlog-cli.js +9 -64
  12. package/dist/src/backlog/backlog-commit.js +17 -9
  13. package/dist/src/backlog/backlog-eligibility.js +44 -26
  14. package/dist/src/backlog/backlog-parts.js +23 -13
  15. package/dist/src/backlog/backlog-release.js +33 -16
  16. package/dist/src/baseline/main-guardrails.js +0 -73
  17. package/dist/src/baseline/main-red.js +31 -21
  18. package/dist/src/baseline/main-watch.js +82 -34
  19. package/dist/src/brief/brief-edit.js +3 -1
  20. package/dist/src/budget/budget-fallback-view.js +288 -0
  21. package/dist/src/budget/budget-wiring.js +158 -0
  22. package/dist/src/budget/budget.js +2 -1
  23. package/dist/src/build/dep-install.js +64 -1
  24. package/dist/src/cli/cli-args.js +63 -0
  25. package/dist/src/cli/cli-flag-specs.js +33 -0
  26. package/dist/src/cli/cli-query-commands.js +16 -41
  27. package/dist/src/cli/cli-review-commands.js +113 -0
  28. package/dist/src/cli/cli-role-command.js +118 -13
  29. package/dist/src/cli/cli-start-summary.js +10 -3
  30. package/dist/src/cli/cli-start.js +33 -5
  31. package/dist/src/cli/completion.js +21 -1
  32. package/dist/src/cli/config-commands.js +17 -8
  33. package/dist/src/cli/help.js +74 -14
  34. package/dist/src/cli/log-commands.js +26 -13
  35. package/dist/src/cli/question-commands.js +7 -1
  36. package/dist/src/cli.js +71 -6
  37. package/dist/src/collections.js +31 -4
  38. package/dist/src/config/config-editable-keys.js +3 -0
  39. package/dist/src/config/config-fallbacks.js +3 -2
  40. package/dist/src/config/config-validation.js +47 -42
  41. package/dist/src/config/config-views.js +10 -2
  42. package/dist/src/config/config-write.js +33 -1
  43. package/dist/src/config/config.js +7 -2
  44. package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
  45. package/dist/src/doctor/doctor-backlog-plans.js +214 -79
  46. package/dist/src/doctor/doctor-backlog.js +155 -22
  47. package/dist/src/doctor/doctor-check-names.js +5 -1
  48. package/dist/src/doctor/doctor-checks.js +10 -15
  49. package/dist/src/doctor/doctor-claude-checks.js +6 -9
  50. package/dist/src/doctor/doctor-codex-checks.js +6 -9
  51. package/dist/src/doctor/doctor-contention-checks.js +244 -118
  52. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  53. package/dist/src/doctor/doctor-holds.js +4 -6
  54. package/dist/src/doctor/doctor-model-checks.js +45 -200
  55. package/dist/src/doctor/doctor-notify.js +7 -2
  56. package/dist/src/doctor/doctor-opencode-checks.js +7 -5
  57. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  58. package/dist/src/doctor/doctor-project-checks.js +106 -12
  59. package/dist/src/doctor/doctor.js +10 -5
  60. package/dist/src/doctor/probe.js +14 -1
  61. package/dist/src/events/event-format.js +7 -6
  62. package/dist/src/events/event-labels.js +0 -1
  63. package/dist/src/events/event-read.js +13 -0
  64. package/dist/src/events/event-window.js +20 -2
  65. package/dist/src/failure/failure-data.js +9 -4
  66. package/dist/src/failure/failure-render.js +8 -8
  67. package/dist/src/failure/time-spend.js +0 -64
  68. package/dist/src/files/file-queue.js +17 -0
  69. package/dist/src/gates/budget-gates.js +27 -257
  70. package/dist/src/gates/gate-polls.js +49 -141
  71. package/dist/src/gates/maintenance-quota.js +27 -17
  72. package/dist/src/git/commit-message.js +12 -0
  73. package/dist/src/git/git.js +18 -0
  74. package/dist/src/git/slot-salvage.js +3 -3
  75. package/dist/src/gui/gui-backlog-commands.js +3 -3
  76. package/dist/src/gui/gui-backlog-endpoints.js +5 -4
  77. package/dist/src/gui/gui-command.js +19 -1
  78. package/dist/src/gui/gui-config-commands.js +75 -9
  79. package/dist/src/gui/gui-endpoint-commands.js +2 -2
  80. package/dist/src/gui/gui-operator-commands.js +134 -7
  81. package/dist/src/gui/gui-prompt-commands.js +15 -6
  82. package/dist/src/gui/gui-report-endpoints.js +10 -3
  83. package/dist/src/gui/gui-server.js +16 -2
  84. package/dist/src/history/history-queued.js +8 -0
  85. package/dist/src/history/history.js +2 -5
  86. package/dist/src/inbox/inbox-top.js +4 -9
  87. package/dist/src/merge/merge-core.js +2 -25
  88. package/dist/src/merge/merge-drain.js +22 -135
  89. package/dist/src/merge/merge-pipeline.js +1 -1
  90. package/dist/src/merge/merge-post-review.js +154 -30
  91. package/dist/src/merge/merge-queue-render.js +11 -12
  92. package/dist/src/merge/merge-queue.js +22 -1
  93. package/dist/src/merge/merge-requeue.js +1 -0
  94. package/dist/src/merge/merge-stack.js +1 -7
  95. package/dist/src/merge/merge-to-main.js +1 -9
  96. package/dist/src/merge/merge-top.js +33 -19
  97. package/dist/src/merge/merge-verify.js +64 -9
  98. package/dist/src/notes-cli.js +8 -3
  99. package/dist/src/orchestrator/orchestrator-poll.js +47 -15
  100. package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
  101. package/dist/src/orchestrator/orchestrator.js +5 -4
  102. package/dist/src/orchestrator/scheduling-scope.js +51 -7
  103. package/dist/src/orchestrator/scheduling-skip.js +15 -5
  104. package/dist/src/paths.js +16 -6
  105. package/dist/src/pi/pi-backend.js +2 -2
  106. package/dist/src/process/run-marker.js +8 -0
  107. package/dist/src/prompt/gate-notes.js +1 -1
  108. package/dist/src/prompt/gate-prompts.js +30 -7
  109. package/dist/src/prompt/notes-backlog.js +13 -10
  110. package/dist/src/prompt/prompt.js +3 -1
  111. package/dist/src/report/report-data.js +49 -69
  112. package/dist/src/report/report-fold.js +11 -12
  113. package/dist/src/report/report-render.js +6 -4
  114. package/dist/src/report/report.js +2 -5
  115. package/dist/src/review/diff-rules.js +53 -0
  116. package/dist/src/review/known-flakes.js +12 -0
  117. package/dist/src/review/post-merge-review-accept.js +51 -0
  118. package/dist/src/review/post-merge-review-render.js +112 -0
  119. package/dist/src/review/post-merge-review.js +157 -24
  120. package/dist/src/review/protected-edits.js +22 -7
  121. package/dist/src/review/quarantine-render.js +65 -0
  122. package/dist/src/review/review.js +33 -49
  123. package/dist/src/roles/role-catalog.js +60 -2
  124. package/dist/src/roles/role-guidance.js +10 -0
  125. package/dist/src/roles/role-labels.js +6 -3
  126. package/dist/src/roles/role-render.js +43 -8
  127. package/dist/src/roles/role-view.js +36 -9
  128. package/dist/src/roles/roles.js +3 -3
  129. package/dist/src/run/ci-parity.js +121 -0
  130. package/dist/src/run/qa-coverage.js +50 -15
  131. package/dist/src/run/run-detail-data.js +9 -13
  132. package/dist/src/run/run-detail-render.js +1 -8
  133. package/dist/src/run/run-finalize.js +1 -0
  134. package/dist/src/run/run-prompt.js +14 -0
  135. package/dist/src/run/run-stage.js +66 -37
  136. package/dist/src/run/run-verdict.js +14 -1
  137. package/dist/src/run/stage-check.js +17 -11
  138. package/dist/src/run/stage-checks-shared.js +65 -0
  139. package/dist/src/run/stage-name-checks.js +204 -0
  140. package/dist/src/run/stage-path-checks.js +16 -515
  141. package/dist/src/run/stage-prose-checks.js +261 -0
  142. package/dist/src/runner/leftover.js +50 -19
  143. package/dist/src/runner/revision.js +12 -23
  144. package/dist/src/runner/runner-outcome.js +4 -2
  145. package/dist/src/runner/runner-recovery.js +18 -3
  146. package/dist/src/runner/runner-run.js +21 -8
  147. package/dist/src/runner/runner-state.js +4 -3
  148. package/dist/src/scheduling/claim-entries.js +49 -0
  149. package/dist/src/status/status-data.js +6 -1
  150. package/dist/src/status/status-polls.js +15 -0
  151. package/dist/src/status/status-roles.js +11 -0
  152. package/dist/src/team/reclaim.js +2 -2
  153. package/dist/src/text/datetime.js +3 -2
  154. package/dist/src/text/phrases.js +6 -3
  155. package/dist/src/text/text.js +11 -0
  156. package/dist/src/ui/badges.js +34 -1
  157. package/dist/src/ui/gui/gui-client-backlog.js +25 -3
  158. package/dist/src/ui/gui/gui-client-boot.js +63 -41
  159. package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
  160. package/dist/src/ui/gui/gui-client-drawer.js +29 -4
  161. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  162. package/dist/src/ui/gui/gui-client-history.js +74 -72
  163. package/dist/src/ui/gui/gui-client-labels.js +5 -1
  164. package/dist/src/ui/gui/gui-client-operator.js +6 -2
  165. package/dist/src/ui/gui/gui-client-pending.js +188 -11
  166. package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
  167. package/dist/src/ui/gui/gui-client-report.js +20 -5
  168. package/dist/src/ui/gui/gui-client-settings.js +109 -3
  169. package/dist/src/ui/gui/gui-client-team.js +11 -0
  170. package/dist/src/ui/gui/gui-client.js +11 -1
  171. package/dist/src/ui/gui/gui-page.js +1 -0
  172. package/dist/src/ui/gui/gui-styles.js +4 -0
  173. package/dist/src/ui/run-progress-model.js +2 -1
  174. package/dist/src/ui/status-payload.js +237 -9
  175. package/dist/src/ui/status-render.js +12 -4
  176. package/dist/src/ui/team-alerts.js +512 -37
  177. package/dist/src/ui/tone.js +7 -0
  178. package/dist/src/ui/tui/tui-backlog.js +7 -5
  179. package/dist/src/ui/tui/tui-editor-attach.js +1 -7
  180. package/dist/src/ui/tui/tui-editor-backends.js +1 -6
  181. package/dist/src/ui/tui/tui-editor-brief.js +4 -24
  182. package/dist/src/ui/tui/tui-editor-budget.js +1 -8
  183. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  184. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  185. package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
  186. package/dist/src/ui/tui/tui-editor-config.js +19 -6
  187. package/dist/src/ui/tui/tui-editor-entry.js +3 -17
  188. package/dist/src/ui/tui/tui-editor-instances.js +9 -0
  189. package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
  190. package/dist/src/ui/tui/tui-editor-interval.js +18 -7
  191. package/dist/src/ui/tui/tui-editor-notes.js +6 -24
  192. package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
  193. package/dist/src/ui/tui/tui-editor-retire.js +1 -7
  194. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  195. package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
  196. package/dist/src/ui/tui/tui-editor-session.js +41 -0
  197. package/dist/src/ui/tui/tui-editor-slots.js +1 -0
  198. package/dist/src/ui/tui/tui-editors.js +10 -1
  199. package/dist/src/ui/tui/tui-frame.js +18 -6
  200. package/dist/src/ui/tui/tui-keys.js +9 -0
  201. package/dist/src/ui/tui/tui-pane-activity.js +21 -8
  202. package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
  203. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  204. package/dist/src/ui/tui/tui-pane-health.js +113 -3
  205. package/dist/src/ui/tui/tui-pane-history.js +44 -0
  206. package/dist/src/ui/tui/tui-pane-pending.js +285 -56
  207. package/dist/src/ui/tui/tui-pane-queued.js +7 -2
  208. package/dist/src/ui/tui/tui-pane-report.js +9 -3
  209. package/dist/src/ui/tui/tui-pane-session.js +9 -0
  210. package/dist/src/ui/tui/tui-pane.js +16 -10
  211. package/dist/src/ui/tui/tui-panes.js +56 -7
  212. package/dist/src/ui/tui/tui.js +8 -5
  213. package/dist/src/verdict/fix-claim.js +47 -1
  214. package/dist/src/verdict/reply-contract.js +35 -0
  215. package/package.json +1 -1
  216. package/dist/src/baseline/main-culprit.js +0 -94
  217. package/dist/src/baseline/main-revert.js +0 -133
package/README.md CHANGED
@@ -42,7 +42,7 @@ and is also outside `npm test`.
42
42
  ## Status
43
43
 
44
44
  <!-- tumwater:status:start -->
45
- **v0.3.0**: working tumwater. The director and 14 of the 16 roles are enabled by default; `selfcheck`,
45
+ **v0.4.0**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
46
46
  which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
47
47
  issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
48
48
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "d36bf79c535e10fe228f058ecc1ca839f3af61b1",
3
- "builtAt": 1792789212458,
2
+ "sha": "4d2bccd84735720b529ab2695a5d274df2e48bbb",
3
+ "builtAt": 1792915702889,
4
4
  "root": "/home/runner/work/tumwater/tumwater"
5
5
  }
@@ -75,3 +75,16 @@ export function modelLabel(backend, provider, model) {
75
75
  export function backendDisplayName(id) {
76
76
  return id.charAt(0).toUpperCase() + id.slice(1);
77
77
  }
78
+ /** A seam's own-default label when neither a provider nor a model resolves:
79
+ * `<backend> default` — pi's historical `pi default` for the pi backend (and an unknown id,
80
+ * which reads as pi), `claude default` for claude, and so on. The one home of the label
81
+ * `role-render.ts`'s Model line and the status rows share, so the two surfaces cannot
82
+ * disagree about what a seam without a model is named. */
83
+ export function backendDefaultLabel(backend) {
84
+ // Only claude, opencode and codex carry their own `<backend> default`; pi, an absent
85
+ // backend and any unregistered id (which reads as pi) all carry pi's historical `pi
86
+ // default`, matching streamsToolOutput's reads-as-pi window.
87
+ if (backend === "claude" || backend === "opencode" || backend === "codex")
88
+ return `${backend} default`;
89
+ return "pi default";
90
+ }
@@ -20,7 +20,7 @@ import { randomUUID } from "node:crypto";
20
20
  import { resolvedBackendConfig } from "../../config/config-views.js";
21
21
  import { MODEL_TIERS } from "../../config/config-schema.js";
22
22
  import { routeModel } from "../../config/model-selector.js";
23
- import { backendBaseEnv, withNotesPath } from "../../process/run-marker.js";
23
+ import { backendRunEnv } from "../../process/run-marker.js";
24
24
  import { resolveBinForBackend } from "../../pi/pi-bin.js";
25
25
  import { expandHomeTilde } from "../../files/files.js";
26
26
  import { claudeArgs } from "./claude-args.js";
@@ -147,7 +147,7 @@ export const claudeBackend = {
147
147
  // the marker the same way pi's adapter carries it (withNotesPath); from part 2/4 the run
148
148
  // saves its note through the `tumwater-notes` command on PATH, so no MCP server reaches the
149
149
  // run and `--strict-mcp-config` (always in claude-args.ts) keeps it that way.
150
- const base = withNotesPath(backendBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
150
+ const base = backendRunEnv(run);
151
151
  const env = claudeEnv(base, {
152
152
  quietTimeoutSeconds: run.config.quietTimeoutSeconds,
153
153
  ...(expandedConfigDir !== undefined ? { configDir: expandedConfigDir } : {}),
@@ -15,6 +15,7 @@
15
15
  * `agent_unknown_event` warning after the run. `cost_unknown` is recorded the same way when a
16
16
  * run produced no authoritative `run_cost`. */
17
17
  import { joinParts } from "../../text/text.js";
18
+ import { secondsUntil } from "../../text/datetime.js";
18
19
  import { canonicalUsageBlock, isJsonObject, objectOrEmpty, nonNegativeNumber, parseJsonObject } from "../../files/json-object.js";
19
20
  /** claude's tool names → the canonical pi names the readers already know. Glob/Grep are kept
20
21
  * lower-cased; MultiEdit collapses onto edit (pi has one edit tool). Anything else is
@@ -295,8 +296,8 @@ export function claudeTranslator(opts = {}) {
295
296
  if (info.isUsingOverage === true)
296
297
  out.isUsingOverage = true;
297
298
  if (status === "rejected" && resetsAt !== undefined) {
298
- // resetsAt is a unix-seconds stamp; retryAfterSeconds is the wait the runner honors.
299
- out.retryAfterSeconds = Math.max(0, Math.round(resetsAt - Date.now() / 1000));
299
+ // resetsAt is a unix-seconds stamp; secondsUntil folds it (×1000) to whole seconds.
300
+ out.retryAfterSeconds = secondsUntil(resetsAt * 1000, Date.now());
300
301
  }
301
302
  return [JSON.stringify(out), ...windows];
302
303
  }
@@ -19,7 +19,7 @@
19
19
  import fs from "node:fs";
20
20
  import { loadConfigSafe } from "../../config/config.js";
21
21
  import { resolvedBackendConfig } from "../../config/config-views.js";
22
- import { backendBaseEnv, withNotesPath } from "../../process/run-marker.js";
22
+ import { backendRunEnv } from "../../process/run-marker.js";
23
23
  import { resolveBinForBackend } from "../../pi/pi-bin.js";
24
24
  import { teamRootOf, nearestTumwaterRoot } from "../../paths.js";
25
25
  import { warnEventBestEffort, TUMWATER_SOURCE } from "../../events/events.js";
@@ -120,7 +120,7 @@ export const codexBackend = {
120
120
  // The notes path rides beside the run marker, as the pi and claude adapters' envs do
121
121
  // (withNotesPath); from part 2/4 the run saves its note through the `tumwater-notes`
122
122
  // command on PATH, so codex configures no MCP server for notes.
123
- const base = withNotesPath(backendBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
123
+ const base = backendRunEnv(run);
124
124
  // A team home of its own (`CODEX_HOME`) keeps team sessions and trust entries out of the
125
125
  // operator's `~/.codex` (design §4, finding 10); unset leaves the operator's home.
126
126
  const home = codexHome(resolvedBackendConfig(run.config, "codex"));
@@ -162,7 +162,7 @@ export const codexBackend = {
162
162
  // invocation later reads was looked up under the run's own config. It never throws a run
163
163
  // away: a failed lookup records `off` (and warns once) and the run continues without the
164
164
  // guard's trust.
165
- const base = withNotesPath(backendBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
165
+ const base = backendRunEnv(run);
166
166
  const home = codexHome(resolvedBackendConfig(run.config, "codex"));
167
167
  const env = codexEnv(base, { home });
168
168
  const root = nearestTumwaterRoot(run.sessionDir);
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import { nonNegativeNumber, objectOrEmpty, parseJsonObject } from "../../files/json-object.js";
17
17
  import { boundText, outputLimits } from "../../pi-extension/bounded-output.js";
18
+ import { secondsUntil } from "../../text/datetime.js";
18
19
  /** How codex wraps every shell command — the prefix to strip (with its quoting) so the
19
20
  * canonical `bash` call carries the model's own command, which suite-rerun detection matches. */
20
21
  const SHELL_WRAPPER = /^\/(?:bin\/)?(?:zsh|bash) -lc '(.*)'$/s;
@@ -76,7 +77,8 @@ function classifyTurnFailed(parsed) {
76
77
  const minutes = nonNegativeNumber(parsed.rate_limit_reached_type, undefined);
77
78
  const named = typeof parsed.rate_limit_reached_type === "string" ? parsed.rate_limit_reached_type : undefined;
78
79
  const resetsAt = nonNegativeNumber(parsed.resets_at, undefined);
79
- const retryAfter = resetsAt === undefined ? undefined : Math.max(0, Math.round(resetsAt - Date.now() / 1000));
80
+ // secondsUntil folds the unix-seconds resets stamp (×1000 to epoch-ms), clamped at 0.
81
+ const retryAfter = resetsAt === undefined ? undefined : secondsUntil(resetsAt * 1000, Date.now());
80
82
  kind.kind = "transientRateLimit";
81
83
  kind.rateLimit = {
82
84
  type: "rate_limit",
@@ -23,7 +23,7 @@ import path from "node:path";
23
23
  import { ensureParentDir } from "../../files/files.js";
24
24
  import { loadConfigSafe } from "../../config/config.js";
25
25
  import { resolvedBackendConfig } from "../../config/config-views.js";
26
- import { backendBaseEnv, withNotesPath } from "../../process/run-marker.js";
26
+ import { backendRunEnv } from "../../process/run-marker.js";
27
27
  import { resolveBinForBackend } from "../../pi/pi-bin.js";
28
28
  import { opencodeDbPath, teamRootOf } from "../../paths.js";
29
29
  import { resumableSession } from "../session-ledger.js";
@@ -189,7 +189,7 @@ export const opencodeBackend = {
189
189
  // The notes path rides beside the run marker, as the pi and claude adapters' envs do
190
190
  // (withNotesPath); from part 2/4 the run saves its note through the `tumwater-notes` command
191
191
  // on PATH, so OpenCode configures no tumwater MCP server for notes.
192
- const base = withNotesPath(backendBaseEnv(run.runMarker, undefined, run.binDir), run.notesPath);
192
+ const base = backendRunEnv(run);
193
193
  const env = opencodeEnv(base, { dbPath, configContent });
194
194
  const { sessionId } = resolveOpencodeSession(run, env);
195
195
  // The thinking variant (part 6d/7): ride the catalog's effort levels for the resolved
@@ -412,14 +412,16 @@ function quarantineTitlePrefix(test) {
412
412
  function fencedTail(tail) {
413
413
  return ["~~~~", ...tail.replaceAll("~~~~", "~~~").split("\n").slice(-30), "~~~~"].join("\n");
414
414
  }
415
- /** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
416
- * branch as one `tumwater(operator):` commit, under the merge lock and the primary-worktree lock
417
- * exactly as archiveFinishedOnMain does. Skipped, writing nothing, when the primary checkout is
418
- * off the base branch, BUGS.md has uncommitted changes, or BUGS.md exists but cannot be read; a
419
- * failed append or commit restores BUGS.md. A test whose quarantine entry is already under
420
- * `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading back
421
- * and is not filed again. */
422
- export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
415
+ /** File one BUGS.md `## Open` entry per given entry and commit BUGS.md on the base branch as one
416
+ * `tumwater(operator):` commit (the subject built from the number of entries actually appended is
417
+ * stamped under the operator role), under the merge lock and the primary-worktree lock exactly as
418
+ * archiveFinishedOnMain does. Skipped,
419
+ * writing nothing, when the primary checkout is off the base branch, BUGS.md has uncommitted
420
+ * changes, or BUGS.md exists but cannot be read; a failed append or commit restores BUGS.md.
421
+ * Every entry is appended verbatim as its own `## Open` heading — unless its `ifOpen` dedupe
422
+ * matches an existing heading, which is reused instead — and the returned headings are the
423
+ * entry headings in input order. */
424
+ export async function fileBugsOnMain(root, entries, subject, opts = {}) {
423
425
  return withLock(mergeLockDir(root), () => withPrimaryWorktreeLockAsync(root, async () => {
424
426
  const primary = await currentBranch(root);
425
427
  const base = targetBranch(loadConfigSafe(root).config?.baseBranch, primary);
@@ -440,9 +442,8 @@ export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
440
442
  catch (err) {
441
443
  return { status: "skipped", reason: `BUGS.md could not be read: ${errorFirstLine(err)}` };
442
444
  }
443
- const date = formatDate(new Date());
444
445
  const open = before === null ? [] : parseEntryDetails(before, "Open").map((e) => e.title);
445
- const bugs = new Map();
446
+ const headings = [];
446
447
  let appended = 0;
447
448
  const restoreBugs = async () => {
448
449
  await gitTry(root, "reset", "-q", "--", "BUGS.md");
@@ -452,17 +453,15 @@ export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
452
453
  writeTextAtomic(file, before);
453
454
  };
454
455
  try {
455
- for (const { test, tail } of tests) {
456
- const existing = open.find((t) => t.startsWith(quarantineTitlePrefix(test)));
457
- if (existing !== undefined) {
458
- bugs.set(test, existing);
459
- continue;
456
+ for (const entry of entries) {
457
+ if (entry.ifOpen !== undefined) {
458
+ const existing = entry.ifOpen(open);
459
+ if (existing !== undefined) {
460
+ headings.push(existing);
461
+ continue;
462
+ }
460
463
  }
461
- const body = [
462
- `The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
463
- ...(tail ? ["", fencedTail(tail)] : []),
464
- ].join("\n");
465
- bugs.set(test, appendBugEntryLocked(root, `${quarantineTitlePrefix(test)}${date})`, body));
464
+ headings.push(appendBugEntryLocked(root, entry.title, entry.body));
466
465
  appended++;
467
466
  }
468
467
  }
@@ -471,17 +470,53 @@ export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
471
470
  return { status: "skipped", reason: `a BUGS.md entry could not be written: ${errorFirstLine(err)}` };
472
471
  }
473
472
  if (appended === 0)
474
- return { status: "filed", bugs };
475
- const subject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
476
- `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`);
473
+ return { status: "filed", headings };
474
+ const commitSubject = stampedSubject("operator", // vocabulary-ok: the tumwater(operator): commit prefix is a machine string
475
+ subject(appended));
477
476
  try {
478
477
  await git(root, "add", "--", "BUGS.md");
479
- await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", subject, "--", "BUGS.md");
478
+ await git(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", commitSubject, "--", "BUGS.md");
480
479
  }
481
480
  catch (err) {
482
481
  await restoreBugs();
483
482
  return { status: "skipped", reason: `the commit failed: ${errorFirstLine(err)}` };
484
483
  }
485
- return { status: "filed", bugs };
484
+ return { status: "filed", headings };
486
485
  }), opts.lockTimeoutMs);
487
486
  }
487
+ /** File one BUGS.md `## Open` entry per newly quarantined test and commit BUGS.md on the base
488
+ * branch as one `tumwater(operator):` commit, delegating the locked write/commit to
489
+ * fileBugsOnMain and preserving the existing subject. A test whose quarantine entry is already
490
+ * under `## Open` (a filing whose markQuarantineFiled failed after the commit) gets that heading
491
+ * back and is not filed again — the dedupe runs inside fileBugsOnMain under the merge lock (via
492
+ * each entry's `ifOpen`), so concurrent quarantine filings cannot both append a duplicate, and
493
+ * the real skip on a dirty or off-base BUGS.md is enforced under the same lock. */
494
+ export async function fileQuarantineBugsOnMain(root, tests, opts = {}) {
495
+ const date = formatDate(new Date());
496
+ const toFile = [];
497
+ for (const { test, tail } of tests) {
498
+ const prefix = quarantineTitlePrefix(test);
499
+ const body = [
500
+ `The check failed on this test and then passed on a re-run of the same tree, so tumwater quarantined it: it counts as a known failure until this entry leaves \`## Open\`. Find why it is unstable and fix it.`,
501
+ ...(tail ? ["", fencedTail(tail)] : []),
502
+ ].join("\n");
503
+ toFile.push({
504
+ title: `${prefix}${date})`,
505
+ body,
506
+ // Dedupe under the lock, against the headings fileBugsOnMain reads: the reused heading
507
+ // (not a duplicate) is returned in headings and zipped back to this test below.
508
+ ifOpen: (open) => open.find((t) => t.startsWith(prefix)),
509
+ });
510
+ }
511
+ const outcome = await fileBugsOnMain(root, toFile,
512
+ // Size the subject by what was actually appended: tests whose `## Open` entry was already
513
+ // present are reused, not filed, so a mixed batch keeps the original "File N …" wording
514
+ // with N the count of new entries only.
515
+ (appended) => `File ${plural(appended, "quarantined flaky test", "quarantined flaky tests")} in BUGS.md`, opts);
516
+ if (outcome.status === "skipped")
517
+ return { status: "skipped", reason: outcome.reason };
518
+ // fileBugsOnMain's headings align with `tests` in order (reused headings or appended titles).
519
+ const bugs = new Map();
520
+ outcome.headings.forEach((heading, i) => bugs.set(tests[i].test, heading));
521
+ return { status: "filed", bugs };
522
+ }
@@ -3,7 +3,7 @@
3
3
  * shells (file, cancel, release, top, edit). Neither shell gates on a ready repo, commits, or
4
4
  * wakes a role. cli.ts reaches them through listAndAnnounce and showAndAnnounce. */
5
5
  import { fail, say, sayJson } from "../cli/cli-output.js";
6
- import { parseCountFlag, parsePositionFlag, rejectRepeatFlag } from "../cli/cli-args.js";
6
+ import { parseCountFlag, rejectRepeatFlag, scanPositionArgs } from "../cli/cli-args.js";
7
7
  import { DONE_DATE_RE, FIXED_DATE_RE, completionDate, donePlanEntries, fixedBugEntries, openBugEntries, plannedPlanEntries, ageInDays, entryAgeSuffix, entryMatches, filingDate, } from "./backlog.js";
8
8
  import { backlogHolds, backlogInProgress, holdMark, inProgressMark } from "./backlog-eligibility.js";
9
9
  import { groupBlockedSeries, partsLabel } from "./backlog-structure.js";
@@ -221,37 +221,12 @@ export function listAndAnnounce(root, args, command, fileName, sectionTitle) {
221
221
  * `--list`: no repo gate, commit, or wake. Prose is the title, a blank line, then the body, so
222
222
  * stdout is the file `--edit <n> --file` takes back. */
223
223
  export function showAndAnnounce(root, args, command) {
224
- let position = null;
225
- let json = false;
226
- let done = false;
227
- const rest = [];
228
- for (let i = 0; i < args.length; i++) {
229
- const arg = args[i];
230
- if (arg === "--done") {
231
- rejectRepeatFlag(done, "--done");
232
- done = true;
233
- continue;
234
- }
235
- if (arg === "--json") {
236
- rejectRepeatFlag(json, "--json");
237
- json = true;
238
- continue;
239
- }
240
- if (arg === "--show") {
241
- rejectRepeatFlag(position !== null, "--show");
242
- position = parsePositionFlag("--show", args[i + 1], "--show 2");
243
- i++;
244
- continue;
245
- }
246
- if (arg === "--help" || arg === "-h")
247
- continue;
248
- rest.push(arg);
249
- }
224
+ const { json, flags, position, rest } = scanPositionArgs(args, "--show", "--show 2", ["--done"]);
250
225
  rejectUnknownArgs(command, rest, []);
251
226
  refuseTeamWorkspace(root);
252
227
  if (position === null)
253
228
  fail(`--show needs a position (usage: tumwater ${command} --show <n>)`);
254
- if (done) {
229
+ if (flags["--done"]) {
255
230
  const bug = command === "bug";
256
231
  const all = bug ? fixedBugEntries(root) : donePlanEntries(root);
257
232
  const entry = all[position - 1];
@@ -6,12 +6,13 @@
6
6
  * surgery and the locked read-modify-write live in backlog-write.ts. The wake of the role that
7
7
  * should act stays in cli.ts beside the other command bodies. */
8
8
  import { fail, say, sayJson, warnIfUncommitted } from "../cli/cli-output.js";
9
- import { parsePositionFlag, rejectRepeatFlag } from "../cli/cli-args.js";
9
+ import { parsePositionFlag, rejectRepeatFlag, scanPositionArgs } from "../cli/cli-args.js";
10
10
  import { promptFileValue, readPromptFileArg } from "../cli/cli-command-args.js";
11
11
  import { plannedPlanEntries } from "./backlog.js";
12
12
  import { allLiveClaims } from "../runner/runner-state-load.js";
13
13
  import { backlogHolds, cancelledClaimNote, stillHeldNote } from "./backlog-eligibility.js";
14
14
  import { entryKey, plansReleasedBy, releasedPartsNote } from "./backlog-parts.js";
15
+ import { liftedNotePhrase } from "./backlog-release.js";
15
16
  import { collapseWhitespace } from "../text/text.js";
16
17
  import { JSON_FLAG, rejectUnknownArgs } from "../cli/cli-flag-specs.js";
17
18
  import { requireReadyRepo } from "../cli/cli-repo-gate.js";
@@ -148,26 +149,7 @@ async function announceFiled(root, command, filed, role, wakeText, json) {
148
149
  * `cancelled <command> in <file>: <title>` or the `--json` payload {file, title}. Unlike
149
150
  * fileAndAnnounce there is no wake: removing work must not wake a role. */
150
151
  export async function cancelAndAnnounce(root, args, command, cancel) {
151
- let position = null;
152
- let json = false;
153
- const rest = [];
154
- for (let i = 0; i < args.length; i++) {
155
- const arg = args[i];
156
- if (arg === "--json") {
157
- rejectRepeatFlag(json, "--json");
158
- json = true;
159
- continue;
160
- }
161
- if (arg === "--cancel") {
162
- rejectRepeatFlag(position !== null, "--cancel");
163
- position = parsePositionFlag("--cancel", args[i + 1], "--cancel 2");
164
- i++;
165
- continue;
166
- }
167
- if (arg === "--help" || arg === "-h")
168
- continue;
169
- rest.push(arg);
170
- }
152
+ const { json, position, rest } = scanPositionArgs(args, "--cancel", "--cancel 2");
171
153
  // rejectUnknownArgs names both a stray positional and an unknown flag, with this command's
172
154
  // (empty) valid-flag list — the vocabulary a cancel invocation accepts is exactly --cancel
173
155
  // and --json, both consumed above.
@@ -197,28 +179,10 @@ export async function cancelAndAnnounce(root, args, command, cancel) {
197
179
  * released entry is eligible now, and that role may be backed off) with no queued prompt. An
198
180
  * entry still held after the release (another note, an unmerged prerequisite) wakes no one and
199
181
  * gets stillHeldNote instead of the wake line. Prints `released <command> in <file>: <title>`
200
- * plus that line, or only the `--json` payload (with `hold` when still held). */
182
+ * naming the note it lifted, plus that line, or only the `--json` payload (with `lifted`, and
183
+ * `hold` when still held). */
201
184
  export async function releaseAndAnnounce(root, args, command, release, role) {
202
- let position = null;
203
- let json = false;
204
- const rest = [];
205
- for (let i = 0; i < args.length; i++) {
206
- const arg = args[i];
207
- if (arg === "--json") {
208
- rejectRepeatFlag(json, "--json");
209
- json = true;
210
- continue;
211
- }
212
- if (arg === "--release") {
213
- rejectRepeatFlag(position !== null, "--release");
214
- position = parsePositionFlag("--release", args[i + 1], "--release 2");
215
- i++;
216
- continue;
217
- }
218
- if (arg === "--help" || arg === "-h")
219
- continue;
220
- rest.push(arg);
221
- }
185
+ const { json, position, rest } = scanPositionArgs(args, "--release", "--release 2");
222
186
  rejectUnknownArgs(command, rest, []);
223
187
  if (position === null)
224
188
  fail(`--release needs a position (usage: tumwater ${command} --release <n>)`);
@@ -229,9 +193,9 @@ export async function releaseAndAnnounce(root, args, command, release, role) {
229
193
  const hold = (command === "bug" ? holds.bugs : holds.plans)[position - 1] ?? null;
230
194
  const wake = hold === null ? requestWake(root, [role]) : "";
231
195
  if (json)
232
- sayJson({ file: released.file, title: released.title, ...(hold === null ? {} : { hold }) });
196
+ sayJson({ file: released.file, title: released.title, lifted: released.lifted, ...(hold === null ? {} : { hold }) });
233
197
  else {
234
- say(`released ${command} in ${released.file}: ${released.title}`);
198
+ say(`released ${command} in ${released.file}: ${released.title} (lifted ${liftedNotePhrase(released.lifted)})`);
235
199
  say(hold === null ? wake : stillHeldNote(hold));
236
200
  }
237
201
  }
@@ -242,26 +206,7 @@ export async function releaseAndAnnounce(root, args, command, release, role) {
242
206
  * (then stillHeldNote when the entry is held) or only the `--json` payload (with `hold` when
243
207
  * held). */
244
208
  export async function topAndAnnounce(root, args, command, top) {
245
- let position = null;
246
- let json = false;
247
- const rest = [];
248
- for (let i = 0; i < args.length; i++) {
249
- const arg = args[i];
250
- if (arg === "--json") {
251
- rejectRepeatFlag(json, "--json");
252
- json = true;
253
- continue;
254
- }
255
- if (arg === "--top") {
256
- rejectRepeatFlag(position !== null, "--top");
257
- position = parsePositionFlag("--top", args[i + 1], "--top 2");
258
- i++;
259
- continue;
260
- }
261
- if (arg === "--help" || arg === "-h")
262
- continue;
263
- rest.push(arg);
264
- }
209
+ const { json, position, rest } = scanPositionArgs(args, "--top", "--top 2");
265
210
  rejectUnknownArgs(command, rest, []);
266
211
  if (position === null)
267
212
  fail(`--top needs a position (usage: tumwater ${command} --top <n>)`);
@@ -5,10 +5,12 @@ import { commitAuthorArgs, committerIdentArgs, gitTry } from "../git/git-run.js"
5
5
  /** Commit one operator-written backlog file on main, so the entry reaches the worktrees that
6
6
  * branch from main and the primary checkout stays clean for the next fast-forward. Runs under
7
7
  * the primary-worktree lock `ffMainTo`'s working-tree arm takes, so the commit and a
8
- * fast-forward cannot interleave. Returns false, writing nothing, when the primary is not on
9
- * the base branch (the ref-push arm: the working tree is not main's) or when git fails; it
10
- * never throws, and the entry stays written either way. `--only` with a pathspec commits just
11
- * that file's working-tree content and leaves other staged or dirty paths alone. */
8
+ * fast-forward cannot interleave. Returns false when the commit did not land — the primary is
9
+ * not on the base branch (the ref-push arm: the working tree is not main's), or git failed —
10
+ * and in that case restores the file to its committed state so the primary checkout does not
11
+ * stay dirty and block every merge touching it with "the merge would overwrite files in the
12
+ * primary checkout". It never throws. `--only` with a pathspec commits just that file's
13
+ * working-tree content and leaves other staged or dirty paths alone. */
12
14
  export async function commitOperatorBacklogEdit(root, file, subject) {
13
15
  try {
14
16
  // Yield first: a synchronous caller (the TUI key handler) may write again right after this
@@ -17,11 +19,17 @@ export async function commitOperatorBacklogEdit(root, file, subject) {
17
19
  await new Promise((resolve) => setImmediate(resolve));
18
20
  return await withPrimaryWorktreeLockAsync(root, async () => {
19
21
  const primary = await currentBranch(root);
20
- if (primary === null)
21
- return false;
22
- if (targetBranch(loadConfigSafe(root).config?.baseBranch, primary) !== primary)
23
- return false;
24
- return (await gitTry(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", subject, "--", file)) !== null;
22
+ let committed = primary !== null &&
23
+ targetBranch(loadConfigSafe(root).config?.baseBranch, primary) === primary &&
24
+ (await gitTry(root, ...(await committerIdentArgs(root)), "commit", ...commitAuthorArgs(), "--only", "-m", subject, "--", file)) !== null;
25
+ if (!committed) {
26
+ // The write landed in the working tree but could not be committed to the base branch:
27
+ // roll the file back to its committed state (HEAD) so the primary checkout stays clean
28
+ // for the next fast-forward instead of refusing every merge that touches it. Only this
29
+ // file is touched; other staged or dirty paths are left alone.
30
+ await gitTry(root, "checkout", "HEAD", "--", file);
31
+ }
32
+ return committed;
25
33
  });
26
34
  }
27
35
  catch {
@@ -10,7 +10,7 @@
10
10
  import path from "node:path";
11
11
  import { statOrNull } from "../files/files.js";
12
12
  import { allLiveClaims } from "../runner/runner-state-load.js";
13
- import { firstTrimmedLineStartingWith } from "../text/text.js";
13
+ import { escapeRegExp, firstTrimmedLineStartingWith } from "../text/text.js";
14
14
  import { HELD_TAG_PREFIX, NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX, PRIOR_HELD_PREFIX, WAITING_FOR_FAILURE_PREFIX } from "../roles/role-guidance.js";
15
15
  import { baseRoleOf } from "../roles/runner-ids.js";
16
16
  import { roleLabel } from "../roles/role-labels.js";
@@ -25,7 +25,7 @@ export const OPERATOR_HOLD_THRESHOLD = 3;
25
25
  * but non-numeric `(held …)` tag is 0 (and stops the scan), so a malformed tag never throws.
26
26
  * Pure over the body text — the data-layer read half of the convergence backstop. */
27
27
  export function heldCount(body) {
28
- const tagRe = new RegExp(`${HELD_TAG_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)\\)`);
28
+ const tagRe = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`);
29
29
  for (const raw of body.split("\n")) {
30
30
  const line = raw.trimStart();
31
31
  const note = line.startsWith(NEEDS_REVIEW_PREFIX) || line.startsWith(NEEDS_REPLAN_PREFIX);
@@ -55,7 +55,7 @@ function lineHeldCount(line) {
55
55
  const isNote = trimmed.startsWith(NEEDS_REVIEW_PREFIX) || trimmed.startsWith(NEEDS_REPLAN_PREFIX);
56
56
  if (!isNote)
57
57
  return undefined;
58
- const tag = new RegExp(`${HELD_TAG_PREFIX.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}(\\d+)\\)`).exec(trimmed);
58
+ const tag = new RegExp(`${escapeRegExp(HELD_TAG_PREFIX)}(\\d+)\\)`).exec(trimmed);
59
59
  return tag === null ? undefined : Number(tag[1]);
60
60
  }
61
61
  /** The `**Refused …` note prefix a refusing run writes. Every hold note — this one and the
@@ -331,37 +331,55 @@ export function backlogMarks(root) {
331
331
  bugs: holds.bugs.map((h, i) => mark(h, working.bugs[i])),
332
332
  };
333
333
  }
334
- /** The planned plans whose hold is `"held-for-operator"`, in file order: the plans parked
335
- * until the operator lifts them with `tumwater plan --release` (the `operator-held` team
336
- * alert's input), each carrying the backlog file and its 0-based position so a surface
337
- * opening the row can land on it, the same RefusedEntry shape refusedEntries returns. Only
338
- * plans — Needs-review/Needs-replan notes are a plan note, so a bug is never held this way.
334
+ /** A planned plan or open bug whose hold is `"held-for-operator"`, in file order: the
335
+ * entries parked until the operator lifts them with `tumwater plan --release` or `tumwater
336
+ * bug --release` (the `operator-held` team alert's input), each carrying the backlog file
337
+ * and its 0-based position so a surface opening the row can land on it, the same RefusedEntry
338
+ * shape refusedEntries returns. The convergence hold writes only on plans, but a role's own
339
+ * too-large note can sit on a bug and reach this threshold, so a bug can be held this way too.
339
340
  * A missing file yields `[]`. */
340
341
  export function operatorHeldEntries(root) {
341
342
  const holds = backlogHolds(root);
342
343
  const out = [];
343
- plannedPlanEntries(root).forEach((e, i) => {
344
- if (holds.plans[i] === "held-for-operator")
345
- out.push({ file: "plans", index: i, title: e.title }); // vocabulary-ok: the held-for-operator hold's key
346
- });
344
+ const entriesOf = (entries, held, file) => {
345
+ entries.forEach((e, i) => {
346
+ if (held[i] === "held-for-operator")
347
+ out.push({ file, index: i, title: e.title }); // vocabulary-ok: the held-for-operator hold's key
348
+ });
349
+ };
350
+ entriesOf(plannedPlanEntries(root), holds.plans, "plans");
351
+ entriesOf(openBugEntries(root), holds.bugs, "bugs");
347
352
  return out;
348
353
  }
349
- /** The planned plans whose hold is exactly `"needs-review"` or `"needs-replan"`, in file
350
- * order: the plans parked by a Needs-review/Needs-replan note until the plan role splits or
351
- * replans them (the `stalled-plans` team alert's input), each carrying the 0-based PLANS.md
352
- * index and the hold. Same `backlogHolds(root)` + `plannedPlanEntries(root)` pair
353
- * `operatorHeldEntries` uses, so the alert and the doctor's `stalled plans` row classify
354
- * through the same `entryHold` and cannot disagree on which holds are role-resolvable. A
355
- * held-for-operator plan (a note at the held threshold) is excluded — it feeds
356
- * `operatorHeldEntries` and the `operator-held` alert instead. A missing file yields `[]`. */
357
- export function stalledPlanEntries(root) {
354
+ /** The display noun for a backlog-file discriminator — the `"plans" | "bugs"` `file` on
355
+ * RefusedEntry and StalledEntry (and the same discriminator the prompt's named-hold wording
356
+ * and the doctor's Stalled row take): BUGS.md is a "Bug" and PLANS.md a "Plan", and the
357
+ * lowercase form is the `Refused plan|bug:` label and the `tumwater plan|bug --release`
358
+ * command noun the operator-held alert body interpolates. */
359
+ export function entryKindLabel(file) {
360
+ return file === "bugs" ? "Bug" : "Plan";
361
+ }
362
+ /** The planned plans and open bugs whose hold is exactly `"needs-review"` or
363
+ * `"needs-replan"`, in file order: the entries parked by a Needs-review/Needs-replan note
364
+ * until the plan role splits or replans them (the `stalled-entries` team alert's input), each
365
+ * carrying its backlog file, 0-based index and the hold. Same `backlogHolds(root)` + the
366
+ * planned/`openBugEntries` readers `operatorHeldEntries` uses, so the alert and the doctor's
367
+ * `stalled entries` row classify through the same `entryHold` and cannot disagree on which
368
+ * holds are role-resolvable. A held-for-operator entry (a note at the held threshold) is
369
+ * excluded — it feeds `operatorHeldEntries` and the `operator-held` alert instead. A missing
370
+ * file yields `[]`. */
371
+ export function stalledEntries(root) {
358
372
  const holds = backlogHolds(root);
359
373
  const out = [];
360
- plannedPlanEntries(root).forEach((e, i) => {
361
- const hold = holds.plans[i];
362
- if (hold === "needs-review" || hold === "needs-replan")
363
- out.push({ index: i, title: e.title, hold });
364
- });
374
+ const entriesOf = (entries, held, file) => {
375
+ entries.forEach((e, i) => {
376
+ const hold = held[i];
377
+ if (hold === "needs-review" || hold === "needs-replan")
378
+ out.push({ file, index: i, title: e.title, hold });
379
+ });
380
+ };
381
+ entriesOf(plannedPlanEntries(root), holds.plans, "plans");
382
+ entriesOf(openBugEntries(root), holds.bugs, "bugs");
365
383
  return out;
366
384
  }
367
385
  /** The planned plans, then the open bugs, whose hold is `"refused"`, in file order: the