lumencode 0.4.3 → 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/README.md CHANGED
@@ -11,22 +11,29 @@
11
11
  </p>
12
12
 
13
13
  <p align="center">
14
- 支持 <b>Claude Code · Codex · OpenCode</b> 三大 AI 编码工具 · 600+ 模型定价 · AI 贡献度归因 · 一键飞书/钉钉周报
14
+ 支持 <b>Claude Code · Codex · OpenCode</b> 三大 AI 编码工具 · 600+ 模型定价 · AI 贡献度归因 · 按项目独立汇报 · 自定义时间范围 · 一键飞书/钉钉周报
15
15
  </p>
16
16
 
17
17
  <p align="center">
18
18
  <a href="README_EN.md">English</a> · <a href="#命令用法">命令</a> · <a href="#常见问题">FAQ</a> · <a href="#更新日志">更新日志</a>
19
19
  </p>
20
+ <div align="center">
21
+ <img src="doc/数据分析页面.png" alt="LumenCode Dashboard" width="800">
22
+ </div>
23
+
20
24
  ---
21
25
 
26
+
22
27
  ## 它解决什么问题?
23
28
 
24
- > 「这周 AI 帮你写了多少代码?」「订阅这些工具值不值?」—— 与其每周手算,不如一条命令搞定。
29
+ > AI 帮你写了多少代码?」「订阅这些工具值不值?」—— 与其手算,不如一条命令搞定。
25
30
 
26
31
  | 场景 | 用 lumencode 解决 |
27
32
  |------|----------------------|
28
33
  | **写周报** | 选周报 → 点「工作汇报 → 复制」→ 粘贴飞书/钉钉。**3 秒搞定。** |
29
- | **证明 AI ROI** | 「本周 67% 提交有 AI 参与,AI 辅助新增 4,200 行,费用 $12.5」**有数据,有底气。** |
34
+ | **证明 AI ROI** | 67% 提交有 AI 参与,AI 辅助新增 4,200 行,费用 $12.5」**有数据,有底气。** |
35
+ | **按项目汇报** | 配置多项目后,选择单个项目生成独立工作汇报,方便向不同项目负责人对齐 |
36
+ | **对齐 Sprint 周期** | 除日/周/月外,支持自定义起止日期,不再被固定周期限制 |
30
37
  | **理解使用习惯** | 哪个项目用得最多?哪个模型最费 Token?什么时段是编码高峰?**一目了然。** |
31
38
  | **追踪 AI 成本** | 内置 **600+ 模型定价**(含 GLM、Kimi、Qwen、DeepSeek 等),自动算出等效 API 花销 |
32
39
 
@@ -49,64 +56,62 @@ npx lumencode serve
49
56
 
50
57
  ## 产品亮点
51
58
 
59
+ <div align="center">
60
+ <img src="doc/核心能力.png" alt="LumenCode 核心能力" width="720">
61
+ </div>
62
+
63
+
52
64
  | 亮点 | 说明 |
53
65
  |------|------|
54
66
  | 🌐 **三工具统一** | Claude Code / Codex / OpenCode 数据全自动汇总,左侧标签一键切换 |
55
67
  | 🤖 **AI 贡献度量化** | 识别 `Co-Authored-By: Claude` 等签名,多层归因引擎量化 AI 在你代码中的实际占比 |
56
68
  | 📝 **自然语言工作汇报** | 详报/简报一键生成,支持标准 Markdown / 飞书 / 钉钉三种格式,每个板块附诊断解读 |
69
+ | 📂 **按项目独立汇报** | 右侧面板选择项目,生成该项目的独立工作汇报(commits + AI 交互量 + 热点文件) |
70
+ | 📅 **自定义时间范围** | 除日/周/月外,支持选择任意起止日期,方便对齐 Sprint 周期 |
57
71
  | 💰 **精确费用估算** | 600+ 模型本地定价(含 GLM/Kimi/Qwen/DeepSeek)+ Portkey API 兜底,未知模型不计费而非乱算 |
58
72
  | 📦 **零配置开箱即用** | 首次运行自动检测工具目录、推导项目路径 |
59
73
  | 🔍 **数据钻取** | 点击任意图表下钻明细,从汇总数据到具体会话/提交一气呵成 |
