@golden-frijoles/kit 0.21.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.
@@ -165,9 +165,10 @@ const TYPES_KNOWN = ['feature', 'spike', 'bug', 'chore'];
165
165
 
166
166
  /**
167
167
  * What the branch names, longest reading first: an epic, else a seed (a seed that carries `epic:` is that
168
- * epic — once scaffolded the seed is funnel-only), else null.
168
+ * epic — once scaffolded the seed is funnel-only), else null. Exported for epic-actuals.mjs (finops D16): spend is
169
+ * attributed to an epic by exactly this reading of a branch, never a second one.
169
170
  */
170
- function resolveTarget(root, branch) {
171
+ export function resolveTarget(root, branch) {
171
172
  for (const c of branchCandidates(branch)) {
172
173
  const epic = readEpic(root, c.slug);
173
174
  if (epic) return { kind: 'epic', epic, sprint: c.sprint, match: c.exact ? 'exact' : 'prefix' };
@@ -258,6 +259,57 @@ export function prFromFacts(facts, branch) {
258
259
 
259
260
  const notInFlight = (reason, extra = {}) => ({ in_flight: false, reason, ...extra });
260
261
 
262
+ // ── Spend (finops S1.3, D5) ─────────────────────────────────────────────────────────────────────────────
263
+ // The band only READS: `<main checkout>/.golden-frijoles/usage-summary.json`, written by epic-actuals.mjs (refreshed
264
+ // by the mod off the hot path, and by any CLI run). Nothing here scans a transcript. No summary, or no row for this
265
+ // epic, means no Spend line — never a zero.
266
+ export const USAGE_SUMMARY = ['.golden-frijoles', 'usage-summary.json'];
267
+
268
+ /** The main checkout of `root` (the parent of git's common dir) — where the usage summary lives (finops D19). */
269
+ function mainCheckoutOf(git, root) {
270
+ const common = tryGit(git, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
271
+ if (!common) return root;
272
+ return /[\\/]\.git$/.test(common) ? dirname(common) : root;
273
+ }
274
+
275
+ /** This epic's row of the usage summary, or null. Never throws. */
276
+ export function spendFor(root, git, slug) {
277
+ try {
278
+ const path = join(mainCheckoutOf(git, root), ...USAGE_SUMMARY);
279
+ if (!existsSync(path)) return null;
280
+ const summary = JSON.parse(readFileSync(path, 'utf8'));
281
+ // A first scan that ran out of its time budget is not a total yet: no row until it completes, never a low number.
282
+ if (summary?.complete === false) return null;
283
+ const row = summary?.epics?.[slug];
284
+ if (!row || typeof row.usd !== 'number') return null;
285
+ return {
286
+ usd: row.usd,
287
+ usd_known: row.usd_known !== false,
288
+ mtok: typeof row.mtok === 'number' ? row.mtok : null,
289
+ sessions: Number.isInteger(row.sessions) ? row.sessions : null,
290
+ basis: typeof summary.basis === 'string' ? summary.basis : 'this machine',
291
+ generated_at: summary.generated_at ?? null,
292
+ };
293
+ } catch {
294
+ return null;
295
+ }
296
+ }
297
+
298
+ /** `≈$38` (a lower bound reads `≥$38`: some turns could not be priced — finops D4). */
299
+ export function dollars(usd, known = true) {
300
+ const sign = known ? '≈' : '≥';
301
+ return usd > 0 && usd < 1 ? `${sign}$${usd.toFixed(2)}` : `${sign}$${Math.round(usd)}`;
302
+ }
303
+
304
+ /** The Spend line's value (finops S1.3): `≈$38 · 1.9M tok · 4 sessions · this machine`. */
305
+ export function spendValue(spend) {
306
+ const parts = [dollars(spend.usd, spend.usd_known)];
307
+ if (spend.mtok !== null) parts.push(`${spend.mtok}M tok`);
308
+ if (spend.sessions !== null) parts.push(`${spend.sessions} session${spend.sessions === 1 ? '' : 's'}`);
309
+ parts.push(spend.basis);
310
+ return parts.join(' · ');
311
+ }
312
+
261
313
  /** Does `text` name `slug` as a whole slug — `aws` in "aws S1.1", but not inside "aws-s3 S1.1"? */
262
314
  export function namesSlug(text, slug) {
263
315
  const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -596,6 +648,7 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
596
648
  kind: 'epic',
597
649
  branch,
598
650
  branch_match: target.match,
651
+ spend: spendFor(root, git, epic.slug),
599
652
  lifecycle: epic.lifecycle,
600
653
  epic: {
601
654
  slug: epic.slug,
@@ -744,6 +797,7 @@ export function renderLines(state) {
744
797
  const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
745
798
  const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
746
799
  lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
800
+ if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend)}`);
747
801
  lines.push(`${pad('Status')}${statusValue(state)}`);
748
802
  return [...lines, ...also, ...boardLines(state, pad)];
749
803
  }
@@ -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
+ }
@@ -53,10 +53,10 @@ export const AGENT_BIN = {
53
53
  // Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
54
54
  // `agy models`), but it means a future typo in either constant would silently review with the WRONG model
55
55
  // instead of failing loud — watch for that if either constant is ever edited.
56
- // agy-doctor: last verified 2026-09-30 against 1.2.14.
56
+ // agy-doctor: last verified 2026-10-02 against 1.2.15.
57
57
  // ^ machine-managed marker — `node scripts/cross-agent-doctor.mjs agy --fix` rewrites it (with the constant
58
58
  // below) after a green live contract probe. Don't hand-edit the marker's shape.
59
- export const AGY_PINNED = '1.2.14';
59
+ export const AGY_PINNED = '1.2.15';
60
60
 
61
61
  // agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
62
62
  // nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
@@ -0,0 +1,103 @@
1
+ // model-prices.mjs — ONE dated price table for the ≈ API $ an epic cost (finops D3, D18).
2
+ //
3
+ // "≈ API $" is a LIST-PRICE EQUIVALENT: what the same tokens would have cost on the Claude API at the prices below.
4
+ // It is not what anyone was billed (a subscription is not billed per token), and it says so wherever it is shown.
5
+ // A stamped actual keeps the $ it was stamped with; changing this table re-prices only what is measured after.
6
+ //
7
+ // Read from the official pricing page on PRICES_AS_OF. To update: re-read that page, change the rows and the date
8
+ // in ONE commit, and say in the CHANGELOG which models moved.
9
+ //
10
+ // Zero deps, no imports.
11
+
12
+ export const PRICES_AS_OF = '2026-10-02';
13
+ export const PRICES_SOURCE = 'https://platform.claude.com/docs/en/about-claude/pricing';
14
+
15
+ // USD per million tokens, per kind. `fast`: the page lists fast-mode pricing for this model, at 2× (Opus 5.5 $8/$40,
16
+ // Opus 5 and Opus 4.8 $10/$50); the prompt-caching multipliers stack on top, so every kind doubles.
17
+ const row = (input, cache_write_5m, cache_write_1h, cache_read, output, fast = false) =>
18
+ Object.freeze({ input, cache_write_5m, cache_write_1h, cache_read, output, fast });
19
+
20
+ export const PRICES = Object.freeze({
21
+ 'claude-fable-5-1': row(10, 12.5, 20, 0.25, 50),
22
+ 'claude-mythos-5-1': row(10, 12.5, 20, 0.25, 50),
23
+ 'claude-fable-5': row(10, 12.5, 20, 1, 50),
24
+ 'claude-mythos-5': row(10, 12.5, 20, 1, 50),
25
+ 'claude-opus-5-5': row(4, 5, 8, 0.2, 20, true),
26
+ 'claude-opus-5': row(5, 6.25, 10, 0.5, 25, true),
27
+ 'claude-opus-4-8': row(5, 6.25, 10, 0.5, 25, true),
28
+ 'claude-opus-4-7': row(5, 6.25, 10, 0.5, 25),
29
+ 'claude-opus-4-6': row(5, 6.25, 10, 0.5, 25),
30
+ 'claude-opus-4-5': row(5, 6.25, 10, 0.5, 25),
31
+ 'claude-opus-4-1': row(15, 18.75, 30, 1.5, 75),
32
+ 'claude-opus-4': row(15, 18.75, 30, 1.5, 75),
33
+ 'claude-sonnet-5-5': row(2, 2.5, 4, 0.2, 10),
34
+ 'claude-sonnet-5': row(2, 2.5, 4, 0.2, 10),
35
+ 'claude-sonnet-4-6': row(3, 3.75, 6, 0.3, 15),
36
+ 'claude-sonnet-4-5': row(3, 3.75, 6, 0.3, 15),
37
+ 'claude-sonnet-4': row(3, 3.75, 6, 0.3, 15),
38
+ 'claude-haiku-4-5': row(1, 1.25, 2, 0.1, 5),
39
+ 'claude-haiku-3-5': row(0.8, 1, 1.6, 0.08, 4),
40
+ });
41
+
42
+ /** The token kinds a usage record carries, in the order every report prints them. */
43
+ export const TOKEN_KINDS = Object.freeze([
44
+ 'input',
45
+ 'output',
46
+ 'cache_read',
47
+ 'cache_write_5m',
48
+ 'cache_write_1h',
49
+ ]);
50
+
51
+ /** `claude-haiku-4-5-20251001` → `claude-haiku-4-5`: a dated snapshot is priced as its model. */
52
+ export function normalizeModel(model) {
53
+ return String(model || '').replace(/-\d{8}$/, '');
54
+ }
55
+
56
+ /** The price row for a model id, or null when the table does not know it (→ `$ unknown`, never $0 — D4). */
57
+ export function priceFor(model) {
58
+ return PRICES[normalizeModel(model)] ?? null;
59
+ }
60
+
61
+ /**
62
+ * The tokens of one transcript `message.usage`, split by kind. A cache write with no 5m/1h split is counted as 5m
63
+ * (the cheaper rate — so the $ is never overstated) and reported in `unsplit_cache_write`.
64
+ */
65
+ export function tokensOf(usage) {
66
+ const u = usage || {};
67
+ const n = (v) => (Number.isFinite(v) && v > 0 ? v : 0);
68
+ const split = u.cache_creation && typeof u.cache_creation === 'object' ? u.cache_creation : null;
69
+ const total = n(u.cache_creation_input_tokens);
70
+ let w5 = split ? n(split.ephemeral_5m_input_tokens) : 0;
71
+ const w1 = split ? n(split.ephemeral_1h_input_tokens) : 0;
72
+ let unsplit = 0;
73
+ if (!split || w5 + w1 < total) {
74
+ unsplit = total - w5 - w1;
75
+ w5 += unsplit;
76
+ }
77
+ return {
78
+ input: n(u.input_tokens),
79
+ output: n(u.output_tokens),
80
+ cache_read: n(u.cache_read_input_tokens),
81
+ cache_write_5m: w5,
82
+ cache_write_1h: w1,
83
+ unsplit_cache_write: unsplit,
84
+ };
85
+ }
86
+
87
+ /**
88
+ * ≈ API $ for one turn: Σ tokens × price per kind, × 2 in fast mode, × 1.1 for US-only inference. null when it cannot
89
+ * be priced (an unknown model, or fast mode on a model the page lists no fast price for).
90
+ */
91
+ export function usdOf(model, tokens, { speed = 'standard', inferenceGeo = null } = {}) {
92
+ const p = priceFor(model);
93
+ if (!p) return null;
94
+ let mult = 1;
95
+ if (speed === 'fast') {
96
+ if (!p.fast) return null;
97
+ mult *= 2;
98
+ }
99
+ if (inferenceGeo === 'us') mult *= 1.1;
100
+ let usd = 0;
101
+ for (const kind of TOKEN_KINDS) usd += ((tokens[kind] || 0) * p[kind]) / 1e6;
102
+ return usd * mult;
103
+ }
@@ -27,9 +27,10 @@
27
27
  // Deliberately small, so a zero-dependency parser owns it completely: top-level `key: scalar` lines
28
28
  // (an unquoted value may carry a trailing ` # comment`), whole-line `#` comments at any indent, and a
29
29
  // top-level `key:` followed by a list of flat maps (` - k: v` then ` k: v`). A scalar is `null`,
30
- // `~` or empty (→ null), `[]` (an empty list), an integer, a double-quoted JSON string, a single-quoted
31
- // YAML string, or a bare string; any of them may carry a trailing ` # comment`. Booleans and floats are
32
- // not in the subset (no contract field uses one). Anything else is a parse error, not guessed around.
30
+ // `~` or empty (→ null), `[]` (an empty list), an integer, a decimal (`38.42`, finops D17 — the FinOps
31
+ // fields carry dollars), a double-quoted JSON string, a single-quoted YAML string, or a bare string; any of
32
+ // them may carry a trailing ` # comment`. Booleans are not in the subset (no contract field uses one), and
33
+ // neither are exponents or a bare `.5`. Anything else is a parse error, not guessed around.
33
34
 
34
35
  export const PHASES = ['Shaping', 'Locking architecture', 'Building', 'Verifying', 'In review', 'Shipped'];
35
36
  export const STORY_STATUSES = ['planned', 'in-progress', 'done'];
@@ -40,6 +41,53 @@ export const EPIC_FIELDS = ['title', 'area', 'risk', 'type', 'phase', 'sprints_t
40
41
  export const SPRINT_FIELDS = ['epic', 'sprint', 'title', 'risk', 'phase', 'stories_total', 'stories'];
41
42
  export const STORY_FIELDS = ['id', 'title', 'as_a', 'i_want', 'so_that', 'risk', 'status'];
42
43
 
44
+ // finops D6/D17 — an epic's quote (written at grooming) and its actual (stamped at close), declared ONCE here.
45
+ // All optional: an epic scaffolded before FinOps has none, and an absent field is never a zero (D4). The numeric
46
+ // ones are numbers >= 0 or null; a `*_basis` says where the number came from, in words.
47
+ export const FINOPS_NUMERIC_FIELDS = ['quote_low_usd', 'quote_high_usd', 'actual_usd', 'actual_mtok'];
48
+ export const FINOPS_BASIS_FIELDS = ['quote_basis', 'actual_basis'];
49
+ export const FINOPS_FIELDS = [
50
+ 'quote_low_usd',
51
+ 'quote_high_usd',
52
+ 'quote_basis',
53
+ 'actual_usd',
54
+ 'actual_mtok',
55
+ 'actual_basis',
56
+ ];
57
+
58
+ /** The FinOps fields of an epic's frontmatter data → offenses (`contract-finops-invalid`). Absent/null is fine. */
59
+ export function validateFinopsFields(fm) {
60
+ const offenses = [];
61
+ for (const key of FINOPS_NUMERIC_FIELDS) {
62
+ const v = fm[key];
63
+ if (v === undefined || v === null) continue;
64
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0)
65
+ offenses.push({
66
+ rule: 'contract-finops-invalid',
67
+ detail: `${key}: "${v}" is not a number >= 0 (or null)`,
68
+ });
69
+ }
70
+ for (const key of FINOPS_BASIS_FIELDS) {
71
+ const v = fm[key];
72
+ if (v === undefined || v === null) continue;
73
+ if (typeof v !== 'string')
74
+ offenses.push({ rule: 'contract-finops-invalid', detail: `${key}: "${v}" is not a string (or null)` });
75
+ }
76
+ const lo = fm.quote_low_usd;
77
+ const hi = fm.quote_high_usd;
78
+ if (typeof lo === 'number' && typeof hi === 'number' && lo > hi)
79
+ offenses.push({
80
+ rule: 'contract-finops-invalid',
81
+ detail: `quote_low_usd ${lo} is above quote_high_usd ${hi}`,
82
+ });
83
+ if ((typeof lo === 'number') !== (typeof hi === 'number'))
84
+ offenses.push({
85
+ rule: 'contract-finops-invalid',
86
+ detail: 'a quote needs both quote_low_usd and quote_high_usd',
87
+ });
88
+ return offenses;
89
+ }
90
+
43
91
  // A story id names its sprint: S<sprint>.<ordinal>. The commit convention D2 derives from uses it.
44
92
  export const STORY_ID_RE = /^S(\d+)\.(\d+)$/;
45
93
 
@@ -58,6 +106,9 @@ function parseScalar(raw, where) {
58
106
  if (v === '' || v === 'null' || v === '~') return null;
59
107
  if (v === '[]') return [];
60
108
  if (/^-?\d+$/.test(v)) return Number(v);
109
+ // finops D17 — decimals. The corpus held no bare decimal frontmatter value when this was added (2026-10-02),
110
+ // so no existing reading changed.
111
+ if (/^-?\d+\.\d+$/.test(v)) return Number(v);
61
112
  if (v.startsWith('"')) {
62
113
  try {
63
114
  return JSON.parse(v);
@@ -139,7 +190,8 @@ export function formatScalar(v) {
139
190
  if (v === null || v === undefined) return 'null';
140
191
  if (typeof v === 'number') return String(v);
141
192
  const s = String(v);
142
- if (BARE_SAFE.test(s) && !/^-?\d+$/.test(s) && !RESERVED.has(s.toLowerCase()) && !s.endsWith(' ')) return s;
193
+ if (BARE_SAFE.test(s) && !/^-?\d+(\.\d+)?$/.test(s) && !RESERVED.has(s.toLowerCase()) && !s.endsWith(' '))
194
+ return s;
143
195
  return JSON.stringify(s);
144
196
  }
145
197
 
@@ -203,6 +255,7 @@ export function validateEpicFrontmatter(parsed, ctx = {}) {
203
255
  if (fm[key] != null && !isInt(fm[key]))
204
256
  offenses.push({ rule: 'contract-total-invalid', detail: `${key}: "${fm[key]}" is not a whole number` });
205
257
  }
258
+ offenses.push(...validateFinopsFields(fm));
206
259
  if (isInt(fm.sprints_total) && isInt(ctx.sprintCount) && fm.sprints_total !== ctx.sprintCount)
207
260
  offenses.push({
208
261
  rule: 'contract-total-mismatch',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "The scripts the Golden Frijoles skills run, packaged so they work in any repo: planning, build-order, reporting and verification rails. Zero dependencies.",
5
5
  "type": "module",
6
6
  "bin": {