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.
Files changed (55) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/adapters/claude/.mcp.json +2 -2
  4. package/adapters/forge/forge.yaml +3 -3
  5. package/adapters/mcp/server-stdio.js +78 -7
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +7 -5
  8. package/config/mcp-allowlists.json +26 -2
  9. package/config/post-deploy-marketing-pages.json +26 -1
  10. package/package.json +38 -7
  11. package/public/architecture.html +130 -0
  12. package/public/assets/diagrams/agent-integration.png +0 -0
  13. package/public/assets/diagrams/before-after.svg +21 -0
  14. package/public/assets/diagrams/decision.svg +36 -0
  15. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  16. package/public/assets/diagrams/loop.svg +34 -0
  17. package/public/assets/diagrams/plugin-topology.png +0 -0
  18. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  19. package/public/assets/diagrams/stack.svg +18 -0
  20. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  21. package/public/case-studies.html +151 -0
  22. package/public/eval-scorecard.html +195 -0
  23. package/public/eval-scorecard.json +18 -0
  24. package/public/evaluations.html +168 -0
  25. package/public/index.html +4 -3
  26. package/public/numbers.html +2 -2
  27. package/public/whitepaper.html +189 -0
  28. package/scripts/activation-quickstart.js +1 -0
  29. package/scripts/agent-outcome-monitor.js +71 -1
  30. package/scripts/billing.js +3 -1
  31. package/scripts/claude-feedback-sync.js +3 -2
  32. package/scripts/cli-feedback.js +13 -7
  33. package/scripts/cross-encoder-reranker.js +3 -0
  34. package/scripts/feedback-aggregate.js +5 -2
  35. package/scripts/feedback-loop.js +244 -182
  36. package/scripts/gates-engine.js +81 -4
  37. package/scripts/generate-case-study-outreach.js +253 -0
  38. package/scripts/generate-eval-scorecard.js +276 -0
  39. package/scripts/growth-campaigns.js +183 -0
  40. package/scripts/jsonl-watcher.js +1 -0
  41. package/scripts/lesson-inference.js +23 -4
  42. package/scripts/lesson-retrieval.js +71 -4
  43. package/scripts/lesson-search.js +26 -3
  44. package/scripts/mcp-config.js +26 -5
  45. package/scripts/mcp-oauth.js +37 -2
  46. package/scripts/model-eval.js +308 -0
  47. package/scripts/parallel-workflow-orchestrator.js +86 -22
  48. package/scripts/published-cli.js +11 -1
  49. package/scripts/refresh-proof-pack.js +261 -0
  50. package/scripts/risk-scorer.js +144 -15
  51. package/scripts/statusline-local-stats.js +1 -1
  52. package/scripts/thumbgate-bench.js +13 -0
  53. package/scripts/tool-kpi-tracker.js +124 -0
  54. package/scripts/tool-registry.js +49 -1
  55. package/src/api/server.js +230 -86
@@ -1,18 +1,41 @@
1
1
  'use strict';
2
2
 
3
- const fs = require('fs');
4
- const path = require('path');
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, resumeHostedJob } = launcher;
24
- const { readJobState, listJobStates } = runner;
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 || true',
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 || true',
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 || true',
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 || true',
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 || true',
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 || true',
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 || true',
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}_${Math.random().toString(36).slice(2, 6)}`,
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()}_${Math.random().toString(36).slice(2, 8)}`;
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 = launchManagedJob(task, { cwd: options.cwd });
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 = readJobState(info.jobId);
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
  };
@@ -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
- const prefixDir = runtimePrefixDir(options.prefixDir);
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
+ };