thumbgate 1.27.18 → 1.27.19

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 (96) hide show
  1. package/.claude-plugin/marketplace.json +6 -6
  2. package/.claude-plugin/plugin.json +4 -3
  3. package/.well-known/agentic-verify.txt +1 -0
  4. package/.well-known/llms.txt +33 -12
  5. package/.well-known/mcp/server-card.json +8 -8
  6. package/README.md +249 -30
  7. package/adapters/chatgpt/openapi.yaml +12 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/gemini/function-declarations.json +1 -0
  11. package/adapters/mcp/server-stdio.js +263 -11
  12. package/adapters/opencode/opencode.json +1 -1
  13. package/bench/thumbgate-bench.json +2 -2
  14. package/bin/cli.js +1429 -121
  15. package/bin/postinstall.js +1 -8
  16. package/config/gate-classifier-routing.json +98 -0
  17. package/config/gate-templates.json +216 -0
  18. package/config/gates/claim-verification.json +12 -0
  19. package/config/gates/default.json +31 -2
  20. package/config/github-about.json +2 -2
  21. package/config/mcp-allowlists.json +23 -13
  22. package/config/merge-quality-checks.json +0 -1
  23. package/config/model-candidates.json +121 -6
  24. package/config/post-deploy-marketing-pages.json +80 -0
  25. package/config/tessl-tiles.json +1 -3
  26. package/openapi/openapi.yaml +12 -0
  27. package/package.json +1 -1
  28. package/public/blog.html +4 -4
  29. package/public/codex-plugin.html +72 -20
  30. package/public/compare.html +31 -8
  31. package/public/dashboard.html +930 -166
  32. package/public/federal.html +2 -2
  33. package/public/guide.html +33 -13
  34. package/public/index.html +469 -111
  35. package/public/learn.html +183 -18
  36. package/public/lessons.html +168 -10
  37. package/public/numbers.html +7 -7
  38. package/public/pro.html +34 -11
  39. package/scripts/agent-memory-lifecycle.js +211 -0
  40. package/scripts/agent-readiness.js +20 -3
  41. package/scripts/agent-reward-model.js +53 -1
  42. package/scripts/auto-promote-gates.js +82 -10
  43. package/scripts/auto-wire-hooks.js +14 -0
  44. package/scripts/billing.js +93 -1
  45. package/scripts/bot-detection.js +61 -3
  46. package/scripts/build-metadata.js +50 -10
  47. package/scripts/cli-feedback.js +4 -2
  48. package/scripts/cli-schema.js +97 -0
  49. package/scripts/cli-telemetry.js +6 -1
  50. package/scripts/commercial-offer.js +82 -2
  51. package/scripts/context-manager.js +74 -6
  52. package/scripts/dashboard.js +68 -2
  53. package/scripts/export-databricks-bundle.js +5 -1
  54. package/scripts/export-dpo-pairs.js +7 -2
  55. package/scripts/feedback-loop.js +123 -1
  56. package/scripts/feedback-quality.js +87 -0
  57. package/scripts/filesystem-search.js +35 -10
  58. package/scripts/gate-stats.js +89 -0
  59. package/scripts/gates-engine.js +1176 -85
  60. package/scripts/gemini-embedding-policy.js +2 -1
  61. package/scripts/hook-runtime.js +20 -14
  62. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  63. package/scripts/hybrid-feedback-context.js +142 -7
  64. package/scripts/lesson-inference.js +8 -3
  65. package/scripts/lesson-search.js +17 -1
  66. package/scripts/license.js +10 -10
  67. package/scripts/llm-client.js +169 -4
  68. package/scripts/local-model-profile.js +15 -8
  69. package/scripts/mcp-config.js +7 -1
  70. package/scripts/memory-scope-readiness.js +159 -0
  71. package/scripts/meta-agent-loop.js +36 -0
  72. package/scripts/operational-integrity.js +39 -5
  73. package/scripts/oss-pr-opportunity-scout.js +35 -5
  74. package/scripts/plausible-server-events.js +9 -6
  75. package/scripts/pro-local-dashboard.js +4 -4
  76. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  77. package/scripts/published-cli.js +0 -8
  78. package/scripts/rate-limiter.js +64 -13
  79. package/scripts/secret-scanner.js +44 -5
  80. package/scripts/security-scanner.js +260 -10
  81. package/scripts/self-distill-agent.js +3 -1
  82. package/scripts/seo-gsd.js +916 -7
  83. package/scripts/statusline-cache-path.js +17 -2
  84. package/scripts/statusline-local-stats.js +9 -1
  85. package/scripts/statusline-meta.js +28 -2
  86. package/scripts/statusline.sh +20 -4
  87. package/scripts/telemetry-analytics.js +357 -0
  88. package/scripts/thompson-sampling.js +31 -10
  89. package/scripts/thumbgate-bench.js +16 -1
  90. package/scripts/thumbgate-search.js +85 -19
  91. package/scripts/tool-registry.js +169 -1
  92. package/scripts/vector-store.js +45 -0
  93. package/scripts/workflow-sentinel.js +286 -53
  94. package/scripts/workspace-evolver.js +62 -2
  95. package/src/api/server.js +2683 -319
  96. package/scripts/bot-detector.js +0 -50
