thumbgate 1.27.19 → 1.27.20

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 (87) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +1 -1
  9. package/.well-known/mcp/server-card.json +1 -1
  10. package/README.md +0 -3
  11. package/adapters/claude/.mcp.json +2 -2
  12. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  13. package/adapters/letta/README.md +41 -0
  14. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  15. package/adapters/mcp/server-stdio.js +1 -1
  16. package/adapters/opencode/opencode.json +1 -1
  17. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  18. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  19. package/bench/observability-eval-suite.json +26 -0
  20. package/bin/cli.js +27 -1
  21. package/bin/dashboard-cli.js +7 -0
  22. package/bin/postinstall.js +14 -23
  23. package/commands/dashboard.md +15 -0
  24. package/commands/thumbgate-dashboard.md +15 -0
  25. package/package.json +225 -100
  26. package/public/about.html +162 -0
  27. package/public/agent-manager.html +179 -0
  28. package/public/agents-cost-savings.html +153 -0
  29. package/public/ai-malpractice-prevention.html +818 -0
  30. package/public/assets/brand/github-social-preview.png +0 -0
  31. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  32. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  33. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  34. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  35. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  36. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  37. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  38. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  39. package/public/assets/brand/thumbgate-mark.svg +21 -0
  40. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  41. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  42. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  43. package/public/assets/legal-intake-control-flow.svg +66 -0
  44. package/public/brand/thumbgate-mark.svg +19 -0
  45. package/public/brand/thumbgate-og.svg +16 -0
  46. package/public/chatgpt-app.html +330 -0
  47. package/public/codex-enterprise.html +123 -0
  48. package/public/diagnostic.html +345 -0
  49. package/public/index.html +2 -2
  50. package/public/install.html +193 -0
  51. package/public/js/buyer-intent.js +672 -0
  52. package/public/numbers.html +2 -2
  53. package/public/pricing.html +399 -0
  54. package/scripts/action-receipts.js +324 -0
  55. package/scripts/activation-quickstart.js +187 -0
  56. package/scripts/agent-operations-planner.js +621 -0
  57. package/scripts/ai-component-inventory.js +367 -0
  58. package/scripts/async-eval-observability.js +236 -0
  59. package/scripts/audit.js +65 -0
  60. package/scripts/aws-blocks-guardrails.js +272 -0
  61. package/scripts/classifier-routing.js +130 -0
  62. package/scripts/dashboard-chat.js +332 -0
  63. package/scripts/feedback-aggregate.js +281 -0
  64. package/scripts/feedback-sanitizer.js +105 -0
  65. package/scripts/hook-stop-anti-claim.js +301 -0
  66. package/scripts/install-shim.js +87 -0
  67. package/scripts/mcp-oauth.js +293 -0
  68. package/scripts/noop-detect.js +285 -0
  69. package/scripts/parallel-workflow-orchestrator.js +293 -0
  70. package/scripts/plan-gate.js +243 -0
  71. package/scripts/plausible-domain-config.js +99 -0
  72. package/scripts/qa-scenario-planner.js +136 -0
  73. package/scripts/repeat-metric.js +137 -0
  74. package/scripts/secret-fixture-tokens.js +61 -0
  75. package/scripts/secret-redaction.js +166 -0
  76. package/scripts/self-harness-optimizer.js +141 -0
  77. package/scripts/self-healing-check.js +193 -0
  78. package/scripts/self-protection.js +90 -0
  79. package/scripts/silent-failure-cluster.js +531 -0
  80. package/scripts/statusline-cache-read.js +57 -0
  81. package/scripts/sync-telemetry-from-prod.js +374 -0
  82. package/scripts/tool-contract-validator.js +76 -0
  83. package/scripts/trajectory-scorer.js +63 -0
  84. package/scripts/verify-marketing-pages-deployed.js +212 -0
  85. package/scripts/visitor-journey.js +172 -0
  86. package/.claude-plugin/marketplace.json +0 -85
  87. package/adapters/chatgpt/openapi.yaml +0 -1707
