thumbgate 1.4.1 → 1.4.3

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 (60) hide show
  1. package/.claude-plugin/README.md +45 -34
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/.claude-plugin/plugin.json +3 -3
  4. package/.well-known/llms.txt +1 -1
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +26 -2
  7. package/adapters/README.md +4 -1
  8. package/adapters/chatgpt/INSTALL.md +39 -19
  9. package/adapters/claude/.mcp.json +2 -2
  10. package/adapters/codex/config.toml +2 -2
  11. package/adapters/mcp/server-stdio.js +10 -4
  12. package/adapters/opencode/opencode.json +1 -1
  13. package/adapters/perplexity/.mcp.json +36 -0
  14. package/adapters/perplexity/config.toml +16 -0
  15. package/adapters/perplexity/opencode.json +29 -0
  16. package/bin/cli.js +246 -90
  17. package/config/mcp-allowlists.json +11 -3
  18. package/package.json +28 -13
  19. package/plugins/claude-codex-bridge/.claude-plugin/plugin.json +1 -1
  20. package/plugins/claude-codex-bridge/.mcp.json +1 -1
  21. package/plugins/codex-profile/.codex-plugin/plugin.json +1 -1
  22. package/plugins/codex-profile/.mcp.json +1 -1
  23. package/plugins/codex-profile/INSTALL.md +1 -1
  24. package/plugins/codex-profile/README.md +1 -1
  25. package/plugins/cursor-marketplace/.cursor-plugin/plugin.json +1 -1
  26. package/plugins/opencode-profile/INSTALL.md +1 -1
  27. package/public/index.html +121 -24
  28. package/public/llm-context.md +17 -1
  29. package/scripts/ai-search-visibility.js +10 -36
  30. package/scripts/audit-trail.js +25 -15
  31. package/scripts/auto-wire-hooks.js +127 -0
  32. package/scripts/cli-demo.js +102 -0
  33. package/scripts/cli-schema.js +285 -0
  34. package/scripts/cli-status.js +166 -0
  35. package/scripts/cross-encoder-reranker.js +235 -0
  36. package/scripts/explore-subcommands.js +277 -0
  37. package/scripts/explore.js +569 -0
  38. package/scripts/feedback-loop.js +20 -6
  39. package/scripts/lesson-inference.js +27 -2
  40. package/scripts/lesson-reranker.js +263 -0
  41. package/scripts/lesson-retrieval.js +34 -17
  42. package/scripts/lesson-search.js +69 -0
  43. package/scripts/perplexity-client.js +210 -0
  44. package/scripts/perplexity-command-center.js +644 -0
  45. package/scripts/perplexity-marketing.js +17 -29
  46. package/scripts/prove-packaged-runtime.js +5 -4
  47. package/scripts/ralph-mode-ci.js +122 -19
  48. package/scripts/reflector-agent.js +2 -2
  49. package/scripts/session-analyzer.js +533 -0
  50. package/scripts/social-analytics/db/marketing-db.js +179 -0
  51. package/scripts/social-analytics/db/schema.sql +23 -0
  52. package/scripts/social-analytics/generate-instagram-card.js +31 -5
  53. package/scripts/social-analytics/generate-slides.js +268 -0
  54. package/scripts/social-analytics/post-video.js +316 -0
  55. package/scripts/social-analytics/publishers/zernio.js +52 -23
  56. package/scripts/statusline-local-stats.js +3 -1
  57. package/scripts/statusline.sh +15 -10
  58. package/scripts/thumbgate-bench.js +494 -0
  59. package/src/api/server.js +65 -1
  60. package/scripts/social-analytics/db/analytics.sqlite +0 -0
