vigiles 12.0.0 → 12.2.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/README.md +22 -11
- package/action.yml +73 -0
- package/dist/audit-report.d.ts +11 -0
- package/dist/audit-report.js +1 -0
- package/dist/audit-report.template.html +36 -26
- package/dist/claude-code.d.ts +2 -0
- package/dist/claude-code.js +9 -1
- package/dist/cli-commands.d.ts +1 -1
- package/dist/cli-commands.js +0 -1
- package/dist/cli.js +96 -135
- package/dist/core/rule-meta.js +8 -0
- package/dist/core/skill-description-budget.d.ts +42 -0
- package/dist/core/skill-description-budget.js +47 -0
- package/dist/core/types.d.ts +9 -0
- package/dist/core/validate.js +3 -0
- package/dist/doc-command-coverage.d.ts +20 -0
- package/dist/doc-command-coverage.js +60 -0
- package/dist/eval-cost.d.ts +75 -0
- package/dist/eval-cost.js +134 -0
- package/dist/eval.d.ts +4 -0
- package/dist/eval.js +46 -6
- package/dist/observe.d.ts +109 -0
- package/dist/observe.js +164 -0
- package/dist/research-index.d.ts +31 -0
- package/dist/research-index.js +48 -0
- package/dist/scaffold-test.js +3 -2
- package/dist/scan-behavioral.d.ts +42 -0
- package/dist/scan-behavioral.js +67 -0
- package/dist/scan.d.ts +3 -23
- package/dist/scan.js +18 -69
- package/dist/setup-plan.d.ts +1 -1
- package/dist/setup-plan.js +1 -0
- package/package.json +1 -1
- package/skills/adopt-spec/SKILL.md +10 -1
- package/skills/debug-my-harness/SKILL.md +56 -0
- package/skills/edit-spec/SKILL.md +1 -0
- package/skills/strengthen/SKILL.md +4 -0
- package/skills/test-harness/SKILL.md +17 -0
- package/dist/core/hook-spec.d.ts +0 -74
- package/dist/core/hook-spec.js +0 -130
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Eval cost transparency — make what a real-model run SPENT impossible to miss.
|
|
3
|
+
* vigiles's whole affordability pitch is "runs on your Claude subscription, not a
|
|
4
|
+
* metered API," so every real-model run should say — out loud — how many tokens it
|
|
5
|
+
* spent, the API-equivalent dollar cost, and (loudly) if it was billed to a
|
|
6
|
+
* METERED API key instead of your subscription.
|
|
7
|
+
*
|
|
8
|
+
* HONEST SCOPE: we surface tokens + the API-equivalent `$` (`total_cost_usd` from
|
|
9
|
+
* the `claude` CLI) + a running session tally. We deliberately do NOT show a
|
|
10
|
+
* "% of your subscription" — Anthropic does not expose a subscription's quota or
|
|
11
|
+
* limit programmatically (and the real limits are rolling rate windows, not a
|
|
12
|
+
* dollar bucket), so any percentage would be fiction. See docs/eval-architecture.md.
|
|
13
|
+
*
|
|
14
|
+
* Pure + injectable (env + an output sink), so the whole thing is unit-tested
|
|
15
|
+
* without a model or a real key.
|
|
16
|
+
*/
|
|
17
|
+
import type { EvalUsage, ArmUsage, EvalReport } from "./eval.js";
|
|
18
|
+
/** A normalized cost/token snapshot — the common shape a report renders from. */
|
|
19
|
+
export interface CostSummary {
|
|
20
|
+
/** API-equivalent cost (`total_cost_usd`) — the number that matters. */
|
|
21
|
+
readonly costUsd: number;
|
|
22
|
+
readonly inputTokens: number;
|
|
23
|
+
readonly outputTokens: number;
|
|
24
|
+
readonly cacheCreationTokens: number;
|
|
25
|
+
readonly cacheReadTokens: number;
|
|
26
|
+
}
|
|
27
|
+
/** Total tokens across all four billing buckets. */
|
|
28
|
+
export declare function totalTokens(c: CostSummary): number;
|
|
29
|
+
/** A per-run {@link EvalUsage} → the common snapshot. */
|
|
30
|
+
export declare function costFromRun(u: EvalUsage): CostSummary;
|
|
31
|
+
/** An aggregated per-arm {@link ArmUsage} → the common snapshot. */
|
|
32
|
+
export declare function costFromArm(u: ArmUsage): CostSummary;
|
|
33
|
+
/** Sum any number of snapshots (e.g. every arm of an A/B). */
|
|
34
|
+
export declare function sumCosts(costs: readonly CostSummary[]): CostSummary;
|
|
35
|
+
/** The whole-{@link EvalReport} cost — every arm summed. */
|
|
36
|
+
export declare function costFromEvalReport(report: EvalReport): CostSummary;
|
|
37
|
+
/**
|
|
38
|
+
* How the run was billed. `metered` is true when a real Anthropic API key is in
|
|
39
|
+
* the environment — the `claude` CLI bills those PER TOKEN, whereas a
|
|
40
|
+
* subscription run (auth via `~/.claude`, no key var) costs $0 beyond the sub.
|
|
41
|
+
* The mock/deterministic tier never reaches here (it has no real cost), so a
|
|
42
|
+
* present key means a real metered run.
|
|
43
|
+
*/
|
|
44
|
+
export interface Billing {
|
|
45
|
+
readonly metered: boolean;
|
|
46
|
+
/** Which env var carried the key (for the actionable "unset X" message). */
|
|
47
|
+
readonly keyVar: string | null;
|
|
48
|
+
}
|
|
49
|
+
export declare function detectBilling(env?: NodeJS.ProcessEnv): Billing;
|
|
50
|
+
/** Add a run to the running session total and return the new total. */
|
|
51
|
+
export declare function recordSessionCost(c: CostSummary): CostSummary;
|
|
52
|
+
/** The session total so far. */
|
|
53
|
+
export declare function sessionCost(): CostSummary;
|
|
54
|
+
/** Reset the session tally (test seam). */
|
|
55
|
+
export declare function resetSessionCost(): void;
|
|
56
|
+
/**
|
|
57
|
+
* The human-readable cost block for a run. Shows tokens + API-equivalent `$`, the
|
|
58
|
+
* billed-to line (a LOUD warning + an actionable fix when metered, a green ✅ when
|
|
59
|
+
* on the subscription), and the session tally when it exceeds this run.
|
|
60
|
+
*/
|
|
61
|
+
export declare function formatCostSummary(c: CostSummary, opts: {
|
|
62
|
+
billing: Billing;
|
|
63
|
+
session?: CostSummary | null;
|
|
64
|
+
}): string;
|
|
65
|
+
/**
|
|
66
|
+
* Record `c` into the session tally and emit its cost block. The default sink is
|
|
67
|
+
* stderr (so a run's cost never pollutes `--json` stdout). Injectable env + sink
|
|
68
|
+
* keep it fully testable. Returns the emitted text (also handy for a skill to
|
|
69
|
+
* relay to the user).
|
|
70
|
+
*/
|
|
71
|
+
export declare function emitCostSummary(c: CostSummary, opts?: {
|
|
72
|
+
env?: NodeJS.ProcessEnv;
|
|
73
|
+
out?: (s: string) => void;
|
|
74
|
+
}): string;
|
|
75
|
+
//# sourceMappingURL=eval-cost.d.ts.map
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.totalTokens = totalTokens;
|
|
4
|
+
exports.costFromRun = costFromRun;
|
|
5
|
+
exports.costFromArm = costFromArm;
|
|
6
|
+
exports.sumCosts = sumCosts;
|
|
7
|
+
exports.costFromEvalReport = costFromEvalReport;
|
|
8
|
+
exports.detectBilling = detectBilling;
|
|
9
|
+
exports.recordSessionCost = recordSessionCost;
|
|
10
|
+
exports.sessionCost = sessionCost;
|
|
11
|
+
exports.resetSessionCost = resetSessionCost;
|
|
12
|
+
exports.formatCostSummary = formatCostSummary;
|
|
13
|
+
exports.emitCostSummary = emitCostSummary;
|
|
14
|
+
const ZERO = {
|
|
15
|
+
costUsd: 0,
|
|
16
|
+
inputTokens: 0,
|
|
17
|
+
outputTokens: 0,
|
|
18
|
+
cacheCreationTokens: 0,
|
|
19
|
+
cacheReadTokens: 0,
|
|
20
|
+
};
|
|
21
|
+
/** Total tokens across all four billing buckets. */
|
|
22
|
+
function totalTokens(c) {
|
|
23
|
+
return (c.inputTokens + c.outputTokens + c.cacheCreationTokens + c.cacheReadTokens);
|
|
24
|
+
}
|
|
25
|
+
/** A per-run {@link EvalUsage} → the common snapshot. */
|
|
26
|
+
function costFromRun(u) {
|
|
27
|
+
return {
|
|
28
|
+
costUsd: u.costUsd,
|
|
29
|
+
inputTokens: u.inputTokens,
|
|
30
|
+
outputTokens: u.outputTokens,
|
|
31
|
+
cacheCreationTokens: u.cacheCreationTokens,
|
|
32
|
+
cacheReadTokens: u.cacheReadTokens,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/** An aggregated per-arm {@link ArmUsage} → the common snapshot. */
|
|
36
|
+
function costFromArm(u) {
|
|
37
|
+
return {
|
|
38
|
+
costUsd: u.totalCostUsd,
|
|
39
|
+
inputTokens: u.totalInputTokens,
|
|
40
|
+
outputTokens: u.totalOutputTokens,
|
|
41
|
+
cacheCreationTokens: u.totalCacheCreationTokens,
|
|
42
|
+
cacheReadTokens: u.totalCacheReadTokens,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Sum any number of snapshots (e.g. every arm of an A/B). */
|
|
46
|
+
function sumCosts(costs) {
|
|
47
|
+
return costs.reduce((a, c) => ({
|
|
48
|
+
costUsd: a.costUsd + c.costUsd,
|
|
49
|
+
inputTokens: a.inputTokens + c.inputTokens,
|
|
50
|
+
outputTokens: a.outputTokens + c.outputTokens,
|
|
51
|
+
cacheCreationTokens: a.cacheCreationTokens + c.cacheCreationTokens,
|
|
52
|
+
cacheReadTokens: a.cacheReadTokens + c.cacheReadTokens,
|
|
53
|
+
}), ZERO);
|
|
54
|
+
}
|
|
55
|
+
/** The whole-{@link EvalReport} cost — every arm summed. */
|
|
56
|
+
function costFromEvalReport(report) {
|
|
57
|
+
return sumCosts(Object.values(report.arms).map((a) => costFromArm(a.usage)));
|
|
58
|
+
}
|
|
59
|
+
const KEY_VARS = ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"];
|
|
60
|
+
function detectBilling(env = process.env) {
|
|
61
|
+
for (const v of KEY_VARS) {
|
|
62
|
+
const val = env[v];
|
|
63
|
+
if (val !== undefined && val.trim() !== "")
|
|
64
|
+
return { metered: true, keyVar: v };
|
|
65
|
+
}
|
|
66
|
+
return { metered: false, keyVar: null };
|
|
67
|
+
}
|
|
68
|
+
// --- Session tally (within one process) ------------------------------------
|
|
69
|
+
let SESSION = ZERO;
|
|
70
|
+
/** Add a run to the running session total and return the new total. */
|
|
71
|
+
function recordSessionCost(c) {
|
|
72
|
+
SESSION = sumCosts([SESSION, c]);
|
|
73
|
+
return SESSION;
|
|
74
|
+
}
|
|
75
|
+
/** The session total so far. */
|
|
76
|
+
function sessionCost() {
|
|
77
|
+
return SESSION;
|
|
78
|
+
}
|
|
79
|
+
/** Reset the session tally (test seam). */
|
|
80
|
+
function resetSessionCost() {
|
|
81
|
+
SESSION = ZERO;
|
|
82
|
+
}
|
|
83
|
+
// --- Formatting ------------------------------------------------------------
|
|
84
|
+
function fmtUsd(n) {
|
|
85
|
+
return `$${n < 0.01 && n > 0 ? n.toFixed(4) : n.toFixed(2)}`;
|
|
86
|
+
}
|
|
87
|
+
function fmtInt(n) {
|
|
88
|
+
return Math.round(n).toLocaleString("en-US");
|
|
89
|
+
}
|
|
90
|
+
function fmtK(n) {
|
|
91
|
+
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n));
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The human-readable cost block for a run. Shows tokens + API-equivalent `$`, the
|
|
95
|
+
* billed-to line (a LOUD warning + an actionable fix when metered, a green ✅ when
|
|
96
|
+
* on the subscription), and the session tally when it exceeds this run.
|
|
97
|
+
*/
|
|
98
|
+
function formatCostSummary(c, opts) {
|
|
99
|
+
const lines = [];
|
|
100
|
+
lines.push(` Spent: ${fmtInt(totalTokens(c))} tokens ` +
|
|
101
|
+
`(${fmtK(c.inputTokens)} in · ${fmtK(c.outputTokens)} out · ${fmtK(c.cacheReadTokens)} cache) ` +
|
|
102
|
+
`· ~${fmtUsd(c.costUsd)} API-equivalent`);
|
|
103
|
+
if (opts.billing.metered) {
|
|
104
|
+
const v = opts.billing.keyVar ?? "ANTHROPIC_API_KEY";
|
|
105
|
+
lines.push(` ⚠ Billed to: METERED API (${v} is set) — you paid ~${fmtUsd(c.costUsd)} this run.`, ` Run it free on your Claude subscription: unset ${v}, then \`claude login\`.`);
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
lines.push(` Billed to: your Claude subscription — $0 metered ✅`);
|
|
109
|
+
}
|
|
110
|
+
if (opts.session && opts.session.costUsd > c.costUsd) {
|
|
111
|
+
lines.push(` Session so far: ${fmtInt(totalTokens(opts.session))} tokens · ~${fmtUsd(opts.session.costUsd)} API-equivalent`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join("\n");
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Record `c` into the session tally and emit its cost block. The default sink is
|
|
117
|
+
* stderr (so a run's cost never pollutes `--json` stdout). Injectable env + sink
|
|
118
|
+
* keep it fully testable. Returns the emitted text (also handy for a skill to
|
|
119
|
+
* relay to the user).
|
|
120
|
+
*/
|
|
121
|
+
function emitCostSummary(c, opts = {}) {
|
|
122
|
+
// A no-cost run (a replay, a zero-trial eval) has nothing to report.
|
|
123
|
+
if (totalTokens(c) === 0 && c.costUsd === 0)
|
|
124
|
+
return "";
|
|
125
|
+
const billing = detectBilling(opts.env ?? process.env);
|
|
126
|
+
const session = recordSessionCost(c);
|
|
127
|
+
const text = formatCostSummary(c, { billing, session });
|
|
128
|
+
(opts.out ??
|
|
129
|
+
((s) => {
|
|
130
|
+
console.error(s);
|
|
131
|
+
}))(text);
|
|
132
|
+
return text;
|
|
133
|
+
}
|
|
134
|
+
//# sourceMappingURL=eval-cost.js.map
|
package/dist/eval.d.ts
CHANGED
|
@@ -327,6 +327,8 @@ export interface CheckRate {
|
|
|
327
327
|
export interface CheckReport {
|
|
328
328
|
readonly n: number;
|
|
329
329
|
readonly perCheck: readonly CheckRate[];
|
|
330
|
+
/** Cost / latency / token totals for the run (the same source as `runEval`). */
|
|
331
|
+
readonly usage: ArmUsage;
|
|
330
332
|
}
|
|
331
333
|
/**
|
|
332
334
|
* Score a check vocabulary across trials — the scored counterpart to
|
|
@@ -714,6 +716,8 @@ export interface TriggerRateReport {
|
|
|
714
716
|
* `n` means the measurement is thin (e.g. a Codex usage limit was hit); re-run.
|
|
715
717
|
*/
|
|
716
718
|
readonly errored?: number;
|
|
719
|
+
/** Cost / tokens SPENT across all runs (relevant + irrelevant) — feeds the cost summary. */
|
|
720
|
+
readonly usage: ArmUsage;
|
|
717
721
|
}
|
|
718
722
|
/**
|
|
719
723
|
* An eval-tier transport: how to RUN a real harness turn and PARSE its output.
|
package/dist/eval.js
CHANGED
|
@@ -65,8 +65,10 @@ const node_child_process_1 = require("node:child_process");
|
|
|
65
65
|
const node_fs_1 = require("node:fs");
|
|
66
66
|
const node_os_1 = require("node:os");
|
|
67
67
|
const node_path_1 = require("node:path");
|
|
68
|
+
const observe_js_1 = require("./observe.js");
|
|
68
69
|
const plugin_loader_js_1 = require("./adapters/claude-code/plugin-loader.js");
|
|
69
70
|
const runtime_js_1 = require("./adapters/claude-code/runtime.js");
|
|
71
|
+
const eval_cost_js_1 = require("./eval-cost.js");
|
|
70
72
|
const proofs_js_1 = require("./core/proofs.js");
|
|
71
73
|
const harness_test_js_1 = require("./harness-test.js");
|
|
72
74
|
const eval_cache_js_1 = require("./eval-cache.js");
|
|
@@ -144,7 +146,11 @@ function spawnAgent(a) {
|
|
|
144
146
|
* `runEvalWith` with the real agent runner.
|
|
145
147
|
*/
|
|
146
148
|
async function runEval(spec) {
|
|
147
|
-
|
|
149
|
+
const report = await runEvalWith(spec, spawnAgent);
|
|
150
|
+
// Surface what the run spent — tokens + API-equivalent $, and a LOUD warning if
|
|
151
|
+
// it was billed to a metered API key instead of the subscription. See eval-cost.ts.
|
|
152
|
+
(0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromEvalReport)(report));
|
|
153
|
+
return report;
|
|
148
154
|
}
|
|
149
155
|
/**
|
|
150
156
|
* Score a check vocabulary across trials — the scored counterpart to
|
|
@@ -201,6 +207,7 @@ async function measureWith(spec, runner) {
|
|
|
201
207
|
n: s?.n ?? 0,
|
|
202
208
|
};
|
|
203
209
|
}),
|
|
210
|
+
usage: arm?.usage ?? aggregateUsage([]),
|
|
204
211
|
};
|
|
205
212
|
}
|
|
206
213
|
finally {
|
|
@@ -211,7 +218,9 @@ async function measureWith(spec, runner) {
|
|
|
211
218
|
/* v8 ignore start -- real claude subprocess; thin wrapper over measureWith */
|
|
212
219
|
/** Score a check vocabulary across trials against the real `claude` CLI. */
|
|
213
220
|
async function measure(spec) {
|
|
214
|
-
|
|
221
|
+
const report = await measureWith(spec, spawnAgent);
|
|
222
|
+
(0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromArm)(report.usage));
|
|
223
|
+
return report;
|
|
215
224
|
}
|
|
216
225
|
/** Score checks across arms (injectable runner). Reuses `runEvalWith`. */
|
|
217
226
|
async function measureArmsWith(spec, runner) {
|
|
@@ -245,6 +254,7 @@ async function measureArmsWith(spec, runner) {
|
|
|
245
254
|
n: s?.n ?? 0,
|
|
246
255
|
};
|
|
247
256
|
}),
|
|
257
|
+
usage: arm.usage,
|
|
248
258
|
};
|
|
249
259
|
}
|
|
250
260
|
return { arms };
|
|
@@ -278,7 +288,10 @@ function stubArmPluginDirs(arms) {
|
|
|
278
288
|
/* v8 ignore start -- real claude subprocess; thin wrapper over measureArmsWith */
|
|
279
289
|
/** Score checks across arms against the real `claude` CLI. */
|
|
280
290
|
async function measureArms(spec) {
|
|
281
|
-
|
|
291
|
+
const report = await measureArmsWith(spec, spawnAgent);
|
|
292
|
+
// Sum every arm's spend — an A/B run pays for both arms.
|
|
293
|
+
(0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.sumCosts)(Object.values(report.arms).map((a) => (0, eval_cost_js_1.costFromArm)(a.usage))));
|
|
294
|
+
return report;
|
|
282
295
|
}
|
|
283
296
|
/* v8 ignore stop */
|
|
284
297
|
/**
|
|
@@ -1496,13 +1509,17 @@ async function runTriggerTrial(prompt, cfg, runner) {
|
|
|
1496
1509
|
pluginDir: cfg.pluginDir,
|
|
1497
1510
|
timeoutMs: cfg.timeoutMs,
|
|
1498
1511
|
});
|
|
1512
|
+
// Usage comes from the parser (harness-neutral: Claude + Codex both fill it),
|
|
1513
|
+
// and a run costs tokens even when it errors — so accumulate it either way.
|
|
1514
|
+
const ctx = makeContext(cwd, out, cfg.parse);
|
|
1499
1515
|
// An errored/rate-limited turn is NOT a "skill didn't fire" miss — it's
|
|
1500
1516
|
// excluded from the rate, so e.g. a Codex usage limit can't read as recall 0.
|
|
1501
1517
|
if (cfg.runError?.(out))
|
|
1502
|
-
return { fired: 0, errored: true };
|
|
1518
|
+
return { fired: 0, errored: true, usage: ctx.usage };
|
|
1503
1519
|
return {
|
|
1504
|
-
fired: cfg.fired(
|
|
1520
|
+
fired: cfg.fired(ctx) ? 1 : 0,
|
|
1505
1521
|
errored: false,
|
|
1522
|
+
usage: ctx.usage,
|
|
1506
1523
|
};
|
|
1507
1524
|
}
|
|
1508
1525
|
finally {
|
|
@@ -1539,6 +1556,7 @@ async function runTriggerSet(prompts, cfg, runner) {
|
|
|
1539
1556
|
fired: firedBy.reduce((a, b) => a + b, 0),
|
|
1540
1557
|
n: trialsBy.reduce((a, b) => a + b, 0),
|
|
1541
1558
|
errored,
|
|
1559
|
+
usages: outcomes.map((o) => o.usage),
|
|
1542
1560
|
};
|
|
1543
1561
|
}
|
|
1544
1562
|
/**
|
|
@@ -1624,6 +1642,7 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
|
|
|
1624
1642
|
perPrompt: relevant.perPrompt,
|
|
1625
1643
|
competitors,
|
|
1626
1644
|
errored: positiveOrUndefined(relevant.errored),
|
|
1645
|
+
usage: aggregateUsage(relevant.usages),
|
|
1627
1646
|
};
|
|
1628
1647
|
if ((spec.irrelevantPrompts?.length ?? 0) === 0)
|
|
1629
1648
|
return base;
|
|
@@ -1635,6 +1654,8 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
|
|
|
1635
1654
|
falsePositiveRate: irrelevant.n > 0 ? irrelevant.fired / irrelevant.n : 0,
|
|
1636
1655
|
precision: fires > 0 ? relevant.fired / fires : undefined,
|
|
1637
1656
|
perIrrelevant: irrelevant.perPrompt,
|
|
1657
|
+
// Total cost across BOTH sets (the precision runs cost tokens too).
|
|
1658
|
+
usage: aggregateUsage([...relevant.usages, ...irrelevant.usages]),
|
|
1638
1659
|
};
|
|
1639
1660
|
});
|
|
1640
1661
|
}
|
|
@@ -1654,7 +1675,26 @@ async function measureTriggerRateWith(spec, runner, parse = parseClaudeRun, runE
|
|
|
1654
1675
|
*/
|
|
1655
1676
|
async function measureTriggerRate(spec, opts = {}) {
|
|
1656
1677
|
const d = opts.evalDriver ?? exports.claudeEvalDriver;
|
|
1657
|
-
|
|
1678
|
+
const report = await measureTriggerRateWith(spec, d.runner, d.parse, d.runError, d.harness ?? "claude-code");
|
|
1679
|
+
// Surface what the run spent (tokens + API-equivalent $ + metered warning).
|
|
1680
|
+
(0, eval_cost_js_1.emitCostSummary)((0, eval_cost_js_1.costFromArm)(report.usage));
|
|
1681
|
+
// Feed the flight recorder: recall (+ precision when measured) for this skill.
|
|
1682
|
+
const evalName = spec.name ?? "trigger-rate";
|
|
1683
|
+
(0, observe_js_1.appendObservation)({
|
|
1684
|
+
kind: "eval",
|
|
1685
|
+
name: evalName,
|
|
1686
|
+
metric: "recall",
|
|
1687
|
+
value: report.rate,
|
|
1688
|
+
});
|
|
1689
|
+
if (report.precision !== undefined) {
|
|
1690
|
+
(0, observe_js_1.appendObservation)({
|
|
1691
|
+
kind: "eval",
|
|
1692
|
+
name: evalName,
|
|
1693
|
+
metric: "precision",
|
|
1694
|
+
value: report.precision,
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
return report;
|
|
1658
1698
|
}
|
|
1659
1699
|
/* v8 ignore stop */
|
|
1660
1700
|
/** Format a trigger-rate report: overall %, then each prompt's rate. */
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/** Bumped when the record shape changes in a non-additive way. */
|
|
2
|
+
export declare const OBSERVE_VERSION = 1;
|
|
3
|
+
/** The ledger filename under the `.vigiles/` directory. */
|
|
4
|
+
export declare const LEDGER_FILE = "runs.jsonl";
|
|
5
|
+
/** Fields every record carries; `v`/`ts` are stamped by the writer, not the caller. */
|
|
6
|
+
export interface ObservationBase {
|
|
7
|
+
/** schema version (`OBSERVE_VERSION`) */
|
|
8
|
+
v: number;
|
|
9
|
+
/** ISO-8601 timestamp */
|
|
10
|
+
ts: string;
|
|
11
|
+
}
|
|
12
|
+
/** A gate/hook decision observed in a real session. */
|
|
13
|
+
export interface HookObservation extends ObservationBase {
|
|
14
|
+
kind: "hook";
|
|
15
|
+
event: string;
|
|
16
|
+
decision: "allow" | "deny" | "ask";
|
|
17
|
+
/** the compiled-hook rule/name, when known */
|
|
18
|
+
rule?: string;
|
|
19
|
+
/** enforce actually blocked; observe recorded a would-be block */
|
|
20
|
+
mode?: "enforce" | "observe";
|
|
21
|
+
/** the bash command inspected, when the gate keyed on one */
|
|
22
|
+
cmd?: string;
|
|
23
|
+
reason?: string;
|
|
24
|
+
}
|
|
25
|
+
/** A subagent tool-contract decision (the PreToolUse rail). */
|
|
26
|
+
export interface AgentObservation extends ObservationBase {
|
|
27
|
+
kind: "agent";
|
|
28
|
+
/** the dispatched subagent */
|
|
29
|
+
name: string;
|
|
30
|
+
tool: string;
|
|
31
|
+
allowed: boolean;
|
|
32
|
+
reason?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Whether a skill fired for a turn (behavioral surface — best-effort per harness). */
|
|
35
|
+
export interface SkillObservation extends ObservationBase {
|
|
36
|
+
kind: "skill";
|
|
37
|
+
name: string;
|
|
38
|
+
fired: boolean;
|
|
39
|
+
}
|
|
40
|
+
/** A measured eval outcome (recall, cost, a check rate, …). */
|
|
41
|
+
export interface EvalObservation extends ObservationBase {
|
|
42
|
+
kind: "eval";
|
|
43
|
+
name: string;
|
|
44
|
+
metric: string;
|
|
45
|
+
value: number;
|
|
46
|
+
}
|
|
47
|
+
/** A capability/blast-radius change observed at PR time. */
|
|
48
|
+
export interface CapabilityDiffObservation extends ObservationBase {
|
|
49
|
+
kind: "capability-diff";
|
|
50
|
+
pr?: number;
|
|
51
|
+
added: string[];
|
|
52
|
+
removed?: string[];
|
|
53
|
+
/** true when the change loosened the agent's effect surface */
|
|
54
|
+
widened: boolean;
|
|
55
|
+
}
|
|
56
|
+
/** The discriminated union every reader narrows on `kind`. */
|
|
57
|
+
export type ObservationRecord = HookObservation | AgentObservation | SkillObservation | EvalObservation | CapabilityDiffObservation;
|
|
58
|
+
/** What a caller supplies — the writer stamps `v` + `ts`. */
|
|
59
|
+
export type ObservationInput = Omit<HookObservation, "v" | "ts"> | Omit<AgentObservation, "v" | "ts"> | Omit<SkillObservation, "v" | "ts"> | Omit<EvalObservation, "v" | "ts"> | Omit<CapabilityDiffObservation, "v" | "ts">;
|
|
60
|
+
/** Serialize one record to a single JSONL line (trailing newline included). */
|
|
61
|
+
export declare function formatObservation(record: ObservationRecord): string;
|
|
62
|
+
/**
|
|
63
|
+
* Append one observation to `<cwd>/.vigiles/runs.jsonl`. Best-effort: any failure is
|
|
64
|
+
* swallowed so recording can never break a live session (the `ts`/`clock` here is a
|
|
65
|
+
* runtime side effect, intentionally — this module records reality, it is not a spec).
|
|
66
|
+
*/
|
|
67
|
+
export declare function appendObservation(input: ObservationInput, cwd?: string): void;
|
|
68
|
+
/**
|
|
69
|
+
* Read the ledger back. Tolerant by design: a malformed or partially-written line is
|
|
70
|
+
* skipped rather than throwing, so a torn append never nukes the whole read.
|
|
71
|
+
*/
|
|
72
|
+
export declare function readObservations(cwd?: string): ObservationRecord[];
|
|
73
|
+
/** Filter the ledger to one record kind, narrowing the type for the caller. */
|
|
74
|
+
export declare function observationsOfKind<K extends ObservationRecord["kind"]>(records: readonly ObservationRecord[], kind: K): Extract<ObservationRecord, {
|
|
75
|
+
kind: K;
|
|
76
|
+
}>[];
|
|
77
|
+
/** A denial rendered as a structured `{label, reason}` — the shared shape the
|
|
78
|
+
* terminal line and the JSON summary both derive from (one-detector-no-drift). */
|
|
79
|
+
export interface LedgerDenial {
|
|
80
|
+
readonly label: string;
|
|
81
|
+
readonly reason: string;
|
|
82
|
+
}
|
|
83
|
+
/** Per-kind record count. */
|
|
84
|
+
export interface LedgerCount {
|
|
85
|
+
readonly kind: ObservationRecord["kind"];
|
|
86
|
+
readonly count: number;
|
|
87
|
+
}
|
|
88
|
+
/** The structured ledger summary carried in the versioned AuditReport JSON. */
|
|
89
|
+
export interface LedgerSummary {
|
|
90
|
+
readonly total: number;
|
|
91
|
+
readonly counts: readonly LedgerCount[];
|
|
92
|
+
readonly denials: number;
|
|
93
|
+
/** The most recent denials (blocked gates / out-of-contract tool calls). */
|
|
94
|
+
readonly recentDenials: readonly LedgerDenial[];
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The structured ledger summary for the AuditReport JSON — total, per-kind counts,
|
|
98
|
+
* and the recent denials. `undefined` when nothing is recorded, so the report field
|
|
99
|
+
* stays absent (additive/optional). Shares `isDenial`/`denialParts` with the
|
|
100
|
+
* terminal `formatLedgerSummary` so the two can't drift.
|
|
101
|
+
*/
|
|
102
|
+
export declare function summarizeObservations(records: readonly ObservationRecord[]): LedgerSummary | undefined;
|
|
103
|
+
/**
|
|
104
|
+
* A compact human summary of the ledger for `vigiles audit` — total, counts by kind,
|
|
105
|
+
* and the recent high-signal denials. Empty string when there is nothing recorded, so
|
|
106
|
+
* the caller can skip the section entirely.
|
|
107
|
+
*/
|
|
108
|
+
export declare function formatLedgerSummary(records: readonly ObservationRecord[]): string;
|
|
109
|
+
//# sourceMappingURL=observe.d.ts.map
|
package/dist/observe.js
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.LEDGER_FILE = exports.OBSERVE_VERSION = void 0;
|
|
4
|
+
exports.formatObservation = formatObservation;
|
|
5
|
+
exports.appendObservation = appendObservation;
|
|
6
|
+
exports.readObservations = readObservations;
|
|
7
|
+
exports.observationsOfKind = observationsOfKind;
|
|
8
|
+
exports.summarizeObservations = summarizeObservations;
|
|
9
|
+
exports.formatLedgerSummary = formatLedgerSummary;
|
|
10
|
+
/**
|
|
11
|
+
* The local, agent-readable "flight recorder" ledger — `.vigiles/runs.jsonl`.
|
|
12
|
+
*
|
|
13
|
+
* The connective layer of the four-instrument loop (see the Direction section of
|
|
14
|
+
* CLAUDE.md and `research/harness-observability-direction.md`): every instrument
|
|
15
|
+
* (verify / gate / measure / observe) appends a typed record here, and the file is
|
|
16
|
+
* read three ways off ONE schema — by `vigiles audit`, by the agent debugging its own
|
|
17
|
+
* harness, and (later) by an aggregation surface.
|
|
18
|
+
*
|
|
19
|
+
* Harness-agnostic by construction: the record kinds are neutral concepts, so this
|
|
20
|
+
* lives at the composition/library root (adapters + cli CALL it; it imports no adapter,
|
|
21
|
+
* and it is NOT part of the reference-verification `core/` domain).
|
|
22
|
+
*
|
|
23
|
+
* Append is best-effort — recording must never break a live session.
|
|
24
|
+
*/
|
|
25
|
+
const node_fs_1 = require("node:fs");
|
|
26
|
+
const node_path_1 = require("node:path");
|
|
27
|
+
/** Bumped when the record shape changes in a non-additive way. */
|
|
28
|
+
exports.OBSERVE_VERSION = 1;
|
|
29
|
+
/** The ledger filename under the `.vigiles/` directory. */
|
|
30
|
+
exports.LEDGER_FILE = "runs.jsonl";
|
|
31
|
+
/** Serialize one record to a single JSONL line (trailing newline included). */
|
|
32
|
+
function formatObservation(record) {
|
|
33
|
+
return JSON.stringify(record) + "\n";
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Append one observation to `<cwd>/.vigiles/runs.jsonl`. Best-effort: any failure is
|
|
37
|
+
* swallowed so recording can never break a live session (the `ts`/`clock` here is a
|
|
38
|
+
* runtime side effect, intentionally — this module records reality, it is not a spec).
|
|
39
|
+
*/
|
|
40
|
+
function appendObservation(input, cwd = process.cwd()) {
|
|
41
|
+
try {
|
|
42
|
+
const dir = (0, node_path_1.resolve)(cwd, ".vigiles");
|
|
43
|
+
(0, node_fs_1.mkdirSync)(dir, { recursive: true });
|
|
44
|
+
const record = {
|
|
45
|
+
v: exports.OBSERVE_VERSION,
|
|
46
|
+
ts: new Date().toISOString(),
|
|
47
|
+
...input,
|
|
48
|
+
};
|
|
49
|
+
(0, node_fs_1.appendFileSync)((0, node_path_1.resolve)(dir, exports.LEDGER_FILE), formatObservation(record));
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* best-effort — recording is never allowed to break a session */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Read the ledger back. Tolerant by design: a malformed or partially-written line is
|
|
57
|
+
* skipped rather than throwing, so a torn append never nukes the whole read.
|
|
58
|
+
*/
|
|
59
|
+
function readObservations(cwd = process.cwd()) {
|
|
60
|
+
let raw;
|
|
61
|
+
try {
|
|
62
|
+
raw = (0, node_fs_1.readFileSync)((0, node_path_1.resolve)(cwd, ".vigiles", exports.LEDGER_FILE), "utf8");
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
const out = [];
|
|
68
|
+
for (const line of raw.split("\n")) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!trimmed)
|
|
71
|
+
continue;
|
|
72
|
+
const parsed = tryParseRecord(trimmed);
|
|
73
|
+
if (parsed)
|
|
74
|
+
out.push(parsed);
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
/** Filter the ledger to one record kind, narrowing the type for the caller. */
|
|
79
|
+
function observationsOfKind(records, kind) {
|
|
80
|
+
return records.filter((r) => r.kind === kind);
|
|
81
|
+
}
|
|
82
|
+
/** Was this record a denial (a blocked gate or an out-of-contract tool call)? */
|
|
83
|
+
function isDenial(r) {
|
|
84
|
+
return ((r.kind === "hook" && r.decision === "deny") ||
|
|
85
|
+
(r.kind === "agent" && !r.allowed));
|
|
86
|
+
}
|
|
87
|
+
/** Structured description of a denial (the single source for label + reason). */
|
|
88
|
+
function denialParts(r) {
|
|
89
|
+
if (r.kind === "hook")
|
|
90
|
+
return { label: `hook ${r.rule ?? r.event}`, reason: r.reason ?? "denied" };
|
|
91
|
+
if (r.kind === "agent")
|
|
92
|
+
return {
|
|
93
|
+
label: `${r.name} → ${r.tool}`,
|
|
94
|
+
reason: r.reason ?? "outside contract",
|
|
95
|
+
};
|
|
96
|
+
return { label: r.kind, reason: "" };
|
|
97
|
+
}
|
|
98
|
+
/** One line describing a denial for the terminal summary. */
|
|
99
|
+
function denialLine(r) {
|
|
100
|
+
const d = denialParts(r);
|
|
101
|
+
return ` ✗ ${d.label}: ${d.reason}`;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* The structured ledger summary for the AuditReport JSON — total, per-kind counts,
|
|
105
|
+
* and the recent denials. `undefined` when nothing is recorded, so the report field
|
|
106
|
+
* stays absent (additive/optional). Shares `isDenial`/`denialParts` with the
|
|
107
|
+
* terminal `formatLedgerSummary` so the two can't drift.
|
|
108
|
+
*/
|
|
109
|
+
function summarizeObservations(records) {
|
|
110
|
+
if (records.length === 0)
|
|
111
|
+
return undefined;
|
|
112
|
+
const map = new Map();
|
|
113
|
+
for (const r of records)
|
|
114
|
+
map.set(r.kind, (map.get(r.kind) ?? 0) + 1);
|
|
115
|
+
const denied = records.filter(isDenial);
|
|
116
|
+
return {
|
|
117
|
+
total: records.length,
|
|
118
|
+
counts: Array.from(map.entries()).map(([kind, count]) => ({ kind, count })),
|
|
119
|
+
denials: denied.length,
|
|
120
|
+
recentDenials: denied.slice(-5).map(denialParts),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A compact human summary of the ledger for `vigiles audit` — total, counts by kind,
|
|
125
|
+
* and the recent high-signal denials. Empty string when there is nothing recorded, so
|
|
126
|
+
* the caller can skip the section entirely.
|
|
127
|
+
*/
|
|
128
|
+
function formatLedgerSummary(records) {
|
|
129
|
+
if (records.length === 0)
|
|
130
|
+
return "";
|
|
131
|
+
const lines = [
|
|
132
|
+
`Flight recorder — ${records.length} record${records.length === 1 ? "" : "s"} in .vigiles/${exports.LEDGER_FILE}`,
|
|
133
|
+
];
|
|
134
|
+
const counts = new Map();
|
|
135
|
+
for (const r of records)
|
|
136
|
+
counts.set(r.kind, (counts.get(r.kind) ?? 0) + 1);
|
|
137
|
+
const byKind = Array.from(counts.entries())
|
|
138
|
+
.map(([k, n]) => `${n} ${k}`)
|
|
139
|
+
.join(", ");
|
|
140
|
+
lines.push(` by kind: ${byKind}`);
|
|
141
|
+
const denials = records.filter(isDenial);
|
|
142
|
+
if (denials.length > 0) {
|
|
143
|
+
lines.push(` recent denials (${denials.length}):`);
|
|
144
|
+
for (const r of denials.slice(-5))
|
|
145
|
+
lines.push(denialLine(r));
|
|
146
|
+
}
|
|
147
|
+
return lines.join("\n");
|
|
148
|
+
}
|
|
149
|
+
/** Parse one JSONL line into a record, or `null` if it is not a well-formed record. */
|
|
150
|
+
function tryParseRecord(line) {
|
|
151
|
+
try {
|
|
152
|
+
const value = JSON.parse(line);
|
|
153
|
+
if (value &&
|
|
154
|
+
typeof value === "object" &&
|
|
155
|
+
typeof value.kind === "string") {
|
|
156
|
+
return value;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
/* tolerate a torn/malformed line */
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=observe.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Research-index completeness — the deterministic FLOOR keeping the `research/`
|
|
3
|
+
* corpus and its index (`research/CLAUDE.md.spec.ts`) in sync. The spec's
|
|
4
|
+
* `keyFiles` map is the AGENT-FACING index of every research doc; the compiler
|
|
5
|
+
* already verifies the OTHER direction (every indexed path EXISTS, else
|
|
6
|
+
* `vigiles compile` fails), so the only open gap is a doc that was ADDED but
|
|
7
|
+
* never indexed. This check closes it: every `research/*.md` (except the
|
|
8
|
+
* human-facing `README.md`) must appear in the index, or the dogfood test fails.
|
|
9
|
+
*
|
|
10
|
+
* Pure — the caller supplies the doc filenames and the index content (the spec
|
|
11
|
+
* source, where an entry is AUTHORED), so it runs over the real `research/` dir
|
|
12
|
+
* in a test or over any file set. Bidirectional sync = compiler (index ⊆ docs)
|
|
13
|
+
* + this check (docs ⊆ index).
|
|
14
|
+
*/
|
|
15
|
+
/** Docs that are the index itself / human front-door, not indexed entries. */
|
|
16
|
+
export declare const INDEX_EXEMPT: readonly ["README.md"];
|
|
17
|
+
/**
|
|
18
|
+
* Research doc basenames (e.g. `roadmap.md`) NOT referenced anywhere in
|
|
19
|
+
* `indexContent`. A doc counts as indexed if its repo-relative path
|
|
20
|
+
* (`research/<name>.md`) appears in the index — the exact form the spec's
|
|
21
|
+
* `keyFiles` keys use. Exempt docs (the README) are never flagged.
|
|
22
|
+
*/
|
|
23
|
+
export declare function unindexedResearchDocs(docFilenames: readonly string[], indexContent: string, exempt?: readonly string[]): string[];
|
|
24
|
+
/**
|
|
25
|
+
* Index entries pointing at a `research/<name>.md` that no longer exists on
|
|
26
|
+
* disk. The compiler catches this at compile time (a missing `keyFiles` path is
|
|
27
|
+
* a compile error), so this is a belt-and-suspenders reader for a test that
|
|
28
|
+
* wants to assert it directly without invoking the compiler.
|
|
29
|
+
*/
|
|
30
|
+
export declare function deadIndexEntries(docFilenames: readonly string[], indexContent: string): string[];
|
|
31
|
+
//# sourceMappingURL=research-index.d.ts.map
|