thumbgate 1.30.0 → 1.31.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-plugin/plugin.json +1 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +54 -16
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/forge/forge.yaml +3 -3
  6. package/adapters/mcp/server-stdio.js +66 -8
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +147 -31
  10. package/config/evals/generation-quality-golden.json +95 -0
  11. package/config/evals/rag-answer-quality-golden.json +91 -0
  12. package/config/evals/retrieval-hybrid-ablation.json +66 -0
  13. package/config/evals/retrieval-ranking-golden.json +522 -0
  14. package/config/gates/default.json +217 -50
  15. package/config/mcp-allowlists.json +212 -206
  16. package/config/model-tiers.json +7 -2
  17. package/glama.json +6 -0
  18. package/package.json +60 -8
  19. package/public/assets/diagrams/before-after.svg +17 -16
  20. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  21. package/public/assets/diagrams/loop.svg +19 -13
  22. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  23. package/public/compare.html +1 -0
  24. package/public/dashboard.html +126 -28
  25. package/public/evaluations.html +1 -1
  26. package/public/index.html +142 -13
  27. package/public/numbers.html +3 -2
  28. package/public/pricing.html +143 -30
  29. package/scripts/a-plus-evidence-scorecard.js +303 -0
  30. package/scripts/async-eval-observability.js +36 -11
  31. package/scripts/audit-trail.js +37 -1
  32. package/scripts/auto-promote-gates.js +149 -34
  33. package/scripts/colbert-style-maxsim.js +236 -0
  34. package/scripts/cross-encoder-reranker.js +356 -126
  35. package/scripts/dashboard-chat.js +350 -17
  36. package/scripts/document-intake.js +283 -7
  37. package/scripts/eval-quality-suite.js +204 -0
  38. package/scripts/feedback-loop.js +115 -7
  39. package/scripts/feedback-paths.js +32 -13
  40. package/scripts/feedback-quality.js +53 -0
  41. package/scripts/filesystem-search.js +17 -7
  42. package/scripts/gates-engine.js +17 -0
  43. package/scripts/harness-tool-names.js +70 -0
  44. package/scripts/hook-runtime.js +10 -3
  45. package/scripts/lesson-db.js +16 -5
  46. package/scripts/lesson-embedding-index.js +67 -20
  47. package/scripts/lesson-embedding-maintenance.js +177 -0
  48. package/scripts/lesson-reranker.js +55 -9
  49. package/scripts/lesson-retrieval.js +305 -29
  50. package/scripts/lesson-search.js +22 -8
  51. package/scripts/llm-client.js +304 -15
  52. package/scripts/model-tier-router.js +593 -0
  53. package/scripts/pragmatic-hybrid-search.js +379 -0
  54. package/scripts/rag-document-pipeline.js +461 -0
  55. package/scripts/rag-structured-output.js +441 -0
  56. package/scripts/ragas-style-metrics.js +351 -0
  57. package/scripts/request-envelope.js +178 -0
  58. package/scripts/rerank-pipeline.js +370 -0
  59. package/scripts/rerank-quality-eval.js +155 -0
  60. package/scripts/retrieval-hybrid-ablation.js +120 -0
  61. package/scripts/retrieval-quality-tier.js +118 -0
  62. package/scripts/secret-scanner.js +395 -4
  63. package/scripts/self-distill-agent.js +7 -1
  64. package/scripts/self-healing-check.js +25 -0
  65. package/scripts/skill-packs.js +183 -0
  66. package/scripts/slow-loop.js +72 -0
  67. package/scripts/statusline-links.js +1 -1
  68. package/scripts/statusline.sh +8 -1
  69. package/scripts/telemetry-analytics.js +13 -1
  70. package/scripts/thumbgate-search.js +98 -6
  71. package/scripts/tier-budget-guard.js +186 -0
  72. package/scripts/tool-registry.js +46 -0
  73. package/scripts/vector-store.js +108 -4
  74. package/scripts/verify-marketing-pages-deployed.js +85 -3
  75. package/server.json +44 -0
  76. package/smithery.yaml +17 -0
  77. package/src/api/server.js +194 -13
