great-cto 2.99.0 → 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.
@@ -163,6 +163,52 @@ button { font-family: inherit; cursor: pointer; }
163
163
  }
164
164
  .stale-board code { font-family: var(--mono); font-size: 12px; }
165
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
+
166
212
  /* ── Sidebar ──────────────────────────────────────────────────────────────── */
167
213
  .sidebar {
168
214
  position: relative; z-index: 1;
@@ -764,6 +810,41 @@ button { font-family: inherit; cursor: pointer; }
764
810
  .pl-stage { cursor: pointer; transition: background 0.12s; }
765
811
  .pl-stage:hover { background: var(--bg-muted); border-radius: 4px; }
766
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
+
767
848
  #hotkey-modal {
768
849
  position: fixed; inset: 0; z-index: 10000;
769
850
  display: flex; align-items: center; justify-content: center;
@@ -1143,6 +1224,15 @@ button { font-family: inherit; cursor: pointer; }
1143
1224
  transform: rotate(45deg);
1144
1225
  }
1145
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; }
1146
1236
  .pl-stage .pl-name {
1147
1237
  font-family: var(--mono); font-size: 11px;
1148
1238
  letter-spacing: 0.04em; color: var(--text);
@@ -1277,6 +1367,7 @@ button { font-family: inherit; cursor: pointer; }
1277
1367
  }
1278
1368
  .cost-bar:hover { background: var(--accent); }
1279
1369
  .cost-bar.has-data { background: var(--accent); }
1370
+ .cost-bar:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
1280
1371
  .cost-bar .tip {
1281
1372
  position: absolute;
1282
1373
  bottom: calc(100% + 4px); left: 50%;
@@ -1289,7 +1380,25 @@ button { font-family: inherit; cursor: pointer; }
1289
1380
  pointer-events: none;
1290
1381
  z-index: 5;
1291
1382
  }
1292
- .cost-bar:hover .tip { display: block; }
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; }
1293
1402
  .cost-axis {
1294
1403
  display: flex; justify-content: space-between;
1295
1404
  font-family: var(--mono); font-size: 10px;
@@ -1605,6 +1714,14 @@ button { font-family: inherit; cursor: pointer; }
1605
1714
  color: var(--text3); letter-spacing: 0.04em;
1606
1715
  cursor: help; border-bottom: 1px dotted var(--border-strong);
1607
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); }
1608
1725
  .metric-num small {
1609
1726
  font-size: 22px; color: var(--text2);
1610
1727
  margin-left: 4px; font-weight: 400;
@@ -1702,6 +1819,20 @@ button { font-family: inherit; cursor: pointer; }
1702
1819
  .logs-layout { display: flex; flex: 1; overflow: hidden; }
1703
1820
  .logs-list { width: 280px; min-width: 220px; border-right: 1px solid var(--border); overflow-y: auto; padding: 8px 0; flex-shrink: 0; }
1704
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; }
1705
1836
  .log-item { padding: 10px 16px; cursor: pointer; border-bottom: 1px solid var(--border); }
1706
1837
  .log-item:hover { background: var(--bg-muted); }
1707
1838
  .log-item.active { background: var(--bg-strong); border-left: 2px solid var(--accent); }
@@ -2302,6 +2433,16 @@ button { font-family: inherit; cursor: pointer; }
2302
2433
  .mp-row { grid-template-columns: 1fr; }
2303
2434
  }
