@golden-frijoles/kit 0.27.1 → 0.27.2

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.
@@ -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
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.27.1",
3
+ "version": "0.27.2",
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": {