thumbgate 1.3.0 → 1.4.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 (146) hide show
  1. package/.claude-plugin/marketplace.json +32 -13
  2. package/.claude-plugin/plugin.json +15 -2
  3. package/.well-known/llms.txt +60 -0
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +109 -20
  6. package/adapters/README.md +1 -1
  7. package/adapters/chatgpt/openapi.yaml +168 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/mcp/server-stdio.js +84 -1
  11. package/adapters/opencode/opencode.json +1 -1
  12. package/bin/cli.js +200 -13
  13. package/bin/postinstall.js +8 -2
  14. package/config/budget.json +18 -0
  15. package/config/gates/code-edit.json +61 -0
  16. package/config/gates/db-write.json +61 -0
  17. package/config/gates/default.json +154 -3
  18. package/config/gates/deploy.json +61 -0
  19. package/config/github-about.json +2 -1
  20. package/config/merge-quality-checks.json +23 -0
  21. package/openapi/openapi.yaml +168 -0
  22. package/package.json +42 -10
  23. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  25. package/plugins/claude-codex-bridge/scripts/codex-bridge.js +1 -3
  26. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  27. package/plugins/codex-profile/.mcp.json +1 -1
  28. package/plugins/codex-profile/INSTALL.md +27 -4
  29. package/plugins/codex-profile/README.md +33 -9
  30. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  31. package/plugins/opencode-profile/INSTALL.md +1 -1
  32. package/public/blog.html +73 -0
  33. package/public/compare/mem0.html +189 -0
  34. package/public/compare/speclock.html +180 -0
  35. package/public/compare.html +10 -2
  36. package/public/guide.html +2 -2
  37. package/public/guides/claude-code-prevent-repeated-mistakes.html +161 -0
  38. package/public/guides/codex-cli-guardrails.html +158 -0
  39. package/public/guides/cursor-prevent-repeated-mistakes.html +161 -0
  40. package/public/guides/pre-action-gates.html +162 -0
  41. package/public/guides/stop-repeated-ai-agent-mistakes.html +159 -0
  42. package/public/index.html +136 -50
  43. package/public/lessons.html +33 -24
  44. package/public/llm-context.md +140 -0
  45. package/public/pro.html +24 -22
  46. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  47. package/scripts/access-anomaly-detector.js +1 -1
  48. package/scripts/adk-consolidator.js +1 -5
  49. package/scripts/agent-security-hardening.js +4 -6
  50. package/scripts/agentic-data-pipeline.js +1 -3
  51. package/scripts/async-job-runner.js +1 -5
  52. package/scripts/audit-trail.js +1 -5
  53. package/scripts/background-agent-governance.js +2 -10
  54. package/scripts/billing.js +2 -16
  55. package/scripts/budget-enforcer.js +173 -0
  56. package/scripts/build-codex-plugin.js +152 -0
  57. package/scripts/check-congruence.js +132 -14
  58. package/scripts/commercial-offer.js +5 -7
  59. package/scripts/content-engine/linkedin-content-generator.js +154 -0
  60. package/scripts/content-engine/output/linkedin-memento-validation.md +17 -0
  61. package/scripts/content-engine/output/linkedin-posts-2026-04-09.md +175 -0
  62. package/scripts/content-engine/reddit-thread-finder.js +154 -0
  63. package/scripts/context-engine.js +21 -6
  64. package/scripts/contextfs.js +1 -21
  65. package/scripts/dashboard.js +20 -0
  66. package/scripts/decision-journal.js +341 -0
  67. package/scripts/delegation-runtime.js +1 -5
  68. package/scripts/distribution-surfaces.js +26 -0
  69. package/scripts/document-intake.js +927 -0
  70. package/scripts/ephemeral-agent-store.js +1 -8
  71. package/scripts/evolution-state.js +1 -5
  72. package/scripts/experiment-tracker.js +1 -5
  73. package/scripts/export-databricks-bundle.js +1 -5
  74. package/scripts/export-hf-dataset.js +1 -5
  75. package/scripts/export-training.js +1 -5
  76. package/scripts/feedback-attribution.js +1 -16
  77. package/scripts/feedback-history-distiller.js +1 -16
  78. package/scripts/feedback-loop.js +1 -5
  79. package/scripts/feedback-root-consolidator.js +2 -21
  80. package/scripts/feedback-session.js +49 -0
  81. package/scripts/feedback-to-rules.js +188 -28
  82. package/scripts/filesystem-search.js +1 -9
  83. package/scripts/fs-utils.js +104 -0
  84. package/scripts/gates-engine.js +149 -4
  85. package/scripts/github-about.js +32 -8
  86. package/scripts/gtm-revenue-loop.js +1 -5
  87. package/scripts/harness-selector.js +148 -0
  88. package/scripts/hosted-job-launcher.js +1 -5
  89. package/scripts/hybrid-feedback-context.js +7 -33
  90. package/scripts/intervention-policy.js +58 -1
  91. package/scripts/lesson-db.js +3 -18
  92. package/scripts/lesson-inference.js +194 -16
  93. package/scripts/lesson-retrieval.js +60 -24
  94. package/scripts/llm-client.js +59 -0
  95. package/scripts/managed-lesson-agent.js +183 -0
  96. package/scripts/marketing-experiment.js +8 -22
  97. package/scripts/meta-agent-loop.js +624 -0
  98. package/scripts/metered-billing.js +1 -1
  99. package/scripts/money-watcher.js +1 -4
  100. package/scripts/obsidian-export.js +1 -5
  101. package/scripts/operational-integrity.js +15 -3
  102. package/scripts/org-dashboard.js +6 -1
  103. package/scripts/per-step-scoring.js +2 -4
  104. package/scripts/pr-manager.js +201 -19
  105. package/scripts/pro-features.js +3 -2
  106. package/scripts/prompt-dlp.js +3 -3
  107. package/scripts/prove-adapters.js +1 -5
  108. package/scripts/prove-attribution.js +1 -5
  109. package/scripts/prove-automation.js +1 -3
  110. package/scripts/prove-cloudflare-sandbox.js +1 -3
  111. package/scripts/prove-data-pipeline.js +1 -3
  112. package/scripts/prove-intelligence.js +1 -3
  113. package/scripts/prove-lancedb.js +1 -5
  114. package/scripts/prove-local-intelligence.js +1 -3
  115. package/scripts/prove-packaged-runtime.js +75 -9
  116. package/scripts/prove-predictive-insights.js +1 -3
  117. package/scripts/prove-training-export.js +1 -3
  118. package/scripts/prove-workflow-contract.js +1 -5
  119. package/scripts/rate-limiter.js +3 -1
  120. package/scripts/reddit-dm-outreach.js +14 -4
  121. package/scripts/schedule-manager.js +3 -5
  122. package/scripts/security-scanner.js +448 -0
  123. package/scripts/self-distill-agent.js +579 -0
  124. package/scripts/semantic-dedup.js +115 -0
  125. package/scripts/skill-exporter.js +1 -3
  126. package/scripts/skill-generator.js +1 -5
  127. package/scripts/social-analytics/engagement-audit.js +1 -18
  128. package/scripts/social-analytics/pollers/linkedin.js +26 -16
  129. package/scripts/social-analytics/publishers/linkedin.js +1 -1
  130. package/scripts/social-analytics/publishers/zernio.js +51 -0
  131. package/scripts/social-pipeline.js +1 -3
  132. package/scripts/social-post-hourly.js +47 -4
  133. package/scripts/statusline-links.js +6 -5
  134. package/scripts/statusline.sh +29 -153
  135. package/scripts/sync-branch-protection.js +340 -0
  136. package/scripts/tessl-export.js +1 -3
  137. package/scripts/thumbgate-search.js +32 -1
  138. package/scripts/tool-kpi-tracker.js +1 -1
  139. package/scripts/tool-registry.js +106 -2
  140. package/scripts/vector-store.js +1 -5
  141. package/scripts/weekly-auto-post.js +1 -1
  142. package/scripts/workflow-sentinel.js +91 -0
  143. package/skills/thumbgate/SKILL.md +1 -1
  144. package/src/api/server.js +273 -4
  145. package/scripts/social-analytics/db/social-analytics.db-shm +0 -0
  146. /package/scripts/social-analytics/db/{social-analytics.db-wal → analytics.sqlite} +0 -0
