thumbgate 1.30.0 → 1.34.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 (92) 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 +105 -10
  7. package/adapters/opencode/opencode.json +1 -1
  8. package/bench/observability-eval-suite.json +2 -2
  9. package/bin/cli.js +168 -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/claim-verifiers.example.json +42 -0
  15. package/config/gates/claim-verifiers.json +25 -0
  16. package/config/gates/default.json +217 -50
  17. package/config/mcp-allowlists.json +233 -206
  18. package/config/model-tiers.json +7 -2
  19. package/glama.json +6 -0
  20. package/hooks/hooks.json +1 -1
  21. package/package.json +69 -12
  22. package/public/assets/diagrams/before-after.svg +17 -16
  23. package/public/assets/diagrams/hero-thumbs.svg +68 -0
  24. package/public/assets/diagrams/loop.svg +19 -13
  25. package/public/assets/diagrams/self-improving-thumbs-loop.svg +105 -0
  26. package/public/compare.html +1 -0
  27. package/public/dashboard.html +126 -28
  28. package/public/evaluations.html +1 -1
  29. package/public/index.html +142 -13
  30. package/public/numbers.html +3 -2
  31. package/public/pricing.html +143 -30
  32. package/scripts/a-plus-evidence-scorecard.js +303 -0
  33. package/scripts/agent-readiness.js +110 -0
  34. package/scripts/async-eval-observability.js +36 -11
  35. package/scripts/audit-trail.js +37 -1
  36. package/scripts/auto-promote-gates.js +149 -34
  37. package/scripts/auto-wire-hooks.js +20 -8
  38. package/scripts/cli-schema.js +14 -0
  39. package/scripts/colbert-style-maxsim.js +236 -0
  40. package/scripts/cross-encoder-reranker.js +356 -126
  41. package/scripts/dashboard-chat.js +350 -17
  42. package/scripts/document-intake.js +283 -7
  43. package/scripts/eval-quality-suite.js +204 -0
  44. package/scripts/feedback-loop.js +115 -7
  45. package/scripts/feedback-paths.js +32 -13
  46. package/scripts/feedback-quality.js +53 -0
  47. package/scripts/feedback-schema.js +3 -0
  48. package/scripts/file-ledger-lock.js +130 -0
  49. package/scripts/filesystem-search.js +17 -7
  50. package/scripts/financial-control-plane.js +1514 -0
  51. package/scripts/gates-engine.js +202 -7
  52. package/scripts/gemini-embedding-policy.js +1 -0
  53. package/scripts/harness-tool-names.js +70 -0
  54. package/scripts/hook-runtime.js +15 -3
  55. package/scripts/hook-stop-anti-claim.js +63 -3
  56. package/scripts/human-escalation.js +353 -41
  57. package/scripts/lesson-db.js +16 -5
  58. package/scripts/lesson-embedding-index.js +67 -20
  59. package/scripts/lesson-embedding-maintenance.js +177 -0
  60. package/scripts/lesson-reranker.js +55 -9
  61. package/scripts/lesson-retrieval.js +305 -29
  62. package/scripts/lesson-search.js +22 -8
  63. package/scripts/llm-client.js +304 -15
  64. package/scripts/model-tier-router.js +593 -0
  65. package/scripts/pragmatic-hybrid-search.js +379 -0
  66. package/scripts/provider-action-normalizer.js +11 -4
  67. package/scripts/rag-document-pipeline.js +461 -0
  68. package/scripts/rag-structured-output.js +441 -0
  69. package/scripts/ragas-style-metrics.js +351 -0
  70. package/scripts/request-envelope.js +178 -0
  71. package/scripts/rerank-pipeline.js +370 -0
  72. package/scripts/rerank-quality-eval.js +155 -0
  73. package/scripts/retrieval-hybrid-ablation.js +120 -0
  74. package/scripts/retrieval-quality-tier.js +118 -0
  75. package/scripts/secret-scanner.js +395 -4
  76. package/scripts/self-distill-agent.js +7 -1
  77. package/scripts/self-healing-check.js +25 -0
  78. package/scripts/skill-packs.js +183 -0
  79. package/scripts/slow-loop.js +72 -0
  80. package/scripts/statusline-links.js +1 -1
  81. package/scripts/statusline.sh +8 -1
  82. package/scripts/telemetry-analytics.js +13 -1
  83. package/scripts/thumbgate-search.js +98 -6
  84. package/scripts/tier-budget-guard.js +186 -0
  85. package/scripts/tool-registry.js +141 -5
  86. package/scripts/universal-claim-evaluator.js +767 -0
  87. package/scripts/vector-store.js +154 -17
  88. package/scripts/verify-marketing-pages-deployed.js +85 -3
  89. package/scripts/workflow-sentinel.js +77 -11
  90. package/server.json +44 -0
  91. package/smithery.yaml +17 -0
  92. package/src/api/server.js +196 -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
  };
@@ -31,6 +31,9 @@ const {
31
31
  } = require('./feedback-quality');
32
32
 
