lumencode 1.3.2 → 1.3.4
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 +12 -0
- package/lib/attribution.js +8 -0
- package/lib/git.js +195 -192
- package/lib/report.js +5 -1
- package/lib/server.js +361 -29
- package/lib/smart-report-store.js +98 -0
- package/lib/smart-report.js +339 -0
- package/package.json +1 -1
- package/public/api.js +25 -0
- package/public/app.js +1483 -1167
- package/public/config.js +4 -0
- package/public/index.html +86 -3
- package/public/style.css +202 -0
package/README.md
CHANGED
|
@@ -66,6 +66,7 @@ npx lumencode serve
|
|
|
66
66
|
| 🎯 **行级 AI 归因** | 通过 hook 步骤追踪,精确识别每一行代码的 AI 参与度。不是"这个提交 AI 帮忙了",而是"这行代码是 AI 写的" |
|
|
67
67
|
| 🌐 **三工具统一** | Claude Code / Codex / OpenCode 数据全自动汇总,左侧标签一键切换 |
|
|
68
68
|
| 📝 **自然语言工作汇报** | 详报/简报一键生成,支持标准 Markdown / 飞书 / 钉钉三种格式,每个板块附诊断解读 |
|
|
69
|
+
| 🤖 **智能报告生成** | 连接本地 OpenCode CLI,对受限统计上下文和原始工作汇报做 AI 分析,支持默认风格与面向领导汇报的「牛马」风格 |
|
|
69
70
|
| 📂 **按项目独立汇报** | 右侧面板选择项目,生成该项目的独立工作汇报(commits + AI 交互量 + 热点文件) |
|
|
70
71
|
| 📅 **自定义时间范围** | 除日/周/月外,支持选择任意起止日期,方便对齐 Sprint 周期 |
|
|
71
72
|
| 💰 **精确费用估算** | 600+ 模型本地定价(含 GLM/Kimi/Qwen/DeepSeek)+ Portkey API 兜底,未知模型不计费而非乱算 |
|
|
@@ -119,6 +120,9 @@ npx lumencode serve
|
|
|
119
120
|
|
|
120
121
|
- **详报** —— 完整数据 + 洞察解读 + 板块编号,适合周报、月报
|
|
121
122
|
- **简报** —— 3-5 句话核心摘要,适合日报或群消息
|
|
123
|
+
- **智能报告** —— 页面内调用本地 OpenCode CLI 生成 AI 分析报告,补充数据摘要、工作亮点分析、关键洞察、风险与建议
|
|
124
|
+
- **风格选择** —— 生成前可选择默认风格,或「牛马」风格输出更适合向领导汇报的表达倾向
|
|
125
|
+
- **持久化与更新提醒** —— 智能报告会按周期、项目、报告层级和风格保存;统计数据变化后提示重新生成
|
|
122
126
|
- **多平台格式** —— 标准 Markdown / 飞书 / 钉钉,一键切换
|
|
123
127
|
- **按项目生成** —— 右侧面板选择项目,生成该项目的独立汇报
|
|
124
128
|
|
|
@@ -250,6 +254,14 @@ node index.js hooks disable
|
|
|
250
254
|
|
|
251
255
|
## 更新日志
|
|
252
256
|
|
|
257
|
+
### v1.3.4 (2026-06-05) — 智能报告风格与工作亮点分析
|
|
258
|
+
|
|
259
|
+
- **智能报告风格选择** — 生成智能报告前弹窗选择「默认风格」或「牛马」风格,后者面向领导汇报口径,突出投入、产出、风险兜底和下一步计划
|
|
260
|
+
- **工作亮点分析** — AI 智能报告新增「工作亮点分析」章节要求,让报告不止复述统计数据,而是提炼可汇报的亮点与依据
|
|
261
|
+
- **Boss 汇报迁移** — 移除普通工作汇报里的「汇报 Boss」层级,将其能力迁移为智能报告的一种风格,减少普通报告入口复杂度
|
|
262
|
+
- **报告持久化增强** — 智能报告按风格独立保存,默认风格兼容旧记录,刷新或切换页面后不会丢失
|
|
263
|
+
- **后台生成体验** — 智能报告改为后台任务生成,支持刷新后恢复进度,并用渐进进度条降低长时间等待的不确定感
|
|
264
|
+
|
|
253
265
|
### v1.3.0 (2026-05-28) — 行级 AI 归因 & 交互式 Hooks 管理
|
|
254
266
|
|
|
255
267
|
- **行级 AI 归因** — 通过 hook 步骤追踪系统,将归因粒度从提交级细化到行级,精确识别每一行代码的 AI 参与度
|
package/lib/attribution.js
CHANGED
|
@@ -99,6 +99,8 @@ export function aggregateAttribution(items = []) {
|
|
|
99
99
|
unknown: 0,
|
|
100
100
|
human: 0,
|
|
101
101
|
excluded: 0,
|
|
102
|
+
mergeCommits: 0,
|
|
103
|
+
mergeCommitLines: 0,
|
|
102
104
|
confirmedAILines: 0,
|
|
103
105
|
probableAILines: 0,
|
|
104
106
|
possibleAILines: 0,
|
|
@@ -112,6 +114,12 @@ export function aggregateAttribution(items = []) {
|
|
|
112
114
|
|
|
113
115
|
for (const item of items || []) {
|
|
114
116
|
const classified = item?.classification ? item : classifyAttribution(item);
|
|
117
|
+
// 合并提交不计入统计,避免稀释 AI 占比
|
|
118
|
+
if (item?.reason === 'human_merge') {
|
|
119
|
+
summary.mergeCommits = (summary.mergeCommits || 0) + 1;
|
|
120
|
+
summary.mergeCommitLines = (summary.mergeCommitLines || 0) + (item?.added || item?.linesAdded || 0) + (item?.deleted || item?.linesDeleted || 0);
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
115
123
|
const lines = (item?.added || item?.linesAdded || 0) + (item?.deleted || item?.linesDeleted || 0);
|
|
116
124
|
summary.totalItems++;
|
|
117
125
|
summary.totalLinesChanged += lines;
|
package/lib/git.js
CHANGED
|
@@ -550,7 +550,10 @@ export function computeAIContribution(commits, toolFilter = null, options = {})
|
|
|
550
550
|
const attributionOptions = resolveAttributionOptions(options.attribution || options);
|
|
551
551
|
const confidenceWeights = attributionOptions.confidenceWeights;
|
|
552
552
|
const allCommits = commits || [];
|
|
553
|
+
const isMergeCommit = c => c.attributionType === 'human_merge';
|
|
553
554
|
for (const c of allCommits) {
|
|
555
|
+
// 合并提交不计入总行数分母,避免稀释 AI 占比
|
|
556
|
+
if (isMergeCommit(c)) continue;
|
|
554
557
|
totalLinesAdded += c.linesAdded || 0;
|
|
555
558
|
totalLinesDeleted += c.linesDeleted || 0;
|
|
556
559
|
}
|
|
@@ -807,14 +810,14 @@ function sanitizeArg(s) {
|
|
|
807
810
|
return String(s || '').replace(/[`$"\\|;&<>!\n\r]/g, '');
|
|
808
811
|
}
|
|
809
812
|
|
|
810
|
-
function buildGitArgs(since, until) {
|
|
811
|
-
const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
|
|
812
|
-
const safeSince = sanitizeArg(sinceFull);
|
|
813
|
-
const safeUntil = sanitizeArg(until);
|
|
814
|
-
// 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
|
|
815
|
-
const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
|
|
816
|
-
return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"`;
|
|
817
|
-
}
|
|
813
|
+
function buildGitArgs(since, until) {
|
|
814
|
+
const sinceFull = since.includes('T') ? since : since + 'T00:00:00';
|
|
815
|
+
const safeSince = sanitizeArg(sinceFull);
|
|
816
|
+
const safeUntil = sanitizeArg(until);
|
|
817
|
+
// 格式:哨兵行(subject) → body 行(可多行) → ENDBODY 行 → numstat 行
|
|
818
|
+
const pretty = `--pretty=format:"${COMMIT_SENTINEL}%H|%ad|%ae|%s%n%B${BODY_END}"`;
|
|
819
|
+
return `--all --no-renames ${pretty} --date=iso-strict --numstat --since="${safeSince}" --until="${safeUntil}"`;
|
|
820
|
+
}
|
|
818
821
|
|
|
819
822
|
function mergeGitStats(target, source) {
|
|
820
823
|
target.commits += source.commits;
|
|
@@ -837,66 +840,66 @@ function mergeGitStats(target, source) {
|
|
|
837
840
|
// filesChanged 在 merge 完后由 finalize 重新计算(跨 repo 去重)
|
|
838
841
|
}
|
|
839
842
|
|
|
840
|
-
function recomputeFilesChanged(stats) {
|
|
841
|
-
const set = new Set();
|
|
842
|
-
for (const c of stats.commitList || []) {
|
|
843
|
-
for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
|
|
844
|
-
}
|
|
845
|
-
stats.filesChanged = set.size;
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
function recomputeStatsFromCommitList(stats) {
|
|
849
|
-
stats.commits = 0;
|
|
850
|
-
stats.filesChanged = 0;
|
|
851
|
-
stats.linesAdded = 0;
|
|
852
|
-
stats.linesDeleted = 0;
|
|
853
|
-
stats.commitsByDate = {};
|
|
854
|
-
stats.linesByDate = {};
|
|
855
|
-
|
|
856
|
-
for (const c of stats.commitList || []) {
|
|
857
|
-
const dateKey = c.dateLocal || (c.date || '').slice(0, 10);
|
|
858
|
-
stats.commits++;
|
|
859
|
-
stats.commitsByDate[dateKey] = (stats.commitsByDate[dateKey] || 0) + 1;
|
|
860
|
-
if (!stats.linesByDate[dateKey]) stats.linesByDate[dateKey] = { added: 0, deleted: 0, files: 0 };
|
|
861
|
-
stats.linesByDate[dateKey].added += c.linesAdded || 0;
|
|
862
|
-
stats.linesByDate[dateKey].deleted += c.linesDeleted || 0;
|
|
863
|
-
stats.linesByDate[dateKey].files += (c.files || []).length;
|
|
864
|
-
stats.linesAdded += c.linesAdded || 0;
|
|
865
|
-
stats.linesDeleted += c.linesDeleted || 0;
|
|
866
|
-
}
|
|
867
|
-
recomputeFilesChanged(stats);
|
|
868
|
-
}
|
|
869
|
-
|
|
870
|
-
function markAuthorOwnership(stats, expectedAuthor) {
|
|
871
|
-
const normalizedExpected = (expectedAuthor || '').toLowerCase();
|
|
872
|
-
for (const c of stats.commitList || []) {
|
|
873
|
-
c.expectedAuthor = expectedAuthor || null;
|
|
874
|
-
c.authorMatchesConfig = normalizedExpected
|
|
875
|
-
? (c.author || '').toLowerCase() === normalizedExpected
|
|
876
|
-
: null;
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
function hasLocalSessionEvidence(commit) {
|
|
881
|
-
if (!commit.sessionId) return false;
|
|
882
|
-
if (commit.sessionAttribution === 'strong') return true;
|
|
883
|
-
return (commit.aiEvidenceDetails?.matchedFileCount || 0) > 0;
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
function filterCommitsForUser(stats) {
|
|
887
|
-
const commits = stats.commitList || [];
|
|
888
|
-
const hasAuthorOwnershipMetadata = commits.some(c => c.expectedAuthor || c.authorMatchesConfig !== undefined);
|
|
889
|
-
|
|
890
|
-
for (const c of commits) {
|
|
891
|
-
c.countedForUser = !hasAuthorOwnershipMetadata
|
|
892
|
-
|| c.authorMatchesConfig === true
|
|
893
|
-
|| hasLocalSessionEvidence(c);
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
if (!hasAuthorOwnershipMetadata) return;
|
|
897
|
-
stats.commitList = commits.filter(c => c.countedForUser);
|
|
898
|
-
recomputeStatsFromCommitList(stats);
|
|
899
|
-
}
|
|
843
|
+
function recomputeFilesChanged(stats) {
|
|
844
|
+
const set = new Set();
|
|
845
|
+
for (const c of stats.commitList || []) {
|
|
846
|
+
for (const f of c.files || []) set.add((c.repo || '') + '::' + f.path);
|
|
847
|
+
}
|
|
848
|
+
stats.filesChanged = set.size;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
function recomputeStatsFromCommitList(stats) {
|
|
852
|
+
stats.commits = 0;
|
|
853
|
+
stats.filesChanged = 0;
|
|
854
|
+
stats.linesAdded = 0;
|
|
855
|
+
stats.linesDeleted = 0;
|
|
856
|
+
stats.commitsByDate = {};
|
|
857
|
+
stats.linesByDate = {};
|
|
858
|
+
|
|
859
|
+
for (const c of stats.commitList || []) {
|
|
860
|
+
const dateKey = c.dateLocal || (c.date || '').slice(0, 10);
|
|
861
|
+
stats.commits++;
|
|
862
|
+
stats.commitsByDate[dateKey] = (stats.commitsByDate[dateKey] || 0) + 1;
|
|
863
|
+
if (!stats.linesByDate[dateKey]) stats.linesByDate[dateKey] = { added: 0, deleted: 0, files: 0 };
|
|
864
|
+
stats.linesByDate[dateKey].added += c.linesAdded || 0;
|
|
865
|
+
stats.linesByDate[dateKey].deleted += c.linesDeleted || 0;
|
|
866
|
+
stats.linesByDate[dateKey].files += (c.files || []).length;
|
|
867
|
+
stats.linesAdded += c.linesAdded || 0;
|
|
868
|
+
stats.linesDeleted += c.linesDeleted || 0;
|
|
869
|
+
}
|
|
870
|
+
recomputeFilesChanged(stats);
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
function markAuthorOwnership(stats, expectedAuthor) {
|
|
874
|
+
const normalizedExpected = (expectedAuthor || '').toLowerCase();
|
|
875
|
+
for (const c of stats.commitList || []) {
|
|
876
|
+
c.expectedAuthor = expectedAuthor || null;
|
|
877
|
+
c.authorMatchesConfig = normalizedExpected
|
|
878
|
+
? (c.author || '').toLowerCase() === normalizedExpected
|
|
879
|
+
: null;
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function hasLocalSessionEvidence(commit) {
|
|
884
|
+
if (!commit.sessionId) return false;
|
|
885
|
+
if (commit.sessionAttribution === 'strong') return true;
|
|
886
|
+
return (commit.aiEvidenceDetails?.matchedFileCount || 0) > 0;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
function filterCommitsForUser(stats) {
|
|
890
|
+
const commits = stats.commitList || [];
|
|
891
|
+
const hasAuthorOwnershipMetadata = commits.some(c => c.expectedAuthor || c.authorMatchesConfig !== undefined);
|
|
892
|
+
|
|
893
|
+
for (const c of commits) {
|
|
894
|
+
c.countedForUser = !hasAuthorOwnershipMetadata
|
|
895
|
+
|| c.authorMatchesConfig === true
|
|
896
|
+
|| hasLocalSessionEvidence(c);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
if (!hasAuthorOwnershipMetadata) return;
|
|
900
|
+
stats.commitList = commits.filter(c => c.countedForUser);
|
|
901
|
+
recomputeStatsFromCommitList(stats);
|
|
902
|
+
}
|
|
900
903
|
|
|
901
904
|
// ── async versions (server) with cache ──
|
|
902
905
|
|
|
@@ -916,26 +919,26 @@ function evictGitCache() {
|
|
|
916
919
|
}
|
|
917
920
|
}
|
|
918
921
|
|
|
919
|
-
async function getGitStatsAsync(repoPath, since, until, author = null) {
|
|
920
|
-
const cacheKey = `${repoPath}|${since}|${until}|${CACHE_VERSION}`;
|
|
921
|
-
const cached = gitCache.get(cacheKey);
|
|
922
|
-
if (cached && Date.now() - cached.ts < GIT_CACHE_TTL) return cached.stats;
|
|
922
|
+
async function getGitStatsAsync(repoPath, since, until, author = null) {
|
|
923
|
+
const cacheKey = `${repoPath}|${since}|${until}|${CACHE_VERSION}`;
|
|
924
|
+
const cached = gitCache.get(cacheKey);
|
|
925
|
+
if (cached && Date.now() - cached.ts < GIT_CACHE_TTL) return cached.stats;
|
|
923
926
|
|
|
924
927
|
try {
|
|
925
928
|
await execAsync('git rev-parse --git-dir', { cwd: repoPath });
|
|
926
929
|
} catch {
|
|
927
930
|
return emptyResult();
|
|
928
931
|
}
|
|
929
|
-
|
|
930
|
-
try {
|
|
931
|
-
const output = await execAsync(`git log ${buildGitArgs(since, until)}`, {
|
|
932
|
-
cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
|
|
933
|
-
});
|
|
934
|
-
const stats = parseGitLogOutput(output, repoPath);
|
|
935
|
-
markAuthorOwnership(stats, author);
|
|
936
|
-
gitCache.set(cacheKey, { stats, ts: Date.now() });
|
|
937
|
-
evictGitCache();
|
|
938
|
-
return stats;
|
|
932
|
+
|
|
933
|
+
try {
|
|
934
|
+
const output = await execAsync(`git log ${buildGitArgs(since, until)}`, {
|
|
935
|
+
cwd: repoPath, encoding: 'utf-8', maxBuffer: 50 * 1024 * 1024,
|
|
936
|
+
});
|
|
937
|
+
const stats = parseGitLogOutput(output, repoPath);
|
|
938
|
+
markAuthorOwnership(stats, author);
|
|
939
|
+
gitCache.set(cacheKey, { stats, ts: Date.now() });
|
|
940
|
+
evictGitCache();
|
|
941
|
+
return stats;
|
|
939
942
|
} catch {
|
|
940
943
|
return emptyResult();
|
|
941
944
|
}
|
|
@@ -1131,38 +1134,38 @@ function sortAttributionCandidates(candidates) {
|
|
|
1131
1134
|
});
|
|
1132
1135
|
}
|
|
1133
1136
|
|
|
1134
|
-
function candidateFromSession(commit, session, distanceMs) {
|
|
1135
|
-
const overlap = computeFileOverlap(session.touchedFiles || [], commit.files || []);
|
|
1136
|
-
return scoreSessionCandidate(commit, session, {
|
|
1137
|
-
distanceMs,
|
|
1137
|
+
function candidateFromSession(commit, session, distanceMs) {
|
|
1138
|
+
const overlap = computeFileOverlap(session.touchedFiles || [], commit.files || []);
|
|
1139
|
+
return scoreSessionCandidate(commit, session, {
|
|
1140
|
+
distanceMs,
|
|
1138
1141
|
fileOverlapRatio: overlap.fileOverlapRatio,
|
|
1139
1142
|
matchedFiles: overlap.matchedFiles,
|
|
1140
1143
|
projectMatches: true,
|
|
1141
|
-
});
|
|
1142
|
-
}
|
|
1143
|
-
|
|
1144
|
-
function getStepSessionIdCandidates(sessionId, session) {
|
|
1145
|
-
if (!sessionId) return [];
|
|
1146
|
-
const candidates = [sessionId];
|
|
1147
|
-
if (sessionId.includes(':')) return candidates;
|
|
1148
|
-
|
|
1149
|
-
const originByTool = {
|
|
1150
|
-
claude: 'claude_code',
|
|
1151
|
-
codex: 'codex_cli',
|
|
1152
|
-
};
|
|
1153
|
-
const origin = originByTool[session?.primaryTool];
|
|
1154
|
-
if (origin) {
|
|
1155
|
-
candidates.push(`${origin}:${sessionId}`);
|
|
1156
|
-
} else {
|
|
1157
|
-
candidates.push(`claude_code:${sessionId}`, `codex_cli:${sessionId}`);
|
|
1158
|
-
}
|
|
1159
|
-
|
|
1160
|
-
return [...new Set(candidates)];
|
|
1161
|
-
}
|
|
1162
|
-
|
|
1163
|
-
const BASH_GIT_COMMIT_RE = /\bgit\s+commit\b/i;
|
|
1164
|
-
const STRONG_WINDOW_BEFORE_MS = 30 * 1000; // 30s before bash invocation
|
|
1165
|
-
const STRONG_WINDOW_AFTER_MS = 5 * 60 * 1000; // 5min after
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
function getStepSessionIdCandidates(sessionId, session) {
|
|
1148
|
+
if (!sessionId) return [];
|
|
1149
|
+
const candidates = [sessionId];
|
|
1150
|
+
if (sessionId.includes(':')) return candidates;
|
|
1151
|
+
|
|
1152
|
+
const originByTool = {
|
|
1153
|
+
claude: 'claude_code',
|
|
1154
|
+
codex: 'codex_cli',
|
|
1155
|
+
};
|
|
1156
|
+
const origin = originByTool[session?.primaryTool];
|
|
1157
|
+
if (origin) {
|
|
1158
|
+
candidates.push(`${origin}:${sessionId}`);
|
|
1159
|
+
} else {
|
|
1160
|
+
candidates.push(`claude_code:${sessionId}`, `codex_cli:${sessionId}`);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
return [...new Set(candidates)];
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
const BASH_GIT_COMMIT_RE = /\bgit\s+commit\b/i;
|
|
1167
|
+
const STRONG_WINDOW_BEFORE_MS = 30 * 1000; // 30s before bash invocation
|
|
1168
|
+
const STRONG_WINDOW_AFTER_MS = 5 * 60 * 1000; // 5min after
|
|
1166
1169
|
|
|
1167
1170
|
function toMs(iso) {
|
|
1168
1171
|
if (!iso) return NaN;
|
|
@@ -1346,11 +1349,11 @@ export function attachCommitsToSessions(sessions, commitList) {
|
|
|
1346
1349
|
}
|
|
1347
1350
|
|
|
1348
1351
|
// 一次性收尾:跑 attribution + 三个聚合
|
|
1349
|
-
export async function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
1350
|
-
if (!merged) return merged;
|
|
1351
|
-
const attributionOptions = resolveAttributionOptions(options.attribution || options);
|
|
1352
|
-
const stepTrackingOptions = options.stepTracking || {};
|
|
1353
|
-
const fileOverrides = loadAttributionOverrides();
|
|
1352
|
+
export async function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
1353
|
+
if (!merged) return merged;
|
|
1354
|
+
const attributionOptions = resolveAttributionOptions(options.attribution || options);
|
|
1355
|
+
const stepTrackingOptions = options.stepTracking || {};
|
|
1356
|
+
const fileOverrides = loadAttributionOverrides();
|
|
1354
1357
|
const inputOverrides = options.overrides || {};
|
|
1355
1358
|
const mergedOverrides = {
|
|
1356
1359
|
commits: { ...fileOverrides.commits, ...(inputOverrides.commits || {}) },
|
|
@@ -1378,69 +1381,69 @@ export async function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1378
1381
|
}
|
|
1379
1382
|
}
|
|
1380
1383
|
|
|
1381
|
-
// Step 1.5: Enrich commits with line-level step blame when available
|
|
1382
|
-
const stepTrackers = new Map();
|
|
1383
|
-
if (stepTrackingOptions.enabled !== false) try {
|
|
1384
|
-
const { StepTracker } = await import('./step-tracker.js');
|
|
1385
|
-
const projectRoots = [...new Set((sessions || []).map(s => s.project).filter(Boolean))];
|
|
1386
|
-
// Also check repo paths from commits
|
|
1387
|
-
for (const c of merged.commitList || []) {
|
|
1388
|
-
if (c.repo) projectRoots.push(c.repo);
|
|
1389
|
-
}
|
|
1390
|
-
for (const root of [...new Set(projectRoots.map(normalizePathForGit))]) {
|
|
1391
|
-
if (!root) continue;
|
|
1392
|
-
const tracker = new StepTracker(root, {
|
|
1393
|
-
dbPath: stepTrackingOptions.dbPath,
|
|
1394
|
-
maxFileSize: stepTrackingOptions.maxFileSize,
|
|
1395
|
-
});
|
|
1396
|
-
if (await tracker.isAvailableAsync()) {
|
|
1397
|
-
await tracker.open();
|
|
1398
|
-
stepTrackers.set(root, tracker);
|
|
1399
|
-
}
|
|
1400
|
-
}
|
|
1401
|
-
} catch {
|
|
1402
|
-
for (const tracker of stepTrackers.values()) tracker.close();
|
|
1403
|
-
stepTrackers.clear();
|
|
1404
|
-
}
|
|
1405
|
-
|
|
1406
|
-
if (stepTrackers.size > 0) {
|
|
1407
|
-
for (const c of merged.commitList || []) {
|
|
1408
|
-
if (!c.sessionId) continue;
|
|
1409
|
-
const candidateRoots = [
|
|
1410
|
-
c.repo,
|
|
1411
|
-
sessionsById.get(c.sessionId)?.project,
|
|
1412
|
-
].filter(Boolean);
|
|
1413
|
-
let stepTracker = null;
|
|
1414
|
-
for (const candidateRoot of candidateRoots) {
|
|
1415
|
-
const normalizedCandidate = normalizePathForGit(candidateRoot);
|
|
1416
|
-
for (const [root, tracker] of stepTrackers.entries()) {
|
|
1417
|
-
if (projectMatchesFromGitPaths(root, normalizedCandidate)) {
|
|
1418
|
-
stepTracker = tracker;
|
|
1419
|
-
break;
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
if (stepTracker) break;
|
|
1423
|
-
}
|
|
1424
|
-
if (!stepTracker && stepTrackers.size === 1) {
|
|
1425
|
-
stepTracker = stepTrackers.values().next().value;
|
|
1426
|
-
}
|
|
1427
|
-
if (!stepTracker) continue;
|
|
1428
|
-
try {
|
|
1429
|
-
const session = sessionsById.get(c.sessionId);
|
|
1430
|
-
for (const stepSessionId of getStepSessionIdCandidates(c.sessionId, session)) {
|
|
1431
|
-
const lineBlame = stepTracker.getLineAttributionForCommit({
|
|
1432
|
-
...c,
|
|
1433
|
-
sessionId: stepSessionId,
|
|
1434
|
-
});
|
|
1435
|
-
if (lineBlame) {
|
|
1436
|
-
c.lineBlame = lineBlame;
|
|
1437
|
-
break;
|
|
1438
|
-
}
|
|
1439
|
-
}
|
|
1440
|
-
} catch { /* best effort */ }
|
|
1441
|
-
}
|
|
1442
|
-
for (const tracker of stepTrackers.values()) tracker.close();
|
|
1443
|
-
}
|
|
1384
|
+
// Step 1.5: Enrich commits with line-level step blame when available
|
|
1385
|
+
const stepTrackers = new Map();
|
|
1386
|
+
if (stepTrackingOptions.enabled !== false) try {
|
|
1387
|
+
const { StepTracker } = await import('./step-tracker.js');
|
|
1388
|
+
const projectRoots = [...new Set((sessions || []).map(s => s.project).filter(Boolean))];
|
|
1389
|
+
// Also check repo paths from commits
|
|
1390
|
+
for (const c of merged.commitList || []) {
|
|
1391
|
+
if (c.repo) projectRoots.push(c.repo);
|
|
1392
|
+
}
|
|
1393
|
+
for (const root of [...new Set(projectRoots.map(normalizePathForGit))]) {
|
|
1394
|
+
if (!root) continue;
|
|
1395
|
+
const tracker = new StepTracker(root, {
|
|
1396
|
+
dbPath: stepTrackingOptions.dbPath,
|
|
1397
|
+
maxFileSize: stepTrackingOptions.maxFileSize,
|
|
1398
|
+
});
|
|
1399
|
+
if (await tracker.isAvailableAsync()) {
|
|
1400
|
+
await tracker.open();
|
|
1401
|
+
stepTrackers.set(root, tracker);
|
|
1402
|
+
}
|
|
1403
|
+
}
|
|
1404
|
+
} catch {
|
|
1405
|
+
for (const tracker of stepTrackers.values()) tracker.close();
|
|
1406
|
+
stepTrackers.clear();
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
if (stepTrackers.size > 0) {
|
|
1410
|
+
for (const c of merged.commitList || []) {
|
|
1411
|
+
if (!c.sessionId) continue;
|
|
1412
|
+
const candidateRoots = [
|
|
1413
|
+
c.repo,
|
|
1414
|
+
sessionsById.get(c.sessionId)?.project,
|
|
1415
|
+
].filter(Boolean);
|
|
1416
|
+
let stepTracker = null;
|
|
1417
|
+
for (const candidateRoot of candidateRoots) {
|
|
1418
|
+
const normalizedCandidate = normalizePathForGit(candidateRoot);
|
|
1419
|
+
for (const [root, tracker] of stepTrackers.entries()) {
|
|
1420
|
+
if (projectMatchesFromGitPaths(root, normalizedCandidate)) {
|
|
1421
|
+
stepTracker = tracker;
|
|
1422
|
+
break;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
1425
|
+
if (stepTracker) break;
|
|
1426
|
+
}
|
|
1427
|
+
if (!stepTracker && stepTrackers.size === 1) {
|
|
1428
|
+
stepTracker = stepTrackers.values().next().value;
|
|
1429
|
+
}
|
|
1430
|
+
if (!stepTracker) continue;
|
|
1431
|
+
try {
|
|
1432
|
+
const session = sessionsById.get(c.sessionId);
|
|
1433
|
+
for (const stepSessionId of getStepSessionIdCandidates(c.sessionId, session)) {
|
|
1434
|
+
const lineBlame = stepTracker.getLineAttributionForCommit({
|
|
1435
|
+
...c,
|
|
1436
|
+
sessionId: stepSessionId,
|
|
1437
|
+
});
|
|
1438
|
+
if (lineBlame) {
|
|
1439
|
+
c.lineBlame = lineBlame;
|
|
1440
|
+
break;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
} catch { /* best effort */ }
|
|
1444
|
+
}
|
|
1445
|
+
for (const tracker of stepTrackers.values()) tracker.close();
|
|
1446
|
+
}
|
|
1444
1447
|
|
|
1445
1448
|
// Step 1.6: compute developer behavioral baselines
|
|
1446
1449
|
const authorBaselines = computeAuthorBaseline(merged.commitList);
|
|
@@ -1548,21 +1551,21 @@ export async function finalizeGitStats(merged, sessions = [], options = {}) {
|
|
|
1548
1551
|
}
|
|
1549
1552
|
|
|
1550
1553
|
// Step 2.5: composite continuous scoring for all commits
|
|
1551
|
-
for (const c of merged.commitList || []) {
|
|
1552
|
-
c.aiScore = computeContinuousScore(c, attributionOptions);
|
|
1553
|
-
const mappedConfidence = scoreToConfidence(c.aiScore, attributionOptions);
|
|
1554
|
-
// Only override if no explicit signature and continuous score disagrees
|
|
1555
|
-
if (c.attributionType !== 'explicit') {
|
|
1554
|
+
for (const c of merged.commitList || []) {
|
|
1555
|
+
c.aiScore = computeContinuousScore(c, attributionOptions);
|
|
1556
|
+
const mappedConfidence = scoreToConfidence(c.aiScore, attributionOptions);
|
|
1557
|
+
// Only override if no explicit signature and continuous score disagrees
|
|
1558
|
+
if (c.attributionType !== 'explicit') {
|
|
1556
1559
|
c.aiConfidence = pickHigherConfidence(c.aiConfidence, mappedConfidence);
|
|
1557
1560
|
c.isAI = isCountedAIConfidence(c.aiConfidence);
|
|
1558
|
-
c.aiAssisted = c.aiConfidence !== AI_CONFIDENCE.NONE;
|
|
1559
|
-
}
|
|
1560
|
-
}
|
|
1561
|
-
|
|
1562
|
-
filterCommitsForUser(merged);
|
|
1563
|
-
|
|
1564
|
-
const attributionItems = [];
|
|
1565
|
-
for (const c of merged.commitList || []) {
|
|
1561
|
+
c.aiAssisted = c.aiConfidence !== AI_CONFIDENCE.NONE;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
filterCommitsForUser(merged);
|
|
1566
|
+
|
|
1567
|
+
const attributionItems = [];
|
|
1568
|
+
for (const c of merged.commitList || []) {
|
|
1566
1569
|
const commitOverride = mergedOverrides.commits[c.hash] || null;
|
|
1567
1570
|
const fileOverride = (c.files || []).find(f => mergedOverrides.files[`${c.hash}:${f.path}`]);
|
|
1568
1571
|
const fileOverrideValue = fileOverride ? mergedOverrides.files[`${c.hash}:${fileOverride.path}`] : null;
|
package/lib/report.js
CHANGED
|
@@ -167,7 +167,11 @@ function buildAttributionSummaryLine(summary) {
|
|
|
167
167
|
const confirmedPct = Math.round((summary.confirmedAILines / total) * 100);
|
|
168
168
|
const upperPct = Math.round(((summary.confirmedAILines + summary.probableAILines + summary.possibleAILines) / total) * 100);
|
|
169
169
|
const unknownPct = Math.round((summary.unknownLines / total) * 100);
|
|
170
|
-
|
|
170
|
+
let line = `AI 归因汇总:确认 AI ${confirmedPct}% / 可能 AI 上限 ${upperPct}% / 未知 ${unknownPct}%。`;
|
|
171
|
+
if (summary.mergeCommits > 0) {
|
|
172
|
+
line += `(已排除 ${summary.mergeCommits} 个合并提交,共 ${formatInt(summary.mergeCommitLines)} 行)`;
|
|
173
|
+
}
|
|
174
|
+
return line;
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
function buildUnknownReasonLine(summary) {
|