@golden-frijoles/kit 0.7.0 → 0.8.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.fixtures.json +7871 -0
- package/dist/jev-eval.mjs +369 -0
- package/dist/lib/config-registry.mjs +1 -1
- package/dist/lib/jev.mjs +10 -5
- package/dist/lib/notification-text.mjs +41 -0
- package/dist/lib/slack-text.mjs +21 -0
- package/dist/notify-setup.mjs +317 -0
- package/dist/slack-notify.mjs +154 -0
- package/package.json +1 -1
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// jev-eval.mjs — the labelled eval set for the Jev guards, and the rot guard on shadow mode
|
|
3
|
+
// (jev-semantic-guards S1.4, D8).
|
|
4
|
+
//
|
|
5
|
+
// node scripts/jev-eval.mjs offline (CI): replay every fixture's RECORDED Jev answers
|
|
6
|
+
// through the real judge, assert the decision still matches the
|
|
7
|
+
// recording, and fail if any rail is in shadow past its expiry.
|
|
8
|
+
// node scripts/jev-eval.mjs --live re-ask Jev for every fixture, REWRITE the recordings, and print
|
|
9
|
+
// accuracy against the labels — regex vs Jev, per rail/family.
|
|
10
|
+
// node scripts/jev-eval.mjs --rail prose limit to one rail.
|
|
11
|
+
// node scripts/jev-eval.mjs --live --limit 10
|
|
12
|
+
// the SETUP PROOF: ask Jev for only the first n fixtures of each
|
|
13
|
+
// rail, print per-rail agreement with the labels, and WRITE
|
|
14
|
+
// NOTHING. A partial run must never rewrite the committed
|
|
15
|
+
// recordings (they would then cover n fixtures, not all), so
|
|
16
|
+
// --limit is refused without --live and never touches the file.
|
|
17
|
+
// It still needs `jev.egress` true and TYPESAFE_API_KEY.
|
|
18
|
+
//
|
|
19
|
+
// Why offline replay exists: a model bump, a threshold change or an edit to a judge's decide logic must
|
|
20
|
+
// show up as a red CI run, not as a quietly different verdict on the next PR. Why --live exists: the
|
|
21
|
+
// recordings are only as current as the model that produced them — run it before bumping `model`.
|
|
22
|
+
//
|
|
23
|
+
// The rot guard: a rail in `shadow` past `shadowExpires` fails this script, so shadow cannot quietly become
|
|
24
|
+
// the permanent "regex and Jev both" the product owner ruled out.
|
|
25
|
+
//
|
|
26
|
+
// Zero deps — Node 18+.
|
|
27
|
+
|
|
28
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
29
|
+
import { dirname, join, resolve } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
import { loadJevConfig, parseJevConfig, RAILS, readApiKey, repoRoot } from './lib/jev.mjs';
|
|
32
|
+
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
export const FIXTURES_PATH = join(__dirname, 'jev-eval.fixtures.json');
|
|
35
|
+
|
|
36
|
+
/** Shadow is a short, expiring measurement: at most this many days out, ever. */
|
|
37
|
+
export const MAX_SHADOW_DAYS = 21;
|
|
38
|
+
/** A judge that exists must be proven on at least this many labelled cases (S1.4 acceptance). */
|
|
39
|
+
export const MIN_FIXTURES = 30;
|
|
40
|
+
|
|
41
|
+
const addDays = (ymd, n) =>
|
|
42
|
+
new Date(Date.parse(`${ymd}T00:00:00Z`) + n * 86_400_000).toISOString().slice(0, 10);
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Shadow rails that fail the rot guard. Pure. `today` is the UTC date, YYYY-MM-DD (CI runs in UTC).
|
|
46
|
+
* Past its date is expired; more than MAX_SHADOW_DAYS out is refused too — `2099-01-01` would otherwise
|
|
47
|
+
* be a permanent shadow wearing an expiry date, and the gap only ever shrinks, so the cap is safe.
|
|
48
|
+
*/
|
|
49
|
+
export function expiredShadowRails(config, today) {
|
|
50
|
+
const cap = addDays(today, MAX_SHADOW_DAYS);
|
|
51
|
+
return Object.entries(config.rails)
|
|
52
|
+
.filter(
|
|
53
|
+
([, r]) => r.mode === 'shadow' && r.shadowExpires && (r.shadowExpires < today || r.shadowExpires > cap)
|
|
54
|
+
)
|
|
55
|
+
.map(([name, r]) => ({
|
|
56
|
+
rail: name,
|
|
57
|
+
shadowExpires: r.shadowExpires,
|
|
58
|
+
why: r.shadowExpires < today ? 'past its shadowExpires' : `more than ${MAX_SHADOW_DAYS} days out`,
|
|
59
|
+
}));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Coverage failures: fixtures with no judge to replay them (a renamed judge must not turn CI green), and a
|
|
64
|
+
* judge with fewer than MIN_FIXTURES labelled cases. Pure.
|
|
65
|
+
*/
|
|
66
|
+
export function coverageFailures(fixtures, rails) {
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const name of RAILS) {
|
|
69
|
+
const n = (fixtures[name] ?? []).length;
|
|
70
|
+
if (!rails[name] && n)
|
|
71
|
+
out.push(`${name}: ${n} fixture(s) but no judge to replay them — was the judge renamed?`);
|
|
72
|
+
if (rails[name] && n < MIN_FIXTURES)
|
|
73
|
+
out.push(`${name}: only ${n} labelled fixture(s); a judge needs ≥${MIN_FIXTURES}`);
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** An `ask` that answers ONLY from a recording — a missing answer is could-not-look, i.e. a stale fixture. */
|
|
79
|
+
export function replayAsk(recorded) {
|
|
80
|
+
return async ({ questions }) => {
|
|
81
|
+
const ids = Object.keys(questions);
|
|
82
|
+
const missing = ids.filter((id) => !recorded?.answers?.[id]);
|
|
83
|
+
if (missing.length)
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
state: 'could-not-look',
|
|
87
|
+
error: `no recording for ${missing.slice(0, 3).join(', ')}`,
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
ok: true,
|
|
91
|
+
answers: Object.fromEntries(ids.map((id) => [id, recorded.answers[id]])),
|
|
92
|
+
usage: null,
|
|
93
|
+
model: recorded.model,
|
|
94
|
+
};
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* An `ask` that forwards to live Jev and captures every answer, so the recording can be rewritten. A
|
|
100
|
+
* could-not-look is RECORDED as a failure: the judge would fall back to the regex, and scoring that as
|
|
101
|
+
* Jev's answer — or baking it into the recording — would corrupt the flip gate (fresh review, PR #34).
|
|
102
|
+
*/
|
|
103
|
+
function recordingAsk(ask, sink) {
|
|
104
|
+
return async (req) => {
|
|
105
|
+
const r = await ask(req);
|
|
106
|
+
if (r.ok) {
|
|
107
|
+
Object.assign(sink.answers, r.answers);
|
|
108
|
+
sink.model = r.model;
|
|
109
|
+
} else sink.errors.push(r.error);
|
|
110
|
+
return r;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const SEMANTIC_CODES = [
|
|
115
|
+
'unsupported-fix-claim',
|
|
116
|
+
'invented-beneficiary',
|
|
117
|
+
'flag-state-claim',
|
|
118
|
+
'invented-commitment',
|
|
119
|
+
];
|
|
120
|
+
const sortedCodes = (findings) =>
|
|
121
|
+
[...new Set((findings ?? []).map((f) => f.code).filter((c) => SEMANTIC_CODES.includes(c)))].sort();
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The rails this harness can evaluate. Each judge is looked up by name so a rail whose judge has not
|
|
125
|
+
* landed yet is SKIPPED loudly instead of failing the import.
|
|
126
|
+
*/
|
|
127
|
+
export async function loadRails() {
|
|
128
|
+
const rails = {};
|
|
129
|
+
const review = await import('./lib/review-guard.mjs');
|
|
130
|
+
if (typeof review.judgeReviewOutput === 'function')
|
|
131
|
+
rails.review = {
|
|
132
|
+
run: (fx, deps) => review.judgeReviewOutput(fx.text, {}, deps),
|
|
133
|
+
regex: (fx) => review.assertReviewOutput(fx.text).ok,
|
|
134
|
+
predicted: (d) => d.ok,
|
|
135
|
+
expected: (fx) => fx.label,
|
|
136
|
+
summary: (d) => ({ ok: d.ok, decider: d.decider }),
|
|
137
|
+
};
|
|
138
|
+
const prose = await import('./lib/prose-guard.mjs');
|
|
139
|
+
if (typeof prose.judgeProse === 'function')
|
|
140
|
+
rails.prose = {
|
|
141
|
+
run: (fx, deps) => prose.judgeProse(fx.draft, fx.evidence ?? {}, deps),
|
|
142
|
+
regex: (fx) => sortedCodes(prose.checkProse(fx.draft, fx.evidence ?? {}).findings),
|
|
143
|
+
predicted: (d) => sortedCodes(d.findings),
|
|
144
|
+
expected: (fx) => [...fx.label].sort(),
|
|
145
|
+
summary: (d) => ({ codes: sortedCodes(d.findings), decider: d.decider }),
|
|
146
|
+
};
|
|
147
|
+
return rails;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Jev config for evaluation: the rail forced to `jev` so Jev's answer — not the regex — is what is scored.
|
|
154
|
+
*
|
|
155
|
+
* `egress` is forced to `true` too — for REPLAY, which sends nothing; `evaluate` refuses a live run without a
|
|
156
|
+
* committed `true` — for the same reason `mode` is forced (golden-frijoles-plugin D12,
|
|
157
|
+
* S5.4): this harness measures what Jev WOULD decide against a replayed or live answer, never gated by
|
|
158
|
+
* whether this checkout's OWN `jev.config.json` has answered the egress question yet. `projectRoot()`
|
|
159
|
+
* resolves to `template/` for a script run as `node template/scripts/…` (D2's documented copied-mode
|
|
160
|
+
* root for this repo's own dogfooding), so `template/jev.config.json`'s tri-state `egress` IS this
|
|
161
|
+
* process's live config — and its new default (`null`, unanswered) would otherwise score every fixture
|
|
162
|
+
* as if the rail were off, which is a fact about this repo's own config, not about Jev's accuracy.
|
|
163
|
+
*/
|
|
164
|
+
const evalConfig = (base, rail) =>
|
|
165
|
+
parseJevConfig({
|
|
166
|
+
model: base.model,
|
|
167
|
+
egress: true,
|
|
168
|
+
rails: { ...base.rails, [rail]: { ...base.rails[rail], mode: 'jev', shadowExpires: null } },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Evaluate. Returns { failures, report, fixtures } — pure over its deps apart from the judge calls.
|
|
173
|
+
* live=false replays recordings; live=true re-asks through deps.ask and rewrites them.
|
|
174
|
+
*/
|
|
175
|
+
export async function evaluate({
|
|
176
|
+
fixtures,
|
|
177
|
+
rails,
|
|
178
|
+
config,
|
|
179
|
+
live = false,
|
|
180
|
+
ask = null,
|
|
181
|
+
only = null,
|
|
182
|
+
limit = null,
|
|
183
|
+
}) {
|
|
184
|
+
// Replay forces egress on (evalConfig) because it sends nothing. LIVE sends every fixture to TypeSafe, so it needs
|
|
185
|
+
// the project's explicit yes: `egress: true`, never null (unanswered) or false (D12; cross-review of #50 — the
|
|
186
|
+
// CLI refused this, but a caller of this export did not).
|
|
187
|
+
if (live && config?.egress !== true)
|
|
188
|
+
throw new Error(
|
|
189
|
+
`jev-eval live: egress is ${JSON.stringify(config?.egress ?? null)}, not true — refusing to send fixtures to Jev.`
|
|
190
|
+
);
|
|
191
|
+
const failures = [];
|
|
192
|
+
const report = {};
|
|
193
|
+
for (const [name, rail] of Object.entries(rails)) {
|
|
194
|
+
if (only && only !== name) continue;
|
|
195
|
+
const cases = limit == null ? (fixtures[name] ?? []) : (fixtures[name] ?? []).slice(0, limit);
|
|
196
|
+
const cfg = evalConfig(config, name);
|
|
197
|
+
const tally = { n: cases.length, jevRight: 0, regexRight: 0, disagreements: 0, families: {} };
|
|
198
|
+
for (const fx of cases) {
|
|
199
|
+
const sink = { answers: {}, model: null, errors: [] };
|
|
200
|
+
const deps = {
|
|
201
|
+
config: cfg,
|
|
202
|
+
key: 'eval',
|
|
203
|
+
log: () => {},
|
|
204
|
+
ask: live ? recordingAsk(ask, sink) : replayAsk(fx.recorded),
|
|
205
|
+
};
|
|
206
|
+
// A recording answers for the model that produced it. Replaying it under a bumped `model` would pass
|
|
207
|
+
// while proving nothing about the new one (codex, PR #34): re-record with --live first.
|
|
208
|
+
if (!live && fx.recorded?.model !== config.model) {
|
|
209
|
+
failures.push(
|
|
210
|
+
`${name}/${fx.id}: recorded by ${fx.recorded?.model ?? 'nothing'}, config pins ${config.model} — run --live`
|
|
211
|
+
);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const decision = await rail.run(fx, deps);
|
|
215
|
+
const summary = rail.summary(decision);
|
|
216
|
+
if (live && sink.errors.length) {
|
|
217
|
+
failures.push(
|
|
218
|
+
`${name}/${fx.id}: jev could not look (${sink.errors[0]}) — not scored, recording kept`
|
|
219
|
+
);
|
|
220
|
+
tally.n--;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
if (live) {
|
|
224
|
+
// A draft the judge needed no answers for (e.g. a heading-only unit, which is never asked about) is
|
|
225
|
+
// recorded as answered by the pinned model with no answers — replaying it asks nothing.
|
|
226
|
+
fx.recorded = { model: sink.model ?? cfg.model, answers: sink.answers };
|
|
227
|
+
fx.decision = summary;
|
|
228
|
+
} else if (!same(summary, fx.decision)) {
|
|
229
|
+
failures.push(
|
|
230
|
+
`${name}/${fx.id}: replay gave ${JSON.stringify(summary)}, recorded ${JSON.stringify(fx.decision)}`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const expected = rail.expected(fx);
|
|
234
|
+
const jevRight = same(rail.predicted(decision), expected);
|
|
235
|
+
const regexRight = same(rail.regex(fx), expected);
|
|
236
|
+
tally.jevRight += jevRight;
|
|
237
|
+
tally.regexRight += regexRight;
|
|
238
|
+
tally.disagreements += !same(rail.predicted(decision), rail.regex(fx));
|
|
239
|
+
if (name === 'prose')
|
|
240
|
+
for (const code of SEMANTIC_CODES) {
|
|
241
|
+
const f = (tally.families[code] ??= { jevRight: 0, regexRight: 0, n: 0 });
|
|
242
|
+
const want = expected.includes(code);
|
|
243
|
+
f.n++;
|
|
244
|
+
f.jevRight += rail.predicted(decision).includes(code) === want;
|
|
245
|
+
f.regexRight += rail.regex(fx).includes(code) === want;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
report[name] = tally;
|
|
249
|
+
}
|
|
250
|
+
return { failures, report, fixtures };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const pct = (a, n) => (n ? `${((100 * a) / n).toFixed(1)}%` : 'n/a');
|
|
254
|
+
|
|
255
|
+
export function formatReport(report) {
|
|
256
|
+
const lines = [];
|
|
257
|
+
for (const [rail, t] of Object.entries(report)) {
|
|
258
|
+
lines.push(
|
|
259
|
+
`${rail}: ${t.n} labelled · jev ${pct(t.jevRight, t.n)} · regex ${pct(t.regexRight, t.n)} · ${t.disagreements} disagreement(s)`
|
|
260
|
+
);
|
|
261
|
+
for (const [code, f] of Object.entries(t.families))
|
|
262
|
+
lines.push(` ${code.padEnd(24)} jev ${pct(f.jevRight, f.n)} · regex ${pct(f.regexRight, f.n)}`);
|
|
263
|
+
}
|
|
264
|
+
return lines.join('\n');
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The one line that refuses a live run, or null when it may go. Egress first, and the key is not even read until the yes is in. */
|
|
268
|
+
export function liveRefusal(config, getKey) {
|
|
269
|
+
if (config.egress !== true)
|
|
270
|
+
// false: no text leaves this machine; null: nobody has said yes yet (D12). `--live` sends fixtures.
|
|
271
|
+
return `jev-eval --live: jev.egress is ${JSON.stringify(config.egress)}, not true — refusing to send fixtures to Jev. Say yes with \`gf-kit config set jev.egress true\` first.`;
|
|
272
|
+
if (!getKey())
|
|
273
|
+
return 'jev-eval --live needs TYPESAFE_API_KEY: put TYPESAFE_API_KEY=… in .env.local (or the environment).';
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Pure — `--limit <n>` as a positive integer, null when absent, or { error } when malformed. */
|
|
278
|
+
export function parseLimit(argv) {
|
|
279
|
+
const ix = argv.indexOf('--limit');
|
|
280
|
+
if (ix < 0) return null;
|
|
281
|
+
const n = Number(argv[ix + 1]);
|
|
282
|
+
if (!/^\d+$/.test(argv[ix + 1] ?? '') || n < 1) return { error: '--limit needs a positive whole number' };
|
|
283
|
+
return n;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* The CLI, with every side effect injected so a spec can watch it: returns the exit code. `io` carries
|
|
288
|
+
* { root, config, fixtures, rails, key, makeAsk, writeFixtures, stdout, stderr, today }.
|
|
289
|
+
*/
|
|
290
|
+
export async function run(argv, io = {}) {
|
|
291
|
+
const { config, fixtures, rails, stdout, stderr } = io;
|
|
292
|
+
const live = argv.includes('--live');
|
|
293
|
+
const railIx = argv.indexOf('--rail');
|
|
294
|
+
const only = railIx >= 0 ? argv[railIx + 1] : null;
|
|
295
|
+
if (railIx >= 0 && !RAILS.includes(only)) {
|
|
296
|
+
stderr(`jev-eval: --rail must be one of ${RAILS.join(', ')}\n`);
|
|
297
|
+
return 2;
|
|
298
|
+
}
|
|
299
|
+
const limit = parseLimit(argv);
|
|
300
|
+
if (limit?.error) {
|
|
301
|
+
stderr(`jev-eval: ${limit.error}\n`);
|
|
302
|
+
return 2;
|
|
303
|
+
}
|
|
304
|
+
if (limit != null && !live) {
|
|
305
|
+
stderr('jev-eval: --limit only makes sense with --live (a setup proof against real Jev).\n');
|
|
306
|
+
return 2;
|
|
307
|
+
}
|
|
308
|
+
const expired = expiredShadowRails(config, io.today ?? new Date().toISOString().slice(0, 10));
|
|
309
|
+
for (const name of ['review', 'prose'])
|
|
310
|
+
if (!rails[name]) stdout(`${name}: no judge in this checkout yet — skipped\n`);
|
|
311
|
+
|
|
312
|
+
let ask = null;
|
|
313
|
+
if (live) {
|
|
314
|
+
const refusal = liveRefusal(config, io.key);
|
|
315
|
+
if (refusal) {
|
|
316
|
+
stderr(`${refusal}\n`);
|
|
317
|
+
return 2;
|
|
318
|
+
}
|
|
319
|
+
ask = io.makeAsk({ key: io.key(), model: config.model });
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const { failures, report } = await evaluate({ fixtures, rails, config, live, ask, only, limit });
|
|
323
|
+
// A partial run is not held to the per-rail fixture floor: that floor is about the committed set.
|
|
324
|
+
if (limit == null) failures.push(...coverageFailures(fixtures, rails));
|
|
325
|
+
const n = Object.values(report).reduce((s, t) => s + t.n, 0);
|
|
326
|
+
if (live && limit != null) {
|
|
327
|
+
stdout(
|
|
328
|
+
`live proof: asked Jev for the first ${limit} fixture(s) of each rail (${n} scored) against ${config.model}; ` +
|
|
329
|
+
'nothing written — the committed recordings are untouched.\n'
|
|
330
|
+
);
|
|
331
|
+
} else if (live && failures.length) {
|
|
332
|
+
stderr('jev-eval --live: failures below — the recordings were NOT rewritten.\n');
|
|
333
|
+
} else if (live) {
|
|
334
|
+
io.writeFixtures(fixtures);
|
|
335
|
+
stdout(`live: re-scored ${n} fixtures against ${config.model}; recordings rewritten.\n`);
|
|
336
|
+
} else {
|
|
337
|
+
stdout(`offline: ${n - failures.length}/${n} fixtures match recordings\n`);
|
|
338
|
+
}
|
|
339
|
+
stdout(`${formatReport(report)}\n`);
|
|
340
|
+
for (const f of failures) stderr(`✗ ${f}\n`);
|
|
341
|
+
for (const e of expired)
|
|
342
|
+
stderr(`✗ rails.${e.rail} is in shadow ${e.why} (${e.shadowExpires}) — promote it to jev or set it off.\n`);
|
|
343
|
+
return failures.length || expired.length ? 1 : 0;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function main() {
|
|
347
|
+
const root = repoRoot();
|
|
348
|
+
const { askJev } = await import('./lib/jev.mjs');
|
|
349
|
+
const code = await run(process.argv.slice(2), {
|
|
350
|
+
root,
|
|
351
|
+
config: loadJevConfig({ root }),
|
|
352
|
+
fixtures: JSON.parse(readFileSync(FIXTURES_PATH, 'utf8')),
|
|
353
|
+
rails: await loadRails(),
|
|
354
|
+
key: () => readApiKey({ root }),
|
|
355
|
+
makeAsk: ({ key, model }) => (req) => askJev(req, { key, model }),
|
|
356
|
+
writeFixtures: (fx) => writeFileSync(FIXTURES_PATH, `${JSON.stringify(fx, null, 2)}\n`),
|
|
357
|
+
stdout: (t) => process.stdout.write(t),
|
|
358
|
+
stderr: (t) => process.stderr.write(t),
|
|
359
|
+
today: new Date().toISOString().slice(0, 10),
|
|
360
|
+
});
|
|
361
|
+
if (code) process.exit(code);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const isMain = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
365
|
+
if (isMain)
|
|
366
|
+
main().catch((e) => {
|
|
367
|
+
process.stderr.write(`jev-eval: ${e.message}\n`);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
});
|
|
@@ -116,7 +116,7 @@ export const REGISTRY = Object.freeze([
|
|
|
116
116
|
module: 'Operate',
|
|
117
117
|
askWhen: 'first-report',
|
|
118
118
|
default: null,
|
|
119
|
-
question: 'Where should standups and recaps go
|
|
119
|
+
question: 'Where should standups and recaps go: Telegram (the scheduled reports post there), or print to the terminal? Slack works for test and ad-hoc messages only, not the scheduled reports. Secrets stay in .env.local.',
|
|
120
120
|
},
|
|
121
121
|
{
|
|
122
122
|
key: 'deploy.vercelProject',
|
package/dist/lib/jev.mjs
CHANGED
|
@@ -136,7 +136,10 @@ export function loadJevConfig({ root = repoRoot(), read = readFileSync, exists =
|
|
|
136
136
|
});
|
|
137
137
|
// Absent everywhere → the defaults (every rail off). A PRESENT legacy file holding JSON null is malformed: the
|
|
138
138
|
// parser throws.
|
|
139
|
-
|
|
139
|
+
// No config at all is UNANSWERED, not "yes" (distribute-what-we-use D7). parseJevConfig({}) keeps
|
|
140
|
+
// egress:true for a legacy FILE that never mentions it, but a stranger with no file has told us nothing,
|
|
141
|
+
// and D12's promise is that nothing leaves the machine before an explicit `egress: true`.
|
|
142
|
+
if (!present) return parseJevConfig({ egress: null });
|
|
140
143
|
// D12: a section that never gives egress a non-null value is UNANSWERED, never `true`. readSection treats a
|
|
141
144
|
// `null` in golden-frijoles.config.json as unset, so without this a new-file `egress: null` (or a migrated
|
|
142
145
|
// template config) reached parseJevConfig as a MISSING key and became `true` — sending with nobody's yes
|
|
@@ -147,7 +150,7 @@ export function loadJevConfig({ root = repoRoot(), read = readFileSync, exists =
|
|
|
147
150
|
}
|
|
148
151
|
|
|
149
152
|
/** Parse `KEY=value` lines. Enough for .env.local; quotes stripped. */
|
|
150
|
-
function envFileValue(text, key) {
|
|
153
|
+
export function envFileValue(text, key) {
|
|
151
154
|
for (const line of String(text).split('\n')) {
|
|
152
155
|
const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
|
|
153
156
|
if (m && m[1] === key) return m[2].trim().replace(/^(['"])(.*)\1$/, '$2') || null;
|
|
@@ -183,10 +186,12 @@ export function readApiKey({
|
|
|
183
186
|
*/
|
|
184
187
|
export function effectiveMode(config, rail, { key } = {}) {
|
|
185
188
|
const configured = config.rails[rail].mode;
|
|
186
|
-
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
+
// Unanswered (D12) is decided FIRST, before the rail's mode: every rail defaults to `off`, so checking
|
|
190
|
+
// the mode first made the ask unreachable for exactly the user it was written for (D7). Distinct from a
|
|
191
|
+
// deliberate `false` so the caller can ask once instead of staying silently off forever;
|
|
192
|
+
// `unanswered: true` is jevContext's cue to fire the D11 protocol. Nothing is sent either way.
|
|
189
193
|
if (config.egress === null) return { mode: 'off', configured, why: 'egress not answered', unanswered: true };
|
|
194
|
+
if (configured === 'off') return { mode: 'off', configured, why: 'configured off' };
|
|
190
195
|
if (!config.egress) return { mode: 'off', configured, why: 'egress disabled (jev.egress: false)' };
|
|
191
196
|
if (!key) return { mode: 'off', configured, why: 'no TYPESAFE_API_KEY' };
|
|
192
197
|
return { mode: configured, configured, why: `configured ${configured}` };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// Cross-channel notification text rules.
|
|
2
|
+
//
|
|
3
|
+
// Telegram HTML and Slack mrkdwn reserve the same three characters and require the same byte-for-
|
|
4
|
+
// byte substitutions. Keeping the expansion-aware truncation here makes "fits before escaping"
|
|
5
|
+
// impossible in either channel. Platform modules own only their distinct hard limit and the name
|
|
6
|
+
// their renderer uses for this same operation.
|
|
7
|
+
|
|
8
|
+
/** Escape the three characters reserved by both Telegram HTML and Slack mrkdwn. */
|
|
9
|
+
export function escapeEntities(s) {
|
|
10
|
+
return String(s ?? '')
|
|
11
|
+
.replace(/&/g, '&')
|
|
12
|
+
.replace(/</g, '<')
|
|
13
|
+
.replace(/>/g, '>');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** Truncate raw text on a word boundary. Never call this after entity escaping. */
|
|
17
|
+
export function truncateWords(text, max) {
|
|
18
|
+
const s = String(text ?? '').trim();
|
|
19
|
+
if (s.length <= max) return s;
|
|
20
|
+
const cut = s.slice(0, max - 1);
|
|
21
|
+
const lastSpace = cut.lastIndexOf(' ');
|
|
22
|
+
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Escape raw text while guaranteeing that the escaped result fits `maxEscaped`.
|
|
27
|
+
*
|
|
28
|
+
* Escaping can expand one raw character to five. Truncating before escaping can therefore
|
|
29
|
+
* overshoot, while truncating after escaping can split an entity. Converge on a raw budget,
|
|
30
|
+
* re-measuring the escaped result each pass.
|
|
31
|
+
*/
|
|
32
|
+
export function escapeToFit(text, maxEscaped) {
|
|
33
|
+
let budget = maxEscaped;
|
|
34
|
+
for (let i = 0; i < 24; i++) {
|
|
35
|
+
const escaped = escapeEntities(truncateWords(text, budget));
|
|
36
|
+
if (escaped.length <= maxEscaped) return escaped;
|
|
37
|
+
const ratio = escaped.length / Math.max(1, maxEscaped);
|
|
38
|
+
budget = Math.max(1, Math.floor(budget / Math.max(1.25, ratio)));
|
|
39
|
+
}
|
|
40
|
+
return escapeEntities(truncateWords(text, 40));
|
|
41
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Slack text helpers — platform naming + limit over the shared notification text rule.
|
|
2
|
+
//
|
|
3
|
+
// Slack's own mrkdwn escaping guide asks for the SAME three substitutions Telegram's HTML
|
|
4
|
+
// parse_mode needs: `&` -> `&`, `<` -> `<`, `>` -> `>`. (Slack isn't rendering HTML —
|
|
5
|
+
// it just reserves those three characters for its own `<url|label>` link syntax and treats them
|
|
6
|
+
// the same way HTML does.) Because the escaping rule is identical, the truncate-then-escape
|
|
7
|
+
// convergence logic below is a straight port of escapeToFit's reasoning: escaping EXPANDS the
|
|
8
|
+
// string, so truncating the raw text to a budget and escaping afterwards can overshoot, and
|
|
9
|
+
// escaping first and truncating second can cut mid-entity. Same bug class, same fix.
|
|
10
|
+
//
|
|
11
|
+
// The shared implementation lives in notification-text.mjs because the substitution table is
|
|
12
|
+
// byte-identical. Slack keeps its own conservative limit here; a shared limit would erase a real
|
|
13
|
+
// platform/product decision.
|
|
14
|
+
|
|
15
|
+
// Conservative on purpose: Slack's Incoming Webhook `text` field tolerates far more than this in
|
|
16
|
+
// practice, but Block Kit's per-section text object caps at 3000, and keeping the ping terse and
|
|
17
|
+
// pager-friendly is the actual goal here (matching Telegram's terse-by-design pings), not "as much
|
|
18
|
+
// as Slack will technically accept."
|
|
19
|
+
export const SLACK_LIMIT = 3000;
|
|
20
|
+
|
|
21
|
+
export { escapeEntities as escapeMrkdwn, escapeToFit, truncateWords } from './notification-text.mjs';
|