@agent-finops/core 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/README.md +2 -2
- package/dist/agentDraftToken.d.ts +80 -0
- package/dist/agentDraftToken.js +188 -0
- package/dist/agentLoopContract.d.ts +27 -0
- package/dist/agentLoopContract.js +36 -0
- package/dist/guidedAnswer.d.ts +51 -0
- package/dist/guidedAnswer.js +352 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/providerConnectors.d.ts +183 -0
- package/dist/providerConnectors.js +677 -12
- package/dist/receiptShare.d.ts +185 -0
- package/dist/receiptShare.js +118 -0
- package/dist/runtimeCommands.d.ts +15 -0
- package/dist/runtimeCommands.js +23 -0
- package/dist/schema.d.ts +2 -0
- package/dist/schema.js +9 -1
- package/dist/sourceRegistry.js +10 -3
- package/package.json +2 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared guided-answer classifier for the aibill improve/identify flows.
|
|
3
|
+
*
|
|
4
|
+
* One function, three consumers: the CLI terminal prompts (typed input and
|
|
5
|
+
* Enter-kept prefills), the CLI agent-draft screening lane, and the MCP
|
|
6
|
+
* `draft_improve_command` preview. Keeping a single pure module in core is
|
|
7
|
+
* what makes the "same classifier at composition and at Enter-accept"
|
|
8
|
+
* invariant true by construction rather than by parity discipline.
|
|
9
|
+
*
|
|
10
|
+
* Design: P0B_FLOW_DESIGN.md §2/§3b/§3c and AGENT_NATIVE_LOOP_DESIGN.md §2f
|
|
11
|
+
* (moved here from packages/cli/src/guidedPrompt.ts, verbatim; the path and
|
|
12
|
+
* credential predicates moved with it from projectAccountabilityState.ts so
|
|
13
|
+
* the floor relationship can never drift).
|
|
14
|
+
*/
|
|
15
|
+
/* ------------------------------------------------------------------ */
|
|
16
|
+
/* Floor predicates (accountability backstop) */
|
|
17
|
+
/* ------------------------------------------------------------------ */
|
|
18
|
+
/**
|
|
19
|
+
* Exported as the classification FLOOR for the guided-prompt engine: the
|
|
20
|
+
* per-prompt classifier must reject at least everything these predicates
|
|
21
|
+
* reject, so `parseDisplayLabel` can never abort on an answer the prompt
|
|
22
|
+
* already accepted (the Aug 17 incident class).
|
|
23
|
+
*/
|
|
24
|
+
export function isPathLike(value) {
|
|
25
|
+
return /^(?:~?[\\/]|\.{1,2}(?:[\\/]|$)|[A-Za-z]:[\\/]|\\\\)/u.test(value) ||
|
|
26
|
+
/(?:^|[\\/])\.\.(?:[\\/]|$)/u.test(value) || value.includes("\\");
|
|
27
|
+
}
|
|
28
|
+
export function isCredentialLike(value) {
|
|
29
|
+
return /(?:sk-(?:ant-|proj-)?|sk_|gh[pousr]_|github_pat_|npm_|AIza|xox[baprs]-|glpat-|AKIA)[A-Za-z0-9_-]{8,}/i
|
|
30
|
+
.test(value) ||
|
|
31
|
+
/(?:api[_ -]?key|access[_ -]?token|auth[_ -]?token|secret|password)\s*[:=]\s*\S+/i
|
|
32
|
+
.test(value);
|
|
33
|
+
}
|
|
34
|
+
const unambiguousBinaries = new Set([
|
|
35
|
+
"node", "npm", "npx", "pnpm", "yarn", "bun", "bunx", "deno", "tsx", "ts-node",
|
|
36
|
+
"git", "gh", "curl", "wget", "bash", "sh", "zsh", "fish", "pwsh", "powershell",
|
|
37
|
+
"python", "python3", "pip", "pip3", "pipx", "uv", "uvx", "poetry", "pytest",
|
|
38
|
+
"vitest", "jest", "brew", "apt", "docker", "kubectl", "cargo", "rustc",
|
|
39
|
+
"dotnet", "mvn", "gradle", "terraform", "ssh", "scp", "rsync", "sudo",
|
|
40
|
+
"chmod", "chown", "xargs", "grep", "rg", "sed", "awk", "tar", "zip", "unzip",
|
|
41
|
+
"aibill", "ai-spend-agent", "vim", "nano", "code", "dir", "del", "robocopy"
|
|
42
|
+
]);
|
|
43
|
+
/** Common English verbs that are also binaries: reject only with corroboration. */
|
|
44
|
+
const ambiguousVerbs = new Set([
|
|
45
|
+
"make", "open", "find", "go", "date", "kill", "top", "head", "tail", "touch",
|
|
46
|
+
"export", "source", "alias", "echo", "type", "cd", "ls", "cat", "cp", "mv",
|
|
47
|
+
"rm", "printf", "less", "ps", "copy", "move"
|
|
48
|
+
]);
|
|
49
|
+
const reservedVocabulary = new Set([
|
|
50
|
+
"held", "passed", "failed", "missing", "regressed", "approve", "approved",
|
|
51
|
+
"yes", "no", "y", "n", "p", "f", "h", "r", "m", "now", "skip", "keep"
|
|
52
|
+
]);
|
|
53
|
+
/**
|
|
54
|
+
* The accountability backstop predicate (`isCredentialLike`) is the FLOOR:
|
|
55
|
+
* this classifier must reject at least everything `parseDisplayLabel` would
|
|
56
|
+
* reject, or a validated answer could still abort later (B2 QA blocker B1).
|
|
57
|
+
* These extra patterns sit on top of the floor.
|
|
58
|
+
*/
|
|
59
|
+
const extraCredentialPattern = /(authorization\s*:\s*(?:bearer|basic)\s+\S+|-----BEGIN [A-Z ]*PRIVATE KEY-----|(?:api[ _-]?key|token|password|secret)\s*[:=]\s*\S+)/i;
|
|
60
|
+
export function looksLikeCredential(answer) {
|
|
61
|
+
return isCredentialLike(answer) || extraCredentialPattern.test(answer);
|
|
62
|
+
}
|
|
63
|
+
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;
|
|
64
|
+
/** Strict full date-time with zone, mirroring the CLI's validIsoString. */
|
|
65
|
+
const strictIsoPattern = /^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:[Zz]|[+-]\d{2}:?\d{2})$/;
|
|
66
|
+
const futureToleranceMs = 2 * 60 * 1000;
|
|
67
|
+
/**
|
|
68
|
+
* Exact non-answer phrases (normalized: lowercase, punctuation stripped).
|
|
69
|
+
* A plan sentence must be actionable later; "i am not sure" passes the
|
|
70
|
+
* two-word substance bar but records a safety net that cannot be executed.
|
|
71
|
+
* Exact match only — a real sentence that merely CONTAINS one still passes.
|
|
72
|
+
*/
|
|
73
|
+
const nonAnswerPhrases = new Set([
|
|
74
|
+
"i am not sure", "im not sure", "not sure", "unsure", "i am unsure",
|
|
75
|
+
"idk", "i dont know", "dont know", "no idea", "dunno",
|
|
76
|
+
"no se", "no sé", "ni idea",
|
|
77
|
+
"whatever", "anything", "nothing", "none", "na", "tbd",
|
|
78
|
+
"help", "test", "testing", "asdf"
|
|
79
|
+
]);
|
|
80
|
+
function isNonAnswer(lowered) {
|
|
81
|
+
const normalized = lowered.replace(/[.,!?'’]/g, "").replace(/\s+/g, " ").trim();
|
|
82
|
+
return nonAnswerPhrases.has(normalized);
|
|
83
|
+
}
|
|
84
|
+
const choiceWordAliases = {
|
|
85
|
+
yes: "y",
|
|
86
|
+
no: "n",
|
|
87
|
+
passed: "p",
|
|
88
|
+
failed: "f",
|
|
89
|
+
held: "h",
|
|
90
|
+
regressed: "r",
|
|
91
|
+
missing: "m"
|
|
92
|
+
};
|
|
93
|
+
function stripQuotes(token) {
|
|
94
|
+
return token.replace(/^["'`]+/, "").replace(/["'`]+$/, "");
|
|
95
|
+
}
|
|
96
|
+
function stripEnvPrefixes(tokens) {
|
|
97
|
+
let index = 0;
|
|
98
|
+
while (index < tokens.length &&
|
|
99
|
+
(/^[A-Za-z_][A-Za-z0-9_]*=\S*$/.test(tokens[index]) ||
|
|
100
|
+
tokens[index] === "sudo" || tokens[index] === "env")) {
|
|
101
|
+
index += 1;
|
|
102
|
+
}
|
|
103
|
+
return tokens.slice(index);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* §2f rule 2 (AGENT_NATIVE_LOOP_DESIGN.md): a pipe straight into an
|
|
107
|
+
* interpreter, spaced or not — `|sh`, `curl …|bash`, `|python3 -c …`.
|
|
108
|
+
*/
|
|
109
|
+
const pipeToInterpreterPattern = /\|\s*(?:sh|bash|zsh|fish|dash|node|python3?|perl|ruby|pwsh)\b/i;
|
|
110
|
+
/** §2f rule 4: free-standing multi-letter short flag (` -rf`), corroboration only. */
|
|
111
|
+
const multiLetterShortFlagPattern = /(?:^|\s)-[A-Za-z]{2,}(?=\s|$)/;
|
|
112
|
+
function looksLikeShellCommand(answer) {
|
|
113
|
+
if (/^[$%#>] ?/.test(answer))
|
|
114
|
+
return true;
|
|
115
|
+
if (/&&|\|\||`|\$\(|>>|<<|2>&1| \| | > | < /.test(answer))
|
|
116
|
+
return true;
|
|
117
|
+
if (pipeToInterpreterPattern.test(answer))
|
|
118
|
+
return true;
|
|
119
|
+
if (/(?:^|\s)--[A-Za-z][\w-]*/.test(answer))
|
|
120
|
+
return true;
|
|
121
|
+
if (/(?:^|\s)-[A-Za-z](?=\s|$)/.test(answer))
|
|
122
|
+
return true;
|
|
123
|
+
// PowerShell Verb-Noun cmdlet shape (Get-ChildItem, Remove-Item …).
|
|
124
|
+
if (/^[A-Z][a-z]+-[A-Z][A-Za-z]+(?:\s|$)/.test(answer))
|
|
125
|
+
return true;
|
|
126
|
+
// §2f rule 1: strip any leading run of whitespace/quote/chain sigils
|
|
127
|
+
// before first-token analysis (covers `"; rm …`, `'; …`, `| sh`, `& …`
|
|
128
|
+
// fragments). If the strip removed a `;`, `|`, or `&`, the text STARTS
|
|
129
|
+
// like a command chain — that is a reject on its own. A plain leading
|
|
130
|
+
// quotation mark followed by words strips harmlessly and never rejects.
|
|
131
|
+
const leadingSigils = /^[\s"'`;|&]+/.exec(answer)?.[0] ?? "";
|
|
132
|
+
if (/[;|&]/.test(leadingSigils))
|
|
133
|
+
return true;
|
|
134
|
+
const body = answer.slice(leadingSigils.length);
|
|
135
|
+
const tokens = stripEnvPrefixes(body.split(/\s+/).filter(Boolean));
|
|
136
|
+
// Second signals, shared by first-token ambiguity and §2f rule 3: a
|
|
137
|
+
// path-like token, a file extension, or a multi-letter short flag
|
|
138
|
+
// (`rm -rf …` — rule 4; needs whitespace before the `-`, so hyphenated
|
|
139
|
+
// words like `well-known` or `re-use` can never match).
|
|
140
|
+
const hasPathToken = tokens.some((token) => /^(?:\/|\.\/|\.\.\/|~\/)/.test(token) || token.includes("/"));
|
|
141
|
+
const hasExtension = pathExtensionPattern.test(body);
|
|
142
|
+
const hasShortFlag = multiLetterShortFlagPattern.test(body);
|
|
143
|
+
const first = stripQuotes(tokens[0] ?? "").toLowerCase();
|
|
144
|
+
if (unambiguousBinaries.has(first))
|
|
145
|
+
return true;
|
|
146
|
+
if (ambiguousVerbs.has(first)) {
|
|
147
|
+
// Ambiguous English verbs reject only with a second signal: a path-like
|
|
148
|
+
// token, a file extension, a corroborating flag, or a terse
|
|
149
|
+
// all-lowercase fragment that reads like a command line, not a sentence.
|
|
150
|
+
const terseLowercase = tokens.length <= 2 && body === body.toLowerCase();
|
|
151
|
+
if (hasPathToken || hasExtension || hasShortFlag || terseLowercase)
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
// §2f rule 3: `;` chained command starts. A `;` followed by an unambiguous
|
|
155
|
+
// binary rejects outright; followed by an ambiguous verb it rejects only
|
|
156
|
+
// with a second signal — English semicolons ("…workflow; keep the earlier
|
|
157
|
+
// settings", "…change; go back to the prior flow") survive.
|
|
158
|
+
for (const chained of body.matchAll(/;\s*([^\s;|&]+)/g)) {
|
|
159
|
+
const chainedFirst = stripQuotes(chained[1]).toLowerCase();
|
|
160
|
+
if (unambiguousBinaries.has(chainedFirst))
|
|
161
|
+
return true;
|
|
162
|
+
if (ambiguousVerbs.has(chainedFirst) &&
|
|
163
|
+
(hasPathToken || hasExtension || hasShortFlag)) {
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
function looksLikePath(answer, kind) {
|
|
170
|
+
// The accountability backstop predicate is the floor: it covers leading
|
|
171
|
+
// slashes and dot-segments, drive letters, backslashes, and bare ".".
|
|
172
|
+
if (isPathLike(answer))
|
|
173
|
+
return true;
|
|
174
|
+
if (kind === "prose") {
|
|
175
|
+
if (pathExtensionPattern.test(answer))
|
|
176
|
+
return true;
|
|
177
|
+
const slashTokens = answer.split(/\s+/).filter((token) => token.includes("/"));
|
|
178
|
+
return slashTokens.length >= 2;
|
|
179
|
+
}
|
|
180
|
+
// Name-like fields: a slashed token is a path; a bare file-extension token
|
|
181
|
+
// counts only when it IS the whole answer — "Node.js Guild" is a team.
|
|
182
|
+
if (/\S+\/\S+/.test(answer))
|
|
183
|
+
return true;
|
|
184
|
+
return pathExtensionPattern.test(answer) && !/\s/.test(answer.trim());
|
|
185
|
+
}
|
|
186
|
+
function byteLength(value) {
|
|
187
|
+
return Buffer.byteLength(value.normalize("NFC"), "utf8");
|
|
188
|
+
}
|
|
189
|
+
function hasControlOrFormatCharacters(value) {
|
|
190
|
+
// C0/C1 controls including embedded newlines and DEL (tab and trailing
|
|
191
|
+
// CR are normalized earlier), plus the invisible/directional format
|
|
192
|
+
// characters that can spoof what the review screen appears to say.
|
|
193
|
+
// ZWNJ/ZWJ (U+200C/U+200D) are deliberately ALLOWED: they are standard
|
|
194
|
+
// orthography in Persian and other scripts and in emoji families.
|
|
195
|
+
if (/[\u0000-\u0008\u000A-\u001F\u007F-\u009F\u2028\u2029]/.test(value))
|
|
196
|
+
return true;
|
|
197
|
+
return /[\u00AD\u061C\u200B\u200E\u200F\u202A-\u202E\u2060-\u2064\u2066-\u2069\uFEFF]/.test(value);
|
|
198
|
+
}
|
|
199
|
+
function hasUnpairedSurrogate(value) {
|
|
200
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
201
|
+
const code = value.charCodeAt(index);
|
|
202
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
203
|
+
const next = value.charCodeAt(index + 1);
|
|
204
|
+
if (!(next >= 0xdc00 && next <= 0xdfff))
|
|
205
|
+
return true;
|
|
206
|
+
index += 1;
|
|
207
|
+
}
|
|
208
|
+
else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
export function classifyGuidedAnswer(kind, rawInput, context = {}) {
|
|
215
|
+
const normalizedTabs = rawInput.replace(/\t/g, " ").replace(/\r$/, "");
|
|
216
|
+
const answer = normalizedTabs.trim();
|
|
217
|
+
const lowered = answer.toLowerCase();
|
|
218
|
+
const reject = (code, message) => ({
|
|
219
|
+
outcome: "reject", code, message
|
|
220
|
+
});
|
|
221
|
+
const exampleSuffix = context.example ? ` e.g. ${context.example}` : "";
|
|
222
|
+
// Navigation pre-pass (every field).
|
|
223
|
+
if (lowered === "back" || lowered === "b")
|
|
224
|
+
return { outcome: "navigate", action: "back" };
|
|
225
|
+
if (lowered === "cancel" || lowered === "q" || lowered === "quit" || lowered === "exit") {
|
|
226
|
+
return { outcome: "navigate", action: "cancel" };
|
|
227
|
+
}
|
|
228
|
+
if (kind === "optional" && (answer === "" || lowered === "skip")) {
|
|
229
|
+
return { outcome: "skip" };
|
|
230
|
+
}
|
|
231
|
+
if (hasControlOrFormatCharacters(answer) || hasUnpairedSurrogate(answer)) {
|
|
232
|
+
return reject("control", "That answer carried hidden control characters (usually a stray paste). Type it as plain text.");
|
|
233
|
+
}
|
|
234
|
+
if (answer === "") {
|
|
235
|
+
if (kind === "approve") {
|
|
236
|
+
// An empty answer at the approval screen is a decline, not an error.
|
|
237
|
+
return { outcome: "navigate", action: "cancel" };
|
|
238
|
+
}
|
|
239
|
+
return reject("empty", "This step needs an answer in words. Type it, or type back or cancel.");
|
|
240
|
+
}
|
|
241
|
+
if (looksLikeCredential(answer)) {
|
|
242
|
+
// Never echo, never store: callers must discard this input entirely.
|
|
243
|
+
return reject("credential", "That looks like it contains a credential. aibill never stores credentials — that answer was discarded. Type it again without the secret.");
|
|
244
|
+
}
|
|
245
|
+
switch (kind) {
|
|
246
|
+
case "approve": {
|
|
247
|
+
if (answer === "APPROVE")
|
|
248
|
+
return { outcome: "accept", value: answer };
|
|
249
|
+
// Clear approval intent gets the nudge, never a silent decline:
|
|
250
|
+
// APPROVED, aprove, i approve, full-width IME APPROVE, yes/y.
|
|
251
|
+
const folded = answer.normalize("NFKC").toUpperCase();
|
|
252
|
+
if (folded.includes("APPROV") || folded.includes("APROVE") ||
|
|
253
|
+
lowered === "yes" || lowered === "y") {
|
|
254
|
+
return reject("approve_case", "Approval must be typed APPROVE, in capitals, so it cannot happen by accident.");
|
|
255
|
+
}
|
|
256
|
+
// Any other answer is a decline — an answer, not an error.
|
|
257
|
+
return { outcome: "navigate", action: "cancel" };
|
|
258
|
+
}
|
|
259
|
+
case "choice": {
|
|
260
|
+
const tokens = context.choiceTokens ?? [];
|
|
261
|
+
if (tokens.includes(lowered))
|
|
262
|
+
return { outcome: "accept", value: lowered };
|
|
263
|
+
// Exact full words map to their canonical letter, but only within
|
|
264
|
+
// their own question family: "no" at a p/f/n question must reprompt,
|
|
265
|
+
// never silently become "n". Never prefix-match either — "probably
|
|
266
|
+
// failed" or "not sure" reprompts, not guesses.
|
|
267
|
+
const alias = choiceWordAliases[lowered];
|
|
268
|
+
if (alias !== undefined && tokens.includes(alias)) {
|
|
269
|
+
const applies = lowered === "yes" || lowered === "no" ? tokens.includes("y") :
|
|
270
|
+
lowered === "passed" || lowered === "failed" ? tokens.includes("p") :
|
|
271
|
+
tokens.includes("h");
|
|
272
|
+
if (applies)
|
|
273
|
+
return { outcome: "accept", value: alias };
|
|
274
|
+
}
|
|
275
|
+
if (tokens.includes("p")) {
|
|
276
|
+
return reject("choice", "Answer p (passed), f (failed), or n (not run yet).");
|
|
277
|
+
}
|
|
278
|
+
if (tokens.includes("h")) {
|
|
279
|
+
return reject("choice", "Answer h (held), r (regressed), or m (cannot say).");
|
|
280
|
+
}
|
|
281
|
+
return reject("choice", `Answer one of: ${tokens.join(", ")}.`);
|
|
282
|
+
}
|
|
283
|
+
case "time": {
|
|
284
|
+
if (lowered === "now") {
|
|
285
|
+
return { outcome: "accept", value: new Date(context.nowMs ?? Date.now()).toISOString() };
|
|
286
|
+
}
|
|
287
|
+
if (!strictIsoPattern.test(answer)) {
|
|
288
|
+
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.");
|
|
289
|
+
}
|
|
290
|
+
const parsed = Date.parse(answer);
|
|
291
|
+
if (!Number.isFinite(parsed)) {
|
|
292
|
+
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.");
|
|
293
|
+
}
|
|
294
|
+
const approvedAt = context.approvedAtIso ? Date.parse(context.approvedAtIso) : undefined;
|
|
295
|
+
if (context.approvedAtIso !== undefined && !Number.isFinite(approvedAt ?? Number.NaN)) {
|
|
296
|
+
// Fail closed: an unreadable approval time must never silently
|
|
297
|
+
// disable the after-approval check.
|
|
298
|
+
return reject("time_invalid", "The approval record's own time is unreadable, so this time cannot be checked. Type cancel and rerun this command.");
|
|
299
|
+
}
|
|
300
|
+
if (approvedAt !== undefined && parsed <= approvedAt) {
|
|
301
|
+
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.`);
|
|
302
|
+
}
|
|
303
|
+
if (parsed > (context.nowMs ?? Date.now()) + futureToleranceMs) {
|
|
304
|
+
return reject("time_future", "That time is in the future. Paste the actual reported time.");
|
|
305
|
+
}
|
|
306
|
+
return { outcome: "accept", value: new Date(parsed).toISOString() };
|
|
307
|
+
}
|
|
308
|
+
default:
|
|
309
|
+
break;
|
|
310
|
+
}
|
|
311
|
+
if (looksLikeShellCommand(answer)) {
|
|
312
|
+
const message = kind === "prose"
|
|
313
|
+
? "That looks like a shell command, not an answer. Nothing runs here — describe it in words."
|
|
314
|
+
: "That looks like a shell command, not a name. Answer with the name in words.";
|
|
315
|
+
return reject("shell", message);
|
|
316
|
+
}
|
|
317
|
+
if (kind === "prose" && lowered === "keep" && (context.priorShellRejections ?? 0) >= 2) {
|
|
318
|
+
// After repeated identical shell rejections the user may type `keep` to
|
|
319
|
+
// record their exact text as words. The caller substitutes the last
|
|
320
|
+
// rejected line; this sentinel value never reaches storage.
|
|
321
|
+
return { outcome: "accept", value: "keep" };
|
|
322
|
+
}
|
|
323
|
+
if (looksLikePath(answer, kind)) {
|
|
324
|
+
return reject("path", "That looks like a file path. Describe it in words instead — the answer must read as a sentence, not a location.");
|
|
325
|
+
}
|
|
326
|
+
if (kind === "name" || kind === "team" || kind === "role" || kind === "optional") {
|
|
327
|
+
if (reservedVocabulary.has(lowered)) {
|
|
328
|
+
const message = kind === "role"
|
|
329
|
+
? `"${answer}" is aibill's own vocabulary, not a role. Answer with your real job role, in words.${exampleSuffix}`
|
|
330
|
+
: `That is aibill vocabulary, not a name. Answer in your own words.${exampleSuffix}`;
|
|
331
|
+
return reject("reserved", message);
|
|
332
|
+
}
|
|
333
|
+
if (strictIsoPattern.test(answer)) {
|
|
334
|
+
return reject("timestamp_shaped", `That is a time, not a name.${exampleSuffix}`);
|
|
335
|
+
}
|
|
336
|
+
if (byteLength(answer) > 192) {
|
|
337
|
+
return reject("length", "That name is longer than aibill can store (192 bytes). Use a shorter form.");
|
|
338
|
+
}
|
|
339
|
+
return { outcome: "accept", value: answer.normalize("NFC") };
|
|
340
|
+
}
|
|
341
|
+
// prose
|
|
342
|
+
if (answer.length > 1000) {
|
|
343
|
+
return reject("length", "Keep it to one or two short sentences (under 1,000 characters).");
|
|
344
|
+
}
|
|
345
|
+
if (answer.split(/\s+/).filter(Boolean).length < 2 || isNonAnswer(lowered)) {
|
|
346
|
+
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}`);
|
|
347
|
+
}
|
|
348
|
+
// NFC like the name fields: the rollback sentence is later re-typed and
|
|
349
|
+
// compared by hash, so composition differences must not fail the match.
|
|
350
|
+
return { outcome: "accept", value: answer.normalize("NFC") };
|
|
351
|
+
}
|
|
352
|
+
//# sourceMappingURL=guidedAnswer.js.map
|
package/dist/index.d.ts
CHANGED
|
@@ -23,10 +23,14 @@ export * from "./planDetection.js";
|
|
|
23
23
|
export * from "./planMath.js";
|
|
24
24
|
export * from "./projectEconomics.js";
|
|
25
25
|
export * from "./resultCard.js";
|
|
26
|
+
export * from "./receiptShare.js";
|
|
26
27
|
export * from "./projectEconomicsBuilder.js";
|
|
27
28
|
export * from "./qualitativeIndexCache.js";
|
|
28
29
|
export * from "./projectIndexStore.js";
|
|
29
30
|
export * from "./runtimeCommands.js";
|
|
31
|
+
export * from "./guidedAnswer.js";
|
|
32
|
+
export * from "./agentDraftToken.js";
|
|
33
|
+
export * from "./agentLoopContract.js";
|
|
30
34
|
export * from "./sampleData.js";
|
|
31
35
|
export * from "./scanGuard.js";
|
|
32
36
|
export * from "./schema.js";
|
package/dist/index.js
CHANGED
|
@@ -21,10 +21,14 @@ export * from "./planDetection.js";
|
|
|
21
21
|
export * from "./planMath.js";
|
|
22
22
|
export * from "./projectEconomics.js";
|
|
23
23
|
export * from "./resultCard.js";
|
|
24
|
+
export * from "./receiptShare.js";
|
|
24
25
|
export * from "./projectEconomicsBuilder.js";
|
|
25
26
|
export * from "./qualitativeIndexCache.js";
|
|
26
27
|
export * from "./projectIndexStore.js";
|
|
27
28
|
export * from "./runtimeCommands.js";
|
|
29
|
+
export * from "./guidedAnswer.js";
|
|
30
|
+
export * from "./agentDraftToken.js";
|
|
31
|
+
export * from "./agentLoopContract.js";
|
|
28
32
|
export * from "./sampleData.js";
|
|
29
33
|
export * from "./scanGuard.js";
|
|
30
34
|
export * from "./schema.js";
|
|
@@ -89,6 +89,13 @@ export type ProviderConnectorInput = {
|
|
|
89
89
|
* shipped CLI can run a reconciliation without new flags.
|
|
90
90
|
*/
|
|
91
91
|
reconciliation?: CursorReconciliationExpectation;
|
|
92
|
+
/**
|
|
93
|
+
* Operator-declared reconciliation anchor for a GitHub Copilot sync. When
|
|
94
|
+
* absent, the Copilot connector also accepts the
|
|
95
|
+
* AI_SPEND_COPILOT_RECONCILE_* environment variables so the shipped CLI can
|
|
96
|
+
* run a reconciliation without new flags.
|
|
97
|
+
*/
|
|
98
|
+
copilotReconciliation?: GitHubCopilotReconciliationExpectation;
|
|
92
99
|
};
|
|
93
100
|
/**
|
|
94
101
|
* A human-read billing anchor for one Cursor reconciliation run: the
|
|
@@ -141,6 +148,69 @@ export declare function parseCursorReconciliationEnv(env?: Record<string, string
|
|
|
141
148
|
expectation?: CursorReconciliationExpectation;
|
|
142
149
|
invalidReason?: string;
|
|
143
150
|
};
|
|
151
|
+
/**
|
|
152
|
+
* A human-read billing anchor for one GitHub Copilot reconciliation run: the
|
|
153
|
+
* AI-credit NET total the operator read off the organization's Billing &
|
|
154
|
+
* Licensing usage page for one calendar billing month (GitHub moved every
|
|
155
|
+
* Copilot Business/Enterprise account to usage-based AI-credit billing on
|
|
156
|
+
* 2026-06-01; 1 AI credit bills as $0.01). The connector compares its summed
|
|
157
|
+
* netAmount from the AI-credit usage report against this figure; only an
|
|
158
|
+
* in-run match within tolerance can produce verified financial evidence.
|
|
159
|
+
*/
|
|
160
|
+
export type GitHubCopilotReconciliationExpectation = {
|
|
161
|
+
/** Billing-page AI-credit net total for the declared month, in USD. */
|
|
162
|
+
expectedNetUsd: number;
|
|
163
|
+
/** The billing month shown next to that figure (YYYY-MM, UTC calendar month). */
|
|
164
|
+
expectedBillingMonth: string;
|
|
165
|
+
/**
|
|
166
|
+
* Optional absolute comparison tolerance in USD. Defaults to $0.01 (the
|
|
167
|
+
* billing page rounds to cents). Clamped to at most 1% of the expected total
|
|
168
|
+
* so a huge tolerance can never rubber-stamp a mismatch.
|
|
169
|
+
*/
|
|
170
|
+
toleranceUsd?: number;
|
|
171
|
+
/**
|
|
172
|
+
* Optional account binding: a bare slug or `org:<slug>` / `enterprise:<slug>`.
|
|
173
|
+
* When present, the anchor applies ONLY to a sync of that account; a sync
|
|
174
|
+
* of any other org/enterprise fails the reconciliation closed (QA C3 —
|
|
175
|
+
* leftover shell env must never verify a different account by coincidence).
|
|
176
|
+
*/
|
|
177
|
+
account?: string;
|
|
178
|
+
};
|
|
179
|
+
export type GitHubCopilotReconciliationOutcome = {
|
|
180
|
+
status: "verified" | "mismatch" | "not_provable";
|
|
181
|
+
/** Product-authored, terminal-safe explanation of the outcome. */
|
|
182
|
+
note: string;
|
|
183
|
+
connectorTotalUsd?: number;
|
|
184
|
+
expectedNetUsd?: number;
|
|
185
|
+
differenceUsd?: number;
|
|
186
|
+
toleranceUsd?: number;
|
|
187
|
+
/** The billing month the reconciliation was declared for (YYYY-MM). */
|
|
188
|
+
billingMonth?: string;
|
|
189
|
+
};
|
|
190
|
+
/** Environment variables the GitHub Copilot connector reads for a reconciliation run. */
|
|
191
|
+
export declare const gitHubCopilotReconciliationEnvVars: {
|
|
192
|
+
readonly expectedUsd: "AI_SPEND_COPILOT_RECONCILE_EXPECTED_USD";
|
|
193
|
+
readonly billingMonth: "AI_SPEND_COPILOT_RECONCILE_MONTH";
|
|
194
|
+
readonly toleranceUsd: "AI_SPEND_COPILOT_RECONCILE_TOLERANCE_USD";
|
|
195
|
+
/**
|
|
196
|
+
* Optional binding of the anchor to ONE account (post-hoc QA C3): a bare
|
|
197
|
+
* slug or `org:<slug>` / `enterprise:<slug>`. When set, a sync of any
|
|
198
|
+
* other account fails the reconciliation closed instead of stamping a
|
|
199
|
+
* coincidence-equal total verified with leftover shell env.
|
|
200
|
+
*/
|
|
201
|
+
readonly account: "AI_SPEND_COPILOT_RECONCILE_ACCOUNT";
|
|
202
|
+
};
|
|
203
|
+
/**
|
|
204
|
+
* Read an operator-declared GitHub Copilot reconciliation anchor from the
|
|
205
|
+
* local environment. Absent variables mean "no reconciliation requested";
|
|
206
|
+
* present but invalid variables fail closed with a reason (records stay
|
|
207
|
+
* estimated) instead of throwing, so a typo can never abort or silently
|
|
208
|
+
* verify a sync. Raw variable values are never echoed into the reason.
|
|
209
|
+
*/
|
|
210
|
+
export declare function parseGitHubCopilotReconciliationEnv(env?: Record<string, string | undefined>): {
|
|
211
|
+
expectation?: GitHubCopilotReconciliationExpectation;
|
|
212
|
+
invalidReason?: string;
|
|
213
|
+
};
|
|
144
214
|
export type ProviderConnectorResult = {
|
|
145
215
|
provider: string;
|
|
146
216
|
source: ApprovedSource;
|
|
@@ -179,6 +249,17 @@ export declare function normalizeAnthropicClaudeCodeUsageResponse(response: unkn
|
|
|
179
249
|
export declare function normalizeGitHubCopilotSeatResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
180
250
|
export declare function normalizeAnthropicCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
181
251
|
export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
|
|
252
|
+
/**
|
|
253
|
+
* Normalize one GitHub AI-credit usage report (the 2026 usage-based billing
|
|
254
|
+
* model: GET /organizations/{org}/settings/billing/ai_credit/usage) into
|
|
255
|
+
* billed-dollar evidence records. Rows are NET provider-reported dollars, but
|
|
256
|
+
* this connector is fixture-verified, so — exactly like the Cursor connector —
|
|
257
|
+
* the records stay "estimated" until an in-run reconciliation proves the
|
|
258
|
+
* connector total against the operator-read billing page figure for the same
|
|
259
|
+
* billing month. Legacy premium-request units are deliberately not fetched or
|
|
260
|
+
* blended here (provider-contract exclusion).
|
|
261
|
+
*/
|
|
262
|
+
export declare function normalizeGitHubCopilotAiCreditUsageResponse(response: unknown, options: NormalizerOptions, reconciliation?: GitHubCopilotReconciliationOutcome): UsageRecord[];
|
|
182
263
|
export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions, reconciliation?: CursorReconciliationOutcome): UsageRecord[];
|
|
183
264
|
export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
|
|
184
265
|
export declare function summarizeProviderFinancials(records: UsageRecord[]): ProviderFinancialSummary;
|
|
@@ -189,6 +270,108 @@ export declare function providerFinancialCompleteness(records: UsageRecord[], co
|
|
|
189
270
|
* spend headlines; callers should retain the original records for attribution.
|
|
190
271
|
*/
|
|
191
272
|
export declare function selectProviderFinancialHeadlineRecords(records: UsageRecord[]): UsageRecord[];
|
|
273
|
+
/** Inputs that determine which provider account (org/team) one sync reads. */
|
|
274
|
+
export type ProviderAccountKeyInput = {
|
|
275
|
+
provider: string;
|
|
276
|
+
authReference: string;
|
|
277
|
+
org?: string;
|
|
278
|
+
enterprise?: string;
|
|
279
|
+
accountId?: string;
|
|
280
|
+
};
|
|
281
|
+
/**
|
|
282
|
+
* Stable identity for one provider account (an OpenAI/Anthropic organization,
|
|
283
|
+
* a Cursor team, a GitHub org/enterprise). Admin credentials are account-
|
|
284
|
+
* scoped and multi-account setups are common, so records from different
|
|
285
|
+
* accounts of one provider must coexist instead of replacing each other.
|
|
286
|
+
*
|
|
287
|
+
* The key prefers the explicit account flag the connector already requires
|
|
288
|
+
* (--org/--enterprise/--account-id, which can share one credential); it
|
|
289
|
+
* otherwise falls back to the user-chosen credential REFERENCE NAME
|
|
290
|
+
* (e.g. "env:OPENAI_ADMIN_KEY_ORG2") — stable, printable, and never derived
|
|
291
|
+
* from secret material. A provider-reported organization id would be
|
|
292
|
+
* preferable, but the cost APIs aibill calls do not reliably return one
|
|
293
|
+
* (the OpenAI costs request groups by project/line-item/api-key only), and a
|
|
294
|
+
* sometimes-present key would split one account into two slices.
|
|
295
|
+
*/
|
|
296
|
+
export declare function providerAccountKey(input: ProviderAccountKeyInput): string;
|
|
297
|
+
/** The deterministic record-id prefix for one account slice. */
|
|
298
|
+
export declare function providerAccountRecordIdPrefix(accountKey: string): string;
|
|
299
|
+
/**
|
|
300
|
+
* Stamp one sync's records with their account slice. The record id gains a
|
|
301
|
+
* deterministic account prefix (slug + raw-key digest) so identical usage
|
|
302
|
+
* buckets from two accounts of the same provider can never collide into one
|
|
303
|
+
* row id — even for slug-equivalent account spellings — and re-syncing the
|
|
304
|
+
* same account regenerates the same ids (idempotent replace).
|
|
305
|
+
*
|
|
306
|
+
* Migration note: slices tagged by the short-lived pre-digest format
|
|
307
|
+
* (slug-only prefix) are superseded on their next re-sync — same-account
|
|
308
|
+
* replacement keys on `source.account`, never on id shape — and any
|
|
309
|
+
* colliding pre-digest rows already persisted are excluded fail-closed by
|
|
310
|
+
* the id-conflict guard in {@link retainProviderRecordsForNewSync}.
|
|
311
|
+
*/
|
|
312
|
+
export declare function tagProviderAccountRecords(records: readonly UsageRecord[], accountKey: string): UsageRecord[];
|
|
313
|
+
/**
|
|
314
|
+
* Records from a prior trusted snapshot that must survive a new sync of
|
|
315
|
+
* `provider` + `accountKey`: every other provider's records, plus this
|
|
316
|
+
* provider's records that belong to a DIFFERENT named account slice.
|
|
317
|
+
* Re-syncing the same account replaces its own slice. Records with no account
|
|
318
|
+
* label (synced before multi-account support) are replaced too — fail-closed:
|
|
319
|
+
* they cannot be proven to come from a different account, and keeping them
|
|
320
|
+
* could double-count the same organization.
|
|
321
|
+
*
|
|
322
|
+
* Id-conflict guard: a retained record may never share an id with a newly
|
|
323
|
+
* synced record, nor with another retained record. Colliding ids describe
|
|
324
|
+
* the same underlying row (possible only in state written by the pre-digest
|
|
325
|
+
* prefix format, where slug-equivalent account spellings collided) — keeping
|
|
326
|
+
* both would double-count, so the copy that is not part of the fresh sync is
|
|
327
|
+
* dropped fail-closed.
|
|
328
|
+
*/
|
|
329
|
+
export declare function retainProviderRecordsForNewSync(priorRecords: readonly UsageRecord[], provider: string, accountKey: string, syncedRecords: readonly UsageRecord[]): UsageRecord[];
|
|
330
|
+
export type ProviderAccountSlice = {
|
|
331
|
+
/** Account key, or null for records synced before multi-account support. */
|
|
332
|
+
account: string | null;
|
|
333
|
+
recordCount: number;
|
|
334
|
+
/** Sum of this slice's verified provider-billed rows; null when none. */
|
|
335
|
+
billedUsd: number | null;
|
|
336
|
+
};
|
|
337
|
+
/** Group one provider's records into per-account slices for honest display. */
|
|
338
|
+
export declare function providerAccountSlices(records: readonly UsageRecord[], provider: string): ProviderAccountSlice[];
|
|
339
|
+
/**
|
|
340
|
+
* Detect the same organization synced under two different references: when
|
|
341
|
+
* two named slices of one provider hold IDENTICAL inner record ids (the ids
|
|
342
|
+
* modulo their account prefixes), the provider almost certainly returned the
|
|
343
|
+
* same data twice and the combined total double-counts. This is an honest
|
|
344
|
+
* diagnostic, not a silent fix — the user chose both identities, so the user
|
|
345
|
+
* removes one.
|
|
346
|
+
*/
|
|
347
|
+
export declare function duplicateProviderAccountSliceWarnings(records: readonly UsageRecord[], provider: string): string[];
|
|
348
|
+
/**
|
|
349
|
+
* Honest notices for prior records a sync removed. Replacement is fail-closed
|
|
350
|
+
* by design (unlabeled legacy rows, same-slice re-sync, id-collision guard),
|
|
351
|
+
* but billed dollars must never disappear without a word: each dropped slice
|
|
352
|
+
* is named with its record count and billed sum. A routine same-slice re-sync
|
|
353
|
+
* that returns the same or more billed evidence stays quiet.
|
|
354
|
+
*/
|
|
355
|
+
export declare function providerSliceReplacementNotices(input: {
|
|
356
|
+
provider: string;
|
|
357
|
+
accountKey: string;
|
|
358
|
+
priorRecords: readonly UsageRecord[];
|
|
359
|
+
retainedRecords: readonly UsageRecord[];
|
|
360
|
+
syncedRecordCount: number;
|
|
361
|
+
syncedBilledUsd: number | null;
|
|
362
|
+
}): string[];
|
|
363
|
+
/**
|
|
364
|
+
* Intersection of two claimed coverage windows — the interval every account
|
|
365
|
+
* slice of a provider actually covers. Returns undefined when either window
|
|
366
|
+
* is absent/malformed or the windows do not overlap (fail-closed: no window
|
|
367
|
+
* is claimed rather than an overstated one).
|
|
368
|
+
*/
|
|
369
|
+
export declare function intersectProviderCoverageIntervals(left: ProviderCoverageInterval | undefined, right: ProviderCoverageInterval | undefined): ProviderCoverageInterval | undefined;
|
|
370
|
+
/**
|
|
371
|
+
* Printable slice list, e.g.
|
|
372
|
+
* `env:OPENAI_ADMIN_KEY (6 records, billed $0.81) + env:OPENAI_ADMIN_KEY_ORG2 (18 records, billed $8.66)`.
|
|
373
|
+
*/
|
|
374
|
+
export declare function formatProviderAccountSlices(slices: readonly ProviderAccountSlice[]): string;
|
|
192
375
|
export declare function createProviderConnection(input: CreateProviderConnectionInput): ApprovedSource;
|
|
193
376
|
export declare function resolveTokenReference(reference: string, env?: Record<string, string | undefined>): string;
|
|
194
377
|
export {};
|