@golden-frijoles/kit 0.22.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.
- package/dist/build-state.mjs +58 -8
- package/dist/epic-actuals.mjs +223 -8
- package/dist/lib/config-registry.mjs +3 -1
- package/dist/quote.mjs +136 -0
- package/dist/roadmap-extract.mjs +22 -2
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +2 -0
- package/package.json +1 -1
package/dist/build-state.mjs
CHANGED
|
@@ -137,6 +137,7 @@ function readEpic(root, slug) {
|
|
|
137
137
|
risk: readme.data.risk ?? null,
|
|
138
138
|
phase: readme.data.phase ?? null,
|
|
139
139
|
lifecycle: readme.data.status ?? null,
|
|
140
|
+
quote: quoteOf(readme.data),
|
|
140
141
|
contract: readme.hasFrontmatter && !readme.error && 'phase' in readme.data,
|
|
141
142
|
sprints,
|
|
142
143
|
};
|
|
@@ -289,6 +290,10 @@ export function spendFor(root, git, slug) {
|
|
|
289
290
|
sessions: Number.isInteger(row.sessions) ? row.sessions : null,
|
|
290
291
|
basis: typeof summary.basis === 'string' ? summary.basis : 'this machine',
|
|
291
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,
|
|
292
297
|
};
|
|
293
298
|
} catch {
|
|
294
299
|
return null;
|
|
@@ -301,13 +306,57 @@ export function dollars(usd, known = true) {
|
|
|
301
306
|
return usd > 0 && usd < 1 ? `${sign}$${usd.toFixed(2)}` : `${sign}$${Math.round(usd)}`;
|
|
302
307
|
}
|
|
303
308
|
|
|
304
|
-
/**
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
309
|
+
/**
|
|
310
|
+
* The quote's label (finops S2.4): `(M)` for a calibrated quote, `(M · 2 past epics, wide)` while history is thin. Read
|
|
311
|
+
* from the `quote_basis` quote.mjs writes (`M, n=2, wide` / `M, n=5, p25–p75`); an unreadable basis shows as written.
|
|
312
|
+
*/
|
|
313
|
+
export function quoteLabel(basis, appetite = null) {
|
|
314
|
+
const m = /^\s*([SML])\s*,\s*n=(\d+)\s*,\s*(wide|p25–p75)\s*$/.exec(String(basis ?? ''));
|
|
315
|
+
if (m)
|
|
316
|
+
return m[3] === 'wide' ? `(${m[1]} · ${m[2]} past epic${m[2] === '1' ? '' : 's'}, wide)` : `(${m[1]})`;
|
|
317
|
+
if (basis) return `(${basis})`;
|
|
318
|
+
return appetite ? `(${appetite})` : '';
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* The Spend line's value — the four states of the approved mockup (finops S2.4; the resolver owns the words, D3):
|
|
323
|
+
* inside ≈$38 of quote $30–55 (M) · 1.9M tok · 4 sessions
|
|
324
|
+
* over ≈$71 · 29% over quote $30–55 (M) · 3.4M tok (D8: alert-only)
|
|
325
|
+
* no quote ≈$22 · no quote · 1.1M tok · 2 sessions (never an invented quote)
|
|
326
|
+
* thin ≈$9 of quote $25–90 (M · 2 past epics, wide) · …
|
|
327
|
+
* With no quote object at all (a seed, an old caller) it is Sprint 1's line, which names where it was measured.
|
|
328
|
+
*/
|
|
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) {
|
|
337
|
+
const tok = spend.mtok !== null ? `${spend.mtok}M tok` : null;
|
|
338
|
+
const sessions =
|
|
339
|
+
spend.sessions !== null ? `${spend.sessions} session${spend.sessions === 1 ? '' : 's'}` : null;
|
|
340
|
+
const usd = dollars(spend.usd, spend.usd_known);
|
|
341
|
+
if (quote === undefined) return [usd, tok, sessions, spend.basis].filter(Boolean).join(' · ');
|
|
342
|
+
if (!quote) return [usd, 'no quote', tok, sessions].filter(Boolean).join(' · ');
|
|
343
|
+
const label = quoteLabel(quote.basis, quote.appetite);
|
|
344
|
+
const range = `$${quote.low}–${quote.high}${label ? ` ${label}` : ''}`;
|
|
345
|
+
if (spend.usd > quote.high) {
|
|
346
|
+
const pct = quote.high > 0 ? Math.round(((spend.usd - quote.high) / quote.high) * 100) : null;
|
|
347
|
+
// `<1% over`, never `0% over`: the comparison is on the raw dollars, so a rounded 0 would read as "not over".
|
|
348
|
+
const over = pct === null ? 'over' : pct < 1 ? '<1% over' : `${pct}% over`;
|
|
349
|
+
return [usd, `${over} quote ${range}`, tok].filter(Boolean).join(' · ');
|
|
350
|
+
}
|
|
351
|
+
return [`${usd} of quote ${range}`, tok, sessions].filter(Boolean).join(' · ');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The epic's quote off its README frontmatter, or null (an unquoted epic — never a $0 quote, D4). */
|
|
355
|
+
export function quoteOf(readme) {
|
|
356
|
+
const lo = readme.quote_low_usd;
|
|
357
|
+
const hi = readme.quote_high_usd;
|
|
358
|
+
if (typeof lo !== 'number' || typeof hi !== 'number' || lo > hi) return null;
|
|
359
|
+
return { low: lo, high: hi, basis: readme.quote_basis ?? null, appetite: readme.appetite ?? null };
|
|
311
360
|
}
|
|
312
361
|
|
|
313
362
|
/** Does `text` name `slug` as a whole slug — `aws` in "aws S1.1", but not inside "aws-s3 S1.1"? */
|
|
@@ -649,6 +698,7 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
|
|
|
649
698
|
branch,
|
|
650
699
|
branch_match: target.match,
|
|
651
700
|
spend: spendFor(root, git, epic.slug),
|
|
701
|
+
quote: epic.quote,
|
|
652
702
|
lifecycle: epic.lifecycle,
|
|
653
703
|
epic: {
|
|
654
704
|
slug: epic.slug,
|
|
@@ -797,7 +847,7 @@ export function renderLines(state) {
|
|
|
797
847
|
const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
|
|
798
848
|
const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
|
|
799
849
|
lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
|
|
800
|
-
if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend)}`);
|
|
850
|
+
if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend, state.quote ?? null)}`);
|
|
801
851
|
lines.push(`${pad('Status')}${statusValue(state)}`);
|
|
802
852
|
return [...lines, ...also, ...boardLines(state, pad)];
|
|
803
853
|
}
|
package/dist/epic-actuals.mjs
CHANGED
|
@@ -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({
|
|
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
|
-
|
|
503
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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/dist/quote.mjs
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// quote.mjs — the quote for an appetite, calibrated from this project's own shipped epics (finops S2.2, D7).
|
|
3
|
+
//
|
|
4
|
+
// node scripts/quote.mjs --appetite M # one line: $30–55 (M, n=6, p25–p75)
|
|
5
|
+
// node scripts/quote.mjs --appetite M --json # for the scaffolder and the band
|
|
6
|
+
// node scripts/quote.mjs --report # per appetite: how many actuals landed inside their quote
|
|
7
|
+
//
|
|
8
|
+
// The quote is the p25–p75 of `actual_usd` over SHIPPED epics at the same appetite — the middle half of what epics
|
|
9
|
+
// that size actually cost here. Until there are MIN_HISTORY of them it is WIDE_DEFAULT, labelled `wide` with its n,
|
|
10
|
+
// never a guess dressed as a calibration (D4). It is recomputed at every groom, so it moves as history accumulates.
|
|
11
|
+
// Epics are read through the extractor (`buildRows`), so appetite follows the board's own rule (README, else seed —
|
|
12
|
+
// D20) and no second frontmatter reader exists.
|
|
13
|
+
//
|
|
14
|
+
// The unit is ≈ API $ (list-price equivalent, D3) — see lib/model-prices.mjs.
|
|
15
|
+
//
|
|
16
|
+
// Zero deps — Node 18+.
|
|
17
|
+
|
|
18
|
+
import { realpathSync } from 'node:fs';
|
|
19
|
+
import { resolve } from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
import { buildRows } from './roadmap-extract.mjs';
|
|
22
|
+
import { projectRoot } from './lib/project-root.mjs';
|
|
23
|
+
|
|
24
|
+
export const APPETITES = Object.freeze(['S', 'M', 'L']);
|
|
25
|
+
export const MIN_HISTORY = 3;
|
|
26
|
+
/** The band before there is history (≈ API $). Deliberately wide: it says "we don't know yet", in dollars. */
|
|
27
|
+
export const WIDE_DEFAULT = Object.freeze({ S: [5, 40], M: [20, 120], L: [50, 300] });
|
|
28
|
+
|
|
29
|
+
/** Linear-interpolated percentile (p in 0..1) of a sorted list. */
|
|
30
|
+
export function percentile(sorted, p) {
|
|
31
|
+
if (!sorted.length) return null;
|
|
32
|
+
const i = (sorted.length - 1) * p;
|
|
33
|
+
const lo = Math.floor(i);
|
|
34
|
+
const hi = Math.ceil(i);
|
|
35
|
+
return sorted[lo] + (sorted[hi] - sorted[lo]) * (i - lo);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The shipped actuals at an appetite, ascending. An epic with a quote but no actual is ignored. */
|
|
39
|
+
export function actualsAt(rows, appetite) {
|
|
40
|
+
return rows
|
|
41
|
+
.filter((r) => r.grain === 'Epic' && r.status === 'Shipped' && r.appetite === appetite)
|
|
42
|
+
.map((r) => r.actual_usd)
|
|
43
|
+
.filter((v) => typeof v === 'number' && Number.isFinite(v))
|
|
44
|
+
.sort((a, b) => a - b);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** `M, n=5, p25–p75` / `M, n=2, wide` — the basis string the scaffolder writes and the band reads. */
|
|
48
|
+
export function basisOf(q) {
|
|
49
|
+
return `${q.appetite}, n=${q.n}, ${q.kind}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The quote for an appetite: { appetite, low, high, n, kind: 'p25–p75' | 'wide', basis, line }. */
|
|
53
|
+
export function quoteFor(rows, appetite) {
|
|
54
|
+
if (!APPETITES.includes(appetite))
|
|
55
|
+
throw new Error(`appetite must be one of ${APPETITES.join(', ')} (got ${appetite})`);
|
|
56
|
+
const xs = actualsAt(rows, appetite);
|
|
57
|
+
const calibrated = xs.length >= MIN_HISTORY;
|
|
58
|
+
const [low, high] = calibrated
|
|
59
|
+
? [Math.floor(percentile(xs, 0.25)), Math.ceil(percentile(xs, 0.75))]
|
|
60
|
+
: WIDE_DEFAULT[appetite];
|
|
61
|
+
const q = { appetite, low, high, n: xs.length, kind: calibrated ? 'p25–p75' : 'wide' };
|
|
62
|
+
return { ...q, basis: basisOf(q), line: `$${low}–${high} (${basisOf(q)})` };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The calibration signal: per appetite, of the shipped epics that carry BOTH a quote and an actual, how many landed
|
|
67
|
+
* inside it, and the n behind today's quote.
|
|
68
|
+
*/
|
|
69
|
+
export function calibrationReport(rows) {
|
|
70
|
+
return APPETITES.map((appetite) => {
|
|
71
|
+
const judged = rows.filter(
|
|
72
|
+
(r) =>
|
|
73
|
+
r.grain === 'Epic' &&
|
|
74
|
+
r.status === 'Shipped' &&
|
|
75
|
+
r.appetite === appetite &&
|
|
76
|
+
typeof r.actual_usd === 'number' &&
|
|
77
|
+
typeof r.quote_low_usd === 'number' &&
|
|
78
|
+
typeof r.quote_high_usd === 'number'
|
|
79
|
+
);
|
|
80
|
+
const inside = judged.filter(
|
|
81
|
+
(r) => r.actual_usd >= r.quote_low_usd && r.actual_usd <= r.quote_high_usd
|
|
82
|
+
).length;
|
|
83
|
+
const over = judged.filter((r) => r.actual_usd > r.quote_high_usd).length;
|
|
84
|
+
return {
|
|
85
|
+
appetite,
|
|
86
|
+
quote: quoteFor(rows, appetite),
|
|
87
|
+
judged: judged.length,
|
|
88
|
+
inside,
|
|
89
|
+
over,
|
|
90
|
+
under: judged.length - inside - over,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function main(argv) {
|
|
96
|
+
const i = argv.indexOf('--repo-root');
|
|
97
|
+
const root = resolve(i !== -1 && argv[i + 1] ? argv[i + 1] : projectRoot());
|
|
98
|
+
const rows = buildRows({ root, dates: false });
|
|
99
|
+
if (argv.includes('--report')) {
|
|
100
|
+
const rep = calibrationReport(rows);
|
|
101
|
+
if (argv.includes('--json')) process.stdout.write(`${JSON.stringify(rep, null, 2)}\n`);
|
|
102
|
+
else
|
|
103
|
+
for (const r of rep)
|
|
104
|
+
process.stdout.write(
|
|
105
|
+
`${r.appetite} quote ${r.quote.line} · ${r.judged ? `${r.inside} of ${r.judged} landed inside (${r.over} over, ${r.under} under)` : 'no quoted epic has shipped yet'}\n`
|
|
106
|
+
);
|
|
107
|
+
return 0;
|
|
108
|
+
}
|
|
109
|
+
const a = argv.indexOf('--appetite');
|
|
110
|
+
const appetite = a !== -1 ? String(argv[a + 1] || '').toUpperCase() : null;
|
|
111
|
+
if (!appetite) {
|
|
112
|
+
process.stderr.write('usage: quote.mjs --appetite S|M|L [--json] | --report [--json]\n');
|
|
113
|
+
return 2;
|
|
114
|
+
}
|
|
115
|
+
const q = quoteFor(rows, appetite);
|
|
116
|
+
process.stdout.write(argv.includes('--json') ? `${JSON.stringify(q)}\n` : `${q.line}\n`);
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const isMain = (() => {
|
|
121
|
+
try {
|
|
122
|
+
return (
|
|
123
|
+
!!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
|
|
124
|
+
);
|
|
125
|
+
} catch {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
})();
|
|
129
|
+
if (isMain) {
|
|
130
|
+
try {
|
|
131
|
+
process.exitCode = main(process.argv.slice(2));
|
|
132
|
+
} catch (err) {
|
|
133
|
+
process.stderr.write(`quote: ${err && err.message ? err.message : err}\n`);
|
|
134
|
+
process.exitCode = 2;
|
|
135
|
+
}
|
|
136
|
+
}
|
package/dist/roadmap-extract.mjs
CHANGED
|
@@ -68,6 +68,7 @@ import { attributeFacts, resolveStage } from './lib/stage.mjs';
|
|
|
68
68
|
import { gatherFacts } from './lib/stage-facts.mjs';
|
|
69
69
|
import { epicKickoffFromDir, sprintBranch } from './lib/epic-kickoff.mjs';
|
|
70
70
|
import { renderBoardText } from './lib/board-text.mjs';
|
|
71
|
+
import { FINOPS_FIELDS, FINOPS_NUMERIC_FIELDS } from './lib/roadmap-contract.mjs';
|
|
71
72
|
import { pushRoadmap, reportPush } from './roadmap-push.mjs';
|
|
72
73
|
|
|
73
74
|
const REPO = projectRoot(); // D2 — the CLI's default root; buildRows takes its own
|
|
@@ -109,6 +110,23 @@ const PRIORITY_LABEL = {
|
|
|
109
110
|
};
|
|
110
111
|
const TYPE_LABEL = { feature: 'Feature', spike: 'Spike', chore: 'Chore', bug: 'Bug', epic: 'Epic' };
|
|
111
112
|
|
|
113
|
+
/**
|
|
114
|
+
* finops D6 — an epic's quote and actual, off its README frontmatter (this file's own line reader yields strings).
|
|
115
|
+
* A number field that does not read as a finite number >= 0 is null, never 0: doc-format reports the bad value, and
|
|
116
|
+
* the board must not show a quote nobody wrote. The field list is the contract's, not a second copy.
|
|
117
|
+
*/
|
|
118
|
+
export function finopsFields(fm) {
|
|
119
|
+
const out = {};
|
|
120
|
+
for (const key of FINOPS_FIELDS) {
|
|
121
|
+
const raw = fm[key];
|
|
122
|
+
if (FINOPS_NUMERIC_FIELDS.includes(key)) {
|
|
123
|
+
const n = raw === null || raw === undefined || raw === '' ? NaN : Number(raw);
|
|
124
|
+
out[key] = Number.isFinite(n) && n >= 0 ? n : null;
|
|
125
|
+
} else out[key] = typeof raw === 'string' && raw ? raw : null;
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
112
130
|
function parseFrontmatter(md) {
|
|
113
131
|
if (!md.startsWith('---')) return {};
|
|
114
132
|
const end = md.indexOf('\n---', 3);
|
|
@@ -546,7 +564,8 @@ export function buildRows({
|
|
|
546
564
|
priority,
|
|
547
565
|
type: TYPE_LABEL[epicFm.type || seed.type] || 'Epic',
|
|
548
566
|
risk,
|
|
549
|
-
|
|
567
|
+
// finops D20 — the README's own appetite first (an epic groomed after FinOps carries it), else the seed's.
|
|
568
|
+
appetite: epicFm.appetite || seed.appetite || null,
|
|
550
569
|
underwritten_by: seed.underwritten_by || null,
|
|
551
570
|
sprint_progress: totStories ? `${doneStories}/${totStories} stories` : `${sprints.length} sprints`,
|
|
552
571
|
build_order: buildOrder,
|
|
@@ -564,6 +583,7 @@ export function buildRows({
|
|
|
564
583
|
pr: prOf(pr),
|
|
565
584
|
kickoff,
|
|
566
585
|
shipped_at: stage === 'Shipped' ? statusDay : null,
|
|
586
|
+
...finopsFields(epicFm),
|
|
567
587
|
});
|
|
568
588
|
|
|
569
589
|
// Sprint rows (one per sprint-N.md), related to the Epic by slug. boardSprints already carries
|
|
@@ -707,7 +727,7 @@ if (isMain) {
|
|
|
707
727
|
].find((p) => existsSync(p));
|
|
708
728
|
if (!notion) {
|
|
709
729
|
process.stderr.write(
|
|
710
|
-
|
|
730
|
+
"roadmap-extract: no roadmap-to-notion.mjs beside this script or in the project's scripts/ — the Notion sink is opt-in: copy " +
|
|
711
731
|
'template/optional/notion/roadmap-to-notion.mjs into scripts/ and set NOTION_TOKEN + NOTION_DB_ID.\n'
|
|
712
732
|
);
|
|
713
733
|
process.exit(2);
|
|
@@ -186,6 +186,8 @@ mechanical half (sprints merged, README `status: shipped`, sprint statuses ticke
|
|
|
186
186
|
retrospective, no leftover branch) — **and** the judgment items below are true:
|
|
187
187
|
|
|
188
188
|
- [ ] The **product poster** reflects what is now live (✅ = enforced in code).
|
|
189
|
+
- [ ] **The actual is stamped** — `node scripts/epic-actuals.mjs --epic <slug> --write` writes `actual_*` into the
|
|
190
|
+
epic README (`epic-dod` warns when it is missing), and the retro's `Quote vs actual:` line carries it.
|
|
189
191
|
- [ ] **`RETROSPECTIVE.md`** says what actually happened, and its durable learnings are promoted into
|
|
190
192
|
`Roadmap/LEARNINGS.md` — sharpen the existing line, don't append a near-duplicate.
|
|
191
193
|
- [ ] **Each sprint has a smoke walkthrough** a person can follow blind, with real URLs; money/auth steps
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@golden-frijoles/kit",
|
|
3
|
-
"version": "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": {
|