ai-spend-agent 0.9.2 → 0.9.3

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";
@@ -1013,7 +1013,27 @@ async function signupCommand(args, runtime) {
1013
1013
  return ok(signupCopy.alreadyLine);
1014
1014
  }
1015
1015
  const payload = { email, ref: buildWaitlistRef("signup", args.signupRef) };
1016
- const consent = (await runtime.prompt(`${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`)).trim().toLowerCase();
1016
+ const consentQuery = `${signupCopy.scopeLine}\n${signupCopy.consentQuestion(serializeWaitlistPayload(payload))}`;
1017
+ // Adversary SF1: the consent question must never be answered by a
1018
+ // buffered byte, and EOF/^C are a quiet "nothing sent", never the crash
1019
+ // voice — this is the exact command the receipt advertises.
1020
+ let consentAnswer;
1021
+ if (runtime.consentRead) {
1022
+ consentAnswer = await runtime.consentRead(consentQuery, signupAskTimeoutMs);
1023
+ }
1024
+ else {
1025
+ try {
1026
+ consentAnswer = await runtime.prompt(consentQuery);
1027
+ }
1028
+ catch {
1029
+ // readline/promises rejects on Ctrl-D/Ctrl-C ("Aborted with Ctrl+D").
1030
+ consentAnswer = undefined;
1031
+ }
1032
+ }
1033
+ if (consentAnswer === undefined) {
1034
+ return ok(signupCopy.nothingSentLine);
1035
+ }
1036
+ const consent = consentAnswer.trim().toLowerCase();
1017
1037
  if (consent !== "y" && consent !== "yes") {
1018
1038
  return ok(signupCopy.nothingSentLine);
1019
1039
  }