@@ -0,0 +1,569 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * thumbgate explore — interactive TUI explorer for lessons, gates, stats, and rules.
6
+ *
7
+ * Inspired by Cloudflare's Local Explorer concept: a zero-dependency, keyboard-driven
8
+ * interface for discovering what your ThumbGate instance has learned and enforces.
9
+ *
10
+ * Keys:
11
+ * 1-4 / Tab switch tabs
12
+ * ↑ / k move up
13
+ * ↓ / j move down
14
+ * / start search filter
15
+ * Enter view detail
16
+ * Esc / q go back / quit
17
+ */
18
+
19
+ const readline = require('node:readline');
20
+ const fs = require('node:fs');
21
+ const path = require('node:path');
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // ANSI helpers
25
+ // ---------------------------------------------------------------------------
26
+ const A = {
27
+ clear: '\x1b[2J\x1b[H',
28
+ b: '\x1b[1m', // bold
29
+ d: '\x1b[2m', // dim
30
+ r: '\x1b[0m', // reset
31
+ inv: '\x1b[7m', // inverse (highlight)
32
+ ul: '\x1b[4m', // underline
33
+ cy: '\x1b[36m', // cyan
34
+ gn: '\x1b[32m', // green
35
+ rd: '\x1b[31m', // red
36
+ yl: '\x1b[33m', // yellow
37
+ mg: '\x1b[35m', // magenta
38
+ gy: '\x1b[90m', // gray
39
+ wh: '\x1b[97m', // bright white
40
+ bgCy: '\x1b[46m\x1b[30m', // cyan bg + black text
41
+ hideCursor:'\x1b[?25l',
42
+ showCursor:'\x1b[?25h',
43
+ };
44
+
45
+ function cols() { return process.stdout.columns || 80; }
46
+ function rows() { return process.stdout.rows || 24; }
47
+ function hr(ch = '─') { return ch.repeat(cols()); }
48
+ function pad(str, w) { const s = String(str || ''); return s.length >= w ? s.slice(0, w) : s + ' '.repeat(w - s.length); }
49
+ function trunc(str, max) {
50
+ const s = String(str || '');
51
+ if (max <= 0) return '';
52
+ if (max === 1) return s.length > 1 ? '…' : s;
53
+ return s.length > max ? s.slice(0, max - 1) + '…' : s;
54
+ }
55
+
56
+ function relDate(ts) {
57
+ if (!ts) return '';
58
+ const time = new Date(ts).getTime();
59
+ if (!Number.isFinite(time)) return '';
60
+ const d = Math.floor((Date.now() - time) / 86400000);
61
+ if (d === 0) return 'today';
62
+ if (d === 1) return '1d ago';
63
+ return `${d}d ago`;
64
+ }
65
+
66
+ function write(s) { process.stdout.write(s); }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Data loaders
70
+ // ---------------------------------------------------------------------------
71
+
72
+ function loadLessons(feedbackDir) {
73
+ const p = path.join(feedbackDir, 'memory-log.jsonl');
74
+ if (!fs.existsSync(p)) return [];
75
+ return fs.readFileSync(p, 'utf8').trim().split('\n').filter(Boolean).map(l => {
76
+ try { return JSON.parse(l); } catch { return null; }
77
+ }).filter(Boolean).reverse(); // newest first
78
+ }
79
+
80
+ function loadGates(pkgRoot) {
81
+ const gatesDir = path.join(pkgRoot, 'config', 'gates');
82
+ const gates = [];
83
+ if (!fs.existsSync(gatesDir)) return gates;
84
+ for (const f of fs.readdirSync(gatesDir).sort((a, b) => a.localeCompare(b))) {
85
+ if (!f.endsWith('.json') || f === 'custom.json') continue;
86
+ try {
87
+ const raw = JSON.parse(fs.readFileSync(path.join(gatesDir, f), 'utf8'));
88
+ const items = Array.isArray(raw) ? raw : (raw.gates || raw.rules || [raw]);
89
+ items.forEach(g => gates.push({ ...g, _file: f }));
90
+ } catch { /* skip malformed */ }
91
+ }
92
+ // Custom gates
93
+ const customPath = path.join(pkgRoot, 'config', 'gates', 'custom.json');
94
+ if (fs.existsSync(customPath)) {
95
+ try {
96
+ const custom = JSON.parse(fs.readFileSync(customPath, 'utf8'));
97
+ (Array.isArray(custom) ? custom : custom.gates || []).forEach(g =>
98
+ gates.push({ ...g, _file: 'custom.json', _custom: true })
99
+ );
100
+ } catch { /* ignore */ }
101
+ }
102
+ return gates;
103
+ }
104
+
105
+ function loadStats(feedbackDir) {
106
+ const p = path.join(feedbackDir, 'feedback-summary.json');
107
+ if (fs.existsSync(p)) { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch {} }
108
+ return null;
109
+ }
110
+
111
+ function loadRules(feedbackDir) {
112
+ const p = path.join(feedbackDir, 'prevention-rules.md');
113
+ if (!fs.existsSync(p)) return [];
114
+ return fs.readFileSync(p, 'utf8').split('\n')
115
+ .filter(l => l.trim())
116
+ .map((text, i) => ({ id: i, text }));
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // Render helpers
121
+ // ---------------------------------------------------------------------------
122
+
123
+ const TABS = ['Lessons', 'Gates', 'Stats', 'Rules'];
124
+
125
+ const KEY_MAP = new Map([
126
+ ['\x1b[A', 'up'],
127
+ ['\x1b[B', 'down'],
128
+ ['\x1b[C', 'right'],
129
+ ['\x1b[D', 'left'],
130
+ ['\r', 'return'],
131
+ ['\n', 'return'],
132
+ ['\x1b', 'escape'],
133
+ ['\x7f', 'backspace'],
134
+ ['\t', 'tab'],
135
+ ]);
136
+ const EXIT_KEY = '\x03';
137
+
138
+ function normalizeKeyData(input) {
139
+ if (Buffer.isBuffer(input)) return input.toString('utf8');
140
+ if (typeof input === 'string') return input;
141
+ return '';
142
+ }
143
+
144
+ function listHeight(state) {
145
+ return Math.max(1, rows() - 8 - (state.query ? 2 : 0));
146
+ }
147
+
148
+ /* c8 ignore start -- terminal drawing is integration-tested through CLI smoke tests. */
149
+
150
+ function renderHeader(state) {
151
+ const version = (() => { try { return require('../package.json').version; } catch { return '?'; } })();
152
+ const title = `${A.b}${A.cy}thumbgate explore${A.r} v${version}`;
153
+ const hint = `${A.gy}q quit / search ↑↓ navigate Enter detail${A.r}`;
154
+ const tabLine = TABS.map((t, i) => {
155
+ const active = i === state.tab;
156
+ const key = `${i + 1}`;
157
+ return active
158
+ ? `${A.b}${A.bgCy} ${key}:${t} ${A.r}`
159
+ : `${A.gy} ${key}:${t} ${A.r}`;
160
+ }).join(' ');
161
+
162
+ write(A.clear);
163
+ write(`${title} ${A.gy}│${A.r} ${hint}\n`);
164
+ write(`${A.cy}${hr()}\n${A.r}`);
165
+ write(`${tabLine}\n`);
166
+ write(`${A.gy}${hr()}\n${A.r}`);
167
+ }
168
+
169
+ function renderSearchBar(state) {
170
+ if (state.mode === 'search') {
171
+ write(`${A.yl}/${A.r} ${state.query}${A.inv} ${A.r}\n`);
172
+ write(`${A.gy}${hr()}\n${A.r}`);
173
+ } else if (state.query) {
174
+ write(`${A.gy}filter: ${A.yl}${state.query}${A.r} (press / to edit, Esc to clear)\n`);
175
+ write(`${A.gy}${hr()}\n${A.r}`);
176
+ }
177
+ }
178
+
179
+ function renderLessons(state) {
180
+ const items = state.filtered;
181
+ const listH = listHeight(state);
182
+ const start = Math.max(0, state.cursor - Math.floor(listH / 2));
183
+ const visible = items.slice(start, start + listH);
184
+
185
+ if (items.length === 0) {
186
+ const suffix = state.query
187
+ ? ` Matching "${state.query}".`
188
+ : ' Run: npx thumbgate capture';
189
+ write(`\n ${A.gy}No lessons found.${suffix}${A.r}\n`);
190
+ return;
191
+ }
192
+
193
+ visible.forEach((m, i) => {
194
+ const idx = start + i;
195
+ const selected = idx === state.cursor;
196
+ const signal = (m.tags || []).includes('negative') ? `${A.rd}●${A.r}` : `${A.gn}●${A.r}`;
197
+ const age = `${A.gy}${pad(relDate(m.timestamp), 8)}${A.r}`;
198
+ const titleText = trunc(m.title || m.context || '(untitled)', cols() - 20);
199
+ const row = ` ${signal} ${pad(titleText, cols() - 20)} ${age}`;
200
+ write(selected ? `${A.inv}${row}${A.r}\n` : `${row}\n`);
201
+ });
202
+
203
+ const total = items.length;
204
+ write(`\n ${A.gy}${total} lesson${total !== 1 ? 's' : ''}${state.query ? ` matching "${state.query}"` : ''}${A.r}\n`);
205
+ }
206
+
207
+ function renderGates(state) {
208
+ const items = state.filtered;
209
+ const listH = listHeight(state);
210
+ const start = Math.max(0, state.cursor - Math.floor(listH / 2));
211
+ const visible = items.slice(start, start + listH);
212
+
213
+ if (items.length === 0) {
214
+ write(`\n ${A.gy}No gates configured.${A.r}\n`);
215
+ return;
216
+ }
217
+
218
+ visible.forEach((g, i) => {
219
+ const idx = start + i;
220
+ const selected = idx === state.cursor;
221
+ const custom = g._custom ? `${A.mg}[custom]${A.r} ` : '';
222
+ const src = `${A.gy}${g._file || ''}${A.r}`;
223
+ const name = g.pattern || g.name || g.id || '(unnamed)';
224
+ const row = ` ${custom}${trunc(name, cols() - 24)} ${src}`;
225
+ write(selected ? `${A.inv}${row}${A.r}\n` : `${row}\n`);
226
+ });
227
+
228
+ write(`\n ${A.gy}${items.length} gate rule${items.length !== 1 ? 's' : ''}${A.r}\n`);
229
+ }
230
+
231
+ function renderStats(state) {
232
+ const stats = state.data.stats;
233
+ if (!stats) {
234
+ write(`\n ${A.gy}No stats available. Run some feedback captures first.${A.r}\n`);
235
+ write(` ${A.d}npx thumbgate capture --feedback=down --context="..." --what-went-wrong="..."${A.r}\n`);
236
+ return;
237
+ }
238
+ write('\n');
239
+ const kv = (label, val, color = A.wh) => {
240
+ write(` ${A.gy}${pad(label, 24)}${A.r}${color}${val}${A.r}\n`);
241
+ };
242
+ kv('Total captures', stats.total ?? 0, A.b + A.wh);
243
+ kv('Thumbs up (👍)', stats.positives ?? 0, A.gn);
244
+ kv('Thumbs down (👎)', stats.negatives ?? 0, A.rd);
245
+ kv('Lessons stored', state.data.lessons.length, A.cy);
246
+ kv('Gates active', state.data.gates.length, A.yl);
247
+
248
+ if (stats.topTags && stats.topTags.length > 0) {
249
+ write(`\n ${A.b}Top tags:${A.r}\n`);
250
+ stats.topTags.slice(0, 8).forEach(([tag, count]) => {
251
+ const bar = '█'.repeat(Math.min(count, 20));
252
+ write(` ${A.gy}${pad(tag, 20)}${A.r}${A.cy}${bar}${A.r} ${count}\n`);
253
+ });
254
+ }
255
+
256
+ if (stats.recentActivity && stats.recentActivity.length > 0) {
257
+ write(`\n ${A.b}Recent activity:${A.r}\n`);
258
+ stats.recentActivity.slice(0, 5).forEach(a => {
259
+ const icon = a.signal === 'negative' ? `${A.rd}👎${A.r}` : `${A.gn}👍${A.r}`;
260
+ write(` ${icon} ${A.gy}${relDate(a.timestamp)}${A.r} ${trunc(a.context || '', cols() - 20)}\n`);
261
+ });
262
+ }
263
+ }
264
+
265
+ function renderRules(state) {
266
+ const items = state.filtered;
267
+ const listH = listHeight(state);
268
+ const start = Math.max(0, state.cursor - Math.floor(listH / 2));
269
+ const visible = items.slice(start, start + listH);
270
+
271
+ if (items.length === 0) {
272
+ write(`\n ${A.gy}No prevention rules yet.${A.r}\n`);
273
+ write(` ${A.d}Run: npx thumbgate feedback:rules${A.r}\n`);
274
+ return;
275
+ }
276
+
277
+ visible.forEach((rule, i) => {
278
+ const idx = start + i;
279
+ const selected = idx === state.cursor;
280
+ const isHead = rule.text.match(/^#{1,3}\s/);
281
+ const color = isHead ? A.b + A.cy : A.r;
282
+ const row = ` ${color}${trunc(rule.text, cols() - 4)}${A.r}`;
283
+ write(selected ? `${A.inv}${row}${A.r}\n` : `${row}\n`);
284
+ });
285
+
286
+ write(`\n ${A.gy}${items.length} rule line${items.length !== 1 ? 's' : ''}${A.r}\n`);
287
+ }
288
+
289
+ function renderDetail(state) {
290
+ const item = state.filtered[state.cursor];
291
+ if (!item) return;
292
+
293
+ write(`${A.b}${A.cy}Detail View${A.r} ${A.gy}Esc to go back${A.r}\n`);
294
+ write(`${A.cy}${hr()}\n${A.r}`);
295
+
296
+ if (state.tab === 0) {
297
+ // Lesson detail
298
+ const signal = (item.tags || []).includes('negative') ? `${A.rd}negative${A.r}` : `${A.gn}positive${A.r}`;
299
+ const fields = [
300
+ ['Signal', signal],
301
+ ['Timestamp', item.timestamp ? new Date(item.timestamp).toLocaleString() : ''],
302
+ ['Tags', (item.tags || []).join(', ')],
303
+ ['Title', item.title || item.context || ''],
304
+ ['What went wrong',item.lesson?.whatWentWrong || item.whatWentWrong || ''],
305
+ ['What worked', item.lesson?.whatWorked || item.whatWorked || ''],
306
+ ['How to avoid', item.lesson?.howToAvoid || item.howToAvoid || ''],
307
+ ['Summary', item.lesson?.summary || item.summary || ''],
308
+ ['Content', item.content || ''],
309
+ ];
310
+ write('\n');
311
+ fields.forEach(([label, val]) => {
312
+ if (!val) return;
313
+ write(` ${A.b}${pad(label + ':', 18)}${A.r}`);
314
+ const lines = String(val).split('\n');
315
+ lines.forEach((l, i) => {
316
+ write((i === 0 ? '' : ' '.repeat(20)) + trunc(l, cols() - 22) + '\n');
317
+ });
318
+ });
319
+ } else if (state.tab === 1) {
320
+ // Gate detail
321
+ write('\n');
322
+ Object.entries(item).filter(([k]) => !k.startsWith('_')).forEach(([k, v]) => {
323
+ const val = typeof v === 'object' ? JSON.stringify(v, null, 2) : String(v);
324
+ write(` ${A.b}${pad(k + ':', 18)}${A.r}${trunc(val, cols() - 22)}\n`);
325
+ });
326
+ } else if (state.tab === 3) {
327
+ write('\n');
328
+ write(` ${item.text}\n`);
329
+ }
330
+ }
331
+
332
+ // ---------------------------------------------------------------------------
333
+ // State machine
334
+ // ---------------------------------------------------------------------------
335
+
336
+ function buildState(data) {
337
+ return {
338
+ tab: 0,
339
+ cursor: 0,
340
+ mode: 'list', // list | search | detail
341
+ query: '',
342
+ data,
343
+ filtered: data.lessons,
344
+ };
345
+ }
346
+
347
+ function getItems(state) {
348
+ const map = [state.data.lessons, state.data.gates, [], state.data.rules];
349
+ return map[state.tab] || [];
350
+ }
351
+
352
+ function applyFilter(state) {
353
+ const items = getItems(state);
354
+ if (!state.query) return items;
355
+ const q = state.query.toLowerCase();
356
+ return items.filter(item => {
357
+ const text = JSON.stringify(item).toLowerCase();
358
+ return text.includes(q);
359
+ });
360
+ }
361
+
362
+ function render(state) {
363
+ renderHeader(state);
364
+ renderSearchBar(state);
365
+
366
+ if (state.mode === 'detail') {
367
+ renderDetail(state);
368
+ return;
369
+ }
370
+
371
+ if (state.tab === 0) renderLessons(state);
372
+ else if (state.tab === 1) renderGates(state);
373
+ else if (state.tab === 2) renderStats(state);
374
+ else if (state.tab === 3) renderRules(state);
375
+ }
376
+
377
+ /* c8 ignore stop */
378
+
379
+ function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
380
+
381
+ function resetFiltered(state) {
382
+ state.filtered = applyFilter(state);
383
+ state.cursor = 0;
384
+ }
385
+
386
+ function handleSearchKey(state, key, data) {
387
+ if (key === 'escape' || key === 'return') {
388
+ state.mode = 'list';
389
+ resetFiltered(state);
390
+ return;
391
+ }
392
+ if (key === 'backspace') {
393
+ state.query = state.query.slice(0, -1);
394
+ resetFiltered(state);
395
+ return;
396
+ }
397
+ if (typeof data === 'string' && data.length === 1 && data >= ' ') {
398
+ state.query += data;
399
+ resetFiltered(state);
400
+ }
401
+ }
402
+
403
+ function handleDetailKey(state, key) {
404
+ if (key === 'escape' || key === 'q') {
405
+ state.mode = 'list';
406
+ }
407
+ }
408
+
409
+ function handleListKey(state, key, data) {
410
+ switch (key) {
411
+ case 'q':
412
+ cleanup();
413
+ process.exit(0);
414
+ break;
415
+ case 'escape':
416
+ if (state.query) {
417
+ state.query = '';
418
+ resetFiltered(state);
419
+ }
420
+ break;
421
+ case '/':
422
+ state.mode = 'search';
423
+ break;
424
+ case 'return':
425
+ if (state.tab !== 2 && state.filtered.length > 0) {
426
+ state.mode = 'detail';
427
+ }
428
+ break;
429
+ case 'up':
430
+ case 'k':
431
+ state.cursor = clamp(state.cursor - 1, 0, Math.max(0, state.filtered.length - 1));
432
+ break;
433
+ case 'down':
434
+ case 'j':
435
+ state.cursor = clamp(state.cursor + 1, 0, Math.max(0, state.filtered.length - 1));
436
+ break;
437
+ case 'tab':
438
+ state.tab = (state.tab + 1) % TABS.length;
439
+ state.cursor = 0;
440
+ state.query = '';
441
+ state.mode = 'list';
442
+ resetFiltered(state);
443
+ break;
444
+ default:
445
+ if (typeof data === 'string' && data >= '1' && data <= '4') {
446
+ state.tab = Number.parseInt(data, 10) - 1;
447
+ state.cursor = 0;
448
+ state.query = '';
449
+ state.mode = 'list';
450
+ resetFiltered(state);
451
+ }
452
+ }
453
+ }
454
+
455
+ function handleKey(state, key, data) {
456
+ if (state.mode === 'search') {
457
+ handleSearchKey(state, key, data);
458
+ } else if (state.mode === 'detail') {
459
+ handleDetailKey(state, key);
460
+ } else {
461
+ handleListKey(state, key, data);
462
+ }
463
+ }
464
+
465
+ function decodeKey(ch) {
466
+ const data = normalizeKeyData(ch);
467
+ return KEY_MAP.get(data) || data;
468
+ }
469
+
470
+ function isExitKey(ch) {
471
+ return normalizeKeyData(ch) === EXIT_KEY;
472
+ }
473
+
474
+ // ---------------------------------------------------------------------------
475
+ // Entry point
476
+ // ---------------------------------------------------------------------------
477
+
478
+ function cleanup() {
479
+ write(A.showCursor);
480
+ if (process.stdin.isTTY) process.stdin.setRawMode(false);
481
+ readline.clearLine(process.stdout, 0);
482
+ }
483
+
484
+ /* c8 ignore start -- run() owns raw TTY wiring and process lifecycle. */
485
+
486
+ function run(options = {}) {
487
+ if (!process.stdout.isTTY) {
488
+ console.error('thumbgate explore requires a TTY terminal.');
489
+ process.exit(1);
490
+ }
491
+
492
+ const PKG_ROOT = path.join(__dirname, '..');
493
+ let feedbackDir = options.feedbackDir;
494
+ if (!feedbackDir) {
495
+ try {
496
+ const { getFeedbackPaths } = require('./feedback-loop');
497
+ feedbackDir = getFeedbackPaths().FEEDBACK_DIR;
498
+ } catch {
499
+ feedbackDir = path.join(PKG_ROOT, '.claude', 'memory', 'feedback');
500
+ }
501
+ }
502
+
503
+ const data = {
504
+ lessons: loadLessons(feedbackDir),
505
+ gates: loadGates(PKG_ROOT),
506
+ stats: loadStats(feedbackDir),
507
+ rules: loadRules(feedbackDir),
508
+ };
509
+
510
+ const state = buildState(data);
511
+ state.filtered = getItems(state);
512
+
513
+ // Setup terminal
514
+ process.stdin.setRawMode(true);
515
+ process.stdin.resume();
516
+ process.stdin.setEncoding('utf8');
517
+ write(A.hideCursor);
518
+
519
+ process.on('exit', cleanup);
520
+ process.on('SIGINT', () => { cleanup(); process.exit(0); });
521
+
522
+ // Handle resize
523
+ process.stdout.on('resize', () => render(state));
524
+
525
+ // Keypress handler
526
+ process.stdin.on('data', (ch) => {
527
+ const data = normalizeKeyData(ch);
528
+ if (isExitKey(data)) {
529
+ cleanup();
530
+ process.exit(0);
531
+ }
532
+ const key = decodeKey(data);
533
+ handleKey(state, key, data);
534
+ render(state);
535
+ });
536
+
537
+ render(state);
538
+ }
539
+
540
+ /* c8 ignore stop */
541
+
542
+ function isDirectInvocation(moduleRef = module, mainRef = require.main) {
543
+ const moduleFile = moduleRef && moduleRef.filename;
544
+ const mainFile = mainRef && mainRef.filename;
545
+ return Boolean(moduleFile && mainFile && mainFile === moduleFile);
546
+ }
547
+
548
+ module.exports = {
549
+ run,
550
+ _internals: {
551
+ applyFilter,
552
+ buildState,
553
+ decodeKey,
554
+ handleKey,
555
+ isDirectInvocation,
556
+ isExitKey,
557
+ loadGates,
558
+ loadLessons,
559
+ loadRules,
560
+ loadStats,
561
+ pad,
562
+ relDate,
563
+ trunc,
564
+ },
565
+ };
566
+
567
+ if (isDirectInvocation()) {
568
+ run();
569
+ }
@@ -42,6 +42,12 @@ const {
42
42
  getFeedbackPaths: resolveFeedbackPaths,
43
43
  } = require('./feedback-paths');
44
44
 
45
+ const AUDIT_TRAIL_TAG = 'audit-trail';
46
+
47
+ function isAuditTrailEntry(entry = {}) {
48
+ return Array.isArray(entry.tags) && entry.tags.includes(AUDIT_TRAIL_TAG);
49
+ }
50
+
45
51
  // Lesson DB — SQLite+FTS5 backing store (dual-write alongside JSONL)
46
52
  let _lessonDB = null;
47
53
  let _lessonDBPath = null;
@@ -1063,8 +1069,12 @@ function captureFeedback(params) {
1063
1069
  const historyEntries = readJSONL(FEEDBACK_LOG_PATH).slice(-SEQUENCE_WINDOW);
1064
1070
 
1065
1071
  const summary = loadSummary();
1066
- summary.total += 1;
1067
- summary[signal] += 1;
1072
+ // Only count real user feedback in the summary, not audit-trail gate events
1073
+ const isAuditEntry = Array.isArray(tags) && tags.includes(AUDIT_TRAIL_TAG);
1074
+ if (!isAuditEntry) {
1075
+ summary.total += 1;
1076
+ summary[signal] += 1;
1077
+ }
1068
1078
 
1069
1079
  if (action.type === 'no-action') {
1070
1080
  const firewallBlocked = maybeBlockMemoryIngress({ feedbackEvent, summary, now });
@@ -1383,6 +1393,8 @@ function analyzeFeedback(logPath) {
1383
1393
  let totalNegative = 0;
1384
1394
 
1385
1395
  for (const entry of entries) {
1396
+ if (isAuditTrailEntry(entry)) continue;
1397
+
1386
1398
  if (entry.signal === 'positive') totalPositive++;
1387
1399
  if (entry.signal === 'negative') totalNegative++;
1388
1400
 
@@ -1416,7 +1428,8 @@ function analyzeFeedback(logPath) {
1416
1428
 
1417
1429
  const total = totalPositive + totalNegative;
1418
1430
  const approvalRate = total > 0 ? Math.round((totalPositive / total) * 1000) / 1000 : 0;
1419
- const recent = entries.slice(-20);
1431
+ const realEntries = entries.filter((entry) => !isAuditTrailEntry(entry));
1432
+ const recent = realEntries.slice(-20);
1420
1433
  const recentPos = recent.filter((e) => e.signal === 'positive').length;
1421
1434
  const recentRate = recent.length > 0 ? Math.round((recentPos / recent.length) * 1000) / 1000 : 0;
1422
1435
 
@@ -1425,7 +1438,7 @@ function analyzeFeedback(logPath) {
1425
1438
  const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
1426
1439
  const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
1427
1440
  const windowStats = { '7d': { total: 0, positive: 0 }, '30d': { total: 0, positive: 0 } };
1428
- for (const entry of entries) {
1441
+ for (const entry of realEntries) {
1429
1442
  const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : 0;
1430
1443
  const age = now - ts;
1431
1444
  if (age <= SEVEN_DAYS_MS) {
@@ -1688,11 +1701,12 @@ function writePreventionRules(filePath, minOccurrences = 2) {
1688
1701
  function feedbackSummary(recentN = 20, options = {}) {
1689
1702
  const { FEEDBACK_LOG_PATH } = getFeedbackPaths(options);
1690
1703
  const entries = readJSONL(FEEDBACK_LOG_PATH);
1691
- if (entries.length === 0) {
1704
+ const realEntries = entries.filter((entry) => !isAuditTrailEntry(entry));
1705
+ if (realEntries.length === 0) {
1692
1706
  return '## Feedback Summary\nNo feedback recorded yet.';
1693
1707
  }
1694
1708
 
1695
- const recent = entries.slice(-recentN);
1709
+ const recent = realEntries.slice(-recentN);
1696
1710
  const positive = recent.filter((e) => e.signal === 'positive').length;
1697
1711
  const negative = recent.filter((e) => e.signal === 'negative').length;
1698
1712
  const pct = Math.round((positive / recent.length) * 100);
@@ -192,7 +192,13 @@ function stripLessonPrefix(lessonText = '') {
192
192
  function formatLessonTimestamp(createdAt = '') {
193
193
  const parsed = new Date(createdAt);
194
194
  if (!Number.isFinite(parsed.getTime())) return '';
195
- return parsed.toISOString().slice(0, 16).replace('T', ' ') + 'Z';
195
+ const mm = String(parsed.getUTCMonth() + 1).padStart(2, '0');
196
+ const dd = String(parsed.getUTCDate()).padStart(2, '0');
197
+ const yyyy = parsed.getUTCFullYear();
198
+ const HH = String(parsed.getUTCHours()).padStart(2, '0');
199
+ const MM = String(parsed.getUTCMinutes()).padStart(2, '0');
200
+ const ss = String(parsed.getUTCSeconds()).padStart(2, '0');
201
+ return `${mm}/${dd}/${yyyy} ${HH}:${MM}:${ss}`;
196
202
  }
197
203
 
198
204
  function buildStatusbarLessonLabel(lesson = {}) {
@@ -303,7 +309,26 @@ function getStatusbarLessonData() {
303
309
  if (!recent) return { hasLesson: false, text: null, link: null };
304
310
 
305
311
  const normalizedLesson = stripLessonPrefix(recent.lesson || '');
306
- const truncated = normalizedLesson.length > 48 ? normalizedLesson.slice(0, 45) + '...' : normalizedLesson;
312
+
313
+ // Distill to actionable insight: prefer structured rule action, then
314
+ // whatToChange, then the lesson text itself. Raw user feedback
315
+ // ("are you sure?", "is this working?") is not useful in a statusbar.
316
+ let displayText = normalizedLesson;
317
+ if (recent.structuredRule && recent.structuredRule.action && recent.structuredRule.action.description) {
318
+ displayText = recent.structuredRule.action.description;
319
+ } else if (recent.whatToChange || recent.what_to_change) {
320
+ displayText = String(recent.whatToChange || recent.what_to_change);
321
+ }
322
+
323
+ // Clean up: strip noise prefixes, collapse whitespace
324
+ displayText = displayText
325
+ .replace(/^CRITICAL ERROR - User frustrated:\s*/i, '')
326
+ .replace(/^thumbs?\s*(up|down)\s*:?\s*/i, '')
327
+ .replace(/\s+/g, ' ')
328
+ .trim();
329
+
330
+ // Truncate to 60 chars (enough to be readable, short enough for statusbar)
331
+ const truncated = displayText.length > 60 ? displayText.slice(0, 57) + '...' : displayText;
307
332
 
308
333
  return {
309
334
  hasLesson: true,