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.
- package/README.md +26 -95
- package/dist/build-info.json +2 -2
- package/dist/src/backlog/backlog-commit.js +31 -0
- package/dist/src/backlog/backlog-eligibility.js +41 -6
- package/dist/src/backlog/backlog-md.js +19 -1
- package/dist/src/backlog/backlog-write.js +152 -11
- package/dist/src/baseline/main-red.js +10 -4
- package/dist/src/brief-edit.js +42 -0
- package/dist/src/brief.js +33 -2
- package/dist/src/change/change-data.js +12 -3
- package/dist/src/cli/brief-command.js +56 -0
- package/dist/src/cli/cli-command-args.js +3 -4
- package/dist/src/cli/cli-editor.js +52 -0
- package/dist/src/cli/cli-flag-specs.js +23 -10
- package/dist/src/cli/cli-output.js +9 -0
- package/dist/src/cli/cli-query-commands.js +32 -6
- package/dist/src/cli/cli-run.js +105 -12
- package/dist/src/cli/completion.js +149 -0
- package/dist/src/cli/help.js +71 -16
- package/dist/src/cli/question-commands.js +18 -2
- package/dist/src/cli.js +49 -10
- package/dist/src/config/config-views.js +12 -12
- package/dist/src/config/config-write.js +1 -1
- package/dist/src/doctor/doctor-backlog.js +75 -4
- package/dist/src/doctor/doctor-checks.js +31 -4
- package/dist/src/doctor/doctor-holds.js +83 -4
- package/dist/src/doctor/doctor-notify.js +28 -0
- package/dist/src/doctor/doctor.js +9 -2
- package/dist/src/events/event-format.js +12 -1
- package/dist/src/events/notify.js +75 -26
- package/dist/src/fleet/fleet-state.js +25 -1
- package/dist/src/fleet/orchestrator-info.js +2 -0
- package/dist/src/git/commit-message.js +6 -0
- package/dist/src/git/git.js +11 -0
- package/dist/src/gui/gui-endpoint-commands.js +52 -5
- package/dist/src/gui/gui-endpoints.js +12 -3
- package/dist/src/gui/gui-server.js +5 -2
- package/dist/src/history/history-data.js +21 -0
- package/dist/src/history/history.js +24 -4
- package/dist/src/inbox/inbox-attachments.js +9 -4
- package/dist/src/inbox/prompt-commands.js +61 -12
- package/dist/src/landing/landing-git.js +1 -2
- package/dist/src/landing/landing-queue-render.js +28 -3
- package/dist/src/landing/landing-slot.js +10 -0
- package/dist/src/loop/loop-pi.js +2 -2
- package/dist/src/loop/loop-state.js +9 -0
- package/dist/src/loop/loop-tick.js +9 -3
- package/dist/src/loop/loop.js +5 -1
- package/dist/src/operator/operator-commands.js +43 -4
- package/dist/src/operator/operator-intent.js +18 -1
- package/dist/src/operator/retire.js +17 -1
- package/dist/src/orchestrator/orchestrator-scheduling.js +60 -6
- package/dist/src/orchestrator/orchestrator.js +10 -5
- package/dist/src/paths.js +5 -0
- package/dist/src/pi/pi-watchdogs.js +44 -41
- package/dist/src/process/supervisor.js +15 -4
- package/dist/src/prompt/prompt-followup.js +7 -1
- package/dist/src/report/report-data.js +5 -1
- package/dist/src/report/report-render.js +46 -0
- package/dist/src/review/protected-edits.js +32 -0
- package/dist/src/review/review.js +28 -7
- package/dist/src/roles/role-catalog.js +17 -5
- package/dist/src/roles/roles.js +2 -1
- package/dist/src/scheduling/claims.js +10 -1
- package/dist/src/scheduling/scheduling.js +17 -1
- package/dist/src/status/status-data.js +10 -5
- package/dist/src/status/status-polls.js +25 -1
- package/dist/src/tick/stage-check.js +5 -1
- package/dist/src/tick/tick-detail-data.js +15 -2
- package/dist/src/tick/tick-detail.js +38 -10
- package/dist/src/tick/tick-stage.js +32 -2
- package/dist/src/tick/tick-verdict.js +1 -0
- package/dist/src/ui/badges.js +7 -0
- package/dist/src/ui/gui/gui-client-boot.js +10 -4
- package/dist/src/ui/gui/gui-client-fleet.js +9 -1
- package/dist/src/ui/gui/gui-client-history.js +2 -1
- package/dist/src/ui/gui/gui-client-loops.js +4 -5
- package/dist/src/ui/gui/gui-client-model.js +4 -1
- package/dist/src/ui/gui/gui-client-operator.js +61 -1
- package/dist/src/ui/gui/gui-client-pending.js +4 -3
- package/dist/src/ui/gui/gui-client-report.js +15 -5
- package/dist/src/ui/gui/gui-client-settings.js +27 -1
- package/dist/src/ui/gui/gui-page.js +1 -0
- package/dist/src/ui/status-model.js +24 -36
- package/dist/src/ui/status-payload.js +10 -1
- package/dist/src/ui/status-render.js +2 -1
- package/dist/src/ui/tone.js +3 -1
- package/dist/src/ui/tui/tui-frame.js +9 -1
- package/dist/src/ui/tui/tui-input.js +11 -0
- package/dist/src/ui/tui/tui-keys.js +129 -8
- package/dist/src/ui/tui/tui-pane.js +15 -1
- package/dist/src/ui/tui/tui.js +4 -2
- package/dist/src/verdict/fix-claim.js +98 -18
- package/dist/src/verdict/refusal.js +54 -2
- 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.
|
|
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
|
|
41
|
+
cd your-project # a new or existing directory
|
|
42
42
|
tumwater init "Build a tiny markdown-to-html converter CLI in Python."
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`tumwater
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
package/dist/build-info.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|