thumbgate 1.29.1 → 1.30.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 (77) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +61 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/forge/forge.yaml +3 -3
  8. package/adapters/mcp/server-stdio.js +164 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/bin/cli.js +7 -5
  11. package/commands/dashboard.md +11 -1
  12. package/commands/thumbgate-dashboard.md +23 -8
  13. package/config/agent-outcome-monitor-thresholds.json +63 -0
  14. package/config/evals/agent-outcomes-baseline.json +17 -0
  15. package/config/evals/agent-outcomes-golden.json +412 -0
  16. package/config/evals/prompt-eval-baseline.json +23 -0
  17. package/config/mcp-allowlists.json +26 -2
  18. package/config/post-deploy-marketing-pages.json +26 -1
  19. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  20. package/openapi/openapi.yaml +235 -0
  21. package/package.json +55 -11
  22. package/public/architecture.html +130 -0
  23. package/public/assets/diagrams/agent-integration.png +0 -0
  24. package/public/assets/diagrams/before-after.svg +21 -0
  25. package/public/assets/diagrams/decision.svg +36 -0
  26. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  27. package/public/assets/diagrams/loop.svg +34 -0
  28. package/public/assets/diagrams/plugin-topology.png +0 -0
  29. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/eval-scorecard.html +195 -0
  34. package/public/eval-scorecard.json +18 -0
  35. package/public/evaluations.html +168 -0
  36. package/public/index.html +6 -3
  37. package/public/numbers.html +2 -2
  38. package/public/whitepaper.html +189 -0
  39. package/scripts/activation-quickstart.js +1 -0
  40. package/scripts/agent-outcome-eval.js +130 -0
  41. package/scripts/agent-outcome-monitor.js +331 -0
  42. package/scripts/agent-reasoning-traces.js +8 -9
  43. package/scripts/async-job-runner.js +107 -13
  44. package/scripts/billing.js +3 -1
  45. package/scripts/claude-feedback-sync.js +3 -2
  46. package/scripts/cli-feedback.js +13 -7
  47. package/scripts/cross-encoder-reranker.js +3 -0
  48. package/scripts/durability/step.js +121 -12
  49. package/scripts/feedback-aggregate.js +5 -2
  50. package/scripts/feedback-loop.js +244 -182
  51. package/scripts/gates-engine.js +512 -22
  52. package/scripts/generate-case-study-outreach.js +253 -0
  53. package/scripts/generate-eval-scorecard.js +276 -0
  54. package/scripts/growth-campaigns.js +183 -0
  55. package/scripts/human-escalation.js +265 -0
  56. package/scripts/hybrid-feedback-context.js +93 -50
  57. package/scripts/jsonl-watcher.js +1 -0
  58. package/scripts/judge-reward-function.js +30 -18
  59. package/scripts/lesson-inference.js +23 -4
  60. package/scripts/lesson-retrieval.js +71 -4
  61. package/scripts/lesson-search.js +26 -3
  62. package/scripts/mcp-config.js +26 -5
  63. package/scripts/mcp-oauth.js +37 -2
  64. package/scripts/model-eval.js +308 -0
  65. package/scripts/parallel-workflow-orchestrator.js +86 -22
  66. package/scripts/prompt-eval.js +81 -4
  67. package/scripts/published-cli.js +11 -1
  68. package/scripts/refresh-proof-pack.js +261 -0
  69. package/scripts/risk-scorer.js +144 -15
  70. package/scripts/schedule-manager.js +249 -0
  71. package/scripts/statusline-local-stats.js +1 -1
  72. package/scripts/task-outcomes.js +425 -0
  73. package/scripts/thumbgate-bench.js +13 -0
  74. package/scripts/tool-contract-validator.js +287 -59
  75. package/scripts/tool-kpi-tracker.js +124 -0
  76. package/scripts/tool-registry.js +192 -1
  77. package/src/api/server.js +355 -89
@@ -12,6 +12,7 @@
12
12
 
13
13
  const fs = require('node:fs');
14
14
  const path = require('node:path');
15
+ const { validateStructuredOutput } = require('./tool-contract-validator');
15
16
 
