great-cto 3.26.4 → 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/routes.mjs +127 -2
- package/board/packages/board/lib/view-counter.mjs +122 -0
- package/board/packages/board/public/index.html +938 -560
- package/board/scripts/lib/cost-meter.mjs +236 -0
- package/board/scripts/lib/cross-model-review.mjs +225 -0
- package/board/scripts/lib/provider-exhaustion.mjs +152 -0
- package/package.json +1 -1
|
@@ -11,11 +11,8 @@
|
|
|
11
11
|
function toggleBoardTheme() {
|
|
12
12
|
var r = document.documentElement, t = r.dataset.theme === 'light' ? 'dark' : 'light';
|
|
13
13
|
r.dataset.theme = t; try { localStorage.setItem('gcto_board_theme', t); } catch (e) {}
|
|
14
|
-
|
|
14
|
+
/* The icon follows data-theme in CSS; nothing to repaint here. */
|
|
15
15
|
}
|
|
16
|
-
addEventListener('DOMContentLoaded', function () {
|
|
17
|
-
var b = document.getElementById('board-theme-btn'); if (b) b.textContent = document.documentElement.dataset.theme === 'light' ? '☀️' : '🌙';
|
|
18
|
-
});
|
|
19
16
|
</script>
|
|
20
17
|
<meta charset="UTF-8" />
|
|
21
18
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
@@ -118,6 +115,17 @@
|
|
|
118
115
|
--p2-bg: rgba(240, 210, 78, 0.14); --p2-fg: #fde68a;
|
|
119
116
|
--p3-bg: rgba(255, 255, 255, 0.05); --p3-fg: #9ca3af;
|
|
120
117
|
|
|
118
|
+
/* Absence — two states the board already produces and could not render
|
|
119
|
+
(§4/§6.3 of DESIGN-board-redesign-2026-09): `!` unreadable, `?` unjudged.
|
|
120
|
+
Reuses the p1 values verbatim rather than introducing a fifth amber. */
|
|
121
|
+
--absent-warn-fg: #fcd34d;
|
|
122
|
+
--absent-warn-bg: rgba(255, 170, 60, 0.14);
|
|
123
|
+
/* Posture — aliases onto the measured p0 pair, not new colours (§6.5): one
|
|
124
|
+
hue for "expensive", distinguished by words. Aliased, not copied, so a
|
|
125
|
+
future change to p0 cannot silently desynchronise these. */
|
|
126
|
+
--posture-expensive-bg: var(--p0-bg);
|
|
127
|
+
--posture-expensive-fg: var(--p0-fg);
|
|
128
|
+
|
|
121
129
|
/* Was `--serif`, and there is no serif anywhere in the stack — it is the
|
|
122
130
|
DISPLAY face, doing display work correctly under a name that promised a
|
|
123
131
|
contrast this page has never had. greatcto.systems already calls the same
|
|
@@ -191,6 +199,13 @@
|
|
|
191
199
|
--p1-bg: rgba(180, 83, 9, 0.12); --p1-fg: #a64c08;
|
|
192
200
|
--p2-bg: rgba(146, 102, 10, 0.12); --p2-fg: #885f09;
|
|
193
201
|
--p3-bg: rgba(0, 0, 0, 0.05); --p3-fg: #5c6b65;
|
|
202
|
+
/* Absence / posture — see :root for the rationale. Both blocks in the same
|
|
203
|
+
commit: a token added to only one theme is the defect this file's own
|
|
204
|
+
comments document (the light block is 39 overrides nobody was checking). */
|
|
205
|
+
--absent-warn-fg: #a64c08;
|
|
206
|
+
--absent-warn-bg: rgba(180, 83, 9, 0.12);
|
|
207
|
+
--posture-expensive-bg: var(--p0-bg);
|
|
208
|
+
--posture-expensive-fg: var(--p0-fg);
|
|
194
209
|
--surface-drawer: #ffffff;
|
|
195
210
|
--surface-translucent: rgba(255, 255, 255, 0.72);
|
|
196
211
|
--rail-ok-bg: rgba(10, 125, 77, 0.12);
|
|
@@ -565,6 +580,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
565
580
|
.nav-item.active .icon { color: var(--accent-text); }
|
|
566
581
|
.nav-item .count { margin-left: auto; font-size: var(--fs-caption); color: var(--text3); font-family: var(--mono); }
|
|
567
582
|
.nav-divider { height: 1px; background: var(--border); margin: 12px 8px; }
|
|
583
|
+
.nav-views-label { padding: 0 18px; font-size: var(--fs-caption); color: var(--text3); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
584
|
+
.nav-views { display: flex; flex-direction: column; gap: 2px; padding: 6px 8px 12px; }
|
|
585
|
+
.nav-view-item { height: 28px; display: flex; align-items: center; padding: 0 10px; font-size: var(--fs-small); color: var(--text3); }
|
|
568
586
|
|
|
569
587
|
.sidebar-footer {
|
|
570
588
|
padding: 12px 16px;
|
|
@@ -1439,6 +1457,94 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
1439
1457
|
/* Same shape as `.muted`: the `--mono` token exists and is used 115×, the
|
|
1440
1458
|
CLASS that applies it did not. */
|
|
1441
1459
|
.mono { font-family: var(--mono); }
|
|
1460
|
+
/* ── Redesign primitives (DESIGN-board-redesign §6.2 GateChip, §6.3 StatusDot,
|
|
1461
|
+
§6.5 PostureStrip). Declared here, ahead of the screens that use them, so
|
|
1462
|
+
each screen task adds markup, not CSS. Rule they all share: weight and
|
|
1463
|
+
glyph carry meaning, hue is one-per-meaning — a filled dot is settled, a
|
|
1464
|
+
hollow one is in flight, an amber glyph is an absence. */
|
|
1465
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
|
1466
|
+
.dot.ok { background: var(--status-done); }
|
|
1467
|
+
.dot.bad { background: var(--status-blocked); }
|
|
1468
|
+
.dot.run { background: transparent; border: 1px solid var(--status-progress); }
|
|
1469
|
+
/* An absence glyph (`·` `—` `!` `?`): mono, amber, never green and never grey-filled. */
|
|
1470
|
+
.abs { font-family: var(--mono); color: var(--absent-warn-fg); }
|
|
1471
|
+
/* GateChip — one violet; solid = expensive, outline = routine, dashed = unclassified. */
|
|
1472
|
+
.gate { display: inline-flex; align-items: center; gap: 6px; height: 22px; padding: 0 9px; border-radius: 999px; font-family: var(--mono); font-size: var(--fs-caption); font-weight: 500; white-space: nowrap; }
|
|
1473
|
+
.gate.exp { background: var(--status-gate); color: var(--on-status); }
|
|
1474
|
+
.gate.rou { border: 1px solid var(--status-gate); color: var(--status-gate); }
|
|
1475
|
+
.gate.unc { border: 1px dashed var(--border-strong); color: var(--absent-warn-fg); }
|
|
1476
|
+
/* PostureStrip — one bed for "expensive", distinguished by words; routine is a hairline. */
|
|
1477
|
+
.pos { display: inline-flex; align-items: center; gap: 4px; height: 18px; padding: 0 7px; border-radius: 999px; font-family: var(--mono); font-size: var(--fs-eyebrow); white-space: nowrap; }
|
|
1478
|
+
.pos.exp { background: var(--posture-expensive-bg); color: var(--posture-expensive-fg); }
|
|
1479
|
+
.pos.rou { border: 1px solid var(--border); color: var(--text2); }
|
|
1480
|
+
.pos.unj { color: var(--absent-warn-fg); }
|
|
1481
|
+
.pos.warn { background: var(--absent-warn-bg); color: var(--absent-warn-fg); }
|
|
1482
|
+
/* A row-level band, not a chip: `Bash(node:*)` reads as a restriction and is a
|
|
1483
|
+
full shell — the subtlest finding agent-posture produces must not be scanned past. */
|
|
1484
|
+
.strip { display: flex; align-items: center; gap: 8px; padding: 4px 10px; background: var(--absent-warn-bg); color: var(--absent-warn-fg); font-size: var(--fs-caption); }
|
|
1485
|
+
/* Decisions row (BRD-R3): the two reviewer cells sit between the title and the
|
|
1486
|
+
actions; each glyph explains itself on hover. */
|
|
1487
|
+
.gate-review { display: inline-flex; gap: 14px; align-items: center; font-family: var(--mono); font-size: var(--fs-caption); white-space: nowrap; color: var(--text2); }
|
|
1488
|
+
.gate-review > span { display: inline-flex; gap: 6px; align-items: center; }
|
|
1489
|
+
/* Ledger head (great_cto-ki1x.7) */
|
|
1490
|
+
.ledger-kpis { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; }
|
|
1491
|
+
.ledger-strip { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 10px 12px; margin-top: 12px; background: var(--bg-card); border: 1px solid var(--border); font-family: var(--mono); font-size: var(--fs-caption); }
|
|
1492
|
+
.ledger-stage { display: inline-flex; align-items: center; gap: 6px; padding: 3px 9px; border: 1px solid var(--border); }
|
|
1493
|
+
.ledger-stuck { margin-top: 12px; background: var(--bg-card); border: 1px solid var(--border); padding: 10px 12px; }
|
|
1494
|
+
.ledger-stuck-head { display: flex; align-items: baseline; gap: 10px; margin-bottom: 6px; }
|
|
1495
|
+
.ledger-stuck-row { display: grid; grid-template-columns: 150px 1fr 60px; gap: 12px; align-items: center; font-size: var(--fs-small); padding: 4px 0; border-top: 1px solid var(--border); }
|
|
1496
|
+
.eyebrow { font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); font-weight: 500; }
|
|
1497
|
+
.tab { font-variant-numeric: tabular-nums; text-align: right; }
|
|
1498
|
+
.cap { font-size: var(--fs-caption); }
|
|
1499
|
+
.ledger-head { display: flex; flex-direction: column; }
|
|
1500
|
+
.ledger-facts { display: flex; flex-wrap: wrap; gap: 8px 28px; margin-top: 10px; font-size: var(--fs-small); align-items: baseline; }
|
|
1501
|
+
.bar { height: 6px; background: var(--bg-strong); border-radius: 999px; overflow: hidden; }
|
|
1502
|
+
.bar > i { display: block; height: 100%; background: var(--accent); }
|
|
1503
|
+
/* Fleet (great_cto-ki1x.8): saved views in the sidebar, domain groups, AgentRow cells */
|
|
1504
|
+
.nav-view-item { cursor: pointer; display: flex; justify-content: space-between; gap: 8px; }
|
|
1505
|
+
.nav-view-item:hover { background: var(--bg-muted); color: var(--text); }
|
|
1506
|
+
.nav-view-item.active { background: var(--bg-strong); color: var(--text); font-weight: 500; }
|
|
1507
|
+
.nav-view-count { font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); font-variant-numeric: tabular-nums; }
|
|
1508
|
+
.fleet-group-head { display: flex; align-items: center; gap: 8px; padding: 8px 12px 4px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); font-weight: 500; }
|
|
1509
|
+
.fleet-group-collapsed { display: flex; flex-wrap: wrap; gap: 20px; padding: 10px 12px; font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text3); }
|
|
1510
|
+
.ar-cells { display: grid; grid-template-columns: minmax(200px, 1.4fr) 130px 48px 56px 80px auto; gap: 12px; align-items: center; margin-top: 4px; }
|
|
1511
|
+
.ar-posture { display: flex; gap: 6px; flex-wrap: wrap; }
|
|
1512
|
+
.ar-last { color: var(--text2); }
|
|
1513
|
+
/* Harness (great_cto-ki1x.10) */
|
|
1514
|
+
.harness-hosts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 24px; }
|
|
1515
|
+
.harness-hosts > div { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
|
1516
|
+
.so-options { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-top: 10px; }
|
|
1517
|
+
.so-option { display: flex; flex-direction: column; gap: 4px; padding: 10px 12px; background: var(--bg-page); border: 1px solid var(--border); cursor: pointer; min-height: 44px; }
|
|
1518
|
+
.so-option.on { border-color: var(--accent); }
|
|
1519
|
+
.so-option input { position: absolute; opacity: 0; pointer-events: none; }
|
|
1520
|
+
.so-option:focus-within { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
|
1521
|
+
.so-label { font-size: var(--fs-small); }
|
|
1522
|
+
.so-why { font-size: var(--fs-caption); }
|
|
1523
|
+
.ev-table { margin-top: 8px; }
|
|
1524
|
+
.ev-head, .ev-row { display: grid; grid-template-columns: 110px 1fr 90px; gap: 12px; align-items: center; padding: 6px 0; border-top: 1px solid var(--border); font-size: var(--fs-small); }
|
|
1525
|
+
.ev-head { font-size: var(--fs-eyebrow); letter-spacing: .08em; text-transform: uppercase; color: var(--text2); border-top: none; }
|
|
1526
|
+
.ev-row.dim { color: var(--text3); }
|
|
1527
|
+
/* Settings (great_cto-ki1x.11) */
|
|
1528
|
+
.settings-rows { display: flex; flex-direction: column; gap: 16px; margin-bottom: 8px; }
|
|
1529
|
+
.settings-group { background: var(--bg-card); border: 1px solid var(--border); padding: 8px 16px 4px; }
|
|
1530
|
+
.settings-row { display: grid; grid-template-columns: 200px 1fr 140px; gap: 14px; align-items: center; padding: 9px 0; border-top: 1px solid var(--border); font-size: var(--fs-small); }
|
|
1531
|
+
.settings-group > .settings-row:first-of-type { border-top: none; }
|
|
1532
|
+
.sr-name { display: flex; flex-direction: column; min-width: 0; }
|
|
1533
|
+
.sr-value { min-width: 0; }
|
|
1534
|
+
.sr-action { display: flex; justify-content: flex-end; gap: 8px; }
|
|
1535
|
+
.pill.on { background: var(--text); color: var(--bg-page); }
|
|
1536
|
+
/* Two-ring focus (§7.1): the outer ring is the focus token, the inner ring is
|
|
1537
|
+
the page itself, so the ring reads on any bed — a card, a solid pill, a strip.
|
|
1538
|
+
Element selectors only; the per-component rules above keep their own offsets. */
|
|
1539
|
+
button:focus-visible, a:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, summary:focus-visible, [role="button"]:focus-visible, [tabindex]:focus-visible {
|
|
1540
|
+
outline: 2px solid var(--focus-ring); outline-offset: 2px; box-shadow: 0 0 0 2px var(--bg-page);
|
|
1541
|
+
}
|
|
1542
|
+
/* Theme toggle: two stroke icons, the theme picks one. No emoji — an emoji is
|
|
1543
|
+
a font the platform chooses, and it was rendering at three sizes on three OSes. */
|
|
1544
|
+
#board-theme-btn svg { width: 16px; height: 16px; stroke: currentColor; fill: none; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
1545
|
+
#board-theme-btn .ico-sun { display: none; }
|
|
1546
|
+
[data-theme="light"] #board-theme-btn .ico-sun { display: inline; }
|
|
1547
|
+
[data-theme="light"] #board-theme-btn .ico-moon { display: none; }
|
|
1442
1548
|
/* The board has two text inputs styled in place — `#agent-panel input` and
|
|
1443
1549
|
`.fleet-search input[type="search"]` — and a third, the Sessions search box,
|
|
1444
1550
|
that asked for the house look by name and got the user agent's. Same values
|
|
@@ -2225,10 +2331,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2225
2331
|
letter-spacing: 0.1em; text-transform: uppercase;
|
|
2226
2332
|
color: var(--text3); margin: 0 0 12px;
|
|
2227
2333
|
}
|
|
2228
|
-
.mp-hero {
|
|
2229
|
-
display: grid; grid-template-columns: repeat(3, 1fr);
|
|
2230
|
-
gap: 14px; margin-bottom: 24px;
|
|
2231
|
-
}
|
|
2232
2334
|
.window-fill {
|
|
2233
2335
|
font-size: var(--fs-body); color: var(--text2); line-height: 1.5;
|
|
2234
2336
|
padding: 8px 12px; margin-bottom: 12px; border-radius: 0;
|
|
@@ -2315,12 +2417,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2315
2417
|
margin-top: 6px; font-size: var(--fs-small); color: var(--text2);
|
|
2316
2418
|
}
|
|
2317
2419
|
|
|
2318
|
-
.mp-secondary {
|
|
2319
|
-
display: grid; grid-template-columns: repeat(4, 1fr);
|
|
2320
|
-
gap: 12px; margin-bottom: 28px;
|
|
2321
|
-
}
|
|
2322
|
-
.mp-secondary .metric-card { padding: 18px; }
|
|
2323
|
-
.mp-secondary .metric-num { font-size: var(--fs-num-m); }
|
|
2324
2420
|
|
|
2325
2421
|
.mp-row {
|
|
2326
2422
|
display: grid; grid-template-columns: 1fr 1fr; gap: 16px;
|
|
@@ -2482,15 +2578,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2482
2578
|
border-radius: 0; padding: 1px 5px; font-size: var(--fs-eyebrow);
|
|
2483
2579
|
}
|
|
2484
2580
|
|
|
2485
|
-
.fleet-facets { display: flex; flex-direction: column; gap: 6px; }
|
|
2486
|
-
.fleet-facets fieldset {
|
|
2487
|
-
border: none; padding: 0; margin: 0;
|
|
2488
|
-
display: flex; gap: 6px; align-items: center; flex-wrap: wrap;
|
|
2489
|
-
}
|
|
2490
|
-
.fleet-facets legend {
|
|
2491
|
-
font-size: var(--fs-caption); color: var(--text3); text-transform: uppercase;
|
|
2492
|
-
letter-spacing: 0.05em; margin-right: 6px; min-width: 60px;
|
|
2493
|
-
}
|
|
2494
2581
|
.facet-chip {
|
|
2495
2582
|
background: var(--bg-muted); border: 1px solid var(--border);
|
|
2496
2583
|
color: var(--text2); font-size: var(--fs-small); font-family: var(--mono);
|
|
@@ -2940,7 +3027,45 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2940
3027
|
@media (max-width:480px) {
|
|
2941
3028
|
.chip-filter-bar { flex-direction: column; align-items: stretch; }
|
|
2942
3029
|
}
|
|
3030
|
+
/* ── Motion contract (DESIGN §10, MOTION_INTENSITY 2/10): motion for state the
|
|
3031
|
+
operator caused, none for state the machine caused. Hover and focus 90ms
|
|
3032
|
+
linear; the drawer 200ms out / 140ms in (exit faster than enter); SSE
|
|
3033
|
+
arrival 0ms — rows and figures swap, they do not animate in. ── */
|
|
3034
|
+
.nav-item, .nav-view-item, .agent-row, .inbox-row, .gate-btn, .btn-black, .btn-ghost-sm, .pill, .so-option { transition: background-color 90ms linear, border-color 90ms linear, outline-color 90ms linear; }
|
|
3035
|
+
@media (prefers-reduced-motion: reduce) {
|
|
3036
|
+
*, *::before, *::after { transition-duration: 1ms !important; animation-duration: 1ms !important; animation-iteration-count: 1 !important; scroll-behavior: auto !important; }
|
|
3037
|
+
}
|
|
3038
|
+
/* ── 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
|
+
}
|
|
2943
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
|
+
|
|
2944
3069
|
.sidebar {
|
|
2945
3070
|
position: fixed; top: 0; left: 0; bottom: 0;
|
|
2946
3071
|
z-index: 40;
|
|
@@ -2953,7 +3078,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
2953
3078
|
focus on the button and the menu unreachable by keyboard. On the way out
|
|
2954
3079
|
it waits for the slide to finish (`0s linear 200ms`), so the panel does
|
|
2955
3080
|
not vanish mid-animation. */
|
|
2956
|
-
transition: transform
|
|
3081
|
+
transition: transform 140ms ease-in, visibility 0s linear 140ms;
|
|
2957
3082
|
box-shadow: none;
|
|
2958
3083
|
}
|
|
2959
3084
|
.sidebar.open {
|
|
@@ -3014,8 +3139,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3014
3139
|
/* Four columns of numbers do not fit in 375px; they were being clipped, which
|
|
3015
3140
|
silently dropped the fourth stat rather than wrapping it. */
|
|
3016
3141
|
.inbox-summary { grid-template-columns: repeat(2, 1fr); }
|
|
3017
|
-
.mp-secondary { grid-template-columns: repeat(2, 1fr); }
|
|
3018
|
-
.mp-hero { grid-template-columns: 1fr; }
|
|
3019
3142
|
.mp-row { grid-template-columns: 1fr; }
|
|
3020
3143
|
}
|
|
3021
3144
|
@media (pointer: coarse) {
|
|
@@ -3045,7 +3168,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3045
3168
|
So: every control gets the floor, and the ones with something to say about
|
|
3046
3169
|
their own size say it after. A button added tomorrow is covered by default;
|
|
3047
3170
|
one that must stay small has to opt out in writing. */
|
|
3048
|
-
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; }
|
|
3049
3172
|
.gate-btn, .inbox-row .actions .gate-btn { height: 44px; font-size: var(--fs-body); }
|
|
3050
3173
|
.menu-btn { width: 44px; height: 44px; }
|
|
3051
3174
|
.btn-bell { min-width: 44px; justify-content: center; }
|
|
@@ -3094,44 +3217,57 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3094
3217
|
<div id="proj-menu-list"></div>
|
|
3095
3218
|
</div>
|
|
3096
3219
|
</div>
|
|
3097
|
-
|
|
3098
|
-
|
|
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)">
|
|
3099
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>
|
|
3100
|
-
<span>
|
|
3232
|
+
<span>Decisions</span>
|
|
3101
3233
|
<span class="count" id="nav-inbox-count">0</span>
|
|
3102
3234
|
</div>
|
|
3103
|
-
<div class="nav-item" data-tab="
|
|
3104
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M9 11l3 3L22 4 M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>
|
|
3105
|
-
<span>Tasks</span>
|
|
3106
|
-
<span class="count" id="nav-task-count">0</span>
|
|
3107
|
-
</div>
|
|
3108
|
-
<div class="nav-item" data-tab="dashboard" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('dashboard', this)">
|
|
3109
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 3v18h18 M7 14l3-3 4 4 5-5"/></svg>
|
|
3110
|
-
<span>Metrics</span>
|
|
3111
|
-
</div>
|
|
3112
|
-
<div class="nav-item" data-tab="docs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('docs', this)">
|
|
3113
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
|
|
3114
|
-
<span>Docs</span>
|
|
3115
|
-
<span class="count" id="nav-docs-count">0</span>
|
|
3116
|
-
</div>
|
|
3117
|
-
<div class="nav-item" data-tab="logs" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('logs', this)">
|
|
3118
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/></svg>
|
|
3119
|
-
<span>Activity</span>
|
|
3120
|
-
<span class="count" id="nav-logs-count">0</span>
|
|
3121
|
-
</div>
|
|
3122
|
-
<div class="nav-item" data-tab="budgets" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('budgets', this)">
|
|
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)">
|
|
3123
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>
|
|
3124
|
-
<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. -->
|
|
3125
3241
|
<span class="count" id="nav-budget-count"></span>
|
|
3126
3242
|
</div>
|
|
3127
|
-
<div class="nav-item" data-tab="
|
|
3128
|
-
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><
|
|
3129
|
-
<span>
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
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>
|
|
3133
3255
|
</div>
|
|
3134
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>
|
|
3135
3271
|
<div class="sidebar-footer">
|
|
3136
3272
|
<!-- Ships in the CONNECTING state. This markup used to say "live · synced
|
|
3137
3273
|
just now" before any connection existed, so from first paint until the
|
|
@@ -3164,15 +3300,14 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3164
3300
|
</div>
|
|
3165
3301
|
<div class="topbar-actions">
|
|
3166
3302
|
<span class="task-count" id="topbar-task-count">0 ISSUES</span>
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3172
|
-
<button class="btn-bell" id="
|
|
3173
|
-
|
|
3174
|
-
<
|
|
3175
|
-
<span class="btn-bell-badge" id="topbar-bell-badge"></span>
|
|
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>
|
|
3176
3311
|
</button>
|
|
3177
3312
|
<button class="btn-new" onclick="openNewIssueModal()" aria-label="New issue" title="New issue">
|
|
3178
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>
|
|
@@ -3247,14 +3382,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3247
3382
|
absent Codex must not render as a switch in the off position. The
|
|
3248
3383
|
evidence rows are the tail of .great_cto/cross-review.log: what the
|
|
3249
3384
|
second opinion did, not what it is set to. -->
|
|
3250
|
-
<div class="inbox-section" id="harnesses-section">
|
|
3251
|
-
<div class="inbox-section-head">
|
|
3252
|
-
<span class="dot dot-blue"></span>
|
|
3253
|
-
<h3>Harnesses</h3>
|
|
3254
|
-
<span class="inbox-count" id="harnesses-status">…</span>
|
|
3255
|
-
</div>
|
|
3256
|
-
<div id="harnesses-body" class="muted" style="font-size:13px;line-height:1.5">loading…</div>
|
|
3257
|
-
</div>
|
|
3258
3385
|
</div>
|
|
3259
3386
|
</details>
|
|
3260
3387
|
|
|
@@ -3323,6 +3450,24 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3323
3450
|
</div>
|
|
3324
3451
|
|
|
3325
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
|
+
|
|
3326
3471
|
<div class="panel" id="panel-kanban">
|
|
3327
3472
|
<h1 class="page-title">Tasks</h1>
|
|
3328
3473
|
<div id="filter-bar" class="filter-bar"></div>
|
|
@@ -3331,38 +3476,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3331
3476
|
|
|
3332
3477
|
|
|
3333
3478
|
<!-- Metrics -->
|
|
3334
|
-
<div class="panel" id="panel-dashboard">
|
|
3335
|
-
<div class="metrics-page">
|
|
3336
|
-
<h1 class="page-title">Metrics</h1>
|
|
3337
|
-
<div id="mp-window-fill" class="window-fill" hidden></div>
|
|
3338
|
-
<div class="period-toolbar">
|
|
3339
|
-
<span class="period-label">PERIOD</span>
|
|
3340
|
-
<div class="period-chips" id="period-chips">
|
|
3341
|
-
<button class="period-chip" data-period="1">1D</button>
|
|
3342
|
-
<button class="period-chip" data-period="7">7D</button>
|
|
3343
|
-
<button class="period-chip active" data-period="30">30D</button>
|
|
3344
|
-
<button class="period-chip" data-period="90">90D</button>
|
|
3345
|
-
<button class="period-chip" data-period="365">1Y</button>
|
|
3346
|
-
</div>
|
|
3347
|
-
</div>
|
|
3348
|
-
<div class="mp-section-label" id="mp-sprint-label">Last 30 days</div>
|
|
3349
|
-
<div class="mp-hero" id="mp-hero"></div>
|
|
3350
|
-
<div class="mp-secondary" id="mp-secondary"></div>
|
|
3351
|
-
|
|
3352
|
-
<!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
|
|
3353
|
-
drill-in. A control the operator could not find is a control they do
|
|
3354
|
-
not have — and it was invisible on top of that, hidden whenever a
|
|
3355
|
-
project had declared no caps, which is exactly the project that
|
|
3356
|
-
needs the button to declare one. -->
|
|
3357
|
-
<div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
|
|
3358
|
-
<div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:var(--fs-small);margin-bottom:4px;"></div>
|
|
3359
|
-
|
|
3360
|
-
<div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
|
|
3361
|
-
<div class="mp-card cost-panel" id="cost-panel">
|
|
3362
|
-
<div class="cost-empty">Loading…</div>
|
|
3363
|
-
</div>
|
|
3364
|
-
</div>
|
|
3365
|
-
</div>
|
|
3366
3479
|
|
|
3367
3480
|
<!-- Agents -->
|
|
3368
3481
|
<!-- Session Logs -->
|
|
@@ -3390,23 +3503,25 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3390
3503
|
<div class="panel" id="panel-agents" role="tabpanel" aria-labelledby="tab-agents">
|
|
3391
3504
|
<div class="metrics-page">
|
|
3392
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. -->
|
|
3393
3510
|
<section aria-label="Fleet summary">
|
|
3394
|
-
<
|
|
3395
|
-
<div class="mp-row" id="fleet-summary">
|
|
3396
|
-
<div class="cost-empty">Loading…</div>
|
|
3397
|
-
</div>
|
|
3511
|
+
<h1 class="page-title" id="fleet-view-title">Fleet</h1>
|
|
3398
3512
|
</section>
|
|
3399
3513
|
|
|
3400
3514
|
<!-- §3.1 Fleet controls -->
|
|
3401
3515
|
<section aria-label="Fleet controls" style="margin-top:20px">
|
|
3402
|
-
<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">
|
|
3403
3517
|
<div class="fleet-search">
|
|
3404
3518
|
<input type="search" id="fleet-search" placeholder="Filter agents (press / to focus)" aria-label="Filter agents by slug" />
|
|
3405
3519
|
<span class="search-hint"><kbd>/</kbd> focus · <kbd>Enter</kbd> open · <kbd>Esc</kbd> close</span>
|
|
3406
3520
|
</div>
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
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. -->
|
|
3410
3525
|
<div class="fleet-sort">
|
|
3411
3526
|
<label for="fleet-sort-by">Sort by</label>
|
|
3412
3527
|
<select id="fleet-sort-by">
|
|
@@ -3480,8 +3595,37 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3480
3595
|
|
|
3481
3596
|
<div class="panel" id="panel-budgets" role="tabpanel">
|
|
3482
3597
|
<div class="metrics-page">
|
|
3483
|
-
<h1 class="page-title">
|
|
3484
|
-
|
|
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">
|
|
3485
3629
|
A cap on what one agent may spend. When measured spend passes it, the pipeline
|
|
3486
3630
|
refuses to dispatch that agent and says so — it does not fail quietly.
|
|
3487
3631
|
Spend is measured from verdicts that carry a cost; while none does, the cap
|
|
@@ -3492,11 +3636,6 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3492
3636
|
connected" and "nothing to check" read the same from here, and the
|
|
3493
3637
|
key is not discoverable: the README names OpenRouter once without
|
|
3494
3638
|
saying where it goes. -->
|
|
3495
|
-
<div id="judge-status" class="judge-status" hidden></div>
|
|
3496
|
-
<!-- The verdicts of the verifier itself. Kept beside the connection
|
|
3497
|
-
status because the two answer consecutive questions: is anything
|
|
3498
|
-
checking, and what did it find. -->
|
|
3499
|
-
<div id="judge-scores" class="jscores" hidden></div>
|
|
3500
3639
|
<div id="budgets-page-body">loading…</div>
|
|
3501
3640
|
</div>
|
|
3502
3641
|
</div>
|
|
@@ -3529,7 +3668,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3529
3668
|
</div>
|
|
3530
3669
|
|
|
3531
3670
|
<!-- Share -->
|
|
3532
|
-
|
|
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">
|
|
3533
3680
|
<div class="share-page">
|
|
3534
3681
|
<div class="share-card">
|
|
3535
3682
|
<h2>Share this project</h2>
|
|
@@ -3566,10 +3713,8 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3566
3713
|
</div>
|
|
3567
3714
|
</div>
|
|
3568
3715
|
</div>
|
|
3569
|
-
|
|
3570
|
-
|
|
3571
|
-
<!-- Notifications -->
|
|
3572
|
-
<div class="panel" id="panel-notifications">
|
|
3716
|
+
</div>
|
|
3717
|
+
<div id="panel-notifications">
|
|
3573
3718
|
<div class="share-page">
|
|
3574
3719
|
<h1 class="page-title">Notifications</h1>
|
|
3575
3720
|
<div class="share-card">
|
|
@@ -3666,6 +3811,7 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
|
|
|
3666
3811
|
</div>
|
|
3667
3812
|
</div>
|
|
3668
3813
|
</div>
|
|
3814
|
+
</div>
|
|
3669
3815
|
</div>
|
|
3670
3816
|
|
|
3671
3817
|
<!-- Task side panel — slide-over detail view, opened by openSide() -->
|
|
@@ -3767,7 +3913,52 @@ function elsewhereClause(e) {
|
|
|
3767
3913
|
return { known: true, text: `${parts.join(' and ')} in ${m} other project${m > 1 ? 's' : ''} ${verb} waiting on you.` };
|
|
3768
3914
|
}
|
|
3769
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
|
+
}
|
|
3770
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) };
|
|
3771
3962
|
// Kept so renderResume can ask what this section is already showing, rather
|
|
3772
3963
|
// than the two of them rendering the same task independently.
|
|
3773
3964
|
window.__inboxData = d;
|
|
@@ -4337,10 +4528,11 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
4337
4528
|
+ `<button type="button" class="gate-btn" title="Copy the line that re-runs this agent"`
|
|
4338
4529
|
+ ` onclick="event.stopPropagation(); copyRerun('${esc(t.agent)}')">Copy re-run</button></div>`
|
|
4339
4530
|
: '';
|
|
4531
|
+
const guardedGate = canDecide && t.reversibility && t.reversibility.state !== 'routine';
|
|
4340
4532
|
const actions = canDecide
|
|
4341
|
-
? `<div class="actions" onclick="event.stopPropagation()">
|
|
4342
|
-
<button class="gate-btn gate-
|
|
4343
|
-
<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>
|
|
4344
4536
|
</div>`
|
|
4345
4537
|
// Age is promoted out of the metadata run into its own column, because it
|
|
4346
4538
|
// is the only field that differs between these rows. `status` stays,
|
|
@@ -4382,8 +4574,9 @@ function renderInboxList(rootId, items, countId, opts = {}) {
|
|
|
4382
4574
|
: '';
|
|
4383
4575
|
return `
|
|
4384
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, "'")})'>
|
|
4385
|
-
|
|
4386
|
-
<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) : ''}
|
|
4387
4580
|
${actions}${rerun}${diffBtn}
|
|
4388
4581
|
</div>`;
|
|
4389
4582
|
}).join('');
|
|
@@ -4599,7 +4792,7 @@ async function loadJudgeStatus() {
|
|
|
4599
4792
|
el.hidden = false;
|
|
4600
4793
|
el.innerHTML = connected
|
|
4601
4794
|
? `<span class="js-dot"></span>
|
|
4602
|
-
<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>
|
|
4603
4796
|
<span class="js-where">${esc(s.fingerprint || '')} · from ${esc(String(s.from || '').replace(/^.*\/(?=\.great_cto)/, '~/'))}</span>
|
|
4604
4797
|
<span class="js-form">
|
|
4605
4798
|
<input id="judge-key" type="password" autocomplete="off" spellcheck="false" placeholder="replace key (sk-or-…)">
|
|
@@ -4705,6 +4898,171 @@ async function saveJudgeKey() {
|
|
|
4705
4898
|
loadJudgeStatus();
|
|
4706
4899
|
}
|
|
4707
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
|
+
|
|
4708
5066
|
async function loadBudgetsPage() {
|
|
4709
5067
|
loadJudgeStatus();
|
|
4710
5068
|
loadJudgeScores();
|
|
@@ -4720,13 +5078,17 @@ async function loadBudgetsPage() {
|
|
|
4720
5078
|
// promise rejected, and the page sat there looking busy. A page that failed
|
|
4721
5079
|
// and a page still working are the same picture, and the one you are looking
|
|
4722
5080
|
// at is the one where nothing more is coming.
|
|
4723
|
-
let h;
|
|
5081
|
+
let h, pipe = null, cost = null, m = null;
|
|
4724
5082
|
try {
|
|
4725
|
-
[h] = await Promise.all([
|
|
5083
|
+
[h, pipe, cost, m] = await Promise.all([
|
|
4726
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),
|
|
4727
5088
|
(typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
|
|
4728
5089
|
? refreshAgentsInstalled() : Promise.resolve(),
|
|
4729
5090
|
]);
|
|
5091
|
+
renderLedgerHead({ heartbeat: h, pipeline: pipe, cost, metrics: m });
|
|
4730
5092
|
} catch (err) {
|
|
4731
5093
|
body.innerHTML = `
|
|
4732
5094
|
<div class="budgets-failed">
|
|
@@ -5247,20 +5609,80 @@ async function renderHarnesses() {
|
|
|
5247
5609
|
hb.style.color = so.state === 'unavailable' ? 'var(--status-blocked)' : '';
|
|
5248
5610
|
hb.style.display = hb.textContent ? '' : 'none';
|
|
5249
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
|
+
|
|
5250
5654
|
body.innerHTML = `
|
|
5251
|
-
<div
|
|
5252
|
-
|
|
5253
|
-
|
|
5254
|
-
|
|
5255
|
-
|
|
5256
|
-
<
|
|
5257
|
-
|
|
5258
|
-
|
|
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>
|
|
5259
5671
|
</div>
|
|
5260
|
-
<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();
|
|
5261
5683
|
|
|
5262
5684
|
document.getElementById('so-apply').onclick = async () => {
|
|
5263
|
-
const v = document.
|
|
5685
|
+
const v = (document.querySelector('input[name="so"]:checked') || {}).value || '';
|
|
5264
5686
|
const out = document.getElementById('so-result');
|
|
5265
5687
|
out.textContent = 'writing PROJECT.md…';
|
|
5266
5688
|
try {
|
|
@@ -5474,8 +5896,9 @@ async function refreshMetricsForPeriod() {
|
|
|
5474
5896
|
if (lbl) lbl.textContent = `Last ${periodLabel(currentPeriod)}`;
|
|
5475
5897
|
// Use seq-guarded fetch so stale init/SSE fetches can't overwrite us
|
|
5476
5898
|
const m = await _refreshMetricsRaced(`/api/metrics${pqsd()}`);
|
|
5477
|
-
if (m) {
|
|
5899
|
+
if (m) { metrics = m; if (typeof renderWindowFill === 'function') renderWindowFill(m); }
|
|
5478
5900
|
refreshCost();
|
|
5901
|
+
if (currentTab === 'budgets') loadBudgetsPage();
|
|
5479
5902
|
}
|
|
5480
5903
|
|
|
5481
5904
|
/* Why four periods can show the same number.
|
|
@@ -5796,7 +6219,7 @@ async function loadDocs() {
|
|
|
5796
6219
|
return;
|
|
5797
6220
|
}
|
|
5798
6221
|
_docsCache = d;
|
|
5799
|
-
document.getElementById('nav-docs-count').textContent = d.total || 0;
|
|
6222
|
+
const navDocsCount = document.getElementById('nav-docs-count'); if (navDocsCount) navDocsCount.textContent = d.total || 0;
|
|
5800
6223
|
|
|
5801
6224
|
const pipeline = d.pipeline
|
|
5802
6225
|
? `<div class="card"><b>Pipeline</b> <span class="muted">— how a feature moves, drawn from shared/pipeline.toml</span>
|
|
@@ -6329,7 +6752,6 @@ async function switchProject(slug) {
|
|
|
6329
6752
|
try { renderInbox(inbox); } catch (e) { console.warn('renderInbox failed', e); }
|
|
6330
6753
|
try { renderFilterBar(); } catch (e) { console.warn('renderFilterBar failed', e); }
|
|
6331
6754
|
try { renderKanban(allTasks); } catch (e) { console.warn('renderKanban failed', e); }
|
|
6332
|
-
try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
|
|
6333
6755
|
try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
|
|
6334
6756
|
loadMemory();
|
|
6335
6757
|
refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
|
|
@@ -6518,7 +6940,6 @@ async function init() {
|
|
|
6518
6940
|
try { renderInbox(inbox); } catch (e) { console.warn('renderInbox failed', e); }
|
|
6519
6941
|
try { renderFilterBar(); } catch (e) { console.warn('renderFilterBar failed', e); }
|
|
6520
6942
|
try { renderKanban(allTasks); } catch (e) { console.warn('renderKanban failed', e); }
|
|
6521
|
-
try { renderDashboard(metrics); } catch (e) { console.warn('renderDashboard failed', e); }
|
|
6522
6943
|
try { renderShareState(shareState); } catch (e) { console.warn('renderShareState failed', e); }
|
|
6523
6944
|
loadMemory();
|
|
6524
6945
|
refreshDocsCount(); // the badge said 0 until the tab was opened — same bug the Logs badge had
|
|
@@ -6731,7 +7152,9 @@ function connectSSE() {
|
|
|
6731
7152
|
}
|
|
6732
7153
|
|
|
6733
7154
|
function updateNavCounts() {
|
|
6734
|
-
|
|
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;
|
|
6735
7158
|
document.getElementById('topbar-task-count').textContent = `${allTasks.length} ISSUES`;
|
|
6736
7159
|
}
|
|
6737
7160
|
|
|
@@ -6936,13 +7359,23 @@ function cardHTML(t) {
|
|
|
6936
7359
|
// following stage, and — when the share report is on — a public URL is
|
|
6937
7360
|
// republished. That last line is the one worth reading: it leaves the machine,
|
|
6938
7361
|
// and revoking the link later does not un-see what was already served.
|
|
6939
|
-
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;
|
|
6940
7368
|
const lines = [
|
|
6941
7369
|
`Approve ${id}?`,
|
|
6942
7370
|
'',
|
|
6943
7371
|
'Records your approval in the verdict log and wakes the pipeline —',
|
|
6944
7372
|
'the next session dispatches the following stage.',
|
|
6945
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
|
+
}
|
|
6946
7379
|
if (shareState && shareState.enabled) {
|
|
6947
7380
|
lines.push('', `Also republishes the public report at ${shareState.url || 'the share URL'} —`,
|
|
6948
7381
|
'anyone holding that link sees the update.');
|
|
@@ -6966,7 +7399,18 @@ async function gateAction(id, action, runAgent) {
|
|
|
6966
7399
|
// a typed-name ritual on a button pressed several times a day. The runAgent
|
|
6967
7400
|
// path already shows an editable prompt that says what it will approve and
|
|
6968
7401
|
// run, and is cancellable — a second dialog there would be pure friction.
|
|
6969
|
-
|
|
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
|
|
6970
7414
|
body = { action, project: currentProject };
|
|
6971
7415
|
if (runAgent) {
|
|
6972
7416
|
const generic = `Gate ${id} was just approved. Continue the great_cto pipeline: claim the next ready task and implement it with TDD.`;
|
|
@@ -7076,6 +7520,8 @@ const ABSENCE = {
|
|
|
7076
7520
|
none: '—', // measured; the answer is that there is none
|
|
7077
7521
|
uncomputable: 'n/a', // the inputs for this figure do not exist
|
|
7078
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
|
|
7079
7525
|
};
|
|
7080
7526
|
|
|
7081
7527
|
function absent(kind, why) {
|
|
@@ -7112,183 +7558,6 @@ function deltaBadge(current, prev, comparable, { invert = false } = {}) {
|
|
|
7112
7558
|
}
|
|
7113
7559
|
|
|
7114
7560
|
/* ── Dashboard ──────────────────────────────────────────────────────────── */
|
|
7115
|
-
function renderDashboard(m) {
|
|
7116
|
-
const llmUsd = m.cost?.llm_usd ?? 0;
|
|
7117
|
-
const realLlmUsd = m.cost?.real_llm_usd ?? 0;
|
|
7118
|
-
const planSavingsX = m.cost?.savings_x;
|
|
7119
|
-
// Prefer the window-scoped count, but fall back to the all-time total when it
|
|
7120
|
-
// is 0 — otherwise projects with completed-but-untimestamped tasks (e.g. a
|
|
7121
|
-
// tasks.md fallback with no dates) show a meaningless "—" despite real work.
|
|
7122
|
-
// (`??` kept 0 because done_in_window is 0, not null; `||` fixes that.)
|
|
7123
|
-
const done = (m.tasks?.done_in_window || m.tasks?.done) ?? 0;
|
|
7124
|
-
// Three states, not two. `?? 0` folds "the payload has no tasks section" into
|
|
7125
|
-
// the same 0 as "zero tasks done", and the tile then rendered that 0 as an
|
|
7126
|
-
// absence — so a day-one project read "—" beside "+0 this week", the board
|
|
7127
|
-
// reporting a failure to measure something it had measured fine. The backend
|
|
7128
|
-
// always sends `tasks` (lib/metrics.mjs), so this is "—" only for a payload
|
|
7129
|
-
// that really is missing it.
|
|
7130
|
-
const doneKnown = m.tasks != null;
|
|
7131
|
-
const costKnown = m.cost != null;
|
|
7132
|
-
// NOT the same shape: avg_completion_min is a median over completions that
|
|
7133
|
-
// carry timestamps, and the backend emits 0 when there are none — so 0 here
|
|
7134
|
-
// means "could not be computed", not "took no time". "—" is the correct
|
|
7135
|
-
// reading; do not "fix" this into a literal 0 m.
|
|
7136
|
-
const avgMin = m.avg_completion_min ?? 0;
|
|
7137
|
-
const cycleStat = m.cycle_time_stat === 'median_30d' ? 'Median cycle (30d)' : 'Avg cycle time';
|
|
7138
|
-
|
|
7139
|
-
// Honest cost model — the backend is the source of truth:
|
|
7140
|
-
// cost.savings_x = MEASURED savings, only set when real verdict cost data
|
|
7141
|
-
// exists. NULL for the time-based (tasks) estimate, because
|
|
7142
|
-
// that ratio is just the hardcoded rate ratio, not a real
|
|
7143
|
-
// measurement — showing it would fabricate a number.
|
|
7144
|
-
// cost.human_usd = the backend's conservative human-cost estimate.
|
|
7145
|
-
// The frontend must NOT recompute its own human/savings figures (that produced
|
|
7146
|
-
// an inflated "×" that contradicted the API); it displays the backend fields.
|
|
7147
|
-
const aiSpend = realLlmUsd > 0 ? realLlmUsd : llmUsd;
|
|
7148
|
-
const humanEst = m.cost?.human_usd ?? 0;
|
|
7149
|
-
const savingsX = (typeof planSavingsX === 'number' && planSavingsX > 1)
|
|
7150
|
-
? planSavingsX
|
|
7151
|
-
: null;
|
|
7152
|
-
|
|
7153
|
-
const llmTrend = realLlmUsd > 0
|
|
7154
|
-
? `real tokens spent (from verdicts)`
|
|
7155
|
-
: (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
|
|
7156
|
-
|
|
7157
|
-
const hero = [
|
|
7158
|
-
{
|
|
7159
|
-
v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'),
|
|
7160
|
-
sub: 'shipped', label: 'Tasks done',
|
|
7161
|
-
trend: `+${m.velocity?.this_week ?? 0} this week`,
|
|
7162
|
-
delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
|
|
7163
|
-
},
|
|
7164
|
-
{
|
|
7165
|
-
// Three states, and $0.00 is only one of them. A measured zero is worth
|
|
7166
|
-
// showing; a zero that is the TIME-BASED ESTIMATE with no plans or tasks
|
|
7167
|
-
// to estimate from is not a figure at all, and rendering it as $0.00 told
|
|
7168
|
-
// a project with dozens of agent runs that it had spent nothing.
|
|
7169
|
-
v: !costKnown
|
|
7170
|
-
? absent('unloaded', 'the metrics payload carried no cost section')
|
|
7171
|
-
: (realLlmUsd === 0 && llmUsd === 0
|
|
7172
|
-
? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
|
|
7173
|
-
: `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}`),
|
|
7174
|
-
sub: '',
|
|
7175
|
-
label: 'AI spend',
|
|
7176
|
-
delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
|
|
7177
|
-
trend: llmTrend,
|
|
7178
|
-
cls: 'amber',
|
|
7179
|
-
},
|
|
7180
|
-
(savingsX != null && savingsX > 1)
|
|
7181
|
-
? {
|
|
7182
|
-
v: savingsX.toLocaleString(),
|
|
7183
|
-
sub: '×',
|
|
7184
|
-
label: 'Cost savings vs FTE',
|
|
7185
|
-
trend: humanEst > 0 ? `measured · human est. $${fmtMoney(humanEst)}` : 'measured savings',
|
|
7186
|
-
cls: 'green',
|
|
7187
|
-
}
|
|
7188
|
-
: {
|
|
7189
|
-
// No MEASURED savings yet (no verdict cost data). Don't fabricate a
|
|
7190
|
-
// multiplier from the time-based estimate — show it's not measured.
|
|
7191
|
-
v: absent('uncomputable', 'a measured multiplier needs verdict cost data, and there is none yet'),
|
|
7192
|
-
sub: '',
|
|
7193
|
-
label: 'Cost savings vs FTE',
|
|
7194
|
-
trend: aiSpend > 0
|
|
7195
|
-
? 'estimate only · run pipelines for measured savings'
|
|
7196
|
-
: (done > 0 ? 'no spend recorded yet' : 'no tasks done yet'),
|
|
7197
|
-
cls: '',
|
|
7198
|
-
},
|
|
7199
|
-
];
|
|
7200
|
-
document.getElementById('mp-hero').innerHTML = hero.map(s => `
|
|
7201
|
-
<div class="metric-card ${s.cls || ''}">
|
|
7202
|
-
<div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
|
|
7203
|
-
<div class="metric-label">${s.label}${s.delta || ''}</div>
|
|
7204
|
-
<div class="metric-trend">${s.trend}</div>
|
|
7205
|
-
</div>`).join('');
|
|
7206
|
-
|
|
7207
|
-
const acc = m.acceptance || {};
|
|
7208
|
-
const secondary = [
|
|
7209
|
-
{ v: avgMin ? `${avgMin}` : absent('uncomputable', 'no completion in this window carried a timestamp to measure'), sub: avgMin ? 'm' : '', label: cycleStat },
|
|
7210
|
-
{ v: m.qa?.pass_rate != null ? m.qa.pass_rate : absent('none', 'no QA run recorded in this window'), sub: m.qa?.pass_rate != null ? '%' : '', label: m.qa?.pass_rate != null ? 'QA pass rate' : 'QA — run /qa', cls: 'green' },
|
|
7211
|
-
// Verified-acceptance metrics: cost of a change a gate actually APPROVED, and
|
|
7212
|
-
// how often work bounced. '—' when nothing was accepted in the window — an
|
|
7213
|
-
// honest gap, not a zero.
|
|
7214
|
-
{ v: acc.cost_per_accepted != null ? `$${acc.cost_per_accepted.toFixed(2)}` : absent('none', 'nothing was accepted in this window'), sub: '', label: acc.accepted ? `Cost / accepted (${acc.accepted})` : 'Cost / accepted' },
|
|
7215
|
-
// `?? 0` here painted a GREEN ZERO over a question nobody asked. The tile one
|
|
7216
|
-
// line above goes out of its way to avoid exactly this, and these two did not:
|
|
7217
|
-
// a project whose acceptance data is missing showed "0 rework rounds" in
|
|
7218
|
-
// green, and — worse — a project where security has NEVER BEEN SCANNED showed
|
|
7219
|
-
// "0 open security blocks" in green, which is a passing security scan that
|
|
7220
|
-
// did not happen. That is this board's governing defect rendered in its most
|
|
7221
|
-
// reassuring colour.
|
|
7222
|
-
{
|
|
7223
|
-
v: acc.rework_rounds != null ? acc.rework_rounds : absent('uncomputable', 'no accepted work in this window, so rework cannot be counted'),
|
|
7224
|
-
sub: '', label: 'Rework rounds',
|
|
7225
|
-
cls: acc.rework_rounds == null ? '' : acc.rework_rounds ? 'amber' : 'green',
|
|
7226
|
-
},
|
|
7227
|
-
{
|
|
7228
|
-
v: m.security?.blocked != null ? m.security.blocked : absent('uncomputable', 'no security scan has run — this is not a clean result'),
|
|
7229
|
-
sub: '', label: 'Open security blocks',
|
|
7230
|
-
// No colour when unmeasured. Green would say "safe"; red would say
|
|
7231
|
-
// "unsafe"; neither is known.
|
|
7232
|
-
cls: m.security?.blocked == null ? '' : m.security.blocked ? 'red' : 'green',
|
|
7233
|
-
},
|
|
7234
|
-
{ v: m.tasks?.in_progress ?? 0, sub: '', label: 'In progress', cls: 'amber' },
|
|
7235
|
-
];
|
|
7236
|
-
document.getElementById('mp-secondary').innerHTML = secondary.map(s => `
|
|
7237
|
-
<div class="metric-card ${s.cls || ''}">
|
|
7238
|
-
<div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
|
|
7239
|
-
<div class="metric-label">${s.label}</div>
|
|
7240
|
-
</div>`).join('');
|
|
7241
|
-
|
|
7242
|
-
const agents = m.agents || {};
|
|
7243
|
-
const maxRuns = Math.max(...Object.values(agents), 1);
|
|
7244
|
-
const colors = ['#2540d4','#7c3aed','#16a34a','#f59e0b','#06b6d4','#2563eb','#64748b','#94a3b8'];
|
|
7245
|
-
const agentKeys = Object.entries(agents).sort((a,b)=>b[1]-a[1]);
|
|
7246
|
-
const agentListEl = document.getElementById('agent-list');
|
|
7247
|
-
if (agentListEl) {
|
|
7248
|
-
agentListEl.innerHTML = agentKeys.length ? agentKeys.map(([a, n], i) => `
|
|
7249
|
-
<div class="bar-row">
|
|
7250
|
-
<span class="name">${esc(a)}</span>
|
|
7251
|
-
<span class="rail"><span class="fill" style="width:${(n/maxRuns*100).toFixed(0)}%; background:${colors[i % colors.length]}"></span></span>
|
|
7252
|
-
<span class="pct">${Math.round(n/maxRuns*100)}%</span>
|
|
7253
|
-
</div>`).join('') : '<div class="empty">No agent runs yet</div>';
|
|
7254
|
-
}
|
|
7255
|
-
|
|
7256
|
-
// Agent cost breakdown
|
|
7257
|
-
const agentsCost = m.agents_cost || [];
|
|
7258
|
-
const costPanel = document.getElementById('agent-cost-panel');
|
|
7259
|
-
if (costPanel) {
|
|
7260
|
-
if (agentsCost.length) {
|
|
7261
|
-
const maxCost = Math.max(...agentsCost.map(a => a.llm_usd), 0.0001);
|
|
7262
|
-
costPanel.innerHTML = agentsCost.map((a, i) => `
|
|
7263
|
-
<div class="agent-cost-row">
|
|
7264
|
-
<span class="name">${esc(a.agent)}</span>
|
|
7265
|
-
<span class="rail"><span class="fill" style="width:${(a.llm_usd/maxCost*100).toFixed(0)}%; background:${colors[i % colors.length]}"></span></span>
|
|
7266
|
-
<span class="amt">$${a.llm_usd.toFixed(4)}</span>
|
|
7267
|
-
</div>`).join('');
|
|
7268
|
-
} else {
|
|
7269
|
-
costPanel.innerHTML = '<div class="cost-empty">No agent cost data — tasks need estimated_minutes set.</div>';
|
|
7270
|
-
}
|
|
7271
|
-
}
|
|
7272
|
-
|
|
7273
|
-
const verdicts = (m.verdicts || []).slice().reverse().slice(0, 10);
|
|
7274
|
-
const timelineEl = document.getElementById('timeline');
|
|
7275
|
-
if (timelineEl) {
|
|
7276
|
-
timelineEl.innerHTML = verdicts.length ? verdicts.map(v => {
|
|
7277
|
-
const vu = (v.verdict || '').toUpperCase();
|
|
7278
|
-
const cls = ['APPROVED','DONE','PASS'].includes(vu) ? 'ok' : ['BLOCKED','FAILED','FAIL'].includes(vu) ? 'fail' : 'def';
|
|
7279
|
-
const raw = v.raw || '';
|
|
7280
|
-
const afterColon = raw.indexOf(': ') !== -1 ? raw.slice(raw.indexOf(': ') + 2) : raw;
|
|
7281
|
-
const text = afterColon.length > 80 ? afterColon.slice(0, 80) + '…' : afterColon;
|
|
7282
|
-
return `
|
|
7283
|
-
<div class="feed-row">
|
|
7284
|
-
<span class="feed-dot ${cls}"></span>
|
|
7285
|
-
<span class="agent">${esc(v.agent)}</span>
|
|
7286
|
-
<span class="msg">${esc(text)}</span>
|
|
7287
|
-
<span class="time">${relTime(v.ts)}</span>
|
|
7288
|
-
</div>`;
|
|
7289
|
-
}).join('') : '<div class="empty">No activity yet</div>';
|
|
7290
|
-
}
|
|
7291
|
-
}
|
|
7292
7561
|
|
|
7293
7562
|
/* ── Share ──────────────────────────────────────────────────────────────── */
|
|
7294
7563
|
function renderShareState(state) {
|
|
@@ -7698,12 +7967,12 @@ let cmdkIndex = 0;
|
|
|
7698
7967
|
|
|
7699
7968
|
function cmdkActions() {
|
|
7700
7969
|
const out = [
|
|
7701
|
-
{ group: 'Go', label: '
|
|
7702
|
-
{ group: 'Go', label: '
|
|
7703
|
-
{ group: 'Go', label: '
|
|
7704
|
-
{ group: 'Go', label: '
|
|
7705
|
-
{ group: 'Go', label: '
|
|
7706
|
-
{ group: 'Go', label: '
|
|
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') },
|
|
7707
7976
|
{ group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
|
|
7708
7977
|
{ group: 'Do', label: 'Toggle light / dark theme', run: () => toggleBoardTheme() },
|
|
7709
7978
|
];
|
|
@@ -7715,9 +7984,67 @@ function cmdkActions() {
|
|
|
7715
7984
|
out.push({ group: 'Gate', label: `Approve — ${t.title}`, hint: t.id,
|
|
7716
7985
|
run: () => gateAction(t.id, 'approve') });
|
|
7717
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);
|
|
7718
8004
|
return out;
|
|
7719
8005
|
}
|
|
7720
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
|
+
|
|
7721
8048
|
function cmdkOpen() {
|
|
7722
8049
|
let el = document.getElementById('cmdk');
|
|
7723
8050
|
if (!el) {
|
|
@@ -7725,7 +8052,7 @@ function cmdkOpen() {
|
|
|
7725
8052
|
el.id = 'cmdk';
|
|
7726
8053
|
el.innerHTML = `
|
|
7727
8054
|
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
|
7728
|
-
<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"
|
|
7729
8056
|
autocomplete="off" oninput="cmdkRender(this.value)" />
|
|
7730
8057
|
<div id="cmdk-list" role="listbox" aria-label="Commands"></div>
|
|
7731
8058
|
</div>`;
|
|
@@ -7736,16 +8063,19 @@ function cmdkOpen() {
|
|
|
7736
8063
|
cmdkIndex = 0;
|
|
7737
8064
|
const input = document.getElementById('cmdk-input');
|
|
7738
8065
|
input.value = '';
|
|
8066
|
+
cmdkLastNeedle = ''; cmdkAsync = { q: '', items: [] };
|
|
7739
8067
|
cmdkRender('');
|
|
7740
8068
|
input.focus();
|
|
8069
|
+
cmdkWarm();
|
|
7741
8070
|
}
|
|
7742
8071
|
|
|
7743
8072
|
function cmdkClose() { document.getElementById('cmdk')?.classList.remove('open'); }
|
|
7744
8073
|
|
|
7745
8074
|
function cmdkRender(q) {
|
|
7746
8075
|
const needle = (q || '').toLowerCase().trim();
|
|
8076
|
+
if (needle !== cmdkLastNeedle) { cmdkLastNeedle = needle; cmdkSearch(needle); }
|
|
7747
8077
|
cmdkItems = cmdkActions().filter((a) =>
|
|
7748
|
-
!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));
|
|
7749
8079
|
if (cmdkIndex >= cmdkItems.length) cmdkIndex = 0;
|
|
7750
8080
|
const list = document.getElementById('cmdk-list');
|
|
7751
8081
|
if (!list) return;
|
|
@@ -7826,41 +8156,53 @@ try {
|
|
|
7826
8156
|
matchMedia('(min-width: 769px)').addEventListener('change', (e) => { if (e.matches) closeMenu(false); });
|
|
7827
8157
|
} catch (_) { /* older Safari: no addEventListener on MediaQueryList — drawer still works */ }
|
|
7828
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 };
|
|
7829
8167
|
function switchTab(id, el) {
|
|
7830
|
-
|
|
7831
|
-
|
|
7832
|
-
|
|
7833
|
-
|
|
7834
|
-
|
|
7835
|
-
|
|
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
|
+
}
|
|
7836
8178
|
closeMenu(false); // choosing a destination is the end of using the menu
|
|
7837
8179
|
// sidebar nav
|
|
7838
8180
|
document.querySelectorAll('.nav-item[data-tab]').forEach(t => {
|
|
7839
|
-
t.classList.toggle('active', t.dataset.tab ===
|
|
8181
|
+
t.classList.toggle('active', t.dataset.tab === navKey);
|
|
7840
8182
|
});
|
|
7841
8183
|
// top tabs
|
|
7842
8184
|
document.querySelectorAll('.tab-btn').forEach(t => t.classList.toggle('active', t.dataset.tab === id));
|
|
7843
8185
|
// panels
|
|
7844
8186
|
document.querySelectorAll('.panel').forEach(p => p.classList.remove('active'));
|
|
7845
|
-
const panelEl = document.getElementById(`panel-${
|
|
8187
|
+
const panelEl = document.getElementById(`panel-${lid}`);
|
|
7846
8188
|
if (panelEl) panelEl.classList.add('active');
|
|
7847
8189
|
// crumb — `labels` used to be referenced here but is only ever a function-local in
|
|
7848
8190
|
// this file, so this threw ReferenceError on every tab switch and skipped the per-tab
|
|
7849
8191
|
// loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
|
|
7850
|
-
const TAB_LABELS = { budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
|
|
7851
|
-
const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
|
|
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';
|
|
7852
8194
|
// Budgets render on Metrics, so the heartbeat that feeds them has to be
|
|
7853
8195
|
// fetched when Metrics opens — not only from the agents drill-in it used to
|
|
7854
8196
|
// live in.
|
|
7855
|
-
if (
|
|
8197
|
+
if (lid === 'dashboard') refreshHeartbeat();
|
|
7856
8198
|
// The Notifications panel has carried an inline history list all along, and
|
|
7857
8199
|
// `renderNotifHistoryInline()` to fill it — but the only caller was
|
|
7858
8200
|
// `openNotifDrawer()`. Opening the tab itself never loaded it, so the sidebar
|
|
7859
8201
|
// promised 50 unread and the screen showed settings. Declared and not
|
|
7860
8202
|
// consumed, in the UI this time.
|
|
7861
|
-
if (
|
|
7862
|
-
if (
|
|
7863
|
-
if (
|
|
8203
|
+
if (lid === 'notifications') loadNotifHistory();
|
|
8204
|
+
if (lid === 'budgets') { loadBudgetsPage(); refreshCost(); }
|
|
8205
|
+
if (lid === 'agents') {
|
|
7864
8206
|
// pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
|
|
7865
8207
|
// its 30-day default and overwrote whatever window the period chips had
|
|
7866
8208
|
// selected. Pick "7d", switch tabs, come back — the chip still read 7d and
|
|
@@ -7869,17 +8211,54 @@ function switchTab(id, el) {
|
|
|
7869
8211
|
//
|
|
7870
8212
|
// Through the sequence guard too, so a tab switch cannot land after a newer
|
|
7871
8213
|
// period request and clobber it.
|
|
7872
|
-
_refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m;
|
|
7873
|
-
if (
|
|
7874
|
-
if (id === 'agents') refreshAgentsInstalled();
|
|
8214
|
+
_refreshMetricsRaced(`/api/metrics${pqsd()}`).then(m => { if (m) { metrics = m; } });
|
|
8215
|
+
if (lid === 'agents') refreshAgentsInstalled();
|
|
7875
8216
|
}
|
|
7876
|
-
if (
|
|
7877
|
-
if (
|
|
7878
|
-
if (
|
|
7879
|
-
if (
|
|
7880
|
-
if (
|
|
7881
|
-
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);
|
|
7882
8259
|
}
|
|
8260
|
+
addEventListener('hashchange', () => { const r = routeFromHash(); if (r) switchTab(r); });
|
|
8261
|
+
addEventListener('DOMContentLoaded', () => { const r = routeFromHash(); if (r && r !== 'decisions') switchTab(r); });
|
|
7883
8262
|
|
|
7884
8263
|
async function refreshLogs() {
|
|
7885
8264
|
const data = await api(`/api/logs${pqs()}`);
|
|
@@ -8004,6 +8383,9 @@ const fleetState = {
|
|
|
8004
8383
|
// doing work on most projects; showing all 35 by default is noise.
|
|
8005
8384
|
// User flips activity to `all` to see the full installation.
|
|
8006
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',
|
|
8007
8389
|
search: '',
|
|
8008
8390
|
sort: 'runs_30d',
|
|
8009
8391
|
drawerSlug: null,
|
|
@@ -8023,8 +8405,6 @@ async function refreshAgentsInstalled() {
|
|
|
8023
8405
|
}
|
|
8024
8406
|
// legacy_agent_count comes from /api/metrics — pull from cached metrics if present
|
|
8025
8407
|
fleetState.legacy_count = (typeof metrics !== 'undefined' && metrics && metrics.legacy_agent_count) || 0;
|
|
8026
|
-
renderFleetSummary();
|
|
8027
|
-
renderFleetFacets();
|
|
8028
8408
|
renderFleetList();
|
|
8029
8409
|
renderLegacyAgentsSection();
|
|
8030
8410
|
// If the URL hash names a drawer, open it (post-load).
|
|
@@ -8032,102 +8412,64 @@ async function refreshAgentsInstalled() {
|
|
|
8032
8412
|
if (m) openAgentDrawer(m[1], { fromHash: true });
|
|
8033
8413
|
}
|
|
8034
8414
|
|
|
8035
|
-
function renderFleetSummary() {
|
|
8036
|
-
const el = document.getElementById('fleet-summary');
|
|
8037
|
-
if (!el) return;
|
|
8038
|
-
const s = fleetState.summary;
|
|
8039
|
-
if (!s) { el.innerHTML = '<div class="cost-empty">No data yet</div>'; return; }
|
|
8040
|
-
const tiles = [
|
|
8041
|
-
{ v: s.installed, label: 'Installed agents', cls: '' },
|
|
8042
|
-
{ v: s.active_30d, label: 'Active last 30d', cls: 'green', trend: s.active_30d > 0 ? '' : 'no activity yet' },
|
|
8043
|
-
{
|
|
8044
|
-
v: s.retire_candidates,
|
|
8045
|
-
label: 'Retire candidates',
|
|
8046
|
-
cls: s.retire_candidates > s.installed * 0.5 ? 'amber' : '',
|
|
8047
|
-
trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
|
|
8048
|
-
onClick: 'setFleetFilter("activity", "never")',
|
|
8049
|
-
},
|
|
8050
|
-
{
|
|
8051
|
-
// `?? 0` turned "nothing was measured" into "$0.00 spent", and before that
|
|
8052
|
-
// the number here was an ESTIMATE from verdict counts while the metrics
|
|
8053
|
-
// page estimated from tasks — two screens, one label, $3.90 against $1.65.
|
|
8054
|
-
v: s.llm_usd_30d != null
|
|
8055
|
-
? '$' + s.llm_usd_30d.toFixed(2)
|
|
8056
|
-
: absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`),
|
|
8057
|
-
vHtml: s.llm_usd_30d == null,
|
|
8058
|
-
label: 'LLM spend 30d',
|
|
8059
|
-
cls: '',
|
|
8060
|
-
trend: s.llm_usd_30d != null
|
|
8061
|
-
? `measured for ${s.llm_usd_30d_measured_for} agent(s)`
|
|
8062
|
-
: (s.failing_7d ? `${s.failing_7d} failing` : 'measured from verdicts'),
|
|
8063
|
-
},
|
|
8064
|
-
];
|
|
8065
|
-
// `t.v` is HTML when it is an absence marker — a span carrying the reason on
|
|
8066
|
-
// hover and in the accessible name — and a plain string otherwise. Escaping it
|
|
8067
|
-
// unconditionally printed the markup as text on the fleet tile. `vHtml` is the
|
|
8068
|
-
// deliberate opt-in; every value without it is still escaped.
|
|
8069
|
-
//
|
|
8070
|
-
// This comment lives out here because it names `t.v` in backticks, and a
|
|
8071
|
-
// backtick inside the template literal below terminates it — which is how the
|
|
8072
|
-
// board shipped a syntax error for about ninety seconds.
|
|
8073
|
-
el.innerHTML = tiles.map(t => `
|
|
8074
|
-
<div class="metric-card ${t.cls}" ${t.onClick ? `role="button" tabindex="0" onclick='${t.onClick}' onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();${t.onClick}}'` : ''}>
|
|
8075
|
-
<div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
|
|
8076
|
-
<div class="metric-label">${esc(t.label)}</div>
|
|
8077
|
-
${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
|
|
8078
|
-
</div>`).join('');
|
|
8079
|
-
}
|
|
8080
8415
|
|
|
8081
|
-
const FACET_GROUPS = [
|
|
8082
|
-
{
|
|
8083
|
-
key: 'domain', legend: 'Domain',
|
|
8084
|
-
options: [
|
|
8085
|
-
['all', 'all'], ['arch', 'arch'], ['pm', 'pm'], ['qa', 'qa'],
|
|
8086
|
-
['security', 'security'], ['ops', 'ops'], ['domain', 'domain'],
|
|
8087
|
-
['memory', 'memory'], ['other', 'other'],
|
|
8088
|
-
],
|
|
8089
|
-
},
|
|
8090
|
-
{
|
|
8091
|
-
key: 'activity', legend: 'Activity',
|
|
8092
|
-
options: [
|
|
8093
|
-
['all', 'all'], ['used', 'used'], ['active', 'active'], ['idle', 'idle ≥30d'], ['never', 'never run'], ['retired', 'retired'],
|
|
8094
|
-
],
|
|
8095
|
-
},
|
|
8096
|
-
{
|
|
8097
|
-
key: 'health', legend: 'Health',
|
|
8098
|
-
options: [
|
|
8099
|
-
['all', 'all'], ['ok', 'ok'], ['failing', 'failing ≥3 in 7d'],
|
|
8100
|
-
],
|
|
8101
|
-
},
|
|
8102
|
-
];
|
|
8103
8416
|
|
|
8104
|
-
|
|
8105
|
-
|
|
8106
|
-
|
|
8107
|
-
|
|
8108
|
-
|
|
8109
|
-
|
|
8110
|
-
|
|
8111
|
-
|
|
8112
|
-
|
|
8113
|
-
|
|
8114
|
-
|
|
8115
|
-
|
|
8116
|
-
|
|
8117
|
-
|
|
8118
|
-
|
|
8119
|
-
|
|
8120
|
-
|
|
8121
|
-
|
|
8122
|
-
|
|
8123
|
-
|
|
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();
|
|
8124
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
|
+
}
|
|
8125
8466
|
}
|
|
8126
8467
|
|
|
8127
8468
|
function applyFleetFilters(agents) {
|
|
8128
8469
|
const { domain, activity, health } = fleetState.filters;
|
|
8129
8470
|
const q = (fleetState.search || '').toLowerCase().trim();
|
|
8130
8471
|
return agents.filter(a => {
|
|
8472
|
+
if (!inFleetView(a, fleetState.view)) return false;
|
|
8131
8473
|
if (q && !a.slug.toLowerCase().includes(q) && !(a.description || '').toLowerCase().includes(q)) return false;
|
|
8132
8474
|
if (domain !== 'all' && a.domain !== domain) return false;
|
|
8133
8475
|
// `used` = has at least one verdict, not retired. Default filter.
|
|
@@ -8173,43 +8515,92 @@ function budgetChip(b) {
|
|
|
8173
8515
|
function renderFleetList() {
|
|
8174
8516
|
const el = document.getElementById('fleet-list');
|
|
8175
8517
|
if (!el) return;
|
|
8518
|
+
renderFleetViews();
|
|
8176
8519
|
const filtered = sortFleet(applyFleetFilters(fleetState.agents));
|
|
8177
8520
|
if (!filtered.length) {
|
|
8178
|
-
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>';
|
|
8179
8522
|
return;
|
|
8180
8523
|
}
|
|
8181
|
-
|
|
8182
|
-
|
|
8183
|
-
|
|
8184
|
-
|
|
8185
|
-
|
|
8186
|
-
|
|
8187
|
-
|
|
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>
|
|
8188
8583
|
<button class="agent-row status-${esc(a.health)}${a.retired ? ' retired' : ''}"
|
|
8189
8584
|
role="button" data-slug="${esc(a.slug)}"
|
|
8190
|
-
aria-label="${esc(a.slug)} · ${esc(a.health)} · ${
|
|
8585
|
+
aria-label="${esc(a.slug)} · ${esc(a.health)} · ${runs} runs 30d"
|
|
8191
8586
|
onclick="openAgentDrawer('${esc(a.slug)}')"
|
|
8192
8587
|
onkeydown="if(event.key==='Enter'){event.preventDefault();openAgentDrawer('${esc(a.slug)}')}">
|
|
8193
8588
|
<span class="ar-status" aria-hidden="true"></span>
|
|
8194
8589
|
<span class="ar-body">
|
|
8195
|
-
<span class="ar-name">${esc(a.slug)}</span>
|
|
8196
|
-
<span class="ar-
|
|
8197
|
-
<span class="ar-
|
|
8198
|
-
<span>${
|
|
8199
|
-
<span>${
|
|
8200
|
-
<span
|
|
8201
|
-
|
|
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>
|
|
8202
8597
|
${budgetChip(a.budget)}
|
|
8203
8598
|
${a.retired ? '<span style="color:var(--agent-retired)">retired</span>' : ''}
|
|
8204
8599
|
</span>
|
|
8205
8600
|
</span>
|
|
8206
|
-
<span class="ar-rail" aria-hidden="true">
|
|
8207
|
-
${rate != null ? `<span class="ar-rail-fill" style="width:${rate}%"></span>` : ''}
|
|
8208
|
-
</span>
|
|
8209
8601
|
<span class="ar-chev" aria-hidden="true">›</span>
|
|
8210
8602
|
</button>
|
|
8211
8603
|
</li>`;
|
|
8212
|
-
}).join('');
|
|
8213
8604
|
}
|
|
8214
8605
|
|
|
8215
8606
|
function renderLegacyAgentsSection() {
|
|
@@ -8276,13 +8667,33 @@ async function openAgentDrawer(slug, opts = {}) {
|
|
|
8276
8667
|
document.getElementById('agent-drawer-close')?.focus();
|
|
8277
8668
|
}, 50);
|
|
8278
8669
|
|
|
8279
|
-
|
|
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
|
+
}
|
|
8280
8684
|
if (!profile || profile.error) {
|
|
8281
8685
|
body.innerHTML = `<div class="cost-empty">Agent <code>${esc(slug)}</code> not found.</div>`;
|
|
8282
8686
|
return;
|
|
8283
8687
|
}
|
|
8284
8688
|
if (fleetState.drawerSlug !== slug) return; // drawer swapped while loading
|
|
8285
|
-
|
|
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
|
+
]);
|
|
8286
8697
|
}
|
|
8287
8698
|
|
|
8288
8699
|
function closeAgentDrawer() {
|
|
@@ -8305,50 +8716,84 @@ function closeAgentDrawer() {
|
|
|
8305
8716
|
document.querySelectorAll('.agent-row').forEach(r => r.setAttribute('aria-current', 'false'));
|
|
8306
8717
|
}
|
|
8307
8718
|
|
|
8308
|
-
function renderAgentProfile(p) {
|
|
8719
|
+
function renderAgentProfile(p, extra = {}) {
|
|
8309
8720
|
const body = document.getElementById('agent-drawer-body');
|
|
8310
8721
|
const title = document.getElementById('agent-drawer-title');
|
|
8311
8722
|
const pill = document.getElementById('agent-drawer-pill');
|
|
8312
8723
|
if (!body) return;
|
|
8313
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>');
|
|
8314
8732
|
pill.innerHTML = `<span class="status-pill tone-${esc(p.health)}">
|
|
8315
8733
|
<span class="pill-dot" aria-hidden="true"></span>${esc(p.retired ? 'retired' : p.health)}
|
|
8316
|
-
</span
|
|
8317
|
-
|
|
8318
|
-
const
|
|
8319
|
-
|
|
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>`;
|
|
8320
8762
|
|
|
8321
8763
|
body.innerHTML = `
|
|
8322
8764
|
<div class="mp-card">
|
|
8323
|
-
<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">
|
|
8324
8771
|
<dl class="agent-summary-grid">
|
|
8325
|
-
<dt>
|
|
8326
|
-
<dt>
|
|
8327
|
-
|
|
8328
|
-
|
|
8329
|
-
|
|
8330
|
-
<dt>
|
|
8331
|
-
<dt>
|
|
8332
|
-
<dt>last run</dt> <dd>${p.last_run ? esc(p.last_run.slice(0, 16).replace('T', ' ') + ' · ' + agoStr(p.last_run)) : 'never'}</dd>
|
|
8333
|
-
<dt>savings</dt> <dd>${esc(savings)} ${esc(realCost)}</dd>
|
|
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>
|
|
8334
8779
|
</dl>
|
|
8335
8780
|
${p.description ? `<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:8px;line-height:1.5">${esc(p.description)}</div>` : ''}
|
|
8336
8781
|
</div>
|
|
8337
8782
|
|
|
8338
8783
|
<div class="mp-card">
|
|
8339
|
-
<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>
|
|
8340
8785
|
${p.recent_runs.length ? `
|
|
8341
8786
|
<ul class="run-list" role="list">
|
|
8342
8787
|
${p.recent_runs.map(r => {
|
|
8343
8788
|
const tone = isSuccessVerdict(r.verdict) ? 'ok' : (isFailureVerdict(r.verdict) ? 'fail' : 'neutral');
|
|
8344
8789
|
return `<li class="run-row">
|
|
8345
|
-
<span>${esc((r.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
8790
|
+
<span class="mono">${esc((r.ts || '').slice(5, 16).replace('T', ' '))}</span>
|
|
8346
8791
|
<span class="rr-verdict ${tone}">${esc(r.verdict || '—')}</span>
|
|
8347
8792
|
<span class="rr-raw" title="${esc(r.raw)}">${esc(r.raw)}</span>
|
|
8348
|
-
<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>
|
|
8349
8794
|
</li>`;
|
|
8350
8795
|
}).join('')}
|
|
8351
|
-
</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>')}
|
|
8352
8797
|
</div>
|
|
8353
8798
|
|
|
8354
8799
|
<div class="mp-card">
|
|
@@ -8360,18 +8805,17 @@ function renderAgentProfile(p) {
|
|
|
8360
8805
|
<span class="fm-count">×${f.count}</span>
|
|
8361
8806
|
<span class="fm-last">${esc(agoStr(f.last_seen))}</span>
|
|
8362
8807
|
</li>`).join('')}
|
|
8363
|
-
</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>`}
|
|
8364
8809
|
</div>
|
|
8365
8810
|
|
|
8366
8811
|
<div class="mp-card">
|
|
8367
8812
|
<h4>Actions</h4>
|
|
8368
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>
|
|
8369
8816
|
${p.retired
|
|
8370
8817
|
? `<button type="button" onclick="restoreAgentAction('${esc(p.slug)}')">Restore agent</button>`
|
|
8371
|
-
: `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent
|
|
8372
|
-
</div>
|
|
8373
|
-
<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:8px">
|
|
8374
|
-
File: <code>${esc(p.file_path)}</code>
|
|
8818
|
+
: `<button type="button" class="danger" onclick="retireAgentAction('${esc(p.slug)}')">Retire agent…</button>`}
|
|
8375
8819
|
</div>
|
|
8376
8820
|
</div>`;
|
|
8377
8821
|
}
|
|
@@ -8444,7 +8888,6 @@ function wireFleetKeyboard() {
|
|
|
8444
8888
|
fleetState.search = '';
|
|
8445
8889
|
const input = document.getElementById('fleet-search');
|
|
8446
8890
|
if (input) input.value = '';
|
|
8447
|
-
renderFleetFacets();
|
|
8448
8891
|
renderFleetList();
|
|
8449
8892
|
});
|
|
8450
8893
|
// Drawer close button + backdrop click
|
|
@@ -8667,21 +9110,11 @@ let _notifHistory = [];
|
|
|
8667
9110
|
let _unreadCount = 0;
|
|
8668
9111
|
let _emailChannelActive = false; // true when email enabled + verified
|
|
8669
9112
|
|
|
8670
|
-
function openNotifDrawer() {
|
|
8671
|
-
document.getElementById('notif-drawer').style.right = '0';
|
|
8672
|
-
document.getElementById('notif-drawer-overlay').style.display = 'block';
|
|
8673
|
-
loadNotifHistory();
|
|
8674
|
-
}
|
|
8675
|
-
function closeNotifDrawer() {
|
|
8676
|
-
document.getElementById('notif-drawer').style.right = '-400px';
|
|
8677
|
-
document.getElementById('notif-drawer-overlay').style.display = 'none';
|
|
8678
|
-
}
|
|
8679
9113
|
async function loadNotifHistory() {
|
|
8680
9114
|
try {
|
|
8681
9115
|
const r = await fetch('/api/notif-history');
|
|
8682
9116
|
if (!r.ok) return;
|
|
8683
9117
|
_notifHistory = await r.json();
|
|
8684
|
-
renderNotifDrawer();
|
|
8685
9118
|
const unread = _notifHistory.filter(n => !n.read).length;
|
|
8686
9119
|
updateNotifBadge(unread);
|
|
8687
9120
|
renderNotifHistoryInline();
|
|
@@ -8690,35 +9123,6 @@ async function loadNotifHistory() {
|
|
|
8690
9123
|
function _notifLevelIcon(level) {
|
|
8691
9124
|
return { info: 'ℹ️', warning: '⚠️', error: '🚨', success: '✅' }[level] || '🔔';
|
|
8692
9125
|
}
|
|
8693
|
-
function renderNotifDrawer() {
|
|
8694
|
-
const list = document.getElementById('notif-drawer-list');
|
|
8695
|
-
const empty = document.getElementById('notif-drawer-empty');
|
|
8696
|
-
if (!list) return;
|
|
8697
|
-
if (!_notifHistory.length) {
|
|
8698
|
-
list.innerHTML = '';
|
|
8699
|
-
if (empty) empty.style.display = 'flex';
|
|
8700
|
-
return;
|
|
8701
|
-
}
|
|
8702
|
-
if (empty) empty.style.display = 'none';
|
|
8703
|
-
list.innerHTML = _notifHistory.map(n => `
|
|
8704
|
-
<div class="notif-item${n.read ? '' : ' unread'}" data-id="${esc(n.id)}"
|
|
8705
|
-
style="padding:12px 16px;border-bottom:1px solid var(--border);
|
|
8706
|
-
${n.read ? '' : 'background:rgba(59,130,246,0.06);'}">
|
|
8707
|
-
<div style="display:flex;align-items:flex-start;gap:10px">
|
|
8708
|
-
<span style="font-size:var(--fs-title-s);flex-shrink:0;margin-top:1px">${_notifLevelIcon(n.level)}</span>
|
|
8709
|
-
<div style="flex:1;min-width:0">
|
|
8710
|
-
<div style="font-size:var(--fs-body);font-weight:${n.read ? '400' : '600'};color:var(--text);
|
|
8711
|
-
white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(n.title)}</div>
|
|
8712
|
-
<div style="font-size:var(--fs-small);color:var(--text3);margin-top:2px;line-height:1.4">${esc(n.body)}</div>
|
|
8713
|
-
<div style="font-size:var(--fs-caption);color:var(--text3);margin-top:4px;font-family:var(--mono)">
|
|
8714
|
-
${n.project ? `<span style="color:var(--accent-text);margin-right:6px">${esc(n.project)}</span>` : ''}
|
|
8715
|
-
${new Date(n.ts).toLocaleString()}
|
|
8716
|
-
</div>
|
|
8717
|
-
</div>
|
|
8718
|
-
${n.read ? '' : '<span style="width:7px;height:7px;background:var(--dot-unread);border-radius:50%;flex-shrink:0;margin-top:5px"></span>'}
|
|
8719
|
-
</div>
|
|
8720
|
-
</div>`).join('');
|
|
8721
|
-
}
|
|
8722
9126
|
function renderNotifHistoryInline() {
|
|
8723
9127
|
const el = document.getElementById('notif-history-inline');
|
|
8724
9128
|
const empty = document.getElementById('notif-history-empty');
|
|
@@ -8775,7 +9179,6 @@ async function markAllNotifsRead() {
|
|
|
8775
9179
|
await fetch('/api/notif-history/read', { method: 'POST',
|
|
8776
9180
|
headers: {'Content-Type':'application/json'}, body: '{}' });
|
|
8777
9181
|
_notifHistory.forEach(n => n.read = true);
|
|
8778
|
-
renderNotifDrawer();
|
|
8779
9182
|
renderNotifHistoryInline();
|
|
8780
9183
|
updateNotifBadge(0);
|
|
8781
9184
|
} catch(e) {}
|
|
@@ -8784,7 +9187,6 @@ function prependNotifLive(notif) {
|
|
|
8784
9187
|
_notifHistory.unshift(notif);
|
|
8785
9188
|
if (_notifHistory.length > 100) _notifHistory.length = 100;
|
|
8786
9189
|
updateNotifBadge(_unreadCount + 1);
|
|
8787
|
-
renderNotifDrawer();
|
|
8788
9190
|
renderNotifHistoryInline();
|
|
8789
9191
|
}
|
|
8790
9192
|
|
|
@@ -8885,29 +9287,5 @@ const _costLbl = document.getElementById('mp-cost-label');
|
|
|
8885
9287
|
if (_costLbl) _costLbl.textContent = `Cost · last ${periodLabel(currentPeriod)}`;
|
|
8886
9288
|
</script>
|
|
8887
9289
|
|
|
8888
|
-
<!-- ── Notification drawer ──────────────────────────────────────────────── -->
|
|
8889
|
-
<div id="notif-drawer" style="position:fixed;top:0;right:-400px;width:380px;height:100vh;
|
|
8890
|
-
background:var(--bg-elevated);border-left:1px solid var(--border);z-index:9999;
|
|
8891
|
-
transition:right .25s cubic-bezier(.4,0,.2,1);display:flex;flex-direction:column;
|
|
8892
|
-
box-shadow: none;border-bottom:1px solid var(--border);
|
|
8893
|
-
display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
8894
|
-
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"
|
|
8895
|
-
style="width:16px;height:16px;color:var(--text2)">
|
|
8896
|
-
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9 M10.3 21a1.94 1.94 0 0 0 3.4 0"/>
|
|
8897
|
-
</svg>
|
|
8898
|
-
<span style="font-weight:600;font-size:var(--fs-base);flex:1;color:var(--text)">Notifications</span>
|
|
8899
|
-
<button class="btn-ghost-sm" onclick="markAllNotifsRead()"
|
|
8900
|
-
style="font-size:var(--fs-small)">Mark all read</button>
|
|
8901
|
-
<button onclick="closeNotifDrawer()" style="background:none;border:none;cursor:pointer;
|
|
8902
|
-
color:var(--text3);font-size:var(--fs-title-l);line-height:1;padding:0 4px;margin-left:4px">×</button>
|
|
8903
|
-
</div>
|
|
8904
|
-
<div id="notif-drawer-list" tabindex="0" role="region" aria-label="Notifications list" style="flex:1;overflow-y:auto"></div>
|
|
8905
|
-
<div id="notif-drawer-empty" style="display:flex;flex:1;align-items:center;
|
|
8906
|
-
justify-content:center;flex-direction:column;gap:8px;color:var(--text3);font-size:var(--fs-body)">
|
|
8907
|
-
<span style="font-size:var(--fs-num-l)">🔔</span>No notifications yet
|
|
8908
|
-
</div>
|
|
8909
|
-
</div>
|
|
8910
|
-
<div id="notif-drawer-overlay" onclick="closeNotifDrawer()"
|
|
8911
|
-
style="display:none;position:fixed;inset:0;z-index:9998;background:rgba(0,0,0,.4)"></div>
|
|
8912
9290
|
</body>
|
|
8913
9291
|
</html>
|