lumencode 1.2.0 → 1.3.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/README.md +41 -0
- package/hooks/claude-post-tool-batch.js +51 -0
- package/hooks/codex-hook.js +56 -0
- package/hooks/init-steps.js +10 -0
- package/hooks/install-codex.js +9 -0
- package/hooks/install.js +14 -0
- package/hooks/opencode-hook.js +45 -0
- package/hooks/post-tool-use.js +42 -0
- package/index.js +236 -22
- package/lib/aggregate.js +27 -9
- package/lib/attribution.js +13 -0
- package/lib/capture-recorder.js +141 -0
- package/lib/config.js +26 -2
- package/lib/git-attribution-candidates.js +37 -0
- package/lib/git-attribution-options.js +105 -0
- package/lib/git-paths.js +41 -0
- package/lib/git.js +350 -167
- package/lib/hooks-manager.js +379 -0
- package/lib/line-blame.js +140 -0
- package/lib/parser.js +40 -18
- package/lib/parsers/base.js +69 -67
- package/lib/parsers/claude.js +51 -53
- package/lib/parsers/codex.js +21 -9
- package/lib/parsers/index.js +153 -151
- package/lib/parsers/opencode.js +28 -20
- package/lib/report.js +3 -3
- package/lib/server.js +213 -35
- package/lib/step-schema.js +217 -0
- package/lib/step-tracker.js +323 -0
- package/package.json +8 -2
- package/public/api.js +21 -0
- package/public/app.js +127 -2
- package/public/config.js +2 -0
- package/public/git-insights.js +19 -0
- package/public/index.html +69 -0
- package/public/style.css +85 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'fs';
|
|
3
|
+
import { isAbsolute, join, resolve } from 'path';
|
|
4
|
+
import { StepDatabase } from './step-schema.js';
|
|
5
|
+
import { computeBlame, buildInitialBlameMap } from './line-blame.js';
|
|
6
|
+
import {
|
|
7
|
+
normalizeCommitFilePath,
|
|
8
|
+
toRepoRelativePath,
|
|
9
|
+
} from './git-paths.js';
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
|
|
12
|
+
const IGNORED_DIRS = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.cache', '__pycache__']);
|
|
13
|
+
|
|
14
|
+
function shouldIgnore(filePath) {
|
|
15
|
+
const parts = filePath.replace(/\\/g, '/').split('/');
|
|
16
|
+
return parts.some(p => IGNORED_DIRS.has(p));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function generateStepHash(data) {
|
|
20
|
+
const raw = typeof data === 'string' ? data : JSON.stringify(data);
|
|
21
|
+
return createHash('sha256').update(raw).digest('hex').slice(0, 16);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizePath(filePath, projectRoot) {
|
|
25
|
+
if (!filePath) return '';
|
|
26
|
+
const relative = toRepoRelativePath(filePath, projectRoot);
|
|
27
|
+
return normalizeCommitFilePath(relative);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function extractPatchTargetFiles(patchText, projectRoot) {
|
|
31
|
+
const files = [];
|
|
32
|
+
const patch = String(patchText || '');
|
|
33
|
+
const markerRe = /^\*\*\* (?:Add File|Update File|Delete File|Move to):\s+(.+)$/gm;
|
|
34
|
+
let match;
|
|
35
|
+
while ((match = markerRe.exec(patch)) !== null) {
|
|
36
|
+
const normalized = normalizePath(match[1].trim(), projectRoot);
|
|
37
|
+
if (normalized) files.push(normalized);
|
|
38
|
+
}
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Extract file paths from tool input (reuses logic from git.js)
|
|
43
|
+
function extractTargetFiles(toolName, toolInput, projectRoot) {
|
|
44
|
+
const files = [];
|
|
45
|
+
const input = toolInput || {};
|
|
46
|
+
const normalizedTool = String(toolName || '').toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (['write', 'edit', 'multiedit', 'notebookedit'].includes(normalizedTool)) {
|
|
49
|
+
const rawPath = input.file_path || input.filePath || input.filepath || input.path || '';
|
|
50
|
+
if (rawPath) {
|
|
51
|
+
const normalized = normalizePath(rawPath, projectRoot);
|
|
52
|
+
if (normalized) files.push(normalized);
|
|
53
|
+
}
|
|
54
|
+
} else if (normalizedTool === 'bash') {
|
|
55
|
+
// Minimal extraction for shell commands touching files
|
|
56
|
+
const cmd = input.command || '';
|
|
57
|
+
const redirectRe = />>?\s*['"]?([^&|;\s<>$`'"]+)['"]?/g;
|
|
58
|
+
let m;
|
|
59
|
+
while ((m = redirectRe.exec(cmd)) !== null) {
|
|
60
|
+
const p = normalizePath(m[1], projectRoot);
|
|
61
|
+
if (p) files.push(p);
|
|
62
|
+
}
|
|
63
|
+
} else if (normalizedTool === 'apply_patch') {
|
|
64
|
+
files.push(...extractPatchTargetFiles(input.patchText || input.patch_text || input.patch, projectRoot));
|
|
65
|
+
} else if (String(toolName || '').startsWith('mcp__')) {
|
|
66
|
+
const rawPath = input.relative_path || input.file_path || input.path || '';
|
|
67
|
+
if (rawPath) {
|
|
68
|
+
const normalized = normalizePath(rawPath, projectRoot);
|
|
69
|
+
if (normalized) files.push(normalized);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return files.filter(f => !shouldIgnore(f));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export class StepTracker {
|
|
77
|
+
constructor(projectRoot, options = {}) {
|
|
78
|
+
this.projectRoot = resolve(projectRoot || process.cwd());
|
|
79
|
+
this.dbPath = options.dbPath
|
|
80
|
+
? (isAbsolute(options.dbPath) ? options.dbPath : join(this.projectRoot, options.dbPath))
|
|
81
|
+
: join(this.projectRoot, '.ccusage', 'steps.db');
|
|
82
|
+
this.db = null;
|
|
83
|
+
this.maxFileSize = options.maxFileSize || MAX_FILE_SIZE;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async open() {
|
|
87
|
+
this.db = new StepDatabase();
|
|
88
|
+
await this.db.open(this.dbPath);
|
|
89
|
+
return this;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
close() {
|
|
93
|
+
if (this.db) {
|
|
94
|
+
this.db.save();
|
|
95
|
+
this.db.close();
|
|
96
|
+
this.db = null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
isAvailable() {
|
|
101
|
+
return this.db && this.db.getStepCount() > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async isAvailableAsync() {
|
|
105
|
+
if (!existsSync(this.dbPath)) return false;
|
|
106
|
+
try {
|
|
107
|
+
const tempDb = new StepDatabase();
|
|
108
|
+
await tempDb.open(this.dbPath);
|
|
109
|
+
const count = tempDb.getStepCount();
|
|
110
|
+
tempDb.close();
|
|
111
|
+
return count > 0;
|
|
112
|
+
} catch { return false; }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── Record a tool use step ──
|
|
116
|
+
|
|
117
|
+
async recordStep(payload) {
|
|
118
|
+
if (!this.db) await this.open();
|
|
119
|
+
|
|
120
|
+
const sessionId = payload.sessionId || 'unknown';
|
|
121
|
+
const origin = payload.origin || 'claude_code';
|
|
122
|
+
const toolName = payload.toolName || '';
|
|
123
|
+
const toolUseId = payload.toolUseId || generateStepHash(`${sessionId}:${Date.now()}`);
|
|
124
|
+
const timestamp = payload.timestamp ? new Date(payload.timestamp).getTime() : Date.now();
|
|
125
|
+
|
|
126
|
+
// Extract target files from tool input
|
|
127
|
+
const explicitTargets = Array.isArray(payload.targetFiles)
|
|
128
|
+
? payload.targetFiles.map(file => normalizePath(file, this.projectRoot)).filter(Boolean)
|
|
129
|
+
: [];
|
|
130
|
+
const batchTargets = Array.isArray(payload.toolCalls)
|
|
131
|
+
? payload.toolCalls.flatMap(call => extractTargetFiles(
|
|
132
|
+
call.toolName || call.tool_name || call.name || call.tool || '',
|
|
133
|
+
call.toolInput || call.tool_input || call.input || call.args || {},
|
|
134
|
+
this.projectRoot
|
|
135
|
+
))
|
|
136
|
+
: [];
|
|
137
|
+
const targetFiles = [...new Set([
|
|
138
|
+
...explicitTargets,
|
|
139
|
+
...batchTargets,
|
|
140
|
+
...extractTargetFiles(toolName, payload.toolInput || {}, this.projectRoot),
|
|
141
|
+
])];
|
|
142
|
+
if (targetFiles.length === 0) return null;
|
|
143
|
+
|
|
144
|
+
// Get parent step for session
|
|
145
|
+
const parentStepId = this.db.getSessionHead(sessionId);
|
|
146
|
+
|
|
147
|
+
// Generate step hash
|
|
148
|
+
const stepHash = generateStepHash(
|
|
149
|
+
`${parentStepId}:${sessionId}:${toolName}:${toolUseId}:${timestamp}`
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
// Compute blame for each target file
|
|
153
|
+
for (const filePath of targetFiles) {
|
|
154
|
+
const absPath = resolve(this.projectRoot, filePath);
|
|
155
|
+
if (!existsSync(absPath)) continue;
|
|
156
|
+
|
|
157
|
+
const stat = statSync(absPath);
|
|
158
|
+
if (stat.size > this.maxFileSize) continue;
|
|
159
|
+
|
|
160
|
+
const newContent = readFileSync(absPath, 'utf-8');
|
|
161
|
+
|
|
162
|
+
// Get old content and blame from parent step
|
|
163
|
+
let oldContent = null;
|
|
164
|
+
let oldBlameMap = null;
|
|
165
|
+
if (parentStepId) {
|
|
166
|
+
oldContent = this.db.getFileBlob(parentStepId, filePath);
|
|
167
|
+
oldBlameMap = this.db.getBlameMap(parentStepId, filePath);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
let newBlameMap;
|
|
171
|
+
if (oldContent !== null) {
|
|
172
|
+
newBlameMap = computeBlame(oldContent, newContent, oldBlameMap, stepHash);
|
|
173
|
+
} else {
|
|
174
|
+
newBlameMap = buildInitialBlameMap(newContent, stepHash);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this.db.upsertStepFile(stepHash, filePath, newBlameMap, newContent);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Write step record
|
|
181
|
+
this.db.insertStep({
|
|
182
|
+
id: stepHash,
|
|
183
|
+
parentId: parentStepId,
|
|
184
|
+
sessionId,
|
|
185
|
+
origin,
|
|
186
|
+
ts: timestamp,
|
|
187
|
+
toolName,
|
|
188
|
+
toolUseId,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Update session head
|
|
192
|
+
this.db.upsertSession({
|
|
193
|
+
id: sessionId,
|
|
194
|
+
origin,
|
|
195
|
+
headStepId: stepHash,
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
this.db.save();
|
|
199
|
+
return stepHash;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Line attribution for a commit ──
|
|
203
|
+
|
|
204
|
+
getLineAttributionForCommit(commit) {
|
|
205
|
+
if (!this.db || !commit.sessionId) return null;
|
|
206
|
+
|
|
207
|
+
const result = {
|
|
208
|
+
aiLines: 0,
|
|
209
|
+
humanLines: 0,
|
|
210
|
+
aiDeletedLines: 0,
|
|
211
|
+
humanDeletedLines: 0,
|
|
212
|
+
totalLines: 0,
|
|
213
|
+
fileBreakdown: {},
|
|
214
|
+
source: 'step_blame',
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
for (const file of commit.files || []) {
|
|
218
|
+
const filePath = normalizeCommitFilePath(file.path);
|
|
219
|
+
if (shouldIgnore(filePath)) continue;
|
|
220
|
+
|
|
221
|
+
// Find the latest step for this file in the session
|
|
222
|
+
const stepFiles = this.db.getStepFilesForPath(filePath, 5);
|
|
223
|
+
const sessionSteps = stepFiles.filter(sf => sf.session_id === commit.sessionId);
|
|
224
|
+
if (sessionSteps.length === 0) continue;
|
|
225
|
+
|
|
226
|
+
const latestStep = sessionSteps[0]; // already sorted DESC by ts
|
|
227
|
+
const blameMap = this.db.getBlameMap(latestStep.step_id, filePath);
|
|
228
|
+
if (!blameMap || !blameMap.lines) continue;
|
|
229
|
+
|
|
230
|
+
// Count AI vs human lines in the blame map
|
|
231
|
+
const stepIds = new Set(sessionSteps.map(s => s.step_id));
|
|
232
|
+
let fileAI = 0;
|
|
233
|
+
let fileHuman = 0;
|
|
234
|
+
|
|
235
|
+
for (const lineStep of blameMap.lines) {
|
|
236
|
+
if (stepIds.has(lineStep)) {
|
|
237
|
+
fileAI++;
|
|
238
|
+
} else {
|
|
239
|
+
fileHuman++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const totalChanged = (file.added || 0) + (file.deleted || 0);
|
|
244
|
+
if (totalChanged === 0) continue;
|
|
245
|
+
|
|
246
|
+
// Proportionally attribute added/deleted lines
|
|
247
|
+
const fileTotal = fileAI + fileHuman;
|
|
248
|
+
const aiRatio = fileTotal > 0 ? fileAI / fileTotal : 0;
|
|
249
|
+
|
|
250
|
+
const fileAIAdded = Math.round((file.added || 0) * aiRatio);
|
|
251
|
+
const fileHumanAdded = (file.added || 0) - fileAIAdded;
|
|
252
|
+
const fileAIDeleted = Math.round((file.deleted || 0) * aiRatio);
|
|
253
|
+
const fileHumanDeleted = (file.deleted || 0) - fileAIDeleted;
|
|
254
|
+
|
|
255
|
+
result.aiLines += fileAIAdded;
|
|
256
|
+
result.humanLines += fileHumanAdded;
|
|
257
|
+
result.aiDeletedLines += fileAIDeleted;
|
|
258
|
+
result.humanDeletedLines += fileHumanDeleted;
|
|
259
|
+
result.totalLines += totalChanged;
|
|
260
|
+
result.fileBreakdown[filePath] = { aiLines: fileAIAdded, humanLines: fileHumanAdded, aiDeletedLines: fileAIDeleted, humanDeletedLines: fileHumanDeleted };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (result.totalLines === 0) return null;
|
|
264
|
+
return result;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ── Backfill from existing session data ──
|
|
268
|
+
|
|
269
|
+
async backfillFromSession(session) {
|
|
270
|
+
if (!this.db) await this.open();
|
|
271
|
+
|
|
272
|
+
const sessionId = session.id || 'unknown';
|
|
273
|
+
let stepCount = 0;
|
|
274
|
+
|
|
275
|
+
for (const tc of session.toolSequence || []) {
|
|
276
|
+
const targetFiles = extractTargetFiles(tc.name, tc.input, this.projectRoot);
|
|
277
|
+
if (targetFiles.length === 0) continue;
|
|
278
|
+
|
|
279
|
+
const timestamp = tc.timestamp ? new Date(tc.timestamp).getTime() : Date.now();
|
|
280
|
+
const parentStepId = this.db.getSessionHead(sessionId);
|
|
281
|
+
const stepHash = generateStepHash(
|
|
282
|
+
`backfill:${parentStepId}:${sessionId}:${tc.name}:${timestamp}`
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
for (const filePath of targetFiles) {
|
|
286
|
+
const absPath = resolve(this.projectRoot, filePath);
|
|
287
|
+
if (!existsSync(absPath)) continue;
|
|
288
|
+
|
|
289
|
+
const stat = statSync(absPath);
|
|
290
|
+
if (stat.size > this.maxFileSize) continue;
|
|
291
|
+
|
|
292
|
+
const content = readFileSync(absPath, 'utf-8');
|
|
293
|
+
const blameMap = buildInitialBlameMap(content, stepHash);
|
|
294
|
+
this.db.upsertStepFile(stepHash, filePath, blameMap);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
this.db.insertStep({
|
|
298
|
+
id: stepHash,
|
|
299
|
+
parentId: parentStepId,
|
|
300
|
+
sessionId,
|
|
301
|
+
ts: timestamp,
|
|
302
|
+
toolName: tc.name,
|
|
303
|
+
toolUseId: `backfill-${stepCount}`,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.db.upsertSession({ id: sessionId, headStepId: stepHash });
|
|
307
|
+
stepCount++;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (stepCount > 0) this.db.save();
|
|
311
|
+
return stepCount;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── Stats ──
|
|
315
|
+
|
|
316
|
+
getStats() {
|
|
317
|
+
if (!this.db) return { stepCount: 0, sessionCount: 0 };
|
|
318
|
+
return {
|
|
319
|
+
stepCount: this.db.getStepCount(),
|
|
320
|
+
sessionCount: this.db.getSessionCount(),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lumencode",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "LumenCode — AI 编码助手使用报告工具,从 JSONL 日志和 Git 仓库提取 AI 贡献度、效率与使用指标,支持 Web 可视化和命令行两种模式",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,11 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node index.js",
|
|
11
|
-
"test": "node --test test
|
|
11
|
+
"test": "node --test test",
|
|
12
|
+
"hooks:init": "node hooks/init-steps.js",
|
|
13
|
+
"hooks:install": "node hooks/install.js",
|
|
14
|
+
"hooks:install-claude": "node hooks/install.js",
|
|
15
|
+
"hooks:install-codex": "node hooks/install-codex.js"
|
|
12
16
|
},
|
|
13
17
|
"keywords": [
|
|
14
18
|
"lumencode",
|
|
@@ -29,6 +33,7 @@
|
|
|
29
33
|
"license": "MIT",
|
|
30
34
|
"author": "zhangyaowen",
|
|
31
35
|
"files": [
|
|
36
|
+
"hooks/",
|
|
32
37
|
"lib/",
|
|
33
38
|
"public/",
|
|
34
39
|
"data/pricing.json",
|
|
@@ -39,6 +44,7 @@
|
|
|
39
44
|
"node": ">=18.0.0"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
47
|
+
"diff-match-patch": "^1.0.5",
|
|
42
48
|
"sql.js": "^1.14.1"
|
|
43
49
|
}
|
|
44
50
|
}
|
package/public/api.js
CHANGED
|
@@ -107,6 +107,27 @@ export async function fetchSessions(params) {
|
|
|
107
107
|
return cachedFetch(`${API.SESSIONS}?${qs}`);
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export async function fetchStepStats() {
|
|
111
|
+
return cachedFetch(API.STEP_STATS, {}, 10_000);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function fetchHooksStatus() {
|
|
115
|
+
const res = await fetch(API.HOOKS);
|
|
116
|
+
if (!res.ok) throw new Error('获取 hooks 状态失败');
|
|
117
|
+
return res.json();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function updateHooks(action, tools = ['claude', 'codex', 'opencode']) {
|
|
121
|
+
const res = await fetch(API.HOOKS, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: { 'Content-Type': 'application/json' },
|
|
124
|
+
body: JSON.stringify({ action, tools: tools.join(',') }),
|
|
125
|
+
});
|
|
126
|
+
const data = await res.json().catch(() => ({}));
|
|
127
|
+
if (!res.ok) throw new Error(data.error || '更新 hooks 失败');
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
|
|
110
131
|
export async function fetchWorkReport(params) {
|
|
111
132
|
const qs = new URLSearchParams(params).toString();
|
|
112
133
|
const res = await fetch(`${API.REPORT}?${qs}&format=work`);
|
package/public/app.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { COLORS, SCENARIO_COLORS, TEXT, ID, STORAGE } from './config.js';
|
|
2
2
|
import { esc, fmt, fmtShort, destroyChart, destroyAllCharts, getChart, setChart, todayISO, fmtDate } from './utils.js';
|
|
3
|
-
import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions } from './api.js';
|
|
3
|
+
import { createLatestRequestGuard, fetchTools, fetchReport, fetchConfig, saveConfig, fetchDetails, fetchSessions, fetchStepStats, fetchHooksStatus, updateHooks } from './api.js';
|
|
4
4
|
import { renderWorkTypePie, renderModelBars, renderProjectBars, renderTimelineArea, renderCacheStack } from './charts.js';
|
|
5
|
-
import { renderGitInsights } from './git-insights.js';
|
|
5
|
+
import { renderGitInsights, renderLineBlameEvidence } from './git-insights.js';
|
|
6
6
|
import { loadWorkReport, copyWorkReport, downloadMarkdown, getWorkReportState, setWorkReportState } from './work-report.js';
|
|
7
7
|
import { exportCSV, printReport, exportJSON, exportHTML } from './export.js';
|
|
8
8
|
|
|
@@ -95,6 +95,12 @@ function appState() {
|
|
|
95
95
|
sourceOpencodePct: 0,
|
|
96
96
|
sourceBreakdown: [],
|
|
97
97
|
aiContributionMeta: '- / - LINES',
|
|
98
|
+
lineBlameEvidence: null,
|
|
99
|
+
lineBlamePrecision: '',
|
|
100
|
+
stepStats: null,
|
|
101
|
+
stepStatusLabel: '',
|
|
102
|
+
hooksStatus: null,
|
|
103
|
+
hooksBusy: false,
|
|
98
104
|
gitOutputCells: [
|
|
99
105
|
{ l: '提交', en: 'COMMITS', v: '-', c: '' },
|
|
100
106
|
{ l: '变更文件', en: 'FILES', v: '-', c: '' },
|
|
@@ -146,6 +152,36 @@ function appState() {
|
|
|
146
152
|
reportSummary: '',
|
|
147
153
|
reportHighlights: [],
|
|
148
154
|
|
|
155
|
+
get hooksNeedAction() {
|
|
156
|
+
if (!this.hooksStatus) return false;
|
|
157
|
+
return !this.hooksStatus.stepsInitialized ||
|
|
158
|
+
!this.hooksStatus.claude?.enabled ||
|
|
159
|
+
!this.hooksStatus.codex?.enabled ||
|
|
160
|
+
!this.hooksStatus.opencode?.enabled;
|
|
161
|
+
},
|
|
162
|
+
|
|
163
|
+
get hooksStatusText() {
|
|
164
|
+
if (!this.hooksStatus) return '正在检查 hooks 状态';
|
|
165
|
+
const total = this.hooksStatus.projectCount ?? this.hooksStatus.claude?.total ?? 0;
|
|
166
|
+
if (this.hooksStatus.targetMode === 'configured-projects') {
|
|
167
|
+
if (total === 0) return '未配置项目,请先在设置中添加项目路径';
|
|
168
|
+
const parts = [
|
|
169
|
+
`Claude ${this.hooksStatus.claude?.enabledCount || 0}/${total}`,
|
|
170
|
+
`Codex ${this.hooksStatus.codex?.enabledCount || 0}/${total}`,
|
|
171
|
+
`OpenCode ${this.hooksStatus.opencode?.enabledCount || 0}/${total}`,
|
|
172
|
+
`steps ${this.hooksStatus.stepsReadyCount || 0}/${total}`,
|
|
173
|
+
];
|
|
174
|
+
return `设置内项目 hooks:${parts.join(' / ')}`;
|
|
175
|
+
}
|
|
176
|
+
const parts = [
|
|
177
|
+
`Claude ${this.hooksStatus.claude?.enabled ? '已开启' : '未开启'}`,
|
|
178
|
+
`Codex ${this.hooksStatus.codex?.enabled ? '已开启' : '未开启'}`,
|
|
179
|
+
`OpenCode ${this.hooksStatus.opencode?.enabled ? '已开启' : '未开启'}`,
|
|
180
|
+
`steps ${this.hooksStatus.stepsInitialized ? '已初始化' : '未初始化'}`,
|
|
181
|
+
];
|
|
182
|
+
return parts.join(' / ');
|
|
183
|
+
},
|
|
184
|
+
|
|
149
185
|
/* ── init ── */
|
|
150
186
|
async init() {
|
|
151
187
|
this.loadStateFromHash();
|
|
@@ -157,6 +193,8 @@ function appState() {
|
|
|
157
193
|
}
|
|
158
194
|
});
|
|
159
195
|
await this.loadTools();
|
|
196
|
+
await this.loadHooksStatus();
|
|
197
|
+
await this.loadStepStats();
|
|
160
198
|
// 首次加载时先获取全量数据填充侧边栏,再按当前工具加载
|
|
161
199
|
if (this.activeTool !== 'all') {
|
|
162
200
|
try {
|
|
@@ -189,6 +227,83 @@ function appState() {
|
|
|
189
227
|
} catch (e) { console.warn('loadTools failed:', e); this.availableTools = []; }
|
|
190
228
|
},
|
|
191
229
|
|
|
230
|
+
async loadStepStats() {
|
|
231
|
+
try {
|
|
232
|
+
const data = await fetchStepStats();
|
|
233
|
+
this.stepStats = data;
|
|
234
|
+
this.stepStatusLabel = data?.available
|
|
235
|
+
? `STEP READY · ${data.stepCount || 0}`
|
|
236
|
+
: 'STEP NOT READY';
|
|
237
|
+
} catch {
|
|
238
|
+
this.stepStats = null;
|
|
239
|
+
this.stepStatusLabel = '';
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
|
|
243
|
+
async loadHooksStatus() {
|
|
244
|
+
try {
|
|
245
|
+
this.hooksStatus = await fetchHooksStatus();
|
|
246
|
+
} catch (e) {
|
|
247
|
+
console.warn('loadHooksStatus failed:', e);
|
|
248
|
+
this.hooksStatus = null;
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
|
|
252
|
+
showHooksConfirmModal() {
|
|
253
|
+
const count = this.hooksStatus?.projectCount ?? 0;
|
|
254
|
+
const el = document.getElementById('hooksConfirmCount');
|
|
255
|
+
if (el) el.textContent = count;
|
|
256
|
+
const modal = document.getElementById('hooksConfirmModal');
|
|
257
|
+
if (modal) modal.style.display = 'flex';
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
hideHooksConfirmModal() {
|
|
261
|
+
const modal = document.getElementById('hooksConfirmModal');
|
|
262
|
+
if (modal) modal.style.display = 'none';
|
|
263
|
+
},
|
|
264
|
+
|
|
265
|
+
async enableHooksFromUi() {
|
|
266
|
+
this.hideHooksConfirmModal();
|
|
267
|
+
if (this.hooksBusy) return;
|
|
268
|
+
this.hooksBusy = true;
|
|
269
|
+
try {
|
|
270
|
+
await updateHooks('enable');
|
|
271
|
+
await this.loadHooksStatus();
|
|
272
|
+
await this.loadStepStats();
|
|
273
|
+
showToast('hooks 已开启');
|
|
274
|
+
} catch (err) {
|
|
275
|
+
showToast('开启 hooks 失败: ' + err.message);
|
|
276
|
+
} finally {
|
|
277
|
+
this.hooksBusy = false;
|
|
278
|
+
}
|
|
279
|
+
},
|
|
280
|
+
|
|
281
|
+
showHooksDisableConfirmModal() {
|
|
282
|
+
const modal = document.getElementById('hooksDisableConfirmModal');
|
|
283
|
+
if (modal) modal.style.display = 'flex';
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
hideHooksDisableConfirmModal() {
|
|
287
|
+
const modal = document.getElementById('hooksDisableConfirmModal');
|
|
288
|
+
if (modal) modal.style.display = 'none';
|
|
289
|
+
},
|
|
290
|
+
|
|
291
|
+
async disableHooksFromUi() {
|
|
292
|
+
this.hideHooksDisableConfirmModal();
|
|
293
|
+
if (this.hooksBusy) return;
|
|
294
|
+
this.hooksBusy = true;
|
|
295
|
+
try {
|
|
296
|
+
await updateHooks('disable');
|
|
297
|
+
await this.loadHooksStatus();
|
|
298
|
+
await this.loadStepStats();
|
|
299
|
+
showToast('hooks 已关闭');
|
|
300
|
+
} catch (err) {
|
|
301
|
+
showToast('关闭 hooks 失败: ' + err.message);
|
|
302
|
+
} finally {
|
|
303
|
+
this.hooksBusy = false;
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
|
|
192
307
|
setTool(name) {
|
|
193
308
|
this.activeTool = name;
|
|
194
309
|
this.loadCurrentView();
|
|
@@ -535,6 +650,16 @@ function appState() {
|
|
|
535
650
|
pctSum += pct;
|
|
536
651
|
return { name: toolDisplayNames[name] || name, pct, tokens: fmtShort(tok), color: toolColors[name] || 'var(--foreground)' };
|
|
537
652
|
});
|
|
653
|
+
|
|
654
|
+
/* Line-level blame evidence */
|
|
655
|
+
const blameEv = renderLineBlameEvidence(gitStats?.commitList);
|
|
656
|
+
if (blameEv) {
|
|
657
|
+
this.lineBlameEvidence = blameEv;
|
|
658
|
+
this.lineBlamePrecision = `行级归因: ${blameEv.aiLines}/${blameEv.totalLines} 行 (${blameEv.precision}%) · ${blameEv.commitCount} 提交`;
|
|
659
|
+
} else {
|
|
660
|
+
this.lineBlameEvidence = null;
|
|
661
|
+
this.lineBlamePrecision = '';
|
|
662
|
+
}
|
|
538
663
|
},
|
|
539
664
|
|
|
540
665
|
renderTimeline(trendData, usageStats) {
|
package/public/config.js
CHANGED
package/public/git-insights.js
CHANGED
|
@@ -42,6 +42,25 @@ function renderCommitTypeChart(typeEntries) {
|
|
|
42
42
|
setChart('commitTypeChart', instance);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Render line-level attribution evidence summary.
|
|
47
|
+
* Called when gitStats.commitList has commits with lineBlame data.
|
|
48
|
+
*/
|
|
49
|
+
export function renderLineBlameEvidence(commitList) {
|
|
50
|
+
const blamed = (commitList || []).filter(c => c.lineBlame && c.lineBlame.source === 'step_blame');
|
|
51
|
+
if (blamed.length === 0) return null;
|
|
52
|
+
const totalAiLines = blamed.reduce((s, c) => s + (c.lineBlame.aiLines || 0), 0);
|
|
53
|
+
const totalLines = blamed.reduce((s, c) => s + (c.lineBlame.totalLines || 0), 0);
|
|
54
|
+
const totalAiDeleted = blamed.reduce((s, c) => s + (c.lineBlame.aiDeletedLines || 0), 0);
|
|
55
|
+
return {
|
|
56
|
+
commitCount: blamed.length,
|
|
57
|
+
aiLines: totalAiLines,
|
|
58
|
+
totalLines,
|
|
59
|
+
aiDeletedLines: totalAiDeleted,
|
|
60
|
+
precision: totalLines > 0 ? Math.round((totalAiLines / totalLines) * 100) : 0,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
45
64
|
const COMMIT_TYPE_COLORS = {
|
|
46
65
|
feat: '#8ab8a0', fix: '#c49090', refactor: '#a090c0', docs: '#90a8c8',
|
|
47
66
|
test: '#c8b880', chore: '#a8a8a8', perf: '#c890b0', style: '#80b8b8',
|
package/public/index.html
CHANGED
|
@@ -52,6 +52,22 @@
|
|
|
52
52
|
</ul>
|
|
53
53
|
</nav>
|
|
54
54
|
|
|
55
|
+
<div class="rail-hooks" x-show="hooksStatus" x-cloak>
|
|
56
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
|
|
57
|
+
<span style="font-size:11px;font-weight:500;">行级 AI 归因</span>
|
|
58
|
+
<span class="font-mono" style="font-size:9px;letter-spacing:0.08em;opacity:0.55">HOOKS</span>
|
|
59
|
+
</div>
|
|
60
|
+
<div class="rail-hooks-meta" x-text="hooksStatusText"></div>
|
|
61
|
+
<div style="display:flex;gap:6px;margin-top:8px;">
|
|
62
|
+
<button class="rail-hooks-btn" x-show="hooksNeedAction" @click="showHooksConfirmModal()" :disabled="hooksBusy">
|
|
63
|
+
<span x-text="hooksBusy ? '...' : '开启'"></span>
|
|
64
|
+
</button>
|
|
65
|
+
<button class="rail-hooks-btn rail-hooks-btn--off" x-show="!hooksNeedAction" @click="showHooksDisableConfirmModal()" :disabled="hooksBusy">
|
|
66
|
+
关闭
|
|
67
|
+
</button>
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
|
|
55
71
|
<div class="rail-footer">
|
|
56
72
|
<div style="display:flex;align-items:center;justify-content:space-between;width:100%;">
|
|
57
73
|
<div style="display:flex;align-items:center;gap:8px;">
|
|
@@ -188,6 +204,8 @@
|
|
|
188
204
|
<span class="label section-head-en">/ AI CONTRIBUTION</span>
|
|
189
205
|
<span class="section-head-line"></span>
|
|
190
206
|
<span class="section-head-meta font-mono" style="font-size:10px;opacity:0.55" x-text="aiContributionMeta"></span>
|
|
207
|
+
<span x-show="stepStatusLabel && !lineBlameEvidence" class="font-mono" style="font-size:9px;padding:2px 8px;border-radius:4px;background:var(--ink-12);color:var(--foreground);letter-spacing:0.08em;opacity:0.7" x-text="stepStatusLabel"></span>
|
|
208
|
+
<span x-show="lineBlameEvidence" class="font-mono" style="font-size:9px;padding:2px 8px;border-radius:4px;background:var(--forest);color:var(--background);letter-spacing:0.08em;opacity:0.85">LINE-BLAME</span>
|
|
191
209
|
</div>
|
|
192
210
|
|
|
193
211
|
<div class="ai-hero-grid">
|
|
@@ -196,6 +214,7 @@
|
|
|
196
214
|
<div style="flex-shrink:0;">
|
|
197
215
|
<div class="ai-hero-pct"><span class="text-aurora-shimmer" x-text="aiLinePctDisplay"></span><span :style="'color:' + colors.rust">%</span></div>
|
|
198
216
|
<div class="ai-hero-desc" x-html="aiSummaryDesc"></div>
|
|
217
|
+
<div x-show="lineBlamePrecision" class="font-mono" style="font-size:10px;margin-top:6px;opacity:0.55" x-text="lineBlamePrecision"></div>
|
|
199
218
|
</div>
|
|
200
219
|
<div style="flex:1;padding-bottom:12px;">
|
|
201
220
|
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
|
|
@@ -640,6 +659,56 @@
|
|
|
640
659
|
</div>
|
|
641
660
|
</div>
|
|
642
661
|
</main>
|
|
662
|
+
|
|
663
|
+
<!-- ── Hooks Confirm Modal ── -->
|
|
664
|
+
<div id="hooksConfirmModal" class="modal-overlay" style="display:none;">
|
|
665
|
+
<div class="modal-backdrop" @click="hideHooksConfirmModal()"></div>
|
|
666
|
+
<div class="modal-panel" style="max-width:440px;">
|
|
667
|
+
<div class="modal-header">
|
|
668
|
+
<h3 style="font-size:15px;font-weight:500;">确认开启 hooks</h3>
|
|
669
|
+
<button class="rail-btn-icon" @click="hideHooksConfirmModal()" style="color:var(--foreground);opacity:0.65;">
|
|
670
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
671
|
+
</button>
|
|
672
|
+
</div>
|
|
673
|
+
<div class="modal-body" style="padding:16px 24px;">
|
|
674
|
+
<p style="font-size:13px;line-height:1.7;opacity:0.85;">将为设置中的 <strong id="hooksConfirmCount">0</strong> 个项目开启 hooks,修改各项目下的配置文件并自动备份:</p>
|
|
675
|
+
<div class="hooks-confirm-files">
|
|
676
|
+
<div><span style="color:var(--claude)">Claude</span> .claude/settings.local.json</div>
|
|
677
|
+
<div><span style="color:var(--codex)">Codex</span> .codex/config.toml</div>
|
|
678
|
+
<div><span style="color:var(--opencode)">OpenCode</span> .opencode/plugins/lumencode-step-tracker.js</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
<div class="modal-footer" style="gap:8px;">
|
|
682
|
+
<button class="btn btn-outline" @click="hideHooksConfirmModal()">取消</button>
|
|
683
|
+
<button class="btn btn-primary" @click="enableHooksFromUi()" :disabled="hooksBusy">确认开启</button>
|
|
684
|
+
</div>
|
|
685
|
+
</div>
|
|
686
|
+
</div>
|
|
687
|
+
|
|
688
|
+
<!-- ── Hooks Disable Confirm Modal ── -->
|
|
689
|
+
<div id="hooksDisableConfirmModal" class="modal-overlay" style="display:none;">
|
|
690
|
+
<div class="modal-backdrop" @click="hideHooksDisableConfirmModal()"></div>
|
|
691
|
+
<div class="modal-panel" style="max-width:440px;">
|
|
692
|
+
<div class="modal-header">
|
|
693
|
+
<h3 style="font-size:15px;font-weight:500;">确认关闭 hooks</h3>
|
|
694
|
+
<button class="rail-btn-icon" @click="hideHooksDisableConfirmModal()" style="color:var(--foreground);opacity:0.65;">
|
|
695
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
696
|
+
</button>
|
|
697
|
+
</div>
|
|
698
|
+
<div class="modal-body" style="padding:16px 24px;">
|
|
699
|
+
<p style="font-size:13px;line-height:1.7;opacity:0.85;">关闭后将<strong style="color:var(--warning)">停止记录</strong>行级编辑步骤,影响如下:</p>
|
|
700
|
+
<div class="hooks-confirm-files">
|
|
701
|
+
<div style="color:var(--warning)">新提交的代码将无法进行行级 AI 归因</div>
|
|
702
|
+
<div>已记录的历史数据会保留,不受影响</div>
|
|
703
|
+
<div>恢复记录需重新开启 hooks</div>
|
|
704
|
+
</div>
|
|
705
|
+
</div>
|
|
706
|
+
<div class="modal-footer" style="gap:8px;">
|
|
707
|
+
<button class="btn btn-outline" @click="hideHooksDisableConfirmModal()">取消</button>
|
|
708
|
+
<button class="btn btn-primary" @click="disableHooksFromUi()" :disabled="hooksBusy">确认关闭</button>
|
|
709
|
+
</div>
|
|
710
|
+
</div>
|
|
711
|
+
</div>
|
|
643
712
|
</div>
|
|
644
713
|
|
|
645
714
|
<!-- ── Settings Modal ── -->
|