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.
@@ -18,6 +18,7 @@ import { log } from './log.mjs';
18
18
  import { bdCacheInvalidate, checkBeadsAvailable, bdWriteSerialised, bd, bdErr, getTasks, setTaskStatusInTasksMd, getReadDegradation } from './beads.mjs';
19
19
  import { getMetrics } from './metrics.mjs';
20
20
  import { readVerdicts } from './verdicts.mjs';
21
+ import { parseAgentBudgets, upsertAgentBudget, removeAgentBudget } from '../../../scripts/lib/agent-budget.mjs';
21
22
  import { getAgentsFleet, getAgentProfile, retireAgent, restoreAgent, appendDecisionLog, readDecisionsLog } from './fleet.mjs';
22
23
  import { getResume, getShareState, toggleShare } from './share.mjs';
23
24
  import { listSessions, readSession, editedFiles, searchSessions } from './transcripts.mjs';
@@ -363,7 +364,18 @@ async function dispatch(req, res, url, cwd) {
363
364
  const degraded = getRegistryDegradation();
364
365
  // A registry that could not be read used to render as a board with no
365
366
  // projects — the same silence this screen exists to remove, one level out.
366
- const out = portfolio(degraded ? { unread: degraded } : reg);
367
+ // The pipeline map, so the fleet can say which projects are waiting on a
368
+ // decision rather than only which ones failed. Read once for the sweep;
369
+ // unreadable means the gate question is simply not answered, never
370
+ // answered as "nothing is waiting".
371
+ let transitions = null;
372
+ try {
373
+ const { pipelineMapFor } = await import('../../../scripts/lib/pipeline-health.mjs');
374
+ const { parsePipelineToml } = await import('../../../scripts/hooks/pipeline-dispatcher.mjs');
375
+ const map = pipelineMapFor(cwd);
376
+ if (map.path) transitions = parsePipelineToml(fs.readFileSync(map.path, 'utf8'));
377
+ } catch { transitions = null; }
378
+ const out = portfolio(degraded ? { unread: degraded } : reg, { transitions });
367
379
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
368
380
  res.end(JSON.stringify(out));
369
381
  } catch (e) {
@@ -1343,16 +1355,20 @@ async function dispatch(req, res, url, cwd) {
1343
1355
  // Per-agent budgets from PROJECT.md
1344
1356
  const projectMdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
1345
1357
  let budgets = {};
1358
+ let budgetsDeprecatedKey = null;
1359
+ const budgetsMalformed = [];
1346
1360
  let goalAncestry = null;
1347
1361
  try {
1348
1362
  const projectTxt = fs.readFileSync(projectMdPath, 'utf8');
1349
- const sectionMatch = projectTxt.match(/^agent-budget:\s*\n((?:[ \t]+\S[^\n]*\n?)*)/m);
1350
- if (sectionMatch) {
1351
- for (const line of sectionMatch[1].split('\n')) {
1352
- const m = line.match(/^\s+([a-z][a-z0-9-]*):\s*(\d+(?:\.\d+)?)/);
1353
- if (m) budgets[m[1]] = parseFloat(m[2]);
1354
- }
1355
- }
1363
+ // One parser, in scripts/lib/agent-budget.mjs — the same one the pipeline
1364
+ // dispatcher enforces with and the fleet view judges with. This was an
1365
+ // inline regex reading a different key (`agent-budget:`) with a different
1366
+ // meaning ("$X/run"), so the board could display a cap the dispatcher had
1367
+ // never heard of, and vice versa.
1368
+ const parsed = parseAgentBudgets(projectTxt);
1369
+ for (const [agent, cap] of parsed.budgets) budgets[agent] = cap;
1370
+ if (parsed.deprecatedKey) budgetsDeprecatedKey = parsed.deprecatedKey;
1371
+ for (const bad of parsed.malformed) budgetsMalformed.push(bad);
1356
1372
  // Goal ancestry
1357
1373
  const archetype = (projectTxt.match(/^(?:archetype|primary):\s*(\S+)/m) || [])[1] || null;
1358
1374
  const compliance = (projectTxt.match(/^compliance:\s*(.+)$/m) || [])[1] || null;
@@ -1370,7 +1386,135 @@ async function dispatch(req, res, url, cwd) {
1370
1386
  } catch { /* no log */ }
1371
1387
 
1372
1388
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1373
- res.end(JSON.stringify({ stuck, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h }));
1389
+ // `budgets_deprecated_key` and `budgets_malformed` travel with the caps.
1390
+ // A limit somebody wrote under the old key still works and they are told; a
1391
+ // line the parser could not read is reported rather than dropped, because a
1392
+ // budget silently ignored is a limit its author believes they have.
1393
+ res.end(JSON.stringify({
1394
+ stuck, budgets, goal_ancestry: goalAncestry, tool_failure_rate_1h: toolFailureRate1h,
1395
+ budgets_deprecated_key: budgetsDeprecatedKey,
1396
+ budgets_malformed: budgetsMalformed,
1397
+ }));
1398
+ return true;
1399
+ }
1400
+
1401
+ // Set or clear one agent's spending cap, by writing PROJECT.md.
1402
+ //
1403
+ // POST /api/agent-budgets { agent, limit_usd } set / replace
1404
+ // POST /api/agent-budgets { agent, remove: true } clear
1405
+ //
1406
+ // This writes a file the operator owns and git tracks, from a browser. Same
1407
+ // two gates as /api/projects/register, for the same reason: the board listens
1408
+ // on 127.0.0.1, and a page the user happens to be visiting can still issue a
1409
+ // simple cross-origin POST to localhost.
1410
+ //
1411
+ // The reply always reports the PREVIOUS value. A cap that silently replaced
1412
+ // another is a change the operator cannot see they made.
1413
+ if (pathname === '/api/agent-budgets' && req.method === 'POST') {
1414
+ if (!originAllowed(req)) {
1415
+ res.writeHead(403, { 'Content-Type': 'application/json' });
1416
+ res.end(JSON.stringify({ error: 'origin not allowed' }));
1417
+ return true;
1418
+ }
1419
+ let body = '';
1420
+ req.on('data', (c) => { body += c; if (body.length > 4096) req.destroy(); });
1421
+ req.on('end', () => {
1422
+ let parsed;
1423
+ try { parsed = JSON.parse(body || '{}'); }
1424
+ catch (e) {
1425
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1426
+ res.end(JSON.stringify({ error: 'invalid_json', message: String(e.message || e) }));
1427
+ return;
1428
+ }
1429
+ const mdPath = path.join(cwd, '.great_cto', 'PROJECT.md');
1430
+ let before;
1431
+ try { before = fs.readFileSync(mdPath, 'utf8'); }
1432
+ catch (e) {
1433
+ // No PROJECT.md is not a project we may create one for from a browser.
1434
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1435
+ res.end(JSON.stringify({ error: 'no PROJECT.md to write to', detail: String(e.message || e) }));
1436
+ return;
1437
+ }
1438
+ let out;
1439
+ try {
1440
+ out = parsed.remove
1441
+ ? removeAgentBudget(before, parsed.agent)
1442
+ : upsertAgentBudget(before, parsed.agent, parsed.limit_usd);
1443
+ } catch (e) {
1444
+ res.writeHead(400, { 'Content-Type': 'application/json' });
1445
+ res.end(JSON.stringify({ error: String(e.message || e) }));
1446
+ return;
1447
+ }
1448
+ try {
1449
+ // Write through a temp file in the same directory, then rename: a
1450
+ // half-written PROJECT.md is the project's own identity truncated.
1451
+ const tmp = `${mdPath}.tmp-${process.pid}`;
1452
+ fs.writeFileSync(tmp, out.text);
1453
+ fs.renameSync(tmp, mdPath);
1454
+ } catch (e) {
1455
+ res.writeHead(500, { 'Content-Type': 'application/json' });
1456
+ res.end(JSON.stringify({ error: `could not write PROJECT.md: ${String(e.message || e)}` }));
1457
+ return;
1458
+ }
1459
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1460
+ res.end(JSON.stringify({
1461
+ ok: true,
1462
+ agent: String(parsed.agent || '').toLowerCase(),
1463
+ previous_usd: out.previousUsd,
1464
+ removed: out.removed === true,
1465
+ created_block: out.created === true,
1466
+ }));
1467
+ });
1468
+ return true;
1469
+ }
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
+ }
1374
1518
  return true;
1375
1519
  }
