tumwater 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/README.md +1 -1
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +13 -0
  4. package/dist/src/agent/claude/claude-backend.js +2 -2
  5. package/dist/src/agent/claude/claude-stream.js +3 -2
  6. package/dist/src/agent/codex/codex-backend.js +3 -3
  7. package/dist/src/agent/codex/codex-stream.js +3 -1
  8. package/dist/src/agent/opencode/opencode-backend.js +2 -2
  9. package/dist/src/backlog/backlog-archive.js +60 -25
  10. package/dist/src/backlog/backlog-cli-view.js +3 -28
  11. package/dist/src/backlog/backlog-cli.js +9 -64
  12. package/dist/src/backlog/backlog-commit.js +17 -9
  13. package/dist/src/backlog/backlog-eligibility.js +44 -26
  14. package/dist/src/backlog/backlog-parts.js +23 -13
  15. package/dist/src/backlog/backlog-release.js +33 -16
  16. package/dist/src/baseline/main-guardrails.js +0 -73
  17. package/dist/src/baseline/main-red.js +31 -21
  18. package/dist/src/baseline/main-watch.js +82 -34
  19. package/dist/src/brief/brief-edit.js +3 -1
  20. package/dist/src/budget/budget-fallback-view.js +288 -0
  21. package/dist/src/budget/budget-wiring.js +158 -0
  22. package/dist/src/budget/budget.js +2 -1
  23. package/dist/src/build/dep-install.js +64 -1
  24. package/dist/src/cli/cli-args.js +63 -0
  25. package/dist/src/cli/cli-flag-specs.js +33 -0
  26. package/dist/src/cli/cli-query-commands.js +16 -41
  27. package/dist/src/cli/cli-review-commands.js +113 -0
  28. package/dist/src/cli/cli-role-command.js +118 -13
  29. package/dist/src/cli/cli-start-summary.js +10 -3
  30. package/dist/src/cli/cli-start.js +33 -5
  31. package/dist/src/cli/completion.js +21 -1
  32. package/dist/src/cli/config-commands.js +17 -8
  33. package/dist/src/cli/help.js +74 -14
  34. package/dist/src/cli/log-commands.js +26 -13
  35. package/dist/src/cli/question-commands.js +7 -1
  36. package/dist/src/cli.js +71 -6
  37. package/dist/src/collections.js +31 -4
  38. package/dist/src/config/config-editable-keys.js +3 -0
  39. package/dist/src/config/config-fallbacks.js +3 -2
  40. package/dist/src/config/config-validation.js +47 -42
  41. package/dist/src/config/config-views.js +10 -2
  42. package/dist/src/config/config-write.js +33 -1
  43. package/dist/src/config/config.js +7 -2
  44. package/dist/src/doctor/doctor-backlog-bugs.js +85 -42
  45. package/dist/src/doctor/doctor-backlog-plans.js +214 -79
  46. package/dist/src/doctor/doctor-backlog.js +155 -22
  47. package/dist/src/doctor/doctor-check-names.js +5 -1
  48. package/dist/src/doctor/doctor-checks.js +10 -15
  49. package/dist/src/doctor/doctor-claude-checks.js +6 -9
  50. package/dist/src/doctor/doctor-codex-checks.js +6 -9
  51. package/dist/src/doctor/doctor-contention-checks.js +244 -118
  52. package/dist/src/doctor/doctor-fallback-checks.js +130 -0
  53. package/dist/src/doctor/doctor-holds.js +4 -6
  54. package/dist/src/doctor/doctor-model-checks.js +45 -200
  55. package/dist/src/doctor/doctor-notify.js +7 -2
  56. package/dist/src/doctor/doctor-opencode-checks.js +7 -5
  57. package/dist/src/doctor/doctor-pi-checks.js +62 -0
  58. package/dist/src/doctor/doctor-project-checks.js +106 -12
  59. package/dist/src/doctor/doctor.js +10 -5
  60. package/dist/src/doctor/probe.js +14 -1
  61. package/dist/src/events/event-format.js +7 -6
  62. package/dist/src/events/event-labels.js +0 -1
  63. package/dist/src/events/event-read.js +13 -0
  64. package/dist/src/events/event-window.js +20 -2
  65. package/dist/src/failure/failure-data.js +9 -4
  66. package/dist/src/failure/failure-render.js +8 -8
  67. package/dist/src/failure/time-spend.js +0 -64
  68. package/dist/src/files/file-queue.js +17 -0
  69. package/dist/src/gates/budget-gates.js +27 -257
  70. package/dist/src/gates/gate-polls.js +49 -141
  71. package/dist/src/gates/maintenance-quota.js +27 -17
  72. package/dist/src/git/commit-message.js +12 -0
  73. package/dist/src/git/git.js +18 -0
  74. package/dist/src/git/slot-salvage.js +3 -3
  75. package/dist/src/gui/gui-backlog-commands.js +3 -3
  76. package/dist/src/gui/gui-backlog-endpoints.js +5 -4
  77. package/dist/src/gui/gui-command.js +19 -1
  78. package/dist/src/gui/gui-config-commands.js +75 -9
  79. package/dist/src/gui/gui-endpoint-commands.js +2 -2
  80. package/dist/src/gui/gui-operator-commands.js +134 -7
  81. package/dist/src/gui/gui-prompt-commands.js +15 -6
  82. package/dist/src/gui/gui-report-endpoints.js +10 -3
  83. package/dist/src/gui/gui-server.js +16 -2
  84. package/dist/src/history/history-queued.js +8 -0
  85. package/dist/src/history/history.js +2 -5
  86. package/dist/src/inbox/inbox-top.js +4 -9
  87. package/dist/src/merge/merge-core.js +2 -25
  88. package/dist/src/merge/merge-drain.js +22 -135
  89. package/dist/src/merge/merge-pipeline.js +1 -1
  90. package/dist/src/merge/merge-post-review.js +154 -30
  91. package/dist/src/merge/merge-queue-render.js +11 -12
  92. package/dist/src/merge/merge-queue.js +22 -1
  93. package/dist/src/merge/merge-requeue.js +1 -0
  94. package/dist/src/merge/merge-stack.js +1 -7
  95. package/dist/src/merge/merge-to-main.js +1 -9
  96. package/dist/src/merge/merge-top.js +33 -19
  97. package/dist/src/merge/merge-verify.js +64 -9
  98. package/dist/src/notes-cli.js +8 -3
  99. package/dist/src/orchestrator/orchestrator-poll.js +47 -15
  100. package/dist/src/orchestrator/orchestrator-scheduling.js +4 -2
  101. package/dist/src/orchestrator/orchestrator.js +5 -4
  102. package/dist/src/orchestrator/scheduling-scope.js +51 -7
  103. package/dist/src/orchestrator/scheduling-skip.js +15 -5
  104. package/dist/src/paths.js +16 -6
  105. package/dist/src/pi/pi-backend.js +2 -2
  106. package/dist/src/process/run-marker.js +8 -0
  107. package/dist/src/prompt/gate-notes.js +1 -1
  108. package/dist/src/prompt/gate-prompts.js +30 -7
  109. package/dist/src/prompt/notes-backlog.js +13 -10
  110. package/dist/src/prompt/prompt.js +3 -1
  111. package/dist/src/report/report-data.js +49 -69
  112. package/dist/src/report/report-fold.js +11 -12
  113. package/dist/src/report/report-render.js +6 -4
  114. package/dist/src/report/report.js +2 -5
  115. package/dist/src/review/diff-rules.js +53 -0
  116. package/dist/src/review/known-flakes.js +12 -0
  117. package/dist/src/review/post-merge-review-accept.js +51 -0
  118. package/dist/src/review/post-merge-review-render.js +112 -0
  119. package/dist/src/review/post-merge-review.js +157 -24
  120. package/dist/src/review/protected-edits.js +22 -7
  121. package/dist/src/review/quarantine-render.js +65 -0
  122. package/dist/src/review/review.js +33 -49
  123. package/dist/src/roles/role-catalog.js +60 -2
  124. package/dist/src/roles/role-guidance.js +10 -0
  125. package/dist/src/roles/role-labels.js +6 -3
  126. package/dist/src/roles/role-render.js +43 -8
  127. package/dist/src/roles/role-view.js +36 -9
  128. package/dist/src/roles/roles.js +3 -3
  129. package/dist/src/run/ci-parity.js +121 -0
  130. package/dist/src/run/qa-coverage.js +50 -15
  131. package/dist/src/run/run-detail-data.js +9 -13
  132. package/dist/src/run/run-detail-render.js +1 -8
  133. package/dist/src/run/run-finalize.js +1 -0
  134. package/dist/src/run/run-prompt.js +14 -0
  135. package/dist/src/run/run-stage.js +66 -37
  136. package/dist/src/run/run-verdict.js +14 -1
  137. package/dist/src/run/stage-check.js +17 -11
  138. package/dist/src/run/stage-checks-shared.js +65 -0
  139. package/dist/src/run/stage-name-checks.js +204 -0
  140. package/dist/src/run/stage-path-checks.js +16 -515
  141. package/dist/src/run/stage-prose-checks.js +261 -0
  142. package/dist/src/runner/leftover.js +50 -19
  143. package/dist/src/runner/revision.js +12 -23
  144. package/dist/src/runner/runner-outcome.js +4 -2
  145. package/dist/src/runner/runner-recovery.js +18 -3
  146. package/dist/src/runner/runner-run.js +21 -8
  147. package/dist/src/runner/runner-state.js +4 -3
  148. package/dist/src/scheduling/claim-entries.js +49 -0
  149. package/dist/src/status/status-data.js +6 -1
  150. package/dist/src/status/status-polls.js +15 -0
  151. package/dist/src/status/status-roles.js +11 -0
  152. package/dist/src/team/reclaim.js +2 -2
  153. package/dist/src/text/datetime.js +3 -2
  154. package/dist/src/text/phrases.js +6 -3
  155. package/dist/src/text/text.js +11 -0
  156. package/dist/src/ui/badges.js +34 -1
  157. package/dist/src/ui/gui/gui-client-backlog.js +25 -3
  158. package/dist/src/ui/gui/gui-client-boot.js +63 -41
  159. package/dist/src/ui/gui/gui-client-drawer-config.js +56 -14
  160. package/dist/src/ui/gui/gui-client-drawer.js +29 -4
  161. package/dist/src/ui/gui/gui-client-feed.js +1 -1
  162. package/dist/src/ui/gui/gui-client-history.js +74 -72
  163. package/dist/src/ui/gui/gui-client-labels.js +5 -1
  164. package/dist/src/ui/gui/gui-client-operator.js +6 -2
  165. package/dist/src/ui/gui/gui-client-pending.js +188 -11
  166. package/dist/src/ui/gui/gui-client-report-chart.js +36 -8
  167. package/dist/src/ui/gui/gui-client-report.js +20 -5
  168. package/dist/src/ui/gui/gui-client-settings.js +109 -3
  169. package/dist/src/ui/gui/gui-client-team.js +11 -0
  170. package/dist/src/ui/gui/gui-client.js +11 -1
  171. package/dist/src/ui/gui/gui-page.js +1 -0
  172. package/dist/src/ui/gui/gui-styles.js +4 -0
  173. package/dist/src/ui/run-progress-model.js +2 -1
  174. package/dist/src/ui/status-payload.js +237 -9
  175. package/dist/src/ui/status-render.js +12 -4
  176. package/dist/src/ui/team-alerts.js +512 -37
  177. package/dist/src/ui/tone.js +7 -0
  178. package/dist/src/ui/tui/tui-backlog.js +7 -5
  179. package/dist/src/ui/tui/tui-editor-attach.js +1 -7
  180. package/dist/src/ui/tui/tui-editor-backends.js +1 -6
  181. package/dist/src/ui/tui/tui-editor-brief.js +4 -24
  182. package/dist/src/ui/tui/tui-editor-budget.js +1 -8
  183. package/dist/src/ui/tui/tui-editor-check.js +29 -0
  184. package/dist/src/ui/tui/tui-editor-checks.js +28 -0
  185. package/dist/src/ui/tui/tui-editor-concurrency.js +1 -0
  186. package/dist/src/ui/tui/tui-editor-config.js +19 -6
  187. package/dist/src/ui/tui/tui-editor-entry.js +3 -17
  188. package/dist/src/ui/tui/tui-editor-instances.js +9 -0
  189. package/dist/src/ui/tui/tui-editor-instructions.js +7 -26
  190. package/dist/src/ui/tui/tui-editor-interval.js +18 -7
  191. package/dist/src/ui/tui/tui-editor-notes.js +6 -24
  192. package/dist/src/ui/tui/tui-editor-quiet.js +2 -12
  193. package/dist/src/ui/tui/tui-editor-retire.js +1 -7
  194. package/dist/src/ui/tui/tui-editor-role-enabled.js +126 -0
  195. package/dist/src/ui/tui/tui-editor-role-key.js +1 -6
  196. package/dist/src/ui/tui/tui-editor-session.js +41 -0
  197. package/dist/src/ui/tui/tui-editor-slots.js +1 -0
  198. package/dist/src/ui/tui/tui-editors.js +10 -1
  199. package/dist/src/ui/tui/tui-frame.js +18 -6
  200. package/dist/src/ui/tui/tui-keys.js +9 -0
  201. package/dist/src/ui/tui/tui-pane-activity.js +21 -8
  202. package/dist/src/ui/tui/tui-pane-backlog.js +36 -9
  203. package/dist/src/ui/tui/tui-pane-ctx.js +1 -1
  204. package/dist/src/ui/tui/tui-pane-health.js +113 -3
  205. package/dist/src/ui/tui/tui-pane-history.js +44 -0
  206. package/dist/src/ui/tui/tui-pane-pending.js +285 -56
  207. package/dist/src/ui/tui/tui-pane-queued.js +7 -2
  208. package/dist/src/ui/tui/tui-pane-report.js +9 -3
  209. package/dist/src/ui/tui/tui-pane-session.js +9 -0
  210. package/dist/src/ui/tui/tui-pane.js +16 -10
  211. package/dist/src/ui/tui/tui-panes.js +56 -7
  212. package/dist/src/ui/tui/tui.js +8 -5
  213. package/dist/src/verdict/fix-claim.js +47 -1
  214. package/dist/src/verdict/reply-contract.js +35 -0
  215. package/package.json +1 -1
  216. package/dist/src/baseline/main-culprit.js +0 -94
  217. package/dist/src/baseline/main-revert.js +0 -133
