great-cto 3.54.0 → 3.56.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.
|
|
4
|
+
"version": "3.56.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 {
|
|
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
|
-
|
|
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)}
|
|
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,93 @@ 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
|
+
const nv = a.noVerdictTotal || 0;
|
|
6300
|
+
const why = Object.entries(a.noVerdict || {}).map(([k, n]) => `${n} ${k === 'reported' ? 'finished without recording one' : k === 'cut-off' ? 'cut off mid-run' : 'ended, shape unknown'}`).join(', ');
|
|
6301
|
+
const noVerdict = nv ? `<span title="${esc(why)}">${nv}</span>` : '0';
|
|
6302
|
+
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">${noVerdict}</td><td class="num"${gap}>${disp == null ? '—' : disp}</td></tr>`;
|
|
6303
|
+
}).join('');
|
|
6304
|
+
const notes = [];
|
|
6305
|
+
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.`);
|
|
6306
|
+
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.`);
|
|
6307
|
+
if (A.unreadable) notes.push(`${A.unreadable} verdict files could not be read.`);
|
|
6308
|
+
return `<section class="usage-card" data-outcomes="agents">${head}<div class="usage-table-wrap"><table class="usage-table">
|
|
6309
|
+
<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="Runs the completion check saw end with no verdict — hover a number for how they ended">No verdict</th><th class="num" title="Agent dispatches seen in Claude Code's logs in the same period">Dispatched</th></tr></thead>
|
|
6310
|
+
<tbody>${rows}</tbody></table></div>
|
|
6311
|
+
<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. No verdict counts runs that ended without one, as the completion check recorded them (from 3.56).${notes.length ? ` ${notes.join(' ')}` : ''}</p></section>`;
|
|
6312
|
+
}
|
|
6313
|
+
|
|
6314
|
+
function usageFindingsCard(d) {
|
|
6315
|
+
const F = d.findings;
|
|
6316
|
+
const head = `<h3><span>Findings by priority</span><span class="muted">Beads bugs — reviewers file every P0/P1</span></h3>`;
|
|
6317
|
+
if (!F) return `<section class="usage-card" data-outcomes="findings">${head}<p class="usage-note">Not read.</p></section>`;
|
|
6318
|
+
const tiles = ['P0', 'P1', 'P2', 'P3'].map((p) => {
|
|
6319
|
+
const hot = (p === 'P0' || p === 'P1') && F.openNow[p] > 0;
|
|
6320
|
+
const close = F.daysToClose[p];
|
|
6321
|
+
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>`;
|
|
6322
|
+
}).join('');
|
|
6323
|
+
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('');
|
|
6324
|
+
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>';
|
|
6325
|
+
const unread = (F.unread || []).length
|
|
6326
|
+
? `<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>` : '';
|
|
6327
|
+
return `<section class="usage-card" data-outcomes="findings">${head}<div class="usage-prios">${tiles}</div>${table}${unread}</section>`;
|
|
6328
|
+
}
|
|
6329
|
+
|
|
6330
|
+
function usageHooksCards(d) {
|
|
6331
|
+
const H = d.hooks;
|
|
6332
|
+
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>';
|
|
6333
|
+
if (!H) return '';
|
|
6334
|
+
const total = (rows) => (rows || []).reduce((n, r) => n + r.n, 0);
|
|
6335
|
+
return `<div class="usage-lists">
|
|
6336
|
+
${usageList(`Guards refused a call · ${total(H.blocks)}`, H.blocks, 'No guard refused a tool call in this period.')}
|
|
6337
|
+
${usageList(`Stop hooks sent the turn back · ${total(H.stopBlocks)}`, H.stopBlocks, 'No Stop hook sent a turn back.')}
|
|
6338
|
+
${usageList(`Hooks that failed · ${total(H.errors)}`, H.errors, 'No hook failed in this period.')}
|
|
6339
|
+
${usageList(`Hooks that timed out · ${total(H.timeouts)}`, H.timeouts, 'No hook timed out in this period.')}
|
|
6340
|
+
</div>
|
|
6341
|
+
<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>`;
|
|
6342
|
+
}
|
|
6343
|
+
|
|
6245
6344
|
function renderUsage(d) {
|
|
6246
6345
|
const body = document.getElementById('usage-body');
|
|
6247
6346
|
if (!body) return;
|
|
@@ -6265,6 +6364,10 @@ function renderUsage(d) {
|
|
|
6265
6364
|
${usageList('Agents', l.agents, 'No subagents were dispatched in this window.')}
|
|
6266
6365
|
${usageList('MCP servers', l.mcp, 'No MCP tools were called in this window.')}
|
|
6267
6366
|
</div>
|
|
6367
|
+
<h2 class="mp-section-label">Agents and reviews</h2>
|
|
6368
|
+
<div id="usage-outcomes" class="usage-note" role="status">Reading every project's verdicts and Beads.</div>
|
|
6369
|
+
<h2 class="mp-section-label">Guards and hooks</h2>
|
|
6370
|
+
${usageHooksCards(d)}
|
|
6268
6371
|
<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
6372
|
}
|
|
6270
6373
|
|
|
@@ -0,0 +1,219 @@
|
|
|
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] = blankAgent(agent));
|
|
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
|
+
// How runs ENDED, from the completion hook's agent-stop events: a run that
|
|
100
|
+
// recorded nothing leaves no verdict line, so only these can count it.
|
|
101
|
+
for (const p of projects) {
|
|
102
|
+
let text = '';
|
|
103
|
+
try { text = fs.readFileSync(path.join(p.path, '.great_cto', 'events.jsonl'), 'utf8'); } catch { continue; }
|
|
104
|
+
for (const line of text.split('\n')) {
|
|
105
|
+
if (!line.includes('"agent-stop"') || !line.includes('no-verdict')) continue;
|
|
106
|
+
let e;
|
|
107
|
+
try { e = JSON.parse(line); } catch { continue; }
|
|
108
|
+
const t = Date.parse(e.ts);
|
|
109
|
+
if (!Number.isFinite(t) || t < from || t > now || !/^no-verdict-/.test(e.outcome || '')) continue;
|
|
110
|
+
const agent = verdictAgent(e.agent);
|
|
111
|
+
if (!agent || (known.size && !known.has(agent))) continue;
|
|
112
|
+
const a = agents[agent] || (agents[agent] = blankAgent(agent));
|
|
113
|
+
const why = e.outcome.slice('no-verdict-'.length);
|
|
114
|
+
a.noVerdict[why] = (a.noVerdict[why] || 0) + 1;
|
|
115
|
+
a.noVerdictTotal++;
|
|
116
|
+
a.projects.add(p.name);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const rows = Object.values(agents).map((a) => ({
|
|
120
|
+
...a,
|
|
121
|
+
projects: a.projects.size,
|
|
122
|
+
// Of the runs that reached a conclusion, how many stopped the pipeline.
|
|
123
|
+
stopRate: a.pass + a.stopped + a.failed ? (a.stopped + a.failed) / (a.pass + a.stopped + a.failed) : null,
|
|
124
|
+
})).sort((x, y) => (y.runs + y.noVerdictTotal) - (x.runs + x.noVerdictTotal));
|
|
125
|
+
return { agents: rows, other: { runs: other.runs, names: [...other.names].sort().slice(0, 20) }, unreadable };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function blankAgent(agent) {
|
|
129
|
+
return { agent, runs: 0, pass: 0, stopped: 0, failed: 0, skipped: 0, unknown: 0, needImplementer: 0, needDecision: 0, noVerdict: {}, noVerdictTotal: 0, last: null, projects: new Set() };
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Run `bd` in a project; resolves to parsed JSON or a reason it could not. */
|
|
133
|
+
function bdJson(cwd, args, { timeoutMs = 20000, bin = 'bd' } = {}) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
execFile(bin, args, { cwd, timeout: timeoutMs, maxBuffer: 64 * 1024 * 1024, env: { ...process.env, BD_NO_DAEMON: '1' } }, (err, stdout, stderr) => {
|
|
136
|
+
if (err) {
|
|
137
|
+
if (err.code === 'ENOENT') return resolve({ ok: false, why: 'bd is not installed' });
|
|
138
|
+
if (err.killed) return resolve({ ok: false, why: `bd took longer than ${timeoutMs / 1000}s` });
|
|
139
|
+
// bd prints warnings first (".beads has permissions 0750"); the reason is the error line.
|
|
140
|
+
const lines = String(stderr || '').split('\n').map((x) => x.trim()).filter(Boolean);
|
|
141
|
+
const said = lines.find((x) => /^error/i.test(x)) || lines[lines.length - 1];
|
|
142
|
+
return resolve({ ok: false, why: (said || String(err.message || err).split('\n')[0]).split(os.homedir()).join('~').slice(0, 160) });
|
|
143
|
+
}
|
|
144
|
+
try { return resolve({ ok: true, data: JSON.parse(stdout || '[]') }); } catch { return resolve({ ok: false, why: 'bd did not answer JSON' }); }
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const PRIOS = ['P0', 'P1', 'P2', 'P3'];
|
|
150
|
+
const prio = (n) => (Number.isInteger(n) && n >= 0 && n <= 3 ? `P${n}` : 'P?');
|
|
151
|
+
|
|
152
|
+
function median(xs) {
|
|
153
|
+
if (!xs.length) return null;
|
|
154
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
155
|
+
const m = Math.floor(s.length / 2);
|
|
156
|
+
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Bugs by priority across the projects that keep Beads.
|
|
161
|
+
* @param {{projects:{name:string,path:string}[], days?:number, now?:number, list?:(cwd:string)=>Promise<{ok:boolean,data?:object[],why?:string}>}} opts
|
|
162
|
+
*/
|
|
163
|
+
export async function bugFindings({ projects, days = 30, now = Date.now(), list = (cwd) => bdJson(cwd, ['list', '--json', '--type', 'bug', '--all']) }) {
|
|
164
|
+
const from = now - days * DAY;
|
|
165
|
+
const blank = () => Object.fromEntries([...PRIOS, 'P?'].map((p) => [p, 0]));
|
|
166
|
+
const filed = blank();
|
|
167
|
+
const openNow = blank();
|
|
168
|
+
const closedIn = blank();
|
|
169
|
+
const toClose = Object.fromEntries([...PRIOS, 'P?'].map((p) => [p, []]));
|
|
170
|
+
const daily = {};
|
|
171
|
+
const perProject = [];
|
|
172
|
+
const unread = [];
|
|
173
|
+
for (const p of projects) {
|
|
174
|
+
if (!fs.existsSync(path.join(p.path, '.beads'))) continue;
|
|
175
|
+
const r = await list(p.path);
|
|
176
|
+
if (!r.ok) { unread.push({ project: p.name, why: r.why }); continue; }
|
|
177
|
+
const row = { project: p.name, filed: blank(), open: blank() };
|
|
178
|
+
for (const b of Array.isArray(r.data) ? r.data : []) {
|
|
179
|
+
const pr = prio(b.priority);
|
|
180
|
+
const created = Date.parse(b.created_at);
|
|
181
|
+
const closed = b.closed_at ? Date.parse(b.closed_at) : NaN;
|
|
182
|
+
if (Number.isFinite(created) && created >= from && created <= now) {
|
|
183
|
+
filed[pr]++; row.filed[pr]++;
|
|
184
|
+
const d = dayOf(created);
|
|
185
|
+
const slot = daily[d] || (daily[d] = blank());
|
|
186
|
+
slot[pr]++;
|
|
187
|
+
}
|
|
188
|
+
if (b.status !== 'closed' && b.status !== 'tombstone') { openNow[pr]++; row.open[pr]++; }
|
|
189
|
+
if (Number.isFinite(closed) && closed >= from && closed <= now) {
|
|
190
|
+
closedIn[pr]++;
|
|
191
|
+
if (Number.isFinite(created) && closed >= created) toClose[pr].push((closed - created) / DAY);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
const any = (o) => Object.values(o).some(Boolean);
|
|
195
|
+
if (any(row.filed) || any(row.open)) perProject.push(row);
|
|
196
|
+
}
|
|
197
|
+
perProject.sort((a, b) => (b.open.P0 - a.open.P0) || (b.open.P1 - a.open.P1) || (sum(b.filed) - sum(a.filed)));
|
|
198
|
+
return {
|
|
199
|
+
filed, openNow, closedIn,
|
|
200
|
+
daysToClose: Object.fromEntries(Object.entries(toClose).map(([k, xs]) => [k, xs.length ? { median: median(xs), n: xs.length } : null])),
|
|
201
|
+
daily,
|
|
202
|
+
projects: perProject,
|
|
203
|
+
unread,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const sum = (o) => Object.values(o).reduce((a, n) => a + n, 0);
|
|
208
|
+
|
|
209
|
+
/** Both, for the board. */
|
|
210
|
+
export async function outcomes({ days = 30, now = Date.now(), roster = [], projects = registeredProjects(), globalDir, list } = {}) {
|
|
211
|
+
const findings = await bugFindings({ projects, days, now, list });
|
|
212
|
+
return {
|
|
213
|
+
state: 'counted',
|
|
214
|
+
window: { days, from: dayOf(now - (days - 1) * DAY), to: dayOf(now) },
|
|
215
|
+
projects: projects.length,
|
|
216
|
+
agents: agentOutcomes({ projects, globalDir, days, now, roster }),
|
|
217
|
+
findings,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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