@@ -21,6 +21,7 @@ const {
21
21
  buildClarificationMessage,
22
22
  isGenericFeedbackText,
23
23
  normalizeFeedbackText,
24
+ scoreFeedbackReward,
24
25
  } = require('./feedback-quality');
25
26
  const {
26
27
  buildRubricEvaluation,
@@ -1613,6 +1614,14 @@ function captureFeedback(params) {
1613
1614
  return firewallBlocked;
1614
1615
  }
1615
1616
 
1617
+ // Grade the correction the operator actually wrote, and persist it with the
1618
+ // entry. assessFeedbackActionability already answered the binary question
1619
+ // (promotable at all); this is the graded one, and without computing it here
1620
+ // the scorer would be reachable-but-never-called — the same defect class it
1621
+ // was added to help detect.
1622
+ const rewardScore = scoreFeedbackReward(feedbackEvent);
1623
+ if (rewardScore) feedbackEvent.rewardScore = rewardScore;
1624
+
1616
1625
  appendJSONL(FEEDBACK_LOG_PATH, feedbackEvent);
1617
1626
  emitAnonymousFeedbackPing(signal);
1618
1627
 
@@ -2243,20 +2252,100 @@ function buildPreventionRules(minOccurrences = 2, options = {}) {
2243
2252
  return Math.exp(-lambda * daysSince);
2244
2253
  }
2245
2254
 
2255
+ // CEO contract tags beat generic richContext.domain=general so honesty/overclaim
2256
+ // whatToChange is not drowned by hook noise in the "general" bucket.
2257
+ const PRIORITY_DOMAIN_TAGS = [
2258
+ 'honesty',
2259
+ 'overclaim',
2260
+ 'completion-claim',
2261
+ 'production-truth',
2262
+ 'false-completion',
2263
+ 'pr-hygiene',
2264
+ 'ceo-feedback',
2265
+ ];
2266
+ const GENERIC_TAGS = new Set([
2267
+ 'feedback',
2268
+ 'negative',
2269
+ 'positive',
2270
+ 'entity:Customer',
2271
+ 'thumbs-down',
2272
+ 'thumbs-up',
2273
+ ]);
2274
+
2275
+ function isNoiseTitle(title) {
2276
+ const t = String(title || '');
2277
+ return /hookEventName|user_prompt_submit|"sessionId"/i.test(t);
2278
+ }
2279
+
2280
+ function extractAvoidLine(content) {
2281
+ return String(content || '')
2282
+ .split('\n')
2283
+ .find((l) => l.toLowerCase().startsWith('how to avoid:')) || null;
2284
+ }
2285
+
2286
+ function domainKeyForMemory(m) {
2287
+ const tags = Array.isArray(m.tags) ? m.tags : [];
2288
+ for (const tag of PRIORITY_DOMAIN_TAGS) {
2289
+ if (tags.includes(tag)) return tag;
2290
+ }
2291
+ const rcDomain = m.richContext && m.richContext.domain;
2292
+ if (rcDomain && rcDomain !== 'unknown' && rcDomain !== 'general') {
2293
+ return rcDomain;
2294
+ }
2295
+ return tags.find((t) => !GENERIC_TAGS.has(t)) || 'general';
2296
+ }
2297
+
2298
+ function pickBestMemory(items) {
2299
+ let best = items[items.length - 1];
2300
+ let bestScore = -Infinity;
2301
+ items.forEach((m, index) => {
2302
+ let score = index;
2303
+ if (isNoiseTitle(m.title)) score -= 1000;
2304
+ const avoid = extractAvoidLine(m.content);
2305
+ if (avoid) score += 100;
2306
+ if (avoid && /never/i.test(avoid)) score += 50;
2307
+ if ((m.tags || []).some((t) => PRIORITY_DOMAIN_TAGS.includes(t))) score += 40;
2308
+ if ((m.occurrences || 1) > 1) score += 10;
2309
+ if (score >= bestScore) {
2310
+ bestScore = score;
2311
+ best = m;
2312
+ }
2313
+ });
2314
+ return best;
2315
+ }
2316
+
2246
2317
  const buckets = {};
2247
2318
  const rubricBuckets = {};
2248
2319
  const diagnosisBuckets = {};
2249
2320
  const repeatedViolationBuckets = {};
2321
+ const priorityContracts = [];
2250
2322
  for (const m of memories) {
2251
- const key = (m.richContext && m.richContext.domain && m.richContext.domain !== 'unknown')
2252
- ? m.richContext.domain
2253
- : (m.tags || []).find((t) => !['feedback', 'negative', 'positive'].includes(t)) || 'general';
2323
+ if (isNoiseTitle(m.title) && !extractAvoidLine(m.content)) {
2324
+ // Skip pure hook-noise shells with no actionable avoid line.
2325
+ continue;
2326
+ }
2327
+ const key = domainKeyForMemory(m);
2254
2328
  if (!buckets[key]) buckets[key] = { items: [], weightedCount: 0 };
2255
2329
  const w = decayWeight(m);
2256
2330
  const occ = m.occurrences || 1;
2257
2331
  buckets[key].items.push(m);
2258
2332
  buckets[key].weightedCount += w * occ;
2259
2333
 
2334
+ const tags = Array.isArray(m.tags) ? m.tags : [];
2335
+ const avoid = extractAvoidLine(m.content);
2336
+ if (
2337
+ avoid
2338
+ && tags.some((t) => PRIORITY_DOMAIN_TAGS.includes(t))
2339
+ ) {
2340
+ priorityContracts.push({
2341
+ id: m.id,
2342
+ tags: tags.filter((t) => PRIORITY_DOMAIN_TAGS.includes(t)),
2343
+ rule: avoid.replace(/^How to avoid:\s*/i, ''),
2344
+ title: m.title,
2345
+ occurrences: occ,
2346
+ });
2347
+ }
2348
+
2260
2349
  const failed = m.rubricSummary && Array.isArray(m.rubricSummary.failingCriteria)
2261
2350
  ? m.rubricSummary.failingCriteria
2262
2351
  : [];
@@ -2294,18 +2383,37 @@ function buildPreventionRules(minOccurrences = 2, options = {}) {
2294
2383
 
2295
2384
  const lines = ['# Prevention Rules', '', 'Generated from negative feedback memories (time-weighted, half-life: ' + decayHalfLifeDays + 'd).'];
2296
2385
 
2386
+ // High-priority CEO contracts: always emit actionable whatToChange (threshold 1).
2387
+ if (priorityContracts.length > 0) {
2388
+ lines.push('');
2389
+ lines.push('## High-Priority Contracts');
2390
+ const seen = new Set();
2391
+ priorityContracts
2392
+ .sort((a, b) => (b.occurrences || 1) - (a.occurrences || 1))
2393
+ .forEach((c) => {
2394
+ const dedupe = c.rule.slice(0, 120);
2395
+ if (seen.has(dedupe)) return;
2396
+ seen.add(dedupe);
2397
+ lines.push(`- **[${c.tags.join(', ')}]** ${c.rule}`);
2398
+ lines.push(` - Source: ${c.title}`);
2399
+ });
2400
+ }
2401
+
2297
2402
  Object.entries(buckets)
2298
2403
  .sort((a, b) => b[1].weightedCount - a[1].weightedCount)
2299
2404
  .forEach(([domain, { items, weightedCount }]) => {
2300
2405
  const effectiveOccurrences = Math.round(weightedCount);
2301
- if (effectiveOccurrences < resolvedMinOccurrences) return;
2302
- const latest = items[items.length - 1];
2303
- const avoid = (latest.content || '').split('\n').find((l) => l.toLowerCase().startsWith('how to avoid:')) || 'How to avoid: Investigate and prevent recurrence';
2406
+ // Priority domains promote with a single solid memory (CEO contracts).
2407
+ const threshold = PRIORITY_DOMAIN_TAGS.includes(domain) ? 1 : resolvedMinOccurrences;
2408
+ if (effectiveOccurrences < threshold) return;
2409
+ const best = pickBestMemory(items);
2410
+ const avoid = extractAvoidLine(best.content)
2411
+ || 'How to avoid: Investigate and prevent recurrence';
2304
2412
  lines.push('');
2305
2413
  lines.push(`## ${domain}`);
2306
2414
  lines.push(`- Recurrence count: ${items.length} (weighted: ${weightedCount.toFixed(1)})`);
2307
2415
  lines.push(`- Rule: ${avoid.replace(/^How to avoid:\s*/i, '')}`);
2308
- lines.push(`- Latest mistake: ${latest.title}`);
2416
+ lines.push(`- Latest mistake: ${best.title}`);
2309
2417
  });
2310
2418
 
2311
2419
  const rubricEntries = Object.entries(rubricBuckets)
@@ -78,6 +78,15 @@ function isTransientProjectDir(dirPath, options = {}) {
78
78
  if (!normalizedDir) return true;
79
79
  if (!dirExists(normalizedDir)) return true;
80
80
 
81
+ // MCP hosts and desktop launchers commonly start global servers from `/` or
82
+ // the user's home directory. Those are launcher contexts, not projects. If
83
+ // they win resolution they create `projects/default` / `~/.thumbgate` split
84
+ // stores and make the same lesson corpus depend on which client started the
85
+ // process. A durable active-project state is a better signal.
86
+ if (normalizedDir === path.parse(normalizedDir).root) return true;
87
+ const homeDir = normalizeDir(getHomeDir(options));
88
+ if (homeDir && normalizedDir === homeDir) return true;
89
+
81
90
  const runtimeDir = getRuntimeDir(options);
82
91
  if (isWithinDir(normalizedDir, runtimeDir)) return true;
83
92
 
@@ -119,11 +128,12 @@ function writeActiveProjectState(projectDir, options = {}) {
119
128
  function resolveProjectDir(options = {}) {
120
129
  const env = options.env || process.env;
121
130
  const stored = options.includeStored === false ? null : readActiveProjectState(options);
122
- const cwdCandidates = uniquePaths([
123
- options.cwd,
124
- env.PWD,
125
- process.cwd(),
126
- ]);
131
+ // An injected cwd is authoritative for callers that resolve on behalf of a
132
+ // different process. Mixing in this Node process's cwd can silently route a
133
+ // global launcher back into ThumbGate's own checkout during diagnostics.
134
+ const cwdCandidates = uniquePaths(options.cwd
135
+ ? [options.cwd]
136
+ : [env.PWD, process.cwd()]);
127
137
  const isTransientExecution = cwdCandidates.length > 0
128
138
  && cwdCandidates.every((candidate) => isTransientProjectDir(candidate, options));
129
139
  const candidates = uniquePaths([
@@ -167,15 +177,12 @@ function getExplicitFeedbackDir(options = {}) {
167
177
  const env = options.env || process.env;
168
178
  if (options.feedbackDir) return options.feedbackDir;
169
179
  if (options.skipExplicitFeedbackDir) return null;
170
- // A caller-provided feedback root should stay authoritative over stored
171
- // active-project state so isolated CLI/test commands do not drift into a
172
- // different project. Only direct project overrides suppress it.
173
- if (env.THUMBGATE_FEEDBACK_DIR && !hasDirectProjectScope(options)) {
180
+ // An explicit storage root is the strongest storage instruction. Project
181
+ // metadata may still identify the project, but it must not redirect writes
182
+ // out of an isolated test/runtime directory.
183
+ if (env.THUMBGATE_FEEDBACK_DIR) {
174
184
  return env.THUMBGATE_FEEDBACK_DIR;
175
185
  }
176
- if (hasDirectProjectScope(options)) {
177
- return null;
178
- }
179
186
  if (env.RAILWAY_VOLUME_MOUNT_PATH) {
180
187
  return path.join(env.RAILWAY_VOLUME_MOUNT_PATH, 'feedback');
181
188
  }
@@ -221,7 +228,19 @@ function resolveFeedbackDir(options = {}) {
221
228
  const localLegacy = getLegacyFeedbackDir(options);
222
229
  if (dirExists(localLegacy)) return localLegacy;
223
230
 
224
- return getGlobalFeedbackDir(options);
231
+ // Existing installations may only have the pre-1.31 basename-scoped store.
232
+ // Preserve that corpus instead of silently switching to an empty directory.
233
+ // New projects never create this legacy global shape, so same-basename
234
+ // projects remain isolated going forward.
235
+ const globalLegacy = getGlobalFeedbackDir(options);
236
+ if (dirExists(globalLegacy)) return globalLegacy;
237
+
238
+ const projectDir = resolveProjectDir(options);
239
+ // New real projects select their collision-free local store before it is
240
+ // created, preventing the old first-write flip from global to local.
241
+ if (!isTransientProjectDir(projectDir, options)) return localThumbgate;
242
+
243
+ return globalLegacy;
225
244
  }
226
245
 
227
246
  function getFeedbackPaths(options = {}) {
@@ -154,6 +154,58 @@ function isGenericFeedbackText(value, signal) {
154
154
  return rules.some((pattern) => pattern.test(normalized));
155
155
  }
156
156
 
157
+ /**
158
+ * Score a captured feedback entry with the reward rubric in judge-reward-function.
159
+ *
160
+ * `assessFeedbackActionability` answers a binary question — is this promotable at
161
+ * all. This answers the graded one: how good is the correction the operator wrote.
162
+ * A 👎 whose whatToChange is "be better" and a 👎 that says "run npm test before
163
+ * claiming green, see commit abc123" are both promotable; only one of them makes a
164
+ * useful prevention rule.
165
+ *
166
+ * Deterministic by construction. `buildCompositeReward` runs its Boolean rubric
167
+ * first and only consults an LLM judge if one is injected; we deliberately inject
168
+ * none, so this returns `scoringMode: 'deterministic_only'` and costs nothing. That
169
+ * matters: ANTHROPIC_API_KEY is frequently absent, and a scorer that silently
170
+ * degrades to nothing is worse than one that never claimed the LLM path.
171
+ *
172
+ * Reporting only — nothing here gates promotion. Wiring a fresh quality signal
173
+ * straight into enforcement would change which lessons become blocking rules, and
174
+ * that decision deserves its own change with its own evidence.
175
+ *
176
+ * @returns {{score: number, label: string, scoringMode: string, dimensions: object, passed: boolean}|null}
177
+ * null when there is no corrective text worth scoring.
178
+ */
179
+ function scoreFeedbackReward(params = {}) {
180
+ const signal = normalizeFeedbackSignal(params.signal);
181
+ const prediction = normalizeFeedbackText(
182
+ signal === 'positive'
183
+ ? (params.whatWorked || params.context)
184
+ : (params.whatToChange || params.whatWentWrong || params.context),
185
+ );
186
+ if (!prediction) return null;
187
+
188
+ // Lazy-require: judge-reward-function is a leaf, but feedback-quality is required
189
+ // by feedback-loop and three other modules — keep the import cost off that path
190
+ // until someone actually asks for a score.
191
+ let buildCompositeReward;
192
+ try {
193
+ ({ buildCompositeReward } = require('./judge-reward-function'));
194
+ } catch {
195
+ return null;
196
+ }
197
+ if (typeof buildCompositeReward !== 'function') return null;
198
+
199
+ const reward = buildCompositeReward({ prediction });
200
+ return {
201
+ score: reward.score,
202
+ label: reward.label,
203
+ scoringMode: reward.scoringMode || 'deterministic_only',
204
+ dimensions: reward.deterministic ? reward.deterministic.dimensions : {},
205
+ passed: Boolean(reward.deterministic && reward.deterministic.passed),
206
+ };
207
+ }
208
+
157
209
  function assessFeedbackActionability(params = {}) {
158
210
  const signal = normalizeFeedbackSignal(params.signal);
159
211
  const primaryFields = signal === 'positive'
@@ -222,4 +274,5 @@ module.exports = {
222
274
  isGenericFeedbackText,
223
275
  assessFeedbackActionability,
224
276
  buildClarificationMessage,
277
+ scoreFeedbackReward,
225
278
  };
@@ -154,14 +154,19 @@ function scoreRecord(queryTokens, queryText, record) {
154
154
  // ---------------------------------------------------------------------------
155
155
 
156
156
  function searchFeedbackLog(queryText, limit = 5, options = {}) {
157
- const logPath = path.join(options.feedbackDir || getFeedbackDir(), 'feedback-log.jsonl');
157
+ const feedbackDir = options.feedbackDir || getFeedbackDir();
158
+ const logPath = path.join(feedbackDir, 'feedback-log.jsonl');
158
159
  let records = readJsonl(logPath);
159
160
 
160
- // SQLite fallback: if JSONL is empty/tiny, pull records from the lesson DB
161
- if (records.length <= 1) {
161
+ // SQLite fallback is allowed only inside the exact selected feedback root.
162
+ // Falling back through lesson-db's ambient default can cross project/tenant
163
+ // boundaries when a sparse project has zero or one JSONL row.
164
+ const lessonDbPath = path.join(feedbackDir, 'lessons.sqlite');
165
+ if (records.length <= 1 && fs.existsSync(lessonDbPath)) {
166
+ let db = null;
162
167
  try {
163
168
  const { initDB } = require('./lesson-db');
164
- const db = initDB();
169
+ db = initDB(lessonDbPath);
165
170
  const rows = db.prepare('SELECT * FROM lessons ORDER BY timestamp DESC LIMIT 500').all();
166
171
  if (rows.length > records.length) {
167
172
  records = rows.map((r) => ({
@@ -171,12 +176,17 @@ function searchFeedbackLog(queryText, limit = 5, options = {}) {
171
176
  title: r.title || r.context,
172
177
  tags: r.tags ? JSON.parse(r.tags) : [],
173
178
  timestamp: r.timestamp,
174
- whatWentWrong: r.what_went_wrong,
175
- whatWorked: r.what_worked,
176
- whatToChange: r.what_to_change,
179
+ whatWentWrong: r.whatWentWrong ?? r.what_went_wrong,
180
+ whatWorked: r.whatWorked ?? r.what_worked,
181
+ whatToChange: r.whatToChange ?? r.what_to_change,
177
182
  }));
178
183
  }
179
184
  } catch { /* lesson-db not available */ }
185
+ finally {
186
+ if (db) {
187
+ try { db.close(); } catch { /* best-effort close */ }
188
+ }
189
+ }
180
190
  }
181
191
 
182
192
  // Wildcard query: return all records sorted by recency
@@ -1236,11 +1236,28 @@ const COMMAND_WRAPPERS = new Set([
1236
1236
  ]);
1237
1237
  const ENV_ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)\s+/;
1238
1238
  const WRAPPER_HEAD = /^([A-Za-z_][\w.-]*)\s+/;
1239
+ const LITERAL_COMMAND_SUBSTITUTION_HEADS = [
1240
+ /^\$\(\s*printf\s+(?:(?:['"]?%s['"]?)\s+)?(['"]?)([A-Za-z_][\w.-]*)\1\s*\)\s+/,
1241
+ /^\$\(\s*echo\s+(['"]?)([A-Za-z_][\w.-]*)\1\s*\)\s+/,
1242
+ /^\$\(\s*(?:command\s+-v|which)\s+(['"]?)([A-Za-z_][\w.-]*)\1\s*\)\s+/,
1243
+ ];
1244
+
1245
+ function canonicalizeLiteralCommandSubstitutionHead(segment) {
1246
+ for (const pattern of LITERAL_COMMAND_SUBSTITUTION_HEADS) {
1247
+ const match = String(segment || '').match(pattern);
1248
+ if (match) return `${match[2]} ${String(segment).slice(match[0].length)}`;
1249
+ }
1250
+ return segment;
1251
+ }
1239
1252
 
1240
1253
  function canonicalizeSegmentHead(segment) {
1241
1254
  let text = String(segment || '').trim();
1242
1255
  for (let i = 0; i < 12; i += 1) {
1243
1256
  const before = text;
1257
+ // Resolve only literal, side-effect-free command-position substitutions.
1258
+ // Never execute or guess arbitrary shell; this closes common `$(printf git)`
1259
+ // and `$(command -v git)` spellings while keeping matching deterministic.
1260
+ text = canonicalizeLiteralCommandSubstitutionHead(text);
1244
1261
  text = text.replace(ENV_ASSIGNMENT_PREFIX, '');
1245
1262
  const wrapper = text.match(WRAPPER_HEAD);
1246
1263
  if (wrapper && COMMAND_WRAPPERS.has(wrapper[1].toLowerCase())) {
@@ -0,0 +1,70 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Harness tool names -> canonical gate-engine names.
5
+ *
6
+ * Policy gates match Claude Code's vocabulary (Bash, Write, Edit, MultiEdit). Other
7
+ * harnesses use their own: Cline's .clinerules instructs the agent to gate-check
8
+ * `execute_command`, `write_to_file`, `replace_in_file` and `browser_action`.
9
+ * Forwarding those through unchanged meant every gate silently missed — a strict
10
+ * `gate_check` on `{tool_name: "execute_command", command: "rm -rf /"}` returned ALLOW,
11
+ * which is precisely the scenario the adapter advertises as protected.
12
+ *
13
+ * Argument keys differ too: Cline writes use `path`, Claude Code uses `file_path`.
14
+ */
15
+
16
+ const TOOL_NAME_MAP = new Map(Object.entries({
17
+ // Cline / Roo Code
18
+ execute_command: 'Bash',
19
+ write_to_file: 'Write',
20
+ replace_in_file: 'Edit',
21
+ apply_diff: 'Edit',
22
+ insert_content: 'Edit',
23
+ search_and_replace: 'Edit',
24
+ // OpenCode / generic MCP
25
+ bash: 'Bash',
26
+ shell: 'Bash',
27
+ run_command: 'Bash',
28
+ edit_file: 'Edit',
29
+ write_file: 'Write',
30
+ create_file: 'Write',
31
+ str_replace_editor: 'Edit',
32
+ // Cursor
33
+ run_terminal_cmd: 'Bash',
34
+ }));
35
+
36
+ const ARG_KEY_MAP = new Map(Object.entries({
37
+ path: 'file_path',
38
+ filePath: 'file_path',
39
+ target_file: 'file_path',
40
+ cmd: 'command',
41
+ shell_command: 'command',
42
+ diff: 'new_string',
43
+ }));
44
+
45
+ function canonicalizeToolName(toolName) {
46
+ const raw = String(toolName || '').trim();
47
+ if (!raw) return raw;
48
+ return TOOL_NAME_MAP.get(raw) || TOOL_NAME_MAP.get(raw.toLowerCase()) || raw;
49
+ }
50
+
51
+ function canonicalizeToolInput(toolInput) {
52
+ if (!toolInput || typeof toolInput !== 'object' || Array.isArray(toolInput)) return toolInput || {};
53
+ const out = {};
54
+ for (const [key, value] of Object.entries(toolInput)) {
55
+ const canonicalKey = ARG_KEY_MAP.get(key) || key;
56
+ // Never let a mapped key clobber one the caller already supplied canonically.
57
+ if (canonicalKey in out) continue;
58
+ out[canonicalKey] = value;
59
+ }
60
+ return out;
61
+ }
62
+
63
+ function canonicalizeToolCall(toolName, toolInput) {
64
+ return {
65
+ toolName: canonicalizeToolName(toolName),
66
+ toolInput: canonicalizeToolInput(toolInput),
67
+ };
68
+ }
69
+
70
+ module.exports = { canonicalizeToolCall, canonicalizeToolName, canonicalizeToolInput, TOOL_NAME_MAP, ARG_KEY_MAP };
@@ -51,13 +51,20 @@ function resolveCliCommand(subcommand) {
51
51
  }
52
52
 
53
53
  function resolveCodexCliCommand(subcommand) {
54
+ // Codex hooks live in user-global config. Pinning them to a disposable source
55
+ // worktree breaks every hook as soon as normal post-merge cleanup removes it.
56
+ // The stable @latest launcher stays offline at configuration time and survives
57
+ // cleanup. Checkout-pinned hooks remain available as an explicit dev override.
58
+ if (isSourceCheckout(PKG_ROOT) && process.env.THUMBGATE_CODEX_USE_SOURCE_RUNTIME === '1') {
59
+ return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))} ${subcommand}`;
60
+ }
61
+ if (isSourceCheckout(PKG_ROOT)) {
62
+ return publishedCliShellCommand('latest', [subcommand]);
63
+ }
54
64
  const version = packageVersion();
55
65
  if (publishedHookCommandsAvailable(version)) {
56
66
  return publishedCliShellCommand('latest', [subcommand]);
57
67
  }
58
- if (isSourceCheckout(PKG_ROOT)) {
59
- return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))} ${subcommand}`;
60
- }
61
68
  return publishedCliShellCommand('latest', [subcommand]);
62
69
  }
63
70
 
@@ -15,14 +15,16 @@
15
15
  const path = require('node:path');
16
16
  const fs = require('node:fs');
17
17
  const { readJsonl } = require('./fs-utils');
18
+ const { resolveFeedbackDir } = require('./feedback-paths');
18
19
 
19
- const PROJECT_ROOT = path.join(__dirname, '..');
20
- const DEFAULT_DB_PATH = path.join(PROJECT_ROOT, '.claude', 'memory', 'lessons.sqlite');
20
+ function resolveDefaultDbPath(options = {}) {
21
+ return path.join(resolveFeedbackDir(options), 'lessons.sqlite');
22
+ }
21
23
 
22
24
  /** @returns {import('better-sqlite3').Database} */
23
25
  function initDB(dbPath) {
24
26
  const Database = require('better-sqlite3');
25
- const resolvedPath = dbPath || process.env.LESSON_DB_PATH || DEFAULT_DB_PATH;
27
+ const resolvedPath = dbPath || process.env.LESSON_DB_PATH || resolveDefaultDbPath();
26
28
 
27
29
  // Ensure parent directory exists
28
30
  const dir = path.dirname(resolvedPath);
@@ -643,7 +645,7 @@ function safeParseTags(tagsStr) {
643
645
  }
644
646
  }
645
647
 
646
- module.exports = {
648
+ const lessonDbApi = {
647
649
  initDB,
648
650
  upsertLesson,
649
651
  upsertSession,
@@ -655,5 +657,14 @@ module.exports = {
655
657
  getStats,
656
658
  getStatsFromDB,
657
659
  backfillFromJsonl,
658
- DEFAULT_DB_PATH,
660
+ resolveDefaultDbPath,
659
661
  };
662
+
663
+ // Preserve the public property while resolving it at access time so callers
664
+ // cannot cache a package-checkout path before project selection is known.
665
+ Object.defineProperty(lessonDbApi, 'DEFAULT_DB_PATH', {
666
+ enumerable: true,
667
+ get: resolveDefaultDbPath,
668
+ });
669
+
670
+ module.exports = lessonDbApi;