thumbgate 1.29.1 → 1.30.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 (77) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +61 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/forge/forge.yaml +3 -3
  8. package/adapters/mcp/server-stdio.js +164 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/bin/cli.js +7 -5
  11. package/commands/dashboard.md +11 -1
  12. package/commands/thumbgate-dashboard.md +23 -8
  13. package/config/agent-outcome-monitor-thresholds.json +63 -0
  14. package/config/evals/agent-outcomes-baseline.json +17 -0
  15. package/config/evals/agent-outcomes-golden.json +412 -0
  16. package/config/evals/prompt-eval-baseline.json +23 -0
  17. package/config/mcp-allowlists.json +26 -2
  18. package/config/post-deploy-marketing-pages.json +26 -1
  19. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  20. package/openapi/openapi.yaml +235 -0
  21. package/package.json +55 -11
  22. package/public/architecture.html +130 -0
  23. package/public/assets/diagrams/agent-integration.png +0 -0
  24. package/public/assets/diagrams/before-after.svg +21 -0
  25. package/public/assets/diagrams/decision.svg +36 -0
  26. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  27. package/public/assets/diagrams/loop.svg +34 -0
  28. package/public/assets/diagrams/plugin-topology.png +0 -0
  29. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/eval-scorecard.html +195 -0
  34. package/public/eval-scorecard.json +18 -0
  35. package/public/evaluations.html +168 -0
  36. package/public/index.html +6 -3
  37. package/public/numbers.html +2 -2
  38. package/public/whitepaper.html +189 -0
  39. package/scripts/activation-quickstart.js +1 -0
  40. package/scripts/agent-outcome-eval.js +130 -0
  41. package/scripts/agent-outcome-monitor.js +331 -0
  42. package/scripts/agent-reasoning-traces.js +8 -9
  43. package/scripts/async-job-runner.js +107 -13
  44. package/scripts/billing.js +3 -1
  45. package/scripts/claude-feedback-sync.js +3 -2
  46. package/scripts/cli-feedback.js +13 -7
  47. package/scripts/cross-encoder-reranker.js +3 -0
  48. package/scripts/durability/step.js +121 -12
  49. package/scripts/feedback-aggregate.js +5 -2
  50. package/scripts/feedback-loop.js +244 -182
  51. package/scripts/gates-engine.js +512 -22
  52. package/scripts/generate-case-study-outreach.js +253 -0
  53. package/scripts/generate-eval-scorecard.js +276 -0
  54. package/scripts/growth-campaigns.js +183 -0
  55. package/scripts/human-escalation.js +265 -0
  56. package/scripts/hybrid-feedback-context.js +93 -50
  57. package/scripts/jsonl-watcher.js +1 -0
  58. package/scripts/judge-reward-function.js +30 -18
  59. package/scripts/lesson-inference.js +23 -4
  60. package/scripts/lesson-retrieval.js +71 -4
  61. package/scripts/lesson-search.js +26 -3
  62. package/scripts/mcp-config.js +26 -5
  63. package/scripts/mcp-oauth.js +37 -2
  64. package/scripts/model-eval.js +308 -0
  65. package/scripts/parallel-workflow-orchestrator.js +86 -22
  66. package/scripts/prompt-eval.js +81 -4
  67. package/scripts/published-cli.js +11 -1
  68. package/scripts/refresh-proof-pack.js +261 -0
  69. package/scripts/risk-scorer.js +144 -15
  70. package/scripts/schedule-manager.js +249 -0
  71. package/scripts/statusline-local-stats.js +1 -1
  72. package/scripts/task-outcomes.js +425 -0
  73. package/scripts/thumbgate-bench.js +13 -0
  74. package/scripts/tool-contract-validator.js +287 -59
  75. package/scripts/tool-kpi-tracker.js +124 -0
  76. package/scripts/tool-registry.js +192 -1
  77. package/src/api/server.js +355 -89
