ai-spend-agent 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -18,6 +18,13 @@ export type CliRuntimeOptions = {
18
18
  interactive?: boolean;
19
19
  /** Foreground terminal prompt. Tests/embeddings must inject it explicitly. */
20
20
  prompt?: (question: string) => Promise<string>;
21
+ /**
22
+ * Consent-grade read for the explicit signup command (adversary SF1):
23
+ * buffered/type-ahead bytes never answer, EOF/^C resolve undefined. The
24
+ * bin wires signup.openTerminalConsentRead; tests inject stubs. When
25
+ * absent, `prompt` is the fallback with aborts mapped to "nothing sent".
26
+ */
27
+ consentRead?: (query: string, timeoutMs: number) => Promise<string | undefined>;
21
28
  /**
22
29
  * Guided-flow line IO for the improve/identify sittings. The foreground
23
30
  * terminal wires an arrival-timestamped readline source; tests inject
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { homedir } from "node:os";
6
6
  import { basename, dirname, extname, join, resolve } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
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";
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
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";
@@ -618,9 +618,11 @@ function quickstartNextSteps(mode, detected) {
618
618
  steps.push(`npx aibill connect ${detected[0].provider} set up the admin connector, then sync provider-reported cost`);
619
619
  }
620
620
  steps.push(mode === "demo"
621
- // Every printed command must run as printed: the demo workspace does
622
- // not exist yet, so the command creates it first (shipped-audit fix).
623
- ? "mkdir -p ./demo-workspace && npx aibill report --sample --path ./demo-workspace write a clearly labeled demo report in an explicitly narrow workspace"
621
+ // 0.9.4: report --sample runs as printed from ANY directory — broad
622
+ // roots write ./ai-spend-report.{md,html} machine-wide-style, project
623
+ // folders keep .ai-spend-agent/report.* (the old mkdir demo-workspace
624
+ // preamble is no longer needed for the command to run as printed).
625
+ ? "npx aibill report --sample write a clearly labeled demo report right here"
624
626
  : "npx aibill report write a shareable Markdown + HTML report");
625
627
  steps.push("npx aibill --group-by project see which project has the most observed activity");
626
628
  steps.push("Need team reconciliation, allocation, budgets, and approvals? Workspace design partners: https://asktilden.com");
@@ -1013,7 +1015,27 @@ async function signupCommand(args, runtime) {
1013
1015
  return ok(signupCopy.alreadyLine);
1014
1016
  }
1015
1017
  const payload = { email, ref: buildWaitlistRef("signup", args.signupRef) };
1016
- const consent = (await runtime.prompt(`${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`)).trim().toLowerCase();
1018
+ const consentQuery = `${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`;
1019
+ // Adversary SF1: the consent question must never be answered by a
1020
+ // buffered byte, and EOF/^C are a quiet "nothing sent", never the crash
1021
+ // voice — this is the exact command the receipt advertises.
1022
+ let consentAnswer;
1023
+ if (runtime.consentRead) {
1024
+ consentAnswer = await runtime.consentRead(consentQuery, signupAskTimeoutMs);
1025
+ }
1026
+ else {
1027
+ try {
1028
+ consentAnswer = await runtime.prompt(consentQuery);
1029
+ }
1030
+ catch {
1031
+ // readline/promises rejects on Ctrl-D/Ctrl-C ("Aborted with Ctrl+D").
1032
+ consentAnswer = undefined;
1033
+ }
1034
+ }
1035
+ if (consentAnswer === undefined) {
1036
+ return ok(signupCopy.nothingSentLine);
1037
+ }
1038
+ const consent = consentAnswer.trim().toLowerCase();
1017
1039
  if (consent !== "y" && consent !== "yes") {
1018
1040
  return ok(signupCopy.nothingSentLine);
1019
1041
  }
@@ -1045,7 +1067,7 @@ async function telemetryCommand(args, runtime) {
1045
1067
  return {
1046
1068
  exitCode: 1,
1047
1069
  stdout: "",
1048
- stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: aibill telemetry [on|off]`
1070
+ stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: npx aibill telemetry [on|off]`
1049
1071
  };
1050
1072
  }
1051
1073
  if (action === "off") {
@@ -1095,7 +1117,7 @@ async function telemetryCommand(args, runtime) {
1095
1117
  "telemetry on · anonymous command counts only",
1096
1118
  `counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp`,
1097
1119
  "never: arguments, paths, file contents, project names, or your email",
1098
- "events start with your next run · see payloads anytime: aibill telemetry"
1120
+ "events start with your next run · see payloads anytime: npx aibill telemetry"
1099
1121
  ].join("\n"));
1100
1122
  }
1101
1123
  const lines = ["aibill telemetry"];
@@ -1122,7 +1144,7 @@ async function telemetryCommand(args, runtime) {
1122
1144
  else {
1123
1145
  lines.push("last payload sent: none");
1124
1146
  }
1125
- lines.push("switch: aibill telemetry on · aibill telemetry off");
1147
+ lines.push("switch: npx aibill telemetry on · npx aibill telemetry off");
1126
1148
  return ok(lines.join("\n"));
1127
1149
  }
1128
1150
  /** The run-level privacy claim: literal truth in both telemetry states. */
@@ -1134,7 +1156,9 @@ function noEvidenceResult(surface, warnings, sinceDays, telemetryDisclosure) {
1134
1156
  ? "Watch has no financial baseline yet; no zero total or sample activity was recorded."
1135
1157
  : surface === "report-card"
1136
1158
  ? "No receipt was written because there is no supported financial evidence to summarize."
1137
- : `No supported AI usage evidence was found in the last ${sinceDays} days.`;
1159
+ : surface === "report"
1160
+ ? "No report was written because there is no supported financial evidence to summarize."
1161
+ : `No supported AI usage evidence was found in the last ${sinceDays} days.`;
1138
1162
  return {
1139
1163
  exitCode: surface === "receipt" ? 0 : 1,
1140
1164
  stdout: surface === "receipt"
@@ -1271,7 +1295,7 @@ async function doctorCommand(args, runtime = {}) {
1271
1295
  `node version: ${process.version}`,
1272
1296
  `cli version: ${await cliVersion()}`,
1273
1297
  runtime.telemetryDisclosure === true
1274
- ? "local-first mode: enabled (evidence stays local · anonymous command counts shared · aibill telemetry off)"
1298
+ ? `local-first mode: enabled (evidence stays local · ${telemetryDisclosureLine})`
1275
1299
  : "local-first mode: enabled (no cloud upload, no telemetry)",
1276
1300
  `path: ${rootPath}`,
1277
1301
  `state directory: ${stateDir}`,
@@ -1746,6 +1770,12 @@ async function cliVersion() {
1746
1770
  }
1747
1771
  }
1748
1772
  async function resetCommand(args) {
1773
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
1774
+ // broad root produces the SAME friendly exact-project guidance — never the
1775
+ // raw scan refusal, never the crash wrapper.
1776
+ const rootGuard = await guardExactProjectRoot("reset", args.path);
1777
+ if (rootGuard)
1778
+ return rootGuard;
1749
1779
  const rootPath = await resolveSafeScanRoot(args.path);
1750
1780
  // The trust receipt is deliberately outside the repository. Reset must
1751
1781
  // clear it too so restoring an old spend.json cannot replay prior trust.
@@ -1809,7 +1839,7 @@ async function statuslineCommand(args, runtime) {
1809
1839
  exitCode: 1,
1810
1840
  stdout: "",
1811
1841
  stderr: `Unknown statusline action: ${sanitizeSecretishError(action)}\n` +
1812
- "Use: aibill statusline [refresh|install|uninstall|expand]"
1842
+ "Use: npx aibill statusline [refresh|install|uninstall|expand]"
1813
1843
  };
1814
1844
  }
1815
1845
  async function packagedStatuslineRunner(runtime) {
@@ -1868,7 +1898,7 @@ function statuslineInstallerFailure(action, error) {
1868
1898
  ? error
1869
1899
  : new StatuslineInstallerError("unsafe-settings-file", `The local filesystem operation failed safely${safeFileSystemErrorCode(error)}; no successful settings change was claimed.`);
1870
1900
  const replacement = installerError.code === "statusline-conflict"
1871
- ? "\nTo replace an existing status line explicitly: aibill statusline install --replace"
1901
+ ? "\nTo replace an existing status line explicitly: npx aibill statusline install --replace"
1872
1902
  : "";
1873
1903
  return {
1874
1904
  exitCode: 1,
@@ -1894,6 +1924,12 @@ async function initCommand(args, runtime = {}) {
1894
1924
  stderr: "aibill init only initializes from real local evidence; --sample was not used and no state or cache was changed. Run `npx aibill --sample` for the illustrative demo."
1895
1925
  };
1896
1926
  }
1927
+ // NEW-B3 (cold-start audit): init from a broad root used to crash-wrap the
1928
+ // raw scan refusal ("unexpected error … open an issue") — for a by-design
1929
+ // guard, on the funnel's second command. Friendly guidance instead.
1930
+ const rootGuard = await guardExactProjectRoot("init", args.path);
1931
+ if (rootGuard)
1932
+ return rootGuard;
1897
1933
  let detectedPlanOverride;
1898
1934
  if (args.plan) {
1899
1935
  const override = planOverrideFromFlag(args.plan);
@@ -2408,21 +2444,38 @@ async function preflightInitCache(cacheDirectory) {
2408
2444
  const existing = await readActivitySnapshot({ cacheDirectory });
2409
2445
  if (existing.status !== "error")
2410
2446
  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({
2447
+ const cacheDirectoryPath = dirname(activitySnapshotCachePath({
2416
2448
  ...(cacheDirectory ? { cacheDirectory } : {})
2417
- })))) {
2449
+ }));
2450
+ // A missing or pre-created-but-EMPTY cache directory holds nothing to
2451
+ // preserve — typically a first run whose home state was stamped before
2452
+ // any cache existed, or a user-made dir with default (non-0700)
2453
+ // permissions. Init's own create path re-validates and tightens it to
2454
+ // 0700, so proceeding is safe; only a directory with actual contents
2455
+ // aborts (shipped-audit fix; cold-start audit NEW-B1: the old check
2456
+ // required cache/ to EXIST, so first runs dead-ended here).
2457
+ if (existing.code === "unsafe_directory" && await isMissingOrEmptyRealDirectory(cacheDirectoryPath)) {
2418
2458
  return;
2419
2459
  }
2420
- throw new Error(`Existing private activity cache is ${existing.code.replaceAll("_", " ")}; ` +
2421
- "it was preserved and init stopped. Remove the cache explicitly before rebuilding it.");
2422
- }
2423
- async function isEmptyRealDirectory(path) {
2460
+ // NEW-B1(d): name the path and the one-line rescue — "remove the cache
2461
+ // explicitly" with no path was unactionable.
2462
+ const parentPath = dirname(cacheDirectoryPath);
2463
+ const rescue = existing.code === "unsafe_directory"
2464
+ ? ` One-line rescue: chmod 700 ${parentPath} ${cacheDirectoryPath} — then rerun init.`
2465
+ : ` Remove it explicitly before rebuilding it.`;
2466
+ throw new Error(`Existing private activity cache (${cacheDirectoryPath}) is ${existing.code.replaceAll("_", " ")}; ` +
2467
+ `it was preserved and init stopped.${rescue}`);
2468
+ }
2469
+ async function isMissingOrEmptyRealDirectory(path) {
2470
+ let info;
2471
+ try {
2472
+ info = await lstat(path);
2473
+ }
2474
+ catch (error) {
2475
+ // Not there yet: nothing to preserve, init's create path builds it 0700.
2476
+ return isNodeError(error, "ENOENT");
2477
+ }
2424
2478
  try {
2425
- const info = await lstat(path);
2426
2479
  if (info.isSymbolicLink() || !info.isDirectory())
2427
2480
  return false;
2428
2481
  return (await readdir(path)).length === 0;
@@ -2769,15 +2822,13 @@ function emptyInitSourceScan(agent) {
2769
2822
  };
2770
2823
  }
2771
2824
  async function scanCommand(args) {
2825
+ // NEW-B3 (cold-start audit): the bare raw refusal tier is gone — scan
2826
+ // gives the same friendly exact-project guidance as every other
2827
+ // project-scoped command.
2828
+ const rootGuard = await guardExactProjectRoot("scan", args.path);
2829
+ if (rootGuard)
2830
+ return rootGuard;
2772
2831
  const rootPath = resolve(args.path);
2773
- const unsafeReason = unsafeScanRootReason(rootPath);
2774
- if (unsafeReason) {
2775
- return {
2776
- exitCode: 1,
2777
- stdout: "",
2778
- stderr: `Refusing to scan ${rootPath}: ${unsafeReason}. Choose a narrower approved folder with --path.`
2779
- };
2780
- }
2781
2832
  const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
2782
2833
  const registry = createLocalFolderSourceRegistry(rootPath);
2783
2834
  const startedAt = new Date().toISOString();
@@ -2870,6 +2921,12 @@ async function scanCommand(args) {
2870
2921
  return ok(lines.join("\n"));
2871
2922
  }
2872
2923
  async function watchCommand(args) {
2924
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
2925
+ // broad root produces the SAME friendly exact-project guidance — never the
2926
+ // raw scan refusal, never the crash wrapper.
2927
+ const rootGuard = await guardExactProjectRoot("watch", args.path);
2928
+ if (rootGuard)
2929
+ return rootGuard;
2873
2930
  const rootPath = resolve(args.path);
2874
2931
  const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
2875
2932
  const intervalSeconds = Number.isFinite(args.interval) && (args.interval ?? 0) > 0 ? args.interval : 3600;
@@ -3127,6 +3184,12 @@ function providerSyncSetupCommand(provider, adminRef) {
3127
3184
  return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time ${thirtyDaysAgoUnix}`;
3128
3185
  }
3129
3186
  async function connectCommand(args) {
3187
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
3188
+ // broad root produces the SAME friendly exact-project guidance — never the
3189
+ // raw scan refusal, never the crash wrapper.
3190
+ const rootGuard = await guardExactProjectRoot("connect", args.path);
3191
+ if (rootGuard)
3192
+ return rootGuard;
3130
3193
  const rootPath = resolve(args.path);
3131
3194
  const requestedProvider = (args.provider ?? "unknown").trim().toLowerCase();
3132
3195
  const provider = providerAliases[requestedProvider] ?? requestedProvider;
@@ -3699,18 +3762,46 @@ async function confirmMappingCommand(args) {
3699
3762
  ].join("\n"));
3700
3763
  }
3701
3764
  async function reportCommand(args, runtime = {}) {
3765
+ // 0.9.4: a broad root (home, /) runs MACHINE-WIDE — the same read-only
3766
+ // transcript scanning as the bare receipt, no project state created, both
3767
+ // report files written to the current directory. The report renders
3768
+ // machine-wide content anyway (it lists every project), so the
3769
+ // exact-project requirement was incoherent here: the receipt's own Next
3770
+ // pointer led from home straight into a refusal. Only a bogus --path
3771
+ // still gets the friendly guard; project folders behave exactly as
3772
+ // before.
3773
+ const machineWide = isBroadScanRoot(args.path);
3774
+ if (!machineWide) {
3775
+ const rootGuard = await guardExactProjectRoot("report", args.path);
3776
+ if (rootGuard)
3777
+ return rootGuard;
3778
+ }
3702
3779
  const rootPath = resolve(args.path);
3703
3780
  try {
3704
3781
  const sinceDays = args.sinceDays ?? 30;
3705
3782
  if (!validSinceDays(sinceDays))
3706
3783
  return invalidSinceDaysResult();
3707
- const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
3784
+ // Machine-wide mode NEVER creates project state at the broad root — the
3785
+ // only writes are the two report files below (plus ~/.aibill home state
3786
+ // owned by other subsystems).
3787
+ const stateDir = machineWide ? undefined : await resolveSafeStateDirectory(rootPath, { create: true });
3708
3788
  // Like Apply, an explicit sample report is a strict privacy boundary. It
3709
3789
  // must not inspect local transcripts, account metadata, or persisted state.
3710
- const reportInput = args.sample
3711
- ? await buildExplicitSampleReportInput(rootPath)
3712
- : await buildReportInput(stateDir, rootPath, sinceDays);
3713
- const persistedPreferredExperiment = args.sample
3790
+ let reportInput;
3791
+ if (args.sample) {
3792
+ reportInput = await buildExplicitSampleReportInput(rootPath);
3793
+ }
3794
+ else if (machineWide) {
3795
+ const machineWideInput = await buildMachineWideReportInput(args, sinceDays);
3796
+ if (machineWideInput.kind === "no_evidence") {
3797
+ return noEvidenceResult("report", machineWideInput.warnings, sinceDays, runtime.telemetryDisclosure);
3798
+ }
3799
+ reportInput = machineWideInput.input;
3800
+ }
3801
+ else {
3802
+ reportInput = await buildReportInput(stateDir, rootPath, sinceDays);
3803
+ }
3804
+ const persistedPreferredExperiment = args.sample || machineWide
3714
3805
  ? undefined
3715
3806
  : chooseLatestTokenReductionExperiment((await loadTokenVerificationState(rootPath)).experiments);
3716
3807
  const preferredExperiment = persistedPreferredExperiment &&
@@ -3752,48 +3843,60 @@ async function reportCommand(args, runtime = {}) {
3752
3843
  : { ...reportInput, telemetryDisclosure };
3753
3844
  const qualitativeActionsSuppressed = reportInput.dataMode !== "sample" &&
3754
3845
  reportInput.qualitativeCoverage?.status !== "complete";
3755
- const outBase = args.out ? resolve(rootPath, args.out) : join(stateDir, "report");
3846
+ // Machine-wide artifacts land in the CURRENT directory under the
3847
+ // ai-spend-* family name; project mode keeps .ai-spend-agent/report.*.
3848
+ const outBase = args.out
3849
+ ? resolve(rootPath, args.out)
3850
+ : machineWide
3851
+ ? join(rootPath, "ai-spend-report")
3852
+ : join(stateDir, "report");
3756
3853
  const markdownPath = `${outBase}.md`;
3757
3854
  const htmlPath = `${outBase}.html`;
3758
- await writeLocalReportFile(markdownPath, generateMarkdownReport(reportRenderInput), stateDir);
3759
- await writeLocalReportFile(htmlPath, generateHtmlReport(reportRenderInput), stateDir);
3855
+ await writeLocalReportFile(markdownPath, generateMarkdownReport(reportRenderInput), stateDir ?? rootPath);
3856
+ await writeLocalReportFile(htmlPath, generateHtmlReport(reportRenderInput), stateDir ?? rootPath);
3760
3857
  // A preferred canonical experiment owns this project's action/result
3761
3858
  // lineage even after completion or rollback. A report may refresh its
3762
3859
  // read-only projection, but never overwrite the frozen handoff with a
3763
3860
  // fresh or contradictory candidate. Coverage gaps receive only explicit
3764
3861
  // non-executable gap artifacts from the report package.
3765
- const artifactPaths = reportableExperiment
3862
+ // Apply artifacts are project-scoped handoffs — machine-wide runs skip
3863
+ // them (apply itself still requires one exact project folder).
3864
+ const artifactPaths = reportableExperiment || machineWide
3766
3865
  ? undefined
3767
3866
  : await writeApplyArtifacts(stateDir, reportInput);
3768
3867
  return ok([
3769
3868
  "aibill report",
3770
- `path: ${rootPath}`,
3869
+ machineWide
3870
+ ? `scope: machine-wide · all supported local agent evidence on this machine (last ${sinceDays} days) · artifacts in ${rootPath}`
3871
+ : `path: ${rootPath}`,
3771
3872
  `markdown: ${markdownPath}`,
3772
3873
  `html: ${htmlPath}`,
3773
- ...(reportableExperiment
3774
- ? [
3775
- `action artifacts: preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})`,
3776
- `token result: status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}`,
3777
- `token test: ${improveRuntimeCommand}`
3778
- ]
3779
- : qualitativeActionsSuppressed
3874
+ ...(machineWide
3875
+ ? []
3876
+ : reportableExperiment
3780
3877
  ? [
3781
- `action artifacts: suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}`,
3782
- `coverage artifact: ${artifactPaths.codingPrompt}`,
3783
- `coverage action plan: ${artifactPaths.actionPlan}`,
3784
- `coverage policy/config: ${artifactPaths.policyConfigDraft}`,
3785
- `coverage verification: ${artifactPaths.verificationPlan}`,
3786
- `coverage package: ${artifactPaths.demoPackage}`
3878
+ `action artifacts: preserved · canonical token test ${reportableExperiment.id} (${reportableExperiment.lifecycle})`,
3879
+ `token result: status=${reportableExperiment.evaluation.status}; reductionPercent=${reportExperimentProjection.reductionPercent ?? "unavailable"}; metricEvidence=${reportExperimentProjection.evidenceLabel}; quality=${reportExperimentProjection.qualityLabel}; qualityEvidence=${reportExperimentProjection.qualityEvidence}; matchingEvidence=${reportableExperiment.evaluation.matchingEvidence}`,
3880
+ `token test: ${improveRuntimeCommand}`
3787
3881
  ]
3788
- : artifactPaths
3882
+ : qualitativeActionsSuppressed
3789
3883
  ? [
3790
- `apply artifact: ${artifactPaths.codingPrompt}`,
3791
- `action plan: ${artifactPaths.actionPlan}`,
3792
- `policy/config draft: ${artifactPaths.policyConfigDraft}`,
3793
- `verification plan: ${artifactPaths.verificationPlan}`,
3794
- `demo package: ${artifactPaths.demoPackage}`
3884
+ `action artifacts: suppressed · qualitative index ${reportInput.qualitativeCoverage?.status ?? "unknown"}`,
3885
+ `coverage artifact: ${artifactPaths.codingPrompt}`,
3886
+ `coverage action plan: ${artifactPaths.actionPlan}`,
3887
+ `coverage policy/config: ${artifactPaths.policyConfigDraft}`,
3888
+ `coverage verification: ${artifactPaths.verificationPlan}`,
3889
+ `coverage package: ${artifactPaths.demoPackage}`
3795
3890
  ]
3796
- : []),
3891
+ : artifactPaths
3892
+ ? [
3893
+ `apply artifact: ${artifactPaths.codingPrompt}`,
3894
+ `action plan: ${artifactPaths.actionPlan}`,
3895
+ `policy/config draft: ${artifactPaths.policyConfigDraft}`,
3896
+ `verification plan: ${artifactPaths.verificationPlan}`,
3897
+ `demo package: ${artifactPaths.demoPackage}`
3898
+ ]
3899
+ : []),
3797
3900
  reportInput.dataMode === "sample"
3798
3901
  ? `DEMO SAMPLE · illustrative cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)} · not user data`
3799
3902
  : reportInput.dataMode === "connected_provider" &&
@@ -3801,19 +3904,26 @@ async function reportCommand(args, runtime = {}) {
3801
3904
  ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
3802
3905
  : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
3803
3906
  runtime.telemetryDisclosure === true
3804
- ? "privacy: report rendered locally · anonymous command counts shared · aibill telemetry off; only explicit sync-provider contacts the selected provider"
3907
+ ? `privacy: report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
3805
3908
  : "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3806
3909
  "",
3807
3910
  "next:",
3808
3911
  ` open ${htmlPath} view the full report in your browser`,
3809
3912
  ` less ${markdownPath} read it in the terminal`,
3810
- reportableExperiment
3811
- ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
3812
- : qualitativeActionsSuppressed
3813
- ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
3814
- : reportInput.dataMode === "sample"
3815
- ? ` ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary`
3816
- : ` ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} print the paste-ready coding-agent prompt from this exact evidence window`
3913
+ machineWide
3914
+ // apply/improve need one exact project folder — a machine-wide
3915
+ // report must never point at a command that then refuses (the exact
3916
+ // trap this mode removes).
3917
+ ? reportInput.dataMode === "sample"
3918
+ ? ` cd <project> && ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary from one exact project folder`
3919
+ : ` cd <project> && ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} per-project action plan from one exact project folder`
3920
+ : reportableExperiment
3921
+ ? ` ${improveRuntimeCommand} review canonical token test ${reportableExperiment.id}`
3922
+ : qualitativeActionsSuppressed
3923
+ ? ` ${actionRuntimeCommand(`context --json --since-days ${sinceDays}`)} complete bounded qualitative evidence before any action`
3924
+ : reportInput.dataMode === "sample"
3925
+ ? ` ${actionRuntimeCommand("apply --sample")} print the non-executable demo boundary`
3926
+ : ` ${actionRuntimeCommand(`apply --since-days ${sinceDays}`)} print the paste-ready coding-agent prompt from this exact evidence window`
3817
3927
  ].join("\n"));
