ai-spend-agent 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * CLI telemetry — anonymous command counts, notice-before-first-byte.
4
+ *
5
+ * Consent model (founder decision 2026-08-24, disclosed opt-out):
6
+ * - Run 1 (interactive) prints a three-line notice AFTER the output and
7
+ * stamps noticedAt. NOTHING is sent on that run.
8
+ * - Events begin only on runs after noticedAt exists. A user who has never
9
+ * seen the notice is never tracked — non-interactive runs before any
10
+ * notice emit nothing, ever.
11
+ * - `aibill telemetry` shows status + the exact last payload verbatim;
12
+ * `aibill telemetry off` / `on` switch it. DO_NOT_TRACK, CI, and
13
+ * AI_SPEND_NO_TELEMETRY hard-disable it regardless of state.
14
+ * - State is fail-closed: corrupt or unreadable state means telemetry OFF.
15
+ *
16
+ * Payload truth (non-negotiable):
17
+ * - The event is EXACTLY {installId, command, version, os, arch, ci,
18
+ * durationBucket, ok, ts} — no args, flag values, paths, content, email,
19
+ * or anything joinable to signup.json. The installId lives ONLY in
20
+ * ~/.aibill/telemetry.json; the signup state has no installId and the
21
+ * telemetry state has no email — no shared field exists in any payload
22
+ * or state file, and tests pin that separation. (Like any two web
23
+ * requests to one host, transport metadata such as the source IP is
24
+ * still shared at the network layer; the claim stops at the payload.)
25
+ * - When telemetry is enabled+noticed, every surface that printed
26
+ * "nothing uploaded" prints the disclosure line instead — the receipt
27
+ * never claims less than what leaves the machine.
28
+ * - One fire-and-forget POST per run, batch of 1 (contract allows up to
29
+ * 10 per batch, 4096-byte body cap), hard 1500ms abort, never retried,
30
+ * never queued, total silence on any failure (any non-204 = drop).
31
+ */
32
+ export declare const telemetryUrl = "https://asktilden.com/api/telemetry";
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"];
35
+ export type TelemetryCommand = (typeof telemetryCommands)[number];
36
+ export type TelemetryDurationBucket = "lt1s" | "lt5s" | "lt30s" | "gte30s";
37
+ export type TelemetryOs = "darwin" | "linux" | "win32" | "other";
38
+ export type TelemetryArch = "arm64" | "x64" | "other";
39
+ /** Closed event type — adding a field must fail the creep-guard test. */
40
+ export type TelemetryEvent = {
41
+ installId: string;
42
+ command: TelemetryCommand;
43
+ version: string;
44
+ os: TelemetryOs;
45
+ arch: TelemetryArch;
46
+ ci: boolean;
47
+ durationBucket: TelemetryDurationBucket;
48
+ ok: boolean;
49
+ ts: string;
50
+ };
51
+ /** Printed instead of "nothing uploaded" while telemetry is active. */
52
+ export declare const telemetryDisclosureLine = "anonymous command counts shared \u00B7 aibill telemetry off";
53
+ export declare const telemetryNoticeLines: readonly ["aibill counts which commands run — anonymous, never your data or content", "turn off: aibill telemetry off", "see payloads: aibill telemetry"];
54
+ export declare function telemetryOsLabel(platform?: string): TelemetryOs;
55
+ export declare function telemetryArchLabel(arch?: string): TelemetryArch;
56
+ export declare function telemetryDurationBucket(durationMs: number): TelemetryDurationBucket;
57
+ /**
58
+ * argv → allowlisted command label. Only the command word is mapped; flag
59
+ * VALUES, paths, and everything else in argv never leave this function.
60
+ * Explicit --sample demo runs map to "other" so the receipt count stays a
61
+ * count of real receipts.
62
+ */
63
+ export declare function telemetryCommandForArgv(argv: readonly string[]): TelemetryCommand;
64
+ export type TelemetryState = {
65
+ version: 1;
66
+ installId: string;
67
+ enabled: boolean;
68
+ noticedAt?: string;
69
+ /** The exact serialized batch last attempted, verbatim (inspectability). */
70
+ lastPayload?: string;
71
+ };
72
+ export type TelemetryStateRead = {
73
+ kind: "fresh";
74
+ } | {
75
+ kind: "ok";
76
+ state: TelemetryState;
77
+ } | {
78
+ kind: "unreadable";
79
+ };
80
+ export declare function telemetryStateFilePath(homeDirectory?: string): string;
81
+ export declare function readTelemetryState(filePath: string): Promise<TelemetryStateRead>;
82
+ export declare function writeTelemetryState(filePath: string, state: TelemetryState): Promise<boolean>;
83
+ /** Environment kill-switches: any non-empty value disables telemetry. */
84
+ export declare function telemetryEnvDisabled(env?: Record<string, string | undefined>): boolean;
85
+ export declare const telemetryBatchMaxEvents = 10;
86
+ export declare const telemetryBatchMaxBytes = 4096;
87
+ export declare function serializeTelemetryEvent(event: TelemetryEvent): string;
88
+ /**
89
+ * Serializes {"events":[...]} within the contract caps (flush at <=10
90
+ * events; body <=4096 bytes). Returns undefined — the batch is DROPPED —
91
+ * when a cap would be exceeded; nothing is ever split-retried.
92
+ */
93
+ export declare function serializeTelemetryBatch(events: readonly TelemetryEvent[]): string | undefined;
94
+ /**
95
+ * One fire-and-forget POST: hard 1500ms abort, no retry, no queue, total
96
+ * silence on ANY outcome that is not a 204 (4xx/429/5xx/transport = drop
97
+ * and forget). Resolves quickly and never throws. Used directly by tests;
98
+ * production sends run in a detached child (see sendTelemetryDetached) so
99
+ * an in-flight socket can never hold the CLI's exit open.
100
+ */
101
+ export declare function postTelemetryBatch(body: string, options?: {
102
+ fetchImpl?: typeof fetch;
103
+ timeoutMs?: number;
104
+ }): Promise<void>;
105
+ /**
106
+ * Build the one-shot sender script executed by the detached child. It reads
107
+ * the ALREADY-CACHED lastPayload from the state file (so the payload never
108
+ * rides on a command line), re-checks enabled, guards the batch shape, and
109
+ * POSTs once with its own 1500ms abort. The URL is baked in by the caller —
110
+ * production passes exactly `telemetryUrl`; nothing about the destination
111
+ * is ever environment-controlled.
112
+ */
113
+ export declare function buildDetachedTelemetrySendScript(url: string): string;
114
+ /**
115
+ * Homebrew-model delivery (QA M3): the POST runs in a detached, unref'd
116
+ * one-shot child so the parent CLI's exit is NEVER held by a slow or
117
+ * black-holed endpoint — the parent adds only the (async) spawn call. The
118
+ * child carries the 1500ms abort and total silence itself; only the state
119
+ * file path travels on its argv.
120
+ */
121
+ export declare function sendTelemetryDetached(stateFilePath: string, options?: {
122
+ spawnImpl?: typeof spawn;
123
+ }): void;
124
+ /** Suppress any further emission from the current process (fail closed). */
125
+ export declare function killTelemetryForThisProcess(): void;
126
+ /** Test-only: reset the process-scoped kill flag between cases. */
127
+ export declare function resetTelemetrySessionKillForTests(): void;
128
+ export type CliTelemetryRuntime = {
129
+ /** True when receipts must print the disclosure line instead of "nothing uploaded". */
130
+ disclosureActive: boolean;
131
+ finish: (input: {
132
+ argv: readonly string[];
133
+ ok: boolean;
134
+ durationMs: number;
135
+ interactive: boolean;
136
+ version: string;
137
+ fetchImpl?: typeof fetch;
138
+ /** Test override for the detached production transport. */
139
+ spawnImpl?: typeof spawn;
140
+ /** Test override; production writes stdout. */
141
+ printNotice?: (lines: readonly string[]) => void;
142
+ }) => Promise<void>;
143
+ };
144
+ export declare function openCliTelemetry(options?: {
145
+ homeDirectory?: string;
146
+ env?: Record<string, string | undefined>;
147
+ now?: () => Date;
148
+ }): Promise<CliTelemetryRuntime>;
149
+ //# sourceMappingURL=telemetry.d.ts.map
@@ -0,0 +1,390 @@
1
+ import { spawn } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { dirname, join } from "node:path";
6
+ /**
7
+ * CLI telemetry — anonymous command counts, notice-before-first-byte.
8
+ *
9
+ * Consent model (founder decision 2026-08-24, disclosed opt-out):
10
+ * - Run 1 (interactive) prints a three-line notice AFTER the output and
11
+ * stamps noticedAt. NOTHING is sent on that run.
12
+ * - Events begin only on runs after noticedAt exists. A user who has never
13
+ * seen the notice is never tracked — non-interactive runs before any
14
+ * notice emit nothing, ever.
15
+ * - `aibill telemetry` shows status + the exact last payload verbatim;
16
+ * `aibill telemetry off` / `on` switch it. DO_NOT_TRACK, CI, and
17
+ * AI_SPEND_NO_TELEMETRY hard-disable it regardless of state.
18
+ * - State is fail-closed: corrupt or unreadable state means telemetry OFF.
19
+ *
20
+ * Payload truth (non-negotiable):
21
+ * - The event is EXACTLY {installId, command, version, os, arch, ci,
22
+ * durationBucket, ok, ts} — no args, flag values, paths, content, email,
23
+ * or anything joinable to signup.json. The installId lives ONLY in
24
+ * ~/.aibill/telemetry.json; the signup state has no installId and the
25
+ * telemetry state has no email — no shared field exists in any payload
26
+ * or state file, and tests pin that separation. (Like any two web
27
+ * requests to one host, transport metadata such as the source IP is
28
+ * still shared at the network layer; the claim stops at the payload.)
29
+ * - When telemetry is enabled+noticed, every surface that printed
30
+ * "nothing uploaded" prints the disclosure line instead — the receipt
31
+ * never claims less than what leaves the machine.
32
+ * - One fire-and-forget POST per run, batch of 1 (contract allows up to
33
+ * 10 per batch, 4096-byte body cap), hard 1500ms abort, never retried,
34
+ * never queued, total silence on any failure (any non-204 = drop).
35
+ */
36
+ export const telemetryUrl = "https://asktilden.com/api/telemetry";
37
+ /** Server-allowlisted command labels; anything else is sent as "other". */
38
+ export const telemetryCommands = [
39
+ "receipt",
40
+ "full",
41
+ "group-by",
42
+ "improve",
43
+ "improve-sample",
44
+ "index",
45
+ "identify",
46
+ "accountability",
47
+ "outcome",
48
+ "statusline",
49
+ "statusline-expand",
50
+ "signup",
51
+ "connect",
52
+ "sync-provider",
53
+ "doctor",
54
+ "report",
55
+ "report-card",
56
+ "apply",
57
+ "watch",
58
+ "init",
59
+ "verify",
60
+ "drop-slice",
61
+ "telemetry",
62
+ "other"
63
+ ];
64
+ /** Printed instead of "nothing uploaded" while telemetry is active. */
65
+ export const telemetryDisclosureLine = "anonymous command counts shared · aibill telemetry off";
66
+ export const telemetryNoticeLines = [
67
+ "aibill counts which commands run — anonymous, never your data or content",
68
+ "turn off: aibill telemetry off",
69
+ "see payloads: aibill telemetry"
70
+ ];
71
+ export function telemetryOsLabel(platform = process.platform) {
72
+ return platform === "darwin" || platform === "linux" || platform === "win32" ? platform : "other";
73
+ }
74
+ export function telemetryArchLabel(arch = process.arch) {
75
+ return arch === "arm64" || arch === "x64" ? arch : "other";
76
+ }
77
+ export function telemetryDurationBucket(durationMs) {
78
+ if (durationMs < 1_000)
79
+ return "lt1s";
80
+ if (durationMs < 5_000)
81
+ return "lt5s";
82
+ if (durationMs < 30_000)
83
+ return "lt30s";
84
+ return "gte30s";
85
+ }
86
+ /**
87
+ * argv → allowlisted command label. Only the command word is mapped; flag
88
+ * VALUES, paths, and everything else in argv never leave this function.
89
+ * Explicit --sample demo runs map to "other" so the receipt count stays a
90
+ * count of real receipts.
91
+ */
92
+ export function telemetryCommandForArgv(argv) {
93
+ const command = argv[0];
94
+ const isQuickstart = command === undefined || command === "quickstart" || command === "demo" ||
95
+ (command !== undefined && command.startsWith("-"));
96
+ if (argv.includes("--version") || argv.includes("-v") || argv.includes("--help") || argv.includes("-h")) {
97
+ return "other";
98
+ }
99
+ if (isQuickstart) {
100
+ if (argv.includes("--sample"))
101
+ return "other";
102
+ if (argv.includes("--group-by"))
103
+ return "group-by";
104
+ if (argv.includes("--full"))
105
+ return "full";
106
+ return "receipt";
107
+ }
108
+ switch (command) {
109
+ case "improve": return argv.includes("--sample") ? "improve-sample" : "improve";
110
+ case "index": return "index";
111
+ case "identify": return "identify";
112
+ case "accountability": return "accountability";
113
+ case "outcome": return "outcome";
114
+ case "statusline": return argv[1] === "expand" ? "statusline-expand" : "statusline";
115
+ case "signup": return "signup";
116
+ case "connect": return "connect";
117
+ case "sync-provider": return "sync-provider";
118
+ case "doctor": return "doctor";
119
+ case "report": return "report";
120
+ case "report-card": return "report-card";
121
+ case "apply":
122
+ case "apply-artifact": return "apply";
123
+ case "watch": return "watch";
124
+ case "init": return "init";
125
+ case "verify": return "verify";
126
+ case "drop-slice": return "drop-slice";
127
+ case "telemetry": return "telemetry";
128
+ default: return "other";
129
+ }
130
+ }
131
+ export function telemetryStateFilePath(homeDirectory) {
132
+ return join(homeDirectory ?? homedir(), ".aibill", "telemetry.json");
133
+ }
134
+ const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
135
+ function isTelemetryState(value) {
136
+ if (typeof value !== "object" || value === null)
137
+ return false;
138
+ const state = value;
139
+ return state.version === 1 &&
140
+ typeof state.installId === "string" && uuidPattern.test(state.installId) &&
141
+ typeof state.enabled === "boolean" &&
142
+ (state.noticedAt === undefined || typeof state.noticedAt === "string") &&
143
+ (state.lastPayload === undefined || typeof state.lastPayload === "string");
144
+ }
145
+ export async function readTelemetryState(filePath) {
146
+ let contents;
147
+ try {
148
+ contents = await readFile(filePath, "utf8");
149
+ }
150
+ catch (error) {
151
+ if (error instanceof Error && error.code === "ENOENT") {
152
+ return { kind: "fresh" };
153
+ }
154
+ // Unreadable: telemetry fails CLOSED to off.
155
+ return { kind: "unreadable" };
156
+ }
157
+ try {
158
+ const parsed = JSON.parse(contents);
159
+ if (!isTelemetryState(parsed))
160
+ return { kind: "unreadable" };
161
+ return { kind: "ok", state: parsed };
162
+ }
163
+ catch {
164
+ return { kind: "unreadable" };
165
+ }
166
+ }
167
+ export async function writeTelemetryState(filePath, state) {
168
+ try {
169
+ await mkdir(dirname(filePath), { recursive: true });
170
+ const temporaryPath = `${filePath}.tmp`;
171
+ await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
172
+ await rename(temporaryPath, filePath);
173
+ return true;
174
+ }
175
+ catch {
176
+ return false;
177
+ }
178
+ }
179
+ /** Environment kill-switches: any non-empty value disables telemetry. */
180
+ export function telemetryEnvDisabled(env = process.env) {
181
+ return Boolean(env.DO_NOT_TRACK) || Boolean(env.CI) || Boolean(env.AI_SPEND_NO_TELEMETRY);
182
+ }
183
+ // ---------------------------------------------------------------------------
184
+ // Batch serialization + transport (contract: web branch telemetry-endpoint).
185
+ // ---------------------------------------------------------------------------
186
+ export const telemetryBatchMaxEvents = 10;
187
+ export const telemetryBatchMaxBytes = 4_096;
188
+ export function serializeTelemetryEvent(event) {
189
+ // Key order pinned by the creep-guard test.
190
+ return JSON.stringify({
191
+ installId: event.installId,
192
+ command: event.command,
193
+ version: event.version,
194
+ os: event.os,
195
+ arch: event.arch,
196
+ ci: event.ci,
197
+ durationBucket: event.durationBucket,
198
+ ok: event.ok,
199
+ ts: event.ts
200
+ });
201
+ }
202
+ /**
203
+ * Serializes {"events":[...]} within the contract caps (flush at <=10
204
+ * events; body <=4096 bytes). Returns undefined — the batch is DROPPED —
205
+ * when a cap would be exceeded; nothing is ever split-retried.
206
+ */
207
+ export function serializeTelemetryBatch(events) {
208
+ if (events.length === 0 || events.length > telemetryBatchMaxEvents)
209
+ return undefined;
210
+ const body = `{"events":[${events.map(serializeTelemetryEvent).join(",")}]}`;
211
+ if (Buffer.byteLength(body, "utf8") > telemetryBatchMaxBytes)
212
+ return undefined;
213
+ return body;
214
+ }
215
+ /**
216
+ * One fire-and-forget POST: hard 1500ms abort, no retry, no queue, total
217
+ * silence on ANY outcome that is not a 204 (4xx/429/5xx/transport = drop
218
+ * and forget). Resolves quickly and never throws. Used directly by tests;
219
+ * production sends run in a detached child (see sendTelemetryDetached) so
220
+ * an in-flight socket can never hold the CLI's exit open.
221
+ */
222
+ export async function postTelemetryBatch(body, options = {}) {
223
+ const fetchImpl = options.fetchImpl ?? fetch;
224
+ try {
225
+ await fetchImpl(telemetryUrl, {
226
+ method: "POST",
227
+ headers: { "content-type": "application/json", "user-agent": "aibill-cli" },
228
+ body,
229
+ signal: AbortSignal.timeout(options.timeoutMs ?? 1_500)
230
+ });
231
+ }
232
+ catch {
233
+ // Silence. Never retry, never queue, never surface.
234
+ }
235
+ }
236
+ /**
237
+ * Build the one-shot sender script executed by the detached child. It reads
238
+ * the ALREADY-CACHED lastPayload from the state file (so the payload never
239
+ * rides on a command line), re-checks enabled, guards the batch shape, and
240
+ * POSTs once with its own 1500ms abort. The URL is baked in by the caller —
241
+ * production passes exactly `telemetryUrl`; nothing about the destination
242
+ * is ever environment-controlled.
243
+ */
244
+ export function buildDetachedTelemetrySendScript(url) {
245
+ return [
246
+ 'const { readFile } = require("node:fs/promises");',
247
+ "(async () => {",
248
+ " try {",
249
+ " const raw = await readFile(process.argv[1], \"utf8\");",
250
+ " const state = JSON.parse(raw);",
251
+ " if (!state || state.enabled !== true || typeof state.lastPayload !== \"string\") return;",
252
+ " const body = state.lastPayload;",
253
+ " if (!body.startsWith('{\"events\":[') || Buffer.byteLength(body, \"utf8\") > 4096) return;",
254
+ ` await fetch(${JSON.stringify(url)}, {`,
255
+ ' method: "POST",',
256
+ ' headers: { "content-type": "application/json", "user-agent": "aibill-cli" },',
257
+ " body,",
258
+ " signal: AbortSignal.timeout(1500)",
259
+ " });",
260
+ " } catch {}",
261
+ "})();"
262
+ ].join("\n");
263
+ }
264
+ /**
265
+ * Homebrew-model delivery (QA M3): the POST runs in a detached, unref'd
266
+ * one-shot child so the parent CLI's exit is NEVER held by a slow or
267
+ * black-holed endpoint — the parent adds only the (async) spawn call. The
268
+ * child carries the 1500ms abort and total silence itself; only the state
269
+ * file path travels on its argv.
270
+ */
271
+ export function sendTelemetryDetached(stateFilePath, options = {}) {
272
+ try {
273
+ const spawnImpl = options.spawnImpl ?? spawn;
274
+ const child = spawnImpl(process.execPath, ["-e", buildDetachedTelemetrySendScript(telemetryUrl), stateFilePath], { detached: true, stdio: "ignore" });
275
+ child.unref();
276
+ }
277
+ catch {
278
+ // Telemetry must never break the CLI.
279
+ }
280
+ }
281
+ // M1: a failed `telemetry off` persist must silence THIS process too —
282
+ // the state still says enabled, so without this flag the very run that
283
+ // printed the failure would emit its event. Module-scoped is correct here:
284
+ // one bin process, and embedded callers never emit at all.
285
+ let sessionTelemetryKilled = false;
286
+ /** Suppress any further emission from the current process (fail closed). */
287
+ export function killTelemetryForThisProcess() {
288
+ sessionTelemetryKilled = true;
289
+ }
290
+ /** Test-only: reset the process-scoped kill flag between cases. */
291
+ export function resetTelemetrySessionKillForTests() {
292
+ sessionTelemetryKilled = false;
293
+ }
294
+ export async function openCliTelemetry(options = {}) {
295
+ const env = options.env ?? process.env;
296
+ const now = options.now ?? (() => new Date());
297
+ const filePath = telemetryStateFilePath(options.homeDirectory);
298
+ const read = await readTelemetryState(filePath);
299
+ const envDisabled = telemetryEnvDisabled(env);
300
+ const noticed = read.kind === "ok" && read.state.enabled && typeof read.state.noticedAt === "string";
301
+ const disclosureActive = noticed && !envDisabled;
302
+ return {
303
+ disclosureActive,
304
+ finish: async (input) => {
305
+ try {
306
+ if (sessionTelemetryKilled)
307
+ return;
308
+ if (envDisabled || read.kind === "unreadable")
309
+ return;
310
+ if (read.kind === "ok" && !read.state.enabled)
311
+ return;
312
+ // The command that just ran may have CHANGED the state (`telemetry
313
+ // off`/`on`). Re-read before any write or send: a stale snapshot
314
+ // must never clobber the user's switch or emit after an off.
315
+ const current = await readTelemetryState(filePath);
316
+ if (current.kind === "unreadable")
317
+ return;
318
+ if (current.kind === "ok" && !current.state.enabled)
319
+ return;
320
+ if (noticed && read.kind === "ok") {
321
+ // Events begin only on runs AFTER the notice was stamped (the
322
+ // open-time snapshot decides), and only while the CURRENT state
323
+ // still agrees.
324
+ if (current.kind !== "ok" || current.state.noticedAt === undefined)
325
+ return;
326
+ const event = {
327
+ installId: current.state.installId,
328
+ command: telemetryCommandForArgv(input.argv),
329
+ version: input.version,
330
+ os: telemetryOsLabel(),
331
+ arch: telemetryArchLabel(),
332
+ ci: Boolean(env.CI),
333
+ durationBucket: telemetryDurationBucket(input.durationMs),
334
+ ok: input.ok,
335
+ ts: now().toISOString()
336
+ };
337
+ const body = serializeTelemetryBatch([event]);
338
+ if (body === undefined)
339
+ return;
340
+ // The exact payload is cached verbatim BEFORE the send so
341
+ // `aibill telemetry` can always show what left the machine.
342
+ await writeTelemetryState(filePath, { ...current.state, lastPayload: body });
343
+ if (input.fetchImpl) {
344
+ // Test transport: in-process, awaited by the mock harness.
345
+ void postTelemetryBatch(body, { fetchImpl: input.fetchImpl });
346
+ }
347
+ else {
348
+ // Production transport: detached one-shot child (QA M3) — the
349
+ // parent's exit is never held by the socket.
350
+ sendTelemetryDetached(filePath, {
351
+ ...(input.spawnImpl ? { spawnImpl: input.spawnImpl } : {})
352
+ });
353
+ }
354
+ return;
355
+ }
356
+ // First-notice path: interactive runs only, and only while the
357
+ // CURRENT state is still un-noticed (an `on` this run already
358
+ // stamped its own notice; an `off` must never be overwritten).
359
+ // --json outputs must stay machine-parseable, so those runs never
360
+ // print (or stamp) the notice either (QA m7).
361
+ if (!input.interactive || input.argv.includes("--json"))
362
+ return;
363
+ if (current.kind === "ok" && current.state.noticedAt !== undefined)
364
+ return;
365
+ const stamped = {
366
+ version: 1,
367
+ installId: current.kind === "ok" ? current.state.installId : randomUUID(),
368
+ enabled: true,
369
+ noticedAt: now().toISOString(),
370
+ ...(current.kind === "ok" && current.state.lastPayload !== undefined
371
+ ? { lastPayload: current.state.lastPayload }
372
+ : {})
373
+ };
374
+ // The notice prints ONLY when the stamp persisted — a user whose
375
+ // state cannot be written is simply never tracked (fail closed),
376
+ // never re-nagged.
377
+ if (!await writeTelemetryState(filePath, stamped))
378
+ return;
379
+ const print = input.printNotice ?? ((lines) => {
380
+ process.stdout.write(`\n${lines.join("\n")}\n`);
381
+ });
382
+ print(telemetryNoticeLines);
383
+ }
384
+ catch {
385
+ // Telemetry must never break the CLI.
386
+ }
387
+ }
388
+ };
389
+ }
390
+ //# sourceMappingURL=telemetry.js.map
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "ai-spend-agent",
3
- "version": "0.9.0",
3
+ "version": "0.9.2",
4
+ "funding": "https://asktilden.com",
4
5
  "description": "Local-first financial accountability CLI: Claude Code/Codex attribution, provenance, and next actions, plus experimental Gemini CLI cost evidence.",
5
6
  "type": "module",
6
7
  "main": "./dist/index.js",
@@ -54,8 +55,8 @@
54
55
  "prepack": "npm run build"
55
56
  },
56
57
  "dependencies": {
57
- "@agent-finops/core": "0.9.0",
58
- "@agent-finops/report": "0.9.0",
58
+ "@agent-finops/core": "0.9.2",
59
+ "@agent-finops/report": "0.9.2",
59
60
  "yocto-spinner": "^1.2.0"
60
61
  }
61
62
  }