ai-spend-agent 0.9.0 → 0.9.1
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 +39 -13
- package/dist/index.js +586 -39
- package/dist/projectAccountabilityState.d.ts +6 -7
- package/dist/projectAccountabilityState.js +6 -15
- package/package.json +3 -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")
|
|
@@ -310,8 +319,9 @@ export async function runPlanSitting(io, options) {
|
|
|
310
319
|
kind: "choice",
|
|
311
320
|
step: 1,
|
|
312
321
|
totalSteps,
|
|
313
|
-
question: "Resume where you left off?
|
|
322
|
+
question: "Resume where you left off? Enter = y · n starts the plan over (saved answers are discarded).",
|
|
314
323
|
context: { choiceTokens: ["y", "n"] },
|
|
324
|
+
enterDefault: "y",
|
|
315
325
|
sittingHint: planSittingHint
|
|
316
326
|
});
|
|
317
327
|
if (resume.outcome === "cancelled" || resume.outcome === "back") {
|
|
@@ -339,7 +349,7 @@ export async function runPlanSitting(io, options) {
|
|
|
339
349
|
const prose = planProse[stepIndex];
|
|
340
350
|
const existing = answers[prose.key];
|
|
341
351
|
const suggested = options.suggestedAnswers?.[prose.key];
|
|
342
|
-
const prefill = existing ?? suggested;
|
|
352
|
+
const prefill = existing ?? suggested?.value;
|
|
343
353
|
const outcome = await ask(io, header, {
|
|
344
354
|
kind: "prose",
|
|
345
355
|
step: stepIndex + 1,
|
|
@@ -352,8 +362,15 @@ export async function runPlanSitting(io, options) {
|
|
|
352
362
|
navigationHint: "Type back to return, or cancel to stop safely.",
|
|
353
363
|
sittingHint: planSittingHint,
|
|
354
364
|
...(prefill !== undefined ? { emptyKeepsValue: prefill } : {}),
|
|
355
|
-
|
|
356
|
-
|
|
365
|
+
// A saved/typed answer outranks any suggestion and shows the
|
|
366
|
+
// default "Current answer" revisit label; a suggestion's label is
|
|
367
|
+
// computed from ITS OWN provenance, per field (B1, m9).
|
|
368
|
+
...(prefill !== undefined && existing === undefined && suggested !== undefined
|
|
369
|
+
? {
|
|
370
|
+
emptyKeepsLabel: suggested.provenance === "agent"
|
|
371
|
+
? "Drafted with your agent"
|
|
372
|
+
: "Suggested"
|
|
373
|
+
}
|
|
357
374
|
: {})
|
|
358
375
|
});
|
|
359
376
|
if (outcome.outcome === "cancelled")
|
|
@@ -393,8 +410,9 @@ export async function runPlanSitting(io, options) {
|
|
|
393
410
|
kind: "choice",
|
|
394
411
|
step: 4,
|
|
395
412
|
totalSteps,
|
|
396
|
-
question: "Approve as this identity?
|
|
413
|
+
question: "Approve as this identity? Enter = y · n stops here so you can run identify again first.",
|
|
397
414
|
context: { choiceTokens: ["y", "n"] },
|
|
415
|
+
enterDefault: "y",
|
|
398
416
|
sittingHint: planSittingHint
|
|
399
417
|
});
|
|
400
418
|
if (confirm.outcome === "cancelled")
|
|
@@ -503,7 +521,13 @@ export async function runRecordSitting(io, options) {
|
|
|
503
521
|
example: "2026-08-17T14:03:00Z",
|
|
504
522
|
navigationHint: "Type back if the change has not been applied yet, or cancel to stop safely.",
|
|
505
523
|
context: { example: "2026-08-17T14:03:00Z", approvedAtIso: options.approvedAtIso },
|
|
506
|
-
sittingHint: recordSittingHint
|
|
524
|
+
sittingHint: recordSittingHint,
|
|
525
|
+
...(options.suggested?.appliedAtIso !== undefined
|
|
526
|
+
? {
|
|
527
|
+
emptyKeepsValue: options.suggested.appliedAtIso,
|
|
528
|
+
emptyKeepsLabel: "Drafted with your agent"
|
|
529
|
+
}
|
|
530
|
+
: {})
|
|
507
531
|
});
|
|
508
532
|
if (time.outcome === "cancelled")
|
|
509
533
|
return { action: "cancelled" };
|
|
@@ -515,7 +539,9 @@ export async function runRecordSitting(io, options) {
|
|
|
515
539
|
kind: "choice",
|
|
516
540
|
step: 2,
|
|
517
541
|
totalSteps: 2,
|
|
518
|
-
question:
|
|
542
|
+
question: options.agentCanaryReport !== undefined
|
|
543
|
+
? `Your agent reports: canary ${options.agentCanaryReport} — not yet recorded; your\nanswer below is what counts.\nDid the approved canary pass?`
|
|
544
|
+
: "Did the approved canary pass?",
|
|
519
545
|
guidance: "Answer p (passed) · f (failed) · n (not run yet)",
|
|
520
546
|
context: { choiceTokens: ["p", "f", "n"] },
|
|
521
547
|
sittingHint: recordSittingHint
|