@humanbased/crosscheck 1.6.0-beta.5 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/README.md +2 -0
  2. package/crosscheck.config.example.yml +17 -4
  3. package/dist/__tests__/board.test.js +332 -33
  4. package/dist/__tests__/board.test.js.map +1 -1
  5. package/dist/__tests__/lock-ownership.test.d.ts +2 -0
  6. package/dist/__tests__/lock-ownership.test.d.ts.map +1 -0
  7. package/dist/__tests__/lock-ownership.test.js +167 -0
  8. package/dist/__tests__/lock-ownership.test.js.map +1 -0
  9. package/dist/__tests__/optimize.test.js +4 -3
  10. package/dist/__tests__/optimize.test.js.map +1 -1
  11. package/dist/__tests__/repository-cache.test.d.ts +2 -0
  12. package/dist/__tests__/repository-cache.test.d.ts.map +1 -0
  13. package/dist/__tests__/repository-cache.test.js +91 -0
  14. package/dist/__tests__/repository-cache.test.js.map +1 -0
  15. package/dist/__tests__/repository-guidance.test.js +1 -1
  16. package/dist/__tests__/repository-guidance.test.js.map +1 -1
  17. package/dist/__tests__/review-command-strategy.test.d.ts +2 -0
  18. package/dist/__tests__/review-command-strategy.test.d.ts.map +1 -0
  19. package/dist/__tests__/review-command-strategy.test.js +194 -0
  20. package/dist/__tests__/review-command-strategy.test.js.map +1 -0
  21. package/dist/__tests__/review-efficiency-config.test.d.ts +2 -0
  22. package/dist/__tests__/review-efficiency-config.test.d.ts.map +1 -0
  23. package/dist/__tests__/review-efficiency-config.test.js +16 -0
  24. package/dist/__tests__/review-efficiency-config.test.js.map +1 -0
  25. package/dist/__tests__/review-memory.test.d.ts +2 -0
  26. package/dist/__tests__/review-memory.test.d.ts.map +1 -0
  27. package/dist/__tests__/review-memory.test.js +252 -0
  28. package/dist/__tests__/review-memory.test.js.map +1 -0
  29. package/dist/__tests__/review-models.test.js +1 -0
  30. package/dist/__tests__/review-models.test.js.map +1 -1
  31. package/dist/__tests__/review-strategy.test.js +78 -3
  32. package/dist/__tests__/review-strategy.test.js.map +1 -1
  33. package/dist/__tests__/vendor-error-summary.test.d.ts +2 -0
  34. package/dist/__tests__/vendor-error-summary.test.d.ts.map +1 -0
  35. package/dist/__tests__/vendor-error-summary.test.js +74 -0
  36. package/dist/__tests__/vendor-error-summary.test.js.map +1 -0
  37. package/dist/__tests__/watch-concurrency.test.d.ts +2 -0
  38. package/dist/__tests__/watch-concurrency.test.d.ts.map +1 -0
  39. package/dist/__tests__/watch-concurrency.test.js +53 -0
  40. package/dist/__tests__/watch-concurrency.test.js.map +1 -0
  41. package/dist/cli.js +18 -2
  42. package/dist/cli.js.map +1 -1
  43. package/dist/commands/onboard.js +2 -2
  44. package/dist/commands/onboard.js.map +1 -1
  45. package/dist/commands/review.d.ts +3 -1
  46. package/dist/commands/review.d.ts.map +1 -1
  47. package/dist/commands/review.js +146 -7
  48. package/dist/commands/review.js.map +1 -1
  49. package/dist/commands/run.d.ts.map +1 -1
  50. package/dist/commands/run.js +24 -6
  51. package/dist/commands/run.js.map +1 -1
  52. package/dist/commands/watch.d.ts +3 -0
  53. package/dist/commands/watch.d.ts.map +1 -1
  54. package/dist/commands/watch.js +170 -25
  55. package/dist/commands/watch.js.map +1 -1
  56. package/dist/config/schema.d.ts +27 -0
  57. package/dist/config/schema.d.ts.map +1 -1
  58. package/dist/config/schema.js +12 -1
  59. package/dist/config/schema.js.map +1 -1
  60. package/dist/github/review-status.d.ts +33 -0
  61. package/dist/github/review-status.d.ts.map +1 -1
  62. package/dist/github/review-status.js +94 -9
  63. package/dist/github/review-status.js.map +1 -1
  64. package/dist/lib/board.d.ts +32 -1
  65. package/dist/lib/board.d.ts.map +1 -1
  66. package/dist/lib/board.js +169 -50
  67. package/dist/lib/board.js.map +1 -1
  68. package/dist/lib/clone.d.ts +3 -0
  69. package/dist/lib/clone.d.ts.map +1 -1
  70. package/dist/lib/clone.js +21 -1
  71. package/dist/lib/clone.js.map +1 -1
  72. package/dist/lib/pr-lock.d.ts +37 -1
  73. package/dist/lib/pr-lock.d.ts.map +1 -1
  74. package/dist/lib/pr-lock.js +176 -29
  75. package/dist/lib/pr-lock.js.map +1 -1
  76. package/dist/lib/pr-workflow-state.d.ts +2 -0
  77. package/dist/lib/pr-workflow-state.d.ts.map +1 -1
  78. package/dist/lib/pr-workflow-state.js +15 -0
  79. package/dist/lib/pr-workflow-state.js.map +1 -1
  80. package/dist/lib/repository-cache.d.ts +18 -0
  81. package/dist/lib/repository-cache.d.ts.map +1 -0
  82. package/dist/lib/repository-cache.js +125 -0
  83. package/dist/lib/repository-cache.js.map +1 -0
  84. package/dist/lib/review-memory.d.ts +212 -0
  85. package/dist/lib/review-memory.d.ts.map +1 -0
  86. package/dist/lib/review-memory.js +305 -0
  87. package/dist/lib/review-memory.js.map +1 -0
  88. package/dist/lib/review-models.d.ts +2 -2
  89. package/dist/lib/review-models.js +2 -2
  90. package/dist/lib/runner.d.ts +37 -2
  91. package/dist/lib/runner.d.ts.map +1 -1
  92. package/dist/lib/runner.js +81 -28
  93. package/dist/lib/runner.js.map +1 -1
  94. package/dist/lib/tips.js +1 -1
  95. package/dist/lib/tips.js.map +1 -1
  96. package/dist/lib/vendor-error-summary.d.ts +19 -0
  97. package/dist/lib/vendor-error-summary.d.ts.map +1 -0
  98. package/dist/lib/vendor-error-summary.js +95 -0
  99. package/dist/lib/vendor-error-summary.js.map +1 -0
  100. package/dist/reviewers/claude.d.ts.map +1 -1
  101. package/dist/reviewers/claude.js +2 -1
  102. package/dist/reviewers/claude.js.map +1 -1
  103. package/dist/reviewers/codex.d.ts.map +1 -1
  104. package/dist/reviewers/codex.js +2 -1
  105. package/dist/reviewers/codex.js.map +1 -1
  106. package/docs/dynamic-thoroughness.md +7 -4
  107. package/get-started.md +83 -9
  108. package/package.json +1 -1
