thumbgate 1.2.0 → 1.3.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 (54) hide show
  1. package/.claude-plugin/README.md +4 -4
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/mcp/server-card.json +1 -1
  5. package/README.md +35 -14
  6. package/adapters/README.md +1 -1
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/codex/config.toml +2 -2
  9. package/adapters/mcp/server-stdio.js +2 -2
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +20 -11
  12. package/config/github-about.json +1 -1
  13. package/config/model-tiers.json +11 -0
  14. package/package.json +8 -6
  15. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  16. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  17. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  18. package/plugins/codex-profile/.mcp.json +1 -1
  19. package/plugins/codex-profile/INSTALL.md +1 -1
  20. package/plugins/codex-profile/README.md +1 -1
  21. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  22. package/plugins/cursor-marketplace/README.md +2 -2
  23. package/plugins/cursor-marketplace/commands/capture-feedback.md +2 -2
  24. package/plugins/cursor-marketplace/rules/feedback-capture.mdc +3 -3
  25. package/plugins/cursor-marketplace/skills/capture-feedback/SKILL.md +3 -2
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/compare.html +4 -4
  28. package/public/guide.html +4 -4
  29. package/public/index.html +51 -38
  30. package/public/learn/ai-agent-persistent-memory.html +1 -0
  31. package/public/lessons.html +325 -17
  32. package/scripts/__pycache__/train_from_feedback.cpython-312.pyc +0 -0
  33. package/scripts/audit-trail.js +6 -0
  34. package/scripts/capture-railway-diagnostics.sh +97 -0
  35. package/scripts/check-congruence.js +1 -1
  36. package/scripts/claude-feedback-sync.js +320 -0
  37. package/scripts/cli-telemetry.js +4 -1
  38. package/scripts/contextfs.js +32 -23
  39. package/scripts/dashboard.js +84 -0
  40. package/scripts/feedback-loop.js +16 -0
  41. package/scripts/intervention-policy.js +696 -0
  42. package/scripts/local-model-profile.js +18 -2
  43. package/scripts/model-tier-router.js +10 -1
  44. package/scripts/operational-integrity.js +354 -31
  45. package/scripts/prove-adapters.js +1 -0
  46. package/scripts/prove-automation.js +2 -2
  47. package/scripts/prove-packaged-runtime.js +260 -0
  48. package/scripts/prove-runtime.js +13 -0
  49. package/scripts/rate-limiter.js +3 -3
  50. package/scripts/statusline-local-stats.js +2 -0
  51. package/scripts/statusline.sh +166 -11
  52. package/scripts/tool-registry.js +2 -2
  53. package/scripts/workflow-sentinel.js +114 -4
  54. package/skills/thumbgate/SKILL.md +1 -1
