great-cto 3.26.2 → 3.27.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.
@@ -11,11 +11,8 @@
11
11
  function toggleBoardTheme() {
12
12
  var r = document.documentElement, t = r.dataset.theme === 'light' ? 'dark' : 'light';
13
13
  r.dataset.theme = t; try { localStorage.setItem('gcto_board_theme', t); } catch (e) {}
14
- var b = document.getElementById('board-theme-btn'); if (b) b.textContent = t === 'light' ? '☀️' : '🌙';
14
+ /* The icon follows data-theme in CSS; nothing to repaint here. */
15
15
  }
16
- addEventListener('DOMContentLoaded', function () {
17
- var b = document.getElementById('board-theme-btn'); if (b) b.textContent = document.documentElement.dataset.theme === 'light' ? '☀️' : '🌙';
18
- });
19
16
  </script>
20
17
  <meta charset="UTF-8" />
21
18
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -118,6 +115,17 @@
118
115
  --p2-bg: rgba(240, 210, 78, 0.14); --p2-fg: #fde68a;
119
116
  --p3-bg: rgba(255, 255, 255, 0.05); --p3-fg: #9ca3af;
120
117
 
118
+ /* Absence — two states the board already produces and could not render
119
+ (§4/§6.3 of DESIGN-board-redesign-2026-09): `!` unreadable, `?` unjudged.
120
+ Reuses the p1 values verbatim rather than introducing a fifth amber. */
121
+ --absent-warn-fg: #fcd34d;
122
+ --absent-warn-bg: rgba(255, 170, 60, 0.14);
123
+ /* Posture — aliases onto the measured p0 pair, not new colours (§6.5): one
124
+ hue for "expensive", distinguished by words. Aliased, not copied, so a
125
+ future change to p0 cannot silently desynchronise these. */
126
+ --posture-expensive-bg: var(--p0-bg);
127
+ --posture-expensive-fg: var(--p0-fg);
128
+
121
129
  /* Was `--serif`, and there is no serif anywhere in the stack — it is the
122
130
  DISPLAY face, doing display work correctly under a name that promised a
123
131
  contrast this page has never had. greatcto.systems already calls the same
@@ -191,6 +199,13 @@
191
199
  --p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #a64c08;
192
200
  --p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #885f09;
193
201
  --p3-bg: rgba(0, 0, 0, 0.05); --p3-fg: #5c6b65;
202
+ /* Absence / posture — see :root for the rationale. Both blocks in the same
203
+ commit: a token added to only one theme is the defect this file's own
204
+ comments document (the light block is 39 overrides nobody was checking). */
205
+ --absent-warn-fg: #a64c08;
206
+ --absent-warn-bg: rgba(180, 83, 9, 0.12);
207
+ --posture-expensive-bg: var(--p0-bg);
208
+ --posture-expensive-fg: var(--p0-fg);
194
209
  --surface-drawer: #ffffff;
195
210
  --surface-translucent: rgba(255, 255, 255, 0.72);
196
211
  --rail-ok-bg: rgba(10, 125, 77, 0.12);
@@ -565,6 +580,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
565
580
  .nav-item.active .icon { color: var(--accent-text); }
566
581
  .nav-item .count { margin-left: auto; font-size: var(--fs-caption); color: var(--text3); font-family: var(--mono); }
567
582
  .nav-divider { height: 1px; background: var(--border); margin: 12px 8px; }
583
+ .nav-views-label { padding: 0 18px; font-size: var(--fs-caption); color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; }
584
+ .nav-views { display: flex; flex-direction: column; gap: 2px; padding: 6px 8px 12px; }
585
+ .nav-view-item { height: 28px; display: flex; align-items: center; padding: 0 10px; font-size: var(--fs-small); color: var(--text3); }
568
586
 
569
587
  .sidebar-footer {
570
588
  padding: 12px 16px;
@@ -577,6 +595,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
577
595
  }
578
596
  .live-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--status-review); }
579
597
  .live-dot.error { background: var(--status-blocked); }
598
+ /* Neither live nor failed: the handshake has not resolved. Three states. */
599
+ .live-dot.connecting { background: var(--text3); animation: none; }
580
600
  .live-dot.pulse { animation: pulse 2s infinite; }
581
601
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.4; } }
582
602
 
@@ -1437,6 +1457,94 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
1437
1457
  /* Same shape as `.muted`: the `--mono` token exists and is used 115×, the
1438
1458
  CLASS that applies it did not. */
1439
1459
  .mono { font-family: var(--mono); }
