@event4u/agent-config 9.15.0 → 9.16.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 +21 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/mcp/content.js +1 -1
- package/dist/cli/mcp/content.js.map +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- 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 +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/server/routes/workspace.js +1 -1
- package/dist/server/routes/workspace.js.map +1 -1
- package/dist/shared/userMd/utils.js +1 -1
- package/dist/shared/userMd/utils.js.map +1 -1
- package/dist/ui/assets/index-DmR0mbk4.js +26 -0
- package/dist/ui/assets/index-DmR0mbk4.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/package.json +4 -4
- package/src/config/agent-settings.template.yml +3 -1
- package/src/config/budget-routing.json +7 -0
- package/src/scripts/_lib/tier_budget_routing.ts +154 -28
- package/src/scripts/anchor_scoring_run.ts +1 -1
- package/src/scripts/bench_persona_placebo.ts +1 -1
- package/src/scripts/bench_quality_run.ts +1 -1
- package/src/scripts/check_cli_registry_budget_sync.ts +168 -0
- package/src/scripts/check_gate_coverage.ts +1 -1
- package/src/scripts/check_token_quality_golden.ts +1 -1
- package/src/scripts/cost/budget.mjs +18 -4
- package/src/scripts/release.ts +110 -7
- package/src/scripts/routing_doctor.ts +76 -1
- package/src/shared/userMd/utils.ts +1 -1
- package/dist/ui/assets/index-DJY_vQVn.js +0 -40
- package/dist/ui/assets/index-DJY_vQVn.js.map +0 -1
package/dist/ui/index.html
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
document.documentElement.setAttribute('data-theme', theme);
|
|
28
28
|
})();
|
|
29
29
|
</script>
|
|
30
|
-
<script type="module" crossorigin src="/assets/index-
|
|
30
|
+
<script type="module" crossorigin src="/assets/index-DmR0mbk4.js"></script>
|
|
31
31
|
<link rel="stylesheet" crossorigin href="/assets/index-jSfMW8_t.css">
|
|
32
32
|
</head>
|
|
33
33
|
<body>
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@event4u/agent-config",
|
|
3
3
|
"mcpName": "io.github.event4u-app/agent-config",
|
|
4
|
-
"version": "9.
|
|
4
|
+
"version": "9.16.0",
|
|
5
5
|
"description": "Governed skills, rules and commands for AI coding tools (Claude Code, Cursor, Windsurf, Copilot) — every claim machine-checked, including \"zero runtime daemon\".",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"private": false,
|
|
@@ -110,11 +110,11 @@
|
|
|
110
110
|
"@fastify/static": "^10.1.2",
|
|
111
111
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
112
112
|
"@preact/signals": "^2.10.0",
|
|
113
|
-
"commander": "^
|
|
113
|
+
"commander": "^15.0.0",
|
|
114
114
|
"execa": "^9.5.0",
|
|
115
115
|
"fastify": "^5.11.0",
|
|
116
|
-
"js-yaml": "^
|
|
117
|
-
"open": "^
|
|
116
|
+
"js-yaml": "^5.2.0",
|
|
117
|
+
"open": "^11.0.0",
|
|
118
118
|
"preact": "^10.29.0",
|
|
119
119
|
"yaml": "^2.9.0",
|
|
120
120
|
"zod": "^3.25.0",
|
|
@@ -724,7 +724,9 @@ subagents:
|
|
|
724
724
|
# the resource-availability gate (council 2026-08-03).
|
|
725
725
|
# ask = (default) first budget-motivated downshift of a session asks once
|
|
726
726
|
# auto = apply the relation silently (telemetry line per routed request)
|
|
727
|
-
# off = today's behavior; single-flip rollback
|
|
727
|
+
# off = today's behavior; single-flip rollback — QUOTE it ("off") when
|
|
728
|
+
# editing: bare off is YAML-1.1 boolean false (readers coerce, but
|
|
729
|
+
# the quoted form is the unambiguous one, same as `auto: "on"`)
|
|
728
730
|
budget_routing: ask
|
|
729
731
|
|
|
730
732
|
# Prefer a separate-quota-pool model for delegable sub-tasks WHERE the host
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$comment": "Single source of truth for the budget-routing reserve lifecycle (docs/contracts/budget-routing.md). Read by BOTH reserve readers — src/scripts/_lib/tier_budget_routing.ts (acquireBudgetPermit) and src/scripts/cost/budget.mjs (tier subcommand) — so the reserve window can never diverge between them. reserve_ttl_ms: reserves are RACE PROTECTION, not spend accounting — a pending entry older than this is dead and is dropped at the next compaction. lock_break_ms: an .lock file older than this is a crash leftover and is broken (unlink + one retry).",
|
|
3
|
+
"owner": "maintainer",
|
|
4
|
+
"review_by": "2027-08-03",
|
|
5
|
+
"reserve_ttl_ms": 600000,
|
|
6
|
+
"lock_break_ms": 30000
|
|
7
|
+
}
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import * as fs from 'node:fs';
|
|
22
22
|
import * as path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
23
24
|
|
|
24
25
|
export type BudgetTier = 'cheap' | 'medium' | 'strong';
|
|
25
26
|
|
|
@@ -123,19 +124,91 @@ export const RESERVE_FILE = 'tier-reserves.jsonl';
|
|
|
123
124
|
export const COOLDOWN_FILE = 'tier-cooldowns.json';
|
|
124
125
|
export const DEFAULT_COOLDOWN_MS = 60 * 60 * 1000;
|
|
125
126
|
|
|
127
|
+
// Reserve lifecycle constants — single source of truth shared with
|
|
128
|
+
// budget.mjs (the second reserve reader) via src/config/budget-routing.json.
|
|
129
|
+
// Duplicating the literal here was the exact two-truths defect the external
|
|
130
|
+
// review flagged; both readers now load the same file.
|
|
131
|
+
const _LIFECYCLE_CONFIG = path.join(
|
|
132
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
133
|
+
'..',
|
|
134
|
+
'..',
|
|
135
|
+
'config',
|
|
136
|
+
'budget-routing.json',
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
function _lifecycle(): { reserve_ttl_ms: number; lock_break_ms: number } {
|
|
140
|
+
try {
|
|
141
|
+
const raw = JSON.parse(fs.readFileSync(_LIFECYCLE_CONFIG, 'utf-8')) as Record<string, unknown>;
|
|
142
|
+
const ttl = typeof raw['reserve_ttl_ms'] === 'number' ? raw['reserve_ttl_ms'] : 600_000;
|
|
143
|
+
const brk = typeof raw['lock_break_ms'] === 'number' ? raw['lock_break_ms'] : 30_000;
|
|
144
|
+
return { reserve_ttl_ms: ttl, lock_break_ms: brk };
|
|
145
|
+
} catch {
|
|
146
|
+
return { reserve_ttl_ms: 600_000, lock_break_ms: 30_000 };
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function reserveTtlMs(): number {
|
|
151
|
+
return _lifecycle().reserve_ttl_ms;
|
|
152
|
+
}
|
|
153
|
+
|
|
126
154
|
interface ReserveEntry {
|
|
127
155
|
ts_ms: number;
|
|
128
156
|
tier: BudgetTier;
|
|
129
157
|
est_usd: number;
|
|
130
|
-
status: 'pending';
|
|
158
|
+
status: 'pending' | 'settled';
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _readReserves(reservePath: string): ReserveEntry[] {
|
|
162
|
+
if (!fs.existsSync(reservePath)) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
const out: ReserveEntry[] = [];
|
|
166
|
+
for (const line of fs.readFileSync(reservePath, 'utf-8').split('\n')) {
|
|
167
|
+
if (!line.trim()) continue;
|
|
168
|
+
try {
|
|
169
|
+
out.push(JSON.parse(line) as ReserveEntry);
|
|
170
|
+
} catch {
|
|
171
|
+
// tolerate a torn line — reserve file is advisory spend state
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Take the reserve lock; break a stale one (crash leftover) once. */
|
|
178
|
+
function _takeLock(lock: string, now_ms: number, lock_break_ms: number): number | null {
|
|
179
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
180
|
+
try {
|
|
181
|
+
return fs.openSync(lock, 'wx');
|
|
182
|
+
} catch {
|
|
183
|
+
try {
|
|
184
|
+
const age = now_ms - fs.statSync(lock).mtimeMs;
|
|
185
|
+
if (age > lock_break_ms) {
|
|
186
|
+
fs.rmSync(lock, { force: true });
|
|
187
|
+
continue; // one retry after breaking the stale lock
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
continue; // lock vanished between open and stat — retry once
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return null;
|
|
131
196
|
}
|
|
132
197
|
|
|
133
198
|
/**
|
|
134
|
-
* Atomic budget reserve
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
199
|
+
* Atomic budget reserve — full lifecycle (external review 2026-08-03):
|
|
200
|
+
*
|
|
201
|
+
* acquire → expire (TTL) / settle → compact-on-write
|
|
202
|
+
*
|
|
203
|
+
* Under the lock: live reserves = pending entries younger than the shared
|
|
204
|
+
* TTL (reserves are RACE PROTECTION, not spend accounting — real spend is
|
|
205
|
+
* the ledger's job); the file is COMPACTED to exactly those live entries
|
|
206
|
+
* on every acquire, so it stays bounded; a lock file older than
|
|
207
|
+
* `lock_break_ms` is a crash leftover and is broken with one retry.
|
|
208
|
+
* Returns granted=false (never throws) when the ceiling would be crossed
|
|
209
|
+
* or a fresh lock is genuinely held — the caller falls back per the
|
|
210
|
+
* relation (fail-open at the DECISION layer, conservative at the SPEND
|
|
211
|
+
* layer).
|
|
139
212
|
*/
|
|
140
213
|
export function acquireBudgetPermit(options: {
|
|
141
214
|
tracking_dir: string;
|
|
@@ -148,44 +221,45 @@ export function acquireBudgetPermit(options: {
|
|
|
148
221
|
const dir = options.tracking_dir;
|
|
149
222
|
fs.mkdirSync(dir, { recursive: true });
|
|
150
223
|
const lock = path.join(dir, `${RESERVE_FILE}.lock`);
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
} catch {
|
|
224
|
+
const { reserve_ttl_ms, lock_break_ms } = _lifecycle();
|
|
225
|
+
const fd = _takeLock(lock, options.now_ms, lock_break_ms);
|
|
226
|
+
if (fd === null) {
|
|
155
227
|
return { granted: false, reason: 'reserve lock busy — treat tier as unavailable this instant' };
|
|
156
228
|
}
|
|
157
229
|
try {
|
|
158
230
|
const reservePath = path.join(dir, RESERVE_FILE);
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
if (e.tier === options.tier && e.status === 'pending') {
|
|
166
|
-
reserved += e.est_usd;
|
|
167
|
-
}
|
|
168
|
-
} catch {
|
|
169
|
-
// tolerate a torn line — reserve file is advisory spend state
|
|
170
|
-
}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
231
|
+
const live = _readReserves(reservePath).filter(
|
|
232
|
+
(e) => e.status === 'pending' && options.now_ms - e.ts_ms < reserve_ttl_ms,
|
|
233
|
+
);
|
|
234
|
+
const reserved = live
|
|
235
|
+
.filter((e) => e.tier === options.tier)
|
|
236
|
+
.reduce((acc, e) => acc + e.est_usd, 0);
|
|
173
237
|
if (
|
|
174
238
|
options.ceiling_usd !== null &&
|
|
175
239
|
options.spent_usd + reserved + options.estimated_cost_usd > options.ceiling_usd
|
|
176
240
|
) {
|
|
241
|
+
// Compact even on deny — expiry must not depend on a grant.
|
|
242
|
+
fs.writeFileSync(
|
|
243
|
+
reservePath,
|
|
244
|
+
live.map((e) => JSON.stringify(e)).join('\n') + (live.length ? '\n' : ''),
|
|
245
|
+
'utf-8',
|
|
246
|
+
);
|
|
177
247
|
return {
|
|
178
248
|
granted: false,
|
|
179
249
|
reason: `ceiling ${options.ceiling_usd} would be crossed (spent ${options.spent_usd} + reserved ${reserved})`,
|
|
180
250
|
};
|
|
181
251
|
}
|
|
182
|
-
|
|
252
|
+
live.push({
|
|
183
253
|
ts_ms: options.now_ms,
|
|
184
254
|
tier: options.tier,
|
|
185
255
|
est_usd: options.estimated_cost_usd,
|
|
186
256
|
status: 'pending',
|
|
187
|
-
};
|
|
188
|
-
fs.
|
|
257
|
+
});
|
|
258
|
+
fs.writeFileSync(
|
|
259
|
+
reservePath,
|
|
260
|
+
live.map((e) => JSON.stringify(e)).join('\n') + '\n',
|
|
261
|
+
'utf-8',
|
|
262
|
+
);
|
|
189
263
|
return { granted: true, reason: 'reserved' };
|
|
190
264
|
} finally {
|
|
191
265
|
fs.closeSync(fd);
|
|
@@ -193,7 +267,54 @@ export function acquireBudgetPermit(options: {
|
|
|
193
267
|
}
|
|
194
268
|
}
|
|
195
269
|
|
|
196
|
-
/**
|
|
270
|
+
/**
|
|
271
|
+
* Settle the oldest live pending reserve of a tier (call when the real
|
|
272
|
+
* cost lands in the ledger, so the estimate stops counting immediately
|
|
273
|
+
* instead of waiting out the TTL). Settled entries are dropped by the
|
|
274
|
+
* next compaction. Best-effort: lock busy → no-op (the TTL is the
|
|
275
|
+
* backstop that makes settling optional, never load-bearing).
|
|
276
|
+
*/
|
|
277
|
+
export function settlePermit(options: {
|
|
278
|
+
tracking_dir: string;
|
|
279
|
+
tier: BudgetTier;
|
|
280
|
+
now_ms: number;
|
|
281
|
+
}): boolean {
|
|
282
|
+
const lock = path.join(options.tracking_dir, `${RESERVE_FILE}.lock`);
|
|
283
|
+
const { reserve_ttl_ms, lock_break_ms } = _lifecycle();
|
|
284
|
+
const fd = _takeLock(lock, options.now_ms, lock_break_ms);
|
|
285
|
+
if (fd === null) {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
const reservePath = path.join(options.tracking_dir, RESERVE_FILE);
|
|
290
|
+
const live = _readReserves(reservePath).filter(
|
|
291
|
+
(e) => e.status === 'pending' && options.now_ms - e.ts_ms < reserve_ttl_ms,
|
|
292
|
+
);
|
|
293
|
+
const idx = live.findIndex((e) => e.tier === options.tier);
|
|
294
|
+
if (idx === -1) {
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
live.splice(idx, 1);
|
|
298
|
+
fs.writeFileSync(
|
|
299
|
+
reservePath,
|
|
300
|
+
live.map((e) => JSON.stringify(e)).join('\n') + (live.length ? '\n' : ''),
|
|
301
|
+
'utf-8',
|
|
302
|
+
);
|
|
303
|
+
return true;
|
|
304
|
+
} finally {
|
|
305
|
+
fs.closeSync(fd);
|
|
306
|
+
fs.rmSync(lock, { force: true });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Record a quota/429 error: the tier cools down; pickTier skips it.
|
|
312
|
+
*
|
|
313
|
+
* Deliberately lock-free: last-writer-wins on a 3-key timestamp map is
|
|
314
|
+
* benign (two concurrent trips write near-identical "until" values), so
|
|
315
|
+
* the reserve lock is not reused here (accepted in the 2026-08-03 review).
|
|
316
|
+
* Expired entries are purged on every write so the map never grows stale.
|
|
317
|
+
*/
|
|
197
318
|
export function tripCooldown(options: {
|
|
198
319
|
tracking_dir: string;
|
|
199
320
|
tier: BudgetTier;
|
|
@@ -208,6 +329,11 @@ export function tripCooldown(options: {
|
|
|
208
329
|
} catch {
|
|
209
330
|
state = {};
|
|
210
331
|
}
|
|
332
|
+
for (const [k, v] of Object.entries(state)) {
|
|
333
|
+
if (typeof v !== 'number' || v <= options.now_ms) {
|
|
334
|
+
delete state[k];
|
|
335
|
+
}
|
|
336
|
+
}
|
|
211
337
|
state[options.tier] = until;
|
|
212
338
|
fs.mkdirSync(options.tracking_dir, { recursive: true });
|
|
213
339
|
fs.writeFileSync(p, `${JSON.stringify(state, null, 2)}\n`, 'utf-8');
|
|
@@ -22,7 +22,7 @@ import * as path from 'node:path';
|
|
|
22
22
|
import process from 'node:process';
|
|
23
23
|
import { fileURLToPath } from 'node:url';
|
|
24
24
|
|
|
25
|
-
import yaml from 'js-yaml';
|
|
25
|
+
import * as yaml from 'js-yaml';
|
|
26
26
|
|
|
27
27
|
import { build_thin, RULES_SOURCE } from './project_thin_rules.js';
|
|
28
28
|
import { cohensKappa } from './check_quality_regression.js';
|
|
@@ -29,7 +29,7 @@ import * as path from 'node:path';
|
|
|
29
29
|
import process from 'node:process';
|
|
30
30
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
31
|
|
|
32
|
-
import yaml from 'js-yaml';
|
|
32
|
+
import * as yaml from 'js-yaml';
|
|
33
33
|
|
|
34
34
|
import { signTestP } from './second_brain_retrieval.js';
|
|
35
35
|
|
|
@@ -30,7 +30,7 @@ import * as path from 'node:path';
|
|
|
30
30
|
import process from 'node:process';
|
|
31
31
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
32
32
|
|
|
33
|
-
import yaml from 'js-yaml';
|
|
33
|
+
import * as yaml from 'js-yaml';
|
|
34
34
|
|
|
35
35
|
import { build_thin, RULES_SOURCE } from './project_thin_rules.js';
|
|
36
36
|
import { evaluatePair, type JudgeFn, type JudgeVerdict, type PairResult } from './check_quality_regression.js';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* CLI-registry ↔ evaluator-budget sync gate.
|
|
4
|
+
*
|
|
5
|
+
* The evaluator budget `cli_help_command_count` (src/config/evaluator-budgets.json)
|
|
6
|
+
* and the committed measurement record (agents/evidence/metrics/
|
|
7
|
+
* evaluator-measurements.json) are derived from `src/cli/registry.ts`, but until
|
|
8
|
+
* this gate nothing forced the PR that changes the registry to move them: the
|
|
9
|
+
* breach only WARNED on main and BLOCKED the next release. That is how 9.15.0
|
|
10
|
+
* went red — f02db54c2 registered `routing:doctor` (80 → 81 entries) and the
|
|
11
|
+
* drift surfaced days later, at release time, on an unrelated branch.
|
|
12
|
+
*
|
|
13
|
+
* This gate runs on every PR (Static Checks + `task preflight`) and fails the
|
|
14
|
+
* moment the three numbers disagree, so the registering PR carries the budget
|
|
15
|
+
* move itself.
|
|
16
|
+
*
|
|
17
|
+
* The count uses the SAME pinned method as evaluator_umbrella.sh — the number
|
|
18
|
+
* of `{ name: ` entry openers in src/cli/registry.ts (NOT --help prose
|
|
19
|
+
* parsing; methods historically disagreed 74 vs 76 vs 79).
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 in sync · 1 out of sync · 2 a required file is missing or
|
|
22
|
+
* unparseable (fail-closed: a gate that cannot read its inputs must not
|
|
23
|
+
* certify them).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import * as fs from 'node:fs';
|
|
27
|
+
import * as path from 'node:path';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
31
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
32
|
+
|
|
33
|
+
const REGISTRY_PATH = path.join(REPO_ROOT, 'src/cli/registry.ts');
|
|
34
|
+
const BUDGETS_PATH = path.join(REPO_ROOT, 'src/config/evaluator-budgets.json');
|
|
35
|
+
const RECORD_PATH = path.join(REPO_ROOT, 'agents/evidence/metrics/evaluator-measurements.json');
|
|
36
|
+
|
|
37
|
+
const METRIC = 'cli_help_command_count';
|
|
38
|
+
|
|
39
|
+
/** Pinned method — byte-for-byte the umbrella's CLI_COUNT probe. */
|
|
40
|
+
function count_registry_entries(source: string): number {
|
|
41
|
+
return (source.match(/\{ name: /g) || []).length;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface BudgetEntry {
|
|
45
|
+
max?: unknown;
|
|
46
|
+
last_measured?: unknown;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface SyncInputs {
|
|
50
|
+
count: number;
|
|
51
|
+
budget: BudgetEntry | undefined;
|
|
52
|
+
recorded: unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const REMEDY =
|
|
56
|
+
'Fix, in THIS PR (the one that moves src/cli/registry.ts):\n' +
|
|
57
|
+
` 1. src/config/evaluator-budgets.json → budgets.${METRIC}: set max +\n` +
|
|
58
|
+
' last_measured to the new count, bump last_measured_at, and extend\n' +
|
|
59
|
+
' baseline_note naming the commit that moves the surface.\n' +
|
|
60
|
+
' 2. Regenerate the committed record (never hand-edit):\n' +
|
|
61
|
+
' node src/scripts/record_evaluator_measurements.mjs \\\n' +
|
|
62
|
+
' --measurements <file with the re-measured metrics> \\\n' +
|
|
63
|
+
' --out agents/evidence/metrics/evaluator-measurements.json \\\n' +
|
|
64
|
+
' --sha "$(git rev-parse HEAD)"\n' +
|
|
65
|
+
'Otherwise the drift stays invisible until the next release, where the\n' +
|
|
66
|
+
'evaluator gate blocks it (the 9.15.0 failure mode this gate exists to stop).\n';
|
|
67
|
+
|
|
68
|
+
/** Pure comparison — the CLI owns the filesystem. */
|
|
69
|
+
function collect_findings(inputs: SyncInputs): string[] {
|
|
70
|
+
const findings: string[] = [];
|
|
71
|
+
const { count, budget, recorded } = inputs;
|
|
72
|
+
|
|
73
|
+
if (budget === undefined) {
|
|
74
|
+
findings.push(`budgets.${METRIC} is missing from evaluator-budgets.json`);
|
|
75
|
+
return findings;
|
|
76
|
+
}
|
|
77
|
+
if (budget.last_measured !== count) {
|
|
78
|
+
findings.push(
|
|
79
|
+
`registry has ${count} entries but budgets.${METRIC}.last_measured says ` +
|
|
80
|
+
`${String(budget.last_measured)}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (typeof budget.max !== 'number' || count > budget.max) {
|
|
84
|
+
findings.push(
|
|
85
|
+
`registry has ${count} entries but budgets.${METRIC}.max is ` +
|
|
86
|
+
`${String(budget.max)}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (recorded === undefined) {
|
|
90
|
+
findings.push(
|
|
91
|
+
`measurements.${METRIC} is missing from the committed record ` +
|
|
92
|
+
'(agents/evidence/metrics/evaluator-measurements.json)',
|
|
93
|
+
);
|
|
94
|
+
} else if (recorded !== count) {
|
|
95
|
+
findings.push(
|
|
96
|
+
`registry has ${count} entries but the committed record says ` +
|
|
97
|
+
`${String(recorded)}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return findings;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _read_json(p: string): Record<string, unknown> {
|
|
104
|
+
return JSON.parse(fs.readFileSync(p, 'utf-8')) as Record<string, unknown>;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function main(): number {
|
|
108
|
+
let registrySource: string;
|
|
109
|
+
let budgetsDoc: Record<string, unknown>;
|
|
110
|
+
let recordDoc: Record<string, unknown>;
|
|
111
|
+
try {
|
|
112
|
+
registrySource = fs.readFileSync(REGISTRY_PATH, 'utf-8');
|
|
113
|
+
budgetsDoc = _read_json(BUDGETS_PATH);
|
|
114
|
+
recordDoc = _read_json(RECORD_PATH);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
process.stderr.write(
|
|
117
|
+
`ERROR: cannot read a gate input: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
118
|
+
);
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const count = count_registry_entries(registrySource);
|
|
123
|
+
const budgets = budgetsDoc['budgets'] as Record<string, BudgetEntry> | undefined;
|
|
124
|
+
const measurements = recordDoc['measurements'] as Record<string, unknown> | undefined;
|
|
125
|
+
const findings = collect_findings({
|
|
126
|
+
count,
|
|
127
|
+
budget: budgets?.[METRIC],
|
|
128
|
+
recorded: measurements?.[METRIC],
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
process.stdout.write('scanned: 3\n');
|
|
132
|
+
if (findings.length > 0) {
|
|
133
|
+
for (const f of findings) {
|
|
134
|
+
process.stdout.write(`❌ ${METRIC} out of sync: ${f}\n`);
|
|
135
|
+
}
|
|
136
|
+
process.stdout.write('\n' + REMEDY);
|
|
137
|
+
return 1;
|
|
138
|
+
}
|
|
139
|
+
process.stdout.write(
|
|
140
|
+
`✅ ${METRIC} in sync — registry (${count}) == budget == committed record.\n`,
|
|
141
|
+
);
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _isCliEntry(): boolean {
|
|
146
|
+
if (process.argv[1] === undefined) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
150
|
+
if (import.meta.url === argvUrl) {
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
153
|
+
// Symlinked invocations (installed projections, macOS /var → /private/var)
|
|
154
|
+
// make the raw URLs differ — compare realpaths so the entry guard fires.
|
|
155
|
+
try {
|
|
156
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
157
|
+
const argvPath = fs.realpathSync(path.resolve(process.argv[1]));
|
|
158
|
+
return here === argvPath;
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
165
|
+
process.exit(main());
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { collect_findings, count_registry_entries, main };
|
|
@@ -48,7 +48,7 @@ import * as path from 'node:path';
|
|
|
48
48
|
import process from 'node:process';
|
|
49
49
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
50
50
|
|
|
51
|
-
import yaml from 'js-yaml';
|
|
51
|
+
import * as yaml from 'js-yaml';
|
|
52
52
|
import { runCountedProbe } from './_lib/counted_probe.js';
|
|
53
53
|
|
|
54
54
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -41,7 +41,7 @@ import * as path from 'node:path';
|
|
|
41
41
|
import process from 'node:process';
|
|
42
42
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
43
43
|
|
|
44
|
-
import yaml from 'js-yaml';
|
|
44
|
+
import * as yaml from 'js-yaml';
|
|
45
45
|
|
|
46
46
|
const _HERE = fileURLToPath(import.meta.url);
|
|
47
47
|
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
@@ -199,9 +199,21 @@ function cmdCheck() {
|
|
|
199
199
|
}
|
|
200
200
|
|
|
201
201
|
// --- Per-tier budget state (docs/contracts/budget-routing.md) -------------
|
|
202
|
-
// v1 window for per-tier ceilings: rolling 24h
|
|
203
|
-
// `tier:`
|
|
204
|
-
//
|
|
202
|
+
// v1 window for per-tier ceilings: rolling 24h for SPEND (ledger entries
|
|
203
|
+
// tagged `tier:`). Pending reserves from tier-reserves.jsonl count only
|
|
204
|
+
// within the shared reserve TTL — reserves are race protection, not spend
|
|
205
|
+
// accounting. The TTL comes from src/config/budget-routing.json, the SAME
|
|
206
|
+
// source acquireBudgetPermit reads, so the two readers cannot diverge
|
|
207
|
+
// (external review 2026-08-03, Finding 1b).
|
|
208
|
+
|
|
209
|
+
function loadReserveTtlMs() {
|
|
210
|
+
try {
|
|
211
|
+
const cfgPath = join(dirname(new URL(import.meta.url).pathname), '..', '..', 'config', 'budget-routing.json');
|
|
212
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf-8'));
|
|
213
|
+
if (Number.isFinite(raw.reserve_ttl_ms)) return raw.reserve_ttl_ms;
|
|
214
|
+
} catch { /* fall through to the safe default below */ }
|
|
215
|
+
return 600000;
|
|
216
|
+
}
|
|
205
217
|
|
|
206
218
|
function loadPerTierCeilings() {
|
|
207
219
|
const out = { cheap: null, medium: null, strong: null };
|
|
@@ -234,13 +246,14 @@ function cmdTier(rest) {
|
|
|
234
246
|
.filter((s) => s.tier === tier && inWindow(s.capturedAt || s.endedAt || s.ts))
|
|
235
247
|
.reduce((acc, s) => acc + (Number(s.total_cost_usd ?? s.cost_usd ?? 0) || 0), 0);
|
|
236
248
|
let reserved = 0;
|
|
249
|
+
const reserveTtl = loadReserveTtlMs();
|
|
237
250
|
const reservePath = join(dirname(STORE), 'tier-reserves.jsonl');
|
|
238
251
|
if (existsSync(reservePath)) {
|
|
239
252
|
for (const line of readFileSync(reservePath, 'utf-8').split('\n')) {
|
|
240
253
|
if (!line.trim()) continue;
|
|
241
254
|
try {
|
|
242
255
|
const e = JSON.parse(line);
|
|
243
|
-
if (e.tier === tier && e.status === 'pending' && (now - e.ts_ms) <
|
|
256
|
+
if (e.tier === tier && e.status === 'pending' && (now - e.ts_ms) < reserveTtl) {
|
|
244
257
|
reserved += Number(e.est_usd || 0) || 0;
|
|
245
258
|
}
|
|
246
259
|
} catch { /* skip malformed line */ }
|
|
@@ -251,6 +264,7 @@ function cmdTier(rest) {
|
|
|
251
264
|
console.log(JSON.stringify({
|
|
252
265
|
tier,
|
|
253
266
|
window: 'rolling-24h',
|
|
267
|
+
reserve_ttl_ms: reserveTtl,
|
|
254
268
|
ceiling_usd: ceiling,
|
|
255
269
|
spent_usd: spent,
|
|
256
270
|
reserved_usd: reserved,
|