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 +7 -0
- package/dist/index.js +272 -74
- package/dist/signup.d.ts +59 -5
- package/dist/signup.js +279 -30
- package/dist/statuslineRuntime.js +6 -6
- package/dist/telemetry.d.ts +9 -4
- package/dist/telemetry.js +23 -5
- package/package.json +3 -3
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
|
-
//
|
|
622
|
-
//
|
|
623
|
-
|
|
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
|
|
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
|
-
:
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3711
|
-
|
|
3712
|
-
|
|
3713
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
...(
|
|
3774
|
-
? [
|
|
3775
|
-
|
|
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:
|
|
3782
|
-
`
|
|
3783
|
-
`
|
|
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
|
-
:
|
|
3882
|
+
: qualitativeActionsSuppressed
|
|
3789
3883
|
? [
|
|
3790
|
-
`
|
|
3791
|
-
`
|
|
3792
|
-
`
|
|
3793
|
-
`
|
|
3794
|
-
`
|
|
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
|
-
?
|
|
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
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
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
|
-
//
|
|
3844
|
-
//
|
|
3845
|
-
//
|
|
3846
|
-
|
|
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
|
-
?
|
|
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:
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
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:
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
488
|
-
|
|
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(
|
|
749
|
+
settleWaiter(arrived);
|
|
593
750
|
else
|
|
594
|
-
strayLines.push(
|
|
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 (
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
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
|
-
//
|
|
608
|
-
|
|
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
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
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.
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
/**
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
59
|
-
"@agent-finops/report": "0.9.
|
|
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
|
}
|