thumbgate 1.5.4 → 1.6.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);
@@ -971,6 +1202,18 @@ function generateDashboard(feedbackDir, options = {}) {
971
1202
  const feedbackTimeSeries = computeFeedbackTimeSeries(entries, 30);
972
1203
  const lessonPipeline = computeLessonPipeline(feedbackDir, entries, gateStats);
973
1204
 
1205
+ // Estimated token savings — computed from gate blocked counts using the
1206
+ // conservative methodology in scripts/token-savings.js. This is the ONLY
1207
+ // place "$ saved" appears that's backed by real gate-block data; the landing
1208
+ // page hero uses a hardcoded sample number disclosed as "Sample".
1209
+ let tokenSavings = null;
1210
+ try {
1211
+ const { computeTokenSavings } = require('./token-savings');
1212
+ tokenSavings = computeTokenSavings({
1213
+ blockedCalls: Number(gateStats.blocked) || 0,
1214
+ });
1215
+ } catch { /* module missing — skip */ }
1216
+
974
1217
  // Merge lesson counts into feedbackTimeSeries days
975
1218
  for (const day of feedbackTimeSeries.days) {
976
1219
  day.lessons = lessonPipeline.lessonsByDay.get(day.dayKey) || 0;
@@ -988,6 +1231,7 @@ function generateDashboard(feedbackDir, options = {}) {
988
1231
  gateStats,
989
1232
  team,
990
1233
  });
1234
+ const reviewDelta = summarizeReviewDelta(entries, memoryEntries, auditEntries, reviewBaseline, reviewSnapshot);
991
1235
 
992
1236
  return {
993
1237
  operational: {
@@ -1017,7 +1261,9 @@ function generateDashboard(feedbackDir, options = {}) {
1017
1261
  templateLibrary,
1018
1262
  liveMetrics,
1019
1263
  predictive,
1264
+ reviewDelta,
1020
1265
  feedbackTimeSeries,
1266
+ tokenSavings,
1021
1267
  lessonPipeline: {
1022
1268
  stages: lessonPipeline.stages,
1023
1269
  rates: lessonPipeline.rates,
@@ -1287,6 +1533,9 @@ function printDashboard(data) {
1287
1533
 
1288
1534
  module.exports = {
1289
1535
  generateDashboard,
1536
+ buildReviewSnapshot,
1537
+ readDashboardReviewState,
1538
+ writeDashboardReviewState,
1290
1539
  printDashboard,
1291
1540
  computeApprovalStats,
1292
1541
  computeDecisionMetrics,
@@ -1518,6 +1518,146 @@ function buildBehavioralContext() {
1518
1518
  }
1519
1519
  }
1520
1520
 
1521
+ /**
1522
+ * Build "recent mistakes" context by reading the tail of memory-log.jsonl.
1523
+ * Surfaces the 3 most recent negative-signal memories (captured via
1524
+ * capture_feedback) as a reminder on EVERY tool call — even when semantic
1525
+ * retrieval returns nothing and there are no recurring patterns yet.
1526
+ *
1527
+ * This plugs the cold-start gap: a mistake captured seconds ago should
1528
+ * surface on the very next tool call, not wait for the recurring-pattern
1529
+ * threshold (≥2 occurrences) that buildBehavioralContext requires.
1530
+ *
1531
+ * @param {Object} [options]
1532
+ * @param {number} [options.maxAgeMs=86400000] - Only include memories from the last 24h by default
1533
+ * @param {number} [options.limit=3]
1534
+ * @returns {string|null}
1535
+ */
1536
+ function buildRecentCorrectiveActionsContext(options = {}) {
1537
+ const maxAgeMs = typeof options.maxAgeMs === 'number' ? options.maxAgeMs : 24 * 60 * 60 * 1000;
1538
+ const limit = typeof options.limit === 'number' ? options.limit : 3;
1539
+
1540
+ let resolveFeedbackDir;
1541
+ try {
1542
+ ({ resolveFeedbackDir } = require('./feedback-paths'));
1543
+ } catch {
1544
+ return null;
1545
+ }
1546
+
1547
+ let feedbackDir;
1548
+ try {
1549
+ feedbackDir = resolveFeedbackDir({});
1550
+ } catch {
1551
+ return null;
1552
+ }
1553
+
1554
+ const memoryLogPath = path.join(feedbackDir, 'memory-log.jsonl');
1555
+ if (!fs.existsSync(memoryLogPath)) return null;
1556
+
1557
+ let raw;
1558
+ try {
1559
+ raw = fs.readFileSync(memoryLogPath, 'utf8');
1560
+ } catch {
1561
+ return null;
1562
+ }
1563
+
1564
+ const lines = raw.split('\n').filter(Boolean);
1565
+ if (lines.length === 0) return null;
1566
+
1567
+ const cutoff = Date.now() - maxAgeMs;
1568
+ const recent = [];
1569
+
1570
+ // Walk from the tail backwards so we get the newest entries first
1571
+ for (let i = lines.length - 1; i >= 0 && recent.length < limit; i--) {
1572
+ try {
1573
+ const entry = JSON.parse(lines[i]);
1574
+ if (entry.category !== 'error' && entry.category !== 'learning') continue;
1575
+ const ts = entry.timestamp ? Date.parse(entry.timestamp) : NaN;
1576
+ if (!Number.isFinite(ts) || ts < cutoff) continue;
1577
+ recent.push(entry);
1578
+ } catch {
1579
+ // skip malformed line
1580
+ }
1581
+ }
1582
+
1583
+ if (recent.length === 0) return null;
1584
+
1585
+ const formatted = recent.map((m) => {
1586
+ const title = String(m.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
1587
+ const content = String(m.content || '');
1588
+ const avoidMatch = content.match(/How to avoid:\s*([^\n]+)/i);
1589
+ const advice = avoidMatch ? avoidMatch[1].trim().slice(0, 220) : null;
1590
+ return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
1591
+ });
1592
+
1593
+ return `[ThumbGate] Recent mistakes (last 24h) — do NOT repeat:\n${formatted.join('\n')}`;
1594
+ }
1595
+
1596
+ /**
1597
+ * Build per-action lesson context: retrieve semantically-relevant lessons for this
1598
+ * specific tool call and inject the top negative ones into hook output so the agent
1599
+ * sees its past mistakes BEFORE executing the action (not after).
1600
+ *
1601
+ * This is the enforcement mechanism that turns ThumbGate from a passive log into an
1602
+ * active governor. Without this, lessons stay in the DB and never get surfaced at
1603
+ * decision time — so the agent repeats mistakes.
1604
+ */
1605
+ function buildRelevantLessonContext(toolName, toolInput) {
1606
+ if (!toolName) return null;
1607
+
1608
+ let retrieveRelevantLessons;
1609
+ try {
1610
+ ({ retrieveRelevantLessons } = require('./lesson-retrieval'));
1611
+ } catch {
1612
+ return null;
1613
+ }
1614
+
1615
+ // Extract a searchable action context from the tool input
1616
+ const actionContext = extractActionContext(toolName, toolInput);
1617
+ if (!actionContext) return null;
1618
+
1619
+ try {
1620
+ const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
1621
+ // retrieveRelevantLessons already filters at relevanceScore > 0.1 internally;
1622
+ // any negative lesson that survives retrieval is relevant enough to surface.
1623
+ const negative = lessons.filter((l) => l.signal === 'negative');
1624
+ if (negative.length === 0) return null;
1625
+
1626
+ const formatted = negative.map((l) => {
1627
+ const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
1628
+ const advice = extractAvoidanceAdvice(l.content);
1629
+ return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
1630
+ });
1631
+
1632
+ return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
1633
+ } catch {
1634
+ return null;
1635
+ }
1636
+ }
1637
+
1638
+ function extractActionContext(toolName, toolInput) {
1639
+ if (!toolInput) return toolName;
1640
+ const parts = [toolName];
1641
+ if (toolInput.command) parts.push(String(toolInput.command).slice(0, 400));
1642
+ if (toolInput.file_path) parts.push(String(toolInput.file_path));
1643
+ if (toolInput.description) parts.push(String(toolInput.description).slice(0, 200));
1644
+ if (toolInput.prompt) parts.push(String(toolInput.prompt).slice(0, 400));
1645
+ if (toolInput.pattern) parts.push(String(toolInput.pattern).slice(0, 200));
1646
+ return parts.filter(Boolean).join(' ');
1647
+ }
1648
+
1649
+ function extractAvoidanceAdvice(content) {
1650
+ if (!content) return null;
1651
+ // Extract the "How to avoid:" section if present
1652
+ const match = content.match(/How to avoid:\s*([^\n]+)/i);
1653
+ if (match) return match[1].trim().slice(0, 220);
1654
+ return null;
1655
+ }
1656
+
1657
+ function mergeContextStrings(...ctxs) {
1658
+ return ctxs.filter((c) => typeof c === 'string' && c.length > 0).join('\n\n') || null;
1659
+ }
1660
+
1521
1661
  async function runAsync(input) {
1522
1662
  const secretGuard = evaluateSecretGuard(input);
1523
1663
  if (secretGuard) {
@@ -1545,7 +1685,10 @@ async function runAsync(input) {
1545
1685
  }
1546
1686
 
1547
1687
  const behavioralContext = buildBehavioralContext();
1548
- return formatOutput(result, behavioralContext);
1688
+ const lessonContext = buildRelevantLessonContext(toolName, toolInput);
1689
+ const recentContext = buildRecentCorrectiveActionsContext();
1690
+ const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
1691
+ return formatOutput(result, combinedContext);
1549
1692
  }
1550
1693
 
1551
1694
  function run(input) {
@@ -1575,7 +1718,10 @@ function run(input) {
1575
1718
  }
1576
1719
 
1577
1720
  const behavioralContext = buildBehavioralContext();
1578
- return formatOutput(result, behavioralContext);
1721
+ const lessonContext = buildRelevantLessonContext(toolName, toolInput);
1722
+ const recentContext = buildRecentCorrectiveActionsContext();
1723
+ const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
1724
+ return formatOutput(result, combinedContext);
1579
1725
  }
1580
1726
 
1581
1727
  // ---------------------------------------------------------------------------
@@ -1796,6 +1942,11 @@ module.exports = {
1796
1942
  PROTECTED_APPROVAL_TTL_MS,
1797
1943
  DEFAULT_PROTECTED_FILE_GLOBS,
1798
1944
  buildBehavioralContext,
1945
+ buildRecentCorrectiveActionsContext,
1946
+ buildRelevantLessonContext,
1947
+ extractActionContext,
1948
+ extractAvoidanceAdvice,
1949
+ mergeContextStrings,
1799
1950
  isHighRiskAction,
1800
1951
  };
1801
1952
 
@@ -44,10 +44,25 @@ function resolveCliBaseCommand() {
44
44
  return publishedCliShellCommand(version);
45
45
  }
46
46
 
47
+ function resolveCodexCliBaseCommand() {
48
+ const version = packageVersion();
49
+ if (publishedHookCommandsAvailable(version)) {
50
+ return publishedCliShellCommand('latest', [], { preferInstalled: false });
51
+ }
52
+ if (isSourceCheckout(PKG_ROOT)) {
53
+ return `node ${shellQuote(path.join(PKG_ROOT, 'bin', 'cli.js'))}`;
54
+ }
55
+ return publishedCliShellCommand('latest', [], { preferInstalled: false });
56
+ }
57
+
47
58
  function buildPortableHookCommand(subcommand) {
48
59
  return `${resolveCliBaseCommand()} ${subcommand}`;
49
60
  }
50
61
 
62
+ function buildCodexPortableHookCommand(subcommand) {
63
+ return `${resolveCodexCliBaseCommand()} ${subcommand}`;
64
+ }
65
+
51
66
  function preToolHookCommand() {
52
67
  return buildPortableHookCommand('gate-check');
53
68
  }
@@ -68,12 +83,39 @@ function statuslineCommand() {
68
83
  return buildPortableHookCommand('statusline-render');
69
84
  }
70
85
 
86
+ function codexPreToolHookCommand() {
87
+ return buildCodexPortableHookCommand('gate-check');
88
+ }
89
+
90
+ function codexUserPromptHookCommand() {
91
+ return buildCodexPortableHookCommand('hook-auto-capture');
92
+ }
93
+
94
+ function codexSessionStartHookCommand() {
95
+ return buildCodexPortableHookCommand('session-start');
96
+ }
97
+
98
+ function codexCacheUpdateHookCommand() {
99
+ return buildCodexPortableHookCommand('cache-update');
100
+ }
101
+
102
+ function codexStatuslineCommand() {
103
+ return buildCodexPortableHookCommand('statusline-render');
104
+ }
105
+
71
106
  module.exports = {
72
107
  buildPortableHookCommand,
108
+ buildCodexPortableHookCommand,
73
109
  cacheUpdateHookCommand,
110
+ codexCacheUpdateHookCommand,
111
+ codexPreToolHookCommand,
112
+ codexSessionStartHookCommand,
113
+ codexStatuslineCommand,
114
+ codexUserPromptHookCommand,
74
115
  packageVersion,
75
116
  publishedHookCommandsAvailable,
76
117
  preToolHookCommand,
118
+ resolveCodexCliBaseCommand,
77
119
  resolveCliBaseCommand,
78
120
  sessionStartHookCommand,
79
121
  statuslineCommand,
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ const { runStep } = require('./durability/step');
5
+
4
6
  const MODELS = {
5
7
  FAST: 'claude-haiku-4-5-20251001',
6
8
  SMART: 'claude-sonnet-4-6',
@@ -33,25 +35,38 @@ function stripCodeFences(text) {
33
35
  return fenced ? fenced[1].trim() : text.trim();
34
36
  }
35
37
 
38
+ // Anthropic SDK throws errors with a `.status` field for HTTP failures.
39
+ // Our defaultClassify already reads `.status`, so 429/5xx retry and 4xx
40
+ // (bad request / unauthorized / not-found) bail immediately — which is
41
+ // what we want: there is no point retrying a malformed prompt or a
42
+ // revoked API key.
36
43
  async function callClaude({ systemPrompt, userPrompt, model, maxTokens } = {}) {
37
44
  const client = getClient();
38
45
  if (!client) return null;
39
46
 
40
47
  try {
41
- const response = await client.messages.create({
42
- model: model || DEFAULT_MODEL,
43
- max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
44
- system: systemPrompt || undefined,
45
- messages: [{ role: 'user', content: userPrompt }],
46
- });
48
+ const text = await runStep('llm.callClaude', {
49
+ retries: 2,
50
+ logger: (msg) => console.warn(msg),
51
+ }, async () => {
52
+ const response = await client.messages.create({
53
+ model: model || DEFAULT_MODEL,
54
+ max_tokens: maxTokens || DEFAULT_MAX_TOKENS,
55
+ system: systemPrompt || undefined,
56
+ messages: [{ role: 'user', content: userPrompt }],
57
+ });
47
58
 
48
- const text = response.content
49
- .filter((b) => b.type === 'text')
50
- .map((b) => b.text)
51
- .join('');
59
+ return response.content
60
+ .filter((b) => b.type === 'text')
61
+ .map((b) => b.text)
62
+ .join('');
63
+ });
52
64
 
53
65
  return stripCodeFences(text);
54
66
  } catch {
67
+ // Preserve the original callClaude contract — callers expect `null` on
68
+ // failure, not an exception. runStep already logged retry attempts,
69
+ // so the permanent failure is visible in logs.
55
70
  return null;
56
71
  }
57
72
  }
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/mailer/index.js — public entry point for the mailer module.
5
+ */
6
+
7
+ const { sendEmail, sendTrialWelcomeEmail, renderTrialWelcomeBodies } = require('./resend-mailer');
8
+
9
+ module.exports = {
10
+ sendEmail,
11
+ sendTrialWelcomeEmail,
12
+ renderTrialWelcomeBodies,
13
+ };