@@ -0,0 +1,340 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawnSync } = require('node:child_process');
7
+ const MERGE_QUALITY_CHECKS = require('../config/merge-quality-checks.json');
8
+
9
+ const DEFAULT_REPO = process.env.GITHUB_REPOSITORY || 'IgorGanapolsky/ThumbGate';
10
+ const DEFAULT_BRANCH = process.env.DEFAULT_BRANCH || 'main';
11
+ const FIXED_GH_BINARIES = [
12
+ '/usr/bin/gh',
13
+ '/usr/local/bin/gh',
14
+ '/opt/homebrew/bin/gh',
15
+ ];
16
+
17
+ function assertSafeGhArgs(args) {
18
+ if (!Array.isArray(args) || args.length === 0) {
19
+ throw new Error('GH CLI args must be a non-empty array.');
20
+ }
21
+
22
+ return args.map((arg) => {
23
+ const normalized = String(arg ?? '');
24
+ if (!normalized || /\0/.test(normalized)) {
25
+ throw new Error(`Unsafe GH CLI arg: ${arg}`);
26
+ }
27
+ return normalized;
28
+ });
29
+ }
30
+
31
+ function resolveGhBinary(options = {}) {
32
+ const accessSync = options.accessSync || fs.accessSync;
33
+ const candidates = [];
34
+ const configuredBinary = String(process.env.THUMBGATE_GH_BIN || '').trim();
35
+
36
+ if (configuredBinary) {
37
+ if (!path.isAbsolute(configuredBinary)) {
38
+ throw new Error(`Unsafe GH binary path: ${configuredBinary}`);
39
+ }
40
+ candidates.push(configuredBinary);
41
+ }
42
+
43
+ candidates.push(...FIXED_GH_BINARIES);
44
+
45
+ for (const candidate of candidates) {
46
+ try {
47
+ accessSync(candidate, fs.constants.X_OK);
48
+ return candidate;
49
+ } catch {
50
+ continue;
51
+ }
52
+ }
53
+
54
+ throw new Error(`Unable to locate GH CLI in fixed paths: ${candidates.join(', ')}`);
55
+ }
56
+
57
+ function runGh(args, options = {}) {
58
+ const env = { ...process.env };
59
+ if (!env.GITHUB_ACTIONS && env.GITHUB_TOKEN && !env.GH_TOKEN) {
60
+ delete env.GITHUB_TOKEN;
61
+ }
62
+
63
+ return spawnSync(resolveGhBinary(options), assertSafeGhArgs(args), {
64
+ encoding: 'utf8',
65
+ env,
66
+ stdio: ['ignore', 'pipe', 'pipe'],
67
+ });
68
+ }
69
+
70
+ function formatGhError(result) {
71
+ return (result.stderr || result.stdout || 'Unknown GH CLI failure').trim();
72
+ }
73
+
74
+ function parseArgs(argv = process.argv.slice(2)) {
75
+ const options = {
76
+ check: false,
77
+ json: false,
78
+ repo: DEFAULT_REPO,
79
+ branch: DEFAULT_BRANCH,
80
+ };
81
+
82
+ for (let index = 0; index < argv.length; index += 1) {
83
+ const arg = argv[index];
84
+ if (arg === '--check') {
85
+ options.check = true;
86
+ } else if (arg === '--json') {
87
+ options.json = true;
88
+ } else if (arg === '--repo' && argv[index + 1]) {
89
+ options.repo = argv[index + 1];
90
+ index += 1;
91
+ } else if (arg === '--branch' && argv[index + 1]) {
92
+ options.branch = argv[index + 1];
93
+ index += 1;
94
+ }
95
+ }
96
+
97
+ return options;
98
+ }
99
+
100
+ function assertSafeRepoSegment(value, label) {
101
+ const normalized = String(value || '').trim();
102
+ if (!/^[A-Za-z0-9_.-]+$/.test(normalized)) {
103
+ throw new Error(`Unsafe repository ${label}: ${value}`);
104
+ }
105
+ return normalized;
106
+ }
107
+
108
+ function splitRepo(repo) {
109
+ const [owner, name] = String(repo || '').trim().split('/');
110
+ if (!owner || !name) {
111
+ throw new Error(`Invalid repository "${repo}". Expected owner/name.`);
112
+ }
113
+ return {
114
+ owner: assertSafeRepoSegment(owner, 'owner'),
115
+ name: assertSafeRepoSegment(name, 'name'),
116
+ };
117
+ }
118
+
119
+ function assertSafeBranchPattern(branch) {
120
+ const normalized = String(branch || '').trim();
121
+ if (!normalized) {
122
+ throw new Error('Branch pattern is required.');
123
+ }
124
+ if (normalized.startsWith('-') || normalized.includes('..') || normalized.includes('//') || normalized.includes('@{')) {
125
+ throw new Error(`Unsafe branch pattern: ${branch}`);
126
+ }
127
+ if (normalized.endsWith('.') || normalized.endsWith('/')) {
128
+ throw new Error(`Unsafe branch pattern: ${branch}`);
129
+ }
130
+ if (!/^[A-Za-z0-9._/-]+$/.test(normalized)) {
131
+ throw new Error(`Unsafe branch pattern: ${branch}`);
132
+ }
133
+ return normalized;
134
+ }
135
+
136
+ function assertSafeRuleId(ruleId) {
137
+ const normalized = String(ruleId || '').trim();
138
+ if (!/^[A-Za-z0-9_=-]+$/.test(normalized)) {
139
+ throw new Error(`Unsafe branch protection rule id: ${ruleId}`);
140
+ }
141
+ return normalized;
142
+ }
143
+
144
+ function assertSafeStatusContext(context) {
145
+ const normalized = String(context || '').trim();
146
+ if (!normalized || /[\0\r\n]/.test(normalized)) {
147
+ throw new Error(`Unsafe status check context: ${context}`);
148
+ }
149
+ return normalized;
150
+ }
151
+
152
+ function normalizeContexts(contexts = []) {
153
+ return [...new Set((Array.isArray(contexts) ? contexts : []).map((value) => {
154
+ const normalized = String(value || '').trim();
155
+ return normalized ? assertSafeStatusContext(normalized) : '';
156
+ }).filter(Boolean))].sort((left, right) => left.localeCompare(right));
157
+ }
158
+
159
+ function loadBranchProtectionRule(repo, runner = runGh) {
160
+ const { owner, name } = splitRepo(repo);
161
+ const query = `
162
+ query BranchProtectionRules($owner: String!, $name: String!) {
163
+ repository(owner: $owner, name: $name) {
164
+ branchProtectionRules(first: 50) {
165
+ nodes {
166
+ id
167
+ pattern
168
+ requiresStatusChecks
169
+ requiredStatusCheckContexts
170
+ requiresApprovingReviews
171
+ requiresConversationResolution
172
+ }
173
+ }
174
+ }
175
+ }
176
+ `;
177
+
178
+ const result = runner([
179
+ 'api',
180
+ 'graphql',
181
+ '-f',
182
+ `query=${query}`,
183
+ '-f',
184
+ `owner=${owner}`,
185
+ '-f',
186
+ `name=${name}`,
187
+ ]);
188
+
189
+ if (result.status !== 0) {
190
+ throw new Error(`Failed to load branch protection: ${formatGhError(result)}`);
191
+ }
192
+
193
+ const payload = JSON.parse(result.stdout || '{}');
194
+ return payload.data?.repository?.branchProtectionRules?.nodes || [];
195
+ }
196
+
197
+ function findBranchProtectionRule(rules, branch) {
198
+ return (Array.isArray(rules) ? rules : []).find((rule) => rule.pattern === branch) || null;
199
+ }
200
+
201
+ function diffContexts(actual, expected) {
202
+ const actualSet = new Set(normalizeContexts(actual));
203
+ const expectedSet = new Set(normalizeContexts(expected));
204
+
205
+ return {
206
+ missing: [...expectedSet].filter((value) => !actualSet.has(value)),
207
+ unexpected: [...actualSet].filter((value) => !expectedSet.has(value)),
208
+ };
209
+ }
210
+
211
+ function updateBranchProtectionRule(ruleId, requiredStatusCheckContexts, runner = runGh) {
212
+ const safeRuleId = assertSafeRuleId(ruleId);
213
+ const contexts = normalizeContexts(requiredStatusCheckContexts);
214
+ const mutation = `
215
+ mutation UpdateBranchProtectionRule($ruleId: ID!, $contexts: [String!]) {
216
+ updateBranchProtectionRule(input: {
217
+ branchProtectionRuleId: $ruleId
218
+ requiresStatusChecks: true
219
+ requiredStatusCheckContexts: $contexts
220
+ }) {
221
+ branchProtectionRule {
222
+ id
223
+ pattern
224
+ requiresStatusChecks
225
+ requiredStatusCheckContexts
226
+ }
227
+ }
228
+ }
229
+ `;
230
+
231
+ const args = [
232
+ 'api',
233
+ 'graphql',
234
+ '-f',
235
+ `query=${mutation}`,
236
+ '-f',
237
+ `ruleId=${safeRuleId}`,
238
+ ];
239
+ for (const context of contexts) {
240
+ args.push('-F', `contexts[]=${context}`);
241
+ }
242
+
243
+ const result = runner(args);
244
+
245
+ if (result.status !== 0) {
246
+ throw new Error(`Failed to update branch protection: ${formatGhError(result)}`);
247
+ }
248
+
249
+ return JSON.parse(result.stdout || '{}').data?.updateBranchProtectionRule?.branchProtectionRule || null;
250
+ }
251
+
252
+ function syncBranchProtection(options = {}, runner = runGh) {
253
+ const repo = options.repo || DEFAULT_REPO;
254
+ const branch = assertSafeBranchPattern(options.branch || DEFAULT_BRANCH);
255
+ const expectedContexts = normalizeContexts(MERGE_QUALITY_CHECKS.requiredStatusCheckContexts);
256
+ const rules = loadBranchProtectionRule(repo, runner);
257
+ const rule = findBranchProtectionRule(rules, branch);
258
+
259
+ if (!rule) {
260
+ throw new Error(`No branch protection rule found for ${repo}#${branch}.`);
261
+ }
262
+
263
+ const actualContexts = normalizeContexts(rule.requiredStatusCheckContexts);
264
+ const diff = diffContexts(actualContexts, expectedContexts);
265
+ const inSync = diff.missing.length === 0 && diff.unexpected.length === 0 && rule.requiresStatusChecks === true;
266
+
267
+ if (options.check) {
268
+ return {
269
+ ok: inSync,
270
+ repo,
271
+ branch,
272
+ ruleId: rule.id,
273
+ actualContexts,
274
+ expectedContexts,
275
+ diff,
276
+ };
277
+ }
278
+
279
+ const updatedRule = inSync
280
+ ? rule
281
+ : updateBranchProtectionRule(rule.id, expectedContexts, runner);
282
+ const finalContexts = normalizeContexts(updatedRule.requiredStatusCheckContexts);
283
+ const finalDiff = diffContexts(finalContexts, expectedContexts);
284
+
285
+ return {
286
+ ok: true,
287
+ repo,
288
+ branch,
289
+ ruleId: rule.id,
290
+ actualContexts: finalContexts,
291
+ expectedContexts,
292
+ diff: finalDiff,
293
+ updated: !inSync,
294
+ };
295
+ }
296
+
297
+ function runCli(argv = process.argv.slice(2), runner = runGh) {
298
+ const options = parseArgs(argv);
299
+ const result = syncBranchProtection(options, runner);
300
+
301
+ if (options.json) {
302
+ console.log(JSON.stringify(result, null, 2));
303
+ } else if (options.check) {
304
+ const status = result.ok ? 'ok' : 'drift';
305
+ console.log(`Branch protection ${status}: ${result.repo} ${result.branch}`);
306
+ if (!result.ok) {
307
+ if (result.diff.missing.length > 0) {
308
+ console.log(`Missing contexts: ${result.diff.missing.join(', ')}`);
309
+ }
310
+ if (result.diff.unexpected.length > 0) {
311
+ console.log(`Unexpected contexts: ${result.diff.unexpected.join(', ')}`);
312
+ }
313
+ }
314
+ } else {
315
+ console.log(`Branch protection synced: ${result.repo} ${result.branch}`);
316
+ }
317
+
318
+ return options.check ? (result.ok ? 0 : 1) : 0;
319
+ }
320
+
321
+ if (process.argv[1] && path.resolve(process.argv[1]) === __filename) {
322
+ process.exitCode = runCli();
323
+ }
324
+
325
+ module.exports = {
326
+ assertSafeBranchPattern,
327
+ assertSafeGhArgs,
328
+ assertSafeRuleId,
329
+ assertSafeStatusContext,
330
+ diffContexts,
331
+ findBranchProtectionRule,
332
+ loadBranchProtectionRule,
333
+ normalizeContexts,
334
+ parseArgs,
335
+ resolveGhBinary,
336
+ runCli,
337
+ splitRepo,
338
+ syncBranchProtection,
339
+ updateBranchProtectionRule,
340
+ };
@@ -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 { ensureDir } = require('./fs-utils');
7
8
 
