@agent-finops/core 0.5.7 → 0.5.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @agent-finops/core
2
2
 
3
- The canonical local-first data and decision engine for
3
+ The canonical local-first evidence and decision engine for
4
4
  [aibill](https://github.com/futurastudio/ai-spend-agent): Claude Code/Codex
5
5
  activity ingestion, provider cost semantics, attribution, provenance, runway,
6
6
  Context Health, and the shared Glance contract.
@@ -8,6 +8,10 @@ Context Health, and the shared Glance contract.
8
8
  Most users should run `npx aibill`. This package is for integrations that
9
9
  need the same evidence-labeled calculations as the CLI and MCP server.
10
10
 
11
+ This is the open foundation for aibill's financial-accountability mission. It
12
+ does not yet implement company-wide ownership, accepted outcomes, approvals,
13
+ invoice reconciliation, or ROI.
14
+
11
15
  Local API-equivalent estimates, subscription context, and official
12
16
  provider-reported cost are separate concepts and must not be added together.
13
17
 
@@ -10,19 +10,22 @@
10
10
  * - Skills use *progressive disclosure*: only the YAML frontmatter (`name` +
11
11
  * `description`) is always loaded — the body loads only when invoked. So a
12
12
  * skill's alwaysLoadedTokens reflects ONLY name + description, never the body.
13
- * - MCP tools: the FULL tool definition (name + description + JSON input schema)
14
- * is always loaded the heavy weight. Local host config almost never carries
15
- * tool schemas, so MCP enumeration is usually limited to server names;
16
- * those items are flagged "estimated_understated" because the real weight is
17
- * larger than what we can see.
13
+ * - MCP servers: configuration proves that a server is available, not that its
14
+ * tool schemas were loaded into a model request. Current Claude Code defers
15
+ * schemas by default through Tool Search. We therefore keep configured and
16
+ * explicitly-always-loaded states separate and never invent a token weight
17
+ * from config alone.
18
18
  * - Subagents / slash commands: estimate from their description/frontmatter line
19
19
  * only (what is surfaced in the always-loaded list), not the whole file body.
20
20
  * - Built-in tools (Read/Edit/Bash/Glob/Grep/etc.) are EXCLUDED entirely: always
21
21
  * loaded, not prunable, not "waste."
22
22
  */
23
23
  export type InventoryKind = "skill" | "subagent" | "command" | "mcp_tool" | "mcp_server" | "hook";
24
- export type InventoryActivation = "discoverable" | "mcp_schema_loaded" | "hook_injected" | "lifecycle_hook";
24
+ export type InventoryActivation = "discoverable" | "mcp_configured" | "mcp_always_loaded"
25
+ /** Legacy fixture/adapter value. New inventory does not emit this. */
26
+ | "mcp_schema_loaded" | "hook_injected" | "lifecycle_hook";
25
27
  export type InventoryHost = "claude-code" | "codex";
28
+ export type InventoryScope = "user" | "local" | "project";
26
29
  export type InventoryItem = {
27
30
  kind: InventoryKind;
28
31
  /**
@@ -30,7 +33,7 @@ export type InventoryItem = {
30
33
  * the server id; skill/subagent/command: their declared name.
31
34
  */
32
35
  name: string;
33
- scope: "user" | "project";
36
+ scope: InventoryScope;
34
37
  /** e.g. mcp server name for an mcp_tool, plugin name for a plugin skill. */
35
38
  group?: string;
36
39
  /** How the host makes this item available to the model/runtime. */
@@ -90,13 +93,7 @@ export type AgentInventoryResult = {
90
93
  };
91
94
  /** Token estimate: Math.ceil(chars / 4). Exported so the parent reuses it. */
92
95
  export declare function estimateTokensFromText(text: string): number;
93
- /**
94
- * Conservative floor for an MCP server's always-loaded token weight when its
95
- * tool schemas aren't readable from config. A single tool's
96
- * name+description+JSON-schema is commonly ~300–800 tokens and servers usually
97
- * expose several; 700 is a deliberately low estimate, always paired with
98
- * weightConfidence "estimated_understated" so we under-claim, never over-claim.
99
- */
96
+ /** @deprecated Config alone cannot prove that MCP schemas were loaded. */
100
97
  export declare const MCP_SERVER_TOKEN_FLOOR = 700;
101
98
  /** Scan this machine's (and the project's) agent inventory. Never throws. */
102
99
  export declare function loadAgentInventory(options?: AgentInventoryOptions): Promise<AgentInventoryResult>;
@@ -5,13 +5,7 @@ import { homedir } from "node:os";
5
5
  export function estimateTokensFromText(text) {
6
6
  return Math.ceil(text.length / 4);
7
7
  }
8
- /**
9
- * Conservative floor for an MCP server's always-loaded token weight when its
10
- * tool schemas aren't readable from config. A single tool's
11
- * name+description+JSON-schema is commonly ~300–800 tokens and servers usually
12
- * expose several; 700 is a deliberately low estimate, always paired with
13
- * weightConfidence "estimated_understated" so we under-claim, never over-claim.
14
- */
8
+ /** @deprecated Config alone cannot prove that MCP schemas were loaded. */
15
9
  export const MCP_SERVER_TOKEN_FLOOR = 700;
16
10
  /** Scan this machine's (and the project's) agent inventory. Never throws. */
17
11
  export async function loadAgentInventory(options = {}) {
@@ -172,23 +166,28 @@ export async function loadAgentInventory(options = {}) {
172
166
  });
173
167
  }
174
168
  }
