great-cto 3.53.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.53.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();
@@ -2244,6 +2244,11 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2244
2244
  font-size: var(--fs-body);
2245
2245
  }
2246
2246
  .judge-status[hidden] { display: none; }
2247
+ /* The "retry" link appears only when the live key check got no answer — a
2248
+ degraded state, so no screenshot ever carried it, and it rendered in the
2249
+ browser's default blue until the on-system check met it on 2026-10-06. */
2250
+ .judge-status a { color: var(--accent-text); text-decoration: underline; text-underline-offset: 2px; }
2251
+ .judge-status a:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }
2247
2252
 
2248
2253
  /* What the judge CONCLUDED, under whether it is connected.
2249
2254
  The scores existed for weeks and no page read them: /api/scores had zero
@@ -2404,7 +2409,9 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2404
2409
  .usage-sub { font-size: var(--fs-caption); color: var(--text3); line-height: 1.5; }
2405
2410
  .usage-note { font-size: var(--fs-small); color: var(--text2); line-height: 1.55; }
2406
2411
  .usage-chart svg { display: block; width: 100%; height: 120px; }
2407
- .usage-axis { display: flex; justify-content: space-between; font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); margin-top: 4px; }
2412
+ .usage-axis { display: flex; justify-content: space-between; gap: 8px; font-family: var(--mono); font-size: var(--fs-caption); color: var(--text3); margin-top: 4px; }
2413
+ .usage-axis span { white-space: nowrap; }
2414
+ .usage-axis span:nth-child(2) { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
2408
2415
  .usage-legend { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: var(--fs-caption); color: var(--text2); margin-top: 8px; }
2409
2416
  .usage-legend i { display: inline-block; width: 10px; height: 10px; margin-right: 5px; vertical-align: -1px; }
2410
2417
  .usage-table-wrap { overflow-x: auto; }
@@ -2421,11 +2428,31 @@ h1, h2, h3, h4 { font-weight: 600; font-family: var(--sans); }
2421
2428
  .usage-lists { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr)); gap: 12px; margin-bottom: 20px; }
2422
2429
  .usage-list { list-style: none; margin: 0; padding: 0; }
2423
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; }
2424
- .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; }
2425
2435
  .usage-list li .n { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text2); }
2426
2436
  .usage-list li .bar { grid-column: 1 / -1; height: 3px; background: var(--bg-muted); }
2427
2437
  .usage-list li .bar > span { display: block; height: 100%; background: var(--accent); opacity: 0.7; }
2428
- @media (max-width: 767px) { .usage-title { max-width: 160px; } .usage-chip { min-height: 44px; } }
2438
+ .usage-line svg { display: block; width: 100%; height: 110px; overflow: visible; }
2439
+ .usage-line .ln { fill: none; stroke: var(--accent); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
2440
+ .usage-line .ceil { stroke: var(--status-progress); stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; }
2441
+ .usage-line .grid { stroke: var(--border-strong); stroke-width: 1; vector-effect: non-scaling-stroke; }
2442
+ .usage-kinds { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: var(--fs-small); color: var(--text2); margin: 10px 0 4px; }
2443
+ .usage-kinds b { font-family: var(--mono); font-variant-numeric: tabular-nums; color: var(--text); font-weight: 500; margin-right: 4px; }
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; }
2445
+ .usage-pace { font-size: var(--fs-small); color: var(--text); margin: 4px 0 8px; }
2446
+ .usage-pace.bad { color: var(--status-blocked-ink); }
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)); } }
2429
2456
  .mp-section-label {
2430
2457
  font-family: var(--mono); font-size: var(--fs-caption);
2431
2458
  letter-spacing: 0.1em; text-transform: uppercase;
@@ -6007,16 +6034,18 @@ async function loadUsage(days) {
6007
6034
  }
6008
6035
  usageData = d;
6009
6036
  renderUsage(d);
6037
+ loadOutcomes();
6010
6038
  }
6011
6039
 
6012
6040
  function setUsageHost(host) {
6013
6041
  usageHost = host;
6014
- if (usageData) renderUsage(usageData);
6042
+ if (usageData) { renderUsage(usageData); loadOutcomes(); }
6015
6043
  }
6016
6044
 
6017
6045
  function usageLimitWindow(minutes) {
6018
6046
  if (minutes === 10080) return 'Weekly limit';
6019
6047
  if (minutes === 300) return '5-hour limit';
6048
+ if (minutes === 43200) return 'Monthly limit';
6020
6049
  return minutes ? `${minutes}-minute limit` : 'Limit';
6021
6050
  }
6022
6051
 
@@ -6137,10 +6166,178 @@ function usageModels(d) {
6137
6166
  function usageList(title, rows, empty) {
6138
6167
  if (!rows || !rows.length) return `<section class="usage-card"><h3><span>${title}</span></h3><p class="usage-note">${empty}</p></section>`;
6139
6168
  const max = Math.max(...rows.map((r) => r.n));
6140
- 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('');
6141
6170
  return `<section class="usage-card"><h3><span>${title}</span></h3><ul class="usage-list">${items}</ul></section>`;
6142
6171
  }
6143
6172
 
6173
+ // ── Limits (great_cto-egkw stage 2) ────────────────────────────────────────
6174
+ const USAGE_LIMIT_KIND = { session: '5-hour session', weekly: 'weekly', spend: 'monthly spend', model: 'per-model', credits: 'usage credits', other: 'other' };
6175
+ const USAGE_LIMIT_COLOR = { session: 'var(--status-blocked)', weekly: 'var(--status-progress)', spend: 'var(--status-gate)', model: 'var(--text2)', credits: 'var(--text2)', other: 'var(--text3)' };
6176
+ const fmtWhen = (ms) => (ms ? fmtDT(new Date(ms).toISOString()) : '—');
6177
+ function fmtHours(ms) {
6178
+ const h = ms / 3600000;
6179
+ return h >= 48 ? `${Math.round(h / 24)} days` : `${Math.max(1, Math.round(h))} h`;
6180
+ }
6181
+
6182
+ /** A line over the period: points [ms, value]; marks [{t, color, title}] drawn as ticks. */
6183
+ function usageLineSvg({ from, to, points, max, marks = [], ceiling = null, label }) {
6184
+ const W = 1000; const H = 100;
6185
+ const x = (t) => (((t - from) / Math.max(1, to - from)) * W).toFixed(1);
6186
+ const y = (v) => (H - (Math.min(v, max) / max) * (H - 4)).toFixed(1);
6187
+ const path = points.map((p, i) => `${i ? 'L' : 'M'}${x(p[0])},${y(p[1])}`).join('');
6188
+ const ticks = marks.map((m) => `<g><title>${esc(m.title)}</title><line x1="${x(m.t)}" x2="${x(m.t)}" y1="0" y2="${H}" stroke="${m.color}" stroke-width="2" vector-effect="non-scaling-stroke" opacity="0.8"></line></g>`).join('');
6189
+ const ceil = ceiling != null ? `<line class="ceil" x1="0" x2="${W}" y1="${y(ceiling)}" y2="${y(ceiling)}"></line>` : '';
6190
+ return `<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="none" role="img" aria-label="${esc(label)}">
6191
+ <line class="grid" x1="0" x2="${W}" y1="${H}" y2="${H}"></line>${ceil}${ticks}<path class="ln" d="${path}"></path></svg>`;
6192
+ }
6193
+
6194
+ function usageClaudeLimits(d) {
6195
+ const head = `<h3><span>Claude Code limits</span><span class="muted">from refusals — no plan percentage is recorded</span></h3>`;
6196
+ if (!d.seen || !d.seen.claude) return `<section class="usage-card" data-limits="claude">${head}<p class="usage-note">No Claude Code logs on this machine.</p></section>`;
6197
+ const L = d.limits && d.limits.claude;
6198
+ if (!L) return `<section class="usage-card" data-limits="claude">${head}<p class="usage-note">Not computed.</p></section>`;
6199
+ const hits = (d.hosts.claude && d.hosts.claude.limitHits) || {};
6200
+ const stat = (v, label) => `<div class="usage-stat"><b>${v}</b><span>${label}</span></div>`;
6201
+ const ceilLine = (c, span) => c
6202
+ ? `Refused at about <b>${esc(fmtUsdEq(c.median))}</b> in ${span} <span class="muted">(median of ${c.n} refused hour${c.n === 1 ? '' : 's'}, ${esc(fmtUsdEq(c.min))}–${esc(fmtUsdEq(c.max))})</span>`
6203
+ : null;
6204
+ const ceilings = [ceilLine(L.ceilings.session, '5 hours'), ceilLine(L.ceilings.weekly, '7 days')].filter(Boolean);
6205
+ const kinds = Object.keys(USAGE_LIMIT_KIND).filter((k) => hits[k]).map((k) => `<span><b>${hits[k]}</b>${USAGE_LIMIT_KIND[k]}</span>`).join('');
6206
+ const s = L.series || { usd5h: [] };
6207
+ const to = s.from + (s.usd5h.length - 1) * s.stepMs;
6208
+ const pts = s.usd5h.map((v, i) => [s.from + i * s.stepMs, v]);
6209
+ const max = Math.max(1, ...s.usd5h, L.ceilings.session ? L.ceilings.session.max : 0);
6210
+ const marks = (L.hits || []).filter((h) => h.kind !== 'server').map((h) => ({ t: h.t, color: USAGE_LIMIT_COLOR[h.kind] || 'var(--text3)', title: `${fmtWhen(h.t)} — refused by the ${USAGE_LIMIT_KIND[h.kind] || h.kind} limit ×${h.n}` }));
6211
+ const chart = pts.length > 1 ? `<div class="usage-line">${usageLineSvg({ from: s.from, to, points: pts, max, marks, ceiling: L.ceilings.session ? L.ceilings.session.median : null, label: 'Claude Code spend in the trailing 5 hours, with refusals marked' })}</div>
6212
+ <div class="usage-axis"><span>${esc(d.window.from)}</span><span>$ in the trailing 5 hours · peak ${esc(fmtUsdEq(max))}</span><span>${esc(d.window.to)}</span></div>` : '';
6213
+ const last = L.lastHit ? `<p class="usage-quote" title="${esc(fmtDate(L.lastHit.at))}">${esc(L.lastHit.text)} <span class="muted">— ${esc(relTime(L.lastHit.at))} ago</span></p>` : '';
6214
+ return `<section class="usage-card" data-limits="claude">${head}
6215
+ <div class="usage-stats">${stat(esc(fmtUsdEq(L.fiveHour.usd)), 'last 5 hours, $ list')}${stat(esc(fmtUsdEq(L.sevenDay.usd)), 'last 7 days, $ list')}${stat(esc(String(hits.plan || 0)), `refusals in ${d.window.days} days`)}</div>
6216
+ ${kinds ? `<div class="usage-kinds">${kinds}</div>` : `<p class="usage-note">No request was refused by a limit in this period.</p>`}
6217
+ ${ceilings.length ? `<p class="usage-note">${ceilings.join('<br>')}. <span class="muted">An estimate from what had been spent when Claude Code refused — not a published limit.</span></p>` : ''}
6218
+ ${chart}
6219
+ ${hits.server ? `<p class="usage-sub">${hits.server} server-side throttles (not your limit) are not marked.</p>` : ''}
6220
+ ${last}
6221
+ </section>`;
6222
+ }
6223
+
6224
+ function usageCodexLane(name, lane, d) {
6225
+ const title = usageLimitWindow(lane.minutes);
6226
+ const P = lane.projection;
6227
+ const lastRead = lane.series.length ? lane.series[lane.series.length - 1][0] : null;
6228
+ let pace = `<p class="usage-pace muted">No open window${lastRead ? ` — the last reading of it is from ${esc(fmtWhen(lastRead))}; the plan may no longer have it` : ''}.</p>`;
6229
+ if (P && P.state === 'full') pace = `<p class="usage-pace bad">Full — resets ${esc(fmtWhen(P.resets))}.</p>`;
6230
+ else if (P && P.state === 'will-fill') pace = `<p class="usage-pace bad">${P.used}% used. At this pace it is full by ${esc(fmtWhen(P.fullBy))} — ${esc(fmtHours(P.resets - P.fullBy))} before the reset.</p>`;
6231
+ else if (P && P.state === 'fits') pace = `<p class="usage-pace">${P.used}% used. At this pace about ${Math.round(P.atReset)}% by the reset, ${esc(fmtWhen(P.resets))}.</p>`;
6232
+ const from = Date.parse(`${d.window.from}T00:00:00`);
6233
+ const to = Date.now();
6234
+ const pts = [];
6235
+ for (const [t, v] of lane.series) { if (pts.length) pts.push([t, pts[pts.length - 1][1]]); pts.push([t, v]); }
6236
+ const marks = lane.windows.filter((w) => w.fullAt && w.fullAt >= from).map((w) => ({ t: w.fullAt, color: 'var(--status-blocked)', title: `filled ${fmtWhen(w.fullAt)}, ${fmtHours(w.resets - w.fullAt)} before its reset` }));
6237
+ const chart = pts.length > 1 ? `<div class="usage-line">${usageLineSvg({ from, to, points: pts, max: 100, marks, label: `Codex ${title} percent used over the period` })}</div>
6238
+ <div class="usage-axis"><span>${esc(d.window.from)}</span><span>% used · red = filled</span><span>${esc(d.window.to)}</span></div>` : '';
6239
+ const touched = lane.windows.filter((w) => w.peak > 0);
6240
+ const early = lane.windows.filter((w) => w.fullAt).map((w) => w.resets - w.fullAt);
6241
+ const summary = touched.length
6242
+ ? `${lane.filled} of ${touched.length} used windows filled${early.length ? `, on median ${esc(fmtHours(early.sort((a, b) => a - b)[Math.floor(early.length / 2)]))} before the reset` : ''}.`
6243
+ : 'No window was used in this period.';
6244
+ return `<div class="usage-limit" data-lane="${esc(name)}"><div class="usage-limit-head"><span>${esc(title)}</span></div>${pace}${chart}<p class="usage-sub">${summary}</p></div>`;
6245
+ }
6246
+
6247
+ function usageCodexLimits(d) {
6248
+ const head = `<h3><span>Codex limits</span><span class="muted">as Codex reported them</span></h3>`;
6249
+ if (!d.seen || !d.seen.codex) return `<section class="usage-card" data-limits="codex">${head}<p class="usage-note">No Codex logs on this machine.</p></section>`;
6250
+ const D = d.limits && d.limits.codex && d.limits.codex.detail;
6251
+ if (!D) return `<section class="usage-card" data-limits="codex">${head}<p class="usage-note">No Codex session reported its plan window in this period.</p></section>`;
6252
+ const order = ['fiveHour', 'weekly', ...Object.keys(D).filter((k) => k !== 'fiveHour' && k !== 'weekly')];
6253
+ const lanes = order.filter((k) => D[k]).map((k) => usageCodexLane(k, D[k], d)).join('');
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>`;
6255
+ }
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
+
6144
6341
  function renderUsage(d) {
6145
6342
  const body = document.getElementById('usage-body');
6146
6343
  if (!body) return;
@@ -6149,6 +6346,8 @@ function renderUsage(d) {
6149
6346
  const hostBtn = (h) => `<button type="button" class="usage-chip${usageHost === h ? ' active' : ''}" aria-pressed="${usageHost === h}" onclick="setUsageHost('${h}')">${USAGE_HOSTS[h]}</button>`;
6150
6347
  body.innerHTML = `
6151
6348
  <div class="usage-grid">${usageHostCard(d, 'claude')}${usageHostCard(d, 'codex')}</div>
6349
+ <h2 class="mp-section-label">Limits</h2>
6350
+ <div class="usage-grid">${usageClaudeLimits(d)}${usageCodexLimits(d)}</div>
6152
6351
  <h2 class="mp-section-label">By day, by kind of work</h2>
6153
6352
  <div class="usage-grid">${usageDayChart(d, 'claude')}${usageDayChart(d, 'codex')}</div>
6154
6353
  <h2 class="mp-section-label">Heaviest conversations</h2>
@@ -6162,6 +6361,10 @@ function renderUsage(d) {
6162
6361
  ${usageList('Agents', l.agents, 'No subagents were dispatched in this window.')}
6163
6362
  ${usageList('MCP servers', l.mcp, 'No MCP tools were called in this window.')}
6164
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)}
6165
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>`;
6166
6369
  }
6167
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,19 +40,29 @@ 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 = 1;
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
+ const SERIES_MAX = 4000; // Codex limit readings kept per file
46
47
  const CHUNK = 1 << 20;
47
48
 
48
49
  const home = () => os.homedir();
49
50
  export const DEFAULT_CLAUDE_DIR = () => path.join(home(), '.claude', 'projects');
50
51
  export const DEFAULT_CODEX_DIRS = () => [path.join(home(), '.codex', 'sessions'), path.join(home(), '.codex', 'archived_sessions')];
51
52
  export const DEFAULT_CODEX_TITLES = () => path.join(home(), '.codex', 'session_index.jsonl');
52
- export const DEFAULT_CACHE_FILE = () => path.join(home(), '.great_cto', 'session-usage-index.json');
53
+ // The version is in the name: a board started on an older release keeps running
54
+ // until restarted, and two versions sharing one file would rebuild it from
55
+ // scratch on every pass, each throwing the other's away.
56
+ export const DEFAULT_CACHE_FILE = () => path.join(home(), '.great_cto', `session-usage-index.v${INDEX_VERSION}.json`);
53
57
 
54
58
  const short = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
55
59
 
60
+ /** Local hour (00–23) of an ISO timestamp. */
61
+ export function hourOf(ts) {
62
+ const d = new Date(ts);
63
+ return Number.isNaN(d.getTime()) ? null : String(d.getHours()).padStart(2, '0');
64
+ }
65
+
56
66
  /** Local calendar day of an ISO timestamp — the operator's day, not UTC's. */
57
67
  export function dayOf(ts) {
58
68
  const d = new Date(ts);
@@ -107,7 +117,29 @@ function isDir(p) { try { return fs.statSync(p).isDirectory(); } catch { return
107
117
  // ── shared accumulation ───────────────────────────────────────────────────────
108
118
 
109
119
  function emptyDay() {
110
- return { in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, models: {}, tools: {}, skills: {}, agents: {}, mcp: {}, lim: { plan: 0, server: 0 } };
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: {} };
124
+ }
125
+
126
+ /** The hour inside a day: tokens per model as [in, out, cacheRead, cacheWrite5m, cacheWrite1h], and limit refusals. */
127
+ function hourBucket(b, ts) {
128
+ const hh = hourOf(ts);
129
+ if (!hh) return null;
130
+ return b.h[hh] || (b.h[hh] = { m: {}, lim: {} });
131
+ }
132
+
133
+ function addUsage(entry, ts, model, t) {
134
+ const b = dayBucket(entry, ts);
135
+ if (!b) return null;
136
+ addTokens(b, model, t);
137
+ const hb = hourBucket(b, ts);
138
+ if (hb) {
139
+ const v = hb.m[model] || (hb.m[model] = [0, 0, 0, 0, 0]);
140
+ v[0] += t.in; v[1] += t.out; v[2] += t.cr; v[3] += t.cw; v[4] += t.cwh;
141
+ }
142
+ return b;
111
143
  }
112
144
 
113
145
  function dayBucket(entry, ts) {
@@ -159,7 +191,22 @@ function claudeFileMeta(file) {
159
191
  return { session: base, parent: null, fn: null, agentType: null };
160
192
  }
161
193
 
162
- const PLAN_LIMIT_TEXT = /not your usage limit/i;
194
+ /**
195
+ * Which limit refused a request, from the words Claude Code shows. Seen on this
196
+ * machine: "session limit" (the 5-hour window), "weekly limit", "monthly spend
197
+ * limit", "reached your <model> limit", "out of usage credits", and the server's
198
+ * own throttle, which says outright that it is "not your usage limit".
199
+ */
200
+ export function limitKind(text) {
201
+ const t = String(text || '');
202
+ if (/not your usage limit/i.test(t)) return 'server';
203
+ if (/monthly spend limit/i.test(t)) return 'spend';
204
+ if (/out of usage credits/i.test(t)) return 'credits';
205
+ if (/session limit/i.test(t)) return 'session';
206
+ if (/weekly limit/i.test(t)) return 'weekly';
207
+ if (/reached your .{1,40} limit/i.test(t)) return 'model';
208
+ return 'other';
209
+ }
163
210
 
164
211
  /** `great-cto:senior-dev` and `senior-dev` are one agent; `feature-dev:code-reviewer` is not ours. */
165
212
  export function agentName(raw) {
@@ -167,17 +214,87 @@ export function agentName(raw) {
167
214
  return raw.replace(/^great[-_]cto:/, '');
168
215
  }
169
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
+
170
265
  export function claudeLine(entry, line) {
171
266
  // Most bytes in a transcript are tool RESULTS in user lines. They carry no
172
- // 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.
173
269
  const isAssistant = line.includes('"type":"assistant"');
174
270
  const isTitle = line.includes('"custom-title"');
175
271
  const wantsPrompt = !entry.meta.prompt && !entry.meta.title && line.includes('"type":"user"');
176
- 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;
177
275
  let r;
178
276
  try { r = JSON.parse(line); } catch { return; }
179
277
  const meta = entry.meta;
180
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
+
181
298
  if (r.type === 'custom-title' && r.customTitle) { meta.title = String(r.customTitle).slice(0, 160); return; }
182
299
  if (r.type === 'user') {
183
300
  if (r.isCompactSummary || r.isMeta) return;
@@ -201,7 +318,12 @@ export function claudeLine(entry, line) {
201
318
  if (r.isApiErrorMessage && r.error === 'rate_limit') {
202
319
  const c = r.message?.content;
203
320
  const text = Array.isArray(c) ? c.map((x) => x?.text || '').join(' ') : String(c || '');
204
- if (PLAN_LIMIT_TEXT.test(text)) b.lim.server += 1; else b.lim.plan += 1;
321
+ const kind = limitKind(text);
322
+ bump(b.lim, kind);
323
+ const hb = hourBucket(b, ts);
324
+ if (hb) bump(hb.lim, kind);
325
+ // The words carry the reset time ("resets Sep 12 at 3pm"); the latest is kept to show.
326
+ if (!meta.lastHit || ts > meta.lastHit.at) meta.lastHit = { at: ts, kind, text: text.replace(/\s+/g, ' ').trim().slice(0, 200) };
205
327
  return;
206
328
  }
207
329
 
@@ -228,7 +350,7 @@ export function claudeLine(entry, line) {
228
350
  cwh,
229
351
  rs: u.output_tokens_details?.thinking_tokens || 0,
230
352
  };
231
- addTokens(b, u.speed === 'fast' ? `${model}@fast` : model, t);
353
+ addUsage(entry, ts, u.speed === 'fast' ? `${model}@fast` : model, t);
232
354
  // Older Claude Code wrote subagent turns into the parent's own file.
233
355
  if (r.isSidechain && meta.fn !== 'subagent') b.side = (b.side || 0) + t.in + t.out + t.cr + t.cw + t.cwh;
234
356
  }
@@ -292,10 +414,8 @@ export function codexLine(entry, line) {
292
414
  if (id && st.ids.includes(id)) return;
293
415
  if (id) remember(st.ids, id);
294
416
  st.records = true;
295
- const b = dayBucket(entry, ts);
296
- if (!b) return;
417
+ if (!addUsage(entry, ts, st.model || 'unknown', codexUsage(p.usage))) return;
297
418
  touchSpan(meta, ts);
298
- addTokens(b, st.model || 'unknown', codexUsage(p.usage));
299
419
  return;
300
420
  }
301
421
 
@@ -308,6 +428,17 @@ export function codexLine(entry, line) {
308
428
  secondary: rl.secondary ? { used: rl.secondary.used_percent, minutes: rl.secondary.window_minutes, resets: rl.secondary.resets_at } : null,
309
429
  credits: rl.credits ? { has: !!rl.credits.has_credits, unlimited: !!rl.credits.unlimited, balance: rl.credits.balance ?? null } : null,
310
430
  };
431
+ // The window's history: one point per change, not per event — Codex
432
+ // repeats the same reading after every response.
433
+ const L = meta.limits;
434
+ const pt = [Date.parse(ts), L.primary?.used ?? null, L.primary?.minutes ?? null, L.primary?.resets ?? null,
435
+ L.secondary?.used ?? null, L.secondary?.minutes ?? null, L.secondary?.resets ?? null, L.reached ? 1 : 0];
436
+ const series = meta.series || (meta.series = []);
437
+ const last = series[series.length - 1];
438
+ if (Number.isFinite(pt[0]) && (!last || [1, 3, 4, 6, 7].some((i) => last[i] !== pt[i]))) {
439
+ series.push(pt);
440
+ if (series.length > SERIES_MAX) series.splice(0, series.length - SERIES_MAX);
441
+ }
311
442
  }
312
443
  // Older sessions have no token_usage_record. Their usage is the growth of
313
444
  // the cumulative total — token_count is emitted more than once per response,
@@ -324,10 +455,8 @@ export function codexLine(entry, line) {
324
455
  // A thread forked from another opens with a total and no breakdown — the
325
456
  // context it inherited, not a response. It is the base, never usage.
326
457
  if (!delta.input_tokens && !delta.output_tokens && !delta.cached_input_tokens) return;
327
- const b = dayBucket(entry, ts);
328
- if (!b) return;
458
+ if (!addUsage(entry, ts, st.model || 'unknown', codexUsage(delta))) return;
329
459
  touchSpan(meta, ts);
330
- addTokens(b, st.model || 'unknown', codexUsage(delta));
331
460
  }
332
461
  }
333
462
  }
@@ -385,6 +514,28 @@ function saveIndex(cacheFile, index) {
385
514
  fs.writeFileSync(tmp, JSON.stringify(index));
386
515
  fs.renameSync(tmp, cacheFile);
387
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;
388
539
  }
389
540
 
390
541
  /**
@@ -471,6 +622,131 @@ export function mcpLabel(server) {
471
622
  return UUID.test(server) ? `claude.ai connector ${server.slice(0, 8)}` : server;
472
623
  }
473
624
 
625
+ const HOUR = 3600000;
626
+
627
+ /** Start of the local hour for a local day + hour key. */
628
+ function hourStart(day, hh) {
629
+ const [y, m, d] = day.split('-').map(Number);
630
+ return new Date(y, m - 1, d, Number(hh)).getTime();
631
+ }
632
+
633
+ function median(xs) {
634
+ if (!xs.length) return null;
635
+ const s = [...xs].sort((a, b) => a - b);
636
+ const mid = Math.floor(s.length / 2);
637
+ return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
638
+ }
639
+
640
+ /**
641
+ * Claude Code's limits as far as its logs can say. The logs carry no plan
642
+ * percentage — only what was spent and when a request was refused, and by which
643
+ * limit. So this reports spend in the last 5 hours and 7 days, and, for every
644
+ * refusal, what had been spent in that window when it came: the ceiling as
645
+ * observed, an estimate with its count, never a stated limit.
646
+ */
647
+ function claudeLimits(hourly, { fromMs, now, lastHit }) {
648
+ const nowH = Math.floor(now / HOUR) * HOUR;
649
+ const startH = Math.floor(fromMs / HOUR) * HOUR;
650
+ const at = (t) => hourly.get(t) || { usd: 0, tokens: 0, lim: {} };
651
+ const roll = (t, n) => {
652
+ let usd = 0; let tokens = 0;
653
+ for (let i = 0; i < n; i++) { const x = at(t - i * HOUR); usd += x.usd; tokens += x.tokens; }
654
+ return { usd, tokens };
655
+ };
656
+ const series = [];
657
+ const hits = [];
658
+ const ceilings = { session: [], weekly: [] };
659
+ for (let t = startH; t <= nowH; t += HOUR) {
660
+ series.push(Number(roll(t, 5).usd.toFixed(4)));
661
+ const lim = at(t).lim;
662
+ for (const [kind, n] of Object.entries(lim)) {
663
+ hits.push({ t, kind, n });
664
+ // One reading per refused hour: ten retries against the same wall are one wall.
665
+ if (kind === 'session') ceilings.session.push(roll(t, 5).usd);
666
+ if (kind === 'weekly') ceilings.weekly.push(roll(t, 168).usd);
667
+ }
668
+ }
669
+ const est = (xs) => (xs.length ? { median: median(xs), min: Math.min(...xs), max: Math.max(...xs), n: xs.length } : null);
670
+ return {
671
+ fiveHour: roll(nowH, 5),
672
+ sevenDay: roll(nowH, 168),
673
+ series: { from: startH, stepMs: HOUR, usd5h: series },
674
+ hits,
675
+ ceilings: { session: est(ceilings.session), weekly: est(ceilings.weekly) },
676
+ lastHit: lastHit || null,
677
+ };
678
+ }
679
+
680
+ /** A Codex window by its length — the field that carries it moved from `secondary` to `primary` between plans. */
681
+ export function laneName(minutes) {
682
+ if (minutes === 10080) return 'weekly';
683
+ if (minutes === 300) return 'fiveHour';
684
+ return `${minutes}m`;
685
+ }
686
+
687
+ /**
688
+ * Codex's plan windows from its own readings: every window the period touched
689
+ * with its peak and when it filled, the readings to draw, and — for the window
690
+ * still open — where the current pace ends up by the reset.
691
+ *
692
+ * Windows are keyed by their length, not by `primary`/`secondary`: on one plan
693
+ * the 5-hour window was primary and the week secondary, on the next the week was
694
+ * primary. And `resets_at` drifts by seconds between readings of one window, so
695
+ * it is grouped to ten minutes — unrounded, one week read as forty windows.
696
+ */
697
+ function codexLimitDetail(points, { fromMs, now }) {
698
+ if (!points.length) return null;
699
+ const pts = [...points].sort((a, b) => a[0] - b[0]);
700
+ const lanes = {};
701
+ for (const p of pts) {
702
+ for (const [iu, im, ir] of [[1, 2, 3], [4, 5, 6]]) {
703
+ const used = p[iu]; const minutes = p[im]; const resets = p[ir];
704
+ if (used == null || !minutes || !resets) continue;
705
+ const resetMs = resets * 1000;
706
+ if (resetMs < fromMs) continue;
707
+ const lane = lanes[laneName(minutes)] || (lanes[laneName(minutes)] = { minutes, windows: new Map(), series: [] });
708
+ if (p[0] >= fromMs) {
709
+ const prev = lane.series[lane.series.length - 1];
710
+ if (!prev || prev[1] !== used) lane.series.push([p[0], used]);
711
+ }
712
+ const key = Math.round(resets / 600);
713
+ const w = lane.windows.get(key) || { start: resetMs - minutes * 60000, resets: resetMs, peak: 0, fullAt: null, last: null };
714
+ w.resets = Math.max(w.resets, resetMs);
715
+ w.peak = Math.max(w.peak, used);
716
+ if (used >= 100 && !w.fullAt) w.fullAt = p[0];
717
+ if (!w.last || p[0] >= w.last[0]) w.last = [p[0], used];
718
+ lane.windows.set(key, w);
719
+ }
720
+ }
721
+ const out = {};
722
+ for (const [name, lane] of Object.entries(lanes)) {
723
+ const list = [...lane.windows.values()].sort((a, b) => a.start - b.start);
724
+ // The open window is the one the LATEST reading belongs to. Codex has reset a
725
+ // week early more than once, so an older window can still look "open" on paper.
726
+ const latest = list.reduce((a, w) => (!a || (w.last && w.last[0] > a.last[0]) ? w : a), null);
727
+ const cur = latest && latest.resets > now ? latest : null;
728
+ let projection = null;
729
+ if (cur && cur.last) {
730
+ const [t, used] = cur.last;
731
+ if (used >= 100) projection = { state: 'full', resets: cur.resets, readAt: t };
732
+ else if (used > 0 && t > cur.start) {
733
+ const fullBy = cur.start + ((t - cur.start) * 100) / used;
734
+ projection = fullBy < cur.resets
735
+ ? { state: 'will-fill', fullBy, resets: cur.resets, readAt: t, used }
736
+ : { state: 'fits', atReset: (used * (cur.resets - cur.start)) / (t - cur.start), resets: cur.resets, readAt: t, used };
737
+ }
738
+ }
739
+ out[name] = {
740
+ minutes: lane.minutes,
741
+ windows: list.map((w) => ({ start: w.start, resets: w.resets, peak: w.peak, fullAt: w.fullAt })),
742
+ filled: list.filter((w) => w.fullAt).length,
743
+ series: lane.series,
744
+ projection,
745
+ };
746
+ }
747
+ return Object.keys(out).length ? out : null;
748
+ }
749
+
474
750
  /**
475
751
  * The window the board shows.
476
752
  * @param {object} index from scanUsage
@@ -483,15 +759,23 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
483
759
  const inWindow = new Set(dates);
484
760
  const from = dates[0];
485
761
 
486
- const blank = () => ({ in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, usd: 0, unpricedMsgs: 0, sessions: 0, byFn: {}, lim: { plan: 0, server: 0 } });
762
+ const blank = () => ({ in: 0, out: 0, cr: 0, cw: 0, cwh: 0, rs: 0, msgs: 0, usd: 0, unpricedMsgs: 0, sessions: 0, byFn: {}, lim: {} });
487
763
  const hosts = { claude: blank(), codex: blank() };
488
764
  const daily = Object.fromEntries(dates.map((d) => [d, { claude: { tokens: 0, usd: 0, byFn: {} }, codex: { tokens: 0, usd: 0, byFn: {} } }]));
489
765
  const models = {};
490
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 }]));
491
769
  const sessions = {};
492
770
  const unpriced = new Set();
493
771
  let codexLimits = null;
494
772
  let codexPlan = null;
773
+ const fromMs = hourStart(from, '00');
774
+ // Hours from a week before the period: a 7-day window ending on its first day needs them.
775
+ const hourlyFrom = dayOf(fromMs - 7 * 86400000);
776
+ const hourly = { claude: new Map(), codex: new Map() };
777
+ const codexPoints = [];
778
+ let lastHit = null;
495
779
 
496
780
  for (const e of Object.values(index.files || {})) {
497
781
  const host = e.host;
@@ -499,15 +783,36 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
499
783
  const lim = host === 'codex' ? e.meta?.limits : null;
500
784
  if (lim && (!codexLimits || lim.at > codexLimits.at)) codexLimits = lim;
501
785
  if (lim?.plan && (!codexPlan || lim.at > codexPlan.at)) codexPlan = { at: lim.at, plan: lim.plan };
786
+ if (host === 'codex' && Array.isArray(e.meta?.series)) for (const p of e.meta.series) codexPoints.push(p);
787
+ if (host === 'claude' && e.meta?.lastHit && (!lastHit || e.meta.lastHit.at > lastHit.at)) lastHit = e.meta.lastHit;
788
+ for (const [day, b] of Object.entries(e.days || {})) {
789
+ if (day < hourlyFrom || !b.h) continue;
790
+ for (const [hh, hb] of Object.entries(b.h)) {
791
+ const t = hourStart(day, hh);
792
+ const slot = hourly[host].get(t) || { usd: 0, tokens: 0, lim: {} };
793
+ for (const [model, v] of Object.entries(hb.m || {})) {
794
+ const { usd } = priceBucket(model, { in: v[0], out: v[1], cr: v[2], cw: v[3], cwh: v[4] }, prices);
795
+ if (usd != null) slot.usd += usd;
796
+ slot.tokens += v[0] + v[1] + v[2] + v[3] + v[4];
797
+ }
798
+ for (const [k, n] of Object.entries(hb.lim || {})) bump(slot.lim, k, n);
799
+ hourly[host].set(t, slot);
800
+ }
801
+ }
502
802
  const fn = e.meta?.fn || 'chat';
503
803
  let touched = false;
504
804
  for (const [day, b] of Object.entries(e.days || {})) {
505
805
  if (!inWindow.has(day)) continue;
506
806
  touched = true;
507
807
  const h = hosts[host];
508
- h.lim.plan += b.lim?.plan || 0;
509
- h.lim.server += b.lim?.server || 0;
808
+ for (const [k, n] of Object.entries(b.lim || {})) bump(h.lim, k, n);
510
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
+ }
511
816
  let dayUsd = 0;
512
817
  let dayUnpriced = false;
513
818
  for (const [model, m] of Object.entries(b.models || {})) {
@@ -568,7 +873,9 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
568
873
  hosts: Object.fromEntries(Object.entries(hosts).map(([k, h]) => [k, {
569
874
  tokens: tokensOf(h), input: h.in, output: h.out, cacheRead: h.cr, cacheWrite: h.cw + h.cwh, reasoning: h.rs,
570
875
  responses: h.msgs, sessions: h.sessions, usd: h.unpricedMsgs === h.msgs && h.msgs > 0 ? null : h.usd,
571
- unpricedResponses: h.unpricedMsgs, byFn: h.byFn, limitHits: h.lim, ...cache(h),
876
+ unpricedResponses: h.unpricedMsgs, byFn: h.byFn, ...cache(h),
877
+ // `plan` is every refusal by a limit of the account; `server` is the API's own throttle.
878
+ limitHits: { ...h.lim, plan: Object.entries(h.lim).filter(([k]) => k !== 'server').reduce((a, [, n]) => a + n, 0), server: h.lim.server || 0 },
572
879
  }])),
573
880
  daily: dates.map((d) => ({ date: d, ...daily[d] })),
574
881
  models: Object.values(models).sort((a, b) => tokensOf(b) - tokensOf(a)).map((m) => ({
@@ -578,8 +885,17 @@ export function summarizeUsage(index, { days = 30, now = Date.now(), codexTitles
578
885
  lists: Object.fromEntries(Object.entries(lists).map(([host, l]) => [host, {
579
886
  tools: rank(l.tools, 15), skills: rank(l.skills, 15), agents: rank(l.agents, 15), mcp: rank(l.mcp, 15, mcpLabel),
580
887
  }])),
581
- limits: { codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null } : null },
888
+ limits: {
889
+ codex: codexLimits ? { ...codexLimits, plan: codexLimits.plan || codexPlan?.plan || null, detail: codexLimitDetail(codexPoints, { fromMs, now }) } : null,
890
+ claude: claudeLimits(hourly.claude, { fromMs, now, lastHit }),
891
+ },
582
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
+ },
583
899
  };
584
900
  }
585
901
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "great-cto",
3
- "version": "3.53.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",