@agentguard-run/spend 0.15.1 → 0.15.3

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.
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'crypto';
2
+ import { resolve as resolvePath } from 'path';
2
3
  import { inferProvider } from '../cost-table';
3
4
  import { canonicalJson, computeSignerFingerprint, sha256Hex } from '../decision-log';
4
5
  import { signDagNode, verifyReceiptDag, type ReceiptCapabilityLevel, type ReceiptDagEnvelope, type ReceiptDagNode } from '../receipts/dag';
@@ -7,7 +8,8 @@ import type { CallContext, CapabilityTier, Provider, SignedDecisionLogEntry, Spe
7
8
  import { safeRequestShape } from './common';
8
9
 
9
10
  export type HermesKanbanProfile = 'researcher' | 'writer' | 'reviewer' | 'director' | 'orchestrator' | string;
10
- export type HermesKanbanTaskState = 'governed' | 'running' | 'completed' | 'revoked';
11
+ export type HermesKanbanTaskState = 'triage' | 'blocked' | 'scheduled' | 'running' | 'complete' | 'revoked';
12
+ export type HermesKanbanGovernanceMode = 'lightweight' | 'full';
11
13
 
12
14
  export interface HermesKanbanProfilePolicy {
13
15
  capability: ReceiptCapabilityLevel;
@@ -23,6 +25,8 @@ export interface HermesKanbanAdapterOptions {
23
25
  defaultModel?: string;
24
26
  defaultCapCents?: number;
25
27
  runtimeBudgetCentsPerMinute?: number;
28
+ lightweightThresholdCents?: number;
29
+ lightweightCapabilityCeiling?: ReceiptCapabilityLevel;
26
30
  profileMap?: Record<string, HermesKanbanProfilePolicy>;
27
31
  reviewerCascade?: {
28
32
  enabled?: boolean;
@@ -39,6 +43,7 @@ export interface HermesKanbanTaskInput {
39
43
  boardSlug: string;
40
44
  profile: HermesKanbanProfile;
41
45
  parentTaskIds?: string[];
46
+ workspacePath?: string;
42
47
  maxRuntimeMinutes?: number;
43
48
  maxRuntimeMs?: number;
44
49
  metadata?: Record<string, unknown>;
@@ -56,6 +61,8 @@ export interface HermesKanbanEnvelope {
56
61
  capCents: number;
57
62
  parentTaskIds: string[];
58
63
  parentEntryHashes: string[];
64
+ workspacePath?: string;
65
+ governanceMode: HermesKanbanGovernanceMode;
59
66
  scope: SpendScope;
60
67
  builderCode?: string;
61
68
  createdAt: string;
@@ -101,6 +108,7 @@ export interface HermesKanbanReceiptInput {
101
108
  tokenCounts?: { inputTokens?: number; outputTokens?: number };
102
109
  totalCostCents?: number;
103
110
  metadata?: Record<string, unknown>;
111
+ workspacePath?: string;
104
112
  highRiskClassifierScore?: number;
105
113
  keywordRisk?: boolean;
106
114
  reviewerVerdict?: 'drafter_only' | 'review_required' | 'approved' | 'rejected';
@@ -146,6 +154,8 @@ const SAFE_METADATA_KEYS = new Set([
146
154
  'ticket_id',
147
155
  'token_count',
148
156
  'workflow_id',
157
+ 'workspace_hash',
158
+ 'workspace_path',
149
159
  ]);
150
160
 
151
161
  export function createHermesKanbanAdapter(opts: HermesKanbanAdapterOptions): HermesKanbanAdapter {
@@ -177,12 +187,14 @@ export class HermesKanbanAdapter {
177
187
  taskHash,
178
188
  boardHash,
179
189
  profile: normalizeProfile(input.profile),
180
- state: 'governed',
190
+ state: 'running',
181
191
  capabilityCeiling: profilePolicy.capability,
182
192
  toolAllowlist: [...(profilePolicy.toolAllowlist ?? [])],
183
193
  capCents,
184
194
  parentTaskIds: [...new Set(input.parentTaskIds ?? [])],
185
195
  parentEntryHashes: [],
196
+ workspacePath: resolveWorkspacePath(input.workspacePath),
197
+ governanceMode: governanceModeFor(profilePolicy.capability, capCents, this.opts),
186
198
  scope,
187
199
  builderCode: safeBuilderCode(input.builderCode),
188
200
  createdAt: new Date().toISOString(),
@@ -263,17 +275,21 @@ export class HermesKanbanAdapter {
263
275
  };
264
276
  }
265
277
 
266
- async revokeTask(taskId: string): Promise<HermesKanbanReceiptResult> {
278
+ async revokeTask(taskId: string, reason = 'stale_lock_reclaimed'): Promise<HermesKanbanReceiptResult> {
267
279
  const envelope = this.requireEnvelope(taskId);
268
280
  if (envelope.state === 'revoked') return this.receiptResult(envelope, this.requireNode(taskId));
269
281
  envelope.state = 'revoked';
270
282
  envelope.revokedAt = new Date().toISOString();
283
+ const finalSpendCents = this.spendCentsSoFar(taskId);
271
284
  const node = await this.signTaskNode(envelope, {
272
285
  event: 'revoked',
273
286
  action: 'block',
274
287
  blocked: true,
275
288
  capability: envelope.capabilityCeiling,
276
289
  trustScore: 0.7,
290
+ revocationReason: safeLabel(reason) ?? 'stale_lock_reclaimed',
291
+ finalSpendCents,
292
+ postRevocationSpendCents: 0,
277
293
  });
278
294
  this.nodesByTaskId.set(taskId, node);
279
295
  this.nodes.push(node);
@@ -284,7 +300,8 @@ export class HermesKanbanAdapter {
284
300
  const envelope = this.requireEnvelope(input.taskId);
285
301
  assertActive(envelope);
286
302
  assertMetadataOnly(input.metadata ?? {});
287
- envelope.state = 'completed';
303
+ if (input.workspacePath) envelope.workspacePath = resolveWorkspacePath(input.workspacePath);
304
+ envelope.state = 'complete';
288
305
  envelope.completedAt = new Date().toISOString();
289
306
  const parentTaskIds = [...new Set(input.parentTaskIds ?? envelope.parentTaskIds)];
290
307
  const parentNodes = parentTaskIds.map((id) => this.nodesByTaskId.get(id)).filter(Boolean) as ReceiptDagNode[];
@@ -346,6 +363,9 @@ export class HermesKanbanAdapter {
346
363
  tokenCounts?: { inputTokens?: number; outputTokens?: number };
347
364
  totalCostCents?: number;
348
365
  reviewerCascade?: Record<string, unknown>;
366
+ revocationReason?: string;
367
+ finalSpendCents?: number;
368
+ postRevocationSpendCents?: number;
349
369
  },
350
370
  ): Promise<ReceiptDagNode> {
351
371
  if (!this.config.signingKeys) throw new Error('Hermes Kanban receipts require signingKeys');
@@ -373,6 +393,15 @@ export class HermesKanbanAdapter {
373
393
  };
374
394
  }
375
395
 
396
+ private spendCentsSoFar(taskId: string): number {
397
+ let total = 0;
398
+ for (const node of this.nodes) {
399
+ const receipt = node.decision?.outcomeReceipt as Record<string, unknown> | undefined;
400
+ if (receipt?.taskId === taskId) total += safeNonNegativeInteger(receipt.totalCostCents);
401
+ }
402
+ return total;
403
+ }
404
+
376
405
  private requireEnvelope(taskId: string): HermesKanbanEnvelope {
377
406
  const envelope = this.envelopes.get(taskId);
378
407
  if (!envelope) throw new Error(`Unknown Hermes Kanban task ${taskId}`);
@@ -413,6 +442,9 @@ function taskDecision(
413
442
  tokenCounts?: { inputTokens?: number; outputTokens?: number };
414
443
  totalCostCents?: number;
415
444
  reviewerCascade?: Record<string, unknown>;
445
+ revocationReason?: string;
446
+ finalSpendCents?: number;
447
+ postRevocationSpendCents?: number;
416
448
  },
417
449
  policy: SpendPolicy,
418
450
  ): SpendDecision {
@@ -433,6 +465,10 @@ function taskDecision(
433
465
  parentTaskIds: [...envelope.parentTaskIds],
434
466
  parentEntryHashes: parentNodes.map((node) => node.entryHash),
435
467
  parentMsgHashes: parentNodes.map((node) => node.msgHash),
468
+ boardScopeHash: envelope.boardHash,
469
+ workspaceProvenance: envelope.workspacePath ? { type: 'directory', location: `dir:${envelope.workspacePath}` } : null,
470
+ governanceMode: envelope.governanceMode,
471
+ lightweight: envelope.governanceMode === 'lightweight',
436
472
  modelIds: safeModelIds(args.modelIds ?? []),
437
473
  inputTokens,
438
474
  outputTokens,
@@ -440,6 +476,15 @@ function taskDecision(
440
476
  metadata: filterReceiptMetadata(args.metadata ?? {}),
441
477
  };
442
478
  if (args.reviewerCascade) receipt.reviewerCascade = args.reviewerCascade;
479
+ if (args.event === 'revoked') {
480
+ receipt.revocation = {
481
+ reason: args.revocationReason ?? 'stale_lock_reclaimed',
482
+ finalSpendCents: safeNonNegativeInteger(args.finalSpendCents),
483
+ postRevocationSpendCents: safeNonNegativeInteger(args.postRevocationSpendCents),
484
+ closedAt: envelope.revokedAt ?? new Date().toISOString(),
485
+ assertion: 'no_spend_after_revoke',
486
+ };
487
+ }
443
488
  const decision: SpendDecision = {
444
489
  decisionId: randomUUID(),
445
490
  timestamp: new Date().toISOString(),
@@ -481,15 +526,29 @@ function buildReviewerCascadeReceipt(input: HermesKanbanReceiptInput, opts: Herm
481
526
  }
482
527
 
483
528
  function reviewerCascadeTriggers(envelope: HermesKanbanEnvelope, input: HermesKanbanReceiptInput, opts: HermesKanbanAdapterOptions): string[] {
484
- if (opts.reviewerCascade?.enabled === false) return [];
529
+ if (opts.reviewerCascade?.enabled === false || envelope.governanceMode === 'lightweight') return [];
485
530
  const threshold = opts.reviewerCascade?.highRiskThreshold ?? 0.55;
486
531
  const triggers: string[] = [];
487
- if (envelope.profile === 'reviewer') triggers.push('reviewer_profile');
488
532
  if (typeof input.highRiskClassifierScore === 'number' && input.highRiskClassifierScore >= threshold) triggers.push(`risk_score_above:${threshold}`);
489
533
  if (input.keywordRisk === true) triggers.push('keyword_prefilter');
490
534
  return [...new Set(triggers)];
491
535
  }
492
536
 
537
+
538
+ function governanceModeFor(capability: ReceiptCapabilityLevel, capCents: number, opts: HermesKanbanAdapterOptions): HermesKanbanGovernanceMode {
539
+ const threshold = opts.lightweightThresholdCents ?? 5;
540
+ const ceiling = opts.lightweightCapabilityCeiling ?? 'READ_ONLY';
541
+ return CAPABILITY_RANK[capability] <= CAPABILITY_RANK[ceiling] && capCents <= threshold ? 'lightweight' : 'full';
542
+ }
543
+
544
+ function resolveWorkspacePath(value: unknown): string | undefined {
545
+ if (typeof value !== 'string') return undefined;
546
+ const trimmed = value.trim();
547
+ if (!trimmed) return undefined;
548
+ if (trimmed.length > 1024 || /[\u0000-\u001f\u007f]/.test(trimmed)) throw new Error('Hermes Kanban workspacePath must be a metadata path');
549
+ return resolvePath(trimmed);
550
+ }
551
+
493
552
  function capForTask(input: HermesKanbanTaskInput, profilePolicy: HermesKanbanProfilePolicy, opts: HermesKanbanAdapterOptions): number {
494
553
  if (Number.isSafeInteger(profilePolicy.capCents) && profilePolicy.capCents! >= 0) return profilePolicy.capCents!;
495
554
  const runtimeMinutes = runtimeMinutesFor(input);
@@ -513,7 +572,7 @@ function spendCapabilityForReceiptCapability(capability: ReceiptCapabilityLevel)
513
572
 
514
573
  function assertTaskInput(input: HermesKanbanTaskInput): void {
515
574
  assertPlainRecord(input, 'task');
516
- const allowed = new Set(['taskId', 'boardSlug', 'profile', 'parentTaskIds', 'maxRuntimeMinutes', 'maxRuntimeMs', 'metadata', 'builderCode']);
575
+ const allowed = new Set(['taskId', 'boardSlug', 'profile', 'parentTaskIds', 'workspacePath', 'maxRuntimeMinutes', 'maxRuntimeMs', 'metadata', 'builderCode']);
517
576
  for (const key of Object.keys(input as unknown as Record<string, unknown>)) {
518
577
  if (!allowed.has(key)) throw new Error(`Hermes Kanban task input cannot include data-plane or unknown field: ${key}`);
519
578
  }
@@ -545,7 +604,7 @@ function assertPlainRecord(value: unknown, name: string): void {
545
604
 
546
605
  function assertActive(envelope: HermesKanbanEnvelope): void {
547
606
  if (envelope.state === 'revoked') throw new AgentGuardBlockedError(blockedDecision(envelope, 'revoked'), envelope.scope);
548
- if (envelope.state === 'completed') throw new AgentGuardBlockedError(blockedDecision(envelope, 'completed'), envelope.scope);
607
+ if (envelope.state === 'complete') throw new AgentGuardBlockedError(blockedDecision(envelope, 'complete'), envelope.scope);
549
608
  }
550
609
 
551
610
  function blockedDecision(envelope: HermesKanbanEnvelope, state: string): SpendDecision {
@@ -0,0 +1,68 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { workloadProvenance, formatLineage } from './portability';
4
+ import type { SignedDecisionLogEntry, SpendDecision, Provider } from '../types';
5
+ import type { ProvenanceProvider } from './schema';
6
+
7
+ let seq = 0;
8
+ function entry(model: string, fam: ProvenanceProvider, route: string, extra: Partial<SpendDecision> = {}): SignedDecisionLogEntry {
9
+ const decision: SpendDecision = {
10
+ decisionId: `d${seq}`, timestamp: `2026-07-10T14:${String(seq).padStart(2, '0')}:00.000Z`,
11
+ action: 'allow', triggeredCap: null, triggeredScopeKey: 'tenant:acme|agent:planner',
12
+ projectedCents: 10, actualCents: 10, windowSpendBefore: 0, windowSpendAfter: 0,
13
+ provider: (fam === 'self_hosted' ? 'unknown' : fam) as Provider,
14
+ modelRequested: model, modelResolved: model, policyId: 'p', policyVersion: 1,
15
+ enforcementMode: 'enforce', reasons: [],
16
+ provenance: {
17
+ model_identity: { provider: fam, model_id: model, model_version: '1', model_family: fam, weights_origin_country: 'US' },
18
+ hosting: { provider_route: route, jurisdiction_country: 'US', jurisdiction_region: 'us' },
19
+ compliance: { baa_in_force: false, baa_vendor: null, hipaa_eligible: false, data_retention_days: null, data_residency_attested: false, foreign_origin_weight_flag: false, foreign_origin_consent_receipt_id: null, inference_billing: 'customer_managed' },
20
+ captured_at: '2026-07-10T14:00:00.000Z',
21
+ },
22
+ ...extra,
23
+ };
24
+ return { sequence: seq++, decision, previousHash: '0'.repeat(64), entryHash: 'x', signature: 'y', signerFingerprint: 'f' };
25
+ }
26
+
27
+ test('lineage, swaps, and boundary crossing across a hot-swap sequence', () => {
28
+ seq = 0;
29
+ const entries = [
30
+ entry('claude-5', 'anthropic', 'anthropic-api'),
31
+ entry('claude-5', 'anthropic', 'anthropic-api'), // repeat: no swap
32
+ entry('gpt-5-mini', 'openai', 'openai-api'), // frontier→frontier swap
33
+ entry('glm-5.2', 'self_hosted', 'self-hosted-gpu'), // frontier→dark swap (boundary)
34
+ ];
35
+ const report = workloadProvenance(entries, { verified: true, entries: entries.length });
36
+ assert.equal(report.workloads.length, 1);
37
+ const w = report.workloads[0];
38
+ assert.deepEqual(w.lineage, ['claude-5', 'gpt-5-mini', 'glm-5.2']);
39
+ assert.equal(formatLineage(w), 'claude-5 → gpt-5-mini → glm-5.2');
40
+ assert.equal(w.swaps.length, 2);
41
+ assert.equal(w.swaps[0].crossesBoundary, false); // frontier→frontier
42
+ assert.equal(w.swaps[1].crossesBoundary, true); // frontier→dark
43
+ assert.equal(w.swaps[1].fromModel, 'gpt-5-mini');
44
+ assert.equal(w.swaps[1].toModel, 'glm-5.2');
45
+ assert.equal(w.darkCents, 10);
46
+ assert.equal(w.frontierCents, 30);
47
+ assert.equal(report.continuity.verified, true); // one chain spans all swaps
48
+ });
49
+
50
+ test('groups by keyOf; separate workloads do not merge', () => {
51
+ seq = 0;
52
+ const a = entry('claude-5', 'anthropic', 'api', { triggeredScopeKey: 'wf:A' });
53
+ const b = entry('glm-5.2', 'self_hosted', 'self', { triggeredScopeKey: 'wf:B' });
54
+ const report = workloadProvenance([a, b], { verified: true, entries: 2 });
55
+ assert.equal(report.workloads.length, 2);
56
+ assert.ok(report.workloads.every(w => w.swaps.length === 0));
57
+ });
58
+
59
+ test('settlement/outcome entries add no model steps', () => {
60
+ seq = 0;
61
+ const entries = [
62
+ entry('claude-5', 'anthropic', 'api'),
63
+ entry('claude-5', 'anthropic', 'api', { entryType: 'settlement', actualCents: 9 }),
64
+ ];
65
+ const w = workloadProvenance(entries, { verified: true, entries: 2 }).workloads[0];
66
+ assert.equal(w.calls, 1);
67
+ assert.equal(w.swaps.length, 0);
68
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * AgentGuard® — model-agnostic provenance that survives hot-swap.
3
+ *
4
+ * The "headless" / model-fungibility problem (raised on All-In E280): teams want
5
+ * to hot-swap models — frontier → open-weight → self-hosted — per task to cut
6
+ * cost, but "no one has figured out how to abstract memory/context/AUDIT away
7
+ * from the model." A provider console can't help: it only sees its own tokens,
8
+ * and the trail breaks the moment you route to a different provider.
9
+ *
10
+ * AgentGuard's signed decision log already records, per call, which model served
11
+ * it (provenance) inside one continuous hash chain. This module reads that log
12
+ * and produces a PORTABLE, model-agnostic provenance manifest per workload: the
13
+ * ordered model lineage, every swap, the frontier/dark origin mix, and a single
14
+ * tamper-evidence verdict that spans the whole swap sequence. The audit is keyed
15
+ * to the workload, not the model — so it survives the swap.
16
+ *
17
+ * Honest scope note (same as the dashboard): the signed SpendDecision does not
18
+ * carry a first-class workload/agent id (only `triggeredScopeKey`). Callers pass
19
+ * a `keyOf` selector to group; the default uses `triggeredScopeKey`. We never
20
+ * invent a grouping the ledger does not support.
21
+ *
22
+ * Patent notice: signed hash-chained decision log (U.S. application filed May
23
+ * 2026); composes with DAG Trust Attestation, Patent D §7.3 (App. No.
24
+ * 63/984,626). AgentGuard® is a U.S. registered trademark (Reg. No. 8281464) of
25
+ * Dunecrest Ventures Inc.
26
+ */
27
+ import type { SignedDecisionLogEntry, SpendDecision } from '../types';
28
+ import { classifyOrigin, decisionCents, type TokenOrigin } from '../dashboard/aggregate';
29
+
30
+ export interface ModelUse {
31
+ model: string;
32
+ origin: TokenOrigin;
33
+ provider: string;
34
+ calls: number;
35
+ cents: number;
36
+ }
37
+ export interface ModelSwap {
38
+ atSequence: number;
39
+ fromModel: string;
40
+ toModel: string;
41
+ fromOrigin: TokenOrigin;
42
+ toOrigin: TokenOrigin;
43
+ /** True when the swap crossed the frontier↔dark boundary (the audit-risky kind). */
44
+ crossesBoundary: boolean;
45
+ }
46
+ export interface WorkloadProvenance {
47
+ key: string;
48
+ calls: number;
49
+ spentCents: number;
50
+ frontierCents: number;
51
+ darkCents: number;
52
+ firstAt: string | null;
53
+ lastAt: string | null;
54
+ models: ModelUse[];
55
+ /** Ordered model lineage, e.g. ['claude-5','gpt-5-mini','glm-5.2']. */
56
+ lineage: string[];
57
+ swaps: ModelSwap[];
58
+ }
59
+ export interface WorkloadProvenanceReport {
60
+ workloads: WorkloadProvenance[];
61
+ /** Chain-level tamper evidence spans ALL swaps — the point of the manifest. */
62
+ continuity: { verified: boolean; entries: number; reason?: string };
63
+ }
64
+
65
+ const defaultKeyOf = (d: SpendDecision): string => d.triggeredScopeKey ?? 'default';
66
+
67
+ /**
68
+ * Build the portable per-workload provenance manifest from signed ledger
69
+ * entries. Pass the async `verifyChain` result as `continuity` so this stays a
70
+ * pure, synchronous function (never null-continuity in production — a manifest
71
+ * without tamper evidence is not the product).
72
+ */
73
+ export function workloadProvenance(
74
+ entries: SignedDecisionLogEntry[],
75
+ continuity: WorkloadProvenanceReport['continuity'] = { verified: false, entries: 0, reason: 'NOT_CHECKED' },
76
+ keyOf: (d: SpendDecision) => string = defaultKeyOf,
77
+ ): WorkloadProvenanceReport {
78
+ interface Group {
79
+ calls: number; spent: number; frontier: number; dark: number;
80
+ first: string | null; last: string | null;
81
+ models: Map<string, ModelUse>; ordered: Array<{ seq: number; model: string; origin: TokenOrigin }>;
82
+ }
83
+ const groups = new Map<string, Group>();
84
+
85
+ for (const entry of entries) {
86
+ const d = entry.decision;
87
+ const kind = d.entryType ?? 'decision';
88
+ if (kind !== 'decision') continue; // settlements/outcomes don't add model steps
89
+
90
+ const key = keyOf(d);
91
+ const grp: Group = groups.get(key) ?? {
92
+ calls: 0, spent: 0, frontier: 0, dark: 0, first: null, last: null,
93
+ models: new Map(), ordered: [],
94
+ };
95
+ const model = d.modelResolved || d.modelRequested || 'unknown';
96
+ const { origin } = classifyOrigin(d);
97
+ const cents = decisionCents(d);
98
+
99
+ grp.calls += 1;
100
+ grp.spent += cents;
101
+ if (origin === 'frontier') grp.frontier += cents;
102
+ else if (origin === 'dark') grp.dark += cents;
103
+ if (d.timestamp) {
104
+ if (!grp.first || d.timestamp < grp.first) grp.first = d.timestamp;
105
+ if (!grp.last || d.timestamp > grp.last) grp.last = d.timestamp;
106
+ }
107
+ const mu = grp.models.get(model) ?? { model, origin, provider: d.provider ?? 'unknown', calls: 0, cents: 0 };
108
+ mu.calls += 1;
109
+ mu.cents += cents;
110
+ grp.models.set(model, mu);
111
+ grp.ordered.push({ seq: entry.sequence, model, origin });
112
+
113
+ groups.set(key, grp);
114
+ }
115
+
116
+ const workloads: WorkloadProvenance[] = [];
117
+ for (const [key, grp] of groups) {
118
+ grp.ordered.sort((a, b) => a.seq - b.seq);
119
+ const swaps: ModelSwap[] = [];
120
+ for (let i = 1; i < grp.ordered.length; i++) {
121
+ const prev = grp.ordered[i - 1];
122
+ const cur = grp.ordered[i];
123
+ if (cur.model !== prev.model) {
124
+ swaps.push({
125
+ atSequence: cur.seq,
126
+ fromModel: prev.model, toModel: cur.model,
127
+ fromOrigin: prev.origin, toOrigin: cur.origin,
128
+ crossesBoundary: prev.origin !== cur.origin
129
+ && prev.origin !== 'unknown' && cur.origin !== 'unknown',
130
+ });
131
+ }
132
+ }
133
+ // lineage = models in first-appearance order (collapse consecutive repeats)
134
+ const lineage: string[] = [];
135
+ for (const step of grp.ordered) if (lineage[lineage.length - 1] !== step.model) lineage.push(step.model);
136
+
137
+ workloads.push({
138
+ key,
139
+ calls: grp.calls,
140
+ spentCents: grp.spent,
141
+ frontierCents: grp.frontier,
142
+ darkCents: grp.dark,
143
+ firstAt: grp.first,
144
+ lastAt: grp.last,
145
+ models: [...grp.models.values()].sort((a, b) => b.cents - a.cents),
146
+ lineage,
147
+ swaps,
148
+ });
149
+ }
150
+
151
+ workloads.sort((a, b) => b.spentCents - a.spentCents);
152
+ return { workloads, continuity };
153
+ }
154
+
155
+ /** Human-readable lineage, e.g. "claude-5 → gpt-5-mini → glm-5.2". */
156
+ export const formatLineage = (w: WorkloadProvenance): string => w.lineage.join(' → ');