great-cto 3.0.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.
@@ -2,7 +2,7 @@
2
2
  "name": "great_cto",
3
3
  "id": "great_cto",
4
4
  "description": "Engineering process for solo founders and teams up to 50 engineers. Agents do architecture, code review, QA, and security. You make two decisions per feature.",
5
- "version": "3.0.0",
5
+ "version": "3.1.0",
6
6
  "author": {
7
7
  "name": "Great CTO",
8
8
  "url": "https://github.com/avelikiy/great_cto"
@@ -1,7 +1,7 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
3
  import os from 'os';
4
- import { spawnSync } from 'child_process';
4
+ import { spawnSync, spawn } from 'child_process';
5
5
  import { readSafe } from './util.mjs';
6
6
  import { bdCache } from './state.mjs';
7
7
  import { log } from './log.mjs';
@@ -38,11 +38,35 @@ import { log } from './log.mjs';
38
38
  // because each request arrived just after the entry expired.
39
39
  const BD_CACHE_TTL_MS = Number(process.env.GREAT_CTO_BD_CACHE_TTL_MS || 300000);
40
40
 
41
+ /**
42
+ * Drop a directory's entry so the next read is guaranteed fresh.
43
+ *
44
+ * For a WRITE the board just performed. The operator clicked approve and is
45
+ * waiting for the result; paying for the read is correct there, and serving the
46
+ * pre-approval state would be a lie about what they just did.
47
+ */
41
48
  function bdCacheInvalidate(cwd) {
42
49
  clearSelfTouch(cwd);
43
50
  bdCache.delete(cwd);
44
51
  }
45
52
 
53
+ /**
54
+ * Mark a directory's entry stale WITHOUT dropping it.
55
+ *
56
+ * For a change that arrived from outside — a file event under a project someone
57
+ * is watching. Deleting made the next reader, who asked for something unrelated,
58
+ * pay 623 ms warm and 6.8 s cold for a write they did not make. An entry marked
59
+ * stale is still served, and the refresh happens off the event loop.
60
+ *
61
+ * The difference is intent, not mechanism: a write we performed must be visible
62
+ * to whoever asked for it; a change we merely noticed must not stop the board.
63
+ */
64
+ function bdCacheStale(cwd) {
65
+ clearSelfTouch(cwd);
66
+ const cached = bdCache.get(cwd);
67
+ if (cached) bdCache.set(cwd, { ...cached, ts: 0 });
68
+ }
69
+
46
70
  // ── bd binary resolution (BH-32) ────────────────────────────────────────────
47
71
  // A board launched from a GUI / launchd / a login shell that didn't source the
48
72
  // usual profile often has a minimal PATH (`/usr/bin:/bin`) that omits where
@@ -251,6 +275,60 @@ function clearSelfTouch(cwd) { lastBdRunAt.delete(cwd); }
251
275
  */
252
276
  const EMPTY_TTL_MS = Number(process.env.GREAT_CTO_BD_EMPTY_TTL_MS || 5000);
253
277
 
278
+ /**
279
+ * Directories with a background refresh already in flight.
280
+ *
281
+ * Without this, ten requests arriving while one refresh runs start ten more.
282
+ */
283
+ const refreshing = new Set();
284
+
285
+ /**
286
+ * Refresh a directory's entry WITHOUT holding the event loop.
287
+ *
288
+ * `spawnSync` is what made this board unanswerable: `bd list` costs seconds and
289
+ * blocks everything for the whole of it — /api/version, one readdirSync,
290
+ * measured at 1-10 s because it was queued behind a task read. Warming at boot
291
+ * moved the first stall out of sight; this removes the rest.
292
+ *
293
+ * Nothing awaits it. It exists to make the NEXT read fast, and a caller that
294
+ * needed the new data would have had to block for it anyway.
295
+ */
296
+ function bdRefreshAsync(cwd) {
297
+ if (refreshing.has(cwd)) return;
298
+ refreshing.add(cwd);
299
+ lastBdRunAt.set(cwd, Date.now());
300
+ let out = '';
301
+ try {
302
+ const child = spawn(BD_BIN, ['list', '--json', '--all', '--include-gates'], { cwd, env: bdEnv() });
303
+ child.stdout?.on('data', (d) => { out += d; });
304
+ child.on('error', (e) => {
305
+ refreshing.delete(cwd);
306
+ bdFailures.set(cwd, `bd could not be run: ${e?.message || e}`.slice(0, 300));
307
+ });
308
+ child.on('close', (code) => {
309
+ refreshing.delete(cwd);
310
+ lastBdRunAt.set(cwd, Date.now());
311
+ if (code !== 0) { bdFailures.set(cwd, `bd exited ${code}`); return; }
312
+ try {
313
+ const parsed = JSON.parse(out || '[]');
314
+ // Same guard as the sync path: bd 0.6x can answer 0 with a JSON object,
315
+ // and a non-array rendered as no tasks is the silent zero again.
316
+ if (!Array.isArray(parsed)) {
317
+ bdFailures.set(cwd, String(parsed?.error || 'bd returned something that is not a task list').slice(0, 300));
318
+ return;
319
+ }
320
+ bdFailures.delete(cwd);
321
+ bdCache.set(cwd, { ts: Date.now(), data: parsed });
322
+ } catch (e) {
323
+ bdFailures.set(cwd, `bd output could not be parsed: ${e?.message || e}`.slice(0, 300));
324
+ }
325
+ });
326
+ } catch (e) {
327
+ refreshing.delete(cwd);
328
+ bdFailures.set(cwd, `bd could not be spawned: ${e?.message || e}`.slice(0, 300));
329
+ }
330
+ }
331
+
254
332
  function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
255
333
  const maxAge = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : BD_CACHE_TTL_MS;
256
334
  const cached = bdCache.get(cwd);
@@ -258,6 +336,24 @@ function bdList(cwd = process.cwd(), runner = bd, opts = {}) {
258
336
  ? Math.min(maxAge, EMPTY_TTL_MS)
259
337
  : maxAge;
260
338
  if (cached && Date.now() - cached.ts < ttl) return cached.data;
339
+
340
+ // Stale-while-revalidate. An entry that exists is served immediately, however
341
+ // old, and refreshed off the event loop. Only a directory with NO entry at all
342
+ // blocks — which after the boot warm-up means a project nobody has opened yet,
343
+ // once.
344
+ //
345
+ // The alternative was making this async and every caller with it: getTasks,
346
+ // getInbox, getPipeline, the metrics readers, the SSE broadcast, the alert
347
+ // sweeps. That refactor is the correct end state and is not what a board
348
+ // hanging today needs.
349
+ //
350
+ // The injected `runner` is how the tests drive this. When one is supplied the
351
+ // sync path is kept, so a test that stubs `bd` still observes the call it
352
+ // stubbed rather than a background spawn it cannot see.
353
+ if (cached && runner === bd) {
354
+ bdRefreshAsync(cwd);
355
+ return cached.data;
356
+ }
261
357
  try {
262
358
  lastBdRunAt.set(cwd, Date.now());
263
359
  const result = runner(['list', '--json', '--all', '--include-gates'], { cwd });
@@ -635,6 +731,7 @@ function detectAgent(task) {
635
731
 
636
732
  export {
637
733
  bdCacheInvalidate,
734
+ bdCacheStale,
638
735
  BD_CACHE_TTL_MS,
639
736
  SWEEP_MAX_AGE_MS,
640
737
  EMPTY_TTL_MS,
@@ -203,7 +203,19 @@ function getAgentsFleet(projectCwd) {
203
203
  const active30d = agents.filter(a => a.runs_30d > 0 && !a.retired).length;
204
204
  const retireCandidates = agents.filter(a => a.runs_30d === 0 && !a.retired).length;
205
205
  const failing = agents.filter(a => a.health === 'failing' && !a.retired).length;
206
- const totalLlm30d = agents.reduce((s, a) => s + (a.llm_usd_30d_est || 0), 0);
206
+ // MEASURED spend, or none. This tile reported the estimate verdict count
207
+ // times a hardcoded rate — under the label "LLM SPEND 30D", while the metrics
208
+ // page reported its own estimate, derived from TASKS, under the same words.
209
+ // The two disagreed by more than twofold ($3.90 against $1.65) and neither
210
+ // said it was estimating, so the board contradicted itself and sounded certain
211
+ // doing it.
212
+ //
213
+ // The estimate stays available per agent as `llm_usd_30d_est`, named as an
214
+ // estimate. The fleet total claims only what verdicts actually recorded, and
215
+ // is null when they recorded nothing — the same rule the metrics tile, the
216
+ // portfolio and the budgets all follow now.
217
+ const totalReal30d = agents.reduce((s, a) => s + (a.llm_usd_30d_real || 0), 0);
218
+ const measuredFor = agents.filter((a) => a.llm_usd_30d_real != null).length;
207
219
 
208
220
  return {
209
221
  agents,
@@ -213,7 +225,9 @@ function getAgentsFleet(projectCwd) {
213
225
  active_30d: active30d,
214
226
  retire_candidates: retireCandidates,
215
227
  failing_7d: failing,
216
- llm_usd_30d: Math.round(totalLlm30d * 100) / 100,
228
+ llm_usd_30d: measuredFor ? Math.round(totalReal30d * 100) / 100 : null,
229
+ llm_usd_30d_measured_for: measuredFor,
230
+ llm_usd_30d_agents_with_runs: agents.filter((a) => a.runs_30d > 0).length,
217
231
  },
218
232
  };
219
233
  }
@@ -160,7 +160,12 @@ function getMetrics(cwd = process.cwd(), days = 30) {
160
160
  // Without this, "AI spend" stayed at lifetime $93 even when period=7D
161
161
  // showed only 12 tasks worth ~$0.30 — making savings ratios nonsensical.
162
162
  for (const v of verdicts) {
163
- if (v.cost_usd == null) continue;
163
+ // A RECORDED zero is not a measurement. `log-verdict.sh` wrote `cost_usd: 0`
164
+ // whenever no cost was passed, for this repository's whole history, so
165
+ // `!= null` counted every one of them as a measured run costing nothing —
166
+ // and the board reported "$0.00 AI spend" over projects with dozens of
167
+ // agent runs. Same defect as the fleet's, on a second screen.
168
+ if (v.cost_usd == null || v.cost_usd === 0) continue;
164
169
  if (!agentCostMap[v.agent]) continue;
165
170
  if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) continue;
166
171
  agentCostMap[v.agent].real_llm_usd += v.cost_usd;
@@ -195,7 +200,7 @@ function getMetrics(cwd = process.cwd(), days = 30) {
195
200
  const taskHumanTotal = agentsCost.reduce((s, a) => s + a.human_usd, 0);
196
201
  // Filter verdicts to the same window for consistent total
197
202
  const verdictLlmTotal = verdicts.reduce((s, v) => {
198
- if (v.cost_usd == null) return s;
203
+ if (v.cost_usd == null || v.cost_usd === 0) return s;
199
204
  if (v.ts && (now - new Date(v.ts).getTime()) > costWindowMs) return s;
200
205
  return s + v.cost_usd;
201
206
  }, 0);
@@ -206,7 +211,7 @@ function getMetrics(cwd = process.cwd(), days = 30) {
206
211
  // Bar: enough windowed done-tasks carry a real verdict cost (coverage ≥ 50%,
207
212
  // min 3), and the measured total is a real spend (≥ 1¢, not a synthetic $0).
208
213
  const doneInWindowCount = done.filter(t => t.closed_at && (now - new Date(t.closed_at).getTime()) <= costWindowMs).length;
209
- const verdictsWithCost = verdicts.filter(v => v.cost_usd != null && (!v.ts || (now - new Date(v.ts).getTime()) <= costWindowMs)).length;
214
+ const verdictsWithCost = verdicts.filter(v => v.cost_usd != null && v.cost_usd !== 0 && (!v.ts || (now - new Date(v.ts).getTime()) <= costWindowMs)).length;
210
215
  const measuredTrustworthy = verdictLlmTotal >= 0.01
211
216
  && verdictsWithCost >= Math.max(3, Math.ceil(0.5 * doneInWindowCount));
212
217
 
@@ -1468,6 +1468,56 @@ async function dispatch(req, res, url, cwd) {
1468
1468
  return true;
1469
1469
  }
1470
1470
 
1471
+ // Search INSIDE documents, not only their titles.
1472
+ //
1473
+ // The docs list has always matched on filename and heading. A hundred and
1474
+ // eighty-seven documents whose contents cannot be searched are a directory
1475
+ // listing, and the thing an operator actually wants — "where did we write down
1476
+ // the reason for X" — was answerable only by leaving the board.
1477
+ //
1478
+ // GET /api/docs/search?q=... -> { q, matched, scanned, results[] }
1479
+ if (pathname === '/api/docs/search' && req.method === 'GET') {
1480
+ const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
1481
+ const q = (url.searchParams.get('q') || '').trim();
1482
+ if (q.length < 2) {
1483
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1484
+ res.end(JSON.stringify({ error: 'query must be at least 2 characters' }));
1485
+ return true;
1486
+ }
1487
+ try {
1488
+ const { listDocs } = await import('./docs.mjs');
1489
+ const all = listDocs(c);
1490
+ const needle = q.toLowerCase();
1491
+ const results = [];
1492
+ let scanned = 0, unreadable = 0;
1493
+ for (const g of (all.groups || [])) {
1494
+ for (const d of (g.docs || [])) {
1495
+ if (results.length >= 60) break;
1496
+ let text;
1497
+ try { text = fs.readFileSync(path.join(c, d.path), 'utf8'); scanned++; }
1498
+ catch { unreadable++; continue; } // counted, never silently skipped
1499
+ const lines = text.split('\n');
1500
+ const hits = [];
1501
+ for (let i = 0; i < lines.length && hits.length < 3; i++) {
1502
+ if (lines[i].toLowerCase().includes(needle)) {
1503
+ hits.push({ line: i + 1, text: lines[i].trim().slice(0, 160) });
1504
+ }
1505
+ }
1506
+ if (hits.length) results.push({ path: d.path, title: d.title || d.name, group: g.label, hits });
1507
+ }
1508
+ }
1509
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1510
+ // `scanned` and `unreadable` travel with the answer: "no results" over
1511
+ // 187 files and "no results" over 3 that could be opened are different
1512
+ // facts, and only one of them means the phrase is not there.
1513
+ res.end(JSON.stringify({ q, matched: results.length, scanned, unreadable, results }));
1514
+ } catch (e) {
1515
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1516
+ res.end(JSON.stringify({ error: String(e.message || e) }));
1517
+ }
1518
+ return true;
1519
+ }
1520
+
1471
1521
  // API requests get a JSON 404 so frontends can JSON.parse() the response
1472
1522
  // without crashing. Static-file 404s stay plain text.
1473
1523
  if (pathname.startsWith('/api/')) {
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import { GREAT_CTO_DIR } from './config.mjs';
4
4
  import { sseClients } from './state.mjs';
5
5
  import { listProjects } from './projects.mjs';
6
- import { bdCacheInvalidate, getTasks, isSelfInflictedTouch } from './beads.mjs';
6
+ import { bdCacheStale, getTasks, isSelfInflictedTouch } from './beads.mjs';
7
7
  import { getPipeline, getInbox } from './data-readers.mjs';
8
8
 
9
9
  // ── File watcher ───────────────────────────────────────────────────────────────
@@ -48,7 +48,10 @@ function watchBeads() {
48
48
  // broadcastTasks itself; a write from outside is not a self-touch and lands
49
49
  // in the branch below.
50
50
  if (isSelfInflictedTouch(dir)) return;
51
- bdCacheInvalidate(dir);
51
+ // Stale, not deleted. A change we NOTICED must not make the next reader pay
52
+ // for it — they asked for something else. The broadcast below still reads
53
+ // fresh, because a stale entry refreshes on read.
54
+ bdCacheStale(dir);
52
55
  for (const res of sseClients) {
53
56
  if (res._gctoCwd === dir) {
54
57
  try {
@@ -1640,6 +1640,44 @@ button { font-family: inherit; cursor: pointer; }
1640
1640
  display: flex; gap: 4px; align-items: center;
1641
1641
  }
1642
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; }
1643
1681
  .inbox-empty {
1644
1682
  padding: 14px 16px;
1645
1683
  background: var(--bg-muted);
@@ -2532,6 +2570,11 @@ button { font-family: inherit; cursor: pointer; }
2532
2570
  <span>Activity</span>
2533
2571
  <span class="count" id="nav-logs-count">0</span>
2534
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>
2535
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()">
2536
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>
2537
2580
  <span>Notifications</span>
@@ -2712,6 +2755,14 @@ button { font-family: inherit; cursor: pointer; }
2712
2755
  <div class="mp-hero" id="mp-hero"></div>
2713
2756
  <div class="mp-secondary" id="mp-secondary"></div>
2714
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
+
2715
2766
  <div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
2716
2767
  <div class="mp-card cost-panel" id="cost-panel">
2717
2768
  <div class="cost-empty">Loading…</div>
@@ -2754,11 +2805,6 @@ button { font-family: inherit; cursor: pointer; }
2754
2805
 
2755
2806
  <!-- §3.1 Fleet controls -->
2756
2807
  <section aria-label="Fleet controls" style="margin-top:20px">
2757
- <!-- Agent budgets (Paperclip cascading budget pattern) -->
2758
- <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;">
2759
- <!-- populated by renderAgentBudgets() from /api/heartbeat -->
2760
- </div>
2761
-
2762
2808
  <div class="mp-card fleet-controls">
2763
2809
  <div class="fleet-search">
2764
2810
  <input type="search" id="fleet-search" placeholder="Filter agents (press / to focus)" aria-label="Filter agents by slug" />
@@ -2838,8 +2884,29 @@ button { font-family: inherit; cursor: pointer; }
2838
2884
  <div id="sessions-body" class="muted">Loading…</div>
2839
2885
  </div>
2840
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
+
2841
2900
  <div class="panel" id="panel-docs">
2842
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>
2843
2910
  <div id="docs-body" class="muted">loading…</div>
2844
2911
  </div>
2845
2912
 
@@ -3410,9 +3477,18 @@ function renderAgentBudgets(budgets, meta = {}) {
3410
3477
  if (!el) return;
3411
3478
  const entries = Object.entries(budgets || {});
3412
3479
  const malformed = meta.budgets_malformed || [];
3413
- if (entries.length === 0 && !malformed.length) { el.style.display = 'none'; return; }
3414
3480
  el.style.display = 'block';
3415
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
+
3416
3492
  // Judgements the fleet already computed, keyed by slug. Absent while the
3417
3493
  // fleet is still loading — the row then shows the cap and says the state is
3418
3494
  // not known yet, rather than guessing one.
@@ -3450,6 +3526,95 @@ function renderAgentBudgets(budgets, meta = {}) {
3450
3526
  + `<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>`;
3451
3527
  }
3452
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
+
3453
3618
  /* Writing a cap writes PROJECT.md — a file the operator owns and git tracks.
3454
3619
  *
3455
3620
  * The confirm states what the number DOES rather than asking whether you are
@@ -3467,6 +3632,7 @@ async function writeAgentBudget(body, consequence) {
3467
3632
  showToast(`Budget for <strong>${esc(r.agent)}</strong> ${r.removed ? 'removed' : 'saved'}`, 'success', 2500);
3468
3633
  refreshHeartbeat();
3469
3634
  if (typeof loadFleet === 'function') loadFleet();
3635
+ if (currentTab === 'budgets') loadBudgetsPage();
3470
3636
  }
3471
3637
 
3472
3638
  function budgetPrompt(agent, current) {
@@ -4223,6 +4389,54 @@ async function loadDocs() {
4223
4389
  * of them live in ~/.great_cto and are shared by every project, which is why
4224
4390
  * they never appeared in a per-project walk at all.
4225
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
+
4226
4440
  async function loadContextLayers() {
4227
4441
  let m;
4228
4442
  try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
@@ -5094,6 +5308,15 @@ function clearFilters() {
5094
5308
  }
5095
5309
 
5096
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
+
5097
5320
  function renderKanban(tasks) {
5098
5321
  renderFilterBar();
5099
5322
  const board = document.getElementById('kanban-board');
@@ -5109,6 +5332,11 @@ function renderKanban(tasks) {
5109
5332
  board.innerHTML = '';
5110
5333
  for (const col of COLUMNS) {
5111
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;
5112
5340
  const dotClass = col.dot === 'filled' ? 'filled' : col.dot === 'half' ? 'half' : '';
5113
5341
  const el = document.createElement('div');
5114
5342
  el.className = `column col-${col.id}`;
@@ -5118,10 +5346,20 @@ function renderKanban(tasks) {
5118
5346
  <span class="col-title">${col.label}</span>
5119
5347
  <span class="col-count">${items.length}</span>
5120
5348
  </div>
5121
- <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>`;
5122
5360
  board.appendChild(el);
5123
5361
  el.querySelectorAll('.card').forEach((cardEl, i) => {
5124
- cardEl.addEventListener('click', () => openSide(items[i]));
5362
+ cardEl.addEventListener('click', () => openSide(shown[i]));
5125
5363
  });
5126
5364
  }
5127
5365
  }
@@ -5411,9 +5649,15 @@ function renderDashboard(m) {
5411
5649
  delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
5412
5650
  },
5413
5651
  {
5414
- // $0.00 with nothing spent yet is a measurement; "—" would claim we never
5415
- // looked. The trend line underneath already says which of the two it is.
5416
- v: costKnown ? `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}` : absent('unloaded', 'the metrics payload carried no cost section'),
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)}`),
5417
5661
  sub: '',
5418
5662
  label: 'AI spend',
5419
5663
  delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
@@ -6066,8 +6310,19 @@ function switchTab(id, el) {
6066
6310
  // crumb — `labels` used to be referenced here but is only ever a function-local in
6067
6311
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
6068
6312
  // loaders below (e.g. logs stuck on "Loading…"). Use a local label map.
6069
- 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' };
6070
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();
6071
6326
  if (id === 'dashboard' || id === 'agents') {
6072
6327
  // pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
6073
6328
  // its 30-day default and overwrote whatever window the period chips had
@@ -6255,11 +6510,32 @@ function renderFleetSummary() {
6255
6510
  trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
6256
6511
  onClick: 'setFleetFilter("activity", "never")',
6257
6512
  },
6258
- { 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
+ },
6259
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.
6260
6536
  el.innerHTML = tiles.map(t => `
6261
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}}'` : ''}>
6262
- <div class="metric-num">${esc(String(t.v))}</div>
6538
+ <div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
6263
6539
  <div class="metric-label">${esc(t.label)}</div>
6264
6540
  ${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
6265
6541
  </div>`).join('');
@@ -13,6 +13,7 @@ import fs from 'fs';
13
13
  import path from 'path';
14
14
  import { spawnSync } from 'child_process';
15
15
  import { PORT, PUBLIC, HOST } from './lib/config.mjs';
16
+ import { getTasks } from './lib/beads.mjs';
16
17
  import { originAllowed, isInsideDir } from './lib/util.mjs';
17
18
  import { discoverProjects, resolveProjectInfo } from './lib/projects.mjs';
18
19
  import { startAlertCron } from './lib/alerts.mjs';
@@ -114,6 +115,29 @@ server.listen(PORT, HOST, () => {
114
115
  log.info(` ⚠ bound to ${HOST} — reachable beyond this machine. Operators authenticate via invite`);
115
116
  log.info(` links; put your reverse-proxy auth in front for anything admin-grade.`);
116
117
  }
118
+ // Warm the task cache for THIS project before a browser asks.
119
+ //
120
+ // `bd list` costs seconds and runs through `spawnSync`, which holds the event
121
+ // loop for the whole of it. On a cold board the first request pays that, and
122
+ // every other request queues behind it: opening the board showed "loading…"
123
+ // on Docs for 27 seconds, measured, while the endpoint itself takes 0.4 s
124
+ // warm. Nothing was broken — everything was waiting.
125
+ //
126
+ // Doing it here moves the stall to before anyone is looking, where a stall
127
+ // costs nothing. `setImmediate` so the listening callback returns first and
128
+ // the port is genuinely open while this runs.
129
+ setImmediate(() => {
130
+ const t0 = Date.now();
131
+ try {
132
+ getTasks(process.cwd());
133
+ log.info(` → task cache warmed in ${Date.now() - t0}ms`);
134
+ } catch (e) {
135
+ // A warm-up that failed changes nothing a request would not have hit
136
+ // anyway; it must never stop the board from serving.
137
+ log.warn(` → could not warm the task cache: ${e?.message || e}`);
138
+ }
139
+ });
140
+
117
141
  // Discover all great_cto projects on disk asynchronously — don't block
118
142
  // the listening event so /api/tasks is available immediately.
119
143
  discoverProjects().then(n => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",