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.
@@ -11,6 +11,8 @@
11
11
  * drain on every answer, exact-token choices, strict ISO times, reserved
12
12
  * vocabulary, Unicode format-character rejection, byte-honest length copy).
13
13
  */
14
+ import { classifyGuidedAnswer, type ClassifyContext, type ClassifyResult, type GuidedFieldKind, type RejectCode } from "@agent-finops/core";
15
+ export { classifyGuidedAnswer, type ClassifyContext, type ClassifyResult, type GuidedFieldKind, type RejectCode };
14
16
  export type PromptSourceEvent = {
15
17
  kind: "line";
16
18
  text: string;
@@ -41,33 +43,6 @@ export declare function createInteractivePromptSource(emitter: {
41
43
  onClose: (listener: () => void) => void;
42
44
  onInterrupt: (listener: () => void) => void;
43
45
  }): GuidedPromptSource;
44
- export type GuidedFieldKind = "prose" | "name" | "team" | "role" | "optional" | "time" | "choice" | "approve";
45
- export type ClassifyContext = {
46
- example?: string;
47
- /** Exact accepted tokens for choice fields, lowercase. */
48
- choiceTokens?: readonly string[];
49
- /** ISO instant the plan was approved (time fields). */
50
- approvedAtIso?: string;
51
- /** Clock override for tests. */
52
- nowMs?: number;
53
- /** Consecutive identical shell-rejections at this step (keep override). */
54
- priorShellRejections?: number;
55
- };
56
- export type ClassifyResult = {
57
- outcome: "accept";
58
- value: string;
59
- } | {
60
- outcome: "navigate";
61
- action: "back" | "cancel";
62
- } | {
63
- outcome: "skip";
64
- } | {
65
- outcome: "reject";
66
- code: RejectCode;
67
- message: string;
68
- };
69
- export type RejectCode = "control" | "empty" | "shell" | "path" | "credential" | "reserved" | "timestamp_shaped" | "length" | "substance" | "time_invalid" | "time_before_approval" | "time_future" | "choice" | "approve_case";
70
- export declare function classifyGuidedAnswer(kind: GuidedFieldKind, rawInput: string, context?: ClassifyContext): ClassifyResult;
71
46
  export type GuidedScreenHeader = {
72
47
  commandTitle: string;
73
48
  experimentLabel: string;
@@ -11,7 +11,11 @@
11
11
  * drain on every answer, exact-token choices, strict ISO times, reserved
12
12
  * vocabulary, Unicode format-character rejection, byte-honest length copy).
13
13
  */
14
- import { isCredentialLike, isPathLike } from "./projectAccountabilityState.js";
14
+ import { classifyGuidedAnswer } from "@agent-finops/core";
15
+ // The classifier lives in core (guidedAnswer.ts) so the terminal lane, the
16
+ // agent-draft screening lane, and the MCP draft_improve_command preview
17
+ // share ONE function by construction. Re-exported for existing consumers.
18
+ export { classifyGuidedAnswer };
15
19
  const PROMPT_MARKER = "> ";
16
20
  /**
17
21
  * Scripted source for tests and non-interactive callers. Exhaustion THROWS:
@@ -98,287 +102,6 @@ export function createInteractivePromptSource(emitter) {
98
102
  }
99
103
  };
100
104
  }
101
- const unambiguousBinaries = new Set([
102
- "node", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", "tsx", "ts-node",
103
- "git", "gh", "curl", "wget", "bash", "sh", "zsh", "fish", "pwsh", "powershell",
104
- "python", "python3", "pip", "pip3", "pipx", "uv", "uvx", "poetry", "pytest",
105
- "vitest", "jest", "brew", "apt", "docker", "kubectl", "cargo", "rustc",
106
- "dotnet", "mvn", "gradle", "terraform", "ssh", "scp", "rsync", "sudo",
107
- "chmod", "chown", "xargs", "grep", "rg", "sed", "awk", "tar", "zip", "unzip",
108
- "aibill", "ai-spend-agent", "vim", "nano", "code", "dir", "del", "robocopy"
109
- ]);
110
- /** Common English verbs that are also binaries: reject only with corroboration. */
111
- const ambiguousVerbs = new Set([
112
- "make", "open", "find", "go", "date", "kill", "top", "head", "tail", "touch",
113
- "export", "source", "alias", "echo", "type", "cd", "ls", "cat", "cp", "mv",
114
- "rm", "printf", "less", "ps", "copy", "move"
115
- ]);
116
- const reservedVocabulary = new Set([
117
- "held", "passed", "failed", "missing", "regressed", "approve", "approved",
118
- "yes", "no", "y", "n", "p", "f", "h", "r", "m", "now", "skip", "keep"
119
- ]);
120
- /**
121
- * The accountability backstop predicate (`isCredentialLike`) is the FLOOR:
122
- * this classifier must reject at least everything `parseDisplayLabel` would
123
- * reject, or a validated answer could still abort later (B2 QA blocker B1).
124
- * These extra patterns sit on top of the floor.
125
- */
126
- const extraCredentialPattern = /(authorization\s*:\s*(?:bearer|basic)\s+\S+|-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[ _-]?key|token|password|secret)\s*[:=]\s*\S+)/i;
127
- function looksLikeCredential(answer) {
128
- return isCredentialLike(answer) || extraCredentialPattern.test(answer);
129
- }
130
- const pathExtensionPattern = /\S+\.(?:js|ts|mjs|cjs|jsx|tsx|json|sh|bash|py|rb|go|rs|md|yml|yaml|toml|lock|xml|ps1|bat|cmd|gradle)(?:$|\s)/i;
131
- /** Strict full date-time with zone, mirroring the CLI's validIsoString. */
132
- const strictIsoPattern = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:[Zz]|[+-]\d{2}:?\d{2})$/;
133
- const futureToleranceMs = 2 * 60 * 1000;
134
- /**
135
- * Exact non-answer phrases (normalized: lowercase, punctuation stripped).
136
- * A plan sentence must be actionable later; "i am not sure" passes the
137
- * two-word substance bar but records a safety net that cannot be executed.
138
- * Exact match only — a real sentence that merely CONTAINS one still passes.
139
- */
140
- const nonAnswerPhrases = new Set([
141
- "i am not sure", "im not sure", "not sure", "unsure", "i am unsure",
142
- "idk", "i dont know", "dont know", "no idea", "dunno",
143
- "no se", "no sé", "ni idea",
144
- "whatever", "anything", "nothing", "none", "na", "tbd",
145
- "help", "test", "testing", "asdf"
146
- ]);
147
- function isNonAnswer(lowered) {
148
- const normalized = lowered.replace(/[.,!?'’]/g, "").replace(/\s+/g, " ").trim();
149
- return nonAnswerPhrases.has(normalized);
150
- }
151
- const choiceWordAliases = {
152
- yes: "y",
153
- no: "n",
154
- passed: "p",
155
- failed: "f",
156
- held: "h",
157
- regressed: "r",
158
- missing: "m"
159
- };
160
- function stripQuotes(token) {
161
- return token.replace(/^["'`]+/, "").replace(/["'`]+$/, "");
162
- }
163
- function stripEnvPrefixes(tokens) {
164
- let index = 0;
165
- while (index < tokens.length &&
166
- (/^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(tokens[index]) ||
167
- tokens[index] === "sudo" || tokens[index] === "env")) {
168
- index += 1;
169
- }
170
- return tokens.slice(index);
171
- }
172
- function looksLikeShellCommand(answer) {
173
- if (/^[$%#>] ?/.test(answer))
174
- return true;
175
- if (/&&|\|\||`|\$\(|>>|<<|2>&1| \| | > | < /.test(answer))
176
- return true;
177
- if (/(?:^|\s)--[A-Za-z][\w-]*/.test(answer))
178
- return true;
179
- if (/(?:^|\s)-[A-Za-z](?=\s|$)/.test(answer))
180
- return true;
181
- // PowerShell Verb-Noun cmdlet shape (Get-ChildItem, Remove-Item …).
182
- if (/^[A-Z][a-z]+-[A-Z][A-Za-z]+(?:\s|$)/.test(answer))
183
- return true;
184
- const tokens = stripEnvPrefixes(answer.split(/\s+/).filter(Boolean));
185
- const first = stripQuotes(tokens[0] ?? "").toLowerCase();
186
- if (unambiguousBinaries.has(first))
187
- return true;
188
- if (ambiguousVerbs.has(first)) {
189
- // Ambiguous English verbs reject only with a second signal: a path-like
190
- // token, a file extension, or a terse all-lowercase fragment that reads
191
- // like a command line rather than a sentence.
192
- const hasPathToken = tokens.some((token) => /^(?:\/|\.\/|\.\.\/|~\/)/.test(token) || token.includes("/"));
193
- const hasExtension = pathExtensionPattern.test(answer);
194
- const terseLowercase = tokens.length <= 2 && answer === answer.toLowerCase();
195
- return hasPathToken || hasExtension || terseLowercase;
196
- }
197
- return false;
198
- }
199
- function looksLikePath(answer, kind) {
200
- // The accountability backstop predicate is the floor: it covers leading
201
- // slashes and dot-segments, drive letters, backslashes, and bare ".".
202
- if (isPathLike(answer))
203
- return true;
204
- if (kind === "prose") {
205
- if (pathExtensionPattern.test(answer))
206
- return true;
207
- const slashTokens = answer.split(/\s+/).filter((token) => token.includes("/"));
208
- return slashTokens.length >= 2;
209
- }
210
- // Name-like fields: a slashed token is a path; a bare file-extension token
211
- // counts only when it IS the whole answer — "Node.js Guild" is a team.
212
- if (/\S+\/\S+/.test(answer))
213
- return true;
214
- return pathExtensionPattern.test(answer) && !/\s/.test(answer.trim());
215
- }
216
- function byteLength(value) {
217
- return Buffer.byteLength(value.normalize("NFC"), "utf8");
218
- }
219
- function hasControlOrFormatCharacters(value) {
220
- // C0/C1 controls including embedded newlines and DEL (tab and trailing
221
- // CR are normalized earlier), plus the invisible/directional format
222
- // characters that can spoof what the review screen appears to say.
223
- // ZWNJ/ZWJ (U+200C/U+200D) are deliberately ALLOWED: they are standard
224
- // orthography in Persian and other scripts and in emoji families.
225
- if (/[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u2028\u2029]/.test(value))
226
- return true;
227
- return /[\u00AD\u061C\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/.test(value);
228
- }
229
- function hasUnpairedSurrogate(value) {
230
- for (let index = 0; index < value.length; index += 1) {
231
- const code = value.charCodeAt(index);
232
- if (code >= 0xd800 && code <= 0xdbff) {
233
- const next = value.charCodeAt(index + 1);
234
- if (!(next >= 0xdc00 && next <= 0xdfff))
235
- return true;
236
- index += 1;
237
- }
238
- else if (code >= 0xdc00 && code <= 0xdfff) {
239
- return true;
240
- }
241
- }
242
- return false;
243
- }
244
- export function classifyGuidedAnswer(kind, rawInput, context = {}) {
245
- const normalizedTabs = rawInput.replace(/\t/g, " ").replace(/\r$/, "");
246
- const answer = normalizedTabs.trim();
247
- const lowered = answer.toLowerCase();
248
- const reject = (code, message) => ({
249
- outcome: "reject", code, message
250
- });
251
- const exampleSuffix = context.example ? ` e.g. ${context.example}` : "";
252
- // Navigation pre-pass (every field).
253
- if (lowered === "back" || lowered === "b")
254
- return { outcome: "navigate", action: "back" };
255
- if (lowered === "cancel" || lowered === "q" || lowered === "quit" || lowered === "exit") {
256
- return { outcome: "navigate", action: "cancel" };
257
- }
258
- if (kind === "optional" && (answer === "" || lowered === "skip")) {
259
- return { outcome: "skip" };
260
- }
261
- if (hasControlOrFormatCharacters(answer) || hasUnpairedSurrogate(answer)) {
262
- return reject("control", "That answer carried hidden control characters (usually a stray paste). Type it as plain text.");
263
- }
264
- if (answer === "") {
265
- if (kind === "approve") {
266
- // An empty answer at the approval screen is a decline, not an error.
267
- return { outcome: "navigate", action: "cancel" };
268
- }
269
- return reject("empty", "This step needs an answer in words. Type it, or type back or cancel.");
270
- }
271
- if (looksLikeCredential(answer)) {
272
- // Never echo, never store: callers must discard this input entirely.
273
- return reject("credential", "That looks like it contains a credential. aibill never stores credentials — that answer was discarded. Type it again without the secret.");
274
- }
275
- switch (kind) {
276
- case "approve": {
277
- if (answer === "APPROVE")
278
- return { outcome: "accept", value: answer };
279
- // Clear approval intent gets the nudge, never a silent decline:
280
- // APPROVED, aprove, i approve, full-width IME APPROVE, yes/y.
281
- const folded = answer.normalize("NFKC").toUpperCase();
282
- if (folded.includes("APPROV") || folded.includes("APROVE") ||
283
- lowered === "yes" || lowered === "y") {
284
- return reject("approve_case", "Approval must be typed APPROVE, in capitals, so it cannot happen by accident.");
285
- }
286
- // Any other answer is a decline — an answer, not an error.
287
- return { outcome: "navigate", action: "cancel" };
288
- }
289
- case "choice": {
290
- const tokens = context.choiceTokens ?? [];
291
- if (tokens.includes(lowered))
292
- return { outcome: "accept", value: lowered };
293
- // Exact full words map to their canonical letter, but only within
294
- // their own question family: "no" at a p/f/n question must reprompt,
295
- // never silently become "n". Never prefix-match either — "probably
296
- // failed" or "not sure" reprompts, not guesses.
297
- const alias = choiceWordAliases[lowered];
298
- if (alias !== undefined && tokens.includes(alias)) {
299
- const applies = lowered === "yes" || lowered === "no" ? tokens.includes("y") :
300
- lowered === "passed" || lowered === "failed" ? tokens.includes("p") :
301
- tokens.includes("h");
302
- if (applies)
303
- return { outcome: "accept", value: alias };
304
- }
305
- if (tokens.includes("p")) {
306
- return reject("choice", "Answer p (passed), f (failed), or n (not run yet).");
307
- }
308
- if (tokens.includes("h")) {
309
- return reject("choice", "Answer h (held), r (regressed), or m (cannot say).");
310
- }
311
- return reject("choice", `Answer one of: ${tokens.join(", ")}.`);
312
- }
313
- case "time": {
314
- if (lowered === "now") {
315
- return { outcome: "accept", value: new Date(context.nowMs ?? Date.now()).toISOString() };
316
- }
317
- if (!strictIsoPattern.test(answer)) {
318
- return reject("time_invalid", "That is not a UTC ISO-8601 time. e.g. 2026-08-17T14:03:00Z — or type now if it just finished.");
319
- }
320
- const parsed = Date.parse(answer);
321
- if (!Number.isFinite(parsed)) {
322
- return reject("time_invalid", "That is not a UTC ISO-8601 time. e.g. 2026-08-17T14:03:00Z — or type now if it just finished.");
323
- }
324
- const approvedAt = context.approvedAtIso ? Date.parse(context.approvedAtIso) : undefined;
325
- if (context.approvedAtIso !== undefined && !Number.isFinite(approvedAt ?? Number.NaN)) {
326
- // Fail closed: an unreadable approval time must never silently
327
- // disable the after-approval check.
328
- return reject("time_invalid", "The approval record's own time is unreadable, so this time cannot be checked. Type cancel and rerun this command.");
329
- }
330
- if (approvedAt !== undefined && parsed <= approvedAt) {
331
- return reject("time_before_approval", `That time is not after the approval at ${context.approvedAtIso}. A change cannot be applied before it was approved. Paste the time the agent reported.`);
332
- }
333
- if (parsed > (context.nowMs ?? Date.now()) + futureToleranceMs) {
334
- return reject("time_future", "That time is in the future. Paste the actual reported time.");
335
- }
336
- return { outcome: "accept", value: new Date(parsed).toISOString() };
337
- }
338
- default:
339
- break;
340
- }
341
- if (looksLikeShellCommand(answer)) {
342
- const message = kind === "prose"
343
- ? "That looks like a shell command, not an answer. Nothing runs here — describe it in words."
344
- : "That looks like a shell command, not a name. Answer with the name in words.";
345
- return reject("shell", message);
346
- }
347
- if (kind === "prose" && lowered === "keep" && (context.priorShellRejections ?? 0) >= 2) {
348
- // After repeated identical shell rejections the user may type `keep` to
349
- // record their exact text as words. The caller substitutes the last
350
- // rejected line; this sentinel value never reaches storage.
351
- return { outcome: "accept", value: "keep" };
352
- }
353
- if (looksLikePath(answer, kind)) {
354
- return reject("path", "That looks like a file path. Describe it in words instead — the answer must read as a sentence, not a location.");
355
- }
356
- if (kind === "name" || kind === "team" || kind === "role" || kind === "optional") {
357
- if (reservedVocabulary.has(lowered)) {
358
- const message = kind === "role"
359
- ? `"${answer}" is aibill's own vocabulary, not a role. Answer with your real job role, in words.${exampleSuffix}`
360
- : `That is aibill vocabulary, not a name. Answer in your own words.${exampleSuffix}`;
361
- return reject("reserved", message);
362
- }
363
- if (strictIsoPattern.test(answer)) {
364
- return reject("timestamp_shaped", `That is a time, not a name.${exampleSuffix}`);
365
- }
366
- if (byteLength(answer) > 192) {
367
- return reject("length", "That name is longer than aibill can store (192 bytes). Use a shorter form.");
368
- }
369
- return { outcome: "accept", value: answer.normalize("NFC") };
370
- }
371
- // prose
372
- if (answer.length > 1000) {
373
- return reject("length", "Keep it to one or two short sentences (under 1,000 characters).");
374
- }
375
- if (answer.split(/\s+/).filter(Boolean).length < 2 || isNonAnswer(lowered)) {
376
- return reject("substance", `That is not something aibill can hold you to later. Write what should actually happen — or type back or cancel if you are not ready.${exampleSuffix}`);
377
- }
378
- // NFC like the name fields: the rollback sentence is later re-typed and
379
- // compared by hash, so composition differences must not fail the match.
380
- return { outcome: "accept", value: answer.normalize("NFC") };
381
- }
382
105
  export function renderGuidedHeader(header) {
383
106
  const demoPrefix = header.demo
384
107
  ? "DEMO · synthetic sample — practice run, nothing is recorded\n"
@@ -86,6 +86,17 @@ export type PlanDraftStore = {
86
86
  clear: () => Promise<void>;
87
87
  };
88
88
  export declare function parsePlanDraft(value: unknown): PlanDraftV1 | undefined;
89
+ export type SuggestedPlanAnswer = {
90
+ value: string;
91
+ /**
92
+ * Who actually wrote this suggestion. Drives the per-field prefill label:
93
+ * "agent" renders `Drafted with your agent`, "aibill" renders `Suggested`.
94
+ * Acceptance converts provenance (m9): the moment the user Enter-accepts
95
+ * or types a sentence it is the USER'S answer — revisits show
96
+ * `Current answer:` and no provenance is stored anywhere.
97
+ */
98
+ provenance: "aibill" | "agent";
99
+ };
89
100
  export type PlanResult = {
90
101
  action: "cancelled";
91
102
  } | {
@@ -118,12 +129,15 @@ export declare function runPlanSitting(io: FlowIo, options: {
118
129
  /**
119
130
  * Machine-drafted plan sentences, accepted with Enter. The human still
120
131
  * approves the exact recorded plan; a saved draft (the user's own words)
121
- * always outranks a suggestion.
132
+ * always outranks a suggestion. Provenance travels WITH each value so
133
+ * the on-screen label is derived per field at render — there is no
134
+ * sitting-wide label option, so a mixed sitting (agent change + aibill
135
+ * rollback fallback) can never mislabel (B1, QA 17).
122
136
  */
123
137
  suggestedAnswers?: {
124
- change?: string;
125
- rollback?: string;
126
- canary?: string;
138
+ change?: SuggestedPlanAnswer;
139
+ rollback?: SuggestedPlanAnswer;
140
+ canary?: SuggestedPlanAnswer;
127
141
  };
128
142
  }): Promise<PlanResult>;
129
143
  export type RecordResult = {
@@ -141,6 +155,22 @@ export declare function runRecordSitting(io: FlowIo, options: {
141
155
  header: Omit<SittingHeader, "sitting">;
142
156
  approvedAtIso: string;
143
157
  approvedByLine: string;
158
+ /**
159
+ * Agent-drafted applied-at prefill. Enter-keep re-classifies it with
160
+ * the approvedAtIso context (defense in depth), so a time before the
161
+ * approval or in the future can never be Enter-accepted.
162
+ */
163
+ suggested?: {
164
+ appliedAtIso?: string;
165
+ };
166
+ /**
167
+ * The agent's REPORTED canary result. Renders only as a claim line
168
+ * above the unchanged p/f/n question — it never prefills anything, and
169
+ * Enter on an empty line reprompts exactly as today (M6, QA 18). The
170
+ * most fakeable financial input in the loop keeps a mandatory human
171
+ * keystroke.
172
+ */
173
+ agentCanaryReport?: "passed" | "failed";
144
174
  }): Promise<RecordResult>;
145
175
  export type QualityResult = {
146
176
  action: "cancelled";
@@ -37,12 +37,16 @@ async function ask(io, header, spec) {
37
37
  }),
38
38
  ""
39
39
  ];
40
- if (spec.emptyKeepsValue !== undefined) {
40
+ if (spec.emptyKeepsValue !== undefined && spec.enterDefault === undefined) {
41
41
  const label = spec.emptyKeepsLabel ?? "Current answer";
42
42
  parts.push(`${label}: "${spec.emptyKeepsValue}"`);
43
- parts.push(label === "Suggested"
44
- ? "Press Enter to accept it, or type your own."
45
- : "Press Enter to keep it, or type a replacement.");
43
+ // Any label other than a revisit of the user's own answer is a
44
+ // machine suggestion "Suggested" (aibill's) or "Drafted with your
45
+ // agent" (B1: the label is computed per field from real provenance,
46
+ // never sitting-wide, so it can never sit over the wrong author).
47
+ parts.push(label === "Current answer"
48
+ ? "Press Enter to keep it, or type a replacement."
49
+ : "Press Enter to accept it, or type your own.");
46
50
  parts.push("");
47
51
  }
48
52
  parts.push(renderGuidedQuestion({
@@ -70,7 +74,11 @@ async function ask(io, header, spec) {
70
74
  ? { maxIdenticalRejections: io.maxIdenticalRejections }
71
75
  : {}),
72
76
  ...(io.nowMs !== undefined ? { nowMs: io.nowMs } : {}),
73
- ...(spec.emptyKeepsValue !== undefined ? { emptyKeepsValue: spec.emptyKeepsValue } : {})
77
+ ...(spec.enterDefault !== undefined
78
+ ? { emptyKeepsValue: spec.enterDefault }
79
+ : spec.emptyKeepsValue !== undefined
80
+ ? { emptyKeepsValue: spec.emptyKeepsValue }
81
+ : {})
74
82
  });
75
83
  }
76
84
  export async function runStartSitting(io, options) {
@@ -92,8 +100,9 @@ export async function runStartSitting(io, options) {
92
100
  step: 1,
93
101
  totalSteps: 2,
94
102
  question: "Start this token test?",
95
- guidance: "Answer y or n. Type cancel to stop safely.",
103
+ guidance: "Enter = y · n stops · cancel stops safely.",
96
104
  context: { choiceTokens: ["y", "n"] },
105
+ enterDefault: "y",
97
106
  sittingHint: shortSittingHint
98
107
  });
99
108
  if (first.outcome === "cancelled" || first.outcome === "back")
@@ -123,18 +132,20 @@ const identityFields = [
123
132
  {
124
133
  field: "owner", kind: "name", label: "owner",
125
134
  question: "Who is the accountable human owner of this project's AI cost?",
126
- example: "Jose Artigas"
135
+ // Neutral placeholder identities only — a real person's details in an
136
+ // e.g. hint read as prefilled truth (shipped-audit fix).
137
+ example: "Sam Rivera"
127
138
  },
128
139
  {
129
140
  field: "team", kind: "team", label: "team",
130
141
  question: "What team does this cost belong to?",
131
- example: "Futura Studio"
142
+ example: "Platform Team"
132
143
  },
133
144
  {
134
145
  field: "role", kind: "role", label: "role",
135
146
  question: "What is your approval role for this project?",
136
147
  guidance: "(A job role — not aibill's held/passed quality vocabulary.)",
137
- example: "Founder"
148
+ example: "Engineering Manager"
138
149
  },
139
150
  {
140
151
  field: "client", kind: "optional", label: "client",
@@ -310,8 +321,9 @@ export async function runPlanSitting(io, options) {
310
321
  kind: "choice",
311
322
  step: 1,
312
323
  totalSteps,
313
- question: "Resume where you left off? Answer y to resume, n to start the plan over (saved answers are discarded).",
324
+ question: "Resume where you left off? Enter = y · n starts the plan over (saved answers are discarded).",
314
325
  context: { choiceTokens: ["y", "n"] },
326
+ enterDefault: "y",
315
327
  sittingHint: planSittingHint
316
328
  });
317
329
  if (resume.outcome === "cancelled" || resume.outcome === "back") {
@@ -339,7 +351,7 @@ export async function runPlanSitting(io, options) {
339
351
  const prose = planProse[stepIndex];
340
352
  const existing = answers[prose.key];
341
353
  const suggested = options.suggestedAnswers?.[prose.key];
342
- const prefill = existing ?? suggested;
354
+ const prefill = existing ?? suggested?.value;
343
355
  const outcome = await ask(io, header, {
344
356
  kind: "prose",
345
357
  step: stepIndex + 1,
@@ -352,8 +364,15 @@ export async function runPlanSitting(io, options) {
352
364
  navigationHint: "Type back to return, or cancel to stop safely.",
353
365
  sittingHint: planSittingHint,
354
366
  ...(prefill !== undefined ? { emptyKeepsValue: prefill } : {}),
355
- ...(prefill !== undefined && existing === undefined
356
- ? { emptyKeepsLabel: "Suggested" }
367
+ // A saved/typed answer outranks any suggestion and shows the
368
+ // default "Current answer" revisit label; a suggestion's label is
369
+ // computed from ITS OWN provenance, per field (B1, m9).
370
+ ...(prefill !== undefined && existing === undefined && suggested !== undefined
371
+ ? {
372
+ emptyKeepsLabel: suggested.provenance === "agent"
373
+ ? "Drafted with your agent"
374
+ : "Suggested"
375
+ }
357
376
  : {})
358
377
  });
359
378
  if (outcome.outcome === "cancelled")
@@ -393,8 +412,9 @@ export async function runPlanSitting(io, options) {
393
412
  kind: "choice",
394
413
  step: 4,
395
414
  totalSteps,
396
- question: "Approve as this identity? Answer y to continue, or n to stop here and run identify again first.",
415
+ question: "Approve as this identity? Enter = y · n stops here so you can run identify again first.",
397
416
  context: { choiceTokens: ["y", "n"] },
417
+ enterDefault: "y",
398
418
  sittingHint: planSittingHint
399
419
  });
400
420
  if (confirm.outcome === "cancelled")
@@ -503,7 +523,13 @@ export async function runRecordSitting(io, options) {
503
523
  example: "2026-08-17T14:03:00Z",
504
524
  navigationHint: "Type back if the change has not been applied yet, or cancel to stop safely.",
505
525
  context: { example: "2026-08-17T14:03:00Z", approvedAtIso: options.approvedAtIso },
506
- sittingHint: recordSittingHint
526
+ sittingHint: recordSittingHint,
527
+ ...(options.suggested?.appliedAtIso !== undefined
528
+ ? {
529
+ emptyKeepsValue: options.suggested.appliedAtIso,
530
+ emptyKeepsLabel: "Drafted with your agent"
531
+ }
532
+ : {})
507
533
  });
508
534
  if (time.outcome === "cancelled")
509
535
  return { action: "cancelled" };
@@ -515,7 +541,9 @@ export async function runRecordSitting(io, options) {
515
541
  kind: "choice",
516
542
  step: 2,
517
543
  totalSteps: 2,
518
- question: "Did the approved canary pass?",
544
+ question: options.agentCanaryReport !== undefined
545
+ ? `Your agent reports: canary ${options.agentCanaryReport} — not yet recorded; your\nanswer below is what counts.\nDid the approved canary pass?`
546
+ : "Did the approved canary pass?",
519
547
  guidance: "Answer p (passed) · f (failed) · n (not run yet)",
520
548
  context: { choiceTokens: ["p", "f", "n"] },
521
549
  sittingHint: recordSittingHint
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { type GuidedPromptSource } from "./guidedPrompt.js";
3
+ import { type SignupDnsResolver } from "./signup.js";
3
4
  export type CliResult = {
4
5
  exitCode: number;
5
6
  stdout: string;
@@ -26,6 +27,16 @@ export type CliRuntimeOptions = {
26
27
  source: GuidedPromptSource;
27
28
  write: (text: string) => void;
28
29
  }>;
30
+ /** Test override for the waitlist signup POST. Production uses global fetch. */
31
+ waitlistFetch?: typeof fetch;
32
+ /** Test override for signup email deliverability DNS. Production uses node:dns. */
33
+ signupDns?: SignupDnsResolver;
34
+ /**
35
+ * Set ONLY by the bin entrypoint when telemetry is enabled AND noticed:
36
+ * every "nothing uploaded" claim then prints the disclosure line instead.
37
+ * Embedded/MCP callers never set it (and never emit telemetry).
38
+ */
39
+ telemetryDisclosure?: boolean;
29
40
  };
30
41
  export declare function runCli(argv?: string[], runtime?: CliRuntimeOptions): Promise<CliResult>;
31
42
  /**