@golden-frijoles/kit 0.27.1 → 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) {
@@ -22,6 +22,7 @@ import { spawnSync } from 'node:child_process';
22
22
  import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
23
23
  import { tmpdir } from 'node:os';
24
24
  import { join } from 'node:path';
25
+ import { assertReviewOutput } from './review-shape.mjs';
25
26
 
26
27
  // label per agent. Drives both the CLI dispatch and the human-readable header.
27
28
  export const AGENTS = {
@@ -551,6 +552,52 @@ export function isTransientAgyError(stderr) {
551
552
  );
552
553
  }
553
554
 
555
+ // WHICH transient failure, because the two that matter mean opposite things to the person reading the error
556
+ // (probed live, agy 1.2.16, 2026-10-04 — it now exits 3 and says why on stderr):
557
+ // quota "RESOURCE_EXHAUSTED (code 429): Individual quota reached … Resets in 155h5m1s." Every Gemini tier
558
+ // shares one pool, and it is a weekly window: waiting a minute does nothing.
559
+ // capacity "UNAVAILABLE (code 503): No capacity available for model gpt-oss-120b-medium" / "high traffic,
560
+ // please try again in a minute". The SAME call answered fine 14 s later.
561
+ // Both used to end in one message that said "quota", so agents reported agy capped while it worked on their
562
+ // next task. Returns 'quota' | 'capacity' | null; a null on a transient error is some other blip.
563
+ export function agyFailureKind(output) {
564
+ const text = output || '';
565
+ if (/RESOURCE_EXHAUSTED|quota (?:reached|exceeded|exhausted)/i.test(text)) return 'quota';
566
+ if (/no capacity|high traffic|UNAVAILABLE \(code 503\)|overloaded/i.test(text)) return 'capacity';
567
+ return null;
568
+ }
569
+
570
+ function agyQuotaReset(output) {
571
+ return /Resets in ([0-9hms]+)/i.exec(output || '')?.[1] ?? null;
572
+ }
573
+
574
+ // How often, and how long apart, a 'capacity' answer is retried on the same model before moving on.
575
+ export const AGY_CAPACITY_RETRIES = Number(process.env.AGY_CAPACITY_RETRIES ?? 2);
576
+ export const AGY_CAPACITY_WAIT_MS = Number(process.env.AGY_CAPACITY_WAIT_MS ?? 30_000);
577
+
578
+ // The reason behind an EMPTY answer. agy can still exit 0 with nothing on stdout, and then the only record of why
579
+ // is its own log (seen on PR #267's security lens, 2026-10-04, which answered on the re-run). The last real error
580
+ // line, skipping the side calls that fail without costing the answer (telemetry, conversation-title generation).
581
+ export function agyLogError(log) {
582
+ const errors = String(log || '')
583
+ .split('\n')
584
+ .filter((l) => /^E\d{4} /.test(l) && !/telemetry|title generation|recordTrajectory/i.test(l));
585
+ const last = errors.pop();
586
+ return last ? last.replace(/^E\S+ \S+\s+\d+ \S+\] /, '').slice(0, 240) : null;
587
+ }
588
+
589
+ function readOrNull(path) {
590
+ try {
591
+ return readFileSync(path, 'utf8');
592
+ } catch {
593
+ return null;
594
+ }
595
+ }
596
+
597
+ function sleepSync(ms) {
598
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
599
+ }
600
+
554
601
  export function isContextWindowOverflow(output) {
555
602
  // ['’] covers both a straight and a "smart"/curly apostrophe — CLIs are inconsistent about which they emit.
556
603
  return /ran out of room in the model['’]?s context window/i.test(output || '');
@@ -884,8 +931,8 @@ export function agyArgs(fullArgv, model = AGY_MODEL) {
884
931
  // One `agy -p "<prompt>" --model "<MODEL>"` invocation. The prompt+framed context ride in `fullArgv` (stdin is
885
932
  // NOT the prompt and must be at EOF — input:'' gives an immediate EOF or print mode blocks forever). Returns
886
933
  // the raw spawn result; the caller classifies status/stdout.
887
- function execAgy(fullArgv, model, spawn) {
888
- return spawn('agy', agyArgs(fullArgv, model), {
934
+ function execAgy(fullArgv, model, spawn, logFile) {
935
+ return spawn('agy', [...agyArgs(fullArgv, model), ...(logFile ? ['--log-file', logFile] : [])], {
889
936
  input: '',
890
937
  encoding: 'utf8',
891
938
  maxBuffer: 64 * 1024 * 1024,
@@ -1106,7 +1153,7 @@ export function renderFileContext({ attached, omitted }) {
1106
1153
  // separate quota pool) so a Gemini quota exhaustion degrades to GPT-OSS instead of silently blanking the review.
1107
1154
  // We enforce the argv size cap up front (clear message, not an opaque E2BIG). `deps.spawn` is injectable for tests.
1108
1155
  export function runAntigravity(fullArgv, opts = {}, deps = {}) {
1109
- const { spawn = spawnSync, warn = (m) => process.stderr.write(`${m}\n`) } = deps;
1156
+ const { spawn = spawnSync, warn = (m) => process.stderr.write(`${m}\n`), sleep = sleepSync } = deps;
1110
1157
  if (Buffer.byteLength(fullArgv, 'utf8') > AGY_ARG_LIMIT) {
1111
1158
  return fail(
1112
1159
  opts.soft,
@@ -1124,47 +1171,76 @@ export function runAntigravity(fullArgv, opts = {}, deps = {}) {
1124
1171
  ? [AGY_MODEL]
1125
1172
  : [AGY_MODEL, AGY_FALLBACK_MODEL];
1126
1173
  const tried = [];
1127
- for (const model of modelPair) {
1128
- const r = execAgy(fullArgv, model, spawn);
1129
- if (r.status !== 0) {
1130
- const last = (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
1131
- // A non-zero exit is USUALLY a real agy error (bad flags, crash) — surface those directly
1132
- // rather than burning the fallback on them.
1133
- //
1134
- // But not always, and this cost a real run to learn (2026-07-25, commit-report's first live
1135
- // use after merge): `gpt-oss-120b-medium` answered "Our servers are experiencing high traffic
1136
- // right now, please try again in a minute" with a NON-ZERO exit. That is precisely the
1137
- // situation the second model exists for — a different provider with a separate capacity pool
1138
- // — and the old branch refused to try it, because the fallback was wired only to the
1139
- // EMPTY-output signal. Same transient condition, different exit code, no fallback.
1140
- // Classify on the FULL output, not on `last`. `last` is only the final non-empty stderr line,
1141
- // chosen because it makes the best human-readable message — but agy can emit the transient
1142
- // notice followed by a stack trace or a trailing status line, which would push the phrase we
1143
- // match on out of view and abort instead of falling back (cross-review, PR #29). Both streams
1144
- // are checked because agy is already known to split diagnostics across them inconsistently —
1145
- // the same reason isContextWindowOverflow is called on stdout and stderr above.
1146
- const failureOutput = `${r.stderr || ''}\n${r.stdout || ''}`;
1147
- if (isTransientAgyError(failureOutput) && model !== modelPair[modelPair.length - 1]) {
1148
- warn(`⚠ agy "${model}" is temporarily unavailable (${last}) → trying the fallback model.`);
1149
- tried.push(model);
1150
- continue;
1174
+ const causes = []; // one plain line per model that did not answer, for the final message
1175
+ const logDir = mkdtempSync(join(tmpdir(), 'agy-log-'));
1176
+ try {
1177
+ for (const model of modelPair) {
1178
+ const logFile = join(logDir, `${model}.log`);
1179
+ let r = execAgy(fullArgv, model, spawn, logFile);
1180
+ for (let retry = 1; retry <= AGY_CAPACITY_RETRIES; retry += 1) {
1181
+ if (r.status === 0 || agyFailureKind(`${r.stderr || ''}\n${r.stdout || ''}`) !== 'capacity') break;
1182
+ warn(
1183
+ `⚠ agy "${model}" has no capacity right now (not quota) → retry ${retry}/${AGY_CAPACITY_RETRIES} in ${Math.round(AGY_CAPACITY_WAIT_MS / 1000)}s.`
1184
+ );
1185
+ sleep(AGY_CAPACITY_WAIT_MS);
1186
+ r = execAgy(fullArgv, model, spawn, logFile);
1151
1187
  }
1152
- return fail(opts.soft, `agy -p failed (model "${model}"): ${last}`);
1153
- }
1154
- const out = (r.stdout || '').trim();
1155
- if (out) {
1156
- if (model !== modelPair[0])
1157
- warn(`⚠ agy "${modelPair[0]}" returned no output (quota/unavailable?) → used "${model}".`);
1158
- // Tell the caller WHICH model answered. Added because the prose rail's footer said "agy",
1159
- // which cannot distinguish the primary from the fallback — so a silent switch between two
1160
- // models with very different registers was unattributable in the channel, and that is exactly
1161
- // the confusion that took a human to notice. Optional and side-effect-only, so every existing
1162
- // caller keeps its byte-identical string return.
1163
- opts.onModel?.(model);
1164
- return out;
1188
+ if (r.status !== 0) {
1189
+ const last = (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
1190
+ // A non-zero exit is USUALLY a real agy error (bad flags, crash) — surface those directly
1191
+ // rather than burning the fallback on them.
1192
+ //
1193
+ // But not always, and this cost a real run to learn (2026-07-25, commit-report's first live
1194
+ // use after merge): `gpt-oss-120b-medium` answered "Our servers are experiencing high traffic
1195
+ // right now, please try again in a minute" with a NON-ZERO exit. That is precisely the
1196
+ // situation the second model exists for — a different provider with a separate capacity pool
1197
+ // — and the old branch refused to try it, because the fallback was wired only to the
1198
+ // EMPTY-output signal. Same transient condition, different exit code, no fallback.
1199
+ // Classify on the FULL output, not on `last`. `last` is only the final non-empty stderr line,
1200
+ // chosen because it makes the best human-readable message — but agy can emit the transient
1201
+ // notice followed by a stack trace or a trailing status line, which would push the phrase we
1202
+ // match on out of view and abort instead of falling back (cross-review, PR #29). Both streams
1203
+ // are checked because agy is already known to split diagnostics across them inconsistently —
1204
+ // the same reason isContextWindowOverflow is called on stdout and stderr above.
1205
+ const failureOutput = `${r.stderr || ''}\n${r.stdout || ''}`;
1206
+ const kind = agyFailureKind(failureOutput);
1207
+ const cause =
1208
+ kind === 'quota'
1209
+ ? `quota reached${agyQuotaReset(failureOutput) ? `, resets in ${agyQuotaReset(failureOutput)}` : ''}`
1210
+ : kind === 'capacity'
1211
+ ? `no capacity after ${AGY_CAPACITY_RETRIES + 1} tries (transient, NOT quota; re-run in a few minutes)`
1212
+ : last;
1213
+ causes.push(`"${model}": ${cause}`);
1214
+ if (isTransientAgyError(failureOutput) && model !== modelPair[modelPair.length - 1]) {
1215
+ warn(`⚠ agy "${model}" is unavailable (${cause}) → trying the fallback model.`);
1216
+ tried.push(model);
1217
+ continue;
1218
+ }
1219
+ return fail(opts.soft, `agy -p failed — ${causes.join('; ')}`);
1220
+ }
1221
+ const out = (r.stdout || '').trim();
1222
+ if (out) {
1223
+ if (model !== modelPair[0])
1224
+ warn(`⚠ agy "${modelPair[0]}" did not answer (${causes[0]}) → used "${model}".`);
1225
+ // Tell the caller WHICH model answered. Added because the prose rail's footer said "agy",
1226
+ // which cannot distinguish the primary from the fallback — so a silent switch between two
1227
+ // models with very different registers was unattributable in the channel, and that is exactly
1228
+ // the confusion that took a human to notice. Optional and side-effect-only, so every existing
1229
+ // caller keeps its byte-identical string return.
1230
+ opts.onModel?.(model);
1231
+ return out;
1232
+ }
1233
+ const why = agyLogError(readOrNull(logFile));
1234
+ causes.push(
1235
+ `"${model}": empty output${why ? ` — agy's log says: ${why}` : ' and no error in its log'}`
1236
+ );
1237
+ tried.push(model);
1165
1238
  }
1166
- tried.push(model);
1239
+ } finally {
1240
+ rmSync(logDir, { recursive: true, force: true });
1167
1241
  }
1242
+ if (causes.some((c) => c.includes("agy's log says")))
1243
+ return fail(opts.soft, `agy returned no review — ${causes.join('; ')}`);
1168
1244
  return fail(
1169
1245
  opts.soft,
1170
1246
  `agy returned no output for ${tried.map((m) => `"${m}"`).join(' and ')} — likely a quota cap ` +
@@ -1338,14 +1414,18 @@ export function runVibe(fullArgv, opts = {}, deps = {}) {
1338
1414
  * Two signals, both cheap and neither dependent on the model's wording:
1339
1415
  * 1. the output STARTS with a tool call (`read_file{…}`, `grep {…}`) — the literal shape vibe emits
1340
1416
  * when it is cut off between turns, or when it asks for a tool that is disabled;
1341
- * 2. it never mentions Blocking / Should-fix / Nit, which the review prompt requires.
1417
+ * 2. it is not a review by review-guard's own test: no severity heading and no clean verdict.
1418
+ *
1419
+ * (2) used to be its own list — Blocking / Should-fix / Nit only — so `Clean.`, the one-line verdict the
1420
+ * prompt asks for on a clean diff, failed here as "truncated" before review-guard ever saw it. Every clean
1421
+ * vibe review died that way (reproduced on PR #265, 2026-10-04). One definition of "a review" now.
1342
1422
  *
1343
1423
  * Exported for the unit layer: this is the guard whose absence let a non-review reach a PR.
1344
1424
  */
1345
1425
  export function isTruncatedReview(out) {
1346
1426
  const text = String(out || '').trim();
1347
1427
  if (/^\w+\s*\{/.test(text)) return true;
1348
- return !/\b(blocking|should-fix|nit)\b/i.test(text);
1428
+ return !assertReviewOutput(text).ok;
1349
1429
  }
1350
1430
 
1351
1431
  // One `claude -p "<prompt>"` invocation with the context piped on stdin (same shape as codex, which is why
@@ -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) {
@@ -12,6 +12,12 @@
12
12
 
13
13
  import { spawnSync } from 'node:child_process';
14
14
 
15
+ // Every log commit also carries this vercel.json. A log branch is an orphan with no package.json, and a
16
+ // repo whose Vercel project builds every pushed branch tried to build it as a preview and failed on each
17
+ // write ("No Next.js version detected", claude/session-journal, 2026-10-04). Vercel reads vercel.json from
18
+ // the commit it is about to build, so the branch itself has to say "don't".
19
+ export const LOG_BRANCH_VERCEL_JSON = `${JSON.stringify({ git: { deploymentEnabled: false } }, null, 2)}\n`;
20
+
15
21
  function git(args, opts = {}) {
16
22
  return spawnSync('git', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...opts });
17
23
  }
@@ -34,7 +40,7 @@ export function readLogFromBranch({ cwd, branch, path }) {
34
40
  }
35
41
 
36
42
  // Writes `content` to `path` on `branch`, creating the branch if it doesn't exist yet. Pure plumbing:
37
- // hash-object (write a blob) → mktree (a single-file tree) → commit-tree (parented on the branch's
43
+ // hash-object (write a blob) → mktree (the file plus LOG_BRANCH_VERCEL_JSON) → commit-tree (parented on the branch's
38
44
  // current tip, if any) → push the new commit straight to the branch ref. Returns true on success.
39
45
  //
40
46
  // `path` MUST be a flat filename (no `/`) — `git mktree` builds a single-level tree, and a slash needs a
@@ -58,7 +64,17 @@ export function writeLogToBranch({ cwd, branch, path, content, message }) {
58
64
  }
59
65
  const blobSha = hashObj.stdout.trim();
60
66
 
61
- const mktree = git(['mktree'], { cwd, input: `100644 blob ${blobSha}\t${path}\n` });
67
+ let tree = `100644 blob ${blobSha}\t${path}\n`;
68
+ if (path !== 'vercel.json') {
69
+ const vercelObj = git(['hash-object', '-w', '--stdin'], { cwd, input: LOG_BRANCH_VERCEL_JSON });
70
+ if (vercelObj.status !== 0) {
71
+ console.error(`log-branch: git hash-object (vercel.json) failed: ${(vercelObj.stderr || '').trim()}`);
72
+ return false;
73
+ }
74
+ tree += `100644 blob ${vercelObj.stdout.trim()}\tvercel.json\n`;
75
+ }
76
+
77
+ const mktree = git(['mktree'], { cwd, input: tree });
62
78
  if (mktree.status !== 0) {
63
79
  console.error(`log-branch: git mktree failed: ${(mktree.stderr || '').trim()}`);
64
80
  return false;
@@ -38,39 +38,13 @@
38
38
  import { spawnSync } from 'node:child_process';
39
39
  import { jevContext } from './jev.mjs';
40
40
  import { loadQuestions, wireQuestion } from './jev-questions.mjs';
41
+ import { TOOL_TRANSCRIPT, assertReviewOutput } from './review-shape.mjs';
42
+
43
+ export { assertReviewOutput };
41
44
 
42
45
  /** Codex CLI version last observed producing a real, structured review. Bump after a verified run. */
43
46
  export const CODEX_VERIFIED = '0.154.0';
44
47
 
45
- const SEVERITY_HEADING =
46
- /^\s{0,3}(?:[-*+]\s+|\d+\.\s+|>\s*|\|\s*)?(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:🔴|🟠|🟡|⚪)?\s*(?:blocking|should[- ]fix|nits?|important|critical|findings?|correctness(?:\s*(?:&|and)\s*architecture)?|security(?: review)? findings?)\b/im;
47
- /^\s{0,3}(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:🔴|🟠|🟡|⚪)?\s*(?:blocking|should[- ]fix|nits?|important|critical)\b/im;
48
- const CLEAN_VERDICT =
49
- /(?:^\s{0,3}(?:[-*+]\s+)?(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:clean|none)\b|\bassessment:\s*(?:\*\*)?\s*clean\b|\bno\s+(?:\*\*)?(?:blocking|security|should[- ]fix|nit)\b[^.\n]{0,80}\b(?:findings|issues|problems)\b|\bno (?:findings|issues|problems)\b|\bnothing (?:to report|found|blocking)\b|\blooks clean\b|\bdiff (?:is|looks) clean\b|\bno concerns\b)/im;
50
- /(?:^\s{0,3}(?:#{1,6}\s*)?(?:\*\*|__)?\s*clean\b|\bassessment:\s*(?:\*\*)?\s*clean\b|\bno (?:blocking |security )?(?:findings|issues|problems)\b|\bnothing (?:to report|found|blocking)\b|\blooks clean\b|\bdiff (?:is|looks) clean\b|\bno concerns\b)/im;
51
- // A reviewer that emitted a raw tool call instead of a review — observed twice from vibe on real PRs
52
- // (`read_file{"path": …}`, `write_file…{"file_path": …}`). Its JSON can CONTAIN review-shaped words, so
53
- // this is checked first and wins.
54
- const TOOL_TRANSCRIPT = /^\s*[a-z_]+\S*\s*\{\s*"/i;
55
-
56
- /**
57
- * THE GUARD. Pure. Returns { ok, reason }.
58
- * ok when the reply has a severity heading or an explicit clean verdict; fails on empty, whitespace,
59
- * or prose with neither (a CLI banner, an error page, a truncated tool transcript).
60
- */
61
- export function assertReviewOutput(text) {
62
- const t = String(text ?? '').trim();
63
- if (!t) return { ok: false, reason: 'the reviewer returned no output' };
64
- if (TOOL_TRANSCRIPT.test(t))
65
- return { ok: false, reason: `the reviewer emitted a raw tool call, not a review ("${t.slice(0, 80)}")` };
66
- if (SEVERITY_HEADING.test(t)) return { ok: true, reason: 'severity-structured findings' };
67
- if (CLEAN_VERDICT.test(t)) return { ok: true, reason: 'explicit clean verdict' };
68
- return {
69
- ok: false,
70
- reason: `the reviewer's reply has no severity heading and no clean verdict — not a review (first line: "${t.split('\n')[0].slice(0, 120)}")`,
71
- };
72
- }
73
-
74
48
  /** Minimal glob → RegExp: `**` any depth, `*` within a segment, `?` one char. Anchored, case-sensitive. */
75
49
  export function globToRegExp(glob) {
76
50
  let re = '';
@@ -289,7 +263,7 @@ export const RE_REVIEW_NOTE =
289
263
  '\n\n## RE-REVIEW\nA previous pass already reviewed this PR and the author has pushed changes since. Report **Blocking and Should-fix findings only** — no nits, and do not repeat a finding the author has already fixed or answered on the PR.\n';
290
264
 
291
265
  // ── 4. Jev decides the SEMANTIC question (jev-semantic-guards D4/D5) ────────────────────────────────
292
- // `assertReviewOutput` above is a regex asking a language question — "did the reviewer actually review?" —
266
+ // `assertReviewOutput` (review-shape.mjs) is a regex asking a language question — "did the reviewer actually review?" —
293
267
  // and on 2026-09-19 it answered it wrong in both directions: it REJECTED a real finding written as plain
294
268
  // prose, and ACCEPTED `## Findings` followed by "(reviewer timed out before completing analysis)". Jev
295
269
  // (TypeSafe's calibrated typed-judgement model) got both right. So the judge below asks Jev, by
@@ -0,0 +1,30 @@
1
+ // review-shape.mjs — "is this text a review at all?", with no dependencies, so a runner can ask it without
2
+ // pulling review-guard's Jev machinery into its closure. review-guard re-exports assertReviewOutput and uses it as
3
+ // the offline fallback; cross-agent-cli's isTruncatedReview asks the same function, so there is one definition.
4
+
5
+ const SEVERITY_HEADING =
6
+ /^\s{0,3}(?:[-*+]\s+|\d+\.\s+|>\s*|\|\s*)?(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:🔴|🟠|🟡|⚪)?\s*(?:blocking|should[- ]fix|nits?|important|critical|findings?|correctness(?:\s*(?:&|and)\s*architecture)?|security(?: review)? findings?)\b/im;
7
+ const CLEAN_VERDICT =
8
+ /(?:^\s{0,3}(?:[-*+]\s+)?(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:clean|none)\b|\bassessment:\s*(?:\*\*)?\s*clean\b|\bno\s+(?:\*\*)?(?:blocking|security|should[- ]fix|nit)\b[^.\n]{0,80}\b(?:findings|issues|problems)\b|\bno (?:findings|issues|problems)\b|\bnothing (?:to report|found|blocking)\b|\blooks clean\b|\bdiff (?:is|looks) clean\b|\bno concerns\b)/im;
9
+ // A reviewer that emitted a raw tool call instead of a review — observed twice from vibe on real PRs
10
+ // (`read_file{"path": …}`, `write_file…{"file_path": …}`). Its JSON can CONTAIN review-shaped words, so
11
+ // this is checked first and wins.
12
+ export const TOOL_TRANSCRIPT = /^\s*[a-z_]+\S*\s*\{\s*"/i;
13
+
14
+ /**
15
+ * THE GUARD. Pure. Returns { ok, reason }.
16
+ * ok when the reply has a severity heading or an explicit clean verdict; fails on empty, whitespace,
17
+ * or prose with neither (a CLI banner, an error page, a truncated tool transcript).
18
+ */
19
+ export function assertReviewOutput(text) {
20
+ const t = String(text ?? '').trim();
21
+ if (!t) return { ok: false, reason: 'the reviewer returned no output' };
22
+ if (TOOL_TRANSCRIPT.test(t))
23
+ return { ok: false, reason: `the reviewer emitted a raw tool call, not a review ("${t.slice(0, 80)}")` };
24
+ if (SEVERITY_HEADING.test(t)) return { ok: true, reason: 'severity-structured findings' };
25
+ if (CLEAN_VERDICT.test(t)) return { ok: true, reason: 'explicit clean verdict' };
26
+ return {
27
+ ok: false,
28
+ reason: `the reviewer's reply has no severity heading and no clean verdict — not a review (first line: "${t.split('\n')[0].slice(0, 120)}")`,
29
+ };
30
+ }
@@ -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.1",
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": {