tumwater 0.1.2 → 0.1.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 (95) hide show
  1. package/README.md +26 -95
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/backlog/backlog-commit.js +31 -0
  4. package/dist/src/backlog/backlog-eligibility.js +41 -6
  5. package/dist/src/backlog/backlog-md.js +19 -1
  6. package/dist/src/backlog/backlog-write.js +152 -11
  7. package/dist/src/baseline/main-red.js +10 -4
  8. package/dist/src/brief-edit.js +42 -0
  9. package/dist/src/brief.js +33 -2
  10. package/dist/src/change/change-data.js +12 -3
  11. package/dist/src/cli/brief-command.js +56 -0
  12. package/dist/src/cli/cli-command-args.js +3 -4
  13. package/dist/src/cli/cli-editor.js +52 -0
  14. package/dist/src/cli/cli-flag-specs.js +23 -10
  15. package/dist/src/cli/cli-output.js +9 -0
  16. package/dist/src/cli/cli-query-commands.js +32 -6
  17. package/dist/src/cli/cli-run.js +105 -12
  18. package/dist/src/cli/completion.js +149 -0
  19. package/dist/src/cli/help.js +71 -16
  20. package/dist/src/cli/question-commands.js +18 -2
  21. package/dist/src/cli.js +49 -10
  22. package/dist/src/config/config-views.js +12 -12
  23. package/dist/src/config/config-write.js +1 -1
  24. package/dist/src/doctor/doctor-backlog.js +75 -4
  25. package/dist/src/doctor/doctor-checks.js +31 -4
  26. package/dist/src/doctor/doctor-holds.js +83 -4
  27. package/dist/src/doctor/doctor-notify.js +28 -0
  28. package/dist/src/doctor/doctor.js +9 -2
  29. package/dist/src/events/event-format.js +12 -1
  30. package/dist/src/events/notify.js +75 -26
  31. package/dist/src/fleet/fleet-state.js +25 -1
  32. package/dist/src/fleet/orchestrator-info.js +2 -0
  33. package/dist/src/git/commit-message.js +6 -0
  34. package/dist/src/git/git.js +11 -0
  35. package/dist/src/gui/gui-endpoint-commands.js +52 -5
  36. package/dist/src/gui/gui-endpoints.js +12 -3
  37. package/dist/src/gui/gui-server.js +5 -2
  38. package/dist/src/history/history-data.js +21 -0
  39. package/dist/src/history/history.js +24 -4
  40. package/dist/src/inbox/inbox-attachments.js +9 -4
  41. package/dist/src/inbox/prompt-commands.js +61 -12
  42. package/dist/src/landing/landing-git.js +1 -2
  43. package/dist/src/landing/landing-queue-render.js +28 -3
  44. package/dist/src/landing/landing-slot.js +10 -0
  45. package/dist/src/loop/loop-pi.js +2 -2
  46. package/dist/src/loop/loop-state.js +9 -0
  47. package/dist/src/loop/loop-tick.js +9 -3
  48. package/dist/src/loop/loop.js +5 -1
  49. package/dist/src/operator/operator-commands.js +43 -4
  50. package/dist/src/operator/operator-intent.js +18 -1
  51. package/dist/src/operator/retire.js +17 -1
  52. package/dist/src/orchestrator/orchestrator-scheduling.js +60 -6
  53. package/dist/src/orchestrator/orchestrator.js +10 -5
  54. package/dist/src/paths.js +5 -0
  55. package/dist/src/pi/pi-watchdogs.js +44 -41
  56. package/dist/src/process/supervisor.js +15 -4
  57. package/dist/src/prompt/prompt-followup.js +7 -1
  58. package/dist/src/report/report-data.js +5 -1
  59. package/dist/src/report/report-render.js +46 -0
  60. package/dist/src/review/protected-edits.js +32 -0
  61. package/dist/src/review/review.js +28 -7
  62. package/dist/src/roles/role-catalog.js +17 -5
  63. package/dist/src/roles/roles.js +2 -1
  64. package/dist/src/scheduling/claims.js +10 -1
  65. package/dist/src/scheduling/scheduling.js +17 -1
  66. package/dist/src/status/status-data.js +10 -5
  67. package/dist/src/status/status-polls.js +25 -1
  68. package/dist/src/tick/stage-check.js +5 -1
  69. package/dist/src/tick/tick-detail-data.js +15 -2
  70. package/dist/src/tick/tick-detail.js +38 -10
  71. package/dist/src/tick/tick-stage.js +32 -2
  72. package/dist/src/tick/tick-verdict.js +1 -0
  73. package/dist/src/ui/badges.js +7 -0
  74. package/dist/src/ui/gui/gui-client-boot.js +10 -4
  75. package/dist/src/ui/gui/gui-client-fleet.js +9 -1
  76. package/dist/src/ui/gui/gui-client-history.js +2 -1
  77. package/dist/src/ui/gui/gui-client-loops.js +4 -5
  78. package/dist/src/ui/gui/gui-client-model.js +4 -1
  79. package/dist/src/ui/gui/gui-client-operator.js +61 -1
  80. package/dist/src/ui/gui/gui-client-pending.js +4 -3
  81. package/dist/src/ui/gui/gui-client-report.js +15 -5
  82. package/dist/src/ui/gui/gui-client-settings.js +27 -1
  83. package/dist/src/ui/gui/gui-page.js +1 -0
  84. package/dist/src/ui/status-model.js +24 -36
  85. package/dist/src/ui/status-payload.js +10 -1
  86. package/dist/src/ui/status-render.js +2 -1
  87. package/dist/src/ui/tone.js +3 -1
  88. package/dist/src/ui/tui/tui-frame.js +9 -1
  89. package/dist/src/ui/tui/tui-input.js +11 -0
  90. package/dist/src/ui/tui/tui-keys.js +129 -8
  91. package/dist/src/ui/tui/tui-pane.js +15 -1
  92. package/dist/src/ui/tui/tui.js +4 -2
  93. package/dist/src/verdict/fix-claim.js +98 -18
  94. package/dist/src/verdict/refusal.js +54 -2
  95. package/package.json +1 -1
package/README.md CHANGED
@@ -28,7 +28,7 @@ spend on a subscription) and is not part of `npm test`, which stays green withou
28
28
  ## Status
29
29
 
30
30
  <!-- tumwater:status:start -->
