ai-spend-agent 0.9.0 → 0.9.2

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
@@ -1,12 +1,15 @@
1
1
  #!/usr/bin/env node
2
+ import { randomUUID } from "node:crypto";
2
3
  import { realpathSync } from "node:fs";
3
- import { mkdir, readFile, rm, stat } from "node:fs/promises";
4
+ import { lstat, mkdir, readdir, readFile, rm, stat } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
6
  import { basename, dirname, extname, join, resolve } from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
7
8
  import { askGuidedQuestion, classifyGuidedAnswer, createInteractivePromptSource, renderForYourAgent } from "./guidedPrompt.js";
9
+ import { assessEmailDeliverability, buildWaitlistRef, normalizeWaitlistEmail, postWaitlistSignup, readSignupState, clearSignupState, sanitizeSignupRefTag, serializeWaitlistPayload, signupCopy, signupStateFilePath, writeSignupState } from "./signup.js";
10
+ import { killTelemetryForThisProcess, readTelemetryState, telemetryDisclosureLine, telemetryStateFilePath, writeTelemetryState } from "./telemetry.js";
8
11
  import { parsePlanDraft, renderCleanExit, runIdentitySequence, runPlanSitting, runQualitySitting, runRecordSitting, runRollbackSitting, runStartSitting, shortSittingHint } from "./improveFlow.js";
9
- import { analyzeSpend, APPROVAL_EVENT_V0_KIND, buildContextHealth, buildActionVerificationProjectionV0, buildProjectEconomicsProjectionV0, buildTokenReductionBaselineV0, aibillCommandV0, aibillImproveCommandV0, 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, 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, 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, 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";
10
13
  import { StatuslineInstallerError, installClaudeStatusline, refreshOwnedStatuslineRunner, uninstallClaudeStatusline } from "./statuslineInstaller.js";
11
14
  import { readStatuslineCache, renderStatusline } from "./statuslineRuntime.js";
12
15
  import { chooseLatestTokenReductionExperiment, loadTokenVerificationState, upsertTokenReductionExperiment } from "./tokenVerificationState.js";
@@ -30,7 +33,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
30
33
  return ok(await cliVersion());
31
34
  }
32
35
  if (argv.includes("--help") || argv.includes("-h") || argv[0] === "help") {
33
- return ok(helpText());
36
+ return ok(helpText(runtime.telemetryDisclosure));
34
37
  }
35
38
  const args = parseArgs(argv);
36
39
  if (args.parseErrors.length > 0) {
@@ -68,7 +71,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
68
71
  return quickstartCommand(args, runtime);
69
72
  }
70
73
  if (args.command === "doctor") {
71
- return doctorCommand(args);
74
+ return doctorCommand(args, runtime);
72
75
  }
73
76
  if (args.command === "reset") {
74
77
  return resetCommand(args);
@@ -79,6 +82,12 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
79
82
  if (args.command === "statusline") {
80
83
  return statuslineCommand(args, runtime);
81
84
  }
85
+ if (args.command === "signup") {
86
+ return signupCommand(args, runtime);
87
+ }
88
+ if (args.command === "telemetry") {
89
+ return telemetryCommand(args, runtime);
90
+ }
82
91
  if (args.command === "scan") {
83
92
  return scanCommand(args);
84
93
  }
@@ -89,7 +98,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
89
98
  return watchCommand(args);
90
99
  }
91
100
  if (args.command === "report") {
92
- return reportCommand(args);
101
+ return reportCommand(args, runtime);
93
102
  }
94
103
  if (args.command === "report-card") {
95
104
  return reportCardCommand(args);
@@ -98,7 +107,7 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
98
107
  return glanceCommand(args);
99
108
  }
100
109
  if (args.command === "context" || args.command === "context-health") {
101
- return contextHealthCommand(args);
110
+ return contextHealthCommand(args, runtime);
102
111
  }
