tumwater 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/build-info.json +2 -2
- package/dist/src/agent/backend-traits.js +13 -0
- package/dist/src/agent/claude/claude-backend.js +2 -2
- package/dist/src/agent/claude/claude-stream.js +3 -2
- package/dist/src/agent/codex/codex-backend.js +3 -3
- package/dist/src/agent/codex/codex-stream.js +3 -1
- package/dist/src/agent/opencode/opencode-backend.js +2 -2
- package/dist/src/backlog/backlog-archive.js +60 -25
- package/dist/src/backlog/backlog-cli-view.js +3 -28
- package/dist/src/backlog/backlog-cli.js +9 -64
- package/dist/src/backlog/backlog-commit.js +17 -9
- package/dist/src/backlog/backlog-eligibility.js +44 -26
- package/dist/src/backlog/backlog-parts.js +23 -13
- package/dist/src/backlog/backlog-release.js +33 -16
- package/dist/src/baseline/main-guardrails.js +0 -73
- package/dist/src/baseline/main-red.js +31 -21
- package/dist/src/baseline/main-watch.js +82 -34
- package/dist/src/brief/brief-edit.js +3 -1
- package/dist/src/budget/budget-fallback-view.js +288 -0
- package/dist/src/budget/budget-wiring.js +158 -0
- package/dist/src/budget/budget.js +2 -1
- package/dist/src/build/dep-install.js +64 -1
- package/dist/src/cli/cli-args.js +63 -0
- package/dist/src/cli/cli-flag-specs.js +33 -0
- package/dist/src/cli/cli-query-commands.js +16 -41
- package/dist/src/cli/cli-review-commands.js +113 -0
- package/dist/src/cli/cli-role-command.js +118 -13
- package/dist/src/cli/cli-start-summary.js +10 -3
- package/dist/src/cli/cli-start.js +33 -5
- package/dist/src/cli/completion.js +21 -1
- package/dist/src/cli/config-commands.js +17 -8
- package/dist/src/cli/help.js +74 -14
- package/dist/src/cli/log-commands.js +26 -13
- package/dist/src/cli/question-commands.js +7 -1
- package/dist/src/cli.js +71 -6
- package/dist/src/collections.js +31 -4
- package/dist/src/config/config-editable-keys.js +3 -0
- package/dist/src/config/config-fallbacks.js +3 -2
- package/dist/src/config/config-validation.js +47 -42
- package/dist/src/config/config-views.js +10 -2
- package/dist/src/config/config-write.js +33 -1
- package/dist/src/config/config.js +7 -2
- package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
- package/dist/src/doctor/doctor-backlog-plans.js +214 -79
- package/dist/src/doctor/doctor-backlog.js +155 -22
- package/dist/src/doctor/doctor-check-names.js +5 -1
- package/dist/src/doctor/doctor-checks.js +10 -15
- package/dist/src/doctor/doctor-claude-checks.js +6 -9
- package/dist/src/doctor/doctor-codex-checks.js +6 -9
- package/dist/src/doctor/doctor-contention-checks.js +244 -118
- package/dist/src/doctor/doctor-fallback-checks.js +130 -0
- package/dist/src/doctor/doctor-holds.js +4 -6
- package/dist/src/doctor/doctor-model-checks.js +45 -200
- package/dist/src/doctor/doctor-notify.js +7 -2
- package/dist/src/doctor/doctor-opencode-checks.js +7 -5
- package/dist/src/doctor/doctor-pi-checks.js +62 -0
- package/dist/src/doctor/doctor-project-checks.js +106 -12
- package/dist/src/doctor/doctor.js +10 -5
- package/dist/src/doctor/probe.js +14 -1
- package/dist/src/events/event-format.js +7 -6
- package/dist/src/events/event-labels.js +0 -1
- package/dist/src/events/event-read.js +13 -0
- package/dist/src/events/event-window.js +20 -2
- package/dist/src/failure/failure-data.js +9 -4
- package/dist/src/failure/failure-render.js +8 -8
- package/dist/src/failure/time-spend.js +0 -64
- package/dist/src/files/file-queue.js +17 -0
- package/dist/src/gates/budget-gates.js +27 -257
- package/dist/src/gates/gate-polls.js +49 -141
- package/dist/src/gates/maintenance-quota.js +27 -17
- package/dist/src/git/commit-message.js +12 -0
- package/dist/src/git/git.js +18 -0
- package/dist/src/git/slot-salvage.js +3 -3
- package/dist/src/gui/gui-backlog-commands.js +3 -3
- package/dist/src/gui/gui-backlog-endpoints.js +5 -4
- package/dist/src/gui/gui-command.js +19 -1
- package/dist/src/gui/gui-config-commands.js +75 -9
- package/dist/src/gui/gui-endpoint-commands.js +2 -2
- package/dist/src/gui/gui-operator-commands.js +134 -7
- package/dist/src/gui/gui-prompt-commands.js +15 -6
- package/dist/src/gui/gui-report-endpoints.js +10 -3
- package/dist/src/gui/gui-server.js +16 -2
- package/dist/src/history/history-queued.js +8 -0
- package/dist/src/history/history.js +2 -5
- package/dist/src/inbox/inbox-top.js +4 -9
- package/dist/src/merge/merge-core.js +2 -25
- package/dist/src/merge/merge-drain.js +22 -135
- package/dist/src/merge/merge-pipeline.js +1 -1
- package/dist/src/merge/merge-post-review.js +154 -30
- package/dist/src/merge/merge-queue-render.js +11 -12
- package/dist/src/merge/merge-queue.js +22 -1
- package/dist/src/merge/merge-requeue.js +1 -0
- package/dist/src/merge/merge-stack.js +1 -7
- package/dist/src/merge/merge-to-main.js +1 -9
- package/dist/src/merge/merge-top.js +33 -19
- package/dist/src/merge/merge-verify.js +64 -9
- package/dist/src/notes-cli.js +8 -3
- package/dist/src/orchestrator/orchestrator-poll.js +47 -15
- package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
- package/dist/src/orchestrator/orchestrator.js +5 -4
- package/dist/src/orchestrator/scheduling-scope.js +51 -7
- package/dist/src/orchestrator/scheduling-skip.js +15 -5
- package/dist/src/paths.js +16 -6
- package/dist/src/pi/pi-backend.js +2 -2
- package/dist/src/process/run-marker.js +8 -0
- package/dist/src/prompt/gate-notes.js +1 -1
- package/dist/src/prompt/gate-prompts.js +30 -7
- package/dist/src/prompt/notes-backlog.js +13 -10
- package/dist/src/prompt/prompt.js +3 -1
- package/dist/src/report/report-data.js +49 -69
- package/dist/src/report/report-fold.js +11 -12
- package/dist/src/report/report-render.js +6 -4
- package/dist/src/report/report.js +2 -5
- package/dist/src/review/diff-rules.js +53 -0
- package/dist/src/review/known-flakes.js +12 -0
- package/dist/src/review/post-merge-review-accept.js +51 -0
- package/dist/src/review/post-merge-review-render.js +112 -0
- package/dist/src/review/post-merge-review.js +157 -24
- package/dist/src/review/protected-edits.js +22 -7
- package/dist/src/review/quarantine-render.js +65 -0
- package/dist/src/review/review.js +33 -49
- package/dist/src/roles/role-catalog.js +60 -2
- package/dist/src/roles/role-guidance.js +10 -0
- package/dist/src/roles/role-labels.js +6 -3
- package/dist/src/roles/role-render.js +43 -8
- package/dist/src/roles/role-view.js +36 -9
- package/dist/src/roles/roles.js +3 -3
- package/dist/src/run/ci-parity.js +121 -0
- package/dist/src/run/qa-coverage.js +50 -15
- package/dist/src/run/run-detail-data.js +9 -13
- package/dist/src/run/run-detail-render.js +1 -8
- package/dist/src/run/run-finalize.js +1 -0
- package/dist/src/run/run-prompt.js +14 -0
- package/dist/src/run/run-stage.js +66 -37
- package/dist/src/run/run-verdict.js +14 -1
- package/dist/src/run/stage-check.js +17 -11
- package/dist/src/run/stage-checks-shared.js +65 -0
- package/dist/src/run/stage-name-checks.js +204 -0
- package/dist/src/run/stage-path-checks.js +16 -515
- package/dist/src/run/stage-prose-checks.js +261 -0
- package/dist/src/runner/leftover.js +50 -19
- package/dist/src/runner/revision.js +12 -23
- package/dist/src/runner/runner-outcome.js +4 -2
- package/dist/src/runner/runner-recovery.js +18 -3
- package/dist/src/runner/runner-run.js +21 -8
- package/dist/src/runner/runner-state.js +4 -3
- package/dist/src/scheduling/claim-entries.js +49 -0
- package/dist/src/status/status-data.js +6 -1
- package/dist/src/status/status-polls.js +15 -0
- package/dist/src/status/status-roles.js +11 -0
- package/dist/src/team/reclaim.js +2 -2
- package/dist/src/text/datetime.js +3 -2
- package/dist/src/text/phrases.js +6 -3
- package/dist/src/text/text.js +11 -0
- package/dist/src/ui/badges.js +34 -1
- package/dist/src/ui/gui/gui-client-backlog.js +25 -3
- package/dist/src/ui/gui/gui-client-boot.js +63 -41
- package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
- package/dist/src/ui/gui/gui-client-drawer.js +29 -4
- package/dist/src/ui/gui/gui-client-feed.js +1 -1
- package/dist/src/ui/gui/gui-client-history.js +74 -72
- package/dist/src/ui/gui/gui-client-labels.js +5 -1
- package/dist/src/ui/gui/gui-client-operator.js +6 -2
- package/dist/src/ui/gui/gui-client-pending.js +188 -11
- package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
- package/dist/src/ui/gui/gui-client-report.js +20 -5
- package/dist/src/ui/gui/gui-client-settings.js +109 -3
- package/dist/src/ui/gui/gui-client-team.js +11 -0
- package/dist/src/ui/gui/gui-client.js +11 -1
- package/dist/src/ui/gui/gui-page.js +1 -0
- package/dist/src/ui/gui/gui-styles.js +4 -0
- package/dist/src/ui/run-progress-model.js +2 -1
- package/dist/src/ui/status-payload.js +237 -9
- package/dist/src/ui/status-render.js +12 -4
- package/dist/src/ui/team-alerts.js +512 -37
- package/dist/src/ui/tone.js +7 -0
- package/dist/src/ui/tui/tui-backlog.js +7 -5
- package/dist/src/ui/tui/tui-editor-attach.js +1 -7
- package/dist/src/ui/tui/tui-editor-backends.js +1 -6
- package/dist/src/ui/tui/tui-editor-brief.js +4 -24
- package/dist/src/ui/tui/tui-editor-budget.js +1 -8
- package/dist/src/ui/tui/tui-editor-check.js +29 -0
- package/dist/src/ui/tui/tui-editor-checks.js +28 -0
- package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
- package/dist/src/ui/tui/tui-editor-config.js +19 -6
- package/dist/src/ui/tui/tui-editor-entry.js +3 -17
- package/dist/src/ui/tui/tui-editor-instances.js +9 -0
- package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
- package/dist/src/ui/tui/tui-editor-interval.js +18 -7
- package/dist/src/ui/tui/tui-editor-notes.js +6 -24
- package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
- package/dist/src/ui/tui/tui-editor-retire.js +1 -7
- package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
- package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
- package/dist/src/ui/tui/tui-editor-session.js +41 -0
- package/dist/src/ui/tui/tui-editor-slots.js +1 -0
- package/dist/src/ui/tui/tui-editors.js +10 -1
- package/dist/src/ui/tui/tui-frame.js +18 -6
- package/dist/src/ui/tui/tui-keys.js +9 -0
- package/dist/src/ui/tui/tui-pane-activity.js +21 -8
- package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
- package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
- package/dist/src/ui/tui/tui-pane-health.js +113 -3
- package/dist/src/ui/tui/tui-pane-history.js +44 -0
- package/dist/src/ui/tui/tui-pane-pending.js +285 -56
- package/dist/src/ui/tui/tui-pane-queued.js +7 -2
- package/dist/src/ui/tui/tui-pane-report.js +9 -3
- package/dist/src/ui/tui/tui-pane-session.js +9 -0
- package/dist/src/ui/tui/tui-pane.js +16 -10
- package/dist/src/ui/tui/tui-panes.js +56 -7
- package/dist/src/ui/tui/tui.js +8 -5
- package/dist/src/verdict/fix-claim.js +47 -1
- package/dist/src/verdict/reply-contract.js +35 -0
- package/package.json +1 -1
- package/dist/src/baseline/main-culprit.js +0 -94
- package/dist/src/baseline/main-revert.js +0 -133
|
@@ -4,32 +4,37 @@
|
|
|
4
4
|
import { collapseWhitespace } from "../text/text.js";
|
|
5
5
|
import { openParenthetical, trailingParenthetical } from "./backlog-md.js";
|
|
6
6
|
import { stripEntryStamp } from "./backlog-structure.js";
|
|
7
|
-
/** One prerequisite
|
|
8
|
-
|
|
7
|
+
/** One prerequisite chunk: its refs resolved against `fallbackSeries` (the last series the clause
|
|
8
|
+
* named, or the entry's own when it named none), and the series this chunk names — null when it
|
|
9
|
+
* names none (a bare `parts a/n` inherits the surrounding clause's series). The series keyword
|
|
10
|
+
* `parts`/`part` belongs to neither a named series nor the bare ref, so a trailing one is dropped
|
|
11
|
+
* from a captured name. */
|
|
12
|
+
function parseRef(chunk, fallbackSeries) {
|
|
9
13
|
const text = chunk.trim().replace(/^parts?\s+/i, "");
|
|
10
14
|
if (text === "")
|
|
11
|
-
return [];
|
|
15
|
+
return { refs: [], named: null };
|
|
12
16
|
const m = /^(?:([\s\S]+?)\s+)?([0-9]+[a-z]?)\/(\d+)(?:\s*[–-]\s*(\d+)\/(\d+))?$/i.exec(text);
|
|
13
17
|
if (m === null)
|
|
14
|
-
return [];
|
|
15
|
-
|
|
18
|
+
return { refs: [], named: null };
|
|
19
|
+
let named = (m[1]?.trim() ?? "").replace(/\s+parts?$/i, "");
|
|
20
|
+
const series = named || fallbackSeries;
|
|
16
21
|
if (series === null || series === "")
|
|
17
|
-
return [];
|
|
22
|
+
return { refs: [], named: named || null };
|
|
18
23
|
const of = Number(m[3]);
|
|
19
24
|
const start = m[2].toLowerCase();
|
|
20
25
|
if (!Number.isInteger(of) || of < 1)
|
|
21
|
-
return [];
|
|
26
|
+
return { refs: [], named: named || null };
|
|
22
27
|
if (m[4] === undefined)
|
|
23
|
-
return [{ series, part: start, of }];
|
|
28
|
+
return { refs: [{ series, part: start, of }], named: named || null };
|
|
24
29
|
// A range is numeric on both ends (the lettered parts never range); expand it inclusively.
|
|
25
30
|
const from = Number(start);
|
|
26
31
|
const to = Number(m[4]);
|
|
27
32
|
if (!Number.isInteger(from) || !Number.isInteger(to) || to < from)
|
|
28
|
-
return [];
|
|
33
|
+
return { refs: [], named: named || null };
|
|
29
34
|
const parts = [];
|
|
30
35
|
for (let p = from; p <= to; p++)
|
|
31
36
|
parts.push({ series, part: String(p), of });
|
|
32
|
-
return parts;
|
|
37
|
+
return { refs: parts, named: named || null };
|
|
33
38
|
}
|
|
34
39
|
/** The stable identity of an entry heading: its `(planned …)`/`(done …)` stamp suffix
|
|
35
40
|
* removed through the shared stripEntryStamp, whitespace collapsed, lowercased. Adding a
|
|
@@ -140,8 +145,13 @@ export function requiredParts(title) {
|
|
|
140
145
|
return [];
|
|
141
146
|
const ownSeries = seriesPart(title)?.series ?? null;
|
|
142
147
|
const refs = [];
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
let lastNamed = null;
|
|
149
|
+
for (const chunk of clause.split(/\s*,\s*|\s+and\s+/i)) {
|
|
150
|
+
const { refs: chunkRefs, named } = parseRef(chunk, lastNamed ?? ownSeries);
|
|
151
|
+
if (named !== null)
|
|
152
|
+
lastNamed = named;
|
|
153
|
+
refs.push(...chunkRefs);
|
|
154
|
+
}
|
|
145
155
|
return refs;
|
|
146
156
|
}
|
|
147
157
|
/** The ref text a heading's `requires <refs> landed` clause captures — read from the trailing
|
|
@@ -198,7 +208,7 @@ export function planDependencyProblems(planned, doneTitles) {
|
|
|
198
208
|
meta = openParenthetical(entry.title);
|
|
199
209
|
const held = new Set(refs.map((ref) => partKey(ref.series, ref.part)));
|
|
200
210
|
for (const m of meta.matchAll(MISWORDED_DEPENDENCY_RE)) {
|
|
201
|
-
for (const ref of parseRef(m[1], own.series)) {
|
|
211
|
+
for (const ref of parseRef(m[1], own.series).refs) {
|
|
202
212
|
const key = partKey(ref.series, ref.part);
|
|
203
213
|
if (!plannedKeys.has(key) || held.has(key))
|
|
204
214
|
continue;
|
|
@@ -11,22 +11,37 @@ import { readTextIfExists, writeTextAtomic } from "../files/files.js";
|
|
|
11
11
|
import { fail } from "../cli/cli-output.js";
|
|
12
12
|
import { trimTrailingBlankLines } from "../text/text.js";
|
|
13
13
|
import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
|
|
14
|
-
/**
|
|
15
|
-
*
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
14
|
+
/** The prose naming the note `lifted` kind a release removed, shared by the CLI, TUI and the
|
|
15
|
+
* exit-announcers so their released lines cannot drift. */
|
|
16
|
+
export function liftedNotePhrase(lifted) {
|
|
17
|
+
return lifted === "refused"
|
|
18
|
+
? "the Refused note"
|
|
19
|
+
: lifted === "waiting-for-failure"
|
|
20
|
+
? "the Waiting-for-the-next-failure note"
|
|
21
|
+
: "the held-for-operator note"; // vocabulary-ok: the held-for-operator note's release wording
|
|
22
|
+
}
|
|
23
|
+
/** Drop the Refused note from `entry`'s slice of `lines`, in place, and return its kind; an
|
|
24
|
+
* entry with none loses its Waiting-for-the-next-failure note instead, and one with neither
|
|
25
|
+
* loses its held-for-operator Needs-review/Needs-replan note (the `(held N)` count at/above
|
|
26
|
+
* threshold, plus its **Previously held N** line). Null (and `lines` untouched) when the entry
|
|
27
|
+
* carries none of the three, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
|
|
19
28
|
function liftRefusal(lines, entry) {
|
|
20
29
|
const slice = lines.slice(entry.start - 1, entry.end);
|
|
21
30
|
let kept = withoutRefusalNotes(slice);
|
|
22
|
-
if (kept.length === slice.length)
|
|
31
|
+
if (kept.length === slice.length) {
|
|
23
32
|
kept = withoutWaitingNotes(slice);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
if (kept.length === slice.length) {
|
|
34
|
+
kept = withoutHeldForOperatorNotes(slice);
|
|
35
|
+
if (kept.length === slice.length)
|
|
36
|
+
return null;
|
|
37
|
+
lines.splice(entry.start - 1, slice.length, ...kept);
|
|
38
|
+
return "held-for-operator"; // vocabulary-ok: the held-for-operator note's release wording
|
|
39
|
+
}
|
|
40
|
+
lines.splice(entry.start - 1, slice.length, ...kept);
|
|
41
|
+
return "waiting-for-failure";
|
|
42
|
+
}
|
|
28
43
|
lines.splice(entry.start - 1, slice.length, ...kept);
|
|
29
|
-
return
|
|
44
|
+
return "refused";
|
|
30
45
|
}
|
|
31
46
|
/** The non-exiting release the dashboard calls: releaseEntry's splice with tryCancelEntry's
|
|
32
47
|
* title check, all decided inside the lock. */
|
|
@@ -41,25 +56,27 @@ export function tryReleaseEntry(root, kind, n, expectedTitle) {
|
|
|
41
56
|
if (entry.title !== expectedTitle)
|
|
42
57
|
return { status: "moved", title: entry.title };
|
|
43
58
|
const lines = md.split("\n");
|
|
44
|
-
|
|
59
|
+
const lifted = liftRefusal(lines, entry);
|
|
60
|
+
if (lifted === null)
|
|
45
61
|
return { status: "not_refused", title: entry.title };
|
|
46
62
|
writeTextAtomic(file, lines.join("\n"));
|
|
47
|
-
return { status: "released", file: fileName, title: entry.title };
|
|
63
|
+
return { status: "released", file: fileName, title: entry.title, lifted };
|
|
48
64
|
});
|
|
49
65
|
}
|
|
50
66
|
/** Lift the Refused, Waiting for the next failure, or held-for-operator note from the
|
|
51
67
|
* `n`th entry (1-based, as `--list` numbers them) of the kind's open section and write the
|
|
52
|
-
* file, under the same lock the cancel and edit writers hold.
|
|
68
|
+
* file, under the same lock the cancel and edit writers hold, returning which hold it lifted.
|
|
53
69
|
* An out-of-range position fails with locateEntry's wording; an entry with neither note
|
|
54
70
|
* fails too, writing nothing. */
|
|
55
71
|
export function releaseEntry(root, kind, n) {
|
|
56
72
|
const { fileName, sectionTitle } = backlogTarget(kind);
|
|
57
73
|
return withPrimaryWorktreeLock(root, () => {
|
|
58
74
|
const { file, lines, entry } = readEntryLines(root, fileName, sectionTitle, n);
|
|
59
|
-
|
|
75
|
+
const lifted = liftRefusal(lines, entry);
|
|
76
|
+
if (lifted === null)
|
|
60
77
|
fail(`${kind} #${n} "${entry.title}" carries no Refused, Waiting for the next failure, or held-for-operator note`); // vocabulary-ok: the held-for-operator note's release wording
|
|
61
78
|
writeTextAtomic(file, lines.join("\n"));
|
|
62
|
-
return { file: fileName, title: entry.title };
|
|
79
|
+
return { file: fileName, title: entry.title, lifted };
|
|
63
80
|
});
|
|
64
81
|
}
|
|
65
82
|
/** Move the `n`th entry (1-based, as `--list` numbers them) of the kind's open section to the
|
|
@@ -113,78 +113,6 @@ export function quarantineWarning(entries, now) {
|
|
|
113
113
|
}
|
|
114
114
|
return phrases.length > 0 ? phrases.join("; ") : null;
|
|
115
115
|
}
|
|
116
|
-
/** How many `main_revert` events in the trailing 24 h the doctor and digest warn once the
|
|
117
|
-
* daily ceiling is passed (G2's rate test): a burst of reverts reads like a flaky-revert
|
|
118
|
-
* habit, distinct from a single false revert. */
|
|
119
|
-
export const REVERT_RATE_LIMIT = 5;
|
|
120
|
-
/** One `main_revert` in the last 24 h looks false when either G2 test holds — its failure did
|
|
121
|
-
* not reproduce (a test it names has been quarantined at or since the revert, or a later
|
|
122
|
-
* `main_check`'s `quarantined` list names it), or its change came back unchanged and main
|
|
123
|
-
* stayed green (a later `merged` event carries the culprit's patch-id, and the first
|
|
124
|
-
* `main_check` after that merge which actually ran the check passed). */
|
|
125
|
-
function revertLooksFalse(r, events, quarantine) {
|
|
126
|
-
const rts = typeof r.ts === "number" ? r.ts : 0;
|
|
127
|
-
const tests = Array.isArray(r.failingTests)
|
|
128
|
-
? r.failingTests.filter((t) => typeof t === "string")
|
|
129
|
-
: [];
|
|
130
|
-
const firstTest = tests[0];
|
|
131
|
-
// Did not reproduce: the named test is quarantined at or since the revert, or a later
|
|
132
|
-
// main_check's `quarantined` list names it (src/baseline/main-watch.ts records a red naming
|
|
133
|
-
// only quarantined flaky tests as passed there).
|
|
134
|
-
if (firstTest !== undefined &&
|
|
135
|
-
(quarantine.some((q) => q.test === firstTest && q.lastSeen >= rts) ||
|
|
136
|
-
events.some((e) => e.type === "main_check" &&
|
|
137
|
-
typeof e.ts === "number" &&
|
|
138
|
-
e.ts >= rts &&
|
|
139
|
-
Array.isArray(e.quarantined) &&
|
|
140
|
-
e.quarantined.includes(firstTest))))
|
|
141
|
-
return "quarantined";
|
|
142
|
-
// Came back unchanged and main stayed green: a later `merged` carries this revert's
|
|
143
|
-
// patch-id, and the first `main_check` after that merge which ran the check passed.
|
|
144
|
-
const patch = typeof r.patchId === "string" ? r.patchId : undefined;
|
|
145
|
-
if (patch === undefined)
|
|
146
|
-
return null;
|
|
147
|
-
const remerged = events.find((e) => e.type === "merged" && e.patchId === patch && typeof e.ts === "number" && e.ts >= rts);
|
|
148
|
-
if (remerged === undefined)
|
|
149
|
-
return null;
|
|
150
|
-
const rmTs = typeof remerged.ts === "number" ? remerged.ts : 0;
|
|
151
|
-
const after = events
|
|
152
|
-
.filter((e) => e.type === "main_check" &&
|
|
153
|
-
typeof e.ts === "number" &&
|
|
154
|
-
e.ts >= rmTs &&
|
|
155
|
-
typeof e.durationMs === "number" &&
|
|
156
|
-
e.durationMs > 0)
|
|
157
|
-
.sort((a, b) => a.ts - b.ts)[0];
|
|
158
|
-
return after?.status === "passed" ? "remerged" : null;
|
|
159
|
-
}
|
|
160
|
-
/** G2 over `events` and the quarantine list: warns on a `main_revert` in the trailing 24 h that
|
|
161
|
-
* looks false, and on more than REVERT_RATE_LIMIT reverts a day. The detail names each
|
|
162
|
-
* false-looking revert (culprit sha and which test failed / that it landed again unchanged)
|
|
163
|
-
* and the count when over the rate limit. Returns the phrases that fire (empty when none do). */
|
|
164
|
-
export function revertWarnings(events, quarantine, now) {
|
|
165
|
-
const cutoff = now - DAY_MS;
|
|
166
|
-
const reverts = events.filter((e) => e.type === "main_revert" && typeof e.ts === "number" && e.ts >= cutoff);
|
|
167
|
-
if (reverts.length === 0)
|
|
168
|
-
return [];
|
|
169
|
-
const phrases = [];
|
|
170
|
-
if (reverts.length > REVERT_RATE_LIMIT) {
|
|
171
|
-
phrases.push(`${reverts.length} reverts in the last 24 h — over the ${REVERT_RATE_LIMIT}-revert ceiling`);
|
|
172
|
-
}
|
|
173
|
-
for (const r of reverts) {
|
|
174
|
-
const culprit = typeof r.commit === "string" ? r.commit : typeof r.sha === "string" ? r.sha : "";
|
|
175
|
-
const reason = revertLooksFalse(r, events, quarantine);
|
|
176
|
-
if (reason === "quarantined") {
|
|
177
|
-
const tests = Array.isArray(r.failingTests)
|
|
178
|
-
? r.failingTests.filter((t) => typeof t === "string")
|
|
179
|
-
: [];
|
|
180
|
-
phrases.push(`revert ${shortSha(culprit)} blamed ${squash(tests[0] ?? "", 60)}, which has been quarantined since — the failure did not reproduce`);
|
|
181
|
-
}
|
|
182
|
-
else if (reason === "remerged") {
|
|
183
|
-
phrases.push(`revert ${shortSha(culprit)}'s change merged again unchanged and main stayed green — the revert looks false`);
|
|
184
|
-
}
|
|
185
|
-
}
|
|
186
|
-
return phrases;
|
|
187
|
-
}
|
|
188
116
|
/** Both guardrail phrases that fire for `root`, reading the trailing 24 h of the event log and
|
|
189
117
|
* quarantine.json, oldest-test text bounded as the doctor rows are. */
|
|
190
118
|
export function guardrailWarnings(root, now = Date.now()) {
|
|
@@ -197,7 +125,6 @@ export function guardrailWarnings(root, now = Date.now()) {
|
|
|
197
125
|
const qw = quarantineWarning(q, now);
|
|
198
126
|
if (qw !== null)
|
|
199
127
|
warnings.push(qw);
|
|
200
|
-
warnings.push(...revertWarnings(events, q, now));
|
|
201
128
|
return warnings;
|
|
202
129
|
}
|
|
203
130
|
/** "40 min", "3 h" — the duration a red main's warning names. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { baselineBlocked
|
|
1
|
+
import { baselineBlocked } from "../roles/roles.js";
|
|
2
2
|
import { baseRoleOf } from "../roles/runner-ids.js";
|
|
3
3
|
import { enqueueRolePrompt } from "../inbox/inbox.js";
|
|
4
4
|
import { isCustomRole, liveConfig } from "../config/config.js";
|
|
@@ -35,7 +35,7 @@ import { ensureDetachedWorktree } from "../git/worktree.js";
|
|
|
35
35
|
let lastMainRedSha = null;
|
|
36
36
|
/** The key of the last failing set the healer was sent a prompt for: the sorted failing test
|
|
37
37
|
* names, or the red SHA when the red names none. Two red commits with the same failing set send
|
|
38
|
-
* one heal prompt, not one per commit; a green main observed by the gate or the
|
|
38
|
+
* one heal prompt, not one per commit; a green main observed by the gate or the ci handoff
|
|
39
39
|
* clears it, so a set that returns after green is healed again. */
|
|
40
40
|
let lastHealKey = null;
|
|
41
41
|
/** The failing tests a red verdict names, when the list is complete — non-empty and short of
|
|
@@ -54,11 +54,11 @@ function redMainMessage(red, action) {
|
|
|
54
54
|
}
|
|
55
55
|
/** Log the team-wide `main_red_hold` event (the notify hook pages it) for `red`'s SHA at
|
|
56
56
|
* most once per process, and hand the red suite to the healer. Module-level (with
|
|
57
|
-
* lastMainRedSha) so the gate and the
|
|
57
|
+
* lastMainRedSha) so the gate and the ci handoff share one guard: whichever observes the
|
|
58
58
|
* red first logs it, the other stays silent. `foundBy` is the role whose path verified the
|
|
59
|
-
* red — when that role is not
|
|
59
|
+
* red — when that role is not ci itself, one prompt is enqueued to ci's own inbox so
|
|
60
60
|
* the healer runs on the orchestrator's next poll instead of waiting out its own interval or
|
|
61
|
-
* deferral;
|
|
61
|
+
* deferral; ci finding its own red skips the enqueue because its running run already
|
|
62
62
|
* carries the note and IS the heal (and enqueuing from inside it would queue a second run). A
|
|
63
63
|
* queued per-role prompt makes the role due by itself (`isEligible`, scheduling.ts, returns
|
|
64
64
|
* `{ run: true, reason: "inbox" }` on a non-empty inbox before any interval, backoff, or
|
|
@@ -80,9 +80,9 @@ function warnMainRedOnce(root, red, foundBy) {
|
|
|
80
80
|
? `${mainRedPhrase(red.sha)} (${plural(named.length, "failing test")}: ${clipToWidth(named[0] ?? "", 80)}${named.length > 1 ? `, +${named.length - 1} more` : ""}); changes that add no new failures still merge`
|
|
81
81
|
: redMainMessage(red, "code merges blocked until main is green"),
|
|
82
82
|
});
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
//
|
|
83
|
+
// The ci role is the healer, and it is exempt from mainRedGate but schedules as an observer,
|
|
84
|
+
// so a red main discovered by any other path could sit unrepaired until ci's own interval or
|
|
85
|
+
// a coincidental wake.
|
|
86
86
|
// Queue the heal: one prompt per newly-verified red SHA per process (a restart re-queues
|
|
87
87
|
// once, desirable because the cache is cold then too). With named failures the key is the
|
|
88
88
|
// failing set, so a second red commit with the same failures queues nothing.
|
|
@@ -90,24 +90,24 @@ function warnMainRedOnce(root, red, foundBy) {
|
|
|
90
90
|
if (lastHealKey === healKey)
|
|
91
91
|
return;
|
|
92
92
|
lastHealKey = healKey;
|
|
93
|
-
if (baseRoleOf(foundBy) !==
|
|
93
|
+
if (baseRoleOf(foundBy) !== "ci") {
|
|
94
94
|
try {
|
|
95
|
-
enqueueRolePrompt(root,
|
|
95
|
+
enqueueRolePrompt(root, "ci", mainRedHealPrompt(red.sha));
|
|
96
96
|
}
|
|
97
97
|
catch (err) {
|
|
98
98
|
// Clear the key so a later verification retries the heal. lastMainRedSha stays set, so a
|
|
99
99
|
// second call for this same SHA still returns early: the retry comes with the next red
|
|
100
100
|
// SHA or a fresh process.
|
|
101
101
|
lastHealKey = null;
|
|
102
|
-
warnEventBestEffort(root, foundBy, `could not queue the heal prompt for
|
|
102
|
+
warnEventBestEffort(root, foundBy, `could not queue the heal prompt for ci: ${errorMessage(err)}`);
|
|
103
103
|
}
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
|
-
/** The one-line, self-limiting directive queued for the
|
|
106
|
+
/** The one-line, self-limiting directive queued for the ci healer when a non-ci path
|
|
107
107
|
* newly verifies a red main. It names the short SHA the check was reported red at, tells the
|
|
108
108
|
* healer to verify main is green now, and to reproduce and land the fix only if it is still
|
|
109
109
|
* red. The self-limiting wording is the green race's whole handling: if main turned green
|
|
110
|
-
* before the healer consumes the prompt,
|
|
110
|
+
* before the healer consumes the prompt, mainRedHealNote returns undefined (no `<main-red>`
|
|
111
111
|
* note is appended) and the "verify green" clause makes the run a no-op. */
|
|
112
112
|
export function mainRedHealPrompt(sha, detail) {
|
|
113
113
|
const base = `Main was reported red at commit ${shortSha(sha)}. Verify main is green now; if it is still red, reproduce the failure and land the fix.`;
|
|
@@ -117,28 +117,38 @@ export function mainRedHealPrompt(sha, detail) {
|
|
|
117
117
|
const tail = detail.outputTail && detail.outputTail.length > 0 ? ` Output tail: ${detail.outputTail.join(" | ")}` : "";
|
|
118
118
|
return `${base} The watch named ${shortSha(detail.culprit)} as the commit that turned it red.${tests}${tail}`;
|
|
119
119
|
}
|
|
120
|
-
/** Queue the heal prompt for
|
|
121
|
-
*
|
|
120
|
+
/** Queue the heal prompt for ci with the culprit's detail: the one way a named red-main culprit
|
|
121
|
+
* is answered (Forward, never back, part 4/6): the watch's onCulprit hand-off routes through
|
|
122
|
+
* this, and nothing is ever reverted. (The ci gate's own red-main hand-off is the separate
|
|
123
|
+
* direct `enqueueRolePrompt` in warnMainRedOnce.) Never throws: a prompt that cannot be
|
|
124
|
+
* queued (an unwritable inbox) is warned, like that hand-off guard in warnMainRedOnce — so
|
|
125
|
+
* the watch's onCulprit (whose caller discards the result) cannot derail a watch run or end
|
|
126
|
+
* the orchestrator. */
|
|
122
127
|
export function queueCulpritHeal(root, tip, detail) {
|
|
123
|
-
|
|
128
|
+
try {
|
|
129
|
+
enqueueRolePrompt(root, "ci", mainRedHealPrompt(tip, detail));
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
warnEventBestEffort(root, TUMWATER_SOURCE, `could not queue the heal prompt for ci: ${errorMessage(err)}`);
|
|
133
|
+
}
|
|
124
134
|
}
|
|
125
135
|
/** checkMainBaseline's per-run hook: log the one run per SHA (cache misses only) under the role
|
|
126
136
|
* that paid for it, with its duration — the gate's build_check sibling, so both halves of the
|
|
127
137
|
* team's deterministic verification are priced in the feed. The one logger for every baseline
|
|
128
|
-
* check this module runs — the
|
|
138
|
+
* check this module runs — the ci handoff, the authoring gate, and mainTipVerdict's tip
|
|
129
139
|
* check (which review.ts's gate and merge-check-failures.ts's attributeRedCheck reach
|
|
130
140
|
* through it) — so the event's shape cannot drift between them. */
|
|
131
141
|
export function baselineCheckLogger(root, role) {
|
|
132
142
|
return baselineCheckEventLogger(role, (event) => logEventBestEffort(root, event));
|
|
133
143
|
}
|
|
134
|
-
/** Red-main handoff for the `
|
|
135
|
-
*
|
|
136
|
-
*
|
|
144
|
+
/** Red-main handoff for the `ci` healer: mainRedGate exempts
|
|
145
|
+
* ci so the only role that can unblock the team may author, but its prompt then starts from
|
|
146
|
+
* its own queue and knows nothing about the red suite. This runs the same per-SHA baseline check
|
|
137
147
|
* (cache and provisional-red re-verification reused unchanged, so it costs at most the one run
|
|
138
148
|
* the gate would have paid for) and, when main is red, returns the note that points the healer
|
|
139
149
|
* at the failure. Returns undefined on green, no declared check, or an environmental skip — the
|
|
140
150
|
* healer's run then proceeds exactly as it did before this existed. Never throws. */
|
|
141
|
-
export async function
|
|
151
|
+
export async function mainRedHealNote(root, role, wt) {
|
|
142
152
|
// The declared check is detected through the live config (plans/portability.md §6/7); a
|
|
143
153
|
// broken file degrades to defaults, which declare none — the run then proceeds as before.
|
|
144
154
|
const config = liveConfig(root);
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
import { checkMainBaseline } from "./main-baseline.js";
|
|
2
|
-
import { findRedCulprit } from "./main-culprit.js";
|
|
3
2
|
import { baselineCheckLogger, withMainTipWorktree } from "./main-red.js";
|
|
4
3
|
import { CHECK_TIER } from "../concurrency/check-permit.js";
|
|
5
4
|
import { FAILING_TESTS_MAX } from "../build/build-check-report.js";
|
|
6
5
|
import { detectBuildCheck } from "../build/build-check-detect.js";
|
|
7
|
-
import { gitTry } from "../git/git-run.js";
|
|
6
|
+
import { git, gitTry } from "../git/git-run.js";
|
|
8
7
|
import { logEventBestEffort, TUMWATER_SOURCE } from "../events/events.js";
|
|
9
8
|
import { errorMessage } from "../text/text.js";
|
|
10
9
|
import { fileQuarantineBugsOnMain } from "../backlog/backlog-archive.js";
|
|
11
10
|
import { loadQuarantine, markQuarantineFiled, onlyQuarantined, quarantinedTests, quarantineTests, releaseQuarantine, } from "../review/known-flakes.js";
|
|
12
11
|
import { shortSha } from "../text/format.js";
|
|
12
|
+
import { treeDiffFiles } from "../git/git-diff.js";
|
|
13
|
+
import { mergingRoleOf } from "../events/event-read.js";
|
|
14
|
+
import { isExemptDiff } from "../review/exemptions.js";
|
|
15
|
+
import { diffRuleReasons } from "../review/diff-rules.js";
|
|
13
16
|
/** The failing tests of each root's newest watch verdict, keyed by root. */
|
|
14
17
|
const knownFailures = new Map();
|
|
15
18
|
/** Main's known failures: the failing tests of the watch's newest verdict — main's tip, or the
|
|
@@ -73,8 +76,57 @@ export function createMainWatch(root, mainBranch, onCulprit) {
|
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
async function watchOnce(mainHead, config) {
|
|
79
|
+
// The deterministic diff rules ("Every commit is verified on main, part 1/3"): run the same
|
|
80
|
+
// five checks the gate runs on a change, against main's new tip. A rule failure is a
|
|
81
|
+
// deterministic red — it takes no confirming re-run and never quarantines — reported as a
|
|
82
|
+
// warning naming the rule and the head (and each committing role, when a recent merged event
|
|
83
|
+
// names one). It runs on every tip the watch covers in a poll — a build green, a build
|
|
84
|
+
// red, and a head with no verdict (a skipped or sleep-spanned check) or no declared build
|
|
85
|
+
// check at all — because a tip counts as watched (lastWatched) either way, so this is the one
|
|
86
|
+
// chance to judge its diff before the next head moves.
|
|
87
|
+
const watchRules = async (wt, base, tip) => {
|
|
88
|
+
// Judge each first-parent commit in the range by its own diff and its own merged-event role,
|
|
89
|
+
// not the tip's: a multi-commit range's union diff attributed to whichever role merged the
|
|
90
|
+
// tip would report a legitimate director/steward PRINCIPLES.md edit in an intermediate commit
|
|
91
|
+
// as unauthorized by the role that merged later. base is undefined only before the first
|
|
92
|
+
// watched head, when the range is the tip's own commit.
|
|
93
|
+
//
|
|
94
|
+
// The diff rules read the change's "head" side from the worktree (conflict markers,
|
|
95
|
+
// brief/protected-file content, secrets, backlog structure) against a base revision, so the
|
|
96
|
+
// mirror checkout must sit ON the commit being judged — not on the tip — or an intermediate
|
|
97
|
+
// commit would be compared against the tip's content of its touched files rather than its
|
|
98
|
+
// own. The range is walked oldest-first and ends back on the tip.
|
|
99
|
+
const commits = base === undefined
|
|
100
|
+
? [tip]
|
|
101
|
+
: ((await gitTry(wt, "rev-list", "--first-parent", "--reverse", `${base}..${tip}`)) ?? "")
|
|
102
|
+
.trim()
|
|
103
|
+
.split(/\s+/)
|
|
104
|
+
.filter(Boolean);
|
|
105
|
+
for (const commit of commits) {
|
|
106
|
+
await git(wt, "checkout", "-q", "--detach", "--force", commit);
|
|
107
|
+
const parent = `${commit}^`;
|
|
108
|
+
const files = (await treeDiffFiles(wt, parent, commit)) ?? [];
|
|
109
|
+
const [ruleReason] = await diffRuleReasons(wt, parent, files, mergingRoleOf(root, commit) ?? "", isExemptDiff(files, config.review.exemptPaths));
|
|
110
|
+
if (ruleReason !== undefined) {
|
|
111
|
+
logEventBestEffort(root, {
|
|
112
|
+
source: TUMWATER_SOURCE,
|
|
113
|
+
type: "warning",
|
|
114
|
+
message: `main's tip ${shortSha(tip)} fails the diff rules: ${ruleReason}`,
|
|
115
|
+
});
|
|
116
|
+
// A finding stops the walk mid-range; put the mirror back on the tip. The first finding
|
|
117
|
+
// across the range is reported once, like the gate's short-circuit.
|
|
118
|
+
await git(wt, "checkout", "-q", "--detach", "--force", tip);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
76
123
|
if (!detectBuildCheck(root, config)) {
|
|
77
|
-
|
|
124
|
+
// No declared build check: nothing to watch for a build red, but the diff rules still run on
|
|
125
|
+
// the tip. The build-red path below is what never runs here.
|
|
126
|
+
await withMainTipWorktree(root, mainBranch, async (wt) => {
|
|
127
|
+
await watchRules(wt, lastWatched, await headOf(wt));
|
|
128
|
+
});
|
|
129
|
+
lastWatched = mainHead; // no declared check: nothing else to watch until main moves
|
|
78
130
|
return;
|
|
79
131
|
}
|
|
80
132
|
await withMainTipWorktree(root, mainBranch, async (wt) => {
|
|
@@ -96,11 +148,14 @@ export function createMainWatch(root, mainBranch, onCulprit) {
|
|
|
96
148
|
const baseline = check.baseline;
|
|
97
149
|
const previous = lastWatched;
|
|
98
150
|
// A skipped or sleep-spanned run made no verdict: the head still counts as watched, so the
|
|
99
|
-
// next poll does not start another run for it.
|
|
100
|
-
if (!baseline)
|
|
101
|
-
|
|
151
|
+
// next poll does not start another run for it — but the diff rules still run on this tip.
|
|
152
|
+
if (!baseline) {
|
|
153
|
+
const tip = await headOf(wt);
|
|
154
|
+
await watchRules(wt, previous, tip);
|
|
155
|
+
return void (lastWatched = tip);
|
|
156
|
+
}
|
|
102
157
|
lastWatched = baseline.sha;
|
|
103
|
-
// A red naming only quarantined tests counts as green: no
|
|
158
|
+
// A red naming only quarantined tests counts as green: no redeploy hold.
|
|
104
159
|
const quarantined = baseline.status === "red" ? onlyQuarantined(root, baseline.failingTests) : null;
|
|
105
160
|
const red = baseline.status === "red" && !quarantined;
|
|
106
161
|
knownFailures.set(root, red ? (baseline.failingTests ?? []) : []);
|
|
@@ -118,35 +173,28 @@ export function createMainWatch(root, mainBranch, onCulprit) {
|
|
|
118
173
|
...(quarantined ? { quarantined } : {}),
|
|
119
174
|
...(baseline.inheritedFrom !== undefined ? { inherited: true } : {}),
|
|
120
175
|
});
|
|
176
|
+
// The diff rules judge the tip whether the build is green or red; the build-red path below
|
|
177
|
+
// names the whole tip as the culprit, so the two red kinds are judged independently.
|
|
178
|
+
await watchRules(wt, previous, baseline.sha);
|
|
121
179
|
if (!red)
|
|
122
180
|
return;
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
else {
|
|
142
|
-
logEventBestEffort(root, {
|
|
143
|
-
source: TUMWATER_SOURCE,
|
|
144
|
-
type: "warning",
|
|
145
|
-
message: found.kind === "environment"
|
|
146
|
-
? `main's last green commit ${shortSha(found.green)} is red now too — not attributing`
|
|
147
|
-
: `main watch could not name the commit that turned main red: ${found.reason}`,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
181
|
+
// The newest head is the culprit ("Every commit is verified on main, part 2/3"): the watch
|
|
182
|
+
// checks each new red tip, so whatever it found belongs to the head it checked. Once per red
|
|
183
|
+
// head — lastWatched already holds it, so a later poll does not attribute it again — name
|
|
184
|
+
// the tip with its own failing tests and, when the newest merged event names it, its runner.
|
|
185
|
+
const runner = mergingRoleOf(root, baseline.sha);
|
|
186
|
+
logEventBestEffort(root, {
|
|
187
|
+
source: TUMWATER_SOURCE,
|
|
188
|
+
type: "main_culprit",
|
|
189
|
+
commit: baseline.sha,
|
|
190
|
+
...(runner !== undefined ? { runner } : {}),
|
|
191
|
+
failingTests: baseline.failingTests ?? [],
|
|
192
|
+
});
|
|
193
|
+
onCulprit?.({
|
|
194
|
+
commit: baseline.sha,
|
|
195
|
+
failingTests: baseline.failingTests ?? [],
|
|
196
|
+
tip: baseline.sha,
|
|
197
|
+
});
|
|
150
198
|
});
|
|
151
199
|
}
|
|
152
200
|
return {
|
|
@@ -37,5 +37,7 @@ export async function editBriefText(root, text) {
|
|
|
37
37
|
if (typeof changed === "string")
|
|
38
38
|
return { ok: false, error: changed };
|
|
39
39
|
const committed = changed && (await commitOperatorBacklogEdit(root, name, stampedSubject("operator", "Edit brief"))); // vocabulary-ok: commit prefix stays
|
|
40
|
-
|
|
40
|
+
// A commit that fails is rolled back by commitOperatorBacklogEdit, so on disk the brief is
|
|
41
|
+
// unchanged: report changed accordingly (nothing persisted) instead of claiming the edit landed.
|
|
42
|
+
return { ok: true, file: name, changed: changed && committed, committed };
|
|
41
43
|
}
|