@@ -0,0 +1,189 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <meta name="generator" content="ThumbGate">
7
+ <meta name="author" content="Igor Ganapolsky">
8
+ <title>How We Know ThumbGate Works — Evaluation White Paper</title>
9
+ <meta name="description" content="Evaluation white paper for ThumbGate: golden datasets, offline regression, tool-call correctness, latency, cost, human review, and production monitoring — with diagrams and re-run commands.">
10
+ <meta property="og:title" content="How We Know ThumbGate Works">
11
+ <meta property="og:description" content="Seven proof dimensions for a pre-action gate: goldens, offline tests, tool-call metrics, latency, cost, human review, production monitoring.">
12
+ <meta property="og:type" content="article">
13
+ <meta property="og:url" content="https://thumbgate.ai/whitepaper">
14
+ <link rel="canonical" href="https://thumbgate.ai/whitepaper">
15
+ <link rel="icon" type="image/png" href="/thumbgate-icon.png">
16
+ <script defer data-domain="thumbgate.ai" src="https://plausible.io/js/script.js"></script>
17
+ <script type="application/ld+json">
18
+ {
19
+ "@context": "https://schema.org",
20
+ "@type": "TechArticle",
21
+ "headline": "How We Know ThumbGate Works",
22
+ "description": "Buyer-facing evaluation white paper covering golden datasets, offline regression, tool-call correctness, latency, cost, human review, and production monitoring.",
23
+ "url": "https://thumbgate.ai/whitepaper",
24
+ "dateModified": "2026-07-29",
25
+ "author": { "@type": "Person", "name": "Igor Ganapolsky", "url": "https://github.com/IgorGanapolsky" }
26
+ }
27
+ </script>
28
+ <style>
29
+ :root {
30
+ --bg:#0b0f14; --panel:#111823; --border:#1e2a3a; --text:#e6edf3;
31
+ --muted:#8b98a5; --cyan:#39c5cf; --green:#3fb950; --red:#f85149;
32
+ }
33
+ * { margin:0; padding:0; box-sizing:border-box; }
34
+ body { background:var(--bg); color:var(--text); font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; line-height:1.65; }
35
+ nav { padding:1rem 2rem; border-bottom:1px solid var(--border); display:flex; gap:1.25rem; flex-wrap:wrap; align-items:center; }
36
+ nav a { color:var(--muted); text-decoration:none; font-size:0.9rem; }
37
+ nav a:hover { color:var(--cyan); }
38
+ nav .brand { color:var(--text); font-weight:700; }
39
+ .container { max-width:900px; margin:0 auto; padding:2.5rem 1.5rem 4rem; }
40
+ h1 { font-size:2rem; margin-bottom:0.4rem; }
41
+ h2 { font-size:1.3rem; margin:2.3rem 0 0.7rem; color:var(--cyan); }
42
+ h3 { font-size:1.05rem; margin:1.3rem 0 0.4rem; }
43
+ .subtitle { color:var(--muted); font-size:1.05rem; margin-bottom:1.25rem; }
44
+ p, li { margin:0.65rem 0; }
45
+ ul, ol { padding-left:1.3rem; }
46
+ table { width:100%; border-collapse:collapse; margin:1rem 0; font-size:0.92rem; }
47
+ th, td { text-align:left; padding:0.5rem 0.65rem; border-bottom:1px solid var(--border); vertical-align:top; }
48
+ th { color:var(--muted); }
49
+ code { background:#0d1420; border:1px solid var(--border); border-radius:4px; padding:0.1rem 0.35rem; font-size:0.86em; color:var(--cyan); }
50
+ pre { background:#0d1420; border:1px solid var(--border); border-radius:8px; padding:1rem; overflow-x:auto; font-size:0.85rem; margin:1rem 0; }
51
+ .panel { background:var(--panel); border:1px solid var(--border); border-radius:10px; padding:1.1rem 1.25rem; margin:1rem 0; }
52
+ .rule { border-left:3px solid var(--cyan); padding:0.65rem 1rem; background:var(--panel); margin:1.25rem 0; font-weight:600; }
53
+ figure { background:var(--panel); border:1px solid var(--border); border-radius:12px; padding:1rem; margin:1rem 0; }
54
+ figure img { width:100%; height:auto; display:block; border-radius:8px; }
55
+ figcaption { color:var(--muted); font-size:0.88rem; margin-top:0.65rem; }
56
+ a.cta { display:inline-block; background:var(--cyan); color:#001018; font-weight:700; padding:0.7rem 1rem; border-radius:8px; text-decoration:none; margin:0.4rem 0.6rem 0.4rem 0; }
57
+ a.inline { color:var(--cyan); }
58
+ .footer-note { color:var(--muted); font-size:0.85rem; margin-top:2.5rem; text-align:center; }
59
+ .footer-note a { color:var(--cyan); text-decoration:none; }
60
+ .good { color:var(--green); } .bad { color:var(--red); }
61
+ </style>
62
+ </head>
63
+ <body>
64
+ <nav>
65
+ <a class="brand" href="/">ThumbGate</a>
66
+ <a href="/whitepaper">White paper</a>
67
+ <a href="/architecture">Architecture</a>
68
+ <a href="/eval-scorecard">Scorecard</a>
69
+ <a href="/evaluations">Evaluations</a>
70
+ <a href="/case-studies">Case studies</a>
71
+ <a href="/numbers">Numbers</a>
72
+ <a href="/pricing">Pricing</a>
73
+ </nav>
74
+ <main class="container">
75
+ <h1>How we know ThumbGate works</h1>
76
+ <p class="subtitle">Evaluation white paper · Updated 2026-07-29 · Source: <code>docs/HOW-WE-KNOW-IT-WORKS.md</code></p>
77
+
78
+ <div class="rule">Offline goldens + live monitors, not vibes. <code>unsafeActionRate</code> must stay <span class="good">0</span>.</div>
79
+
80
+ <p>ThumbGate is a local-first PreToolUse enforcement layer: feedback becomes lessons, repeated failures become gates, and risky tool calls are checked before execution. This page is the buyer-readable map from that claim to evidence.</p>
81
+
82
+ <figure>
83
+ <img src="/assets/diagrams/thumbgate-architecture.png" alt="ThumbGate system architecture diagram" width="1200" height="800" loading="eager">
84
+ <figcaption>System map: agents, hooks, gate engine, governance, security, feedback promotion. Full gallery on <a class="inline" href="/architecture">/architecture</a>.</figcaption>
85
+ </figure>
86
+
87
+ <h2>1. Golden evaluation dataset</h2>
88
+ <p>Committed suites encode expected allow / deny / warn decisions and reference answers — not aspirational prose.</p>
89
+ <table>
90
+ <tr><th>Pack</th><th>Path</th></tr>
91
+ <tr><td>ThumbGate Bench</td><td><code>bench/thumbgate-bench.json</code></td></tr>
92
+ <tr><td>Agent safety eval</td><td><code>config/evals/agent-safety-eval.json</code></td></tr>
93
+ <tr><td>Prompt eval</td><td><code>bench/prompt-eval-suite.json</code></td></tr>
94
+ <tr><td>Observability / grounding</td><td><code>bench/observability-eval-suite.json</code></td></tr>
95
+ <tr><td>ProgramBench smoke</td><td><code>bench/programbench-smoke.json</code></td></tr>
96
+ <tr><td>Shell golden tests</td><td><code>verification/golden_tests/**</code></td></tr>
97
+ </table>
98
+ <p>Live failures feed offline goldens through <code>scripts/llm-behavior-monitor.js</code> promotion candidates.</p>
99
+
100
+ <h2>2. Offline regression tests</h2>
101
+ <pre>npm test
102
+ npm run test:coverage
103
+ npm run prove:adapters
104
+ npm run prove:automation
105
+ npm run self-heal:check</pre>
106
+ <p>Observed results land in <a class="inline" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/VERIFICATION_EVIDENCE.md">docs/VERIFICATION_EVIDENCE.md</a>. Releases also require Changesets, SemVer, and version sync.</p>
107
+
108
+ <h2>3. Tool-call correctness</h2>
109
+ <p>ThumbGate Bench measures whether the gate decides correctly on the actual tool call.</p>
110
+ <table>
111
+ <tr><th>Metric</th><th>Intent</th></tr>
112
+ <tr><td><code>taskSuccessRate</code></td><td>Decision matches expected</td></tr>
113
+ <tr><td><code>unsafeActionRate</code></td><td class="good">Must stay 0</td></tr>
114
+ <tr><td><code>capabilityRate</code></td><td>Safe work still allowed</td></tr>
115
+ <tr><td><code>falseBlockRate</code></td><td>No “block everything” cheat</td></tr>
116
+ <tr><td><code>replayStability</code></td><td>Same suite → same decisions</td></tr>
117
+ </table>
118
+ <p><a class="cta" href="/eval-scorecard">Open the live scorecard</a> <a class="inline" href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/THUMBGATE_BENCH.md">Bench methodology →</a></p>
119
+
120
+ <h2>4. Latency</h2>
121
+ <figure>
122
+ <img src="/assets/diagrams/pre-action-gate-loop.svg" alt="Pre-action gate loop" width="900" height="360" loading="lazy">
123
+ <figcaption>Enforcement is local: deterministic match first, optional local embeddings, no cloud model on the critical path.</figcaption>
124
+ </figure>
125
+ <ul>
126
+ <li>No LLM on block/allow for the PreToolUse decision</li>
127
+ <li>Routing budgets: <code>lowLatencyBudgetMs: 300</code> for the fast classical lane</li>
128
+ <li>High-risk ambiguity can stop for human review instead of spending tokens</li>
129
+ </ul>
130
+
131
+ <h2>5. Cost</h2>
132
+ <ul>
133
+ <li>A blocked tool call does not spend the model round-trip</li>
134
+ <li><code>npx thumbgate cost</code> prints conservative $ saved from recorded blocks</li>
135
+ <li>Budget ledger + tokenomics guardrails prevent runaway spend</li>
136
+ <li>Methodology: <a class="inline" href="/agents-cost-savings">/agents-cost-savings</a> · inventory: <a class="inline" href="/numbers">/numbers</a></li>
137
+ </ul>
138
+
139
+ <h2>6. Human review</h2>
140
+ <ul>
141
+ <li>Classifier route <code>human_review</code> for credentials, customer data, regulated workflows, payments</li>
142
+ <li>Rubrics require verification evidence before “done”</li>
143
+ <li>Protected actions need scoped approval; break-glass is short-lived and narrow</li>
144
+ <li>Outbound social replies stay draft-only until a human publishes</li>
145
+ </ul>
146
+
147
+ <h2>7. Production monitoring</h2>
148
+ <figure>
149
+ <img src="/assets/diagrams/feedback-pipeline.png" alt="Feedback pipeline sequence diagram" width="1200" height="700" loading="lazy">
150
+ <figcaption>Capture → promote → block the repeat. Production monitors exist to catch silence (fail-open hooks) as well as loud breakage.</figcaption>
151
+ </figure>
152
+ <ul>
153
+ <li>Deploy health: Railway <code>/health</code> version + dashboard smoke</li>
154
+ <li><code>self-heal:check</code> — budget, tests, prove lanes must be HEALTHY</li>
155
+ <li>Gate stats + LLM behavior rates (wrong-tool, malformed, drift)</li>
156
+ <li>Published-artifact evasion jobs and silent-gate canaries</li>
157
+ </ul>
158
+
159
+ <h2>20-minute buyer audit</h2>
160
+ <pre>git clone https://github.com/IgorGanapolsky/ThumbGate
161
+ cd ThumbGate &amp;&amp; npm ci
162
+ npm test
163
+ npm run prove:adapters &amp;&amp; npm run prove:automation
164
+ npm run self-heal:check
165
+ npm run thumbgate:bench -- --json
166
+ curl -s https://thumbgate-production.up.railway.app/health</pre>
167
+
168
+ <h2>What this paper is not</h2>
169
+ <div class="panel">
170
+ <ul>
171
+ <li>Not external customer revenue proof — see commercial truth in the repo</li>
172
+ <li>Not a claim that free installs hard-block every risky command by default</li>
173
+ <li>Not a substitute for human review on payments, credentials, or regulated actions</li>
174
+ </ul>
175
+ </div>
176
+
177
+ <p>
178
+ <a class="cta" href="/case-studies">Case studies</a>
179
+ <a class="cta" href="/evaluations">ML evaluations</a>
180
+ <a class="cta" href="/architecture">Diagram gallery</a>
181
+ </p>
182
+
183
+ <p class="footer-note">
184
+ Longform source: <a href="https://github.com/IgorGanapolsky/ThumbGate/blob/main/docs/HOW-WE-KNOW-IT-WORKS.md">docs/HOW-WE-KNOW-IT-WORKS.md</a>.
185
+ Built by <a href="https://github.com/IgorGanapolsky">Igor Ganapolsky</a>.
186
+ </p>
187
+ </main>
188
+ </body>
189
+ </html>
@@ -93,6 +93,7 @@ async function runActivationFlow({ ask, out, isTTY, deps = {} }) {
93
93
  whatToChange: `Block this action: ${mistake}`,
94
94
  tags: 'quickstart,activation,first-rule',
95
95
  gateAction: 'block',
96
+ reviewOrigin: 'human',
96
97
  });
97
98
  } catch {
98
99
  // Capture failure should not abort the activation aha.
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const {
7
+ TASK_OUTCOME_SCHEMA,
8
+ calculateTaskOutcomeMetrics,
9
+ normalizeTaskOutcome,
10
+ } = require('./task-outcomes');
11
+ const { validateToolContract } = require('./tool-contract-validator');
12
+
13
+ const ROOT = path.join(__dirname, '..');
14
+ const DEFAULT_SUITE = path.join(ROOT, 'config', 'evals', 'agent-outcomes-golden.json');
15
+ const DEFAULT_BASELINE = path.join(ROOT, 'config', 'evals', 'agent-outcomes-baseline.json');
16
+
17
+ function runCase(testCase = {}) {
18
+ const receipt = normalizeTaskOutcome(testCase.receipt, new Date('2026-07-26T00:00:00.000Z'));
19
+ const validation = validateToolContract(TASK_OUTCOME_SCHEMA, receipt);
20
+ const expectedReasons = [...(testCase.expected?.reasons || [])]
21
+ .sort((left, right) => left.localeCompare(right));
22
+ const actualReasons = [...(receipt.workingReasons || [])]
23
+ .sort((left, right) => left.localeCompare(right));
24
+ const checks = [
25
+ {
26
+ id: 'schema_valid',
27
+ pass: validation.valid,
28
+ detail: validation.valid ? 'receipt schema valid' : validation.errors.join('; '),
29
+ },
30
+ {
31
+ id: 'working_verdict',
32
+ pass: receipt.working === testCase.expected?.working,
33
+ detail: `expected ${testCase.expected?.working}, got ${receipt.working}`,
34
+ },
35
+ {
36
+ id: 'reason_codes',
37
+ pass: JSON.stringify(actualReasons) === JSON.stringify(expectedReasons),
38
+ detail: `expected [${expectedReasons.join(', ')}], got [${actualReasons.join(', ')}]`,
39
+ },
40
+ ];
41
+ const passed = checks.every((check) => check.pass);
42
+ return {
43
+ id: testCase.id,
44
+ status: passed ? 'pass' : 'fail',
45
+ score: Math.round((checks.filter((check) => check.pass).length / checks.length) * 100),
46
+ severity: testCase.severity,
47
+ split: testCase.split,
48
+ checks,
49
+ receipt,
50
+ };
51
+ }
52
+
53
+ function runAgentOutcomeEval(options = {}) {
54
+ const suitePath = path.resolve(options.suitePath || DEFAULT_SUITE);
55
+ const suite = JSON.parse(fs.readFileSync(suitePath, 'utf8'));
56
+ if (!Array.isArray(suite.cases) || suite.cases.length === 0) {
57
+ throw new Error('Agent outcome suite must contain non-empty cases');
58
+ }
59
+ const results = suite.cases.map(runCase);
60
+ const passed = results.filter((result) => result.status === 'pass').length;
61
+ const score = Math.round((results.reduce((sum, result) => sum + result.score, 0) / results.length));
62
+ const minimumCases = Number(suite.successCriteria?.minimumCases || 1);
63
+ const minimumScore = Number(suite.successCriteria?.minAggregateScore || 100);
64
+ const baselinePath = path.resolve(options.baselinePath || DEFAULT_BASELINE);
65
+ const baseline = fs.existsSync(baselinePath)
66
+ ? JSON.parse(fs.readFileSync(baselinePath, 'utf8'))
67
+ : null;
68
+ const regressions = compareBaseline(results, baseline);
69
+ const report = {
70
+ suite: suite.name,
71
+ generatedAt: new Date().toISOString(),
72
+ evidenceStatus: results.length >= minimumCases ? 'measured' : 'insufficient_evidence',
73
+ total: results.length,
74
+ passed,
75
+ failed: results.length - passed,
76
+ score,
77
+ minimumScore,
78
+ minimumCases,
79
+ regressions,
80
+ pass: results.length >= minimumCases
81
+ && score >= minimumScore
82
+ && (!suite.successCriteria?.requireNoRegressions || regressions.length === 0),
83
+ metrics: calculateTaskOutcomeMetrics(results.map((result) => result.receipt)),
84
+ results,
85
+ };
86
+ return report;
87
+ }
88
+
89
+ function compareBaseline(results, baseline) {
90
+ if (!baseline) return [];
91
+ const current = new Map(results.map((result) => [result.id, result]));
92
+ const regressions = [];
93
+ for (const previous of baseline.results || []) {
94
+ const result = current.get(previous.id);
95
+ if (!result) {
96
+ regressions.push({ id: previous.id, reason: 'missing_case' });
97
+ } else if (result.score < previous.score || (previous.status === 'pass' && result.status !== 'pass')) {
98
+ regressions.push({
99
+ id: previous.id,
100
+ reason: 'score_or_status_regression',
101
+ baselineScore: previous.score,
102
+ currentScore: result.score,
103
+ });
104
+ }
105
+ }
106
+ return regressions;
107
+ }
108
+
109
+ function isCliInvocation() {
110
+ return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === __filename;
111
+ }
112
+
113
+ if (isCliInvocation()) {
114
+ const suitePath = process.argv.find((arg) => arg.startsWith('--suite='))?.slice(8);
115
+ const baselinePath = process.argv.find((arg) => arg.startsWith('--baseline='))?.slice(11);
116
+ const outputPath = process.argv.find((arg) => arg.startsWith('--output='))?.slice(9);
117
+ const report = runAgentOutcomeEval({ suitePath, baselinePath });
118
+ if (outputPath) {
119
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
120
+ fs.writeFileSync(path.resolve(outputPath), `${JSON.stringify(report, null, 2)}\n`, 'utf8');
121
+ }
122
+ console.log(JSON.stringify(report, null, 2));
123
+ process.exitCode = report.pass ? 0 : 1;
124
+ }
125
+
126
+ module.exports = {
127
+ compareBaseline,
128
+ runAgentOutcomeEval,
129
+ runCase,
130
+ };
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const { calculateTaskOutcomeMetrics, readTaskOutcomes } = require('./task-outcomes');
8
+ const { computeToolKpis } = require('./tool-kpi-tracker');
9
+
10
+ const DEFAULT_THRESHOLDS = path.join(__dirname, '..', 'config', 'agent-outcome-monitor-thresholds.json');
11
+ const DEFAULT_HOSTED_ORIGIN = 'https://thumbgate-production.up.railway.app';
12
+ const DEFAULT_MONITOR_PATH = '/v1/task-outcomes/monitor';
13
+ const DEFAULT_SCHEDULE_ID = 'thumbgate-agent-outcome-monitor';
14
+ const DEFAULT_MINIMUM_TOOL_CALLS = 20;
15
+
16
+ function monitorTaskOutcomes(outcomes = [], options = {}) {
17
+ const metrics = calculateTaskOutcomeMetrics(outcomes);
18
+ const thresholds = options.thresholds || JSON.parse(fs.readFileSync(
19
+ path.resolve(options.thresholdsPath || DEFAULT_THRESHOLDS),
20
+ 'utf8',
21
+ ));
22
+ const minimumSamples = Number(thresholds.minimumSamples || 1);
23
+ if (metrics.sampleSize < minimumSamples) {
24
+ return {
25
+ generatedAt: new Date().toISOString(),
26
+ verdict: 'insufficient_evidence',
27
+ sampleSize: metrics.sampleSize,
28
+ minimumSamples,
29
+ alerts: [{
30
+ id: 'minimum-samples',
31
+ severity: 'block',
32
+ message: `Need ${minimumSamples} task outcomes; observed ${metrics.sampleSize}.`,
33
+ }],
34
+ metrics,
35
+ };
36
+ }
37
+
38
+ const values = flattenMetricValues(metrics);
39
+ const alerts = [];
40
+ for (const [id, rule] of Object.entries(thresholds)) {
41
+ if (id === 'minimumSamples') continue;
42
+ const actual = values[id];
43
+ if (actual === null || actual === undefined) {
44
+ alerts.push({
45
+ id: `${id}-missing`,
46
+ severity: rule.severity || 'warn',
47
+ message: `${id} has no measured denominator.`,
48
+ });
49
+ continue;
50
+ }
51
+ if (!passesRule(actual, rule)) {
52
+ alerts.push({
53
+ id: `${id}-threshold`,
54
+ severity: rule.severity || 'warn',
55
+ actual,
56
+ expected: `${rule.operator} ${rule.value}`,
57
+ message: `${id}=${actual} violates ${rule.operator} ${rule.value}.`,
58
+ });
59
+ }
60
+ }
61
+ let verdict = 'healthy';
62
+ if (alerts.some((alert) => alert.severity === 'block')) {
63
+ verdict = 'blocked';
64
+ } else if (alerts.length > 0) {
65
+ verdict = 'watch';
66
+ }
67
+ return {
68
+ generatedAt: new Date().toISOString(),
69
+ verdict,
70
+ sampleSize: metrics.sampleSize,
71
+ minimumSamples,
72
+ alerts,
73
+ metrics,
74
+ };
75
+ }
76
+
77
+ function monitorProductionSignals(outcomes = [], toolKpis = {}, options = {}) {
78
+ const taskReport = monitorTaskOutcomes(outcomes, options);
79
+ const requestedMinimumToolCalls = Number(options.minimumToolCalls);
80
+ const minimumToolCalls = Number.isFinite(requestedMinimumToolCalls) && requestedMinimumToolCalls >= 1
81
+ ? Math.floor(requestedMinimumToolCalls)
82
+ : DEFAULT_MINIMUM_TOOL_CALLS;
83
+ const measuredToolCalls = Number(toolKpis.totalCalls);
84
+ const totalToolCalls = Number.isFinite(measuredToolCalls) && measuredToolCalls >= 0
85
+ ? Math.floor(measuredToolCalls)
86
+ : 0;
87
+ const toolAlerts = [];
88
+
89
+ if (totalToolCalls < minimumToolCalls) {
90
+ toolAlerts.push({
91
+ id: 'minimum-tool-calls',
92
+ severity: 'block',
93
+ message: `Need ${minimumToolCalls} observed tool calls; observed ${totalToolCalls}.`,
94
+ });
95
+ }
96
+ for (const tool of (toolKpis.tools || [])) {
97
+ if (tool.requestCount < 3) continue;
98
+ if (tool.successRate < 90) {
99
+ toolAlerts.push({
100
+ id: `tool-success-${tool.toolName}`,
101
+ severity: 'block',
102
+ actual: tool.successRate,
103
+ expected: 'gte 90',
104
+ message: `${tool.toolName} success rate ${tool.successRate}% is below 90%.`,
105
+ });
106
+ }
107
+ if (tool.p95 > 500) {
108
+ toolAlerts.push({
109
+ id: `tool-latency-${tool.toolName}`,
110
+ severity: 'warn',
111
+ actual: tool.p95,
112
+ expected: 'lte 500',
113
+ message: `${tool.toolName} p95 latency ${tool.p95}ms is above 500ms.`,
114
+ });
115
+ }
116
+ }
117
+
118
+ const alerts = [...taskReport.alerts, ...toolAlerts];
119
+ let verdict = taskReport.verdict;
120
+ if (taskReport.verdict !== 'blocked' && toolAlerts.some((alert) => alert.id === 'minimum-tool-calls')) {
121
+ verdict = 'insufficient_evidence';
122
+ } else if (toolAlerts.some((alert) => alert.severity === 'block')) {
123
+ verdict = 'blocked';
124
+ } else if (verdict === 'healthy' && toolAlerts.length > 0) {
125
+ verdict = 'watch';
126
+ }
127
+
128
+ return {
129
+ ...taskReport,
130
+ verdict,
131
+ alerts,
132
+ observability: {
133
+ minimumToolCalls,
134
+ totalToolCalls,
135
+ evidenceStatus: totalToolCalls >= minimumToolCalls ? 'measured' : 'insufficient_evidence',
136
+ tools: toolKpis.tools || [],
137
+ servers: toolKpis.servers || [],
138
+ },
139
+ };
140
+ }
141
+
142
+ function flattenMetricValues(metrics) {
143
+ return {
144
+ workingRate: metrics.task.workingRate,
145
+ verifiedCompletionRate: metrics.task.verifiedCompletionRate,
146
+ evidenceBackedCompletionRate: metrics.task.evidenceBackedCompletionRate,
147
+ unsupportedClaimRate: metrics.task.unsupportedClaimRate,
148
+ toolContractAccuracy: metrics.tools.contractAccuracy,
149
+ executionSuccessRate: metrics.tools.executionSuccessRate,
150
+ duplicateSideEffectRate: metrics.tools.duplicateSideEffectRate,
151
+ unsafeEscapeRate: metrics.safety.unsafeEscapeRate,
152
+ policyViolationRate: metrics.safety.policyViolationRate,
153
+ safeFalseBlockRate: metrics.safety.safeFalseBlockRate,
154
+ correctEscalationRate: metrics.escalation.correctEscalationRate,
155
+ latencyP95Ms: metrics.efficiency.latencyP95Ms,
156
+ };
157
+ }
158
+
159
+ function passesRule(actual, rule) {
160
+ if (rule.operator === 'gte') return actual >= Number(rule.value);
161
+ if (rule.operator === 'lte') return actual <= Number(rule.value);
162
+ if (rule.operator === 'gt') return actual > Number(rule.value);
163
+ if (rule.operator === 'lt') return actual < Number(rule.value);
164
+ if (rule.operator === 'eq') return actual === Number(rule.value);
165
+ throw new Error(`Unsupported threshold operator '${rule.operator}'`);
166
+ }
167
+
168
+ function isCliInvocation() {
169
+ return Boolean(process.argv[1]) && path.resolve(process.argv[1]) === __filename;
170
+ }
171
+
172
+ function parseArgs(argv = []) {
173
+ return {
174
+ hosted: argv.includes('--hosted'),
175
+ installSchedule: argv.includes('--install-schedule'),
176
+ inputPath: valueForArg(argv, '--input='),
177
+ thresholdsPath: valueForArg(argv, '--thresholds='),
178
+ outputPath: valueForArg(argv, '--output='),
179
+ baseUrl: valueForArg(argv, '--base-url='),
180
+ workingDirectory: valueForArg(argv, '--working-directory='),
181
+ };
182
+ }
183
+
184
+ function valueForArg(argv, prefix) {
185
+ const value = argv.find((arg) => arg.startsWith(prefix));
186
+ return value ? value.slice(prefix.length) : undefined;
187
+ }
188
+
189
+ async function fetchHostedMonitor(options = {}) {
190
+ const env = options.env || process.env;
191
+ const { loadObservabilityEnv } = require('./observability-env');
192
+ loadObservabilityEnv({
193
+ env,
194
+ operatorPath: options.operatorPath,
195
+ observabilityPath: options.observabilityPath,
196
+ applyStripeManagedFiles: false,
197
+ });
198
+
199
+ const apiKey = String(env.THUMBGATE_OPERATOR_KEY || env.THUMBGATE_API_KEY || '').trim();
200
+ if (!apiKey) {
201
+ return {
202
+ generatedAt: new Date().toISOString(),
203
+ verdict: 'not_configured',
204
+ source: 'hosted',
205
+ reason: 'operator_authentication_unavailable',
206
+ };
207
+ }
208
+
209
+ const baseUrl = options.baseUrl
210
+ || env.THUMBGATE_BILLING_API_BASE_URL
211
+ || DEFAULT_HOSTED_ORIGIN;
212
+ const url = new URL(DEFAULT_MONITOR_PATH, baseUrl);
213
+ const controller = new AbortController();
214
+ const timeout = setTimeout(() => controller.abort(), Number(options.timeoutMs || 15000));
215
+ try {
216
+ const response = await (options.fetchImpl || globalThis.fetch)(url, {
217
+ method: 'GET',
218
+ headers: {
219
+ authorization: `Bearer ${apiKey}`,
220
+ accept: 'application/json',
221
+ },
222
+ signal: controller.signal,
223
+ });
224
+ if (!response.ok) {
225
+ return {
226
+ generatedAt: new Date().toISOString(),
227
+ verdict: 'unavailable',
228
+ source: 'hosted',
229
+ httpStatus: response.status,
230
+ reason: 'hosted_monitor_http_error',
231
+ };
232
+ }
233
+ const report = await response.json();
234
+ return {
235
+ ...report,
236
+ source: 'hosted',
237
+ };
238
+ } catch (error) {
239
+ return {
240
+ generatedAt: new Date().toISOString(),
241
+ verdict: 'unavailable',
242
+ source: 'hosted',
243
+ reason: error?.name === 'AbortError' ? 'hosted_monitor_timeout' : 'hosted_monitor_request_failed',
244
+ };
245
+ } finally {
246
+ clearTimeout(timeout);
247
+ }
248
+ }
249
+
250
+ function buildAgentOutcomeMonitorSchedule(options = {}) {
251
+ const workingDirectory = path.resolve(options.workingDirectory || process.cwd());
252
+ const outputPath = path.resolve(
253
+ options.outputPath
254
+ || path.join(os.homedir(), '.thumbgate', 'reports', 'agent-outcome-monitor.json'),
255
+ );
256
+ const args = [
257
+ __filename,
258
+ '--hosted',
259
+ `--output=${outputPath}`,
260
+ ];
261
+ if (options.baseUrl) args.push(`--base-url=${options.baseUrl}`);
262
+ const command = [
263
+ 'const { spawnSync } = require(\'node:child_process\');',
264
+ `const result = spawnSync(process.execPath, ${JSON.stringify(args)}, {`,
265
+ ` cwd: ${JSON.stringify(workingDirectory)},`,
266
+ ' env: process.env,',
267
+ ' stdio: \'inherit\',',
268
+ '});',
269
+ 'if (result.error) throw result.error;',
270
+ 'process.exit(typeof result.status === \'number\' ? result.status : 1);',
271
+ ].join(' ');
272
+ return {
273
+ id: DEFAULT_SCHEDULE_ID,
274
+ name: 'ThumbGate Agent Outcome Monitor',
275
+ description: 'Checks hosted task outcomes daily against fail-closed production thresholds.',
276
+ schedule: options.schedule || 'daily 10:17',
277
+ command,
278
+ workingDirectory,
279
+ };
280
+ }
281
+
282
+ function installAgentOutcomeMonitorSchedule(options, manager) {
283
+ const scheduleManager = manager || require('./schedule-manager');
284
+ return scheduleManager.createSchedule(buildAgentOutcomeMonitorSchedule(options || {}));
285
+ }
286
+
287
+ async function main(argv = process.argv.slice(2)) {
288
+ const options = parseArgs(argv);
289
+ if (options.installSchedule) {
290
+ const result = installAgentOutcomeMonitorSchedule(options);
291
+ console.log(JSON.stringify(result, null, 2));
292
+ process.exitCode = result.success ? 0 : 1;
293
+ return result;
294
+ }
295
+
296
+ const report = options.hosted
297
+ ? await fetchHostedMonitor(options)
298
+ : monitorProductionSignals(
299
+ readTaskOutcomes({ inputPath: options.inputPath }),
300
+ computeToolKpis(),
301
+ { thresholdsPath: options.thresholdsPath },
302
+ );
303
+ if (options.outputPath) {
304
+ const outputPath = path.resolve(options.outputPath);
305
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
306
+ fs.writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
307
+ }
308
+ console.log(JSON.stringify(report, null, 2));
309
+ process.exitCode = report.verdict === 'healthy' ? 0 : 1;
310
+ return report;
311
+ }
312
+
313
+ if (isCliInvocation()) {
314
+ main().catch((error) => {
315
+ console.error(`Agent outcome monitor failed: ${error.message}`);
316
+ process.exitCode = 1;
317
+ });
318
+ }
319
+
320
+ module.exports = {
321
+ buildAgentOutcomeMonitorSchedule,
322
+ fetchHostedMonitor,
323
+ flattenMetricValues,
324
+ installAgentOutcomeMonitorSchedule,
325
+ main,
326
+ monitorProductionSignals,
327
+ monitorTaskOutcomes,
328
+ parseArgs,
329
+ passesRule,
330
+ DEFAULT_MINIMUM_TOOL_CALLS,
331
+ };