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.
package/dist/signup.js ADDED
@@ -0,0 +1,681 @@
1
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { renderDrainNotice } from "./guidedPrompt.js";
4
+ import { dirname, join } from "node:path";
5
+ /**
6
+ * CLI email capture — the launch-list signup lane (v0.9.2).
7
+ *
8
+ * Design: docs/qa-handoff/CLI_CAPTURE_DESIGN.md (see its dated placement
9
+ * addendum). QA verdict (its B/M fixes are mandatory):
10
+ * docs/qa-handoff/CLI_CAPTURE_QA_VERDICT.md.
11
+ *
12
+ * Placement (founder decision 2026-08-24): the ONE ask runs PRE-RECEIPT,
13
+ * DURING the first evidence scan — it fills the first-run wait instead of
14
+ * adding one. The receipt renders when both the human's answer (or skip /
15
+ * timeout / Ctrl-C) and the pipeline resolve; consent, when an email was
16
+ * typed, renders strictly AFTER the receipt.
17
+ *
18
+ * Hard rules encoded here:
19
+ * - The payload is EXACTLY {email, ref} — the deployed asktilden.com route
20
+ * reads `ref` (not `sourceRef`) and normalizes anything failing
21
+ * /^[a-z0-9][a-z0-9_-]{0,63}$/ (dots included) to "direct" (verdict B1).
22
+ * - Consent shows the literal payload JSON; the sent line claims only the
23
+ * payload layer, never "nothing else left this machine" (verdict B2).
24
+ * - One POST attempt, 3s timeout, no retry, no queue, and the typed email is
25
+ * never persisted on failure.
26
+ * - Every read is timeout-guarded and can never eat a skip: a timeout or
27
+ * Ctrl-C records NO lifetime skip (verdict M3).
28
+ * - Skip = two empty Enters with one declarative nudge between them; the
29
+ * completed pair counts as ONE lifetime skip. This is the founder-approved
30
+ * ceiling: no further skip friction may be added, and removal of skip is
31
+ * refused by design.
32
+ * - Never-ask state lives in ~/.aibill/signup.json (the existing home dir —
33
+ * verdict M5) and fails CLOSED to never-ask on corrupt/readonly state.
34
+ */
35
+ export const waitlistUrl = "https://asktilden.com/api/waitlist";
36
+ // Server-parity email rules (apps/web/app/api/waitlist/route.ts): trim,
37
+ // lowercase, <=254 chars, pragmatic shape check. The CLI additionally rejects
38
+ // control bytes (terminal-escape injection into the echoed consent line).
39
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
40
+ const controlBytePattern = /[\u0000-\u001f\u007f-\u009f]/u;
41
+ // The deployed route's ref contract: dot-free, else attribution silently
42
+ // becomes "direct" (verdict B1). Both patterns are pinned by tests.
43
+ export const deployedRefPattern = /^[a-z0-9][a-z0-9_-]{0,63}$/;
44
+ const refTagPattern = /^[a-z0-9-]{1,24}$/;
45
+ export function normalizeWaitlistEmail(raw) {
46
+ const email = raw.trim().toLowerCase();
47
+ if (!email || email.length > 254)
48
+ return undefined;
49
+ if (controlBytePattern.test(email))
50
+ return undefined;
51
+ if (!emailPattern.test(email))
52
+ return undefined;
53
+ return email;
54
+ }
55
+ /** `--ref` allowlist: lowercase alphanumerics and dashes, 1–24 chars. */
56
+ export function sanitizeSignupRefTag(raw) {
57
+ const tag = raw.trim().toLowerCase();
58
+ return refTagPattern.test(tag) ? tag : undefined;
59
+ }
60
+ export function buildWaitlistRef(surface, tag) {
61
+ const ref = tag === undefined ? `cli-${surface}` : `cli-${surface}-${tag}`;
62
+ if (!deployedRefPattern.test(ref)) {
63
+ // Structurally unreachable (surface enum + tag allowlist), kept as a
64
+ // fail-closed guard so a future edit cannot ship a ref the deployed
65
+ // route would rewrite to "direct".
66
+ throw new Error("signup ref failed the deployed route contract");
67
+ }
68
+ return ref;
69
+ }
70
+ /**
71
+ * The exact bytes sent — key order pinned. Payload creep (adding os/plan/
72
+ * version data, or stuffing values into ref) fails the CI creep-guard test;
73
+ * see docs/qa-handoff/CLI_CAPTURE_DESIGN.md §3c before touching this.
74
+ */
75
+ export function serializeWaitlistPayload(payload) {
76
+ return JSON.stringify({ email: payload.email, ref: payload.ref });
77
+ }
78
+ /**
79
+ * Single POST, 3s timeout, one attempt, no retry, no queue. The response
80
+ * body is never printed (it could be an HTML error page); only the status
81
+ * code is mapped. Headers are the full explicit set this request adds —
82
+ * pinned by test so header creep fails CI too.
83
+ */
84
+ export async function postWaitlistSignup(payload, options = {}) {
85
+ const fetchImpl = options.fetchImpl ?? fetch;
86
+ try {
87
+ const response = await fetchImpl(waitlistUrl, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json", "user-agent": "aibill-cli" },
90
+ body: serializeWaitlistPayload(payload),
91
+ signal: AbortSignal.timeout(options.timeoutMs ?? 3_000)
92
+ });
93
+ // 201 covers duplicates too — the route maps the unique-email conflict
94
+ // to 201, so signup is idempotent server-side.
95
+ if (response.status === 201)
96
+ return "sent";
97
+ if (response.status === 422)
98
+ return "invalid_email";
99
+ if (response.status === 429)
100
+ return "rate_limited";
101
+ return "unreachable";
102
+ }
103
+ catch {
104
+ return "unreachable";
105
+ }
106
+ }
107
+ /** Small static blocklist of throwaway-inbox domains (exact match only). */
108
+ export const disposableEmailDomains = new Set([
109
+ "mailinator.com",
110
+ "guerrillamail.com",
111
+ "10minutemail.com",
112
+ "tempmail.com",
113
+ "temp-mail.org",
114
+ "yopmail.com",
115
+ "sharklasers.com",
116
+ "grr.la",
117
+ "trashmail.com",
118
+ "getnada.com",
119
+ "dispostable.com",
120
+ "maildrop.cc",
121
+ "mintemail.com",
122
+ "throwawaymail.com",
123
+ "fakeinbox.com",
124
+ "mailnesia.com",
125
+ "spamgourmet.com",
126
+ "mytemp.email",
127
+ "tempinbox.com",
128
+ "emailondeck.com"
129
+ ]);
130
+ /** Exact or subdomain match against the blocklist (QA m5). */
131
+ export function isDisposableEmailDomain(domain) {
132
+ if (disposableEmailDomains.has(domain))
133
+ return true;
134
+ for (const blocked of disposableEmailDomains) {
135
+ if (domain.endsWith(`.${blocked}`))
136
+ return true;
137
+ }
138
+ return false;
139
+ }
140
+ async function defaultSignupDnsResolver() {
141
+ const dns = await import("node:dns/promises");
142
+ return { resolveMx: (domain) => dns.resolveMx(domain), resolve4: (domain) => dns.resolve4(domain) };
143
+ }
144
+ const dnsTimeoutMs = 1_500;
145
+ function isDefinitiveDnsMiss(error) {
146
+ const code = error?.code;
147
+ // EBADNAME: the resolver refused the name itself (invalid/homoglyph
148
+ // label) — as provably undeliverable as NXDOMAIN (QA m4).
149
+ return code === "ENOTFOUND" || code === "ENODATA" || code === "EBADNAME";
150
+ }
151
+ export async function assessEmailDeliverability(email, options = {}) {
152
+ const domain = email.slice(email.lastIndexOf("@") + 1);
153
+ if (isDisposableEmailDomain(domain))
154
+ return "disposable";
155
+ const resolver = options.resolver ?? await defaultSignupDnsResolver().catch(() => undefined);
156
+ if (!resolver)
157
+ return "ok";
158
+ // ONE shared deadline for the whole check (QA m3): the MX lookup and the
159
+ // A-record fallback split a single 1.5s budget instead of stacking two.
160
+ const deadlineAt = Date.now() + (options.timeoutMs ?? dnsTimeoutMs);
161
+ const withTimeout = async (work) => {
162
+ let timer;
163
+ try {
164
+ return await Promise.race([
165
+ work,
166
+ new Promise((_, reject) => {
167
+ timer = setTimeout(() => reject(new Error("dns timeout")), Math.max(1, deadlineAt - Date.now()));
168
+ timer.unref?.();
169
+ })
170
+ ]);
171
+ }
172
+ finally {
173
+ clearTimeout(timer);
174
+ }
175
+ };
176
+ try {
177
+ const records = await withTimeout(resolver.resolveMx(domain));
178
+ // RFC 7505 null MX ("." exchange) is an explicit refusal to receive.
179
+ const receiving = records.filter((record) => record.exchange !== "" && record.exchange !== ".");
180
+ if (receiving.length > 0)
181
+ return "ok";
182
+ if (records.length > 0)
183
+ return "no_mx";
184
+ // Zero MX records without an error: fall through to the A-record check.
185
+ }
186
+ catch (error) {
187
+ if (!isDefinitiveDnsMiss(error)) {
188
+ // Timeout / transport / resolver failure: NEVER block capture on DNS.
189
+ return "ok";
190
+ }
191
+ }
192
+ try {
193
+ const addresses = await withTimeout(resolver.resolve4(domain));
194
+ return addresses.length > 0 ? "ok" : "no_mx";
195
+ }
196
+ catch (error) {
197
+ return isDefinitiveDnsMiss(error) ? "no_mx" : "ok";
198
+ }
199
+ }
200
+ // ---------------------------------------------------------------------------
201
+ // Copy — declarative, ·-separated, promising ONLY what exists for the cli-*
202
+ // refs: the launch email + product updates (docs/EMAIL_SEND_POLICY.md
203
+ // anchors the promise per ref; verdict M1/M2). No savings claims, no
204
+ // urgency, no confirm-shaming, no beta/Workspace/weekly language.
205
+ // ---------------------------------------------------------------------------
206
+ export const signupCopy = {
207
+ /**
208
+ * Launch-week headline — TRUE only through launch day (Fri Aug 28); the
209
+ * date gate in signupAskBlockLines swaps in the evergreen fallback after
210
+ * signupLaunchWindowEndsAt so this copy can never go stale.
211
+ */
212
+ askHeadlineLaunch: [
213
+ "aibill launches Friday with Star.fun.",
214
+ "Get the launch email + what ships next:"
215
+ ],
216
+ askHeadlineFallback: ["Get product updates:"],
217
+ askFooter: "type your email, or press Enter to skip",
218
+ askPrompt: " > ",
219
+ waitLine: "reading your local AI evidence…",
220
+ /** Printed once if the scan finishes while the ask is still open. */
221
+ receiptReadyLine: "your receipt is ready — type your email, or press Enter to see it",
222
+ /** Printed after the FIRST empty Enter; the second empty Enter skips. */
223
+ skipNudgeLine: "one launch email · Enter again to skip, or type your email",
224
+ scopeLine: "used only for updates · never shared",
225
+ /** Printed on the first Ctrl-C at the ask (the scan continues; M2). */
226
+ interruptLine: "ok — skipped the ask · your receipt is still being read (Ctrl-C again to quit)",
227
+ /** Printed when ^C lands after the ask settled but before the receipt. */
228
+ interruptAfterAnswerLine: "still reading your evidence · Ctrl-C again to quit",
229
+ /** Printed when the re-prompt budget closes the ask (no skip consumed). */
230
+ budgetClosedLine: "moving on · npx aibill signup <email> anytime",
231
+ consentQuestion: (payloadJson) => `send ${payloadJson} → asktilden.com/api/waitlist? [y/N] `,
232
+ sentLine: "sent: exactly that JSON · nothing else in the payload",
233
+ neverLine: "ok — never asking again · npx aibill signup <email> if you change your mind",
234
+ invalidEmailLine: "that email did not validate — check it and retry",
235
+ noMxLine: "that domain can't receive email — check the spelling",
236
+ disposableLine: "use an address you actually check",
237
+ rateLimitedLine: "busy — try again in a minute, or join at https://asktilden.com",
238
+ unreachableLine: "could not reach asktilden.com — join at https://asktilden.com",
239
+ alreadyLine: "already on the list · nothing sent twice",
240
+ nothingSentLine: "nothing sent",
241
+ nonInteractiveLine: "signup needs an interactive terminal — or join at https://asktilden.com",
242
+ forgetLine: "local signup state cleared · to leave the list, reply to any email or write hello@asktilden.com",
243
+ // Static pointer lines (never interactive; see the capture design's
244
+ // moments map).
245
+ receiptPointer: "npx aibill signup <email> launch updates · optional · email only",
246
+ samplePointer: "launch updates: npx aibill signup <email> · optional · email only",
247
+ initPointer: "launch updates (optional): npx aibill signup <email>",
248
+ statuslinePointer: "launch updates: npx aibill signup <email> · optional"
249
+ };
250
+ /**
251
+ * End of the launch-week headline window (the Friday line is true only
252
+ * through launch day, Fri 2026-08-28). After this instant the ask block
253
+ * automatically uses the evergreen fallback headline.
254
+ */
255
+ export const signupLaunchWindowEndsAt = "2026-08-29T00:00:00Z";
256
+ /**
257
+ * The standout ask block: full-width rule (the receipt's own rule
258
+ * convention), then the headline for the current window, then the skip
259
+ * footer. Text lines carry the receipt's two-space indent.
260
+ */
261
+ export function signupAskBlockLines(now, width) {
262
+ const headline = now.getTime() < Date.parse(signupLaunchWindowEndsAt)
263
+ ? signupCopy.askHeadlineLaunch
264
+ : signupCopy.askHeadlineFallback;
265
+ return [
266
+ "─".repeat(Math.max(8, width)),
267
+ ...headline.map((line) => ` ${line}`),
268
+ ` ${signupCopy.askFooter}`
269
+ ];
270
+ }
271
+ /** Same clamp the receipt renderer applies (COLUMNS → tty → 72, 40..120). */
272
+ export function signupTerminalWidth() {
273
+ const envColumns = Number(process.env.COLUMNS);
274
+ const ttyColumns = process.stdout.columns;
275
+ const requested = Number.isFinite(envColumns) && envColumns > 0
276
+ ? envColumns
277
+ : Number.isFinite(ttyColumns) && (ttyColumns ?? 0) > 0
278
+ ? ttyColumns
279
+ : 72;
280
+ return Math.max(40, Math.min(120, Math.floor(requested)));
281
+ }
282
+ export function signupStateFilePath(homeDirectory) {
283
+ return join(homeDirectory ?? homedir(), ".aibill", "signup.json");
284
+ }
285
+ function isSignupState(value) {
286
+ if (typeof value !== "object" || value === null)
287
+ return false;
288
+ const state = value;
289
+ return state.version === 1 &&
290
+ (state.status === "subscribed" || state.status === "never" || state.status === "deferred") &&
291
+ typeof state.askCount === "number" && Number.isInteger(state.askCount) && state.askCount >= 0 &&
292
+ (state.stampCount === undefined ||
293
+ (typeof state.stampCount === "number" && Number.isInteger(state.stampCount) && state.stampCount >= 0)) &&
294
+ (state.lastAskedAt === undefined || typeof state.lastAskedAt === "string") &&
295
+ (state.email === undefined || typeof state.email === "string");
296
+ }
297
+ export async function readSignupState(filePath) {
298
+ let contents;
299
+ try {
300
+ contents = await readFile(filePath, "utf8");
301
+ }
302
+ catch (error) {
303
+ if (error instanceof Error && error.code === "ENOENT") {
304
+ return { kind: "fresh" };
305
+ }
306
+ // Unreadable (permissions, not a file, …): fail closed to never-ask.
307
+ return { kind: "unreadable" };
308
+ }
309
+ try {
310
+ const parsed = JSON.parse(contents);
311
+ if (!isSignupState(parsed))
312
+ return { kind: "unreadable" };
313
+ return { kind: "ok", state: parsed };
314
+ }
315
+ catch {
316
+ // Corrupt state must never cause re-nagging: fail closed.
317
+ return { kind: "unreadable" };
318
+ }
319
+ }
320
+ /** Atomic-ish write; returns false instead of throwing so callers fail closed. */
321
+ export async function writeSignupState(filePath, state) {
322
+ try {
323
+ await mkdir(dirname(filePath), { recursive: true });
324
+ const temporaryPath = `${filePath}.tmp`;
325
+ await writeFile(temporaryPath, `${JSON.stringify(state, null, 2)}\n`, "utf8");
326
+ await rename(temporaryPath, filePath);
327
+ return true;
328
+ }
329
+ catch {
330
+ return false;
331
+ }
332
+ }
333
+ export async function clearSignupState(filePath) {
334
+ try {
335
+ await rm(filePath, { force: true });
336
+ return true;
337
+ }
338
+ catch {
339
+ return false;
340
+ }
341
+ }
342
+ const minimumMsBetweenAsks = 7 * 24 * 60 * 60 * 1_000;
343
+ /** Hard lifetime ceiling on ask openings, decided answer or not (QA m10). */
344
+ export const maximumLifetimeAskStamps = 6;
345
+ /**
346
+ * Whether the ONE ask may run. Fails closed on unreadable state; a
347
+ * future-dated lastAskedAt (clock moved back) counts as recently asked,
348
+ * never as a reason to re-ask.
349
+ */
350
+ export function signupAskAllowed(read, now) {
351
+ if (read.kind === "unreadable")
352
+ return false;
353
+ if (read.kind === "fresh")
354
+ return true;
355
+ const state = read.state;
356
+ if (state.status === "subscribed" || state.status === "never")
357
+ return false;
358
+ if (state.askCount >= 2)
359
+ return false;
360
+ if ((state.stampCount ?? 0) >= maximumLifetimeAskStamps)
361
+ return false;
362
+ if (state.lastAskedAt !== undefined) {
363
+ const askedAt = Date.parse(state.lastAskedAt);
364
+ if (!Number.isFinite(askedAt))
365
+ return false;
366
+ const age = now.getTime() - askedAt;
367
+ if (age < minimumMsBetweenAsks)
368
+ return false;
369
+ }
370
+ return true;
371
+ }
372
+ export const signupAskTimeoutMs = 30_000;
373
+ /**
374
+ * Opens the ONE ask, or returns undefined (with zero output) when state
375
+ * disallows it — subsequent runs stay byte-identical to the fast path.
376
+ *
377
+ * Decision ledger (unchanged semantics, earlier placement):
378
+ * - timeout / Ctrl-C → no decision: lastAskedAt stamped, NO skip
379
+ * - Enter, then Enter again → ONE lifetime skip (nudge line between)
380
+ * - n / never → never ask again
381
+ * - typed input → email path (format + deliverability checks
382
+ * re-prompt without consuming skips)
383
+ * The consent step (literal payload JSON → y/N → single POST) runs strictly
384
+ * AFTER the receipt via runSignupConsentAfterReceipt.
385
+ */
386
+ export async function openPreReceiptSignupAsk(options) {
387
+ const now = options.now ?? (() => new Date());
388
+ const read = await readSignupState(options.stateFilePath);
389
+ if (!signupAskAllowed(read, now()))
390
+ return undefined;
391
+ const base = read.kind === "ok"
392
+ ? read.state
393
+ : { version: 1, status: "deferred", askCount: 0 };
394
+ // Stamp the ask BEFORE prompting. If home state cannot be written the ask
395
+ // never shows (readonly home must not become an every-run nag: the decision
396
+ // could never be persisted — fail closed to never-ask).
397
+ const stamped = {
398
+ ...base,
399
+ status: "deferred",
400
+ stampCount: (base.stampCount ?? 0) + 1,
401
+ lastAskedAt: now().toISOString()
402
+ };
403
+ if (!await writeSignupState(options.stateFilePath, stamped))
404
+ return undefined;
405
+ let settled = false;
406
+ const io = options.io;
407
+ const write = (line) => io.write(line);
408
+ const outcome = (async () => {
409
+ for (const line of options.preambleLines ?? [])
410
+ write(line);
411
+ write("");
412
+ for (const line of signupAskBlockLines(now(), options.width ?? 72))
413
+ write(line);
414
+ let enterNudgeShown = false;
415
+ // Bounded re-prompt budget: format/deliverability rejections re-prompt
416
+ // without consuming skips, but an adversarial or hopeless session ends
417
+ // silently (no skip consumed) instead of looping forever.
418
+ for (let rejections = 0; rejections < 6; rejections += 1) {
419
+ const answer = await io.question(signupCopy.askPrompt, signupAskTimeoutMs);
420
+ if (answer === undefined) {
421
+ // Inactivity timeout or Ctrl-C: no decision, no lifetime skip
422
+ // (verdict M3) — the lastAskedAt stamp alone throttles re-asks.
423
+ return { kind: "skipped" };
424
+ }
425
+ const trimmed = answer.trim();
426
+ if (trimmed === "") {
427
+ if (!enterNudgeShown) {
428
+ // Founder-approved ceiling: exactly one declarative nudge, then
429
+ // the second empty Enter completes the skip. Never more.
430
+ enterNudgeShown = true;
431
+ write(` ${signupCopy.skipNudgeLine}`);
432
+ continue;
433
+ }
434
+ await writeSignupState(options.stateFilePath, { ...stamped, askCount: stamped.askCount + 1 });
435
+ return { kind: "skipped" };
436
+ }
437
+ const lowered = trimmed.toLowerCase();
438
+ if (lowered === "n" || lowered === "never") {
439
+ await writeSignupState(options.stateFilePath, { ...stamped, status: "never" });
440
+ write(` ${signupCopy.neverLine}`);
441
+ return { kind: "skipped" };
442
+ }
443
+ const email = normalizeWaitlistEmail(trimmed);
444
+ if (email === undefined) {
445
+ write(` ${signupCopy.invalidEmailLine}`);
446
+ continue;
447
+ }
448
+ const deliverability = await assessEmailDeliverability(email, {
449
+ ...(options.dnsResolver ? { resolver: options.dnsResolver } : {})
450
+ });
451
+ if (deliverability === "no_mx") {
452
+ write(` ${signupCopy.noMxLine}`);
453
+ continue;
454
+ }
455
+ if (deliverability === "disposable") {
456
+ write(` ${signupCopy.disposableLine}`);
457
+ continue;
458
+ }
459
+ return { kind: "email", payload: { email, ref: buildWaitlistRef("receipt") }, stamped };
460
+ }
461
+ // Re-prompt budget exhausted: close the surface honestly instead of a
462
+ // silent dead prompt (QA m2). No skip is consumed.
463
+ write(` ${signupCopy.budgetClosedLine}`);
464
+ return { kind: "skipped" };
465
+ })().finally(() => {
466
+ settled = true;
467
+ });
468
+ return {
469
+ outcome,
470
+ notifyReceiptReady: () => {
471
+ if (settled)
472
+ return;
473
+ io.write(` ${signupCopy.receiptReadyLine}`);
474
+ io.writeRaw?.(signupCopy.askPrompt);
475
+ }
476
+ };
477
+ }
478
+ /**
479
+ * The consent step, strictly AFTER the receipt has printed: scope line, the
480
+ * literal payload JSON, a typed y — then ONE POST. Failures never persist,
481
+ * retry, or queue the typed email.
482
+ */
483
+ export async function runSignupConsentAfterReceipt(options) {
484
+ const { io, stamped } = options;
485
+ io.write("");
486
+ io.write(signupCopy.scopeLine);
487
+ const consent = await io.question(signupCopy.consentQuestion(serializeWaitlistPayload(options.payload)), signupAskTimeoutMs);
488
+ if (consent === undefined)
489
+ return;
490
+ const consentAnswer = consent.trim().toLowerCase();
491
+ if (consentAnswer !== "y" && consentAnswer !== "yes") {
492
+ await writeSignupState(options.stateFilePath, { ...stamped, askCount: stamped.askCount + 1 });
493
+ return;
494
+ }
495
+ const outcome = await postWaitlistSignup(options.payload, {
496
+ ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {})
497
+ });
498
+ if (outcome === "sent") {
499
+ await writeSignupState(options.stateFilePath, {
500
+ ...stamped,
501
+ status: "subscribed",
502
+ email: options.payload.email
503
+ });
504
+ io.write(signupCopy.sentLine);
505
+ return;
506
+ }
507
+ // Failure: the typed email is never persisted, retried, or queued.
508
+ await writeSignupState(options.stateFilePath, stamped);
509
+ io.write(outcome === "invalid_email"
510
+ ? signupCopy.invalidEmailLine
511
+ : outcome === "rate_limited"
512
+ ? signupCopy.rateLimitedLine
513
+ : signupCopy.unreachableLine);
514
+ }
515
+ /**
516
+ * Which argv shapes qualify for the during-scan ask: the real receipt path
517
+ * only — the default/quickstart command (flag-form included) — and never
518
+ * sample, drill-downs, JSON, help/version, or any named command (improve,
519
+ * report, init, …).
520
+ */
521
+ export function qualifiesForPreReceiptSignupAsk(argv) {
522
+ if (argv.includes("--sample") || argv.includes("--group-by") || argv.includes("--json"))
523
+ return false;
524
+ if (argv.includes("--help") || argv.includes("-h") || argv.includes("--version") || argv.includes("-v"))
525
+ return false;
526
+ const command = argv[0];
527
+ return command === undefined || command === "quickstart" || command === "demo" || command.startsWith("--");
528
+ }
529
+ /**
530
+ * Concurrency shell: run the evidence pipeline and the open ask together.
531
+ * The receipt renders only after BOTH resolve; a pipeline error is captured
532
+ * (never an unhandled rejection) and still waits for the bounded ask, so a
533
+ * prompt can never hang over a dead pipeline longer than its own timeout.
534
+ * If the pipeline settles first, the session gets the one ready-nudge.
535
+ */
536
+ export async function orchestratePreReceiptAsk(input) {
537
+ const settledPipeline = input.runPipeline().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
538
+ const session = input.session;
539
+ if (!session) {
540
+ return { pipeline: await settledPipeline, outcome: { kind: "no_ask" } };
541
+ }
542
+ let askSettled = false;
543
+ const trackedOutcome = session.outcome.then((value) => {
544
+ askSettled = true;
545
+ return value;
546
+ });
547
+ void settledPipeline.then((settled) => {
548
+ // The nudge announces a READY receipt — never fire it over an error
549
+ // (QA m1); the error voice follows once the bounded ask resolves.
550
+ if (settled.ok && !askSettled)
551
+ session.notifyReceiptReady();
552
+ });
553
+ const [pipeline, outcome] = await Promise.all([settledPipeline, trackedOutcome]);
554
+ return { pipeline, outcome };
555
+ }
556
+ /**
557
+ * Opens the during-scan ask on the real terminal: its own readline with
558
+ * per-read AbortSignal timeouts. The timeout race can never eat a typed
559
+ * skip — either the line resolved first (the skip is recorded) or the abort
560
+ * won and NOTHING is recorded; the wrong decision is unrepresentable.
561
+ * Ctrl-C cancels only the ask (no skip consumed, state already stamped);
562
+ * the receipt still renders. Returns undefined — with zero output — when
563
+ * signup state disallows the ask.
564
+ */
565
+ export async function openPreReceiptSignupAskInTerminal() {
566
+ try {
567
+ const { createInterface } = await import("node:readline/promises");
568
+ const lineInterface = createInterface({ input: process.stdin, output: process.stdout });
569
+ let interrupted = false;
570
+ 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
+ let waiter;
578
+ const settleWaiter = (line) => {
579
+ const settle = waiter;
580
+ waiter = undefined;
581
+ if (settle)
582
+ settle(line);
583
+ };
584
+ // QA M4: lines that arrive while no read is armed (paste bursts landing
585
+ // in the gaps between questions) are buffered and then DISCARDED with
586
+ // the guided engine's drain notice when the next read arms — they are
587
+ // never used as answers, so pasted input can neither pre-answer the
588
+ // consent step nor strand the user in 30s of silent dead air.
589
+ const strayLines = [];
590
+ lineInterface.on("line", (line) => {
591
+ if (waiter)
592
+ settleWaiter(line);
593
+ else
594
+ strayLines.push(line);
595
+ });
596
+ lineInterface.on("close", () => {
597
+ settleWaiter(undefined);
598
+ });
599
+ lineInterface.on("SIGINT", () => {
600
+ interrupted = true;
601
+ if (waiter) {
602
+ // A read was pending: skip the ask, keep the scan (QA M2).
603
+ process.stdout.write(`\n ${signupCopy.interruptLine}\n`);
604
+ settleWaiter(undefined);
605
+ }
606
+ else {
607
+ // No read pending (already answered / consent done): readline was
608
+ // swallowing the ^C — say so and stand aside so the NEXT ^C gets
609
+ // the default kill behavior.
610
+ process.stdout.write(`\n ${signupCopy.interruptAfterAnswerLine}\n`);
611
+ }
612
+ lineInterface.close();
613
+ });
614
+ const io = {
615
+ question: async (query, timeoutMs) => {
616
+ if (interrupted)
617
+ return undefined;
618
+ if (strayLines.length > 0) {
619
+ const discarded = strayLines.length;
620
+ strayLines.length = 0;
621
+ process.stdout.write(` ${renderDrainNotice(discarded)}\n`);
622
+ }
623
+ process.stdout.write(query);
624
+ const timer = setTimeout(() => settleWaiter(undefined), timeoutMs);
625
+ timer.unref?.();
626
+ try {
627
+ const answer = await new Promise((resolvePromise) => {
628
+ waiter = resolvePromise;
629
+ });
630
+ abortedRead = answer === undefined;
631
+ return answer;
632
+ }
633
+ finally {
634
+ clearTimeout(timer);
635
+ }
636
+ },
637
+ write: (line) => {
638
+ process.stdout.write(`${line}\n`);
639
+ },
640
+ writeRaw: (text) => {
641
+ process.stdout.write(text);
642
+ }
643
+ };
644
+ const session = await openPreReceiptSignupAsk({
645
+ io,
646
+ stateFilePath: signupStateFilePath(),
647
+ width: signupTerminalWidth(),
648
+ preambleLines: [signupCopy.waitLine]
649
+ });
650
+ if (!session) {
651
+ lineInterface.close();
652
+ return undefined;
653
+ }
654
+ return {
655
+ session,
656
+ needsFreshLine: () => abortedRead,
657
+ runConsent: async (outcome) => {
658
+ if (interrupted)
659
+ return;
660
+ try {
661
+ await runSignupConsentAfterReceipt({
662
+ io,
663
+ stateFilePath: signupStateFilePath(),
664
+ payload: outcome.payload,
665
+ stamped: outcome.stamped
666
+ });
667
+ }
668
+ catch {
669
+ // Consent must never break the receipt path.
670
+ }
671
+ },
672
+ close: () => {
673
+ lineInterface.close();
674
+ }
675
+ };
676
+ }
677
+ catch {
678
+ return undefined;
679
+ }
680
+ }
681
+ //# sourceMappingURL=signup.js.map
@@ -558,8 +558,10 @@ function freshnessSegment(snapshot, now) {
558
558
  const ageMs = now.getTime() - Date.parse(snapshot.lastSuccessAt ?? snapshot.generatedAt);
559
559
  if (ageMs < 0)
560
560
  return "clock mismatch";
561
+ // "cache Nm old" states the fact; "stale" overclaimed at minutes-old ages
562
+ // next to the product's multi-day evidence windows (shipped-audit fix).
561
563
  return ageMs > STALE_AFTER_MS
562
- ? `stale ${formatAge(ageMs)}`
564
+ ? `cache ${formatAge(ageMs)} old`
563
565
  : `updated ${formatAge(ageMs)}`;
564
566
  }
565
567
  function formatAge(rawAgeMs) {