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.
@@ -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, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
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, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
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
- const profile = getAgentProfile(slug);
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
- function readVerdicts(cwd = null) {
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
- const useProjectDir = projectVerdictDir
25
- && fs.existsSync(projectVerdictDir)
26
- && fs.readdirSync(projectVerdictDir).filter(f => f.endsWith('.log')).length > 0;
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
- for (const file of fs.readdirSync(verdictDir)) {
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
- const lines = fs.readFileSync(path.join(verdictDir, file), 'utf8')
47
- .split('\n').filter(Boolean);
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; // unreadable — counted by the CLI, not shown as a verdict
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 };