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
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { aggregateFailureDiagnostics } = require('./failure-diagnostics');
7
+ const { AUDIT_LOG_FILENAME } = require('./audit-trail');
7
8
  const { getBillingSummary, loadFunnelLedger, loadResolvedRevenueEvents } = require('./billing');
8
9
  const { getTelemetryAnalytics, loadTelemetryEvents } = require('./telemetry-analytics');
9
10
  const { getAutoGatesPath } = require('./auto-promote-gates');
@@ -19,6 +20,7 @@ const { routeProfile } = require('./profile-router');
19
20
  const { getSettingsStatus } = require('./settings-hierarchy');
20
21
  const { summarizeWorkflowRuns } = require('./workflow-runs');
21
22
  const { searchLessons } = require('./lesson-search');
23
+ const { getInterventionPolicySummary } = require('./intervention-policy');
22
24
 
23
25
  const PROJECT_ROOT = path.join(__dirname, '..');
24
26
  const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
@@ -60,6 +62,15 @@ function pickFirstText(...values) {
60
62
  return null;
61
63
  }
62
64
 
65
+ function toLocalDayKey(value) {
66
+ const ts = value instanceof Date ? value : new Date(value);
67
+ if (Number.isNaN(ts.getTime())) return null;
68
+ const year = ts.getFullYear();
69
+ const month = String(ts.getMonth() + 1).padStart(2, '0');
70
+ const day = String(ts.getDate()).padStart(2, '0');
71
+ return `${year}-${month}-${day}`;
72
+ }
73
+
63
74
  // ---------------------------------------------------------------------------
64
75
  // Approval rate + trend
65
76
  // ---------------------------------------------------------------------------
@@ -143,6 +154,58 @@ function computeGateStats() {
143
154
  };
144
155
  }
145
156
 
