lumencode 1.2.0 → 1.3.1

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.
@@ -0,0 +1,379 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { StepTracker } from './step-tracker.js';
5
+
6
+ const libRoot = dirname(fileURLToPath(import.meta.url));
7
+ const packageRoot = resolve(libRoot, '..');
8
+ const hookRoot = resolve(packageRoot, 'hooks');
9
+
10
+ const CLAUDE_LEGACY_HOOK_FILE = 'post-tool-use.js';
11
+ const CLAUDE_BATCH_HOOK_FILE = 'claude-post-tool-batch.js';
12
+ const CODEX_HOOK_FILE = 'codex-hook.js';
13
+ const OPENCODE_HOOK_FILE = 'opencode-hook.js';
14
+ const OPENCODE_PLUGIN_FILE = 'lumencode-step-tracker.js';
15
+ const OPENCODE_PLUGIN_MARKER = 'LUMENCODE_STEP_TRACKER_PLUGIN';
16
+
17
+ export const HOOK_TOOLS = Object.freeze({
18
+ CLAUDE: 'claude',
19
+ CODEX: 'codex',
20
+ OPENCODE: 'opencode',
21
+ });
22
+
23
+ function projectPaths(projectRoot = process.cwd()) {
24
+ const root = resolve(projectRoot);
25
+ return {
26
+ root,
27
+ stepsDbPath: join(root, '.ccusage', 'steps.db'),
28
+ claudeSettingsDir: join(root, '.claude'),
29
+ claudeSettingsPath: join(root, '.claude', 'settings.local.json'),
30
+ codexConfigDir: join(root, '.codex'),
31
+ codexConfigPath: join(root, '.codex', 'config.toml'),
32
+ opencodePluginDir: join(root, '.opencode', 'plugins'),
33
+ opencodePluginPath: join(root, '.opencode', 'plugins', OPENCODE_PLUGIN_FILE),
34
+ };
35
+ }
36
+
37
+ function backupFile(filePath) {
38
+ if (!existsSync(filePath)) return null;
39
+ const backupPath = `${filePath}.bak`;
40
+ if (!existsSync(backupPath)) copyFileSync(filePath, backupPath);
41
+ return backupPath;
42
+ }
43
+
44
+ function readJsonConfig(filePath) {
45
+ if (!existsSync(filePath)) return {};
46
+ try {
47
+ return JSON.parse(readFileSync(filePath, 'utf8'));
48
+ } catch (error) {
49
+ throw new Error(`Invalid JSON config: ${filePath}`);
50
+ }
51
+ }
52
+
53
+ function isClaudeHook(entry, fileName) {
54
+ return Boolean(
55
+ entry &&
56
+ typeof entry === 'object' &&
57
+ Array.isArray(entry.hooks) &&
58
+ entry.hooks.some(sub => commandReferencesFile(sub?.command, fileName))
59
+ );
60
+ }
61
+
62
+ function isAnyClaudeHook(entry) {
63
+ return isClaudeHook(entry, CLAUDE_BATCH_HOOK_FILE) || isClaudeHook(entry, CLAUDE_LEGACY_HOOK_FILE);
64
+ }
65
+
66
+ function commandReferencesFile(command, fileName) {
67
+ if (typeof command !== 'string') return false;
68
+ const normalized = command.replace(/\\/g, '/');
69
+ const escaped = fileName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
70
+ return new RegExp(`(^|/)${escaped}(["'\\s]|$)`).test(normalized);
71
+ }
72
+
73
+ function ensureHooksFeature(config) {
74
+ const lines = config.split(/\r?\n/);
75
+ const featureIndex = lines.findIndex(line => line.trim() === '[features]');
76
+
77
+ if (featureIndex === -1) {
78
+ const prefix = config.trimEnd();
79
+ return `${prefix}${prefix ? '\n\n' : ''}[features]\nhooks = true\n`;
80
+ }
81
+
82
+ let nextSectionIndex = lines.length;
83
+ for (let i = featureIndex + 1; i < lines.length; i++) {
84
+ if (/^\s*\[/.test(lines[i])) {
85
+ nextSectionIndex = i;
86
+ break;
87
+ }
88
+ }
89
+
90
+ const hooksIndex = lines
91
+ .slice(featureIndex + 1, nextSectionIndex)
92
+ .findIndex(line => /^\s*hooks\s*=/.test(line));
93
+
94
+ if (hooksIndex === -1) {
95
+ lines.splice(featureIndex + 1, 0, 'hooks = true');
96
+ } else {
97
+ lines[featureIndex + 1 + hooksIndex] = 'hooks = true';
98
+ }
99
+
100
+ return `${lines.join('\n').trimEnd()}\n`;
101
+ }
102
+
103
+ function hasCodexHook(config) {
104
+ return commandReferencesFile(config, CODEX_HOOK_FILE);
105
+ }
106
+
107
+ function appendCodexHook(config) {
108
+ const hookPath = resolve(hookRoot, CODEX_HOOK_FILE).replace(/\\/g, '/');
109
+ return `${config.trimEnd()}
110
+
111
+ [[hooks.PostToolUse]]
112
+ matcher = ""
113
+ [[hooks.PostToolUse.hooks]]
114
+ type = "command"
115
+ command = 'node "${hookPath}"'
116
+ `;
117
+ }
118
+
119
+ function buildOpenCodePlugin() {
120
+ const hookPath = resolve(hookRoot, OPENCODE_HOOK_FILE);
121
+ return `// ${OPENCODE_PLUGIN_MARKER}
122
+ // Generated by LumenCode. Project-local plugin; safe to remove with hooks disable.
123
+
124
+ export const LumencodeStepTracker = async () => ({
125
+ "tool.execute.after": async (input, output) => {
126
+ try {
127
+ const payload = {
128
+ input,
129
+ output,
130
+ cwd: input?.cwd || input?.directory || (typeof process !== "undefined" && process.cwd ? process.cwd() : undefined),
131
+ sessionId: input?.sessionID || input?.sessionId || input?.session_id || input?.session?.id,
132
+ toolUseId: input?.toolCallID || input?.toolUseId || input?.id,
133
+ toolName: input?.toolName || input?.tool || input?.tool?.name,
134
+ toolInput: output?.args || input?.args || input?.toolInput,
135
+ toolResponse: output?.result || output,
136
+ timestamp: new Date().toISOString(),
137
+ };
138
+ const proc = Bun.spawn(["node", ${JSON.stringify(hookPath)}], {
139
+ stdin: "pipe",
140
+ stdout: "ignore",
141
+ stderr: "ignore",
142
+ });
143
+ proc.stdin.write(JSON.stringify(payload));
144
+ proc.stdin.end();
145
+ await proc.exited;
146
+ } catch {}
147
+ },
148
+ });
149
+ `;
150
+ }
151
+
152
+ function hasOpenCodePlugin(filePath) {
153
+ return existsSync(filePath) && readFileSync(filePath, 'utf8').includes(OPENCODE_PLUGIN_MARKER);
154
+ }
155
+
156
+ function removeCodexHook(config) {
157
+ const lines = config.split(/\r?\n/);
158
+ const output = [];
159
+ let changed = false;
160
+
161
+ for (let i = 0; i < lines.length;) {
162
+ if (lines[i].trim() !== '[[hooks.PostToolUse]]') {
163
+ output.push(lines[i]);
164
+ i++;
165
+ continue;
166
+ }
167
+
168
+ let j = i + 1;
169
+ while (j < lines.length) {
170
+ const line = lines[j].trim();
171
+ const startsArray = /^\[\[/.test(line);
172
+ const startsSection = /^\[[^\[]/.test(line);
173
+ if (line === '[[hooks.PostToolUse]]') break;
174
+ if (startsSection) break;
175
+ if (startsArray && line !== '[[hooks.PostToolUse.hooks]]') break;
176
+ j++;
177
+ }
178
+
179
+ const block = lines.slice(i, j);
180
+ if (block.some(line => commandReferencesFile(line, CODEX_HOOK_FILE))) {
181
+ changed = true;
182
+ } else {
183
+ output.push(...block);
184
+ }
185
+ i = j;
186
+ }
187
+
188
+ return {
189
+ changed,
190
+ config: `${output.join('\n').trimEnd()}\n`,
191
+ };
192
+ }
193
+
194
+ export function getHooksStatus(projectRoot = process.cwd()) {
195
+ const paths = projectPaths(projectRoot);
196
+ let claudeEnabled = false;
197
+ let claudeBatchEnabled = false;
198
+ let claudeLegacyEnabled = false;
199
+ let claudeInvalid = false;
200
+ if (existsSync(paths.claudeSettingsPath)) {
201
+ try {
202
+ const settings = readJsonConfig(paths.claudeSettingsPath);
203
+ claudeBatchEnabled = Array.isArray(settings.hooks?.PostToolBatch) &&
204
+ settings.hooks.PostToolBatch.some(entry => isClaudeHook(entry, CLAUDE_BATCH_HOOK_FILE));
205
+ claudeLegacyEnabled = Array.isArray(settings.hooks?.PostToolUse) &&
206
+ settings.hooks.PostToolUse.some(entry => isClaudeHook(entry, CLAUDE_LEGACY_HOOK_FILE));
207
+ claudeEnabled = claudeBatchEnabled || claudeLegacyEnabled;
208
+ } catch {
209
+ claudeInvalid = true;
210
+ }
211
+ }
212
+
213
+ let codexEnabled = false;
214
+ if (existsSync(paths.codexConfigPath)) {
215
+ codexEnabled = hasCodexHook(readFileSync(paths.codexConfigPath, 'utf8'));
216
+ }
217
+
218
+ const opencodeEnabled = hasOpenCodePlugin(paths.opencodePluginPath);
219
+
220
+ return {
221
+ projectRoot: paths.root,
222
+ stepsInitialized: existsSync(paths.stepsDbPath),
223
+ claude: {
224
+ configPath: paths.claudeSettingsPath,
225
+ configExists: existsSync(paths.claudeSettingsPath),
226
+ enabled: claudeEnabled,
227
+ batchEnabled: claudeBatchEnabled,
228
+ legacyEnabled: claudeLegacyEnabled,
229
+ invalid: claudeInvalid,
230
+ },
231
+ codex: {
232
+ configPath: paths.codexConfigPath,
233
+ configExists: existsSync(paths.codexConfigPath),
234
+ enabled: codexEnabled,
235
+ },
236
+ opencode: {
237
+ configPath: paths.opencodePluginPath,
238
+ configExists: existsSync(paths.opencodePluginPath),
239
+ enabled: opencodeEnabled,
240
+ },
241
+ };
242
+ }
243
+
244
+ export async function initStepTracking(projectRoot = process.cwd()) {
245
+ const paths = projectPaths(projectRoot);
246
+ const stepsDir = join(paths.root, '.ccusage');
247
+ if (!existsSync(stepsDir)) mkdirSync(stepsDir, { recursive: true });
248
+ const tracker = new StepTracker(paths.root);
249
+ await tracker.open();
250
+ const stats = tracker.getStats();
251
+ tracker.close();
252
+ return { dbPath: paths.stepsDbPath, ...stats };
253
+ }
254
+
255
+ export function enableClaudeHooks(projectRoot = process.cwd(), options = {}) {
256
+ const paths = projectPaths(projectRoot);
257
+ const settings = readJsonConfig(paths.claudeSettingsPath);
258
+ if (!settings.hooks) settings.hooks = {};
259
+ if (!Array.isArray(settings.hooks.PostToolBatch)) settings.hooks.PostToolBatch = [];
260
+
261
+ if (settings.hooks.PostToolBatch.some(entry => isClaudeHook(entry, CLAUDE_BATCH_HOOK_FILE))) {
262
+ return { tool: HOOK_TOOLS.CLAUDE, changed: false, configPath: paths.claudeSettingsPath, backupPath: null };
263
+ }
264
+
265
+ const hookPath = resolve(hookRoot, CLAUDE_BATCH_HOOK_FILE);
266
+ settings.hooks.PostToolBatch.push({
267
+ matcher: '',
268
+ hooks: [{ type: 'command', command: `node "${hookPath}"` }],
269
+ });
270
+
271
+ const backupPath = options.backup === false ? null : backupFile(paths.claudeSettingsPath);
272
+ if (!existsSync(paths.claudeSettingsDir)) mkdirSync(paths.claudeSettingsDir, { recursive: true });
273
+ writeFileSync(paths.claudeSettingsPath, JSON.stringify(settings, null, 2));
274
+ return { tool: HOOK_TOOLS.CLAUDE, changed: true, configPath: paths.claudeSettingsPath, backupPath };
275
+ }
276
+
277
+ export function disableClaudeHooks(projectRoot = process.cwd(), options = {}) {
278
+ const paths = projectPaths(projectRoot);
279
+ if (!existsSync(paths.claudeSettingsPath)) {
280
+ return { tool: HOOK_TOOLS.CLAUDE, changed: false, configPath: paths.claudeSettingsPath, backupPath: null };
281
+ }
282
+
283
+ const settings = readJsonConfig(paths.claudeSettingsPath);
284
+ const currentUse = Array.isArray(settings.hooks?.PostToolUse) ? settings.hooks.PostToolUse : [];
285
+ const currentBatch = Array.isArray(settings.hooks?.PostToolBatch) ? settings.hooks.PostToolBatch : [];
286
+ const nextUse = currentUse.filter(entry => !isAnyClaudeHook(entry));
287
+ const nextBatch = currentBatch.filter(entry => !isAnyClaudeHook(entry));
288
+ if (nextUse.length === currentUse.length && nextBatch.length === currentBatch.length) {
289
+ return { tool: HOOK_TOOLS.CLAUDE, changed: false, configPath: paths.claudeSettingsPath, backupPath: null };
290
+ }
291
+
292
+ if (settings.hooks?.PostToolUse) settings.hooks.PostToolUse = nextUse;
293
+ if (settings.hooks?.PostToolBatch) settings.hooks.PostToolBatch = nextBatch;
294
+ const backupPath = options.backup === false ? null : backupFile(paths.claudeSettingsPath);
295
+ writeFileSync(paths.claudeSettingsPath, JSON.stringify(settings, null, 2));
296
+ return { tool: HOOK_TOOLS.CLAUDE, changed: true, configPath: paths.claudeSettingsPath, backupPath };
297
+ }
298
+
299
+ export function enableCodexHooks(projectRoot = process.cwd(), options = {}) {
300
+ const paths = projectPaths(projectRoot);
301
+ let config = existsSync(paths.codexConfigPath)
302
+ ? readFileSync(paths.codexConfigPath, 'utf8')
303
+ : '';
304
+
305
+ const original = config;
306
+ config = ensureHooksFeature(config);
307
+ if (!hasCodexHook(config)) config = appendCodexHook(config);
308
+
309
+ if (config === original) {
310
+ return { tool: HOOK_TOOLS.CODEX, changed: false, configPath: paths.codexConfigPath, backupPath: null };
311
+ }
312
+
313
+ const backupPath = options.backup === false ? null : backupFile(paths.codexConfigPath);
314
+ if (!existsSync(paths.codexConfigDir)) mkdirSync(paths.codexConfigDir, { recursive: true });
315
+ writeFileSync(paths.codexConfigPath, config);
316
+ return { tool: HOOK_TOOLS.CODEX, changed: true, configPath: paths.codexConfigPath, backupPath };
317
+ }
318
+
319
+ export function disableCodexHooks(projectRoot = process.cwd(), options = {}) {
320
+ const paths = projectPaths(projectRoot);
321
+ if (!existsSync(paths.codexConfigPath)) {
322
+ return { tool: HOOK_TOOLS.CODEX, changed: false, configPath: paths.codexConfigPath, backupPath: null };
323
+ }
324
+
325
+ const config = readFileSync(paths.codexConfigPath, 'utf8');
326
+ const removed = removeCodexHook(config);
327
+ if (!removed.changed) {
328
+ return { tool: HOOK_TOOLS.CODEX, changed: false, configPath: paths.codexConfigPath, backupPath: null };
329
+ }
330
+
331
+ const backupPath = options.backup === false ? null : backupFile(paths.codexConfigPath);
332
+ writeFileSync(paths.codexConfigPath, removed.config);
333
+ return { tool: HOOK_TOOLS.CODEX, changed: true, configPath: paths.codexConfigPath, backupPath };
334
+ }
335
+
336
+ export function enableOpenCodeHooks(projectRoot = process.cwd(), options = {}) {
337
+ const paths = projectPaths(projectRoot);
338
+ const plugin = buildOpenCodePlugin();
339
+ if (existsSync(paths.opencodePluginPath) && readFileSync(paths.opencodePluginPath, 'utf8') === plugin) {
340
+ return { tool: HOOK_TOOLS.OPENCODE, changed: false, configPath: paths.opencodePluginPath, backupPath: null };
341
+ }
342
+
343
+ const backupPath = options.backup === false ? null : backupFile(paths.opencodePluginPath);
344
+ if (!existsSync(paths.opencodePluginDir)) mkdirSync(paths.opencodePluginDir, { recursive: true });
345
+ writeFileSync(paths.opencodePluginPath, plugin);
346
+ return { tool: HOOK_TOOLS.OPENCODE, changed: true, configPath: paths.opencodePluginPath, backupPath };
347
+ }
348
+
349
+ export function disableOpenCodeHooks(projectRoot = process.cwd(), options = {}) {
350
+ const paths = projectPaths(projectRoot);
351
+ if (!existsSync(paths.opencodePluginPath)) {
352
+ return { tool: HOOK_TOOLS.OPENCODE, changed: false, configPath: paths.opencodePluginPath, backupPath: null };
353
+ }
354
+ if (!hasOpenCodePlugin(paths.opencodePluginPath)) {
355
+ return { tool: HOOK_TOOLS.OPENCODE, changed: false, configPath: paths.opencodePluginPath, backupPath: null };
356
+ }
357
+
358
+ const backupPath = options.backup === false ? null : backupFile(paths.opencodePluginPath);
359
+ unlinkSync(paths.opencodePluginPath);
360
+ return { tool: HOOK_TOOLS.OPENCODE, changed: true, configPath: paths.opencodePluginPath, backupPath };
361
+ }
362
+
363
+ export function enableHooks(projectRoot = process.cwd(), tools = [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE], options = {}) {
364
+ return tools.map(tool => {
365
+ if (tool === HOOK_TOOLS.CLAUDE) return enableClaudeHooks(projectRoot, options);
366
+ if (tool === HOOK_TOOLS.CODEX) return enableCodexHooks(projectRoot, options);
367
+ if (tool === HOOK_TOOLS.OPENCODE) return enableOpenCodeHooks(projectRoot, options);
368
+ throw new Error(`Unsupported hook tool: ${tool}`);
369
+ });
370
+ }
371
+
372
+ export function disableHooks(projectRoot = process.cwd(), tools = [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE], options = {}) {
373
+ return tools.map(tool => {
374
+ if (tool === HOOK_TOOLS.CLAUDE) return disableClaudeHooks(projectRoot, options);
375
+ if (tool === HOOK_TOOLS.CODEX) return disableCodexHooks(projectRoot, options);
376
+ if (tool === HOOK_TOOLS.OPENCODE) return disableOpenCodeHooks(projectRoot, options);
377
+ throw new Error(`Unsupported hook tool: ${tool}`);
378
+ });
379
+ }
@@ -0,0 +1,140 @@
1
+ import DiffMatchPatch from 'diff-match-patch';
2
+
3
+ const dmp = new DiffMatchPatch();
4
+
5
+ // ── Line-level diff ──
6
+
7
+ function splitLines(content) {
8
+ if (!content || content.length === 0) return [];
9
+ const lines = content.split('\n');
10
+ if (lines.length > 0 && lines[lines.length - 1] === '') lines.pop();
11
+ return lines;
12
+ }
13
+
14
+ function joinLines(lines) {
15
+ if (!lines || lines.length === 0) return '';
16
+ return lines.join('\n') + '\n';
17
+ }
18
+
19
+ /**
20
+ * Myers diff in line mode.
21
+ * Returns array of { tag, oldStart, oldEnd, newStart, newEnd }
22
+ * where tag is 'equal', 'insert', 'delete', or 'replace'.
23
+ */
24
+ export function lineDiff(oldContent, newContent) {
25
+ const oldLines = splitLines(oldContent);
26
+ const newLines = splitLines(newContent);
27
+
28
+ const oldText = joinLines(oldLines);
29
+ const newText = joinLines(newLines);
30
+
31
+ // Line-mode trick: encode each unique line as a single character
32
+ const { chars1, chars2, lineArray } = dmp.diff_linesToChars_(oldText, newText);
33
+ const diffs = dmp.diff_main(chars1, chars2, false);
34
+ dmp.diff_charsToLines_(diffs, lineArray);
35
+
36
+ return diffsToOpcodes(diffs, oldLines, newLines);
37
+ }
38
+
39
+ function diffsToOpcodes(diffs, oldLines, newLines) {
40
+ const opcodes = [];
41
+ let i1 = 0, i2 = 0; // indices in old
42
+ let j1 = 0, j2 = 0; // indices in new
43
+
44
+ for (const diff of diffs) {
45
+ const lineCount = countNewlines(diff[1]);
46
+ switch (diff[0]) {
47
+ case DiffMatchPatch.DIFF_EQUAL:
48
+ i1 = i2; i2 += lineCount;
49
+ j1 = j2; j2 += lineCount;
50
+ opcodes.push({ tag: 'equal', oldStart: i1, oldEnd: i2, newStart: j1, newEnd: j2 });
51
+ break;
52
+ case DiffMatchPatch.DIFF_DELETE:
53
+ i1 = i2; i2 += lineCount;
54
+ opcodes.push({ tag: 'delete', oldStart: i1, oldEnd: i2, newStart: j2, newEnd: j2 });
55
+ break;
56
+ case DiffMatchPatch.DIFF_INSERT:
57
+ j1 = j2; j2 += lineCount;
58
+ opcodes.push({ tag: 'insert', oldStart: i2, oldEnd: i2, newStart: j1, newEnd: j2 });
59
+ break;
60
+ }
61
+ }
62
+
63
+ return mergeReplaces(opcodes);
64
+ }
65
+
66
+ function countNewlines(text) {
67
+ let count = 0;
68
+ for (let i = 0; i < text.length; i++) {
69
+ if (text[i] === '\n') count++;
70
+ }
71
+ return count;
72
+ }
73
+
74
+ function mergeReplaces(opcodes) {
75
+ if (opcodes.length === 0) return opcodes;
76
+ const result = [];
77
+ let i = 0;
78
+ while (i < opcodes.length) {
79
+ const op = opcodes[i];
80
+ if (i + 1 < opcodes.length && op.tag === 'delete' && opcodes[i + 1].tag === 'insert') {
81
+ const next = opcodes[i + 1];
82
+ result.push({ tag: 'replace', oldStart: op.oldStart, oldEnd: op.oldEnd, newStart: next.newStart, newEnd: next.newEnd });
83
+ i += 2;
84
+ } else {
85
+ result.push(op);
86
+ i++;
87
+ }
88
+ }
89
+ return result;
90
+ }
91
+
92
+ // ── Blame computation ──
93
+
94
+ /**
95
+ * Compute per-line blame map.
96
+ * Ported from re_gent's ComputeBlame algorithm.
97
+ *
98
+ * @param {string|null} oldContent - Previous file content
99
+ * @param {string} newContent - Current file content
100
+ * @param {{ lines: string[] }|null} oldBlameMap - Previous blame map
101
+ * @param {string} stepHash - Current step identifier
102
+ * @returns {{ lines: string[] }} New blame map
103
+ */
104
+ export function computeBlame(oldContent, newContent, oldBlameMap, stepHash) {
105
+ const ops = lineDiff(oldContent || '', newContent || '');
106
+ const lines = [];
107
+
108
+ for (const op of ops) {
109
+ switch (op.tag) {
110
+ case 'equal':
111
+ for (let i = op.oldStart; i < op.oldEnd; i++) {
112
+ if (oldBlameMap && i < oldBlameMap.lines.length) {
113
+ lines.push(oldBlameMap.lines[i]);
114
+ } else {
115
+ lines.push(stepHash);
116
+ }
117
+ }
118
+ break;
119
+ case 'insert':
120
+ case 'replace':
121
+ for (let j = op.newStart; j < op.newEnd; j++) {
122
+ lines.push(stepHash);
123
+ }
124
+ break;
125
+ // 'delete' produces no lines in new blame
126
+ }
127
+ }
128
+
129
+ return { lines };
130
+ }
131
+
132
+ /**
133
+ * Build initial blame map for a file with no prior history.
134
+ * All lines are attributed to the given step.
135
+ */
136
+ export function buildInitialBlameMap(content, stepHash) {
137
+ if (!content || content.length === 0) return { lines: [] };
138
+ const lineCount = splitLines(content).length;
139
+ return { lines: Array(lineCount).fill(stepHash) };
140
+ }
package/lib/parser.js CHANGED
@@ -1,5 +1,6 @@
1
- import { readFileSync, readdirSync, statSync } from 'fs';
2
- import { join, dirname } from 'path';
1
+ import { readFileSync, readdirSync, statSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { execFileSync } from 'child_process';
3
4
 
4
5
  export function parseJsonlFile(filePath) {
5
6
  const content = readFileSync(filePath, 'utf-8');
@@ -14,7 +15,7 @@ export function parseJsonlFile(filePath) {
14
15
  if (obj.isApiErrorMessage === true) continue;
15
16
  records.push(normalizeRecord(obj));
16
17
  }
17
- } catch {}
18
+ } catch (e) { console.warn("[parser] parse error", e.message); }
18
19
  }
19
20
 
20
21
  return records;
@@ -41,7 +42,7 @@ export function parseSubagentFiles(sessionDir) {
41
42
  r.isSubagent = true;
42
43
  }
43
44
  records.push(...subRecords);
44
- } catch {}
45
+ } catch (e) { console.warn("[parser] parse error", e.message); }
45
46
  }
46
47
 
47
48
  return records;
@@ -61,16 +62,37 @@ export function detectClaudeDir() {
61
62
  try {
62
63
  const projectsDir = join(dir, 'projects');
63
64
  if (statSync(projectsDir).isDirectory()) return dir;
64
- } catch {}
65
+ } catch (e) { console.warn("[parser] parse error", e.message); }
65
66
  }