31
- **v0.1.1**: working harness. The director and 14 of the 15 roles are enabled by default; `telemetry`,
31
+ **v0.1.3**: working harness. The director and 14 of the 15 roles are enabled by default; `telemetry`,
32
32
  which files harness bugs from tumwater's own event log, is opt-in (`roles.telemetry.enabled`).
33
33
 
34
34
  Open work: [PLANS.md](PLANS.md) (planned), [BUGS.md](BUGS.md) (open bugs),
@@ -38,103 +38,34 @@ Open work: [PLANS.md](PLANS.md) (planned), [BUGS.md](BUGS.md) (open bugs),
38
38
  ## Usage
39
39
 
40
40
  ```bash
41
- cd your-project # a new or existing directory
41
+ cd your-project # a new or existing directory
42
42
  tumwater init "Build a tiny markdown-to-html converter CLI in Python."
43
- # add --template <id> to seed from a bundled starting point
44
- # (blank, python-cli, node-cli, static-site); --list-templates
45
- # prints the catalog
46
- # add --file <path> to read the brief from a file
47
- # add --adopt to adopt an existing repo as-is
48
- # a fresh/empty project is seeded with
49
- # "bootstrap": {"untilPlansDone": 5}, holding the maintenance
50
- # loops until 5 plans are done; remove "bootstrap" from
51
- # tumwater.json to end that early
52
- tumwater run # start the loops (Ctrl+C to stop)
53
- tumwater run --gui # ... and serve the browser dashboard at http://127.0.0.1:7180 from the same process
54
- tumwater run --for 2h # run for a bounded window (capped at 90d), then drain and exit like Ctrl+C would
55
- tumwater run --once # one full round of ticks, drains its landings, and exits (for cron/CI);
56
- # add --role <id> to scope the round to one loop
43
+ # --template, --file, and --adopt pick a starting point (see docs/cli.md)
44
+ tumwater run # start the loops (Ctrl+C to stop)
45
+ tumwater run --gui # ... and serve the browser dashboard at http://127.0.0.1:7180
46
+ tumwater run --for 2h # run for a bounded window, then drain, summarize, and exit
47
+ tumwater run --once # one round of ticks for cron/CI; exits 3 if a loop's tick failed
57
48
  ```
58
49
 
