thumbgate 1.1.0 → 1.3.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 (63) hide show
  1. package/.claude-plugin/README.md +4 -4
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +48 -16
  6. package/adapters/README.md +1 -1
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/mcp/server-stdio.js +11 -8
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +20 -11
  12. package/config/github-about.json +1 -1
  13. package/config/model-tiers.json +11 -0
  14. package/package.json +22 -11
  15. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  17. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  18. package/plugins/codex-profile/.mcp.json +1 -1
  19. package/plugins/codex-profile/INSTALL.md +1 -1
  20. package/plugins/codex-profile/README.md +1 -1
  21. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  22. package/plugins/cursor-marketplace/README.md +2 -2
  23. package/plugins/cursor-marketplace/commands/capture-feedback.md +2 -2
  24. package/plugins/cursor-marketplace/rules/feedback-capture.mdc +3 -3
  25. package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +3 -2
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/compare.html +302 -0
  28. package/public/guide.html +4 -4
  29. package/public/index.html +77 -38
  30. package/public/learn/ai-agent-persistent-memory.html +1 -0
  31. package/public/lessons.html +325 -17
  32. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  33. package/scripts/ai-search-visibility.js +142 -0
  34. package/scripts/audit-trail.js +6 -0
  35. package/scripts/capture-railway-diagnostics.sh +97 -0
  36. package/scripts/changeset-check.js +372 -0
  37. package/scripts/check-congruence.js +8 -5
  38. package/scripts/claude-feedback-sync.js +320 -0
  39. package/scripts/cli-telemetry.js +4 -1
  40. package/scripts/computer-use-firewall.js +45 -15
  41. package/scripts/contextfs.js +32 -23
  42. package/scripts/dashboard.js +84 -0
  43. package/scripts/docker-sandbox-planner.js +208 -0
  44. package/scripts/feedback-loop.js +16 -0
  45. package/scripts/github-about.js +56 -0
  46. package/scripts/intervention-policy.js +696 -0
  47. package/scripts/local-model-profile.js +18 -2
  48. package/scripts/model-tier-router.js +10 -1
  49. package/scripts/operational-integrity.js +361 -32
  50. package/scripts/prove-adapters.js +1 -0
  51. package/scripts/prove-automation.js +2 -2
  52. package/scripts/prove-packaged-runtime.js +260 -0
  53. package/scripts/prove-runtime.js +13 -0
  54. package/scripts/published-cli.js +10 -1
  55. package/scripts/rate-limiter.js +3 -3
  56. package/scripts/statusline-links.js +238 -0
  57. package/scripts/statusline-local-stats.js +2 -0
  58. package/scripts/statusline.sh +200 -10
  59. package/scripts/sync-github-about.js +7 -4
  60. package/scripts/tool-registry.js +2 -2
  61. package/scripts/workflow-sentinel.js +197 -39
  62. package/skills/thumbgate/SKILL.md +1 -1
  63. package/src/api/server.js +12 -1
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ captureFeedback,
8
+ getFeedbackPaths,
9
+ readJSONL,
10
+ analyzeFeedback,
11
+ } = require('./feedback-loop');
12
+ const { normalizeFeedbackText } = require('./feedback-quality');
13
+ const {
14
+ resolveFeedbackDir,
15
+ resolveProjectDir,
16
+ } = require('./feedback-paths');
17
+ const { refreshStatuslineCache } = require('./hook-thumbgate-cache-updater');
18
+
19
+ const SYNC_STATE_FILE = 'claude-feedback-sync-state.json';
20
+ const DEFAULT_RECENT_FEEDBACK_LIMIT = 250;
21
+ const DEFAULT_PROCESSED_ID_LIMIT = 512;
22
+ const DUPLICATE_WINDOW_MS = 30 * 1000;
23
+
24
+ function getClaudeHistoryPath(options = {}) {
25
+ if (options.historyPath) return options.historyPath;
26
+ if (process.env.THUMBGATE_CLAUDE_HISTORY_PATH) return process.env.THUMBGATE_CLAUDE_HISTORY_PATH;
27
+ const homeDir = options.homeDir || process.env.HOME || process.env.USERPROFILE || '';
28
+ return path.join(homeDir, '.claude', 'history.jsonl');
29
+ }
30
+
31
+ function getSyncStatePath(options = {}) {
32
+ const feedbackDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir });
33
+ return path.join(feedbackDir, SYNC_STATE_FILE);
34
+ }
35
+
36
+ function readSyncState(options = {}) {
37
+ const statePath = getSyncStatePath(options);
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
40
+ return {
41
+ historyOffset: Number(parsed.historyOffset || 0),
42
+ historySize: Number(parsed.historySize || 0),
43
+ processedIds: Array.isArray(parsed.processedIds) ? parsed.processedIds : [],
44
+ statePath,
45
+ };
46
+ } catch {
47
+ return {
48
+ historyOffset: 0,
49
+ historySize: 0,
50
+ processedIds: [],
51
+ statePath,
52
+ };
53
+ }
54
+ }
55
+
56
+ function writeSyncState(state, options = {}) {
57
+ const statePath = getSyncStatePath(options);
58
+ const payload = {
59
+ historyOffset: Number(state.historyOffset || 0),
60
+ historySize: Number(state.historySize || 0),
61
+ processedIds: Array.isArray(state.processedIds) ? state.processedIds.slice(-DEFAULT_PROCESSED_ID_LIMIT) : [],
62
+ updatedAt: new Date().toISOString(),
63
+ };
64
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
65
+ fs.writeFileSync(statePath, JSON.stringify(payload, null, 2));
66
+ return payload;
67
+ }
68
+
69
+ function readHistoryEntriesSince(filePath, state) {
70
+ if (!filePath || !fs.existsSync(filePath)) {
71
+ return {
72
+ entries: [],
73
+ nextOffset: 0,
74
+ size: 0,
75
+ };
76
+ }
77
+
78
+ const stat = fs.statSync(filePath);
79
+ const safeOffset = state && state.historyOffset > 0 && state.historyOffset <= stat.size
80
+ ? state.historyOffset
81
+ : 0;
82
+
83
+ const fileBuffer = fs.readFileSync(filePath);
84
+ const contents = fileBuffer.slice(safeOffset).toString('utf8');
85
+
86
+ const entries = contents
87
+ .split('\n')
88
+ .map((line) => line.trim())
89
+ .filter(Boolean)
90
+ .map((line) => {
91
+ try {
92
+ return JSON.parse(line);
93
+ } catch {
94
+ return null;
95
+ }
96
+ })
97
+ .filter(Boolean);
98
+
99
+ return {
100
+ entries,
101
+ nextOffset: stat.size,
102
+ size: stat.size,
103
+ };
104
+ }
105
+
106
+ function normalizeProjectPath(value) {
107
+ try {
108
+ return value ? path.resolve(String(value)) : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function parseHistoryTimestamp(value) {
115
+ if (typeof value === 'number' && Number.isFinite(value)) {
116
+ return value > 1e12 ? value : value * 1000;
117
+ }
118
+ const text = String(value || '').trim();
119
+ if (!text) return null;
120
+ if (/^\d+$/.test(text)) {
121
+ const numeric = Number(text);
122
+ return numeric > 1e12 ? numeric : numeric * 1000;
123
+ }
124
+ const parsed = Date.parse(text);
125
+ return Number.isFinite(parsed) ? parsed : null;
126
+ }
127
+
128
+ function detectSignal(text) {
129
+ const normalized = String(text || '').toLowerCase();
130
+ if (/(thumbs?\s*down|that failed|that was wrong|fix this)/i.test(normalized)) return 'down';
131
+ if (/(thumbs?\s*up|that worked|looks good|nice work|perfect|good job)/i.test(normalized)) return 'up';
132
+ return null;
133
+ }
134
+
135
+ function extractPromptText(entry) {
136
+ const candidates = [
137
+ entry && entry.display,
138
+ entry && entry.message && entry.message.content,
139
+ entry && entry.attachment && entry.attachment.prompt,
140
+ entry && entry.content,
141
+ ];
142
+
143
+ for (const candidate of candidates) {
144
+ if (typeof candidate === 'string' && candidate.trim()) {
145
+ return candidate.trim();
146
+ }
147
+ }
148
+ return '';
149
+ }
150
+
151
+ function buildExternalId(entry, promptText) {
152
+ const hash = crypto.createHash('sha256');
153
+ hash.update(String(entry.sessionId || ''));
154
+ hash.update('|');
155
+ hash.update(String(entry.timestamp || ''));
156
+ hash.update('|');
157
+ hash.update(String(entry.project || entry.cwd || ''));
158
+ hash.update('|');
159
+ hash.update(String(promptText || ''));
160
+ return `claude-history:${hash.digest('hex')}`;
161
+ }
162
+
163
+ function toHistoryCandidate(entry, options = {}) {
164
+ const promptText = extractPromptText(entry);
165
+ const signal = detectSignal(promptText);
166
+ if (!signal) return null;
167
+
168
+ const projectDir = normalizeProjectPath(options.projectDir);
169
+ const entryProject = normalizeProjectPath(entry.project || entry.cwd || '');
170
+ if (projectDir && entryProject && entryProject !== projectDir && !entryProject.startsWith(`${projectDir}${path.sep}`)) {
171
+ return null;
172
+ }
173
+ if (projectDir && !entryProject) {
174
+ return null;
175
+ }
176
+
177
+ return {
178
+ externalId: buildExternalId(entry, promptText),
179
+ promptText,
180
+ signal,
181
+ timestampMs: parseHistoryTimestamp(entry.timestamp),
182
+ };
183
+ }
184
+
185
+ function normalizeCandidateText(value) {
186
+ return normalizeFeedbackText(String(value || '').replace(/\s+/g, ' '));
187
+ }
188
+
189
+ function hasMatchingFeedbackEntry(candidate, feedbackEntries) {
190
+ const candidateText = normalizeCandidateText(candidate.promptText);
191
+ if (!candidateText) return false;
192
+
193
+ return feedbackEntries.some((entry) => {
194
+ const signal = entry && entry.signal === 'negative' ? 'down' : 'up';
195
+ if (signal !== candidate.signal) return false;
196
+
197
+ const feedbackText = normalizeCandidateText(
198
+ entry.submittedContext
199
+ || entry.context
200
+ || entry.whatWentWrong
201
+ || entry.whatWorked
202
+ || ''
203
+ );
204
+ if (feedbackText !== candidateText) return false;
205
+
206
+ const feedbackTimestamp = Date.parse(entry.timestamp || '');
207
+ if (!Number.isFinite(feedbackTimestamp) || !Number.isFinite(candidate.timestampMs)) {
208
+ return true;
209
+ }
210
+ return Math.abs(feedbackTimestamp - candidate.timestampMs) <= DUPLICATE_WINDOW_MS;
211
+ });
212
+ }
213
+
214
+ function syncClaudeHistoryFeedback(options = {}) {
215
+ if (options.disabled || process.env.THUMBGATE_DISABLE_CLAUDE_HISTORY_SYNC === '1') {
216
+ return {
217
+ importedCount: 0,
218
+ skippedCount: 0,
219
+ reason: 'disabled',
220
+ };
221
+ }
222
+
223
+ const originalEnv = {
224
+ THUMBGATE_FEEDBACK_DIR: process.env.THUMBGATE_FEEDBACK_DIR,
225
+ THUMBGATE_PROJECT_DIR: process.env.THUMBGATE_PROJECT_DIR,
226
+ THUMBGATE_CLAUDE_HISTORY_PATH: process.env.THUMBGATE_CLAUDE_HISTORY_PATH,
227
+ };
228
+
229
+ if (options.feedbackDir) process.env.THUMBGATE_FEEDBACK_DIR = options.feedbackDir;
230
+ if (options.projectDir && !options.feedbackDir) process.env.THUMBGATE_PROJECT_DIR = options.projectDir;
231
+ if (options.historyPath) process.env.THUMBGATE_CLAUDE_HISTORY_PATH = options.historyPath;
232
+
233
+ try {
234
+ const feedbackDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir });
235
+ const projectDir = normalizeProjectPath(options.projectDir) || resolveProjectDir({
236
+ cwd: process.cwd(),
237
+ env: process.env,
238
+ });
239
+ const historyPath = getClaudeHistoryPath(options);
240
+ const state = readSyncState({ feedbackDir });
241
+ const history = readHistoryEntriesSince(historyPath, state);
242
+ const existingEntries = readJSONL(path.join(feedbackDir, 'feedback-log.jsonl'), {
243
+ maxLines: DEFAULT_RECENT_FEEDBACK_LIMIT,
244
+ });
245
+
246
+ let importedCount = 0;
247
+ let skippedCount = 0;
248
+ const processedIds = new Set(state.processedIds || []);
249
+
250
+ for (const entry of history.entries) {
251
+ const candidate = toHistoryCandidate(entry, { projectDir });
252
+ if (!candidate) continue;
253
+ if (processedIds.has(candidate.externalId)) {
254
+ skippedCount += 1;
255
+ continue;
256
+ }
257
+
258
+ if (hasMatchingFeedbackEntry(candidate, existingEntries)) {
259
+ processedIds.add(candidate.externalId);
260
+ skippedCount += 1;
261
+ continue;
262
+ }
263
+
264
+ const captureResult = captureFeedback({
265
+ signal: candidate.signal,
266
+ context: candidate.promptText,
267
+ whatWentWrong: candidate.signal === 'down' ? candidate.promptText : undefined,
268
+ whatWorked: candidate.signal === 'up' ? candidate.promptText : undefined,
269
+ tags: ['claude-history-sync', 'auto-capture-fallback'],
270
+ });
271
+
272
+ if (captureResult && captureResult.feedbackEvent) {
273
+ existingEntries.push(captureResult.feedbackEvent);
274
+ }
275
+ processedIds.add(candidate.externalId);
276
+ importedCount += 1;
277
+ }
278
+
279
+ writeSyncState({
280
+ historyOffset: history.nextOffset,
281
+ historySize: history.size,
282
+ processedIds: Array.from(processedIds),
283
+ }, { feedbackDir });
284
+
285
+ if (importedCount > 0) {
286
+ refreshStatuslineCache(analyzeFeedback(path.join(feedbackDir, 'feedback-log.jsonl')), path.join(feedbackDir, 'statusline_cache.json'));
287
+ }
288
+
289
+ return {
290
+ importedCount,
291
+ skippedCount,
292
+ historyPath,
293
+ feedbackDir,
294
+ projectDir,
295
+ };
296
+ } finally {
297
+ if (originalEnv.THUMBGATE_FEEDBACK_DIR == null) delete process.env.THUMBGATE_FEEDBACK_DIR;
298
+ else process.env.THUMBGATE_FEEDBACK_DIR = originalEnv.THUMBGATE_FEEDBACK_DIR;
299
+
300
+ if (originalEnv.THUMBGATE_PROJECT_DIR == null) delete process.env.THUMBGATE_PROJECT_DIR;
301
+ else process.env.THUMBGATE_PROJECT_DIR = originalEnv.THUMBGATE_PROJECT_DIR;
302
+
303
+ if (originalEnv.THUMBGATE_CLAUDE_HISTORY_PATH == null) delete process.env.THUMBGATE_CLAUDE_HISTORY_PATH;
304
+ else process.env.THUMBGATE_CLAUDE_HISTORY_PATH = originalEnv.THUMBGATE_CLAUDE_HISTORY_PATH;
305
+ }
306
+ }
307
+
308
+ module.exports = {
309
+ SYNC_STATE_FILE,
310
+ detectSignal,
311
+ extractPromptText,
312
+ getClaudeHistoryPath,
313
+ hasMatchingFeedbackEntry,
314
+ parseHistoryTimestamp,
315
+ readHistoryEntriesSince,
316
+ readSyncState,
317
+ syncClaudeHistoryFeedback,
318
+ toHistoryCandidate,
319
+ writeSyncState,
320
+ };
@@ -5,7 +5,9 @@ const crypto = require('crypto');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
 
