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.
package/lib/server.js CHANGED
@@ -7,10 +7,12 @@ import { generateWorkReport, generateFeishuCard } from './report.js';
7
7
  import { collectAllRecords, filterRecordsByPeriod, groupBySessions, computeUsageStats, computeTrendData, computePrevPeriodRange } from './aggregate.js';
8
8
  import { normalizeProjectPath } from './aggregate.js';
9
9
  import { invalidateFileCache } from './cache.js';
10
- import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats } from './git.js';
10
+ import { invalidateGitCache, getGitStatsForMultipleReposAsync, finalizeGitStats, computeAIContribution, computeCommitTypes, computeFileHotspots } from './git.js';
11
11
  import { identifyBillingBlocks } from './blocks.js';
12
- import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
- import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
12
+ import { detectAvailableTools, parseAllEnabledTools } from './parsers/index.js';
13
+ import { isAssistantRecord, getInputTokens, getOutputTokens } from './record-utils.js';
14
+ import { StepTracker } from './step-tracker.js';
15
+ import { disableHooks, enableHooks, getHooksStatus, HOOK_TOOLS, initStepTracking } from './hooks-manager.js';
14
16
 
15
17
  // basename 提取,兼容不同路径格式
16
18
  function getProjectBaseName(p) {
@@ -25,7 +27,7 @@ let appVersion = '0.0.0';
25
27
  try {
26
28
  const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
27
29
  appVersion = pkg.version || '0.0.0';
28
- } catch {}
30
+ } catch (e) { console.warn("[server] error", e.message); }
29
31
 
