ai-spend-agent 0.5.8 → 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 +178 -52
  2. package/package.json +3 -3
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;
@@ -124,7 +127,10 @@ async function quickstartCommand(args) {
124
127
  // specific hints from leaking into a recording.
125
128
  const detection = args.sample
126
129
  ? { credentials: [], scannedFiles: [] }
127
- : await detectLocalCredentials({ cwd: resolve(args.path) });
130
+ : await detectLocalCredentials({
131
+ cwd: resolve(args.path),
132
+ home: process.env.AI_SPEND_CLAUDE_HOME_DIR
133
+ });
128
134
  const nextSteps = quickstartNextSteps(mode, detection.credentials);
129
135
  // Dead-context cost, globalized across the user's whole Claude Code setup
130
136
  // (all projects' MCP + user-scope skills/agents/commands, vs. every
@@ -142,8 +148,9 @@ async function quickstartCommand(args) {
142
148
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
143
149
  projectDir: resolve(args.path),
144
150
  includeAllProjectMcp: true,
145
- sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
146
- windowDays: 30
151
+ sinceIso: sinceIsoForDays(sinceDays),
152
+ windowDays: sinceDays,
153
+ codexInvocationFiles
147
154
  }).catch(() => undefined);
148
155
  // Sample dead-context is shown ONLY on the demo readout. A real readout
149
156
  // (local logs / connected billing) never gets fabricated waste injected —
@@ -168,21 +175,21 @@ async function quickstartCommand(args) {
168
175
  }
169
176
  async function glanceCommand(args) {
170
177
  const sinceDays = args.sinceDays ?? 30;
171
- if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
172
- return {
173
- exitCode: 1,
174
- stdout: "",
175
- stderr: "--since-days must be a whole number between 1 and 365"
176
- };
177
- }
178
+ if (!validSinceDays(sinceDays))
179
+ return invalidSinceDaysResult();
178
180
  const logs = await loadLocalAgentUsage({
179
181
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
180
182
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
181
- sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString()
183
+ sinceIso: sinceIsoForDays(sinceDays),
184
+ collectCodexInvocationEvidence: true
182
185
  });
183
186
  const calls = args.project
184
187
  ? logs.calls.filter((call) => call.project === args.project)
185
188
  : logs.calls;
189
+ const latestWorkingDirectory = latestObservedWorkingDirectory(calls);
190
+ const contextProjectDir = args.pathExplicit
191
+ ? resolve(args.path)
192
+ : latestWorkingDirectory ?? resolve(args.path);
186
193
  let detectedPlans;
187
194
  if (args.plan) {
188
195
  const override = planOverrideFromFlag(args.plan);
@@ -208,9 +215,10 @@ async function glanceCommand(args) {
208
215
  codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
209
216
  claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
210
217
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
211
- projectDir: resolve(args.path),
212
- sinceIso: new Date(Date.now() - sinceDays * 24 * 60 * 60 * 1_000).toISOString(),
213
- windowDays: sinceDays
218
+ projectDir: contextProjectDir,
219
+ sinceIso: sinceIsoForDays(sinceDays),
220
+ windowDays: sinceDays,
221
+ codexInvocationFiles: logs.codexInvocationFiles
214
222
  });
215
223
  const snapshot = buildUsageGlance(calls, {
216
224
  filesParsed: logs.filesParsed,
@@ -223,18 +231,14 @@ async function glanceCommand(args) {
223
231
  }
224
232
  async function contextHealthCommand(args) {
225
233
  const sinceDays = args.sinceDays ?? 30;
226
- if (!Number.isInteger(sinceDays) || sinceDays < 1 || sinceDays > 365) {
227
- return {
228
- exitCode: 1,
229
- stdout: "",
230
- stderr: "--since-days must be a whole number between 1 and 365"
231
- };
232
- }
233
- 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);
234
237
  const logs = await loadLocalAgentUsage({
235
238
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
236
239
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
237
- sinceIso
240
+ sinceIso,
241
+ collectCodexInvocationEvidence: true
238
242
  });
239
243
  const calls = args.project
240
244
  ? logs.calls.filter((call) => call.project === args.project)
@@ -248,7 +252,8 @@ async function contextHealthCommand(args) {
248
252
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
249
253
  projectDir: resolve(args.path),
250
254
  sinceIso,
251
- windowDays: sinceDays
255
+ windowDays: sinceDays,
256
+ codexInvocationFiles: logs.codexInvocationFiles
252
257
  });
253
258
  return ok(args.json ? JSON.stringify(health) : renderContextHealth(health));
254
259
  }