60
74
  | 📈 **趋势与洞察** | 周报/月报附峰值日识别、连续活跃分析、工具使用五类分布(编辑/阅读/执行/规划/研究) |
61
- | 🌙 **暗色模式** | 亮/暗主题一键切换,全图表自适配 |
75
+ | 🌙 **亮/暗主题** | 亮色/暗色主题一键切换,全图表自适配 |
62
76
 
63
77
  ---
64
78
 
65
79
  ## 产品截图
66
80
 
67
- ### 多工具汇总视图
81
+ ### 数据分析总览
68
82
 
69
- > 全工具数据汇总,AI 贡献占比、Token 消耗、费用、活跃时段、模型分布、场景拆分一屏掌握。
70
-
71
- ![全量汇总](doc/全量Ai工具汇总报告.png)
72
-
73
- ### 单工具报告
74
-
75
- > 切换左侧标签即可单独查看任一工具的数据。
83
+ > 左侧数据源面板一键切换工具,主区域汇总 Token 消耗、费用、模型分布、AI 贡献度归因。
76
84
 
77
85
  <table>
78
86
  <tr>
79
- <td><img src="doc/claude_code工具使用报告.png" alt="Claude Code" width="400"></td>
80
- <td><img src="doc/codex使用报告.png" alt="Codex" width="400"></td>
81
- </tr>
82
- <tr>
83
- <td align="center"><b>Claude Code</b></td>
84
- <td align="center"><b>OpenAI Codex</b></td>
85
- </tr>
86
- <tr>
87
- <td><img src="doc/opencode使用报告.png" alt="OpenCode" width="400"></td>
88
- <td></td>
87
+ <td><img src="doc/数据分析页面.png" alt="汇总面板与趋势图" width="400"></td>
88
+ <td><img src="doc/数据分析页面2.png" alt="项目分布与时段分布" width="400"></td>
89
89
  </tr>
90
90
  <tr>
91
- <td align="center"><b>OpenCode</b></td>
92
- <td></td>
91
+ <td align="center">汇总指标 + Token 趋势</td>
92
+ <td align="center">项目分布 + 时段分布 + 会话列表</td>
93
93
  </tr>
94
94
  </table>
95
95
 
96
- ### 场景分析 & 模型分布
96
+ ![AI 贡献度与提交分析](doc/数据分析页面3.png)
97
97
 
98
- > 按工作类型分类(编码 / 测试 / 调试 / 文档 / 审查 / 规划),还能下钻看每个模型的具体 Token 用量。
98
+ ### 多工具维度
99
99
 
100
- <table>
101
- <tr>
102
- <td><img src="doc/工作类型分布_匹配示例.png" alt="场景分析" width="400"></td>
103
- <td><img src="doc/模型使用分布_具体用量.png" alt="模型分布" width="400"></td>
104
- </tr>
105
- <tr>
106
- <td align="center">工作类型分布(含匹配关键词示例)</td>
107
- <td align="center">模型使用分布(含具体 Token 用量)</td>
108
- </tr>
109
- </table>
100
+ > 切换到「全部工具」视图,查看跨工具的汇总数据与对比分析。
101
+
102
+ ![多工具维度](doc/多工具维度.png)
103
+
104
+ ### 项目分布 & 会话记录
105
+
106
+ > 按项目统计 Token、费用、会话数,点击下钻查看单条会话明细。
107
+
108
+ ![项目分布与会话记录](doc/项目分布-会话记录.png)
109
+
110
+ ### 场景分析
111
+
112
+ > 按工作类型分类(编码 / 测试 / 调试 / 文档 / 审查 / 规划),附匹配关键词示例。
113
+
114
+ ![场景分析](doc/工作类型分布_匹配示例.png)
110
115
 
111
116
  ### 工作汇报 · 一键生成可直接发布的周报
112
117
 
@@ -115,6 +120,7 @@ npx lumencode serve
115
120
  - **详报** —— 完整数据 + 洞察解读 + 板块编号,适合周报、月报
116
121
  - **简报** —— 3-5 句话核心摘要,适合日报或群消息
117
122
  - **多平台格式** —— 标准 Markdown / 飞书 / 钉钉,一键切换
123
+ - **按项目生成** —— 右侧面板选择项目,生成该项目的独立汇报
118
124
 
119
125
  <table>
