ai-spend-agent 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/index.js +157 -58
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -618,9 +618,11 @@ function quickstartNextSteps(mode, detected) {
618
618
  steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
619
619
  }
620
620
  steps.push(mode === "demo"
621
- // Every printed command must run as printed: the demo workspace does
622
- // not exist yet, so the command creates it first (shipped-audit fix).
623
- ? "mkdir -p ./demo-workspace && npx aibill report --sample --path ./demo-workspace write a clearly labeled demo report in an explicitly narrow workspace"
621
+ // 0.9.4: report --sample runs as printed from ANY directory — broad
622
+ // roots write ./ai-spend-report.{md,html} machine-wide-style, project
623
+ // folders keep .ai-spend-agent/report.* (the old mkdir demo-workspace
624
+ // 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"
624
626
  : "npx aibill report write a shareable Markdown + HTML report");
625
627
  steps.push("npx aibill --group-by project see which project has the most observed activity");
626
628
  steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
@@ -1154,7 +1156,9 @@ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
1154
1156
  ? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
1155
1157
  : surface === "report-card"
1156
1158
  ? "No receipt was written because there is no supported financial evidence to summarize."
1157
- : `No supported AI usage evidence was found in the last ${sinceDays} days.`;
1159
+ : surface === "report"
1160
+ ? "No report was written because there is no supported financial evidence to summarize."
1161
+ : `No supported AI usage evidence was found in the last ${sinceDays} days.`;
1158
1162
  return {
1159
1163
  exitCode: surface === "receipt" ? 0 : 1,
1160
1164
  stdout: surface === "receipt"
@@ -3758,27 +3762,46 @@ async function confirmMappingCommand(args) {
3758
3762
  ].join("\n"));
3759
3763
  }
