ai-spend-agent 0.9.5 → 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.js CHANGED
@@ -9,7 +9,7 @@ import { askGuidedQuestion, classifyGuidedAnswer, createInteractivePromptSource,
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,8 +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 { decideReportAutoOpen, openReportInBrowser } from "./reportOpener.js";
21
- import { generateActionPlanMarkdown, generateApplyArtifactMarkdown, generateCommandSummary, 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";
22
22
  // One shared v2 sharded store instance for BOTH evidence kinds: the v1
23
23
  // monolithic qualitative adapter re-probed git privacy on every read (176
24
24
  // spawned git processes per warm run with $HOME itself a git repo) and
@@ -102,7 +102,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
102
102
  return reportCommand(args, runtime);
103
103
  }
104
104
  if (args.command === "report-card") {
105
- return reportCardCommand(args);
105
+ return reportCardCommand(args, runtime);
106
106
  }
107
107
  if (args.command === "glance") {
108
108
  return glanceCommand(args);
@@ -166,52 +166,19 @@ async function quickstartCommand(args, runtime = {}) {
166
166
  stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
167
167
  };
168
168
  }
169
- 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;
170
173
  if (records.length === 0) {
171
174
  return noEvidenceResult("receipt", warnings, sinceDays, runtime.telemetryDisclosure);
172
175
  }
173
- const summaryRecords = mode === "connected"
174
- ? selectProviderFinancialHeadlineRecords(records)
175
- : records;
176
- const summary = analyzeSpend(summaryRecords);
177
- // Connected receipts stay billed-primary but never ERASE the estimated
178
- // axis: local transcript records ride along so subscription rows keep
179
- // their ~ API-equivalent figures next to billed money (C-lane §1.4). The
180
- // renderer classifies each record by basis and never blends the totals.
181
- const receiptRecords = mode === "connected" && (localFinancialRecords?.length ?? 0) > 0
182
- ? [...summaryRecords, ...localFinancialRecords]
183
- : summaryRecords;
176
+ const receiptRecords = evidence.analysisRecords;
184
177
  // For real local-log users the by-project view is the flagship table
185
178
  // ("which project burns my plan"); demo/connected keep by-model.
186
179
  const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
187
180
  const color = args.noColor ? false : undefined;
188
181
  const outputWidth = terminalOutputWidth();
189
- // Persona: --plan override wins; otherwise read the plans the coding agents
190
- // themselves persisted locally (read-only, whitelisted fields, no network).
191
- let detectedPlans;
192
- if (args.sample) {
193
- // An explicit sample run must be deterministic and safe to record/share.
194
- // Never mix the developer's real local plan into illustrative output.
195
- detectedPlans = [];
196
- }
197
- else if (args.plan) {
198
- const override = planOverrideFromFlag(args.plan);
199
- if (!override) {
200
- return {
201
- exitCode: 1,
202
- stdout: "",
203
- stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
204
- };
205
- }
206
- detectedPlans = [override];
207
- }
208
- else {
209
- detectedPlans = await detectLocalPlans({
210
- // Env overrides keep tests (and unusual installs) isolated from $HOME.
211
- claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
212
- codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
213
- }).catch(() => []);
214
- }
215
182
  // Surface auto-detected credentials so the user knows their next 2-min step,
216
183
  // without ever printing a raw secret.
217
184
  // Sample output is designed for demos, docs, and screenshots. Keeping local
@@ -224,32 +191,6 @@ async function quickstartCommand(args, runtime = {}) {
224
191
  home: process.env.AI_SPEND_CLAUDE_HOME_DIR
225
192
  });
226
193
  const nextSteps = quickstartNextSteps(mode, detection.credentials);
