@golden-frijoles/kit 0.10.0 → 0.11.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.
@@ -121,6 +121,18 @@ export const INTENT_QUESTIONS = Object.freeze({
121
121
  'Yes: it names the action, where to do it and the exact result to observe, so two testers would agree.',
122
122
  ],
123
123
  },
124
+ agreement: {
125
+ // Asked by intent-reader.mjs at the architecture lock (D16), once per epic, only when `intent.reader` is on.
126
+ // Not measured: there is no labelled set of reader replies yet. Its answer is one signal among five, advisory.
127
+ type: 'noul',
128
+ instructions:
129
+ '`plan` is the architecture lock a builder wrote for this work. `reading` is what a second reader, given only the pitch, says they would build, would not build, and would ask first. Would the two of them build the same thing?',
130
+ criteria: {
131
+ true: 'Yes: the reading describes the same product and the same scope as the plan; the differences are wording, order, or detail the plan already settles.',
132
+ false:
133
+ 'No: the reading would build something the plan does not, leave out something the plan builds, or its first question exposes a decision the plan has not made.',
134
+ },
135
+ },
124
136
  route: {
125
137
  type: 'choice',
126
138
  instructions:
@@ -150,6 +162,13 @@ export const INTENT_QUESTIONS = Object.freeze({
150
162
 
151
163
  const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n?/;
152
164
 
165
+ /**
166
+ * A fence line, by CommonMark's rule: at most THREE spaces of indent. A line indented four or more is code, never a
167
+ * fence — and the reader writes its reply indented, so a `\s*` here let an odd fence line in a reply flip the scanner
168
+ * and make the next write delete every section after `## Intent match` (fresh review round 2, #197).
169
+ */
170
+ export const FENCE_RE = /^ {0,3}(```|~~~)/;
171
+
153
172
  /** Flat frontmatter → { key: value } (comment-stripped, quotes stripped). The same rule epic-dod uses. */
154
173
  export function frontmatterOf(text) {
155
174
  const m = /^---\n([\s\S]*?)\n---/.exec(String(text));
@@ -167,7 +186,7 @@ export function sections(body) {
167
186
  const out = [{ heading: null, lines: [] }];
168
187
  let fence = false;
169
188
  for (const line of String(body).split('\n')) {
170
- if (/^\s*(```|~~~)/.test(line)) fence = !fence;
189
+ if (FENCE_RE.test(line)) fence = !fence;
171
190
  if (!fence && /^## /.test(line)) out.push({ heading: line.slice(3).trim(), lines: [] });
172
191
  else out.at(-1).lines.push(line);
173
192
  }
@@ -184,7 +203,7 @@ export function listItems(lines, { numbered = false } = {}) {
184
203
  const top = numbered ? /^(\d+)[.)]\s+(.*)$/ : /^(?:[-*+]|\d+[.)])\s+(.*)$/;
185
204
  let fence = false;
186
205
  for (const raw of lines) {
187
- if (/^\s*(```|~~~)/.test(raw)) {
206
+ if (FENCE_RE.test(raw)) {
188
207
  fence = !fence;
189
208
  continue;
190
209
  }
@@ -450,6 +469,24 @@ export function componentsOf(result) {
450
469
  return Object.fromEntries(Object.entries(result.signals).map(([k, v]) => [k, round(v)]));
451
470
  }
452
471
 
472
+ /**
473
+ * The components a `--write` stored in a seed's `<!-- intent-match: {…} -->` comment, or null. Pure. Only unit
474
+ * numbers survive, so a hand-edited comment can never smuggle a non-number into a total.
475
+ */
476
+ export function componentsFrom(text) {
477
+ const m = /<!-- intent-match: (\{[^\n]*?\}) -->/.exec(String(text ?? ''));
478
+ if (!m) return null;
479
+ let raw;
480
+ try {
481
+ raw = JSON.parse(m[1]);
482
+ } catch {
483
+ return null;
484
+ }
485
+ const out = {};
486
+ for (const k of Object.keys(SIGNAL_NAMES)) if (isUnit(raw?.[k])) out[k] = raw[k];
487
+ return Object.keys(out).length ? out : null;
488
+ }
489
+
453
490
  /** The `## Intent match` section `--write` puts in the seed. Pure. */
454
491
  export function intentSection(result, opts = {}) {
455
492
  return [
@@ -466,34 +503,29 @@ export function intentSection(result, opts = {}) {
466
503
  ].join('\n');
467
504
  }
468
505
 
469
- /**
470
- * Put the score into the seed text. Pure. `intent_match:` goes into the frontmatter (replaced, or added before the
471
- * closing fence), `intent_ask: verbatim` is added when the seed has an ask and no `intent_ask:` yet, and
472
- * `## Intent match` replaces any earlier one or is appended.
473
- */
474
- export function writeIntoSeed(text, result, opts = {}) {
475
- let src = String(text);
506
+ /** Set `key: value` in a document's frontmatter — replaced, or added before the closing fence. Pure. */
507
+ export function setFrontmatterKey(text, key, value, { onlyIfAbsent = false } = {}) {
508
+ const src = String(text);
476
509
  const fm = FRONTMATTER_RE.exec(src);
477
- if (fm) {
478
- let block = fm[0];
479
- const set = (key, value, { onlyIfAbsent = false } = {}) => {
480
- const re = new RegExp(`^${key}:.*$`, 'm');
481
- if (re.test(block)) {
482
- if (!onlyIfAbsent) block = block.replace(re, `${key}: ${value}`);
483
- } else block = block.replace(/\n---\n?$/, `\n${key}: ${value}\n---\n`);
484
- };
485
- set('intent_match', result.total);
486
- if (opts.hasAsk) set('intent_ask', 'verbatim', { onlyIfAbsent: true });
487
- src = block + src.slice(fm[0].length);
488
- }
489
- const section = intentSection(result, opts);
510
+ if (!fm) return src;
511
+ let block = fm[0];
512
+ const re = new RegExp(`^${key}:.*$`, 'm');
513
+ if (re.test(block)) {
514
+ if (!onlyIfAbsent) block = block.replace(re, `${key}: ${value}`);
515
+ } else block = block.replace(/\n---\n?$/, `\n${key}: ${value}\n---\n`);
516
+ return block + src.slice(fm[0].length);
517
+ }
518
+
519
+ /** Replace the `## Intent match` section (up to the next `## `), or append it. Pure. */
520
+ export function upsertIntentSection(text, section) {
521
+ const src = String(text);
490
522
  const lines = src.split('\n');
491
523
  // Fence-aware, like sections(): a `## Intent match` inside a code block is an example, and treating it as the
492
524
  // section deleted everything up to the next heading, closing fence included (fresh review of #196).
493
525
  const headingAt = [];
494
526
  let fence = false;
495
527
  lines.forEach((l, i) => {
496
- if (/^\s*(```|~~~)/.test(l)) fence = !fence;
528
+ if (FENCE_RE.test(l)) fence = !fence;
497
529
  else if (!fence && /^## /.test(l)) headingAt.push(i);
498
530
  });
499
531
  const start = headingAt.find((i) => INTENT_HEADING.test(lines[i].slice(3).trim())) ?? -1;
@@ -504,6 +536,16 @@ export function writeIntoSeed(text, result, opts = {}) {
504
536
  .replace(/\n{3,}/g, '\n\n');
505
537
  }
506
538
 
539
+ /**
540
+ * Put the score into the seed text. Pure. `intent_match:` goes into the frontmatter, `intent_ask: verbatim` is added
541
+ * when the seed has an ask and no `intent_ask:` yet, and `## Intent match` replaces any earlier one or is appended.
542
+ */
543
+ export function writeIntoSeed(text, result, opts = {}) {
544
+ let src = setFrontmatterKey(text, 'intent_match', result.total);
545
+ if (opts.hasAsk) src = setFrontmatterKey(src, 'intent_ask', 'verbatim', { onlyIfAbsent: true });
546
+ return upsertIntentSection(src, intentSection(result, opts));
547
+ }
548
+
507
549
  // ── The run, every side effect injected ───────────────────────────────────────────────────────────────────────
508
550
 
509
551
  const couldNotLook = (why) => ({ state: 'could-not-look', why });
@@ -0,0 +1,330 @@
1
+ #!/usr/bin/env node
2
+ // intent-reader.mjs — an OPTIONAL second-family read of a pitch at the architecture lock (intent-match D4, D16).
3
+ //
4
+ // node scripts/intent-reader.mjs --epic <slug> [--timeout <seconds>]
5
+ //
6
+ // The epic kickoff's lock step names this command on every run. With `intent.reader` off — the default — it prints
7
+ // one line and exits before touching any CLI, so it costs nothing. Turned on (`gf-kit config set intent.reader on`),
8
+ // it hands the epic's seed (the pitch file, nothing else) to the first of codex, agy and vibe that is installed,
9
+ // once, with a hard timeout, and asks Jev one Noul: would that reader build the same thing as the lock's plan? The
10
+ // answer is the fifth signal, "agreement", and it is written into the epic README's `## Intent match` section.
11
+ //
12
+ // ── IT NEVER GETS IN THE WAY ─────────────────────────────────────────────────────────────────────────────────
13
+ // Any failure — no reader CLI, a capped or logged-out CLI, a timeout, an empty or unstructured reply, Jev unable to
14
+ // look — prints exactly ONE line, `reader skipped: <why>`, and exits 0. There is no retry and no second family after
15
+ // one has been asked: free-tier CLIs can be finicky, and three reads of every pitch cost more time than they return
16
+ // (the product owner's call at grooming). Exit 1 is kept for a usage error only.
17
+ //
18
+ // ── NEVER CLAUDE ─────────────────────────────────────────────────────────────────────────────────────────────
19
+ // Planning and building run on Claude; a Claude reader adds no independent read. `FAMILIES` holds no Claude entry
20
+ // and a spec pins that.
21
+ //
22
+ // Every CLI is spawned directly with `timeout`, from the argv builders `lib/cross-agent-cli.mjs` exports — never
23
+ // through `runCodex`/`runAntigravity`/`runVibe`, which have no deadline, retry agy on a second model and print their
24
+ // own warning line (C4). Zero deps — Node 18+.
25
+
26
+ import { spawnSync } from 'node:child_process';
27
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
28
+ import { join, relative, resolve } from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+ import {
31
+ AGY_ARG_LIMIT,
32
+ agyArgs,
33
+ codexExecArgs,
34
+ hasCmd,
35
+ VIBE_ARG_LIMIT,
36
+ vibeArgs,
37
+ } from './lib/cross-agent-cli.mjs';
38
+ import { getKey } from './lib/config.mjs';
39
+ import { collectSecretValues, findSecretLeaks } from './lib/secret-guard.mjs';
40
+ import { askJev, loadJevConfig, readApiKey, repoRoot, stateSize, STATE_CHAR_BUDGET } from './lib/jev.mjs';
41
+ import {
42
+ componentsFrom,
43
+ INTENT_QUESTIONS,
44
+ INTENT_TIMEOUT_MS,
45
+ SIGNAL_NAMES,
46
+ setFrontmatterKey,
47
+ totalOf,
48
+ upsertIntentSection,
49
+ } from './intent-match.mjs';
50
+
51
+ export const DEFAULT_TIMEOUT_S = 120;
52
+
53
+ /** The reader families, in preference order (review-route's order). NEVER Claude (D4). */
54
+ export const FAMILIES = Object.freeze([
55
+ {
56
+ id: 'codex',
57
+ bin: 'codex',
58
+ argv: (prompt) => ({ args: codexExecArgs(prompt.head), input: prompt.body, limit: null }),
59
+ },
60
+ {
61
+ id: 'agy',
62
+ bin: 'agy',
63
+ argv: (prompt) => ({ args: agyArgs(prompt.full), input: '', limit: AGY_ARG_LIMIT }),
64
+ },
65
+ {
66
+ id: 'vibe',
67
+ bin: 'vibe',
68
+ argv: (prompt) => ({ args: vibeArgs(prompt.full), input: '', limit: VIBE_ARG_LIMIT }),
69
+ },
70
+ ]);
71
+
72
+ /** The fixed prompt (D16): the reader gets the pitch file and these three headings, nothing else. */
73
+ export const READER_PROMPT = `You are reading a product pitch you did not write, before anyone builds it. Do not open, run or change any file: the pitch is given to you below. Answer in exactly three sections, with exactly these headings:
74
+
75
+ ## Will build
76
+ - each thing you would build from this pitch, one bullet each
77
+
78
+ ## Won't build
79
+ - each thing this pitch leaves out on purpose, or that you would not build from it
80
+
81
+ ## First question
82
+ - the one question you would ask the person who wrote it before you started
83
+
84
+ Keep it short. No preamble, no other sections.`;
85
+
86
+ const HEADINGS = [
87
+ /^#+\s*will build\b/im,
88
+ /^#+\s*won['’]?t build\b|^#+\s*will not build\b/im,
89
+ /^#+\s*first question\b/im,
90
+ ];
91
+
92
+ /** Does a reply carry the three headings the prompt asked for? Pure. */
93
+ export const isStructured = (text) => HEADINGS.every((re) => re.test(String(text ?? '')));
94
+
95
+ /** The one line every skip prints. Pure. */
96
+ export const skipLine = (why) => `reader skipped: ${why}`;
97
+
98
+ const lastLine = (t) =>
99
+ String(t ?? '')
100
+ .trim()
101
+ .split('\n')
102
+ .filter(Boolean)
103
+ .pop() ?? '';
104
+
105
+ /**
106
+ * Ask ONE family, once. Returns { ok: true, text } or { ok: false, why }. Never throws. The spawn result is read the
107
+ * way spawnSync reports a deadline: `error.code === 'ETIMEDOUT'` (with `timeout` set, the child is killed).
108
+ */
109
+ export function askReader(family, pitch, { spawn = spawnSync, timeoutMs }) {
110
+ const prompt = { head: READER_PROMPT, body: pitch, full: `${READER_PROMPT}\n\n---\n\n${pitch}\n` };
111
+ const { args, input, limit } = family.argv(prompt);
112
+ if (limit && Buffer.byteLength(prompt.full, 'utf8') > limit)
113
+ return { ok: false, why: `${family.id}: the pitch is over its ${limit / 1024} KB argument limit` };
114
+ let r;
115
+ try {
116
+ r = spawn(family.bin, args, {
117
+ input,
118
+ encoding: 'utf8',
119
+ timeout: timeoutMs,
120
+ killSignal: 'SIGKILL',
121
+ maxBuffer: 16 * 1024 * 1024,
122
+ });
123
+ } catch (e) {
124
+ return { ok: false, why: `${family.id}: could not start (${e?.message || e})` };
125
+ }
126
+ if (r?.error?.code === 'ETIMEDOUT')
127
+ return { ok: false, why: `${family.id}: timed out after ${timeoutMs / 1000}s` };
128
+ if (r?.error?.code === 'ENOBUFS') return { ok: false, why: `${family.id}: the reply was over 16 MB` };
129
+ if (r?.error) return { ok: false, why: `${family.id}: could not start (${r.error.message})` };
130
+ if (r?.status !== 0)
131
+ return {
132
+ ok: false,
133
+ why: `${family.id}: exited ${r?.status ?? r?.signal ?? '?'}${lastLine(r?.stderr) ? ` — ${lastLine(r.stderr).slice(0, 160)}` : ''}`,
134
+ };
135
+ const text = String(r.stdout ?? '').trim();
136
+ if (!text) return { ok: false, why: `${family.id}: empty reply` };
137
+ if (!isStructured(text))
138
+ return {
139
+ ok: false,
140
+ why: `${family.id}: unstructured reply (no Will build / Won't build / First question)`,
141
+ };
142
+ return { ok: true, text };
143
+ }
144
+
145
+ /** The first family whose binary is installed, or null. Missing is the ONLY reason to move to the next one. */
146
+ export function pickFamily(has = hasCmd) {
147
+ return FAMILIES.find((f) => has(f.bin)) ?? null;
148
+ }
149
+
150
+ const f2 = (n) => n.toFixed(2);
151
+
152
+ /** The README's `## Intent match` section, with agreement. Pure. */
153
+ export function readerSection({ family, agreement, seedComponents, seedTotal, date, reply }) {
154
+ const withAgreement = totalOf({ ...(seedComponents ?? {}), agreement });
155
+ const seedLine = seedComponents
156
+ ? `- Seed score: **${seedTotal ?? '—'}** (${Object.entries(seedComponents)
157
+ .map(([k, v]) => `${SIGNAL_NAMES[k]} ${f2(v)}`)
158
+ .join(' · ')})`
159
+ : '- Seed score: none recorded (run `node scripts/intent-match.mjs <seed> --write` at groom Stage 3.5)';
160
+ const lines = [
161
+ '## Intent match',
162
+ '',
163
+ '_Advisory and uncalibrated (intent-match D1, D3). Agreement written by `intent-reader.mjs` at the architecture lock._',
164
+ '',
165
+ seedLine,
166
+ `- Agreement (reader: ${family}, ${date}): **${f2(agreement)}** — P(the reader, given only the pitch, would build what this plan builds)`,
167
+ ];
168
+ if (seedComponents) lines.push(`- Total with agreement: **${withAgreement.total} / 100** — uncalibrated`);
169
+ const clipped = reply.length > 4000 ? `${reply.slice(0, 4000)}\n…[truncated]` : reply;
170
+ // Indented, never fenced: a reply is model output, and a fence it carries (``` or ~~~) would close ours and leave its
171
+ // own `## ` headings outside the section, where the next run cannot find them (fresh review of #197).
172
+ lines.push(
173
+ '',
174
+ "<details><summary>The reader's reply</summary>",
175
+ '',
176
+ ...clipped.split('\n').map((l) => ` ${l}`),
177
+ '',
178
+ '</details>',
179
+ ''
180
+ );
181
+ lines.push(
182
+ `<!-- intent-match: ${JSON.stringify({ ...(seedComponents ?? {}), agreement: Math.round(agreement * 1000) / 1000, total: seedComponents ? withAgreement.total : null })} -->`,
183
+ ''
184
+ );
185
+ return { text: lines.join('\n'), total: seedComponents ? withAgreement.total : null };
186
+ }
187
+
188
+ /** Roadmap/<macro>/<slug>/README.md and the seed, or { error }. */
189
+ export function locateEpic(
190
+ root,
191
+ slug,
192
+ { exists = existsSync, list = (d) => readdirSync(d, { withFileTypes: true }) } = {}
193
+ ) {
194
+ const roadmap = join(root, 'Roadmap');
195
+ if (!exists(roadmap)) return { error: `no Roadmap/ under ${root}` };
196
+ const hits = list(roadmap)
197
+ .filter((d) => d.isDirectory() && exists(join(roadmap, d.name, slug, 'README.md')))
198
+ .map((d) => join(roadmap, d.name, slug, 'README.md'));
199
+ if (hits.length !== 1)
200
+ return {
201
+ error: hits.length ? `"${slug}" is under more than one macro` : `no epic "${slug}" under Roadmap/*/`,
202
+ };
203
+ const seed = join(roadmap, '00-ideas', 'seeds', `${slug}.md`);
204
+ return { readme: hits[0], seed: exists(seed) ? seed : null };
205
+ }
206
+
207
+ export function parseArgs(argv) {
208
+ const out = { epic: null, timeout: DEFAULT_TIMEOUT_S, help: false, bad: null };
209
+ for (let i = 0; i < argv.length; i++) {
210
+ const a = argv[i];
211
+ if (a === '--help' || a === '-h') out.help = true;
212
+ else if (a === '--epic') out.epic = argv[++i] ?? null;
213
+ else if (a === '--timeout') {
214
+ const n = Number(argv[++i]);
215
+ if (!Number.isFinite(n) || n <= 0) out.bad = '--timeout needs a positive number of seconds';
216
+ else out.timeout = n;
217
+ } else out.bad = `unknown argument ${a}`;
218
+ }
219
+ if (!out.help && !out.bad && !out.epic) out.bad = '--epic <slug> is required';
220
+ return out;
221
+ }
222
+
223
+ /**
224
+ * The run. Every side effect injected; returns the exit code (0, or 1 for usage). io = { root, setting, config, key,
225
+ * has, spawn, ask, read, write, out, today }.
226
+ */
227
+ export async function run(argv, io) {
228
+ const args = parseArgs(argv);
229
+ if (args.help || args.bad) {
230
+ if (args.bad) io.out(`intent-reader: ${args.bad}`);
231
+ io.out('usage: node scripts/intent-reader.mjs --epic <slug> [--timeout <seconds>]');
232
+ return args.help ? 0 : 1;
233
+ }
234
+ // OFF first, before anything is read or spawned: the default costs nothing and waits for nothing (D4).
235
+ const setting = io.setting();
236
+ if (setting !== 'on') {
237
+ io.out(
238
+ `intent reader: off (intent.reader: ${JSON.stringify(setting)}) — no reader asked. Turn it on with \`gf-kit config set intent.reader on\`.`
239
+ );
240
+ return 0;
241
+ }
242
+ const skip = (why) => {
243
+ io.out(skipLine(why));
244
+ return 0;
245
+ };
246
+ const where = locateEpic(io.root, args.epic);
247
+ if (where.error) return skip(where.error);
248
+ if (!where.seed)
249
+ return skip(
250
+ `no seed at Roadmap/00-ideas/seeds/${args.epic}.md — the reader reads the pitch, and there is none`
251
+ );
252
+ // Agreement is a Jev answer. Check Jev can be asked BEFORE spending up to two minutes on a reader.
253
+ const config = io.config();
254
+ if (config.egress !== true)
255
+ return skip(`Jev cannot score agreement (jev.egress is ${JSON.stringify(config.egress)}, not true)`);
256
+ const key = io.key();
257
+ if (!key) return skip('Jev cannot score agreement (no TYPESAFE_API_KEY)');
258
+ const family = pickFamily(io.has);
259
+ if (!family) return skip('no reader CLI on PATH (codex, agy or vibe)');
260
+ const pitch = io.read(where.seed);
261
+ const plan = io.read(where.readme);
262
+ const reply = askReader(family, pitch, { spawn: io.spawn, timeoutMs: args.timeout * 1000 });
263
+ if (!reply.ok) return skip(reply.why);
264
+ // The reply is about to leave this process twice: to Jev, then into a README that may be public. A reader can READ
265
+ // host files (codex's sandbox allows reads; agy has no tool restriction), so a secret-shaped reply is skipped before
266
+ // either, as cross-review does (lib/secret-guard.mjs). Names only: a value is never printed.
267
+ const leaks = io.secrets(reply.text).leaks;
268
+ if (leaks.length)
269
+ return skip(
270
+ `${family.id}: the reply carried secret-shaped text (${leaks.map((l) => l.name).join(', ')}); nothing sent, nothing written`
271
+ );
272
+ const state = { plan, reading: reply.text };
273
+ if (stateSize(state) > STATE_CHAR_BUDGET)
274
+ return skip(`the plan and the reading are over Jev's state budget (${stateSize(state)} chars)`);
275
+ const res = await io.ask(
276
+ { state, questions: { agreement: INTENT_QUESTIONS.agreement } },
277
+ { key, model: config.model }
278
+ );
279
+ const a = res?.ok ? res.answers?.agreement : undefined;
280
+ if (a?.type !== 'noul' || typeof a.noul !== 'number' || !(a.noul >= 0 && a.noul <= 1))
281
+ return skip(
282
+ `Jev could not look (${res?.ok ? 'malformed agreement answer' : (res?.error ?? 'no answer')})`
283
+ );
284
+ const seedText = pitch;
285
+ const seedComponents = componentsFrom(seedText);
286
+ const seedTotal = seedComponents ? totalOf(seedComponents)?.total : null;
287
+ const section = readerSection({
288
+ family: family.id,
289
+ agreement: a.noul,
290
+ seedComponents,
291
+ seedTotal,
292
+ date: io.today,
293
+ reply: reply.text,
294
+ });
295
+ let next = upsertIntentSection(plan, section.text);
296
+ if (section.total != null) next = setFrontmatterKey(next, 'intent_match', section.total);
297
+ io.write(where.readme, next);
298
+ io.out(
299
+ `intent reader: ${family.id} read the pitch — agreement ${f2(a.noul)}${section.total != null ? `; total ${section.total} (seed ${seedTotal})` : ''}, written to ${relative(io.root, where.readme)}`
300
+ );
301
+ return 0;
302
+ }
303
+
304
+ async function main() {
305
+ const root = repoRoot();
306
+ const code = await run(process.argv.slice(2), {
307
+ root,
308
+ setting: () => getKey('intent.reader', { root }),
309
+ config: () => loadJevConfig({ root }),
310
+ key: () => readApiKey({ root }),
311
+ has: hasCmd,
312
+ secrets: (text) => findSecretLeaks(text, { values: collectSecretValues({ root }) }),
313
+ spawn: spawnSync,
314
+ ask: (req, { key, model }) => askJev(req, { key, model, timeoutMs: INTENT_TIMEOUT_MS }),
315
+ read: (p) => readFileSync(p, 'utf8'),
316
+ write: (p, t) => writeFileSync(p, t),
317
+ out: (l) => process.stdout.write(`${l}\n`),
318
+ today: new Date().toISOString().slice(0, 10),
319
+ });
320
+ process.exitCode = code;
321
+ }
322
+
323
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
324
+ if (isMain)
325
+ main().catch((e) => {
326
+ // Even an unexpected crash is one skip line and exit 0: the reader never stops a lock (D4). A malformed config
327
+ // is the one thing a person must see, and it is still only one line.
328
+ process.stdout.write(`${skipLine(`internal error (${e?.message || e})`)}\n`);
329
+ process.exitCode = 0;
330
+ });
@@ -142,6 +142,16 @@ export const REGISTRY = Object.freeze([
142
142
  default: 'every-risk-high-story-names-its-flag',
143
143
  question: 'Should every risk:high story name its kill-switch flag?',
144
144
  },
145
+ {
146
+ // intent-match D4/D16: an optional second-family read of the pitch at the architecture lock. OFF unless a person
147
+ // turns it on — never asked (`never-yet`), because a reader that runs by default is the stall D4 rules out.
148
+ key: 'intent.reader',
149
+ module: 'Plan',
150
+ askWhen: 'never-yet',
151
+ default: 'off',
152
+ choices: ['off', 'on'],
153
+ question: 'At the architecture lock, ask one other model family (codex, agy or vibe) to read the pitch and score whether it would build the same thing? Off by default; any failure is skipped.',
154
+ },
145
155
  {
146
156
  key: 'spend.telemetry',
147
157
  module: 'Spend',
@@ -43,6 +43,7 @@ export const SECTIONS = Object.freeze([
43
43
  'ship',
44
44
  'spend',
45
45
  'routines',
46
+ 'intent',
46
47
  ]);
47
48
 
48
49
  /**
@@ -875,11 +875,17 @@ export function runWithCodexFallback({ prompt, stdin, antigravityArgv, builder =
875
875
  }
876
876
  }
877
877
 
878
+ // The argv for one agy print-mode call. Exported so a caller that needs its OWN spawn (a hard timeout, one model, no
879
+ // fallback line — the intent reader, intent-match C4) builds the identical invocation instead of a second copy of it.
880
+ export function agyArgs(fullArgv, model = AGY_MODEL) {
881
+ return ['-p', fullArgv, '--model', model];
882
+ }
883
+
878
884
  // One `agy -p "<prompt>" --model "<MODEL>"` invocation. The prompt+framed context ride in `fullArgv` (stdin is
879
885
  // NOT the prompt and must be at EOF — input:'' gives an immediate EOF or print mode blocks forever). Returns
880
886
  // the raw spawn result; the caller classifies status/stdout.
881
887
  function execAgy(fullArgv, model, spawn) {
882
- return spawn('agy', ['-p', fullArgv, '--model', model], {
888
+ return spawn('agy', agyArgs(fullArgv, model), {
883
889
  input: '',
884
890
  encoding: 'utf8',
885
891
  maxBuffer: 64 * 1024 * 1024,
@@ -1252,16 +1258,8 @@ export const CLAUDE_REVIEW_MODEL = process.env.CLAUDE_REVIEW_MODEL || 'sonnet';
1252
1258
  // Empty stdout is treated as a FAILURE, not as "no findings". Every CLI on this roster can exit 0 having
1253
1259
  // produced nothing when it is quota-capped or misconfigured, and a review that silently becomes empty is
1254
1260
  // worse than one that errors — it reads as a clean pass. `deps.spawn` is injectable for tests.
1255
- export function runVibe(fullArgv, opts = {}, deps = {}) {
1256
- const { spawn = spawnSync } = deps;
1257
- if (Buffer.byteLength(fullArgv, 'utf8') > VIBE_ARG_LIMIT) {
1258
- return fail(
1259
- opts.soft,
1260
- `input too large for vibe (${Math.round(Buffer.byteLength(fullArgv) / 1024)} KB > ` +
1261
- `${VIBE_ARG_LIMIT / 1024} KB; vibe takes the prompt in argv, not stdin) — use --agent codex instead.`
1262
- );
1263
- }
1264
-
1261
+ // The argv for one vibe call, read-only (every tool disabled). Exported for the same reason as agyArgs (C4).
1262
+ export function vibeArgs(fullArgv) {
1265
1263
  const args = [
1266
1264
  '--prompt',
1267
1265
  fullArgv,
@@ -1276,8 +1274,20 @@ export function runVibe(fullArgv, opts = {}, deps = {}) {
1276
1274
  '*',
1277
1275
  ];
1278
1276
  if (VIBE_MODEL) args.push('--model', VIBE_MODEL);
1277
+ return args;
1278
+ }
1279
+
1280
+ export function runVibe(fullArgv, opts = {}, deps = {}) {
1281
+ const { spawn = spawnSync } = deps;
1282
+ if (Buffer.byteLength(fullArgv, 'utf8') > VIBE_ARG_LIMIT) {
1283
+ return fail(
1284
+ opts.soft,
1285
+ `input too large for vibe (${Math.round(Buffer.byteLength(fullArgv) / 1024)} KB > ` +
1286
+ `${VIBE_ARG_LIMIT / 1024} KB; vibe takes the prompt in argv, not stdin) — use --agent codex instead.`
1287
+ );
1288
+ }
1279
1289
 
1280
- const r = spawn('vibe', args, { input: '', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
1290
+ const r = spawn('vibe', vibeArgs(fullArgv), { input: '', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
1281
1291
  if (r.error)
1282
1292
  return fail(
1283
1293
  opts.soft,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.10.0",
3
+ "version": "0.11.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": {