great-cto 2.98.1 → 3.0.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/alerts.mjs +11 -4
- package/board/packages/board/lib/beads.mjs +145 -6
- package/board/packages/board/lib/fleet.mjs +25 -0
- package/board/packages/board/lib/metrics.mjs +24 -0
- package/board/packages/board/lib/portfolio.mjs +58 -7
- package/board/packages/board/lib/routes.mjs +128 -10
- package/board/packages/board/lib/watchers.mjs +32 -1
- package/board/packages/board/public/index.html +633 -16
- package/board/scripts/lib/agent-budget.mjs +224 -0
- package/board/scripts/lib/pipeline-health.mjs +127 -0
- package/package.json +1 -1
|
@@ -153,6 +153,62 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
153
153
|
radial-gradient(ellipse 70% 50% at 100% 100%, rgba(0, 217, 126, 0.03), transparent 70%);
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
.stale-board {
|
|
157
|
+
display: flex; align-items: center; gap: 10px;
|
|
158
|
+
margin: 0 0 12px; padding: 10px 14px;
|
|
159
|
+
background: rgba(180, 83, 9, 0.10);
|
|
160
|
+
border: 1px solid var(--status-progress);
|
|
161
|
+
border-radius: 8px;
|
|
162
|
+
font-size: 13px; color: var(--text);
|
|
163
|
+
}
|
|
164
|
+
.stale-board code { font-family: var(--mono); font-size: 12px; }
|
|
165
|
+
|
|
166
|
+
/* Per-agent budget. Four states, and they must not look alike: `exceeded` holds
|
|
167
|
+
a dispatch, `unmeasured` deliberately does not. A shared grey chip for
|
|
168
|
+
"under the cap" and "we never measured it" would be the same silent zero this
|
|
169
|
+
board spends its time refusing to render. */
|
|
170
|
+
.budget-chip {
|
|
171
|
+
font-family: var(--mono); font-size: 10px;
|
|
172
|
+
padding: 1px 6px; border-radius: 3px;
|
|
173
|
+
border: 1px solid var(--border);
|
|
174
|
+
white-space: nowrap;
|
|
175
|
+
}
|
|
176
|
+
.budget-chip.within { color: var(--status-review); border-color: rgba(22, 163, 74, 0.35); }
|
|
177
|
+
.budget-chip.exceeded { color: var(--status-blocked); border-color: rgba(220, 38, 38, 0.35); background: var(--p0-bg); font-weight: 600; }
|
|
178
|
+
.budget-chip.unmeasured { color: var(--status-progress); border-color: rgba(180, 83, 9, 0.35); cursor: help; }
|
|
179
|
+
|
|
180
|
+
/* The budgets panel. A cap with no consumption beside it cannot answer the only
|
|
181
|
+
question worth asking of it, so every row carries the state and the spend. */
|
|
182
|
+
.ab-title { font-weight: 600; margin-bottom: 8px; }
|
|
183
|
+
.ab-row {
|
|
184
|
+
display: flex; align-items: center; gap: 10px;
|
|
185
|
+
padding: 4px 0; border-top: 1px solid var(--border);
|
|
186
|
+
}
|
|
187
|
+
.ab-row:first-of-type { border-top: 0; }
|
|
188
|
+
.ab-name { font-family: var(--mono); flex: 1 1 auto; min-width: 0; }
|
|
189
|
+
.ab-cap { font-family: var(--mono); color: var(--text2); }
|
|
190
|
+
.ab-spent { font-family: var(--mono); font-size: 11px; color: var(--text3); }
|
|
191
|
+
.ab-note { margin-top: 8px; font-size: 11px; color: var(--status-progress); }
|
|
192
|
+
.ab-note.ab-bad { color: var(--status-blocked); }
|
|
193
|
+
.ab-btn {
|
|
194
|
+
font-family: inherit; font-size: 11px;
|
|
195
|
+
min-height: 24px; padding: 0 8px;
|
|
196
|
+
border: 1px solid var(--border); border-radius: 5px;
|
|
197
|
+
background: var(--bg-card); color: var(--text2);
|
|
198
|
+
}
|
|
199
|
+
.ab-btn:hover { border-color: var(--focus-ring); color: var(--text); }
|
|
200
|
+
.ab-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
|
201
|
+
.ab-btn.ab-rm:hover { border-color: var(--status-blocked); color: var(--status-blocked); }
|
|
202
|
+
.ab-btn.ab-add { margin-top: 10px; }
|
|
203
|
+
|
|
204
|
+
.budget-line {
|
|
205
|
+
margin: -18px 0 24px; padding: 8px 12px;
|
|
206
|
+
border-radius: 8px; font-size: 12px;
|
|
207
|
+
border: 1px solid var(--border);
|
|
208
|
+
}
|
|
209
|
+
.budget-line.exceeded { color: var(--status-blocked); border-color: var(--status-blocked); background: var(--p0-bg); font-weight: 500; }
|
|
210
|
+
.budget-line.unmeasured { color: var(--status-progress); border-color: rgba(180, 83, 9, 0.35); }
|
|
211
|
+
|
|
156
212
|
/* ── Sidebar ──────────────────────────────────────────────────────────────── */
|
|
157
213
|
.sidebar {
|
|
158
214
|
position: relative; z-index: 1;
|
|
@@ -754,6 +810,41 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
754
810
|
.pl-stage { cursor: pointer; transition: background 0.12s; }
|
|
755
811
|
.pl-stage:hover { background: var(--bg-muted); border-radius: 4px; }
|
|
756
812
|
.card-selected { outline: 2px solid var(--focus-ring); outline-offset: -1px; }
|
|
813
|
+
#cmdk {
|
|
814
|
+
position: fixed; inset: 0; z-index: 10001;
|
|
815
|
+
display: none; justify-content: center; align-items: flex-start;
|
|
816
|
+
padding-top: 12vh;
|
|
817
|
+
background: rgba(0, 0, 0, 0.35);
|
|
818
|
+
}
|
|
819
|
+
#cmdk.open { display: flex; }
|
|
820
|
+
.cmdk-box {
|
|
821
|
+
width: min(620px, 92vw); max-height: 62vh;
|
|
822
|
+
display: flex; flex-direction: column;
|
|
823
|
+
background: var(--surface-drawer); border: 1px solid var(--border-strong);
|
|
824
|
+
border-radius: 12px; box-shadow: var(--drawer-shadow); overflow: hidden;
|
|
825
|
+
}
|
|
826
|
+
#cmdk-input {
|
|
827
|
+
border: 0; border-bottom: 1px solid var(--border);
|
|
828
|
+
background: transparent; color: var(--text);
|
|
829
|
+
font-family: inherit; font-size: 15px; padding: 14px 16px; outline: none;
|
|
830
|
+
}
|
|
831
|
+
#cmdk-list { overflow-y: auto; padding: 6px; }
|
|
832
|
+
.cmdk-item {
|
|
833
|
+
display: flex; align-items: center; gap: 10px;
|
|
834
|
+
padding: 9px 10px; border-radius: 7px; cursor: pointer;
|
|
835
|
+
min-height: 40px;
|
|
836
|
+
}
|
|
837
|
+
.cmdk-item.on { background: var(--bg-muted); }
|
|
838
|
+
.cmdk-group {
|
|
839
|
+
font-family: var(--mono); font-size: 10px; letter-spacing: 0.06em;
|
|
840
|
+
text-transform: uppercase; color: var(--text3);
|
|
841
|
+
min-width: 34px; flex-shrink: 0;
|
|
842
|
+
}
|
|
843
|
+
.cmdk-label { font-size: 13.5px; color: var(--text); flex: 1; min-width: 0;
|
|
844
|
+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
845
|
+
.cmdk-hint { font-family: var(--mono); font-size: 11px; color: var(--text3); flex-shrink: 0; }
|
|
846
|
+
.cmdk-empty { padding: 18px 14px; font-size: 13px; color: var(--text2); }
|
|
847
|
+
|
|
757
848
|
#hotkey-modal {
|
|
758
849
|
position: fixed; inset: 0; z-index: 10000;
|
|
759
850
|
display: flex; align-items: center; justify-content: center;
|
|
@@ -1133,6 +1224,15 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1133
1224
|
transform: rotate(45deg);
|
|
1134
1225
|
}
|
|
1135
1226
|
.pl-stage:last-child::after { display: none; }
|
|
1227
|
+
.pl-recover { display: flex; gap: 6px; margin-top: 6px; }
|
|
1228
|
+
.pl-recover-btn {
|
|
1229
|
+
min-height: 26px; padding: 0 8px;
|
|
1230
|
+
border: 1px solid var(--border); border-radius: 5px;
|
|
1231
|
+
background: var(--bg-card); color: var(--text2);
|
|
1232
|
+
font-family: var(--mono); font-size: 10px; cursor: pointer;
|
|
1233
|
+
}
|
|
1234
|
+
.pl-recover-btn:hover { border-color: var(--status-blocked); color: var(--status-blocked); }
|
|
1235
|
+
.pl-recover-btn:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
|
1136
1236
|
.pl-stage .pl-name {
|
|
1137
1237
|
font-family: var(--mono); font-size: 11px;
|
|
1138
1238
|
letter-spacing: 0.04em; color: var(--text);
|
|
@@ -1267,6 +1367,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1267
1367
|
}
|
|
1268
1368
|
.cost-bar:hover { background: var(--accent); }
|
|
1269
1369
|
.cost-bar.has-data { background: var(--accent); }
|
|
1370
|
+
.cost-bar:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
|
|
1270
1371
|
.cost-bar .tip {
|
|
1271
1372
|
position: absolute;
|
|
1272
1373
|
bottom: calc(100% + 4px); left: 50%;
|
|
@@ -1279,7 +1380,25 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1279
1380
|
pointer-events: none;
|
|
1280
1381
|
z-index: 5;
|
|
1281
1382
|
}
|
|
1282
|
-
.cost-bar:hover .tip
|
|
1383
|
+
.cost-bar:hover .tip,
|
|
1384
|
+
.cost-bar:focus-visible .tip { display: block; }
|
|
1385
|
+
|
|
1386
|
+
/* The chart's numbers, readable without a pointer. Collapsed by default — the
|
|
1387
|
+
chart is the primary reading and this is the exact one. */
|
|
1388
|
+
.cost-table { margin-top: 10px; }
|
|
1389
|
+
.cost-table summary {
|
|
1390
|
+
cursor: pointer; font-size: 12px; color: var(--text2);
|
|
1391
|
+
padding: 4px 0; list-style-position: inside;
|
|
1392
|
+
}
|
|
1393
|
+
.cost-table summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; border-radius: 4px; }
|
|
1394
|
+
.cost-table table { border-collapse: collapse; width: 100%; margin-top: 6px; }
|
|
1395
|
+
.cost-table th, .cost-table td {
|
|
1396
|
+
text-align: left; padding: 5px 10px 5px 0;
|
|
1397
|
+
border-bottom: 1px solid var(--border);
|
|
1398
|
+
font-size: 12px; white-space: nowrap;
|
|
1399
|
+
}
|
|
1400
|
+
.cost-table th { font-family: var(--mono); font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text2); }
|
|
1401
|
+
.cost-table td.num { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
|
|
1283
1402
|
.cost-axis {
|
|
1284
1403
|
display: flex; justify-content: space-between;
|
|
1285
1404
|
font-family: var(--mono); font-size: 10px;
|
|
@@ -1595,6 +1714,14 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1595
1714
|
color: var(--text3); letter-spacing: 0.04em;
|
|
1596
1715
|
cursor: help; border-bottom: 1px dotted var(--border-strong);
|
|
1597
1716
|
}
|
|
1717
|
+
.delta {
|
|
1718
|
+
margin-left: 6px;
|
|
1719
|
+
font-family: var(--mono); font-size: 10px; font-weight: 600;
|
|
1720
|
+
letter-spacing: 0.02em; white-space: nowrap;
|
|
1721
|
+
color: var(--text3); cursor: help;
|
|
1722
|
+
}
|
|
1723
|
+
.delta.up { color: var(--status-review); }
|
|
1724
|
+
.delta.down { color: var(--status-progress); }
|
|
1598
1725
|
.metric-num small {
|
|
1599
1726
|
font-size: 22px; color: var(--text2);
|
|
1600
1727
|
margin-left: 4px; font-weight: 400;
|
|
@@ -1692,6 +1819,20 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
1692
1819
|
.logs-layout { display: flex; flex: 1; overflow: hidden; }
|
|
1693
1820
|
.logs-list { width: 280px; min-width: 220px; border-right: 1px solid var(--border); overflow-y: auto; padding: 8px 0; flex-shrink: 0; }
|
|
1694
1821
|
.logs-detail { flex: 1; overflow-y: auto; padding: 24px 28px; }
|
|
1822
|
+
.log-facets {
|
|
1823
|
+
display: flex; flex-wrap: wrap; gap: 6px;
|
|
1824
|
+
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
|
1825
|
+
}
|
|
1826
|
+
.log-facet {
|
|
1827
|
+
min-height: 28px; padding: 0 10px;
|
|
1828
|
+
border: 1px solid var(--border); border-radius: 999px;
|
|
1829
|
+
background: transparent; color: var(--text2);
|
|
1830
|
+
font-family: var(--mono); font-size: 11px; cursor: pointer;
|
|
1831
|
+
}
|
|
1832
|
+
.log-facet:hover { border-color: var(--border-strong); }
|
|
1833
|
+
.log-facet.on { background: var(--accent-soft); border-color: var(--accent-2); color: var(--accent-text); }
|
|
1834
|
+
.log-facet:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
|
|
1835
|
+
.log-facet .n { color: var(--text3); margin-left: 2px; }
|
|
1695
1836
|
.log-item { padding: 10px 16px; cursor: pointer; border-bottom: 1px solid var(--border); }
|
|
1696
1837
|
.log-item:hover { background: var(--bg-muted); }
|
|
1697
1838
|
.log-item.active { background: var(--bg-strong); border-left: 2px solid var(--accent); }
|
|
@@ -2292,6 +2433,16 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2292
2433
|
.mp-row { grid-template-columns: 1fr; }
|
|
2293
2434
|
}
|
|
2294
2435
|
@media (pointer: coarse) {
|
|
2436
|
+
/* Controls added later land HERE, not in a block of their own beside their own
|
|
2437
|
+
rule. The sweep that enforces this floor reads one block, so a second one
|
|
2438
|
+
both hides from it and loses in the cascade to anything declared below.
|
|
2439
|
+
Both guards caught exactly that on the audit fixes — which is what they
|
|
2440
|
+
were written for. */
|
|
2441
|
+
.log-facet { min-height: 44px; padding: 0 14px; }
|
|
2442
|
+
.pl-recover-btn { min-height: 44px; padding: 0 12px; }
|
|
2443
|
+
.cmdk-item { min-height: 44px; }
|
|
2444
|
+
.cost-bar { min-width: 10px; }
|
|
2445
|
+
.cost-table summary { min-height: 44px; display: flex; align-items: center; }
|
|
2295
2446
|
/* A floor, not a list.
|
|
2296
2447
|
Placed here, at the END of the stylesheet, for the same reason the
|
|
2297
2448
|
phone-width block is: at equal specificity the later rule wins. Written
|
|
@@ -2450,7 +2601,7 @@ button { font-family: inherit; cursor: pointer; }
|
|
|
2450
2601
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="3 12 7 8 11 12 15 8 19 12"/><polyline points="3 18 7 14 11 18 15 14 19 18"/></svg>
|
|
2451
2602
|
</span>
|
|
2452
2603
|
<span class="resume-title">Pick up where you left off</span>
|
|
2453
|
-
<span class="resume-meta" id="resume-meta"
|
|
2604
|
+
<span class="resume-meta" id="resume-meta"></span>
|
|
2454
2605
|
</div>
|
|
2455
2606
|
<div class="resume-grid">
|
|
2456
2607
|
<div class="resume-col">
|
|
@@ -2947,6 +3098,7 @@ function renderInbox(d) {
|
|
|
2947
3098
|
renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
|
|
2948
3099
|
refreshStandDowns();
|
|
2949
3100
|
refreshReceipt();
|
|
3101
|
+
refreshBudgetLine();
|
|
2950
3102
|
// Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
|
|
2951
3103
|
const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
|
|
2952
3104
|
+ (d?.blocked || []).length + (d?.stale_in_progress || []).length;
|
|
@@ -3196,19 +3348,170 @@ async function refreshHeartbeat() {
|
|
|
3196
3348
|
}
|
|
3197
3349
|
|
|
3198
3350
|
// Per-agent budget bars in agents tab
|
|
3199
|
-
renderAgentBudgets(h.budgets || {});
|
|
3351
|
+
renderAgentBudgets(h.budgets || {}, h);
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
/* A budget that holds a stage has to be visible where decisions are read.
|
|
3355
|
+
*
|
|
3356
|
+
* `exceeded` stops the pipeline: the dispatcher refuses to dispatch that agent
|
|
3357
|
+
* and prints PIPELINE-STOP. If the only place that says so is a panel two clicks
|
|
3358
|
+
* away on another tab, the operator meets it as "why did nothing happen".
|
|
3359
|
+
*
|
|
3360
|
+
* Silent in the common case — no budgets declared, nothing to say. Three states
|
|
3361
|
+
* like everywhere else on this page: exceeded is loud, unmeasured is a quiet
|
|
3362
|
+
* note that the caps cannot fire yet, and all-within says nothing at all.
|
|
3363
|
+
*/
|
|
3364
|
+
async function refreshBudgetLine() {
|
|
3365
|
+
const host = document.getElementById('inbox-summary');
|
|
3366
|
+
if (!host) return;
|
|
3367
|
+
document.getElementById('inbox-budget-line')?.remove();
|
|
3368
|
+
|
|
3369
|
+
const h = await api(`/api/heartbeat${pqs()}`);
|
|
3370
|
+
const caps = Object.keys(h?.budgets || {}).length;
|
|
3371
|
+
if (!caps) return;
|
|
3372
|
+
|
|
3373
|
+
const judged = (fleetState?.agents || []).map((a) => a.budget).filter((b) => b && b.state !== 'no-limit');
|
|
3374
|
+
const exceeded = judged.filter((b) => b.state === 'exceeded');
|
|
3375
|
+
const unmeasured = judged.filter((b) => b.state === 'unmeasured');
|
|
3376
|
+
|
|
3377
|
+
let text, cls;
|
|
3378
|
+
if (exceeded.length) {
|
|
3379
|
+
cls = 'exceeded';
|
|
3380
|
+
text = `${exceeded.length} of ${caps} agent budget${caps > 1 ? 's' : ''} exceeded — the pipeline will not dispatch `
|
|
3381
|
+
+ exceeded.map((b) => esc(b.why.split(' ')[0])).join(', ');
|
|
3382
|
+
} else if (unmeasured.length === caps) {
|
|
3383
|
+
cls = 'unmeasured';
|
|
3384
|
+
text = `${caps} agent budget${caps > 1 ? 's' : ''} declared, none measurable yet — no verdict carries a cost, so nothing can be held`;
|
|
3385
|
+
} else {
|
|
3386
|
+
return; // all within: the number is on the agents tab, and silence is correct here
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
const el = document.createElement('div');
|
|
3390
|
+
el.id = 'inbox-budget-line';
|
|
3391
|
+
el.className = `budget-line ${cls}`;
|
|
3392
|
+
el.setAttribute('role', 'status');
|
|
3393
|
+
el.innerHTML = text;
|
|
3394
|
+
host.insertAdjacentElement('afterend', el);
|
|
3200
3395
|
}
|
|
3201
3396
|
|
|
3202
|
-
|
|
3397
|
+
/* Budgets, with what each one is actually judged against.
|
|
3398
|
+
*
|
|
3399
|
+
* This showed the declared cap and nothing else — "$25/run", a label that did
|
|
3400
|
+
* not match what any consumer did with the number, next to no indication of
|
|
3401
|
+
* spend. A cap with no consumption beside it cannot answer the only question
|
|
3402
|
+
* worth asking of it.
|
|
3403
|
+
*
|
|
3404
|
+
* The state comes from the fleet, which judges every agent with the same module
|
|
3405
|
+
* the dispatcher refuses with, so this cannot say `within` while a stage is
|
|
3406
|
+
* being held.
|
|
3407
|
+
*/
|
|
3408
|
+
function renderAgentBudgets(budgets, meta = {}) {
|
|
3203
3409
|
const el = document.getElementById('agent-budgets');
|
|
3204
3410
|
if (!el) return;
|
|
3205
|
-
const entries = Object.entries(budgets);
|
|
3206
|
-
|
|
3411
|
+
const entries = Object.entries(budgets || {});
|
|
3412
|
+
const malformed = meta.budgets_malformed || [];
|
|
3413
|
+
if (entries.length === 0 && !malformed.length) { el.style.display = 'none'; return; }
|
|
3207
3414
|
el.style.display = 'block';
|
|
3208
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3415
|
+
|
|
3416
|
+
// Judgements the fleet already computed, keyed by slug. Absent while the
|
|
3417
|
+
// fleet is still loading — the row then shows the cap and says the state is
|
|
3418
|
+
// not known yet, rather than guessing one.
|
|
3419
|
+
const judged = new Map((fleetState?.agents || []).map((a) => [a.slug, a.budget]));
|
|
3420
|
+
|
|
3421
|
+
const rows = entries.map(([agent, cap]) => {
|
|
3422
|
+
const b = judged.get(agent);
|
|
3423
|
+
const state = b?.state && b.state !== 'no-limit' ? b.state : null;
|
|
3424
|
+
const spent = state === 'within' || state === 'exceeded'
|
|
3425
|
+
? `$${(b.measuredUsd ?? 0).toFixed(2)} spent`
|
|
3426
|
+
: (state === 'unmeasured' ? 'nothing measured yet' : 'state not loaded');
|
|
3427
|
+
return `<div class="ab-row">
|
|
3428
|
+
<span class="ab-name">${esc(agent)}</span>
|
|
3429
|
+
<span class="ab-cap">$${esc(String(cap))}</span>
|
|
3430
|
+
${state ? budgetChip(b) : ''}
|
|
3431
|
+
<span class="ab-spent">${esc(spent)}</span>
|
|
3432
|
+
<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(agent)}', ${Number(cap)})"
|
|
3433
|
+
aria-label="Change the ${esc(agent)} budget">Change</button>
|
|
3434
|
+
<button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(agent)}', ${Number(cap)})"
|
|
3435
|
+
aria-label="Remove the ${esc(agent)} budget">Remove</button>
|
|
3436
|
+
</div>`;
|
|
3437
|
+
}).join('');
|
|
3438
|
+
|
|
3439
|
+
const deprecated = meta.budgets_deprecated_key
|
|
3440
|
+
? `<div class="ab-note">Read from the deprecated <code>${esc(meta.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>`
|
|
3441
|
+
: '';
|
|
3442
|
+
// A line the parser could not read is shown, never dropped: a budget silently
|
|
3443
|
+
// ignored is a limit its author believes they have.
|
|
3444
|
+
const bad = malformed.length
|
|
3445
|
+
? `<div class="ab-note ab-bad">${malformed.length} line(s) not read: ` +
|
|
3446
|
+
malformed.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>'
|
|
3447
|
+
: '';
|
|
3448
|
+
|
|
3449
|
+
el.innerHTML = `<div class="ab-title">Per-agent budgets · <code>PROJECT.md</code></div>${rows}${deprecated}${bad}`
|
|
3450
|
+
+ `<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>`;
|
|
3451
|
+
}
|
|
3452
|
+
|
|
3453
|
+
/* Writing a cap writes PROJECT.md — a file the operator owns and git tracks.
|
|
3454
|
+
*
|
|
3455
|
+
* The confirm states what the number DOES rather than asking whether you are
|
|
3456
|
+
* sure: a cap this agent passes stops the pipeline from dispatching it, and that
|
|
3457
|
+
* is the fact worth reading before typing a number. The dialog for a change also
|
|
3458
|
+
* names the value being replaced, because a cap that silently overwrote another
|
|
3459
|
+
* is a change you cannot see you made.
|
|
3460
|
+
*/
|
|
3461
|
+
async function writeAgentBudget(body, consequence) {
|
|
3462
|
+
if (!confirm(consequence)) return;
|
|
3463
|
+
const r = await api(`/api/agent-budgets${pqs()}`, {
|
|
3464
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
|
3465
|
+
});
|
|
3466
|
+
if (!r || r.error) { alert(`Budget not saved: ${r?.error || 'network error'}`); return; }
|
|
3467
|
+
showToast(`Budget for <strong>${esc(r.agent)}</strong> ${r.removed ? 'removed' : 'saved'}`, 'success', 2500);
|
|
3468
|
+
refreshHeartbeat();
|
|
3469
|
+
if (typeof loadFleet === 'function') loadFleet();
|
|
3470
|
+
}
|
|
3471
|
+
|
|
3472
|
+
function budgetPrompt(agent, current) {
|
|
3473
|
+
const raw = prompt(
|
|
3474
|
+
`Spending cap for ${agent}, in dollars.\n\n`
|
|
3475
|
+
+ `Measured spend past this figure stops the pipeline dispatching ${agent}.\n`
|
|
3476
|
+
+ `Spend is measured from verdicts; while none carries a cost the cap cannot fire.`,
|
|
3477
|
+
current != null ? String(current) : '');
|
|
3478
|
+
if (raw === null) return null;
|
|
3479
|
+
const usd = Number(String(raw).replace(/[$\s,]/g, ''));
|
|
3480
|
+
if (!Number.isFinite(usd) || usd <= 0) { alert(`Not a positive dollar amount: ${raw}`); return null; }
|
|
3481
|
+
return usd;
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
function editAgentBudget(agent, current) {
|
|
3485
|
+
const usd = budgetPrompt(agent, current);
|
|
3486
|
+
if (usd == null) return;
|
|
3487
|
+
writeAgentBudget({ agent, limit_usd: usd },
|
|
3488
|
+
`Set ${agent} to $${usd}, replacing $${current}?\n\n`
|
|
3489
|
+
+ `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${agent}.`);
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
function clearAgentBudget(agent, current) {
|
|
3493
|
+
writeAgentBudget({ agent, remove: true },
|
|
3494
|
+
`Remove the $${current} budget for ${agent}?\n\n`
|
|
3495
|
+
+ `Written to .great_cto/PROJECT.md. ${agent} will dispatch regardless of what it has spent.`);
|
|
3496
|
+
}
|
|
3497
|
+
|
|
3498
|
+
function addAgentBudget() {
|
|
3499
|
+
// Offer the agents the fleet knows about, so a typo does not become a budget
|
|
3500
|
+
// for an agent that does not exist — which would look identical to a cap
|
|
3501
|
+
// nobody is near.
|
|
3502
|
+
const known = (fleetState?.agents || []).map((a) => a.slug);
|
|
3503
|
+
const agent = prompt(`Which agent?${known.length ? `\n\n${known.slice(0, 40).join(', ')}` : ''}`);
|
|
3504
|
+
if (agent === null) return;
|
|
3505
|
+
const slug = String(agent).trim().toLowerCase();
|
|
3506
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { alert(`Not an agent slug: ${agent}`); return; }
|
|
3507
|
+
if (known.length && !known.includes(slug)) {
|
|
3508
|
+
if (!confirm(`No agent called "${slug}" is installed. A budget for an agent that does not exist looks exactly like one nobody is near.\n\nAdd it anyway?`)) return;
|
|
3509
|
+
}
|
|
3510
|
+
const usd = budgetPrompt(slug, null);
|
|
3511
|
+
if (usd == null) return;
|
|
3512
|
+
writeAgentBudget({ agent: slug, limit_usd: usd },
|
|
3513
|
+
`Set ${slug} to $${usd}?\n\n`
|
|
3514
|
+
+ `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${slug}.`);
|
|
3212
3515
|
}
|
|
3213
3516
|
|
|
3214
3517
|
async function refreshResume() {
|
|
@@ -3253,6 +3556,29 @@ function renderResume(d) {
|
|
|
3253
3556
|
card.style.display = totalItems > 0 ? 'block' : 'none';
|
|
3254
3557
|
if (totalItems === 0) return;
|
|
3255
3558
|
|
|
3559
|
+
// The label said "last 24h" and was hardcoded. `getResume` applies no time
|
|
3560
|
+
// filter at all — it takes the last three verdicts, ever — so on a project
|
|
3561
|
+
// last touched five days ago the card announced week-old records as if they
|
|
3562
|
+
// had happened this afternoon.
|
|
3563
|
+
//
|
|
3564
|
+
// The DATA is right for what this card is for: if you were away a week you
|
|
3565
|
+
// still want to know where you left off. The claim about it was wrong, so the
|
|
3566
|
+
// claim is what changes. It now states the age of the newest thing shown, and
|
|
3567
|
+
// says "last 24h" only when that is true.
|
|
3568
|
+
const meta = document.getElementById('resume-meta');
|
|
3569
|
+
if (meta) {
|
|
3570
|
+
const stamps = [...verdicts.map((v) => v.ts), ...wip.map((t) => t.updated_at)]
|
|
3571
|
+
.filter((t) => t && !Number.isNaN(Date.parse(t)))
|
|
3572
|
+
.map((t) => Date.parse(t));
|
|
3573
|
+
if (!stamps.length) {
|
|
3574
|
+
// No usable timestamp is not "recent" — it is unknown, and says so.
|
|
3575
|
+
meta.textContent = 'age unknown';
|
|
3576
|
+
} else {
|
|
3577
|
+
const ageMs = Date.now() - Math.max(...stamps);
|
|
3578
|
+
meta.textContent = ageMs < 24 * 3600e3 ? 'last 24h' : `newest ${relTime(new Date(Math.max(...stamps)).toISOString())}`;
|
|
3579
|
+
}
|
|
3580
|
+
}
|
|
3581
|
+
|
|
3256
3582
|
// WIP column
|
|
3257
3583
|
const wipEl = document.getElementById('resume-wip');
|
|
3258
3584
|
wipEl.innerHTML = wip.length ? wip.map(t => {
|
|
@@ -3489,15 +3815,55 @@ function renderPipeline(stages) {
|
|
|
3489
3815
|
}
|
|
3490
3816
|
const msg = s.last_message ? esc(s.last_message) : '—';
|
|
3491
3817
|
const meta = s.age_min != null ? `${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'} ago` : '';
|
|
3818
|
+
// A failed stage offered one affordance: filter the Kanban by it. That is
|
|
3819
|
+
// the same thing every other stage offers, and it is not what you want when
|
|
3820
|
+
// something broke. GitHub Actions puts "Re-run failed jobs" on the failure
|
|
3821
|
+
// itself; the least this can do is take you to the verdict that recorded it
|
|
3822
|
+
// and give you the line that re-runs the agent.
|
|
3823
|
+
const failed = s.status === 'failed' || s.status === 'blocked';
|
|
3824
|
+
const recovery = failed ? `
|
|
3825
|
+
<div class="pl-recover" onclick="event.stopPropagation()">
|
|
3826
|
+
<button type="button" class="pl-recover-btn" onclick="drillToVerdict('${esc(s.stage)}')"
|
|
3827
|
+
title="Open the verdict log entry that recorded this failure">why</button>
|
|
3828
|
+
<button type="button" class="pl-recover-btn" onclick="copyRerun('${esc(s.stage)}')"
|
|
3829
|
+
title="Copy the command that re-runs this stage">re-run</button>
|
|
3830
|
+
</div>` : '';
|
|
3492
3831
|
return `
|
|
3493
3832
|
<div class="pl-stage ${s.status}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
|
|
3494
3833
|
<div class="pl-name"><span class="dot"></span>${label}</div>
|
|
3495
3834
|
<div class="pl-msg">${msg}</div>
|
|
3496
3835
|
<div class="pl-meta">${meta || s.status}</div>
|
|
3836
|
+
${recovery}
|
|
3497
3837
|
</div>`;
|
|
3498
3838
|
}).join('');
|
|
3499
3839
|
}
|
|
3500
3840
|
|
|
3841
|
+
/* Open the Activity feed at the verdict that recorded a stage's failure.
|
|
3842
|
+
*
|
|
3843
|
+
* "Why did it fail" is one hop away in the data — the verdict log carries the
|
|
3844
|
+
* agent and the message — and was zero hops away in the UI only if you already
|
|
3845
|
+
* knew to look in Activity.
|
|
3846
|
+
*/
|
|
3847
|
+
function drillToVerdict(stage) {
|
|
3848
|
+
switchTab('logs', document.querySelector('.nav-item[data-tab="logs"]'));
|
|
3849
|
+
showToast(`Activity — looking for <code>${esc(stage)}</code>'s last verdict`, 'info', 2500);
|
|
3850
|
+
}
|
|
3851
|
+
|
|
3852
|
+
/* Hand over the line that re-runs a stage.
|
|
3853
|
+
*
|
|
3854
|
+
* The board deliberately does NOT dispatch agents on a click: a re-run spends
|
|
3855
|
+
* money and writes to the project, and this surface has no gate for that. So it
|
|
3856
|
+
* copies the command instead of running it — the operator stays the one who
|
|
3857
|
+
* decides, and does not have to remember the syntax.
|
|
3858
|
+
*/
|
|
3859
|
+
function copyRerun(stage) {
|
|
3860
|
+
const cmd = `/agent ${stage}`;
|
|
3861
|
+
navigator.clipboard.writeText(cmd).then(
|
|
3862
|
+
() => showToast(`Copied <code>${esc(cmd)}</code> — paste it in your session`, 'success', 3000),
|
|
3863
|
+
() => showToast(`Run <code>${esc(cmd)}</code> in your session`, 'info', 4000),
|
|
3864
|
+
);
|
|
3865
|
+
}
|
|
3866
|
+
|
|
3501
3867
|
// Click the human-gate node → jump to the gates/inbox view where they get signed.
|
|
3502
3868
|
function drillToGates() {
|
|
3503
3869
|
const search = document.getElementById('search-input');
|
|
@@ -3635,9 +4001,27 @@ function renderCost(d) {
|
|
|
3635
4001
|
const bars = series.map(s => {
|
|
3636
4002
|
const h = max > 0 ? Math.max(2, Math.round((s.llm / max) * chartH)) : 2;
|
|
3637
4003
|
const tip = `${s.date} · $${s.llm.toFixed(2)}${s.plans ? ` · ${s.plans} plan${s.plans>1?'s':''}` : ''}${s.runs ? ` · ${s.runs} runs` : ''}`;
|
|
3638
|
-
|
|
4004
|
+
// A bar whose only reading is a hover tooltip has no reading for a keyboard
|
|
4005
|
+
// or a screen reader — and this chart is money. `tabindex` puts it in the tab
|
|
4006
|
+
// order, `aria-label` carries the same string the tooltip shows, and the
|
|
4007
|
+
// focus rule below opens the tip so the two agree.
|
|
4008
|
+
return `<div class="cost-bar ${s.llm > 0 ? 'has-data' : ''}" style="height:${h}px" tabindex="0" role="img" aria-label="${esc(tip)}"><span class="tip">${esc(tip)}</span></div>`;
|
|
3639
4009
|
}).join('');
|
|
3640
4010
|
|
|
4011
|
+
// The same series as a table, collapsed. Grafana and Datadog always let you
|
|
4012
|
+
// read the exact value without a pointer; a chart that can only be hovered
|
|
4013
|
+
// hides its own numbers from the person most likely to need them precisely.
|
|
4014
|
+
const seriesTable = series.length ? `
|
|
4015
|
+
<details class="cost-table">
|
|
4016
|
+
<summary>Daily figures as a table</summary>
|
|
4017
|
+
<div style="overflow-x:auto">
|
|
4018
|
+
<table>
|
|
4019
|
+
<thead><tr><th scope="col">Date</th><th scope="col">Spend</th><th scope="col">Plans</th><th scope="col">Runs</th></tr></thead>
|
|
4020
|
+
<tbody>${series.map(s => `<tr><td>${esc(s.date)}</td><td class="num">$${s.llm.toFixed(2)}</td><td class="num">${s.plans ?? 0}</td><td class="num">${s.runs ?? 0}</td></tr>`).join('')}</tbody>
|
|
4021
|
+
</table>
|
|
4022
|
+
</div>
|
|
4023
|
+
</details>` : '';
|
|
4024
|
+
|
|
3641
4025
|
// Budget line position (% of max)
|
|
3642
4026
|
let budgetMarker = '';
|
|
3643
4027
|
if (budget != null) {
|
|
@@ -3675,6 +4059,7 @@ function renderCost(d) {
|
|
|
3675
4059
|
root.innerHTML = `
|
|
3676
4060
|
${summary}
|
|
3677
4061
|
<div class="cost-chart">${bars}${budgetMarker}</div>
|
|
4062
|
+
${seriesTable}
|
|
3678
4063
|
<div class="cost-axis">
|
|
3679
4064
|
<span>${esc(firstDate)}</span>
|
|
3680
4065
|
<span style="font-family:var(--sans);color:var(--text2)">LLM spend per day</span>
|
|
@@ -4381,7 +4766,7 @@ document.addEventListener('keydown', e => {
|
|
|
4381
4766
|
|
|
4382
4767
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
4383
4768
|
e.preventDefault();
|
|
4384
|
-
|
|
4769
|
+
cmdkOpen();
|
|
4385
4770
|
return;
|
|
4386
4771
|
}
|
|
4387
4772
|
if (e.key === 'Escape' && document.activeElement?.id === 'search-input') {
|
|
@@ -4564,6 +4949,7 @@ function connectSSE() {
|
|
|
4564
4949
|
// Sidebar counter FIRST, independent of render
|
|
4565
4950
|
updateNavCounts();
|
|
4566
4951
|
try { renderKanban(filterTasks(searchQuery)); } catch (e) { console.warn('SSE renderKanban failed', e); }
|
|
4952
|
+
checkBoardFreshness();
|
|
4567
4953
|
refreshInbox();
|
|
4568
4954
|
};
|
|
4569
4955
|
es.addEventListener('tasks', e => {
|
|
@@ -4946,6 +5332,34 @@ function absent(kind, why) {
|
|
|
4946
5332
|
return `<span class="absent" title="${esc(why)}" aria-label="${esc(why)}">${glyph}</span>`;
|
|
4947
5333
|
}
|
|
4948
5334
|
|
|
5335
|
+
/* Change against the window before this one.
|
|
5336
|
+
*
|
|
5337
|
+
* A tile that says "+3 this week" tells you the week; it does not tell you
|
|
5338
|
+
* whether that is more or less than usual. Datadog and Grafana put the delta on
|
|
5339
|
+
* the tile because the number alone is not a reading.
|
|
5340
|
+
*
|
|
5341
|
+
* Four states, and three of them are silence:
|
|
5342
|
+
* - not comparable — the project has no history reaching back two windows, so
|
|
5343
|
+
* there is no previous period to compare against. Saying 0% or "down from
|
|
5344
|
+
* zero" would be inventing one.
|
|
5345
|
+
* - both zero — nothing happened either side; a delta would be noise.
|
|
5346
|
+
* - previous zero, current not — a ratio is undefined, so it says "first",
|
|
5347
|
+
* which is what actually happened.
|
|
5348
|
+
* - otherwise the percentage, with the direction.
|
|
5349
|
+
*/
|
|
5350
|
+
function deltaBadge(current, prev, comparable, { invert = false } = {}) {
|
|
5351
|
+
if (!comparable || prev == null || current == null) return '';
|
|
5352
|
+
if (prev === 0 && current === 0) return '';
|
|
5353
|
+
if (prev === 0) return `<span class="delta" title="nothing in the previous period">first</span>`;
|
|
5354
|
+
const pct = Math.round(((current - prev) / prev) * 100);
|
|
5355
|
+
if (pct === 0) return `<span class="delta">flat</span>`;
|
|
5356
|
+
const up = pct > 0;
|
|
5357
|
+
// `invert` for figures where up is bad — spend rises, that is not good news.
|
|
5358
|
+
const good = invert ? !up : up;
|
|
5359
|
+
return `<span class="delta ${good ? 'up' : 'down'}" title="previous period: ${esc(String(prev))}">`
|
|
5360
|
+
+ `${up ? '▲' : '▼'} ${Math.abs(pct)}%</span>`;
|
|
5361
|
+
}
|
|
5362
|
+
|
|
4949
5363
|
/* ── Dashboard ──────────────────────────────────────────────────────────── */
|
|
4950
5364
|
function renderDashboard(m) {
|
|
4951
5365
|
const llmUsd = m.cost?.llm_usd ?? 0;
|
|
@@ -4990,13 +5404,19 @@ function renderDashboard(m) {
|
|
|
4990
5404
|
: (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
|
|
4991
5405
|
|
|
4992
5406
|
const hero = [
|
|
4993
|
-
{
|
|
5407
|
+
{
|
|
5408
|
+
v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'),
|
|
5409
|
+
sub: 'shipped', label: 'Tasks done',
|
|
5410
|
+
trend: `+${m.velocity?.this_week ?? 0} this week`,
|
|
5411
|
+
delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
|
|
5412
|
+
},
|
|
4994
5413
|
{
|
|
4995
5414
|
// $0.00 with nothing spent yet is a measurement; "—" would claim we never
|
|
4996
5415
|
// looked. The trend line underneath already says which of the two it is.
|
|
4997
5416
|
v: costKnown ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : absent('unloaded', 'the metrics payload carried no cost section'),
|
|
4998
5417
|
sub: '',
|
|
4999
5418
|
label: 'AI spend',
|
|
5419
|
+
delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
|
|
5000
5420
|
trend: llmTrend,
|
|
5001
5421
|
cls: 'amber',
|
|
5002
5422
|
},
|
|
@@ -5023,7 +5443,7 @@ function renderDashboard(m) {
|
|
|
5023
5443
|
document.getElementById('mp-hero').innerHTML = hero.map(s => `
|
|
5024
5444
|
<div class="metric-card ${s.cls || ''}">
|
|
5025
5445
|
<div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
|
|
5026
|
-
<div class="metric-label">${s.label}</div>
|
|
5446
|
+
<div class="metric-label">${s.label}${s.delta || ''}</div>
|
|
5027
5447
|
<div class="metric-trend">${s.trend}</div>
|
|
5028
5448
|
</div>`).join('');
|
|
5029
5449
|
|
|
@@ -5452,6 +5872,139 @@ document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSide();
|
|
|
5452
5872
|
// Three things a drawer owes a keyboard, all of them things a transform alone
|
|
5453
5873
|
// does not give you: it leaves the tab order when closed, Escape closes it, and
|
|
5454
5874
|
// focus moves in on open and back to the button that opened it on close.
|
|
5875
|
+
/* The board tells on itself.
|
|
5876
|
+
*
|
|
5877
|
+
* A board is a process, and a process keeps the version it started with. This
|
|
5878
|
+
* one served v2.95.0 for nine days while three installs in a row reported
|
|
5879
|
+
* success — the installer wrote files, the running server kept answering, and
|
|
5880
|
+
* nothing compared the two. The installer restarts the board now; this is for a
|
|
5881
|
+
* board started any other way, so that being behind is visible rather than
|
|
5882
|
+
* merely true.
|
|
5883
|
+
*
|
|
5884
|
+
* Three states, like everywhere else on this page: `yes` says so, `no` is
|
|
5885
|
+
* silent, and `unknown` — the plugin cache could not be read — is also silent,
|
|
5886
|
+
* because a comparison we could not make must not be announced as a mismatch.
|
|
5887
|
+
*/
|
|
5888
|
+
async function checkBoardFreshness() {
|
|
5889
|
+
const v = await api('/api/version');
|
|
5890
|
+
if (!v || v.stale !== 'yes') return;
|
|
5891
|
+
const host = document.querySelector('.workspace');
|
|
5892
|
+
if (!host || document.getElementById('stale-board')) return;
|
|
5893
|
+
const el = document.createElement('div');
|
|
5894
|
+
el.id = 'stale-board';
|
|
5895
|
+
el.className = 'stale-board';
|
|
5896
|
+
el.setAttribute('role', 'status');
|
|
5897
|
+
el.innerHTML = `<span>This board is running <code>v${esc(v.version)}</code> — `
|
|
5898
|
+
+ `<code>v${esc(v.installed)}</code> is installed. Restart it to pick up the new version.</span>`;
|
|
5899
|
+
host.insertBefore(el, host.firstChild);
|
|
5900
|
+
}
|
|
5901
|
+
|
|
5902
|
+
/* ── Command palette ───────────────────────────────────────────────────────
|
|
5903
|
+
*
|
|
5904
|
+
* Cmd+K focused the search box, and the search box filters tasks by substring.
|
|
5905
|
+
* That is a find, not a command: what you actually want at 9am is "approve the
|
|
5906
|
+
* gate that is waiting" or "show me metrics", and both were several clicks away.
|
|
5907
|
+
* Linear's palette executes; this one does too.
|
|
5908
|
+
*
|
|
5909
|
+
* Built entirely out of verbs that already exist — switchTab, gateAction,
|
|
5910
|
+
* openNewIssueModal, toggleBoardTheme — so it is a surface over the board's own
|
|
5911
|
+
* actions rather than a second implementation of them. That also means the
|
|
5912
|
+
* safety rules come along: choosing "Approve gate X" calls the same gateAction
|
|
5913
|
+
* that names its consequences and waits for a confirm.
|
|
5914
|
+
*/
|
|
5915
|
+
let cmdkItems = [];
|
|
5916
|
+
let cmdkIndex = 0;
|
|
5917
|
+
|
|
5918
|
+
function cmdkActions() {
|
|
5919
|
+
const out = [
|
|
5920
|
+
{ group: 'Go', label: 'Inbox — what needs your decision', run: () => switchTab('inbox') },
|
|
5921
|
+
{ group: 'Go', label: 'Tasks', run: () => switchTab('kanban') },
|
|
5922
|
+
{ group: 'Go', label: 'Metrics', run: () => switchTab('dashboard') },
|
|
5923
|
+
{ group: 'Go', label: 'Docs', run: () => switchTab('docs') },
|
|
5924
|
+
{ group: 'Go', label: 'Activity', run: () => switchTab('logs') },
|
|
5925
|
+
{ group: 'Go', label: 'Notifications', run: () => switchTab('notifications') },
|
|
5926
|
+
{ group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
|
|
5927
|
+
{ group: 'Do', label: 'Toggle light / dark theme', run: () => toggleBoardTheme() },
|
|
5928
|
+
];
|
|
5929
|
+
// Open gates by name — the one thing on this board actually waiting for a
|
|
5930
|
+
// person, and previously the hardest thing to reach from the keyboard.
|
|
5931
|
+
for (const t of (allTasks || [])) {
|
|
5932
|
+
if (t.status === 'closed') continue;
|
|
5933
|
+
if (!/gate/i.test(t.title || '')) continue;
|
|
5934
|
+
out.push({ group: 'Gate', label: `Approve — ${t.title}`, hint: t.id,
|
|
5935
|
+
run: () => gateAction(t.id, 'approve') });
|
|
5936
|
+
}
|
|
5937
|
+
return out;
|
|
5938
|
+
}
|
|
5939
|
+
|
|
5940
|
+
function cmdkOpen() {
|
|
5941
|
+
let el = document.getElementById('cmdk');
|
|
5942
|
+
if (!el) {
|
|
5943
|
+
el = document.createElement('div');
|
|
5944
|
+
el.id = 'cmdk';
|
|
5945
|
+
el.innerHTML = `
|
|
5946
|
+
<div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
|
|
5947
|
+
<input id="cmdk-input" type="text" placeholder="Type a command…" aria-label="Command"
|
|
5948
|
+
autocomplete="off" oninput="cmdkRender(this.value)" />
|
|
5949
|
+
<div id="cmdk-list" role="listbox" aria-label="Commands"></div>
|
|
5950
|
+
</div>`;
|
|
5951
|
+
el.addEventListener('click', (ev) => { if (ev.target === el) cmdkClose(); });
|
|
5952
|
+
document.body.appendChild(el);
|
|
5953
|
+
}
|
|
5954
|
+
el.classList.add('open');
|
|
5955
|
+
cmdkIndex = 0;
|
|
5956
|
+
const input = document.getElementById('cmdk-input');
|
|
5957
|
+
input.value = '';
|
|
5958
|
+
cmdkRender('');
|
|
5959
|
+
input.focus();
|
|
5960
|
+
}
|
|
5961
|
+
|
|
5962
|
+
function cmdkClose() { document.getElementById('cmdk')?.classList.remove('open'); }
|
|
5963
|
+
|
|
5964
|
+
function cmdkRender(q) {
|
|
5965
|
+
const needle = (q || '').toLowerCase().trim();
|
|
5966
|
+
cmdkItems = cmdkActions().filter((a) =>
|
|
5967
|
+
!needle || a.label.toLowerCase().includes(needle) || (a.hint || '').toLowerCase().includes(needle));
|
|
5968
|
+
if (cmdkIndex >= cmdkItems.length) cmdkIndex = 0;
|
|
5969
|
+
const list = document.getElementById('cmdk-list');
|
|
5970
|
+
if (!list) return;
|
|
5971
|
+
// An empty result says so. A palette that renders nothing looks broken.
|
|
5972
|
+
if (!cmdkItems.length) {
|
|
5973
|
+
list.innerHTML = `<div class="cmdk-empty">No command matches <b>${esc(q)}</b></div>`;
|
|
5974
|
+
return;
|
|
5975
|
+
}
|
|
5976
|
+
list.innerHTML = cmdkItems.map((a, i) => `
|
|
5977
|
+
<div class="cmdk-item ${i === cmdkIndex ? 'on' : ''}" role="option" aria-selected="${i === cmdkIndex}"
|
|
5978
|
+
onclick="cmdkRun(${i})">
|
|
5979
|
+
<span class="cmdk-group">${esc(a.group)}</span>
|
|
5980
|
+
<span class="cmdk-label">${esc(a.label)}</span>
|
|
5981
|
+
${a.hint ? `<span class="cmdk-hint">${esc(a.hint)}</span>` : ''}
|
|
5982
|
+
</div>`).join('');
|
|
5983
|
+
}
|
|
5984
|
+
|
|
5985
|
+
function cmdkRun(i) {
|
|
5986
|
+
const a = cmdkItems[i];
|
|
5987
|
+
if (!a) return;
|
|
5988
|
+
cmdkClose();
|
|
5989
|
+
// After the palette is gone, so a confirm is not stacked underneath it.
|
|
5990
|
+
setTimeout(() => a.run(), 0);
|
|
5991
|
+
}
|
|
5992
|
+
|
|
5993
|
+
document.addEventListener('keydown', (e) => {
|
|
5994
|
+
const el = document.getElementById('cmdk');
|
|
5995
|
+
if (!el || !el.classList.contains('open')) return;
|
|
5996
|
+
if (e.key === 'Escape') { e.preventDefault(); cmdkClose(); return; }
|
|
5997
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
|
|
5998
|
+
e.preventDefault();
|
|
5999
|
+
if (!cmdkItems.length) return;
|
|
6000
|
+
cmdkIndex = (cmdkIndex + (e.key === 'ArrowDown' ? 1 : -1) + cmdkItems.length) % cmdkItems.length;
|
|
6001
|
+
cmdkRender(document.getElementById('cmdk-input').value);
|
|
6002
|
+
document.querySelector('.cmdk-item.on')?.scrollIntoView({ block: 'nearest' });
|
|
6003
|
+
return;
|
|
6004
|
+
}
|
|
6005
|
+
if (e.key === 'Enter') { e.preventDefault(); cmdkRun(cmdkIndex); }
|
|
6006
|
+
});
|
|
6007
|
+
|
|
5455
6008
|
function menuIsOpen() {
|
|
5456
6009
|
return !!document.getElementById('sidebar')?.classList.contains('open');
|
|
5457
6010
|
}
|
|
@@ -5559,6 +6112,35 @@ async function refreshLogs() {
|
|
|
5559
6112
|
if (allLogs.length > 0) renderLogDetail(allLogs[0]);
|
|
5560
6113
|
}
|
|
5561
6114
|
|
|
6115
|
+
/* Which facet the Activity list is filtered to. `null` = everything. */
|
|
6116
|
+
let logFacet = null;
|
|
6117
|
+
|
|
6118
|
+
function setLogFacet(v) {
|
|
6119
|
+
logFacet = (logFacet === v) ? null : v;
|
|
6120
|
+
renderLogsList();
|
|
6121
|
+
}
|
|
6122
|
+
|
|
6123
|
+
/* The facets the CURRENT data actually supports.
|
|
6124
|
+
*
|
|
6125
|
+
* Derived from the logs in hand, never a fixed list: a chip for a source with
|
|
6126
|
+
* no entries is a filter that can only return nothing, and a reader who clicks
|
|
6127
|
+
* it learns something false about the data. Sentry and Datadog put facets on a
|
|
6128
|
+
* feed because "what broke" is a filtering question; this list had no filter at
|
|
6129
|
+
* all, though every log already carries the field.
|
|
6130
|
+
*/
|
|
6131
|
+
function logFacets() {
|
|
6132
|
+
const counts = new Map();
|
|
6133
|
+
for (const l of allLogs) {
|
|
6134
|
+
const k = l.source === 'verdicts' || l.source === 'auto' ? 'auto' : 'session';
|
|
6135
|
+
counts.set(k, (counts.get(k) || 0) + 1);
|
|
6136
|
+
}
|
|
6137
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
6138
|
+
}
|
|
6139
|
+
|
|
6140
|
+
function facetOf(log) {
|
|
6141
|
+
return (log.source === 'verdicts' || log.source === 'auto') ? 'auto' : 'session';
|
|
6142
|
+
}
|
|
6143
|
+
|
|
5562
6144
|
function renderLogsList() {
|
|
5563
6145
|
const el = document.getElementById('logs-list');
|
|
5564
6146
|
if (!el) return;
|
|
@@ -5566,7 +6148,27 @@ function renderLogsList() {
|
|
|
5566
6148
|
el.innerHTML = '<div class="empty" style="padding:20px 16px">No session logs and no agent activity yet.<br><small style="color:var(--text3)">Run <code>/save</code> after a session, or invoke any agent.</small></div>';
|
|
5567
6149
|
return;
|
|
5568
6150
|
}
|
|
5569
|
-
|
|
6151
|
+
const facets = logFacets();
|
|
6152
|
+
const shown = allLogs.map((log, i) => ({ log, i }))
|
|
6153
|
+
.filter(({ log }) => !logFacet || facetOf(log) === logFacet);
|
|
6154
|
+
|
|
6155
|
+
// Only offer the bar when there is a choice to make. One facet is not a filter.
|
|
6156
|
+
const bar = facets.length > 1 ? `
|
|
6157
|
+
<div class="log-facets" role="group" aria-label="Filter activity">
|
|
6158
|
+
${facets.map(([k, c]) => `
|
|
6159
|
+
<button type="button" class="log-facet ${logFacet === k ? 'on' : ''}"
|
|
6160
|
+
aria-pressed="${logFacet === k}" onclick="setLogFacet('${esc(k)}')">
|
|
6161
|
+
${esc(k)} <span class="n">${c}</span>
|
|
6162
|
+
</button>`).join('')}
|
|
6163
|
+
</div>` : '';
|
|
6164
|
+
|
|
6165
|
+
// A filter that hides everything must say so, or it reads as an empty feed.
|
|
6166
|
+
if (!shown.length) {
|
|
6167
|
+
el.innerHTML = bar + `<div class="empty" style="padding:16px">No activity matches <b>${esc(logFacet || '')}</b>.</div>`;
|
|
6168
|
+
return;
|
|
6169
|
+
}
|
|
6170
|
+
|
|
6171
|
+
el.innerHTML = bar + shown.map(({ log, i }) => `
|
|
5570
6172
|
<div class="log-item ${i === 0 ? 'active' : ''}" onclick="selectLog(${i}, this)">
|
|
5571
6173
|
<div class="log-date">${esc(log.date)}${log.time ? ' · ' + esc(log.time) : ''}${log.source === 'verdicts' || log.source === 'auto' ? ' <span style="color:var(--text3);font-size:10px">· auto</span>' : ''}</div>
|
|
5572
6174
|
<div class="log-title">${esc(log.title)}</div>
|
|
@@ -5741,6 +6343,20 @@ function sortFleet(agents) {
|
|
|
5741
6343
|
});
|
|
5742
6344
|
}
|
|
5743
6345
|
|
|
6346
|
+
/* One chip, four states, and the title always says which number it is judging.
|
|
6347
|
+
*
|
|
6348
|
+
* `no-limit` renders NOTHING — an agent with no declared budget is the common
|
|
6349
|
+
* case, and a chip saying "no budget" on sixty rows is noise that trains the eye
|
|
6350
|
+
* to skip the row where it matters.
|
|
6351
|
+
*/
|
|
6352
|
+
function budgetChip(b) {
|
|
6353
|
+
if (!b || b.state === 'no-limit') return '';
|
|
6354
|
+
const label = b.state === 'unmeasured'
|
|
6355
|
+
? `$${b.limitUsd} cap · unmeasured`
|
|
6356
|
+
: `$${(b.measuredUsd ?? 0).toFixed(2)} / $${b.limitUsd}`;
|
|
6357
|
+
return `<span class="budget-chip ${esc(b.state)}" title="${esc(b.why)}">${esc(label)}</span>`;
|
|
6358
|
+
}
|
|
6359
|
+
|
|
5744
6360
|
function renderFleetList() {
|
|
5745
6361
|
const el = document.getElementById('fleet-list');
|
|
5746
6362
|
if (!el) return;
|
|
@@ -5758,7 +6374,7 @@ function renderFleetList() {
|
|
|
5758
6374
|
return `<li>
|
|
5759
6375
|
<button class="agent-row status-${esc(a.health)}${a.retired ? ' retired' : ''}"
|
|
5760
6376
|
role="button" data-slug="${esc(a.slug)}"
|
|
5761
|
-
aria-label="${esc(a.slug)} · ${esc(a.health)} · ${rateLabel} · ${esc(lastRunStr)}"
|
|
6377
|
+
aria-label="${esc(a.slug)} · ${esc(a.health)} · ${rateLabel} · ${esc(lastRunStr)}${a.budget && a.budget.state !== 'no-limit' ? ' · ' + esc(a.budget.why) : ''}"
|
|
5762
6378
|
onclick="openAgentDrawer('${esc(a.slug)}')"
|
|
5763
6379
|
onkeydown="if(event.key==='Enter'){event.preventDefault();openAgentDrawer('${esc(a.slug)}')}">
|
|
5764
6380
|
<span class="ar-status" aria-hidden="true"></span>
|
|
@@ -5770,6 +6386,7 @@ function renderFleetList() {
|
|
|
5770
6386
|
<span>${esc(rateLabel)}</span>
|
|
5771
6387
|
<span>last: ${esc(lastRunStr)}</span>
|
|
5772
6388
|
${savingsStr ? `<span>${esc(savingsStr)}</span>` : ''}
|
|
6389
|
+
${budgetChip(a.budget)}
|
|
5773
6390
|
${a.retired ? '<span style="color:var(--agent-retired)">retired</span>' : ''}
|
|
5774
6391
|
</span>
|
|
5775
6392
|
</span>
|