@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.
- package/dist/build-order.mjs +19 -6
- package/dist/lib/cross-agent-cli.mjs +123 -43
- package/dist/lib/epic-kickoff.mjs +33 -3
- package/dist/lib/kickoff-cli.mjs +8 -0
- package/dist/lib/log-branch.mjs +18 -2
- package/dist/lib/review-guard.mjs +4 -30
- package/dist/lib/review-shape.mjs +30 -0
- package/dist/roadmap-extract.mjs +2 -12
- package/dist/skeleton/Roadmap/00-ideas/README.md +15 -12
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +9 -5
- package/package.json +1 -1
package/dist/build-order.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
}
|
package/dist/lib/kickoff-cli.mjs
CHANGED
|
@@ -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) {
|
package/dist/lib/log-branch.mjs
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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`
|
|
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
|
+
}
|
package/dist/roadmap-extract.mjs
CHANGED
|
@@ -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
|
-
|
|
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 #
|
|
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 #
|
|
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 **
|
|
68
|
-
`Roadmap/bets/<
|
|
69
|
-
fine in the funnel, impossible on the board: `build-order.mjs`
|
|
70
|
-
no `appetite`, and
|
|
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. **
|
|
80
|
-
`
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
`status: scaffolded
|
|
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; **
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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.
|
|
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": {
|