@golden-frijoles/kit 0.12.1 → 0.14.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/jev-eval.mjs CHANGED
@@ -27,15 +27,26 @@
27
27
  // wording of intent-match's questions on labelled items, and there is no regex to compare it with, no mode and no
28
28
  // threshold. Its report adds how many answers were DECIDED (P ≤ 0.2 or ≥ 0.8) and how many of those were right.
29
29
  //
30
+ // The `lint` rail (semantic-lint D8) is reported the same way — its selector picks candidates but has no verdict to
31
+ // compare with — and "decided" means raised or cleared at the rail's own threshold. Its question is project data, so a
32
+ // recording pins the wording's hash, and the fixtures live in the project's own jev-eval.lint.fixtures.json.
33
+ //
30
34
  // Zero deps — Node 18+.
31
35
 
32
- import { readFileSync, writeFileSync } from 'node:fs';
36
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
33
37
  import { dirname, join, resolve } from 'node:path';
34
38
  import { fileURLToPath } from 'node:url';
39
+ import { readSection } from './lib/config.mjs';
35
40
  import { loadJevConfig, parseJevConfig, RAILS, readApiKey, repoRoot } from './lib/jev.mjs';
36
41
 
37
42
  const __dirname = dirname(fileURLToPath(import.meta.url));
38
43
  export const FIXTURES_PATH = join(__dirname, 'jev-eval.fixtures.json');
44
+ /**
45
+ * The `lint` set's fixtures are PROJECT data, like the rules they measure (semantic-lint D8, fresh review of #200):
46
+ * this repo's rule-1 labels shipped in the shared file would fail any consumer whose own rule reused the id, and fail
47
+ * every consumer with a rule but no such fixtures. So they live beside the script in a file the template never has.
48
+ */
49
+ export const LINT_FIXTURES_PATH = join(__dirname, 'jev-eval.lint.fixtures.json');
39
50
 
40
51
  /** Shadow is a short, expiring measurement: at most this many days out, ever. */
41
52
  export const MAX_SHADOW_DAYS = 21;
