tumwater 0.4.1 → 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 (186) hide show
  1. package/README.md +4 -6
  2. package/dist/build-info.json +2 -2
  3. package/dist/src/agent/backend-traits.js +14 -0
  4. package/dist/src/agent/claude/claude-stream.js +27 -31
  5. package/dist/src/agent/codex/codex-rollout.js +38 -24
  6. package/dist/src/agent/codex/codex-stream.js +71 -38
  7. package/dist/src/agent/image-input.js +18 -87
  8. package/dist/src/agent/opencode/opencode-catalog.js +2 -2
  9. package/dist/src/agent/opencode/opencode-stream.js +4 -4
  10. package/dist/src/agent/run-agent.js +11 -2
  11. package/dist/src/agent/run-settle.js +22 -13
  12. package/dist/src/agent/run-spawn.js +3 -12
  13. package/dist/src/agent/run-stream.js +2 -10
  14. package/dist/src/agent/session-ledger.js +16 -0
  15. package/dist/src/backlog/backlog-archive.js +64 -165
  16. package/dist/src/backlog/backlog-eligibility.js +50 -58
  17. package/dist/src/backlog/backlog-file-bugs.js +133 -0
  18. package/dist/src/backlog/backlog-index-data.js +2 -2
  19. package/dist/src/backlog/backlog-md.js +38 -27
  20. package/dist/src/backlog/backlog-release.js +11 -19
  21. package/dist/src/backlog/backlog-structure.js +14 -1
  22. package/dist/src/backlog/backlog-write.js +1 -1
  23. package/dist/src/backlog/backlog.js +5 -4
  24. package/dist/src/backlog/question-answer.js +30 -14
  25. package/dist/src/baseline/main-baseline.js +30 -20
  26. package/dist/src/baseline/main-guardrails.js +54 -17
  27. package/dist/src/baseline/main-watch.js +1 -1
  28. package/dist/src/budget/budget-wiring.js +0 -5
  29. package/dist/src/budget/budget.js +2 -1
  30. package/dist/src/build/build-check-scoped.js +70 -41
  31. package/dist/src/change/change-data.js +4 -2
  32. package/dist/src/cli/cli-command-args.js +199 -98
  33. package/dist/src/cli/cli-flag-specs.js +1 -4
  34. package/dist/src/cli/cli-review-commands.js +2 -1
  35. package/dist/src/cli/cli-role-command.js +27 -50
  36. package/dist/src/cli/cli-start.js +62 -12
  37. package/dist/src/cli/follow-view.js +3 -1
  38. package/dist/src/cli/help.js +6 -7
  39. package/dist/src/cli/log-commands.js +3 -1
  40. package/dist/src/cli.js +3 -7
  41. package/dist/src/collections.js +21 -7
  42. package/dist/src/config/config-fallbacks.js +0 -4
  43. package/dist/src/config/config-live.js +179 -147
  44. package/dist/src/config/config-views.js +2 -7
  45. package/dist/src/config/config-write.js +19 -26
  46. package/dist/src/doctor/doctor-backlog-bugs.js +17 -115
  47. package/dist/src/doctor/doctor-backlog-plans.js +8 -8
  48. package/dist/src/doctor/doctor-backlog.js +22 -36
  49. package/dist/src/doctor/doctor-check-names.js +2 -2
  50. package/dist/src/doctor/doctor-claude-checks.js +2 -2
  51. package/dist/src/doctor/doctor-contention-checks.js +1 -1
  52. package/dist/src/doctor/doctor-holds.js +13 -11
  53. package/dist/src/doctor/doctor-model-checks.js +8 -184
  54. package/dist/src/doctor/doctor-notify.js +2 -5
  55. package/dist/src/doctor/doctor-opencode-checks.js +85 -48
  56. package/dist/src/doctor/doctor-project-checks.js +66 -17
  57. package/dist/src/doctor/doctor-role-notes.js +6 -24
  58. package/dist/src/doctor/doctor.js +6 -6
  59. package/dist/src/doctor/probe.js +1 -1
  60. package/dist/src/events/event-format.js +5 -15
  61. package/dist/src/events/event-labels.js +0 -1
  62. package/dist/src/events/event-legacy.js +1 -1
  63. package/dist/src/events/event-window.js +1 -1
  64. package/dist/src/events/events.js +15 -2
  65. package/dist/src/failure/failure-state-change.js +183 -68
  66. package/dist/src/failure/time-spend.js +1 -1
  67. package/dist/src/files/json-object.js +15 -2
  68. package/dist/src/gates/startup-gate.js +28 -28
  69. package/dist/src/git/slot-salvage.js +5 -4
  70. package/dist/src/gui/gui-admin-endpoints.js +2 -3
  71. package/dist/src/gui/gui-backlog-commands.js +1 -1
  72. package/dist/src/gui/gui-backlog-endpoints.js +1 -7
  73. package/dist/src/gui/gui-endpoint-commands.js +1 -1
  74. package/dist/src/gui/gui-operator-commands.js +0 -12
  75. package/dist/src/gui/gui-prompt-commands.js +26 -20
  76. package/dist/src/gui/gui-report-endpoints.js +2 -1
  77. package/dist/src/gui/gui-server.js +1 -2
  78. package/dist/src/history/history.js +102 -67
  79. package/dist/src/inbox/inbox-submit.js +1 -5
  80. package/dist/src/inbox/prompt-commands.js +24 -19
  81. package/dist/src/init/init.js +68 -15
  82. package/dist/src/merge/merge-core.js +1 -1
  83. package/dist/src/merge/merge-post-review-drain.js +1 -1
  84. package/dist/src/operator/operator-commands.js +68 -47
  85. package/dist/src/operator/retire.js +2 -1
  86. package/dist/src/orchestrator/retention.js +6 -5
  87. package/dist/src/paths.js +0 -6
  88. package/dist/src/pi/pi-watchdogs.js +20 -1
  89. package/dist/src/pi-extension/context-shake.js +55 -31
  90. package/dist/src/process/process.js +23 -0
  91. package/dist/src/process/supervisor.js +17 -1
  92. package/dist/src/prompt/gate-notes.js +9 -9
  93. package/dist/src/prompt/notes-backlog.js +11 -24
  94. package/dist/src/prompt/prompt-followup.js +4 -12
  95. package/dist/src/prompt/prompt.js +4 -5
  96. package/dist/src/report/report-data.js +10 -225
  97. package/dist/src/report/report-fold.js +20 -295
  98. package/dist/src/report/report-main-red.js +45 -42
  99. package/dist/src/report/report-render.js +66 -39
  100. package/dist/src/report/report-since-data.js +218 -0
  101. package/dist/src/report/report-usage-fold.js +288 -0
  102. package/dist/src/report/report.js +2 -1
  103. package/dist/src/review/known-flakes.js +3 -3
  104. package/dist/src/review/post-merge-review-accept.js +3 -2
  105. package/dist/src/review/post-merge-review.js +9 -9
  106. package/dist/src/review/quarantine-render.js +2 -1
  107. package/dist/src/roles/role-catalog.js +18 -30
  108. package/dist/src/roles/role-guidance.js +18 -43
  109. package/dist/src/roles/role-view.js +1 -2
  110. package/dist/src/run/ci-parity.js +5 -6
  111. package/dist/src/run/qa-coverage.js +4 -4
  112. package/dist/src/run/run-detail-data.js +23 -7
  113. package/dist/src/run/run-detail-render.js +6 -0
  114. package/dist/src/run/run-detail.js +44 -22
  115. package/dist/src/run/run-dismiss.js +43 -0
  116. package/dist/src/run/run-prompt.js +100 -56
  117. package/dist/src/run/run-resume.js +32 -31
  118. package/dist/src/run/run-stage.js +4 -21
  119. package/dist/src/run/run-timing.js +18 -4
  120. package/dist/src/run/run-verdict.js +8 -73
  121. package/dist/src/run/stage-backlog-checks.js +2 -29
  122. package/dist/src/run/stage-check.js +4 -12
  123. package/dist/src/runner/runner-outcome.js +0 -5
  124. package/dist/src/runner/runner-run.js +2 -79
  125. package/dist/src/runner/runner-state-load.js +0 -10
  126. package/dist/src/runner/runner.js +1 -32
  127. package/dist/src/scheduling/claims.js +3 -10
  128. package/dist/src/status/status-data.js +5 -9
  129. package/dist/src/status/status-role-notes.js +18 -14
  130. package/dist/src/status/status-roles.js +2 -35
  131. package/dist/src/text/datetime.js +8 -1
  132. package/dist/src/text/text.js +12 -0
  133. package/dist/src/ui/alert-feeds.js +7 -212
  134. package/dist/src/ui/badges.js +2 -3
  135. package/dist/src/ui/gui/gui-client-boot.js +19 -4
  136. package/dist/src/ui/gui/gui-client-drawer.js +1 -1
  137. package/dist/src/ui/gui/gui-client-report-chart.js +10 -8
  138. package/dist/src/ui/gui/gui-client-report.js +1 -2
  139. package/dist/src/ui/gui/gui-client-settings.js +0 -23
  140. package/dist/src/ui/gui/gui-client-team.js +26 -0
  141. package/dist/src/ui/gui/gui-icons.js +7 -1
  142. package/dist/src/ui/status-model.js +2 -2
  143. package/dist/src/ui/status-payload.js +3 -3
  144. package/dist/src/ui/status-render.js +3 -3
  145. package/dist/src/ui/team-alert-backlog.js +23 -54
  146. package/dist/src/ui/team-alert-shared.js +23 -10
  147. package/dist/src/ui/team-alert-status.js +8 -3
  148. package/dist/src/ui/team-alert-tail.js +1 -14
  149. package/dist/src/ui/team-alerts.js +6 -17
  150. package/dist/src/ui/transcript-tail.js +137 -94
  151. package/dist/src/ui/tui/tui-editor-answer.js +54 -44
  152. package/dist/src/ui/tui/tui-editor-attach.js +74 -63
  153. package/dist/src/ui/tui/tui-editor-backends.js +62 -56
  154. package/dist/src/ui/tui/tui-editor-brief.js +64 -54
  155. package/dist/src/ui/tui/tui-editor-budget.js +105 -93
  156. package/dist/src/ui/tui/tui-editor-config.js +2 -4
  157. package/dist/src/ui/tui/tui-editor-entry.js +171 -153
  158. package/dist/src/ui/tui/tui-editor-instances.js +2 -1
  159. package/dist/src/ui/tui/tui-editor-pause.js +209 -183
  160. package/dist/src/ui/tui/tui-editor-prompt.js +44 -34
  161. package/dist/src/ui/tui/tui-editor-queued.js +74 -64
  162. package/dist/src/ui/tui/tui-editor-quiet.js +121 -108
  163. package/dist/src/ui/tui/tui-editor-release.js +50 -42
  164. package/dist/src/ui/tui/tui-editor-retire.js +57 -46
  165. package/dist/src/ui/tui/tui-editor-role-enabled.js +90 -80
  166. package/dist/src/ui/tui/tui-editor-role-file.js +58 -46
  167. package/dist/src/ui/tui/tui-editor-role-key.js +49 -42
  168. package/dist/src/ui/tui/tui-editor-search.js +70 -58
  169. package/dist/src/ui/tui/tui-frame.js +1 -1
  170. package/dist/src/ui/tui/tui-keys.js +1 -1
  171. package/dist/src/ui/tui/tui-pane-activity.js +62 -50
  172. package/dist/src/ui/tui/tui-pane-backlog.js +4 -4
  173. package/dist/src/ui/tui/tui-pane-health.js +3 -24
  174. package/dist/src/ui/tui/tui-pane-history.js +20 -24
  175. package/dist/src/ui/tui/tui-pane-report.js +74 -57
  176. package/dist/src/ui/tui/tui-pane-session.js +3 -2
  177. package/dist/src/ui/tui/tui-pane-transcript.js +151 -128
  178. package/dist/src/ui/tui/tui-pane.js +201 -174
  179. package/dist/src/ui/tui/tui-panes.js +2 -3
  180. package/dist/src/ui/tui/tui.js +3 -3
  181. package/dist/src/verdict/fix-claim.js +18 -6
  182. package/dist/src/verdict/refusal.js +30 -6
  183. package/package.json +1 -1
  184. package/dist/src/doctor/image-probe.js +0 -257
  185. package/dist/src/ui/team-alert-backlog-feed.js +0 -181
  186. package/dist/src/ui/team-alert-merge.js +0 -183
