thumbgate 1.29.2 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +78 -7
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +7 -5
- package/config/mcp-allowlists.json +26 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/package.json +38 -7
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +21 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/loop.svg +34 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +4 -3
- package/public/numbers.html +2 -2
- package/public/whitepaper.html +189 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-monitor.js +71 -1
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/cross-encoder-reranker.js +3 -0
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +244 -182
- package/scripts/gates-engine.js +81 -4
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-retrieval.js +71 -4
- package/scripts/lesson-search.js +26 -3
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/published-cli.js +11 -1
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +49 -1
- package/src/api/server.js +230 -86
|
@@ -1,18 +1,41 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
const
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const fs = require('node:fs');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const { spawn } = require('node:child_process');
|
|
5
7
|
const { getFeedbackPaths } = require('./feedback-loop');
|
|
6
8
|
const { ensureDir } = require('./fs-utils');
|
|
7
9
|
const { loadOptionalModule } = require('./private-core-boundary');
|
|
8
10
|
|
|
11
|
+
const RUNNER_SCRIPT_PATH = path.join(__dirname, 'async-job-runner.js');
|
|
12
|
+
|
|
13
|
+
function launchPublicManagedJob(jobSpec, options = {}) {
|
|
14
|
+
const publicRunner = require('./async-job-runner');
|
|
15
|
+
const jobId = options.jobId || jobSpec.id || `job_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
16
|
+
const { jobDir } = publicRunner.getJobRuntimePaths(jobId);
|
|
17
|
+
ensureDir(jobDir);
|
|
18
|
+
const jobFilePath = path.join(jobDir, 'job.json');
|
|
19
|
+
const finalSpec = { ...jobSpec, id: jobId };
|
|
20
|
+
fs.writeFileSync(jobFilePath, `${JSON.stringify(finalSpec, null, 2)}\n`, 'utf8');
|
|
21
|
+
publicRunner.queueJob({ ...finalSpec, jobFilePath });
|
|
22
|
+
const child = spawn(process.execPath, [RUNNER_SCRIPT_PATH, `--run-file=${jobFilePath}`], {
|
|
23
|
+
cwd: options.cwd || process.cwd(),
|
|
24
|
+
env: process.env,
|
|
25
|
+
detached: true,
|
|
26
|
+
stdio: 'ignore',
|
|
27
|
+
});
|
|
28
|
+
child.unref();
|
|
29
|
+
return {
|
|
30
|
+
jobId,
|
|
31
|
+
jobFilePath,
|
|
32
|
+
launchMode: 'public-background',
|
|
33
|
+
pid: child.pid || null,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
9
37
|
const launcher = loadOptionalModule(path.join(__dirname, 'hosted-job-launcher'), () => ({
|
|
10
|
-
launchManagedJob:
|
|
11
|
-
throw new Error('Managed jobs require ThumbGate-Core.');
|
|
12
|
-
},
|
|
13
|
-
resumeHostedJob: () => {
|
|
14
|
-
throw new Error('Resuming hosted jobs requires ThumbGate-Core.');
|
|
15
|
-
},
|
|
38
|
+
launchManagedJob: launchPublicManagedJob,
|
|
16
39
|
}));
|
|
17
40
|
|
|
18
41
|
const runner = loadOptionalModule(path.join(__dirname, 'async-job-runner'), () => ({
|
|
@@ -20,8 +43,8 @@ const runner = loadOptionalModule(path.join(__dirname, 'async-job-runner'), () =
|
|
|
20
43
|
listJobStates: () => [],
|
|
21
44
|
}));
|
|
22
45
|
|
|
23
|
-
const { launchManagedJob
|
|
24
|
-
const { readJobState
|
|
46
|
+
const { launchManagedJob } = launcher;
|
|
47
|
+
const { readJobState } = runner;
|
|
25
48
|
|
|
26
49
|
const DEFAULT_CONCURRENCY = 3;
|
|
27
50
|
const POLL_INTERVAL_MS = 200;
|
|
@@ -45,7 +68,7 @@ function planWorkflow(objective) {
|
|
|
45
68
|
stages: [
|
|
46
69
|
{
|
|
47
70
|
name: 'secret_scan',
|
|
48
|
-
command: 'node scripts/secret-scanner.js --json
|
|
71
|
+
command: 'node scripts/secret-scanner.js --json',
|
|
49
72
|
}
|
|
50
73
|
]
|
|
51
74
|
});
|
|
@@ -55,7 +78,7 @@ function planWorkflow(objective) {
|
|
|
55
78
|
stages: [
|
|
56
79
|
{
|
|
57
80
|
name: 'npm_audit',
|
|
58
|
-
command: 'npm audit --json
|
|
81
|
+
command: 'npm audit --json',
|
|
59
82
|
}
|
|
60
83
|
]
|
|
61
84
|
});
|
|
@@ -65,7 +88,7 @@ function planWorkflow(objective) {
|
|
|
65
88
|
stages: [
|
|
66
89
|
{
|
|
67
90
|
name: 'credential_gate_check',
|
|
68
|
-
command: 'node scripts/single-use-credential-gate.js plan
|
|
91
|
+
command: 'node scripts/single-use-credential-gate.js plan',
|
|
69
92
|
}
|
|
70
93
|
]
|
|
71
94
|
});
|
|
@@ -76,7 +99,7 @@ function planWorkflow(objective) {
|
|
|
76
99
|
stages: [
|
|
77
100
|
{
|
|
78
101
|
name: 'run_bench',
|
|
79
|
-
command: 'npx thumbgate bench --json --min-score=90
|
|
102
|
+
command: 'npx thumbgate bench --json --min-score=90',
|
|
80
103
|
}
|
|
81
104
|
]
|
|
82
105
|
});
|
|
@@ -86,7 +109,7 @@ function planWorkflow(objective) {
|
|
|
86
109
|
stages: [
|
|
87
110
|
{
|
|
88
111
|
name: 'budget_status',
|
|
89
|
-
command: 'node scripts/budget-guard.js --status
|
|
112
|
+
command: 'node scripts/budget-guard.js --status',
|
|
90
113
|
}
|
|
91
114
|
]
|
|
92
115
|
});
|
|
@@ -98,7 +121,7 @@ function planWorkflow(objective) {
|
|
|
98
121
|
stages: [
|
|
99
122
|
{
|
|
100
123
|
name: 'search_fs',
|
|
101
|
-
command: 'node scripts/filesystem-search.js --query="pretool" --limit=5
|
|
124
|
+
command: 'node scripts/filesystem-search.js --query="pretool" --limit=5',
|
|
102
125
|
}
|
|
103
126
|
]
|
|
104
127
|
});
|
|
@@ -108,7 +131,7 @@ function planWorkflow(objective) {
|
|
|
108
131
|
stages: [
|
|
109
132
|
{
|
|
110
133
|
name: 'ops_integrity',
|
|
111
|
-
command: 'node scripts/operational-integrity.js --ci
|
|
134
|
+
command: 'node scripts/operational-integrity.js --ci',
|
|
112
135
|
}
|
|
113
136
|
]
|
|
114
137
|
});
|
|
@@ -119,7 +142,7 @@ function planWorkflow(objective) {
|
|
|
119
142
|
plannedAt: nowIso(),
|
|
120
143
|
subtasks: subtasks.map((task, idx) => ({
|
|
121
144
|
...task,
|
|
122
|
-
id: `subtask_${Date.now()}_${idx}_${
|
|
145
|
+
id: `subtask_${Date.now()}_${idx}_${crypto.randomBytes(3).toString('hex')}`,
|
|
123
146
|
autoImprove: false,
|
|
124
147
|
verificationMode: 'none',
|
|
125
148
|
recordFeedback: false,
|
|
@@ -132,12 +155,14 @@ function planWorkflow(objective) {
|
|
|
132
155
|
* Polls active jobs until all complete, then consolidates the results.
|
|
133
156
|
*/
|
|
134
157
|
async function executeWorkflow(objective, options = {}) {
|
|
135
|
-
const plan = planWorkflow(objective);
|
|
158
|
+
const plan = options.plan || planWorkflow(objective);
|
|
136
159
|
const concurrency = Number(options.concurrency) || DEFAULT_CONCURRENCY;
|
|
137
160
|
const timeoutMs = Number(options.timeoutMs) || 60000; // 60s timeout safety
|
|
161
|
+
const launchJob = options.launchManagedJob || launchManagedJob;
|
|
162
|
+
const getJobState = options.readJobState || readJobState;
|
|
138
163
|
|
|
139
164
|
const { FEEDBACK_DIR } = getFeedbackPaths();
|
|
140
|
-
const workflowId = `wf_${Date.now()}_${
|
|
165
|
+
const workflowId = `wf_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
|
|
141
166
|
const workflowDir = path.join(FEEDBACK_DIR, 'workflows', workflowId);
|
|
142
167
|
ensureDir(workflowDir);
|
|
143
168
|
|
|
@@ -145,17 +170,33 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
145
170
|
const queue = [...plan.subtasks];
|
|
146
171
|
const results = [];
|
|
147
172
|
const start = Date.now();
|
|
173
|
+
const statePath = path.join(workflowDir, 'state.json');
|
|
174
|
+
|
|
175
|
+
const persistState = (status) => {
|
|
176
|
+
fs.writeFileSync(statePath, `${JSON.stringify({
|
|
177
|
+
workflowId,
|
|
178
|
+
objective,
|
|
179
|
+
status,
|
|
180
|
+
updatedAt: nowIso(),
|
|
181
|
+
queue: queue.map((task) => ({ id: task.id, name: task.name })),
|
|
182
|
+
activeJobs: [...activeJobs.entries()].map(([taskId, info]) => ({ taskId, ...info })),
|
|
183
|
+
results,
|
|
184
|
+
}, null, 2)}\n`, 'utf8');
|
|
185
|
+
};
|
|
148
186
|
|
|
149
187
|
const runNext = () => {
|
|
150
188
|
while (activeJobs.size < concurrency && queue.length > 0) {
|
|
151
189
|
const task = queue.shift();
|
|
152
|
-
const launched =
|
|
190
|
+
const launched = launchJob(task, { cwd: options.cwd });
|
|
153
191
|
activeJobs.set(task.id, {
|
|
154
192
|
jobId: launched.jobId,
|
|
155
193
|
taskName: task.name,
|
|
156
194
|
launchedAt: Date.now(),
|
|
195
|
+
pid: launched.pid || null,
|
|
196
|
+
launchMode: launched.launchMode || 'managed',
|
|
157
197
|
});
|
|
158
198
|
}
|
|
199
|
+
persistState('running');
|
|
159
200
|
};
|
|
160
201
|
|
|
161
202
|
runNext();
|
|
@@ -166,7 +207,7 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
166
207
|
let allDone = true;
|
|
167
208
|
|
|
168
209
|
for (const [taskId, info] of activeJobs.entries()) {
|
|
169
|
-
const jobState =
|
|
210
|
+
const jobState = getJobState(info.jobId);
|
|
170
211
|
if (!jobState) {
|
|
171
212
|
allDone = false;
|
|
172
213
|
continue;
|
|
@@ -193,11 +234,21 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
193
234
|
const elapsed = Date.now() - start;
|
|
194
235
|
if (allDone && queue.length === 0) {
|
|
195
236
|
clearInterval(interval);
|
|
237
|
+
persistState(results.every((result) => result.status === 'completed')
|
|
238
|
+
? 'completed'
|
|
239
|
+
: 'completed_with_failures');
|
|
196
240
|
resolve();
|
|
197
241
|
} else if (elapsed >= timeoutMs) {
|
|
198
242
|
clearInterval(interval);
|
|
199
243
|
// Timeout remaining active tasks
|
|
200
244
|
for (const [taskId, info] of activeJobs.entries()) {
|
|
245
|
+
if (info.pid) {
|
|
246
|
+
try {
|
|
247
|
+
process.kill(process.platform === 'win32' ? info.pid : -info.pid, 'SIGTERM');
|
|
248
|
+
} catch {
|
|
249
|
+
// The worker may have exited between the last poll and timeout.
|
|
250
|
+
}
|
|
251
|
+
}
|
|
201
252
|
results.push({
|
|
202
253
|
taskId,
|
|
203
254
|
taskName: info.taskName,
|
|
@@ -206,6 +257,17 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
206
257
|
lastError: { message: `Subtask timed out after ${timeoutMs}ms`, code: 'TIMEOUT' },
|
|
207
258
|
});
|
|
208
259
|
}
|
|
260
|
+
for (const task of queue.splice(0)) {
|
|
261
|
+
results.push({
|
|
262
|
+
taskId: task.id,
|
|
263
|
+
taskName: task.name,
|
|
264
|
+
jobId: null,
|
|
265
|
+
status: 'timeout',
|
|
266
|
+
lastError: { message: `Subtask was not launched before ${timeoutMs}ms`, code: 'TIMEOUT' },
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
activeJobs.clear();
|
|
270
|
+
persistState('timed_out');
|
|
209
271
|
resolve();
|
|
210
272
|
}
|
|
211
273
|
}, POLL_INTERVAL_MS);
|
|
@@ -234,6 +296,7 @@ async function executeWorkflow(objective, options = {}) {
|
|
|
234
296
|
durationMs,
|
|
235
297
|
reportPath,
|
|
236
298
|
results,
|
|
299
|
+
statePath,
|
|
237
300
|
};
|
|
238
301
|
}
|
|
239
302
|
|
|
@@ -290,4 +353,5 @@ module.exports = {
|
|
|
290
353
|
planWorkflow,
|
|
291
354
|
executeWorkflow,
|
|
292
355
|
compileWorkflowReport,
|
|
356
|
+
launchPublicManagedJob,
|
|
293
357
|
};
|
package/scripts/published-cli.js
CHANGED
|
@@ -13,6 +13,14 @@ function runtimePrefixDir(prefixDir) {
|
|
|
13
13
|
return prefixDir || path.join(os.homedir(), '.thumbgate', 'runtime');
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
// For GENERATED SHELL COMMANDS only. Shell command strings land in shared, committed config
|
|
17
|
+
// (.mcp.json entries, hook command lines), so expanding os.homedir() at generation time bakes
|
|
18
|
+
// the generating machine's home into files other machines execute — /Users/alice/.thumbgate
|
|
19
|
+
// fails with a permission error on bob's machine. shellQuote uses double quotes, so a literal
|
|
20
|
+
// $HOME expands at RUNTIME on whichever machine runs the command. Non-shell consumers
|
|
21
|
+
// (execFileSync paths) must keep using runtimePrefixDir, which returns a real filesystem path.
|
|
22
|
+
const SHELL_RUNTIME_PREFIX = '$HOME/.thumbgate/runtime';
|
|
23
|
+
|
|
16
24
|
function installedRuntimeBin(prefixDir) {
|
|
17
25
|
return path.join(runtimePrefixDir(prefixDir), 'node_modules', '.bin', 'thumbgate');
|
|
18
26
|
}
|
|
@@ -32,7 +40,9 @@ function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
|
|
35
|
-
|
|
43
|
+
// Default to the runtime-expanded $HOME form; an explicit options.prefixDir (tests,
|
|
44
|
+
// throwaway prefixes) is honoured verbatim.
|
|
45
|
+
const prefixDir = options.prefixDir || SHELL_RUNTIME_PREFIX;
|
|
36
46
|
const runtimeBin = installedRuntimeBin(prefixDir);
|
|
37
47
|
const escapedArgs = commandArgs.map(shellQuote).join(' ');
|
|
38
48
|
const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* refresh-proof-pack.js — regenerate (or check) the public evaluation scorecard.
|
|
6
|
+
*
|
|
7
|
+
* Cadence (repo policy): GitHub Actions schedule is limited to CodeQL. Noncritical
|
|
8
|
+
* loops run via workflow_dispatch or local LaunchAgent:
|
|
9
|
+
* npm run proof-pack:refresh # write public/eval-scorecard.html
|
|
10
|
+
* npm run proof-pack:refresh:check # CI gate: metrics must still match
|
|
11
|
+
* npm run proof-pack:schedule # install daily local LaunchAgent
|
|
12
|
+
*
|
|
13
|
+
* Isolation: generation goes through generate-eval-scorecard → thumbgate-bench
|
|
14
|
+
* isolated runtime (strict enforcement pinned).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const fs = require('node:fs');
|
|
18
|
+
const path = require('node:path');
|
|
19
|
+
const { spawnSync } = require('node:child_process');
|
|
20
|
+
|
|
21
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
22
|
+
const SCORECARD_HTML = path.join(PROJECT_ROOT, 'public', 'eval-scorecard.html');
|
|
23
|
+
const SCORECARD_JSON = path.join(PROJECT_ROOT, 'public', 'eval-scorecard.json');
|
|
24
|
+
|
|
25
|
+
function parseArgs(argv = process.argv.slice(2)) {
|
|
26
|
+
const args = {
|
|
27
|
+
write: false,
|
|
28
|
+
check: false,
|
|
29
|
+
json: false,
|
|
30
|
+
help: false,
|
|
31
|
+
minScore: 90,
|
|
32
|
+
};
|
|
33
|
+
for (const arg of argv) {
|
|
34
|
+
if (arg === '--write') args.write = true;
|
|
35
|
+
else if (arg === '--check') args.check = true;
|
|
36
|
+
else if (arg === '--json') args.json = true;
|
|
37
|
+
else if (arg === '--help' || arg === '-h') args.help = true;
|
|
38
|
+
else if (arg.startsWith('--min-score=')) {
|
|
39
|
+
args.minScore = Number(arg.slice('--min-score='.length));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
if (!args.write && !args.check) {
|
|
43
|
+
// Default to write for operator cadence runs.
|
|
44
|
+
args.write = true;
|
|
45
|
+
}
|
|
46
|
+
return args;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function printHelp() {
|
|
50
|
+
console.log(`Usage: node scripts/refresh-proof-pack.js [--write] [--check] [--json] [--min-score=90]
|
|
51
|
+
|
|
52
|
+
--write Regenerate public/eval-scorecard.html (+ .json sidecar)
|
|
53
|
+
--check Fail if committed scorecard metrics diverge from a fresh bench run
|
|
54
|
+
--json Print machine-readable summary to stdout
|
|
55
|
+
--min-score=N Minimum composite score (default 90)
|
|
56
|
+
`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function extractMetricsFromHtml(html) {
|
|
60
|
+
const metrics = {};
|
|
61
|
+
// Prefer JSON-LD Dataset variableMeasured
|
|
62
|
+
const ldMatch = html.match(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/);
|
|
63
|
+
if (ldMatch) {
|
|
64
|
+
try {
|
|
65
|
+
const ld = JSON.parse(ldMatch[1]);
|
|
66
|
+
const vars = Array.isArray(ld.variableMeasured) ? ld.variableMeasured : [];
|
|
67
|
+
for (const item of vars) {
|
|
68
|
+
if (item && item.name != null) metrics[item.name] = item.value;
|
|
69
|
+
}
|
|
70
|
+
} catch {
|
|
71
|
+
// fall through to regex
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const scoreMatch = html.match(/composite score <strong>([^<]+)<\/strong>/i)
|
|
75
|
+
|| html.match(/composite score[^0-9]*([0-9]+)/i);
|
|
76
|
+
if (scoreMatch && metrics.score == null) {
|
|
77
|
+
metrics.score = Number(scoreMatch[1]);
|
|
78
|
+
}
|
|
79
|
+
const passMatch = html.match(/Overall:\s*<span class="(good|bad)">(PASSED|FAILED)<\/span>/i);
|
|
80
|
+
if (passMatch) {
|
|
81
|
+
metrics.passedLabel = passMatch[2].toUpperCase();
|
|
82
|
+
}
|
|
83
|
+
return metrics;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function normalizeMetrics(metrics = {}) {
|
|
87
|
+
const keys = [
|
|
88
|
+
'score',
|
|
89
|
+
'taskSuccessRate',
|
|
90
|
+
'unsafeActionRate',
|
|
91
|
+
'blockedUnsafeRate',
|
|
92
|
+
'capabilityRate',
|
|
93
|
+
'falseBlockRate',
|
|
94
|
+
'replayStability',
|
|
95
|
+
];
|
|
96
|
+
const out = {};
|
|
97
|
+
for (const key of keys) {
|
|
98
|
+
if (metrics[key] == null || metrics[key] === '') continue;
|
|
99
|
+
const n = Number(metrics[key]);
|
|
100
|
+
out[key] = Number.isFinite(n) ? Number(n.toFixed(4)) : metrics[key];
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function metricsEqual(a, b, options = {}) {
|
|
106
|
+
const left = normalizeMetrics(a);
|
|
107
|
+
const right = normalizeMetrics(b);
|
|
108
|
+
// When comparing a committed HTML extract to a fresh bench report, only
|
|
109
|
+
// assert keys present on the committed side (JSON-LD may omit some rates).
|
|
110
|
+
const keys = options.keys
|
|
111
|
+
|| (options.committedOnly
|
|
112
|
+
? Object.keys(left)
|
|
113
|
+
: [...new Set([...Object.keys(left), ...Object.keys(right)])]);
|
|
114
|
+
const diffs = [];
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (left[key] !== right[key]) {
|
|
117
|
+
diffs.push({ key, committed: left[key], fresh: right[key] });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { equal: diffs.length === 0, diffs, left, right };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runFreshBench() {
|
|
124
|
+
const { generate, runBench } = require('./generate-eval-scorecard');
|
|
125
|
+
// Prefer direct bench for metrics; generate for write path.
|
|
126
|
+
let report;
|
|
127
|
+
try {
|
|
128
|
+
report = runBench();
|
|
129
|
+
} catch {
|
|
130
|
+
// generate also runs the bench
|
|
131
|
+
report = null;
|
|
132
|
+
}
|
|
133
|
+
return { generate, report };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function buildSidecar(report, version, nowIso) {
|
|
137
|
+
const metrics = report.metrics || report;
|
|
138
|
+
return {
|
|
139
|
+
generatedAt: nowIso,
|
|
140
|
+
version,
|
|
141
|
+
sourcePath: report.sourcePath || 'bench/thumbgate-bench.json',
|
|
142
|
+
passed: report.passed !== false,
|
|
143
|
+
isolatedRuntime: report.isolatedRuntime !== false,
|
|
144
|
+
metrics: normalizeMetrics(metrics),
|
|
145
|
+
scenarioCount: Array.isArray(report.scenarios) ? report.scenarios.length : null,
|
|
146
|
+
proofUrl: 'https://thumbgate.ai/eval-scorecard',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function refreshWrite(options = {}) {
|
|
151
|
+
const { generate } = require('./generate-eval-scorecard');
|
|
152
|
+
const now = options.now instanceof Date ? options.now : new Date();
|
|
153
|
+
const result = generate({
|
|
154
|
+
now,
|
|
155
|
+
outputPath: options.outputPath || SCORECARD_HTML,
|
|
156
|
+
});
|
|
157
|
+
const version = options.version || require(path.join(PROJECT_ROOT, 'package.json')).version;
|
|
158
|
+
const sidecar = buildSidecar(result.report, version, now.toISOString());
|
|
159
|
+
const sidecarPath = options.sidecarPath || SCORECARD_JSON;
|
|
160
|
+
fs.writeFileSync(sidecarPath, `${JSON.stringify(sidecar, null, 2)}\n`, 'utf8');
|
|
161
|
+
return {
|
|
162
|
+
mode: 'write',
|
|
163
|
+
htmlPath: result.outPath,
|
|
164
|
+
sidecarPath,
|
|
165
|
+
passed: result.report.passed !== false,
|
|
166
|
+
metrics: sidecar.metrics,
|
|
167
|
+
score: sidecar.metrics.score,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function refreshCheck(options = {}) {
|
|
172
|
+
const htmlPath = options.htmlPath || SCORECARD_HTML;
|
|
173
|
+
if (!fs.existsSync(htmlPath)) {
|
|
174
|
+
throw new Error(`Missing committed scorecard: ${htmlPath}`);
|
|
175
|
+
}
|
|
176
|
+
const committedHtml = fs.readFileSync(htmlPath, 'utf8');
|
|
177
|
+
const committed = normalizeMetrics(extractMetricsFromHtml(committedHtml));
|
|
178
|
+
|
|
179
|
+
const { runBench } = require('./generate-eval-scorecard');
|
|
180
|
+
const report = options.report || runBench();
|
|
181
|
+
const fresh = normalizeMetrics(report.metrics || report);
|
|
182
|
+
const comparison = metricsEqual(committed, fresh, { committedOnly: true });
|
|
183
|
+
const score = Number(fresh.score ?? committed.score);
|
|
184
|
+
const minScore = options.minScore ?? 90;
|
|
185
|
+
const scoreOk = Number.isFinite(score) && score >= minScore;
|
|
186
|
+
const passed = report.passed !== false && scoreOk && comparison.equal;
|
|
187
|
+
|
|
188
|
+
return {
|
|
189
|
+
mode: 'check',
|
|
190
|
+
passed,
|
|
191
|
+
scoreOk,
|
|
192
|
+
metricsMatch: comparison.equal,
|
|
193
|
+
diffs: comparison.diffs,
|
|
194
|
+
committed,
|
|
195
|
+
fresh,
|
|
196
|
+
minScore,
|
|
197
|
+
reportPassed: report.passed !== false,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function main(argv = process.argv.slice(2)) {
|
|
202
|
+
const args = parseArgs(argv);
|
|
203
|
+
if (args.help) {
|
|
204
|
+
printHelp();
|
|
205
|
+
return 0;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
let summary;
|
|
209
|
+
if (args.check) {
|
|
210
|
+
summary = refreshCheck({ minScore: args.minScore });
|
|
211
|
+
} else {
|
|
212
|
+
summary = refreshWrite();
|
|
213
|
+
if (Number(summary.score) < args.minScore || summary.passed === false) {
|
|
214
|
+
summary.checkFailed = true;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (args.json) {
|
|
219
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
220
|
+
} else if (summary.mode === 'write') {
|
|
221
|
+
console.log(
|
|
222
|
+
`Proof pack scorecard written: ${summary.htmlPath} (score=${summary.score}, passed=${summary.passed})`,
|
|
223
|
+
);
|
|
224
|
+
console.log(`Sidecar: ${summary.sidecarPath}`);
|
|
225
|
+
} else {
|
|
226
|
+
console.log(
|
|
227
|
+
`Proof pack check: metricsMatch=${summary.metricsMatch} scoreOk=${summary.scoreOk} reportPassed=${summary.reportPassed}`,
|
|
228
|
+
);
|
|
229
|
+
if (summary.diffs.length) {
|
|
230
|
+
for (const diff of summary.diffs) {
|
|
231
|
+
console.log(` drift ${diff.key}: committed=${diff.committed} fresh=${diff.fresh}`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (summary.mode === 'check' && !summary.passed) return 1;
|
|
237
|
+
if (summary.mode === 'write' && summary.checkFailed) return 1;
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
|
|
242
|
+
try {
|
|
243
|
+
process.exitCode = main();
|
|
244
|
+
} catch (err) {
|
|
245
|
+
console.error(err.message || err);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
module.exports = {
|
|
251
|
+
parseArgs,
|
|
252
|
+
extractMetricsFromHtml,
|
|
253
|
+
normalizeMetrics,
|
|
254
|
+
metricsEqual,
|
|
255
|
+
refreshWrite,
|
|
256
|
+
refreshCheck,
|
|
257
|
+
buildSidecar,
|
|
258
|
+
main,
|
|
259
|
+
SCORECARD_HTML,
|
|
260
|
+
SCORECARD_JSON,
|
|
261
|
+
};
|