great-cto 3.14.0 → 3.16.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.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.14.0",
5
+ "version": "3.16.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -114,8 +114,21 @@ function getPipeline(cwd = process.cwd()) {
114
114
  // signature. Surface that checkpoint AS A STAGE in the pipeline, sitting just
115
115
  // before the irreversible steps (devops/ship). It lights up when a gate is
116
116
  // awaiting a human — so the operator always sees where the rails are.
117
+ // Awaiting a signature and blocked are not the same thing, and this said they
118
+ // were. `getInbox`'s pendingGates excludes blocked gates — deliberately, and
119
+ // under test: in a tasks.md project a gate marked `blocked` carried raw_status
120
+ // 'open' and never left the inbox. This filter excluded only done/closed, so
121
+ // the same gate the tile reported as 0 PENDING DECISIONS was announced here as
122
+ // 1 GATE AWAITING SIGNATURE. Two counts of one concept, on one screen,
123
+ // disagreeing over a single word.
124
+ //
125
+ // Dropping it from the rail would have been the other wrong answer: a gate
126
+ // stuck for 29 days is exactly what the rail exists to show. It is still
127
+ // shown, and now it is named for what it is.
117
128
  const openGates = tasks.filter(t => t.is_gate && t.status !== 'done' && t.status !== 'closed' && t.raw_status !== 'closed');
118
- const pending = openGates.length;
129
+ const blockedGates = openGates.filter(t => t.raw_status === 'blocked' || t.status === 'blocked');
130
+ const awaitingGates = openGates.filter(t => !blockedGates.includes(t));
131
+ const pending = awaitingGates.length;
119
132
  const newestGate = openGates.reduce((acc, t) => {
120
133
  const ts = t.updated_at || t.created_at; return (!acc || (ts && ts > acc)) ? ts : acc;
121
134
  }, null);
