great-cto 3.54.0 → 3.55.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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "great-cto",
3
3
  "description": "You already have the agent. This is everything around it. great_cto runs Claude Code as a pipeline of 72 specialist agents \u2014 an independent model checks each stage before the next builds on it, spending caps refuse rather than warn, and three decisions stay yours: what gets built, how, and whether it ships.",
4
- "version": "3.54.0",
4
+ "version": "3.55.0",
5
5
  "author": {
6
6
  "name": "Alexander Velikiy",
7
7
  "url": "https://hashnode.com/@Greatcto"
@@ -14,6 +14,7 @@ import { autoRegisterProject, listProjects, resolveProjectCwd, resolveProjectInf
14
14
  import { readVerdictsWithHealth } from './verdicts.mjs';
15
15
  import { agentUsage, usageSnapshot } from '../../../scripts/lib/agent-usage.mjs';
16
16
  import { usageIndexSnapshot, summarizeUsage, readCodexTitles } from '../../../scripts/lib/session-usage.mjs';
17
+ import { outcomes as computeOutcomes } from '../../../scripts/lib/outcomes.mjs';
17
18
  import { reviewerStatus } from '../../../scripts/lib/required-reviewers.mjs';
18
19
  import { readSessionStatus } from '../../../scripts/lib/session-status.mjs';
19
20
  import { readScores, summarizeScores } from '../../../scripts/lib/scores.mjs';
@@ -1511,6 +1512,14 @@ async function dispatch(req, res, url, cwd) {
1511
1512
  for (const host of Object.keys(sum.lists)) {
1512
1513
  sum.lists[host].agents = sum.lists[host].agents.map((a) => ({ ...a, ours: ours.has(a.name) }));
1513
1514
  }
1515
+ // Which hooks are great_cto's: a guard that names itself, a status message
1516
+ // from our plugin.json, or a script in our scripts/hooks.
1517
+ const mine = boardHookNames();
1518
+ const tag = (rows, kind) => rows.map((r) => ({
1519
+ ...r,
1520
+ ours: kind === 'blocks' ? !r.name.startsWith('other: ') : mine.has(r.name.replace(/^[A-Za-z]+:\s*/, '')),
1521
+ }));
1522
+ for (const k of ['blocks', 'stopBlocks', 'errors', 'timeouts']) sum.hooks[k] = tag(sum.hooks[k], k);
1514
1523
  body = { ...sum, seen: snap.seen, files: snap.files };
1515
1524
  }
1516
1525
  res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
@@ -1518,6 +1527,18 @@ async function dispatch(req, res, url, cwd) {
1518
1527
  return true;
1519
1528
  }
1520
1529
 
1530
+ // What the agents concluded and what reviews found, across every registered
1531
+ // project (scripts/lib/outcomes.mjs): verdicts per agent, Beads bugs by
1532
+ // priority. Counts only — no bug title leaves the server. Answers at once:
1533
+ // `computing` until the first read of every project's Beads lands.
1534
+ if (pathname === '/api/outcomes') {
1535
+ const rawDays = parseInt(url.searchParams.get('days') || '30', 10);
1536
+ const days = Number.isFinite(rawDays) && rawDays > 0 ? Math.min(rawDays, 365) : 30;
1537
+ res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1538
+ res.end(JSON.stringify(boardOutcomes(days)));
1539
+ return true;
1540
+ }
1541
+
1521
1542
  // Domain reviewers this project's PROJECT.md requires (archetype, packs,
1522
1543
  // compliance), each with whether a verdict exists — the list gate:ship refuses on.
1523
1544
  // Sessions in this project that wait for a person — written by the
@@ -2181,6 +2202,44 @@ function boardUsage() {
2181
2202
  return _usageSnap;
2182
2203
  }
2183
2204
 
2205
+ let _hookNames = null;
2206
+ function boardHookNames() {
2207
+ if (_hookNames) return _hookNames;
2208
+ const root = path.join(path.dirname(new URL(import.meta.url).pathname), '..', '..', '..');
2209
+ const names = new Set();
2210
+ try {
2211
+ const text = fs.readFileSync(path.join(root, '.claude-plugin', 'plugin.json'), 'utf8');
2212
+ for (const m of text.matchAll(/"statusMessage"\s*:\s*"([^"]+)"/g)) names.add(m[1]);
2213
+ } catch { /* no manifest: only scripts count */ }
2214
+ try {
2215
+ for (const f of fs.readdirSync(path.join(root, 'scripts', 'hooks'))) {
2216
+ names.add(f.replace(/\.(mjs|js|py|sh)$/, ''));
2217
+ // A Stop hook that sends the turn back opens its message with a label —
2218
+ // "PIPELINE-NEXT: senior-dev succeeded …"; the labels our hooks write are ours.
2219
+ try {
2220
+ const src = fs.readFileSync(path.join(root, 'scripts', 'hooks', f), 'utf8');
2221
+ for (const m of src.matchAll(/['"`]([A-Z][A-Z0-9_-]{2,40}):\s/g)) names.add(m[1]);
2222
+ } catch { /* a directory or unreadable: its name is enough */ }
2223
+ }
2224
+ } catch { /* none */ }
2225
+ _hookNames = names;
2226
+ return names;
2227
+ }
2228
+
2229
+ const _outcomes = new Map();
2230
+ const OUTCOMES_TTL = 5 * 60 * 1000;
2231
+ function boardOutcomes(days) {
2232
+ const slot = _outcomes.get(days) || {};
2233
+ const fresh = slot.value && Date.now() - slot.at < OUTCOMES_TTL;
2234
+ if (!fresh && !slot.running) {
2235
+ slot.running = computeOutcomes({ days, roster: boardAgentNames() })
2236
+ .then((v) => { slot.value = v; }, (e) => { slot.value = slot.value || { state: 'unavailable', why: `outcomes could not be read: ${e?.message || e}` }; })
2237
+ .finally(() => { slot.at = Date.now(); slot.running = null; });
2238
+ _outcomes.set(days, slot);
2239
+ }
2240
+ return slot.value || { state: 'computing', why: 'reading every project\'s verdicts and Beads' };
2241
+ }
2242
+
2184
2243
  let _sessionUsageSnap = null;
2185
2244
  function boardSessionUsage() {
2186
2245
  if (!_sessionUsageSnap) _sessionUsageSnap = usageIndexSnapshot();
@@ -2428,7 +2428,10 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2428
2428
  .usage-lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; margin-bottom: 20px; }
2429
2429
  .usage-list { list-style: none; margin: 0; padding: 0; }
2430
2430
  .usage-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; font-size: var(--fs-small); padding: 4px 0; position: relative; }
2431
- .usage-list li .nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
2431
+ .usage-list li .nm { display: flex; align-items: baseline; min-width: 0; color: var(--text); }
2432
+ .usage-list li .nm .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
2433
+ .usage-list li .nm .usage-tag { flex-shrink: 0; }
2434
+ [data-outcomes="agents"] .usage-table td:first-child { white-space: nowrap; }
2432
2435
  .usage-list li .n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text2); }
2433
2436
  .usage-list li .bar { grid-column: 1 / -1; height: 3px; background: var(--bg-muted); }
2434
2437
  .usage-list li .bar > span { display: block; height: 100%; background: var(--accent); opacity: 0.7; }
@@ -2441,7 +2444,15 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2441
2444
  .usage-quote { font-size: var(--fs-small); color: var(--text2); border-left: 2px solid var(--border-strong); padding: 2px 0 2px 10px; margin: 10px 0 0; overflow-wrap: anywhere; }
2442
2445
  .usage-pace { font-size: var(--fs-small); color: var(--text); margin: 4px 0 8px; }
2443
2446
  .usage-pace.bad { color: var(--status-blocked-ink); }
2444
- @media (max-width: 767px) { .usage-title { max-width: 160px; } .usage-chip { min-height: 44px; } }
2447
+ .usage-prios { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin-bottom: 12px; }
2448
+ .usage-prio { border: 1px solid var(--border); padding: 8px 10px; min-width: 0; }
2449
+ .usage-prio b { display: block; font-family: var(--display); font-size: var(--fs-num-s); font-variant-numeric: tabular-nums; color: var(--text); line-height: 1.1; }
2450
+ .usage-prio .k { font-family: var(--mono); font-size: var(--fs-caption); color: var(--text2); }
2451
+ .usage-prio.hot { border-color: var(--status-blocked); }
2452
+ .usage-prio.hot .k { color: var(--status-blocked-ink); }
2453
+ .usage-rate { display: inline-block; width: 44px; height: 6px; background: var(--bg-muted); margin-right: 6px; vertical-align: 1px; }
2454
+ .usage-rate > span { display: block; height: 100%; background: var(--status-blocked); }
2455
+ @media (max-width: 767px) { .usage-title { max-width: 160px; } .usage-chip { min-height: 44px; } .usage-prios { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
2445
2456
  .mp-section-label {
2446
2457
  font-family: var(--mono); font-size: var(--fs-caption);
2447
2458
  letter-spacing: 0.1em; text-transform: uppercase;
@@ -6023,11 +6034,12 @@ async function loadUsage(days) {
6023
6034
  }
6024
6035
  usageData = d;
6025
6036
  renderUsage(d);
6037
+ loadOutcomes();
6026
6038
  }
6027
6039
 
6028
6040
  function setUsageHost(host) {
6029
6041
  usageHost = host;
6030
- if (usageData) renderUsage(usageData);
6042
+ if (usageData) { renderUsage(usageData); loadOutcomes(); }
6031
6043
  }
6032
6044
 
6033
6045
  function usageLimitWindow(minutes) {
@@ -6154,7 +6166,7 @@ function usageModels(d) {
6154
6166
  function usageList(title, rows, empty) {
6155
6167
  if (!rows || !rows.length) return `<section class="usage-card"><h3><span>${title}</span></h3><p class="usage-note">${empty}</p></section>`;
6156
6168
  const max = Math.max(...rows.map((r) => r.n));
6157
- const items = rows.map((r) => `<li><span class="nm" title="${esc(r.name)}">${esc(r.name)}${r.ours ? '<span class="usage-tag">great_cto</span>' : ''}</span><span class="n">${r.n.toLocaleString('en-US')}</span><span class="bar" aria-hidden="true"><span style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></span></span></li>`).join('');
6169
+ const items = rows.map((r) => `<li><span class="nm" title="${esc(r.name)}"><span class="t">${esc(r.name)}</span>${r.ours ? '<span class="usage-tag">great_cto</span>' : ''}</span><span class="n">${r.n.toLocaleString('en-US')}</span><span class="bar" aria-hidden="true"><span style="width:${Math.max(2, Math.round((r.n / max) * 100))}%"></span></span></li>`).join('');
6158
6170
  return `<section class="usage-card"><h3><span>${title}</span></h3><ul class="usage-list">${items}</ul></section>`;
6159
6171
  }
6160
6172
 
@@ -6242,6 +6254,90 @@ function usageCodexLimits(d) {
6242
6254
  return `<section class="usage-card" data-limits="codex">${head}${lanes}<p class="usage-sub">A window Codex reset early shows as its own window.</p></section>`;
6243
6255
  }
6244
6256
 
6257
+ // ── Agents and reviews (great_cto-egkw stage 3) ────────────────────────────
6258
+ // Outcomes come from /api/outcomes (verdicts of every registered project, Beads
6259
+ // bugs by priority); hook refusals and failures from /api/usage (Claude Code's
6260
+ // own logs). Counts only — no bug title reaches the page.
6261
+ let _outcomesTimer = null;
6262
+ function fmtSpan(days) {
6263
+ if (days == null) return '—';
6264
+ const h = days * 24;
6265
+ if (h < 1) return `${Math.max(1, Math.round(h * 60))} min`;
6266
+ if (h < 48) return `${Math.round(h)} h`;
6267
+ return `${Math.round(days)} days`;
6268
+ }
6269
+
6270
+ async function loadOutcomes() {
6271
+ clearTimeout(_outcomesTimer);
6272
+ if (!document.getElementById('usage-outcomes')) return;
6273
+ const d = await api(`/api/outcomes?days=${usageDays}`);
6274
+ // Looked up AFTER the answer: Usage may have re-rendered while it was on the
6275
+ // way, and an answer written into the replaced element is an answer nobody sees.
6276
+ const el = document.getElementById('usage-outcomes');
6277
+ if (!el) return;
6278
+ if (!d || d.error) { el.className = 'usage-note'; el.textContent = `Outcomes could not be read: ${(d && d.error) || 'the board is not reachable'}`; return; }
6279
+ if (d.state === 'computing') {
6280
+ el.className = 'usage-note';
6281
+ el.textContent = `Counting — ${d.why}`;
6282
+ _outcomesTimer = setTimeout(() => { if (currentTab === 'usage') loadOutcomes(); }, 2000);
6283
+ return;
6284
+ }
6285
+ if (d.state !== 'counted') { el.className = 'usage-note'; el.textContent = `Outcomes are not available: ${d.why || d.state}`; return; }
6286
+ el.className = 'usage-grid';
6287
+ el.innerHTML = usageAgentsCard(d) + usageFindingsCard(d);
6288
+ }
6289
+
6290
+ function usageAgentsCard(d) {
6291
+ const A = d.agents || { agents: [] };
6292
+ const head = `<h3><span>Agents: what they concluded</span><span class="muted">${d.projects} projects · ${esc(d.window.from)} → ${esc(d.window.to)}</span></h3>`;
6293
+ if (!A.agents.length) return `<section class="usage-card" data-outcomes="agents">${head}<p class="usage-note">No great_cto agent recorded a verdict in this period.</p></section>`;
6294
+ const dispatched = Object.fromEntries(((usageData && usageData.lists && usageData.lists.claude && usageData.lists.claude.agents) || []).map((a) => [a.name, a.n]));
6295
+ const rows = A.agents.map((a) => {
6296
+ const rate = a.stopRate == null ? absent('none', 'no run of this agent reached a conclusion in this period') : `<span class="usage-rate" aria-hidden="true"><span style="width:${Math.round(a.stopRate * 100)}%"></span></span>${Math.round(a.stopRate * 100)}%`;
6297
+ const disp = dispatched[a.agent];
6298
+ const gap = disp != null && disp > a.runs ? ` title="${esc(`dispatched ${disp} times in Claude Code, ${a.runs} verdicts recorded — the rest ran without one`)}"` : '';
6299
+ return `<tr><td>${esc(a.agent)}</td><td class="num">${a.runs}</td><td class="num">${a.pass}</td><td class="num">${a.stopped}</td><td class="num">${a.failed}</td><td class="num">${rate}</td><td class="num"${gap}>${disp == null ? '—' : disp}</td></tr>`;
6300
+ }).join('');
6301
+ const notes = [];
6302
+ if (A.agents.some((a) => a.unknown)) notes.push(`${A.agents.reduce((n, a) => n + a.unknown, 0)} verdicts used a word no rule classifies — counted as runs, not as passes.`);
6303
+ if (A.other && A.other.runs) notes.push(`${A.other.runs} verdicts from names outside great_cto's roster (${esc(A.other.names.slice(0, 4).join(', '))}) are not shown.`);
6304
+ if (A.unreadable) notes.push(`${A.unreadable} verdict files could not be read.`);
6305
+ return `<section class="usage-card" data-outcomes="agents">${head}<div class="usage-table-wrap"><table class="usage-table">
6306
+ <thead><tr><th>Agent</th><th class="num">Verdicts</th><th class="num">Pass</th><th class="num">Stopped</th><th class="num">Failed</th><th class="num">Stop rate</th><th class="num" title="Agent dispatches seen in Claude Code's logs in the same period">Dispatched</th></tr></thead>
6307
+ <tbody>${rows}</tbody></table></div>
6308
+ <p class="usage-sub">Stopped = BLOCKED, REWORK, REJECTED, ESCALATED — the pipeline halted on the agent's word. Stop rate is of the verdicts that concluded.${notes.length ? ` ${notes.join(' ')}` : ''}</p></section>`;
6309
+ }
6310
+
6311
+ function usageFindingsCard(d) {
6312
+ const F = d.findings;
6313
+ const head = `<h3><span>Findings by priority</span><span class="muted">Beads bugs — reviewers file every P0/P1</span></h3>`;
6314
+ if (!F) return `<section class="usage-card" data-outcomes="findings">${head}<p class="usage-note">Not read.</p></section>`;
6315
+ const tiles = ['P0', 'P1', 'P2', 'P3'].map((p) => {
6316
+ const hot = (p === 'P0' || p === 'P1') && F.openNow[p] > 0;
6317
+ const close = F.daysToClose[p];
6318
+ return `<div class="usage-prio${hot ? ' hot' : ''}"><span class="k">${p} filed</span><b>${F.filed[p]}</b><span class="usage-sub">${F.openNow[p]} open now${close ? ` · closed in ${esc(fmtSpan(close.median))} (median of ${close.n})` : ''}</span></div>`;
6319
+ }).join('');
6320
+ const rows = (F.projects || []).slice(0, 10).map((r) => `<tr><td>${esc(r.project)}</td><td class="num">${r.open.P0 || 0}</td><td class="num">${r.open.P1 || 0}</td><td class="num">${r.open.P2 || 0}</td><td class="num">${(r.filed.P0 || 0) + (r.filed.P1 || 0) + (r.filed.P2 || 0) + (r.filed.P3 || 0)}</td></tr>`).join('');
6321
+ const table = rows ? `<div class="usage-table-wrap"><table class="usage-table"><thead><tr><th>Project</th><th class="num">P0 open</th><th class="num">P1 open</th><th class="num">P2 open</th><th class="num">Filed in period</th></tr></thead><tbody>${rows}</tbody></table></div>` : '<p class="usage-note">No project has a bug filed or open.</p>';
6322
+ const unread = (F.unread || []).length
6323
+ ? `<p class="usage-sub">Not read, so not counted: ${F.unread.map((u) => `<span title="${esc(u.why)}">${esc(u.project)}</span>`).join(', ')} — hover for the reason.</p>` : '';
6324
+ return `<section class="usage-card" data-outcomes="findings">${head}<div class="usage-prios">${tiles}</div>${table}${unread}</section>`;
6325
+ }
6326
+
6327
+ function usageHooksCards(d) {
6328
+ const H = d.hooks;
6329
+ if (!d.seen || !d.seen.claude) return '<p class="usage-note">No Claude Code logs on this machine — hook outcomes are read from them.</p>';
6330
+ if (!H) return '';
6331
+ const total = (rows) => (rows || []).reduce((n, r) => n + r.n, 0);
6332
+ return `<div class="usage-lists">
6333
+ ${usageList(`Guards refused a call · ${total(H.blocks)}`, H.blocks, 'No guard refused a tool call in this period.')}
6334
+ ${usageList(`Stop hooks sent the turn back · ${total(H.stopBlocks)}`, H.stopBlocks, 'No Stop hook sent a turn back.')}
6335
+ ${usageList(`Hooks that failed · ${total(H.errors)}`, H.errors, 'No hook failed in this period.')}
6336
+ ${usageList(`Hooks that timed out · ${total(H.timeouts)}`, H.timeouts, 'No hook timed out in this period.')}
6337
+ </div>
6338
+ <p class="usage-sub">From Claude Code's logs; Codex does not write hook outcomes. A refusal is counted only where Claude Code marked the result an error — a command that merely prints one is not a refusal.</p>`;
6339
+ }
6340
+
6245
6341
  function renderUsage(d) {
6246
6342
  const body = document.getElementById('usage-body');
6247
6343
  if (!body) return;
@@ -6265,6 +6361,10 @@ function renderUsage(d) {
6265
6361
  ${usageList('Agents', l.agents, 'No subagents were dispatched in this window.')}
6266
6362
  ${usageList('MCP servers', l.mcp, 'No MCP tools were called in this window.')}
6267
6363
  </div>
6364
+ <h2 class="mp-section-label">Agents and reviews</h2>
6365
+ <div id="usage-outcomes" class="usage-note" role="status">Reading every project's verdicts and Beads.</div>
6366
+ <h2 class="mp-section-label">Guards and hooks</h2>
6367
+ ${usageHooksCards(d)}
6268
6368
  <p class="usage-note">Read on this machine from ${USAGE_LOGS.claude} and ${USAGE_LOGS.codex}; nothing is sent anywhere. Tokens include cache reads. <b>$ list</b> is the API list-price equivalent from great_cto's price table, not a subscription bill — Claude Code's own estimate can differ for some models. A model without a price shows n/a, never $0.</p>`;
6269
6369
  }
6270
6370
 
@@ -0,0 +1,195 @@
1
+ /**
2
+ * outcomes — what great_cto's agents concluded and what reviews found, across
3
+ * every project on this machine.
4
+ *
5
+ * agent outcomes every verdict line of every registered project (and the
6
+ * global layer), classified pass / stopped / failed / skipped
7
+ * findings bugs in each project's Beads, by priority: filed in the
8
+ * window, open now, and how long the closed ones took. Every
9
+ * reviewer files its P0/P1 findings as Beads bugs, so this is
10
+ * where they land — the review files were written once on this
11
+ * machine; the bugs were filed a hundred times.
12
+ *
13
+ * Rules:
14
+ * - A verdict nobody can classify is `unknown`, not a pass. A project whose
15
+ * Beads cannot be read is listed as unread, not as a project with no bugs.
16
+ * - The same line can sit in a project log and in the global layer; it is
17
+ * counted once.
18
+ * - Titles and descriptions of bugs never leave this module — counts only.
19
+ */
20
+ import fs from 'node:fs';
21
+ import os from 'node:os';
22
+ import path from 'node:path';
23
+ import { execFile } from 'node:child_process';
24
+ import { parseVerdictLine } from './verdict-record.mjs';
25
+ import { dayOf } from './session-usage.mjs';
26
+
27
+ const DAY = 86400000;
28
+
29
+ /** pass · stopped · failed · skipped · unknown — what a verdict means for the pipeline. */
30
+ export function outcomeOf(verdict) {
31
+ const v = String(verdict || '').toUpperCase();
32
+ if (['APPROVED', 'PASS', 'DONE', 'TASK_DONE'].includes(v)) return 'pass';
33
+ if (['BLOCKED', 'REWORK', 'ESCALATED', 'REJECTED', 'CHANGES_REQUESTED'].includes(v)) return 'stopped';
34
+ if (v === 'FAIL') return 'failed';
35
+ if (v === 'SKIPPED') return 'skipped';
36
+ return 'unknown';
37
+ }
38
+
39
+ /**
40
+ * The agent a verdict belongs to. `great-cto:senior-dev` is senior-dev; a legacy
41
+ * file named `devops-2026-09-15-123328.log` is devops.
42
+ */
43
+ export function verdictAgent(raw) {
44
+ return String(raw || '').replace(/^great[-_]cto:/, '').replace(/-\d{4}-\d{2}-\d{2}.*$/, '').trim();
45
+ }
46
+
47
+ /** Project directories from the board registry, plus the global layer's home. */
48
+ export function registeredProjects(file = path.join(os.homedir(), '.great_cto', 'projects.json')) {
49
+ try {
50
+ const list = JSON.parse(fs.readFileSync(file, 'utf8')).projects || [];
51
+ return list.map((p) => ({ name: p.name || path.basename(p.path || ''), path: p.path })).filter((p) => p.path && fs.existsSync(p.path));
52
+ } catch { return []; }
53
+ }
54
+
55
+ function verdictFiles(dir) {
56
+ try { return fs.readdirSync(dir).filter((f) => f.endsWith('.log')).map((f) => path.join(dir, f)); } catch { return []; }
57
+ }
58
+
59
+ /**
60
+ * Per-agent outcomes in the window.
61
+ * @param {{projects:{name:string,path:string}[], globalDir?:string, days?:number, now?:number, roster?:string[]}} opts
62
+ */
63
+ export function agentOutcomes({ projects, globalDir = path.join(os.homedir(), '.great_cto', 'verdicts'), days = 30, now = Date.now(), roster = [] }) {
64
+ const from = now - days * DAY;
65
+ const known = new Set(roster);
66
+ const seen = new Set();
67
+ const agents = {};
68
+ const other = { runs: 0, names: new Set() };
69
+ let unreadable = 0;
70
+ const sources = [...projects.map((p) => ({ project: p.name, dir: path.join(p.path, '.great_cto', 'verdicts') })), { project: null, dir: globalDir }];
71
+ for (const { project, dir } of sources) {
72
+ for (const file of verdictFiles(dir)) {
73
+ let text;
74
+ try { text = fs.readFileSync(file, 'utf8'); } catch { unreadable++; continue; }
75
+ const fileAgent = verdictAgent(path.basename(file, '.log'));
76
+ for (const line of text.split('\n')) {
77
+ if (!line.trim()) continue;
78
+ const p = parseVerdictLine(line);
79
+ if (!p.ok) continue;
80
+ const rec = p.rec;
81
+ const t = Date.parse(rec.ts);
82
+ if (!Number.isFinite(t) || t < from || t > now) continue;
83
+ const key = line.trim();
84
+ if (seen.has(key)) continue;
85
+ seen.add(key);
86
+ const agent = verdictAgent(rec.agent || fileAgent) || fileAgent;
87
+ if (known.size && !known.has(agent)) { other.runs++; other.names.add(agent); continue; }
88
+ const a = agents[agent] || (agents[agent] = { agent, runs: 0, pass: 0, stopped: 0, failed: 0, skipped: 0, unknown: 0, needImplementer: 0, needDecision: 0, last: null, projects: new Set() });
89
+ a.runs++;
90
+ a[outcomeOf(rec.verdict)]++;
91
+ if (rec.meta?.need === 'implementer') a.needImplementer++;
92
+ if (rec.meta?.need === 'decision') a.needDecision++;
93
+ if (!a.last || rec.ts > a.last) a.last = rec.ts;
94
+ const proj = rec.project || project;
95
+ if (proj) a.projects.add(proj);
96
+ }
97
+ }
98
+ }
99
+ const rows = Object.values(agents).map((a) => ({
100
+ ...a,
101
+ projects: a.projects.size,
102
+ // Of the runs that reached a conclusion, how many stopped the pipeline.
103
+ stopRate: a.pass + a.stopped + a.failed ? (a.stopped + a.failed) / (a.pass + a.stopped + a.failed) : null,
104
+ })).sort((x, y) => y.runs - x.runs);
105
+ return { agents: rows, other: { runs: other.runs, names: [...other.names].sort().slice(0, 20) }, unreadable };
106
+ }
107
+
108
+ /** Run `bd` in a project; resolves to parsed JSON or a reason it could not. */
109
+ function bdJson(cwd, args, { timeoutMs = 20000, bin = 'bd' } = {}) {
110
+ return new Promise((resolve) => {
111
+ execFile(bin, args, { cwd, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, BD_NO_DAEMON: '1' } }, (err, stdout, stderr) => {
112
+ if (err) {
113
+ if (err.code === 'ENOENT') return resolve({ ok: false, why: 'bd is not installed' });
114
+ if (err.killed) return resolve({ ok: false, why: `bd took longer than ${timeoutMs / 1000}s` });
115
+ // bd prints warnings first (".beads has permissions 0750"); the reason is the error line.
116
+ const lines = String(stderr || '').split('\n').map((x) => x.trim()).filter(Boolean);
117
+ const said = lines.find((x) => /^error/i.test(x)) || lines[lines.length - 1];
118
+ return resolve({ ok: false, why: (said || String(err.message || err).split('\n')[0]).split(os.homedir()).join('~').slice(0, 160) });
119
+ }
120
+ try { return resolve({ ok: true, data: JSON.parse(stdout || '[]') }); } catch { return resolve({ ok: false, why: 'bd did not answer JSON' }); }
121
+ });
122
+ });
123
+ }
124
+
125
+ const PRIOS = ['P0', 'P1', 'P2', 'P3'];
126
+ const prio = (n) => (Number.isInteger(n) && n >= 0 && n <= 3 ? `P${n}` : 'P?');
127
+
128
+ function median(xs) {
129
+ if (!xs.length) return null;
130
+ const s = [...xs].sort((a, b) => a - b);
131
+ const m = Math.floor(s.length / 2);
132
+ return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
133
+ }
134
+
135
+ /**
136
+ * Bugs by priority across the projects that keep Beads.
137
+ * @param {{projects:{name:string,path:string}[], days?:number, now?:number, list?:(cwd:string)=>Promise<{ok:boolean,data?:object[],why?:string}>}} opts
138
+ */
139
+ export async function bugFindings({ projects, days = 30, now = Date.now(), list = (cwd) => bdJson(cwd, ['list', '--json', '--type', 'bug', '--all']) }) {
140
+ const from = now - days * DAY;
141
+ const blank = () => Object.fromEntries([...PRIOS, 'P?'].map((p) => [p, 0]));
142
+ const filed = blank();
143
+ const openNow = blank();
144
+ const closedIn = blank();
145
+ const toClose = Object.fromEntries([...PRIOS, 'P?'].map((p) => [p, []]));
146
+ const daily = {};
147
+ const perProject = [];
148
+ const unread = [];
149
+ for (const p of projects) {
150
+ if (!fs.existsSync(path.join(p.path, '.beads'))) continue;
151
+ const r = await list(p.path);
152
+ if (!r.ok) { unread.push({ project: p.name, why: r.why }); continue; }
153
+ const row = { project: p.name, filed: blank(), open: blank() };
154
+ for (const b of Array.isArray(r.data) ? r.data : []) {
155
+ const pr = prio(b.priority);
156
+ const created = Date.parse(b.created_at);
157
+ const closed = b.closed_at ? Date.parse(b.closed_at) : NaN;
158
+ if (Number.isFinite(created) && created >= from && created <= now) {
159
+ filed[pr]++; row.filed[pr]++;
160
+ const d = dayOf(created);
161
+ const slot = daily[d] || (daily[d] = blank());
162
+ slot[pr]++;
163
+ }
164
+ if (b.status !== 'closed' && b.status !== 'tombstone') { openNow[pr]++; row.open[pr]++; }
165
+ if (Number.isFinite(closed) && closed >= from && closed <= now) {
166
+ closedIn[pr]++;
167
+ if (Number.isFinite(created) && closed >= created) toClose[pr].push((closed - created) / DAY);
168
+ }
169
+ }
170
+ const any = (o) => Object.values(o).some(Boolean);
171
+ if (any(row.filed) || any(row.open)) perProject.push(row);
172
+ }
173
+ perProject.sort((a, b) => (b.open.P0 - a.open.P0) || (b.open.P1 - a.open.P1) || (sum(b.filed) - sum(a.filed)));
174
+ return {
175
+ filed, openNow, closedIn,
176
+ daysToClose: Object.fromEntries(Object.entries(toClose).map(([k, xs]) => [k, xs.length ? { median: median(xs), n: xs.length } : null])),
177
+ daily,
178
+ projects: perProject,
179
+ unread,
180
+ };
181
+ }
182
+
183
+ const sum = (o) => Object.values(o).reduce((a, n) => a + n, 0);
184
+
185
+ /** Both, for the board. */
186
+ export async function outcomes({ days = 30, now = Date.now(), roster = [], projects = registeredProjects(), globalDir, list } = {}) {
187
+ const findings = await bugFindings({ projects, days, now, list });
188
+ return {
189
+ state: 'counted',
190
+ window: { days, from: dayOf(now - (days - 1) * DAY), to: dayOf(now) },
191
+ projects: projects.length,
192
+ agents: agentOutcomes({ projects, globalDir, days, now, roster }),
193
+ findings,
194
+ };
195
+ }
@@ -40,7 +40,7 @@ import path from 'node:path';
40
40
  import { createHash } from 'node:crypto';
41
41
  import { priceUsage, effectivePrices } from './cost-meter.mjs';
42
42
 
43
- const INDEX_VERSION = 2; // 2: hourly buckets, limit kinds, Codex limit series
43
+ const INDEX_VERSION = 3; // 2: hourly buckets, limit kinds, Codex limit series · 3: hook blocks, errors, timeouts
44
44
  const RING = 256; // recent response ids remembered across reads
45
45
  const KEEP_GONE_DAYS = 400; // how long a vanished file's days are kept
46
46
  const SERIES_MAX = 4000; // Codex limit readings kept per file
@@ -117,7 +117,10 @@ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return
117
117
  // ── shared accumulation ───────────────────────────────────────────────────────
118
118
 
119
119
  function emptyDay() {
120
- return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: {}, h: {} };
120
+ // g: a guard hook refused a tool call · sb: a Stop hook sent the turn back ·
121
+ // he: a hook failed · ht: a hook timed out. Keyed by the guard's name or the
122
+ // hook's status message — the only name a transcript gives a hook.
123
+ return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: {}, h: {}, g: {}, sb: {}, he: {}, ht: {} };
121
124
  }
122
125
 
123
126
  /** The hour inside a day: tokens per model as [in, out, cacheRead, cacheWrite5m, cacheWrite1h], and limit refusals. */
@@ -211,17 +214,87 @@ export function agentName(raw) {
211
214
  return raw.replace(/^great[-_]cto:/, '');
212
215
  }
213
216
 
217
+ /**
218
+ * A hook that refused a tool call, from the tool result Claude Code wrote:
219
+ * "PreToolUse:Bash hook error: [<cmd>]: great_cto shared-tree guard blocked …"
220
+ * Named by the guard when the hook names itself, else by the event and tool.
221
+ */
222
+ export function guardOf(text) {
223
+ const m = String(text || '').trimStart().match(/^(PreToolUse|PostToolUse|PermissionRequest|UserPromptSubmit):?([^\s]*) hook (?:error|blocking error)/);
224
+ if (!m) return null;
225
+ const g = text.match(/great_cto ([a-z0-9-]+(?: [a-z0-9-]+)?) guard\b/);
226
+ if (g) return { name: g[1], ours: true };
227
+ return { name: `${m[1]}${m[2] ? `:${m[2]}` : ''}`, ours: /\bgreat_cto\b/.test(text) };
228
+ }
229
+
230
+ /** A Stop-hook continuation: "PIPELINE-NEXT: senior-dev succeeded …" → PIPELINE-NEXT. */
231
+ export function stopBlockName(event, text) {
232
+ const m = String(text || '').match(/^\s*([A-Z][A-Z0-9_-]{2,40}):/);
233
+ return m ? `${event}: ${m[1]}` : event || 'hook';
234
+ }
235
+
236
+ /**
237
+ * A short name for a hook. Claude Code records a hook by its status message
238
+ * ("Safety check...") or, when it has none, by its whole shell command — a
239
+ * 200-character `PLUGIN_DIR=$(ls -d …)` line. The script it runs is the name.
240
+ */
241
+ export function hookLabel(command) {
242
+ const c = String(command || '').trim();
243
+ const m = c.match(/scripts\/hooks\/([A-Za-z0-9_.-]+?)(?:\.(?:mjs|js|py|sh))?(?=["'\s]|$)/);
244
+ if (m) return m[1];
245
+ return c.length > 60 ? `${c.slice(0, 57)}…` : c || 'hook';
246
+ }
247
+
248
+ function hookRecord(entry, r) {
249
+ const a = r.attachment || {};
250
+ const ts = typeof r.timestamp === 'string' ? r.timestamp : null;
251
+ if (!ts) return;
252
+ const b = dayBucket(entry, ts);
253
+ if (!b) return;
254
+ for (const k of ['g', 'sb', 'he', 'ht']) if (!b[k]) b[k] = {};
255
+ if (a.type === 'hook_blocking_error') {
256
+ const text = a.blockingError?.blockingError ?? a.blockingError ?? '';
257
+ bump(b.sb, stopBlockName(a.hookEvent || a.hookName, typeof text === 'string' ? text : ''));
258
+ } else if (a.type === 'hook_non_blocking_error') {
259
+ bump(b.he, `${a.hookEvent || a.hookName}: ${hookLabel(a.command || a.hookName)}`);
260
+ } else if (a.type === 'hook_cancelled' && a.timedOut) {
261
+ bump(b.ht, `${a.hookEvent || a.hookName}: ${hookLabel(a.command || a.hookName)}`);
262
+ }
263
+ }
264
+
214
265
  export function claudeLine(entry, line) {
215
266
  // Most bytes in a transcript are tool RESULTS in user lines. They carry no
216
- // usage, so they are not parsed — except to name a session that has no title.
267
+ // usage, so they are not parsed — except to name a session that has no title,
268
+ // and when a hook refused the call the result answers.
217
269
  const isAssistant = line.includes('"type":"assistant"');
218
270
  const isTitle = line.includes('"custom-title"');
219
271
  const wantsPrompt = !entry.meta.prompt && !entry.meta.title && line.includes('"type":"user"');
220
- if (!isAssistant && !isTitle && !wantsPrompt) return;
272
+ const isHookResult = line.includes(' hook error') && line.includes('"tool_result"');
273
+ const isHookRecord = line.includes('"hook_blocking_error"') || line.includes('"hook_non_blocking_error"') || line.includes('"hook_cancelled"');
274
+ if (!isAssistant && !isTitle && !wantsPrompt && !isHookResult && !isHookRecord) return;
221
275
  let r;
222
276
  try { r = JSON.parse(line); } catch { return; }
223
277
  const meta = entry.meta;
224
278
 
279
+ if (r.type === 'attachment') { if (isHookRecord) hookRecord(entry, r); return; }
280
+ if (r.type === 'user' && isHookResult) {
281
+ const ts = typeof r.timestamp === 'string' ? r.timestamp : null;
282
+ const b = ts && dayBucket(entry, ts);
283
+ for (const block of Array.isArray(r.message?.content) ? r.message.content : []) {
284
+ // Only a result Claude Code marked as an error: a command whose OUTPUT
285
+ // quotes a refusal (a grep over these logs, say) is not one.
286
+ if (block?.type !== 'tool_result' || block.is_error !== true) continue;
287
+ const c = block.content;
288
+ const text = typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : '';
289
+ const g = guardOf(text);
290
+ if (g && b) {
291
+ if (!b.g) b.g = {};
292
+ bump(b.g, g.ours ? g.name : `other: ${g.name}`);
293
+ }
294
+ }
295
+ if (!wantsPrompt) return;
296
+ }
297
+
225
298
  if (r.type === 'custom-title' && r.customTitle) { meta.title = String(r.customTitle).slice(0, 160); return; }
226
299
  if (r.type === 'user') {
227
300
  if (r.isCompactSummary || r.isMeta) return;
@@ -441,6 +514,28 @@ function saveIndex(cacheFile, index) {
441
514
  fs.writeFileSync(tmp, JSON.stringify(index));
442
515
  fs.renameSync(tmp, cacheFile);
443
516
  } catch { /* unsaved: the next pass re-reads, nothing else is lost */ }
517
+ removeOlderIndexes(cacheFile);
518
+ }
519
+
520
+ /**
521
+ * An index of an older format is a copy nothing current reads — megabytes each.
522
+ * Only names this module writes, only versions below the current one, and only
523
+ * next to the default file: a caller's own cache path is the caller's business.
524
+ */
525
+ export function removeOlderIndexes(cacheFile, version = INDEX_VERSION) {
526
+ if (path.basename(cacheFile) !== `session-usage-index.v${version}.json`) return [];
527
+ const dir = path.dirname(cacheFile);
528
+ const removed = [];
529
+ let names = [];
530
+ try { names = fs.readdirSync(dir); } catch { return removed; }
531
+ for (const name of names) {
532
+ const m = name.match(/^session-usage-index(?:\.v(\d+))?\.json$/);
533
+ if (!m) continue;
534
+ const v = m[1] ? Number(m[1]) : 1;
535
+ if (v >= version) continue;
536
+ try { fs.rmSync(path.join(dir, name)); removed.push(name); } catch { /* in use or gone: next save tries again */ }
537
+ }
538
+ return removed;
444
539
  }
445
540
 
446
541
  /**
@@ -669,6 +764,8 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
669
764
  const daily = Object.fromEntries(dates.map((d) => [d, { claude: { tokens: 0, usd: 0, byFn: {} }, codex: { tokens: 0, usd: 0, byFn: {} } }]));
670
765
  const models = {};
671
766
  const lists = { claude: { tools: {}, skills: {}, agents: {}, mcp: {} }, codex: { tools: {}, skills: {}, agents: {}, mcp: {} } };
767
+ const hooks = { blocks: {}, stopBlocks: {}, errors: {}, timeouts: {} };
768
+ const hookDaily = Object.fromEntries(dates.map((d) => [d, { blocks: 0, stopBlocks: 0 }]));
672
769
  const sessions = {};
673
770
  const unpriced = new Set();
674
771
  let codexLimits = null;
@@ -710,6 +807,12 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
710
807
  const h = hosts[host];
711
808
  for (const [k, n] of Object.entries(b.lim || {})) bump(h.lim, k, n);
712
809
  for (const kind of ['tools', 'skills', 'agents', 'mcp']) for (const [k, n] of Object.entries(b[kind] || {})) bump(lists[host][kind], k, n);
810
+ for (const [field, key] of [['g', 'blocks'], ['sb', 'stopBlocks'], ['he', 'errors'], ['ht', 'timeouts']]) {
811
+ for (const [k, n] of Object.entries(b[field] || {})) {
812
+ bump(hooks[key], k, n);
813
+ if (key === 'blocks' || key === 'stopBlocks') hookDaily[day][key] += n;
814
+ }
815
+ }
713
816
  let dayUsd = 0;
714
817
  let dayUnpriced = false;
715
818
  for (const [model, m] of Object.entries(b.models || {})) {
@@ -787,6 +890,12 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
787
890
  claude: claudeLimits(hourly.claude, { fromMs, now, lastHit }),
788
891
  },
789
892
  unpricedModels: [...unpriced].sort(),
893
+ // Claude Code only: Codex does not write hook outcomes to its session logs.
894
+ hooks: {
895
+ blocks: rank(hooks.blocks, 20), stopBlocks: rank(hooks.stopBlocks, 15),
896
+ errors: rank(hooks.errors, 15), timeouts: rank(hooks.timeouts, 15),
897
+ daily: dates.map((d) => ({ date: d, ...hookDaily[d] })),
898
+ },
790
899
  };
791
900
  }
792
901
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.54.0",
3
+ "version": "3.55.0",
4
4
  "description": "One command install for the great_cto Claude Code plugin. Auto-detects your stack, picks the right archetype, bootstraps PROJECT.md.",
5
5
  "keywords": [
6
6
  "claude-code",