3818
3928
  }
3819
3929
  catch (error) {
@@ -3839,11 +3949,25 @@ async function resolveReceiptPath(rootPath, out) {
3839
3949
  return extname(resolved) ? resolved : `${resolved}.svg`;
3840
3950
  }
3841
3951
  async function reportCardCommand(args) {
3952
+ // 0.9.4: a broad root (home, /) runs MACHINE-WIDE — identical read-only
3953
+ // scanning to the bare receipt (loadInstantReadData below), SVG written to
3954
+ // the current directory. The card renders machine-wide content anyway, so
3955
+ // an exact-project requirement was incoherent here; only a bogus --path
3956
+ // still gets the friendly guard.
3957
+ const machineWide = isBroadScanRoot(args.path);
3958
+ if (!args.sample && !machineWide) {
3959
+ const rootGuard = await guardExactProjectRoot("report-card", args.path);
3960
+ if (rootGuard)
3961
+ return rootGuard;
3962
+ }
3842
3963
  try {
3843
- // Explicit sample mode reads no workspace data, so a broad-root scan guard
3844
- // would reject a harmless receipt written from the user's home directory.
3845
- // Output still goes through the safe-write/symlink checks below.
3846
- const rootPath = args.sample ? resolve(args.path) : await resolveSafeScanRoot(args.path);
3964
+ // Sample mode reads no workspace data and machine-wide mode reads only
3965
+ // the agent transcript dirs neither scans the current directory, so
3966
+ // both write the receipt from wherever the user stands. Output still
3967
+ // goes through the safe-write/symlink checks below.
3968
+ const rootPath = args.sample || machineWide
3969
+ ? resolve(args.path)
3970
+ : await resolveSafeScanRoot(args.path);
3847
3971
  const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
3848
3972
  if (records.length === 0) {
3849
3973
  return noEvidenceResult("report-card", warnings, args.sinceDays ?? 30);
@@ -3898,6 +4022,23 @@ async function reportCardCommand(args) {
3898
4022
  * exist (a location problem is not a breadth problem, but the fix is the
3899
4023
  * same). Returns undefined when the root is an acceptable exact project.
3900
4024
  */
4025
+ /**
4026
+ * True when the requested root is a machine-wide location (home, filesystem
4027
+ * root, a system directory, or anything containing home). report/report-card
4028
+ * treat this as MACHINE-WIDE MODE — the same read-only, transcript-dir
4029
+ * scanning the bare receipt performs — instead of refusing (0.9.4 founder
4030
+ * fix: the receipt's own Next pointer led from home into a refusal that
4031
+ * read as "the commands don't work"). Genuinely project-scoped commands
4032
+ * (improve, apply, verify, watch, connect, reset, …) keep the guard.
4033
+ */
4034
+ function isBroadScanRoot(requestedPath) {
4035
+ const rootPath = resolve(requestedPath);
4036
+ const home = homedir();
4037
+ const guardHome = home && home.trim().length > 0
4038
+ ? home
4039
+ : join(rootPath, "aibill-impossible-home-sentinel");
4040
+ return unsafeScanRootReason(rootPath, guardHome) !== undefined;
4041
+ }
3901
4042
  async function guardExactProjectRoot(commandName, requestedPath) {
3902
4043
  const rootPath = resolve(requestedPath);
3903
4044
  // An unset/empty $HOME (containers) must never make the current directory
@@ -5333,6 +5474,12 @@ function formatMeasuredPercent(value) {
5333
5474
  return `${value.toFixed(digits).replace(/\.00$/u, "").replace(/(\.\d)0$/u, "$1")}%`;
5334
5475
  }
5335
5476
  async function applyArtifactCommand(args) {
5477
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
5478
+ // broad root produces the SAME friendly exact-project guidance — never the
5479
+ // raw scan refusal, never the crash wrapper.
5480
+ const rootGuard = await guardExactProjectRoot("apply", args.path);
5481
+ if (rootGuard)
5482
+ return rootGuard;
5336
5483
  const rootPath = resolve(args.path);
5337
5484
  try {
5338
5485
  const sinceDays = args.sinceDays ?? 30;
@@ -5418,6 +5565,12 @@ async function applyArtifactCommand(args) {
5418
5565
  }
5419
5566
  }
5420
5567
  async function tokenVerificationCommand(args) {
5568
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
5569
+ // broad root produces the SAME friendly exact-project guidance — never the
5570
+ // raw scan refusal, never the crash wrapper.
5571
+ const rootGuard = await guardExactProjectRoot("verify", args.path);
5572
+ if (rootGuard)
5573
+ return rootGuard;
5421
5574
  if (args.sample) {
5422
5575
  return {
5423
5576
  exitCode: 1,
@@ -5802,6 +5955,40 @@ function applyEvidenceAcquisitionLines(input) {
5802
5955
  }
5803
5956
  return lines;
5804
5957
  }
5958
+ /**
5959
+ * Machine-wide report input (0.9.4): the bare receipt's own data path —
5960
+ * loadInstantReadData over the agent transcript dirs (read-only) — rendered
5961
+ * through the report package. No project state is read or created; plan
5962
+ * detection is home-scoped metadata, exactly as the receipt reads it.
5963
+ */
5964
+ async function buildMachineWideReportInput(args, sinceDays) {
5965
+ const { records, mode, providerCoverage, warnings } = await loadInstantReadData(args);
5966
+ if (records.length === 0) {
5967
+ // Same honest empty-state voice the receipt/report-card use — an empty
5968
+ // report file would just look broken.
5969
+ return { kind: "no_evidence", warnings };
5970
+ }
5971
+ const detectedPlans = await detectLocalPlans({
5972
+ claudeConfigPath: process.env.AI_SPEND_CLAUDE_CONFIG,
5973
+ codexAuthPath: process.env.AI_SPEND_CODEX_AUTH
5974
+ }).catch(() => []);
5975
+ const headlineRecords = mode === "connected"
5976
+ ? selectProviderFinancialHeadlineRecords(records)
5977
+ : records;
5978
+ return {
5979
+ kind: "input",
5980
+ input: {
5981
+ generatedAt: new Date().toISOString(),
5982
+ summary: analyzeSpend(headlineRecords),
5983
+ allRecords: records,
5984
+ dataMode: mode === "connected" ? "connected_provider" : "local_logs",
5985
+ evidenceWindowDays: sinceDays,
5986
+ detectedPlans,
5987
+ ...(mode === "connected" ? { providerRecords: records } : {}),
5988
+ ...(providerCoverage ? { providerCoverage } : {})
5989
+ }
5990
+ };
5991
+ }
5805
5992
  async function buildExplicitSampleReportInput(rootPath) {
5806
5993
  const records = await loadSampleUsageData();
5807
5994
  return {
@@ -6871,7 +7058,7 @@ function helpText(telemetryDisclosure) {
6871
7058
  " 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
6872
7059
  "",
6873
7060
  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."
7061
+ ? `Privacy: local analysis and reports upload nothing; ${telemetryDisclosureLine}. Only explicit sync-provider contacts the selected provider through an env: reference.`
6875
7062
  : "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
6876
7063
  "aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
6877
7064
  ].join("\n");
@@ -6954,6 +7141,7 @@ export async function runMain() {
6954
7141
  let askOutcome = { kind: "no_ask" };
6955
7142
  let promptInterface;
6956
7143
  let guidedInterface;
7144
+ let consentReader;
6957
7145
  try {
6958
7146
  let guidedIoShared;
6959
7147
  const runPipeline = () => runCli(argv, {
@@ -6967,6 +7155,15 @@ export async function runMain() {
6967
7155
  }
6968
7156
  return promptInterface.question(question);
6969
7157
  },
7158
+ // Consent-grade read for `signup <email>` (adversary SF1): buffered
7159
+ // type-ahead never answers; EOF/^C resolve undefined quietly.
7160
+ consentRead: async (query, timeoutMs) => {
7161
+ if (!consentReader) {
7162
+ const signup = await import("./signup.js");
7163
+ consentReader = await signup.openTerminalConsentRead();
7164
+ }
7165
+ return consentReader ? consentReader.read(query, timeoutMs) : undefined;
7166
+ },
6970
7167
  openGuidedIo: async () => {
6971
7168
  if (!guidedIoShared) {
6972
7169
  const { createInterface } = await import("node:readline");
@@ -7020,6 +7217,7 @@ export async function runMain() {
7020
7217
  finally {
7021
7218
  promptInterface?.close();
7022
7219
  guidedInterface?.close();
7220
+ consentReader?.close();
7023
7221
  spinner?.stop();
7024
7222
  }
7025
7223
  // A read that ended without the user pressing Enter (timeout / Ctrl-C)
package/dist/signup.d.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * CLI email capture — the launch-list signup lane (v0.9.2).
3
3
  *
4
- * Design: docs/qa-handoff/CLI_CAPTURE_DESIGN.md (see its dated placement
5
- * addendum). QA verdict (its B/M fixes are mandatory):
6
- * docs/qa-handoff/CLI_CAPTURE_QA_VERDICT.md.
4
+ * Design: the CLI capture design + its dated placement addendum
5
+ * (2026-08-24). The QA verdict's B/M fixes are mandatory and are encoded
6
+ * in the rules below — the verdict tags (B1, B2, M1…) cite it. The email
7
+ * promise each ref makes is anchored publicly in docs/EMAIL_SEND_POLICY.md.
7
8
  *
8
9
  * Placement (founder decision 2026-08-24): the ONE ask runs PRE-RECEIPT,
9
10
  * DURING the first evidence scan — it fills the first-run wait instead of
@@ -42,8 +43,8 @@ export declare function sanitizeSignupRefTag(raw: string): string | undefined;
42
43
  export declare function buildWaitlistRef(surface: WaitlistRefSurface, tag?: string): string;
43
44
  /**
44
45
  * The exact bytes sent — key order pinned. Payload creep (adding os/plan/
45
- * version data, or stuffing values into ref) fails the CI creep-guard test;
46
- * see docs/qa-handoff/CLI_CAPTURE_DESIGN.md §3c before touching this.
46
+ * version data, or stuffing values into ref) fails the CI creep-guard test
47
+ * (signup.test.ts); read that test's contract before touching this.
47
48
  */
48
49
  export declare function serializeWaitlistPayload(payload: WaitlistPayload): string;
49
50
  export type WaitlistPostOutcome = "sent" | "invalid_email" | "rate_limited" | "unreachable";
@@ -166,11 +167,37 @@ export declare function signupAskAllowed(read: SignupStateRead, now: Date): bool
166
167
  export type SignupAskIo = {
167
168
  /** Resolves the typed line, or undefined when the read timed out / aborted. */
168
169
  question: (query: string, timeoutMs: number) => Promise<string | undefined>;
170
+ /**
171
+ * Consent-grade read (0.9.3): drains every buffered byte before rendering
172
+ * and ignores lines that were already in flight when the prompt rendered —
173
+ * the same burst-guard discipline the APPROVE screen uses. A consent
174
+ * question may NEVER be answered by a buffered byte. Scripted/test IO may
175
+ * omit it; the consent step then falls back to `question`.
176
+ */
177
+ questionFresh?: (query: string, timeoutMs: number) => Promise<string | undefined>;
169
178
  write: (line: string) => void;
170
179
  /** Raw prompt redraw after a nudge line (no newline appended). */
171
180
  writeRaw?: (text: string) => void;
172
181
  };
173
182
  export declare const signupAskTimeoutMs = 30000;
183
+ /**
184
+ * Consent burst-guard (0.9.3, founder incident 2026-08-24): lines arriving
185
+ * within this window of the previous line belong to the SAME burst and
186
+ * inherit its first timestamp — the guided prompt engine's convention
187
+ * (createInteractivePromptSource). Key-repeat Enters and paste chains can
188
+ * therefore never look "fresh" one line at a time.
189
+ */
190
+ export declare const signupConsentBurstWindowMs = 75;
191
+ /**
192
+ * Minimum time between the consent question rendering and a line that may
193
+ * answer it. The payload JSON is ~100 characters; no human reads it and
194
+ * decides faster than this. Anything quicker is an in-flight keypress from
195
+ * the email entry (the reproduced production failure: the follow-up Enter
196
+ * landed ~0.4–0.7s after the email and silently auto-declined consent).
197
+ * Discarded lines never re-render the prompt — the read simply keeps
198
+ * waiting for a deliberate keypress.
199
+ */
200
+ export declare const signupConsentFreshKeypressMs = 1000;
174
201
  export type PreReceiptAskOutcome = {
175
202
  kind: "no_ask";
176
203
  } | {
@@ -215,6 +242,15 @@ export declare function openPreReceiptSignupAsk(options: {
215
242
  * The consent step, strictly AFTER the receipt has printed: scope line, the
216
243
  * literal payload JSON, a typed y — then ONE POST. Failures never persist,
217
244
  * retry, or queue the typed email.
245
+ *
246
+ * 0.9.3 contract (founder incident 2026-08-24 — production consent was
247
+ * auto-declined by the Enter that had submitted the email moments earlier):
248
+ * - The read goes through io.questionFresh when the binding provides it, so
249
+ * a buffered or in-flight byte can never be the answer.
250
+ * - EVERY resolution prints exactly one final outcome line: sentLine on a
251
+ * 201, nothingSentLine on decline / timeout / interrupt / close / send
252
+ * failure (failure detail prints above it). The human is never left
253
+ * guessing whether anything left the machine.
218
254
  */
219
255
  export declare function runSignupConsentAfterReceipt(options: {
220
256
  io: SignupAskIo;
@@ -268,5 +304,23 @@ export type TerminalPreReceiptAsk = {
268
304
  * the receipt still renders. Returns undefined — with zero output — when
269
305
  * signup state disallows the ask.
270
306
  */
307
+ /**
308
+ * Consent-grade terminal read for the explicit `aibill signup <email>`
309
+ * command (adversary SF1). The plain readline prompt let a buffered
310
+ * type-ahead `y` auto-consent and turned Ctrl-D/Ctrl-C into the crash
311
+ * voice. Same discipline as the pre-receipt binding's questionFresh:
312
+ * drain buffered input before rendering, render once, accept only a line
313
+ * whose burst began after the render AND that arrived past the
314
+ * fresh-keypress holdoff; EOF and ^C resolve undefined SILENTLY so the
315
+ * command's own "nothing sent" outcome line speaks.
316
+ *
317
+ * (Deliberately a compact standalone rather than a refactor of the
318
+ * pre-receipt binding two days before launch — the mechanics mirror
319
+ * questionFresh above; change them together.)
320
+ */
321
+ export declare function openTerminalConsentRead(): Promise<{
322
+ read: (query: string, timeoutMs: number) => Promise<string | undefined>;
323
+ close: () => void;
324
+ } | undefined>;
271
325
  export declare function openPreReceiptSignupAskInTerminal(): Promise<TerminalPreReceiptAsk | undefined>;
272
326
  //# sourceMappingURL=signup.d.ts.map
package/dist/signup.js CHANGED
@@ -5,9 +5,10 @@ import { dirname, join } from "node:path";
5
5
  /**
6
6
  * CLI email capture — the launch-list signup lane (v0.9.2).
7
7
  *
8
- * Design: docs/qa-handoff/CLI_CAPTURE_DESIGN.md (see its dated placement
9
- * addendum). QA verdict (its B/M fixes are mandatory):
10
- * docs/qa-handoff/CLI_CAPTURE_QA_VERDICT.md.
8
+ * Design: the CLI capture design + its dated placement addendum
9
+ * (2026-08-24). The QA verdict's B/M fixes are mandatory and are encoded
10
+ * in the rules below — the verdict tags (B1, B2, M1…) cite it. The email
11
+ * promise each ref makes is anchored publicly in docs/EMAIL_SEND_POLICY.md.
11
12
  *
12
13
  * Placement (founder decision 2026-08-24): the ONE ask runs PRE-RECEIPT,
13
14
  * DURING the first evidence scan — it fills the first-run wait instead of
@@ -69,8 +70,8 @@ export function buildWaitlistRef(surface, tag) {
69
70
  }
70
71
  /**
71
72
  * The exact bytes sent — key order pinned. Payload creep (adding os/plan/
72
- * version data, or stuffing values into ref) fails the CI creep-guard test;
73
- * see docs/qa-handoff/CLI_CAPTURE_DESIGN.md §3c before touching this.
73
+ * version data, or stuffing values into ref) fails the CI creep-guard test
74
+ * (signup.test.ts); read that test's contract before touching this.
74
75
  */
75
76
  export function serializeWaitlistPayload(payload) {
76
77
  return JSON.stringify({ email: payload.email, ref: payload.ref });
@@ -320,7 +321,10 @@ export async function readSignupState(filePath) {
320
321
  /** Atomic-ish write; returns false instead of throwing so callers fail closed. */
321
322
  export async function writeSignupState(filePath, state) {
322
323
  try {
323
- await mkdir(dirname(filePath), { recursive: true });
324
+ // 0o700 (NEW-B1): under the default umask a modeless mkdir left
325
+ // ~/.aibill at 755, which the private-cache guard then refused —
326
+ // dead-ending `init` on every fresh machine after the first ask stamp.
327
+ await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
324
328
  const temporaryPath = `${filePath}.tmp`;
325
329
  await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
326
330
  await rename(temporaryPath, filePath);
@@ -370,6 +374,24 @@ export function signupAskAllowed(read, now) {
370
374
  return true;
371
375
  }
372
376
  export const signupAskTimeoutMs = 30_000;
377
+ /**
378
+ * Consent burst-guard (0.9.3, founder incident 2026-08-24): lines arriving
379
+ * within this window of the previous line belong to the SAME burst and
380
+ * inherit its first timestamp — the guided prompt engine's convention
381
+ * (createInteractivePromptSource). Key-repeat Enters and paste chains can
382
+ * therefore never look "fresh" one line at a time.
383
+ */
384
+ export const signupConsentBurstWindowMs = 75;
385
+ /**
386
+ * Minimum time between the consent question rendering and a line that may
387
+ * answer it. The payload JSON is ~100 characters; no human reads it and
388
+ * decides faster than this. Anything quicker is an in-flight keypress from
389
+ * the email entry (the reproduced production failure: the follow-up Enter
390
+ * landed ~0.4–0.7s after the email and silently auto-declined consent).
391
+ * Discarded lines never re-render the prompt — the read simply keeps
392
+ * waiting for a deliberate keypress.
393
+ */
394
+ export const signupConsentFreshKeypressMs = 1_000;
373
395
  /**
374
396
  * Opens the ONE ask, or returns undefined (with zero output) when state
375
397
  * disallows it — subsequent runs stay byte-identical to the fast path.
@@ -470,6 +492,9 @@ export async function openPreReceiptSignupAsk(options) {
470
492
  notifyReceiptReady: () => {
471
493
  if (settled)
472
494
  return;
495
+ // Break off the pending prompt row first (PC-4a: without the leading
496
+ // newline the ready line rendered glued to the open " > " prompt).
497
+ io.writeRaw?.("\n");
473
498
  io.write(` ${signupCopy.receiptReadyLine}`);
474
499
  io.writeRaw?.(signupCopy.askPrompt);
475
500
  }
@@ -479,17 +504,32 @@ export async function openPreReceiptSignupAsk(options) {
479
504
  * The consent step, strictly AFTER the receipt has printed: scope line, the
480
505
  * literal payload JSON, a typed y — then ONE POST. Failures never persist,
481
506
  * retry, or queue the typed email.
507
+ *
508
+ * 0.9.3 contract (founder incident 2026-08-24 — production consent was
509
+ * auto-declined by the Enter that had submitted the email moments earlier):
510
+ * - The read goes through io.questionFresh when the binding provides it, so
511
+ * a buffered or in-flight byte can never be the answer.
512
+ * - EVERY resolution prints exactly one final outcome line: sentLine on a
513
+ * 201, nothingSentLine on decline / timeout / interrupt / close / send
514
+ * failure (failure detail prints above it). The human is never left
515
+ * guessing whether anything left the machine.
482
516
  */
483
517
  export async function runSignupConsentAfterReceipt(options) {
484
518
  const { io, stamped } = options;
485
519
  io.write("");
486
520
  io.write(signupCopy.scopeLine);
487
- const consent = await io.question(signupCopy.consentQuestion(serializeWaitlistPayload(options.payload)), signupAskTimeoutMs);
488
- if (consent === undefined)
521
+ const read = io.questionFresh ?? io.question;
522
+ const consent = await read(signupCopy.consentQuestion(serializeWaitlistPayload(options.payload)), signupAskTimeoutMs);
523
+ if (consent === undefined) {
524
+ // Timeout / close / interrupt: no decision, no skip consumed (M3) —
525
+ // but the resolution is still announced.
526
+ io.write(signupCopy.nothingSentLine);
489
527
  return;
528
+ }
490
529
  const consentAnswer = consent.trim().toLowerCase();
491
530
  if (consentAnswer !== "y" && consentAnswer !== "yes") {
492
531
  await writeSignupState(options.stateFilePath, { ...stamped, askCount: stamped.askCount + 1 });
532
+ io.write(signupCopy.nothingSentLine);
493
533
  return;
494
534
  }
495
535
  const outcome = await postWaitlistSignup(options.payload, {
@@ -511,6 +551,7 @@ export async function runSignupConsentAfterReceipt(options) {
511
551
  : outcome === "rate_limited"
512
552
  ? signupCopy.rateLimitedLine
513
553
  : signupCopy.unreachableLine);
554
+ io.write(signupCopy.nothingSentLine);
514
555
  }
515
556
  /**
516
557
  * Which argv shapes qualify for the during-scan ask: the real receipt path
@@ -562,18 +603,120 @@ export async function orchestratePreReceiptAsk(input) {
562
603
  * the receipt still renders. Returns undefined — with zero output — when
563
604
  * signup state disallows the ask.
564
605
  */
606
+ /**
607
+ * Consent-grade terminal read for the explicit `aibill signup <email>`
608
+ * command (adversary SF1). The plain readline prompt let a buffered
609
+ * type-ahead `y` auto-consent and turned Ctrl-D/Ctrl-C into the crash
610
+ * voice. Same discipline as the pre-receipt binding's questionFresh:
611
+ * drain buffered input before rendering, render once, accept only a line
612
+ * whose burst began after the render AND that arrived past the
613
+ * fresh-keypress holdoff; EOF and ^C resolve undefined SILENTLY so the
614
+ * command's own "nothing sent" outcome line speaks.
615
+ *
616
+ * (Deliberately a compact standalone rather than a refactor of the
617
+ * pre-receipt binding two days before launch — the mechanics mirror
618
+ * questionFresh above; change them together.)
619
+ */
620
+ export async function openTerminalConsentRead() {
621
+ try {
622
+ const { createInterface } = await import("node:readline/promises");
623
+ const lineInterface = createInterface({ input: process.stdin, output: process.stdout });
624
+ lineInterface.setPrompt("");
625
+ let waiter;
626
+ let done = false;
627
+ const settleWaiter = (line) => {
628
+ const settle = waiter;
629
+ waiter = undefined;
630
+ if (settle)
631
+ settle(line);
632
+ };
633
+ const strayLines = [];
634
+ let lastArrivalMs;
635
+ let burstStartMs;
636
+ lineInterface.on("line", (line) => {
637
+ const arrivedAtMs = Date.now();
638
+ if (lastArrivalMs === undefined || arrivedAtMs - lastArrivalMs > signupConsentBurstWindowMs) {
639
+ burstStartMs = arrivedAtMs;
640
+ }
641
+ lastArrivalMs = arrivedAtMs;
642
+ const arrived = { text: line, arrivedAtMs, burstStartAtMs: burstStartMs ?? arrivedAtMs };
643
+ if (waiter)
644
+ settleWaiter(arrived);
645
+ else
646
+ strayLines.push(arrived);
647
+ });
648
+ lineInterface.on("close", () => {
649
+ done = true;
650
+ settleWaiter(undefined);
651
+ });
652
+ lineInterface.on("SIGINT", () => {
653
+ // No copy: the command's outcome line ("nothing sent") is the answer.
654
+ // Deliberately NO close() here — closing the interface from inside its
655
+ // own keypress processing leaves a piped stdin flowing (the process
656
+ // then never exits); the bin's finally owns the close, exactly like
657
+ // the answered path.
658
+ done = true;
659
+ process.stdout.write("\n");
660
+ settleWaiter(undefined);
661
+ });
662
+ return {
663
+ read: async (query, timeoutMs) => {
664
+ if (done)
665
+ return undefined;
666
+ strayLines.length = 0;
667
+ try {
668
+ const editable = lineInterface;
669
+ editable.line = "";
670
+ editable.cursor = 0;
671
+ }
672
+ catch {
673
+ // Cosmetic only; the burst-guard below still refuses stale lines.
674
+ }
675
+ const renderedAtMs = Date.now();
676
+ process.stdout.write(query);
677
+ const timer = setTimeout(() => settleWaiter(undefined), timeoutMs);
678
+ timer.unref?.();
679
+ try {
680
+ for (;;) {
681
+ const answer = await new Promise((resolvePromise) => {
682
+ waiter = resolvePromise;
683
+ });
684
+ if (answer === undefined)
685
+ return undefined;
686
+ if (answer.burstStartAtMs <= renderedAtMs)
687
+ continue;
688
+ if (answer.arrivedAtMs - renderedAtMs < signupConsentFreshKeypressMs)
689
+ continue;
690
+ return answer.text;
691
+ }
692
+ }
693
+ finally {
694
+ clearTimeout(timer);
695
+ }
696
+ },
697
+ close: () => {
698
+ lineInterface.close();
699
+ }
700
+ };
701
+ }
702
+ catch {
703
+ return undefined;
704
+ }
705
+ }
565
706
  export async function openPreReceiptSignupAskInTerminal() {
566
707
  try {
567
708
  const { createInterface } = await import("node:readline/promises");
568
709
  const lineInterface = createInterface({ input: process.stdin, output: process.stdout });
710
+ // Kill readline's own default "> " prompt: this interface never calls
711
+ // prompt() itself, but terminal-mode readline repaints its prompt on any
712
+ // internal refresh (backspace edits, cursor movement). With the default
713
+ // marker those repaints were the only in-process source of stray ">"
714
+ // markers over our output (founder incident 2026-08-24: the consent
715
+ // line rendered with the marker repeated and "[y/N]" overwritten). An
716
+ // empty prompt makes every internal repaint marker-free by construction.
717
+ lineInterface.setPrompt("");
569
718
  let interrupted = false;
570
719
  let abortedRead = false;
571
- // Ctrl-C must SETTLE the pending read (closing the interface alone
572
- // leaves the waiter unsettled, draining the loop before the receipt
573
- // prints — the ask would eat the whole run). The interrupt closes the
574
- // read deterministically; the ask resolves as a no-decision, one ack
575
- // line explains that the scan continues (QA M2), and the receipt still
576
- // renders.
577
720
  let waiter;
578
721
  const settleWaiter = (line) => {
579
722
  const settle = waiter;
@@ -586,40 +729,92 @@ export async function openPreReceiptSignupAskInTerminal() {
586
729
  // the guided engine's drain notice when the next read arms — they are
587
730
  // never used as answers, so pasted input can neither pre-answer the
588
731
  // consent step nor strand the user in 30s of silent dead air.
732
+ //
733
+ // 0.9.3: every line additionally carries its arrival time and the start
734
+ // time of the burst it belongs to (guided-engine convention: lines
735
+ // within signupConsentBurstWindowMs of the previous line inherit the
736
+ // burst's FIRST timestamp — key-repeat and paste chains count as one
737
+ // burst). The consent read uses this to refuse in-flight bytes.
589
738
  const strayLines = [];
739
+ let lastArrivalMs;
740
+ let burstStartMs;
590
741
  lineInterface.on("line", (line) => {
742
+ const arrivedAtMs = Date.now();
743
+ if (lastArrivalMs === undefined || arrivedAtMs - lastArrivalMs > signupConsentBurstWindowMs) {
744
+ burstStartMs = arrivedAtMs;
745
+ }
746
+ lastArrivalMs = arrivedAtMs;
747
+ const arrived = { text: line, arrivedAtMs, burstStartAtMs: burstStartMs ?? arrivedAtMs };
591
748
  if (waiter)
592
- settleWaiter(line);
749
+ settleWaiter(arrived);
593
750
  else
594
- strayLines.push(line);
751
+ strayLines.push(arrived);
595
752
  });
753
+ // NEW-B2 (cold-start audit): EOF must behave like a skip, never swallow
754
+ // the receipt. When stdin closes while NO read is armed (between ask
755
+ // reads, or before the first), the old code armed the next read against
756
+ // a dead interface with only an unref'd timer left — the event loop
757
+ // drained and the process exited 0 BEFORE the receipt printed. The
758
+ // closed flag makes every subsequent read resolve undefined instantly.
759
+ let closed = false;
596
760
  lineInterface.on("close", () => {
761
+ closed = true;
597
762
  settleWaiter(undefined);
598
763
  });
764
+ // SF2 (adversary): the interrupt copy is ask-phase copy. Once the flow
765
+ // has moved to the consent question, "skipped the ask · your receipt is
766
+ // still being read" is triple-false (email given, receipt rendered,
767
+ // exit imminent) — in consent phase a ^C settles the read silently and
768
+ // the consent step's own outcome line ("nothing sent") speaks.
769
+ let phase = "ask";
599
770
  lineInterface.on("SIGINT", () => {
600
771
  interrupted = true;
601
- if (waiter) {
602
- // A read was pending: skip the ask, keep the scan (QA M2).
603
- process.stdout.write(`\n ${signupCopy.interruptLine}\n`);
604
- settleWaiter(undefined);
772
+ if (phase === "ask") {
773
+ if (waiter) {
774
+ // A read was pending: skip the ask, keep the scan (QA M2).
775
+ process.stdout.write(`\n ${signupCopy.interruptLine}\n`);
776
+ }
777
+ else {
778
+ // No read pending (already answered): readline was swallowing
779
+ // the ^C — say so and stand aside so the NEXT ^C gets the
780
+ // default kill behavior.
781
+ process.stdout.write(`\n ${signupCopy.interruptAfterAnswerLine}\n`);
782
+ }
605
783
  }
606
784
  else {
607
- // No read pending (already answered / consent done): readline was
608
- // swallowing the ^C — say so and stand aside so the NEXT ^C gets
609
- // the default kill behavior.
610
- process.stdout.write(`\n ${signupCopy.interruptAfterAnswerLine}\n`);
785
+ // Consent phase: end the line the ^C landed on; nothing more.
786
+ process.stdout.write("\n");
611
787
  }
788
+ settleWaiter(undefined);
612
789
  lineInterface.close();
613
790
  });
791
+ const drainStrayLines = () => {
792
+ if (strayLines.length === 0)
793
+ return;
794
+ const discarded = strayLines.length;
795
+ strayLines.length = 0;
796
+ process.stdout.write(` ${renderDrainNotice(discarded)}\n`);
797
+ };
614
798
  const io = {
615
799
  question: async (query, timeoutMs) => {
616
800
  if (interrupted)
617
801
  return undefined;
618
- if (strayLines.length > 0) {
619
- const discarded = strayLines.length;
620
- strayLines.length = 0;
621
- process.stdout.write(` ${renderDrainNotice(discarded)}\n`);
802
+ // PC-5/PC-4b (cold-start audit): a rapid second Enter lands while
803
+ // the ask loop is between reads (both keypresses can even share one
804
+ // stdin chunk) — discarding it as "paste" broke the double-Enter
805
+ // skip pair (askCount stayed 0), printed a drain notice the human
806
+ // never earned, and left a 30s dead prompt. Ask-phase lines typed
807
+ // between ask reads ARE the conversation: feed them in order (even
808
+ // after EOF, so a completed Enter-Enter pair still counts). The
809
+ // consent step never sees them — questionFresh drains + burst-guards
810
+ // (QA M4's actual goal).
811
+ const buffered = strayLines.shift();
812
+ if (buffered !== undefined) {
813
+ abortedRead = false;
814
+ return buffered.text;
622
815
  }
816
+ if (closed)
817
+ return undefined;
623
818
  process.stdout.write(query);
624
819
  const timer = setTimeout(() => settleWaiter(undefined), timeoutMs);
625
820
  timer.unref?.();
@@ -628,7 +823,55 @@ export async function openPreReceiptSignupAskInTerminal() {
628
823
  waiter = resolvePromise;
629
824
  });
630
825
  abortedRead = answer === undefined;
631
- return answer;
826
+ return answer?.text;
827
+ }
828
+ finally {
829
+ clearTimeout(timer);
830
+ }
831
+ },
832
+ // Consent-grade read (founder incident 2026-08-24: the Enter that had
833
+ // submitted the email answered — and silently declined — the consent
834
+ // question that armed a few hundred ms later). Contract:
835
+ // (a) drain EVERY buffered byte before rendering: whole stray lines
836
+ // AND the half-typed remainder readline is still holding;
837
+ // (b) render the full line exactly once — discarded arrivals never
838
+ // re-print the prompt, and nothing here redraws a marker;
839
+ // (c) only a FRESH keypress answers: lines whose burst began at or
840
+ // before the render (key-repeat/paste chains), or that landed
841
+ // faster than a human could have read the payload, are dropped
842
+ // silently while the read keeps waiting.
843
+ questionFresh: async (query, timeoutMs) => {
844
+ if (interrupted || closed)
845
+ return undefined;
846
+ drainStrayLines();
847
+ try {
848
+ // Clear readline's in-progress line buffer without a repaint —
849
+ // bytes typed before the question rendered must not seed its
850
+ // answer. Internal fields, so cosmetic-only failure is fine.
851
+ const editable = lineInterface;
852
+ editable.line = "";
853
+ editable.cursor = 0;
854
+ }
855
+ catch {
856
+ // Best-effort; the burst-guard below still refuses stale lines.
857
+ }
858
+ const renderedAtMs = Date.now();
859
+ process.stdout.write(query);
860
+ const timer = setTimeout(() => settleWaiter(undefined), timeoutMs);
861
+ timer.unref?.();
862
+ try {
863
+ for (;;) {
864
+ const answer = await new Promise((resolvePromise) => {
865
+ waiter = resolvePromise;
866
+ });
867
+ if (answer === undefined)
868
+ return undefined;
869
+ if (answer.burstStartAtMs <= renderedAtMs)
870
+ continue;
871
+ if (answer.arrivedAtMs - renderedAtMs < signupConsentFreshKeypressMs)
872
+ continue;
873
+ return answer.text;
874
+ }
632
875
  }
633
876
  finally {
634
877
  clearTimeout(timer);
@@ -655,8 +898,14 @@ export async function openPreReceiptSignupAskInTerminal() {
655
898
  session,
656
899
  needsFreshLine: () => abortedRead,
657
900
  runConsent: async (outcome) => {
658
- if (interrupted)
901
+ phase = "consent";
902
+ if (interrupted) {
903
+ // The ask was interrupted after the email was typed: consent never
904
+ // renders, but the resolution is still announced (0.9.3 — an
905
+ // email-shaped run must never end without an outcome line).
906
+ io.write(signupCopy.nothingSentLine);
659
907
  return;
908
+ }
660
909
  try {
661
910
  await runSignupConsentAfterReceipt({
662
911
  io,
@@ -139,14 +139,14 @@ export function renderStatusline(result, options = {}) {
139
139
  const columns = normalizeColumns(options.columns ?? Number(process.env.COLUMNS));
140
140
  const tier = columns >= 80 ? "full" : columns >= 50 ? "compact" : "minimal";
141
141
  if (result.status === "missing")
142
- return fitStatic("aibill · run aibill init", columns);
142
+ return fitStatic("aibill · run npx aibill init", columns);
143
143
  if (result.status === "error") {
144
- return fitStatic("aibill · cache error · run aibill init", columns);
144
+ return fitStatic("aibill · cache error · run npx aibill init", columns);
145
145
  }
146
146
  const snapshot = result.snapshot;
147
147
  const freshness = freshnessSegment(snapshot, now);
148
148
  if (snapshot.mode === "error") {
149
- return fitStatic(`aibill · ${freshness} · run aibill init`, columns);
149
+ return fitStatic(`aibill · ${freshness} · run npx aibill init`, columns);
150
150
  }
151
151
  if (snapshot.mode === "empty") {
152
152
  return assembleLine(["no usage yet"], freshness, columns);
@@ -169,7 +169,7 @@ export function renderStatusline(result, options = {}) {
169
169
  segments = renderUnresolved(snapshot, tier);
170
170
  break;
171
171
  default:
172
- segments = ["cache error", "run aibill init"];
172
+ segments = ["cache error", "run npx aibill init"];
173
173
  }
174
174
  if (overage) {
175
175
  // Billed overage is the sole compact paid-alert bridge and must survive
@@ -248,7 +248,7 @@ export async function runStatuslineHook(options = {}) {
248
248
  stdout.on("error", () => undefined);
249
249
  guardedOutputs.add(stdout);
250
250
  }
251
- let line = "aibill · cache error · run aibill init";
251
+ let line = "aibill · cache error · run npx aibill init";
252
252
  try {
253
253
  const [result] = await Promise.all([
254
254
  readStatuslineCache(options.cache),
@@ -1343,7 +1343,7 @@ if (isDirectInvocation()) {
1343
1343
  process.exitCode = 0;
1344
1344
  }).catch(() => {
1345
1345
  try {
1346
- process.stdout.write("aibill · cache error · run aibill init\n");
1346
+ process.stdout.write("aibill · cache error · run npx aibill init\n");
1347
1347
  }
1348
1348
  catch {
1349
1349
  // No stderr or non-zero exit on a hook path.
@@ -31,7 +31,7 @@ import { spawn } from "node:child_process";
31
31
  */
32
32
  export declare const telemetryUrl = "https://asktilden.com/api/telemetry";
33
33
  /** Server-allowlisted command labels; anything else is sent as "other". */
34
- export declare const telemetryCommands: readonly ["receipt", "full", "group-by", "improve", "improve-sample", "index", "identify", "accountability", "outcome", "statusline", "statusline-expand", "signup", "connect", "sync-provider", "doctor", "report", "report-card", "apply", "watch", "init", "verify", "drop-slice", "telemetry", "other"];
34
+ export declare const telemetryCommands: readonly ["receipt", "full", "group-by", "improve", "improve-sample", "index", "identify", "accountability", "outcome", "statusline", "statusline-expand", "signup", "connect", "sync-provider", "doctor", "glance", "report", "report-card", "apply", "watch", "init", "verify", "drop-slice", "telemetry", "other"];
35
35
  export type TelemetryCommand = (typeof telemetryCommands)[number];
36
36
  export type TelemetryDurationBucket = "lt1s" | "lt5s" | "lt30s" | "gte30s";
37
37
  export type TelemetryOs = "darwin" | "linux" | "win32" | "other";
@@ -48,9 +48,14 @@ export type TelemetryEvent = {
48
48
  ok: boolean;
49
49
  ts: string;
50
50
  };
51
- /** Printed instead of "nothing uploaded" while telemetry is active. */
52
- export declare const telemetryDisclosureLine = "anonymous command counts shared \u00B7 aibill telemetry off";
53
- export declare const telemetryNoticeLines: readonly ["aibill counts which commands run — anonymous, never your data or content", "turn off: aibill telemetry off", "see payloads: aibill telemetry"];
51
+ /**
52
+ * Printed instead of "nothing uploaded" while telemetry is active. The
53
+ * command is composed through the runtime command helper (aibillCommandV0,
54
+ * npx form): npx users have no bare `aibill` on PATH (0.9.2 founder
55
+ * incident — "command not found").
56
+ */
57
+ export declare const telemetryDisclosureLine: string;
58
+ export declare const telemetryNoticeLines: readonly ["aibill counts which commands run — anonymous, never your data or content", `turn off: ${string}`, `see payloads: ${string}`];
54
59
  export declare function telemetryOsLabel(platform?: string): TelemetryOs;
55
60
  export declare function telemetryArchLabel(arch?: string): TelemetryArch;
56
61
  export declare function telemetryDurationBucket(durationMs: number): TelemetryDurationBucket;
package/dist/telemetry.js CHANGED
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
+ import { aibillCommandV0 } from "@agent-finops/core";
6
7
  /**
7
8
  * CLI telemetry — anonymous command counts, notice-before-first-byte.
8
9
  *
@@ -51,6 +52,7 @@ export const telemetryCommands = [
51
52
  "connect",
52
53
  "sync-provider",
53
54
  "doctor",
55
+ "glance",
54
56
  "report",
55
57
  "report-card",
56
58
  "apply",
@@ -61,12 +63,17 @@ export const telemetryCommands = [
61
63
  "telemetry",
62
64
  "other"
63
65
  ];
64
- /** Printed instead of "nothing uploaded" while telemetry is active. */
65
- export const telemetryDisclosureLine = "anonymous command counts shared · aibill telemetry off";
66
+ /**
67
+ * Printed instead of "nothing uploaded" while telemetry is active. The
68
+ * command is composed through the runtime command helper (aibillCommandV0,
69
+ * npx form): npx users have no bare `aibill` on PATH (0.9.2 founder
70
+ * incident — "command not found").
71
+ */
72
+ export const telemetryDisclosureLine = `anonymous command counts shared · ${aibillCommandV0("telemetry off")}`;
66
73
  export const telemetryNoticeLines = [
67
74
  "aibill counts which commands run — anonymous, never your data or content",
68
- "turn off: aibill telemetry off",
69
- "see payloads: aibill telemetry"
75
+ `turn off: ${aibillCommandV0("telemetry off")}`,
76
+ `see payloads: ${aibillCommandV0("telemetry")}`
70
77
  ];
71
78
  export function telemetryOsLabel(platform = process.platform) {
72
79
  return platform === "darwin" || platform === "linux" || platform === "win32" ? platform : "other";
@@ -116,6 +123,7 @@ export function telemetryCommandForArgv(argv) {
116
123
  case "connect": return "connect";
117
124
  case "sync-provider": return "sync-provider";
118
125
  case "doctor": return "doctor";
126
+ case "glance": return "glance";
119
127
  case "report": return "report";
120
128
  case "report-card": return "report-card";
121
129
  case "apply":
@@ -166,7 +174,10 @@ export async function readTelemetryState(filePath) {
166
174
  }
167
175
  export async function writeTelemetryState(filePath, state) {
168
176
  try {
169
- await mkdir(dirname(filePath), { recursive: true });
177
+ // 0o700 (NEW-B1): under the default umask a modeless mkdir left
178
+ // ~/.aibill at 755, which the private-cache guard then refused —
179
+ // dead-ending `init` on every fresh machine after the notice stamp.
180
+ await mkdir(dirname(filePath), { recursive: true, mode: 0o700 });
170
181
  const temporaryPath = `${filePath}.tmp`;
171
182
  await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
172
183
  await rename(temporaryPath, filePath);
@@ -305,6 +316,13 @@ export async function openCliTelemetry(options = {}) {
305
316
  try {
306
317
  if (sessionTelemetryKilled)
307
318
  return;
319
+ // `glance` is a machine-invoked poll (the Glance menu-bar app spawns
320
+ // it every ~30s), not a human command — counting it is noise by
321
+ // definition and would flood the anonymous command counts (~2,880
322
+ // events/day/user). Never emit for it, notice or not; the label
323
+ // still exists in the map so any stray event is at least honest.
324
+ if (telemetryCommandForArgv(input.argv) === "glance")
325
+ return;
308
326
  if (envDisabled || read.kind === "unreadable")
309
327
  return;
310
328
  if (read.kind === "ok" && !read.state.enabled)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
4
4
  "funding": "https://asktilden.com",
5
5
  "description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
6
6
  "type": "module",
@@ -55,8 +55,8 @@
55
55
  "prepack": "npm run build"
56
56
  },
57
57
  "dependencies": {
58
- "@agent-finops/core": "0.9.2",
59
- "@agent-finops/report": "0.9.2",
58
+ "@agent-finops/core": "0.9.4",
59
+ "@agent-finops/report": "0.9.4",
60
60
  "yocto-spinner": "^1.2.0"
61
61
  }
62
62
  }