@@ -123,11 +136,15 @@ function getPipeline(cwd = process.cwd()) {
123
136
  const gateNode = {
124
137
  stage: 'human-gate',
125
138
  is_human_gate: true,
126
- status: pending > 0 ? 'active' : 'idle',
139
+ // Blocked is not active: an active stage is one somebody can act on now.
140
+ status: pending > 0 ? 'active' : (blockedGates.length ? 'blocked' : 'idle'),
127
141
  pending,
142
+ blocked: blockedGates.length,
128
143
  last_message: pending > 0
129
144
  ? `${pending} gate${pending > 1 ? 's' : ''} awaiting signature`
130
- : 'no gate pending',
145
+ : (blockedGates.length
146
+ ? `${blockedGates.length} gate${blockedGates.length > 1 ? 's' : ''} blocked`
147
+ : 'no gate pending'),
131
148
  verdict: null,
132
149
  ts: newestGate,
133
150
  age_min: gateAgeMs != null ? Math.round(gateAgeMs / 60000) : null,
@@ -182,7 +182,7 @@ function walk(dir, root, out, depth = 0) {
182
182
  * 8. a bare README a name that describes nothing else
183
183
  *
184
184
  * The leading filename token outranks the directory, and that ordering was
185
- * bought: 14 `docs/architecture/ADR-0NN-*.md` files were being filed as
185
+ * bought: 14 `docs/adr/ADR-0NN-*.md` files were being filed as
186
186
  * Architecture, and `docs/design/PLAN-*.md` as Design. A directory is where a
187
187
  * project dumps a category; the front of a filename is what the author called
188
188
  * THIS document. Mid-name tokens rank below the directory instead, because
@@ -31,6 +31,28 @@
31
31
  <script src="/assets/vendor/purify.min.js"></script>
32
32
  <style>
33
33
  :root {
34
+ /* Colours that used to be written straight into rules.
35
+ Twenty-nine declarations bypassed the token system, so the contrast audit —
36
+ which reads `:root` — could not see any of them. Values are carried across
37
+ unchanged: this is a rename, not a redesign, and the rendered-layout check
38
+ proves it by comparing the colours the page actually paints. */
39
+ --on-solid: #fff; /* text on a solid coloured chip or button */
40
+ --on-warm: #000; /* text on amber, where white fails contrast */
41
+ --on-accent: #04140d; /* text on the green accent */
42
+ --bg-solid: #0f1115; /* opaque button and tooltip surface */
43
+ --bg-solid-hover: #2a2d33;
44
+ --accent-hover: #00ee8a;
45
+ --verdict-ok: #4ade80;
46
+ --verdict-fail: #f87171;
47
+ --toast-bg: #1f2937;
48
+ --toast-ok: #14532d;
49
+ --toast-err: #7f1d1d;
50
+ --toast-info: #1e3a5f;
51
+ --dot-orange: #f97316;
52
+ --dot-unread: #3b82f6;
53
+ --metric-purple: #7c3aed;
54
+ --chip-agent: #2540d4;
55
+
34
56
  /* Dark emerald — matches landing greatcto.systems */
35
57
  --bg-page: #0a0e0c;
36
58
  --bg-panel: rgba(17, 22, 26, 0.85);
@@ -545,7 +567,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
545
567
  .btn-bell:hover { border-color: var(--border-strong); color: var(--text); }
546
568
  .btn-bell-badge {
547
569
  position: absolute; top: -5px; right: -5px;
548
- background: var(--accent); color: #04140d;
570
+ background: var(--accent); color: var(--on-accent);
549
571
  font-size: var(--fs-eyebrow); font-weight: 700; line-height: 1;
550
572
  min-width: 16px; height: 16px; border-radius: 8px;
551
573
  display: none; align-items: center; justify-content: center;
@@ -574,13 +596,13 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
574
596
  .icon-btn:hover { background: var(--bg-muted); color: var(--text); }
575
597
 
576
598
  .btn-new {
577
- background: #0f1115; color: #fff; border: none;
599
+ background: var(--bg-solid); color: var(--on-solid); border: none;
578
600
  height: 30px; padding: 0 14px;
579
601
  border-radius: 999px;
580
602
  font-size: var(--fs-body); font-weight: 500;
581
603
  display: inline-flex; align-items: center; gap: 6px;
582
604
  }
583
- .btn-new:hover { background: #2a2d33; }
605
+ .btn-new:hover { background: var(--bg-solid-hover); }
584
606
 
585
607
  .task-count {
586
608
  font-family: var(--mono); font-size: var(--fs-caption);
@@ -902,7 +924,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
902
924
  pointer-events: none;
903
925
  }
904
926
  .toast {
905
- background: #1f2937; color: #fff;
927
+ background: var(--toast-bg); color: var(--on-solid);
906
928
  padding: 8px 14px; border-radius: 6px;
907
929
  font-size: var(--fs-body); min-width: 200px; max-width: 360px;
908
930
  box-shadow: 0 4px 16px rgba(0,0,0,0.35);
@@ -911,9 +933,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
911
933
  pointer-events: auto;
912
934
  }
913
935
  .toast.show { opacity: 1; transform: translateY(0); }
914
- .toast-success { background: #14532d; border-left: 3px solid #22c55e; }
915
- .toast-error { background: #7f1d1d; border-left: 3px solid #ef4444; }
916
- .toast-info { background: #1e3a5f; border-left: 3px solid #3b82f6; }
936
+ .toast-success { background: var(--toast-ok); border-left: 3px solid #22c55e; }
937
+ .toast-error { background: var(--toast-err); border-left: 3px solid #ef4444; }
938
+ .toast-info { background: var(--toast-info); border-left: 3px solid #3b82f6; }
917
939
  .toast code { background: rgba(255,255,255,0.1); padding: 1px 4px; border-radius: 3px; }
918
940
  .pl-stage { cursor: pointer; transition: background 0.12s; }
919
941
  .pl-stage:hover { background: var(--bg-muted); border-radius: 4px; }
@@ -991,8 +1013,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
991
1013
  }
992
1014
  .rdm-head { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
993
1015
  .rdm-head .rdm-agent { font-weight: 600; }
994
- .rdm-head .rdm-verdict.ok { color: #4ade80; }
995
- .rdm-head .rdm-verdict.fail { color: #f87171; }
1016
+ .rdm-head .rdm-verdict.ok { color: var(--verdict-ok); }
1017
+ .rdm-head .rdm-verdict.fail { color: var(--verdict-fail); }
996
1018
  .rdm-head .rdm-ts { color: var(--text2); font-size: var(--fs-caption); margin-left: auto; }
997
1019
  .rdm-raw {
998
1020
  background: var(--bg-muted); padding: 8px 10px; border-radius: 4px;
@@ -1106,9 +1128,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1106
1128
  .ni-btn-ghost:hover { background: var(--bg-muted, rgba(255,255,255,0.05)); color: var(--text); }
1107
1129
  .ni-btn-primary {
1108
1130
  background: var(--accent, #00d97e);
1109
- color: #0a0e0c; font-weight: 500;
1131
+ color: var(--bg-page); font-weight: 500;
1110
1132
  }
1111
- .ni-btn-primary:hover { background: #00ee8a; }
1133
+ .ni-btn-primary:hover { background: var(--accent-hover); }
1112
1134
  .ni-btn-primary:disabled { opacity: 0.6; cursor: not-allowed; }
1113
1135
  @media (max-width: 600px) {
1114
1136
  .ni-row { flex-direction: column; gap: 14px; }
@@ -1303,9 +1325,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1303
1325
  .tier-badge { font-size: var(--fs-caption); padding: 1px 8px; border-radius: 999px; margin-left: 6px;
1304
1326
  font-family: var(--mono); white-space: nowrap; }
1305
1327
  .tier-badge:empty { display: none; }
1306
- .tier-badge.tier-T0 { background: var(--status-review); color: #fff; }
1307
- .tier-badge.tier-T1 { background: var(--status-progress); color: #fff; }
1308
- .tier-badge.tier-T2 { background: var(--status-blocked); color: #fff; }
1328
+ .tier-badge.tier-T0 { background: var(--status-review); color: var(--on-solid); }
1329
+ .tier-badge.tier-T1 { background: var(--status-progress); color: var(--on-solid); }
1330
+ .tier-badge.tier-T2 { background: var(--status-blocked); color: var(--on-solid); }
1309
1331
 
1310
1332
  /* ── Pipeline track ───────────────────────────────────────────────────────── */
1311
1333
  .pipeline-track {
@@ -1318,7 +1340,16 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1318
1340
  overflow-x: auto;
1319
1341
  }
1320
1342
  .pl-stage {
1321
- flex: 1; min-width: 100px;
1343
+ /* `flex: 1` let a cell shrink below its own label, and `.pl-name` below
1344
+ refuses to wrap — so the text simply ran over its neighbour. Measured at
1345
+ 1440px: three of ten labels overflowed, "12-angle review" needing 127px
1346
+ inside an 80px cell. Box geometry hid it (getBoundingClientRect reports the
1347
+ BOX, and the boxes were 20px apart while the ink was not), which is why it
1348
+ read as a rendering quirk for so long.
1349
+ `min-width: max-content` is the missing half of the decision recorded on
1350
+ `.pl-name`: refusing to wrap is only honest if the row may overflow and
1351
+ scroll, which `overflow-x: auto` on the track already allows. */
1352
+ flex: 1 0 auto; min-width: max-content;
1322
1353
  position: relative;
1323
1354
  padding: 8px 10px 6px;
1324
1355
  display: flex; flex-direction: column; gap: 4px;
@@ -1412,7 +1443,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1412
1443
  color: var(--text); cursor: pointer; transition: background 0.12s, border-color 0.12s;
1413
1444
  }
1414
1445
  .inbox-allclear .ac-btn:hover { background: var(--bg-muted); border-color: var(--accent, #00d97e); }
1415
- .inbox-allclear .ac-btn.ac-primary { background: var(--accent, #00d97e); border-color: var(--accent, #00d97e); color: #04150d; font-weight: 500; }
1446
+ .inbox-allclear .ac-btn.ac-primary { background: var(--accent, #00d97e); border-color: var(--accent, #00d97e); color: var(--on-accent); font-weight: 500; }
1416
1447
  .inbox-allclear .ac-btn.ac-primary:hover { filter: brightness(1.06); }
1417
1448
 
1418
1449
  /* Human gate — the signing checkpoint. Visually set apart from agent stages:
@@ -1434,7 +1465,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1434
1465
  .pl-gate.active .pl-msg, .pl-gate.active .pl-meta { opacity: 1; color: var(--text); }
1435
1466
  .pl-gate .gate-badge {
1436
1467
  margin-left: auto;
1437
- background: var(--status-blocked); color: #fff;
1468
+ background: var(--status-blocked); color: var(--on-solid);
1438
1469
  font-size: var(--fs-eyebrow); font-weight: 700;
1439
1470
  min-width: 16px; height: 16px; padding: 0 4px;
1440
1471
  border-radius: 8px;
@@ -1506,7 +1537,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1506
1537
  position: absolute;
1507
1538
  bottom: calc(100% + 4px); left: 50%;
1508
1539
  transform: translateX(-50%);
1509
- background: #0f1115; color: #fff;
1540
+ background: var(--bg-solid); color: var(--on-solid);
1510
1541
  padding: 4px 8px; border-radius: 4px;
1511
1542
  font-family: var(--mono); font-size: var(--fs-eyebrow);
1512
1543
  white-space: nowrap;
@@ -1717,7 +1748,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1717
1748
 
1718
1749
  .dot-purple { background: var(--status-gate); }
1719
1750
  .dot-red { background: var(--status-blocked); }
1720
- .dot-orange { background: #f97316; }
1751
+ .dot-orange { background: var(--dot-orange); }
1721
1752
  .dot-amber { background: var(--status-progress); }
1722
1753
 
1723
1754
  .inbox-row {
@@ -1822,6 +1853,39 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1822
1853
  font-size: var(--fs-body);
1823
1854
  }
1824
1855
  .judge-status[hidden] { display: none; }
1856
+
1857
+ /* What the judge CONCLUDED, under whether it is connected.
1858
+ The scores existed for weeks and no page read them: /api/scores had zero
1859
+ callers, so every verification ran, wrote a record, and vanished. */
1860
+ .jscores { margin-bottom: 16px; }
1861
+ .jscores[hidden] { display: none; }
1862
+ .jscores-head {
1863
+ display: flex; align-items: baseline; gap: 14px; flex-wrap: wrap;
1864
+ padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px 8px 0 0;
1865
+ background: var(--bg-muted); font-size: var(--fs-body);
1866
+ }
1867
+ .jscores-head .rate { font-size: var(--fs-num-s); font-weight: 600; }
1868
+ .jscores-rows { border: 1px solid var(--border); border-top: 0; border-radius: 0 0 8px 8px; }
1869
+ .jscore {
1870
+ display: grid; grid-template-columns: minmax(9rem, 12rem) 7.5rem 1fr auto;
1871
+ gap: 12px; align-items: baseline;
1872
+ padding: 7px 12px; border-top: 1px solid var(--border); font-size: var(--fs-small);
1873
+ }
1874
+ .jscore:first-child { border-top: 0; }
1875
+ .jscore .who { font-weight: 500; }
1876
+ .jscore .why { color: var(--text3); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1877
+ .jscore .when { color: var(--text3); font-family: var(--mono); font-size: var(--fs-caption); }
1878
+ .jstate { font-weight: 600; }
1879
+ .jstate.verified { color: var(--status-done); }
1880
+ .jstate.rework { color: var(--status-progress); }
1881
+ /* Deliberately grey, not red. `unverifiable` is not a failing grade — it is the
1882
+ absence of one, and colouring it like a failure is the same collapse of three
1883
+ states into two that the scores exist to prevent. */
1884
+ .jstate.unverifiable { color: var(--text3); }
1885
+ @media (max-width: 640px) {
1886
+ .jscore { grid-template-columns: 1fr auto; }
1887
+ .jscore .why, .jscore .when { grid-column: 1 / -1; }
1888
+ }
1825
1889
  .judge-status .js-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
1826
1890
  .judge-status.ok .js-dot { background: var(--status-done); }
1827
1891
  .judge-status.off .js-dot { background: var(--status-blocked); }
@@ -1944,7 +2008,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1944
2008
  .metric-card.green::before { background: var(--status-review); }
1945
2009
  .metric-card.amber::before { background: var(--status-progress); }
1946
2010
  .metric-card.red::before { background: var(--status-blocked); }
1947
- .metric-card.purple::before { background: #7c3aed; }
2011
+ .metric-card.purple::before { background: var(--metric-purple); }
1948
2012
  .metric-num {
1949
2013
  font-family: var(--serif); font-weight: 500;
1950
2014
  /* The board is live (SSE). In a proportional face a 1 is narrower than an 8,
@@ -2428,7 +2492,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2428
2492
  text-overflow: ellipsis;
2429
2493
  }
2430
2494
  .btn-black {
2431
- background: #0f1115; color: #fff; border: none;
2495
+ background: var(--bg-solid); color: var(--on-solid); border: none;
2432
2496
  padding: 8px 14px; border-radius: 999px;
2433
2497
  font-size: var(--fs-small); font-weight: 500;
2434
2498
  }
@@ -2498,9 +2562,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2498
2562
  transition: background 0.12s, color 0.12s, border-color 0.12s;
2499
2563
  }
2500
2564
  .fchip:hover { background: var(--bg-elevated); color: var(--text); border-color: var(--border-strong); }
2501
- .fchip.active { background: var(--accent); color: #fff; border-color: transparent; }
2502
- .fchip-agent.active { background: #2540d4; }
2503
- .fchip-priority.active { background: #f59e0b; color: #000; }
2565
+ .fchip.active { background: var(--accent); color: var(--on-solid); border-color: transparent; }
2566
+ .fchip-agent.active { background: var(--chip-agent); }
2567
+ .fchip-priority.active { background: var(--status-progress); color: var(--on-warm); }
2504
2568
  .fchip-label.active { background: var(--accent); }
2505
2569
  .fchip-clear { background: transparent; color: var(--text3); border-color: transparent; }
2506
2570
  .fchip-clear:hover { background: var(--bg-strong); color: var(--status-blocked); }
@@ -3136,6 +3200,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3136
3200
  key is not discoverable: the README names OpenRouter once without
3137
3201
  saying where it goes. -->
3138
3202
  <div id="judge-status" class="judge-status" hidden></div>
3203
+ <!-- The verdicts of the verifier itself. Kept beside the connection
3204
+ status because the two answer consecutive questions: is anything
3205
+ checking, and what did it find. -->
3206
+ <div id="judge-scores" class="jscores" hidden></div>
3139
3207
  <div id="budgets-page-body">loading…</div>
3140
3208
  </div>
3141
3209
  </div>
@@ -3383,6 +3451,9 @@ function greetByHour() {
3383
3451
  }
3384
3452
 
3385
3453
  function renderInbox(d) {
3454
+ // Kept so renderResume can ask what this section is already showing, rather
3455
+ // than the two of them rendering the same task independently.
3456
+ window.__inboxData = d;
3386
3457
  inboxData = d || {};
3387
3458
  const s = d?.summary || { gates: 0, blocked: 0, p0: 0, stale: 0 };
3388
3459
  // `needs_you` counts distinct objects. Summing the three categories double-counted
@@ -3397,12 +3468,24 @@ function renderInbox(d) {
3397
3468
  ? "Some data could not be read — treat the counts below as incomplete."
3398
3469
  : (s.gates + s.p0 ? "Here's what needs your decision." : 'Nothing urgent — back to deep work.');
3399
3470
  document.getElementById('inbox-greet').textContent = `${greetByHour()} ${greetTail}`;
3400
- document.getElementById('inbox-summary').innerHTML = `
3401
- <div class="pill-stat"><span class="dot dot-purple"></span><div><div class="num">${s.gates}</div><div class="lbl">Pending decisions</div></div></div>
3402
- <div class="pill-stat"><span class="dot dot-red"></span><div><div class="num">${s.p0}</div><div class="lbl">P0 open</div></div></div>
3403
- <div class="pill-stat"><span class="dot dot-orange"></span><div><div class="num">${s.blocked}</div><div class="lbl">Blocked</div></div></div>
3404
- <div class="pill-stat"><span class="dot dot-amber"></span><div><div class="num">${s.stale}</div><div class="lbl">Stale &gt; 48h</div></div></div>
3405
- `;
3471
+ // Only the tiles that have something in them.
3472
+ //
3473
+ // Four rendered unconditionally, and on an ordinary morning two of them read 0
3474
+ // half the header spending a quarter of the width each to say "nothing here".
3475
+ // A zero is worth stating when it is the WHOLE answer, so the all-clear is one
3476
+ // line instead of four boxes of noughts.
3477
+ const TILES = [
3478
+ { n: s.gates, dot: 'purple', lbl: 'Pending decisions' },
3479
+ { n: s.p0, dot: 'red', lbl: 'P0 open' },
3480
+ { n: s.blocked, dot: 'orange', lbl: 'Blocked' },
3481
+ { n: s.stale, dot: 'amber', lbl: 'Stale &gt; 48h' },
3482
+ ];
3483
+ const liveTiles = TILES.filter((t) => Number(t.n) > 0);
3484
+ document.getElementById('inbox-summary').innerHTML = liveTiles.length
3485
+ ? liveTiles.map((t) => `<div class="pill-stat"><span class="dot dot-${t.dot}"></span>`
3486
+ + `<div><div class="num">${t.n}</div><div class="lbl">${t.lbl}</div></div></div>`).join('')
3487
+ : `<div class="pill-stat" style="grid-column:1/-1"><span class="dot dot-green"></span>`
3488
+ + `<div><div class="lbl">No gates, no P0s, nothing blocked or stale.</div></div></div>`;
3406
3489
  renderInboxList('inbox-gates', d?.pending_gates || [], 'inbox-gates-count', { showApprove: true });
3407
3490
  // `implies` names the fact the section heading already states, so the rows
3408
3491
  // below it stop repeating that fact on every card. "P0 open" states the
@@ -3455,10 +3538,18 @@ async function refreshReceipt() {
3455
3538
  if (!d || d.state === 'matches' || d.state === 'no-receipt') return;
3456
3539
 
3457
3540
  const stamp = d.agent ? `${esc(d.agent)}${d.ts ? ' · ' + relTime(d.ts) : ''}` : '';
3541
+ // The FACT is one line; the file list is evidence for it.
3542
+ //
3543
+ // Four full paths printed inline wrapped the notice onto three lines and buried
3544
+ // the sentence that matters — that the approval no longer describes the tree —
3545
+ // under `docs/plans/PLAN-2026-08-17-gate-fail-closed.md`. The paths are still
3546
+ // here, one click away: a reader who wants to act needs them, a reader deciding
3547
+ // whether to care does not.
3548
+ const changedFiles = [...(d.changed || []), ...(d.removed || [])];
3458
3549
  const body = d.state === 'differs'
3459
- ? `<b>${(d.changed || []).length + (d.removed || []).length} reviewed file(s) changed since ${stamp} approved</b>
3550
+ ? `<b>${changedFiles.length} reviewed file(s) changed since ${stamp} approved</b>
3460
3551
  — the approval no longer describes what is in the tree.
3461
- <span class="muted">${esc((d.changed || []).slice(0, 4).join(', '))}${(d.changed || []).length > 4 ? ' …' : ''}</span>`
3552
+ ${changedFiles.length ? `<details style="display:inline"><summary class="muted" style="display:inline;cursor:pointer">which files</summary><div class="muted" style="margin-top:4px">${esc(changedFiles.join(', '))}</div></details>` : ''}`
3462
3553
  : d.state === 'unreadable'
3463
3554
  ? `<b>Could not check the review receipt</b> — <span class="muted">${esc(d.why || '')}</span>. This is not the same as a clean tree.`
3464
3555
  : `${(d.added || []).length} file(s) added since ${stamp} approved; nothing reviewed was altered.`;
@@ -3960,6 +4051,75 @@ async function loadJudgeStatus() {
3960
4051
  </span>`;
3961
4052
  }
3962
4053
 
4054
+ /**
4055
+ * What the verifier concluded, per stage.
4056
+ *
4057
+ * `/api/scores` existed with ZERO front-end callers: every verification ran,
4058
+ * appended a record, and was never shown to anyone. The user's question — "how
4059
+ * much did introducing the judge improve anything, and why can I not see it?" —
4060
+ * had a correct answer of "you cannot, nothing renders it".
4061
+ *
4062
+ * The rate divides by ASSESSED, never by total, and says how many that was.
4063
+ * An agent with nine unverifiable runs and one verified run scores 100% here
4064
+ * with `1 assessed` beside it; reporting 10% would be a made-up number about
4065
+ * work nobody looked at.
4066
+ */
4067
+ async function loadJudgeScores() {
4068
+ const el = document.getElementById('judge-scores');
4069
+ if (!el) return;
4070
+ let d;
4071
+ try { d = await api(`/api/scores${pqs()}&limit=40`); } catch { d = null; }
4072
+
4073
+ // Three states, and the empty one earns a sentence rather than a blank space:
4074
+ // "nothing has been verified yet" and "the panel is broken" look identical
4075
+ // when a component renders nothing.
4076
+ if (!d || !Array.isArray(d.scores)) {
4077
+ el.hidden = false;
4078
+ el.innerHTML = `<div class="jscores-head"><span class="muted">Verification results could not be read.</span></div>`;
4079
+ return;
4080
+ }
4081
+ if (!d.scores.length) {
4082
+ el.hidden = false;
4083
+ el.innerHTML = `<div class="jscores-head"><span class="muted">No stage has been verified yet — run a pipeline and the judge's conclusions land here.</span></div>`;
4084
+ return;
4085
+ }
4086
+
4087
+ const sum = d.summary || {};
4088
+ const by = sum.byState || {};
4089
+ const rate = sum.rate === null || sum.rate === undefined ? '—' : `${sum.rate}%`;
4090
+
4091
+ // Newest first, one row per agent: an older score for the same stage is
4092
+ // history, and history belongs in the file, not in a status panel.
4093
+ const seen = new Set();
4094
+ const rows = d.scores.filter((s) => {
4095
+ if (seen.has(s.agent)) return false;
4096
+ seen.add(s.agent);
4097
+ return true;
4098
+ });
4099
+
4100
+ el.hidden = false;
4101
+ el.innerHTML = `
4102
+ <div class="jscores-head">
4103
+ <span class="rate">${rate}</span>
4104
+ <span class="muted">verified, of ${sum.assessed || 0} assessed</span>
4105
+ <span class="jstate verified">${by.verified || 0} verified</span>
4106
+ <span class="jstate rework">${by.rework || 0} rework</span>
4107
+ <span class="jstate unverifiable"
4108
+ title="Nothing was checked — not a failing grade. Excluded from the rate rather than counted as zeros, which is why the rate says how many it divided by.">${by.unverifiable || 0} unverifiable</span>
4109
+ ${d.unreadable_lines
4110
+ ? `<span class="jstate rework">${d.unreadable_lines} unreadable line(s)</span>` : ''}
4111
+ </div>
4112
+ <div class="jscores-rows">
4113
+ ${rows.map((s) => `
4114
+ <div class="jscore">
4115
+ <span class="who">${esc(s.agent)}</span>
4116
+ <span class="jstate ${esc(s.state)}">${esc(s.state)}</span>
4117
+ <span class="why" title="${esc(s.comment || '')}">${esc(s.comment || '')}</span>
4118
+ <span class="when">${esc(String(s.ts || '').slice(0, 10))} · ${esc(s.scorer || '')}</span>
4119
+ </div>`).join('')}
4120
+ </div>`;
4121
+ }
4122
+
3963
4123
  async function saveJudgeKey() {
3964
4124
  const input = document.getElementById('judge-key');
3965
4125
  const key = (input?.value || '').trim();
@@ -3982,6 +4142,7 @@ async function saveJudgeKey() {
3982
4142
 
3983
4143
  async function loadBudgetsPage() {
3984
4144
  loadJudgeStatus();
4145
+ loadJudgeScores();
3985
4146
  const body = document.getElementById('budgets-page-body');
3986
4147
  if (!body) return;
3987
4148
  body.innerHTML = '<div class="muted">loading…</div>';
@@ -4234,7 +4395,25 @@ function summariseVerdict(v, raw) {
4234
4395
  function renderResume(d) {
4235
4396
  const card = document.getElementById('resume-card');
4236
4397
  if (!card) return;
4237
- const wip = d.wip_tasks || [];
4398
+
4399
+ // Whatever the Stale section is already showing does not need showing again.
4400
+ //
4401
+ // Measured on a real morning: three of the five stale rows also filled the
4402
+ // "in progress" column here, so 60% of "pick up where you left off" was the
4403
+ // list immediately below it. Both blocks were honest; together they made one
4404
+ // fact look like two, which is the same arithmetic error as counting a task
4405
+ // twice in the nav badge — just spent in vertical space instead of a number.
4406
+ //
4407
+ // Stale wins the duplicate because it says more: it carries the age, which is
4408
+ // the reason the row deserves attention at all.
4409
+ const shownElsewhere = new Set(
4410
+ (window.__inboxData?.stale_in_progress || []).map((t) => t?.id).filter(Boolean));
4411
+ const allWip = d.wip_tasks || [];
4412
+ const wip = allWip.filter((t) => !shownElsewhere.has(t?.id));
4413
+ // How many were dropped, so the empty column can say WHY it is empty. Rendering
4414
+ // "Nothing in progress" over three tasks that are in progress — and listed a
4415
+ // screen below — would be a worse lie than the duplication this removed.
4416
+ const wipHiddenAsStale = allWip.length - wip.length;
4238
4417
  const verdicts = d.recent_verdicts || [];
4239
4418
  const decisions = d.decisions || [];
4240
4419
  const totalItems = wip.length + verdicts.length + decisions.length;
@@ -4266,7 +4445,11 @@ function renderResume(d) {
4266
4445
 
4267
4446
  // WIP column
4268
4447
  const wipEl = document.getElementById('resume-wip');
4269
- wipEl.innerHTML = wip.length ? wip.map(t => {
4448
+ if (!wip.length && wipHiddenAsStale > 0) {
4449
+ wipEl.innerHTML = `<div class="empty">${wipHiddenAsStale} in progress — all of them `
4450
+ + `<a href="#" onclick="document.getElementById('inbox-stale')`
4451
+ + `?.scrollIntoView({behavior:'smooth'});return false">stale, listed below</a></div>`;
4452
+ } else wipEl.innerHTML = wip.length ? wip.map(t => {
4270
4453
  const tag = (t.agent || 'wip').slice(0, 12);
4271
4454
  return `<div class="resume-item" onclick="openSideById('${esc(t.id)}')">
4272
4455
  <span class="ri-tag">${esc(tag)}</span>
@@ -7749,7 +7932,7 @@ function renderNotifDrawer() {
7749
7932
  ${new Date(n.ts).toLocaleString()}
7750
7933
  </div>
7751
7934
  </div>
7752
- ${n.read ? '' : '<span style="width:7px;height:7px;background:#3b82f6;border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
7935
+ ${n.read ? '' : '<span style="width:7px;height:7px;background:var(--dot-unread);border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
7753
7936
  </div>
7754
7937
  </div>`).join('');
7755
7938
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.14.0",
3
+ "version": "3.16.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",
package/postinstall.mjs CHANGED
@@ -1,5 +1,124 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * great-cto postinstall — no-op placeholder.
4
- * Reserved for future setup steps; currently does nothing.
3
+ * great-cto postinstall — says when the plugin is now older than the CLI.
4
+ *
5
+ * THE PROBLEM
6
+ * -----------
7
+ * great-cto ships as two things on purpose: an npm package (the `great-cto`
8
+ * command) and a Claude Code plugin (agents, skills, commands, the board). They
9
+ * update through different channels — `npm i -g great-cto` moves one of them and
10
+ * nothing at all moves the other.
11
+ *
12
+ * So a user upgrades, sees a new version number, and runs a pipeline whose
13
+ * agents are three releases behind. Nothing errors. The board opened, the agents
14
+ * ran, the verdicts were written — by the old code. That is this project's
15
+ * governing defect in its own installer: a thing that did not happen looking
16
+ * exactly like a thing that did.
17
+ *
18
+ * WHAT THIS DELIBERATELY DOES NOT DO
19
+ * ----------------------------------
20
+ * It does not update the plugin. An npm lifecycle hook writing into
21
+ * ~/.claude/plugins/ would reach across a project boundary into global state
22
+ * that every other project's agents read, from a script the user never chose to
23
+ * run — the ADR-009 case that wants a human decision, not a silent one. `npm i`
24
+ * is not consent to rewrite the agent definitions of every project on the
25
+ * machine.
26
+ *
27
+ * So it detects and it tells. The user runs one command, or does not.
28
+ *
29
+ * IT ALSO MUST NOT BREAK AN INSTALL
30
+ * ---------------------------------
31
+ * A postinstall hook that throws fails `npm i`. Every path here is wrapped, the
32
+ * exit code is always 0, and every unknown answers "say nothing": no plugin
33
+ * directory is a perfectly good CLI-only install, and an unreadable one is not
34
+ * evidence of staleness.
5
35
  */
36
+
37
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
38
+ import { join, dirname } from 'node:path';
39
+ import { homedir } from 'node:os';
40
+ import { fileURLToPath } from 'node:url';
41
+
42
+ /** Numeric semver compare. Returns >0 when a is newer. Non-numeric parts sort last. */
43
+ export function cmpVersion(a, b) {
44
+ const parse = (v) => String(v).split('.').map((n) => (/^\d+$/.test(n) ? Number(n) : -1));
45
+ const x = parse(a);
46
+ const y = parse(b);
47
+ for (let i = 0; i < Math.max(x.length, y.length); i += 1) {
48
+ const d = (x[i] ?? 0) - (y[i] ?? 0);
49
+ if (d) return d;
50
+ }
51
+ return 0;
52
+ }
53
+
54
+ /**
55
+ * Newest great_cto version across EVERY marketplace, not just `local`.
56
+ *
57
+ * Same reasoning as board-path: a user who installed the plugin from a
58
+ * marketplace of their own is not running a broken setup, and looking in one
59
+ * hard-coded directory would report them as having no plugin at all.
60
+ */
61
+ export function newestPluginVersion(cacheRoot) {
62
+ let best = null;
63
+ let dirs;
64
+ try { dirs = readdirSync(cacheRoot); } catch { return null; }
65
+ for (const marketplace of dirs) {
66
+ const base = join(cacheRoot, marketplace, 'great_cto');
67
+ let versions;
68
+ try { versions = readdirSync(base); } catch { continue; }
69
+ for (const v of versions) {
70
+ if (!/^\d/.test(v)) continue;
71
+ if (best === null || cmpVersion(v, best) > 0) best = v;
72
+ }
73
+ }
74
+ return best;
75
+ }
76
+
77
+ /**
78
+ * @returns {{state: 'stale'|'current'|'no-plugin', cli: string, plugin: string|null}}
79
+ *
80
+ * Three states, and `no-plugin` is not a problem to report. Someone who wants
81
+ * only the CLI has a correct installation, and telling them to update a plugin
82
+ * they never installed is the false alarm that teaches people to ignore output.
83
+ */
84
+ export function compare({ cli, plugin }) {
85
+ if (!plugin) return { state: 'no-plugin', cli, plugin: null };
86
+ return { state: cmpVersion(cli, plugin) > 0 ? 'stale' : 'current', cli, plugin };
87
+ }
88
+
89
+ export function message({ cli, plugin }) {
90
+ return [
91
+ '',
92
+ ` great-cto ${cli} is installed, but the Claude Code plugin is still ${plugin}.`,
93
+ '',
94
+ ' They update separately. Until the plugin is updated, your agents, skills',
95
+ ' and commands are the ones from ' + plugin + ' — pipelines will run, and they',
96
+ ' will run the old definitions.',
97
+ '',
98
+ ' In Claude Code: /plugin update great_cto',
99
+ '',
100
+ ].join('\n');
101
+ }
102
+
103
+ function main() {
104
+ // CI installs nobody reads, and a notice there is noise in a build log.
105
+ if (process.env.CI || process.env.GREAT_CTO_QUIET_POSTINSTALL) return;
106
+
107
+ let cli;
108
+ try {
109
+ const here = dirname(fileURLToPath(import.meta.url));
110
+ cli = JSON.parse(readFileSync(join(here, 'package.json'), 'utf8')).version;
111
+ } catch { return; }
112
+ if (!cli) return;
113
+
114
+ const cacheRoot = join(homedir(), '.claude', 'plugins', 'cache');
115
+ if (!existsSync(cacheRoot)) return; // not a Claude Code machine — nothing to say
116
+
117
+ const r = compare({ cli, plugin: newestPluginVersion(cacheRoot) });
118
+ if (r.state === 'stale') process.stdout.write(message(r) + '\n');
119
+ }
120
+
121
+ // Only when run as the hook, so the pure parts above stay importable by tests.
122
+ if (process.argv[1] && process.argv[1].endsWith('postinstall.mjs')) {
123
+ try { main(); } catch { /* an installer must never fail the install */ }
124
+ }