8
- const TELEMETRY_ENDPOINT = 'https://thumbgate-production.up.railway.app/v1/telemetry/ping';
8
+ const _DEFAULT_TELEMETRY_HOST = 'https://thumbgate-production.up.railway.app';
9
+ // Respect THUMBGATE_API_URL so test environments can point to a local stub
10
+ const TELEMETRY_ENDPOINT = `${process.env.THUMBGATE_API_URL || _DEFAULT_TELEMETRY_HOST}/v1/telemetry/ping`;
9
11
  const INSTALL_ID_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.thumbgate', 'install-id');
10
12
 
11
13
  /**
@@ -80,6 +82,7 @@ function trackEvent(eventType, metadata = {}) {
80
82
  });
81
83
  req.on('error', () => {}); // silently ignore
82
84
  req.on('timeout', () => req.destroy());
85
+ req.on('socket', (s) => s.unref()); // fire-and-forget: never block process exit
83
86
  req.end(payload);
84
87
  } catch (_) {} // never crash the CLI
85
88
  }
@@ -3,6 +3,7 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const { buildDockerSandboxPlan } = require('./docker-sandbox-planner');
6
7
 
7
8
  /**
8
9
  * Computer-Use Action Firewall — normalizes OpenAI Responses API
@@ -129,13 +130,13 @@ function evaluateAction(action, preset = 'dev-sandbox', customRules = []) {
129
130
  const normalized = action.type ? action : normalizeAction(action);
130
131
  const presetConfig = PRESETS[preset];
131
132
  if (!presetConfig) {
132
- return {
133
+ return attachExecutionSurface({
133
134
  decision: 'deny',
134
135
  reason: `Unknown preset: ${preset}`,
135
136
  preset,
136
137
  riskLevel: normalized.riskLevel,
137
138
  auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Unknown preset: ${preset}`, preset }),
138
- };
139
+ }, normalized);
139
140
  }
140
141
 
141
142
  // Custom rules override preset defaults
@@ -143,81 +144,108 @@ function evaluateAction(action, preset = 'dev-sandbox', customRules = []) {
143
144
  if (rule.action === normalized.type) {
144
145
  const decision = rule.decision || 'deny';
145
146
  const reason = rule.reason || `Custom rule override for ${normalized.type}`;
146
- return {
147
+ return attachExecutionSurface({
147
148
  decision,
148
149
  reason,
149
150
  preset,
150
151
  riskLevel: normalized.riskLevel,
151
152
  auditEntry: createAuditEntry(normalized, { decision, reason, preset }),
152
- };
153
+ }, normalized);
153
154
  }
154
155
  }
155
156
 
156
157
  // Check dangerous shell patterns (always deny)
157
158
  const dangerousMatch = matchesDangerousPattern(normalized);
158
159
  if (dangerousMatch) {
159
- return {
160
+ return attachExecutionSurface({
160
161
  decision: 'deny',
161
162
  reason: `Dangerous shell pattern detected: ${dangerousMatch}`,
162
163
  preset,
163
164
  riskLevel: 'critical',
164
165
  auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Dangerous shell pattern: ${dangerousMatch}`, preset }),
165
- };
166
+ }, normalized);
166
167
  }
167
168
 
168
169
  // Check secret patterns (always deny)
169
170
  const secretMatch = matchesSecretPattern(normalized);
170
171
  if (secretMatch) {
171
- return {
172
+ return attachExecutionSurface({
172
173
  decision: 'deny',
173
174
  reason: `Secret pattern detected in content: ${secretMatch}`,
174
175
  preset,
175
176
  riskLevel: 'critical',
176
177
  auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Secret pattern: ${secretMatch}`, preset }),
177
- };
178
+ }, normalized);
178
179
  }
179
180
 
180
181
  // Evaluate against preset
181
182
  if (presetConfig.deny.includes(normalized.type)) {
182
- return {
183
+ return attachExecutionSurface({
183
184
  decision: 'deny',
184
185
  reason: `Action ${normalized.type} denied by ${preset} preset`,
185
186
  preset,
186
187
  riskLevel: normalized.riskLevel,
187
188
  auditEntry: createAuditEntry(normalized, { decision: 'deny', reason: `Denied by preset`, preset }),
188
- };
189
+ }, normalized);
189
190
  }
190
191
 
191
192
  if (presetConfig.requireApproval.includes(normalized.type)) {
192
- return {
193
+ return attachExecutionSurface({
193
194
  decision: 'require-approval',
194
195
  reason: `Action ${normalized.type} requires approval in ${preset} preset`,
195
196
  preset,
196
197
  riskLevel: normalized.riskLevel,
197
198
  auditEntry: createAuditEntry(normalized, { decision: 'require-approval', reason: `Requires approval`, preset }),
198
- };
199
+ }, normalized);
199
200
  }
200
201
 
201
202
  if (presetConfig.allow.includes(normalized.type)) {
202
- return {
203
+ return attachExecutionSurface({
203
204
  decision: 'allow',
204
205
  reason: `Action ${normalized.type} allowed by ${preset} preset`,
205
206
  preset,
206
207
  riskLevel: normalized.riskLevel,
207
208
  auditEntry: createAuditEntry(normalized, { decision: 'allow', reason: `Allowed by preset`, preset }),
208
- };
209
+ }, normalized);
209
210
  }
210
211
 
211
212
  // Default: unknown actions require approval
212
- return {
213
+ return attachExecutionSurface({
213
214
  decision: 'require-approval',
214
215
  reason: `Action ${normalized.type} not in preset; defaulting to require-approval`,
215
216
  preset,
216
217
  riskLevel: normalized.riskLevel,
217
218
  auditEntry: createAuditEntry(normalized, { decision: 'require-approval', reason: `Not in preset`, preset }),
219
+ }, normalized);
220
+ }
221
+
222
+ function attachExecutionSurface(result, action) {
223
+ const executionSurface = buildDockerSandboxPlan({
224
+ toolName: action.type === 'shell.exec' ? 'Bash' : 'Write',
225
+ actionType: action.type,
226
+ command: action.type === 'shell.exec' ? action.target : '',
227
+ repoPath: action.args.repoPath || action.args.cwd || '',
228
+ affectedFiles: action.type.startsWith('file.') && action.target ? [action.target] : [],
229
+ riskBand: toSandboxRiskBand(action.riskLevel),
230
+ requiresNetwork: ['upload', 'download', 'message.send'].includes(action.type),
231
+ });
232
+
233
+ if (!executionSurface.shouldSandbox) {
234
+ return result;
235
+ }
236
+
237
+ return {
238
+ ...result,
239
+ executionSurface,
218
240
  };
219
241
  }
220
242
 
243
+ function toSandboxRiskBand(riskLevel) {
244
+ if (riskLevel === 'high') return 'high';
245
+ if (riskLevel === 'medium') return 'medium';
246
+ return 'low';
247
+ }
248
+
221
249
  function createAuditEntry(action, decision) {
222
250
  return {
223
251
  timestamp: action.timestamp || new Date().toISOString(),
@@ -247,4 +275,6 @@ module.exports = {
247
275
  loadConfig,
248
276
  matchesDangerousPattern,
249
277
  matchesSecretPattern,
278
+ attachExecutionSurface,
279
+ toSandboxRiskBand,
250
280
  };
@@ -23,9 +23,15 @@ function getFeedbackBaseDir() {
23
23
  return resolveFeedbackDir();
24
24
  }
25
25
 
26
- const FEEDBACK_DIR = getFeedbackBaseDir();
27
- const CONTEXTFS_ROOT = process.env.THUMBGATE_CONTEXTFS_DIR
28
- || (FEEDBACK_DIR.endsWith('contextfs') ? FEEDBACK_DIR : path.join(FEEDBACK_DIR, 'contextfs'));
26
+ function getContextFsRoot() {
27
+ const feedbackDir = getFeedbackBaseDir();
28
+ if (process.env.THUMBGATE_CONTEXTFS_DIR) return process.env.THUMBGATE_CONTEXTFS_DIR;
29
+ return feedbackDir.endsWith('contextfs') ? feedbackDir : path.join(feedbackDir, 'contextfs');
30
+ }
31
+
32
+ function contextFsPath(...segments) {
33
+ return path.join(getContextFsRoot(), ...segments);
34
+ }
29
35
 
30
36
  const NAMESPACES = {
31
37
  rawHistory: 'raw_history',
@@ -101,7 +107,7 @@ function ensureDir(dirPath) {
101
107
 
102
108
  function ensureContextFs() {
103
109
  Object.values(NAMESPACES).forEach((subPath) => {
104
- ensureDir(path.join(CONTEXTFS_ROOT, subPath));
110
+ ensureDir(contextFsPath(subPath));
105
111
  });
106
112
  }
107
113
 
@@ -111,7 +117,7 @@ function nowIso() {
111
117
 
112
118
  function inferNamespaceFromPath(filePath) {
113
119
  if (!filePath) return '';
114
- const relativeDir = path.relative(CONTEXTFS_ROOT, path.dirname(filePath));
120
+ const relativeDir = path.relative(getContextFsRoot(), path.dirname(filePath));
115
121
  if (!relativeDir || relativeDir.startsWith('..')) return '';
116
122
  return relativeDir;
117
123
  }
@@ -211,7 +217,7 @@ function getSemanticCacheConfig() {
211
217
  }
212
218
 
213
219
  function getSemanticCachePath() {
214
- return path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'semantic-cache.jsonl');
220
+ return contextFsPath(NAMESPACES.provenance, 'semantic-cache.jsonl');
215
221
  }
216
222
 
217
223
  function loadSemanticCacheEntries() {
@@ -227,7 +233,7 @@ function getSourceHash(namespaces) {
227
233
  const normalizedNamespaces = normalizeNamespaces(namespaces);
228
234
 
229
235
  for (const ns of normalizedNamespaces) {
230
- const dirPath = path.join(CONTEXTFS_ROOT, ns);
236
+ const dirPath = contextFsPath(ns);
231
237
  if (!fs.existsSync(dirPath)) continue;
232
238
 
233
239
  const files = fs.readdirSync(dirPath).sort();
@@ -291,7 +297,7 @@ function recordProvenance(event) {
291
297
  timestamp: nowIso(),
292
298
  ...event,
293
299
  };
294
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'events.jsonl'), payload);
300
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'events.jsonl'), payload);
295
301
  return payload;
296
302
  }
297
303
 
@@ -299,7 +305,7 @@ function writeContextObject({ namespace, title, content, tags = [], source, ttl
299
305
  ensureContextFs();
300
306
 
301
307
  const id = `${Date.now()}_${toSlug(title)}`;
302
- const filePath = path.join(CONTEXTFS_ROOT, namespace, `${id}.json`);
308
+ const filePath = contextFsPath(namespace, `${id}.json`);
303
309
 
304
310
  const doc = {
305
311
  id,
@@ -361,7 +367,7 @@ function findExistingContextObject({ namespace, title, content, tags = [], sourc
361
367
  ensureContextFs();
362
368
 
363
369
  const expectedTags = normalizeTagList(tags);
364
- const dirPath = path.join(CONTEXTFS_ROOT, namespace);
370
+ const dirPath = contextFsPath(namespace);
365
371
  const files = listJsonFiles(dirPath).sort();
366
372
 
367
373
  for (const filePath of files) {
@@ -507,7 +513,7 @@ function loadCandidates(namespaces) {
507
513
  const docs = [];
508
514
 
509
515
  selected.forEach((namespace) => {
510
- const dir = path.join(CONTEXTFS_ROOT, namespace);
516
+ const dir = contextFsPath(namespace);
511
517
  const files = listJsonFiles(dir);
512
518
  files.forEach((filePath) => {
513
519
  try {
@@ -624,7 +630,7 @@ function selectFlatContextItems(candidates, maxItems, maxChars) {
624
630
  const MEMEX_INDEX_FILE = 'memex-index.jsonl';
625
631
 
626
632
  function getMemexIndexPath() {
627
- return path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, MEMEX_INDEX_FILE);
633
+ return contextFsPath(NAMESPACES.provenance, MEMEX_INDEX_FILE);
628
634
  }
629
635
 
630
636
  function buildIndexEntry(doc, filePath) {
@@ -751,7 +757,7 @@ function constructMemexPack({ query = '', maxItems = 8, maxChars = 6000, namespa
751
757
  cache: { hit: false },
752
758
  };
753
759
 
754
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
760
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
755
761
  recordProvenance({
756
762
  type: 'memex_pack_constructed',
757
763
  packId,
@@ -792,7 +798,7 @@ function constructContextPack({ query = '', maxItems = 8, maxChars = 6000, names
792
798
  },
793
799
  };
794
800
 
795
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
801
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
796
802
  recordProvenance({
797
803
  type: 'context_pack_cache_hit',
798
804
  packId,
@@ -861,7 +867,7 @@ function constructContextPack({ query = '', maxItems = 8, maxChars = 6000, names
861
867
  retrieval: selection.retrieval,
862
868
  };
863
869
 
864
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
870
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
865
871
  appendSemanticCacheEntry({
866
872
  id: `cache_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
867
873
  timestamp: nowIso(),
@@ -899,7 +905,7 @@ function evaluateContextPack({ packId, outcome, signal = null, notes = '', rubri
899
905
  timestamp: nowIso(),
900
906
  };
901
907
 
902
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'evaluations.jsonl'), evaluation);
908
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'evaluations.jsonl'), evaluation);
903
909
  recordProvenance({
904
910
  type: 'context_pack_evaluated',
905
911
  packId,
@@ -912,7 +918,7 @@ function evaluateContextPack({ packId, outcome, signal = null, notes = '', rubri
912
918
  }
913
919
 
914
920
  function getProvenance(limit = 50) {
915
- const eventsPath = path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'events.jsonl');
921
+ const eventsPath = contextFsPath(NAMESPACES.provenance, 'events.jsonl');
916
922
  const events = readJsonl(eventsPath);
917
923
  return events.slice(-limit);
918
924
  }
@@ -923,7 +929,7 @@ function getProvenance(limit = 50) {
923
929
  * session starts with full context — no manual primer.md needed.
924
930
  */
