@event4u/agent-config 7.3.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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +33 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/fix/pr-comments.md +42 -13
  5. package/dist/agent-src/rules/cli-output-handling.md +18 -0
  6. package/dist/cli/registry.js +1 -0
  7. package/dist/cli/registry.js.map +1 -1
  8. package/dist/discovery/deprecation-report.md +1 -1
  9. package/dist/discovery/discovery-manifest.json +4 -4
  10. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  11. package/dist/discovery/discovery-manifest.summary.md +1 -1
  12. package/dist/discovery/orphan-report.md +1 -1
  13. package/dist/discovery/packs.json +2 -2
  14. package/dist/discovery/trust-report.md +1 -1
  15. package/dist/discovery/workspaces.json +2 -2
  16. package/dist/mcp/registry-manifest.json +2 -2
  17. package/dist/router.json +1 -1
  18. package/dist/server/schemas/settings.js +3 -0
  19. package/dist/server/schemas/settings.js.map +1 -1
  20. package/docs/enforcement-by-host.md +9 -0
  21. package/docs/governance-advantage.md +72 -0
  22. package/package.json +2 -1
  23. package/src/config/agent-settings.template.yml +9 -0
  24. package/src/scripts/_cli/cmd_analyze_session.ts +347 -0
  25. package/src/scripts/_dispatch.bash +8 -0
  26. package/src/scripts/_lib/token_count.ts +110 -35
  27. package/src/scripts/audit_initial_context.ts +37 -12
  28. package/src/scripts/bench_quality_run.ts +253 -0
  29. package/src/scripts/bench_rule_load_latency.ts +178 -0
  30. package/src/scripts/check_kernel_prefix_stability.ts +0 -0
  31. package/src/scripts/check_quality_regression.ts +244 -0
  32. package/src/scripts/check_token_quality_golden.ts +253 -0
  33. package/src/scripts/check_token_regression.ts +266 -0
  34. package/src/scripts/hook_manifest.yaml +15 -3
  35. package/src/scripts/hooks/rtk_wrap_hook.ts +228 -0
  36. package/src/scripts/probe_host_compliance.ts +145 -0
@@ -1,41 +1,80 @@
1
1
  /**
2
- * Real-tokenizer counting for the budget tooling (roadmap 0B.1).
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
- * TypeScript twin of `src/scripts/_lib/token_count.py` (ADR-200 —
5
- * Python→TS migration, Phase 2 / Wave 1). Public API mirrors the
6
- * Python module exactly (snake_case kept deliberately).
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
- * `char != token`. Every budget in this suite is historically in
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
- * Design — no silent installs, no mandatory network:
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
- * - **GPT** — the Python original uses `tiktoken` when installed; no
16
- * tokenizer dependency ships with the TS twin, so the documented
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
- * Both proxies are intentionally conservative ratios drawn from
24
- * English-prose + markdown samples; they are estimates, never gates.
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 (Python `len(str)`),
27
- * not UTF-16 units; rounding is banker's rounding (Python `round()`).
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
- const _TIKTOKEN_ENCODING = "o200k_base"; // GPT-4o / GPT-4.1 family.
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
- // No tiktoken port ships with the TS twin — always the proxy path.
38
- export const TIKTOKEN_AVAILABLE = false;
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 — Python `len(str)` parity. */
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
- /** Python `round()` — round half to even (banker's rounding). */
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's f-string (4.0 → "4.0", 3.6 → "3.6"). */
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
- /** GPT token count — char proxy (tiktoken unavailable in the TS twin). */
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
- * Keys: chars, tokens_gpt, tokens_gpt_exact, tokens_claude,
99
- * tokens_claude_exact. The `*_exact` booleans tell a report consumer
100
- * whether the number is a real tokenizer count or a proxy estimate.
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
  }
@@ -67,13 +67,41 @@ const MCP_SERVER_NAME = 'agent-config';
67
67
  const MCP_OVERSUBSCRIPTION_TOOL_CAP = 25;
68
68
 
69
69
  // Initial-token budget per surface (null = advisory only, no gate).