120
126
  <tr>
@@ -127,11 +133,13 @@ npx lumencode serve
127
133
  </tr>
128
134
  </table>
129
135
 
130
- ### 暗色模式
136
+ ### 亮色 / 暗色主题
131
137
 
132
138
  > 全图表配色自适配,长时间阅读不伤眼。
133
139
 
134
- ![暗色模式](doc/暗色模式.png)
140
+ ![亮色模式](doc/浅色模式.png)
141
+
142
+ > 暗色模式为默认主题,上方截图均为暗色模式下的效果。
135
143
 
136
144
  ---
137
145
 
@@ -224,6 +232,14 @@ v0.4.0 起支持 Claude Code、Codex、OpenCode 三种工具,**首次运行自
224
232
 
225
233
  ## 更新日志
226
234
 
235
+ ### v1.0.0 (2026-05-24) — 项目级汇报 & 自定义时间
236
+
237
+ - **按项目独立汇报** — 工作汇报右侧面板新增项目选择器,选定后自动筛选该项目数据,生成独立工作汇报(commits + AI 交互量 + 热点文件)
238
+ - **自定义时间范围** — 新增「自定义」周期选项,支持选择任意起止日期,方便对齐 Sprint 周期
239
+ - **智能日期导航** — 左右箭头根据当前周期自动调整步长:日报 ±1天、周报 ±7天、月报 ±1个月
240
+ - **侧边栏重构** — 版本信息、主题切换、收起按钮统一移至底部,顶部仅保留标题与链接,布局更紧凑
241
+ - **界面重构** — 全新视觉设计,布局更紧凑美观,数据呈现更直观
242
+
227
243
  ### v0.4.0 (2026-05-22) — 多工具统一平台
228
244
 
229
245
  从 Claude Code 单工具报告升级为 AI 编码全栈分析平台。
package/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { loadConfig, initConfig, getConfigPath } from './lib/config.js';
3
3
  import { collectAllRecords, computeUsageStats, filterRecordsByPeriod, normalizeProjectPath, computeTrendData, computePrevPeriodRange, groupBySessions } from './lib/aggregate.js';
4
- import { getGitStatsForMultipleReposAsync, invalidateGitCache, finalizeGitStats, computeCommitTypes, computeFileHotspots } from './lib/git.js';
4
+ import { getGitStatsForMultipleReposAsync, getPerRepoGitStats, invalidateGitCache, finalizeGitStats, computeCommitTypes, computeFileHotspots } from './lib/git.js';
5
5
  import { invalidateFileCache } from './lib/cache.js';
6
6
  import { generateReport, generateWorkReport } from './lib/report.js';
7
7
  import { startServer } from './lib/server.js';
@@ -72,12 +72,17 @@ function loadCliConfig() {
72
72
  return { config, dateArg, effectiveIncludeProjects, configPath };
73
73
  }
74
74
 
