@devtrace-ai/cli 1.0.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/dist/card.js ADDED
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.buildCardSvg = buildCardSvg;
37
+ exports.writeCardSvg = writeCardSvg;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const config_1 = require("./config");
41
+ const html_report_1 = require("./html_report");
42
+ const CARD_WIDTH = 800;
43
+ const CARD_HEIGHT = 418;
44
+ /** Truncates a display string to a max length, appending an ellipsis if cut. */
45
+ function truncate(input, maxLen) {
46
+ if (input.length <= maxLen)
47
+ return input;
48
+ return `${input.slice(0, Math.max(0, maxLen - 1))}…`;
49
+ }
50
+ function weightedSurvivalRate(sessions) {
51
+ let totalInsertions = 0;
52
+ let totalSurviving = 0;
53
+ let anyKnown = false;
54
+ for (const s of sessions) {
55
+ if (!s.churn || s.diff.totalInsertions <= 0)
56
+ continue;
57
+ anyKnown = true;
58
+ totalInsertions += s.diff.totalInsertions;
59
+ totalSurviving += s.diff.totalInsertions * s.churn.survivalRate;
60
+ }
61
+ if (!anyKnown || totalInsertions === 0)
62
+ return null;
63
+ return totalSurviving / totalInsertions;
64
+ }
65
+ function weightedSurvival30d(sessions) {
66
+ const measured = sessions
67
+ .map(s => s.churn?.survival30d)
68
+ .filter((v) => v !== undefined && v.status === 'measured');
69
+ if (measured.length === 0)
70
+ return null;
71
+ const rate = measured.reduce((sum, v) => sum + v.rate, 0) / measured.length;
72
+ return { rate, n: measured.length };
73
+ }
74
+ /** Builds the shareable SVG scorecard content (dark theme, ~800x418 social-card ratio). */
75
+ function buildCardSvg(sessions, projectDir) {
76
+ const repoName = truncate(path.basename(projectDir), 40);
77
+ const aiSessions = sessions.filter((s) => s.aiTool !== 'none');
78
+ const total = sessions.length;
79
+ const aiSharePct = total > 0 ? Math.round((aiSessions.length / total) * 100) : 0;
80
+ const headSurvival = weightedSurvivalRate(aiSessions);
81
+ const w30 = weightedSurvival30d(aiSessions);
82
+ let periodLabel = 'no commits recorded';
83
+ if (total > 0) {
84
+ const dates = sessions.map((s) => s.timestamp.substring(0, 10)).sort();
85
+ const first = dates[0];
86
+ const last = dates[dates.length - 1];
87
+ periodLabel = first === last ? first : `${first} → ${last}`;
88
+ }
89
+ const survivalHeadText = headSurvival === null ? 'unknown' : `${(headSurvival * 100).toFixed(0)}%`;
90
+ const survival30Text = w30 ? `${(w30.rate * 100).toFixed(0)}% (n=${w30.n})` : 'window open / unknown';
91
+ const esc = (s) => (0, html_report_1.escapeHtml)(s);
92
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_WIDTH}" height="${CARD_HEIGHT}" viewBox="0 0 ${CARD_WIDTH} ${CARD_HEIGHT}" role="img" aria-label="DevTrace scorecard for ${esc(repoName)}">
93
+ <defs>
94
+ <linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
95
+ <stop offset="0%" stop-color="#0f172a"/>
96
+ <stop offset="100%" stop-color="#1e1b4b"/>
97
+ </linearGradient>
98
+ </defs>
99
+ <rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="url(#bg)"/>
100
+ <rect x="1" y="1" width="${CARD_WIDTH - 2}" height="${CARD_HEIGHT - 2}" fill="none" stroke="#334155" stroke-width="2" rx="16"/>
101
+
102
+ <text x="48" y="72" fill="#e2e8f0" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="30" font-weight="700">${esc(repoName)}</text>
103
+ <text x="48" y="100" fill="#94a3b8" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="16">${esc(periodLabel)}</text>
104
+
105
+ <g transform="translate(48, 140)">
106
+ <text x="0" y="0" fill="#64748b" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13" letter-spacing="1">AI COMMIT SHARE</text>
107
+ <text x="0" y="52" fill="#22d3ee" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="48" font-weight="700">${aiSharePct}%</text>
108
+ <text x="0" y="76" fill="#64748b" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13">${aiSessions.length} of ${total} commit(s)</text>
109
+ </g>
110
+
111
+ <g transform="translate(300, 140)">
112
+ <text x="0" y="0" fill="#64748b" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13" letter-spacing="1">SURVIVAL (HEAD)</text>
113
+ <text x="0" y="52" fill="#a78bfa" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="48" font-weight="700">${esc(survivalHeadText)}</text>
114
+ <text x="0" y="76" fill="#64748b" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13">AI-authored lines still present</text>
115
+ </g>
116
+
117
+ <g transform="translate(552, 140)">
118
+ <text x="0" y="0" fill="#64748b" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13" letter-spacing="1">SURVIVAL (30D)</text>
119
+ <text x="0" y="52" fill="#34d399" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="30" font-weight="700">${esc(survival30Text)}</text>
120
+ <text x="0" y="76" fill="#64748b" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13">windowed at commit+30d</text>
121
+ </g>
122
+
123
+ <line x1="48" y1="330" x2="${CARD_WIDTH - 48}" y2="330" stroke="#334155" stroke-width="1"/>
124
+ <text x="48" y="360" fill="#475569" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="13">measured from git history; heuristic detections are estimates</text>
125
+ <text x="48" y="384" fill="#334155" font-family="-apple-system, Helvetica, Arial, sans-serif" font-size="12">DevTrace</text>
126
+ </svg>
127
+ `;
128
+ }
129
+ /** Writes the SVG scorecard to disk and returns the absolute path. */
130
+ function writeCardSvg(sessions, projectDir, outPath) {
131
+ const config = config_1.ConfigManager.loadConfig(projectDir);
132
+ const target = outPath
133
+ ? path.resolve(projectDir, outPath)
134
+ : path.join(projectDir, config.storageDir, 'card.svg');
135
+ const dir = path.dirname(target);
136
+ if (!fs.existsSync(dir)) {
137
+ fs.mkdirSync(dir, { recursive: true });
138
+ }
139
+ const svg = buildCardSvg(sessions, projectDir);
140
+ fs.writeFileSync(target, svg, 'utf8');
141
+ return target;
142
+ }
package/dist/chat.js ADDED
@@ -0,0 +1,266 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.ChatLogParser = void 0;
37
+ const child_process_1 = require("child_process");
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const os = __importStar(require("os"));
41
+ const claude_1 = require("./capture/claude");
42
+ class ChatLogParser {
43
+ /**
44
+ * Attempts to parse chat logs from a user-supplied file path.
45
+ */
46
+ static parseUserFile(filePath) {
47
+ if (!fs.existsSync(filePath)) {
48
+ return null;
49
+ }
50
+ try {
51
+ const content = fs.readFileSync(filePath, 'utf8').trim();
52
+ // Try JSON parsing
53
+ try {
54
+ const parsed = JSON.parse(content);
55
+ // Standard Claude Code conversation JSON format
56
+ if (parsed.events && Array.isArray(parsed.events)) {
57
+ const conversation = [];
58
+ let promptText = '';
59
+ for (const event of parsed.events) {
60
+ if (event.type === 'user_message') {
61
+ const text = event.text || '';
62
+ if (!promptText)
63
+ promptText = text;
64
+ conversation.push({ role: 'user', text });
65
+ }
66
+ else if (event.type === 'assistant_message') {
67
+ conversation.push({ role: 'assistant', text: event.text || '' });
68
+ }
69
+ }
70
+ return { promptText, conversation };
71
+ }
72
+ }
73
+ catch {
74
+ // Fall back to treating it as plain text prompt
75
+ }
76
+ return {
77
+ promptText: content,
78
+ conversation: [{ role: 'user', text: content }]
79
+ };
80
+ }
81
+ catch (error) {
82
+ console.warn(`[DevTrace] Failed to parse user chat log: ${error.message}`);
83
+ return null;
84
+ }
85
+ }
86
+ /**
87
+ * Automatically attempts to parse the latest Claude Code session transcript
88
+ * for the current repo. Delegates to ClaudeCaptureSource (src/capture/claude.ts),
89
+ * which reads the real transcript location: ~/.claude/projects/<slugified-cwd>/*.jsonl,
90
+ * correlated to `cwd` so unrelated projects' sessions never leak in here.
91
+ *
92
+ * `cwd` defaults to process.cwd() to preserve the previous call signature
93
+ * (index.ts calls this with no arguments).
94
+ */
95
+ static parseLatestClaudeCode(cwd = process.cwd()) {
96
+ try {
97
+ const source = new claude_1.ClaudeCaptureSource();
98
+ const result = source.getLatestPrompt(cwd);
99
+ if (!result)
100
+ return null;
101
+ return {
102
+ promptText: result.promptText,
103
+ conversation: [{ role: 'user', text: result.promptText }]
104
+ };
105
+ }
106
+ catch (error) {
107
+ console.warn(`[DevTrace] Failed to parse Claude Code chat logs: ${error.message}`);
108
+ return null;
109
+ }
110
+ }
111
+ /**
112
+ * Attempts to query Cursor's state database for recent chat panels using native sqlite3 client.
113
+ * If sqlite3 is not installed, falls back to raw binary/string scanning.
114
+ * Cross-platform compatible (handles macOS, Windows, Linux paths).
115
+ */
116
+ static parseLatestCursorChat(homeDir = process.env.HOME || process.env.USERPROFILE || '') {
117
+ if (!homeDir)
118
+ return null;
119
+ let dbPath = '';
120
+ if (process.platform === 'darwin') {
121
+ dbPath = path.join(homeDir, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
122
+ }
123
+ else if (process.platform === 'win32') {
124
+ dbPath = path.join(process.env.APPDATA || path.join(homeDir, 'AppData', 'Roaming'), 'Cursor', 'User', 'globalStorage', 'state.vscdb');
125
+ }
126
+ else {
127
+ dbPath = path.join(homeDir, '.config', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
128
+ }
129
+ if (!fs.existsSync(dbPath)) {
130
+ return null;
131
+ }
132
+ const uniqueId = `temp_cursor_state_${Date.now()}_${Math.random().toString(36).substring(7)}`;
133
+ const tempDbPath = path.join(os.tmpdir(), `${uniqueId}.vscdb`);
134
+ const tempWalPath = `${tempDbPath}-wal`;
135
+ const tempShmPath = `${tempDbPath}-shm`;
136
+ let chatState = null;
137
+ try {
138
+ // 1. Snapshot the database. Try executing sqlite3 CLI tool backup command first for atomic consistency.
139
+ let snapshotSuccess = false;
140
+ try {
141
+ const backupRes = (0, child_process_1.spawnSync)('sqlite3', [dbPath, `.backup '${tempDbPath}'`]);
142
+ if (backupRes.status === 0 && fs.existsSync(tempDbPath)) {
143
+ snapshotSuccess = true;
144
+ }
145
+ }
146
+ catch { }
147
+ if (!snapshotSuccess) {
148
+ // Fallback: Copy the main database file directly (ignoring WAL/SHM sidecars to prevent lock/SHM issues)
149
+ fs.copyFileSync(dbPath, tempDbPath);
150
+ }
151
+ // 2. Try executing sqlite3 CLI tool first
152
+ const query = "SELECT value FROM ItemTable WHERE key = 'workbench.panel.chatSidebar';";
153
+ const result = (0, child_process_1.spawnSync)('sqlite3', [tempDbPath, query], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
154
+ const resultRaw = result.status === 0 ? result.stdout.trim() : '';
155
+ if (resultRaw) {
156
+ try {
157
+ const parsed = JSON.parse(resultRaw);
158
+ if (parsed && parsed.chatState) {
159
+ chatState = parsed.chatState;
160
+ }
161
+ }
162
+ catch { }
163
+ }
164
+ // 3. Fallback: Parse binary/string if sqlite3 is missing or failed
165
+ if (!chatState) {
166
+ const binaryContent = fs.readFileSync(tempDbPath, 'latin1');
167
+ chatState = this.extractChatStateFromBinary(binaryContent);
168
+ }
169
+ if (chatState && Array.isArray(chatState.chats)) {
170
+ const chats = chatState.chats;
171
+ if (chats.length === 0)
172
+ return null;
173
+ const latestChat = chats[chats.length - 1];
174
+ if (latestChat && Array.isArray(latestChat.bubbles)) {
175
+ const conversation = [];
176
+ let promptText = '';
177
+ for (const bubble of latestChat.bubbles) {
178
+ const role = bubble.type === 'user' ? 'user' : 'assistant';
179
+ const text = bubble.text || '';
180
+ if (role === 'user' && !promptText)
181
+ promptText = text;
182
+ conversation.push({ role, text });
183
+ }
184
+ return { promptText, conversation };
185
+ }
186
+ }
187
+ }
188
+ catch (error) {
189
+ // Silent catch to prevent git hooks blocking
190
+ }
191
+ finally {
192
+ // Cleanup copied temp files
193
+ const toDelete = [tempDbPath, tempWalPath, tempShmPath];
194
+ for (const f of toDelete) {
195
+ if (fs.existsSync(f)) {
196
+ try {
197
+ fs.unlinkSync(f);
198
+ }
199
+ catch { }
200
+ }
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+ /**
206
+ * Scans a string (loaded with 'latin1' encoding) for JSON chatState structure.
207
+ * Useful when sqlite3 CLI is not available on user's system.
208
+ */
209
+ static extractChatStateFromBinary(content) {
210
+ const markers = ['{"chatState"', '{"chats"'];
211
+ for (const marker of markers) {
212
+ let searchStart = 0;
213
+ while (true) {
214
+ const matchIdx = content.indexOf(marker, searchStart);
215
+ if (matchIdx === -1)
216
+ break;
217
+ let braceCount = 0;
218
+ let inString = false;
219
+ let escaped = false;
220
+ let endIdx = -1;
221
+ for (let i = matchIdx; i < content.length; i++) {
222
+ const char = content[i];
223
+ if (escaped) {
224
+ escaped = false;
225
+ continue;
226
+ }
227
+ if (char === '\\') {
228
+ escaped = true;
229
+ continue;
230
+ }
231
+ if (char === '"') {
232
+ inString = !inString;
233
+ continue;
234
+ }
235
+ if (!inString) {
236
+ if (char === '{') {
237
+ braceCount++;
238
+ }
239
+ else if (char === '}') {
240
+ braceCount--;
241
+ if (braceCount === 0) {
242
+ endIdx = i;
243
+ break;
244
+ }
245
+ }
246
+ }
247
+ }
248
+ if (endIdx !== -1) {
249
+ const jsonStr = content.substring(matchIdx, endIdx + 1);
250
+ try {
251
+ const parsed = JSON.parse(jsonStr);
252
+ if (parsed && (parsed.chatState || parsed.chats)) {
253
+ return parsed.chatState || parsed;
254
+ }
255
+ }
256
+ catch {
257
+ // Keep scanning if JSON parse failed
258
+ }
259
+ }
260
+ searchStart = matchIdx + 1;
261
+ }
262
+ }
263
+ return null;
264
+ }
265
+ }
266
+ exports.ChatLogParser = ChatLogParser;
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.TIER2_CONFIDENCE = void 0;
37
+ exports.correlateCommitWithLedger = correlateCommitWithLedger;
38
+ const fs = __importStar(require("fs"));
39
+ const path = __importStar(require("path"));
40
+ const claude_hooks_1 = require("./claude-hooks");
41
+ /**
42
+ * Correlates a landed git commit against the Claude Code hook ledger
43
+ * (.devtrace/claude-ledger.jsonl) to upgrade detection to tier 2
44
+ * ("instrumented", tool claude-code, confidence 0.85) — but only when no
45
+ * tier-1 trailer already matched (trailers stay authoritative; see
46
+ * src/detector.ts). Ledger events are watermarked by timestamp so a given
47
+ * edit event is never counted toward more than one commit.
48
+ *
49
+ * See docs/plans/03b-sota-capture-research-completion.md Gap 1 / Gap 4
50
+ * (tier scheme) and plan 04 §2.4 (T1.4).
51
+ */
52
+ exports.TIER2_CONFIDENCE = 0.85;
53
+ const WATERMARK_FILENAME = 'claude-ledger.watermark';
54
+ function getLedgerPath(cwd) {
55
+ return path.join(cwd, '.devtrace', claude_hooks_1.CLAUDE_LEDGER_FILENAME);
56
+ }
57
+ function getWatermarkPath(cwd) {
58
+ return path.join(cwd, '.devtrace', WATERMARK_FILENAME);
59
+ }
60
+ function readWatermark(cwd) {
61
+ try {
62
+ const raw = fs.readFileSync(getWatermarkPath(cwd), 'utf8').trim();
63
+ const ts = Date.parse(raw);
64
+ return Number.isNaN(ts) ? 0 : ts;
65
+ }
66
+ catch {
67
+ return 0;
68
+ }
69
+ }
70
+ function writeWatermark(cwd, isoTs) {
71
+ try {
72
+ fs.writeFileSync(getWatermarkPath(cwd), isoTs, 'utf8');
73
+ }
74
+ catch {
75
+ // Best-effort — a failed watermark write only risks double-counting a future
76
+ // commit's correlation, not data loss or a crash.
77
+ }
78
+ }
79
+ /** Reads and parses all ledger lines, tolerating malformed lines. */
80
+ function readLedgerEvents(cwd) {
81
+ const ledgerPath = getLedgerPath(cwd);
82
+ if (!fs.existsSync(ledgerPath))
83
+ return [];
84
+ let raw;
85
+ try {
86
+ raw = fs.readFileSync(ledgerPath, 'utf8');
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ const events = [];
92
+ for (const line of raw.split('\n')) {
93
+ const trimmed = line.trim();
94
+ if (!trimmed)
95
+ continue;
96
+ try {
97
+ const parsed = JSON.parse(trimmed);
98
+ if (parsed && typeof parsed === 'object' && (parsed.kind === 'edit' || parsed.kind === 'session-start')) {
99
+ events.push(parsed);
100
+ }
101
+ }
102
+ catch {
103
+ // Skip malformed lines rather than aborting the whole file.
104
+ }
105
+ }
106
+ return events;
107
+ }
108
+ /**
109
+ * Determines whether the given commit's changed files were touched by
110
+ * unconsumed Claude Code hook ledger events (i.e. events with ts newer than
111
+ * the last-consumed watermark). Advances the watermark to the newest event
112
+ * timestamp seen in this call, whether or not it matched a file, so events
113
+ * are never double-counted across commits.
114
+ *
115
+ * `changedFiles` must be repo-relative paths (same convention the ledger
116
+ * uses — see resolveRelativeFilePath in src/claude-hooks.ts).
117
+ */
118
+ function correlateCommitWithLedger(cwd, changedFiles) {
119
+ const events = readLedgerEvents(cwd);
120
+ if (events.length === 0) {
121
+ return { matched: false, matchedFiles: [], sessionIds: [] };
122
+ }
123
+ const watermark = readWatermark(cwd);
124
+ const changedSet = new Set(changedFiles);
125
+ let newestTs = watermark;
126
+ const matchedFiles = new Set();
127
+ const sessionIds = new Set();
128
+ for (const event of events) {
129
+ const ts = Date.parse(event.ts);
130
+ if (Number.isNaN(ts) || ts <= watermark)
131
+ continue;
132
+ if (ts > newestTs)
133
+ newestTs = ts;
134
+ if (event.kind === 'edit') {
135
+ const edit = event;
136
+ if (changedSet.has(edit.filePath)) {
137
+ matchedFiles.add(edit.filePath);
138
+ sessionIds.add(edit.sessionId);
139
+ }
140
+ }
141
+ }
142
+ if (newestTs > watermark) {
143
+ writeWatermark(cwd, new Date(newestTs).toISOString());
144
+ }
145
+ return {
146
+ matched: matchedFiles.size > 0,
147
+ matchedFiles: Array.from(matchedFiles),
148
+ sessionIds: Array.from(sessionIds)
149
+ };
150
+ }