great-cto 3.26.4 → 3.27.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/routes.mjs +127 -2
- package/board/packages/board/lib/view-counter.mjs +122 -0
- package/board/packages/board/public/index.html +992 -589
- package/board/scripts/lib/cost-meter.mjs +236 -0
- package/board/scripts/lib/cross-model-review.mjs +225 -0
- package/board/scripts/lib/provider-exhaustion.mjs +152 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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;
|
|
@@ -1439,6 +1457,94 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1439
1457
|
/* Same shape as `.muted`: the `--mono` token exists and is used 115×, the
|
|
1440
1458
|
CLASS that applies it did not. */
|
|
1441
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; }
|
|
1442
1548
|
/* The board has two text inputs styled in place — `#agent-panel input` and
|
|
1443
1549
|
`.fleet-search input[type="search"]` — and a third, the Sessions search box,
|
|
1444
1550
|
that asked for the house look by name and got the user agent's. Same values
|
|
@@ -2225,10 +2331,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2225
2331
|
letter-spacing: 0.1em; text-transform: uppercase;
|
|
2226
2332
|
color: var(--text3); margin: 0 0 12px;
|
|
2227
2333
|
}
|
|
2228
|
-
.mp-hero {
|
|
2229
|
-
display: grid; grid-template-columns: repeat(3, 1fr);
|
|
2230
|
-
gap: 14px; margin-bottom: 24px;
|
|
2231
|
-
}
|
|
2232
2334
|
.window-fill {
|
|
2233
2335
|
font-size: var(--fs-body); color: var(--text2); line-height: 1.5;
|
|
2234
2336
|
padding: 8px 12px; margin-bottom: 12px; border-radius: 0;
|
|
@@ -2315,12 +2417,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2315
2417
|
margin-top: 6px; font-size: var(--fs-small); color: var(--text2);
|
|
2316
2418
|
}
|
|
2317
2419
|
|
|
2318
|
-
.mp-secondary {
|
|
2319
|
-
display: grid; grid-template-columns: repeat(4, 1fr);
|
|
2320
|
-
gap: 12px; margin-bottom: 28px;
|
|
2321
|
-
}
|
|
2322
|
-
.mp-secondary .metric-card { padding: 18px; }
|
|
2323
|
-
.mp-secondary .metric-num { font-size: var(--fs-num-m); }
|
|
2324
2420
|
|
|
2325
2421
|
.mp-row {
|
|
2326
2422
|
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
|
@@ -2482,15 +2578,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2482
2578
|
border-radius: 0; padding: 1px 5px; font-size: var(--fs-eyebrow);
|
|
2483
2579
|
}
|
|
2484
2580
|
|
|
2485
|
-
.fleet-facets { display: flex; flex-direction: column; gap: 6px; }
|
|
2486
|
-
.fleet-facets fieldset {
|
|
2487
|
-
border: none; padding: 0; margin: 0;
|
|
2488
|
-
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
|
2489
|
-
}
|
|
2490
|
-
.fleet-facets legend {
|
|
2491
|
-
font-size: var(--fs-caption); color: var(--text3); text-transform: uppercase;
|
|
2492
|
-
letter-spacing: 0.05em; margin-right: 6px; min-width: 60px;
|
|
2493
|
-
}
|
|
2494
2581
|
.facet-chip {
|
|
2495
2582
|
background: var(--bg-muted); border: 1px solid var(--border);
|
|
2496
2583
|
color: var(--text2); font-size: var(--fs-small); font-family: var(--mono);
|
|
@@ -2940,7 +3027,87 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2940
3027
|
@media (max-width:480px) {
|
|
2941
3028
|
.chip-filter-bar { flex-direction: column; align-items: stretch; }
|
|
2942
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
|
+
/* ── Fidelity pass (v3.27.1): the density the design canvas draws. These come
|
|
3039
|
+
after the rules they override — same specificity, later wins. ── */
|
|
3040
|
+
.metric-card { padding: 14px 16px; }
|
|
3041
|
+
.metric-card::before { display: none; }
|
|
3042
|
+
.metric-num { font-size: var(--fs-num-m); }
|
|
3043
|
+
.metric-label { margin-top: 6px; }
|
|
3044
|
+
.inbox-greet { font-family: var(--sans); font-size: var(--fs-small); color: var(--text2); line-height: 1.5; margin: 0 0 12px; font-weight: 400; letter-spacing: 0; }
|
|
3045
|
+
.inbox-row { padding: 8px 12px; gap: 12px; }
|
|
3046
|
+
.inbox-row .ttl { font-size: var(--fs-small); }
|
|
3047
|
+
/* A stalled task is one line: id · reason · title · agent, and the re-run button
|
|
3048
|
+
sits at the row's right instead of on a second grid row under it. */
|
|
3049
|
+
.inbox-row { grid-template-columns: 110px 1fr auto auto; align-items: center; }
|
|
3050
|
+
.inbox-row .ttl { flex-direction: row; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; }
|
|
3051
|
+
.inbox-row .ttl > :first-child { display: inline; }
|
|
3052
|
+
.inbox-row .ttl .why { display: inline-block; font-size: var(--fs-eyebrow); padding: 0 6px; line-height: 18px; white-space: nowrap; }
|
|
3053
|
+
.inbox-row .ttl .meta { margin-top: 0; }
|
|
3054
|
+
.inbox-row .ttl .ttl-desc { flex-basis: 100%; -webkit-line-clamp: 1; margin-top: 0; }
|
|
3055
|
+
.inbox-row .id { padding-top: 0; }
|
|
3056
|
+
.agent-row .budget-chip { display: block; font-size: var(--fs-eyebrow); line-height: 14px; padding: 0 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 96px; margin-left: auto; }
|
|
3057
|
+
.inbox-section-head h3 { font-size: var(--fs-small); }
|
|
3058
|
+
.fleet-title-row { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-bottom: 12px; }
|
|
3059
|
+
.fleet-controls-row { display: flex; align-items: center; gap: 10px; margin: 0 0 10px; }
|
|
3060
|
+
.fleet-controls-row input[type="search"] { width: 260px; max-width: 100%; background: var(--bg-muted); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: var(--fs-small); padding: 6px 10px; }
|
|
3061
|
+
.fleet-controls-row select { background: var(--bg-muted); border: 1px solid var(--border); color: var(--text); font-family: var(--mono); font-size: var(--fs-small); padding: 5px 8px; }
|
|
3062
|
+
.fleet-list { padding: 0; margin: 0; list-style: none; }
|
|
3063
|
+
.fleet-list > li { margin: 0; }
|
|
3064
|
+
.fleet-head, .agent-row { display: grid; grid-template-columns: 10px minmax(200px, 1fr) minmax(220px, 1.3fr) 130px 48px 56px 100px 16px; gap: 12px; align-items: center; }
|
|
3065
|
+
.fleet-head { padding: 6px 12px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); border-bottom: 1px solid var(--border); }
|
|
3066
|
+
.agent-row { min-height: 36px; padding: 4px 12px; border-radius: 0; border: none; border-bottom: 1px solid var(--border); width: 100%; }
|
|
3067
|
+
.agent-row .ar-name { font-size: var(--fs-small); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
3068
|
+
.agent-row .ar-posture { display: flex; gap: 6px; flex-wrap: nowrap; overflow: hidden; }
|
|
3069
|
+
.agent-row .ar-status { width: 6px; height: 6px; border-radius: 50%; }
|
|
3070
|
+
.agent-row .ar-chev { color: var(--text3); }
|
|
3071
|
+
.budgets-table .bt-desc { display: none; }
|
|
3072
|
+
.mp-card { padding: 14px 16px; }
|
|
3073
|
+
/* ── 768–1199: the sidebar becomes a 56px icon rail (DESIGN §9). Labels leave
|
|
3074
|
+
the screen but not the element — every nav item carries its name as
|
|
3075
|
+
aria-label and title. The Views block and the switcher's text go with the
|
|
3076
|
+
labels; the destinations stay one tap away. ── */
|
|
3077
|
+
@media (min-width: 768px) and (max-width: 1199px) {
|
|
3078
|
+
.sidebar { width: 56px; }
|
|
3079
|
+
.nav-item { justify-content: center; padding: 0; gap: 0; }
|
|
3080
|
+
.nav-item > span { display: none; }
|
|
3081
|
+
.nav-item .count.abs { display: inline; position: absolute; top: 2px; right: 6px; }
|
|
3082
|
+
.nav-item { position: relative; }
|
|
3083
|
+
.nav-divider, .nav-views-label, .nav-views { display: none; }
|
|
3084
|
+
.proj-switch > :not(.proj-arch-icon):not(svg) { display: none; }
|
|
3085
|
+
.proj-switch { justify-content: center; padding: 14px 8px; }
|
|
3086
|
+
#live-label { display: none; }
|
|
3087
|
+
}
|
|
2943
3088
|
@media (max-width: 768px) {
|
|
3089
|
+
.settings-row { grid-template-columns: 1fr; gap: 6px; }
|
|
3090
|
+
.sr-action { justify-content: flex-start; }
|
|
3091
|
+
|
|
3092
|
+
.harness-hosts, .so-options { grid-template-columns: 1fr; }
|
|
3093
|
+
|
|
3094
|
+
.fleet-head { display: none; }
|
|
3095
|
+
.agent-row { grid-template-columns: 10px 1fr auto auto auto 16px; grid-template-areas: "dot name name name name chev" "dot posture posture posture posture chev" "dot last runs pass spend chev"; row-gap: 4px; padding: 8px 12px; }
|
|
3096
|
+
.agent-row .ar-status { grid-area: dot; }
|
|
3097
|
+
.agent-row .ar-name { grid-area: name; white-space: normal; }
|
|
3098
|
+
.agent-row .ar-posture { grid-area: posture; flex-wrap: wrap; }
|
|
3099
|
+
.agent-row .ar-last { grid-area: last; }
|
|
3100
|
+
.agent-row .ar-chev { grid-area: chev; }
|
|
3101
|
+
.fleet-controls-row { flex-wrap: wrap; }
|
|
3102
|
+
.fleet-controls-row input[type="search"] { width: 100%; }
|
|
3103
|
+
|
|
3104
|
+
/* The one asymmetry DESIGN §3.2 draws: an expensive or unclassified gate's
|
|
3105
|
+
Reject/Approve stack full-width at phone width; a routine gate's may sit
|
|
3106
|
+
side by side. Cheap and expensive must not share a motor cost. */
|
|
3107
|
+
.inbox-row .actions.gate-actions-stack { flex-wrap: wrap; flex-direction: column; align-items: stretch; flex: 1 0 100%; }
|
|
3108
|
+
.inbox-row .actions.gate-actions-stack .gate-btn { width: 100%; min-height: 44px; }
|
|
3109
|
+
.gate-review { flex-basis: 100%; }
|
|
3110
|
+
|
|
2944
3111
|
.sidebar {
|
|
2945
3112
|
position: fixed; top: 0; left: 0; bottom: 0;
|
|
2946
3113
|
z-index: 40;
|
|
@@ -2953,7 +3120,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2953
3120
|
focus on the button and the menu unreachable by keyboard. On the way out
|
|
2954
3121
|
it waits for the slide to finish (`0s linear 200ms`), so the panel does
|
|
2955
3122
|
not vanish mid-animation. */
|
|
2956
|
-
transition: transform
|
|
3123
|
+
transition: transform 140ms ease-in, visibility 0s linear 140ms;
|
|
2957
3124
|
box-shadow: none;
|
|
2958
3125
|
}
|
|
2959
3126
|
.sidebar.open {
|
|
@@ -3014,8 +3181,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3014
3181
|
/* Four columns of numbers do not fit in 375px; they were being clipped, which
|
|
3015
3182
|
silently dropped the fourth stat rather than wrapping it. */
|
|
3016
3183
|
.inbox-summary { grid-template-columns: repeat(2, 1fr); }
|
|
3017
|
-
.mp-secondary { grid-template-columns: repeat(2, 1fr); }
|
|
3018
|
-
.mp-hero { grid-template-columns: 1fr; }
|
|
3019
3184
|
.mp-row { grid-template-columns: 1fr; }
|
|
3020
3185
|
}
|
|
3021
3186
|
@media (pointer: coarse) {
|
|
@@ -3045,7 +3210,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3045
3210
|
So: every control gets the floor, and the ones with something to say about
|
|
3046
3211
|
their own size say it after. A button added tomorrow is covered by default;
|
|
3047
3212
|
one that must stay small has to opt out in writing. */
|
|
3048
|
-
button, [role="button"], .nav-item, .toggle, summary { min-height: 44px; }
|
|
3213
|
+
button, [role="button"], .nav-item, .toggle, summary, label.radio, .copy, .so-option, .nav-view-item { min-height: 44px; }
|
|
3049
3214
|
.gate-btn, .inbox-row .actions .gate-btn { height: 44px; font-size: var(--fs-body); }
|
|
3050
3215
|
.menu-btn { width: 44px; height: 44px; }
|
|
3051
3216
|
.btn-bell { min-width: 44px; justify-content: center; }
|
|
@@ -3094,44 +3259,57 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3094
3259
|
<div id="proj-menu-list"></div>
|
|
3095
3260
|
</div>
|
|
3096
3261
|
</div>
|
|
3097
|
-
|
|
3098
|
-
|
|
3262
|
+
<!-- Nav shell cutover (great_cto-ki1x.5): eight tabs (+ a topbar share
|
|
3263
|
+
button and bell) become four destinations. Tasks/kanban, Metrics/
|
|
3264
|
+
dashboard, Docs, Activity/logs, Notifications and Share lose their
|
|
3265
|
+
entry point here — their panels and functions are untouched, so nothing
|
|
3266
|
+
that already linked to `#dashboard`/`switchTab('budgets', …)`/etc. goes
|
|
3267
|
+
dead; a bookmark just stops appearing in the menu. Settings — the
|
|
3268
|
+
future home for the Share/Notifications rows this detaches — is
|
|
3269
|
+
reachable from the topbar gear, not a fifth item here (kept under the
|
|
3270
|
+
≤5 ceiling `ui-ux-pro-max` sets for the same nav to also serve mobile). -->
|
|
3271
|
+
<nav class="nav" id="nav-tablist" role="tablist" aria-orientation="vertical">
|
|
3272
|
+
<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)">
|
|
3099
3273
|
<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>
|
|
3100
|
-
<span>
|
|
3274
|
+
<span>Decisions</span>
|
|
3101
3275
|
<span class="count" id="nav-inbox-count">0</span>
|
|
3102
3276
|
</div>
|
|
3103
|
-
<div class="nav-item" data-tab="
|
|
3104
|
-
<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>
|
|
3105
|
-
<span>Tasks</span>
|
|
3106
|
-
<span class="count" id="nav-task-count">0</span>
|
|
3107
|
-
</div>
|
|
3108
|
-
<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)">
|
|
3109
|
-
<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>
|
|
3110
|
-
<span>Metrics</span>
|
|
3111
|
-
</div>
|
|
3112
|
-
<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)">
|
|
3113
|
-
<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>
|
|
3114
|
-
<span>Docs</span>
|
|
3115
|
-
<span class="count" id="nav-docs-count">0</span>
|
|
3116
|
-
</div>
|
|
3117
|
-
<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)">
|
|
3118
|
-
<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>
|
|
3119
|
-
<span>Activity</span>
|
|
3120
|
-
<span class="count" id="nav-logs-count">0</span>
|
|
3121
|
-
</div>
|
|
3122
|
-
<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)">
|
|
3277
|
+
<div class="nav-item" data-tab="ledger" role="tab" tabindex="0" aria-label="Ledger" title="Ledger" onkeydown="navTabKeydown(event, this)" onclick="switchTab('ledger', this)">
|
|
3123
3278
|
<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>
|
|
3124
|
-
<span>
|
|
3279
|
+
<span>Ledger</span>
|
|
3280
|
+
<!-- Real spend total is Ledger's own content (.7); this keeps the count
|
|
3281
|
+
of agent budget caps the old Budgets tab already computed, rather
|
|
3282
|
+
than inventing a $ figure with nothing behind it yet. -->
|
|
3125
3283
|
<span class="count" id="nav-budget-count"></span>
|
|
3126
3284
|
</div>
|
|
3127
|
-
<div class="nav-item" data-tab="
|
|
3128
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><
|
|
3129
|
-
<span>
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3285
|
+
<div class="nav-item" data-tab="fleet" role="tab" tabindex="0" aria-label="Fleet" title="Fleet" onkeydown="navTabKeydown(event, this)" onclick="switchTab('fleet', this)">
|
|
3286
|
+
<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>
|
|
3287
|
+
<span>Fleet</span>
|
|
3288
|
+
<!-- The absence glyph, not a count (DESIGN §5): something in Fleet is
|
|
3289
|
+
unclassified. Left hidden — the classification read lives in the
|
|
3290
|
+
Fleet screen body (.8/.9), out of this shell task's scope. -->
|
|
3291
|
+
<span class="count abs" id="nav-fleet-abs" hidden title="something in Fleet is unclassified — not a count">!</span>
|
|
3292
|
+
</div>
|
|
3293
|
+
<div class="nav-item" data-tab="harness" role="tab" tabindex="0" aria-label="Harness" title="Harness" onkeydown="navTabKeydown(event, this)" onclick="switchTab('harness', this)">
|
|
3294
|
+
<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>
|
|
3295
|
+
<span>Harness</span>
|
|
3296
|
+
<span class="count" id="nav-harness-count"></span>
|
|
3133
3297
|
</div>
|
|
3134
3298
|
</nav>
|
|
3299
|
+
<div class="nav-divider"></div>
|
|
3300
|
+
<div class="nav-views-label" id="nav-views-label">Views</div>
|
|
3301
|
+
<!-- Saved views over Fleet (DESIGN §5 point 2). Static for this shell —
|
|
3302
|
+
filtering the fleet list is the Fleet screen body's job (.8), not this
|
|
3303
|
+
task's; deliberately not `.nav-item`/`role="tab"`, since they neither
|
|
3304
|
+
switch a top-level destination nor do anything yet, and a control that
|
|
3305
|
+
looks live and isn't fails `ui-ux-pro-max` empty-nav-state. -->
|
|
3306
|
+
<div class="nav-views">
|
|
3307
|
+
<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>
|
|
3308
|
+
<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>
|
|
3309
|
+
<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>
|
|
3310
|
+
<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>
|
|
3311
|
+
<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>
|
|
3312
|
+
</div>
|
|
3135
3313
|
<div class="sidebar-footer">
|
|
3136
3314
|
<!-- Ships in the CONNECTING state. This markup used to say "live · synced
|
|
3137
3315
|
just now" before any connection existed, so from first paint until the
|
|
@@ -3164,15 +3342,14 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3164
3342
|
</div>
|
|
3165
3343
|
<div class="topbar-actions">
|
|
3166
3344
|
<span class="task-count" id="topbar-task-count">0 ISSUES</span>
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
<button class="btn-bell" id="
|
|
3173
|
-
|
|
3174
|
-
<
|
|
3175
|
-
<span class="btn-bell-badge" id="topbar-bell-badge"></span>
|
|
3345
|
+
<!-- Share (topbar button, data-tab="share") and the notification bell
|
|
3346
|
+
(#topbar-bell, opened the drawer) are deleted as top-level surfaces
|
|
3347
|
+
here — great_cto-ki1x.5. Their panels/functions are untouched for
|
|
3348
|
+
.11 to re-wire as Settings rows; nothing here calls them anymore. -->
|
|
3349
|
+
<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>
|
|
3350
|
+
<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;">
|
|
3351
|
+
<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>
|
|
3352
|
+
<span class="btn-label" style="font-size:var(--fs-small);font-weight:500">Settings</span>
|
|
3176
3353
|
</button>
|
|
3177
3354
|
<button class="btn-new" onclick="openNewIssueModal()" aria-label="New issue" title="New issue">
|
|
3178
3355
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 5v14 M5 12h14"/></svg>
|
|
@@ -3184,6 +3361,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3184
3361
|
<!-- Inbox -->
|
|
3185
3362
|
<div class="panel active" id="panel-inbox">
|
|
3186
3363
|
<div class="inbox-page">
|
|
3364
|
+
<!-- Fidelity pass (v3.27.1): the design's header is the screen's name and
|
|
3365
|
+
its count; the sentence the board used to shout in 52px display
|
|
3366
|
+
type is still said, one line, muted, because it carries the stall. -->
|
|
3367
|
+
<div class="panel-head" style="margin-bottom:6px"><h1 class="page-title" style="margin:0">Decisions <span id="decisions-count" class="muted" style="font-weight:400;font-size:var(--fs-title-s)"></span></h1></div>
|
|
3187
3368
|
<div class="inbox-greet" id="inbox-greet">Good morning. Here's what needs your decision.</div>
|
|
3188
3369
|
<!-- Top rung of the evidence ladder: are the bytes that were reviewed
|
|
3189
3370
|
still the bytes in the tree? Hidden while they match — and now only
|
|
@@ -3247,14 +3428,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3247
3428
|
absent Codex must not render as a switch in the off position. The
|
|
3248
3429
|
evidence rows are the tail of .great_cto/cross-review.log: what the
|
|
3249
3430
|
second opinion did, not what it is set to. -->
|
|
3250
|
-
<div class="inbox-section" id="harnesses-section">
|
|
3251
|
-
<div class="inbox-section-head">
|
|
3252
|
-
<span class="dot dot-blue"></span>
|
|
3253
|
-
<h3>Harnesses</h3>
|
|
3254
|
-
<span class="inbox-count" id="harnesses-status">…</span>
|
|
3255
|
-
</div>
|
|
3256
|
-
<div id="harnesses-body" class="muted" style="font-size:13px;line-height:1.5">loading…</div>
|
|
3257
|
-
</div>
|
|
3258
3431
|
</div>
|
|
3259
3432
|
</details>
|
|
3260
3433
|
|
|
@@ -3323,6 +3496,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3323
3496
|
</div>
|
|
3324
3497
|
|
|
3325
3498
|
<!-- Kanban -->
|
|
3499
|
+
<!-- Harness (great_cto-ki1x.5): the second-opinion card used to sit inside
|
|
3500
|
+
Inbox. It is a destination now; .10 draws the full screen around it. -->
|
|
3501
|
+
<div class="panel" id="panel-harness" role="tabpanel">
|
|
3502
|
+
<div class="metrics-page">
|
|
3503
|
+
<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>
|
|
3504
|
+
<!-- Harness (great_cto-ki1x.10). The one decision: is the second opinion
|
|
3505
|
+
actually on for this project. Codex is a status panel, not a switch —
|
|
3506
|
+
the board cannot install it. Four radios, not one toggle, because
|
|
3507
|
+
`undeclared` and `none` are different states. -->
|
|
3508
|
+
<div id="harnesses-body" class="muted" style="font-size:13px;line-height:1.5">loading…</div>
|
|
3509
|
+
<span id="harnesses-status" hidden></span>
|
|
3510
|
+
<!-- The judge (OpenRouter key) and its scores: is anything checking, and
|
|
3511
|
+
what did it find. Moved here from the caps page — a harness fact. -->
|
|
3512
|
+
<div id="judge-status" class="judge-status" hidden style="margin-top:12px"></div>
|
|
3513
|
+
<div id="judge-scores" class="jscores" hidden></div>
|
|
3514
|
+
</div>
|
|
3515
|
+
</div>
|
|
3516
|
+
|
|
3326
3517
|
<div class="panel" id="panel-kanban">
|
|
3327
3518
|
<h1 class="page-title">Tasks</h1>
|
|
3328
3519
|
<div id="filter-bar" class="filter-bar"></div>
|
|
@@ -3331,38 +3522,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3331
3522
|
|
|
3332
3523
|
|
|
3333
3524
|
<!-- Metrics -->
|
|
3334
|
-
<div class="panel" id="panel-dashboard">
|
|
3335
|
-
<div class="metrics-page">
|
|
3336
|
-
<h1 class="page-title">Metrics</h1>
|
|
3337
|
-
<div id="mp-window-fill" class="window-fill" hidden></div>
|
|
3338
|
-
<div class="period-toolbar">
|
|
3339
|
-
<span class="period-label">PERIOD</span>
|
|
3340
|
-
<div class="period-chips" id="period-chips">
|
|
3341
|
-
<button class="period-chip" data-period="1">1D</button>
|
|
3342
|
-
<button class="period-chip" data-period="7">7D</button>
|
|
3343
|
-
<button class="period-chip active" data-period="30">30D</button>
|
|
3344
|
-
<button class="period-chip" data-period="90">90D</button>
|
|
3345
|
-
<button class="period-chip" data-period="365">1Y</button>
|
|
3346
|
-
</div>
|
|
3347
|
-
</div>
|
|
3348
|
-
<div class="mp-section-label" id="mp-sprint-label">Last 30 days</div>
|
|
3349
|
-
<div class="mp-hero" id="mp-hero"></div>
|
|
3350
|
-
<div class="mp-secondary" id="mp-secondary"></div>
|
|
3351
|
-
|
|
3352
|
-
<!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
|
|
3353
|
-
drill-in. A control the operator could not find is a control they do
|
|
3354
|
-
not have — and it was invisible on top of that, hidden whenever a
|
|
3355
|
-
project had declared no caps, which is exactly the project that
|
|
3356
|
-
needs the button to declare one. -->
|
|
3357
|
-
<div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
|
|
3358
|
-
<div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:var(--fs-small);margin-bottom:4px;"></div>
|
|
3359
|
-
|
|
3360
|
-
<div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
|
|
3361
|
-
<div class="mp-card cost-panel" id="cost-panel">
|
|
3362
|
-
<div class="cost-empty">Loading…</div>
|
|
3363
|
-
</div>
|
|
3364
|
-
</div>
|
|
3365
|
-
</div>
|
|
3366
3525
|
|
|
3367
3526
|
<!-- Agents -->
|
|
3368
3527
|
<!-- Session Logs -->
|
|
@@ -3390,36 +3549,26 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3390
3549
|
<div class="panel" id="panel-agents" role="tabpanel" aria-labelledby="tab-agents">
|
|
3391
3550
|
<div class="metrics-page">
|
|
3392
3551
|
<!-- §3.1 Fleet summary -->
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3552
|
+
<!-- The summary tiles ("Installed agents 70", "Active last 30d") left with
|
|
3553
|
+
the redesign: a count of installed agents is not a decision (brief,
|
|
3554
|
+
"What must NOT be on the board"). The view title carries the one
|
|
3555
|
+
number that is — how many need attention, of how many. -->
|
|
3556
|
+
<section aria-label="Fleet summary" class="fleet-title-row">
|
|
3557
|
+
<h1 class="page-title" id="fleet-view-title" style="margin:0">Fleet</h1>
|
|
3398
3558
|
</section>
|
|
3399
3559
|
|
|
3400
3560
|
<!-- §3.1 Fleet controls -->
|
|
3401
|
-
<section aria-label="Fleet controls"
|
|
3402
|
-
<
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
</
|
|
3407
|
-
<
|
|
3408
|
-
|
|
3409
|
-
</
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
<select id="fleet-sort-by">
|
|
3413
|
-
<option value="runs_30d">runs (30d)</option>
|
|
3414
|
-
<option value="last_run">last run</option>
|
|
3415
|
-
<option value="success_rate">success rate</option>
|
|
3416
|
-
<option value="savings_x">savings ×</option>
|
|
3417
|
-
<option value="llm_usd_30d_est">LLM spend (30d)</option>
|
|
3418
|
-
<option value="slug">slug A→Z</option>
|
|
3419
|
-
</select>
|
|
3420
|
-
<button type="button" id="fleet-reset-filters">Reset filters</button>
|
|
3421
|
-
</div>
|
|
3422
|
-
</div>
|
|
3561
|
+
<section aria-label="Fleet controls" class="fleet-controls-row">
|
|
3562
|
+
<input type="search" id="fleet-search" placeholder="Filter agents ( / )" aria-label="Filter agents by slug" />
|
|
3563
|
+
<label for="fleet-sort-by" class="muted cap">Sort</label>
|
|
3564
|
+
<select id="fleet-sort-by">
|
|
3565
|
+
<option value="runs_30d">runs (30d)</option>
|
|
3566
|
+
<option value="last_run">last run</option>
|
|
3567
|
+
<option value="success_rate">success rate</option>
|
|
3568
|
+
<option value="llm_usd_30d_est">LLM spend (30d)</option>
|
|
3569
|
+
<option value="slug">slug A→Z</option>
|
|
3570
|
+
</select>
|
|
3571
|
+
<button type="button" id="fleet-reset-filters" class="btn-ghost-sm">Reset</button>
|
|
3423
3572
|
</section>
|
|
3424
3573
|
|
|
3425
3574
|
<!-- §3.1 Fleet list -->
|
|
@@ -3480,23 +3629,42 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3480
3629
|
|
|
3481
3630
|
<div class="panel" id="panel-budgets" role="tabpanel">
|
|
3482
3631
|
<div class="metrics-page">
|
|
3483
|
-
<h1 class="page-title">
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3632
|
+
<h1 class="page-title">Ledger</h1>
|
|
3633
|
+
<!-- Ledger (great_cto-ki1x.7): the one decision this screen supports is
|
|
3634
|
+
"is something running that I should stop". Two figures, the stage
|
|
3635
|
+
strip and the stuck list answer it; the caps table below is where
|
|
3636
|
+
the money is stopped. -->
|
|
3637
|
+
<div id="ledger-head" class="ledger-head"><div class="muted">loading…</div></div>
|
|
3638
|
+
<!-- From the old Metrics tab (great_cto-ki1x.16): the period chips, the caps
|
|
3639
|
+
card and the cost panel keep their ids — the code that fills them did
|
|
3640
|
+
not move, only the screen that shows them. -->
|
|
3641
|
+
<div class="period-toolbar">
|
|
3642
|
+
<span class="period-label">PERIOD</span>
|
|
3643
|
+
<div class="period-chips" id="period-chips">
|
|
3644
|
+
<button class="period-chip" data-period="1">1D</button>
|
|
3645
|
+
<button class="period-chip" data-period="7">7D</button>
|
|
3646
|
+
<button class="period-chip active" data-period="30">30D</button>
|
|
3647
|
+
<button class="period-chip" data-period="90">90D</button>
|
|
3648
|
+
<button class="period-chip" data-period="365">1Y</button>
|
|
3649
|
+
</div>
|
|
3650
|
+
</div>
|
|
3651
|
+
<!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
|
|
3652
|
+
drill-in. A control the operator could not find is a control they do
|
|
3653
|
+
not have — and it was invisible on top of that, hidden whenever a
|
|
3654
|
+
project had declared no caps, which is exactly the project that
|
|
3655
|
+
needs the button to declare one. -->
|
|
3656
|
+
<div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
|
|
3657
|
+
<div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:var(--fs-small);margin-bottom:4px;"></div>
|
|
3658
|
+
<!-- The old cost panel (four tiles: 'vs human team', a projected month, a
|
|
3659
|
+
daily burn, an empty chart) left in the fidelity pass — a forecast and a
|
|
3660
|
+
human-team comparison are two of the brief's refusals, and the
|
|
3661
|
+
Ledger head already carries spend with its provenance. -->
|
|
3662
|
+
|
|
3490
3663
|
<!-- Whether the judge is connected at all. Without a key nothing breaks
|
|
3491
3664
|
— every stage is scored `unverifiable`, honestly — but "not
|
|
3492
3665
|
connected" and "nothing to check" read the same from here, and the
|
|
3493
3666
|
key is not discoverable: the README names OpenRouter once without
|
|
3494
3667
|
saying where it goes. -->
|
|
3495
|
-
<div id="judge-status" class="judge-status" hidden></div>
|
|
3496
|
-
<!-- The verdicts of the verifier itself. Kept beside the connection
|
|
3497
|
-
status because the two answer consecutive questions: is anything
|
|
3498
|
-
checking, and what did it find. -->
|
|
3499
|
-
<div id="judge-scores" class="jscores" hidden></div>
|
|
3500
3668
|
<div id="budgets-page-body">loading…</div>
|
|
3501
3669
|
</div>
|
|
3502
3670
|
</div>
|
|
@@ -3529,7 +3697,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3529
3697
|
</div>
|
|
3530
3698
|
|
|
3531
3699
|
<!-- Share -->
|
|
3532
|
-
|
|
3700
|
+
<!-- Settings (great_cto-ki1x.5): everything that writes to PROJECT.md or
|
|
3701
|
+
~/.great_cto. The old Share and Notifications tabs are gone as destinations;
|
|
3702
|
+
their controls live here as rows until .11 redraws them. Their ids are kept
|
|
3703
|
+
(not their .panel class) so every function that renders into them still
|
|
3704
|
+
finds its element. -->
|
|
3705
|
+
<div class="panel" id="panel-settings" role="tabpanel">
|
|
3706
|
+
<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>
|
|
3707
|
+
<div id="settings-rows" class="settings-rows"><div class="muted">loading…</div></div>
|
|
3708
|
+
<div id="panel-share">
|
|
3533
3709
|
<div class="share-page">
|
|
3534
3710
|
<div class="share-card">
|
|
3535
3711
|
<h2>Share this project</h2>
|
|
@@ -3566,10 +3742,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3566
3742
|
</div>
|
|
3567
3743
|
</div>
|
|
3568
3744
|
</div>
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
<!-- Notifications -->
|
|
3572
|
-
<div class="panel" id="panel-notifications">
|
|
3745
|
+
</div>
|
|
3746
|
+
<div id="panel-notifications">
|
|
3573
3747
|
<div class="share-page">
|
|
3574
3748
|
<h1 class="page-title">Notifications</h1>
|
|
3575
3749
|
<div class="share-card">
|
|
@@ -3666,6 +3840,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3666
3840
|
</div>
|
|
3667
3841
|
</div>
|
|
3668
3842
|
</div>
|
|
3843
|
+
</div>
|
|
3669
3844
|
</div>
|
|
3670
3845
|
|
|
3671
3846
|
<!-- Task side panel — slide-over detail view, opened by openSide() -->
|
|
@@ -3767,7 +3942,53 @@ function elsewhereClause(e) {
|
|
|
3767
3942
|
return { known: true, text: `${parts.join(' and ')} in ${m} other project${m > 1 ? 's' : ''} ${verb} waiting on you.` };
|
|
3768
3943
|
}
|
|
3769
3944
|
|
|
3945
|
+
// gate id → reversibilityOf() as served on the inbox row; read by gateAction and
|
|
3946
|
+
// approveConsequence so a gate clicked from anywhere (row, palette) gets the
|
|
3947
|
+
// ritual its cost of undo demands.
|
|
3948
|
+
const GATE_REVERSIBILITY = {};
|
|
3949
|
+
// BRD-R3 ordering: expensive first, then unclassified — sorted WITH the expensive
|
|
3950
|
+
// group, never with routine, because "nobody judged this" is not evidence of
|
|
3951
|
+
// cheapness — then routine; inside a group, longest-waiting first.
|
|
3952
|
+
const REV_RANK = { expensive: 0, unclassified: 1, routine: 2 };
|
|
3953
|
+
function sortGatesByCostOfUndo(gates) {
|
|
3954
|
+
return (gates || []).slice().sort((a, b) => {
|
|
3955
|
+
const ra = REV_RANK[a?.reversibility?.state] ?? 1, rb = REV_RANK[b?.reversibility?.state] ?? 1;
|
|
3956
|
+
if (ra !== rb) return ra - rb;
|
|
3957
|
+
return (ageHours(b.updated_at) ?? -1) - (ageHours(a.updated_at) ?? -1);
|
|
3958
|
+
});
|
|
3959
|
+
}
|
|
3960
|
+
// The second opinion for the tree at HEAD, as /api/inbox resolved it — one
|
|
3961
|
+
// value for every pending gate, four states, none a verdict unless `ok`.
|
|
3962
|
+
let inboxSecondOpinion = null;
|
|
3963
|
+
function reviewerCells(t) {
|
|
3964
|
+
// Claude: the pipeline's own reviewer raised this gate — the gate exists
|
|
3965
|
+
// because a stage finished and asked for a human. `agent` names the stage
|
|
3966
|
+
// when the task carries one; the cell stays hollow (in flight) until the
|
|
3967
|
+
// operator decides, because the decision IS the settled state.
|
|
3968
|
+
const raised = t.agent && t.agent !== 'gate' ? esc(t.agent) : 'pipeline';
|
|
3969
|
+
const claude = `<span title="raised by ${raised} — waiting on you"><span class="dot run"></span> Claude</span>`;
|
|
3970
|
+
const so = inboxSecondOpinion;
|
|
3971
|
+
let codex;
|
|
3972
|
+
if (!so || so.state === 'not-run') codex = `<span class="abs" title="${esc(so?.why || 'second opinion not run')}">— Codex</span>`;
|
|
3973
|
+
else if (so.state === 'unmeasured') codex = `<span class="abs" title="${esc(so.why)}">· Codex</span>`;
|
|
3974
|
+
else if (so.state === 'unreadable') codex = `<span class="abs" title="${esc(so.why)}">! Codex</span>`;
|
|
3975
|
+
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>`;
|
|
3976
|
+
else codex = `<span title="Codex ${esc(so.verdict || 'PASS')} on ${esc((so.sha || '').slice(0, 8))}"><span class="dot ok"></span> Codex ✓</span>`;
|
|
3977
|
+
return `<span class="gate-review">${claude}${codex}</span>`;
|
|
3978
|
+
}
|
|
3979
|
+
function gateChip(t) {
|
|
3980
|
+
const r = t.reversibility || { state: 'unclassified', gate: '', why: 'no reversibility on this row' };
|
|
3981
|
+
const name = r.gate ? `gate:${r.gate}` : (String(t.title || '').match(/gate:[a-z-]+/) || ['gate'])[0];
|
|
3982
|
+
const cls = r.state === 'expensive' ? 'exp' : r.state === 'routine' ? 'rou' : 'unc';
|
|
3983
|
+
const glyph = r.state === 'expensive' ? '▲' : r.state === 'routine' ? '▢' : '?';
|
|
3984
|
+
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.`;
|
|
3985
|
+
return `<span class="gate ${cls}" title="${esc(why)}">${glyph} ${esc(name)}</span>`;
|
|
3986
|
+
}
|
|
3770
3987
|
function renderInbox(d) {
|
|
3988
|
+
inboxSecondOpinion = d?.second_opinion || null;
|
|
3989
|
+
{ const c = document.getElementById('decisions-count'); const n = (d?.pending_gates || []).length; if (c) c.textContent = n ? `${n} waiting` : 'nothing waiting'; }
|
|
3990
|
+
for (const g of (d?.pending_gates || [])) if (g?.id && g.reversibility) GATE_REVERSIBILITY[g.id] = g.reversibility;
|
|
3991
|
+
if (d && Array.isArray(d.pending_gates)) d = { ...d, pending_gates: sortGatesByCostOfUndo(d.pending_gates) };
|
|
3771
3992
|
// Kept so renderResume can ask what this section is already showing, rather
|
|
3772
3993
|
// than the two of them rendering the same task independently.
|
|
3773
3994
|
window.__inboxData = d;
|
|
@@ -4337,10 +4558,11 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
4337
4558
|
+ `<button type="button" class="gate-btn" title="Copy the line that re-runs this agent"`
|
|
4338
4559
|
+ ` onclick="event.stopPropagation(); copyRerun('${esc(t.agent)}')">Copy re-run</button></div>`
|
|
4339
4560
|
: '';
|
|
4561
|
+
const guardedGate = canDecide && t.reversibility && t.reversibility.state !== 'routine';
|
|
4340
4562
|
const actions = canDecide
|
|
4341
|
-
? `<div class="actions" onclick="event.stopPropagation()">
|
|
4342
|
-
<button class="gate-btn gate-
|
|
4343
|
-
<button class="gate-btn gate-
|
|
4563
|
+
? `<div class="actions${guardedGate ? ' gate-actions-stack' : ''}" onclick="event.stopPropagation()">
|
|
4564
|
+
<button class="gate-btn gate-reject" onclick="event.stopPropagation(); gateAction('${esc(t.id)}', 'reject')">Reject…</button>
|
|
4565
|
+
<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>
|
|
4344
4566
|
</div>`
|
|
4345
4567
|
// Age is promoted out of the metadata run into its own column, because it
|
|
4346
4568
|
// is the only field that differs between these rows. `status` stays,
|
|
@@ -4382,8 +4604,9 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
4382
4604
|
: '';
|
|
4383
4605
|
return `
|
|
4384
4606
|
<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, "'")})'>
|
|
4385
|
-
|
|
4386
|
-
<span class="ttl">${reasonChip}${esc(t.title)}${desc}<span class="meta">${meta}</span>${tierNote}</span>
|
|
4607
|
+
${canDecide ? gateChip(t) : `<span class="id">${esc(t.id || '')}</span>`}
|
|
4608
|
+
<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>
|
|
4609
|
+
${canDecide ? reviewerCells(t) : ''}
|
|
4387
4610
|
${actions}${rerun}${diffBtn}
|
|
4388
4611
|
</div>`;
|
|
4389
4612
|
}).join('');
|
|
@@ -4599,7 +4822,7 @@ async function loadJudgeStatus() {
|
|
|
4599
4822
|
el.hidden = false;
|
|
4600
4823
|
el.innerHTML = connected
|
|
4601
4824
|
? `<span class="js-dot"></span>
|
|
4602
|
-
<span><strong>Judge
|
|
4825
|
+
<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>
|
|
4603
4826
|
<span class="js-where">${esc(s.fingerprint || '')} · from ${esc(String(s.from || '').replace(/^.*\/(?=\.great_cto)/, '~/'))}</span>
|
|
4604
4827
|
<span class="js-form">
|
|
4605
4828
|
<input id="judge-key" type="password" autocomplete="off" spellcheck="false" placeholder="replace key (sk-or-…)">
|
|
@@ -4705,6 +4928,171 @@ async function saveJudgeKey() {
|
|
|
4705
4928
|
loadJudgeStatus();
|
|
4706
4929
|
}
|
|
4707
4930
|
|
|
4931
|
+
// Ledger head (great_cto-ki1x.7). Every figure names its state: spend is
|
|
4932
|
+
// `measured` only when a verdict carried a cost, otherwise `estimated` and
|
|
4933
|
+
// labelled so; a cap that is not set says "no cap", never $0; a stage is a
|
|
4934
|
+
// filled dot when done, hollow when active, red when failed, bare when idle;
|
|
4935
|
+
// a stuck task with no readable age is counted and shown as `?`, never dropped.
|
|
4936
|
+
function renderLedgerHead({ heartbeat, pipeline, cost, metrics }) {
|
|
4937
|
+
const el = document.getElementById('ledger-head');
|
|
4938
|
+
if (!el) return;
|
|
4939
|
+
// Spend, three states, and $0.00 is only one of them (carried over from the
|
|
4940
|
+
// Metrics tab this replaced): a measured figure; a time-based ESTIMATE that
|
|
4941
|
+
// says so; and a zero that is the estimate with nothing to estimate from,
|
|
4942
|
+
// which is not a figure at all.
|
|
4943
|
+
const costKnown = !!(metrics && metrics.cost);
|
|
4944
|
+
const realLlmUsd = metrics?.cost?.real_llm_usd ?? null;
|
|
4945
|
+
const llmUsd = metrics?.cost?.llm_usd ?? null;
|
|
4946
|
+
const spent = realLlmUsd != null ? realLlmUsd : llmUsd;
|
|
4947
|
+
const spentState = realLlmUsd != null ? 'measured' : (llmUsd != null ? 'estimated' : 'unmeasured');
|
|
4948
|
+
const cap = cost?.monthly_budget;
|
|
4949
|
+
const pct = (spent != null && cap) ? Math.min(100, Math.round(spent / cap * 100)) : null;
|
|
4950
|
+
const spentCell = !costKnown
|
|
4951
|
+
? absent('unloaded', 'the metrics payload carried no cost section')
|
|
4952
|
+
: (realLlmUsd === 0 && llmUsd === 0) || spent == null
|
|
4953
|
+
? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
4954
|
+
: `$${spent < 10 ? spent.toFixed(2) : fmtMoney(spent)}`;
|
|
4955
|
+
// Tasks done: a zero is a count; only a payload with no tasks section is an absence.
|
|
4956
|
+
const doneKnown = !!(metrics && metrics.tasks);
|
|
4957
|
+
const done = (metrics?.tasks?.done_in_window || metrics?.tasks?.done) ?? 0;
|
|
4958
|
+
const acc = metrics?.acceptance || {};
|
|
4959
|
+
const avgMin = metrics?.avg_completion_min;
|
|
4960
|
+
// The same tiles the Metrics tab drew, as facts on one line. Each keeps its
|
|
4961
|
+
// three-way distinction: a zero is a count, an absence names its kind.
|
|
4962
|
+
const facts = [
|
|
4963
|
+
{ label: 'Done', v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'), sub: 'in window' },
|
|
4964
|
+
// A dash here means "could not be computed", not "took no time".
|
|
4965
|
+
{ label: 'Cycle', v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'min median' : '' },
|
|
4966
|
+
{ 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' : '' },
|
|
4967
|
+
];
|
|
4968
|
+
// Fleet 30d spend from verdicts — measured for some agents, or not at all.
|
|
4969
|
+
const s = fleetState?.summary || null;
|
|
4970
|
+
const fleetSpend = s == null ? absent('unloaded', 'the fleet summary has not been read')
|
|
4971
|
+
: 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>`
|
|
4972
|
+
: absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`);
|
|
4973
|
+
facts.push({ label: 'Fleet 30d', v: fleetSpend, sub: '' });
|
|
4974
|
+
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>`;
|
|
4975
|
+
const stages = Array.isArray(pipeline) ? pipeline : [];
|
|
4976
|
+
const running = stages.filter((st) => st.status === 'active');
|
|
4977
|
+
const dotFor = (st) => st.status === 'done' ? 'dot ok' : st.status === 'active' ? 'dot run' : st.status === 'failed' ? 'dot bad' : 'dot';
|
|
4978
|
+
const strip = stages.length
|
|
4979
|
+
? 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('')
|
|
4980
|
+
: `<span class="abs" title="/api/pipeline returned nothing — stage state unknown, not idle">· pipeline state unloaded</span>`;
|
|
4981
|
+
const stuck = Array.isArray(heartbeat?.stuck) ? heartbeat.stuck : [];
|
|
4982
|
+
const unmeasurable = Number(heartbeat?.stuck_unmeasurable) || 0;
|
|
4983
|
+
const stuckRows = stuck.slice(0, 5).map((t) => `
|
|
4984
|
+
<div class="ledger-stuck-row">
|
|
4985
|
+
<span class="mono">${esc(t.id || '')}</span>
|
|
4986
|
+
<span class="ttl-desc">${esc(t.title || '')}</span>
|
|
4987
|
+
<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>
|
|
4988
|
+
</div>`).join('');
|
|
4989
|
+
el.innerHTML = `
|
|
4990
|
+
<div class="ledger-kpis">
|
|
4991
|
+
<div class="metric-card">
|
|
4992
|
+
<div class="metric-num">${spentCell}</div>
|
|
4993
|
+
<div class="metric-label">Spent · ${esc(spentState)}</div>
|
|
4994
|
+
<div class="metric-trend">${capCell}</div>
|
|
4995
|
+
${pct != null ? `<div class="bar" style="margin-top:8px"><i style="width:${pct}%${pct >= 100 ? ';background:var(--status-blocked)' : ''}"></i></div>` : ''}
|
|
4996
|
+
</div>
|
|
4997
|
+
<div class="metric-card">
|
|
4998
|
+
<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>
|
|
4999
|
+
<div class="metric-label">Running now</div>
|
|
5000
|
+
<div class="metric-trend">${running.length ? esc(running.map((st) => st.stage).join(' · ')) : (Array.isArray(pipeline) ? 'nothing active' : '')}</div>
|
|
5001
|
+
</div>
|
|
5002
|
+
</div>
|
|
5003
|
+
<div class="ledger-facts">
|
|
5004
|
+
${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('')}
|
|
5005
|
+
</div>
|
|
5006
|
+
<div class="ledger-strip">${strip}</div>
|
|
5007
|
+
<div class="ledger-stuck">
|
|
5008
|
+
<div class="ledger-stuck-head">
|
|
5009
|
+
<span class="eyebrow">Stuck · in progress over the line</span>
|
|
5010
|
+
<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>
|
|
5011
|
+
</div>
|
|
5012
|
+
${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>`}
|
|
5013
|
+
</div>`;
|
|
5014
|
+
}
|
|
5015
|
+
|
|
5016
|
+
// Settings (great_cto-ki1x.11). A row is a fact and the file that holds it;
|
|
5017
|
+
// what writes elsewhere links to the screen that owns the write (caps → Ledger,
|
|
5018
|
+
// second opinion and judge → Harness). Nothing here is a toggle for a fact the
|
|
5019
|
+
// board cannot change: the Codex binary and telemetry are read-only rows.
|
|
5020
|
+
async function loadSettingsPage() {
|
|
5021
|
+
const el = document.getElementById('settings-rows');
|
|
5022
|
+
if (!el) return;
|
|
5023
|
+
const get = (u) => api(u).catch(() => null);
|
|
5024
|
+
const [tiers, share, harn, projects, cost] = await Promise.all([
|
|
5025
|
+
get(`/api/gate-tiers${pqs()}`), get(`/api/share${pqs()}`), get(`/api/harnesses${pqs()}`), get('/api/projects'), get(`/api/cost${pqsd()}`),
|
|
5026
|
+
]);
|
|
5027
|
+
const row = (name, file, value, action) => `
|
|
5028
|
+
<div class="settings-row">
|
|
5029
|
+
<div class="sr-name"><span>${name}</span><span class="cap muted mono">${esc(file)}</span></div>
|
|
5030
|
+
<div class="sr-value">${value}</div>
|
|
5031
|
+
<div class="sr-action">${action || ''}</div>
|
|
5032
|
+
</div>`;
|
|
5033
|
+
const link = (label, fn) => `<button type="button" class="btn-ghost-sm" onclick="${fn}">${label}</button>`;
|
|
5034
|
+
const so = harn?.second_opinion || null;
|
|
5035
|
+
const cx = harn?.codex || null;
|
|
5036
|
+
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>';
|
|
5037
|
+
const tiering = tiers == null ? '<span class="abs" title="gate-tiers not read">· unloaded</span>'
|
|
5038
|
+
: tiers.enabled === false ? '<span class="mono">off</span> <span class="muted">— every gate waits for a human</span>'
|
|
5039
|
+
: `<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>`;
|
|
5040
|
+
const shareV = share == null ? '<span class="abs" title="share state not read">· unloaded</span>'
|
|
5041
|
+
: 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>`
|
|
5042
|
+
: '<span class="mono">off</span> <span class="muted">— gate confirms never claim a public report</span>';
|
|
5043
|
+
const soV = so == null ? '<span class="abs" title="harness state not read">· unloaded</span>'
|
|
5044
|
+
: `<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>`;
|
|
5045
|
+
const codexV = cx == null ? '<span class="abs" title="harness state not read">· unloaded</span>'
|
|
5046
|
+
: cx.state === 'available' ? `<span class="mono">codex</span> <span class="muted">· on PATH · ${esc(cx.auth || 'authenticated')} · ${esc(cx.version || '')}</span>`
|
|
5047
|
+
: cx.state === 'no-auth' ? '<span class="mono">codex</span> <span style="color:var(--status-blocked)">· not logged in</span>'
|
|
5048
|
+
: `<span class="abs" title="${esc(cx.why || '')}">— absent</span>`;
|
|
5049
|
+
const plist = Array.isArray(projects) ? projects : (projects?.projects || null);
|
|
5050
|
+
const projRows = plist == null ? '<div class="abs" title="the project registry could not be read">! registry unreadable</div>'
|
|
5051
|
+
: plist.slice(0, 40).map((pr) => `
|
|
5052
|
+
<div class="settings-row">
|
|
5053
|
+
<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>
|
|
5054
|
+
<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>
|
|
5055
|
+
<div class="sr-action">${pr.slug === currentProject ? '' : link('switch', `switchProject('${esc(pr.slug)}')`)}</div>
|
|
5056
|
+
</div>`).join('');
|
|
5057
|
+
const theme = document.documentElement.dataset.theme || 'dark';
|
|
5058
|
+
el.innerHTML = `
|
|
5059
|
+
<div class="settings-group">
|
|
5060
|
+
<div class="eyebrow">Project · .great_cto/PROJECT.md</div>
|
|
5061
|
+
${row('Budget cap', 'agent-budgets · monthly, USD', cap, link('Ledger', "switchTab('ledger')"))}
|
|
5062
|
+
${row('Second opinion', 'capabilities.second_opinion', soV, link('Harness', "switchTab('harness')"))}
|
|
5063
|
+
${row('Gate tiering', 'gate-tiering', tiering, '')}
|
|
5064
|
+
${row('Share', 'promote / public report', shareV, link('below', "document.getElementById('panel-share').scrollIntoView({behavior:'smooth'})"))}
|
|
5065
|
+
</div>
|
|
5066
|
+
<div class="settings-group">
|
|
5067
|
+
<div class="eyebrow">Machine · ~/.great_cto</div>
|
|
5068
|
+
${row('Judge key', 'secrets.env · OPENROUTER_API_KEY', '<span class="muted">stored and replaced on the Harness screen</span>', link('Harness', "switchTab('harness')"))}
|
|
5069
|
+
${row('Codex binary', 'GREAT_CTO_CODEX_BIN', codexV, '')}
|
|
5070
|
+
${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>')}
|
|
5071
|
+
${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>`, '')}
|
|
5072
|
+
</div>
|
|
5073
|
+
<div class="settings-group">
|
|
5074
|
+
<div class="eyebrow">Projects · ~/.great_cto/projects.json · ${plist ? plist.length + ' registered' : ''}</div>
|
|
5075
|
+
${projRows}
|
|
5076
|
+
<div class="settings-row" style="border-top:none">
|
|
5077
|
+
<div class="sr-name"><span class="muted cap">Paths must live inside HOME — /tmp and /etc are refused.</span></div>
|
|
5078
|
+
<div class="sr-value"><input id="settings-register-path" class="input" placeholder="~/path/to/project" style="width:100%"></div>
|
|
5079
|
+
<div class="sr-action">${link('Register', 'registerProjectFromSettings()')}</div>
|
|
5080
|
+
</div>
|
|
5081
|
+
</div>
|
|
5082
|
+
<div class="eyebrow" style="margin:18px 0 6px">Alerts · the channel delivers, the board does not</div>`;
|
|
5083
|
+
}
|
|
5084
|
+
async function registerProjectFromSettings() {
|
|
5085
|
+
const inp = document.getElementById('settings-register-path');
|
|
5086
|
+
const v = (inp?.value || '').trim();
|
|
5087
|
+
if (!v) return;
|
|
5088
|
+
try {
|
|
5089
|
+
const r = await api('/api/projects/register', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ path: v }) });
|
|
5090
|
+
if (r && r.error) { showToast(`Not registered: ${r.error}`, 'error', 3000); return; }
|
|
5091
|
+
showToast('Registered', 'info', 1500);
|
|
5092
|
+
loadSettingsPage();
|
|
5093
|
+
} catch (e) { showToast(`Not registered: ${String(e.message || e)}`, 'error', 3000); }
|
|
5094
|
+
}
|
|
5095
|
+
|
|
4708
5096
|
async function loadBudgetsPage() {
|
|
4709
5097
|
loadJudgeStatus();
|
|
4710
5098
|
loadJudgeScores();
|
|
@@ -4720,13 +5108,17 @@ async function loadBudgetsPage() {
|
|
|
4720
5108
|
// promise rejected, and the page sat there looking busy. A page that failed
|
|
4721
5109
|
// and a page still working are the same picture, and the one you are looking
|
|
4722
5110
|
// at is the one where nothing more is coming.
|
|
4723
|
-
let h;
|
|
5111
|
+
let h, pipe = null, cost = null, m = null;
|
|
4724
5112
|
try {
|
|
4725
|
-
[h] = await Promise.all([
|
|
5113
|
+
[h, pipe, cost, m] = await Promise.all([
|
|
4726
5114
|
api(`/api/heartbeat${pqs()}`),
|
|
5115
|
+
api(`/api/pipeline${pqs()}`).catch(() => null),
|
|
5116
|
+
api(`/api/cost${pqsd()}`).catch(() => null),
|
|
5117
|
+
api(`/api/metrics${pqsd()}`).catch(() => null),
|
|
4727
5118
|
(typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
|
|
4728
5119
|
? refreshAgentsInstalled() : Promise.resolve(),
|
|
4729
5120
|
]);
|
|
5121
|
+
renderLedgerHead({ heartbeat: h, pipeline: pipe, cost, metrics: m });
|
|
4730
5122
|
} catch (err) {
|
|
4731
5123
|
body.innerHTML = `
|
|
4732
5124
|
<div class="budgets-failed">
|
|
@@ -5247,20 +5639,80 @@ async function renderHarnesses() {
|
|
|
5247
5639
|
hb.style.color = so.state === 'unavailable' ? 'var(--status-blocked)' : '';
|
|
5248
5640
|
hb.style.display = hb.textContent ? '' : 'none';
|
|
5249
5641
|
}
|
|
5642
|
+
// Pre-click consequence per option (DESIGN §3.7 point 3): what choosing it
|
|
5643
|
+
// would resolve to HERE, before Save.
|
|
5644
|
+
let rk = null;
|
|
5645
|
+
try { rk = await api(`/api/router-key${pqs()}`); } catch { rk = null; }
|
|
5646
|
+
const routerKeyPresent = rk && rk.state === 'present';
|
|
5647
|
+
const codexDot = cx.state === 'available' ? 'dot ok' : cx.state === 'no-auth' ? 'dot bad' : 'dot';
|
|
5648
|
+
const codexState = cx.state === 'available' ? `<span style="color:var(--status-done)">detected · ${esc(cx.auth === 'chatgpt' ? 'authenticated' : cx.auth || 'authenticated')}</span>`
|
|
5649
|
+
: cx.state === 'no-auth' ? `<span style="color:var(--status-blocked)">detected · not logged in</span>`
|
|
5650
|
+
: `<span class="abs" title="${esc(cx.why || 'codex is not on PATH')}">— absent</span>`;
|
|
5651
|
+
const consequence = {
|
|
5652
|
+
'': 'PROJECT.md says nothing. A future harness may be picked up.',
|
|
5653
|
+
none: 'deliberately off — recorded as a decision.',
|
|
5654
|
+
codex: cx.state === 'available' ? 'resolves to declared here' : `⚠ would resolve to unavailable here — ${cx.why || 'codex is not on PATH'}`,
|
|
5655
|
+
openrouter: routerKeyPresent ? 'resolves to declared here' : '⚠ would resolve to unavailable here — no key configured',
|
|
5656
|
+
};
|
|
5657
|
+
const current = so.declared && so.declared.state !== 'undeclared' ? (so.declared.tool || (so.declared.state === 'none' ? 'none' : '')) : '';
|
|
5658
|
+
const radio = (val, label) => `
|
|
5659
|
+
<label class="so-option${current === val ? ' on' : ''}" title="${esc(consequence[val] || '')}">
|
|
5660
|
+
<input type="radio" name="so" value="${val}" ${current === val ? 'checked' : ''}>
|
|
5661
|
+
<span class="so-label">${esc(label)}</span>
|
|
5662
|
+
<span class="so-why ${/^⚠/.test(consequence[val] || '') ? 'abs' : 'muted'}">${esc(consequence[val] || '')}</span>
|
|
5663
|
+
</label>`;
|
|
5664
|
+
const paired = sum.reviewed ?? 0;
|
|
5665
|
+
const pairedCell = ev.state === 'absent' ? '<span class="abs" title="no cross-review has run in this project">— none yet</span>'
|
|
5666
|
+
: paired > 0 ? `<span class="mono">${paired}</span> <span class="muted">paired review(s)</span>`
|
|
5667
|
+
: `<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>`;
|
|
5668
|
+
const evRows = (ev.recent || []).slice(0, 8).map((r) => {
|
|
5669
|
+
const when = (r.ts || '').slice(5, 16).replace('T', ' ');
|
|
5670
|
+
const dim = r.state !== 'ok';
|
|
5671
|
+
const verdict = r.state === 'ok'
|
|
5672
|
+
? (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>`
|
|
5673
|
+
: r.state === 'unreadable' ? '<span class="abs" title="predates the join key — cannot be paired with any tree">! unreadable</span>'
|
|
5674
|
+
: `<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>` : ''}`;
|
|
5675
|
+
const cost = r.cost != null ? `$${Number(r.cost).toFixed(4)}` : '<span class="abs" title="no cost — nothing ran">—</span>';
|
|
5676
|
+
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>`;
|
|
5677
|
+
}).join('');
|
|
5678
|
+
const evidenceBlock = ev.state === 'absent'
|
|
5679
|
+
? '<div class="muted cap">no cross-model review has run in this project yet</div>'
|
|
5680
|
+
: ev.state === 'unreadable'
|
|
5681
|
+
? '<div class="abs">! the review log could not be read</div>'
|
|
5682
|
+
: `<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>` : ''}`;
|
|
5683
|
+
|
|
5250
5684
|
body.innerHTML = `
|
|
5251
|
-
<div
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
<div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
|
5255
|
-
<label for="so-select" class="muted">set</label>
|
|
5256
|
-
<select id="so-select">${undecl}${sel}</select>
|
|
5257
|
-
<button id="so-apply" class="btn-ghost-sm">apply</button>
|
|
5258
|
-
<span id="so-result" class="muted"></span>
|
|
5685
|
+
<div class="mp-card harness-hosts">
|
|
5686
|
+
<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>
|
|
5687
|
+
<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>
|
|
5259
5688
|
</div>
|
|
5260
|
-
<div style="margin-top:
|
|
5689
|
+
<div class="mp-card" style="margin-top:12px">
|
|
5690
|
+
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
5691
|
+
<span class="eyebrow">Second opinion</span>
|
|
5692
|
+
<span class="mono cap">${esc(so.declared?.tool || so.declared?.state || 'undeclared')}</span>
|
|
5693
|
+
<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>
|
|
5694
|
+
<span class="cap" style="margin-left:auto">${pairedCell}</span>
|
|
5695
|
+
</div>
|
|
5696
|
+
<div class="so-options">${radio('', 'Undeclared')}${radio('none', 'None')}${radio('codex', 'Codex')}${radio('openrouter', 'OpenRouter')}</div>
|
|
5697
|
+
<div style="display:flex;gap:10px;align-items:center;margin-top:10px">
|
|
5698
|
+
<span id="so-result" class="muted cap"></span>
|
|
5699
|
+
<button id="so-apply" class="btn-black" style="margin-left:auto">Save to PROJECT.md</button>
|
|
5700
|
+
</div>
|
|
5701
|
+
</div>
|
|
5702
|
+
<div class="mp-card" style="margin-top:12px">
|
|
5703
|
+
<div style="display:flex;align-items:baseline;gap:12px;flex-wrap:wrap">
|
|
5704
|
+
<span class="eyebrow">What it did</span>
|
|
5705
|
+
<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>
|
|
5706
|
+
<span class="mono cap muted" style="margin-left:auto">.great_cto/cross-review.log</span>
|
|
5707
|
+
</div>
|
|
5708
|
+
<div class="ev-table">${evidenceBlock}</div>
|
|
5709
|
+
</div>`;
|
|
5710
|
+
// The judge and its scores sit under the harness facts now.
|
|
5711
|
+
if (typeof loadJudgeStatus === 'function') loadJudgeStatus();
|
|
5712
|
+
if (typeof loadJudgeScores === 'function') loadJudgeScores();
|
|
5261
5713
|
|
|
5262
5714
|
document.getElementById('so-apply').onclick = async () => {
|
|
5263
|
-
const v = document.
|
|
5715
|
+
const v = (document.querySelector('input[name="so"]:checked') || {}).value || '';
|
|
5264
5716
|
const out = document.getElementById('so-result');
|
|
5265
5717
|
out.textContent = 'writing PROJECT.md…';
|
|
5266
5718
|
try {
|
|
@@ -5474,8 +5926,9 @@ async function refreshMetricsForPeriod() {
|
|
|
5474
5926
|
if (lbl) lbl.textContent = `Last ${periodLabel(currentPeriod)}`;
|
|
5475
5927
|
// Use seq-guarded fetch so stale init/SSE fetches can't overwrite us
|
|
5476
5928
|
const m = await _refreshMetricsRaced(`/api/metrics${pqsd()}`);
|
|
5477
|
-
if (m) {
|
|
5929
|
+
if (m) { metrics = m; if (typeof renderWindowFill === 'function') renderWindowFill(m); }
|
|
5478
5930
|
refreshCost();
|
|
5931
|
+
if (currentTab === 'budgets') loadBudgetsPage();
|
|
5479
5932
|
}
|
|
5480
5933
|
|
|
5481
5934
|
/* Why four periods can show the same number.
|
|
@@ -5796,7 +6249,7 @@ async function loadDocs() {
|
|
|
5796
6249
|
return;
|
|
5797
6250
|
}
|
|
5798
6251
|
_docsCache = d;
|
|
5799
|
-
document.getElementById('nav-docs-count').textContent = d.total || 0;
|
|
6252
|
+
const navDocsCount = document.getElementById('nav-docs-count'); if (navDocsCount) navDocsCount.textContent = d.total || 0;
|
|
5800
6253
|
|
|
5801
6254
|
const pipeline = d.pipeline
|
|
5802
6255
|
? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
|
|
@@ -6329,7 +6782,6 @@ async function switchProject(slug) {
|
|
|
6329
6782
|
try { renderInbox(inbox); } catch (e) { console.warn('renderInbox failed', e); }
|
|
6330
6783
|
try { renderFilterBar(); } catch (e) { console.warn('renderFilterBar failed', e); }
|
|
6331
6784
|
try { renderKanban(allTasks); } catch (e) { console.warn('renderKanban failed', e); }
|
|
6332
|
-
try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
|
|
6333
6785
|
try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
|
|
6334
6786
|
loadMemory();
|
|
6335
6787
|
refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
|
|
@@ -6518,7 +6970,6 @@ async function init() {
|
|
|
6518
6970
|
try { renderInbox(inbox); } catch (e) { console.warn('renderInbox failed', e); }
|
|
6519
6971
|
try { renderFilterBar(); } catch (e) { console.warn('renderFilterBar failed', e); }
|
|
6520
6972
|
try { renderKanban(allTasks); } catch (e) { console.warn('renderKanban failed', e); }
|
|
6521
|
-
try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
|
|
6522
6973
|
try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
|
|
6523
6974
|
loadMemory();
|
|
6524
6975
|
refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
|
|
@@ -6731,7 +7182,9 @@ function connectSSE() {
|
|
|
6731
7182
|
}
|
|
6732
7183
|
|
|
6733
7184
|
function updateNavCounts() {
|
|
6734
|
-
|
|
7185
|
+
// The Tasks nav item left with the redesign (kanban is a deep link); the
|
|
7186
|
+
// count still feeds the kanban header where that element remains.
|
|
7187
|
+
const navTaskCount = document.getElementById('nav-task-count'); if (navTaskCount) navTaskCount.textContent = allTasks.length;
|
|
6735
7188
|
document.getElementById('topbar-task-count').textContent = `${allTasks.length} ISSUES`;
|
|
6736
7189
|
}
|
|
6737
7190
|
|
|
@@ -6936,13 +7389,23 @@ function cardHTML(t) {
|
|
|
6936
7389
|
// following stage, and — when the share report is on — a public URL is
|
|
6937
7390
|
// republished. That last line is the one worth reading: it leaves the machine,
|
|
6938
7391
|
// and revoking the link later does not un-see what was already served.
|
|
6939
|
-
function approveConsequence(id) {
|
|
7392
|
+
function approveConsequence(id, reversibility) {
|
|
7393
|
+
// The gate's cost of undo is part of the consequence (ADR-009, DESIGN §6.2):
|
|
7394
|
+
// `expensive` names its categories in the words gate-reversibility.mjs uses —
|
|
7395
|
+
// escapes-the-machine, costs-money, destroys-evidence, unrevocable-disclosure;
|
|
7396
|
+
// `unclassified` says so, because an unknown cost is not a low one.
|
|
7397
|
+
const r = reversibility || GATE_REVERSIBILITY[id] || null;
|
|
6940
7398
|
const lines = [
|
|
6941
7399
|
`Approve ${id}?`,
|
|
6942
7400
|
'',
|
|
6943
7401
|
'Records your approval in the verdict log and wakes the pipeline —',
|
|
6944
7402
|
'the next session dispatches the following stage.',
|
|
6945
7403
|
];
|
|
7404
|
+
if (r && r.state === 'expensive') {
|
|
7405
|
+
lines.push('', `Expensive to undo: ${(r.categories || []).join(' · ') || 'see gate-reversibility.mjs'}.`);
|
|
7406
|
+
} else if (r && r.state === 'unclassified') {
|
|
7407
|
+
lines.push('', 'Unclassified gate — its cost of undo is unknown, and unknown is not harmless.');
|
|
7408
|
+
}
|
|
6946
7409
|
if (shareState && shareState.enabled) {
|
|
6947
7410
|
lines.push('', `Also republishes the public report at ${shareState.url || 'the share URL'} —`,
|
|
6948
7411
|
'anyone holding that link sees the update.');
|
|
@@ -6966,7 +7429,18 @@ async function gateAction(id, action, runAgent) {
|
|
|
6966
7429
|
// a typed-name ritual on a button pressed several times a day. The runAgent
|
|
6967
7430
|
// path already shows an editable prompt that says what it will approve and
|
|
6968
7431
|
// run, and is cancellable — a second dialog there would be pure friction.
|
|
6969
|
-
|
|
7432
|
+
// Approve ritual follows cost of undo (DESIGN §6.2). `routine`: one confirm
|
|
7433
|
+
// naming the consequence. `expensive` and `unclassified`: the operator types
|
|
7434
|
+
// the gate name — exact, case-sensitive — before the approval is posted; an
|
|
7435
|
+
// unclassified gate gets the expensive ritual because nobody judged it cheap.
|
|
7436
|
+
const rev = GATE_REVERSIBILITY[id] || null;
|
|
7437
|
+
const guarded = rev && (rev.state === 'expensive' || rev.state === 'unclassified');
|
|
7438
|
+
if (!runAgent && guarded) {
|
|
7439
|
+
const gateName = rev.gate ? `gate:${rev.gate}` : id;
|
|
7440
|
+
const typed = prompt(`${approveConsequence(id, rev)}\n\nType ${gateName} to approve:`, '');
|
|
7441
|
+
if (typed === null) return;
|
|
7442
|
+
if (typed.trim() !== gateName) { alert(`Not approved — "${typed.trim()}" is not ${gateName}.`); return; }
|
|
7443
|
+
} else if (!runAgent && !confirm(approveConsequence(id, rev))) return; // routine
|
|
6970
7444
|
body = { action, project: currentProject };
|
|
6971
7445
|
if (runAgent) {
|
|
6972
7446
|
const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
|
|
@@ -7076,6 +7550,8 @@ const ABSENCE = {
|
|
|
7076
7550
|
none: '—', // measured; the answer is that there is none
|
|
7077
7551
|
uncomputable: 'n/a', // the inputs for this figure do not exist
|
|
7078
7552
|
unloaded: '·', // no answer yet — the read has not landed
|
|
7553
|
+
unreadable: '!', // it was there and could not be parsed
|
|
7554
|
+
unjudged: '?', // it exists and nobody has classified it yet
|
|
7079
7555
|
};
|
|
7080
7556
|
|
|
7081
7557
|
function absent(kind, why) {
|
|
@@ -7112,183 +7588,6 @@ function deltaBadge(current, prev, comparable, { invert = false } = {}) {
|
|
|
7112
7588
|
}
|
|
7113
7589
|
|
|
7114
7590
|
/* ── Dashboard ──────────────────────────────────────────────────────────── */
|
|
7115
|
-
function renderDashboard(m) {
|
|
7116
|
-
const llmUsd = m.cost?.llm_usd ?? 0;
|
|
7117
|
-
const realLlmUsd = m.cost?.real_llm_usd ?? 0;
|
|
7118
|
-
const planSavingsX = m.cost?.savings_x;
|
|
7119
|
-
// Prefer the window-scoped count, but fall back to the all-time total when it
|
|
7120
|
-
// is 0 — otherwise projects with completed-but-untimestamped tasks (e.g. a
|
|
7121
|
-
// tasks.md fallback with no dates) show a meaningless "—" despite real work.
|
|
7122
|
-
// (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
|
|
7123
|
-
const done = (m.tasks?.done_in_window || m.tasks?.done) ?? 0;
|
|
7124
|
-
// Three states, not two. `?? 0` folds "the payload has no tasks section" into
|
|
7125
|
-
// the same 0 as "zero tasks done", and the tile then rendered that 0 as an
|
|
7126
|
-
// absence — so a day-one project read "—" beside "+0 this week", the board
|
|
7127
|
-
// reporting a failure to measure something it had measured fine. The backend
|
|
7128
|
-
// always sends `tasks` (lib/metrics.mjs), so this is "—" only for a payload
|
|
7129
|
-
// that really is missing it.
|
|
7130
|
-
const doneKnown = m.tasks != null;
|
|
7131
|
-
const costKnown = m.cost != null;
|
|
7132
|
-
// NOT the same shape: avg_completion_min is a median over completions that
|
|
7133
|
-
// carry timestamps, and the backend emits 0 when there are none — so 0 here
|
|
7134
|
-
// means "could not be computed", not "took no time". "—" is the correct
|
|
7135
|
-
// reading; do not "fix" this into a literal 0 m.
|
|
7136
|
-
const avgMin = m.avg_completion_min ?? 0;
|
|
7137
|
-
const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
|
|
7138
|
-
|
|
7139
|
-
// Honest cost model — the backend is the source of truth:
|
|
7140
|
-
// cost.savings_x = MEASURED savings, only set when real verdict cost data
|
|
7141
|
-
// exists. NULL for the time-based (tasks) estimate, because
|
|
7142
|
-
// that ratio is just the hardcoded rate ratio, not a real
|
|
7143
|
-
// measurement — showing it would fabricate a number.
|
|
7144
|
-
// cost.human_usd = the backend's conservative human-cost estimate.
|
|
7145
|
-
// The frontend must NOT recompute its own human/savings figures (that produced
|
|
7146
|
-
// an inflated "×" that contradicted the API); it displays the backend fields.
|
|
7147
|
-
const aiSpend = realLlmUsd > 0 ? realLlmUsd : llmUsd;
|
|
7148
|
-
const humanEst = m.cost?.human_usd ?? 0;
|
|
7149
|
-
const savingsX = (typeof planSavingsX === 'number' && planSavingsX > 1)
|
|
7150
|
-
? planSavingsX
|
|
7151
|
-
: null;
|
|
7152
|
-
|
|
7153
|
-
const llmTrend = realLlmUsd > 0
|
|
7154
|
-
? `real tokens spent (from verdicts)`
|
|
7155
|
-
: (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
|
|
7156
|
-
|
|
7157
|
-
const hero = [
|
|
7158
|
-
{
|
|
7159
|
-
v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'),
|
|
7160
|
-
sub: 'shipped', label: 'Tasks done',
|
|
7161
|
-
trend: `+${m.velocity?.this_week ?? 0} this week`,
|
|
7162
|
-
delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
|
|
7163
|
-
},
|
|
7164
|
-
{
|
|
7165
|
-
// Three states, and $0.00 is only one of them. A measured zero is worth
|
|
7166
|
-
// showing; a zero that is the TIME-BASED ESTIMATE with no plans or tasks
|
|
7167
|
-
// to estimate from is not a figure at all, and rendering it as $0.00 told
|
|
7168
|
-
// a project with dozens of agent runs that it had spent nothing.
|
|
7169
|
-
v: !costKnown
|
|
7170
|
-
? absent('unloaded', 'the metrics payload carried no cost section')
|
|
7171
|
-
: (realLlmUsd === 0 && llmUsd === 0
|
|
7172
|
-
? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
7173
|
-
: `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}`),
|
|
7174
|
-
sub: '',
|
|
7175
|
-
label: 'AI spend',
|
|
7176
|
-
delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
|
|
7177
|
-
trend: llmTrend,
|
|
7178
|
-
cls: 'amber',
|
|
7179
|
-
},
|
|
7180
|
-
(savingsX != null && savingsX > 1)
|
|
7181
|
-
? {
|
|
7182
|
-
v: savingsX.toLocaleString(),
|
|
7183
|
-
sub: '×',
|
|
7184
|
-
label: 'Cost savings vs FTE',
|
|
7185
|
-
trend: humanEst > 0 ? `measured · human est. $${fmtMoney(humanEst)}` : 'measured savings',
|
|
7186
|
-
cls: 'green',
|
|
7187
|
-
}
|
|
7188
|
-
: {
|
|
7189
|
-
// No MEASURED savings yet (no verdict cost data). Don't fabricate a
|
|
7190
|
-
// multiplier from the time-based estimate — show it's not measured.
|
|
7191
|
-
v: absent('uncomputable', 'a measured multiplier needs verdict cost data, and there is none yet'),
|
|
7192
|
-
sub: '',
|
|
7193
|
-
label: 'Cost savings vs FTE',
|
|
7194
|
-
trend: aiSpend > 0
|
|
7195
|
-
? 'estimate only · run pipelines for measured savings'
|
|
7196
|
-
: (done > 0 ? 'no spend recorded yet' : 'no tasks done yet'),
|
|
7197
|
-
cls: '',
|
|
7198
|
-
},
|
|
7199
|
-
];
|
|
7200
|
-
document.getElementById('mp-hero').innerHTML = hero.map(s => `
|
|
7201
|
-
<div class="metric-card ${s.cls || ''}">
|
|
7202
|
-
<div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
|
|
7203
|
-
<div class="metric-label">${s.label}${s.delta || ''}</div>
|
|
7204
|
-
<div class="metric-trend">${s.trend}</div>
|
|
7205
|
-
</div>`).join('');
|
|
7206
|
-
|
|
7207
|
-
const acc = m.acceptance || {};
|
|
7208
|
-
const secondary = [
|
|
7209
|
-
{ v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'm' : '', label: cycleStat },
|
|
7210
|
-
{ 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' },
|
|
7211
|
-
// Verified-acceptance metrics: cost of a change a gate actually APPROVED, and
|
|
7212
|
-
// how often work bounced. '—' when nothing was accepted in the window — an
|
|
7213
|
-
// honest gap, not a zero.
|
|
7214
|
-
{ 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' },
|
|
7215
|
-
// `?? 0` here painted a GREEN ZERO over a question nobody asked. The tile one
|
|
7216
|
-
// line above goes out of its way to avoid exactly this, and these two did not:
|
|
7217
|
-
// a project whose acceptance data is missing showed "0 rework rounds" in
|
|
7218
|
-
// green, and — worse — a project where security has NEVER BEEN SCANNED showed
|
|
7219
|
-
// "0 open security blocks" in green, which is a passing security scan that
|
|
7220
|
-
// did not happen. That is this board's governing defect rendered in its most
|
|
7221
|
-
// reassuring colour.
|
|
7222
|
-
{
|
|
7223
|
-
v: acc.rework_rounds != null ? acc.rework_rounds : absent('uncomputable', 'no accepted work in this window, so rework cannot be counted'),
|
|
7224
|
-
sub: '', label: 'Rework rounds',
|
|
7225
|
-
cls: acc.rework_rounds == null ? '' : acc.rework_rounds ? 'amber' : 'green',
|
|
7226
|
-
},
|
|
7227
|
-
{
|
|
7228
|
-
v: m.security?.blocked != null ? m.security.blocked : absent('uncomputable', 'no security scan has run — this is not a clean result'),
|
|
7229
|
-
sub: '', label: 'Open security blocks',
|
|
7230
|
-
// No colour when unmeasured. Green would say "safe"; red would say
|
|
7231
|
-
// "unsafe"; neither is known.
|
|
7232
|
-
cls: m.security?.blocked == null ? '' : m.security.blocked ? 'red' : 'green',
|
|
7233
|
-
},
|
|
7234
|
-
{ v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
|
|
7235
|
-
];
|
|
7236
|
-
document.getElementById('mp-secondary').innerHTML = secondary.map(s => `
|
|
7237
|
-
<div class="metric-card ${s.cls || ''}">
|
|
7238
|
-
<div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
|
|
7239
|
-
<div class="metric-label">${s.label}</div>
|
|
7240
|
-
</div>`).join('');
|
|
7241
|
-
|
|
7242
|
-
const agents = m.agents || {};
|
|
7243
|
-
const maxRuns = Math.max(...Object.values(agents), 1);
|
|
7244
|
-
const colors = ['#2540d4','#7c3aed','#16a34a','#f59e0b','#06b6d4','#2563eb','#64748b','#94a3b8'];
|
|
7245
|
-
const agentKeys = Object.entries(agents).sort((a,b)=>b[1]-a[1]);
|
|
7246
|
-
const agentListEl = document.getElementById('agent-list');
|
|
7247
|
-
if (agentListEl) {
|
|
7248
|
-
agentListEl.innerHTML = agentKeys.length ? agentKeys.map(([a, n], i) => `
|
|
7249
|
-
<div class="bar-row">
|
|
7250
|
-
<span class="name">${esc(a)}</span>
|
|
7251
|
-
<span class="rail"><span class="fill" style="width:${(n/maxRuns*100).toFixed(0)}%; background:${colors[i % colors.length]}"></span></span>
|
|
7252
|
-
<span class="pct">${Math.round(n/maxRuns*100)}%</span>
|
|
7253
|
-
</div>`).join('') : '<div class="empty">No agent runs yet</div>';
|
|
7254
|
-
}
|
|
7255
|
-
|
|
7256
|
-
// Agent cost breakdown
|
|
7257
|
-
const agentsCost = m.agents_cost || [];
|
|
7258
|
-
const costPanel = document.getElementById('agent-cost-panel');
|
|
7259
|
-
if (costPanel) {
|
|
7260
|
-
if (agentsCost.length) {
|
|
7261
|
-
const maxCost = Math.max(...agentsCost.map(a => a.llm_usd), 0.0001);
|
|
7262
|
-
costPanel.innerHTML = agentsCost.map((a, i) => `
|
|
7263
|
-
<div class="agent-cost-row">
|
|
7264
|
-
<span class="name">${esc(a.agent)}</span>
|
|
7265
|
-
<span class="rail"><span class="fill" style="width:${(a.llm_usd/maxCost*100).toFixed(0)}%; background:${colors[i % colors.length]}"></span></span>
|
|
7266
|
-
<span class="amt">$${a.llm_usd.toFixed(4)}</span>
|
|
7267
|
-
</div>`).join('');
|
|
7268
|
-
} else {
|
|
7269
|
-
costPanel.innerHTML = '<div class="cost-empty">No agent cost data — tasks need estimated_minutes set.</div>';
|
|
7270
|
-
}
|
|
7271
|
-
}
|
|
7272
|
-
|
|
7273
|
-
const verdicts = (m.verdicts || []).slice().reverse().slice(0, 10);
|
|
7274
|
-
const timelineEl = document.getElementById('timeline');
|
|
7275
|
-
if (timelineEl) {
|
|
7276
|
-
timelineEl.innerHTML = verdicts.length ? verdicts.map(v => {
|
|
7277
|
-
const vu = (v.verdict || '').toUpperCase();
|
|
7278
|
-
const cls = ['APPROVED','DONE','PASS'].includes(vu) ? 'ok' : ['BLOCKED','FAILED','FAIL'].includes(vu) ? 'fail' : 'def';
|
|
7279
|
-
const raw = v.raw || '';
|
|
7280
|
-
const afterColon = raw.indexOf(': ') !== -1 ? raw.slice(raw.indexOf(': ') + 2) : raw;
|
|
7281
|
-
const text = afterColon.length > 80 ? afterColon.slice(0, 80) + '…' : afterColon;
|
|
7282
|
-
return `
|
|
7283
|
-
<div class="feed-row">
|
|
7284
|
-
<span class="feed-dot ${cls}"></span>
|
|
7285
|
-
<span class="agent">${esc(v.agent)}</span>
|
|
7286
|
-
<span class="msg">${esc(text)}</span>
|
|
7287
|
-
<span class="time">${relTime(v.ts)}</span>
|
|
7288
|
-
</div>`;
|
|
7289
|
-
}).join('') : '<div class="empty">No activity yet</div>';
|
|
7290
|
-
}
|
|
7291
|
-
}
|
|
7292
7591
|
|
|
7293
7592
|
/* ── Share ──────────────────────────────────────────────────────────────── */
|
|
7294
7593
|
function renderShareState(state) {
|
|
@@ -7698,12 +7997,12 @@ let cmdkIndex = 0;
|
|
|
7698
7997
|
|
|
7699
7998
|
function cmdkActions() {
|
|
7700
7999
|
const out = [
|
|
7701
|
-
{ group: 'Go', label: '
|
|
7702
|
-
{ group: 'Go', label: '
|
|
7703
|
-
{ group: 'Go', label: '
|
|
7704
|
-
{ group: 'Go', label: '
|
|
7705
|
-
{ group: 'Go', label: '
|
|
7706
|
-
{ group: 'Go', label: '
|
|
8000
|
+
{ group: 'Go', label: 'Decisions — what needs you', run: () => switchTab('decisions') },
|
|
8001
|
+
{ group: 'Go', label: 'Ledger — what it cost, what is running', run: () => switchTab('ledger') },
|
|
8002
|
+
{ group: 'Go', label: 'Fleet — the agents and what they may do', run: () => switchTab('fleet') },
|
|
8003
|
+
{ group: 'Go', label: 'Harness — host and second opinion', run: () => switchTab('harness') },
|
|
8004
|
+
{ group: 'Go', label: 'Settings', run: () => switchTab('settings') },
|
|
8005
|
+
{ group: 'Go', label: 'Tasks board (#/kanban, deep link)', run: () => switchTab('kanban') },
|
|
7707
8006
|
{ group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
|
|
7708
8007
|
{ group: 'Do', label: 'Toggle light / dark theme', run: () => toggleBoardTheme() },
|
|
7709
8008
|
];
|
|
@@ -7715,9 +8014,67 @@ function cmdkActions() {
|
|
|
7715
8014
|
out.push({ group: 'Gate', label: `Approve — ${t.title}`, hint: t.id,
|
|
7716
8015
|
run: () => gateAction(t.id, 'approve') });
|
|
7717
8016
|
}
|
|
8017
|
+
// ⌘K is where the board's artifacts are found by name (brief: docs, sessions,
|
|
8018
|
+
// memory and decisions are one job — find the artifact — and a job done by
|
|
8019
|
+
// recalling a name belongs in a palette, not a tab). Agents by slug (DESIGN
|
|
8020
|
+
// §5 device 4): `sec` → security-officer, opened in place.
|
|
8021
|
+
for (const a of (fleetState?.agents || [])) {
|
|
8022
|
+
if (a.retired) continue;
|
|
8023
|
+
out.push({ group: 'Agent', label: a.slug, hint: a.posture?.state === 'expensive' ? '▲ ' + ((a.posture.expensive || [])[0] || '') : (a.posture?.state === 'unclassified' ? '? not classified' : ''),
|
|
8024
|
+
run: () => { switchTab('fleet'); openAgentDrawer(a.slug); } });
|
|
8025
|
+
}
|
|
8026
|
+
for (const m of (cmdkCache.memory || [])) {
|
|
8027
|
+
out.push({ group: 'Memory', label: `${m.name} — ${m.desc || ''}`, hint: m.layer || '', run: () => switchTab('memory') });
|
|
8028
|
+
}
|
|
8029
|
+
for (const d of (cmdkCache.decisions || [])) {
|
|
8030
|
+
out.push({ group: 'Decisions', label: `${d.id} — ${d.title || ''}`, hint: `${d.verdict || ''} ${(d.ts || '').slice(0, 10)}`.trim(),
|
|
8031
|
+
run: () => switchTab('decisions') });
|
|
8032
|
+
}
|
|
8033
|
+
for (const it of (cmdkAsync.q === cmdkLastNeedle ? cmdkAsync.items : [])) out.push(it);
|
|
7718
8034
|
return out;
|
|
7719
8035
|
}
|
|
7720
8036
|
|
|
8037
|
+
// Memory layers and the decision log are small and stable: read once per
|
|
8038
|
+
// palette session. Docs and sessions are searched server-side per query
|
|
8039
|
+
// (BM25 over the files), debounced, and only shown while the query they
|
|
8040
|
+
// answered is still the one typed — a stale result is a wrong result.
|
|
8041
|
+
const cmdkCache = { memory: null, decisions: null };
|
|
8042
|
+
let cmdkAsync = { q: '', items: [] };
|
|
8043
|
+
let cmdkLastNeedle = '';
|
|
8044
|
+
let cmdkTimer = null;
|
|
8045
|
+
async function cmdkWarm() {
|
|
8046
|
+
if (cmdkCache.memory == null) {
|
|
8047
|
+
try { const m = await api(`/api/memory${pqs()}`); cmdkCache.memory = Array.isArray(m?.layers) ? m.layers : []; } catch { cmdkCache.memory = []; }
|
|
8048
|
+
}
|
|
8049
|
+
if (cmdkCache.decisions == null) {
|
|
8050
|
+
try { const d = await api(`/api/decisions${pqs()}`); cmdkCache.decisions = Array.isArray(d) ? d.slice(-30).reverse() : []; } catch { cmdkCache.decisions = []; }
|
|
8051
|
+
}
|
|
8052
|
+
cmdkRender(document.getElementById('cmdk-input')?.value || '');
|
|
8053
|
+
}
|
|
8054
|
+
function cmdkSearch(needle) {
|
|
8055
|
+
clearTimeout(cmdkTimer);
|
|
8056
|
+
if (needle.length < 2) { cmdkAsync = { q: '', items: [] }; return; }
|
|
8057
|
+
cmdkTimer = setTimeout(async () => {
|
|
8058
|
+
const items = [];
|
|
8059
|
+
const [docs, sessions] = await Promise.all([
|
|
8060
|
+
api(`/api/docs/search?q=${encodeURIComponent(needle)}${pqs().replace(/^\?/, '&')}`).catch(() => null),
|
|
8061
|
+
api(`/api/session-search?q=${encodeURIComponent(needle)}${pqs().replace(/^\?/, '&')}`).catch(() => null),
|
|
8062
|
+
]);
|
|
8063
|
+
for (const r of (docs?.results || []).slice(0, 6)) {
|
|
8064
|
+
const hit = (r.hits || [])[0];
|
|
8065
|
+
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) });
|
|
8066
|
+
}
|
|
8067
|
+
if (docs && docs.unreadable) items.push({ group: 'Docs', label: `! ${docs.unreadable} document(s) could not be read`, hint: 'counted, not dropped', run: () => switchTab('docs') });
|
|
8068
|
+
for (const h of (sessions?.hits || []).slice(0, 6)) {
|
|
8069
|
+
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) });
|
|
8070
|
+
}
|
|
8071
|
+
if (docs == null && sessions == null) items.push({ group: 'Docs', label: '· search unavailable — the board could not read docs or sessions', hint: '', run: () => {} });
|
|
8072
|
+
cmdkAsync = { q: needle, items };
|
|
8073
|
+
const cur = (document.getElementById('cmdk-input')?.value || '').toLowerCase().trim();
|
|
8074
|
+
if (cur === needle) cmdkRender(cur);
|
|
8075
|
+
}, 150);
|
|
8076
|
+
}
|
|
8077
|
+
|
|
7721
8078
|
function cmdkOpen() {
|
|
7722
8079
|
let el = document.getElementById('cmdk');
|
|
7723
8080
|
if (!el) {
|
|
@@ -7725,7 +8082,7 @@ function cmdkOpen() {
|
|
|
7725
8082
|
el.id = 'cmdk';
|
|
7726
8083
|
el.innerHTML = `
|
|
7727
8084
|
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
|
7728
|
-
<input id="cmdk-input" type="text" placeholder="
|
|
8085
|
+
<input id="cmdk-input" type="text" placeholder="Go, do, gate · agents · docs · sessions · memory · decisions" aria-label="Command"
|
|
7729
8086
|
autocomplete="off" oninput="cmdkRender(this.value)" />
|
|
7730
8087
|
<div id="cmdk-list" role="listbox" aria-label="Commands"></div>
|
|
7731
8088
|
</div>`;
|
|
@@ -7736,16 +8093,19 @@ function cmdkOpen() {
|
|
|
7736
8093
|
cmdkIndex = 0;
|
|
7737
8094
|
const input = document.getElementById('cmdk-input');
|
|
7738
8095
|
input.value = '';
|
|
8096
|
+
cmdkLastNeedle = ''; cmdkAsync = { q: '', items: [] };
|
|
7739
8097
|
cmdkRender('');
|
|
7740
8098
|
input.focus();
|
|
8099
|
+
cmdkWarm();
|
|
7741
8100
|
}
|
|
7742
8101
|
|
|
7743
8102
|
function cmdkClose() { document.getElementById('cmdk')?.classList.remove('open'); }
|
|
7744
8103
|
|
|
7745
8104
|
function cmdkRender(q) {
|
|
7746
8105
|
const needle = (q || '').toLowerCase().trim();
|
|
8106
|
+
if (needle !== cmdkLastNeedle) { cmdkLastNeedle = needle; cmdkSearch(needle); }
|
|
7747
8107
|
cmdkItems = cmdkActions().filter((a) =>
|
|
7748
|
-
!needle || a.label.toLowerCase().includes(needle) || (a.hint || '').toLowerCase().includes(needle));
|
|
8108
|
+
!needle || a.group === 'Docs' || a.group === 'Sessions' || a.label.toLowerCase().includes(needle) || (a.hint || '').toLowerCase().includes(needle));
|
|
7749
8109
|
if (cmdkIndex >= cmdkItems.length) cmdkIndex = 0;
|
|
7750
8110
|
const list = document.getElementById('cmdk-list');
|
|
7751
8111
|
if (!list) return;
|
|
@@ -7826,41 +8186,53 @@ try {
|
|
|
7826
8186
|
matchMedia('(min-width: 769px)').addEventListener('change', (e) => { if (e.matches) closeMenu(false); });
|
|
7827
8187
|
} catch (_) { /* older Safari: no addEventListener on MediaQueryList — drawer still works */ }
|
|
7828
8188
|
|
|
8189
|
+
// Redesign IA (great_cto-ki1x.5): four destinations + Settings. A new key
|
|
8190
|
+
// resolves to the panel that holds its content today (DEST); an old key —
|
|
8191
|
+
// still used by deep links, the SSE handlers and the kanban drill-in — maps
|
|
8192
|
+
// back to the destination that should light up (NAV). Screens .6–.11 replace
|
|
8193
|
+
// panels, not keys.
|
|
8194
|
+
const DEST = { decisions: 'inbox', ledger: 'budgets', fleet: 'agents', dashboard: 'budgets', share: 'settings', notifications: 'settings' };
|
|
8195
|
+
const NAV = { inbox: 'decisions', budgets: 'ledger', agents: 'fleet', dashboard: 'ledger', share: 'settings', notifications: 'settings' };
|
|
8196
|
+
const ROUTES = { decisions: 1, ledger: 1, fleet: 1, harness: 1, settings: 1, kanban: 1 };
|
|
7829
8197
|
function switchTab(id, el) {
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
8198
|
+
const lid = DEST[id] || id; // the panel's own name
|
|
8199
|
+
const navKey = NAV[id] || id; // the destination to highlight
|
|
8200
|
+
if (!el) el = document.querySelector(`.nav-item[data-tab="${navKey}"]`)
|
|
8201
|
+
|| document.querySelector('.nav-item[data-tab="decisions"]');
|
|
8202
|
+
currentTab = lid;
|
|
8203
|
+
if (ROUTES[id]) {
|
|
8204
|
+
if (location.hash !== `#/${id}`) history.replaceState(null, '', `#/${id}`);
|
|
8205
|
+
// BRD-R9: one local line per destination open, never blocking, never surfaced.
|
|
8206
|
+
try { fetch('/api/view', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ view: id }) }).catch(() => {}); } catch (e) {}
|
|
8207
|
+
}
|
|
7836
8208
|
closeMenu(false); // choosing a destination is the end of using the menu
|
|
7837
8209
|
// sidebar nav
|
|
7838
8210
|
document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
|
|
7839
|
-
t.classList.toggle('active', t.dataset.tab ===
|
|
8211
|
+
t.classList.toggle('active', t.dataset.tab === navKey);
|
|
7840
8212
|
});
|
|
7841
8213
|
// top tabs
|
|
7842
8214
|
document.querySelectorAll('.tab-btn').forEach(t => t.classList.toggle('active', t.dataset.tab === id));
|
|
7843
8215
|
// panels
|
|
7844
8216
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
7845
|
-
const panelEl = document.getElementById(`panel-${
|
|
8217
|
+
const panelEl = document.getElementById(`panel-${lid}`);
|
|
7846
8218
|
if (panelEl) panelEl.classList.add('active');
|
|
7847
8219
|
// crumb — `labels` used to be referenced here but is only ever a function-local in
|
|
7848
8220
|
// this file, so this threw ReferenceError on every tab switch and skipped the per-tab
|
|
7849
8221
|
// loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
|
|
7850
|
-
const TAB_LABELS = { budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
|
|
7851
|
-
const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
|
|
8222
|
+
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' };
|
|
8223
|
+
const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || TAB_LABELS[lid] || 'Board';
|
|
7852
8224
|
// Budgets render on Metrics, so the heartbeat that feeds them has to be
|
|
7853
8225
|
// fetched when Metrics opens — not only from the agents drill-in it used to
|
|
7854
8226
|
// live in.
|
|
7855
|
-
if (
|
|
8227
|
+
if (lid === 'dashboard') refreshHeartbeat();
|
|
7856
8228
|
// The Notifications panel has carried an inline history list all along, and
|
|
7857
8229
|
// `renderNotifHistoryInline()` to fill it — but the only caller was
|
|
7858
8230
|
// `openNotifDrawer()`. Opening the tab itself never loaded it, so the sidebar
|
|
7859
8231
|
// promised 50 unread and the screen showed settings. Declared and not
|
|
7860
8232
|
// consumed, in the UI this time.
|
|
7861
|
-
if (
|
|
7862
|
-
if (
|
|
7863
|
-
if (
|
|
8233
|
+
if (lid === 'notifications') loadNotifHistory();
|
|
8234
|
+
if (lid === 'budgets') loadBudgetsPage();
|
|
8235
|
+
if (lid === 'agents') {
|
|
7864
8236
|
// pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
|
|
7865
8237
|
// its 30-day default and overwrote whatever window the period chips had
|
|
7866
8238
|
// selected. Pick "7d", switch tabs, come back — the chip still read 7d and
|
|
@@ -7869,17 +8241,54 @@ function switchTab(id, el) {
|
|
|
7869
8241
|
//
|
|
7870
8242
|
// Through the sequence guard too, so a tab switch cannot land after a newer
|
|
7871
8243
|
// period request and clobber it.
|
|
7872
|
-
_refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m;
|
|
7873
|
-
if (
|
|
7874
|
-
|
|
8244
|
+
_refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; } });
|
|
8245
|
+
if (lid === 'agents') refreshAgentsInstalled();
|
|
8246
|
+
}
|
|
8247
|
+
if (lid === 'inbox') refreshInbox();
|
|
8248
|
+
if (lid === 'logs') setActivityView('logs');
|
|
8249
|
+
if (lid === 'docs') loadDocs();
|
|
8250
|
+
if (lid === 'sessions') loadSessions();
|
|
8251
|
+
if (lid === 'logs') refreshLogs();
|
|
8252
|
+
if (lid === 'notifications' || lid === 'settings') loadNotifications();
|
|
8253
|
+
if (lid === 'settings') loadSettingsPage();
|
|
8254
|
+
if (lid === 'harness') renderHarnesses();
|
|
8255
|
+
}
|
|
8256
|
+
|
|
8257
|
+
// Sidebar tabs: Enter/Space activate, arrows move (WAI-ARIA tablist).
|
|
8258
|
+
function navTabKeydown(event, el) {
|
|
8259
|
+
const items = [...document.querySelectorAll('.nav-item[role="tab"]')];
|
|
8260
|
+
const i = items.indexOf(el);
|
|
8261
|
+
if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); el.click(); return; }
|
|
8262
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
|
8263
|
+
event.preventDefault();
|
|
8264
|
+
const next = items[(i + (event.key === 'ArrowDown' ? 1 : items.length - 1)) % items.length];
|
|
8265
|
+
if (next) next.focus();
|
|
8266
|
+
}
|
|
8267
|
+
}
|
|
8268
|
+
// Routes: #/decisions … #/settings and #/kanban (a deep link, not a menu item —
|
|
8269
|
+
// brief 2026-09-06, revisited on 2026-09-20). Legacy hashes land on their new
|
|
8270
|
+
// home so a bookmark does not open on nothing. #agents/<slug> keeps its handler.
|
|
8271
|
+
const LEGACY_ROUTES = { inbox: 'decisions', budgets: 'ledger', dashboard: 'ledger', agents: 'fleet', notifications: 'settings', share: 'settings' };
|
|
8272
|
+
function routeFromHash() {
|
|
8273
|
+
const h = (location.hash || '').replace(/^#\/?/, '');
|
|
8274
|
+
if (!h || h.startsWith('agents/')) return null;
|
|
8275
|
+
// #/fleet/<slug>: open the agent in place (DESIGN §2 deep link).
|
|
8276
|
+
const m = h.match(/^fleet\/([a-z0-9-]+)$/i);
|
|
8277
|
+
if (m) {
|
|
8278
|
+
// Open once the board has a project and a fleet to open into; a deep link
|
|
8279
|
+
// that fires before init has set currentProject asks the API for nothing.
|
|
8280
|
+
const slug = m[1];
|
|
8281
|
+
const tryOpen = (n) => {
|
|
8282
|
+
if (typeof openAgentDrawer === 'function' && typeof currentProject !== 'undefined' && currentProject && (fleetState.agents || []).length) openAgentDrawer(slug, { fromHash: true });
|
|
8283
|
+
else if (n < 50) setTimeout(() => tryOpen(n + 1), 100);
|
|
8284
|
+
};
|
|
8285
|
+
setTimeout(() => tryOpen(0), 0);
|
|
8286
|
+
return 'fleet';
|
|
7875
8287
|
}
|
|
7876
|
-
|
|
7877
|
-
if (id === 'logs') setActivityView('logs');
|
|
7878
|
-
if (id === 'docs') loadDocs();
|
|
7879
|
-
if (id === 'sessions') loadSessions();
|
|
7880
|
-
if (id === 'logs') refreshLogs();
|
|
7881
|
-
if (id === 'notifications') loadNotifications();
|
|
8288
|
+
return ROUTES[h] ? h : (LEGACY_ROUTES[h] || null);
|
|
7882
8289
|
}
|
|
8290
|
+
addEventListener('hashchange', () => { const r = routeFromHash(); if (r) switchTab(r); });
|
|
8291
|
+
addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'decisions') switchTab(r); });
|
|
7883
8292
|
|
|
7884
8293
|
async function refreshLogs() {
|
|
7885
8294
|
const data = await api(`/api/logs${pqs()}`);
|
|
@@ -8004,6 +8413,9 @@ const fleetState = {
|
|
|
8004
8413
|
// doing work on most projects; showing all 35 by default is noise.
|
|
8005
8414
|
// User flips activity to `all` to see the full installation.
|
|
8006
8415
|
filters: { domain: 'all', activity: 'all', health: 'all' },
|
|
8416
|
+
// Five saved views, and only five (DESIGN §5): the default is the subset
|
|
8417
|
+
// nobody has judged yet, not the roster.
|
|
8418
|
+
view: 'needs',
|
|
8007
8419
|
search: '',
|
|
8008
8420
|
sort: 'runs_30d',
|
|
8009
8421
|
drawerSlug: null,
|
|
@@ -8023,8 +8435,6 @@ async function refreshAgentsInstalled() {
|
|
|
8023
8435
|
}
|
|
8024
8436
|
// legacy_agent_count comes from /api/metrics — pull from cached metrics if present
|
|
8025
8437
|
fleetState.legacy_count = (typeof metrics !== 'undefined' && metrics && metrics.legacy_agent_count) || 0;
|
|
8026
|
-
renderFleetSummary();
|
|
8027
|
-
renderFleetFacets();
|
|
8028
8438
|
renderFleetList();
|
|
8029
8439
|
renderLegacyAgentsSection();
|
|
8030
8440
|
// If the URL hash names a drawer, open it (post-load).
|
|
@@ -8032,102 +8442,64 @@ async function refreshAgentsInstalled() {
|
|
|
8032
8442
|
if (m) openAgentDrawer(m[1], { fromHash: true });
|
|
8033
8443
|
}
|
|
8034
8444
|
|
|
8035
|
-
function renderFleetSummary() {
|
|
8036
|
-
const el = document.getElementById('fleet-summary');
|
|
8037
|
-
if (!el) return;
|
|
8038
|
-
const s = fleetState.summary;
|
|
8039
|
-
if (!s) { el.innerHTML = '<div class="cost-empty">No data yet</div>'; return; }
|
|
8040
|
-
const tiles = [
|
|
8041
|
-
{ v: s.installed, label: 'Installed agents', cls: '' },
|
|
8042
|
-
{ v: s.active_30d, label: 'Active last 30d', cls: 'green', trend: s.active_30d > 0 ? '' : 'no activity yet' },
|
|
8043
|
-
{
|
|
8044
|
-
v: s.retire_candidates,
|
|
8045
|
-
label: 'Retire candidates',
|
|
8046
|
-
cls: s.retire_candidates > s.installed * 0.5 ? 'amber' : '',
|
|
8047
|
-
trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
|
|
8048
|
-
onClick: 'setFleetFilter("activity", "never")',
|
|
8049
|
-
},
|
|
8050
|
-
{
|
|
8051
|
-
// `?? 0` turned "nothing was measured" into "$0.00 spent", and before that
|
|
8052
|
-
// the number here was an ESTIMATE from verdict counts while the metrics
|
|
8053
|
-
// page estimated from tasks — two screens, one label, $3.90 against $1.65.
|
|
8054
|
-
v: s.llm_usd_30d != null
|
|
8055
|
-
? '$' + s.llm_usd_30d.toFixed(2)
|
|
8056
|
-
: absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`),
|
|
8057
|
-
vHtml: s.llm_usd_30d == null,
|
|
8058
|
-
label: 'LLM spend 30d',
|
|
8059
|
-
cls: '',
|
|
8060
|
-
trend: s.llm_usd_30d != null
|
|
8061
|
-
? `measured for ${s.llm_usd_30d_measured_for} agent(s)`
|
|
8062
|
-
: (s.failing_7d ? `${s.failing_7d} failing` : 'measured from verdicts'),
|
|
8063
|
-
},
|
|
8064
|
-
];
|
|
8065
|
-
// `t.v` is HTML when it is an absence marker — a span carrying the reason on
|
|
8066
|
-
// hover and in the accessible name — and a plain string otherwise. Escaping it
|
|
8067
|
-
// unconditionally printed the markup as text on the fleet tile. `vHtml` is the
|
|
8068
|
-
// deliberate opt-in; every value without it is still escaped.
|
|
8069
|
-
//
|
|
8070
|
-
// This comment lives out here because it names `t.v` in backticks, and a
|
|
8071
|
-
// backtick inside the template literal below terminates it — which is how the
|
|
8072
|
-
// board shipped a syntax error for about ninety seconds.
|
|
8073
|
-
el.innerHTML = tiles.map(t => `
|
|
8074
|
-
<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}}'` : ''}>
|
|
8075
|
-
<div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
|
|
8076
|
-
<div class="metric-label">${esc(t.label)}</div>
|
|
8077
|
-
${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
|
|
8078
|
-
</div>`).join('');
|
|
8079
|
-
}
|
|
8080
8445
|
|
|
8081
|
-
const FACET_GROUPS = [
|
|
8082
|
-
{
|
|
8083
|
-
key: 'domain', legend: 'Domain',
|
|
8084
|
-
options: [
|
|
8085
|
-
['all', 'all'], ['arch', 'arch'], ['pm', 'pm'], ['qa', 'qa'],
|
|
8086
|
-
['security', 'security'], ['ops', 'ops'], ['domain', 'domain'],
|
|
8087
|
-
['memory', 'memory'], ['other', 'other'],
|
|
8088
|
-
],
|
|
8089
|
-
},
|
|
8090
|
-
{
|
|
8091
|
-
key: 'activity', legend: 'Activity',
|
|
8092
|
-
options: [
|
|
8093
|
-
['all', 'all'], ['used', 'used'], ['active', 'active'], ['idle', 'idle ≥30d'], ['never', 'never run'], ['retired', 'retired'],
|
|
8094
|
-
],
|
|
8095
|
-
},
|
|
8096
|
-
{
|
|
8097
|
-
key: 'health', legend: 'Health',
|
|
8098
|
-
options: [
|
|
8099
|
-
['all', 'all'], ['ok', 'ok'], ['failing', 'failing ≥3 in 7d'],
|
|
8100
|
-
],
|
|
8101
|
-
},
|
|
8102
|
-
];
|
|
8103
8446
|
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
8447
|
+
|
|
8448
|
+
|
|
8449
|
+
const FLEET_VIEWS = [
|
|
8450
|
+
['needs', 'Needs attention'], ['expensive', 'Expensive grants'], ['never', 'Never observed'], ['all', 'All agents'], ['retired', 'Retired'],
|
|
8451
|
+
];
|
|
8452
|
+
// What "needs attention" means, in one place: failing, an unjudged posture, a
|
|
8453
|
+
// grant that is scoped in name only, or spend past its cap. Not "idle" — the
|
|
8454
|
+
// brief refuses an inferred idle/retired status in v1.
|
|
8455
|
+
function agentNeedsAttention(a) {
|
|
8456
|
+
const p = a.posture || {};
|
|
8457
|
+
return a.health === 'failing'
|
|
8458
|
+
|| p.state === 'unclassified' || p.state === 'unreadable'
|
|
8459
|
+
|| (Array.isArray(p.scopedInNameOnly) && p.scopedInNameOnly.length > 0)
|
|
8460
|
+
|| String(a.budget?.state || '').startsWith('over');
|
|
8461
|
+
}
|
|
8462
|
+
function inFleetView(a, view) {
|
|
8463
|
+
if (view === 'retired') return !!a.retired;
|
|
8464
|
+
if (a.retired) return false;
|
|
8465
|
+
if (view === 'needs') return agentNeedsAttention(a);
|
|
8466
|
+
if (view === 'expensive') return a.posture?.state === 'expensive';
|
|
8467
|
+
if (view === 'never') return !(a.runs_total > 0);
|
|
8468
|
+
return true;
|
|
8469
|
+
}
|
|
8470
|
+
function setFleetView(view) {
|
|
8471
|
+
fleetState.view = FLEET_VIEWS.some(([k]) => k === view) ? view : 'all';
|
|
8472
|
+
renderFleetViews();
|
|
8124
8473
|
renderFleetList();
|
|
8474
|
+
if (currentTab !== 'agents') switchTab('fleet');
|
|
8475
|
+
}
|
|
8476
|
+
// The sidebar's Views block: counts per view, the active one marked, and the
|
|
8477
|
+
// Fleet item's absence glyph lit when something is unclassified — a glyph, not
|
|
8478
|
+
// a count, because "2" would read as two new items.
|
|
8479
|
+
function renderFleetViews() {
|
|
8480
|
+
const agents = fleetState.agents || [];
|
|
8481
|
+
for (const [key, label] of FLEET_VIEWS) {
|
|
8482
|
+
const el = document.getElementById(`nav-view-${key}`);
|
|
8483
|
+
if (!el) continue;
|
|
8484
|
+
const n = agents.filter((a) => inFleetView(a, key)).length;
|
|
8485
|
+
el.classList.toggle('active', fleetState.view === key);
|
|
8486
|
+
el.innerHTML = `<span>${esc(label)}</span><span class="nav-view-count">${n}</span>`;
|
|
8487
|
+
}
|
|
8488
|
+
const abs = document.getElementById('nav-fleet-abs');
|
|
8489
|
+
if (abs) abs.hidden = !agents.some((a) => a.posture?.state === 'unclassified' || a.posture?.state === 'unreadable');
|
|
8490
|
+
const title = document.getElementById('fleet-view-title');
|
|
8491
|
+
if (title) {
|
|
8492
|
+
const [, label] = FLEET_VIEWS.find(([k]) => k === fleetState.view) || ['all', 'All agents'];
|
|
8493
|
+
const n = agents.filter((a) => inFleetView(a, fleetState.view)).length;
|
|
8494
|
+
title.innerHTML = `${esc(label)} <span class="muted" style="font-weight:400;font-size:var(--fs-title-s)">${n} of ${agents.length}</span>`;
|
|
8495
|
+
}
|
|
8125
8496
|
}
|
|
8126
8497
|
|
|
8127
8498
|
function applyFleetFilters(agents) {
|
|
8128
8499
|
const { domain, activity, health } = fleetState.filters;
|
|
8129
8500
|
const q = (fleetState.search || '').toLowerCase().trim();
|
|
8130
8501
|
return agents.filter(a => {
|
|
8502
|
+
if (!inFleetView(a, fleetState.view)) return false;
|
|
8131
8503
|
if (q && !a.slug.toLowerCase().includes(q) && !(a.description || '').toLowerCase().includes(q)) return false;
|
|
8132
8504
|
if (domain !== 'all' && a.domain !== domain) return false;
|
|
8133
8505
|
// `used` = has at least one verdict, not retired. Default filter.
|
|
@@ -8173,43 +8545,87 @@ function budgetChip(b) {
|
|
|
8173
8545
|
function renderFleetList() {
|
|
8174
8546
|
const el = document.getElementById('fleet-list');
|
|
8175
8547
|
if (!el) return;
|
|
8548
|
+
renderFleetViews();
|
|
8176
8549
|
const filtered = sortFleet(applyFleetFilters(fleetState.agents));
|
|
8177
8550
|
if (!filtered.length) {
|
|
8178
|
-
el.innerHTML = '<li class="empty" style="padding:20px;text-align:center;color:var(--text3)">No agents match
|
|
8551
|
+
el.innerHTML = '<li class="empty" style="padding:20px;text-align:center;color:var(--text3)">No agents match this view.</li>';
|
|
8179
8552
|
return;
|
|
8180
8553
|
}
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
8554
|
+
// Groups come from deriveDomain() on the server — one taxonomy, not two. A
|
|
8555
|
+
// group with rows in this view is expanded; the rest are one line each with
|
|
8556
|
+
// their count, so 70 agents fit a screen without a scroll or a virtualizer.
|
|
8557
|
+
const DOMAINS = ['arch', 'security', 'qa', 'ops', 'domain', 'pm', 'memory', 'other'];
|
|
8558
|
+
const totals = {}; for (const a of fleetState.agents) totals[a.domain] = (totals[a.domain] || 0) + 1;
|
|
8559
|
+
const byDomain = {}; for (const a of filtered) (byDomain[a.domain] = byDomain[a.domain] || []).push(a);
|
|
8560
|
+
const order = [...DOMAINS, ...Object.keys(byDomain).filter((d) => !DOMAINS.includes(d))];
|
|
8561
|
+
const expanded = order.filter((d) => (byDomain[d] || []).length);
|
|
8562
|
+
const collapsed = order.filter((d) => !(byDomain[d] || []).length && totals[d]);
|
|
8563
|
+
const head = `<li class="fleet-head" aria-hidden="true"><span></span><span>Agent</span><span>Posture</span><span>Last verdict</span><span class="tab">Runs</span><span class="tab">Pass</span><span class="tab">30d $</span><span></span></li>`;
|
|
8564
|
+
const rows = head + expanded.map((d) => `
|
|
8565
|
+
<li class="fleet-group-head"><span>${esc(d)} · ${byDomain[d].length} of ${totals[d] || 0}</span></li>
|
|
8566
|
+
${byDomain[d].map(agentRowHtml).join('')}`).join('');
|
|
8567
|
+
const tail = collapsed.length
|
|
8568
|
+
? `<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>`
|
|
8569
|
+
: '';
|
|
8570
|
+
el.innerHTML = rows + tail;
|
|
8571
|
+
}
|
|
8572
|
+
|
|
8573
|
+
// AgentRow (DESIGN §6.4 / §8.5): slug · posture · last verdict · runs · pass · 30d $.
|
|
8574
|
+
// Numbers that can be summed are tabular and right-aligned; identifiers are not.
|
|
8575
|
+
// Every absence carries its reason on hover.
|
|
8576
|
+
function postureChips(a) {
|
|
8577
|
+
const p = a.posture || {};
|
|
8578
|
+
const out = [];
|
|
8579
|
+
if (p.state === 'expensive') {
|
|
8580
|
+
const first = (p.expensive || [])[0] || 'expensive';
|
|
8581
|
+
const more = (p.expensive || []).length - 1;
|
|
8582
|
+
out.push(`<span class="pos exp" title="${esc((p.expensive || []).join(' · '))}">▲ ${esc(first)}${more > 0 ? ` +${more}` : ''}</span>`);
|
|
8583
|
+
} else if (p.state === 'routine') {
|
|
8584
|
+
out.push('<span class="pos rou" title="every granted tool is routine — cheap to undo">· routine</span>');
|
|
8585
|
+
} else if (p.state === 'unclassified') {
|
|
8586
|
+
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>`);
|
|
8587
|
+
} else if (p.state === 'undeclared') {
|
|
8588
|
+
out.push('<span class="abs" title="the agent declares no tools: line — nothing is granted, nothing is known">— no tools line</span>');
|
|
8589
|
+
} else {
|
|
8590
|
+
out.push(`<span class="abs" title="${esc(p.why || 'the agent file could not be read')}">! unreadable</span>`);
|
|
8591
|
+
}
|
|
8592
|
+
for (const t of (p.scopedInNameOnly || [])) {
|
|
8593
|
+
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>`);
|
|
8594
|
+
}
|
|
8595
|
+
return out.join('');
|
|
8596
|
+
}
|
|
8597
|
+
function agentRowHtml(a) {
|
|
8598
|
+
const rate = a.success_rate;
|
|
8599
|
+
const runs = a.runs_30d ?? 0;
|
|
8600
|
+
const observed = a.runs_total > 0 || !!a.last_run;
|
|
8601
|
+
const last = observed
|
|
8602
|
+
? (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))}`)
|
|
8603
|
+
: '<span class="abs" title="no verdict line names this agent — absence of a log is not absence of a run">never observed</span>';
|
|
8604
|
+
const pass = !observed ? '<span class="abs" title="never observed">—</span>'
|
|
8605
|
+
: runs === 0 ? '<span class="abs" title="no run in this window — measured, none">—</span>'
|
|
8606
|
+
: rate != null ? `${rate}%` : '<span class="abs" title="runs exist but no verdict carried a result yet">·</span>';
|
|
8607
|
+
const spend = a.llm_usd_30d_real != null ? `$${a.llm_usd_30d_real.toFixed(2)}`
|
|
8608
|
+
: 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>`
|
|
8609
|
+
: `<span class="abs" title="no verdict carries a cost for this agent">—</span>`;
|
|
8610
|
+
const model = a.model_state === 'pinned' && a.model ? esc(a.model)
|
|
8611
|
+
: a.model_state === 'undeclared' ? '<span class="abs" title="declares no model — not pinned to any default">—</span>'
|
|
8612
|
+
: '<span class="abs" title="the agent file could not be read">!</span>';
|
|
8613
|
+
return `<li>
|
|
8188
8614
|
<button class="agent-row status-${esc(a.health)}${a.retired ? ' retired' : ''}"
|
|
8189
8615
|
role="button" data-slug="${esc(a.slug)}"
|
|
8190
|
-
aria-label="${esc(a.slug)} · ${esc(a.health)} · ${
|
|
8616
|
+
aria-label="${esc(a.slug)} · ${esc(a.health)} · ${runs} runs 30d"
|
|
8191
8617
|
onclick="openAgentDrawer('${esc(a.slug)}')"
|
|
8192
8618
|
onkeydown="if(event.key==='Enter'){event.preventDefault();openAgentDrawer('${esc(a.slug)}')}">
|
|
8193
8619
|
<span class="ar-status" aria-hidden="true"></span>
|
|
8194
|
-
<span class="ar-
|
|
8195
|
-
|
|
8196
|
-
|
|
8197
|
-
|
|
8198
|
-
|
|
8199
|
-
|
|
8200
|
-
<span>last: ${esc(lastRunStr)}</span>
|
|
8201
|
-
${savingsStr ? `<span>${esc(savingsStr)}</span>` : ''}
|
|
8202
|
-
${budgetChip(a.budget)}
|
|
8203
|
-
${a.retired ? '<span style="color:var(--agent-retired)">retired</span>' : ''}
|
|
8204
|
-
</span>
|
|
8205
|
-
</span>
|
|
8206
|
-
<span class="ar-rail" aria-hidden="true">
|
|
8207
|
-
${rate != null ? `<span class="ar-rail-fill" style="width:${rate}%"></span>` : ''}
|
|
8208
|
-
</span>
|
|
8620
|
+
<span class="ar-name">${esc(a.slug)} <span class="mono cap muted" style="font-weight:400">${model}</span>${a.retired ? ' <span class="cap" style="color:var(--agent-retired)">retired</span>' : ''}</span>
|
|
8621
|
+
<span class="ar-posture">${postureChips(a)}</span>
|
|
8622
|
+
<span class="mono cap ar-last">${last}</span>
|
|
8623
|
+
<span class="mono cap tab" title="runs in the last 30 days">${runs}</span>
|
|
8624
|
+
<span class="mono cap tab" title="pass rate over those runs">${pass}</span>
|
|
8625
|
+
<span class="mono cap tab" title="LLM spend, last 30 days">${spend}${budgetChip(a.budget)}</span>
|
|
8209
8626
|
<span class="ar-chev" aria-hidden="true">›</span>
|
|
8210
8627
|
</button>
|
|
8211
8628
|
</li>`;
|
|
8212
|
-
}).join('');
|
|
8213
8629
|
}
|
|
8214
8630
|
|
|
8215
8631
|
function renderLegacyAgentsSection() {
|
|
@@ -8276,13 +8692,33 @@ async function openAgentDrawer(slug, opts = {}) {
|
|
|
8276
8692
|
document.getElementById('agent-drawer-close')?.focus();
|
|
8277
8693
|
}, 50);
|
|
8278
8694
|
|
|
8279
|
-
|
|
8695
|
+
// Agent detail (great_cto-ki1x.9): the profile plus the three facts the
|
|
8696
|
+
// decision needs — its gate tier and eval count, its stand-downs, and whether
|
|
8697
|
+
// the last approval's receipt still describes the tree. Each is its own read
|
|
8698
|
+
// and its own state; one failing must not blank the others.
|
|
8699
|
+
// The profile paints first; the slower facts — tier + evals, stand-downs,
|
|
8700
|
+
// and the receipt, which hashes the tree and takes seconds — arrive on their
|
|
8701
|
+
// own and replace their `· unloaded` cells. A slow read never holds the
|
|
8702
|
+
// drawer, and a failed one is a state on its cell, not a spinner.
|
|
8703
|
+
let profile;
|
|
8704
|
+
try { profile = await api(`/api/agents/${encodeURIComponent(slug)}`); }
|
|
8705
|
+
catch (err) {
|
|
8706
|
+
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>`;
|
|
8707
|
+
return;
|
|
8708
|
+
}
|
|
8280
8709
|
if (!profile || profile.error) {
|
|
8281
8710
|
body.innerHTML = `<div class="cost-empty">Agent <code>${esc(slug)}</code> not found.</div>`;
|
|
8282
8711
|
return;
|
|
8283
8712
|
}
|
|
8284
8713
|
if (fleetState.drawerSlug !== slug) return; // drawer swapped while loading
|
|
8285
|
-
|
|
8714
|
+
const extra = { tier: null, tiersState: null, receipt: null, standDowns: null };
|
|
8715
|
+
renderAgentProfile(profile, extra);
|
|
8716
|
+
const settle = (p, apply) => p.then(apply, () => {}).then(() => { if (fleetState.drawerSlug === slug) renderAgentProfile(profile, extra); });
|
|
8717
|
+
await Promise.all([
|
|
8718
|
+
settle(api(`/api/gate-tiers${pqs()}`), (t) => { extra.tiersState = t; extra.tier = t?.agents?.[slug] || null; }),
|
|
8719
|
+
settle(api(`/api/stand-downs${pqs()}`), (d) => { extra.standDowns = d; }),
|
|
8720
|
+
settle(api(`/api/receipt${pqs()}`), (r) => { extra.receipt = r; }),
|
|
8721
|
+
]);
|
|
8286
8722
|
}
|
|
8287
8723
|
|
|
8288
8724
|
function closeAgentDrawer() {
|
|
@@ -8305,50 +8741,84 @@ function closeAgentDrawer() {
|
|
|
8305
8741
|
document.querySelectorAll('.agent-row').forEach(r => r.setAttribute('aria-current', 'false'));
|
|
8306
8742
|
}
|
|
8307
8743
|
|
|
8308
|
-
function renderAgentProfile(p) {
|
|
8744
|
+
function renderAgentProfile(p, extra = {}) {
|
|
8309
8745
|
const body = document.getElementById('agent-drawer-body');
|
|
8310
8746
|
const title = document.getElementById('agent-drawer-title');
|
|
8311
8747
|
const pill = document.getElementById('agent-drawer-pill');
|
|
8312
8748
|
if (!body) return;
|
|
8313
8749
|
title.textContent = p.slug;
|
|
8750
|
+
const row = (fleetState.agents || []).find((a) => a.slug === p.slug) || {};
|
|
8751
|
+
const tier = extra.tier;
|
|
8752
|
+
const tierChip = tier
|
|
8753
|
+
? `<span class="chip mono" style="background:var(--bg-strong);color:var(--text)" title="${esc(tier.why || '')}">${esc(tier.tier)}</span>`
|
|
8754
|
+
: (extra.tiersState && extra.tiersState.enabled === false
|
|
8755
|
+
? '<span class="abs" title="gate tiering is off for this project">— tiering off</span>'
|
|
8756
|
+
: '<span class="abs" title="no tier computed for this agent — no eval has judged it">? tier</span>');
|
|
8314
8757
|
pill.innerHTML = `<span class="status-pill tone-${esc(p.health)}">
|
|
8315
8758
|
<span class="pill-dot" aria-hidden="true"></span>${esc(p.retired ? 'retired' : p.health)}
|
|
8316
|
-
</span
|
|
8317
|
-
|
|
8318
|
-
const
|
|
8319
|
-
|
|
8759
|
+
</span> ${tierChip}`;
|
|
8760
|
+
|
|
8761
|
+
const model = row.model_state === 'pinned' && p.model ? `<span class="mono">${esc(p.model)}</span> <span class="muted">pinned</span>`
|
|
8762
|
+
: row.model_state === 'undeclared' ? '<span class="abs" title="declares no model — not pinned to any default">— no model pinned</span>'
|
|
8763
|
+
: `<span class="mono">${esc(p.model || '')}</span>`;
|
|
8764
|
+
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>`
|
|
8765
|
+
: (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>`
|
|
8766
|
+
: '<span class="abs" title="no verdict carries a cost for this agent">— spend unmeasured</span>';
|
|
8767
|
+
const posture = row.posture || {};
|
|
8768
|
+
const strip = (posture.scopedInNameOnly || []).length
|
|
8769
|
+
? `<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>`
|
|
8770
|
+
: '';
|
|
8771
|
+
// Evals: a count of zero is "measured, none"; a missing tier read is "not measured".
|
|
8772
|
+
const evals = tier
|
|
8773
|
+
? (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>')
|
|
8774
|
+
: '<span class="abs" title="no eval→agent mapping was read — not measured, not zero">· not measured</span>';
|
|
8775
|
+
const sd = extra.standDowns;
|
|
8776
|
+
const sdRows = Array.isArray(sd?.rows) ? sd.rows.filter((r) => !r.agent || r.agent === p.slug) : null;
|
|
8777
|
+
const standDowns = sd == null ? '<span class="abs" title="stand-down log not read">· unloaded</span>'
|
|
8778
|
+
: sd.state === 'unreadable' ? `<span class="abs" title="${esc(sd.why || '')}">! unreadable</span>`
|
|
8779
|
+
: 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>`
|
|
8780
|
+
: '<span class="abs" title="measured — no gate stood down for this agent">—</span>';
|
|
8781
|
+
const rc = extra.receipt;
|
|
8782
|
+
const receipt = rc == null ? '<span class="abs" title="receipt not read">· unloaded</span>'
|
|
8783
|
+
: rc.state === 'no-receipt' ? `<span class="abs" title="${esc(rc.why || '')}">— no approval carries a receipt yet</span>`
|
|
8784
|
+
: rc.state === 'unreadable' ? `<span class="abs" title="${esc(rc.why || '')}">! unreadable</span>`
|
|
8785
|
+
: 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>`
|
|
8786
|
+
: `<span style="color:var(--status-done)">${esc(rc.state)}</span> <span class="muted">— the approved bytes are the bytes in the tree</span>`;
|
|
8320
8787
|
|
|
8321
8788
|
body.innerHTML = `
|
|
8322
8789
|
<div class="mp-card">
|
|
8323
|
-
<h4>
|
|
8790
|
+
<h4>Grant <span class="muted" style="font-weight:normal">· hover a chip for what it permits</span></h4>
|
|
8791
|
+
<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>
|
|
8792
|
+
<div class="ar-posture">${postureChips({ posture })}</div>
|
|
8793
|
+
${strip}
|
|
8794
|
+
</div>
|
|
8795
|
+
<div class="mp-card">
|
|
8324
8796
|
<dl class="agent-summary-grid">
|
|
8325
|
-
<dt>
|
|
8326
|
-
<dt>
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
<dt>
|
|
8331
|
-
<dt>
|
|
8332
|
-
<dt>last run</dt> <dd>${p.last_run ? esc(p.last_run.slice(0, 16).replace('T', ' ') + ' · ' + agoStr(p.last_run)) : 'never'}</dd>
|
|
8333
|
-
<dt>savings</dt> <dd>${esc(savings)} ${esc(realCost)}</dd>
|
|
8797
|
+
<dt>model</dt> <dd>${model}</dd>
|
|
8798
|
+
<dt>spend</dt> <dd>${spend} ${budgetChip(row.budget)}</dd>
|
|
8799
|
+
<dt>runs there</dt> <dd>Claude Code <span class="muted">· host</span> · Codex <span class="abs" title="skills + MCP only — a role does not run there">·</span></dd>
|
|
8800
|
+
<dt>evals</dt> <dd>${evals}</dd>
|
|
8801
|
+
<dt>stand-downs</dt> <dd>${standDowns}</dd>
|
|
8802
|
+
<dt>receipt</dt> <dd>${receipt}</dd>
|
|
8803
|
+
<dt>prompt</dt> <dd><code style="font-size:var(--fs-caption)">${esc(p.file_path || '')}</code></dd>
|
|
8334
8804
|
</dl>
|
|
8335
8805
|
${p.description ? `<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:8px;line-height:1.5">${esc(p.description)}</div>` : ''}
|
|
8336
8806
|
</div>
|
|
8337
8807
|
|
|
8338
8808
|
<div class="mp-card">
|
|
8339
|
-
<h4>
|
|
8809
|
+
<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>
|
|
8340
8810
|
${p.recent_runs.length ? `
|
|
8341
8811
|
<ul class="run-list" role="list">
|
|
8342
8812
|
${p.recent_runs.map(r => {
|
|
8343
8813
|
const tone = isSuccessVerdict(r.verdict) ? 'ok' : (isFailureVerdict(r.verdict) ? 'fail' : 'neutral');
|
|
8344
8814
|
return `<li class="run-row">
|
|
8345
|
-
<span>${esc((r.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
8815
|
+
<span class="mono">${esc((r.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
8346
8816
|
<span class="rr-verdict ${tone}">${esc(r.verdict || '—')}</span>
|
|
8347
8817
|
<span class="rr-raw" title="${esc(r.raw)}">${esc(r.raw)}</span>
|
|
8348
|
-
<span>${r.cost_usd != null ? '$' + r.cost_usd.toFixed(4) : ''}</span>
|
|
8818
|
+
<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>
|
|
8349
8819
|
</li>`;
|
|
8350
8820
|
}).join('')}
|
|
8351
|
-
</ul>` : '<div class="cost-empty">No
|
|
8821
|
+
</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>')}
|
|
8352
8822
|
</div>
|
|
8353
8823
|
|
|
8354
8824
|
<div class="mp-card">
|
|
@@ -8360,18 +8830,17 @@ function renderAgentProfile(p) {
|
|
|
8360
8830
|
<span class="fm-count">×${f.count}</span>
|
|
8361
8831
|
<span class="fm-last">${esc(agoStr(f.last_seen))}</span>
|
|
8362
8832
|
</li>`).join('')}
|
|
8363
|
-
</ul>` :
|
|
8833
|
+
</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>`}
|
|
8364
8834
|
</div>
|
|
8365
8835
|
|
|
8366
8836
|
<div class="mp-card">
|
|
8367
8837
|
<h4>Actions</h4>
|
|
8368
8838
|
<div class="agent-actions">
|
|
8839
|
+
<button type="button" title="Copy the line that re-runs this agent" onclick="copyRerun('${esc(p.slug)}')">Copy re-run</button>
|
|
8840
|
+
<button type="button" title="Caps live in the Ledger's budgets table" onclick="switchTab('ledger')">Raise budget…</button>
|
|
8369
8841
|
${p.retired
|
|
8370
8842
|
? `<button type="button" onclick="restoreAgentAction('${esc(p.slug)}')">Restore agent</button>`
|
|
8371
|
-
: `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent
|
|
8372
|
-
</div>
|
|
8373
|
-
<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:8px">
|
|
8374
|
-
File: <code>${esc(p.file_path)}</code>
|
|
8843
|
+
: `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent…</button>`}
|
|
8375
8844
|
</div>
|
|
8376
8845
|
</div>`;
|
|
8377
8846
|
}
|
|
@@ -8444,7 +8913,6 @@ function wireFleetKeyboard() {
|
|
|
8444
8913
|
fleetState.search = '';
|
|
8445
8914
|
const input = document.getElementById('fleet-search');
|
|
8446
8915
|
if (input) input.value = '';
|
|
8447
|
-
renderFleetFacets();
|
|
8448
8916
|
renderFleetList();
|
|
8449
8917
|
});
|
|
8450
8918
|
// Drawer close button + backdrop click
|
|
@@ -8667,21 +9135,11 @@ let _notifHistory = [];
|
|
|
8667
9135
|
let _unreadCount = 0;
|
|
8668
9136
|
let _emailChannelActive = false; // true when email enabled + verified
|
|
8669
9137
|
|
|
8670
|
-
function openNotifDrawer() {
|
|
8671
|
-
document.getElementById('notif-drawer').style.right = '0';
|
|
8672
|
-
document.getElementById('notif-drawer-overlay').style.display = 'block';
|
|
8673
|
-
loadNotifHistory();
|
|
8674
|
-
}
|
|
8675
|
-
function closeNotifDrawer() {
|
|
8676
|
-
document.getElementById('notif-drawer').style.right = '-400px';
|
|
8677
|
-
document.getElementById('notif-drawer-overlay').style.display = 'none';
|
|
8678
|
-
}
|
|
8679
9138
|
async function loadNotifHistory() {
|
|
8680
9139
|
try {
|
|
8681
9140
|
const r = await fetch('/api/notif-history');
|
|
8682
9141
|
if (!r.ok) return;
|
|
8683
9142
|
_notifHistory = await r.json();
|
|
8684
|
-
renderNotifDrawer();
|
|
8685
9143
|
const unread = _notifHistory.filter(n => !n.read).length;
|
|
8686
9144
|
updateNotifBadge(unread);
|
|
8687
9145
|
renderNotifHistoryInline();
|
|
@@ -8690,35 +9148,6 @@ async function loadNotifHistory() {
|
|
|
8690
9148
|
function _notifLevelIcon(level) {
|
|
8691
9149
|
return { info: 'ℹ️', warning: '⚠️', error: '🚨', success: '✅' }[level] || '🔔';
|
|
8692
9150
|
}
|
|
8693
|
-
function renderNotifDrawer() {
|
|
8694
|
-
const list = document.getElementById('notif-drawer-list');
|
|
8695
|
-
const empty = document.getElementById('notif-drawer-empty');
|
|
8696
|
-
if (!list) return;
|
|
8697
|
-
if (!_notifHistory.length) {
|
|
8698
|
-
list.innerHTML = '';
|
|
8699
|
-
if (empty) empty.style.display = 'flex';
|
|
8700
|
-
return;
|
|
8701
|
-
}
|
|
8702
|
-
if (empty) empty.style.display = 'none';
|
|
8703
|
-
list.innerHTML = _notifHistory.map(n => `
|
|
8704
|
-
<div class="notif-item${n.read ? '' : ' unread'}" data-id="${esc(n.id)}"
|
|
8705
|
-
style="padding:12px 16px;border-bottom:1px solid var(--border);
|
|
8706
|
-
${n.read ? '' : 'background:rgba(59,130,246,0.06);'}">
|
|
8707
|
-
<div style="display:flex;align-items:flex-start;gap:10px">
|
|
8708
|
-
<span style="font-size:var(--fs-title-s);flex-shrink:0;margin-top:1px">${_notifLevelIcon(n.level)}</span>
|
|
8709
|
-
<div style="flex:1;min-width:0">
|
|
8710
|
-
<div style="font-size:var(--fs-body);font-weight:${n.read ? '400' : '600'};color:var(--text);
|
|
8711
|
-
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(n.title)}</div>
|
|
8712
|
-
<div style="font-size:var(--fs-small);color:var(--text3);margin-top:2px;line-height:1.4">${esc(n.body)}</div>
|
|
8713
|
-
<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:4px;font-family:var(--mono)">
|
|
8714
|
-
${n.project ? `<span style="color:var(--accent-text);margin-right:6px">${esc(n.project)}</span>` : ''}
|
|
8715
|
-
${new Date(n.ts).toLocaleString()}
|
|
8716
|
-
</div>
|
|
8717
|
-
</div>
|
|
8718
|
-
${n.read ? '' : '<span style="width:7px;height:7px;background:var(--dot-unread);border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
|
|
8719
|
-
</div>
|
|
8720
|
-
</div>`).join('');
|
|
8721
|
-
}
|
|
8722
9151
|
function renderNotifHistoryInline() {
|
|
8723
9152
|
const el = document.getElementById('notif-history-inline');
|
|
8724
9153
|
const empty = document.getElementById('notif-history-empty');
|
|
@@ -8775,7 +9204,6 @@ async function markAllNotifsRead() {
|
|
|
8775
9204
|
await fetch('/api/notif-history/read', { method: 'POST',
|
|
8776
9205
|
headers: {'Content-Type':'application/json'}, body: '{}' });
|
|
8777
9206
|
_notifHistory.forEach(n => n.read = true);
|
|
8778
|
-
renderNotifDrawer();
|
|
8779
9207
|
renderNotifHistoryInline();
|
|
8780
9208
|
updateNotifBadge(0);
|
|
8781
9209
|
} catch(e) {}
|
|
@@ -8784,7 +9212,6 @@ function prependNotifLive(notif) {
|
|
|
8784
9212
|
_notifHistory.unshift(notif);
|
|
8785
9213
|
if (_notifHistory.length > 100) _notifHistory.length = 100;
|
|
8786
9214
|
updateNotifBadge(_unreadCount + 1);
|
|
8787
|
-
renderNotifDrawer();
|
|
8788
9215
|
renderNotifHistoryInline();
|
|
8789
9216
|
}
|
|
8790
9217
|
|
|
@@ -8885,29 +9312,5 @@ const _costLbl = document.getElementById('mp-cost-label');
|
|
|
8885
9312
|
if (_costLbl) _costLbl.textContent = `Cost · last ${periodLabel(currentPeriod)}`;
|
|
8886
9313
|
</script>
|
|
8887
9314
|
|
|
8888
|
-
<!-- ── Notification drawer ──────────────────────────────────────────────── -->
|
|
8889
|
-
<div id="notif-drawer" style="position:fixed;top:0;right:-400px;width:380px;height:100vh;
|
|
8890
|
-
background:var(--bg-elevated);border-left:1px solid var(--border);z-index:9999;
|
|
8891
|
-
transition:right .25s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;
|
|
8892
|
-
box-shadow: none;border-bottom:1px solid var(--border);
|
|
8893
|
-
display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
8894
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
|
8895
|
-
style="width:16px;height:16px;color:var(--text2)">
|
|
8896
|
-
<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"/>
|
|
8897
|
-
</svg>
|
|
8898
|
-
<span style="font-weight:600;font-size:var(--fs-base);flex:1;color:var(--text)">Notifications</span>
|
|
8899
|
-
<button class="btn-ghost-sm" onclick="markAllNotifsRead()"
|
|
8900
|
-
style="font-size:var(--fs-small)">Mark all read</button>
|
|
8901
|
-
<button onclick="closeNotifDrawer()" style="background:none;border:none;cursor:pointer;
|
|
8902
|
-
color:var(--text3);font-size:var(--fs-title-l);line-height:1;padding:0 4px;margin-left:4px">×</button>
|
|
8903
|
-
</div>
|
|
8904
|
-
<div id="notif-drawer-list" tabindex="0" role="region" aria-label="Notifications list" style="flex:1;overflow-y:auto"></div>
|
|
8905
|
-
<div id="notif-drawer-empty" style="display:flex;flex:1;align-items:center;
|
|
8906
|
-
justify-content:center;flex-direction:column;gap:8px;color:var(--text3);font-size:var(--fs-body)">
|
|
8907
|
-
<span style="font-size:var(--fs-num-l)">🔔</span>No notifications yet
|
|
8908
|
-
</div>
|
|
8909
|
-
</div>
|
|
8910
|
-
<div id="notif-drawer-overlay" onclick="closeNotifDrawer()"
|
|
8911
|
-
style="display:none;position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.4)"></div>
|
|
8912
9315
|
</body>
|
|
8913
9316
|
</html>
|