tumwater 0.4.4 → 0.4.5

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 (187) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-run.js +1 -0
  4. package/dist/src/agent/opencode/write-guard-plugin/index.js +11 -8
  5. package/dist/src/agent/subscription-account.js +68 -3
  6. package/dist/src/agent/write-guard.js +50 -13
  7. package/dist/src/backlog/backlog-eligibility.js +8 -4
  8. package/dist/src/backlog/backlog-index.js +2 -2
  9. package/dist/src/backlog/backlog-md.js +13 -2
  10. package/dist/src/backlog/backlog-release.js +3 -11
  11. package/dist/src/backlog/backlog-write.js +19 -11
  12. package/dist/src/baseline/main-baseline.js +47 -72
  13. package/dist/src/baseline/main-guardrails.js +11 -7
  14. package/dist/src/baseline/main-red.js +100 -12
  15. package/dist/src/baseline/main-watch.js +66 -20
  16. package/dist/src/budget/budget-fallback-view.js +96 -28
  17. package/dist/src/build/build-check-detect.js +22 -3
  18. package/dist/src/build/build-check-events.js +25 -5
  19. package/dist/src/build/build-check-report.js +40 -4
  20. package/dist/src/build/build-check-rerun.js +62 -0
  21. package/dist/src/build/build-check-scoped.js +13 -2
  22. package/dist/src/build/build-check.js +2 -1
  23. package/dist/src/build/build-info.js +4 -0
  24. package/dist/src/build/dep-install.js +1 -2
  25. package/dist/src/build/host-sleep.js +9 -0
  26. package/dist/src/change/change-render.js +2 -1
  27. package/dist/src/cli/cli-args.js +34 -2
  28. package/dist/src/cli/cli-command-args.js +12 -4
  29. package/dist/src/cli/cli-init.js +5 -1
  30. package/dist/src/cli/cli-query-commands.js +10 -6
  31. package/dist/src/cli/cli-review-commands.js +3 -3
  32. package/dist/src/cli/cli-role-command.js +338 -244
  33. package/dist/src/cli/cli-start.js +10 -2
  34. package/dist/src/cli/completion.js +6 -2
  35. package/dist/src/cli/help.js +1 -1
  36. package/dist/src/cli.js +111 -139
  37. package/dist/src/collections.js +21 -0
  38. package/dist/src/concurrency/check-permit.js +30 -3
  39. package/dist/src/concurrency/lock.js +73 -14
  40. package/dist/src/config/config-editable-keys.js +9 -0
  41. package/dist/src/config/config-fallbacks.js +16 -5
  42. package/dist/src/config/config-schema.js +3 -3
  43. package/dist/src/config/config-validation.js +2 -0
  44. package/dist/src/config/config-views.js +55 -33
  45. package/dist/src/config/config.js +12 -5
  46. package/dist/src/doctor/doctor-backlog-bugs.js +33 -1
  47. package/dist/src/doctor/doctor-backlog.js +6 -8
  48. package/dist/src/doctor/doctor-check-names.js +2 -0
  49. package/dist/src/doctor/doctor-checks.js +13 -6
  50. package/dist/src/doctor/doctor-claude-checks.js +3 -3
  51. package/dist/src/doctor/doctor-contention-checks.js +7 -6
  52. package/dist/src/doctor/doctor-fallback-checks.js +37 -2
  53. package/dist/src/doctor/doctor-holds.js +7 -10
  54. package/dist/src/doctor/doctor-model-checks.js +7 -0
  55. package/dist/src/doctor/doctor-opencode-checks.js +2 -2
  56. package/dist/src/doctor/doctor-orphans.js +103 -27
  57. package/dist/src/doctor/doctor-project-checks.js +37 -20
  58. package/dist/src/doctor/doctor.js +5 -3
  59. package/dist/src/events/event-format.js +63 -98
  60. package/dist/src/events/event-labels.js +2 -1
  61. package/dist/src/events/events.js +3 -3
  62. package/dist/src/failure/failure-data.js +6 -4
  63. package/dist/src/failure/failure-state-change.js +14 -4
  64. package/dist/src/files/file-queue.js +32 -19
  65. package/dist/src/files/files.js +2 -2
  66. package/dist/src/files/json-object.js +3 -2
  67. package/dist/src/gates/budget-gates.js +37 -2
  68. package/dist/src/gates/gate-polls.js +8 -7
  69. package/dist/src/gates/{maintenance-quota.js → maintenance-quota-gates.js} +1 -1
  70. package/dist/src/gates/startup-gates.js +18 -1
  71. package/dist/src/git/git.js +8 -0
  72. package/dist/src/git/worktree-pool.js +18 -5
  73. package/dist/src/gui/gui-admin-endpoints.js +11 -1
  74. package/dist/src/gui/gui-args.js +2 -2
  75. package/dist/src/gui/gui-backlog-endpoints.js +2 -1
  76. package/dist/src/gui/gui-history-endpoints.js +3 -3
  77. package/dist/src/gui/gui-operator-commands.js +2 -3
  78. package/dist/src/history/history.js +10 -6
  79. package/dist/src/inbox/director-request.js +92 -0
  80. package/dist/src/merge/merge-backlog-conflicts.js +33 -11
  81. package/dist/src/merge/merge-batch.js +13 -235
  82. package/dist/src/merge/merge-cancel.js +2 -1
  83. package/dist/src/merge/merge-core.js +13 -22
  84. package/dist/src/merge/merge-drain.js +103 -99
  85. package/dist/src/merge/merge-pipeline.js +6 -4
  86. package/dist/src/merge/merge-post-review-drain.js +8 -2
  87. package/dist/src/merge/merge-questions.js +3 -3
  88. package/dist/src/merge/merge-queue-render.js +2 -2
  89. package/dist/src/merge/merge-queue.js +2 -1
  90. package/dist/src/merge/merge-requeue.js +62 -19
  91. package/dist/src/merge/merge-slot.js +41 -10
  92. package/dist/src/merge/merge-to-main.js +16 -165
  93. package/dist/src/merge/merge-top.js +2 -1
  94. package/dist/src/merge/merge-verify.js +5 -3
  95. package/dist/src/model-check/model-check-gate.js +206 -0
  96. package/dist/src/model-check/model-check-probe.js +109 -0
  97. package/dist/src/model-check/model-check.js +101 -0
  98. package/dist/src/operator/operator-commands.js +6 -23
  99. package/dist/src/orchestrator/orchestrator-launch.js +6 -1
  100. package/dist/src/orchestrator/orchestrator-poll.js +32 -5
  101. package/dist/src/orchestrator/orchestrator-scheduling.js +7 -7
  102. package/dist/src/orchestrator/orchestrator.js +14 -1
  103. package/dist/src/paths.js +22 -0
  104. package/dist/src/pi/pi-run-result.js +16 -0
  105. package/dist/src/pi/pi-watchdogs.js +15 -4
  106. package/dist/src/process/process.js +24 -0
  107. package/dist/src/prompt/gate-notes.js +10 -4
  108. package/dist/src/prompt/gate-prompts.js +13 -14
  109. package/dist/src/prompt/principles.js +13 -3
  110. package/dist/src/prompt/prompt-followup.js +14 -5
  111. package/dist/src/redeploy/redeploy-probes.js +53 -18
  112. package/dist/src/redeploy/redeploy-target.js +100 -0
  113. package/dist/src/redeploy/redeployer.js +99 -45
  114. package/dist/src/report/report-main-red.js +2 -2
  115. package/dist/src/report/report-render.js +2 -2
  116. package/dist/src/review/known-flakes.js +4 -2
  117. package/dist/src/review/post-merge-review-accept.js +2 -1
  118. package/dist/src/review/post-merge-review.js +6 -1
  119. package/dist/src/review/review-precheck.js +72 -28
  120. package/dist/src/roles/role-catalog.js +13 -3
  121. package/dist/src/roles/role-render.js +158 -83
  122. package/dist/src/roles/role-view.js +272 -71
  123. package/dist/src/roles/roles.js +1 -1
  124. package/dist/src/run/ci-parity.js +15 -2
  125. package/dist/src/run/run-apply.js +0 -6
  126. package/dist/src/run/run-detail-render.js +2 -2
  127. package/dist/src/run/run-finalize.js +4 -0
  128. package/dist/src/run/run-timing.js +2 -2
  129. package/dist/src/run/run-usage.js +9 -1
  130. package/dist/src/run/run-verdict.js +79 -51
  131. package/dist/src/runner/runner-outcome.js +0 -1
  132. package/dist/src/runner/runner-pi.js +6 -0
  133. package/dist/src/runner/runner-recovery.js +5 -4
  134. package/dist/src/runner/runner-revision.js +2 -1
  135. package/dist/src/runner/runner-run.js +154 -108
  136. package/dist/src/runner/runner-state-load.js +88 -42
  137. package/dist/src/runner/runner-state.js +3 -2
  138. package/dist/src/runner/runner.js +39 -20
  139. package/dist/src/{orchestrator/scheduling-admit.js → scheduling/admit.js} +2 -2
  140. package/dist/src/scheduling/backoff.js +6 -6
  141. package/dist/src/scheduling/claims.js +10 -12
  142. package/dist/src/{orchestrator/scheduling-scope.js → scheduling/scope.js} +9 -59
  143. package/dist/src/{orchestrator/scheduling-skip.js → scheduling/skip.js} +112 -78
  144. package/dist/src/status/status-budget.js +38 -4
  145. package/dist/src/status/status-data.js +23 -14
  146. package/dist/src/status/status-polls.js +82 -25
  147. package/dist/src/status/status-role-notes.js +25 -7
  148. package/dist/src/status/status-roles.js +56 -25
  149. package/dist/src/team/team-orchestrator-info.js +51 -0
  150. package/dist/src/team/team-state.js +25 -9
  151. package/dist/src/text/format.js +20 -2
  152. package/dist/src/text/phrases.js +57 -10
  153. package/dist/src/text/suggest.js +14 -1
  154. package/dist/src/text/text.js +30 -1
  155. package/dist/src/ui/badges.js +102 -17
  156. package/dist/src/ui/gui/gui-client-drawer-config.js +12 -6
  157. package/dist/src/ui/gui/gui-client-model.js +4 -0
  158. package/dist/src/ui/gui/gui-client-operator.js +11 -14
  159. package/dist/src/ui/gui/gui-client-pending.js +10 -3
  160. package/dist/src/ui/gui/gui-client-report-chart.js +22 -15
  161. package/dist/src/ui/gui/gui-client-settings.js +5 -1
  162. package/dist/src/ui/gui/gui-client-team.js +7 -0
  163. package/dist/src/ui/status-model.js +21 -6
  164. package/dist/src/ui/status-payload.js +19 -5
  165. package/dist/src/ui/status-render.js +14 -8
  166. package/dist/src/ui/team-alert-shared.js +2 -2
  167. package/dist/src/ui/team-alert-standings.js +96 -0
  168. package/dist/src/ui/team-alert-status.js +39 -12
  169. package/dist/src/ui/team-alerts.js +8 -4
  170. package/dist/src/ui/tui/tui-backlog.js +12 -0
  171. package/dist/src/ui/tui/tui-editor-interval.js +2 -2
  172. package/dist/src/ui/tui/tui-editor-role-model.js +5 -1
  173. package/dist/src/ui/tui/tui-input.js +2 -2
  174. package/dist/src/ui/tui/tui-pane-backlog.js +2 -2
  175. package/dist/src/ui/tui/tui-pane-health.js +25 -21
  176. package/dist/src/ui/tui/tui-pane-history.js +156 -471
  177. package/dist/src/ui/tui/tui-pane-pending-selection.js +346 -0
  178. package/dist/src/ui/tui/tui-pane-pending.js +46 -322
  179. package/dist/src/ui/tui/tui-pane-queued.js +2 -2
  180. package/dist/src/ui/tui/tui-pane-trail.js +420 -0
  181. package/dist/src/ui/tui/tui-pane.js +3 -3
  182. package/dist/src/ui/tui/tui.js +2 -1
  183. package/dist/src/verdict/fix-claim.js +10 -4
  184. package/dist/src/verdict/reply-contract.js +2 -4
  185. package/package.json +9 -7
  186. package/dist/src/merge/merge-check-failures.js +0 -212
  187. package/dist/src/merge/merge-stack.js +0 -235
