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