@@ -0,0 +1,320 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const {
7
+ captureFeedback,
8
+ getFeedbackPaths,
9
+ readJSONL,
10
+ analyzeFeedback,
11
+ } = require('./feedback-loop');
12
+ const { normalizeFeedbackText } = require('./feedback-quality');
13
+ const {
14
+ resolveFeedbackDir,
15
+ resolveProjectDir,
16
+ } = require('./feedback-paths');
17
+ const { refreshStatuslineCache } = require('./hook-thumbgate-cache-updater');
18
+
19
+ const SYNC_STATE_FILE = 'claude-feedback-sync-state.json';
20
+ const DEFAULT_RECENT_FEEDBACK_LIMIT = 250;
21
+ const DEFAULT_PROCESSED_ID_LIMIT = 512;
22
+ const DUPLICATE_WINDOW_MS = 30 * 1000;
23
+
24
+ function getClaudeHistoryPath(options = {}) {
25
+ if (options.historyPath) return options.historyPath;
26
+ if (process.env.THUMBGATE_CLAUDE_HISTORY_PATH) return process.env.THUMBGATE_CLAUDE_HISTORY_PATH;
27
+ const homeDir = options.homeDir || process.env.HOME || process.env.USERPROFILE || '';
28
+ return path.join(homeDir, '.claude', 'history.jsonl');
29
+ }
30
+
31
+ function getSyncStatePath(options = {}) {
32
+ const feedbackDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir });
33
+ return path.join(feedbackDir, SYNC_STATE_FILE);
34
+ }
35
+
36
+ function readSyncState(options = {}) {
37
+ const statePath = getSyncStatePath(options);
38
+ try {
39
+ const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8'));
40
+ return {
41
+ historyOffset: Number(parsed.historyOffset || 0),
42
+ historySize: Number(parsed.historySize || 0),
43
+ processedIds: Array.isArray(parsed.processedIds) ? parsed.processedIds : [],
44
+ statePath,
45
+ };
46
+ } catch {
47
+ return {
48
+ historyOffset: 0,
49
+ historySize: 0,
50
+ processedIds: [],
51
+ statePath,
52
+ };
53
+ }
54
+ }
55
+
56
+ function writeSyncState(state, options = {}) {
57
+ const statePath = getSyncStatePath(options);
58
+ const payload = {
59
+ historyOffset: Number(state.historyOffset || 0),
60
+ historySize: Number(state.historySize || 0),
61
+ processedIds: Array.isArray(state.processedIds) ? state.processedIds.slice(-DEFAULT_PROCESSED_ID_LIMIT) : [],
62
+ updatedAt: new Date().toISOString(),
63
+ };
64
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
65
+ fs.writeFileSync(statePath, JSON.stringify(payload, null, 2));
66
+ return payload;
67
+ }
68
+
69
+ function readHistoryEntriesSince(filePath, state) {
70
+ if (!filePath || !fs.existsSync(filePath)) {
71
+ return {
72
+ entries: [],
73
+ nextOffset: 0,
74
+ size: 0,
75
+ };
76
+ }
77
+
78
+ const stat = fs.statSync(filePath);
79
+ const safeOffset = state && state.historyOffset > 0 && state.historyOffset <= stat.size
80
+ ? state.historyOffset
81
+ : 0;
82
+
83
+ const fileBuffer = fs.readFileSync(filePath);
84
+ const contents = fileBuffer.slice(safeOffset).toString('utf8');
85
+
86
+ const entries = contents
87
+ .split('\n')
88
+ .map((line) => line.trim())
89
+ .filter(Boolean)
90
+ .map((line) => {
91
+ try {
92
+ return JSON.parse(line);
93
+ } catch {
94
+ return null;
95
+ }
96
+ })
97
+ .filter(Boolean);
98
+
99
+ return {
100
+ entries,
101
+ nextOffset: stat.size,
102
+ size: stat.size,
103
+ };
104
+ }
105
+
106
+ function normalizeProjectPath(value) {
107
+ try {
108
+ return value ? path.resolve(String(value)) : null;
109
+ } catch {
110
+ return null;
111
+ }
112
+ }
113
+
114
+ function parseHistoryTimestamp(value) {
115
+ if (typeof value === 'number' && Number.isFinite(value)) {
116
+ return value > 1e12 ? value : value * 1000;
117
+ }
118
+ const text = String(value || '').trim();
119
+ if (!text) return null;
120
+ if (/^\d+$/.test(text)) {
121
+ const numeric = Number(text);
122
+ return numeric > 1e12 ? numeric : numeric * 1000;
123
+ }
124
+ const parsed = Date.parse(text);
125
+ return Number.isFinite(parsed) ? parsed : null;
126
+ }
127
+
128
+ function detectSignal(text) {
129
+ const normalized = String(text || '').toLowerCase();
130
+ if (/(thumbs?\s*down|that failed|that was wrong|fix this)/i.test(normalized)) return 'down';
131
+ if (/(thumbs?\s*up|that worked|looks good|nice work|perfect|good job)/i.test(normalized)) return 'up';
132
+ return null;
133
+ }
134
+
135
+ function extractPromptText(entry) {
136
+ const candidates = [
137
+ entry && entry.display,
138
+ entry && entry.message && entry.message.content,
139
+ entry && entry.attachment && entry.attachment.prompt,
140
+ entry && entry.content,
141
+ ];
142
+
143
+ for (const candidate of candidates) {
144
+ if (typeof candidate === 'string' && candidate.trim()) {
145
+ return candidate.trim();
146
+ }
147
+ }
148
+ return '';
149
+ }
150
+
151
+ function buildExternalId(entry, promptText) {
152
+ const hash = crypto.createHash('sha256');
153
+ hash.update(String(entry.sessionId || ''));
154
+ hash.update('|');
155
+ hash.update(String(entry.timestamp || ''));
156
+ hash.update('|');
157
+ hash.update(String(entry.project || entry.cwd || ''));
158
+ hash.update('|');
159
+ hash.update(String(promptText || ''));
160
+ return `claude-history:${hash.digest('hex')}`;
161
+ }
162
+
163
+ function toHistoryCandidate(entry, options = {}) {
164
+ const promptText = extractPromptText(entry);
165
+ const signal = detectSignal(promptText);
166
+ if (!signal) return null;
167
+
168
+ const projectDir = normalizeProjectPath(options.projectDir);
169
+ const entryProject = normalizeProjectPath(entry.project || entry.cwd || '');
170
+ if (projectDir && entryProject && entryProject !== projectDir && !entryProject.startsWith(`${projectDir}${path.sep}`)) {
171
+ return null;
172
+ }
173
+ if (projectDir && !entryProject) {
174
+ return null;
175
+ }
176
+
177
+ return {
178
+ externalId: buildExternalId(entry, promptText),
179
+ promptText,
180
+ signal,
181
+ timestampMs: parseHistoryTimestamp(entry.timestamp),
182
+ };
183
+ }
184
+
185
+ function normalizeCandidateText(value) {
186
+ return normalizeFeedbackText(String(value || '').replace(/\s+/g, ' '));
187
+ }
188
+
189
+ function hasMatchingFeedbackEntry(candidate, feedbackEntries) {
190
+ const candidateText = normalizeCandidateText(candidate.promptText);
191
+ if (!candidateText) return false;
192
+
193
+ return feedbackEntries.some((entry) => {
194
+ const signal = entry && entry.signal === 'negative' ? 'down' : 'up';
195
+ if (signal !== candidate.signal) return false;
196
+
197
+ const feedbackText = normalizeCandidateText(
198
+ entry.submittedContext
199
+ || entry.context
200
+ || entry.whatWentWrong
201
+ || entry.whatWorked
202
+ || ''
203
+ );
204
+ if (feedbackText !== candidateText) return false;
205
+
206
+ const feedbackTimestamp = Date.parse(entry.timestamp || '');
207
+ if (!Number.isFinite(feedbackTimestamp) || !Number.isFinite(candidate.timestampMs)) {
208
+ return true;
209
+ }
210
+ return Math.abs(feedbackTimestamp - candidate.timestampMs) <= DUPLICATE_WINDOW_MS;
211
+ });
212
+ }
213
+
214
+ function syncClaudeHistoryFeedback(options = {}) {
215
+ if (options.disabled || process.env.THUMBGATE_DISABLE_CLAUDE_HISTORY_SYNC === '1') {
216
+ return {
217
+ importedCount: 0,
218
+ skippedCount: 0,
219
+ reason: 'disabled',
220
+ };
221
+ }
222
+
223
+ const originalEnv = {
224
+ THUMBGATE_FEEDBACK_DIR: process.env.THUMBGATE_FEEDBACK_DIR,
225
+ THUMBGATE_PROJECT_DIR: process.env.THUMBGATE_PROJECT_DIR,
226
+ THUMBGATE_CLAUDE_HISTORY_PATH: process.env.THUMBGATE_CLAUDE_HISTORY_PATH,
227
+ };
228
+
229
+ if (options.feedbackDir) process.env.THUMBGATE_FEEDBACK_DIR = options.feedbackDir;
230
+ if (options.projectDir && !options.feedbackDir) process.env.THUMBGATE_PROJECT_DIR = options.projectDir;
231
+ if (options.historyPath) process.env.THUMBGATE_CLAUDE_HISTORY_PATH = options.historyPath;
232
+
233
+ try {
234
+ const feedbackDir = resolveFeedbackDir({ feedbackDir: options.feedbackDir });
235
+ const projectDir = normalizeProjectPath(options.projectDir) || resolveProjectDir({
236
+ cwd: process.cwd(),
237
+ env: process.env,
238
+ });
239
+ const historyPath = getClaudeHistoryPath(options);
240
+ const state = readSyncState({ feedbackDir });
241
+ const history = readHistoryEntriesSince(historyPath, state);
242
+ const existingEntries = readJSONL(path.join(feedbackDir, 'feedback-log.jsonl'), {
243
+ maxLines: DEFAULT_RECENT_FEEDBACK_LIMIT,
244
+ });
245
+
246
+ let importedCount = 0;
247
+ let skippedCount = 0;
248
+ const processedIds = new Set(state.processedIds || []);
249
+
250
+ for (const entry of history.entries) {
251
+ const candidate = toHistoryCandidate(entry, { projectDir });
252
+ if (!candidate) continue;
253
+ if (processedIds.has(candidate.externalId)) {
254
+ skippedCount += 1;
255
+ continue;
256
+ }
257
+
258
+ if (hasMatchingFeedbackEntry(candidate, existingEntries)) {
259
+ processedIds.add(candidate.externalId);
260
+ skippedCount += 1;
261
+ continue;
262
+ }
263
+
264
+ const captureResult = captureFeedback({
265
+ signal: candidate.signal,
266
+ context: candidate.promptText,
267
+ whatWentWrong: candidate.signal === 'down' ? candidate.promptText : undefined,
268
+ whatWorked: candidate.signal === 'up' ? candidate.promptText : undefined,
269
+ tags: ['claude-history-sync', 'auto-capture-fallback'],
270
+ });
271
+
272
+ if (captureResult && captureResult.feedbackEvent) {
273
+ existingEntries.push(captureResult.feedbackEvent);
274
+ }
275
+ processedIds.add(candidate.externalId);
276
+ importedCount += 1;
277
+ }
278
+
279
+ writeSyncState({
280
+ historyOffset: history.nextOffset,
281
+ historySize: history.size,
282
+ processedIds: Array.from(processedIds),
283
+ }, { feedbackDir });
284
+
285
+ if (importedCount > 0) {
286
+ refreshStatuslineCache(analyzeFeedback(path.join(feedbackDir, 'feedback-log.jsonl')), path.join(feedbackDir, 'statusline_cache.json'));
287
+ }
288
+
289
+ return {
290
+ importedCount,
291
+ skippedCount,
292
+ historyPath,
293
+ feedbackDir,
294
+ projectDir,
295
+ };
296
+ } finally {
297
+ if (originalEnv.THUMBGATE_FEEDBACK_DIR == null) delete process.env.THUMBGATE_FEEDBACK_DIR;
298
+ else process.env.THUMBGATE_FEEDBACK_DIR = originalEnv.THUMBGATE_FEEDBACK_DIR;
299
+
300
+ if (originalEnv.THUMBGATE_PROJECT_DIR == null) delete process.env.THUMBGATE_PROJECT_DIR;
301
+ else process.env.THUMBGATE_PROJECT_DIR = originalEnv.THUMBGATE_PROJECT_DIR;
302
+
303
+ if (originalEnv.THUMBGATE_CLAUDE_HISTORY_PATH == null) delete process.env.THUMBGATE_CLAUDE_HISTORY_PATH;
304
+ else process.env.THUMBGATE_CLAUDE_HISTORY_PATH = originalEnv.THUMBGATE_CLAUDE_HISTORY_PATH;
305
+ }
306
+ }
307
+
308
+ module.exports = {
309
+ SYNC_STATE_FILE,
310
+ detectSignal,
311
+ extractPromptText,
312
+ getClaudeHistoryPath,
313
+ hasMatchingFeedbackEntry,
314
+ parseHistoryTimestamp,
315
+ readHistoryEntriesSince,
316
+ readSyncState,
317
+ syncClaudeHistoryFeedback,
318
+ toHistoryCandidate,
319
+ writeSyncState,
320
+ };
@@ -5,7 +5,9 @@ const crypto = require('crypto');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
 
