ai-spend-agent 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { type GuidedPromptSource } from "./guidedPrompt.js";
3
3
  import { type SignupDnsResolver } from "./signup.js";
4
+ import { decideReportAutoOpen, openReportInBrowser } from "./reportOpener.js";
4
5
  export type CliResult = {
5
6
  exitCode: number;
6
7
  stdout: string;
@@ -44,6 +45,15 @@ export type CliRuntimeOptions = {
44
45
  * Embedded/MCP callers never set it (and never emit telemetry).
45
46
  */
46
47
  telemetryDisclosure?: boolean;
48
+ /**
49
+ * Test seams for `report`'s HTML auto-open (0.9.5): decide computes the
50
+ * truthful open/suppress verdict (platform, TTY, CI/SSH, --no-open,
51
+ * AI_SPEND_NO_OPEN); open fires the detached platform opener. Production
52
+ * uses the real implementations; tests inject stubs to pin the opener
53
+ * argv per platform, every suppression path, and summary-line truth.
54
+ */
55
+ reportOpenDecide?: typeof decideReportAutoOpen;
56
+ reportOpenLaunch?: typeof openReportInBrowser;
47
57
  };
48
58
  export declare function runCli(argv?: string[], runtime?: CliRuntimeOptions): Promise<CliResult>;
49
59
  /**
package/dist/index.js CHANGED
@@ -3,13 +3,13 @@ import { randomUUID } from "node:crypto";
3
3
  import { realpathSync } from "node:fs";
4
4
  import { lstat, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
5
5
  import { homedir } from "node:os";
6
- import { basename, dirname, extname, join, resolve } from "node:path";
6
+ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { askGuidedQuestion, classifyGuidedAnswer, createInteractivePromptSource, renderForYourAgent } from "./guidedPrompt.js";
9
9
  import { assessEmailDeliverability, buildWaitlistRef, normalizeWaitlistEmail, postWaitlistSignup, readSignupState, clearSignupState, sanitizeSignupRefTag, serializeWaitlistPayload, signupAskTimeoutMs, signupCopy, signupStateFilePath, writeSignupState } from "./signup.js";
10
10
  import { killTelemetryForThisProcess, readTelemetryState, telemetryDisclosureLine, telemetryStateFilePath, writeTelemetryState } from "./telemetry.js";
11
11
  import { parsePlanDraft, renderCleanExit, runIdentitySequence, runPlanSitting, runQualitySitting, runRecordSitting, runRollbackSitting, runStartSitting, shortSittingHint } from "./improveFlow.js";
12
- import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, decodeAgentDraftTokenV1, IMPROVE_USER_SAFETY_LINE_V1, looksLikeAgentDraftToken, screenAgentDraftSentence, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, buildResultCard, buildResultCardProjectLine, formatBilledUsdExact, formatCommittedPerMonth, resultCardSchema, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, SAFE_QUALITATIVE_SCAN_POLICY, summarizeProviderFinancials, providerFinancialCompleteness, providerAccountKey, tagProviderAccountRecords, retainProviderRecordsForNewSync, providerAccountSlices, formatProviderAccountSlices, intersectProviderCoverageIntervals, duplicateProviderAccountSliceWarnings, providerSliceReplacementNotices, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, loadDeadContext, sampleDeadContext, sanitizeLocalActivityText, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, loadLocalAgentActionEvidence, extractSessionVitalsV0, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProjectIndexAdapters, createActionVerificationReference, createProjectEconomicsReference, createProjectEconomicsPlannedActionRefV0, PROJECT_ECONOMICS_V0_VERSION, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, markTokenReductionAppliedV0, invalidateTokenReductionExperimentV0, markTokenReductionRolledBackV0, activitySnapshotCachePath, readActivitySnapshot, recordActivitySnapshotRefreshFailure, refreshTokenReductionExperimentV0, resolveWasteFindingTargetV0, selectBestWasteFindingV0, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
12
+ import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, decodeAgentDraftTokenV1, IMPROVE_USER_SAFETY_LINE_V1, looksLikeAgentDraftToken, screenAgentDraftSentence, attributeUsageRecords, buildUsageGlance, buildActivitySnapshot, buildResultCard, buildResultCardProjectLine, formatBilledUsdExact, formatCommittedPerMonth, resultCardSchema, loadContextHealth, detectLocalCredentials, detectLocalPlans, redactSecrets, readSafeStateText, invalidateConnectedSpendTrustReceipt, resolveSafeScanRoot, resolveSafeStateDirectory, subscriptionPlans, unsafeScanRootReason, selectProviderFinancialHeadlineRecords, SAFE_QUALITATIVE_SCAN_POLICY, summarizeProviderFinancials, providerFinancialCompleteness, providerAccountKey, tagProviderAccountRecords, retainProviderRecordsForNewSync, providerAccountSlices, formatProviderAccountSlices, intersectProviderCoverageIntervals, duplicateProviderAccountSliceWarnings, providerSliceReplacementNotices, writeSafeStateText, verifyConnectedSpendTrustReceipt, verifyConnectedSourceRegistryTrustReceipt, writeConnectedSpendTrustReceipt, generateCutList, loadDeadContext, sampleDeadContext, sanitizeLocalActivityText, latestObservedWorkingDirectory, downgradeSampleUsageEvidence, isBundledSampleUsage, hasCompleteQualitativeCoverage, hasExactSelectedQualitativeEvidence, loadLocalAgentActionEvidence, extractSessionVitalsV0, loadLocalAgentFinancialUsage, localAgentFormatDescriptors, localAgentFormatLabel, localAgentFormatSupports, loadSampleUsageData, parseUsageRecord, scanLocalUsageSignals, buildMissingSourcePrompts, confirmMapping, createProjectIndexAdapters, createActionVerificationReference, createProjectEconomicsReference, createProjectEconomicsPlannedActionRefV0, PROJECT_ECONOMICS_V0_VERSION, createProviderConnectorStub, createProviderConnection, createLocalFolderSourceRegistry, createScanAuditLog, fetchProviderUsageRecords, addApprovedSource, normalizeSourceRegistry, downgradeUntrustedSourceRegistryClaims, buildSourceStatuses, applyProviderContractGate, applyProviderContractGateToSourceRegistry, slugifySourceId, financialEvidenceForRecords, formatSourceStatuses, markTokenReductionAppliedV0, invalidateTokenReductionExperimentV0, markTokenReductionRolledBackV0, activitySnapshotCachePath, readActivitySnapshot, recordActivitySnapshotRefreshFailure, refreshTokenReductionExperimentV0, resolveWasteFindingTargetV0, selectBestWasteFindingV0, sourceStatusDefinitions, writeActivitySnapshot } from "@agent-finops/core";
13
13
  import { StatuslineInstallerError, installClaudeStatusline, refreshOwnedStatuslineRunner, uninstallClaudeStatusline } from "./statuslineInstaller.js";
14
14
  import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
15
15
  import { chooseLatestTokenReductionExperiment, loadTokenVerificationState, upsertTokenReductionExperiment } from "./tokenVerificationState.js";
@@ -17,7 +17,8 @@ import { buildGuidedExperience } from "./guidedExperience.js";
17
17
  import { buildImproveExperience } from "./improveExperience.js";
18
18
  import { appendAcceptedProjectOutcome, appendProjectApprovalEvent, createProjectAccountabilityOwnership, loadProjectAccountabilityState, projectAccountabilityStatePath, upsertConfirmedProjectOwnership } from "./projectAccountabilityState.js";
19
19
  import { fetchGitHubAcceptedOutcomeV0 } from "./githubAcceptedOutcome.js";
20
- import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions } from "@agent-finops/report";
20
+ import { decideReportAutoOpen, openReportInBrowser, platformOpenCommand } from "./reportOpener.js";
21
+ import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateCommandSummary, generateDemoPackageMarkdown, generateHtmlReport, generateMarkdownReport, generatePlainEnglishSummary, generatePolicyConfigDraftMarkdown, generateReceiptCompanionHtml, generateReportCardCaption, generateReportCardSvg, generateVerificationPlanMarkdown, groupByDimensions, shellPathPointer } from "@agent-finops/report";
21
22
  // One shared v2 sharded store instance for BOTH evidence kinds: the v1
22
23
  // monolithic qualitative adapter re-probed git privacy on every read (176
23
24
  // spawned git processes per warm run with $HOME itself a git repo) and
@@ -101,7 +102,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
101
102
  return reportCommand(args, runtime);
102
103
  }
103
104
  if (args.command === "report-card") {
104
- return reportCardCommand(args);
105
+ return reportCardCommand(args, runtime);
105
106
  }
106
107
  if (args.command === "glance") {
107
108
  return glanceCommand(args);
@@ -165,52 +166,19 @@ async function quickstartCommand(args, runtime = {}) {
165
166
  stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
166
167
  };
167
168
  }
168
- const { records, mode, warnings, providerCoverage, codexInvocationFiles, actionEvidence, localFinancialRecords, financialCoverageComplete } = await loadInstantReadData(args);
169
+ // ONE evidence bundle, shared with the machine-wide `report` (0.9.6) so the
170
+ // readout and the written artifact can never disagree.
171
+ const evidence = await loadBroadScanEvidence(args, sinceDays);
172
+ const { records, mode, warnings, providerCoverage, actionEvidence, financialCoverageComplete, summaryRecords, summary, detectedPlans, deadContext } = evidence;
169
173
  if (records.length === 0) {
170
174
  return noEvidenceResult("receipt", warnings, sinceDays, runtime.telemetryDisclosure);
171
175
  }
172
- const summaryRecords = mode === "connected"
173
- ? selectProviderFinancialHeadlineRecords(records)
174
- : records;
175
- const summary = analyzeSpend(summaryRecords);
176
- // Connected receipts stay billed-primary but never ERASE the estimated
177
- // axis: local transcript records ride along so subscription rows keep
178
- // their ~ API-equivalent figures next to billed money (C-lane §1.4). The
179
- // renderer classifies each record by basis and never blends the totals.
180
- const receiptRecords = mode === "connected" && (localFinancialRecords?.length ?? 0) > 0
181
- ? [...summaryRecords, ...localFinancialRecords]
182
- : summaryRecords;
176
+ const receiptRecords = evidence.analysisRecords;
183
177
  // For real local-log users the by-project view is the flagship table
184
178
  // ("which project burns my plan"); demo/connected keep by-model.
185
179
  const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
186
180
  const color = args.noColor ? false : undefined;
187
181
  const outputWidth = terminalOutputWidth();
188
- // Persona: --plan override wins; otherwise read the plans the coding agents
189
- // themselves persisted locally (read-only, whitelisted fields, no network).
190
- let detectedPlans;
191
- if (args.sample) {
192
- // An explicit sample run must be deterministic and safe to record/share.
193
- // Never mix the developer's real local plan into illustrative output.
194
- detectedPlans = [];
195
- }
196
- else if (args.plan) {
197
- const override = planOverrideFromFlag(args.plan);
198
- if (!override) {
199
- return {
200
- exitCode: 1,
201
- stdout: "",
202
- stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
203
- };
204
- }
205
- detectedPlans = [override];
206
- }
207
- else {
208
- detectedPlans = await detectLocalPlans({
209
- // Env overrides keep tests (and unusual installs) isolated from $HOME.
210
- claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
211
- codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
212
- }).catch(() => []);
213
- }
214
182
  // Surface auto-detected credentials so the user knows their next 2-min step,
215
183
  // without ever printing a raw secret.
216
184
  // Sample output is designed for demos, docs, and screenshots. Keeping local
@@ -223,32 +191,6 @@ async function quickstartCommand(args, runtime = {}) {
223
191
  home: process.env.AI_SPEND_CLAUDE_HOME_DIR
224
192
  });
225
193
  const nextSteps = quickstartNextSteps(mode, detection.credentials);
226
- // Dead-context cost, globalized across the user's whole Claude Code setup
227
- // (all projects' MCP + user-scope skills/agents/commands, vs. every
228
- // transcript) so it's populated from ANY directory on the first run.
229
- // Never throws into the readout.
230
- let deadContext = args.sample
231
- ? undefined
232
- : await loadDeadContext({
233
- // Env overrides keep tests (and unusual installs) isolated from $HOME.
234
- claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
235
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
236
- claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
237
- codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
238
- claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
239
- claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
240
- projectDir: resolve(args.path),
241
- includeAllProjectMcp: true,
242
- sinceIso: sinceIsoForDays(sinceDays),
243
- windowDays: sinceDays,
244
- codexInvocationFiles
245
- }).catch(() => undefined);
246
- // Sample dead-context is shown ONLY on the demo readout. A real readout
247
- // (local logs / connected billing) never gets fabricated waste injected —
248
- // a genuinely clean setup earns its congratulation line instead.
249
- if (mode === "demo" && (!deadContext || !deadContext.hasData)) {
250
- deadContext = sampleDeadContext();
251
- }
252
194
  const guidedExperience = !args.sample && actionEvidence
253
195
  ? await buildQuickstartGuidedExperience({
254
196
  args,
@@ -261,6 +203,11 @@ async function quickstartCommand(args, runtime = {}) {
261
203
  : undefined;
262
204
  const summaryText = generatePlainEnglishSummary(summary, {
263
205
  records: receiptRecords,
206
+ // The same array the machine-wide report renders into its ACT section.
207
+ cutList: evidence.actionCandidates,
208
+ // …and the same coverage facts the artifact discloses in its banner, so
209
+ // the two surfaces state the same thing about what was actually read.
210
+ ...(actionEvidence ? { qualitativeCoverage: summarizeCliQualitativeCoverage(actionEvidence) } : {}),
264
211
  groupBy,
265
212
  color,
266
213
  mode,
@@ -272,6 +219,11 @@ async function quickstartCommand(args, runtime = {}) {
272
219
  nextSteps,
273
220
  deadContext,
274
221
  detectedPlans,
222
+ // 0.9.5: from a broad root the --full view's project-scoped pointers
223
+ // (apply, apply-artifact, watch, connect) carry the machine-wide
224
+ // report's `cd /path/to/project && …` prefix instead of advertising commands
225
+ // that friendly-refuse right where they were printed.
226
+ commandScope: isBroadScanRoot(args.path) ? "machine-wide" : "project",
275
227
  // C-lane §1.4: the result card header states the evidence window.
276
228
  windowDays: sinceDays,
277
229
  width: outputWidth,
@@ -611,20 +563,31 @@ function quickstartNextSteps(mode, detected) {
611
563
  // Connect/verify guidance now lives in the readout's APPLY/VERIFY sections;
612
564
  // this footer only carries what those can't know (detected local keys) and
613
565
  // the report/waitlist pointers.
566
+ // Command steps are STRUCTURED, never hand-padded: every option handed to
567
+ // the readout passes through a sanitizer that collapses whitespace runs, so
568
+ // "npx aibill report write a shareable report" reached the user
569
+ // as "npx aibill report write a shareable report" — no visible boundary
570
+ // between the command and its description (0.9.6).
614
571
  const steps = [];
615
572
  if (detected.length > 0) {
616
573
  const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
617
574
  steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
618
- steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
575
+ steps.push({
576
+ command: `npx aibill connect ${detected[0].provider}`,
577
+ description: "set up the admin connector, then sync provider-reported cost"
578
+ });
619
579
  }
620
580
  steps.push(mode === "demo"
621
581
  // 0.9.4: report --sample runs as printed from ANY directory — broad
622
582
  // roots write ./ai-spend-report.{md,html} machine-wide-style, project
623
583
  // folders keep .ai-spend-agent/report.* (the old mkdir demo-workspace
624
584
  // preamble is no longer needed for the command to run as printed).
625
- ? "npx aibill report --sample write a clearly labeled demo report right here"
626
- : "npx aibill report write a shareable Markdown + HTML report");
627
- steps.push("npx aibill --group-by project see which project has the most observed activity");
585
+ ? { command: "npx aibill report --sample", description: "write a clearly labeled demo report right here" }
586
+ : { command: "npx aibill report", description: "write a shareable Markdown + HTML report" });
587
+ steps.push({
588
+ command: "npx aibill --group-by project",
589
+ description: "see which project has the most observed activity"
590
+ });
628
591
  steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
629
592
  if (mode === "demo") {
630
593
  // Static pointer only — sample output is built for recordings and
@@ -823,6 +786,86 @@ async function loadInstantReadData(args) {
823
786
  : false
824
787
  };
825
788
  }
789
+ /**
790
+ * Build the one {@link BroadScanEvidence} bundle. Called by the receipt /
791
+ * `--full` readout AND by the machine-wide `report`; nothing else may
792
+ * re-derive these facts.
793
+ *
794
+ * Everything here was previously inline in the receipt path. The machine-wide
795
+ * report used to call {@link loadInstantReadData} directly and keep only
796
+ * `records`, which is exactly how its ACT and VERIFY sections lost the
797
+ * transcript index and degraded to internal-jargon "unknown" copy while the
798
+ * readout from the same directory showed real, ranked candidates.
799
+ */
800
+ async function loadBroadScanEvidence(args, sinceDays) {
801
+ const instant = await loadInstantReadData(args);
802
+ const { records, mode, warnings, providerCoverage } = instant;
803
+ const summaryRecords = mode === "connected"
804
+ ? selectProviderFinancialHeadlineRecords(records)
805
+ : records;
806
+ // Connected receipts stay billed-primary but never ERASE the estimated
807
+ // axis: local transcript records ride along so subscription rows keep
808
+ // their ~ API-equivalent figures next to billed money (C-lane §1.4).
809
+ const analysisRecords = mode === "connected" && (instant.localFinancialRecords?.length ?? 0) > 0
810
+ ? [...summaryRecords, ...instant.localFinancialRecords]
811
+ : summaryRecords;
812
+ // Persona: --plan override wins; otherwise read the plans the coding agents
813
+ // themselves persisted locally (read-only, whitelisted fields, no network).
814
+ // An explicit sample run must be deterministic and safe to record/share:
815
+ // never mix the developer's real local plan into illustrative output.
816
+ const planOverride = args.plan ? planOverrideFromFlag(args.plan) : undefined;
817
+ const detectedPlans = args.sample
818
+ ? []
819
+ : planOverride
820
+ ? [planOverride]
821
+ : await detectLocalPlans({
822
+ // Env overrides keep tests (and unusual installs) isolated from $HOME.
823
+ claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
824
+ codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
825
+ }).catch(() => []);
826
+ // Dead-context cost, globalized across the user's whole Claude Code setup
827
+ // (all projects' MCP + user-scope skills/agents/commands, vs. every
828
+ // transcript) so it's populated from ANY directory on the first run.
829
+ // Never throws into the readout.
830
+ let deadContext = args.sample
831
+ ? undefined
832
+ : await loadDeadContext({
833
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
834
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
835
+ claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
836
+ codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
837
+ claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
838
+ claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
839
+ projectDir: resolve(args.path),
840
+ includeAllProjectMcp: true,
841
+ sinceIso: sinceIsoForDays(sinceDays),
842
+ windowDays: sinceDays,
843
+ codexInvocationFiles: instant.codexInvocationFiles
844
+ }).catch(() => undefined);
845
+ // Sample dead-context is shown ONLY on the demo readout. A real readout
846
+ // (local logs / connected billing) never gets fabricated waste injected —
847
+ // a genuinely clean setup earns its congratulation line instead.
848
+ if (mode === "demo" && (!deadContext || !deadContext.hasData)) {
849
+ deadContext = sampleDeadContext();
850
+ }
851
+ return {
852
+ records,
853
+ mode,
854
+ warnings,
855
+ ...(providerCoverage ? { providerCoverage } : {}),
856
+ summaryRecords,
857
+ analysisRecords,
858
+ summary: analyzeSpend(summaryRecords),
859
+ // THE parity anchor: derived once, handed to every surface.
860
+ actionCandidates: generateCutList(analysisRecords),
861
+ detectedPlans,
862
+ ...(deadContext ? { deadContext } : {}),
863
+ ...(instant.actionEvidence ? { actionEvidence: instant.actionEvidence } : {}),
864
+ ...(instant.codexInvocationFiles ? { codexInvocationFiles: instant.codexInvocationFiles } : {}),
865
+ ...(instant.localFinancialRecords ? { localFinancialRecords: instant.localFinancialRecords } : {}),
866
+ financialCoverageComplete: instant.financialCoverageComplete === true
867
+ };
868
+ }
826
869
  function localFinancialEvidenceComplete(result) {
827
870
  const scanCoverageComplete = result.sourceScans.every((scan) => (scan.directoryStatus !== "unreadable" &&
828
871
  scan.unreadableFiles === 0 &&
@@ -850,9 +893,23 @@ async function loadBoundedLocalActionEvidence(sinceIso) {
850
893
  * qualitative index converges, printing honest per-pass progress.
851
894
  */
852
895
  async function indexEvidenceCommand(args, runtime) {
853
- const rootGuard = await guardExactProjectRoot("index", args.path);
854
- if (rootGuard)
855
- return rootGuard;
896
+ // 0.9.6, same fix and same reason as `report` (0.9.4): the report prints
897
+ // `npx aibill index` as its prescribed next step 13 times, and from a broad
898
+ // root — the home folder the machine-wide report is normally written in —
899
+ // the exact-project guard made that pointer exit 1. The report's own next
900
+ // step refused from the directory that wrote the report.
901
+ //
902
+ // The guard was never load-bearing here. `index` reads the env-scoped GLOBAL
903
+ // transcript directories (AI_SPEND_CLAUDE_LOGS_DIR / AI_SPEND_CODEX_LOGS_DIR,
904
+ // defaulting to the agents' own home-level locations) and writes the
905
+ // home-level private cache under ~/.aibill. `args.path` scopes nothing it
906
+ // reads and nothing it writes, so a broad root scans no more than a project
907
+ // root does. Only a bogus --path still gets the friendly guard.
908
+ if (!isBroadScanRoot(args.path)) {
909
+ const rootGuard = await guardExactProjectRoot("index", args.path);
910
+ if (rootGuard)
911
+ return rootGuard;
912
+ }
856
913
  const sinceDays = args.sinceDays ?? 30;
857
914
  if (!validSinceDays(sinceDays))
858
915
  return invalidSinceDaysResult();
@@ -897,7 +954,7 @@ async function indexEvidenceCommand(args, runtime) {
897
954
  `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
898
955
  `${formatBytes(totalBytes)} streamed across ${passes} pass(es)`
899
956
  ],
900
- next: { reason: "your evidence is fully indexed — run the token test", command: improveRuntimeCommand }
957
+ next: { reason: "your evidence is fully indexed — run the token test", command: projectScopedPointer(improveRuntimeCommand, args.path) }
901
958
  }));
902
959
  }
903
960
  if (passBytes === 0 && stillConverging === 0) {
@@ -909,7 +966,7 @@ async function indexEvidenceCommand(args, runtime) {
909
966
  `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
910
967
  `${coverage.skippedForBudget} file(s) remain outside the read budget`
911
968
  ],
912
- next: { reason: "run the token test on the evidence that is indexed", command: improveRuntimeCommand }
969
+ next: { reason: "run the token test on the evidence that is indexed", command: projectScopedPointer(improveRuntimeCommand, args.path) }
913
970
  }));
914
971
  }
915
972
  }
@@ -950,10 +1007,28 @@ function summarizeCliQualitativeCoverageByAgent(logs) {
950
1007
  ];
951
1008
  }));
