ai-spend-agent 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.
Files changed (2) hide show
  1. package/dist/index.js +202 -66
  2. package/package.json +4 -4
package/dist/index.js CHANGED
@@ -3,7 +3,7 @@ import { realpathSync } from "node:fs";
3
3
  import { mkdir, readFile, rm, stat } from "node:fs/promises";
4
4
  import { basename, dirname, extname, join, resolve } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
- import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, loadLocalAgentUsage, loadSampleUsageData, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
6
+ import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
7
7
  import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
8
8
  export async function runCli(argv = process.argv.slice(2)) {
9
9
  if (argv.includes("--version") || argv.includes("-v")) {
@@ -82,7 +82,10 @@ export async function runCli(argv = process.argv.slice(2)) {
82
82
  };
83
83
  }
84
84
  async function quickstartCommand(args) {
85
- const { records, mode, warnings } = await loadInstantReadData(args);
85
+ const sinceDays = args.sinceDays ?? 30;
86
+ if (!validSinceDays(sinceDays))
87
+ return invalidSinceDaysResult();
88
+ const { records, mode, warnings, codexInvocationFiles } = await loadInstantReadData(args);
86
89
  const summaryRecords = mode === "connected"
87
90
  ? selectProviderFinancialHeadlineRecords(records)
88
91
  : records;
@@ -94,7 +97,12 @@ async function quickstartCommand(args) {
94
97
  // Persona: --plan override wins; otherwise read the plans the coding agents
95
98
  // themselves persisted locally (read-only, whitelisted fields, no network).
96
99
  let detectedPlans;
97
- if (args.plan) {
100
+ if (args.sample) {
101
+ // An explicit sample run must be deterministic and safe to record/share.
102
+ // Never mix the developer's real local plan into illustrative output.
103
+ detectedPlans = [];
104
+ }
105
+ else if (args.plan) {
98
106
  const override = planOverrideFromFlag(args.plan);
99
107
  if (!override) {
100
108
  return {
@@ -114,7 +122,15 @@ async function quickstartCommand(args) {
114
122
  }
115
123
  // Surface auto-detected credentials so the user knows their next 2-min step,
116
124
  // without ever printing a raw secret.
117
- const detection = await detectLocalCredentials({ cwd: resolve(args.path) });
125
+ // Sample output is designed for demos, docs, and screenshots. Keeping local
126
+ // credential discovery out of that path prevents even redacted machine-
127
+ // specific hints from leaking into a recording.
128
+ const detection = args.sample
129
+ ? { credentials: [], scannedFiles: [] }
130
+ : await detectLocalCredentials({
131
+ cwd: resolve(args.path),
132
+ home: process.env.AI_SPEND_CLAUDE_HOME_DIR
133
+ });
118
134
  const nextSteps = quickstartNextSteps(mode, detection.credentials);
119
135
  // Dead-context cost, globalized across the user's whole Claude Code setup
120
136
  // (all projects' MCP + user-scope skills/agents/commands, vs. every
@@ -132,8 +148,9 @@ async function quickstartCommand(args) {
132
148
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
133
149
  projectDir: resolve(args.path),
134
150
  includeAllProjectMcp: true,
135
- sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
136
- windowDays: 30
151
+ sinceIso: sinceIsoForDays(sinceDays),
152
+ windowDays: sinceDays,
153
+ codexInvocationFiles
137
154
  }).catch(() => undefined);
138
155
  // Sample dead-context is shown ONLY on the demo readout. A real readout
139
156
  // (local logs / connected billing) never gets fabricated waste injected —
@@ -158,21 +175,21 @@ async function quickstartCommand(args) {
158
175
  }
159
176
  async function glanceCommand(args) {
160
177
  const sinceDays = args.sinceDays ?? 30;
161
- if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
162
- return {
163
- exitCode: 1,
164
- stdout: "",
165
- stderr: "--since-days must be a whole number between 1 and 365"
166
- };
167
- }
178
+ if (!validSinceDays(sinceDays))
179
+ return invalidSinceDaysResult();
168
180
  const logs = await loadLocalAgentUsage({
169
181
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
170
182
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
171
- sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString()
183
+ sinceIso: sinceIsoForDays(sinceDays),
184
+ collectCodexInvocationEvidence: true
172
185
  });
173
186
  const calls = args.project
174
187
  ? logs.calls.filter((call) => call.project === args.project)
175
188
  : logs.calls;
189
+ const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
190
+ const contextProjectDir = args.pathExplicit
191
+ ? resolve(args.path)
192
+ : latestWorkingDirectory ?? resolve(args.path);
176
193
  let detectedPlans;
177
194
  if (args.plan) {
178
195
  const override = planOverrideFromFlag(args.plan);
@@ -198,9 +215,10 @@ async function glanceCommand(args) {
198
215
  codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
199
216
  claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
200
217
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
201
- projectDir: resolve(args.path),
202
- sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString(),
203
- windowDays: sinceDays
218
+ projectDir: contextProjectDir,
219
+ sinceIso: sinceIsoForDays(sinceDays),
220
+ windowDays: sinceDays,
221
+ codexInvocationFiles: logs.codexInvocationFiles
204
222
  });
205
223
  const snapshot = buildUsageGlance(calls, {
206
224
  filesParsed: logs.filesParsed,
@@ -213,18 +231,14 @@ async function glanceCommand(args) {
213
231
  }
214
232
  async function contextHealthCommand(args) {
215
233
  const sinceDays = args.sinceDays ?? 30;
216
- if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
217
- return {
218
- exitCode: 1,
219
- stdout: "",
220
- stderr: "--since-days must be a whole number between 1 and 365"
221
- };
222
- }
223
- const sinceIso = new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString();
234
+ if (!validSinceDays(sinceDays))
235
+ return invalidSinceDaysResult();
236
+ const sinceIso = sinceIsoForDays(sinceDays);
224
237
  const logs = await loadLocalAgentUsage({
225
238
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
226
239
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
227
- sinceIso
240
+ sinceIso,
241
+ collectCodexInvocationEvidence: true
228
242
  });
229
243
  const calls = args.project
230
244
  ? logs.calls.filter((call) => call.project === args.project)
@@ -238,7 +252,8 @@ async function contextHealthCommand(args) {
238
252
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
239
253
  projectDir: resolve(args.path),
240
254
  sinceIso,
241
- windowDays: sinceDays
255
+ windowDays: sinceDays,
256
+ codexInvocationFiles: logs.codexInvocationFiles
242
257
  });
243
258
  return ok(args.json ? JSON.stringify(health) : renderContextHealth(health));
244
259
  }
@@ -254,20 +269,23 @@ function renderContextHealth(health) {
254
269
  `Confidence: ${health.confidence}`,
255
270
  "",
256
271
  "Activation",
257
- ` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP schema-loaded: ${activation.mcpSchemaLoadedItems}`,
272
+ ` Discoverable: ${activation.discoverableItems} Invoked: ${activation.explicitlyInvokedItems} MCP configured: ${activation.mcpConfiguredItems}`,
273
+ ` MCP always-load requested: ${activation.mcpAlwaysLoadedItems} Legacy schema-loaded label: ${activation.mcpSchemaLoadedItems}`,
258
274
  ` Hook-injected: ${activation.hookInjectedItems} Other lifecycle hooks: ${activation.lifecycleHooks} Unmeasured weight: ${activation.unmeasuredItems}`,
259
275
  ` Invocation-unobservable: ${activation.invocationUnobservableItems}`,
260
276
  "",
261
- `Never invoked among observable inventory (${dead.windowDays}d): ${dead.neverInvokedItems}/${dead.loadedItems} ` +
277
+ `No matching invocation among observable inventory (${dead.windowDays}d): ${dead.neverInvokedItems}/${dead.loadedItems} ` +
262
278
  `(${dead.measuredNeverInvokedItems} measured, ${dead.unmeasuredNeverInvokedItems} unmeasured)`
263
279
  ];
264
280
  if (health.currentSession) {
265
281
  const session = health.currentSession;
266
- lines.push(`Session: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
267
- `${session.totalTokens.toLocaleString("en-US")} tokens · ` +
268
- (session.ratioToMedian === null
269
- ? "no same-agent baseline"
270
- : `${session.ratioToMedian median (${session.comparisonSessions} prior)`));
282
+ lines.push(`Latest turn: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
283
+ (session.usageSource === "not_available"
284
+ ? "input context unavailable; cumulative lifetime usage excluded"
285
+ : `${session.contextTokens.toLocaleString("en-US")} input context tokens · ` +
286
+ (session.ratioToMedian === null
287
+ ? `${session.comparisonSessions} comparable prior session${session.comparisonSessions === 1 ? "" : "s"}; baseline not yet sufficient`
288
+ : `${session.ratioCapped ? "at least " : ""}${session.ratioToMedian}× comparable median (${session.comparisonSessions} prior)`)));
271
289
  }
272
290
  const churn = health.contextChurn;
273
291
  if (churn.currentSessionEvidence === "matched") {
@@ -331,7 +349,9 @@ async function loadInstantReadData(args) {
331
349
  const logs = await loadLocalAgentUsage({
332
350
  // Env overrides keep tests (and unusual installs) isolated from $HOME.
333
351
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
334
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
352
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
353
+ sinceIso: sinceIsoForDays(args.sinceDays ?? 30),
354
+ collectCodexInvocationEvidence: true
335
355
  }).catch(() => undefined);
336
356
  if (logs && logs.records.length > 0) {
337
357
  // Persisted local_logs state (written by report/apply-artifact) is the
@@ -340,7 +360,12 @@ async function loadInstantReadData(args) {
340
360
  if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
341
361
  warnings.push("Ignored persisted sample/legacy state in .ai-spend-agent/spend.json — showing your real local agent logs. Run `ai-spend-agent reset` to clear it, or pass --ignore-state.");
342
362
  }
343
- return { records: logs.records, mode: "local-logs", warnings };
363
+ return {
364
+ records: logs.records,
365
+ mode: "local-logs",
366
+ warnings,
367
+ codexInvocationFiles: logs.codexInvocationFiles
368
+ };
344
369
  }
345
370
  // No real logs. Persisted sample/legacy state may still be shown, but only as
346
371
  // DEMO (never as connected), with a warning when its origin is unknown.
@@ -375,7 +400,10 @@ async function doctorCommand(args) {
375
400
  const claudeFound = detected.includes("claude-code");
376
401
  const codexFound = detected.includes("codex");
377
402
  const hasLogs = claudeFound || codexFound;
378
- const detection = await detectLocalCredentials({ cwd: rootPath }).catch(() => ({ credentials: [] }));
403
+ const detection = await detectLocalCredentials({
404
+ cwd: rootPath,
405
+ home: process.env.AI_SPEND_CLAUDE_HOME_DIR
406
+ }).catch(() => ({ credentials: [] }));
379
407
  const providerRefs = detection.credentials.map((credential) => `${credential.provider} (${credential.hint})`);
380
408
  const plans = await detectLocalPlans({
381
409
  claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
@@ -399,7 +427,7 @@ async function doctorCommand(args) {
399
427
  ? "your local agent logs (estimated at API-equivalent rates)"
400
428
  : "demo sample (illustrative)";
401
429
  const lines = [
402
- "AI Spend Analyst doctor",
430
+ "aibill doctor",
403
431
  `node version: ${process.version}`,
404
432
  `cli version: ${await cliVersion()}`,
405
433
  "local-first mode: enabled (no cloud upload, no telemetry)",
@@ -437,7 +465,7 @@ async function resetCommand(args) {
437
465
  if (!isNodeError(error, "ENOENT"))
438
466
  throw error;
439
467
  return ok([
440
- "AI Spend Analyst reset",
468
+ "aibill reset",
441
469
  `path: ${rootPath}`,
442
470
  "nothing to clear (no persisted spend state found)",
443
471
  "next run will re-read your real local agent logs (or demo sample if none)."
@@ -457,7 +485,7 @@ async function resetCommand(args) {
457
485
  }
458
486
  }
459
487
  return ok([
460
- "AI Spend Analyst reset",
488
+ "aibill reset",
461
489
  `path: ${rootPath}`,
462
490
  removed.length > 0 ? `cleared: ${removed.join(", ")}` : "nothing to clear (no persisted spend state found)",
463
491
  "next run will re-read your real local agent logs (or demo sample if none)."
@@ -469,7 +497,7 @@ async function initCommand(args) {
469
497
  await mkdir(stateDir, { recursive: true });
470
498
  const registry = createLocalFolderSourceRegistry(rootPath);
471
499
  await writeJson(join(stateDir, "manifest.json"), {
472
- product: "AI Spend Analyst Agent",
500
+ product: "aibill",
473
501
  mode: "local-first-demo",
474
502
  cloudUpload: false,
475
503
  cronJobsEnabled: false,
@@ -493,7 +521,7 @@ async function initCommand(args) {
493
521
  }
494
522
  ]));
495
523
  return ok([
496
- "AI Spend Analyst Agent init",
524
+ "aibill init",
497
525
  `path: ${rootPath}`,
498
526
  "demo mode: local-first sample workflow",
499
527
  "cloud upload: disabled",
@@ -570,7 +598,7 @@ async function scanCommand(args) {
570
598
  await writeJson(join(stateDir, "discovery.json"), discovery);
571
599
  await writeJson(join(stateDir, "missing-sources.json"), missingSourcePrompts);
572
600
  const lines = [
573
- "AI Spend Analyst Agent scan",
601
+ "aibill scan",
574
602
  `path: ${rootPath}`,
575
603
  "source registry: .ai-spend-agent/sources.json",
576
604
  "audit log: .ai-spend-agent/audit-log.json",
@@ -779,7 +807,7 @@ async function addSourceCommand(args) {
779
807
  detail: `${args.sourceType} approved via CLI add-source.`
780
808
  });
781
809
  return ok([
782
- "AI Spend Analyst Agent add-source",
810
+ "aibill add-source",
783
811
  `source added: ${id}`,
784
812
  `type: ${args.sourceType}`,
785
813
  `path: ${sourcePath}`,
@@ -792,7 +820,7 @@ async function listSourcesCommand(args) {
792
820
  const stateDir = join(rootPath, ".ai-spend-agent");
793
821
  const registry = await readSourceRegistry(stateDir, rootPath);
794
822
  const lines = [
795
- "AI Spend Analyst Agent sources",
823
+ "aibill sources",
796
824
  `approved sources: ${registry.approvedSources.length}`
797
825
  ];
798
826
  for (const source of registry.approvedSources) {
@@ -847,10 +875,13 @@ async function connectCommand(args) {
847
875
  detail: `${provider} ${type} connector stub registered. No raw secrets stored.`
848
876
  });
849
877
  // Auto-detect a local key for this provider (never prints the raw value).
850
- const detection = await detectLocalCredentials({ cwd: rootPath });
878
+ const detection = await detectLocalCredentials({
879
+ cwd: rootPath,
880
+ home: process.env.AI_SPEND_CLAUDE_HOME_DIR
881
+ });
851
882
  const detected = detection.credentials.find((credential) => credential.provider === provider);
852
883
  const lines = [
853
- "AI Spend Analyst Agent connect",
884
+ "aibill connect",
854
885
  `connector stub: ${source.id}`,
855
886
  `provider: ${provider}`,
856
887
  `type: ${type}`,
@@ -983,7 +1014,7 @@ async function syncProviderCommand(args) {
983
1014
  detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
984
1015
  });
985
1016
  return ok([
986
- "AI Spend Analyst Agent sync-provider",
1017
+ "aibill sync-provider",
987
1018
  `provider: ${result.provider}`,
988
1019
  `source: ${result.source.id}`,
989
1020
  `verification: ${result.source.verification}`,
@@ -1035,7 +1066,7 @@ async function confirmMappingCommand(args) {
1035
1066
  detail: `${args.provider} mapped to ${[args.team, args.project, args.workflow].filter(Boolean).join(" / ")}`
1036
1067
  });
1037
1068
  return ok([
1038
- "AI Spend Analyst Agent confirm-mapping",
1069
+ "aibill confirm-mapping",
1039
1070
  `mapping confirmed: ${mapping.id}`,
1040
1071
  `provider: ${mapping.provider}`,
1041
1072
  `target: ${[mapping.team, mapping.project, mapping.workflow].filter(Boolean).join(" / ")}`,
@@ -1046,7 +1077,10 @@ async function reportCommand(args) {
1046
1077
  const rootPath = resolve(args.path);
1047
1078
  const stateDir = join(rootPath, ".ai-spend-agent");
1048
1079
  try {
1049
- const reportInput = await buildReportInput(stateDir, rootPath);
1080
+ const sinceDays = args.sinceDays ?? 30;
1081
+ if (!validSinceDays(sinceDays))
1082
+ return invalidSinceDaysResult();
1083
+ const reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
1050
1084
  const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
1051
1085
  const markdownPath = `${outBase}.md`;
1052
1086
  const htmlPath = `${outBase}.html`;
@@ -1054,7 +1088,7 @@ async function reportCommand(args) {
1054
1088
  await writeLocalReportFile(htmlPath, generateHtmlReport(reportInput), stateDir);
1055
1089
  const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
1056
1090
  return ok([
1057
- "AI Spend Analyst Agent report",
1091
+ "aibill report",
1058
1092
  `path: ${rootPath}`,
1059
1093
  `markdown: ${markdownPath}`,
1060
1094
  `html: ${htmlPath}`,
@@ -1063,8 +1097,8 @@ async function reportCommand(args) {
1063
1097
  `policy/config draft: ${artifactPaths.policyConfigDraft}`,
1064
1098
  `verification plan: ${artifactPaths.verificationPlan}`,
1065
1099
  `demo package: ${artifactPaths.demoPackage}`,
1066
- `total spend: $${reportInput.summary.totalUsd.toFixed(2)}`,
1067
- "privacy: local files only; no cloud upload performed",
1100
+ `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
1101
+ "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
1068
1102
  "",
1069
1103
  "next:",
1070
1104
  ` open ${htmlPath} view the full report in your browser`,
@@ -1118,7 +1152,7 @@ async function reportCardCommand(args) {
1118
1152
  "Caption to share:",
1119
1153
  generateReportCardCaption({ summary, records: headlineRecords, mode }),
1120
1154
  "",
1121
- "privacy: rendered locally; only totals, modeled opportunities, and model-level investigations are included."
1155
+ "privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
1122
1156
  ].join("\n"));
1123
1157
  }
1124
1158
  catch (error) {
@@ -1133,13 +1167,34 @@ async function applyArtifactCommand(args) {
1133
1167
  const rootPath = resolve(args.path);
1134
1168
  const stateDir = join(rootPath, ".ai-spend-agent");
1135
1169
  try {
1136
- const reportInput = await buildReportInput(stateDir, rootPath);
1170
+ const sinceDays = args.sinceDays ?? 30;
1171
+ if (!validSinceDays(sinceDays))
1172
+ return invalidSinceDaysResult();
1173
+ // `--sample` is a privacy boundary, not presentation sugar. It must never
1174
+ // fall through to live transcript, plan, credential, or persisted-state
1175
+ // discovery — regardless of where the flag appears after the command.
1176
+ const reportInput = args.sample
1177
+ ? await buildExplicitSampleReportInput(rootPath)
1178
+ : await buildReportInput(stateDir, rootPath, sinceDays);
1179
+ await mkdir(stateDir, { recursive: true });
1137
1180
  const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
1138
1181
  // The prompt IS the product of this command — print it so a terminal
1139
1182
  // user can copy it right here instead of hunting for a file path.
1140
1183
  const codingPrompt = await readFile(artifactPaths.codingPrompt, "utf8");
1184
+ if (args.sample) {
1185
+ return ok([
1186
+ "aibill apply-artifact",
1187
+ "data: DEMO sample data (illustrative — not your logs, account, bill, project, or workflow)",
1188
+ "artifacts: .ai-spend-agent/ (non-executable demo files)",
1189
+ "safety: no live transcripts, account metadata, credentials, or persisted spend state were read",
1190
+ "",
1191
+ "──── non-executable demo prompt (also saved under .ai-spend-agent/) ────",
1192
+ "",
1193
+ codingPrompt.trimEnd()
1194
+ ].join("\n"));
1195
+ }
1141
1196
  return ok([
1142
- "AI Spend Analyst Agent apply-artifact",
1197
+ "aibill apply-artifact",
1143
1198
  `path: ${rootPath}`,
1144
1199
  `action plan: ${artifactPaths.actionPlan}`,
1145
1200
  `policy/config draft: ${artifactPaths.policyConfigDraft}`,
@@ -1160,9 +1215,51 @@ async function applyArtifactCommand(args) {
1160
1215
  };
1161
1216
  }
1162
1217
  }
1163
- async function buildReportInput(stateDir, rootPath) {
1218
+ async function buildExplicitSampleReportInput(rootPath) {
1219
+ const records = await loadSampleUsageData();
1220
+ return {
1221
+ // Fixed alongside the bundled fixture so repeated demo runs stay stable
1222
+ // and cannot absorb this machine's clock or account state into an asset.
1223
+ generatedAt: "2026-05-20T00:00:00.000Z",
1224
+ summary: analyzeSpend(records),
1225
+ allRecords: records,
1226
+ dataMode: "sample",
1227
+ discovery: emptyDiscovery(rootPath),
1228
+ mappings: attributeUsageRecords(records),
1229
+ missingSourcePrompts: [],
1230
+ confirmedMappings: [],
1231
+ providerRecords: [],
1232
+ providerQa: [],
1233
+ deadContext: sampleDeadContext(),
1234
+ detectedPlans: []
1235
+ };
1236
+ }
1237
+ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
1238
+ // One anchor for logs, Context Health, the paste-ready prompt, and every
1239
+ // supporting Apply artifact. This prevents millisecond window drift between
1240
+ // files generated by the same command.
1241
+ const generatedAt = new Date();
1242
+ const sinceIso = sinceIsoForDays(sinceDays, generatedAt);
1243
+ let freshLocalCalls;
1244
+ let freshCodexInvocationFiles;
1164
1245
  let spendState = await readOptionalJson(join(stateDir, "spend.json"), undefined);
1165
1246
  let mappings = await readOptionalJson(join(stateDir, "mappings.json"), undefined);
1247
+ // Never trust a persisted summary or an absent mode. Re-parse the records,
1248
+ // recover the narrowly identifiable bundled sample written by older
1249
+ // releases, and recompute decision output under the current evidence rules.
1250
+ // Any other unlabeled state remains unlabeled and therefore non-executable.
1251
+ if (spendState?.records && spendState.records.length > 0) {
1252
+ const records = spendState.records.map((record) => parseUsageRecord(record));
1253
+ const mode = spendState.mode ?? (isBundledSampleUsage(records) ? "sample" : undefined);
1254
+ const headlineRecords = mode === "connected_provider"
1255
+ ? selectProviderFinancialHeadlineRecords(records)
1256
+ : records;
1257
+ spendState = {
1258
+ records,
1259
+ mode,
1260
+ summary: analyzeSpend(headlineRecords)
1261
+ };
1262
+ }
1166
1263
  // Local-log state is a CACHE, not a source of truth: the quickstart always
1167
1264
  // re-reads the logs fresh, so report/apply must too — otherwise yesterday's
1168
1265
  // persisted snapshot makes the artifact's numbers disagree with the screen.
@@ -1182,9 +1279,13 @@ async function buildReportInput(stateDir, rootPath) {
1182
1279
  if (needsFreshLogs) {
1183
1280
  const logs = await loadLocalAgentUsage({
1184
1281
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1185
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
1282
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1283
+ sinceIso,
1284
+ collectCodexInvocationEvidence: true
1186
1285
  }).catch(() => undefined);
1187
1286
  if (logs && logs.records.length > 0) {
1287
+ freshLocalCalls = logs.calls;
1288
+ freshCodexInvocationFiles = logs.codexInvocationFiles;
1188
1289
  const records = logs.records;
1189
1290
  const summary = analyzeSpend(records);
1190
1291
  const liveMappings = attributeUsageRecords(records);
@@ -1218,8 +1319,9 @@ async function buildReportInput(stateDir, rootPath) {
1218
1319
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
1219
1320
  projectDir: rootPath,
1220
1321
  includeAllProjectMcp: true,
1221
- sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
1222
- windowDays: 30
1322
+ sinceIso,
1323
+ windowDays: sinceDays,
1324
+ codexInvocationFiles: freshCodexInvocationFiles
1223
1325
  }).catch(() => undefined)
1224
1326
  : undefined;
1225
1327
  const detectedPlans = spendState.mode === "local_logs"
@@ -1228,10 +1330,30 @@ async function buildReportInput(stateDir, rootPath) {
1228
1330
  codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
1229
1331
  }).catch(() => [])
1230
1332
  : [];
1333
+ // Report/apply and Glance consume the same canonical Context Health result.
1334
+ // If live transcript calls are unavailable, omit it instead of fabricating a
1335
+ // session-level recommendation from day-aggregate spend records.
1336
+ const contextHealth = spendState.mode === "local_logs" && freshLocalCalls
1337
+ ? await loadContextHealth(freshLocalCalls, {
1338
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1339
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1340
+ claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
1341
+ codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
1342
+ claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
1343
+ claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
1344
+ projectDir: rootPath,
1345
+ includeAllProjectMcp: true,
1346
+ sinceIso,
1347
+ windowDays: sinceDays,
1348
+ codexInvocationFiles: freshCodexInvocationFiles
1349
+ }).catch(() => undefined)
1350
+ : undefined;
1231
1351
  return {
1352
+ generatedAt: generatedAt.toISOString(),
1232
1353
  summary: spendState.summary,
1233
1354
  deadContext,
1234
1355
  detectedPlans,
1356
+ contextHealth,
1235
1357
  // Evidence ledger is built from the SAME records as the confidence
1236
1358
  // breakdown so the two sections can never contradict each other.
1237
1359
  allRecords: spendState.mode === "connected_provider"
@@ -1247,6 +1369,19 @@ async function buildReportInput(stateDir, rootPath) {
1247
1369
  providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
1248
1370
  };
1249
1371
  }
1372
+ function validSinceDays(value) {
1373
+ return Number.isInteger(value) && value >= 1 && value <= 365;
1374
+ }
1375
+ function sinceIsoForDays(value, now = new Date()) {
1376
+ return new Date(now.getTime() - value * 24 * 60 * 60 * 1_000).toISOString();
1377
+ }
1378
+ function invalidSinceDaysResult() {
1379
+ return {
1380
+ exitCode: 1,
1381
+ stdout: "",
1382
+ stderr: "--since-days must be a whole number between 1 and 365"
1383
+ };
1384
+ }
1250
1385
  function emptyDiscovery(rootPath) {
1251
1386
  return {
1252
1387
  rootPath,
@@ -1348,6 +1483,7 @@ function parseArgs(argv) {
1348
1483
  const next = rest[index + 1];
1349
1484
  if (next) {
1350
1485
  parsed.path = next;
1486
+ parsed.pathExplicit = true;
1351
1487
  index += 1;
1352
1488
  }
1353
1489
  continue;
@@ -1644,20 +1780,20 @@ function helpText() {
1644
1780
  " --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
1645
1781
  " scan [--path <dir>] Scan a local workspace for AI usage signals",
1646
1782
  " scan --sample Include deterministic sample spend analysis",
1647
- " quickstart [--sample] Plain-English local readout (alias of the default run)",
1648
- " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
1649
- " report [--out <name>] Generate local Markdown and HTML reports",
1783
+ " quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
1784
+ " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
1785
+ " report [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
1650
1786
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
1651
- " glance [--project <name>] [--plan <id>] Emit the local, machine-readable Glance snapshot JSON",
1787
+ " glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
1652
1788
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
1653
1789
  " [--json] Emit the same canonical Context Health object used by MCP and Glance",
1654
- " apply Print the paste-ready coding-agent prompt + write action/policy/verification plans",
1790
+ " apply [--sample] [--since-days N] Print an evidence-constrained inspection/approval prompt + verification plans",
1655
1791
  " apply-artifact Same as `apply` (long form)",
1656
1792
  "",
1657
1793
  "Cron (production watch): add a crontab entry such as:",
1658
1794
  " 0 * * * * cd /path/to/workspace && ai-spend-agent watch --interval 3600 --cycles 1 >> ai-spend-watch.log 2>&1",
1659
1795
  "",
1660
- "Privacy: local-first. No files, credentials, or spend data are uploaded. Secrets are never printed."
1796
+ "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference; secrets are never printed or persisted."
1661
1797
  ].join("\n");
1662
1798
  }
1663
1799
  // Main-module check that survives npm's bin SYMLINKS: argv[1] is
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.5.7",
4
- "description": "Local-first financial intelligence CLI for Claude Code and Codex activity, provider-reported cost, attribution, runway, provenance, and Context Health.",
3
+ "version": "0.5.9",
4
+ "description": "Local-first financial accountability CLI for Claude Code and Codex work, cost evidence, attribution, runway, provenance, and Context Health.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -54,8 +54,8 @@
54
54
  "prepack": "npm run build"
55
55
  },
56
56
  "dependencies": {
57
- "@agent-finops/core": "0.5.7",
58
- "@agent-finops/report": "0.5.7",
57
+ "@agent-finops/core": "0.5.9",
58
+ "@agent-finops/report": "0.5.9",
59
59
  "yocto-spinner": "^1.2.0"
60
60
  }
61
61
  }