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.
|
|
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 {
|
|
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
|
-
|
|
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)}
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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,
|
|
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: {
|
|
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