tumwater 0.4.0 → 0.4.2

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 (291) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/agent-backend.js +5 -2
  4. package/dist/src/agent/backend-traits.js +21 -0
  5. package/dist/src/agent/claude/claude-backend.js +1 -1
  6. package/dist/src/agent/claude/claude-stream.js +181 -182
  7. package/dist/src/agent/claude/{write-guard-hook.js → claude-write-guard-hook.js} +1 -1
  8. package/dist/src/agent/claude/claude-write-guard.js +5 -4
  9. package/dist/src/agent/codex/codex-backend.js +1 -1
  10. package/dist/src/agent/codex/codex-rollout.js +48 -45
  11. package/dist/src/agent/codex/codex-stream.js +269 -215
  12. package/dist/src/agent/codex/{write-guard-hook.js → codex-write-guard-hook.js} +2 -1
  13. package/dist/src/agent/codex/codex-write-guard.js +3 -3
  14. package/dist/src/agent/image-input.js +18 -87
  15. package/dist/src/agent/notes-shim.js +2 -2
  16. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  17. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  18. package/dist/src/agent/opencode/opencode-config.js +2 -2
  19. package/dist/src/agent/opencode/opencode-stream.js +101 -92
  20. package/dist/src/agent/run-agent.js +76 -382
  21. package/dist/src/agent/run-settle.js +25 -15
  22. package/dist/src/agent/run-spawn.js +226 -0
  23. package/dist/src/agent/run-stream.js +185 -0
  24. package/dist/src/agent/session-ledger.js +16 -0
  25. package/dist/src/agent/write-guard.js +5 -3
  26. package/dist/src/backlog/backlog-archive.js +86 -170
  27. package/dist/src/backlog/backlog-cli-view.js +89 -70
  28. package/dist/src/backlog/backlog-eligibility.js +51 -59
  29. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  30. package/dist/src/backlog/backlog-index-data.js +159 -0
  31. package/dist/src/backlog/backlog-index.js +7 -5
  32. package/dist/src/backlog/backlog-md.js +39 -28
  33. package/dist/src/backlog/backlog-release.js +21 -36
  34. package/dist/src/backlog/backlog-render.js +1 -1
  35. package/dist/src/backlog/backlog-structure.js +14 -145
  36. package/dist/src/backlog/backlog-write.js +31 -18
  37. package/dist/src/backlog/backlog.js +14 -4
  38. package/dist/src/backlog/question-answer.js +33 -15
  39. package/dist/src/baseline/main-baseline.js +71 -26
  40. package/dist/src/baseline/main-guardrails.js +76 -15
  41. package/dist/src/baseline/main-red.js +45 -94
  42. package/dist/src/baseline/main-watch.js +9 -3
  43. package/dist/src/brief/brief.js +40 -0
  44. package/dist/src/budget/budget-fallback-view.js +50 -8
  45. package/dist/src/budget/budget-wiring.js +15 -11
  46. package/dist/src/budget/budget.js +3 -2
  47. package/dist/src/build/build-check-events.js +4 -2
  48. package/dist/src/build/build-check-report.js +2 -1
  49. package/dist/src/build/build-check-scoped.js +70 -41
  50. package/dist/src/change/change-data.js +4 -2
  51. package/dist/src/cli/cli-args.js +9 -0
  52. package/dist/src/cli/cli-command-args.js +199 -98
  53. package/dist/src/cli/cli-flag-specs.js +6 -4
  54. package/dist/src/cli/cli-marker-commands.js +2 -2
  55. package/dist/src/cli/cli-query-commands.js +41 -6
  56. package/dist/src/cli/cli-review-commands.js +2 -1
  57. package/dist/src/cli/cli-role-command.js +46 -92
  58. package/dist/src/cli/cli-start.js +123 -47
  59. package/dist/src/cli/follow-view.js +3 -1
  60. package/dist/src/cli/help.js +19 -12
  61. package/dist/src/cli/log-commands.js +128 -107
  62. package/dist/src/cli/question-commands.js +82 -63
  63. package/dist/src/cli.js +396 -343
  64. package/dist/src/collections.js +46 -9
  65. package/dist/src/config/config-fallbacks.js +0 -4
  66. package/dist/src/config/config-live.js +179 -147
  67. package/dist/src/config/config-request.js +176 -51
  68. package/dist/src/config/config-validation-roles.js +271 -0
  69. package/dist/src/config/config-validation.js +10 -205
  70. package/dist/src/config/config-views.js +2 -7
  71. package/dist/src/config/config-write.js +205 -175
  72. package/dist/src/config/config.js +2 -1
  73. package/dist/src/doctor/doctor-backlog-bugs.js +24 -122
  74. package/dist/src/doctor/doctor-backlog-plans.js +24 -25
  75. package/dist/src/doctor/doctor-backlog.js +34 -42
  76. package/dist/src/doctor/doctor-check-names.js +4 -2
  77. package/dist/src/doctor/doctor-checks.js +16 -4
  78. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  79. package/dist/src/doctor/doctor-codex-checks.js +9 -20
  80. package/dist/src/doctor/doctor-contention-checks.js +52 -19
  81. package/dist/src/doctor/doctor-holds.js +22 -20
  82. package/dist/src/doctor/doctor-model-checks.js +8 -184
  83. package/dist/src/doctor/doctor-notify.js +2 -5
  84. package/dist/src/doctor/doctor-opencode-checks.js +90 -41
  85. package/dist/src/doctor/doctor-principles.js +26 -0
  86. package/dist/src/doctor/doctor-project-checks.js +69 -19
  87. package/dist/src/doctor/doctor-role-notes.js +33 -0
  88. package/dist/src/doctor/doctor.js +13 -6
  89. package/dist/src/doctor/probe.js +1 -1
  90. package/dist/src/events/event-format.js +307 -162
  91. package/dist/src/events/event-labels.js +0 -1
  92. package/dist/src/events/event-legacy.js +1 -1
  93. package/dist/src/events/event-window.js +1 -1
  94. package/dist/src/events/events.js +15 -2
  95. package/dist/src/events/notify.js +1 -1
  96. package/dist/src/failure/failure-cluster.js +4 -3
  97. package/dist/src/failure/failure-data.js +126 -94
  98. package/dist/src/failure/failure-render.js +58 -17
  99. package/dist/src/failure/failure-state-change.js +308 -27
  100. package/dist/src/failure/time-spend.js +139 -106
  101. package/dist/src/files/files.js +7 -0
  102. package/dist/src/files/json-object.js +15 -2
  103. package/dist/src/gates/budget-gates.js +90 -68
  104. package/dist/src/gates/gate-polls.js +92 -61
  105. package/dist/src/gates/role-cap-gates.js +1 -1
  106. package/dist/src/gates/startup-gate.js +28 -28
  107. package/dist/src/gates/streak-gate.js +1 -1
  108. package/dist/src/git/git.js +1 -1
  109. package/dist/src/git/slot-salvage.js +9 -9
  110. package/dist/src/git/slots-state.js +12 -3
  111. package/dist/src/git/worktree-pool-startup.js +127 -0
  112. package/dist/src/git/worktree-pool.js +1 -120
  113. package/dist/src/gui/gui-admin-endpoints.js +3 -4
  114. package/dist/src/gui/gui-args.js +1 -1
  115. package/dist/src/gui/gui-backlog-commands.js +20 -11
  116. package/dist/src/gui/gui-backlog-endpoints.js +2 -8
  117. package/dist/src/gui/gui-config-commands.js +51 -76
  118. package/dist/src/gui/gui-endpoint-commands.js +4 -2
  119. package/dist/src/gui/gui-endpoint-common.js +4 -3
  120. package/dist/src/gui/gui-history-endpoints.js +36 -27
  121. package/dist/src/gui/gui-operator-commands.js +43 -17
  122. package/dist/src/gui/gui-prompt-commands.js +27 -21
  123. package/dist/src/gui/gui-quarantine-commands.js +30 -0
  124. package/dist/src/gui/gui-report-endpoints.js +13 -9
  125. package/dist/src/gui/gui-server.js +5 -3
  126. package/dist/src/gui/gui-team-commands.js +1 -1
  127. package/dist/src/history/history-data.js +7 -1
  128. package/dist/src/history/history-render.js +15 -10
  129. package/dist/src/history/history.js +103 -68
  130. package/dist/src/inbox/inbox-submit.js +1 -5
  131. package/dist/src/inbox/pending-prompt.js +3 -2
  132. package/dist/src/inbox/prompt-commands.js +194 -161
  133. package/dist/src/init/init.js +104 -25
  134. package/dist/src/merge/merge-core.js +1 -1
  135. package/dist/src/merge/merge-drain.js +1 -1
  136. package/dist/src/merge/merge-git.js +5 -5
  137. package/dist/src/merge/{merge-post-review.js → merge-post-review-drain.js} +1 -1
  138. package/dist/src/merge/merge-queue.js +2 -1
  139. package/dist/src/merge/merge-to-main.js +1 -1
  140. package/dist/src/merge/merge-verify.js +3 -2
  141. package/dist/src/operator/operator-commands.js +84 -52
  142. package/dist/src/operator/operator-intent.js +26 -3
  143. package/dist/src/operator/retire.js +2 -1
  144. package/dist/src/operator/revert.js +18 -12
  145. package/dist/src/orchestrator/orchestrator-poll.js +2 -2
  146. package/dist/src/orchestrator/orchestrator.js +1 -1
  147. package/dist/src/orchestrator/retention.js +6 -5
  148. package/dist/src/paths.js +10 -10
  149. package/dist/src/pi/pi-bin.js +2 -2
  150. package/dist/src/pi/pi-models.js +9 -0
  151. package/dist/src/pi/pi-stream.js +63 -41
  152. package/dist/src/pi/pi-watchdogs.js +84 -49
  153. package/dist/src/pi-extension/context-shake.js +55 -31
  154. package/dist/src/pi-extension/full-output.js +2 -3
  155. package/dist/src/process/process.js +24 -1
  156. package/dist/src/process/run-marker.js +2 -2
  157. package/dist/src/process/supervisor.js +24 -5
  158. package/dist/src/prompt/gate-notes.js +41 -16
  159. package/dist/src/prompt/notes-backlog.js +22 -29
  160. package/dist/src/prompt/principles.js +11 -0
  161. package/dist/src/prompt/prompt-followup.js +4 -12
  162. package/dist/src/prompt/prompt.js +15 -12
  163. package/dist/src/redeploy/redeploy-probes.js +11 -1
  164. package/dist/src/redeploy/redeployer.js +54 -7
  165. package/dist/src/redeploy/self-reload.js +7 -2
  166. package/dist/src/report/report-data.js +224 -320
  167. package/dist/src/report/report-fold.js +23 -231
  168. package/dist/src/report/report-main-red.js +70 -3
  169. package/dist/src/report/report-render.js +181 -48
  170. package/dist/src/report/report-since-data.js +218 -0
  171. package/dist/src/report/report-usage-fold.js +288 -0
  172. package/dist/src/report/report.js +2 -1
  173. package/dist/src/review/known-flakes.js +22 -3
  174. package/dist/src/review/post-merge-review-accept.js +3 -2
  175. package/dist/src/review/post-merge-review.js +16 -14
  176. package/dist/src/review/quarantine-render.js +29 -11
  177. package/dist/src/review/review-precheck.js +2 -1
  178. package/dist/src/roles/role-catalog.js +30 -29
  179. package/dist/src/roles/role-guidance.js +26 -43
  180. package/dist/src/roles/role-render.js +19 -0
  181. package/dist/src/roles/role-view.js +19 -5
  182. package/dist/src/roles/roles.js +2 -22
  183. package/dist/src/run/ci-parity.js +5 -6
  184. package/dist/src/run/feedback-filings.js +10 -1
  185. package/dist/src/run/qa-coverage.js +4 -4
  186. package/dist/src/run/run-apply.js +14 -2
  187. package/dist/src/run/run-detail-data.js +50 -10
  188. package/dist/src/run/run-detail-render.js +32 -1
  189. package/dist/src/run/run-detail.js +44 -22
  190. package/dist/src/run/run-dismiss.js +43 -0
  191. package/dist/src/run/run-finalize.js +75 -46
  192. package/dist/src/run/run-prompt.js +271 -187
  193. package/dist/src/run/run-resume.js +32 -31
  194. package/dist/src/run/run-stage.js +116 -64
  195. package/dist/src/run/run-timing.js +18 -4
  196. package/dist/src/run/run-verdict.js +119 -83
  197. package/dist/src/run/stage-backlog-checks.js +4 -31
  198. package/dist/src/run/stage-check.js +4 -12
  199. package/dist/src/run/stage-checks-shared.js +15 -0
  200. package/dist/src/run/stage-name-checks.js +3 -13
  201. package/dist/src/run/stage-path-checks.js +26 -29
  202. package/dist/src/runner/leftover.js +13 -4
  203. package/dist/src/runner/revision.js +2 -2
  204. package/dist/src/runner/runner-fallback.js +1 -1
  205. package/dist/src/runner/runner-outcome.js +0 -5
  206. package/dist/src/runner/runner-pi.js +72 -49
  207. package/dist/src/runner/runner-run.js +19 -102
  208. package/dist/src/runner/runner-state-load.js +0 -10
  209. package/dist/src/runner/runner.js +1 -32
  210. package/dist/src/scheduling/claims.js +4 -11
  211. package/dist/src/status/status-data.js +140 -91
  212. package/dist/src/status/status-polls.js +41 -5
  213. package/dist/src/status/status-role-notes.js +53 -0
  214. package/dist/src/status/status-roles.js +10 -37
  215. package/dist/src/text/datetime.js +8 -1
  216. package/dist/src/text/format.js +13 -1
  217. package/dist/src/text/phrases.js +16 -2
  218. package/dist/src/text/text.js +12 -0
  219. package/dist/src/ui/alert-feeds.js +115 -0
  220. package/dist/src/ui/badges.js +3 -4
  221. package/dist/src/ui/gui/gui-client-boot.js +78 -57
  222. package/dist/src/ui/gui/gui-client-drawer-config.js +8 -3
  223. package/dist/src/ui/gui/gui-client-drawer.js +18 -2
  224. package/dist/src/ui/gui/gui-client-feed.js +3 -8
  225. package/dist/src/ui/gui/gui-client-history.js +16 -16
  226. package/dist/src/ui/gui/gui-client-model.js +9 -9
  227. package/dist/src/ui/gui/gui-client-operator.js +6 -10
  228. package/dist/src/ui/gui/gui-client-pending.js +4 -4
  229. package/dist/src/ui/gui/gui-client-report-chart.js +111 -10
  230. package/dist/src/ui/gui/gui-client-report.js +70 -11
  231. package/dist/src/ui/gui/gui-client-roles.js +52 -12
  232. package/dist/src/ui/gui/gui-client-settings.js +31 -26
  233. package/dist/src/ui/gui/gui-client-team.js +28 -2
  234. package/dist/src/ui/gui/gui-client.js +33 -4
  235. package/dist/src/ui/gui/gui-icons.js +7 -1
  236. package/dist/src/ui/progress-data.js +2 -2
  237. package/dist/src/ui/run-progress-model.js +2 -2
  238. package/dist/src/ui/status-model.js +26 -16
  239. package/dist/src/ui/status-payload.js +149 -326
  240. package/dist/src/ui/status-render.js +131 -80
  241. package/dist/src/ui/team-alert-backlog.js +236 -0
  242. package/dist/src/ui/team-alert-shared.js +92 -0
  243. package/dist/src/ui/team-alert-status.js +326 -0
  244. package/dist/src/ui/team-alert-tail.js +238 -0
  245. package/dist/src/ui/team-alerts.js +16 -984
  246. package/dist/src/ui/transcript-tail.js +137 -94
  247. package/dist/src/ui/tui/tui-backlog.js +1 -1
  248. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  249. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  250. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  251. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  252. package/dist/src/ui/tui/tui-editor-budget.js +106 -89
  253. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  254. package/dist/src/ui/tui/tui-editor-ctx.js +12 -1
  255. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  256. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  257. package/dist/src/ui/tui/tui-editor-instructions.js +50 -107
  258. package/dist/src/ui/tui/tui-editor-interval.js +39 -112
  259. package/dist/src/ui/tui/tui-editor-notes.js +28 -84
  260. package/dist/src/ui/tui/tui-editor-pause.js +210 -183
  261. package/dist/src/ui/tui/tui-editor-prompt.js +46 -35
  262. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  263. package/dist/src/ui/tui/tui-editor-quiet.js +122 -108
  264. package/dist/src/ui/tui/tui-editor-release.js +85 -0
  265. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  266. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  267. package/dist/src/ui/tui/tui-editor-role-file.js +87 -0
  268. package/dist/src/ui/tui/tui-editor-role-key.js +50 -42
  269. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  270. package/dist/src/ui/tui/tui-editor-session.js +3 -0
  271. package/dist/src/ui/tui/tui-editors.js +4 -1
  272. package/dist/src/ui/tui/tui-frame.js +7 -3
  273. package/dist/src/ui/tui/tui-history.js +12 -10
  274. package/dist/src/ui/tui/tui-keys.js +6 -1
  275. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  276. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  277. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  278. package/dist/src/ui/tui/tui-pane-health.js +45 -26
  279. package/dist/src/ui/tui/tui-pane-history.js +296 -264
  280. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  281. package/dist/src/ui/tui/tui-pane-session.js +6 -2
  282. package/dist/src/ui/tui/tui-pane-transcript.js +158 -131
  283. package/dist/src/ui/tui/tui-pane.js +201 -174
  284. package/dist/src/ui/tui/tui-panes.js +211 -148
  285. package/dist/src/ui/tui/tui.js +4 -4
  286. package/dist/src/verdict/fix-claim.js +18 -6
  287. package/dist/src/verdict/refusal.js +32 -7
  288. package/package.json +1 -1
  289. package/dist/src/doctor/image-probe.js +0 -257
  290. /package/dist/src/gui/{http-body.js → gui-http-body.js} +0 -0
  291. /package/dist/src/merge/{backlog-conflicts.js → merge-backlog-conflicts.js} +0 -0
