thumbgate 1.30.0 → 1.34.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 (92) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -13
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Pragmatic multi-stage hybrid retrieval (inspired by turbopuffer + Pragmatic Engineer playbook).
6
+ *
7
+ * We do NOT call turbopuffer SaaS. We steal the architecture patterns:
8
+ * 1) Multi-query first stage: lexical/BM25-ish list ⊕ dense list (when embedder exists)
9
+ * 2) Rank fusion (RRF) — ranks, not raw scores
10
+ * 3) Attribute-aware first-stage boosts (recency Decay, occurrence Saturate)
11
+ * 4) Field-weighted BM25 second-stage rerank
12
+ * 5) Diversification (limit-per domain/tool) so one theme does not monopolize top-K
13
+ * 6) Dual features on candidates (lexicalRank, denseRank, rrfScore, attributeBoost)
14
+ * 7) Continuous recall sampling hook for offline monitoring
15
+ *
16
+ * Application search logic stays here; indexes stay local (JSONL + optional LanceDB).
17
+ *
18
+ * @see https://turbopuffer.com/docs/hybrid
19
+ * @see https://turbopuffer.com/blog/rank-by-attribute
20
+ * @see https://turbopuffer.com/blog/continuous-recall
21
+ */
22
+
23
+ const path = require('path');
24
+ const fs = require('fs');
25
+ const crypto = require('node:crypto');
26
+
27
+ const DEFAULT_RRF_K = 60;
28
+ const DEFAULT_POOL = 50;
29
+ const DEFAULT_TOP_K = 10;
30
+
31
+ /**
32
+ * Saturate(x) = x^e / (x^e + midpoint^e) ∈ [0, 1)
33
+ * Used for "higher is better" attributes (occurrence count, engagement).
34
+ */
35
+ function saturate(value, midpoint = 3, exponent = 1) {
36
+ const x = Math.max(0, Number(value) || 0);
37
+ const m = Math.max(1e-9, Number(midpoint) || 1);
38
+ const e = Math.max(0.1, Number(exponent) || 1);
39
+ const xe = Math.pow(x, e);
40
+ const me = Math.pow(m, e);
41
+ return xe / (xe + me);
42
+ }
43
+
44
+ /**
45
+ * Decay(x) = midpoint^e / (x^e + midpoint^e) ∈ (0, 1]
46
+ * Used for distances (age in days). Recent → ~1, old → ~0.
47
+ */
48
+ function decay(value, midpoint = 30, exponent = 1) {
49
+ const x = Math.max(0, Number(value) || 0);
50
+ const m = Math.max(1e-9, Number(midpoint) || 1);
51
+ const e = Math.max(0.1, Number(exponent) || 1);
52
+ const me = Math.pow(m, e);
53
+ return me / (Math.pow(x, e) + me);
54
+ }
55
+
56
+ function ageDays(timestamp) {
57
+ if (!timestamp) return null;
58
+ const t = new Date(timestamp).getTime();
59
+ if (!Number.isFinite(t)) return null;
60
+ return Math.max(0, (Date.now() - t) / (1000 * 60 * 60 * 24));
61
+ }
62
+
63
+ /**
64
+ * Attribute boost comparable in scale to a weak BM25 term (~0–1.5).
65
+ * turbopuffer: Sum(BM25, Product(w, Decay/Saturate(attr)))
66
+ */
67
+ function attributeBoost(doc, options = {}) {
68
+ const recencyMidpointDays = options.recencyMidpointDays ?? 30;
69
+ const recencyWeight = options.recencyWeight ?? 0.35;
70
+ const occurrenceMidpoint = options.occurrenceMidpoint ?? 3;
71
+ const occurrenceWeight = options.occurrenceWeight ?? 0.25;
72
+ const negativeSignalWeight = options.negativeSignalWeight ?? 0.15;
73
+
74
+ let boost = 0;
75
+ const age = ageDays(doc.timestamp || doc.metadata?.timestamp);
76
+ if (age != null) {
77
+ boost += recencyWeight * decay(age, recencyMidpointDays, 1);
78
+ }
79
+
80
+ const occurrences = Number(
81
+ doc.metadata?.occurrences
82
+ ?? doc.occurrences
83
+ ?? doc.metadata?.count
84
+ ?? 0,
85
+ );
86
+ if (occurrences > 0) {
87
+ boost += occurrenceWeight * saturate(occurrences, occurrenceMidpoint, 1);
88
+ }
89
+
90
+ const signal = String(doc.signal || '').toLowerCase();
91
+ if (signal === 'negative' || signal === 'down' || (doc.tags || []).includes('negative')) {
92
+ boost += negativeSignalWeight;
93
+ }
94
+
95
+ return boost;
96
+ }
97
+
98
+ /**
99
+ * RRF over ranked id lists. Optional per-list weights (default 1).
100
+ * score = Σ weight_i / (k + rank_i)
101
+ */
102
+ function reciprocalRankFusion(rankedLists, options = {}) {
103
+ const k = Math.max(1, Number(options.k) || DEFAULT_RRF_K);
104
+ const weights = options.weights || rankedLists.map(() => 1);
105
+ const scores = new Map();
106
+
107
+ rankedLists.forEach((list, listIndex) => {
108
+ const w = Number(weights[listIndex]) || 1;
109
+ const ids = (list || []).map((item) => (typeof item === 'string' ? item : item.id)).filter(Boolean);
110
+ ids.forEach((id, rank) => {
111
+ const add = w / (k + rank + 1);
112
+ scores.set(id, (scores.get(id) || 0) + add);
113
+ });
114
+ });
115
+
116
+ return [...scores.entries()]
117
+ .map(([id, score]) => ({ id, score }))
118
+ .sort((a, b) => b.score - a.score);
119
+ }
120
+
121
+ /**
122
+ * Diversify top-K: at most `perLimit` docs share the same key (domain or primary tool).
123
+ * turbopuffer limit.per pattern.
124
+ */
125
+ function diversifyByAttribute(rankedDocs, options = {}) {
126
+ const total = options.total || DEFAULT_TOP_K;
127
+ const perLimit = options.perLimit || 3;
128
+ const keyFn = options.keyFn || ((doc) => {
129
+ const domain = doc.metadata?.domain || doc.tags?.[0] || 'general';
130
+ const tool = (doc.metadata?.toolsUsed || [])[0] || 'any';
131
+ return `${domain}::${tool}`;
132
+ });
133
+
134
+ const counts = new Map();
135
+ const out = [];
136
+ for (const doc of rankedDocs || []) {
137
+ const key = keyFn(doc);
138
+ const n = counts.get(key) || 0;
139
+ if (n >= perLimit) continue;
140
+ counts.set(key, n + 1);
141
+ out.push(doc);
142
+ if (out.length >= total) break;
143
+ }
144
+ // If diversification emptied the list too aggressively, pad with remainder
145
+ if (out.length < total) {
146
+ const seen = new Set(out.map((d) => d.id));
147
+ for (const doc of rankedDocs || []) {
148
+ if (seen.has(doc.id)) continue;
149
+ out.push(doc);
150
+ if (out.length >= total) break;
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * Build multi-query lists + fuse + attribute boost + BM25 rerank.
158
+ *
159
+ * @param {object} params
160
+ * @param {Array<object>} params.corpus - memory-shaped docs with id
161
+ * @param {string} params.query
162
+ * @param {string} [params.toolName]
163
+ * @param {object} [params.options]
164
+ */
165
+ function pragmaticHybridSearch(params = {}) {
166
+ const {
167
+ corpus = [],
168
+ query = '',
169
+ toolName = 'Bash',
170
+ options = {},
171
+ } = params;
172
+
173
+ const {
174
+ scoreRelevance,
175
+ buildActionSignature,
176
+ reciprocalRankFusion: rrfFromLesson,
177
+ } = require('./lesson-retrieval');
178
+ const { rerankLessons } = require('./lesson-reranker');
179
+
180
+ const topK = options.topK || DEFAULT_TOP_K;
181
+ const pool = options.pool || DEFAULT_POOL;
182
+ const rrfK = options.rrfK || DEFAULT_RRF_K;
183
+ const diversify = options.diversify !== false;
184
+ const denseRankedIds = options.denseRankedIds || []; // precomputed dense order (optional)
185
+ const queryVariants = [...new Set(
186
+ [query, ...(options.queryVariants || [])]
187
+ .map((value) => String(value || '').trim())
188
+ .filter(Boolean),
189
+ )].slice(0, 4);
190
+
191
+ // --- Query 1: lexical / sparse (always) ---
192
+ // Attribute boost reorders candidates that already have lexical signal (or will
193
+ // enter via dense multi-query). It must NOT alone promote zero-overlap docs —
194
+ // that would break the lexical-vs-hybrid paraphrase contract and flood top-K
195
+ // with recent-but-unrelated mistakes (turbopuffer: attr is another clause, not
196
+ // a substitute for matching).
197
+ const denseIdSet = new Set((denseRankedIds || []).slice(0, pool));
198
+ const lexicalLists = [];
199
+ const bestLexicalById = new Map();
200
+ for (const variant of queryVariants) {
201
+ const actionSig = buildActionSignature(toolName, variant);
202
+ const scoredForVariant = corpus.map((mem) => {
203
+ const base = scoreRelevance(mem, toolName, variant, actionSig);
204
+ const attr = attributeBoost(mem, options.attribute);
205
+ const inDense = denseIdSet.has(mem.id);
206
+ const relevanceScore = (base > 0.1 || inDense) ? (base + attr) : 0;
207
+ return {
208
+ ...mem,
209
+ lexicalScore: base,
210
+ attributeBoost: attr,
211
+ relevanceScore,
212
+ };
213
+ })
214
+ .filter((memory) => memory.relevanceScore > 0.05)
215
+ .sort((left, right) => right.relevanceScore - left.relevanceScore);
216
+ lexicalLists.push(scoredForVariant.slice(0, pool).map((memory) => memory.id));
217
+ for (const memory of scoredForVariant) {
218
+ const previous = bestLexicalById.get(memory.id);
219
+ if (!previous || memory.relevanceScore > previous.relevanceScore) {
220
+ bestLexicalById.set(memory.id, memory);
221
+ }
222
+ }
223
+ }
224
+
225
+ const lexicalScored = [...bestLexicalById.values()]
226
+ .sort((left, right) => right.relevanceScore - left.relevanceScore);
227
+ /*
228
+ * The best score carries candidate metadata; each query variant keeps its
229
+ * own ranked list for RRF so expansion cannot overwrite the original query.
230
+ */
231
+ const lexicalRanked = lexicalLists[0] || [];
232
+
233
+ // --- Query 2: dense (optional; caller supplies ids when embedder ran) ---
234
+ const denseRanked = (denseRankedIds || []).slice(0, pool);
235
+ const denseWeight = Math.max(0.1, Number(options.denseWeight) || 1.5);
236
+
237
+ // --- Fuse multi-query ranks (RRF) ---
238
+ const lists = denseRanked.length > 0
239
+ ? [...lexicalLists, denseRanked]
240
+ : lexicalLists;
241
+ const weights = denseRanked.length > 0
242
+ ? [...lexicalLists.map(() => 1), denseWeight]
243
+ : lexicalLists.map(() => 1);
244
+ const fuse = typeof rrfFromLesson === 'function'
245
+ ? rrfFromLesson(lists, { k: rrfK, weights })
246
+ : reciprocalRankFusion(lists, { k: rrfK, weights });
247
+
248
+ const byId = new Map(corpus.map((m) => [m.id, m]));
249
+ const lexMeta = new Map(lexicalScored.map((m, i) => [m.id, {
250
+ lexicalRank: i + 1,
251
+ lexicalScore: m.lexicalScore,
252
+ attributeBoost: m.attributeBoost,
253
+ relevanceScore: m.relevanceScore,
254
+ }]));
255
+ const denseRankMap = new Map(denseRanked.map((id, i) => [id, i + 1]));
256
+
257
+ const maxFusionScore = fuse[0]?.score || 1;
258
+ const candidates = fuse.slice(0, pool).map((entry) => {
259
+ const mem = byId.get(entry.id);
260
+ if (!mem) return null;
261
+ const meta = lexMeta.get(entry.id) || {};
262
+ return {
263
+ ...mem,
264
+ relevanceScore: meta.relevanceScore ?? entry.score,
265
+ rrfScore: entry.score,
266
+ lexicalRank: meta.lexicalRank || null,
267
+ denseRank: denseRankMap.get(entry.id) || null,
268
+ fusionScoreNormalized: entry.score / maxFusionScore,
269
+ lexicalScore: meta.lexicalScore ?? 0,
270
+ attributeBoost: meta.attributeBoost ?? 0,
271
+ hybridFeatures: {
272
+ rrfScore: entry.score,
273
+ lexicalRank: meta.lexicalRank || null,
274
+ denseRank: denseRankMap.get(entry.id) || null,
275
+ attributeBoost: meta.attributeBoost ?? 0,
276
+ fusionScoreNormalized: entry.score / maxFusionScore,
277
+ },
278
+ };
279
+ }).filter(Boolean);
280
+
281
+ if (candidates.length === 0) {
282
+ return {
283
+ results: [],
284
+ meta: {
285
+ strategy: denseRanked.length ? 'hybrid-rrf' : 'lexical-attribute',
286
+ lexicalPool: lexicalRanked.length,
287
+ densePool: denseRanked.length,
288
+ fused: 0,
289
+ rerankApplied: false,
290
+ queryVariants,
291
+ },
292
+ };
293
+ }
294
+
295
+ // --- Second stage: field-weighted BM25F rerank ---
296
+ let reranked = rerankLessons(query, candidates, { topK: pool, toolName });
297
+
298
+ // Light blend of attribute boost into reranked score (keeps recency after BM25)
299
+ reranked = reranked.map((doc) => {
300
+ const attr = doc.attributeBoost ?? attributeBoost(doc, options.attribute);
301
+ const base = doc.rerankedScore ?? doc.relevanceScore ?? 0;
302
+ const fusionWeight = denseRanked.length > 0
303
+ ? Math.max(0, Math.min(1, Number(options.fusionWeight) || 0.7))
304
+ : 0;
305
+ return {
306
+ ...doc,
307
+ attributeBoost: attr,
308
+ rerankedScore: (
309
+ (1 - fusionWeight) * base
310
+ + fusionWeight * (doc.fusionScoreNormalized || 0)
311
+ + 0.05 * attr
312
+ ),
313
+ };
314
+ }).sort((a, b) => (b.rerankedScore || 0) - (a.rerankedScore || 0));
315
+
316
+ const diversified = diversify
317
+ ? diversifyByAttribute(reranked, {
318
+ total: topK,
319
+ perLimit: options.perLimit || 3,
320
+ })
321
+ : reranked.slice(0, topK);
322
+
323
+ return {
324
+ results: diversified,
325
+ meta: {
326
+ strategy: denseRanked.length ? 'hybrid-rrf+attr+rerank' : 'lexical-attr+rerank',
327
+ lexicalPool: lexicalRanked.length,
328
+ densePool: denseRanked.length,
329
+ fused: fuse.length,
330
+ diversified: diversify,
331
+ rrfK,
332
+ denseWeight,
333
+ queryVariants,
334
+ rerankApplied: true,
335
+ },
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Continuous recall sampling (turbopuffer spirit): append a sample of retrieval
341
+ * outcomes for offline monitoring. Never throws; never blocks the hot path.
342
+ */
343
+ function sampleRetrievalRecall(event, options = {}) {
344
+ try {
345
+ if (options.enabled === false) return { sampled: false };
346
+ const rate = Number(options.sampleRate ?? process.env.THUMBGATE_RETRIEVAL_RECALL_SAMPLE_RATE ?? 0.02);
347
+ // crypto PRNG — not security-critical sampling, but avoid Math.random for Sonar S2245
348
+ const roll = crypto.randomInt(0, 1_000_000) / 1_000_000;
349
+ if (!(rate > 0) || roll > rate) return { sampled: false };
350
+
351
+ const feedbackDir = options.feedbackDir
352
+ || process.env.THUMBGATE_FEEDBACK_DIR
353
+ || path.join(process.cwd(), '.thumbgate');
354
+ const outDir = path.join(feedbackDir, 'retrieval-recall-samples');
355
+ fs.mkdirSync(outDir, { recursive: true });
356
+ const line = JSON.stringify({
357
+ ts: new Date().toISOString(),
358
+ ...event,
359
+ });
360
+ fs.appendFileSync(path.join(outDir, 'samples.jsonl'), `${line}\n`, 'utf8');
361
+ return { sampled: true };
362
+ } catch {
363
+ return { sampled: false, error: true };
364
+ }
365
+ }
366
+
367
+ module.exports = {
368
+ saturate,
369
+ decay,
370
+ ageDays,
371
+ attributeBoost,
372
+ reciprocalRankFusion,
373
+ diversifyByAttribute,
374
+ pragmaticHybridSearch,
375
+ sampleRetrievalRecall,
376
+ DEFAULT_RRF_K,
377
+ DEFAULT_POOL,
378
+ DEFAULT_TOP_K,
379
+ };
@@ -491,12 +491,19 @@ function normalizeProviderAction(input = {}) {
491
491
 
492
492
  function normalizeBudget(input = {}) {
493
493
  const budget = asObject(input);
494
+ const hasNumericAlias = (...keys) => keys.some((key) => (
495
+ Object.hasOwn(budget, key) && Number.isFinite(Number(budget[key]))
496
+ ));
494
497
  return {
495
498
  maxTokensPerAction: firstNumber(budget.maxTokensPerAction, budget.perActionTokens, budget.tokenLimit),
496
499
  remainingTokens: firstNumber(budget.remainingTokens, budget.tokensRemaining),
497
500
  maxCostUsdPerAction: firstNumber(budget.maxCostUsdPerAction, budget.perActionCostUsd, budget.costLimitUsd),
498
501
  remainingCostUsd: firstNumber(budget.remainingCostUsd, budget.costUsdRemaining),
499
502
  maxParallelBranches: firstNumber(budget.maxParallelBranches, budget.parallelBranchLimit, DEFAULT_MAX_PARALLEL_BRANCHES),
503
+ hasMaxTokensPerAction: hasNumericAlias('maxTokensPerAction', 'perActionTokens', 'tokenLimit'),
504
+ hasRemainingTokens: hasNumericAlias('remainingTokens', 'tokensRemaining'),
505
+ hasMaxCostUsdPerAction: hasNumericAlias('maxCostUsdPerAction', 'perActionCostUsd', 'costLimitUsd'),
506
+ hasRemainingCostUsd: hasNumericAlias('remainingCostUsd', 'costUsdRemaining'),
500
507
  };
501
508
  }
502
509
 
@@ -540,16 +547,16 @@ function buildCostControl(normalizedAction = {}, budgetInput = {}) {
540
547
  const totalTokens = firstNumber(usage.totalTokens);
541
548
  const estimatedCostUsd = firstNumber(usage.estimatedCostUsd);
542
549
 
543
- if (budget.maxTokensPerAction > 0 && totalTokens > budget.maxTokensPerAction) {
550
+ if (budget.hasMaxTokensPerAction && totalTokens > budget.maxTokensPerAction) {
544
551
  reasons.push(`Token estimate ${totalTokens} exceeds per-action limit ${budget.maxTokensPerAction}.`);
545
552
  }
546
- if (budget.remainingTokens > 0 && totalTokens > budget.remainingTokens) {
553
+ if (budget.hasRemainingTokens && totalTokens > budget.remainingTokens) {
547
554
  reasons.push(`Token estimate ${totalTokens} exceeds remaining budget ${budget.remainingTokens}.`);
548
555
  }
549
- if (budget.maxCostUsdPerAction > 0 && estimatedCostUsd > budget.maxCostUsdPerAction) {
556
+ if (budget.hasMaxCostUsdPerAction && estimatedCostUsd > budget.maxCostUsdPerAction) {
550
557
  reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds per-action limit $${budget.maxCostUsdPerAction.toFixed(4)}.`);
551
558
  }
552
- if (budget.remainingCostUsd > 0 && estimatedCostUsd > budget.remainingCostUsd) {
559
+ if (budget.hasRemainingCostUsd && estimatedCostUsd > budget.remainingCostUsd) {
553
560
  reasons.push(`Estimated cost $${estimatedCostUsd.toFixed(4)} exceeds remaining budget $${budget.remainingCostUsd.toFixed(4)}.`);
554
561
  }
555
562
  if (budget.maxParallelBranches > 0 && normalizedAction.workflow?.branchCount > budget.maxParallelBranches) {