16
17
  const DEFAULT_CRITERIA = [
17
18
  {
@@ -117,15 +118,19 @@ function buildCompositeReward(sample = {}, options = {}) {
117
118
  }
118
119
 
119
120
  const judge = runJudgeSafely(sample, options.judge);
120
- const judgeScore = judge.ok ? judge.score : 0.5;
121
- const score = round((deterministic.score * 0.65) + (judgeScore * 0.35));
121
+ const score = judge.ok
122
+ ? round((deterministic.score * 0.65) + (judge.score * 0.35))
123
+ : deterministic.score;
122
124
  return {
123
125
  score,
124
- label: rewardLabel(score),
126
+ label: judge.ok ? rewardLabel(score) : 'deterministic_only',
125
127
  deterministic,
126
128
  judge,
127
- failureMode: judge.ok ? [] : ['judge_error_neutral_reward'],
128
- recommendation: rewardRecommendation(score),
129
+ scoringMode: judge.ok ? 'deterministic_plus_llm_judge' : 'deterministic_only',
130
+ failureMode: judge.ok ? [] : [judge.available ? 'judge_error' : 'judge_unavailable'],
131
+ recommendation: judge.ok
132
+ ? rewardRecommendation(score)
133
+ : 'Use deterministic results only; do not represent unavailable judge evidence as a neutral judgment.',
129
134
  };
130
135
  }
131
136
 
@@ -148,7 +153,8 @@ function buildPreferenceJudgment(a, b, options = {}) {
148
153
  function buildJudgeReadinessReport(samples = [], options = {}) {
149
154
  const rewards = samples.map((sample) => buildCompositeReward(sample, options));
150
155
  const blocked = rewards.filter((reward) => reward.label === 'deterministic_block');
151
- const neutralFallbacks = rewards.filter((reward) => reward.failureMode.includes('judge_error_neutral_reward'));
156
+ const neutralFallbacks = rewards.filter((reward) =>
157
+ reward.failureMode.includes('judge_error') || reward.failureMode.includes('judge_unavailable'));
152
158
  return {
153
159
  generatedAt: new Date().toISOString(),
154
160
  samples: samples.length,
@@ -186,7 +192,7 @@ function measureJudgeConsistency(samples = [], judge = null, options = {}) {
186
192
 
187
193
  function evaluateCriterion(id, prediction, sample, { requiresJson }) {
188
194
  if (id === 'schema_valid') {
189
- return evaluateSchemaCriterion(prediction, requiresJson);
195
+ return evaluateSchemaCriterion(prediction, requiresJson, sample.outputSchema);
190
196
  }
191
197
  if (id === 'grounded_evidence') {
192
198
  return hasGroundedEvidence(prediction);
@@ -203,14 +209,17 @@ function evaluateCriterion(id, prediction, sample, { requiresJson }) {
203
209
  return true;
204
210
  }
205
211
 
206
- function evaluateSchemaCriterion(prediction, requiresJson) {
212
+ function evaluateSchemaCriterion(prediction, requiresJson, outputSchema) {
207
213
  if (!requiresJson) return true;
208
- try {
209
- JSON.parse(prediction);
210
- return true;
211
- } catch {
212
- return false;
214
+ if (!outputSchema) {
215
+ try {
216
+ JSON.parse(prediction);
217
+ return true;
218
+ } catch {
219
+ return false;
220
+ }
213
221
  }
222
+ return validateStructuredOutput(prediction, outputSchema).valid;
214
223
  }
215
224
 
216
225
  function hasGroundedEvidence(prediction) {
@@ -253,9 +262,10 @@ function buildCriterionReason(id, pass) {
253
262
  function runJudgeSafely(sample, judge) {
254
263
  if (typeof judge !== 'function') {
255
264
  return {
256
- ok: true,
257
- score: 0.5,
258
- rationale: 'No external judge configured; deterministic checks carried the reward.',
265
+ ok: false,
266
+ available: false,
267
+ score: null,
268
+ rationale: 'No external judge configured; only deterministic checks are evidence.',
259
269
  raw: null,
260
270
  };
261
271
  }
@@ -264,6 +274,7 @@ function runJudgeSafely(sample, judge) {
264
274
  const score = clamp(Number(result.score ?? result), 0, 1);
265
275
  return {
266
276
  ok: true,
277
+ available: true,
267
278
  score,
268
279
  rationale: result.rationale || 'Judge returned a bounded score.',
269
280
  raw: result,
@@ -271,8 +282,9 @@ function runJudgeSafely(sample, judge) {
271
282
  } catch (err) {
272
283
  return {
273
284
  ok: false,
274
- score: 0.5,
275
- rationale: `Judge failed; returned neutral reward. ${err.message}`,
285
+ available: true,
286
+ score: null,
287
+ rationale: `Judge failed; deterministic checks remain authoritative. ${err.message}`,
276
288
  raw: null,
277
289
  };
278
290
  }
@@ -170,6 +170,20 @@ function isPositiveSignal(signal) {
170
170
  return signal === 'positive' || signal === 'up';
171
171
  }
172
172
 
173
+ function isHumanReviewedLesson(lesson = {}) {
174
+ return (lesson.metadata?.reviewOrigin || lesson.reviewOrigin) === 'human';
175
+ }
176
+
177
+ function dedupeHumanReviewedLessons(lessons = []) {
178
+ const byFeedback = new Map();
179
+ for (const lesson of lessons) {
180
+ if (isHumanReviewedLesson(lesson) && lesson.feedbackId) {
181
+ byFeedback.set(lesson.feedbackId, lesson);
182
+ }
183
+ }
184
+ return [...byFeedback.values()];
185
+ }
186
+
173
187
  function selectStatusbarLesson() {
174
188
  const lessons = readJsonl(getLessonsPath())
175
189
  .slice()
@@ -242,10 +256,14 @@ function searchLessons({ query = '', limit = 10, signal } = {}) {
242
256
  */
243
257
  function getLessonStats() {
244
258
  const lessons = readJsonl(getLessonsPath());
245
- const positive = lessons.filter((l) => l.signal === 'positive' || l.signal === 'up').length;
246
- const negative = lessons.filter((l) => l.signal === 'negative' || l.signal === 'down').length;
247
- const avgConfidence = lessons.length > 0 ? Math.round(lessons.reduce((s, l) => s + (l.confidence || 0), 0) / lessons.length) : 0;
248
- return { total: lessons.length, positive, negative, avgConfidence };
259
+ const humanReviewed = dedupeHumanReviewedLessons(lessons);
260
+ const positive = humanReviewed.filter((lesson) => isPositiveSignal(lesson.signal)).length;
261
+ const negative = humanReviewed.filter((lesson) => isNegativeSignal(lesson.signal)).length;
262
+ const avgConfidence = humanReviewed.length > 0
263
+ ? Math.round(humanReviewed.reduce((sum, lesson) => sum + (lesson.confidence || 0), 0) / humanReviewed.length)
264
+ : 0;
265
+ return { total: positive + negative, positive, negative, avgConfidence,
266
+ rawTotal: lessons.length, excludedTotal: lessons.length - humanReviewed.length };
249
267
  }
250
268
 
251
269
  // ---------------------------------------------------------------------------
@@ -650,6 +668,7 @@ async function inferStructuredLessonLLM(conversationWindow, signal, context) {
650
668
  module.exports = {
651
669
  inferFromSurroundingMessages, createLesson, getRecentLesson,
652
670
  searchLessons, getLessonStats, getStatusbarLessonData, getAllLessonsForContext,
671
+ isHumanReviewedLesson,
653
672
  getLessonsPath, getRecentLessonPath,
654
673
  selectStatusbarLesson, getLessonKind, stripLessonPrefix,
655
674
  formatLessonTimestamp, buildStatusbarLessonLabel,
@@ -14,6 +14,63 @@
14
14
 
15
15
  const RECENCY_DECAY_DAYS = 30;
16
16
  const RERANK_CANDIDATE_POOL = 50; // bi-encoder retrieves this many; reranker picks topK
17
+ const MAX_RETRIEVAL_MEMORY_CHARS = 20000;
18
+
19
+ // Line cap for reading the memory log during retrieval.
20
+ //
21
+ // This was 200, which quietly made relevance irrelevant. Retrieval scores memories and keeps
22
+ // anything over 0.1, but it only ever SAW the newest 200 entries — so once 200 newer lessons
23
+ // existed, the single most relevant lesson in the corpus became unreachable no matter how well
24
+ // it matched. Measured on a synthetic corpus where the best-scoring lesson (0.183, threshold
25
+ // 0.1) is the oldest entry:
26
+ //
27
+ // corpus 150 -> found
28
+ // corpus 201 -> NOT found <- cliff, purely from recency
29
+ // corpus 2,000 -> NOT found
30
+ //
31
+ // A firewall that forgets its oldest lessons forgets the ones it learned the hard way.
32
+ //
33
+ // The cap exists for cost, so it is set from measurement rather than taste. Worst case (every
34
+ // entry scoring above threshold, so nothing filters out early):
35
+ //
36
+ // 200 entries 2.6 ms/call | 5,000 entries 2.6 ms/call | 20,000 entries 4.2 ms/call
37
+ //
38
+ // 5,000 therefore costs nothing measurable against the old 200 while covering realistic
39
+ // corpora with wide headroom. Override with THUMBGATE_RETRIEVAL_MAX_LINES if a machine ever
40
+ // grows past it.
41
+ const MAX_RETRIEVAL_MEMORY_LINES = Math.max(
42
+ 1,
43
+ Number(process.env.THUMBGATE_RETRIEVAL_MAX_LINES) || 5000,
44
+ );
45
+
46
+ function isRetrievableMemory(memory, options = {}) {
47
+ if (!memory || typeof memory !== 'object') return false;
48
+ const { looksLikeTransportBlob } = require('./feedback-sanitizer');
49
+ const title = String(memory.title || '');
50
+ const content = String(memory.content || '');
51
+ const combined = `${title}\n${content}`.trim();
52
+ const maxChars = Number.isFinite(options.maxMemoryChars)
53
+ ? Math.max(1, options.maxMemoryChars)
54
+ : MAX_RETRIEVAL_MEMORY_CHARS;
55
+ if (!combined || combined.length > maxChars) return false;
56
+ return !looksLikeTransportBlob(title)
57
+ && !looksLikeTransportBlob(content)
58
+ && !looksLikeTransportBlob(combined);
59
+ }
60
+
61
+ function selectRetrievalMemories(memories = [], options = {}) {
62
+ let selected = memories.filter((memory) => isRetrievableMemory(memory, options));
63
+ if (options.requireScope && !options.scope) {
64
+ throw new Error('Scoped lesson retrieval requires scope');
65
+ }
66
+ if (options.scope) {
67
+ const { selectRecordsForScope } = require('./memory-scope-readiness');
68
+ selected = selectRecordsForScope(selected, options.scope, {
69
+ includeShared: options.includeShared !== false,
70
+ }).allowed;
71
+ }
72
+ return selected;
73
+ }
17
74
 
18
75
  function retrieveRelevantLessons(toolName, actionContext, options = {}) {
19
76
  const { maxResults = 5, feedbackDir } = options;
@@ -24,7 +81,10 @@ function retrieveRelevantLessons(toolName, actionContext, options = {}) {
24
81
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
25
82
  : getFeedbackPaths();
26
83
 
27
- const memories = readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
84
+ const memories = selectRetrievalMemories(
85
+ readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
86
+ options,
87
+ );
28
88
  if (memories.length === 0) return [];
29
89
 
30
90
  const actionSig = buildActionSignature(toolName, actionContext);
@@ -92,13 +152,16 @@ function reciprocalRankFusion(rankedLists = [], options = {}) {
92
152
  .sort((a, b) => b.score - a.score);
93
153
  }
94
154
 
95
- function loadMemories(feedbackDir) {
155
+ function loadMemories(feedbackDir, options = {}) {
96
156
  const { getFeedbackPaths, readJSONL } = require('./feedback-loop');
97
157
  const pathMod = require('path');
98
158
  const paths = feedbackDir
99
159
  ? { MEMORY_LOG_PATH: pathMod.join(feedbackDir, 'memory-log.jsonl') }
100
160
  : getFeedbackPaths();
101
- return readJSONL(paths.MEMORY_LOG_PATH, { maxLines: 200 });
161
+ return selectRetrievalMemories(
162
+ readJSONL(paths.MEMORY_LOG_PATH, { maxLines: MAX_RETRIEVAL_MEMORY_LINES }),
163
+ options,
164
+ );
102
165
  }
103
166
 
104
167
  function shapeLesson(m) {
@@ -140,7 +203,7 @@ async function retrieveRelevantLessonsAsync(toolName, actionContext, options = {
140
203
  return retrieveRelevantLessons(toolName, actionContext, options);
141
204
  }
142
205
 
143
- const memories = loadMemories(feedbackDir);
206
+ const memories = loadMemories(feedbackDir, options);
144
207
  if (memories.length === 0) return [];
145
208
 
146
209
  const actionSig = buildActionSignature(toolName, actionContext);
@@ -482,4 +545,8 @@ module.exports = {
482
545
  filterTopP,
483
546
  resolveTopP,
484
547
  dedupeSupersededLessons,
548
+ isRetrievableMemory,
549
+ selectRetrievalMemories,
550
+ MAX_RETRIEVAL_MEMORY_CHARS,
551
+ MAX_RETRIEVAL_MEMORY_LINES,
485
552
  };
@@ -4,6 +4,7 @@ const path = require('node:path');
4
4
  const { readJSONL, getFeedbackPaths } = require('./feedback-loop');
5
5
  const { buildMemoryLifecycleView, scoreHybridMemoryMatch } = require('./agent-memory-lifecycle');
6
6
  const { loadOptionalModule } = require('./private-core-boundary');
7
+ const { selectRetrievalMemories } = require('./lesson-retrieval');
7
8
 
8
9
  const HIGH_RISK_TAGS = new Set([
9
10
  'billing',
@@ -481,7 +482,8 @@ function searchLessons(query = '', options = {}) {
481
482
  const sqliteResults = tryFts5Search(query, options);
482
483
  if (sqliteResults) return sqliteResults;
483
484
 
484
- const memories = readJSONL(MEMORY_LOG_PATH);
485
+ const allMemories = readJSONL(MEMORY_LOG_PATH);
486
+ const memories = selectRetrievalMemories(allMemories, options);
485
487
  const feedbackEntries = readJSONL(FEEDBACK_LOG_PATH);
486
488
  const feedbackById = new Map(feedbackEntries.map((entry) => [entry.id, entry]));
487
489
  const parsedLimit = Number(options.limit || 10);
@@ -534,9 +536,12 @@ function searchLessons(query = '', options = {}) {
534
536
  filters: {
535
537
  category: category || null,
536
538
  tags: requiredTags,
539
+ scope: options.scope || null,
540
+ requireScope: options.requireScope === true,
537
541
  },
538
542
  feedbackDir: FEEDBACK_DIR,
539
543
  totalLessons: memories.length,
544
+ excludedLessons: allMemories.length - memories.length,
540
545
  returned: Math.min(limit, results.length),
541
546
  results: results.slice(0, limit),
542
547
  backend: 'jsonl-jaccard',
@@ -548,6 +553,10 @@ function searchLessons(query = '', options = {}) {
548
553
  * or not opted in. Set LESSON_DB_SEARCH=1 to enable FTS5 as primary backend.
549
554
  */
550
555
  function tryFts5Search(query, options) {
556
+ // The SQLite index does not currently carry the complete four-field scope
557
+ // contract. Fall back to JSONL whenever isolation is requested rather than
558
+ // silently searching across tenants or sessions.
559
+ if (options.scope || options.requireScope) return null;
551
560
  if (!process.env.LESSON_DB_SEARCH && !options.useFts5) return null;
552
561
  try {
553
562
  const { initDB, searchLessons: fts5Search, getStats } = require('./lesson-db');
@@ -567,11 +576,24 @@ function tryFts5Search(query, options) {
567
576
  .map((tag) => tag.trim())
568
577
  .filter(Boolean);
569
578
 
570
- const rows = fts5Search(db, query || '', {
571
- limit,
579
+ const candidateRows = fts5Search(db, query || '', {
580
+ limit: Math.max(limit * 5, 50),
572
581
  signal,
573
582
  tags: requiredTags.length > 0 ? requiredTags : undefined,
574
583
  });
584
+ const retrievableRows = selectRetrievalMemories(
585
+ candidateRows.map((row) => ({
586
+ ...row,
587
+ title: row.context || '',
588
+ content: [
589
+ row.whatWentWrong,
590
+ row.whatToChange,
591
+ row.whatWorked,
592
+ ].filter(Boolean).join('\n'),
593
+ })),
594
+ options,
595
+ );
596
+ const rows = retrievableRows.slice(0, limit);
575
597
 
576
598
  return {
577
599
  query: String(query || ''),
@@ -581,6 +603,7 @@ function tryFts5Search(query, options) {
581
603
  tags: requiredTags,
582
604
  },
583
605
  totalLessons: stats.total,
606
+ excludedLessons: candidateRows.length - retrievableRows.length,
584
607
  returned: rows.length,
585
608
  results: rows.map((row) => ({
586
609
  id: row.id,
@@ -193,17 +193,37 @@ function publishedCliAvailable(pkgVersion) {
193
193
  return cliAvailabilityCache.get(pkgVersion);
194
194
  }
195
195
 
196
+ /**
197
+ * Project-scope entries land in COMMITTED, SHARED config (.mcp.json / .cursor/mcp.json —
198
+ * init's own banner says the file serves every agent on the repo). A machine-absolute path
199
+ * there is a bug by construction: run init on machine A (or a Cowork sandbox with a home
200
+ * like /Users/busy-clever-newton) and the committed config breaks for every other machine,
201
+ * teammate, and CI runner. Observed for real on 2026-07-29.
202
+ *
203
+ * So: absolute paths may only ever go to HOME-scope config (machine-local by definition).
204
+ * Project scope gets a repo-relative path when the project IS the ThumbGate checkout
205
+ * (dogfooding unpublished source still works — project MCP servers launch with cwd at the
206
+ * project root), and the portable npx launcher otherwise.
207
+ */
208
+ function relativeLocalMcpEntry(pkgRoot, targetDir) {
209
+ const rel = path.relative(targetDir, resolveLocalServerPath(pkgRoot, 'project'));
210
+ // Committed config must be separator-portable too.
211
+ return { command: 'node', args: [rel.split(path.sep).join('/')] };
212
+ }
213
+
196
214
  function resolveMcpEntry({ pkgRoot, pkgVersion, scope = 'project', targetDir = pkgRoot }) {
197
215
  if (!isSourceCheckout(pkgRoot)) {
198
216
  return codexAutoUpdateMcpEntry();
199
217
  }
200
- if (scope === 'home' && publishedCliAvailable(pkgVersion)) {
201
- return codexAutoUpdateMcpEntry();
218
+ if (scope === 'home') {
219
+ if (publishedCliAvailable(pkgVersion)) return codexAutoUpdateMcpEntry();
220
+ return localMcpEntry(pkgRoot, scope);
202
221
  }
203
- if (scope === 'project' && !isSameCheckoutFamily(pkgRoot, targetDir) && publishedCliAvailable(pkgVersion)) {
204
- return codexAutoUpdateMcpEntry();
222
+ // scope === 'project': this is going into shared, committed config.
223
+ if (isSameCheckoutFamily(pkgRoot, targetDir)) {
224
+ return relativeLocalMcpEntry(pkgRoot, targetDir);
205
225
  }
206
- return localMcpEntry(pkgRoot, scope);
226
+ return codexAutoUpdateMcpEntry();
207
227
  }
208
228
 
209
229
  module.exports = {
@@ -214,6 +234,7 @@ module.exports = {
214
234
  localMcpEntry,
215
235
  parseWorktreePaths,
216
236
  portableMcpEntry,
237
+ relativeLocalMcpEntry,
217
238
  resolveGitCommonDir,
218
239
  resolveLocalServerPath,
219
240
  resolveMcpEntry,
@@ -29,6 +29,7 @@ const crypto = require('crypto');
29
29
  const AUTH_CODE_TTL_MS = 60 * 1000; // 1 minute
30
30
  const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
31
31
  const DEFAULT_SCOPE = 'mcp:read mcp:write';
32
+ const SUPPORTED_SCOPES = Object.freeze(['mcp:read', 'mcp:write']);
32
33
 
33
34
  // Upper bounds on the in-memory store. The registration and authorization
34
35
  // endpoints are reachable pre-auth, so without a cap a malicious caller could
@@ -160,6 +161,27 @@ function getClient(store, clientId) {
160
161
  return store.clients.get(clientId) || null;
161
162
  }
162
163
 
164
+ function normalizeScopes(scope = DEFAULT_SCOPE, allowedScopes = SUPPORTED_SCOPES) {
165
+ const requested = [...new Set(String(scope || DEFAULT_SCOPE).split(/\s+/).filter(Boolean))];
166
+ const supported = new Set(SUPPORTED_SCOPES);
167
+ const allowed = new Set(allowedScopes || SUPPORTED_SCOPES);
168
+ const invalid = requested.filter((candidate) => !supported.has(candidate));
169
+ const disallowed = requested.filter((candidate) => supported.has(candidate) && !allowed.has(candidate));
170
+ return {
171
+ valid: requested.length > 0 && invalid.length === 0 && disallowed.length === 0,
172
+ scopes: requested,
173
+ scope: requested.join(' '),
174
+ invalid,
175
+ disallowed,
176
+ };
177
+ }
178
+
179
+ function scopeAllows(session, requiredScope) {
180
+ if (!session || !requiredScope) return false;
181
+ const normalized = normalizeScopes(session.scope, SUPPORTED_SCOPES);
182
+ return normalized.valid && normalized.scopes.includes(requiredScope);
183
+ }
184
+
163
185
  // ---------------------------------------------------------------------------
164
186
  // Authorization code (PKCE S256)
165
187
  // ---------------------------------------------------------------------------
@@ -169,20 +191,30 @@ function getClient(store, clientId) {
169
191
  * token will act as (resolved by the authorize step once the user consents).
170
192
  */
171
193
  function createAuthorizationCode(store, {
172
- clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, boundKey, state, resource,
194
+ clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, allowedScopes, boundKey, state, resource,
173
195
  } = {}) {
174
196
  const client = getClient(store, clientId);
175
197
  if (!client) return { error: 'invalid_client' };
176
198
  if (!client.redirect_uris.includes(redirectUri)) return { error: 'invalid_request', error_description: 'redirect_uri mismatch' };
177
199
  if (codeChallengeMethod !== 'S256') return { error: 'invalid_request', error_description: 'code_challenge_method must be S256' };
178
200
  if (!codeChallenge || String(codeChallenge).length < 16) return { error: 'invalid_request', error_description: 'code_challenge required' };
201
+ const normalizedScopes = normalizeScopes(scope, allowedScopes || SUPPORTED_SCOPES);
202
+ if (!normalizedScopes.valid) {
203
+ return {
204
+ error: 'invalid_scope',
205
+ error_description: [
206
+ normalizedScopes.invalid.length > 0 ? `unsupported: ${normalizedScopes.invalid.join(', ')}` : '',
207
+ normalizedScopes.disallowed.length > 0 ? `not permitted: ${normalizedScopes.disallowed.join(', ')}` : '',
208
+ ].filter(Boolean).join('; ') || 'scope is required',
209
+ };
210
+ }
179
211
 
180
212
  const code = randomToken(24);
181
213
  capInsert(store.codes, code, {
182
214
  clientId,
183
215
  redirectUri,
184
216
  codeChallenge,
185
- scope: scope || DEFAULT_SCOPE,
217
+ scope: normalizedScopes.scope,
186
218
  boundKey: boundKey || '',
187
219
  resource: resource || '', // RFC 8707 resource indicator (the MCP server URL)
188
220
  expiresAt: now() + AUTH_CODE_TTL_MS,
@@ -287,6 +319,9 @@ module.exports = {
287
319
  AUTH_CODE_TTL_MS,
288
320
  ACCESS_TOKEN_TTL_MS,
289
321
  DEFAULT_SCOPE,
322
+ SUPPORTED_SCOPES,
323
+ normalizeScopes,
324
+ scopeAllows,
290
325
  MAX_CLIENTS,
291
326
  MAX_CODES,
292
327
  MAX_TOKENS,