thumbgate 1.1.0 → 1.2.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 (35) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +16 -5
  5. package/adapters/README.md +1 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/codex/config.toml +2 -2
  8. package/adapters/mcp/server-stdio.js +10 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/config/github-about.json +1 -1
  11. package/package.json +20 -11
  12. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  13. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  14. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  15. package/plugins/codex-profile/.mcp.json +1 -1
  16. package/plugins/codex-profile/INSTALL.md +1 -1
  17. package/plugins/codex-profile/README.md +1 -1
  18. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  19. package/plugins/opencode-profile/INSTALL.md +1 -1
  20. package/public/compare.html +302 -0
  21. package/public/index.html +36 -10
  22. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  23. package/scripts/ai-search-visibility.js +142 -0
  24. package/scripts/changeset-check.js +372 -0
  25. package/scripts/check-congruence.js +7 -4
  26. package/scripts/computer-use-firewall.js +45 -15
  27. package/scripts/docker-sandbox-planner.js +208 -0
  28. package/scripts/github-about.js +56 -0
  29. package/scripts/operational-integrity.js +7 -1
  30. package/scripts/published-cli.js +10 -1
  31. package/scripts/statusline-links.js +238 -0
  32. package/scripts/statusline.sh +39 -4
  33. package/scripts/sync-github-about.js +7 -4
  34. package/scripts/workflow-sentinel.js +83 -35
  35. package/src/api/server.js +12 -1
