great-cto 3.1.0 → 3.3.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.
package/README.md CHANGED
@@ -20,6 +20,25 @@ That's it. The CLI detects your stack, picks the right archetype, clones the plu
20
20
 
21
21
  After install, restart Claude Code and run `/inbox` or `/audit`.
22
22
 
23
+ ## What the plugin gives you
24
+
25
+ The CLI installs it; these are what it installs.
26
+
27
+ - **A pipeline of specialist agents** — architect, product owner, senior dev, QA,
28
+ security officer, and archetype-specific reviewers — that run in sequence with
29
+ human approval gates at the points that are expensive to undo.
30
+ - **Spending caps that refuse.** Set a per-agent cap; when measured spend passes
31
+ it the dispatcher declines to run that agent instead of quietly continuing. An
32
+ agent with no measurement reads as `unmeasured`, never as `$0`.
33
+ - **A board at `localhost:3141`** — tasks, metrics, per-agent budgets, docs
34
+ search, and the decision journal, all served from your own machine. Nothing
35
+ leaves it.
36
+ - **A decision journal** that records what each dispatch decided and why, so a
37
+ pipeline that did not run looks different from one that ran and found nothing.
38
+
39
+ Telemetry is opt-in and off by default — see
40
+ [docs/PRIVACY.md](https://github.com/avelikiy/great_cto/blob/main/docs/PRIVACY.md).
41
+
23
42
  ## Examples
24
43
 
25
44
  ### Commerce detection (Stripe + Next.js)
@@ -82,8 +101,10 @@ npx great-cto init [options]
82
101
  ## Trust signals
83
102
 
84
103
  - Zero runtime dependencies — only Node built-ins (`node:fs`, `node:path`, etc.)
85
- - 47 unit tests covering stack detection, archetype scoring, settings merge
86
- - CI matrix: Node 18/20/22 × Ubuntu/macOS/Windows
104
+ - 305 unit tests covering stack detection, archetype scoring, and settings merge
105
+ - Tests and the release gate run locally (`scripts/ci-local.sh`) the hosted CI
106
+ workflows are present in the repo but have not run since June 2026, so treat the
107
+ badge-shaped claims in this file as claims about the local gate, not a cloud matrix
87
108
  - Source: [`packages/cli/src/`](https://github.com/avelikiy/great_cto/tree/main/packages/cli/src) — ~1.2k LOC TypeScript
88
109
 
89
110
  ## License
@@ -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.1.0",
5
+ "version": "3.3.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -1174,9 +1174,24 @@ async function dispatch(req, res, url, cwd) {
1174
1174
  return (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]);
1175
1175
  }).pop() || null;
1176
1176
  } catch { installed = null; }