75
- async function buildReportData(period, dateArg, config, effectiveIncludeProjects, tool = 'all') {
76
- // 使用新的多工具解析入口
77
- const { records, toolBreakdown } = await parseAllEnabledTools(config, {
78
- excludeProjects: config.excludeProjects,
79
- includeProjects: effectiveIncludeProjects,
80
- });
75
+ async function buildReportData(period, dateArg, config, effectiveIncludeProjects, tool = 'all', preParsed = null, options = {}) {
76
+ // 使用预解析结果或全量解析
77
+ let records, toolBreakdown;
78
+ if (preParsed) {
79
+ ({ records, toolBreakdown } = preParsed);
80
+ } else {
81
+ ({ records, toolBreakdown } = await parseAllEnabledTools(config, {
82
+ excludeProjects: config.excludeProjects,
83
+ includeProjects: effectiveIncludeProjects,
84
+ }));
85
+ }
81
86
 
82
87
  if (records.length === 0) {
83
88
  return null;
@@ -93,7 +98,7 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
93
98
  }
94
99
 
95
100
  // 其余逻辑保持不变
96
- const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg);
101
+ const { filtered, start, end } = filterRecordsByPeriod(toolRecords, period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
97
102
  const usageStats = computeUsageStats(filtered, config.scenarioKeywords, config.costMode);
98
103
  const sessions = groupBySessions(filtered);
99
104
 
@@ -132,7 +137,7 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
132
137
  const trendData = computeTrendData(toolRecords, period, dateArg);
133
138
 
134
139
  // Previous period stats
135
- const prevRange = computePrevPeriodRange(period, dateArg);
140
+ const prevRange = computePrevPeriodRange(period, dateArg, { customStart: options.customStart, customEnd: options.customEnd });
136
141
  const prevFiltered = toolRecords.filter(r => {
137
142
  if (!r.timestamp) return false;
138
143
  const date = r.timestamp.slice(0, 10);
@@ -152,7 +157,71 @@ async function buildReportData(period, dateArg, config, effectiveIncludeProjects
152
157
  const billingBlocks = identifyBillingBlocks(filtered, config.blockQuota ? 5 : 5, config.costMode);
153
158
 
154
159
  const reposConfigured = !!(config.repos && config.repos.length > 0);
155
- return { usageStats, gitStats, reposConfigured, sessions: slimSessions, start, end, trendData, prevStats, billingBlocks, toolBreakdown };
160
+
161
+ // 合并 toolBreakdown:usageStats 内含 token 粒度数据,parsers 提供 sessionCount
162
+ const statsTB = usageStats.toolBreakdown || {};
163
+ const mergedBreakdown = {};
164
+ // 以 parsers toolBreakdown 为基础(包含所有已启用工具),补充 stats 中的 token 数据
165
+ for (const [name, base] of Object.entries(toolBreakdown)) {
166
+ const s = statsTB[name] || {};
167
+ mergedBreakdown[name] = {
168
+ inputTokens: s.inputTokens || 0,
169
+ outputTokens: s.outputTokens || 0,
170
+ cacheRead: s.cacheRead || 0,
171
+ cacheCreate: s.cacheCreate || 0,
172
+ count: s.count || 0,
173
+ sessionCount: base.sessionCount || 0,
174
+ };
175
+ }
176
+ // 补充 stats 中有但 parsers 无的极端情况
177
+ for (const [name, data] of Object.entries(statsTB)) {
178
+ if (!mergedBreakdown[name]) {
179
+ mergedBreakdown[name] = {
180
+ inputTokens: data.inputTokens || 0,
181
+ outputTokens: data.outputTokens || 0,
182
+ cacheRead: data.cacheRead || 0,
183
+ cacheCreate: data.cacheCreate || 0,
184
+ count: data.count || 0,
185
+ sessionCount: 0,
186
+ };
187
+ }
188
+ }
189
+
190
+ // Per-project details
191
+ const projectDetails = {};
192
+ const projEntries = Object.entries(usageStats.projects || {}).sort((a, b) => b[1].requests - a[1].requests);
193
+ if (reposConfigured && gitStats) {
194
+ const repoMap = await getPerRepoGitStats(
195
+ config.repos.filter(r => projEntries.some(([name]) => {
196
+ const base = r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop();
197
+ return base === name;
198
+ })),
199
+ start, end + 'T23:59:59'
200
+ );
201
+ for (const [projName, projStats] of projEntries) {
202
+ const matchedRepo = [...repoMap.keys()].find(r => r.replace(/\\/g, '/').replace(/\/$/, '').split('/').pop() === projName);
203
+ const repoGit = matchedRepo ? repoMap.get(matchedRepo) : null;
204
+ const topCommits = (repoGit?.commitList || [])
205
+ .filter(c => c.type === 'feat' || c.type === 'fix')
206
+ .slice(0, 5)
207
+ .map(c => ({ type: c.type, subject: c.subject, scope: c.scope }));
208
+ projectDetails[projName] = {
209
+ usage: projStats,
210
+ git: repoGit ? {
211
+ commits: repoGit.commits, linesAdded: repoGit.linesAdded, linesDeleted: repoGit.linesDeleted,
212
+ filesChanged: repoGit.filesChanged,
213
+ fileHotspots: (repoGit.fileHotspots || []).slice(0, 5),
214
+ } : null,
215
+ topCommits,
216
+ };
217
+ }
218
+ } else {
219
+ for (const [projName, projStats] of projEntries) {
220
+ projectDetails[projName] = { usage: projStats, git: null, topCommits: [] };
221
+ }
222
+ }
223
+
224
+ return { usageStats, gitStats, reposConfigured, sessions: slimSessions, start, end, trendData, prevStats, billingBlocks, toolBreakdown: mergedBreakdown, projectDetails };
156
225
  }
157
226
 
158
227
  if (!command || command === 'help' || command === '--help') {
package/lib/aggregate.js CHANGED
@@ -250,11 +250,12 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
250
250
 
251
251
  // Model
252
252
  if (model) {
253
- if (!stats.models[model]) stats.models[model] = { count: 0, outputTokens: 0, inputTokens: 0, cacheRead: 0, cost: 0, costMode: 'unknown' };
253
+ if (!stats.models[model]) stats.models[model] = { count: 0, outputTokens: 0, inputTokens: 0, cacheRead: 0, cacheCreate: 0, cost: 0, costMode: 'unknown' };
254
254
  stats.models[model].count++;
255
255
  stats.models[model].outputTokens += outputTokens;
256
256
  stats.models[model].inputTokens += inputTokens;
257
257
  stats.models[model].cacheRead += cacheRead;
258
+ stats.models[model].cacheCreate += cacheCreate;
258
259
  }
259
260
  }
260
261
 
@@ -275,10 +276,23 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
275
276
  const projectName = getProjectBaseName(r.project);
276
277
  if (projectName) {
277
278
  if (!stats.projects[projectName]) {
278
- stats.projects[projectName] = { sessions: new Set(), requests: 0 };
279
+ stats.projects[projectName] = { sessions: new Set(), requests: 0, inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheCreate: 0, estimatedCost: 0, models: {} };
279
280
  }
280
281
  if (isAssistant) {
281
282
  stats.projects[projectName].requests++;
283
+ stats.projects[projectName].inputTokens += inputTokens;
284
+ stats.projects[projectName].outputTokens += outputTokens;
285
+ stats.projects[projectName].cacheRead += cacheRead;
286
+ stats.projects[projectName].cacheCreate += cacheCreate;
287
+ const pricing = model ? resolveModelPricing(model) : { unknown: true };
288
+ const recCost = (r.costUSD != null && r.costUSD > 0) ? r.costUSD : calculateRecordCost(r, pricing);
289
+ stats.projects[projectName].estimatedCost += recCost;
290
+ if (model) {
291
+ if (!stats.projects[projectName].models[model]) stats.projects[projectName].models[model] = { count: 0, inputTokens: 0, outputTokens: 0 };
292
+ stats.projects[projectName].models[model].count++;
293
+ stats.projects[projectName].models[model].inputTokens += inputTokens;
294
+ stats.projects[projectName].models[model].outputTokens += outputTokens;
295
+ }
282
296
  }
283
297
  if (r.sessionId) {
284
298
  stats.projects[projectName].sessions.add(r.sessionId);
@@ -373,16 +387,23 @@ export function computeUsageStats(records, scenarioKeywords, costMode = 'auto')
373
387
 
374
388
  // Convert project session Sets to sizes
375
389
  for (const proj of Object.keys(stats.projects)) {
390
+ const p = stats.projects[proj];
376
391
  stats.projects[proj] = {
377
- sessions: stats.projects[proj].sessions.size,
378
- requests: stats.projects[proj].requests,
392
+ sessions: p.sessions.size,
393
+ requests: p.requests,
394
+ inputTokens: p.inputTokens || 0,
395
+ outputTokens: p.outputTokens || 0,
396
+ cacheRead: p.cacheRead || 0,
397
+ cacheCreate: p.cacheCreate || 0,
398
+ estimatedCost: p.estimatedCost || 0,
399
+ models: p.models || {},
379
400
  };
380
401
  }
381
402
 
382
403
  return stats;
383
404
  }
384
405
 
385
- export function filterRecordsByPeriod(records, period, refDate) {
406
+ export function filterRecordsByPeriod(records, period, refDate, options = {}) {
386
407
  const d = new Date(refDate);
387
408
  let start, end;
388
409
 
@@ -406,6 +427,10 @@ export function filterRecordsByPeriod(records, period, refDate) {
406
427
  start = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
407
428
  end = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate()).padStart(2, '0')}`;
408
429
  break;
430
+ case 'custom':
431
+ start = options.customStart || formatDate(d);
432
+ end = options.customEnd || start;
433
+ break;
409
434
  default:
410
435
  start = formatDate(d);
411
436
  end = start;
@@ -554,7 +579,7 @@ export function groupBySessions(records) {
554
579
  }).sort((a, b) => b.endTime.localeCompare(a.endTime));
555
580
  }
556
581
 
557
- export function computePrevPeriodRange(period, refDate) {
582
+ export function computePrevPeriodRange(period, refDate, options = {}) {
558
583
  const d = new Date(refDate);
559
584
  switch (period) {
560
585
  case 'daily':
@@ -574,6 +599,15 @@ export function computePrevPeriodRange(period, refDate) {
574
599
  const y = d.getFullYear(), m = d.getMonth();
575
600
  return { start: `${y}-${String(m + 1).padStart(2, '0')}-01`, end: `${y}-${String(m + 1).padStart(2, '0')}-${String(new Date(y, m + 1, 0).getDate()).padStart(2, '0')}` };
576
601
  }
602
+ case 'custom': {
603
+ const cs = options.customStart || formatDate(d);
604
+ const ce = options.customEnd || cs;
605
+ const spanMs = new Date(ce) - new Date(cs) + 86400000; // inclusive days
606
+ const gap = 86400000; // 1 day gap
607
+ const prevEnd = new Date(new Date(cs).getTime() - gap);
608
+ const prevStart = new Date(prevEnd.getTime() - spanMs + 86400000);
609
+ return { start: formatDate(prevStart), end: formatDate(prevEnd) };
610
+ }
577
611
  default:
578
612
  d.setDate(d.getDate() - 1);
579
613
  return { start: formatDate(d), end: formatDate(d) };
package/lib/cache.js CHANGED
@@ -2,6 +2,7 @@ import { statSync } from 'fs';
2
2
  import { parseJsonlFile } from './parser.js';
3
3
 
4
4
  const fileCache = new Map();
5
+ const CACHE_MAX_FILES = 200;
5
6
 
6
7
  export function getCachedFileRecords(filePath) {
7
8
  const { mtimeMs } = statSync(filePath);
@@ -10,6 +11,13 @@ export function getCachedFileRecords(filePath) {
10
11
 
11
12
  const records = parseJsonlFile(filePath);
12
13
  fileCache.set(filePath, { mtime: mtimeMs, records });
14
+
15
+ // LRU eviction
16
+ while (fileCache.size > CACHE_MAX_FILES) {
17
+ const oldest = fileCache.keys().next().value;
18
+ fileCache.delete(oldest);
19
+ }
20
+
13
21
  return records;
14
22
  }
15
23
 
package/lib/git.js CHANGED
@@ -501,12 +501,19 @@ export function parseGitLogOutput(output, repo = '') {
501
501
  return result;
502
502
  }
503
503
 
504
+ function sanitizeArg(s) {
505
+ // 移除 shell 特殊字符,防止命令注入
506
+ return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
507
+ }
508
+
504
509
  function buildGitArgs(since, until, author) {
505
510
  const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
506
- const authorArg = author ? ` --author="${author}"` : '';
511
+ const safeSince = sanitizeArg(sinceFull);
512
+ const safeUntil = sanitizeArg(until);
513
+ const authorArg = author ? ` --author="${sanitizeArg(author)}"` : '';
507
514
  // 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
508
515
  const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
509
- return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${sinceFull}" --until="${until}"${authorArg}`;
516
+ return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"${authorArg}`;
510
517
  }
511
518
 
512
519
  function mergeGitStats(target, source) {
@@ -576,6 +583,21 @@ export async function getGitStatsForMultipleReposAsync(repos, since, until) {
576
583
  return merged;
577
584
  }
578
585
 
586
+ // Per-repo git stats (unmerged), returns Map<repoPath, stats>
587
+ export async function getPerRepoGitStats(repos, since, until) {
588
+ const results = await Promise.all(
589
+ repos.map(async repo => {
590
+ try {
591
+ const stats = await getGitStatsAsync(repo, since, until, getGitAuthor(repo));
592
+ return [repo, stats];
593
+ } catch {
594
+ return [repo, emptyResult()];
595
+ }
596
+ })
597
+ );
598
+ return new Map(results);
599
+ }
600
+
579
601
  export function invalidateGitCache() {
580
602
  gitCache.clear();
581
603
  }
package/lib/report.js CHANGED
@@ -1146,16 +1146,17 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
1146
1146
  const opts = typeof options === 'string'
1147
1147
  ? { level: 'detailed', platform: options }
1148
1148
  : { level: 'detailed', platform: 'default', ...options };
1149
- const { level, platform: fmt, tool } = opts;
1150
- const titlePrefix = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
1149
+ const { level, platform: fmt, tool, projectName } = opts;
1150
+ const toolLabel = tool && tool !== 'all' ? toolTitle(tool) : 'AI 编码助手';
1151
+ const titlePrefix = projectName ? `${projectName} · ${toolLabel}` : toolLabel;
1151
1152
 
1152
1153
  // 简报路由
1153
1154
  if (level === 'brief') {
1154
1155
  return generateBriefReport(usageData, gitData, period, startDate, endDate, prevData, fmt, tool);
1155
1156
  }
1156
1157
  const lines = [];
1157
- const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : '月报';
1158
- const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'weekly' ? `${startDate} ~ ${endDate}` : startDate;
1158
+ const periodLabel = period === 'daily' ? '日报' : period === 'weekly' ? '周报' : period === 'monthly' ? '月报' : '自定义';
1159
+ const dateLabel = period === 'monthly' ? startDate.slice(0, 7) : period === 'daily' ? startDate : `${startDate} ~ ${endDate}`;
1159
1160
 
1160
1161
  lines.push(`# ${titlePrefix} 工作${periodLabel} - ${dateLabel}`);
1161
1162
  lines.push('');
@@ -1291,18 +1292,17 @@ export function generateWorkReport(usageData, gitData, period, startDate, endDat
1291
1292
  const aiLinePct = Math.round(((gitData.aiContribution?.aiLineRatio ?? gitData.aiContribution?.aiRatio) || 0) * 100);
1292
1293
  sectionLines.push(`- 高/中置信 AI 提交 **${totalAI}/${totalCommits}**(${aiLinePct}%),涉及 +${formatInt(aiDetail.totalAIFileAdded)}/-${formatInt(aiDetail.totalAIFileDeleted)} 行`);
1293
1294
 
1294
- if (aiDetail.explicit.length > 0) {
1295
- sectionLines.push(`- **显式 AI**(${aiDetail.explicit.length} 项):${aiDetail.explicit.map(s => `\`${s}\``).join('、')}`);
1296
- }
1297
- if (aiDetail.sessionStrong.length > 0) {
1298
- sectionLines.push(`- **强关联**(${aiDetail.sessionStrong.length} 项):${aiDetail.sessionStrong.map(s => `\`${s}\``).join('、')}`);
1299
- }
1300
- if (aiDetail.fileOverlap.length > 0) {
1301
- sectionLines.push(`- **文件重叠**(${aiDetail.fileOverlap.length} 项):${aiDetail.fileOverlap.map(s => `\`${s}\``).join('、')}`);
1295
+ // 汇总统计(不列出具体 commit subject,工作汇报中无阅读价值)
1296
+ const parts = [];
1297
+ if (aiDetail.explicit.length > 0) parts.push(`显式标记 ${aiDetail.explicit.length} 项`);
1298
+ if (aiDetail.sessionStrong.length > 0) parts.push(`会话强关联 ${aiDetail.sessionStrong.length} 项`);
1299
+ if (aiDetail.fileOverlap.length > 0) parts.push(`文件重叠 ${aiDetail.fileOverlap.length} 项`);
1300
+ if (parts.length > 0) {
1301
+ sectionLines.push(`- 归因方式:${parts.join('、')}`);
1302
1302
  }
1303
1303
  if (aiDetail.aiFiles.length > 0) {
1304
- const topFiles = aiDetail.aiFiles.slice(0, 8).map(f => `\`${f}\``).join('、');
1305
- const overflow = aiDetail.aiFiles.length > 8 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
1304
+ const topFiles = aiDetail.aiFiles.slice(0, 5).join('、');
1305
+ const overflow = aiDetail.aiFiles.length > 5 ? ` 等 ${aiDetail.aiFiles.length} 个` : '';
1306
1306
  sectionLines.push(`- **AI 涉及文件**:${topFiles}${overflow}`);
1307
1307
  }
1308
1308
  sectionLines.push('');