@@ -0,0 +1,372 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { execFileSync } = require('node:child_process');
7
+
8
+ const PROJECT_ROOT = path.join(__dirname, '..');
9
+ const CHANGESET_DIR = path.join(PROJECT_ROOT, '.changeset');
10
+ const DEFAULT_PACKAGE_NAME = 'thumbgate';
11
+ const MIN_SUMMARY_LENGTH = 20;
12
+ const RELEASE_TYPES = new Set(['major', 'minor', 'patch']);
13
+ const RELEASE_RELEVANT_FILES = new Set([
14
+ 'README.md',
15
+ 'package.json',
16
+ 'package-lock.json',
17
+ 'server.json',
18
+ ]);
19
+ const RELEASE_RELEVANT_PREFIXES = [
20
+ '.claude-plugin/',
21
+ '.cursor-plugin/',
22
+ '.well-known/',
23
+ 'adapters/',
24
+ 'bin/',
25
+ 'config/',
26
+ 'plugins/',
27
+ 'public/',
28
+ 'scripts/',
29
+ 'src/',
30
+ 'workers/',
31
+ ];
32
+
33
+ function parseArgs(argv = process.argv.slice(2)) {
34
+ const options = {};
35
+ for (const arg of argv) {
36
+ if (arg.startsWith('--base=')) {
37
+ options.baseRef = arg.slice('--base='.length);
38
+ } else if (arg.startsWith('--since=')) {
39
+ options.baseRef = arg.slice('--since='.length);
40
+ }
41
+ }
42
+ return options;
43
+ }
44
+
45
+ function isChangesetMarkdownFile(relPath) {
46
+ return relPath.startsWith('.changeset/')
47
+ && relPath.endsWith('.md')
48
+ && path.basename(relPath) !== 'README.md';
49
+ }
50
+
51
+ function isReleaseRelevantFile(relPath) {
52
+ const normalized = String(relPath || '').trim().replaceAll('\\', '/');
53
+ if (!normalized || isChangesetMarkdownFile(normalized)) {
54
+ return false;
55
+ }
56
+ if (normalized.startsWith('docs/')
57
+ || normalized.startsWith('proof/')
58
+ || normalized.startsWith('tests/')
59
+ || normalized.startsWith('.github/')) {
60
+ return false;
61
+ }
62
+ if (RELEASE_RELEVANT_FILES.has(normalized)) {
63
+ return true;
64
+ }
65
+ return RELEASE_RELEVANT_PREFIXES.some((prefix) => normalized.startsWith(prefix));
66
+ }
67
+
68
+ function isVersionedReleaseChangeSet(changedFiles = []) {
69
+ const normalizedFiles = changedFiles.map((file) => String(file || '').trim().replaceAll('\\', '/'));
70
+ return normalizedFiles.includes('package.json')
71
+ && normalizedFiles.includes('CHANGELOG.md')
72
+ && normalizedFiles.some(isChangesetMarkdownFile);
73
+ }
74
+
75
+ function splitChangesetDocument(content) {
76
+ const normalized = String(content || '').replaceAll('\r\n', '\n');
77
+ const lines = normalized.split('\n');
78
+ if (lines[0]?.trim() !== '---') {
79
+ return null;
80
+ }
81
+
82
+ const closingIndex = lines.findIndex((line, index) => index > 0 && line.trim() === '---');
83
+ if (closingIndex === -1) {
84
+ return null;
85
+ }
86
+
87
+ return {
88
+ frontmatterLines: lines.slice(1, closingIndex),
89
+ summary: lines.slice(closingIndex + 1).join('\n').trim(),
90
+ };
91
+ }
92
+
93
+ function stripWrappingQuotes(value) {
94
+ const text = String(value || '').trim();
95
+ if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith('\'') && text.endsWith('\'')))) {
96
+ return text.slice(1, -1).trim();
97
+ }
98
+ return text;
99
+ }
100
+
101
+ function parseReleaseLine(line) {
102
+ const normalized = String(line || '').trim();
103
+ if (!normalized) {
104
+ return null;
105
+ }
106
+
107
+ const separatorIndex = normalized.indexOf(':');
108
+ if (separatorIndex <= 0 || separatorIndex === normalized.length - 1) {
109
+ return null;
110
+ }
111
+
112
+ const packageName = stripWrappingQuotes(normalized.slice(0, separatorIndex));
113
+ const releaseType = normalized.slice(separatorIndex + 1).trim();
114
+ if (!packageName || !RELEASE_TYPES.has(releaseType)) {
115
+ return null;
116
+ }
117
+
118
+ return {
119
+ packageName,
120
+ releaseType,
121
+ };
122
+ }
123
+
124
+ function parseChangesetMarkdown(content) {
125
+ const document = splitChangesetDocument(content);
126
+ if (!document) {
127
+ return {
128
+ releases: {},
129
+ summary: '',
130
+ errors: ['missing frontmatter'],
131
+ };
132
+ }
133
+
134
+ const summary = document.summary;
135
+ const releases = {};
136
+ const errors = [];
137
+ const lines = document.frontmatterLines.map((line) => line.trim()).filter(Boolean);
138
+
139
+ for (const line of lines) {
140
+ const entry = parseReleaseLine(line);
141
+ if (!entry) {
142
+ errors.push(`invalid frontmatter line: ${line}`);
143
+ continue;
144
+ }
145
+ releases[entry.packageName] = entry.releaseType;
146
+ }
147
+
148
+ if (!summary) {
149
+ errors.push('missing summary');
150
+ } else if (summary.length < MIN_SUMMARY_LENGTH) {
151
+ errors.push(`summary must be at least ${MIN_SUMMARY_LENGTH} characters`);
152
+ }
153
+
154
+ if (Object.keys(releases).length === 0) {
155
+ errors.push('missing release entries');
156
+ }
157
+
158
+ return {
159
+ releases,
160
+ summary,
161
+ errors,
162
+ };
163
+ }
164
+
165
+ function collectChangesets({
166
+ dir = CHANGESET_DIR,
167
+ packageName = DEFAULT_PACKAGE_NAME,
168
+ } = {}) {
169
+ if (!fs.existsSync(dir)) {
170
+ return [];
171
+ }
172
+
173
+ return fs.readdirSync(dir)
174
+ .filter((name) => name.endsWith('.md') && name !== 'README.md')
175
+ .sort()
176
+ .map((name) => {
177
+ const filePath = path.join(dir, name);
178
+ const parsed = parseChangesetMarkdown(fs.readFileSync(filePath, 'utf8'));
179
+ const releaseType = parsed.releases[packageName] || null;
180
+ const errors = [...parsed.errors];
181
+ if (!releaseType) {
182
+ errors.push(`missing ${packageName} release entry`);
183
+ }
184
+ return {
185
+ file: path.posix.join('.changeset', name),
186
+ releaseType,
187
+ summary: parsed.summary,
188
+ errors,
189
+ validForPackage: errors.length === 0,
190
+ };
191
+ });
192
+ }
193
+
194
+ function evaluateChangesetRequirement({
195
+ changedFiles = [],
196
+ changesets = [],
197
+ } = {}) {
198
+ const relevantFiles = changedFiles.filter(isReleaseRelevantFile);
199
+ const required = relevantFiles.length > 0;
200
+ const validChangesets = changesets.filter((entry) => entry.validForPackage);
201
+ const invalidChangesets = changesets.filter((entry) => !entry.validForPackage);
202
+ const versionedRelease = isVersionedReleaseChangeSet(changedFiles);
203
+
204
+ if (!required) {
205
+ return {
206
+ ok: true,
207
+ required: false,
208
+ relevantFiles,
209
+ validChangesets,
210
+ invalidChangesets,
211
+ reason: 'No release-relevant changes detected. Changeset not required.',
212
+ };
213
+ }
214
+
215
+ if (validChangesets.length > 0) {
216
+ return {
217
+ ok: true,
218
+ required: true,
219
+ relevantFiles,
220
+ validChangesets,
221
+ invalidChangesets,
222
+ reason: `Found ${validChangesets.length} valid changeset file(s) for release-relevant changes.`,
223
+ };
224
+ }
225
+
226
+ if (versionedRelease) {
227
+ return {
228
+ ok: true,
229
+ required: true,
230
+ relevantFiles,
231
+ validChangesets,
232
+ invalidChangesets,
233
+ reason: 'Release PR already consumed pending changesets into versioned artifacts.',
234
+ };
235
+ }
236
+
237
+ return {
238
+ ok: false,
239
+ required: true,
240
+ relevantFiles,
241
+ validChangesets,
242
+ invalidChangesets,
243
+ reason: 'Release-relevant changes require at least one valid .changeset entry for thumbgate.',
244
+ };
245
+ }
246
+
247
+ function runGitCommand(args, {
248
+ cwd = PROJECT_ROOT,
249
+ runner = execFileSync,
250
+ } = {}) {
251
+ return String(runner('git', args, {
252
+ cwd,
253
+ encoding: 'utf8',
254
+ stdio: ['ignore', 'pipe', 'pipe'],
255
+ }) || '').trim();
256
+ }
257
+
258
+ function resolveBaseRef({
259
+ args = parseArgs(),
260
+ env = process.env,
261
+ cwd = PROJECT_ROOT,
262
+ runner = execFileSync,
263
+ } = {}) {
264
+ const explicitBase = String(args.baseRef || '').trim();
265
+ const baseRef = explicitBase
266
+ || String(env.CHANGESET_BASE_REF || '').trim()
267
+ || String(env.GITHUB_BASE_REF || '').trim()
268
+ || (env.GITHUB_EVENT_NAME === 'merge_group' ? 'origin/main' : '');
269
+
270
+ if (!baseRef) {
271
+ return null;
272
+ }
273
+
274
+ const candidates = [baseRef];
275
+ if (!baseRef.startsWith('origin/')) {
276
+ candidates.push(`origin/${baseRef}`);
277
+ }
278
+
279
+ for (const candidate of candidates) {
280
+ try {
281
+ runGitCommand(['rev-parse', '--verify', candidate], { cwd, runner });
282
+ return candidate;
283
+ } catch {}
284
+ }
285
+
286
+ return candidates.at(-1);
287
+ }
288
+
289
+ function getChangedFiles({
290
+ baseRef,
291
+ cwd = PROJECT_ROOT,
292
+ runner = execFileSync,
293
+ } = {}) {
294
+ if (!baseRef) {
295
+ return [];
296
+ }
297
+
298
+ const mergeBase = runGitCommand(['merge-base', 'HEAD', baseRef], { cwd, runner });
299
+ const output = runGitCommand(['diff', '--name-only', '--diff-filter=ACDMRTUXB', `${mergeBase}...HEAD`], { cwd, runner });
300
+ return output ? output.split('\n').map((line) => line.trim()).filter(Boolean) : [];
301
+ }
302
+
303
+ function formatFailure(result) {
304
+ const lines = [result.reason, ''];
305
+ if (result.relevantFiles.length > 0) {
306
+ lines.push('Release-relevant files:');
307
+ result.relevantFiles.forEach((file) => lines.push(`- ${file}`));
308
+ lines.push('');
309
+ }
310
+ if (result.invalidChangesets.length > 0) {
311
+ lines.push('Invalid changesets:');
312
+ result.invalidChangesets.forEach((entry) => {
313
+ lines.push(`- ${entry.file}: ${entry.errors.join('; ')}`);
314
+ });
315
+ lines.push('');
316
+ }
317
+ lines.push('Run `npm run changeset` and add a release note for thumbgate before merging.');
318
+ return lines.join('\n');
319
+ }
320
+
321
+ function runCli({
322
+ cwd = PROJECT_ROOT,
323
+ env = process.env,
324
+ runner = execFileSync,
325
+ } = {}) {
326
+ const baseRef = resolveBaseRef({ env, cwd, runner });
327
+ if (!baseRef) {
328
+ const result = {
329
+ ok: true,
330
+ skipped: true,
331
+ reason: 'No base ref detected. Skipping changeset check outside PR or merge-group context.',
332
+ };
333
+ console.log(result.reason);
334
+ return result;
335
+ }
336
+
337
+ const changedFiles = getChangedFiles({ baseRef, cwd, runner });
338
+ const changesets = collectChangesets();
339
+ const result = evaluateChangesetRequirement({ changedFiles, changesets });
340
+ if (result.ok) {
341
+ console.log(result.reason);
342
+ return result;
343
+ }
344
+
345
+ console.error(formatFailure(result));
346
+ process.exitCode = 1;
347
+ return result;
348
+ }
349
+
350
+ if (require.main === module) {
351
+ runCli();
352
+ }
353
+
354
+ module.exports = {
355
+ CHANGESET_DIR,
356
+ DEFAULT_PACKAGE_NAME,
357
+ MIN_SUMMARY_LENGTH,
358
+ RELEASE_RELEVANT_FILES,
359
+ RELEASE_RELEVANT_PREFIXES,
360
+ collectChangesets,
361
+ evaluateChangesetRequirement,
362
+ formatFailure,
363
+ getChangedFiles,
364
+ isChangesetMarkdownFile,
365
+ isReleaseRelevantFile,
366
+ isVersionedReleaseChangeSet,
367
+ parseArgs,
368
+ parseChangesetMarkdown,
369
+ resolveBaseRef,
370
+ runCli,
371
+ runGitCommand,
372
+ };
@@ -11,9 +11,8 @@ const fs = require('fs');
11
11
  const path = require('path');
12
12
  const {
13
13
  collectLocalGitHubAboutErrors,
14
- compareGitHubAbout,
15
- fetchLiveGitHubAbout,
16
14
  loadGitHubAboutConfig,
15
+ verifyLiveGitHubAbout,
17
16
  } = require('./github-about');
18
17
  const {
19
18
  PRODUCTHUNT_URL,
@@ -295,8 +294,12 @@ async function main() {
295
294
 
296
295
  if (checkLiveGitHubAbout) {
297
296
  try {
298
- const liveAbout = await fetchLiveGitHubAbout({ root: ROOT, repo: githubAbout.repo });
299
- errors.push(...compareGitHubAbout(githubAbout, liveAbout, `Live GitHub About (${githubAbout.repo})`));
297
+ const liveCheck = await verifyLiveGitHubAbout({
298
+ expected: githubAbout,
299
+ repo: githubAbout.repo,
300
+ root: ROOT,
301
+ });
302
+ errors.push(...liveCheck.errors);
300
303
  } catch (error) {
301
304
  errors.push(`Unable to verify live GitHub About: ${error.message}`);
302
305
  }
@@ -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
  };