227
- // Dead-context cost, globalized across the user's whole Claude Code setup
228
- // (all projects' MCP + user-scope skills/agents/commands, vs. every
229
- // transcript) so it's populated from ANY directory on the first run.
230
- // Never throws into the readout.
231
- let deadContext = args.sample
232
- ? undefined
233
- : await loadDeadContext({
234
- // Env overrides keep tests (and unusual installs) isolated from $HOME.
235
- claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
236
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
237
- claudeHomeDir: process.env.AI_SPEND_CLAUDE_HOME_DIR,
238
- codexHomeDir: process.env.AI_SPEND_CODEX_HOME_DIR,
239
- claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
240
- claudeSettingsPath: process.env.AI_SPEND_CLAUDE_SETTINGS,
241
- projectDir: resolve(args.path),
242
- includeAllProjectMcp: true,
243
- sinceIso: sinceIsoForDays(sinceDays),
244
- windowDays: sinceDays,
245
- codexInvocationFiles
246
- }).catch(() => undefined);
247
- // Sample dead-context is shown ONLY on the demo readout. A real readout
248
- // (local logs / connected billing) never gets fabricated waste injected —
249
- // a genuinely clean setup earns its congratulation line instead.
250
- if (mode === "demo" && (!deadContext || !deadContext.hasData)) {
251
- deadContext = sampleDeadContext();
252
- }
253
194
  const guidedExperience = !args.sample && actionEvidence
254
195
  ? await buildQuickstartGuidedExperience({
255
196
  args,
@@ -262,6 +203,11 @@ async function quickstartCommand(args, runtime = {}) {
262
203
  : undefined;
263
204
  const summaryText = generatePlainEnglishSummary(summary, {
264
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) } : {}),
265
211
  groupBy,
266
212
  color,
267
213
  mode,
@@ -275,7 +221,7 @@ async function quickstartCommand(args, runtime = {}) {
275
221
  detectedPlans,
276
222
  // 0.9.5: from a broad root the --full view's project-scoped pointers
277
223
  // (apply, apply-artifact, watch, connect) carry the machine-wide
278
- // report's `cd <project> && …` prefix instead of advertising commands
224
+ // report's `cd /path/to/project && …` prefix instead of advertising commands
279
225
  // that friendly-refuse right where they were printed.
280
226
  commandScope: isBroadScanRoot(args.path) ? "machine-wide" : "project",
281
227
  // C-lane §1.4: the result card header states the evidence window.
@@ -617,20 +563,31 @@ function quickstartNextSteps(mode, detected) {
617
563
  // Connect/verify guidance now lives in the readout's APPLY/VERIFY sections;
618
564
  // this footer only carries what those can't know (detected local keys) and
619
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).
620
571
  const steps = [];
621
572
  if (detected.length > 0) {
622
573
  const names = detected.map((credential) => `${credential.provider} (${credential.hint})`).join(", ");
623
574
  steps.push(`Found local key${detected.length === 1 ? "" : "s"}: ${names}`);
624
- 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
+ });
625
579
  }
626
580
  steps.push(mode === "demo"
627
581
  // 0.9.4: report --sample runs as printed from ANY directory — broad
628
582
  // roots write ./ai-spend-report.{md,html} machine-wide-style, project
629
583
  // folders keep .ai-spend-agent/report.* (the old mkdir demo-workspace
630
584
  // preamble is no longer needed for the command to run as printed).
631
- ? "npx aibill report --sample write a clearly labeled demo report right here"
632
- : "npx aibill report write a shareable Markdown + HTML report");
633
- 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
+ });
634
591
  steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
635
592
  if (mode === "demo") {
636
593
  // Static pointer only — sample output is built for recordings and
@@ -829,6 +786,86 @@ async function loadInstantReadData(args) {
829
786
  : false
830
787
  };
831
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
+ }
832
869
  function localFinancialEvidenceComplete(result) {
833
870
  const scanCoverageComplete = result.sourceScans.every((scan) => (scan.directoryStatus !== "unreadable" &&
834
871
  scan.unreadableFiles === 0 &&
@@ -856,9 +893,23 @@ async function loadBoundedLocalActionEvidence(sinceIso) {
856
893
  * qualitative index converges, printing honest per-pass progress.
857
894
  */
858
895
  async function indexEvidenceCommand(args, runtime) {
859
- const rootGuard = await guardExactProjectRoot("index", args.path);
860
- if (rootGuard)
861
- 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
+ }
862
913
  const sinceDays = args.sinceDays ?? 30;
863
914
  if (!validSinceDays(sinceDays))
864
915
  return invalidSinceDaysResult();
@@ -903,7 +954,7 @@ async function indexEvidenceCommand(args, runtime) {
903
954
  `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
904
955
  `${formatBytes(totalBytes)} streamed across ${passes} pass(es)`
905
956
  ],
906
- 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) }
907
958
  }));
908
959
  }
909
960
  if (passBytes === 0 && stillConverging === 0) {
@@ -915,7 +966,7 @@ async function indexEvidenceCommand(args, runtime) {
915
966
  `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
916
967
  `${coverage.skippedForBudget} file(s) remain outside the read budget`
917
968
  ],
918
- 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) }
919
970
  }));