@@ -14,13 +14,14 @@
14
14
  * CLI command package for domain logic.
15
15
  */
16
16
  import path from "node:path";
17
- import { fencedHeadingTitle, fenceTracker, nextSectionHeading } from "./backlog-md.js";
17
+ import { fencedHeadingTitle, fenceTracker, firstDate, nextSectionHeading } from "./backlog-md.js";
18
18
  import { answeredQuestionEntries, entryMatches, openQuestionEntries, ageInDays, ageSuffix } from "./backlog.js";
19
19
  import { normalizeEntryBody } from "./backlog-write.js";
20
20
  import { collapseWhitespace, firstTrimmedLineStartingWith, trimLeadingBlankLines, trimTrailingBlankLines, truncate } from "../text/text.js";
21
21
  import { readTextOrNull, writeTextAtomic } from "../files/files.js";
22
22
  import { formatDate } from "../text/datetime.js";
23
23
  import { numberedList } from "../text/markdown.js";
24
+ import { topAndHidden } from "../collections.js";
24
25
  import { withPrimaryWorktreeLock } from "../concurrency/lock.js";
25
26
  /** Cap on a question's one-line body preview, so one very long first line cannot dominate
26
27
  * the `questions` list. */
@@ -42,10 +43,11 @@ function firstBodySuffix(body) {
42
43
  /** The open question's filing stamp, `YYYY-MM-DD`, read from the heading's `(asked by <who>
43
44
  * YYYY-MM-DD)` clause — the first `YYYY-MM-DD` after an `asked by` word in the title text — or
44
45
  * null when the heading carries no such clause. Taken from the title text only, not the body:
45
- * the stamp is the entry's own, and the body may name other dates. */
46
+ * the stamp is the entry's own, and the body may name other dates. Routed through
47
+ * backlog-md.ts's firstDate - the one first-match mechanism filingDate (backlog.ts) uses too -
48
+ * with the `asked by` verb in place of the plans/bugs stamp verbs. */
46
49
  export function questionFilingDate(title) {
47
- const m = /(?:asked by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/.exec(title);
48
- return m === null ? null : m[1];
50
+ return firstDate(title, /(?:asked by)\b[^)]*?\b(\d{4}-\d{2}-\d{2})/);
49
51
  }
50
52
  /** The role id named in the heading's `(asked by <role> …)` clause — the first word after
51
53
  * `asked by` — or null when the heading has no such clause. Taken from the title only, like
@@ -133,7 +135,8 @@ function answerPreview(body) {
133
135
  function answeredQuestionsNewestFirst(root, count, pattern) {
134
136
  const numbered = answeredQuestionEntries(root).reverse().map((e, i) => ({ ...e, position: i + 1 }));
135
137
  const all = pattern === undefined ? numbered : numbered.filter((e) => entryMatches(e, pattern));
136
- return { entries: all.slice(0, count), older: Math.max(0, all.length - count) };
138
+ const { visible: entries, hidden: older } = topAndHidden(all, count);
139
+ return { entries, older };
137
140
  }
138
141
  /** `tumwater questions --answered`: lines numbered by `--show <n> --answered` position, newest
139
142
  * first — a numbering separate from the `## Open` positions `questions answer <n>` takes. */
@@ -271,40 +274,56 @@ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
271
274
  if (expectedTitle !== undefined && block.title !== expectedTitle)
272
275
  return { status: "moved", title: block.title };
273
276
  const moved = lines.slice(block.start, block.end);
274
- trimTrailingBlankLines(moved);
277
+ const rebuilt = cutOpenBlock(lines, scanned, block);
278
+ const text = appendAnsweredEntry(rebuilt, moved, decision, scanned.answeredIdx !== -1);
279
+ writeTextAtomic(file, text);
280
+ return { status: "answered", title: block.title, decision };
281
+ }
282
+ /** Cut `block` out of `## Open`, returning the rebuilt file text (as lines). When the section
283
+ * is left with no content at all, restores the `_None yet._` placeholder the skeleton carries,
284
+ * so an empty outbox still reads as an intentionally empty section. */
285
+ function cutOpenBlock(lines, scan, block) {
275
286
  // Cut the block out of ## Open: everything before it, everything from its end on. When the
276
287
  // section is left with no content at all, restore the `_None yet._` placeholder the skeleton
277
288
  // carries, so an empty outbox still reads as an intentionally empty section.
278
289
  // Emptiness is judged against the Open section only (openIdx+1 .. openEnd): with an
279
290
  // intermediate section between Open and Answered, judging against answeredIdx would keep
280
291
  // the placeholder from returning and, worse, the empty rebuild would drop that section.
281
- const openContent = lines.slice(scanned.openIdx + 1, scanned.openEnd);
282
- const remaining = openContent.filter((_, i) => i < block.start - scanned.openIdx - 1 || i >= block.end - scanned.openIdx - 1);
292
+ const openContent = lines.slice(scan.openIdx + 1, scan.openEnd);
293
+ const remaining = openContent.filter((_, i) => i < block.start - scan.openIdx - 1 || i >= block.end - scan.openIdx - 1);
283
294
  const openEmpty = remaining.every((l) => l.trim() === "");
284
295
  const rebuilt = [];
285
296
  if (openEmpty) {
286
- rebuilt.push(...lines.slice(0, scanned.openIdx + 1), "", "_None yet._",
297
+ rebuilt.push(...lines.slice(0, scan.openIdx + 1), "", "_None yet._",
287
298
  // Resume at the section that followed Open (openEnd, not answeredIdx — an intermediate
288
299
  // section between the two must survive) with the blank separator line the dropped Open
289
300
  // content used to carry, so `_None yet._` never reads as a paragraph glued to a heading.
290
- "", ...lines.slice(scanned.openEnd));
301
+ "", ...lines.slice(scan.openEnd));
291
302
  }
292
303
  else {
293
304
  rebuilt.push(...lines.slice(0, block.start), ...lines.slice(block.end));
294
305
  }
306
+ return rebuilt;
307
+ }
308
+ /** Append the moved block at the end of `## Answered` (growing the section when the file has
309
+ * none, stripping a real `_None yet._` placeholder from it), returning the full text to write.
310
+ * `hasAnswered` is whether the file already held a `## Answered` section. */
311
+ function appendAnsweredEntry(rebuilt, moved, decision, hasAnswered) {
312
+ // The moved block's trailing blank lines are dropped, so the appended entry never reads
313
+ // as a spill of blank lines before the dated answer stamp.
314
+ trimTrailingBlankLines(moved);
295
315
  // Append the block at the end of ## Answered: before the next `## ` heading when one
296
316
  // follows, at the file's end otherwise — the section is last in the documented skeleton.
297
317
  const stamp = `**Answered ${formatDate(new Date())} by the user:**`;
298
318
  const stamped = decision.includes("\n") ? [stamp, "", ...decision.split("\n")] : [`${stamp} ${decision}`];
299
319
  const answerLines = moved.concat("", ...stamped, "");
300
- if (scanned.answeredIdx === -1) {
320
+ if (!hasAnswered) {
301
321
  // No ## Answered section: grow the file's end with the skeleton section plus the block.
302
322
  let out = rebuilt.join("\n");
303
323
  if (out !== "" && !out.endsWith("\n"))
304
324
  out += "\n";
305
325
  out += `\n## Answered\n\n${answerLines.join("\n")}`;
306
- writeTextAtomic(file, out);
307
- return { status: "answered", title: block.title, decision };
326
+ return out;
308
327
  }
309
328
  const fenced2 = fenceTracker();
310
329
  let answeredAt = -1;
@@ -340,6 +359,5 @@ function answerQuestionLocked(root, n, rawDecision, expectedTitle, keepLines) {
340
359
  trimTrailingBlankLines(head);
341
360
  trimLeadingBlankLines(tail);
342
361
  const result = head.concat("", answerLines, tail);
343
- writeTextAtomic(file, result.join("\n"));
344
- return { status: "answered", title: block.title, decision };
362
+ return result.join("\n");
345
363
  }
@@ -2,7 +2,7 @@ import { runBuildCheck, } from "../build/build-check.js";
2
2
  import { SLEEP_SPAN_TOLERANCE_MS, runWithCleanRetry } from "../build/build-check-events.js";
3
3
  import { sampleSleepClock } from "../build/host-sleep.js";
4
4
  import { CHECK_TIER, withCheckPermit } from "../concurrency/check-permit.js";
5
- import { detectBuildCheck } from "../build/build-check-detect.js";
5
+ import { detectBuildCheck, mergeCommandOf } from "../build/build-check-detect.js";
6
6
  import { gitLines, refSha } from "../git/git.js";
7
7
  import { gitTry } from "../git/git-run.js";
8
8
  import { treeDiffFiles } from "../git/git-diff.js";
@@ -110,7 +110,7 @@ export function persistBaselineVerdicts(root) {
110
110
  greensFile = file;
111
111
  }
112
112
  /** In-flight dedup: concurrent runs on the same not-yet-cached SHA (a fresh main move wakes
113
- * every blocked role at once) share one check run instead of racing N npm invocations. Keyed by
113
+ * every role's verifier at once) share one check run instead of racing N npm invocations. Keyed by
114
114
  * SHA for ordinary checks; a re-verification adds its worktree, because joining another
115
115
  * worktree's run would observe the wrong environment — the one thing it exists to re-test. */
116
116
  const baselineInFlight = new Map();
@@ -174,6 +174,15 @@ export function noteInheritedGreen(sha, from) {
174
174
  * verdict anyway (every code merge seeds one), so this bound only caps the rev-list on a cold
175
175
  * cache. */
176
176
  const INHERIT_WALK_MAX = 64;
177
+ /** The body of a `git rev-list --first-parent --max-count=<max> <ref>` read — the walk the three
178
+ * first-parent scans in this module share, spelled the same on purpose so a change to the walk
179
+ * shape (the bound, the `--first-parent` flag) lands once. `inheritedGreen` and `watchedBaseline`
180
+ * walk a commit's ancestors (`${sha}^` / `${tip}^`, the commit itself excluded);
181
+ * `newestGreenAboveBuild` walks `tip` itself and down. Never throws: a git failure is an empty
182
+ * list, and every caller already treats that as a no-inheritance / no-verdict answer. */
183
+ async function firstParentWalk(wt, ref, max) {
184
+ return gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${max}`, ref));
185
+ }
177
186
  /** A green for `sha` carried over from its nearest verdict-bearing first-parent ancestor, or null
178
187
  * when there is none to carry. The nearest ancestor with a cached verdict must be GREEN, and the
179
188
  * tree diff from it to `sha` must touch only `exemptPaths` (config.review.exemptPaths, the same
@@ -185,7 +194,7 @@ const INHERIT_WALK_MAX = 64;
185
194
  * past. An uncached ancestor is walked through, since the single diff from the green covers it.
186
195
  * Never throws: any git failure means no inheritance, and the caller runs the suite as before. */
187
196
  async function inheritedGreen(wt, sha, exemptPaths) {
188
- const ancestors = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${INHERIT_WALK_MAX}`, `${sha}^`));
197
+ const ancestors = await firstParentWalk(wt, `${sha}^`, INHERIT_WALK_MAX);
189
198
  for (const ancestor of ancestors) {
190
199
  const verdict = baselineCache.get(ancestor);
191
200
  if (verdict === undefined)
@@ -229,7 +238,7 @@ export async function watchedBaseline(wt, sha) {
229
238
  const own = baselineCache.get(tip);
230
239
  if (own)
231
240
  return own;
232
- const ancestors = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${INHERIT_WALK_MAX}`, `${tip}^`));
241
+ const ancestors = await firstParentWalk(wt, `${tip}^`, INHERIT_WALK_MAX);
233
242
  for (const ancestor of ancestors) {
234
243
  const verdict = baselineCache.get(ancestor);
235
244
  if (verdict)
@@ -239,49 +248,85 @@ export async function watchedBaseline(wt, sha) {
239
248
  }
240
249
  /** How many first-parent commits below main's tip redeployTarget looks at. */
241
250
  const REDEPLOY_WALK_MAX = 64;
242
- /** The commit self-redeploy should run: main's newest first-parent commit with a green full-check
243
- * verdict in the cache (observed or inherited) that is newer than the running build `buildSha`,
244
- * looking at most REDEPLOY_WALK_MAX commits down from `tip`. A tip the cache has no verdict for
245
- * is the target as is — the redeployer checks it. A tip whose cached red was observed in the
246
- * redeployer's mirror worktree (mirrorWorktreePath of `wt`) falls back to the newest older green
247
- * above the build. Any other red may be another worktree's environmental one, which the redeployer
248
- * re-verifies; a build that is not a first-parent ancestor of `tip`, or no green, also leaves the
249
- * tip the target, and the redeployer blocks that commit alone. The cache holds only full-check
250
- * verdicts, so a cheaper command's green never makes a commit deployable.
251
- * Never throws: a git failure leaves the tip as the target. */
252
- export async function redeployTarget(wt, tip, buildSha) {
253
- const red = baselineCache.get(tip);
254
- if (tip === "" || red?.status !== "red" || !(red.redFrom ?? []).includes(mirrorWorktreePath(wt)))
255
- return tip;
256
- const walk = gitLines(await gitTry(wt, "rev-list", "--first-parent", `--max-count=${REDEPLOY_WALK_MAX}`, tip));
251
+ /** True when the main watch (src/baseline/main-watch.ts) verifies main — the configs where no
252
+ * merge seeds a green verdict (`watchedBaseline`'s doc) and the watch has a check to run: a
253
+ * declared check and a `check.mergeCommand` (or its gate-command fallback) in effect. In this
254
+ * mode the redeployer reads the watch's verdicts and starts no full check of its own
255
+ * (redeployTarget's `watched`). False when the project declares no check, or when a blank
256
+ * `check.mergeCommand` keeps the full check in the merge lock. Never throws: detection resolves
257
+ * to null, which is also false. */
258
+ export function watchVerifiesMain(root, config) {
259
+ return detectBuildCheck(root, config) !== null && mergeCommandOf(config) !== undefined;
260
+ }
261
+ /** The newest first-parent commit below `tip`, down to and through `buildSha`, with a green
262
+ * cached verdict — the walk+scan redeployTarget's watched and un-watched branches share. Only
263
+ * their fallback differs: `notOnHistory` when `buildSha` is not on the first-parent walk at all
264
+ * (the caller keeps today's answer, `tip`), `onHistoryNone` when it is but nothing above it is
265
+ * green (watched keeps the build; un-watched keeps `tip`). Never throws: a git failure leaves
266
+ * the walk empty, so the fallback answers. */
267
+ async function newestGreenAboveBuild(wt, tip, buildSha, notOnHistory, onHistoryNone) {
268
+ const walk = await firstParentWalk(wt, tip, REDEPLOY_WALK_MAX);
257
269
  const buildAt = walk.indexOf(buildSha);
258
270
  if (buildAt === -1)
259
- return tip;
271
+ return notOnHistory;
260
272
  for (const sha of walk.slice(0, buildAt)) {
261
273
  if (baselineCache.get(sha)?.status === "green")
262
274
  return sha;
263
275
  }
264
- return tip;
276
+ return onHistoryNone;
277
+ }
278
+ /** The commit self-redeploy should run: main's newest first-parent commit with a green full-check
279
+ * verdict in the cache (observed or inherited) that is newer than the running build `buildSha`,
280
+ * looking at most REDEPLOY_WALK_MAX commits down from `tip`.
281
+ *
282
+ * With `watched` false (the default, a project whose main the watch does not verify), a tip the
283
+ * cache has no verdict for is the target as is — the redeployer checks it. A tip whose cached red
284
+ * was observed in the redeployer's mirror worktree (mirrorWorktreePath of `wt`) falls back to the
285
+ * newest older green above the build. Any other red may be another worktree's environmental one,
286
+ * which the redeployer re-verifies; a build that is not a first-parent ancestor of `tip`, or no
287
+ * green, also leaves the tip the target, and the redeployer blocks that commit alone.
288
+ *
289
+ * With `watched` true (watchVerifiesMain), the watch supplies the verdicts and the redeployer only
290
+ * reads them: walk `tip`'s first-parent history down to `buildSha` and return the first commit
291
+ * whose cached verdict is green — a cached red is walked past, never targeted. With no green
292
+ * above the build, target `buildSha` itself, so buildStaleness reads it as not stale and no
293
+ * episode, hold or pre-warm starts until the watch calls a newer commit green. A build that is
294
+ * not a first-parent ancestor of `tip` keeps today's answer (`tip`, which the redeployer
295
+ * checks), as does `watched` false. Every target `watched` picks has a cached green, so
296
+ * mainIsGreen on it is a cache hit and the redeployer starts no suite.
297
+ * The cache holds only full-check verdicts, so a cheaper command's green never makes a commit
298
+ * deployable.
299
+ * Never throws: a git failure leaves the tip as the target. */
300
+ export async function redeployTarget(wt, tip, buildSha, watched = false) {
301
+ if (watched) {
302
+ if (tip === "")
303
+ return tip;
304
+ return newestGreenAboveBuild(wt, tip, buildSha, tip, buildSha);
305
+ }
306
+ const red = baselineCache.get(tip);
307
+ if (tip === "" || red?.status !== "red" || !(red.redFrom ?? []).includes(mirrorWorktreePath(wt)))
308
+ return tip;
309
+ return newestGreenAboveBuild(wt, tip, buildSha, tip, tip);
265
310
  }
266
311
  /** Verify main's own build/test suite at `wt`'s HEAD — which must be pristine main (the caller
267
312
  * is the fresh-run path right after resetWorktreeToMain; a dirty or ahead worktree would
268
313
  * measure the wrong thing). Cache hit returns immediately; on miss runs detectBuildCheck +
269
314
  * runBuildCheck once per SHA (in-flight deduped) and caches green/red. Never throws: git,
270
- * detection, and execution failures all resolve to "nothing blocks authoring". */
315
+ * detection, and execution failures all resolve to no verdict (nothing to verify). */
271
316
  export async function checkMainBaseline(wt,
272
317
  /** The live config — the declared check is detected through it (plans/portability.md
273
318
  * §6/7), so a configured command verifies main on a non-npm repo too. Required in this
274
319
  * position: detection needs it, and an optional trailing parameter would let future
275
- * callers skip the baseline gate entirely. */
320
+ * callers skip the baseline check entirely. */
276
321
  config,
277
322
  /** Called once per actual script run (never for cache hits or deduped waiters) with what ran
278
323
  * and how long it took — the caller's hook for a build_check event. */
279
324
  onRun,
280
325
  /** Ignore a cached RED verdict and run the suite here instead (a cached green still short-
281
326
  * circuits — see baselineCache). For the caller whose false block is expensive enough to pay
282
- * one extra run: the redeploy gate, where trusting another worktree's environmental red
283
- * strands the whole team on a stale build until main moves. A green from the re-run promotes
284
- * the SHA team-wide, which unblocks the role runners too. */
327
+ * one extra run: the redeploy path, where trusting another worktree's environmental red
328
+ * strands the team on a stale build until main moves. A green from the re-run promotes
329
+ * the SHA team-wide, so every consumer reads main green. */
285
330
  reverifyRed = false,
286
331
  /** Ignore the cache entirely and run the suite here, even over a GREEN verdict. For the one
287
332
  * caller that cannot trust a green (BUGS.md 2026-10-06: the review gate re-attributing a
@@ -4,7 +4,9 @@
4
4
  * selfcheck digest) words them the same. Read-only: it never writes an event, state or file.
5
5
  *
6
6
  * G1 lives on the `main_check` / `main_red_hold` event trail (src/baseline/main-watch.ts,
7
- * src/baseline/main-red.ts): only `main_check` events that actually ran the check
7
+ * src/baseline/main-red.ts), and names the merge the watch blamed (`main_culprit`, logged by
8
+ * src/baseline/main-watch.ts) in the same clause the dashboard alert uses. Only `main_check`
9
+ * events that actually ran the check
8
10
  * (`durationMs` > 0; a cached or inherited verdict is ignored) and `main_red_hold` events
9
11
  * count, and a `main_check` that passed (a red naming only quarantined tests is recorded
10
12
  * passed) ends a red. G3 reads the quarantine list (src/review/known-flakes.ts) directly.
@@ -13,6 +15,8 @@ import { readEventsSince } from "../events/event-window.js";
13
15
  import { loadQuarantine } from "../review/known-flakes.js";
14
16
  import { DAY_MS, clockHm, humanAgo } from "../text/datetime.js";
15
17
  import { shortSha } from "../text/format.js";
18
+ import { roleLabel } from "../roles/role-labels.js";
19
+ import { plural } from "../text/phrases.js";
16
20
  import { squash } from "../text/text.js";
17
21
  /** How long a red main must last before the doctor and the selfcheck digest warn (G1). */
18
22
  export const RED_MAIN_WARN_MS = 30 * 60_000;
@@ -45,22 +49,36 @@ export const QUARANTINE_WARN_COUNT = 10;
45
49
  /** How many of the oldest quarantined tests a warning names before folding the rest into a
46
50
  * remainder count. */
47
51
  const QUARANTINE_DETAIL_MAX = 3;
48
- /** G1 over `events`: warns when main has been red for more than RED_MAIN_WARN_MS, over the
49
- * trailing 24 h of `events` from `now`. Only `main_check` events with `durationMs` > 0 and
50
- * `main_red_hold` events count; a `main_check` with `durationMs` 0 (cached or inherited) is
51
- * ignored; a pass (including one naming only quarantined tests, recorded passed) ends the red.
52
- * The phrase names how long main has had no passing full check, the sha it has been red since,
53
- * its clock time, and the newest red's first failing test when one is named. Returns null when
54
- * main is green, or red for RED_MAIN_WARN_MS or less. */
55
- export function redMainWarning(events, now) {
56
- const cutoff = now - DAY_MS;
52
+ /** True when `e` is the newest `main_culprit` seen so far: no contender yet, or a later `ts`.
53
+ * The newest-in-window pick that `scanRedWindow` folds into its loop. */
54
+ function isNewerCulprit(c, e) {
55
+ return c === undefined || e.ts > c.ts;
56
+ }
57
+ /** Scans the trailing 24 h of `events` (cutoff `now - DAY_MS`), collecting the events that open,
58
+ * extend or end a red and picking the newest in-window `main_culprit` with a non-empty string
59
+ * `commit`. Owns the window scan and the newest-culprit pick. */
60
+ function scanRedWindow(events, cutoff) {
57
61
  const relevant = [];
62
+ let culprit;
58
63
  for (const e of events) {
59
64
  if (typeof e.ts !== "number" || e.ts < cutoff)
60
65
  continue;
66
+ if (e.type === "main_culprit") {
67
+ if (typeof e.commit === "string" && e.commit.length > 0 && isNewerCulprit(culprit, e)) {
68
+ culprit = e;
69
+ }
70
+ continue;
71
+ }
61
72
  if (isRedMain(e) || isPassingMainCheck(e))
62
73
  relevant.push(e);
63
74
  }
75
+ return { relevant, culprit };
76
+ }
77
+ /** Derives the red spell over `window`'s `relevant` events: the start event and duration from
78
+ * `now`, whether the spell is unbroken, and the 30-minute threshold (null when main is green,
79
+ * or red for RED_MAIN_WARN_MS or less). Owns the red-spell derivation and the 30-minute bail. */
80
+ function redSpell(window, now) {
81
+ const relevant = window.relevant;
64
82
  if (relevant.length === 0)
65
83
  return null;
66
84
  let newest = relevant[0];
@@ -85,10 +103,53 @@ export function redMainWarning(events, now) {
85
103
  if (durationMs <= RED_MAIN_WARN_MS)
86
104
  return null;
87
105
  const unbroken = lastPass === undefined && reds.length > 1;
88
- const duration = unbroken || durationMs >= DAY_MS ? "more than 24 h" : redDurationPhrase(durationMs);
89
- const failing = Array.isArray(newest.failingTests) && typeof newest.failingTests[0] === "string" ? newest.failingTests[0] : undefined;
90
- const head = `main has had no passing full check for ${duration}: red since ${shortSha(startEv.sha)} at ${clockHm(startEv.ts)}`;
91
- return failing ? `${head} — ${squash(failing, 120)}` : head;
106
+ return { startEv, newest, durationMs, unbroken };
107
+ }
108
+ /** "more than 24 h" for an unbroken or full-window red, else the short duration phrase. Owns
109
+ * the duration wording. */
110
+ function redSpellDuration(spell) {
111
+ return spell.unbroken || spell.durationMs >= DAY_MS ? "more than 24 h" : redDurationPhrase(spell.durationMs);
112
+ }
113
+ /** The newest red's first failing test, when one is named. Owns the first-failing-test read. */
114
+ function redFailingTest(newest) {
115
+ return Array.isArray(newest.failingTests) && typeof newest.failingTests[0] === "string" ? newest.failingTests[0] : undefined;
116
+ }
117
+ /** The blame clause naming the merge the main watch blamed, when the newest `culprit` in the
118
+ * window blames the same sha the phrase names. Owns the blame clause. */
119
+ function redBlameClause(culprit, newest) {
120
+ // The blame names the merge the main watch blamed for the red — the same `main_culprit`
121
+ // commit-equals-tip rule the dashboard alert uses (status-polls only publishes a blame whose
122
+ // commit is the derived tip sha), so a red the watch did not blame never borrows another
123
+ // head's culprit. A culprit naming another commit, or none, leaves the phrase as today.
124
+ const shouldBlame = culprit !== undefined && culprit.commit === newest.sha;
125
+ return shouldBlame
126
+ ? typeof culprit.runner === "string" && culprit.runner.length > 0
127
+ ? `; the main watch blames ${roleLabel(culprit.runner)}'s merge ${shortSha(culprit.commit)}`
128
+ : `; the main watch blames merge ${shortSha(culprit.commit)}`
129
+ : "";
130
+ }
131
+ /** G1 over `events`: warns when main has been red for more than RED_MAIN_WARN_MS, over the
132
+ * trailing 24 h of `events` from `now`. Only `main_check` events with `durationMs` > 0 and
133
+ * `main_red_hold` events count; a `main_check` with `durationMs` 0 (cached or inherited) is
134
+ * ignored; a pass (including one naming only quarantined tests, recorded passed) ends the red.
135
+ * The phrase names how long main has had no passing full check, the sha it has been red since,
136
+ * its clock time, the newest red's first failing test when one is named, and — when the newest
137
+ * `main_culprit` in the window blames the same sha the phrase names — the merge the main watch
138
+ * blamed (`; the main watch blames <role>'s merge <sha>`, or `; the main watch blames merge
139
+ * <sha>` when no runner is named), the one rule the dashboard alert and this phrase share.
140
+ * Returns null when main is green, or red for RED_MAIN_WARN_MS or less. The per-job work is
141
+ * split into the module-private helpers `scanRedWindow`, `redSpell`, `redSpellDuration`,
142
+ * `redFailingTest` and `redBlameClause`, one per job, so each is found by name. */
143
+ export function redMainWarning(events, now) {
144
+ const window = scanRedWindow(events, now - DAY_MS);
145
+ const spell = redSpell(window, now);
146
+ if (spell === null)
147
+ return null;
148
+ const duration = redSpellDuration(spell);
149
+ const head = `main has had no passing full check for ${duration}: red since ${shortSha(spell.startEv.sha)} at ${clockHm(spell.startEv.ts)}`;
150
+ const failing = redFailingTest(spell.newest);
151
+ const blame = redBlameClause(window.culprit, spell.newest);
152
+ return failing ? `${head} — ${squash(failing, 120)}${blame}` : `${head}${blame}`;
92
153
  }
93
154
  /** G3 over `entries` (the loadQuarantine list): warns when the list holds more than
94
155
  * QUARANTINE_WARN_COUNT tests, or when any test's `firstSeen` is more than QUARANTINE_AGE_MS
@@ -109,7 +170,7 @@ export function quarantineWarning(entries, now) {
109
170
  return `"${squash(e.test, 40)}" (${filed}, ${humanAgo(e.firstSeen, now)})`;
110
171
  });
111
172
  const more = old.length > QUARANTINE_DETAIL_MAX ? ` (and ${old.length - QUARANTINE_DETAIL_MAX} more)` : "";
112
- phrases.push(`quarantine holds ${old.length} test${old.length === 1 ? "" : "s"} over 3 days: ${named.join(", ")}${more}`);
173
+ phrases.push(`quarantine holds ${plural(old.length, "test")} over 3 days: ${named.join(", ")}${more}`);
113
174
  }
114
175
  return phrases.length > 0 ? phrases.join("; ") : null;
115
176
  }