@agentguard-run/spend 0.13.3 → 0.14.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 (38) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/dist/cli/hermes-kanban.d.ts +2 -0
  3. package/dist/cli/hermes-kanban.d.ts.map +1 -0
  4. package/dist/cli/hermes-kanban.js +86 -0
  5. package/dist/cli/hermes-kanban.js.map +1 -0
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/cli/main.js +5 -0
  8. package/dist/cli/main.js.map +1 -1
  9. package/dist/cli/serve.d.ts +1 -0
  10. package/dist/cli/serve.d.ts.map +1 -1
  11. package/dist/cli/serve.js +8 -4
  12. package/dist/cli/serve.js.map +1 -1
  13. package/dist/frameworks/hermes-kanban.d.ts +131 -0
  14. package/dist/frameworks/hermes-kanban.d.ts.map +1 -0
  15. package/dist/frameworks/hermes-kanban.js +486 -0
  16. package/dist/frameworks/hermes-kanban.js.map +1 -0
  17. package/dist/frameworks/index.d.ts +1 -0
  18. package/dist/frameworks/index.d.ts.map +1 -1
  19. package/dist/frameworks/index.js +1 -0
  20. package/dist/frameworks/index.js.map +1 -1
  21. package/dist/index.d.ts +1 -1
  22. package/dist/index.js +1 -1
  23. package/dist/telemetry.js +1 -1
  24. package/dist/workflow/context.d.ts.map +1 -1
  25. package/dist/workflow/context.js +6 -3
  26. package/dist/workflow/context.js.map +1 -1
  27. package/dist/workflow/receipt.d.ts +22 -6
  28. package/dist/workflow/receipt.d.ts.map +1 -1
  29. package/dist/workflow/receipt.js +109 -22
  30. package/dist/workflow/receipt.js.map +1 -1
  31. package/dist/workflow/types.d.ts +7 -0
  32. package/dist/workflow/types.d.ts.map +1 -1
  33. package/package.json +6 -1
  34. package/src/frameworks/hermes-kanban.ts +622 -0
  35. package/src/frameworks/index.ts +1 -0
  36. package/src/workflow/context.ts +6 -3
  37. package/src/workflow/receipt.ts +134 -30
  38. package/src/workflow/types.ts +7 -0