157
+ function computeGateAuditSeries(feedbackDir, options = {}) {
158
+ const auditLogPath = path.join(feedbackDir, AUDIT_LOG_FILENAME);
159
+ const entries = readJSONL(auditLogPath).filter((entry) => entry && entry.timestamp);
160
+ const dayCount = Number.isInteger(options.dayCount) ? options.dayCount : 14;
161
+ const today = new Date();
162
+ today.setHours(0, 0, 0, 0);
163
+ const countsByDay = new Map();
164
+
165
+ for (const entry of entries) {
166
+ if (!['allow', 'deny', 'warn'].includes(entry.decision)) continue;
167
+ const dayKey = toLocalDayKey(entry.timestamp);
168
+ if (!dayKey) continue;
169
+ if (!countsByDay.has(dayKey)) {
170
+ countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0 });
171
+ }
172
+ countsByDay.get(dayKey)[entry.decision] += 1;
173
+ }
174
+
175
+ const days = [];
176
+ const totals = { allow: 0, deny: 0, warn: 0, intercepted: 0, total: 0 };
177
+
178
+ for (let offset = dayCount - 1; offset >= 0; offset -= 1) {
179
+ const day = new Date(today);
180
+ day.setDate(today.getDate() - offset);
181
+ const dayKey = toLocalDayKey(day);
182
+ const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0 };
183
+ const intercepted = record.deny + record.warn;
184
+ const total = intercepted + record.allow;
185
+ const summary = {
186
+ dayKey,
187
+ allow: record.allow,
188
+ deny: record.deny,
189
+ warn: record.warn,
190
+ intercepted,
191
+ total,
192
+ };
193
+ totals.allow += record.allow;
194
+ totals.deny += record.deny;
195
+ totals.warn += record.warn;
196
+ totals.intercepted += intercepted;
197
+ totals.total += total;
198
+ days.push(summary);
199
+ }
200
+
201
+ return {
202
+ dayCount,
203
+ days,
204
+ totals,
205
+ activeDays: days.filter((day) => day.total > 0).length,
206
+ };
207
+ }
208
+
146
209
  function listActiveGates() {
147
210
  try {
148
211
  const config = loadGatesConfig();
@@ -710,6 +773,7 @@ function generateDashboard(feedbackDir, options = {}) {
710
773
  const prevention = computePreventionImpact(feedbackDir, gateStats);
711
774
  const trend = computeSessionTrend(entries, 10);
712
775
  const health = computeSystemHealth(feedbackDir, gateStats);
776
+ const gateAudit = computeGateAuditSeries(feedbackDir);
713
777
  const diagnostics = aggregateFailureDiagnostics([...entries, ...diagnosticEntries]);
714
778
  const secretGuard = computeSecretGuardStats(diagnosticEntries);
715
779
  const gates = listActiveGates();
@@ -722,6 +786,7 @@ function generateDashboard(feedbackDir, options = {}) {
722
786
  const delegation = summarizeDelegation(feedbackDir);
723
787
  const readiness = generateAgentReadinessReport({ projectRoot: PROJECT_ROOT });
724
788
  const harness = computeHarnessOverview(feedbackDir, entries);
789
+ const interventionPolicy = getInterventionPolicySummary(feedbackDir);
725
790
  const settingsStatus = getSettingsStatus({ projectRoot: PROJECT_ROOT });
726
791
  settingsStatus.routingPreview = {
727
792
  dashboardTool: routeProfile({
@@ -782,6 +847,7 @@ function generateDashboard(feedbackDir, options = {}) {
782
847
  prevention,
783
848
  trend,
784
849
  health,
850
+ gateAudit,
785
851
  diagnostics,
786
852
  delegation,
787
853
  secretGuard,
@@ -790,6 +856,7 @@ function generateDashboard(feedbackDir, options = {}) {
790
856
  observability,
791
857
  instrumentation,
792
858
  readiness,
859
+ interventionPolicy,
793
860
  settingsStatus,
794
861
  team,
795
862
  templateLibrary,
@@ -809,6 +876,7 @@ function printDashboard(data) {
809
876
  prevention,
810
877
  trend,
811
878
  health,
879
+ gateAudit,
812
880
  diagnostics,
813
881
  delegation,
814
882
  secretGuard,
@@ -817,6 +885,7 @@ function printDashboard(data) {
817
885
  observability,
818
886
  instrumentation,
819
887
  readiness,
888
+ interventionPolicy,
820
889
  settingsStatus,
821
890
  team,
822
891
  templateLibrary,
@@ -862,6 +931,20 @@ function printDashboard(data) {
862
931
  console.log(` Top Next Fix : ${harness.topRecommendations[0].type} (${harness.topRecommendations[0].count} lessons)`);
863
932
  }
864
933
 
934
+ console.log('');
935
+ console.log('🧠 Learned Policy');
936
+ console.log(` Enabled : ${interventionPolicy.enabled ? 'yes' : 'no'}`);
937
+ console.log(` Examples : ${interventionPolicy.exampleCount}`);
938
+ console.log(` Train Accuracy : ${Math.round((interventionPolicy.metrics.trainingAccuracy || 0) * 100)}%`);
939
+ console.log(` Holdout Accuracy : ${Math.round((interventionPolicy.metrics.holdoutAccuracy || 0) * 100)}%`);
940
+ console.log(` Recent Pressure : ${Math.round((interventionPolicy.nonAllowRate || 0) * 100)}% non-allow`);
941
+ if (interventionPolicy.updatedAt) {
942
+ console.log(` Updated : ${interventionPolicy.updatedAt}`);
943
+ }
944
+ if (interventionPolicy.topTokens && interventionPolicy.topTokens.deny && interventionPolicy.topTokens.deny[0]) {
945
+ console.log(` Top Deny Signal : ${interventionPolicy.topTokens.deny[0].token}`);
946
+ }
947
+
865
948
  console.log('');
866
949
  console.log('🎯 North Star');
867
950
  console.log(` Weekly Proof Runs: ${analytics.northStar.weeklyActiveProofBackedWorkflowRuns}`);
@@ -1043,6 +1126,7 @@ module.exports = {
1043
1126
  computeSystemHealth,
1044
1127
  computeEfficiencyMetrics,
1045
1128
  computeHarnessOverview,
1129
+ getInterventionPolicySummary,
1046
1130
  computeAnalyticsSummary,
1047
1131
  computeSecretGuardStats,
1048
1132
  computeObservabilityStats,
@@ -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
+ }
@@ -377,6 +377,10 @@ function appendDiagnosticRecord(params = {}) {
377
377
  timestamp: params.timestamp || new Date().toISOString(),
378
378
  };
379
379
  appendJSONL(DIAGNOSTIC_LOG_PATH, record);
380
+ try {
381
+ const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
382
+ trainAndPersistInterventionPolicy(getFeedbackPaths().FEEDBACK_DIR);
383
+ } catch { /* non-critical */ }
380
384
  return record;
381
385
  }
382
386
 
@@ -1090,6 +1094,10 @@ function captureFeedback(params) {
1090
1094
  const riskScorer = getRiskScorerModule();
1091
1095
  if (riskScorer) riskScorer.trainAndPersistRiskModel(FEEDBACK_DIR);
1092
1096
  } catch { /* non-critical */ }
1097
+ try {
1098
+ const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
1099
+ trainAndPersistInterventionPolicy(FEEDBACK_DIR);
1100
+ } catch { /* non-critical */ }
1093
1101
  updateStatuslineWithLesson({
1094
1102
  accepted: false,
1095
1103
  signal,
@@ -1127,6 +1135,10 @@ function captureFeedback(params) {
1127
1135
  const riskScorer = getRiskScorerModule();
1128
1136
  if (riskScorer) riskScorer.trainAndPersistRiskModel(FEEDBACK_DIR);
1129
1137
  } catch { /* non-critical */ }
1138
+ try {
1139
+ const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
1140
+ trainAndPersistInterventionPolicy(FEEDBACK_DIR);
1141
+ } catch { /* non-critical */ }
1130
1142
  return {
1131
1143
  accepted: false,
1132
1144
  signalLogged: true,
@@ -1285,6 +1297,10 @@ function captureFeedback(params) {
1285
1297
  const riskScorer = getRiskScorerModule();
1286
1298
  if (riskScorer) riskScorer.trainAndPersistRiskModel(FEEDBACK_DIR);
1287
1299
  } catch { /* non-critical */ }
1300
+ try {
1301
+ const { trainAndPersistInterventionPolicy } = require('./intervention-policy');
1302
+ trainAndPersistInterventionPolicy(FEEDBACK_DIR);
1303
+ } catch { /* non-critical */ }
1288
1304
  try {
1289
1305
  const toolName = feedbackEvent.toolName || feedbackEvent.tool_name || 'unknown';
1290
1306
  const toolInput = feedbackEvent.context || feedbackEvent.input || '';
@@ -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
  };