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.
@@ -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,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, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
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
- const profile = getAgentProfile(slug);
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
- 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 };