@@ -43,10 +43,16 @@ export function createTuiPanes(deps) {
43
43
  session.notesClearArmed = null;
44
44
  session.armedKillOrphans = session.killOrphansArmed;
45
45
  session.killOrphansArmed = false;
46
+ session.armedDropBrokenPins = session.dropBrokenPinsArmed;
47
+ session.dropBrokenPinsArmed = false;
48
+ session.armedRetireLeftovers = session.retireLeftoversArmed;
49
+ session.retireLeftoversArmed = false;
46
50
  session.armedRevert = session.revertArmed;
47
51
  session.revertArmed = null;
48
52
  session.armedQueue = session.queueArmed;
49
53
  session.queueArmed = null;
54
+ session.armedReview = session.reviewArmed;
55
+ session.reviewArmed = null;
50
56
  };
51
57
  /** The per-key pane snapshot the editors' open arms read. */
52
58
  const editorCtx = () => {
@@ -159,6 +165,16 @@ export function createTuiPanes(deps) {
159
165
  return health.handleKey?.(key, ctx) ?? false;
160
166
  if (key.ctrl && key.name === "w")
161
167
  return history.handleKey?.(key, ctx) ?? false;
168
+ // Health owns Ctrl+S (drop broken pins) ahead of the pane router's fallthrough; off the
169
+ // Health view it falls through so the quiet-hours (Activity), trailing-window (History)
170
+ // and min-run-interval (Transcript) arms keep their current meanings.
171
+ if (key.ctrl && key.name === "s" && session.view === session.roleIds.length + 7) {
172
+ // On the Health view the drop-pins arm consumes Ctrl+S only on an empty prompt line; a
173
+ // non-empty prompt or a held editor falls through so History's trailing-window cycle still
174
+ // takes the key, exactly as it did before the drop-pins arm existed.
175
+ if (health.handleKey?.(key, ctx))
176
+ return true;
177
+ }
162
178
  if (key.ctrl && key.name === "s")
163
179
  return history.handleKey?.(key, ctx) ?? false;
164
180
  // Ctrl+P on Usage/Failures steps the report window back; every other view is History's.
@@ -170,6 +186,29 @@ export function createTuiPanes(deps) {
170
186
  return health.handleKey?.(key, ctx) ?? false;
171
187
  if (key.ctrl && key.name === "p")
172
188
  return history.handleKey?.(key, ctx) ?? false;
189
+ // Health owns Ctrl+A (commit-backlog) ahead of the pane router's fallthrough; the Backlog
190
+ // view's Ctrl+A (the answer editor) is handled by tui-editor-answer.ts, which the key
191
+ // dispatcher consults before this router, so this case never shadows it.
192
+ if (key.ctrl && key.name === "a" && session.view === session.roleIds.length + 7)
193
+ return health.handleKey?.(key, ctx) ?? false;
194
+ // Pending owns Ctrl+A on its own view (the two-step post-merge-review accept), routed after
195
+ // Health's so the Health commit-backlog arm keeps its view's slot.
196
+ if (key.ctrl && key.name === "a" && session.view === session.roleIds.length + 5)
197
+ return pending.handleKey?.(key, ctx) ?? false;
198
+ // Health owns Ctrl+U (re-sync install) ahead of the pane router's fallthrough; off the
199
+ // Health view it falls through so the prompt-line editor keeps Ctrl+U (kill-to-start).
200
+ if (key.ctrl && key.name === "u" && session.view === session.roleIds.length + 7)
201
+ return health.handleKey?.(key, ctx) ?? false;
202
+ // Health owns Ctrl+L (retire leftovers) ahead of the pane router's fallthrough; off the
203
+ // Health view it falls through so the Activity fallback shift (view 0) and the
204
+ // instructions editor on a role's Transcript keep their current meanings.
205
+ if (key.ctrl && key.name === "l" && session.view === session.roleIds.length + 7)
206
+ return health.handleKey?.(key, ctx) ?? false;
207
+ // History owns Ctrl+L (newest failure) after Health's; off the History view it falls
208
+ // through so the Activity fallback editor (view 0) and a Transcript's instructions editor
209
+ // (handled in the editors ahead of this router) keep their current meanings.
210
+ if (key.ctrl && key.name === "l")
211
+ return history.handleKey?.(key, ctx) ?? false;
173
212
  if (key.ctrl && key.name === "y") {
174
213
  // Ctrl+Y's transcript change-step arm (the diffstat toggle) is routed first and falls
175
214
  // through off it, so History keeps its Summary cycle on every other view.
@@ -179,12 +218,18 @@ export function createTuiPanes(deps) {
179
218
  }
180
219
  if (key.ctrl && key.name === "o") {
181
220
  // The Activity type cycle is routed first on its own view (0); on the Health view the
182
- // problems-only toggle precedes the History outcome cycle, and every other view keeps
183
- // the History outcome cycle.
221
+ // problems-only toggle precedes the History outcome cycle; on the Pending view the
222
+ // post-merge review state filter is routed ahead of History's outcome cycle (its own arm
223
+ // consumes the key on Pending); on the Backlog view the Ready-only filter is routed
224
+ // ahead of History's outcome cycle, and every other view keeps the History outcome cycle.
184
225
  if (session.view === 0)
185
226
  return activity.handleKey?.(key, ctx) ?? false;
186
227
  if (session.view === session.roleIds.length + 7)
187
228
  return health.handleKey?.(key, ctx) ?? false;
229
+ if (session.view === session.roleIds.length + 5)
230
+ return pending.handleKey?.(key, ctx) ?? false;
231
+ if (session.view === session.roleIds.length + 1)
232
+ return backlog.handleKey?.(key, ctx) ?? false;
188
233
  return history.handleKey?.(key, ctx) ?? false;
189
234
  }
190
235
  if (key.ctrl && key.name === "x") {
@@ -214,15 +259,17 @@ export function createTuiPanes(deps) {
214
259
  return backlog.handleKey?.(key, ctx) ?? false;
215
260
  }
216
261
  if (key.ctrl && key.name === "v") {
217
- // The Ctrl+V arms, routed like Ctrl+X's: the History trail's revert, then the queued
218
- // prompt's and the backlog entry's move-to-top — each returns false when its
219
- // view/selection does not apply. The
220
- // Activity attach editor's own Ctrl+V arm is guarded to view 0, so it still fires
221
- // there ahead of this router.
262
+ // The Ctrl+V arms, routed like Ctrl+X's: the History trail's revert, the queued
263
+ // prompt's move-to-top, then the Pending pane's merge move-to-top, and the backlog
264
+ // entry's move-to-top last — each returns false when its view/selection does not
265
+ // apply. The Activity attach editor's own Ctrl+V arm is guarded to view 0, so it
266
+ // still fires there ahead of this router.
222
267
  if (history.handleKey?.(key, ctx))
223
268
  return true;
224
269
  if (queued.handleKey?.(key, ctx))
225
270
  return true;
271
+ if (pending.handleKey?.(key, ctx))
272
+ return true;
226
273
  return backlog.handleKey?.(key, ctx) ?? false;
227
274
  }
228
275
  const arrow = arrowDir(key.name);
@@ -310,6 +357,7 @@ export function createTuiPanes(deps) {
310
357
  activityRole: act.activityRole,
311
358
  activityGrep: act.activityGrep,
312
359
  activityType: act.activityType,
360
+ activityNotable: act.activityNotable,
313
361
  noteMode: tra.noteMode,
314
362
  noteCache: tra.noteCache,
315
363
  changeMode: tra.changeMode,
@@ -319,6 +367,7 @@ export function createTuiPanes(deps) {
319
367
  selectedEntry: back.selectedEntry,
320
368
  entryScroll: back.entryScroll,
321
369
  backlogGrep: back.backlogGrep,
370
+ backlogReadyOnly: back.backlogReadyOnly,
322
371
  scopeRole: rep.scopeRole,
323
372
  reportSince: rep.reportSince,
324
373
  reportStep: rep.reportStep,
@@ -2,10 +2,13 @@ import { jsx as _jsx } from "react/jsx-runtime";
2
2
  import { render as inkRender } from "ink";
3
3
  import { TuiApp } from "./tui-app.js";
4
4
  import { openQuestions } from "../../backlog/backlog.js";
5
- import { operatorHeldEntries, refusedEntries, stalledPlanEntries } from "../../backlog/backlog-eligibility.js";
6
- import { planDependencyAlert, skippedMergeFiles, staleMerges, staleOpenBugs, stalePrompts, staleWaitingBugs } from "../status-payload.js";
5
+ import { operatorHeldEntries, refusedEntries, stalledEntries } from "../../backlog/backlog-eligibility.js";
6
+ import { capacityContention, droppedPostReviewsAlert, flappedChecksAlert, mainRuleFailures, notifyFailures, planDependencyAlert, postMergeChurnAlert, postReviewRiskAlert, primaryOverwriteBlocks, qaCoverageAlert, quarantineAlert, regressedFixesAlert, skippedMergeFiles, staleMerges, staleOpenBugs, staleOpenQuestions, stalePrompts, staleWaitingBugs, staleWorkspacePins } from "../status-payload.js";
7
+ import { strandedPlanEntriesFor } from "../../backlog/backlog-structure.js";
8
+ import { duplicatedBacklogHeadings, fixClaimProblems, liveFinishedEntriesFor, validationGapProblems } from "../../doctor/doctor-backlog-plans.js";
7
9
  import { snapshot } from "../../status/status-data.js";
8
10
  import { renderStatusSpans } from "../status-render.js";
11
+ import { staleClaimEntries } from "../../scheduling/claim-entries.js";
9
12
  import { teamAlerts } from "../team-alerts.js";
10
13
  import { captureStartupBuild, createReloadWatch, reexecSelf, watchReloadSupervisor, } from "../../redeploy/self-reload.js";
11
14
  import { renderInputView, tuiTerminalError, } from "./tui-input.js";
@@ -90,7 +93,7 @@ export async function runTui(root, seams = {}) {
90
93
  const view = s.view;
91
94
  // What needs the operator — the dashboard's alert banners (team-alerts.ts's teamAlerts), as
92
95
  // attention lines under the header. Each consumes one line of the height budget.
93
- const attention = alertLines(teamAlerts(snap, openQuestions(root), status.roles, Date.now(), refusedEntries(root), operatorHeldEntries(root), staleOpenBugs(root), stalePrompts(root), staleMerges(root), planDependencyAlert(root), staleWaitingBugs(root), skippedMergeFiles(root), stalledPlanEntries(root)), width);
96
+ const attention = alertLines(teamAlerts(snap, openQuestions(root), status.roles, Date.now(), refusedEntries(root), operatorHeldEntries(root), staleOpenBugs(root), stalePrompts(root), staleMerges(root), planDependencyAlert(root), staleWaitingBugs(root), skippedMergeFiles(root), stalledEntries(root), quarantineAlert(root), strandedPlanEntriesFor(root), duplicatedBacklogHeadings(root), validationGapProblems(root), notifyFailures(root), primaryOverwriteBlocks(root), capacityContention(root), flappedChecksAlert(root), regressedFixesAlert(root, Date.now()), postMergeChurnAlert(root), postReviewRiskAlert(root), droppedPostReviewsAlert(root), mainRuleFailures(root, Date.now()), staleClaimEntries(root, status.roles.map((r) => r.role), Date.now()), staleOpenQuestions(root), fixClaimProblems(root), qaCoverageAlert(root, Date.now()), staleWorkspacePins(root, Date.now()), liveFinishedEntriesFor(root)), width);
94
97
  const statusLines = [status.lines[0] ?? [], ...attention, ...status.lines.slice(1)];
95
98
  // Numbered previews of prompts queued for the director, between the table and the
96
99
  // activity pane: what will run next, in execution order. Each line consumes exactly
@@ -112,7 +115,7 @@ export async function runTui(root, seams = {}) {
112
115
  : view === roleIds.length + 6 ? { kind: "queued", grep: s.queuedGrep }
113
116
  : view === roleIds.length + 7 ? { kind: "health", problems: s.healthProblems }
114
117
  : view === roleIds.length + 8 ? { kind: "brief" }
115
- : { kind: "activity", role: s.activityRole, grep: s.activityGrep, type: s.activityType };
118
+ : { kind: "activity", role: s.activityRole, grep: s.activityGrep, type: s.activityType, notable: s.activityNotable };
116
119
  // The pane's body lines (tui-pane.ts, extracted from this closure): transcripts, the
117
120
  // backlog views, usage/failures, or activity, composed at this frame's budgets.
118
121
  const paneResult = paneBody(pane, { root, width, eventBudget, entryBudget, state: s });
@@ -121,7 +124,7 @@ export async function runTui(root, seams = {}) {
121
124
  // The frame as the ink tree draws it (tui-app.tsx): the same composed lines the
122
125
  // hand-painted string frame joined, as clipped StatusLines — one column row per line,
123
126
  // one styled Text per span, colors through tui-app.tsx's toneColor.
124
- const mode = { budget: s.budgetMode, concurrencyMode: s.concurrencyMode, fallbackMode: s.fallbackMode, modelMode: s.modelMode, rolePromptFor: s.rolePromptFor, attachMode: s.attachMode, pauseEditMode: s.pauseEditMode, pauseEditRole: s.pauseEditRole, pauseEditWake: s.pauseEditWake, pauseEditStop: s.pauseEditStop, queuedEditFor: s.queuedEditFor, answerFor: s.answerFor, entryEditFor: s.entryEditFor, notesEditFor: s.notesEditFor, briefEditMode: s.briefEditMode, retireMode: s.retireMode, quietMode: s.quietMode, quietTeam: s.quietTeam, providerCapsMode: s.providerCapsMode, claudeReserveMode: s.claudeReserveMode, intervalMode: s.intervalMode, roleModelMode: s.roleModelMode, instructionsEditFor: s.instructionsEditFor, slotsMode: s.slotsMode, images: s.images, notifyMode: s.notifyMode, backendsMode: s.backendsMode, instancesMode: s.instancesMode };
127
+ const mode = { budget: s.budgetMode, concurrencyMode: s.concurrencyMode, checksMode: s.checksMode, checkMode: s.checkMode, fallbackMode: s.fallbackMode, modelMode: s.modelMode, rolePromptFor: s.rolePromptFor, attachMode: s.attachMode, pauseEditMode: s.pauseEditMode, pauseEditRole: s.pauseEditRole, pauseEditWake: s.pauseEditWake, pauseEditStop: s.pauseEditStop, queuedEditFor: s.queuedEditFor, answerFor: s.answerFor, entryEditFor: s.entryEditFor, notesEditFor: s.notesEditFor, briefEditMode: s.briefEditMode, retireMode: s.retireMode, roleEnabledMode: s.roleEnabledMode, quietMode: s.quietMode, quietTeam: s.quietTeam, providerCapsMode: s.providerCapsMode, claudeReserveMode: s.claudeReserveMode, intervalMode: s.intervalMode, roleModelMode: s.roleModelMode, instructionsEditFor: s.instructionsEditFor, slotsMode: s.slotsMode, images: s.images, notifyMode: s.notifyMode, backendsMode: s.backendsMode, instancesMode: s.instancesMode };
125
128
  const lines = [...statusLines, []]; // a blank row below the header
126
129
  for (const [i, preview] of queued.entries())
127
130
  lines.push(toneLine(`${i + 1}. ${preview}`, width));
@@ -45,7 +45,7 @@
45
45
  * bare file name is backed by a file of that name anywhere in the walked source dirs. */
46
46
  import { existsSync, readFileSync, statSync } from "node:fs";
47
47
  import { extname, join } from "node:path";
48
- import { readTextOrNull, walkFiles } from "../files/files.js";
48
+ import { readTextOrNull, statOrNull, walkFiles } from "../files/files.js";
49
49
  import { fenceTracker, finishedArchiveOf, parseEntryDetails, sectionLines, trailingParenthetical, } from "../backlog/backlog-md.js";
50
50
  import { changeBaseRev, fileContentAt } from "../git/git.js";
51
51
  import { collapseWhitespace, truncate } from "../text/text.js";
@@ -217,6 +217,52 @@ export function sourceHaystack(root) {
217
217
  }
218
218
  return parts.join("\n");
219
219
  }
220
+ /** The module-level cache backing cachedSourceHaystack, keyed by root. Cleared wholesale past a
221
+ * small cap, like the other stat caches (src/files/stat-cache.ts) — evicting only costs one
222
+ * re-walk and re-read per tree on the next call. */
223
+ const sourceHaystackCache = new Map();
224
+ const HAYSTACK_CACHE_MAX = 64;
225
+ /** sourceHaystack with a stat-keyed cache so the always-on observer poll reads the tree only
226
+ * when a file changed: the first call per root walks the tree once and, on every later call,
227
+ * builds a signature from each file's path + mtimeMs + size (all stat-only, sorted so the walk
228
+ * order cannot matter) and returns the cached text unchanged when the signature matches, or
229
+ * re-reads on a change. Transparent — its return equals sourceHaystack(root) for the same tree —
230
+ * so the merge gate keeps calling sourceHaystack and never notices the cache. */
231
+ export function cachedSourceHaystack(root) {
232
+ const signature = sourceHaystackSignature(root);
233
+ const cached = sourceHaystackCache.get(root);
234
+ if (cached?.signature === signature)
235
+ return cached.text;
236
+ const text = sourceHaystack(root);
237
+ if (sourceHaystackCache.size >= HAYSTACK_CACHE_MAX)
238
+ sourceHaystackCache.clear();
239
+ sourceHaystackCache.set(root, { signature, text });
240
+ return text;
241
+ }
242
+ /** The stat-only signature a cachedSourceHaystack call compares: every source file the haystack
243
+ * reads (docs excluded, exactly as sourceHaystack does) plus package.json/tsconfig.json, each as
244
+ * `path:mtimeMs:size`. Sorted so the walk order cannot matter; an unreadable or missing file
245
+ * contributes nothing, the same way sourceHaystack skips it — except a file that is stat-able
246
+ * but unreadable, which contributes its stat but read nothing: the cache then keys on a byte-for-
247
+ * byte consistent shape, never stale text. */
248
+ function sourceHaystackSignature(root) {
249
+ const sigs = [];
250
+ const docExts = new Set([".md", ".markdown"]);
251
+ const add = (p) => {
252
+ const st = statOrNull(p);
253
+ if (st?.isFile())
254
+ sigs.push(`${p}:${st.mtimeMs}:${st.size}`);
255
+ };
256
+ walkSourceFiles(root, (p, entry) => {
257
+ if (docExts.has(extname(entry.name).toLowerCase()))
258
+ return;
259
+ add(p);
260
+ });
261
+ for (const file of ["package.json", "tsconfig.json"])
262
+ add(join(root, file));
263
+ sigs.sort();
264
+ return sigs.join("|");
265
+ }
220
266
  /** Every file under the source dirs the haystack reads (src/, test/, scripts/), node_modules and
221
267
  * dist trees skipped at any depth. */
222
268
  function walkSourceFiles(root, visit) {
@@ -185,6 +185,41 @@ export function extractFlow(text) {
185
185
  return null;
186
186
  return { flow: match[1].trim(), result: match[2].toLowerCase() };
187
187
  }
188
+ /** Extract the `ci` run's result-carrying `CI: <sha> — <passed|failed>` line (plans, part
189
+ * 3/3); null when absent. Built on `labeledLine`, so the label is anchored at line start and
190
+ * a mid-sentence mention is ignored. The verdict is required and the sha must be a hex id: a
191
+ * bare `CI: <sha>` — or a reply truncated mid-verdict (`CI: abc123 — pa`) — is not a result,
192
+ * and returning null leaves the parity ledger unadvanced rather than latching a coverage the
193
+ * run never declared (the same lesson as qa's FLOW line, BUGS.md 2026-09-23). Splits on the
194
+ * dash/em-dash token before the verdict. */
195
+ export function extractCiResult(text) {
196
+ const value = labeledLine(text, "CI");
197
+ if (!value)
198
+ return null;
199
+ const match = /^([0-9a-f]{7,64})\s*[—-]\s*(passed|failed)\s*$/i.exec(value);
200
+ if (!match?.[1])
201
+ return null;
202
+ return { sha: match[1].toLowerCase(), result: match[2].toLowerCase() };
203
+ }
204
+ /** The paths a post-merge review's rejecting reply pairs with its verdict to declare a repair
205
+ * (Forward never back 1a/6): `FIX: <comma-separated paths>` naming the files the reviewer edited
206
+ * in the review worktree. Unlike `labeledLine` — whose `\s` can span the newline, so
207
+ * a bare `FIX:` with no content of its own would borrow the next line and read as paths — this
208
+ * regex anchors its content to ONE line: the label is anchored at line start (a mid-sentence
209
+ * mention is ignored), the content is captured on the same line, and each comma-separated path is
210
+ * whitespace-trimmed. The paths are required — a bare `FIX:` (or a reply truncated so the line
211
+ * carries none) records nothing and the review stays a plain reject, the same fail-closed lesson
212
+ * as qa's FLOW line (BUGS.md 2026-09-23). null when no usable one-line list exists. */
213
+ export function fixLine(text) {
214
+ const match = /^[ \t]*FIX:[ \t]*(.+?)[ \t]*$/m.exec(text);
215
+ if (!match)
216
+ return null;
217
+ const paths = match[1]
218
+ .split(",")
219
+ .map((p) => p.trim())
220
+ .filter((p) => p.length > 0);
221
+ return paths.length > 0 ? paths : null;
222
+ }
188
223
  /** The shortest a filed title may be to count as a declared result. A truncated `FILED:` line
189
224
  * cuts the title off mid-word (`FILED: #12 — tit` where the run meant `title`); requiring a
190
225
  * title of at least this many characters keeps a torn line from being latched as a filing the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tumwater",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Opinionated autonomous development harness built on pi. You write the initial prompt; role-driven loops build the project.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,94 +0,0 @@
1
- import { cachedBaselineVerdict, checkMainBaseline } from "./main-baseline.js";
2
- import { baselineCheckLogger } from "./main-red.js";
3
- import { CHECK_TIER } from "../concurrency/check-permit.js";
4
- import { gitTry } from "../git/git-run.js";
5
- import { treeDiffFiles } from "../git/git-diff.js";
6
- import { ensureDetachedWorktree } from "../git/worktree.js";
7
- import { gateMainWorktreePath } from "../paths.js";
8
- import { isExemptDiff } from "../review/exemptions.js";
9
- import { onlyQuarantined } from "../review/known-flakes.js";
10
- import { readEvents } from "../events/event-read.js";
11
- import { TUMWATER_SOURCE } from "../events/events.js";
12
- import { shortSha } from "../text/format.js";
13
- /** How many first-parent commits below the red tip the search for a last green looks at. */
14
- const CULPRIT_SEARCH_MAX = 50;
15
- /** How many recent events the owner lookup reads; an older `merged` event counts as none. */
16
- const OWNER_EVENT_TAIL = 2000;
17
- /** Name the first-parent commit that turned main red at `red.sha` (plans/fast-merge.md, Lever 2,
18
- * part 4a). Runs inside the main watch's `_gate-main` checkout `wt`, re-pointing it per candidate
19
- * and back at the tip before returning. Finds the newest first-parent commit with a cached green
20
- * verdict, re-checks it fresh (a green that is red now means the environment broke, not a
21
- * change), then bisects the commits since it — skipping doc-only ones — for the first red. A
22
- * search that cannot name a commit says why instead of guessing. */
23
- export async function findRedCulprit(root, wt, config, red) {
24
- const tip = red.sha;
25
- const onRun = baselineCheckLogger(root, TUMWATER_SOURCE);
26
- const mirror = gateMainWorktreePath(root);
27
- const at = async (sha) => {
28
- await ensureDetachedWorktree(root, mirror, sha);
29
- };
30
- const check = async (sha, reverifyRed, forceFresh, observedOnly) => {
31
- await at(sha);
32
- return (await checkMainBaseline(wt, config, onRun, reverifyRed, forceFresh, undefined, observedOnly, CHECK_TIER.merge))
33
- .baseline;
34
- };
35
- try {
36
- const walk = ((await gitTry(wt, "rev-list", "--first-parent", `--max-count=${CULPRIT_SEARCH_MAX + 1}`, tip)) ?? "")
37
- .split("\n")
38
- .filter((s) => s !== "")
39
- .slice(1);
40
- const green = walk.find((sha) => cachedBaselineVerdict(sha) === "green");
41
- if (!green) {
42
- return { kind: "unattributed", reason: `no green commit within ${CULPRIT_SEARCH_MAX} first-parent commits` };
43
- }
44
- const recheck = await check(green, false, true, false);
45
- if (!recheck)
46
- return { kind: "unattributed", reason: `the check made no verdict at ${shortSha(green)}` };
47
- if (recheck.status === "red")
48
- return { kind: "environment", green };
49
- const exempt = config.review.exemptPaths;
50
- const since = ((await gitTry(wt, "rev-list", "--first-parent", "--reverse", `${green}..${tip}`)) ?? "")
51
- .split("\n")
52
- .filter((s) => s !== "");
53
- const candidates = [];
54
- for (const sha of since) {
55
- const files = await treeDiffFiles(wt, `${sha}^`, sha);
56
- if (files === null || !isExemptDiff(files, exempt))
57
- candidates.push(sha);
58
- }
59
- if (candidates.length === 0)
60
- return { kind: "unattributed", reason: "no non-doc commit since the last green" };
61
- // The last candidate is taken as red unrun: it is the tip, or only doc commits follow it.
62
- let lo = 0;
63
- let hi = candidates.length - 1;
64
- while (lo < hi) {
65
- const mid = (lo + hi) >> 1;
66
- const verdict = await check(candidates[mid], false, false, true);
67
- if (!verdict)
68
- return { kind: "unattributed", reason: `the check made no verdict at ${shortSha(candidates[mid])}` };
69
- if (verdict.status === "red" && !onlyQuarantined(root, verdict.failingTests))
70
- hi = mid;
71
- else
72
- lo = mid + 1;
73
- }
74
- const commit = candidates[lo];
75
- const confirmed = await check(commit, true, false, true);
76
- if (confirmed?.status !== "red" || onlyQuarantined(root, confirmed.failingTests)) {
77
- return { kind: "unattributed", reason: `red did not reproduce at ${shortSha(commit)}` };
78
- }
79
- const failingTests = red.failingTests ?? []; // G was just verified green, so it carries none to subtract
80
- const merged = readEvents(root, OWNER_EVENT_TAIL)
81
- .reverse()
82
- .find((ev) => ev.type === "merged" && ev.commit === commit);
83
- return {
84
- kind: "culprit",
85
- commit,
86
- ...(merged ? { runner: merged.source } : {}),
87
- failingTests,
88
- green,
89
- };
90
- }
91
- finally {
92
- await at(tip);
93
- }
94
- }
@@ -1,133 +0,0 @@
1
- import { queueCulpritHeal } from "./main-red.js";
2
- import { gateCommandOf } from "../build/build-check-detect.js";
3
- import { runScopedBuildCheck } from "../build/build-check-scoped.js";
4
- import { withLock } from "../concurrency/lock.js";
5
- import { logEventBestEffort, TUMWATER_SOURCE, warnEventBestEffort } from "../events/events.js";
6
- import { branchHead, patchId } from "../git/git.js";
7
- import { gitTry } from "../git/git-run.js";
8
- import { stampedSubject } from "../git/commit-message.js";
9
- import { ensureDetachedWorktree } from "../git/worktree.js";
10
- import { useWorktree } from "../git/worktree-use.js";
11
- import { ffMainToOrWhy } from "../merge/merge-git.js";
12
- import { revertCommitIn } from "../operator/revert.js";
13
- import { mergeLockDir, mergeWorktreePath } from "../paths.js";
14
- import { DIRECTOR_ROLE } from "../roles/roles.js";
15
- import { baseRoleOf } from "../roles/runner-ids.js";
16
- import { errorMessage } from "../text/text.js";
17
- import { shortSha } from "../text/format.js";
18
- const REVERT_PREFIXES = ["tumwater(operator): Revert", "tumwater(tumwater): Revert"]; // vocabulary-ok: commit prefixes are machine strings
19
- /** Why the culprit is not reverted automatically, or null when it should be: no runner (an
20
- * operator or hand commit), a director's merge, a revert itself, or a commit whose revert
21
- * already merged. */
22
- export async function revertSkipReason(root, mainBranch, c) {
23
- if (c.runner === undefined)
24
- return "no role runner merged it";
25
- if (baseRoleOf(c.runner) === DIRECTOR_ROLE)
26
- return "the director merged it";
27
- const subject = ((await gitTry(root, "log", "-1", "--format=%s", c.commit)) ?? "").trim();
28
- if (REVERT_PREFIXES.some((p) => subject.startsWith(p)))
29
- return "it is itself a revert";
30
- const merged = (await gitTry(root, "log", mainBranch, "--format=%s", "--fixed-strings", `--grep=Revert ${shortSha(c.commit)}`)) ?? "";
31
- if (merged.split("\n").some((s) => REVERT_PREFIXES.some((p) => s === `${p} ${shortSha(c.commit)}`)))
32
- return "a revert of it already merged";
33
- return null;
34
- }
35
- /** The main watch's culprit hand-off: starts the automatic revert, or falls back to bugfix's heal
36
- * prompt when the revert is skipped. Never rejects, because its caller discards the promise and an
37
- * unhandled rejection would end the orchestrator: a heal prompt that cannot be queued is warned. */
38
- export async function routeMainCulprit(root, mainBranch, c, startRevert) {
39
- const skip = await revertSkipReason(root, mainBranch, c);
40
- if (skip === null) {
41
- startRevert(c);
42
- return;
43
- }
44
- try {
45
- queueCulpritHeal(root, c.tip, { culprit: c.commit, failingTests: c.failingTests });
46
- }
47
- catch (err) {
48
- warnEventBestEffort(root, TUMWATER_SOURCE, `not reverting ${shortSha(c.commit)} (${skip}) and could not queue the heal prompt for bugfix: ${errorMessage(err)}`);
49
- }
50
- }
51
- /** Run one revert task on main's current tip in the shared `_merge` checkout: revert the culprit,
52
- * run `check.gateCommand` on the result when one is set (red aborts), and fast-forward main under
53
- * the merge lock. Logs `main_revert` on success and calls `onReverted` (the hand-back to a role);
54
- * every failure queues the heal prompt instead. Never throws: a hand-off that cannot be queued is
55
- * warned, not thrown. */
56
- export async function runMainRevert(root, mainBranch, config, c, onReverted, onFailed) {
57
- /** Hand the failure to a role and return the warning's suffix saying truthfully what happened. */
58
- const heal = (reason, outputTail) => {
59
- const toReviewer = c.postReview && onFailed;
60
- try {
61
- if (toReviewer)
62
- onFailed(c, reason);
63
- else
64
- queueCulpritHeal(root, c.tip, { culprit: c.commit, failingTests: c.failingTests, outputTail });
65
- return toReviewer ? "sent the reviewer's reasons to its role" : "queued the heal prompt for bugfix";
66
- }
67
- catch (err) {
68
- return toReviewer
69
- ? `could not send the reviewer's reasons to its role: ${errorMessage(err)}`
70
- : `could not queue the heal prompt for bugfix: ${errorMessage(err)}`;
71
- }
72
- };
73
- try {
74
- const failure = await useWorktree(root, mergeWorktreePath(root), async () => {
75
- const tip = await branchHead(root, mainBranch);
76
- if (tip === null)
77
- return { reason: `${mainBranch} is unreadable` };
78
- const wt = await ensureDetachedWorktree(root, mergeWorktreePath(root), tip);
79
- const subject = ((await gitTry(wt, "log", "-1", "--format=%s", c.commit)) ?? "").trim();
80
- const tests = c.failingTests.length > 0 ? `\n\nFailing tests: ${c.failingTests.join("; ")}` : "";
81
- const why = c.postReview
82
- ? `which a post-merge review rejected.\n\nReasons: ${c.postReview.reasons.join("; ")}`
83
- : `which turned main red.${tests}`;
84
- const message = `${stampedSubject("tumwater", `Revert ${shortSha(c.commit)}`)}\n\nReverts ${c.commit} (${subject}), ${why}`;
85
- const reverted = await revertCommitIn(wt, c.commit, message);
86
- if (!reverted.ok)
87
- return { reason: reverted.error };
88
- if (gateCommandOf(config) !== undefined) {
89
- const gate = await runScopedBuildCheck(root, TUMWATER_SOURCE, "gate", wt, config);
90
- if (gate && gate.outcome.status !== "passed")
91
- return { reason: `the gate command was ${gate.outcome.status} on the revert`, tail: gate.outcome.outputTail };
92
- }
93
- const ffFailure = await withLock(mergeLockDir(root), () => ffMainToOrWhy(root, reverted.sha, mainBranch));
94
- if (ffFailure !== null)
95
- return { reason: ffFailure };
96
- // The culprit's patch-id (`C^...C`, src/git/git.ts patchId, -U0 so a re-landing onto a
97
- // moved main keeps the id): the fast-merge guardrail G2 matches a later `merged`
98
- // event against it to spot a change that came back unchanged (plans/fast-merge.md).
99
- const culpritPatch = await patchId(wt, `${c.commit}^`, c.commit).catch(() => null);
100
- logEventBestEffort(root, {
101
- source: TUMWATER_SOURCE,
102
- type: "main_revert",
103
- commit: c.commit,
104
- revert: reverted.sha,
105
- runner: c.runner,
106
- failingTests: c.failingTests,
107
- ...(culpritPatch ? { patchId: culpritPatch } : {}),
108
- });
109
- await onReverted?.(c).catch((err) => logEventBestEffort(root, {
110
- source: TUMWATER_SOURCE,
111
- type: "warning",
112
- message: `could not hand ${shortSha(c.commit)} back to its role: ${errorMessage(err)}`,
113
- }));
114
- return null;
115
- });
116
- if (failure !== null) {
117
- const suffix = heal(failure.reason, failure.tail);
118
- logEventBestEffort(root, {
119
- source: TUMWATER_SOURCE,
120
- type: "warning",
121
- message: `could not revert ${shortSha(c.commit)}: ${failure.reason} — ${suffix}`,
122
- });
123
- }
124
- }
125
- catch (err) {
126
- const suffix = heal(errorMessage(err));
127
- logEventBestEffort(root, {
128
- source: TUMWATER_SOURCE,
129
- type: "warning",
130
- message: `revert of ${shortSha(c.commit)} failed: ${errorMessage(err)} — ${suffix}`,
131
- });
132
- }
133
- }