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 +7 -0
- package/dist/index.js +128 -29
- 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";
|
|
@@ -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
|
|
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
|
-
?
|
|
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
|
-
|
|
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
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
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
|
-
?
|
|
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
|
-
?
|
|
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:
|
|
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.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.
|
|
59
|
-
"@agent-finops/report": "0.9.
|
|
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
|
}
|