thumbgate 1.14.1 → 1.15.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 (43) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/mcp/server-card.json +1 -1
  4. package/README.md +2 -1
  5. package/adapters/claude/.mcp.json +2 -2
  6. package/adapters/mcp/server-stdio.js +8 -1
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bin/cli.js +54 -0
  9. package/config/enforcement.json +59 -7
  10. package/config/gates/default.json +33 -0
  11. package/config/mcp-allowlists.json +4 -0
  12. package/config/merge-quality-checks.json +2 -1
  13. package/package.json +17 -5
  14. package/public/codex-plugin.html +7 -1
  15. package/public/dashboard.html +23 -2
  16. package/public/index.html +20 -2
  17. package/public/learn.html +39 -0
  18. package/public/lessons.html +25 -1
  19. package/public/numbers.html +271 -0
  20. package/public/pro.html +7 -1
  21. package/scripts/cli-feedback.js +2 -1
  22. package/scripts/cli-schema.js +43 -4
  23. package/scripts/commercial-offer.js +1 -1
  24. package/scripts/contextfs.js +214 -32
  25. package/scripts/feedback-loop.js +49 -5
  26. package/scripts/harness-selector.js +132 -0
  27. package/scripts/lesson-canonical.js +181 -0
  28. package/scripts/lesson-db.js +71 -10
  29. package/scripts/lesson-synthesis.js +23 -2
  30. package/scripts/native-messaging-audit.js +514 -0
  31. package/scripts/pr-manager.js +47 -7
  32. package/scripts/profile-router.js +16 -1
  33. package/scripts/rule-validator.js +285 -0
  34. package/scripts/seo-gsd.js +182 -2
  35. package/scripts/tool-registry.js +12 -0
  36. package/skills/thumbgate/SKILL.md +1 -1
  37. package/src/api/server.js +53 -0
  38. package/.claude-plugin/README.md +0 -170
  39. package/adapters/README.md +0 -12
  40. package/skills/agent-memory/SKILL.md +0 -97
  41. package/skills/solve-architecture-autonomy/SKILL.md +0 -17
  42. package/skills/solve-architecture-autonomy/tool.js +0 -33
  43. package/skills/thumbgate-feedback/SKILL.md +0 -49