package/README.md CHANGED
@@ -257,6 +257,8 @@ Classification may set a **floor**, or promote on **consequence** — a security
257
257
 
258
258
  Class tier, effort, **and** step set are all applied. The class is resolved once per workflow, not per step — the fix step pushes commits, so re-classifying could make the review and recheck comments cite different tiers for the same PR.
259
259
 
260
+ `crosscheck review <pr>` classifies the same way and runs at the class's tier and effort. It is an explicit request for one review, so the step set does not apply, and a class that would skip the PR is overridden: the review runs at the configured `quality.tier`, as `crosscheck run --steps review` does.
261
+
260
262
  The step set **narrows** the configured pipeline and never widens it: a repo pinned to review-only with `crosscheck alter` stays review-only whatever the class says.
261
263
 
262
264
  Rounds beyond the first escalate on measured non-convergence rather than prediction — effort rises where the model supports it, the tier is promoted where it does not, and the model never weakens.
@@ -21,6 +21,8 @@ mode: cross-vendor
21
21
  # Pick https if you have a multi-account SSH setup or your default SSH key
22
22
  # cannot access target repos. Independent of `gh config get git_protocol`.
23
23
  clone_protocol: ssh
24
+ # Reuse fetched Git objects; set false to always clone from scratch.
25
+ repository_cache: true
24
26
 
25
27
  # ─── Vendors ─────────────────────────────────────────────────────────────────
26
28
  vendors:
@@ -39,6 +41,8 @@ vendors:
39
41
 
40
42
  # ─── Quality & Focus ─────────────────────────────────────────────────────────
41
43
  quality:
44
+ # Structured findings and incremental follow-ups; set false for legacy prose reviews.
45
+ review_memory: true
42
46
  mode: smart # smart (default) | fixed
43
47
  #
44
48
  # smart - dynamically adjust model + effort based on task type. The PR is
@@ -67,7 +71,9 @@ quality:
67
71
  tier: balanced # fast | balanced | thorough
68
72
  #
69
73
  # Under `fixed` this is the tier for every call. Under `smart` it is the