1376
1520
 
@@ -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 } 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 ───────────────────────────────────────────────────────────────
@@ -17,7 +17,41 @@ function watchBeads() {
17
17
  if (!dirs.includes(process.cwd())) dirs.push(process.cwd());
18
18
 
19
19
  const broadcast = (dir) => {
20
- bdCacheInvalidate(dir);
20
+ // Invalidate ONLY for a project somebody is looking at.
21
+ //
22
+ // This ran unconditionally, for all sixteen registered projects, on every
23
+ // file event in any of them — and an invalidated entry is deleted, so no
24
+ // later read can be served from cache no matter how stale it is willing to
25
+ // accept. The alert crons then re-ran `bd list` for all sixteen every five
26
+ // minutes, at 2-6 s each, synchronously. That is the pair that made the
27
+ // board unanswerable: watchers emptying the cache as fast as sweeps filled
28
+ // it, with the event loop held by `spawnSync` throughout.
29
+ //
30
+ // A project nobody is watching serves data up to BD_CACHE_TTL_MS old, which
31
+ // is the freshness contract everywhere else, and the read that refreshes it
32
+ // happens when someone actually opens it.
33
+ const watched = [...sseClients].some((r) => r._gctoCwd === dir);
34
+ if (!watched) return;
35
+ // Do not react at all to the echo of our own read. `bd list` touches the dolt
36
+ // journal this watcher watches, so every read was dropping the entry it had
37
+ // just filled — a loop that made the cache useless at any TTL.
38
+ //
39
+ // Return, rather than merely skipping the invalidation. I tried the narrower
40
+ // version — suppress the cache drop, still broadcast — reasoning that a
41
+ // watcher event is still an event. It is not: the broadcast reads
42
+ // getTasks(dir), and on a self-touch the entry was deliberately NOT
43
+ // invalidated, so the event carries the cache's older answer. Clients were
44
+ // pushed stale tasks, and `gate: SSE broadcasts updated tasks after
45
+ // approval` failed on exactly that.
46
+ //
47
+ // Nothing is lost by staying silent here. A write through the board calls
48
+ // broadcastTasks itself; a write from outside is not a self-touch and lands
49
+ // in the branch below.
50
+ if (isSelfInflictedTouch(dir)) return;
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);
21
55
  for (const res of sseClients) {
22
56
  if (res._gctoCwd === dir) {
23
57
  try {