@golden-frijoles/kit 0.22.0 → 0.23.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.
@@ -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
  };
@@ -301,13 +302,50 @@ export function dollars(usd, known = true) {
301
302
  return usd > 0 && usd < 1 ? `${sign}$${usd.toFixed(2)}` : `${sign}$${Math.round(usd)}`;
302
303
  }
303
304
 
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(' · ');
305
+ /**
306
+ * The quote's label (finops S2.4): `(M)` for a calibrated quote, `(M · 2 past epics, wide)` while history is thin. Read
307
+ * from the `quote_basis` quote.mjs writes (`M, n=2, wide` / `M, n=5, p25–p75`); an unreadable basis shows as written.
308
+ */
309
+ export function quoteLabel(basis, appetite = null) {
310
+ const m = /^\s*([SML])\s*,\s*n=(\d+)\s*,\s*(wide|p25–p75)\s*$/.exec(String(basis ?? ''));
311
+ if (m)
312
+ return m[3] === 'wide' ? `(${m[1]} · ${m[2]} past epic${m[2] === '1' ? '' : 's'}, wide)` : `(${m[1]})`;
313
+ if (basis) return `(${basis})`;
314
+ return appetite ? `(${appetite})` : '';
315
+ }
316
+
317
+ /**
318
+ * The Spend line's value — the four states of the approved mockup (finops S2.4; the resolver owns the words, D3):
319
+ * inside ≈$38 of quote $30–55 (M) · 1.9M tok · 4 sessions
320
+ * over ≈$71 · 29% over quote $30–55 (M) · 3.4M tok (D8: alert-only)
321
+ * no quote ≈$22 · no quote · 1.1M tok · 2 sessions (never an invented quote)
322
+ * thin ≈$9 of quote $25–90 (M · 2 past epics, wide) · …
323
+ * With no quote object at all (a seed, an old caller) it is Sprint 1's line, which names where it was measured.
324
+ */
325
+ export function spendValue(spend, quote) {
326
+ const tok = spend.mtok !== null ? `${spend.mtok}M tok` : null;
327
+ const sessions =
328
+ spend.sessions !== null ? `${spend.sessions} session${spend.sessions === 1 ? '' : 's'}` : null;
329
+ const usd = dollars(spend.usd, spend.usd_known);
330
+ if (quote === undefined) return [usd, tok, sessions, spend.basis].filter(Boolean).join(' · ');
331
+ if (!quote) return [usd, 'no quote', tok, sessions].filter(Boolean).join(' · ');
332
+ const label = quoteLabel(quote.basis, quote.appetite);
333
+ const range = `$${quote.low}–${quote.high}${label ? ` ${label}` : ''}`;
334
+ if (spend.usd > quote.high) {
335
+ const pct = quote.high > 0 ? Math.round(((spend.usd - quote.high) / quote.high) * 100) : null;
336
+ // `<1% over`, never `0% over`: the comparison is on the raw dollars, so a rounded 0 would read as "not over".
337
+ const over = pct === null ? 'over' : pct < 1 ? '<1% over' : `${pct}% over`;
338
+ return [usd, `${over} quote ${range}`, tok].filter(Boolean).join(' · ');
339
+ }
340
+ return [`${usd} of quote ${range}`, tok, sessions].filter(Boolean).join(' · ');
341
+ }
342
+
343
+ /** The epic's quote off its README frontmatter, or null (an unquoted epic — never a $0 quote, D4). */
344
+ export function quoteOf(readme) {
345
+ const lo = readme.quote_low_usd;
346
+ const hi = readme.quote_high_usd;
347
+ if (typeof lo !== 'number' || typeof hi !== 'number' || lo > hi) return null;
348
+ return { low: lo, high: hi, basis: readme.quote_basis ?? null, appetite: readme.appetite ?? null };
311
349
  }
312
350
 
313
351
  /** Does `text` name `slug` as a whole slug — `aws` in "aws S1.1", but not inside "aws-s3 S1.1"? */
@@ -649,6 +687,7 @@ function resolve_({ root, offline = false, git = makeGit(root), facts = null, gh
649
687
  branch,
650
688
  branch_match: target.match,
651
689
  spend: spendFor(root, git, epic.slug),
690
+ quote: epic.quote,
652
691
  lifecycle: epic.lifecycle,
653
692
  epic: {
654
693
  slug: epic.slug,
@@ -797,7 +836,7 @@ export function renderLines(state) {
797
836
  const storyPart = `Story ${progress.story ?? '?'} of ${progress.stories}`;
798
837
  const sprintPart = `Sprint ${progress.sprint ?? '?'} of ${progress.sprints}`;
799
838
  lines.push(`${pad('Progress')}${storyPart} · ${sprintPart}`);
800
- if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend)}`);
839
+ if (state.spend) lines.push(`${pad('Spend')}${spendValue(state.spend, state.quote ?? null)}`);
801
840
  lines.push(`${pad('Status')}${statusValue(state)}`);
802
841
  return [...lines, ...also, ...boardLines(state, pad)];
803
842
  }
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
+ }
@@ -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
- appetite: seed.appetite || null,
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
- 'roadmap-extract: no roadmap-to-notion.mjs beside this script or in the project\'s scripts/ — the Notion sink is opt-in: copy ' +
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.22.0",
3
+ "version": "0.23.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": {