thumbgate 1.0.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 (38) 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 +19 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/config/github-about.json +1 -1
  11. package/config/mcp-allowlists.json +1 -0
  12. package/package.json +22 -11
  13. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  15. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  16. package/plugins/codex-profile/.mcp.json +1 -1
  17. package/plugins/codex-profile/INSTALL.md +1 -1
  18. package/plugins/codex-profile/README.md +1 -1
  19. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  20. package/plugins/opencode-profile/INSTALL.md +1 -1
  21. package/public/compare.html +302 -0
  22. package/public/index.html +41 -11
  23. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  24. package/scripts/ai-search-visibility.js +142 -0
  25. package/scripts/changeset-check.js +372 -0
  26. package/scripts/check-congruence.js +7 -4
  27. package/scripts/computer-use-firewall.js +45 -15
  28. package/scripts/docker-sandbox-planner.js +208 -0
  29. package/scripts/export-hf-dataset.js +293 -0
  30. package/scripts/github-about.js +56 -0
  31. package/scripts/operational-integrity.js +7 -1
  32. package/scripts/published-cli.js +10 -1
  33. package/scripts/statusline-links.js +238 -0
  34. package/scripts/statusline.sh +39 -4
  35. package/scripts/sync-github-about.js +7 -4
  36. package/scripts/tool-registry.js +11 -0
  37. package/scripts/workflow-sentinel.js +83 -35
  38. package/src/api/server.js +12 -1
