@golden-frijoles/kit 0.23.0 → 0.24.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.
@@ -290,6 +290,10 @@ export function spendFor(root, git, slug) {
290
290
  sessions: Number.isInteger(row.sessions) ? row.sessions : null,
291
291
  basis: typeof summary.basis === 'string' ? summary.basis : 'this machine',
292
292
  generated_at: summary.generated_at ?? null,
293
+ // finops S3 (round 3, #232): snapshots the engine refused as malformed, cumulative for this index. Shown on the
294
+ // Spend line so a refusal during the automatic refresh is never only a number in a file nobody opens.
295
+ push_rejected:
296
+ Number.isInteger(summary.push_rejected) && summary.push_rejected > 0 ? summary.push_rejected : 0,
293
297
  };
294
298
  } catch {
295
299
  return null;
@@ -323,6 +327,13 @@ export function quoteLabel(basis, appetite = null) {
323
327
  * With no quote object at all (a seed, an old caller) it is Sprint 1's line, which names where it was measured.
324
328
  */
325
329
  export function spendValue(spend, quote) {
330
+ const line = spendLine(spend, quote);
331
+ return spend.push_rejected
332
+ ? `${line} · ${spend.push_rejected} usage push${spend.push_rejected === 1 ? '' : 'es'} refused — epic-actuals --push --json`
333
+ : line;
334
+ }
335
+
336
+ function spendLine(spend, quote) {
326
337
  const tok = spend.mtok !== null ? `${spend.mtok}M tok` : null;
327
338
  const sessions =
328
339
  spend.sessions !== null ? `${spend.sessions} session${spend.sessions === 1 ? '' : 's'}` : null;
@@ -5,6 +5,7 @@
5
5
  // node scripts/epic-actuals.mjs --refresh [--json] # update the index + summary only (what the band's mod runs)
6
6
  // node scripts/epic-actuals.mjs --backfill [--write] # every shipped epic: measured, or why not; --write stamps
7
7
  // node scripts/epic-actuals.mjs --epic <slug> --write # stamp actual_* into that epic's README (at close)
8
+ // node scripts/epic-actuals.mjs --push # send changed sessions to the engine (opt-in: spend.telemetry)
8
9
  // options: --repo-root <dir> (default: the project) · --projects-dir <dir> (default: ~/.claude/projects)
9
10
  //
10
11
  // ── Where the numbers come from (README § Architecture lock) ────────────────────────────────────────
@@ -52,6 +53,8 @@ import { resolveTarget } from './build-state.mjs';
52
53
  import { buildRows } from './roadmap-extract.mjs';
53
54
  import { projectRoot } from './lib/project-root.mjs';
54
55
  import { formatScalar, parseDocFrontmatter } from './lib/roadmap-contract.mjs';
56
+ import { needSetting } from './lib/config.mjs';
57
+ import { apiKeyFrom } from './roadmap-push.mjs';
55
58
  import { PRICES_AS_OF, PRICES_SOURCE, TOKEN_KINDS, tokensOf, usdOf } from './lib/model-prices.mjs';
56
59
 
57
60
  export const INDEX_VERSION = 1;
@@ -490,7 +493,16 @@ export function epicOfBranch(root) {
490
493
  }
491
494
 
492
495
  /** 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 }) {
496
+ export async function refresh({
497
+ root,
498
+ projectsDir = defaultProjectsDir(),
499
+ now = new Date(),
500
+ budgetMs = Infinity,
501
+ push = false,
502
+ throttle = false,
503
+ env = process.env,
504
+ fetchImpl = globalThis.fetch,
505
+ }) {
494
506
  const dir = join(mainCheckout(root), STATE_DIR);
495
507
  const indexPath = join(dir, INDEX_FILE);
496
508
  const index = loadIndex(indexPath);
@@ -499,10 +511,27 @@ export function refresh({ root, projectsDir = defaultProjectsDir(), now = new Da
499
511
  paths: repoPaths(root),
500
512
  deadline: Date.now() + budgetMs,
501
513
  });
502
- const summary = { ...summarize(index, { epicOf: epicOfBranch(root), now }), complete: changes.complete };
503
- if (changes.files_read || !existsSync(indexPath)) writeJson(indexPath, index);
514
+ // The push runs only on a COMPLETE scan: a partial index would send understated snapshots (finops D24).
515
+ const pushed =
516
+ push && changes.complete
517
+ ? await pushUsage({ root, index, epicOf: epicOfBranch(root), env, fetchImpl, throttle })
518
+ : null;
519
+ const summary = {
520
+ ...summarize(index, { epicOf: epicOfBranch(root), now }),
521
+ complete: changes.complete,
522
+ pushed_at: Number.isFinite(index.pushed_at) ? new Date(index.pushed_at).toISOString() : null,
523
+ // Refusals since the last clean push — 0 once a later push went through with none.
524
+ push_rejected:
525
+ Number.isFinite(index.push_rejected_at) && !(index.push_clean_at > index.push_rejected_at)
526
+ ? (index.push_rejected ?? 0)
527
+ : 0,
528
+ };
529
+ // A push that TRIED the network changed the index (attempt time, 409/400 marks) even when no transcript moved — an
530
+ // idle machine must persist its backoff, or it retries every minute and re-sends what was refused (round 2, #232).
531
+ const triedNetwork = pushed && !/^(off|throttled|no ingest key|config unreadable)/.test(pushed.reason);
532
+ if (changes.files_read || triedNetwork || !existsSync(indexPath)) writeJson(indexPath, index);
504
533
  writeJson(join(dir, SUMMARY_FILE), summary);
505
- return { summary, changes, dir };
534
+ return { summary, changes, dir, pushed };
506
535
  }
507
536
 
508
537
  /** One epic's report — the `--epic <slug> --json` shape (a spec pins the key set, D9). */
@@ -526,6 +555,7 @@ export function epicReport(summary, slug) {
526
555
  prices_as_of: summary.prices_as_of,
527
556
  prices_source: summary.prices_source,
528
557
  not_measured: summary.not_measured,
558
+ pushed_at: summary.pushed_at ?? null,
529
559
  };
530
560
  }
531
561
 
@@ -534,6 +564,168 @@ export function usdText(usd, known) {
534
564
  return `${known ? '≈' : '≥'}$${usd >= 100 ? Math.round(usd) : usd.toFixed(2)}`;
535
565
  }
536
566
 
567
+ // ── The engine push (finops S3.1, D21–D24) ────────────────────────────────────────────────────────────
568
+ // OPT-IN: nothing leaves this machine unless `spend.telemetry` is `on` (golden-frijoles.config.json; unset = off).
569
+ // One `$agent_usage` event per (session, epic) on the EXISTING `POST /api/v1/track`, with the project's ingest key —
570
+ // the same one the roadmap push reads (`apiKeyFrom`), so the engine resolves the project from the key and nothing in
571
+ // the body names one (D23). Each event is a cumulative snapshot; the engine keeps the latest per (session, epic)
572
+ // (D22), and the idempotency key makes an unchanged re-push a no-op. Metrics only: the payload is built field by
573
+ // field from the index, which never held content (D9).
574
+
575
+ export const AGENT_USAGE_EVENT = '$agent_usage';
576
+ export const PUSH_EVERY_MS = 10 * 60_000;
577
+ export const PUSH_BUDGET_MS = 5_000;
578
+
579
+ const part = () => ({ tokens: zeroTokens(), usd: 0 });
580
+ function addPart(p, t, usd) {
581
+ for (const k of TOKEN_KINDS) p.tokens[k] += t[k];
582
+ if (usd === null || p.usd === null) p.usd = null;
583
+ else p.usd += usd;
584
+ }
585
+
586
+ /**
587
+ * The index → one snapshot per (session, epic): the exact `$agent_usage` payload (the engine's lib/agent-usage.ts
588
+ * AGENT_USAGE_KEYS). Unattributed turns are never pushed — there is no epic to put them on (D11).
589
+ */
590
+ export function sessionSnapshots(index, { epicOf }) {
591
+ const out = new Map();
592
+ for (const rec of Object.values(index.messages)) {
593
+ const [session, branch, skill, model, at, speed, geo] = rec;
594
+ const epic = branch ? epicOf(branch) : null;
595
+ if (!epic || !session || !at) continue;
596
+ const key = `${session}|${epic}`;
597
+ if (!out.has(key))
598
+ out.set(key, {
599
+ session_id: session,
600
+ epic,
601
+ branch,
602
+ model_breakdown: {},
603
+ skill_breakdown: {},
604
+ tokens_by_kind: zeroTokens(),
605
+ usd_estimate: 0,
606
+ price_table_date: PRICES_AS_OF,
607
+ first_at: at,
608
+ last_at: at,
609
+ });
610
+ const s = out.get(key);
611
+ const t = tokensFromRecord(rec);
612
+ const usd = usdOf(model, t, { speed, inferenceGeo: geo });
613
+ for (const k of TOKEN_KINDS) s.tokens_by_kind[k] += t[k];
614
+ if (usd !== null) s.usd_estimate += usd;
615
+ s.model_breakdown[model] ??= part();
616
+ addPart(s.model_breakdown[model], t, usd);
617
+ const sk = skill || NO_SKILL;
618
+ s.skill_breakdown[sk] ??= part();
619
+ addPart(s.skill_breakdown[sk], t, usd);
620
+ if (at < s.first_at) s.first_at = at;
621
+ if (at > s.last_at) {
622
+ s.last_at = at;
623
+ s.branch = branch;
624
+ }
625
+ }
626
+ const r = (p) => ({ tokens: p.tokens, usd: p.usd === null ? null : round2(p.usd) });
627
+ return [...out.values()].map((s) => ({
628
+ ...s,
629
+ usd_estimate: round2(s.usd_estimate),
630
+ model_breakdown: Object.fromEntries(Object.entries(s.model_breakdown).map(([k, p]) => [k, r(p)])),
631
+ skill_breakdown: Object.fromEntries(Object.entries(s.skill_breakdown).map(([k, p]) => [k, r(p)])),
632
+ // The engine wants ISO-8601 UTC with exactly milliseconds and a `Z`; the transcript writes that shape, copied as is.
633
+ first_at: s.first_at,
634
+ last_at: s.last_at,
635
+ }));
636
+ }
637
+
638
+ /**
639
+ * Push every snapshot that changed since the last push. Returns { sent, rejected, reason }. Never throws: a failure is a
640
+ * reason, and the snapshots it did not send go next time. `index.pushed` remembers what each (session, epic) last sent.
641
+ */
642
+ export async function pushUsage({
643
+ root,
644
+ index,
645
+ epicOf,
646
+ env = process.env,
647
+ fetchImpl = globalThis.fetch,
648
+ now = Date.now(),
649
+ throttle = false,
650
+ }) {
651
+ // Never throws (fresh review, #232): a malformed golden-frijoles.config.json must not take the Spend row's refresh
652
+ // down with it — it is a reason, and the scan still saves.
653
+ let setting;
654
+ try {
655
+ setting = needSetting('spend.telemetry', { root });
656
+ } catch (err) {
657
+ return { sent: 0, reason: `config unreadable — ${err && err.message ? err.message : err}` };
658
+ }
659
+ if (setting !== 'on')
660
+ return { sent: 0, reason: 'off — spend.telemetry is not on (gf-kit config set spend.telemetry on)' };
661
+ // The throttle counts ATTEMPTS, not successes: a persistent 401 or 429 retries every 10 minutes, not every minute.
662
+ const last = Math.max(index.pushed_at ?? -Infinity, index.push_attempt_at ?? -Infinity);
663
+ if (throttle && Number.isFinite(last) && now - last < PUSH_EVERY_MS)
664
+ return { sent: 0, reason: 'throttled' };
665
+ const key = apiKeyFrom(env);
666
+ if (!key) return { sent: 0, reason: 'no ingest key — set SELF_PROJECT_API_KEY (or GROWTH_ENGINE_API_KEY)' };
667
+ const base = String(env.GROWTH_ENGINE_URL || 'http://localhost:3000').replace(/\/+$/, '');
668
+ index.pushed ??= {};
669
+ index.push_attempt_at = now;
670
+ const due = sessionSnapshots(index, { epicOf }).filter(
671
+ (s) => index.pushed[`${s.session_id}|${s.epic}`] !== s.last_at
672
+ );
673
+ const deadline = Date.now() + PUSH_BUDGET_MS;
674
+ let sent = 0;
675
+ let rejected = 0;
676
+ for (const s of due) {
677
+ const left = deadline - Date.now();
678
+ if (left <= 0) return { sent, rejected, reason: 'budget — the rest go next time' };
679
+ let res;
680
+ try {
681
+ res = await fetchImpl(`${base}/api/v1/track`, {
682
+ method: 'POST',
683
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
684
+ body: JSON.stringify({
685
+ userId: 'agent:claude-code',
686
+ event: AGENT_USAGE_EVENT,
687
+ metadata: s,
688
+ context: { version: 1, idempotencyKey: `agent_usage:${s.session_id}:${s.epic}:${s.last_at}` },
689
+ }),
690
+ // Clamped to what is left of the budget, so the run as a whole stays inside the hook's timeout.
691
+ signal: AbortSignal.timeout(Math.max(1, left)),
692
+ });
693
+ } catch (err) {
694
+ return { sent, rejected, reason: `network — ${err && err.message ? err.message : err}` };
695
+ }
696
+ // Per snapshot, never "one bad row stops everything forever" (fresh review, #232):
697
+ // 201 stored · 200 this exact snapshot was already there → done.
698
+ // 409 the engine already holds a snapshot at this (session, epic, last_at) with other numbers — a rebuilt index,
699
+ // or a new price table re-pricing an old session. The engine's copy stands (append-only, latest-wins) → done.
700
+ // 400 this snapshot will never be accepted (say, a branch name over the limit) → recorded and counted (the CLI says so
701
+ // and exits non-zero); not retried at this `last_at` — a grown session tries again.
702
+ // anything else (401 wrong key, 429 quota, 5xx) → stop; it is about the run, not the row. Next try in 10 min.
703
+ if (res.status === 201 || res.status === 200 || res.status === 409) {
704
+ index.pushed[`${s.session_id}|${s.epic}`] = s.last_at;
705
+ if (res.status !== 409) sent++;
706
+ continue;
707
+ }
708
+ if (res.status === 400) {
709
+ index.pushed[`${s.session_id}|${s.epic}`] = s.last_at;
710
+ index.push_rejected = (index.push_rejected ?? 0) + 1;
711
+ index.push_rejected_at = now;
712
+ rejected++;
713
+ continue;
714
+ }
715
+ return { sent, rejected, reason: `engine answered ${res.status}` };
716
+ }
717
+ index.pushed_at = now;
718
+ // A run that finished with no refusal clears the alarm (round 4, #232): the band stops saying "refused" once a later
719
+ // push went through cleanly, so a signal that fired once does not become one people learn to ignore.
720
+ // CLEAN means something actually went through with no refusal — an idle run ("nothing changed") or one where every
721
+ // snapshot was a 409 proves nothing, and must not silence a refusal that is still current (round 5, #232).
722
+ if (sent > 0 && !rejected) {
723
+ index.push_clean_at = now;
724
+ index.push_rejected = 0;
725
+ }
726
+ return { sent, rejected, reason: due.length ? 'ok' : 'nothing changed' };
727
+ }
728
+
537
729
  // ── Stamping actual_* (S1.4 backfill, S2.5 close) ───────────────────────────────────────────────────
538
730
 
539
731
  /**
@@ -674,7 +866,7 @@ function printEpic(r) {
674
866
  return lines.join('\n');
675
867
  }
676
868
 
677
- function main(argv) {
869
+ async function main(argv) {
678
870
  const rootArg = arg(argv, '--repo-root');
679
871
  const root = resolve(rootArg ?? projectRoot());
680
872
  const projectsDir = arg(argv, '--projects-dir') ?? defaultProjectsDir();
@@ -683,11 +875,34 @@ function main(argv) {
683
875
  const today = new Date().toISOString().slice(0, 10);
684
876
  // --refresh is what the mod runs under a 10 s timeout (D24): it saves what it read within REFRESH_BUDGET_MS.
685
877
  const budgetMs = argv.includes('--refresh') ? REFRESH_BUDGET_MS : Infinity;
686
- const { summary, changes } = refresh({ root, projectsDir, budgetMs });
878
+ // The push: `--push` asks for it now; `--refresh` (the mod) pushes too when `spend.telemetry` is on, at most every
879
+ // PUSH_EVERY_MS. With the setting off both are a no-op that says so (D21).
880
+ const wantsPush = argv.includes('--push') || argv.includes('--refresh');
881
+ const { summary, changes, pushed } = await refresh({
882
+ root,
883
+ projectsDir,
884
+ budgetMs,
885
+ push: wantsPush,
886
+ throttle: !argv.includes('--push'),
887
+ });
888
+ if (argv.includes('--push')) {
889
+ // This run's refusals, and every one before it (a refusal during the band's automatic refresh lands here too).
890
+ const total = summary.push_rejected ?? 0; // since the last clean push
891
+ const refused = total
892
+ ? ` · ${pushed?.rejected ?? 0} refused now, ${total} since the last clean push — the engine answered 400 (malformed); run --push --json and report it`
893
+ : '';
894
+ process.stdout.write(
895
+ `epic-actuals: pushed ${pushed?.sent ?? 0} session snapshot(s) — ${pushed?.reason ?? 'scan incomplete, nothing sent'}${refused}\n`
896
+ );
897
+ // The exit code is THIS run's: a refusal last week must not fail every --push after it.
898
+ return pushed && /^(ok|nothing changed)$/.test(pushed.reason) && !pushed.rejected ? 0 : 1;
899
+ }
687
900
 
688
901
  if (argv.includes('--refresh')) {
689
902
  if (json)
690
- process.stdout.write(`${JSON.stringify({ changes, epics: Object.keys(summary.epics).length })}\n`);
903
+ process.stdout.write(
904
+ `${JSON.stringify({ changes, epics: Object.keys(summary.epics).length, pushed })}\n`
905
+ );
691
906
  return 0;
692
907
  }
693
908
  const slug = arg(argv, '--epic');
@@ -790,7 +1005,7 @@ const isMain = (() => {
790
1005
  })();
791
1006
  if (isMain) {
792
1007
  try {
793
- process.exitCode = main(process.argv.slice(2));
1008
+ process.exitCode = await main(process.argv.slice(2));
794
1009
  } catch (err) {
795
1010
  process.stderr.write(`epic-actuals: ${err && err.message ? err.message : err}\n`);
796
1011
  process.exitCode = 2;
@@ -185,7 +185,9 @@ export const REGISTRY = Object.freeze([
185
185
  askWhen: 'never-yet',
186
186
  default: 'off',
187
187
  choices: ['off', 'on'],
188
- question: 'Export cost telemetry?',
188
+ // finops D21 — the opt-in for `epic-actuals --push`: one `$agent_usage` event per (session, epic), metrics only.
189
+ question:
190
+ "Send this project's Claude Code usage to its Golden Frijoles project (token counts, models, skills and ≈ API $ per session and epic — never message content)?",
189
191
  },
190
192
  ]);
191
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.23.0",
3
+ "version": "0.24.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": {