175
- // --- MCP servers (from ~/.claude.json: top-level + per-project map) ---
169
+ // --- MCP servers -------------------------------------------------------
170
+ // Claude Code's active MCP configuration lives in ~/.claude.json (user and
171
+ // local scopes) plus <project>/.mcp.json (project scope). settings.json is
172
+ // intentionally NOT treated as an active MCP source: legacy/stale mcpServers
173
+ // keys there must not turn into removal advice.
176
174
  const serverScopes = new Map();
177
- for (const sourcePath of [...new Set([configPath, settingsPath])]) {
178
- const config = await readJson(sourcePath);
179
- for (const server of collectMcpServers(config, projectDir, options.includeAllProjectMcp ?? false)) {
180
- const key = `claude-code:${server.scope}:${server.id}`;
181
- const prior = serverScopes.get(key);
182
- if (prior) {
183
- for (const ownerDir of server.ownerDirs) {
184
- if (!prior.ownerDirs.includes(ownerDir))
185
- prior.ownerDirs.push(ownerDir);
186
- }
187
- }
188
- else {
189
- serverScopes.set(key, { ...server, path: sourcePath, host: "claude-code" });
190
- }
191
- }
175
+ const claudeConfig = await readJson(configPath);
176
+ for (const server of collectMcpServers(claudeConfig, projectDir, options.includeAllProjectMcp ?? false)) {
177
+ mergeServerScope(serverScopes, {
178
+ ...server,
179
+ path: configPath,
180
+ host: "claude-code"
181
+ });
182
+ }
183
+ const projectMcpPath = join(projectDir, ".mcp.json");
184
+ const projectMcpConfig = await readJson(projectMcpPath);
185
+ for (const server of collectProjectMcpServers(projectMcpConfig, projectDir)) {
186
+ mergeServerScope(serverScopes, {
187
+ ...server,
188
+ path: projectMcpPath,
189
+ host: "claude-code"
190
+ });
192
191
  }
193
192
  const codexConfigPath = join(codexHome, "config.toml");
194
193
  const codexConfigText = await readFile(codexConfigPath, "utf8").catch(() => "");
@@ -200,34 +199,100 @@ export async function loadAgentInventory(options = {}) {
200
199
  scope: "user",
201
200
  ownerDirs: [],
202
201
  path: codexConfigPath,
203
- host: "codex"
202
+ host: "codex",
203
+ alwaysLoad: false
204
204
  });
205
205
  }
206
206
  }