70
- const BUDGETS: Record<string, number | null> = {
70
+ //
71
+ // PROVISIONAL caps (token-saving Phase 8): regression caps at the current
72
+ // surface + ~15% headroom — NOT the quality elbow. The context-rot quality
73
+ // elbow (the roadmap's intended threshold) needs the Phase 0 live validation
74
+ // run, which is operator/cost-gated; until then these caps stop SILENT growth
75
+ // of the always-loaded token surface. Re-anchor with evidence once the elbow is
76
+ // measured. `rules.gpt` stays advisory here — the always-RULES surface is gated
77
+ // (char-based, extended size) by `check_always_budget`; a token cap would be
78
+ // redundant.
79
+ export const BUDGETS: Record<string, number | null> = {
71
80
  'rules.gpt': null,
72
- 'skill_catalog.gpt': null,
73
- 'command_catalog.gpt': null,
74
- 'mcp_schemas.gpt': null,
81
+ 'skill_catalog.gpt': 12_500, // always-scanned skill descriptions (skills_core_source, 258); current ~10,999
82
+ 'command_catalog.gpt': 5_800, // always-scanned command catalog; current ~5,005
83
+ 'mcp_schemas.gpt': 3_500, // per-connected-client MCP tool schemas; current ~2,942
75
84
  };
76
85
 
86
+ /**
87
+ * Pure budget check: return one breach line per surface whose measured token
88
+ * count exceeds its cap. A `null`/absent cap is advisory (never a breach).
89
+ * Exported so the gate's pass/fail logic is unit-testable (token-saving Phase 8).
90
+ */
91
+ export function evaluate_budgets(
92
+ checks: Record<string, number>,
93
+ budgets: Record<string, number | null>,
94
+ ): string[] {
95
+ const breaches: string[] = [];
96
+ for (const [key, val] of Object.entries(checks)) {
97
+ const cap = budgets[key];
98
+ if (cap !== null && cap !== undefined && val > cap) {
99
+ breaches.push(`${key} ${val} > budget ${cap}`);
100
+ }
101
+ }
102
+ return breaches;
103
+ }
104
+
77
105
  type Measure = token_count.Measure & { files?: number; entries?: number };
78
106
 
79
107
  function _isDir(p: string): boolean {
@@ -645,7 +673,6 @@ export function main(argv: string[] | null = null): number {
645
673
  const data = build();
646
674
 
647
675
  if (args.fail_if_over_budget) {
648
- const breaches: string[] = [];
649
676
  const rfVals = Object.values(data.rule_footprint as Record<string, Measure>);
650
677
  const rf = rfVals.length > 0 ? (rfVals[0] as Measure) : ({} as Measure);
651
678
  const dc = data.description_catalog as Record<string, Measure>;
@@ -653,16 +680,14 @@ export function main(argv: string[] | null = null): number {
653
680
  const mcp_gpt = Object.values(mcp).reduce((acc, s) => acc + s.tokens_gpt, 0);
654
681
  const checks: Record<string, number> = {
655
682
  'rules.gpt': rf.tokens_gpt ?? 0,
656
- 'skill_catalog.gpt': (dc.skills_projected as Measure).tokens_gpt,
683
+ // The always-SCANNED skill descriptions (258 source skills) — the
684
+ // surface the host reads for selection — not the larger per-tool
685
+ // projected catalog (skills_projected, 412 entries). Phase 8.
686
+ 'skill_catalog.gpt': (dc.skills_core_source as Measure).tokens_gpt,
657
687
  'command_catalog.gpt': (dc.commands_core_source as Measure).tokens_gpt,
658
688
  'mcp_schemas.gpt': mcp_gpt,
659
689
  };
660
- for (const [key, val] of Object.entries(checks)) {
661
- const cap = BUDGETS[key];
662
- if (cap !== null && cap !== undefined && val > cap) {
663
- breaches.push(`${key} ${val} > budget ${cap}`);
664
- }
665
- }
690
+ const breaches = evaluate_budgets(checks, BUDGETS);
666
691
  if (breaches.length > 0) {
667
692
  process.stdout.write('❌ initial-context budget: ' + breaches.join('; ') + '\n');
668
693
  return 1;
@@ -0,0 +1,253 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Thin-vs-eager quality-run producer (token-saving Phase 0 EXIT).
4
+ *
5
+ * The missing RUNNER that produces `internal/bench/reports/quality-run.json` —
6
+ * the report the `check_quality_regression` gate consumes. For each labelled
7
+ * golden task it generates the agent's answer under the THIN rule context
8
+ * (kernel bodies + non-kernel pointers) and the EAGER context (all rule bodies),
9
+ * then judges the pair with the length-controlled paired judge
10
+ * (`evaluatePair`: both orders → reject-on-flip).
11
+ *
12
+ * Model calls (answer generation + judging) go through the proven council
13
+ * `AnthropicClient` (synchronous curl → Anthropic Messages API) — so this is
14
+ * **API-gated**: a real run needs an Anthropic key and costs money proportional
15
+ * to (golden tasks × 2 arms + 2 judge calls) × the rule-context size. Use
16
+ * `--dry-run` (deterministic mock, no API) to prove the pipeline + see the
17
+ * output shape first, and `--limit N` to bound a live run.
18
+ *
19
+ * CLI:
20
+ * ./scripts-run src/scripts/bench_quality_run --dry-run # mock, no API
21
+ * ./scripts-run src/scripts/bench_quality_run --limit 5 # live, first 5 tasks
22
+ * ./scripts-run src/scripts/bench_quality_run --model claude-sonnet-4-5
23
+ * Then gate on it: ./scripts-run src/scripts/check_quality_regression
24
+ *
25
+ * Exit codes: 0 wrote report · 1 error.
26
+ */
27
+ import { createRequire } from 'node:module';
28
+ import * as fs from 'node:fs';
29
+ import * as path from 'node:path';
30
+ import process from 'node:process';
31
+ import { fileURLToPath, pathToFileURL } from 'node:url';
32
+
33
+ import yaml from 'js-yaml';
34
+
35
+ import { build_thin, RULES_SOURCE } from './project_thin_rules.js';
36
+ import { evaluatePair, type JudgeFn, type JudgeVerdict, type PairResult } from './check_quality_regression.js';
37
+
38
+ const _HERE = fileURLToPath(import.meta.url);
39
+ const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
40
+ const GOLDEN = path.join(REPO_ROOT, 'internal/bench/corpora/token-quality-golden.yaml');
41
+ const OUT = path.join(REPO_ROOT, 'internal/bench/reports/quality-run.json');
42
+ const DEFAULT_THRESHOLD = 0.48;
43
+
44
+ export interface GoldenTask {
45
+ id: string;
46
+ rubric: string;
47
+ prompt: string;
48
+ must_include: string[];
49
+ must_not: string[];
50
+ }
51
+
52
+ /** Load the labelled golden tasks (stubs are skipped — they have no real rubric). */
53
+ export function load_golden(file = GOLDEN): GoldenTask[] {
54
+ const doc = yaml.load(fs.readFileSync(file, 'utf-8')) as { tasks?: unknown[] };
55
+ const tasks = Array.isArray(doc?.tasks) ? doc.tasks : [];
56
+ const out: GoldenTask[] = [];
57
+ for (const t of tasks as Array<Record<string, unknown>>) {
58
+ if (t.label_status !== 'labelled') continue;
59
+ const exp = (t.expected ?? {}) as Record<string, unknown>;
60
+ out.push({
61
+ id: String(t.id),
62
+ rubric: String(exp.rubric ?? ''),
63
+ prompt: String(t.prompt ?? ''),
64
+ must_include: Array.isArray(exp.must_include) ? exp.must_include.map(String) : [],
65
+ must_not: Array.isArray(exp.must_not) ? exp.must_not.map(String) : [],
66
+ });
67
+ }
68
+ return out;
69
+ }
70
+
71
+ /** Assemble the always-loaded rule context for each arm (eager bodies vs thin pointers). */
72
+ export function assemble_contexts(rulesDir = RULES_SOURCE): { eager: string; thin: string } {
73
+ const thinMap = build_thin(rulesDir);
74
+ const thin = [...thinMap.values()].join('\n\n');
75
+ const eager = fs
76
+ .readdirSync(rulesDir)
77
+ .filter((n) => n.endsWith('.md'))
78
+ .sort()
79
+ .map((n) => fs.readFileSync(path.join(rulesDir, n), 'utf-8'))
80
+ .join('\n\n');
81
+ return { eager, thin };
82
+ }
83
+
84
+ /** A model that answers a task under a given rule context. */
85
+ export type AnswerFn = (armContext: string, task: GoldenTask) => string;
86
+
87
+ /** A judge that, given the FULL task (rubric + anchors) + two presented answers,
88
+ * returns the order-relative verdict. Wrapped into evaluatePair's narrow JudgeFn. */
89
+ export type ModelJudgeFn = (task: GoldenTask, first: string, second: string) => JudgeVerdict;
90
+
91
+ /** Build the judge user-prompt — length-controlled (ignore verbosity). */
92
+ export function judge_prompt(task: GoldenTask, first: string, second: string): string {
93
+ return (
94
+ `Rubric: ${task.rubric}\n` +
95
+ (task.must_include.length ? `Must satisfy: ${task.must_include.join('; ')}\n` : '') +
96
+ (task.must_not.length ? `Must avoid: ${task.must_not.join('; ')}\n` : '') +
97
+ `\nTwo answers to the same request. Judge ONLY which better satisfies the rubric —\n` +
98
+ `IGNORE length and verbosity (a longer answer is not better).\n\n` +
99
+ `--- ANSWER A ---\n${first}\n\n--- ANSWER B ---\n${second}\n\n` +
100
+ `Reply with exactly one line: "VERDICT: A", "VERDICT: B", or "VERDICT: TIE".`
101
+ );
102
+ }
103
+
104
+ /** Parse a judge reply into the order-relative verdict (default tie = inconclusive). */
105
+ export function parse_verdict(reply: string): JudgeVerdict {
106
+ const m = /VERDICT:\s*(A|B|TIE)/i.exec(reply);
107
+ if (!m) return 'tie';
108
+ const v = (m[1] as string).toUpperCase();
109
+ return v === 'A' ? 'first' : v === 'B' ? 'second' : 'tie';
110
+ }
111
+
112
+ /** Run every task through both arms + the paired judge. Pure given the injected fns. */
113
+ export function run_golden_judge(
114
+ tasks: GoldenTask[],
115
+ contexts: { eager: string; thin: string },
116
+ answer: AnswerFn,
117
+ judge: ModelJudgeFn,
118
+ ): PairResult[] {
119
+ return tasks.map((task) => {
120
+ const thinAnswer = answer(contexts.thin, task);
121
+ const eagerAnswer = answer(contexts.eager, task);
122
+ // evaluatePair calls its JudgeFn with the narrow {id,rubric} ctx; capture
123
+ // the full task here so the model judge gets the rubric + anchors.
124
+ const judgeFn: JudgeFn = (_ctx, first, second) => judge(task, first, second);
125
+ return evaluatePair(task, thinAnswer, eagerAnswer, judgeFn);
126
+ });
127
+ }
128
+
129
+ // ── Mock model (--dry-run): deterministic, no API ───────────────────────────
130
+ function mock_answer(_ctx: string, task: GoldenTask): string {
131
+ return `Answer to ${task.id}: ${(task.must_include[0] ?? 'addresses the request')}.`;
132
+ }
133
+ const mock_judge: ModelJudgeFn = () => 'tie'; // dry-run can't truly compare → all ties
134
+
135
+ interface Args {
136
+ dryRun: boolean;
137
+ model: string;
138
+ limit: number | null;
139
+ output: string;
140
+ }
141
+
142
+ function parse_args(argv: string[]): Args | number {
143
+ const a: Args = { dryRun: false, model: 'claude-sonnet-4-5', limit: null, output: OUT };
144
+ for (let i = 0; i < argv.length; i += 1) {
145
+ const arg = argv[i];
146
+ if (arg === '--dry-run') a.dryRun = true;
147
+ else if (arg === '--model') a.model = String(argv[(i += 1)] ?? a.model);
148
+ else if (arg === '--output') a.output = String(argv[(i += 1)] ?? a.output);
149
+ else if (arg === '--limit') {
150
+ const v = Number(argv[(i += 1)]);
151
+ if (!Number.isInteger(v) || v < 1) {
152
+ process.stderr.write('error: --limit must be a positive integer\n');
153
+ return 1;
154
+ }
155
+ a.limit = v;
156
+ } else if (arg === '-h' || arg === '--help') {
157
+ process.stdout.write('usage: bench_quality_run [--dry-run] [--model M] [--limit N] [--output PATH]\n');
158
+ return 0;
159
+ } else {
160
+ process.stderr.write(`error: unknown argument: ${arg}\n`);
161
+ return 1;
162
+ }
163
+ }
164
+ return a;
165
+ }
166
+
167
+ function main(argv: string[]): number {
168
+ const parsed = parse_args(argv);
169
+ if (typeof parsed === 'number') return parsed;
170
+ const args = parsed;
171
+
172
+ let tasks = load_golden();
173
+ if (tasks.length === 0) {
174
+ process.stderr.write('error: no labelled golden tasks — fill internal/bench/corpora/token-quality-golden.yaml\n');
175
+ return 1;
176
+ }
177
+ if (args.limit !== null) tasks = tasks.slice(0, args.limit);
178
+ const contexts = assemble_contexts();
179
+
180
+ let answer: AnswerFn;
181
+ let judge: ModelJudgeFn;
182
+ let modelLabel: string;
183
+
184
+ if (args.dryRun) {
185
+ answer = mock_answer;
186
+ judge = mock_judge;
187
+ modelLabel = 'dry-run-mock';
188
+ process.stdout.write(`⚠️ --dry-run: deterministic mock, no API. Output shape only.\n`);
189
+ } else {
190
+ // Live path — reuse the proven council AnthropicClient (sync curl). API-gated.
191
+ let client: { ask(system: string, user: string): { text: string } };
192
+ try {
193
+ // Lazy import so --dry-run + tests never touch the council key path.
194
+ const require = createRequire(import.meta.url);
195
+ const clients = require('./ai_council/clients.js') as {
196
+ AnthropicClient: new (o: { model: string; api_key: string }) => { ask(s: string, u: string): { text: string } };
197
+ load_anthropic_key: () => string;
198
+ };
199
+ client = new clients.AnthropicClient({ model: args.model, api_key: clients.load_anthropic_key() });
200
+ } catch (e) {
201
+ process.stderr.write(
202
+ `error: live model unavailable (${(e as Error).message}). ` +
203
+ `Set an Anthropic key (see install-anthropic-key) or use --dry-run.\n`,
204
+ );
205
+ return 1;
206
+ }
207
+ const SYS =
208
+ 'You are an AI coding agent governed by the following always-loaded package rules. ' +
209
+ 'Answer the user request as that agent would, honouring these rules:\n\n';
210
+ answer = (ctx, task) => client.ask(SYS + ctx, task.prompt).text;
211
+ judge = (task, first, second) =>
212
+ parse_verdict(
213
+ client.ask('You are a strict, length-neutral answer judge.', judge_prompt(task, first, second)).text,
214
+ );
215
+ modelLabel = args.model;
216
+ process.stdout.write(
217
+ `Live run: ${tasks.length} tasks × 2 arms + 2 judge calls each via ${args.model}. This costs API $.\n`,
218
+ );
219
+ }
220
+
221
+ let results: PairResult[];
222
+ try {
223
+ results = run_golden_judge(tasks, contexts, answer, judge);
224
+ } catch (e) {
225
+ process.stderr.write(`error: run failed: ${(e as Error).message}\n`);
226
+ return 1;
227
+ }
228
+
229
+ const payload = {
230
+ generated_by: 'bench_quality_run',
231
+ judge_model: modelLabel,
232
+ dry_run: args.dryRun,
233
+ threshold: DEFAULT_THRESHOLD,
234
+ results,
235
+ };
236
+ fs.writeFileSync(args.output, JSON.stringify(payload, null, 2) + '\n');
237
+ const thinWins = results.filter((r) => r.winner === 'thin').length;
238
+ const eagerWins = results.filter((r) => r.winner === 'eager').length;
239
+ process.stdout.write(
240
+ `→ wrote ${path.relative(REPO_ROOT, args.output)} (${results.length} pairs; ` +
241
+ `thin ${thinWins} / eager ${eagerWins}). Gate: \`check_quality_regression\`.\n`,
242
+ );
243
+ return 0;
244
+ }
245
+
246
+ const _IS_MAIN =
247
+ process.argv[1] !== undefined &&
248
+ import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
249
+ if (_IS_MAIN) {
250
+ process.exit(main(process.argv.slice(2)));
251
+ }
252
+
253
+ export { main, mock_answer };