@golden-frijoles/kit 0.9.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.
- package/dist/intent-match.mjs +706 -0
- package/dist/intent-reader.mjs +330 -0
- package/dist/jev-eval.fixtures.json +1390 -0
- package/dist/jev-eval.mjs +38 -6
- package/dist/lib/config-registry.mjs +10 -0
- package/dist/lib/config.mjs +1 -0
- package/dist/lib/cross-agent-cli.mjs +22 -12
- package/dist/lib/jev.mjs +7 -2
- package/package.json +1 -1
|
@@ -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
|
+
});
|