103
112
  if (args.command === "apply-artifact" || args.command === "apply") {
104
113
  return applyArtifactCommand(args);
@@ -133,13 +142,16 @@ export async function runCli(argv = process.argv.slice(2), runtime = {}) {
133
142
  if (args.command === "sync-provider") {
134
143
  return syncProviderCommand(args);
135
144
  }
145
+ if (args.command === "drop-slice") {
146
+ return dropSliceCommand(args);
147
+ }
136
148
  if (args.command === "confirm-mapping") {
137
149
  return confirmMappingCommand(args);
138
150
  }
139
151
  return {
140
152
  exitCode: 1,
141
153
  stdout: "",
142
- stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText()}`
154
+ stderr: `Unknown command: ${sanitizeSecretishError(args.command)}\n${helpText(runtime.telemetryDisclosure)}`
143
155
  };
144
156
  }
145
157
  async function quickstartCommand(args, runtime = {}) {
@@ -153,14 +165,21 @@ async function quickstartCommand(args, runtime = {}) {
153
165
  stderr: `Unknown --plan "${sanitizeSecretishError(args.plan)}". Valid plans: ${subscriptionPlans.map((plan) => plan.id).join(", ")}`
154
166
  };
155
167
  }
156
- const { records, mode, warnings, providerCoverage, codexInvocationFiles, actionEvidence, financialCoverageComplete } = await loadInstantReadData(args);
168
+ const { records, mode, warnings, providerCoverage, codexInvocationFiles, actionEvidence, localFinancialRecords, financialCoverageComplete } = await loadInstantReadData(args);
157
169
  if (records.length === 0) {
158
- return noEvidenceResult("receipt", warnings, sinceDays);
170
+ return noEvidenceResult("receipt", warnings, sinceDays, runtime.telemetryDisclosure);
159
171
  }
160
172
  const summaryRecords = mode === "connected"
161
173
  ? selectProviderFinancialHeadlineRecords(records)
162
174
  : records;
163
175
  const summary = analyzeSpend(summaryRecords);
176
+ // Connected receipts stay billed-primary but never ERASE the estimated
177
+ // axis: local transcript records ride along so subscription rows keep
178
+ // their ~ API-equivalent figures next to billed money (C-lane §1.4). The
179
+ // renderer classifies each record by basis and never blends the totals.
180
+ const receiptRecords = mode === "connected" && (localFinancialRecords?.length ?? 0) > 0
181
+ ? [...summaryRecords, ...localFinancialRecords]
182
+ : summaryRecords;
164
183
  // For real local-log users the by-project view is the flagship table
165
184
  // ("which project burns my plan"); demo/connected keep by-model.
166
185
  const groupBy = args.groupBy ?? (mode === "local-logs" ? "project" : "model");
@@ -241,10 +260,14 @@ async function quickstartCommand(args, runtime = {}) {
241
260
  }).catch(() => undefined)
242
261
  : undefined;
243
262
  const summaryText = generatePlainEnglishSummary(summary, {
244
- records: summaryRecords,
263
+ records: receiptRecords,
245
264
  groupBy,
246
265
  color,
247
266
  mode,
267
+ // Receipt-line truth: with telemetry enabled+noticed the receipt's
268
+ // privacy claim discloses the command counts instead of "nothing
269
+ // uploaded".
270
+ ...(runtime.telemetryDisclosure === true ? { telemetryDisclosureLine } : {}),
248
271
  ...(providerCoverage ? { providerCoverage } : {}),
249
272
  nextSteps,
250
273
  deadContext,
@@ -509,7 +532,7 @@ async function glanceCommand(args) {
509
532
  });
510
533
  return ok(JSON.stringify(snapshot));
511
534
  }
512
- async function contextHealthCommand(args) {
535
+ async function contextHealthCommand(args, runtime = {}) {
513
536
  const sinceDays = args.sinceDays ?? 30;
514
537
  if (!validSinceDays(sinceDays))
515
538
  return invalidSinceDaysResult();
@@ -534,9 +557,9 @@ async function contextHealthCommand(args) {
534
557
  const qualitativeCoverage = summarizeCliQualitativeCoverage(logs);
535
558
  return ok(args.json
536
559
  ? JSON.stringify({ ...health, qualitativeCoverage })
537
- : `${renderContextHealth(health)}\n\n${renderCliQualitativeCoverage(qualitativeCoverage)}`);
560
+ : `${renderContextHealth(health, runtime.telemetryDisclosure)}\n\n${renderCliQualitativeCoverage(qualitativeCoverage)}`);
538
561
  }
539
- function renderContextHealth(health) {
562
+ function renderContextHealth(health, telemetryDisclosure) {
540
563
  const status = health.status.replace("_", " ").toUpperCase();
541
564
  const activation = health.activation;
542
565
  const dead = health.deadContext;
@@ -581,7 +604,7 @@ function renderContextHealth(health) {
581
604
  lines.push(` - ${evidence.summary} [${evidence.confidence}; ${evidence.source}]`);
582
605
  }
583
606
  }
584
- lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.", "Privacy: this CLI run uploads nothing.");
607
+ lines.push("", "Data: local agent configuration + local Claude Code/Codex transcripts; hook commands were not run.", `Privacy: ${uploadsNothingLine(telemetryDisclosure, "this CLI run uploads nothing.")}`);
585
608
  return lines.join("\n");
586
609
  }
587
610
  function quickstartNextSteps(mode, detected) {
@@ -595,10 +618,17 @@ function quickstartNextSteps(mode, detected) {
595
618
  steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
596
619
  }
597
620
  steps.push(mode === "demo"
598
- ? "npx aibill report --sample --path ./demo-workspace write a clearly labeled demo report in an explicitly narrow workspace"
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"
599
624
  : "npx aibill report write a shareable Markdown + HTML report");
600
625
  steps.push("npx aibill --group-by project see which project has the most observed activity");
601
626
  steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
627
+ if (mode === "demo") {
628
+ // Static pointer only — sample output is built for recordings and
629
+ // screenshots, so it never prompts (capture design moments map).
630
+ steps.push(signupCopy.samplePointer);
631
+ }
602
632
  return steps;
603
633
  }
604
634
  async function readPersistedSpend(rootPath, options = {}) {
@@ -714,6 +744,12 @@ async function loadInstantReadData(args) {
714
744
  actionEvidence,
715
745
  codexInvocationFiles: actionEvidence.codexInvocationFiles
716
746
  } : {}),
747
+ // Billed provider records are the headline, but the machine's local
748
+ // API-equivalent evidence is a separate axis the receipt must keep
749
+ // (per-subscription ~ figures) — connected mode never erases it.
750
+ ...(financialLogs && financialLogs.records.length > 0
751
+ ? { localFinancialRecords: financialLogs.records }
752
+ : {}),
717
753
  ...(persisted.providerCoverage ? { providerCoverage: persisted.providerCoverage } : {}),
718
754
  financialCoverageComplete: persisted.providerCoverage === "complete" &&
719
755
  connectedHeadlineRecords.length > 0 &&
@@ -917,7 +953,183 @@ function renderCliQualitativeCoverage(coverage) {
917
953
  `${coverage.readCompletely}/${coverage.selectedFiles} selected files read completely · ` +
918
954
  `${coverage.skippedForBudget} eligible files skipped by budget`;
919
955
  }
920
- function noEvidenceResult(surface, warnings, sinceDays) {
956
+ /**
957
+ * `aibill signup <email> [--ref <token>]` — the explicit, deliberate path to
958
+ * the launch list. Sends EXACTLY {email, ref} to the deployed waitlist route
959
+ * after showing the literal payload JSON and receiving a typed `y` on a real
960
+ * terminal. Never sends without the confirm; never retries, queues, or
961
+ * persists the typed email on failure. `--forget` clears local signup state;
962
+ * `--never` records never-ask without sending anything.
963
+ */
964
+ async function signupCommand(args, runtime) {
965
+ const stateFile = signupStateFilePath(runtime.homeDirectory);
966
+ if (args.signupForget) {
967
+ const cleared = await clearSignupState(stateFile);
968
+ return cleared
969
+ ? ok(signupCopy.forgetLine)
970
+ : { exitCode: 1, stdout: "", stderr: "local signup state could not be cleared; check ~/.aibill permissions" };
971
+ }
972
+ const priorRead = await readSignupState(stateFile);
973
+ const priorAskCount = priorRead.kind === "ok" ? priorRead.state.askCount : 0;
974
+ if (args.signupNever) {
975
+ const written = await writeSignupState(stateFile, { version: 1, status: "never", askCount: priorAskCount });
976
+ return written
977
+ ? ok(signupCopy.neverLine)
978
+ : { exitCode: 1, stdout: "", stderr: "never-ask could not be persisted; check ~/.aibill permissions" };
979
+ }
980
+ if (!args.signupEmail) {
981
+ return {
982
+ exitCode: 1,
983
+ stdout: "",
984
+ stderr: [
985
+ "signup needs an email: npx aibill signup you@work.com [--ref <token>]",
986
+ "Optional: signup --never (never ask again) · signup --forget (clear local signup state)"
987
+ ].join("\n")
988
+ };
989
+ }
990
+ // Consent requires a human at a real terminal; there is deliberately no
991
+ // --yes flag and no non-TTY path (QA 2).
992
+ if (runtime.interactive !== true || !runtime.prompt) {
993
+ return { exitCode: 1, stdout: "", stderr: signupCopy.nonInteractiveLine };
994
+ }
995
+ const email = normalizeWaitlistEmail(args.signupEmail);
996
+ if (email === undefined) {
997
+ return { exitCode: 1, stdout: "", stderr: signupCopy.invalidEmailLine };
998
+ }
999
+ // Deliverability (MX with A fallback, 1.5s budget, fail-open on DNS
1000
+ // trouble): only a provable cannot-receive domain or a throwaway inbox is
1001
+ // refused — never a slow or offline resolver.
1002
+ const deliverability = await assessEmailDeliverability(email, {
1003
+ ...(runtime.signupDns ? { resolver: runtime.signupDns } : {})
1004
+ });
1005
+ if (deliverability === "no_mx") {
1006
+ return { exitCode: 1, stdout: "", stderr: signupCopy.noMxLine };
1007
+ }
1008
+ if (deliverability === "disposable") {
1009
+ return { exitCode: 1, stdout: "", stderr: signupCopy.disposableLine };
1010
+ }
1011
+ if (priorRead.kind === "ok" && priorRead.state.status === "subscribed" && priorRead.state.email === email) {
1012
+ // Cosmetic-local dedupe; the route itself is idempotent (201 on duplicate).
1013
+ return ok(signupCopy.alreadyLine);
1014
+ }
1015
+ const payload = { email, ref: buildWaitlistRef("signup", args.signupRef) };
1016
+ const consent = (await runtime.prompt(`${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`)).trim().toLowerCase();
1017
+ if (consent !== "y" && consent !== "yes") {
1018
+ return ok(signupCopy.nothingSentLine);
1019
+ }
1020
+ const outcome = await postWaitlistSignup(payload, { fetchImpl: runtime.waitlistFetch });
1021
+ if (outcome === "sent") {
1022
+ await writeSignupState(stateFile, { version: 1, status: "subscribed", askCount: priorAskCount, email });
1023
+ return ok(signupCopy.sentLine);
1024
+ }
1025
+ return {
1026
+ exitCode: 1,
1027
+ stdout: "",
1028
+ stderr: outcome === "invalid_email"
1029
+ ? signupCopy.invalidEmailLine
1030
+ : outcome === "rate_limited"
1031
+ ? signupCopy.rateLimitedLine
1032
+ : signupCopy.unreachableLine
1033
+ };
1034
+ }
1035
+ /**
1036
+ * `aibill telemetry [on|off]` — inspect or switch anonymous command-count
1037
+ * telemetry. Status shows the EXACT last payload verbatim; state is
1038
+ * fail-closed (corrupt/readonly ⇒ off).
1039
+ */
1040
+ async function telemetryCommand(args, runtime) {
1041
+ const filePath = telemetryStateFilePath(runtime.homeDirectory);
1042
+ const read = await readTelemetryState(filePath);
1043
+ const action = args.telemetryAction;
1044
+ if (action !== undefined && action !== "on" && action !== "off") {
1045
+ return {
1046
+ exitCode: 1,
1047
+ stdout: "",
1048
+ stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: aibill telemetry [on|off]`
1049
+ };
1050
+ }
1051
+ if (action === "off") {
1052
+ const state = {
1053
+ version: 1,
1054
+ installId: read.kind === "ok" ? read.state.installId : randomUUID(),
1055
+ enabled: false,
1056
+ ...(read.kind === "ok" && read.state.noticedAt !== undefined ? { noticedAt: read.state.noticedAt } : {}),
1057
+ ...(read.kind === "ok" && read.state.lastPayload !== undefined ? { lastPayload: read.state.lastPayload } : {})
1058
+ };
1059
+ if (!await writeTelemetryState(filePath, state)) {
1060
+ // FAIL CLOSED (QA M1): the off could not be persisted and the stored
1061
+ // state may still say enabled — silence THIS process and point at the
1062
+ // env kill-switches for a durable off.
1063
+ killTelemetryForThisProcess();
1064
+ return {
1065
+ exitCode: 1,
1066
+ stdout: "",
1067
+ stderr: [
1068
+ "telemetry off could not be persisted — nothing more will be sent by this run.",
1069
+ "For a durable off, set AI_SPEND_NO_TELEMETRY=1 or DO_NOT_TRACK=1, then fix ~/.aibill permissions."
1070
+ ].join("\n")
1071
+ };
1072
+ }
1073
+ killTelemetryForThisProcess();
1074
+ return ok("telemetry off · nothing is sent");
1075
+ }
1076
+ if (action === "on") {
1077
+ // Turning telemetry on explicitly IS the notice moment: the user is
1078
+ // reading this command's output, so noticedAt is stamped now and
1079
+ // events begin on the next run.
1080
+ const state = {
1081
+ version: 1,
1082
+ installId: read.kind === "ok" ? read.state.installId : randomUUID(),
1083
+ enabled: true,
1084
+ noticedAt: new Date().toISOString(),
1085
+ ...(read.kind === "ok" && read.state.lastPayload !== undefined ? { lastPayload: read.state.lastPayload } : {})
1086
+ };
1087
+ if (!await writeTelemetryState(filePath, state)) {
1088
+ return {
1089
+ exitCode: 1,
1090
+ stdout: "",
1091
+ stderr: "telemetry state could not be written — telemetry remains off (unreadable state fails closed)"
1092
+ };
1093
+ }
1094
+ return ok([
1095
+ "telemetry on · anonymous command counts only",
1096
+ `counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp`,
1097
+ "never: arguments, paths, file contents, project names, or your email",
1098
+ "events start with your next run · see payloads anytime: aibill telemetry"
1099
+ ].join("\n"));
1100
+ }
1101
+ const lines = ["aibill telemetry"];
1102
+ if (read.kind === "unreadable") {
1103
+ lines.push("status: off (state unreadable — telemetry fails closed)");
1104
+ }
1105
+ else if (read.kind === "fresh") {
1106
+ lines.push("status: not yet noticed · nothing has ever been sent");
1107
+ lines.push("a one-time notice prints after your next interactive run; events begin only after it");
1108
+ }
1109
+ else if (!read.state.enabled) {
1110
+ lines.push("status: off · nothing is sent");
1111
+ }
1112
+ else if (read.state.noticedAt === undefined) {
1113
+ lines.push("status: on but not yet noticed · nothing has been sent");
1114
+ }
1115
+ else {
1116
+ lines.push(`status: on · noticed ${read.state.noticedAt}`);
1117
+ }
1118
+ lines.push("counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp", "never: arguments, paths, file contents, project names, or your email");
1119
+ if (read.kind === "ok" && read.state.lastPayload !== undefined) {
1120
+ lines.push("last payload sent (verbatim):", read.state.lastPayload);
1121
+ }
1122
+ else {
1123
+ lines.push("last payload sent: none");
1124
+ }
1125
+ lines.push("switch: aibill telemetry on · aibill telemetry off");
1126
+ return ok(lines.join("\n"));
1127
+ }
1128
+ /** The run-level privacy claim: literal truth in both telemetry states. */
1129
+ function uploadsNothingLine(telemetryDisclosure, original) {
1130
+ return telemetryDisclosure === true ? telemetryDisclosureLine : original;
1131
+ }
1132
+ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
921
1133
  const surfaceLine = surface === "watch"
922
1134
  ? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
923
1135
  : surface === "report-card"
@@ -931,11 +1143,12 @@ function noEvidenceResult(surface, warnings, sinceDays) {
931
1143
  "",
932
1144
  surfaceLine,
933
1145
  "Looked for: Claude Code, Codex, and Gemini CLI local history.",
934
- "Nothing was uploaded. No sample data was substituted.",
1146
+ `${uploadsNothingLine(telemetryDisclosure, "Nothing was uploaded.")} No sample data was substituted.`,
935
1147
  ...warnings.map((warning) => `! ${warning}`),
936
1148
  "",
937
1149
  "Next",
938
- " npx aibill doctor --sources see the exact evidence gap and setup paths"
1150
+ " npx aibill doctor --sources see the exact evidence gap and setup paths",
1151
+ ` ${signupCopy.receiptPointer}`
939
1152
  ].join("\n")
940
1153
  : "",
941
1154
  stderr: surface === "receipt"
@@ -992,7 +1205,7 @@ function dataModeBanner(mode, records) {
992
1205
  return "DATA MODE: connected provider billing";
993
1206
  return "DATA MODE: demo sample (illustrative — not your real spend)";
994
1207
  }