@@ -4,6 +4,7 @@
4
4
  const fs = require('node:fs');
5
5
  const os = require('node:os');
6
6
  const path = require('node:path');
7
+ const { expandFixturePlaceholders } = require('./secret-fixture-tokens');
7
8
 
8
9
  const ROOT = path.join(__dirname, '..');
9
10
  const DEFAULT_SUITE_PATH = path.join(ROOT, 'bench', 'thumbgate-bench.json');
@@ -180,6 +181,20 @@ function assertObject(value, label) {
180
181
  }
181
182
  }
182
183
 
184
+ function expandScenarioFixturePlaceholders(value) {
185
+ if (typeof value === 'string') return expandFixturePlaceholders(value);
186
+ if (Array.isArray(value)) return value.map(expandScenarioFixturePlaceholders);
187
+ if (value && typeof value === 'object') {
188
+ return Object.fromEntries(
189
+ Object.entries(value).map(([key, nestedValue]) => [
190
+ key,
191
+ expandScenarioFixturePlaceholders(nestedValue),
192
+ ]),
193
+ );
194
+ }
195
+ return value;
196
+ }
197
+
183
198
  function loadScenarioSuite(filePath = DEFAULT_SUITE_PATH) {
184
199
  const suite = readJson(filePath);
185
200
  assertObject(suite, 'Scenario suite');
@@ -202,7 +217,7 @@ function loadScenarioSuite(filePath = DEFAULT_SUITE_PATH) {
202
217
  throw new Error(`Scenario ${id} has invalid expectedDecision`);
203
218
  }
204
219
  return {
205
- ...scenario,
220
+ ...expandScenarioFixturePlaceholders(scenario),
206
221
  id,
207
222
  unsafe: Boolean(scenario.unsafe),
208
223
  positivePattern: Boolean(scenario.positivePattern),
@@ -151,8 +151,67 @@ function sortResults(results) {
151
151
  });
152
152
  }
153
153
 
