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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/CHANGELOG.md +198 -0
- package/README.md +7 -6
- package/adapters/README.md +1 -1
- package/adapters/chatgpt/openapi.yaml +25 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/codex/config.toml +4 -4
- package/adapters/mcp/server-stdio.js +41 -2
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +100 -10
- package/openapi/openapi.yaml +25 -0
- package/package.json +13 -3
- package/public/codex-plugin.html +277 -0
- package/public/dashboard.html +141 -13
- package/public/index.html +92 -34
- package/public/learn.html +13 -2
- package/public/lessons.html +5 -2
- package/public/pro.html +8 -1
- package/scripts/auto-wire-hooks.js +10 -5
- package/scripts/billing.js +503 -8
- package/scripts/contextfs.js +1 -1
- package/scripts/dashboard.js +236 -0
- package/scripts/feedback-loop.js +22 -0
- package/scripts/gates-engine.js +461 -7
- package/scripts/hook-runtime.js +42 -0
- package/scripts/llm-client.js +25 -10
- package/scripts/mailer/index.js +13 -0
- package/scripts/mailer/resend-mailer.js +350 -0
- package/scripts/mcp-config.js +13 -0
- package/scripts/published-cli.js +8 -0
- package/scripts/seo-gsd.js +118 -4
- package/scripts/statusline.sh +8 -0
- package/scripts/vector-store.js +21 -7
- package/src/api/server.js +112 -7
package/scripts/dashboard.js
CHANGED
|
@@ -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,
|
package/scripts/feedback-loop.js
CHANGED
|
@@ -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,
|