@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,266 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Token-regression gate (token-saving Phase 0).
|
|
4
|
+
*
|
|
5
|
+
* Fails CI when a tracked always-loaded / projected token surface grows more
|
|
6
|
+
* than the tolerance (default 5%) over a committed baseline. This is the
|
|
7
|
+
* RELATIVE guard — distinct from `audit-tokens-budget` (absolute budgets) and
|
|
8
|
+
* from the cache-prefix byte-stability guard (Phase 5).
|
|
9
|
+
*
|
|
10
|
+
* Source of truth is `internal/bench/reports/projection-cost.json`, which is
|
|
11
|
+
* now produced with EXACT counts via the real tokenizer
|
|
12
|
+
* (`src/scripts/_lib/token_count.ts` → js-tiktoken cl100k_base). Run
|
|
13
|
+
* `task audit-tokens` first so the report reflects the working tree.
|
|
14
|
+
*
|
|
15
|
+
* Gated metrics (all `tokens_gpt`):
|
|
16
|
+
* - eager_rule_load — thin_projection.eager_gpt (the eager always-on load)
|
|
17
|
+
* - thin_rule_load — thin_projection.thin_gpt
|
|
18
|
+
* - skill_descriptions — description_catalog.skills_core_source.tokens_gpt
|
|
19
|
+
* - command_descriptions — description_catalog.commands_core_source.tokens_gpt
|
|
20
|
+
* - mcp_schemas — Σ mcp_tool_schemas[*].tokens_gpt
|
|
21
|
+
*
|
|
22
|
+
* A baseline captured under a different `token_method` (e.g. proxy → exact)
|
|
23
|
+
* is a legitimate reset, not a regression — the gate WARNS and asks for a
|
|
24
|
+
* `--update-baseline` rather than failing on the method switch alone.
|
|
25
|
+
*
|
|
26
|
+
* CLI:
|
|
27
|
+
* ./scripts-run src/scripts/check_token_regression # gate (read-only)
|
|
28
|
+
* ./scripts-run src/scripts/check_token_regression --json # machine output
|
|
29
|
+
* ./scripts-run src/scripts/check_token_regression --tolerance 0.05
|
|
30
|
+
* ./scripts-run src/scripts/check_token_regression --update-baseline # record current as baseline
|
|
31
|
+
*
|
|
32
|
+
* Exit codes:
|
|
33
|
+
* 0 — within tolerance (or baseline written / warmup: no baseline yet)
|
|
34
|
+
* 1 — argument / file error
|
|
35
|
+
* 2 — regression: a metric exceeded baseline by more than the tolerance
|
|
36
|
+
*/
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
import process from 'node:process';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
|
|
42
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
43
|
+
// REPO_ROOT = <repo>/src/scripts/<file> → up two.
|
|
44
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
45
|
+
|
|
46
|
+
const PROJECTION_REPORT = path.join(
|
|
47
|
+
REPO_ROOT,
|
|
48
|
+
'internal/bench/reports/projection-cost.json',
|
|
49
|
+
);
|
|
50
|
+
const BASELINE_FILE = path.join(
|
|
51
|
+
REPO_ROOT,
|
|
52
|
+
'internal/bench/reports/token-baseline.json',
|
|
53
|
+
);
|
|
54
|
+
const DEFAULT_TOLERANCE = 0.05;
|
|
55
|
+
|
|
56
|
+
type Json = Record<string, unknown>;
|
|
57
|
+
|
|
58
|
+
interface Metrics {
|
|
59
|
+
token_method: string;
|
|
60
|
+
metrics: Record<string, number>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _num(obj: unknown, ...keys: string[]): number {
|
|
64
|
+
let cur: unknown = obj;
|
|
65
|
+
for (const k of keys) {
|
|
66
|
+
if (cur && typeof cur === 'object' && k in (cur as Json)) {
|
|
67
|
+
cur = (cur as Json)[k];
|
|
68
|
+
} else {
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return typeof cur === 'number' ? cur : 0;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Extract the flat gated-metric map from a projection-cost report. */
|
|
76
|
+
function extract_metrics(report: Json): Metrics {
|
|
77
|
+
const mcp = (report['mcp_tool_schemas'] as Json) ?? {};
|
|
78
|
+
let mcp_total = 0;
|
|
79
|
+
for (const server of Object.values(mcp)) {
|
|
80
|
+
mcp_total += _num(server, 'tokens_gpt');
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
token_method: String(report['token_method'] ?? 'unknown'),
|
|
84
|
+
metrics: {
|
|
85
|
+
eager_rule_load: _num(report, 'thin_projection', 'eager_gpt'),
|
|
86
|
+
thin_rule_load: _num(report, 'thin_projection', 'thin_gpt'),
|
|
87
|
+
skill_descriptions: _num(
|
|
88
|
+
report,
|
|
89
|
+
'description_catalog',
|
|
90
|
+
'skills_core_source',
|
|
91
|
+
'tokens_gpt',
|
|
92
|
+
),
|
|
93
|
+
command_descriptions: _num(
|
|
94
|
+
report,
|
|
95
|
+
'description_catalog',
|
|
96
|
+
'commands_core_source',
|
|
97
|
+
'tokens_gpt',
|
|
98
|
+
),
|
|
99
|
+
mcp_schemas: mcp_total,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function _readJson(file: string): Json | null {
|
|
105
|
+
try {
|
|
106
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8')) as Json;
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface Row {
|
|
113
|
+
id: string;
|
|
114
|
+
current: number;
|
|
115
|
+
baseline: number;
|
|
116
|
+
pct: number; // signed fraction, e.g. 0.07 = +7%
|
|
117
|
+
regressed: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface GateResult {
|
|
121
|
+
status: 'ok' | 'regression' | 'warmup' | 'method-changed';
|
|
122
|
+
tolerance: number;
|
|
123
|
+
current_method: string;
|
|
124
|
+
baseline_method: string | null;
|
|
125
|
+
rows: Row[];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function evaluate(
|
|
129
|
+
current: Metrics,
|
|
130
|
+
baseline: Metrics | null,
|
|
131
|
+
tolerance: number,
|
|
132
|
+
): GateResult {
|
|
133
|
+
if (baseline === null) {
|
|
134
|
+
return {
|
|
135
|
+
status: 'warmup',
|
|
136
|
+
tolerance,
|
|
137
|
+
current_method: current.token_method,
|
|
138
|
+
baseline_method: null,
|
|
139
|
+
rows: [],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
const method_changed = baseline.token_method !== current.token_method;
|
|
143
|
+
const rows: Row[] = [];
|
|
144
|
+
for (const [id, cur] of Object.entries(current.metrics)) {
|
|
145
|
+
const base = baseline.metrics[id] ?? 0;
|
|
146
|
+
const pct = base > 0 ? (cur - base) / base : 0;
|
|
147
|
+
rows.push({
|
|
148
|
+
id,
|
|
149
|
+
current: cur,
|
|
150
|
+
baseline: base,
|
|
151
|
+
pct,
|
|
152
|
+
// A method switch is a legitimate baseline reset, not a regression.
|
|
153
|
+
regressed: !method_changed && base > 0 && pct > tolerance,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
rows.sort((a, b) => b.pct - a.pct);
|
|
157
|
+
const anyRegressed = rows.some((r) => r.regressed);
|
|
158
|
+
const status: GateResult['status'] = anyRegressed
|
|
159
|
+
? 'regression'
|
|
160
|
+
: method_changed
|
|
161
|
+
? 'method-changed'
|
|
162
|
+
: 'ok';
|
|
163
|
+
return {
|
|
164
|
+
status,
|
|
165
|
+
tolerance,
|
|
166
|
+
current_method: current.token_method,
|
|
167
|
+
baseline_method: baseline.token_method,
|
|
168
|
+
rows,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function _fmtPct(p: number): string {
|
|
173
|
+
const sign = p >= 0 ? '+' : '';
|
|
174
|
+
return `${sign}${(p * 100).toFixed(1)}%`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function main(argv: string[]): number {
|
|
178
|
+
let tolerance = DEFAULT_TOLERANCE;
|
|
179
|
+
let asJson = false;
|
|
180
|
+
let updateBaseline = false;
|
|
181
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
182
|
+
const a = argv[i];
|
|
183
|
+
if (a === '--json') asJson = true;
|
|
184
|
+
else if (a === '--update-baseline') updateBaseline = true;
|
|
185
|
+
else if (a === '--tolerance') {
|
|
186
|
+
const v = Number(argv[(i += 1)]);
|
|
187
|
+
if (!Number.isFinite(v) || v < 0) {
|
|
188
|
+
process.stderr.write(`error: invalid --tolerance\n`);
|
|
189
|
+
return 1;
|
|
190
|
+
}
|
|
191
|
+
tolerance = v;
|
|
192
|
+
} else if (a === '-h' || a === '--help') {
|
|
193
|
+
process.stdout.write(
|
|
194
|
+
'usage: check_token_regression [--json] [--tolerance F] [--update-baseline]\n',
|
|
195
|
+
);
|
|
196
|
+
return 0;
|
|
197
|
+
} else {
|
|
198
|
+
process.stderr.write(`error: unknown argument: ${a}\n`);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const report = _readJson(PROJECTION_REPORT);
|
|
204
|
+
if (report === null) {
|
|
205
|
+
process.stderr.write(
|
|
206
|
+
`error: cannot read ${path.relative(REPO_ROOT, PROJECTION_REPORT)} — run \`task audit-tokens\` first\n`,
|
|
207
|
+
);
|
|
208
|
+
return 1;
|
|
209
|
+
}
|
|
210
|
+
const current = extract_metrics(report);
|
|
211
|
+
|
|
212
|
+
if (updateBaseline) {
|
|
213
|
+
const payload = JSON.stringify(current, null, 2) + '\n';
|
|
214
|
+
fs.writeFileSync(BASELINE_FILE, payload);
|
|
215
|
+
process.stdout.write(
|
|
216
|
+
`✅ token baseline written: ${path.relative(REPO_ROOT, BASELINE_FILE)} (method: ${current.token_method})\n`,
|
|
217
|
+
);
|
|
218
|
+
return 0;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const baselineRaw = _readJson(BASELINE_FILE);
|
|
222
|
+
const baseline: Metrics | null =
|
|
223
|
+
baselineRaw === null ? null : (baselineRaw as unknown as Metrics);
|
|
224
|
+
const result = evaluate(current, baseline, tolerance);
|
|
225
|
+
|
|
226
|
+
if (asJson) {
|
|
227
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
228
|
+
} else if (result.status === 'warmup') {
|
|
229
|
+
process.stdout.write(
|
|
230
|
+
`⚠️ no token baseline yet — run \`./scripts-run src/scripts/check_token_regression --update-baseline\` to record one.\n`,
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
for (const r of result.rows) {
|
|
234
|
+
const mark = r.regressed ? '❌' : '✅';
|
|
235
|
+
process.stdout.write(
|
|
236
|
+
`${mark} ${r.id}: ${r.current} vs baseline ${r.baseline} (${_fmtPct(r.pct)})\n`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (result.status === 'method-changed') {
|
|
240
|
+
process.stdout.write(
|
|
241
|
+
`⚠️ token_method changed (${result.baseline_method} → ${result.current_method}); ` +
|
|
242
|
+
`baseline reset is legitimate — run --update-baseline to re-anchor.\n`,
|
|
243
|
+
);
|
|
244
|
+
} else if (result.status === 'regression') {
|
|
245
|
+
process.stdout.write(
|
|
246
|
+
`❌ token regression: a projection grew >${(tolerance * 100).toFixed(0)}% over baseline.\n`,
|
|
247
|
+
);
|
|
248
|
+
} else {
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
`✅ token surfaces within +${(tolerance * 100).toFixed(0)}% of baseline.\n`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result.status === 'regression' ? 2 : 0;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const _IS_MAIN =
|
|
259
|
+
process.argv[1] !== undefined &&
|
|
260
|
+
import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href;
|
|
261
|
+
|
|
262
|
+
if (_IS_MAIN) {
|
|
263
|
+
process.exit(main(process.argv.slice(2)));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export { extract_metrics, main };
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cross_model_smoke — the Phase-0 keystone (roadmap `road-to-operator-runtime-harvest`,
|
|
4
|
+
* T-004/T-006). Runs the existing trigger-routing eval across multiple host
|
|
5
|
+
* vendors (Anthropic / OpenAI / Gemini) over plain HTTP and emits, PER HOST:
|
|
6
|
+
*
|
|
7
|
+
* - routing pass-rate + precision/recall (the parity signal),
|
|
8
|
+
* - input/output tokens + cost estimate,
|
|
9
|
+
* - output-shape parse-rate (did the host return parseable `would_load` JSON?
|
|
10
|
+
* — the format-governance signal, T-004 hardening #2),
|
|
11
|
+
* - negative-control catch-rate (did the eval correctly reject the
|
|
12
|
+
* should-NOT-trigger queries? — the discrimination signal, T-003).
|
|
13
|
+
*
|
|
14
|
+
* Honesty couplings (3rd-pass council): the result names the coverage
|
|
15
|
+
* denominator (N skills / M fixtures) and the read of outcome (a)/(b)/(c) is
|
|
16
|
+
* left to a human in T-006 — this script reports, it does not declare parity.
|
|
17
|
+
*
|
|
18
|
+
* Modes:
|
|
19
|
+
* --dry-run use MockRouter for every host (no keys, no spend) — CI path.
|
|
20
|
+
* --skills a,b,c fixture skills to run (must have evals/triggers.json).
|
|
21
|
+
* --hosts anthropic,openai,gemini subset of hosts (default: all three).
|
|
22
|
+
*
|
|
23
|
+
* Live mode reads keys from ~/.event4u/agent-config/{anthropic,openai,gemini}.key
|
|
24
|
+
* and makes real, paid API calls — keep --skills small.
|
|
25
|
+
*/
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
type EvalResult,
|
|
31
|
+
type Query,
|
|
32
|
+
type SkillMeta,
|
|
33
|
+
type TriggerRouter,
|
|
34
|
+
MockRouter,
|
|
35
|
+
compute_metrics,
|
|
36
|
+
estimate_cost,
|
|
37
|
+
load_skill_metas,
|
|
38
|
+
load_triggers,
|
|
39
|
+
} from './skill_trigger_eval.js';
|
|
40
|
+
import {
|
|
41
|
+
AnthropicFetchRouter,
|
|
42
|
+
GeminiRouter,
|
|
43
|
+
OpenAiRouter,
|
|
44
|
+
loadKeyFromFile,
|
|
45
|
+
} from './_lib/trigger_routers.js';
|
|
46
|
+
|
|
47
|
+
type AsyncRouter = TriggerRouter & {
|
|
48
|
+
routeAsync?: (q: string, skills: SkillMeta[]) => Promise<[string[], number, number]>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
interface HostSummary {
|
|
52
|
+
host: string;
|
|
53
|
+
model: string;
|
|
54
|
+
queries: number;
|
|
55
|
+
passed: number;
|
|
56
|
+
pass_rate: number;
|
|
57
|
+
input_tokens: number;
|
|
58
|
+
output_tokens: number;
|
|
59
|
+
cost_usd_estimate: number;
|
|
60
|
+
// output-shape signal: fraction of calls that produced a parseable would_load
|
|
61
|
+
// (a host that returned 0 loaded AND 0 tokens is treated as an unparseable / failed call)
|
|
62
|
+
parse_ok: number;
|
|
63
|
+
parse_rate: number;
|
|
64
|
+
// discrimination signal: should-NOT-trigger queries correctly rejected
|
|
65
|
+
neg_controls: number;
|
|
66
|
+
neg_caught: number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Mirror run_eval but await routeAsync when present (fetch routers); fall back to sync route (MockRouter). */
|
|
70
|
+
async function runEvalAsync(
|
|
71
|
+
skill: string,
|
|
72
|
+
queries: Query[],
|
|
73
|
+
router: AsyncRouter,
|
|
74
|
+
skills: SkillMeta[],
|
|
75
|
+
model: string,
|
|
76
|
+
): Promise<{ result: EvalResult; parse_ok: number; neg_controls: number; neg_caught: number }> {
|
|
77
|
+
const result: EvalResult = {
|
|
78
|
+
skill,
|
|
79
|
+
model,
|
|
80
|
+
timestamp: '',
|
|
81
|
+
router: router.name,
|
|
82
|
+
queries: [],
|
|
83
|
+
metrics: { true_positive: 0, false_positive: 0, true_negative: 0, false_negative: 0, precision: 0, recall: 0 },
|
|
84
|
+
input_tokens: 0,
|
|
85
|
+
output_tokens: 0,
|
|
86
|
+
cost_usd_estimate: 0.0,
|
|
87
|
+
};
|
|
88
|
+
let parse_ok = 0;
|
|
89
|
+
let neg_controls = 0;
|
|
90
|
+
let neg_caught = 0;
|
|
91
|
+
for (const q of queries) {
|
|
92
|
+
const [loaded, inTok, outTok] =
|
|
93
|
+
typeof router.routeAsync === 'function'
|
|
94
|
+
? await router.routeAsync(q.q, skills)
|
|
95
|
+
: router.route(q.q, skills);
|
|
96
|
+
const observed = loaded.includes(skill);
|
|
97
|
+
const passed = observed === q.trigger;
|
|
98
|
+
// a call that returned at least one token OR a non-empty load is treated as parseable
|
|
99
|
+
if (inTok > 0 || outTok > 0 || loaded.length > 0) parse_ok += 1;
|
|
100
|
+
if (!q.trigger) {
|
|
101
|
+
neg_controls += 1;
|
|
102
|
+
if (passed) neg_caught += 1; // correctly rejected a should-not query
|
|
103
|
+
}
|
|
104
|
+
result.queries.push({ q: q.q, expected: q.trigger, observed, loaded_skills: [...loaded], passed });
|
|
105
|
+
result.input_tokens += inTok;
|
|
106
|
+
result.output_tokens += outTok;
|
|
107
|
+
}
|
|
108
|
+
result.metrics = compute_metrics(result.queries);
|
|
109
|
+
result.cost_usd_estimate = estimate_cost(model, result.input_tokens, result.output_tokens);
|
|
110
|
+
return { result, parse_ok, neg_controls, neg_caught };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function buildRouter(host: string, dryRun: boolean): AsyncRouter {
|
|
114
|
+
if (dryRun) {
|
|
115
|
+
// Deterministic stub: loads a skill iff the query mentions its name.
|
|
116
|
+
return new MockRouter((q, skills) => skills.filter((s) => q.toLowerCase().includes(s.name)).map((s) => s.name)) as AsyncRouter;
|
|
117
|
+
}
|
|
118
|
+
if (host === 'openai') return new OpenAiRouter({ apiKey: loadKeyFromFile('openai.key') }) as AsyncRouter;
|
|
119
|
+
if (host === 'gemini') return new GeminiRouter({ apiKey: loadKeyFromFile('gemini.key') }) as AsyncRouter;
|
|
120
|
+
if (host === 'anthropic') return new AnthropicFetchRouter({ apiKey: loadKeyFromFile('anthropic.key') }) as AsyncRouter;
|
|
121
|
+
throw new Error(`unknown host: ${host}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function modelFor(host: string): string {
|
|
125
|
+
if (host === 'openai') return 'gpt-4o-mini';
|
|
126
|
+
if (host === 'gemini') return 'gemini-2.5-flash';
|
|
127
|
+
if (host === 'anthropic') return 'claude-haiku-4-5-20251001';
|
|
128
|
+
return 'mock';
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function runSmoke(opts: {
|
|
132
|
+
skills: string[];
|
|
133
|
+
hosts: string[];
|
|
134
|
+
dryRun: boolean;
|
|
135
|
+
}): Promise<{ catalogue_size: number; fixtures: number; hosts: HostSummary[] }> {
|
|
136
|
+
// Read the catalogue from the source-of-truth `src/skills` tree explicitly.
|
|
137
|
+
// load_skill_metas' built-in default points at a legacy tree that is absent
|
|
138
|
+
// in fresh clones / CI, so we pass the path rather than rely on the default.
|
|
139
|
+
// Keeps the catalogue and the fixture paths on the same tree.
|
|
140
|
+
const catalogue = load_skill_metas(path.join('src', 'skills'));
|
|
141
|
+
const summaries: HostSummary[] = [];
|
|
142
|
+
let totalFixtures = 0;
|
|
143
|
+
|
|
144
|
+
for (const host of opts.hosts) {
|
|
145
|
+
const router = buildRouter(host, opts.dryRun);
|
|
146
|
+
const model = opts.dryRun ? 'mock' : modelFor(host);
|
|
147
|
+
let q = 0,
|
|
148
|
+
passed = 0,
|
|
149
|
+
inTok = 0,
|
|
150
|
+
outTok = 0,
|
|
151
|
+
cost = 0,
|
|
152
|
+
parseOk = 0,
|
|
153
|
+
negC = 0,
|
|
154
|
+
negCaught = 0,
|
|
155
|
+
fixtures = 0;
|
|
156
|
+
for (const skill of opts.skills) {
|
|
157
|
+
const trigPath = path.join('src', 'skills', skill, 'evals', 'triggers.json');
|
|
158
|
+
if (!fs.existsSync(trigPath)) continue;
|
|
159
|
+
// Some fixtures use a legacy shape (`should_trigger`/`should_not_trigger`)
|
|
160
|
+
// instead of the unified `queries[]`. Skip-and-warn rather than crash the
|
|
161
|
+
// whole run — and never silently: the skip is surfaced on stderr.
|
|
162
|
+
let skillName: string;
|
|
163
|
+
let queries: Query[];
|
|
164
|
+
try {
|
|
165
|
+
[skillName, queries] = load_triggers(trigPath);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
process.stderr.write(`skip ${skill}: unparseable triggers.json — ${(e as Error).message}\n`);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
fixtures += 1;
|
|
171
|
+
const r = await runEvalAsync(skillName, queries, router, catalogue, model);
|
|
172
|
+
q += r.result.queries.length;
|
|
173
|
+
passed += r.result.queries.filter((x) => x.passed).length;
|
|
174
|
+
inTok += r.result.input_tokens;
|
|
175
|
+
outTok += r.result.output_tokens;
|
|
176
|
+
cost += r.result.cost_usd_estimate;
|
|
177
|
+
parseOk += r.parse_ok;
|
|
178
|
+
negC += r.neg_controls;
|
|
179
|
+
negCaught += r.neg_caught;
|
|
180
|
+
}
|
|
181
|
+
totalFixtures = fixtures;
|
|
182
|
+
summaries.push({
|
|
183
|
+
host,
|
|
184
|
+
model,
|
|
185
|
+
queries: q,
|
|
186
|
+
passed,
|
|
187
|
+
pass_rate: q ? passed / q : 0,
|
|
188
|
+
input_tokens: inTok,
|
|
189
|
+
output_tokens: outTok,
|
|
190
|
+
cost_usd_estimate: cost,
|
|
191
|
+
parse_ok: parseOk,
|
|
192
|
+
parse_rate: q ? parseOk / q : 0,
|
|
193
|
+
neg_controls: negC,
|
|
194
|
+
neg_caught: negCaught,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return { catalogue_size: catalogue.length, fixtures: totalFixtures, hosts: summaries };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function parseArgs(argv: string[]): { skills: string[]; hosts: string[]; dryRun: boolean } {
|
|
201
|
+
let skills = ['image-analyser'];
|
|
202
|
+
let hosts = ['anthropic', 'openai', 'gemini'];
|
|
203
|
+
let dryRun = false;
|
|
204
|
+
for (let i = 0; i < argv.length; i++) {
|
|
205
|
+
if (argv[i] === '--dry-run') dryRun = true;
|
|
206
|
+
else if (argv[i] === '--skills') skills = (argv[++i] ?? '').split(',').filter(Boolean);
|
|
207
|
+
else if (argv[i] === '--hosts') hosts = (argv[++i] ?? '').split(',').filter(Boolean);
|
|
208
|
+
}
|
|
209
|
+
return { skills, hosts, dryRun };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function main(): Promise<number> {
|
|
213
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
214
|
+
const out = await runSmoke(opts);
|
|
215
|
+
const lines: string[] = [];
|
|
216
|
+
lines.push(`cross_model_smoke · catalogue=${out.catalogue_size} skills · fixtures=${out.fixtures} · ${opts.dryRun ? 'DRY-RUN (mock)' : 'LIVE'}`);
|
|
217
|
+
lines.push('host model queries pass% parse% neg-ctrl in_tok out_tok ~$');
|
|
218
|
+
for (const h of out.hosts) {
|
|
219
|
+
lines.push(
|
|
220
|
+
`${h.host.padEnd(11)} ${h.model.padEnd(27)} ${String(h.queries).padStart(7)} ${(h.pass_rate * 100).toFixed(0).padStart(5)} ${(h.parse_rate * 100).toFixed(0).padStart(6)} ${String(h.neg_caught + '/' + h.neg_controls).padStart(8)} ${String(h.input_tokens).padStart(7)} ${String(h.output_tokens).padStart(7)} ${h.cost_usd_estimate.toFixed(4)}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
process.stdout.write(lines.join('\n') + '\n');
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Entry guard: only run main when invoked directly (not when imported by tests).
|
|
228
|
+
const _invokedDirectly = (() => {
|
|
229
|
+
try {
|
|
230
|
+
const argv1 = process.argv[1];
|
|
231
|
+
if (!argv1) return false;
|
|
232
|
+
return fs.realpathSync(argv1) === fs.realpathSync(new URL(import.meta.url).pathname);
|
|
233
|
+
} catch {
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
})();
|
|
237
|
+
if (_invokedDirectly) {
|
|
238
|
+
main().then(
|
|
239
|
+
(code) => process.exit(code),
|
|
240
|
+
(err) => {
|
|
241
|
+
process.stderr.write(String(err?.stack ?? err) + '\n');
|
|
242
|
+
process.exit(1);
|
|
243
|
+
},
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -64,6 +64,18 @@ concerns:
|
|
|
64
64
|
script: src/scripts/hooks/block_no_verify.ts
|
|
65
65
|
args: []
|
|
66
66
|
fail_closed: true
|
|
67
|
+
# token-saving Phase 3 — PreToolUse RTK-wrap nudge. DETERMINISTIC: keys off a
|
|
68
|
+
# live `which rtk` probe (not the self-reported flag). When rtk is installed
|
|
69
|
+
# and the agent is about to run a single verbose CLI command that is NOT
|
|
70
|
+
# completeness-critical, warns "re-run wrapped with rtk" (60–90% fewer output
|
|
71
|
+
# tokens). Warn only — never blocks (the v1 contract has no transparent
|
|
72
|
+
# rewrite); never fires when rtk is absent (silent plain-command fallback).
|
|
73
|
+
# Default-OFF (hooks.rtk_wrap.enabled). fail_closed: false — never break a
|
|
74
|
+
# command for a token optimization.
|
|
75
|
+
rtk-wrap:
|
|
76
|
+
script: src/scripts/hooks/rtk_wrap_hook.ts
|
|
77
|
+
args: []
|
|
78
|
+
fail_closed: false
|
|
67
79
|
# Phase 2 of road-to-hooks-actually-fire-in-consumers — session_start
|
|
68
80
|
# gate that surfaces the marketplace-install-but-unscaffolded shape.
|
|
69
81
|
# Writes .augment/.first-run-action-needed.md + one stderr line so
|
|
@@ -96,7 +108,7 @@ platforms:
|
|
|
96
108
|
session_start: [chat-history, first-run-gate, onboarding-gate, verify-before-complete, minimal-safe-diff, profile-staleness, wrapper-freshness]
|
|
97
109
|
session_end: [chat-history]
|
|
98
110
|
stop: [chat-history, verify-before-complete]
|
|
99
|
-
pre_tool_use: [block-no-verify]
|
|
111
|
+
pre_tool_use: [block-no-verify, rtk-wrap]
|
|
100
112
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
|
|
101
113
|
|
|
102
114
|
claude:
|
|
@@ -104,7 +116,7 @@ platforms:
|
|
|
104
116
|
session_end: [chat-history]
|
|
105
117
|
stop: [chat-history, verify-before-complete]
|
|
106
118
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
107
|
-
pre_tool_use: [block-no-verify]
|
|
119
|
+
pre_tool_use: [block-no-verify, rtk-wrap]
|
|
108
120
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
|
|
109
121
|
|
|
110
122
|
# Cowork — the Claude desktop app's local-agent-mode runtime, built
|
|
@@ -126,7 +138,7 @@ platforms:
|
|
|
126
138
|
session_end: [chat-history]
|
|
127
139
|
stop: [chat-history, verify-before-complete]
|
|
128
140
|
user_prompt_submit: [chat-history, verify-before-complete, minimal-safe-diff]
|
|
129
|
-
pre_tool_use: [block-no-verify]
|
|
141
|
+
pre_tool_use: [block-no-verify, rtk-wrap]
|
|
130
142
|
post_tool_use: [chat-history, roadmap-progress, context-hygiene, verify-before-complete, minimal-safe-diff, injection-scan]
|
|
131
143
|
|
|
132
144
|
# Phase 7.5 — Cursor. `.cursor/hooks.json` (project) is read by the
|