995
- async function doctorCommand(args) {
1208
+ async function doctorCommand(args, runtime = {}) {
996
1209
  if (args.sources) {
997
1210
  return doctorSourcesCommand(args);
998
1211
  }
@@ -1057,7 +1270,9 @@ async function doctorCommand(args) {
1057
1270
  "aibill doctor",
1058
1271
  `node version: ${process.version}`,
1059
1272
  `cli version: ${await cliVersion()}`,
1060
- "local-first mode: enabled (no cloud upload, no telemetry)",
1273
+ runtime.telemetryDisclosure === true
1274
+ ? "local-first mode: enabled (evidence stays local · anonymous command counts shared · aibill telemetry off)"
1275
+ : "local-first mode: enabled (no cloud upload, no telemetry)",
1061
1276
  `path: ${rootPath}`,
1062
1277
  `state directory: ${stateDir}`,
1063
1278
  `state mode: ${stateMode}`,
@@ -1171,10 +1386,22 @@ async function doctorSourcesCommand(args) {
1171
1386
  .sort((left, right) => right.approvedAt.localeCompare(left.approvedAt))[0];
1172
1387
  const stateCheckedAt = attempt?.checkedAt
1173
1388
  ?? (providerState.provider === id ? providerState.fetchedAt : undefined);
1389
+ // Multi-account honesty: when this provider's records carry account
1390
+ // slices (one Admin key covers ONE organization), name every slice so a
1391
+ // second org's sync is visibly accumulated rather than silently merged.
1392
+ const accountSliceNote = records.some((record) => typeof record.source.account === "string")
1393
+ ? ` Account slices: ${formatProviderAccountSlices(providerAccountSlices(records, id))}.`
1394
+ : "";
1395
+ // QA M1: two slices holding identical records are almost certainly one
1396
+ // organization synced under two references — the combined total counts
1397
+ // it twice, so doctor must say so where the slices are listed.
1398
+ const duplicateSliceNote = duplicateProviderAccountSliceWarnings(records, id)
1399
+ .map((warning) => ` WARNING: ${warning}`)
1400
+ .join("");
1174
1401
  observations.push({
1175
1402
  id,
1176
1403
  financialEvidence: evidence,
1177
- financialEvidenceNote: providerFinancialEvidenceNote(records, evidence),
1404
+ financialEvidenceNote: `${providerFinancialEvidenceNote(records, evidence)}${accountSliceNote}${duplicateSliceNote}`,
1178
1405
  // A connector stub is only configuration, not a source check. Older
1179
1406
  // successful syncs predate source-status.json, so their non-missing
1180
1407
  // registry approval time is the conservative migration fallback.
@@ -1604,7 +1831,9 @@ async function installStatuslineCommand(args, runtime) {
1604
1831
  ? "aibill statusline is already installed in Claude user settings."
1605
1832
  : "aibill statusline installed in Claude user settings.",
1606
1833
  "Claude Code: run /status to verify the active setting and every managed source.",
1607
- "The renderer reads only the private aibill cache; it never reads Claude's session stdin as financial evidence."
1834
+ "The renderer reads only the private aibill cache; it never reads Claude's session stdin as financial evidence.",
1835
+ // Static pointer only — never a prompt (capture design moments map).
1836
+ signupCopy.statuslinePointer
1608
1837
  ].join("\n"));
1609
1838
  }
1610
1839
  catch (error) {
@@ -1733,7 +1962,8 @@ async function initCommand(args, runtime = {}) {
1733
1962
  trustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted === true,
1734
1963
  untrustedProviderState: persisted?.mode === "connected_provider" && persisted.connectedTrust?.trusted !== true,
1735
1964
  scanError,
1736
- cacheStatus
1965
+ cacheStatus,
1966
+ ...(runtime.telemetryDisclosure !== undefined ? { telemetryDisclosure: runtime.telemetryDisclosure } : {})
1737
1967
  });
1738
1968
  if (!args.statusline) {
1739
1969
  return ok(`${receipt}\noptional Claude Code status line: npx aibill statusline install`);
@@ -2170,15 +2400,37 @@ function expansionFreshness(snapshot, now) {
2170
2400
  : seconds < 48 * 3_600
2171
2401
  ? `${Math.floor(seconds / 3_600)}h`
2172
2402
  : `${Math.floor(seconds / 86_400)}d`;
2173
- return ageMs > 5 * 60 * 1_000 ? `stale ${age}` : `updated ${age}`;
2403
+ // Same freshness vocabulary as the statusline runner: state the cache age
2404
+ // as fact instead of calling minutes-old data "stale".
2405
+ return ageMs > 5 * 60 * 1_000 ? `cache ${age} old` : `updated ${age}`;
2174
2406
  }
2175
2407
  async function preflightInitCache(cacheDirectory) {
2176
2408
  const existing = await readActivitySnapshot({ cacheDirectory });
2177
2409
  if (existing.status !== "error")
2178
2410
  return;
2411
+ // A pre-created but EMPTY cache directory holds nothing to preserve —
2412
+ // typically a user-made dir with default (non-0700) permissions. Init's
2413
+ // own create path re-validates and tightens it to 0700, so proceeding is
2414
+ // safe; only a directory with actual contents aborts (shipped-audit fix).
2415
+ if (existing.code === "unsafe_directory" && await isEmptyRealDirectory(dirname(activitySnapshotCachePath({
2416
+ ...(cacheDirectory ? { cacheDirectory } : {})
2417
+ })))) {
2418
+ return;
2419
+ }
2179
2420
  throw new Error(`Existing private activity cache is ${existing.code.replaceAll("_", " ")}; ` +
2180
2421
  "it was preserved and init stopped. Remove the cache explicitly before rebuilding it.");
2181
2422
  }
2423
+ async function isEmptyRealDirectory(path) {
2424
+ try {
2425
+ const info = await lstat(path);
2426
+ if (info.isSymbolicLink() || !info.isDirectory())
2427
+ return false;
2428
+ return (await readdir(path)).length === 0;
2429
+ }
2430
+ catch {
2431
+ return false;
2432
+ }
2433
+ }
2182
2434
  async function preserveOrCreateInitRegistry(stateDir, rootPath, asOf, existing) {
2183
2435
  if (existing) {
2184
2436
  // Leave the valid contract byte-for-byte alone. Rewriting would drop
@@ -2389,9 +2641,12 @@ function formatInitReceipt(input) {
2389
2641
  ...diagnosticLines,
2390
2642
  input.scanError ? ` ! scan failed: ${input.scanError}` : "",
2391
2643
  "",
2392
- `status cache: ${input.cacheStatus} · private local aggregate · nothing uploaded`,
2644
+ `status cache: ${input.cacheStatus} · private local aggregate · ${uploadsNothingLine(input.telemetryDisclosure, "nothing uploaded")}`,
2393
2645
  "state: .ai-spend-agent",
2394
2646
  "manifest: written last",
2647
+ // Static pointer only, ABOVE the strict single next-command exit line —
2648
+ // init's `next:` line stays last (capture design moments map).
2649
+ signupCopy.initPointer,
2395
2650
  "next: npx aibill doctor --sources"
2396
2651
  ].filter((line) => line !== "").join("\n");
2397
2652
  }
@@ -2844,7 +3099,9 @@ async function listSourcesCommand(args) {
2844
3099
  // not first-run blockers.
2845
3100
  const selfServeProviders = new Set(["openai", "anthropic"]);
2846
3101
  const adminUpgradeProviders = {
2847
- cursor: "requires a Cursor TEAM-ADMIN key (Business plan only)",
3102
+ // Cursor's Admin API is gated to Enterprise teams (cursor.com/docs/api);
3103
+ // Business/Teams keys are rejected with 401/403.
3104
+ cursor: "requires a Cursor TEAM-ADMIN key (Enterprise teams only)",
2848
3105
  "github-copilot": "requires a GitHub BILLING-ADMIN token (org/enterprise)",
2849
3106
  copilot: "requires a GitHub BILLING-ADMIN token (org/enterprise)"
2850
3107
  };
@@ -2866,7 +3123,8 @@ function providerSyncSetupCommand(provider, adminRef) {
2866
3123
  if (provider === "github-copilot") {
2867
3124
  return `npx aibill sync-provider --provider github-copilot --auth-reference ${adminRef} --org <organization>`;
2868
3125
  }
2869
- return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time <unix>`;
3126
+ const thirtyDaysAgoUnix = Math.floor(Date.now() / 1_000) - 30 * 24 * 60 * 60;
3127
+ return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time ${thirtyDaysAgoUnix}`;
2870
3128
  }
2871
3129
  async function connectCommand(args) {
2872
3130
  const rootPath = resolve(args.path);
@@ -2954,6 +3212,10 @@ async function connectCommand(args) {
2954
3212
  lines.push("");
2955
3213
  lines.push(`next: export an admin key reference, e.g. ${adminRef}, then run:`);
2956
3214
  lines.push(` ${providerSyncSetupCommand(provider, adminRef)}`);
3215
+ lines.push(" (that start time is 30 days ago; change it to widen the window)");
3216
+ }
3217
+ if (provider === "openai") {
3218
+ lines.push("multi-org: an Admin API key covers ONE organization; repeat the sync with a separate env reference per org (e.g. env:OPENAI_ADMIN_KEY_ORG2) — org totals accumulate");
2957
3219
  }
2958
3220
  lines.push(`missing: ${source.fieldsMissing.join(", ")}`);
2959
3221
  return ok(lines.join("\n"));
@@ -3064,7 +3326,18 @@ async function syncProviderCommand(args) {
3064
3326
  enterprise: args.enterprise,
3065
3327
  accountId: args.accountId
3066
3328
  });
3067
- const syncedRecords = applyProviderContractGate(result.records);
3329
+ // Admin credentials are account-scoped (an OpenAI Admin key covers ONE
3330
+ // organization). Each sync belongs to one account slice: different slices
3331
+ // of the same provider accumulate, re-syncing the same slice replaces it,
3332
+ // and unlabeled legacy rows are replaced fail-closed (never double-count).
3333
+ const accountKey = providerAccountKey({
3334
+ provider,
3335
+ authReference: args.authReference,
3336
+ org: args.org,
3337
+ enterprise: args.enterprise,
3338
+ accountId: args.accountId
3339
+ });
3340
+ const syncedRecords = tagProviderAccountRecords(applyProviderContractGate(result.records), accountKey);
3068
3341
  const syncedFinancials = summarizeProviderFinancials(syncedRecords);
3069
3342
  const syncedCompleteness = providerFinancialCompleteness(syncedRecords, result.coverage);
3070
3343
  const syncedSource = createProviderConnection({
@@ -3076,8 +3349,9 @@ async function syncProviderCommand(args) {
3076
3349
  completeness: syncedCompleteness,
3077
3350
  fetchedAt: new Date(result.fetchedAt)
3078
3351
  });
3352
+ const retainedPriorRecords = retainProviderRecordsForNewSync(trustedPrior?.records ?? [], result.provider, accountKey, syncedRecords);
3079
3353
  const records = applyProviderContractGate([
3080
- ...(trustedPrior?.records ?? []).filter((record) => record.source.provider !== result.provider),
3354
+ ...retainedPriorRecords,
3081
3355
  ...syncedRecords
3082
3356
  ]).sort((left, right) => left.timestamp.localeCompare(right.timestamp));
3083
3357
  const registry = await readSourceRegistry(stateDir, rootPath);
@@ -3085,27 +3359,59 @@ async function syncProviderCommand(args) {
3085
3359
  const headlineRecords = selectProviderFinancialHeadlineRecords(records);
3086
3360
  const summary = analyzeSpend(headlineRecords);
3087
3361
  const mappings = attributeUsageRecords(records);
3362
+ // Whether prior account slices of THIS provider survived the merge. When
3363
+ // they did, the provider-keyed accounting maps below must stay honest for
3364
+ // the union of slices, not just the slice this run fetched.
3365
+ const retainedSameProviderSlices = retainedPriorRecords.some((record) => record.source.provider === result.provider);
3088
3366
  const qaByProvider = {
3089
3367
  ...trustedAccountingMap(trustedPrior?.accounting, "qaByProvider"),
3090
3368
  [result.provider]: result.qa
3091
3369
  };
3370
+ const priorProviderCoverage = trustedAccountingMap(trustedPrior?.accounting, "coverageByProvider")[result.provider];
3371
+ // Fail-closed coverage: a provider is complete only when this sync AND
3372
+ // every retained slice were complete. Known ratchet (QA m4): per-slice
3373
+ // coverage is not persisted, so with other slices retained a prior
3374
+ // "partial" sticks even after the offending slice re-syncs complete —
3375
+ // it only UNDER-claims, and recovers when the provider merges with no
3376
+ // other slices retained (single-slice re-sync), after `aibill
3377
+ // drop-slice` removes the stale slice, or after `aibill reset`. The
3378
+ // checkedAt merge below shares the same ratchet and recovery direction.
3379
+ const mergedProviderCoverage = retainedSameProviderSlices && priorProviderCoverage === "partial"
3380
+ ? "partial"
3381
+ : result.coverage;
3092
3382
  const coverageByProvider = {
3093
3383
  ...trustedAccountingMap(trustedPrior?.accounting, "coverageByProvider"),
3094
- [result.provider]: result.coverage
3384
+ [result.provider]: mergedProviderCoverage
3095
3385
  };
3386
+ // Financials span every retained slice of the provider plus this sync —
3387
+ // never just the account this run happened to fetch.
3096
3388
  const financialsByProvider = {
3097
3389
  ...trustedAccountingMap(trustedPrior?.accounting, "financialsByProvider"),
3098
- [result.provider]: syncedFinancials
3390
+ [result.provider]: summarizeProviderFinancials(records.filter((record) => record.source.provider === result.provider))
3099
3391
  };
3392
+ const priorProviderCheckedAt = trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider")[result.provider];
3393
+ // Freshness stays conservative: a retained slice keeps its older check
3394
+ // time, so an old account slice is never claimed as freshly checked.
3395
+ const mergedProviderCheckedAt = retainedSameProviderSlices &&
3396
+ typeof priorProviderCheckedAt === "string" &&
3397
+ validIsoString(priorProviderCheckedAt) &&
3398
+ priorProviderCheckedAt < result.fetchedAt
3399
+ ? priorProviderCheckedAt
3400
+ : result.fetchedAt;
3100
3401
  const checkedAtByProvider = {
3101
3402
  ...trustedAccountingMap(trustedPrior?.accounting, "checkedAtByProvider"),
3102
- [result.provider]: result.fetchedAt
3403
+ [result.provider]: mergedProviderCheckedAt
3103
3404
  };
3104
3405
  const priorCoverageIntervals = trustedAccountingMap(trustedPrior?.accounting, "coverageIntervalsByProvider");
3105
3406
  const coverageIntervalsByProvider = Object.fromEntries(Object.entries(priorCoverageIntervals).filter(([provider]) => provider !== result.provider));
3106
3407
  const requestedCoverageInterval = result.coverageInterval;
3107
- if (requestedCoverageInterval) {
3108
- coverageIntervalsByProvider[result.provider] = requestedCoverageInterval;
3408
+ // The provider's claimed window must hold for EVERY retained slice, so it
3409
+ // shrinks to the intersection — and disappears when slices do not overlap.
3410
+ const mergedProviderInterval = retainedSameProviderSlices
3411
+ ? intersectProviderCoverageIntervals(priorCoverageIntervals[result.provider], requestedCoverageInterval)
3412
+ : requestedCoverageInterval;
3413
+ if (mergedProviderInterval) {
3414
+ coverageIntervalsByProvider[result.provider] = mergedProviderInterval;
3109
3415
  }
3110
3416
  // Invalidate any earlier receipt before the first mutation. If a later
3111
3417
  // local write fails, the partially updated repository state stays
@@ -3157,6 +3463,22 @@ async function syncProviderCommand(args) {
3157
3463
  `financial evidence: ${syncedSource.financialEvidence}`,
3158
3464
  `coverage: ${result.coverage}`,
3159
3465
  `records fetched: ${result.records.length}`,
3466
+ `account: ${accountKey}`,
3467
+ `provider accounts: ${formatProviderAccountSlices(providerAccountSlices(records, result.provider))}`,
3468
+ // QA M2: billed dollars never disappear without a word — every dropped
3469
+ // prior slice is named with its record count and billed sum.
3470
+ ...providerSliceReplacementNotices({
3471
+ provider: result.provider,
3472
+ accountKey,
3473
+ priorRecords: trustedPrior?.records ?? [],
3474
+ retainedRecords: retainedPriorRecords,
3475
+ syncedRecordCount: syncedRecords.length,
3476
+ syncedBilledUsd: syncedFinancials.providerReportedBilledUsd
3477
+ }).map((notice) => `notice: ${notice}`),
3478
+ // QA M1: identical inner record ids across two named slices are the
3479
+ // signature of one organization synced under two references.
3480
+ ...duplicateProviderAccountSliceWarnings(records, result.provider)
3481
+ .map((warning) => `warning: ${warning}`),
3160
3482
  provider === "cursor"
3161
3483
  ? "source window: current team subscription cycle returned by Cursor"
3162
3484
  : provider === "github-copilot"
@@ -3185,6 +3507,130 @@ async function syncProviderCommand(args) {
3185
3507
  };
3186
3508
  }
3187
3509
  }
3510
+ /**
3511
+ * `aibill drop-slice --provider X --account KEY` — remove one named account
3512
+ * slice from trusted connected state. This is the prune path for the
3513
+ * duplicate-slice diagnostic (one organization synced under two references)
3514
+ * and for a slice whose credential reference was renamed: without it the only
3515
+ * cleanup is a full `aibill reset` plus re-sync of every org. Local-only; no
3516
+ * provider is contacted; the re-signed state can only shrink totals.
3517
+ */
3518
+ async function dropSliceCommand(args) {
3519
+ const rootPath = resolve(args.path);
3520
+ const requestedProvider = (args.provider ?? "").trim().toLowerCase();
3521
+ const provider = providerAliases[requestedProvider] ?? requestedProvider;
3522
+ const accountKey = (args.account ?? "").trim();
3523
+ if (!provider || !supportedAdminProviders.has(provider) || !accountKey) {
3524
+ return {
3525
+ exitCode: 1,
3526
+ stdout: "",
3527
+ stderr: [
3528
+ "drop-slice requires --provider (openai, anthropic, cursor, github-copilot) and --account <slice key>.",
3529
+ "List the slices first: npx aibill doctor --sources",
3530
+ "example: npx aibill drop-slice --provider openai --account env:OPENAI_ADMIN_KEY_ORG2"
3531
+ ].join("\n")
3532
+ };
3533
+ }
3534
+ const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
3535
+ const persisted = await readPersistedSpend(rootPath);
3536
+ if (!persisted || persisted.mode !== "connected_provider" || persisted.connectedTrust?.trusted !== true) {
3537
+ return {
3538
+ exitCode: 1,
3539
+ stdout: "",
3540
+ stderr: "drop-slice requires trusted connected provider state. Run `npx aibill sync-provider ...` first, or `npx aibill reset` to clear all local state."
3541
+ };
3542
+ }
3543
+ const droppedRecords = persisted.records.filter((record) => (record.source.provider === provider && record.source.account === accountKey));
3544
+ if (droppedRecords.length === 0) {
3545
+ const slices = providerAccountSlices(persisted.records, provider);
3546
+ return {
3547
+ exitCode: 1,
3548
+ stdout: "",
3549
+ stderr: [
3550
+ `No ${provider} slice matches account "${sanitizeSecretishError(accountKey)}".`,
3551
+ slices.length > 0
3552
+ ? `Known ${provider} slices: ${formatProviderAccountSlices(slices)}`
3553
+ : `No ${provider} slices exist in local state.`,
3554
+ "Unlabeled legacy rows cannot be dropped by account; re-syncing the provider replaces them."
3555
+ ].join("\n")
3556
+ };
3557
+ }
3558
+ const records = persisted.records.filter((record) => !(record.source.provider === provider && record.source.account === accountKey));
3559
+ const summary = analyzeSpend(selectProviderFinancialHeadlineRecords(records));
3560
+ const mappings = attributeUsageRecords(records);
3561
+ const providerRemaining = records.filter((record) => record.source.provider === provider);
3562
+ // Rebuild the provider-keyed accounting maps. Financials are recomputed
3563
+ // over the remaining slices; coverage/checkedAt/intervals are left as-is —
3564
+ // they can only UNDER-claim after a drop (partial stays partial, windows
3565
+ // stay narrow) and recover on the provider's next sync. A provider with no
3566
+ // remaining records loses its map entries entirely.
3567
+ const accounting = isPlainObject(persisted.accounting)
3568
+ ? { ...persisted.accounting }
3569
+ : {};
3570
+ for (const mapName of [
3571
+ "coverageByProvider",
3572
+ "checkedAtByProvider",
3573
+ "coverageIntervalsByProvider",
3574
+ "qaByProvider",
3575
+ "financialsByProvider"
3576
+ ]) {
3577
+ const prior = trustedAccountingMap(persisted.accounting, mapName);
3578
+ if (Object.keys(prior).length === 0)
3579
+ continue;
3580
+ const next = { ...prior };
3581
+ if (providerRemaining.length === 0) {
3582
+ delete next[provider];
3583
+ }
3584
+ else if (mapName === "financialsByProvider") {
3585
+ next[provider] = summarizeProviderFinancials(providerRemaining);
3586
+ }
3587
+ if (Object.keys(next).length > 0)
3588
+ accounting[mapName] = next;
3589
+ else
3590
+ delete accounting[mapName];
3591
+ }
3592
+ const droppedFinancials = summarizeProviderFinancials(droppedRecords);
3593
+ const droppedBilled = droppedFinancials.providerReportedBilledUsd;
3594
+ await invalidateConnectedSpendTrustReceipt(rootPath);
3595
+ await writeLocalSpendState(stateDir, records, summary, mappings, "connected_provider", accounting, persisted.checkedAt ?? new Date().toISOString());
3596
+ // Keep provider-records.json consistent with the receipt-bound spend state
3597
+ // (same records + maps); a missing file is tolerable — spend.json is the
3598
+ // receipt-bound truth.
3599
+ try {
3600
+ const providerFile = await readJson(join(stateDir, "provider-records.json"));
3601
+ await writeJson(join(stateDir, "provider-records.json"), {
3602
+ ...providerFile,
3603
+ records,
3604
+ ...(accounting.qaByProvider !== undefined ? { qaByProvider: accounting.qaByProvider } : {}),
3605
+ ...(accounting.checkedAtByProvider !== undefined ? { checkedAtByProvider: accounting.checkedAtByProvider } : {}),
3606
+ ...(accounting.coverageByProvider !== undefined ? { coverageByProvider: accounting.coverageByProvider } : {}),
3607
+ ...(accounting.coverageIntervalsByProvider !== undefined
3608
+ ? { coverageIntervalsByProvider: accounting.coverageIntervalsByProvider }
3609
+ : {}),
3610
+ ...(accounting.financialsByProvider !== undefined ? { financialsByProvider: accounting.financialsByProvider } : {})
3611
+ });
3612
+ }
3613
+ catch {
3614
+ // Diagnostic mirror only; never block the drop on it.
3615
+ }
3616
+ await appendAuditEvent(stateDir, {
3617
+ timestamp: new Date().toISOString(),
3618
+ action: "source_scanned",
3619
+ sourceId: `${provider}-provider-api`,
3620
+ detail: `drop-slice removed the ${provider} account slice "${accountKey}" (${droppedRecords.length} record(s), ${droppedBilled === null ? "no billed evidence" : `billed ${formatOptionalUsd(droppedBilled)}`}). Local state maintenance only; no provider was contacted.`
3621
+ });
3622
+ await writeConnectedSpendTrustReceipt(rootPath, await readSafeStateText(stateDir, "spend.json"), { sourceRegistryContents: await readSafeStateText(stateDir, "sources.json") });
3623
+ const remainingSlices = providerAccountSlices(records, provider);
3624
+ return ok([
3625
+ "aibill drop-slice",
3626
+ `provider: ${provider}`,
3627
+ `dropped account: ${accountKey}`,
3628
+ `records removed: ${droppedRecords.length} (${droppedBilled === null ? "no billed evidence" : `billed ${formatOptionalUsd(droppedBilled)}`})`,
3629
+ `remaining provider accounts: ${remainingSlices.length > 0 ? formatProviderAccountSlices(remainingSlices) : "none"}`,
3630
+ `combined headline spend: ${selectProviderFinancialHeadlineRecords(records).some((record) => typeof record.amountUsd === "number") ? formatOptionalUsd(summary.totalUsd) : "unavailable"}`,
3631
+ "note: coverage and freshness labels stay conservative until the provider is re-synced"
3632
+ ].join("\n"));
3633
+ }
3188
3634
  function formatOptionalUsd(value) {
3189
3635
  if (value === null)
3190
3636
  return "unavailable";
@@ -3252,7 +3698,7 @@ async function confirmMappingCommand(args) {
3252
3698
  `confidence: ${mapping.confidence}`
3253
3699
  ].join("\n"));
3254
3700
  }
3255
- async function reportCommand(args) {
3701
+ async function reportCommand(args, runtime = {}) {
3256
3702
  const rootPath = resolve(args.path);
3257
3703
  try {
3258
3704
  const sinceDays = args.sinceDays ?? 30;
@@ -3289,9 +3735,11 @@ async function reportCommand(args) {
3289
3735
  const reportExperimentProjection = reportableExperiment
3290
3736
  ? buildActionVerificationProjectionV0(reportableExperiment)
3291
3737
  : undefined;
3738
+ const telemetryDisclosure = runtime.telemetryDisclosure === true;
3292
3739
  const reportRenderInput = reportableExperiment
3293
3740
  ? {
3294
3741
  ...reportInput,
3742
+ telemetryDisclosure,
3295
3743
  tokenExperiment: {
3296
3744
  id: reportableExperiment.id,
3297
3745
  lifecycle: reportableExperiment.lifecycle,
@@ -3301,7 +3749,7 @@ async function reportCommand(args) {
3301
3749
  nextCommand: improveRuntimeCommand
3302
3750
  }
3303
3751
  }
3304
- : reportInput;
3752
+ : { ...reportInput, telemetryDisclosure };
3305
3753
  const qualitativeActionsSuppressed = reportInput.dataMode !== "sample" &&
3306
3754
  reportInput.qualitativeCoverage?.status !== "complete";
3307
3755
  const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
@@ -3352,7 +3800,9 @@ async function reportCommand(args) {
3352
3800
  !(reportInput.allRecords ?? reportInput.providerRecords ?? []).some((record) => typeof record.amountUsd === "number")
3353
3801
  ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
3354
3802
  : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
3355
- "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3803
+ runtime.telemetryDisclosure === true
3804
+ ? "privacy: report rendered locally · anonymous command counts shared · aibill telemetry off; only explicit sync-provider contacts the selected provider"
3805
+ : "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3356
3806
  "",
3357
3807
  "next:",
3358
3808
  ` open ${htmlPath} view the full report in your browser`,
@@ -3497,23 +3947,121 @@ async function guardExactProjectRoot(commandName, requestedPath) {
3497
3947
  ].join("\n")
3498
3948
  };
3499
3949
  }
3950
+ /* ------------------------------------------------------------------ */
3951
+ /* Agent-draft screening copy (AGENT_NATIVE_LOOP_DESIGN.md §5, A1-A11) */
3952
+ /* ------------------------------------------------------------------ */
3953
+ /**
3954
+ * A1 · plan banner, printed once before step 1 when at least one
3955
+ * agent-drafted sentence survived screening. The final line IS the shared
3956
+ * `userSafetyLine` constant, rendered verbatim and unwrapped (n1) so QA 25
3957
+ * can assert byte-identity across the CLI banner, `agentLoop`, and
3958
+ * `draft_improve_command`.
3959
+ */
3960
+ const agentDraftPlanBanner = [
3961
+ "Your agent helped draft this plan. Nothing is approved yet: review each",
3962
+ "sentence, press Enter to accept it or type your own, and only the APPROVE",
3963
+ "you type at the end authorizes anything.",
3964
+ IMPROVE_USER_SAFETY_LINE_V1
3965
+ ].join("\n");
3966
+ /** A4 · whole-draft set-asides. */
3967
+ const agentDraftUnreadableNotice = [
3968
+ "Your agent's draft could not be read (not a valid ab1 draft token).",
3969
+ "Continuing with aibill's own suggestions. Ask your agent to call",
3970
+ "draft_improve_command again and hand you the exact command it returns."
3971
+ ].join("\n");
3972
+ const agentDraftStaleNotice = [
3973
+ "Your agent's draft was made for a different test or an older revision of",
3974
+ "this one, so it was set aside. Ask your agent to re-read",
3975
+ "get_token_reduction_test and draft again."
3976
+ ].join("\n");
3977
+ const agentDraftNoTestNotice = [
3978
+ "There is no frozen baseline yet, so a drafted plan cannot attach to a test.",
3979
+ "Finish the two start questions first; then ask your agent to draft against",
3980
+ "the new test id."
3981
+ ].join("\n");
3982
+ /** A5 · --draft while a plan awaits its record. */
3983
+ const agentDraftAfterApprovalNotice = [
3984
+ "A plan is already approved and waiting for its result, so the draft was",
3985
+ "not used. Record what happened below."
3986
+ ].join("\n");
3987
+ /** A7 · record flags with no approved plan. */
3988
+ const recordFlagsNoApprovalNotice = [
3989
+ "No plan is approved yet, so --record-applied-at/--record-canary were not",
3990
+ "used. Approve a plan first."
3991
+ ].join("\n");
3992
+ /** A7 · applied-at prefill set aside (reason = exact time-classifier copy). */
3993
+ function recordTimeSetAsideNotice(reason) {
3994
+ return [
3995
+ `Your agent's applied-at time was set aside: ${reason}`,
3996
+ "Answer the question yourself below."
3997
+ ].join("\n");
3998
+ }
3999
+ /** A8 · non-interactive run with any draft/record flag. */
4000
+ const agentFlagsNonInteractiveNote = [
4001
+ "Agent drafts and record values only pre-fill the interactive flow; nothing",
4002
+ "was recorded. Run this command in an interactive terminal."
4003
+ ].join("\n");
4004
+ /** A10 · verify-flag confusion on improve. */
4005
+ const verifyFlagConfusionNote = [
4006
+ "Note: --applied-at/--canary belong to the advanced verify commands. With",
4007
+ "improve, use --record-applied-at and --record-canary."
4008
+ ].join("\n");
4009
+ /** A9 · demo variant banner (binding is checked only in a real run). */
4010
+ const demoDraftBanner = [
4011
+ "DEMO: your agent's draft is used for practice only. Draft binding to a real",
4012
+ "test id and revision is checked only in a real run. Nothing is recorded."
4013
+ ].join("\n");
4014
+ /**
4015
+ * Screen a decoded draft with the SAME shared core path the MCP composition
4016
+ * preview uses (`screenAgentDraftSentence`), field by field. A rejected
4017
+ * field falls back to aibill's own suggestion — and to aibill's label; a
4018
+ * surviving field carries `provenance: "agent"` so only genuinely
4019
+ * agent-authored words ever render `Drafted with your agent` (B1, QA 17).
4020
+ */
4021
+ function screenAgentDraftSentences(draft) {
4022
+ const answers = {};
4023
+ const setAsideNotices = [];
4024
+ let survivors = 0;
4025
+ for (const field of ["change", "rollback", "canary"]) {
4026
+ const verdict = screenAgentDraftSentence(draft[field]);
4027
+ if (verdict.ok) {
4028
+ answers[field] = { value: verdict.value, provenance: "agent" };
4029
+ survivors += 1;
4030
+ }
4031
+ else {
4032
+ // A3 · the reason is the classifier's exact reprompt message; the
4033
+ // credential path's message never contains the rejected text.
4034
+ setAsideNotices.push([
4035
+ `Your agent's ${field} draft was set aside: ${verdict.reason}`,
4036
+ "aibill's own suggestion is shown for that step instead, labeled Suggested."
4037
+ ].join("\n"));
4038
+ }
4039
+ }
4040
+ return { answers, setAsideNotices, survivors };
4041
+ }
3500
4042
  /**
3501
4043
  * `improve --sample`: the full guided questionnaire against synthetic
3502
4044
  * evidence so a new user can practice every step safely. Fail-closed by
3503
4045
  * construction: this function never receives a draft store or persistence
3504
4046
  * path — no experiment, ownership, approval, draft, or file is written.
4047
+ * With `--draft` (A9) the demo decodes and screens the token exactly like a
4048
+ * real run — practicing the screening is the point — but skips the binding
4049
+ * check, because there is no real test to bind to.
3505
4050
  */
3506
- async function demoImproveCommand(runtime) {
4051
+ async function demoImproveCommand(args, runtime) {
3507
4052
  const demoNext = {
3508
4053
  reason: "run the real flow from inside one exact project",
3509
4054
  command: improveRuntimeCommand
3510
4055
  };
4056
+ const hasAgentFlags = args.agentDraftToken !== undefined ||
4057
+ args.recordAppliedAt !== undefined || args.recordCanary !== undefined;
3511
4058
  const guidedIo = runtime.interactive ? await createGuidedIo(runtime) : undefined;
3512
4059
  if (!runtime.interactive || !guidedIo) {
3513
4060
  return ok(renderCleanExit({
3514
4061
  lines: [
3515
4062
  "aibill improve · DEMO · synthetic sample — practice run, nothing is recorded",
3516
- "Demo sample data can never start a token test."
4063
+ "Demo sample data can never start a token test.",
4064
+ ...(hasAgentFlags ? [agentFlagsNonInteractiveNote] : [])
3517
4065
  ],
3518
4066
  next: {
3519
4067
  reason: "practice the guided token test safely in an interactive terminal",
@@ -3527,6 +4075,35 @@ async function demoImproveCommand(runtime) {
3527
4075
  demo: true
3528
4076
  };
3529
4077
  guidedIo.write("Demo sample data can never start a token test.");
4078
+ // A9 · decode + screen exactly like a real run; binding is not checked.
4079
+ const demoDraft = args.agentDraftToken !== undefined
4080
+ ? decodeAgentDraftTokenV1(args.agentDraftToken)
4081
+ : undefined;
4082
+ let demoSuggestedAnswers = {
4083
+ change: {
4084
+ value: "Start with only the files and instructions this task needs.",
4085
+ provenance: "aibill"
4086
+ },
4087
+ rollback: { value: "Restore the prior session workflow.", provenance: "aibill" },
4088
+ canary: {
4089
+ value: "The project tests pass and the requested output is accepted.",
4090
+ provenance: "aibill"
4091
+ }
4092
+ };
4093
+ const demoDraftNotices = [];
4094
+ if (demoDraft !== undefined) {
4095
+ guidedIo.write(demoDraftBanner);
4096
+ if (!demoDraft.ok) {
4097
+ demoDraftNotices.push(agentDraftUnreadableNotice);
4098
+ }
4099
+ else {
4100
+ const screened = screenAgentDraftSentences(demoDraft.draft);
4101
+ if (screened.survivors > 0)
4102
+ demoDraftNotices.push(agentDraftPlanBanner);
4103
+ demoDraftNotices.push(...screened.setAsideNotices);
4104
+ demoSuggestedAnswers = { ...demoSuggestedAnswers, ...screened.answers };
4105
+ }
4106
+ }
3530
4107
  const demoComplete = (firstLine) => ok(renderCleanExit({
3531
4108
  lines: [
3532
4109
  firstLine,
@@ -3543,6 +4120,9 @@ async function demoImproveCommand(runtime) {
3543
4120
  if (start.action !== "start") {
3544
4121
  return demoComplete("DEMO ENDED · nothing was created or stored");
3545
4122
  }
4123
+ if (demoDraftNotices.length > 0) {
4124
+ guidedIo.write(demoDraftNotices.join("\n\n"));
4125
+ }
3546
4126
  const plan = await runPlanSitting(guidedIo, {
3547
4127
  header,
3548
4128
  experimentId: "DEMO-tre_v0_0000000000000000",
@@ -3550,11 +4130,7 @@ async function demoImproveCommand(runtime) {
3550
4130
  sanitize: (value) => value,
3551
4131
  nowIso: () => new Date().toISOString(),
3552
4132
  approveExtraLine: "This is a practice approval. It is not recorded and creates no claim.",
3553
- suggestedAnswers: {
3554
- change: "Start with only the files and instructions this task needs.",
3555
- rollback: "Restore the prior session workflow.",
3556
- canary: "The project tests pass and the requested output is accepted."
3557
- }
4133
+ suggestedAnswers: demoSuggestedAnswers
3558
4134
  });
3559
4135
  return demoComplete(plan.action === "approved"
3560
4136
  ? "DEMO COMPLETE · nothing was created or stored"
@@ -3622,21 +4198,48 @@ function createPlanDraftStore(rootPath) {
3622
4198
  }
3623
4199
  };
3624
4200
  }
4201
+ /**
4202
+ * The approved-plan agent handoff (§2e): symmetric with the record leg. The
4203
+ * record command placeholder line lives INSIDE the quoted agent text, not in
4204
+ * a NEXT COMMAND block, so the one-command exit contract is untouched.
4205
+ */
3625
4206
  function improveAgentInstruction(changeSentence) {
3626
4207
  return [
3627
4208
  `"Execute only the pre-approved reversible plan: ${changeSentence}`,
3628
- "Make no other optimization, preserve the approved rollback, run the",
3629
- "approved canary, and report the exact UTC ISO-8601 time the change was",
3630
- 'applied plus whether that exact canary passed or failed."'
4209
+ "Make no other optimization, preserve the approved rollback, and run the",
4210
+ "approved canary. Then report the exact UTC ISO-8601 time the change was",
4211
+ "applied and whether that exact canary passed or failed — if the canary has",
4212
+ "not run, say so instead. Give the user this one command with the time",
4213
+ "filled in:",
4214
+ ` ${actionRuntimeCommand("improve --record-applied-at <time> --record-canary <passed|failed>")}`,
4215
+ "That command only pre-fills the applied-at question; the user types the",
4216
+ 'canary answer themselves in their terminal."'
3631
4217
  ].join("\n");
3632
4218
  }
4219
+ /**
4220
+ * m12c: the record-backedOut re-show restates the FINDING label — the exact
4221
+ * approved sentence is unrecoverable by design (hash-only persistence) — and
4222
+ * must say so under the quoted text.
4223
+ */
4224
+ const improveAgentInstructionHashCaveat = [
4225
+ "(aibill keeps only hashes of your approved sentences; the plan above",
4226
+ "restates the finding, not your exact approved wording.)"
4227
+ ].join("\n");
3633
4228
  async function improveCommand(args, runtime) {
3634
4229
  if (args.sample) {
3635
- return demoImproveCommand(runtime);
4230
+ return demoImproveCommand(args, runtime);
3636
4231
  }
3637
4232
  const rootGuard = await guardExactProjectRoot("improve", args.path);
3638
4233
  if (rootGuard)
3639
4234
  return rootGuard;
4235
+ // §2c dispatch inputs. Decoding never throws; every set-aside prints one
4236
+ // notice and CONTINUES — a bad draft never ends the run (P0B principle 2).
4237
+ let agentDraft = args.agentDraftToken !== undefined
4238
+ ? decodeAgentDraftTokenV1(args.agentDraftToken)
4239
+ : undefined;
4240
+ const hasRecordFlags = args.recordAppliedAt !== undefined || args.recordCanary !== undefined;
4241
+ const hasAgentFlags = agentDraft !== undefined || hasRecordFlags;
4242
+ let recordFlagsNoticed = false;
3640
4243
  const sinceDays = args.sinceDays ?? 30;
3641
4244
  if (!validSinceDays(sinceDays))
3642
4245
  return invalidSinceDaysResult();
@@ -3712,14 +4315,39 @@ async function improveCommand(args, runtime) {
3712
4315
  });
3713
4316
  const guidedIo = runtime.interactive ? await createGuidedIo(runtime) : undefined;
3714
4317
  if (!runtime.interactive || !guidedIo) {
4318
+ // A8 · drafts and record values are prefills for the interactive flow
4319
+ // only; the read-only render must say nothing was recorded.
4320
+ const readOnlyNote = "No experiment, approval, or project state changed. The private local evidence cache may refresh. Run this command in an interactive terminal to start or record a test." +
4321
+ (hasAgentFlags ? `\n${agentFlagsNonInteractiveNote}` : "");
3715
4322
  return ok(renderImproveExperience(model, {
3716
- note: "No experiment, approval, or project state changed. The private local evidence cache may refresh. Run this command in an interactive terminal to start or record a test.",
4323
+ note: readOnlyNote,
3717
4324
  ...(improveProjectLine ? { projectLine: improveProjectLine } : {})
3718
4325
  }));
3719
4326
  }
3720
4327
  const commandTitle = "aibill improve · one reversible token test";
3721
4328
  const experimentTag = (id) => id ? `test ${id.slice(0, 15)}` : "test: none yet";
4329
+ // A10 · the verify flags do not belong to improve; say so and ignore.
4330
+ if (args.appliedAt !== undefined || args.canary !== undefined) {
4331
+ guidedIo.write(verifyFlagConfusionNote);
4332
+ }
4333
+ // Phases past plan/record (collecting, rollback, terminal): the flags
4334
+ // have no question to pre-fill; say so once instead of failing.
4335
+ if (hasAgentFlags &&
4336
+ model.phase !== "start" && model.phase !== "awaiting_intervention") {
4337
+ guidedIo.write("The current step needs no draft or record values, so they were not used.");
4338
+ }
3722
4339
  if (model.phase === "start" && !preferred) {
4340
+ // §2c dispatch 2: a plan draft cannot bind before the freeze — the
4341
+ // experimentId is created at freeze. The draft is set aside NOW; after
4342
+ // the freeze the plan sitting uses aibill's own suggestions.
4343
+ if (agentDraft !== undefined) {
4344
+ guidedIo.write(agentDraftNoTestNotice);
4345
+ agentDraft = undefined;
4346
+ }
4347
+ if (hasRecordFlags && !recordFlagsNoticed) {
4348
+ guidedIo.write(recordFlagsNoApprovalNotice);
4349
+ recordFlagsNoticed = true;
4350
+ }
3723
4351
  const start = await runStartSitting(guidedIo, {
3724
4352
  header: { commandTitle, experimentLabel: experimentTag() },
3725
4353
  findingLabel: model.oneChange.label,
@@ -3786,6 +4414,54 @@ async function improveCommand(args, runtime) {
3786
4414
  }
3787
4415
  : undefined;
3788
4416
  const draftStore = createPlanDraftStore(rootPath);
4417
+ // §2c dispatch 3: record flags cannot apply before an approval.
4418
+ if (hasRecordFlags && !recordFlagsNoticed) {
4419
+ guidedIo.write(recordFlagsNoApprovalNotice);
4420
+ recordFlagsNoticed = true;
4421
+ }
4422
+ // The machine drafts; the human approves. aibill already knows the
4423
+ // change it found — never make the user re-type a worse version.
4424
+ // Every fallback field carries aibill's OWN provenance so it can
4425
+ // never render under the agent label (B1).
4426
+ const aibillSuggestions = {
4427
+ change: {
4428
+ value: model.oneChange.label.endsWith(".")
4429
+ ? model.oneChange.label
4430
+ : `${model.oneChange.label}.`,
4431
+ provenance: "aibill"
4432
+ },
4433
+ rollback: {
4434
+ value: "Restore the prior session workflow.",
4435
+ provenance: "aibill"
4436
+ },
4437
+ canary: {
4438
+ value: "The project tests pass and the requested output is accepted.",
4439
+ provenance: "aibill"
4440
+ }
4441
+ };
4442
+ let suggestedAnswers = aibillSuggestions;
4443
+ const draftNotices = [];
4444
+ if (agentDraft !== undefined) {
4445
+ if (!agentDraft.ok) {
4446
+ draftNotices.push(agentDraftUnreadableNotice);
4447
+ }
4448
+ else if (agentDraft.draft.experimentId !== preferred.id ||
4449
+ agentDraft.draft.revisionId !== preferred.revisionId) {
4450
+ // Stale revision, wrong test, or a token replayed from another
4451
+ // project — the id simply does not match this project's test.
4452
+ draftNotices.push(agentDraftStaleNotice);
4453
+ }
4454
+ else {
4455
+ const screened = screenAgentDraftSentences(agentDraft.draft);
4456
+ if (screened.survivors > 0)
4457
+ draftNotices.push(agentDraftPlanBanner);
4458
+ draftNotices.push(...screened.setAsideNotices);
4459
+ suggestedAnswers = { ...aibillSuggestions, ...screened.answers };
4460
+ }
4461
+ }
4462
+ if (draftNotices.length > 0) {
4463
+ guidedIo.write(draftNotices.join("\n\n"));
4464
+ }
3789
4465
  const plan = await runPlanSitting(guidedIo, {
3790
4466
  header,
3791
4467
  experimentId: preferred.id,
@@ -3794,15 +4470,7 @@ async function improveCommand(args, runtime) {
3794
4470
  draftStore,
3795
4471
  sanitize: sanitizeLocalActivityText,
3796
4472
  nowIso: () => new Date().toISOString(),
3797
- // The machine drafts; the human approves. aibill already knows the
3798
- // change it found — never make the user re-type a worse version.
3799
- suggestedAnswers: {
3800
- change: model.oneChange.label.endsWith(".")
3801
- ? model.oneChange.label
3802
- : `${model.oneChange.label}.`,
3803
- rollback: "Restore the prior session workflow.",
3804
- canary: "The project tests pass and the requested output is accepted."
3805
- }
4473
+ suggestedAnswers
3806
4474
  });
3807
4475
  const rerunNext = { reason: "run this again to continue the plan", command: improveRuntimeCommand };
3808
4476
  if (plan.action === "cancelled" || plan.action === "backedOut") {
@@ -3912,10 +4580,40 @@ async function improveCommand(args, runtime) {
3912
4580
  const approvedByLine = accountabilityState.ownership
3913
4581
  ? `Approved ${approvedPlan.approvedAt} by ${accountabilityState.ownership.displayLabels.humanOwner} (${accountabilityState.ownership.approverRole.roleLabel})`
3914
4582
  : `Approved ${approvedPlan.approvedAt}`;
4583
+ // §2c dispatch 4: a plan is pre-approved — the draft (if any) is set
4584
+ // aside (A5); the applied-at prefill is pre-screened with the FULL
4585
+ // time classifier (approvedAtIso context) so before-approval/future
4586
+ // values are set aside honestly NOW instead of at Enter (A7); the
4587
+ // canary report NEVER prefills — claim line only, typed p/f/n (M6).
4588
+ const recordNotices = [];
4589
+ if (agentDraft !== undefined) {
4590
+ recordNotices.push(agentDraftAfterApprovalNotice);
4591
+ }
4592
+ let suggestedRecord;
4593
+ if (args.recordAppliedAt !== undefined) {
4594
+ const timeVerdict = classifyGuidedAnswer("time", args.recordAppliedAt, {
4595
+ approvedAtIso: approvedPlan.approvedAt
4596
+ });
4597
+ if (timeVerdict.outcome === "accept") {
4598
+ suggestedRecord = { appliedAtIso: args.recordAppliedAt };
4599
+ }
4600
+ else {
4601
+ recordNotices.push(recordTimeSetAsideNotice(timeVerdict.outcome === "reject"
4602
+ ? timeVerdict.message
4603
+ : "the time could not be read"));
4604
+ }
4605
+ }
4606
+ if (recordNotices.length > 0) {
4607
+ guidedIo.write(recordNotices.join("\n\n"));
4608
+ }
3915
4609
  const record = await runRecordSitting(guidedIo, {
3916
4610
  header,
3917
4611
  approvedAtIso: approvedPlan.approvedAt,
3918
- approvedByLine
4612
+ approvedByLine,
4613
+ ...(suggestedRecord !== undefined ? { suggested: suggestedRecord } : {}),
4614
+ ...(args.recordCanary !== undefined
4615
+ ? { agentCanaryReport: args.recordCanary }
4616
+ : {})
3919
4617
  });
3920
4618
  if (record.action === "cancelled") {
3921
4619
  return ok(renderCleanExit({
@@ -3927,9 +4625,12 @@ async function improveCommand(args, runtime) {
3927
4625
  }));
3928
4626
  }
3929
4627
  if (record.action === "backedOut") {
4628
+ // m12c: this path restates the FINDING label, not the approved
4629
+ // sentence (hash-only persistence) — the caveat says so, indented
4630
+ // inside the quoted block by renderForYourAgent.
3930
4631
  return ok(renderCleanExit({
3931
4632
  lines: [`${experimentTag(preferred.id)} · waiting for the applied change`],
3932
- extraBlocks: [renderForYourAgent(improveAgentInstruction(model.oneChange.label))],
4633
+ extraBlocks: [renderForYourAgent(`${improveAgentInstruction(model.oneChange.label)}\n${improveAgentInstructionHashCaveat}`)],
3933
4634
  next: { reason: "after the agent reports back, run exactly this", command: improveRuntimeCommand }
3934
4635
  }));
3935
4636
  }
@@ -4652,7 +5353,8 @@ async function applyArtifactCommand(args) {
4652
5353
  experiment.lifecycle !== "invalidated"));
4653
5354
  if (active) {
4654
5355
  return tokenVerificationResult(active, false, [
4655
- "An active token test already owns this project; Apply handed off to it and generated no conflicting candidate or artifacts."
5356
+ "An active token test already owns this project; Apply handed off to it and generated no conflicting candidate or artifacts.",
5357
+ `Use your agent normally on this project, then: ${improveRuntimeCommand}`
4656
5358
  ]);
4657
5359
  }
4658
5360
  }
@@ -5219,13 +5921,17 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}
5219
5921
  freshLocalCalls = freshActionLogs.calls;
5220
5922
  freshCodexInvocationFiles = freshActionLogs.codexInvocationFiles;
5221
5923
  }
5924
+ // Financial and qualitative readers have separate truth contracts. Always
5925
+ // read the bounded/streaming financial axis once: local mode uses it as the
5926
+ // headline, while connected mode carries it into the saved report as a
5927
+ // separate API-equivalent comparison that is never added to billed cost.
5928
+ const freshFinancialLogs = await loadLocalAgentFinancialUsage({ financialIndex: cliFinancialIndex,
5929
+ claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
5930
+ codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
5931
+ geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
5932
+ sinceIso
5933
+ }).catch(() => undefined);
5222
5934
  if (needsFreshLogs) {
5223
- const freshFinancialLogs = await loadLocalAgentFinancialUsage({ financialIndex: cliFinancialIndex,
5224
- claudeProjectsDir: process.env.AI_SPEND_CLAUDE_LOGS_DIR,
5225
- codexSessionsDir: process.env.AI_SPEND_CODEX_LOGS_DIR,
5226
- geminiSessionsDir: process.env.AI_SPEND_GEMINI_LOGS_DIR,
5227
- sinceIso
5228
- }).catch(() => undefined);
5229
5935
  if (freshFinancialLogs && freshFinancialLogs.records.length > 0) {
5230
5936
  const records = freshFinancialLogs.records;
5231
5937
  const summary = analyzeSpend(records);
@@ -5368,6 +6074,9 @@ async function buildReportInput(stateDir, rootPath, sinceDays = 30, options = {}
5368
6074
  allRecords: spendState.mode === "connected_provider"
5369
6075
  ? selectProviderFinancialHeadlineRecords(spendState.records ?? [])
5370
6076
  : spendState.records ?? [],
6077
+ ...(spendState.mode === "connected_provider" && (freshFinancialLogs?.records.length ?? 0) > 0
6078
+ ? { localFinancialRecords: freshFinancialLogs.records }
6079
+ : {}),
5371
6080
  dataMode: spendState.mode,
5372
6081
  discovery,
5373
6082
  mappings: mappings ?? [],
@@ -5452,6 +6161,12 @@ function parseArgs(argv) {
5452
6161
  parsed.provider = rest[0];
5453
6162
  rest.shift();
5454
6163
  }
6164
+ if (command === "signup" && rest[0] && !rest[0].startsWith("-")) {
6165
+ parsed.signupEmail = rest.shift();
6166
+ }
6167
+ if (command === "telemetry" && rest[0] && !rest[0].startsWith("--")) {
6168
+ parsed.telemetryAction = rest.shift();
6169
+ }
5455
6170
  if (command === "verify") {
5456
6171
  const first = rest[0];
5457
6172
  if (first === "inspect" || first === "start" || first === "mark-applied" ||
@@ -5478,14 +6193,27 @@ function parseArgs(argv) {
5478
6193
  "--source-path", "--type", "--provider", "--source-id", "--team",
5479
6194
  "--person", "--client", "--cost-center", "--role", "--project", "--agent", "--workflow",
5480
6195
  "--evidence", "--confidence", "--label", "--auth-reference",
5481
- "--start-time", "--end-time", "--org", "--enterprise", "--account-id",
6196
+ "--start-time", "--end-time", "--org", "--enterprise", "--account-id", "--account",
5482
6197
  "--interval", "--cycles", "--canary", "--quality", "--change-digest",
5483
6198
  "--rollback-digest", "--canary-digest", "--approved-at", "--applied-at",
5484
- "--pr", "--business-outcome"
6199
+ "--pr", "--business-outcome",
6200
+ "--draft", "--record-applied-at", "--record-canary",
6201
+ "--ref"
5485
6202
  ]);
5486
6203
  const numericValueFlags = new Set([
5487
6204
  "--since-days", "--confidence", "--start-time", "--end-time", "--interval", "--cycles", "--pr"
5488
6205
  ]);
6206
+ // A repeated --draft/--record-* flag means the pasted line was assembled
6207
+ // from two commands: a parse error, never a silent last-wins (§2b, m11).
6208
+ const seenOnceOnlyFlags = new Set();
6209
+ const onceOnly = (flag) => {
6210
+ if (seenOnceOnlyFlags.has(flag)) {
6211
+ parsed.parseErrors.push(`${flag} may appear once`);
6212
+ return false;
6213
+ }
6214
+ seenOnceOnlyFlags.add(flag);
6215
+ return true;
6216
+ };
5489
6217
  for (let index = 0; index < rest.length; index += 1) {
5490
6218
  const arg = rest[index];
5491
6219
  const nextValue = rest[index + 1];
@@ -5558,6 +6286,51 @@ function parseArgs(argv) {
5558
6286
  }
5559
6287
  continue;
5560
6288
  }
6289
+ if (arg === "--draft") {
6290
+ const next = rest[index + 1];
6291
+ if (next) {
6292
+ if (onceOnly("--draft")) {
6293
+ if (looksLikeAgentDraftToken(next)) {
6294
+ parsed.agentDraftToken = next;
6295
+ }
6296
+ else {
6297
+ parsed.parseErrors.push("--draft must be the single ab1.… token from draft_improve_command; do not hand-build or quote it");
6298
+ }
6299
+ }
6300
+ index += 1;
6301
+ }
6302
+ continue;
6303
+ }
6304
+ if (arg === "--record-applied-at") {
6305
+ const next = rest[index + 1];
6306
+ if (next) {
6307
+ if (onceOnly("--record-applied-at")) {
6308
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?Z$/.test(next)) {
6309
+ parsed.recordAppliedAt = next;
6310
+ }
6311
+ else {
6312
+ parsed.parseErrors.push("--record-applied-at must be a UTC Z time, e.g. 2026-08-18T09:12:00Z");
6313
+ }
6314
+ }
6315
+ index += 1;
6316
+ }
6317
+ continue;
6318
+ }
6319
+ if (arg === "--record-canary") {
6320
+ const next = rest[index + 1];
6321
+ if (next) {
6322
+ if (onceOnly("--record-canary")) {
6323
+ if (next === "passed" || next === "failed") {
6324
+ parsed.recordCanary = next;
6325
+ }
6326
+ else {
6327
+ parsed.parseErrors.push("--record-canary must be passed or failed");
6328
+ }
6329
+ }
6330
+ index += 1;
6331
+ }
6332
+ continue;
6333
+ }
5561
6334
  if (arg === "--change-digest" || arg === "--rollback-digest" ||
5562
6335
  arg === "--canary-digest") {
5563
6336
  const next = rest[index + 1];
@@ -5576,6 +6349,30 @@ function parseArgs(argv) {
5576
6349
  parsed.ignoreState = true;
5577
6350
  continue;
5578
6351
  }
6352
+ if (arg === "--forget") {
6353
+ parsed.signupForget = true;
6354
+ continue;
6355
+ }
6356
+ if (arg === "--never") {
6357
+ parsed.signupNever = true;
6358
+ continue;
6359
+ }
6360
+ if (arg === "--ref") {
6361
+ const next = rest[index + 1];
6362
+ if (next) {
6363
+ // Allowlist sanitization happens here so nothing outside
6364
+ // [a-z0-9-]{1,24} can ever reach the payload builder (QA 8).
6365
+ const tag = sanitizeSignupRefTag(next);
6366
+ if (tag === undefined) {
6367
+ parsed.parseErrors.push("--ref must be 1-24 lowercase letters, digits, or dashes (e.g. --ref starfund)");
6368
+ }
6369
+ else {
6370
+ parsed.signupRef = tag;
6371
+ }
6372
+ index += 1;
6373
+ }
6374
+ continue;
6375
+ }
5579
6376
  if (arg === "--plan") {
5580
6377
  const next = rest[index + 1];
5581
6378
  if (next) {
@@ -5808,6 +6605,14 @@ function parseArgs(argv) {
5808
6605
  }
5809
6606
  continue;
5810
6607
  }
6608
+ if (arg === "--account") {
6609
+ const next = rest[index + 1];
6610
+ if (next) {
6611
+ parsed.account = next;
6612
+ index += 1;
6613
+ }
6614
+ continue;
6615
+ }
5811
6616
  if (arg === "--account-id") {
5812
6617
  const next = rest[index + 1];
5813
6618
  if (next) {
@@ -5862,8 +6667,13 @@ function parseArgs(argv) {
5862
6667
  }
5863
6668
  continue;
5864
6669
  }
6670
+ // A11 (copy polish on the existing unknown-flag rejection): quoted
6671
+ // per-sentence draft flags never existed — point at the one-token design.
6672
+ const draftFlagHint = arg === "--draft-change" || arg === "--draft-rollback" || arg === "--draft-canary"
6673
+ ? " — agent drafts travel as one --draft token from draft_improve_command, not as quoted sentences"
6674
+ : "";
5865
6675
  parsed.parseErrors.push(arg.startsWith("-")
5866
- ? `unknown flag "${arg}"`
6676
+ ? `unknown flag "${arg}"${draftFlagHint}`
5867
6677
  : `unexpected argument "${arg}"`);
5868
6678
  }
5869
6679
  return parsed;
@@ -5987,7 +6797,7 @@ function isNodeError(error, code) {
5987
6797
  function ok(stdout) {
5988
6798
  return { exitCode: 0, stdout, stderr: "" };
5989
6799
  }
5990
- function helpText() {
6800
+ function helpText(telemetryDisclosure) {
5991
6801
  return [
5992
6802
  "aibill — your AI cost and usage evidence in one private view",
5993
6803
  "",
@@ -5997,11 +6807,11 @@ function helpText() {
5997
6807
  " npx aibill --sample Show the clearly labeled illustrative demo (never implicit)",
5998
6808
  " npx aibill --group-by agent Drill down by source|model|client|project|agent|user|workspace|apiKey",
5999
6809
  " npx aibill --plan <id> Declare your plan when auto-detection can't (claude-max-5x|claude-max-20x|claude-pro|chatgpt-plus|chatgpt-pro)",
6000
- ` ${improveRuntimeCommand} Source preview: test one personalized change and measure whether token usage fell`,
6001
- ` ${actionRuntimeCommand("index")} Source preview: read very large agent histories to completion so results stop saying "indexing"`,
6002
- ` ${actionRuntimeCommand("identify")} Source preview: confirm the human owner, team, client/cost center, and approval role`,
6003
- ` ${actionRuntimeCommand("outcome github")} Source preview: attach one merged PR whose observed status checks passed`,
6004
- ` ${actionRuntimeCommand("accountability")} Source preview: answer owner → outcome → approval → measured-result for this project`,
6810
+ ` ${improveRuntimeCommand} Test one personalized change and measure whether token usage fell`,
6811
+ ` ${actionRuntimeCommand("index")} Read very large agent histories to completion so results stop saying "indexing"`,
6812
+ ` ${actionRuntimeCommand("identify")} Confirm the human owner, team, client/cost center, and approval role`,
6813
+ ` ${actionRuntimeCommand("outcome github")} Attach one merged PR whose observed status checks passed`,
6814
+ ` ${actionRuntimeCommand("accountability")} Answer owner → outcome → approval → measured-result for this project`,
6005
6815
  "",
6006
6816
  "Add official provider-reported cost (ADMIN/owner-gated):",
6007
6817
  " npx aibill connect openai Requires an org-owner Admin credential reference",
@@ -6024,6 +6834,9 @@ function helpText() {
6024
6834
  " [--replace] Explicitly replace an existing statusLine while preserving it for uninstall",
6025
6835
  " statusline uninstall Remove only the owned setting and restore its preserved predecessor",
6026
6836
  " statusline expand Print every subscription with committed price, runways, and 7d API-equivalent",
6837
+ " signup <email> [--ref <token>] Join the launch list · email only · the exact payload is shown before send",
6838
+ " [--never] Never ask again (nothing is sent) [--forget] Clear local signup state",
6839
+ " telemetry [on|off] Show anonymous command-count status + the exact last payload · switch it",
6027
6840
  " doctor [--sources] Launch diagnostics; --sources shows validation, evidence, freshness, and errors",
6028
6841
  " reset [--path <dir>] Clear persisted spend state (so sample state can't mask real logs)",
6029
6842
  " --ignore-state On the default/quickstart run, ignore persisted spend.json for this run",
@@ -6057,7 +6870,9 @@ function helpText() {
6057
6870
  "Cron (production watch): add a crontab entry such as:",
6058
6871
  " 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
6059
6872
  "",
6060
- "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
6873
+ telemetryDisclosure === true
6874
+ ? "Privacy: local analysis and reports upload nothing; anonymous command counts shared · aibill telemetry off. Only explicit sync-provider contacts the selected provider through an env: reference."
6875
+ : "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
6061
6876
  "aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
6062
6877
  ].join("\n");
6063
6878
  }
@@ -6093,10 +6908,40 @@ export async function runMain() {
6093
6908
  const argv = process.argv.slice(2);
6094
6909
  const command = argv[0];
6095
6910
  const isInstantDemo = !command || command === "quickstart" || command === "demo";
6096
- // Show a spinner only for the work-heavy instant-demo path, and only on a
6097
- // real TTY so piped output stays clean.
6911
+ const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
6912
+ const startedAtMs = Date.now();
6913
+ // Bin-entry-only telemetry (notice-before-first-byte; see telemetry.ts).
6914
+ // Embedded runCli callers and the MCP server never construct this, so
6915
+ // they can never emit an event.
6916
+ let telemetry;
6917
+ try {
6918
+ const { openCliTelemetry } = await import("./telemetry.js");
6919
+ telemetry = await openCliTelemetry();
6920
+ }
6921
+ catch {
6922
+ // Telemetry must never break the CLI.
6923
+ }
6924
+ // The during-scan signup ask (capture design, placement addendum
6925
+ // 2026-08-24): on a qualifying interactive first run the ask fills the
6926
+ // scan wait — it prints its own wait line, so the spinner stays off. A
6927
+ // run that does not qualify (or whose signup state disallows the ask)
6928
+ // takes the exact fast path below, byte-identical.
6929
+ let preAsk;
6930
+ if (interactive && !process.env.CI && !process.env.AI_SPEND_NO_PROMPT) {
6931
+ try {
6932
+ const signup = await import("./signup.js");
6933
+ if (signup.qualifiesForPreReceiptSignupAsk(argv)) {
6934
+ preAsk = await signup.openPreReceiptSignupAskInTerminal();
6935
+ }
6936
+ }
6937
+ catch {
6938
+ // The ask must never break the receipt path.
6939
+ }
6940
+ }
6941
+ // Show a spinner only for the work-heavy instant-demo path, only on a
6942
+ // real TTY so piped output stays clean, and never over the open ask.
6098
6943
  let spinner;
6099
- if (isInstantDemo && process.stdout.isTTY && !process.env.NO_COLOR) {
6944
+ if (isInstantDemo && !preAsk && process.stdout.isTTY && !process.env.NO_COLOR) {
6100
6945
  try {
6101
6946
  const { default: yoctoSpinner } = await import("yocto-spinner");
6102
6947
  spinner = yoctoSpinner({ text: "Reading local AI evidence…" }).start();
@@ -6106,13 +6951,14 @@ export async function runMain() {
6106
6951
  }
6107
6952
  }
6108
6953
  let result;
6954
+ let askOutcome = { kind: "no_ask" };
6109
6955
  let promptInterface;
6110
6956
  let guidedInterface;
6111
6957
  try {
6112
- const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
6113
6958
  let guidedIoShared;
6114
- result = await runCli(argv, {
6959
+ const runPipeline = () => runCli(argv, {
6115
6960
  interactive,
6961
+ ...(telemetry?.disclosureActive === true ? { telemetryDisclosure: true } : {}),
6116
6962
  ...(interactive ? {
6117
6963
  prompt: async (question) => {
6118
6964
  if (!promptInterface) {
@@ -6141,6 +6987,21 @@ export async function runMain() {
6141
6987
  }
6142
6988
  } : {})
6143
6989
  });
6990
+ if (preAsk) {
6991
+ // Receipt renders only when BOTH the human's answer (bounded by the
6992
+ // ask's own timeouts) and the pipeline have resolved; a pipeline
6993
+ // error still waits for the bounded ask, then re-throws into the
6994
+ // error voice below — never a hung prompt over a dead pipeline.
6995
+ const { orchestratePreReceiptAsk } = await import("./signup.js");
6996
+ const orchestrated = await orchestratePreReceiptAsk({ session: preAsk.session, runPipeline });
6997
+ askOutcome = orchestrated.outcome;
6998
+ if (!orchestrated.pipeline.ok)
6999
+ throw orchestrated.pipeline.error;
7000
+ result = orchestrated.pipeline.value;
7001
+ }
7002
+ else {
7003
+ result = await runPipeline();
7004
+ }
6144
7005
  }
6145
7006
  catch (error) {
6146
7007
  // The product's error voice, never a raw stack trace — and never an
@@ -6151,7 +7012,7 @@ export async function runMain() {
6151
7012
  stdout: "",
6152
7013
  stderr: [
6153
7014
  `aibill hit an unexpected error: ${message}`,
6154
- "Nothing was uploaded. The command stopped without completing; run diagnostics before retrying.",
7015
+ `${telemetry?.disclosureActive === true ? telemetryDisclosureLine : "Nothing was uploaded."} The command stopped without completing; run diagnostics before retrying.`,
6155
7016
  "Try `npx aibill doctor` for diagnostics, or open an issue: https://github.com/futurastudio/ai-spend-agent/issues"
6156
7017
  ].join("\n")
6157
7018
  };
@@ -6161,6 +7022,11 @@ export async function runMain() {
6161
7022
  guidedInterface?.close();
6162
7023
  spinner?.stop();
6163
7024
  }
7025
+ // A read that ended without the user pressing Enter (timeout / Ctrl-C)
7026
+ // left the prompt line open; start the receipt on a fresh line.
7027
+ if (preAsk?.needsFreshLine()) {
7028
+ process.stdout.write("\n");
7029
+ }
6164
7030
  if (result.stdout) {
6165
7031
  console.log(result.stdout);
6166
7032
  }
@@ -6168,6 +7034,29 @@ export async function runMain() {
6168
7034
  console.error(result.stderr);
6169
7035
  }
6170
7036
  process.exitCode = result.exitCode;
7037
+ // Consent renders strictly AFTER the receipt: scope line, the literal
7038
+ // payload JSON, a typed y, ONE POST. Nothing here can change the
7039
+ // receipt's bytes or exit code.
7040
+ if (preAsk && askOutcome.kind === "email") {
7041
+ await preAsk.runConsent(askOutcome);
7042
+ }
7043
+ preAsk?.close();
7044
+ // Telemetry LAST: on the first interactive run this prints the one-time
7045
+ // notice (and stamps it); on later noticed runs it fires ONE
7046
+ // fire-and-forget event with a hard 1500ms abort. Never blocks the
7047
+ // receipt, never changes the exit code.
7048
+ try {
7049
+ await telemetry?.finish({
7050
+ argv,
7051
+ ok: result.exitCode === 0,
7052
+ durationMs: Date.now() - startedAtMs,
7053
+ interactive,
7054
+ version: await cliVersion()
7055
+ });
7056
+ }
7057
+ catch {
7058
+ // Telemetry must never break the CLI.
7059
+ }
6171
7060
  }
6172
7061
  if (invokedAsMain) {
6173
7062
  await runMain();