package/README.md CHANGED
@@ -42,7 +42,7 @@ and is also outside `npm test`.
42
42
  ## Status
43
43
 
44
44
  <!-- tumwater:status:start -->
45
- **v0.4.4**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
45
+ **v0.4.5**: working tumwater. The director and 15 of the 17 roles are enabled by default; `selfcheck`,
46
46
  which files tumwater bugs from tumwater's own event log, and `feedback`, which files them as GitHub
47
47
  issues on znation/tumwater, are opt-in (`roles.selfcheck.enabled`, `roles.feedback.enabled`).
48
48
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "sha": "1f6978ed5d8061b12afc907b017f0164426f49ee",
3
- "builtAt": 1793345933702,
2
+ "sha": "6440aaa1705f2accb491fa2155a5da6aadee05d8",
3
+ "builtAt": 1793604373395,
4
4
  "root": "/home/runner/work/tumwater/tumwater"
5
5
  }
@@ -122,6 +122,7 @@ export function runAgent(opts) {
122
122
  extendOnProgress: opts.kind === "gate",
123
123
  kill: () => terminateChild(child),
124
124
  onStallWarning: opts.onStallWarning,
125
+ onUnansweredReply: opts.onUnansweredReply,
125
126
  });
126
127
  let aborted = false;
127
128
  // A blind image read on an authoring run deliberately kills it, like the watchdogs do.