70
- # fallback used when a PR's file list cannot be read.
74
+ # fallback used when a PR's file list cannot be read, and when an explicit
75
+ # request (`crosscheck review`, `run --steps`) overrides a class that would
76
+ # skip the PR.
71
77
  #
72
78
  # Cost is output-token cost at 48k output tokens, the measured median for one
73
79
  # review. A review is an agentic session, so it takes 10-16 minutes of wall
@@ -254,12 +260,19 @@ post_review:
254
260
  # timeout_min: 30 # minutes of no PR activity before the idle prompt fires (min: 5)
255
261
 
256
262
  # ─── Backtrace ───────────────────────────────────────────────────────────────
257
- # On startup, scan all open PRs in the monitored scope and review any that have
258
- # never received a [crosscheck] comment. Ensures coverage for PRs opened while
263
+ # Scan all open PRs in the monitored scope and review any that have never
264
+ # received a [crosscheck] comment. Ensures coverage for PRs opened while
259
265
  # crosscheck was offline or before it was first set up.
260
266
  # Off by default — enable with the config below or pass --backtrace at startup.
267
+ #
268
+ # Webhook delivery is best-effort, so the scan is also the recovery path for PRs
269
+ # no future event will mention: opened while watch was down, an org hook that
270
+ # failed to register, a smee reconnect gap, or a push dropped while the PR was
271
+ # locked. Set interval_min to re-run it on a cadence, not just at startup.
261
272
  # backtrace:
262
- # enabled: true # set to true to enable the startup scan (or pass --backtrace)
273
+ # enabled: true # set to true to enable the scan (or pass --backtrace)
274
+ # interval_min: 30 # re-scan every N minutes while watch is up; 0 = startup only
275
+ # concurrency: 2 # max reviews the scan starts at once (default 2)
263
276
 
264
277
  # ─── Issue enrichment ────────────────────────────────────────────────────────
