@agent-finops/core 0.8.1 → 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.
Files changed (51) hide show
  1. package/README.md +5 -3
  2. package/dist/actionPlanner.d.ts +140 -0
  3. package/dist/actionPlanner.js +938 -0
  4. package/dist/actionVerification.d.ts +1240 -0
  5. package/dist/actionVerification.js +1028 -0
  6. package/dist/activitySnapshot.d.ts +142 -50
  7. package/dist/activitySnapshot.js +145 -6
  8. package/dist/activitySnapshotCache.d.ts +8 -1
  9. package/dist/activitySnapshotCache.js +103 -7
  10. package/dist/agentDraftToken.d.ts +80 -0
  11. package/dist/agentDraftToken.js +188 -0
  12. package/dist/agentEconomicsReceipt.d.ts +74 -74
  13. package/dist/agentLoopContract.d.ts +27 -0
  14. package/dist/agentLoopContract.js +36 -0
  15. package/dist/glance.d.ts +27 -1
  16. package/dist/glance.js +151 -12
  17. package/dist/guidedAnswer.d.ts +51 -0
  18. package/dist/guidedAnswer.js +352 -0
  19. package/dist/index.d.ts +14 -2
  20. package/dist/index.js +13 -1
  21. package/dist/localAgentFormats/gemini.js +2 -2
  22. package/dist/localAgentFormats/registry.js +6 -2
  23. package/dist/localAgentFormats/runtimeRegistry.js +5 -2
  24. package/dist/localAgentFormats/types.d.ts +2 -1
  25. package/dist/localAgentLogs.d.ts +362 -3
  26. package/dist/localAgentLogs.js +1964 -165
  27. package/dist/modelPricing.d.ts +1 -1
  28. package/dist/modelPricing.js +1 -1
  29. package/dist/projectEconomics.d.ts +617 -0
  30. package/dist/projectEconomics.js +620 -0
  31. package/dist/projectEconomicsBuilder.d.ts +89 -0
  32. package/dist/projectEconomicsBuilder.js +473 -0
  33. package/dist/projectIndexStore.d.ts +545 -0
  34. package/dist/projectIndexStore.js +606 -0
  35. package/dist/providerConnectors.d.ts +161 -1
  36. package/dist/providerConnectors.js +406 -11
  37. package/dist/qualitativeIndexCache.d.ts +494 -0
  38. package/dist/qualitativeIndexCache.js +930 -0
  39. package/dist/resultCard.d.ts +350 -0
  40. package/dist/resultCard.js +604 -0
  41. package/dist/runtimeCommands.d.ts +36 -0
  42. package/dist/runtimeCommands.js +50 -0
  43. package/dist/scanGuard.d.ts +3 -1
  44. package/dist/scanGuard.js +164 -4
  45. package/dist/schema.d.ts +33 -31
  46. package/dist/schema.js +9 -1
  47. package/dist/sessionVitals.d.ts +145 -0
  48. package/dist/sessionVitals.js +521 -0
  49. package/dist/toolInvocations.d.ts +40 -1
  50. package/dist/toolInvocations.js +101 -20
  51. package/package.json +1 -1
@@ -82,6 +82,64 @@ export type ProviderConnectorInput = {
82
82
  accountId?: string;
83
83
  fetcher?: Fetcher;
84
84
  tokenResolver?: TokenResolver;
85
+ /**
86
+ * Operator-declared reconciliation anchor for this sync. Today only the
87
+ * Cursor connector consumes it; when absent, the Cursor connector also
88
+ * accepts the AI_SPEND_CURSOR_RECONCILE_* environment variables so the
89
+ * shipped CLI can run a reconciliation without new flags.
90
+ */
91
+ reconciliation?: CursorReconciliationExpectation;
92
+ };
93
+ /**
94
+ * A human-read billing anchor for one Cursor reconciliation run: the
95
+ * on-demand ("usage based pricing") total the operator read off the Cursor
96
+ * team dashboard or invoice for the CURRENT subscription cycle. The connector
97
+ * compares its own summed spendCents against this figure; only an in-run
98
+ * match within tolerance can produce verified financial evidence.
99
+ */
100
+ export type CursorReconciliationExpectation = {
101
+ /** Dashboard/invoice on-demand total for the current cycle, in USD. */
102
+ expectedOnDemandUsd: number;
103
+ /**
104
+ * The billing-cycle start date shown next to that figure (YYYY-MM-DD).
105
+ * Must land within one calendar day of the UTC date of the API's
106
+ * subscriptionCycleStart, proving both numbers describe the same window.
107
+ */
108
+ expectedCycleStartDate: string;
109
+ /**
110
+ * Optional absolute comparison tolerance in USD. Defaults to $0.01 (the
111
+ * dashboard rounds to cents). Clamped to at most 1% of the expected total
112
+ * so a huge tolerance can never rubber-stamp a mismatch.
113
+ */
114
+ toleranceUsd?: number;
115
+ };
116
+ export type CursorReconciliationOutcome = {
117
+ status: "verified" | "mismatch" | "not_provable";
118
+ /** Product-authored, terminal-safe explanation of the outcome. */
119
+ note: string;
120
+ connectorTotalUsd?: number;
121
+ expectedOnDemandUsd?: number;
122
+ differenceUsd?: number;
123
+ toleranceUsd?: number;
124
+ /** Provider-reported cycle start for the reconciled window, ISO-8601. */
125
+ cycleStartIso?: string;
126
+ };
127
+ /** Environment variables the Cursor connector reads for a reconciliation run. */
128
+ export declare const cursorReconciliationEnvVars: {
129
+ readonly expectedUsd: "AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD";
130
+ readonly cycleStart: "AI_SPEND_CURSOR_RECONCILE_CYCLE_START";
131
+ readonly toleranceUsd: "AI_SPEND_CURSOR_RECONCILE_TOLERANCE_USD";
132
+ };
133
+ /**
134
+ * Read an operator-declared Cursor reconciliation anchor from the local
135
+ * environment. Absent variables mean "no reconciliation requested"; present
136
+ * but invalid variables fail closed with a reason (records stay estimated)
137
+ * instead of throwing, so a typo can never abort or silently verify a sync.
138
+ * Raw variable values are never echoed into the reason.
139
+ */
140
+ export declare function parseCursorReconciliationEnv(env?: Record<string, string | undefined>): {
141
+ expectation?: CursorReconciliationExpectation;
142
+ invalidReason?: string;
85
143
  };