30
32
  const MIME = {
31
33
  '.html': 'text/html',
@@ -37,13 +39,42 @@ const MIME = {
37
39
  '.ico': 'image/x-icon',
38
40
  };
39
41
 
40
- export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
41
- function computeIncludeProjects(cfg) {
42
- if (cfg.repos && cfg.repos.length > 0) {
43
- return cfg.repos.map(r => normalizeProjectPath(r));
44
- }
45
- return null;
46
- }
42
+ export function startServer(config, effectiveIncludeProjects, buildReportData, configPath) {
43
+ function computeIncludeProjects(cfg) {
44
+ if (cfg.repos && cfg.repos.length > 0) {
45
+ return cfg.repos.map(r => normalizeProjectPath(r));
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function getHookProjectRoots(cfg) {
51
+ if (!Array.isArray(cfg.repos)) return [];
52
+ return [...new Set(cfg.repos.map(r => normalizeProjectPath(String(r || '').trim())).filter(Boolean))];
53
+ }
54
+
55
+ function getConfiguredHooksStatus(cfg) {
56
+ const projectRoots = getHookProjectRoots(cfg);
57
+ const projects = projectRoots.map(root => getHooksStatus(root));
58
+ const total = projects.length;
59
+ const enabledCount = (tool) => projects.filter(p => p[tool]?.enabled).length;
60
+ const stepsReadyCount = projects.filter(p => p.stepsInitialized).length;
61
+ const toolStatus = (tool) => ({
62
+ enabled: total > 0 && enabledCount(tool) === total,
63
+ enabledCount: enabledCount(tool),
64
+ total,
65
+ });
66
+
67
+ return {
68
+ targetMode: 'configured-projects',
69
+ projectCount: total,
70
+ projects,
71
+ stepsInitialized: total > 0 && stepsReadyCount === total,
72
+ stepsReadyCount,
73
+ claude: toolStatus(HOOK_TOOLS.CLAUDE),
74
+ codex: toolStatus(HOOK_TOOLS.CODEX),
75
+ opencode: toolStatus(HOOK_TOOLS.OPENCODE),
76
+ };
77
+ }
47
78
 
48
79
  const PORT = process.env.LUMENCODE_PORT || 4567;
49
80
 
@@ -58,9 +89,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
58
89
  const REPORT_CACHE_TTL = 30_000; // 30s
59
90
  const REPORT_CACHE_MAX_SIZE = 50;
60
91
 
61
- function getReportCacheKey(period, date, tool, customStart, customEnd, format) {
62
- return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}|${format || 'json'}`;
63
- }
92
+ function getReportCacheKey(period, date, tool, customStart, customEnd) {
93
+ return `${period}|${date}|${tool || 'all'}|${customStart || ''}|${customEnd || ''}`;
94
+ }
64
95
 
65
96
  function getCachedReport(cacheKey) {
66
97
  const cached = _reportCache.get(cacheKey);
@@ -78,9 +109,55 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
78
109
  }
79
110
  }
80
111
 
81
- function invalidateReportCache() {
82
- _reportCache.clear();
83
- }
112
+ function invalidateReportCache() {
113
+ _reportCache.clear();
114
+ }
115
+
116
+ function writeJson(res, statusCode, data) {
117
+ res.writeHead(statusCode, {
118
+ 'Content-Type': 'application/json',
119
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
120
+ });
121
+ res.end(JSON.stringify(data));
122
+ }
123
+
124
+ function parseHookTools(value) {
125
+ if (!value) return [HOOK_TOOLS.CLAUDE, HOOK_TOOLS.CODEX, HOOK_TOOLS.OPENCODE];
126
+ const tools = [];
127
+ for (const raw of String(value).split(',')) {
128
+ const tool = raw.trim().toLowerCase();
129
+ if (!tool) continue;
130
+ if (tool === 'claude' || tool === 'claude-code') tools.push(HOOK_TOOLS.CLAUDE);
131
+ else if (tool === 'codex') tools.push(HOOK_TOOLS.CODEX);
132
+ else if (tool === 'opencode' || tool === 'open-code') tools.push(HOOK_TOOLS.OPENCODE);
133
+ }
134
+ return [...new Set(tools)];
135
+ }
136
+
137
+ function readJsonBody(req, res, callback) {
138
+ let body = '';
139
+ let bodySize = 0;
140
+ const MAX_BODY = 1024 * 1024; // 1MB
141
+ req.on('data', chunk => {
142
+ bodySize += chunk.length;
143
+ if (bodySize > MAX_BODY) { req.destroy(); return; }
144
+ body += chunk;
145
+ });
146
+ req.on('end', () => {
147
+ if (bodySize > MAX_BODY) {
148
+ writeJson(res, 413, { error: '请求体过大' });
149
+ return;
150
+ }
151
+ try {
152
+ Promise.resolve(callback(body ? JSON.parse(body) : {})).catch(err => {
153
+ console.error('API error:', err.message);
154
+ writeJson(res, 500, { error: err.message || '服务器内部错误' });
155
+ });
156
+ } catch {
157
+ writeJson(res, 400, { error: 'JSON 解析失败' });
158
+ }
159
+ });
160
+ }
84
161
 
85
162
  function getCachedParse(config, includeProjects) {
86
163
  const key = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
@@ -89,20 +166,80 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
89
166
  return null;
90
167
  }
91
168
 
92
- async function getOrParse(config, includeProjects) {
93
- const cached = getCachedParse(config, includeProjects);
94
- if (cached) return cached;
95
- const result = await parseAllEnabledTools(config, {
96
- excludeProjects: config.excludeProjects,
169
+ async function getOrParse(config, includeProjects) {
170
+ const cached = getCachedParse(config, includeProjects);
171
+ if (cached) return cached;
172
+ const result = await parseAllEnabledTools(config, {
173
+ excludeProjects: config.excludeProjects,
97
174
  includeProjects,
98
175
  });
99
176
  _parsedCache = result;
100
177
  _parsedCacheKey = `${config.claudeDir}|${includeProjects?.join(',') || ''}`;
101
- _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
102
- return result;
103
- }
104
-
105
- const server = createServer(async (req, res) => {
178
+ _parsedCacheExpire = Date.now() + PARSED_CACHE_TTL;
179
+ return result;
180
+ }
181
+
182
+ function deriveProjectGitStats(gitStats, project, start, end, attributionOptions) {
183
+ if (!gitStats?.commitList?.length || !project) return null;
184
+ const windowEnd = end + 'T23:59:59';
185
+ const commitList = gitStats.commitList.filter(c => {
186
+ return getProjectBaseName(c.repo) === project && (c.date || '') >= start && (c.date || '') <= windowEnd;
187
+ });
188
+ if (commitList.length === 0) return null;
189
+
190
+ return {
191
+ commits: commitList.length,
192
+ filesChanged: new Set(commitList.flatMap(c => (c.files || []).map(f => f.path))).size,
193
+ linesAdded: commitList.reduce((s, c) => s + (c.linesAdded || 0), 0),
194
+ linesDeleted: commitList.reduce((s, c) => s + (c.linesDeleted || 0), 0),
195
+ commitList,
196
+ commitTypes: computeCommitTypes(commitList),
197
+ fileHotspots: computeFileHotspots(commitList, 10),
198
+ aiContribution: computeAIContribution(commitList, null, attributionOptions),
199
+ attributionSummary: gitStats.attributionSummary,
200
+ };
201
+ }
202
+
203
+ function prepareJsonReportData(baseData, tool) {
204
+ const data = { ...baseData };
205
+
206
+ if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
207
+ const toolAi = data.gitStats.aiContributionByTool[tool];
208
+ if (toolAi) {
209
+ data.gitStats = { ...data.gitStats, aiContribution: toolAi };
210
+ }
211
+ }
212
+
213
+ if (data.usageStats?.models) {
214
+ const modelEntries = Object.entries(data.usageStats.models)
215
+ .sort((a, b) => b[1].cost - a[1].cost);
216
+ const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
217
+ const cacheRead = data.usageStats.cacheRead || 0;
218
+ const cacheCreate = data.usageStats.cacheCreate || 0;
219
+ let cacheSaving = 0;
220
+ if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
221
+ const totalInput = data.usageStats.inputTokens || 1;
222
+ const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
223
+ cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
224
+ }
225
+ data.costBreakdown = {
226
+ models: modelEntries.map(([name, d]) => ({
227
+ name,
228
+ cost: d.cost || 0,
229
+ mode: d.costMode || 'unknown',
230
+ requests: d.count,
231
+ inputTokens: d.inputTokens,
232
+ outputTokens: d.outputTokens,
233
+ })),
234
+ cacheSaving,
235
+ total: Math.round(totalCost * 100) / 100,
236
+ };
237
+ }
238
+
239
+ return data;
240
+ }
241
+
242
+ const server = createServer(async (req, res) => {
106
243
  const url = new URL(req.url, `http://localhost:${PORT}`);
107
244
 
108
245
  // 安全响应头
@@ -112,8 +249,60 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
112
249
  res.setHeader('X-XSS-Protection', '1; mode=block');
113
250
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
114
251
 
115
- // API endpoint
116
- if (url.pathname === '/api/tools') {
252
+ // API endpoint
253
+ if (url.pathname === '/api/hooks') {
254
+ try {
255
+ if (req.method === 'GET') {
256
+ writeJson(res, 200, getConfiguredHooksStatus(config));
257
+ return;
258
+ }
259
+
260
+ if (req.method === 'POST') {
261
+ readJsonBody(req, res, async (body) => {
262
+ const action = body.action || 'enable';
263
+ const tools = parseHookTools(body.tools || url.searchParams.get('tools'));
264
+ if (tools.length === 0) {
265
+ writeJson(res, 400, { error: '未选择支持的 hooks 工具' });
266
+ return;
267
+ }
268
+ const projectRoots = getHookProjectRoots(config);
269
+ if (projectRoots.length === 0) {
270
+ writeJson(res, 400, { error: '请先在设置中添加项目路径,页面开启 hooks 只作用于设置内配置的项目。' });
271
+ return;
272
+ }
273
+ const stepTracking = [];
274
+ const results = [];
275
+ for (const projectRoot of projectRoots) {
276
+ let projectStepTracking = null;
277
+ if (action !== 'disable') {
278
+ projectStepTracking = await initStepTracking(projectRoot);
279
+ stepTracking.push({ projectRoot, ...projectStepTracking });
280
+ }
281
+ const projectResults = action === 'disable'
282
+ ? disableHooks(projectRoot, tools, { backup: true })
283
+ : enableHooks(projectRoot, tools, { backup: true });
284
+ results.push({ projectRoot, stepTracking: projectStepTracking, results: projectResults });
285
+ }
286
+ writeJson(res, 200, {
287
+ success: true,
288
+ action,
289
+ stepTracking,
290
+ results,
291
+ status: getConfiguredHooksStatus(config),
292
+ });
293
+ });
294
+ return;
295
+ }
296
+
297
+ writeJson(res, 405, { error: 'Method not allowed' });
298
+ } catch (err) {
299
+ console.error('API error:', err.message);
300
+ writeJson(res, 500, { error: err.message || '服务器内部错误' });
301
+ }
302
+ return;
303
+ }
304
+
305
+ if (url.pathname === '/api/tools') {
117
306
  try {
118
307
  const tools = await detectAvailableTools(config);
119
308
  const enabled = config.enabledTools || tools.filter(t => t.detected).map(t => t.name);
@@ -183,21 +372,26 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
183
372
 
184
373
  try {
185
374
  // 查询结果缓存:相同条件直接返回缓存
186
- const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd, format);
187
- let data = getCachedReport(reportCacheKey);
188
- if (data) {
189
- res.writeHead(200, {
190
- 'Content-Type': 'application/json',
191
- 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
192
- 'X-Cache': 'HIT',
193
- });
194
- res.end(JSON.stringify(data));
195
- return;
196
- }
197
-
198
- const parsed = await getOrParse(config, includeProjects);
199
- data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
200
- if (!data) {
375
+ const reportCacheKey = getReportCacheKey(period, date, tool, customStart, customEnd);
376
+ let data = getCachedReport(reportCacheKey);
377
+ if (data && format !== 'work') {
378
+ const responseData = prepareJsonReportData(data, tool);
379
+ res.writeHead(200, {
380
+ 'Content-Type': 'application/json',
381
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
382
+ 'X-Cache': 'HIT',
383
+ });
384
+ res.end(JSON.stringify(responseData));
385
+ return;
386
+ }
387
+
388
+ let parsed = null;
389
+ if (!data) {
390
+ parsed = await getOrParse(config, includeProjects);
391
+ data = await buildReportData(period, date, config, includeProjects, tool, parsed, { customStart, customEnd });
392
+ if (data) setCachedReport(reportCacheKey, data);
393
+ }
394
+ if (!data) {
201
395
  res.writeHead(200, { 'Content-Type': 'application/json' });
202
396
  res.end(JSON.stringify({
203
397
  error: '未找到数据',
@@ -223,10 +417,11 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
223
417
  let projUsageStats = data.usageStats;
224
418
  let projGitStats = data.gitStats;
225
419
  let projPrevStats = data.prevStats;
226
- let projectName = '';
227
-
228
- if (project) {
229
- const { records: allRecords } = parsed;
420
+ let projectName = '';
421
+
422
+ if (project) {
423
+ if (!parsed) parsed = await getOrParse(config, includeProjects);
424
+ const { records: allRecords } = parsed;
230
425
  const toolRecords = tool !== 'all' ? allRecords.filter(r => r.tool === tool) : allRecords;
231
426
  // basename 匹配
232
427
  const projRecords = toolRecords.filter(r => {
@@ -246,22 +441,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
246
441
  projPrevStats = prevProjFiltered.length > 0 ? computeUsageStats(prevProjFiltered, config.scenarioKeywords, config.costMode) : null;
247
442
 
248
443
  // 单项目 Git 统计
249
- if (config.repos?.length > 0) {
250
- const matchedRepo = config.repos.find(r => getProjectBaseName(r) === project);
251
- if (matchedRepo) {
252
- try {
253
- const { getGitStatsAsync } = await import('./git.js');
254
- const sessions = groupBySessions(projFiltered);
255
- const { finalizeGitStats, getGitStatsForMultipleReposAsync } = await import('./git.js');
256
- let repoGit = await getGitStatsForMultipleReposAsync([matchedRepo], pStart, pEnd + 'T23:59:59');
257
- repoGit = finalizeGitStats(repoGit, sessions);
258
- projGitStats = repoGit;
259
- } catch {}
260
- }
261
- } else {
262
- projGitStats = null;
263
- }
264
- }
444
+ projGitStats = deriveProjectGitStats(data.gitStats, project, pStart, pEnd, config.aiAttribution);
445
+ }
265
446
 
266
447
  const markdown = generateWorkReport(projUsageStats, projGitStats, period, data.start, data.end, projPrevStats, { level, platform, tool, projectName });
267
448
  res.writeHead(200, {
@@ -272,52 +453,14 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
272
453
  return;
273
454
  }
274
455
 
275
- // 按工具替换 aiContribution
276
- if (tool !== 'all' && data.gitStats?.aiContributionByTool) {
277
- const toolAi = data.gitStats.aiContributionByTool[tool];
278
- if (toolAi) {
279
- data.gitStats.aiContribution = toolAi;
280
- }
281
- }
282
-
283
- // 添加费用分解
284
- if (data.usageStats?.models) {
285
- const modelEntries = Object.entries(data.usageStats.models)
286
- .sort((a, b) => b[1].cost - a[1].cost);
287
- const totalCost = modelEntries.reduce((s, [, d]) => s + (d.cost || 0), 0);
288
- // 缓存节省 = (inputTokens - cacheRead) * avgInputRate - 已通过 cacheRead 低价体现
289
- // 简化:缓存节省 = cacheRead * avgInputRate * (1 - cacheReadRate/inputRate)
290
- const cacheRead = data.usageStats.cacheRead || 0;
291
- const cacheCreate = data.usageStats.cacheCreate || 0;
292
- let cacheSaving = 0;
293
- if (totalCost > 0 && (cacheRead + cacheCreate) > 0) {
294
- const totalInput = data.usageStats.inputTokens || 1;
295
- const avgInputCostPerToken = totalCost / (totalInput + data.usageStats.outputTokens + cacheRead + cacheCreate || 1);
296
- cacheSaving = Math.round(cacheRead * avgInputCostPerToken * 0.9 * 100) / 100;
297
- }
298
- data.costBreakdown = {
299
- models: modelEntries.map(([name, d]) => ({
300
- name,
301
- cost: d.cost || 0,
302
- mode: d.costMode || 'unknown',
303
- requests: d.count,
304
- inputTokens: d.inputTokens,
305
- outputTokens: d.outputTokens,
306
- })),
307
- cacheSaving,
308
- total: Math.round(totalCost * 100) / 100,
309
- };
310
- }
456
+ const responseData = prepareJsonReportData(data, tool);
311
457
 
312
- // 写入查询结果缓存
313
- setCachedReport(reportCacheKey, data);
314
-
315
- res.writeHead(200, {
458
+ res.writeHead(200, {
316
459
  'Content-Type': 'application/json',
317
460
  'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
318
461
  'X-Cache': 'MISS',
319
462
  });
320
- res.end(JSON.stringify(data));
463
+ res.end(JSON.stringify(responseData));
321
464
  } catch (err) {
322
465
  res.writeHead(500, { 'Content-Type': 'application/json' });
323
466
  console.error('API error:', err.message);
@@ -348,9 +491,12 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
348
491
  const extEnd = new Date(end);
349
492
  extEnd.setDate(extEnd.getDate() + 2);
350
493
  const gitStats = await getGitStatsForMultipleReposAsync(sessionRepos, start, extEnd.toISOString().slice(0, 10) + 'T23:59:59');
351
- finalizeGitStats(gitStats, sessions);
494
+ await finalizeGitStats(gitStats, sessions, {
495
+ attribution: config.aiAttribution,
496
+ stepTracking: config.stepTracking,
497
+ });
352
498
  }
353
- } catch {}
499
+ } catch (e) { console.warn("[server] error", e.message); }
354
500
  }
355
501
 
356
502
  // 精简返回字段,保留效率指标
@@ -500,7 +646,8 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
500
646
  if (newConfig.enabledTools !== undefined) config.enabledTools = newConfig.enabledTools;
501
647
  if (newConfig.repos !== undefined) { if (!Array.isArray(newConfig.repos)) { res.writeHead(400, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'repos 格式无效' })); return; } config.repos = newConfig.repos; }
502
648
  if (newConfig.excludeProjects !== undefined) config.excludeProjects = newConfig.excludeProjects;
503
- if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
649
+ if (newConfig.scenarioKeywords !== undefined) config.scenarioKeywords = newConfig.scenarioKeywords;
650
+ if (newConfig.stepTracking !== undefined) config.stepTracking = newConfig.stepTracking;
504
651
  invalidateFileCache();
505
652
  invalidateGitCache();
506
653
  _parsedCache = null; // 配置变更后清除解析缓存
@@ -521,6 +668,30 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
521
668
  return;
522
669
  }
523
670
 
671
+ // Step blame stats API
672
+ if (url.pathname === '/api/step-stats') {
673
+ let stepStats = { stepCount: 0, sessionCount: 0, available: false };
674
+ try {
675
+ if (config.stepTracking?.enabled !== false) {
676
+ for (const repo of config.repos || []) {
677
+ const tracker = new StepTracker(repo, { dbPath: config.stepTracking?.dbPath });
678
+ if (await tracker.isAvailableAsync()) {
679
+ await tracker.open();
680
+ stepStats = { ...tracker.getStats(), available: true };
681
+ tracker.close();
682
+ break;
683
+ }
684
+ }
685
+ }
686
+ } catch { /* step tracking not available */ }
687
+ res.writeHead(200, {
688
+ 'Content-Type': 'application/json',
689
+ 'Access-Control-Allow-Origin': 'http://localhost:' + PORT,
690
+ });
691
+ res.end(JSON.stringify(stepStats));
692
+ return;
693
+ }
694
+
524
695
  // Favicon - 返回空响应避免 404 控制台报错
525
696
  if (url.pathname === '/favicon.ico') {
526
697
  res.writeHead(204);
@@ -567,8 +738,9 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
567
738
  const cyan = '\x1b[96m';
568
739
  const green = '\x1b[92m';
569
740
  const yellow = '\x1b[93m';
570
- const blue = '\x1b[94m';
571
- const dim = '\x1b[2m';
741
+ const blue = '\x1b[94m';
742
+ const dim = '\x1b[2m';
743
+ const actualPort = server.address()?.port || PORT;
572
744
 
573
745
  const banner = [
574
746
  '',
@@ -591,18 +763,35 @@ export function startServer(config, effectiveIncludeProjects, buildReportData, c
591
763
  if (configPath) {
592
764
  process.stdout.write(` ${dim}●${R} ${B}Config${R} ${configPath}\n`);
593
765
  }
594
- const repoCount = config.repos?.length || 0;
595
- if (repoCount > 0) {
596
- process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
597
- }
598
- process.stdout.write('\n');
599
- process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${PORT}${R}\n`);
766
+ const repoCount = config.repos?.length || 0;
767
+ if (repoCount > 0) {
768
+ process.stdout.write(` ${dim}●${R} ${B}Projects${R} ${repoCount} repo(s) detected\n`);
769
+ }
770
+ const hookStatus = getConfiguredHooksStatus(config);
771
+ const hookParts = [
772
+ `Claude ${hookStatus.claude.enabledCount}/${hookStatus.projectCount}`,
773
+ `Codex ${hookStatus.codex.enabledCount}/${hookStatus.projectCount}`,
774
+ `OpenCode ${hookStatus.opencode.enabledCount}/${hookStatus.projectCount}`,
775
+ `steps ${hookStatus.stepsReadyCount}/${hookStatus.projectCount}`,
776
+ ];
777
+ process.stdout.write(` ${dim}●${R} ${B}Hooks${R} ${hookParts.join(' / ')}\n`);
778
+ if (hookStatus.projectCount === 0) {
779
+ process.stdout.write(` ${yellow}${B}!${R} 未配置项目路径:请先在页面设置中添加项目,页面开启 hooks 只作用于设置内项目。\n`);
780
+ } else if (!hookStatus.claude.enabled || !hookStatus.codex.enabled || !hookStatus.opencode.enabled || !hookStatus.stepsInitialized) {
781
+ process.stdout.write(` ${yellow}${B}!${R} 行级归因未完整开启:在页面中开启,或进入项目目录运行 ${B}npx lumencode hooks enable${R}。\n`);
782
+ }
783
+ process.stdout.write('\n');
784
+ process.stdout.write(` ${green}${B}✓${R} Server ready at ${blue}${B}http://localhost:${actualPort}${R}\n`);
600
785
  process.stdout.write('\n');
601
786
 
602
- // Auto-open browser
603
- const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
604
- import('child_process').then(({ exec }) => {
605
- exec(`${openCmd} http://localhost:${PORT}`, () => {});
606
- });
607
- });
608
- }
787
+ // Auto-open browser
788
+ if (process.env.LUMENCODE_NO_OPEN !== '1') {
789
+ const openCmd = process.platform === 'win32' ? 'start' : process.platform === 'darwin' ? 'open' : 'xdg-open';
790
+ import('child_process').then(({ exec }) => {
791
+ exec(`${openCmd} http://localhost:${actualPort}`, () => {});
792
+ });
793
+ }
794
+ });
795
+
796
+ return server;
797
+ }