@@ -0,0 +1,514 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+
8
+ const ROOT = path.join(__dirname, '..');
9
+
10
+ const BROWSER_TARGETS = Object.freeze({
11
+ darwin: [
12
+ browserTarget(
13
+ 'chrome',
14
+ 'Google Chrome',
15
+ ['Library', 'Application Support', 'Google', 'Chrome', 'NativeMessagingHosts'],
16
+ ['/Applications/Google Chrome.app', '~/Applications/Google Chrome.app']
17
+ ),
18
+ browserTarget(
19
+ 'edge',
20
+ 'Microsoft Edge',
21
+ ['Library', 'Application Support', 'Microsoft Edge', 'NativeMessagingHosts'],
22
+ ['/Applications/Microsoft Edge.app', '~/Applications/Microsoft Edge.app']
23
+ ),
24
+ browserTarget(
25
+ 'brave',
26
+ 'Brave',
27
+ ['Library', 'Application Support', 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts'],
28
+ ['/Applications/Brave Browser.app', '~/Applications/Brave Browser.app']
29
+ ),
30
+ browserTarget(
31
+ 'arc',
32
+ 'Arc',
33
+ ['Library', 'Application Support', 'Arc', 'User Data', 'NativeMessagingHosts'],
34
+ ['/Applications/Arc.app', '~/Applications/Arc.app']
35
+ ),
36
+ browserTarget(
37
+ 'chromium',
38
+ 'Chromium',
39
+ ['Library', 'Application Support', 'Chromium', 'NativeMessagingHosts'],
40
+ ['/Applications/Chromium.app', '~/Applications/Chromium.app']
41
+ ),
42
+ browserTarget(
43
+ 'vivaldi',
44
+ 'Vivaldi',
45
+ ['Library', 'Application Support', 'Vivaldi', 'NativeMessagingHosts'],
46
+ ['/Applications/Vivaldi.app', '~/Applications/Vivaldi.app']
47
+ ),
48
+ browserTarget(
49
+ 'opera',
50
+ 'Opera',
51
+ ['Library', 'Application Support', 'com.operasoftware.Opera', 'NativeMessagingHosts'],
52
+ ['/Applications/Opera.app', '~/Applications/Opera.app']
53
+ ),
54
+ ],
55
+ linux: [
56
+ browserTarget(
57
+ 'chrome',
58
+ 'Google Chrome',
59
+ ['.config', 'google-chrome', 'NativeMessagingHosts'],
60
+ ['/usr/bin/google-chrome', '/opt/google/chrome/chrome']
61
+ ),
62
+ browserTarget(
63
+ 'edge',
64
+ 'Microsoft Edge',
65
+ ['.config', 'microsoft-edge', 'NativeMessagingHosts'],
66
+ ['/usr/bin/microsoft-edge', '/opt/microsoft/msedge/msedge']
67
+ ),
68
+ browserTarget(
69
+ 'brave',
70
+ 'Brave',
71
+ ['.config', 'BraveSoftware', 'Brave-Browser', 'NativeMessagingHosts'],
72
+ ['/usr/bin/brave-browser', '/opt/brave.com/brave/brave-browser']
73
+ ),
74
+ browserTarget(
75
+ 'chromium',
76
+ 'Chromium',
77
+ ['.config', 'chromium', 'NativeMessagingHosts'],
78
+ ['/usr/bin/chromium', '/usr/bin/chromium-browser']
79
+ ),
80
+ browserTarget(
81
+ 'vivaldi',
82
+ 'Vivaldi',
83
+ ['.config', 'vivaldi', 'NativeMessagingHosts'],
84
+ ['/usr/bin/vivaldi', '/opt/vivaldi/vivaldi']
85
+ ),
86
+ browserTarget(
87
+ 'opera',
88
+ 'Opera',
89
+ ['.config', 'opera', 'NativeMessagingHosts'],
90
+ ['/usr/bin/opera', '/usr/lib/x86_64-linux-gnu/opera/opera']
91
+ ),
92
+ ],
93
+ win32: [],
94
+ });
95
+
96
+ const AI_VENDOR_PATTERNS = Object.freeze([
97
+ { vendor: 'Anthropic', pattern: /\banthropic\b|\bclaude\b/i },
98
+ { vendor: 'OpenAI', pattern: /\bopenai\b|\bcodex\b|\bchatgpt\b/i },
99
+ { vendor: 'Google', pattern: /\bgoogle\b|\bgemini\b/i },
100
+ { vendor: 'Cursor', pattern: /\bcursor\b/i },
101
+ { vendor: 'Perplexity', pattern: /\bperplexity\b/i },
102
+ { vendor: 'Browserbase', pattern: /\bbrowserbase\b|\bstagehand\b/i },
103
+ ]);
104
+
105
+ function browserTarget(key, displayName, manifestDirParts, installHints) {
106
+ return Object.freeze({ key, displayName, manifestDirParts, installHints });
107
+ }
108
+
109
+ function normalizePlatform(platform) {
110
+ const normalized = String(platform || process.platform).toLowerCase();
111
+ if (normalized === 'mac' || normalized === 'macos' || normalized === 'darwin') return 'darwin';
112
+ if (normalized === 'linux') return 'linux';
113
+ if (normalized === 'windows' || normalized === 'win32') return 'win32';
114
+ return normalized;
115
+ }
116
+
117
+ function resolveInstallHint(hint, homeDir) {
118
+ return hint.startsWith('~/')
119
+ ? path.join(homeDir, hint.slice(2))
120
+ : hint;
121
+ }
122
+
123
+ function getBrowserTargets(platform) {
124
+ return BROWSER_TARGETS[normalizePlatform(platform)] || [];
125
+ }
126
+
127
+ function listJsonFiles(dirPath) {
128
+ try {
129
+ return fs.readdirSync(dirPath, { withFileTypes: true })
130
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.json'))
131
+ .map((entry) => path.join(dirPath, entry.name));
132
+ } catch {
133
+ return [];
134
+ }
135
+ }
136
+
137
+ function guessVendor(manifestPath, manifest) {
138
+ const haystack = [
139
+ manifestPath,
140
+ manifest?.name,
141
+ manifest?.description,
142
+ manifest?.path,
143
+ ...(Array.isArray(manifest?.allowed_origins) ? manifest.allowed_origins : []),
144
+ ]
145
+ .filter(Boolean)
146
+ .join(' ');
147
+
148
+ for (const candidate of AI_VENDOR_PATTERNS) {
149
+ if (candidate.pattern.test(haystack)) {
150
+ return candidate.vendor;
151
+ }
152
+ }
153
+
154
+ return 'Unknown';
155
+ }
156
+
157
+ function isAiVendor(vendor) {
158
+ return vendor !== 'Unknown';
159
+ }
160
+
161
+ function extractExtensionId(origin) {
162
+ const match = /^chrome-extension:\/\/([^/]+)/i.exec(String(origin || ''));
163
+ return match?.[1] || null;
164
+ }
165
+
166
+ function readManifest(filePath) {
167
+ const raw = fs.readFileSync(filePath, 'utf8');
168
+ try {
169
+ const parsed = JSON.parse(raw);
170
+ return { raw, parsed, parseError: null };
171
+ } catch (error) {
172
+ return { raw, parsed: null, parseError: error.message };
173
+ }
174
+ }
175
+
176
+ function guessBrowserInstalled(target, { platform, homeDir, explicitHomeDir }) {
177
+ const normalizedPlatform = normalizePlatform(platform);
178
+ if (normalizedPlatform !== 'darwin' && normalizedPlatform !== 'linux') {
179
+ return null;
180
+ }
181
+
182
+ const installHints = Array.isArray(target.installHints) ? target.installHints : [];
183
+ if (installHints.length === 0) return null;
184
+ return installHints.some((hint) => {
185
+ if (explicitHomeDir && !hint.startsWith('~/')) {
186
+ return false;
187
+ }
188
+ return fs.existsSync(resolveInstallHint(hint, homeDir));
189
+ });
190
+ }
191
+
192
+ function describeFinding(code, severity, message) {
193
+ return { code, severity, message };
194
+ }
195
+
196
+ function analyzeManifestEntry(entry) {
197
+ const findings = [];
198
+
199
+ if (entry.parseError) {
200
+ findings.push(describeFinding(
201
+ 'invalid_manifest_json',
202
+ 'high',
203
+ 'Manifest JSON is invalid, so the host registration cannot be reviewed safely.'
204
+ ));
205
+ return findings;
206
+ }
207
+
208
+ if (!entry.hostName) {
209
+ findings.push(describeFinding(
210
+ 'missing_host_name',
211
+ 'medium',
212
+ 'Manifest is missing the required host name.'
213
+ ));
214
+ }
215
+
216
+ if (!entry.hostPath) {
217
+ findings.push(describeFinding(
218
+ 'missing_host_path',
219
+ 'high',
220
+ 'Manifest does not declare a host binary path.'
221
+ ));
222
+ } else if (!entry.hostPathExists) {
223
+ findings.push(describeFinding(
224
+ 'missing_host_binary',
225
+ 'high',
226
+ 'Manifest points at a host binary that is not present on disk.'
227
+ ));
228
+ }
229
+
230
+ if (entry.allowedOriginsCount > 0) {
231
+ findings.push(describeFinding(
232
+ 'preauthorized_extension_bridge',
233
+ 'medium',
234
+ `Manifest pre-authorizes ${entry.allowedOriginsCount} browser extension origin${entry.allowedOriginsCount === 1 ? '' : 's'}.`
235
+ ));
236
+ }
237
+
238
+ if (entry.aiBridge) {
239
+ findings.push(describeFinding(
240
+ 'ai_browser_bridge',
241
+ 'medium',
242
+ `${entry.vendor} browser bridge detected through native messaging.`
243
+ ));
244
+ }
245
+
246
+ if (entry.browserInstalledGuess === false) {
247
+ findings.push(describeFinding(
248
+ 'browser_not_detected',
249
+ 'medium',
250
+ `${entry.browser} is not detected in the usual install locations for this machine.`
251
+ ));
252
+ }
253
+
254
+ if (entry.aiBridge && entry.allowedOriginsCount > 0 && entry.browserInstalledGuess === false) {
255
+ findings.push(describeFinding(
256
+ 'dormant_ai_browser_bridge',
257
+ 'high',
258
+ 'An AI browser bridge is registered for a browser that is not detected locally, which expands future attack surface without an obvious active integration.'
259
+ ));
260
+ }
261
+
262
+ return findings;
263
+ }
264
+
265
+ function shouldIncludeEntry(entry, options = {}) {
266
+ if (options.aiOnly !== true) return true;
267
+ return entry.aiBridge;
268
+ }
269
+
270
+ function getAllowedOrigins(manifest) {
271
+ return Array.isArray(manifest?.allowed_origins)
272
+ ? manifest.allowed_origins.filter((origin) => typeof origin === 'string' && origin.trim())
273
+ : [];
274
+ }
275
+
276
+ function buildManifestEntry(target, manifestPath, auditOptions) {
277
+ const { parsed, parseError } = readManifest(manifestPath);
278
+ const allowedOrigins = getAllowedOrigins(parsed);
279
+ const hostPath = typeof parsed?.path === 'string' ? parsed.path : null;
280
+ const vendor = guessVendor(manifestPath, parsed);
281
+ const entry = {
282
+ browser: target.displayName,
283
+ browserKey: target.key,
284
+ manifestPath,
285
+ manifestDir: path.join(auditOptions.homeDir, ...target.manifestDirParts),
286
+ hostName: typeof parsed?.name === 'string' ? parsed.name : null,
287
+ hostPath,
288
+ hostPathExists: hostPath ? fs.existsSync(hostPath) : false,
289
+ allowedOrigins,
290
+ allowedOriginsCount: allowedOrigins.length,
291
+ extensionIds: allowedOrigins.map(extractExtensionId).filter(Boolean),
292
+ vendor,
293
+ aiBridge: isAiVendor(vendor),
294
+ browserInstalledGuess: guessBrowserInstalled(target, auditOptions),
295
+ parseError,
296
+ };
297
+ return {
298
+ ...entry,
299
+ findings: analyzeManifestEntry(entry),
300
+ };
301
+ }
302
+
303
+ function collectTargetEntries(target, auditOptions, options) {
304
+ const manifestDir = path.join(auditOptions.homeDir, ...target.manifestDirParts);
305
+ return listJsonFiles(manifestDir)
306
+ .map((manifestPath) => buildManifestEntry(target, manifestPath, auditOptions))
307
+ .filter((entry) => shouldIncludeEntry(entry, options));
308
+ }
309
+
310
+ function buildAuditOptions(options = {}) {
311
+ return {
312
+ platform: normalizePlatform(options.platform),
313
+ homeDir: path.resolve(options.homeDir || os.homedir()),
314
+ explicitHomeDir: typeof options.homeDir === 'string' && options.homeDir.trim().length > 0,
315
+ };
316
+ }
317
+
318
+ function buildWindowsAudit(auditOptions) {
319
+ return {
320
+ platform: auditOptions.platform,
321
+ homeDir: auditOptions.homeDir,
322
+ entries: [],
323
+ notes: ['Windows native messaging is registry-based; this file audit focuses on macOS and Linux host manifests.'],
324
+ };
325
+ }
326
+
327
+ function collectNativeMessagingEntries(options = {}) {
328
+ const auditOptions = buildAuditOptions(options);
329
+ if (auditOptions.platform === 'win32') {
330
+ return buildWindowsAudit(auditOptions);
331
+ }
332
+
333
+ const entries = getBrowserTargets(auditOptions.platform)
334
+ .flatMap((target) => collectTargetEntries(target, auditOptions, options));
335
+ return {
336
+ platform: auditOptions.platform,
337
+ homeDir: auditOptions.homeDir,
338
+ entries,
339
+ notes: [],
340
+ };
341
+ }
342
+
343
+ function summarizeFindings(entries) {
344
+ return entries.flatMap((entry) => entry.findings.map((finding) => ({
345
+ browser: entry.browser,
346
+ manifestPath: entry.manifestPath,
347
+ hostName: entry.hostName,
348
+ vendor: entry.vendor,
349
+ ...finding,
350
+ })));
351
+ }
352
+
353
+ function buildRecommendations(findings, options = {}) {
354
+ const recommendations = [
355
+ 'Review every native messaging host that grants browser automation or extension bridge access before allowing high-risk tasks.',
356
+ 'Prefer ask-before-acting modes for browser-use agents until connector scope, extension permissions, and revocation steps are explicit.',
357
+ 'Use ThumbGate to gate new connector installs and require explicit approval before cross-app integrations become part of the workflow.',
358
+ ];
359
+
360
+ if (findings.some((finding) => finding.code === 'dormant_ai_browser_bridge')) {
361
+ recommendations.unshift('Remove or disable AI browser bridge manifests for browsers you did not intentionally integrate, then re-enable only after explicit approval.');
362
+ }
363
+
364
+ if (findings.some((finding) => finding.code === 'missing_host_binary')) {
365
+ recommendations.push('Clean up broken host registrations so browsers do not keep stale native messaging entries that point at missing binaries.');
366
+ }
367
+
368
+ if (options.aiOnly === true) {
369
+ recommendations.push('Re-run the full audit without --ai-only when you need a complete inventory of non-AI browser bridge registrations.');
370
+ }
371
+
372
+ return recommendations;
373
+ }
374
+
375
+ function buildNativeMessagingAudit(options = {}) {
376
+ const collected = collectNativeMessagingEntries(options);
377
+ const findings = summarizeFindings(collected.entries);
378
+ const highSeverityCount = findings.filter((finding) => finding.severity === 'high').length;
379
+ const mediumSeverityCount = findings.filter((finding) => finding.severity === 'medium').length;
380
+ const browsersCovered = [...new Set(collected.entries.map((entry) => entry.browser))];
381
+ const aiBridgeCount = collected.entries.filter((entry) => entry.aiBridge).length;
382
+
383
+ let status = 'clear';
384
+ if (highSeverityCount > 0) {
385
+ status = 'review';
386
+ } else if (mediumSeverityCount > 0) {
387
+ status = 'watch';
388
+ }
389
+
390
+ return {
391
+ name: 'thumbgate-native-messaging-audit',
392
+ generatedAt: new Date().toISOString(),
393
+ platform: collected.platform,
394
+ homeDir: collected.homeDir,
395
+ status,
396
+ summary: {
397
+ manifestCount: collected.entries.length,
398
+ browsersCovered: browsersCovered.length,
399
+ aiBridgeCount,
400
+ highSeverityCount,
401
+ mediumSeverityCount,
402
+ },
403
+ notes: collected.notes,
404
+ manifests: collected.entries,
405
+ findings,
406
+ recommendations: buildRecommendations(findings, options),
407
+ };
408
+ }
409
+
410
+ function appendBlock(lines, heading, entries) {
411
+ if (entries.length === 0) return;
412
+ lines.push('', heading, ...entries);
413
+ }
414
+
415
+ function formatFindingLine(finding) {
416
+ return ` - [${finding.severity}] ${finding.browser}: ${finding.message}`;
417
+ }
418
+
419
+ function formatManifestLines(entry) {
420
+ const lines = [
421
+ ` - ${entry.browser} -> ${entry.hostName || path.basename(entry.manifestPath)}`,
422
+ ` manifest: ${entry.manifestPath}`,
423
+ ];
424
+ if (entry.hostPath) {
425
+ lines.push(` host: ${entry.hostPath}${entry.hostPathExists ? '' : ' (missing)'}`);
426
+ }
427
+ if (entry.allowedOriginsCount > 0) {
428
+ lines.push(` allowed origins: ${entry.allowedOriginsCount}`);
429
+ }
430
+ return lines;
431
+ }
432
+
433
+ function formatNativeMessagingAudit(report) {
434
+ const lines = [
435
+ 'ThumbGate Native Messaging Audit',
436
+ `Status : ${report.status}`,
437
+ `Hosts : ${report.summary.manifestCount} manifest${report.summary.manifestCount === 1 ? '' : 's'} across ${report.summary.browsersCovered} browser${report.summary.browsersCovered === 1 ? '' : 's'}`,
438
+ `AI : ${report.summary.aiBridgeCount} AI browser bridge${report.summary.aiBridgeCount === 1 ? '' : 's'}`,
439
+ ];
440
+ appendBlock(lines, 'Findings:', report.findings.map(formatFindingLine));
441
+ appendBlock(lines, 'Registered manifests:', report.manifests.flatMap(formatManifestLines));
442
+ appendBlock(lines, 'Recommendations:', report.recommendations.map((recommendation) => ` - ${recommendation}`));
443
+ if (report.notes.length > 0) {
444
+ lines.splice(4, 0, '', ...report.notes.map((note) => `Note : ${note}`));
445
+ }
446
+ lines.push('');
447
+ return `${lines.join('\n')}`;
448
+ }
449
+
450
+ function parseArgs(argv) {
451
+ const args = {};
452
+ for (let index = 0; index < argv.length; index++) {
453
+ const token = argv[index];
454
+ if (!token.startsWith('--')) continue;
455
+ const [rawKey, inlineValue] = token.slice(2).split('=');
456
+ const key = rawKey;
457
+ if (inlineValue !== undefined) {
458
+ args[key] = inlineValue;
459
+ continue;
460
+ }
461
+ const next = argv[index + 1];
462
+ if (next && !next.startsWith('--')) {
463
+ args[key] = next;
464
+ index += 1;
465
+ continue;
466
+ }
467
+ args[key] = true;
468
+ }
469
+ return args;
470
+ }
471
+
472
+ function parseBooleanFlag(value) {
473
+ return value === true || value === 'true';
474
+ }
475
+
476
+ function isMainModule() {
477
+ return Boolean(process.argv[1] && path.resolve(process.argv[1]) === __filename);
478
+ }
479
+
480
+ function main(argv = process.argv.slice(2)) {
481
+ const args = parseArgs(argv);
482
+ const report = buildNativeMessagingAudit({
483
+ homeDir: args['home-dir'],
484
+ platform: args.platform,
485
+ aiOnly: parseBooleanFlag(args['ai-only']),
486
+ });
487
+
488
+ if (args.json) {
489
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
490
+ return;
491
+ }
492
+
493
+ process.stdout.write(formatNativeMessagingAudit(report));
494
+ }
495
+
496
+ if (isMainModule()) {
497
+ try {
498
+ main();
499
+ } catch (error) {
500
+ console.error(error?.message || error);
501
+ process.exit(1);
502
+ }
503
+ }
504
+
505
+ module.exports = {
506
+ AI_VENDOR_PATTERNS,
507
+ BROWSER_TARGETS,
508
+ buildNativeMessagingAudit,
509
+ collectNativeMessagingEntries,
510
+ formatNativeMessagingAudit,
511
+ getBrowserTargets,
512
+ guessVendor,
513
+ normalizePlatform,
514
+ };
@@ -90,9 +90,18 @@ function resolveGhBinary(options = {}) {
90
90
  throw new Error(`Unable to locate GH CLI in fixed paths: ${candidates.join(', ')}`);
91
91
  }
92
92
 
93
+ function buildGhEnv(baseEnv = process.env) {
94
+ const env = { ...baseEnv };
95
+ if (!env.GH_TOKEN && !env.GITHUB_TOKEN && env.GH_PAT) {
96
+ env.GH_TOKEN = env.GH_PAT;
97
+ }
98
+ return env;
99
+ }
100
+
93
101
  function runGh(args, options = {}) {
94
102
  return spawnSync(resolveGhBinary(options), assertSafeGhArgs(args), {
95
103
  encoding: 'utf-8',
104
+ env: buildGhEnv(options.env || process.env),
96
105
  stdio: ['ignore', 'pipe', 'pipe'],
97
106
  });
98
107
  }
@@ -154,7 +163,8 @@ function isOpenPr(pr) {
154
163
 
155
164
  function loadManagedPrs(prNumber = '', runner = runGh) {
156
165
  if (prNumber) {
157
- return [getPrStatus(prNumber, runner)];
166
+ const explicitPr = getPrStatus(prNumber, runner);
167
+ return isOpenPr(explicitPr) ? [explicitPr] : [];
158
168
  }
159
169
 
160
170
  const currentBranchPr = getPrStatus('', runner);
@@ -336,11 +346,40 @@ function waitForMergeCommit(prNumber, runner = runGh, options = {}) {
336
346
  };
337
347
  }
338
348
 
349
+ function submitTrunkMergeRequest(prNumber, runner = runGh) {
350
+ const normalizedPrNumber = normalizePrNumber(prNumber, { allowEmpty: false });
351
+ const args = ['pr', 'comment', normalizedPrNumber, '--body', '/trunk merge'];
352
+ console.log(`[PR Manager] Requesting Trunk merge queue for PR #${normalizedPrNumber}...`);
353
+ const result = runner(args);
354
+ if (result.status !== 0) {
355
+ console.error(`[PR Manager] Queue request failed: ${formatGhError(result)}`);
356
+ return { ok: false, mode: 'failed', args, error: formatGhError(result) };
357
+ }
358
+
359
+ console.log(`[PR Manager] Queue request accepted for PR #${normalizedPrNumber} (/trunk merge).`);
360
+ return {
361
+ ok: true,
362
+ mode: 'queued',
363
+ args,
364
+ finalized: false,
365
+ merged: false,
366
+ reason: 'merge_commit_pending',
367
+ };
368
+ }
369
+
339
370
  /**
340
371
  * Perform autonomous merge
341
372
  */
342
- function performMerge(prNumber, runner = runGh, options = {}) {
343
- const normalizedPrNumber = normalizePrNumber(prNumber, { allowEmpty: false });
373
+ function performMerge(prInput, runner = runGh, options = {}) {
374
+ const pr = (prInput && typeof prInput === 'object')
375
+ ? prInput
376
+ : { number: prInput, baseRefName: options.baseRefName || '' };
377
+ const normalizedPrNumber = normalizePrNumber(pr.number, { allowEmpty: false });
378
+
379
+ if (String(pr.baseRefName || '').toLowerCase() === 'main') {
380
+ return submitTrunkMergeRequest(normalizedPrNumber, runner);
381
+ }
382
+
344
383
  const args = ['pr', 'merge', normalizedPrNumber, '--squash', '--delete-branch'];
345
384
  console.log(`[PR Manager] Initiating protected squash merge for PR #${normalizedPrNumber}...`);
346
385
  const result = runner(args);
@@ -352,10 +391,10 @@ function performMerge(prNumber, runner = runGh, options = {}) {
352
391
  ? { finalized: false, merged: false, reason: 'merge_commit_pending' }
353
392
  : waitForMergeCommit(normalizedPrNumber, runner, options);
354
393
  return { ok: true, mode, args, ...mergeStatus };
355
- } else {
356
- console.error(`[PR Manager] Merge failed: ${formatGhError(result)}`);
357
- return { ok: false, mode: 'failed', args, error: formatGhError(result) };
358
394
  }
395
+
396
+ console.error(`[PR Manager] Merge failed: ${formatGhError(result)}`);
397
+ return { ok: false, mode: 'failed', args, error: formatGhError(result) };
359
398
  }
360
399
 
361
400
  async function managePrs(prNumber = '', runner = runGh, options = {}) {
@@ -370,7 +409,7 @@ async function managePrs(prNumber = '', runner = runGh, options = {}) {
370
409
  for (const pr of prs) {
371
410
  const outcome = await resolveBlockers(pr, runner);
372
411
  if (outcome.status === 'ready') {
373
- const mergeResult = performMerge(pr.number, runner, options);
412
+ const mergeResult = performMerge(pr, runner, options);
374
413
  outcome.mergeRequested = mergeResult.ok;
375
414
  outcome.mergeMode = mergeResult.mode;
376
415
  if (mergeResult.mergeCommit) {
@@ -406,6 +445,7 @@ if (require.main === module) {
406
445
 
407
446
  module.exports = {
408
447
  assertSafeGhArgs,
448
+ buildGhEnv,
409
449
  getPrStatus,
410
450
  getPrChecks,
411
451
  listOpenPrs,
@@ -107,6 +107,15 @@ function loadProfilesLazy() {
107
107
  }
108
108
  }
109
109
 
110
+ const PROFILE_RESTRICTIVENESS_ORDER = Object.freeze([
111
+ 'locked',
112
+ 'readonly',
113
+ 'essential',
114
+ 'commerce',
115
+ 'dispatch',
116
+ 'default',
117
+ ]);
118
+
110
119
  /**
111
120
  * Find the most restrictive (smallest) profile that includes the given tool.
112
121
  * Profile restrictiveness = fewer tools allowed = more restricted.
@@ -114,11 +123,17 @@ function loadProfilesLazy() {
114
123
  function findMostRestrictiveProfile(toolName) {
115
124
  const policy = loadProfilesLazy();
116
125
  if (!policy || !policy.profiles) return null;
126
+ const profileRank = new Map(PROFILE_RESTRICTIVENESS_ORDER.map((name, index) => [name, index]));
117
127
 
118
128
  // Sort profiles by number of tools (most restrictive first)
119
129
  const candidates = Object.entries(policy.profiles)
120
130
  .filter(([, tools]) => tools.includes(toolName))
121
- .sort((a, b) => a[1].length - b[1].length);
131
+ .sort((a, b) => {
132
+ const sizeDelta = a[1].length - b[1].length;
133
+ if (sizeDelta !== 0) return sizeDelta;
134
+ return (profileRank.get(a[0]) ?? Number.MAX_SAFE_INTEGER)
135
+ - (profileRank.get(b[0]) ?? Number.MAX_SAFE_INTEGER);
136
+ });
122
137
 
123
138
  if (candidates.length === 0) return null;
124
139
  return candidates[0][0]; // most restrictive profile that has this tool