@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.
- package/dist/build-state.mjs +56 -2
- package/dist/epic-actuals.mjs +798 -0
- package/dist/lib/cross-agent-cli.mjs +2 -2
- package/dist/lib/model-prices.mjs +103 -0
- package/dist/lib/roadmap-contract.mjs +57 -4
- package/package.json +1 -1
package/dist/build-state.mjs
CHANGED
|
@@ -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-
|
|
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.
|
|
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
|
|
31
|
-
//
|
|
32
|
-
// not in the subset (no contract field uses one)
|
|
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
|
|
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.
|
|
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": {
|