8
- const TELEMETRY_ENDPOINT = 'https://thumbgate-production.up.railway.app/v1/telemetry/ping';
8
+ const _DEFAULT_TELEMETRY_HOST = 'https://thumbgate-production.up.railway.app';
9
+ // Respect THUMBGATE_API_URL so test environments can point to a local stub
10
+ const TELEMETRY_ENDPOINT = `${process.env.THUMBGATE_API_URL || _DEFAULT_TELEMETRY_HOST}/v1/telemetry/ping`;
9
11
  const INSTALL_ID_PATH = path.join(process.env.HOME || process.env.USERPROFILE || '.', '.thumbgate', 'install-id');
10
12
 
11
13
  /**
@@ -80,6 +82,7 @@ function trackEvent(eventType, metadata = {}) {
80
82
  });
81
83
  req.on('error', () => {}); // silently ignore
82
84
  req.on('timeout', () => req.destroy());
85
+ req.on('socket', (s) => s.unref()); // fire-and-forget: never block process exit
83
86
  req.end(payload);
84
87
  } catch (_) {} // never crash the CLI
85
88
  }
@@ -23,9 +23,15 @@ function getFeedbackBaseDir() {
23
23
  return resolveFeedbackDir();
24
24
  }
25
25
 
26
- const FEEDBACK_DIR = getFeedbackBaseDir();
27
- const CONTEXTFS_ROOT = process.env.THUMBGATE_CONTEXTFS_DIR
28
- || (FEEDBACK_DIR.endsWith('contextfs') ? FEEDBACK_DIR : path.join(FEEDBACK_DIR, 'contextfs'));
26
+ function getContextFsRoot() {
27
+ const feedbackDir = getFeedbackBaseDir();
28
+ if (process.env.THUMBGATE_CONTEXTFS_DIR) return process.env.THUMBGATE_CONTEXTFS_DIR;
29
+ return feedbackDir.endsWith('contextfs') ? feedbackDir : path.join(feedbackDir, 'contextfs');
30
+ }
31
+
32
+ function contextFsPath(...segments) {
33
+ return path.join(getContextFsRoot(), ...segments);
34
+ }
29
35
 
30
36
  const NAMESPACES = {
31
37
  rawHistory: 'raw_history',
@@ -101,7 +107,7 @@ function ensureDir(dirPath) {
101
107
 
102
108
  function ensureContextFs() {
103
109
  Object.values(NAMESPACES).forEach((subPath) => {
104
- ensureDir(path.join(CONTEXTFS_ROOT, subPath));
110
+ ensureDir(contextFsPath(subPath));
105
111
  });
106
112
  }
107
113
 
@@ -111,7 +117,7 @@ function nowIso() {
111
117
 
112
118
  function inferNamespaceFromPath(filePath) {
113
119
  if (!filePath) return '';
114
- const relativeDir = path.relative(CONTEXTFS_ROOT, path.dirname(filePath));
120
+ const relativeDir = path.relative(getContextFsRoot(), path.dirname(filePath));
115
121
  if (!relativeDir || relativeDir.startsWith('..')) return '';
116
122
  return relativeDir;
117
123
  }
@@ -211,7 +217,7 @@ function getSemanticCacheConfig() {
211
217
  }
212
218
 
213
219
  function getSemanticCachePath() {
214
- return path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'semantic-cache.jsonl');
220
+ return contextFsPath(NAMESPACES.provenance, 'semantic-cache.jsonl');
215
221
  }
216
222
 
217
223
  function loadSemanticCacheEntries() {
@@ -227,7 +233,7 @@ function getSourceHash(namespaces) {
227
233
  const normalizedNamespaces = normalizeNamespaces(namespaces);
228
234
 
229
235
  for (const ns of normalizedNamespaces) {
230
- const dirPath = path.join(CONTEXTFS_ROOT, ns);
236
+ const dirPath = contextFsPath(ns);
231
237
  if (!fs.existsSync(dirPath)) continue;
232
238
 
233
239
  const files = fs.readdirSync(dirPath).sort();
@@ -291,7 +297,7 @@ function recordProvenance(event) {
291
297
  timestamp: nowIso(),
292
298
  ...event,
293
299
  };
294
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'events.jsonl'), payload);
300
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'events.jsonl'), payload);
295
301
  return payload;
296
302
  }
297
303
 
@@ -299,7 +305,7 @@ function writeContextObject({ namespace, title, content, tags = [], source, ttl
299
305
  ensureContextFs();
300
306
 
301
307
  const id = `${Date.now()}_${toSlug(title)}`;
302
- const filePath = path.join(CONTEXTFS_ROOT, namespace, `${id}.json`);
308
+ const filePath = contextFsPath(namespace, `${id}.json`);
303
309
 
304
310
  const doc = {
305
311
  id,
@@ -361,7 +367,7 @@ function findExistingContextObject({ namespace, title, content, tags = [], sourc
361
367
  ensureContextFs();
362
368
 
363
369
  const expectedTags = normalizeTagList(tags);
364
- const dirPath = path.join(CONTEXTFS_ROOT, namespace);
370
+ const dirPath = contextFsPath(namespace);
365
371
  const files = listJsonFiles(dirPath).sort();
366
372
 
367
373
  for (const filePath of files) {
@@ -507,7 +513,7 @@ function loadCandidates(namespaces) {
507
513
  const docs = [];
508
514
 
509
515
  selected.forEach((namespace) => {
510
- const dir = path.join(CONTEXTFS_ROOT, namespace);
516
+ const dir = contextFsPath(namespace);
511
517
  const files = listJsonFiles(dir);
512
518
  files.forEach((filePath) => {
513
519
  try {
@@ -624,7 +630,7 @@ function selectFlatContextItems(candidates, maxItems, maxChars) {
624
630
  const MEMEX_INDEX_FILE = 'memex-index.jsonl';
625
631
 
626
632
  function getMemexIndexPath() {
627
- return path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, MEMEX_INDEX_FILE);
633
+ return contextFsPath(NAMESPACES.provenance, MEMEX_INDEX_FILE);
628
634
  }
629
635
 
630
636
  function buildIndexEntry(doc, filePath) {
@@ -751,7 +757,7 @@ function constructMemexPack({ query = '', maxItems = 8, maxChars = 6000, namespa
751
757
  cache: { hit: false },
752
758
  };
753
759
 
754
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
760
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
755
761
  recordProvenance({
756
762
  type: 'memex_pack_constructed',
757
763
  packId,
@@ -792,7 +798,7 @@ function constructContextPack({ query = '', maxItems = 8, maxChars = 6000, names
792
798
  },
793
799
  };
794
800
 
795
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
801
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
796
802
  recordProvenance({
797
803
  type: 'context_pack_cache_hit',
798
804
  packId,
@@ -861,7 +867,7 @@ function constructContextPack({ query = '', maxItems = 8, maxChars = 6000, names
861
867
  retrieval: selection.retrieval,
862
868
  };
863
869
 
864
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
870
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
865
871
  appendSemanticCacheEntry({
866
872
  id: `cache_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
867
873
  timestamp: nowIso(),
@@ -899,7 +905,7 @@ function evaluateContextPack({ packId, outcome, signal = null, notes = '', rubri
899
905
  timestamp: nowIso(),
900
906
  };
901
907
 
902
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'evaluations.jsonl'), evaluation);
908
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'evaluations.jsonl'), evaluation);
903
909
  recordProvenance({
904
910
  type: 'context_pack_evaluated',
905
911
  packId,
@@ -912,7 +918,7 @@ function evaluateContextPack({ packId, outcome, signal = null, notes = '', rubri
912
918
  }
913
919
 
914
920
  function getProvenance(limit = 50) {
915
- const eventsPath = path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'events.jsonl');
921
+ const eventsPath = contextFsPath(NAMESPACES.provenance, 'events.jsonl');
916
922
  const events = readJsonl(eventsPath);
917
923
  return events.slice(-limit);
918
924
  }
@@ -923,7 +929,7 @@ function getProvenance(limit = 50) {
923
929
  * session starts with full context — no manual primer.md needed.
924
930
  */