@@ -4,20 +4,23 @@
4
4
  *
5
5
  * OpenCode 2.x has no hook that blocks a tool from running. It has a permission `evaluate` hook
6
6
  * that may turn a decision to `deny` with a `message`, which reaches the model as the tool's error
7
- * while the run goes on. Every file-writing tool asks for the `edit` action with the absolute
8
- * paths it writes as `resources`, and the hook runs under `--auto` too, since `--auto` only
9
- * answers what would otherwise be asked. Checked live on opencode 2.1.0 on 2026-10-23. A
10
- * configured plugin path must be a directory, which is why this is `index.js` in a directory of
11
- * its own.
7
+ * while the run goes on. The file-writing tools ask for the `edit` action (edit in place) and the
8
+ * `write` action (create or overwrite a file — a new path a `../` climb can point outside the
9
+ * checkout) with the absolute paths they write as `resources`, and the hook runs under `--auto`
10
+ * too, since `--auto` only answers what would otherwise be asked. Checked live on opencode 2.1.0
11
+ * on 2026-10-23. A configured plugin path must be a directory, which is why this is `index.js` in
12
+ * a directory of its own.
12
13
  *
13
14
  * The plugin's context names the run's directory (`location.directory`, the worktree OpenCode
14
15
  * runs in), which is the run directory the rule reads its checkout from. A failure of its own
15
16
  * leaves the decision alone, so a broken guard cannot stop every edit of a run. */
16
17
  import { strayWriteRefusal } from "../../write-guard.js";
