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.
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +54 -16
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +66 -8
- package/adapters/opencode/opencode.json +1 -1
- package/bench/observability-eval-suite.json +2 -2
- package/bin/cli.js +147 -31
- package/config/evals/generation-quality-golden.json +95 -0
- package/config/evals/rag-answer-quality-golden.json +91 -0
- package/config/evals/retrieval-hybrid-ablation.json +66 -0
- package/config/evals/retrieval-ranking-golden.json +522 -0
- package/config/gates/default.json +217 -50
- package/config/mcp-allowlists.json +212 -206
- package/config/model-tiers.json +7 -2
- package/glama.json +6 -0
- package/package.json +60 -8
- package/public/assets/diagrams/before-after.svg +17 -16
- package/public/assets/diagrams/hero-thumbs.svg +68 -0
- package/public/assets/diagrams/loop.svg +19 -13
- package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
- package/public/compare.html +1 -0
- package/public/dashboard.html +126 -28
- package/public/evaluations.html +1 -1
- package/public/index.html +142 -13
- package/public/numbers.html +3 -2
- package/public/pricing.html +143 -30
- package/scripts/a-plus-evidence-scorecard.js +303 -0
- package/scripts/async-eval-observability.js +36 -11
- package/scripts/audit-trail.js +37 -1
- package/scripts/auto-promote-gates.js +149 -34
- package/scripts/colbert-style-maxsim.js +236 -0
- package/scripts/cross-encoder-reranker.js +356 -126
- package/scripts/dashboard-chat.js +350 -17
- package/scripts/document-intake.js +283 -7
- package/scripts/eval-quality-suite.js +204 -0
- package/scripts/feedback-loop.js +115 -7
- package/scripts/feedback-paths.js +32 -13
- package/scripts/feedback-quality.js +53 -0
- package/scripts/filesystem-search.js +17 -7
- package/scripts/gates-engine.js +17 -0
- package/scripts/harness-tool-names.js +70 -0
- package/scripts/hook-runtime.js +10 -3
- package/scripts/lesson-db.js +16 -5
- package/scripts/lesson-embedding-index.js +67 -20
- package/scripts/lesson-embedding-maintenance.js +177 -0
- package/scripts/lesson-reranker.js +55 -9
- package/scripts/lesson-retrieval.js +305 -29
- package/scripts/lesson-search.js +22 -8
- package/scripts/llm-client.js +304 -15
- package/scripts/model-tier-router.js +593 -0
- package/scripts/pragmatic-hybrid-search.js +379 -0
- package/scripts/rag-document-pipeline.js +461 -0
- package/scripts/rag-structured-output.js +441 -0
- package/scripts/ragas-style-metrics.js +351 -0
- package/scripts/request-envelope.js +178 -0
- package/scripts/rerank-pipeline.js +370 -0
- package/scripts/rerank-quality-eval.js +155 -0
- package/scripts/retrieval-hybrid-ablation.js +120 -0
- package/scripts/retrieval-quality-tier.js +118 -0
- package/scripts/secret-scanner.js +395 -4
- package/scripts/self-distill-agent.js +7 -1
- package/scripts/self-healing-check.js +25 -0
- package/scripts/skill-packs.js +183 -0
- package/scripts/slow-loop.js +72 -0
- package/scripts/statusline-links.js +1 -1
- package/scripts/statusline.sh +8 -1
- package/scripts/telemetry-analytics.js +13 -1
- package/scripts/thumbgate-search.js +98 -6
- package/scripts/tier-budget-guard.js +186 -0
- package/scripts/tool-registry.js +46 -0
- package/scripts/vector-store.js +108 -4
- package/scripts/verify-marketing-pages-deployed.js +85 -3
- package/server.json +44 -0
- package/smithery.yaml +17 -0
- package/src/api/server.js +194 -13
package/scripts/feedback-loop.js
CHANGED
|
@@ -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
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
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
|
-
|
|
2302
|
-
const
|
|
2303
|
-
|
|
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: ${
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
if (env.THUMBGATE_FEEDBACK_DIR
|
|
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
|
-
|
|
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
|
|
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
|
|
161
|
-
|
|
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
|
-
|
|
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
|
package/scripts/gates-engine.js
CHANGED
|
@@ -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 };
|
package/scripts/hook-runtime.js
CHANGED
|
@@ -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
|
|
package/scripts/lesson-db.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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 ||
|
|
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
|
-
|
|
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
|
-
|
|
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;
|