ai-spend-agent 0.9.5 → 0.9.7
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/README.md +13 -1
- package/dist/index.js +304 -126
- package/dist/reportOpener.d.ts +14 -0
- package/dist/reportOpener.js +24 -0
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -29,9 +29,21 @@ provider evidence. Remove it with `npx aibill statusline uninstall`.
|
|
|
29
29
|
It reads supported local Claude Code, Codex, and Gemini CLI financial metadata,
|
|
30
30
|
labels API-equivalent estimates, and can optionally add official OpenAI or
|
|
31
31
|
Anthropic provider-reported cost
|
|
32
|
-
through an environment-variable reference.
|
|
32
|
+
through an environment-variable reference.
|
|
33
33
|
aibill never sits in the inference path and never stores, prints, or proxies provider credentials.
|
|
34
34
|
|
|
35
|
+
**Telemetry: anonymous command counts, disclosed at first run.** Terminal
|
|
36
|
+
analysis stays on your machine — transcripts, prompts, file names, and dollar
|
|
37
|
+
amounts are never uploaded. Separately, the CLI counts *which* commands run:
|
|
38
|
+
anonymous, never your arguments, paths, content, dollars, or your email.
|
|
39
|
+
Nothing is sent before a one-time notice has been printed on an interactive
|
|
40
|
+
run, so a machine that has never seen that notice (CI, scripts, pipes) never
|
|
41
|
+
sends anything. One command turns it off — `aibill telemetry off` (or
|
|
42
|
+
`DO_NOT_TRACK`, `CI`, or `AI_SPEND_NO_TELEMETRY`) — `aibill telemetry` prints
|
|
43
|
+
the exact last payload verbatim, and while it is on every receipt says so in
|
|
44
|
+
place of "nothing uploaded". Full scope and the never-list:
|
|
45
|
+
[`docs/TELEMETRY.md`](https://github.com/futurastudio/ai-spend-agent/blob/main/docs/TELEMETRY.md).
|
|
46
|
+
|
|
35
47
|
Connector validation (`live_verified`, `fixture_verified`, `untested`, or
|
|
36
48
|
`failed`) and each number's financial evidence (`verified`, `estimated`,
|
|
37
49
|
`detected_unverified`, or `missing`) are separate status axes. Run `npx aibill
|
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, spendReportTotalLine } 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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
632
|
-
: "npx aibill report
|
|
633
|
-
steps.push(
|
|
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
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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 `
|
|
961
|
-
|
|
962
|
-
|
|
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
|
-
|
|
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
|
-
?
|
|
2213
|
-
:
|
|
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
|
-
|
|
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:
|
|
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 },
|
|
@@ -3925,12 +4019,14 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3925
4019
|
: []),
|
|
3926
4020
|
{
|
|
3927
4021
|
label: "Total",
|
|
3928
|
-
|
|
3929
|
-
|
|
3930
|
-
|
|
3931
|
-
|
|
3932
|
-
|
|
3933
|
-
|
|
4022
|
+
// B1: the row is rendered by the report package from this exact
|
|
4023
|
+
// input, so the terminal summary and the report.md/report.html this
|
|
4024
|
+
// command just wrote cannot disagree about whether the money is zero
|
|
4025
|
+
// or unknown. The local branch used to fall straight through to
|
|
4026
|
+
// `formatOptionalUsd(summary.totalUsd)` — and `analyzeSpend` returns
|
|
4027
|
+
// a plain 0 for a window where nothing could be priced, so `report`
|
|
4028
|
+
// printed "$0.00" beside artifacts that said "Unavailable".
|
|
4029
|
+
value: spendReportTotalLine(reportInput)
|
|
3934
4030
|
},
|
|
3935
4031
|
{
|
|
3936
4032
|
label: "Privacy",
|
|
@@ -3942,21 +4038,34 @@ async function reportCommand(args, runtime = {}) {
|
|
|
3942
4038
|
const nextSteps = [
|
|
3943
4039
|
// Summary-line truth: only a fired opener may claim it opened; every
|
|
3944
4040
|
// suppression path keeps the plain copy-pasteable pointer.
|
|
4041
|
+
// 0.9.6: the pointer must survive a NAIVE PARTIAL READ. The founder saw
|
|
4042
|
+
// `› open <the full absolute path>` with its description on the
|
|
4043
|
+
// next line, read "open" as a label rather than the command, typed bare
|
|
4044
|
+
// `open`, and got macOS's usage dump — "i don't know what im looking
|
|
4045
|
+
// at." shellPathPointer names an artifact in the current directory
|
|
4046
|
+
// relatively (one short unit) and quotes anything absolute so the
|
|
4047
|
+
// command and its argument read — and paste — as one thing.
|
|
3945
4048
|
openedInBrowser
|
|
3946
4049
|
? { command: `opened ${basename(htmlPath)} in your browser · next time: --no-open to skip` }
|
|
3947
|
-
: {
|
|
3948
|
-
|
|
4050
|
+
: {
|
|
4051
|
+
command: shellPathPointer(platformOpenCommand(), htmlPath, process.cwd()),
|
|
4052
|
+
description: `view in your browser — or double-click ${basename(htmlPath)} in your file manager`
|
|
4053
|
+
},
|
|
4054
|
+
{
|
|
4055
|
+
command: shellPathPointer("less", markdownPath, process.cwd()),
|
|
4056
|
+
description: "read it in the terminal"
|
|
4057
|
+
},
|
|
3949
4058
|
machineWide
|
|
3950
4059
|
// apply/improve need one exact project folder — a machine-wide
|
|
3951
4060
|
// report must never point at a command that then refuses (the exact
|
|
3952
4061
|
// trap this mode removes).
|
|
3953
4062
|
? reportInput.dataMode === "sample"
|
|
3954
|
-
? { command: `cd
|
|
3955
|
-
: { command: `cd
|
|
4063
|
+
? { command: `cd /path/to/project && ${actionRuntimeCommand("apply --sample")}`, description: "print the non-executable demo boundary from one exact project folder" }
|
|
4064
|
+
: { command: `cd /path/to/project && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)}`, description: "per-project action plan from one exact project folder" }
|
|
3956
4065
|
: reportableExperiment
|
|
3957
4066
|
? { command: improveRuntimeCommand, description: `review canonical token test ${reportableExperiment.id}` }
|
|
3958
4067
|
: qualitativeActionsSuppressed
|
|
3959
|
-
? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "
|
|
4068
|
+
? { command: actionRuntimeCommand(`context --json --since-days ${sinceDays}`), description: "inspect the transcript evidence read so far" }
|
|
3960
4069
|
: reportInput.dataMode === "sample"
|
|
3961
4070
|
? { command: actionRuntimeCommand("apply --sample"), description: "print the non-executable demo boundary" }
|
|
3962
4071
|
: { command: actionRuntimeCommand(`apply --since-days ${sinceDays}`), description: "print the paste-ready coding-agent prompt from this exact evidence window" }
|
|
@@ -3992,7 +4101,7 @@ async function resolveReceiptPath(rootPath, out) {
|
|
|
3992
4101
|
return join(resolved, "ai-spend-receipt.svg");
|
|
3993
4102
|
return extname(resolved) ? resolved : `${resolved}.svg`;
|
|
3994
4103
|
}
|
|
3995
|
-
async function reportCardCommand(args) {
|
|
4104
|
+
async function reportCardCommand(args, runtime = {}) {
|
|
3996
4105
|
// 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
|
|
3997
4106
|
// scanning to the bare receipt (loadInstantReadData below), SVG written to
|
|
3998
4107
|
// the current directory. The card renders machine-wide content anyway, so
|
|
@@ -4029,12 +4138,39 @@ async function reportCardCommand(args) {
|
|
|
4029
4138
|
const summary = analyzeSpend(headlineRecords);
|
|
4030
4139
|
const outPath = await resolveReceiptPath(rootPath, args.out);
|
|
4031
4140
|
await mkdir(dirname(outPath), { recursive: true });
|
|
4032
|
-
|
|
4141
|
+
const svg = generateReportCardSvg({
|
|
4033
4142
|
summary,
|
|
4034
4143
|
records: headlineRecords,
|
|
4035
4144
|
mode,
|
|
4036
4145
|
...(providerCoverage ? { providerCoverage } : {})
|
|
4037
|
-
})
|
|
4146
|
+
});
|
|
4147
|
+
await writeSafeStateText(dirname(outPath), basename(outPath), svg);
|
|
4148
|
+
const caption = generateReportCardCaption({
|
|
4149
|
+
summary,
|
|
4150
|
+
records: headlineRecords,
|
|
4151
|
+
mode,
|
|
4152
|
+
...(providerCoverage ? { providerCoverage } : {})
|
|
4153
|
+
});
|
|
4154
|
+
// 0.9.6: the receipt now SHOWS itself. 0.9.5 wrote the SVG and stopped,
|
|
4155
|
+
// leaving the user to go find and open it by hand ("not automatically
|
|
4156
|
+
// showing on a html or opening the file: making it inefficient").
|
|
4157
|
+
//
|
|
4158
|
+
// We auto-open a companion .html rather than the .svg itself: platform
|
|
4159
|
+
// openers hand a .svg to whatever claims that extension, which on a
|
|
4160
|
+
// developer machine is frequently an editor, so "show me my receipt"
|
|
4161
|
+
// could open a wall of XML. An .html is claimed by a browser everywhere.
|
|
4162
|
+
// The .svg remains the canonical shareable artifact; the companion just
|
|
4163
|
+
// renders it next to the caption, embedding both verbatim so it inherits
|
|
4164
|
+
// the card's redaction guarantees exactly.
|
|
4165
|
+
const companionPath = `${outPath.replace(/\.svg$/iu, "")}.html`;
|
|
4166
|
+
await writeSafeStateText(dirname(companionPath), basename(companionPath), generateReceiptCompanionHtml({ svg, caption }));
|
|
4167
|
+
// Same decision function, same suppression matrix, same metacharacter
|
|
4168
|
+
// refusal, same detached launch as `report` — one opener, two commands.
|
|
4169
|
+
const openDecision = (runtime.reportOpenDecide ?? decideReportAutoOpen)({
|
|
4170
|
+
htmlPath: companionPath,
|
|
4171
|
+
noOpenFlag: args.noOpen === true
|
|
4172
|
+
});
|
|
4173
|
+
const openedInBrowser = (runtime.reportOpenLaunch ?? openReportInBrowser)(openDecision);
|
|
4038
4174
|
const dataRow = mode === "demo"
|
|
4039
4175
|
? args.sample
|
|
4040
4176
|
? "DEMO sample data — explicit illustrative mode; no local transcripts or persisted spend state were read"
|
|
@@ -4050,18 +4186,29 @@ async function reportCardCommand(args) {
|
|
|
4050
4186
|
note: "a shareable, redacted spend card (no client/project/user names)",
|
|
4051
4187
|
rows: [
|
|
4052
4188
|
{ label: "Receipt", value: outPath },
|
|
4189
|
+
{ label: "Preview", value: companionPath },
|
|
4053
4190
|
{ label: "Data", value: dataRow },
|
|
4054
4191
|
{ label: "Privacy", value: "rendered locally; only totals, generic candidate categories, and evidence labels are included" }
|
|
4055
4192
|
],
|
|
4056
4193
|
sections: [{
|
|
4057
4194
|
heading: "Caption to share",
|
|
4058
|
-
body: [
|
|
4059
|
-
summary,
|
|
4060
|
-
records: headlineRecords,
|
|
4061
|
-
mode,
|
|
4062
|
-
...(providerCoverage ? { providerCoverage } : {})
|
|
4063
|
-
})]
|
|
4195
|
+
body: [caption]
|
|
4064
4196
|
}],
|
|
4197
|
+
nextSteps: [
|
|
4198
|
+
// Summary-line truth: only a fired opener may claim it opened; every
|
|
4199
|
+
// suppression path keeps the plain copy-pasteable pointer.
|
|
4200
|
+
openedInBrowser
|
|
4201
|
+
? { command: `opened ${basename(companionPath)} in your browser · next time: --no-open to skip` }
|
|
4202
|
+
: {
|
|
4203
|
+
command: shellPathPointer(platformOpenCommand(), companionPath, process.cwd()),
|
|
4204
|
+
description: `view the receipt — or double-click ${basename(companionPath)} in your file manager`
|
|
4205
|
+
},
|
|
4206
|
+
// NOT a command, so it must not LOOK like one: `post ai-receipt.svg`
|
|
4207
|
+
// sitting under "Next" beside two real commands is exactly the trap
|
|
4208
|
+
// that made the founder type a bare `open`. Description-less lines
|
|
4209
|
+
// render verbatim, so this one states a fact instead.
|
|
4210
|
+
{ command: `${basename(outPath)} is the file to share — post that one` }
|
|
4211
|
+
],
|
|
4065
4212
|
color: args.noColor ? false : undefined,
|
|
4066
4213
|
width: terminalOutputWidth()
|
|
4067
4214
|
}));
|
|
@@ -4093,6 +4240,19 @@ async function reportCardCommand(args) {
|
|
|
4093
4240
|
function isBroadScanRoot(requestedPath) {
|
|
4094
4241
|
return broadScanRootKind(requestedPath) !== undefined;
|
|
4095
4242
|
}
|
|
4243
|
+
/**
|
|
4244
|
+
* A project-scoped command, printed so it WORKS from where it was printed.
|
|
4245
|
+
*
|
|
4246
|
+
* The founder's most-repeated complaint class (0.9.4 report, 0.9.6 index, and
|
|
4247
|
+
* these): a surface running machine-wide prints a bare project-scoped command,
|
|
4248
|
+
* the user copies it, and it friendly-refuses in the very directory that
|
|
4249
|
+
* printed it. Commands that genuinely need one project carry their own `cd`
|
|
4250
|
+
* from a broad root — a PATH placeholder, never `<project>`, which the shell
|
|
4251
|
+
* would read as redirection.
|
|
4252
|
+
*/
|
|
4253
|
+
function projectScopedPointer(command, requestedPath) {
|
|
4254
|
+
return isBroadScanRoot(requestedPath) ? `cd /path/to/project && ${command}` : command;
|
|
4255
|
+
}
|
|
4096
4256
|
/** Classifies WHICH broad-root category a machine-wide path falls in. */
|
|
4097
4257
|
function broadScanRootKind(requestedPath) {
|
|
4098
4258
|
const rootPath = resolve(requestedPath);
|
|
@@ -5912,7 +6072,7 @@ async function tokenVerificationCommand(args) {
|
|
|
5912
6072
|
}
|
|
5913
6073
|
const observation = await loadTokenVerificationObservation(rootPath, experiment);
|
|
5914
6074
|
if (!observation.qualitativeCoverageComplete) {
|
|
5915
|
-
return tokenVerificationUsageError("
|
|
6075
|
+
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
6076
|
}
|
|
5917
6077
|
const unlabelled = refreshTokenReductionExperimentV0(experiment, {
|
|
5918
6078
|
sessionVitals: observation.sessionVitals,
|
|
@@ -6066,36 +6226,50 @@ function applyEvidenceAcquisitionLines(input) {
|
|
|
6066
6226
|
return lines;
|
|
6067
6227
|
}
|
|
6068
6228
|
/**
|
|
6069
|
-
* Machine-wide report input
|
|
6070
|
-
*
|
|
6071
|
-
*
|
|
6072
|
-
*
|
|
6229
|
+
* Machine-wide report input — built from {@link loadBroadScanEvidence}, the
|
|
6230
|
+
* SAME call the `--full` readout makes from the same directory (0.9.6).
|
|
6231
|
+
*
|
|
6232
|
+
* 0.9.4 shipped this as a parallel thin builder over `loadInstantReadData`,
|
|
6233
|
+
* keeping only the financial records. Everything that gates an action claim —
|
|
6234
|
+
* the bounded transcript index above all — was dropped, so the report package
|
|
6235
|
+
* fell back to `qualitativeCoverage: "unknown"` and every ACT/VERIFY branch
|
|
6236
|
+
* degraded, while `--full` from the same home showed real ranked candidates.
|
|
6237
|
+
* The evidence is not project-scoped and never was; only the builder was
|
|
6238
|
+
* withholding it.
|
|
6239
|
+
*
|
|
6240
|
+
* No project state is read or created. Everything below comes from the shared
|
|
6241
|
+
* bundle; nothing is re-derived here.
|
|
6073
6242
|
*/
|
|
6074
6243
|
async function buildMachineWideReportInput(args, sinceDays) {
|
|
6075
|
-
const
|
|
6076
|
-
if (records.length === 0) {
|
|
6244
|
+
const evidence = await loadBroadScanEvidence(args, sinceDays);
|
|
6245
|
+
if (evidence.records.length === 0) {
|
|
6077
6246
|
// Same honest empty-state voice the receipt/report-card use — an empty
|
|
6078
6247
|
// report file would just look broken.
|
|
6079
|
-
return { kind: "no_evidence", warnings };
|
|
6248
|
+
return { kind: "no_evidence", warnings: evidence.warnings };
|
|
6080
6249
|
}
|
|
6081
|
-
const
|
|
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;
|
|
6250
|
+
const { records, mode, actionEvidence } = evidence;
|
|
6088
6251
|
return {
|
|
6089
6252
|
kind: "input",
|
|
6090
6253
|
input: {
|
|
6091
6254
|
generatedAt: new Date().toISOString(),
|
|
6092
|
-
summary:
|
|
6255
|
+
summary: evidence.summary,
|
|
6093
6256
|
allRecords: records,
|
|
6094
6257
|
dataMode: mode === "connected" ? "connected_provider" : "local_logs",
|
|
6095
6258
|
evidenceWindowDays: sinceDays,
|
|
6096
|
-
detectedPlans,
|
|
6259
|
+
detectedPlans: evidence.detectedPlans,
|
|
6260
|
+
// The ranked set the readout prints, verbatim — not a second derivation.
|
|
6261
|
+
actionCandidates: evidence.actionCandidates,
|
|
6262
|
+
analysisScope: "machine-wide",
|
|
6263
|
+
// The bounded transcript index the 0.9.4 builder dropped. This is what
|
|
6264
|
+
// un-degrades ACT, VERIFY, plan context, and the configuration section.
|
|
6265
|
+
qualitativeCoverage: summarizeCliQualitativeCoverage(actionEvidence),
|
|
6266
|
+
qualitativeCoverageByAgent: summarizeCliQualitativeCoverageByAgent(actionEvidence),
|
|
6267
|
+
...(evidence.deadContext ? { deadContext: evidence.deadContext } : {}),
|
|
6097
6268
|
...(mode === "connected" ? { providerRecords: records } : {}),
|
|
6098
|
-
...(
|
|
6269
|
+
...(mode === "connected" && (evidence.localFinancialRecords?.length ?? 0) > 0
|
|
6270
|
+
? { localFinancialRecords: evidence.localFinancialRecords }
|
|
6271
|
+
: {}),
|
|
6272
|
+
...(evidence.providerCoverage ? { providerCoverage: evidence.providerCoverage } : {})
|
|
6099
6273
|
}
|
|
6100
6274
|
};
|
|
6101
6275
|
}
|
|
@@ -7149,6 +7323,10 @@ function helpText(telemetryDisclosure) {
|
|
|
7149
7323
|
" report [--sample] [--out <name>] [--since-days N] Generate local Markdown and HTML reports and open the HTML in your browser",
|
|
7150
7324
|
" [--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
7325
|
" report-card [--out f.svg] Write your AI Receipt — a redacted, shareable SVG + caption",
|
|
7326
|
+
// C3: report-card writes TWO files. `--out index.svg` silently replaced an
|
|
7327
|
+
// existing index.html next to it; the second artifact is now disclosed
|
|
7328
|
+
// where the user chooses the name.
|
|
7329
|
+
" [--out f.svg] Also writes the companion viewer f.html alongside it, overwriting any existing file of that name",
|
|
7152
7330
|
" glance [--project <name>] [--plan <id>] [--since-days N] Emit the local, machine-readable Glance snapshot JSON",
|
|
7153
7331
|
" context [--project <name>] [--since-days N] Show hook-aware Context Health in the terminal",
|
|
7154
7332
|
" [--json] Emit the same canonical Context Health object used by MCP and Glance",
|
package/dist/reportOpener.d.ts
CHANGED
|
@@ -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;
|
package/dist/reportOpener.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "0.9.7",
|
|
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.
|
|
59
|
-
"@agent-finops/report": "0.9.
|
|
58
|
+
"@agent-finops/core": "0.9.7",
|
|
59
|
+
"@agent-finops/report": "0.9.7",
|
|
60
60
|
"yocto-spinner": "^1.2.0"
|
|
61
61
|
}
|
|
62
62
|
}
|