@humanbased/crosscheck 1.3.1-beta.7 → 1.6.0-beta.11

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 (165) hide show
  1. package/README.md +16 -1
  2. package/assets/demo-block-to-approve.gif +0 -0
  3. package/assets/demo-block-to-approve.mp4 +0 -0
  4. package/dist/__tests__/base-ref-recovery.test.d.ts +2 -0
  5. package/dist/__tests__/base-ref-recovery.test.d.ts.map +1 -0
  6. package/dist/__tests__/base-ref-recovery.test.js +102 -0
  7. package/dist/__tests__/base-ref-recovery.test.js.map +1 -0
  8. package/dist/__tests__/board.test.js +510 -20
  9. package/dist/__tests__/board.test.js.map +1 -1
  10. package/dist/__tests__/codex-env.test.js +4 -0
  11. package/dist/__tests__/codex-env.test.js.map +1 -1
  12. package/dist/__tests__/credential-free-origin.test.js +134 -2
  13. package/dist/__tests__/credential-free-origin.test.js.map +1 -1
  14. package/dist/__tests__/demo-arc.test.d.ts +2 -0
  15. package/dist/__tests__/demo-arc.test.d.ts.map +1 -0
  16. package/dist/__tests__/demo-arc.test.js +73 -0
  17. package/dist/__tests__/demo-arc.test.js.map +1 -0
  18. package/dist/__tests__/doc-only-verdict.test.d.ts +2 -0
  19. package/dist/__tests__/doc-only-verdict.test.d.ts.map +1 -0
  20. package/dist/__tests__/doc-only-verdict.test.js +79 -0
  21. package/dist/__tests__/doc-only-verdict.test.js.map +1 -0
  22. package/dist/__tests__/error-classification.test.js +14 -0
  23. package/dist/__tests__/error-classification.test.js.map +1 -1
  24. package/dist/__tests__/fix-requires-recheck.test.d.ts +2 -0
  25. package/dist/__tests__/fix-requires-recheck.test.d.ts.map +1 -0
  26. package/dist/__tests__/fix-requires-recheck.test.js +56 -0
  27. package/dist/__tests__/fix-requires-recheck.test.js.map +1 -0
  28. package/dist/__tests__/human-feedback.test.d.ts +2 -0
  29. package/dist/__tests__/human-feedback.test.d.ts.map +1 -0
  30. package/dist/__tests__/human-feedback.test.js +132 -0
  31. package/dist/__tests__/human-feedback.test.js.map +1 -0
  32. package/dist/__tests__/inconclusive-review.test.d.ts +2 -0
  33. package/dist/__tests__/inconclusive-review.test.d.ts.map +1 -0
  34. package/dist/__tests__/inconclusive-review.test.js +110 -0
  35. package/dist/__tests__/inconclusive-review.test.js.map +1 -0
  36. package/dist/__tests__/lock-ownership.test.d.ts +2 -0
  37. package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
  38. package/dist/__tests__/lock-ownership.test.js +167 -0
  39. package/dist/__tests__/lock-ownership.test.js.map +1 -0
  40. package/dist/__tests__/merge-gate.test.d.ts +2 -0
  41. package/dist/__tests__/merge-gate.test.d.ts.map +1 -0
  42. package/dist/__tests__/merge-gate.test.js +220 -0
  43. package/dist/__tests__/merge-gate.test.js.map +1 -0
  44. package/dist/__tests__/no-verdict.test.d.ts +2 -0
  45. package/dist/__tests__/no-verdict.test.d.ts.map +1 -0
  46. package/dist/__tests__/no-verdict.test.js +415 -0
  47. package/dist/__tests__/no-verdict.test.js.map +1 -0
  48. package/dist/__tests__/pr-spec.test.js +37 -0
  49. package/dist/__tests__/pr-spec.test.js.map +1 -1
  50. package/dist/__tests__/run.test.js +50 -1
  51. package/dist/__tests__/run.test.js.map +1 -1
  52. package/dist/__tests__/runner.test.js +82 -1
  53. package/dist/__tests__/runner.test.js.map +1 -1
  54. package/dist/__tests__/standing-verdict-history.test.d.ts +2 -0
  55. package/dist/__tests__/standing-verdict-history.test.d.ts.map +1 -0
  56. package/dist/__tests__/standing-verdict-history.test.js +109 -0
  57. package/dist/__tests__/standing-verdict-history.test.js.map +1 -0
  58. package/dist/__tests__/step-plan.test.d.ts +2 -0
  59. package/dist/__tests__/step-plan.test.d.ts.map +1 -0
  60. package/dist/__tests__/step-plan.test.js +129 -0
  61. package/dist/__tests__/step-plan.test.js.map +1 -0
  62. package/dist/cli.js +14 -1
  63. package/dist/cli.js.map +1 -1
  64. package/dist/commands/merge.d.ts +11 -0
  65. package/dist/commands/merge.d.ts.map +1 -0
  66. package/dist/commands/merge.js +188 -0
  67. package/dist/commands/merge.js.map +1 -0
  68. package/dist/commands/review.d.ts +3 -1
  69. package/dist/commands/review.d.ts.map +1 -1
  70. package/dist/commands/review.js +117 -9
  71. package/dist/commands/review.js.map +1 -1
  72. package/dist/commands/run.d.ts +53 -0
  73. package/dist/commands/run.d.ts.map +1 -1
  74. package/dist/commands/run.js +218 -36
  75. package/dist/commands/run.js.map +1 -1
  76. package/dist/commands/watch.d.ts.map +1 -1
  77. package/dist/commands/watch.js +41 -26
  78. package/dist/commands/watch.js.map +1 -1
  79. package/dist/config/loader.d.ts +4 -0
  80. package/dist/config/loader.d.ts.map +1 -1
  81. package/dist/config/loader.js +8 -0
  82. package/dist/config/loader.js.map +1 -1
  83. package/dist/config/review-strategy.json +5 -5
  84. package/dist/github/client.d.ts +18 -1
  85. package/dist/github/client.d.ts.map +1 -1
  86. package/dist/github/client.js +19 -3
  87. package/dist/github/client.js.map +1 -1
  88. package/dist/github/merge.d.ts +28 -0
  89. package/dist/github/merge.d.ts.map +1 -1
  90. package/dist/github/merge.js +41 -0
  91. package/dist/github/merge.js.map +1 -1
  92. package/dist/github/review-status.d.ts +33 -0
  93. package/dist/github/review-status.d.ts.map +1 -1
  94. package/dist/github/review-status.js +94 -9
  95. package/dist/github/review-status.js.map +1 -1
  96. package/dist/github/webhook.d.ts +1 -0
  97. package/dist/github/webhook.d.ts.map +1 -1
  98. package/dist/github/webhook.js.map +1 -1
  99. package/dist/lib/board.d.ts +60 -4
  100. package/dist/lib/board.d.ts.map +1 -1
  101. package/dist/lib/board.js +357 -65
  102. package/dist/lib/board.js.map +1 -1
  103. package/dist/lib/clone.d.ts +65 -1
  104. package/dist/lib/clone.d.ts.map +1 -1
  105. package/dist/lib/clone.js +252 -16
  106. package/dist/lib/clone.js.map +1 -1
  107. package/dist/lib/human-feedback.d.ts +9 -0
  108. package/dist/lib/human-feedback.d.ts.map +1 -0
  109. package/dist/lib/human-feedback.js +83 -0
  110. package/dist/lib/human-feedback.js.map +1 -0
  111. package/dist/lib/logger.d.ts +1 -1
  112. package/dist/lib/logger.d.ts.map +1 -1
  113. package/dist/lib/logger.js +6 -0
  114. package/dist/lib/logger.js.map +1 -1
  115. package/dist/lib/merge-gate.d.ts +70 -0
  116. package/dist/lib/merge-gate.d.ts.map +1 -0
  117. package/dist/lib/merge-gate.js +138 -0
  118. package/dist/lib/merge-gate.js.map +1 -0
  119. package/dist/lib/no-verdict.d.ts +104 -0
  120. package/dist/lib/no-verdict.d.ts.map +1 -0
  121. package/dist/lib/no-verdict.js +262 -0
  122. package/dist/lib/no-verdict.js.map +1 -0
  123. package/dist/lib/pr-lock.d.ts +37 -1
  124. package/dist/lib/pr-lock.d.ts.map +1 -1
  125. package/dist/lib/pr-lock.js +176 -29
  126. package/dist/lib/pr-lock.js.map +1 -1
  127. package/dist/lib/pr-spec.d.ts +7 -0
  128. package/dist/lib/pr-spec.d.ts.map +1 -1
  129. package/dist/lib/pr-spec.js +12 -1
  130. package/dist/lib/pr-spec.js.map +1 -1
  131. package/dist/lib/pr-workflow-state.d.ts +39 -0
  132. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  133. package/dist/lib/pr-workflow-state.js +73 -1
  134. package/dist/lib/pr-workflow-state.js.map +1 -1
  135. package/dist/lib/review-strategy.d.ts +14 -0
  136. package/dist/lib/review-strategy.d.ts.map +1 -1
  137. package/dist/lib/review-strategy.js +32 -1
  138. package/dist/lib/review-strategy.js.map +1 -1
  139. package/dist/lib/runner.d.ts +51 -2
  140. package/dist/lib/runner.d.ts.map +1 -1
  141. package/dist/lib/runner.js +211 -105
  142. package/dist/lib/runner.js.map +1 -1
  143. package/dist/lib/tips.d.ts.map +1 -1
  144. package/dist/lib/tips.js +1 -0
  145. package/dist/lib/tips.js.map +1 -1
  146. package/dist/lib/verdict.d.ts +30 -1
  147. package/dist/lib/verdict.d.ts.map +1 -1
  148. package/dist/lib/verdict.js +94 -9
  149. package/dist/lib/verdict.js.map +1 -1
  150. package/dist/reviewers/codex-env.d.ts.map +1 -1
  151. package/dist/reviewers/codex-env.js +2 -0
  152. package/dist/reviewers/codex-env.js.map +1 -1
  153. package/dist/reviewers/codex.d.ts.map +1 -1
  154. package/dist/reviewers/codex.js +3 -1
  155. package/dist/reviewers/codex.js.map +1 -1
  156. package/dist/reviewers/fix.d.ts +2 -2
  157. package/dist/reviewers/fix.d.ts.map +1 -1
  158. package/dist/reviewers/fix.js +10 -4
  159. package/dist/reviewers/fix.js.map +1 -1
  160. package/docs/growth/launch/README.md +59 -0
  161. package/docs/growth/launch/essay-stop-letting-one-agent-review-its-own-code.md +118 -0
  162. package/docs/growth/launch/posts.md +175 -0
  163. package/docs/trust.md +16 -1
  164. package/get-started.md +17 -0
  165. package/package.json +11 -3