920
971
  }
921
972
  }
@@ -956,10 +1007,28 @@ function summarizeCliQualitativeCoverageByAgent(logs) {
956
1007
  ];
957
1008
  }));
958
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
+ }
959
1027
  function renderCliQualitativeCoverage(coverage) {
960
- return `QUALITATIVE INDEX ${coverage.status.toUpperCase()} · ` +
961
- `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
962
- `${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
+ : "");
963
1032
  }
964
1033
  /**
965
1034
  * `aibill signup <email> [--ref <token>]` — the explicit, deliberate path to
@@ -1177,7 +1246,19 @@ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
1177
1246
  ...warnings.map((warning) => `! ${warning}`),
1178
1247
  "",
1179
1248
  "Next",
1180
- " 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",
1181
1262
  ` ${signupCopy.receiptPointer}`
1182
1263
  ].join("\n")
1183
1264
  : "",
@@ -1840,7 +1921,7 @@ async function statuslineCommand(args, runtime) {
1840
1921
  if (action === "refresh")
1841
1922
  return refreshStatuslineCommand(args, runtime);
1842
1923
  if (action === "expand")
1843
- return expandStatuslineCommand(runtime);
1924
+ return expandStatuslineCommand(args, runtime);
1844
1925
  return {
1845
1926
  exitCode: 1,
1846
1927
  stdout: "",
@@ -2132,6 +2213,19 @@ async function refreshStatuslineCommand(args, runtime) {
2132
2213
  }
2133
2214
  detectedPlanOverride = [override];
2134
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;
2135
2229
  const rootPath = await resolveSafeScanRoot(args.path);
2136
2230
  const cacheDirectory = process.env.AIBILL_CACHE_DIR;
2137
2231
  await preflightInitCache(cacheDirectory);
@@ -2199,7 +2293,7 @@ async function refreshStatuslineCommand(args, runtime) {
2199
2293
  * snapshot; `runways[]` carries both limits), never bypassing the contract.
2200
2294
  * Printed command output, not a hook line.
2201
2295
  */
