@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.
@@ -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 {};