8
9
  const ROOT = path.join(__dirname, '..');
9
10
  const DEFAULT_CONFIG_PATH = path.join(ROOT, 'config', 'tessl-tiles.json');
@@ -14,9 +15,6 @@ function readJson(filePath) {
14
15
  return JSON.parse(fs.readFileSync(filePath, 'utf8'));
15
16
  }
16
17
 
17
- function ensureDir(dirPath) {
18
- fs.mkdirSync(dirPath, { recursive: true });
19
- }
20
18
 
21
19
  function cleanDir(dirPath) {
22
20
  fs.rmSync(dirPath, { recursive: true, force: true });
@@ -6,8 +6,11 @@ const {
6
6
  searchContextFs,
7
7
  searchPreventionRulesSync,
8
8
  } = require('./filesystem-search');
9
+ const {
10
+ searchImportedDocuments,
11
+ } = require('./document-intake');
9
12
 
10
- const VALID_SOURCES = ['all', 'feedback', 'context', 'rules'];
13
+ const VALID_SOURCES = ['all', 'feedback', 'context', 'rules', 'documents'];
11
14
  const SIGNAL_ALIASES = {
12
15
  up: 'up',
13
16
  positive: 'up',
@@ -118,6 +121,27 @@ function mapRuleResult(record) {
118
121
  };
119
122
  }
120
123
 
124
+ function mapDocumentResult(record) {
125
+ return {
126
+ id: record.documentId || null,
127
+ source: 'document',
128
+ score: clampScore(record._score),
129
+ signal: null,
130
+ tags: safeArray(record.tags),
131
+ timestamp: record.importedAt || null,
132
+ title: record.title || null,
133
+ context: excerpt(record.excerpt || record.content || ''),
134
+ correctiveAction: safeArray(record.proposals)[0]
135
+ ? safeArray(record.proposals)[0].title || safeArray(record.proposals)[0].evidence || null
136
+ : null,
137
+ matchedTokens: safeArray(record._matchedTokens),
138
+ documentId: record.documentId || null,
139
+ proposalCount: safeArray(record.proposals).length,
140
+ matchedTemplateIds: safeArray(record.matchedTemplateIds),
141
+ sourceFormat: record.sourceFormat || null,
142
+ };
143
+ }
144
+
121
145
  function sortResults(results) {
122
146
  return [...results].sort((left, right) => {
123
147
  if ((right.score || 0) !== (left.score || 0)) {
@@ -144,6 +168,10 @@ function getRuleResults(query, limit) {
144
168
  return searchPreventionRulesSync(query, limit).map(mapRuleResult);
145
169
  }
146
170
 
171
+ function getDocumentResults(query, limit) {
172
+ return searchImportedDocuments({ query, limit }).map(mapDocumentResult);
173
+ }
174
+
147
175
  function searchThumbgate({ query, source = 'all', limit = 10, signal = null } = {}) {
148
176
  const trimmedQuery = String(query || '').trim();
149
177
  if (!trimmedQuery) {
@@ -161,11 +189,14 @@ function searchThumbgate({ query, source = 'all', limit = 10, signal = null } =
161
189
  results = getContextResults(trimmedQuery, normalizedLimit);
162
190
  } else if (normalizedSource === 'rules') {
163
191
  results = getRuleResults(trimmedQuery, normalizedLimit);
192
+ } else if (normalizedSource === 'documents') {
193
+ results = getDocumentResults(trimmedQuery, normalizedLimit);
164
194
  } else {
165
195
  results = sortResults([
166
196
  ...getFeedbackResults(trimmedQuery, normalizedLimit, normalizedSignal),
167
197
  ...getContextResults(trimmedQuery, normalizedLimit),
168
198
  ...getRuleResults(trimmedQuery, normalizedLimit),
199
+ ...getDocumentResults(trimmedQuery, normalizedLimit),
169
200
  ]).slice(0, normalizedLimit);
170
201
  }
171
202
 
@@ -3,8 +3,8 @@
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { resolveFeedbackDir } = require('./feedback-paths');
6
+ const { readJsonl } = require('./fs-utils');
6
7
  function getKpiLogPath() { return path.join(resolveFeedbackDir(), 'tool-kpi.jsonl'); }
7
- function readJsonl(fp) { if (!fs.existsSync(fp)) return []; const raw = fs.readFileSync(fp, 'utf-8').trim(); if (!raw) return []; return raw.split('\n').map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean); }
8
8
  function recordToolCall({ toolName, serverName, latencyMs, success, agentId, metadata } = {}) { const lp = getKpiLogPath(); const dir = path.dirname(lp); if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); const e = { id: `kpi_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`, timestamp: new Date().toISOString(), toolName: toolName || 'unknown', serverName: serverName || 'default', latencyMs: typeof latencyMs === 'number' ? latencyMs : 0, success: success !== false, agentId: agentId || 'unknown', metadata: metadata || {} }; fs.appendFileSync(lp, JSON.stringify(e) + '\n'); return e; }
9
9
  function percentile(sorted, p) { if (sorted.length === 0) return 0; const idx = Math.ceil((p / 100) * sorted.length) - 1; return sorted[Math.max(0, idx)]; }
10
10
  function computeToolKpis({ periodHours = 24 } = {}) { const entries = readJsonl(getKpiLogPath()); const cutoff = Date.now() - periodHours * 60 * 60 * 1000; const recent = entries.filter((e) => new Date(e.timestamp).getTime() > cutoff); const byTool = {}; for (const e of recent) { const k = e.toolName; if (!byTool[k]) byTool[k] = { toolName: k, calls: [], successes: 0, failures: 0 }; byTool[k].calls.push(e.latencyMs); if (e.success) byTool[k].successes++; else byTool[k].failures++; } const tools = Object.values(byTool).map((t) => { const sorted = t.calls.slice().sort((a, b) => a - b); const total = t.successes + t.failures; return { toolName: t.toolName, requestCount: total, successRate: total > 0 ? Math.round((t.successes / total) * 1000) / 10 : 100, p50: Math.round(percentile(sorted, 50)), p90: Math.round(percentile(sorted, 90)), p95: Math.round(percentile(sorted, 95)), successes: t.successes, failures: t.failures }; }).sort((a, b) => b.requestCount - a.requestCount); const byServer = {}; for (const e of recent) { const k = e.serverName; if (!byServer[k]) byServer[k] = { serverName: k, total: 0, successes: 0 }; byServer[k].total++; if (e.success) byServer[k].successes++; } const servers = Object.values(byServer).map((s) => ({ serverName: s.serverName, totalCalls: s.total, successRate: s.total > 0 ? Math.round((s.successes / s.total) * 1000) / 10 : 100 })); return { periodHours, totalCalls: recent.length, tools, servers }; }
@@ -122,18 +122,57 @@ const TOOLS = [
122
122
  }),
123
123
  readOnlyTool({
124
124
  name: 'search_thumbgate',
125
- description: 'Search raw ThumbGate state across feedback logs, ContextFS memory, and prevention rules.',
125
+ description: 'Search raw ThumbGate state across feedback logs, ContextFS memory, prevention rules, and imported policy documents.',
126
126
  inputSchema: {
127
127
  type: 'object',
128
128
  required: ['query'],
129
129
  properties: {
130
130
  query: { type: 'string', description: 'Search query for ThumbGate state.' },
131
131
  limit: { type: 'number', description: 'Maximum results to return (default 10)' },
132
- source: { type: 'string', enum: ['all', 'feedback', 'context', 'rules'], description: 'Restrict search to a single ThumbGate source.' },
132
+ source: { type: 'string', enum: ['all', 'feedback', 'context', 'rules', 'documents'], description: 'Restrict search to a single ThumbGate source.' },
133
133
  signal: { type: 'string', enum: ['up', 'down', 'positive', 'negative'], description: 'Optional feedback-signal filter when searching feedback data.' },
134
134
  },
135
135
  },
136
136
  }),
137
+ destructiveTool({
138
+ name: 'import_document',
139
+ description: 'Import a local policy or runbook document into ThumbGate, normalize it for search, and propose provenance-backed gate candidates.',
140
+ inputSchema: {
141
+ type: 'object',
142
+ properties: {
143
+ filePath: { type: 'string', description: 'Local file path inside the active workspace or ThumbGate runtime.' },
144
+ content: { type: 'string', description: 'Inline document content for hosted or generated imports.' },
145
+ title: { type: 'string', description: 'Optional display title override.' },
146
+ sourceFormat: { type: 'string', enum: ['markdown', 'text', 'yaml', 'json', 'html'], description: 'Optional source format override when importing inline content.' },
147
+ sourceUrl: { type: 'string', description: 'Optional external URL or provenance label for the imported document.' },
148
+ tags: { type: 'array', items: { type: 'string' }, description: 'Optional tags such as policy, runbook, or team.' },
149
+ proposeGates: { type: 'boolean', description: 'When true (default), derive reviewable gate proposals from the document.' },
150
+ },
151
+ },
152
+ }),
153
+ readOnlyTool({
154
+ name: 'list_imported_documents',
155
+ description: 'List imported policy and runbook documents stored in local ThumbGate state.',
156
+ inputSchema: {
157
+ type: 'object',
158
+ properties: {
159
+ query: { type: 'string', description: 'Optional title or excerpt filter.' },
160
+ tag: { type: 'string', description: 'Optional tag or matched template id filter.' },
161
+ limit: { type: 'number', description: 'Maximum documents to return (default 20).' },
162
+ },
163
+ },
164
+ }),
165
+ readOnlyTool({
166
+ name: 'get_imported_document',
167
+ description: 'Read a previously imported document with its proposed gate candidates and provenance.',
168
+ inputSchema: {
169
+ type: 'object',
170
+ required: ['documentId'],
171
+ properties: {
172
+ documentId: { type: 'string', description: 'Imported document id.' },
173
+ },
174
+ },
175
+ }),
137
176
  readOnlyTool({
138
177
  name: 'feedback_stats',
139
178
  description: 'Get feedback stats and recommendations',
@@ -296,6 +335,19 @@ const TOOLS = [
296
335
  properties: {},
297
336
  },
298
337
  }),
338
+ readOnlyTool({
339
+ name: 'security_scan',
340
+ description: 'Scan code for OWASP vulnerabilities (injection, XSS, path traversal, SSRF, prototype pollution) and supply chain risks (typosquatting, install script abuse, wildcard versions). Returns findings with severity, category, and line numbers.',
341
+ inputSchema: {
342
+ type: 'object',
343
+ required: ['content'],
344
+ properties: {
345
+ content: { type: 'string', description: 'Code content to scan' },
346
+ filePath: { type: 'string', description: 'File path for language-aware scanning' },
347
+ diffMode: { type: 'boolean', description: 'When true, treats content as git diff output' },
348
+ },
349
+ },
350
+ }),
299
351
  readOnlyTool({
300
352
  name: 'capture_memory_feedback',
301
353
  description: 'Capture success/failure feedback to harden future workflows. Aliased to capture_feedback.',
@@ -949,6 +1001,58 @@ const TOOLS = [
949
1001
  },
950
1002
  },
951
1003
  }),
1004
+ destructiveTool({
1005
+ name: 'run_managed_lesson_agent',
1006
+ description: 'Run the LLM-powered lesson inference and rule generation agent over accumulated feedback. Requires ANTHROPIC_API_KEY for LLM mode; falls back to heuristics if unavailable.',
1007
+ inputSchema: {
1008
+ type: 'object',
1009
+ properties: {
1010
+ dryRun: { type: 'boolean', description: 'Preview what would be written without persisting' },
1011
+ limit: { type: 'number', description: 'Max feedback entries to process (default: 20)' },
1012
+ model: { type: 'string', description: 'Override the Claude model (default: claude-haiku-4-5)' },
1013
+ },
1014
+ },
1015
+ }),
1016
+ readOnlyTool({
1017
+ name: 'managed_agent_status',
1018
+ description: 'Show status of the last managed lesson agent run: entries processed, lessons created, gates promoted, and total runs.',
1019
+ inputSchema: {
1020
+ type: 'object',
1021
+ properties: {},
1022
+ },
1023
+ }),
1024
+ destructiveTool({
1025
+ name: 'run_self_distill',
1026
+ description: 'Run the self-distillation agent to auto-evaluate recent agent sessions and generate improvement lessons without human feedback. Reads conversation logs, detects success/failure signals, and persists lessons.',
1027
+ inputSchema: {
1028
+ type: 'object',
1029
+ properties: {
1030
+ dryRun: { type: 'boolean', description: 'If true, analyzes but does not persist lessons' },
1031
+ limit: { type: 'number', description: 'Max conversation logs to process (default 20)' },
1032
+ model: { type: 'string', description: 'LLM model to use for analysis (requires ANTHROPIC_API_KEY)' },
1033
+ },
1034
+ },
1035
+ }),
1036
+ readOnlyTool({
1037
+ name: 'self_distill_status',
1038
+ description: 'Show status of the last self-distillation run: sessions analyzed, lessons generated, signals detected.',
1039
+ inputSchema: {
1040
+ type: 'object',
1041
+ properties: {},
1042
+ },
1043
+ }),
1044
+ readOnlyTool({
1045
+ name: 'context_stuff_lessons',
1046
+ description: 'Dump ALL prevention lessons into a single text block for context-window injection. Bypasses RAG/search — returns every lesson sorted by confidence. For most projects (20-200 lessons), fits in 1K-10K tokens.',
1047
+ inputSchema: {
1048
+ type: 'object',
1049
+ properties: {
1050
+ maxTokenBudget: { type: 'number', description: 'Approximate token budget (default: 10000)' },
1051
+ signal: { type: 'string', enum: ['positive', 'negative'], description: 'Filter by signal type' },
1052
+ format: { type: 'string', enum: ['compact', 'full'], description: 'Output format (default: compact)' },
1053
+ },
1054
+ },
1055
+ }),
952
1056
  ];
953
1057
 
954
1058
  module.exports = {
@@ -2,6 +2,7 @@
2
2
 
3
3
  const fs = require('fs');
4
4
  const path = require('path');
5
+ const { ensureDir } = require('./fs-utils');
5
6
  const {
6
7
  resolveEmbeddingProfile,
7
8
  writeModelFitReport,
@@ -35,11 +36,6 @@ function getLanceDir() {
35
36
  return path.join(getFeedbackDir(), 'lancedb');
36
37
  }
37
38
 
38
- function ensureDir(dirPath) {
39
- if (!fs.existsSync(dirPath)) {
40
- fs.mkdirSync(dirPath, { recursive: true });
41
- }
42
- }
43
39
 
44
40
  function truncateForEmbedding(text, maxChars) {
45
41
  const raw = String(text || '');
@@ -14,10 +14,10 @@ const path = require('path');
14
14
  const os = require('os');
15
15
  const { generateWeeklyStatsPost } = require('./daily-digest');
16
16
  const { createSchedule } = require('./schedule-manager');
17
+ const { ensureDir } = require('./fs-utils');
17
18
 
18
19
  const POSTS_DIR = path.join(os.homedir(), '.thumbgate', 'weekly-posts');
19
20
 
20
- function ensureDir(p) { if (!fs.existsSync(p)) fs.mkdirSync(p, { recursive: true }); }
21
21
 
22
22
  /**
23
23
  * Generate a weekly stats post file in post-everywhere format.