17
- /** Deny an `edit` decision whose resources include a stray path from a run in `runDir`, naming
18
- * the first such path's refusal. Every other decision is left as it is. Exported for tests. */
18
+ /** Deny an `edit` or `write` decision whose resources include a stray path from a run in
19
+ * `runDir`, naming the first such path's refusal. Every other decision is left as it is. Exported
20
+ * for tests. */
21
+ const FILE_WRITE_ACTIONS = new Set(["edit", "write"]);
19
22
  export function guardEvaluate(event, runDir) {
20
- if (event.action !== "edit" || !Array.isArray(event.resources))
23
+ if (!FILE_WRITE_ACTIONS.has(event.action) || !Array.isArray(event.resources))
21
24
  return;
22
25
  for (const resource of event.resources) {
23
26
  if (typeof resource !== "string" || resource === "")
@@ -3,7 +3,8 @@
3
3
  * "Billing" and §8 "The subscription usage window"). Persisted at
4
4
  * `.tumwater/state/<backend>-account.json` (plan design grounds/codex-backend.md "Groundwork:
5
5
  * the subscription account is per backend", item 1), written only by the orchestrator process,
6
- * so the sibling parts read one answer instead of re-deriving it per surface.
6
+ * so the free-ness rule and the window displays read one answer instead of re-deriving it per
7
+ * surface.
7
8
  *
8
9
  * The file is a cache, never a source of truth: a run that reports billing overwrites it, and a
9
10
  * missing or unreadable file reads as "no subscription". `resetsAt` is stored as epoch
@@ -14,6 +15,7 @@
14
15
  * keeps today's shape. */
15
16
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
16
17
  import { finiteNumber, isJsonObject, setOwn } from "../files/json-object.js";
18
+ import { cachedByStat } from "../files/stat-cache.js";
17
19
  import { accountPath } from "../paths.js";
18
20
  /** True when a failed run ended on a subscription usage window the account reported as
19
21
  * `rejected` without overage — the signal the team hold throttles on (plans/agent-backends.md
@@ -27,12 +29,74 @@ export function subscriptionWindowRejected(run) {
27
29
  run.billing === "subscription" &&
28
30
  (run.rateWindows ?? []).some((w) => w.status === "rejected" && w.isUsingOverage !== true));
29
31
  }
32
+ /** Status severity for ordering: a rejected window limits, an allowed_warning nears its limit,
33
+ * every other reading (allowed/ok) rides at the bottom, and a reset one no longer limits. */
34
+ const WINDOW_RANK = { rejected: 3, allowed_warning: 2, reset: 0 };
35
+ /** The window type as a compact label: `five_hour` → `5h`, `seven_day` → `7d`; an unknown
36
+ * type falls back to its words with underscores as spaces. The one home of the window spelling
37
+ * the status badge and the role view share (the browser dashboard keeps its inline copy). */
38
+ export function windowLabel(type) {
39
+ const m = /^(\w+)_(hour|day|week)$/.exec(type);
40
+ if (!m)
41
+ return type.replace(/_/g, " ");
42
+ const words = { one: "1", two: "2", three: "3", four: "4", five: "5", six: "6", seven: "7", eight: "8", nine: "9", ten: "10" };
43
+ const unit = m[2] === "hour" ? "h" : m[2] === "day" ? "d" : "w";
44
+ const word = m[1] ?? "";
45
+ return `${words[word] ?? word}${unit}`;
46
+ }
47
+ /** The fraction of a subscription usage window consumed (0–1) as a whole-number percent: a
48
+ * `0.64` utilization reads `64`. The one node-side home of the fraction→percent conversion
49
+ * (the browser dashboard's own window line is embedded client-side and keeps its inline copy). */
50
+ export function utilizationPct(utilization) {
51
+ return Math.round(utilization * 100);
52
+ }
53
+ /** Order two window entries by the windowEntries rule: status severity, then the reported
54
+ * utilization (a missing reading sorts below any number), then the earlier reset, then the
55
+ * type name. Exported so a list of per-backend blocks can be ordered by their primary entry
56
+ * the same way windowEntries orders one account's windows (part 3a/9). */
57
+ export function compareWindowEntries(a, b) {
58
+ const rank = (WINDOW_RANK[b.status] ?? 1) - (WINDOW_RANK[a.status] ?? 1);
59
+ if (rank !== 0)
60
+ return rank;
61
+ const util = (b.utilization ?? -1) - (a.utilization ?? -1);
62
+ if (util !== 0)
63
+ return util;
64
+ const reset = (a.resetsAt ?? Number.POSITIVE_INFINITY) - (b.resetsAt ?? Number.POSITIVE_INFINITY);
65
+ if (reset !== 0)
66
+ return reset;
67
+ return a.type.localeCompare(b.type);
68
+ }
69
+ /** The account's windows as a list, most constrained first (by compareWindowEntries). Empty
70
+ * when the account reported none. */
71
+ export function windowEntries(account) {
72
+ return Object.entries(account.windows)
73
+ .map(([type, w]) => ({ type, ...w }))
74
+ .sort((a, b) => compareWindowEntries(a, b));
75
+ }
76
+ /** The parsed raw body of one backend's account file, keyed by that file's own stat — the same
77
+ * stat-keyed contract cachedByStat gives every observer that polls a slowly-changing file. The
78
+ * account file is written only by the orchestrator on a run's billing/window report, so it
79
+ * changes no faster than runs; on the status snapshot path readAccount ran per backend per poll
80
+ * as one more readJsonFile (read + parse) alongside the already stat-cached marker reads, and
81
+ * budget-gates/role-view read it on other polling surfaces. Writes go through writeJsonAtomic's
82
+ * tmp+rename (writeAccount), which bumps dev/ino/mtime/size, so a refresh invalidates like any
83
+ * edit. The `now`-dependent heal is NOT cached: only the raw body is, and readAccount reapplies
84
+ * its window/observedAt rules against each call's own `now` on a per-call copy, so a window
85
+ * whose `resetsAt` lapses still reads back as reset on the very call it does. Bounded inside
86
+ * cachedByStat. */
87
+ const accountBodyCache = new Map();
88
+ /** Serve `file`'s parsed raw JSON-object body from accountBodyCache: null when the file is
89
+ * missing, unreadable, torn, or not a plain object — the same no-data policy as readJsonFile —
90
+ * and a fresh per-call copy so readAccount's owns the object it reads fields off. Never throws. */
91
+ function readAccountBody(file) {
92
+ return cachedByStat(accountBodyCache, file, file, () => readJsonFile(file), (body) => ({ ...body }));
93
+ }
30
94
  /** Read the persisted account state for a backend, or null when it is absent or unreadable. A
31
95
  * file without a usable top-level `observedAt` reads as absent (the plan's torn-file rule),
32
96
  * and a window whose `resetsAt` is at or before `now` reads back with `status: "reset"` — the
33
97
  * reader, not every consumer, decides that the window no longer limits. */
34
98
  export function readAccount(root, backend, now = Date.now()) {
35
- const doc = readJsonFile(accountPath(root, backend));
99
+ const doc = readAccountBody(accountPath(root, backend));
36
100
  if (doc === null)
37
101
  return null;
38
102
  const billing = doc.billing === "subscription" || doc.billing === "api" ? (doc.billing) : undefined;
@@ -64,7 +128,8 @@ function readWindow(value, fileObservedAt, now) {
64
128
  const resetsAt = finiteNumber(value.resetsAt, Number.NaN);
65
129
  const resets = Number.isFinite(resetsAt);
66
130
  // utilization heals like the two stamps above: JSON's `1e999` parses to Infinity, and a
67
- // non-finite fraction would poison any consumer's utilization read.
131
+ // non-finite fraction would render as `Infinity%` in the badges and poison the
132
+ // windowEntries comparator (Infinity - Infinity is NaN).
68
133
  const utilization = finiteNumber(value.utilization, undefined);
69
134
  const reset = resets && resetsAt <= now;
70
135
  const window = {
@@ -27,25 +27,62 @@ function isWithin(p, dir) {
27
27
  const rel = path.relative(dir, p);
28
28
  return rel === "" || (rel !== ".." && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
29
29
  }
30
- /** `p` with its longest existing prefix resolved through realpath, so a symlink, a `/tmp` that is
31
- * `/private/tmp`, or a path typed in the wrong case on a case-insensitive disk compares as the
32
- * file it reaches. The part that does not exist yet (a file a write would create) is appended
33
- * unchanged. */
30
+ /** `p` with its symlinks resolved as the file it reaches: each component is peeled and a
31
+ * symlink is followed to its target, so a symlinked checkout or a `/tmp` that is `/private/tmp`
32
+ * compares as the path it reaches. The not-yet-existing tail (a file a write would create) is
33
+ * appended unchanged. A broken symlink — target absent, the write/create case, where
34
+ * `realpathSync` throws ENOENT and the old longest-existing-prefix walk read the link as its
35
+ * own location inside the checkout — is followed to its target instead: the write would follow
36
+ * it, so the guard must see where it points. */
34
37
  function realish(p) {
35
- let existing = p;
36
- const rest = [];
37
- for (;;) {
38
+ // Peel the path component by component, exactly as `realpath` does: a regular component is
39
+ // kept, a symlink is replaced by the components its target resolves to, and the first absent
40
+ // component ends the walk, so a not-yet-existing tail is appended unchanged. Replacing the
41
+ // link with its target — instead of keeping the link's own path — is what catches a broken
42
+ // symlink: its target is absent (the write/create case, where `realpathSync` throws ENOENT
43
+ // and the old longest-existing-prefix walk read the link as its location inside the
44
+ // checkout), yet the write would still follow it, so the guard must see where it points. A
45
+ // depth bound stops a symlink cycle (a → b → a) from looping instead of escaping through the
46
+ // tail.
47
+ let q = path.resolve(p).split(path.sep).filter(Boolean);
48
+ const out = [];
49
+ let depth = 0;
50
+ while (q.length > 0) {
51
+ const part = q[0]; // the loop's length check guarantees a first component
52
+ const here = path.join(path.sep, ...out, part);
53
+ let st;
38
54
  try {
39
- return path.join(fs.realpathSync.native(existing), ...rest);
55
+ st = fs.lstatSync(here);
40
56
  }
41
57
  catch {
42
- const parent = path.dirname(existing);
43
- if (parent === existing)
44
- return p;
45
- rest.unshift(path.basename(existing));
46
- existing = parent;
58
+ out.push(part, ...q.slice(1)); // absent: this and the rest are the not-yet-existing tail
59
+ break;
60
+ }
61
+ if (!st.isSymbolicLink()) {
62
+ out.push(part);
63
+ q = q.slice(1);
64
+ }
65
+ else if (depth++ > 100) {
66
+ out.push(part, ...q.slice(1)); // cycle: give up rather than loop
67
+ break;
68
+ }
69
+ else {
70
+ let raw;
71
+ try {
72
+ raw = fs.readlinkSync(here); // a relative target is relative to the link's dir
73
+ }
74
+ catch {
75
+ out.push(part, ...q.slice(1)); // unreadable: treat as the not-yet-existing tail
76
+ break;
77
+ }
78
+ // The target, absolute-resolved, becomes the whole remaining path to peel: the link's
79
+ // own directory is dropped and replaced by wherever the link points.
80
+ const target = path.isAbsolute(raw) ? raw : path.resolve(path.join(path.sep, ...out), raw);
81
+ q = [...target.split(path.sep).filter(Boolean), ...q.slice(1)];
82
+ out.length = 0;
47
83
  }
48
84
  }
85
+ return path.join(path.sep, ...out);
49
86
  }
50
87
  /** A tool's path argument as pi's tools resolve it (pi `resolveToCwd`): a leading `@` dropped,
51
88
  * `~` expanded to the home directory, and a relative path taken from `base`. */
@@ -53,8 +53,11 @@ export function needsReviewNote(body) {
53
53
  * which the plan role clears by splitting or replanning (role-catalog.ts `plan` find). False
54
54
  * for the bugfix role's wait-for-the-next-failure note ("by bugfix: …"), which parks the
55
55
  * entry for selfcheck to lift when the cluster fails again (selfcheck step 4) and which no
56
- * role can act on until then. entryHold returns "needs-review" for every such note (all of
57
- * them park the entry), so the doctor's `stalled entries` row (checkStalledEntries) — the
56
+ * role can act on until then, and for the director's user fallback ("by director: …", the
57
+ * director-request handoff's final park) — the director has already reviewed the entry and the
58
+ * park names what the user must decide, so it is not plan-role work either. entryHold returns
59
+ * "needs-review" for every such note (all of them park the entry), so the doctor's `stalled
60
+ * entries` row (checkStalledEntries) — the
58
61
  * stalled-with-the-plan-role surface — classifies by this agreed subset, keeping the bugfix
59
62
  * parks out of the plan-role blame. Reads the note's text after the NEEDS_REVIEW_PREFIX once,
60
63
  * so the row cannot disagree on which notes the plan role clears; the eligibility mark
@@ -63,8 +66,9 @@ export function isPlanStalled(entry) {
63
66
  const note = needsReviewNote(entry.body);
64
67
  if (note === null)
65
68
  return false;
66
- // A "by bugfix:" note is a wait for evidence that selfcheck clears, not plan-role work.
67
- return !note.includes("by bugfix");
69
+ // A "by bugfix:" note is a wait for evidence that selfcheck clears, and a "by director:"
70
+ // note is the director's reviewed user fallback (INV-B8) — neither is plan-role work.
71
+ return !note.includes("by bugfix") && !note.includes("by director");
68
72
  }
69
73
  /** `lines` without any line starting with `prefix` (the same line-prefix rule hasNoteLine
70
74
  * applies). When a dropped note sat between two blank lines, the blank line after it goes too,
@@ -15,7 +15,7 @@ import { collapseWhitespace, truncate } from "../text/text.js";
15
15
  import { actionableEntryRanges, groupBlockedSeries, partsLabel, } from "./backlog-index-data.js";
16
16
  import { stripEntryStamp } from "./backlog-structure.js";
17
17
  import { entryHoldAgainst, holdMark } from "./backlog-eligibility.js";
18
- import { plannedPartKeys } from "./backlog-parts.js";
18
+ import { entryKey, plannedPartKeys } from "./backlog-parts.js";
19
19
  import { plannedPlanEntries } from "./backlog.js";
20
20
  /** Max characters an index title keeps before a trailing ellipsis, so one very long BUGS.md
21
21
  * heading cannot dominate the block. */
@@ -110,7 +110,7 @@ export function renderBacklogIndexBlock(root) {
110
110
  // plan) — BUGS.md and QUESTIONS.md sections keep every entry on its own line.
111
111
  const rows = entries.map((e) => {
112
112
  const hold = entryHoldAgainst({ title: e.title, body: e.body }, plannedParts);
113
- const key = collapseWhitespace(stripEntryStamp(e.title)).toLowerCase();
113
+ const key = entryKey(e.title);
114
114
  return {
115
115
  start: e.start,
116
116
  end: e.end,
@@ -342,9 +342,20 @@ export function entryLags(md, sectionTitle, dateRe, filingRe) {
342
342
  export function headingMetadata(lines, start) {
343
343
  const line = lines[start] ?? "";
344
344
  const meta = [line];
345
+ const j = collectHeadingContinuation(lines, start, meta, headingIsWrapped(line));
346
+ return { text: meta.join(" "), next: j };
347
+ }
348
+ /** Whether a `### ` heading line opens an unbalanced parenthetical: the only shape whose
349
+ * metadata runs beyond its first line. */
350
+ function headingIsWrapped(line) {
345
351
  // A wrapped heading's first line opens a parenthetical its continuation will close; an
346
352
  // unbalanced count is therefore the only shape that has metadata beyond the first line.
347
- const wrapped = (line.match(/\(/g) ?? []).length > (line.match(/\)/g) ?? []).length;
353
+ return (line.match(/\(/g) ?? []).length > (line.match(/\)/g) ?? []).length;
354
+ }
355
+ /** Append a wrapped `### ` heading's continuation lines to `meta`, stopping at the next entry
356
+ * start, the 3-line cap, or a line ending in `)`; returns the first line index not consumed. */
357
+ function collectHeadingContinuation(lines, start, meta, wrapped) {
358
+ const line = lines[start] ?? "";
348
359
  let closed = line.endsWith(")");
349
360
  let j = start + 1;
350
361
  // Continuation is a heading-only concern (wrapped headings); bullets are single-line.
@@ -356,7 +367,7 @@ export function headingMetadata(lines, start) {
356
367
  j++;
357
368
  closed = next.endsWith(")");
358
369
  }
359
- return { text: meta.join(" "), next: j };
370
+ return j;
360
371
  }
361
372
  /** A `- ` line's or `### ` heading's trailing parenthetical's inner text, nesting-aware: a
362
373
  * backward scan from the text's closing ")" to its matching "(" returns the group's FULL inner
@@ -4,7 +4,7 @@
4
4
  * section. Each comes as an exiting writer (the CLI) and a non-exiting `try…` core that refuses as
5
5
  * data (the TUI and dashboard); entry lookup and the section's file target are
6
6
  * backlog-write.ts's. */
7
- import { backlogTarget, findEntry, locateDashboardEntry, readEntryLines } from "./backlog-write.js";
7
+ import { backlogTarget, findEntry, readEntryLines, withLocatedEntry } from "./backlog-write.js";
8
8
  import { withoutNeedsReviewNotes, withoutRefusalNotes } from "./backlog-eligibility.js";
9
9
  import { writeTextAtomic } from "../files/files.js";
10
10
  import { fail } from "../cli/cli-output.js";
@@ -38,11 +38,7 @@ function liftRefusal(lines, entry) {
38
38
  * title check, all decided inside the lock. */
39
39
  export function tryReleaseEntry(root, kind, n, expectedTitle) {
40
40
  const { fileName, sectionTitle } = backlogTarget(kind);
41
- return withPrimaryWorktreeLock(root, () => {
42
- const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
43
- if (located.status !== "ok")
44
- return located;
45
- const { file, md, entry } = located;
41
+ return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
46
42
  const lines = md.split("\n");
47
43
  const lifted = liftRefusal(lines, entry);
48
44
  if (lifted === null)
@@ -104,11 +100,7 @@ function spliceEntryToTop(lines, sectionTitle, entry) {
104
100
  * title check, all decided inside the lock. */
105
101
  export function tryMoveEntryToTop(root, kind, n, expectedTitle) {
106
102
  const { fileName, sectionTitle } = backlogTarget(kind);
107
- return withPrimaryWorktreeLock(root, () => {
108
- const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
109
- if (located.status !== "ok")
110
- return located;
111
- const { file, md, entry } = located;
103
+ return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
112
104
  if (n === 1)
113
105
  return { status: "already_first", title: entry.title };
114
106
  const lines = md.split("\n");
@@ -147,7 +147,7 @@ export function readEntryLines(root, fileName, sectionTitle, n) {
147
147
  * refuse as data without writing; on success returns the `ok` file path, raw markdown and entry
148
148
  * for the caller to splice and write its own outcome. A pure lookup, decided inside the caller's
149
149
  * lock. */
150
- export function locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle) {
150
+ function locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle) {
151
151
  const file = path.join(root, fileName);
152
152
  const md = readTextIfExists(file) ?? "";
153
153
  const entry = findEntry(md, sectionTitle, n);
@@ -157,6 +157,22 @@ export function locateDashboardEntry(root, fileName, sectionTitle, n, expectedTi
157
157
  return { status: "moved", title: entry.title };
158
158
  return { status: "ok", file, md, entry };
159
159
  }
160
+ /** The shared lock-and-locate prologue of the four non-exiting dashboard writers (tryCancelEntry
161
+ * and tryEditEntry here; tryReleaseEntry and tryMoveEntryToTop in backlog-release.ts): take the
162
+ * cross-process lock, locate the `n`th entry by position and expected title, and refuse as data —
163
+ * returning `located`'s `gone`/`moved` verbatim when the lookup fails — without writing. On a
164
+ * match it calls `splice` with the located file, markdown and entry and returns whatever the
165
+ * splice returns, so each writer keeps its own `ok`-branch outcome; the return is the union of the
166
+ * two refusals and `splice`'s own return, which is exactly each writer's outcome type. The
167
+ * `splice` runs inside the lock, the same window the inline prologue held. */
168
+ export function withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, splice) {
169
+ return withPrimaryWorktreeLock(root, () => {
170
+ const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
171
+ if (located.status !== "ok")
172
+ return located;
173
+ return splice({ file: located.file, md: located.md, entry: located.entry });
174
+ });
175
+ }
160
176
  /** The section-remove entry point, the appendEntry analogue: the same cross-process lock, so a
161
177
  * cancel and a merge's working-tree fast-forward cannot each clobber the other's
162
178
  * read-modify-write. */
@@ -189,11 +205,7 @@ function spliceOutEntry(file, lines, entry) {
189
205
  * `plans`/`bugs` strings, the same `### ` text as ActionableEntryRange.title). */
190
206
  export function tryCancelEntry(root, kind, n, expectedTitle) {
191
207
  const { fileName, sectionTitle } = backlogTarget(kind);
192
- return withPrimaryWorktreeLock(root, () => {
193
- const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
194
- if (located.status !== "ok")
195
- return located;
196
- const { file, md, entry } = located;
208
+ return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
197
209
  spliceOutEntry(file, md.split("\n"), entry);
198
210
  return { status: "cancelled", file: fileName, title: entry.title };
199
211
  });
@@ -264,11 +276,7 @@ export function tryEditEntry(root, kind, n, expectedTitle, rawTitle, rawBody) {
264
276
  const { fileName, sectionTitle } = backlogTarget(kind);
265
277
  const title = stripEntryStamp(collapseWhitespace(rawTitle));
266
278
  const body = rawBody.replace(/\r\n?/g, "\n").trim();
267
- return withPrimaryWorktreeLock(root, () => {
268
- const located = locateDashboardEntry(root, fileName, sectionTitle, n, expectedTitle);
269
- if (located.status !== "ok")
270
- return located;
271
- const { file, md, entry } = located;
279
+ return withLocatedEntry(root, fileName, sectionTitle, n, expectedTitle, ({ file, md, entry }) => {
272
280
  const wrote = spliceInEditedEntry(file, md.split("\n"), entry, title, body);
273
281
  return { status: wrote ? "edited" : "unchanged", file: fileName, title };
274
282
  });
@@ -9,10 +9,14 @@ import { treeDiffFiles } from "../git/git-diff.js";
9
9
  import { isExemptDiff } from "../review/exemptions.js";
10
10
  import { readJsonFile, writeJsonAtomic } from "../files/json-files.js";
11
11
  import { isJsonObject } from "../files/json-object.js";
12
- import { baselineVerdictsPath, mirrorWorktreePath } from "../paths.js";
12
+ import { baselineVerdictsPath } from "../paths.js";
13
13
  import { memoSetLru } from "../collections.js";
14
- /** Team-shared verdict cache, keyed by main SHA. In-memory only: after a restart the cache is cold
15
- * and one re-check per red SHA happens — cheap and deterministic, mirroring the budget gate's
14
+ /** Team-shared verdict cache, keyed by main SHA. Exported read-only to the one consumer outside
15
+ * this module that decides redeploy's target from it (src/redeploy/redeploy-target.ts's
16
+ * `redeployTarget`); writes stay here — `rememberBaseline`, `noteGreenBaseline` and
17
+ * `noteInheritedGreen` — so the cache's write policy has one home. In-memory only: after a
18
+ * restart the cache is cold and one re-check per red SHA happens — cheap and deterministic,
19
+ * mirroring the budget gate's
16
20
  * stateless resume. Entries come from two sources: checkMainBaseline's own runs, and
17
21
  * noteGreenBaseline seeding a green verdict for a SHA that just became main
18
22
  * (src/merge/merge-to-main.ts: either its in-lock post-rebase re-check passed on exactly that
@@ -33,7 +37,7 @@ import { memoSetLru } from "../collections.js";
33
37
  * not opt-in only: a red carries the worktrees that observed it (`redFrom`) and is re-run by the
34
38
  * next DIFFERENT worktree to consult it, so a single environmental red can no longer block every
35
39
  * role until main moves — see shouldRerunRed. */
36
- const baselineCache = new Map();
40
+ export const baselineCache = new Map();
37
41
  /** Safety cap so the per-process verdict cache can never grow unbounded: it is keyed by every
38
42
  * merged code SHA and a red entry carries the failed run's output tail, so a team running for
39
43
  * weeks would accumulate one entry per merge forever. Evicting the oldest entry (Map's insertion
@@ -150,15 +154,10 @@ function shouldRerunRed(cached, wt, reverifyRed) {
150
154
  const seen = cached.redFrom ?? [];
151
155
  return seen.length < 2 && !seen.includes(wt);
152
156
  }
153
- /** Record a green baseline verdict for `sha` WITHOUT running anything. The callers are the merge
154
- * path (src/merge/merge-to-main.ts's verifyMerge) and the stack lander
155
- * (src/merge/merge-stack.ts' mergeStack) — both call it only after their fast-forward SUCCEEDED,
156
- * with the exact SHA that just became main: verifyMerge with the POST-rebase head in two cases —
157
- * its own in-lock re-check just ran this project's declared check green on that tree, or the
158
- * rebase was a no-op so the review gate's pre-check (which runs outside the merge lock) had
159
- * already run green on exactly this tree — and mergeStack with the stacked tip when a
160
- * scope-`batch` check ran green on exactly it (a skipped check seeds nothing, and neither does a
161
- * merge_blocked stack or a doc-only re-stack that merged without a check of its own). Seeding
157
+ /** Record a green baseline verdict for `sha` WITHOUT running anything. The caller is the merge
158
+ * path (src/merge/merge-to-main.ts's mergeToMain), called only after its fast-forward SUCCEEDED,
159
+ * with the exact SHA that just became main.
160
+ * Seeding
162
161
  * here means that once the merge completes — main now points at this very SHA — the next fresh
163
162
  * run's checkMainBaseline is a cache hit instead of re-running the full suite on an
164
163
  * already-verified tree: for tumwater itself that saves one redundant `npm test` (~1 min) per
@@ -171,11 +170,23 @@ function shouldRerunRed(cached, wt, reverifyRed) {
171
170
  export function noteGreenBaseline(sha) {
172
171
  rememberBaseline(sha, { status: "green", sha });
173
172
  }
173
+ /** Settle `sha`'s red verdict by a re-run of only its failing tests on the same tree
174
+ * (build-check-rerun.ts; the main watch's confirmation, which replaced a second full run): with
175
+ * none failing again the SHA is promoted green, as the full re-run's green promoted it — every test
176
+ * has now passed on this tree — and otherwise the red narrows to the tests that failed again.
177
+ * Returns the settled verdict, or null when `sha` holds no red to settle. */
178
+ export function settleRedByRerun(sha, failing) {
179
+ const red = baselineCache.get(sha);
180
+ if (red?.status !== "red")
181
+ return null;
182
+ const settled = failing.length === 0 ? { status: "green", sha } : { ...red, failingTests: [...failing] };
183
+ rememberBaseline(sha, settled);
184
+ return settled;
185
+ }
174
186
  /** Record an INHERITED green for `sha` (MainBaseline.inheritedFrom): a tree no check ran on, whose
175
- * only difference from the green-verified `from` is in review-exempt paths. The caller is
176
- * merge-to-main.ts's verifyMerge, after it skipped the in-lock check because main moved
177
- * under the gate-verified head only by doc commits. It seeds the head about to become main, as
178
- * verifyMerge's own noteGreenBaseline calls do. If the fast-forward then fails, the entry is
187
+ * only difference from the green-verified `from` is in review-exempt paths. It seeds the head
188
+ * about to become main, as
189
+ * noteGreenBaseline calls do. If the fast-forward then fails, the entry is
179
190
  * still true of that tree and simply never consulted. It never displaces an observed verdict: a
180
191
  * check that actually ran on this tree knows more than the carry-over. */
181
192
  export function noteInheritedGreen(sha, from) {
@@ -193,7 +204,7 @@ const INHERIT_WALK_MAX = 64;
193
204
  * first-parent scans in this module share, spelled the same on purpose so a change to the walk
194
205
  * shape (the bound, the `--first-parent` flag) lands once. `inheritedGreen` and `watchedBaseline`
195
206
  * walk a commit's ancestors (`${sha}^` / `${tip}^`, the commit itself excluded);
196
- * `newestGreenAboveBuild` walks `tip` itself and down. Never throws: a git failure is an empty
207
+ * `newestObservedBaseline` walks `tip` itself and down. Never throws: a git failure is an empty
197
208
  * list, and every caller already treats that as a no-inheritance / no-verdict answer. */
198
209
  async function firstParentWalk(wt, ref, max) {
199
210
  return gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${max}`, ref));
@@ -261,8 +272,21 @@ export async function watchedBaseline(wt, sha) {
261
272
  }
262
273
  return null;
263
274
  }
264
- /** How many first-parent commits below main's tip redeployTarget looks at. */
265
- const REDEPLOY_WALK_MAX = 64;
275
+ /** The newest verdict a check OBSERVED on `tip` or its first-parent ancestors — `tip` itself
276
+ * first, looking at most INHERIT_WALK_MAX commits down — WITHOUT running anything; null when
277
+ * there is none. An inherited green is walked past: no check ran on that tree, and a doc edit can
278
+ * break a test that reads the docs (checkMainBaseline's `observedOnly`). The review gate's
279
+ * attribution reads this when the main watch verifies main (main-red.ts's watchedMainVerdict):
280
+ * with `check.mergeCommand` in effect every observed verdict is a full check the watch or
281
+ * another consumer ran. Never throws: a git failure reads as no verdict. */
282
+ export async function newestObservedBaseline(wt, tip) {
283
+ for (const sha of await firstParentWalk(wt, tip, INHERIT_WALK_MAX)) {
284
+ const verdict = baselineCache.get(sha);
285
+ if (verdict && verdict.inheritedFrom === undefined)
286
+ return verdict;
287
+ }
288
+ return null;
289
+ }
266
290
  /** True when the main watch (src/baseline/main-watch.ts) verifies main — the configs where no
267
291
  * merge seeds a green verdict (`watchedBaseline`'s doc) and the watch has a check to run: a
268
292
  * declared check and a `check.mergeCommand` (or its gate-command fallback) in effect. In this
@@ -273,56 +297,6 @@ const REDEPLOY_WALK_MAX = 64;
273
297
  export function watchVerifiesMain(root, config) {
274
298
  return detectBuildCheck(root, config) !== null && mergeCommandOf(config) !== undefined;
275
299
  }
276
- /** The newest first-parent commit below `tip`, down to and through `buildSha`, with a green
277
- * cached verdict — the walk+scan redeployTarget's watched and un-watched branches share. Only
278
- * their fallback differs: `notOnHistory` when `buildSha` is not on the first-parent walk at all
279
- * (the caller keeps today's answer, `tip`), `onHistoryNone` when it is but nothing above it is
280
- * green (watched keeps the build; un-watched keeps `tip`). Never throws: a git failure leaves
281
- * the walk empty, so the fallback answers. */
282
- async function newestGreenAboveBuild(wt, tip, buildSha, notOnHistory, onHistoryNone) {
283
- const walk = await firstParentWalk(wt, tip, REDEPLOY_WALK_MAX);
284
- const buildAt = walk.indexOf(buildSha);
285
- if (buildAt === -1)
286
- return notOnHistory;
287
- for (const sha of walk.slice(0, buildAt)) {
288
- if (baselineCache.get(sha)?.status === "green")
289
- return sha;
290
- }
291
- return onHistoryNone;
292
- }
293
- /** The commit self-redeploy should run: main's newest first-parent commit with a green full-check
294
- * verdict in the cache (observed or inherited) that is newer than the running build `buildSha`,
295
- * looking at most REDEPLOY_WALK_MAX commits down from `tip`.
296
- *
297
- * With `watched` false (the default, a project whose main the watch does not verify), a tip the
298
- * cache has no verdict for is the target as is — the redeployer checks it. A tip whose cached red
299
- * was observed in the redeployer's mirror worktree (mirrorWorktreePath of `wt`) falls back to the
300
- * newest older green above the build. Any other red may be another worktree's environmental one,
301
- * which the redeployer re-verifies; a build that is not a first-parent ancestor of `tip`, or no
302
- * green, also leaves the tip the target, and the redeployer blocks that commit alone.
303
- *
304
- * With `watched` true (watchVerifiesMain), the watch supplies the verdicts and the redeployer only
305
- * reads them: walk `tip`'s first-parent history down to `buildSha` and return the first commit
306
- * whose cached verdict is green — a cached red is walked past, never targeted. With no green
307
- * above the build, target `buildSha` itself, so buildStaleness reads it as not stale and no
308
- * episode, hold or pre-warm starts until the watch calls a newer commit green. A build that is
309
- * not a first-parent ancestor of `tip` keeps today's answer (`tip`, which the redeployer
310
- * checks), as does `watched` false. Every target `watched` picks has a cached green, so
311
- * mainIsGreen on it is a cache hit and the redeployer starts no suite.
312
- * The cache holds only full-check verdicts, so a cheaper command's green never makes a commit
313
- * deployable.
314
- * Never throws: a git failure leaves the tip as the target. */
315
- export async function redeployTarget(wt, tip, buildSha, watched = false) {
316
- if (watched) {
317
- if (tip === "")
318
- return tip;
319
- return newestGreenAboveBuild(wt, tip, buildSha, tip, buildSha);
320
- }
321
- const red = baselineCache.get(tip);
322
- if (tip === "" || red?.status !== "red" || !(red.redFrom ?? []).includes(mirrorWorktreePath(wt)))
323
- return tip;
324
- return newestGreenAboveBuild(wt, tip, buildSha, tip, tip);
325
- }
326
300
  /** Verify main's own build/test suite at `wt`'s HEAD — which must be pristine main (the caller
327
301
  * is the fresh-run path right after resetWorktreeToMain; a dirty or ahead worktree would
328
302
  * measure the wrong thing). Cache hit returns immediately; on miss runs detectBuildCheck +
@@ -451,6 +425,7 @@ function baselineFromOutcome(sha, wt, outcome) {
451
425
  script: outcome.script,
452
426
  outputTail: outcome.outputTail,
453
427
  failingTests: outcome.failingTests ?? [],
428
+ ...(outcome.failingTestFiles ? { failingTestFiles: outcome.failingTestFiles } : {}),
454
429
  redFrom: priorRedFrom.includes(wt) ? priorRedFrom : [...priorRedFrom, wt],
455
430
  };
456
431
  }
@@ -495,8 +470,8 @@ async function runBaselineOnce(sha, wt, config, onRun, sampleSleep, tier) {
495
470
  return { baseline };
496
471
  }
497
472
  /** Is main green at the mirror worktree's HEAD? The merge path seeds a green verdict for every
498
- * merged code SHA (noteGreenBaseline — its in-lock post-rebase re-check, or the gate's pre-check
499
- * when the rebase was a no-op), so the common case is a cache hit. A head that only added doc
473
+ * merged code SHA (noteGreenBaseline — the stack lander's scope-`batch` check on the merged tip,
474
+ * or a no-op gate pre-check, so the common case is a cache hit. A head that only added doc
500
475
  * commits on top of such a SHA inherits its green (checkMainBaseline's inheritedGreen). The
501
476
  * redeployer's cooldown pre-warm asks this for every new head, and those doc heads were most of the
502
477
  * harness's baseline runs (247 of 319 over 2026-10-02..12). Otherwise the project's declared check