1460
+ /* ── Redesign primitives (DESIGN-board-redesign §6.2 GateChip, §6.3 StatusDot,
1461
+ §6.5 PostureStrip). Declared here, ahead of the screens that use them, so
1462
+ each screen task adds markup, not CSS. Rule they all share: weight and
1463
+ glyph carry meaning, hue is one-per-meaning — a filled dot is settled, a
1464
+ hollow one is in flight, an amber glyph is an absence. */
1465
+ .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
1466
+ .dot.ok { background: var(--status-done); }
1467
+ .dot.bad { background: var(--status-blocked); }
1468
+ .dot.run { background: transparent; border: 1px solid var(--status-progress); }
1469
+ /* An absence glyph (`·` `—` `!` `?`): mono, amber, never green and never grey-filled. */
1470
+ .abs { font-family: var(--mono); color: var(--absent-warn-fg); }
1471
+ /* GateChip — one violet; solid = expensive, outline = routine, dashed = unclassified. */
1472
+ .gate { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px; border-radius: 999px; font-family: var(--mono); font-size: var(--fs-caption); font-weight: 500; white-space: nowrap; }
1473
+ .gate.exp { background: var(--status-gate); color: var(--on-status); }
1474
+ .gate.rou { border: 1px solid var(--status-gate); color: var(--status-gate); }
1475
+ .gate.unc { border: 1px dashed var(--border-strong); color: var(--absent-warn-fg); }
1476
+ /* PostureStrip — one bed for "expensive", distinguished by words; routine is a hairline. */
1477
+ .pos { display: inline-flex; align-items: center; gap: 4px; height: 18px; padding: 0 7px; border-radius: 999px; font-family: var(--mono); font-size: var(--fs-eyebrow); white-space: nowrap; }
1478
+ .pos.exp { background: var(--posture-expensive-bg); color: var(--posture-expensive-fg); }
1479
+ .pos.rou { border: 1px solid var(--border); color: var(--text2); }
1480
+ .pos.unj { color: var(--absent-warn-fg); }
1481
+ .pos.warn { background: var(--absent-warn-bg); color: var(--absent-warn-fg); }
1482
+ /* A row-level band, not a chip: `Bash(node:*)` reads as a restriction and is a
1483
+ full shell — the subtlest finding agent-posture produces must not be scanned past. */
1484
+ .strip { display: flex; align-items: center; gap: 8px; padding: 4px 10px; background: var(--absent-warn-bg); color: var(--absent-warn-fg); font-size: var(--fs-caption); }
1485
+ /* Decisions row (BRD-R3): the two reviewer cells sit between the title and the
1486
+ actions; each glyph explains itself on hover. */
1487
+ .gate-review { display: inline-flex; gap: 14px; align-items: center; font-family: var(--mono); font-size: var(--fs-caption); white-space: nowrap; color: var(--text2); }
1488
+ .gate-review > span { display: inline-flex; gap: 6px; align-items: center; }
1489
+ /* Ledger head (great_cto-ki1x.7) */
1490
+ .ledger-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
1491
+ .ledger-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 12px; margin-top: 12px; background: var(--bg-card); border: 1px solid var(--border); font-family: var(--mono); font-size: var(--fs-caption); }
1492
+ .ledger-stage { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border: 1px solid var(--border); }
1493
+ .ledger-stuck { margin-top: 12px; background: var(--bg-card); border: 1px solid var(--border); padding: 10px 12px; }
1494
+ .ledger-stuck-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
1495
+ .ledger-stuck-row { display: grid; grid-template-columns: 150px 1fr 60px; gap: 12px; align-items: center; font-size: var(--fs-small); padding: 4px 0; border-top: 1px solid var(--border); }
1496
+ .eyebrow { font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); font-weight: 500; }
1497
+ .tab { font-variant-numeric: tabular-nums; text-align: right; }
1498
+ .cap { font-size: var(--fs-caption); }
1499
+ .ledger-head { display: flex; flex-direction: column; }
1500
+ .ledger-facts { display: flex; flex-wrap: wrap; gap: 8px 28px; margin-top: 10px; font-size: var(--fs-small); align-items: baseline; }
1501
+ .bar { height: 6px; background: var(--bg-strong); border-radius: 999px; overflow: hidden; }
1502
+ .bar > i { display: block; height: 100%; background: var(--accent); }
1503
+ /* Fleet (great_cto-ki1x.8): saved views in the sidebar, domain groups, AgentRow cells */
1504
+ .nav-view-item { cursor: pointer; display: flex; justify-content: space-between; gap: 8px; }
1505
+ .nav-view-item:hover { background: var(--bg-muted); color: var(--text); }
1506
+ .nav-view-item.active { background: var(--bg-strong); color: var(--text); font-weight: 500; }
1507
+ .nav-view-count { font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); font-variant-numeric: tabular-nums; }
1508
+ .fleet-group-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px 4px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); font-weight: 500; }
1509
+ .fleet-group-collapsed { display: flex; flex-wrap: wrap; gap: 20px; padding: 10px 12px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
1510
+ .ar-cells { display: grid; grid-template-columns: minmax(200px, 1.4fr) 130px 48px 56px 80px auto; gap: 12px; align-items: center; margin-top: 4px; }
1511
+ .ar-posture { display: flex; gap: 6px; flex-wrap: wrap; }
1512
+ .ar-last { color: var(--text2); }
1513
+ /* Harness (great_cto-ki1x.10) */
1514
+ .harness-hosts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
1515
+ .harness-hosts > div { display: flex; align-items: center; gap: 10px; min-width: 0; }
1516
+ .so-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
1517
+ .so-option { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; background: var(--bg-page); border: 1px solid var(--border); cursor: pointer; min-height: 44px; }
1518
+ .so-option.on { border-color: var(--accent); }
1519
+ .so-option input { position: absolute; opacity: 0; pointer-events: none; }
1520
+ .so-option:focus-within { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
1521
+ .so-label { font-size: var(--fs-small); }
1522
+ .so-why { font-size: var(--fs-caption); }
1523
+ .ev-table { margin-top: 8px; }
1524
+ .ev-head, .ev-row { display: grid; grid-template-columns: 110px 1fr 90px; gap: 12px; align-items: center; padding: 6px 0; border-top: 1px solid var(--border); font-size: var(--fs-small); }
1525
+ .ev-head { font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); border-top: none; }
1526
+ .ev-row.dim { color: var(--text3); }
1527
+ /* Settings (great_cto-ki1x.11) */
1528
+ .settings-rows { display: flex; flex-direction: column; gap: 16px; margin-bottom: 8px; }
1529
+ .settings-group { background: var(--bg-card); border: 1px solid var(--border); padding: 8px 16px 4px; }
1530
+ .settings-row { display: grid; grid-template-columns: 200px 1fr 140px; gap: 14px; align-items: center; padding: 9px 0; border-top: 1px solid var(--border); font-size: var(--fs-small); }
1531
+ .settings-group > .settings-row:first-of-type { border-top: none; }
1532
+ .sr-name { display: flex; flex-direction: column; min-width: 0; }
1533
+ .sr-value { min-width: 0; }
1534
+ .sr-action { display: flex; justify-content: flex-end; gap: 8px; }
1535
+ .pill.on { background: var(--text); color: var(--bg-page); }
1536
+ /* Two-ring focus (§7.1): the outer ring is the focus token, the inner ring is
1537
+ the page itself, so the ring reads on any bed — a card, a solid pill, a strip.
1538
+ Element selectors only; the per-component rules above keep their own offsets. */
1539
+ button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, [role="button"]:focus-visible, [tabindex]:focus-visible {
1540
+ outline: 2px solid var(--focus-ring); outline-offset: 2px; box-shadow: 0 0 0 2px var(--bg-page);
1541
+ }
1542
+ /* Theme toggle: two stroke icons, the theme picks one. No emoji — an emoji is
1543
+ a font the platform chooses, and it was rendering at three sizes on three OSes. */
1544
+ #board-theme-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
1545
+ #board-theme-btn .ico-sun { display: none; }
1546
+ [data-theme="light"] #board-theme-btn .ico-sun { display: inline; }
1547
+ [data-theme="light"] #board-theme-btn .ico-moon { display: none; }
1440
1548
  /* The board has two text inputs styled in place — `#agent-panel input` and
1441
1549
  `.fleet-search input[type="search"]` — and a third, the Sessions search box,
1442
1550
  that asked for the house look by name and got the user agent's. Same values
@@ -2223,10 +2331,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2223
2331
  letter-spacing: 0.1em; text-transform: uppercase;
2224
2332
  color: var(--text3); margin: 0 0 12px;
2225
2333
  }
2226
- .mp-hero {
2227
- display: grid; grid-template-columns: repeat(3, 1fr);
2228
- gap: 14px; margin-bottom: 24px;
2229
- }
2230
2334
  .window-fill {
2231
2335
  font-size: var(--fs-body); color: var(--text2); line-height: 1.5;
2232
2336
  padding: 8px 12px; margin-bottom: 12px; border-radius: 0;
@@ -2313,12 +2417,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2313
2417
  margin-top: 6px; font-size: var(--fs-small); color: var(--text2);
2314
2418
  }
2315
2419
 
2316
- .mp-secondary {
2317
- display: grid; grid-template-columns: repeat(4, 1fr);
2318
- gap: 12px; margin-bottom: 28px;
2319
- }
2320
- .mp-secondary .metric-card { padding: 18px; }
2321
- .mp-secondary .metric-num { font-size: var(--fs-num-m); }
2322
2420
 
2323
2421
  .mp-row {
2324
2422
  display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
@@ -2480,15 +2578,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2480
2578
  border-radius: 0; padding: 1px 5px; font-size: var(--fs-eyebrow);
2481
2579
  }
2482
2580
 
2483
- .fleet-facets { display: flex; flex-direction: column; gap: 6px; }
2484
- .fleet-facets fieldset {
2485
- border: none; padding: 0; margin: 0;
2486
- display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
2487
- }
2488
- .fleet-facets legend {
2489
- font-size: var(--fs-caption); color: var(--text3); text-transform: uppercase;
2490
- letter-spacing: 0.05em; margin-right: 6px; min-width: 60px;
2491
- }
2492
2581
  .facet-chip {
2493
2582
  background: var(--bg-muted); border: 1px solid var(--border);
2494
2583
  color: var(--text2); font-size: var(--fs-small); font-family: var(--mono);
@@ -2938,7 +3027,45 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2938
3027
  @media (max-width:480px) {
2939
3028
  .chip-filter-bar { flex-direction: column; align-items: stretch; }
2940
3029
  }
3030
+ /* ── Motion contract (DESIGN §10, MOTION_INTENSITY 2/10): motion for state the
3031
+ operator caused, none for state the machine caused. Hover and focus 90ms
3032
+ linear; the drawer 200ms out / 140ms in (exit faster than enter); SSE
3033
+ arrival 0ms — rows and figures swap, they do not animate in. ── */
3034
+ .nav-item, .nav-view-item, .agent-row, .inbox-row, .gate-btn, .btn-black, .btn-ghost-sm, .pill, .so-option { transition: background-color 90ms linear, border-color 90ms linear, outline-color 90ms linear; }
3035
+ @media (prefers-reduced-motion: reduce) {
3036
+ *, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }
3037
+ }
3038
+ /* ── 768–1199: the sidebar becomes a 56px icon rail (DESIGN §9). Labels leave
3039
+ the screen but not the element — every nav item carries its name as
3040
+ aria-label and title. The Views block and the switcher's text go with the
3041
+ labels; the destinations stay one tap away. ── */
3042
+ @media (min-width: 768px) and (max-width: 1199px) {
3043
+ .sidebar { width: 56px; }
3044
+ .nav-item { justify-content: center; padding: 0; gap: 0; }
3045
+ .nav-item > span { display: none; }
3046
+ .nav-item .count.abs { display: inline; position: absolute; top: 2px; right: 6px; }
3047
+ .nav-item { position: relative; }
3048
+ .nav-divider, .nav-views-label, .nav-views { display: none; }
3049
+ .proj-switch > :not(.proj-arch-icon):not(svg) { display: none; }
3050
+ .proj-switch { justify-content: center; padding: 14px 8px; }
3051
+ #live-label { display: none; }
3052
+ }
2941
3053
  @media (max-width: 768px) {
3054
+ .settings-row { grid-template-columns: 1fr; gap: 6px; }
3055
+ .sr-action { justify-content: flex-start; }
3056
+
3057
+ .harness-hosts, .so-options { grid-template-columns: 1fr; }
3058
+
3059
+ .ar-cells { grid-template-columns: 1fr auto; }
3060
+ .ar-posture { grid-column: 1 / -1; }
3061
+
3062
+ /* The one asymmetry DESIGN §3.2 draws: an expensive or unclassified gate's
3063
+ Reject/Approve stack full-width at phone width; a routine gate's may sit
3064
+ side by side. Cheap and expensive must not share a motor cost. */
3065
+ .inbox-row .actions.gate-actions-stack { flex-wrap: wrap; flex-direction: column; align-items: stretch; flex: 1 0 100%; }
3066
+ .inbox-row .actions.gate-actions-stack .gate-btn { width: 100%; min-height: 44px; }
3067
+ .gate-review { flex-basis: 100%; }
3068
+
2942
3069
  .sidebar {
2943
3070
  position: fixed; top: 0; left: 0; bottom: 0;
2944
3071
  z-index: 40;
@@ -2951,7 +3078,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2951
3078
  focus on the button and the menu unreachable by keyboard. On the way out
2952
3079
  it waits for the slide to finish (`0s linear 200ms`), so the panel does
2953
3080
  not vanish mid-animation. */
2954
- transition: transform 200ms ease-out, visibility 0s linear 200ms;
3081
+ transition: transform 140ms ease-in, visibility 0s linear 140ms;
2955
3082
  box-shadow: none;
2956
3083
  }
2957
3084
  .sidebar.open {
@@ -3012,8 +3139,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3012
3139
  /* Four columns of numbers do not fit in 375px; they were being clipped, which
3013
3140
  silently dropped the fourth stat rather than wrapping it. */
3014
3141
  .inbox-summary { grid-template-columns: repeat(2, 1fr); }
3015
- .mp-secondary { grid-template-columns: repeat(2, 1fr); }
3016
- .mp-hero { grid-template-columns: 1fr; }
3017
3142
  .mp-row { grid-template-columns: 1fr; }
3018
3143
  }
3019
3144
  @media (pointer: coarse) {
@@ -3043,7 +3168,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3043
3168
  So: every control gets the floor, and the ones with something to say about
3044
3169
  their own size say it after. A button added tomorrow is covered by default;
3045
3170
  one that must stay small has to opt out in writing. */
3046
- button, [role="button"], .nav-item, .toggle, summary { min-height: 44px; }
3171
+ button, [role="button"], .nav-item, .toggle, summary, label.radio, .copy, .so-option, .nav-view-item { min-height: 44px; }
3047
3172
  .gate-btn, .inbox-row .actions .gate-btn { height: 44px; font-size: var(--fs-body); }
3048
3173
  .menu-btn { width: 44px; height: 44px; }
3049
3174
  .btn-bell { min-width: 44px; justify-content: center; }
@@ -3092,47 +3217,64 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3092
3217
  <div id="proj-menu-list"></div>
3093
3218
  </div>
3094
3219
  </div>
3095
- <nav class="nav">
3096
- <div class="nav-item active" data-tab="inbox" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('inbox', this)">
3220
+ <!-- Nav shell cutover (great_cto-ki1x.5): eight tabs (+ a topbar share
3221
+ button and bell) become four destinations. Tasks/kanban, Metrics/
3222
+ dashboard, Docs, Activity/logs, Notifications and Share lose their
3223
+ entry point here — their panels and functions are untouched, so nothing
3224
+ that already linked to `#dashboard`/`switchTab('budgets', …)`/etc. goes
3225
+ dead; a bookmark just stops appearing in the menu. Settings — the
3226
+ future home for the Share/Notifications rows this detaches — is
3227
+ reachable from the topbar gear, not a fifth item here (kept under the
3228
+ ≤5 ceiling `ui-ux-pro-max` sets for the same nav to also serve mobile). -->
3229
+ <nav class="nav" id="nav-tablist" role="tablist" aria-orientation="vertical">
3230
+ <div class="nav-item active" data-tab="decisions" role="tab" tabindex="0" aria-label="Decisions" title="Decisions" onkeydown="navTabKeydown(event, this)" onclick="switchTab('decisions', this)">
3097
3231
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-6l-2 3h-4l-2-3H2 M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z"/></svg>
3098
- <span>Inbox</span>
3232
+ <span>Decisions</span>
3099
3233
  <span class="count" id="nav-inbox-count">0</span>
3100
3234
  </div>
3101
- <div class="nav-item" data-tab="kanban" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('kanban', this)">
3102
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
3103
- <span>Tasks</span>
3104
- <span class="count" id="nav-task-count">0</span>
3105
- </div>
3106
- <div class="nav-item" data-tab="dashboard" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('dashboard', this)">
3107
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18 M7 14l3-3 4 4 5-5"/></svg>
3108
- <span>Metrics</span>
3109
- </div>
3110
- <div class="nav-item" data-tab="docs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('docs', this)">
3111
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
3112
- <span>Docs</span>
3113
- <span class="count" id="nav-docs-count">0</span>
3114
- </div>
3115
- <div class="nav-item" data-tab="logs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('logs', this)">
3116
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
3117
- <span>Activity</span>
3118
- <span class="count" id="nav-logs-count">0</span>
3119
- </div>
3120
- <div class="nav-item" data-tab="budgets" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('budgets', this)">
3235
+ <div class="nav-item" data-tab="ledger" role="tab" tabindex="0" aria-label="Ledger" title="Ledger" onkeydown="navTabKeydown(event, this)" onclick="switchTab('ledger', this)">
3121
3236
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1v22 M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
3122
- <span>Budgets</span>
3237
+ <span>Ledger</span>
3238
+ <!-- Real spend total is Ledger's own content (.7); this keeps the count
3239
+ of agent budget caps the old Budgets tab already computed, rather
3240
+ than inventing a $ figure with nothing behind it yet. -->
3123
3241
  <span class="count" id="nav-budget-count"></span>
3124
3242
  </div>
3125
- <div class="nav-item" data-tab="notifications" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('notifications', this); loadNotifHistory()">
3126
- <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><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"/></svg>
3127
- <span>Notifications</span>
3128
- <span id="nav-notifications-badge" class="share-badge off" style="margin-left:auto;">
3129
- <span class="share-badge-dot"></span>off
3130
- </span>
3243
+ <div class="nav-item" data-tab="fleet" role="tab" tabindex="0" aria-label="Fleet" title="Fleet" onkeydown="navTabKeydown(event, this)" onclick="switchTab('fleet', this)">
3244
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="6" cy="6" r="2"/><circle cx="18" cy="6" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><path d="M8 6h8M6 8v8M18 8v8M8 18h8"/></svg>
3245
+ <span>Fleet</span>
3246
+ <!-- The absence glyph, not a count (DESIGN §5): something in Fleet is
3247
+ unclassified. Left hidden — the classification read lives in the
3248
+ Fleet screen body (.8/.9), out of this shell task's scope. -->
3249
+ <span class="count abs" id="nav-fleet-abs" hidden title="something in Fleet is unclassified — not a count">!</span>
3250
+ </div>
3251
+ <div class="nav-item" data-tab="harness" role="tab" tabindex="0" aria-label="Harness" title="Harness" onkeydown="navTabKeydown(event, this)" onclick="switchTab('harness', this)">
3252
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 12h4l2-5 3 10 2-5h7"/></svg>
3253
+ <span>Harness</span>
3254
+ <span class="count" id="nav-harness-count"></span>
3131
3255
  </div>
3132
3256
  </nav>
3257
+ <div class="nav-divider"></div>
3258
+ <div class="nav-views-label" id="nav-views-label">Views</div>
3259
+ <!-- Saved views over Fleet (DESIGN §5 point 2). Static for this shell —
3260
+ filtering the fleet list is the Fleet screen body's job (.8), not this
3261
+ task's; deliberately not `.nav-item`/`role="tab"`, since they neither
3262
+ switch a top-level destination nor do anything yet, and a control that
3263
+ looks live and isn't fails `ui-ux-pro-max` empty-nav-state. -->
3264
+ <div class="nav-views">
3265
+ <div class="nav-view-item" id="nav-view-needs" role="button" tabindex="0" onclick="setFleetView('needs')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('needs')}">Needs attention</div>
3266
+ <div class="nav-view-item" id="nav-view-expensive" role="button" tabindex="0" onclick="setFleetView('expensive')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('expensive')}">Expensive grants</div>
3267
+ <div class="nav-view-item" id="nav-view-never" role="button" tabindex="0" onclick="setFleetView('never')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('never')}">Never observed</div>
3268
+ <div class="nav-view-item" id="nav-view-all" role="button" tabindex="0" onclick="setFleetView('all')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('all')}">All agents</div>
3269
+ <div class="nav-view-item" id="nav-view-retired" role="button" tabindex="0" onclick="setFleetView('retired')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();setFleetView('retired')}">Retired</div>
3270
+ </div>
3133
3271
  <div class="sidebar-footer">
3134
- <span class="live-dot pulse" id="live-dot"></span>
3135
- <span id="live-label">live · synced just now</span>
3272
+ <!-- Ships in the CONNECTING state. This markup used to say "live · synced
3273
+ just now" before any connection existed, so from first paint until the
3274
+ SSE handshake resolved — or forever, if neither handler ever fired —
3275
+ the footer asserted a sync that had not happened. -->
3276
+ <span class="live-dot connecting" id="live-dot"></span>
3277
+ <span id="live-label">connecting…</span>
3136
3278
  </div>
3137
3279
  </aside>
3138
3280
 
@@ -3158,15 +3300,14 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3158
3300
  </div>
3159
3301
  <div class="topbar-actions">
3160
3302
  <span class="task-count" id="topbar-task-count">0 ISSUES</span>
3161
- <button class="btn-bell" data-tab="share" onclick="switchTab('share', this)" title="Share a public project report" aria-label="Share" style="gap:6px;padding:6px 10px;">
3162
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" y1="13.51" x2="15.42" y2="17.49"/><line x1="15.41" y1="6.51" x2="8.59" y2="10.49"/></svg>
3163
- <span class="btn-label" style="font-size:var(--fs-small);font-weight:500">Share</span>
3164
- <span id="share-nav-badge" class="share-badge off"><span class="share-badge-dot"></span>off</span>
3165
- </button>
3166
- <button class="btn-bell" id="board-theme-btn" onclick="toggleBoardTheme()" title="Toggle light / dark theme" aria-label="Toggle theme">🌙</button>
3167
- <button class="btn-bell" id="topbar-bell" onclick="openNotifDrawer()" title="Notifications">
3168
- <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
3169
- <span class="btn-bell-badge" id="topbar-bell-badge"></span>
3303
+ <!-- Share (topbar button, data-tab="share") and the notification bell
3304
+ (#topbar-bell, opened the drawer) are deleted as top-level surfaces
3305
+ here — great_cto-ki1x.5. Their panels/functions are untouched for
3306
+ .11 to re-wire as Settings rows; nothing here calls them anymore. -->
3307
+ <button class="btn-bell" id="board-theme-btn" onclick="toggleBoardTheme()" title="Toggle light / dark theme" aria-label="Toggle theme"><svg class="ico-moon" viewBox="0 0 16 16" aria-hidden="true"><path d="M13.5 9.5A6 6 0 0 1 6.5 2.5a6 6 0 1 0 7 7z"></path></svg><svg class="ico-sun" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="3"></circle><path d="M8 1.5v1.5M8 13v1.5M1.5 8H3M13 8h1.5M3.4 3.4l1 1M11.6 11.6l1 1M3.4 12.6l1-1M11.6 4.4l1-1"></path></svg></button>
3308
+ <button class="btn-bell" id="topbar-settings-btn" data-tab="settings" onclick="switchTab('settings', this)" title="Settings" aria-label="Settings" style="gap:6px;padding:6px 10px;">
3309
+ <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
3310
+ <span class="btn-label" style="font-size:var(--fs-small);font-weight:500">Settings</span>
3170
3311
  </button>
3171
3312
  <button class="btn-new" onclick="openNewIssueModal()" aria-label="New issue" title="New issue">
3172
3313
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14 M5 12h14"/></svg>
@@ -3241,14 +3382,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3241
3382
  absent Codex must not render as a switch in the off position. The
3242
3383
  evidence rows are the tail of .great_cto/cross-review.log: what the
3243
3384
  second opinion did, not what it is set to. -->
3244
- <div class="inbox-section" id="harnesses-section">
3245
- <div class="inbox-section-head">
3246
- <span class="dot dot-blue"></span>
3247
- <h3>Harnesses</h3>
3248
- <span class="inbox-count" id="harnesses-status">…</span>
3249
- </div>
3250
- <div id="harnesses-body" class="muted" style="font-size:13px;line-height:1.5">loading…</div>
3251
- </div>
3252
3385
  </div>
3253
3386
  </details>
3254
3387
 
@@ -3317,6 +3450,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3317
3450
  </div>
3318
3451
 
3319
3452
  <!-- Kanban -->
3453
+ <!-- Harness (great_cto-ki1x.5): the second-opinion card used to sit inside
3454
+ Inbox. It is a destination now; .10 draws the full screen around it. -->
3455
+ <div class="panel" id="panel-harness" role="tabpanel">
3456
+ <div class="metrics-page">
3457
+ <h1 class="page-title">Harness <span class="muted" style="font-weight:400;font-size:var(--fs-small)">who is host, who gives the second opinion, and what it actually did</span></h1>
3458
+ <!-- Harness (great_cto-ki1x.10). The one decision: is the second opinion
3459
+ actually on for this project. Codex is a status panel, not a switch —
3460
+ the board cannot install it. Four radios, not one toggle, because
3461
+ `undeclared` and `none` are different states. -->
3462
+ <div id="harnesses-body" class="muted" style="font-size:13px;line-height:1.5">loading…</div>
3463
+ <span id="harnesses-status" hidden></span>
3464
+ <!-- The judge (OpenRouter key) and its scores: is anything checking, and
3465
+ what did it find. Moved here from the caps page — a harness fact. -->
3466
+ <div id="judge-status" class="judge-status" hidden style="margin-top:12px"></div>
3467
+ <div id="judge-scores" class="jscores" hidden></div>
3468
+ </div>
3469
+ </div>
3470
+
3320
3471
  <div class="panel" id="panel-kanban">
3321
3472
  <h1 class="page-title">Tasks</h1>
3322
3473
  <div id="filter-bar" class="filter-bar"></div>
@@ -3325,38 +3476,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3325
3476
 
3326
3477
 
3327
3478
  <!-- Metrics -->
3328
- <div class="panel" id="panel-dashboard">
3329
- <div class="metrics-page">
3330
- <h1 class="page-title">Metrics</h1>
3331
- <div id="mp-window-fill" class="window-fill" hidden></div>
3332
- <div class="period-toolbar">
3333
- <span class="period-label">PERIOD</span>
3334
- <div class="period-chips" id="period-chips">
3335
- <button class="period-chip" data-period="1">1D</button>
3336
- <button class="period-chip" data-period="7">7D</button>
3337
- <button class="period-chip active" data-period="30">30D</button>
3338
- <button class="period-chip" data-period="90">90D</button>
3339
- <button class="period-chip" data-period="365">1Y</button>
3340
- </div>
3341
- </div>
3342
- <div class="mp-section-label" id="mp-sprint-label">Last 30 days</div>
3343
- <div class="mp-hero" id="mp-hero"></div>
3344
- <div class="mp-secondary" id="mp-secondary"></div>
3345
-
3346
- <!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
3347
- drill-in. A control the operator could not find is a control they do
3348
- not have — and it was invisible on top of that, hidden whenever a
3349
- project had declared no caps, which is exactly the project that
3350
- needs the button to declare one. -->
3351
- <div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
3352
- <div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:var(--fs-small);margin-bottom:4px;"></div>
3353
-
3354
- <div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
3355
- <div class="mp-card cost-panel" id="cost-panel">
3356
- <div class="cost-empty">Loading…</div>
3357
- </div>
3358
- </div>
3359
- </div>
3360
3479
 
3361
3480
  <!-- Agents -->
3362
3481
  <!-- Session Logs -->
@@ -3384,23 +3503,25 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3384
3503
  <div class="panel" id="panel-agents" role="tabpanel" aria-labelledby="tab-agents">
3385
3504
  <div class="metrics-page">
3386
3505
  <!-- §3.1 Fleet summary -->
3506
+ <!-- The summary tiles ("Installed agents 70", "Active last 30d") left with
3507
+ the redesign: a count of installed agents is not a decision (brief,
3508
+ "What must NOT be on the board"). The view title carries the one
3509
+ number that is — how many need attention, of how many. -->
3387
3510
  <section aria-label="Fleet summary">
3388
- <div class="mp-section-label">Fleet</div>
3389
- <div class="mp-row" id="fleet-summary">
3390
- <div class="cost-empty">Loading…</div>
3391
- </div>
3511
+ <h1 class="page-title" id="fleet-view-title">Fleet</h1>
3392
3512
  </section>
3393
3513
 
3394
3514
  <!-- §3.1 Fleet controls -->
3395
3515
  <section aria-label="Fleet controls" style="margin-top:20px">
3396
- <div class="mp-card fleet-controls">
3516
+ <div class="mp-card fleet-controls" style="display:flex;flex-wrap:wrap;align-items:center;gap:12px 20px;padding:8px 12px">
3397
3517
  <div class="fleet-search">
3398
3518
  <input type="search" id="fleet-search" placeholder="Filter agents (press / to focus)" aria-label="Filter agents by slug" />
3399
3519
  <span class="search-hint"><kbd>/</kbd> focus · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</span>
3400
3520
  </div>
3401
- <div class="fleet-facets" id="fleet-facets">
3402
- <!-- populated via renderFleetFacets() -->
3403
- </div>
3521
+ <!-- The Domain / Activity / Health facet chips left with the redesign:
3522
+ domain is the grouping, activity and health are the saved views in
3523
+ the sidebar (DESIGN §5 — five views, not user-creatable). The
3524
+ functions stay for the drawer's deep links. -->
3404
3525
  <div class="fleet-sort">
3405
3526
  <label for="fleet-sort-by">Sort by</label>
3406
3527
  <select id="fleet-sort-by">
@@ -3474,8 +3595,37 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3474
3595
 
3475
3596
  <div class="panel" id="panel-budgets" role="tabpanel">
3476
3597
  <div class="metrics-page">
3477
- <h1 class="page-title">Agent budgets</h1>
3478
- <p class="muted" style="max-width:70ch;margin:6px 0 20px">
3598
+ <h1 class="page-title">Ledger</h1>
3599
+ <!-- Ledger (great_cto-ki1x.7): the one decision this screen supports is
3600
+ "is something running that I should stop". Two figures, the stage
3601
+ strip and the stuck list answer it; the caps table below is where
3602
+ the money is stopped. -->
3603
+ <div id="ledger-head" class="ledger-head"><div class="muted">loading…</div></div>
3604
+ <!-- From the old Metrics tab (great_cto-ki1x.16): the period chips, the caps
3605
+ card and the cost panel keep their ids — the code that fills them did
3606
+ not move, only the screen that shows them. -->
3607
+ <div class="period-toolbar">
3608
+ <span class="period-label">PERIOD</span>
3609
+ <div class="period-chips" id="period-chips">
3610
+ <button class="period-chip" data-period="1">1D</button>
3611
+ <button class="period-chip" data-period="7">7D</button>
3612
+ <button class="period-chip active" data-period="30">30D</button>
3613
+ <button class="period-chip" data-period="90">90D</button>
3614
+ <button class="period-chip" data-period="365">1Y</button>
3615
+ </div>
3616
+ </div>
3617
+ <!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
3618
+ drill-in. A control the operator could not find is a control they do
3619
+ not have — and it was invisible on top of that, hidden whenever a
3620
+ project had declared no caps, which is exactly the project that
3621
+ needs the button to declare one. -->
3622
+ <div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
3623
+ <div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:var(--fs-small);margin-bottom:4px;"></div>
3624
+ <div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
3625
+ <div class="mp-card cost-panel" id="cost-panel">
3626
+ <div class="cost-empty">Loading…</div>
3627
+ </div>
3628
+ <p class="muted" style="max-width:70ch;margin:14px 0 12px">
3479
3629
  A cap on what one agent may spend. When measured spend passes it, the pipeline
3480
3630
  refuses to dispatch that agent and says so — it does not fail quietly.
3481
3631
  Spend is measured from verdicts that carry a cost; while none does, the cap
@@ -3486,11 +3636,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3486
3636
  connected" and "nothing to check" read the same from here, and the
3487
3637
  key is not discoverable: the README names OpenRouter once without
3488
3638
  saying where it goes. -->
3489
- <div id="judge-status" class="judge-status" hidden></div>
3490
- <!-- The verdicts of the verifier itself. Kept beside the connection
3491
- status because the two answer consecutive questions: is anything
3492
- checking, and what did it find. -->
3493
- <div id="judge-scores" class="jscores" hidden></div>
3494
3639
  <div id="budgets-page-body">loading…</div>
3495
3640
  </div>
3496
3641
  </div>
@@ -3523,7 +3668,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3523
3668
  </div>
3524
3669
 
3525
3670
  <!-- Share -->
3526
- <div class="panel" id="panel-share">
3671
+ <!-- Settings (great_cto-ki1x.5): everything that writes to PROJECT.md or
3672
+ ~/.great_cto. The old Share and Notifications tabs are gone as destinations;
3673
+ their controls live here as rows until .11 redraws them. Their ids are kept
3674
+ (not their .panel class) so every function that renders into them still
3675
+ finds its element. -->
3676
+ <div class="panel" id="panel-settings" role="tabpanel">
3677
+ <div class="panel-head"><h2>Settings <span class="muted" style="font-weight:400;font-size:var(--fs-small)">everything that writes to <code>PROJECT.md</code> or <code>~/.great_cto</code> — each row names its file</span></h2></div>
3678
+ <div id="settings-rows" class="settings-rows"><div class="muted">loading…</div></div>
3679
+ <div id="panel-share">
3527
3680
  <div class="share-page">
3528
3681
  <div class="share-card">
3529
3682
  <h2>Share this project</h2>
@@ -3560,10 +3713,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3560
3713
  </div>
3561
3714
  </div>
3562
3715
  </div>
3563
- </div>
3564
-
3565
- <!-- Notifications -->
3566
- <div class="panel" id="panel-notifications">
3716
+ </div>
3717
+ <div id="panel-notifications">
3567
3718
  <div class="share-page">
3568
3719
  <h1 class="page-title">Notifications</h1>
3569
3720
  <div class="share-card">
@@ -3660,6 +3811,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
3660
3811
  </div>
3661
3812
  </div>
3662
3813
  </div>
3814
+ </div>
3663
3815
  </div>
3664
3816
 
3665
3817
  <!-- Task side panel — slide-over detail view, opened by openSide() -->
@@ -3761,7 +3913,52 @@ function elsewhereClause(e) {
3761
3913
  return { known: true, text: `${parts.join(' and ')} in ${m} other project${m > 1 ? 's' : ''} ${verb} waiting on you.` };
3762
3914
  }
3763
3915
 
3916
+ // gate id → reversibilityOf() as served on the inbox row; read by gateAction and
3917
+ // approveConsequence so a gate clicked from anywhere (row, palette) gets the
3918
+ // ritual its cost of undo demands.
3919
+ const GATE_REVERSIBILITY = {};
3920
+ // BRD-R3 ordering: expensive first, then unclassified — sorted WITH the expensive
3921
+ // group, never with routine, because "nobody judged this" is not evidence of
3922
+ // cheapness — then routine; inside a group, longest-waiting first.
3923
+ const REV_RANK = { expensive: 0, unclassified: 1, routine: 2 };
3924
+ function sortGatesByCostOfUndo(gates) {
3925
+ return (gates || []).slice().sort((a, b) => {
3926
+ const ra = REV_RANK[a?.reversibility?.state] ?? 1, rb = REV_RANK[b?.reversibility?.state] ?? 1;
3927
+ if (ra !== rb) return ra - rb;
3928
+ return (ageHours(b.updated_at) ?? -1) - (ageHours(a.updated_at) ?? -1);
3929
+ });
3930
+ }
3931
+ // The second opinion for the tree at HEAD, as /api/inbox resolved it — one
3932
+ // value for every pending gate, four states, none a verdict unless `ok`.
3933
+ let inboxSecondOpinion = null;
3934
+ function reviewerCells(t) {
3935
+ // Claude: the pipeline's own reviewer raised this gate — the gate exists
3936
+ // because a stage finished and asked for a human. `agent` names the stage
3937
+ // when the task carries one; the cell stays hollow (in flight) until the
3938
+ // operator decides, because the decision IS the settled state.
3939
+ const raised = t.agent && t.agent !== 'gate' ? esc(t.agent) : 'pipeline';
3940
+ const claude = `<span title="raised by ${raised} — waiting on you"><span class="dot run"></span> Claude</span>`;
3941
+ const so = inboxSecondOpinion;
3942
+ let codex;
3943
+ if (!so || so.state === 'not-run') codex = `<span class="abs" title="${esc(so?.why || 'second opinion not run')}">— Codex</span>`;
3944
+ else if (so.state === 'unmeasured') codex = `<span class="abs" title="${esc(so.why)}">· Codex</span>`;
3945
+ else if (so.state === 'unreadable') codex = `<span class="abs" title="${esc(so.why)}">! Codex</span>`;
3946
+ else if (so.verdict === 'BLOCK') codex = `<span title="Codex BLOCK on ${esc((so.sha || '').slice(0, 8))} — ${esc(String(so.p0 ?? '?'))}×P0"><span class="dot bad"></span> Codex ✗</span>`;
3947
+ else codex = `<span title="Codex ${esc(so.verdict || 'PASS')} on ${esc((so.sha || '').slice(0, 8))}"><span class="dot ok"></span> Codex ✓</span>`;
3948
+ return `<span class="gate-review">${claude}${codex}</span>`;
3949
+ }
3950
+ function gateChip(t) {
3951
+ const r = t.reversibility || { state: 'unclassified', gate: '', why: 'no reversibility on this row' };
3952
+ const name = r.gate ? `gate:${r.gate}` : (String(t.title || '').match(/gate:[a-z-]+/) || ['gate'])[0];
3953
+ const cls = r.state === 'expensive' ? 'exp' : r.state === 'routine' ? 'rou' : 'unc';
3954
+ const glyph = r.state === 'expensive' ? '▲' : r.state === 'routine' ? '▢' : '?';
3955
+ const why = r.state === 'expensive' ? (r.categories || []).join(' · ') : r.state === 'routine' ? `routine — ${r.why || 'cheap to undo'}` : `unclassified — ${r.why || 'not in gate-reversibility.mjs'}. Unjudged, not harmless.`;
3956
+ return `<span class="gate ${cls}" title="${esc(why)}">${glyph} ${esc(name)}</span>`;
3957
+ }
3764
3958
  function renderInbox(d) {
3959
+ inboxSecondOpinion = d?.second_opinion || null;
3960
+ for (const g of (d?.pending_gates || [])) if (g?.id && g.reversibility) GATE_REVERSIBILITY[g.id] = g.reversibility;
3961
+ if (d && Array.isArray(d.pending_gates)) d = { ...d, pending_gates: sortGatesByCostOfUndo(d.pending_gates) };
3765
3962
  // Kept so renderResume can ask what this section is already showing, rather
3766
3963
  // than the two of them rendering the same task independently.
3767
3964
  window.__inboxData = d;
@@ -4331,10 +4528,11 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4331
4528
  + `<button type="button" class="gate-btn" title="Copy the line that re-runs this agent"`
4332
4529
  + ` onclick="event.stopPropagation(); copyRerun('${esc(t.agent)}')">Copy re-run</button></div>`
4333
4530
  : '';
4531
+ const guardedGate = canDecide && t.reversibility && t.reversibility.state !== 'routine';
4334
4532
  const actions = canDecide
4335
- ? `<div class="actions" onclick="event.stopPropagation()">
4336
- <button class="gate-btn gate-approve" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'approve')">Approve</button>
4337
- <button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject</button>
4533
+ ? `<div class="actions${guardedGate ? ' gate-actions-stack' : ''}" onclick="event.stopPropagation()">
4534
+ <button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject…</button>
4535
+ <button class="gate-btn gate-approve" title="${guardedGate ? 'expensive to undo — you will be asked to type the gate name' : 'one confirm names the consequence'}" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'approve')">${guardedGate ? 'Approve…' : 'Approve'}</button>
4338
4536
  </div>`
4339
4537
  // Age is promoted out of the metadata run into its own column, because it
4340
4538
  // is the only field that differs between these rows. `status` stays,
@@ -4376,8 +4574,9 @@ function renderInboxList(rootId, items, countId, opts = {}) {
4376
4574
  : '';
4377
4575
  return `
4378
4576
  <div class="inbox-row${aged ? ' row-aged' : ''}" tabindex="0" role="button" aria-label="${esc(t.title || t.id)}" onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();this.click();}' onclick='openSide(${JSON.stringify(t).replace(/'/g, "&apos;")})'>
4379
- <span class="id">${esc(t.id || '')}</span>
4380
- <span class="ttl">${reasonChip}${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
4577
+ ${canDecide ? gateChip(t) : `<span class="id">${esc(t.id || '')}</span>`}
4578
+ <span class="ttl">${canDecide ? '' : reasonChip}${esc(canDecide ? String(t.title || '').replace(/^gate:[a-z-]+\s*[—–-]?\s*/, '') : t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
4579
+ ${canDecide ? reviewerCells(t) : ''}
4381
4580
  ${actions}${rerun}${diffBtn}
4382
4581
  </div>`;
4383
4582
  }).join('');
@@ -4593,7 +4792,7 @@ async function loadJudgeStatus() {
4593
4792
  el.hidden = false;
4594
4793
  el.innerHTML = connected
4595
4794
  ? `<span class="js-dot"></span>
4596
- <span><strong>Judge connected</strong> — ${esc(s.model || 'default model')}</span>
4795
+ <span><strong>Judge key stored</strong> <span class="abs" title="a stored key is not a working key — no probe has verified it from here">· not verified</span> — ${esc(s.model || 'default model')}</span>
4597
4796
  <span class="js-where">${esc(s.fingerprint || '')} · from ${esc(String(s.from || '').replace(/^.*\/(?=\.great_cto)/, '~/'))}</span>
4598
4797
  <span class="js-form">
4599
4798
  <input id="judge-key" type="password" autocomplete="off" spellcheck="false" placeholder="replace key (sk-or-…)">
@@ -4699,6 +4898,171 @@ async function saveJudgeKey() {
4699
4898
  loadJudgeStatus();
4700
4899
  }
4701
4900
 
4901
+ // Ledger head (great_cto-ki1x.7). Every figure names its state: spend is
4902
+ // `measured` only when a verdict carried a cost, otherwise `estimated` and
4903
+ // labelled so; a cap that is not set says "no cap", never $0; a stage is a
4904
+ // filled dot when done, hollow when active, red when failed, bare when idle;
4905
+ // a stuck task with no readable age is counted and shown as `?`, never dropped.
4906
+ function renderLedgerHead({ heartbeat, pipeline, cost, metrics }) {
4907
+ const el = document.getElementById('ledger-head');
4908
+ if (!el) return;
4909
+ // Spend, three states, and $0.00 is only one of them (carried over from the
4910
+ // Metrics tab this replaced): a measured figure; a time-based ESTIMATE that
4911
+ // says so; and a zero that is the estimate with nothing to estimate from,
4912
+ // which is not a figure at all.
4913
+ const costKnown = !!(metrics && metrics.cost);
4914
+ const realLlmUsd = metrics?.cost?.real_llm_usd ?? null;
4915
+ const llmUsd = metrics?.cost?.llm_usd ?? null;
4916
+ const spent = realLlmUsd != null ? realLlmUsd : llmUsd;
4917
+ const spentState = realLlmUsd != null ? 'measured' : (llmUsd != null ? 'estimated' : 'unmeasured');
4918
+ const cap = cost?.monthly_budget;
4919
+ const pct = (spent != null && cap) ? Math.min(100, Math.round(spent / cap * 100)) : null;
4920
+ const spentCell = !costKnown
4921
+ ? absent('unloaded', 'the metrics payload carried no cost section')
4922
+ : (realLlmUsd === 0 && llmUsd === 0) || spent == null
4923
+ ? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
4924
+ : `$${spent < 10 ? spent.toFixed(2) : fmtMoney(spent)}`;
4925
+ // Tasks done: a zero is a count; only a payload with no tasks section is an absence.
4926
+ const doneKnown = !!(metrics && metrics.tasks);
4927
+ const done = (metrics?.tasks?.done_in_window || metrics?.tasks?.done) ?? 0;
4928
+ const acc = metrics?.acceptance || {};
4929
+ const avgMin = metrics?.avg_completion_min;
4930
+ // The same tiles the Metrics tab drew, as facts on one line. Each keeps its
4931
+ // three-way distinction: a zero is a count, an absence names its kind.
4932
+ const facts = [
4933
+ { label: 'Done', v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'), sub: 'in window' },
4934
+ // A dash here means "could not be computed", not "took no time".
4935
+ { label: 'Cycle', v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'min median' : '' },
4936
+ { label: 'Cost', v: acc.cost_per_accepted != null ? `$${Number(acc.cost_per_accepted).toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: acc.cost_per_accepted != null ? '/ accepted' : '' },
4937
+ ];
4938
+ // Fleet 30d spend from verdicts — measured for some agents, or not at all.
4939
+ const s = fleetState?.summary || null;
4940
+ const fleetSpend = s == null ? absent('unloaded', 'the fleet summary has not been read')
4941
+ : s.llm_usd_30d != null ? `$${s.llm_usd_30d.toFixed(2)} <span class="muted">measured for ${s.llm_usd_30d_measured_for} agent(s)</span>`
4942
+ : absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`);
4943
+ facts.push({ label: 'Fleet 30d', v: fleetSpend, sub: '' });
4944
+ const capCell = cap ? `of $${fmtMoney(cap)}${pct != null ? ` · ${pct}%` : ''}` : `<span class="abs" title="no monthly cap in PROJECT.md — spend is not being stopped">no cap</span>`;
4945
+ const stages = Array.isArray(pipeline) ? pipeline : [];
4946
+ const running = stages.filter((st) => st.status === 'active');
4947
+ const dotFor = (st) => st.status === 'done' ? 'dot ok' : st.status === 'active' ? 'dot run' : st.status === 'failed' ? 'dot bad' : 'dot';
4948
+ const strip = stages.length
4949
+ ? stages.map((st, i) => `${i ? '<span class="muted">→</span>' : ''}<span class="ledger-stage${st.status === 'idle' ? ' muted' : ''}" title="${esc(st.stage)} · ${esc(st.status)}${st.verdict ? ' · ' + esc(st.verdict) : ''}${st.ts ? ' · ' + esc(fmtDT(st.ts)) : ''}"><span class="${dotFor(st)}"${st.status === 'idle' ? ' style="background:var(--bg-strong)"' : ''}></span>${esc(st.stage)}</span>`).join('')
4950
+ : `<span class="abs" title="/api/pipeline returned nothing — stage state unknown, not idle">· pipeline state unloaded</span>`;
4951
+ const stuck = Array.isArray(heartbeat?.stuck) ? heartbeat.stuck : [];
4952
+ const unmeasurable = Number(heartbeat?.stuck_unmeasurable) || 0;
4953
+ const stuckRows = stuck.slice(0, 5).map((t) => `
4954
+ <div class="ledger-stuck-row">
4955
+ <span class="mono">${esc(t.id || '')}</span>
4956
+ <span class="ttl-desc">${esc(t.title || '')}</span>
4957
+ <span class="mono tab" style="color:var(--status-progress)" title="${t.since ? esc(fmtDate(t.since)) : 'no timestamp'}">${t.age_h != null ? Math.round(t.age_h) + 'h' : '<span class="abs" title="updated_at unreadable — counted, age unknown">?</span>'}</span>
4958
+ </div>`).join('');
4959
+ el.innerHTML = `
4960
+ <div class="ledger-kpis">
4961
+ <div class="metric-card">
4962
+ <div class="metric-num">${spentCell}</div>
4963
+ <div class="metric-label">Spent · ${esc(spentState)}</div>
4964
+ <div class="metric-trend">${capCell}</div>
4965
+ ${pct != null ? `<div class="bar" style="margin-top:8px"><i style="width:${pct}%${pct >= 100 ? ';background:var(--status-blocked)' : ''}"></i></div>` : ''}
4966
+ </div>
4967
+ <div class="metric-card">
4968
+ <div class="metric-num"><span class="dot run" style="width:12px;height:12px;vertical-align:middle;margin-right:8px"></span>${Array.isArray(pipeline) ? running.length : absent('unloaded', 'pipeline state not read')}</div>
4969
+ <div class="metric-label">Running now</div>
4970
+ <div class="metric-trend">${running.length ? esc(running.map((st) => st.stage).join(' · ')) : (Array.isArray(pipeline) ? 'nothing active' : '')}</div>
4971
+ </div>
4972
+ </div>
4973
+ <div class="ledger-facts">
4974
+ ${facts.map((f) => `<span><span class="eyebrow">${esc(f.label)}</span> <span class="mono">${f.v}</span>${f.sub ? ` <span class="muted cap">${esc(f.sub)}</span>` : ''}</span>`).join('')}
4975
+ </div>
4976
+ <div class="ledger-strip">${strip}</div>
4977
+ <div class="ledger-stuck">
4978
+ <div class="ledger-stuck-head">
4979
+ <span class="eyebrow">Stuck · in progress over the line</span>
4980
+ <span class="mono cap" style="color:var(--status-progress)">${stuck.length}${unmeasurable ? ` <span class="abs" title="${unmeasurable} in-progress task(s) with no readable age — counted, not dropped">+${unmeasurable}?</span>` : ''}</span>
4981
+ </div>
4982
+ ${stuckRows || `<div class="muted cap">${Array.isArray(heartbeat?.stuck) ? 'nothing over the line' : '<span class="abs" title="heartbeat not read">· stuck state unloaded</span>'}</div>`}
4983
+ </div>`;
4984
+ }
4985
+
4986
+ // Settings (great_cto-ki1x.11). A row is a fact and the file that holds it;
4987
+ // what writes elsewhere links to the screen that owns the write (caps → Ledger,
4988
+ // second opinion and judge → Harness). Nothing here is a toggle for a fact the
4989
+ // board cannot change: the Codex binary and telemetry are read-only rows.
4990
+ async function loadSettingsPage() {
4991
+ const el = document.getElementById('settings-rows');
4992
+ if (!el) return;
4993
+ const get = (u) => api(u).catch(() => null);
4994
+ const [tiers, share, harn, projects, cost] = await Promise.all([
4995
+ get(`/api/gate-tiers${pqs()}`), get(`/api/share${pqs()}`), get(`/api/harnesses${pqs()}`), get('/api/projects'), get(`/api/cost${pqsd()}`),
4996
+ ]);
4997
+ const row = (name, file, value, action) => `
4998
+ <div class="settings-row">
4999
+ <div class="sr-name"><span>${name}</span><span class="cap muted mono">${esc(file)}</span></div>
5000
+ <div class="sr-value">${value}</div>
5001
+ <div class="sr-action">${action || ''}</div>
5002
+ </div>`;
5003
+ const link = (label, fn) => `<button type="button" class="btn-ghost-sm" onclick="${fn}">${label}</button>`;
5004
+ const so = harn?.second_opinion || null;
5005
+ const cx = harn?.codex || null;
5006
+ const cap = cost ? (cost.monthly_budget ? `<span class="mono">$${fmtMoney(cost.monthly_budget)}</span> <span class="muted">monthly</span>` : '<span class="abs" title="no cap set — spend is not being stopped">no cap</span>') : '<span class="abs" title="cost not read">· unloaded</span>';
5007
+ const tiering = tiers == null ? '<span class="abs" title="gate-tiers not read">· unloaded</span>'
5008
+ : tiers.enabled === false ? '<span class="mono">off</span> <span class="muted">— every gate waits for a human</span>'
5009
+ : `<span class="mono">on</span> <span class="muted">— ${tiers.measured ? 'evidence-tiered: an agent with a passing eval may stand a gate down to notify-only' : 'declared, nothing measured yet'}</span>`;
5010
+ const shareV = share == null ? '<span class="abs" title="share state not read">· unloaded</span>'
5011
+ : share.enabled ? `<span class="mono">on</span> <span class="muted">— <a href="${esc(share.url || '#')}" target="_blank" rel="noopener">public report</a>, gate confirms name it</span>`
5012
+ : '<span class="mono">off</span> <span class="muted">— gate confirms never claim a public report</span>';
5013
+ const soV = so == null ? '<span class="abs" title="harness state not read">· unloaded</span>'
5014
+ : `<span class="mono">${esc(so.declared?.tool || so.declared?.state || 'undeclared')}</span> <span class="muted">→ resolves</span> <span class="mono" style="color:${so.state === 'declared' ? 'var(--status-done)' : so.state === 'unavailable' ? 'var(--status-blocked)' : 'var(--absent-warn-fg)'}">${esc(so.state || '?')}</span>`;
5015
+ const codexV = cx == null ? '<span class="abs" title="harness state not read">· unloaded</span>'
5016
+ : cx.state === 'available' ? `<span class="mono">codex</span> <span class="muted">· on PATH · ${esc(cx.auth || 'authenticated')} · ${esc(cx.version || '')}</span>`
5017
+ : cx.state === 'no-auth' ? '<span class="mono">codex</span> <span style="color:var(--status-blocked)">· not logged in</span>'
5018
+ : `<span class="abs" title="${esc(cx.why || '')}">— absent</span>`;
5019
+ const plist = Array.isArray(projects) ? projects : (projects?.projects || null);
5020
+ const projRows = plist == null ? '<div class="abs" title="the project registry could not be read">! registry unreadable</div>'
5021
+ : plist.slice(0, 40).map((pr) => `
5022
+ <div class="settings-row">
5023
+ <div class="sr-name"><span>${esc(pr.slug)}${pr.slug === currentProject ? ' <span class="cap muted">current</span>' : ''}</span><span class="cap muted mono">${esc(String(pr.path || '').replace(/^\/Users\/[^/]+/, '~'))}</span></div>
5024
+ <div class="sr-value">${pr.archetype ? `<span class="mono cap">${esc(pr.archetype)}</span>` : '<span class="abs" title="no PROJECT.md read for this project">· no archetype</span>'}${pr.last_activity ? ` <span class="muted cap">· ${esc(agoStr(pr.last_activity))}</span>` : ''}</div>
5025
+ <div class="sr-action">${pr.slug === currentProject ? '' : link('switch', `switchProject('${esc(pr.slug)}')`)}</div>
5026
+ </div>`).join('');
5027
+ const theme = document.documentElement.dataset.theme || 'dark';
5028
+ el.innerHTML = `
5029
+ <div class="settings-group">
5030
+ <div class="eyebrow">Project · .great_cto/PROJECT.md</div>
5031
+ ${row('Budget cap', 'agent-budgets · monthly, USD', cap, link('Ledger', "switchTab('ledger')"))}
5032
+ ${row('Second opinion', 'capabilities.second_opinion', soV, link('Harness', "switchTab('harness')"))}
5033
+ ${row('Gate tiering', 'gate-tiering', tiering, '')}
5034
+ ${row('Share', 'promote / public report', shareV, link('below', "document.getElementById('panel-share').scrollIntoView({behavior:'smooth'})"))}
5035
+ </div>
5036
+ <div class="settings-group">
5037
+ <div class="eyebrow">Machine · ~/.great_cto</div>
5038
+ ${row('Judge key', 'secrets.env · OPENROUTER_API_KEY', '<span class="muted">stored and replaced on the Harness screen</span>', link('Harness', "switchTab('harness')"))}
5039
+ ${row('Codex binary', 'GREAT_CTO_CODEX_BIN', codexV, '')}
5040
+ ${row('Telemetry', 'opt-in, off by default', '<span class="mono">off</span>', '<a class="cap" href="https://github.com/avelikiy/great_cto/blob/main/docs/PRIVACY.md" target="_blank" rel="noopener">PRIVACY.md</a>')}
5041
+ ${row('Theme', 'localStorage · this browser', `<span class="pill${theme !== 'light' ? ' on' : ''}" role="button" tabindex="0" onclick="if(document.documentElement.dataset.theme==='light')toggleBoardTheme();loadSettingsPage()">Dark</span> <span class="pill${theme === 'light' ? ' on' : ''}" role="button" tabindex="0" onclick="if(document.documentElement.dataset.theme!=='light')toggleBoardTheme();loadSettingsPage()">Light</span>`, '')}
5042
+ </div>
5043
+ <div class="settings-group">
5044
+ <div class="eyebrow">Projects · ~/.great_cto/projects.json · ${plist ? plist.length + ' registered' : ''}</div>
5045
+ ${projRows}
5046
+ <div class="settings-row" style="border-top:none">
5047
+ <div class="sr-name"><span class="muted cap">Paths must live inside HOME — /tmp and /etc are refused.</span></div>
5048
+ <div class="sr-value"><input id="settings-register-path" class="input" placeholder="~/path/to/project" style="width:100%"></div>
5049
+ <div class="sr-action">${link('Register', 'registerProjectFromSettings()')}</div>
5050
+ </div>
5051
+ </div>
5052
+ <div class="eyebrow" style="margin:18px 0 6px">Alerts · the channel delivers, the board does not</div>`;
5053
+ }
5054
+ async function registerProjectFromSettings() {
5055
+ const inp = document.getElementById('settings-register-path');
5056
+ const v = (inp?.value || '').trim();
5057
+ if (!v) return;
5058
+ try {
5059
+ const r = await api('/api/projects/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: v }) });
5060
+ if (r && r.error) { showToast(`Not registered: ${r.error}`, 'error', 3000); return; }
5061
+ showToast('Registered', 'info', 1500);
5062
+ loadSettingsPage();
5063
+ } catch (e) { showToast(`Not registered: ${String(e.message || e)}`, 'error', 3000); }
5064
+ }
5065
+
4702
5066
  async function loadBudgetsPage() {
4703
5067
  loadJudgeStatus();
4704
5068
  loadJudgeScores();
@@ -4714,13 +5078,17 @@ async function loadBudgetsPage() {
4714
5078
  // promise rejected, and the page sat there looking busy. A page that failed
4715
5079
  // and a page still working are the same picture, and the one you are looking
4716
5080
  // at is the one where nothing more is coming.
4717
- let h;
5081
+ let h, pipe = null, cost = null, m = null;
4718
5082
  try {
4719
- [h] = await Promise.all([
5083
+ [h, pipe, cost, m] = await Promise.all([
4720
5084
  api(`/api/heartbeat${pqs()}`),
5085
+ api(`/api/pipeline${pqs()}`).catch(() => null),
5086
+ api(`/api/cost${pqsd()}`).catch(() => null),
5087
+ api(`/api/metrics${pqsd()}`).catch(() => null),
4721
5088
  (typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
4722
5089
  ? refreshAgentsInstalled() : Promise.resolve(),
4723
5090
  ]);
5091
+ renderLedgerHead({ heartbeat: h, pipeline: pipe, cost, metrics: m });
4724
5092
  } catch (err) {
4725
5093
  body.innerHTML = `
4726
5094
  <div class="budgets-failed">
@@ -5241,20 +5609,80 @@ async function renderHarnesses() {
5241
5609
  hb.style.color = so.state === 'unavailable' ? 'var(--status-blocked)' : '';
5242
5610
  hb.style.display = hb.textContent ? '' : 'none';
5243
5611
  }
5612
+ // Pre-click consequence per option (DESIGN §3.7 point 3): what choosing it
5613
+ // would resolve to HERE, before Save.
5614
+ let rk = null;
5615
+ try { rk = await api(`/api/router-key${pqs()}`); } catch { rk = null; }
5616
+ const routerKeyPresent = rk && rk.state === 'present';
5617
+ const codexDot = cx.state === 'available' ? 'dot ok' : cx.state === 'no-auth' ? 'dot bad' : 'dot';
5618
+ const codexState = cx.state === 'available' ? `<span style="color:var(--status-done)">detected · ${esc(cx.auth === 'chatgpt' ? 'authenticated' : cx.auth || 'authenticated')}</span>`
5619
+ : cx.state === 'no-auth' ? `<span style="color:var(--status-blocked)">detected · not logged in</span>`
5620
+ : `<span class="abs" title="${esc(cx.why || 'codex is not on PATH')}">— absent</span>`;
5621
+ const consequence = {
5622
+ '': 'PROJECT.md says nothing. A future harness may be picked up.',
5623
+ none: 'deliberately off — recorded as a decision.',
5624
+ codex: cx.state === 'available' ? 'resolves to declared here' : `⚠ would resolve to unavailable here — ${cx.why || 'codex is not on PATH'}`,
5625
+ openrouter: routerKeyPresent ? 'resolves to declared here' : '⚠ would resolve to unavailable here — no key configured',
5626
+ };
5627
+ const current = so.declared && so.declared.state !== 'undeclared' ? (so.declared.tool || (so.declared.state === 'none' ? 'none' : '')) : '';
5628
+ const radio = (val, label) => `
5629
+ <label class="so-option${current === val ? ' on' : ''}" title="${esc(consequence[val] || '')}">
5630
+ <input type="radio" name="so" value="${val}" ${current === val ? 'checked' : ''}>
5631
+ <span class="so-label">${esc(label)}</span>
5632
+ <span class="so-why ${/^⚠/.test(consequence[val] || '') ? 'abs' : 'muted'}">${esc(consequence[val] || '')}</span>
5633
+ </label>`;
5634
+ const paired = sum.reviewed ?? 0;
5635
+ const pairedCell = ev.state === 'absent' ? '<span class="abs" title="no cross-review has run in this project">— none yet</span>'
5636
+ : paired > 0 ? `<span class="mono">${paired}</span> <span class="muted">paired review(s)</span>`
5637
+ : `<span class="abs" title="join key absent on ${sum.unreadable ?? 0} of ${sum.runs ?? 0} log line(s) — nothing can be paired with a tree">unmeasured — 0 paired, join key absent on ${sum.unreadable ?? 0} of ${sum.runs ?? 0}</span>`;
5638
+ const evRows = (ev.recent || []).slice(0, 8).map((r) => {
5639
+ const when = (r.ts || '').slice(5, 16).replace('T', ' ');
5640
+ const dim = r.state !== 'ok';
5641
+ const verdict = r.state === 'ok'
5642
+ ? (r.verdict === 'BLOCK' ? `<span style="color:var(--status-blocked)">BLOCK</span>` : `<span style="color:var(--status-done)">${esc(r.verdict || 'PASS')}</span>`) + ` <span class="muted">· ${r.findings ?? 0} finding${r.findings === 1 ? '' : 's'}${r.p0 ? ` · ${r.p0} P0` : ''}</span>`
5643
+ : r.state === 'unreadable' ? '<span class="abs" title="predates the join key — cannot be paired with any tree">! unreadable</span>'
5644
+ : `<span class="abs" title="skipped — not counted as reviewed">${esc(r.state)}</span>${r.resets_at ? ` <span class="muted">→ ${esc(String(r.resets_at).slice(11, 16) || r.resets_at)}</span>` : ''}`;
5645
+ const cost = r.cost != null ? `$${Number(r.cost).toFixed(4)}` : '<span class="abs" title="no cost — nothing ran">—</span>';
5646
+ return `<div class="ev-row${dim ? ' dim' : ''}" title="${esc(r.provider || '')}${r.model ? ' · ' + esc(r.model) : ''}${r.sha ? ' · ' + esc(String(r.sha).slice(0, 8)) : ''}"><span class="mono">${esc(when)}</span><span>${verdict}</span><span class="mono tab">${cost}</span></div>`;
5647
+ }).join('');
5648
+ const evidenceBlock = ev.state === 'absent'
5649
+ ? '<div class="muted cap">no cross-model review has run in this project yet</div>'
5650
+ : ev.state === 'unreadable'
5651
+ ? '<div class="abs">! the review log could not be read</div>'
5652
+ : `<div class="ev-head"><span>When</span><span>Verdict</span><span class="tab">Cost</span></div>${evRows}${sum.unreadable_lines ? `<div class="abs cap" style="padding:6px 0" title="counted, not dropped">! ${sum.unreadable_lines} line(s) could not be parsed</div>` : ''}`;
5653
+
5244
5654
  body.innerHTML = `
5245
- <div><b>Claude Code</b> · host · runs the pipeline, gates and hooks</div>
5246
- <div>${codexLine}</div>
5247
- <div style="margin-top:8px">Second opinion: ${stateLabel}</div>
5248
- <div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
5249
- <label for="so-select" class="muted">set</label>
5250
- <select id="so-select">${undecl}${sel}</select>
5251
- <button id="so-apply" class="btn-ghost-sm">apply</button>
5252
- <span id="so-result" class="muted"></span>
5655
+ <div class="mp-card harness-hosts">
5656
+ <div><span class="dot ok"></span><b>Claude Code</b> <span class="mono cap muted">host · v${esc(h.claude_code?.version || '')}</span><span class="cap muted" style="margin-left:auto" title="70 agents · commands · skills · hooks">full pipeline</span></div>
5657
+ <div><span class="${codexDot}"></span><b>OpenAI Codex</b> <span class="mono cap">${codexState}</span>${cx.version ? `<span class="mono cap muted">${esc(cx.version)}</span>` : ''}${cx.model ? `<span class="mono cap muted" style="margin-left:auto">${esc(cx.model)}</span>` : ''}</div>
5658
+ </div>
5659
+ <div class="mp-card" style="margin-top:12px">
5660
+ <div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
5661
+ <span class="eyebrow">Second opinion</span>
5662
+ <span class="mono cap">${esc(so.declared?.tool || so.declared?.state || 'undeclared')}</span>
5663
+ <span class="mono cap" style="color:${so.state === 'declared' ? 'var(--status-done)' : so.state === 'unavailable' ? 'var(--status-blocked)' : 'var(--absent-warn-fg)'}" title="${esc(so.why || '')}">${esc(so.state || '?')}</span>
5664
+ <span class="cap" style="margin-left:auto">${pairedCell}</span>
5665
+ </div>
5666
+ <div class="so-options">${radio('', 'Undeclared')}${radio('none', 'None')}${radio('codex', 'Codex')}${radio('openrouter', 'OpenRouter')}</div>
5667
+ <div style="display:flex;gap:10px;align-items:center;margin-top:10px">
5668
+ <span id="so-result" class="muted cap"></span>
5669
+ <button id="so-apply" class="btn-black" style="margin-left:auto">Save to PROJECT.md</button>
5670
+ </div>
5253
5671
  </div>
5254
- <div style="margin-top:8px"><span class="muted">Evidence:</span> ${evidence}</div>`;
5672
+ <div class="mp-card" style="margin-top:12px">
5673
+ <div style="display:flex;align-items:baseline;gap:12px;flex-wrap:wrap">
5674
+ <span class="eyebrow">What it did</span>
5675
+ <span class="mono cap">${sum.runs ?? 0} runs · ${sum.reviewed ?? 0} reviewed · <span class="muted">${sum.skipped ?? 0} skipped</span> · <span class="abs">${sum.unreadable ?? 0} unreadable</span> · <span style="color:var(--status-blocked)">${sum.blocked ?? 0} blocked</span></span>
5676
+ <span class="mono cap muted" style="margin-left:auto">.great_cto/cross-review.log</span>
5677
+ </div>
5678
+ <div class="ev-table">${evidenceBlock}</div>
5679
+ </div>`;
5680
+ // The judge and its scores sit under the harness facts now.
5681
+ if (typeof loadJudgeStatus === 'function') loadJudgeStatus();
5682
+ if (typeof loadJudgeScores === 'function') loadJudgeScores();
5255
5683
 
5256
5684
  document.getElementById('so-apply').onclick = async () => {
5257
- const v = document.getElementById('so-select').value;
5685
+ const v = (document.querySelector('input[name="so"]:checked') || {}).value || '';
5258
5686
  const out = document.getElementById('so-result');
5259
5687
  out.textContent = 'writing PROJECT.md…';
5260
5688
  try {
@@ -5468,8 +5896,9 @@ async function refreshMetricsForPeriod() {
5468
5896
  if (lbl) lbl.textContent = `Last ${periodLabel(currentPeriod)}`;
5469
5897
  // Use seq-guarded fetch so stale init/SSE fetches can't overwrite us
5470
5898
  const m = await _refreshMetricsRaced(`/api/metrics${pqsd()}`);
5471
- if (m) { renderDashboard(m); renderWindowFill(m); }
5899
+ if (m) { metrics = m; if (typeof renderWindowFill === 'function') renderWindowFill(m); }
5472
5900
  refreshCost();
5901
+ if (currentTab === 'budgets') loadBudgetsPage();
5473
5902
  }
5474
5903
 
5475
5904
  /* Why four periods can show the same number.
@@ -5790,7 +6219,7 @@ async function loadDocs() {
5790
6219
  return;
5791
6220
  }
5792
6221
  _docsCache = d;
5793
- document.getElementById('nav-docs-count').textContent = d.total || 0;
6222
+ const navDocsCount = document.getElementById('nav-docs-count'); if (navDocsCount) navDocsCount.textContent = d.total || 0;
5794
6223
 
5795
6224
  const pipeline = d.pipeline
5796
6225
  ? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
@@ -6323,7 +6752,6 @@ async function switchProject(slug) {
6323
6752
  try { renderInbox(inbox); } catch (e) { console.warn('renderInbox failed', e); }
6324
6753
  try { renderFilterBar(); } catch (e) { console.warn('renderFilterBar failed', e); }
6325
6754
  try { renderKanban(allTasks); } catch (e) { console.warn('renderKanban failed', e); }
6326
- try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
6327
6755
  try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
6328
6756
  loadMemory();
6329
6757
  refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
@@ -6512,7 +6940,6 @@ async function init() {
6512
6940
  try { renderInbox(inbox); } catch (e) { console.warn('renderInbox failed', e); }
6513
6941
  try { renderFilterBar(); } catch (e) { console.warn('renderFilterBar failed', e); }
6514
6942
  try { renderKanban(allTasks); } catch (e) { console.warn('renderKanban failed', e); }
6515
- try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
6516
6943
  try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
6517
6944
  loadMemory();
6518
6945
  refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
@@ -6725,7 +7152,9 @@ function connectSSE() {
6725
7152
  }
6726
7153
 
6727
7154
  function updateNavCounts() {
6728
- document.getElementById('nav-task-count').textContent = allTasks.length;
7155
+ // The Tasks nav item left with the redesign (kanban is a deep link); the
7156
+ // count still feeds the kanban header where that element remains.
7157
+ const navTaskCount = document.getElementById('nav-task-count'); if (navTaskCount) navTaskCount.textContent = allTasks.length;
6729
7158
  document.getElementById('topbar-task-count').textContent = `${allTasks.length} ISSUES`;
6730
7159
  }
6731
7160
 
@@ -6930,13 +7359,23 @@ function cardHTML(t) {
6930
7359
  // following stage, and — when the share report is on — a public URL is
6931
7360
  // republished. That last line is the one worth reading: it leaves the machine,
6932
7361
  // and revoking the link later does not un-see what was already served.
6933
- function approveConsequence(id) {
7362
+ function approveConsequence(id, reversibility) {
7363
+ // The gate's cost of undo is part of the consequence (ADR-009, DESIGN §6.2):
7364
+ // `expensive` names its categories in the words gate-reversibility.mjs uses —
7365
+ // escapes-the-machine, costs-money, destroys-evidence, unrevocable-disclosure;
7366
+ // `unclassified` says so, because an unknown cost is not a low one.
7367
+ const r = reversibility || GATE_REVERSIBILITY[id] || null;
6934
7368
  const lines = [
6935
7369
  `Approve ${id}?`,
6936
7370
  '',
6937
7371
  'Records your approval in the verdict log and wakes the pipeline —',
6938
7372
  'the next session dispatches the following stage.',
6939
7373
  ];
7374
+ if (r && r.state === 'expensive') {
7375
+ lines.push('', `Expensive to undo: ${(r.categories || []).join(' · ') || 'see gate-reversibility.mjs'}.`);
7376
+ } else if (r && r.state === 'unclassified') {
7377
+ lines.push('', 'Unclassified gate — its cost of undo is unknown, and unknown is not harmless.');
7378
+ }
6940
7379
  if (shareState && shareState.enabled) {
6941
7380
  lines.push('', `Also republishes the public report at ${shareState.url || 'the share URL'} —`,
6942
7381
  'anyone holding that link sees the update.');
@@ -6960,7 +7399,18 @@ async function gateAction(id, action, runAgent) {
6960
7399
  // a typed-name ritual on a button pressed several times a day. The runAgent
6961
7400
  // path already shows an editable prompt that says what it will approve and
6962
7401
  // run, and is cancellable — a second dialog there would be pure friction.
6963
- if (!runAgent && !confirm(approveConsequence(id))) return;
7402
+ // Approve ritual follows cost of undo (DESIGN §6.2). `routine`: one confirm
7403
+ // naming the consequence. `expensive` and `unclassified`: the operator types
7404
+ // the gate name — exact, case-sensitive — before the approval is posted; an
7405
+ // unclassified gate gets the expensive ritual because nobody judged it cheap.
7406
+ const rev = GATE_REVERSIBILITY[id] || null;
7407
+ const guarded = rev && (rev.state === 'expensive' || rev.state === 'unclassified');
7408
+ if (!runAgent && guarded) {
7409
+ const gateName = rev.gate ? `gate:${rev.gate}` : id;
7410
+ const typed = prompt(`${approveConsequence(id, rev)}\n\nType ${gateName} to approve:`, '');
7411
+ if (typed === null) return;
7412
+ if (typed.trim() !== gateName) { alert(`Not approved — "${typed.trim()}" is not ${gateName}.`); return; }
7413
+ } else if (!runAgent && !confirm(approveConsequence(id, rev))) return; // routine
6964
7414
  body = { action, project: currentProject };
6965
7415
  if (runAgent) {
6966
7416
  const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
@@ -7070,6 +7520,8 @@ const ABSENCE = {
7070
7520
  none: '—', // measured; the answer is that there is none
7071
7521
  uncomputable: 'n/a', // the inputs for this figure do not exist
7072
7522
  unloaded: '·', // no answer yet — the read has not landed
7523
+ unreadable: '!', // it was there and could not be parsed
7524
+ unjudged: '?', // it exists and nobody has classified it yet
7073
7525
  };
7074
7526
 
7075
7527
  function absent(kind, why) {
@@ -7106,166 +7558,6 @@ function deltaBadge(current, prev, comparable, { invert = false } = {}) {
7106
7558
  }
7107
7559
 
7108
7560
  /* ── Dashboard ──────────────────────────────────────────────────────────── */
7109
- function renderDashboard(m) {
7110
- const llmUsd = m.cost?.llm_usd ?? 0;
7111
- const realLlmUsd = m.cost?.real_llm_usd ?? 0;
7112
- const planSavingsX = m.cost?.savings_x;
7113
- // Prefer the window-scoped count, but fall back to the all-time total when it
7114
- // is 0 — otherwise projects with completed-but-untimestamped tasks (e.g. a
7115
- // tasks.md fallback with no dates) show a meaningless "—" despite real work.
7116
- // (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
7117
- const done = (m.tasks?.done_in_window || m.tasks?.done) ?? 0;
7118
- // Three states, not two. `?? 0` folds "the payload has no tasks section" into
7119
- // the same 0 as "zero tasks done", and the tile then rendered that 0 as an
7120
- // absence — so a day-one project read "—" beside "+0 this week", the board
7121
- // reporting a failure to measure something it had measured fine. The backend
7122
- // always sends `tasks` (lib/metrics.mjs), so this is "—" only for a payload
7123
- // that really is missing it.
7124
- const doneKnown = m.tasks != null;
7125
- const costKnown = m.cost != null;
7126
- // NOT the same shape: avg_completion_min is a median over completions that
7127
- // carry timestamps, and the backend emits 0 when there are none — so 0 here
7128
- // means "could not be computed", not "took no time". "—" is the correct
7129
- // reading; do not "fix" this into a literal 0 m.
7130
- const avgMin = m.avg_completion_min ?? 0;
7131
- const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
7132
-
7133
- // Honest cost model — the backend is the source of truth:
7134
- // cost.savings_x = MEASURED savings, only set when real verdict cost data
7135
- // exists. NULL for the time-based (tasks) estimate, because
7136
- // that ratio is just the hardcoded rate ratio, not a real
7137
- // measurement — showing it would fabricate a number.
7138
- // cost.human_usd = the backend's conservative human-cost estimate.
7139
- // The frontend must NOT recompute its own human/savings figures (that produced
7140
- // an inflated "×" that contradicted the API); it displays the backend fields.
7141
- const aiSpend = realLlmUsd > 0 ? realLlmUsd : llmUsd;
7142
- const humanEst = m.cost?.human_usd ?? 0;
7143
- const savingsX = (typeof planSavingsX === 'number' && planSavingsX > 1)
7144
- ? planSavingsX
7145
- : null;
7146
-
7147
- const llmTrend = realLlmUsd > 0
7148
- ? `real tokens spent (from verdicts)`
7149
- : (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
7150
-
7151
- const hero = [
7152
- {
7153
- v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'),
7154
- sub: 'shipped', label: 'Tasks done',
7155
- trend: `+${m.velocity?.this_week ?? 0} this week`,
7156
- delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
7157
- },
7158
- {
7159
- // Three states, and $0.00 is only one of them. A measured zero is worth
7160
- // showing; a zero that is the TIME-BASED ESTIMATE with no plans or tasks
7161
- // to estimate from is not a figure at all, and rendering it as $0.00 told
7162
- // a project with dozens of agent runs that it had spent nothing.
7163
- v: !costKnown
7164
- ? absent('unloaded', 'the metrics payload carried no cost section')
7165
- : (realLlmUsd === 0 && llmUsd === 0
7166
- ? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
7167
- : `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}`),
7168
- sub: '',
7169
- label: 'AI spend',
7170
- delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
7171
- trend: llmTrend,
7172
- cls: 'amber',
7173
- },
7174
- (savingsX != null && savingsX > 1)
7175
- ? {
7176
- v: savingsX.toLocaleString(),
7177
- sub: '×',
7178
- label: 'Cost savings vs FTE',
7179
- trend: humanEst > 0 ? `measured · human est. $${fmtMoney(humanEst)}` : 'measured savings',
7180
- cls: 'green',
7181
- }
7182
- : {
7183
- // No MEASURED savings yet (no verdict cost data). Don't fabricate a
7184
- // multiplier from the time-based estimate — show it's not measured.
7185
- v: absent('uncomputable', 'a measured multiplier needs verdict cost data, and there is none yet'),
7186
- sub: '',
7187
- label: 'Cost savings vs FTE',
7188
- trend: aiSpend > 0
7189
- ? 'estimate only · run pipelines for measured savings'
7190
- : (done > 0 ? 'no spend recorded yet' : 'no tasks done yet'),
7191
- cls: '',
7192
- },
7193
- ];
7194
- document.getElementById('mp-hero').innerHTML = hero.map(s => `
7195
- <div class="metric-card ${s.cls || ''}">
7196
- <div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
7197
- <div class="metric-label">${s.label}${s.delta || ''}</div>
7198
- <div class="metric-trend">${s.trend}</div>
7199
- </div>`).join('');
7200
-
7201
- const acc = m.acceptance || {};
7202
- const secondary = [
7203
- { v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'm' : '', label: cycleStat },
7204
- { v: m.qa?.pass_rate != null ? m.qa.pass_rate : absent('none', 'no QA run recorded in this window'), sub: m.qa?.pass_rate != null ? '%' : '', label: m.qa?.pass_rate != null ? 'QA pass rate' : 'QA — run /qa', cls: 'green' },
7205
- // Verified-acceptance metrics: cost of a change a gate actually APPROVED, and
7206
- // how often work bounced. '—' when nothing was accepted in the window — an
7207
- // honest gap, not a zero.
7208
- { v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: '', label: acc.accepted ? `Cost / accepted (${acc.accepted})` : 'Cost / accepted' },
7209
- { v: acc.rework_rounds ?? 0, sub: '', label: 'Rework rounds', cls: acc.rework_rounds ? 'amber' : 'green' },
7210
- { v: m.security?.blocked ?? 0, sub: '', label: 'Open security blocks', cls: m.security?.blocked ? 'red' : 'green' },
7211
- { v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
7212
- ];
7213
- document.getElementById('mp-secondary').innerHTML = secondary.map(s => `
7214
- <div class="metric-card ${s.cls || ''}">
7215
- <div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
7216
- <div class="metric-label">${s.label}</div>
7217
- </div>`).join('');
7218
-
7219
- const agents = m.agents || {};
7220
- const maxRuns = Math.max(...Object.values(agents), 1);
7221
- const colors = ['#2540d4','#7c3aed','#16a34a','#f59e0b','#06b6d4','#2563eb','#64748b','#94a3b8'];
7222
- const agentKeys = Object.entries(agents).sort((a,b)=>b[1]-a[1]);
7223
- const agentListEl = document.getElementById('agent-list');
7224
- if (agentListEl) {
7225
- agentListEl.innerHTML = agentKeys.length ? agentKeys.map(([a, n], i) => `
7226
- <div class="bar-row">
7227
- <span class="name">${esc(a)}</span>
7228
- <span class="rail"><span class="fill" style="width:${(n/maxRuns*100).toFixed(0)}%; background:${colors[i % colors.length]}"></span></span>
7229
- <span class="pct">${Math.round(n/maxRuns*100)}%</span>
7230
- </div>`).join('') : '<div class="empty">No agent runs yet</div>';
7231
- }
7232
-
7233
- // Agent cost breakdown
7234
- const agentsCost = m.agents_cost || [];
7235
- const costPanel = document.getElementById('agent-cost-panel');
7236
- if (costPanel) {
7237
- if (agentsCost.length) {
7238
- const maxCost = Math.max(...agentsCost.map(a => a.llm_usd), 0.0001);
7239
- costPanel.innerHTML = agentsCost.map((a, i) => `
7240
- <div class="agent-cost-row">
7241
- <span class="name">${esc(a.agent)}</span>
7242
- <span class="rail"><span class="fill" style="width:${(a.llm_usd/maxCost*100).toFixed(0)}%; background:${colors[i % colors.length]}"></span></span>
7243
- <span class="amt">$${a.llm_usd.toFixed(4)}</span>
7244
- </div>`).join('');
7245
- } else {
7246
- costPanel.innerHTML = '<div class="cost-empty">No agent cost data — tasks need estimated_minutes set.</div>';
7247
- }
7248
- }
7249
-
7250
- const verdicts = (m.verdicts || []).slice().reverse().slice(0, 10);
7251
- const timelineEl = document.getElementById('timeline');
7252
- if (timelineEl) {
7253
- timelineEl.innerHTML = verdicts.length ? verdicts.map(v => {
7254
- const vu = (v.verdict || '').toUpperCase();
7255
- const cls = ['APPROVED','DONE','PASS'].includes(vu) ? 'ok' : ['BLOCKED','FAILED','FAIL'].includes(vu) ? 'fail' : 'def';
7256
- const raw = v.raw || '';
7257
- const afterColon = raw.indexOf(': ') !== -1 ? raw.slice(raw.indexOf(': ') + 2) : raw;
7258
- const text = afterColon.length > 80 ? afterColon.slice(0, 80) + '…' : afterColon;
7259
- return `
7260
- <div class="feed-row">
7261
- <span class="feed-dot ${cls}"></span>
7262
- <span class="agent">${esc(v.agent)}</span>
7263
- <span class="msg">${esc(text)}</span>
7264
- <span class="time">${relTime(v.ts)}</span>
7265
- </div>`;
7266
- }).join('') : '<div class="empty">No activity yet</div>';
7267
- }
7268
- }
7269
7561
 
7270
7562
  /* ── Share ──────────────────────────────────────────────────────────────── */
7271
7563
  function renderShareState(state) {
@@ -7675,12 +7967,12 @@ let cmdkIndex = 0;
7675
7967
 
7676
7968
  function cmdkActions() {
7677
7969
  const out = [
7678
- { group: 'Go', label: 'Inbox — what needs your decision', run: () => switchTab('inbox') },
7679
- { group: 'Go', label: 'Tasks', run: () => switchTab('kanban') },
7680
- { group: 'Go', label: 'Metrics', run: () => switchTab('dashboard') },
7681
- { group: 'Go', label: 'Docs', run: () => switchTab('docs') },
7682
- { group: 'Go', label: 'Activity', run: () => switchTab('logs') },
7683
- { group: 'Go', label: 'Notifications', run: () => switchTab('notifications') },
7970
+ { group: 'Go', label: 'Decisions — what needs you', run: () => switchTab('decisions') },
7971
+ { group: 'Go', label: 'Ledger — what it cost, what is running', run: () => switchTab('ledger') },
7972
+ { group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
7973
+ { group: 'Go', label: 'Harness — host and second opinion', run: () => switchTab('harness') },
7974
+ { group: 'Go', label: 'Settings', run: () => switchTab('settings') },
7975
+ { group: 'Go', label: 'Tasks board (#/kanban, deep link)', run: () => switchTab('kanban') },
7684
7976
  { group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
7685
7977
  { group: 'Do', label: 'Toggle light / dark theme', run: () => toggleBoardTheme() },
7686
7978
  ];
@@ -7692,9 +7984,67 @@ function cmdkActions() {
7692
7984
  out.push({ group: 'Gate', label: `Approve — ${t.title}`, hint: t.id,
7693
7985
  run: () => gateAction(t.id, 'approve') });
7694
7986
  }
7987
+ // ⌘K is where the board's artifacts are found by name (brief: docs, sessions,
7988
+ // memory and decisions are one job — find the artifact — and a job done by
7989
+ // recalling a name belongs in a palette, not a tab). Agents by slug (DESIGN
7990
+ // §5 device 4): `sec` → security-officer, opened in place.
7991
+ for (const a of (fleetState?.agents || [])) {
7992
+ if (a.retired) continue;
7993
+ out.push({ group: 'Agent', label: a.slug, hint: a.posture?.state === 'expensive' ? '▲ ' + ((a.posture.expensive || [])[0] || '') : (a.posture?.state === 'unclassified' ? '? not classified' : ''),
7994
+ run: () => { switchTab('fleet'); openAgentDrawer(a.slug); } });
7995
+ }
7996
+ for (const m of (cmdkCache.memory || [])) {
7997
+ out.push({ group: 'Memory', label: `${m.name} — ${m.desc || ''}`, hint: m.layer || '', run: () => switchTab('memory') });
7998
+ }
7999
+ for (const d of (cmdkCache.decisions || [])) {
8000
+ out.push({ group: 'Decisions', label: `${d.id} — ${d.title || ''}`, hint: `${d.verdict || ''} ${(d.ts || '').slice(0, 10)}`.trim(),
8001
+ run: () => switchTab('decisions') });
8002
+ }
8003
+ for (const it of (cmdkAsync.q === cmdkLastNeedle ? cmdkAsync.items : [])) out.push(it);
7695
8004
  return out;
7696
8005
  }
7697
8006
 
8007
+ // Memory layers and the decision log are small and stable: read once per
8008
+ // palette session. Docs and sessions are searched server-side per query
8009
+ // (BM25 over the files), debounced, and only shown while the query they
8010
+ // answered is still the one typed — a stale result is a wrong result.
8011
+ const cmdkCache = { memory: null, decisions: null };
8012
+ let cmdkAsync = { q: '', items: [] };
8013
+ let cmdkLastNeedle = '';
8014
+ let cmdkTimer = null;
8015
+ async function cmdkWarm() {
8016
+ if (cmdkCache.memory == null) {
8017
+ try { const m = await api(`/api/memory${pqs()}`); cmdkCache.memory = Array.isArray(m?.layers) ? m.layers : []; } catch { cmdkCache.memory = []; }
8018
+ }
8019
+ if (cmdkCache.decisions == null) {
8020
+ try { const d = await api(`/api/decisions${pqs()}`); cmdkCache.decisions = Array.isArray(d) ? d.slice(-30).reverse() : []; } catch { cmdkCache.decisions = []; }
8021
+ }
8022
+ cmdkRender(document.getElementById('cmdk-input')?.value || '');
8023
+ }
8024
+ function cmdkSearch(needle) {
8025
+ clearTimeout(cmdkTimer);
8026
+ if (needle.length < 2) { cmdkAsync = { q: '', items: [] }; return; }
8027
+ cmdkTimer = setTimeout(async () => {
8028
+ const items = [];
8029
+ const [docs, sessions] = await Promise.all([
8030
+ api(`/api/docs/search?q=${encodeURIComponent(needle)}${pqs().replace(/^\?/, '&')}`).catch(() => null),
8031
+ api(`/api/session-search?q=${encodeURIComponent(needle)}${pqs().replace(/^\?/, '&')}`).catch(() => null),
8032
+ ]);
8033
+ for (const r of (docs?.results || []).slice(0, 6)) {
8034
+ const hit = (r.hits || [])[0];
8035
+ items.push({ group: 'Docs', label: r.title || r.path, hint: hit ? `L${hit.line} · ${String(hit.text || '').slice(0, 60)}` : r.path, run: () => openDoc(r.path) });
8036
+ }
8037
+ if (docs && docs.unreadable) items.push({ group: 'Docs', label: `! ${docs.unreadable} document(s) could not be read`, hint: 'counted, not dropped', run: () => switchTab('docs') });
8038
+ for (const h of (sessions?.hits || []).slice(0, 6)) {
8039
+ items.push({ group: 'Sessions', label: `${h.title || h.session} · ${(h.ts || '').slice(0, 10)}`, hint: String(h.excerpt || '').replace(/\s+/g, ' ').slice(0, 70), run: () => openSession(h.session) });
8040
+ }
8041
+ if (docs == null && sessions == null) items.push({ group: 'Docs', label: '· search unavailable — the board could not read docs or sessions', hint: '', run: () => {} });
8042
+ cmdkAsync = { q: needle, items };
8043
+ const cur = (document.getElementById('cmdk-input')?.value || '').toLowerCase().trim();
8044
+ if (cur === needle) cmdkRender(cur);
8045
+ }, 150);
8046
+ }
8047
+
7698
8048
  function cmdkOpen() {
7699
8049
  let el = document.getElementById('cmdk');
7700
8050
  if (!el) {
@@ -7702,7 +8052,7 @@ function cmdkOpen() {
7702
8052
  el.id = 'cmdk';
7703
8053
  el.innerHTML = `
7704
8054
  <div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
7705
- <input id="cmdk-input" type="text" placeholder="Type a command…" aria-label="Command"
8055
+ <input id="cmdk-input" type="text" placeholder="Go, do, gate · agents · docs · sessions · memory · decisions" aria-label="Command"
7706
8056
  autocomplete="off" oninput="cmdkRender(this.value)" />
7707
8057
  <div id="cmdk-list" role="listbox" aria-label="Commands"></div>
7708
8058
  </div>`;
@@ -7713,16 +8063,19 @@ function cmdkOpen() {
7713
8063
  cmdkIndex = 0;
7714
8064
  const input = document.getElementById('cmdk-input');
7715
8065
  input.value = '';
8066
+ cmdkLastNeedle = ''; cmdkAsync = { q: '', items: [] };
7716
8067
  cmdkRender('');
7717
8068
  input.focus();
8069
+ cmdkWarm();
7718
8070
  }
7719
8071
 
7720
8072
  function cmdkClose() { document.getElementById('cmdk')?.classList.remove('open'); }
7721
8073
 
7722
8074
  function cmdkRender(q) {
7723
8075
  const needle = (q || '').toLowerCase().trim();
8076
+ if (needle !== cmdkLastNeedle) { cmdkLastNeedle = needle; cmdkSearch(needle); }
7724
8077
  cmdkItems = cmdkActions().filter((a) =>
7725
- !needle || a.label.toLowerCase().includes(needle) || (a.hint || '').toLowerCase().includes(needle));
8078
+ !needle || a.group === 'Docs' || a.group === 'Sessions' || a.label.toLowerCase().includes(needle) || (a.hint || '').toLowerCase().includes(needle));
7726
8079
  if (cmdkIndex >= cmdkItems.length) cmdkIndex = 0;
7727
8080
  const list = document.getElementById('cmdk-list');
7728
8081
  if (!list) return;
@@ -7803,41 +8156,53 @@ try {
7803
8156
  matchMedia('(min-width: 769px)').addEventListener('change', (e) => { if (e.matches) closeMenu(false); });
7804
8157
  } catch (_) { /* older Safari: no addEventListener on MediaQueryList — drawer still works */ }
7805
8158
 
8159
+ // Redesign IA (great_cto-ki1x.5): four destinations + Settings. A new key
8160
+ // resolves to the panel that holds its content today (DEST); an old key —
8161
+ // still used by deep links, the SSE handlers and the kanban drill-in — maps
8162
+ // back to the destination that should light up (NAV). Screens .6–.11 replace
8163
+ // panels, not keys.
8164
+ const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
8165
+ const NAV = { inbox: 'decisions', budgets: 'ledger', agents: 'fleet', dashboard: 'ledger', share: 'settings', notifications: 'settings' };
8166
+ const ROUTES = { decisions: 1, ledger: 1, fleet: 1, harness: 1, settings: 1, kanban: 1 };
7806
8167
  function switchTab(id, el) {
7807
- // `agents` has no sidebar entry by design: it is a drill-in from Dashboard.
7808
- // Without this the call fell through to code that expects a nav element and
7809
- // left the sidebar with nothing selected.
7810
- if (!el) el = document.querySelector(`.nav-item[data-tab="${id}"]`)
7811
- || document.querySelector('.nav-item[data-tab="dashboard"]');
7812
- currentTab = id;
8168
+ const lid = DEST[id] || id; // the panel's own name
8169
+ const navKey = NAV[id] || id; // the destination to highlight
8170
+ if (!el) el = document.querySelector(`.nav-item[data-tab="${navKey}"]`)
8171
+ || document.querySelector('.nav-item[data-tab="decisions"]');
8172
+ currentTab = lid;
8173
+ if (ROUTES[id]) {
8174
+ if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
8175
+ // BRD-R9: one local line per destination open, never blocking, never surfaced.
8176
+ try { fetch('/api/view', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ view: id }) }).catch(() => {}); } catch (e) {}
8177
+ }
7813
8178
  closeMenu(false); // choosing a destination is the end of using the menu
7814
8179
  // sidebar nav
7815
8180
  document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
7816
- t.classList.toggle('active', t.dataset.tab === id);
8181
+ t.classList.toggle('active', t.dataset.tab === navKey);
7817
8182
  });
7818
8183
  // top tabs
7819
8184
  document.querySelectorAll('.tab-btn').forEach(t => t.classList.toggle('active', t.dataset.tab === id));
7820
8185
  // panels
7821
8186
  document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
7822
- const panelEl = document.getElementById(`panel-${id}`);
8187
+ const panelEl = document.getElementById(`panel-${lid}`);
7823
8188
  if (panelEl) panelEl.classList.add('active');
7824
8189
  // crumb — `labels` used to be referenced here but is only ever a function-local in
7825
8190
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
7826
8191
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
7827
- const TAB_LABELS = { budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
7828
- const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
8192
+ const TAB_LABELS = { decisions: 'Decisions', ledger: 'Ledger', fleet: 'Fleet', harness: 'Harness', settings: 'Settings', budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
8193
+ const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
7829
8194
  // Budgets render on Metrics, so the heartbeat that feeds them has to be
7830
8195
  // fetched when Metrics opens — not only from the agents drill-in it used to
7831
8196
  // live in.
7832
- if (id === 'dashboard') refreshHeartbeat();
8197
+ if (lid === 'dashboard') refreshHeartbeat();
7833
8198
  // The Notifications panel has carried an inline history list all along, and
7834
8199
  // `renderNotifHistoryInline()` to fill it — but the only caller was
7835
8200
  // `openNotifDrawer()`. Opening the tab itself never loaded it, so the sidebar
7836
8201
  // promised 50 unread and the screen showed settings. Declared and not
7837
8202
  // consumed, in the UI this time.
7838
- if (id === 'notifications') loadNotifHistory();
7839
- if (id === 'budgets') loadBudgetsPage();
7840
- if (id === 'dashboard' || id === 'agents') {
8203
+ if (lid === 'notifications') loadNotifHistory();
8204
+ if (lid === 'budgets') { loadBudgetsPage(); refreshCost(); }
8205
+ if (lid === 'agents') {
7841
8206
  // pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
7842
8207
  // its 30-day default and overwrote whatever window the period chips had
7843
8208
  // selected. Pick "7d", switch tabs, come back — the chip still read 7d and
@@ -7846,17 +8211,54 @@ function switchTab(id, el) {
7846
8211
  //
7847
8212
  // Through the sequence guard too, so a tab switch cannot land after a newer
7848
8213
  // period request and clobber it.
7849
- _refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; renderDashboard(m); } });
7850
- if (id === 'dashboard') refreshCost();
7851
- if (id === 'agents') refreshAgentsInstalled();
8214
+ _refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; } });
8215
+ if (lid === 'agents') refreshAgentsInstalled();
7852
8216
  }
7853
- if (id === 'inbox') refreshInbox();
7854
- if (id === 'logs') setActivityView('logs');
7855
- if (id === 'docs') loadDocs();
7856
- if (id === 'sessions') loadSessions();
7857
- if (id === 'logs') refreshLogs();
7858
- if (id === 'notifications') loadNotifications();
8217
+ if (lid === 'inbox') refreshInbox();
8218
+ if (lid === 'logs') setActivityView('logs');
8219
+ if (lid === 'docs') loadDocs();
8220
+ if (lid === 'sessions') loadSessions();
8221
+ if (lid === 'logs') refreshLogs();
8222
+ if (lid === 'notifications' || lid === 'settings') loadNotifications();
8223
+ if (lid === 'settings') loadSettingsPage();
8224
+ if (lid === 'harness') renderHarnesses();
8225
+ }
8226
+
8227
+ // Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).
8228
+ function navTabKeydown(event, el) {
8229
+ const items = [...document.querySelectorAll('.nav-item[role="tab"]')];
8230
+ const i = items.indexOf(el);
8231
+ if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); el.click(); return; }
8232
+ if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
8233
+ event.preventDefault();
8234
+ const next = items[(i + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length];
8235
+ if (next) next.focus();
8236
+ }
8237
+ }
8238
+ // Routes: #/decisions … #/settings and #/kanban (a deep link, not a menu item —
8239
+ // brief 2026-09-06, revisited on 2026-09-20). Legacy hashes land on their new
8240
+ // home so a bookmark does not open on nothing. #agents/<slug> keeps its handler.
8241
+ const LEGACY_ROUTES = { inbox: 'decisions', budgets: 'ledger', dashboard: 'ledger', agents: 'fleet', notifications: 'settings', share: 'settings' };
8242
+ function routeFromHash() {
8243
+ const h = (location.hash || '').replace(/^#\/?/, '');
8244
+ if (!h || h.startsWith('agents/')) return null;
8245
+ // #/fleet/<slug>: open the agent in place (DESIGN §2 deep link).
8246
+ const m = h.match(/^fleet\/([a-z0-9-]+)$/i);
8247
+ if (m) {
8248
+ // Open once the board has a project and a fleet to open into; a deep link
8249
+ // that fires before init has set currentProject asks the API for nothing.
8250
+ const slug = m[1];
8251
+ const tryOpen = (n) => {
8252
+ if (typeof openAgentDrawer === 'function' && typeof currentProject !== 'undefined' && currentProject && (fleetState.agents || []).length) openAgentDrawer(slug, { fromHash: true });
8253
+ else if (n < 50) setTimeout(() => tryOpen(n + 1), 100);
8254
+ };
8255
+ setTimeout(() => tryOpen(0), 0);
8256
+ return 'fleet';
8257
+ }
8258
+ return ROUTES[h] ? h : (LEGACY_ROUTES[h] || null);
7859
8259
  }
8260
+ addEventListener('hashchange', () => { const r = routeFromHash(); if (r) switchTab(r); });
8261
+ addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'decisions') switchTab(r); });
7860
8262
 
7861
8263
  async function refreshLogs() {
7862
8264
  const data = await api(`/api/logs${pqs()}`);
@@ -7981,6 +8383,9 @@ const fleetState = {
7981
8383
  // doing work on most projects; showing all 35 by default is noise.
7982
8384
  // User flips activity to `all` to see the full installation.
7983
8385
  filters: { domain: 'all', activity: 'all', health: 'all' },
8386
+ // Five saved views, and only five (DESIGN §5): the default is the subset
8387
+ // nobody has judged yet, not the roster.
8388
+ view: 'needs',
7984
8389
  search: '',
7985
8390
  sort: 'runs_30d',
7986
8391
  drawerSlug: null,
@@ -8000,8 +8405,6 @@ async function refreshAgentsInstalled() {
8000
8405
  }
8001
8406
  // legacy_agent_count comes from /api/metrics — pull from cached metrics if present
8002
8407
  fleetState.legacy_count = (typeof metrics !== 'undefined' && metrics && metrics.legacy_agent_count) || 0;
8003
- renderFleetSummary();
8004
- renderFleetFacets();
8005
8408
  renderFleetList();
8006
8409
  renderLegacyAgentsSection();
8007
8410
  // If the URL hash names a drawer, open it (post-load).
@@ -8009,102 +8412,64 @@ async function refreshAgentsInstalled() {
8009
8412
  if (m) openAgentDrawer(m[1], { fromHash: true });
8010
8413
  }
8011
8414
 
8012
- function renderFleetSummary() {
8013
- const el = document.getElementById('fleet-summary');
8014
- if (!el) return;
8015
- const s = fleetState.summary;
8016
- if (!s) { el.innerHTML = '<div class="cost-empty">No data yet</div>'; return; }
8017
- const tiles = [
8018
- { v: s.installed, label: 'Installed agents', cls: '' },
8019
- { v: s.active_30d, label: 'Active last 30d', cls: 'green', trend: s.active_30d > 0 ? '' : 'no activity yet' },
8020
- {
8021
- v: s.retire_candidates,
8022
- label: 'Retire candidates',
8023
- cls: s.retire_candidates > s.installed * 0.5 ? 'amber' : '',
8024
- trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
8025
- onClick: 'setFleetFilter("activity", "never")',
8026
- },
8027
- {
8028
- // `?? 0` turned "nothing was measured" into "$0.00 spent", and before that
8029
- // the number here was an ESTIMATE from verdict counts while the metrics
8030
- // page estimated from tasks — two screens, one label, $3.90 against $1.65.
8031
- v: s.llm_usd_30d != null
8032
- ? '$' + s.llm_usd_30d.toFixed(2)
8033
- : absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`),
8034
- vHtml: s.llm_usd_30d == null,
8035
- label: 'LLM spend 30d',
8036
- cls: '',
8037
- trend: s.llm_usd_30d != null
8038
- ? `measured for ${s.llm_usd_30d_measured_for} agent(s)`
8039
- : (s.failing_7d ? `${s.failing_7d} failing` : 'measured from verdicts'),
8040
- },
8041
- ];
8042
- // `t.v` is HTML when it is an absence marker — a span carrying the reason on
8043
- // hover and in the accessible name — and a plain string otherwise. Escaping it
8044
- // unconditionally printed the markup as text on the fleet tile. `vHtml` is the
8045
- // deliberate opt-in; every value without it is still escaped.
8046
- //
8047
- // This comment lives out here because it names `t.v` in backticks, and a
8048
- // backtick inside the template literal below terminates it — which is how the
8049
- // board shipped a syntax error for about ninety seconds.
8050
- el.innerHTML = tiles.map(t => `
8051
- <div class="metric-card ${t.cls}" ${t.onClick ? `role="button" tabindex="0" onclick='${t.onClick}' onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();${t.onClick}}'` : ''}>
8052
- <div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
8053
- <div class="metric-label">${esc(t.label)}</div>
8054
- ${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
8055
- </div>`).join('');
8056
- }
8057
8415
 
8058
- const FACET_GROUPS = [
8059
- {
8060
- key: 'domain', legend: 'Domain',
8061
- options: [
8062
- ['all', 'all'], ['arch', 'arch'], ['pm', 'pm'], ['qa', 'qa'],
8063
- ['security', 'security'], ['ops', 'ops'], ['domain', 'domain'],
8064
- ['memory', 'memory'], ['other', 'other'],
8065
- ],
8066
- },
8067
- {
8068
- key: 'activity', legend: 'Activity',
8069
- options: [
8070
- ['all', 'all'], ['used', 'used'], ['active', 'active'], ['idle', 'idle ≥30d'], ['never', 'never run'], ['retired', 'retired'],
8071
- ],
8072
- },
8073
- {
8074
- key: 'health', legend: 'Health',
8075
- options: [
8076
- ['all', 'all'], ['ok', 'ok'], ['failing', 'failing ≥3 in 7d'],
8077
- ],
8078
- },
8079
- ];
8080
8416
 
8081
- function renderFleetFacets() {
8082
- const el = document.getElementById('fleet-facets');
8083
- if (!el) return;
8084
- el.innerHTML = FACET_GROUPS.map(g => `
8085
- <fieldset>
8086
- <legend>${esc(g.legend)}</legend>
8087
- ${g.options.map(([val, label]) => {
8088
- const checked = fleetState.filters[g.key] === val;
8089
- return `<button type="button" class="facet-chip"
8090
- role="checkbox" aria-checked="${checked}"
8091
- data-facet="${esc(g.key)}" data-value="${esc(val)}"
8092
- onclick="setFleetFilter('${esc(g.key)}', '${esc(val)}')">
8093
- ${esc(label)}</button>`;
8094
- }).join('')}
8095
- </fieldset>`).join('');
8096
- }
8097
-
8098
- function setFleetFilter(key, value) {
8099
- fleetState.filters[key] = value;
8100
- renderFleetFacets();
8417
+
8418
+
8419
+ const FLEET_VIEWS = [
8420
+ ['needs', 'Needs attention'], ['expensive', 'Expensive grants'], ['never', 'Never observed'], ['all', 'All agents'], ['retired', 'Retired'],
8421
+ ];
8422
+ // What "needs attention" means, in one place: failing, an unjudged posture, a
8423
+ // grant that is scoped in name only, or spend past its cap. Not "idle" — the
8424
+ // brief refuses an inferred idle/retired status in v1.
8425
+ function agentNeedsAttention(a) {
8426
+ const p = a.posture || {};
8427
+ return a.health === 'failing'
8428
+ || p.state === 'unclassified' || p.state === 'unreadable'
8429
+ || (Array.isArray(p.scopedInNameOnly) && p.scopedInNameOnly.length > 0)
8430
+ || String(a.budget?.state || '').startsWith('over');
8431
+ }
8432
+ function inFleetView(a, view) {
8433
+ if (view === 'retired') return !!a.retired;
8434
+ if (a.retired) return false;
8435
+ if (view === 'needs') return agentNeedsAttention(a);
8436
+ if (view === 'expensive') return a.posture?.state === 'expensive';
8437
+ if (view === 'never') return !(a.runs_total > 0);
8438
+ return true;
8439
+ }
8440
+ function setFleetView(view) {
8441
+ fleetState.view = FLEET_VIEWS.some(([k]) => k === view) ? view : 'all';
8442
+ renderFleetViews();
8101
8443
  renderFleetList();
8444
+ if (currentTab !== 'agents') switchTab('fleet');
8445
+ }
8446
+ // The sidebar's Views block: counts per view, the active one marked, and the
8447
+ // Fleet item's absence glyph lit when something is unclassified — a glyph, not
8448
+ // a count, because "2" would read as two new items.
8449
+ function renderFleetViews() {
8450
+ const agents = fleetState.agents || [];
8451
+ for (const [key, label] of FLEET_VIEWS) {
8452
+ const el = document.getElementById(`nav-view-${key}`);
8453
+ if (!el) continue;
8454
+ const n = agents.filter((a) => inFleetView(a, key)).length;
8455
+ el.classList.toggle('active', fleetState.view === key);
8456
+ el.innerHTML = `<span>${esc(label)}</span><span class="nav-view-count">${n}</span>`;
8457
+ }
8458
+ const abs = document.getElementById('nav-fleet-abs');
8459
+ if (abs) abs.hidden = !agents.some((a) => a.posture?.state === 'unclassified' || a.posture?.state === 'unreadable');
8460
+ const title = document.getElementById('fleet-view-title');
8461
+ if (title) {
8462
+ const [, label] = FLEET_VIEWS.find(([k]) => k === fleetState.view) || ['all', 'All agents'];
8463
+ const n = agents.filter((a) => inFleetView(a, fleetState.view)).length;
8464
+ title.innerHTML = `${esc(label)} <span class="muted" style="font-weight:400;font-size:var(--fs-title-s)">${n} of ${agents.length}</span>`;
8465
+ }
8102
8466
  }
8103
8467
 
8104
8468
  function applyFleetFilters(agents) {
8105
8469
  const { domain, activity, health } = fleetState.filters;
8106
8470
  const q = (fleetState.search || '').toLowerCase().trim();
8107
8471
  return agents.filter(a => {
8472
+ if (!inFleetView(a, fleetState.view)) return false;
8108
8473
  if (q && !a.slug.toLowerCase().includes(q) && !(a.description || '').toLowerCase().includes(q)) return false;
8109
8474
  if (domain !== 'all' && a.domain !== domain) return false;
8110
8475
  // `used` = has at least one verdict, not retired. Default filter.
@@ -8150,43 +8515,92 @@ function budgetChip(b) {
8150
8515
  function renderFleetList() {
8151
8516
  const el = document.getElementById('fleet-list');
8152
8517
  if (!el) return;
8518
+ renderFleetViews();
8153
8519
  const filtered = sortFleet(applyFleetFilters(fleetState.agents));
8154
8520
  if (!filtered.length) {
8155
- el.innerHTML = '<li class="empty" style="padding:20px;text-align:center;color:var(--text3)">No agents match these filters.</li>';
8521
+ el.innerHTML = '<li class="empty" style="padding:20px;text-align:center;color:var(--text3)">No agents match this view.</li>';
8156
8522
  return;
8157
8523
  }
8158
- el.innerHTML = filtered.map(a => {
8159
- const rate = a.success_rate;
8160
- const rateLabel = rate != null ? `${rate}% ok` : 'no verdicts';
8161
- const lastRunStr = a.last_run ? agoStr(a.last_run) : 'never run';
8162
- const savingsStr = a.savings_x != null ? `${a.savings_x}× savings` : '';
8163
- const runsStr = `${a.runs_30d} run${a.runs_30d === 1 ? '' : 's'} 30d`;
8164
- return `<li>
8524
+ // Groups come from deriveDomain() on the server — one taxonomy, not two. A
8525
+ // group with rows in this view is expanded; the rest are one line each with
8526
+ // their count, so 70 agents fit a screen without a scroll or a virtualizer.
8527
+ const DOMAINS = ['arch', 'security', 'qa', 'ops', 'domain', 'pm', 'memory', 'other'];
8528
+ const totals = {}; for (const a of fleetState.agents) totals[a.domain] = (totals[a.domain] || 0) + 1;
8529
+ const byDomain = {}; for (const a of filtered) (byDomain[a.domain] = byDomain[a.domain] || []).push(a);
8530
+ const order = [...DOMAINS, ...Object.keys(byDomain).filter((d) => !DOMAINS.includes(d))];
8531
+ const expanded = order.filter((d) => (byDomain[d] || []).length);
8532
+ const collapsed = order.filter((d) => !(byDomain[d] || []).length && totals[d]);
8533
+ const rows = expanded.map((d) => `
8534
+ <li class="fleet-group-head"><span>${esc(d)} · ${byDomain[d].length} of ${totals[d] || 0}</span></li>
8535
+ ${byDomain[d].map(agentRowHtml).join('')}`).join('');
8536
+ const tail = collapsed.length
8537
+ ? `<li class="fleet-group-collapsed">${collapsed.map((d) => `<span title="no ${esc(d)} agent is in this view">${esc(d)} · 0 of ${totals[d]}</span>`).join('')}</li>`
8538
+ : '';
8539
+ el.innerHTML = rows + tail;
8540
+ }
8541
+
8542
+ // AgentRow (DESIGN §6.4 / §8.5): slug · posture · last verdict · runs · pass · 30d $.
8543
+ // Numbers that can be summed are tabular and right-aligned; identifiers are not.
8544
+ // Every absence carries its reason on hover.
8545
+ function postureChips(a) {
8546
+ const p = a.posture || {};
8547
+ const out = [];
8548
+ if (p.state === 'expensive') {
8549
+ const first = (p.expensive || [])[0] || 'expensive';
8550
+ const more = (p.expensive || []).length - 1;
8551
+ out.push(`<span class="pos exp" title="${esc((p.expensive || []).join(' · '))}">▲ ${esc(first)}${more > 0 ? ` +${more}` : ''}</span>`);
8552
+ } else if (p.state === 'routine') {
8553
+ out.push('<span class="pos rou" title="every granted tool is routine — cheap to undo">· routine</span>');
8554
+ } else if (p.state === 'unclassified') {
8555
+ out.push(`<span class="pos unj" title="${esc((p.unknownTools || []).join(', ') || 'a tool agent-posture.mjs does not classify')} — unjudged, not harmless">? not classified</span>`);
8556
+ } else if (p.state === 'undeclared') {
8557
+ out.push('<span class="abs" title="the agent declares no tools: line — nothing is granted, nothing is known">— no tools line</span>');
8558
+ } else {
8559
+ out.push(`<span class="abs" title="${esc(p.why || 'the agent file could not be read')}">! unreadable</span>`);
8560
+ }
8561
+ for (const t of (p.scopedInNameOnly || [])) {
8562
+ out.push(`<span class="pos warn" title="scoped in name only: ${esc(t)} — this grant reads as a restriction and is a full shell">⚠ ${esc(String(t).replace(/^Bash\(|\)$/g, ''))}</span>`);
8563
+ }
8564
+ return out.join('');
8565
+ }
8566
+ function agentRowHtml(a) {
8567
+ const rate = a.success_rate;
8568
+ const runs = a.runs_30d ?? 0;
8569
+ const observed = a.runs_total > 0 || !!a.last_run;
8570
+ const last = observed
8571
+ ? (a.health === 'failing' ? `<span style="color:var(--status-blocked)">failing</span> ${esc(agoStr(a.last_run))}` : `<span style="color:var(--status-done)">ok</span> ${esc(agoStr(a.last_run))}`)
8572
+ : '<span class="abs" title="no verdict line names this agent — absence of a log is not absence of a run">never observed</span>';
8573
+ const pass = !observed ? '<span class="abs" title="never observed">—</span>'
8574
+ : runs === 0 ? '<span class="abs" title="no run in this window — measured, none">—</span>'
8575
+ : rate != null ? `${rate}%` : '<span class="abs" title="runs exist but no verdict carried a result yet">·</span>';
8576
+ const spend = a.llm_usd_30d_real != null ? `$${a.llm_usd_30d_real.toFixed(2)}`
8577
+ : a.llm_usd_30d_est != null && a.llm_usd_30d_est > 0 ? `<span title="estimated from time, not from a verdict">≈$${a.llm_usd_30d_est.toFixed(2)}</span>`
8578
+ : `<span class="abs" title="no verdict carries a cost for this agent">—</span>`;
8579
+ const model = a.model_state === 'pinned' && a.model ? esc(a.model)
8580
+ : a.model_state === 'undeclared' ? '<span class="abs" title="declares no model — not pinned to any default">—</span>'
8581
+ : '<span class="abs" title="the agent file could not be read">!</span>';
8582
+ return `<li>
8165
8583
  <button class="agent-row status-${esc(a.health)}${a.retired ? ' retired' : ''}"
8166
8584
  role="button" data-slug="${esc(a.slug)}"
8167
- aria-label="${esc(a.slug)} · ${esc(a.health)} · ${rateLabel} · ${esc(lastRunStr)}${a.budget && a.budget.state !== 'no-limit' ? ' · ' + esc(a.budget.why) : ''}"
8585
+ aria-label="${esc(a.slug)} · ${esc(a.health)} · ${runs} runs 30d"
8168
8586
  onclick="openAgentDrawer('${esc(a.slug)}')"
8169
8587
  onkeydown="if(event.key==='Enter'){event.preventDefault();openAgentDrawer('${esc(a.slug)}')}">
8170
8588
  <span class="ar-status" aria-hidden="true"></span>
8171
8589
  <span class="ar-body">
8172
- <span class="ar-name">${esc(a.slug)}</span>
8173
- <span class="ar-meta">
8174
- <span class="ar-domain">${esc(a.domain)}</span>
8175
- <span>${esc(runsStr)}</span>
8176
- <span>${esc(rateLabel)}</span>
8177
- <span>last: ${esc(lastRunStr)}</span>
8178
- ${savingsStr ? `<span>${esc(savingsStr)}</span>` : ''}
8590
+ <span class="ar-name">${esc(a.slug)} <span class="mono cap muted" style="font-weight:400">${model}</span></span>
8591
+ <span class="ar-cells">
8592
+ <span class="ar-posture">${postureChips(a)}</span>
8593
+ <span class="mono cap ar-last">${last}</span>
8594
+ <span class="mono cap tab" title="runs in the last 30 days">${runs}</span>
8595
+ <span class="mono cap tab" title="pass rate over those runs">${pass}</span>
8596
+ <span class="mono cap tab" title="LLM spend, last 30 days">${spend}</span>
8179
8597
  ${budgetChip(a.budget)}
8180
8598
  ${a.retired ? '<span style="color:var(--agent-retired)">retired</span>' : ''}
8181
8599
  </span>
8182
8600
  </span>
8183
- <span class="ar-rail" aria-hidden="true">
8184
- ${rate != null ? `<span class="ar-rail-fill" style="width:${rate}%"></span>` : ''}
8185
- </span>
8186
8601
  <span class="ar-chev" aria-hidden="true">›</span>
8187
8602
  </button>
8188
8603
  </li>`;
8189
- }).join('');
8190
8604
  }
8191
8605
 
8192
8606
  function renderLegacyAgentsSection() {
@@ -8253,13 +8667,33 @@ async function openAgentDrawer(slug, opts = {}) {
8253
8667
  document.getElementById('agent-drawer-close')?.focus();
8254
8668
  }, 50);
8255
8669
 
8256
- const profile = await api(`/api/agents/${encodeURIComponent(slug)}`);
8670
+ // Agent detail (great_cto-ki1x.9): the profile plus the three facts the
8671
+ // decision needs — its gate tier and eval count, its stand-downs, and whether
8672
+ // the last approval's receipt still describes the tree. Each is its own read
8673
+ // and its own state; one failing must not blank the others.
8674
+ // The profile paints first; the slower facts — tier + evals, stand-downs,
8675
+ // and the receipt, which hashes the tree and takes seconds — arrive on their
8676
+ // own and replace their `· unloaded` cells. A slow read never holds the
8677
+ // drawer, and a failed one is a state on its cell, not a spinner.
8678
+ let profile;
8679
+ try { profile = await api(`/api/agents/${encodeURIComponent(slug)}`); }
8680
+ catch (err) {
8681
+ body.innerHTML = `<div class="cost-empty">Could not load <code>${esc(slug)}</code>: ${esc(String(err && err.message || err))} <button type="button" class="btn-ghost-sm" onclick="openAgentDrawer('${esc(slug)}', { fromHash: true })">Try again</button></div>`;
8682
+ return;
8683
+ }
8257
8684
  if (!profile || profile.error) {
8258
8685
  body.innerHTML = `<div class="cost-empty">Agent <code>${esc(slug)}</code> not found.</div>`;
8259
8686
  return;
8260
8687
  }
8261
8688
  if (fleetState.drawerSlug !== slug) return; // drawer swapped while loading
8262
- renderAgentProfile(profile);
8689
+ const extra = { tier: null, tiersState: null, receipt: null, standDowns: null };
8690
+ renderAgentProfile(profile, extra);
8691
+ const settle = (p, apply) => p.then(apply, () => {}).then(() => { if (fleetState.drawerSlug === slug) renderAgentProfile(profile, extra); });
8692
+ await Promise.all([
8693
+ settle(api(`/api/gate-tiers${pqs()}`), (t) => { extra.tiersState = t; extra.tier = t?.agents?.[slug] || null; }),
8694
+ settle(api(`/api/stand-downs${pqs()}`), (d) => { extra.standDowns = d; }),
8695
+ settle(api(`/api/receipt${pqs()}`), (r) => { extra.receipt = r; }),
8696
+ ]);
8263
8697
  }
8264
8698
 
8265
8699
  function closeAgentDrawer() {
@@ -8282,50 +8716,84 @@ function closeAgentDrawer() {
8282
8716
  document.querySelectorAll('.agent-row').forEach(r => r.setAttribute('aria-current', 'false'));
8283
8717
  }
8284
8718
 
8285
- function renderAgentProfile(p) {
8719
+ function renderAgentProfile(p, extra = {}) {
8286
8720
  const body = document.getElementById('agent-drawer-body');
8287
8721
  const title = document.getElementById('agent-drawer-title');
8288
8722
  const pill = document.getElementById('agent-drawer-pill');
8289
8723
  if (!body) return;
8290
8724
  title.textContent = p.slug;
8725
+ const row = (fleetState.agents || []).find((a) => a.slug === p.slug) || {};
8726
+ const tier = extra.tier;
8727
+ const tierChip = tier
8728
+ ? `<span class="chip mono" style="background:var(--bg-strong);color:var(--text)" title="${esc(tier.why || '')}">${esc(tier.tier)}</span>`
8729
+ : (extra.tiersState && extra.tiersState.enabled === false
8730
+ ? '<span class="abs" title="gate tiering is off for this project">— tiering off</span>'
8731
+ : '<span class="abs" title="no tier computed for this agent — no eval has judged it">? tier</span>');
8291
8732
  pill.innerHTML = `<span class="status-pill tone-${esc(p.health)}">
8292
8733
  <span class="pill-dot" aria-hidden="true"></span>${esc(p.retired ? 'retired' : p.health)}
8293
- </span>`;
8294
-
8295
- const savings = p.savings_x != null ? `${p.savings_x}× ($${(p.llm_usd_30d_est ?? 0).toFixed(2)} vs $${(p.human_usd_30d_est ?? 0).toLocaleString()})` : '—';
8296
- const realCost = p.llm_usd_30d_real != null ? `· measured $${p.llm_usd_30d_real.toFixed(4)}` : '';
8734
+ </span> ${tierChip}`;
8735
+
8736
+ const model = row.model_state === 'pinned' && p.model ? `<span class="mono">${esc(p.model)}</span> <span class="muted">pinned</span>`
8737
+ : row.model_state === 'undeclared' ? '<span class="abs" title="declares no model — not pinned to any default">— no model pinned</span>'
8738
+ : `<span class="mono">${esc(p.model || '')}</span>`;
8739
+ const spend = p.llm_usd_30d_real != null ? `<span class="mono">$${p.llm_usd_30d_real.toFixed(2)}</span> <span class="muted">measured · 30d</span>`
8740
+ : (p.llm_usd_30d_est != null && p.llm_usd_30d_est > 0) ? `<span class="mono">≈$${p.llm_usd_30d_est.toFixed(2)}</span> <span class="muted">estimated · 30d</span>`
8741
+ : '<span class="abs" title="no verdict carries a cost for this agent">— spend unmeasured</span>';
8742
+ const posture = row.posture || {};
8743
+ const strip = (posture.scopedInNameOnly || []).length
8744
+ ? `<div class="strip" style="margin-top:8px" title="a grant that reads as a restriction and is a full shell">⚠ <span><span class="mono">${esc((posture.scopedInNameOnly || []).join(', '))}</span> — scoped in name only: a full shell</span></div>`
8745
+ : '';
8746
+ // Evals: a count of zero is "measured, none"; a missing tier read is "not measured".
8747
+ const evals = tier
8748
+ ? (tier.evals > 0 ? `<span class="mono">${tier.evals}</span> <span class="muted">eval(s) · ${esc(tier.why || '')}</span>` : '<span class="abs" title="measured — no eval names this agent">— none</span>')
8749
+ : '<span class="abs" title="no eval→agent mapping was read — not measured, not zero">· not measured</span>';
8750
+ const sd = extra.standDowns;
8751
+ const sdRows = Array.isArray(sd?.rows) ? sd.rows.filter((r) => !r.agent || r.agent === p.slug) : null;
8752
+ const standDowns = sd == null ? '<span class="abs" title="stand-down log not read">· unloaded</span>'
8753
+ : sd.state === 'unreadable' ? `<span class="abs" title="${esc(sd.why || '')}">! unreadable</span>`
8754
+ : sdRows && sdRows.length ? `<span class="mono">${sdRows.length}</span> <span class="muted">— last ${esc(agoStr(sdRows[sdRows.length - 1].ts || sdRows[sdRows.length - 1].at))}, record reached the inbox</span>`
8755
+ : '<span class="abs" title="measured — no gate stood down for this agent">—</span>';
8756
+ const rc = extra.receipt;
8757
+ const receipt = rc == null ? '<span class="abs" title="receipt not read">· unloaded</span>'
8758
+ : rc.state === 'no-receipt' ? `<span class="abs" title="${esc(rc.why || '')}">— no approval carries a receipt yet</span>`
8759
+ : rc.state === 'unreadable' ? `<span class="abs" title="${esc(rc.why || '')}">! unreadable</span>`
8760
+ : rc.state === 'differs' ? `<span style="color:var(--status-progress)">differs</span> <span class="muted">— ${(rc.changed || []).length} changed, ${(rc.added || []).length} added since ${esc(rc.agent || 'the last')} approval${rc.ts ? ' ' + esc(agoStr(rc.ts)) : ''}</span>`
8761
+ : `<span style="color:var(--status-done)">${esc(rc.state)}</span> <span class="muted">— the approved bytes are the bytes in the tree</span>`;
8297
8762
 
8298
8763
  body.innerHTML = `
8299
8764
  <div class="mp-card">
8300
- <h4>Summary</h4>
8765
+ <h4>Grant <span class="muted" style="font-weight:normal">· hover a chip for what it permits</span></h4>
8766
+ <div class="mono cap" style="color:var(--text2);margin:6px 0 8px">${(p.tools || []).length ? esc(p.tools.join(', ')) : '<span class="abs" title="the agent declares no tools: line">— no tools line</span>'}</div>
8767
+ <div class="ar-posture">${postureChips({ posture })}</div>
8768
+ ${strip}
8769
+ </div>
8770
+ <div class="mp-card">
8301
8771
  <dl class="agent-summary-grid">
8302
- <dt>domain</dt> <dd>${esc(p.domain)}</dd>
8303
- <dt>model</dt> <dd>${esc(p.model)}</dd>
8304
- ${p.applies_to.length ? `<dt>applies_to</dt><dd>${esc(p.applies_to.join(', '))}</dd>` : ''}
8305
- ${(p.tools && p.tools.length) ? `<dt>tools</dt><dd>${p.tools.map(t => `<span class="chip" style="font-size:var(--fs-caption)">${esc(t)}</span>`).join(' ')}</dd>` : '<dt>tools</dt><dd style="color:var(--text3)">none declared</dd>'}
8306
- ${(p.skills && p.skills.length) ? `<dt>skills</dt><dd>${p.skills.map(s => `<span class="chip chip-agent" style="font-size:var(--fs-caption)">${esc(s)}</span>`).join(' ')}</dd>` : ''}
8307
- <dt>runs total</dt> <dd>${p.runs_total} · ${p.runs_30d} in 30d</dd>
8308
- <dt>success</dt> <dd>${p.success_rate != null ? `${p.success_rate}% (${p.ok_30d}/${p.ok_30d + p.fail_30d})` : 'no verdicts in 30d'}</dd>
8309
- <dt>last run</dt> <dd>${p.last_run ? esc(p.last_run.slice(0, 16).replace('T', ' ') + ' · ' + agoStr(p.last_run)) : 'never'}</dd>
8310
- <dt>savings</dt> <dd>${esc(savings)} ${esc(realCost)}</dd>
8772
+ <dt>model</dt> <dd>${model}</dd>
8773
+ <dt>spend</dt> <dd>${spend} ${budgetChip(row.budget)}</dd>
8774
+ <dt>runs there</dt> <dd>Claude Code <span class="muted">· host</span> &nbsp;·&nbsp; Codex <span class="abs" title="skills + MCP only — a role does not run there">·</span></dd>
8775
+ <dt>evals</dt> <dd>${evals}</dd>
8776
+ <dt>stand-downs</dt> <dd>${standDowns}</dd>
8777
+ <dt>receipt</dt> <dd>${receipt}</dd>
8778
+ <dt>prompt</dt> <dd><code style="font-size:var(--fs-caption)">${esc(p.file_path || '')}</code></dd>
8311
8779
  </dl>
8312
8780
  ${p.description ? `<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:8px;line-height:1.5">${esc(p.description)}</div>` : ''}
8313
8781
  </div>
8314
8782
 
8315
8783
  <div class="mp-card">
8316
- <h4>Recent runs <span style="color:var(--text3);font-weight:normal">(last ${p.recent_runs.length})</span></h4>
8784
+ <h4>Runs <span style="color:var(--text3);font-weight:normal">(last ${p.recent_runs.length} · ${p.runs_30d} in 30d${p.success_rate != null ? ` · ${p.success_rate}% pass` : ''})</span></h4>
8317
8785
  ${p.recent_runs.length ? `
8318
8786
  <ul class="run-list" role="list">
8319
8787
  ${p.recent_runs.map(r => {
8320
8788
  const tone = isSuccessVerdict(r.verdict) ? 'ok' : (isFailureVerdict(r.verdict) ? 'fail' : 'neutral');
8321
8789
  return `<li class="run-row">
8322
- <span>${esc((r.ts || '').slice(5, 16).replace('T', ' '))}</span>
8790
+ <span class="mono">${esc((r.ts || '').slice(5, 16).replace('T', ' '))}</span>
8323
8791
  <span class="rr-verdict ${tone}">${esc(r.verdict || '—')}</span>
8324
8792
  <span class="rr-raw" title="${esc(r.raw)}">${esc(r.raw)}</span>
8325
- <span>${r.cost_usd != null ? '$' + r.cost_usd.toFixed(4) : ''}</span>
8793
+ <span class="mono tab">${r.cost_usd != null ? (r.cost_usd > 0 && r.cost_usd < 0.0001 ? '~0' : '$' + r.cost_usd.toFixed(4)) : '<span class="abs" title="this verdict carried no cost">—</span>'}</span>
8326
8794
  </li>`;
8327
8795
  }).join('')}
8328
- </ul>` : '<div class="cost-empty">No recent runs.</div>'}
8796
+ </ul>` : (p.runs_total > 0 ? '<div class="cost-empty">No run in this window.</div>' : '<div class="cost-empty"><span class="abs" title="no verdict line names this agent — absence of a log is not absence of a run">never observed</span></div>')}
8329
8797
  </div>
8330
8798
 
8331
8799
  <div class="mp-card">
@@ -8337,18 +8805,17 @@ function renderAgentProfile(p) {
8337
8805
  <span class="fm-count">×${f.count}</span>
8338
8806
  <span class="fm-last">${esc(agoStr(f.last_seen))}</span>
8339
8807
  </li>`).join('')}
8340
- </ul>` : '<div class="cost-empty">No failures clustered in 30d. Quiet agent.</div>'}
8808
+ </ul>` : `<div class="cost-empty">${p.runs_total > 0 ? '— none in 30d' : '<span class="abs" title="never observed — nothing to cluster">· not measured</span>'}</div>`}
8341
8809
  </div>
8342
8810
 
8343
8811
  <div class="mp-card">
8344
8812
  <h4>Actions</h4>
8345
8813
  <div class="agent-actions">
8814
+ <button type="button" title="Copy the line that re-runs this agent" onclick="copyRerun('${esc(p.slug)}')">Copy re-run</button>
8815
+ <button type="button" title="Caps live in the Ledger's budgets table" onclick="switchTab('ledger')">Raise budget…</button>
8346
8816
  ${p.retired
8347
8817
  ? `<button type="button" onclick="restoreAgentAction('${esc(p.slug)}')">Restore agent</button>`
8348
- : `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent</button>`}
8349
- </div>
8350
- <div style="font-size:var(--fs-caption);color:var(--text3);margin-top:8px">
8351
- File: <code>${esc(p.file_path)}</code>
8818
+ : `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent…</button>`}
8352
8819
  </div>
8353
8820
  </div>`;
8354
8821
  }
@@ -8421,7 +8888,6 @@ function wireFleetKeyboard() {
8421
8888
  fleetState.search = '';
8422
8889
  const input = document.getElementById('fleet-search');
8423
8890
  if (input) input.value = '';
8424
- renderFleetFacets();
8425
8891
  renderFleetList();
8426
8892
  });
8427
8893
  // Drawer close button + backdrop click
@@ -8644,21 +9110,11 @@ let _notifHistory = [];
8644
9110
  let _unreadCount = 0;
8645
9111
  let _emailChannelActive = false; // true when email enabled + verified
8646
9112
 
8647
- function openNotifDrawer() {
8648
- document.getElementById('notif-drawer').style.right = '0';
8649
- document.getElementById('notif-drawer-overlay').style.display = 'block';
8650
- loadNotifHistory();
8651
- }
8652
- function closeNotifDrawer() {
8653
- document.getElementById('notif-drawer').style.right = '-400px';
8654
- document.getElementById('notif-drawer-overlay').style.display = 'none';
8655
- }
8656
9113
  async function loadNotifHistory() {
8657
9114
  try {
8658
9115
  const r = await fetch('/api/notif-history');
8659
9116
  if (!r.ok) return;
8660
9117
  _notifHistory = await r.json();
8661
- renderNotifDrawer();
8662
9118
  const unread = _notifHistory.filter(n => !n.read).length;
8663
9119
  updateNotifBadge(unread);
8664
9120
  renderNotifHistoryInline();
@@ -8667,35 +9123,6 @@ async function loadNotifHistory() {
8667
9123
  function _notifLevelIcon(level) {
8668
9124
  return { info: 'ℹ️', warning: '⚠️', error: '🚨', success: '✅' }[level] || '🔔';
8669
9125
  }
8670
- function renderNotifDrawer() {
8671
- const list = document.getElementById('notif-drawer-list');
8672
- const empty = document.getElementById('notif-drawer-empty');
8673
- if (!list) return;
8674
- if (!_notifHistory.length) {
8675
- list.innerHTML = '';
8676
- if (empty) empty.style.display = 'flex';
8677
- return;
8678
- }
8679
- if (empty) empty.style.display = 'none';
8680
- list.innerHTML = _notifHistory.map(n => `
8681
- <div class="notif-item${n.read ? '' : ' unread'}" data-id="${esc(n.id)}"
8682
- style="padding:12px 16px;border-bottom:1px solid var(--border);
8683
- ${n.read ? '' : 'background:rgba(59,130,246,0.06);'}">
8684
- <div style="display:flex;align-items:flex-start;gap:10px">
8685
- <span style="font-size:var(--fs-title-s);flex-shrink:0;margin-top:1px">${_notifLevelIcon(n.level)}</span>
8686
- <div style="flex:1;min-width:0">
8687
- <div style="font-size:var(--fs-body);font-weight:${n.read ? '400' : '600'};color:var(--text);
8688
- white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(n.title)}</div>
8689
- <div style="font-size:var(--fs-small);color:var(--text3);margin-top:2px;line-height:1.4">${esc(n.body)}</div>
8690
- <div style="font-size:var(--fs-caption);color:var(--text3);margin-top:4px;font-family:var(--mono)">
8691
- ${n.project ? `<span style="color:var(--accent-text);margin-right:6px">${esc(n.project)}</span>` : ''}
8692
- ${new Date(n.ts).toLocaleString()}
8693
- </div>
8694
- </div>
8695
- ${n.read ? '' : '<span style="width:7px;height:7px;background:var(--dot-unread);border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
8696
- </div>
8697
- </div>`).join('');
8698
- }
8699
9126
  function renderNotifHistoryInline() {
8700
9127
  const el = document.getElementById('notif-history-inline');
8701
9128
  const empty = document.getElementById('notif-history-empty');
@@ -8752,7 +9179,6 @@ async function markAllNotifsRead() {
8752
9179
  await fetch('/api/notif-history/read', { method: 'POST',
8753
9180
  headers: {'Content-Type':'application/json'}, body: '{}' });
8754
9181
  _notifHistory.forEach(n => n.read = true);
8755
- renderNotifDrawer();
8756
9182
  renderNotifHistoryInline();
8757
9183
  updateNotifBadge(0);
8758
9184
  } catch(e) {}
@@ -8761,7 +9187,6 @@ function prependNotifLive(notif) {
8761
9187
  _notifHistory.unshift(notif);
8762
9188
  if (_notifHistory.length > 100) _notifHistory.length = 100;
8763
9189
  updateNotifBadge(_unreadCount + 1);
8764
- renderNotifDrawer();
8765
9190
  renderNotifHistoryInline();
8766
9191
  }
8767
9192
 
@@ -8862,29 +9287,5 @@ const _costLbl = document.getElementById('mp-cost-label');
8862
9287
  if (_costLbl) _costLbl.textContent = `Cost · last ${periodLabel(currentPeriod)}`;
8863
9288
  </script>
8864
9289
 
8865
- <!-- ── Notification drawer ──────────────────────────────────────────────── -->
8866
- <div id="notif-drawer" style="position:fixed;top:0;right:-400px;width:380px;height:100vh;
8867
- background:var(--bg-elevated);border-left:1px solid var(--border);z-index:9999;
8868
- transition:right .25s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;
8869
- box-shadow: none;border-bottom:1px solid var(--border);
8870
- display:flex;align-items:center;gap:8px;flex-shrink:0">
8871
- <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
8872
- style="width:16px;height:16px;color:var(--text2)">
8873
- <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"/>
8874
- </svg>
8875
- <span style="font-weight:600;font-size:var(--fs-base);flex:1;color:var(--text)">Notifications</span>
8876
- <button class="btn-ghost-sm" onclick="markAllNotifsRead()"
8877
- style="font-size:var(--fs-small)">Mark all read</button>
8878
- <button onclick="closeNotifDrawer()" style="background:none;border:none;cursor:pointer;
8879
- color:var(--text3);font-size:var(--fs-title-l);line-height:1;padding:0 4px;margin-left:4px">&times;</button>
8880
- </div>
8881
- <div id="notif-drawer-list" tabindex="0" role="region" aria-label="Notifications list" style="flex:1;overflow-y:auto"></div>
8882
- <div id="notif-drawer-empty" style="display:flex;flex:1;align-items:center;
8883
- justify-content:center;flex-direction:column;gap:8px;color:var(--text3);font-size:var(--fs-body)">
8884
- <span style="font-size:var(--fs-num-l)">🔔</span>No notifications yet
8885
- </div>
8886
- </div>
8887
- <div id="notif-drawer-overlay" onclick="closeNotifDrawer()"
8888
- style="display:none;position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.4)"></div>
8889
9290
  </body>
8890
9291
  </html>