59
- Then, from another terminal:
60
-
61
- | To | Run |
62
- | --- | --- |
63
- | Watch the fleet | `tumwater tui`, or the browser dashboard at http://127.0.0.1:7180 — `tumwater gui` on its own, or `tumwater run --gui` to boot the fleet and the dashboard together |
64
- | Watch per-tick history | `tumwater history [--role <id>] [-n N] [--since <duration>] [--grep <text>] [--result <changes|none|problems>]`, or `tumwater history --json` for the rows as JSON; `tumwater tick <role> <n>` for one tick's full event trail (a summary header — when it ran, how long, result, usage — followed by the tick's events, oldest first; `--last` shows the newest completed tick's trail instead of numbering one; `--json` prints the payload as JSON, `null` when the log holds no such tick) |
65
- | Check state | `tumwater status`, `tumwater logs -f`, `tumwater logs --since <duration>`, `tumwater logs --grep <text>`, `tumwater logs --json` (the event feed as NDJSON, for scripts), `tumwater logs --role <id>`, `tumwater queue [--json]` (the durable land queue in execution order — one line per waiting landing with its position, loop, tick, pinned sha, age, and summary; `--json` for scripts), `tumwater backlog` (planned features, open bugs, open questions as Markdown, each held entry's heading marked `[refused]`, `[needs review]`, `[needs replan]`, or `[blocked: requires …]`), `tumwater backlog --json` (the backlog as JSON, for scripts, with a `holds.plans`/`holds.bugs` array parallel to `plans`/`bugs`), `tumwater role <id>` (one loop's standing prompt — find text, `instructions` override, resolved model and interval, enabled/paused state, its notebook — plus its next tick's assembled prompt, which shows the oldest queued prompt without consuming it (one is dequeued per tick; `--json` for scripts); `--clear-notes` clears that loop's notebook and `--notes-file <path>` (`-` for stdin) replaces it (4 KB cap); the dashboard's loop drawer edits or clears the notebook the same way, and the TUI's notebook view (Ctrl+N) clears it with Ctrl+X pressed twice) |
66
- | See a loop's pending change | `tumwater diff --role <id>` — that loop's branch's unlanded commits (with the patch) and its worktree's uncommitted edits (staged and unstaged); without `--role`, one line per loop holding pending work; `--json` prints the payload as data |
67
- | Steer the project | `tumwater prompt "prefer no third-party deps"` queues a request for the director; add `--role <id>` to aim it at one loop's next tick, `--file <path>` (`-` for stdin) to read the text from a file, or `--at <duration>` to defer it until the duration passes (45s, 90m, 1h30m, 1d; capped at 90d), `tumwater prompt --list` shows the queued prompts numbered and grouped by loop, with how long each has waited (`--json` for scripts), and `tumwater prompt --cancel <n>` removes the Nth entry as `--list` shows them, and `tumwater prompt --edit <n> "new text" [--attach <path>]` rewrites the Nth entry in place (or `--edit <n> --file <path>` takes the replacement from a file), keeping its position and wait time, and appends any `--attach` images after the ones it already carries (both take `--role <id>` when several loops share that number), and `--attach <path>` (repeatable, up to 4 images — png, jpg, jpeg, gif, webp, bmp, each at most 5 MiB) attaches an image the receiving loop reads at its next tick, whether it is queued or edited; `tumwater bug "<symptom>"` files a bug into BUGS.md's Open section and wakes the bugfix loop, `tumwater plan "<title>" [body...]` — or `--file <path>` (`-` for stdin) to file one from a markdown file, its first line the title and the rest the body, kept line for line — files a plan request into PLANS.md's Planned section and wakes the feature loop — both stamped as operator-reported (the other half of `tumwater backlog`), `--list` shows a command's own entries numbered with their eligibility marks (`--json` for scripts), and `--cancel <n>` retracts one or `--edit <n> "<text>"` rewrites one in place (or `--edit <n> --file <path>` takes the replacement from a file, keeping its lines), neither with a wake; the dashboard's plan/bug drawer cancels an entry the same way, after a confirming second click, and the TUI's backlog pane does too, with Ctrl+X pressed twice on the selected entry (Ctrl+E edits its title; Ctrl+N files a new plan or bug by title and wakes its loop), the drawer edits an entry's title and body in place, and the Plans/Bugs tabs' New form files one with an optional markdown body |
68
- | Answer open questions | `tumwater questions` lists QUESTIONS.md's open questions numbered (as the loops wrote them); `tumwater questions answer <n> "decision text"` moves the Nth entry to ## Answered with your dated answer (`--file <path>`, `-` for stdin, records a multiline decision with its lines kept; loops read the file back on their next tick); `--json` prints the list as data; the dashboard's question drawer records an answer with its lines kept, as `--file` does, and the TUI's backlog pane answers the selected question with Ctrl+A |
69
- | Control the loops | `tumwater pause [--for <duration>]` / `resume [--role <id>]` (fleet or one loop; `--for 2h` auto-resumes, capped at 90d; add `--reason <text>` on a fleet pause to state why — it shows on `status`, the TUI, and the dashboard; the TUI's Ctrl+F takes a duration and reason too, and its Ctrl+P on a loop's transcript takes a duration, and its Ctrl+W takes a duration or `cancel`), `tumwater wake` (skip backoff; `wake --in 45m` schedules the wake for 45 minutes from now — the marker is written immediately but consumed no earlier than the deadline, like `pause --for`'s auto-resume, and each scheduled wake stands on its own, so later wakes do not cancel it, while `tumwater wake --cancel [--role <id>]` drops the pending scheduled wakes (or that loop's share of them), `tumwater wake --list [--role <id>] [--json]` lists them, and the dashboard's Wake later menu and loop drawer cancel pending wakes the same way; the dashboard's loop drawer schedules one loop's wake the same way ("Wake later…"); `tumwater status` and the dashboard's loop rows show the pending wake until it fires), `tumwater reclaim [--dry-run]` (drop gitignored build outputs from every harness worktree; `--dry-run` lists the candidates and cleans nothing), `tumwater retire --role <id>` (remove a disabled loop's worktree, branch, and per-role state — use after setting `enabled: false` for that role; `--force` overrides the safety rails), `tumwater abort --role <id>`, `tumwater stop` (drain and exit, like Ctrl+C), `tumwater reset-counters [--role <id>]` (zero the per-loop counters the dashboards show, starting a fresh observation window — scheduling is untouched; the dashboard's Loops card and loop drawer reach the same reset) |
70
- | Audit | `tumwater doctor` (pre-flight, naming a standing fleet or per-role pause or quiet-hours window holding now, today's spend against the daily cap, and the maintenance allowance's rolling-24 h use; `--json` prints the report as JSON, for scripts; the dashboard's Settings view runs the same checks in its Health check card), `tumwater report` (usage and cost, with landed commits by role and the work/maintenance split; totals include landing runs — reviewer + conflict resolution), `tumwater report --role <id>` (scopes the day or `--since` report to one loop's ticks, tokens, cost, and commits; landing runs and backlog tallies are fleet-wide and omitted; with `--failures`, scopes the failure digest to that loop too), `tumwater report --since <duration>` (totals over a trailing window, capped at 7d; with `--failures`, the failure digest over that window instead), `tumwater report --json` (the `--days`/`--since`/`--failures` reports as JSON, for scripts), `tumwater report --failures` (the failure digest: per-role outcomes, each role's time and spend by outcome, and the top five loss causes ranked by agent-hours, with a marker naming how many were cut; `--since <duration>` windows the digest over a trailing span, capped at 7d, compared against the equal-length window before it) |
71
-
72
- `tumwater help` lists every command and flag; `tumwater help <command>` shows one command's
73
- usage. `gui --all-interfaces` exposes the dashboard, and with it the director prompt, to your
74
- whole network, so pair it with `--token <secret>`.
75
-
76
- Settings live in `tumwater.json`: enabled roles, model (either one selector or a map of tiers
77
- `small`/`default`/`strong` — omitted tiers inherit `default`; per-tier `fallback` overrides may
78
- name a model or `"pause"`, and a role's `model` may name a tier; see
79
- [plans/model-tiers.md](plans/model-tiers.md)),
80
- intervals, per-role `instances` (`feature` and `bugfix` may each run 1–8 parallel runners, every one
81
- with its own branch and state; default 1), the daily spend cap
82
- (`maxDailyCostUsd`, with optional per-role caps `maxDailyCostUsdPerRole` — a loop over its own
83
- cap starts no new ticks until the next local day or a live edit), a
84
- `maintenancePerWorkLanding` ratio (default 2; a rolling 24 h allowance of that many
85
- code-maintenance landings per feature/bugfix/director landing, plus a floor of 12 — when the
86
- window reaches it the scheduler holds the enabled maintenance loops until it rolls under, and a
87
- fresh `tumwater wake <role>` or a queued prompt for one admits a single tick anyway), a nightly
88
- `quietHours` window (e.g. `"23:00-07:00"` local time) during
89
- which role loops start no new ticks (the director is exempt), with optional per-role windows
90
- `quietHoursPerRole` — a loop inside its own window starts no new ticks, whether or not the
91
- fleet-wide window covers now — a `diskHoldGB` free-space floor (default 10; when the volume
92
- holding the worktrees drops below it, no new work starts until free space recovers 5 GB above it;
93
- 0 disables), a `diskReclaimGB` pressure-reclaim threshold (default 40; below it idle worktrees
94
- drop their gitignored build outputs before the hold engages; 0 disables), and a
95
- `worktreeIdleReclaimHours` window (default 24; an hourly pass drops a worktree's gitignored build
96
- outputs once it has sat unused that long, whatever the free space; 0 disables), a
97
- `worktreeSlots` count of pooled checkouts shared by role ticks and landing vets (default
98
- `maxConcurrent` + 1; the director's worktree sits outside the pool) — user-defined
99
- `customLoops`, and an
100
- optional `notify` shell command run when the fleet needs a human (a budget pause, a budget warning
101
- at 80% of the cap while the gate is still open, an error-streak
102
- breaker trip, a failed landing, a blocked restart, a posted question — the command gets
103
- `TUMWATER_EVENT_TYPE`, `TUMWATER_EVENT_LOOP`, and `TUMWATER_EVENT_MESSAGE` in its environment;
104
- verify it with `tumwater notify --test`).
105
- Edits apply live while the fleet runs.
106
- From the terminal, `tumwater config` prints the effective config as JSON, `tumwater config get
107
- <key>` reads one resolved value, `tumwater config set <key> <value>` writes one top-level
108
- key, and `tumwater config unset <key>` removes one — the inverse of `set`, so a value reverts
109
- to its default or disappears from its map or section; dotted keys
110
- (`maxDailyCostUsdPerRole.feature 1.5`, `roles.qa.model x`) merge one entry into the existing
111
- map or role entry, while bare keys replace the whole value. `roles` always merges the built-in
112
- catalog per entry at load, so a bare `config set roles` replaces the file's section but cannot
113
- disable a role by omission — disable one with `tumwater config set roles.<id>.enabled false`.
114
-
115
- **Backends:** any OpenAI-compatible model pi can reach works; set `model` in `tumwater.json` to
116
- one `provider/id[:thinking]` selector (plus `fallback` to a free selector), or a map of tiers
117
- `small`/`default`/`strong`. A role whose primary keeps failing with provider-class errors
118
- (three consecutive 429s or backend failures) runs its next ticks on its tier's resolved
119
- `fallback` pair and probes the primary once the 5-minute cooldown elapses, returning to it
120
- when a probe answers; `tumwater status`, the TUI, the dashboard, and `tumwater role <id>` name
121
- the off-model episode. Set `agent` to run bare selectors and the no-model default on another
122
- backend — `claude` runs on Claude Code, with per-tier defaults (`claude-sonnet-5-5` and
123
- friends) and per-backend `agents.claude.bin`/`agents.claude.args` overrides; a selector whose
124
- provider names a backend id (`claude/claude-opus-5-5`) always routes there. A claude run's
125
- cost comes from the CLI's own price table. A run paid by a claude.ai subscription is exempt
126
- from the daily dollar cap: its API-equivalent cost is tracked as notional spend and shown on
127
- its own line in `tumwater report`, never in the cap; a run billed to an API key — or one that
128
- saw `isUsingOverage` — counts against the cap like a priced pi model. A subscription claude
129
- model with no overage also counts as free for the budget badge and fallback decisions, so at
130
- the cap it stays put while priced pi seams move to their fallback. A rejected claude.ai
131
- subscription usage window holds subscription claude seams until it resets (a first
132
- `allowed_warning` is logged too); `tumwater status`, the TUI, the dashboard, and
133
- `tumwater role <id>` show the window in place of the daily spend on a subscription-only fleet
134
- (or beside it on a mixed one, where the daily spend covers priced seams only), and a run cut off
135
- by a rejected window keeps its session and worktree edits and resumes once the window resets.
136
- See
137
- [docs/backends.md](docs/backends.md) for requirements and a worked setup.
50
+ A fresh or empty project is seeded with `"bootstrap": {"untilPlansDone": 5}`, which holds the
51
+ maintenance loops until 5 plans are done; remove it from `tumwater.json` to end that early.
52
+
53
+ From another terminal, `tumwater tui` or the browser dashboard watches the fleet, `tumwater status`
54
+ and `tumwater logs -f` check it, `tumwater brief --file <path>` replaces the initial prompt,
55
+ `tumwater prompt "<text>"` queues a request for the director, `tumwater plan` and `tumwater bug`
56
+ file backlog entries, and `tumwater pause`, `resume`, `wake`, and `stop` control the loops.
57
+ `tumwater doctor` audits the fleet and `tumwater report` shows usage and cost. `gui
58
+ --all-interfaces` exposes the dashboard, and with it the director prompt, to your whole network,
59
+ so pair it with `--token <secret>`.
60
+
61
+ Every command, flag, and TUI key is in [docs/cli.md](docs/cli.md); `tumwater help` prints the
62
+ same list, and `tumwater completion bash|zsh` prints shell completion. Settings live in
63
+ `tumwater.json` and edits apply live while the fleet runs; see [docs/config.md](docs/config.md).
64
+
65
+ **Backends:** any OpenAI-compatible model pi can reach works, chosen by `model` in `tumwater.json`
66
+ with a `fallback` that a failing role switches to. `agent: "claude"` runs bare selectors on Claude
67
+ Code instead. Requirements, the fallback and usage-window behavior, and how subscription runs are
68
+ billed (as notional spend, outside the daily cap) are in [docs/backends.md](docs/backends.md).
138
69
 
139
70
  For how the loops, review gate, scheduling, and self-redeploy work, see
140
71
  [docs/how-it-works.md](docs/how-it-works.md). For a measured comparison of tumwater's own
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "90a6c2c27eb822740efee37d84a0518b76b4d7bb",
3
- "builtAt": 1791821277046,
2
+ "sha": "e44157e8609091b848898ab0e370e0995d931404",
3
+ "builtAt": 1791912860508,
4
4
  "root": "/home/runner/work/tumwater/tumwater"
5
5
  }
@@ -0,0 +1,31 @@
1
+ import { loadConfigSafe } from "../config/config.js";
2
+ import { withStateLockAsync } from "../concurrency/lock.js";
3
+ import { currentBranch, targetBranch } from "../git/git.js";
4
+ import { COMMIT_IDENT, gitTry } from "../git/git-run.js";
5
+ import { primaryWorktreeLockPath } from "../paths.js";
6
+ /** Commit one operator-written backlog file on main, so the entry reaches the worktrees that
7
+ * branch from main and the primary checkout stays clean for the next fast-forward. Runs under
8
+ * the primary-worktree lock `ffMainTo`'s working-tree arm takes, so the commit and a
9
+ * fast-forward cannot interleave. Returns false, writing nothing, when the primary is not on
10
+ * the base branch (the ref-push arm: the working tree is not main's) or when git fails; it
11
+ * never throws, and the entry stays written either way. `--only` with a pathspec commits just
12
+ * that file's working-tree content and leaves other staged or dirty paths alone. */
13
+ export async function commitOperatorBacklogEdit(root, file, subject) {
14
+ try {
15
+ // Yield first: a synchronous caller (the TUI key handler) may write again right after this
16
+ // call, and that write takes the same lock synchronously — a lock already held across our
17
+ // awaits would block its event loop until the lock times out.
18
+ await new Promise((resolve) => setImmediate(resolve));
19
+ return await withStateLockAsync(primaryWorktreeLockPath(root), async () => {
20
+ const primary = await currentBranch(root);
21
+ if (primary === null)
22
+ return false;
23
+ if (targetBranch(loadConfigSafe(root).config?.baseBranch, primary) !== primary)
24
+ return false;
25
+ return (await gitTry(root, ...COMMIT_IDENT, "commit", "--only", "-m", subject, "--", file)) !== null;
26
+ });
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
@@ -1,14 +1,14 @@
1
1
  /** Backlog eligibility: which PLANS.md / BUGS.md entries a work loop may take now (plans,
2
2
  * parallel-work-instances, part 2/7). An entry is held when its body carries a **Refused …**,
3
3
  * **Needs review …** or **Needs replan …** note, or when its heading's trailing parenthetical
4
- * names a prerequisite (`requires parts 1/5–3/5 landed`) that is itself still listed under
4
+ * (or a parenthetical left open by a wrapped heading) names a prerequisite (`requires parts 1/5–3/5 landed`) that is itself still listed under
5
5
  * `## Planned`. The parsers are pure over markdown text; `eligibleEntries` is the stat-backed
6
6
  * reader for a caller that wants the list. The backlog index marks every held entry so a loop
7
7
  * skips it without re-deriving the rule. */
8
8
  import { collapseWhitespace } from "../text/text.js";
9
9
  import { NEEDS_REPLAN_PREFIX, NEEDS_REVIEW_PREFIX } from "../roles/role-guidance.js";
10
10
  import { baseRoleOf } from "../roles/loop-ids.js";
11
- import { trailingParenthetical } from "./backlog-md.js";
11
+ import { openParenthetical, trailingParenthetical } from "./backlog-md.js";
12
12
  import { actionableEntryRangesFor, stripEntryStamp } from "./backlog-structure.js";
13
13
  import { openBugEntries, plannedPlanEntries } from "./backlog.js";
14
14
  /** The `**Refused …` note prefix a refusing tick writes. Every hold note — this one and the
@@ -21,6 +21,30 @@ const REFUSED_PREFIX = "**Refused ";
21
21
  function hasNoteLine(body, prefix) {
22
22
  return body.split("\n").some((line) => line.trimStart().startsWith(prefix));
23
23
  }
24
+ /** The text of `body`'s Refused note: the first line (ignoring leading indentation) that begins
25
+ * with REFUSED_PREFIX, trimmed, with the leading `**` and the first closing `**` removed. Null
26
+ * when no line matches — the same line-prefix rule entryHold's "refused" hold uses. */
27
+ export function refusalNote(body) {
28
+ const line = body.split("\n").map((l) => l.trim()).find((l) => l.startsWith(REFUSED_PREFIX));
29
+ return line === undefined ? null : line.replace(/^\*\*/, "").replace("**", "");
30
+ }
31
+ /** `lines` without any Refused note line (the same line-prefix rule hasNoteLine applies). When a
32
+ * dropped note sat between two blank lines, the blank line after it goes too, so releasing
33
+ * leaves one blank line where the note was. Every other line, Needs-review and needs-replan
34
+ * notes included, is kept. */
35
+ export function withoutRefusalNotes(lines) {
36
+ const out = [];
37
+ for (let i = 0; i < lines.length; i++) {
38
+ const line = lines[i];
39
+ if (!line.trimStart().startsWith(REFUSED_PREFIX)) {
40
+ out.push(line);
41
+ continue;
42
+ }
43
+ if ((lines[i - 1] ?? "x").trim() === "" && (lines[i + 1] ?? "x").trim() === "")
44
+ i++;
45
+ }
46
+ return out;
47
+ }
24
48
  /** One prerequisite ref, resolved against the entry's own series when it names none. */
25
49
  function parseRef(chunk, ownSeries) {
26
50
  const text = chunk.trim().replace(/^parts?\s+/i, "");
@@ -83,12 +107,15 @@ export function plannedPartKeys(planned) {
83
107
  }
84
108
  return keys;
85
109
  }
86
- /** The prerequisite parts a plan heading's trailing parenthetical names through the clause
110
+ /** The prerequisite parts a plan heading's trailing parenthetical (or, when the heading wraps
111
+ * and the parenthetical stays open past the line, its open parenthetical) names through the clause
87
112
  * `requires <ref>((, | and )<ref>)* landed`, with each range (e.g. `parts 1/5–3/5`) expanded.
88
113
  * Returns [] when the heading has no such clause or the clause does not parse — an agent judges
89
114
  * an unparseable clause, exactly as it does today. Only the heading is read, never the body. */
90
115
  export function requiredParts(title) {
91
- const meta = trailingParenthetical(title);
116
+ let meta = trailingParenthetical(title);
117
+ if (meta === "")
118
+ meta = openParenthetical(title);
92
119
  if (meta === "")
93
120
  return [];
94
121
  const m = /requires\s+(.+?)\s+landed\b/i.exec(meta);
@@ -100,12 +127,20 @@ export function requiredParts(title) {
100
127
  refs.push(...parseRef(chunk, ownSeries));
101
128
  return refs;
102
129
  }
130
+ /** The prerequisite refs of `title`'s `requires … landed` clause when none of them is still in
131
+ * `plannedParts` (built by plannedPartKeys), else []. A heading with no parseable clause is never
132
+ * reported, so only a refusal whose named prerequisites have all left `## Planned` reads as
133
+ * possibly stale. */
134
+ export function prerequisitesLanded(title, plannedParts) {
135
+ const refs = requiredParts(title);
136
+ return refs.some((ref) => plannedParts.has(partKey(ref.series, ref.part))) ? [] : refs;
137
+ }
103
138
  /** Why `entry` is held against the still-`planned` entries, or null when it may be taken: a
104
139
  * `**Refused …` line (refused), a Needs-review line (needs-review), a Needs-replan line
105
140
  * (needs-replan — the plan loop owns it), or a prerequisite `(series, part)` still among
106
141
  * `planned` ({ blockedBy }). Series compare case-insensitively; part tokens compare verbatim.
107
- * Bodies mentioning "requires" are never consulted — only the heading's trailing
108
- * parenthetical. */
142
+ * Bodies mentioning "requires" are never consulted — only the heading's trailing or
143
+ * still-open parenthetical. */
109
144
  export function entryHold(entry, planned) {
110
145
  return entryHoldAgainst(entry, plannedPartKeys(planned));
111
146
  }
@@ -262,7 +262,8 @@ export function headingMetadata(lines, start) {
262
262
  * and silently dropped the epitaph's date from the day report (BUGS.md 2026-09-29). Unbalanced
263
263
  * text (no matching open paren) yields "" — the guard then treats the bullet as body text, as
264
264
  * before. The one home for this rule: entryDates reads it for `- ` epitaphs and
265
- * backlog-eligibility.ts's requiredParts for a heading's prerequisite clause. */
265
+ * backlog-eligibility.ts's requiredParts for a heading's prerequisite clause (falling back to
266
+ * openParenthetical when the heading's parenthetical wraps past the line). */
266
267
  export function trailingParenthetical(line) {
267
268
  if (!line.endsWith(")"))
268
269
  return "";
@@ -276,6 +277,23 @@ export function trailingParenthetical(line) {
276
277
  }
277
278
  return "";
278
279
  }
280
+ /** The text after the outermost "(" still open at the end of `line`, or "" when every group
281
+ * closes: a wrapped `### ` heading's stamp parenthetical continues on the following lines, so
282
+ * the heading line has no closing ")". A ")" at depth 0 is ignored. */
283
+ export function openParenthetical(line) {
284
+ let depth = 0;
285
+ let open = -1;
286
+ for (let i = 0; i < line.length; i++) {
287
+ const ch = line[i];
288
+ if (ch === "(") {
289
+ if (depth++ === 0)
290
+ open = i;
291
+ }
292
+ else if (ch === ")" && depth > 0)
293
+ depth--;
294
+ }
295
+ return depth > 0 ? line.slice(open + 1) : "";
296
+ }
279
297
  /** A text's LAST `<dateRe>` capture — the completion rule both branches of entryDates share:
280
298
  * an entry's completion is its LAST dated verb, not the first, because found-by,
281
299
  * decomposition, and sibling mentions ("decomposed from the X bug fixed <date>") all precede
@@ -13,20 +13,23 @@ import { fencedHeadingTitle, fenceTracker, headingMetadata, nextSectionHeading }
13
13
  import { actionableEntryRanges, stripEntryStamp } from "./backlog-structure.js";
14
14
  import { readTextIfExists, writeTextAtomic } from "../files/files.js";
15
15
  import { formatDate } from "../text/datetime.js";
16
- import { fail, say, sayJson } from "../cli/cli-output.js";
16
+ import { fail, say, sayJson, warnIfUncommitted } from "../cli/cli-output.js";
17
17
  import { parsePositionFlag, rejectRepeatFlag } from "../cli/cli-args.js";
18
18
  import { promptFileValue, readPromptFileArg } from "../cli/cli-command-args.js";
19
19
  import { plannedPlanEntries, openBugEntries } from "./backlog.js";
20
- import { backlogHolds, holdMark } from "./backlog-eligibility.js";
20
+ import { backlogHolds, holdMark, withoutRefusalNotes } from "./backlog-eligibility.js";
21
21
  import { numberedList } from "../text/markdown.js";
22
22
  import { collapseWhitespace, trimLeadingBlankLines, trimTrailingBlankLines } from "../text/text.js";
23
23
  import { BUGS_TEMPLATE, PLANS_TEMPLATE } from "../init/init.js";
24
24
  import { JSON_FLAG, rejectUnknownArgs } from "../cli/cli-flag-specs.js";
25
25
  import { requireReadyRepo } from "../cli/cli-repo-gate.js";
26
- import { submitRolePromptAndWake } from "../operator/operator-intent.js";
26
+ import { editInEditor } from "../cli/cli-editor.js";
27
+ import { requestWake, submitRolePromptAndWake } from "../operator/operator-intent.js";
27
28
  import { promptLengthProblem } from "../inbox/inbox-submit.js";
28
29
  import { withStateLock } from "../concurrency/lock.js";
29
30
  import { primaryWorktreeLockPath } from "../paths.js";
31
+ import { commitOperatorBacklogEdit } from "./backlog-commit.js";
32
+ import { stampedSubject } from "../git/commit-message.js";
30
33
  /** Today's stamp body every operator-filed entry carries, parenthesized by the caller that
31
34
  * builds the heading. Computed once per call (not module load) so a long-lived process
32
35
  * stamps with the date it filed on. */
@@ -337,7 +340,7 @@ export async function fileAndAnnounce(root, args, command, file, role, wakeText,
337
340
  const wakeProblem = promptLengthProblem(wakeText(splitEntryFile(text).title), role);
338
341
  if (wakeProblem)
339
342
  fail(wakeProblem);
340
- announceFiled(root, command, fileText(text), role, wakeText, json);
343
+ await announceFiled(root, command, fileText(text), role, wakeText, json);
341
344
  return;
342
345
  }
343
346
  // peelPositionals cannot serve this command: bug/plan's text is operator prose, and a
@@ -350,11 +353,12 @@ export async function fileAndAnnounce(root, args, command, file, role, wakeText,
350
353
  const wakeProblem = promptLengthProblem(wakeText(titleOf(positionals)), role);
351
354
  if (wakeProblem)
352
355
  fail(wakeProblem);
353
- announceFiled(root, command, file(positionals), role, wakeText, json);
356
+ await announceFiled(root, command, file(positionals), role, wakeText, json);
354
357
  }
355
- /** The tail both fileAndAnnounce forms share: wake the `role` loop with the filed entry's
358
+ /** The tail both fileAndAnnounce forms share: commit the entry on main, wake the `role` loop with the filed entry's
356
359
  * title, then print the confirmation (the wake line rides the prose output only). */
357
- function announceFiled(root, command, filed, role, wakeText, json) {
360
+ async function announceFiled(root, command, filed, role, wakeText, json) {
361
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, filed.file, stampedSubject("operator", `File ${command}: ${filed.title}`)), filed.file);
358
362
  const wake = submitRolePromptAndWake(root, role, wakeText(filed.title));
359
363
  sayFiled(command, filed, json);
360
364
  if (!json)
@@ -470,7 +474,9 @@ export function cancelPlan(root, n) {
470
474
  * title and body kept line for line (splitEntryFile), position and original stamp kept. A body
471
475
  * that would grow a phantom section writes nothing. */
472
476
  export function editPlanFromText(root, n, text, usage) {
473
- const { title, body } = splitEntryFile(text);
477
+ const { title: rawTitle, body } = splitEntryFile(text);
478
+ // A first line pasted back as the full heading carries the entry's stamp; the splice re-adds it.
479
+ const title = stripEntryStamp(rawTitle);
474
480
  if (title === "")
475
481
  fail(`plan needs a title: ${usage}`);
476
482
  const problem = bodyStructureProblem(body);
@@ -481,7 +487,8 @@ export function editPlanFromText(root, n, text, usage) {
481
487
  /** `tumwater bug --edit <n> --file`: the bug twin of editPlanFromText over `## Open`. The body is
482
488
  * written as the file gives it, so a bug filed with `--file` can be revised with its body. */
483
489
  export function editBugFromText(root, n, text, usage) {
484
- const { title, body } = splitEntryFile(text);
490
+ const { title: rawTitle, body } = splitEntryFile(text);
491
+ const title = stripEntryStamp(rawTitle);
485
492
  if (title === "")
486
493
  fail(`bug needs a symptom: ${usage}`);
487
494
  const problem = bodyStructureProblem(body);
@@ -547,11 +554,70 @@ export async function cancelAndAnnounce(root, args, command, cancel) {
547
554
  fail(`--cancel needs a position (usage: tumwater ${command} --cancel <n>)`);
548
555
  await requireReadyRepo(root);
549
556
  const removed = cancel(position);
557
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, removed.file, stampedSubject("operator", `Cancel ${command}: ${removed.title}`)), removed.file);
550
558
  if (json)
551
559
  sayJson({ file: removed.file, title: removed.title });
552
560
  else
553
561
  say(`cancelled ${command} in ${removed.file}: ${removed.title}`);
554
562
  }
563
+ /** Lift the Refused note from the `n`th entry (1-based, as `--list` numbers them) of the
564
+ * kind's open section and write the file, under the same lock the cancel and edit writers hold.
565
+ * An out-of-range position fails with locateEntry's wording; an entry without a Refused note
566
+ * fails too, writing nothing. */
567
+ export function releaseEntry(root, kind, n) {
568
+ const fileName = kind === "bug" ? "BUGS.md" : "PLANS.md";
569
+ const sectionTitle = kind === "bug" ? "Open" : "Planned";
570
+ return withStateLock(primaryWorktreeLockPath(root), () => {
571
+ const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
572
+ const slice = lines.slice(entry.start - 1, entry.end);
573
+ const kept = withoutRefusalNotes(slice);
574
+ if (kept.length === slice.length)
575
+ fail(`${kind} #${n} "${entry.title}" carries no Refused note`);
576
+ lines.splice(entry.start - 1, slice.length, ...kept);
577
+ writeTextAtomic(file, lines.join("\n"));
578
+ return { file: fileName, title: entry.title };
579
+ });
580
+ }
581
+ /** The shell of the `bug --release` and `plan --release` CLI cases, parsed as cancelAndAnnounce
582
+ * parses `--cancel`: `--release <n>`, an optional `--json`, anything else refused. After
583
+ * requireReadyRepo it releases the entry, commits the file on main, and wakes `role` (the
584
+ * released entry is eligible now, and that loop may be backed off) with no queued prompt. Prints
585
+ * `released <command> in <file>: <title>` plus the wake line, or only the `--json` payload. */
586
+ export async function releaseAndAnnounce(root, args, command, release, role) {
587
+ let position = null;
588
+ let json = false;
589
+ const rest = [];
590
+ for (let i = 0; i < args.length; i++) {
591
+ const arg = args[i];
592
+ if (arg === "--json") {
593
+ rejectRepeatFlag(json, "--json");
594
+ json = true;
595
+ continue;
596
+ }
597
+ if (arg === "--release") {
598
+ rejectRepeatFlag(position !== null, "--release");
599
+ position = parsePositionFlag("--release", args[i + 1], "--release 2");
600
+ i++;
601
+ continue;
602
+ }
603
+ if (arg === "--help" || arg === "-h")
604
+ continue;
605
+ rest.push(arg);
606
+ }
607
+ rejectUnknownArgs(command, rest, []);
608
+ if (position === null)
609
+ fail(`--release needs a position (usage: tumwater ${command} --release <n>)`);
610
+ await requireReadyRepo(root);
611
+ const released = release(position);
612
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, released.file, stampedSubject("operator", `Release ${command}: ${released.title}`)), released.file);
613
+ const wake = requestWake(root, [role]);
614
+ if (json)
615
+ sayJson({ file: released.file, title: released.title });
616
+ else {
617
+ say(`released ${command} in ${released.file}: ${released.title}`);
618
+ say(wake);
619
+ }
620
+ }
555
621
  /** The shared shell of the `bug --list` and `plan --list` CLI cases — the read half of the
556
622
  * operator queue whose `--edit`/`--cancel` flags address entries by position. Reads the same
557
623
  * entries, in the same file order, that locateEntry numbers for `--edit`/`--cancel`, pairs
@@ -607,6 +673,59 @@ export function listAndAnnounce(root, args, command, fileName, sectionTitle) {
607
673
  const numbered = entries.map((entry, i) => `${entry.title}${holdMark(entryHolds[i] ?? null)}`);
608
674
  say(`${noun}s in ${fileName}:\n${numberedList(numbered)}`);
609
675
  }
676
+ /** The nth entry as the markdown file `--edit <n> --file` reads back: the stamp-stripped title
677
+ * and the body with its indentation kept (read from the raw lines, not the trimmed
678
+ * BacklogEntry.body), plus the entry's eligibility hold. Read-only. */
679
+ function showEntry(root, kind, n) {
680
+ const fileName = kind === "bug" ? "BUGS.md" : "PLANS.md";
681
+ const sectionTitle = kind === "bug" ? "Open" : "Planned";
682
+ const { lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
683
+ const meta = headingMetadata(lines, entry.start - 1);
684
+ const title = collapseWhitespace(stripEntryStamp(meta.text.replace(/^### /, "")));
685
+ const body = normalizeEntryBody(lines.slice(meta.next, entry.end).join("\n"));
686
+ const holds = backlogHolds(root);
687
+ const hold = (kind === "bug" ? holds.bugs : holds.plans)[n - 1] ?? null;
688
+ return { position: n, title, body, hold };
689
+ }
690
+ /** The shell of `bug --show` and `plan --show`: `--show <n>` (once), an optional `--json`
691
+ * (once), `--help`/`-h` tolerated, anything else refused through rejectUnknownArgs. Read-only
692
+ * like `--list`: no repo gate, commit, or wake. Prose is the title, a blank line, then the body,
693
+ * so stdout is the file `--edit <n> --file` takes back. */
694
+ export function showAndAnnounce(root, args, command) {
695
+ let position = null;
696
+ let json = false;
697
+ const rest = [];
698
+ for (let i = 0; i < args.length; i++) {
699
+ const arg = args[i];
700
+ if (arg === "--json") {
701
+ rejectRepeatFlag(json, "--json");
702
+ json = true;
703
+ continue;
704
+ }
705
+ if (arg === "--show") {
706
+ rejectRepeatFlag(position !== null, "--show");
707
+ position = parsePositionFlag("--show", args[i + 1], "--show 2");
708
+ i++;
709
+ continue;
710
+ }
711
+ if (arg === "--help" || arg === "-h")
712
+ continue;
713
+ rest.push(arg);
714
+ }
715
+ rejectUnknownArgs(command, rest, []);
716
+ if (position === null)
717
+ fail(`--show needs a position (usage: tumwater ${command} --show <n>)`);
718
+ const shown = showEntry(root, command, position);
719
+ if (json)
720
+ sayJson(shown);
721
+ else
722
+ say(renderShownEntry(shown));
723
+ }
724
+ /** The prose `--show` prints: the title, a blank line, then the body (the title alone when the
725
+ * body is empty). */
726
+ function renderShownEntry(shown) {
727
+ return shown.body === "" ? shown.title : `${shown.title}\n\n${shown.body}`;
728
+ }
610
729
  /** The shared shell of the `bug --edit` and `plan --edit` CLI cases, mirroring
611
730
  * fileAndAnnounce's prose classification and cancelAndAnnounce's flag handling: `--edit <n>` is
612
731
  * the one valued flag, `--json` the one boolean (at most once), `--help`/`-h` ride into the new
@@ -616,7 +735,11 @@ export function listAndAnnounce(root, args, command, fileName, sectionTitle) {
616
735
  * `edited <command> in <file>: <title>` or the `--json` payload {file, title}. Unlike
617
736
  * fileAndAnnounce there is no wake: an edit adds no work for a loop to pick up. With
618
737
  * `--file <path>` (`-` reads stdin) the new entry comes from a markdown file instead of
619
- * positionals: `editText` receives its text, and any positional alongside --file is refused. */
738
+ * positionals: `editText` receives its text, and any positional alongside --file is refused.
739
+ * With neither positionals nor --file the entry, rendered as `--show` prints it, opens in
740
+ * $VISUAL/$EDITOR and the saved buffer goes through `editText`; an unchanged buffer writes and
741
+ * commits nothing (`unchanged <command> <n>: <title>`), and an out-of-range position fails
742
+ * before the editor launches. */
620
743
  export async function editAndAnnounce(root, args, command, edit, editText) {
621
744
  const fileFlag = args.indexOf("--file");
622
745
  const path = fileFlag >= 0 ? promptFileValue(args[fileFlag + 1]) : null;
@@ -635,7 +758,25 @@ export async function editAndAnnounce(root, args, command, edit, editText) {
635
758
  if (position === null)
636
759
  fail(`--edit needs a position (usage: tumwater ${command} --edit <n>)`);
637
760
  await requireReadyRepo(root);
638
- const edited = path === null ? edit(position, positionals) : editText(position, readPromptFileArg(path));
761
+ let text = null;
762
+ if (path === null && positionals.length === 0) {
763
+ const shown = showEntry(root, command, position);
764
+ const before = renderShownEntry(shown);
765
+ const after = editInEditor(before, `${command}-${position}.md`);
766
+ if (after.trim() === before.trim()) {
767
+ const file = command === "bug" ? "BUGS.md" : "PLANS.md";
768
+ if (json)
769
+ sayJson({ file, title: shown.title, changed: false });
770
+ else
771
+ say(`unchanged ${command} ${position}: ${shown.title}`);
772
+ return;
773
+ }
774
+ text = after;
775
+ }
776
+ else if (path !== null)
777
+ text = readPromptFileArg(path);
778
+ const edited = text === null ? edit(position, positionals) : editText(position, text);
779
+ warnIfUncommitted(await commitOperatorBacklogEdit(root, edited.file, stampedSubject("operator", `Edit ${command}: ${edited.title}`)), edited.file);
639
780
  if (json)
640
781
  sayJson({ file: edited.file, title: edited.title });
641
782
  else
@@ -18,11 +18,11 @@ import { ensureDetachedWorktree } from "../git/worktree.js";
18
18
  * and "Tests red on main" entries), so while a SHA is known red the blocked roles skip authoring
19
19
  * instead of burning runs that are guaranteed to fail. Split out of loop.ts — which keeps the
20
20
  * tick lifecycle around it — because this is a self-contained policy with its own per-process
21
- * state (one harness-level warning per newly-discovered red SHA) and its own event contract; what
21
+ * state (one harness-level `main_red_hold` event per newly-discovered red SHA) and its own event contract; what
22
22
  * it borrows from the loop is identity and the role's worktree, and it consumes
23
23
  * build/build-check.ts's checkMainBaseline machinery (detection + execution + per-SHA cache),
24
24
  * which stays unaware of ticks. */
25
- /** The last main SHA for which this process logged a red-main warning (the baseline check):
25
+ /** The last main SHA for which this process logged a red-main hold event (the baseline check):
26
26
  * one harness-level warning per newly-discovered red SHA, not one per blocked role's tick —
27
27
  * module-level so every runner in the fleet shares it. A restart re-logs once: the cache is
28
28
  * cold then too, and an operator restarting into a still-red main should see why nothing lands. */
@@ -34,14 +34,20 @@ function redMainMessage(red, action) {
34
34
  const firstLine = failureHeadline(red.outputTail);
35
35
  return `${mainRedPhrase(red.sha)} (${red.script}${firstLine ? `: ${firstLine}` : ""}) — ${action}`;
36
36
  }
37
- /** Log the fleet-wide red-main warning for `red`'s SHA at most once per process. Module-level
37
+ /** Log the fleet-wide `main_red_hold` event (the notify hook pages it) for `red`'s SHA at most once per process. Module-level
38
38
  * (with lastMainRedSha) so the gate and the bugfix handoff share one guard: whichever observes
39
39
  * the red first logs it, the other stays silent. */
40
40
  function warnMainRedOnce(root, red) {
41
41
  if (lastMainRedSha === red.sha)
42
42
  return;
43
43
  lastMainRedSha = red.sha;
44
- warnEventBestEffort(root, "harness", redMainMessage(red, "code merges blocked until main is green"));
44
+ logEventBestEffort(root, {
45
+ loop: "harness",
46
+ type: "main_red_hold",
47
+ sha: red.sha,
48
+ script: red.script,
49
+ message: redMainMessage(red, "code merges blocked until main is green"),
50
+ });
45
51
  }
46
52
  /** checkMainBaseline's per-run hook: log the one run per SHA (cache misses only) under the role
47
53
  * that paid for it, with its duration — the gate's build_check sibling, so both halves of the