@golden-frijoles/kit 0.9.0 → 0.10.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.
@@ -0,0 +1,664 @@
1
+ #!/usr/bin/env node
2
+ // intent-match.mjs — how well a groomed pitch captured the ask it was written from (intent-match S1, D9–D15).
3
+ //
4
+ // node scripts/intent-match.mjs <seed.md> print the score, the band and a route for each gap
5
+ // node scripts/intent-match.mjs <seed.md> --json the same, as JSON on stdout (the report goes to stderr)
6
+ // node scripts/intent-match.mjs <seed.md> --write also put `intent_match:` and `## Intent match` into the seed
7
+ // node scripts/intent-match.mjs <seed.md> --no-route skip the second call that routes each gap to an artifact
8
+ //
9
+ // ── ADVISORY, NEVER A GATE (D1) ──────────────────────────────────────────────────────────────────────────────
10
+ // The score can add a step to a groom; it never blocks a scaffold and never removes the product owner's approval.
11
+ // Nothing reads the exit code as a verdict: 0 means "scored", 2 means "could not look", 1 means a usage error.
12
+ //
13
+ // ── ONE STATISTIC PER QUESTION (D2, D11) ─────────────────────────────────────────────────────────────────────
14
+ // Each signal is one number over Jev Noul/Score answers, so a later calibration can fit weights and bands without
15
+ // re-asking anything (the jev-reanchor spike's lesson). Coverage in = mean P(true) over the ask's claims; coverage
16
+ // out = mean P(true) over the pitch's acceptance criteria; clarity = mean Score ÷ 3 over the same criteria;
17
+ // teach-back = the product owner's answer to groom's Stage 1 mirror (yes 1 · partly 0.5 · no 0). The total is
18
+ // 100 × the equal-weight mean of the signals PRESENT, and it always says "uncalibrated" and which signals it used:
19
+ // the 80 / 60 bands are placeholders until `intent-outcomes` has twenty answered epics to fit them against.
20
+ //
21
+ // ── THREE STATES, NEVER TWO ──────────────────────────────────────────────────────────────────────────────────
22
+ // No key, `jev.egress` not `true`, a state over Jev's budget, a timeout or a malformed answer all print "could not
23
+ // look" and NO number. A partial score dressed as a whole one is worse than none: it is the number a groom would
24
+ // quote. The pitch is never truncated to fit — `askJev` refuses an over-budget state, and so do we.
25
+ //
26
+ // ── NOT A JEV RAIL (C2) ──────────────────────────────────────────────────────────────────────────────────────
27
+ // A rail (`review`, `prose`) is a guard with a regex to fall back to and an off/shadow/jev switch. This decides
28
+ // nothing and has no fallback, so it only reads `jev.egress` and `jev.model`: egress must be an explicit `true`.
29
+ //
30
+ // The seed format this parses is the contract in the epic README's D9 (and groom's `templates/scope-seed.md`):
31
+ // `## The ask, as given` → `### Claims` (a numbered list) → `**Teach-back:** yes | partly | no`, and the list under
32
+ // the first `## Acceptance…` heading. Zero deps — Node 18+.
33
+
34
+ import { readFileSync, writeFileSync } from 'node:fs';
35
+ import { resolve } from 'node:path';
36
+ import { fileURLToPath } from 'node:url';
37
+ import {
38
+ askJev,
39
+ loadJevConfig,
40
+ logDecision,
41
+ readApiKey,
42
+ repoRoot,
43
+ stateSize,
44
+ STATE_CHAR_BUDGET,
45
+ } from './lib/jev.mjs';
46
+ import { needSetting } from './lib/config.mjs';
47
+
48
+ export const EXIT_SCORED = 0;
49
+ export const EXIT_USAGE = 1;
50
+ export const EXIT_COULD_NOT_LOOK = 2;
51
+
52
+ /** One call carries 40-odd questions; the guards' 8 s default is sized for two (D10). */
53
+ export const INTENT_TIMEOUT_MS = 30_000;
54
+
55
+ /** Placeholder bands (D3, D11) — the calibration fits them. Never read one as a threshold anything enforces. */
56
+ export const BANDS = Object.freeze([
57
+ { min: 80, label: 'build' },
58
+ { min: 60, label: 'resolve the follow-ups first' },
59
+ { min: 0, label: 'sketch or spike first' },
60
+ ]);
61
+
62
+ /** The artifacts a gap can be routed to (D14) — the visuals rule (groom Stage 4.6) draws from the same words. */
63
+ export const ROUTES = Object.freeze({
64
+ copy_deck: 'copy deck',
65
+ wireframe: 'wireframe',
66
+ flow: 'flow',
67
+ data_sample: 'data sample',
68
+ state_machine: 'state machine',
69
+ sequence: 'sequence',
70
+ container_diagram: 'container diagram',
71
+ spike: 'spike',
72
+ think_chain: 'think chain (answer by hand until think-skills ships)',
73
+ });
74
+
75
+ /**
76
+ * EVERY question this script (and the reader at the lock) asks Jev, in one object (D13), so `compiled-prompts` can
77
+ * move it to a data file unchanged. `{item}` is replaced by the backticked state path of the claim or criterion
78
+ * being asked about (`claims.c3`, `criteria.a2`) — the item's text is in the state, never in the question.
79
+ *
80
+ * MEASURED, not guessed (D15) — on the 37 labelled items of jev-eval's `intent` set (2026-09-29, jev-1.13.0; five
81
+ * pitches, two of them anonymised from a consuming project). "Right" = the yes/no at 0.5 matches the label;
82
+ * "decided" = the answer sits at P ≤ 0.2 or ≥ 0.8. Re-measure with `node scripts/jev-eval.mjs --live --rail intent`
83
+ * before changing a word:
84
+ * coverage_in 17 items · 17 right · 16 decided, all right.
85
+ * coverage_out 8 items · 8 right · 6 decided, all right. The first wording ("does this check trace back to a
86
+ * claim?") was also 8/8 right but decided only 4: a check on a DETAIL of a claim (a field's
87
+ * validation) sat at 0.47–0.51. Naming "a detail of how the plan delivers it" fixed that.
88
+ * clarity 12 items · 12 right · 10 decided, all right (score ÷ 3, split at 0.5).
89
+ * route not measured: a route is a suggestion for the next artifact, never part of the score.
90
+ * Re-running the same request moved one clarity answer across the 0.8 line (0.79 ↔ 0.81): "decided" counts are ±1.
91
+ */
92
+ export const INTENT_QUESTIONS = Object.freeze({
93
+ coverage_in: {
94
+ type: 'noul',
95
+ instructions:
96
+ '`pitch` is a plan written in answer to a request. `{item}` is one thing the requester asked for. Does the plan deliver it — is there a part, story, decision or acceptance check in `pitch` that satisfies it, even in different words?',
97
+ criteria: {
98
+ true: 'Yes: something the plan proposes to build, decide, write or check delivers this requirement, fully or in substance.',
99
+ false:
100
+ 'No: the plan does not deliver it — it is missing, only restated as background or as the problem, deferred to later work, cut, or listed as out of scope.',
101
+ },
102
+ },
103
+ coverage_out: {
104
+ type: 'noul',
105
+ instructions:
106
+ '`{item}` is an acceptance check from the plan in `pitch`. `claims` lists what the requester asked for. Is this check there because of the request: does it test something a claim asks for, or a detail of how the plan delivers it, or a call the plan records the requester making?',
107
+ criteria: {
108
+ true: 'Yes: it tests something a claim asks for, or a detail of delivering it (a limit, an error message, a format, a step), or a decision the plan says the requester made.',
109
+ false:
110
+ 'No: it tests a feature or outcome that no claim asks for and no recorded decision of the requester explains, so it is scope the plan added on its own.',
111
+ },
112
+ },
113
+ clarity: {
114
+ type: 'score',
115
+ instructions:
116
+ '`{item}` is an acceptance check from the plan in `pitch`. Could two builders, working separately, test it the same way and agree whether it passes?',
117
+ criteria: [
118
+ 'No: it states an aim or a quality ("works well", "is clear", "feels fast") with nothing anyone could observe.',
119
+ 'Barely: it names what to look at, but not what counts as passing, so two testers would likely disagree.',
120
+ 'Mostly: it names an action and an outcome but leaves one thing to judgement, such as a threshold, a wording or which case to try.',
121
+ 'Yes: it names the action, where to do it and the exact result to observe, so two testers would agree.',
122
+ ],
123
+ },
124
+ route: {
125
+ type: 'choice',
126
+ instructions:
127
+ '`{item}` is a gap between the request and the plan in `pitch`: {gap}. Which ONE artifact, made next, would close this gap fastest?',
128
+ criteria: {
129
+ copy_deck:
130
+ 'The exact words a person will read (labels, messages, names, an email) are what is missing or unclear.',
131
+ wireframe: 'A screen or page someone uses: what is on it, in what order, and what they can do there.',
132
+ flow: 'A journey of several steps: the order of the steps and the branches between them.',
133
+ data_sample:
134
+ 'A new record, table, file or payload: three real-looking rows would settle what it holds.',
135
+ state_machine:
136
+ 'A lifecycle: the statuses something moves through and what moves it from one to the next.',
137
+ sequence:
138
+ 'Calls between services, background work or retries: who calls whom, in what order, and what happens on failure.',
139
+ container_diagram:
140
+ 'A new repo, package, service or deploy boundary: what runs where and what talks to what.',
141
+ spike:
142
+ 'A technical unknown: nobody knows yet whether or how it can be done, and a short experiment would answer it.',
143
+ think_chain:
144
+ 'A judgement or trade-off that needs reasoning through in writing, which none of the other artifacts would settle.',
145
+ },
146
+ },
147
+ });
148
+
149
+ // ── Parsing the seed (D9) ─────────────────────────────────────────────────────────────────────────────────────
150
+
151
+ const FRONTMATTER_RE = /^---\n[\s\S]*?\n---\n?/;
152
+
153
+ /** Flat frontmatter → { key: value } (comment-stripped, quotes stripped). The same rule epic-dod uses. */
154
+ export function frontmatterOf(text) {
155
+ const m = /^---\n([\s\S]*?)\n---/.exec(String(text));
156
+ const out = {};
157
+ if (!m) return out;
158
+ for (const line of m[1].split('\n')) {
159
+ const kv = /^([A-Za-z_][\w-]*):\s*(.*?)\s*(?:#.*)?$/.exec(line);
160
+ if (kv) out[kv[1]] = kv[2].replace(/^["']|["']$/g, '');
161
+ }
162
+ return out;
163
+ }
164
+
165
+ /** Split a markdown body at its `## ` headings: [{ heading, lines }], the preamble first with heading null. */
166
+ export function sections(body) {
167
+ const out = [{ heading: null, lines: [] }];
168
+ let fence = false;
169
+ for (const line of String(body).split('\n')) {
170
+ if (/^\s*(```|~~~)/.test(line)) fence = !fence;
171
+ if (!fence && /^## /.test(line)) out.push({ heading: line.slice(3).trim(), lines: [] });
172
+ else out.at(-1).lines.push(line);
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * The top-level items of a markdown list: `- x`, `* x`, `1. x`, `1) x`, with indented or wrapped lines folded into
179
+ * the item they belong to. A nested bullet is part of its parent, not an item of its own — a criterion's sub-points
180
+ * are how a builder tests it, and scoring them separately would double-count one check.
181
+ */
182
+ export function listItems(lines, { numbered = false } = {}) {
183
+ const items = [];
184
+ const top = numbered ? /^(\d+)[.)]\s+(.*)$/ : /^(?:[-*+]|\d+[.)])\s+(.*)$/;
185
+ let fence = false;
186
+ for (const raw of lines) {
187
+ if (/^\s*(```|~~~)/.test(raw)) {
188
+ fence = !fence;
189
+ continue;
190
+ }
191
+ if (fence) continue;
192
+ const m = top.exec(raw);
193
+ if (m) {
194
+ items.push((numbered ? m[2] : m[1]).trim());
195
+ continue;
196
+ }
197
+ if (!items.length) continue;
198
+ if (!raw.trim()) {
199
+ items.push(null); // a blank line ends the item; a later unindented paragraph is not part of it
200
+ continue;
201
+ }
202
+ // An indented line, or markdown's lazy continuation of an unindented one, belongs to the open item.
203
+ if (items.at(-1) !== null && !/^#/.test(raw)) items[items.length - 1] += ` ${raw.trim()}`;
204
+ }
205
+ return items.filter((i) => i !== null && i.length);
206
+ }
207
+
208
+ const ASK_HEADING = /^the ask, as given\b/i;
209
+ const TEACH_BACK_LINE = /\*\*Teach-back:\*\*/i;
210
+ const INTENT_HEADING = /^intent match\b/i;
211
+ const ACCEPTANCE_HEADING = /^acceptance\b/i;
212
+
213
+ /**
214
+ * Parse a seed into what the score needs. Pure. HTML comments are template guidance, not pitch: stripped first.
215
+ * Returns { frontmatter, ask, claims, teachBack, criteria, pitch }. An absent section leaves its field empty/null —
216
+ * never a default that would score as zero (D9).
217
+ */
218
+ export function parseSeed(text) {
219
+ // CRLF would defeat every `$` below and parse to "no claims" (fresh review of #196): normalise first.
220
+ const src = String(text ?? '').replace(/\r\n?/g, '\n');
221
+ const frontmatter = frontmatterOf(src);
222
+ const body = src.replace(FRONTMATTER_RE, '').replace(/<!--[\s\S]*?-->/g, '');
223
+ const secs = sections(body);
224
+ const askSec = secs.find((s) => s.heading && ASK_HEADING.test(s.heading));
225
+ let ask = null;
226
+ let claims = [];
227
+ let teachBack = null;
228
+ if (askSec) {
229
+ const claimsAt = askSec.lines.findIndex((l) => /^###\s+claims\b/i.test(l));
230
+ const askLines = claimsAt < 0 ? askSec.lines : askSec.lines.slice(0, claimsAt);
231
+ ask =
232
+ askLines
233
+ .filter((l) => !/\*\*Teach-back:\*\*/i.test(l))
234
+ .join('\n')
235
+ .trim() || null;
236
+ if (claimsAt >= 0) {
237
+ const after = askSec.lines.slice(claimsAt + 1);
238
+ // The claims end at the next sub-heading OR the teach-back line: a `**Teach-back:**` written directly under the
239
+ // last claim would otherwise fold into it as a lazy continuation (fresh review of #196).
240
+ const end = after.findIndex((l) => /^###\s/.test(l) || TEACH_BACK_LINE.test(l));
241
+ claims = listItems(end < 0 ? after : after.slice(0, end), { numbered: true });
242
+ }
243
+ // `yes | partly | no` is the template's format string, not an answer: a word followed by `|` never counts
244
+ // (the same rule D17 applies to the retro's `_Intent:_` line).
245
+ const tb = askSec.lines.join('\n').match(/\*\*Teach-back:\*\*\s*(yes|partly|no)\b(?!\s*\|)/i);
246
+ teachBack = tb ? tb[1].toLowerCase() : null;
247
+ }
248
+ const accSec = secs.find((s) => s.heading && ACCEPTANCE_HEADING.test(s.heading));
249
+ const criteria = accSec ? listItems(accSec.lines) : [];
250
+ // C5: the pitch Jev reads never holds the ask itself (coverage-in would find every claim in it) or a previous
251
+ // score (the model would grade its own earlier answer).
252
+ const pitch = secs
253
+ .filter((s) => !(s.heading && (ASK_HEADING.test(s.heading) || INTENT_HEADING.test(s.heading))))
254
+ .map((s) => (s.heading ? [`## ${s.heading}`, ...s.lines] : s.lines).join('\n'))
255
+ .join('\n')
256
+ .replace(/\n{3,}/g, '\n\n')
257
+ .trim();
258
+ return { frontmatter, ask, claims, teachBack, criteria, pitch };
259
+ }
260
+
261
+ // ── The request (D10) ─────────────────────────────────────────────────────────────────────────────────────────
262
+
263
+ const itemQuestion = (id, path, extra = {}) => {
264
+ const q = INTENT_QUESTIONS[id];
265
+ let instructions = q.instructions.replace('{item}', `\`${path}\``);
266
+ for (const [k, v] of Object.entries(extra)) instructions = instructions.replace(`{${k}}`, v);
267
+ return { type: q.type, instructions, criteria: q.criteria };
268
+ };
269
+
270
+ const claimKey = (i) => `c${i + 1}`;
271
+ const criterionKey = (j) => `a${j + 1}`;
272
+
273
+ /** The state every question reads: the pitch without the ask, and the items keyed so a question can name them. */
274
+ export function stateOf(parsed) {
275
+ return {
276
+ pitch: parsed.pitch,
277
+ claims: Object.fromEntries(parsed.claims.map((c, i) => [claimKey(i), c])),
278
+ criteria: Object.fromEntries(parsed.criteria.map((a, j) => [criterionKey(j), a])),
279
+ };
280
+ }
281
+
282
+ /**
283
+ * The scoring request. Pure. Claims are required — without the ask there is nothing to match against, so the
284
+ * caller reports "could not look" rather than a clarity-only number wearing the name "intent match".
285
+ */
286
+ export function buildRequest(parsed) {
287
+ const questions = {};
288
+ parsed.claims.forEach((_, i) => {
289
+ questions[`in_${claimKey(i)}`] = itemQuestion('coverage_in', `claims.${claimKey(i)}`);
290
+ });
291
+ parsed.criteria.forEach((_, j) => {
292
+ questions[`out_${criterionKey(j)}`] = itemQuestion('coverage_out', `criteria.${criterionKey(j)}`);
293
+ questions[`clar_${criterionKey(j)}`] = itemQuestion('clarity', `criteria.${criterionKey(j)}`);
294
+ });
295
+ return { state: stateOf(parsed), questions };
296
+ }
297
+
298
+ // ── Scoring (D11) ─────────────────────────────────────────────────────────────────────────────────────────────
299
+
300
+ const isUnit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
301
+ const mean = (xs) => xs.reduce((s, x) => s + x, 0) / xs.length;
302
+ const TEACH_BACK = { yes: 1, partly: 0.5, no: 0 };
303
+ export const SIGNAL_NAMES = Object.freeze({
304
+ coverage_in: 'coverage in',
305
+ coverage_out: 'coverage out',
306
+ clarity: 'clarity',
307
+ teach_back: 'teach-back',
308
+ agreement: 'agreement',
309
+ });
310
+
311
+ export function band(total) {
312
+ return BANDS.find((b) => total >= b.min).label;
313
+ }
314
+
315
+ /** The total from whatever signals are present (the reader step reuses this to add agreement). Pure. */
316
+ export function totalOf(signals) {
317
+ const present = Object.entries(signals).filter(([, v]) => isUnit(v));
318
+ if (!present.length) return null;
319
+ return { total: Math.round(100 * mean(present.map(([, v]) => v))), present: present.map(([k]) => k) };
320
+ }
321
+
322
+ /**
323
+ * Turn Jev's answers into the score. Pure. ANY answer that is not the right shape makes the whole result
324
+ * could-not-look: a Noul must be a probability in [0,1] and a Score a number in [0,3] — `Number(true)` scoring as
325
+ * a certainty is the bug the guards already shipped once (LEARNINGS, jev-semantic-guards).
326
+ */
327
+ export function scoreAnswers(parsed, answers) {
328
+ const bad = [];
329
+ // The answer's own `type` must match the question's too: a `{ type: 'score', noul: 0.9 }` is not a Noul answer,
330
+ // and reading its `.noul` anyway would turn a malformed reply into a number (codex on #196).
331
+ const noul = (id) => {
332
+ const a = answers?.[id];
333
+ if (a?.type !== 'noul' || !isUnit(a.noul)) bad.push(id);
334
+ return a?.noul;
335
+ };
336
+ const score = (id) => {
337
+ const a = answers?.[id];
338
+ const v = a?.score;
339
+ if (a?.type !== 'score' || !(typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 3))
340
+ bad.push(id);
341
+ return v / 3;
342
+ };
343
+ const claims = parsed.claims.map((text, i) => ({ id: claimKey(i), text, p: noul(`in_${claimKey(i)}`) }));
344
+ const criteria = parsed.criteria.map((text, j) => ({
345
+ id: criterionKey(j),
346
+ text,
347
+ traced: noul(`out_${criterionKey(j)}`),
348
+ clarity: score(`clar_${criterionKey(j)}`),
349
+ }));
350
+ if (bad.length) return { ok: false, error: `malformed answer(s): ${bad.slice(0, 3).join(', ')}` };
351
+ const signals = {
352
+ coverage_in: claims.length ? mean(claims.map((c) => c.p)) : null,
353
+ coverage_out: criteria.length ? mean(criteria.map((a) => a.traced)) : null,
354
+ clarity: criteria.length ? mean(criteria.map((a) => a.clarity)) : null,
355
+ teach_back: parsed.teachBack ? TEACH_BACK[parsed.teachBack] : null,
356
+ };
357
+ const { total, present } = totalOf(signals);
358
+ const gaps = [
359
+ ...claims
360
+ .filter((c) => c.p < 0.5)
361
+ .map((c) => ({ id: c.id, kind: 'uncovered', value: c.p, text: c.text })),
362
+ ...criteria
363
+ .filter((a) => a.clarity < 0.5)
364
+ .map((a) => ({ id: a.id, kind: 'unclear', value: a.clarity, text: a.text })),
365
+ ];
366
+ const untraced = criteria
367
+ .filter((a) => a.traced < 0.5)
368
+ .map((a) => ({ id: a.id, value: a.traced, text: a.text }));
369
+ return { ok: true, signals, total, present, band: band(total), claims, criteria, gaps, untraced };
370
+ }
371
+
372
+ const GAP_WORDS = {
373
+ uncovered: 'the requester asked for this and the plan does not deliver it',
374
+ unclear: 'the plan says it will check this, but two builders could not test it the same way',
375
+ };
376
+
377
+ /** The routing request: one Choice per gap (D14). Pure. */
378
+ export function buildRouteRequest(parsed, gaps) {
379
+ const questions = {};
380
+ for (const g of gaps) {
381
+ const path = g.id.startsWith('c') ? `claims.${g.id}` : `criteria.${g.id}`;
382
+ questions[`route_${g.id}`] = itemQuestion('route', path, { gap: GAP_WORDS[g.kind] });
383
+ }
384
+ return { state: stateOf(parsed), questions };
385
+ }
386
+
387
+ /** Attach each gap's route, or "could not look" for that gap. A route never changes the score. Pure. */
388
+ export function applyRoutes(gaps, res) {
389
+ return gaps.map((g) => {
390
+ const choice = res?.ok ? res.answers?.[`route_${g.id}`]?.choice : undefined;
391
+ return { ...g, route: Object.hasOwn(ROUTES, choice ?? '') ? choice : null };
392
+ });
393
+ }
394
+
395
+ // ── Output ────────────────────────────────────────────────────────────────────────────────────────────────────
396
+
397
+ const f2 = (n) => n.toFixed(2);
398
+ const clip = (s, n = 90) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
399
+ const itemLabel = (id) => (id.startsWith('c') ? `claim ${id.slice(1)}` : `criterion ${id.slice(1)}`);
400
+
401
+ /** The human report. Pure. `agreement` is the reader's P(same build), or undefined ("pending"). */
402
+ export function formatReport(result, { source = 'the seed', agreement, reader } = {}) {
403
+ const s = result.signals;
404
+ const row = (name, v, detail) => ` ${name.padEnd(13)} ${v == null ? '— ' : f2(v)} ${detail}`;
405
+ const lines = [
406
+ `Intent match — ${source}`,
407
+ row(
408
+ 'coverage in',
409
+ s.coverage_in,
410
+ `(${result.claims.length} claim${result.claims.length === 1 ? '' : 's'})`
411
+ ),
412
+ row(
413
+ 'coverage out',
414
+ s.coverage_out,
415
+ result.criteria.length ? `(${result.criteria.length} criteria)` : '(no acceptance list)'
416
+ ),
417
+ row(
418
+ 'clarity',
419
+ s.clarity,
420
+ result.criteria.length ? `(${result.criteria.length} criteria)` : '(no acceptance list)'
421
+ ),
422
+ row(
423
+ 'teach-back',
424
+ s.teach_back,
425
+ s.teach_back == null ? '(not recorded)' : `(${{ 1: 'yes', 0.5: 'partly', 0: 'no' }[s.teach_back]})`
426
+ ),
427
+ agreement == null
428
+ ? ' agreement pending (the optional reader at the architecture lock)'
429
+ : row('agreement', agreement, reader ? `(reader: ${reader})` : ''),
430
+ `Total ${result.total} / 100 — uncalibrated · signals: ${result.present.map((k) => SIGNAL_NAMES[k]).join(', ')}`,
431
+ `Band: ${result.band} (placeholder bands: 80 build · 60 resolve follow-ups · below 60 sketch or spike)`,
432
+ ];
433
+ if (result.gaps.length) {
434
+ lines.push('Gaps:');
435
+ for (const g of result.gaps)
436
+ lines.push(
437
+ ` ${itemLabel(g.id)} — ${g.kind} (${f2(g.value)}): "${clip(g.text)}" → ${g.route ? ROUTES[g.route] : 'route: could not look'}`
438
+ );
439
+ } else lines.push('Gaps: none');
440
+ if (result.untraced.length) {
441
+ lines.push('Untraced (trace it to the ask or cut it):');
442
+ for (const u of result.untraced) lines.push(` ${itemLabel(u.id)} (${f2(u.value)}): "${clip(u.text)}"`);
443
+ }
444
+ return lines.join('\n');
445
+ }
446
+
447
+ /** The components, machine-readable, for the seed's `## Intent match` comment (D12). Pure. */
448
+ export function componentsOf(result) {
449
+ const round = (v) => (v == null ? null : Math.round(v * 1000) / 1000);
450
+ return Object.fromEntries(Object.entries(result.signals).map(([k, v]) => [k, round(v)]));
451
+ }
452
+
453
+ /** The `## Intent match` section `--write` puts in the seed. Pure. */
454
+ export function intentSection(result, opts = {}) {
455
+ return [
456
+ '## Intent match',
457
+ '',
458
+ '_Advisory and uncalibrated (intent-match D1, D3): it can add a step, never block one. Regenerate with `node scripts/intent-match.mjs <this seed> --write`._',
459
+ '',
460
+ '```text',
461
+ formatReport(result, opts),
462
+ '```',
463
+ '',
464
+ `<!-- intent-match: ${JSON.stringify({ ...componentsOf(result), total: result.total })} -->`,
465
+ '',
466
+ ].join('\n');
467
+ }
468
+
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);
476
+ 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);
490
+ const lines = src.split('\n');
491
+ // Fence-aware, like sections(): a `## Intent match` inside a code block is an example, and treating it as the
492
+ // section deleted everything up to the next heading, closing fence included (fresh review of #196).
493
+ const headingAt = [];
494
+ let fence = false;
495
+ lines.forEach((l, i) => {
496
+ if (/^\s*(```|~~~)/.test(l)) fence = !fence;
497
+ else if (!fence && /^## /.test(l)) headingAt.push(i);
498
+ });
499
+ const start = headingAt.find((i) => INTENT_HEADING.test(lines[i].slice(3).trim())) ?? -1;
500
+ if (start < 0) return `${src.replace(/\s*$/, '')}\n\n${section}`;
501
+ const end = headingAt.find((i) => i > start) ?? lines.length;
502
+ return [...lines.slice(0, start), ...section.replace(/\n$/, '').split('\n'), '', ...lines.slice(end)]
503
+ .join('\n')
504
+ .replace(/\n{3,}/g, '\n\n');
505
+ }
506
+
507
+ // ── The run, every side effect injected ───────────────────────────────────────────────────────────────────────
508
+
509
+ const couldNotLook = (why) => ({ state: 'could-not-look', why });
510
+
511
+ /**
512
+ * Score one seed's text. Returns { state: 'scored', result } or { state: 'could-not-look', why }. Never throws for
513
+ * a Jev problem. deps: { config, key, ask, route=true, onCount }.
514
+ */
515
+ export async function scoreSeed(text, deps) {
516
+ const { config, key, ask, route = true, onCount = () => {} } = deps;
517
+ const parsed = parseSeed(text);
518
+ if (!parsed.claims.length)
519
+ return couldNotLook(
520
+ 'no claims to match against — the seed needs `## The ask, as given` with a `### Claims` list'
521
+ );
522
+ if (config.egress !== true)
523
+ return couldNotLook(`jev.egress is ${JSON.stringify(config.egress)}, not true — nothing is sent to Jev`);
524
+ if (!key) return couldNotLook('no TYPESAFE_API_KEY');
525
+ const req = buildRequest(parsed);
526
+ // Checked here as well as in askJev so the refusal names the pitch, and happens before the count is printed.
527
+ if (stateSize(req.state) > STATE_CHAR_BUDGET)
528
+ return couldNotLook(
529
+ `the pitch is over Jev's state budget (${stateSize(req.state)} > ${STATE_CHAR_BUDGET} chars); it is never truncated`
530
+ );
531
+ onCount(Object.keys(req.questions).length, parsed);
532
+ const res = await ask(req);
533
+ if (!res?.ok) return couldNotLook(`jev: ${res?.error ?? 'no answer'}`);
534
+ const result = scoreAnswers(parsed, res.answers);
535
+ if (!result.ok) return couldNotLook(`jev: ${result.error}`);
536
+ if (route && result.gaps.length)
537
+ result.gaps = applyRoutes(result.gaps, await ask(buildRouteRequest(parsed, result.gaps)));
538
+ else result.gaps = result.gaps.map((g) => ({ ...g, route: null }));
539
+ return { state: 'scored', result, parsed, model: res.model ?? null };
540
+ }
541
+
542
+ /**
543
+ * One labelled fixture, judged the way the scorer asks it — the `intent` set in jev-eval (D15). A fixture is
544
+ * { question: 'coverage_in' | 'coverage_out' | 'clarity', pitch, claims, criteria, item, label }. Returns
545
+ * { value: boolean | null, p, decider }: value is the answer as a yes/no (P ≥ 0.5, or clarity ≥ 0.5).
546
+ */
547
+ export async function judgeItem(fx, deps) {
548
+ const prefix = { coverage_in: 'in', coverage_out: 'out', clarity: 'clar' }[fx.question];
549
+ if (!prefix) return { value: null, p: null, decider: 'could-not-look' };
550
+ const path = `${fx.item.startsWith('c') ? 'claims' : 'criteria'}.${fx.item}`;
551
+ const id = `${prefix}_${fx.item}`;
552
+ const res = await deps.ask({
553
+ state: { pitch: fx.pitch, claims: fx.claims ?? {}, criteria: fx.criteria ?? {} },
554
+ questions: { [id]: itemQuestion(fx.question, path) },
555
+ });
556
+ const a = res?.ok ? res.answers?.[id] : undefined;
557
+ const want = INTENT_QUESTIONS[fx.question].type;
558
+ const p =
559
+ a?.type !== want
560
+ ? undefined
561
+ : want === 'score'
562
+ ? typeof a.score === 'number'
563
+ ? a.score / 3
564
+ : undefined
565
+ : a.noul;
566
+ if (!isUnit(p)) return { value: null, p: null, decider: 'could-not-look' };
567
+ return { value: p >= 0.5, p: Math.round(p * 1000) / 1000, decider: 'jev' };
568
+ }
569
+
570
+ export async function run(argv, io) {
571
+ const args = argv.filter((a) => !a.startsWith('--'));
572
+ const flags = new Set(argv.filter((a) => a.startsWith('--')));
573
+ const unknown = [...flags].filter((f) => !['--json', '--write', '--no-route', '--help'].includes(f));
574
+ if (flags.has('--help') || args.length !== 1 || unknown.length) {
575
+ io.stderr(
576
+ `${unknown.length ? `intent-match: unknown flag ${unknown.join(', ')}\n` : ''}usage: node scripts/intent-match.mjs <seed.md> [--json] [--write] [--no-route]\n`
577
+ );
578
+ return EXIT_USAGE;
579
+ }
580
+ const path = args[0];
581
+ let text;
582
+ try {
583
+ // Normalised ONCE, before parse and write: a CRLF seed used to score and then have `--write` miss its frontmatter
584
+ // and still report the write (fresh review round 2, #196). The seed is written back with LF endings.
585
+ text = io.read(path).replace(/\r\n?/g, '\n');
586
+ } catch (e) {
587
+ io.stderr(`intent-match: cannot read ${path} (${e.code ?? e.message})\n`);
588
+ return EXIT_USAGE;
589
+ }
590
+ const config = io.config();
591
+ // Unanswered egress is a question to ask once, not a silent "off" forever (golden-frijoles-plugin D11/D12).
592
+ if (config.egress === null) io.needEgress();
593
+ // The key is not even read until egress is a yes (the jev-eval rule): an unanswered project reads no secret.
594
+ const key = config.egress === true ? io.key() : null;
595
+ const out = await scoreSeed(text, {
596
+ config,
597
+ key,
598
+ ask: io.makeAsk({ model: config.model, key }),
599
+ route: !flags.has('--no-route'),
600
+ onCount: (n, parsed) =>
601
+ io.stderr(
602
+ `intent-match: asking Jev ${n} question(s) — ${parsed.claims.length} claim(s), ${parsed.criteria.length} criteria${flags.has('--no-route') ? '' : ', then one more per gap to route it'}.\n`
603
+ ),
604
+ });
605
+ if (out.state !== 'scored') {
606
+ const line = `intent match: could not look (${out.why}) — no score.`;
607
+ if (flags.has('--json')) io.stdout(`${JSON.stringify({ state: out.state, why: out.why })}\n`);
608
+ io.stderr(`${line}\n`);
609
+ return EXIT_COULD_NOT_LOOK;
610
+ }
611
+ const { result, parsed } = out;
612
+ const report = formatReport(result, { source: path });
613
+ if (flags.has('--json')) {
614
+ io.stdout(
615
+ `${JSON.stringify({ state: 'scored', total: result.total, band: result.band, signals: componentsOf(result), present: result.present, gaps: result.gaps, untraced: result.untraced, uncalibrated: true, model: out.model })}\n`
616
+ );
617
+ io.stderr(`${report}\n`);
618
+ } else io.stdout(`${report}\n`);
619
+ if (flags.has('--write')) {
620
+ io.write(path, writeIntoSeed(text, result, { source: path, hasAsk: Boolean(parsed.ask) }));
621
+ io.stderr(`intent-match: wrote intent_match: ${result.total} and ## Intent match into ${path}\n`);
622
+ }
623
+ io.log({
624
+ rail: 'intent',
625
+ mode: 'advisory',
626
+ decider: 'jev',
627
+ confidence: result.total / 100,
628
+ text: path,
629
+ source: path,
630
+ evidence: {
631
+ signals: componentsOf(result),
632
+ present: result.present,
633
+ gaps: result.gaps.length,
634
+ untraced: result.untraced.length,
635
+ },
636
+ });
637
+ return EXIT_SCORED;
638
+ }
639
+
640
+ async function main() {
641
+ const root = repoRoot();
642
+ const code = await run(process.argv.slice(2), {
643
+ read: (p) => readFileSync(p, 'utf8'),
644
+ write: (p, t) => writeFileSync(p, t),
645
+ config: () => loadJevConfig({ root }),
646
+ key: () => readApiKey({ root }),
647
+ needEgress: () => needSetting('jev.egress', { blocking: false, root }),
648
+ makeAsk:
649
+ ({ model, key }) =>
650
+ (req) =>
651
+ askJev(req, { key, model, timeoutMs: INTENT_TIMEOUT_MS }),
652
+ log: (entry) => logDecision(entry, { root }),
653
+ stdout: (t) => process.stdout.write(t),
654
+ stderr: (t) => process.stderr.write(t),
655
+ });
656
+ process.exitCode = code;
657
+ }
658
+
659
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
660
+ if (isMain)
661
+ main().catch((e) => {
662
+ process.stderr.write(`intent-match: ${e.message}\n`);
663
+ process.exitCode = EXIT_USAGE;
664
+ });