great-cto 2.95.0 → 2.97.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 +5 -1
- package/board/packages/board/lib/beads.mjs +53 -5
- package/board/packages/board/lib/data-readers.mjs +53 -8
- package/board/packages/board/lib/docs.mjs +176 -0
- package/board/packages/board/lib/fleet.mjs +13 -2
- package/board/packages/board/lib/portfolio.mjs +128 -0
- package/board/packages/board/lib/projects.mjs +31 -0
- package/board/packages/board/lib/routes.mjs +253 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +568 -34
- package/board/scripts/lib/freshness.mjs +175 -0
- package/board/scripts/lib/gate-tier.mjs +279 -0
- package/board/scripts/lib/pipeline-wake.mjs +120 -0
- package/board/scripts/lib/receipt.mjs +386 -0
- package/board/scripts/lib/stand-down.mjs +147 -0
- package/board/scripts/lib/system-map.mjs +206 -0
- package/board/scripts/lib/verdict-record.mjs +18 -1
- package/dist/detect.js +1 -0
- package/dist/main.js +40 -2
- package/package.json +1 -1
|
@@ -9,7 +9,8 @@ import {
|
|
|
9
9
|
import { GREAT_CTO_DIR, VAPID_KEYS_FILE, PUSH_SUBS_FILE, BUILD_VERSION } from './config.mjs';
|
|
10
10
|
import { eventSurface, readFileSafe, originAllowed } from './util.mjs';
|
|
11
11
|
import { sseClients, notifHistory } from './state.mjs';
|
|
12
|
-
import { autoRegisterProject, listProjects, resolveProjectCwd, getChangeTier } from './projects.mjs';
|
|
12
|
+
import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInfo, getChangeTier, readProjectsRegistry, getRegistryDegradation } from './projects.mjs';
|
|
13
|
+
import { readVerdictsWithHealth } from './verdicts.mjs';
|
|
13
14
|
import { broadcastTasks } from './sse.mjs';
|
|
14
15
|
import { saveNotifHistory } from './notifications.mjs';
|
|
15
16
|
import { getMemory, getPipeline, getCostHistory, getInbox } from './data-readers.mjs';
|
|
@@ -25,9 +26,51 @@ import { listSessions, readSession, editedFiles, searchSessions } from './transc
|
|
|
25
26
|
// dispatch(req, res, url, cwd, projInfo) handles every /api/* route plus /api/sse.
|
|
26
27
|
// Returns true if the request was handled (response already sent or streaming),
|
|
27
28
|
// false if the caller (server.mjs) should fall through to static file serving.
|
|
29
|
+
/**
|
|
30
|
+
* Headers for a response whose data came from the verdict reader.
|
|
31
|
+
*
|
|
32
|
+
* Metrics, cost, the pipeline strip and the inbox are all built from
|
|
33
|
+
* readVerdicts, which returned [] on every kind of failure — so an unreadable
|
|
34
|
+
* verdict directory arrived at four panels as "this project has not run
|
|
35
|
+
* anything". That is a different claim from "I could not look", and a confident
|
|
36
|
+
* one.
|
|
37
|
+
*/
|
|
38
|
+
function verdictHeaders(cwd, base = { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }) {
|
|
39
|
+
try {
|
|
40
|
+
const { unread } = readVerdictsWithHealth(cwd);
|
|
41
|
+
if (unread) return { ...base, 'X-Board-Degraded': encodeURIComponent(unread) };
|
|
42
|
+
} catch { /* never fail a response over its own health check */ }
|
|
43
|
+
return base;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
async function dispatch(req, res, url, cwd) {
|
|
29
47
|
const pathname = url.pathname;
|
|
30
48
|
|
|
49
|
+
// The selected project, resolved ONCE for every route below.
|
|
50
|
+
//
|
|
51
|
+
// The board is multi-project — a registry of twenty-two, a switcher in the
|
|
52
|
+
// sidebar — and sixteen of its eighteen endpoints read `cwd`, the directory
|
|
53
|
+
// the SERVER was started in. Switching to another project changed the heading
|
|
54
|
+
// and nothing else: a project with a .great_cto holding two verdicts and
|
|
55
|
+
// thirty-nine session logs had every panel show 0.
|
|
56
|
+
//
|
|
57
|
+
// Which is the failure this board keeps making in other forms: a read that did
|
|
58
|
+
// not happen looked exactly like an absence of data. Resolving here rather than
|
|
59
|
+
// per-route means the next endpoint added cannot forget.
|
|
60
|
+
//
|
|
61
|
+
// A slug that does not resolve falls back to the server's directory — that is
|
|
62
|
+
// pre-existing behaviour and it is defensible, but it must not be silent, so
|
|
63
|
+
// it is announced in a header rather than shown as if it were the project asked
|
|
64
|
+
// for.
|
|
65
|
+
const requestedProject = url.searchParams.get('project');
|
|
66
|
+
if (requestedProject) {
|
|
67
|
+
const info = resolveProjectInfo(requestedProject);
|
|
68
|
+
cwd = info.cwd;
|
|
69
|
+
if (info.resolved === 'fallback' && typeof res.setHeader === 'function') {
|
|
70
|
+
try { res.setHeader('X-Project-Fallback', String(info.requested || requestedProject)); } catch { /* headers already sent */ }
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
31
74
|
// SSE
|
|
32
75
|
if (pathname === '/api/sse') {
|
|
33
76
|
res.writeHead(200, {
|
|
@@ -120,7 +163,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
120
163
|
let days = parseInt(url.searchParams.get('days') || '30', 10);
|
|
121
164
|
if (!Number.isFinite(days) || days < 1) days = 30;
|
|
122
165
|
if (days > 365) days = 365;
|
|
123
|
-
res.writeHead(200,
|
|
166
|
+
res.writeHead(200, verdictHeaders(cwd));
|
|
124
167
|
res.end(JSON.stringify(getMetrics(cwd, days)));
|
|
125
168
|
return true;
|
|
126
169
|
}
|
|
@@ -297,6 +340,61 @@ async function dispatch(req, res, url, cwd) {
|
|
|
297
340
|
|
|
298
341
|
// ── Read a project doc (markdown) referenced from a task — for the side-panel viewer.
|
|
299
342
|
// Path-traversal-safe: the resolved path must stay inside the project cwd; .md only.
|
|
343
|
+
// Which documents exist, and the two diagrams nobody hand-draws.
|
|
344
|
+
//
|
|
345
|
+
// /api/doc could already fetch one document by path; the question before that
|
|
346
|
+
// — which documents exist — had no answer, so a project's twenty architecture
|
|
347
|
+
// documents and ten ADRs were reachable only by knowing a filename.
|
|
348
|
+
//
|
|
349
|
+
// The maps are computed per request rather than stored. docs/ARCHITECTURE.md
|
|
350
|
+
// is a hand-drawn diagram, three months old, that says "34 agents" where there
|
|
351
|
+
// are sixty-nine; a picture that is regenerated when looked at cannot drift.
|
|
352
|
+
// The whole fleet on one screen.
|
|
353
|
+
//
|
|
354
|
+
// Every other endpoint answers about one project, so "what needs me right now
|
|
355
|
+
// across all twenty-two" had no answer and the switcher was a poor substitute
|
|
356
|
+
// for one. Read-only, file-only: gate approval is authoritative but costs about
|
|
357
|
+
// 530ms per project, which is twelve seconds on a screen meant to be glanced
|
|
358
|
+
// at. This reads 23 projects in about 140ms.
|
|
359
|
+
if (pathname === '/api/portfolio' && req.method === 'GET') {
|
|
360
|
+
try {
|
|
361
|
+
const { portfolio } = await import('./portfolio.mjs');
|
|
362
|
+
const reg = readProjectsRegistry();
|
|
363
|
+
const degraded = getRegistryDegradation();
|
|
364
|
+
// A registry that could not be read used to render as a board with no
|
|
365
|
+
// projects — the same silence this screen exists to remove, one level out.
|
|
366
|
+
const out = portfolio(degraded ? { unread: degraded } : reg);
|
|
367
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
368
|
+
res.end(JSON.stringify(out));
|
|
369
|
+
} catch (e) {
|
|
370
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
371
|
+
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
372
|
+
}
|
|
373
|
+
return true;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (pathname === '/api/docs' && req.method === 'GET') {
|
|
377
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
378
|
+
try {
|
|
379
|
+
const [{ listDocs }, { systemMap, toMermaid, pipelineMermaid }] = await Promise.all([
|
|
380
|
+
import('./docs.mjs'), import('../../../scripts/lib/system-map.mjs'),
|
|
381
|
+
]);
|
|
382
|
+
const map = systemMap(c);
|
|
383
|
+
let pipeline = null;
|
|
384
|
+
try { pipeline = pipelineMermaid(fs.readFileSync(path.join(c, 'shared', 'pipeline.toml'), 'utf8')); } catch { /* not a great_cto project */ }
|
|
385
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
386
|
+
res.end(JSON.stringify({
|
|
387
|
+
...listDocs(c),
|
|
388
|
+
map: { nodes: map.nodes, edges: map.edges, generatedAt: map.generatedAt, mermaid: toMermaid(map) },
|
|
389
|
+
pipeline,
|
|
390
|
+
}));
|
|
391
|
+
} catch (e) {
|
|
392
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
393
|
+
res.end(JSON.stringify({ error: String(e.message || e) }));
|
|
394
|
+
}
|
|
395
|
+
return true;
|
|
396
|
+
}
|
|
397
|
+
|
|
300
398
|
if (pathname === '/api/doc' && req.method === 'GET') {
|
|
301
399
|
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
302
400
|
const rel = String(url.searchParams.get('path') || '');
|
|
@@ -352,6 +450,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
352
450
|
res.end(JSON.stringify(beadsErr));
|
|
353
451
|
return;
|
|
354
452
|
}
|
|
453
|
+
// Named outside the serialised block so the wake below can say WHICH gate a
|
|
454
|
+
// human approved; the lookup itself belongs inside the lock.
|
|
455
|
+
let gateTitle = id;
|
|
456
|
+
|
|
355
457
|
// BH-16 fix: serialise gate writes through bd-write queue.
|
|
356
458
|
// Without this, concurrent approve+reject on the same gate produced
|
|
357
459
|
// TWO appendDecisionLog entries (one wrong) — log says approved AND
|
|
@@ -379,6 +481,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
379
481
|
const allTasks = getTasks(gateCwd);
|
|
380
482
|
const gateTask = allTasks.find(t => t.id === id);
|
|
381
483
|
const title = gateTask?.title || id;
|
|
484
|
+
gateTitle = title;
|
|
382
485
|
appendDecisionLog({
|
|
383
486
|
ts: new Date().toISOString(),
|
|
384
487
|
project: projectSlug,
|
|
@@ -396,8 +499,37 @@ async function dispatch(req, res, url, cwd) {
|
|
|
396
499
|
res.end(JSON.stringify({ error: (result && result.error) || 'bd update failed' }));
|
|
397
500
|
return;
|
|
398
501
|
}
|
|
502
|
+
// An approval is evidence that the pipeline is waiting — record it.
|
|
503
|
+
//
|
|
504
|
+
// `session-pipeline-resume` opens with a freshness shortcut: a pipeline
|
|
505
|
+
// whose newest verdict is over a day old is treated as history and the
|
|
506
|
+
// hook returns before reading a single gate. Approve `gate:arch` on a
|
|
507
|
+
// stage that ran three days ago and the one fact that proves work is
|
|
508
|
+
// waiting is the one thing never consulted.
|
|
509
|
+
//
|
|
510
|
+
// This records the approval so the next session looks properly. It
|
|
511
|
+
// decides nothing: tickDecision still applies every refusal it has,
|
|
512
|
+
// including never dispatching devops or infra-provisioner unattended
|
|
513
|
+
// (ADR-009). Best-effort — a wake we cannot write must not fail an
|
|
514
|
+
// approval that already happened.
|
|
515
|
+
let wake = null;
|
|
516
|
+
if (action === 'approve') {
|
|
517
|
+
try {
|
|
518
|
+
const { recordWake } = await import('../../../scripts/lib/pipeline-wake.mjs');
|
|
519
|
+
const r = recordWake(gateCwd, { gate: gateTitle, id });
|
|
520
|
+
wake = r.ok ? { recorded: true } : { recorded: false, why: r.why };
|
|
521
|
+
} catch (e) {
|
|
522
|
+
// Report the actual failure. The first version of this catch guessed
|
|
523
|
+
// ("unavailable in this board build") and printed the guess as a
|
|
524
|
+
// finding — it was a ReferenceError three lines up, and the guess sent
|
|
525
|
+
// the reader looking at packaging. A handler that names a cause it did
|
|
526
|
+
// not observe is worse than one that says nothing.
|
|
527
|
+
wake = { recorded: false, why: String(e?.message || e) };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
399
531
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
400
|
-
res.end(JSON.stringify({ ok: true, id, action, via: result.via }));
|
|
532
|
+
res.end(JSON.stringify({ ok: true, id, action, via: result.via, ...(wake ? { wake } : {}) }));
|
|
401
533
|
broadcastTasks(gateCwd);
|
|
402
534
|
// Auto-republish share report when a gate is approved (fire-and-forget)
|
|
403
535
|
if (action === 'approve') {
|
|
@@ -414,14 +546,121 @@ async function dispatch(req, res, url, cwd) {
|
|
|
414
546
|
|
|
415
547
|
// Inbox — what needs your attention right now
|
|
416
548
|
if (pathname === '/api/inbox') {
|
|
417
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
549
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
418
550
|
res.end(JSON.stringify(getInbox(cwd)));
|
|
419
551
|
return true;
|
|
420
552
|
}
|
|
421
553
|
|
|
554
|
+
// Receipt — does the approval still apply to what is in the tree?
|
|
555
|
+
//
|
|
556
|
+
// The reviewing agents already record a hash-bound receipt of the bytes they
|
|
557
|
+
// read, and those verdicts are in the log this board reads. Nothing surfaced
|
|
558
|
+
// them, so the top rung of the evidence ladder — the reviewed bytes are the
|
|
559
|
+
// shipped bytes — was being written and never looked at. An approval that no
|
|
560
|
+
// longer describes the tree is the one thing a gate button cannot tell you.
|
|
561
|
+
if (pathname === '/api/receipt') {
|
|
562
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
563
|
+
let out;
|
|
564
|
+
try {
|
|
565
|
+
const { latestApproval, treeReceipt, compareReceipts, mergeBase } = await import('../../../scripts/lib/receipt.mjs');
|
|
566
|
+
const approval = latestApproval(c);
|
|
567
|
+
if (!approval?.receipt) {
|
|
568
|
+
out = { state: 'no-receipt', why: 'no approving verdict carries a receipt yet', agent: approval?.agent || null, ts: approval?.ts || null };
|
|
569
|
+
} else {
|
|
570
|
+
const current = treeReceipt(c, { base: approval.receipt.base || mergeBase(c) });
|
|
571
|
+
const cmp = compareReceipts(approval.receipt, current, { cwd: c });
|
|
572
|
+
out = { ...cmp, agent: approval.agent, ts: approval.ts };
|
|
573
|
+
}
|
|
574
|
+
} catch (e) {
|
|
575
|
+
// Never "matches" on an error. Not knowing whether the approval still
|
|
576
|
+
// holds is its own state, and calling it a match would put the strongest
|
|
577
|
+
// reassurance exactly where the least is known.
|
|
578
|
+
out = { state: 'unreadable', why: String(e?.message || e), agent: null, ts: null };
|
|
579
|
+
}
|
|
580
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
581
|
+
res.end(JSON.stringify(out));
|
|
582
|
+
return true;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Gate tiers — will this gate wait for you, or announce and proceed?
|
|
586
|
+
//
|
|
587
|
+
// A gate card offered Approve/Reject regardless, which is the right control
|
|
588
|
+
// for a `gated` gate and the wrong one for a tiered gate that already went
|
|
589
|
+
// ahead. Two states rendered where the machinery has three.
|
|
590
|
+
//
|
|
591
|
+
// `enabled` is returned alongside, because a tier is only in force when the
|
|
592
|
+
// project opted in with `gate-tiering: evidence`. Showing "notify" on a
|
|
593
|
+
// project that never opted in would describe a stand-down that will not
|
|
594
|
+
// happen — the mirror image of the defect this whole feature guards.
|
|
595
|
+
if (pathname === '/api/gate-tiers') {
|
|
596
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
597
|
+
try {
|
|
598
|
+
const { tierAll, tieringEnabled } = await import('../../../scripts/lib/gate-tier.mjs');
|
|
599
|
+
const { fileURLToPath: f2u } = await import('node:url');
|
|
600
|
+
const libDir = path.dirname(f2u(import.meta.url));
|
|
601
|
+
const histPath = path.join(libDir, '..', '..', '..', 'tests', 'eval', 'results-history.jsonl');
|
|
602
|
+
|
|
603
|
+
let enabled = false;
|
|
604
|
+
try { enabled = tieringEnabled(fs.readFileSync(path.join(c, '.great_cto', 'PROJECT.md'), 'utf8')); }
|
|
605
|
+
catch { /* no PROJECT.md — tiering is off, which is the safe reading */ }
|
|
606
|
+
|
|
607
|
+
const rows = [];
|
|
608
|
+
let historyRead = false;
|
|
609
|
+
try {
|
|
610
|
+
for (const line of fs.readFileSync(histPath, 'utf8').split('\n')) {
|
|
611
|
+
if (!line.trim()) continue;
|
|
612
|
+
try { rows.push(JSON.parse(line)); } catch { /* a bad row is not evidence */ }
|
|
613
|
+
}
|
|
614
|
+
historyRead = true;
|
|
615
|
+
} catch { /* reported as unmeasured below, never as gated-because-fine */ }
|
|
616
|
+
|
|
617
|
+
const byAgent = {};
|
|
618
|
+
for (const t of tierAll(rows)) byAgent[t.agent] = { tier: t.tier, why: t.why, evals: t.evals };
|
|
619
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
620
|
+
res.end(JSON.stringify({ enabled, measured: historyRead, agents: byAgent }));
|
|
621
|
+
} catch (e) {
|
|
622
|
+
// The real error. An agent missing from `agents` reads as unmeasured on the
|
|
623
|
+
// client, which keeps its gate — the correct direction to be wrong in.
|
|
624
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
625
|
+
res.end(JSON.stringify({ enabled: false, measured: false, agents: {}, why: String(e?.message || e) }));
|
|
626
|
+
}
|
|
627
|
+
return true;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Stand-downs — gates that proceeded WITHOUT being asked, and on what evidence.
|
|
631
|
+
//
|
|
632
|
+
// ADR-009: a gate is not the only valid answer, but silence is never one. An
|
|
633
|
+
// evidence-tiered gate announces instead of waiting, and until now that
|
|
634
|
+
// announcement had nowhere on this board to land — the record was being
|
|
635
|
+
// written and nothing read it.
|
|
636
|
+
if (pathname === '/api/stand-downs') {
|
|
637
|
+
const c = url.searchParams.get('project') ? resolveProjectCwd(url.searchParams.get('project')) : cwd;
|
|
638
|
+
let rows = null;
|
|
639
|
+
let why = '';
|
|
640
|
+
try {
|
|
641
|
+
const { readStandDowns } = await import('../../../scripts/lib/stand-down.mjs');
|
|
642
|
+
rows = readStandDowns(c, { limit: 50 });
|
|
643
|
+
} catch (e) {
|
|
644
|
+
// The real error. A handler that names a cause it did not observe sends
|
|
645
|
+
// the reader looking in the wrong place — this file has done that once.
|
|
646
|
+
why = String(e?.message || e);
|
|
647
|
+
}
|
|
648
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
649
|
+
// Three states, because two cannot carry them: `readStandDowns` returns null
|
|
650
|
+
// when the ledger exists and could not be read, [] when nothing has stood
|
|
651
|
+
// down, and rows when something has. "I could not look" must never render as
|
|
652
|
+
// "nothing happened".
|
|
653
|
+
res.end(JSON.stringify(
|
|
654
|
+
rows === null
|
|
655
|
+
? { state: 'unreadable', why: why || 'the stand-down ledger exists but could not be read', rows: [] }
|
|
656
|
+
: { state: rows.length ? 'some' : 'none', why: '', rows },
|
|
657
|
+
));
|
|
658
|
+
return true;
|
|
659
|
+
}
|
|
660
|
+
|
|
422
661
|
// Resume — pick up where you left off (last verdicts + WIP + recent decisions)
|
|
423
662
|
if (pathname === '/api/resume') {
|
|
424
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
663
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
425
664
|
res.end(JSON.stringify(getResume(cwd)));
|
|
426
665
|
return true;
|
|
427
666
|
}
|
|
@@ -435,28 +674,28 @@ async function dispatch(req, res, url, cwd) {
|
|
|
435
674
|
const limit = Number.isFinite(parsed) && parsed > 0
|
|
436
675
|
? Math.min(parsed, 200)
|
|
437
676
|
: 20;
|
|
438
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
677
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
439
678
|
res.end(JSON.stringify(readDecisionsLog(limit, cwd)));
|
|
440
679
|
return true;
|
|
441
680
|
}
|
|
442
681
|
|
|
443
682
|
// Memory — 4-layer memory file contents
|
|
444
683
|
if (pathname === '/api/memory') {
|
|
445
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
684
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
446
685
|
res.end(JSON.stringify(getMemory(cwd)));
|
|
447
686
|
return true;
|
|
448
687
|
}
|
|
449
688
|
|
|
450
689
|
// Pipeline — current stage states (idle / active / done / failed)
|
|
451
690
|
if (pathname === '/api/pipeline') {
|
|
452
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
691
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
453
692
|
res.end(JSON.stringify(getPipeline(cwd)));
|
|
454
693
|
return true;
|
|
455
694
|
}
|
|
456
695
|
|
|
457
696
|
// change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
|
|
458
697
|
if (pathname === '/api/change-tier') {
|
|
459
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
698
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
460
699
|
res.end(JSON.stringify(getChangeTier(cwd)));
|
|
461
700
|
return true;
|
|
462
701
|
}
|
|
@@ -475,7 +714,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
475
714
|
const days = Number.isFinite(parsed) && parsed > 0
|
|
476
715
|
? Math.min(parsed, 365)
|
|
477
716
|
: 30;
|
|
478
|
-
res.writeHead(200,
|
|
717
|
+
res.writeHead(200, verdictHeaders(cwd));
|
|
479
718
|
res.end(JSON.stringify(getCostHistory(cwd, days)));
|
|
480
719
|
return true;
|
|
481
720
|
}
|
|
@@ -923,7 +1162,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
923
1162
|
return true;
|
|
924
1163
|
}
|
|
925
1164
|
if (!sub) {
|
|
926
|
-
|
|
1165
|
+
// Scoped to the selected project — see getAgentProfile. Unscoped, this
|
|
1166
|
+
// reported an agent's record across all twenty-two projects while the
|
|
1167
|
+
// reader was looking at one of them.
|
|
1168
|
+
const profile = getAgentProfile(slug, cwd);
|
|
927
1169
|
if (!profile) {
|
|
928
1170
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
929
1171
|
res.end(JSON.stringify({ error: 'agent_not_found', slug }));
|
|
@@ -4,7 +4,38 @@ import { GREAT_CTO_DIR } from './config.mjs';
|
|
|
4
4
|
import { parseVerdictLine } from '../../../scripts/lib/verdict-record.mjs';
|
|
5
5
|
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Verdicts, plus an account of what could not be read.
|
|
9
|
+
*
|
|
10
|
+
* This function feeds metrics, cost, the pipeline strip, the inbox, resume and
|
|
11
|
+
* agent statistics — six surfaces from one read. It returned `[]` on every kind
|
|
12
|
+
* of failure, so an unreadable verdict directory arrived at all six as "this
|
|
13
|
+
* project has not run anything", which is a different claim and a confident one.
|
|
14
|
+
*
|
|
15
|
+
* Three things can go wrong and each is now named rather than absorbed: a
|
|
16
|
+
* directory that cannot be listed, a file that cannot be read, and a line that
|
|
17
|
+
* does not parse. The last is not a failure of this reader — a half-written
|
|
18
|
+
* append is normal — so it is counted and only reported when it is the reason a
|
|
19
|
+
* project looks empty.
|
|
20
|
+
*/
|
|
21
|
+
function readVerdictsWithHealth(cwd = null) {
|
|
22
|
+
const problems = [];
|
|
23
|
+
let unreadableLines = 0;
|
|
24
|
+
const verdicts = readVerdicts(cwd, { problems, onBadLine: () => { unreadableLines += 1; } });
|
|
25
|
+
return {
|
|
26
|
+
verdicts,
|
|
27
|
+
unreadableLines,
|
|
28
|
+
// Only a real read failure degrades. Unparseable lines beside readable ones
|
|
29
|
+
// are noise; unparseable lines and NOTHING else is the project looking empty
|
|
30
|
+
// for a reason worth saying out loud.
|
|
31
|
+
unread: problems.length
|
|
32
|
+
? problems.join('; ')
|
|
33
|
+
: (unreadableLines && !verdicts.length
|
|
34
|
+
? `${unreadableLines} verdict line(s) could not be parsed and none could` : null),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readVerdicts(cwd = null, health = null) {
|
|
8
39
|
// Verdict attribution model:
|
|
9
40
|
// 1. cwd given → read project-local <cwd>/.great_cto/verdicts/
|
|
10
41
|
// PLUS any global verdict line tagged `project=<slug>` matching cwd
|
|
@@ -21,9 +52,18 @@ function readVerdicts(cwd = null) {
|
|
|
21
52
|
}
|
|
22
53
|
// First read project-local verdicts when scoped
|
|
23
54
|
const projectVerdictDir = cwd ? path.join(cwd, '.great_cto', 'verdicts') : null;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
55
|
+
// This listing happens before the main loop, to decide whether a project has
|
|
56
|
+
// any local verdicts at all — and it threw where the loop's own read is
|
|
57
|
+
// guarded, so an unreadable directory crashed the caller instead of being
|
|
58
|
+
// reported. Same failure, one line earlier.
|
|
59
|
+
let useProjectDir = false;
|
|
60
|
+
if (projectVerdictDir && fs.existsSync(projectVerdictDir)) {
|
|
61
|
+
try {
|
|
62
|
+
useProjectDir = fs.readdirSync(projectVerdictDir).filter(f => f.endsWith('.log')).length > 0;
|
|
63
|
+
} catch (e) {
|
|
64
|
+
health?.problems?.push(`verdicts could not be listed in ${projectVerdictDir}: ${e.code || e.message}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
27
67
|
// For cwd-scoped reads, we collect from BOTH local AND tagged global lines
|
|
28
68
|
const verdictDirs = [];
|
|
29
69
|
if (useProjectDir) verdictDirs.push(projectVerdictDir);
|
|
@@ -41,13 +81,27 @@ function readVerdicts(cwd = null) {
|
|
|
41
81
|
const verdictDir = typeof entry === 'string' ? entry : entry.dir;
|
|
42
82
|
const projectTagFilter = typeof entry === 'string' ? null : entry.filterByProjectTag;
|
|
43
83
|
if (!fs.existsSync(verdictDir)) continue;
|
|
44
|
-
|
|
84
|
+
let files;
|
|
85
|
+
try {
|
|
86
|
+
files = fs.readdirSync(verdictDir);
|
|
87
|
+
} catch (e) {
|
|
88
|
+
// A directory that exists and cannot be listed is the case that made a
|
|
89
|
+
// project look like it had never run.
|
|
90
|
+
health?.problems?.push(`verdicts could not be listed in ${verdictDir}: ${e.code || e.message}`);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
for (const file of files) {
|
|
45
94
|
const agent = file.replace('.log', '');
|
|
46
|
-
|
|
47
|
-
|
|
95
|
+
let lines;
|
|
96
|
+
try {
|
|
97
|
+
lines = fs.readFileSync(path.join(verdictDir, file), 'utf8').split('\n').filter(Boolean);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
health?.problems?.push(`${file} could not be read: ${e.code || e.message}`);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
48
102
|
for (const line of lines) {
|
|
49
103
|
const parsed = parseVerdictLine(line);
|
|
50
|
-
if (!parsed.ok) continue;
|
|
104
|
+
if (!parsed.ok) { health?.onBadLine?.(); continue; } // counted, never shown as a verdict
|
|
51
105
|
|
|
52
106
|
// When reading global with a project filter, only include records for this
|
|
53
107
|
// project. Read from the parsed record, not from a `project=` substring:
|
|
@@ -169,4 +223,4 @@ function readSecStats(cwd = process.cwd()) {
|
|
|
169
223
|
return { approved, blocked };
|
|
170
224
|
}
|
|
171
225
|
|
|
172
|
-
export { readVerdicts, readPlanCosts, readQAStats, readSecStats };
|
|
226
|
+
export { readVerdicts, readVerdictsWithHealth, readPlanCosts, readQAStats, readSecStats };
|