@@ -264,20 +269,23 @@ function renderContextHealth(health) {
264
269
  `Confidence: ${health.confidence}`,
265
270
  "",
266
271
  "Activation",
267
- ` 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}`,
268
274
  ` Hook-injected: ${activation.hookInjectedItems} Other lifecycle hooks: ${activation.lifecycleHooks} Unmeasured weight: ${activation.unmeasuredItems}`,
269
275
  ` Invocation-unobservable: ${activation.invocationUnobservableItems}`,
270
276
  "",
271
- `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} ` +
272
278
  `(${dead.measuredNeverInvokedItems} measured, ${dead.unmeasuredNeverInvokedItems} unmeasured)`
273
279
  ];
274
280
  if (health.currentSession) {
275
281
  const session = health.currentSession;
276
- lines.push(`Session: ${session.agent}${session.project ? ` · ${session.project}` : ""} · ` +
277
- `${session.totalTokens.toLocaleString("en-US")} tokens · ` +
278
- (session.ratioToMedian === null
279
- ? "no same-agent baseline"
280
- : `${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)`)));
281
289
  }
282
290
  const churn = health.contextChurn;
283
291
  if (churn.currentSessionEvidence === "matched") {
@@ -341,7 +349,9 @@ async function loadInstantReadData(args) {
341
349
  const logs = await loadLocalAgentUsage({
342
350
  // Env overrides keep tests (and unusual installs) isolated from $HOME.
343
351
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
344
- 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
345
355
  }).catch(() => undefined);
346
356
  if (logs && logs.records.length > 0) {
347
357
  // Persisted local_logs state (written by report/apply-artifact) is the
@@ -350,7 +360,12 @@ async function loadInstantReadData(args) {
350
360
  if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
351
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.");
352
362
  }
353
- 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
+ };
354
369
  }
355
370
  // No real logs. Persisted sample/legacy state may still be shown, but only as
356
371
  // DEMO (never as connected), with a warning when its origin is unknown.
@@ -385,7 +400,10 @@ async function doctorCommand(args) {
385
400
  const claudeFound = detected.includes("claude-code");
386
401
  const codexFound = detected.includes("codex");
387
402
  const hasLogs = claudeFound || codexFound;
388
- 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: [] }));
389
407
  const providerRefs = detection.credentials.map((credential) => `${credential.provider} (${credential.hint})`);