925
931
  function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, openFiles, customContext } = {}) {
926
- ensureDir(path.join(CONTEXTFS_ROOT, NAMESPACES.session));
932
+ ensureDir(contextFsPath(NAMESPACES.session));
927
933
 
928
934
  let gitContext = {};
929
935
  try {
@@ -951,7 +957,7 @@ function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, op
951
957
  customContext: customContext || null,
952
958
  };
953
959
 
954
- const primerPath = path.join(CONTEXTFS_ROOT, NAMESPACES.session, 'primer.json');
960
+ const primerPath = contextFsPath(NAMESPACES.session, 'primer.json');
955
961
  fs.writeFileSync(primerPath, JSON.stringify(primer, null, 2));
956
962
 
957
963
  // Sync to primer.md if it exists
@@ -991,7 +997,7 @@ function writeSessionHandoff({ project, branch, lastTask, nextStep, blockers, op
991
997
  * Read the most recent session handoff primer.
992
998
  */
993
999
  function readSessionHandoff() {
994
- const primerPath = path.join(CONTEXTFS_ROOT, NAMESPACES.session, 'primer.json');
1000
+ const primerPath = contextFsPath(NAMESPACES.session, 'primer.json');
995
1001
  if (!fs.existsSync(primerPath)) return null;
996
1002
  try {
997
1003
  return JSON.parse(fs.readFileSync(primerPath, 'utf8'));
@@ -1192,7 +1198,7 @@ function constructMultiHopPack({ query = '', maxItems = 8, maxChars = 6000, name
1192
1198
  },
1193
1199
  };
1194
1200
 
1195
- appendJsonl(path.join(CONTEXTFS_ROOT, NAMESPACES.provenance, 'packs.jsonl'), pack);
1201
+ appendJsonl(contextFsPath(NAMESPACES.provenance, 'packs.jsonl'), pack);
1196
1202
  appendSemanticCacheEntry({
1197
1203
  id: `cache_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
1198
1204
  timestamp: nowIso(),
@@ -1244,7 +1250,10 @@ function listPackTemplates() {
1244
1250
  }
1245
1251
 
1246
1252
  module.exports = {
1247
- CONTEXTFS_ROOT,
1253
+ get CONTEXTFS_ROOT() {
1254
+ return getContextFsRoot();
1255
+ },
1256
+ getContextFsRoot,
1248
1257
  NAMESPACES,
1249
1258
  ensureContextFs,
1250
1259
  recordProvenance,
@@ -1283,5 +1292,5 @@ module.exports = {
1283
1292
 
1284
1293
  if (require.main === module) {
1285
1294
  ensureContextFs();
1286
- console.log(`ContextFS ready at ${CONTEXTFS_ROOT}`);
1295
+ console.log(`ContextFS ready at ${getContextFsRoot()}`);
1287
1296
  }
@@ -4,6 +4,7 @@
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { aggregateFailureDiagnostics } = require('./failure-diagnostics');
7
+ const { AUDIT_LOG_FILENAME } = require('./audit-trail');
7
8
  const { getBillingSummary, loadFunnelLedger, loadResolvedRevenueEvents } = require('./billing');
8
9
  const { getTelemetryAnalytics, loadTelemetryEvents } = require('./telemetry-analytics');
9
10
  const { getAutoGatesPath } = require('./auto-promote-gates');
@@ -19,6 +20,7 @@ const { routeProfile } = require('./profile-router');
19
20
  const { getSettingsStatus } = require('./settings-hierarchy');
20
21
  const { summarizeWorkflowRuns } = require('./workflow-runs');
21
22
  const { searchLessons } = require('./lesson-search');
23
+ const { getInterventionPolicySummary } = require('./intervention-policy');
22
24
 
23
25
  const PROJECT_ROOT = path.join(__dirname, '..');
24
26
  const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
@@ -60,6 +62,15 @@ function pickFirstText(...values) {
60
62
  return null;
61
63
  }
62
64
 
65
+ function toLocalDayKey(value) {
66
+ const ts = value instanceof Date ? value : new Date(value);
67
+ if (Number.isNaN(ts.getTime())) return null;
68
+ const year = ts.getFullYear();
69
+ const month = String(ts.getMonth() + 1).padStart(2, '0');
70
+ const day = String(ts.getDate()).padStart(2, '0');
71
+ return `${year}-${month}-${day}`;
72
+ }
73
+
63
74
  // ---------------------------------------------------------------------------
64
75
  // Approval rate + trend
65
76
  // ---------------------------------------------------------------------------
@@ -143,6 +154,58 @@ function computeGateStats() {
143
154
  };
144
155
  }
145
156
 
157
+ function computeGateAuditSeries(feedbackDir, options = {}) {
158
+ const auditLogPath = path.join(feedbackDir, AUDIT_LOG_FILENAME);
159
+ const entries = readJSONL(auditLogPath).filter((entry) => entry && entry.timestamp);
160
+ const dayCount = Number.isInteger(options.dayCount) ? options.dayCount : 14;
161
+ const today = new Date();
162
+ today.setHours(0, 0, 0, 0);
163
+ const countsByDay = new Map();
164
+
165
+ for (const entry of entries) {
166
+ if (!['allow', 'deny', 'warn'].includes(entry.decision)) continue;
167
+ const dayKey = toLocalDayKey(entry.timestamp);
168
+ if (!dayKey) continue;
169
+ if (!countsByDay.has(dayKey)) {
170
+ countsByDay.set(dayKey, { allow: 0, deny: 0, warn: 0 });
171
+ }
172
+ countsByDay.get(dayKey)[entry.decision] += 1;
173
+ }
174
+
175
+ const days = [];
176
+ const totals = { allow: 0, deny: 0, warn: 0, intercepted: 0, total: 0 };
177
+
178
+ for (let offset = dayCount - 1; offset >= 0; offset -= 1) {
179
+ const day = new Date(today);
180
+ day.setDate(today.getDate() - offset);
181
+ const dayKey = toLocalDayKey(day);
182
+ const record = countsByDay.get(dayKey) || { allow: 0, deny: 0, warn: 0 };
183
+ const intercepted = record.deny + record.warn;
184
+ const total = intercepted + record.allow;
185
+ const summary = {
186
+ dayKey,
187
+ allow: record.allow,
188
+ deny: record.deny,
189
+ warn: record.warn,
190
+ intercepted,
191
+ total,
192
+ };
193
+ totals.allow += record.allow;
194
+ totals.deny += record.deny;
195
+ totals.warn += record.warn;
196
+ totals.intercepted += intercepted;
197
+ totals.total += total;
198
+ days.push(summary);
199
+ }
200
+
201
+ return {
202
+ dayCount,
203
+ days,
204
+ totals,
205
+ activeDays: days.filter((day) => day.total > 0).length,
206
+ };
207
+ }
208
+
146
209
  function listActiveGates() {
147
210
  try {
148
211
  const config = loadGatesConfig();
@@ -710,6 +773,7 @@ function generateDashboard(feedbackDir, options = {}) {
710
773
  const prevention = computePreventionImpact(feedbackDir, gateStats);
711
774
  const trend = computeSessionTrend(entries, 10);
712
775
  const health = computeSystemHealth(feedbackDir, gateStats);
776
+ const gateAudit = computeGateAuditSeries(feedbackDir);
713
777
  const diagnostics = aggregateFailureDiagnostics([...entries, ...diagnosticEntries]);
714
778
  const secretGuard = computeSecretGuardStats(diagnosticEntries);
715
779
  const gates = listActiveGates();
@@ -722,6 +786,7 @@ function generateDashboard(feedbackDir, options = {}) {
722
786
  const delegation = summarizeDelegation(feedbackDir);
723
787
  const readiness = generateAgentReadinessReport({ projectRoot: PROJECT_ROOT });
724
788
  const harness = computeHarnessOverview(feedbackDir, entries);
789
+ const interventionPolicy = getInterventionPolicySummary(feedbackDir);
725
790
  const settingsStatus = getSettingsStatus({ projectRoot: PROJECT_ROOT });
726
791
  settingsStatus.routingPreview = {
727
792
  dashboardTool: routeProfile({
@@ -782,6 +847,7 @@ function generateDashboard(feedbackDir, options = {}) {
782
847
  prevention,
783
848
  trend,
784
849
  health,
850
+ gateAudit,
785
851
  diagnostics,
786
852
  delegation,
787
853
  secretGuard,
@@ -790,6 +856,7 @@ function generateDashboard(feedbackDir, options = {}) {
790
856
  observability,
791
857
  instrumentation,
792
858
  readiness,
859
+ interventionPolicy,
793
860
  settingsStatus,
794
861
  team,
795
862
  templateLibrary,
@@ -809,6 +876,7 @@ function printDashboard(data) {
809
876
  prevention,
810
877
  trend,
811
878
  health,
879
+ gateAudit,
812
880
  diagnostics,
813
881
  delegation,
814
882
  secretGuard,
@@ -817,6 +885,7 @@ function printDashboard(data) {
817
885
  observability,
818
886
  instrumentation,
819
887
  readiness,
888
+ interventionPolicy,
820
889
  settingsStatus,
821
890
  team,
822
891
  templateLibrary,
@@ -862,6 +931,20 @@ function printDashboard(data) {
862
931
  console.log(` Top Next Fix : ${harness.topRecommendations[0].type} (${harness.topRecommendations[0].count} lessons)`);
863
932
  }
864
933
 
934
+ console.log('');
935
+ console.log('🧠 Learned Policy');
936
+ console.log(` Enabled : ${interventionPolicy.enabled ? 'yes' : 'no'}`);
937
+ console.log(` Examples : ${interventionPolicy.exampleCount}`);
938
+ console.log(` Train Accuracy : ${Math.round((interventionPolicy.metrics.trainingAccuracy || 0) * 100)}%`);
939
+ console.log(` Holdout Accuracy : ${Math.round((interventionPolicy.metrics.holdoutAccuracy || 0) * 100)}%`);
940
+ console.log(` Recent Pressure : ${Math.round((interventionPolicy.nonAllowRate || 0) * 100)}% non-allow`);
941
+ if (interventionPolicy.updatedAt) {
942
+ console.log(` Updated : ${interventionPolicy.updatedAt}`);
943
+ }
944
+ if (interventionPolicy.topTokens && interventionPolicy.topTokens.deny && interventionPolicy.topTokens.deny[0]) {
945
+ console.log(` Top Deny Signal : ${interventionPolicy.topTokens.deny[0].token}`);
946
+ }
947
+
865
948
  console.log('');
866
949
  console.log('🎯 North Star');
867
950
  console.log(` Weekly Proof Runs: ${analytics.northStar.weeklyActiveProofBackedWorkflowRuns}`);
@@ -1043,6 +1126,7 @@ module.exports = {
1043
1126
  computeSystemHealth,
1044
1127
  computeEfficiencyMetrics,
1045
1128
  computeHarnessOverview,
1129
+ getInterventionPolicySummary,
1046
1130
  computeAnalyticsSummary,
1047
1131
  computeSecretGuardStats,
1048
1132
  computeObservabilityStats,