@golden-frijoles/kit 0.27.2 → 0.28.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.
@@ -58,12 +58,7 @@ function line(r) {
58
58
  if (r.sprint_progress) meta.push(r.sprint_progress);
59
59
  if (r.risk) meta.push(`risk: ${r.risk}`);
60
60
  if (r.appetite) meta.push(`appetite ${r.appetite}`);
61
- // A queued seed without an underwriter is funded-but-unowned — advisory, loud on the board.
62
- const warn =
63
- r.grain === 'Seed' && r.status === 'Queued' && !r.underwritten_by
64
- ? ' — ⚠️ no underwriter (set `underwritten_by:` at the betting table)'
65
- : '';
66
- return `- [${r.name}](${link}) — ${meta.join(' · ')} · _${r.stage_source}_${warn}`;
61
+ return `- [${r.name}](${link}) — ${meta.join(' · ')} · _${r.stage_source}_`;
67
62
  }
68
63
 
69
64
  function render(rows) {
@@ -130,6 +125,24 @@ if (unfunded.length) {
130
125
  process.exit(1);
131
126
  }
132
127
 
128
+ // Scaffolded ⇒ funded (fund-at-approval D8). A live bet — an epic scaffolded or in progress, or a queued seed — must
129
+ // name the cycle that paid for it, and that cycle must be a file in Roadmap/bets/. `groom` funds a bet at its approval
130
+ // gate (`fund.mjs`), in the same commit as the scaffold, so this only fails on work that skipped the gate.
131
+ const BETS = join(REPO, 'Roadmap', 'bets');
132
+ const cycleOf = (v) => String(v).replace(/^Roadmap\/bets\//, '').replace(/\.md$/, '');
133
+ const liveBets = rows.filter(
134
+ (r) => (r.grain === 'Epic' && ['Scaffolded', 'In progress'].includes(r.status)) || (r.grain === 'Seed' && r.status === 'Queued')
135
+ );
136
+ // A cycle name is kebab-case: anything else (a `/`, a `..`) names no cycle file and is never turned into a path.
137
+ const paidBy = (r) => r.underwritten_by && /^[a-z0-9][a-z0-9-]*$/.test(cycleOf(r.underwritten_by)) && existsSync(join(BETS, `${cycleOf(r.underwritten_by)}.md`));
138
+ const unpaid = liveBets.filter((r) => !paidBy(r));
139
+ if (unpaid.length) {
140
+ console.error('Live bets with no funding record (`underwritten_by:` must name a Roadmap/bets/<cycle>.md):');
141
+ for (const r of unpaid) console.error(` - ${r.doc_link}${r.underwritten_by ? ` — no Roadmap/bets/${cycleOf(r.underwritten_by)}.md` : ''}`);
142
+ console.error('Fund it at the approval gate: node "$GROOM/fund.mjs" --slug <slug> --displaced "<…>" --next | --after <slug>');
143
+ process.exit(1);
144
+ }
145
+
133
146
  const content = render(rows);
134
147
 
135
148
  if (LIVE) {
@@ -145,7 +145,7 @@ const FLAG_RE = /\b[a-z][a-z0-9_]*\.[a-z0-9_]+_enabled\b|\bgf flags (?:create|se
145
145
  const NEGATED_RE =
146
146
  /\b(?:no|without|zero|not an?|nor an?)\s+(?:new\s+)?(?:db\s+|database\s+|schema\s+)?migrations?\b/gi;
147
147
 
148
- export function buildEpicRules({ risk, texts }) {
148
+ export function buildEpicRules({ risk, texts, appetite = null, slug = '<slug>' }) {
149
149
  const all = texts.join('\n').replace(NEGATED_RE, '');
150
150
  const rules = [];
151
151
  if (String(risk).toUpperCase() === 'HIGH')
@@ -160,12 +160,19 @@ export function buildEpicRules({ risk, texts }) {
160
160
  rules.push(
161
161
  '- **Flag:** create it in Golden Frijoles in every env and ACTIVATE it; `gf flags get <key>` must show production.'
162
162
  );
163
+ // fund-at-approval D7 — an L bet is funded one wave at a time; the re-bet is one line, asked where the builder stops.
164
+ if (String(appetite).toUpperCase() === 'L')
165
+ rules.push(
166
+ `- **L bet:** it is funded one wave at a time. When you stop at a wave boundary, ask the product owner one line — ` +
167
+ `"fund the next wave of \`${slug}\`? what does it displace?" — and on yes run groom's ` +
168
+ `\`fund.mjs --slug ${slug} --displaced "<…>"\` (position kept) before the next wave starts.`
169
+ );
163
170
  return rules.length ? `\nFor this epic:\n${rules.join('\n')}\n` : '';
164
171
  }
165
172
 
166
- export function buildEpicKickoff({ macro, slug, epicTitle, risk, sprints, templateText, texts = [] }) {
173
+ export function buildEpicKickoff({ macro, slug, epicTitle, risk, sprints, templateText, texts = [], appetite = null }) {
167
174
  return sub(templateText, {
168
- EPIC_RULES: buildEpicRules({ risk, texts }),
175
+ EPIC_RULES: buildEpicRules({ risk, texts, appetite, slug }),
169
176
  MACRO: macro,
170
177
  SLUG: slug,
171
178
  EPIC_TITLE: epicTitle,
@@ -176,6 +183,28 @@ export function buildEpicKickoff({ macro, slug, epicTitle, risk, sprints, templa
176
183
  });
177
184
  }
178
185
 
186
+ // `appetite:` from a doc's frontmatter (the README's own first, else its seed's — finops D20), or null.
187
+ export function parseAppetite(text) {
188
+ const fm = /^---\n([\s\S]*?)\n---/.exec(text ?? '')?.[1] ?? '';
189
+ return /^appetite:\s*"?([SML])"?\s*(?:#.*)?$/m.exec(fm)?.[1] ?? null;
190
+ }
191
+
192
+ // The epic's seed: the one whose `epic:` names `<macro>/<slug>` (as the extractor matches it — a seed's slug may differ
193
+ // from its epic's), else `seeds/<slug>.md`; '' when there is none.
194
+ function readSeed(read, list, dir, macro, slug) {
195
+ const seeds = join(dir, '..', '..', '00-ideas', 'seeds');
196
+ const pointer = new RegExp(`^epic:\\s*"?${macro}/${slug}"?\\s*(?:#.*)?$`, 'm');
197
+ try {
198
+ for (const name of list(seeds).filter((n) => String(n).endsWith('.md'))) {
199
+ const text = read(join(seeds, String(name)));
200
+ if (pointer.test(/^---\n([\s\S]*?)\n---/.exec(text)?.[1] ?? '')) return text;
201
+ }
202
+ return read(join(seeds, `${slug}.md`));
203
+ } catch {
204
+ return '';
205
+ }
206
+ }
207
+
179
208
  // The epic README header line carries `**Risk:** <low|high>`; the frontmatter doesn't. Falls back to
180
209
  // 'high' — the WAYS-OF-WORKING rule is "when unsure, treat it as high", and a kickoff that under-declares
181
210
  // risk is the one that skips the mandatory fresh-reviewer pass.
@@ -261,6 +290,7 @@ export function epicKickoffFromDir({
261
290
  sprints,
262
291
  templateText: EPIC_KICKOFF_TEMPLATE,
263
292
  texts: [readmeText, ...sprints.map((s) => s.text)],
293
+ appetite: parseAppetite(readmeText) ?? parseAppetite(readSeed(read, list, dir, macro, slug)),
264
294
  }),
265
295
  };
266
296
  }
@@ -67,6 +67,14 @@ export function findEpicDir(repoRoot, slug, { exists = existsSync, list = readdi
67
67
  }
68
68
  const hits = macros.filter((m) => exists(join(roadmapDir, m, slug, 'README.md')));
69
69
  if (hits.length === 0) {
70
+ // A seed with no epic yet (dogfood F33): say what it is and the one command that makes it buildable.
71
+ if (exists(join(roadmapDir, '00-ideas', 'seeds', `${slug}.md`))) {
72
+ return {
73
+ error:
74
+ `"${slug}" is a seed with no epic yet — a kickoff builds an epic. Fund it at the approval gate, then scaffold it ` +
75
+ `(a fixed-scope seed needs nothing but its slug): node "$GROOM/scaffold-epic.mjs" --slug ${slug}`,
76
+ };
77
+ }
70
78
  return { error: `no epic found for slug "${slug}" under any Roadmap/*/ dir in "${repoRoot}" (searched: ${macros.join(', ') || '(none)'})` };
71
79
  }
72
80
  if (hits.length > 1) {
@@ -101,13 +101,6 @@ const SEED_STATUS_LABEL = {
101
101
  shipped: 'Shipped',
102
102
  archived: 'Archived',
103
103
  };
104
- const PRIORITY_LABEL = {
105
- 'wave-0': 'Wave 0 Enablers',
106
- 'wave-1': 'Wave 1',
107
- 'wave-2': 'Wave 2',
108
- 'wave-3': 'Wave 3',
109
- 'wave-4': 'Wave 4',
110
- };
111
104
  const TYPE_LABEL = { feature: 'Feature', spike: 'Spike', chore: 'Chore', bug: 'Bug', epic: 'Epic' };
112
105
 
113
106
  /**
@@ -523,7 +516,6 @@ export function buildRows({
523
516
  const totStories = boardSprints.reduce((a, s) => a + s.total, 0);
524
517
  const doneStories = boardSprints.reduce((a, s) => a + s.done, 0);
525
518
  const area = AREA_NAMES[e.area] || e.area;
526
- const priority = seed.priority ? PRIORITY_LABEL[seed.priority] || seed.priority : null;
527
519
  const riskWord = epicFm.risk || seed.risk;
528
520
  const risk = riskWord ? (riskWord === 'high' ? 'High' : 'Low') : null;
529
521
  const readmePath = `Roadmap/${epicKey}/README.md`;
@@ -561,12 +553,12 @@ export function buildRows({
561
553
  stage,
562
554
  stage_source: stageSource,
563
555
  area,
564
- priority,
565
556
  type: TYPE_LABEL[epicFm.type || seed.type] || 'Epic',
566
557
  risk,
567
558
  // finops D20 — the README's own appetite first (an epic groomed after FinOps carries it), else the seed's.
568
559
  appetite: epicFm.appetite || seed.appetite || null,
569
- underwritten_by: seed.underwritten_by || null,
560
+ // fund-at-approval D8 — the README's own first (an epic with no seed carries it there), else the seed's.
561
+ underwritten_by: epicFm.underwritten_by || seed.underwritten_by || null,
570
562
  sprint_progress: totStories ? `${doneStories}/${totStories} stories` : `${sprints.length} sprints`,
571
563
  build_order: buildOrder,
572
564
  build_order_num: buildOrderNum(buildOrder),
@@ -598,7 +590,6 @@ export function buildRows({
598
590
  status: sp.status,
599
591
  status_date: when(`Roadmap/${epicKey}/sprint-${sp.n}.md`, '^(\\*\\*)?Status:'),
600
592
  area,
601
- priority,
602
593
  type: 'Sprint',
603
594
  risk,
604
595
  sprint_progress: sp.total ? `${sp.done}/${sp.total} stories` : '—',
@@ -625,7 +616,6 @@ export function buildRows({
625
616
  stage,
626
617
  stage_source: stageSource,
627
618
  area: AREA_NAMES[s.area] || s.area || null,
628
- priority: s.priority ? PRIORITY_LABEL[s.priority] || s.priority : null,
629
619
  type: TYPE_LABEL[s.type] || 'Feature',
630
620
  risk: s.risk ? (s.risk === 'high' ? 'High' : 'Low') : null,
631
621
  appetite: s.appetite || null,
@@ -22,12 +22,11 @@ slug: example-feature-idea # kebab; matches the filename
22
22
  status: raw # raw | ready | queued | scaffolded | in-progress | shipped | archived
23
23
  area: "01" # macro-section number, matching Roadmap/README.md's table
24
24
  type: feature # feature | spike | chore | epic
25
- priority: null # a wave/priority label, or null
26
25
  appetite: null # S | M | L — the budget, set at shaping; REQUIRED before `queued`
27
- underwritten_by: null # wave that pays for this (a Roadmap/bets/<wave>.md), or null
26
+ underwritten_by: null # the cycle that PAID for it (Roadmap/bets/<cycle>.md, bare name) — fund.mjs
28
27
  risk: low # low | high
29
28
  epic: null # path to the scaffolded epic, or null until scaffolded
30
- build_order: null # BUILD-ORDER id, or null
29
+ build_order: null # integer position in the one global sequence — fund.mjs places it
31
30
  updated: <date>
32
31
  ---
33
32
  ```
@@ -64,10 +63,11 @@ One field is authoritative at each stage — they never both drive the board:
64
63
 
65
64
  `appetite` (S | M | L) is the **budget the idea is worth**, fixed at shaping *before* the solution
66
65
  is designed — sessions + an implied token band, never a time estimate (see WAYS-OF-WORKING →
67
- *Betting & appetite*). `underwritten_by` names the **wave that pays for it** (a
68
- `Roadmap/bets/<wave>.md` file), set at the betting table. `null` means nobody has paid for it yet —
69
- fine in the funnel, impossible on the board: `build-order.mjs` **hard-fails** a `queued` seed with
70
- no `appetite`, and flags a missing `underwritten_by` as drift. Like `status`, `appetite` is an
66
+ *Betting & appetite*). `underwritten_by` names the **cycle that paid for it** — the bare name of a
67
+ `Roadmap/bets/<cycle>.md` file (`wave-2026-10`), written by `groom`'s `fund.mjs` at the approval gate.
68
+ `null` means nobody has paid for it yet — fine in the funnel, impossible on the board: `build-order.mjs`
69
+ **hard-fails** a `queued` seed with no `appetite`, and a live bet (`queued`, or an epic scaffolded or in
70
+ progress) whose `underwritten_by` is missing or names no cycle file. Like `status`, `appetite` is an
71
71
  enforced enum — a present-but-unrecognized value fails the board, it never falls back silently.
72
72
 
73
73
  ## How seeds flow (no file moves)
@@ -76,10 +76,13 @@ enforced enum — a present-but-unrecognized value fails the board, it never fal
76
76
  from a brain-dump).
77
77
  2. **Scope** — `groom` fills out the Definition-of-Ready (appetite included) and flips
78
78
  `status: ready`.
79
- 3. **Queue** — bet on it at a wave boundary (`appetite:` + `underwritten_by:` set, the wave's
80
- `Roadmap/bets/` file records what it displaced); `status: queued`.
81
- 4. **Scaffold** — on approval, `groom` runs its own `scaffold-epic.mjs` (ships inside the `groom`
82
- skill, `golden-frijoles` plugin) to create the epic/sprint docs, then sets the seed's `epic:` +
83
- `status: scaffolded`. **No file ever moves between folders** — the frontmatter carries the state.
79
+ 3. **Fund + scaffold, in one answer** — the approval gate is the betting table. On "approve", `groom`
80
+ runs its own `fund.mjs` (a row in the month's `Roadmap/bets/wave-YYYY-MM.md`, created on first use,
81
+ recording what the bet displaced; `underwritten_by:`; a `build_order` placed with `--next` or
82
+ `--after <slug>`, renumbering only the queue) and then `scaffold-epic.mjs` (the epic/sprint docs; the
83
+ seed gets `epic:` + `status: scaffolded`), committed together. Both ship inside the `groom` skill,
84
+ `golden-frijoles` plugin. A fixed-scope seed scaffolds from its slug alone, its acceptance criteria
85
+ becoming sprint 1's stories. "Approve, don't fund" leaves the seed `ready` and scaffolds nothing.
86
+ **No file ever moves between folders** — the frontmatter carries the state.
84
87
 
85
88
  Filenames are kebab-case and match `slug`. Audits live in `audits/`, never in `seeds/`.
@@ -92,11 +92,15 @@ and session context. Fixed appetite, variable scope.
92
92
  | **M** | one wave: an architect session + builder fan-out + review rounds | appetite exhausted → stop, back to shaping |
93
93
  | **L** | a multi-wave epic | per-wave: each wave is re-bet at the boundary |
94
94
 
95
- Four rules: **an exhausted bet returns to shaping**, never extends in flight; **nothing reaches
96
- `status: queued` without an `appetite:` and an `underwritten_by:` wave**; **bets are placed at wave
97
- boundaries** into `Roadmap/bets/<wave>.md`, three lines each, recording what they displaced; and **uphill
98
- work stays on the strongest model**. Not every ask earns the betting table — `groom` sorts shaped bets
99
- from fixed scope (appetite S, straight to a builder) and reactive/ops work. Why it works this way:
95
+ Four rules: **an exhausted bet returns to shaping**, never extends in flight; **the approval gate is the
96
+ betting table** — approving a pitch in `groom` funds it in the same answer (`fund.mjs`: a row in the month's
97
+ cycle file `Roadmap/bets/wave-YYYY-MM.md` recording what it displaced, `underwritten_by:`, and a build
98
+ position), in the same commit as the scaffold, while "approve, don't fund" leaves it `ready` and scaffolds
99
+ nothing; **nothing scaffolded is unfunded** — `build-order.mjs` fails a live bet with no `underwritten_by:`;
100
+ and **uphill work stays on the strongest model**. An L bet is re-bet at each wave boundary with one line
101
+ when its builder stops there ("fund the next wave of `<slug>`? what does it displace?"; yes runs `fund.mjs`
102
+ again, position kept). Fixed scope (appetite S) and reactive/ops work go through the same gate, straight to
103
+ a builder. Why it works this way:
100
104
  [`references/shapeup/`](https://github.com/golden-frijoles/skills/blob/main/template/references/shapeup/README.md).
101
105
 
102
106
  ## Review & merge
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.27.2",
3
+ "version": "0.28.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": {