great-cto 3.0.0 → 3.2.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.2.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,56 @@ 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
+ /* A row has to answer three questions: what is this agent, what does it cost,
1651
+ over what period. The slug alone answered none of them. */
1652
+ .budgets-table .bt-slug { font-family: var(--mono); font-size: 12.5px; }
1653
+ .budgets-table .bt-desc {
1654
+ font-size: 12px; color: var(--text2); margin-top: 2px;
1655
+ max-width: 52ch; text-wrap: pretty;
1656
+ }
1657
+ .budgets-table .bt-runs,
1658
+ .budgets-table .bt-cap { font-family: var(--mono); font-variant-numeric: tabular-nums; text-align: right; }
1659
+ .budgets-table .bt-spend { font-family: var(--mono); font-size: 12px; white-space: nowrap; }
1660
+ .budgets-table .bt-usd { font-variant-numeric: tabular-nums; color: var(--text); }
1661
+ .budgets-table th[title] { cursor: help; }
1662
+ .budgets-idle summary { cursor: pointer; font-size: 13px; color: var(--text2); }
1663
+ .budgets-idle summary:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 2px; }
1664
+ .budgets-table .bt-actions { text-align: right; white-space: nowrap; }
1665
+ .budgets-table .bt-actions .ab-btn + .ab-btn { margin-left: 6px; }
1666
+ .budgets-empty {
1667
+ padding: 16px; border: 1px dashed var(--border-strong); border-radius: 8px;
1668
+ color: var(--text2); font-size: 13px;
1669
+ }
1670
+ .budgets-uncapped { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
1671
+ .docs-search { display: flex; align-items: center; gap: 8px; margin: 10px 0 16px; }
1672
+ .docs-search input {
1673
+ flex: 1 1 auto; min-width: 0; max-width: 460px;
1674
+ height: 32px; padding: 0 10px;
1675
+ border: 1px solid var(--border); border-radius: 8px;
1676
+ background: var(--bg-card); color: var(--text); font: inherit; font-size: 13px;
1677
+ }
1678
+ .docs-search input:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1679
+ .docs-search-hint { font-family: var(--mono); font-size: 10px; color: var(--text3); }
1680
+ .docs-search-meta { font-size: 12px; color: var(--text2); margin-bottom: 12px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
1681
+ .docs-hit { padding: 10px 0; border-top: 1px solid var(--border); }
1682
+ .docs-hit-line { display: flex; gap: 10px; font-family: var(--mono); font-size: 11px; color: var(--text2); margin-top: 4px; }
1683
+ .docs-hit-no { color: var(--text3); min-width: 34px; text-align: right; }
1684
+ .col-more {
1685
+ display: block; width: 100%;
1686
+ margin-top: 6px; padding: 8px;
1687
+ font-family: var(--mono); font-size: 11px;
1688
+ border: 1px dashed var(--border-strong); border-radius: 8px;
1689
+ background: transparent; color: var(--text2);
1690
+ }
1691
+ .col-more:hover { border-style: solid; color: var(--text); }
1692
+ .col-more:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
1643
1693
  .inbox-empty {
1644
1694
  padding: 14px 16px;
1645
1695
  background: var(--bg-muted);
@@ -2532,6 +2582,11 @@ button { font-family: inherit; cursor: pointer; }
2532
2582
  <span>Activity</span>
2533
2583
  <span class="count" id="nav-logs-count">0</span>
2534
2584
  </div>
2585
+ <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)">
2586
+ <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>
2587
+ <span>Budgets</span>
2588
+ <span class="count" id="nav-budget-count"></span>
2589
+ </div>
2535
2590
  <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
2591
  <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
2592
  <span>Notifications</span>
@@ -2712,6 +2767,14 @@ button { font-family: inherit; cursor: pointer; }
2712
2767
  <div class="mp-hero" id="mp-hero"></div>
2713
2768
  <div class="mp-secondary" id="mp-secondary"></div>
2714
2769
 
2770
+ <!-- Budgets live HERE, on Metrics, not two clicks deep in the Agents
2771
+ drill-in. A control the operator could not find is a control they do
2772
+ not have — and it was invisible on top of that, hidden whenever a
2773
+ project had declared no caps, which is exactly the project that
2774
+ needs the button to declare one. -->
2775
+ <div class="mp-section-label" style="margin-top:24px">Agent budgets</div>
2776
+ <div id="agent-budgets" class="mp-card" style="padding:12px 16px;font-size:12px;margin-bottom:4px;"></div>
2777
+
2715
2778
  <div class="mp-section-label" id="mp-cost-label" style="margin-top:24px">Cost · last 30 days</div>
2716
2779
  <div class="mp-card cost-panel" id="cost-panel">
2717
2780
  <div class="cost-empty">Loading…</div>
@@ -2754,11 +2817,6 @@ button { font-family: inherit; cursor: pointer; }
2754
2817
 
2755
2818
  <!-- §3.1 Fleet controls -->
2756
2819
  <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
2820
  <div class="mp-card fleet-controls">
2763
2821
  <div class="fleet-search">
2764
2822
  <input type="search" id="fleet-search" placeholder="Filter agents (press / to focus)" aria-label="Filter agents by slug" />
@@ -2838,8 +2896,29 @@ button { font-family: inherit; cursor: pointer; }
2838
2896
  <div id="sessions-body" class="muted">Loading…</div>
2839
2897
  </div>
2840
2898
 
2899
+ <div class="panel" id="panel-budgets" role="tabpanel">
2900
+ <div class="metrics-page">
2901
+ <h1>Agent budgets</h1>
2902
+ <p class="muted" style="max-width:70ch;margin:6px 0 20px">
2903
+ A cap on what one agent may spend. When measured spend passes it, the pipeline
2904
+ refuses to dispatch that agent and says so — it does not fail quietly.
2905
+ Spend is measured from verdicts that carry a cost; while none does, the cap
2906
+ cannot fire and says that too. An estimate never refuses.
2907
+ </p>
2908
+ <div id="budgets-page-body">loading…</div>
2909
+ </div>
2910
+ </div>
2911
+
2841
2912
  <div class="panel" id="panel-docs">
2842
2913
  <h1>Docs</h1>
2914
+ <div class="docs-search">
2915
+ <input type="search" id="docs-q" placeholder="Search inside documents…"
2916
+ aria-label="Search inside documents"
2917
+ onkeydown="if(event.key==='Enter'){event.preventDefault();searchDocs();}else if(event.key==='Escape'){this.value='';clearDocsSearch();}" />
2918
+ <button type="button" class="ab-btn" onclick="searchDocs()">Search</button>
2919
+ <span class="docs-search-hint"><kbd>Enter</kbd> search · <kbd>Esc</kbd> clear</span>
2920
+ </div>
2921
+ <div id="docs-results" style="display:none"></div>
2843
2922
  <div id="docs-body" class="muted">loading…</div>
2844
2923
  </div>
2845
2924
 
@@ -3410,9 +3489,18 @@ function renderAgentBudgets(budgets, meta = {}) {
3410
3489
  if (!el) return;
3411
3490
  const entries = Object.entries(budgets || {});
3412
3491
  const malformed = meta.budgets_malformed || [];
3413
- if (entries.length === 0 && !malformed.length) { el.style.display = 'none'; return; }
3414
3492
  el.style.display = 'block';
3415
3493
 
3494
+ // Shown even with nothing declared. Hiding it meant the only project that
3495
+ // needed the "add a budget" button — one with no budgets — was the only
3496
+ // project that never saw it.
3497
+ if (entries.length === 0 && !malformed.length) {
3498
+ el.innerHTML = '<div class="ab-title">Agent budgets · <code>PROJECT.md</code></div>'
3499
+ + '<div class="ab-spent">No caps declared — every agent dispatches regardless of what it has spent.</div>'
3500
+ + '<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>';
3501
+ return;
3502
+ }
3503
+
3416
3504
  // Judgements the fleet already computed, keyed by slug. Absent while the
3417
3505
  // fleet is still loading — the row then shows the cap and says the state is
3418
3506
  // not known yet, rather than guessing one.
@@ -3450,6 +3538,149 @@ function renderAgentBudgets(budgets, meta = {}) {
3450
3538
  + `<button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget</button>`;
3451
3539
  }
3452
3540
 
3541
+ /* The Budgets screen.
3542
+ *
3543
+ * The panel on the agents tab was only reachable two clicks in, and hidden
3544
+ * entirely on a project with no caps declared — so on the project the operator
3545
+ * actually opened, the feature did not exist. A limit you can only see after
3546
+ * hand-editing the config it reads is documentation, not a feature.
3547
+ *
3548
+ * This screen shows the same judgements the dispatcher refuses with, plus every
3549
+ * agent that has NO cap, because "which agents are uncapped" is the question you
3550
+ * arrive with when you come here at all.
3551
+ */
3552
+ async function loadBudgetsPage() {
3553
+ const body = document.getElementById('budgets-page-body');
3554
+ if (!body) return;
3555
+ body.innerHTML = '<div class="muted">loading…</div>';
3556
+
3557
+ const [h] = await Promise.all([
3558
+ api(`/api/heartbeat${pqs()}`),
3559
+ (typeof refreshAgentsInstalled === 'function' && !(fleetState?.agents || []).length)
3560
+ ? refreshAgentsInstalled() : Promise.resolve(),
3561
+ ]);
3562
+ const caps = h?.budgets || {};
3563
+ const agents = (fleetState?.agents || []).filter((a) => !a.retired);
3564
+
3565
+ // 69 identical chips in one blob answered none of the three questions a person
3566
+ // arrives with: what does this agent do, what does it usually cost, and over
3567
+ // what period. Worse, 62 of the 69 had not run in thirty days — a cap on an
3568
+ // agent that never runs is a cap on nothing, and they were the bulk of the
3569
+ // wall.
3570
+ //
3571
+ // Ordered by what it costs to leave uncapped: agents that ran, most spend
3572
+ // first. The ones that never ran are still reachable, behind a count.
3573
+ const spendOf = (a) => (a.llm_usd_30d_real ?? a.llm_usd_30d_est ?? 0);
3574
+ const ran = agents.filter((a) => (a.runs_30d || 0) > 0)
3575
+ .sort((x, y) => spendOf(y) - spendOf(x) || (y.runs_30d || 0) - (x.runs_30d || 0));
3576
+ const idle = agents.filter((a) => !(a.runs_30d > 0));
3577
+
3578
+ const firstSentence = (t) => {
3579
+ const s1 = String(t || '').split(/(?<=[.!?])\s/)[0] || '';
3580
+ return s1.length > 150 ? s1.slice(0, 147) + '…' : s1;
3581
+ };
3582
+
3583
+ // Spend is stated with its window and its provenance. `est` is a time-based
3584
+ // guess and says so; a real figure only exists where a verdict carried a cost.
3585
+ const spendCell = (a) => {
3586
+ if (a.llm_usd_30d_real != null && a.llm_usd_30d_real > 0) {
3587
+ return `<span class="bt-usd">$${a.llm_usd_30d_real.toFixed(2)}</span> <span class="muted">measured</span>`;
3588
+ }
3589
+ if (a.llm_usd_30d_est != null && a.llm_usd_30d_est > 0) {
3590
+ return `<span class="bt-usd">$${a.llm_usd_30d_est.toFixed(2)}</span> <span class="muted">estimated</span>`;
3591
+ }
3592
+ return '<span class="muted">not measured</span>';
3593
+ };
3594
+
3595
+ const stateCell = (slug) => {
3596
+ const b = (fleetState?.agents || []).find((a) => a.slug === slug)?.budget;
3597
+ if (!b || b.state === 'no-limit') return '';
3598
+ const spent = (b.state === 'within' || b.state === 'exceeded')
3599
+ ? `$${(b.measuredUsd ?? 0).toFixed(2)} measured` : (b.state === 'unmeasured' ? 'nothing measured yet' : '');
3600
+ return `${budgetChip(b)} <span class="ab-spent">${esc(spent)}</span>`;
3601
+ };
3602
+
3603
+ const row = (a, cap) => `
3604
+ <tr>
3605
+ <td class="bt-agent">
3606
+ <div class="bt-slug">${esc(a.slug)}</div>
3607
+ <div class="bt-desc">${esc(firstSentence(a.description))}</div>
3608
+ </td>
3609
+ <td class="bt-runs">${a.runs_30d || 0}</td>
3610
+ <td class="bt-spend">${spendCell(a)}</td>
3611
+ <td class="bt-cap">${cap != null ? `$${esc(String(cap))}` : '<span class="muted">—</span>'}</td>
3612
+ <td class="bt-state">${cap != null ? stateCell(a.slug) : ''}</td>
3613
+ <td class="bt-actions">${cap != null
3614
+ ? `<button type="button" class="ab-btn" onclick="editAgentBudget('${esc(a.slug)}', ${Number(cap)})">Change</button>`
3615
+ + `<button type="button" class="ab-btn ab-rm" onclick="clearAgentBudget('${esc(a.slug)}', ${Number(cap)})">Remove</button>`
3616
+ : `<button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')">Set a cap</button>`}</td>
3617
+ </tr>`;
3618
+
3619
+ const table = (rows) => `
3620
+ <table class="budgets-table">
3621
+ <thead><tr>
3622
+ <th scope="col">Agent</th>
3623
+ <th scope="col" title="Dispatches in the last 30 days">Runs 30d</th>
3624
+ <th scope="col" title="LLM spend over the same 30 days">Spend 30d</th>
3625
+ <th scope="col">Cap</th><th scope="col">State</th><th scope="col"></th>
3626
+ </tr></thead>
3627
+ <tbody>${rows}</tbody>
3628
+ </table>`;
3629
+
3630
+ // A cap declared for an agent the fleet does not list still has to appear —
3631
+ // otherwise a limit that is holding a stage would be invisible here.
3632
+ const known = new Set(agents.map((a) => a.slug));
3633
+ const orphanCaps = Object.entries(caps).filter(([slug]) => !known.has(slug));
3634
+
3635
+ const capped = ran.filter((a) => a.slug in caps);
3636
+ const uncappedRan = ran.filter((a) => !(a.slug in caps));
3637
+
3638
+ const notes = [
3639
+ h?.budgets_deprecated_key
3640
+ ? `<div class="ab-note">Read from the deprecated <code>${esc(h.budgets_deprecated_key)}:</code> key — rename it to <code>agent-budgets:</code>.</div>` : '',
3641
+ (h?.budgets_malformed || []).length
3642
+ ? `<div class="ab-note ab-bad">${h.budgets_malformed.length} line(s) in PROJECT.md were not read: `
3643
+ + h.budgets_malformed.map((m) => `<code>${esc(m.line)}</code> — ${esc(m.why)}`).join('; ') + '</div>' : '',
3644
+ ].join('');
3645
+
3646
+ const capsBlock = (capped.length || orphanCaps.length) ? `
3647
+ <div class="mp-section-label">Capped (${capped.length + orphanCaps.length})</div>
3648
+ ${table(capped.map((a) => row(a, caps[a.slug])).join('')
3649
+ + orphanCaps.map(([slug, cap]) => row({ slug, description: 'not among the installed agents', runs_30d: 0 }, cap)).join(''))}`
3650
+ : `<div class="budgets-empty">No agent has a spending cap in this project. Nothing is being held, and nothing will be.</div>`;
3651
+
3652
+ const activeBlock = uncappedRan.length ? `
3653
+ <div class="mp-section-label" style="margin-top:28px">Ran in the last 30 days, no cap (${uncappedRan.length})</div>
3654
+ <p class="muted" style="margin:4px 0 10px">These are the ones a cap would actually apply to.</p>
3655
+ ${table(uncappedRan.map((a) => row(a, null)).join(''))}` : '';
3656
+
3657
+ // Capping an agent that has not run is capping nothing. Reachable, not shown.
3658
+ const idleBlock = idle.length ? `
3659
+ <details class="budgets-idle" style="margin-top:28px">
3660
+ <summary>${idle.length} installed agents have not run in 30 days</summary>
3661
+ <div class="budgets-uncapped">${idle.slice(0, 80).map((a) => `
3662
+ <button type="button" class="ab-btn" onclick="addBudgetFor('${esc(a.slug)}')"
3663
+ title="${esc(firstSentence(a.description))}">${esc(a.slug)} +</button>`).join('')}
3664
+ ${idle.length > 80 ? `<span class="muted">and ${idle.length - 80} more</span>` : ''}
3665
+ </div>
3666
+ </details>` : '';
3667
+
3668
+ body.innerHTML = `${notes}${capsBlock}${activeBlock}${idleBlock}
3669
+ <button type="button" class="ab-btn ab-add" onclick="addAgentBudget()">+ Add a budget for any agent</button>`;
3670
+
3671
+ const badge = document.getElementById('nav-budget-count');
3672
+ if (badge) badge.textContent = Object.keys(caps).length ? String(Object.keys(caps).length) : '';
3673
+ }
3674
+
3675
+ /** Set a cap for a named agent, skipping the "which agent?" question. */
3676
+ function addBudgetFor(slug) {
3677
+ const usd = budgetPrompt(slug, null);
3678
+ if (usd == null) return;
3679
+ writeAgentBudget({ agent: slug, limit_usd: usd },
3680
+ `Set ${slug} to $${usd}?\n\n`
3681
+ + `Written to .great_cto/PROJECT.md. Measured spend past $${usd} stops the pipeline dispatching ${slug}.`);
3682
+ }
3683
+
3453
3684
  /* Writing a cap writes PROJECT.md — a file the operator owns and git tracks.
3454
3685
  *
3455
3686
  * The confirm states what the number DOES rather than asking whether you are
@@ -3467,6 +3698,7 @@ async function writeAgentBudget(body, consequence) {
3467
3698
  showToast(`Budget for <strong>${esc(r.agent)}</strong> ${r.removed ? 'removed' : 'saved'}`, 'success', 2500);
3468
3699
  refreshHeartbeat();
3469
3700
  if (typeof loadFleet === 'function') loadFleet();
3701
+ if (currentTab === 'budgets') loadBudgetsPage();
3470
3702
  }
3471
3703
 
3472
3704
  function budgetPrompt(agent, current) {
@@ -4223,6 +4455,54 @@ async function loadDocs() {
4223
4455
  * of them live in ~/.great_cto and are shared by every project, which is why
4224
4456
  * they never appeared in a per-project walk at all.
4225
4457
  */
4458
+ /* Search inside documents, not only their names.
4459
+ *
4460
+ * 187 documents whose contents cannot be searched are a directory listing, and
4461
+ * the question actually asked of them — "where did we write down the reason for
4462
+ * X" — was answerable only by leaving the board.
4463
+ *
4464
+ * `scanned` and `unreadable` come back with the answer and are shown: "nothing
4465
+ * found across 187 files" and "nothing found across the 3 that could be opened"
4466
+ * are different facts, and only one of them means the phrase is not there.
4467
+ */
4468
+ async function searchDocs() {
4469
+ const q = (document.getElementById('docs-q')?.value || '').trim();
4470
+ const out = document.getElementById('docs-results');
4471
+ const body = document.getElementById('docs-body');
4472
+ if (!out) return;
4473
+ if (q.length < 2) { clearDocsSearch(); return; }
4474
+
4475
+ out.style.display = 'block';
4476
+ out.innerHTML = '<div class="muted" style="padding:12px 0">searching…</div>';
4477
+ if (body) body.style.display = 'none';
4478
+
4479
+ const d = await api(`/api/docs/search?q=${encodeURIComponent(q)}${pqs().replace(/^\?/, '&')}`);
4480
+ if (!d || d.error) {
4481
+ out.innerHTML = `<div class="muted" style="padding:12px 0">search failed — ${esc(d?.error || 'no response')}</div>`;
4482
+ return;
4483
+ }
4484
+ const head = `<div class="docs-search-meta">${d.matched} document(s) contain “${esc(d.q)}” · `
4485
+ + `${d.scanned} searched${d.unreadable ? ` · ${d.unreadable} could not be opened` : ''} · `
4486
+ + `<button type="button" class="ab-btn" onclick="clearDocsSearch()">back to all docs</button></div>`;
4487
+ if (!d.matched) {
4488
+ out.innerHTML = head + '<div class="muted" style="padding:12px 0">No document contains that phrase.</div>';
4489
+ return;
4490
+ }
4491
+ out.innerHTML = head + d.results.map((r) => `
4492
+ <div class="docs-hit">
4493
+ <a href="#" onclick="event.preventDefault();openDoc('${esc(r.path)}')"><b>${esc(r.title)}</b></a>
4494
+ <span class="muted"> · ${esc(r.group)} · <code>${esc(r.path)}</code></span>
4495
+ ${r.hits.map((h) => `<div class="docs-hit-line"><span class="docs-hit-no">${h.line}</span>${esc(h.text)}</div>`).join('')}
4496
+ </div>`).join('');
4497
+ }
4498
+
4499
+ function clearDocsSearch() {
4500
+ const out = document.getElementById('docs-results');
4501
+ const body = document.getElementById('docs-body');
4502
+ if (out) { out.style.display = 'none'; out.innerHTML = ''; }
4503
+ if (body) body.style.display = '';
4504
+ }
4505
+
4226
4506
  async function loadContextLayers() {
4227
4507
  let m;
4228
4508
  try { m = await api(`/api/memory${pqs()}`); } catch { m = null; }
@@ -5094,6 +5374,15 @@ function clearFilters() {
5094
5374
  }
5095
5375
 
5096
5376
  /* ── Kanban ─────────────────────────────────────────────────────────────── */
5377
+ /** Columns the operator has asked to see in full, for this page load. */
5378
+ const expandedColumns = new Set();
5379
+ const COLUMN_CAP = 25;
5380
+
5381
+ function expandColumn(id) {
5382
+ expandedColumns.add(id);
5383
+ renderKanban(allTasks);
5384
+ }
5385
+
5097
5386
  function renderKanban(tasks) {
5098
5387
  renderFilterBar();
5099
5388
  const board = document.getElementById('kanban-board');
@@ -5109,6 +5398,11 @@ function renderKanban(tasks) {
5109
5398
  board.innerHTML = '';
5110
5399
  for (const col of COLUMNS) {
5111
5400
  const items = byCol[col.id];
5401
+ // Capped per column, not globally: a Backlog of 5 and a Done of 227 are
5402
+ // different problems and only one of them needs a lid.
5403
+ const cap = expandedColumns.has(col.id) ? Infinity : COLUMN_CAP;
5404
+ const shown = items.slice(0, cap);
5405
+ const hidden = items.length - shown.length;
5112
5406
  const dotClass = col.dot === 'filled' ? 'filled' : col.dot === 'half' ? 'half' : '';
5113
5407
  const el = document.createElement('div');
5114
5408
  el.className = `column col-${col.id}`;
@@ -5118,10 +5412,20 @@ function renderKanban(tasks) {
5118
5412
  <span class="col-title">${col.label}</span>
5119
5413
  <span class="col-count">${items.length}</span>
5120
5414
  </div>
5121
- <div class="col-body">${items.length ? items.map(cardHTML).join('') : '<div class="empty">No tasks</div>'}</div>`;
5415
+ <div class="col-body">${
5416
+ items.length
5417
+ ? shown.map(cardHTML).join('') + (hidden > 0
5418
+ // 227 done cards in one column is not a list anybody reads; it is
5419
+ // a scroll. The count in the header is the fact worth having, and
5420
+ // the rest is available by asking rather than by default.
5421
+ ? `<button type="button" class="col-more" onclick="expandColumn('${col.id}')">`
5422
+ + `Show ${hidden} more · ${items.length} total</button>`
5423
+ : '')
5424
+ : '<div class="empty">No tasks</div>'
5425
+ }</div>`;
5122
5426
  board.appendChild(el);
5123
5427
  el.querySelectorAll('.card').forEach((cardEl, i) => {
5124
- cardEl.addEventListener('click', () => openSide(items[i]));
5428
+ cardEl.addEventListener('click', () => openSide(shown[i]));
5125
5429
  });
5126
5430
  }
5127
5431
  }
@@ -5411,9 +5715,15 @@ function renderDashboard(m) {
5411
5715
  delta: deltaBadge(done, m.previous?.done, m.previous?.comparable),
5412
5716
  },
5413
5717
  {
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'),
5718
+ // Three states, and $0.00 is only one of them. A measured zero is worth
5719
+ // showing; a zero that is the TIME-BASED ESTIMATE with no plans or tasks
5720
+ // to estimate from is not a figure at all, and rendering it as $0.00 told
5721
+ // a project with dozens of agent runs that it had spent nothing.
5722
+ v: !costKnown
5723
+ ? absent('unloaded', 'the metrics payload carried no cost section')
5724
+ : (realLlmUsd === 0 && llmUsd === 0
5725
+ ? absent('uncomputable', 'no verdict carries a cost and there is nothing to estimate from')
5726
+ : `$${aiSpend < 10 ? aiSpend.toFixed(2) : fmtMoney(aiSpend)}`),
5417
5727
  sub: '',
5418
5728
  label: 'AI spend',
5419
5729
  delta: deltaBadge(aiSpend, m.previous?.llm_usd, m.previous?.comparable, { invert: true }),
@@ -6066,8 +6376,19 @@ function switchTab(id, el) {
6066
6376
  // crumb — `labels` used to be referenced here but is only ever a function-local in
6067
6377
  // this file, so this threw ReferenceError on every tab switch and skipped the per-tab
6068
6378
  // 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' };
6379
+ const TAB_LABELS = { budgets: 'Budgets', docs: 'Docs', dashboard: 'Board', inbox: 'Inbox', tasks: 'Tasks', agents: 'Agents', logs: 'Activity', notifications: 'Notifications', metrics: 'Metrics', share: 'Share' };
6070
6380
  const crumb = document.getElementById('crumb-here'); if (crumb) crumb.textContent = TAB_LABELS[id] || 'Board';
6381
+ // Budgets render on Metrics, so the heartbeat that feeds them has to be
6382
+ // fetched when Metrics opens — not only from the agents drill-in it used to
6383
+ // live in.
6384
+ if (id === 'dashboard') refreshHeartbeat();
6385
+ // The Notifications panel has carried an inline history list all along, and
6386
+ // `renderNotifHistoryInline()` to fill it — but the only caller was
6387
+ // `openNotifDrawer()`. Opening the tab itself never loaded it, so the sidebar
6388
+ // promised 50 unread and the screen showed settings. Declared and not
6389
+ // consumed, in the UI this time.
6390
+ if (id === 'notifications') loadNotifHistory();
6391
+ if (id === 'budgets') loadBudgetsPage();
6071
6392
  if (id === 'dashboard' || id === 'agents') {
6072
6393
  // pqsd(), not pqs(): this call omitted `days` entirely, so the server applied
6073
6394
  // its 30-day default and overwrote whatever window the period chips had
@@ -6255,11 +6576,32 @@ function renderFleetSummary() {
6255
6576
  trend: s.retire_candidates > 0 ? 'click to filter' : 'fleet is lean',
6256
6577
  onClick: 'setFleetFilter("activity", "never")',
6257
6578
  },
6258
- { v: '$' + (s.llm_usd_30d ?? 0).toFixed(2), label: 'LLM spend 30d', cls: '', trend: s.failing_7d ? `${s.failing_7d} failing` : '' },
6579
+ {
6580
+ // `?? 0` turned "nothing was measured" into "$0.00 spent", and before that
6581
+ // the number here was an ESTIMATE from verdict counts while the metrics
6582
+ // page estimated from tasks — two screens, one label, $3.90 against $1.65.
6583
+ v: s.llm_usd_30d != null
6584
+ ? '$' + s.llm_usd_30d.toFixed(2)
6585
+ : absent('uncomputable', `no verdict carries a cost — ${s.llm_usd_30d_agents_with_runs ?? 0} agent(s) ran in this window`),
6586
+ vHtml: s.llm_usd_30d == null,
6587
+ label: 'LLM spend 30d',
6588
+ cls: '',
6589
+ trend: s.llm_usd_30d != null
6590
+ ? `measured for ${s.llm_usd_30d_measured_for} agent(s)`
6591
+ : (s.failing_7d ? `${s.failing_7d} failing` : 'measured from verdicts'),
6592
+ },
6259
6593
  ];
6594
+ // `t.v` is HTML when it is an absence marker — a span carrying the reason on
6595
+ // hover and in the accessible name — and a plain string otherwise. Escaping it
6596
+ // unconditionally printed the markup as text on the fleet tile. `vHtml` is the
6597
+ // deliberate opt-in; every value without it is still escaped.
6598
+ //
6599
+ // This comment lives out here because it names `t.v` in backticks, and a
6600
+ // backtick inside the template literal below terminates it — which is how the
6601
+ // board shipped a syntax error for about ninety seconds.
6260
6602
  el.innerHTML = tiles.map(t => `
6261
6603
  <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>
6604
+ <div class="metric-num">${t.vHtml ? t.v : esc(String(t.v))}</div>
6263
6605
  <div class="metric-label">${esc(t.label)}</div>
6264
6606
  ${t.trend ? `<div class="metric-trend">${esc(t.trend)}</div>` : ''}
6265
6607
  </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.2.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",