154
- function getFeedbackResults(query, limit, signal) {
155
- const results = searchFeedbackLog(query, Math.max(limit * 3, limit));
154
+ function extractFeedbackId(str) {
155
+ if (!str) return null;
156
+ const match = str.match(/fb[_-]\d+[_-][a-z0-9]+/i);
157
+ return match ? match[0].replace(/-/g, '_').toLowerCase() : null;
158
+ }
159
+
160
+ function deduplicateResults(results) {
161
+ const bestByFeedbackId = new Map();
162
+
163
+ for (const r of results) {
164
+ const feedId = extractFeedbackId(r.id || r.title || r.file || '');
165
+ if (feedId) {
166
+ const existing = bestByFeedbackId.get(feedId);
167
+ if (!existing) {
168
+ bestByFeedbackId.set(feedId, r);
169
+ } else {
170
+ const sourceOrder = { feedback: 4, contextfs: 3, prevention_rule: 2, document: 1 };
171
+ const existingOrder = sourceOrder[existing.source] || 0;
172
+ const currentOrder = sourceOrder[r.source] || 0;
173
+ if (currentOrder > existingOrder) {
174
+ bestByFeedbackId.set(feedId, r);
175
+ } else if (currentOrder === existingOrder && (r.score || 0) > (existing.score || 0)) {
176
+ bestByFeedbackId.set(feedId, r);
177
+ }
178
+ }
179
+ }
180
+ }
181
+
182
+ const finalResults = [];
183
+ const seenContent = new Set();
184
+ const seenIds = new Set();
185
+
186
+ for (const r of results) {
187
+ const feedId = extractFeedbackId(r.id || r.title || r.file || '');
188
+ let recordToUse = r;
189
+ if (feedId) {
190
+ recordToUse = bestByFeedbackId.get(feedId);
191
+ if (seenIds.has(recordToUse.id)) continue;
192
+ } else {
193
+ if (r.id && r.id !== 'null' && String(r.id).trim() !== '') {
194
+ if (seenIds.has(r.id)) continue;
195
+ }
196
+ }
197
+
198
+ const normTitle = String(recordToUse.title || '').trim().toLowerCase();
199
+ const normContext = String(recordToUse.context || '').trim().toLowerCase();
200
+ const contentKey = `${normTitle}|${normContext}`;
201
+ if (seenContent.has(contentKey)) continue;
202
+
203
+ if (recordToUse.id && recordToUse.id !== 'null' && String(recordToUse.id).trim() !== '') {
204
+ seenIds.add(recordToUse.id);
205
+ }
206
+ seenContent.add(contentKey);
207
+ finalResults.push(recordToUse);
208
+ }
209
+
210
+ return finalResults;
211
+ }
212
+
213
+ function getFeedbackResults(query, limit, signal, feedbackDir) {
214
+ const results = searchFeedbackLog(query, Math.max(limit * 3, limit), { feedbackDir });
156
215
  const normalizedSignal = normalizeSignal(signal);
157
216
  const filtered = normalizedSignal
158
217
  ? results.filter((record) => normalizeRecordSignal(record.signal) === normalizedSignal)
@@ -160,19 +219,19 @@ function getFeedbackResults(query, limit, signal) {
160
219
  return filtered.slice(0, limit).map(mapFeedbackResult);
161
220
  }
162
221
 
163
- function getContextResults(query, limit) {
164
- return searchContextFs(query, limit).map(mapContextResult);
222
+ function getContextResults(query, limit, feedbackDir) {
223
+ return searchContextFs(query, limit, { feedbackDir }).map(mapContextResult);
165
224
  }
166
225
 
167
- function getRuleResults(query, limit) {
168
- return searchPreventionRulesSync(query, limit).map(mapRuleResult);
226
+ function getRuleResults(query, limit, feedbackDir) {
227
+ return searchPreventionRulesSync(query, limit, { feedbackDir }).map(mapRuleResult);
169
228
  }
170
229
 
171
- function getDocumentResults(query, limit) {
172
- return searchImportedDocuments({ query, limit }).map(mapDocumentResult);
230
+ function getDocumentResults(query, limit, feedbackDir) {
231
+ return searchImportedDocuments({ query, limit, feedbackDir }).map(mapDocumentResult);
173
232
  }
174
233
 
175
- function searchThumbgate({ query, source = 'all', limit = 10, signal = null } = {}) {
234
+ function searchThumbgate({ query, source = 'all', limit = 10, signal = null, feedbackDir = null } = {}) {
176
235
  const trimmedQuery = String(query || '').trim();
177
236
  if (!trimmedQuery) {
178
237
  throw new Error('query is required');
@@ -182,22 +241,29 @@ function searchThumbgate({ query, source = 'all', limit = 10, signal = null } =
182
241
  const normalizedSignal = normalizeSignal(signal);
183
242
  const normalizedLimit = normalizeLimit(limit);
184
243
 
244
+ const fetchLimit = Math.max(100, normalizedLimit * 5);
245
+
185
246
  let results = [];
186
247
  if (normalizedSource === 'feedback') {
187
- results = getFeedbackResults(trimmedQuery, normalizedLimit, normalizedSignal);
248
+ const raw = getFeedbackResults(trimmedQuery, fetchLimit, normalizedSignal, feedbackDir);
249
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
188
250
  } else if (normalizedSource === 'context') {
189
- results = getContextResults(trimmedQuery, normalizedLimit);
251
+ const raw = getContextResults(trimmedQuery, fetchLimit, feedbackDir);
252
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
190
253
  } else if (normalizedSource === 'rules') {
191
- results = getRuleResults(trimmedQuery, normalizedLimit);
254
+ const raw = getRuleResults(trimmedQuery, fetchLimit, feedbackDir);
255
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
192
256
  } else if (normalizedSource === 'documents') {
193
- results = getDocumentResults(trimmedQuery, normalizedLimit);
257
+ const raw = getDocumentResults(trimmedQuery, fetchLimit, feedbackDir);
258
+ results = deduplicateResults(raw).slice(0, normalizedLimit);
194
259
  } else {
195
- results = sortResults([
196
- ...getFeedbackResults(trimmedQuery, normalizedLimit, normalizedSignal),
197
- ...getContextResults(trimmedQuery, normalizedLimit),
198
- ...getRuleResults(trimmedQuery, normalizedLimit),
199
- ...getDocumentResults(trimmedQuery, normalizedLimit),
200
- ]).slice(0, normalizedLimit);
260
+ const combined = [
261
+ ...getFeedbackResults(trimmedQuery, fetchLimit, normalizedSignal, feedbackDir),
262
+ ...getContextResults(trimmedQuery, fetchLimit, feedbackDir),
263
+ ...getRuleResults(trimmedQuery, fetchLimit, feedbackDir),
264
+ ...getDocumentResults(trimmedQuery, fetchLimit, feedbackDir),
265
+ ];
266
+ results = deduplicateResults(sortResults(combined)).slice(0, normalizedLimit);
201
267
  }
202
268
 
203
269
  return {
@@ -1,10 +1,23 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ // Human-readable display title from a snake_case tool name.
5
+ // e.g. "capture_feedback" -> "Capture Feedback". The Claude Connectors Directory
6
+ // requires every tool to carry BOTH a title and a readOnlyHint/destructiveHint.
7
+ function humanizeTitle(name) {
8
+ return String(name || '')
9
+ .split(/[_-]+/)
10
+ .filter(Boolean)
11
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
12
+ .join(' ');
13
+ }
14
+
4
15
  function readOnlyTool(tool) {
5
16
  return {
6
17
  ...tool,
18
+ title: tool.title || humanizeTitle(tool.name),
7
19
  annotations: {
20
+ title: tool.title || humanizeTitle(tool.name),
8
21
  readOnlyHint: true,
9
22
  },
10
23
  };
@@ -13,12 +26,40 @@ function readOnlyTool(tool) {
13
26
  function destructiveTool(tool) {
14
27
  return {
15
28
  ...tool,
29
+ title: tool.title || humanizeTitle(tool.name),
16
30
  annotations: {
31
+ title: tool.title || humanizeTitle(tool.name),
17
32
  destructiveHint: true,
18
33
  },
19
34
  };
20
35
  }
21
36
 
37
+ const GOAL_CONTRACT_SCHEMA = {
38
+ type: 'object',
39
+ description: 'Optional agent handoff contract. Use this when a worker/orchestrator/reviewer loop needs explicit done criteria before a done/fixed/shipped claim is allowed.',
40
+ properties: {
41
+ goal: { type: 'string', description: 'The operator-visible goal this claim is trying to close.' },
42
+ doneWhen: {
43
+ type: 'array',
44
+ items: { type: 'string' },
45
+ description: 'Human-readable acceptance criteria for the task.',
46
+ },
47
+ proveBy: {
48
+ type: 'array',
49
+ items: { type: 'string' },
50
+ description: 'Tracked action ids required before the claim can pass, such as tests_passed, review_completed, ci_green, deploy_verified, or operator_approved.',
51
+ },
52
+ mustNotChange: {
53
+ type: 'array',
54
+ items: { type: 'string' },
55
+ description: 'Protected areas or constraints the agents must preserve while completing the goal.',
56
+ },
57
+ workerAgent: { type: 'string', description: 'Agent responsible for implementation.' },
58
+ reviewerAgent: { type: 'string', description: 'Agent responsible for independent verification.' },
59
+ orchestratorAgent: { type: 'string', description: 'Agent responsible for routing and deciding whether done can be claimed.' },
60
+ },
61
+ };
62
+
22
63
  const TOOLS = [
23
64
  readOnlyTool({
24
65
  name: 'capture_feedback',
@@ -120,6 +161,19 @@ const TOOLS = [
120
161
  required: ['toolName'],
121
162
  },
122
163
  }),
164
+ readOnlyTool({
165
+ name: 'ai_component_inventory',
166
+ description: 'Scan a project for AI/ML provider SDKs, agent frameworks, vector databases, Vertex/Gemini/Dialogflow CX usage, and model artifacts. Returns evidence suitable for enterprise AI inventory and ML-BOM review.',
167
+ inputSchema: {
168
+ type: 'object',
169
+ properties: {
170
+ rootDir: { type: 'string', description: 'Project root to scan. Defaults to the current process working directory.' },
171
+ format: { type: 'string', enum: ['summary', 'json', 'cyclonedx'], description: 'Response format. summary is compact text; json returns ThumbGate inventory; cyclonedx returns ML-BOM JSON.' },
172
+ maxFiles: { type: 'number', description: 'Maximum files to scan (default 2500).' },
173
+ includeSnippets: { type: 'boolean', description: 'Include matched source snippets in evidence. Defaults true.' },
174
+ },
175
+ },
176
+ }),
123
177
  readOnlyTool({
124
178
  name: 'search_thumbgate',
125
179
  description: 'Search raw ThumbGate state across feedback logs, ContextFS memory, prevention rules, and imported policy documents.',
@@ -371,6 +425,24 @@ const TOOLS = [
371
425
  },
372
426
  },
373
427
  }),
428
+ readOnlyTool({
429
+ name: 'suggest_fix',
430
+ description: 'Suggest corrective actions for a described failure by searching the lesson DB and prevention rules. Returns up to 3 ranked suggestions with their source. Call this when something goes wrong and you need guidance on what to do next.',
431
+ inputSchema: {
432
+ type: 'object',
433
+ required: ['context'],
434
+ properties: {
435
+ context: {
436
+ type: 'string',
437
+ description: 'Description of what went wrong or what the agent is trying to fix.',
438
+ },
439
+ limit: {
440
+ type: 'number',
441
+ description: 'Maximum number of suggestions to return (default 3, max 5).',
442
+ },
443
+ },
444
+ },
445
+ }),
374
446
  readOnlyTool({
375
447
  name: 'infer_lesson_from_history',
376
448
  description: 'Perform autonomous inference on chat history to identify why a failure occurred and what rule should be recorded.',
@@ -417,6 +489,7 @@ const TOOLS = [
417
489
  bundleId: { type: 'string' },
418
490
  partnerProfile: { type: 'string' },
419
491
  delegationMode: { type: 'string', enum: ['off', 'auto', 'sequential'] },
492
+ enforcePlanQuality: { type: 'boolean' },
420
493
  approved: { type: 'boolean' },
421
494
  repoPath: { type: 'string' },
422
495
  },
@@ -758,6 +831,17 @@ const TOOLS = [
758
831
  items: { type: 'string' },
759
832
  description: 'Optional protected-file globs that require explicit approval before editing or publishing',
760
833
  },
834
+ workflowContract: {
835
+ type: 'object',
836
+ description: 'Optional deterministic workflow run contract. Supports workflowId, allowedBranches, blockedActions, requiredEvidence, and completionGate.',
837
+ properties: {
838
+ workflowId: { type: 'string' },
839
+ allowedBranches: { type: 'array', items: { type: 'string' } },
840
+ blockedActions: { type: 'array', items: { type: 'string' } },
841
+ requiredEvidence: { type: 'array', items: { type: 'string' } },
842
+ completionGate: { type: 'string' },
843
+ },
844
+ },
761
845
  repoPath: { type: 'string', description: 'Optional repo root used when evaluating git diff scope' },
762
846
  localOnly: { type: 'boolean', description: 'When true, also marks the task as local-only' },
763
847
  clear: { type: 'boolean', description: 'Clear the current task scope instead of setting one' },
@@ -835,6 +919,56 @@ const TOOLS = [
835
919
  },
836
920
  },
837
921
  }),
922
+ destructiveTool({
923
+ name: 'detect_noop',
924
+ title: 'Detect No-op Action',
925
+ description: 'Detect whether a tool call was a no-op (state unchanged) or identical to a prior attempt in the session — a cheap repeat-loop signal. Records the action attempt state for repeat detection.',
926
+ inputSchema: {
927
+ type: 'object',
928
+ required: ['actionId'],
929
+ properties: {
930
+ actionId: { type: 'string', description: 'Stable identifier for the action being checked (e.g. the file path or command being attempted)' },
931
+ kind: { type: 'string', enum: ['file', 'command'], description: 'Action kind: file edit/write or command execution' },
932
+ filePath: { type: 'string', description: 'Path of the file the action targets (file kind)' },
933
+ beforeContent: { type: 'string', description: 'File content before the action (file kind)' },
934
+ afterContent: { type: 'string', description: 'File content after the action (file kind)' },
935
+ exitCode: { type: 'number', description: 'Command exit code (command kind)' },
936
+ stdout: { type: 'string', description: 'Command stdout (command kind)' },
937
+ stderr: { type: 'string', description: 'Command stderr (command kind)' },
938
+ sessionId: { type: 'string', description: 'Optional session id used to scope repeat-attempt detection' },
939
+ },
940
+ },
941
+ }),
942
+ destructiveTool({
943
+ name: 'record_action_receipt',
944
+ title: 'Record Action Receipt',
945
+ description: 'Pair a tracked tool call with its outcome (diff, exit code, test result) so a promoted lesson encodes "this action -> this outcome", not just a thumbs signal. Appends to the action-receipts log.',
946
+ inputSchema: {
947
+ type: 'object',
948
+ required: ['actionId'],
949
+ properties: {
950
+ actionId: { type: 'string', description: 'Identifier of the tracked action this receipt pairs with' },
951
+ toolName: { type: 'string', description: 'Name of the tool that was invoked' },
952
+ toolInput: { type: 'object', description: 'Structured input the tool was called with' },
953
+ diff: { type: 'string', description: 'Optional unified diff or change summary produced by the action' },
954
+ exitCode: { type: 'number', description: 'Optional command exit code outcome' },
955
+ testOutcome: { type: 'string', description: 'Optional test outcome (e.g. passed, failed, 12/12)' },
956
+ stateHash: { type: 'string', description: 'Optional post-action state hash (from detect_noop)' },
957
+ },
958
+ },
959
+ }),
960
+ readOnlyTool({
961
+ name: 'get_action_receipts',
962
+ title: 'Get Action Receipts',
963
+ description: 'Read outcome-paired action receipts. Returns the receipt for a specific actionId, or the most recent receipts when no actionId is given.',
964
+ inputSchema: {
965
+ type: 'object',
966
+ properties: {
967
+ actionId: { type: 'string', description: 'Optional action id to fetch the matching receipt for' },
968
+ limit: { type: 'number', description: 'Max number of recent receipts to return when no actionId is given (default 20)' },
969
+ },
970
+ },
971
+ }),
838
972
  readOnlyTool({
839
973
  name: 'verify_claim',
840
974
  description: 'Check whether a claim has enough tracked evidence before the agent asserts it.',
@@ -843,6 +977,7 @@ const TOOLS = [
843
977
  required: ['claim'],
844
978
  properties: {
845
979
  claim: { type: 'string', description: 'The claim text to verify' },
980
+ goalContract: GOAL_CONTRACT_SCHEMA,
846
981
  },
847
982
  },
848
983
  }),
@@ -1291,6 +1426,7 @@ const TOOLS = [
1291
1426
  claim: { type: 'string', description: 'The completion claim text to verify (e.g. "Fix shipped", "Tests passing")' },
1292
1427
  mode: { type: 'string', enum: ['blocking', 'advisory'], description: 'blocking (default) returns blocking=true when evidence missing; advisory returns blocking=false' },
1293
1428
  sessionId: { type: 'string', description: 'Optional session id to associate with the gate decision' },
1429
+ goalContract: GOAL_CONTRACT_SCHEMA,
1294
1430
  },
1295
1431
  },
1296
1432
  }),
@@ -1348,8 +1484,40 @@ const TOOLS = [
1348
1484
  },
1349
1485
  },
1350
1486
  }),
1487
+ destructiveTool({
1488
+ name: 'parallel_workflow',
1489
+ description: 'Execute a parallel, multi-step subtask workflow to resolve an objective like a security audit, performance benchmark, or repository inspection.',
1490
+ inputSchema: {
1491
+ type: 'object',
1492
+ required: ['objective'],
1493
+ properties: {
1494
+ objective: { type: 'string', description: 'The objective to plan and execute (e.g. security audit, performance benchmark)' },
1495
+ concurrency: { type: 'number', description: 'Maximum parallel subtasks (default 3)' },
1496
+ timeoutMs: { type: 'number', description: 'Timeout in milliseconds (default 60000)' },
1497
+ },
1498
+ },
1499
+ }),
1351
1500
  ];
1352
1501
 
1502
+ // Normalize at export: guarantee EVERY tool carries a human-readable title and a
1503
+ // readOnlyHint/destructiveHint annotation (both required by the Connectors
1504
+ // Directory; the #1 rejection cause is missing annotations). Tools defined as
1505
+ // plain objects (not via readOnlyTool/destructiveTool) are backfilled here:
1506
+ // title from the name, and a conservative destructiveHint when no hint is set
1507
+ // (so an un-hinted tool is gated rather than silently treated as read-only).
1508
+ const NORMALIZED_TOOLS = TOOLS.map((tool) => {
1509
+ const title = tool.title || humanizeTitle(tool.name);
1510
+ const existing = tool.annotations || {};
1511
+ const hasHint = existing.readOnlyHint === true || existing.destructiveHint === true;
1512
+ const annotations = {
1513
+ title,
1514
+ ...existing,
1515
+ ...(hasHint ? {} : { destructiveHint: true }),
1516
+ };
1517
+ return { ...tool, title, annotations };
1518
+ });
1519
+
1353
1520
  module.exports = {
1354
- TOOLS,
1521
+ TOOLS: NORMALIZED_TOOLS,
1522
+ humanizeTitle,
1355
1523
  };
@@ -172,6 +172,30 @@ async function embedWithGemini(text, options = {}) {
172
172
  return values.map(Number);
173
173
  }
174
174
 
175
+ async function embedWithCoreAI(text, options = {}) {
176
+ if (process.platform !== 'darwin') {
177
+ throw new Error('Core AI is only supported on macOS');
178
+ }
179
+ const endpoint = process.env.THUMBGATE_COREAI_ENDPOINT || 'http://localhost:8088';
180
+ try {
181
+ const res = await fetch(`${endpoint}/embed`, {
182
+ method: 'POST',
183
+ headers: { 'Content-Type': 'application/json' },
184
+ body: JSON.stringify({ text, options }),
185
+ signal: AbortSignal.timeout(2000),
186
+ });
187
+ if (res.ok) {
188
+ const payload = await res.json();
189
+ if (Array.isArray(payload.embedding)) {
190
+ return payload.embedding.map(Number);
191
+ }
192
+ }
193
+ } catch (err) {
194
+ throw new Error(`Core AI local service unavailable: ${err.message}`);
195
+ }
196
+ throw new Error('Core AI local service did not return a valid embedding');
197
+ }
198
+
175
199
  async function embed(text, options = {}) {
176
200
  if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') {
177
201
  // Deterministic 384-dim unit vector: first element = 1.0, rest = 0.0
@@ -180,6 +204,26 @@ async function embed(text, options = {}) {
180
204
  return stub;
181
205
  }
182
206
  const geminiConfig = resolveGeminiEmbeddingConfig();
207
+ if (geminiConfig.provider === 'coreai') {
208
+ try {
209
+ const vector = await embedWithCoreAI(text, options);
210
+ _lastEmbeddingProfile = {
211
+ generatedAt: new Date().toISOString(),
212
+ source: 'local-coreai',
213
+ activeProfile: {
214
+ id: 'coreai',
215
+ model: 'Core AI local model',
216
+ outputDimensionality: vector.length,
217
+ task: options.task || 'code retrieval',
218
+ rationale: 'Local Core AI Apple Silicon accelerated path.',
219
+ },
220
+ fallbackUsed: false,
221
+ };
222
+ return vector;
223
+ } catch (coreaiError) {
224
+ console.warn(`Core AI embedding failed, falling back to local: ${coreaiError.message}`);
225
+ }
226
+ }
183
227
  if (geminiConfig.enabled) {
184
228
  try {
185
229
  const vector = await embedWithGemini(text, options);
@@ -312,6 +356,7 @@ function setGeminiEmbedderForTests(loader) {
312
356
  module.exports = {
313
357
  upsertFeedback,
314
358
  searchSimilar,
359
+ embed,
315
360
  TABLE_NAME,
316
361
  getEmbeddingConfig,
317
362
  getLastEmbeddingProfile,