@@ -0,0 +1,208 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const path = require('node:path');
5
+
6
+ const { classifyCommand } = require('./operational-integrity');
7
+
8
+ const HIGH_RISK_ACTION_TYPES = new Set([
9
+ 'shell.exec',
10
+ 'file.delete',
11
+ 'upload',
12
+ 'message.send',
13
+ ]);
14
+
15
+ function normalizeText(value) {
16
+ if (value === undefined || value === null) return '';
17
+ return String(value).trim();
18
+ }
19
+
20
+ function normalizeStringArray(values = []) {
21
+ if (!Array.isArray(values)) return [];
22
+ return Array.from(new Set(
23
+ values
24
+ .map((value) => normalizeText(value))
25
+ .filter(Boolean),
26
+ ));
27
+ }
28
+
29
+ function normalizeRiskBand(value) {
30
+ const normalized = normalizeText(value).toLowerCase();
31
+ if (['very_high', 'high', 'medium', 'low'].includes(normalized)) {
32
+ return normalized;
33
+ }
34
+ if (normalized === 'critical') return 'very_high';
35
+ return 'low';
36
+ }
37
+
38
+ function quoteShellArg(value) {
39
+ return `'${String(value).replaceAll('\'', String.raw`'\''`)}'`;
40
+ }
41
+
42
+ function buildNetworkPolicy(input = {}) {
43
+ const allowedHosts = normalizeStringArray(input.allowedHosts || input.egressAllowlist);
44
+ if (input.requiresNetwork !== true) {
45
+ return {
46
+ mode: 'deny_all',
47
+ allowedHosts: [],
48
+ };
49
+ }
50
+ return {
51
+ mode: allowedHosts.length > 0 ? 'allow_list' : 'egress_enabled',
52
+ allowedHosts,
53
+ };
54
+ }
55
+
56
+ function buildLaunchers(workspacePath) {
57
+ const suffix = workspacePath ? ` shell ${quoteShellArg(workspacePath)}` : ' shell';
58
+ return {
59
+ standalone: `sbx run${suffix}`,
60
+ dockerDesktop: `docker sandbox run${suffix}`,
61
+ followUp: workspacePath
62
+ ? [
63
+ 'sbx list',
64
+ 'docker sandbox ls',
65
+ ]
66
+ : [],
67
+ };
68
+ }
69
+
70
+ function buildSummary(shouldSandbox, recommendation) {
71
+ if (!shouldSandbox) {
72
+ return 'Current action can stay on the normal local execution path.';
73
+ }
74
+ if (recommendation === 'required') {
75
+ return 'Route this action into Docker Sandboxes before retrying so the run happens inside a disposable microVM instead of on the host.';
76
+ }
77
+ return 'Prefer Docker Sandboxes for this action to reduce host blast radius while keeping local autonomy.';
78
+ }
79
+
80
+ function buildWhy({
81
+ recommendation,
82
+ command,
83
+ riskBand,
84
+ actionType,
85
+ affectedFiles,
86
+ }) {
87
+ const lines = [];
88
+ if (recommendation === 'required') {
89
+ lines.push('The predicted action is destructive or release-sensitive enough to justify host isolation.');
90
+ } else if (recommendation === 'recommended') {
91
+ lines.push('The predicted action is high-risk enough that isolated execution meaningfully reduces host blast radius.');
92
+ } else {
93
+ lines.push('The current action does not need a dedicated Docker sandbox boundary.');
94
+ }
95
+
96
+ if (command && /\brm\s+-rf\b/i.test(command)) {
97
+ lines.push('Recursive delete commands are safer when the filesystem boundary lives inside a disposable microVM.');
98
+ }
99
+ if (command && /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command)) {
100
+ lines.push('Force-push flows should run in an isolated lane so host credentials and unrelated state stay out of scope.');
101
+ }
102
+ if (command && /\b(?:gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish)\b/i.test(command)) {
103
+ lines.push('PR, merge, and publish flows are governance-sensitive and benefit from a disposable execution boundary.');
104
+ }
105
+ if (HIGH_RISK_ACTION_TYPES.has(actionType)) {
106
+ lines.push(`Action type ${actionType} is in the high-risk set for local execution.`);
107
+ }
108
+ if (riskBand === 'very_high' || riskBand === 'high') {
109
+ lines.push(`Risk band ${riskBand} predicts elevated blast radius on the local host.`);
110
+ }
111
+ if (affectedFiles.length >= 4) {
112
+ lines.push(`The change touches ${affectedFiles.length} files, so host isolation improves recovery if the run goes sideways.`);
113
+ }
114
+ return lines;
115
+ }
116
+
117
+ function buildDockerSandboxPlan(input = {}) {
118
+ const toolName = normalizeText(input.toolName);
119
+ const actionType = normalizeText(input.actionType)
120
+ || (toolName === 'Bash' ? 'shell.exec' : '');
121
+ const command = normalizeText(input.command);
122
+ const repoPath = normalizeText(input.repoPath);
123
+ const workspacePath = repoPath ? path.resolve(repoPath) : null;
124
+ const affectedFiles = normalizeStringArray(input.affectedFiles || input.changedFiles || input.files);
125
+ const riskBand = normalizeRiskBand(input.riskBand || input.band);
126
+ const riskScore = Number.isFinite(Number(input.riskScore))
127
+ ? Number(Number(input.riskScore).toFixed(4))
128
+ : null;
129
+ const commandInfo = classifyCommand(command);
130
+ const destructiveCommand = /\brm\s+-rf\b/i.test(command)
131
+ || /\bgit\s+push\b.*(?:--force|-f)\b/i.test(command)
132
+ || /\bgh\s+pr\s+merge\b.*--admin\b/i.test(command);
133
+ const governedCommand = Boolean(
134
+ commandInfo.isPrCreate
135
+ || commandInfo.isPrMerge
136
+ || commandInfo.isPublish
137
+ || commandInfo.isReleaseCreate
138
+ || commandInfo.isTagCreate
139
+ );
140
+ const highRiskAction = HIGH_RISK_ACTION_TYPES.has(actionType)
141
+ || destructiveCommand
142
+ || governedCommand
143
+ || riskBand === 'high'
144
+ || riskBand === 'very_high';
145
+
146
+ let recommendation = 'not_needed';
147
+ if (destructiveCommand || commandInfo.isPublish || commandInfo.isReleaseCreate || actionType === 'upload' || actionType === 'message.send') {
148
+ recommendation = 'required';
149
+ } else if (highRiskAction || affectedFiles.length >= 4) {
150
+ recommendation = 'recommended';
151
+ }
152
+
153
+ const shouldSandbox = recommendation !== 'not_needed';
154
+ const networkPolicy = buildNetworkPolicy({
155
+ requiresNetwork: input.requiresNetwork === true || governedCommand || commandInfo.isPublish || actionType === 'upload' || actionType === 'message.send',
156
+ allowedHosts: input.allowedHosts,
157
+ egressAllowlist: input.egressAllowlist,
158
+ });
159
+ const launchers = buildLaunchers(workspacePath);
160
+ const summary = buildSummary(shouldSandbox, recommendation);
161
+
162
+ return {
163
+ plannerVersion: 'docker-sandbox-plan-v1',
164
+ shouldSandbox,
165
+ recommendation,
166
+ summary,
167
+ sandboxKind: shouldSandbox ? 'docker_microvm' : 'host',
168
+ workspacePath,
169
+ actionType: actionType || null,
170
+ riskBand,
171
+ riskScore,
172
+ command: command || null,
173
+ affectedFiles,
174
+ networkPolicy,
175
+ launchers,
176
+ claims: shouldSandbox ? {
177
+ isolationBoundary: 'microvm',
178
+ hostAccess: 'bounded_outside_host',
179
+ dockerDaemon: 'private_inside_sandbox',
180
+ workspaceStrategy: workspacePath ? 'directory_sync' : 'ephemeral',
181
+ } : null,
182
+ why: buildWhy({
183
+ recommendation,
184
+ command,
185
+ riskBand,
186
+ actionType,
187
+ affectedFiles,
188
+ }),
189
+ };
190
+ }
191
+
192
+ module.exports = {
193
+ HIGH_RISK_ACTION_TYPES,
194
+ buildDockerSandboxPlan,
195
+ buildLaunchers,
196
+ buildNetworkPolicy,
197
+ buildSummary,
198
+ normalizeRiskBand,
199
+ };
200
+
201
+ if (require.main === module) {
202
+ const plan = buildDockerSandboxPlan({
203
+ toolName: process.argv[2] || 'Bash',
204
+ command: process.argv.slice(3).join(' '),
205
+ repoPath: process.cwd(),
206
+ });
207
+ console.log(JSON.stringify(plan, null, 2));
208
+ }
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * HuggingFace Dataset Exporter
6
+ *
7
+ * Exports ThumbGate agent traces as a HuggingFace-compatible dataset in two formats:
8
+ *
9
+ * 1. Agent Traces (traces split) — raw feedback entries with tool calls, signals,
10
+ * context, and outcomes. Matches the "share your agent traces" initiative.
11
+ *
12
+ * 2. DPO Preferences (preferences split) — chosen/rejected preference pairs
13
+ * derived from error→learning memory promotion. Ready for DPO/RLHF training.
14
+ *
15
+ * Output: Parquet-compatible JSONL files + dataset_info.json (HF Dataset Card metadata).
16
+ *
17
+ * HuggingFace Datasets format:
18
+ * dataset_dir/
19
+ * dataset_info.json — metadata, features schema, splits
20
+ * traces.jsonl — agent trace rows
21
+ * preferences.jsonl — DPO preference pair rows
22
+ */
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { resolveFeedbackDir } = require('./feedback-paths');
27
+ const { exportDpoFromMemories } = require('./export-dpo-pairs');
28
+ const { getProvenance } = require('./contextfs');
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Helpers
32
+ // ---------------------------------------------------------------------------
33
+
34
+ function readJSONL(filePath) {
35
+ if (!fs.existsSync(filePath)) return [];
36
+ const raw = fs.readFileSync(filePath, 'utf-8').trim();
37
+ if (!raw) return [];
38
+ return raw
39
+ .split('\n')
40
+ .map((line) => {
41
+ try { return JSON.parse(line); } catch { return null; }
42
+ })
43
+ .filter(Boolean);
44
+ }
45
+
46
+ function ensureDir(dirPath) {
47
+ if (!fs.existsSync(dirPath)) {
48
+ fs.mkdirSync(dirPath, { recursive: true });
49
+ }
50
+ }
51
+
52
+ function writeJSONL(filePath, rows) {
53
+ const content = rows.map((row) => JSON.stringify(row)).join('\n');
54
+ fs.writeFileSync(filePath, content ? `${content}\n` : '');
55
+ }
56
+
57
+ // ---------------------------------------------------------------------------
58
+ // PII / path redaction
59
+ // ---------------------------------------------------------------------------
60
+
61
+ function redactPaths(text) {
62
+ if (!text || typeof text !== 'string') return text || '';
63
+ return text
64
+ .replace(/\/Users\/[^\s/]+/g, '/Users/redacted')
65
+ .replace(/\/home\/[^\s/]+/g, '/home/redacted')
66
+ .replace(/C:\\Users\\[^\s\\]+/g, 'C:\\Users\\redacted');
67
+ }
68
+
69
+ function redactEntry(obj) {
70
+ if (!obj || typeof obj !== 'object') return obj;
71
+ const out = {};
72
+ for (const [key, value] of Object.entries(obj)) {
73
+ if (typeof value === 'string') {
74
+ out[key] = redactPaths(value);
75
+ } else if (Array.isArray(value)) {
76
+ out[key] = value.map((v) => (typeof v === 'string' ? redactPaths(v) : v));
77
+ } else {
78
+ out[key] = value;
79
+ }
80
+ }
81
+ return out;
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Trace row builder — converts feedback-log entries to HF trace rows
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function buildTraceRow(entry, index) {
89
+ return {
90
+ trace_id: entry.id || `trace_${index}`,
91
+ timestamp: entry.timestamp || null,
92
+ signal: entry.signal || entry.feedback || 'unknown',
93
+ tool_name: entry.toolName || entry.actionType || 'unknown',
94
+ context: redactPaths(entry.context || ''),
95
+ what_worked: redactPaths(entry.whatWorked || ''),
96
+ what_went_wrong: redactPaths(entry.whatWentWrong || ''),
97
+ what_to_change: redactPaths(entry.whatToChange || ''),
98
+ tags: Array.isArray(entry.tags) ? entry.tags : [],
99
+ failure_type: entry.failureType || null,
100
+ source: 'thumbgate',
101
+ };
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Preference row builder — converts DPO pairs to HF preference rows
106
+ // ---------------------------------------------------------------------------
107
+
108
+ function buildPreferenceRow(pair, index) {
109
+ return {
110
+ pair_id: `pref_${index}`,
111
+ prompt: redactPaths(pair.prompt || ''),
112
+ chosen: redactPaths(pair.chosen || ''),
113
+ rejected: redactPaths(pair.rejected || ''),
114
+ match_score: pair.metadata ? pair.metadata.matchScore : null,
115
+ matched_keys: pair.metadata ? pair.metadata.matchedKeys || [] : [],
116
+ rubric_delta: pair.metadata && pair.metadata.rubric
117
+ ? pair.metadata.rubric.weightedDelta
118
+ : null,
119
+ source: 'thumbgate',
120
+ };
121
+ }
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // Dataset info (HuggingFace Dataset Card metadata)
125
+ // ---------------------------------------------------------------------------
126
+
127
+ function buildDatasetInfo({ traceCount, preferenceCount, exportedAt }) {
128
+ return {
129
+ dataset_info: {
130
+ description: 'Agent traces and DPO preference pairs from ThumbGate — pre-action gates for AI coding agents. Contains real-world tool call feedback, failure patterns, and learned corrections.',
131
+ citation: '',
132
+ homepage: 'https://github.com/IgorGanapolsky/ThumbGate',
133
+ license: 'MIT',
134
+ features: {
135
+ traces: {
136
+ trace_id: { dtype: 'string' },
137
+ timestamp: { dtype: 'string' },
138
+ signal: { dtype: 'string' },
139
+ tool_name: { dtype: 'string' },
140
+ context: { dtype: 'string' },
141
+ what_worked: { dtype: 'string' },
142
+ what_went_wrong: { dtype: 'string' },
143
+ what_to_change: { dtype: 'string' },
144
+ tags: { dtype: 'list', inner: { dtype: 'string' } },
145
+ failure_type: { dtype: 'string' },
146
+ source: { dtype: 'string' },
147
+ },
148
+ preferences: {
149
+ pair_id: { dtype: 'string' },
150
+ prompt: { dtype: 'string' },
151
+ chosen: { dtype: 'string' },
152
+ rejected: { dtype: 'string' },
153
+ match_score: { dtype: 'float32' },
154
+ matched_keys: { dtype: 'list', inner: { dtype: 'string' } },
155
+ rubric_delta: { dtype: 'float32' },
156
+ source: { dtype: 'string' },
157
+ },
158
+ },
159
+ splits: {
160
+ traces: { num_examples: traceCount },
161
+ preferences: { num_examples: preferenceCount },
162
+ },
163
+ },
164
+ exported_at: exportedAt,
165
+ exporter: 'thumbgate/export-hf-dataset',
166
+ version: '1.0.0',
167
+ };
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------
171
+ // Main export function
172
+ // ---------------------------------------------------------------------------
173
+
174
+ /**
175
+ * Export ThumbGate data as a HuggingFace-compatible dataset.
176
+ *
177
+ * @param {Object} options
178
+ * @param {string} [options.outputDir] - Directory to write dataset files
179
+ * @param {string} [options.feedbackDir] - Override feedback data directory
180
+ * @param {boolean} [options.includeProvenance] - Include provenance events in traces
181
+ * @returns {Object} Export summary
182
+ */
183
+ function exportHfDataset(options = {}) {
184
+ const feedbackDir = options.feedbackDir || resolveFeedbackDir();
185
+ const outputDir = options.outputDir || path.join(feedbackDir, 'hf-dataset');
186
+ const includeProvenance = options.includeProvenance !== false;
187
+
188
+ ensureDir(outputDir);
189
+
190
+ // --- Traces split ---
191
+ const feedbackLogPath = path.join(feedbackDir, 'feedback-log.jsonl');
192
+ const feedbackEntries = readJSONL(feedbackLogPath);
193
+ const traceRows = feedbackEntries.map((entry, i) => buildTraceRow(redactEntry(entry), i));
194
+
195
+ // Optionally append provenance events as traces
196
+ if (includeProvenance) {
197
+ try {
198
+ const provenanceEvents = getProvenance(200);
199
+ for (const evt of provenanceEvents) {
200
+ traceRows.push({
201
+ trace_id: evt.id || `prov_${traceRows.length}`,
202
+ timestamp: evt.timestamp || null,
203
+ signal: 'provenance',
204
+ tool_name: evt.type || 'context_assembly',
205
+ context: redactPaths(JSON.stringify(evt).slice(0, 500)),
206
+ what_worked: '',
207
+ what_went_wrong: '',
208
+ what_to_change: '',
209
+ tags: ['provenance'],
210
+ failure_type: null,
211
+ source: 'thumbgate',
212
+ });
213
+ }
214
+ } catch {
215
+ // Provenance read failure should not break export
216
+ }
217
+ }
218
+
219
+ writeJSONL(path.join(outputDir, 'traces.jsonl'), traceRows);
220
+
221
+ // --- Preferences split ---
222
+ const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
223
+ const memories = readJSONL(memoryLogPath);
224
+ let preferenceRows = [];
225
+
226
+ if (memories.length > 0) {
227
+ try {
228
+ const dpoResult = exportDpoFromMemories(memories);
229
+ preferenceRows = dpoResult.pairs.map((pair, i) => buildPreferenceRow(pair, i));
230
+ } catch {
231
+ // DPO export failure should not break the traces export
232
+ }
233
+ }
234
+
235
+ writeJSONL(path.join(outputDir, 'preferences.jsonl'), preferenceRows);
236
+
237
+ // --- Dataset info ---
238
+ const exportedAt = new Date().toISOString();
239
+ const info = buildDatasetInfo({
240
+ traceCount: traceRows.length,
241
+ preferenceCount: preferenceRows.length,
242
+ exportedAt,
243
+ });
244
+ fs.writeFileSync(
245
+ path.join(outputDir, 'dataset_info.json'),
246
+ JSON.stringify(info, null, 2) + '\n',
247
+ );
248
+
249
+ return {
250
+ outputDir,
251
+ traceCount: traceRows.length,
252
+ preferenceCount: preferenceRows.length,
253
+ files: ['traces.jsonl', 'preferences.jsonl', 'dataset_info.json'],
254
+ exportedAt,
255
+ };
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // CLI
260
+ // ---------------------------------------------------------------------------
261
+
262
+ function main() {
263
+ const args = {};
264
+ process.argv.slice(2).forEach((arg) => {
265
+ if (!arg.startsWith('--')) return;
266
+ const [key, ...rest] = arg.slice(2).split('=');
267
+ args[key] = rest.length ? rest.join('=') : true;
268
+ });
269
+
270
+ const result = exportHfDataset({
271
+ outputDir: args.output || undefined,
272
+ includeProvenance: args.provenance !== 'false',
273
+ });
274
+
275
+ console.log(`Exported HuggingFace dataset to ${result.outputDir}`);
276
+ console.log(` Traces: ${result.traceCount}`);
277
+ console.log(` Preferences: ${result.preferenceCount}`);
278
+ console.log(` Files: ${result.files.join(', ')}`);
279
+ }
280
+
281
+ if (require.main === module) {
282
+ main();
283
+ }
284
+
285
+ module.exports = {
286
+ exportHfDataset,
287
+ buildTraceRow,
288
+ buildPreferenceRow,
289
+ buildDatasetInfo,
290
+ redactPaths,
291
+ redactEntry,
292
+ readJSONL,
293
+ };
@@ -12,6 +12,8 @@ const ROOT = path.join(__dirname, '..');
12
12
  const CONFIG_RELATIVE_PATH = path.join('config', 'github-about.json');
13
13
  const LEGACY_REPOSITORY_URL = 'https://github.com/IgorGanapolsky/thumbgate';
14
14
  const GITHUB_API_BASE_URL = 'https://api.github.com';
15
+ const DEFAULT_VERIFY_ATTEMPTS = 5;
16
+ const DEFAULT_VERIFY_DELAY_MS = 2000;
15
17
 
16
18
  function readText(root, relativePath) {
17
19
  return fs.readFileSync(path.join(root, relativePath), 'utf8');
@@ -296,6 +298,57 @@ async function fetchLiveGitHubAbout(options = {}) {
296
298
  };
297
299
  }
298
300
 
301
+ function normalizePositiveInteger(value, fallback) {
302
+ const parsed = Number.parseInt(value, 10);
303
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
304
+ }
305
+
306
+ function sleep(delayMs) {
307
+ return new Promise((resolve) => {
308
+ setTimeout(resolve, delayMs);
309
+ });
310
+ }
311
+
312
+ async function verifyLiveGitHubAbout(options = {}) {
313
+ const root = options.root || ROOT;
314
+ const expected = options.expected || loadGitHubAboutConfig(root);
315
+ const repo = normalizeText(options.repo) || expected.repo;
316
+ const label = options.label || `Live GitHub About (${repo})`;
317
+ const attempts = normalizePositiveInteger(options.attempts, DEFAULT_VERIFY_ATTEMPTS);
318
+ const delayMs = normalizePositiveInteger(options.delayMs, DEFAULT_VERIFY_DELAY_MS);
319
+ const fetcher = typeof options.fetcher === 'function' ? options.fetcher : fetchLiveGitHubAbout;
320
+ const sleeper = typeof options.sleep === 'function' ? options.sleep : sleep;
321
+ let actual = null;
322
+ let errors = [];
323
+
324
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
325
+ actual = await fetcher({
326
+ root,
327
+ repo,
328
+ token: options.token,
329
+ });
330
+ errors = compareGitHubAbout(expected, actual, label);
331
+ if (errors.length === 0) {
332
+ return {
333
+ ok: true,
334
+ actual,
335
+ attemptsUsed: attempt,
336
+ errors: [],
337
+ };
338
+ }
339
+ if (attempt < attempts) {
340
+ await sleeper(delayMs * attempt);
341
+ }
342
+ }
343
+
344
+ return {
345
+ ok: false,
346
+ actual,
347
+ attemptsUsed: attempts,
348
+ errors,
349
+ };
350
+ }
351
+
299
352
  async function updateLiveGitHubAbout(options = {}) {
300
353
  const about = loadGitHubAboutConfig(options.root || ROOT);
301
354
  const repo = normalizeText(options.repo) || about.repo;
@@ -334,6 +387,8 @@ async function updateLiveGitHubAbout(options = {}) {
334
387
  }
335
388
 
336
389
  module.exports = {
390
+ DEFAULT_VERIFY_ATTEMPTS,
391
+ DEFAULT_VERIFY_DELAY_MS,
337
392
  LEGACY_REPOSITORY_URL,
338
393
  buildCanonicalRepoUrls,
339
394
  collectLocalGitHubAboutErrors,
@@ -347,4 +402,5 @@ module.exports = {
347
402
  normalizeTopics,
348
403
  normalizeUrl,
349
404
  updateLiveGitHubAbout,
405
+ verifyLiveGitHubAbout,
350
406
  };
@@ -457,12 +457,17 @@ function parseCliArgs(argv = process.argv.slice(2)) {
457
457
  return options;
458
458
  }
459
459
 
460
+ function resolveCiBranchName(env = process.env) {
461
+ const branchName = String(env.GITHUB_HEAD_REF || env.GITHUB_REF_NAME || '').trim();
462
+ return branchName || undefined;
463
+ }
464
+
460
465
  function runCli(env = process.env, argv = process.argv.slice(2)) {
461
466
  const args = parseCliArgs(argv);
462
467
  const result = evaluateOperationalIntegrity({
463
468
  repoPath: args.repoPath,
464
469
  baseBranch: args.baseBranch || env.DEFAULT_BRANCH || DEFAULT_BASE_BRANCH,
465
- currentBranch: env.GITHUB_REF_NAME || undefined,
470
+ currentBranch: resolveCiBranchName(env),
466
471
  requirePrForReleaseSensitive: args.requirePrForReleaseSensitive,
467
472
  requireVersionNotBehindBase: args.requireVersionNotBehindBase,
468
473
  fetchBase: args.fetchBase,
@@ -517,6 +522,7 @@ module.exports = {
517
522
  parseSemver,
518
523
  readPackageVersion,
519
524
  resolveBaseRef,
525
+ resolveCiBranchName,
520
526
  resolveRepoRoot,
521
527
  runCli,
522
528
  sanitizeGlobList,
@@ -13,6 +13,10 @@ function runtimePrefixDir(prefixDir) {
13
13
  return prefixDir || path.join(os.homedir(), '.thumbgate', 'runtime');
14
14
  }
15
15
 
16
+ function installedRuntimeBin(prefixDir) {
17
+ return path.join(runtimePrefixDir(prefixDir), 'node_modules', '.bin', 'thumbgate');
18
+ }
19
+
16
20
  function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
17
21
  return [
18
22
  'exec',
@@ -29,7 +33,11 @@ function publishedCliArgs(pkgVersion, commandArgs = [], options = {}) {
29
33
 
30
34
  function publishedCliShellCommand(pkgVersion, commandArgs = [], options = {}) {
31
35
  const prefixDir = runtimePrefixDir(options.prefixDir);
32
- return `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
36
+ const runtimeBin = installedRuntimeBin(prefixDir);
37
+ const escapedArgs = commandArgs.map(shellQuote).join(' ');
38
+ const fastPath = `[ -x ${shellQuote(runtimeBin)} ] && exec ${shellQuote(runtimeBin)}${escapedArgs ? ` ${escapedArgs}` : ''}`;
39
+ const installPath = `mkdir -p ${shellQuote(prefixDir)} && exec npm ${publishedCliArgs(pkgVersion, commandArgs, { prefixDir }).map(shellQuote).join(' ')}`;
40
+ return `${fastPath} || ${installPath}`;
33
41
  }
34
42
 
35
43
  function runPublishedCli(pkgVersion, commandArgs = [], options = {}) {
@@ -55,6 +63,7 @@ function runPublishedCliHelp(pkgVersion, options = {}) {
55
63
  module.exports = {
56
64
  publishedCliArgs,
57
65
  publishedCliShellCommand,
66
+ installedRuntimeBin,
58
67
  runtimePrefixDir,
59
68
  runPublishedCli,
60
69
  runPublishedCliHelp,