2304
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; }
2305
2446
  /* A floor, not a list.
2306
2447
  Placed here, at the END of the stylesheet, for the same reason the
2307
2448
  phone-width block is: at equal specificity the later rule wins. Written
@@ -2460,7 +2601,7 @@ button { font-family: inherit; cursor: pointer; }
2460
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>
2461
2602
  </span>
2462
2603
  <span class="resume-title">Pick up where you left off</span>
2463
- <span class="resume-meta" id="resume-meta">last 24h</span>
2604
+ <span class="resume-meta" id="resume-meta"></span>
2464
2605
  </div>
2465
2606
  <div class="resume-grid">
2466
2607
  <div class="resume-col">
@@ -2957,6 +3098,7 @@ function renderInbox(d) {
2957
3098
  renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
2958
3099
  refreshStandDowns();
2959
3100
  refreshReceipt();
3101
+ refreshBudgetLine();
2960
3102
  // Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
2961
3103
  const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
2962
3104
  + (d?.blocked || []).length + (d?.stale_in_progress || []).length;
@@ -3206,19 +3348,170 @@ async function refreshHeartbeat() {
3206
3348
  }
3207
3349
 
3208
3350
  // Per-agent budget bars in agents tab
3209
- renderAgentBudgets(h.budgets || {});
3351
+ renderAgentBudgets(h.budgets || {}, h);
3210
3352
  }
3211
3353
 
3212
- function renderAgentBudgets(budgets) {
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);
3395
+ }
3396
+
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 = {}) {
3213
3409
  const el = document.getElementById('agent-budgets');
3214
3410
  if (!el) return;
3215
- const entries = Object.entries(budgets);
3216
- if (entries.length === 0) { el.style.display = 'none'; return; }
3411
+ const entries = Object.entries(budgets || {});
3412
+ const malformed = meta.budgets_malformed || [];
3413
+ if (entries.length === 0 && !malformed.length) { el.style.display = 'none'; return; }
3217
3414
  el.style.display = 'block';
3218
- el.innerHTML = `<div class="ab-title">Per-agent budgets (PROJECT.md)</div>` +
3219
- entries.map(([agent, cap]) =>
3220
- `<div class="ab-row"><span class="ab-name">${esc(agent)}</span><span class="ab-cap">$${cap}/run</span></div>`
3221
- ).join('');
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}.`);
3222
3515
  }
3223
3516
 
3224
3517
  async function refreshResume() {
@@ -3263,6 +3556,29 @@ function renderResume(d) {
3263
3556
  card.style.display = totalItems > 0 ? 'block' : 'none';
3264
3557
  if (totalItems === 0) return;
3265
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
+
3266
3582
  // WIP column
3267
3583
  const wipEl = document.getElementById('resume-wip');
3268
3584
  wipEl.innerHTML = wip.length ? wip.map(t => {
@@ -3499,15 +3815,55 @@ function renderPipeline(stages) {
3499
3815
  }
3500
3816
  const msg = s.last_message ? esc(s.last_message) : '—';
3501
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>` : '';
3502
3831
  return `
3503
3832
  <div class="pl-stage ${s.status}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
3504
3833
  <div class="pl-name"><span class="dot"></span>${label}</div>
3505
3834
  <div class="pl-msg">${msg}</div>
3506
3835
  <div class="pl-meta">${meta || s.status}</div>
3836
+ ${recovery}
3507
3837
  </div>`;
3508
3838
  }).join('');
3509
3839
  }
3510
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
+
3511
3867
  // Click the human-gate node → jump to the gates/inbox view where they get signed.
3512
3868
  function drillToGates() {
3513
3869
  const search = document.getElementById('search-input');
@@ -3645,9 +4001,27 @@ function renderCost(d) {
3645
4001
  const bars = series.map(s => {
3646
4002
  const h = max > 0 ? Math.max(2, Math.round((s.llm / max) * chartH)) : 2;
3647
4003
  const tip = `${s.date} · $${s.llm.toFixed(2)}${s.plans ? ` · ${s.plans} plan${s.plans>1?'s':''}` : ''}${s.runs ? ` · ${s.runs} runs` : ''}`;
3648
- return `<div class="cost-bar ${s.llm > 0 ? 'has-data' : ''}" style="height:${h}px"><span class="tip">${esc(tip)}</span></div>`;
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>`;
3649
4009
  }).join('');
3650
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
+
3651
4025
  // Budget line position (% of max)
3652
4026
  let budgetMarker = '';
3653
4027
  if (budget != null) {
@@ -3685,6 +4059,7 @@ function renderCost(d) {
3685
4059
  root.innerHTML = `
3686
4060
  ${summary}
3687
4061
  <div class="cost-chart">${bars}${budgetMarker}</div>
4062
+ ${seriesTable}
3688
4063
  <div class="cost-axis">
3689
4064
  <span>${esc(firstDate)}</span>
3690
4065
  <span style="font-family:var(--sans);color:var(--text2)">LLM spend per day</span>
@@ -4391,7 +4766,7 @@ document.addEventListener('keydown', e => {
4391
4766
 
4392
4767
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
4393
4768
  e.preventDefault();
4394
- document.getElementById('search-input').focus();
4769
+ cmdkOpen();
4395
4770
  return;
4396
4771
  }
4397
4772
  if (e.key === 'Escape' && document.activeElement?.id === 'search-input') {
@@ -4957,6 +5332,34 @@ function absent(kind, why) {
4957
5332
  return `<span class="absent" title="${esc(why)}" aria-label="${esc(why)}">${glyph}</span>`;
4958
5333
  }
4959
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
+
4960
5363
  /* ── Dashboard ──────────────────────────────────────────────────────────── */