86
144
  export type ProviderConnectorResult = {
87
145
  provider: string;
@@ -121,7 +179,7 @@ export declare function normalizeAnthropicClaudeCodeUsageResponse(response: unkn
121
179
  export declare function normalizeGitHubCopilotSeatResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
122
180
  export declare function normalizeAnthropicCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
123
181
  export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
124
- export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
182
+ export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions, reconciliation?: CursorReconciliationOutcome): UsageRecord[];
125
183
  export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
126
184
  export declare function summarizeProviderFinancials(records: UsageRecord[]): ProviderFinancialSummary;
127
185
  export declare function providerFinancialCompleteness(records: UsageRecord[], coverage: ProviderCoverageStatus): ProviderConnectorResult["completeness"];
@@ -131,6 +189,108 @@ export declare function providerFinancialCompleteness(records: UsageRecord[], co
131
189
  * spend headlines; callers should retain the original records for attribution.
132
190
  */
133
191
  export declare function selectProviderFinancialHeadlineRecords(records: UsageRecord[]): UsageRecord[];
192
+ /** Inputs that determine which provider account (org/team) one sync reads. */
193
+ export type ProviderAccountKeyInput = {
194
+ provider: string;
195
+ authReference: string;
196
+ org?: string;
197
+ enterprise?: string;
198
+ accountId?: string;
199
+ };
200
+ /**
201
+ * Stable identity for one provider account (an OpenAI/Anthropic organization,
202
+ * a Cursor team, a GitHub org/enterprise). Admin credentials are account-
203
+ * scoped and multi-account setups are common, so records from different
204
+ * accounts of one provider must coexist instead of replacing each other.
205
+ *
206
+ * The key prefers the explicit account flag the connector already requires
207
+ * (--org/--enterprise/--account-id, which can share one credential); it
208
+ * otherwise falls back to the user-chosen credential REFERENCE NAME
209
+ * (e.g. "env:OPENAI_ADMIN_KEY_ORG2") — stable, printable, and never derived
210
+ * from secret material. A provider-reported organization id would be
211
+ * preferable, but the cost APIs aibill calls do not reliably return one
212
+ * (the OpenAI costs request groups by project/line-item/api-key only), and a
213
+ * sometimes-present key would split one account into two slices.
214
+ */
215
+ export declare function providerAccountKey(input: ProviderAccountKeyInput): string;
216
+ /** The deterministic record-id prefix for one account slice. */
217
+ export declare function providerAccountRecordIdPrefix(accountKey: string): string;
218
+ /**
219
+ * Stamp one sync's records with their account slice. The record id gains a
220
+ * deterministic account prefix (slug + raw-key digest) so identical usage
221
+ * buckets from two accounts of the same provider can never collide into one
222
+ * row id — even for slug-equivalent account spellings — and re-syncing the
223
+ * same account regenerates the same ids (idempotent replace).
224
+ *
225
+ * Migration note: slices tagged by the short-lived pre-digest format
226
+ * (slug-only prefix) are superseded on their next re-sync — same-account
227
+ * replacement keys on `source.account`, never on id shape — and any
228
+ * colliding pre-digest rows already persisted are excluded fail-closed by
229
+ * the id-conflict guard in {@link retainProviderRecordsForNewSync}.
230
+ */
231
+ export declare function tagProviderAccountRecords(records: readonly UsageRecord[], accountKey: string): UsageRecord[];
232
+ /**
233
+ * Records from a prior trusted snapshot that must survive a new sync of
234
+ * `provider` + `accountKey`: every other provider's records, plus this
235
+ * provider's records that belong to a DIFFERENT named account slice.
236
+ * Re-syncing the same account replaces its own slice. Records with no account
237
+ * label (synced before multi-account support) are replaced too — fail-closed:
238
+ * they cannot be proven to come from a different account, and keeping them
239
+ * could double-count the same organization.
240
+ *
241
+ * Id-conflict guard: a retained record may never share an id with a newly
242
+ * synced record, nor with another retained record. Colliding ids describe
243
+ * the same underlying row (possible only in state written by the pre-digest
244
+ * prefix format, where slug-equivalent account spellings collided) — keeping
245
+ * both would double-count, so the copy that is not part of the fresh sync is
246
+ * dropped fail-closed.
247
+ */
248
+ export declare function retainProviderRecordsForNewSync(priorRecords: readonly UsageRecord[], provider: string, accountKey: string, syncedRecords: readonly UsageRecord[]): UsageRecord[];
249
+ export type ProviderAccountSlice = {
250
+ /** Account key, or null for records synced before multi-account support. */
251
+ account: string | null;
252
+ recordCount: number;
253
+ /** Sum of this slice's verified provider-billed rows; null when none. */
254
+ billedUsd: number | null;
255
+ };
256
+ /** Group one provider's records into per-account slices for honest display. */
257
+ export declare function providerAccountSlices(records: readonly UsageRecord[], provider: string): ProviderAccountSlice[];
258
+ /**
259
+ * Detect the same organization synced under two different references: when
260
+ * two named slices of one provider hold IDENTICAL inner record ids (the ids
261
+ * modulo their account prefixes), the provider almost certainly returned the
262
+ * same data twice and the combined total double-counts. This is an honest
263
+ * diagnostic, not a silent fix — the user chose both identities, so the user
264
+ * removes one.
265
+ */
266
+ export declare function duplicateProviderAccountSliceWarnings(records: readonly UsageRecord[], provider: string): string[];
267
+ /**
268
+ * Honest notices for prior records a sync removed. Replacement is fail-closed
269
+ * by design (unlabeled legacy rows, same-slice re-sync, id-collision guard),
270
+ * but billed dollars must never disappear without a word: each dropped slice
271
+ * is named with its record count and billed sum. A routine same-slice re-sync
272
+ * that returns the same or more billed evidence stays quiet.
273
+ */
274
+ export declare function providerSliceReplacementNotices(input: {
275
+ provider: string;
276
+ accountKey: string;
277
+ priorRecords: readonly UsageRecord[];
278
+ retainedRecords: readonly UsageRecord[];
279
+ syncedRecordCount: number;
280
+ syncedBilledUsd: number | null;
281
+ }): string[];
282
+ /**
283
+ * Intersection of two claimed coverage windows — the interval every account
284
+ * slice of a provider actually covers. Returns undefined when either window
285
+ * is absent/malformed or the windows do not overlap (fail-closed: no window
286
+ * is claimed rather than an overstated one).
287
+ */
288
+ export declare function intersectProviderCoverageIntervals(left: ProviderCoverageInterval | undefined, right: ProviderCoverageInterval | undefined): ProviderCoverageInterval | undefined;
289
+ /**
290
+ * Printable slice list, e.g.
291
+ * `env:OPENAI_ADMIN_KEY (6 records, billed $0.81) + env:OPENAI_ADMIN_KEY_ORG2 (18 records, billed $8.66)`.
292
+ */
293
+ export declare function formatProviderAccountSlices(slices: readonly ProviderAccountSlice[]): string;
134
294
  export declare function createProviderConnection(input: CreateProviderConnectionInput): ApprovedSource;
135
295
  export declare function resolveTokenReference(reference: string, env?: Record<string, string | undefined>): string;
136
296
  export {};
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { createProviderConnectorStub, slugifySourceId } from "./sourceRegistry.js";
2
3
  import { redactSecrets } from "./discovery.js";
3
4
  /**
@@ -19,6 +20,55 @@ export function isProviderAuthenticationError(error) {
19
20
  return error instanceof ProviderConnectorError &&
20
21
  error.code === "authentication_error";
21
22
  }
23
+ /** Environment variables the Cursor connector reads for a reconciliation run. */
24
+ export const cursorReconciliationEnvVars = {
25
+ expectedUsd: "AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD",
26
+ cycleStart: "AI_SPEND_CURSOR_RECONCILE_CYCLE_START",
27
+ toleranceUsd: "AI_SPEND_CURSOR_RECONCILE_TOLERANCE_USD"
28
+ };
29
+ /**
30
+ * Read an operator-declared Cursor reconciliation anchor from the local
31
+ * environment. Absent variables mean "no reconciliation requested"; present
32
+ * but invalid variables fail closed with a reason (records stay estimated)
33
+ * instead of throwing, so a typo can never abort or silently verify a sync.
34
+ * Raw variable values are never echoed into the reason.
35
+ */
36
+ export function parseCursorReconciliationEnv(env = process.env) {
37
+ const rawExpected = env[cursorReconciliationEnvVars.expectedUsd];
38
+ const rawCycleStart = env[cursorReconciliationEnvVars.cycleStart];
39
+ const rawTolerance = env[cursorReconciliationEnvVars.toleranceUsd];
40
+ if (rawExpected === undefined && rawCycleStart === undefined && rawTolerance === undefined) {
41
+ return {};
42
+ }
43
+ if (rawExpected === undefined || rawCycleStart === undefined) {
44
+ return {
45
+ invalidReason: `both ${cursorReconciliationEnvVars.expectedUsd} and ${cursorReconciliationEnvVars.cycleStart} are required to request a reconciliation`
46
+ };
47
+ }
48
+ const expectedOnDemandUsd = Number(rawExpected.trim());
49
+ const toleranceUsd = rawTolerance === undefined ? undefined : Number(rawTolerance.trim());
50
+ const expectation = {
51
+ expectedOnDemandUsd,
52
+ expectedCycleStartDate: rawCycleStart.trim(),
53
+ ...(toleranceUsd === undefined ? {} : { toleranceUsd })
54
+ };
55
+ const invalidReason = invalidCursorReconciliationExpectationReason(expectation);
56
+ return invalidReason ? { invalidReason } : { expectation };
57
+ }
58
+ function invalidCursorReconciliationExpectationReason(expectation) {
59
+ if (!Number.isFinite(expectation.expectedOnDemandUsd) || expectation.expectedOnDemandUsd <= 0) {
60
+ return `${cursorReconciliationEnvVars.expectedUsd} must be a positive USD amount read off the Cursor dashboard or invoice`;
61
+ }
62
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(expectation.expectedCycleStartDate) ||
63
+ !Number.isFinite(Date.parse(`${expectation.expectedCycleStartDate}T00:00:00Z`))) {
64
+ return `${cursorReconciliationEnvVars.cycleStart} must be the cycle start date shown on the dashboard, formatted YYYY-MM-DD`;
65
+ }
66
+ if (expectation.toleranceUsd !== undefined &&
67
+ (!Number.isFinite(expectation.toleranceUsd) || expectation.toleranceUsd < 0)) {
68
+ return `${cursorReconciliationEnvVars.toleranceUsd} must be a non-negative USD amount when set`;
69
+ }
70
+ return undefined;
71
+ }
22
72
  export function normalizeOpenAiCostResponse(response, options) {
23
73
  const data = isObject(response) && Array.isArray(response.data) ? response.data : [];
24
74
  const records = [];
@@ -399,10 +449,21 @@ export function normalizeGitHubCopilotMetricsResponse(response, options) {
399
449
  }
400
450
  return records;
401
451
  }
402
- export function normalizeCursorSpendResponse(response, options) {
452
+ export function normalizeCursorSpendResponse(response, options, reconciliation) {
403
453
  const users = extractArray(response, "teamMemberSpend");
404
454
  const cycleStart = isRecord(response) ? numberValue(response.subscriptionCycleStart) : undefined;
405
455
  const timestamp = typeof cycleStart === "number" ? new Date(cycleStart).toISOString() : new Date().toISOString();
456
+ // The Cursor connector's dollars are labeled estimated until an in-run
457
+ // reconciliation proves the connector total against a human-read dashboard
458
+ // or invoice figure for the same cycle. Only that evidence — never a
459
+ // hardcoded flip — can stamp these records "verified", and a mismatched or
460
+ // unprovable reconciliation fails closed back to estimated.
461
+ const reconciled = reconciliation?.status === "verified";
462
+ const confidence = reconciled ? "verified" : "estimated";
463
+ // Documented semantics: spendCents is "On-demand spend in cents for the
464
+ // current billing cycle" — seat fees and included-pool usage are excluded.
465
+ const baseOperation = "Cursor on-demand team spend (current billing cycle; excludes seat fees and included-pool usage)";
466
+ const operation = reconciled ? `${baseOperation}; ${reconciliation.note}` : baseOperation;
406
467
  return users.flatMap((user) => {
407
468
  if (!isRecord(user))
408
469
  return [];
@@ -413,20 +474,17 @@ export function normalizeCursorSpendResponse(response, options) {
413
474
  return [{
414
475
  id: slugifySourceId(["cursor-spend", options.accountId, userId].filter(Boolean).join("-")),
415
476
  timestamp,
416
- // The Cursor connector is spec-built and not yet live-verified (beta),
417
- // so its dollars are labeled estimated until reconciled against a real
418
- // team's invoice. Never stamp "verified" on data we haven't verified.
419
- source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence: "estimated", observedFrom: options.observedFrom },
477
+ source: { id: options.sourceId, name: "Cursor Admin API", provider: "cursor", confidence, observedFrom: options.observedFrom },
420
478
  model: "cursor-team-usage",
421
479
  inputTokens: 0,
422
480
  outputTokens: 0,
423
481
  amountUsd: cents / 100,
424
- costConfidence: "estimated",
482
+ costConfidence: confidence,
425
483
  userId,
426
484
  projectId: options.accountId,
427
485
  providerCostType: "cursor_spend",
428
486
  usageGranularity: "user_aggregate",
429
- operation: "Cursor team spend"
487
+ operation
430
488
  }];
431
489
  });
432
490
  }
@@ -571,8 +629,102 @@ async function fetchGitHubCopilot(input, token, fetcher, sourceId) {
571
629
  async function fetchCursor(input, token, fetcher, sourceId) {
572
630
  const accountId = input.accountId ?? input.org ?? "cursor-team";
573
631
  const spendFetch = await fetchCursorSpendPages(fetcher, token);
574
- const records = spendFetch.pages.flatMap((page) => normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId }));
575
- return providerResult("cursor", sourceId, input.authReference, records, qaSummary("cursor", [spendFetch]));
632
+ const requested = input.reconciliation
633
+ ? { expectation: input.reconciliation, invalidReason: invalidCursorReconciliationExpectationReason(input.reconciliation) }
634
+ : parseCursorReconciliationEnv();
635
+ const reconciliation = assessCursorReconciliation(spendFetch, requested.expectation, requested.invalidReason);
636
+ const records = spendFetch.pages.flatMap((page) => normalizeCursorSpendResponse(page, { sourceId, observedFrom: "Cursor Admin API", accountId }, reconciliation));
637
+ const qa = qaSummary("cursor", [spendFetch]);
638
+ if (reconciliation) {
639
+ // The outcome must survive the persisted-QA round trip, so it rides in
640
+ // instructions (kept verbatim) and, on failure, in responseDrift.
641
+ qa.instructions = [...qa.instructions, `Reconciliation ${reconciliation.status}: ${reconciliation.note}`];
642
+ if (reconciliation.status !== "verified") {
643
+ qa.responseDrift.push({
644
+ label: "Cursor Admin API spend",
645
+ field: "teamMemberSpend[].spendCents (cycle total)",
646
+ issue: reconciliation.note
647
+ });
648
+ }
649
+ }
650
+ return providerResult("cursor", sourceId, input.authReference, records, qa);
651
+ }
652
+ /**
653
+ * Compare the connector's summed current-cycle on-demand total against the
654
+ * operator-read dashboard/invoice figure. Every exit that is not an exact
655
+ * window-proven match inside the clamped tolerance fails closed: the records
656
+ * stay estimated and the note says exactly why. Returns undefined when no
657
+ * reconciliation was requested.
658
+ */
659
+ function assessCursorReconciliation(spendFetch, expectation, invalidReason) {
660
+ if (!expectation && !invalidReason)
661
+ return undefined;
662
+ if (invalidReason || !expectation) {
663
+ return {
664
+ status: "not_provable",
665
+ note: `Cursor reconciliation input was rejected (${invalidReason ?? "missing expectation"}); records remain estimated.`
666
+ };
667
+ }
668
+ if (spendFetch.pagination.stoppedBecause !== "complete" || spendFetch.coverageIncomplete === true) {
669
+ return {
670
+ status: "not_provable",
671
+ note: `Cursor reconciliation requires a complete spend window; pagination stopped because "${spendFetch.pagination.stoppedBecause}" so a partial window cannot verify billed dollars. Records remain estimated.`
672
+ };
673
+ }
674
+ const cycleStarts = spendFetch.pages.map((page) => isRecord(page) ? numberValue(page.subscriptionCycleStart) : undefined);
675
+ const cycleStart = cycleStarts[0];
676
+ if (typeof cycleStart !== "number" || cycleStarts.some((value) => value !== cycleStart)) {
677
+ return {
678
+ status: "not_provable",
679
+ note: "Cursor did not report one consistent subscriptionCycleStart across spend pages; the reconciliation window cannot be proven. Records remain estimated."
680
+ };
681
+ }
682
+ const cycleStartIso = new Date(cycleStart).toISOString();
683
+ const apiCycleDate = cycleStartIso.slice(0, 10);
684
+ const declaredDateMs = Date.parse(`${expectation.expectedCycleStartDate}T00:00:00Z`);
685
+ const dayMs = 24 * 60 * 60 * 1000;
686
+ if (!Number.isFinite(declaredDateMs) || Math.abs(Date.parse(`${apiCycleDate}T00:00:00Z`) - declaredDateMs) > dayMs) {
687
+ return {
688
+ status: "not_provable",
689
+ cycleStartIso,
690
+ note: `The declared cycle start ${expectation.expectedCycleStartDate} does not match the provider-reported cycle start ${apiCycleDate} (UTC); the dashboard figure and the connector read different windows. Records remain estimated.`
691
+ };
692
+ }
693
+ const connectorTotalCents = spendFetch.pages.reduce((sum, page) => sum + extractArray(page, "teamMemberSpend").reduce((pageSum, member) => pageSum + (isRecord(member) ? numberValue(member.spendCents) ?? 0 : 0), 0), 0);
694
+ const connectorTotalUsd = connectorTotalCents / 100;
695
+ if (!(connectorTotalUsd > 0)) {
696
+ return {
697
+ status: "not_provable",
698
+ cycleStartIso,
699
+ connectorTotalUsd,
700
+ expectedOnDemandUsd: expectation.expectedOnDemandUsd,
701
+ note: "Cursor reconciliation needs a non-zero connector total; matching $0.00 against a dashboard figure proves nothing. Records remain estimated."
702
+ };
703
+ }
704
+ // Default $0.01 (dashboards round to cents); clamp to at most 1% of the
705
+ // expected figure so an oversized tolerance cannot manufacture a match.
706
+ const requestedTolerance = Math.max(expectation.toleranceUsd ?? 0.01, 0.01);
707
+ const toleranceUsd = Math.min(requestedTolerance, Math.max(0.01, expectation.expectedOnDemandUsd * 0.01));
708
+ const differenceUsd = Math.abs(connectorTotalUsd - expectation.expectedOnDemandUsd);
709
+ const shared = {
710
+ connectorTotalUsd,
711
+ expectedOnDemandUsd: expectation.expectedOnDemandUsd,
712
+ differenceUsd,
713
+ toleranceUsd,
714
+ cycleStartIso
715
+ };
716
+ if (differenceUsd <= toleranceUsd + 1e-9) {
717
+ return {
718
+ status: "verified",
719
+ ...shared,
720
+ note: `reconciled to the operator-read dashboard/invoice on-demand total $${expectation.expectedOnDemandUsd.toFixed(2)} for the cycle starting ${apiCycleDate}: connector total $${connectorTotalUsd.toFixed(2)}, difference $${differenceUsd.toFixed(2)} within tolerance $${toleranceUsd.toFixed(2)}`
721
+ };
722
+ }
723
+ return {
724
+ status: "mismatch",
725
+ ...shared,
726
+ note: `Cursor reconciliation mismatch: connector on-demand total $${connectorTotalUsd.toFixed(2)} vs operator-read $${expectation.expectedOnDemandUsd.toFixed(2)} for the cycle starting ${apiCycleDate}; difference $${differenceUsd.toFixed(2)} exceeds tolerance $${toleranceUsd.toFixed(2)}. Records remain estimated until the totals agree.`
727
+ };
576
728
  }
577
729
  async function fetchCursorSpendPages(fetcher, token) {
578
730
  const label = "Cursor Admin API spend";
@@ -1392,7 +1544,18 @@ function knownProviderFields(provider, label) {
1392
1544
  return new Set([...common, "total_seats", "seats", "seats[]", "seats[].created_at", "seats[].updated_at", "seats[].pending_cancellation_date", "seats[].last_activity_at", "seats[].last_activity_editor", "seats[].last_authenticated_at", "seats[].plan_type", "seats[].login", "seats[].id", "seats[].assignee", "seats[].assignee.login", "seats[].assignee.email", "seats[].assignee.id", "seats[].assignee.node_id", "seats[].assignee.avatar_url", "seats[].assignee.html_url", "seats[].assignee.type", "seats[].assignee.site_admin", "seats[].assigning_team", "seats[].organization"]);
1393
1545
  }
1394
1546
  if (provider === "cursor") {
1395
- return new Set([...common, "teamMemberSpend", "teamMemberSpend[]", "teamMemberSpend[].userId", "teamMemberSpend[].email", "teamMemberSpend[].name", "teamMemberSpend[].role", "teamMemberSpend[].spendCents", "teamMemberSpend[].fastPremiumRequests", "teamMemberSpend[].hardLimitOverrideDollars", "subscriptionCycleStart", "totalMembers", "totalPages"]);
1547
+ return new Set([
1548
+ ...common,
1549
+ "teamMemberSpend", "teamMemberSpend[]", "teamMemberSpend[].userId", "teamMemberSpend[].email", "teamMemberSpend[].name", "teamMemberSpend[].role", "teamMemberSpend[].spendCents", "teamMemberSpend[].fastPremiumRequests", "teamMemberSpend[].hardLimitOverrideDollars",
1550
+ // Documented in the 2026 Admin API reference alongside spendCents.
1551
+ "teamMemberSpend[].overallSpendCents", "teamMemberSpend[].monthlyLimitDollars", "teamMemberSpend[].effectivePerUserLimitDollars",
1552
+ // Present in live responses and staff-acknowledged as a docs lag
1553
+ // (forum.cursor.com thread 162742, "docs for Get Spending Data are
1554
+ // behind the current API schema"). billingTier and the percent fields
1555
+ // are tiered-team-only and may be undefined elsewhere.
1556
+ "teamMemberSpend[].includedSpendCents", "teamMemberSpend[].profilePictureUrl", "teamMemberSpend[].billingTier", "teamMemberSpend[].autoPercentUsed", "teamMemberSpend[].apiPercentUsed", "teamMemberSpend[].totalPercentUsed",
1557
+ "subscriptionCycleStart", "totalMembers", "totalPages"
1558
+ ]);
1396
1559
  }
1397
1560
  return new Set([...common]);
1398
1561
  }
@@ -1429,7 +1592,9 @@ function providerInstructions(provider) {
1429
1592
  if (provider === "cursor") {
1430
1593
  return [
1431
1594
  "Use a Cursor team admin API key reference, or fall back to Browser Account UI/manual export when API access is unavailable.",
1432
- "Validate user-level spend against invoices before treating the source as finance-grade."
1595
+ "Cursor's 2026 docs list the Admin API under Enterprise teams; individual Pro/Ultra plans expose no billing API. Standard Admin API endpoints are rate-limited to 20 requests/minute per team.",
1596
+ "spendCents is on-demand spend for the current billing cycle; seat fees and included-pool usage are not in this total.",
1597
+ "Validate user-level spend against invoices before treating the source as finance-grade; set AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD and AI_SPEND_CURSOR_RECONCILE_CYCLE_START to run an in-sync reconciliation."
1433
1598
  ];
1434
1599
  }
1435
1600
  return ["Use a local token reference only; never paste raw provider secrets into commands or reports."];
@@ -1533,6 +1698,236 @@ export function selectProviderFinancialHeadlineRecords(records) {
1533
1698
  : providerRecords;
1534
1699
  });
1535
1700
  }