@@ -74,6 +85,11 @@ export function expiredShadowRails(config, today) {
74
85
  export function coverageFailures(fixtures, rails) {
75
86
  const out = [];
76
87
  for (const name of EVAL_SETS) {
88
+ // A set that knows its own coverage rule (lint: per configured rule) answers for itself.
89
+ if (rails[name]?.coverage) {
90
+ out.push(...rails[name].coverage(fixtures[name] ?? []));
91
+ continue;
92
+ }
77
93
  const n = (fixtures[name] ?? []).length;
78
94
  if (!rails[name] && n)
79
95
  out.push(`${name}: ${n} fixture(s) but no judge to replay them — was the judge renamed?`);
@@ -132,7 +148,7 @@ const sortedCodes = (findings) =>
132
148
  * The rails this harness can evaluate. Each judge is looked up by name so a rail whose judge has not
133
149
  * landed yet is SKIPPED loudly instead of failing the import.
134
150
  */
135
- export async function loadRails() {
151
+ export async function loadRails({ lintRules = [] } = {}) {
136
152
  const rails = {};
137
153
  const review = await import('./lib/review-guard.mjs');
138
154
  if (typeof review.judgeReviewOutput === 'function')
@@ -161,6 +177,43 @@ export async function loadRails() {
161
177
  expected: (fx) => fx.label,
162
178
  summary: (d) => ({ value: d.value, p: d.p, decider: d.decider }),
163
179
  };
180
+ // semantic-lint D8. The QUESTION is the project's own data (`lint.rules`), so a fixture is replayed against the
181
+ // rule this project has; one whose rule it lacks (the template's own run) is skipped and counted, never scored.
182
+ const lint = await import('./semantic-lint.mjs');
183
+ if (typeof lint.judgeCandidate === 'function') {
184
+ const byId = new Map(lintRules.map((r) => [r.id, r]));
185
+ rails.lint = {
186
+ // Never replayed (there is no question to replay it against) — and `coverage` below fails it, so a renamed or
187
+ // removed rule cannot go green by skipping every fixture it had (fresh review of #200).
188
+ skip: (fx) => (byId.has(fx.rule) ? null : `no rule "${fx.rule}" in this project's lint config`),
189
+ coverage: (cases) => [
190
+ ...[...new Set(cases.filter((fx) => !byId.has(fx.rule)).map((fx) => fx.rule))].map(
191
+ (id) => `lint: fixtures for "${id}", which this project's lint config does not define — renamed or removed?`
192
+ ),
193
+ ...[...byId.keys()]
194
+ .map((id) => [id, cases.filter((fx) => fx.rule === id).length])
195
+ .filter(([, n]) => n < MIN_FIXTURES)
196
+ .map(([id, n]) => `lint/${id}: only ${n} labelled fixture(s); a rule needs ≥${MIN_FIXTURES}`),
197
+ ],
198
+ // A recording answers the wording that produced it: an edited question replaying green would prove nothing.
199
+ stale: (fx) =>
200
+ fx.recorded?.questionHash === lint.questionHash(byId.get(fx.rule))
201
+ ? null
202
+ : `recorded against another wording of ${fx.rule}'s question — run --live`,
203
+ recordExtra: (fx) => ({ questionHash: lint.questionHash(byId.get(fx.rule)) }),
204
+ run: (fx, deps) =>
205
+ lint.judgeCandidate(
206
+ { rule: byId.get(fx.rule), file: fx.file, hunk: fx.hunk },
207
+ { ask: deps.ask, threshold: lint.thresholdFor(deps.config.rails.lint, fx.rule) }
208
+ ),
209
+ regex: null, // the selector only picks candidates; it has no verdict of its own to compare with
210
+ predicted: (d) => (d.outcome === 'raise' ? true : d.outcome === 'clear' ? false : null),
211
+ expected: (fx) => fx.label,
212
+ // Decided = raised or cleared at THIS rail's threshold, not intent's fixed margin.
213
+ decided: (d) => d.outcome === 'raise' || d.outcome === 'clear',
214
+ summary: (d) => ({ outcome: d.outcome, p: d.p }),
215
+ };
216
+ }
164
217
  return rails;
165
218
  }
166
219
 
@@ -215,6 +268,13 @@ export async function evaluate({
215
268
  const tally = { n: cases.length, jevRight: 0, regexRight: 0, disagreements: 0, families: {} };
216
269
  if (!rail.regex) Object.assign(tally, { regexRight: null, disagreements: null, decided: 0, decidedRight: 0 });
217
270
  for (const fx of cases) {
271
+ const skip = rail.skip?.(fx);
272
+ if (skip) {
273
+ tally.n--;
274
+ tally.skipped = (tally.skipped ?? 0) + 1;
275
+ tally.skipWhy = skip;
276
+ continue;
277
+ }
218
278
  const sink = { answers: {}, model: null, errors: [] };
219
279
  const deps = {
220
280
  config: cfg,
@@ -230,6 +290,11 @@ export async function evaluate({
230
290
  );
231
291
  continue;
232
292
  }
293
+ const stale = live ? null : rail.stale?.(fx);
294
+ if (stale) {
295
+ failures.push(`${name}/${fx.id}: ${stale}`);
296
+ continue;
297
+ }
233
298
  const decision = await rail.run(fx, deps);
234
299
  const summary = rail.summary(decision);
235
300
  if (live && sink.errors.length) {
@@ -242,7 +307,7 @@ export async function evaluate({
242
307
  if (live) {
243
308
  // A draft the judge needed no answers for (e.g. a heading-only unit, which is never asked about) is
244
309
  // recorded as answered by the pinned model with no answers — replaying it asks nothing.
245
- fx.recorded = { model: sink.model ?? cfg.model, answers: sink.answers };
310
+ fx.recorded = { model: sink.model ?? cfg.model, answers: sink.answers, ...(rail.recordExtra?.(fx) ?? {}) };
246
311
  fx.decision = summary;
247
312
  } else if (!same(summary, fx.decision)) {
248
313
  failures.push(
@@ -253,7 +318,10 @@ export async function evaluate({
253
318
  const jevRight = same(rail.predicted(decision), expected);
254
319
  tally.jevRight += jevRight;
255
320
  if (!rail.regex) {
256
- if (typeof decision.p === 'number' && Math.abs(decision.p - 0.5) >= DECIDED_MARGIN) {
321
+ const decided = rail.decided
322
+ ? rail.decided(decision)
323
+ : typeof decision.p === 'number' && Math.abs(decision.p - 0.5) >= DECIDED_MARGIN;
324
+ if (decided) {
257
325
  tally.decided++;
258
326
  tally.decidedRight += jevRight;
259
327
  }
@@ -283,7 +351,8 @@ export function formatReport(report) {
283
351
  for (const [rail, t] of Object.entries(report)) {
284
352
  if (t.regexRight === null) {
285
353
  lines.push(
286
- `${rail}: ${t.n} labelled · jev ${pct(t.jevRight, t.n)} · decided ${t.decided}/${t.n}, ${t.decidedRight} right · no deterministic rule`
354
+ `${rail}: ${t.n} labelled · jev ${pct(t.jevRight, t.n)} · decided ${t.decided}/${t.n}, ${t.decidedRight} right · no deterministic rule` +
355
+ (t.skipped ? ` · ${t.skipped} skipped (${t.skipWhy})` : '')
287
356
  );
288
357
  continue;
289
358
  }
@@ -378,14 +447,22 @@ export async function run(argv, io = {}) {
378
447
  async function main() {
379
448
  const root = repoRoot();
380
449
  const { askJev } = await import('./lib/jev.mjs');
450
+ const { parseLintRules } = await import('./semantic-lint.mjs');
381
451
  const code = await run(process.argv.slice(2), {
382
452
  root,
383
453
  config: loadJevConfig({ root }),
384
- fixtures: JSON.parse(readFileSync(FIXTURES_PATH, 'utf8')),
385
- rails: await loadRails(),
454
+ fixtures: {
455
+ ...JSON.parse(readFileSync(FIXTURES_PATH, 'utf8')),
456
+ lint: existsSync(LINT_FIXTURES_PATH) ? JSON.parse(readFileSync(LINT_FIXTURES_PATH, 'utf8')) : [],
457
+ },
458
+ // A malformed lint section throws here: CI must go red on it, not replay against no rules.
459
+ rails: await loadRails({ lintRules: parseLintRules(readSection('lint', { root }).raw) }),
386
460
  key: () => readApiKey({ root }),
387
461
  makeAsk: ({ key, model }) => (req) => askJev(req, { key, model }),
388
- writeFixtures: (fx) => writeFileSync(FIXTURES_PATH, `${JSON.stringify(fx, null, 2)}\n`),
462
+ writeFixtures: ({ lint, ...shared }) => {
463
+ writeFileSync(FIXTURES_PATH, `${JSON.stringify(shared, null, 2)}\n`);
464
+ if (lint.length) writeFileSync(LINT_FIXTURES_PATH, `${JSON.stringify(lint, null, 2)}\n`);
465
+ },
389
466
  stdout: (t) => process.stdout.write(t),
390
467
  stderr: (t) => process.stderr.write(t),
391
468
  today: new Date().toISOString().slice(0, 10),
@@ -152,6 +152,16 @@ export const REGISTRY = Object.freeze([
152
152
  choices: ['off', 'on'],
153
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
154
  },
155
+ {
156
+ // semantic-lint D4: the rules the lint rail's selectors run (id, globs, allowlist, patterns, question). never-yet,
157
+ // like `routines`: it declares the section so `config get/set` accept it, and `gf doctor` (which skips never-yet
158
+ // rows and prints one line per MODULE) never reports Build unconfigured because a project has no lint rules.
159
+ key: 'lint.rules',
160
+ module: 'Build',
161
+ askWhen: 'never-yet',
162
+ default: null,
163
+ question: 'Which rules should semantic-lint check on every push (selectors plus one question each for Jev)?',
164
+ },
155
165
  {
156
166
  key: 'spend.telemetry',
157
167
  module: 'Spend',
@@ -44,6 +44,7 @@ export const SECTIONS = Object.freeze([
44
44
  'spend',
45
45
  'routines',
46
46
  'intent',
47
+ 'lint',
47
48
  ]);
48
49
 
49
50
  /**
@@ -53,10 +53,10 @@ export const AGENT_BIN = {
53
53
  // Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
54
54
  // `agy models`), but it means a future typo in either constant would silently review with the WRONG model
55
55
  // instead of failing loud — watch for that if either constant is ever edited.
56
- // agy-doctor: last verified 2026-09-29 against 1.2.13.
56
+ // agy-doctor: last verified 2026-09-30 against 1.2.14.
57
57
  // ^ machine-managed marker — `node scripts/cross-agent-doctor.mjs agy --fix` rewrites it (with the constant
58
58
  // below) after a green live contract probe. Don't hand-edit the marker's shape.
59
- export const AGY_PINNED = '1.2.13';
59
+ export const AGY_PINNED = '1.2.14';
60
60
 
61
61
  // agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
62
62
  // nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
package/dist/lib/jev.mjs CHANGED
@@ -34,7 +34,7 @@ import { needSetting, readSection } from './config.mjs';
34
34
  export const JEV_ENDPOINT = 'https://api.typesafe.ai/v1/systemone';
35
35
  export const DEFAULT_MODEL = 'jev-1.13.0';
36
36
  export const MODES = ['off', 'shadow', 'jev'];
37
- export const RAILS = ['review', 'prose'];
37
+ export const RAILS = ['review', 'prose', 'lint'];
38
38
 
39
39
  /** 32k tokens covers state + the longest question. ~4 chars/token, with headroom for the question. */
40
40
  export const STATE_CHAR_BUDGET = 110_000;
@@ -53,11 +53,19 @@ export const DEFAULT_CONFIG = Object.freeze({
53
53
  // thresholds are the MEASURED ones (jev-semantic-guards S5.2, sprint-5.md), not the pitch's starting guesses.
54
54
  review: { mode: 'off', thresholds: { real: 0.85, notReal: 0.3 }, shadowExpires: null },
55
55
  prose: { mode: 'off', thresholds: { claim: 0.8 }, shadowExpires: null },
56
+ // semantic-lint D3: `default` plus one optional key per rule id (see LINT_THRESHOLD_KEY below).
57
+ lint: { mode: 'off', thresholds: { default: 0.8 }, shadowExpires: null },
56
58
  },
57
59
  });
58
60
 
59
61
  export class JevConfigError extends Error {}
60
62
 
63
+ /**
64
+ * The lint rail's threshold keys are OPEN — `default` or any rule id — because rules are data (semantic-lint D3/D4):
65
+ * a new rule must not need a code change here to get its own threshold. Every other rail's keys stay a closed set.
66
+ */
67
+ export const LINT_THRESHOLD_KEY = /^[a-z0-9][a-z0-9-]*$/;
68
+
61
69
  const isUnit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
62
70
 
63
71
  /**
@@ -105,10 +113,21 @@ export function parseJevConfig(json) {
105
113
  throw new JevConfigError(`jev.config.json: rails.${name}.mode must be one of ${MODES.join(' | ')}`);
106
114
  if (r.thresholds !== undefined && !isObj(r.thresholds))
107
115
  throw new JevConfigError(`jev.config.json: rails.${name}.thresholds must be an object`);
108
- refuseUnknown(r.thresholds ?? {}, Object.keys(d.thresholds), `rails.${name}.thresholds`);
116
+ if (name === 'lint') {
117
+ const bad = Object.keys(r.thresholds ?? {}).filter((k) => !LINT_THRESHOLD_KEY.test(k));
118
+ if (bad.length)
119
+ throw new JevConfigError(
120
+ `jev.config.json: rails.lint.thresholds: ${bad.join(', ')} is not \`default\` or a rule id (a-z, 0-9, -)`
121
+ );
122
+ } else refuseUnknown(r.thresholds ?? {}, Object.keys(d.thresholds), `rails.${name}.thresholds`);
109
123
  const thresholds = { ...d.thresholds, ...(r.thresholds ?? {}) };
110
124
  for (const [k, v] of Object.entries(thresholds))
111
125
  if (!isUnit(v)) throw new JevConfigError(`jev.config.json: rails.${name}.thresholds.${k} must be 0…1`);
126
+ // lint raises at p ≥ t and clears at p ≤ 1 − t (semantic-lint D2): below 0.5 those bands overlap, and one
127
+ // answer would be both a finding and a pass.
128
+ if (name === 'lint')
129
+ for (const [k, v] of Object.entries(thresholds))
130
+ if (v < 0.5) throw new JevConfigError(`jev.config.json: rails.lint.thresholds.${k} must be 0.5…1`);
112
131
  if (name === 'review' && thresholds.notReal >= thresholds.real)
113
132
  throw new JevConfigError('jev.config.json: rails.review.thresholds.notReal must be below .real');
114
133
  const shadowExpires = r.shadowExpires ?? null;
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+ // semantic-lint.mjs — deterministic selectors pick candidate hunks, Jev judges only those (semantic-lint D1–D9).
3
+ //
4
+ // node scripts/semantic-lint.mjs the branch against @{upstream}, else origin/main
5
+ // node scripts/semantic-lint.mjs --range <base>...<head> what the pre-push hook passes, once per pushed ref
6
+ //
7
+ // Some rules cannot be a regex: "no parallel telemetry pipeline" (AGENTS rule 1) is broken by a usage table under
8
+ // another name, an analytics route or a vendor SDK call — paraphrases a regex either misses or flags on every read.
9
+ // So the regex only SELECTS (globs + added-line patterns − an allowlist, all data in `lint.rules`), and one Noul per
10
+ // selected hunk asks whether it actually breaks the rule. Most pushes select nothing and make no call at all.
11
+ //
12
+ // Four outcomes, never two (D2): raise, clear, uncertain, and NOT CHECKED — no key, egress off, a timeout, a malformed
13
+ // answer, an oversized hunk, past the candidate cap or the time budget. "Could not look" is printed and logged, and is
14
+ // never counted as clear.
15
+ //
16
+ // Raise-only: this adds findings; it never clears another check's finding, and it never blocks. v1 runs in `shadow`
17
+ // from the advisory pre-push hook: it logs every decision to .jev/decisions.jsonl as `lint:<rule id>` and prints one
18
+ // line per rule. Exit 0 always, except 2 for bad usage or a malformed config — a lint that cannot run must say so, and
19
+ // the hook still ignores the code.
20
+ //
21
+ // Zero deps — Node 18+.
22
+
23
+ import { execFileSync } from 'node:child_process';
24
+ import { resolve } from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+ import { readSection } from './lib/config.mjs';
27
+ import { jevContext, LINT_THRESHOLD_KEY, loadJevConfig, repoRoot, textHash } from './lib/jev.mjs';
28
+ import { globToRegExp } from './lib/review-guard.mjs';
29
+
30
+ export const QUESTION_ID = 'violates';
31
+ export const SEVERITIES = ['blocking', 'should-fix', 'nit'];
32
+ /** A hunk this long is not sent (D2/D6): it is rarely one decision, and it is the most code to send off the machine. */
33
+ export const HUNK_CHAR_LIMIT = 6_000;
34
+ /** A push selecting more than this is a refactor, not a telemetry change; the rest are not checked, and say so. */
35
+ export const MAX_CANDIDATES = 20;
36
+ /** A git hook must stay a hook: nothing new starts after this, and what is left is not checked. */
37
+ export const TIME_BUDGET_MS = 60_000;
38
+ const CONCURRENCY = 4;
39
+
40
+ export class LintConfigError extends Error {}
41
+
42
+ const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
43
+ const isUnit = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0 && n <= 1;
44
+ const RULE_KEYS = ['id', 'source', 'severity', 'globs', 'allowlist', 'patterns', 'question'];
45
+
46
+ /**
47
+ * The `lint` section → rules with their selectors compiled. Pure; throws LintConfigError naming the problem, because a
48
+ * typo in a glob or a pattern would otherwise select nothing and read as a clean push (CODE-QUALITY §7).
49
+ * `null` (no section anywhere) is no rules.
50
+ */
51
+ export function parseLintRules(raw) {
52
+ if (raw === null || raw === undefined) return [];
53
+ const fail = (m) => {
54
+ throw new LintConfigError(`lint: ${m}`);
55
+ };
56
+ if (!isObj(raw)) fail('the section must be an object');
57
+ const extra = Object.keys(raw).filter((k) => k !== 'rules' && !k.startsWith('$'));
58
+ if (extra.length) fail(`unknown key(s): ${extra.join(', ')}`);
59
+ if (raw.rules === undefined) return [];
60
+ if (!Array.isArray(raw.rules)) fail('rules must be an array');
61
+ const seen = new Set();
62
+ const strings = (v, where, { nonEmpty }) => {
63
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string' || !x))
64
+ fail(`${where} must be an array of strings`);
65
+ if (nonEmpty && !v.length) fail(`${where} must not be empty`);
66
+ return v;
67
+ };
68
+ return raw.rules.map((r, i) => {
69
+ if (!isObj(r)) fail(`rules[${i}] must be an object`);
70
+ const at = `rules[${i}]${typeof r.id === 'string' ? ` (${r.id})` : ''}`;
71
+ const unknown = Object.keys(r).filter((k) => !RULE_KEYS.includes(k) && !k.startsWith('$'));
72
+ if (unknown.length) fail(`${at}: unknown key(s): ${unknown.join(', ')}`);
73
+ // The id is also a threshold key in jev.config.json (rails.lint.thresholds.<id>), so it obeys that key's shape.
74
+ if (typeof r.id !== 'string' || !LINT_THRESHOLD_KEY.test(r.id) || r.id === 'default')
75
+ fail(`${at}: id must be lowercase a-z, 0-9 and -, and not "default"`);
76
+ if (seen.has(r.id)) fail(`${at}: duplicate id`);
77
+ seen.add(r.id);
78
+ if (typeof r.source !== 'string' || !r.source) fail(`${at}: source must name the rule it enforces`);
79
+ if (!SEVERITIES.includes(r.severity)) fail(`${at}: severity must be one of ${SEVERITIES.join(' | ')}`);
80
+ const globs = strings(r.globs, `${at}.globs`, { nonEmpty: true });
81
+ const allowlist = strings(r.allowlist ?? [], `${at}.allowlist`, { nonEmpty: false });
82
+ const patterns = strings(r.patterns, `${at}.patterns`, { nonEmpty: true }).map((p) => {
83
+ try {
84
+ return new RegExp(p, 'i');
85
+ } catch (e) {
86
+ return fail(`${at}.patterns: ${JSON.stringify(p)} is not a valid RegExp (${e.message})`);
87
+ }
88
+ });
89
+ const q = r.question;
90
+ if (
91
+ !isObj(q) ||
92
+ typeof q.instructions !== 'string' ||
93
+ !q.instructions ||
94
+ !isObj(q.criteria) ||
95
+ typeof q.criteria.true !== 'string' ||
96
+ typeof q.criteria.false !== 'string'
97
+ )
98
+ fail(`${at}.question must be { instructions, criteria: { true, false } }, all strings`);
99
+ return {
100
+ id: r.id,
101
+ source: r.source,
102
+ severity: r.severity,
103
+ question: {
104
+ instructions: q.instructions,
105
+ criteria: { true: q.criteria.true, false: q.criteria.false },
106
+ },
107
+ match: { globs: globs.map(globToRegExp), allow: allowlist.map(globToRegExp), patterns },
108
+ };
109
+ });
110
+ }
111
+
112
+ /** The one Noul a rule asks, in the shape askJev sends. */
113
+ export const questionsFor = (rule) => ({ [QUESTION_ID]: { type: 'noul', ...rule.question } });
114
+
115
+ /** Pins a recording to the wording that produced it: an edited question must be re-measured, not replayed (D8). */
116
+ export const questionHash = (rule) =>
117
+ // `source` is sent in the state beside the question, so it is part of what was measured (fresh review of #200).
118
+ textHash(JSON.stringify({ questions: questionsFor(rule), source: rule.source }));
119
+
120
+ /**
121
+ * `git diff` text → [{ file, hunks: [{ header, lines: [diff line], added: [line] }] }]. Pure. `lines` keep their +/-/space
122
+ * prefix, as the diff shows them.
123
+ */
124
+ export function parseDiff(diffText) {
125
+ const files = [];
126
+ let file = null;
127
+ let hunk = null;
128
+ for (const line of String(diffText).split('\n')) {
129
+ if (line.startsWith('diff --git ')) {
130
+ file = null;
131
+ hunk = null;
132
+ continue;
133
+ }
134
+ // Only a header before the first `@@` is a file header: inside a hunk, an added line whose content starts with
135
+ // `++ ` also reads `+++ …`, and taking it for a new file would drop the rest of the hunk.
136
+ if (!hunk && line.startsWith('+++ ')) {
137
+ // `+++ /dev/null` is a deletion: it adds nothing, so it selects nothing.
138
+ const path = line.slice(4).replace(/\t.*$/, '');
139
+ file = path === '/dev/null' ? null : { file: path.replace(/^b\//, ''), hunks: [] };
140
+ if (file) files.push(file);
141
+ hunk = null;
142
+ continue;
143
+ }
144
+ if (!file) continue;
145
+ if (line.startsWith('@@')) {
146
+ hunk = { header: line, lines: [], added: [] };
147
+ file.hunks.push(hunk);
148
+ continue;
149
+ }
150
+ if (!hunk) continue;
151
+ if (line.startsWith('+')) hunk.added.push(line.slice(1));
152
+ if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) hunk.lines.push(line);
153
+ }
154
+ return files;
155
+ }
156
+
157
+ /** Lines of a hunk kept around each matching added line. */
158
+ export const WINDOW = 15;
159
+
160
+ /**
161
+ * What is sent for one candidate (D1/D6): the hunk's `@@` header and a window of WINDOW lines either side of each
162
+ * matching added line, overlapping windows merged, gaps marked `…`. Not the whole hunk: a new file is ONE hunk, and
163
+ * measured on 220 commits of this repo's history, 33 of 60 candidate hunks were over HUNK_CHAR_LIMIT — nearly every
164
+ * new route and migration would have been "not checked". Pure.
165
+ */
166
+ export function candidateText(hunk, isHit) {
167
+ const keep = new Set();
168
+ hunk.lines.forEach((l, i) => {
169
+ if (!l.startsWith('+') || !isHit(l.slice(1))) return;
170
+ for (let j = Math.max(0, i - WINDOW); j <= Math.min(hunk.lines.length - 1, i + WINDOW); j++) keep.add(j);
171
+ });
172
+ const out = [hunk.header];
173
+ let prev = -1;
174
+ for (const i of [...keep].sort((a, b) => a - b)) {
175
+ if (i !== prev + 1) out.push('…');
176
+ out.push(hunk.lines[i]);
177
+ prev = i;
178
+ }
179
+ if (prev < hunk.lines.length - 1) out.push('…');
180
+ return out.join('\n');
181
+ }
182
+
183
+ /**
184
+ * The deterministic half (D1): every hunk, per rule, in a file its globs match and its allowlist does not, with at
185
+ * least one ADDED line a pattern matches. Removed and context lines never select — deleting a vendor call is not a
186
+ * reason to ask. Duplicates (the same hunk reached through two pushed refs) collapse. Pure.
187
+ */
188
+ export function selectCandidates(files, rules) {
189
+ const out = [];
190
+ const seen = new Set();
191
+ for (const rule of rules)
192
+ for (const { file, hunks } of files) {
193
+ if (!rule.match.globs.some((re) => re.test(file))) continue;
194
+ if (rule.match.allow.some((re) => re.test(file))) continue;
195
+ for (const h of hunks) {
196
+ const hits = rule.match.patterns
197
+ .filter((re) => h.added.some((l) => re.test(l)))
198
+ .map((re) => re.source);
199
+ if (!hits.length) continue;
200
+ const text = candidateText(h, (l) => rule.match.patterns.some((re) => re.test(l)));
201
+ const key = `${rule.id}\0${file}\0${textHash(text)}`;
202
+ if (seen.has(key)) continue;
203
+ seen.add(key);
204
+ out.push({ rule, file, hunk: text, hits });
205
+ }
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** D2 — one statistic per rule. Only a probability in [0,1] is a verdict (LEARNINGS: `Number(true)` once passed). */
211
+ export function outcomeOf(p, threshold) {
212
+ if (!isUnit(p)) return null;
213
+ if (p >= threshold) return 'raise';
214
+ // `1 - p >= t`, not `p <= 1 - t`: 1 - 0.8 is 0.19999…, which put p = 0.2 in the uncertain band.
215
+ if (1 - p >= threshold) return 'clear';
216
+ return 'uncertain';
217
+ }
218
+
219
+ /** A rule's threshold: its own key in rails.lint.thresholds, else `default`. */
220
+ export const thresholdFor = (rail, ruleId) => rail.thresholds[ruleId] ?? rail.thresholds.default;
221
+
222
+ /**
223
+ * Ask about one candidate. Never throws (askJev does not, and a non-probability is not-checked).
224
+ * Returns { outcome: raise|clear|uncertain|not-checked, p, error }.
225
+ */
226
+ export async function judgeCandidate(c, { ask, threshold }) {
227
+ const res = await ask({
228
+ state: { rule: c.rule.id, source: c.rule.source, file: c.file, hunk: c.hunk },
229
+ questions: questionsFor(c.rule),
230
+ });
231
+ if (!res?.ok) return { outcome: 'not-checked', p: null, error: res?.error ?? 'no response' };
232
+ const raw = res.answers?.[QUESTION_ID]?.noul;
233
+ const outcome = outcomeOf(raw, threshold);
234
+ if (!outcome) return { outcome: 'not-checked', p: null, error: `invalid noul (${JSON.stringify(raw)})` };
235
+ return { outcome, p: Math.round(raw * 1000) / 1000, error: null };
236
+ }
237
+
238
+ /** The reason a person reads. `no key` is the words the smoke walkthrough and the hook's users look for. */
239
+ const reasonFor = (why) => (why === 'no TYPESAFE_API_KEY' ? 'no key' : why);
240
+
241
+ /**
242
+ * Judge every candidate under the caps and the budget (D2), log each decision (D7). Returns the results in candidate
243
+ * order. `notChecked` (a reason) short-circuits every call: Jev could not legitimately be asked at all.
244
+ */
245
+ export async function judgeAll(
246
+ candidates,
247
+ { ask, log, rail, mode, sha, notChecked = null, now = Date.now, budgetMs = TIME_BUDGET_MS }
248
+ ) {
249
+ const deadline = now() + budgetMs;
250
+ const results = new Array(candidates.length);
251
+ const judgeOne = async (i) => {
252
+ const c = candidates[i];
253
+ let r;
254
+ if (notChecked) r = { outcome: 'not-checked', p: null, error: notChecked };
255
+ else if (i >= MAX_CANDIDATES)
256
+ r = { outcome: 'not-checked', p: null, error: `over the ${MAX_CANDIDATES}-candidate cap` };
257
+ else if (c.hunk.length > HUNK_CHAR_LIMIT)
258
+ r = { outcome: 'not-checked', p: null, error: `hunk over ${HUNK_CHAR_LIMIT} chars (${c.hunk.length})` };
259
+ else if (now() >= deadline)
260
+ r = { outcome: 'not-checked', p: null, error: `time budget (${budgetMs / 1000}s) spent` };
261
+ else r = await judgeCandidate(c, { ask, threshold: thresholdFor(rail, c.rule.id) });
262
+ results[i] = r;
263
+ log({
264
+ rail: `lint:${c.rule.id}`,
265
+ mode,
266
+ decider: r.outcome === 'not-checked' ? 'not-checked' : 'jev',
267
+ regex: null,
268
+ jev: r.outcome === 'not-checked' ? null : r.outcome === 'raise',
269
+ confidence: r.p,
270
+ text: c.hunk,
271
+ source: `${c.file}@${sha ?? 'unknown'}`,
272
+ evidence: { file: c.file, outcome: r.outcome, hits: c.hits },
273
+ error: r.error,
274
+ });
275
+ };
276
+ let next = 0;
277
+ const worker = async () => {
278
+ while (next < candidates.length) await judgeOne(next++);
279
+ };
280
+ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, worker));
281
+ return results;
282
+ }
283
+
284
+ /** D9 — one line per rule, plus one finding line per raise in `jev` mode. Pure. */
285
+ export function summarize(candidates, results, { mode }) {
286
+ const lines = [];
287
+ const byRule = new Map();
288
+ candidates.forEach((c, i) => {
289
+ if (!byRule.has(c.rule.id)) byRule.set(c.rule.id, []);
290
+ byRule.get(c.rule.id).push({ c, r: results[i] });
291
+ });
292
+ for (const [id, rows] of byRule) {
293
+ const of = (o) => rows.filter((x) => x.r.outcome === o);
294
+ const raised = of('raise');
295
+ const nc = of('not-checked');
296
+ if (nc.length === rows.length) {
297
+ const reasons = [...new Set(nc.map((x) => reasonFor(x.r.error)))];
298
+ lines.push(
299
+ `semantic-lint: not checked (${reasons.join('; ')}) — lint:${id}, ${rows.length} candidate(s) logged, none judged`
300
+ );
301
+ continue;
302
+ }
303
+ const parts = [];
304
+ if (raised.length)
305
+ parts.push(
306
+ `${raised.length} ${mode === 'jev' ? 'raised' : 'would raise'} (${raised.map((x) => `p=${x.r.p} ${x.c.file}`).join(', ')})`
307
+ );
308
+ for (const o of ['uncertain', 'clear']) if (of(o).length) parts.push(`${of(o).length} ${o}`);
309
+ if (nc.length)
310
+ parts.push(
311
+ `${nc.length} not checked (${[...new Set(nc.map((x) => reasonFor(x.r.error)))].join('; ')})`
312
+ );
313
+ const tail = mode === 'jev' ? 'logged' : 'logged, not shown as findings';
314
+ lines.push(
315
+ `semantic-lint (${mode}): lint:${id} — ${rows.length} candidate(s): ${parts.join(', ')} · ${tail}`
316
+ );
317
+ if (mode === 'jev')
318
+ for (const { c, r } of raised)
319
+ lines.push(` [${c.rule.severity}] ${c.file}: p=${r.p} — may break ${c.rule.source}`);
320
+ }
321
+ return lines;
322
+ }
323
+
324
+ const USAGE = 'usage: node scripts/semantic-lint.mjs [--range <base>...<head>]…';
325
+
326
+ /** Pure — `--range` values, or { error }. */
327
+ export function parseArgs(argv) {
328
+ const ranges = [];
329
+ for (let i = 0; i < argv.length; i++) {
330
+ if (argv[i] === '--range' && argv[i + 1] && !argv[i + 1].startsWith('--')) ranges.push(argv[++i]);
331
+ else return { error: `unexpected argument ${JSON.stringify(argv[i])}` };
332
+ }
333
+ return { ranges };
334
+ }
335
+
336
+ /**
337
+ * The CLI with every side effect injected (specs never touch git, the network or the real log). Returns the exit code.
338
+ * io: { root, jevConfig(), lintSection(), defaultRange(), diff(range), headSha(range), context(config), stdout, stderr }
339
+ */
340
+ export async function run(argv, io) {
341
+ const args = parseArgs(argv);
342
+ if (args.error) {
343
+ io.stderr(`semantic-lint: ${args.error}\n${USAGE}\n`);
344
+ return 2;
345
+ }
346
+ let config;
347
+ let rules;
348
+ try {
349
+ config = io.jevConfig();
350
+ // Off exits before anything else — before the rules are read and, above all, before jevContext, whose unanswered-
351
+ // egress ask must never fire on a stranger's push for a rail they never turned on (D6).
352
+ if (config.rails.lint.mode === 'off') {
353
+ io.stdout('semantic-lint: off (jev.rails.lint.mode)\n');
354
+ return 0;
355
+ }
356
+ rules = parseLintRules(io.lintSection());
357
+ } catch (e) {
358
+ io.stderr(`semantic-lint: not run — ${e.message}\n`);
359
+ return 2;
360
+ }
361
+ if (!rules.length) {
362
+ io.stdout('semantic-lint: no rules (lint.rules is empty)\n');
363
+ return 0;
364
+ }
365
+ const ranges = args.ranges.length ? args.ranges : [io.defaultRange()];
366
+ let files = [];
367
+ try {
368
+ for (const range of ranges) files = files.concat(parseDiff(io.diff(range)));
369
+ } catch (e) {
370
+ // Could not look is never a pass: say it, even though there is nothing to log per candidate.
371
+ io.stdout(`semantic-lint: not checked (could not read the diff: ${String(e.message).split('\n')[0]})\n`);
372
+ return 0;
373
+ }
374
+ const candidates = selectCandidates(files, rules);
375
+ const mode = config.rails.lint.mode;
376
+ if (!candidates.length) {
377
+ io.stdout(`semantic-lint (${mode}): nothing to judge — no added line matched a rule's selector\n`);
378
+ return 0;
379
+ }
380
+ const ctx = io.context(config);
381
+ const results = await judgeAll(candidates, {
382
+ ask: ctx.ask,
383
+ log: ctx.log,
384
+ rail: config.rails.lint,
385
+ mode,
386
+ sha: io.headSha(ranges[ranges.length - 1]),
387
+ notChecked: ctx.mode === 'off' ? ctx.why : null,
388
+ });
389
+ for (const line of summarize(candidates, results, { mode })) io.stdout(`${line}\n`);
390
+ return 0;
391
+ }
392
+
393
+ const git = (root, args) =>
394
+ execFileSync('git', args, {
395
+ cwd: root,
396
+ encoding: 'utf8',
397
+ maxBuffer: 64 * 1024 * 1024,
398
+ stdio: ['ignore', 'pipe', 'pipe'],
399
+ });
400
+
401
+ async function main() {
402
+ const root = repoRoot();
403
+ const code = await run(process.argv.slice(2), {
404
+ jevConfig: () => loadJevConfig({ root }),
405
+ lintSection: () => readSection('lint', { root }).raw,
406
+ defaultRange: () => {
407
+ try {
408
+ git(root, ['rev-parse', '--verify', '--quiet', '@{upstream}']);
409
+ return '@{upstream}...HEAD';
410
+ } catch {
411
+ return 'origin/main...HEAD';
412
+ }
413
+ },
414
+ diff: (range) =>
415
+ // Pinned, not inherited from the person's git config: the default core.quotePath turns a non-ASCII path into
416
+ // "b/…\303\261.ts", and a custom diff.dstPrefix changes `b/` — either one made a real candidate match no glob
417
+ // and read as "nothing to judge" (fresh review of #200).
418
+ git(root, [
419
+ '-c',
420
+ 'core.quotePath=false',
421
+ 'diff',
422
+ '--src-prefix=a/',
423
+ '--dst-prefix=b/',
424
+ '-U3',
425
+ '--no-color',
426
+ '--no-ext-diff',
427
+ '--diff-filter=d',
428
+ range,
429
+ ]),
430
+ headSha: (range) => {
431
+ try {
432
+ return git(root, ['rev-parse', '--short=12', range.split('...').pop() || 'HEAD']).trim();
433
+ } catch {
434
+ return null;
435
+ }
436
+ },
437
+ context: (config) => jevContext('lint', { root, config }),
438
+ stdout: (t) => process.stdout.write(t),
439
+ stderr: (t) => process.stderr.write(t),
440
+ });
441
+ process.exitCode = code;
442
+ }
443
+
444
+ const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
445
+ if (isMain)
446
+ main().catch((e) => {
447
+ // A hook caller ignores the code, but a person running it by hand must see a crash as one.
448
+ process.stderr.write(`semantic-lint: ${e?.stack || e}\n`);
449
+ process.exitCode = 2;
450
+ });
@@ -159,6 +159,9 @@ one-liner + why + date shape.
159
159
  non-state-mutating and lock that with a test.
160
160
 
161
161
  ## Working efficiently
162
- - **Running a whole multi-sprint epic in one session is the main context-cost driver.** The durable
163
- state (the plan file, sprint docs, team memory) makes re-entry cheap by design — compact at each
164
- sprint/PR boundary, and for big epics consider a fresh session per sprint.
162
+ - **Session length is set by a measured line, not a stamina rule.** A whole epic in one session is the
163
+ main context-cost driver, and the durable state (the plan file, sprint docs, team memory) makes
164
+ re-entry cheap by design — so checkpoint or hand off when the budget line says so (its thresholds
165
+ live in one table, `THRESHOLDS` in groom's `session-budget.mjs`), not per sprint. Groom's twin rule is "one deep ask
166
+ per approval gate". *(Sharpened 2026-09-30, session-budget: "fresh session per sprint" was set for
167
+ earlier models.)*
@@ -224,8 +224,10 @@ derived views. **`Roadmap/bets/`** holds one file per wave; **`tasks/`** is the
224
224
  degrade gracefully.
225
225
  - **Worker death is a normal case.** Each builder on its own worktree; a killed worker's uncommitted tree
226
226
  is evidence, not garbage; **verify by re-deriving repo state, never by trusting a completion report** —
227
- a rate-limited subagent still returns a plausible-sounding result. Compact at sprint/PR boundaries; for a
228
- big epic consider a fresh session per sprint.
227
+ a rate-limited subagent still returns a plausible-sounding result. Checkpoint and hand
228
+ off when the budget line says so (Claude Code: under the prompt; groom: at each approval gate) —
229
+ not by sprint count. Where no line shows (function hooks off, or no figure yet), compact at
230
+ sprint/PR boundaries.
229
231
  - Commit messages end with the `Co-Authored-By: Claude` trailer.
230
232
  - **Language.** Docs, comments and PR descriptions are in **English**. **TEMPLATE FILL-IN:** if your user-facing copy has its own policy (a default plus a bilingual allow-list), state it here and in `AGENTS.md`.
231
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@golden-frijoles/kit",
3
- "version": "0.12.1",
3
+ "version": "0.14.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": {