thumbgate 1.5.8 → 1.7.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.
@@ -26,6 +26,7 @@ const { computeDecisionMetrics } = require('./decision-journal');
26
26
  const PROJECT_ROOT = path.join(__dirname, '..');
27
27
  const DEFAULT_GATES_PATH = path.join(PROJECT_ROOT, 'config', 'gates', 'default.json');
28
28
  const LANDING_PAGE_PATH = path.join(PROJECT_ROOT, 'public', 'index.html');
29
+ const DASHBOARD_REVIEW_STATE_FILE = 'dashboard-review-state.json';
29
30
 
30
31
  // ---------------------------------------------------------------------------
31
32
  // Data readers
@@ -72,6 +73,225 @@ function toLocalDayKey(value) {
72
73
  return `${year}-${month}-${day}`;
73
74
  }
74
75
 
76
+ function getDashboardReviewStatePath(feedbackDir) {
77
+ return path.join(feedbackDir, DASHBOARD_REVIEW_STATE_FILE);
78
+ }
79
+
80
+ function inferProjectRootFromFeedbackDir(feedbackDir) {
81
+ if (!feedbackDir) return null;
82
+ const resolved = path.resolve(feedbackDir);
83
+ return path.basename(resolved) === '.thumbgate' ? path.dirname(resolved) : null;
84
+ }
85
+
86
+ function resolveGitDir(projectRoot) {
87
+ const gitPath = path.join(projectRoot, '.git');
88
+ if (!fs.existsSync(gitPath)) return null;
89
+ const stat = fs.statSync(gitPath);
90
+ if (stat.isDirectory()) return gitPath;
91
+ // Worktree / submodule: .git is a file like "gitdir: /path/to/real/gitdir".
92
+ // Parse with startsWith+slice — no regex, so no ReDoS surface (S5852).
93
+ if (stat.isFile()) {
94
+ const contents = fs.readFileSync(gitPath, 'utf8').trim();
95
+ const prefix = 'gitdir:';
96
+ if (!contents.startsWith(prefix)) return null;
97
+ const target = contents.slice(prefix.length).trim();
98
+ if (!target) return null;
99
+ const resolved = path.isAbsolute(target)
100
+ ? target
101
+ : path.resolve(projectRoot, target);
102
+ return fs.existsSync(resolved) ? resolved : null;
103
+ }
104
+ return null;
105
+ }
106
+
107
+ function readGitHead(projectRoot) {
108
+ if (!projectRoot) return null;
109
+ const gitDir = resolveGitDir(projectRoot);
110
+ if (!gitDir) return null;
111
+ try {
112
+ // Read .git/HEAD directly instead of spawning `git rev-parse HEAD`.
113
+ // Avoids S4036 (PATH-resolved binary) and is faster: no subprocess.
114
+ const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim();
115
+ if (!head) return null;
116
+ // Detached HEAD: the file contains the raw SHA.
117
+ if (/^[0-9a-f]{40}$/i.test(head)) return head;
118
+ // Symbolic ref: "ref: refs/heads/<branch>" — resolve the ref file.
119
+ // Parse with startsWith+slice — no regex, so no ReDoS surface (S5852).
120
+ const refPrefix = 'ref:';
121
+ if (!head.startsWith(refPrefix)) return null;
122
+ const refName = head.slice(refPrefix.length).trim();
123
+ if (!refName) return null;
124
+ // For worktrees, the commondir points to the main .git. Refs may live
125
+ // there rather than in the per-worktree gitdir.
126
+ const commonDirFile = path.join(gitDir, 'commondir');
127
+ let commonDir = gitDir;
128
+ if (fs.existsSync(commonDirFile)) {
129
+ const rel = fs.readFileSync(commonDirFile, 'utf8').trim();
130
+ commonDir = path.isAbsolute(rel) ? rel : path.resolve(gitDir, rel);
131
+ }
132
+ for (const base of [gitDir, commonDir]) {
133
+ const refPath = path.join(base, refName);
134
+ if (fs.existsSync(refPath)) {
135
+ const sha = fs.readFileSync(refPath, 'utf8').trim();
136
+ if (/^[0-9a-f]{40}$/i.test(sha)) return sha;
137
+ }
138
+ }
139
+ // Packed refs fallback.
140
+ const packed = path.join(commonDir, 'packed-refs');
141
+ if (!fs.existsSync(packed)) return null;
142
+ const lines = fs.readFileSync(packed, 'utf8').split(/\r?\n/);
143
+ for (const line of lines) {
144
+ if (line.startsWith('#') || line.startsWith('^')) continue;
145
+ const [sha, ref] = line.split(/\s+/);
146
+ if (ref === refName && /^[0-9a-f]{40}$/i.test(sha)) return sha;
147
+ }
148
+ return null;
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ function findLatestTimestamp(entries) {
155
+ return entries.reduce((latest, entry) => {
156
+ const timestamp = normalizeText(entry && entry.timestamp);
157
+ if (!timestamp) return latest;
158
+ if (!latest) return timestamp;
159
+ return new Date(timestamp).getTime() > new Date(latest).getTime() ? timestamp : latest;
160
+ }, null);
161
+ }
162
+
163
+ function buildReviewSnapshot(feedbackDir, options = {}) {
164
+ const feedbackEntries = Array.isArray(options.feedbackEntries)
165
+ ? options.feedbackEntries
166
+ : readJSONL(path.join(feedbackDir, 'feedback-log.jsonl'));
167
+ const memoryEntries = Array.isArray(options.memoryEntries)
168
+ ? options.memoryEntries
169
+ : readJSONL(path.join(feedbackDir, 'memory-log.jsonl'));
170
+ const auditEntries = Array.isArray(options.auditEntries)
171
+ ? options.auditEntries
172
+ : readJSONL(path.join(feedbackDir, AUDIT_LOG_FILENAME));
173
+ const projectRoot = options.projectRoot === undefined
174
+ ? inferProjectRootFromFeedbackDir(feedbackDir)
175
+ : options.projectRoot;
176
+
177
+ return {
178
+ reviewedAt: options.reviewedAt || new Date().toISOString(),
179
+ feedbackCount: feedbackEntries.length,
180
+ positiveCount: feedbackEntries.filter((entry) => entry.signal === 'positive').length,
181
+ negativeCount: feedbackEntries.filter((entry) => entry.signal === 'negative').length,
182
+ lessonCount: memoryEntries.length,
183
+ blockedCount: auditEntries.filter((entry) => entry && entry.decision === 'deny').length,
184
+ warnedCount: auditEntries.filter((entry) => entry && entry.decision === 'warn').length,
185
+ latestFeedbackAt: findLatestTimestamp(feedbackEntries),
186
+ latestLessonAt: findLatestTimestamp(memoryEntries),
187
+ gitHead: readGitHead(projectRoot),
188
+ };
189
+ }
190
+
191
+ function readDashboardReviewState(feedbackDir) {
192
+ return readJsonFile(getDashboardReviewStatePath(feedbackDir));
193
+ }
194
+
195
+ function writeDashboardReviewState(feedbackDir, snapshot) {
196
+ fs.mkdirSync(feedbackDir, { recursive: true });
197
+ fs.writeFileSync(getDashboardReviewStatePath(feedbackDir), `${JSON.stringify(snapshot, null, 2)}\n`);
198
+ return snapshot;
199
+ }
200
+
201
+ function selectLatestRecord(entries, mapper) {
202
+ let latest = null;
203
+ let latestTime = -Infinity;
204
+ for (const entry of entries) {
205
+ const timestamp = normalizeText(entry && entry.timestamp);
206
+ if (!timestamp) continue;
207
+ const currentTime = new Date(timestamp).getTime();
208
+ if (Number.isNaN(currentTime) || currentTime < latestTime) continue;
209
+ latest = mapper(entry);
210
+ latestTime = currentTime;
211
+ }
212
+ return latest;
213
+ }
214
+
215
+ function summarizeReviewDelta(feedbackEntries, memoryEntries, auditEntries, baseline, currentSnapshot) {
216
+ const noBaselineSummary = {
217
+ hasBaseline: false,
218
+ reviewedAt: null,
219
+ previousHead: null,
220
+ currentHead: currentSnapshot.gitHead || null,
221
+ feedbackAdded: feedbackEntries.length,
222
+ negativeAdded: feedbackEntries.filter((entry) => entry.signal === 'negative').length,
223
+ lessonsAdded: memoryEntries.length,
224
+ blocksAdded: auditEntries.filter((entry) => entry && entry.decision === 'deny').length,
225
+ warnsAdded: auditEntries.filter((entry) => entry && entry.decision === 'warn').length,
226
+ headline: 'No review checkpoint yet. Mark the current dashboard as reviewed to start seeing only new changes.',
227
+ latestFeedback: selectLatestRecord(feedbackEntries, (entry) => ({
228
+ title: pickFirstText(entry.title, entry.context, entry.whatWentWrong, entry.whatWorked) || 'Feedback event',
229
+ timestamp: entry.timestamp,
230
+ signal: entry.signal || null,
231
+ })),
232
+ latestLesson: selectLatestRecord(memoryEntries, (entry) => ({
233
+ title: pickFirstText(entry.title, entry.content) || 'Lesson event',
234
+ timestamp: entry.timestamp,
235
+ category: entry.category || null,
236
+ })),
237
+ };
238
+
239
+ if (!baseline || !baseline.reviewedAt) return noBaselineSummary;
240
+
241
+ const reviewedAtMs = new Date(baseline.reviewedAt).getTime();
242
+ if (!Number.isFinite(reviewedAtMs)) return noBaselineSummary;
243
+
244
+ const isAfterBaseline = (entry) => {
245
+ const timestamp = entry && entry.timestamp ? new Date(entry.timestamp).getTime() : NaN;
246
+ return Number.isFinite(timestamp) && timestamp > reviewedAtMs;
247
+ };
248
+ const newFeedback = feedbackEntries.filter(isAfterBaseline);
249
+ const newLessons = memoryEntries.filter(isAfterBaseline);
250
+ const newAudit = auditEntries.filter(isAfterBaseline);
251
+ const reviewFeedback = newFeedback.some((entry) => entry.signal === 'negative')
252
+ ? newFeedback.filter((entry) => entry.signal === 'negative')
253
+ : newFeedback;
254
+ const feedbackAdded = newFeedback.length;
255
+ const negativeAdded = newFeedback.filter((entry) => entry.signal === 'negative').length;
256
+ const lessonsAdded = newLessons.length;
257
+ const blocksAdded = newAudit.filter((entry) => entry && entry.decision === 'deny').length;
258
+ const warnsAdded = newAudit.filter((entry) => entry && entry.decision === 'warn').length;
259
+ let headline = 'No new review activity since your last checkpoint.';
260
+
261
+ if (feedbackAdded || lessonsAdded || blocksAdded || warnsAdded) {
262
+ const parts = [];
263
+ if (feedbackAdded) parts.push(`${feedbackAdded} feedback event${feedbackAdded === 1 ? '' : 's'}`);
264
+ if (negativeAdded) parts.push(`${negativeAdded} negative`);
265
+ if (lessonsAdded) parts.push(`${lessonsAdded} lesson${lessonsAdded === 1 ? '' : 's'}`);
266
+ if (blocksAdded) parts.push(`${blocksAdded} gate block${blocksAdded === 1 ? '' : 's'}`);
267
+ if (warnsAdded) parts.push(`${warnsAdded} warning${warnsAdded === 1 ? '' : 's'}`);
268
+ headline = `Since your last review: ${parts.join(' · ')}.`;
269
+ }
270
+
271
+ return {
272
+ hasBaseline: true,
273
+ reviewedAt: baseline.reviewedAt,
274
+ previousHead: baseline.gitHead || null,
275
+ currentHead: currentSnapshot.gitHead || null,
276
+ feedbackAdded,
277
+ negativeAdded,
278
+ lessonsAdded,
279
+ blocksAdded,
280
+ warnsAdded,
281
+ headline,
282
+ latestFeedback: selectLatestRecord(reviewFeedback, (entry) => ({
283
+ title: pickFirstText(entry.title, entry.context, entry.whatWentWrong, entry.whatWorked) || 'Feedback event',
284
+ timestamp: entry.timestamp,
285
+ signal: entry.signal || null,
286
+ })),
287
+ latestLesson: selectLatestRecord(newLessons, (entry) => ({
288
+ title: pickFirstText(entry.title, entry.content) || 'Lesson event',
289
+ timestamp: entry.timestamp,
290
+ category: entry.category || null,
291
+ })),
292
+ };
293
+ }
294
+
75
295
  // ---------------------------------------------------------------------------
76
296
  // Approval rate + trend
77
297
  // ---------------------------------------------------------------------------
@@ -904,6 +1124,17 @@ function generateDashboard(feedbackDir, options = {}) {
904
1124
  const diagnosticLogPath = path.join(feedbackDir, 'diagnostic-log.jsonl');
905
1125
  const entries = collectAllFeedbackEntries(feedbackDir);
906
1126
  const diagnosticEntries = readJSONL(diagnosticLogPath);
1127
+ const memoryEntries = readJSONL(path.join(feedbackDir, 'memory-log.jsonl'));
1128
+ const auditEntries = readJSONL(path.join(feedbackDir, AUDIT_LOG_FILENAME));
1129
+ const reviewBaseline = options.reviewBaseline === undefined
1130
+ ? readDashboardReviewState(feedbackDir)
1131
+ : options.reviewBaseline;
1132
+ const reviewSnapshot = buildReviewSnapshot(feedbackDir, {
1133
+ feedbackEntries: entries,
1134
+ memoryEntries,
1135
+ auditEntries,
1136
+ reviewedAt: options.now || new Date().toISOString(),
1137
+ });
907
1138
  const billingSummary = options.billingSummary || getBillingSummary(analyticsWindow);
908
1139
 
909
1140
  const approval = computeApprovalStats(entries);
@@ -1000,6 +1231,7 @@ function generateDashboard(feedbackDir, options = {}) {
1000
1231
  gateStats,
1001
1232
  team,
1002
1233
  });
1234
+ const reviewDelta = summarizeReviewDelta(entries, memoryEntries, auditEntries, reviewBaseline, reviewSnapshot);
1003
1235
 
1004
1236
  return {
1005
1237
  operational: {
@@ -1029,6 +1261,7 @@ function generateDashboard(feedbackDir, options = {}) {
1029
1261
  templateLibrary,
1030
1262
  liveMetrics,
1031
1263
  predictive,
1264
+ reviewDelta,
1032
1265
  feedbackTimeSeries,
1033
1266
  tokenSavings,
1034
1267
  lessonPipeline: {
@@ -1300,6 +1533,9 @@ function printDashboard(data) {
1300
1533
 
1301
1534
  module.exports = {
1302
1535
  generateDashboard,
1536
+ buildReviewSnapshot,
1537
+ readDashboardReviewState,
1538
+ writeDashboardReviewState,
1303
1539
  printDashboard,
1304
1540
  computeApprovalStats,
1305
1541
  computeDecisionMetrics,
@@ -1265,6 +1265,8 @@ function captureFeedback(params) {
1265
1265
  feedbackSession = openSession(feedbackEvent.id, signal, inferredContext);
1266
1266
  } catch (_err) { /* non-critical */ }
1267
1267
 
1268
+ const correctiveActionsReminder = buildCorrectiveActionsReminder(correctiveActions);
1269
+
1268
1270
  // Build result immediately — all remaining side-effects are deferred
1269
1271
  const result = {
1270
1272
  accepted: true,
@@ -1274,6 +1276,10 @@ function captureFeedback(params) {
1274
1276
  memoryRecord,
1275
1277
  _captureMs,
1276
1278
  ...(correctiveActions.length > 0 && { correctiveActions }),
1279
+ ...(correctiveActionsReminder && {
1280
+ systemReminder: correctiveActionsReminder,
1281
+ thumbgateSystemReminder: correctiveActionsReminder,
1282
+ }),
1277
1283
  ...(reflection && { reflection }),
1278
1284
  ...(feedbackSession && { feedbackSession }),
1279
1285
  ...(synthesisResult && { synthesis: synthesisResult }),
@@ -1911,9 +1917,25 @@ function compactMemories() {
1911
1917
  };
1912
1918
  }
1913
1919
 
1920
+ function buildCorrectiveActionsReminder(correctiveActions = []) {
1921
+ if (!Array.isArray(correctiveActions) || correctiveActions.length === 0) return null;
1922
+ const lines = correctiveActions
1923
+ .slice(0, 3)
1924
+ .map((action) => {
1925
+ const type = String(action.type || action.source || 'corrective_action').replace(/_/g, ' ');
1926
+ const text = String(action.text || action.action || action.description || '').trim();
1927
+ if (!text) return null;
1928
+ return ` - ${type}: ${text.slice(0, 240)}`;
1929
+ })
1930
+ .filter(Boolean);
1931
+ if (lines.length === 0) return null;
1932
+ return `[ThumbGate] Corrective actions from prior lessons - apply before the next tool call:\n${lines.join('\n')}`;
1933
+ }
1934
+
1914
1935
  module.exports = {
1915
1936
  captureFeedback,
1916
1937
  compactMemories,
1938
+ buildCorrectiveActionsReminder,
1917
1939
  analyzeFeedback,
1918
1940
  buildPreventionRules,
1919
1941
  writePreventionRules,