1701
+ /**
1702
+ * Stable identity for one provider account (an OpenAI/Anthropic organization,
1703
+ * a Cursor team, a GitHub org/enterprise). Admin credentials are account-
1704
+ * scoped and multi-account setups are common, so records from different
1705
+ * accounts of one provider must coexist instead of replacing each other.
1706
+ *
1707
+ * The key prefers the explicit account flag the connector already requires
1708
+ * (--org/--enterprise/--account-id, which can share one credential); it
1709
+ * otherwise falls back to the user-chosen credential REFERENCE NAME
1710
+ * (e.g. "env:OPENAI_ADMIN_KEY_ORG2") — stable, printable, and never derived
1711
+ * from secret material. A provider-reported organization id would be
1712
+ * preferable, but the cost APIs aibill calls do not reliably return one
1713
+ * (the OpenAI costs request groups by project/line-item/api-key only), and a
1714
+ * sometimes-present key would split one account into two slices.
1715
+ */
1716
+ export function providerAccountKey(input) {
1717
+ if (input.org)
1718
+ return `org:${input.org}`;
1719
+ if (input.enterprise)
1720
+ return `enterprise:${input.enterprise}`;
1721
+ if (input.accountId)
1722
+ return `account:${input.accountId}`;
1723
+ return input.authReference;
1724
+ }
1725
+ /**
1726
+ * Deterministic short digest of the RAW account key. The slug alone is not
1727
+ * injective — cursor `--account-id "team a"` and `--account-id "team-a"`
1728
+ * both slug to `team-a` — so the record-id prefix carries this digest of the
1729
+ * raw identity: distinct account keys can never share a record-id namespace,
1730
+ * while the same key always regenerates the same digest (idempotent
1731
+ * re-sync). Never derived from secret material: account keys are reference
1732
+ * names and explicit account flags by construction.
1733
+ */
1734
+ function providerAccountKeyDigest(accountKey) {
1735
+ return createHash("sha256").update(accountKey, "utf8").digest("hex").slice(0, 8);
1736
+ }
1737
+ /** The deterministic record-id prefix for one account slice. */
1738
+ export function providerAccountRecordIdPrefix(accountKey) {
1739
+ return `${slugifySourceId(accountKey)}-${providerAccountKeyDigest(accountKey)}`;
1740
+ }
1741
+ /**
1742
+ * Stamp one sync's records with their account slice. The record id gains a
1743
+ * deterministic account prefix (slug + raw-key digest) so identical usage
1744
+ * buckets from two accounts of the same provider can never collide into one
1745
+ * row id — even for slug-equivalent account spellings — and re-syncing the
1746
+ * same account regenerates the same ids (idempotent replace).
1747
+ *
1748
+ * Migration note: slices tagged by the short-lived pre-digest format
1749
+ * (slug-only prefix) are superseded on their next re-sync — same-account
1750
+ * replacement keys on `source.account`, never on id shape — and any
1751
+ * colliding pre-digest rows already persisted are excluded fail-closed by
1752
+ * the id-conflict guard in {@link retainProviderRecordsForNewSync}.
1753
+ */
1754
+ export function tagProviderAccountRecords(records, accountKey) {
1755
+ const prefix = providerAccountRecordIdPrefix(accountKey);
1756
+ return records.map((record) => ({
1757
+ ...record,
1758
+ id: `${prefix}-${record.id}`,
1759
+ source: { ...record.source, account: accountKey }
1760
+ }));
1761
+ }
1762
+ /**
1763
+ * Records from a prior trusted snapshot that must survive a new sync of
1764
+ * `provider` + `accountKey`: every other provider's records, plus this
1765
+ * provider's records that belong to a DIFFERENT named account slice.
1766
+ * Re-syncing the same account replaces its own slice. Records with no account
1767
+ * label (synced before multi-account support) are replaced too — fail-closed:
1768
+ * they cannot be proven to come from a different account, and keeping them
1769
+ * could double-count the same organization.
1770
+ *
1771
+ * Id-conflict guard: a retained record may never share an id with a newly
1772
+ * synced record, nor with another retained record. Colliding ids describe
1773
+ * the same underlying row (possible only in state written by the pre-digest
1774
+ * prefix format, where slug-equivalent account spellings collided) — keeping
1775
+ * both would double-count, so the copy that is not part of the fresh sync is
1776
+ * dropped fail-closed.
1777
+ */
1778
+ export function retainProviderRecordsForNewSync(priorRecords, provider, accountKey, syncedRecords) {
1779
+ const syncedIds = new Set(syncedRecords.map((record) => record.id));
1780
+ const seenIds = new Set();
1781
+ return priorRecords.filter((record) => {
1782
+ const replacedSlice = record.source.provider === provider &&
1783
+ !(typeof record.source.account === "string" && record.source.account !== accountKey);
1784
+ if (replacedSlice)
1785
+ return false;
1786
+ if (syncedIds.has(record.id) || seenIds.has(record.id))
1787
+ return false;
1788
+ seenIds.add(record.id);
1789
+ return true;
1790
+ });
1791
+ }
1792
+ /** Group one provider's records into per-account slices for honest display. */
1793
+ export function providerAccountSlices(records, provider) {
1794
+ const slices = new Map();
1795
+ for (const record of records) {
1796
+ if (record.source.provider !== provider)
1797
+ continue;
1798
+ const key = record.source.account ?? null;
1799
+ const slice = slices.get(key) ?? { recordCount: 0, billedUsd: null };
1800
+ slice.recordCount += 1;
1801
+ if (record.costConfidence === "verified" && typeof record.amountUsd === "number") {
1802
+ slice.billedUsd = (slice.billedUsd ?? 0) + record.amountUsd;
1803
+ }
1804
+ slices.set(key, slice);
1805
+ }
1806
+ return [...slices.entries()]
1807
+ .map(([account, slice]) => ({ account, ...slice }))
1808
+ .sort((left, right) => (left.account ?? "").localeCompare(right.account ?? ""));
1809
+ }
1810
+ /**
1811
+ * A slice's record ids with their account prefix stripped — the provider-side
1812
+ * bucket identity. Understands the current slug+digest prefix and the
1813
+ * short-lived pre-digest slug-only prefix; unprefixed ids pass through.
1814
+ */
1815
+ function sliceInnerRecordId(id, accountKey) {
1816
+ const digestPrefix = `${providerAccountRecordIdPrefix(accountKey)}-`;
1817
+ if (id.startsWith(digestPrefix))
1818
+ return id.slice(digestPrefix.length);
1819
+ const slugPrefix = `${slugifySourceId(accountKey)}-`;
1820
+ if (id.startsWith(slugPrefix))
1821
+ return id.slice(slugPrefix.length);
1822
+ return id;
1823
+ }
1824
+ /**
1825
+ * Detect the same organization synced under two different references: when
1826
+ * two named slices of one provider hold IDENTICAL inner record ids (the ids
1827
+ * modulo their account prefixes), the provider almost certainly returned the
1828
+ * same data twice and the combined total double-counts. This is an honest
1829
+ * diagnostic, not a silent fix — the user chose both identities, so the user
1830
+ * removes one.
1831
+ */
1832
+ export function duplicateProviderAccountSliceWarnings(records, provider) {
1833
+ const innerIdsByAccount = new Map();
1834
+ for (const record of records) {
1835
+ if (record.source.provider !== provider)
1836
+ continue;
1837
+ const account = record.source.account;
1838
+ if (typeof account !== "string")
1839
+ continue;
1840
+ const inner = innerIdsByAccount.get(account) ?? new Set();
1841
+ inner.add(sliceInnerRecordId(record.id, account));
1842
+ innerIdsByAccount.set(account, inner);
1843
+ }
1844
+ const accounts = [...innerIdsByAccount.keys()].sort();
1845
+ const warnings = [];
1846
+ for (let leftIndex = 0; leftIndex < accounts.length; leftIndex += 1) {
1847
+ for (let rightIndex = leftIndex + 1; rightIndex < accounts.length; rightIndex += 1) {
1848
+ const left = innerIdsByAccount.get(accounts[leftIndex]);
1849
+ const right = innerIdsByAccount.get(accounts[rightIndex]);
1850
+ if (left.size === 0 || left.size !== right.size)
1851
+ continue;
1852
+ if (![...left].every((id) => right.has(id)))
1853
+ continue;
1854
+ warnings.push(`${provider} slices ${accounts[leftIndex]} and ${accounts[rightIndex]} contain identical records — ` +
1855
+ "likely the same organization under two references; the combined total counts it twice. " +
1856
+ `Remove one: npx aibill drop-slice --provider ${provider} --account "${accounts[rightIndex]}"`);
1857
+ }
1858
+ }
1859
+ return warnings;
1860
+ }
1861
+ /**
1862
+ * Honest notices for prior records a sync removed. Replacement is fail-closed
1863
+ * by design (unlabeled legacy rows, same-slice re-sync, id-collision guard),
1864
+ * but billed dollars must never disappear without a word: each dropped slice
1865
+ * is named with its record count and billed sum. A routine same-slice re-sync
1866
+ * that returns the same or more billed evidence stays quiet.
1867
+ */
1868
+ export function providerSliceReplacementNotices(input) {
1869
+ const retained = new Set(input.retainedRecords);
1870
+ const dropped = input.priorRecords.filter((record) => !retained.has(record));
1871
+ if (dropped.length === 0)
1872
+ return [];
1873
+ const notices = [];
1874
+ for (const slice of providerAccountSlices(dropped, input.provider)) {
1875
+ const billed = slice.billedUsd === null
1876
+ ? "no billed evidence"
1877
+ : `${formatProviderUsd(slice.billedUsd)} billed`;
1878
+ const rows = `${slice.recordCount} record${slice.recordCount === 1 ? "" : "s"}`;
1879
+ if (slice.account === null) {
1880
+ notices.push(`replaced prior unlabeled slice: ${billed} from ${rows} superseded`);
1881
+ continue;
1882
+ }
1883
+ if (slice.account === input.accountKey) {
1884
+ const reducesBilledEvidence = slice.billedUsd !== null &&
1885
+ (input.syncedBilledUsd === null || input.syncedBilledUsd + 0.005 < slice.billedUsd);
1886
+ if (!reducesBilledEvidence)
1887
+ continue;
1888
+ const newBilled = input.syncedBilledUsd === null
1889
+ ? "no billed evidence"
1890
+ : `billed ${formatProviderUsd(input.syncedBilledUsd)}`;
1891
+ notices.push(`replaced prior slice ${slice.account}: ${billed} from ${rows} superseded ` +
1892
+ `(this sync returned ${input.syncedRecordCount} record${input.syncedRecordCount === 1 ? "" : "s"}, ${newBilled})`);
1893
+ continue;
1894
+ }
1895
+ notices.push(`replaced prior slice ${slice.account}: ${billed} from ${rows} superseded (record ids collided with newer state)`);
1896
+ }
1897
+ return notices;
1898
+ }
1899
+ /**
1900
+ * Intersection of two claimed coverage windows — the interval every account
1901
+ * slice of a provider actually covers. Returns undefined when either window
1902
+ * is absent/malformed or the windows do not overlap (fail-closed: no window
1903
+ * is claimed rather than an overstated one).
1904
+ */
1905
+ export function intersectProviderCoverageIntervals(left, right) {
1906
+ if (!left || !right)
1907
+ return undefined;
1908
+ if (typeof left.coverageStart !== "string" || typeof left.coverageEnd !== "string" ||
1909
+ typeof right.coverageStart !== "string" || typeof right.coverageEnd !== "string") {
1910
+ return undefined;
1911
+ }
1912
+ const coverageStart = left.coverageStart > right.coverageStart
1913
+ ? left.coverageStart
1914
+ : right.coverageStart;
1915
+ const coverageEnd = left.coverageEnd < right.coverageEnd
1916
+ ? left.coverageEnd
1917
+ : right.coverageEnd;
1918
+ return coverageStart <= coverageEnd ? { coverageStart, coverageEnd } : undefined;
1919
+ }
1920
+ /**
1921
+ * Printable slice list, e.g.
1922
+ * `env:OPENAI_ADMIN_KEY (6 records, billed $0.81) + env:OPENAI_ADMIN_KEY_ORG2 (18 records, billed $8.66)`.
1923
+ */
1924
+ export function formatProviderAccountSlices(slices) {
1925
+ return slices.map((slice) => {
1926
+ const label = slice.account ?? "earlier sync (unlabeled account)";
1927
+ const billed = slice.billedUsd === null ? "" : `, billed ${formatProviderUsd(slice.billedUsd)}`;
1928
+ return `${label} (${slice.recordCount} record${slice.recordCount === 1 ? "" : "s"}${billed})`;
1929
+ }).join(" + ");
1930
+ }
1536
1931
  function sumAmounts(records) {
1537
1932
  const amounts = records
1538
1933
  .map((record) => record.amountUsd)