265
278
  # Recover the linked tracker issue (e.g. IN-2017) from the PR title / branch /
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import { EventEmitter } from 'events';
3
- import { PRBoard, fmtTokens, pageKeyAction, isNoticeLine } from '../lib/board.js';
3
+ import chalk from 'chalk';
4
+ import { PRBoard, fmtEnteredAt, fmtTokens, pageKeyAction, isNoticeLine, distribute, fmtUptime } from '../lib/board.js';
4
5
  describe('fmtTokens', () => {
5
6
  it('returns empty string for undefined', () => {
6
7
  expect(fmtTokens(undefined)).toBe('');
@@ -87,6 +88,14 @@ describe('PRBoard — TTY workspace retention', () => {
87
88
  const invokeRender = () => board.render();
88
89
  const invokeFolded = (key) => board.renderPRSlotFolded(slots().get(key));
89
90
  const superseded = (key) => slots().get(key)?.superseded === true;
91
+ // The PR rows only. Layout is config │ stats │ workspace │ footer, separated by
92
+ // rules, so the third block is the workspace. Assertions about which PRs are on
93
+ // screen scope to this: the stats panel names verdicts too (outcome shares), so
94
+ // searching the whole frame for "BLOCK" no longer answers "is that row shown?".
95
+ const workspaceOf = (output) => {
96
+ const blocks = output.split(/^─+$/m);
97
+ return blocks[2] ?? '';
98
+ };
90
99
  it('keeps completed slot in the workspace (no auto-clear)', () => {
91
100
  board.addPR('k1', 1, 'a/b', 'main');
92
101
  board.completePR('k1', { elapsedMs: 1000, url: 'https://github.com/a/b/pull/1' });
@@ -167,9 +176,9 @@ describe('PRBoard — TTY workspace retention', () => {
167
176
  expect(slots().has('k1@sha2')).toBe(true); // new round present
168
177
  board.updatePR('k1@sha2', { recheckVerdict: 'APPROVE' });
169
178
  board.completePR('k1@sha2', { elapsedMs: 362_000, url: 'https://github.com/owner/repo/pull/214' });
170
- const output = stripAnsi(invokeRender());
171
- expect(output).not.toContain('BLOCK');
172
- expect(output).toContain('APPROVE');
179
+ const workspace = workspaceOf(stripAnsi(invokeRender()));
180
+ expect(workspace).not.toContain('BLOCK');
181
+ expect(workspace).toContain('APPROVE');
173
182
  });
174
183
  it('does not supersede active slots when round 2 starts', () => {
175
184
  // Active round 1 for a different PR — must not be touched
@@ -319,6 +328,27 @@ describe('PRBoard — viewport height fitting', () => {
319
328
  const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
320
329
  expect(residue).toEqual([]);
321
330
  });
331
+ // failPR settles into the live block now rather than printing a static line
332
+ // per failure. This pins that: a long run of failures on a short viewport must
333
+ // stay a redraw, never scrollback. (It is an invariant guard, not a reproducer
334
+ // — the doubled header operators report comes from a viewport resize, which
335
+ // strands rows above the cursor-up clamp regardless of what wrote them.)
336
+ it('keeps a long run of failures in the live block, not scrollback', () => {
337
+ const ROWS = 14, COLS = 100;
338
+ setViewport(ROWS, COLS);
339
+ board.setTunnel('smee', 'https://smee.io/test', true);
340
+ invokeRedraw();
341
+ for (let i = 0; i < 20; i++) {
342
+ board.addPR(`k${i}`, 4600 + i, 'humanbased-ai/monorepo', `codex/branch-${i}`);
343
+ invokeRedraw();
344
+ board.failPR(`k${i}`, 'codex: timed out after 1200s (retried once) — PR diff may be too large');
345
+ invokeRedraw();
346
+ }
347
+ const { scrollback } = emulateVT(captured.join(''), ROWS, COLS);
348
+ const liveOnlyMarkers = ['crosscheck', 'workflow:', 'vendors:', 'PRs:', 'tunnel:'];
349
+ const residue = scrollback.filter(l => liveOnlyMarkers.some(m => l.includes(m)));
350
+ expect(residue).toEqual([]);
351
+ });
322
352
  });
323
353
  // ── History pagination ────────────────────────────────────────────────────────
324
354
  //
@@ -353,6 +383,7 @@ describe('PRBoard — history pagination', () => {
353
383
  const page = () => board.page;
354
384
  const pageCount = () => board.pageCount;
355
385
  // The footer is the last line — the tip line also names the page keys.
386
+ const slotsOf = (b) => b.slots;
356
387
  const footerOf = (content) => stripAnsi(content).split('\n').at(-1) ?? '';
357
388
  const addCompleted = (n) => {
358
389
  for (let i = 0; i < n; i++) {
@@ -402,7 +433,7 @@ describe('PRBoard — history pagination', () => {
402
433
  board.pageOlder();
403
434
  const older = footerOf(invokeRender());
404
435
  expect(older).toContain('history · page 2/');
405
- expect(older).toContain('ctrl+<');
436
+ expect(older).toContain('→');
406
437
  });
407
438
  it('keeps a history page inside the viewport', () => {
408
439
  addCompleted(60);
@@ -412,11 +443,50 @@ describe('PRBoard — history pagination', () => {
412
443
  .reduce((sum, l) => sum + Math.max(1, Math.ceil(l.length / 120)), 0);
413
444
  expect(rows).toBeLessThanOrEqual(19);
414
445
  });
446
+ it('mutes the page key that has nowhere to go', () => {
447
+ const level = chalk.level;
448
+ chalk.level = 3;
449
+ try {
450
+ addCompleted(60);
451
+ const rawFooter = () => invokeRender().split('\n').at(-1) ?? '';
452
+ const muted = (k) => chalk.gray.dim(k);
453
+ // Live page: nothing newer, so ← fades; → still leads somewhere.
454
+ expect(rawFooter()).toContain(muted('← newer'));
455
+ expect(rawFooter()).not.toContain(muted('→ older'));
456
+ board.pageOlder();
457
+ expect(rawFooter()).not.toContain(muted('← newer'));
458
+ expect(rawFooter()).not.toContain(muted('→ older'));
459
+ for (let i = 0; i < 50; i++)
460
+ board.pageOlder();
461
+ expect(rawFooter()).toContain(muted('→ older'));
462
+ expect(rawFooter()).not.toContain(muted('← newer'));
463
+ }
464
+ finally {
465
+ chalk.level = level;
466
+ }
467
+ });
468
+ it('shows when each PR entered, in a column after the PR number', () => {
469
+ board.addPR('a', 7, 'acme/api', 'branch-a');
470
+ board.addPR('b', 4880, 'acme/api', 'branch-b');
471
+ for (const k of ['a', 'b']) {
472
+ board.updatePR(k, { verdict: 'APPROVE', commentCount: 0 });
473
+ board.completePR(k, { elapsedMs: 60_000, url: `https://github.com/acme/api/pull/${k}` });
474
+ }
475
+ const rows = stripAnsi(invokeRender()).split('\n').filter(l => l.includes('branch-'));
476
+ expect(rows).toHaveLength(2);
477
+ const stampOf = (k) => fmtEnteredAt(slotsOf(board).get(k).startedAt);
478
+ const [sa, sb] = [stampOf('a'), stampOf('b')];
479
+ expect(rows[0]).toContain(sa);
480
+ expect(rows[1]).toContain(sb);
481
+ // #7 is padded to #4880's width, so the timestamp starts in the same column.
482
+ expect(rows[0].indexOf(sa)).toBe(rows[1].indexOf(sb));
483
+ expect(rows[0].indexOf('#7')).toBeLessThan(rows[0].indexOf(sa));
484
+ });
415
485
  it('has one page when everything fits on the live page', () => {
416
486
  addCompleted(2);
417
487
  invokeRender();
418
488
  expect(pageCount()).toBe(1);
419
- expect(footerOf(invokeRender())).not.toContain('ctrl+<'); // no keys offered with nowhere to go
489
+ expect(footerOf(invokeRender())).not.toContain('older'); // no keys offered with nowhere to go
420
490
  expect(footerOf(invokeRender())).toContain('showing 2 of 2');
421
491
  });
422
492
  });
@@ -477,16 +547,16 @@ describe('PRBoard — key input', () => {
477
547
  fillHistory();
478
548
  board.start();
479
549
  expect(fake.isRaw).toBe(true);
480
- fake.emit('data', Buffer.from('<'));
550
+ fake.emit('data', Buffer.from('\u001b[C'));
481
551
  expect(page()).toBe(1);
482
- fake.emit('data', Buffer.from('<'));
552
+ fake.emit('data', Buffer.from('\u001b[C'));
483
553
  expect(page()).toBe(2);
484
- fake.emit('data', Buffer.from('>'));
554
+ fake.emit('data', Buffer.from('\u001b[D'));
485
555
  expect(page()).toBe(1);
486
556
  board.stop();
487
557
  expect(fake.isRaw).toBe(false); // terminal handed back
488
558
  expect(fake.listenerCount('data')).toBe(0);
489
- fake.emit('data', Buffer.from('<'));
559
+ fake.emit('data', Buffer.from('\u001b[C'));
490
560
  expect(page()).toBe(1); // no longer listening
491
561
  });
492
562
  it('raises SIGINT itself, since raw mode suppresses the terminal ctrl-c', () => {
@@ -505,29 +575,17 @@ describe('PRBoard — key input', () => {
505
575
  });
506
576
  });
507
577
  describe('pageKeyAction', () => {
508
- it('maps bare < and > and their unshifted keys', () => {
509
- expect(pageKeyAction('<')).toBe('older');
510
- expect(pageKeyAction(',')).toBe('older');
511
- expect(pageKeyAction('>')).toBe('newer');
512
- expect(pageKeyAction('.')).toBe('newer');
513
- });
514
- it('maps the CSI-u encodings terminals use for ctrl/cmd + punctuation', () => {
515
- expect(pageKeyAction('\u001b[44;5u')).toBe('older'); // ctrl+,
516
- expect(pageKeyAction('\u001b[46;5u')).toBe('newer'); // ctrl+.
517
- expect(pageKeyAction('\u001b[60;9u')).toBe('older'); // cmd+<
518
- expect(pageKeyAction('\u001b[62;9u')).toBe('newer'); // cmd+>
519
- });
520
- it('maps plain and modified arrows', () => {
521
- expect(pageKeyAction('\u001b[D')).toBe('older');
522
- expect(pageKeyAction('\u001b[C')).toBe('newer');
523
- expect(pageKeyAction('\u001b[1;5D')).toBe('older');
524
- expect(pageKeyAction('\u001b[1;5C')).toBe('newer');
525
- });
526
- it('ignores everything else', () => {
527
- expect(pageKeyAction('a')).toBe(null);
528
- expect(pageKeyAction('\u0003')).toBe(null);
529
- expect(pageKeyAction('\u001b[A')).toBe(null);
530
- expect(pageKeyAction('\u001b[48;5u')).toBe(null);
578
+ it('maps the left and right arrows, in normal and application cursor mode', () => {
579
+ expect(pageKeyAction('\u001b[D')).toBe('newer');
580
+ expect(pageKeyAction('\u001b[C')).toBe('older');
581
+ expect(pageKeyAction('\u001bOD')).toBe('newer');
582
+ expect(pageKeyAction('\u001bOC')).toBe('older');
583
+ });
584
+ it('ignores every other key, including the retired < > , . and modified-arrow bindings', () => {
585
+ for (const seq of ['<', '>', ',', '.', '\u001b[1;5D', '\u001b[1;5C', '\u001bb', '\u001bf',
586
+ '\u001b\u001b[D', '\u001b[44;5u', '\u001b[5~', '\u001b[6~', 'a', '\u0003', '\u001b[A']) {
587
+ expect(pageKeyAction(seq)).toBe(null);
588
+ }
531
589
  });
532
590
  });
533
591
  describe('isNoticeLine', () => {
@@ -544,6 +602,247 @@ describe('isNoticeLine', () => {
544
602
  expect(isNoticeLine(' skills: typescript')).toBe(false);
545
603
  });
546
604
  });
605
+ describe('distribute', () => {
606
+ it('returns all zeroes when nothing has been counted', () => {
607
+ expect(distribute([0, 0, 0])).toEqual([0, 0, 0]);
608
+ });
609
+ it('sums to exactly 100 where plain rounding would not', () => {
610
+ // Three equal shares floor to 33 each; the leftover point goes to a remainder.
611
+ const out = distribute([1, 1, 1]);
612
+ expect(out.reduce((a, b) => a + b, 0)).toBe(100);
613
+ expect(out.sort((a, b) => a - b)).toEqual([33, 33, 34]);
614
+ });
615
+ it('sums to 100 across a spread of awkward splits', () => {
616
+ const cases = [[1, 2], [1, 1, 1, 1, 1, 1], [7, 11, 13], [95, 1, 1, 1, 1, 1], [1, 999]];
617
+ for (const c of cases) {
618
+ expect(distribute(c).reduce((a, b) => a + b, 0)).toBe(100);
619
+ }
620
+ });
621
+ it('gives a lone outcome the whole 100%', () => {
622
+ expect(distribute([43, 0, 0])).toEqual([100, 0, 0]);
623
+ });
624
+ it('reports 0 for a share too small to earn a point, so callers can say "<1%"', () => {
625
+ const out = distribute([999, 1]);
626
+ expect(out[1]).toBe(0);
627
+ expect(out.reduce((a, b) => a + b, 0)).toBe(100);
628
+ });
629
+ });
630
+ describe('fmtUptime', () => {
631
+ it('renders minutes only under an hour', () => {
632
+ expect(fmtUptime(0)).toBe('0m');
633
+ expect(fmtUptime(59_000)).toBe('0m');
634
+ expect(fmtUptime(12 * 60_000)).toBe('12m');
635
+ });
636
+ it('renders hours and zero-padded minutes past the hour', () => {
637
+ expect(fmtUptime(60 * 60_000)).toBe('1h00m');
638
+ expect(fmtUptime((3 * 60 + 32) * 60_000)).toBe('3h32m');
639
+ expect(fmtUptime((11 * 60 + 57) * 60_000)).toBe('11h57m');
640
+ });
641
+ it('never renders a negative age if the clock steps backwards', () => {
642
+ expect(fmtUptime(-5000)).toBe('0m');
643
+ });
644
+ });
645
+ describe('PRBoard \u2014 failed PRs stay in the workspace', () => {
646
+ let board;
647
+ let originalIsTTY;
648
+ let originalRows;
649
+ let originalWrite;
650
+ let written;
651
+ beforeEach(() => {
652
+ originalIsTTY = process.stdout.isTTY;
653
+ originalRows = process.stdout.rows;
654
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
655
+ Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
656
+ originalWrite = process.stdout.write.bind(process.stdout);
657
+ written = [];
658
+ process.stdout.write = ((chunk) => { written.push(String(chunk)); return true; });
659
+ board = new PRBoard();
660
+ board.setConfig(baseConfig, [reviewStep]);
661
+ });
662
+ afterEach(() => {
663
+ board.stop();
664
+ process.stdout.write = originalWrite;
665
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
666
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
667
+ });
668
+ const slots = () => board.slots;
669
+ const invokeRender = () => board.render();
670
+ it('keeps the slot instead of deleting it', () => {
671
+ board.addPR('k1', 4641, 'a/b', 'feat/x');
672
+ board.failPR('k1', 'codex: timed out after 600s');
673
+ expect(slots().size).toBe(1);
674
+ expect(slots().has('k1')).toBe(true);
675
+ });
676
+ it('renders the failed PR as a row carrying its error, not as scrollback', () => {
677
+ board.addPR('k1', 4641, 'a/b', 'feat/x');
678
+ board.failPR('k1', 'codex: timed out after 600s');
679
+ const output = stripAnsi(invokeRender());
680
+ expect(output).toContain('#4641');
681
+ expect(output).toContain('codex: timed out after 600s');
682
+ // The regression: the row went to scrollback and the table read "no PRs yet".
683
+ expect(output).not.toContain('no PRs yet');
684
+ expect(written.join('')).not.toContain('codex: timed out after 600s');
685
+ });
686
+ it('folds a failed row even when it is the only one on the page', () => {
687
+ board.addPR('k1', 4641, 'a/b', 'feat/x');
688
+ board.failPR('k1', 'codex: timed out after 600s');
689
+ const output = stripAnsi(invokeRender());
690
+ // Folded rows carry no pipeline bars; an expanded row would show "CR" queued
691
+ // against work the failed run will never do.
692
+ expect(output).not.toMatch(/CR [\u2588\u2591]/);
693
+ expect(output).toContain('\u2717 #4641');
694
+ });
695
+ it('flattens a multi-line subprocess dump into a single row', () => {
696
+ board.addPR('k1', 4823, 'a/b', 'feat/x');
697
+ board.failPR('k1', 'claude: Command failed with exit code 1: claude --print\n\n{"is_error":true,\n"result":"limit reached"}');
698
+ const rows = stripAnsi(invokeRender()).split('\n').filter(l => l.includes('#4823'));
699
+ // One row per folded slot is what history pagination sizes a page by.
700
+ expect(rows).toHaveLength(1);
701
+ expect(rows[0]).not.toContain('{');
702
+ expect(rows[0]).toContain('claude: Command failed');
703
+ });
704
+ it('counts a failure once, toward errors and the outcome split', () => {
705
+ board.addPR('k1', 1, 'a/b', 'feat/x');
706
+ board.failPR('k1', 'boom');
707
+ const stats = board.stats;
708
+ expect(stats.errorsOccurred).toBe(1);
709
+ expect(stats.outcomes.error).toBe(1);
710
+ });
711
+ it('still counts the error when the slot is already gone', () => {
712
+ board.failPR('never-added', 'boom');
713
+ const stats = board.stats;
714
+ expect(stats.errorsOccurred).toBe(1);
715
+ // No slot means no row, so nothing should be added to the distribution.
716
+ expect(stats.outcomes.error).toBe(0);
717
+ });
718
+ it('pages failed rows into history like any other settled row', () => {
719
+ for (let i = 0; i < 40; i++) {
720
+ board.addPR(`k${i}`, 4600 + i, 'a/b', `feat/${i}`);
721
+ board.failPR(`k${i}`, 'codex: timed out after 600s');
722
+ }
723
+ invokeRender();
724
+ expect(slots().size).toBe(40);
725
+ const pageCount = board.pageCount;
726
+ expect(pageCount).toBeGreaterThan(0);
727
+ });
728
+ it('emits the folded line to scrollback and drops the slot when not a TTY', () => {
729
+ Object.defineProperty(process.stdout, 'isTTY', { value: false, configurable: true });
730
+ const nonTty = new PRBoard();
731
+ nonTty.setConfig(baseConfig, [reviewStep]);
732
+ nonTty.addPR('k1', 4641, 'a/b', 'feat/x');
733
+ nonTty.failPR('k1', 'codex: timed out after 600s');
734
+ expect(stripAnsi(written.join(''))).toContain('codex: timed out after 600s');
735
+ expect(nonTty.slots.size).toBe(0);
736
+ });
737
+ });
738
+ describe('PRBoard \u2014 session stats panel', () => {
739
+ let board;
740
+ let originalIsTTY;
741
+ let originalRows;
742
+ let originalWrite;
743
+ beforeEach(() => {
744
+ originalIsTTY = process.stdout.isTTY;
745
+ originalRows = process.stdout.rows;
746
+ Object.defineProperty(process.stdout, 'isTTY', { value: true, configurable: true });
747
+ Object.defineProperty(process.stdout, 'rows', { value: 200, configurable: true });
748
+ originalWrite = process.stdout.write.bind(process.stdout);
749
+ process.stdout.write = (() => true);
750
+ board = new PRBoard();
751
+ board.setConfig(baseConfig, [reviewStep]);
752
+ });
753
+ afterEach(() => {
754
+ board.stop();
755
+ process.stdout.write = originalWrite;
756
+ Object.defineProperty(process.stdout, 'isTTY', { value: originalIsTTY, configurable: true });
757
+ Object.defineProperty(process.stdout, 'rows', { value: originalRows, configurable: true });
758
+ });
759
+ const invokeRender = () => board.render();
760
+ const setStart = (ms) => (board.stats.sessionStart = ms);
761
+ it('shows the local start time and the session age', () => {
762
+ const start = Date.now() - (3 * 60 + 32) * 60_000;
763
+ setStart(start);
764
+ const output = stripAnsi(invokeRender());
765
+ const expected = new Date(start).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: true });
766
+ expect(output).toContain(`since ${expected}`);
767
+ expect(output).toContain('up 3h32m');
768
+ });
769
+ it('omits the outcome split until something settles', () => {
770
+ const output = stripAnsi(invokeRender());
771
+ expect(output).toContain('since');
772
+ expect(output).not.toContain('%');
773
+ });
774
+ it('reports the outcome split as percentages that sum to 100', () => {
775
+ const verdicts = ['APPROVE', 'APPROVE', 'BLOCK', 'NEEDS WORK'];
776
+ verdicts.forEach((v, i) => {
777
+ board.addPR(`k${i}`, i, 'a/b', `b${i}`);
778
+ board.updatePR(`k${i}`, { verdict: v });
779
+ board.completePR(`k${i}`, { elapsedMs: 1000, url: `u${i}` });
780
+ });
781
+ const output = stripAnsi(invokeRender());
782
+ expect(output).toContain('APPROVE 50%');
783
+ expect(output).toContain('BLOCK 25%');
784
+ expect(output).toContain('NEEDS WORK 25%');
785
+ });
786
+ it('counts a failure in the split alongside verdicts', () => {
787
+ board.addPR('ok', 1, 'a/b', 'b1');
788
+ board.updatePR('ok', { verdict: 'APPROVE' });
789
+ board.completePR('ok', { elapsedMs: 1000, url: 'u1' });
790
+ board.addPR('bad', 2, 'a/b', 'b2');
791
+ board.failPR('bad', 'codex: timed out after 600s');
792
+ const output = stripAnsi(invokeRender());
793
+ expect(output).toContain('APPROVE 50%');
794
+ expect(output).toContain('error 50%');
795
+ });
796
+ it('takes the recheck verdict as the outcome when a recheck ran', () => {
797
+ board.addPR('k1', 1, 'a/b', 'b1');
798
+ board.updatePR('k1', { verdict: 'BLOCK', recheckVerdict: 'APPROVE' });
799
+ board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
800
+ // Scoped to the stats block: the PR row still shows the round's own BLOCK in
801
+ // its CR bar, which is correct — only the split should read APPROVE.
802
+ const stats = stripAnsi(invokeRender()).split(/^─+$/m)[1] ?? '';
803
+ expect(stats).toContain('APPROVE 100%');
804
+ expect(stats).not.toContain('BLOCK');
805
+ });
806
+ it('separates a reviewer that returned nothing from one that never ran', () => {
807
+ board.addPR('noverdict', 1, 'a/b', 'b1');
808
+ board.updatePR('noverdict', { verdict: null });
809
+ board.completePR('noverdict', { elapsedMs: 1000, url: 'u1' });
810
+ board.addPR('skipped', 2, 'a/b', 'b2');
811
+ board.completePR('skipped', { elapsedMs: 900, url: 'u2', label: 'skipped \u00b7 generated' });
812
+ const output = stripAnsi(invokeRender());
813
+ expect(output).toContain('no verdict 50%');
814
+ expect(output).toContain('skipped 50%');
815
+ });
816
+ it('never pairs a "<1%" outcome with a flat 100%', () => {
817
+ for (let i = 0; i < 999; i++) {
818
+ board.addPR(`e${i}`, i, 'a/b', 'x');
819
+ board.failPR(`e${i}`, 'boom');
820
+ }
821
+ board.addPR('ok', 9999, 'a/b', 'y');
822
+ board.updatePR('ok', { verdict: 'APPROVE' });
823
+ board.completePR('ok', { elapsedMs: 1, url: 'u' });
824
+ const output = stripAnsi(invokeRender());
825
+ expect(output).toContain('APPROVE <1%');
826
+ expect(output).toContain('error >99%');
827
+ expect(output).not.toContain('error 100%');
828
+ });
829
+ it('still reads a clean 100% when one outcome is the only one', () => {
830
+ for (let i = 0; i < 5; i++) {
831
+ board.addPR(`e${i}`, i, 'a/b', 'x');
832
+ board.failPR(`e${i}`, 'boom');
833
+ }
834
+ expect(stripAnsi(invokeRender())).toContain('error 100%');
835
+ });
836
+ it('keeps the cumulative split after the slot history cap drops old rows', () => {
837
+ const stats = board.stats;
838
+ board.addPR('k1', 1, 'a/b', 'b1');
839
+ board.updatePR('k1', { verdict: 'APPROVE' });
840
+ board.completePR('k1', { elapsedMs: 1000, url: 'u1' });
841
+ board.slots.clear();
842
+ expect(stats.outcomes.APPROVE).toBe(1);
843
+ expect(stripAnsi(invokeRender())).toContain('APPROVE 100%');
844
+ });
845
+ });
547
846
  // Minimal VT emulator: LF scrolls at the bottom row, CUU clamps at the viewport
548
847
  // top, ED-0J clears cursor→end-of-screen, autowrap is deferred at the last
549
848
  // column. Rows pushed off the top are collected as scrollback.