390
408
  const plans = await detectLocalPlans({
391
409
  claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
@@ -857,7 +875,10 @@ async function connectCommand(args) {
857
875
  detail: `${provider} ${type} connector stub registered. No raw secrets stored.`
858
876
  });
859
877
  // Auto-detect a local key for this provider (never prints the raw value).
860
- const detection = await detectLocalCredentials({ cwd: rootPath });
878
+ const detection = await detectLocalCredentials({
879
+ cwd: rootPath,
880
+ home: process.env.AI_SPEND_CLAUDE_HOME_DIR
881
+ });
861
882
  const detected = detection.credentials.find((credential) => credential.provider === provider);
862
883
  const lines = [
863
884
  "aibill connect",
@@ -1056,7 +1077,10 @@ async function reportCommand(args) {
1056
1077
  const rootPath = resolve(args.path);
1057
1078
  const stateDir = join(rootPath, ".ai-spend-agent");
1058
1079
  try {
1059
- 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);
1060
1084
  const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
1061
1085
  const markdownPath = `${outBase}.md`;
1062
1086
  const htmlPath = `${outBase}.html`;
@@ -1073,8 +1097,8 @@ async function reportCommand(args) {
1073
1097
  `policy/config draft: ${artifactPaths.policyConfigDraft}`,
1074
1098
  `verification plan: ${artifactPaths.verificationPlan}`,
1075
1099
  `demo package: ${artifactPaths.demoPackage}`,
1076
- `total spend: $${reportInput.summary.totalUsd.toFixed(2)}`,
1077
- "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",
1078
1102
  "",
1079
1103
  "next:",
1080
1104
  ` open ${htmlPath} view the full report in your browser`,
@@ -1128,7 +1152,7 @@ async function reportCardCommand(args) {
1128
1152
  "Caption to share:",
1129
1153
  generateReportCardCaption({ summary, records: headlineRecords, mode }),
1130
1154
  "",
1131
- "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."
1132
1156
  ].join("\n"));
1133
1157
  }
1134
1158
  catch (error) {
@@ -1143,11 +1167,32 @@ async function applyArtifactCommand(args) {
1143
1167
  const rootPath = resolve(args.path);
1144
1168
  const stateDir = join(rootPath, ".ai-spend-agent");
1145
1169
  try {
1146
- 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 });
1147
1180
  const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
1148
1181
  // The prompt IS the product of this command — print it so a terminal
1149
1182
  // user can copy it right here instead of hunting for a file path.
1150
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
+ }
1151
1196
  return ok([
1152
1197
  "aibill apply-artifact",
1153
1198
  `path: ${rootPath}`,
@@ -1170,9 +1215,51 @@ async function applyArtifactCommand(args) {
1170
1215
  };
1171
1216
  }
1172
1217
  }
1173
- 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;
1174
1245
  let spendState = await readOptionalJson(join(stateDir, "spend.json"), undefined);
1175
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
+ }
1176
1263
  // Local-log state is a CACHE, not a source of truth: the quickstart always
1177
1264
  // re-reads the logs fresh, so report/apply must too — otherwise yesterday's
1178
1265
  // persisted snapshot makes the artifact's numbers disagree with the screen.
@@ -1192,9 +1279,13 @@ async function buildReportInput(stateDir, rootPath) {
1192
1279
  if (needsFreshLogs) {
1193
1280
  const logs = await loadLocalAgentUsage({
1194
1281
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
1195
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
1282
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
1283
+ sinceIso,
1284
+ collectCodexInvocationEvidence: true
1196
1285
  }).catch(() => undefined);
1197
1286
  if (logs && logs.records.length > 0) {
1287
+ freshLocalCalls = logs.calls;
1288
+ freshCodexInvocationFiles = logs.codexInvocationFiles;
1198
1289
  const records = logs.records;
1199
1290
  const summary = analyzeSpend(records);
1200
1291
  const liveMappings = attributeUsageRecords(records);
@@ -1228,8 +1319,9 @@ async function buildReportInput(stateDir, rootPath) {
1228
1319
  claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
1229
1320
  projectDir: rootPath,
1230
1321
  includeAllProjectMcp: true,
1231
- sinceIso: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
1232
- windowDays: 30
1322
+ sinceIso,
1323
+ windowDays: sinceDays,
1324
+ codexInvocationFiles: freshCodexInvocationFiles
1233
1325
  }).catch(() => undefined)
1234
1326
  : undefined;
1235
1327
  const detectedPlans = spendState.mode === "local_logs"
@@ -1238,10 +1330,30 @@ async function buildReportInput(stateDir, rootPath) {
1238
1330
  codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
1239
1331
  }).catch(() => [])
1240
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;
1241
1351
  return {
1352
+ generatedAt: generatedAt.toISOString(),
1242
1353
  summary: spendState.summary,
1243
1354
  deadContext,
1244
1355
  detectedPlans,
1356
+ contextHealth,
1245
1357
  // Evidence ledger is built from the SAME records as the confidence
1246
1358
  // breakdown so the two sections can never contradict each other.
1247
1359
  allRecords: spendState.mode === "connected_provider"
@@ -1257,6 +1369,19 @@ async function buildReportInput(stateDir, rootPath) {
1257
1369
  providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
1258
1370
  };
1259
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
+ }
1260
1385
  function emptyDiscovery(rootPath) {
1261
1386
  return {
1262
1387
  rootPath,
@@ -1358,6 +1483,7 @@ function parseArgs(argv) {
1358
1483
  const next = rest[index + 1];
1359
1484
  if (next) {
1360
1485
  parsed.path = next;
1486
+ parsed.pathExplicit = true;
1361
1487
  index += 1;
1362
1488
  }
1363
1489
  continue;
@@ -1654,20 +1780,20 @@ function helpText() {
1654
1780
  " --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
1655
1781
  " scan [--path <dir>] Scan a local workspace for AI usage signals",
1656
1782
  " scan --sample Include deterministic sample spend analysis",
1657
- " quickstart [--sample] Plain-English local readout (alias of the default run)",
1658
- " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: model",
1659
- " 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",
1660
1786
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
1661
- " 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",
1662
1788
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
1663
1789
  " [--json] Emit the same canonical Context Health object used by MCP and Glance",
1664
- " 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",
1665
1791
  " apply-artifact Same as `apply` (long form)",
1666
1792
  "",
1667
1793
  "Cron (production watch): add a crontab entry such as:",
1668
1794
  " 0 * * * * cd /path/to/workspace && ai-spend-agent watch --interval 3600 --cycles 1 >> ai-spend-watch.log 2>&1",
1669
1795
  "",
1670
- "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."
1671
1797
  ].join("\n");
1672
1798
  }
1673
1799
  // Main-module check that survives npm's bin SYMLINKS: argv[1] is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
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",
@@ -54,8 +54,8 @@
54
54
  "prepack": "npm run build"
55
55
  },
56
56
  "dependencies": {
57
- "@agent-finops/core": "0.5.8",
58
- "@agent-finops/report": "0.5.8",
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
  }