ai-spend-agent 0.5.9 → 0.6.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 (3) hide show
  1. package/README.md +8 -0
  2. package/dist/index.js +791 -78
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -11,6 +11,14 @@ npx aibill
11
11
  It reads local Claude Code and Codex metadata, labels API-equivalent estimates,
12
12
  and can optionally add official OpenAI or Anthropic provider-reported cost
13
13
  through an environment-variable reference. No product telemetry is sent.
14
+ aibill never sits in the inference path and never stores, prints, or proxies provider credentials.
15
+
16
+ Connector validation (`live_verified`, `fixture_verified`, `untested`, or
17
+ `failed`) and each number's financial evidence (`verified`, `estimated`,
18
+ `detected_unverified`, or `missing`) are separate status axes. Run `npx aibill
19
+ doctor --sources` to see both with freshness and the last sanitized error.
20
+ The source registry also records read-boundary approval separately; an approved
21
+ local folder is permission to scan, not verified financial evidence.
14
22
 
15
23
  See the repository
16
24
  [README](https://github.com/futurastudio/ai-spend-agent#readme) for commands,
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, latestObservedWorkingDirectory, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, slugifySourceId } from "@agent-finops/core";
6
+ import { analyzeSpend, attributeUsageRecords, buildUsageGlance, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, loadLocalAgentUsage, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProviderConnectorStub, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses } 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")) {
@@ -85,7 +85,7 @@ async function quickstartCommand(args) {
85
85
  const sinceDays = args.sinceDays ?? 30;
86
86
  if (!validSinceDays(sinceDays))
87
87
  return invalidSinceDaysResult();
88
- const { records, mode, warnings, codexInvocationFiles } = await loadInstantReadData(args);
88
+ const { records, mode, warnings, providerCoverage, codexInvocationFiles } = await loadInstantReadData(args);
89
89
  const summaryRecords = mode === "connected"
90
90
  ? selectProviderFinancialHeadlineRecords(records)
91
91
  : records;
@@ -163,6 +163,7 @@ async function quickstartCommand(args) {
163
163
  groupBy,
164
164
  color,
165
165
  mode,
166
+ ...(providerCoverage ? { providerCoverage } : {}),
166
167
  nextSteps,
167
168
  deadContext,
168
169
  detectedPlans,
@@ -315,7 +316,9 @@ function quickstartNextSteps(mode, detected) {
315
316
  steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
316
317
  steps.push(`npx aibill connect ${detected[0].provider} add official provider-reported cost (ADMIN/owner key)`);
317
318
  }
318
- steps.push("npx aibill report write a shareable Markdown + HTML report");
319
+ steps.push(mode === "demo"
320
+ ? "npx aibill report --sample write a clearly labeled demo Markdown + HTML report"
321
+ : "npx aibill report write a shareable Markdown + HTML report");
319
322
  steps.push("npx aibill --group-by project see which project has the most observed activity");
320
323
  steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://ai-spend-agent.vercel.app");
321
324
  return steps;
@@ -323,13 +326,59 @@ function quickstartNextSteps(mode, detected) {
323
326
  async function readPersistedSpend(rootPath) {
324
327
  const stateDir = join(rootPath, ".ai-spend-agent");
325
328
  try {
326
- const spend = await readJson(join(stateDir, "spend.json"));
327
- return { mode: spend.mode, records: spend.records ?? [] };
329
+ const exactSpendContents = await readSafeStateText(stateDir, "spend.json");
330
+ const spend = JSON.parse(exactSpendContents);
331
+ if (!isPlainObject(spend) || !Array.isArray(spend.records)) {
332
+ throw new Error("persisted spend state must contain a records array");
333
+ }
334
+ const parsedRecords = spend.records.map((record) => parseUsageRecord(record));
335
+ const storedMode = isPersistedDataMode(spend.mode) ? spend.mode : undefined;
336
+ // The bundled fixture fingerprint is authoritative over a conflicting mode
337
+ // tag. A copied/tampered sample must never become connected billing merely
338
+ // because `mode` was changed in JSON.
339
+ const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
340
+ const records = mode === "sample" || mode === undefined
341
+ ? downgradeSampleUsageEvidence(parsedRecords)
342
+ : parsedRecords;
343
+ const providerCoverage = persistedProviderCoverage(spend.accounting);
344
+ const connectedTrust = mode === "connected_provider"
345
+ ? await verifyConnectedSpendTrustReceipt(rootPath, exactSpendContents)
346
+ : undefined;
347
+ return {
348
+ mode,
349
+ records,
350
+ ...(providerCoverage ? { providerCoverage } : {}),
351
+ ...(isPlainObject(spend.accounting) ? { accounting: spend.accounting } : {}),
352
+ ...(connectedTrust ? { connectedTrust } : {})
353
+ };
328
354
  }
329
355
  catch {
330
356
  return undefined;
331
357
  }
332
358
  }
359
+ function persistedProviderCoverage(accounting) {
360
+ if (accounting === undefined)
361
+ return undefined;
362
+ if (!isPlainObject(accounting)) {
363
+ throw new Error("persisted accounting state has an invalid shape");
364
+ }
365
+ if (accounting.coverageByProvider === undefined)
366
+ return undefined;
367
+ if (!isPlainObject(accounting.coverageByProvider)) {
368
+ throw new Error("persisted provider coverage has an invalid shape");
369
+ }
370
+ const coverage = Object.values(accounting.coverageByProvider);
371
+ if (coverage.some((value) => value !== "complete" && value !== "partial")) {
372
+ throw new Error("persisted provider coverage contains an invalid status");
373
+ }
374
+ if (coverage.length === 0)
375
+ return undefined;
376
+ return coverage.includes("partial") ? "partial" : "complete";
377
+ }
378
+ function trustedAccountingMap(accounting, field) {
379
+ const value = accounting?.[field];
380
+ return isPlainObject(value) ? value : {};
381
+ }
333
382
  async function loadInstantReadData(args) {
334
383
  const warnings = [];
335
384
  if (args.sample) {
@@ -342,8 +391,20 @@ async function loadInstantReadData(args) {
342
391
  // came from a provider — local-log records mislabeled connected (a past bug)
343
392
  // must never be served as billing data.
344
393
  const looksConnected = (persisted?.records ?? []).some((record) => record.providerCostType !== "local_agent_logs");
345
- if (persisted && persisted.records.length > 0 && persisted.mode === "connected_provider" && looksConnected) {
346
- return { records: persisted.records, mode: "connected", warnings };
394
+ if (persisted &&
395
+ persisted.records.length > 0 &&
396
+ persisted.mode === "connected_provider" &&
397
+ looksConnected &&
398
+ persisted.connectedTrust?.trusted === true) {
399
+ return {
400
+ records: persisted.records,
401
+ mode: "connected",
402
+ warnings,
403
+ ...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {})
404
+ };
405
+ }
406
+ if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
407
+ warnings.push(`${persisted.connectedTrust.message} CLI: run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command. The repository-provided connected totals were ignored.`);
347
408
  }
348
409
  // Real local agent logs (Claude Code / Codex) beat any sample/legacy state.
349
410
  const logs = await loadLocalAgentUsage({
@@ -358,7 +419,7 @@ async function loadInstantReadData(args) {
358
419
  // same data source we just re-read — superseding it silently is correct,
359
420
  // not worth a scary "sample/legacy" warning.
360
421
  if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
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.");
422
+ warnings.push("Ignored persisted sample/legacy state in .ai-spend-agent/spend.json — showing your real local agent logs. Run `npx aibill reset` to clear it, or pass --ignore-state.");
362
423
  }
363
424
  return {
364
425
  records: logs.records,
@@ -369,12 +430,15 @@ async function loadInstantReadData(args) {
369
430
  }
370
431
  // No real logs. Persisted sample/legacy state may still be shown, but only as
371
432
  // DEMO (never as connected), with a warning when its origin is unknown.
372
- if (persisted && persisted.records.length > 0) {
433
+ if (persisted && persisted.records.length > 0 && persisted.mode !== "connected_provider" && persisted.mode !== "local_logs") {
373
434
  if (persisted.mode === undefined) {
374
- warnings.push("Persisted state in .ai-spend-agent/spend.json is from an older format with no data-mode tag — treating it as demo. Run `ai-spend-agent reset`, then re-scan to refresh.");
435
+ warnings.push("Persisted state in .ai-spend-agent/spend.json is from an older format with no data-mode tag — treating it as demo. Run `npx aibill reset`, then re-scan to refresh.");
375
436
  }
376
437
  return { records: persisted.records, mode: "demo", warnings };
377
438
  }
439
+ if (persisted?.mode === "local_logs") {
440
+ warnings.push("Ignored persisted local-log cache because no current Claude Code/Codex source records were found. Re-run the local agent activity first; repository state alone cannot authorize an Apply action.");
441
+ }
378
442
  // loadSampleUsageData resolves the bundled CSVs relative to the installed
379
443
  // package, so this works from ANY directory (true zero-config).
380
444
  return { records: await loadSampleUsageData(), mode: "demo", warnings };
@@ -388,10 +452,18 @@ function dataModeBanner(mode) {
388
452
  return "DATA MODE: demo sample (illustrative — not your real spend)";
389
453
  }
390
454
  async function doctorCommand(args) {
455
+ if (args.sources) {
456
+ return doctorSourcesCommand(args);
457
+ }
391
458
  const rootPath = resolve(args.path);
392
459
  const stateDir = join(rootPath, ".ai-spend-agent");
393
460
  const persisted = await readPersistedSpend(rootPath);
394
- const stateMode = persisted ? (persisted.mode ?? "unknown legacy") : "no state";
461
+ const connectedStateTrusted = persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true;
462
+ const stateMode = persisted
463
+ ? persisted.mode === "connected_provider" && !connectedStateTrusted
464
+ ? "connected_provider (UNTRUSTED — ignored)"
465
+ : (persisted.mode ?? "unknown legacy")
466
+ : "no state";
395
467
  const logs = await loadLocalAgentUsage({
396
468
  claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
397
469
  codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
@@ -414,14 +486,17 @@ async function doctorCommand(args) {
414
486
  : "none detected (use --plan to declare one)";
415
487
  const warnings = [];
416
488
  if (stateMode === "sample")
417
- warnings.push("sample state present — it will be shown as DEMO and cannot mask real logs; run `ai-spend-agent reset` to clear it");
489
+ warnings.push("sample state present — it will be shown as DEMO and cannot mask real logs; run `npx aibill reset` to clear it");
418
490
  if (stateMode === "unknown legacy")
419
- warnings.push("legacy state with no data-mode tag — run `ai-spend-agent reset`, then re-scan");
491
+ warnings.push("legacy state with no data-mode tag — run `npx aibill reset`, then re-scan");
492
+ if (persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === false) {
493
+ warnings.push(`${persisted.connectedTrust.message} Run \`npx aibill connect <provider>\` or repeat the prior \`npx aibill sync-provider ...\` command.`);
494
+ }
420
495
  if (!hasLogs)
421
496
  warnings.push("no real Claude Code / Codex logs found — a first run here will show DEMO sample data");
422
497
  if (providerRefs.length === 0)
423
498
  warnings.push("no provider admin keys detected — connect OpenAI/Anthropic to add official provider-reported cost (local logs stay API-equivalent estimates)");
424
- const predictedMode = stateMode === "connected_provider"
499
+ const predictedMode = connectedStateTrusted
425
500
  ? "connected provider billing"
426
501
  : hasLogs
427
502
  ? "your local agent logs (estimated at API-equivalent rates)"
@@ -445,6 +520,428 @@ async function doctorCommand(args) {
445
520
  ];
446
521
  return ok(lines.join("\n"));
447
522
  }
523
+ const providerStatusIds = ["openai", "anthropic", "cursor", "github-copilot"];
524
+ /**
525
+ * Show connector validation maturity and this machine's financial evidence as
526
+ * separate axes. This command reads local state only; it never contacts a
527
+ * provider or treats a connector capability claim as current financial data.
528
+ */
529
+ async function doctorSourcesCommand(args) {
530
+ const rootPath = resolve(args.path);
531
+ const stateDir = join(rootPath, ".ai-spend-agent");
532
+ const now = new Date();
533
+ const observations = [];
534
+ let localLogs;
535
+ let localError;
536
+ try {
537
+ localLogs = await loadLocalAgentUsage({
538
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
539
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR
540
+ });
541
+ }
542
+ catch (error) {
543
+ localError = sanitizeSecretishError(error instanceof Error ? error.message : String(error));
544
+ }
545
+ const checkedAt = now.toISOString();
546
+ for (const id of ["claude-code", "codex"]) {
547
+ const records = (localLogs?.records ?? []).filter((record) => record.agentId === id);
548
+ const evidence = financialEvidenceForRecords(records);
549
+ const scan = localLogs?.sourceScans.find((entry) => entry.agent === id);
550
+ const diagnostics = (localLogs?.diagnostics ?? []).filter((entry) => entry.agent === id);
551
+ const diagnosticError = localAgentDiagnosticSummary(diagnostics);
552
+ const validationFailed = Boolean(localError) || localDiagnosticsRequireFailure(records, scan, diagnostics);
553
+ const lastError = localError ?? diagnosticError;
554
+ observations.push({
555
+ id,
556
+ financialEvidence: evidence,
557
+ financialEvidenceNote: localFinancialEvidenceNote(records, evidence, scan),
558
+ checkedAt,
559
+ latestEvidenceAt: latestRecordTimestamp(records),
560
+ ...(lastError ? { lastError } : {}),
561
+ ...(validationFailed ? { validationCoverage: "failed" } : {})
562
+ });
563
+ }
564
+ let providerState = {};
565
+ let providerStateError;
566
+ const persistedSpend = await readPersistedSpend(rootPath);
567
+ if (persistedSpend?.mode === "connected_provider" && persistedSpend.connectedTrust?.trusted === true) {
568
+ providerState = {
569
+ records: persistedSpend.records,
570
+ qaByProvider: trustedAccountingMap(persistedSpend.accounting, "qaByProvider")
571
+ };
572
+ }
573
+ else if (persistedSpend?.mode === "connected_provider" && persistedSpend.connectedTrust?.trusted === false) {
574
+ providerStateError = `${persistedSpend.connectedTrust.message} Provider financial evidence was ignored.`;
575
+ }
576
+ else {
577
+ try {
578
+ await readSafeStateText(stateDir, "provider-records.json");
579
+ providerStateError = "provider records have no matching trusted connected spend receipt; financial evidence was ignored";
580
+ }
581
+ catch (error) {
582
+ if (!isNodeError(error, "ENOENT")) {
583
+ providerStateError = "provider state could not be safely read; financial evidence was ignored";
584
+ }
585
+ }
586
+ }
587
+ const registry = await readSourceRegistry(stateDir, rootPath);
588
+ let attemptState = { version: 1, providers: {} };
589
+ let attemptStateError;
590
+ try {
591
+ const parsed = parsePersistedSourceAttemptState(await readJson(join(stateDir, "source-status.json")));
592
+ attemptState = parsed.state;
593
+ attemptStateError = parsed.error;
594
+ }
595
+ catch (error) {
596
+ if (!isNodeError(error, "ENOENT")) {
597
+ attemptStateError = "source attempt state could not be parsed; recorded freshness and errors were ignored";
598
+ }
599
+ }
600
+ const providerRecords = Array.isArray(providerState.records) ? providerState.records : [];
601
+ for (const id of providerStatusIds) {
602
+ const records = providerRecords.filter((record) => record.source?.provider === id);
603
+ const evidence = financialEvidenceForRecords(records);
604
+ const qa = providerState.qaByProvider?.[id]
605
+ ?? (providerState.provider === id ? providerState.qa : undefined);
606
+ const attempt = attemptState.providers?.[id];
607
+ const lastError = providerStateError
608
+ ?? attemptStateError
609
+ ?? sanitizePersistedStatusText(attempt?.lastError ?? undefined)
610
+ ?? providerQaLastError(qa);
611
+ const registeredSource = (Array.isArray(registry.approvedSources) ? registry.approvedSources : [])
612
+ .filter((source) => source && source.provider === id && typeof source.approvedAt === "string")
613
+ .sort((left, right) => right.approvedAt.localeCompare(left.approvedAt))[0];
614
+ const stateCheckedAt = attempt?.checkedAt
615
+ ?? (providerState.provider === id ? providerState.fetchedAt : undefined);
616
+ observations.push({
617
+ id,
618
+ financialEvidence: evidence,
619
+ financialEvidenceNote: providerFinancialEvidenceNote(records, evidence),
620
+ // A connector stub is only configuration, not a source check. Older
621
+ // successful syncs predate source-status.json, so their non-missing
622
+ // registry approval time is the conservative migration fallback.
623
+ checkedAt: stateCheckedAt ?? (registeredSource?.financialEvidence !== "missing" ? registeredSource?.approvedAt : undefined),
624
+ latestEvidenceAt: latestRecordTimestamp(records),
625
+ ...(lastError ? { lastError, validationCoverage: "failed" } : {})
626
+ });
627
+ }
628
+ const statuses = buildSourceStatuses(observations, now);
629
+ return ok([
630
+ "aibill doctor --sources",
631
+ "local status only: no provider was contacted",
632
+ "status axes (never interchangeable):",
633
+ " validation coverage: live_verified | fixture_verified | untested | failed",
634
+ " financial evidence: verified | estimated | detected_unverified | missing",
635
+ "",
636
+ formatSourceStatuses(statuses)
637
+ ].join("\n"));
638
+ }
639
+ function localFinancialEvidenceNote(records, evidence, scan) {
640
+ if (records.length === 0) {
641
+ if (!scan)
642
+ return "The local transcript scan did not complete.";
643
+ if (scan.directoryStatus === "missing") {
644
+ return "No local transcript directory was found for this agent; no usage evidence was available.";
645
+ }
646
+ if (scan.directoryStatus === "unreadable") {
647
+ return "The local transcript path could not be read; absence of usage cannot be confirmed.";
648
+ }
649
+ if (scan.filesDiscovered === 0) {
650
+ return "The local transcript directory was readable, but no JSONL files were found.";
651
+ }
652
+ if (scan.unreadableFiles > 0) {
653
+ return `${scan.filesDiscovered} transcript file(s) were found, but ${scan.unreadableFiles} could not be read; absence of usage cannot be confirmed.`;
654
+ }
655
+ if (scan.malformedLines > 0) {
656
+ return `${scan.filesDiscovered} transcript file(s) were found, but no valid usage rows were parsed; ${scan.malformedLines} malformed JSONL line(s) were skipped.`;
657
+ }
658
+ return `${scan.filesDiscovered} transcript file(s) were found, but no supported usage rows were observed.`;
659
+ }
660
+ if (evidence === "estimated") {
661
+ const estimatedRows = records.filter((record) => (record.costConfidence === "estimated" && typeof record.amountUsd === "number")).length;
662
+ const missingRows = records.length - estimatedRows;
663
+ if (missingRows > 0 || (scan?.unsupportedUsageSnapshots ?? 0) > 0) {
664
+ const unsupportedCount = scan?.unsupportedUsageSnapshots ?? 0;
665
+ const unsupported = unsupportedCount > 0
666
+ ? `; ${unsupportedCount} token snapshot(s) lacked input/output components and were not priced`
667
+ : "";
668
+ const otherMissing = Math.max(0, missingRows - unsupportedCount);
669
+ const unpricedModels = otherMissing > 0
670
+ ? `; ${otherMissing} other row(s) lacked a supported model price`
671
+ : "";
672
+ return `${estimatedRows} of ${records.length} local aggregate row(s) were priced at published API rates${unsupported}${unpricedModels}; missing rows are excluded, and estimates are not billed subscription spend.`;
673
+ }
674
+ return `${records.length} local aggregate row(s) priced at published API rates; this is not billed subscription spend.`;
675
+ }
676
+ if (evidence === "missing") {
677
+ if ((scan?.unsupportedUsageSnapshots ?? 0) > 0) {
678
+ return `${records.length} local aggregate row(s) were observed, but ${scan.unsupportedUsageSnapshots} token snapshot(s) lacked input/output components required for pricing.`;
679
+ }
680
+ return `${records.length} local aggregate row(s) were observed, but no supported price basis was available.`;
681
+ }
682
+ return `${records.length} local aggregate row(s) were observed with ${evidence} financial evidence.`;
683
+ }
684
+ function localAgentDiagnosticSummary(diagnostics) {
685
+ const relevant = diagnostics.filter((diagnostic) => diagnostic.code !== "directory_missing");
686
+ const unsupported = relevant.filter((diagnostic) => diagnostic.code === "unsupported_token_shape");
687
+ const malformed = relevant.filter((diagnostic) => diagnostic.code === "malformed_jsonl");
688
+ const messages = [...new Set(relevant
689
+ .filter((diagnostic) => !["unsupported_token_shape", "malformed_jsonl"].includes(diagnostic.code))
690
+ .map((diagnostic) => diagnostic.message))];
691
+ const unsupportedCount = unsupported
692
+ .reduce((total, diagnostic) => total + diagnostic.count, 0);
693
+ if (unsupportedCount > 0) {
694
+ messages.push(`${unsupportedCount} ${unsupported[0].agent === "codex" ? "Codex" : "Claude Code"} token snapshot(s) lacked the input/output components required for pricing.`);
695
+ }
696
+ const malformedCount = malformed
697
+ .reduce((total, diagnostic) => total + diagnostic.count, 0);
698
+ if (malformedCount > 0) {
699
+ messages.push(`${malformedCount} malformed JSONL line(s) were skipped in ${malformed[0].agent === "codex" ? "Codex" : "Claude Code"} transcripts.`);
700
+ }
701
+ return messages.length > 0 ? messages.join(" ") : undefined;
702
+ }
703
+ function localDiagnosticsRequireFailure(records, scan, diagnostics) {
704
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error"))
705
+ return true;
706
+ // A partially malformed active JSONL can still yield supported evidence.
707
+ // If nothing valid survived, however, an empty result is not trustworthy.
708
+ return records.length === 0 && (scan?.malformedLines ?? 0) > 0;
709
+ }
710
+ function providerFinancialEvidenceNote(records, evidence) {
711
+ if (records.length === 0)
712
+ return "No provider financial evidence is present in local aibill state.";
713
+ const verifiedRows = records.filter((record) => (record.costConfidence === "verified" && typeof record.amountUsd === "number")).length;
714
+ const estimatedRows = records.filter((record) => (record.costConfidence === "estimated" && typeof record.amountUsd === "number")).length;
715
+ const detectedRows = records.filter((record) => (record.costConfidence === "detected_unverified" && typeof record.amountUsd === "number")).length;
716
+ const missingRows = records.length - verifiedRows - estimatedRows - detectedRows;
717
+ // Keep the concise homogeneous messages, but never let a single verified
718
+ // row promote every mixed provider row to official billed cost.
719
+ if (evidence === "verified" && verifiedRows === records.length) {
720
+ return `${records.length} provider row(s) include official provider-reported cost.`;
721
+ }
722
+ if (evidence === "estimated" && estimatedRows === records.length) {
723
+ return `${records.length} provider row(s) include estimated cost; reconcile before treating it as billed spend.`;
724
+ }
725
+ if (evidence === "detected_unverified" && detectedRows === records.length) {
726
+ return `${records.length} provider row(s) were detected with partial or unreconciled financial coverage.`;
727
+ }
728
+ if (evidence === "missing" && missingRows === records.length) {
729
+ return `${records.length} provider row(s) were observed without a supported cost basis.`;
730
+ }
731
+ const parts = [];
732
+ if (verifiedRows > 0) {
733
+ parts.push(`${verifiedRows} of ${records.length} provider row(s) include official provider-reported cost`);
734
+ }
735
+ if (estimatedRows > 0) {
736
+ parts.push(`${estimatedRows} provider row(s) include estimated cost`);
737
+ }
738
+ if (detectedRows > 0) {
739
+ parts.push(`${detectedRows} provider row(s) have partial or unreconciled financial coverage`);
740
+ }
741
+ if (missingRows > 0) {
742
+ parts.push(`${missingRows} provider row(s) have no supported cost basis`);
743
+ }
744
+ return `${parts.join("; ")}. Row-level financial evidence remains separate.`;
745
+ }
746
+ function latestRecordTimestamp(records) {
747
+ return records
748
+ .map((record) => record.timestamp)
749
+ .filter((timestamp) => Number.isFinite(Date.parse(timestamp)))
750
+ .sort((left, right) => right.localeCompare(left))[0];
751
+ }
752
+ function providerQaLastError(qa) {
753
+ const incompletePage = qa?.pagination.find((entry) => entry.stoppedBecause !== "complete");
754
+ if (incompletePage) {
755
+ const fallback = incompletePage.stoppedBecause === "fetch_error"
756
+ ? "provider fetch failed"
757
+ : incompletePage.stoppedBecause === "max_pages"
758
+ ? "pagination stopped at the connector page safety cap"
759
+ : incompletePage.stoppedBecause === "max_range_days"
760
+ ? "requested range exceeded the connector coverage cap"
761
+ : incompletePage.stoppedBecause === "unsafe_next_link"
762
+ ? "an unsafe pagination link was rejected"
763
+ : "pagination ended before the provider marked it complete";
764
+ return sanitizePersistedStatusText(incompletePage.note
765
+ ? `${incompletePage.label}: ${incompletePage.note}`
766
+ : `${incompletePage.label}: ${fallback}`);
767
+ }
768
+ const drift = qa?.responseDrift[0];
769
+ if (drift) {
770
+ return sanitizePersistedStatusText(`${drift.label}: ${drift.field} ${drift.issue}`);
771
+ }
772
+ if (qa?.coverage === "partial") {
773
+ return sanitizePersistedStatusText(`${qa.provider}: provider returned partial coverage`);
774
+ }
775
+ return undefined;
776
+ }
777
+ function parsePersistedProviderStatusState(value) {
778
+ if (!isPlainObject(value)) {
779
+ return { state: {}, error: "provider state has an invalid shape; financial evidence was ignored" };
780
+ }
781
+ if (value.provider !== undefined && typeof value.provider !== "string") {
782
+ return { state: {}, error: "provider state has an invalid provider id; financial evidence was ignored" };
783
+ }
784
+ if (value.fetchedAt !== undefined && !validIsoString(value.fetchedAt)) {
785
+ return { state: {}, error: "provider state has an invalid freshness timestamp; financial evidence was ignored" };
786
+ }
787
+ if (value.records !== undefined && !Array.isArray(value.records)) {
788
+ return { state: {}, error: "provider state has invalid financial records; financial evidence was ignored" };
789
+ }
790
+ let records = [];
791
+ try {
792
+ records = (value.records ?? []).map((record) => parseUsageRecord(record));
793
+ }
794
+ catch {
795
+ return { state: {}, error: "provider state has invalid financial records; financial evidence was ignored" };
796
+ }
797
+ const qa = value.qa === undefined ? undefined : parsePersistedProviderQa(value.qa);
798
+ if (value.qa !== undefined && !qa) {
799
+ return { state: {}, error: "provider state has invalid QA metadata; financial evidence was ignored" };
800
+ }
801
+ const qaByProvider = {};
802
+ if (value.qaByProvider !== undefined) {
803
+ if (!isPlainObject(value.qaByProvider)) {
804
+ return { state: {}, error: "provider state has invalid QA metadata; financial evidence was ignored" };
805
+ }
806
+ for (const [provider, rawQa] of Object.entries(value.qaByProvider)) {
807
+ // Unknown providers are forward-compatible but irrelevant to this
808
+ // four-provider doctor view. Known providers must pass the full shape.
809
+ if (!isProviderStatusId(provider))
810
+ continue;
811
+ const parsedQa = parsePersistedProviderQa(rawQa);
812
+ if (!parsedQa) {
813
+ return { state: {}, error: "provider state has invalid QA metadata; financial evidence was ignored" };
814
+ }
815
+ qaByProvider[provider] = parsedQa;
816
+ }
817
+ }
818
+ return {
819
+ state: {
820
+ ...(typeof value.provider === "string" ? { provider: value.provider } : {}),
821
+ ...(typeof value.fetchedAt === "string" ? { fetchedAt: value.fetchedAt } : {}),
822
+ records,
823
+ ...(qa ? { qa } : {}),
824
+ ...(Object.keys(qaByProvider).length > 0 ? { qaByProvider } : {})
825
+ }
826
+ };
827
+ }
828
+ function parsePersistedSourceAttemptState(value) {
829
+ const empty = { version: 1, providers: {} };
830
+ if (!isPlainObject(value) || value.version !== 1 || !isPlainObject(value.providers)) {
831
+ return { state: empty, error: "source attempt state has an invalid shape; recorded freshness and errors were ignored" };
832
+ }
833
+ const providers = {};
834
+ for (const [provider, rawAttempt] of Object.entries(value.providers)) {
835
+ if (!isProviderStatusId(provider) || !isPlainObject(rawAttempt) || !validIsoString(rawAttempt.checkedAt)) {
836
+ return { state: empty, error: "source attempt state has an invalid provider or timestamp; recorded freshness and errors were ignored" };
837
+ }
838
+ if (rawAttempt.lastError !== null && typeof rawAttempt.lastError !== "string") {
839
+ return { state: empty, error: "source attempt state has an invalid error field; recorded freshness and errors were ignored" };
840
+ }
841
+ providers[provider] = {
842
+ checkedAt: rawAttempt.checkedAt,
843
+ lastError: rawAttempt.lastError === null
844
+ ? null
845
+ : (sanitizePersistedStatusText(rawAttempt.lastError) ?? "invalid empty provider error")
846
+ };
847
+ }
848
+ return { state: { version: 1, providers } };
849
+ }
850
+ function parsePersistedProviderQa(value) {
851
+ if (!isPlainObject(value) || typeof value.provider !== "string")
852
+ return undefined;
853
+ if (value.coverage !== undefined && value.coverage !== "complete" && value.coverage !== "partial")
854
+ return undefined;
855
+ if (!isStringArray(value.requestedEndpoints) || !Array.isArray(value.pagination) ||
856
+ !Array.isArray(value.rateLimits) || !Array.isArray(value.responseDrift) ||
857
+ !isStringArray(value.instructions)) {
858
+ return undefined;
859
+ }
860
+ const pagination = [];
861
+ for (const entry of value.pagination) {
862
+ if (!isPlainObject(entry) || typeof entry.label !== "string" ||
863
+ !isFiniteNumber(entry.pagesFetched) || !isFiniteNumber(entry.maxPages) ||
864
+ !isProviderPaginationStop(entry.stoppedBecause) ||
865
+ (entry.limitPerPage !== undefined && !isFiniteNumber(entry.limitPerPage)) ||
866
+ (entry.note !== undefined && typeof entry.note !== "string")) {
867
+ return undefined;
868
+ }
869
+ pagination.push({
870
+ label: entry.label,
871
+ pagesFetched: entry.pagesFetched,
872
+ stoppedBecause: entry.stoppedBecause,
873
+ maxPages: entry.maxPages,
874
+ ...(typeof entry.limitPerPage === "number" ? { limitPerPage: entry.limitPerPage } : {}),
875
+ ...(typeof entry.note === "string" ? { note: sanitizePersistedStatusText(entry.note) ?? "empty provider error" } : {})
876
+ });
877
+ }
878
+ const rateLimits = [];
879
+ for (const entry of value.rateLimits) {
880
+ if (!isPlainObject(entry) || typeof entry.label !== "string" ||
881
+ (entry.remainingRequests !== undefined && !isFiniteNumber(entry.remainingRequests)) ||
882
+ (entry.retryAfterSeconds !== undefined && !isFiniteNumber(entry.retryAfterSeconds))) {
883
+ return undefined;
884
+ }
885
+ rateLimits.push({
886
+ label: entry.label,
887
+ ...(typeof entry.remainingRequests === "number" ? { remainingRequests: entry.remainingRequests } : {}),
888
+ ...(typeof entry.retryAfterSeconds === "number" ? { retryAfterSeconds: entry.retryAfterSeconds } : {})
889
+ });
890
+ }
891
+ const responseDrift = [];
892
+ for (const entry of value.responseDrift) {
893
+ if (!isPlainObject(entry) || typeof entry.label !== "string" ||
894
+ typeof entry.field !== "string" || typeof entry.issue !== "string") {
895
+ return undefined;
896
+ }
897
+ responseDrift.push({
898
+ label: entry.label,
899
+ field: entry.field,
900
+ issue: entry.issue
901
+ });
902
+ }
903
+ return {
904
+ provider: value.provider,
905
+ ...(value.coverage === "complete" || value.coverage === "partial" ? { coverage: value.coverage } : {}),
906
+ requestedEndpoints: value.requestedEndpoints,
907
+ pagination,
908
+ rateLimits,
909
+ responseDrift,
910
+ instructions: value.instructions
911
+ };
912
+ }
913
+ function sanitizePersistedStatusText(value) {
914
+ if (!value)
915
+ return undefined;
916
+ const sanitized = sanitizeSecretishError(value)
917
+ .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, "")
918
+ .replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
919
+ .replace(/\s+/g, " ")
920
+ .trim()
921
+ .slice(0, 500);
922
+ return sanitized || undefined;
923
+ }
924
+ function isProviderStatusId(value) {
925
+ return providerStatusIds.includes(value);
926
+ }
927
+ function isProviderPaginationStop(value) {
928
+ return value === "complete" || value === "missing_cursor" || value === "max_pages" ||
929
+ value === "max_range_days" || value === "fetch_error" || value === "unsafe_next_link";
930
+ }
931
+ function validIsoString(value) {
932
+ return typeof value === "string" &&
933
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test(value) &&
934
+ Number.isFinite(Date.parse(value));
935
+ }
936
+ function isStringArray(value) {
937
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
938
+ }
939
+ function isFiniteNumber(value) {
940
+ return typeof value === "number" && Number.isFinite(value);
941
+ }
942
+ function isPlainObject(value) {
943
+ return typeof value === "object" && value !== null && !Array.isArray(value);
944
+ }
448
945
  async function cliVersion() {
449
946
  try {
450
947
  const here = dirname(fileURLToPath(import.meta.url));
@@ -457,6 +954,9 @@ async function cliVersion() {
457
954
  }
458
955
  async function resetCommand(args) {
459
956
  const rootPath = await resolveSafeScanRoot(args.path);
957
+ // The trust receipt is deliberately outside the repository. Reset must
958
+ // clear it too so restoring an old spend.json cannot replay prior trust.
959
+ await invalidateConnectedSpendTrustReceipt(rootPath);
460
960
  let stateDir;
461
961
  try {
462
962
  stateDir = await resolveSafeStateDirectory(rootPath);
@@ -473,7 +973,7 @@ async function resetCommand(args) {
473
973
  }
474
974
  // Clear derived spend state so a prior `scan --sample` (or stale provider
475
975
  // sync) can never mask the next real local-log read. Leaves sources/audit.
476
- const targets = ["spend.json", "mappings.json", "provider-records.json", "watch-latest.json", "watch-history.json"];
976
+ const targets = ["spend.json", "mappings.json", "provider-records.json", "source-status.json", "watch-latest.json", "watch-history.json"];
477
977
  const removed = [];
478
978
  for (const file of targets) {
479
979
  try {
@@ -505,9 +1005,9 @@ async function initCommand(args) {
505
1005
  sourceRegistry: "sources.json",
506
1006
  auditLog: "audit-log.json",
507
1007
  nextCommands: [
508
- "ai-spend-agent doctor",
509
- `ai-spend-agent scan --sample --path ${rootPath}`,
510
- `ai-spend-agent report --out ai-spend-report --path ${rootPath}`
1008
+ "npx aibill doctor",
1009
+ `npx aibill scan --sample --path ${rootPath}`,
1010
+ `npx aibill report --sample --out ai-spend-report --path ${rootPath}`
511
1011
  ]
512
1012
  });
513
1013
  await writeJson(join(stateDir, "sources.json"), registry);
@@ -527,7 +1027,7 @@ async function initCommand(args) {
527
1027
  "cloud upload: disabled",
528
1028
  "cron jobs: disabled in V0 demo",
529
1029
  `state directory: ${stateDir}`,
530
- `next: ai-spend-agent scan --sample --path ${rootPath}`
1030
+ `next: npx aibill scan --sample --path ${rootPath}`
531
1031
  ].join("\n"));
532
1032
  }
533
1033
  async function scanCommand(args) {
@@ -695,9 +1195,14 @@ async function runWatchCycle(stateDir, args) {
695
1195
  mode = "sample";
696
1196
  }
697
1197
  else {
698
- const providerState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
699
- if (providerState.records.length > 0) {
700
- records = providerState.records;
1198
+ const persisted = await readPersistedSpend(dirname(stateDir));
1199
+ if (persisted?.mode === "connected_provider" &&
1200
+ persisted.connectedTrust?.trusted === true &&
1201
+ persisted.records.length > 0) {
1202
+ // Watch may observe an already trusted provider snapshot, but it may not
1203
+ // mint trust from repository-authored provider-records.json or rewrite
1204
+ // connected state. Only an explicit provider sync can do that.
1205
+ records = persisted.records;
701
1206
  mode = "connected_provider";
702
1207
  }
703
1208
  else {
@@ -722,7 +1227,9 @@ async function runWatchCycle(stateDir, args) {
722
1227
  : records;
723
1228
  const summary = analyzeSpend(headlineRecords);
724
1229
  const mappings = attributeUsageRecords(records);
725
- await writeLocalSpendState(stateDir, records, summary, mappings, mode);
1230
+ if (mode !== "connected_provider") {
1231
+ await writeLocalSpendState(stateDir, records, summary, mappings, mode);
1232
+ }
726
1233
  const snapshot = {
727
1234
  capturedAt: new Date().toISOString(),
728
1235
  totalUsd: summary.totalUsd,
@@ -798,6 +1305,7 @@ async function addSourceCommand(args) {
798
1305
  path: sourcePath,
799
1306
  provider: args.provider
800
1307
  });
1308
+ const addedSource = nextRegistry.approvedSources.find((source) => source.id === id);
801
1309
  await writeJson(join(stateDir, "sources.json"), nextRegistry);
802
1310
  await appendAuditEvent(stateDir, {
803
1311
  timestamp: nextRegistry.updatedAt,
@@ -812,7 +1320,10 @@ async function addSourceCommand(args) {
812
1320
  `type: ${args.sourceType}`,
813
1321
  `path: ${sourcePath}`,
814
1322
  `provider: ${args.provider ?? "unknown"}`,
815
- "read-only: true"
1323
+ "read-only: true",
1324
+ `boundary approval: ${addedSource.boundaryApproval}`,
1325
+ `validation coverage: ${addedSource.validationCoverage}`,
1326
+ `financial evidence: ${addedSource.financialEvidence}`
816
1327
  ].join("\n"));
817
1328
  }
818
1329
  async function listSourcesCommand(args) {
@@ -824,7 +1335,7 @@ async function listSourcesCommand(args) {
824
1335
  `approved sources: ${registry.approvedSources.length}`
825
1336
  ];
826
1337
  for (const source of registry.approvedSources) {
827
- lines.push(`- ${source.id} | ${source.type} | ${source.label} | ${source.provider ?? "unknown"} | ${source.path ?? "no path"}`);
1338
+ lines.push(`- ${source.id} | ${source.type} | ${source.label} | ${source.provider ?? "unknown"} | ${source.path ?? "no path"}`, ` boundary approval: ${source.boundaryApproval}`, ` validation coverage: ${source.validationCoverage}`, ` financial evidence: ${source.financialEvidence}`);
828
1339
  }
829
1340
  return ok(lines.join("\n"));
830
1341
  }
@@ -854,11 +1365,11 @@ async function connectCommand(args) {
854
1365
  stdout: "",
855
1366
  stderr: [
856
1367
  "connect requires a provider. Start with one you can self-serve in ~2 min:",
857
- " ai-spend-agent connect openai (org-owner Admin key)",
858
- " ai-spend-agent connect anthropic (Admin key)",
1368
+ " npx aibill connect openai (org-owner Admin credential reference)",
1369
+ " npx aibill connect anthropic (Admin credential reference)",
859
1370
  "Team/billing-admin upgrades:",
860
- " ai-spend-agent connect cursor (Cursor team-admin key, Business plan)",
861
- " ai-spend-agent connect github-copilot (GitHub billing-admin token)"
1371
+ " npx aibill connect cursor (Cursor team-admin credential reference)",
1372
+ " npx aibill connect github-copilot (GitHub billing-admin credential reference)"
862
1373
  ].join("\n")
863
1374
  };
864
1375
  }
@@ -886,7 +1397,9 @@ async function connectCommand(args) {
886
1397
  `provider: ${provider}`,
887
1398
  `type: ${type}`,
888
1399
  `access method: ${source.accessMethod}`,
889
- `verification: ${source.verification}`,
1400
+ `boundary approval: ${source.boundaryApproval}`,
1401
+ `validation coverage: ${source.validationCoverage}`,
1402
+ `financial evidence: ${source.financialEvidence}`,
890
1403
  "secrets: no raw secrets stored; we only reference a local env var such as env:OPENAI_ADMIN_KEY"
891
1404
  ];
892
1405
  if (selfServeProviders.has(provider)) {
@@ -901,19 +1414,19 @@ async function connectCommand(args) {
901
1414
  lines.push(`auto-detected: a ${provider} key in ${detected.reference} (${detected.hint}) from ${describeOrigin(detected)}`);
902
1415
  if (detected.isLikelyAdminKey) {
903
1416
  const adminRef = providerAdminEnvHint[provider] ?? detected.reference;
904
- lines.push(`next: ai-spend-agent sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
1417
+ lines.push(`next: npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
905
1418
  }
906
1419
  else {
907
1420
  const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
908
1421
  lines.push(`this looks like a regular key — for COST data set an admin key in ${adminRef}, then:`);
909
- lines.push(` ai-spend-agent sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
1422
+ lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
910
1423
  }
911
1424
  }
912
1425
  else {
913
1426
  const adminRef = providerAdminEnvHint[provider] ?? "env:YOUR_ADMIN_KEY";
914
1427
  lines.push("");
915
1428
  lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
916
- lines.push(` ai-spend-agent sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
1429
+ lines.push(` npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`);
917
1430
  }
918
1431
  lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
919
1432
  return ok(lines.join("\n"));
@@ -954,6 +1467,15 @@ async function syncProviderCommand(args) {
954
1467
  };
955
1468
  }
956
1469
  try {
1470
+ // A provider sync may merge only a prior connected snapshot whose exact
1471
+ // repository bytes have a matching external machine receipt. A cloned
1472
+ // provider-records.json is never allowed to launder fake rows into a new
1473
+ // trusted multi-provider snapshot.
1474
+ const priorSpend = await readPersistedSpend(rootPath);
1475
+ const trustedPrior = priorSpend?.mode === "connected_provider" &&
1476
+ priorSpend.connectedTrust?.trusted === true
1477
+ ? priorSpend
1478
+ : undefined;
957
1479
  const result = await fetchProviderUsageRecords({
958
1480
  provider: args.provider,
959
1481
  sourceId: `${args.provider}-provider-api`,
@@ -964,9 +1486,8 @@ async function syncProviderCommand(args) {
964
1486
  enterprise: args.enterprise,
965
1487
  accountId: args.accountId
966
1488
  });
967
- const priorProviderState = await readOptionalJson(join(stateDir, "provider-records.json"), { records: [] });
968
1489
  const records = [
969
- ...priorProviderState.records.filter((record) => record.source.provider !== result.provider),
1490
+ ...(trustedPrior?.records ?? []).filter((record) => record.source.provider !== result.provider),
970
1491
  ...result.records
971
1492
  ].sort((left, right) => left.timestamp.localeCompare(right.timestamp));
972
1493
  const registry = await readSourceRegistry(stateDir, rootPath);
@@ -975,17 +1496,21 @@ async function syncProviderCommand(args) {
975
1496
  const summary = analyzeSpend(headlineRecords);
976
1497
  const mappings = attributeUsageRecords(records);
977
1498
  const qaByProvider = {
978
- ...(priorProviderState.qaByProvider ?? {}),
1499
+ ...trustedAccountingMap(trustedPrior?.accounting, "qaByProvider"),
979
1500
  [result.provider]: result.qa
980
1501
  };
981
1502
  const coverageByProvider = {
982
- ...(priorProviderState.coverageByProvider ?? {}),
1503
+ ...trustedAccountingMap(trustedPrior?.accounting, "coverageByProvider"),
983
1504
  [result.provider]: result.coverage
984
1505
  };
985
1506
  const financialsByProvider = {
986
- ...(priorProviderState.financialsByProvider ?? {}),
1507
+ ...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
987
1508
  [result.provider]: result.financials
988
1509
  };
1510
+ // Invalidate any earlier receipt before the first mutation. If a later
1511
+ // local write fails, the partially updated repository state stays
1512
+ // untrusted rather than inheriting the previous sync's authority.
1513
+ await invalidateConnectedSpendTrustReceipt(rootPath);
989
1514
  await mkdir(stateDir, { recursive: true });
990
1515
  await writeJson(join(stateDir, "sources.json"), nextRegistry);
991
1516
  await writeJson(join(stateDir, "provider-records.json"), {
@@ -1001,10 +1526,14 @@ async function syncProviderCommand(args) {
1001
1526
  coverageByProvider,
1002
1527
  financialsByProvider
1003
1528
  });
1529
+ await recordProviderSourceAttempt(stateDir, result.provider, result.fetchedAt, result.coverage === "partial"
1530
+ ? (providerQaLastError(result.qa) ?? `${result.provider}: provider returned partial coverage`)
1531
+ : null);
1004
1532
  await writeLocalSpendState(stateDir, records, summary, mappings, "connected_provider", {
1005
1533
  policy: "provider_reported_billed_cost_preferred",
1006
1534
  note: "Official provider-reported billed costs are the spend headline. API-equivalent estimates remain separate evidence and are not added to that total.",
1007
1535
  coverageByProvider,
1536
+ qaByProvider,
1008
1537
  financialsByProvider
1009
1538
  });
1010
1539
  await appendAuditEvent(stateDir, {
@@ -1013,16 +1542,19 @@ async function syncProviderCommand(args) {
1013
1542
  sourceId: result.source.id,
1014
1543
  detail: `${args.provider} provider connector synced ${result.records.length} evidence records with ${result.coverage} coverage. Auth reference only; no raw secrets stored.`
1015
1544
  });
1545
+ await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
1016
1546
  return ok([
1017
1547
  "aibill sync-provider",
1018
1548
  `provider: ${result.provider}`,
1019
1549
  `source: ${result.source.id}`,
1020
- `verification: ${result.source.verification}`,
1550
+ `boundary approval: ${result.source.boundaryApproval}`,
1551
+ `validation coverage: ${result.source.validationCoverage}`,
1552
+ `financial evidence: ${result.source.financialEvidence}`,
1021
1553
  `coverage: ${result.coverage}`,
1022
1554
  `records fetched: ${result.records.length}`,
1023
1555
  `headline basis: ${result.financials.headlineBasis}`,
1024
- `synced provider headline: $${(result.financials.headlineUsd ?? 0).toFixed(2)}`,
1025
- `combined headline spend: $${summary.totalUsd.toFixed(2)}`,
1556
+ `synced provider headline: ${formatOptionalUsd(result.financials.headlineUsd)}`,
1557
+ `combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
1026
1558
  ...(result.financials.apiEquivalentEstimatedUsd !== null
1027
1559
  ? [`API-equivalent estimate (kept separate): $${result.financials.apiEquivalentEstimatedUsd.toFixed(2)}`]
1028
1560
  : []),
@@ -1030,13 +1562,51 @@ async function syncProviderCommand(args) {
1030
1562
  ].join("\n"));
1031
1563
  }
1032
1564
  catch (error) {
1565
+ const sanitizedError = sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference);
1566
+ await recordProviderSourceAttempt(stateDir, args.provider, new Date().toISOString(), sanitizedError).catch(() => {
1567
+ // Source-status state is diagnostic only. Do not hide the provider's
1568
+ // real error if derived-state persistence is unavailable.
1569
+ });
1033
1570
  return {
1034
1571
  exitCode: 1,
1035
1572
  stdout: "",
1036
- stderr: sanitizeSecretishError(error instanceof Error ? error.message : String(error), args.authReference)
1573
+ stderr: sanitizedError
1037
1574
  };
1038
1575
  }
1039
1576
  }
1577
+ function formatOptionalUsd(value) {
1578
+ if (value === null)
1579
+ return "unavailable";
1580
+ if (value > 0 && value < 0.01)
1581
+ return "<$0.01";
1582
+ return `$${value.toFixed(2)}`;
1583
+ }
1584
+ async function recordProviderSourceAttempt(stateDir, provider, checkedAt, lastError) {
1585
+ if (!isProviderStatusId(provider) || !validIsoString(checkedAt)) {
1586
+ return;
1587
+ }
1588
+ let prior = { version: 1, providers: {} };
1589
+ try {
1590
+ prior = parsePersistedSourceAttemptState(await readJson(join(stateDir, "source-status.json"))).state;
1591
+ }
1592
+ catch {
1593
+ // Missing/corrupt derived status state is safe to replace. Provider
1594
+ // financial records live in a separate file and are never touched here.
1595
+ }
1596
+ await mkdir(stateDir, { recursive: true });
1597
+ await writeJson(join(stateDir, "source-status.json"), {
1598
+ version: 1,
1599
+ providers: {
1600
+ ...(prior.providers ?? {}),
1601
+ [provider]: {
1602
+ checkedAt,
1603
+ lastError: lastError === null
1604
+ ? null
1605
+ : (sanitizePersistedStatusText(lastError) ?? "empty provider error")
1606
+ }
1607
+ }
1608
+ });
1609
+ }
1040
1610
  async function confirmMappingCommand(args) {
1041
1611
  const rootPath = resolve(args.path);
1042
1612
  const stateDir = join(rootPath, ".ai-spend-agent");
@@ -1080,10 +1650,15 @@ async function reportCommand(args) {
1080
1650
  const sinceDays = args.sinceDays ?? 30;
1081
1651
  if (!validSinceDays(sinceDays))
1082
1652
  return invalidSinceDaysResult();
1083
- const reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
1653
+ // Like Apply, an explicit sample report is a strict privacy boundary. It
1654
+ // must not inspect local transcripts, account metadata, or persisted state.
1655
+ const reportInput = args.sample
1656
+ ? await buildExplicitSampleReportInput(rootPath)
1657
+ : await buildReportInput(stateDir, rootPath, sinceDays);
1084
1658
  const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
1085
1659
  const markdownPath = `${outBase}.md`;
1086
1660
  const htmlPath = `${outBase}.html`;
1661
+ await mkdir(stateDir, { recursive: true });
1087
1662
  await writeLocalReportFile(markdownPath, generateMarkdownReport(reportInput), stateDir);
1088
1663
  await writeLocalReportFile(htmlPath, generateHtmlReport(reportInput), stateDir);
1089
1664
  const artifactPaths = await writeApplyArtifacts(stateDir, reportInput);
@@ -1097,13 +1672,17 @@ async function reportCommand(args) {
1097
1672
  `policy/config draft: ${artifactPaths.policyConfigDraft}`,
1098
1673
  `verification plan: ${artifactPaths.verificationPlan}`,
1099
1674
  `demo package: ${artifactPaths.demoPackage}`,
1100
- `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
1675
+ reportInput.dataMode === "sample"
1676
+ ? `DEMO SAMPLE · illustrative cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)} · not user data`
1677
+ : `cost/value evidence total: $${reportInput.summary.totalUsd.toFixed(2)}`,
1101
1678
  "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
1102
1679
  "",
1103
1680
  "next:",
1104
1681
  ` open ${htmlPath} view the full report in your browser`,
1105
1682
  ` less ${markdownPath} read it in the terminal`,
1106
- " npx aibill apply print the paste-ready coding-agent prompt"
1683
+ reportInput.dataMode === "sample"
1684
+ ? " npx aibill apply --sample print the non-executable demo boundary"
1685
+ : " npx aibill apply print the paste-ready coding-agent prompt"
1107
1686
  ].join("\n"));
1108
1687
  }
1109
1688
  catch (error) {
@@ -1130,17 +1709,27 @@ async function resolveReceiptPath(rootPath, out) {
1130
1709
  }
1131
1710
  async function reportCardCommand(args) {
1132
1711
  try {
1133
- const rootPath = await resolveSafeScanRoot(args.path);
1134
- const { records, mode } = await loadInstantReadData(args);
1712
+ // Explicit sample mode reads no workspace data, so a broad-root scan guard
1713
+ // would reject a harmless receipt written from the user's home directory.
1714
+ // Output still goes through the safe-write/symlink checks below.
1715
+ const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
1716
+ const { records, mode, providerCoverage } = await loadInstantReadData(args);
1135
1717
  const headlineRecords = mode === "connected"
1136
1718
  ? selectProviderFinancialHeadlineRecords(records)
1137
1719
  : records;
1138
1720
  const summary = analyzeSpend(headlineRecords);
1139
1721
  const outPath = await resolveReceiptPath(rootPath, args.out);
1140
1722
  await mkdir(dirname(outPath), { recursive: true });
1141
- await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({ summary, records: headlineRecords, mode }));
1723
+ await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({
1724
+ summary,
1725
+ records: headlineRecords,
1726
+ mode,
1727
+ ...(providerCoverage ? { providerCoverage } : {})
1728
+ }));
1142
1729
  const dataLine = mode === "demo"
1143
- ? "data: DEMO sample data — run without --sample on a machine with Claude Code/Codex logs for your own numbers."
1730
+ ? args.sample
1731
+ ? "data: DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read."
1732
+ : "data: DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly."
1144
1733
  : mode === "local-logs"
1145
1734
  ? "data: local Claude Code/Codex logs priced at API-equivalent rates."
1146
1735
  : "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
@@ -1150,7 +1739,12 @@ async function reportCardCommand(args) {
1150
1739
  dataLine,
1151
1740
  "",
1152
1741
  "Caption to share:",
1153
- generateReportCardCaption({ summary, records: headlineRecords, mode }),
1742
+ generateReportCardCaption({
1743
+ summary,
1744
+ records: headlineRecords,
1745
+ mode,
1746
+ ...(providerCoverage ? { providerCoverage } : {})
1747
+ }),
1154
1748
  "",
1155
1749
  "privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
1156
1750
  ].join("\n"));
@@ -1242,23 +1836,59 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
1242
1836
  const sinceIso = sinceIsoForDays(sinceDays, generatedAt);
1243
1837
  let freshLocalCalls;
1244
1838
  let freshCodexInvocationFiles;
1245
- let spendState = await readOptionalJson(join(stateDir, "spend.json"), undefined);
1839
+ let exactSpendContents;
1840
+ try {
1841
+ exactSpendContents = await readSafeStateText(stateDir, "spend.json");
1842
+ }
1843
+ catch (error) {
1844
+ if (!isNodeError(error, "ENOENT"))
1845
+ throw error;
1846
+ }
1847
+ let spendState = exactSpendContents === undefined
1848
+ ? undefined
1849
+ : JSON.parse(exactSpendContents);
1850
+ let untrustedConnectedStateMessage;
1851
+ let unavailablePersistedLocalLogs = false;
1246
1852
  let mappings = await readOptionalJson(join(stateDir, "mappings.json"), undefined);
1247
1853
  // Never trust a persisted summary or an absent mode. Re-parse the records,
1248
1854
  // recover the narrowly identifiable bundled sample written by older
1249
1855
  // releases, and recompute decision output under the current evidence rules.
1250
1856
  // Any other unlabeled state remains unlabeled and therefore non-executable.
1251
1857
  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);
1858
+ const parsedRecords = spendState.records.map((record) => parseUsageRecord(record));
1859
+ const storedMode = isPersistedDataMode(spendState.mode) ? spendState.mode : undefined;
1860
+ // A bundled sample remains sample even if a conflicting mode was written.
1861
+ // This guards report and Apply separately from the quickstart read path.
1862
+ const mode = isBundledSampleUsage(parsedRecords) ? "sample" : storedMode;
1863
+ const records = mode === "sample" || mode === undefined
1864
+ ? downgradeSampleUsageEvidence(parsedRecords)
1865
+ : parsedRecords;
1866
+ unavailablePersistedLocalLogs = mode === "local_logs";
1254
1867
  const headlineRecords = mode === "connected_provider"
1255
1868
  ? selectProviderFinancialHeadlineRecords(records)
1256
1869
  : records;
1257
1870
  spendState = {
1258
1871
  records,
1259
1872
  mode,
1260
- summary: analyzeSpend(headlineRecords)
1873
+ summary: analyzeSpend(headlineRecords),
1874
+ ...(spendState.accounting !== undefined ? { accounting: spendState.accounting } : {})
1261
1875
  };
1876
+ if (mode === "connected_provider" && exactSpendContents !== undefined) {
1877
+ const trust = await verifyConnectedSpendTrustReceipt(rootPath, exactSpendContents);
1878
+ if (!trust.trusted) {
1879
+ untrustedConnectedStateMessage = [
1880
+ trust.message,
1881
+ "CLI: run `npx aibill connect <provider>` or repeat the prior `npx aibill sync-provider ...` command."
1882
+ ].join(" ");
1883
+ spendState = undefined;
1884
+ mappings = undefined;
1885
+ }
1886
+ else {
1887
+ // Derived attribution is rebuilt from the receipt-bound records. A
1888
+ // repository-authored mappings.json cannot steer Apply ownership.
1889
+ mappings = attributeUsageRecords(records);
1890
+ }
1891
+ }
1262
1892
  }
1263
1893
  // Local-log state is a CACHE, not a source of truth: the quickstart always
1264
1894
  // re-reads the logs fresh, so report/apply must too — otherwise yesterday's
@@ -1293,20 +1923,57 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
1293
1923
  await writeLocalSpendState(stateDir, records, summary, liveMappings, "local_logs");
1294
1924
  spendState = { summary, records, mode: "local_logs" };
1295
1925
  mappings = liveMappings;
1926
+ unavailablePersistedLocalLogs = false;
1927
+ }
1928
+ else if (unavailablePersistedLocalLogs) {
1929
+ // Persisted local_logs is only a cache. If the source transcripts are no
1930
+ // longer present, repository-authored rows cannot become an executable
1931
+ // Apply artifact merely by claiming local_logs mode.
1932
+ spendState = undefined;
1933
+ mappings = undefined;
1296
1934
  }
1297
- // else: logs vanished — an existing local_logs snapshot (if any) is used below.
1298
1935
  }
1299
1936
  if (!spendState?.summary || !spendState.records || spendState.records.length === 0) {
1937
+ if (untrustedConnectedStateMessage) {
1938
+ throw new Error(`${untrustedConnectedStateMessage} No connected totals or Apply actions were generated.`);
1939
+ }
1940
+ if (unavailablePersistedLocalLogs) {
1941
+ throw new Error("Persisted local-log state is an untrusted cache and its source Claude Code/Codex records are unavailable. " +
1942
+ "Re-run `npx aibill` while the local transcripts are available; no report or Apply action was generated from repository state alone.");
1943
+ }
1300
1944
  throw new Error("no persisted spend state and no local Claude Code/Codex logs found. " +
1301
1945
  "Run `npx aibill` first (or `npx aibill scan --sample --path <dir>` for a demo-data report).");
1302
1946
  }
1303
- const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, providerRecordsState] = await Promise.all([
1947
+ const [discovery, sourceRegistry, missingSourcePrompts, confirmedMappings, persistedProviderRecordsState] = await Promise.all([
1304
1948
  readOptionalJson(join(stateDir, "discovery.json"), emptyDiscovery(rootPath)),
1305
1949
  readSourceRegistry(stateDir, rootPath),
1306
1950
  readOptionalJson(join(stateDir, "missing-sources.json"), []),
1307
1951
  readConfirmedMappings(stateDir),
1308
1952
  readOptionalJson(join(stateDir, "provider-records.json"), { records: [] })
1309
1953
  ]);
1954
+ const providerRecordsState = spendState.mode === "connected_provider"
1955
+ ? {
1956
+ records: spendState.records,
1957
+ qaByProvider: trustedAccountingMap(isPlainObject(spendState.accounting) ? spendState.accounting : undefined, "qaByProvider"),
1958
+ coverageByProvider: trustedAccountingMap(isPlainObject(spendState.accounting) ? spendState.accounting : undefined, "coverageByProvider")
1959
+ }
1960
+ : persistedProviderRecordsState;
1961
+ const providerQa = providerRecordsState.qaByProvider
1962
+ ? Object.values(providerRecordsState.qaByProvider).sort((left, right) => left.provider.localeCompare(right.provider))
1963
+ : providerRecordsState.qa
1964
+ ? [providerRecordsState.qa]
1965
+ : [];
1966
+ const spendProviderCoverage = persistedProviderCoverage(spendState.accounting);
1967
+ const coverageStatuses = [
1968
+ ...Object.values(providerRecordsState.coverageByProvider ?? {}),
1969
+ ...providerQa.map((qa) => qa.coverage).filter((coverage) => coverage === "complete" || coverage === "partial"),
1970
+ ...(spendProviderCoverage ? [spendProviderCoverage] : [])
1971
+ ];
1972
+ const providerCoverage = coverageStatuses.includes("partial")
1973
+ ? "partial"
1974
+ : coverageStatuses.includes("complete")
1975
+ ? "complete"
1976
+ : undefined;
1310
1977
  // Named dead-context items feed the apply artifact for local-log users —
1311
1978
  // the concrete "remove these" list, from the same engine as the readout.
1312
1979
  const deadContext = spendState.mode === "local_logs"
@@ -1364,9 +2031,13 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30) {
1364
2031
  mappings: mappings ?? [],
1365
2032
  sourceRegistry,
1366
2033
  missingSourcePrompts,
1367
- confirmedMappings,
2034
+ // Confirmed mappings are mutable repository state with a different user-
2035
+ // approval lifecycle. Until they receive their own receipt, they cannot
2036
+ // be promoted into connected-provider Apply actions.
2037
+ confirmedMappings: spendState.mode === "connected_provider" ? [] : confirmedMappings,
1368
2038
  providerRecords: providerRecordsState.records,
1369
- providerQa: providerRecordsState.qa ? [providerRecordsState.qa] : []
2039
+ providerQa,
2040
+ ...(providerCoverage ? { providerCoverage } : {})
1370
2041
  };
1371
2042
  }
1372
2043
  function validSinceDays(value) {
@@ -1439,6 +2110,10 @@ function parseArgs(argv) {
1439
2110
  parsed.json = true;
1440
2111
  continue;
1441
2112
  }
2113
+ if (arg === "--sources") {
2114
+ parsed.sources = true;
2115
+ continue;
2116
+ }
1442
2117
  if (arg === "--ignore-state") {
1443
2118
  parsed.ignoreState = true;
1444
2119
  continue;
@@ -1674,13 +2349,34 @@ function parseArgs(argv) {
1674
2349
  function sanitizeSecretishError(message, authReference) {
1675
2350
  // Core's redactSecrets covers sk-*/ghp_*/github_pat_*/JWT/AIza/xox/AKIA and
1676
2351
  // secret-suffixed env assignments; the sk- fallback keeps short keys covered.
1677
- let sanitized = redactSecrets(message).replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]");
2352
+ // Strip terminal controls first so escapes cannot split a secret pattern or
2353
+ // forge extra CLI lines, then exact-redact again after normalization.
2354
+ const withoutAuthReference = authReference && !authReference.startsWith("env:")
2355
+ ? message.split(authReference).join("[REDACTED]")
2356
+ : message;
2357
+ let sanitized = redactSecrets(stripTerminalControlSequences(withoutAuthReference))
2358
+ .replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]");
1678
2359
  if (authReference && !authReference.startsWith("env:")) {
1679
2360
  sanitized = sanitized.split(authReference).join("[REDACTED]");
1680
2361
  }
1681
- return sanitized;
2362
+ return sanitized.trim();
2363
+ }
2364
+ function stripTerminalControlSequences(message) {
2365
+ return message
2366
+ .replace(/(?:\u001b\]|\u009d)[\s\S]*?(?:\u0007|\u001b\\|\u009c|$)/gu, "")
2367
+ .replace(/(?:\u001b(?:P|X|\^|_)|[\u0090\u0098\u009e\u009f])[\s\S]*?(?:\u001b\\|\u009c|$)/gu, "")
2368
+ .replace(/(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/gu, "")
2369
+ .replace(/\u001b[@-_]/gu, "")
2370
+ .replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
2371
+ .replace(/\s+/gu, " ");
2372
+ }
2373
+ function isPersistedDataMode(value) {
2374
+ return value === "sample" || value === "local_logs" || value === "connected_provider";
1682
2375
  }
1683
2376
  async function writeLocalSpendState(stateDir, records, summary, mappings, mode, accounting) {
2377
+ if (mode !== "connected_provider") {
2378
+ await invalidateConnectedSpendTrustReceipt(dirname(stateDir));
2379
+ }
1684
2380
  await writeJson(join(stateDir, "spend.json"), {
1685
2381
  mode,
1686
2382
  records,
@@ -1691,7 +2387,23 @@ async function writeLocalSpendState(stateDir, records, summary, mappings, mode,
1691
2387
  }
1692
2388
  async function readSourceRegistry(stateDir, rootPath) {
1693
2389
  try {
1694
- return await readJson(join(stateDir, "sources.json"));
2390
+ const exactSourceRegistryContents = await readSafeStateText(stateDir, "sources.json");
2391
+ const registry = normalizeSourceRegistry(JSON.parse(exactSourceRegistryContents));
2392
+ try {
2393
+ const exactSpendContents = await readSafeStateText(stateDir, "spend.json");
2394
+ const parsedSpend = JSON.parse(exactSpendContents);
2395
+ if (parsedSpend.mode === "connected_provider") {
2396
+ const trust = await verifyConnectedSourceRegistryTrustReceipt(rootPath, exactSpendContents, exactSourceRegistryContents);
2397
+ if (trust.trusted)
2398
+ return registry;
2399
+ }
2400
+ }
2401
+ catch {
2402
+ // A source boundary remains usable as configuration, but its repository-
2403
+ // controlled validation/evidence claims are never promoted without the
2404
+ // matching external provider-sync receipt.
2405
+ }
2406
+ return downgradeUntrustedSourceRegistryClaims(registry);
1695
2407
  }
1696
2408
  catch {
1697
2409
  return createLocalFolderSourceRegistry(rootPath);
@@ -1756,17 +2468,17 @@ function helpText() {
1756
2468
  return [
1757
2469
  "aibill — your AI cost and usage evidence in one private view",
1758
2470
  "",
1759
- "Run with no command for an instant, zero-key demo:",
1760
- " ai-spend-agent Show available AI cost/value evidence (sample or local data)",
1761
- " ai-spend-agent --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
1762
- " ai-spend-agent --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
2471
+ "Run with no command for an instant, zero-key local readout:",
2472
+ " npx aibill Show available AI cost/value evidence (sample or local data)",
2473
+ " npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
2474
+ " npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
1763
2475
  "",
1764
2476
  "Add official provider-reported cost (ADMIN/owner-gated):",
1765
- " ai-spend-agent connect openai Requires an org-owner Admin key",
1766
- " ai-spend-agent connect anthropic Requires an Admin key",
1767
- " ai-spend-agent connect cursor Upgrade: requires a Cursor team-admin key (Business plan)",
1768
- " ai-spend-agent connect github-copilot Upgrade: requires a GitHub billing-admin token",
1769
- " ai-spend-agent sync-provider ... Pull provider cost/usage evidence via a local env: reference (never a raw key)",
2477
+ " npx aibill connect openai Requires an org-owner Admin credential reference",
2478
+ " npx aibill connect anthropic Requires an Admin credential reference",
2479
+ " npx aibill connect cursor Beta: requires a Cursor team-admin credential reference",
2480
+ " npx aibill connect github-copilot Beta: requires a GitHub billing-admin credential reference",
2481
+ " npx aibill sync-provider ... Pull provider cost/usage evidence via a local env: reference (never a raw credential)",
1770
2482
  "",
1771
2483
  "Watch continuously (deltas + anomalies):",
1772
2484
  " watch [--interval N] Re-run analysis on an interval and report deltas/anomalies",
@@ -1775,14 +2487,14 @@ function helpText() {
1775
2487
  "Other commands:",
1776
2488
  " --version, -v Print the package version without reading local data",
1777
2489
  " init [--path <dir>] Initialize local state",
1778
- " doctor Launch-grade diagnostics: data mode, logs found, provider keys, warnings",
2490
+ " doctor [--sources] Launch diagnostics; --sources shows validation, evidence, freshness, and errors",
1779
2491
  " reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
1780
2492
  " --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
1781
2493
  " scan [--path <dir>] Scan a local workspace for AI usage signals",
1782
2494
  " scan --sample Include deterministic sample spend analysis",
1783
2495
  " quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
1784
2496
  " [--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",
2497
+ " report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
1786
2498
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
1787
2499
  " glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
1788
2500
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
@@ -1791,9 +2503,10 @@ function helpText() {
1791
2503
  " apply-artifact Same as `apply` (long form)",
1792
2504
  "",
1793
2505
  "Cron (production watch): add a crontab entry such as:",
1794
- " 0 * * * * cd /path/to/workspace && ai-spend-agent watch --interval 3600 --cycles 1 >> ai-spend-watch.log 2>&1",
2506
+ " 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
1795
2507
  "",
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."
2508
+ "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
2509
+ "aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
1797
2510
  ].join("\n");
1798
2511
  }
1799
2512
  // Main-module check that survives npm's bin SYMLINKS: argv[1] is
@@ -1821,8 +2534,8 @@ export async function runMain() {
1821
2534
  // error deep in a dependency. npm warns on engines but never blocks install.
1822
2535
  const major = Number(process.versions.node.split(".")[0]);
1823
2536
  if (Number.isFinite(major) && major < 22) {
1824
- console.error(`ai-spend-agent needs Node 22 or newer (you have ${process.versions.node}).\n` +
1825
- "Upgrade Node, then run: npx ai-spend-agent");
2537
+ console.error(`aibill needs Node 22 or newer (you have ${process.versions.node}).\n` +
2538
+ "Upgrade Node, then run: npx aibill");
1826
2539
  process.exit(1);
1827
2540
  }
1828
2541
  const argv = process.argv.slice(2);
@@ -1852,9 +2565,9 @@ export async function runMain() {
1852
2565
  exitCode: 1,
1853
2566
  stdout: "",
1854
2567
  stderr: [
1855
- `ai-spend-agent hit an unexpected error: ${message}`,
2568
+ `aibill hit an unexpected error: ${message}`,
1856
2569
  "Nothing was uploaded; local state is unchanged.",
1857
- "Try `ai-spend-agent doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
2570
+ "Try `npx aibill doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
1858
2571
  ].join("\n")
1859
2572
  };
1860
2573
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.5.9",
3
+ "version": "0.6.0",
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.9",
58
- "@agent-finops/report": "0.5.9",
57
+ "@agent-finops/core": "0.6.0",
58
+ "@agent-finops/report": "0.6.0",
59
59
  "yocto-spinner": "^1.2.0"
60
60
  }
61
61
  }