207
- for (const { id, scope, ownerDirs, path, host } of serverScopes.values()) {
207
+ for (const { id, scope, ownerDirs, path, host, alwaysLoad } of serverScopes.values()) {
208
208
  scanned.mcpServers += 1;
209
- // We almost never have tool schemas from config, so we can't measure the
210
- // real weight (full tool definitions). Use a conservative published-typical
211
- // FLOOR per server instead of the bare id a single MCP tool's
212
- // name+description+JSON schema is commonly several hundred tokens, and
213
- // servers usually expose multiple tools. Flagged "estimated_understated":
214
- // the true weight is almost certainly higher, never lower.
209
+ // Claude transcripts identify an invoked MCP server by name, not by the
210
+ // concrete local config entry that supplied it. When same-named entries
211
+ // from multiple project roots were merged above, treating the aggregate as
212
+ // observable could produce one ambiguous action spanning several owners.
213
+ // Keep it visible as inventory, but exclude it from never-invoked actions.
214
+ const invocationTracking = host === "claude-code" && ownerDirs.length <= 1
215
+ ? "observable"
216
+ : "not_observable";
215
217
  items.push({
216
218
  kind: "mcp_server",
217
219
  name: id,
218
220
  scope,
219
221
  group: id,
220
- activation: "mcp_schema_loaded",
222
+ activation: alwaysLoad ? "mcp_always_loaded" : "mcp_configured",
221
223
  host,
222
- invocationTracking: host === "claude-code" ? "observable" : "not_observable",
223
- alwaysLoadedTokens: MCP_SERVER_TOKEN_FLOOR,
224
- weightConfidence: "estimated_understated",
224
+ invocationTracking,
225
+ // Config has no tool schema payload. Even alwaysLoad proves intent, not
226
+ // the connected server's runtime schema size.
227
+ alwaysLoadedTokens: 0,
228
+ weightConfidence: "unmeasured",
225
229
  path,
226
230
  ownerDirs
227
231
  });
228
232
  }
229
233
  // --- Installed lifecycle hooks (metadata only; commands are NEVER run) ---
230
234
  const seenHooks = new Set();
235
+ // Ordinary Claude settings can register hooks without an installed plugin.
236
+ // Read only a fixed allowlist of event and hook-type categories. Matcher,
237
+ // command, prompt, URL, headers, environment, and all other runtime payloads
238
+ // are deliberately neither copied nor surfaced in inventory metadata.
239
+ const seenSettingsPaths = new Set();
240
+ for (const source of [
241
+ {
242
+ configPath: settingsPath,
243
+ scope: "user",
244
+ sourceId: "user",
245
+ label: "Claude user settings"
246
+ },
247
+ {
248
+ configPath: join(projectClaude, "settings.json"),
249
+ scope: "project",
250
+ sourceId: "project",
251
+ label: "Claude project settings"
252
+ },
253
+ {
254
+ configPath: join(projectClaude, "settings.local.json"),
255
+ scope: "local",
256
+ sourceId: "project-local",
257
+ label: "Claude project-local settings"
258
+ }
259
+ ]) {
260
+ const resolvedPath = resolve(source.configPath);
261
+ if (seenSettingsPaths.has(resolvedPath))
262
+ continue;
263
+ seenSettingsPaths.add(resolvedPath);
264
+ const settings = await readJson(source.configPath);
265
+ if (!isRecord(settings) || !isRecord(settings.hooks))
266
+ continue;
267
+ const events = Object.entries(settings.hooks)
268
+ .filter(([event, registrations]) => isKnownClaudeHookEvent(event) && hasConfiguredHookRegistration(registrations))
269
+ .map(([event]) => event);
270
+ if (events.length === 0)
271
+ continue;
272
+ scanned.hookManifests += 1;
273
+ for (const event of events) {
274
+ const key = `claude-code:settings:${source.sourceId}:${event}`;
275
+ if (seenHooks.has(key))
276
+ continue;
277
+ seenHooks.add(key);
278
+ scanned.hooks += 1;
279
+ items.push({
280
+ kind: "hook",
281
+ name: `claude-settings:${source.sourceId}:${event}`,
282
+ scope: source.scope,
283
+ group: source.label,
284
+ activation: contextInjectingEvent(event) ? "hook_injected" : "lifecycle_hook",
285
+ host: "claude-code",
286
+ event,
287
+ invocationTracking: "not_observable",
288
+ alwaysLoadedTokens: 0,
289
+ weightConfidence: "unmeasured",
290
+ // contextHealth surfaces this field as provenance. Use a fixed label,
291
+ // never an absolute user/project path.
292
+ path: source.label
293
+ });
294
+ }
295
+ }
231
296
  for (const pluginRoot of pluginRoots) {
232
297
  for (const manifestPath of await pluginManifestPaths(pluginRoot.root)) {
233
298
  const manifest = await readJson(manifestPath);
@@ -422,6 +487,50 @@ function contextInjectingEvent(event) {
422
487
  event === "UserPromptSubmit" ||
423
488
  event === "SubagentStart";
424
489
  }
490
+ // Claude rejects unknown event names, and accepting arbitrary object keys here
491
+ // would let instruction-like config keys flow into MCP/context-health output.
492
+ // Keep this list explicit and update it alongside supported Claude hook events.
493
+ const KNOWN_CLAUDE_HOOK_EVENTS = new Set([
494
+ "PreToolUse",
495
+ "PermissionRequest",
496
+ "PostToolUse",
497
+ "PostToolUseFailure",
498
+ "Notification",
499
+ "UserPromptSubmit",
500
+ "SessionStart",
501
+ "SessionEnd",
502
+ "Stop",
503
+ "SubagentStart",
504
+ "SubagentStop",
505
+ "PreCompact",
506
+ "Setup",
507
+ "TeammateIdle",
508
+ "TaskCompleted",
509
+ "ConfigChange",
510
+ "WorktreeCreate",
511
+ "WorktreeRemove",
512
+ "InstructionsLoaded",
513
+ "Elicitation",
514
+ "ElicitationResult"
515
+ ]);
516
+ const KNOWN_CLAUDE_HOOK_TYPES = new Set([
517
+ "command",
518
+ "prompt",
519
+ "agent",
520
+ "http"
521
+ ]);
522
+ function isKnownClaudeHookEvent(event) {
523
+ return KNOWN_CLAUDE_HOOK_EVENTS.has(event);
524
+ }
525
+ function hasConfiguredHookRegistration(registrations) {
526
+ if (!Array.isArray(registrations))
527
+ return false;
528
+ return registrations.some((registration) => isRecord(registration) &&
529
+ Array.isArray(registration.hooks) &&
530
+ registration.hooks.some((hook) => isRecord(hook) &&
531
+ typeof hook.type === "string" &&
532
+ KNOWN_CLAUDE_HOOK_TYPES.has(hook.type)));
533
+ }
425
534
  function isWithinRoot(path, root) {
426
535
  const resolvedPath = resolve(path);
427
536
  const resolvedRoot = resolve(root);
@@ -439,25 +548,27 @@ function collectMcpServers(config, projectDir, includeAllProjectMcp) {
439
548
  if (!isRecord(config))
440
549
  return [];
441
550
  const byKey = new Map();
442
- const add = (id, scope, ownerDir) => {
443
- // Dedupe by id across all scopes so a server configured in several projects
444
- // is counted once in the global view — but keep EVERY owning project dir,
445
- // because that's where `claude mcp remove` has to run.
446
- const key = includeAllProjectMcp ? id : `${scope}:${id}`;
551
+ const add = (id, value, scope, ownerDir) => {
552
+ // Keep user and local configurations separate. A same-named user server and
553
+ // local override have different ownership, precedence, and rollback.
554
+ const key = `${scope}:${id}`;
555
+ const alwaysLoad = isRecord(value) && value.alwaysLoad === true;
447
556
  const existing = byKey.get(key);
448
557
  if (existing) {
449
558
  if (ownerDir && !existing.ownerDirs.includes(ownerDir))
450
559
  existing.ownerDirs.push(ownerDir);
560
+ existing.alwaysLoad ||= alwaysLoad;
451
561
  return;
452
562
  }
453
- byKey.set(key, { id, scope, ownerDirs: ownerDir ? [ownerDir] : [] });
563
+ byKey.set(key, { id, scope, ownerDirs: ownerDir ? [ownerDir] : [], alwaysLoad });
454
564
  };
455
565
  // Top-level mcpServers are user-scope (global).
456
566
  if (isRecord(config.mcpServers)) {
457
- for (const id of Object.keys(config.mcpServers))
458
- add(id, "user");
567
+ for (const [id, value] of Object.entries(config.mcpServers))
568
+ add(id, value, "user");
459
569
  }
460
- // Per-project mcpServers live under projects[<absolute dir>].mcpServers.
570
+ // Per-project entries in ~/.claude.json are Claude's LOCAL scope (private to
571
+ // the user and active only from that working directory), not project scope.
461
572
  // Global view: collect every project's servers; otherwise just this project's.
462
573
  if (isRecord(config.projects)) {
463
574
  const entries = includeAllProjectMcp
@@ -465,13 +576,36 @@ function collectMcpServers(config, projectDir, includeAllProjectMcp) {
465
576
  : [[projectDir, config.projects[projectDir]]];
466
577
  for (const [dir, projectEntry] of entries) {
467
578
  if (isRecord(projectEntry) && isRecord(projectEntry.mcpServers)) {
468
- for (const id of Object.keys(projectEntry.mcpServers))
469
- add(id, "project", dir);
579
+ for (const [id, value] of Object.entries(projectEntry.mcpServers))
580
+ add(id, value, "local", dir);
470
581
  }
471
582
  }
472
583
  }
473
584
  return [...byKey.values()];
474
585
  }
586
+ function collectProjectMcpServers(config, projectDir) {
587
+ if (!isRecord(config) || !isRecord(config.mcpServers))
588
+ return [];
589
+ return Object.entries(config.mcpServers).map(([id, value]) => ({
590
+ id,
591
+ scope: "project",
592
+ ownerDirs: [projectDir],
593
+ alwaysLoad: isRecord(value) && value.alwaysLoad === true
594
+ }));
595
+ }
596
+ function mergeServerScope(target, server) {
597
+ const key = `${server.host}:${server.scope}:${server.id}`;
598
+ const prior = target.get(key);
599
+ if (!prior) {
600
+ target.set(key, server);
601
+ return;
602
+ }
603
+ for (const ownerDir of server.ownerDirs) {
604
+ if (!prior.ownerDirs.includes(ownerDir))
605
+ prior.ownerDirs.push(ownerDir);
606
+ }
607
+ prior.alwaysLoad ||= server.alwaysLoad;
608
+ }
475
609
  /**
476
610
  * Extract `name` and `description` from a leading `---` fenced YAML block.
477
611
  * Handles quoted values and folded/multi-line descriptions (continuation lines