@@ -0,0 +1,374 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * sync-telemetry-from-prod.js
6
+ *
7
+ * The agentic data pipeline (scripts/agentic-data-pipeline.js →
8
+ * scripts/telemetry-analytics.js) computes `get_business_metrics` from the LOCAL
9
+ * telemetry store at `<feedbackDir>/telemetry-pings.jsonl`. On a developer
10
+ * machine that store is near-empty, so metrics read uniqueVisitors:0 /
11
+ * checkoutStarts:0 even though real web traffic is flowing.
12
+ *
13
+ * The real web funnel lives on the PROD Railway volume and is exposed only via
14
+ * the operator-key-gated endpoint `GET /v1/telemetry/export`. Both stores use the
15
+ * SAME jsonl format/filename and matching fields (receivedAt/event/eventType/
16
+ * installId/visitorId/sessionId/traceId), so a sync is a clean append + dedupe.
17
+ *
18
+ * This script:
19
+ * 1. Resolves the operator (or admin) key from env or ~/.config/thumbgate/operator.json.
20
+ * 2. GETs /v1/telemetry/export?source=both with `Authorization: Bearer <key>`.
21
+ * 3. Merges/dedupes the returned `telemetry` rows into
22
+ * `<feedbackDir>/telemetry-pings.jsonl` (dedupe by a stable sha256 of the
23
+ * canonical row, so repeat runs never double-count).
24
+ * 4. Optionally (`--funnel`) persists the returned `funnel` rows into
25
+ * `<feedbackDir>/funnel-events.jsonl` the same way.
26
+ *
27
+ * After a run, `get_business_metrics` reflects the real funnel because the
28
+ * pipeline reads the same feedback dir on each call.
29
+ *
30
+ * SECURITY: the operator key is never printed. Only its source is reported.
31
+ *
32
+ * Usage:
33
+ * node scripts/sync-telemetry-from-prod.js [--since-days=30] [--since=<iso>]
34
+ * [--limit=10000] [--source=both|telemetry|funnel] [--funnel]
35
+ * [--base-url=<url>] [--feedback-dir=<dir>] [--json] [--dry-run]
36
+ */
37
+
38
+ const fs = require('node:fs');
39
+ const os = require('node:os');
40
+ const path = require('node:path');
41
+ const crypto = require('node:crypto');
42
+
43
+ const { getFeedbackPaths } = require('./feedback-loop');
44
+
45
+ const DEFAULT_BASE_URL = 'https://thumbgate-production.up.railway.app';
46
+ const OPERATOR_CONFIG_PATH = path.join(os.homedir(), '.config', 'thumbgate', 'operator.json');
47
+ const TELEMETRY_FILE_NAME = 'telemetry-pings.jsonl';
48
+ const FUNNEL_FILE_NAME = 'funnel-events.jsonl';
49
+ const EXPORT_PATH = '/v1/telemetry/export';
50
+ const HARD_LIMIT = 10000; // server clamps each stream to this
51
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
52
+
53
+ // Configure fetch proxy when running behind a corporate/sandbox proxy. Mirrors
54
+ // scripts/operational-summary.js so hosted reads behave the same everywhere.
55
+ (function configureProxy() {
56
+ const proxyUrl = process.env.HTTPS_PROXY || process.env.https_proxy
57
+ || process.env.HTTP_PROXY || process.env.http_proxy;
58
+ if (!proxyUrl) return;
59
+ try {
60
+ const { ProxyAgent, setGlobalDispatcher } = require('undici');
61
+ setGlobalDispatcher(new ProxyAgent(proxyUrl));
62
+ } catch {
63
+ // undici not available — fetch will use the default dispatcher.
64
+ }
65
+ }());
66
+
67
+ function normalizeText(value) {
68
+ if (value === undefined || value === null) return null;
69
+ const text = String(value).trim();
70
+ return text || null;
71
+ }
72
+
73
+ function loadOperatorConfig(configPath = OPERATOR_CONFIG_PATH) {
74
+ try {
75
+ const parsed = JSON.parse(fs.readFileSync(configPath, 'utf8'));
76
+ return {
77
+ operatorKey: normalizeText(parsed.operatorKey),
78
+ baseUrl: normalizeText(parsed.baseUrl),
79
+ };
80
+ } catch {
81
+ return { operatorKey: null, baseUrl: null };
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Resolve the credential + base URL the same way scripts/operational-summary.js
87
+ * does: env THUMBGATE_OPERATOR_KEY > operator.json operatorKey > env
88
+ * THUMBGATE_API_KEY (admin keys are an accepted alternate auth path on the
89
+ * export endpoint). `source` records WHERE the key came from for diagnostics —
90
+ * the key value itself is never returned to callers that print it.
91
+ */
92
+ function resolveOperatorConfig(options = {}) {
93
+ const operatorConfig = loadOperatorConfig(options.configPath);
94
+ let apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY);
95
+ let keySource = apiKey ? 'env:THUMBGATE_OPERATOR_KEY' : null;
96
+ if (!apiKey && operatorConfig.operatorKey) {
97
+ apiKey = operatorConfig.operatorKey;
98
+ keySource = 'operator.json';
99
+ }
100
+ if (!apiKey) {
101
+ apiKey = normalizeText(process.env.THUMBGATE_API_KEY);
102
+ keySource = apiKey ? 'env:THUMBGATE_API_KEY' : null;
103
+ }
104
+ const baseUrl = normalizeText(options.baseUrl)
105
+ || normalizeText(process.env.THUMBGATE_SYNC_BASE_URL)
106
+ || normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
107
+ || operatorConfig.baseUrl
108
+ || DEFAULT_BASE_URL;
109
+ return { apiKey, keySource, baseUrl };
110
+ }
111
+
112
+ // Deterministic, LOCALE-INDEPENDENT key comparator (UTF-16 code-unit order).
113
+ // Do NOT switch this to String.prototype.localeCompare (what SonarCloud S2871
114
+ // suggests): localeCompare is locale-sensitive, so it would make the canonical
115
+ // serialization — and therefore the dedupe hash below — differ across machines
116
+ // and CI runners, silently breaking cross-environment dedupe.
117
+ function compareKeys(a, b) {
118
+ if (a < b) return -1;
119
+ if (a > b) return 1;
120
+ return 0;
121
+ }
122
+
123
+ // Recursively sort object keys so structurally-identical rows serialize
124
+ // identically regardless of original key order.
125
+ function canonicalize(value) {
126
+ if (Array.isArray(value)) return value.map(canonicalize);
127
+ if (value && typeof value === 'object') {
128
+ const out = {};
129
+ for (const key of Object.keys(value).sort(compareKeys)) out[key] = canonicalize(value[key]);
130
+ return out;
131
+ }
132
+ return value;
133
+ }
134
+
135
+ // Stable dedupe id. sha256 (not sha1/md5 — SonarCloud S4790) over the canonical
136
+ // JSON of the whole row: identical rows → identical id across runs.
137
+ function stableRowId(row) {
138
+ return crypto.createHash('sha256').update(JSON.stringify(canonicalize(row))).digest('hex');
139
+ }
140
+
141
+ // The export endpoint returns each stream as { rows: [...] }; older/simplified
142
+ // shapes may return a bare array. Accept both.
143
+ function extractRows(streamValue) {
144
+ if (Array.isArray(streamValue)) return streamValue;
145
+ if (streamValue && Array.isArray(streamValue.rows)) return streamValue.rows;
146
+ return [];
147
+ }
148
+
149
+ /**
150
+ * Fetch the telemetry export. `fetchImpl` is injectable so tests run without
151
+ * network or secrets. Auth is `Authorization: Bearer <key>` — the server's
152
+ * extractApiKey reads Bearer / x-api-key, NOT x-operator-key.
153
+ */
154
+ async function fetchTelemetryExport(params = {}) {
155
+ const {
156
+ baseUrl,
157
+ operatorKey,
158
+ since,
159
+ limit = HARD_LIMIT,
160
+ source = 'both',
161
+ fetchImpl = globalThis.fetch,
162
+ } = params;
163
+ if (!operatorKey) {
164
+ const err = new Error('No operator/admin key available for telemetry export.');
165
+ err.code = 'no_key';
166
+ throw err;
167
+ }
168
+ if (typeof fetchImpl !== 'function') {
169
+ const err = new Error('No fetch implementation available (Node >=18 or inject fetchImpl).');
170
+ err.code = 'no_fetch';
171
+ throw err;
172
+ }
173
+ const url = new URL(EXPORT_PATH, baseUrl || DEFAULT_BASE_URL);
174
+ url.searchParams.set('source', source);
175
+ url.searchParams.set('limit', String(Math.min(Math.max(1, Number(limit) || HARD_LIMIT), HARD_LIMIT)));
176
+ if (since) url.searchParams.set('since', since);
177
+
178
+ const response = await fetchImpl(url, {
179
+ method: 'GET',
180
+ headers: {
181
+ authorization: `Bearer ${operatorKey}`,
182
+ accept: 'application/json',
183
+ },
184
+ });
185
+ if (!response.ok) {
186
+ const bodyText = await response.text().catch(() => '');
187
+ const err = new Error(`Telemetry export failed: HTTP ${response.status} ${bodyText.slice(0, 200)}`);
188
+ err.code = 'http_error';
189
+ err.status = response.status;
190
+ throw err;
191
+ }
192
+ return response.json();
193
+ }
194
+
195
+ // Read every stable id already present in a jsonl ledger (skips unparseable lines).
196
+ function readExistingIds(filePath) {
197
+ const ids = new Set();
198
+ if (!fs.existsSync(filePath)) return ids;
199
+ const text = fs.readFileSync(filePath, 'utf8');
200
+ for (const line of text.split('\n')) {
201
+ if (!line.trim()) continue;
202
+ try {
203
+ ids.add(stableRowId(JSON.parse(line)));
204
+ } catch {
205
+ // Tolerate malformed historical lines.
206
+ }
207
+ }
208
+ return ids;
209
+ }
210
+
211
+ /**
212
+ * Append only rows whose stable id isn't already present. Pure aside from the
213
+ * single append; safe to re-run. Returns { added, skipped }.
214
+ */
215
+ function mergeRowsIntoLedger(filePath, rows, options = {}) {
216
+ const existing = options.existingIds || readExistingIds(filePath);
217
+ const toAppend = [];
218
+ let skipped = 0;
219
+ for (const row of rows) {
220
+ if (!row || typeof row !== 'object') { skipped += 1; continue; }
221
+ const id = stableRowId(row);
222
+ if (existing.has(id)) { skipped += 1; continue; }
223
+ existing.add(id);
224
+ toAppend.push(JSON.stringify(row));
225
+ }
226
+ if (toAppend.length && !options.dryRun) {
227
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
228
+ fs.appendFileSync(filePath, toAppend.map((l) => `${l}\n`).join(''), 'utf8');
229
+ }
230
+ return { added: toAppend.length, skipped };
231
+ }
232
+
233
+ function resolveFeedbackDir(options = {}) {
234
+ if (options.feedbackDir) return path.resolve(options.feedbackDir);
235
+ return getFeedbackPaths().FEEDBACK_DIR;
236
+ }
237
+
238
+ // Resolve the `since` ISO timestamp: an explicit --since wins; otherwise derive
239
+ // it from --since-days relative to nowMs (test-injectable) or the current time.
240
+ function computeSinceIso(options = {}) {
241
+ if (options.since) return options.since;
242
+ if (!Number.isFinite(options.sinceDays)) return undefined;
243
+ const baseMs = Number.isFinite(options.nowMs) ? options.nowMs : Date.now();
244
+ return new Date(baseMs - options.sinceDays * MS_PER_DAY).toISOString();
245
+ }
246
+
247
+ /**
248
+ * End-to-end sync: fetch export → merge telemetry (always) and funnel (opt-in).
249
+ * Returns a summary object with counts and resolved paths (no secrets).
250
+ */
251
+ async function syncTelemetryFromProd(options = {}) {
252
+ const config = resolveOperatorConfig(options);
253
+ const feedbackDir = resolveFeedbackDir(options);
254
+ const source = options.source || (options.funnel ? 'both' : 'telemetry');
255
+ const sinceIso = computeSinceIso(options);
256
+
257
+ const payload = await fetchTelemetryExport({
258
+ baseUrl: config.baseUrl,
259
+ operatorKey: config.apiKey,
260
+ since: sinceIso,
261
+ limit: options.limit || HARD_LIMIT,
262
+ source,
263
+ fetchImpl: options.fetchImpl,
264
+ });
265
+
266
+ const telemetryRows = extractRows(payload.telemetry);
267
+ const telemetryPath = path.join(feedbackDir, TELEMETRY_FILE_NAME);
268
+ const telemetryResult = mergeRowsIntoLedger(telemetryPath, telemetryRows, { dryRun: options.dryRun });
269
+
270
+ const summary = {
271
+ feedbackDir,
272
+ baseUrl: config.baseUrl,
273
+ keySource: config.keySource,
274
+ source,
275
+ since: payload.since || sinceIso || null,
276
+ dryRun: !!options.dryRun,
277
+ telemetry: {
278
+ path: telemetryPath,
279
+ fetched: telemetryRows.length,
280
+ added: telemetryResult.added,
281
+ skipped: telemetryResult.skipped,
282
+ },
283
+ funnel: null,
284
+ };
285
+
286
+ if (options.funnel) {
287
+ const funnelRows = extractRows(payload.funnel);
288
+ const funnelPath = path.join(feedbackDir, FUNNEL_FILE_NAME);
289
+ const funnelResult = mergeRowsIntoLedger(funnelPath, funnelRows, { dryRun: options.dryRun });
290
+ summary.funnel = {
291
+ path: funnelPath,
292
+ fetched: funnelRows.length,
293
+ added: funnelResult.added,
294
+ skipped: funnelResult.skipped,
295
+ };
296
+ }
297
+
298
+ return summary;
299
+ }
300
+
301
+ function parseArgs(argv) {
302
+ const opts = { sinceDays: 30, funnel: false, json: false, dryRun: false };
303
+ for (const arg of argv) {
304
+ if (arg === '--funnel') opts.funnel = true;
305
+ else if (arg === '--json') opts.json = true;
306
+ else if (arg === '--dry-run') opts.dryRun = true;
307
+ else if (arg.startsWith('--since-days=')) opts.sinceDays = Number(arg.split('=')[1]);
308
+ else if (arg.startsWith('--since=')) opts.since = arg.split('=').slice(1).join('=');
309
+ else if (arg.startsWith('--limit=')) opts.limit = Number(arg.split('=')[1]);
310
+ else if (arg.startsWith('--source=')) opts.source = arg.split('=')[1];
311
+ else if (arg.startsWith('--base-url=')) opts.baseUrl = arg.split('=').slice(1).join('=');
312
+ else if (arg.startsWith('--feedback-dir=')) opts.feedbackDir = arg.split('=').slice(1).join('=');
313
+ }
314
+ if (!Number.isFinite(opts.sinceDays)) opts.sinceDays = 30;
315
+ return opts;
316
+ }
317
+
318
+ async function main() {
319
+ const opts = parseArgs(process.argv.slice(2));
320
+ const preflight = resolveOperatorConfig(opts);
321
+ if (!preflight.apiKey) {
322
+ console.error(
323
+ 'No operator/admin key found. Set THUMBGATE_OPERATOR_KEY (or THUMBGATE_API_KEY), '
324
+ + 'or add operatorKey to ~/.config/thumbgate/operator.json. '
325
+ + 'The key lives in Railway prod env as THUMBGATE_OPERATOR_KEY.'
326
+ );
327
+ process.exitCode = 1;
328
+ return;
329
+ }
330
+ try {
331
+ const summary = await syncTelemetryFromProd(opts);
332
+ if (opts.json) {
333
+ console.log(JSON.stringify(summary, null, 2));
334
+ } else {
335
+ const t = summary.telemetry;
336
+ console.log(`✅ Telemetry sync${summary.dryRun ? ' (dry-run)' : ''} complete`);
337
+ console.log(` source=${summary.source} since=${summary.since || 'server-default'} via ${summary.keySource}`);
338
+ console.log(` telemetry: ${t.fetched} fetched → ${t.added} added, ${t.skipped} already present`);
339
+ console.log(` → ${t.path}`);
340
+ if (summary.funnel) {
341
+ const f = summary.funnel;
342
+ console.log(` funnel: ${f.fetched} fetched → ${f.added} added, ${f.skipped} already present`);
343
+ console.log(` → ${f.path}`);
344
+ }
345
+ }
346
+ } catch (err) {
347
+ console.error(`❌ Telemetry sync failed: ${err.message}`);
348
+ process.exitCode = 1;
349
+ }
350
+ }
351
+
352
+ // Path-based entrypoint check (require.main === module is flagged by SonarCloud S3403).
353
+ if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(__filename)) {
354
+ main();
355
+ }
356
+
357
+ module.exports = {
358
+ DEFAULT_BASE_URL,
359
+ TELEMETRY_FILE_NAME,
360
+ FUNNEL_FILE_NAME,
361
+ HARD_LIMIT,
362
+ canonicalize,
363
+ stableRowId,
364
+ extractRows,
365
+ loadOperatorConfig,
366
+ resolveOperatorConfig,
367
+ fetchTelemetryExport,
368
+ readExistingIds,
369
+ mergeRowsIntoLedger,
370
+ resolveFeedbackDir,
371
+ computeSinceIso,
372
+ syncTelemetryFromProd,
373
+ parseArgs,
374
+ };
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Tool Contract Validator
5
+ * Validates tool arguments against the tool's inputSchema.
6
+ */
7
+ function validateToolContract(schema, args) {
8
+ const errors = [];
9
+ if (!schema) return { valid: true, errors };
10
+
11
+ if (schema.type === 'object') {
12
+ if (typeof args !== 'object' || args === null || Array.isArray(args)) {
13
+ errors.push(`Expected object, got ${args === null ? 'null' : Array.isArray(args) ? 'array' : typeof args}`);
14
+ return { valid: false, errors };
15
+ }
16
+
17
+ // Check required fields
18
+ if (Array.isArray(schema.required)) {
19
+ for (const req of schema.required) {
20
+ if (args[req] === undefined || args[req] === null || args[req] === '') {
21
+ errors.push(`Missing required parameter: '${req}'`);
22
+ }
23
+ }
24
+ }
25
+
26
+ // Check properties
27
+ if (schema.properties) {
28
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
29
+ const value = args[key];
30
+ if (value === undefined || value === null) continue; // Optional field not provided
31
+
32
+ const valType = typeof value;
33
+ if (propSchema.type === 'string') {
34
+ if (valType !== 'string') {
35
+ errors.push(`Parameter '${key}' must be a string (got ${valType})`);
36
+ } else if (Array.isArray(propSchema.enum)) {
37
+ if (!propSchema.enum.includes(value)) {
38
+ errors.push(`Parameter '${key}' must be one of [${propSchema.enum.join(', ')}] (got '${value}')`);
39
+ }
40
+ }
41
+ } else if (propSchema.type === 'number') {
42
+ if (valType !== 'number' || isNaN(value)) {
43
+ errors.push(`Parameter '${key}' must be a number (got ${valType})`);
44
+ }
45
+ } else if (propSchema.type === 'boolean') {
46
+ if (valType !== 'boolean') {
47
+ errors.push(`Parameter '${key}' must be a boolean (got ${valType})`);
48
+ }
49
+ } else if (propSchema.type === 'array') {
50
+ if (!Array.isArray(value)) {
51
+ errors.push(`Parameter '${key}' must be an array (got ${valType})`);
52
+ }
53
+ } else if (propSchema.type === 'object') {
54
+ if (valType !== 'object' || value === null || Array.isArray(value)) {
55
+ errors.push(`Parameter '${key}' must be an object (got ${valType})`);
56
+ } else {
57
+ // Recurse for nested objects
58
+ const subRes = validateToolContract(propSchema, value);
59
+ if (!subRes.valid) {
60
+ for (const err of subRes.errors) {
61
+ errors.push(`Parameter '${key}': ${err}`);
62
+ }
63
+ }
64
+ }
65
+ }
66
+ }
67
+ }
68
+ }
69
+
70
+ return {
71
+ valid: errors.length === 0,
72
+ errors,
73
+ };
74
+ }
75
+
76
+ module.exports = { validateToolContract };
@@ -0,0 +1,63 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Trajectory Scorer — Strategic Drift Detection.
6
+ *
7
+ * Measures the "Semantic Distance" between the original user intent
8
+ * (from primer.md) and the current set of changed files.
9
+ *
10
+ * If the agent modifies too many unrelated files, it triggers a safety block.
11
+ */
12
+
13
+ const fs = require('fs');
14
+ const path = require('path');
15
+ const { execSync } = require('child_process');
16
+
17
+ function getTrajectoryScore(options = {}) {
18
+ const projectRoot = options.projectRoot || process.cwd();
19
+ const primerPath = path.join(projectRoot, 'primer.md');
20
+
21
+ if (!fs.existsSync(primerPath)) return { score: 0, isDrifting: false, drift: false };
22
+
23
+ const intent = fs.readFileSync(primerPath, 'utf8').toLowerCase();
24
+
25
+ // Get currently modified files (unstaged + staged)
26
+ let changedFiles = options.changedFiles;
27
+ if (!changedFiles) {
28
+ try {
29
+ const output = execSync('git diff --name-only HEAD', { cwd: projectRoot, encoding: 'utf8' });
30
+ changedFiles = output.split('\n').filter(f => f.trim());
31
+ } catch {
32
+ return { score: 0, isDrifting: false, drift: false };
33
+ }
34
+ }
35
+
36
+ if (changedFiles.length === 0) return { score: 0, isDrifting: false, drift: false };
37
+
38
+ // Calculate drift: How many changed files are NOT mentioned in the intent?
39
+ let driftCount = 0;
40
+ for (const file of changedFiles) {
41
+ const base = path.basename(file).toLowerCase();
42
+ if (!intent.includes(base)) {
43
+ driftCount++;
44
+ }
45
+ }
46
+
47
+ const driftRatio = driftCount / changedFiles.length;
48
+ const isDrifting = driftRatio > 0.6 && changedFiles.length > 3;
49
+
50
+ return {
51
+ score: Number((1 - driftRatio).toFixed(2)),
52
+ changedCount: changedFiles.length,
53
+ driftCount,
54
+ isDrifting,
55
+ message: isDrifting
56
+ ? `🚫 THUMBGATE: Strategic Drift Detected. You have modified ${changedFiles.length} files, but ${driftCount} of them were not mentioned in the original intent. Please refocus or update the intent.`
57
+ : null
58
+ };
59
+ }
60
+
61
+ module.exports = {
62
+ getTrajectoryScore
63
+ };