@@ -0,0 +1,622 @@
1
+ import { randomUUID } from 'crypto';
2
+ import { inferProvider } from '../cost-table';
3
+ import { canonicalJson, computeSignerFingerprint, sha256Hex } from '../decision-log';
4
+ import { signDagNode, verifyReceiptDag, type ReceiptCapabilityLevel, type ReceiptDagEnvelope, type ReceiptDagNode } from '../receipts/dag';
5
+ import { AgentGuardBlockedError, SpendGuard, type SpendGuardConfig } from '../spend-guard';
6
+ import type { CallContext, CapabilityTier, Provider, SignedDecisionLogEntry, SpendDecision, SpendPolicy, SpendScope } from '../types';
7
+ import { safeRequestShape } from './common';
8
+
9
+ export type HermesKanbanProfile = 'researcher' | 'writer' | 'reviewer' | 'director' | 'orchestrator' | string;
10
+ export type HermesKanbanTaskState = 'governed' | 'running' | 'completed' | 'revoked';
11
+
12
+ export interface HermesKanbanProfilePolicy {
13
+ capability: ReceiptCapabilityLevel;
14
+ toolAllowlist?: string[];
15
+ capCents?: number;
16
+ }
17
+
18
+ export interface HermesKanbanAdapterOptions {
19
+ policy: SpendPolicy;
20
+ scope: SpendScope;
21
+ config?: Omit<SpendGuardConfig, 'policy'>;
22
+ licenseKey?: string;
23
+ defaultModel?: string;
24
+ defaultCapCents?: number;
25
+ runtimeBudgetCentsPerMinute?: number;
26
+ profileMap?: Record<string, HermesKanbanProfilePolicy>;
27
+ reviewerCascade?: {
28
+ enabled?: boolean;
29
+ highRiskThreshold?: number;
30
+ drafterModel?: string;
31
+ reviewerModel?: string;
32
+ drafterMaxCostCents?: number;
33
+ reviewerMaxCostCents?: number;
34
+ };
35
+ }
36
+
37
+ export interface HermesKanbanTaskInput {
38
+ taskId: string;
39
+ boardSlug: string;
40
+ profile: HermesKanbanProfile;
41
+ parentTaskIds?: string[];
42
+ maxRuntimeMinutes?: number;
43
+ maxRuntimeMs?: number;
44
+ metadata?: Record<string, unknown>;
45
+ builderCode?: string;
46
+ }
47
+
48
+ export interface HermesKanbanEnvelope {
49
+ taskId: string;
50
+ taskHash: string;
51
+ boardHash: string;
52
+ profile: string;
53
+ state: HermesKanbanTaskState;
54
+ capabilityCeiling: ReceiptCapabilityLevel;
55
+ toolAllowlist: string[];
56
+ capCents: number;
57
+ parentTaskIds: string[];
58
+ parentEntryHashes: string[];
59
+ scope: SpendScope;
60
+ builderCode?: string;
61
+ createdAt: string;
62
+ revokedAt?: string;
63
+ completedAt?: string;
64
+ }
65
+
66
+ export interface HermesKanbanDispatchInput {
67
+ taskId: string;
68
+ model?: string;
69
+ provider?: Provider;
70
+ inputTokens: number;
71
+ outputTokens: number;
72
+ metadata?: Record<string, unknown>;
73
+ }
74
+
75
+ export interface HermesKanbanDispatchResult {
76
+ allow: boolean;
77
+ decision: SpendDecision;
78
+ signed: SignedDecisionLogEntry | null;
79
+ }
80
+
81
+ export interface HermesKanbanCapabilityInput {
82
+ taskId: string;
83
+ requestedCapability: ReceiptCapabilityLevel;
84
+ toolName?: string;
85
+ toolArgs?: Record<string, unknown>;
86
+ }
87
+
88
+ export interface HermesKanbanCapabilityResult {
89
+ approved: boolean;
90
+ reason?: string;
91
+ signed?: SignedDecisionLogEntry | null;
92
+ decision?: SpendDecision;
93
+ editedArgs?: Record<string, unknown>;
94
+ }
95
+
96
+ export interface HermesKanbanReceiptInput {
97
+ taskId: string;
98
+ parentTaskIds?: string[];
99
+ capability?: ReceiptCapabilityLevel;
100
+ modelIds?: string[];
101
+ tokenCounts?: { inputTokens?: number; outputTokens?: number };
102
+ totalCostCents?: number;
103
+ metadata?: Record<string, unknown>;
104
+ highRiskClassifierScore?: number;
105
+ keywordRisk?: boolean;
106
+ reviewerVerdict?: 'drafter_only' | 'review_required' | 'approved' | 'rejected';
107
+ }
108
+
109
+ export interface HermesKanbanReceiptResult {
110
+ node: ReceiptDagNode;
111
+ envelope: HermesKanbanEnvelope;
112
+ dag: ReceiptDagEnvelope;
113
+ verification?: Awaited<ReturnType<typeof verifyReceiptDag>>;
114
+ }
115
+
116
+ const DEFAULT_PROFILE_MAP: Record<string, HermesKanbanProfilePolicy> = {
117
+ researcher: { capability: 'READ_ONLY', toolAllowlist: ['web.fetch', 'file.read', 'notes.read', 'search.query'] },
118
+ writer: { capability: 'TRANSACT', toolAllowlist: ['draft.create', 'file.write', 'notes.write', 'email.draft'] },
119
+ reviewer: { capability: 'READ_ONLY', toolAllowlist: ['review.read', 'notes.read', 'decision.record'] },
120
+ director: { capability: 'ORCHESTRATE', toolAllowlist: ['task.assign', 'board.plan', 'decision.record'] },
121
+ orchestrator: { capability: 'ORCHESTRATE', toolAllowlist: ['task.assign', 'board.plan', 'decision.record'] },
122
+ };
123
+
124
+ const CAPABILITY_RANK: Record<ReceiptCapabilityLevel, number> = {
125
+ READ_ONLY: 0,
126
+ TRANSACT: 1,
127
+ ADMIN: 2,
128
+ ORCHESTRATE: 3,
129
+ };
130
+
131
+ const DATA_PLANE_KEYS = /^(title|summary|prompt|completion|content|messages|input|output|text|raw|source|notes|draft|body|transcript)$/i;
132
+ const SAFE_METADATA_KEYS = new Set([
133
+ 'assignee',
134
+ 'board_hash',
135
+ 'duration_ms',
136
+ 'event',
137
+ 'model',
138
+ 'model_id',
139
+ 'outcome',
140
+ 'priority',
141
+ 'profile',
142
+ 'provider',
143
+ 'risk_score',
144
+ 'state',
145
+ 'task_id',
146
+ 'ticket_id',
147
+ 'token_count',
148
+ 'workflow_id',
149
+ ]);
150
+
151
+ export function createHermesKanbanAdapter(opts: HermesKanbanAdapterOptions): HermesKanbanAdapter {
152
+ return new HermesKanbanAdapter(opts);
153
+ }
154
+
155
+ export class HermesKanbanAdapter {
156
+ private readonly taskGuards = new Map<string, SpendGuard>();
157
+ private readonly envelopes = new Map<string, HermesKanbanEnvelope>();
158
+ private readonly nodesByTaskId = new Map<string, ReceiptDagNode>();
159
+ private readonly nodes: ReceiptDagNode[] = [];
160
+ private readonly profileMap: Record<string, HermesKanbanProfilePolicy>;
161
+ private readonly config: Omit<SpendGuardConfig, 'policy'>;
162
+
163
+ constructor(private readonly opts: HermesKanbanAdapterOptions) {
164
+ this.profileMap = { ...DEFAULT_PROFILE_MAP, ...(opts.profileMap ?? {}) };
165
+ this.config = opts.config ?? {};
166
+ }
167
+
168
+ governTask(input: HermesKanbanTaskInput): HermesKanbanEnvelope {
169
+ assertTaskInput(input);
170
+ const profilePolicy = this.profilePolicy(input.profile);
171
+ const capCents = capForTask(input, profilePolicy, this.opts);
172
+ const boardHash = hashValue(input.boardSlug);
173
+ const taskHash = hashValue(input.taskId);
174
+ const scope: SpendScope = { ...this.opts.scope, taskId: input.taskId };
175
+ const envelope: HermesKanbanEnvelope = {
176
+ taskId: input.taskId,
177
+ taskHash,
178
+ boardHash,
179
+ profile: normalizeProfile(input.profile),
180
+ state: 'governed',
181
+ capabilityCeiling: profilePolicy.capability,
182
+ toolAllowlist: [...(profilePolicy.toolAllowlist ?? [])],
183
+ capCents,
184
+ parentTaskIds: [...new Set(input.parentTaskIds ?? [])],
185
+ parentEntryHashes: [],
186
+ scope,
187
+ builderCode: safeBuilderCode(input.builderCode),
188
+ createdAt: new Date().toISOString(),
189
+ };
190
+ const taskPolicy: SpendPolicy = {
191
+ ...this.opts.policy,
192
+ id: `${this.opts.policy.id}:hermes-kanban:${taskHash.slice(0, 12)}`,
193
+ name: `${this.opts.policy.name} Hermes Kanban task`,
194
+ scope,
195
+ caps: [{ window: 'per_day', amountCents: capCents, action: 'block', reason: 'Hermes Kanban task cap' }],
196
+ requiredCapability: spendCapabilityForReceiptCapability(profilePolicy.capability),
197
+ };
198
+ this.envelopes.set(input.taskId, envelope);
199
+ this.taskGuards.set(input.taskId, new SpendGuard({
200
+ policy: taskPolicy,
201
+ ...this.config,
202
+ licenseKey: this.opts.licenseKey ?? this.config.licenseKey,
203
+ }));
204
+ return { ...envelope };
205
+ }
206
+
207
+ async enforceProviderCall(input: HermesKanbanDispatchInput): Promise<HermesKanbanDispatchResult> {
208
+ const envelope = this.requireEnvelope(input.taskId);
209
+ assertActive(envelope);
210
+ assertMetadataOnly(input.metadata ?? {});
211
+ validateTokens(input.inputTokens, input.outputTokens);
212
+ const guard = this.requireGuard(input.taskId);
213
+ const model = safeLabel(input.model) ?? this.opts.defaultModel ?? 'openai/gpt-4o-mini';
214
+ const call: CallContext = {
215
+ provider: input.provider ?? inferProvider(model),
216
+ model,
217
+ inputTokens: input.inputTokens,
218
+ outputTokens: input.outputTokens,
219
+ scope: envelope.scope,
220
+ workflowId: envelope.boardHash,
221
+ subagentId: envelope.taskId,
222
+ label: 'hermes-kanban',
223
+ capabilityClaim: spendCapabilityForReceiptCapability(envelope.capabilityCeiling),
224
+ requestShape: safeRequestShape({
225
+ framework: 'hermes-kanban',
226
+ event: 'provider-call',
227
+ taskId: envelope.taskId,
228
+ taskHash: envelope.taskHash,
229
+ boardHash: envelope.boardHash,
230
+ profile: envelope.profile,
231
+ metadata: filterReceiptMetadata(input.metadata ?? {}),
232
+ }),
233
+ };
234
+ const { decision, signed } = await guard.decide(call);
235
+ return { allow: decision.action !== 'block', decision, signed };
236
+ }
237
+
238
+ async guardCapability(input: HermesKanbanCapabilityInput): Promise<HermesKanbanCapabilityResult> {
239
+ const envelope = this.requireEnvelope(input.taskId);
240
+ assertActive(envelope);
241
+ assertMetadataOnly(input.toolArgs ?? {});
242
+ if (CAPABILITY_RANK[input.requestedCapability] > CAPABILITY_RANK[envelope.capabilityCeiling]) {
243
+ return {
244
+ approved: false,
245
+ reason: `Hermes Kanban profile ${envelope.profile} is capped at ${envelope.capabilityCeiling}`,
246
+ };
247
+ }
248
+ if (input.toolName && envelope.toolAllowlist.length > 0 && !toolAllowed(input.toolName, envelope.toolAllowlist)) {
249
+ return { approved: false, reason: `Tool ${input.toolName} is outside the Hermes Kanban profile allowlist` };
250
+ }
251
+ const gate = await this.requireGuard(input.taskId).guardToolCall({
252
+ toolName: input.toolName ?? 'hermes-kanban.tool',
253
+ toolArgs: safeRequestShape(input.toolArgs ?? {}),
254
+ workflowId: envelope.boardHash,
255
+ scope: envelope.scope,
256
+ });
257
+ return {
258
+ approved: gate.approved,
259
+ reason: gate.decision.reasons.join('; '),
260
+ signed: gate.signed,
261
+ decision: gate.decision,
262
+ editedArgs: gate.editedArgs,
263
+ };
264
+ }
265
+
266
+ async revokeTask(taskId: string): Promise<HermesKanbanReceiptResult> {
267
+ const envelope = this.requireEnvelope(taskId);
268
+ if (envelope.state === 'revoked') return this.receiptResult(envelope, this.requireNode(taskId));
269
+ envelope.state = 'revoked';
270
+ envelope.revokedAt = new Date().toISOString();
271
+ const node = await this.signTaskNode(envelope, {
272
+ event: 'revoked',
273
+ action: 'block',
274
+ blocked: true,
275
+ capability: envelope.capabilityCeiling,
276
+ trustScore: 0.7,
277
+ });
278
+ this.nodesByTaskId.set(taskId, node);
279
+ this.nodes.push(node);
280
+ return this.receiptResult(envelope, node);
281
+ }
282
+
283
+ async emitTaskReceipt(input: HermesKanbanReceiptInput): Promise<HermesKanbanReceiptResult> {
284
+ const envelope = this.requireEnvelope(input.taskId);
285
+ assertActive(envelope);
286
+ assertMetadataOnly(input.metadata ?? {});
287
+ envelope.state = 'completed';
288
+ envelope.completedAt = new Date().toISOString();
289
+ const parentTaskIds = [...new Set(input.parentTaskIds ?? envelope.parentTaskIds)];
290
+ const parentNodes = parentTaskIds.map((id) => this.nodesByTaskId.get(id)).filter(Boolean) as ReceiptDagNode[];
291
+ envelope.parentEntryHashes = parentNodes.map((node) => node.entryHash);
292
+ const capability = input.capability ?? envelope.capabilityCeiling;
293
+ const triggers = reviewerCascadeTriggers(envelope, input, this.opts);
294
+ const node = await this.signTaskNode(envelope, {
295
+ event: 'completed',
296
+ action: 'allow',
297
+ blocked: false,
298
+ capability,
299
+ trustScore: triggers.length > 0 ? 0.9 : 0.94,
300
+ parentNodes,
301
+ metadata: input.metadata,
302
+ modelIds: input.modelIds,
303
+ tokenCounts: input.tokenCounts,
304
+ totalCostCents: input.totalCostCents,
305
+ reviewerCascade: triggers.length > 0 ? buildReviewerCascadeReceipt(input, this.opts, triggers) : undefined,
306
+ });
307
+ this.nodesByTaskId.set(input.taskId, node);
308
+ this.nodes.push(node);
309
+ return this.receiptResult(envelope, node);
310
+ }
311
+
312
+ getEnvelope(taskId: string): HermesKanbanEnvelope | null {
313
+ const envelope = this.envelopes.get(taskId);
314
+ return envelope ? { ...envelope, parentTaskIds: [...envelope.parentTaskIds], parentEntryHashes: [...envelope.parentEntryHashes], toolAllowlist: [...envelope.toolAllowlist] } : null;
315
+ }
316
+
317
+ getReceiptDag(rootTaskId?: string): ReceiptDagEnvelope {
318
+ const root = rootTaskId ? this.nodesByTaskId.get(rootTaskId) : this.nodes[this.nodes.length - 1];
319
+ return {
320
+ nodes: [...this.nodes],
321
+ rootId: root?.entryHash ?? '0'.repeat(64),
322
+ workflowId: this.nodes[0]?.workflowId ?? null,
323
+ };
324
+ }
325
+
326
+ outboundPayloads(): Record<string, unknown>[] {
327
+ return [];
328
+ }
329
+
330
+ async verifyDag(publicKeyHex?: string): Promise<Awaited<ReturnType<typeof verifyReceiptDag>>> {
331
+ const key = publicKeyHex ?? this.publicKeyHex();
332
+ return verifyReceiptDag(this.getReceiptDag(), key);
333
+ }
334
+
335
+ private async signTaskNode(
336
+ envelope: HermesKanbanEnvelope,
337
+ args: {
338
+ event: 'completed' | 'revoked';
339
+ action: 'allow' | 'block';
340
+ blocked: boolean;
341
+ capability: ReceiptCapabilityLevel;
342
+ trustScore: number;
343
+ parentNodes?: ReceiptDagNode[];
344
+ metadata?: Record<string, unknown>;
345
+ modelIds?: string[];
346
+ tokenCounts?: { inputTokens?: number; outputTokens?: number };
347
+ totalCostCents?: number;
348
+ reviewerCascade?: Record<string, unknown>;
349
+ },
350
+ ): Promise<ReceiptDagNode> {
351
+ if (!this.config.signingKeys) throw new Error('Hermes Kanban receipts require signingKeys');
352
+ const parentNodes = args.parentNodes ?? [];
353
+ const decision = taskDecision(envelope, args, this.opts.policy);
354
+ return signDagNode({
355
+ sequence: this.nodes.length,
356
+ decision,
357
+ privateKey: this.config.signingKeys.privateKey,
358
+ publicKey: this.config.signingKeys.publicKey,
359
+ parents: parentNodes,
360
+ workflowId: envelope.boardHash,
361
+ trustScore: args.trustScore,
362
+ blocked: args.blocked,
363
+ capability: args.capability,
364
+ builderCode: envelope.builderCode ?? null,
365
+ });
366
+ }
367
+
368
+ private receiptResult(envelope: HermesKanbanEnvelope, node: ReceiptDagNode): HermesKanbanReceiptResult {
369
+ return {
370
+ node,
371
+ envelope: { ...envelope, parentTaskIds: [...envelope.parentTaskIds], parentEntryHashes: [...envelope.parentEntryHashes], toolAllowlist: [...envelope.toolAllowlist] },
372
+ dag: this.getReceiptDag(envelope.taskId),
373
+ };
374
+ }
375
+
376
+ private requireEnvelope(taskId: string): HermesKanbanEnvelope {
377
+ const envelope = this.envelopes.get(taskId);
378
+ if (!envelope) throw new Error(`Unknown Hermes Kanban task ${taskId}`);
379
+ return envelope;
380
+ }
381
+
382
+ private requireGuard(taskId: string): SpendGuard {
383
+ const guard = this.taskGuards.get(taskId);
384
+ if (!guard) throw new Error(`Unknown Hermes Kanban task ${taskId}`);
385
+ return guard;
386
+ }
387
+
388
+ private requireNode(taskId: string): ReceiptDagNode {
389
+ const node = this.nodesByTaskId.get(taskId);
390
+ if (!node) throw new Error(`Hermes Kanban task ${taskId} has no receipt`);
391
+ return node;
392
+ }
393
+
394
+ private profilePolicy(profile: string): HermesKanbanProfilePolicy {
395
+ return this.profileMap[normalizeProfile(profile)] ?? { capability: 'READ_ONLY', toolAllowlist: [] };
396
+ }
397
+
398
+ private publicKeyHex(): string {
399
+ if (!this.config.signingKeys) throw new Error('Hermes Kanban receipts require signingKeys');
400
+ return Buffer.from(this.config.signingKeys.publicKey).toString('hex');
401
+ }
402
+ }
403
+
404
+ function taskDecision(
405
+ envelope: HermesKanbanEnvelope,
406
+ args: {
407
+ event: 'completed' | 'revoked';
408
+ action: 'allow' | 'block';
409
+ capability: ReceiptCapabilityLevel;
410
+ parentNodes?: ReceiptDagNode[];
411
+ metadata?: Record<string, unknown>;
412
+ modelIds?: string[];
413
+ tokenCounts?: { inputTokens?: number; outputTokens?: number };
414
+ totalCostCents?: number;
415
+ reviewerCascade?: Record<string, unknown>;
416
+ },
417
+ policy: SpendPolicy,
418
+ ): SpendDecision {
419
+ const parentNodes = args.parentNodes ?? [];
420
+ const inputTokens = safeNonNegativeInteger(args.tokenCounts?.inputTokens);
421
+ const outputTokens = safeNonNegativeInteger(args.tokenCounts?.outputTokens);
422
+ const totalCostCents = safeNonNegativeInteger(args.totalCostCents);
423
+ const receipt: Record<string, unknown> = {
424
+ type: 'hermes_kanban_task',
425
+ event: args.event,
426
+ taskId: envelope.taskId,
427
+ taskHash: envelope.taskHash,
428
+ boardHash: envelope.boardHash,
429
+ profile: envelope.profile,
430
+ capability: args.capability,
431
+ capabilityCeiling: envelope.capabilityCeiling,
432
+ state: envelope.state,
433
+ parentTaskIds: [...envelope.parentTaskIds],
434
+ parentEntryHashes: parentNodes.map((node) => node.entryHash),
435
+ parentMsgHashes: parentNodes.map((node) => node.msgHash),
436
+ modelIds: safeModelIds(args.modelIds ?? []),
437
+ inputTokens,
438
+ outputTokens,
439
+ totalCostCents,
440
+ metadata: filterReceiptMetadata(args.metadata ?? {}),
441
+ };
442
+ if (args.reviewerCascade) receipt.reviewerCascade = args.reviewerCascade;
443
+ const decision: SpendDecision = {
444
+ decisionId: randomUUID(),
445
+ timestamp: new Date().toISOString(),
446
+ action: args.action,
447
+ triggeredCap: null,
448
+ triggeredScopeKey: null,
449
+ projectedCents: totalCostCents,
450
+ windowSpendBefore: 0,
451
+ windowSpendAfter: totalCostCents,
452
+ provider: 'unknown',
453
+ modelRequested: 'hermes-kanban',
454
+ modelResolved: 'hermes-kanban',
455
+ policyId: policy.id,
456
+ policyVersion: policy.version,
457
+ enforcementMode: policy.mode,
458
+ reasons: [`Hermes Kanban task ${args.event}: ${envelope.taskId}`],
459
+ entryType: 'outcome',
460
+ outcomeReceipt: receipt,
461
+ };
462
+ if (envelope.builderCode) decision.builderCode = envelope.builderCode;
463
+ return decision;
464
+ }
465
+
466
+ function buildReviewerCascadeReceipt(input: HermesKanbanReceiptInput, opts: HermesKanbanAdapterOptions, triggers: string[]): Record<string, unknown> {
467
+ const cascade = opts.reviewerCascade ?? {};
468
+ return {
469
+ outcome: safeLabel(input.metadata?.outcome) ?? 'hermes_kanban_task',
470
+ drafter: {
471
+ model: cascade.drafterModel ?? opts.defaultModel ?? 'openai/gpt-4o-mini',
472
+ maxCostCents: safeNonNegativeInteger(cascade.drafterMaxCostCents ?? 25),
473
+ },
474
+ reviewer: {
475
+ model: cascade.reviewerModel ?? 'anthropic/claude-sonnet-4-6',
476
+ maxCostCents: safeNonNegativeInteger(cascade.reviewerMaxCostCents ?? 75),
477
+ },
478
+ triggerFired: triggers,
479
+ reviewerVerdict: input.reviewerVerdict ?? 'review_required',
480
+ };
481
+ }
482
+
483
+ function reviewerCascadeTriggers(envelope: HermesKanbanEnvelope, input: HermesKanbanReceiptInput, opts: HermesKanbanAdapterOptions): string[] {
484
+ if (opts.reviewerCascade?.enabled === false) return [];
485
+ const threshold = opts.reviewerCascade?.highRiskThreshold ?? 0.55;
486
+ const triggers: string[] = [];
487
+ if (envelope.profile === 'reviewer') triggers.push('reviewer_profile');
488
+ if (typeof input.highRiskClassifierScore === 'number' && input.highRiskClassifierScore >= threshold) triggers.push(`risk_score_above:${threshold}`);
489
+ if (input.keywordRisk === true) triggers.push('keyword_prefilter');
490
+ return [...new Set(triggers)];
491
+ }
492
+
493
+ function capForTask(input: HermesKanbanTaskInput, profilePolicy: HermesKanbanProfilePolicy, opts: HermesKanbanAdapterOptions): number {
494
+ if (Number.isSafeInteger(profilePolicy.capCents) && profilePolicy.capCents! >= 0) return profilePolicy.capCents!;
495
+ const runtimeMinutes = runtimeMinutesFor(input);
496
+ const runtimeBudget = opts.runtimeBudgetCentsPerMinute ?? 10;
497
+ const cap = Math.ceil(runtimeMinutes * runtimeBudget);
498
+ return Math.max(1, Math.min(cap, opts.defaultCapCents ?? cap));
499
+ }
500
+
501
+ function runtimeMinutesFor(input: HermesKanbanTaskInput): number {
502
+ if (Number.isFinite(input.maxRuntimeMinutes) && input.maxRuntimeMinutes! > 0) return input.maxRuntimeMinutes!;
503
+ if (Number.isFinite(input.maxRuntimeMs) && input.maxRuntimeMs! > 0) return input.maxRuntimeMs! / 60_000;
504
+ return 5;
505
+ }
506
+
507
+ function spendCapabilityForReceiptCapability(capability: ReceiptCapabilityLevel): CapabilityTier {
508
+ if (capability === 'ORCHESTRATE') return 'payment_execute';
509
+ if (capability === 'ADMIN') return 'payment_initiate';
510
+ if (capability === 'TRANSACT') return 'data_write';
511
+ return 'read_only';
512
+ }
513
+
514
+ function assertTaskInput(input: HermesKanbanTaskInput): void {
515
+ assertPlainRecord(input, 'task');
516
+ const allowed = new Set(['taskId', 'boardSlug', 'profile', 'parentTaskIds', 'maxRuntimeMinutes', 'maxRuntimeMs', 'metadata', 'builderCode']);
517
+ for (const key of Object.keys(input as unknown as Record<string, unknown>)) {
518
+ if (!allowed.has(key)) throw new Error(`Hermes Kanban task input cannot include data-plane or unknown field: ${key}`);
519
+ }
520
+ if (!safeLabel(input.taskId)) throw new Error('Hermes Kanban taskId is required');
521
+ if (!safeLabel(input.boardSlug)) throw new Error('Hermes Kanban boardSlug is required');
522
+ if (!safeLabel(input.profile)) throw new Error('Hermes Kanban profile is required');
523
+ assertMetadataOnly(input.metadata ?? {});
524
+ for (const parent of input.parentTaskIds ?? []) {
525
+ if (!safeLabel(parent)) throw new Error('Hermes Kanban parent task id must be a metadata identifier');
526
+ }
527
+ }
528
+
529
+ function assertMetadataOnly(value: unknown, path = 'metadata'): void {
530
+ if (value == null) return;
531
+ if (Array.isArray(value)) {
532
+ for (let i = 0; i < value.length; i++) assertMetadataOnly(value[i], `${path}[${i}]`);
533
+ return;
534
+ }
535
+ if (typeof value !== 'object') return;
536
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
537
+ if (DATA_PLANE_KEYS.test(key)) throw new Error(`Hermes Kanban metadata cannot include data-plane field: ${path}.${key}`);
538
+ assertMetadataOnly(child, `${path}.${key}`);
539
+ }
540
+ }
541
+
542
+ function assertPlainRecord(value: unknown, name: string): void {
543
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error(`Hermes Kanban ${name} must be an object`);
544
+ }
545
+
546
+ function assertActive(envelope: HermesKanbanEnvelope): void {
547
+ 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);
549
+ }
550
+
551
+ function blockedDecision(envelope: HermesKanbanEnvelope, state: string): SpendDecision {
552
+ return {
553
+ decisionId: randomUUID(),
554
+ timestamp: new Date().toISOString(),
555
+ action: 'block',
556
+ triggeredCap: null,
557
+ triggeredScopeKey: null,
558
+ projectedCents: 0,
559
+ windowSpendBefore: 0,
560
+ windowSpendAfter: 0,
561
+ provider: 'unknown',
562
+ modelRequested: 'hermes-kanban',
563
+ modelResolved: 'hermes-kanban',
564
+ policyId: 'hermes-kanban',
565
+ policyVersion: 1,
566
+ enforcementMode: 'enforce',
567
+ reasons: [`Hermes Kanban task ${envelope.taskId} is ${state}`],
568
+ };
569
+ }
570
+
571
+ function filterReceiptMetadata(metadata: Record<string, unknown>): Record<string, unknown> {
572
+ assertMetadataOnly(metadata);
573
+ const out: Record<string, unknown> = {};
574
+ for (const [key, value] of Object.entries(metadata)) {
575
+ if (!SAFE_METADATA_KEYS.has(key)) continue;
576
+ if (value == null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
577
+ out[key] = value;
578
+ }
579
+ }
580
+ return out;
581
+ }
582
+
583
+ function validateTokens(inputTokens: number, outputTokens: number): void {
584
+ if (!Number.isSafeInteger(inputTokens) || inputTokens < 0) throw new Error('inputTokens must be a non-negative safe integer');
585
+ if (!Number.isSafeInteger(outputTokens) || outputTokens < 0) throw new Error('outputTokens must be a non-negative safe integer');
586
+ }
587
+
588
+ function hashValue(value: string): string {
589
+ return sha256Hex(canonicalJson({ value }));
590
+ }
591
+
592
+ function normalizeProfile(profile: string): string {
593
+ return profile.trim().toLowerCase().replace(/[^a-z0-9_.:-]/g, '_');
594
+ }
595
+
596
+ function safeLabel(value: unknown): string | undefined {
597
+ if (typeof value !== 'string') return undefined;
598
+ const trimmed = value.trim();
599
+ if (!trimmed || !/^[A-Za-z0-9_.:/-]{1,160}$/.test(trimmed)) return undefined;
600
+ return trimmed;
601
+ }
602
+
603
+ function safeBuilderCode(value: unknown): string | undefined {
604
+ const label = safeLabel(value);
605
+ return label ? label.slice(0, 64) : undefined;
606
+ }
607
+
608
+ function safeModelIds(models: string[]): string[] {
609
+ return models.map((model) => safeLabel(model)).filter(Boolean) as string[];
610
+ }
611
+
612
+ function safeNonNegativeInteger(value: unknown): number {
613
+ return Number.isSafeInteger(value) && (value as number) >= 0 ? value as number : 0;
614
+ }
615
+
616
+ function toolAllowed(toolName: string, allowlist: string[]): boolean {
617
+ return allowlist.some((allowed) => toolName === allowed || toolName.startsWith(`${allowed}.`));
618
+ }
619
+
620
+ export function hermesKanbanSignerFingerprint(opts: HermesKanbanAdapterOptions): string | null {
621
+ return opts.config?.signingKeys ? computeSignerFingerprint(opts.config.signingKeys.publicKey) : null;
622
+ }
@@ -3,3 +3,4 @@ export * from './openrouter';
3
3
  export * from './vercel-ai';