2202
- async function expandStatuslineCommand(runtime) {
2296
+ async function expandStatuslineCommand(args, runtime) {
2203
2297
  const cache = await readStatuslineCache({
2204
2298
  cacheDirectory: process.env.AIBILL_CACHE_DIR,
2205
2299
  homeDirectory: runtime.homeDirectory
@@ -2209,8 +2303,8 @@ async function expandStatuslineCommand(runtime) {
2209
2303
  exitCode: 1,
2210
2304
  stdout: "",
2211
2305
  stderr: cache.status === "missing"
2212
- ? "aibill statusline expand needs a refreshed private cache: run `npx aibill statusline refresh` first."
2213
- : "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)}\`.`
2214
2308
  };
2215
2309
  }
2216
2310
  const now = runtime.statuslineNow ?? new Date();
@@ -2219,7 +2313,7 @@ async function expandStatuslineCommand(runtime) {
2219
2313
  return ok([
2220
2314
  "aibill subscriptions · 30d window",
2221
2315
  " no detected subscription evidence in the private cache",
2222
- " 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`
2223
2317
  ].join("\n"));
2224
2318
  }
2225
2319
  const card = statuslineSnapshotToResultCard(snapshot, now);
@@ -3907,7 +4001,7 @@ async function reportCommand(args, runtime = {}) {
3907
4001
  ]
3908
4002
  : qualitativeActionsSuppressed
3909
4003
  ? [
3910
- { label: "Action artifacts", value: `suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}` },
4004
+ { label: "Action artifacts", value: reportQualitativeRowValue(reportInput.qualitativeCoverage) },
3911
4005
  { label: "Coverage artifact", value: artifactPaths.codingPrompt },
3912
4006
  { label: "Coverage action plan", value: artifactPaths.actionPlan },
3913
4007
  { label: "Coverage policy/config", value: artifactPaths.policyConfigDraft },
@@ -3942,21 +4036,34 @@ async function reportCommand(args, runtime = {}) {
3942
4036
  const nextSteps = [
3943
4037
  // Summary-line truth: only a fired opener may claim it opened; every
3944
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.
3945
4046
  openedInBrowser
3946
4047
  ? { command: `opened ${basename(htmlPath)} in your browser · next time: --no-open to skip` }
3947
- : { command: `open ${htmlPath}`, description: "view the full report in your browser" },
3948
- { command: `less ${markdownPath}`, description: "read it in the terminal" },
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
+ },
3949
4056
  machineWide
3950
4057
  // apply/improve need one exact project folder — a machine-wide
3951
4058
  // report must never point at a command that then refuses (the exact
3952
4059
  // trap this mode removes).
3953
4060
  ? reportInput.dataMode === "sample"
3954
- ? { command: `cd <project> && ${actionRuntimeCommand("apply --sample")}`, description: "print the non-executable demo boundary from one exact project folder" }
3955
- : { command: `cd <project> && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)}`, description: "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" }
3956
4063
  : reportableExperiment
3957
4064
  ? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
3958
4065
  : qualitativeActionsSuppressed
3959
- ? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "complete bounded qualitative evidence before any action" }
4066
+ ? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "inspect the transcript evidence read so far" }
3960
4067
  : reportInput.dataMode === "sample"
3961
4068
  ? { command: actionRuntimeCommand("apply --sample"), description: "print the non-executable demo boundary" }
3962
4069
  : { command: actionRuntimeCommand(`apply --since-days ${sinceDays}`), description: "print the paste-ready coding-agent prompt from this exact evidence window" }
@@ -3992,7 +4099,7 @@ async function resolveReceiptPath(rootPath, out) {
3992
4099
  return join(resolved, "ai-spend-receipt.svg");
3993
4100
  return extname(resolved) ? resolved : `${resolved}.svg`;
3994
4101
  }
3995
- async function reportCardCommand(args) {
4102
+ async function reportCardCommand(args, runtime = {}) {
3996
4103
  // 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
3997
4104
  // scanning to the bare receipt (loadInstantReadData below), SVG written to
3998
4105
  // the current directory. The card renders machine-wide content anyway, so
@@ -4029,12 +4136,39 @@ async function reportCardCommand(args) {
4029
4136
  const summary = analyzeSpend(headlineRecords);
4030
4137
  const outPath = await resolveReceiptPath(rootPath, args.out);
4031
4138
  await mkdir(dirname(outPath), { recursive: true });
4032
- await writeSafeStateText(dirname(outPath), basename(outPath), generateReportCardSvg({
4139
+ const svg = generateReportCardSvg({
4033
4140
  summary,
4034
4141
  records: headlineRecords,
4035
4142
  mode,
4036
4143
  ...(providerCoverage ? { providerCoverage } : {})
4037
- }));
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);
4038
4172
  const dataRow = mode === "demo"
4039
4173
  ? args.sample
4040
4174
  ? "DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read"
@@ -4050,18 +4184,29 @@ async function reportCardCommand(args) {
4050
4184
  note: "a shareable, redacted spend card (no client/project/user names)",
4051
4185
  rows: [
4052
4186
  { label: "Receipt", value: outPath },
4187
+ { label: "Preview", value: companionPath },
4053
4188
  { label: "Data", value: dataRow },
4054
4189
  { label: "Privacy", value: "rendered locally; only totals, generic candidate categories, and evidence labels are included" }
4055
4190
  ],
4056
4191
  sections: [{
4057
4192
  heading: "Caption to share",
4058
- body: [generateReportCardCaption({
4059
- summary,
4060
- records: headlineRecords,
4061
- mode,
4062
- ...(providerCoverage ? { providerCoverage } : {})
4063
- })]
4193
+ body: [caption]
4064
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
+ ],
4065
4210
  color: args.noColor ? false : undefined,
4066
4211
  width: terminalOutputWidth()
4067
4212
  }));
@@ -4093,6 +4238,19 @@ async function reportCardCommand(args) {
4093
4238
  function isBroadScanRoot(requestedPath) {
4094
4239
  return broadScanRootKind(requestedPath) !== undefined;
4095
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
+ }
4096
4254
  /** Classifies WHICH broad-root category a machine-wide path falls in. */
4097
4255
  function broadScanRootKind(requestedPath) {
4098
4256
  const rootPath = resolve(requestedPath);
@@ -5912,7 +6070,7 @@ async function tokenVerificationCommand(args) {
5912
6070
  }
5913
6071
  const observation = await loadTokenVerificationObservation(rootPath, experiment);
5914
6072
  if (!observation.qualitativeCoverageComplete) {
5915
- 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");
5916
6074
  }
5917
6075
  const unlabelled = refreshTokenReductionExperimentV0(experiment, {
5918
6076
  sessionVitals: observation.sessionVitals,
@@ -6066,36 +6224,50 @@ function applyEvidenceAcquisitionLines(input) {
6066
6224
  return lines;
6067
6225
  }
6068
6226
  /**
6069
- * Machine-wide report input (0.9.4): the bare receipt's own data path —
6070
- * loadInstantReadData over the agent transcript dirs (read-only) — rendered
6071
- * through the report package. No project state is read or created; plan
6072
- * 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.
6073
6240
  */
6074
6241
  async function buildMachineWideReportInput(args, sinceDays) {
6075
- const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
6076
- if (records.length === 0) {
6242
+ const evidence = await loadBroadScanEvidence(args, sinceDays);
6243
+ if (evidence.records.length === 0) {
6077
6244
  // Same honest empty-state voice the receipt/report-card use — an empty
6078
6245
  // report file would just look broken.
6079
- return { kind: "no_evidence", warnings };
6246
+ return { kind: "no_evidence", warnings: evidence.warnings };
6080
6247
  }
6081
- const detectedPlans = await detectLocalPlans({
6082
- claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
6083
- codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
6084
- }).catch(() => []);
6085
- const headlineRecords = mode === "connected"
6086
- ? selectProviderFinancialHeadlineRecords(records)
6087
- : records;
6248
+ const { records, mode, actionEvidence } = evidence;
6088
6249
  return {
6089
6250
  kind: "input",
6090
6251
  input: {
6091
6252
  generatedAt: new Date().toISOString(),
6092
- summary: analyzeSpend(headlineRecords),
6253
+ summary: evidence.summary,
6093
6254
  allRecords: records,
6094
6255
  dataMode: mode === "connected" ? "connected_provider" : "local_logs",
6095
6256
  evidenceWindowDays: sinceDays,
6096
- 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 } : {}),
6097
6266
  ...(mode === "connected" ? { providerRecords: records } : {}),
6098
- ...(providerCoverage ? { providerCoverage } : {})
6267
+ ...(mode === "connected" && (evidence.localFinancialRecords?.length ?? 0) > 0
6268
+ ? { localFinancialRecords: evidence.localFinancialRecords }
6269
+ : {}),
6270
+ ...(evidence.providerCoverage ? { providerCoverage: evidence.providerCoverage } : {})
6099
6271
  }
6100
6272
  };
6101
6273
  }
@@ -7149,6 +7321,10 @@ function helpText(telemetryDisclosure) {
7149
7321
  " report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports and open the HTML in your browser",
7150
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)",
7151
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",
7152
7328
  " glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
7153
7329
  " context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
7154
7330
  " [--json] Emit the same canonical Context Health object used by MCP and Glance",
@@ -7,6 +7,20 @@ export type ReportOpenDecision = {
7
7
  open: false;
8
8
  reason: "no-open-flag" | "env-switch" | "not-a-tty" | "ci" | "ssh" | "unsafe-path" | "no-opener";
9
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;
10
24
  export declare function decideReportAutoOpen(input: {
11
25
  htmlPath: string;
12
26
  noOpenFlag: boolean;
@@ -50,6 +50,30 @@ import { delimiter, join } from "node:path";
50
50
  * to a platform opener; auto-open falls back to the plain pointer instead.
51
51
  */
52
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
+ }
53
77
  export function decideReportAutoOpen(input) {
54
78
  const env = input.env ?? process.env;
55
79
  const platform = input.platform ?? process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.9.5",
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.5",
59
- "@agent-finops/report": "0.9.5",
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
  }