952
1009
  }
1010
+ /**
1011
+ * The report summary's coverage row, in the user-facing voice (0.9.6). It read
1012
+ * "suppressed · qualitative index partial" — a status nobody outside this
1013
+ * codebase can act on, and a word ("suppressed") that was not even accurate
1014
+ * for the ranked candidates, which are financial-derived and never withheld.
1015
+ *
1016
+ * Names the ACTUAL gap: unread transcripts and budget-skipped files are
1017
+ * different problems with different fixes, and a bare "4 of 4 read" hid the
1018
+ * second one entirely.
1019
+ */
1020
+ function reportQualitativeRowValue(coverage) {
1021
+ const read = coverage?.readCompletely ?? 0;
1022
+ const selected = coverage?.selectedFiles ?? 0;
1023
+ const skipped = coverage?.skippedForBudget ?? 0;
1024
+ return `not drafted · ${read} of ${selected} session transcripts read` +
1025
+ (skipped > 0 ? ` · ${skipped} ${skipped === 1 ? "file" : "files"} skipped by budget` : "");
1026
+ }
953
1027
  function renderCliQualitativeCoverage(coverage) {
954
- return `QUALITATIVE INDEX ${coverage.status.toUpperCase()} · ` +
955
- `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
956
- `${coverage.skippedForBudget} eligible files skipped by budget`;
1028
+ return `SESSION TRANSCRIPTS ${coverage.readCompletely} of ${coverage.selectedFiles} read completely` +
1029
+ (coverage.skippedForBudget > 0
1030
+ ? ` · ${coverage.skippedForBudget} ${coverage.skippedForBudget === 1 ? "file was" : "files were"} skipped by budget`
1031
+ : "");
957
1032
  }
958
1033
  /**
959
1034
  * `aibill signup <email> [--ref <token>]` — the explicit, deliberate path to
@@ -1171,7 +1246,19 @@ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
1171
1246
  ...warnings.map((warning) => `! ${warning}`),
1172
1247
  "",
1173
1248
  "Next",
1174
- " npx aibill doctor --sources see the exact evidence gap and setup paths",
1249
+ // 0.9.6: this screen used to offer exactly two next steps — a
1250
+ // diagnostic and an email signup — to a visitor who had just been
1251
+ // told there is nothing to show. The only occurrence of the word
1252
+ // "sample" on it was "No sample data was substituted." `--sample`
1253
+ // works, needs no evidence and no email, and is the one thing that
1254
+ // shows a first-time visitor what the tool actually produces, so it
1255
+ // goes FIRST.
1256
+ " npx aibill --sample see a full worked example on bundled demo data",
1257
+ // Honest label (0.9.6): this prints a per-source coverage matrix —
1258
+ // which locations were checked and what each returned. It does not
1259
+ // print install or setup instructions, and promising "setup paths"
1260
+ // sent people looking for something that is not there.
1261
+ " npx aibill doctor --sources see which source locations were checked, and what each returned",
1175
1262
  ` ${signupCopy.receiptPointer}`
1176
1263
  ].join("\n")
1177
1264
  : "",
@@ -1834,7 +1921,7 @@ async function statuslineCommand(args, runtime) {
1834
1921
  if (action === "refresh")
1835
1922
  return refreshStatuslineCommand(args, runtime);
1836
1923
  if (action === "expand")
1837
- return expandStatuslineCommand(runtime);
1924
+ return expandStatuslineCommand(args, runtime);
1838
1925
  return {
1839
1926
  exitCode: 1,
1840
1927
  stdout: "",
@@ -2126,6 +2213,19 @@ async function refreshStatuslineCommand(args, runtime) {
2126
2213
  }
2127
2214
  detectedPlanOverride = [override];
2128
2215
  }
2216
+ // `statusline refresh` scans an approved workspace root, so a broad root is
2217
+ // a genuine refusal — but it must SOUND like one (0.9.6). It used to let
2218
+ // resolveSafeScanRoot throw, which the crash wrapper rendered as "aibill hit
2219
+ // an unexpected error: Refusing to scan …" plus "run diagnostics before
2220
+ // retrying" — the tool reporting its own deliberate safety check as a bug.
2221
+ // `statusline refresh` scans an approved workspace root, so a broad root is
2222
+ // a genuine refusal — but it must SOUND like one (0.9.6). It used to let
2223
+ // resolveSafeScanRoot throw, which the crash wrapper rendered as "aibill hit
2224
+ // an unexpected error: Refusing to scan …" plus "run diagnostics before
2225
+ // retrying" — the tool reporting its own deliberate safety check as a bug.
2226
+ const refreshGuard = await guardExactProjectRoot("statusline refresh", args.path);
2227
+ if (refreshGuard)
2228
+ return refreshGuard;
2129
2229
  const rootPath = await resolveSafeScanRoot(args.path);
2130
2230
  const cacheDirectory = process.env.AIBILL_CACHE_DIR;
2131
2231
  await preflightInitCache(cacheDirectory);
@@ -2193,7 +2293,7 @@ async function refreshStatuslineCommand(args, runtime) {
2193
2293
  * snapshot; `runways[]` carries both limits), never bypassing the contract.
2194
2294
  * Printed command output, not a hook line.
2195
2295
  */
2196
- async function expandStatuslineCommand(runtime) {
2296
+ async function expandStatuslineCommand(args, runtime) {
2197
2297
  const cache = await readStatuslineCache({
2198
2298
  cacheDirectory: process.env.AIBILL_CACHE_DIR,
2199
2299
  homeDirectory: runtime.homeDirectory
@@ -2203,8 +2303,8 @@ async function expandStatuslineCommand(runtime) {
2203
2303
  exitCode: 1,
2204
2304
  stdout: "",
2205
2305
  stderr: cache.status === "missing"
2206
- ? "aibill statusline expand needs a refreshed private cache: run `npx aibill statusline refresh` first."
2207
- : "aibill statusline expand could not read the private cache safely; run `npx aibill statusline refresh`."
2306
+ ? `aibill statusline expand needs a refreshed private cache: run \`${projectScopedPointer(actionRuntimeCommand("statusline refresh"), args.path)}\` first.`
2307
+ : `aibill statusline expand could not read the private cache safely; run \`${projectScopedPointer(actionRuntimeCommand("statusline refresh"), args.path)}\`.`
2208
2308
  };