@@ -47,207 +47,234 @@ export function roleInspectMarkdown(root, role) {
47
47
  * the head with the selected entry's full body in entry mode, usage/failures re-window
48
48
  * the cached Markdown set on view entry, and the activity pane keeps the event tail. */
49
49
  export function paneBody(pane, opts) {
50
- const { root, width, eventBudget, entryBudget, state } = opts;
51
- if (pane.kind === "transcript") {
52
- // The notes view (Ctrl+N) windows the cached inspection Markdown exactly like the
53
- // usage/failures panes, headings bold; the live transcript keeps the tail.
54
- if (pane.note) {
55
- const win = entryBodyWindow(state.noteCache ?? "", state.paneScroll, eventBudget, width);
56
- return {
57
- body: win.lines.map((l) => toneLine(l, width, l.startsWith("#") ? "bold" : undefined)),
58
- emptyNote: "(nothing to inspect yet)",
59
- };
60
- }
61
- // The change view (the second Ctrl+N step) windows the collected unmerged-change text the
62
- // same way; its async collector leaves the cache null for the first frames, so the pane
63
- // paints the loading note rather than entryBodyWindow's "no details" placeholder.
64
- if (pane.change) {
65
- if (state.changeCache === null)
66
- return { body: [], emptyNote: "(loading pending change…)" };
67
- const win = entryBodyWindow(state.changeCache, state.paneScroll, eventBudget, width);
68
- return {
69
- body: win.lines.map((l) => toneLine(l, width, l.startsWith("#") ? "bold" : undefined)),
70
- emptyNote: "(loading pending change…)",
71
- };
72
- }
73
- if (pane.grep) {
74
- // Filtered: scan the drawer's 200 entries with the shared keep rule (a lowercased needle).
75
- return {
76
- body: readTranscript(root, pane.role, 200, { grep: pane.grep.toLowerCase() }).slice(-eventBudget).map((l) => toneLine(l, width, transcriptTone(l))),
77
- emptyNote: `(no transcript entries match "${pane.grep}")`,
78
- };
79
- }
50
+ if (pane.kind === "transcript")
51
+ return transcriptBody(pane, opts);
52
+ if (pane.kind === "backlog")
53
+ return backlogBody(pane, opts);
54
+ if (pane.kind === "history")
55
+ return historyBody(pane, opts);
56
+ if (pane.kind === "pending")
57
+ return pendingBody(pane, opts);
58
+ if (pane.kind === "health")
59
+ return healthBody(pane, opts);
60
+ if (pane.kind === "brief")
61
+ return briefBody(pane, opts);
62
+ if (pane.kind === "queued")
63
+ return queuedBody(pane, opts);
64
+ if (pane.kind === "usage" || pane.kind === "failures")
65
+ return reportBody(pane, opts);
66
+ return activityBody(pane, opts);
67
+ }
68
+ function transcriptBody(pane, opts) {
69
+ const { root, width, eventBudget, state } = opts;
70
+ // The notes view (Ctrl+N) windows the cached inspection Markdown exactly like the
71
+ // usage/failures panes, headings bold; the live transcript keeps the tail.
72
+ if (pane.note) {
73
+ const win = entryBodyWindow(state.noteCache ?? "", state.paneScroll, eventBudget, width);
80
74
  return {
81
- body: readTranscript(root, pane.role, eventBudget).slice(-eventBudget).map((l) => toneLine(l, width, transcriptTone(l))),
82
- emptyNote: "(no transcript yet)",
75
+ body: win.lines.map((l) => toneLine(l, width, l.startsWith("#") ? "bold" : undefined)),
76
+ emptyNote: "(nothing to inspect yet)",
83
77
  };
84
78
  }
85
- if (pane.kind === "backlog") {
86
- // The flat, labeled entry list is the one backlog read this frame needs: it carries every
87
- // planned feature, open bug, and open question (then the newest answered questions, done
88
- // plans and fixed bugs)
89
- // from PLANS.md/BUGS.md/QUESTIONS.md with its section label and eligibility mark (the same
90
- // entryHold verdict `tumwater backlog` renders). List and entry mode share this one pass;
91
- // entry mode used to read the three sections and derive the holds directly and
92
- // then again inside the flat list, so every render paid twice for the same reads and verdicts.
93
- const flat = browsableBacklogEntries(root);
94
- // A `/` search narrows the browsable list to the matching open entries (the answered/done/
95
- // fixed rows are read-only and hidden while a search is applied); entry mode still opens
96
- // `flat[sel]`, the unfiltered index the selection holds.
97
- const grep = state.backlogGrep ?? null;
98
- const shown = flat.filter((e) => {
99
- if (grep !== null && !(isOpenBacklogLabel(e.label) && entryMatches(e, grep)))
100
- return false;
101
- if (state.backlogReadyOnly && isOpenBacklogLabel(e.label) && e.hold !== null)
102
- return false;
103
- return true;
104
- });
105
- // A stale selection (an entry removed since the last render) clamps to the last; no
106
- // entries at all falls back to list mode.
107
- const sel = state.selectedEntry === null || flat.length === 0 ? null : Math.min(state.selectedEntry, flat.length - 1);
108
- if (sel === null) {
109
- // List mode: section headings stand out, empty sections read dim. Keeps the HEAD of the
110
- // list when it overflows — file order is newest-first, unlike events which keep the tail.
111
- const listed = (label) => shown.filter((e) => e.label === label).map((e) => e.title + e.mark);
112
- // Plans list a series' blocked parts as one line (as `plan --list` does); the selection
113
- // below still walks the flat list, one entry per part.
114
- const plans = shown.filter((e) => e.label === "plan");
115
- // A `/` search active: the answered/done/fixed sections are hidden, the sections build
116
- // only from the matching open entries under a `search: "<grep>"` header, and nothing
117
- // matching paints a self-explanatory line.
118
- if (grep !== null) {
119
- const prefix = [`search: "${grep}"`];
120
- const rest = shown.length === 0
121
- ? [`(no entries match "${grep}")`]
122
- : backlogLines(planListLines(plans), listed("bug"), listed("question"), plans.length);
123
- return {
124
- body: [...prefix, ...rest]
125
- .slice(0, eventBudget)
126
- .map((l) => toneLine(l, width, /^(?:plans|open bugs|open questions) \(\d+\):$/.test(l) ? "bold" : l.startsWith("(") ? "dim" : undefined)),
127
- emptyNote: "(no events yet)",
128
- };
129
- }
130
- // The newest answered questions follow the open ones: they are in `flat` (up/down open
131
- // their bodies), but no edit key acts on them.
132
- const answered = answeredQuestionSummaries(root, DASHBOARD_ANSWERED_QUESTIONS).map((q) => q.decision ? `${q.title} — ${q.decision}` : q.title);
133
- // Done plans and fixed bugs follow, browsable and read-only the same way.
134
- const finishedRows = (rows, verb) => rows.map((r) => (r.completedOn ? `${r.title} — ${verb} ${r.completedOn}` : r.title));
135
- const finished = {
136
- done: finishedRows(recentFinished(donePlanEntries(root), DONE_DATE_RE), "done"),
137
- fixed: finishedRows(recentFinished(fixedBugEntries(root), FIXED_DATE_RE), "fixed"),
138
- };
139
- return {
140
- body: backlogLines(planListLines(plans), listed("bug"), listed("question"), plans.length, answered, finished)
141
- .slice(0, eventBudget)
142
- .map((l) => toneLine(l, width, /^(?:plans|open bugs|open questions|answered recently|done recently|fixed recently) \(\d+\):$/.test(l) ? "bold" : l.startsWith("(") ? "dim" : undefined)),
143
- emptyNote: "(no events yet)",
144
- };
145
- }
146
- // Entry browsing: the selected entry's full body under a line naming its section and
147
- // title.
148
- const e = flat[sel];
149
- const win = entryBodyWindow(e.body, state.entryScroll, entryBudget, width);
79
+ // The change view (the second Ctrl+N step) windows the collected unmerged-change text the
80
+ // same way; its async collector leaves the cache null for the first frames, so the pane
81
+ // paints the loading note rather than entryBodyWindow's "no details" placeholder.
82
+ if (pane.change) {
83
+ if (state.changeCache === null)
84
+ return { body: [], emptyNote: "(loading pending change…)" };
85
+ const win = entryBodyWindow(state.changeCache, state.paneScroll, eventBudget, width);
150
86
  return {
151
- body: [toneLine(`${e.label}: ${e.title}${e.mark}`, width, "bold"), ...win.lines.map((l) => toneLine(l, width))],
152
- emptyNote: "(no events yet)",
87
+ body: win.lines.map((l) => toneLine(l, width, l.startsWith("#") ? "bold" : undefined)),
88
+ emptyNote: "(loading pending change…)",
153
89
  };
154
90
  }
155
- if (pane.kind === "history") {
156
- // The run trail (Ctrl+N) windows the cached renderRunDetail text; a null cache (the
157
- // read found no run) paints the pane's own note rather than entryBodyWindow's placeholder.
158
- if (pane.trail) {
159
- if (state.trailCache === null)
160
- return { body: [], emptyNote: "(no run trail)" };
161
- const win = entryBodyWindow(state.trailCache, state.paneScroll, eventBudget, width);
162
- return {
163
- body: win.lines.map((l) => toneLine(l, width)),
164
- emptyNote: "(no run trail)",
165
- };
166
- }
167
- // historyPaneText returns "" when the log holds no completed runs; return no lines
168
- // (rather than windowing an empty string, which entryBodyWindow replaces with its
169
- // "no details" placeholder) so the frame prints the pane's own empty note instead.
170
- const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
91
+ if (pane.grep) {
92
+ // Filtered: scan the drawer's 200 entries with the shared keep rule (a lowercased needle).
171
93
  return {
172
- body: win.lines.map((l) => toneLine(l, width)),
173
- emptyNote: "(no runs yet)",
94
+ body: readTranscript(root, pane.role, 200, { grep: pane.grep.toLowerCase() }).slice(-eventBudget).map((l) => toneLine(l, width, transcriptTone(l))),
95
+ emptyNote: `(no transcript entries match "${pane.grep}")`,
174
96
  };
175
97
  }
176
- if (pane.kind === "pending") {
177
- // A selected queued merge shows its detail in place of the queue list; otherwise paneCache
178
- // stays null until the async collector resolves (the Ctrl+T handler kicks it off
179
- // fire-and-forget); like history, return no lines while null so the frame prints the
180
- // loading note rather than windowing an empty string into the "no details" placeholder.
181
- if (state.queueDetail) {
182
- const detail = state.queueDetail;
183
- const sel = entryBodyWindow(detail, state.paneScroll, eventBudget, width);
98
+ return {
99
+ body: readTranscript(root, pane.role, eventBudget).slice(-eventBudget).map((l) => toneLine(l, width, transcriptTone(l))),
100
+ emptyNote: "(no transcript yet)",
101
+ };
102
+ }
103
+ function backlogBody(_pane, opts) {
104
+ const { root, width, eventBudget, entryBudget, state } = opts;
105
+ // The flat, labeled entry list is the one backlog read this frame needs: it carries every
106
+ // planned feature, open bug, and open question (then the newest answered questions, done
107
+ // plans and fixed bugs)
108
+ // from PLANS.md/BUGS.md/QUESTIONS.md with its section label and eligibility mark (the same
109
+ // entryHold verdict `tumwater backlog` renders). List and entry mode share this one pass;
110
+ // entry mode used to read the three sections and derive the holds directly and
111
+ // then again inside the flat list, so every render paid twice for the same reads and verdicts.
112
+ const flat = browsableBacklogEntries(root);
113
+ // A `/` search narrows the browsable list to the matching open entries (the answered/done/
114
+ // fixed rows are read-only and hidden while a search is applied); entry mode still opens
115
+ // `flat[sel]`, the unfiltered index the selection holds.
116
+ const grep = state.backlogGrep ?? null;
117
+ const shown = flat.filter((e) => {
118
+ if (grep !== null && !(isOpenBacklogLabel(e.label) && entryMatches(e, grep)))
119
+ return false;
120
+ if (state.backlogReadyOnly && isOpenBacklogLabel(e.label) && e.hold !== null)
121
+ return false;
122
+ return true;
123
+ });
124
+ // A stale selection (an entry removed since the last render) clamps to the last; no
125
+ // entries at all falls back to list mode.
126
+ const sel = state.selectedEntry === null || flat.length === 0 ? null : Math.min(state.selectedEntry, flat.length - 1);
127
+ if (sel === null) {
128
+ // List mode: section headings stand out, empty sections read dim. Keeps the HEAD of the
129
+ // list when it overflows — file order is newest-first, unlike events which keep the tail.
130
+ const listed = (label) => shown.filter((e) => e.label === label).map((e) => e.title + e.mark);
131
+ // Plans list a series' blocked parts as one line (as `plan --list` does); the selection
132
+ // below still walks the flat list, one entry per part.
133
+ const plans = shown.filter((e) => e.label === "plan");
134
+ // A `/` search active: the answered/done/fixed sections are hidden, the sections build
135
+ // only from the matching open entries under a `search: "<grep>"` header, and nothing
136
+ // matching paints a self-explanatory line.
137
+ if (grep !== null) {
138
+ const prefix = [`search: "${grep}"`];
139
+ const rest = shown.length === 0
140
+ ? [`(no entries match "${grep}")`]
141
+ : backlogLines(planListLines(plans), listed("bug"), listed("question"), plans.length);
184
142
  return {
185
- body: sel.lines.map((l) => toneLine(l, width)),
186
- emptyNote: "(loading pending changes…)",
143
+ body: [...prefix, ...rest]
144
+ .slice(0, eventBudget)
145
+ .map((l) => toneLine(l, width, /^(?:plans|open bugs|open questions) \(\d+\):$/.test(l) ? "bold" : l.startsWith("(") ? "dim" : undefined)),
146
+ emptyNote: "(no events yet)",
187
147
  };
188
148
  }
189
- const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
149
+ // The newest answered questions follow the open ones: they are in `flat` (up/down open
150
+ // their bodies), but no edit key acts on them.
151
+ const answered = answeredQuestionSummaries(root, DASHBOARD_ANSWERED_QUESTIONS).map((q) => q.decision ? `${q.title} — ${q.decision}` : q.title);
152
+ // Done plans and fixed bugs follow, browsable and read-only the same way.
153
+ const finishedRows = (rows, verb) => rows.map((r) => (r.completedOn ? `${r.title} — ${verb} ${r.completedOn}` : r.title));
154
+ const finished = {
155
+ done: finishedRows(recentFinished(donePlanEntries(root), DONE_DATE_RE), "done"),
156
+ fixed: finishedRows(recentFinished(fixedBugEntries(root), FIXED_DATE_RE), "fixed"),
157
+ };
190
158
  return {
191
- body: win.lines.map((l) => toneLine(l, width)),
192
- emptyNote: "(loading pending changes…)",
159
+ body: backlogLines(planListLines(plans), listed("bug"), listed("question"), plans.length, answered, finished)
160
+ .slice(0, eventBudget)
161
+ .map((l) => toneLine(l, width, /^(?:plans|open bugs|open questions|answered recently|done recently|fixed recently) \(\d+\):$/.test(l) ? "bold" : l.startsWith("(") ? "dim" : undefined)),
162
+ emptyNote: "(no events yet)",
193
163
  };
194
164
  }
195
- if (pane.kind === "health") {
196
- const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
165
+ // Entry browsing: the selected entry's full body under a line naming its section and
166
+ // title.
167
+ const e = flat[sel];
168
+ const win = entryBodyWindow(e.body, state.entryScroll, entryBudget, width);
169
+ return {
170
+ body: [toneLine(`${e.label}: ${e.title}${e.mark}`, width, "bold"), ...win.lines.map((l) => toneLine(l, width))],
171
+ emptyNote: "(no events yet)",
172
+ };
173
+ }
174
+ function historyBody(pane, opts) {
175
+ const { width, eventBudget, state } = opts;
176
+ // The run trail (Ctrl+N) windows the cached renderRunDetail text; a null cache (the
177
+ // read found no run) paints the pane's own note rather than entryBodyWindow's placeholder.
178
+ if (pane.trail) {
179
+ if (state.trailCache === null)
180
+ return { body: [], emptyNote: "(no run trail)" };
181
+ const win = entryBodyWindow(state.trailCache, state.paneScroll, eventBudget, width);
197
182
  return {
198
183
  body: win.lines.map((l) => toneLine(l, width)),
199
- emptyNote: "(running health checks…)",
184
+ emptyNote: "(no run trail)",
200
185
  };
201
186
  }
202
- if (pane.kind === "brief") {
203
- const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
187
+ // historyPaneText returns "" when the log holds no completed runs; return no lines
188
+ // (rather than windowing an empty string, which entryBodyWindow replaces with its
189
+ // "no details" placeholder) so the frame prints the pane's own empty note instead.
190
+ const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
191
+ return {
192
+ body: win.lines.map((l) => toneLine(l, width)),
193
+ emptyNote: "(no runs yet)",
194
+ };
195
+ }
196
+ function pendingBody(_pane, opts) {
197
+ const { width, eventBudget, state } = opts;
198
+ // A selected queued merge shows its detail in place of the queue list; otherwise paneCache
199
+ // stays null until the async collector resolves (the Ctrl+T handler kicks it off
200
+ // fire-and-forget); like history, return no lines while null so the frame prints the
201
+ // loading note rather than windowing an empty string into the "no details" placeholder.
202
+ if (state.queueDetail) {
203
+ const detail = state.queueDetail;
204
+ const sel = entryBodyWindow(detail, state.paneScroll, eventBudget, width);
204
205
  return {
205
- body: win.lines.map((l) => toneLine(l, width)),
206
- emptyNote: "(no project brief with a prompt block — run `tumwater init` first)",
206
+ body: sel.lines.map((l) => toneLine(l, width)),
207
+ emptyNote: "(loading pending changes…)",
207
208
  };
208
209
  }
209
- if (pane.kind === "queued") {
210
- // A set selection shows the row's full prompt under a cancel header (the numbered list
211
- // clips long prompts); list mode windows the cached renderQueuedList string, which is
212
- // computed synchronously on Ctrl+T entry over the same queue `tumwater prompt --list`
213
- // prints, so paneCache is already set this frame. An empty queue falls to the pane's
214
- // own note either way.
215
- const sel = state.selectedQueued === null || state.queuedRows.length === 0
216
- ? null
217
- : Math.min(state.selectedQueued, state.queuedRows.length - 1);
218
- if (sel !== null) {
219
- const row = state.queuedRows[sel];
220
- const win = entryBodyWindow(row.text, state.paneScroll, entryBudget, width);
221
- return {
222
- body: [
223
- toneLine(`${row.role}'s inbox #${row.position} (Ctrl+X to cancel)`, width, "bold"),
224
- ...win.lines.map((l) => toneLine(l, width)),
225
- ],
226
- emptyNote: "(inbox empty)",
227
- };
228
- }
229
- // An applied search names itself above the list; one that matched nothing says so instead
230
- // of claiming the inbox is empty.
231
- const searchLine = pane.grep ? [toneLine(`search: "${pane.grep}"`, width, "bold")] : [];
232
- if (pane.grep && state.queuedRows.length === 0) {
233
- return { body: [...searchLine, toneLine(`(no queued prompt matches "${pane.grep}")`, width)], emptyNote: "(inbox empty)" };
234
- }
235
- const listBudget = Math.max(1, eventBudget - searchLine.length);
236
- const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, listBudget, width) : { lines: [] };
210
+ const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
211
+ return {
212
+ body: win.lines.map((l) => toneLine(l, width)),
213
+ emptyNote: "(loading pending changes…)",
214
+ };
215
+ }
216
+ function healthBody(_pane, opts) {
217
+ const { width, eventBudget, state } = opts;
218
+ const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
219
+ return {
220
+ body: win.lines.map((l) => toneLine(l, width)),
221
+ emptyNote: "(running health checks…)",
222
+ };
223
+ }
224
+ function briefBody(_pane, opts) {
225
+ const { width, eventBudget, state } = opts;
226
+ const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, eventBudget, width) : { lines: [] };
227
+ return {
228
+ body: win.lines.map((l) => toneLine(l, width)),
229
+ emptyNote: "(no project brief with a prompt block — run `tumwater init` first)",
230
+ };
231
+ }
232
+ function queuedBody(pane, opts) {
233
+ const { width, eventBudget, entryBudget, state } = opts;
234
+ // A set selection shows the row's full prompt under a cancel header (the numbered list
235
+ // clips long prompts); list mode windows the cached renderQueuedList string, which is
236
+ // computed synchronously on Ctrl+T entry over the same queue `tumwater prompt --list`
237
+ // prints, so paneCache is already set this frame. An empty queue falls to the pane's
238
+ // own note either way.
239
+ const sel = state.selectedQueued === null || state.queuedRows.length === 0
240
+ ? null
241
+ : Math.min(state.selectedQueued, state.queuedRows.length - 1);
242
+ if (sel !== null) {
243
+ const row = state.queuedRows[sel];
244
+ const win = entryBodyWindow(row.text, state.paneScroll, entryBudget, width);
237
245
  return {
238
- body: [...searchLine, ...win.lines.map((l) => toneLine(l, width))],
246
+ body: [
247
+ toneLine(`${row.role}'s inbox #${row.position} (Ctrl+X to cancel)`, width, "bold"),
248
+ ...win.lines.map((l) => toneLine(l, width)),
249
+ ],
239
250
  emptyNote: "(inbox empty)",
240
251
  };
241
252
  }
242
- if (pane.kind === "usage" || pane.kind === "failures") {
243
- // The Markdown `tumwater report`/`report --failures` prints, windowed like backlog entry
244
- // mode. The cache is set on view entry by the Ctrl+T handler, so this only re-windows a string.
245
- const win = entryBodyWindow(state.paneCache ?? "", state.paneScroll, eventBudget, width);
246
- return {
247
- body: win.lines.map((l) => toneLine(l, width, l.startsWith("#") ? "bold" : undefined)),
248
- emptyNote: "(no events yet)",
249
- };
253
+ // An applied search names itself above the list; one that matched nothing says so instead
254
+ // of claiming the inbox is empty.
255
+ const searchLine = pane.grep ? [toneLine(`search: "${pane.grep}"`, width, "bold")] : [];
256
+ if (pane.grep && state.queuedRows.length === 0) {
257
+ return { body: [...searchLine, toneLine(`(no queued prompt matches "${pane.grep}")`, width)], emptyNote: "(inbox empty)" };
250
258
  }
259
+ const listBudget = Math.max(1, eventBudget - searchLine.length);
260
+ const win = state.paneCache ? entryBodyWindow(state.paneCache, state.paneScroll, listBudget, width) : { lines: [] };
261
+ return {
262
+ body: [...searchLine, ...win.lines.map((l) => toneLine(l, width))],
263
+ emptyNote: "(inbox empty)",
264
+ };
265
+ }
266
+ function reportBody(_pane, opts) {
267
+ const { width, eventBudget, state } = opts;
268
+ // The Markdown `tumwater report`/`report --failures` prints, windowed like backlog entry
269
+ // mode. The cache is set on view entry by the Ctrl+T handler, so this only re-windows a string.
270
+ const win = entryBodyWindow(state.paneCache ?? "", state.paneScroll, eventBudget, width);
271
+ return {
272
+ body: win.lines.map((l) => toneLine(l, width, l.startsWith("#") ? "bold" : undefined)),
273
+ emptyNote: "(no events yet)",
274
+ };
275
+ }
276
+ function activityBody(pane, opts) {
277
+ const { root, width, eventBudget, state } = opts;
251
278
  // The Activity pane loads a fixed tail once per read, narrows it by the active role scope
252
279
  // and text search, and windows it: null follows the live tail (the newest eventBudget
253
280
  // events), a stored offset pages back through the loaded events. Both clamp to
@@ -176,9 +176,8 @@ export function createTuiPanes(deps) {
176
176
  if (key.ctrl && key.name === "p" && (session.view === session.roleIds.length + 2 || session.view === session.roleIds.length + 3)) {
177
177
  return report.handleKey?.(key, ctx) ?? false;
178
178
  }
179
- // Health owns Ctrl+P (re-probe image-input models) ahead of History's fallback.
180
- if (key.ctrl && key.name === "p" && session.view === session.roleIds.length + 7)
181
- return health.handleKey?.(key, ctx) ?? false;
179
+ // Health owns none of the Ctrl+P cycle now that the image-model re-probe is gone; it
180
+ // falls through to History's Ctrl+P below.
182
181
  if (key.ctrl && key.name === "p")
183
182
  return history.handleKey?.(key, ctx) ?? false;
184
183
  return false;
@@ -2,8 +2,8 @@ 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, stalledEntries } from "../../backlog/backlog-eligibility.js";
6
- import { capacityContention, droppedPostReviewsAlert, flappedChecksAlert, instancePoolProblems, mainRuleFailures, notifyFailures, planDependencyAlert, postMergeChurnAlert, postReviewRiskAlert, primaryOverwriteBlocks, providerCapProblems, qaCoverageAlert, quarantineAlert, regressedFixesAlert, skippedMergeFiles, staleMerges, staleOpenBugs, staleOpenQuestions, stalePrompts, staleWaitingBugs, staleWorkspacePins } from "../alert-feeds.js";
5
+ import { refusedEntries, stalledEntries } from "../../backlog/backlog-eligibility.js";
6
+ import { capacityContention, instancePoolProblems, planDependencyAlert, providerCapProblems, regressedFixesAlert, staleOpenQuestions, staleWorkspacePins } from "../alert-feeds.js";
7
7
  import { strandedPlanEntriesFor } from "../../backlog/backlog-structure.js";
8
8
  import { duplicatedBacklogHeadings, fixClaimProblems, liveFinishedEntriesFor, validationGapProblems } from "../../doctor/doctor-backlog-plans.js";
9
9
  import { snapshot } from "../../status/status-data.js";
@@ -93,7 +93,7 @@ export async function runTui(root, seams = {}) {
93
93
  const view = s.view;
94
94
  // What needs the operator — the dashboard's alert banners (team-alerts.ts's teamAlerts), as
95
95
  // attention lines under the header. Each consumes one line of the height budget.
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), instancePoolProblems(root), providerCapProblems(root)), width);
96
+ const attention = alertLines(teamAlerts(snap, openQuestions(root), status.roles, Date.now(), refusedEntries(root), planDependencyAlert(root), stalledEntries(root), strandedPlanEntriesFor(root), duplicatedBacklogHeadings(root), validationGapProblems(root), capacityContention(root), regressedFixesAlert(root, Date.now()), staleClaimEntries(root, status.roles.map((r) => r.role), Date.now()), staleOpenQuestions(root), fixClaimProblems(root), staleWorkspacePins(root, Date.now()), liveFinishedEntriesFor(root), instancePoolProblems(root), providerCapProblems(root)), width);
97
97
  const statusLines = [status.lines[0] ?? [], ...attention, ...status.lines.slice(1)];
98
98
  // Numbered previews of prompts queued for the director, between the table and the
99
99
  // activity pane: what will run next, in execution order. Each line consumes exactly
@@ -43,7 +43,7 @@
43
43
  * - A `path:line` location (`status-roles.ts:66`) was checked as a symbol, and no file is named
44
44
  * that. A location now claims its file, a bare line reference (`:368`) claims nothing, and a
45
45
  * bare file name is backed by a file of that name anywhere in the walked source dirs. */
46
- import { existsSync, readFileSync, statSync } from "node:fs";
46
+ import { existsSync, lstatSync, readFileSync, statSync } from "node:fs";
47
47
  import { extname, join } from "node:path";
48
48
  import { readTextOrNull, statOrNull, walkFiles } from "../files/files.js";
49
49
  import { fenceTracker, finishedArchiveOf, parseEntryDetails, sectionLines, trailingParenthetical, } from "../backlog/backlog-md.js";
@@ -307,11 +307,23 @@ export async function falseFixReason(wt, mainBranch, files) {
307
307
  // is no claim at all. Both sides of the comparison read both files.
308
308
  if (!files.includes("BUGS.md") && !files.includes(BUGS_ARCHIVE))
309
309
  return undefined;
310
- // A deleted or unreadable file reads as empty: there is no Fixed transition to verify,
311
- // so this check passes and lets the gate's sibling structural check (backlog-structure.ts)
312
- // report the dropped section. Reading it raw would throw ENOENT out of the gate instead —
313
- // and a file the change deletes is exactly the input the sibling check already handles.
314
- const heads = ["BUGS.md", BUGS_ARCHIVE].map((f) => readTextOrNull(join(wt, f)) ?? "");
310
+ // A deleted, unreadable, or non-regular (symlinked) file reads as empty: there is no Fixed
311
+ // transition to verify, so this check passes and lets the gate's sibling structural check
312
+ // (backlog-structure.ts) report the dropped section. Reading a symlink raw would pull an
313
+ // outside host file's `## Fixed` heading into this gate's verdict (the same lstat policy the
314
+ // backlog-conflict resolver and backlogStructureReason pin under INV-G3), so a head is read
315
+ // only when lstat shows a regular file; ENOENT and symlinks both read as empty.
316
+ const heads = ["BUGS.md", BUGS_ARCHIVE].map((f) => {
317
+ const full = join(wt, f);
318
+ try {
319
+ if (!lstatSync(full).isFile())
320
+ return "";
321
+ }
322
+ catch {
323
+ return "";
324
+ }
325
+ return readTextOrNull(full) ?? "";
326
+ });
315
327
  if (!heads.some((head) => head.includes("## Fixed")))
316
328
  return undefined;
317
329
  // The diff's own base, not main's tip: the gate diffs `mainBranch...HEAD` against the
@@ -88,6 +88,15 @@ export function newlyRefusedTitles(baseMd, headMd, section) {
88
88
  * logs a warning and leaves PLANS.md out of the commit, so the other notes still merge. */
89
89
  export async function handleRefusal(ctx, state, wt, pi) {
90
90
  const reason = (pi.refusedReason ?? "").trim() || "no reason given";
91
+ const plansUnstripped = await stripBlockedPlanRefusalNotes(ctx, wt);
92
+ const notes = await collectRefusalNotes(wt, plansUnstripped);
93
+ const entries = await collectRefusedEntryTitles(ctx, wt, notes);
94
+ return mergeRefusalNotes(ctx, state, wt, reason, notes, entries);
95
+ }
96
+ /** Strip this run's newly added Refused notes from any `## Planned` entry main still holds as
97
+ * blocked by a planned prerequisite. Returns whether the strip write failed: a throw leaves the
98
+ * flag set and PLANS.md out of the commit, so the other notes still merge. */
99
+ async function stripBlockedPlanRefusalNotes(ctx, wt) {
91
100
  const plansFile = path.join(wt, "PLANS.md");
92
101
  let plansUnstripped = false;
93
102
  if (fs.existsSync(plansFile)) {
@@ -118,9 +127,17 @@ export async function handleRefusal(ctx, state, wt, pi) {
118
127
  }
119
128
  }
120
129
  }
121
- const notes = (await changedFiles(wt)).filter((f) => f.toLowerCase().endsWith(".md") && !(plansUnstripped && f === "PLANS.md"));
130
+ return plansUnstripped;
131
+ }
132
+ /** The markdown notes this refusal may commit: changed `.md` files, PLANS.md dropped only when
133
+ * the blocked-plan strip write failed. */
134
+ async function collectRefusalNotes(wt, plansUnstripped) {
135
+ return (await changedFiles(wt)).filter((f) => f.toLowerCase().endsWith(".md") && !(plansUnstripped && f === "PLANS.md"));
136
+ }
137
+ /** Best-effort names for the entry_refused event, per changed file's section — it must never
138
+ * fail the refusal, so a read error swallows and leaves that file unnamed. */
139
+ async function collectRefusedEntryTitles(ctx, wt, notes) {
122
140
  const entries = [];
123
- // Best-effort naming for the entry_refused event; it must never fail the refusal.
124
141
  for (const { file, section } of [{ file: "PLANS.md", section: "Planned" }, { file: "BUGS.md", section: "Open" }]) {
125
142
  if (!notes.includes(file))
126
143
  continue;
@@ -131,10 +148,19 @@ export async function handleRefusal(ctx, state, wt, pi) {
131
148
  // Unnamed is fine: the event still carries reason and files.
132
149
  }
133
150
  }
151
+ return entries;
152
+ }
153
+ /** Merge this refusal's notes onto main: commit only the markdown notes (subject + trailer only —
154
+ * a refusal carries no WHY/RISK/VERIFIED body; the reason is the subject, and the trailer's turn
155
+ * count is the same field the friction flag reads), then merge directly. A failed note merge (a
156
+ * throw such as a lock timeout, or a non-"changed" result) is recorded in `state.lastError` and
157
+ * reported through the "refused" result like any other non-merge. A merged refusal note supersedes
158
+ * a stale rejection exactly as applyMergeOutcome's merged branch does (this path merges directly,
159
+ * bypassing that fold), then pages the operator: the entry stays held until a human or the
160
+ * director lifts it. */
161
+ async function mergeRefusalNotes(ctx, state, wt, reason, notes, entries) {
134
162
  let commit;
135
163
  if (notes.length > 0) {
136
- // Subject + trailer only — a refusal carries no WHY/RISK/VERIFIED body; the reason is
137
- // the subject, and the trailer's turn count is the same field the friction flag reads.
138
164
  const message = buildCommitMessage(stampedSubject(baseRoleOf(ctx.role), `refuse — ${reason}`), null, commitTrailer(ctx.role, state.runs, ctx.turns, state.peakContextTokens, undefined, undefined, ctx.modelLine));
139
165
  commit = (await commitPathsAndDiscardRest(wt, message, notes)) ?? undefined;
140
166
  }
@@ -156,8 +182,6 @@ export async function handleRefusal(ctx, state, wt, pi) {
156
182
  }
157
183
  if (result !== "changed")
158
184
  state.lastError = `refusal note merge failed: ${result}`;
159
- // A merged refusal note is a merged change too: it supersedes a stale rejection exactly as
160
- // applyMergeOutcome's merged branch does (this path merges directly, bypassing that fold).
161
185
  else {
162
186
  clearSupersededRejection(state);
163
187
  // Page the operator: the entry stays held until a human or the director lifts it. Best-effort —
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tumwater",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
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",