tumwater 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +13 -0
  4. package/dist/src/agent/claude/claude-backend.js +2 -2
  5. package/dist/src/agent/claude/claude-stream.js +3 -2
  6. package/dist/src/agent/codex/codex-backend.js +3 -3
  7. package/dist/src/agent/codex/codex-stream.js +3 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  9. package/dist/src/backlog/backlog-archive.js +60 -25
  10. package/dist/src/backlog/backlog-cli-view.js +3 -28
  11. package/dist/src/backlog/backlog-cli.js +9 -64
  12. package/dist/src/backlog/backlog-commit.js +17 -9
  13. package/dist/src/backlog/backlog-eligibility.js +44 -26
  14. package/dist/src/backlog/backlog-parts.js +23 -13
  15. package/dist/src/backlog/backlog-release.js +33 -16
  16. package/dist/src/baseline/main-guardrails.js +0 -73
  17. package/dist/src/baseline/main-red.js +31 -21
  18. package/dist/src/baseline/main-watch.js +82 -34
  19. package/dist/src/brief/brief-edit.js +3 -1
  20. package/dist/src/budget/budget-fallback-view.js +288 -0
  21. package/dist/src/budget/budget-wiring.js +158 -0
  22. package/dist/src/budget/budget.js +2 -1
  23. package/dist/src/build/dep-install.js +64 -1
  24. package/dist/src/cli/cli-args.js +63 -0
  25. package/dist/src/cli/cli-flag-specs.js +33 -0
  26. package/dist/src/cli/cli-query-commands.js +16 -41
  27. package/dist/src/cli/cli-review-commands.js +113 -0
  28. package/dist/src/cli/cli-role-command.js +118 -13
  29. package/dist/src/cli/cli-start-summary.js +10 -3
  30. package/dist/src/cli/cli-start.js +33 -5
  31. package/dist/src/cli/completion.js +21 -1
  32. package/dist/src/cli/config-commands.js +17 -8
  33. package/dist/src/cli/help.js +74 -14
  34. package/dist/src/cli/log-commands.js +26 -13
  35. package/dist/src/cli/question-commands.js +7 -1
  36. package/dist/src/cli.js +71 -6
  37. package/dist/src/collections.js +31 -4
  38. package/dist/src/config/config-editable-keys.js +3 -0
  39. package/dist/src/config/config-fallbacks.js +3 -2
  40. package/dist/src/config/config-validation.js +47 -42
  41. package/dist/src/config/config-views.js +10 -2
  42. package/dist/src/config/config-write.js +33 -1
  43. package/dist/src/config/config.js +7 -2
  44. package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
  45. package/dist/src/doctor/doctor-backlog-plans.js +214 -79
  46. package/dist/src/doctor/doctor-backlog.js +155 -22
  47. package/dist/src/doctor/doctor-check-names.js +5 -1
  48. package/dist/src/doctor/doctor-checks.js +10 -15
  49. package/dist/src/doctor/doctor-claude-checks.js +6 -9
  50. package/dist/src/doctor/doctor-codex-checks.js +6 -9
  51. package/dist/src/doctor/doctor-contention-checks.js +244 -118
  52. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  53. package/dist/src/doctor/doctor-holds.js +4 -6
  54. package/dist/src/doctor/doctor-model-checks.js +45 -200
  55. package/dist/src/doctor/doctor-notify.js +7 -2
  56. package/dist/src/doctor/doctor-opencode-checks.js +7 -5
  57. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  58. package/dist/src/doctor/doctor-project-checks.js +106 -12
  59. package/dist/src/doctor/doctor.js +10 -5
  60. package/dist/src/doctor/probe.js +14 -1
  61. package/dist/src/events/event-format.js +7 -6
  62. package/dist/src/events/event-labels.js +0 -1
  63. package/dist/src/events/event-read.js +13 -0
  64. package/dist/src/events/event-window.js +20 -2
  65. package/dist/src/failure/failure-data.js +9 -4
  66. package/dist/src/failure/failure-render.js +8 -8
  67. package/dist/src/failure/time-spend.js +0 -64
  68. package/dist/src/files/file-queue.js +17 -0
  69. package/dist/src/gates/budget-gates.js +27 -257
  70. package/dist/src/gates/gate-polls.js +49 -141
  71. package/dist/src/gates/maintenance-quota.js +27 -17
  72. package/dist/src/git/commit-message.js +12 -0
  73. package/dist/src/git/git.js +18 -0
  74. package/dist/src/git/slot-salvage.js +3 -3
  75. package/dist/src/gui/gui-backlog-commands.js +3 -3
  76. package/dist/src/gui/gui-backlog-endpoints.js +5 -4
  77. package/dist/src/gui/gui-command.js +19 -1
  78. package/dist/src/gui/gui-config-commands.js +75 -9
  79. package/dist/src/gui/gui-endpoint-commands.js +2 -2
  80. package/dist/src/gui/gui-operator-commands.js +134 -7
  81. package/dist/src/gui/gui-prompt-commands.js +15 -6
  82. package/dist/src/gui/gui-report-endpoints.js +10 -3
  83. package/dist/src/gui/gui-server.js +16 -2
  84. package/dist/src/history/history-queued.js +8 -0
  85. package/dist/src/history/history.js +2 -5
  86. package/dist/src/inbox/inbox-top.js +4 -9
  87. package/dist/src/merge/merge-core.js +2 -25
  88. package/dist/src/merge/merge-drain.js +22 -135
  89. package/dist/src/merge/merge-pipeline.js +1 -1
  90. package/dist/src/merge/merge-post-review.js +154 -30
  91. package/dist/src/merge/merge-queue-render.js +11 -12
  92. package/dist/src/merge/merge-queue.js +22 -1
  93. package/dist/src/merge/merge-requeue.js +1 -0
  94. package/dist/src/merge/merge-stack.js +1 -7
  95. package/dist/src/merge/merge-to-main.js +1 -9
  96. package/dist/src/merge/merge-top.js +33 -19
  97. package/dist/src/merge/merge-verify.js +64 -9
  98. package/dist/src/notes-cli.js +8 -3
  99. package/dist/src/orchestrator/orchestrator-poll.js +47 -15
  100. package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
  101. package/dist/src/orchestrator/orchestrator.js +5 -4
  102. package/dist/src/orchestrator/scheduling-scope.js +51 -7
  103. package/dist/src/orchestrator/scheduling-skip.js +15 -5
  104. package/dist/src/paths.js +16 -6
  105. package/dist/src/pi/pi-backend.js +2 -2
  106. package/dist/src/process/run-marker.js +8 -0
  107. package/dist/src/prompt/gate-notes.js +1 -1
  108. package/dist/src/prompt/gate-prompts.js +30 -7
  109. package/dist/src/prompt/notes-backlog.js +13 -10
  110. package/dist/src/prompt/prompt.js +3 -1
  111. package/dist/src/report/report-data.js +49 -69
  112. package/dist/src/report/report-fold.js +11 -12
  113. package/dist/src/report/report-render.js +6 -4
  114. package/dist/src/report/report.js +2 -5
  115. package/dist/src/review/diff-rules.js +53 -0
  116. package/dist/src/review/known-flakes.js +12 -0
  117. package/dist/src/review/post-merge-review-accept.js +51 -0
  118. package/dist/src/review/post-merge-review-render.js +112 -0
  119. package/dist/src/review/post-merge-review.js +157 -24
  120. package/dist/src/review/protected-edits.js +22 -7
  121. package/dist/src/review/quarantine-render.js +65 -0
  122. package/dist/src/review/review.js +33 -49
  123. package/dist/src/roles/role-catalog.js +60 -2
  124. package/dist/src/roles/role-guidance.js +10 -0
  125. package/dist/src/roles/role-labels.js +6 -3
  126. package/dist/src/roles/role-render.js +43 -8
  127. package/dist/src/roles/role-view.js +36 -9
  128. package/dist/src/roles/roles.js +3 -3
  129. package/dist/src/run/ci-parity.js +121 -0
  130. package/dist/src/run/qa-coverage.js +50 -15
  131. package/dist/src/run/run-detail-data.js +9 -13
  132. package/dist/src/run/run-detail-render.js +1 -8
  133. package/dist/src/run/run-finalize.js +1 -0
  134. package/dist/src/run/run-prompt.js +14 -0
  135. package/dist/src/run/run-stage.js +66 -37
  136. package/dist/src/run/run-verdict.js +14 -1
  137. package/dist/src/run/stage-check.js +17 -11
  138. package/dist/src/run/stage-checks-shared.js +65 -0
  139. package/dist/src/run/stage-name-checks.js +204 -0
  140. package/dist/src/run/stage-path-checks.js +16 -515
  141. package/dist/src/run/stage-prose-checks.js +261 -0
  142. package/dist/src/runner/leftover.js +50 -19
  143. package/dist/src/runner/revision.js +12 -23
  144. package/dist/src/runner/runner-outcome.js +4 -2
  145. package/dist/src/runner/runner-recovery.js +18 -3
  146. package/dist/src/runner/runner-run.js +21 -8
  147. package/dist/src/runner/runner-state.js +4 -3
  148. package/dist/src/scheduling/claim-entries.js +49 -0
  149. package/dist/src/status/status-data.js +6 -1
  150. package/dist/src/status/status-polls.js +15 -0
  151. package/dist/src/status/status-roles.js +11 -0
  152. package/dist/src/team/reclaim.js +2 -2
  153. package/dist/src/text/datetime.js +3 -2
  154. package/dist/src/text/phrases.js +6 -3
  155. package/dist/src/text/text.js +11 -0
  156. package/dist/src/ui/badges.js +34 -1
  157. package/dist/src/ui/gui/gui-client-backlog.js +25 -3
  158. package/dist/src/ui/gui/gui-client-boot.js +63 -41
  159. package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
  160. package/dist/src/ui/gui/gui-client-drawer.js +29 -4
  161. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  162. package/dist/src/ui/gui/gui-client-history.js +74 -72
  163. package/dist/src/ui/gui/gui-client-labels.js +5 -1
  164. package/dist/src/ui/gui/gui-client-operator.js +6 -2
  165. package/dist/src/ui/gui/gui-client-pending.js +188 -11
  166. package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
  167. package/dist/src/ui/gui/gui-client-report.js +20 -5
  168. package/dist/src/ui/gui/gui-client-settings.js +109 -3
  169. package/dist/src/ui/gui/gui-client-team.js +11 -0
  170. package/dist/src/ui/gui/gui-client.js +11 -1
  171. package/dist/src/ui/gui/gui-page.js +1 -0
  172. package/dist/src/ui/gui/gui-styles.js +4 -0
  173. package/dist/src/ui/run-progress-model.js +2 -1
  174. package/dist/src/ui/status-payload.js +237 -9
  175. package/dist/src/ui/status-render.js +12 -4
  176. package/dist/src/ui/team-alerts.js +512 -37
  177. package/dist/src/ui/tone.js +7 -0
  178. package/dist/src/ui/tui/tui-backlog.js +7 -5
  179. package/dist/src/ui/tui/tui-editor-attach.js +1 -7
  180. package/dist/src/ui/tui/tui-editor-backends.js +1 -6
  181. package/dist/src/ui/tui/tui-editor-brief.js +4 -24
  182. package/dist/src/ui/tui/tui-editor-budget.js +1 -8
  183. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  184. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  185. package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
  186. package/dist/src/ui/tui/tui-editor-config.js +19 -6
  187. package/dist/src/ui/tui/tui-editor-entry.js +3 -17
  188. package/dist/src/ui/tui/tui-editor-instances.js +9 -0
  189. package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
  190. package/dist/src/ui/tui/tui-editor-interval.js +18 -7
  191. package/dist/src/ui/tui/tui-editor-notes.js +6 -24
  192. package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
  193. package/dist/src/ui/tui/tui-editor-retire.js +1 -7
  194. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  195. package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
  196. package/dist/src/ui/tui/tui-editor-session.js +41 -0
  197. package/dist/src/ui/tui/tui-editor-slots.js +1 -0
  198. package/dist/src/ui/tui/tui-editors.js +10 -1
  199. package/dist/src/ui/tui/tui-frame.js +18 -6
  200. package/dist/src/ui/tui/tui-keys.js +9 -0
  201. package/dist/src/ui/tui/tui-pane-activity.js +21 -8
  202. package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
  203. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  204. package/dist/src/ui/tui/tui-pane-health.js +113 -3
  205. package/dist/src/ui/tui/tui-pane-history.js +44 -0
  206. package/dist/src/ui/tui/tui-pane-pending.js +285 -56
  207. package/dist/src/ui/tui/tui-pane-queued.js +7 -2
  208. package/dist/src/ui/tui/tui-pane-report.js +9 -3
  209. package/dist/src/ui/tui/tui-pane-session.js +9 -0
  210. package/dist/src/ui/tui/tui-pane.js +16 -10
  211. package/dist/src/ui/tui/tui-panes.js +56 -7
  212. package/dist/src/ui/tui/tui.js +8 -5
  213. package/dist/src/verdict/fix-claim.js +47 -1
  214. package/dist/src/verdict/reply-contract.js +35 -0
  215. package/package.json +1 -1
  216. package/dist/src/baseline/main-culprit.js +0 -94
  217. package/dist/src/baseline/main-revert.js +0 -133