3760
3764
  async function reportCommand(args, runtime = {}) {
3761
- // NEW-B3 + adversary F2: guard UNCONDITIONALLY. Even --sample writes
3762
- // project state into the root (resolveSafeStateDirectory below), so a
3763
- // broad root must refuse with the friendly guidance the sample gate
3764
- // alone still leaked the raw "Refusing to scan" from home. report-card
3765
- // --sample differs: it writes only the SVG artifact and keeps its
3766
- // deliberate home exemption.
3767
- const rootGuard = await guardExactProjectRoot("report", args.path);
3768
- if (rootGuard)
3769
- return rootGuard;
3765
+ // 0.9.4: a broad root (home, /) runs MACHINE-WIDE — the same read-only
3766
+ // transcript scanning as the bare receipt, no project state created, both
3767
+ // report files written to the current directory. The report renders
3768
+ // machine-wide content anyway (it lists every project), so the
3769
+ // exact-project requirement was incoherent here: the receipt's own Next
3770
+ // pointer led from home straight into a refusal. Only a bogus --path
3771
+ // still gets the friendly guard; project folders behave exactly as
3772
+ // before.
3773
+ const machineWide = isBroadScanRoot(args.path);
3774
+ if (!machineWide) {
3775
+ const rootGuard = await guardExactProjectRoot("report", args.path);
3776
+ if (rootGuard)
3777
+ return rootGuard;
3778
+ }
3770
3779
  const rootPath = resolve(args.path);
3771
3780
  try {
3772
3781
  const sinceDays = args.sinceDays ?? 30;
3773
3782
  if (!validSinceDays(sinceDays))
3774
3783
  return invalidSinceDaysResult();
3775
- const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
3784
+ // Machine-wide mode NEVER creates project state at the broad root — the
3785
+ // only writes are the two report files below (plus ~/.aibill home state
3786
+ // owned by other subsystems).
3787
+ const stateDir = machineWide ? undefined : await resolveSafeStateDirectory(rootPath, { create: true });
3776
3788
  // Like Apply, an explicit sample report is a strict privacy boundary. It
3777
3789
  // must not inspect local transcripts, account metadata, or persisted state.
3778
- const reportInput = args.sample
3779
- ? await buildExplicitSampleReportInput(rootPath)
3780
- : await buildReportInput(stateDir, rootPath, sinceDays);
3781
- const persistedPreferredExperiment = args.sample
3790
+ let reportInput;
3791
+ if (args.sample) {
3792
+ reportInput = await buildExplicitSampleReportInput(rootPath);
3793
+ }
3794
+ else if (machineWide) {
3795
+ const machineWideInput = await buildMachineWideReportInput(args, sinceDays);
3796
+ if (machineWideInput.kind === "no_evidence") {
3797
+ return noEvidenceResult("report", machineWideInput.warnings, sinceDays, runtime.telemetryDisclosure);
3798
+ }
3799
+ reportInput = machineWideInput.input;
3800
+ }
3801
+ else {
3802
+ reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
3803
+ }
3804
+ const persistedPreferredExperiment = args.sample || machineWide
3782
3805
  ? undefined
3783
3806
  : chooseLatestTokenReductionExperiment((await loadTokenVerificationState(rootPath)).experiments);
3784
3807
  const preferredExperiment = persistedPreferredExperiment &&
@@ -3820,48 +3843,60 @@ async function reportCommand(args, runtime = {}) {
3820
3843
  : { ...reportInput, telemetryDisclosure };
3821
3844
  const qualitativeActionsSuppressed = reportInput.dataMode !== "sample" &&
3822
3845
  reportInput.qualitativeCoverage?.status !== "complete";
3823
- const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
3846
+ // Machine-wide artifacts land in the CURRENT directory under the
3847
+ // ai-spend-* family name; project mode keeps .ai-spend-agent/report.*.
3848
+ const outBase = args.out
3849
+ ? resolve(rootPath, args.out)
3850
+ : machineWide
3851
+ ? join(rootPath, "ai-spend-report")
3852
+ : join(stateDir, "report");
3824
3853
  const markdownPath = `${outBase}.md`;
3825
3854
  const htmlPath = `${outBase}.html`;
3826
- await writeLocalReportFile(markdownPath, generateMarkdownReport(reportRenderInput), stateDir);
3827
- await writeLocalReportFile(htmlPath, generateHtmlReport(reportRenderInput), stateDir);
3855
+ await writeLocalReportFile(markdownPath, generateMarkdownReport(reportRenderInput), stateDir ?? rootPath);
3856
+ await writeLocalReportFile(htmlPath, generateHtmlReport(reportRenderInput), stateDir ?? rootPath);
3828
3857
  // A preferred canonical experiment owns this project's action/result
3829
3858
  // lineage even after completion or rollback. A report may refresh its
3830
3859
  // read-only projection, but never overwrite the frozen handoff with a
3831
3860
  // fresh or contradictory candidate. Coverage gaps receive only explicit
3832
3861
  // non-executable gap artifacts from the report package.
3833
- const artifactPaths = reportableExperiment
3862
+ // Apply artifacts are project-scoped handoffs — machine-wide runs skip
3863
+ // them (apply itself still requires one exact project folder).
3864
+ const artifactPaths = reportableExperiment || machineWide
3834
3865
  ? undefined
3835
3866
  : await writeApplyArtifacts(stateDir, reportInput);
3836
3867
  return ok([
3837
3868
  "aibill report",
3838
- `path: ${rootPath}`,
3869
+ machineWide
3870
+ ? `scope: machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}`
3871
+ : `path: ${rootPath}`,
3839
3872
  `markdown: ${markdownPath}`,
3840
3873
  `html: ${htmlPath}`,
3841
- ...(reportableExperiment
3842
- ? [
3843
- `action artifacts: preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})`,
3844
- `token result: status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}`,
3845
- `token test: ${improveRuntimeCommand}`
3846
- ]
3847
- : qualitativeActionsSuppressed
3874
+ ...(machineWide
3875
+ ? []
3876
+ : reportableExperiment
3848
3877
  ? [
3849
- `action artifacts: suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}`,
3850
- `coverage artifact: ${artifactPaths.codingPrompt}`,
3851
- `coverage action plan: ${artifactPaths.actionPlan}`,
3852
- `coverage policy/config: ${artifactPaths.policyConfigDraft}`,
3853
- `coverage verification: ${artifactPaths.verificationPlan}`,
3854
- `coverage package: ${artifactPaths.demoPackage}`
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}`
3855
3881
  ]
3856
- : artifactPaths
3882
+ : qualitativeActionsSuppressed
3857
3883
  ? [
3858
- `apply artifact: ${artifactPaths.codingPrompt}`,
3859
- `action plan: ${artifactPaths.actionPlan}`,
3860
- `policy/config draft: ${artifactPaths.policyConfigDraft}`,
3861
- `verification plan: ${artifactPaths.verificationPlan}`,
3862
- `demo package: ${artifactPaths.demoPackage}`
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}`
3863
3890
  ]
3864
- : []),
3891
+ : artifactPaths
3892
+ ? [
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}`
3898
+ ]
3899
+ : []),
3865
3900
  reportInput.dataMode === "sample"
3866
3901
  ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
3867
3902
  : reportInput.dataMode === "connected_provider" &&
@@ -3875,13 +3910,20 @@ async function reportCommand(args, runtime = {}) {
3875
3910
  "next:",
3876
3911
  ` open ${htmlPath} view the full report in your browser`,
3877
3912
  ` less ${markdownPath} read it in the terminal`,
3878
- reportableExperiment
3879
- ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
3880
- : qualitativeActionsSuppressed
3881
- ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
3882
- : reportInput.dataMode === "sample"
3883
- ? ` ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary`
3884
- : ` ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} print the paste-ready coding-agent prompt from this exact evidence window`
3913
+ machineWide
3914
+ // apply/improve need one exact project folder — a machine-wide
3915
+ // report must never point at a command that then refuses (the exact
3916
+ // trap this mode removes).
3917
+ ? 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`
3920
+ : reportableExperiment
3921
+ ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
3922
+ : qualitativeActionsSuppressed
3923
+ ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
3924
+ : 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`
3885
3927
  ].join("\n"));
3886
3928
  }
3887
3929
  catch (error) {
@@ -3907,19 +3949,25 @@ async function resolveReceiptPath(rootPath, out) {
3907
3949
  return extname(resolved) ? resolved : `${resolved}.svg`;
3908
3950
  }
3909
3951
  async function reportCardCommand(args) {
3910
- if (!args.sample) {
3911
- // NEW-B3 + founder repro (`npx aibill report-card` from home): the raw
3912
- // scan refusal used to surface wrapped in "Couldn't write the report
3913
- // card:". The friendly guard renders clean, BEFORE the try/wrapper.
3952
+ // 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
3953
+ // scanning to the bare receipt (loadInstantReadData below), SVG written to
3954
+ // the current directory. The card renders machine-wide content anyway, so
3955
+ // an exact-project requirement was incoherent here; only a bogus --path
3956
+ // still gets the friendly guard.
3957
+ const machineWide = isBroadScanRoot(args.path);
3958
+ if (!args.sample && !machineWide) {
3914
3959
  const rootGuard = await guardExactProjectRoot("report-card", args.path);
3915
3960
  if (rootGuard)
3916
3961
  return rootGuard;
3917
3962
  }
3918
3963
  try {
3919
- // Explicit sample mode reads no workspace data, so a broad-root scan guard
3920
- // would reject a harmless receipt written from the user's home directory.
3921
- // Output still goes through the safe-write/symlink checks below.
3922
- const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
3964
+ // Sample mode reads no workspace data and machine-wide mode reads only
3965
+ // the agent transcript dirs neither scans the current directory, so
3966
+ // both write the receipt from wherever the user stands. Output still
3967
+ // goes through the safe-write/symlink checks below.
3968
+ const rootPath = args.sample || machineWide
3969
+ ? resolve(args.path)
3970
+ : await resolveSafeScanRoot(args.path);
3923
3971
  const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
3924
3972
  if (records.length === 0) {
3925
3973
  return noEvidenceResult("report-card", warnings, args.sinceDays ?? 30);
@@ -3974,6 +4022,23 @@ async function reportCardCommand(args) {
3974
4022
  * exist (a location problem is not a breadth problem, but the fix is the
3975
4023
  * same). Returns undefined when the root is an acceptable exact project.
3976
4024
  */
4025
+ /**
4026
+ * True when the requested root is a machine-wide location (home, filesystem
4027
+ * root, a system directory, or anything containing home). report/report-card
4028
+ * treat this as MACHINE-WIDE MODE — the same read-only, transcript-dir
4029
+ * scanning the bare receipt performs — instead of refusing (0.9.4 founder
4030
+ * fix: the receipt's own Next pointer led from home into a refusal that
4031
+ * read as "the commands don't work"). Genuinely project-scoped commands
4032
+ * (improve, apply, verify, watch, connect, reset, …) keep the guard.
4033
+ */
4034
+ function isBroadScanRoot(requestedPath) {
4035
+ const rootPath = resolve(requestedPath);
4036
+ const home = homedir();
4037
+ const guardHome = home && home.trim().length > 0
4038
+ ? home
4039
+ : join(rootPath, "aibill-impossible-home-sentinel");
4040
+ return unsafeScanRootReason(rootPath, guardHome) !== undefined;
4041
+ }
3977
4042
  async function guardExactProjectRoot(commandName, requestedPath) {
3978
4043
  const rootPath = resolve(requestedPath);
3979
4044
  // An unset/empty $HOME (containers) must never make the current directory
@@ -5890,6 +5955,40 @@ function applyEvidenceAcquisitionLines(input) {
5890
5955
  }
5891
5956
  return lines;
5892
5957
  }
5958
+ /**
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.
5963
+ */
5964
+ async function buildMachineWideReportInput(args, sinceDays) {
5965
+ const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
5966
+ if (records.length === 0) {
5967
+ // Same honest empty-state voice the receipt/report-card use — an empty
5968
+ // report file would just look broken.
5969
+ return { kind: "no_evidence", warnings };
5970
+ }
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;
5978
+ return {
5979
+ kind: "input",
5980
+ input: {
5981
+ generatedAt: new Date().toISOString(),
5982
+ summary: analyzeSpend(headlineRecords),
5983
+ allRecords: records,
5984
+ dataMode: mode === "connected" ? "connected_provider" : "local_logs",
5985
+ evidenceWindowDays: sinceDays,
5986
+ detectedPlans,
5987
+ ...(mode === "connected" ? { providerRecords: records } : {}),
5988
+ ...(providerCoverage ? { providerCoverage } : {})
5989
+ }
5990
+ };
5991
+ }
5893
5992
  async function buildExplicitSampleReportInput(rootPath) {
5894
5993
  const records = await loadSampleUsageData();
5895
5994
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
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.3",
59
- "@agent-finops/report": "0.9.3",
58
+ "@agent-finops/core": "0.9.4",
59
+ "@agent-finops/report": "0.9.4",
60
60
  "yocto-spinner": "^1.2.0"
61
61
  }
62
62
  }