925
931
  function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, openFiles, customContext } = {}) {
926
- ensureDir(path.join(CONTEXTFS_ROOT, NAMESPACES.session));
932
+ ensureDir(contextFsPath(NAMESPACES.session));
927
933
 
928
934
  let gitContext = {};
929
935
  try {
@@ -951,7 +957,7 @@ function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, op
951
957
  customContext: customContext || null,
952
958
  };
953
959
 
954
- const primerPath = path.join(CONTEXTFS_ROOT, NAMESPACES.session, 'primer.json');
960
+ const primerPath = contextFsPath(NAMESPACES.session, 'primer.json');
955
961
  fs.writeFileSync(primerPath, JSON.stringify(primer, null, 2));
956
962
 
957
963
  // Sync to primer.md if it exists
@@ -991,7 +997,7 @@ function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, op
991
997
  * Read the most recent session handoff primer.
992
998
  */
993
999
  function readSessionHandoff() {
994
- const primerPath = path.join(CONTEXTFS_ROOT, NAMESPACES.session, 'primer.json');
1000
+ const primerPath = contextFsPath(NAMESPACES.session, 'primer.json');
995
1001
  if (!fs.existsSync(primerPath)) return null;
996
1002
  try {
997
1003
  return JSON.parse(fs.readFileSync(primerPath, 'utf8'));
@@ -1192,7 +1198,7 @@ function constructMultiHopPack({ query = '', maxItems = 8, maxChars = 6000, name
1192
1198
  },
1193
1199
  };
1194
1200
 
1195
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
1201
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
1196
1202
  appendSemanticCacheEntry({
1197
1203
  id: `cache_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1198
1204
  timestamp: nowIso(),
@@ -1244,7 +1250,10 @@ function listPackTemplates() {
1244
1250
  }
1245
1251
 
1246
1252
  module.exports = {
1247
- CONTEXTFS_ROOT,
1253
+ get CONTEXTFS_ROOT() {
1254
+ return getContextFsRoot();
1255
+ },
1256
+ getContextFsRoot,
1248
1257
  NAMESPACES,
1249
1258
  ensureContextFs,
1250
1259
  recordProvenance,
@@ -1283,5 +1292,5 @@ module.exports = {
1283
1292
 
1284
1293
  if (require.main === module) {
1285
1294
  ensureContextFs();
1286
- console.log(`ContextFS ready at ${CONTEXTFS_ROOT}`);
1295
+ console.log(`ContextFS ready at ${getContextFsRoot()}`);
1287
1296
  }