1177
- const stale = installed && BUILD_VERSION !== 'unknown'
1178
- ? (installed !== BUILD_VERSION ? 'yes' : 'no')
1179
- : 'unknown';
1177
+ // Direction, not inequality. `installed !== BUILD_VERSION` is true in both
1178
+ // directions, and the banner it fed said "restart to pick up the new
1179
+ // version" either way — so a board running 3.2.0 against an installed 3.1.0
1180
+ // was told to restart into the OLDER build. A prompt to downgrade that
1181
+ // looks exactly like a prompt to upgrade is the same defect this endpoint
1182
+ // was written to fix, pointed the other way.
1183
+ //
1184
+ // Four states: `behind` (an install is waiting), `ahead` (the running code
1185
+ // is newer — restarting would lose it), `no`, and `unknown`.
1186
+ const cmp = (a, b) => {
1187
+ const x = a.split('.').map(Number), y = b.split('.').map(Number);
1188
+ return (x[0] - y[0]) || (x[1] - y[1]) || (x[2] - y[2]);
1189
+ };
1190
+ let stale = 'unknown';
1191
+ if (installed && BUILD_VERSION !== 'unknown' && /^\d+\.\d+\.\d+$/.test(BUILD_VERSION)) {
1192
+ const d = cmp(installed, BUILD_VERSION);
1193
+ stale = d > 0 ? 'behind' : d < 0 ? 'ahead' : 'no';
1194
+ }
1180
1195
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1181
1196
  res.end(JSON.stringify({
1182
1197
  version: BUILD_VERSION, installed, stale,
@@ -123,6 +123,38 @@
123
123
  --shadow-card-hover: 0 1px 3px rgba(0, 0, 0, 0.12), 0 8px 24px rgba(0, 168, 98, 0.08);
124
124
  --drawer-shadow: 0 16px 32px rgba(0, 0, 0, 0.18);
125
125
  }
126
+ /* ── Type scale ──────────────────────────────────────────────────────────────
127
+ The board had 22 raw font sizes (9px…52px, including 11.5/12.5/13.5) and no
128
+ rule for h1/h2/h3 at all — so Budgets, Docs, Sessions and Activity rendered
129
+ their titles in the browser's default bold 700, a weight used nowhere else in
130
+ the design. One role, six renderings.
131
+
132
+ Two ramps, because the board has two kinds of text. Text steps by +1px in the
133
+ dense band (this is a single-operator console; density is the point) and by
134
+ ~1.2x in the title band. Live numbers get their own ramp: they are read as
135
+ quantities, not prose, so they take --serif at 500 with tabular-nums.
136
+
137
+ --serif is NOT a serif face — it resolves to the same Geist as --sans. The
138
+ name is legacy; the role is "display numerals". Reach for it only at >=24px.
139
+
140
+ Legal 10px: --fs-eyebrow, and only for mono uppercase labels with tracking.
141
+ 10px in sans body text is not a size, it is a mistake. */
142
+ :root {
143
+ --fs-eyebrow: 10px; /* mono uppercase label, tracking 0.08–0.1em */
144
+ --fs-caption: 11px; /* metadata, counts, timestamps */
145
+ --fs-small: 12px; /* table cells, secondary meta, chips */
146
+ --fs-body: 13px; /* dense body: cards, list rows, form fields */
147
+ --fs-base: 14px; /* document base / longform prose */
148
+ --fs-title-s: 15px; /* card or section heading (h3 role) */
149
+ --fs-title-m: 18px; /* region heading (h2 role) */
150
+ --fs-title-l: 22px; /* page heading (h1 role) */
151
+
152
+ --fs-num-s: 24px; /* inline stat */
153
+ --fs-num-m: 30px; /* secondary metric card */
154
+ --fs-num-l: 36px; /* second-tier hero KPI */
155
+ --fs-num-xl: 52px; /* primary hero KPI */
156
+ }
157
+
126
158
  * { box-sizing: border-box; margin: 0; padding: 0; }
127
159
  html, body { height: 100%; }
128
160
  body {
@@ -135,6 +167,51 @@ body {
135
167
  }
136
168
  button { font-family: inherit; cursor: pointer; }
137
169
 
170
+ /* Headings had no base rule, so every unstyled h1/h2/h3 fell to the browser's
171
+ bold 700. Three weights exist in this design — 400 body, 500 value, 600 title
172
+ — and 700 is not one of them. This removes it everywhere at once. Bare-element
173
+ specificity (0,0,1), so every component rule below still wins. */
174
+ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
175
+
176
+ /* The page's own title. Present on every panel, one size, one weight. Before
177
+ this, two panels had no title element at all, four rendered the browser
178
+ default, and one rendered a section heading at title size. */
179
+ .page-title {
180
+ font-size: var(--fs-title-l);
181
+ font-weight: 600;
182
+ line-height: 1.25;
183
+ letter-spacing: -0.02em;
184
+ color: var(--text);
185
+ margin: 0 0 14px;
186
+ }
187
+
188
+ /* A heading INSIDE a page. Notifications rendered its three sections through
189
+ .share-card h2 — the showcase hero — so "Email alerts" was set at 28px, the
190
+ size of a page title, three times on one screen. */
191
+ .section-title {
192
+ font-size: var(--fs-title-m);
193
+ font-weight: 600;
194
+ line-height: 1.3;
195
+ letter-spacing: -0.01em;
196
+ color: var(--text);
197
+ margin: 0 0 6px;
198
+ }
199
+
200
+ /* Sessions had a .panel-head element with no rule of any kind: its h2 fell to
201
+ the browser default of 1.5em bold-700 and the search box beside it did not
202
+ align to anything. */
203
+ .panel-head {
204
+ display: flex; align-items: center; gap: 12px;
205
+ margin: 0 0 14px; flex-wrap: wrap;
206
+ }
207
+ .panel-head h2 {
208
+ font-size: var(--fs-title-l);
209
+ letter-spacing: -0.02em;
210
+ line-height: 1.25;
211
+ margin: 0; flex: 1 1 auto;
212
+ }
213
+
214
+
138
215
  .board {
139
216
  position: relative;
140
217
  width: 100%;
@@ -554,7 +631,7 @@ button { font-family: inherit; cursor: pointer; }
554
631
  flex-shrink: 0;
555
632
  }
556
633
  .card .title {
557
- font-weight: 600; font-size: 13px;
634
+ font-weight: 600; font-size: var(--fs-body);
558
635
  line-height: 1.35;
559
636
  color: var(--text);
560
637
  display: -webkit-box;
@@ -628,13 +705,13 @@ button { font-family: inherit; cursor: pointer; }
628
705
  #agent-panel.open { display: flex; }
629
706
  #agent-panel header { display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--border, #1f2a26); background: transparent; }
630
707
  #agent-panel header b { font-size: 13px; }
631
- #agent-panel .ap-x { margin-left: auto; cursor: pointer; border: 0; background: none; color: var(--text-muted, #8aa); font-size: 16px; }
708
+ #agent-panel .ap-x { margin-left: auto; cursor: pointer; border: 0; background: none; color: var(--text3); font-size: 16px; }
632
709
  #agent-log { flex: 1; overflow: auto; padding: 10px 14px; font: 12px/1.55 ui-monospace, Menlo, monospace; }
633
710
  #agent-log .al { margin: 2px 0; white-space: pre-wrap; word-break: break-word; }
634
- #agent-log .al.tool { color: var(--status-review); } #agent-log .al.system { color: var(--text-muted, #8aa); } #agent-log .al.error { color: var(--status-blocked, #ff5a5a); } #agent-log .al.done { color: var(--text-muted, #8aa); font-weight: 600; }
711
+ #agent-log .al.tool { color: var(--status-review); } #agent-log .al.system { color: var(--text3); } #agent-log .al.error { color: var(--status-blocked, #ff5a5a); } #agent-log .al.done { color: var(--text3); font-weight: 600; }
635
712
  #agent-panel .ap-foot { display: flex; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--border, #1f2a26); }
636
- #agent-panel input { flex: 1; background: var(--bg, #0a0e0c); color: var(--text, #ecf2ee); border: 1px solid var(--border, #1f2a26); border-radius: 8px; padding: 7px 10px; font: inherit; font-size: 13px; }
637
- #agent-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text-muted, #556); }
713
+ #agent-panel input { flex: 1; background: var(--bg-muted); color: var(--text, #ecf2ee); border: 1px solid var(--border, #1f2a26); border-radius: 8px; padding: 7px 10px; font: inherit; font-size: 13px; }
714
+ #agent-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--text3); }
638
715
  #agent-dot.live { background: var(--status-review); animation: apulse 1.2s infinite; }
639
716
  @keyframes apulse { 0%,100%{opacity:1} 50%{opacity:.4} }
640
717
  .gate-reject { color: var(--status-blocked); border-color: rgba(220, 38, 38, 0.30); }
@@ -677,7 +754,7 @@ button { font-family: inherit; cursor: pointer; }
677
754
  font-size: 12px;
678
755
  line-height: 1.45;
679
756
  }
680
- .degraded-banner strong { color: var(--text1); font-weight: 600; }
757
+ .degraded-banner strong { color: var(--text); font-weight: 600; }
681
758
  .degraded-banner span { color: var(--text2); font-family: var(--mono, ui-monospace, monospace); word-break: break-word; }
682
759
 
683
760
  /* ── Side panel ───────────────────────────────────────────────────────────── */
@@ -733,9 +810,9 @@ button { font-family: inherit; cursor: pointer; }
733
810
  .side-close:hover { background: var(--bg-muted); color: var(--text); }
734
811
  .side-body { flex: 1; overflow: auto; padding: 18px; }
735
812
  .side-title {
736
- font-family: var(--serif); font-weight: 600;
737
- font-size: 20px;
738
- letter-spacing: -0.02em;
813
+ font-family: var(--sans); font-weight: 600;
814
+ font-size: var(--fs-title-m);
815
+ letter-spacing: -0.01em;
739
816
  margin-bottom: 12px;
740
817
  color: var(--text);
741
818
  }
@@ -840,7 +917,7 @@ button { font-family: inherit; cursor: pointer; }
840
917
  text-transform: uppercase; color: var(--text3);
841
918
  min-width: 34px; flex-shrink: 0;
842
919
  }
843
- .cmdk-label { font-size: 13.5px; color: var(--text); flex: 1; min-width: 0;
920
+ .cmdk-label { font-size: var(--fs-body); color: var(--text); flex: 1; min-width: 0;
844
921
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
845
922
  .cmdk-hint { font-family: var(--mono); font-size: 11px; color: var(--text3); flex-shrink: 0; }
846
923
  .cmdk-empty { padding: 18px 14px; font-size: 13px; color: var(--text2); }
@@ -853,7 +930,7 @@ button { font-family: inherit; cursor: pointer; }
853
930
  position: absolute; inset: 0; background: rgba(0,0,0,0.5);
854
931
  }
855
932
  .hk-card {
856
- position: relative; background: var(--bg, #0f172a);
933
+ position: relative; background: var(--bg-elevated);
857
934
  color: var(--text); border: 1px solid var(--border);
858
935
  border-radius: 8px; padding: 20px 24px; min-width: 360px;
859
936
  box-shadow: 0 12px 32px rgba(0,0,0,0.5);
@@ -876,7 +953,7 @@ button { font-family: inherit; cursor: pointer; }
876
953
  }
877
954
  .rdm-backdrop { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
878
955
  .rdm-card {
879
- position: relative; background: var(--bg, #0f172a);
956
+ position: relative; background: var(--bg-elevated);
880
957
  color: var(--text); border: 1px solid var(--border);
881
958
  border-radius: 8px; padding: 18px 22px; min-width: 480px; max-width: 720px;
882
959
  box-shadow: 0 12px 32px rgba(0,0,0,0.5);
@@ -903,10 +980,10 @@ button { font-family: inherit; cursor: pointer; }
903
980
  text-decoration: none; color: var(--text);
904
981
  font-family: ui-monospace, monospace;
905
982
  }
906
- .rdm-artefact:hover { background: var(--bg-hover, rgba(255,255,255,0.08)); }
983
+ .rdm-artefact:hover { background: var(--bg-muted); }
907
984
  .rdm-artefact .rdm-art-kind {
908
985
  font-size: 10px; padding: 1px 4px; border-radius: 3px;
909
- background: var(--accent-bg, rgba(59,130,246,0.2)); color: var(--accent, #3b82f6);
986
+ background: var(--accent-soft); color: var(--accent-text);
910
987
  }
911
988
  .rdm-foot { margin-top: 12px; font-size: 11px; color: var(--text3, #9ca3af); }
912
989
  .resume-item { cursor: pointer; }
@@ -937,9 +1014,9 @@ button { font-family: inherit; cursor: pointer; }
937
1014
  display: flex; align-items: center; justify-content: space-between;
938
1015
  padding: 14px 18px; border-bottom: 1px solid var(--border);
939
1016
  }
940
- .ni-header h3 { font-size: 14px; font-weight: 600; margin: 0; }
1017
+ .ni-header h3 { font-size: var(--fs-title-s); font-weight: 600; margin: 0; }
941
1018
  .ni-close {
942
- background: transparent; border: 0; color: var(--text-3, #9ca3af);
1019
+ background: transparent; border: 0; color: var(--text3);
943
1020
  cursor: pointer; padding: 4px; display: flex; border-radius: 4px;
944
1021
  }
945
1022
  .ni-close:hover { background: var(--bg-muted, rgba(255,255,255,0.06)); color: var(--text); }
@@ -952,8 +1029,8 @@ button { font-family: inherit; cursor: pointer; }
952
1029
  flex: 1;
953
1030
  }
954
1031
  .ni-label {
955
- font-size: 11px; color: var(--text-3, #9ca3af);
956
- text-transform: uppercase; letter-spacing: 0.04em; font-weight: 500;
1032
+ font-family: var(--mono); font-size: var(--fs-eyebrow); color: var(--text3);
1033
+ text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500;
957
1034
  }
958
1035
  .ni-field input[type="text"],
959
1036
  .ni-field textarea,
@@ -981,7 +1058,7 @@ button { font-family: inherit; cursor: pointer; }
981
1058
  font-size: 13px; cursor: pointer;
982
1059
  }
983
1060
  .ni-checkbox input { margin: 0; cursor: pointer; }
984
- .ni-checkbox span { color: var(--text-2); }
1061
+ .ni-checkbox span { color: var(--text2); }
985
1062
  .ni-actions {
986
1063
  display: flex; justify-content: flex-end; gap: 8px;
987
1064
  margin-top: 4px;
@@ -992,7 +1069,7 @@ button { font-family: inherit; cursor: pointer; }
992
1069
  cursor: pointer; transition: all 120ms;
993
1070
  }
994
1071
  .ni-btn-ghost {
995
- background: transparent; color: var(--text-2);
1072
+ background: transparent; color: var(--text2);
996
1073
  border-color: var(--border);
997
1074
  }
998
1075
  .ni-btn-ghost:hover { background: var(--bg-muted, rgba(255,255,255,0.05)); color: var(--text); }
@@ -1024,7 +1101,7 @@ button { font-family: inherit; cursor: pointer; }
1024
1101
  color: var(--text3); margin-bottom: 6px;
1025
1102
  }
1026
1103
  .side-section-body {
1027
- font-size: 13.5px; line-height: 1.55;
1104
+ font-size: var(--fs-body); line-height: 1.5;
1028
1105
  color: var(--text2);
1029
1106
  white-space: pre-wrap;
1030
1107
  word-break: break-word;
@@ -1101,7 +1178,7 @@ button { font-family: inherit; cursor: pointer; }
1101
1178
  color: var(--text);
1102
1179
  }
1103
1180
  .mem-item .desc {
1104
- font-size: 11.5px; color: var(--text2);
1181
+ font-size: var(--fs-caption); color: var(--text2);
1105
1182
  margin-top: 2px;
1106
1183
  }
1107
1184
  .mem-item .meta {
@@ -1338,11 +1415,11 @@ button { font-family: inherit; cursor: pointer; }
1338
1415
  .cost-cell .v {
1339
1416
  font-family: var(--serif); font-weight: 500;
1340
1417
  font-variant-numeric: tabular-nums;
1341
- font-size: 26px; letter-spacing: -0.02em;
1418
+ font-size: var(--fs-num-s); letter-spacing: -0.02em;
1342
1419
  color: var(--text); line-height: 1.1;
1343
1420
  }
1344
1421
  .cost-cell .v small { font-size: 14px; color: var(--text2); margin-left: 2px; font-weight: 400; }
1345
- .cost-cell .sub { font-size: 11.5px; color: var(--text2); }
1422
+ .cost-cell .sub { font-size: var(--fs-caption); color: var(--text2); }
1346
1423
  .cost-cell.warn .v { color: var(--status-blocked); }
1347
1424
  .cost-cell.good .v { color: var(--status-review); }
1348
1425
 
@@ -1443,12 +1520,12 @@ button { font-family: inherit; cursor: pointer; }
1443
1520
  font-size: 12px;
1444
1521
  }
1445
1522
  .feat-name {
1446
- font-family: var(--mono); color: var(--text1);
1523
+ font-family: var(--mono); color: var(--text);
1447
1524
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
1448
1525
  }
1449
- .feat-bar-wrap { background: var(--bg); border-radius: 3px; height: 6px; }
1526
+ .feat-bar-wrap { background: var(--bg-strong); border-radius: 3px; height: 6px; }
1450
1527
  .feat-bar { height: 6px; border-radius: 3px; background: var(--accent); transition: width .3s; }
1451
- .feat-cost { font-family: var(--mono); color: var(--text1); text-align: right; }
1528
+ .feat-cost { font-family: var(--mono); color: var(--text); text-align: right; }
1452
1529
  .feat-runs { font-size: 11px; color: var(--text3); text-align: right; }
1453
1530
 
1454
1531
  /* ── Inbox page ───────────────────────────────────────────────────────────── */
@@ -1476,8 +1553,10 @@ button { font-family: inherit; cursor: pointer; }
1476
1553
  display: flex; align-items: center; gap: 10px;
1477
1554
  }
1478
1555
  .inbox-summary .pill-stat .num {
1479
- font-family: var(--mono); font-weight: 600;
1480
- font-size: 24px; color: var(--text);
1556
+ font-family: var(--serif); font-weight: 500;
1557
+ font-variant-numeric: tabular-nums;
1558
+ font-size: var(--fs-num-s); letter-spacing: -0.02em;
1559
+ color: var(--text);
1481
1560
  }
1482
1561
  .inbox-summary .pill-stat .lbl {
1483
1562
  font-family: var(--mono); font-size: 10px;
@@ -1542,7 +1621,7 @@ button { font-family: inherit; cursor: pointer; }
1542
1621
  .resume-list .resume-item:hover { color: var(--text); }
1543
1622
  .resume-list .resume-item .ri-tag {
1544
1623
  flex-shrink: 0;
1545
- font-family: var(--mono); font-size: 9px;
1624
+ font-family: var(--mono); font-size: var(--fs-eyebrow);
1546
1625
  padding: 2px 5px; border-radius: 3px;
1547
1626
  letter-spacing: 0.04em;
1548
1627
  background: rgba(255,255,255,0.05);
@@ -1550,7 +1629,7 @@ button { font-family: inherit; cursor: pointer; }
1550
1629
  margin-top: 1px;
1551
1630
  }
1552
1631
  .resume-list .resume-item .ri-tag.ok { color: var(--accent); background: rgba(0,217,126,0.08); }
1553
- .resume-list .resume-item .ri-tag.fail { color: var(--p1); background: rgba(255,80,80,0.08); }
1632
+ .resume-list .resume-item .ri-tag.fail { color: var(--p1-fg); background: rgba(255,80,80,0.08); }
1554
1633
  .resume-list .resume-item .ri-text {
1555
1634
  flex: 1; min-width: 0;
1556
1635
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -1572,7 +1651,7 @@ button { font-family: inherit; cursor: pointer; }
1572
1651
  }
1573
1652
  .inbox-section-head h3 {
1574
1653
  font-family: var(--sans); font-weight: 600;
1575
- font-size: 14px; margin: 0; color: var(--text);
1654
+ font-size: var(--fs-title-s); margin: 0; color: var(--text);
1576
1655
  }
1577
1656
  .inbox-section-head .inbox-count {
1578
1657
  font-family: var(--mono); font-size: 11px;
@@ -1607,7 +1686,7 @@ button { font-family: inherit; cursor: pointer; }
1607
1686
  min-width: 0; overflow-wrap: anywhere; word-break: break-word;
1608
1687
  }
1609
1688
  .inbox-row .ttl {
1610
- font-size: 13.5px; color: var(--text);
1689
+ font-size: var(--fs-body); color: var(--text);
1611
1690
  font-weight: 500;
1612
1691
  display: flex;
1613
1692
  flex-direction: column;
@@ -1623,7 +1702,7 @@ button { font-family: inherit; cursor: pointer; }
1623
1702
  }
1624
1703
  .inbox-row .ttl .ttl-desc {
1625
1704
  font-weight: 400;
1626
- font-size: 12.5px;
1705
+ font-size: var(--fs-small);
1627
1706
  color: var(--text2);
1628
1707
  display: -webkit-box;
1629
1708
  -webkit-line-clamp: 2;
@@ -1647,8 +1726,20 @@ button { font-family: inherit; cursor: pointer; }
1647
1726
  padding: 0 12px 8px 0; border-bottom: 1px solid var(--border);
1648
1727
  }
1649
1728
  .budgets-table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
1650
- .budgets-table .bt-agent { font-family: var(--mono); }
1651
- .budgets-table .bt-cap { font-family: var(--mono); font-variant-numeric: tabular-nums; }
1729
+ /* A row has to answer three questions: what is this agent, what does it cost,
1730
+ over what period. The slug alone answered none of them. */
1731
+ .budgets-table .bt-slug { font-family: var(--mono); font-size: var(--fs-small); }
1732
+ .budgets-table .bt-desc {
1733
+ font-size: 12px; color: var(--text2); margin-top: 2px;
1734
+ max-width: 52ch; text-wrap: pretty;
1735
+ }
1736
+ .budgets-table .bt-runs,
1737
+ .budgets-table .bt-cap { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
1738
+ .budgets-table .bt-spend { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
1739
+ .budgets-table .bt-usd { font-variant-numeric: tabular-nums; color: var(--text); }
1740
+ .budgets-table th[title] { cursor: help; }
1741
+ .budgets-idle summary { cursor: pointer; font-size: 13px; color: var(--text2); }
1742
+ .budgets-idle summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
1652
1743
  .budgets-table .bt-actions { text-align: right; white-space: nowrap; }
1653
1744
  .budgets-table .bt-actions .ab-btn + .ab-btn { margin-left: 6px; }
1654
1745
  .budgets-empty {
@@ -1741,7 +1832,7 @@ button { font-family: inherit; cursor: pointer; }
1741
1832
  so a figure that ticks over re-flows its own width and the number appears to
1742
1833
  breathe. Tabular figures cost nothing here and stop it. */
1743
1834
  font-variant-numeric: tabular-nums;
1744
- font-size: 52px; line-height: 1;
1835
+ font-size: var(--fs-num-xl); line-height: 1;
1745
1836
  letter-spacing: -0.02em;
1746
1837
  }
1747
1838
  /* An absence is rendered quietly and never silently — the glyph stays out of the
@@ -1778,7 +1869,7 @@ button { font-family: inherit; cursor: pointer; }
1778
1869
  gap: 12px; margin-bottom: 28px;
1779
1870
  }
1780
1871
  .mp-secondary .metric-card { padding: 18px; }
1781
- .mp-secondary .metric-num { font-size: 30px; }
1872
+ .mp-secondary .metric-num { font-size: var(--fs-num-m); }
1782
1873
 
1783
1874
  .mp-row {
1784
1875
  display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
@@ -1789,7 +1880,7 @@ button { font-family: inherit; cursor: pointer; }
1789
1880
  }
1790
1881
  .mp-card h3 {
1791
1882
  font-family: var(--sans); font-weight: 600;
1792
- font-size: 15px; margin: 0 0 16px;
1883
+ font-size: var(--fs-title-s); margin: 0 0 16px;
1793
1884
  }
1794
1885
  .mp-card-head { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 16px; }
1795
1886
  .mp-card-head h3 { margin: 0; }
@@ -1809,7 +1900,7 @@ button { font-family: inherit; cursor: pointer; }
1809
1900
  .bar-row {
1810
1901
  display: grid; grid-template-columns: 130px 1fr 50px;
1811
1902
  align-items: center; gap: 12px;
1812
- margin-bottom: 10px; font-size: 12.5px;
1903
+ margin-bottom: 10px; font-size: var(--fs-small);
1813
1904
  }
1814
1905
  .bar-row .name { font-family: var(--mono); color: var(--text); }
1815
1906
  .bar-row .rail {
@@ -1851,7 +1942,7 @@ button { font-family: inherit; cursor: pointer; }
1851
1942
  /* ── Logs tab ───────────────────────────────────────────── */
1852
1943
  .logs-page { display: flex; flex-direction: column; height: 100%; }
1853
1944
  .logs-header { padding: 20px 24px 12px; border-bottom: 1px solid var(--border); display: flex; align-items: baseline; gap: 16px; }
1854
- .logs-header h2 { margin: 0; font-size: 18px; }
1945
+ .logs-header h2 { margin: 0; font-size: var(--fs-title-l); letter-spacing: -0.02em; }
1855
1946
  .logs-hint { font-size: 12px; color: var(--text3); }
1856
1947
  .logs-hint code { font-family: var(--mono); background: var(--bg-strong); padding: 1px 5px; border-radius: 3px; }
1857
1948
  .logs-layout { display: flex; flex: 1; overflow: hidden; }
@@ -1878,7 +1969,7 @@ button { font-family: inherit; cursor: pointer; }
1878
1969
  .log-item .log-title { font-size: 13px; font-weight: 500; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1879
1970
  .log-item .log-meta { font-size: 11px; color: var(--text3); margin-top: 2px; }
1880
1971
  .log-detail-head { margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
1881
- .log-detail-head h3 { margin: 0 0 4px; font-size: 17px; }
1972
+ .log-detail-head h3 { margin: 0 0 4px; font-size: var(--fs-title-m); letter-spacing: -0.01em; }
1882
1973
  .log-detail-head .log-detail-meta { font-size: 12px; color: var(--text3); }
1883
1974
  .log-section { margin-bottom: 18px; }
1884
1975
  .log-section-label { font-size: 11px; color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
@@ -2074,7 +2165,8 @@ button { font-family: inherit; cursor: pointer; }
2074
2165
  padding: 16px 20px; border-bottom: 1px solid var(--border);
2075
2166
  }
2076
2167
  .agent-drawer-header h3 {
2077
- font-family: var(--mono); font-size: 14px;
2168
+ font-family: var(--sans); font-weight: 600;
2169
+ font-size: var(--fs-title-s);
2078
2170
  color: var(--text); flex: 1; margin: 0;
2079
2171
  }
2080
2172
  .agent-drawer-close {
@@ -2157,7 +2249,7 @@ button { font-family: inherit; cursor: pointer; }
2157
2249
  border-radius: 16px;
2158
2250
  padding: 32px;
2159
2251
  }
2160
- .share-card h2 {
2252
+ .share-card h2:not(.section-title) {
2161
2253
  font-family: var(--serif); font-weight: 500;
2162
2254
  font-size: 28px; letter-spacing: -0.01em;
2163
2255
  margin: 0 0 6px;
@@ -2175,10 +2267,10 @@ button { font-family: inherit; cursor: pointer; }
2175
2267
  .share-toggle-row .label-grp .l { font-weight: 600; font-size: 14px; }
2176
2268
  .share-toggle-row .label-grp .h { color: var(--text2); font-size: 12px; }
2177
2269
 
2178
- .notif-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; transition: border-color 0.15s; }
2270
+ .notif-row { display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: var(--bg-muted); border: 1px solid var(--border); border-radius: 8px; cursor: pointer; transition: border-color 0.15s; }
2179
2271
  .notif-row:hover { border-color: var(--accent); }
2180
2272
  .notif-row .emoji { font-size: 16px; flex-shrink: 0; }
2181
- .notif-row strong { color: var(--text1); margin-right: 4px; }
2273
+ .notif-row strong { color: var(--text); margin-right: 4px; }
2182
2274
  .notif-row input[type="checkbox"] { width: 16px; height: 16px; accent-color: var(--accent); cursor: pointer; }
2183
2275
 
2184
2276
  .toggle {
@@ -2212,7 +2304,7 @@ button { font-family: inherit; cursor: pointer; }
2212
2304
  }
2213
2305
  .url-block .url-text {
2214
2306
  flex: 1; font-family: var(--mono);
2215
- font-size: 12.5px; color: var(--text);
2307
+ font-size: var(--fs-small); color: var(--text);
2216
2308
  white-space: nowrap; overflow: hidden;
2217
2309
  text-overflow: ellipsis;
2218
2310
  }
@@ -2345,7 +2437,7 @@ button { font-family: inherit; cursor: pointer; }
2345
2437
  display: grid;
2346
2438
  grid-template-columns: 130px 1fr 70px;
2347
2439
  align-items: center; gap: 12px;
2348
- margin-bottom: 10px; font-size: 12.5px;
2440
+ margin-bottom: 10px; font-size: var(--fs-small);
2349
2441
  }
2350
2442
  .agent-cost-row .name { font-family: var(--mono); color: var(--text); }
2351
2443
  .agent-cost-row .rail { display: block; height: 6px; background: var(--bg-strong); border-radius: 999px; overflow: hidden; }
@@ -2378,7 +2470,11 @@ button { font-family: inherit; cursor: pointer; }
2378
2470
  .side-section-head { font-family: var(--mono); font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text3); margin-bottom: 8px; margin-top: 16px; }
2379
2471
 
2380
2472
  /* ── Security tab: hero KPI tile + activity feed ──────────────────────────── */
2381
- .kpi-hero .kpi-value-hero { font-size: 36px; line-height: 1.1; font-weight: 500; }
2473
+ .kpi-hero .kpi-value-hero {
2474
+ font-family: var(--serif); font-weight: 500;
2475
+ font-variant-numeric: tabular-nums;
2476
+ font-size: var(--fs-num-l); line-height: 1.1; letter-spacing: -0.02em;
2477
+ }
2382
2478
  .kpi-hero .kpi-label { font-size: 13px; font-weight: 500; }
2383
2479
  .kpi-empty .kpi-value { color: var(--accent); font-size: 22px; }
2384
2480
  .kpi-empty .kpi-value::after {
@@ -2733,6 +2829,7 @@ button { font-family: inherit; cursor: pointer; }
2733
2829
 
2734
2830
  <!-- Kanban -->
2735
2831
  <div class="panel" id="panel-kanban">
2832
+ <h1 class="page-title">Tasks</h1>
2736
2833
  <div id="filter-bar" class="filter-bar"></div>
2737
2834
  <div class="kanban" id="kanban-board"></div>
2738
2835
  </div>
@@ -2741,6 +2838,7 @@ button { font-family: inherit; cursor: pointer; }
2741
2838
  <!-- Metrics -->
2742
2839
  <div class="panel" id="panel-dashboard">
2743
2840
  <div class="metrics-page">
2841
+ <h1 class="page-title">Metrics</h1>
2744
2842
  <div class="period-toolbar">
2745
2843
  <span class="period-label">PERIOD</span>
2746
2844
  <div class="period-chips" id="period-chips">
@@ -2886,7 +2984,7 @@ button { font-family: inherit; cursor: pointer; }
2886
2984
 
2887
2985
  <div class="panel" id="panel-budgets" role="tabpanel">
2888
2986
  <div class="metrics-page">
2889
- <h1>Agent budgets</h1>
2987
+ <h1 class="page-title">Agent budgets</h1>
2890
2988
  <p class="muted" style="max-width:70ch;margin:6px 0 20px">
2891
2989
  A cap on what one agent may spend. When measured spend passes it, the pipeline
2892
2990
  refuses to dispatch that agent and says so — it does not fail quietly.
@@ -2898,7 +2996,7 @@ button { font-family: inherit; cursor: pointer; }
2898
2996
  </div>
2899
2997
 
2900
2998
  <div class="panel" id="panel-docs">
2901
- <h1>Docs</h1>
2999
+ <h1 class="page-title">Docs</h1>
2902
3000
  <div class="docs-search">
2903
3001
  <input type="search" id="docs-q" placeholder="Search inside documents…"
2904
3002
  aria-label="Search inside documents"
@@ -2967,8 +3065,9 @@ button { font-family: inherit; cursor: pointer; }
2967
3065
  <!-- Notifications -->
2968
3066
  <div class="panel" id="panel-notifications">
2969
3067
  <div class="share-page">
3068
+ <h1 class="page-title">Notifications</h1>
2970
3069
  <div class="share-card">
2971
- <h2>Email alerts</h2>
3070
+ <h2 class="section-title">Email alerts</h2>
2972
3071
  <p class="sub">Get notified when something needs your attention — even when you're away from the board. Verify your email once, pick what to receive, done. No API keys, no extra signup.</p>
2973
3072
 
2974
3073
  <div class="share-toggle-row">
@@ -2985,7 +3084,7 @@ button { font-family: inherit; cursor: pointer; }
2985
3084
  <div id="notif-step-email">
2986
3085
  <div style="font-size:11px;color:var(--text3);margin-bottom:4px;font-family:var(--mono);letter-spacing:.05em">RECIPIENT EMAIL</div>
2987
3086
  <div style="display:flex;gap:8px;flex-wrap:wrap">
2988
- <input id="notif-to" type="email" placeholder="you@example.com" style="flex:1 1 280px;min-width:240px;padding:8px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text1);font-size:14px" />
3087
+ <input id="notif-to" type="email" placeholder="you@example.com" style="flex:1 1 280px;min-width:240px;padding:8px 10px;background:var(--bg-muted);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:14px" />
2989
3088
  <button class="btn-black" onclick="sendVerifyCode()">Send code</button>
2990
3089
  </div>
2991
3090
  </div>
@@ -2994,23 +3093,23 @@ button { font-family: inherit; cursor: pointer; }
2994
3093
  <div id="notif-step-code" style="display:none;margin-top:14px">
2995
3094
  <div style="font-size:11px;color:var(--text3);margin-bottom:4px;font-family:var(--mono);letter-spacing:.05em">VERIFICATION CODE (CHECK <span id="notif-code-target">YOUR INBOX</span>)</div>
2996
3095
  <div style="display:flex;gap:8px;flex-wrap:wrap">
2997
- <input id="notif-code" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" placeholder="123456" style="flex:1 1 200px;min-width:140px;padding:8px 10px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;color:var(--text1);font-size:18px;font-family:var(--mono);letter-spacing:.2em;text-align:center" />
3096
+ <input id="notif-code" type="text" inputmode="numeric" pattern="\d{6}" maxlength="6" placeholder="123456" style="flex:1 1 200px;min-width:140px;padding:8px 10px;background:var(--bg-muted);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:18px;font-family:var(--mono);letter-spacing:.2em;text-align:center" />
2998
3097
  <button class="btn-black" onclick="confirmVerifyCode()">Verify</button>
2999
3098
  <button class="btn-ghost-sm" onclick="sendVerifyCode()">Resend code</button>
3000
3099
  </div>
3001
3100
  </div>
3002
3101
 
3003
3102
  <!-- Step 3: verified -->
3004
- <div id="notif-step-verified" style="display:none;margin-top:14px;padding:10px 14px;background:rgba(0,217,126,0.08);border:1px solid rgba(0,217,126,0.30);border-radius:8px;display:none;align-items:center;gap:10px;font-size:13.5px">
3103
+ <div id="notif-step-verified" style="display:none;margin-top:14px;padding:10px 14px;background:rgba(0,217,126,0.08);border:1px solid rgba(0,217,126,0.30);border-radius:8px;display:none;align-items:center;gap:10px;font-size: var(--fs-body)">
3005
3104
  <span style="color:var(--accent);font-size:18px">✓</span>
3006
3105
  <span><strong id="notif-verified-email">—</strong> verified · alerts delivered through <code>greatcto.systems/notify</code></span>
3007
3106
  <button class="btn-ghost-sm" style="margin-left:auto" onclick="changeEmail()">Change</button>
3008
3107
  </div>
3009
3108
 
3010
3109
  <div class="share-note" style="margin-top:24px">
3011
- <strong style="color:var(--text1)">Which alerts to send</strong>
3110
+ <strong style="color:var(--text)">Which alerts to send</strong>
3012
3111
  </div>
3013
- <div id="notif-triggers" style="display:flex;flex-direction:column;gap:10px;margin-top:10px;font-size:13.5px">
3112
+ <div id="notif-triggers" style="display:flex;flex-direction:column;gap:10px;margin-top:10px;font-size: var(--fs-body)">
3014
3113
  <label class="notif-row"><input type="checkbox" data-trigger="incident.p0"> <span class="emoji">🚨</span><strong>P0 incident</strong> — production failure, ack-grade</label>
3015
3114
  <label class="notif-row"><input type="checkbox" data-trigger="gate.stale"> <span class="emoji">⏸️</span><strong>Gate stale &gt; 2h</strong> — ship gate waiting for your approval</label>
3016
3115
  <label class="notif-row"><input type="checkbox" data-trigger="gate.blocked"> <span class="emoji">🛡️</span><strong>Security BLOCKED</strong> — security-officer rejected a merge</label>
@@ -3032,7 +3131,7 @@ button { font-family: inherit; cursor: pointer; }
3032
3131
 
3033
3132
  <!-- ── Browser push card ───────────────────────────────────────── -->
3034
3133
  <div class="share-card" style="margin-top:16px" id="push-card">
3035
- <h2>Browser push</h2>
3134
+ <h2 class="section-title">Browser push</h2>
3036
3135
  <p class="sub">Native desktop notifications — works even when the board tab is in the background. No email required. Uses Web Push (VAPID) — keys stay local.</p>
3037
3136
  <div class="share-toggle-row">
3038
3137
  <div class="label-grp">
@@ -3043,7 +3142,7 @@ button { font-family: inherit; cursor: pointer; }
3043
3142
  <input type="checkbox" id="push-toggle" onclick="event.stopPropagation()">
3044
3143
  </div>
3045
3144
  </div>
3046
- <div id="push-status" style="margin-top:10px;font-size:12.5px;font-family:var(--mono);color:var(--text3)"></div>
3145
+ <div id="push-status" style="margin-top:10px;font-size: var(--fs-small);font-family:var(--mono);color:var(--text3)"></div>
3047
3146
  <div class="share-note" style="margin-top:16px">
3048
3147
  Push subscriptions are stored locally in <code>~/.great_cto/push-subscriptions.json</code>. Notifications fire for the same 5 triggers as email alerts.
3049
3148
  </div>
@@ -3051,7 +3150,7 @@ button { font-family: inherit; cursor: pointer; }
3051
3150
 
3052
3151
  <!-- ── In-app notification history ─────────────────────────────── -->
3053
3152
  <div class="share-card" style="margin-top:16px">
3054
- <h2>Recent notifications</h2>
3153
+ <h2 class="section-title">Recent notifications</h2>
3055
3154
  <p class="sub">Last 100 alerts fired this session. Also accessible via the notification drawer (bell icon).</p>
3056
3155
  <div id="notif-history-inline" style="display:flex;flex-direction:column;gap:0;margin-top:12px;
3057
3156
  border:1px solid var(--border);border-radius:8px;overflow:hidden;font-size:13px">
@@ -3544,66 +3643,120 @@ async function loadBudgetsPage() {
3544
3643
 
3545
3644
  const [h] = await Promise.all([
3546
3645
  api(`/api/heartbeat${pqs()}`),
3547
- (typeof loadFleet === 'function' && !(fleetState?.agents || []).length) ? loadFleet() : Promise.resolve(),
3646
+ (typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
3647
+ ? refreshAgentsInstalled() : Promise.resolve(),
3548
3648
  ]);
3549
3649
  const caps = h?.budgets || {};
3550
- const agents = fleetState?.agents || [];
3551
- const judged = new Map(agents.map((a) => [a.slug, a.budget]));
3552
- const capped = Object.entries(caps);
3553
- const uncapped = agents.filter((a) => !(a.slug in caps));
3650
+ const agents = (fleetState?.agents || []).filter((a) => !a.retired);
3651
+
3652
+ // 69 identical chips in one blob answered none of the three questions a person
3653
+ // arrives with: what does this agent do, what does it usually cost, and over
3654
+ // what period. Worse, 62 of the 69 had not run in thirty days — a cap on an
3655
+ // agent that never runs is a cap on nothing, and they were the bulk of the
3656
+ // wall.
3657
+ //
3658
+ // Ordered by what it costs to leave uncapped: agents that ran, most spend
3659
+ // first. The ones that never ran are still reachable, behind a count.
3660
+ const spendOf = (a) => (a.llm_usd_30d_real ?? a.llm_usd_30d_est ?? 0);
3661
+ const ran = agents.filter((a) => (a.runs_30d || 0) > 0)
3662
+ .sort((x, y) => spendOf(y) - spendOf(x) || (y.runs_30d || 0) - (x.runs_30d || 0));
3663
+ const idle = agents.filter((a) => !(a.runs_30d > 0));
3664
+
3665
+ const firstSentence = (t) => {
3666
+ const s1 = String(t || '').split(/(?<=[.!?])\s/)[0] || '';
3667
+ return s1.length > 150 ? s1.slice(0, 147) + '…' : s1;
3668
+ };
3669
+
3670
+ // Spend is stated with its window and its provenance. `est` is a time-based
3671
+ // guess and says so; a real figure only exists where a verdict carried a cost.
3672
+ const spendCell = (a) => {
3673
+ if (a.llm_usd_30d_real != null && a.llm_usd_30d_real > 0) {
3674
+ return `<span class="bt-usd">$${a.llm_usd_30d_real.toFixed(2)}</span> <span class="muted">measured</span>`;
3675
+ }
3676
+ if (a.llm_usd_30d_est != null && a.llm_usd_30d_est > 0) {
3677
+ return `<span class="bt-usd">$${a.llm_usd_30d_est.toFixed(2)}</span> <span class="muted">estimated</span>`;
3678
+ }
3679
+ return '<span class="muted">not measured</span>';
3680
+ };
3554
3681
 
3555
3682
  const stateCell = (slug) => {
3556
- const b = judged.get(slug);
3557
- if (!b || b.state === 'no-limit') return '<span class="muted">state not loaded</span>';
3683
+ const b = (fleetState?.agents || []).find((a) => a.slug === slug)?.budget;
3684
+ if (!b || b.state === 'no-limit') return '';
3558
3685
  const spent = (b.state === 'within' || b.state === 'exceeded')
3559
- ? `$${(b.measuredUsd ?? 0).toFixed(2)} measured`
3560
- : (b.state === 'unmeasured' ? 'nothing measured yet' : '');
3686
+ ? `$${(b.measuredUsd ?? 0).toFixed(2)} measured` : (b.state === 'unmeasured' ? 'nothing measured yet' : '');
3561
3687
  return `${budgetChip(b)} <span class="ab-spent">${esc(spent)}</span>`;
3562
3688
  };
3563
3689
 
3564
- // Three states, as everywhere: capped, uncapped, and unreadable — never a
3565
- // silent "all fine".
3566
- const bad = (h?.budgets_malformed || []);
3690
+ const row = (a, cap) => `
3691
+ <tr>
3692
+ <td class="bt-agent">
3693
+ <div class="bt-slug">${esc(a.slug)}</div>
3694
+ <div class="bt-desc">${esc(firstSentence(a.description))}</div>
3695
+ </td>
3696
+ <td class="bt-runs">${a.runs_30d || 0}</td>
3697
+ <td class="bt-spend">${spendCell(a)}</td>
3698
+ <td class="bt-cap">${cap != null ? `$${esc(String(cap))}` : '<span class="muted">—</span>'}</td>
3699
+ <td class="bt-state">${cap != null ? stateCell(a.slug) : ''}</td>
3700
+ <td class="bt-actions">${cap != null
3701
+ ? `<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(a.slug)}', ${Number(cap)})">Change</button>`
3702
+ + `<button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(a.slug)}', ${Number(cap)})">Remove</button>`
3703
+ : `<button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')">Set a cap</button>`}</td>
3704
+ </tr>`;
3705
+
3706
+ const table = (rows) => `
3707
+ <table class="budgets-table">
3708
+ <thead><tr>
3709
+ <th scope="col">Agent</th>
3710
+ <th scope="col" title="Dispatches in the last 30 days">Runs 30d</th>
3711
+ <th scope="col" title="LLM spend over the same 30 days">Spend 30d</th>
3712
+ <th scope="col">Cap</th><th scope="col">State</th><th scope="col"></th>
3713
+ </tr></thead>
3714
+ <tbody>${rows}</tbody>
3715
+ </table>`;
3716
+
3717
+ // A cap declared for an agent the fleet does not list still has to appear —
3718
+ // otherwise a limit that is holding a stage would be invisible here.
3719
+ const known = new Set(agents.map((a) => a.slug));
3720
+ const orphanCaps = Object.entries(caps).filter(([slug]) => !known.has(slug));
3721
+
3722
+ const capped = ran.filter((a) => a.slug in caps);
3723
+ const uncappedRan = ran.filter((a) => !(a.slug in caps));
3724
+
3567
3725
  const notes = [
3568
3726
  h?.budgets_deprecated_key
3569
3727
  ? `<div class="ab-note">Read from the deprecated <code>${esc(h.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>` : '',
3570
- bad.length
3571
- ? `<div class="ab-note ab-bad">${bad.length} line(s) in PROJECT.md were not read: `
3572
- + bad.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
3728
+ (h?.budgets_malformed || []).length
3729
+ ? `<div class="ab-note ab-bad">${h.budgets_malformed.length} line(s) in PROJECT.md were not read: `
3730
+ + h.budgets_malformed.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
3573
3731
  ].join('');
3574
3732
 
3575
- const capsBlock = capped.length ? `
3576
- <table class="budgets-table">
3577
- <thead><tr><th scope="col">Agent</th><th scope="col">Cap</th><th scope="col">State</th><th scope="col"></th></tr></thead>
3578
- <tbody>${capped.map(([slug, cap]) => `
3579
- <tr>
3580
- <td class="bt-agent">${esc(slug)}</td>
3581
- <td class="bt-cap">$${esc(String(cap))}</td>
3582
- <td>${stateCell(slug)}</td>
3583
- <td class="bt-actions">
3584
- <button type="button" class="ab-btn" onclick="editAgentBudget('${esc(slug)}', ${Number(cap)})">Change</button>
3585
- <button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(slug)}', ${Number(cap)})">Remove</button>
3586
- </td>
3587
- </tr>`).join('')}</tbody>
3588
- </table>`
3589
- : `<div class="budgets-empty">
3590
- No agent has a spending cap in this project. Nothing is being held, and nothing will be.
3591
- </div>`;
3592
-
3593
- const uncappedBlock = uncapped.length ? `
3594
- <div class="mp-section-label" style="margin-top:24px">No cap (${uncapped.length})</div>
3595
- <div class="budgets-uncapped">${uncapped.slice(0, 60).map((a) => `
3596
- <button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
3597
- title="Set a cap for ${esc(a.slug)}">${esc(a.slug)} +</button>`).join('')}
3598
- ${uncapped.length > 60 ? `<span class="muted">and ${uncapped.length - 60} more</span>` : ''}
3599
- </div>` : '';
3733
+ const capsBlock = (capped.length || orphanCaps.length) ? `
3734
+ <div class="mp-section-label">Capped (${capped.length + orphanCaps.length})</div>
3735
+ ${table(capped.map((a) => row(a, caps[a.slug])).join('')
3736
+ + orphanCaps.map(([slug, cap]) => row({ slug, description: 'not among the installed agents', runs_30d: 0 }, cap)).join(''))}`
3737
+ : `<div class="budgets-empty">No agent has a spending cap in this project. Nothing is being held, and nothing will be.</div>`;
3738
+
3739
+ const activeBlock = uncappedRan.length ? `
3740
+ <div class="mp-section-label" style="margin-top:28px">Ran in the last 30 days, no cap (${uncappedRan.length})</div>
3741
+ <p class="muted" style="margin:4px 0 10px">These are the ones a cap would actually apply to.</p>
3742
+ ${table(uncappedRan.map((a) => row(a, null)).join(''))}` : '';
3743
+
3744
+ // Capping an agent that has not run is capping nothing. Reachable, not shown.
3745
+ const idleBlock = idle.length ? `
3746
+ <details class="budgets-idle" style="margin-top:28px">
3747
+ <summary>${idle.length} installed agents have not run in 30 days</summary>
3748
+ <div class="budgets-uncapped">${idle.slice(0, 80).map((a) => `
3749
+ <button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
3750
+ title="${esc(firstSentence(a.description))}">${esc(a.slug)} +</button>`).join('')}
3751
+ ${idle.length > 80 ? `<span class="muted">and ${idle.length - 80} more</span>` : ''}
3752
+ </div>
3753
+ </details>` : '';
3600
3754
 
3601
- body.innerHTML = `${notes}${capsBlock}
3602
- <button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>
3603
- ${uncappedBlock}`;
3755
+ body.innerHTML = `${notes}${capsBlock}${activeBlock}${idleBlock}
3756
+ <button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget for any agent</button>`;
3604
3757
 
3605
3758
  const badge = document.getElementById('nav-budget-count');
3606
- if (badge) badge.textContent = capped.length ? String(capped.length) : '';
3759
+ if (badge) badge.textContent = Object.keys(caps).length ? String(Object.keys(caps).length) : '';
3607
3760
  }
3608
3761
 
3609
3762
  /** Set a cap for a named agent, skipping the "which agent?" question. */
@@ -6131,15 +6284,22 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide();
6131
6284
  */
6132
6285
  async function checkBoardFreshness() {
6133
6286
  const v = await api('/api/version');
6134
- if (!v || v.stale !== 'yes') return;
6287
+ // `behind` and `ahead` are different problems with different fixes; `no` and
6288
+ // `unknown` are both silent, because a comparison we could not make must not
6289
+ // render as a mismatch.
6290
+ if (!v || (v.stale !== 'behind' && v.stale !== 'ahead')) return;
6135
6291
  const host = document.querySelector('.workspace');
6136
6292
  if (!host || document.getElementById('stale-board')) return;
6137
6293
  const el = document.createElement('div');
6138
6294
  el.id = 'stale-board';
6139
6295
  el.className = 'stale-board';
6140
6296
  el.setAttribute('role', 'status');
6141
- el.innerHTML = `<span>This board is running <code>v${esc(v.version)}</code> `
6142
- + `<code>v${esc(v.installed)}</code> is installed. Restart it to pick up the new version.</span>`;
6297
+ el.innerHTML = v.stale === 'behind'
6298
+ ? `<span>This board is running <code>v${esc(v.version)}</code> `
6299
+ + `<code>v${esc(v.installed)}</code> is installed. Restart it to pick up the new version.</span>`
6300
+ : `<span>This board is running <code>v${esc(v.version)}</code>, which is newer than the `
6301
+ + `installed <code>v${esc(v.installed)}</code>. Restarting would go back to the older build — `
6302
+ + `install this version first.</span>`;
6143
6303
  host.insertBefore(el, host.firstChild);
6144
6304
  }
6145
6305
 
@@ -7170,7 +7330,7 @@ function renderNotifDrawer() {
7170
7330
  <div style="display:flex;align-items:flex-start;gap:10px">
7171
7331
  <span style="font-size:15px;flex-shrink:0;margin-top:1px">${_notifLevelIcon(n.level)}</span>
7172
7332
  <div style="flex:1;min-width:0">
7173
- <div style="font-size:13px;font-weight:${n.read ? '400' : '600'};color:var(--text1);
7333
+ <div style="font-size:13px;font-weight:${n.read ? '400' : '600'};color:var(--text);
7174
7334
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(n.title)}</div>
7175
7335
  <div style="font-size:12px;color:var(--text3);margin-top:2px;line-height:1.4">${esc(n.body)}</div>
7176
7336
  <div style="font-size:11px;color:var(--text3);margin-top:4px;font-family:var(--mono)">
@@ -7350,7 +7510,7 @@ if (_costLbl) _costLbl.textContent = `Cost · last ${periodLabel(currentPeriod)}
7350
7510
 
7351
7511
  <!-- ── Notification drawer ──────────────────────────────────────────────── -->
7352
7512
  <div id="notif-drawer" style="position:fixed;top:0;right:-400px;width:380px;height:100vh;
7353
- background:var(--bg1);border-left:1px solid var(--border);z-index:9999;
7513
+ background:var(--bg-elevated);border-left:1px solid var(--border);z-index:9999;
7354
7514
  transition:right .25s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;
7355
7515
  box-shadow:-4px 0 32px rgba(0,0,0,.5)">
7356
7516
  <div style="padding:14px 16px;border-bottom:1px solid var(--border);
@@ -7359,7 +7519,7 @@ if (_costLbl) _costLbl.textContent = `Cost · last ${periodLabel(currentPeriod)}
7359
7519
  style="width:16px;height:16px;color:var(--text2)">
7360
7520
  <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9 M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
7361
7521
  </svg>
7362
- <span style="font-weight:600;font-size:14px;flex:1;color:var(--text1)">Notifications</span>
7522
+ <span style="font-weight:600;font-size:14px;flex:1;color:var(--text)">Notifications</span>
7363
7523
  <button class="btn-ghost-sm" onclick="markAllNotifsRead()"
7364
7524
  style="font-size:12px">Mark all read</button>
7365
7525
  <button onclick="closeNotifDrawer()" style="background:none;border:none;cursor:pointer;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.1.0",
3
+ "version": "3.3.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",