4961
5364
  function renderDashboard(m) {
4962
5365
  const llmUsd = m.cost?.llm_usd ?? 0;
@@ -5001,13 +5404,19 @@ function renderDashboard(m) {
5001
5404
  : (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
5002
5405
 
5003
5406
  const hero = [
5004
- { v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'), sub: 'shipped', label: 'Tasks done', trend: `+${m.velocity?.this_week ?? 0} this week` },
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
+ },
5005
5413
  {
5006
5414
  // $0.00 with nothing spent yet is a measurement; "—" would claim we never
5007
5415
  // looked. The trend line underneath already says which of the two it is.
5008
5416
  v: costKnown ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : absent('unloaded', 'the metrics payload carried no cost section'),
5009
5417
  sub: '',
5010
5418
  label: 'AI spend',
5419
+ delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
5011
5420
  trend: llmTrend,
5012
5421
  cls: 'amber',
5013
5422
  },
@@ -5034,7 +5443,7 @@ function renderDashboard(m) {
5034
5443
  document.getElementById('mp-hero').innerHTML = hero.map(s => `
5035
5444
  <div class="metric-card ${s.cls || ''}">
5036
5445
  <div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
5037
- <div class="metric-label">${s.label}</div>
5446
+ <div class="metric-label">${s.label}${s.delta || ''}</div>
5038
5447
  <div class="metric-trend">${s.trend}</div>
5039
5448
  </div>`).join('');
5040
5449
 
@@ -5490,6 +5899,112 @@ async function checkBoardFreshness() {
5490
5899
  host.insertBefore(el, host.firstChild);
5491
5900
  }
5492
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
+
5493
6008
  function menuIsOpen() {
5494
6009
  return !!document.getElementById('sidebar')?.classList.contains('open');
5495
6010
  }
@@ -5597,6 +6112,35 @@ async function refreshLogs() {
5597
6112
  if (allLogs.length > 0) renderLogDetail(allLogs[0]);
5598
6113
  }
5599
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
+
5600
6144
  function renderLogsList() {
5601
6145
  const el = document.getElementById('logs-list');
5602
6146
  if (!el) return;
@@ -5604,7 +6148,27 @@ function renderLogsList() {
5604
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>';
5605
6149
  return;
5606
6150
  }
5607
- el.innerHTML = allLogs.map((log, i) => `
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 }) => `
5608
6172
  <div class="log-item ${i === 0 ? 'active' : ''}" onclick="selectLog(${i}, this)">
5609
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>
5610
6174
  <div class="log-title">${esc(log.title)}</div>
@@ -5779,6 +6343,20 @@ function sortFleet(agents) {
5779
6343
  });
5780
6344
  }
5781
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
+
5782
6360
  function renderFleetList() {
5783
6361
  const el = document.getElementById('fleet-list');
5784
6362
  if (!el) return;
@@ -5796,7 +6374,7 @@ function renderFleetList() {
5796
6374
  return `<li>
5797
6375
  <button class="agent-row status-${esc(a.health)}${a.retired ? ' retired' : ''}"
5798
6376
  role="button" data-slug="${esc(a.slug)}"
5799
- 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) : ''}"
5800
6378
  onclick="openAgentDrawer('${esc(a.slug)}')"
5801
6379
  onkeydown="if(event.key==='Enter'){event.preventDefault();openAgentDrawer('${esc(a.slug)}')}">
5802
6380
  <span class="ar-status" aria-hidden="true"></span>
@@ -5808,6 +6386,7 @@ function renderFleetList() {
5808
6386
  <span>${esc(rateLabel)}</span>
5809
6387
  <span>last: ${esc(lastRunStr)}</span>
5810
6388
  ${savingsStr ? `<span>${esc(savingsStr)}</span>` : ''}
6389
+ ${budgetChip(a.budget)}
5811
6390
  ${a.retired ? '<span style="color:var(--agent-retired)">retired</span>' : ''}
5812
6391
  </span>
5813
6392
  </span>