2209
2309
  }
2210
2310
  const now = runtime.statuslineNow ?? new Date();
@@ -2213,7 +2313,7 @@ async function expandStatuslineCommand(runtime) {
2213
2313
  return ok([
2214
2314
  "aibill subscriptions · 30d window",
2215
2315
  " no detected subscription evidence in the private cache",
2216
- " run `npx aibill statusline refresh` after using Claude Code or Codex"
2316
+ ` run \`${projectScopedPointer(actionRuntimeCommand("statusline refresh"), args.path)}\` after using Claude Code or Codex`
2217
2317
  ].join("\n"));
2218
2318
  }
2219
2319
  const card = statuslineSnapshotToResultCard(snapshot, now);
@@ -3769,9 +3869,16 @@ async function reportCommand(args, runtime = {}) {
3769
3869
  // exact-project requirement was incoherent here: the receipt's own Next
3770
3870
  // pointer led from home straight into a refusal. Only a bogus --path
3771
3871
  // still gets the friendly guard; project folders behave exactly as
3772
- // before.
3872
+ // before. 0.9.5: broad roots that cannot HOLD the artifacts (/, /etc,
3873
+ // /Users, …) get the friendly guard voice up front instead of dying at
3874
+ // write time with a wrapped raw error.
3773
3875
  const machineWide = isBroadScanRoot(args.path);
3774
- if (!machineWide) {
3876
+ if (machineWide) {
3877
+ const broadGuard = guardUnwritableBroadRoot("report", args);
3878
+ if (broadGuard)
3879
+ return broadGuard;
3880
+ }
3881
+ else {
3775
3882
  const rootGuard = await guardExactProjectRoot("report", args.path);
3776
3883
  if (rootGuard)
3777
3884
  return rootGuard;
@@ -3864,67 +3971,111 @@ async function reportCommand(args, runtime = {}) {
3864
3971
  const artifactPaths = reportableExperiment || machineWide
3865
3972
  ? undefined
3866
3973
  : await writeApplyArtifacts(stateDir, reportInput);
3867
- return ok([
3868
- "aibill report",
3974
+ // 0.9.5 "agent feel": the HTML report opens itself in the browser via
3975
+ // the platform opener — decided truthfully BEFORE the summary renders,
3976
+ // suppressed for non-TTY/CI/SSH/--no-open/AI_SPEND_NO_OPEN, and fired
3977
+ // detached so a missing or slow opener can never crash, hang, or delay
3978
+ // exit (the telemetry detached-child pattern).
3979
+ const openDecision = (runtime.reportOpenDecide ?? decideReportAutoOpen)({
3980
+ htmlPath,
3981
+ noOpenFlag: args.noOpen === true
3982
+ });
3983
+ const openedInBrowser = (runtime.reportOpenLaunch ?? openReportInBrowser)(openDecision);
3984
+ // 0.9.5 founder polish ("really hard to read… I wonder if we can have
3985
+ // the text aligned"): the same facts, rendered in the receipt's visual
3986
+ // language — header, one shared label column, dot separators, and a Next
3987
+ // block whose commands pad to one description column. Display-only.
3988
+ const rows = [
3869
3989
  machineWide
3870
- ? `scope: machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}`
3871
- : `path: ${rootPath}`,
3872
- `markdown: ${markdownPath}`,
3873
- `html: ${htmlPath}`,
3990
+ ? { label: "Scope", value: `machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}` }
3991
+ : { label: "Path", value: rootPath },
3992
+ { label: "Markdown", value: markdownPath },
3993
+ { label: "HTML", value: htmlPath },
3874
3994
  ...(machineWide
3875
3995
  ? []
3876
3996
  : reportableExperiment
3877
3997
  ? [
3878
- `action artifacts: preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})`,
3879
- `token result: status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}`,
3880
- `token test: ${improveRuntimeCommand}`
3998
+ { label: "Action artifacts", value: `preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})` },
3999
+ { label: "Token result", value: `status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}` },
4000
+ { label: "Token test", value: improveRuntimeCommand }
3881
4001
  ]
3882
4002
  : qualitativeActionsSuppressed
3883
4003
  ? [
3884
- `action artifacts: suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}`,
3885
- `coverage artifact: ${artifactPaths.codingPrompt}`,
3886
- `coverage action plan: ${artifactPaths.actionPlan}`,
3887
- `coverage policy/config: ${artifactPaths.policyConfigDraft}`,
3888
- `coverage verification: ${artifactPaths.verificationPlan}`,
3889
- `coverage package: ${artifactPaths.demoPackage}`
4004
+ { label: "Action artifacts", value: reportQualitativeRowValue(reportInput.qualitativeCoverage) },
4005
+ { label: "Coverage artifact", value: artifactPaths.codingPrompt },
4006
+ { label: "Coverage action plan", value: artifactPaths.actionPlan },
4007
+ { label: "Coverage policy/config", value: artifactPaths.policyConfigDraft },
4008
+ { label: "Coverage verification", value: artifactPaths.verificationPlan },
4009
+ { label: "Coverage package", value: artifactPaths.demoPackage }
3890
4010
  ]
3891
4011
  : artifactPaths
3892
4012
  ? [
3893
- `apply artifact: ${artifactPaths.codingPrompt}`,
3894
- `action plan: ${artifactPaths.actionPlan}`,
3895
- `policy/config draft: ${artifactPaths.policyConfigDraft}`,
3896
- `verification plan: ${artifactPaths.verificationPlan}`,
3897
- `demo package: ${artifactPaths.demoPackage}`
4013
+ { label: "Apply artifact", value: artifactPaths.codingPrompt },
4014
+ { label: "Action plan", value: artifactPaths.actionPlan },
4015
+ { label: "Policy/config draft", value: artifactPaths.policyConfigDraft },
4016
+ { label: "Verification plan", value: artifactPaths.verificationPlan },
4017
+ { label: "Demo package", value: artifactPaths.demoPackage }
3898
4018
  ]
3899
4019
  : []),
3900
- reportInput.dataMode === "sample"
3901
- ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
3902
- : reportInput.dataMode === "connected_provider" &&
3903
- !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
3904
- ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
3905
- : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
3906
- runtime.telemetryDisclosure === true
3907
- ? `privacy: report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
3908
- : "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3909
- "",
3910
- "next:",
3911
- ` open ${htmlPath} view the full report in your browser`,
3912
- ` less ${markdownPath} read it in the terminal`,
4020
+ {
4021
+ label: "Total",
4022
+ value: reportInput.dataMode === "sample"
4023
+ ? `${formatOptionalUsd(reportInput.summary.totalUsd)} · DEMO SAMPLE · illustrative cost/value evidence · not user data`
4024
+ : reportInput.dataMode === "connected_provider" &&
4025
+ !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
4026
+ ? "Unavailable · cost/value evidence · no priced financial evidence; missing/null is not zero"
4027
+ : `${formatOptionalUsd(reportInput.summary.totalUsd)} · cost/value evidence`
4028
+ },
4029
+ {
4030
+ label: "Privacy",
4031
+ value: runtime.telemetryDisclosure === true
4032
+ ? `report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
4033
+ : "report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider"
4034
+ }
4035
+ ];
4036
+ const nextSteps = [
4037
+ // Summary-line truth: only a fired opener may claim it opened; every
4038
+ // suppression path keeps the plain copy-pasteable pointer.
4039
+ // 0.9.6: the pointer must survive a NAIVE PARTIAL READ. The founder saw
4040
+ // `› open <the full absolute path>` with its description on the
4041
+ // next line, read "open" as a label rather than the command, typed bare
4042
+ // `open`, and got macOS's usage dump — "i don't know what im looking
4043
+ // at." shellPathPointer names an artifact in the current directory
4044
+ // relatively (one short unit) and quotes anything absolute so the
4045
+ // command and its argument read — and paste — as one thing.
4046
+ openedInBrowser
4047
+ ? { command: `opened ${basename(htmlPath)} in your browser · next time: --no-open to skip` }
4048
+ : {
4049
+ command: shellPathPointer(platformOpenCommand(), htmlPath, process.cwd()),
4050
+ description: `view in your browser — or double-click ${basename(htmlPath)} in your file manager`
4051
+ },
4052
+ {
4053
+ command: shellPathPointer("less", markdownPath, process.cwd()),
4054
+ description: "read it in the terminal"
4055
+ },
3913
4056
  machineWide
3914
4057
  // apply/improve need one exact project folder — a machine-wide
3915
4058
  // report must never point at a command that then refuses (the exact
3916
4059
  // trap this mode removes).
3917
4060
  ? reportInput.dataMode === "sample"
3918
- ? ` cd <project> && ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary from one exact project folder`
3919
- : ` cd <project> && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} per-project action plan from one exact project folder`
4061
+ ? { command: `cd /path/to/project && ${actionRuntimeCommand("apply --sample")}`, description: "print the non-executable demo boundary from one exact project folder" }
4062
+ : { command: `cd /path/to/project && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)}`, description: "per-project action plan from one exact project folder" }
3920
4063
  : reportableExperiment
3921
- ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
4064
+ ? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
3922
4065
  : qualitativeActionsSuppressed
3923
- ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
4066
+ ? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "inspect the transcript evidence read so far" }
3924
4067
  : reportInput.dataMode === "sample"
3925
- ? ` ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary`
3926
- : ` ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} print the paste-ready coding-agent prompt from this exact evidence window`
3927
- ].join("\n"));
4068
+ ? { command: actionRuntimeCommand("apply --sample"), description: "print the non-executable demo boundary" }
4069
+ : { command: actionRuntimeCommand(`apply --since-days ${sinceDays}`), description: "print the paste-ready coding-agent prompt from this exact evidence window" }
4070
+ ];
4071
+ return ok(generateCommandSummary({
4072
+ title: "aibill report",
4073
+ note: "a shareable Markdown + HTML report, written locally",
4074
+ rows,
4075
+ nextSteps,
4076
+ color: args.noColor ? false : undefined,
4077
+ width: terminalOutputWidth()
4078
+ }));
3928
4079
  }
3929
4080
  catch (error) {
3930
4081
  return {
@@ -3948,14 +4099,21 @@ async function resolveReceiptPath(rootPath, out) {
3948
4099
  return join(resolved, "ai-spend-receipt.svg");
3949
4100
  return extname(resolved) ? resolved : `${resolved}.svg`;
3950
4101
  }
3951
- async function reportCardCommand(args) {
4102
+ async function reportCardCommand(args, runtime = {}) {
3952
4103
  // 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
3953
4104
  // scanning to the bare receipt (loadInstantReadData below), SVG written to
3954
4105
  // the current directory. The card renders machine-wide content anyway, so
3955
4106
  // an exact-project requirement was incoherent here; only a bogus --path
3956
- // still gets the friendly guard.
4107
+ // still gets the friendly guard. 0.9.5: broad roots that cannot HOLD the
4108
+ // receipt (/, /etc, /Users, …) get the friendly guard voice up front
4109
+ // instead of dying at write time with a wrapped raw error.
3957
4110
  const machineWide = isBroadScanRoot(args.path);
3958
- if (!args.sample && !machineWide) {
4111
+ if (machineWide) {
4112
+ const broadGuard = guardUnwritableBroadRoot("report-card", args);
4113
+ if (broadGuard)
4114
+ return broadGuard;
4115
+ }
4116
+ else if (!args.sample) {
3959
4117
  const rootGuard = await guardExactProjectRoot("report-card", args.path);
3960
4118
  if (rootGuard)
3961
4119
  return rootGuard;
@@ -3978,34 +4136,80 @@ async function reportCardCommand(args) {
3978
4136
  const summary = analyzeSpend(headlineRecords);
3979
4137
  const outPath = await resolveReceiptPath(rootPath, args.out);
3980
4138
  await mkdir(dirname(outPath), { recursive: true });
3981
- await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({
4139
+ const svg = generateReportCardSvg({
3982
4140
  summary,
3983
4141
  records: headlineRecords,
3984
4142
  mode,
3985
4143
  ...(providerCoverage ? { providerCoverage } : {})
3986
- }));
3987
- const dataLine = mode === "demo"
4144
+ });
4145
+ await writeSafeStateText(dirname(outPath), basename(outPath), svg);
4146
+ const caption = generateReportCardCaption({
4147
+ summary,
4148
+ records: headlineRecords,
4149
+ mode,
4150
+ ...(providerCoverage ? { providerCoverage } : {})
4151
+ });
4152
+ // 0.9.6: the receipt now SHOWS itself. 0.9.5 wrote the SVG and stopped,
4153
+ // leaving the user to go find and open it by hand ("not automatically
4154
+ // showing on a html or opening the file: making it inefficient").
4155
+ //
4156
+ // We auto-open a companion .html rather than the .svg itself: platform
4157
+ // openers hand a .svg to whatever claims that extension, which on a
4158
+ // developer machine is frequently an editor, so "show me my receipt"
4159
+ // could open a wall of XML. An .html is claimed by a browser everywhere.
4160
+ // The .svg remains the canonical shareable artifact; the companion just
4161
+ // renders it next to the caption, embedding both verbatim so it inherits
4162
+ // the card's redaction guarantees exactly.
4163
+ const companionPath = `${outPath.replace(/\.svg$/iu, "")}.html`;
4164
+ await writeSafeStateText(dirname(companionPath), basename(companionPath), generateReceiptCompanionHtml({ svg, caption }));
4165
+ // Same decision function, same suppression matrix, same metacharacter
4166
+ // refusal, same detached launch as `report` — one opener, two commands.
4167
+ const openDecision = (runtime.reportOpenDecide ?? decideReportAutoOpen)({
4168
+ htmlPath: companionPath,
4169
+ noOpenFlag: args.noOpen === true
4170
+ });
4171
+ const openedInBrowser = (runtime.reportOpenLaunch ?? openReportInBrowser)(openDecision);
4172
+ const dataRow = mode === "demo"
3988
4173
  ? args.sample
3989
- ? "data: DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read."
3990
- : "data: DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly."
4174
+ ? "DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read"
4175
+ : "DEMO sample data — no supported local Claude Code/Codex evidence was found; use --sample to reproduce this demo explicitly"
3991
4176
  : mode === "local-logs"
3992
- ? "data: local Claude Code/Codex logs priced at API-equivalent rates."
3993
- : "data: connected local spend state with provider-reported cost kept separate from API-equivalent estimates.";
3994
- return ok([
3995
- "Your AI Receipt a shareable, redacted spend card (no client/project/user names).",
3996
- `receipt: ${outPath}`,
3997
- dataLine,
3998
- "",
3999
- "Caption to share:",
4000
- generateReportCardCaption({
4001
- summary,
4002
- records: headlineRecords,
4003
- mode,
4004
- ...(providerCoverage ? { providerCoverage } : {})
4005
- }),
4006
- "",
4007
- "privacy: rendered locally; only totals, generic candidate categories, and evidence labels are included."
4008
- ].join("\n"));
4177
+ ? "local Claude Code/Codex logs priced at API-equivalent rates"
4178
+ : "connected local spend state with provider-reported cost kept separate from API-equivalent estimates";
4179
+ // 0.9.5 founder polish: same facts, receipt-language layout — header,
4180
+ // one label column, and the caption set off as its own block.
4181
+ return ok(generateCommandSummary({
4182
+ title: "aibill report-card",
4183
+ badge: "Your AI Receipt",
4184
+ note: "a shareable, redacted spend card (no client/project/user names)",
4185
+ rows: [
4186
+ { label: "Receipt", value: outPath },
4187
+ { label: "Preview", value: companionPath },
4188
+ { label: "Data", value: dataRow },
4189
+ { label: "Privacy", value: "rendered locally; only totals, generic candidate categories, and evidence labels are included" }
4190
+ ],
4191
+ sections: [{
4192
+ heading: "Caption to share",
4193
+ body: [caption]
4194
+ }],
4195
+ nextSteps: [
4196
+ // Summary-line truth: only a fired opener may claim it opened; every
4197
+ // suppression path keeps the plain copy-pasteable pointer.
4198
+ openedInBrowser
4199
+ ? { command: `opened ${basename(companionPath)} in your browser · next time: --no-open to skip` }
4200
+ : {
4201
+ command: shellPathPointer(platformOpenCommand(), companionPath, process.cwd()),
4202
+ description: `view the receipt — or double-click ${basename(companionPath)} in your file manager`
4203
+ },
4204
+ // NOT a command, so it must not LOOK like one: `post ai-receipt.svg`
4205
+ // sitting under "Next" beside two real commands is exactly the trap
4206
+ // that made the founder type a bare `open`. Description-less lines
4207
+ // render verbatim, so this one states a fact instead.
4208
+ { command: `${basename(outPath)} is the file to share — post that one` }
4209
+ ],
4210
+ color: args.noColor ? false : undefined,
4211
+ width: terminalOutputWidth()
4212
+ }));
4009
4213
  }
4010
4214
  catch (error) {
4011
4215
  return {
@@ -4032,12 +4236,76 @@ async function reportCardCommand(args) {
4032
4236
  * (improve, apply, verify, watch, connect, reset, …) keep the guard.
4033
4237
  */
4034
4238
  function isBroadScanRoot(requestedPath) {
4239
+ return broadScanRootKind(requestedPath) !== undefined;
4240
+ }
4241
+ /**
4242
+ * A project-scoped command, printed so it WORKS from where it was printed.
4243
+ *
4244
+ * The founder's most-repeated complaint class (0.9.4 report, 0.9.6 index, and
4245
+ * these): a surface running machine-wide prints a bare project-scoped command,
4246
+ * the user copies it, and it friendly-refuses in the very directory that
4247
+ * printed it. Commands that genuinely need one project carry their own `cd`
4248
+ * from a broad root — a PATH placeholder, never `<project>`, which the shell
4249
+ * would read as redirection.
4250
+ */
4251
+ function projectScopedPointer(command, requestedPath) {
4252
+ return isBroadScanRoot(requestedPath) ? `cd /path/to/project && ${command}` : command;
4253
+ }
4254
+ /** Classifies WHICH broad-root category a machine-wide path falls in. */
4255
+ function broadScanRootKind(requestedPath) {
4035
4256
  const rootPath = resolve(requestedPath);
4036
4257
  const home = homedir();
4037
4258
  const guardHome = home && home.trim().length > 0
4038
4259
  ? home
4039
4260
  : join(rootPath, "aibill-impossible-home-sentinel");
4040
- return unsafeScanRootReason(rootPath, guardHome) !== undefined;
4261
+ const reason = unsafeScanRootReason(rootPath, guardHome);
4262
+ if (reason === undefined)
4263
+ return undefined;
4264
+ if (reason.includes("filesystem root"))
4265
+ return "filesystem-root";
4266
+ if (reason.includes("home directory is too broad"))
4267
+ return "home";
4268
+ if (reason.includes("contains your home directory"))
4269
+ return "contains-home";
4270
+ return "system-directory";
4271
+ }
4272
+ /**
4273
+ * 0.9.5: machine-wide report/report-card write their artifacts INTO the
4274
+ * requested root. That works from the home directory, but /, /etc, or a
4275
+ * folder that contains home cannot hold them — the run used to die at write
4276
+ * time with a wrapped raw error ("Couldn't build a report: EROFS…",
4277
+ * "Refusing to use /etc…"). Those roots get the friendly guard voice BEFORE
4278
+ * anything is scanned or written. Returns undefined when the root is home
4279
+ * itself (machine-wide proceeds) or when an explicit absolute --out points
4280
+ * the artifacts somewhere else entirely.
4281
+ */
4282
+ function guardUnwritableBroadRoot(commandName, args) {
4283
+ const kind = broadScanRootKind(args.path);
4284
+ if (kind === undefined || kind === "home")
4285
+ return undefined;
4286
+ // An absolute --out lands outside the broad root; only rootPath-relative
4287
+ // artifacts make this location a write problem.
4288
+ if (args.out !== undefined && isAbsolute(args.out))
4289
+ return undefined;
4290
+ const artifactNoun = commandName === "report" ? "its report files" : "the receipt";
4291
+ const explanation = kind === "filesystem-root"
4292
+ ? `You pointed it at the filesystem root, which can't hold ${artifactNoun}.`
4293
+ : kind === "contains-home"
4294
+ ? `You pointed it at a folder that contains your home directory, which can't hold ${artifactNoun}.`
4295
+ : `You pointed it at a system directory, which can't hold ${artifactNoun}.`;
4296
+ return {
4297
+ exitCode: 1,
4298
+ stdout: "",
4299
+ stderr: [
4300
+ `aibill ${commandName} writes ${artifactNoun} into the folder it points at.`,
4301
+ explanation,
4302
+ "",
4303
+ "Run it from your home directory for a machine-wide view, or from one exact project folder",
4304
+ ` e.g. cd ~ && ${actionRuntimeCommand(commandName)}`,
4305
+ "",
4306
+ "Nothing was read, created, or changed."
4307
+ ].join("\n")
4308
+ };
4041
4309
  }
4042
4310
  async function guardExactProjectRoot(commandName, requestedPath) {
4043
4311
  const rootPath = resolve(requestedPath);
@@ -5802,7 +6070,7 @@ async function tokenVerificationCommand(args) {
5802
6070
  }
5803
6071
  const observation = await loadTokenVerificationObservation(rootPath, experiment);
5804
6072
  if (!observation.qualitativeCoverageComplete) {
5805
- return tokenVerificationUsageError("the bounded qualitative index is incomplete for this experiment's agent; no post-change sessions or reduction percentage were accepted");
6073
+ return tokenVerificationUsageError("this experiment's agent still has unread session transcripts; no post-change sessions or reduction percentage were accepted. Run `npx aibill index` to finish reading them, then verify again");
5806
6074
  }
5807
6075
  const unlabelled = refreshTokenReductionExperimentV0(experiment, {
5808
6076
  sessionVitals: observation.sessionVitals,
@@ -5956,36 +6224,50 @@ function applyEvidenceAcquisitionLines(input) {
5956
6224
  return lines;
5957
6225
  }
5958
6226
  /**
5959
- * Machine-wide report input (0.9.4): the bare receipt's own data path —
5960
- * loadInstantReadData over the agent transcript dirs (read-only) — rendered
5961
- * through the report package. No project state is read or created; plan
5962
- * detection is home-scoped metadata, exactly as the receipt reads it.
6227
+ * Machine-wide report input built from {@link loadBroadScanEvidence}, the
6228
+ * SAME call the `--full` readout makes from the same directory (0.9.6).
6229
+ *
6230
+ * 0.9.4 shipped this as a parallel thin builder over `loadInstantReadData`,
6231
+ * keeping only the financial records. Everything that gates an action claim —
6232
+ * the bounded transcript index above all — was dropped, so the report package
6233
+ * fell back to `qualitativeCoverage: "unknown"` and every ACT/VERIFY branch
6234
+ * degraded, while `--full` from the same home showed real ranked candidates.
6235
+ * The evidence is not project-scoped and never was; only the builder was
6236
+ * withholding it.
6237
+ *
6238
+ * No project state is read or created. Everything below comes from the shared
6239
+ * bundle; nothing is re-derived here.
5963
6240
  */
5964
6241
  async function buildMachineWideReportInput(args, sinceDays) {
5965
- const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
5966
- if (records.length === 0) {
6242
+ const evidence = await loadBroadScanEvidence(args, sinceDays);
6243
+ if (evidence.records.length === 0) {
5967
6244
  // Same honest empty-state voice the receipt/report-card use — an empty
5968
6245
  // report file would just look broken.
5969
- return { kind: "no_evidence", warnings };
6246
+ return { kind: "no_evidence", warnings: evidence.warnings };
5970
6247
  }
5971
- const detectedPlans = await detectLocalPlans({
5972
- claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
5973
- codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
5974
- }).catch(() => []);
5975
- const headlineRecords = mode === "connected"
5976
- ? selectProviderFinancialHeadlineRecords(records)
5977
- : records;
6248
+ const { records, mode, actionEvidence } = evidence;
5978
6249
  return {
5979
6250
  kind: "input",
5980
6251
  input: {
5981
6252
  generatedAt: new Date().toISOString(),
5982
- summary: analyzeSpend(headlineRecords),
6253
+ summary: evidence.summary,
5983
6254
  allRecords: records,
5984
6255
  dataMode: mode === "connected" ? "connected_provider" : "local_logs",
5985
6256
  evidenceWindowDays: sinceDays,
5986
- detectedPlans,
6257
+ detectedPlans: evidence.detectedPlans,
6258
+ // The ranked set the readout prints, verbatim — not a second derivation.
6259
+ actionCandidates: evidence.actionCandidates,
6260
+ analysisScope: "machine-wide",
6261
+ // The bounded transcript index the 0.9.4 builder dropped. This is what
6262
+ // un-degrades ACT, VERIFY, plan context, and the configuration section.
6263
+ qualitativeCoverage: summarizeCliQualitativeCoverage(actionEvidence),
6264
+ qualitativeCoverageByAgent: summarizeCliQualitativeCoverageByAgent(actionEvidence),
6265
+ ...(evidence.deadContext ? { deadContext: evidence.deadContext } : {}),
5987
6266
  ...(mode === "connected" ? { providerRecords: records } : {}),
5988
- ...(providerCoverage ? { providerCoverage } : {})
6267
+ ...(mode === "connected" && (evidence.localFinancialRecords?.length ?? 0) > 0
6268
+ ? { localFinancialRecords: evidence.localFinancialRecords }
6269
+ : {}),
6270
+ ...(evidence.providerCoverage ? { providerCoverage: evidence.providerCoverage } : {})
5989
6271
  }
5990
6272
  };
5991
6273
  }
@@ -6418,6 +6700,10 @@ function parseArgs(argv) {
6418
6700
  parsed.noColor = true;
6419
6701
  continue;
6420
6702
  }
6703
+ if (arg === "--no-open") {
6704
+ parsed.noOpen = true;
6705
+ continue;
6706
+ }
6421
6707
  if (arg === "--json") {
6422
6708
  parsed.json = true;
6423
6709
  continue;
@@ -7032,8 +7318,13 @@ function helpText(telemetryDisclosure) {
7032
7318
  " quickstart [--sample] [--since-days N] Plain-English local readout (default 30 days)",
7033
7319
  " [--full] Render the complete audit; default is the compact receipt",
7034
7320
  " [--group-by source|model|client|project|agent|user|workspace|apiKey] Default: project for local logs; model otherwise",
7035
- " report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports from the same window",
7321
+ " report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports and open the HTML in your browser",
7322
+ " [--no-open] Skip the automatic browser open (also AI_SPEND_NO_OPEN=1; auto-open is TTY-only and never fires in CI or SSH sessions)",
7036
7323
  " report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
7324
+ // C3: report-card writes TWO files. `--out index.svg` silently replaced an
7325
+ // existing index.html next to it; the second artifact is now disclosed
7326
+ // where the user chooses the name.
7327
+ " [--out f.svg] Also writes the companion viewer f.html alongside it, overwriting any existing file of that name",
7037
7328
  " glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
7038
7329
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
7039
7330
  " [--json] Emit the same canonical Context Health object used by MCP and Glance",
@@ -0,0 +1,41 @@
1
+ import { spawn } from "node:child_process";
2
+ export type ReportOpenDecision = {
3
+ open: true;
4
+ command: string;
5
+ args: string[];
6
+ } | {
7
+ open: false;
8
+ reason: "no-open-flag" | "env-switch" | "not-a-tty" | "ci" | "ssh" | "unsafe-path" | "no-opener";
9
+ };
10
+ /**
11
+ * The command a USER would type to open a file on this platform (0.9.6).
12
+ *
13
+ * The printed "open this report" pointers hardcoded macOS `open`, so every
14
+ * Linux and Windows user was told to run a command they do not have — while
15
+ * {@link decideReportAutoOpen} right below already branched per platform to
16
+ * decide what to spawn. One platform decision, used by both.
17
+ *
18
+ * This is the TYPED form, which is not always the spawned form: auto-open uses
19
+ * `rundll32 url.dll,FileProtocolHandler` on Windows precisely to keep cmd.exe
20
+ * out of the chain, but nobody types that — `start` is the builtin a Windows
21
+ * user actually uses, and by then they are typing into a shell anyway.
22
+ */
23
+ export declare function platformOpenCommand(platform?: NodeJS.Platform): string;
24
+ export declare function decideReportAutoOpen(input: {
25
+ htmlPath: string;
26
+ noOpenFlag: boolean;
27
+ env?: NodeJS.ProcessEnv;
28
+ platform?: NodeJS.Platform;
29
+ stdoutIsTty?: boolean;
30
+ /** Test seam for the linux xdg-open PATH probe. */
31
+ hasCommandImpl?: (command: string, env: NodeJS.ProcessEnv) => boolean;
32
+ }): ReportOpenDecision;
33
+ /**
34
+ * Fire-and-forget launch of an affirmative decision. Returns true when the
35
+ * opener was handed to the OS (the summary may then say "opened …");
36
+ * returns false — never throws — on any spawn failure.
37
+ */
38
+ export declare function openReportInBrowser(decision: ReportOpenDecision, options?: {
39
+ spawnImpl?: typeof spawn;
40
+ }): boolean;
41
+ //# sourceMappingURL=reportOpener.d.ts.map
@@ -0,0 +1,156 @@
1
+ import { spawn } from "node:child_process";
2
+ import { accessSync, constants } from "node:fs";
3
+ import { delimiter, join } from "node:path";
4
+ /**
5
+ * 0.9.5 "agent feel": after `report` writes its artifacts, the HTML report
6
+ * opens in the user's browser automatically — through the platform opener,
7
+ * never a hardcoded browser:
8
+ *
9
+ * darwin → open <html>
10
+ * linux → xdg-open <html> (only when xdg-open is actually on PATH)
11
+ * win32 → rundll32 url.dll,FileProtocolHandler <html>
12
+ *
13
+ * The decision to open is computed SYNCHRONOUSLY and truthfully before the
14
+ * summary renders, so the summary's Next block can say what actually
15
+ * happened. Auto-open is suppressed — silently, keeping the plain
16
+ * `open <path>` pointer — whenever any of these hold:
17
+ *
18
+ * - stdout is not a TTY (pipes, redirection, scripts)
19
+ * - CI is set (any non-empty value)
20
+ * - an SSH session (SSH_CONNECTION or SSH_TTY set): the browser would
21
+ * open on the wrong machine
22
+ * - the user asked not to: `--no-open` flag or AI_SPEND_NO_OPEN env
23
+ * (any non-empty value, same convention as AI_SPEND_NO_TELEMETRY)
24
+ * - the resolved path contains a shell metacharacter (see below)
25
+ * - the platform has no known opener (or linux without xdg-open)
26
+ *
27
+ * The spawn itself follows the telemetry detached-child pattern: detached,
28
+ * stdio ignored, unref'd, every failure (including async ENOENT) swallowed —
29
+ * the CLI must never crash, hang, or delay exit because an opener is
30
+ * missing or slow.
31
+ *
32
+ * SECURITY (win32 command-injection, fixed 0.9.5): the earlier
33
+ * `cmd /c start "" <path>` opener passed the path through cmd.exe, which
34
+ * re-parses `& ^ % ( ) < > |` even when spawned with shell:false — a
35
+ * space-free path like `C:\code\proj&calc` (all legal filename chars)
36
+ * would make cmd execute `calc`, and `%VAR%` would expand (info leak). The
37
+ * cwd-derived machine-wide path AND an absolute `--out` both reach here.
38
+ * Two independent defenses now stand:
39
+ * 1. UNSAFE_PATH_METACHARACTERS refuses auto-open (falling back to the
40
+ * plain pointer) for ANY path carrying those characters or a quote,
41
+ * on every platform — a metacharacter path is a reasonable thing to
42
+ * decline to shell-open anywhere.
43
+ * 2. The win32 opener no longer touches a shell: rundll32 hands the path
44
+ * straight to url.dll's FileProtocolHandler with a discrete argv, so
45
+ * even a metacharacter path that slipped past (1) cannot reach cmd.
46
+ */
47
+ /**
48
+ * cmd.exe re-parsing set plus the double-quote (which can break out of
49
+ * libuv's own arg quoting). A path containing any of these is never handed
50
+ * to a platform opener; auto-open falls back to the plain pointer instead.
51
+ */
52
+ const UNSAFE_PATH_METACHARACTERS = /[&^%()<>|"]/u;
53
+ /**
54
+ * The command a USER would type to open a file on this platform (0.9.6).
55
+ *
56
+ * The printed "open this report" pointers hardcoded macOS `open`, so every
57
+ * Linux and Windows user was told to run a command they do not have — while
58
+ * {@link decideReportAutoOpen} right below already branched per platform to
59
+ * decide what to spawn. One platform decision, used by both.
60
+ *
61
+ * This is the TYPED form, which is not always the spawned form: auto-open uses
62
+ * `rundll32 url.dll,FileProtocolHandler` on Windows precisely to keep cmd.exe
63
+ * out of the chain, but nobody types that — `start` is the builtin a Windows
64
+ * user actually uses, and by then they are typing into a shell anyway.
65
+ */
66
+ export function platformOpenCommand(platform = process.platform) {
67
+ if (platform === "darwin")
68
+ return "open";
69
+ // `start ""`, not bare `start`. cmd's `start` reads a QUOTED first argument
70
+ // as the new window's title, so `start "C:\...\ai-receipt.html"` opens an
71
+ // empty console window titled with the path and never opens the file. The
72
+ // empty title is the canonical fix, and every path we print is quoted.
73
+ if (platform === "win32")
74
+ return 'start ""';
75
+ return "xdg-open";
76
+ }
77
+ export function decideReportAutoOpen(input) {
78
+ const env = input.env ?? process.env;
79
+ const platform = input.platform ?? process.platform;
80
+ const stdoutIsTty = input.stdoutIsTty ?? Boolean(process.stdout.isTTY);
81
+ const hasCommand = input.hasCommandImpl ?? commandOnPath;
82
+ if (input.noOpenFlag)
83
+ return { open: false, reason: "no-open-flag" };
84
+ if (env.AI_SPEND_NO_OPEN)
85
+ return { open: false, reason: "env-switch" };
86
+ if (!stdoutIsTty)
87
+ return { open: false, reason: "not-a-tty" };
88
+ if (env.CI)
89
+ return { open: false, reason: "ci" };
90
+ if (env.SSH_CONNECTION || env.SSH_TTY)
91
+ return { open: false, reason: "ssh" };
92
+ // Defense (1): never shell-open a path carrying a cmd metacharacter or a
93
+ // quote — on any platform. This alone neutralizes the win32 vector.
94
+ if (UNSAFE_PATH_METACHARACTERS.test(input.htmlPath)) {
95
+ return { open: false, reason: "unsafe-path" };
96
+ }
97
+ if (platform === "darwin") {
98
+ return { open: true, command: "open", args: [input.htmlPath] };
99
+ }
100
+ if (platform === "win32") {
101
+ // Defense (2): rundll32 → url.dll,FileProtocolHandler opens the path
102
+ // with NO shell in the chain — cmd.exe never sees it, so its
103
+ // `& ^ % ( ) < > |` re-parsing (which shell:false does not prevent for
104
+ // `cmd /c start`) cannot fire even if defense (1) ever missed a char.
105
+ return {
106
+ open: true,
107
+ command: "rundll32",
108
+ args: ["url.dll,FileProtocolHandler", input.htmlPath]
109
+ };
110
+ }
111
+ if (platform === "linux" && hasCommand("xdg-open", env)) {
112
+ return { open: true, command: "xdg-open", args: [input.htmlPath] };
113
+ }
114
+ return { open: false, reason: "no-opener" };
115
+ }
116
+ /**
117
+ * Fire-and-forget launch of an affirmative decision. Returns true when the
118
+ * opener was handed to the OS (the summary may then say "opened …");
119
+ * returns false — never throws — on any spawn failure.
120
+ */
121
+ export function openReportInBrowser(decision, options = {}) {
122
+ if (!decision.open)
123
+ return false;
124
+ try {
125
+ const spawnImpl = options.spawnImpl ?? spawn;
126
+ const child = spawnImpl(decision.command, decision.args, {
127
+ detached: true,
128
+ stdio: "ignore"
129
+ });
130
+ // Async spawn errors (a vanished opener) surface on the child, not the
131
+ // call — swallow them so they can never crash the exiting CLI.
132
+ child.on?.("error", () => { });
133
+ child.unref();
134
+ return true;
135
+ }
136
+ catch {
137
+ return false;
138
+ }
139
+ }
140
+ /** Synchronous PATH probe (linux xdg-open) — cheap, no child process. */
141
+ function commandOnPath(command, env) {
142
+ const pathValue = env.PATH ?? "";
143
+ for (const dir of pathValue.split(delimiter)) {
144
+ if (!dir)
145
+ continue;
146
+ try {
147
+ accessSync(join(dir, command), constants.X_OK);
148
+ return true;
149
+ }
150
+ catch {
151
+ // keep looking
152
+ }
153
+ }
154
+ return false;
155
+ }
156
+ //# sourceMappingURL=reportOpener.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.9.4",
3
+ "version": "0.9.6",
4
4
  "funding": "https://asktilden.com",
5
5
  "description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
6
6
  "type": "module",
@@ -55,8 +55,8 @@
55
55
  "prepack": "npm run build"
56
56
  },
57
57
  "dependencies": {
58
- "@agent-finops/core": "0.9.4",
59
- "@agent-finops/report": "0.9.4",
58
+ "@agent-finops/core": "0.9.6",
59
+ "@agent-finops/report": "0.9.6",
60
60
  "yocto-spinner": "^1.2.0"
61
61
  }
62
62
  }