@@ -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 ref, resolved against the entry's own series when it names none. */
8
- function parseRef(chunk, ownSeries) {
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
- const series = (m[1]?.trim() ?? "") || ownSeries;
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
- for (const chunk of clause.split(/\s*,\s*|\s+and\s+/i))
144
- refs.push(...parseRef(chunk, ownSeries));
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
- /** Drop the Refused note from `entry`'s slice of `lines`, in place; an entry with none loses its
15
- * Waiting-for-the-next-failure note instead, and one with neither loses its held-for-operator
16
- * Needs-review/Needs-replan note (the `(held N)` count at/above threshold, plus its
17
- * **Previously held N** line). False (and `lines` untouched) when the entry carries none of the
18
- * three, shared by releaseEntry and tryReleaseEntry so they cannot drift. */
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
- if (kept.length === slice.length)
25
- kept = withoutHeldForOperatorNotes(slice);
26
- if (kept.length === slice.length)
27
- return false;
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 true;
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
- if (!liftRefusal(lines, entry))
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
- if (!liftRefusal(lines, entry))
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, BUGFIX_ROLE } from "../roles/roles.js";
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 bugfix handoff
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 bugfix handoff share one guard: whichever observes 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 bugfix itself, one prompt is enqueued to bugfix's own inbox so
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; bugfix finding its own red skips the enqueue because its running run already
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
- // Bugfix is the only repairer, and it is exempt from mainRedGate — but it is a work role
84
- // that with no open bugs defers like a maintenance role, so a red main discovered by any
85
- // other path could sit unrepaired until bugfix's own interval or a coincidental wake.
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) !== BUGFIX_ROLE) {
93
+ if (baseRoleOf(foundBy) !== "ci") {
94
94
  try {
95
- enqueueRolePrompt(root, BUGFIX_ROLE, mainRedHealPrompt(red.sha));
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 bugfix: ${errorMessage(err)}`);
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 bugfix healer when a non-bugfix path
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, bugfixMainRedNote returns undefined (no `<main-red>`
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 bugfix with the culprit's detail: the fallback when the automatic
121
- * revert (src/baseline/main-revert.ts) is skipped or fails. */
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
- enqueueRolePrompt(root, BUGFIX_ROLE, mainRedHealPrompt(tip, detail));
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 bugfix handoff, the authoring gate, and mainTipVerdict's tip
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 `bugfix` healer: mainRedGate exempts
135
- * bugfix so the only role that can unblock the team may author, but its prompt then starts from
136
- * BUGS.md and knows nothing about the red suite. This runs the same per-SHA baseline check
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 bugfixMainRedNote(root, role, wt) {
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
- lastWatched = mainHead; // no declared check: nothing to watch until main moves
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
- return void (lastWatched = await headOf(wt));
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 bisect, no redeploy hold.
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
- // Once per red head: lastWatched already holds it, so a later poll does not search again.
124
- const found = await findRedCulprit(root, wt, config, baseline);
125
- if (found.kind === "culprit") {
126
- logEventBestEffort(root, {
127
- source: TUMWATER_SOURCE,
128
- type: "main_culprit",
129
- commit: found.commit,
130
- ...(found.runner !== undefined ? { runner: found.runner } : {}),
131
- failingTests: found.failingTests,
132
- green: found.green,
133
- });
134
- onCulprit?.({
135
- commit: found.commit,
136
- ...(found.runner !== undefined ? { runner: found.runner } : {}),
137
- failingTests: found.failingTests,
138
- tip: baseline.sha,
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
- return { ok: true, file: name, changed, committed };
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
  }