package/dist/lib/board.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import chalk from 'chalk';
2
2
  import { selectTip } from './tips.js';
3
+ const OUTCOME_ORDER = ['APPROVE', 'NEEDS WORK', 'BLOCK', 'no verdict', 'skipped', 'error'];
3
4
  // ── Constants ─────────────────────────────────────────────────────────────────
4
5
  const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5
6
  const BAR_FILLED = '█';
@@ -27,6 +28,44 @@ function fmtDuration(ms) {
27
28
  function fmtStartTime(epochMs) {
28
29
  return new Date(epochMs).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
29
30
  }
31
+ // Session age in hours and minutes: "3h32m", or "12m" under the hour. Deliberately
32
+ // not fmtDuration: that renders MM:SS shaped output under an hour, which reads as
33
+ // hours:minutes on a long-running watch and understates the session by 60x.
34
+ export function fmtUptime(ms) {
35
+ const totalMin = Math.max(0, Math.floor(ms / 60_000));
36
+ const h = Math.floor(totalMin / 60);
37
+ const m = totalMin % 60;
38
+ return h > 0 ? `${h}h${String(m).padStart(2, '0')}m` : `${m}m`;
39
+ }
40
+ /**
41
+ * Whole percentages summing to exactly 100, by the largest-remainder method.
42
+ * Plain rounding drifts — three equal shares render as 33/33/33 — and a
43
+ * distribution that visibly fails to add up reads as a bug in the numbers.
44
+ *
45
+ * A non-zero count too small to earn a point comes back as 0; the caller
46
+ * renders those as "<1%" rather than claiming the outcome never happened.
47
+ */
48
+ export function distribute(counts) {
49
+ const total = counts.reduce((a, b) => a + b, 0);
50
+ if (total === 0)
51
+ return counts.map(() => 0);
52
+ const exact = counts.map(c => (c * 100) / total);
53
+ const out = exact.map(Math.floor);
54
+ // Exact shares sum to 100, so the floors leave fewer whole points than there
55
+ // are entries: one pass in remainder order always places every last one.
56
+ let remaining = 100 - out.reduce((a, b) => a + b, 0);
57
+ const byRemainder = exact
58
+ .map((e, i) => ({ i, rem: e - Math.floor(e) }))
59
+ .filter(({ i }) => counts[i] > 0)
60
+ .sort((a, b) => b.rem - a.rem);
61
+ for (const { i } of byRemainder) {
62
+ if (remaining <= 0)
63
+ break;
64
+ out[i]++;
65
+ remaining--;
66
+ }
67
+ return out;
68
+ }
30
69
  // Format token count as a compact suffix: "(900)", "(1.2K)", "(1.5M)". Returns '' when undefined.
31
70
  export function fmtTokens(n) {
32
71
  if (n == null)
@@ -38,7 +77,7 @@ export function fmtTokens(n) {
38
77
  return `(${(n / 1_000_000).toFixed(1).replace(/\.0$/, '')}M)`;
39
78
  }
40
79
  // Raw token count without surrounding parens: "900", "1.2K", "1.5M". Returns '' when undefined.
41
- function fmtTokensRaw(n) {
80
+ export function fmtTokensRaw(n) {
42
81
  if (n == null)
43
82
  return '';
44
83
  if (n < 1_000)
@@ -55,6 +94,104 @@ function stripAnsi(s) {
55
94
  function truncate(s, max) {
56
95
  return s.length <= max ? s : s.slice(0, max - 1) + '…';
57
96
  }
97
+ // Cut a rendered line to `max` visible columns, keeping the ANSI sequences it
98
+ // passes over (they have no width) and closing with a reset so a cut mid-colour
99
+ // cannot bleed into the next line. Folded rows are clamped with this so they
100
+ // always occupy exactly one terminal row — history pagination counts on it.
101
+ function truncateVisible(s, max) {
102
+ if (max <= 0)
103
+ return '';
104
+ if (stripAnsi(s).length <= max)
105
+ return s;
106
+ let out = '';
107
+ let width = 0;
108
+ for (let i = 0; i < s.length; i++) {
109
+ const ch = s[i];
110
+ if (ch === '\x1B') {
111
+ const end = s.indexOf('m', i);
112
+ if (end === -1)
113
+ break;
114
+ out += s.slice(i, end + 1);
115
+ i = end;
116
+ continue;
117
+ }
118
+ if (width >= max - 1)
119
+ break;
120
+ out += ch;
121
+ width++;
122
+ }
123
+ return out + '…\x1B[0m';
124
+ }
125
+ // Lines the runner hands to its log callback are prefixed ⚠ or ✗ when they
126
+ // report something an operator must act on. Everything else is progress
127
+ // narration that the board's own PR rows already carry, and a multi-line
128
+ // message is a dump (an unposted review, a dry-run comment) that always
129
+ // follows one of those notices. Only these earn a line of terminal scrollback
130
+ // beside the board; the rest goes to the file log.
131
+ // eslint-disable-next-line no-control-regex -- matching the ESC byte is the point
132
+ const NOTICE_PREFIX = /^(?:\x1B\[[0-9;]*m)*\s*[⚠✗]/;
133
+ export function isNoticeLine(msg) {
134
+ return NOTICE_PREFIX.test(msg) || msg.includes('\n');
135
+ }
136
+ // Bare `<`/`>` (and their unshifted `,`/`.`) always work. Terminals cannot agree
137
+ // on how to report ctrl/cmd with a punctuation key, so every encoding they do
138
+ // emit is accepted: meta-prefixed, CSI-u (kitty / xterm modifyOtherKeys) and
139
+ // modified arrows. macOS Terminal swallows cmd entirely — there the bare keys,
140
+ // or ctrl+arrow, are the way through.
141
+ const PAGE_OLDER_KEYS = new Set(['<', ',', '\u001b<', '\u001b,', '\u001b[D', '\u001b[5~']);
142
+ const PAGE_NEWER_KEYS = new Set(['>', '.', '\u001b>', '\u001b.', '\u001b[C', '\u001b[6~']);
143
+ // eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
144
+ const CSI_U = /^\u001b\[(\d+);\d+u$/; // ESC [ <codepoint> ; <modifiers> u
145
+ // eslint-disable-next-line no-control-regex -- the ESC byte is the sequence
146
+ const CSI_ARROW = /^\u001b\[1;\d+([DC])$/; // ESC [ 1 ; <modifiers> D|C
147
+ /** Map a raw stdin key sequence to a page direction, or null when it is not a page key. */
148
+ export function pageKeyAction(seq) {
149
+ if (PAGE_OLDER_KEYS.has(seq))
150
+ return 'older';
151
+ if (PAGE_NEWER_KEYS.has(seq))
152
+ return 'newer';
153
+ const csiU = CSI_U.exec(seq);
154
+ if (csiU) {
155
+ const codepoint = Number(csiU[1]);
156
+ if (codepoint === 44 || codepoint === 60)
157
+ return 'older'; // , <
158
+ if (codepoint === 46 || codepoint === 62)
159
+ return 'newer'; // . >
160
+ return null;
161
+ }
162
+ const arrow = CSI_ARROW.exec(seq);
163
+ if (arrow)
164
+ return arrow[1] === 'D' ? 'older' : 'newer';
165
+ return null;
166
+ }
167
+ // Keep every active slot plus the newest `budget` settled ones. Anything older
168
+ // cannot fit the live page (one row minimum per slot), so the fitting loop need
169
+ // never consider it.
170
+ function trimToBudget(pool, budget) {
171
+ const settled = pool.filter(s => s.completedAt !== undefined);
172
+ if (settled.length <= budget)
173
+ return pool.slice();
174
+ const keep = new Set(settled.slice(settled.length - budget));
175
+ return pool.filter(s => s.completedAt === undefined || keep.has(s));
176
+ }
177
+ /**
178
+ * The one outcome a settled slot counts toward. A recheck verdict supersedes
179
+ * the review verdict — it is the later word on the same PR — and `null` from
180
+ * either step means a reviewer ran but returned nothing parseable, which is a
181
+ * different event from never having run at all.
182
+ */
183
+ function outcomeOf(slot) {
184
+ if (slot.error !== undefined)
185
+ return 'error';
186
+ const final = typeof slot.recheckVerdict === 'string'
187
+ ? slot.recheckVerdict
188
+ : typeof slot.verdict === 'string' ? slot.verdict : null;
189
+ if (final === 'APPROVE' || final === 'NEEDS WORK' || final === 'BLOCK')
190
+ return final;
191
+ if (slot.recheckVerdict === null || slot.verdict === null)
192
+ return 'no verdict';
193
+ return 'skipped';
194
+ }
58
195
  function makeBar(filled, total, fillFn, emptyFn) {
59
196
  const f = Math.max(0, Math.min(total, Math.round(filled)));
60
197
  return fillFn(BAR_FILLED.repeat(f)) + emptyFn(BAR_EMPTY.repeat(total - f));
@@ -154,7 +291,7 @@ function buildTheme(cfg) {
154
291
  const CONN_LOG_MAX = 6; // max connectivity log lines kept in memory
155
292
  const COMPACT_CONN_LOG_LINES = 2; // conn log lines shown when the live block must shrink to fit the viewport
156
293
  const FOLD_THRESHOLD = 3; // when completed count exceeds this, fold all completed PRs to 1 line
157
- const WORKSPACE_MAX = 25; // when total slots exceed this, evict oldest completed to scrollback
294
+ const HISTORY_MAX = 2000; // hard cap on retained slots oldest settled rows drop out beyond it
158
295
  const LAYOUT_NORMAL = { foldAll: false, connLogLines: CONN_LOG_MAX, showTip: true };
159
296
  const LAYOUT_COMPACT = { foldAll: true, connLogLines: COMPACT_CONN_LOG_LINES, showTip: false };
160
297
  export class PRBoard {
@@ -165,6 +302,10 @@ export class PRBoard {
165
302
  liveContent = '';
166
303
  isTTY = Boolean(process.stdout.isTTY);
167
304
  connLog = [];
305
+ page = 0; // 0 = live page (newest); higher = further back in history
306
+ pageCount = 1; // recomputed every render; page keys clamp against it
307
+ keyHandler = null;
308
+ stdinWasRaw = false;
168
309
  stats = {
169
310
  prsReceived: 0,
170
311
  crsCompleted: 0,
@@ -172,6 +313,7 @@ export class PRBoard {
172
313
  errorsOccurred: 0,
173
314
  crTotalMs: 0,
174
315
  sessionStart: Date.now(),
316
+ outcomes: { 'APPROVE': 0, 'NEEDS WORK': 0, 'BLOCK': 0, 'no verdict': 0, 'skipped': 0, 'error': 0 },
175
317
  };
176
318
  tunnel = {
177
319
  type: 'none', url: null, alive: false,
@@ -195,6 +337,7 @@ export class PRBoard {
195
337
  start() {
196
338
  if (!this.isTTY)
197
339
  return;
340
+ this.attachKeys();
198
341
  this.timer = setInterval(() => {
199
342
  this.frameIdx = (this.frameIdx + 1) % FRAMES.length;
200
343
  this.redraw();
@@ -205,18 +348,34 @@ export class PRBoard {
205
348
  clearInterval(this.timer);
206
349
  this.timer = null;
207
350
  }
351
+ this.detachKeys();
208
352
  this.eraseLive();
209
353
  }
354
+ /** Flip one page toward older history. No-op when already at the oldest page. */
355
+ pageOlder() {
356
+ const next = Math.min(this.page + 1, Math.max(0, this.pageCount - 1));
357
+ if (next === this.page)
358
+ return;
359
+ this.page = next;
360
+ this.redraw();
361
+ }
362
+ /** Flip one page toward the live page. No-op when already on it. */
363
+ pageNewer() {
364
+ if (this.page === 0)
365
+ return;
366
+ this.page--;
367
+ this.redraw();
368
+ }
210
369
  addPR(key, prNumber, repo, branch, round) {
211
370
  // When a new round starts for a PR that already has a completed slot in the
212
- // workspace, evict the prior-round slot to scrollback so only the current
213
- // round is shown. Prior-round slots always have a different key (different
214
- // SHA suffix) but the same prNumber + repo combination.
371
+ // workspace, mark the prior-round slot superseded so only the current round
372
+ // shows on the live page. It stays in the map: history pages still carry it.
373
+ // Prior-round slots always have a different key (different SHA suffix) but
374
+ // the same prNumber + repo combination.
215
375
  if ((round ?? 1) >= 2) {
216
376
  for (const [existingKey, slot] of this.slots) {
217
377
  if (existingKey !== key && slot.prNumber === prNumber && slot.repo === repo && slot.completedAt !== undefined) {
218
- this.printStatic(this.renderPRSlotFolded(slot));
219
- this.slots.delete(existingKey);
378
+ slot.superseded = true;
220
379
  }
221
380
  }
222
381
  }
@@ -271,19 +430,32 @@ export class PRBoard {
271
430
  }
272
431
  if (fixCount !== undefined && fixCount > 0)
273
432
  this.stats.fixesApplied++;
433
+ this.stats.outcomes[outcomeOf(slot)]++;
274
434
  // Non-TTY has no live block to re-render — emit the folded line to scrollback and drop the slot.
275
435
  if (!this.isTTY) {
276
436
  process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
277
437
  this.slots.delete(key);
278
438
  }
279
439
  }
440
+ /**
441
+ * Settle a slot that ended in an error. The slot stays in the workspace as a
442
+ * settled row, the same as a completed one: a failed run is a session record,
443
+ * and deleting it here was what pushed reviewer timeouts out of the table and
444
+ * into raw scrollback, where a long watch session showed 43 errors above an
445
+ * empty workspace reading "no PRs yet".
446
+ */
280
447
  failPR(key, error) {
281
448
  const slot = this.slots.get(key);
282
- this.slots.delete(key);
283
449
  this.stats.errorsOccurred++;
284
- if (slot) {
285
- const ts = fmtTime();
286
- this.printStatic(`${chalk.dim(ts)} PR #${slot.prNumber} ${chalk.red('✗')} ${error}`);
450
+ if (!slot || slot.completedAt !== undefined)
451
+ return;
452
+ slot.completedAt = Date.now();
453
+ slot.error = error;
454
+ slot.label = 'failed';
455
+ this.stats.outcomes.error++;
456
+ if (!this.isTTY) {
457
+ process.stdout.write(this.renderPRSlotFolded(slot) + '\n');
458
+ this.slots.delete(key);
287
459
  }
288
460
  }
289
461
  /** Print 1–2 static lines to scrollback (above the live block). */
@@ -353,14 +525,43 @@ export class PRBoard {
353
525
  return t.barCRNeedsWork;
354
526
  }
355
527
  // ── Private: render ────────────────────────────────────────────────────────
356
- uptime() {
357
- const totalSec = Math.floor((Date.now() - this.stats.sessionStart) / 1000);
358
- const h = Math.floor(totalSec / 3600);
359
- const m = Math.floor((totalSec % 3600) / 60);
360
- const s = totalSec % 60;
361
- if (h > 0)
362
- return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`;
363
- return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
528
+ /** "since 08:25 PM · up 3h32m" — when watch started, and how long it has run. */
529
+ sessionRow() {
530
+ const t = this.theme;
531
+ const started = fmtStartTime(this.stats.sessionStart);
532
+ const age = fmtUptime(Date.now() - this.stats.sessionStart);
533
+ return `${t.dim('since')} ${started} ${t.dim('·')} ${t.dim('up')} ${age}`;
534
+ }
535
+ /**
536
+ * Outcome shares across every PR that settled this session, e.g.
537
+ * "APPROVE 45% · BLOCK 30% · error 25%". Empty when nothing has settled yet.
538
+ */
539
+ outcomeRow() {
540
+ const t = this.theme;
541
+ const counts = OUTCOME_ORDER.map(o => this.stats.outcomes[o]);
542
+ const total = counts.reduce((a, b) => a + b, 0);
543
+ if (total === 0)
544
+ return '';
545
+ const pcts = distribute(counts);
546
+ const mixed = counts.filter(c => c > 0).length > 1;
547
+ const parts = OUTCOME_ORDER.map((outcome, i) => {
548
+ if (counts[i] === 0)
549
+ return null;
550
+ // A share too small to earn a whole point still happened; "<1%" says so
551
+ // where "0%" would read as never. Its complement is then not a whole 100
552
+ // either — "error 100%" beside "APPROVE <1%" contradicts itself — so the
553
+ // rounded-up bucket reads ">99%" whenever some other outcome is non-zero.
554
+ const pct = pcts[i] === 0 ? '<1%'
555
+ : pcts[i] === 100 && mixed ? '>99%'
556
+ : `${pcts[i]}%`;
557
+ const paint = outcome === 'APPROVE' ? t.barCRApprove
558
+ : outcome === 'BLOCK' ? t.barCRBlock
559
+ : outcome === 'NEEDS WORK' ? t.barCRNeedsWork
560
+ : outcome === 'error' ? t.error
561
+ : t.dim;
562
+ return `${paint(outcome)} ${t.dim(pct)}`;
563
+ }).filter((p) => p !== null);
564
+ return parts.join(t.dim(' · '));
364
565
  }
365
566
  statsRow() {
366
567
  const { prsReceived, crsCompleted, fixesApplied, errorsOccurred, crTotalMs } = this.stats;
@@ -523,29 +724,62 @@ export class PRBoard {
523
724
  render() {
524
725
  if (!this.config)
525
726
  return '';
526
- // When the workspace overflows, evict oldest completed slots to scrollback.
527
- this.evictOverflow();
727
+ this.trimHistory();
528
728
  const w = process.stdout.columns || 80;
529
729
  // The live block must never be taller than the viewport: lines that scroll
530
730
  // out the top cannot be erased on the next frame (cursor-up clamps at the
531
731
  // viewport top), leaving permanent duplicates in scrollback. Reserve one
532
732
  // row for the trailing newline writeLive appends.
533
733
  const budget = (process.stdout.rows || 24) - 1;
534
- const normal = this.renderLayout(w, LAYOUT_NORMAL);
535
- if (this.countRenderedLines(normal, w) <= budget)
536
- return normal;
537
- let compact = this.renderLayout(w, LAYOUT_COMPACT);
538
- // Still too tall: flush oldest completed slots to scrollback until it fits.
539
- while (this.countRenderedLines(compact, w) > budget && this.evictOldestCompleted()) {
540
- compact = this.renderLayout(w, LAYOUT_COMPACT);
734
+ const entries = [...this.slots.values()];
735
+ // The live page carries every active slot plus the newest settled ones that
736
+ // fit. What it cannot show is not dropped — it moves into the history pages.
737
+ const live = this.fitLivePage(entries.filter(s => s.superseded !== true), w, budget);
738
+ const onLivePage = new Set(live.visible);
739
+ const history = entries.filter(s => !onLivePage.has(s)); // oldest → newest
740
+ // History rows are always folded, and a folded row is clamped to one
741
+ // terminal row, so a history page holds exactly the rows the panels leave.
742
+ const perPage = Math.max(1, budget - this.chromeRows(w, LAYOUT_COMPACT));
743
+ this.pageCount = 1 + Math.ceil(history.length / perPage);
744
+ this.page = Math.max(0, Math.min(this.page, this.pageCount - 1));
745
+ if (this.page === 0)
746
+ return this.fitToBudget(this.renderLayout(w, live.opts, live.visible, entries.length), w, budget);
747
+ // Page 1 is the newest history page, so it ends where the live page begins.
748
+ const end = history.length - (this.page - 1) * perPage;
749
+ const slice = history.slice(Math.max(0, end - perPage), end);
750
+ return this.fitToBudget(this.renderLayout(w, LAYOUT_COMPACT, slice, entries.length), w, budget);
751
+ }
752
+ /**
753
+ * Pick the slots the live page shows: the expanded layout when it fits, then
754
+ * the compact one, then compact with the oldest settled slots handed over to
755
+ * history until the block fits the viewport. Active slots are never handed
756
+ * over — a running review must stay on screen.
757
+ */
758
+ fitLivePage(pool, w, budget) {
759
+ // Bound the work: a slot costs at least one row, so a settled slot older
760
+ // than the last `budget` of them can never fit on the live page anyway.
761
+ const visible = trimToBudget(pool, budget);
762
+ const fits = (opts) => this.countRenderedLines(this.renderLayout(w, opts, visible, pool.length), w) <= budget;
763
+ if (fits(LAYOUT_NORMAL))
764
+ return { visible, opts: LAYOUT_NORMAL };
765
+ while (!fits(LAYOUT_COMPACT)) {
766
+ const oldestSettled = visible.findIndex(s => s.completedAt !== undefined);
767
+ if (oldestSettled === -1)
768
+ break; // only active slots left — truncateTop takes it from here
769
+ visible.splice(oldestSettled, 1);
541
770
  }
542
- if (this.countRenderedLines(compact, w) <= budget)
543
- return compact;
544
- // Only active slots remain and they still overflow (tiny terminal):
545
- // drop rows from the top, keeping the most recent activity visible.
546
- return this.truncateTop(compact, w, budget);
771
+ return { visible, opts: LAYOUT_COMPACT };
772
+ }
773
+ /** Rows the panels and footer cost, i.e. everything but the PR rows. */
774
+ chromeRows(w, opts) {
775
+ return this.countRenderedLines(this.renderLayout(w, opts, [], this.slots.size), w);
547
776
  }
548
- renderLayout(w, opts) {
777
+ fitToBudget(content, w, budget) {
778
+ return this.countRenderedLines(content, w) <= budget
779
+ ? content
780
+ : this.truncateTop(content, w, budget);
781
+ }
782
+ renderLayout(w, opts, visible, total) {
549
783
  const t = this.theme;
550
784
  // Use w-1 to prevent the exact-terminal-width cursor wrap ambiguity that
551
785
  // causes the first char of the next line to appear at the end of the separator.
@@ -555,20 +789,25 @@ export class PRBoard {
555
789
  sep,
556
790
  ...this.renderStatsPanel(opts.connLogLines, opts.showTip),
557
791
  sep,
558
- ...this.renderPRWorkspace(opts.foldAll),
792
+ ...this.renderPRWorkspace(visible, opts.foldAll, w),
559
793
  sep,
794
+ // Clamped: the footer is counted as exactly one row when sizing a page.
795
+ truncateVisible(this.renderFooter(visible.length, total), w - 1),
560
796
  ].join('\n');
561
797
  }
562
- /** Evict the oldest completed slot to scrollback. Returns false when none left. */
563
- evictOldestCompleted() {
798
+ /** Drop the oldest settled slots once the session history outgrows the cap. */
799
+ trimHistory() {
800
+ if (this.slots.size <= HISTORY_MAX)
801
+ return;
802
+ let excess = this.slots.size - HISTORY_MAX;
564
803
  for (const [key, slot] of this.slots) {
804
+ if (excess <= 0)
805
+ break;
565
806
  if (slot.completedAt === undefined)
566
- continue;
567
- this.printStatic(this.renderPRSlotFolded(slot));
807
+ continue; // never drop an active slot
568
808
  this.slots.delete(key);
569
- return true;
809
+ excess--;
570
810
  }
571
- return false;
572
811
  }
573
812
  /** Drop rows from the top until the content (plus an indicator line) fits the budget. */
574
813
  truncateTop(content, w, budget) {
@@ -601,7 +840,11 @@ export class PRBoard {
601
840
  renderStatsPanel(connLogLines, showTip) {
602
841
  const t = this.theme;
603
842
  const lines = [];
604
- lines.push(` ${this.statsRow()} ${t.dim('│')} ${t.dim('↑')} ${this.uptime()}`);
843
+ lines.push(` ${this.statsRow()}`);
844
+ // Session line: start time + age, and the outcome split when anything has
845
+ // settled. One row, because every row here costs a PR row on the live page.
846
+ const outcomes = this.outcomeRow();
847
+ lines.push(` ${this.sessionRow()}${outcomes ? ` ${t.dim('│')} ${outcomes}` : ''}`);
605
848
  const { type: tunnelType, url, alive } = this.tunnel;
606
849
  if (tunnelType !== 'none') {
607
850
  const tunnelLabel = tunnelType === 'serve' ? 'endpoint:' : 'tunnel: ';
@@ -630,14 +873,14 @@ export class PRBoard {
630
873
  const formatted = parts.map(p => p.startsWith('`') ? t.accent(p) : t.dim(p)).join('');
631
874
  return ` ${badge}${formatted}`;
632
875
  }
633
- renderPRWorkspace(foldAll) {
876
+ renderPRWorkspace(visible, foldAll, w) {
634
877
  const t = this.theme;
635
878
  const frame = FRAMES[this.frameIdx];
636
- if (this.slots.size === 0) {
637
- return [t.dim(' waiting for PRs...')];
879
+ if (visible.length === 0) {
880
+ return this.slots.size === 0 ? [t.dim(' waiting for PRs...')] : [];
638
881
  }
639
882
  let completedCount = 0;
640
- for (const slot of this.slots.values()) {
883
+ for (const slot of visible) {
641
884
  if (slot.completedAt !== undefined)
642
885
  completedCount++;
643
886
  }
@@ -645,7 +888,7 @@ export class PRBoard {
645
888
  const lines = [];
646
889
  let prevWasExpanded = false;
647
890
  let first = true;
648
- for (const slot of this.slots.values()) {
891
+ for (const slot of visible) {
649
892
  const isCompleted = slot.completedAt !== undefined;
650
893
  // Sticky fold: once a completed slot folds because the count crossed
651
894
  // FOLD_THRESHOLD, keep it folded. Otherwise a later recheck round drops
@@ -655,9 +898,14 @@ export class PRBoard {
655
898
  // should re-expand.
656
899
  if (isCompleted && foldByCount)
657
900
  slot.stickyFolded = true;
658
- const useFolded = isCompleted && (foldAll || slot.stickyFolded === true);
901
+ // A failed slot is always folded: its pipeline bars are frozen wherever the
902
+ // run died ("CR queued", "Fix queued"), which describes work that will
903
+ // never happen. The folded row carries the error instead.
904
+ const useFolded = isCompleted && (foldAll || slot.stickyFolded === true || slot.error !== undefined);
659
905
  if (useFolded) {
660
- lines.push(this.renderPRSlotFolded(slot));
906
+ // Clamped so the row never wraps: history pagination sizes a page by
907
+ // counting one terminal row per folded slot.
908
+ lines.push(truncateVisible(this.renderPRSlotFolded(slot), w - 1));
661
909
  prevWasExpanded = false;
662
910
  }
663
911
  else {
@@ -670,6 +918,22 @@ export class PRBoard {
670
918
  }
671
919
  return lines;
672
920
  }
921
+ /** Footer: where in the retained history this page sits, and how to move. */
922
+ renderFooter(shown, total) {
923
+ const t = this.theme;
924
+ if (total === 0)
925
+ return t.dim(' no PRs yet');
926
+ const position = this.page === 0
927
+ ? `${t.success('live')}${this.pageCount > 1 ? t.dim(` · page 1/${this.pageCount}`) : ''}`
928
+ : t.dim(`history · page ${this.page + 1}/${this.pageCount}`);
929
+ // "shown of retained", not of stats.prsReceived: rounds add rows, and the
930
+ // history cap eventually drops the oldest, so the two counts diverge.
931
+ const counts = t.dim(`showing ${shown} of ${total}`);
932
+ const keys = this.pageCount > 1
933
+ ? ` ${t.dim('│')} ${t.accent('ctrl+<')} ${t.dim('older')} ${t.accent('ctrl+>')} ${t.dim('newer')}`
934
+ : '';
935
+ return ` ${position} ${t.dim('│')} ${counts}${keys}`;
936
+ }
673
937
  // ── Folded PR slot ─────────────────────────────────────────────────────────
674
938
  renderPRSlotFolded(slot) {
675
939
  const t = this.theme;
@@ -697,29 +961,57 @@ export class PRBoard {
697
961
  const rFn = this.crLabelFn(slot.recheckVerdict);
698
962
  parts.push(`recheck ${rFn(slot.recheckVerdict)}`);
699
963
  }
964
+ // A failed run reports the error in place of the verdict trail: whatever the
965
+ // pipeline had reached is what it never got to finish, and the reason it
966
+ // stopped is the only thing worth the row.
967
+ if (slot.error !== undefined) {
968
+ parts.push(t.error(slot.error));
969
+ }
700
970
  const urlPart = slot.url ? ` ${t.dim('→')} ${t.accent(slot.url)}` : '';
701
971
  const partsStr = parts.length > 0 ? parts.join(t.dim(' · ')) : t.dim('—');
702
- return ` ${t.success('')} ${t.dim(`#${slot.prNumber}`)} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
703
- }
704
- // ── Overflow eviction ──────────────────────────────────────────────────────
705
- evictOverflow() {
706
- if (this.slots.size <= WORKSPACE_MAX)
707
- return;
708
- let toEvict = this.slots.size - WORKSPACE_MAX;
709
- for (const [key, slot] of this.slots) {
710
- if (toEvict <= 0)
711
- break;
712
- if (slot.completedAt === undefined)
713
- continue; // never evict active
714
- this.printStatic(this.renderPRSlotFolded(slot));
715
- this.slots.delete(key);
716
- toEvict--;
717
- }
972
+ const icon = slot.error !== undefined ? t.error('') : t.success('✓');
973
+ return ` ${icon} ${t.dim(`#${slot.prNumber}`)} ${t.dim(slot.repo)} ${t.dim(branch)} ${partsStr} ${t.dim(`(${elapsed})`)}${urlPart}`;
718
974
  }
719
975
  redraw() {
720
976
  const content = this.render();
721
977
  if (content)
722
978
  this.writeLive(content);
723
979
  }
980
+ // ── Private: key input ─────────────────────────────────────────────────────
981
+ attachKeys() {
982
+ const stdin = process.stdin;
983
+ if (this.keyHandler || !stdin.isTTY)
984
+ return;
985
+ this.stdinWasRaw = stdin.isRaw === true;
986
+ stdin.setRawMode(true);
987
+ stdin.resume();
988
+ const handler = (data) => {
989
+ const seq = data.toString('utf8');
990
+ // Raw mode suppresses the terminal's own ctrl-c → SIGINT translation, so
991
+ // the board has to raise it itself or the daemon becomes unkillable.
992
+ if (seq === '\u0003') {
993
+ process.kill(process.pid, 'SIGINT');
994
+ return;
995
+ }
996
+ const action = pageKeyAction(seq);
997
+ if (action === 'older')
998
+ this.pageOlder();
999
+ else if (action === 'newer')
1000
+ this.pageNewer();
1001
+ };
1002
+ stdin.on('data', handler);
1003
+ this.keyHandler = handler;
1004
+ }
1005
+ detachKeys() {
1006
+ if (!this.keyHandler)
1007
+ return;
1008
+ process.stdin.off('data', this.keyHandler);
1009
+ this.keyHandler = null;
1010
+ // Hand the terminal back the way it was found: the idle-issue flow and the
1011
+ // interactive prompts read stdin themselves while the board is stopped.
1012
+ if (process.stdin.isTTY && !this.stdinWasRaw)
1013
+ process.stdin.setRawMode(false);
1014
+ process.stdin.pause();
1015
+ }
724
1016
  }
725
1017
  //# sourceMappingURL=board.js.map