great-cto 2.95.0 → 2.96.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/beads.mjs +53 -5
- package/board/packages/board/lib/docs.mjs +137 -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 +146 -11
- package/board/packages/board/lib/verdicts.mjs +63 -9
- package/board/packages/board/public/index.html +376 -32
- package/board/scripts/lib/verdict-record.mjs +18 -1
- 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,14 @@ 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
|
|
|
422
554
|
// Resume — pick up where you left off (last verdicts + WIP + recent decisions)
|
|
423
555
|
if (pathname === '/api/resume') {
|
|
424
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
556
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
425
557
|
res.end(JSON.stringify(getResume(cwd)));
|
|
426
558
|
return true;
|
|
427
559
|
}
|
|
@@ -435,28 +567,28 @@ async function dispatch(req, res, url, cwd) {
|
|
|
435
567
|
const limit = Number.isFinite(parsed) && parsed > 0
|
|
436
568
|
? Math.min(parsed, 200)
|
|
437
569
|
: 20;
|
|
438
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
570
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
439
571
|
res.end(JSON.stringify(readDecisionsLog(limit, cwd)));
|
|
440
572
|
return true;
|
|
441
573
|
}
|
|
442
574
|
|
|
443
575
|
// Memory — 4-layer memory file contents
|
|
444
576
|
if (pathname === '/api/memory') {
|
|
445
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
577
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
446
578
|
res.end(JSON.stringify(getMemory(cwd)));
|
|
447
579
|
return true;
|
|
448
580
|
}
|
|
449
581
|
|
|
450
582
|
// Pipeline — current stage states (idle / active / done / failed)
|
|
451
583
|
if (pathname === '/api/pipeline') {
|
|
452
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
584
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
453
585
|
res.end(JSON.stringify(getPipeline(cwd)));
|
|
454
586
|
return true;
|
|
455
587
|
}
|
|
456
588
|
|
|
457
589
|
// change_tier for the current working-tree diff — the gate + judge plan (ADR-003/004).
|
|
458
590
|
if (pathname === '/api/change-tier') {
|
|
459
|
-
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
591
|
+
res.writeHead(200, verdictHeaders(cwd, { 'Content-Type': 'application/json' }));
|
|
460
592
|
res.end(JSON.stringify(getChangeTier(cwd)));
|
|
461
593
|
return true;
|
|
462
594
|
}
|
|
@@ -475,7 +607,7 @@ async function dispatch(req, res, url, cwd) {
|
|
|
475
607
|
const days = Number.isFinite(parsed) && parsed > 0
|
|
476
608
|
? Math.min(parsed, 365)
|
|
477
609
|
: 30;
|
|
478
|
-
res.writeHead(200,
|
|
610
|
+
res.writeHead(200, verdictHeaders(cwd));
|
|
479
611
|
res.end(JSON.stringify(getCostHistory(cwd, days)));
|
|
480
612
|
return true;
|
|
481
613
|
}
|
|
@@ -923,7 +1055,10 @@ async function dispatch(req, res, url, cwd) {
|
|
|
923
1055
|
return true;
|
|
924
1056
|
}
|
|
925
1057
|
if (!sub) {
|
|
926
|
-
|
|
1058
|
+
// Scoped to the selected project — see getAgentProfile. Unscoped, this
|
|
1059
|
+
// reported an agent's record across all twenty-two projects while the
|
|
1060
|
+
// reader was looking at one of them.
|
|
1061
|
+
const profile = getAgentProfile(slug, cwd);
|
|
927
1062
|
if (!profile) {
|
|
928
1063
|
res.writeHead(404, { 'Content-Type': 'application/json' });
|
|
929
1064
|
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 };
|