@humanbased/crosscheck 1.5.0 → 1.6.0-beta.13

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 (53) hide show
  1. package/dist/__tests__/board.test.js +527 -20
  2. package/dist/__tests__/board.test.js.map +1 -1
  3. package/dist/__tests__/lock-ownership.test.d.ts +2 -0
  4. package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
  5. package/dist/__tests__/lock-ownership.test.js +167 -0
  6. package/dist/__tests__/lock-ownership.test.js.map +1 -0
  7. package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
  8. package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
  9. package/dist/__tests__/vendor-error-summary.test.js +74 -0
  10. package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
  11. package/dist/cli.js +2 -1
  12. package/dist/cli.js.map +1 -1
  13. package/dist/commands/review.d.ts +3 -1
  14. package/dist/commands/review.d.ts.map +1 -1
  15. package/dist/commands/review.js +88 -2
  16. package/dist/commands/review.js.map +1 -1
  17. package/dist/commands/run.d.ts.map +1 -1
  18. package/dist/commands/run.js +22 -5
  19. package/dist/commands/run.js.map +1 -1
  20. package/dist/commands/watch.d.ts.map +1 -1
  21. package/dist/commands/watch.js +27 -23
  22. package/dist/commands/watch.js.map +1 -1
  23. package/dist/github/review-status.d.ts +33 -0
  24. package/dist/github/review-status.d.ts.map +1 -1
  25. package/dist/github/review-status.js +94 -9
  26. package/dist/github/review-status.js.map +1 -1
  27. package/dist/lib/board.d.ts +59 -4
  28. package/dist/lib/board.d.ts.map +1 -1
  29. package/dist/lib/board.js +376 -64
  30. package/dist/lib/board.js.map +1 -1
  31. package/dist/lib/pr-lock.d.ts +37 -1
  32. package/dist/lib/pr-lock.d.ts.map +1 -1
  33. package/dist/lib/pr-lock.js +176 -29
  34. package/dist/lib/pr-lock.js.map +1 -1
  35. package/dist/lib/pr-workflow-state.d.ts +2 -0
  36. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  37. package/dist/lib/pr-workflow-state.js +15 -0
  38. package/dist/lib/pr-workflow-state.js.map +1 -1
  39. package/dist/lib/tips.d.ts.map +1 -1
  40. package/dist/lib/tips.js +1 -0
  41. package/dist/lib/tips.js.map +1 -1
  42. package/dist/lib/vendor-error-summary.d.ts +19 -0
  43. package/dist/lib/vendor-error-summary.d.ts.map +1 -0
  44. package/dist/lib/vendor-error-summary.js +95 -0
  45. package/dist/lib/vendor-error-summary.js.map +1 -0
  46. package/dist/reviewers/claude.d.ts.map +1 -1
  47. package/dist/reviewers/claude.js +2 -1
  48. package/dist/reviewers/claude.js.map +1 -1
  49. package/dist/reviewers/codex.d.ts.map +1 -1
  50. package/dist/reviewers/codex.js +2 -1
  51. package/dist/reviewers/codex.js.map +1 -1
  52. package/get-started.md +18 -0
  53. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
