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/guidedPrompt.d.ts +2 -27
- package/dist/guidedPrompt.js +5 -282
- package/dist/improveFlow.d.ts +34 -4
- package/dist/improveFlow.js +44 -16
- package/dist/index.d.ts +11 -0
- package/dist/index.js +972 -83
- package/dist/projectAccountabilityState.d.ts +6 -7
- package/dist/projectAccountabilityState.js +6 -15
- package/dist/signup.d.ts +272 -0
- package/dist/signup.js +681 -0
- package/dist/statuslineRuntime.js +3 -1
- package/dist/telemetry.d.ts +149 -0
- package/dist/telemetry.js +390 -0
- package/package.json +4 -3
package/dist/guidedPrompt.d.ts
CHANGED
|
@@ -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;
|
package/dist/guidedPrompt.js
CHANGED
|
@@ -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 {
|
|
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"
|
package/dist/improveFlow.d.ts
CHANGED
|
@@ -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?:
|
|
125
|
-
rollback?:
|
|
126
|
-
canary?:
|
|
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";
|
package/dist/improveFlow.js
CHANGED
|
@@ -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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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.
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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?
|
|
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
|
-
|
|
356
|
-
|
|
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?
|
|
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:
|
|
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
|
/**
|