@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.
Files changed (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +21 -0
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/cli/mcp/content.js +1 -1
  5. package/dist/cli/mcp/content.js.map +1 -1
  6. package/dist/discovery/deprecation-report.md +1 -1
  7. package/dist/discovery/discovery-manifest.json +1 -1
  8. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  9. package/dist/discovery/discovery-manifest.summary.md +1 -1
  10. package/dist/discovery/orphan-report.md +1 -1
  11. package/dist/discovery/packs.json +1 -1
  12. package/dist/discovery/trust-report.md +1 -1
  13. package/dist/discovery/workspaces.json +1 -1
  14. package/dist/mcp/registry-manifest.json +1 -1
  15. package/dist/mcp/server.json +2 -2
  16. package/dist/server/routes/workspace.js +1 -1
  17. package/dist/server/routes/workspace.js.map +1 -1
  18. package/dist/shared/userMd/utils.js +1 -1
  19. package/dist/shared/userMd/utils.js.map +1 -1
  20. package/dist/ui/assets/index-DmR0mbk4.js +26 -0
  21. package/dist/ui/assets/index-DmR0mbk4.js.map +1 -0
  22. package/dist/ui/index.html +1 -1
  23. package/package.json +4 -4
  24. package/src/config/agent-settings.template.yml +3 -1
  25. package/src/config/budget-routing.json +7 -0
  26. package/src/scripts/_lib/tier_budget_routing.ts +154 -28
  27. package/src/scripts/anchor_scoring_run.ts +1 -1
  28. package/src/scripts/bench_persona_placebo.ts +1 -1
  29. package/src/scripts/bench_quality_run.ts +1 -1
  30. package/src/scripts/check_cli_registry_budget_sync.ts +168 -0
  31. package/src/scripts/check_gate_coverage.ts +1 -1
  32. package/src/scripts/check_token_quality_golden.ts +1 -1
  33. package/src/scripts/cost/budget.mjs +18 -4
  34. package/src/scripts/release.ts +110 -7
  35. package/src/scripts/routing_doctor.ts +76 -1
  36. package/src/shared/userMd/utils.ts +1 -1
  37. package/dist/ui/assets/index-DJY_vQVn.js +0 -40
  38. package/dist/ui/assets/index-DJY_vQVn.js.map +0 -1
@@ -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-DJY_vQVn.js"></script>
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.15.0",
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": "^12.1.0",
113
+ "commander": "^15.0.0",
114
114
  "execa": "^9.5.0",
115
115
  "fastify": "^5.11.0",
116
- "js-yaml": "^4.1.0",
117
- "open": "^10.2.0",
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: O_EXCL lock → sum ledger+reserves → append a
135
- * pending entry → release. Returns granted=false (never throws) when the
136
- * ceiling would be crossed or the lock cannot be taken quickly — the
137
- * caller falls back per the relation (fail-open at the DECISION layer,
138
- * conservative at the SPEND layer).
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
- let fd: number;
152
- try {
153
- fd = fs.openSync(lock, 'wx');
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
- let reserved = 0;
160
- if (fs.existsSync(reservePath)) {
161
- for (const line of fs.readFileSync(reservePath, 'utf-8').split('\n')) {
162
- if (!line.trim()) continue;
163
- try {
164
- const e = JSON.parse(line) as ReserveEntry;
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
- const entry: ReserveEntry = {
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.appendFileSync(reservePath, `${JSON.stringify(entry)}\n`, 'utf-8');
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
- /** Record a quota/429 error: the tier cools down; pickTier skips it. */
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. Sums ledger entries tagged
203
- // `tier:` plus pending reserves from tier-reserves.jsonl (the atomic-permit
204
- // file) so a concurrent dispatch cannot read a stale "available".
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) < dayMs) {
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,