- import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
- import { PRBoard, fmtTokens } from '../lib/board.js';
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { EventEmitter } from 'events';
3
+ import { PRBoard, fmtTokens, pageKeyAction, isNoticeLine, distribute, fmtUptime } from '../lib/board.js';
3
4
  describe('fmtTokens', () => {
4
5
  it('returns empty string for undefined', () => {
5
6
  expect(fmtTokens(undefined)).toBe('');
@@ -85,26 +86,34 @@ describe('PRBoard — TTY workspace retention', () => {
85
86
  const slots = () => board.slots;
86
87
  const invokeRender = () => board.render();
87
88
  const invokeFolded = (key) => board.renderPRSlotFolded(slots().get(key));
89
+ const superseded = (key) => slots().get(key)?.superseded === true;
90
+ // The PR rows only. Layout is config │ stats │ workspace │ footer, separated by
91
+ // rules, so the third block is the workspace. Assertions about which PRs are on
92
+ // screen scope to this: the stats panel names verdicts too (outcome shares), so
93
+ // searching the whole frame for "BLOCK" no longer answers "is that row shown?".
94
+ const workspaceOf = (output) => {
95
+ const blocks = output.split(/^─+$/m);
96
+ return blocks[2] ?? '';
97
+ };
88
98
  it('keeps completed slot in the workspace (no auto-clear)', () => {
89
99
  board.addPR('k1', 1, 'a/b', 'main');
90
100
  board.completePR('k1', { elapsedMs: 1000, url: 'https://github.com/a/b/pull/1' });
91
101
  expect(slots().size).toBe(1);
92
102
  expect(slots().has('k1')).toBe(true);
93
103
  });
94
- it('evicts oldest completed slots when total exceeds workspace cap', () => {
104
+ // The workspace is the session record now: nothing is dropped on a count cap,
105
+ // because anything the live page cannot show is reachable on a history page.
106
+ it('keeps every completed PR in history instead of evicting on a count cap', () => {
95
107
  for (let i = 0; i < 30; i++) {
96
108
  board.addPR(`k${i}`, i, 'a/b', `branch-${i}`);
97
109
  board.completePR(`k${i}`, { elapsedMs: 1000, url: `https://github.com/a/b/pull/${i}` });
98
110
  }
99
111
  invokeRender();
100
- expect(slots().size).toBe(25);
101
- // The 5 oldest should be evicted (0..4); 5..29 retained.
102
- for (let i = 0; i < 5; i++)
103
- expect(slots().has(`k${i}`)).toBe(false);
104
- for (let i = 5; i < 30; i++)
112
+ expect(slots().size).toBe(30);
113
+ for (let i = 0; i < 30; i++)
105
114
  expect(slots().has(`k${i}`)).toBe(true);
106
115
  });
107
- it('never evicts active slots even at overflow', () => {
116
+ it('keeps active slots on the live page however much history is behind them', () => {
108
117
  for (let i = 0; i < 24; i++) {
109
118
  board.addPR(`done-${i}`, i, 'a/b', `branch-${i}`);
110
119
  board.completePR(`done-${i}`, { elapsedMs: 1000, url: `url-${i}` });
@@ -112,11 +121,11 @@ describe('PRBoard — TTY workspace retention', () => {
112
121
  for (let i = 0; i < 5; i++) {
113
122
  board.addPR(`active-${i}`, 100 + i, 'a/b', `active-${i}`);
114
123
  }
124
+ const output = stripAnsi(invokeRender());
115
125
  expect(slots().size).toBe(29);
116
- invokeRender();
117
- expect(slots().size).toBe(25);
118
126
  for (let i = 0; i < 5; i++) {
119
127
  expect(slots().has(`active-${i}`)).toBe(true);
128
+ expect(output).toContain(`active-${i}`);
120
129
  }
121
130
  });
122
131
  it('renders a folded line with verdict, fix count, recheck and url', () => {
@@ -153,7 +162,7 @@ describe('PRBoard — TTY workspace retention', () => {
153
162
  });
154
163
  expect(stripAnsi(invokeRender())).toContain('skipped · generated');
155
164
  });
156
- it('evicts the prior-round completed slot when round 2 starts for the same PR', () => {
165
+ it('supersedes the prior-round completed slot when round 2 starts for the same PR', () => {
157
166
  // Round 1 — BLOCK, fix skipped, recheck skipped (the stale slot the user saw)
158
167
  board.addPR('k1@sha1', 214, 'owner/repo', 'fix/branch', 1);
159
168
  board.updatePR('k1@sha1', { verdict: 'BLOCK', commentCount: 1, fixCount: 0 });
@@ -161,15 +170,16 @@ describe('PRBoard — TTY workspace retention', () => {
161
170
  expect(slots().has('k1@sha1')).toBe(true);
162
171
  // Round 2 — new SHA push: board must evict round 1 and add round 2
163
172
  board.addPR('k1@sha2', 214, 'owner/repo', 'fix/branch', 2);
164
- expect(slots().has('k1@sha1')).toBe(false); // prior round evicted
173
+ expect(slots().has('k1@sha1')).toBe(true); // prior round kept in history
174
+ expect(superseded('k1@sha1')).toBe(true); // but off the live page
165
175
  expect(slots().has('k1@sha2')).toBe(true); // new round present
166
176
  board.updatePR('k1@sha2', { recheckVerdict: 'APPROVE' });
167
177
  board.completePR('k1@sha2', { elapsedMs: 362_000, url: 'https://github.com/owner/repo/pull/214' });
168
- const output = stripAnsi(invokeRender());
169
- expect(output).not.toContain('BLOCK');
170
- expect(output).toContain('APPROVE');
178
+ const workspace = workspaceOf(stripAnsi(invokeRender()));
179
+ expect(workspace).not.toContain('BLOCK');
180
+ expect(workspace).toContain('APPROVE');
171
181
  });
172
- it('does not evict active slots when round 2 starts', () => {
182
+ it('does not supersede active slots when round 2 starts', () => {
173
183
  // Active round 1 for a different PR — must not be touched
174
184
  board.addPR('other@sha', 99, 'owner/repo', 'other-branch', 1);
175
185
  // Completed round 1 for PR 214
@@ -177,7 +187,8 @@ describe('PRBoard — TTY workspace retention', () => {
177
187
  board.completePR('k1@sha1', { elapsedMs: 1_000, url: 'u' });
178
188
  board.addPR('k1@sha2', 214, 'owner/repo', 'fix/branch', 2);
179
189
  expect(slots().has('other@sha')).toBe(true); // untouched
180
- expect(slots().has('k1@sha1')).toBe(false); // evicted
190
+ expect(superseded('other@sha')).toBe(false);
191
+ expect(superseded('k1@sha1')).toBe(true); // prior round hidden, not dropped
181
192
  expect(slots().has('k1@sha2')).toBe(true);
182
193
  });
183
194
  it('orders sections top-to-bottom: config → stats → PR workspace', () => {
@@ -272,12 +283,14 @@ describe('PRBoard — viewport height fitting', () => {
272
283
  expect(output).not.toContain('issues');
273
284
  expect(countRows(invokeRender(), 100)).toBeLessThanOrEqual(11);
274
285
  });
275
- it('evicts completed slots to scrollback when the compact layout still overflows', () => {
286
+ it('moves overflow onto history pages instead of dropping it when compact still overflows', () => {
276
287
  setViewport(12, 100);
277
288
  addCompleted(15);
278
289
  const output = invokeRender();
279
290
  expect(countRows(output, 100)).toBeLessThanOrEqual(11);
280
- expect(slots().size).toBeLessThan(15);
291
+ expect(slots().size).toBe(15); // nothing dropped
292
+ expect(stripAnsi(output)).toContain('branch-14'); // newest is on the live page
293
+ expect(stripAnsi(output)).not.toContain('branch-0'); // oldest moved to history
281
294
  });
282
295
  it('truncates from the top as a last resort when active slots alone overflow', () => {
283
296
  setViewport(8, 100);
@@ -314,6 +327,500 @@ describe('PRBoard — viewport height fitting', () => {
314
327
  const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
315
328
  expect(residue).toEqual([]);
316
329
  });
330
+ // failPR settles into the live block now rather than printing a static line
331
+ // per failure. This pins that: a long run of failures on a short viewport must
332
+ // stay a redraw, never scrollback. (It is an invariant guard, not a reproducer
333
+ // — the doubled header operators report comes from a viewport resize, which
334
+ // strands rows above the cursor-up clamp regardless of what wrote them.)
335
+ it('keeps a long run of failures in the live block, not scrollback', () => {
336
+ const ROWS = 14, COLS = 100;
337
+ setViewport(ROWS, COLS);
338
+ board.setTunnel('smee', 'https://smee.io/test', true);
339
+ invokeRedraw();
340
+ for (let i = 0; i < 20; i++) {
341
+ board.addPR(`k${i}`, 4600 + i, 'humanbased-ai/monorepo', `codex/branch-${i}`);
342
+ invokeRedraw();
343
+ board.failPR(`k${i}`, 'codex: timed out after 1200s (retried once) — PR diff may be too large');
344
+ invokeRedraw();
345
+ }
346
+ const { scrollback } = emulateVT(captured.join(''), ROWS, COLS);
347
+ const liveOnlyMarkers = ['crosscheck', 'workflow:', 'vendors:', 'PRs:', 'tunnel:'];
348
+ const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
349
+ expect(residue).toEqual([]);
350
+ });
351
+ });
352
+ // ── History pagination ────────────────────────────────────────────────────────
353
+ //
354
+ // The live page shows what fits; everything older stays in the map and is
355
+ // reached by flipping pages. Nothing a session has seen leaves the board.
356
+ describe('PRBoard — history pagination', () => {
357
+ let board;
358
+ let originalIsTTY;
359
+ let originalRows;
360
+ let originalColumns;
361
+ let originalWrite;
362
+ beforeEach(() => {
363
+ originalIsTTY = process.stdout.isTTY;
364
+ originalRows = process.stdout.rows;
365
+ originalColumns = process.stdout.columns;
366
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
367
+ Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true });
368
+ Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true });
369
+ originalWrite = process.stdout.write.bind(process.stdout);
370
+ process.stdout.write = (() => true);
371
+ board = new PRBoard();
372
+ board.setConfig(baseConfig, [reviewStep]);
373
+ });
374
+ afterEach(() => {
375
+ board.stop();
376
+ process.stdout.write = originalWrite;
377
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
378
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
379
+ Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
380
+ });
381
+ const invokeRender = () => board.render();
382
+ const page = () => board.page;
383
+ const pageCount = () => board.pageCount;
384
+ // The footer is the last line — the tip line also names the page keys.
385
+ const footerOf = (content) => stripAnsi(content).split('\n').at(-1) ?? '';
386
+ const addCompleted = (n) => {
387
+ for (let i = 0; i < n; i++) {
388
+ board.addPR(`k${i}`, i, 'acme/api', `branch-${i}`);
389
+ board.updatePR(`k${i}`, { verdict: 'APPROVE', commentCount: 2 });
390
+ board.completePR(`k${i}`, { elapsedMs: 60_000, url: `https://github.com/acme/api/pull/${i}` });
391
+ }
392
+ };
393
+ it('reaches PRs the live page cannot show by paging back', () => {
394
+ addCompleted(40);
395
+ const live = stripAnsi(invokeRender());
396
+ expect(live).not.toContain('branch-0 ');
397
+ expect(pageCount()).toBeGreaterThan(1);
398
+ // Walk back to the oldest page — PR 0 has to surface somewhere along it.
399
+ let found = live.includes('branch-0 ');
400
+ while (page() < pageCount() - 1 && !found) {
401
+ board.pageOlder();
402
+ found = stripAnsi(invokeRender()).includes('branch-0 ');
403
+ }
404
+ expect(found).toBe(true);
405
+ });
406
+ it('stops at the oldest page and returns to live', () => {
407
+ addCompleted(40);
408
+ invokeRender();
409
+ for (let i = 0; i < 50; i++)
410
+ board.pageOlder();
411
+ expect(page()).toBe(pageCount() - 1);
412
+ for (let i = 0; i < 50; i++)
413
+ board.pageNewer();
414
+ expect(page()).toBe(0);
415
+ });
416
+ it('stays on a history page as new PRs arrive', () => {
417
+ addCompleted(40);
418
+ invokeRender();
419
+ board.pageOlder();
420
+ board.pageOlder();
421
+ const before = page();
422
+ board.addPR('new', 999, 'acme/api', 'branch-new');
423
+ invokeRender();
424
+ expect(page()).toBe(before);
425
+ expect(stripAnsi(invokeRender())).not.toContain('branch-new');
426
+ });
427
+ it('labels the live page and the history pages in the footer', () => {
428
+ addCompleted(40);
429
+ expect(stripAnsi(invokeRender())).toContain('live');
430
+ expect(stripAnsi(invokeRender())).toMatch(/showing \d+ of 40/);
431
+ board.pageOlder();
432
+ const older = footerOf(invokeRender());
433
+ expect(older).toContain('history · page 2/');
434
+ expect(older).toContain('←');
435
+ });
436
+ it('keeps a history page inside the viewport', () => {
437
+ addCompleted(60);
438
+ invokeRender();
439
+ board.pageOlder();
440
+ const rows = stripAnsi(invokeRender()).split('\n')
441
+ .reduce((sum, l) => sum + Math.max(1, Math.ceil(l.length / 120)), 0);
442
+ expect(rows).toBeLessThanOrEqual(19);
443
+ });
444
+ it('has one page when everything fits on the live page', () => {
445
+ addCompleted(2);
446
+ invokeRender();
447
+ expect(pageCount()).toBe(1);
448
+ expect(footerOf(invokeRender())).not.toContain('older'); // no keys offered with nowhere to go
449
+ expect(footerOf(invokeRender())).toContain('showing 2 of 2');
450
+ });
451
+ });
452
+ // The board owns the terminal while it runs, so it also owns stdin: raw mode
453
+ // for the page keys, and the ctrl-c the terminal no longer translates for it.
454
+ describe('PRBoard — key input', () => {
455
+ let board;
456
+ let originalStdin;
457
+ let originalIsTTY;
458
+ let originalRows;
459
+ let originalColumns;
460
+ let originalWrite;
461
+ let fake;
462
+ const makeFakeStdin = () => {
463
+ const emitter = new EventEmitter();
464
+ emitter.isTTY = true;
465
+ emitter.isRaw = false;
466
+ emitter.rawModeCalls = [];
467
+ emitter.paused = false;
468
+ emitter.setRawMode = (on) => { emitter.isRaw = on; emitter.rawModeCalls.push(on); return emitter; };
469
+ emitter.resume = () => { emitter.paused = false; return emitter; };
470
+ emitter.pause = () => { emitter.paused = true; return emitter; };
471
+ return emitter;
472
+ };
473
+ beforeEach(() => {
474
+ originalStdin = process.stdin;
475
+ originalIsTTY = process.stdout.isTTY;
476
+ originalRows = process.stdout.rows;
477
+ originalColumns = process.stdout.columns;
478
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
479
+ Object.defineProperty(process.stdout, 'rows', { value: 20, configurable: true });
480
+ Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true });
481
+ originalWrite = process.stdout.write.bind(process.stdout);
482
+ process.stdout.write = (() => true);
483
+ fake = makeFakeStdin();
484
+ Object.defineProperty(process, 'stdin', { value: fake, configurable: true });
485
+ board = new PRBoard();
486
+ board.setConfig(baseConfig, [reviewStep]);
487
+ });
488
+ afterEach(() => {
489
+ board.stop();
490
+ Object.defineProperty(process, 'stdin', { value: originalStdin, configurable: true });
491
+ process.stdout.write = originalWrite;
492
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
493
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
494
+ Object.defineProperty(process.stdout, 'columns', { value: originalColumns, configurable: true });
495
+ });
496
+ const page = () => board.page;
497
+ const fillHistory = () => {
498
+ for (let i = 0; i < 40; i++) {
499
+ board.addPR(`k${i}`, i, 'acme/api', `branch-${i}`);
500
+ board.completePR(`k${i}`, { elapsedMs: 1000, url: `https://github.com/acme/api/pull/${i}` });
501
+ }
502
+ ;
503
+ board.render();
504
+ };
505
+ it('pages with the key sequences while running, and stops listening once stopped', () => {
506
+ fillHistory();
507
+ board.start();
508
+ expect(fake.isRaw).toBe(true);
509
+ fake.emit('data', Buffer.from('<'));
510
+ expect(page()).toBe(1);
511
+ fake.emit('data', Buffer.from('<'));
512
+ expect(page()).toBe(2);
513
+ fake.emit('data', Buffer.from('>'));
514
+ expect(page()).toBe(1);
515
+ board.stop();
516
+ expect(fake.isRaw).toBe(false); // terminal handed back
517
+ expect(fake.listenerCount('data')).toBe(0);
518
+ fake.emit('data', Buffer.from('<'));
519
+ expect(page()).toBe(1); // no longer listening
520
+ });
521
+ it('raises SIGINT itself, since raw mode suppresses the terminal ctrl-c', () => {
522
+ const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
523
+ board.start();
524
+ fake.emit('data', Buffer.from('\u0003'));
525
+ expect(kill).toHaveBeenCalledWith(process.pid, 'SIGINT');
526
+ kill.mockRestore();
527
+ });
528
+ it('ignores keys that are not page keys', () => {
529
+ fillHistory();
530
+ board.start();
531
+ fake.emit('data', Buffer.from('q'));
532
+ fake.emit('data', Buffer.from('\u001b[A'));
533
+ expect(page()).toBe(0);
534
+ });
535
+ });
536
+ describe('pageKeyAction', () => {
537
+ it('maps bare < and > and their unshifted keys', () => {
538
+ expect(pageKeyAction('<')).toBe('older');
539
+ expect(pageKeyAction(',')).toBe('older');
540
+ expect(pageKeyAction('>')).toBe('newer');
541
+ expect(pageKeyAction('.')).toBe('newer');
542
+ });
543
+ it('maps the CSI-u encodings terminals use for ctrl/cmd + punctuation', () => {
544
+ expect(pageKeyAction('\u001b[44;5u')).toBe('older'); // ctrl+,
545
+ expect(pageKeyAction('\u001b[46;5u')).toBe('newer'); // ctrl+.
546
+ expect(pageKeyAction('\u001b[60;9u')).toBe('older'); // cmd+<
547
+ expect(pageKeyAction('\u001b[62;9u')).toBe('newer'); // cmd+>
548
+ });
549
+ it('maps plain and modified arrows', () => {
550
+ expect(pageKeyAction('\u001b[D')).toBe('older');
551
+ expect(pageKeyAction('\u001b[C')).toBe('newer');
552
+ expect(pageKeyAction('\u001b[1;5D')).toBe('older');
553
+ expect(pageKeyAction('\u001b[1;5C')).toBe('newer');
554
+ });
555
+ it('maps the option+arrow and application-cursor forms macOS terminals send', () => {
556
+ expect(pageKeyAction('\u001bb')).toBe('older'); // Terminal.app option+left
557
+ expect(pageKeyAction('\u001bf')).toBe('newer'); // Terminal.app option+right
558
+ expect(pageKeyAction('\u001b\u001b[D')).toBe('older'); // iTerm2 option+left
559
+ expect(pageKeyAction('\u001b\u001b[C')).toBe('newer'); // iTerm2 option+right
560
+ expect(pageKeyAction('\u001bOD')).toBe('older'); // application-cursor left
561
+ expect(pageKeyAction('\u001bOC')).toBe('newer'); // application-cursor right
562
+ });
563
+ it('ignores everything else', () => {
564
+ expect(pageKeyAction('a')).toBe(null);
565
+ expect(pageKeyAction('\u0003')).toBe(null);
566
+ expect(pageKeyAction('\u001b[A')).toBe(null);
567
+ expect(pageKeyAction('\u001b[48;5u')).toBe(null);
568
+ });
569
+ });
570
+ describe('isNoticeLine', () => {
571
+ it('keeps warnings and errors on the terminal', () => {
572
+ expect(isNoticeLine('⚠ push rejected')).toBe(true);
573
+ expect(isNoticeLine('✗ codex did not review PR #1')).toBe(true);
574
+ expect(isNoticeLine('\u001b[33m⚠ usage limit\u001b[39m')).toBe(true);
575
+ });
576
+ it('keeps multi-line dumps, which always follow a notice', () => {
577
+ expect(isNoticeLine('\n--- unposted review ---\nbody\n--- end ---')).toBe(true);
578
+ });
579
+ it('routes routine narration to the file log', () => {
580
+ expect(isNoticeLine(' strategy v1.2.0: trivial → fast tier (medium)')).toBe(false);
581
+ expect(isNoticeLine(' skills: typescript')).toBe(false);
582
+ });
583
+ });
584
+ describe('distribute', () => {
585
+ it('returns all zeroes when nothing has been counted', () => {
586
+ expect(distribute([0, 0, 0])).toEqual([0, 0, 0]);
587
+ });
588
+ it('sums to exactly 100 where plain rounding would not', () => {
589
+ // Three equal shares floor to 33 each; the leftover point goes to a remainder.
590
+ const out = distribute([1, 1, 1]);
591
+ expect(out.reduce((a, b) => a + b, 0)).toBe(100);
592
+ expect(out.sort((a, b) => a - b)).toEqual([33, 33, 34]);
593
+ });
594
+ it('sums to 100 across a spread of awkward splits', () => {
595
+ const cases = [[1, 2], [1, 1, 1, 1, 1, 1], [7, 11, 13], [95, 1, 1, 1, 1, 1], [1, 999]];
596
+ for (const c of cases) {
597
+ expect(distribute(c).reduce((a, b) => a + b, 0)).toBe(100);
598
+ }
599
+ });
600
+ it('gives a lone outcome the whole 100%', () => {
601
+ expect(distribute([43, 0, 0])).toEqual([100, 0, 0]);
602
+ });
603
+ it('reports 0 for a share too small to earn a point, so callers can say "<1%"', () => {
604
+ const out = distribute([999, 1]);
605
+ expect(out[1]).toBe(0);
606
+ expect(out.reduce((a, b) => a + b, 0)).toBe(100);
607
+ });
608
+ });
609
+ describe('fmtUptime', () => {
610
+ it('renders minutes only under an hour', () => {
611
+ expect(fmtUptime(0)).toBe('0m');
612
+ expect(fmtUptime(59_000)).toBe('0m');
613
+ expect(fmtUptime(12 * 60_000)).toBe('12m');
614
+ });
615
+ it('renders hours and zero-padded minutes past the hour', () => {
616
+ expect(fmtUptime(60 * 60_000)).toBe('1h00m');
617
+ expect(fmtUptime((3 * 60 + 32) * 60_000)).toBe('3h32m');
618
+ expect(fmtUptime((11 * 60 + 57) * 60_000)).toBe('11h57m');
619
+ });
620
+ it('never renders a negative age if the clock steps backwards', () => {
621
+ expect(fmtUptime(-5000)).toBe('0m');
622
+ });
623
+ });
624
+ describe('PRBoard \u2014 failed PRs stay in the workspace', () => {
625
+ let board;
626
+ let originalIsTTY;
627
+ let originalRows;
628
+ let originalWrite;
629
+ let written;
630
+ beforeEach(() => {
631
+ originalIsTTY = process.stdout.isTTY;
632
+ originalRows = process.stdout.rows;
633
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
634
+ Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
635
+ originalWrite = process.stdout.write.bind(process.stdout);
636
+ written = [];
637
+ process.stdout.write = ((chunk) => { written.push(String(chunk)); return true; });
638
+ board = new PRBoard();
639
+ board.setConfig(baseConfig, [reviewStep]);
640
+ });
641
+ afterEach(() => {
642
+ board.stop();
643
+ process.stdout.write = originalWrite;
644
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
645
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
646
+ });
647
+ const slots = () => board.slots;
648
+ const invokeRender = () => board.render();
649
+ it('keeps the slot instead of deleting it', () => {
650
+ board.addPR('k1', 4641, 'a/b', 'feat/x');
651
+ board.failPR('k1', 'codex: timed out after 600s');
652
+ expect(slots().size).toBe(1);
653
+ expect(slots().has('k1')).toBe(true);
654
+ });
655
+ it('renders the failed PR as a row carrying its error, not as scrollback', () => {
656
+ board.addPR('k1', 4641, 'a/b', 'feat/x');
657
+ board.failPR('k1', 'codex: timed out after 600s');
658
+ const output = stripAnsi(invokeRender());
659
+ expect(output).toContain('#4641');
660
+ expect(output).toContain('codex: timed out after 600s');
661
+ // The regression: the row went to scrollback and the table read "no PRs yet".
662
+ expect(output).not.toContain('no PRs yet');
663
+ expect(written.join('')).not.toContain('codex: timed out after 600s');
664
+ });
665
+ it('folds a failed row even when it is the only one on the page', () => {
666
+ board.addPR('k1', 4641, 'a/b', 'feat/x');
667
+ board.failPR('k1', 'codex: timed out after 600s');
668
+ const output = stripAnsi(invokeRender());
669
+ // Folded rows carry no pipeline bars; an expanded row would show "CR" queued
670
+ // against work the failed run will never do.
671
+ expect(output).not.toMatch(/CR [\u2588\u2591]/);
672
+ expect(output).toContain('\u2717 #4641');
673
+ });
674
+ it('flattens a multi-line subprocess dump into a single row', () => {
675
+ board.addPR('k1', 4823, 'a/b', 'feat/x');
676
+ board.failPR('k1', 'claude: Command failed with exit code 1: claude --print\n\n{"is_error":true,\n"result":"limit reached"}');
677
+ const rows = stripAnsi(invokeRender()).split('\n').filter(l => l.includes('#4823'));
678
+ // One row per folded slot is what history pagination sizes a page by.
679
+ expect(rows).toHaveLength(1);
680
+ expect(rows[0]).not.toContain('{');
681
+ expect(rows[0]).toContain('claude: Command failed');
682
+ });
683
+ it('counts a failure once, toward errors and the outcome split', () => {
684
+ board.addPR('k1', 1, 'a/b', 'feat/x');
685
+ board.failPR('k1', 'boom');
686
+ const stats = board.stats;
687
+ expect(stats.errorsOccurred).toBe(1);
688
+ expect(stats.outcomes.error).toBe(1);
689
+ });
690
+ it('still counts the error when the slot is already gone', () => {
691
+ board.failPR('never-added', 'boom');
692
+ const stats = board.stats;
693
+ expect(stats.errorsOccurred).toBe(1);
694
+ // No slot means no row, so nothing should be added to the distribution.
695
+ expect(stats.outcomes.error).toBe(0);
696
+ });
697
+ it('pages failed rows into history like any other settled row', () => {
698
+ for (let i = 0; i < 40; i++) {
699
+ board.addPR(`k${i}`, 4600 + i, 'a/b', `feat/${i}`);
700
+ board.failPR(`k${i}`, 'codex: timed out after 600s');
701
+ }
702
+ invokeRender();
703
+ expect(slots().size).toBe(40);
704
+ const pageCount = board.pageCount;
705
+ expect(pageCount).toBeGreaterThan(0);
706
+ });
707
+ it('emits the folded line to scrollback and drops the slot when not a TTY', () => {
708
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
709
+ const nonTty = new PRBoard();
710
+ nonTty.setConfig(baseConfig, [reviewStep]);
711
+ nonTty.addPR('k1', 4641, 'a/b', 'feat/x');
712
+ nonTty.failPR('k1', 'codex: timed out after 600s');
713
+ expect(stripAnsi(written.join(''))).toContain('codex: timed out after 600s');
714
+ expect(nonTty.slots.size).toBe(0);
715
+ });
716
+ });
717
+ describe('PRBoard \u2014 session stats panel', () => {
718
+ let board;
719
+ let originalIsTTY;
720
+ let originalRows;
721
+ let originalWrite;
722
+ beforeEach(() => {
723
+ originalIsTTY = process.stdout.isTTY;
724
+ originalRows = process.stdout.rows;
725
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
726
+ Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
727
+ originalWrite = process.stdout.write.bind(process.stdout);
728
+ process.stdout.write = (() => true);
729
+ board = new PRBoard();
730
+ board.setConfig(baseConfig, [reviewStep]);
731
+ });
732
+ afterEach(() => {
733
+ board.stop();
734
+ process.stdout.write = originalWrite;
735
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
736
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
737
+ });
738
+ const invokeRender = () => board.render();
739
+ const setStart = (ms) => (board.stats.sessionStart = ms);
740
+ it('shows the local start time and the session age', () => {
741
+ const start = Date.now() - (3 * 60 + 32) * 60_000;
742
+ setStart(start);
743
+ const output = stripAnsi(invokeRender());
744
+ const expected = new Date(start).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
745
+ expect(output).toContain(`since ${expected}`);
746
+ expect(output).toContain('up 3h32m');
747
+ });
748
+ it('omits the outcome split until something settles', () => {
749
+ const output = stripAnsi(invokeRender());
750
+ expect(output).toContain('since');
751
+ expect(output).not.toContain('%');
752
+ });
753
+ it('reports the outcome split as percentages that sum to 100', () => {
754
+ const verdicts = ['APPROVE', 'APPROVE', 'BLOCK', 'NEEDS WORK'];
755
+ verdicts.forEach((v, i) => {
756
+ board.addPR(`k${i}`, i, 'a/b', `b${i}`);
757
+ board.updatePR(`k${i}`, { verdict: v });
758
+ board.completePR(`k${i}`, { elapsedMs: 1000, url: `u${i}` });
759
+ });
760
+ const output = stripAnsi(invokeRender());
761
+ expect(output).toContain('APPROVE 50%');
762
+ expect(output).toContain('BLOCK 25%');
763
+ expect(output).toContain('NEEDS WORK 25%');
764
+ });
765
+ it('counts a failure in the split alongside verdicts', () => {
766
+ board.addPR('ok', 1, 'a/b', 'b1');
767
+ board.updatePR('ok', { verdict: 'APPROVE' });
768
+ board.completePR('ok', { elapsedMs: 1000, url: 'u1' });
769
+ board.addPR('bad', 2, 'a/b', 'b2');
770
+ board.failPR('bad', 'codex: timed out after 600s');
771
+ const output = stripAnsi(invokeRender());
772
+ expect(output).toContain('APPROVE 50%');
773
+ expect(output).toContain('error 50%');
774
+ });
775
+ it('takes the recheck verdict as the outcome when a recheck ran', () => {
776
+ board.addPR('k1', 1, 'a/b', 'b1');
777
+ board.updatePR('k1', { verdict: 'BLOCK', recheckVerdict: 'APPROVE' });
778
+ board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
779
+ // Scoped to the stats block: the PR row still shows the round's own BLOCK in
780
+ // its CR bar, which is correct — only the split should read APPROVE.
781
+ const stats = stripAnsi(invokeRender()).split(/^─+$/m)[1] ?? '';
782
+ expect(stats).toContain('APPROVE 100%');
783
+ expect(stats).not.toContain('BLOCK');
784
+ });
785
+ it('separates a reviewer that returned nothing from one that never ran', () => {
786
+ board.addPR('noverdict', 1, 'a/b', 'b1');
787
+ board.updatePR('noverdict', { verdict: null });
788
+ board.completePR('noverdict', { elapsedMs: 1000, url: 'u1' });
789
+ board.addPR('skipped', 2, 'a/b', 'b2');
790
+ board.completePR('skipped', { elapsedMs: 900, url: 'u2', label: 'skipped \u00b7 generated' });
791
+ const output = stripAnsi(invokeRender());
792
+ expect(output).toContain('no verdict 50%');
793
+ expect(output).toContain('skipped 50%');
794
+ });
795
+ it('never pairs a "<1%" outcome with a flat 100%', () => {
796
+ for (let i = 0; i < 999; i++) {
797
+ board.addPR(`e${i}`, i, 'a/b', 'x');
798
+ board.failPR(`e${i}`, 'boom');
799
+ }
800
+ board.addPR('ok', 9999, 'a/b', 'y');
801
+ board.updatePR('ok', { verdict: 'APPROVE' });
802
+ board.completePR('ok', { elapsedMs: 1, url: 'u' });
803
+ const output = stripAnsi(invokeRender());
804
+ expect(output).toContain('APPROVE <1%');
805
+ expect(output).toContain('error >99%');
806
+ expect(output).not.toContain('error 100%');
807
+ });
808
+ it('still reads a clean 100% when one outcome is the only one', () => {
809
+ for (let i = 0; i < 5; i++) {
810
+ board.addPR(`e${i}`, i, 'a/b', 'x');
811
+ board.failPR(`e${i}`, 'boom');
812
+ }
813
+ expect(stripAnsi(invokeRender())).toContain('error 100%');
814
+ });
815
+ it('keeps the cumulative split after the slot history cap drops old rows', () => {
816
+ const stats = board.stats;
817
+ board.addPR('k1', 1, 'a/b', 'b1');
818
+ board.updatePR('k1', { verdict: 'APPROVE' });
819
+ board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
820
+ board.slots.clear();
821
+ expect(stats.outcomes.APPROVE).toBe(1);
822
+ expect(stripAnsi(invokeRender())).toContain('APPROVE 100%');
823
+ });
317
824
  });
318
825
  // Minimal VT emulator: LF scrolls at the bottom row, CUU clamps at the viewport
319
826
  // top, ED-0J clears cursor→end-of-screen, autowrap is deferred at the last