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.
- package/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/alerts.mjs +11 -4
- package/board/packages/board/lib/beads.mjs +231 -7
- package/board/packages/board/lib/fleet.mjs +41 -2
- package/board/packages/board/lib/metrics.mjs +32 -3
- package/board/packages/board/lib/portfolio.mjs +58 -7
- package/board/packages/board/lib/routes.mjs +153 -9
- package/board/packages/board/lib/watchers.mjs +36 -2
- package/board/packages/board/public/index.html +884 -29
- package/board/packages/board/server.mjs +24 -0
- package/board/scripts/lib/agent-budget.mjs +224 -0
- package/board/scripts/lib/pipeline-health.mjs +127 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|