@agentguard-run/spend 0.8.0 → 0.10.0

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 (61) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/bindings/anthropic.js +18 -0
  3. package/dist/bindings/anthropic.js.map +1 -1
  4. package/dist/bindings/bedrock.js +9 -0
  5. package/dist/bindings/bedrock.js.map +1 -1
  6. package/dist/byo/index.d.ts +33 -0
  7. package/dist/byo/index.d.ts.map +1 -0
  8. package/dist/byo/index.js +344 -0
  9. package/dist/byo/index.js.map +1 -0
  10. package/dist/cli/byo.d.ts +4 -0
  11. package/dist/cli/byo.d.ts.map +1 -0
  12. package/dist/cli/byo.js +118 -0
  13. package/dist/cli/byo.js.map +1 -0
  14. package/dist/cli/init.d.ts.map +1 -1
  15. package/dist/cli/init.js +5 -0
  16. package/dist/cli/init.js.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/main.js +14 -1
  19. package/dist/cli/main.js.map +1 -1
  20. package/dist/governance.d.ts +77 -0
  21. package/dist/governance.d.ts.map +1 -0
  22. package/dist/governance.js +283 -0
  23. package/dist/governance.js.map +1 -0
  24. package/dist/index.d.ts +4 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +4 -1
  27. package/dist/index.js.map +1 -1
  28. package/dist/outcomes/runtime.js +7 -0
  29. package/dist/outcomes/runtime.js.map +1 -1
  30. package/dist/outcomes/types.d.ts +9 -0
  31. package/dist/outcomes/types.d.ts.map +1 -1
  32. package/dist/outcomes/types.js.map +1 -1
  33. package/dist/router.d.ts +8 -1
  34. package/dist/router.d.ts.map +1 -1
  35. package/dist/router.js +932 -229
  36. package/dist/router.js.map +1 -1
  37. package/dist/spend-guard.d.ts +19 -0
  38. package/dist/spend-guard.d.ts.map +1 -1
  39. package/dist/spend-guard.js +75 -1
  40. package/dist/spend-guard.js.map +1 -1
  41. package/dist/types.d.ts +21 -1
  42. package/dist/types.d.ts.map +1 -1
  43. package/dist/verticals/catalogs.d.ts +62 -0
  44. package/dist/verticals/catalogs.d.ts.map +1 -0
  45. package/dist/verticals/catalogs.js +3936 -0
  46. package/dist/verticals/catalogs.js.map +1 -0
  47. package/dist/verticals/index.d.ts +2 -0
  48. package/dist/verticals/index.d.ts.map +1 -0
  49. package/dist/verticals/index.js +18 -0
  50. package/dist/verticals/index.js.map +1 -0
  51. package/package.json +21 -2
  52. package/src/bindings/anthropic.ts +21 -0
  53. package/src/bindings/bedrock.ts +10 -0
  54. package/src/byo/index.ts +322 -0
  55. package/src/cli/byo.ts +87 -0
  56. package/src/governance.ts +376 -0
  57. package/src/outcomes/runtime.ts +8 -0
  58. package/src/outcomes/types.ts +9 -0
  59. package/src/router.ts +938 -227
  60. package/src/verticals/catalogs.ts +3967 -0
  61. package/src/verticals/index.ts +1 -0