33
33
  const INFERRED_TAG_RULES = [
34
+ { tag: 'claw-style', keywords: ['claw', 'enterprise-claw', 'openshell', 'dynamic-tool', 'screen-interaction', 'computer-use'] },
35
+ { tag: 'hybrid-inference', keywords: ['hybrid', 'cloud-escalation', 'local-route', 'hybrid-route', 'perplexity-pc'] },
36
+ { tag: 'agent-identity', keywords: ['agent identity', 'audit trail', 'identity separation', 'agent-credential'] },
34
37
  { tag: 'thumbgate', keywords: ['thumbgate', 'feedback-loop', 'statusline', 'dashboard', 'mcp'] },
35
38
  { tag: 'testing', keywords: ['test', 'testing', 'jest', 'coverage', 'verify', 'verification'] },
36
39
  { tag: 'security', keywords: ['security', 'secret', 'credential', 'token', 'auth'] },
@@ -0,0 +1,130 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * Cross-process lock for append-only local ledgers.
6
+ *
7
+ * `mkdir` is the atomic acquisition primitive. An owner record prevents a
8
+ * crashed process from permanently wedging the control plane, while a nonce
9
+ * check prevents an old owner from deleting a replacement lock.
10
+ */
11
+
12
+ const crypto = require('node:crypto');
13
+ const fs = require('node:fs');
14
+ const path = require('node:path');
15
+
16
+ const DEFAULT_STALE_MS = 30 * 1000;
17
+
18
+ function withFileLedgerLock(lockPath, callback, options = {}) {
19
+ const resolvedLockPath = path.resolve(lockPath);
20
+ fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
21
+ const owner = acquireLock(resolvedLockPath, options);
22
+ try {
23
+ if (typeof options.beforeCallback === 'function') options.beforeCallback();
24
+ return callback();
25
+ } finally {
26
+ releaseOwnedLock(resolvedLockPath, owner);
27
+ }
28
+ }
29
+
30
+ function acquireLock(lockPath, options) {
31
+ const now = options.now || new Date();
32
+ const staleMs = positiveNumber(options.lockStaleMs, DEFAULT_STALE_MS);
33
+ const owner = {
34
+ schemaVersion: 'thumbgate-ledger-lock-v1',
35
+ pid: process.pid,
36
+ nonce: crypto.randomUUID(),
37
+ acquiredAt: now.toISOString(),
38
+ };
39
+ for (let attempt = 0; attempt < 3; attempt += 1) {
40
+ try {
41
+ fs.mkdirSync(lockPath);
42
+ writeOwner(lockPath, owner);
43
+ return owner;
44
+ } catch (error) {
45
+ if (error.code !== 'EEXIST') throw error;
46
+ if (!recoverStaleLock(lockPath, now, staleMs)) {
47
+ throw lockError(options, 'ledger is busy; deny and retry only after the active writer finishes');
48
+ }
49
+ }
50
+ }
51
+ throw lockError(options, 'ledger lock could not be acquired after stale-lock recovery');
52
+ }
53
+
54
+ function recoverStaleLock(lockPath, now, staleMs) {
55
+ const observed = readOwner(lockPath);
56
+ const ageMs = lockAgeMs(lockPath, observed, now);
57
+ if (ageMs < staleMs || processIsAlive(observed?.pid)) return false;
58
+
59
+ // Rename is atomic. If another process already recovered or replaced the
60
+ // lock, this attempt loses harmlessly and acquisition is retried.
61
+ const quarantine = `${lockPath}.stale-${process.pid}-${crypto.randomUUID()}`;
62
+ try {
63
+ fs.renameSync(lockPath, quarantine);
64
+ } catch (error) {
65
+ if (['ENOENT', 'EEXIST'].includes(error.code)) return true;
66
+ throw error;
67
+ }
68
+ fs.rmSync(quarantine, { recursive: true, force: true });
69
+ return true;
70
+ }
71
+
72
+ function writeOwner(lockPath, owner) {
73
+ const target = path.join(lockPath, 'owner.json');
74
+ const temporary = `${target}.tmp-${process.pid}-${owner.nonce}`;
75
+ fs.writeFileSync(temporary, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 });
76
+ fs.renameSync(temporary, target);
77
+ }
78
+
79
+ function readOwner(lockPath) {
80
+ try {
81
+ return JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+
87
+ function lockAgeMs(lockPath, owner, now) {
88
+ const recorded = Date.parse(owner?.acquiredAt || '');
89
+ if (Number.isFinite(recorded)) return Math.max(0, now.getTime() - recorded);
90
+ try {
91
+ return Math.max(0, now.getTime() - fs.statSync(lockPath).mtimeMs);
92
+ } catch {
93
+ return Number.POSITIVE_INFINITY;
94
+ }
95
+ }
96
+
97
+ function processIsAlive(pid) {
98
+ if (!Number.isInteger(pid) || pid <= 0) return false;
99
+ try {
100
+ process.kill(pid, 0);
101
+ return true;
102
+ } catch (error) {
103
+ return error.code !== 'ESRCH';
104
+ }
105
+ }
106
+
107
+ function releaseOwnedLock(lockPath, owner) {
108
+ const recorded = readOwner(lockPath);
109
+ if (!recorded || recorded.nonce !== owner.nonce) return;
110
+ try { fs.unlinkSync(path.join(lockPath, 'owner.json')); } catch (error) {
111
+ if (error.code !== 'ENOENT') return;
112
+ }
113
+ try { fs.rmdirSync(lockPath); } catch { /* a replacement owner wins */ }
114
+ }
115
+
116
+ function positiveNumber(value, fallback) {
117
+ const number = Number(value);
118
+ return Number.isFinite(number) && number > 0 ? number : fallback;
119
+ }
120
+
121
+ function lockError(options, message) {
122
+ return typeof options.errorFactory === 'function'
123
+ ? options.errorFactory(message)
124
+ : Object.assign(new Error(message), { code: 'THUMBGATE_LEDGER_BUSY' });
125
+ }
126
+
127
+ module.exports = {
128
+ DEFAULT_STALE_MS,
129
+ withFileLedgerLock,
130
+ };
@@ -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