66
67
 
67
68
  return null;
68
69
  }
69
70
 
70
- // 从 JSONL 的 cwd 字段自动推导项目路径
71
- export function deriveProjectPaths(claudeDir, excludeProjects = []) {
72
- const projectsDir = join(claudeDir, 'projects');
73
- const paths = new Set();
71
+ // 从 JSONL 的 cwd 字段自动推导项目路径
72
+ export function deriveProjectPaths(claudeDir, excludeProjects = []) {
73
+ const projectsDir = join(claudeDir, 'projects');
74
+ const paths = new Set();
75
+ const gitRootCache = new Map();
76
+
77
+ function normalizePath(value) {
78
+ return value.replace(/\\/g, '/').replace(/\/$/, '');
79
+ }
80
+
81
+ function resolveProjectRoot(cwd) {
82
+ if (gitRootCache.has(cwd)) return gitRootCache.get(cwd);
83
+ let root = cwd;
84
+ try {
85
+ root = normalizePath(execFileSync('git', ['rev-parse', '--show-toplevel'], {
86
+ cwd,
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'ignore'],
89
+ }).trim());
90
+ } catch {
91
+ root = cwd;
92
+ }
93
+ gitRootCache.set(cwd, root);
94
+ return root;
95
+ }
74
96
 
75
97
  try {
76
98
  if (!statSync(projectsDir).isDirectory()) return [];
@@ -95,16 +117,16 @@ export function deriveProjectPaths(claudeDir, excludeProjects = []) {
95
117
  if (!trimmed) continue;
96
118
  try {
97
119
  const obj = JSON.parse(trimmed);
98
- if (obj.cwd) {
99
- // 只保留真实的文件系统路径
100
- const cwd = obj.cwd.replace(/\\/g, '/').replace(/\/$/, '');
101
- if (cwd.startsWith('/') || /^[A-Z]:\//i.test(cwd)) {
102
- paths.add(cwd);
103
- }
104
- }
105
- } catch {}
120
+ if (obj.cwd) {
121
+ // 只保留真实的文件系统路径
122
+ const cwd = normalizePath(obj.cwd);
123
+ if (cwd.startsWith('/') || /^[A-Z]:\//i.test(cwd)) {
124
+ paths.add(resolveProjectRoot(cwd));
125
+ }
126
+ }
127
+ } catch (e) { console.warn("[parser] parse error", e.message); }
106
128
  }
107
- } catch {}
129
+ } catch (e) { console.warn("[parser] parse error", e.message); }
108
130
  }
109
131
  }
110
132