@@ -1045,7 +1065,7 @@ async function telemetryCommand(args, runtime) {
1045
1065
  return {
1046
1066
  exitCode: 1,
1047
1067
  stdout: "",
1048
- stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: aibill telemetry [on|off]`
1068
+ stderr: `Unknown telemetry action: ${sanitizeSecretishError(action)}\nUse: npx aibill telemetry [on|off]`
1049
1069
  };
1050
1070
  }
1051
1071
  if (action === "off") {
@@ -1095,7 +1115,7 @@ async function telemetryCommand(args, runtime) {
1095
1115
  "telemetry on · anonymous command counts only",
1096
1116
  `counted: command name, version, os, arch, ci flag, duration bucket, ok flag, timestamp`,
1097
1117
  "never: arguments, paths, file contents, project names, or your email",
1098
- "events start with your next run · see payloads anytime: aibill telemetry"
1118
+ "events start with your next run · see payloads anytime: npx aibill telemetry"
1099
1119
  ].join("\n"));
1100
1120
  }
1101
1121
  const lines = ["aibill telemetry"];
@@ -1122,7 +1142,7 @@ async function telemetryCommand(args, runtime) {
1122
1142
  else {
1123
1143
  lines.push("last payload sent: none");
1124
1144
  }
1125
- lines.push("switch: aibill telemetry on · aibill telemetry off");
1145
+ lines.push("switch: npx aibill telemetry on · npx aibill telemetry off");
1126
1146
  return ok(lines.join("\n"));
1127
1147
  }
1128
1148
  /** The run-level privacy claim: literal truth in both telemetry states. */
@@ -1271,7 +1291,7 @@ async function doctorCommand(args, runtime = {}) {
1271
1291
  `node version: ${process.version}`,
1272
1292
  `cli version: ${await cliVersion()}`,
1273
1293
  runtime.telemetryDisclosure === true
1274
- ? "local-first mode: enabled (evidence stays local · anonymous command counts shared · aibill telemetry off)"
1294
+ ? `local-first mode: enabled (evidence stays local · ${telemetryDisclosureLine})`
1275
1295
  : "local-first mode: enabled (no cloud upload, no telemetry)",
1276
1296
  `path: ${rootPath}`,
1277
1297
  `state directory: ${stateDir}`,
@@ -1746,6 +1766,12 @@ async function cliVersion() {
1746
1766
  }
1747
1767
  }
1748
1768
  async function resetCommand(args) {
1769
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
1770
+ // broad root produces the SAME friendly exact-project guidance — never the
1771
+ // raw scan refusal, never the crash wrapper.
1772
+ const rootGuard = await guardExactProjectRoot("reset", args.path);
1773
+ if (rootGuard)
1774
+ return rootGuard;
1749
1775
  const rootPath = await resolveSafeScanRoot(args.path);
1750
1776
  // The trust receipt is deliberately outside the repository. Reset must
1751
1777
  // clear it too so restoring an old spend.json cannot replay prior trust.
@@ -1809,7 +1835,7 @@ async function statuslineCommand(args, runtime) {
1809
1835
  exitCode: 1,
1810
1836
  stdout: "",
1811
1837
  stderr: `Unknown statusline action: ${sanitizeSecretishError(action)}\n` +
1812
- "Use: aibill statusline [refresh|install|uninstall|expand]"
1838
+ "Use: npx aibill statusline [refresh|install|uninstall|expand]"
1813
1839
  };
1814
1840
  }
1815
1841
  async function packagedStatuslineRunner(runtime) {
@@ -1868,7 +1894,7 @@ function statuslineInstallerFailure(action, error) {
1868
1894
  ? error
1869
1895
  : new StatuslineInstallerError("unsafe-settings-file", `The local filesystem operation failed safely${safeFileSystemErrorCode(error)}; no successful settings change was claimed.`);
1870
1896
  const replacement = installerError.code === "statusline-conflict"
1871
- ? "\nTo replace an existing status line explicitly: aibill statusline install --replace"
1897
+ ? "\nTo replace an existing status line explicitly: npx aibill statusline install --replace"
1872
1898
  : "";
1873
1899
  return {
1874
1900
  exitCode: 1,
@@ -1894,6 +1920,12 @@ async function initCommand(args, runtime = {}) {
1894
1920
  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
1921
  };
1896
1922
  }
1923
+ // NEW-B3 (cold-start audit): init from a broad root used to crash-wrap the
1924
+ // raw scan refusal ("unexpected error … open an issue") — for a by-design
1925
+ // guard, on the funnel's second command. Friendly guidance instead.
1926
+ const rootGuard = await guardExactProjectRoot("init", args.path);
1927
+ if (rootGuard)
1928
+ return rootGuard;
1897
1929
  let detectedPlanOverride;
1898
1930
  if (args.plan) {
1899
1931
  const override = planOverrideFromFlag(args.plan);
@@ -2408,21 +2440,38 @@ async function preflightInitCache(cacheDirectory) {
2408
2440
  const existing = await readActivitySnapshot({ cacheDirectory });
2409
2441
  if (existing.status !== "error")
2410
2442
  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({
2443
+ const cacheDirectoryPath = dirname(activitySnapshotCachePath({
2416
2444
  ...(cacheDirectory ? { cacheDirectory } : {})
2417
- })))) {
2445
+ }));
2446
+ // A missing or pre-created-but-EMPTY cache directory holds nothing to
2447
+ // preserve — typically a first run whose home state was stamped before
2448
+ // any cache existed, or a user-made dir with default (non-0700)
2449
+ // permissions. Init's own create path re-validates and tightens it to
2450
+ // 0700, so proceeding is safe; only a directory with actual contents
2451
+ // aborts (shipped-audit fix; cold-start audit NEW-B1: the old check
2452
+ // required cache/ to EXIST, so first runs dead-ended here).
2453
+ if (existing.code === "unsafe_directory" && await isMissingOrEmptyRealDirectory(cacheDirectoryPath)) {
2418
2454
  return;
2419
2455
  }
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) {
2456
+ // NEW-B1(d): name the path and the one-line rescue — "remove the cache
2457
+ // explicitly" with no path was unactionable.
2458
+ const parentPath = dirname(cacheDirectoryPath);
2459
+ const rescue = existing.code === "unsafe_directory"
2460
+ ? ` One-line rescue: chmod 700 ${parentPath} ${cacheDirectoryPath} — then rerun init.`
2461
+ : ` Remove it explicitly before rebuilding it.`;
2462
+ throw new Error(`Existing private activity cache (${cacheDirectoryPath}) is ${existing.code.replaceAll("_", " ")}; ` +
2463
+ `it was preserved and init stopped.${rescue}`);
2464
+ }
2465
+ async function isMissingOrEmptyRealDirectory(path) {
2466
+ let info;
2467
+ try {
2468
+ info = await lstat(path);
2469
+ }
2470
+ catch (error) {
2471
+ // Not there yet: nothing to preserve, init's create path builds it 0700.
2472
+ return isNodeError(error, "ENOENT");
2473
+ }
2424
2474
  try {
2425
- const info = await lstat(path);
2426
2475
  if (info.isSymbolicLink() || !info.isDirectory())
2427
2476
  return false;
2428
2477
  return (await readdir(path)).length === 0;
@@ -2769,15 +2818,13 @@ function emptyInitSourceScan(agent) {
2769
2818
  };
2770
2819
  }
2771
2820
  async function scanCommand(args) {
2821
+ // NEW-B3 (cold-start audit): the bare raw refusal tier is gone — scan
2822
+ // gives the same friendly exact-project guidance as every other
2823
+ // project-scoped command.
2824
+ const rootGuard = await guardExactProjectRoot("scan", args.path);
2825
+ if (rootGuard)
2826
+ return rootGuard;
2772
2827
  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
2828
  const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
2782
2829
  const registry = createLocalFolderSourceRegistry(rootPath);
2783
2830
  const startedAt = new Date().toISOString();
@@ -2870,6 +2917,12 @@ async function scanCommand(args) {
2870
2917
  return ok(lines.join("\n"));
2871
2918
  }
2872
2919
  async function watchCommand(args) {
2920
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
2921
+ // broad root produces the SAME friendly exact-project guidance — never the
2922
+ // raw scan refusal, never the crash wrapper.
2923
+ const rootGuard = await guardExactProjectRoot("watch", args.path);
2924
+ if (rootGuard)
2925
+ return rootGuard;
2873
2926
  const rootPath = resolve(args.path);
2874
2927
  const stateDir = await resolveSafeStateDirectory(rootPath, { create: true });
2875
2928
  const intervalSeconds = Number.isFinite(args.interval) && (args.interval ?? 0) > 0 ? args.interval : 3600;
@@ -3127,6 +3180,12 @@ function providerSyncSetupCommand(provider, adminRef) {
3127
3180
  return `npx aibill sync-provider --provider ${provider} --auth-reference ${adminRef} --start-time ${thirtyDaysAgoUnix}`;
3128
3181
  }
3129
3182
  async function connectCommand(args) {
3183
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
3184
+ // broad root produces the SAME friendly exact-project guidance — never the
3185
+ // raw scan refusal, never the crash wrapper.
3186
+ const rootGuard = await guardExactProjectRoot("connect", args.path);
3187
+ if (rootGuard)
3188
+ return rootGuard;
3130
3189
  const rootPath = resolve(args.path);
3131
3190
  const requestedProvider = (args.provider ?? "unknown").trim().toLowerCase();
3132
3191
  const provider = providerAliases[requestedProvider] ?? requestedProvider;
@@ -3699,6 +3758,15 @@ async function confirmMappingCommand(args) {
3699
3758
  ].join("\n"));
3700
3759
  }
3701
3760
  async function reportCommand(args, runtime = {}) {
3761
+ // NEW-B3 + adversary F2: guard UNCONDITIONALLY. Even --sample writes
3762
+ // project state into the root (resolveSafeStateDirectory below), so a
3763
+ // broad root must refuse with the friendly guidance — the sample gate
3764
+ // alone still leaked the raw "Refusing to scan" from home. report-card
3765
+ // --sample differs: it writes only the SVG artifact and keeps its
3766
+ // deliberate home exemption.
3767
+ const rootGuard = await guardExactProjectRoot("report", args.path);
3768
+ if (rootGuard)
3769
+ return rootGuard;
3702
3770
  const rootPath = resolve(args.path);
3703
3771
  try {
3704
3772
  const sinceDays = args.sinceDays ?? 30;
@@ -3801,7 +3869,7 @@ async function reportCommand(args, runtime = {}) {
3801
3869
  ? "cost/value evidence total: Unavailable · no priced financial evidence; missing/null is not zero"
3802
3870
  : `cost/value evidence total: ${formatOptionalUsd(reportInput.summary.totalUsd)}`,
3803
3871
  runtime.telemetryDisclosure === true
3804
- ? "privacy: report rendered locally · anonymous command counts shared · aibill telemetry off; only explicit sync-provider contacts the selected provider"
3872
+ ? `privacy: report rendered locally · ${telemetryDisclosureLine}; only explicit sync-provider contacts the selected provider`
3805
3873
  : "privacy: report rendered locally with no aibill telemetry; only explicit sync-provider contacts the selected provider",
3806
3874
  "",
3807
3875
  "next:",
@@ -3839,6 +3907,14 @@ async function resolveReceiptPath(rootPath, out) {
3839
3907
  return extname(resolved) ? resolved : `${resolved}.svg`;
3840
3908
  }
3841
3909
  async function reportCardCommand(args) {
3910
+ if (!args.sample) {
3911
+ // NEW-B3 + founder repro (`npx aibill report-card` from home): the raw
3912
+ // scan refusal used to surface wrapped in "Couldn't write the report
3913
+ // card:". The friendly guard renders clean, BEFORE the try/wrapper.
3914
+ const rootGuard = await guardExactProjectRoot("report-card", args.path);
3915
+ if (rootGuard)
3916
+ return rootGuard;
3917
+ }
3842
3918
  try {
3843
3919
  // Explicit sample mode reads no workspace data, so a broad-root scan guard
3844
3920
  // would reject a harmless receipt written from the user's home directory.
@@ -5333,6 +5409,12 @@ function formatMeasuredPercent(value) {
5333
5409
  return `${value.toFixed(digits).replace(/\.00$/u, "").replace(/(\.\d)0$/u, "$1")}%`;
5334
5410
  }
5335
5411
  async function applyArtifactCommand(args) {
5412
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
5413
+ // broad root produces the SAME friendly exact-project guidance — never the
5414
+ // raw scan refusal, never the crash wrapper.
5415
+ const rootGuard = await guardExactProjectRoot("apply", args.path);
5416
+ if (rootGuard)
5417
+ return rootGuard;
5336
5418
  const rootPath = resolve(args.path);
5337
5419
  try {
5338
5420
  const sinceDays = args.sinceDays ?? 30;
@@ -5418,6 +5500,12 @@ async function applyArtifactCommand(args) {
5418
5500
  }
5419
5501
  }
5420
5502
  async function tokenVerificationCommand(args) {
5503
+ // NEW-B3 (cold-start audit): every project-scoped command reachable from a
5504
+ // broad root produces the SAME friendly exact-project guidance — never the
5505
+ // raw scan refusal, never the crash wrapper.
5506
+ const rootGuard = await guardExactProjectRoot("verify", args.path);
5507
+ if (rootGuard)
5508
+ return rootGuard;
5421
5509
  if (args.sample) {
5422
5510
  return {
5423
5511
  exitCode: 1,
@@ -6871,7 +6959,7 @@ function helpText(telemetryDisclosure) {
6871
6959
  " 0 * * * * cd /path/to/workspace && npx --yes aibill watch --interval 3600 --cycles 1 >> aibill-watch.log 2>&1",
6872
6960
  "",
6873
6961
  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."
6962
+ ? `Privacy: local analysis and reports upload nothing; ${telemetryDisclosureLine}. Only explicit sync-provider contacts the selected provider through an env: reference.`
6875
6963
  : "Privacy: local analysis and reports upload nothing. Only explicit sync-provider contacts the selected provider through an env: reference.",
6876
6964
  "aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
6877
6965
  ].join("\n");
@@ -6954,6 +7042,7 @@ export async function runMain() {
6954
7042
  let askOutcome = { kind: "no_ask" };
6955
7043
  let promptInterface;
6956
7044
  let guidedInterface;
7045
+ let consentReader;
6957
7046
  try {
6958
7047
  let guidedIoShared;
6959
7048
  const runPipeline = () => runCli(argv, {
@@ -6967,6 +7056,15 @@ export async function runMain() {
6967
7056
  }
6968
7057
  return promptInterface.question(question);
6969
7058
  },
7059
+ // Consent-grade read for `signup <email>` (adversary SF1): buffered
7060
+ // type-ahead never answers; EOF/^C resolve undefined quietly.
7061
+ consentRead: async (query, timeoutMs) => {
7062
+ if (!consentReader) {
7063
+ const signup = await import("./signup.js");
7064
+ consentReader = await signup.openTerminalConsentRead();
7065
+ }
7066
+ return consentReader ? consentReader.read(query, timeoutMs) : undefined;
7067
+ },
6970
7068
  openGuidedIo: async () => {
6971
7069
  if (!guidedIoShared) {
6972
7070
  const { createInterface } = await import("node:readline");
@@ -7020,6 +7118,7 @@ export async function runMain() {
7020
7118
  finally {
7021
7119
  promptInterface?.close();
7022
7120
  guidedInterface?.close();
7121
+ consentReader?.close();
7023
7122
  spinner?.stop();
7024
7123
  }
7025
7124
  // 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.3",
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.3",
59
+ "@agent-finops/report": "0.9.3",
60
60
  "yocto-spinner": "^1.2.0"
61
61
  }
62
62
  }