@golden-frijoles/kit 0.20.0 → 0.22.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.
@@ -0,0 +1,798 @@
1
+ #!/usr/bin/env node
2
+ // epic-actuals.mjs — what each epic consumed, measured from this machine's Claude Code transcripts (finops S1).
3
+ //
4
+ // node scripts/epic-actuals.mjs --epic <slug> [--json] # one epic: sessions, tokens by kind/model/skill, ≈ API $
5
+ // node scripts/epic-actuals.mjs --refresh [--json] # update the index + summary only (what the band's mod runs)
6
+ // node scripts/epic-actuals.mjs --backfill [--write] # every shipped epic: measured, or why not; --write stamps
7
+ // node scripts/epic-actuals.mjs --epic <slug> --write # stamp actual_* into that epic's README (at close)
8
+ // options: --repo-root <dir> (default: the project) · --projects-dir <dir> (default: ~/.claude/projects)
9
+ //
10
+ // ── Where the numbers come from (README § Architecture lock) ────────────────────────────────────────
11
+ // Claude Code writes every turn to `~/.claude/projects/<dir>/<session>.jsonl`, subagents to
12
+ // `<dir>/<session>/subagents/agent-*.jsonl` (D14). Each assistant entry carries `message.id`, `message.model`,
13
+ // `message.usage`, `cwd`, `gitBranch`, `sessionId` and sometimes `attributionSkill`. So no hook, receiver or
14
+ // credential is needed: the record already exists.
15
+ // • D15 — an entry is THIS repo's when its cwd is the repo, under it, or under one of its worktrees.
16
+ // • D13 — one message is written as several entries (one per content block; the last carries the final
17
+ // output_tokens), and a resumed session copies earlier entries into a new file re-stamped with the NEW
18
+ // branch. So: one record per message id; its counts are the entry with the most output tokens; its
19
+ // attribution is the FIRST occurrence indexed (files are first read oldest-born first).
20
+ // • D16 — branch → epic is build-state.mjs's own resolveTarget(). Anything it does not name — main, a fix
21
+ // branch with no epic — is `unattributed`, reported by branch (D11), never guessed.
22
+ // • C14 — `gitBranch` is the branch of the session's OWN checkout. A session sitting on epic A that builds epic B
23
+ // in another directory is counted as A. So build an epic from a session on its branch, or in a worktree on it.
24
+ // • D4 — unknown is not zero: an unpriceable turn keeps its tokens and makes the $ a lower bound (`usd_known:
25
+ // false`); an entry missing a field the count needs is skipped and counted in `skipped`.
26
+ // • D9 — metrics only. Nothing here reads, stores or prints message content; a spec pins every output key.
27
+ //
28
+ // The index (D19) lives in the MAIN checkout's `.golden-frijoles/` so every worktree reads one summary, and it is
29
+ // incremental: per transcript file it remembers how far it read. Aggregates outlive the transcripts — Claude Code
30
+ // deletes old ones, the index keeps what it already counted.
31
+ //
32
+ // Zero deps — Node 18+.
33
+
34
+ import { execFileSync } from 'node:child_process';
35
+ import {
36
+ closeSync,
37
+ existsSync,
38
+ mkdirSync,
39
+ openSync,
40
+ readFileSync,
41
+ readSync,
42
+ readdirSync,
43
+ realpathSync,
44
+ renameSync,
45
+ statSync,
46
+ writeFileSync,
47
+ } from 'node:fs';
48
+ import { homedir } from 'node:os';
49
+ import { basename, dirname, join, resolve, sep } from 'node:path';
50
+ import { fileURLToPath } from 'node:url';
51
+ import { resolveTarget } from './build-state.mjs';
52
+ import { buildRows } from './roadmap-extract.mjs';
53
+ import { projectRoot } from './lib/project-root.mjs';
54
+ import { formatScalar, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
55
+ import { PRICES_AS_OF, PRICES_SOURCE, TOKEN_KINDS, tokensOf, usdOf } from './lib/model-prices.mjs';
56
+
57
+ export const INDEX_VERSION = 1;
58
+ export const STATE_DIR = '.golden-frijoles';
59
+ export const INDEX_FILE = 'usage-index.json';
60
+ export const SUMMARY_FILE = 'usage-summary.json';
61
+ export const MACHINE = 'this machine';
62
+ /** D12 — the agents whose passes this does not see. Named, so a reader never takes "Claude Code" for "everything". */
63
+ export const NOT_MEASURED = Object.freeze(['codex', 'agy', 'vibe', 'devin']);
64
+ export const NO_SKILL = '(no skill)';
65
+ /** How long `--refresh` reads before saving and stopping (the mod's timeout is 10 s — D24). */
66
+ export const REFRESH_BUDGET_MS = 6_000;
67
+
68
+ // The same rule build-state.mjs applies: a git run from a hook must describe --repo-root, never an inherited GIT_DIR.
69
+ const GIT_ENV_TO_CLEAR = [
70
+ 'GIT_DIR',
71
+ 'GIT_INDEX_FILE',
72
+ 'GIT_WORK_TREE',
73
+ 'GIT_COMMON_DIR',
74
+ 'GIT_OBJECT_DIRECTORY',
75
+ ];
76
+ function git(root, args) {
77
+ const env = { ...process.env };
78
+ for (const k of GIT_ENV_TO_CLEAR) delete env[k];
79
+ try {
80
+ return execFileSync('git', args, {
81
+ cwd: root,
82
+ encoding: 'utf8',
83
+ stdio: ['ignore', 'pipe', 'ignore'],
84
+ env,
85
+ }).trim();
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ const real = (p) => {
92
+ try {
93
+ return realpathSync(p);
94
+ } catch {
95
+ return p;
96
+ }
97
+ };
98
+
99
+ // ── Where things are ────────────────────────────────────────────────────────────────────────────────
100
+
101
+ /** The transcripts folder: `$CLAUDE_CONFIG_DIR/projects`, else `~/.claude/projects`. */
102
+ export function defaultProjectsDir(env = process.env) {
103
+ return join(env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), 'projects');
104
+ }
105
+
106
+ /** D19 — the MAIN checkout (the parent of git's common dir), so every worktree shares one index. */
107
+ export function mainCheckout(root) {
108
+ const common = git(root, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
109
+ if (!common) return root;
110
+ return basename(common) === '.git' ? dirname(common) : root;
111
+ }
112
+
113
+ /** D15 — the paths an entry's cwd must sit in: the repo, its main checkout and every listed worktree. */
114
+ export function repoPaths(root) {
115
+ const out = new Set([real(root), real(mainCheckout(root))]);
116
+ const list = git(root, ['worktree', 'list', '--porcelain']) || '';
117
+ for (const line of list.split('\n')) if (line.startsWith('worktree ')) out.add(real(line.slice(9)));
118
+ return [...out];
119
+ }
120
+
121
+ /** Is `cwd` one of `paths`, or under one? A sibling that merely shares a prefix (`repo-old`) is not. */
122
+ export function inRepo(cwd, paths) {
123
+ if (typeof cwd !== 'string' || !cwd) return false;
124
+ const c = real(cwd);
125
+ return paths.some((p) => c === p || c.startsWith(p.endsWith(sep) ? p : p + sep));
126
+ }
127
+
128
+ /**
129
+ * Claude Code's folder name for a session started in `path`: every character other than a letter, digit or `-`
130
+ * becomes `-` (`/Users/me/repo` → `-Users-me-repo`, `/r/.claude/worktrees/x` → `-r--claude-worktrees-x`).
131
+ */
132
+ export function projectDirName(path) {
133
+ return String(path).replace(/[^A-Za-z0-9-]/g, '-');
134
+ }
135
+
136
+ /**
137
+ * D15 (amended, C15) — the folders Claude Code keeps for this repo and its worktrees. A transcript in one of them is
138
+ * this repo's whatever `cwd` it recorded: when a checkout MOVES, Claude Code moves its folder but the entries keep the
139
+ * old path (this repo: 24k entries under ~/dobby/golden-beans, recorded before the 2026-09-29 folder move).
140
+ */
141
+ export function ownProjectDirs(paths) {
142
+ return new Set(paths.map(projectDirName));
143
+ }
144
+
145
+ /** The project folder a transcript file lives in (`<projects>/<dir>/…`). */
146
+ export function projectDirOf(file, projectsDir) {
147
+ const rel = file.slice(projectsDir.length).replace(/^[\\/]+/, '');
148
+ return rel.split(/[\\/]/)[0];
149
+ }
150
+
151
+ /** D14 — every transcript file: `<dir>/*.jsonl` and `<dir>/<session>/subagents/*.jsonl`. */
152
+ export function listTranscripts(projectsDir) {
153
+ const files = [];
154
+ let dirs = [];
155
+ try {
156
+ dirs = readdirSync(projectsDir, { withFileTypes: true }).filter((d) => d.isDirectory());
157
+ } catch {
158
+ return files;
159
+ }
160
+ for (const d of dirs) {
161
+ const dir = join(projectsDir, d.name);
162
+ let names = [];
163
+ try {
164
+ names = readdirSync(dir, { withFileTypes: true });
165
+ } catch {
166
+ continue;
167
+ }
168
+ for (const n of names) {
169
+ if (n.isFile() && n.name.endsWith('.jsonl')) files.push(join(dir, n.name));
170
+ else if (n.isDirectory()) {
171
+ const sub = join(dir, n.name, 'subagents');
172
+ let subs = [];
173
+ try {
174
+ subs = readdirSync(sub);
175
+ } catch {
176
+ continue;
177
+ }
178
+ for (const f of subs) if (f.endsWith('.jsonl')) files.push(join(sub, f));
179
+ }
180
+ }
181
+ }
182
+ return files;
183
+ }
184
+
185
+ // ── One transcript line ─────────────────────────────────────────────────────────────────────────────
186
+
187
+ /**
188
+ * One transcript line → `{ record }`, `{ skip: reason }` or null (not an assistant turn — most lines). The record holds
189
+ * metrics and ids ONLY (D9): it is built field by field, never by copying the entry.
190
+ */
191
+ export function readEntry(line) {
192
+ if (!line.includes('"assistant"')) return null;
193
+ let d;
194
+ try {
195
+ d = JSON.parse(line);
196
+ } catch {
197
+ return { skip: 'unparseable' };
198
+ }
199
+ if (!d || d.type !== 'assistant') return null;
200
+ const m = d.message && typeof d.message === 'object' ? d.message : null;
201
+ if (!m) return { skip: 'no_message' };
202
+ const id = (typeof m.id === 'string' && m.id) || (typeof d.requestId === 'string' && d.requestId) || null;
203
+ if (!id) return { skip: 'no_id' };
204
+ if (m.model === '<synthetic>') return { skip: 'synthetic', cwd: d.cwd };
205
+ if (!m.usage || typeof m.usage !== 'object') return { skip: 'no_usage', cwd: d.cwd };
206
+ if (typeof m.model !== 'string' || !m.model) return { skip: 'no_model', cwd: d.cwd };
207
+ if (typeof d.cwd !== 'string' || !d.cwd) return { skip: 'no_cwd' };
208
+ return {
209
+ record: {
210
+ id,
211
+ session: typeof d.sessionId === 'string' ? d.sessionId : null,
212
+ branch: typeof d.gitBranch === 'string' && d.gitBranch ? d.gitBranch : null,
213
+ cwd: d.cwd,
214
+ skill: typeof d.attributionSkill === 'string' && d.attributionSkill ? d.attributionSkill : null,
215
+ model: m.model,
216
+ at: typeof d.timestamp === 'string' ? d.timestamp : null,
217
+ speed: m.usage.speed === 'fast' ? 'fast' : 'standard',
218
+ geo: m.usage.inference_geo === 'us' ? 'us' : null,
219
+ tokens: tokensOf(m.usage),
220
+ },
221
+ };
222
+ }
223
+
224
+ // ── The index ───────────────────────────────────────────────────────────────────────────────────────
225
+ // messages[id] = [session, branch, skill, model, at, speed, geo, input, output, cache_read, w5, w1]
226
+ // (compact on purpose: ~20k records). The cwd is checked at read time and not stored — every stored record is
227
+ // this repo's.
228
+
229
+ const T0 = 7; // first token column
230
+ export function emptyIndex() {
231
+ return { version: INDEX_VERSION, files: {}, messages: {}, skipped: {} };
232
+ }
233
+
234
+ function packRecord(r) {
235
+ const t = r.tokens;
236
+ return [
237
+ r.session,
238
+ r.branch,
239
+ r.skill,
240
+ r.model,
241
+ r.at,
242
+ r.speed,
243
+ r.geo,
244
+ t.input,
245
+ t.output,
246
+ t.cache_read,
247
+ t.cache_write_5m,
248
+ t.cache_write_1h,
249
+ ];
250
+ }
251
+
252
+ /** D13 — the first occurrence owns the attribution; a later one may only raise the counts (the final streamed write). */
253
+ export function mergeRecord(index, r) {
254
+ const prev = index.messages[r.id];
255
+ if (!prev) {
256
+ index.messages[r.id] = packRecord(r);
257
+ return 'added';
258
+ }
259
+ if (r.tokens.output > prev[T0 + 1]) {
260
+ const t = r.tokens;
261
+ prev.splice(T0, 5, t.input, t.output, t.cache_read, t.cache_write_5m, t.cache_write_1h);
262
+ return 'raised';
263
+ }
264
+ return 'same';
265
+ }
266
+
267
+ /** The bytes of `path` from `offset` to its last complete line. */
268
+ function readFrom(path, offset, size) {
269
+ const len = size - offset;
270
+ if (len <= 0) return { text: '', end: offset };
271
+ const fd = openSync(path, 'r');
272
+ try {
273
+ const buf = Buffer.alloc(len);
274
+ readSync(fd, buf, 0, len, offset);
275
+ const lastNl = buf.lastIndexOf(0x0a);
276
+ if (lastNl === -1) return { text: '', end: offset };
277
+ return { text: buf.subarray(0, lastNl + 1).toString('utf8'), end: offset + lastNl + 1 };
278
+ } finally {
279
+ closeSync(fd);
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Bring the index up to date: read only the bytes each transcript gained since the last run. A file that SHRANK
285
+ * (rewritten) is read again from the start — harmless, records are keyed by message id. Files are taken oldest-born
286
+ * first so an original session is indexed before a resumed copy of it (D13). Returns what changed.
287
+ */
288
+ export function updateIndex(
289
+ index,
290
+ { projectsDir, paths, stat = statSync, deadline = Infinity, now = Date.now }
291
+ ) {
292
+ const own = ownProjectDirs(paths);
293
+ const files = listTranscripts(projectsDir)
294
+ .map((path) => {
295
+ try {
296
+ const s = stat(path);
297
+ return { path, size: s.size, mtimeMs: s.mtimeMs, born: s.birthtimeMs || s.mtimeMs };
298
+ } catch {
299
+ return null;
300
+ }
301
+ })
302
+ .filter(Boolean)
303
+ .sort((a, b) => a.born - b.born || (a.path < b.path ? -1 : 1));
304
+ const changes = { files_read: 0, added: 0, raised: 0, complete: true };
305
+ for (const f of files) {
306
+ // A time budget (the mod's run is timeout-bound): stop BETWEEN files, so every file recorded is fully read and the
307
+ // next run resumes where this one stopped. A first scan on a slow machine then completes over a few runs instead of
308
+ // starting from zero every time and never saving (fresh review, #230).
309
+ // At least ONE file per run, by construction: listing and stat-ing every transcript happens inside the budget, so
310
+ // checking before the first file could let a slow listing read nothing, forever (round-2 review, #230).
311
+ if (changes.files_read > 0 && now() > deadline) {
312
+ changes.complete = false;
313
+ break;
314
+ }
315
+ const known = index.files[f.path];
316
+ if (known && known.size === f.size && known.mtimeMs === f.mtimeMs) continue;
317
+ const from = known && f.size >= known.offset ? known.offset : 0;
318
+ const { text, end } = readFrom(f.path, from, f.size);
319
+ changes.files_read++;
320
+ const ownFolder = own.has(projectDirOf(f.path, projectsDir));
321
+ for (const line of text.split('\n')) {
322
+ if (!line) continue;
323
+ const e = readEntry(line);
324
+ if (!e) continue;
325
+ if (e.skip) {
326
+ // Counted only when it is THIS repo's (its folder, or its cwd when the entry still has one) — another
327
+ // project's skips are not this summary's business. A line too broken to say counts only in our own folder.
328
+ if (ownFolder || inRepo(e.cwd, paths)) index.skipped[e.skip] = (index.skipped[e.skip] || 0) + 1;
329
+ continue;
330
+ }
331
+ if (!ownFolder && !inRepo(e.record.cwd, paths)) continue;
332
+ const how = mergeRecord(index, e.record);
333
+ if (how !== 'same') changes[how]++;
334
+ }
335
+ index.files[f.path] = { size: f.size, mtimeMs: f.mtimeMs, offset: end };
336
+ }
337
+ return changes;
338
+ }
339
+
340
+ export function readJson(path, fallback) {
341
+ try {
342
+ return JSON.parse(readFileSync(path, 'utf8'));
343
+ } catch {
344
+ return fallback;
345
+ }
346
+ }
347
+
348
+ /** Atomic: a second session writing at the same instant leaves one whole file, never a torn one. */
349
+ export function writeJson(path, value) {
350
+ mkdirSync(dirname(path), { recursive: true });
351
+ const ignore = join(dirname(path), '.gitignore');
352
+ if (!existsSync(ignore)) writeFileSync(ignore, '*\n');
353
+ const tmp = `${path}.${process.pid}.tmp`;
354
+ writeFileSync(tmp, `${JSON.stringify(value)}\n`);
355
+ renameSync(tmp, path);
356
+ }
357
+
358
+ export function loadIndex(file) {
359
+ const idx = readJson(file, null);
360
+ return idx && idx.version === INDEX_VERSION && idx.messages && idx.files ? idx : emptyIndex();
361
+ }
362
+
363
+ // ── Totals ──────────────────────────────────────────────────────────────────────────────────────────
364
+
365
+ const zeroTokens = () => Object.fromEntries(TOKEN_KINDS.map((k) => [k, 0]));
366
+ const totalTokens = (t) => TOKEN_KINDS.reduce((a, k) => a + (t[k] || 0), 0);
367
+
368
+ function newBucket() {
369
+ return {
370
+ usd: 0,
371
+ usd_known: true,
372
+ tokens: zeroTokens(),
373
+ sessions: new Set(),
374
+ first_at: null,
375
+ last_at: null,
376
+ };
377
+ }
378
+
379
+ function addTo(bucket, rec) {
380
+ const [session, , , , at] = rec;
381
+ const t = tokensFromRecord(rec);
382
+ for (const k of TOKEN_KINDS) bucket.tokens[k] += t[k];
383
+ const usd = usdOf(rec[3], t, { speed: rec[5], inferenceGeo: rec[6] });
384
+ if (usd === null) bucket.usd_known = false;
385
+ else bucket.usd += usd;
386
+ if (session) bucket.sessions.add(session);
387
+ if (at && (!bucket.first_at || at < bucket.first_at)) bucket.first_at = at;
388
+ if (at && (!bucket.last_at || at > bucket.last_at)) bucket.last_at = at;
389
+ }
390
+
391
+ function tokensFromRecord(rec) {
392
+ return {
393
+ input: rec[T0],
394
+ output: rec[T0 + 1],
395
+ cache_read: rec[T0 + 2],
396
+ cache_write_5m: rec[T0 + 3],
397
+ cache_write_1h: rec[T0 + 4],
398
+ };
399
+ }
400
+
401
+ const round2 = (n) => Math.round(n * 100) / 100;
402
+ const mtok = (t) => Math.round(totalTokens(t) / 1e5) / 10;
403
+
404
+ function finish(b) {
405
+ return {
406
+ usd: round2(b.usd),
407
+ usd_known: b.usd_known,
408
+ tokens: b.tokens,
409
+ mtok: mtok(b.tokens),
410
+ sessions: b.sessions.size,
411
+ first_at: b.first_at,
412
+ last_at: b.last_at,
413
+ };
414
+ }
415
+
416
+ /**
417
+ * The index → per-epic totals (and the unattributed rest, by branch). `epicOf(branch)` is the D16 resolution —
418
+ * injected so a spec can drive it without a Roadmap on disk.
419
+ */
420
+ export function summarize(index, { epicOf, now = new Date() }) {
421
+ const epics = new Map();
422
+ const unattributed = new Map();
423
+ const cache = new Map();
424
+ const resolveBranch = (b) => {
425
+ if (!b) return null;
426
+ if (!cache.has(b)) cache.set(b, epicOf(b));
427
+ return cache.get(b);
428
+ };
429
+ for (const rec of Object.values(index.messages)) {
430
+ const epic = resolveBranch(rec[1]);
431
+ if (!epic) {
432
+ const key = rec[1] || '(no branch)';
433
+ if (!unattributed.has(key)) unattributed.set(key, newBucket());
434
+ addTo(unattributed.get(key), rec);
435
+ continue;
436
+ }
437
+ if (!epics.has(epic))
438
+ epics.set(epic, { all: newBucket(), models: new Map(), skills: new Map(), branches: new Set() });
439
+ const e = epics.get(epic);
440
+ addTo(e.all, rec);
441
+ e.branches.add(rec[1]);
442
+ const model = rec[3];
443
+ if (!e.models.has(model)) e.models.set(model, newBucket());
444
+ addTo(e.models.get(model), rec);
445
+ const skill = rec[2] || NO_SKILL;
446
+ if (!e.skills.has(skill)) e.skills.set(skill, newBucket());
447
+ addTo(e.skills.get(skill), rec);
448
+ }
449
+ const part = (b) => {
450
+ const f = finish(b);
451
+ return { usd: f.usd, usd_known: f.usd_known, mtok: f.mtok, tokens: f.tokens };
452
+ };
453
+ const out = {};
454
+ for (const [slug, e] of [...epics].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
455
+ out[slug] = {
456
+ ...finish(e.all),
457
+ branches: [...e.branches].sort(),
458
+ by_model: Object.fromEntries([...e.models].map(([k, b]) => [k, part(b)])),
459
+ by_skill: Object.fromEntries([...e.skills].map(([k, b]) => [k, part(b)])),
460
+ };
461
+ }
462
+ let oldest = null;
463
+ for (const rec of Object.values(index.messages))
464
+ if (rec[4] && (!oldest || rec[4] < oldest)) oldest = rec[4];
465
+ return {
466
+ generated_at: now.toISOString(),
467
+ basis: MACHINE,
468
+ oldest_at: oldest,
469
+ prices_as_of: PRICES_AS_OF,
470
+ prices_source: PRICES_SOURCE,
471
+ not_measured: [...NOT_MEASURED],
472
+ epics: out,
473
+ unattributed: Object.fromEntries(
474
+ [...unattributed].sort((a, b) => (a[0] < b[0] ? -1 : 1)).map(([k, b]) => [k, finish(b)])
475
+ ),
476
+ skipped: { ...index.skipped },
477
+ };
478
+ }
479
+
480
+ /** D16 — the epic slug a branch names in `root`'s Roadmap, or null. */
481
+ export function epicOfBranch(root) {
482
+ return (branch) => {
483
+ try {
484
+ const t = resolveTarget(root, branch);
485
+ return t && t.kind === 'epic' ? t.epic.slug : null;
486
+ } catch {
487
+ return null;
488
+ }
489
+ };
490
+ }
491
+
492
+ /** Refresh index + summary under the main checkout's `.golden-frijoles/`. Returns { summary, changes, dir }. */
493
+ export function refresh({ root, projectsDir = defaultProjectsDir(), now = new Date(), budgetMs = Infinity }) {
494
+ const dir = join(mainCheckout(root), STATE_DIR);
495
+ const indexPath = join(dir, INDEX_FILE);
496
+ const index = loadIndex(indexPath);
497
+ const changes = updateIndex(index, {
498
+ projectsDir,
499
+ paths: repoPaths(root),
500
+ deadline: Date.now() + budgetMs,
501
+ });
502
+ const summary = { ...summarize(index, { epicOf: epicOfBranch(root), now }), complete: changes.complete };
503
+ if (changes.files_read || !existsSync(indexPath)) writeJson(indexPath, index);
504
+ writeJson(join(dir, SUMMARY_FILE), summary);
505
+ return { summary, changes, dir };
506
+ }
507
+
508
+ /** One epic's report — the `--epic <slug> --json` shape (a spec pins the key set, D9). */
509
+ export function epicReport(summary, slug) {
510
+ const e = summary.epics[slug];
511
+ return {
512
+ epic: slug,
513
+ measured: !!e,
514
+ basis: summary.basis,
515
+ sessions: e ? e.sessions : 0,
516
+ tokens: e ? e.tokens : zeroTokens(),
517
+ mtok: e ? e.mtok : 0,
518
+ usd: e ? e.usd : null,
519
+ usd_known: e ? e.usd_known : true,
520
+ first_at: e ? e.first_at : null,
521
+ last_at: e ? e.last_at : null,
522
+ branches: e ? e.branches : [],
523
+ by_model: e ? e.by_model : {},
524
+ by_skill: e ? e.by_skill : {},
525
+ skipped: summary.skipped,
526
+ prices_as_of: summary.prices_as_of,
527
+ prices_source: summary.prices_source,
528
+ not_measured: summary.not_measured,
529
+ };
530
+ }
531
+
532
+ /** `≈$38.42` / `≥$38.42` (a lower bound: some turns could not be priced — D4). */
533
+ export function usdText(usd, known) {
534
+ return `${known ? '≈' : '≥'}$${usd >= 100 ? Math.round(usd) : usd.toFixed(2)}`;
535
+ }
536
+
537
+ // ── Stamping actual_* (S1.4 backfill, S2.5 close) ───────────────────────────────────────────────────
538
+
539
+ /**
540
+ * Set `fields` in a doc's frontmatter: an existing `key:` line is replaced in place, a missing one is inserted just
541
+ * before the closing fence. No other line is touched. Re-running with the same values is a no-op (idempotent).
542
+ */
543
+ export function stampFrontmatter(md, fields) {
544
+ const lines = md.split('\n');
545
+ if (lines[0].trim() !== '---') throw new Error('no frontmatter');
546
+ const end = lines.findIndex((l, i) => i > 0 && l.trim() === '---');
547
+ if (end === -1) throw new Error('unterminated frontmatter');
548
+ const missing = [];
549
+ for (const [key, value] of Object.entries(fields)) {
550
+ const line = `${key}: ${formatScalar(value)}`;
551
+ const at = lines.findIndex((l, i) => i > 0 && i < end && new RegExp(`^${key}:(\\s|$)`).test(l));
552
+ if (at === -1) missing.push(line);
553
+ else lines[at] = line;
554
+ }
555
+ lines.splice(end, 0, ...missing);
556
+ return lines.join('\n');
557
+ }
558
+
559
+ /** The three actual_* fields for an epic's report. */
560
+ export function actualFields(report, basisPrefix, date) {
561
+ return {
562
+ actual_usd: report.usd,
563
+ actual_mtok: report.mtok,
564
+ actual_basis: `${basisPrefix}${MACHINE} · ${date} · ${report.sessions} session${report.sessions === 1 ? '' : 's'}${
565
+ report.usd_known ? '' : ' · lower bound (unpriced model)'
566
+ } · prices ${PRICES_AS_OF}`,
567
+ };
568
+ }
569
+
570
+ function epicReadme(root, slug) {
571
+ const roadmap = join(root, 'Roadmap');
572
+ for (const macro of existsSync(roadmap) ? readdirSync(roadmap).sort() : []) {
573
+ if (!/^\d{2}-/.test(macro)) continue;
574
+ const p = join(roadmap, macro, slug, 'README.md');
575
+ if (existsSync(p)) return p;
576
+ }
577
+ return null;
578
+ }
579
+
580
+ /** Write an epic's actual into its README. Returns the README path, or null when nothing was measured. */
581
+ export function stampEpic({ root, summary, slug, basisPrefix = '', date, keepStamped = false }) {
582
+ const report = epicReport(summary, slug);
583
+ if (!report.measured) return null;
584
+ const path = epicReadme(root, slug);
585
+ if (!path) throw new Error(`no Roadmap/*/${slug}/README.md`);
586
+ const md = readFileSync(path, 'utf8');
587
+ // A backfill never overwrites an actual already written — one stamped at close, or a deliberate hold such as
588
+ // `actual_usd: null` with its reason (README C14). `--epic <slug> --write` (the close) is the one that replaces.
589
+ if (keepStamped) {
590
+ // Read through the contract's own parser: an empty `actual_basis:` or `actual_basis: null` is NOT a written actual.
591
+ const fm = parseDocFrontmatter(md);
592
+ // Fail CLOSED: a frontmatter this parser cannot read (an out-of-subset line, CRLF) may still hold an actual.
593
+ if (fm.error) return null;
594
+ const basis = fm.data.actual_basis;
595
+ if (typeof basis === 'string' && basis.trim()) return null;
596
+ }
597
+ const next = stampFrontmatter(md, actualFields(report, basisPrefix, date));
598
+ if (next !== md) writeFileSync(path, next);
599
+ return path;
600
+ }
601
+
602
+ // ── Backfill (S1.4) ─────────────────────────────────────────────────────────────────────────────────
603
+
604
+ /**
605
+ * Every shipped epic: measured from local transcripts, or the reason it could not be (findings recorded, not fixed).
606
+ * `shippedAt(slug)` is when its README last changed; `oldestTranscriptAt` bounds what this machine can still see.
607
+ */
608
+ export function backfillReport({
609
+ summary,
610
+ shipped,
611
+ oldestTranscriptAt,
612
+ shippedAt = () => null,
613
+ scaffoldedAt = () => null,
614
+ }) {
615
+ const resolved = [];
616
+ const partial = [];
617
+ const unresolved = [];
618
+ for (const slug of shipped) {
619
+ const e = summary.epics[slug];
620
+ if (e) {
621
+ // An epic scaffolded before the oldest transcript this machine still has was partly built in sessions that are
622
+ // gone: its total is an UNDERSTATEMENT, and stamping it would teach the first quotes a number too low (D4).
623
+ // And one measured only on `docs/` branches (a retro, a close-out) is not its build: the build ran elsewhere.
624
+ const born = scaffoldedAt(slug);
625
+ const onlyDocs = (e.branches || []).length > 0 && e.branches.every((b) => /^docs\//.test(b));
626
+ const reason =
627
+ born && oldestTranscriptAt && born < oldestTranscriptAt
628
+ ? `scaffolded ${born.slice(0, 10)}, before the oldest transcript on this machine (${oldestTranscriptAt.slice(0, 10)}) — a partial total, not stamped`
629
+ : onlyDocs
630
+ ? `only close-out sessions here (${e.branches.join(', ')}) — the build ran elsewhere; a partial total, not stamped`
631
+ : null;
632
+ if (reason) partial.push({ epic: slug, ...epicReport(summary, slug), reason });
633
+ else resolved.push({ epic: slug, ...epicReport(summary, slug) });
634
+ continue;
635
+ }
636
+ const at = shippedAt(slug);
637
+ const reason =
638
+ at && oldestTranscriptAt && at < oldestTranscriptAt
639
+ ? `shipped ${at.slice(0, 10)}, before the oldest transcript on this machine (${oldestTranscriptAt.slice(0, 10)}) — no transcripts left`
640
+ : at
641
+ ? `shipped ${at.slice(0, 10)}, but no session on this machine ran on a branch naming it — built in the cloud, on another machine, or from a session whose own checkout sat on another branch`
642
+ : 'no session on this machine ran on a branch naming it';
643
+ unresolved.push({ epic: slug, reason });
644
+ }
645
+ return { resolved, partial, unresolved };
646
+ }
647
+
648
+ // ── CLI ─────────────────────────────────────────────────────────────────────────────────────────────
649
+
650
+ function arg(argv, name) {
651
+ const i = argv.indexOf(name);
652
+ if (i === -1) return null;
653
+ const v = argv[i + 1];
654
+ if (!v || v.startsWith('--')) throw new Error(`${name} needs a value`);
655
+ return v;
656
+ }
657
+
658
+ function printEpic(r) {
659
+ if (!r.measured) return `${r.epic}: no session on ${MACHINE} ran on a branch naming this epic`;
660
+ const t = r.tokens;
661
+ const m = (n) => `${Math.round(n / 1e5) / 10}M`;
662
+ const lines = [
663
+ `${r.epic}: ${usdText(r.usd, r.usd_known)} · ${r.mtok}M tok · ${r.sessions} session${r.sessions === 1 ? '' : 's'} · ${MACHINE}`,
664
+ ` tokens in ${m(t.input)} · out ${m(t.output)} · cache-read ${m(t.cache_read)} · cache-write ${m(t.cache_write_5m + t.cache_write_1h)}`,
665
+ ` models ${Object.entries(r.by_model)
666
+ .map(([k, v]) => `${k} ${usdText(v.usd, v.usd_known)}`)
667
+ .join(' · ')}`,
668
+ ` skills ${Object.entries(r.by_skill)
669
+ .map(([k, v]) => `${k} ${usdText(v.usd, v.usd_known)}`)
670
+ .join(' · ')}`,
671
+ ` span ${r.first_at ?? '?'} → ${r.last_at ?? '?'} · branches ${r.branches.join(', ')}`,
672
+ ` ≈ API $ = list-price equivalent (${r.prices_source}, ${r.prices_as_of}); not measured: ${r.not_measured.join(', ')}`,
673
+ ];
674
+ return lines.join('\n');
675
+ }
676
+
677
+ function main(argv) {
678
+ const rootArg = arg(argv, '--repo-root');
679
+ const root = resolve(rootArg ?? projectRoot());
680
+ const projectsDir = arg(argv, '--projects-dir') ?? defaultProjectsDir();
681
+ const json = argv.includes('--json');
682
+ const write = argv.includes('--write');
683
+ const today = new Date().toISOString().slice(0, 10);
684
+ // --refresh is what the mod runs under a 10 s timeout (D24): it saves what it read within REFRESH_BUDGET_MS.
685
+ const budgetMs = argv.includes('--refresh') ? REFRESH_BUDGET_MS : Infinity;
686
+ const { summary, changes } = refresh({ root, projectsDir, budgetMs });
687
+
688
+ if (argv.includes('--refresh')) {
689
+ if (json)
690
+ process.stdout.write(`${JSON.stringify({ changes, epics: Object.keys(summary.epics).length })}\n`);
691
+ return 0;
692
+ }
693
+ const slug = arg(argv, '--epic');
694
+ if (slug) {
695
+ const report = epicReport(summary, slug);
696
+ if (write) {
697
+ const path = stampEpic({ root, summary, slug, date: today });
698
+ if (!path) {
699
+ process.stderr.write(`epic-actuals: nothing measured for ${slug} on ${MACHINE} — nothing written\n`);
700
+ return 1;
701
+ }
702
+ process.stderr.write(`epic-actuals: stamped actual_* in ${path.slice(root.length + 1)}\n`);
703
+ }
704
+ process.stdout.write(json ? `${JSON.stringify(report, null, 2)}\n` : `${printEpic(report)}\n`);
705
+ return 0;
706
+ }
707
+ if (argv.includes('--backfill')) {
708
+ const rows = buildRows({ root, dates: false });
709
+ const shipped = rows.filter((r) => r.grain === 'Epic' && r.status === 'Shipped').map((r) => r.slug);
710
+ const shippedAt = (s) => {
711
+ const p = epicReadme(root, s);
712
+ // The FIRST commit that wrote `status: shipped` — a later edit (a move, a close-out) would date it too late.
713
+ const dates = p ? git(root, ['log', '--format=%cI', '-G', '^status: shipped', '--', p]) : null;
714
+ return dates ? dates.split('\n').filter(Boolean).at(-1) || null : null;
715
+ };
716
+ const scaffoldedAt = (s) => {
717
+ const p = epicReadme(root, s);
718
+ const dates = p ? git(root, ['log', '--format=%cI', '--follow', '--diff-filter=A', '--', p]) : null;
719
+ return dates ? dates.split('\n').filter(Boolean).at(-1) || null : null;
720
+ };
721
+ const rep = backfillReport({
722
+ summary,
723
+ shipped,
724
+ oldestTranscriptAt: summary.oldest_at,
725
+ shippedAt,
726
+ scaffoldedAt,
727
+ });
728
+ if (write)
729
+ for (const r of rep.resolved)
730
+ if (
731
+ !stampEpic({
732
+ root,
733
+ summary,
734
+ slug: r.epic,
735
+ basisPrefix: 'backfill · ',
736
+ date: today,
737
+ keepStamped: true,
738
+ })
739
+ )
740
+ process.stderr.write(`epic-actuals: ${r.epic} already carries an actual — left as written\n`);
741
+ if (json) {
742
+ process.stdout.write(
743
+ `${JSON.stringify({ ...rep, unattributed: summary.unattributed, written: write }, null, 2)}\n`
744
+ );
745
+ return 0;
746
+ }
747
+ const out = [
748
+ `Measured on ${MACHINE} (${rep.resolved.length} shipped epic${rep.resolved.length === 1 ? '' : 's'}):`,
749
+ ];
750
+ for (const r of rep.resolved)
751
+ out.push(
752
+ ` ${r.epic.padEnd(36)} ${usdText(r.usd, r.usd_known).padStart(9)} · ${r.mtok}M tok · ${r.sessions} session${r.sessions === 1 ? '' : 's'}`
753
+ );
754
+ out.push(`Partly measured — not stamped (${rep.partial.length}):`);
755
+ for (const r of rep.partial)
756
+ out.push(` ${r.epic.padEnd(36)} ${usdText(r.usd, r.usd_known).padStart(9)} · ${r.reason}`);
757
+ out.push(`Could not measure (${rep.unresolved.length}):`);
758
+ for (const u of rep.unresolved) out.push(` ${u.epic.padEnd(36)} ${u.reason}`);
759
+ const un = Object.entries(summary.unattributed);
760
+ out.push(
761
+ `Unattributed (D11 — planning on main, fixes with no epic; ${un.length} branch${un.length === 1 ? '' : 'es'}):`
762
+ );
763
+ for (const [b, v] of un.slice(0, 12))
764
+ out.push(
765
+ ` ${b.padEnd(36)} ${usdText(v.usd, v.usd_known).padStart(9)} · ${v.sessions} session${v.sessions === 1 ? '' : 's'}`
766
+ );
767
+ if (un.length > 12) out.push(` … ${un.length - 12} more — --json`);
768
+ out.push(
769
+ write
770
+ ? 'Stamped actual_* on every measured shipped epic.'
771
+ : 'Dry run — add --write to stamp actual_* on the measured ones.'
772
+ );
773
+ process.stdout.write(`${out.join('\n')}\n`);
774
+ return 0;
775
+ }
776
+ process.stderr.write(
777
+ 'usage: epic-actuals.mjs --epic <slug> [--json] [--write] | --refresh | --backfill [--write]\n'
778
+ );
779
+ return 2;
780
+ }
781
+
782
+ const isMain = (() => {
783
+ try {
784
+ return (
785
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
786
+ );
787
+ } catch {
788
+ return false;
789
+ }
790
+ })();
791
+ if (isMain) {
792
+ try {
793
+ process.exitCode = main(process.argv.slice(2));
794
+ } catch (err) {
795
+ process.stderr.write(`epic-actuals: ${err && err.message ? err.message : err}\n`);
796
+ process.exitCode = 2;
797
+ }
798
+ }