package/src/cli/byo.ts ADDED
@@ -0,0 +1,87 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import {
4
+ isStarterTemplateName,
5
+ listStarterTemplates,
6
+ parseOutcomeYaml,
7
+ persistDeployment,
8
+ simulateOutcomeRun,
9
+ validateOutcomeYaml,
10
+ writeStarterTemplate,
11
+ } from '../byo';
12
+ import { green, yellow, dim } from './colors';
13
+
14
+ export async function runByoInit(argv: string[]): Promise<number> {
15
+ const template = argv[0];
16
+ const force = argv.includes('--force');
17
+ const output = valueAfter(argv, '--output') ?? 'outcome.yaml';
18
+ if (!isStarterTemplateName(template)) {
19
+ process.stderr.write(`agentguard init: unknown template '${template ?? ''}'\n`);
20
+ process.stderr.write('available templates: ' + listStarterTemplates().join(', ') + '\n');
21
+ return 2;
22
+ }
23
+ try {
24
+ const file = writeStarterTemplate(template, path.resolve(output), force);
25
+ process.stdout.write(`${green('+ created')} ${path.relative(process.cwd(), file) || file}\n`);
26
+ process.stdout.write(`${dim('next:')} agentguard run ${template} --inputs '{"task_id":"demo","payload_ref":"local-file"}'\n`);
27
+ return 0;
28
+ } catch (err) {
29
+ process.stderr.write(`agentguard init: ${err instanceof Error ? err.message : String(err)}\n`);
30
+ return 1;
31
+ }
32
+ }
33
+
34
+ export async function runOutcome(argv: string[]): Promise<number> {
35
+ const slug = argv[0];
36
+ if (!slug || argv.includes('--help') || argv.includes('-h')) {
37
+ process.stdout.write("agentguard run <slug> --inputs '{...}' [--config ./outcome.yaml]\n");
38
+ return slug ? 0 : 2;
39
+ }
40
+ const configPath = path.resolve(valueAfter(argv, '--config') ?? 'outcome.yaml');
41
+ const inputsRaw = valueAfter(argv, '--inputs') ?? '{}';
42
+ let inputs: Record<string, unknown>;
43
+ try {
44
+ inputs = JSON.parse(inputsRaw) as Record<string, unknown>;
45
+ } catch {
46
+ process.stderr.write('agentguard run: --inputs must be valid JSON\n');
47
+ return 2;
48
+ }
49
+ const loaded = loadConfig(configPath);
50
+ if (!loaded.ok) return loaded.code;
51
+ if (loaded.config.slug !== slug) {
52
+ process.stderr.write(`agentguard run: config slug is ${loaded.config.slug}, not ${slug}\n`);
53
+ return 2;
54
+ }
55
+ process.stdout.write(JSON.stringify(simulateOutcomeRun(loaded.config, inputs), null, 2) + '\n');
56
+ return 0;
57
+ }
58
+
59
+ export async function runDeploy(argv: string[]): Promise<number> {
60
+ const configPath = path.resolve(valueAfter(argv, '--config') ?? 'outcome.yaml');
61
+ const loaded = loadConfig(configPath);
62
+ if (!loaded.ok) return loaded.code;
63
+ const file = persistDeployment(loaded.config);
64
+ process.stdout.write(`${green('+ deployed')} ${loaded.config.slug} ${dim('local config only')}\n`);
65
+ process.stdout.write(`${dim('receipt config:')} ${path.relative(process.cwd(), file) || file}\n`);
66
+ return 0;
67
+ }
68
+
69
+ function loadConfig(configPath: string): { ok: true; config: ReturnType<typeof parseOutcomeYaml> } | { ok: false; code: number } {
70
+ if (!fs.existsSync(configPath)) {
71
+ process.stderr.write(`agentguard: missing ${configPath}. Run agentguard init <template> first.\n`);
72
+ return { ok: false, code: 2 };
73
+ }
74
+ const source = fs.readFileSync(configPath, 'utf8');
75
+ const validated = validateOutcomeYaml(source);
76
+ if (!validated.ok || !validated.config) {
77
+ process.stderr.write(`${yellow('invalid outcome.yaml')}\n`);
78
+ for (const error of validated.errors) process.stderr.write(` ${error}\n`);
79
+ return { ok: false, code: 2 };
80
+ }
81
+ return { ok: true, config: validated.config };
82
+ }
83
+
84
+ function valueAfter(argv: string[], flag: string): string | undefined {
85
+ const index = argv.indexOf(flag);
86
+ return index >= 0 ? argv[index + 1] : undefined;
87
+ }
@@ -0,0 +1,376 @@
1
+ import { createHash, randomUUID } from 'crypto';
2
+ import { computeCallCents } from './cost-table';
3
+ import { canonicalJson } from './decision-log';
4
+ import type { CallContext, SpendDecision, SpendPolicy } from './types';
5
+
6
+ export interface CircuitBreakerConfig {
7
+ enabled?: boolean;
8
+ repeatThreshold?: number;
9
+ repeat_threshold?: number;
10
+ windowSeconds?: number;
11
+ window_seconds?: number;
12
+ planChurnMax?: number;
13
+ plan_churn_max?: number;
14
+ stepCap?: number;
15
+ step_cap?: number;
16
+ }
17
+
18
+ export interface WorkflowEnvelopeConfig {
19
+ maxUsdPerRun?: number;
20
+ max_usd_per_run?: number;
21
+ maxIterations?: number;
22
+ max_iterations?: number;
23
+ maxTokens?: number;
24
+ max_tokens?: number;
25
+ maxWallClockSeconds?: number;
26
+ max_wall_clock_seconds?: number;
27
+ dailyUsdCap?: number;
28
+ daily_usd_cap?: number;
29
+ }
30
+
31
+ export interface ToolGateApproval {
32
+ approved: boolean;
33
+ approver?: string;
34
+ verdict?: string;
35
+ editedArgs?: Record<string, unknown>;
36
+ }
37
+
38
+ export interface ToolGateConfig {
39
+ enabled?: boolean;
40
+ mode?: 'human' | 'model';
41
+ destructivePatterns?: string[];
42
+ destructive_patterns?: string[];
43
+ escalationModel?: string;
44
+ escalation_model?: string;
45
+ escalationEffort?: 'low' | 'medium' | 'high' | 'xhigh';
46
+ escalation_effort?: 'low' | 'medium' | 'high' | 'xhigh';
47
+ humanApprover?: (input: ToolGateInput) => ToolGateApproval | Promise<ToolGateApproval>;
48
+ modelReviewer?: (input: ToolGateInput) => ToolGateApproval | Promise<ToolGateApproval>;
49
+ }
50
+
51
+ export interface ToolGateInput {
52
+ toolName: string;
53
+ toolArgs?: Record<string, unknown>;
54
+ workflowId?: string;
55
+ scope?: CallContext['scope'];
56
+ }
57
+
58
+ export interface GovernanceBlock {
59
+ kind: 'circuit_breaker' | 'workflow_envelope';
60
+ reason: string;
61
+ projectedCents: number;
62
+ receipt: Record<string, unknown>;
63
+ }
64
+
65
+ export interface ToolGateResult {
66
+ approved: boolean;
67
+ matchedPattern: string | null;
68
+ mode: 'human' | 'model';
69
+ decision: SpendDecision;
70
+ editedArgs?: Record<string, unknown>;
71
+ }
72
+
73
+ interface WorkflowState {
74
+ workflowId: string;
75
+ startedAtMs: number;
76
+ spendCents: number;
77
+ iterations: number;
78
+ tokens: number;
79
+ haltedReason?: string;
80
+ }
81
+
82
+ const DEFAULT_DESTRUCTIVE_PATTERNS = [
83
+ 'db.write',
84
+ 'db.delete',
85
+ 'db.drop',
86
+ 'drop table',
87
+ 'shell.rm',
88
+ 'rm -rf',
89
+ 'deploy.*',
90
+ 'git push --force',
91
+ 'git push -f',
92
+ 'payment.*',
93
+ 'transfer.*',
94
+ 'refund.*',
95
+ 'email.send_bulk',
96
+ ];
97
+
98
+ const circuitWindows = new Map<string, number[]>();
99
+ const workflowStates = new Map<string, WorkflowState>();
100
+ const dailyWorkflowSpend = new Map<string, number>();
101
+
102
+ export function evaluateGovernanceBeforeDispatch(
103
+ policy: SpendPolicy,
104
+ call: CallContext,
105
+ config: { circuitBreaker?: CircuitBreakerConfig; workflowEnvelope?: WorkflowEnvelopeConfig },
106
+ nowMs = Date.now(),
107
+ ): GovernanceBlock | null {
108
+ const circuit = evaluateCircuitBreaker(call, config.circuitBreaker, nowMs);
109
+ if (circuit) return circuit;
110
+ return evaluateWorkflowEnvelope(policy, call, config.workflowEnvelope, nowMs);
111
+ }
112
+
113
+ export async function evaluateToolGate(
114
+ policy: SpendPolicy,
115
+ input: ToolGateInput,
116
+ config: ToolGateConfig | undefined,
117
+ ): Promise<ToolGateResult> {
118
+ const toolName = String(input.toolName || '').trim();
119
+ if (!toolName) throw new Error('toolName is required');
120
+ const cleanArgs = sanitizeMetadata(input.toolArgs ?? {});
121
+ const enabled = config?.enabled === true;
122
+ const patterns = (config?.destructivePatterns ?? config?.destructive_patterns ?? DEFAULT_DESTRUCTIVE_PATTERNS).filter(Boolean);
123
+ const matchedPattern = enabled ? matchDestructivePattern(toolName, cleanArgs, patterns) : null;
124
+ const mode = config?.mode === 'model' ? 'model' : 'human';
125
+ let approval: ToolGateApproval = { approved: true, approver: 'agentguard:not-required', verdict: 'not_destructive' };
126
+ if (matchedPattern) {
127
+ const callback = mode === 'model' ? config?.modelReviewer : config?.humanApprover;
128
+ approval = callback
129
+ ? await callback({ ...input, toolArgs: cleanArgs })
130
+ : { approved: false, approver: mode === 'model' ? config?.escalationModel ?? 'model-reviewer' : 'human', verdict: 'approval_required' };
131
+ }
132
+
133
+ const approved = matchedPattern ? approval.approved === true : true;
134
+ const receipt = {
135
+ type: 'destructive_tool_gate',
136
+ toolName,
137
+ toolArgs: cleanArgs,
138
+ workflowId: input.workflowId ?? null,
139
+ matchedPattern,
140
+ mode,
141
+ approved,
142
+ approver: approval.approver ?? null,
143
+ reviewerModel: mode === 'model' ? config?.escalationModel ?? config?.escalation_model ?? null : null,
144
+ reviewerEffort: mode === 'model' ? config?.escalationEffort ?? config?.escalation_effort ?? 'high' : null,
145
+ verdict: approval.verdict ?? null,
146
+ };
147
+ const decision = buildGovernanceDecision({
148
+ policy,
149
+ call: {
150
+ provider: 'unknown',
151
+ model: 'tool-call',
152
+ inputTokens: 0,
153
+ outputTokens: 0,
154
+ scope: input.scope ?? { tenantId: 'local' },
155
+ workflowId: input.workflowId,
156
+ toolName,
157
+ toolArgs: cleanArgs,
158
+ },
159
+ action: approved ? 'allow' : 'block',
160
+ projectedCents: 0,
161
+ reason: approved
162
+ ? matchedPattern
163
+ ? `approved: destructive tool matched ${matchedPattern}`
164
+ : 'approved: tool call did not match destructive patterns'
165
+ : `blocked: destructive tool matched ${matchedPattern}`,
166
+ receipt,
167
+ });
168
+ return { approved, matchedPattern, mode, decision, editedArgs: approval.editedArgs };
169
+ }
170
+
171
+ export function buildGovernanceDecision(args: {
172
+ policy: SpendPolicy;
173
+ call: CallContext;
174
+ action: 'allow' | 'block';
175
+ projectedCents: number;
176
+ reason: string;
177
+ receipt: Record<string, unknown>;
178
+ }): SpendDecision {
179
+ return {
180
+ decisionId: randomUUID(),
181
+ timestamp: new Date().toISOString(),
182
+ action: args.action,
183
+ triggeredCap: null,
184
+ triggeredScopeKey: null,
185
+ projectedCents: args.projectedCents,
186
+ windowSpendBefore: 0,
187
+ windowSpendAfter: args.action === 'allow' ? args.projectedCents : 0,
188
+ provider: args.call.provider,
189
+ modelRequested: args.call.model,
190
+ modelResolved: args.call.model,
191
+ policyId: args.policy.id,
192
+ policyVersion: args.policy.version,
193
+ enforcementMode: args.policy.mode,
194
+ reasons: [args.reason],
195
+ entryType: 'governance',
196
+ governanceReceipt: sanitizeMetadata(args.receipt),
197
+ };
198
+ }
199
+
200
+ export function fingerprintCall(call: CallContext): string {
201
+ if (call.callFingerprint) return sha256(String(call.callFingerprint));
202
+ const payload = {
203
+ provider: call.provider,
204
+ model: call.model,
205
+ label: call.label ?? null,
206
+ capabilityClaim: call.capabilityClaim ?? null,
207
+ workflowId: call.workflowId ?? null,
208
+ toolName: call.toolName ?? null,
209
+ toolArgs: sanitizeMetadata(call.toolArgs ?? null),
210
+ requestShape: sanitizeMetadata(call.requestShape ?? null),
211
+ };
212
+ return sha256(canonicalJson(payload));
213
+ }
214
+
215
+ export function sanitizeMetadata<T>(value: T, path = 'metadata'): T {
216
+ if (value == null) return value;
217
+ if (Array.isArray(value)) return value.map((child, index) => sanitizeMetadata(child, `${path}[${index}]`)) as T;
218
+ if (typeof value !== 'object') return value;
219
+ const out: Record<string, unknown> = {};
220
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
221
+ if (/^(prompt|completion|content|messages|input|output|text|raw)$/i.test(key)) {
222
+ throw new Error('Governance metadata cannot include data-plane field: ' + path + '.' + key);
223
+ }
224
+ out[key] = sanitizeMetadata(child, path + '.' + key);
225
+ }
226
+ return out as T;
227
+ }
228
+
229
+ function evaluateCircuitBreaker(call: CallContext, config: CircuitBreakerConfig | undefined, nowMs: number): GovernanceBlock | null {
230
+ if (config?.enabled !== true) return null;
231
+ const threshold = positiveInt(config.repeatThreshold ?? config.repeat_threshold, 3);
232
+ const windowSeconds = positiveInt(config.windowSeconds ?? config.window_seconds, 300);
233
+ const planChurnMax = positiveInt(config.planChurnMax ?? config.plan_churn_max, 2);
234
+ const stepCap = positiveInt(config.stepCap ?? config.step_cap, 50);
235
+ const projectedCents = computeCallCents(call.model, call.inputTokens, call.outputTokens) ?? 0;
236
+
237
+ if (typeof call.reasoningStep === 'number' && call.reasoningStep >= stepCap) {
238
+ return {
239
+ kind: 'circuit_breaker',
240
+ projectedCents,
241
+ reason: `blocked: reasoning step cap ${stepCap} reached`,
242
+ receipt: { type: 'circuit_breaker', reason: 'step_cap', stepCap, reasoningStep: call.reasoningStep },
243
+ };
244
+ }
245
+
246
+ if (typeof call.planRevision === 'number' && call.planRevision >= planChurnMax && call.stateProgress !== true) {
247
+ return {
248
+ kind: 'circuit_breaker',
249
+ projectedCents,
250
+ reason: `blocked: plan churn exceeded ${planChurnMax} revisions without state progress`,
251
+ receipt: { type: 'circuit_breaker', reason: 'plan_churn', planChurnMax, planRevision: call.planRevision },
252
+ };
253
+ }
254
+
255
+ const fingerprint = fingerprintCall(call);
256
+ const key = `${call.scope.tenantId}:${call.workflowId ?? call.scope.taskId ?? 'global'}:${fingerprint}`;
257
+ const windowMs = windowSeconds * 1000;
258
+ const recent = (circuitWindows.get(key) ?? []).filter((at) => nowMs - at <= windowMs);
259
+ const nextCount = recent.length + 1;
260
+ if (nextCount >= threshold) {
261
+ circuitWindows.set(key, [...recent, nowMs]);
262
+ return {
263
+ kind: 'circuit_breaker',
264
+ projectedCents,
265
+ reason: `blocked: identical call repeated ${threshold}x in ${Math.round((recent.length ? nowMs - recent[0]! : 0) / 1000)}s (loop circuit breaker)`,
266
+ receipt: { type: 'circuit_breaker', fingerprint, count: nextCount, repeatThreshold: threshold, windowSeconds },
267
+ };
268
+ }
269
+ circuitWindows.set(key, [...recent, nowMs]);
270
+ return null;
271
+ }
272
+
273
+ function evaluateWorkflowEnvelope(policy: SpendPolicy, call: CallContext, config: WorkflowEnvelopeConfig | undefined, nowMs: number): GovernanceBlock | null {
274
+ if (!config) return null;
275
+ const workflowId = call.workflowId ?? call.scope.taskId;
276
+ if (!workflowId) return null;
277
+ const projectedCents = computeCallCents(call.model, call.inputTokens, call.outputTokens) ?? 0;
278
+ const state = workflowStates.get(workflowId) ?? {
279
+ workflowId,
280
+ startedAtMs: nowMs,
281
+ spendCents: 0,
282
+ iterations: 0,
283
+ tokens: 0,
284
+ };
285
+ if (state.haltedReason) {
286
+ return envelopeBlock(policy, call, projectedCents, workflowId, state, state.haltedReason);
287
+ }
288
+
289
+ const maxRunCents = usdToCents(config.maxUsdPerRun ?? config.max_usd_per_run);
290
+ const maxIterations = positiveOptional(config.maxIterations ?? config.max_iterations);
291
+ const maxTokens = positiveOptional(config.maxTokens ?? config.max_tokens);
292
+ const maxWallClockSeconds = positiveOptional(config.maxWallClockSeconds ?? config.max_wall_clock_seconds);
293
+ const dailyCents = usdToCents(config.dailyUsdCap ?? config.daily_usd_cap);
294
+ const tokensAfter = state.tokens + call.inputTokens + call.outputTokens;
295
+ const spendAfter = state.spendCents + projectedCents;
296
+ const iterationAfter = state.iterations + 1;
297
+ const elapsedSeconds = Math.floor((nowMs - state.startedAtMs) / 1000);
298
+ const dailyKey = `${workflowId}:${new Date(nowMs).toISOString().slice(0, 10)}`;
299
+ const dailyAfter = (dailyWorkflowSpend.get(dailyKey) ?? 0) + projectedCents;
300
+ let reason: string | null = null;
301
+ if (maxRunCents !== null && spendAfter > maxRunCents) {
302
+ reason = `blocked: workflow envelope exceeded at ${formatUsd(state.spendCents)}/${formatUsd(maxRunCents)} (run ${workflowId})`;
303
+ } else if (dailyCents !== null && dailyAfter > dailyCents) {
304
+ reason = `blocked: workflow daily envelope exceeded at ${formatUsd(dailyAfter)}/${formatUsd(dailyCents)} (run ${workflowId})`;
305
+ } else if (maxIterations !== null && iterationAfter > maxIterations) {
306
+ reason = `blocked: workflow iteration cap exceeded at ${state.iterations}/${maxIterations} (run ${workflowId})`;
307
+ } else if (maxTokens !== null && tokensAfter > maxTokens) {
308
+ reason = `blocked: workflow token envelope exceeded at ${state.tokens}/${maxTokens} (run ${workflowId})`;
309
+ } else if (maxWallClockSeconds !== null && elapsedSeconds > maxWallClockSeconds) {
310
+ reason = `blocked: workflow wall-clock envelope exceeded at ${elapsedSeconds}s/${maxWallClockSeconds}s (run ${workflowId})`;
311
+ }
312
+ if (reason) {
313
+ state.haltedReason = reason;
314
+ workflowStates.set(workflowId, state);
315
+ return envelopeBlock(policy, call, projectedCents, workflowId, state, reason);
316
+ }
317
+ state.spendCents = spendAfter;
318
+ state.iterations = iterationAfter;
319
+ state.tokens = tokensAfter;
320
+ workflowStates.set(workflowId, state);
321
+ dailyWorkflowSpend.set(dailyKey, dailyAfter);
322
+ return null;
323
+ }
324
+
325
+ function envelopeBlock(policy: SpendPolicy, _call: CallContext, projectedCents: number, workflowId: string, state: WorkflowState, reason: string): GovernanceBlock {
326
+ return {
327
+ kind: 'workflow_envelope',
328
+ projectedCents,
329
+ reason,
330
+ receipt: {
331
+ type: 'workflow_envelope_exceeded',
332
+ workflowId,
333
+ spendCents: state.spendCents,
334
+ iterations: state.iterations,
335
+ tokens: state.tokens,
336
+ policyId: policy.id,
337
+ reason,
338
+ },
339
+ };
340
+ }
341
+
342
+ function matchDestructivePattern(toolName: string, toolArgs: Record<string, unknown>, patterns: string[]): string | null {
343
+ const haystack = `${toolName} ${canonicalJson(toolArgs)}`.toLowerCase();
344
+ for (const pattern of patterns) {
345
+ const clean = String(pattern).trim().toLowerCase();
346
+ if (!clean) continue;
347
+ const regex = new RegExp('^' + escapeRegex(clean).replace(/\\\*/g, '.*') + '$');
348
+ if (regex.test(toolName.toLowerCase()) || haystack.includes(clean.replace(/\*/g, ''))) return pattern;
349
+ }
350
+ return null;
351
+ }
352
+
353
+ function sha256(value: string): string {
354
+ return createHash('sha256').update(value, 'utf8').digest('hex');
355
+ }
356
+
357
+ function escapeRegex(value: string): string {
358
+ return value.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
359
+ }
360
+
361
+ function positiveInt(value: unknown, fallback: number): number {
362
+ return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : fallback;
363
+ }
364
+
365
+ function positiveOptional(value: unknown): number | null {
366
+ return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null;
367
+ }
368
+
369
+ function usdToCents(value: unknown): number | null {
370
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value * 100) : null;
371
+ }
372
+
373
+ function formatUsd(cents: number): string {
374
+ return '$' + (cents / 100).toFixed(2);
375
+ }
376
+
@@ -247,10 +247,15 @@ function buildModelRequest(args: {
247
247
  promptVersion: args.template.promptVersion,
248
248
  need: args.route.need,
249
249
  role: args.role,
250
+ effort: effortForRole(args.route, args.role),
250
251
  },
251
252
  };
