@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
@@ -0,0 +1,604 @@
1
+ import { z } from "zod";
2
+ import { subscriptionPlans } from "./planMath.js";
3
+ /** §1.2 canonical vocabulary — everything outside this set is killed. */
4
+ export const resultCardVocabulary = Object.freeze({
5
+ committed: "committed",
6
+ /** Sanctioned narrow alias for the committed total, statusline <75 col only. */
7
+ committedNarrowAlias: "subs",
8
+ apiEquivalent: "API-equivalent",
9
+ billed: "billed",
10
+ estimatedMarker: "~",
11
+ notReported: "not reported",
12
+ notReportedShort: "n/r",
13
+ notReportedLegend: "n/r = not reported",
14
+ estimatedMarkerLegend: "~ = estimated at API rates",
15
+ everythingElse: "everything else",
16
+ unattributed: "unattributed",
17
+ detectedUnverifiedSuffix: "detected (unverified · beta connector)",
18
+ blendPolicy: "never_blended"
19
+ });
20
+ /**
21
+ * Killed on sight (§1.2): QA greps new/changed rendered copy for these.
22
+ * "provider-reported" survives ONLY in trust/mode lines explaining where
23
+ * `billed` comes from — never on a figure.
24
+ */
25
+ export const resultCardKilledTerms = Object.freeze([
26
+ "usage value",
27
+ "observed value",
28
+ "cost/value",
29
+ "API-equivalent/estimated",
30
+ // QA MINOR-5: the pre-C-lane statusline suffix, killed on every new surface.
31
+ "7d value"
32
+ ]);
33
+ const usdSchema = z.number().finite().nonnegative();
34
+ const shareSchema = z.number().finite().min(0).max(1);
35
+ export const resultCardRunwaySchema = z.object({
36
+ kind: z.enum(["five-hour", "weekly"]),
37
+ remainingPercent: z.number().finite().min(0).max(100),
38
+ resetsAt: z.string().datetime({ offset: true })
39
+ }).strict();
40
+ export const resultCardSubscriptionRowSchema = z.object({
41
+ id: z.string().min(1),
42
+ agentId: z.enum(["claude-code", "codex"]).nullable(),
43
+ planLabel: z.string().min(1).nullable(),
44
+ connection: z.enum(["local_logs", "connected", "detected_only"]),
45
+ committedUsdPerMonth: usdSchema.nullable(),
46
+ apiEquivalentUsd: usdSchema.nullable(),
47
+ providerBilledUsd: usdSchema.nullable(),
48
+ detectedUnverifiedUsd: usdSchema.nullable(),
49
+ runways: z.array(resultCardRunwaySchema).max(2)
50
+ }).strict();
51
+ export const resultCardTotalsSchema = z.object({
52
+ subscriptionCommitted: z.object({
53
+ amountUsd: usdSchema.nullable(),
54
+ pricedSubs: z.number().int().nonnegative(),
55
+ totalSubs: z.number().int().nonnegative()
56
+ }).strict().superRefine((committed, context) => {
57
+ if (committed.pricedSubs > committed.totalSubs) {
58
+ context.addIssue({
59
+ code: "custom",
60
+ path: ["pricedSubs"],
61
+ message: "Priced subscriptions cannot exceed total subscriptions."
62
+ });
63
+ }
64
+ if ((committed.amountUsd === null) !== (committed.pricedSubs === 0)) {
65
+ context.addIssue({
66
+ code: "custom",
67
+ path: ["amountUsd"],
68
+ message: "A committed total exists exactly when at least one subscription is priced."
69
+ });
70
+ }
71
+ }),
72
+ apiEquivalent: z.object({
73
+ amountUsd: usdSchema.nullable(),
74
+ financialEvidence: z.enum(["estimated", "missing"])
75
+ }).strict().superRefine((total, context) => {
76
+ if ((total.amountUsd === null) !== (total.financialEvidence === "missing")) {
77
+ context.addIssue({
78
+ code: "custom",
79
+ path: ["financialEvidence"],
80
+ message: "API-equivalent amount and financial evidence must agree (null ⟺ missing)."
81
+ });
82
+ }
83
+ }),
84
+ providerBilled: z.object({
85
+ amountUsd: usdSchema.nullable(),
86
+ financialEvidence: z.enum(["verified", "missing"])
87
+ }).strict().superRefine((total, context) => {
88
+ if ((total.amountUsd === null) !== (total.financialEvidence === "missing")) {
89
+ context.addIssue({
90
+ code: "custom",
91
+ path: ["financialEvidence"],
92
+ message: "Provider-billed amount and financial evidence must agree (null ⟺ missing)."
93
+ });
94
+ }
95
+ }),
96
+ blended: z.null(),
97
+ blendPolicy: z.literal("never_blended")
98
+ }).strict();
99
+ export const resultCardByProjectSchema = z.object({
100
+ basis: z.enum(["api_equivalent", "provider_billed"]),
101
+ rows: z.array(z.object({
102
+ project: z.string().min(1),
103
+ amountUsd: usdSchema,
104
+ share: shareSchema,
105
+ unattributed: z.boolean()
106
+ }).strict()).min(1),
107
+ everythingElse: z.object({
108
+ amountUsd: usdSchema,
109
+ share: shareSchema,
110
+ projectCount: z.number().int().positive()
111
+ }).strict().nullable()
112
+ }).strict().superRefine((block, context) => {
113
+ const shares = [
114
+ ...block.rows.map((row) => row.share),
115
+ ...(block.everythingElse ? [block.everythingElse.share] : [])
116
+ ];
117
+ const shareSum = shares.reduce((total, share) => total + share, 0);
118
+ if (Math.abs(shareSum - 1) > 0.0001) {
119
+ context.addIssue({
120
+ code: "custom",
121
+ path: ["rows"],
122
+ message: "By-project shares must sum to 1.0000."
123
+ });
124
+ }
125
+ });
126
+ export const resultCardSchema = z.object({
127
+ kind: z.literal("aibill.result_card"),
128
+ schemaVersion: z.literal(1),
129
+ currency: z.literal("USD"),
130
+ windowDays: z.number().int().positive(),
131
+ mode: z.enum(["local-logs", "connected", "mixed", "demo"]),
132
+ subscriptions: z.array(resultCardSubscriptionRowSchema),
133
+ totals: resultCardTotalsSchema,
134
+ byProject: resultCardByProjectSchema.nullable()
135
+ }).strict().superRefine((card, context) => {
136
+ if (card.mode === "demo" && card.subscriptions.length > 0) {
137
+ context.addIssue({
138
+ code: "custom",
139
+ path: ["subscriptions"],
140
+ message: "Sample/demo cards never carry real detected subscriptions."
141
+ });
142
+ }
143
+ if (card.totals.subscriptionCommitted.totalSubs !== card.subscriptions.length) {
144
+ context.addIssue({
145
+ code: "custom",
146
+ path: ["totals", "subscriptionCommitted", "totalSubs"],
147
+ message: "totalSubs must equal the number of subscription rows."
148
+ });
149
+ }
150
+ if (new Set(card.subscriptions.map((row) => row.id)).size !== card.subscriptions.length) {
151
+ context.addIssue({
152
+ code: "custom",
153
+ path: ["subscriptions"],
154
+ message: "Subscription ids must be unique."
155
+ });
156
+ }
157
+ if (card.byProject) {
158
+ // rows + everythingElse reconcile exactly to the basis total (≤$0.01 drift).
159
+ const basisTotal = card.byProject.basis === "api_equivalent"
160
+ ? card.totals.apiEquivalent.amountUsd
161
+ : card.totals.providerBilled.amountUsd;
162
+ if (basisTotal === null) {
163
+ context.addIssue({
164
+ code: "custom",
165
+ path: ["byProject", "basis"],
166
+ message: "A by-project block requires its basis total to exist."
167
+ });
168
+ }
169
+ else {
170
+ const rowSum = card.byProject.rows.reduce((total, row) => total + row.amountUsd, 0) +
171
+ (card.byProject.everythingElse?.amountUsd ?? 0);
172
+ if (Math.abs(rowSum - basisTotal) > 0.011) {
173
+ context.addIssue({
174
+ code: "custom",
175
+ path: ["byProject", "rows"],
176
+ message: "By-project rows plus everything-else must reconcile to the basis total."
177
+ });
178
+ }
179
+ }
180
+ }
181
+ });
182
+ /** Providers whose connected billing is a subscription with no local agent. */
183
+ const providerSubscriptionProviders = new Set(["cursor", "github-copilot"]);
184
+ /**
185
+ * Cost types whose estimated dollars are usage × published API rates — the
186
+ * `api_equivalent` basis. Estimated dollars from any other connected source
187
+ * are provider-reported-but-unverified and stay disclosure-only.
188
+ */
189
+ const apiEquivalentCostTypes = new Set(["local_agent_logs", "anthropic_claude_code_usage"]);
190
+ const unattributedProjectKeys = new Set(["unmapped", "(home)", "home", "unattributed", "unknown", ""]);
191
+ function isUnattributedProject(projectId) {
192
+ return projectId === undefined || unattributedProjectKeys.has(projectId.trim().toLowerCase());
193
+ }
194
+ /**
195
+ * The one basis classifier every surface shares (§1.2): verified dollars are
196
+ * provider_billed; estimated dollars are api_equivalent ONLY when they were
197
+ * priced at published API rates; every other priced-but-unverified dollar is
198
+ * detected_unverified (disclosure-only). Exported so renderers can keep their
199
+ * bars/tables same-kind (QA finding M2) instead of re-deriving basis rules.
200
+ */
201
+ export function classifyResultCardRecordBasis(record, mode) {
202
+ return classifyRecordBasis(record, mode);
203
+ }
204
+ function classifyRecordBasis(record, mode) {
205
+ if (typeof record.amountUsd !== "number")
206
+ return "none";
207
+ if (record.costConfidence === "verified")
208
+ return "provider_billed";
209
+ if (record.costConfidence === "estimated") {
210
+ if (mode === "demo")
211
+ return "api_equivalent";
212
+ return record.providerCostType !== undefined && apiEquivalentCostTypes.has(record.providerCostType)
213
+ ? "api_equivalent"
214
+ : "detected_unverified";
215
+ }
216
+ if (record.costConfidence === "detected_unverified")
217
+ return "detected_unverified";
218
+ return "none";
219
+ }
220
+ function roundCents(amount) {
221
+ return Math.round(amount * 100) / 100;
222
+ }
223
+ function sumOrNull(amounts) {
224
+ if (amounts.length === 0)
225
+ return null;
226
+ return roundCents(amounts.reduce((total, amount) => total + amount, 0));
227
+ }
228
+ /** Strip the provider brand from a detected plan label: "Claude Max 5x" → "Max 5x". */
229
+ function displayPlanLabel(plan) {
230
+ const known = plan.planId
231
+ ? subscriptionPlans.find((candidate) => candidate.id === plan.planId)
232
+ : undefined;
233
+ const label = known?.name ?? plan.planLabel;
234
+ if (!label)
235
+ return null;
236
+ return label.replace(/^Claude /u, "").replace(/^ChatGPT /u, "").trim() || null;
237
+ }
238
+ function committedPriceFor(plan) {
239
+ const known = plan.planId
240
+ ? subscriptionPlans.find((candidate) => candidate.id === plan.planId)
241
+ : undefined;
242
+ return known?.monthlyUsd ?? null;
243
+ }
244
+ function subscriptionDisplayId(agent) {
245
+ return agent === "claude-code" ? "claude" : "chatgpt";
246
+ }
247
+ function subscriptionOrder(id) {
248
+ if (id === "claude")
249
+ return 0;
250
+ if (id === "chatgpt")
251
+ return 1;
252
+ if (id === "cursor")
253
+ return 2;
254
+ return 3;
255
+ }
256
+ function orderRunways(runways) {
257
+ return [...runways]
258
+ .sort((left, right) => {
259
+ const remaining = left.remainingPercent - right.remainingPercent;
260
+ if (remaining !== 0)
261
+ return remaining;
262
+ const reset = Date.parse(left.resetsAt) - Date.parse(right.resetsAt);
263
+ if (reset !== 0)
264
+ return reset;
265
+ return left.kind === right.kind ? 0 : left.kind === "five-hour" ? -1 : 1;
266
+ })
267
+ .slice(0, 2);
268
+ }
269
+ /** Project names clip at 24 chars + `…` on every renderer (§1.1). */
270
+ export function clipResultCardProjectName(name) {
271
+ const characters = [...name];
272
+ if (characters.length <= 24)
273
+ return name;
274
+ return `${characters.slice(0, 24).join("")}…`;
275
+ }
276
+ /**
277
+ * Largest-remainder integer percentages: printed shares sum to exactly 100.
278
+ * Input weights need not be normalized.
279
+ */
280
+ export function largestRemainderPercents(weights) {
281
+ return largestRemainderUnits(weights, 100);
282
+ }
283
+ function largestRemainderUnits(weights, totalUnits) {
284
+ const total = weights.reduce((sum, weight) => sum + weight, 0);
285
+ if (total <= 0 || weights.length === 0)
286
+ return weights.map(() => 0);
287
+ const exact = weights.map((weight) => (weight / total) * totalUnits);
288
+ const floors = exact.map((value) => Math.floor(value));
289
+ let remaining = totalUnits - floors.reduce((sum, value) => sum + value, 0);
290
+ const byRemainder = exact
291
+ .map((value, index) => ({ index, remainder: value - Math.floor(value) }))
292
+ .sort((left, right) => right.remainder - left.remainder || left.index - right.index);
293
+ const result = [...floors];
294
+ for (const { index } of byRemainder) {
295
+ if (remaining <= 0)
296
+ break;
297
+ result[index] = (result[index] ?? 0) + 1;
298
+ remaining -= 1;
299
+ }
300
+ return result;
301
+ }
302
+ /**
303
+ * Build the canonical result card from window-scoped usage records plus
304
+ * locally detected plans. Pure and deterministic; every §1.1 rule lives here,
305
+ * not in the renderers.
306
+ */
307
+ export function buildResultCard(input) {
308
+ const windowDays = input.windowDays ?? 30;
309
+ const mode = input.mode;
310
+ const records = input.records;
311
+ // --- basis-wide sums (same-kind money is summable; cross-kind never) -----
312
+ const apiEquivalentAmounts = [];
313
+ const providerBilledAmounts = [];
314
+ for (const record of records) {
315
+ const basis = classifyRecordBasis(record, mode);
316
+ if (basis === "api_equivalent")
317
+ apiEquivalentAmounts.push(record.amountUsd ?? 0);
318
+ if (basis === "provider_billed")
319
+ providerBilledAmounts.push(record.amountUsd ?? 0);
320
+ }
321
+ const apiEquivalentTotal = sumOrNull(apiEquivalentAmounts);
322
+ const providerBilledTotal = sumOrNull(providerBilledAmounts);
323
+ // --- subscription rows ---------------------------------------------------
324
+ // Sample/demo mode: real detected plans never mix into demo output (§1.1).
325
+ const subscriptions = [];
326
+ if (mode !== "demo") {
327
+ const detectedSubscriptions = (input.detectedPlans ?? []).filter((plan) => plan.billing === "subscription" && (plan.agent === "claude-code" || plan.agent === "codex"));
328
+ const seenAgents = new Set();
329
+ for (const plan of detectedSubscriptions) {
330
+ if (seenAgents.has(plan.agent))
331
+ continue;
332
+ seenAgents.add(plan.agent);
333
+ const agentRecords = records.filter((record) => record.agentId === plan.agent);
334
+ const agentApiAmounts = agentRecords
335
+ .filter((record) => classifyRecordBasis(record, mode) === "api_equivalent")
336
+ .map((record) => record.amountUsd ?? 0);
337
+ const agentBilledAmounts = agentRecords
338
+ .filter((record) => classifyRecordBasis(record, mode) === "provider_billed")
339
+ .map((record) => record.amountUsd ?? 0);
340
+ subscriptions.push({
341
+ id: subscriptionDisplayId(plan.agent),
342
+ agentId: plan.agent,
343
+ planLabel: displayPlanLabel(plan),
344
+ connection: "local_logs",
345
+ committedUsdPerMonth: committedPriceFor(plan),
346
+ apiEquivalentUsd: sumOrNull(agentApiAmounts),
347
+ providerBilledUsd: sumOrNull(agentBilledAmounts),
348
+ detectedUnverifiedUsd: null,
349
+ runways: orderRunways(input.runways?.[plan.agent] ?? [])
350
+ });
351
+ }
352
+ // Provider-billed subscriptions with no local agent (cursor today). The
353
+ // beta cursor connector hard-codes estimated confidence, so its dollars
354
+ // land in detectedUnverifiedUsd — never providerBilledUsd — until the
355
+ // connector is live-verified.
356
+ const providersSeen = [...new Set(records
357
+ .map((record) => record.source.provider)
358
+ .filter((provider) => providerSubscriptionProviders.has(provider)))].sort();
359
+ for (const provider of providersSeen) {
360
+ const providerRecords = records.filter((record) => record.source.provider === provider);
361
+ const billedAmounts = providerRecords
362
+ .filter((record) => classifyRecordBasis(record, mode) === "provider_billed")
363
+ .map((record) => record.amountUsd ?? 0);
364
+ const detectedAmounts = providerRecords
365
+ .filter((record) => classifyRecordBasis(record, mode) === "detected_unverified")
366
+ .map((record) => record.amountUsd ?? 0);
367
+ const providerPlan = input.providerPlans?.find((plan) => plan.provider === provider);
368
+ subscriptions.push({
369
+ id: provider,
370
+ agentId: null,
371
+ planLabel: providerPlan?.planLabel ?? null,
372
+ connection: "connected",
373
+ committedUsdPerMonth: providerPlan?.committedUsdPerMonth ?? null,
374
+ apiEquivalentUsd: null,
375
+ providerBilledUsd: sumOrNull(billedAmounts),
376
+ detectedUnverifiedUsd: sumOrNull(detectedAmounts),
377
+ runways: []
378
+ });
379
+ }
380
+ subscriptions.sort((left, right) => subscriptionOrder(left.id) - subscriptionOrder(right.id) || left.id.localeCompare(right.id));
381
+ }
382
+ // --- totals stack (never blended) ----------------------------------------
383
+ const pricedSubs = subscriptions.filter((row) => row.committedUsdPerMonth !== null);
384
+ const totals = {
385
+ subscriptionCommitted: {
386
+ amountUsd: pricedSubs.length > 0
387
+ ? roundCents(pricedSubs.reduce((total, row) => total + (row.committedUsdPerMonth ?? 0), 0))
388
+ : null,
389
+ pricedSubs: pricedSubs.length,
390
+ totalSubs: subscriptions.length
391
+ },
392
+ apiEquivalent: {
393
+ amountUsd: apiEquivalentTotal,
394
+ financialEvidence: apiEquivalentTotal === null ? "missing" : "estimated"
395
+ },
396
+ providerBilled: {
397
+ amountUsd: providerBilledTotal,
398
+ financialEvidence: providerBilledTotal === null ? "missing" : "verified"
399
+ },
400
+ blended: null,
401
+ blendPolicy: "never_blended"
402
+ };
403
+ return resultCardSchema.parse({
404
+ kind: "aibill.result_card",
405
+ schemaVersion: 1,
406
+ currency: "USD",
407
+ windowDays,
408
+ mode,
409
+ subscriptions,
410
+ totals,
411
+ byProject: buildByProject(records, mode, totals)
412
+ });
413
+ }
414
+ function buildByProject(records, mode, totals) {
415
+ // The card's primary basis follows the MODE (§3: local → api_equivalent,
416
+ // connected → provider_billed), falling back to the other basis only when
417
+ // the preferred one has no money at all. QA finding M3: the first verified
418
+ // billed row must never hijack by-project away from rich local attribution
419
+ // in local/mixed modes.
420
+ const preferred = mode === "connected" ? "provider_billed" : "api_equivalent";
421
+ const preferredTotal = preferred === "provider_billed"
422
+ ? totals.providerBilled.amountUsd
423
+ : totals.apiEquivalent.amountUsd;
424
+ const fallback = preferred === "provider_billed" ? "api_equivalent" : "provider_billed";
425
+ const fallbackTotal = fallback === "provider_billed"
426
+ ? totals.providerBilled.amountUsd
427
+ : totals.apiEquivalent.amountUsd;
428
+ const basis = preferredTotal !== null ? preferred : fallbackTotal !== null ? fallback : null;
429
+ if (basis === null)
430
+ return null;
431
+ const basisTotal = basis === "provider_billed"
432
+ ? totals.providerBilled.amountUsd
433
+ : totals.apiEquivalent.amountUsd;
434
+ if (basisTotal <= 0)
435
+ return null;
436
+ const basisRecords = records.filter((record) => classifyRecordBasis(record, mode) === basis);
437
+ const named = new Map();
438
+ let unattributedAmount = 0;
439
+ for (const record of basisRecords) {
440
+ const amount = record.amountUsd ?? 0;
441
+ if (isUnattributedProject(record.projectId)) {
442
+ unattributedAmount += amount;
443
+ }
444
+ else {
445
+ const key = record.projectId;
446
+ named.set(key, (named.get(key) ?? 0) + amount);
447
+ }
448
+ }
449
+ const namedSorted = [...named.entries()]
450
+ .filter(([, amount]) => amount > 0)
451
+ .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0]));
452
+ if (namedSorted.length === 0 && unattributedAmount <= 0)
453
+ return null;
454
+ // byProject spans the ENTIRE primary basis: top-2 named projects + the
455
+ // unattributed row (never hidden, never renamed) + everything else.
456
+ const topNamed = namedSorted.slice(0, 2);
457
+ const restNamed = namedSorted.slice(2);
458
+ const restAmount = restNamed.reduce((total, [, amount]) => total + amount, 0);
459
+ const buckets = [
460
+ ...topNamed.map(([project, amount]) => ({ kind: "named", project, amount })),
461
+ ...(unattributedAmount > 0
462
+ ? [{ kind: "unattributed", amount: unattributedAmount }]
463
+ : []),
464
+ ...(restNamed.length > 0
465
+ ? [{ kind: "everything_else", amount: restAmount }]
466
+ : [])
467
+ ];
468
+ // Exact reconciliation: cent-round every bucket, then absorb the residual
469
+ // rounding drift into the largest bucket so rows + everythingElse equal the
470
+ // basis total to the cent (≤$0.01 drift rule met by construction).
471
+ const rounded = buckets.map((bucket) => roundCents(bucket.amount));
472
+ const drift = roundCents(basisTotal - rounded.reduce((total, amount) => total + amount, 0));
473
+ if (drift !== 0) {
474
+ const largestIndex = rounded.reduce((best, amount, index) => (amount > (rounded[best] ?? 0) ? index : best), 0);
475
+ rounded[largestIndex] = roundCents((rounded[largestIndex] ?? 0) + drift);
476
+ }
477
+ // Machine shares: 4-decimal largest-remainder fractions summing to 1.0000.
478
+ const shareUnits = largestRemainderUnits(rounded, 10_000);
479
+ const shares = shareUnits.map((units) => units / 10_000);
480
+ const rows = [];
481
+ let everythingElse = null;
482
+ buckets.forEach((bucket, index) => {
483
+ if (bucket.kind === "everything_else") {
484
+ everythingElse = {
485
+ amountUsd: rounded[index] ?? 0,
486
+ share: shares[index] ?? 0,
487
+ projectCount: restNamed.length
488
+ };
489
+ return;
490
+ }
491
+ rows.push({
492
+ project: bucket.kind === "unattributed"
493
+ ? resultCardVocabulary.unattributed
494
+ : clipResultCardProjectName(bucket.project ?? ""),
495
+ amountUsd: rounded[index] ?? 0,
496
+ share: shares[index] ?? 0,
497
+ unattributed: bucket.kind === "unattributed"
498
+ });
499
+ });
500
+ if (rows.length === 0)
501
+ return null;
502
+ return { basis, rows, everythingElse };
503
+ }
504
+ /**
505
+ * The improve card's PROJECT line (§3): the CURRENT project's standing on the
506
+ * card's primary basis — one line, N=1 plus context. Returns undefined when
507
+ * no project attribution exists for the current directory (the line is
508
+ * omitted, never fabricated). Rank counts named projects; the unattributed
509
+ * bucket is excluded from ranking but included in the denominator total.
510
+ */
511
+ export function buildResultCardProjectLine(input) {
512
+ const byProject = input.card.byProject;
513
+ if (!byProject)
514
+ return undefined;
515
+ const basis = byProject.basis;
516
+ const basisTotal = basis === "provider_billed"
517
+ ? input.card.totals.providerBilled.amountUsd
518
+ : input.card.totals.apiEquivalent.amountUsd;
519
+ if (basisTotal === null || basisTotal <= 0)
520
+ return undefined;
521
+ const named = new Map();
522
+ let unattributedAmount = 0;
523
+ for (const record of input.records) {
524
+ if (classifyRecordBasis(record, input.card.mode) !== basis)
525
+ continue;
526
+ const amount = record.amountUsd ?? 0;
527
+ if (isUnattributedProject(record.projectId)) {
528
+ unattributedAmount += amount;
529
+ }
530
+ else {
531
+ named.set(record.projectId, (named.get(record.projectId) ?? 0) + amount);
532
+ }
533
+ }
534
+ const formatAmount = (amount) => basis === "provider_billed"
535
+ ? formatBilledUsdExact(amount)
536
+ : formatApproxUsd(amount);
537
+ const basisWord = basis === "provider_billed" ? "billed" : "API-equivalent";
538
+ const windowSuffix = `${input.card.windowDays}d`;
539
+ // QA MINOR-2: one bucket, one percentage. When the current bucket is also
540
+ // a by-project row, reuse the card's largest-remainder display percent so
541
+ // the same money never shows two different shares across surfaces.
542
+ const bucketWeights = [
543
+ ...byProject.rows.map((row) => row.amountUsd),
544
+ ...(byProject.everythingElse ? [byProject.everythingElse.amountUsd] : [])
545
+ ];
546
+ const bucketPercents = largestRemainderPercents(bucketWeights);
547
+ const cardPercentFor = (predicate) => {
548
+ const index = byProject.rows.findIndex(predicate);
549
+ return index >= 0 ? bucketPercents[index] : undefined;
550
+ };
551
+ if (input.currentProjectId === undefined || isUnattributedProject(input.currentProjectId)) {
552
+ if (unattributedAmount <= 0)
553
+ return undefined;
554
+ const amount = roundCents(unattributedAmount);
555
+ const percent = cardPercentFor((row) => row.unattributed) ??
556
+ Math.round((amount / basisTotal) * 100);
557
+ return `${resultCardVocabulary.unattributed} · ${formatAmount(amount)} of ` +
558
+ `${formatAmount(basisTotal)} ${basisWord} (${percent}%, ${windowSuffix})`;
559
+ }
560
+ const currentAmount = named.get(input.currentProjectId);
561
+ if (currentAmount === undefined || currentAmount <= 0)
562
+ return undefined;
563
+ const amount = roundCents(currentAmount);
564
+ const clippedName = clipResultCardProjectName(input.currentProjectId);
565
+ const percent = cardPercentFor((row) => !row.unattributed && row.project === clippedName) ??
566
+ Math.round((amount / basisTotal) * 100);
567
+ const rank = 1 + [...named.values()].filter((value) => value > currentAmount).length;
568
+ const projectCount = named.size;
569
+ return `${clippedName} · ${formatAmount(amount)} of ` +
570
+ `${formatAmount(basisTotal)} ${basisWord} (${percent}%, ${windowSuffix}) · ` +
571
+ `rank ${rank} of ${projectCount} project${projectCount === 1 ? "" : "s"}`;
572
+ }
573
+ // --- shared renderer formatting (one grammar for every surface, §1.2) -------
574
+ /** `committed $320/mo` amount part: "$320/mo" (whole dollars, list prices). */
575
+ export function formatCommittedPerMonth(amountUsd) {
576
+ return `$${formatWholeUsdNumber(amountUsd)}/mo`;
577
+ }
578
+ /**
579
+ * API-equivalent figures always carry `~` and round to whole dollars.
580
+ * Real-but-tiny usage prints `~<$1` (QA MINOR-4): `~$0` reads as absence.
581
+ */
582
+ export function formatApproxUsd(amountUsd) {
583
+ if (amountUsd > 0 && Math.round(amountUsd) === 0)
584
+ return "~<$1";
585
+ return `~$${formatWholeUsdNumber(amountUsd)}`;
586
+ }
587
+ function formatWholeUsdNumber(amountUsd) {
588
+ return Math.round(amountUsd).toLocaleString("en-US");
589
+ }
590
+ /** Provider-billed money is never compacted, rounded, or approximated. */
591
+ export function formatBilledUsdExact(amountUsd) {
592
+ const text = String(amountUsd);
593
+ if (/[eE]/u.test(text))
594
+ return `$${text}`;
595
+ const [whole, fraction] = text.split(".");
596
+ const grouped = (whole ?? "0").replace(/\B(?=(\d{3})+(?!\d))/gu, ",");
597
+ const decimal = fraction === undefined
598
+ ? ".00"
599
+ : fraction.length === 1
600
+ ? `.${fraction}0`
601
+ : `.${fraction}`;
602
+ return `$${grouped}${decimal}`;
603
+ }
604
+ //# sourceMappingURL=resultCard.js.map
@@ -0,0 +1,36 @@
1
+ export type AibillImproveDeliveryV0 = "source_preview" | "published";
2
+ /**
3
+ * Distribution truth for the guided action loop.
4
+ *
5
+ * Release gate: change this single value to `published` only in the exact,
6
+ * coordinated new-version release candidate. Its packed-install gate must
7
+ * prove every generated handoff uses the new package command before publish;
8
+ * the clean public-registry smoke then verifies that same exact commit. Until
9
+ * that release candidate exists, handoffs execute the already-built checkout
10
+ * and must not let `npx` download the older public package.
11
+ */
12
+ export declare const AIBILL_IMPROVE_DELIVERY_V0: AibillImproveDeliveryV0;
13
+ /**
14
+ * Build a command for capabilities that exist only in the current source
15
+ * preview. Keep every generated handoff on the checkout until the coordinated
16
+ * npm release containing those capabilities has passed its registry smoke.
17
+ */
18
+ export declare function aibillCommandV0(args: string, delivery?: AibillImproveDeliveryV0): string;
19
+ /** One privacy-safe command shared by terminal, MCP, and Glance. */
20
+ export declare function aibillImproveCommandV0(delivery?: AibillImproveDeliveryV0): string;
21
+ /**
22
+ * Version-pinned command for machine-composed lines (M4c): a command an AI
23
+ * client relays to a human must be reproducible and must not silently
24
+ * resolve to a different release, so `draft_improve_command` pins to the
25
+ * composing package's own version (`npx aibill@<version> …`). A version
26
+ * that is not a plain semver falls back to the unpinned published command
27
+ * rather than composing an unrunnable line. In source-preview builds the
28
+ * checkout command needs no pin.
29
+ *
30
+ * Release gate (n2): the coordinated release must also prove the pinned
31
+ * version EXISTS on the public registry and supports the composed flags —
32
+ * the packed-install gate described on AIBILL_IMPROVE_DELIVERY_V0 is the
33
+ * natural home for that check; QA 24 asserts only that the pin is present.
34
+ */
35
+ export declare function aibillPinnedCommandV0(args: string, version: string, delivery?: AibillImproveDeliveryV0): string;
36
+ //# sourceMappingURL=runtimeCommands.d.ts.map