@event4u/agent-config 7.2.0 → 7.4.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +74 -0
- package/README.md +9 -10
- package/dist/agent-src/commands/fix/pr-comments.md +42 -13
- package/dist/agent-src/rules/cli-output-handling.md +18 -0
- package/dist/agent-src/skills/bug-analyzer/evals/evals.json +13 -0
- package/dist/agent-src/skills/code-review/evals/evals.json +21 -0
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +4 -4
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +3 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/governance-enforcement-projection.md +154 -0
- package/docs/enforcement-by-host.md +44 -0
- package/docs/governance-advantage.md +72 -0
- package/package.json +2 -1
- package/src/config/agent-settings.template.yml +9 -0
- package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
- package/src/scripts/_dispatch.bash +8 -0
- package/src/scripts/_lib/eval_discrimination.ts +95 -0
- package/src/scripts/_lib/token_count.ts +110 -35
- package/src/scripts/_lib/trigger_routers.ts +268 -0
- package/src/scripts/audit_initial_context.ts +37 -12
- package/src/scripts/bench_ab_v2_run.ts +38 -0
- package/src/scripts/bench_quality_run.ts +253 -0
- package/src/scripts/bench_rule_load_latency.ts +178 -0
- package/src/scripts/check_kernel_prefix_stability.ts +0 -0
- package/src/scripts/check_quality_regression.ts +244 -0
- package/src/scripts/check_token_quality_golden.ts +253 -0
- package/src/scripts/check_token_regression.ts +266 -0
- package/src/scripts/cross_model_smoke.ts +245 -0
- package/src/scripts/hook_manifest.yaml +15 -3
- package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
- package/src/scripts/probe_host_compliance.ts +145 -0
- package/src/scripts/run_skill_evals.ts +38 -0
- package/src/scripts/skill_linter.ts +26 -10
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* `agent-config analyze-session` — post-session report from on-disk runtime
|
|
4
|
+
* state.
|
|
5
|
+
*
|
|
6
|
+
* Read-only diagnostic. No daemon, no network, no model calls. It reads two
|
|
7
|
+
* real data sources the work-engine / context-hygiene hook leave behind and
|
|
8
|
+
* prints a deterministic Markdown report:
|
|
9
|
+
*
|
|
10
|
+
* 1. the work_engine state envelope `.work-state.json` (version 1) — loaded
|
|
11
|
+
* via `load_state` from `explain_last/state_loader.ts`. Fields read:
|
|
12
|
+
* - `changes` — files touched (array of {kind, stack, file, summary}).
|
|
13
|
+
* - `outcomes` — directive → "success" / "blocked" / … .
|
|
14
|
+
* - `halts` — array of recorded halts.
|
|
15
|
+
* 2. `agents/runtime/state/context-hygiene.json` — fields read:
|
|
16
|
+
* - `tool_calls` (int)
|
|
17
|
+
* - `consecutive_same_tool`(int)
|
|
18
|
+
* - `loop_detected` (bool)
|
|
19
|
+
*
|
|
20
|
+
* There is NO general token/cost data source in this package (only
|
|
21
|
+
* video-specific telemetry), so the report deliberately does NOT report
|
|
22
|
+
* tokens or cost — it prints a single honest "not tracked" line instead.
|
|
23
|
+
*
|
|
24
|
+
* Exit codes (mirroring `cmd_explain`'s `last` subject):
|
|
25
|
+
* 0 report rendered (even when a source file is absent — graceful note).
|
|
26
|
+
* 1 the work-state file is missing or unreadable.
|
|
27
|
+
* 2 invocation error (bad project root, bad --state-file path).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import * as fs from 'node:fs';
|
|
31
|
+
import * as path from 'node:path';
|
|
32
|
+
import process from 'node:process';
|
|
33
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
34
|
+
|
|
35
|
+
import { ProjectRootError, resolve_project_root } from '../_lib/agent_settings.js';
|
|
36
|
+
import { StateLoadError, load_state } from './explain_last/state_loader.js';
|
|
37
|
+
|
|
38
|
+
type Dict = Record<string, unknown>;
|
|
39
|
+
|
|
40
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
41
|
+
|
|
42
|
+
const CONTEXT_HYGIENE_RELATIVE = path.join(
|
|
43
|
+
'agents',
|
|
44
|
+
'runtime',
|
|
45
|
+
'state',
|
|
46
|
+
'context-hygiene.json',
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** Python `raise SystemExit(code)` analogue — propagates an explicit code. */
|
|
50
|
+
class SystemExitError extends Error {
|
|
51
|
+
constructor(public readonly code: number) {
|
|
52
|
+
super(`system-exit-${code}`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** argparse-style usage / help exit (code 2 / 0). Caught at the CLI entry. */
|
|
57
|
+
class ArgparseExit extends Error {
|
|
58
|
+
constructor(public readonly code: number) {
|
|
59
|
+
super(`argparse-exit-${code}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function eprint(line = ''): void {
|
|
64
|
+
process.stderr.write(line + '\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isPlainObject(value: unknown): value is Dict {
|
|
68
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function _resolve_root(arg: string | null): string {
|
|
72
|
+
try {
|
|
73
|
+
const [root] = resolve_project_root(arg, { cwd: process.cwd() });
|
|
74
|
+
return root;
|
|
75
|
+
} catch (exc) {
|
|
76
|
+
if (exc instanceof ProjectRootError) {
|
|
77
|
+
eprint(`❌ analyze-session: ${exc.message}`);
|
|
78
|
+
throw new SystemExitError(2);
|
|
79
|
+
}
|
|
80
|
+
throw exc;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `Path.relative_to(base)` — returns null when `p` is not under `base`. */
|
|
85
|
+
function relativeToOrNull(p: string, base: string): string | null {
|
|
86
|
+
const rel = path.relative(base, p);
|
|
87
|
+
if (rel === '') return rel;
|
|
88
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return rel;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Read the context-hygiene snapshot. Returns the parsed object, or `null`
|
|
96
|
+
* when the file is absent / unreadable / not an object — the caller renders
|
|
97
|
+
* a graceful "not available" note rather than failing the whole report.
|
|
98
|
+
*/
|
|
99
|
+
function _load_context_hygiene(project_root: string): Dict | null {
|
|
100
|
+
const p = path.join(project_root, CONTEXT_HYGIENE_RELATIVE);
|
|
101
|
+
if (!fs.existsSync(p)) {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
let text: string;
|
|
105
|
+
try {
|
|
106
|
+
text = fs.readFileSync(p, 'utf-8');
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
let payload: unknown;
|
|
111
|
+
try {
|
|
112
|
+
payload = JSON.parse(text);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
return isPlainObject(payload) ? payload : null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Render `value` as an integer string, or `'?'` when not a finite number. */
|
|
120
|
+
function intOrUnknown(value: unknown): string {
|
|
121
|
+
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
122
|
+
return String(Math.trunc(value));
|
|
123
|
+
}
|
|
124
|
+
return '?';
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Build the deterministic Markdown report from already-loaded state.
|
|
129
|
+
*
|
|
130
|
+
* Pure function — no I/O, no clock, no environment. Both inputs come from the
|
|
131
|
+
* caller so the report is unit-testable in isolation.
|
|
132
|
+
*
|
|
133
|
+
* @param state the parsed `.work-state.json` (version 1).
|
|
134
|
+
* @param hygiene the parsed context-hygiene snapshot, or `null` when absent.
|
|
135
|
+
*/
|
|
136
|
+
export function render_report(state: Dict, hygiene: Dict | null): string {
|
|
137
|
+
const lines: string[] = [];
|
|
138
|
+
lines.push('# Session analysis');
|
|
139
|
+
lines.push('');
|
|
140
|
+
|
|
141
|
+
// --- Files touched (from state.changes) ---
|
|
142
|
+
const changes = Array.isArray(state['changes']) ? (state['changes'] as unknown[]) : [];
|
|
143
|
+
lines.push(`## Files touched (${changes.length})`);
|
|
144
|
+
lines.push('');
|
|
145
|
+
if (changes.length === 0) {
|
|
146
|
+
lines.push('_No files recorded as changed._');
|
|
147
|
+
} else {
|
|
148
|
+
for (const change of changes) {
|
|
149
|
+
if (!isPlainObject(change)) continue;
|
|
150
|
+
const file = typeof change['file'] === 'string' ? change['file'] : '(unknown file)';
|
|
151
|
+
const summary =
|
|
152
|
+
typeof change['summary'] === 'string' && change['summary'].length > 0
|
|
153
|
+
? ` — ${change['summary']}`
|
|
154
|
+
: '';
|
|
155
|
+
lines.push(`- \`${file}\`${summary}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
lines.push('');
|
|
159
|
+
|
|
160
|
+
// --- Per-directive outcomes (from state.outcomes) ---
|
|
161
|
+
const outcomes = isPlainObject(state['outcomes']) ? (state['outcomes'] as Dict) : {};
|
|
162
|
+
const directives = Object.keys(outcomes).sort(); // deterministic order
|
|
163
|
+
const blocked = directives.filter((d) => outcomes[d] === 'blocked');
|
|
164
|
+
lines.push(`## Outcomes (${directives.length})`);
|
|
165
|
+
lines.push('');
|
|
166
|
+
if (directives.length === 0) {
|
|
167
|
+
lines.push('_No directive outcomes recorded._');
|
|
168
|
+
} else {
|
|
169
|
+
for (const directive of directives) {
|
|
170
|
+
const result = outcomes[directive];
|
|
171
|
+
const marker = result === 'blocked' ? '⚠️ ' : '';
|
|
172
|
+
lines.push(`- ${marker}\`${directive}\`: ${String(result)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
lines.push('');
|
|
176
|
+
if (blocked.length > 0) {
|
|
177
|
+
lines.push(`**Blocked directives (${blocked.length}):** ${blocked.join(', ')}`);
|
|
178
|
+
lines.push('');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- Halts (from state.halts) ---
|
|
182
|
+
const halts = Array.isArray(state['halts']) ? (state['halts'] as unknown[]) : [];
|
|
183
|
+
lines.push(`## Halts (${halts.length})`);
|
|
184
|
+
lines.push('');
|
|
185
|
+
if (halts.length === 0) {
|
|
186
|
+
lines.push('_No halts recorded._');
|
|
187
|
+
} else {
|
|
188
|
+
lines.push(`This session recorded ${halts.length} halt(s).`);
|
|
189
|
+
}
|
|
190
|
+
lines.push('');
|
|
191
|
+
|
|
192
|
+
// --- Tool activity (from context-hygiene) ---
|
|
193
|
+
lines.push('## Tool activity');
|
|
194
|
+
lines.push('');
|
|
195
|
+
if (hygiene === null) {
|
|
196
|
+
lines.push('_Context-hygiene snapshot not available._');
|
|
197
|
+
} else {
|
|
198
|
+
const toolCalls = intOrUnknown(hygiene['tool_calls']);
|
|
199
|
+
const consecutive = intOrUnknown(hygiene['consecutive_same_tool']);
|
|
200
|
+
const loop = hygiene['loop_detected'] === true;
|
|
201
|
+
lines.push(`- Tool calls: ${toolCalls}`);
|
|
202
|
+
lines.push(`- Consecutive same-tool calls: ${consecutive}`);
|
|
203
|
+
lines.push(`- Loop detected: ${loop ? 'yes' : 'no'}`);
|
|
204
|
+
}
|
|
205
|
+
lines.push('');
|
|
206
|
+
|
|
207
|
+
// --- Honest token/cost note (no per-session source in this package) ---
|
|
208
|
+
lines.push('## Token / cost');
|
|
209
|
+
lines.push('');
|
|
210
|
+
lines.push('Token/cost: not tracked (no per-session source).');
|
|
211
|
+
lines.push('');
|
|
212
|
+
|
|
213
|
+
return lines.join('\n');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function _analyze_session(project_root: string, state_file: string | null): number {
|
|
217
|
+
const target_state = state_file || path.join(project_root, '.work-state.json');
|
|
218
|
+
let state: Dict;
|
|
219
|
+
try {
|
|
220
|
+
state = load_state(target_state) as Dict;
|
|
221
|
+
} catch (exc) {
|
|
222
|
+
if (exc instanceof StateLoadError) {
|
|
223
|
+
// Never print absolute paths in errors (username leakage).
|
|
224
|
+
const rel = relativeToOrNull(target_state, project_root);
|
|
225
|
+
const shown = rel !== null ? rel : path.basename(target_state);
|
|
226
|
+
const msg = exc.message.split(target_state).join(shown);
|
|
227
|
+
eprint(`❌ analyze-session: ${msg}`);
|
|
228
|
+
return exc.exitCode;
|
|
229
|
+
}
|
|
230
|
+
throw exc;
|
|
231
|
+
}
|
|
232
|
+
const hygiene = _load_context_hygiene(project_root);
|
|
233
|
+
process.stdout.write(render_report(state, hygiene));
|
|
234
|
+
return 0;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
interface Opts {
|
|
238
|
+
project: string | null;
|
|
239
|
+
state_file: string | null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const PROG = 'agent-config analyze-session';
|
|
243
|
+
const USAGE = `usage: ${PROG} [-h] [--project PROJECT] [--state-file STATE_FILE]\n`;
|
|
244
|
+
|
|
245
|
+
const _HELP = `\
|
|
246
|
+
usage: ${PROG} [--project PATH] [--state-file PATH]
|
|
247
|
+
|
|
248
|
+
Print a post-session report from on-disk runtime state. Read-only;
|
|
249
|
+
no daemon, no network, no model calls.
|
|
250
|
+
|
|
251
|
+
Sources read:
|
|
252
|
+
.work-state.json (version 1)
|
|
253
|
+
· changes — files touched
|
|
254
|
+
· outcomes — per-directive success / blocked
|
|
255
|
+
· halts — recorded halts
|
|
256
|
+
agents/runtime/state/context-hygiene.json
|
|
257
|
+
· tool_calls · consecutive_same_tool · loop_detected
|
|
258
|
+
|
|
259
|
+
Token/cost is NOT reported — this package has no per-session token
|
|
260
|
+
source (only video-specific telemetry).
|
|
261
|
+
|
|
262
|
+
options:
|
|
263
|
+
--project PATH project root (defaults to anchor walk from cwd)
|
|
264
|
+
--state-file PATH .work-state.json path (default <root>/.work-state.json)
|
|
265
|
+
-h, --help show this help and exit
|
|
266
|
+
|
|
267
|
+
exit codes:
|
|
268
|
+
0 report rendered (a missing source file is noted, not fatal)
|
|
269
|
+
1 no recent run found (state file missing or unreadable)
|
|
270
|
+
2 invocation error (bad project root, bad --state-file path)
|
|
271
|
+
`;
|
|
272
|
+
|
|
273
|
+
function _argError(msg: string): never {
|
|
274
|
+
process.stderr.write(USAGE);
|
|
275
|
+
process.stderr.write(`${PROG}: error: ${msg}\n`);
|
|
276
|
+
throw new ArgparseExit(2);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function _parse(argv: string[]): Opts {
|
|
280
|
+
const opts: Opts = { project: null, state_file: null };
|
|
281
|
+
let i = 0;
|
|
282
|
+
while (i < argv.length) {
|
|
283
|
+
const a = argv[i] as string;
|
|
284
|
+
if (a === '-h' || a === '--help') {
|
|
285
|
+
process.stdout.write(_HELP);
|
|
286
|
+
throw new ArgparseExit(0);
|
|
287
|
+
}
|
|
288
|
+
const eq = a.startsWith('--') ? a.indexOf('=') : -1;
|
|
289
|
+
const flag = eq >= 0 ? a.slice(0, eq) : a;
|
|
290
|
+
const inlineVal = eq >= 0 ? a.slice(eq + 1) : null;
|
|
291
|
+
if (flag === '--project' || flag === '--state-file') {
|
|
292
|
+
const dest = flag === '--project' ? 'project' : 'state_file';
|
|
293
|
+
let value: string;
|
|
294
|
+
if (inlineVal !== null) {
|
|
295
|
+
value = inlineVal;
|
|
296
|
+
} else {
|
|
297
|
+
if (i + 1 >= argv.length) {
|
|
298
|
+
_argError(`argument ${flag}: expected one argument`);
|
|
299
|
+
}
|
|
300
|
+
value = argv[i + 1] as string;
|
|
301
|
+
i += 1;
|
|
302
|
+
}
|
|
303
|
+
opts[dest] = value;
|
|
304
|
+
i += 1;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
_argError(`unrecognized arguments: ${a}`);
|
|
308
|
+
}
|
|
309
|
+
return opts;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
export function main(argv: string[] | null = null): number {
|
|
313
|
+
const argv_list = argv !== null ? Array.from(argv) : process.argv.slice(2);
|
|
314
|
+
const opts = _parse(argv_list);
|
|
315
|
+
let project_root: string;
|
|
316
|
+
try {
|
|
317
|
+
project_root = _resolve_root(opts.project);
|
|
318
|
+
} catch (exc) {
|
|
319
|
+
if (exc instanceof SystemExitError) {
|
|
320
|
+
return exc.code;
|
|
321
|
+
}
|
|
322
|
+
throw exc;
|
|
323
|
+
}
|
|
324
|
+
return _analyze_session(project_root, opts.state_file);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// --- CLI entry ---
|
|
328
|
+
|
|
329
|
+
const _isCliEntry =
|
|
330
|
+
process.argv[1] !== undefined &&
|
|
331
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
332
|
+
if (_isCliEntry || process.argv[1] === _HERE) {
|
|
333
|
+
try {
|
|
334
|
+
process.exitCode = main(process.argv.slice(2));
|
|
335
|
+
} catch (e) {
|
|
336
|
+
if (e instanceof ArgparseExit) {
|
|
337
|
+
process.exitCode = e.code;
|
|
338
|
+
} else if (e instanceof SystemExitError) {
|
|
339
|
+
process.exitCode = e.code;
|
|
340
|
+
} else {
|
|
341
|
+
throw e;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export { _parse, _resolve_root, _load_context_hygiene, _analyze_session, ArgparseExit, SystemExitError };
|
|
347
|
+
export type { Opts };
|
|
@@ -933,6 +933,13 @@ cmd_explain() {
|
|
|
933
933
|
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_explain.ts" "$@"
|
|
934
934
|
}
|
|
935
935
|
|
|
936
|
+
# `agent-config analyze-session` — read-only post-session report from on-disk
|
|
937
|
+
# runtime state (.work-state.json + context-hygiene.json). No daemon, no
|
|
938
|
+
# network, no model calls. See scripts/_cli/cmd_analyze_session.ts.
|
|
939
|
+
cmd_analyze_session() {
|
|
940
|
+
exec_ts "$PACKAGE_ROOT/src/scripts/_cli/cmd_analyze_session.ts" "$@"
|
|
941
|
+
}
|
|
942
|
+
|
|
936
943
|
main() {
|
|
937
944
|
local cmd="${1-}"
|
|
938
945
|
[[ $# -gt 0 ]] && shift || true
|
|
@@ -991,6 +998,7 @@ main() {
|
|
|
991
998
|
doctor) cmd_doctor "$@" ;;
|
|
992
999
|
versions) cmd_versions "$@" ;;
|
|
993
1000
|
explain) cmd_explain "$@" ;;
|
|
1001
|
+
analyze-session) cmd_analyze_session "$@" ;;
|
|
994
1002
|
help|--help|-h|"")
|
|
995
1003
|
# Optional `--tier=0|1|all` filter (default 0).
|
|
996
1004
|
local tier_arg="0"
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* eval_discrimination — the "falsifier of the falsifier" for the cross-model
|
|
3
|
+
* parity smoke (roadmap `road-to-operator-runtime-harvest`, T-003).
|
|
4
|
+
*
|
|
5
|
+
* A green parity result is trustworthy only if the eval *can* go red — and not
|
|
6
|
+
* just on a gross violation, but on a subtle, just-over-threshold one. A gross
|
|
7
|
+
* negative control proves "the harness can fail"; the SUBTLE control proves it
|
|
8
|
+
* is sensitive enough that an "all hosts pass" reading (outcome (a) in T-006)
|
|
9
|
+
* is not the underpowered-null trap wearing a green check. `discrimination_ok`
|
|
10
|
+
* is therefore gated on the SUBTLE control being caught — a gross-only control
|
|
11
|
+
* set is explicitly insufficient.
|
|
12
|
+
*
|
|
13
|
+
* Pure, dependency-free, host-agnostic: the smoke computes each control's
|
|
14
|
+
* `caught` from its assertion results (via `controlCaughtFromAssertions`) and
|
|
15
|
+
* passes the set here. No vendor calls happen in this module.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type ControlSeverity = 'gross' | 'subtle';
|
|
19
|
+
|
|
20
|
+
export interface NegativeControl {
|
|
21
|
+
/** Stable id of the planted-violation control scenario. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** How blatant the planted RDP violation is. */
|
|
24
|
+
severity: ControlSeverity;
|
|
25
|
+
/**
|
|
26
|
+
* Did the harness flag this control (grade it RED)? A control is "caught"
|
|
27
|
+
* when NOT all of its assertions passed — i.e. the harness correctly
|
|
28
|
+
* detected the planted violation. Caught is the *desired* outcome.
|
|
29
|
+
*/
|
|
30
|
+
caught: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DiscriminationResult {
|
|
34
|
+
/**
|
|
35
|
+
* True only when the harness caught the SUBTLE control (and a gross one
|
|
36
|
+
* exists and was also caught). This is the strict gate the parity smoke
|
|
37
|
+
* checks before trusting an "all hosts pass" reading.
|
|
38
|
+
*/
|
|
39
|
+
discrimination_ok: boolean;
|
|
40
|
+
gross_caught: boolean;
|
|
41
|
+
subtle_caught: boolean;
|
|
42
|
+
/** Human-readable reason, surfaced in the smoke's emitted summary. */
|
|
43
|
+
reason: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A control is "caught" iff at least one of its assertions explicitly failed.
|
|
48
|
+
* `pass: null` (e.g. an ungraded `rubric` assertion deferred to a sub-agent
|
|
49
|
+
* grader) is NOT a fail — an un-graded assertion cannot count as catching a
|
|
50
|
+
* violation, so only explicit `false` qualifies.
|
|
51
|
+
*/
|
|
52
|
+
export function controlCaughtFromAssertions(
|
|
53
|
+
assertionResults: ReadonlyArray<{ pass: boolean | null }>,
|
|
54
|
+
): boolean {
|
|
55
|
+
return assertionResults.some((r) => r.pass === false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Decide whether the eval discriminates well enough to trust a green reading.
|
|
60
|
+
* Requires at least one `gross` AND one `subtle` control; gates on the subtle.
|
|
61
|
+
*/
|
|
62
|
+
export function computeDiscrimination(
|
|
63
|
+
controls: ReadonlyArray<NegativeControl>,
|
|
64
|
+
): DiscriminationResult {
|
|
65
|
+
const gross = controls.filter((c) => c.severity === 'gross');
|
|
66
|
+
const subtle = controls.filter((c) => c.severity === 'subtle');
|
|
67
|
+
|
|
68
|
+
const gross_caught = gross.length > 0 && gross.every((c) => c.caught);
|
|
69
|
+
const subtle_caught = subtle.length > 0 && subtle.every((c) => c.caught);
|
|
70
|
+
|
|
71
|
+
let discrimination_ok = false;
|
|
72
|
+
let reason: string;
|
|
73
|
+
|
|
74
|
+
if (gross.length === 0 || subtle.length === 0) {
|
|
75
|
+
reason =
|
|
76
|
+
'GRADED control set incomplete: need at least one `gross` and one `subtle` ' +
|
|
77
|
+
'negative control. A single severity cannot establish sensitivity — outcome (a) stays invalid.';
|
|
78
|
+
} else if (!gross_caught) {
|
|
79
|
+
reason =
|
|
80
|
+
'Harness is blind to a GROSS violation — it cannot fail at all. ' +
|
|
81
|
+
'Any "all hosts pass" reading is meaningless.';
|
|
82
|
+
} else if (!subtle_caught) {
|
|
83
|
+
reason =
|
|
84
|
+
'Underpowered: the gross control was caught but the SUBTLE one slipped through. ' +
|
|
85
|
+
'This is the uninformative-null trap with a green check in front — outcome (a) is ' +
|
|
86
|
+
'INVALID until the subtle control is caught.';
|
|
87
|
+
} else {
|
|
88
|
+
discrimination_ok = true;
|
|
89
|
+
reason =
|
|
90
|
+
'Both gross and subtle negative controls were caught — the eval discriminates; ' +
|
|
91
|
+
'an "all hosts pass" reading is trustworthy.';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return { discrimination_ok, gross_caught, subtle_caught, reason };
|
|
95
|
+
}
|
|
@@ -1,41 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Real-tokenizer counting for the budget tooling
|
|
2
|
+
* Real-tokenizer counting for the budget tooling
|
|
3
|
+
* (roadmap 0B.1 → token-saving Phase 0, "real tokenizer into the bench path").
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* `char != token`. Every budget in this suite is historically in characters;
|
|
6
|
+
* this helper reports a token count *alongside* chars so chars stay the cheap
|
|
7
|
+
* proxy and tokens become the truth where a real tokenizer is available.
|
|
7
8
|
*
|
|
8
|
-
*
|
|
9
|
-
* characters; this helper adds a token count *alongside* chars so chars
|
|
10
|
-
* stay the cheap proxy and tokens become the truth where a real
|
|
11
|
-
* tokenizer is available.
|
|
9
|
+
* Design — optional dependency, graceful proxy fallback, **synchronous** API:
|
|
12
10
|
*
|
|
13
|
-
*
|
|
11
|
+
* - **GPT** — uses `js-tiktoken` (encoding `cl100k_base`) when the dependency
|
|
12
|
+
* is present, producing EXACT BPE counts flagged `exact: true`. `js-tiktoken`
|
|
13
|
+
* is a build/measurement-time **devDependency** (consumed by the bench /
|
|
14
|
+
* audit tooling, never shipped to a consumer runtime). When it is absent —
|
|
15
|
+
* e.g. a consumer that installed without devDependencies — the module falls
|
|
16
|
+
* back to the documented `chars / 4` proxy flagged `exact: false`. The
|
|
17
|
+
* optional load is a synchronous `createRequire(...)` inside a try/catch so
|
|
18
|
+
* the public `measure()` / `gpt_tokens()` API stays synchronous and never
|
|
19
|
+
* hard-fails on a missing dependency (no mandatory install, no network).
|
|
20
|
+
* - **Claude** — no offline Claude tokenizer exists; documented `chars / 3.6`
|
|
21
|
+
* proxy, always `exact: false`. (Anthropic's exact count is API-only.)
|
|
14
22
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* `chars / 4` proxy applies, flagged `exact: false`
|
|
18
|
-
* (`TIKTOKEN_AVAILABLE` is always `false` here — matching the
|
|
19
|
-
* Python module's behaviour when tiktoken is absent).
|
|
20
|
-
* - **Claude** — no offline tokenizer; documented `chars / 3.6` proxy
|
|
21
|
-
* flagged `exact: false`.
|
|
23
|
+
* Both proxies are intentionally conservative ratios drawn from English-prose +
|
|
24
|
+
* markdown samples; they are estimates, never gates.
|
|
22
25
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
26
|
+
* The `*_proxy` helpers (`gpt_tokens_proxy`, `measure_proxy`,
|
|
27
|
+
* `method_note_proxy`) expose the fallback math directly — they are used by the
|
|
28
|
+
* fallback regression guard in the test suite and by any report that wants to
|
|
29
|
+
* record both the exact and the proxy count to flag the delta.
|
|
25
30
|
*
|
|
26
|
-
* Parity notes: "chars" counts Unicode code points (
|
|
27
|
-
*
|
|
31
|
+
* Parity notes: "chars" counts Unicode code points (`len(str)` parity), not
|
|
32
|
+
* UTF-16 units; proxy rounding is banker's rounding.
|
|
28
33
|
*/
|
|
29
34
|
|
|
35
|
+
import { createRequire } from "node:module";
|
|
36
|
+
|
|
30
37
|
// Proxy ratios (chars per token) for the no-tokenizer fallback. Tuned for
|
|
31
38
|
// English markdown rule/skill prose; deliberately conservative.
|
|
32
39
|
const _GPT_CHARS_PER_TOKEN = 4.0;
|
|
33
40
|
const _CLAUDE_CHARS_PER_TOKEN = 3.6;
|
|
34
41
|
|
|
35
|
-
|
|
42
|
+
// Roadmap (token-saving Phase 0) mandates the `cl100k_base` encoding. The
|
|
43
|
+
// dependency also bundles `o200k_base` (GPT-4o / GPT-4.1 family); switching the
|
|
44
|
+
// GPT count basis is a one-line change to this constant. The exact-vs-proxy
|
|
45
|
+
// delta is recorded in every report via `method_note()`.
|
|
46
|
+
const _TIKTOKEN_ENCODING = "cl100k_base";
|
|
47
|
+
|
|
48
|
+
/** Minimal structural type for the js-tiktoken encoder we rely on. */
|
|
49
|
+
interface _Encoder {
|
|
50
|
+
encode(text: string): number[];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Load the optional `js-tiktoken` encoder synchronously, returning `null` when
|
|
55
|
+
* the dependency is absent or fails to initialise (graceful proxy fallback).
|
|
56
|
+
* `createRequire` resolves the CJS build so the call stays synchronous even
|
|
57
|
+
* though this module is ESM.
|
|
58
|
+
*/
|
|
59
|
+
function _loadEncoder(): _Encoder | null {
|
|
60
|
+
try {
|
|
61
|
+
const require = createRequire(import.meta.url);
|
|
62
|
+
const mod = require("js-tiktoken") as {
|
|
63
|
+
getEncoding(name: string): _Encoder;
|
|
64
|
+
};
|
|
65
|
+
return mod.getEncoding(_TIKTOKEN_ENCODING);
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Resolved once at module load. js-tiktoken's `getEncoding` is synchronous with
|
|
72
|
+
// bundled rank tables, so eager init is cheap and keeps `TIKTOKEN_AVAILABLE` a
|
|
73
|
+
// plain const (the historical API shape).
|
|
74
|
+
const _ENCODER: _Encoder | null = _loadEncoder();
|
|
36
75
|
|
|
37
|
-
|
|
38
|
-
export const TIKTOKEN_AVAILABLE =
|
|
76
|
+
/** True when the real tokenizer is available (exact GPT counts), else proxy. */
|
|
77
|
+
export const TIKTOKEN_AVAILABLE = _ENCODER !== null;
|
|
39
78
|
|
|
40
79
|
/** A single token measurement and whether it is exact or a proxy. */
|
|
41
80
|
export class TokenCount {
|
|
@@ -49,7 +88,7 @@ export class TokenCount {
|
|
|
49
88
|
}
|
|
50
89
|
}
|
|
51
90
|
|
|
52
|
-
/** Count Unicode code points —
|
|
91
|
+
/** Count Unicode code points — `len(str)` parity. */
|
|
53
92
|
function _len(text: string): number {
|
|
54
93
|
let count = 0;
|
|
55
94
|
// for..of iterates code points, so astral chars count once.
|
|
@@ -57,7 +96,7 @@ function _len(text: string): number {
|
|
|
57
96
|
return count;
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
/**
|
|
99
|
+
/** Banker's rounding (round half to even) — proxy-path parity. */
|
|
61
100
|
function _python_round(x: number): number {
|
|
62
101
|
const floor = Math.floor(x);
|
|
63
102
|
const diff = x - floor;
|
|
@@ -66,17 +105,31 @@ function _python_round(x: number): number {
|
|
|
66
105
|
return floor % 2 === 0 ? floor : floor + 1;
|
|
67
106
|
}
|
|
68
107
|
|
|
69
|
-
/** Render a float like Python
|
|
108
|
+
/** Render a float like a Python f-string (4.0 → "4.0", 3.6 → "3.6"). */
|
|
70
109
|
function _py_float_repr(x: number): string {
|
|
71
110
|
return Number.isInteger(x) ? `${x}.0` : `${x}`;
|
|
72
111
|
}
|
|
73
112
|
|
|
74
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* GPT token count — EXACT via js-tiktoken (`cl100k_base`) when available,
|
|
115
|
+
* otherwise the documented `chars / 4` proxy.
|
|
116
|
+
*/
|
|
75
117
|
export function gpt_tokens(text: string): TokenCount {
|
|
118
|
+
if (_ENCODER !== null) {
|
|
119
|
+
return new TokenCount(_ENCODER.encode(text).length, true);
|
|
120
|
+
}
|
|
121
|
+
return gpt_tokens_proxy(text);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* GPT proxy count (`chars / 4`, banker's rounding) — the fallback path,
|
|
126
|
+
* exposed directly for the fallback regression guard and delta reporting.
|
|
127
|
+
*/
|
|
128
|
+
export function gpt_tokens_proxy(text: string): TokenCount {
|
|
76
129
|
return new TokenCount(_python_round(_len(text) / _GPT_CHARS_PER_TOKEN), false);
|
|
77
130
|
}
|
|
78
131
|
|
|
79
|
-
/** Claude token count — documented offline proxy (no local tokenizer). */
|
|
132
|
+
/** Claude token count — documented offline proxy (no local Claude tokenizer). */
|
|
80
133
|
export function claude_tokens(text: string): TokenCount {
|
|
81
134
|
return new TokenCount(
|
|
82
135
|
_python_round(_len(text) / _CLAUDE_CHARS_PER_TOKEN),
|
|
@@ -95,9 +148,9 @@ export interface Measure {
|
|
|
95
148
|
/**
|
|
96
149
|
* Return chars + per-model token counts for one text blob.
|
|
97
150
|
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
151
|
+
* `tokens_gpt` is exact (js-tiktoken) when available; `tokens_gpt_exact` tells
|
|
152
|
+
* the report consumer which path produced it. `tokens_claude` is always the
|
|
153
|
+
* proxy (no offline Claude tokenizer).
|
|
101
154
|
*/
|
|
102
155
|
export function measure(text: string): Measure {
|
|
103
156
|
const g = gpt_tokens(text);
|
|
@@ -111,6 +164,31 @@ export function measure(text: string): Measure {
|
|
|
111
164
|
};
|
|
112
165
|
}
|
|
113
166
|
|
|
167
|
+
/**
|
|
168
|
+
* Proxy-only measure (forces the chars-based estimate for GPT regardless of
|
|
169
|
+
* tokenizer availability). Use to record both the exact and the proxy count
|
|
170
|
+
* and flag the delta, and as the fallback regression guard.
|
|
171
|
+
*/
|
|
172
|
+
export function measure_proxy(text: string): Measure {
|
|
173
|
+
const g = gpt_tokens_proxy(text);
|
|
174
|
+
const c = claude_tokens(text);
|
|
175
|
+
return {
|
|
176
|
+
chars: _len(text),
|
|
177
|
+
tokens_gpt: g.tokens,
|
|
178
|
+
tokens_gpt_exact: g.exact,
|
|
179
|
+
tokens_claude: c.tokens,
|
|
180
|
+
tokens_claude_exact: c.exact,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Proxy-path provenance string (the fallback note). */
|
|
185
|
+
export function method_note_proxy(): string {
|
|
186
|
+
return (
|
|
187
|
+
`tokens_gpt: proxy (chars/${_py_float_repr(_GPT_CHARS_PER_TOKEN)}, tiktoken not installed); ` +
|
|
188
|
+
`tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
114
192
|
/** One-line provenance of how token counts were produced (for reports). */
|
|
115
193
|
export function method_note(): string {
|
|
116
194
|
if (TIKTOKEN_AVAILABLE) {
|
|
@@ -119,8 +197,5 @@ export function method_note(): string {
|
|
|
119
197
|
`tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
|
|
120
198
|
);
|
|
121
199
|
}
|
|
122
|
-
return (
|
|
123
|
-
`tokens_gpt: proxy (chars/${_py_float_repr(_GPT_CHARS_PER_TOKEN)}, tiktoken not installed); ` +
|
|
124
|
-
`tokens_claude: proxy (chars/${_py_float_repr(_CLAUDE_CHARS_PER_TOKEN)})`
|
|
125
|
-
);
|
|
200
|
+
return method_note_proxy();
|
|
126
201
|
}
|