252
253
  }
253
254
 
255
+ function effortForRole(route: ModelRouteRecommendation, role: 'drafter' | 'reviewer' | 'fallback'): string {
256
+ return role === 'reviewer' ? route.reviewerEffort : route.drafterEffort;
257
+ }
258
+
254
259
  function buildReceipt(args: {
255
260
  template: OutcomeTemplate;
256
261
  context: OutcomeExecutionContext;
@@ -292,6 +297,9 @@ function buildReceipt(args: {
292
297
  posture: args.posture,
293
298
  budgetTier: args.budgetTier,
294
299
  blockedOrigins: args.blockedOrigins,
300
+ effort: args.route.effort,
301
+ drafterEffort: args.route.drafterEffort,
302
+ reviewerEffort: args.route.reviewerEffort,
295
303
  provenance: provenanceSummary(args.drafter, args.reviewer),
296
304
  },
297
305
  };
@@ -3,6 +3,9 @@ import type { CapabilityTier, SpendScope, SpendWindow } from '../types';
3
3
  export type OutcomeVertical = 'law' | 'accounting' | 'insurance' | 'realestate' | 'ecommerce' | string;
4
4
  export type OutcomeRole = 'drafter' | 'reviewer' | 'fallback';
5
5
  export type OutcomeStatus = 'completed' | 'blocked' | 'failed' | 'quality_failed';
6
+ export type OutcomeEffort = 'low' | 'medium' | 'high' | 'xhigh';
7
+ export type OutcomeGuardrailTier = 'grounded_cite_or_refuse' | 'reviewer_required' | 'human_of_record';
8
+ export type OutcomeTemplateStatus = 'active' | 'specced';
6
9
 
7
10
  export interface OutcomeInputDefinition {
8
11
  name: string;
@@ -47,6 +50,9 @@ export interface OutcomeTemplate {
47
50
  capability: CapabilityTier;
48
51
  spendCaps: OutcomeSpendCaps;
49
52
  promptVersion: string;
53
+ effort?: OutcomeEffort;
54
+ guardrailTier?: OutcomeGuardrailTier;
55
+ status?: OutcomeTemplateStatus;
50
56
  contextInjection?: OutcomeContextInjection[];
51
57
  blockedOrigins?: string[];
52
58
  premiumAgent?: string;
@@ -145,6 +151,9 @@ export interface OutcomeRuntimeReceipt {
145
151
  posture: string;
146
152
  budgetTier: string;
147
153
  blockedOrigins: string[];
154
+ effort: OutcomeEffort;
155
+ drafterEffort: OutcomeEffort;
156
+ reviewerEffort: OutcomeEffort;
148
157
  provenance: Record<string, string | number | boolean>;
149
158
  };
150
159
  }