great-cto 2.99.0 → 3.1.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;
@@ -1531,6 +1640,44 @@ button { font-family: inherit; cursor: pointer; }
1531
1640
  display: flex; gap: 4px; align-items: center;
1532
1641
  }
1533
1642
  .inbox-row .actions .gate-btn { height: 26px; padding: 0 10px; font-size: 11px; }
1643
+ .budgets-table { width: 100%; border-collapse: collapse; font-size: 13px; }
1644
+ .budgets-table th {
1645
+ text-align: left; font-family: var(--mono); font-size: 10px;
1646
+ letter-spacing: 0.08em; text-transform: uppercase; color: var(--text2);
1647
+ padding: 0 12px 8px 0; border-bottom: 1px solid var(--border);
1648
+ }
1649
+ .budgets-table td { padding: 10px 12px 10px 0; border-bottom: 1px solid var(--border); vertical-align: middle; }
1650
+ .budgets-table .bt-agent { font-family: var(--mono); }
1651
+ .budgets-table .bt-cap { font-family: var(--mono); font-variant-numeric: tabular-nums; }
1652
+ .budgets-table .bt-actions { text-align: right; white-space: nowrap; }
1653
+ .budgets-table .bt-actions .ab-btn + .ab-btn { margin-left: 6px; }
1654
+ .budgets-empty {
1655
+ padding: 16px; border: 1px dashed var(--border-strong); border-radius: 8px;
1656
+ color: var(--text2); font-size: 13px;
1657
+ }
1658
+ .budgets-uncapped { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
1659
+ .docs-search { display: flex; align-items: center; gap: 8px; margin: 10px 0 16px; }
1660
+ .docs-search input {
1661
+ flex: 1 1 auto; min-width: 0; max-width: 460px;
1662
+ height: 32px; padding: 0 10px;
1663
+ border: 1px solid var(--border); border-radius: 8px;
1664
+ background: var(--bg-card); color: var(--text); font: inherit; font-size: 13px;
1665
+ }
1666
+ .docs-search input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1667
+ .docs-search-hint { font-family: var(--mono); font-size: 10px; color: var(--text3); }
1668
+ .docs-search-meta { font-size: 12px; color: var(--text2); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1669
+ .docs-hit { padding: 10px 0; border-top: 1px solid var(--border); }
1670
+ .docs-hit-line { display: flex; gap: 10px; font-family: var(--mono); font-size: 11px; color: var(--text2); margin-top: 4px; }
1671
+ .docs-hit-no { color: var(--text3); min-width: 34px; text-align: right; }
1672
+ .col-more {
1673
+ display: block; width: 100%;
1674
+ margin-top: 6px; padding: 8px;
1675
+ font-family: var(--mono); font-size: 11px;
1676
+ border: 1px dashed var(--border-strong); border-radius: 8px;
1677
+ background: transparent; color: var(--text2);
1678
+ }
1679
+ .col-more:hover { border-style: solid; color: var(--text); }
1680
+ .col-more:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1534
1681
  .inbox-empty {
1535
1682
  padding: 14px 16px;
1536
1683
  background: var(--bg-muted);
@@ -1605,6 +1752,14 @@ button { font-family: inherit; cursor: pointer; }
1605
1752
  color: var(--text3); letter-spacing: 0.04em;
1606
1753
  cursor: help; border-bottom: 1px dotted var(--border-strong);
1607
1754
  }
1755
+ .delta {
1756
+ margin-left: 6px;
1757
+ font-family: var(--mono); font-size: 10px; font-weight: 600;
1758
+ letter-spacing: 0.02em; white-space: nowrap;
1759
+ color: var(--text3); cursor: help;
1760
+ }
1761
+ .delta.up { color: var(--status-review); }
1762
+ .delta.down { color: var(--status-progress); }
1608
1763
  .metric-num small {
1609
1764
  font-size: 22px; color: var(--text2);
1610
1765
  margin-left: 4px; font-weight: 400;
@@ -1702,6 +1857,20 @@ button { font-family: inherit; cursor: pointer; }
1702
1857
  .logs-layout { display: flex; flex: 1; overflow: hidden; }
1703
1858
  .logs-list { width: 280px; min-width: 220px; border-right: 1px solid var(--border); overflow-y: auto; padding: 8px 0; flex-shrink: 0; }
1704
1859
  .logs-detail { flex: 1; overflow-y: auto; padding: 24px 28px; }
1860
+ .log-facets {
1861
+ display: flex; flex-wrap: wrap; gap: 6px;
1862
+ padding: 10px 12px; border-bottom: 1px solid var(--border);
1863
+ }
1864
+ .log-facet {
1865
+ min-height: 28px; padding: 0 10px;
1866
+ border: 1px solid var(--border); border-radius: 999px;
1867
+ background: transparent; color: var(--text2);
1868
+ font-family: var(--mono); font-size: 11px; cursor: pointer;
1869
+ }
1870
+ .log-facet:hover { border-color: var(--border-strong); }
1871
+ .log-facet.on { background: var(--accent-soft); border-color: var(--accent-2); color: var(--accent-text); }
1872
+ .log-facet:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1873
+ .log-facet .n { color: var(--text3); margin-left: 2px; }
1705
1874
  .log-item { padding: 10px 16px; cursor: pointer; border-bottom: 1px solid var(--border); }
1706
1875
  .log-item:hover { background: var(--bg-muted); }
1707
1876
  .log-item.active { background: var(--bg-strong); border-left: 2px solid var(--accent); }
@@ -2302,6 +2471,16 @@ button { font-family: inherit; cursor: pointer; }
2302
2471
  .mp-row { grid-template-columns: 1fr; }
2303
2472
  }
2304
2473
  @media (pointer: coarse) {
2474
+ /* Controls added later land HERE, not in a block of their own beside their own
2475
+ rule. The sweep that enforces this floor reads one block, so a second one
2476
+ both hides from it and loses in the cascade to anything declared below.
2477
+ Both guards caught exactly that on the audit fixes — which is what they
2478
+ were written for. */
2479
+ .log-facet { min-height: 44px; padding: 0 14px; }
2480
+ .pl-recover-btn { min-height: 44px; padding: 0 12px; }
2481
+ .cmdk-item { min-height: 44px; }
2482
+ .cost-bar { min-width: 10px; }
2483
+ .cost-table summary { min-height: 44px; display: flex; align-items: center; }
2305
2484
  /* A floor, not a list.
2306
2485
  Placed here, at the END of the stylesheet, for the same reason the
2307
2486
  phone-width block is: at equal specificity the later rule wins. Written
@@ -2391,6 +2570,11 @@ button { font-family: inherit; cursor: pointer; }
2391
2570
  <span>Activity</span>
2392
2571
  <span class="count" id="nav-logs-count">0</span>
2393
2572
  </div>
2573
+ <div class="nav-item" data-tab="budgets" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('budgets', this)">
2574
+ <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 1v22 M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
2575
+ <span>Budgets</span>
2576
+ <span class="count" id="nav-budget-count"></span>
2577
+ </div>
2394
2578
  <div class="nav-item" data-tab="notifications" role="tab" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.click();}" onclick="switchTab('notifications', this); loadNotifHistory()">
2395
2579
  <svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9 M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
2396
2580
  <span>Notifications</span>
@@ -2460,7 +2644,7 @@ button { font-family: inherit; cursor: pointer; }
2460
2644
  <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
2645
  </span>
2462
2646
  <span class="resume-title">Pick up where you left off</span>
2463
- <span class="resume-meta" id="resume-meta">last 24h</span>
2647
+ <span class="resume-meta" id="resume-meta"></span>
2464
2648
  </div>
2465
2649
  <div class="resume-grid">
2466
2650
  <div class="resume-col">
@@ -2571,6 +2755,14 @@ button { font-family: inherit; cursor: pointer; }
2571
2755
  <div class="mp-hero" id="mp-hero"></div>
2572
2756
  <div class="mp-secondary" id="mp-secondary"></div>
2573
2757
 
2758
+ <!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
2759
+ drill-in. A control the operator could not find is a control they do
2760
+ not have — and it was invisible on top of that, hidden whenever a
2761
+ project had declared no caps, which is exactly the project that
2762
+ needs the button to declare one. -->
2763
+ <div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
2764
+ <div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:12px;margin-bottom:4px;"></div>
2765
+
2574
2766
  <div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
2575
2767
  <div class="mp-card cost-panel" id="cost-panel">
2576
2768
  <div class="cost-empty">Loading…</div>
@@ -2613,11 +2805,6 @@ button { font-family: inherit; cursor: pointer; }
2613
2805
 
2614
2806
  <!-- §3.1 Fleet controls -->
2615
2807
  <section aria-label="Fleet controls" style="margin-top:20px">
2616
- <!-- Agent budgets (Paperclip cascading budget pattern) -->
2617
- <div id="agent-budgets" style="display:none;background:var(--bg-muted);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:12px;">
2618
- <!-- populated by renderAgentBudgets() from /api/heartbeat -->
2619
- </div>
2620
-
2621
2808
  <div class="mp-card fleet-controls">
2622
2809
  <div class="fleet-search">
2623
2810
  <input type="search" id="fleet-search" placeholder="Filter agents (press / to focus)" aria-label="Filter agents by slug" />
@@ -2697,8 +2884,29 @@ button { font-family: inherit; cursor: pointer; }
2697
2884
  <div id="sessions-body" class="muted">Loading…</div>
2698
2885
  </div>
2699
2886
 
2887
+ <div class="panel" id="panel-budgets" role="tabpanel">
2888
+ <div class="metrics-page">
2889
+ <h1>Agent budgets</h1>
2890
+ <p class="muted" style="max-width:70ch;margin:6px 0 20px">
2891
+ A cap on what one agent may spend. When measured spend passes it, the pipeline
2892
+ refuses to dispatch that agent and says so — it does not fail quietly.
2893
+ Spend is measured from verdicts that carry a cost; while none does, the cap
2894
+ cannot fire and says that too. An estimate never refuses.
2895
+ </p>
2896
+ <div id="budgets-page-body">loading…</div>
2897
+ </div>
2898
+ </div>
2899
+
2700
2900
  <div class="panel" id="panel-docs">
2701
2901
  <h1>Docs</h1>
2902
+ <div class="docs-search">
2903
+ <input type="search" id="docs-q" placeholder="Search inside documents…"
2904
+ aria-label="Search inside documents"
2905
+ onkeydown="if(event.key==='Enter'){event.preventDefault();searchDocs();}else if(event.key==='Escape'){this.value='';clearDocsSearch();}" />
2906
+ <button type="button" class="ab-btn" onclick="searchDocs()">Search</button>
2907
+ <span class="docs-search-hint"><kbd>Enter</kbd> search · <kbd>Esc</kbd> clear</span>
2908
+ </div>
2909
+ <div id="docs-results" style="display:none"></div>
2702
2910
  <div id="docs-body" class="muted">loading…</div>
2703
2911
  </div>
2704
2912
 
@@ -2957,6 +3165,7 @@ function renderInbox(d) {
2957
3165
  renderInboxList('inbox-stale', d?.stale_in_progress || [], 'inbox-stale-count', {});
2958
3166
  refreshStandDowns();
2959
3167
  refreshReceipt();
3168
+ refreshBudgetLine();
2960
3169
  // Idle focus: collapse the empty sections into one all-clear card when nothing needs a decision.
2961
3170
  const attention = (d?.pending_gates || []).length + (d?.p0_open || []).length
2962
3171
  + (d?.blocked || []).length + (d?.stale_in_progress || []).length;
@@ -3206,19 +3415,269 @@ async function refreshHeartbeat() {
3206
3415
  }
3207
3416
 
3208
3417
  // Per-agent budget bars in agents tab
3209
- renderAgentBudgets(h.budgets || {});
3418
+ renderAgentBudgets(h.budgets || {}, h);
3210
3419
  }
3211
3420
 
3212
- function renderAgentBudgets(budgets) {
3421
+ /* A budget that holds a stage has to be visible where decisions are read.
3422
+ *
3423
+ * `exceeded` stops the pipeline: the dispatcher refuses to dispatch that agent
3424
+ * and prints PIPELINE-STOP. If the only place that says so is a panel two clicks
3425
+ * away on another tab, the operator meets it as "why did nothing happen".
3426
+ *
3427
+ * Silent in the common case — no budgets declared, nothing to say. Three states
3428
+ * like everywhere else on this page: exceeded is loud, unmeasured is a quiet
3429
+ * note that the caps cannot fire yet, and all-within says nothing at all.
3430
+ */
3431
+ async function refreshBudgetLine() {
3432
+ const host = document.getElementById('inbox-summary');
3433
+ if (!host) return;
3434
+ document.getElementById('inbox-budget-line')?.remove();
3435
+
3436
+ const h = await api(`/api/heartbeat${pqs()}`);
3437
+ const caps = Object.keys(h?.budgets || {}).length;
3438
+ if (!caps) return;
3439
+
3440
+ const judged = (fleetState?.agents || []).map((a) => a.budget).filter((b) => b && b.state !== 'no-limit');
3441
+ const exceeded = judged.filter((b) => b.state === 'exceeded');
3442
+ const unmeasured = judged.filter((b) => b.state === 'unmeasured');
3443
+
3444
+ let text, cls;
3445
+ if (exceeded.length) {
3446
+ cls = 'exceeded';
3447
+ text = `${exceeded.length} of ${caps} agent budget${caps > 1 ? 's' : ''} exceeded — the pipeline will not dispatch `
3448
+ + exceeded.map((b) => esc(b.why.split(' ')[0])).join(', ');
3449
+ } else if (unmeasured.length === caps) {
3450
+ cls = 'unmeasured';
3451
+ text = `${caps} agent budget${caps > 1 ? 's' : ''} declared, none measurable yet — no verdict carries a cost, so nothing can be held`;
3452
+ } else {
3453
+ return; // all within: the number is on the agents tab, and silence is correct here
3454
+ }
3455
+
3456
+ const el = document.createElement('div');
3457
+ el.id = 'inbox-budget-line';
3458
+ el.className = `budget-line ${cls}`;
3459
+ el.setAttribute('role', 'status');
3460
+ el.innerHTML = text;
3461
+ host.insertAdjacentElement('afterend', el);
3462
+ }
3463
+
3464
+ /* Budgets, with what each one is actually judged against.
3465
+ *
3466
+ * This showed the declared cap and nothing else — "$25/run", a label that did
3467
+ * not match what any consumer did with the number, next to no indication of
3468
+ * spend. A cap with no consumption beside it cannot answer the only question
3469
+ * worth asking of it.
3470
+ *
3471
+ * The state comes from the fleet, which judges every agent with the same module
3472
+ * the dispatcher refuses with, so this cannot say `within` while a stage is
3473
+ * being held.
3474
+ */
3475
+ function renderAgentBudgets(budgets, meta = {}) {
3213
3476
  const el = document.getElementById('agent-budgets');
3214
3477
  if (!el) return;
3215
- const entries = Object.entries(budgets);
3216
- if (entries.length === 0) { el.style.display = 'none'; return; }
3478
+ const entries = Object.entries(budgets || {});
3479
+ const malformed = meta.budgets_malformed || [];
3217
3480
  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('');
3481
+
3482
+ // Shown even with nothing declared. Hiding it meant the only project that
3483
+ // needed the "add a budget" button — one with no budgets — was the only
3484
+ // project that never saw it.
3485
+ if (entries.length === 0 && !malformed.length) {
3486
+ el.innerHTML = '<div class="ab-title">Agent budgets · <code>PROJECT.md</code></div>'
3487
+ + '<div class="ab-spent">No caps declared — every agent dispatches regardless of what it has spent.</div>'
3488
+ + '<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>';
3489
+ return;
3490
+ }
3491
+
3492
+ // Judgements the fleet already computed, keyed by slug. Absent while the
3493
+ // fleet is still loading — the row then shows the cap and says the state is
3494
+ // not known yet, rather than guessing one.
3495
+ const judged = new Map((fleetState?.agents || []).map((a) => [a.slug, a.budget]));
3496
+
3497
+ const rows = entries.map(([agent, cap]) => {
3498
+ const b = judged.get(agent);
3499
+ const state = b?.state && b.state !== 'no-limit' ? b.state : null;
3500
+ const spent = state === 'within' || state === 'exceeded'
3501
+ ? `$${(b.measuredUsd ?? 0).toFixed(2)} spent`
3502
+ : (state === 'unmeasured' ? 'nothing measured yet' : 'state not loaded');
3503
+ return `<div class="ab-row">
3504
+ <span class="ab-name">${esc(agent)}</span>
3505
+ <span class="ab-cap">$${esc(String(cap))}</span>
3506
+ ${state ? budgetChip(b) : ''}
3507
+ <span class="ab-spent">${esc(spent)}</span>
3508
+ <button type="button" class="ab-btn" onclick="editAgentBudget('${esc(agent)}', ${Number(cap)})"
3509
+ aria-label="Change the ${esc(agent)} budget">Change</button>
3510
+ <button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(agent)}', ${Number(cap)})"
3511
+ aria-label="Remove the ${esc(agent)} budget">Remove</button>
3512
+ </div>`;
3513
+ }).join('');
3514
+
3515
+ const deprecated = meta.budgets_deprecated_key
3516
+ ? `<div class="ab-note">Read from the deprecated <code>${esc(meta.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>`
3517
+ : '';
3518
+ // A line the parser could not read is shown, never dropped: a budget silently
3519
+ // ignored is a limit its author believes they have.
3520
+ const bad = malformed.length
3521
+ ? `<div class="ab-note ab-bad">${malformed.length} line(s) not read: ` +
3522
+ malformed.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>'
3523
+ : '';
3524
+
3525
+ el.innerHTML = `<div class="ab-title">Per-agent budgets · <code>PROJECT.md</code></div>${rows}${deprecated}${bad}`
3526
+ + `<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>`;
3527
+ }
3528
+
3529
+ /* The Budgets screen.
3530
+ *
3531
+ * The panel on the agents tab was only reachable two clicks in, and hidden
3532
+ * entirely on a project with no caps declared — so on the project the operator
3533
+ * actually opened, the feature did not exist. A limit you can only see after
3534
+ * hand-editing the config it reads is documentation, not a feature.
3535
+ *
3536
+ * This screen shows the same judgements the dispatcher refuses with, plus every
3537
+ * agent that has NO cap, because "which agents are uncapped" is the question you
3538
+ * arrive with when you come here at all.
3539
+ */
3540
+ async function loadBudgetsPage() {
3541
+ const body = document.getElementById('budgets-page-body');
3542
+ if (!body) return;
3543
+ body.innerHTML = '<div class="muted">loading…</div>';
3544
+
3545
+ const [h] = await Promise.all([
3546
+ api(`/api/heartbeat${pqs()}`),
3547
+ (typeof loadFleet === 'function' && !(fleetState?.agents || []).length) ? loadFleet() : Promise.resolve(),
3548
+ ]);
3549
+ const caps = h?.budgets || {};
3550
+ const agents = fleetState?.agents || [];
3551
+ const judged = new Map(agents.map((a) => [a.slug, a.budget]));
3552
+ const capped = Object.entries(caps);
3553
+ const uncapped = agents.filter((a) => !(a.slug in caps));
3554
+
3555
+ const stateCell = (slug) => {
3556
+ const b = judged.get(slug);
3557
+ if (!b || b.state === 'no-limit') return '<span class="muted">state not loaded</span>';
3558
+ const spent = (b.state === 'within' || b.state === 'exceeded')
3559
+ ? `$${(b.measuredUsd ?? 0).toFixed(2)} measured`
3560
+ : (b.state === 'unmeasured' ? 'nothing measured yet' : '');
3561
+ return `${budgetChip(b)} <span class="ab-spent">${esc(spent)}</span>`;
3562
+ };
3563
+
3564
+ // Three states, as everywhere: capped, uncapped, and unreadable — never a
3565
+ // silent "all fine".
3566
+ const bad = (h?.budgets_malformed || []);
3567
+ const notes = [
3568
+ h?.budgets_deprecated_key
3569
+ ? `<div class="ab-note">Read from the deprecated <code>${esc(h.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>` : '',
3570
+ bad.length
3571
+ ? `<div class="ab-note ab-bad">${bad.length} line(s) in PROJECT.md were not read: `
3572
+ + bad.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
3573
+ ].join('');
3574
+
3575
+ const capsBlock = capped.length ? `
3576
+ <table class="budgets-table">
3577
+ <thead><tr><th scope="col">Agent</th><th scope="col">Cap</th><th scope="col">State</th><th scope="col"></th></tr></thead>
3578
+ <tbody>${capped.map(([slug, cap]) => `
3579
+ <tr>
3580
+ <td class="bt-agent">${esc(slug)}</td>
3581
+ <td class="bt-cap">$${esc(String(cap))}</td>
3582
+ <td>${stateCell(slug)}</td>
3583
+ <td class="bt-actions">
3584
+ <button type="button" class="ab-btn" onclick="editAgentBudget('${esc(slug)}', ${Number(cap)})">Change</button>
3585
+ <button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(slug)}', ${Number(cap)})">Remove</button>
3586
+ </td>
3587
+ </tr>`).join('')}</tbody>
3588
+ </table>`
3589
+ : `<div class="budgets-empty">
3590
+ No agent has a spending cap in this project. Nothing is being held, and nothing will be.
3591
+ </div>`;
3592
+
3593
+ const uncappedBlock = uncapped.length ? `
3594
+ <div class="mp-section-label" style="margin-top:24px">No cap (${uncapped.length})</div>
3595
+ <div class="budgets-uncapped">${uncapped.slice(0, 60).map((a) => `
3596
+ <button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
3597
+ title="Set a cap for ${esc(a.slug)}">${esc(a.slug)} +</button>`).join('')}
3598
+ ${uncapped.length > 60 ? `<span class="muted">and ${uncapped.length - 60} more</span>` : ''}
3599
+ </div>` : '';
3600
+
3601
+ body.innerHTML = `${notes}${capsBlock}
3602
+ <button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>
3603
+ ${uncappedBlock}`;
3604
+
3605
+ const badge = document.getElementById('nav-budget-count');
3606
+ if (badge) badge.textContent = capped.length ? String(capped.length) : '';
3607
+ }
3608
+
3609
+ /** Set a cap for a named agent, skipping the "which agent?" question. */
3610
+ function addBudgetFor(slug) {
3611
+ const usd = budgetPrompt(slug, null);
3612
+ if (usd == null) return;
3613
+ writeAgentBudget({ agent: slug, limit_usd: usd },
3614
+ `Set ${slug} to $${usd}?\n\n`
3615
+ + `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${slug}.`);
3616
+ }
3617
+
3618
+ /* Writing a cap writes PROJECT.md — a file the operator owns and git tracks.
3619
+ *
3620
+ * The confirm states what the number DOES rather than asking whether you are
3621
+ * sure: a cap this agent passes stops the pipeline from dispatching it, and that
3622
+ * is the fact worth reading before typing a number. The dialog for a change also
3623
+ * names the value being replaced, because a cap that silently overwrote another
3624
+ * is a change you cannot see you made.
3625
+ */
3626
+ async function writeAgentBudget(body, consequence) {
3627
+ if (!confirm(consequence)) return;
3628
+ const r = await api(`/api/agent-budgets${pqs()}`, {
3629
+ method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
3630
+ });
3631
+ if (!r || r.error) { alert(`Budget not saved: ${r?.error || 'network error'}`); return; }
3632
+ showToast(`Budget for <strong>${esc(r.agent)}</strong> ${r.removed ? 'removed' : 'saved'}`, 'success', 2500);
3633
+ refreshHeartbeat();
3634
+ if (typeof loadFleet === 'function') loadFleet();
3635
+ if (currentTab === 'budgets') loadBudgetsPage();
3636
+ }
3637
+
3638
+ function budgetPrompt(agent, current) {
3639
+ const raw = prompt(
3640
+ `Spending cap for ${agent}, in dollars.\n\n`
3641
+ + `Measured spend past this figure stops the pipeline dispatching ${agent}.\n`
3642
+ + `Spend is measured from verdicts; while none carries a cost the cap cannot fire.`,
3643
+ current != null ? String(current) : '');
3644
+ if (raw === null) return null;
3645
+ const usd = Number(String(raw).replace(/[$\s,]/g, ''));
3646
+ if (!Number.isFinite(usd) || usd <= 0) { alert(`Not a positive dollar amount: ${raw}`); return null; }
3647
+ return usd;
3648
+ }
3649
+
3650
+ function editAgentBudget(agent, current) {
3651
+ const usd = budgetPrompt(agent, current);
3652
+ if (usd == null) return;
3653
+ writeAgentBudget({ agent, limit_usd: usd },
3654
+ `Set ${agent} to $${usd}, replacing $${current}?\n\n`
3655
+ + `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${agent}.`);
3656
+ }
3657
+
3658
+ function clearAgentBudget(agent, current) {
3659
+ writeAgentBudget({ agent, remove: true },
3660
+ `Remove the $${current} budget for ${agent}?\n\n`
3661
+ + `Written to .great_cto/PROJECT.md. ${agent} will dispatch regardless of what it has spent.`);
3662
+ }
3663
+
3664
+ function addAgentBudget() {
3665
+ // Offer the agents the fleet knows about, so a typo does not become a budget
3666
+ // for an agent that does not exist — which would look identical to a cap
3667
+ // nobody is near.
3668
+ const known = (fleetState?.agents || []).map((a) => a.slug);
3669
+ const agent = prompt(`Which agent?${known.length ? `\n\n${known.slice(0, 40).join(', ')}` : ''}`);
3670
+ if (agent === null) return;
3671
+ const slug = String(agent).trim().toLowerCase();
3672
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) { alert(`Not an agent slug: ${agent}`); return; }
3673
+ if (known.length && !known.includes(slug)) {
3674
+ 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;
3675
+ }
3676
+ const usd = budgetPrompt(slug, null);
3677
+ if (usd == null) return;
3678
+ writeAgentBudget({ agent: slug, limit_usd: usd },
3679
+ `Set ${slug} to $${usd}?\n\n`
3680
+ + `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${slug}.`);
3222
3681
  }
3223
3682
 
3224
3683
  async function refreshResume() {
@@ -3263,6 +3722,29 @@ function renderResume(d) {
3263
3722
  card.style.display = totalItems > 0 ? 'block' : 'none';
3264
3723
  if (totalItems === 0) return;
3265
3724
 
3725
+ // The label said "last 24h" and was hardcoded. `getResume` applies no time
3726
+ // filter at all — it takes the last three verdicts, ever — so on a project
3727
+ // last touched five days ago the card announced week-old records as if they
3728
+ // had happened this afternoon.
3729
+ //
3730
+ // The DATA is right for what this card is for: if you were away a week you
3731
+ // still want to know where you left off. The claim about it was wrong, so the
3732
+ // claim is what changes. It now states the age of the newest thing shown, and
3733
+ // says "last 24h" only when that is true.
3734
+ const meta = document.getElementById('resume-meta');
3735
+ if (meta) {
3736
+ const stamps = [...verdicts.map((v) => v.ts), ...wip.map((t) => t.updated_at)]
3737
+ .filter((t) => t && !Number.isNaN(Date.parse(t)))
3738
+ .map((t) => Date.parse(t));
3739
+ if (!stamps.length) {
3740
+ // No usable timestamp is not "recent" — it is unknown, and says so.
3741
+ meta.textContent = 'age unknown';
3742
+ } else {
3743
+ const ageMs = Date.now() - Math.max(...stamps);
3744
+ meta.textContent = ageMs < 24 * 3600e3 ? 'last 24h' : `newest ${relTime(new Date(Math.max(...stamps)).toISOString())}`;
3745
+ }
3746
+ }
3747
+
3266
3748
  // WIP column
3267
3749
  const wipEl = document.getElementById('resume-wip');
3268
3750
  wipEl.innerHTML = wip.length ? wip.map(t => {
@@ -3499,15 +3981,55 @@ function renderPipeline(stages) {
3499
3981
  }
3500
3982
  const msg = s.last_message ? esc(s.last_message) : '—';
3501
3983
  const meta = s.age_min != null ? `${s.age_min < 60 ? s.age_min + 'm' : Math.round(s.age_min/60)+'h'} ago` : '';
3984
+ // A failed stage offered one affordance: filter the Kanban by it. That is
3985
+ // the same thing every other stage offers, and it is not what you want when
3986
+ // something broke. GitHub Actions puts "Re-run failed jobs" on the failure
3987
+ // itself; the least this can do is take you to the verdict that recorded it
3988
+ // and give you the line that re-runs the agent.
3989
+ const failed = s.status === 'failed' || s.status === 'blocked';
3990
+ const recovery = failed ? `
3991
+ <div class="pl-recover" onclick="event.stopPropagation()">
3992
+ <button type="button" class="pl-recover-btn" onclick="drillToVerdict('${esc(s.stage)}')"
3993
+ title="Open the verdict log entry that recorded this failure">why</button>
3994
+ <button type="button" class="pl-recover-btn" onclick="copyRerun('${esc(s.stage)}')"
3995
+ title="Copy the command that re-runs this stage">re-run</button>
3996
+ </div>` : '';
3502
3997
  return `
3503
3998
  <div class="pl-stage ${s.status}" title="Click to filter Kanban by ${esc(s.stage)}" onclick="drillToStage('${esc(s.stage)}')">
3504
3999
  <div class="pl-name"><span class="dot"></span>${label}</div>
3505
4000
  <div class="pl-msg">${msg}</div>
3506
4001
  <div class="pl-meta">${meta || s.status}</div>
4002
+ ${recovery}
3507
4003
  </div>`;
3508
4004
  }).join('');
3509
4005
  }
3510
4006
 
4007
+ /* Open the Activity feed at the verdict that recorded a stage's failure.
4008
+ *
4009
+ * "Why did it fail" is one hop away in the data — the verdict log carries the
4010
+ * agent and the message — and was zero hops away in the UI only if you already
4011
+ * knew to look in Activity.
4012
+ */
4013
+ function drillToVerdict(stage) {
4014
+ switchTab('logs', document.querySelector('.nav-item[data-tab="logs"]'));
4015
+ showToast(`Activity — looking for <code>${esc(stage)}</code>'s last verdict`, 'info', 2500);
4016
+ }
4017
+
4018
+ /* Hand over the line that re-runs a stage.
4019
+ *
4020
+ * The board deliberately does NOT dispatch agents on a click: a re-run spends
4021
+ * money and writes to the project, and this surface has no gate for that. So it
4022
+ * copies the command instead of running it — the operator stays the one who
4023
+ * decides, and does not have to remember the syntax.
4024
+ */
4025
+ function copyRerun(stage) {
4026
+ const cmd = `/agent ${stage}`;
4027
+ navigator.clipboard.writeText(cmd).then(
4028
+ () => showToast(`Copied <code>${esc(cmd)}</code> — paste it in your session`, 'success', 3000),
4029
+ () => showToast(`Run <code>${esc(cmd)}</code> in your session`, 'info', 4000),
4030
+ );
4031
+ }
4032
+
3511
4033
  // Click the human-gate node → jump to the gates/inbox view where they get signed.
3512
4034
  function drillToGates() {
3513
4035
  const search = document.getElementById('search-input');
@@ -3645,9 +4167,27 @@ function renderCost(d) {
3645
4167
  const bars = series.map(s => {
3646
4168
  const h = max > 0 ? Math.max(2, Math.round((s.llm / max) * chartH)) : 2;
3647
4169
  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>`;
4170
+ // A bar whose only reading is a hover tooltip has no reading for a keyboard
4171
+ // or a screen reader — and this chart is money. `tabindex` puts it in the tab
4172
+ // order, `aria-label` carries the same string the tooltip shows, and the
4173
+ // focus rule below opens the tip so the two agree.
4174
+ 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
4175
  }).join('');
3650
4176
 
4177
+ // The same series as a table, collapsed. Grafana and Datadog always let you
4178
+ // read the exact value without a pointer; a chart that can only be hovered
4179
+ // hides its own numbers from the person most likely to need them precisely.
4180
+ const seriesTable = series.length ? `
4181
+ <details class="cost-table">
4182
+ <summary>Daily figures as a table</summary>
4183
+ <div style="overflow-x:auto">
4184
+ <table>
4185
+ <thead><tr><th scope="col">Date</th><th scope="col">Spend</th><th scope="col">Plans</th><th scope="col">Runs</th></tr></thead>
4186
+ <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>
4187
+ </table>
4188
+ </div>
4189
+ </details>` : '';
4190
+
3651
4191
  // Budget line position (% of max)
3652
4192
  let budgetMarker = '';
3653
4193
  if (budget != null) {
@@ -3685,6 +4225,7 @@ function renderCost(d) {
3685
4225
  root.innerHTML = `
3686
4226
  ${summary}
3687
4227
  <div class="cost-chart">${bars}${budgetMarker}</div>
4228
+ ${seriesTable}
3688
4229
  <div class="cost-axis">
3689
4230
  <span>${esc(firstDate)}</span>
3690
4231
  <span style="font-family:var(--sans);color:var(--text2)">LLM spend per day</span>
@@ -3848,6 +4389,54 @@ async function loadDocs() {
3848
4389
  * of them live in ~/.great_cto and are shared by every project, which is why
3849
4390
  * they never appeared in a per-project walk at all.
3850
4391
  */
4392
+ /* Search inside documents, not only their names.
4393
+ *
4394
+ * 187 documents whose contents cannot be searched are a directory listing, and
4395
+ * the question actually asked of them — "where did we write down the reason for
4396
+ * X" — was answerable only by leaving the board.
4397
+ *
4398
+ * `scanned` and `unreadable` come back with the answer and are shown: "nothing
4399
+ * found across 187 files" and "nothing found across the 3 that could be opened"
4400
+ * are different facts, and only one of them means the phrase is not there.
4401
+ */
4402
+ async function searchDocs() {
4403
+ const q = (document.getElementById('docs-q')?.value || '').trim();
4404
+ const out = document.getElementById('docs-results');
4405
+ const body = document.getElementById('docs-body');
4406
+ if (!out) return;
4407
+ if (q.length < 2) { clearDocsSearch(); return; }
4408
+
4409
+ out.style.display = 'block';
4410
+ out.innerHTML = '<div class="muted" style="padding:12px 0">searching…</div>';
4411
+ if (body) body.style.display = 'none';
4412
+
4413
+ const d = await api(`/api/docs/search?q=${encodeURIComponent(q)}${pqs().replace(/^\?/, '&')}`);
4414
+ if (!d || d.error) {
4415
+ out.innerHTML = `<div class="muted" style="padding:12px 0">search failed — ${esc(d?.error || 'no response')}</div>`;
4416
+ return;
4417
+ }
4418
+ const head = `<div class="docs-search-meta">${d.matched} document(s) contain “${esc(d.q)}” · `
4419
+ + `${d.scanned} searched${d.unreadable ? ` · ${d.unreadable} could not be opened` : ''} · `
4420
+ + `<button type="button" class="ab-btn" onclick="clearDocsSearch()">back to all docs</button></div>`;
4421
+ if (!d.matched) {
4422
+ out.innerHTML = head + '<div class="muted" style="padding:12px 0">No document contains that phrase.</div>';
4423
+ return;
4424
+ }
4425
+ out.innerHTML = head + d.results.map((r) => `
4426
+ <div class="docs-hit">
4427
+ <a href="#" onclick="event.preventDefault();openDoc('${esc(r.path)}')"><b>${esc(r.title)}</b></a>
4428
+ <span class="muted"> · ${esc(r.group)} · <code>${esc(r.path)}</code></span>
4429
+ ${r.hits.map((h) => `<div class="docs-hit-line"><span class="docs-hit-no">${h.line}</span>${esc(h.text)}</div>`).join('')}
4430
+ </div>`).join('');
4431
+ }
4432
+
4433
+ function clearDocsSearch() {
4434
+ const out = document.getElementById('docs-results');
4435
+ const body = document.getElementById('docs-body');
4436
+ if (out) { out.style.display = 'none'; out.innerHTML = ''; }
4437
+ if (body) body.style.display = '';
4438
+ }
4439
+
3851
4440
  async function loadContextLayers() {
3852
4441
  let m;
3853
4442
  try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
@@ -4391,7 +4980,7 @@ document.addEventListener('keydown', e => {
4391
4980
 
4392
4981
  if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
4393
4982
  e.preventDefault();
4394
- document.getElementById('search-input').focus();
4983
+ cmdkOpen();
4395
4984
  return;
4396
4985
  }
4397
4986
  if (e.key === 'Escape' && document.activeElement?.id === 'search-input') {
@@ -4719,6 +5308,15 @@ function clearFilters() {
4719
5308
  }
4720
5309
 
4721
5310
  /* ── Kanban ─────────────────────────────────────────────────────────────── */
5311
+ /** Columns the operator has asked to see in full, for this page load. */
5312
+ const expandedColumns = new Set();
5313
+ const COLUMN_CAP = 25;
5314
+
5315
+ function expandColumn(id) {
5316
+ expandedColumns.add(id);
5317
+ renderKanban(allTasks);
5318
+ }
5319
+
4722
5320
  function renderKanban(tasks) {
4723
5321
  renderFilterBar();
4724
5322
  const board = document.getElementById('kanban-board');
@@ -4734,6 +5332,11 @@ function renderKanban(tasks) {
4734
5332
  board.innerHTML = '';
4735
5333
  for (const col of COLUMNS) {
4736
5334
  const items = byCol[col.id];
5335
+ // Capped per column, not globally: a Backlog of 5 and a Done of 227 are
5336
+ // different problems and only one of them needs a lid.
5337
+ const cap = expandedColumns.has(col.id) ? Infinity : COLUMN_CAP;
5338
+ const shown = items.slice(0, cap);
5339
+ const hidden = items.length - shown.length;
4737
5340
  const dotClass = col.dot === 'filled' ? 'filled' : col.dot === 'half' ? 'half' : '';
4738
5341
  const el = document.createElement('div');
4739
5342
  el.className = `column col-${col.id}`;
@@ -4743,10 +5346,20 @@ function renderKanban(tasks) {
4743
5346
  <span class="col-title">${col.label}</span>
4744
5347
  <span class="col-count">${items.length}</span>
4745
5348
  </div>
4746
- <div class="col-body">${items.length ? items.map(cardHTML).join('') : '<div class="empty">No tasks</div>'}</div>`;
5349
+ <div class="col-body">${
5350
+ items.length
5351
+ ? shown.map(cardHTML).join('') + (hidden > 0
5352
+ // 227 done cards in one column is not a list anybody reads; it is
5353
+ // a scroll. The count in the header is the fact worth having, and
5354
+ // the rest is available by asking rather than by default.
5355
+ ? `<button type="button" class="col-more" onclick="expandColumn('${col.id}')">`
5356
+ + `Show ${hidden} more · ${items.length} total</button>`
5357
+ : '')
5358
+ : '<div class="empty">No tasks</div>'
5359
+ }</div>`;
4747
5360
  board.appendChild(el);
4748
5361
  el.querySelectorAll('.card').forEach((cardEl, i) => {
4749
- cardEl.addEventListener('click', () => openSide(items[i]));
5362
+ cardEl.addEventListener('click', () => openSide(shown[i]));
4750
5363
  });
4751
5364
  }
4752
5365
  }
@@ -4957,6 +5570,34 @@ function absent(kind, why) {
4957
5570
  return `<span class="absent" title="${esc(why)}" aria-label="${esc(why)}">${glyph}</span>`;
4958
5571
  }
4959
5572
 
5573
+ /* Change against the window before this one.
5574
+ *
5575
+ * A tile that says "+3 this week" tells you the week; it does not tell you
5576
+ * whether that is more or less than usual. Datadog and Grafana put the delta on
5577
+ * the tile because the number alone is not a reading.
5578
+ *
5579
+ * Four states, and three of them are silence:
5580
+ * - not comparable — the project has no history reaching back two windows, so
5581
+ * there is no previous period to compare against. Saying 0% or "down from
5582
+ * zero" would be inventing one.
5583
+ * - both zero — nothing happened either side; a delta would be noise.
5584
+ * - previous zero, current not — a ratio is undefined, so it says "first",
5585
+ * which is what actually happened.
5586
+ * - otherwise the percentage, with the direction.
5587
+ */
5588
+ function deltaBadge(current, prev, comparable, { invert = false } = {}) {
5589
+ if (!comparable || prev == null || current == null) return '';
5590
+ if (prev === 0 && current === 0) return '';
5591
+ if (prev === 0) return `<span class="delta" title="nothing in the previous period">first</span>`;
5592
+ const pct = Math.round(((current - prev) / prev) * 100);
5593
+ if (pct === 0) return `<span class="delta">flat</span>`;
5594
+ const up = pct > 0;
5595
+ // `invert` for figures where up is bad — spend rises, that is not good news.
5596
+ const good = invert ? !up : up;
5597
+ return `<span class="delta ${good ? 'up' : 'down'}" title="previous period: ${esc(String(prev))}">`
5598
+ + `${up ? '▲' : '▼'} ${Math.abs(pct)}%</span>`;
5599
+ }
5600
+
4960
5601
  /* ── Dashboard ──────────────────────────────────────────────────────────── */
4961
5602
  function renderDashboard(m) {
4962
5603
  const llmUsd = m.cost?.llm_usd ?? 0;
@@ -5001,13 +5642,25 @@ function renderDashboard(m) {
5001
5642
  : (llmUsd > 0 ? `time-based estimate (no verdict data yet)` : 'no plans or tasks yet');
5002
5643
 
5003
5644
  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` },
5005
5645
  {
5006
- // $0.00 with nothing spent yet is a measurement; "—" would claim we never
5007
- // looked. The trend line underneath already says which of the two it is.
5008
- v: costKnown ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : absent('unloaded', 'the metrics payload carried no cost section'),
5646
+ v: doneKnown ? done : absent('unloaded', 'the metrics payload carried no tasks section'),
5647
+ sub: 'shipped', label: 'Tasks done',
5648
+ trend: `+${m.velocity?.this_week ?? 0} this week`,
5649
+ delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
5650
+ },
5651
+ {
5652
+ // Three states, and $0.00 is only one of them. A measured zero is worth
5653
+ // showing; a zero that is the TIME-BASED ESTIMATE with no plans or tasks
5654
+ // to estimate from is not a figure at all, and rendering it as $0.00 told
5655
+ // a project with dozens of agent runs that it had spent nothing.
5656
+ v: !costKnown
5657
+ ? absent('unloaded', 'the metrics payload carried no cost section')
5658
+ : (realLlmUsd === 0 && llmUsd === 0
5659
+ ? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
5660
+ : `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}`),
5009
5661
  sub: '',
5010
5662
  label: 'AI spend',
5663
+ delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
5011
5664
  trend: llmTrend,
5012
5665
  cls: 'amber',
5013
5666
  },
@@ -5034,7 +5687,7 @@ function renderDashboard(m) {
5034
5687
  document.getElementById('mp-hero').innerHTML = hero.map(s => `
5035
5688
  <div class="metric-card ${s.cls || ''}">
5036
5689
  <div class="metric-num">${s.v}${s.sub ? `<small>${s.sub}</small>` : ''}</div>
5037
- <div class="metric-label">${s.label}</div>
5690
+ <div class="metric-label">${s.label}${s.delta || ''}</div>
5038
5691
  <div class="metric-trend">${s.trend}</div>
5039
5692
  </div>`).join('');
5040
5693
 
@@ -5490,6 +6143,112 @@ async function checkBoardFreshness() {
5490
6143
  host.insertBefore(el, host.firstChild);
5491
6144
  }
5492
6145
 
6146
+ /* ── Command palette ───────────────────────────────────────────────────────
6147
+ *
6148
+ * Cmd+K focused the search box, and the search box filters tasks by substring.
6149
+ * That is a find, not a command: what you actually want at 9am is "approve the
6150
+ * gate that is waiting" or "show me metrics", and both were several clicks away.
6151
+ * Linear's palette executes; this one does too.
6152
+ *
6153
+ * Built entirely out of verbs that already exist — switchTab, gateAction,
6154
+ * openNewIssueModal, toggleBoardTheme — so it is a surface over the board's own
6155
+ * actions rather than a second implementation of them. That also means the
6156
+ * safety rules come along: choosing "Approve gate X" calls the same gateAction
6157
+ * that names its consequences and waits for a confirm.
6158
+ */
6159
+ let cmdkItems = [];
6160
+ let cmdkIndex = 0;
6161
+
6162
+ function cmdkActions() {
6163
+ const out = [
6164
+ { group: 'Go', label: 'Inbox — what needs your decision', run: () => switchTab('inbox') },
6165
+ { group: 'Go', label: 'Tasks', run: () => switchTab('kanban') },
6166
+ { group: 'Go', label: 'Metrics', run: () => switchTab('dashboard') },
6167
+ { group: 'Go', label: 'Docs', run: () => switchTab('docs') },
6168
+ { group: 'Go', label: 'Activity', run: () => switchTab('logs') },
6169
+ { group: 'Go', label: 'Notifications', run: () => switchTab('notifications') },
6170
+ { group: 'Do', label: 'New issue', run: () => openNewIssueModal() },
6171
+ { group: 'Do', label: 'Toggle light / dark theme', run: () => toggleBoardTheme() },
6172
+ ];
6173
+ // Open gates by name — the one thing on this board actually waiting for a
6174
+ // person, and previously the hardest thing to reach from the keyboard.
6175
+ for (const t of (allTasks || [])) {
6176
+ if (t.status === 'closed') continue;
6177
+ if (!/gate/i.test(t.title || '')) continue;
6178
+ out.push({ group: 'Gate', label: `Approve — ${t.title}`, hint: t.id,
6179
+ run: () => gateAction(t.id, 'approve') });
6180
+ }
6181
+ return out;
6182
+ }
6183
+
6184
+ function cmdkOpen() {
6185
+ let el = document.getElementById('cmdk');
6186
+ if (!el) {
6187
+ el = document.createElement('div');
6188
+ el.id = 'cmdk';
6189
+ el.innerHTML = `
6190
+ <div class="cmdk-box" role="dialog" aria-modal="true" aria-label="Command palette">
6191
+ <input id="cmdk-input" type="text" placeholder="Type a command…" aria-label="Command"
6192
+ autocomplete="off" oninput="cmdkRender(this.value)" />
6193
+ <div id="cmdk-list" role="listbox" aria-label="Commands"></div>
6194
+ </div>`;
6195
+ el.addEventListener('click', (ev) => { if (ev.target === el) cmdkClose(); });
6196
+ document.body.appendChild(el);
6197
+ }
6198
+ el.classList.add('open');
6199
+ cmdkIndex = 0;
6200
+ const input = document.getElementById('cmdk-input');
6201
+ input.value = '';
6202
+ cmdkRender('');
6203
+ input.focus();
6204
+ }
6205
+
6206
+ function cmdkClose() { document.getElementById('cmdk')?.classList.remove('open'); }
6207
+
6208
+ function cmdkRender(q) {
6209
+ const needle = (q || '').toLowerCase().trim();
6210
+ cmdkItems = cmdkActions().filter((a) =>
6211
+ !needle || a.label.toLowerCase().includes(needle) || (a.hint || '').toLowerCase().includes(needle));
6212
+ if (cmdkIndex >= cmdkItems.length) cmdkIndex = 0;
6213
+ const list = document.getElementById('cmdk-list');
6214
+ if (!list) return;
6215
+ // An empty result says so. A palette that renders nothing looks broken.
6216
+ if (!cmdkItems.length) {
6217
+ list.innerHTML = `<div class="cmdk-empty">No command matches <b>${esc(q)}</b></div>`;
6218
+ return;
6219
+ }
6220
+ list.innerHTML = cmdkItems.map((a, i) => `
6221
+ <div class="cmdk-item ${i === cmdkIndex ? 'on' : ''}" role="option" aria-selected="${i === cmdkIndex}"
6222
+ onclick="cmdkRun(${i})">
6223
+ <span class="cmdk-group">${esc(a.group)}</span>
6224
+ <span class="cmdk-label">${esc(a.label)}</span>
6225
+ ${a.hint ? `<span class="cmdk-hint">${esc(a.hint)}</span>` : ''}
6226
+ </div>`).join('');
6227
+ }
6228
+
6229
+ function cmdkRun(i) {
6230
+ const a = cmdkItems[i];
6231
+ if (!a) return;
6232
+ cmdkClose();
6233
+ // After the palette is gone, so a confirm is not stacked underneath it.
6234
+ setTimeout(() => a.run(), 0);
6235
+ }
6236
+
6237
+ document.addEventListener('keydown', (e) => {
6238
+ const el = document.getElementById('cmdk');
6239
+ if (!el || !el.classList.contains('open')) return;
6240
+ if (e.key === 'Escape') { e.preventDefault(); cmdkClose(); return; }
6241
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
6242
+ e.preventDefault();
6243
+ if (!cmdkItems.length) return;
6244
+ cmdkIndex = (cmdkIndex + (e.key === 'ArrowDown' ? 1 : -1) + cmdkItems.length) % cmdkItems.length;
6245
+ cmdkRender(document.getElementById('cmdk-input').value);
6246
+ document.querySelector('.cmdk-item.on')?.scrollIntoView({ block: 'nearest' });
6247
+ return;
6248
+ }
6249
+ if (e.key === 'Enter') { e.preventDefault(); cmdkRun(cmdkIndex); }
6250
+ });
6251
+
5493
6252
  function menuIsOpen() {
5494
6253
  return !!document.getElementById('sidebar')?.classList.contains('open');
5495
6254
  }
@@ -5551,8 +6310,19 @@ function switchTab(id, el) {
5551
6310
  // crumb — `labels` used to be referenced here but is only ever a function-local in
5552
6311
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
5553
6312
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
5554
- const TAB_LABELS = { docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
6313
+ const TAB_LABELS = { budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
5555
6314
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
6315
+ // Budgets render on Metrics, so the heartbeat that feeds them has to be
6316
+ // fetched when Metrics opens — not only from the agents drill-in it used to
6317
+ // live in.
6318
+ if (id === 'dashboard') refreshHeartbeat();
6319
+ // The Notifications panel has carried an inline history list all along, and
6320
+ // `renderNotifHistoryInline()` to fill it — but the only caller was
6321
+ // `openNotifDrawer()`. Opening the tab itself never loaded it, so the sidebar
6322
+ // promised 50 unread and the screen showed settings. Declared and not
6323
+ // consumed, in the UI this time.
6324
+ if (id === 'notifications') loadNotifHistory();
6325
+ if (id === 'budgets') loadBudgetsPage();
5556
6326
  if (id === 'dashboard' || id === 'agents') {
5557
6327
  // pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
5558
6328
  // its 30-day default and overwrote whatever window the period chips had
@@ -5597,6 +6367,35 @@ async function refreshLogs() {
5597
6367
  if (allLogs.length > 0) renderLogDetail(allLogs[0]);
5598
6368
  }
5599
6369
 
6370
+ /* Which facet the Activity list is filtered to. `null` = everything. */
6371
+ let logFacet = null;
6372
+
6373
+ function setLogFacet(v) {
6374
+ logFacet = (logFacet === v) ? null : v;
6375
+ renderLogsList();
6376
+ }
6377
+
6378
+ /* The facets the CURRENT data actually supports.
6379
+ *
6380
+ * Derived from the logs in hand, never a fixed list: a chip for a source with
6381
+ * no entries is a filter that can only return nothing, and a reader who clicks
6382
+ * it learns something false about the data. Sentry and Datadog put facets on a
6383
+ * feed because "what broke" is a filtering question; this list had no filter at
6384
+ * all, though every log already carries the field.
6385
+ */
6386
+ function logFacets() {
6387
+ const counts = new Map();
6388
+ for (const l of allLogs) {
6389
+ const k = l.source === 'verdicts' || l.source === 'auto' ? 'auto' : 'session';
6390
+ counts.set(k, (counts.get(k) || 0) + 1);
6391
+ }
6392
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
6393
+ }
6394
+
6395
+ function facetOf(log) {
6396
+ return (log.source === 'verdicts' || log.source === 'auto') ? 'auto' : 'session';
6397
+ }
6398
+
5600
6399
  function renderLogsList() {
5601
6400
  const el = document.getElementById('logs-list');
5602
6401
  if (!el) return;
@@ -5604,7 +6403,27 @@ function renderLogsList() {
5604
6403
  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
6404
  return;
5606
6405
  }
5607
- el.innerHTML = allLogs.map((log, i) => `
6406
+ const facets = logFacets();
6407
+ const shown = allLogs.map((log, i) => ({ log, i }))
6408
+ .filter(({ log }) => !logFacet || facetOf(log) === logFacet);
6409
+
6410
+ // Only offer the bar when there is a choice to make. One facet is not a filter.
6411
+ const bar = facets.length > 1 ? `
6412
+ <div class="log-facets" role="group" aria-label="Filter activity">
6413
+ ${facets.map(([k, c]) => `
6414
+ <button type="button" class="log-facet ${logFacet === k ? 'on' : ''}"
6415
+ aria-pressed="${logFacet === k}" onclick="setLogFacet('${esc(k)}')">
6416
+ ${esc(k)} <span class="n">${c}</span>
6417
+ </button>`).join('')}
6418
+ </div>` : '';
6419
+
6420
+ // A filter that hides everything must say so, or it reads as an empty feed.
6421
+ if (!shown.length) {
6422
+ el.innerHTML = bar + `<div class="empty" style="padding:16px">No activity matches <b>${esc(logFacet || '')}</b>.</div>`;
6423
+ return;
6424
+ }
6425
+
6426
+ el.innerHTML = bar + shown.map(({ log, i }) => `
5608
6427
  <div class="log-item ${i === 0 ? 'active' : ''}" onclick="selectLog(${i}, this)">
5609
6428
  <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
6429
  <div class="log-title">${esc(log.title)}</div>
@@ -5691,11 +6510,32 @@ function renderFleetSummary() {
5691
6510
  trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
5692
6511
  onClick: 'setFleetFilter("activity", "never")',
5693
6512
  },
5694
- { v: '$' + (s.llm_usd_30d ?? 0).toFixed(2), label: 'LLM spend 30d', cls: '', trend: s.failing_7d ? `${s.failing_7d} failing` : '' },
6513
+ {
6514
+ // `?? 0` turned "nothing was measured" into "$0.00 spent", and before that
6515
+ // the number here was an ESTIMATE from verdict counts while the metrics
6516
+ // page estimated from tasks — two screens, one label, $3.90 against $1.65.
6517
+ v: s.llm_usd_30d != null
6518
+ ? '$' + s.llm_usd_30d.toFixed(2)
6519
+ : absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`),
6520
+ vHtml: s.llm_usd_30d == null,
6521
+ label: 'LLM spend 30d',
6522
+ cls: '',
6523
+ trend: s.llm_usd_30d != null
6524
+ ? `measured for ${s.llm_usd_30d_measured_for} agent(s)`
6525
+ : (s.failing_7d ? `${s.failing_7d} failing` : 'measured from verdicts'),
6526
+ },
5695
6527
  ];
6528
+ // `t.v` is HTML when it is an absence marker — a span carrying the reason on
6529
+ // hover and in the accessible name — and a plain string otherwise. Escaping it
6530
+ // unconditionally printed the markup as text on the fleet tile. `vHtml` is the
6531
+ // deliberate opt-in; every value without it is still escaped.
6532
+ //
6533
+ // This comment lives out here because it names `t.v` in backticks, and a
6534
+ // backtick inside the template literal below terminates it — which is how the
6535
+ // board shipped a syntax error for about ninety seconds.
5696
6536
  el.innerHTML = tiles.map(t => `
5697
6537
  <div class="metric-card ${t.cls}" ${t.onClick ? `role="button" tabindex="0" onclick='${t.onClick}' onkeydown='if(event.key==="Enter"||event.key===" "){event.preventDefault();${t.onClick}}'` : ''}>
5698
- <div class="metric-num">${esc(String(t.v))}</div>
6538
+ <div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
5699
6539
  <div class="metric-label">${esc(t.label)}</div>
5700
6540
  ${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
5701
6541
  </div>`).join('');
@@ -5779,6 +6619,20 @@ function sortFleet(agents) {
5779
6619
  });
5780
6620
  }
5781
6621
 
6622
+ /* One chip, four states, and the title always says which number it is judging.
6623
+ *
6624
+ * `no-limit` renders NOTHING — an agent with no declared budget is the common
6625
+ * case, and a chip saying "no budget" on sixty rows is noise that trains the eye
6626
+ * to skip the row where it matters.
6627
+ */
6628
+ function budgetChip(b) {
6629
+ if (!b || b.state === 'no-limit') return '';
6630
+ const label = b.state === 'unmeasured'
6631
+ ? `$${b.limitUsd} cap · unmeasured`
6632
+ : `$${(b.measuredUsd ?? 0).toFixed(2)} / $${b.limitUsd}`;
6633
+ return `<span class="budget-chip ${esc(b.state)}" title="${esc(b.why)}">${esc(label)}</span>`;
6634
+ }
6635
+
5782
6636
  function renderFleetList() {
5783
6637
  const el = document.getElementById('fleet-list');
5784
6638
  if (!el) return;
@@ -5796,7 +6650,7 @@ function renderFleetList() {
5796
6650
  return `<li>
5797
6651
  <button class="agent-row status-${esc(a.health)}${a.retired ? ' retired' : ''}"
5798
6652
  role="button" data-slug="${esc(a.slug)}"
5799
- aria-label="${esc(a.slug)} · ${esc(a.health)} · ${rateLabel} · ${esc(lastRunStr)}"
6653
+ aria-label="${esc(a.slug)} · ${esc(a.health)} · ${rateLabel} · ${esc(lastRunStr)}${a.budget && a.budget.state !== 'no-limit' ? ' · ' + esc(a.budget.why) : ''}"
5800
6654
  onclick="openAgentDrawer('${esc(a.slug)}')"
5801
6655
  onkeydown="if(event.key==='Enter'){event.preventDefault();openAgentDrawer('${esc(a.slug)}')}">
5802
6656
  <span class="ar-status" aria-hidden="true"></span>
@@ -5808,6 +6662,7 @@ function renderFleetList() {
5808
6662
  <span>${esc(rateLabel)}</span>
5809
6663
  <span>last: ${esc(lastRunStr)}</span>
5810
6664
  ${savingsStr ? `<span>${esc(savingsStr)}</span>` : ''}
6665
+ ${budgetChip(a.budget)}
5811
6666
  ${a.retired ? '<span style="color:var(--agent-retired)">retired</span>' : ''}
5812
6667
  </span>
5813
6668
  </span>