4
4
  export * from './claude-code';
5
5
  export * from './hermes';
6
+ export * from './hermes-kanban';
@@ -127,7 +127,7 @@ export class WorkflowContext {
127
127
  const cost = this.extractOutcomeCost(result);
128
128
  const parentReceiptId = this._last_receipt_id;
129
129
  const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
130
- const receipt = buildReceipt({
130
+ const receipt = await buildReceipt({
131
131
  workflow_id: this.workflow_id,
132
132
  outcome_name: name,
133
133
  user_id: this.user_id,
@@ -137,6 +137,7 @@ export class WorkflowContext {
137
137
  workflow_checkpoint_idx: null,
138
138
  workflow_total_spend_usd_to_date: this._total_spend_usd + cost,
139
139
  reviewer_cascade: extractReviewerCascade(result),
140
+ signingKeys: this.config.signingKeys,
140
141
  });
141
142
 
142
143
  this._receipts.push(receipt);
@@ -196,7 +197,7 @@ export class WorkflowContext {
196
197
 
197
198
  private async writeCheckpoint(label?: string): Promise<CheckpointReceipt> {
198
199
  const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
199
- const checkpoint = buildReceipt({
200
+ const checkpoint = await buildReceipt({
200
201
  workflow_id: this.workflow_id,
201
202
  outcome_name: 'CHECKPOINT',
202
203
  user_id: this.user_id,
@@ -208,6 +209,7 @@ export class WorkflowContext {
208
209
  is_checkpoint: true,
209
210
  checkpoint_label: label ?? null,
210
211
  receipt_type: 'checkpoint',
212
+ signingKeys: this.config.signingKeys,
211
213
  }) as CheckpointReceipt;
212
214
  this._receipts.push(checkpoint);
213
215
  this._last_checkpoint_id = checkpoint.receipt_id;
@@ -219,7 +221,7 @@ export class WorkflowContext {
219
221
 
220
222
  private async writeFinalReceipt(type: 'cancelled' | 'completed' | 'cap_hit', reason: string): Promise<void> {
221
223
  const chainHash = this._receipts.length ? await computeChainHash(this._receipts[this._receipts.length - 1]!) : null;
222
- const receipt = buildReceipt({
224
+ const receipt = await buildReceipt({
223
225
  workflow_id: this.workflow_id,
224
226
  outcome_name: type === 'completed' ? 'WORKFLOW_COMPLETED' : 'WORKFLOW_STOPPED',
225
227
  user_id: this.user_id,
@@ -230,6 +232,7 @@ export class WorkflowContext {
230
232
  workflow_total_spend_usd_to_date: this._total_spend_usd,
231
233
  receipt_type: type === 'cancelled' ? 'cancel' : type,
232
234
  cancelled_reason: reason,
235
+ signingKeys: this.config.signingKeys,
233
236
  });
234
237
  this._receipts.push(receipt);
235
238
  this._last_receipt_id = receipt.receipt_id;