@hunterzheng/kld-sdd 2.4.19
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 +275 -0
- package/bin/kld-sdd-init.js +24 -0
- package/index.js +13 -0
- package/lib/init.js +1124 -0
- package/lib/skills-bundle.js +30 -0
- package/package.json +48 -0
- package/skywalk-sdd/apply-worktree-finish.cjs +551 -0
- package/skywalk-sdd/index.cjs +2991 -0
- package/templates/ci/github-actions-sdd.yml +67 -0
- package/templates/ci/gitlab-ci-sdd.yml +44 -0
- package/templates/git-hooks/pre-commit-sdd-check.cjs +155 -0
- package/templates/git-hooks/pre-push-sdd-check.cjs +56 -0
- package/templates/hooks/claude/hooks/sdd-apply-gate.cjs +173 -0
- package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +315 -0
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +146 -0
- package/templates/hooks/claude/hooks/sdd-pre-tool.cjs +41 -0
- package/templates/hooks/claude/hooks/sdd-prompt.cjs +88 -0
- package/templates/hooks/claude/hooks/sdd-skill-apply-gate.cjs +268 -0
- package/templates/hooks/claude/hooks/sdd-stop.cjs +108 -0
- package/templates/hooks/claude/settings.json +72 -0
- package/templates/openspec/design.md +290 -0
- package/templates/openspec/overview.md +143 -0
- package/templates/openspec/proposal.md +108 -0
- package/templates/openspec/spec.md +185 -0
- package/templates/openspec/tasks.md +287 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +251 -0
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
- package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +129 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +335 -0
- package/templates/skills/kld-sdd/opsx-apply/worktree-setup.md +104 -0
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +162 -0
- package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +197 -0
- package/templates/skills/kld-sdd/opsx-check/checklist.md +35 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +166 -0
- package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +104 -0
- package/templates/skills/kld-sdd/opsx-knowledge/SKILL.md +130 -0
- package/templates/skills/kld-sdd/opsx-knowledge/references/modules.md +26 -0
- package/templates/skills/kld-sdd/opsx-knowledge/scripts/config.json +39 -0
- package/templates/skills/kld-sdd/opsx-knowledge/scripts/retrieve.cjs +199 -0
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +201 -0
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +168 -0
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +199 -0
- package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +143 -0
|
@@ -0,0 +1,2991 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SDD Telemetry CLI
|
|
4
|
+
*
|
|
5
|
+
* 本地 CLI 工具,为 SDD 工作流提供精确的流程度量采集。
|
|
6
|
+
* AI Agent 通过终端命令调用,将事件写入项目本地 skywalk-sdd/ 目录。
|
|
7
|
+
*
|
|
8
|
+
* 用法:
|
|
9
|
+
* node skywalk-sdd/log.cjs start --command=propose --project=/path --change=xxx [--agent=cursor]
|
|
10
|
+
* node skywalk-sdd/log.cjs end --event-id=evt_xxx --result=success --summary="..."
|
|
11
|
+
* node skywalk-sdd/log.cjs metrics --project=/path [--change=xxx]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const crypto = require('crypto');
|
|
17
|
+
const { execFileSync } = require('child_process');
|
|
18
|
+
|
|
19
|
+
const SCHEMA_VERSION = 2;
|
|
20
|
+
|
|
21
|
+
// result → 状态标记映射(execution-log.md 人读标记;checklist/SKILL 文档同步说明这套映射)
|
|
22
|
+
const RESULT_MARK = { success: '✅OK', partial: '🟡WARN', failure: '❌FAIL' };
|
|
23
|
+
|
|
24
|
+
// 返工原因 5 类(事件状态 + report-time git 推导 + agent 兜底);agent --reason 枚举校验白名单
|
|
25
|
+
const REWORK_REASON_CATEGORIES = ['incomplete', 'prev-failed', 'code-changed-after-pass', 'recheck-no-change', 'unspecified'];
|
|
26
|
+
function createReasonCategory() {
|
|
27
|
+
return { incomplete: 0, 'prev-failed': 0, 'code-changed-after-pass': 0, 'recheck-no-change': 0, unspecified: 0 };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ── 配置 ──────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** 获取项目的 skywalk-sdd 数据目录 */
|
|
33
|
+
function getDataDir(projectRoot) {
|
|
34
|
+
return path.join(projectRoot, 'skywalk-sdd');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function getStateDir(projectRoot) {
|
|
38
|
+
return path.join(getDataDir(projectRoot), 'state');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// SDD 标准阶段顺序
|
|
42
|
+
const STAGE_ORDER = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'test', 'archive', 'explore'];
|
|
43
|
+
const CORE_STAGES = ['propose', 'spec', 'design', 'apply', 'test'];
|
|
44
|
+
|
|
45
|
+
// ── 工具函数 ──────────────────────────────────────────
|
|
46
|
+
function ensureDir(dirPath) {
|
|
47
|
+
if (!fs.existsSync(dirPath)) {
|
|
48
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function generateEventId() {
|
|
53
|
+
return 'evt_' + crypto.randomBytes(6).toString('hex');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function today() {
|
|
57
|
+
return new Date().toISOString().slice(0, 10);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function nowISO() {
|
|
61
|
+
return new Date().toISOString();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeProjectRoot(projectRoot) {
|
|
65
|
+
const rawProjectRoot = projectRoot || process.cwd();
|
|
66
|
+
if (process.platform === 'win32' && /^[a-zA-Z]:[^\\/]/.test(String(rawProjectRoot))) {
|
|
67
|
+
fail(`Windows 项目路径格式不安全: ${rawProjectRoot}。请使用 --project=.,或使用正斜杠路径如 D:/project/demo,或给路径加引号。`);
|
|
68
|
+
}
|
|
69
|
+
return path.resolve(rawProjectRoot);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function inferAgentType(args) {
|
|
73
|
+
const explicit = args.agent || args['agent-type'];
|
|
74
|
+
if (explicit) return explicit;
|
|
75
|
+
|
|
76
|
+
return process.env.SDD_AGENT_TYPE
|
|
77
|
+
|| process.env.AI_AGENT_TYPE
|
|
78
|
+
|| process.env.CLAUDE_CODE
|
|
79
|
+
|| process.env.CURSOR_AGENT
|
|
80
|
+
|| 'unknown';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function readJsonFile(filePath, projectRoot) {
|
|
84
|
+
const resolved = path.isAbsolute(filePath)
|
|
85
|
+
? filePath
|
|
86
|
+
: path.resolve(projectRoot || process.cwd(), filePath);
|
|
87
|
+
return JSON.parse(fs.readFileSync(resolved, 'utf8'));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function parseJsonOption(args, inlineKey, fileKey, projectRoot, fallback = {}) {
|
|
91
|
+
const inlineValue = args[inlineKey];
|
|
92
|
+
const fileValue = args[fileKey];
|
|
93
|
+
|
|
94
|
+
if (inlineValue && fileValue) {
|
|
95
|
+
throw new Error(`不能同时指定 --${inlineKey} 和 --${fileKey}`);
|
|
96
|
+
}
|
|
97
|
+
if (inlineValue) {
|
|
98
|
+
return JSON.parse(inlineValue);
|
|
99
|
+
}
|
|
100
|
+
if (fileValue) {
|
|
101
|
+
return readJsonFile(fileValue, projectRoot);
|
|
102
|
+
}
|
|
103
|
+
return fallback;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cleanOptionalFields(event) {
|
|
107
|
+
for (const key of Object.keys(event)) {
|
|
108
|
+
if (event[key] == null || event[key] === '') {
|
|
109
|
+
delete event[key];
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return event;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function fail(message) {
|
|
116
|
+
console.error(`错误: ${message}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 安全化 change name,防止路径穿越 */
|
|
121
|
+
function safeChangeName(name) {
|
|
122
|
+
if (!name) return 'general';
|
|
123
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '') || 'general';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function getActiveStageKey(criteria) {
|
|
127
|
+
if (criteria.session_id) {
|
|
128
|
+
return `session-${safeChangeName(criteria.session_id)}`;
|
|
129
|
+
}
|
|
130
|
+
const parts = [
|
|
131
|
+
criteria.change || 'general',
|
|
132
|
+
criteria.command || criteria.stage || 'unknown',
|
|
133
|
+
criteria.agent_type || 'unknown',
|
|
134
|
+
];
|
|
135
|
+
return safeChangeName(parts.join('-'));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function getActiveStageFile(projectRoot, criteria) {
|
|
139
|
+
return path.join(getStateDir(projectRoot), `${getActiveStageKey(criteria)}.json`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writeActiveStage(projectRoot, event) {
|
|
143
|
+
const stateDir = getStateDir(projectRoot);
|
|
144
|
+
ensureDir(stateDir);
|
|
145
|
+
fs.writeFileSync(
|
|
146
|
+
getActiveStageFile(projectRoot, event),
|
|
147
|
+
JSON.stringify({ updated_at: nowISO(), event }, null, 2),
|
|
148
|
+
'utf8'
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function readStateFile(filePath) {
|
|
153
|
+
try {
|
|
154
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
155
|
+
return data.event || null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findActiveStage(projectRoot, criteria) {
|
|
162
|
+
const exactFile = getActiveStageFile(projectRoot, criteria);
|
|
163
|
+
if (fs.existsSync(exactFile)) {
|
|
164
|
+
const exact = readStateFile(exactFile);
|
|
165
|
+
if (exact) return exact;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const stateDir = getStateDir(projectRoot);
|
|
169
|
+
if (!fs.existsSync(stateDir)) return null;
|
|
170
|
+
|
|
171
|
+
const candidates = fs.readdirSync(stateDir)
|
|
172
|
+
.filter(file => file.endsWith('.json'))
|
|
173
|
+
.map(file => readStateFile(path.join(stateDir, file)))
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.filter(event => {
|
|
176
|
+
if (criteria.change && event.change !== criteria.change) return false;
|
|
177
|
+
if (criteria.command && event.command !== criteria.command) return false;
|
|
178
|
+
if (criteria.agent_type && event.agent_type !== criteria.agent_type) return false;
|
|
179
|
+
return true;
|
|
180
|
+
})
|
|
181
|
+
.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
182
|
+
|
|
183
|
+
if (candidates.length > 0) return candidates[0];
|
|
184
|
+
|
|
185
|
+
// 跨 session 匹配:当 session_id 变化(如 compaction 恢复)但 command+change 相同时,
|
|
186
|
+
// 在 events 目录中搜索最近的 stage_start 事件
|
|
187
|
+
if (criteria.command && criteria.change) {
|
|
188
|
+
const dataDir = getDataDir(projectRoot);
|
|
189
|
+
const eventsDir = path.join(dataDir, 'events');
|
|
190
|
+
if (fs.existsSync(eventsDir)) {
|
|
191
|
+
const changeDirs = fs.readdirSync(eventsDir).filter(d => {
|
|
192
|
+
return fs.statSync(path.join(eventsDir, d)).isDirectory();
|
|
193
|
+
});
|
|
194
|
+
const matchingStarts = [];
|
|
195
|
+
for (const changeDir of changeDirs) {
|
|
196
|
+
const dir = path.join(eventsDir, changeDir);
|
|
197
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort().reverse();
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean);
|
|
200
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
201
|
+
try {
|
|
202
|
+
const event = JSON.parse(lines[i]);
|
|
203
|
+
if (event.type === 'stage_start' &&
|
|
204
|
+
event.command === criteria.command &&
|
|
205
|
+
event.change === criteria.change &&
|
|
206
|
+
(!criteria.capability || event.capability === criteria.capability)) {
|
|
207
|
+
matchingStarts.push(event);
|
|
208
|
+
}
|
|
209
|
+
} catch {}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
if (matchingStarts.length > 0) {
|
|
214
|
+
matchingStarts.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
|
|
215
|
+
return matchingStarts[0];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function clearActiveStage(projectRoot, event) {
|
|
224
|
+
const files = [
|
|
225
|
+
getActiveStageFile(projectRoot, event),
|
|
226
|
+
];
|
|
227
|
+
if (event.session_id) {
|
|
228
|
+
files.push(getActiveStageFile(projectRoot, { session_id: event.session_id }));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
for (const file of [...new Set(files)]) {
|
|
232
|
+
if (fs.existsSync(file)) {
|
|
233
|
+
fs.unlinkSync(file);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function walkFiles(dirPath, predicate, files = []) {
|
|
239
|
+
if (!fs.existsSync(dirPath)) return files;
|
|
240
|
+
|
|
241
|
+
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
|
|
242
|
+
const fullPath = path.join(dirPath, entry.name);
|
|
243
|
+
if (entry.isDirectory()) {
|
|
244
|
+
walkFiles(fullPath, predicate, files);
|
|
245
|
+
} else if (!predicate || predicate(fullPath)) {
|
|
246
|
+
files.push(fullPath);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return files;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function copyDirSync(sourceDir, targetDir) {
|
|
254
|
+
ensureDir(targetDir);
|
|
255
|
+
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
256
|
+
const sourcePath = path.join(sourceDir, entry.name);
|
|
257
|
+
const targetPath = path.join(targetDir, entry.name);
|
|
258
|
+
if (entry.isDirectory()) {
|
|
259
|
+
copyDirSync(sourcePath, targetPath);
|
|
260
|
+
} else {
|
|
261
|
+
ensureDir(path.dirname(targetPath));
|
|
262
|
+
fs.copyFileSync(sourcePath, targetPath);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function nextAvailableDir(baseDir, preferredName) {
|
|
268
|
+
let candidate = path.join(baseDir, preferredName);
|
|
269
|
+
let suffix = 2;
|
|
270
|
+
while (fs.existsSync(candidate)) {
|
|
271
|
+
candidate = path.join(baseDir, `${preferredName}-${suffix}`);
|
|
272
|
+
suffix += 1;
|
|
273
|
+
}
|
|
274
|
+
return candidate;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function getChangeDir(projectRoot, changeName) {
|
|
278
|
+
return path.join(projectRoot, 'openspec', 'changes', changeName);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function getArchiveRoot(projectRoot) {
|
|
282
|
+
return path.join(projectRoot, 'openspec', 'changes', 'archive');
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function discoverTaskFiles(projectRoot, changeName) {
|
|
286
|
+
const changeDir = getChangeDir(projectRoot, changeName);
|
|
287
|
+
return walkFiles(changeDir, filePath => {
|
|
288
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
289
|
+
return fileName === 'tasks.md' || fileName === 'task.md';
|
|
290
|
+
}).sort();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
|
|
294
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
295
|
+
const files = [];
|
|
296
|
+
let completed = 0;
|
|
297
|
+
let incomplete = 0;
|
|
298
|
+
const incompleteItems = [];
|
|
299
|
+
|
|
300
|
+
for (const filePath of taskFiles) {
|
|
301
|
+
const relPath = path.relative(normalizedRoot, filePath).replace(/\\/g, '/');
|
|
302
|
+
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
303
|
+
let fileCompleted = 0;
|
|
304
|
+
let fileIncomplete = 0;
|
|
305
|
+
|
|
306
|
+
lines.forEach((line, index) => {
|
|
307
|
+
if (/\[[xX]\]/.test(line)) {
|
|
308
|
+
completed += 1;
|
|
309
|
+
fileCompleted += 1;
|
|
310
|
+
} else if (/\[\s\]/.test(line)) {
|
|
311
|
+
incomplete += 1;
|
|
312
|
+
fileIncomplete += 1;
|
|
313
|
+
incompleteItems.push({
|
|
314
|
+
file: relPath,
|
|
315
|
+
line: index + 1,
|
|
316
|
+
text: line.trim(),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
files.push({
|
|
322
|
+
path: relPath,
|
|
323
|
+
completed: fileCompleted,
|
|
324
|
+
incomplete: fileIncomplete,
|
|
325
|
+
total: fileCompleted + fileIncomplete,
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
change: changeName,
|
|
331
|
+
task_files: files,
|
|
332
|
+
completed,
|
|
333
|
+
incomplete,
|
|
334
|
+
total: completed + incomplete,
|
|
335
|
+
has_incomplete: incomplete > 0,
|
|
336
|
+
incomplete_items: incompleteItems,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
function scanTaskCompletion(projectRoot, changeName) {
|
|
341
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
342
|
+
const taskFiles = discoverTaskFiles(normalizedRoot, changeName);
|
|
343
|
+
return scanTaskCompletionFromFiles(normalizedRoot, changeName, taskFiles);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir) {
|
|
347
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
348
|
+
const taskFiles = walkFiles(archiveDir, filePath => {
|
|
349
|
+
const fileName = path.basename(filePath).toLowerCase();
|
|
350
|
+
return fileName === 'tasks.md' || fileName === 'task.md';
|
|
351
|
+
}).sort();
|
|
352
|
+
return scanTaskCompletionFromFiles(normalizedRoot, changeName, taskFiles);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function discoverFullSpecFiles(changeDir) {
|
|
356
|
+
const specsDir = path.join(changeDir, 'specs');
|
|
357
|
+
const specs = [];
|
|
358
|
+
|
|
359
|
+
if (fs.existsSync(specsDir)) {
|
|
360
|
+
for (const entry of fs.readdirSync(specsDir, { withFileTypes: true })) {
|
|
361
|
+
if (!entry.isDirectory()) continue;
|
|
362
|
+
const specPath = path.join(specsDir, entry.name, 'spec.md');
|
|
363
|
+
if (fs.existsSync(specPath)) {
|
|
364
|
+
specs.push({ capability: entry.name, source: specPath });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const simpleSpecPath = path.join(changeDir, 'spec.md');
|
|
370
|
+
if (fs.existsSync(simpleSpecPath)) {
|
|
371
|
+
specs.push({ capability: path.basename(changeDir), source: simpleSpecPath });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return specs.sort((a, b) => a.capability.localeCompare(b.capability));
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function toProjectRelative(projectRoot, targetPath) {
|
|
378
|
+
return path.relative(projectRoot, targetPath).replace(/\\/g, '/');
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function findArchivedChangeDir(projectRoot, changeName) {
|
|
382
|
+
const archiveRoot = getArchiveRoot(projectRoot);
|
|
383
|
+
if (!fs.existsSync(archiveRoot)) return null;
|
|
384
|
+
|
|
385
|
+
const prefix = `-${changeName}`;
|
|
386
|
+
const candidates = fs.readdirSync(archiveRoot, { withFileTypes: true })
|
|
387
|
+
.filter(entry => entry.isDirectory() && entry.name.includes(prefix))
|
|
388
|
+
.map(entry => path.join(archiveRoot, entry.name))
|
|
389
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
390
|
+
|
|
391
|
+
return candidates[0] || null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function syncArchivedSpecs(projectRoot, archiveDir) {
|
|
395
|
+
const copiedSpecs = [];
|
|
396
|
+
const specFiles = discoverFullSpecFiles(archiveDir);
|
|
397
|
+
|
|
398
|
+
for (const spec of specFiles) {
|
|
399
|
+
const targetSpec = path.join(projectRoot, 'openspec', 'specs', spec.capability, 'spec.md');
|
|
400
|
+
ensureDir(path.dirname(targetSpec));
|
|
401
|
+
fs.copyFileSync(spec.source, targetSpec);
|
|
402
|
+
copiedSpecs.push({
|
|
403
|
+
capability: spec.capability,
|
|
404
|
+
source: toProjectRelative(projectRoot, spec.source),
|
|
405
|
+
target: toProjectRelative(projectRoot, targetSpec),
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return copiedSpecs;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}) {
|
|
413
|
+
const manifestPath = path.join(archiveDir, 'archive-manifest.json');
|
|
414
|
+
const copiedSpecs = syncArchivedSpecs(projectRoot, archiveDir);
|
|
415
|
+
const manifest = {
|
|
416
|
+
change: changeName,
|
|
417
|
+
archived_at: options.archivedAt || nowISO(),
|
|
418
|
+
reason: options.reason || '',
|
|
419
|
+
method: options.method || 'skywalk-full-spec-archive',
|
|
420
|
+
source_path: `openspec/changes/${changeName}`,
|
|
421
|
+
archive_path: toProjectRelative(projectRoot, archiveDir),
|
|
422
|
+
copied_specs: copiedSpecs,
|
|
423
|
+
};
|
|
424
|
+
fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
425
|
+
return {
|
|
426
|
+
manifest,
|
|
427
|
+
manifest_path: manifestPath,
|
|
428
|
+
copied_specs: copiedSpecs,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, options = {}) {
|
|
433
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
434
|
+
const activeChangeDir = getChangeDir(normalizedRoot, changeName);
|
|
435
|
+
const archiveReason = details.archive_result?.reason || options.reason || '';
|
|
436
|
+
|
|
437
|
+
let archiveDir = details.archive_result?.archive_path
|
|
438
|
+
? path.resolve(normalizedRoot, details.archive_result.archive_path)
|
|
439
|
+
: findArchivedChangeDir(normalizedRoot, changeName);
|
|
440
|
+
let archiveMethod = details.archive_result?.method || '';
|
|
441
|
+
|
|
442
|
+
if (!archiveDir && fs.existsSync(activeChangeDir)) {
|
|
443
|
+
const archived = archiveChangeDocs(normalizedRoot, changeName, { reason: archiveReason || '' });
|
|
444
|
+
archiveDir = archived.archive_path;
|
|
445
|
+
archiveMethod = archived.method;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (!archiveDir || !fs.existsSync(archiveDir)) {
|
|
449
|
+
return {
|
|
450
|
+
changed: false,
|
|
451
|
+
archive_result: details.archive_result || {},
|
|
452
|
+
task_completion: details.archive_result?.task_completion || null,
|
|
453
|
+
reportPath: '',
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// 显式 reportOutput 优先;否则默认落归档后 archive 目录的 reports/ 子目录
|
|
458
|
+
const reportPath = options.reportOutput
|
|
459
|
+
? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
|
|
460
|
+
: path.join(archiveDir, 'reports', `${safeChangeName(changeName)}-report.md`);
|
|
461
|
+
|
|
462
|
+
const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
|
|
463
|
+
reason: archiveReason,
|
|
464
|
+
method: archiveMethod || 'skywalk-full-spec-archive',
|
|
465
|
+
archivedAt: details.archive_result?.archived_at || nowISO(),
|
|
466
|
+
});
|
|
467
|
+
const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
|
|
468
|
+
|
|
469
|
+
const archiveResult = {
|
|
470
|
+
reason: archiveReason,
|
|
471
|
+
method: manifestInfo.manifest.method,
|
|
472
|
+
archive_path: manifestInfo.manifest.archive_path,
|
|
473
|
+
report_path: toProjectRelative(normalizedRoot, reportPath),
|
|
474
|
+
// report_html_path 为预期路径:由 cmdEnd 落盘阶段写入(try/catch 容错,失败时不阻塞 md 主产物)。
|
|
475
|
+
// 此处无条件派生,html 实际落盘失败时该路径可能不存在;语义为"预期路径",消费者不应假定文件已存在。
|
|
476
|
+
report_html_path: toProjectRelative(normalizedRoot, reportPath.replace(/\.md$/i, '.html')),
|
|
477
|
+
manifest_path: toProjectRelative(normalizedRoot, manifestInfo.manifest_path),
|
|
478
|
+
task_completion: taskCompletion,
|
|
479
|
+
copied_specs: manifestInfo.copied_specs,
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
changed: true,
|
|
484
|
+
archive_result: archiveResult,
|
|
485
|
+
task_completion: taskCompletion,
|
|
486
|
+
reportPath,
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function archiveChangeDocs(projectRoot, changeName, options = {}) {
|
|
491
|
+
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
492
|
+
if (!changeName) {
|
|
493
|
+
throw new Error('缺少 change 名称');
|
|
494
|
+
}
|
|
495
|
+
if (safeChangeName(changeName) !== changeName) {
|
|
496
|
+
throw new Error(`change 名称不安全: ${changeName}`);
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const sourceDir = getChangeDir(normalizedRoot, changeName);
|
|
500
|
+
if (!fs.existsSync(sourceDir)) {
|
|
501
|
+
throw new Error(`变更目录不存在: ${sourceDir}`);
|
|
502
|
+
}
|
|
503
|
+
if (path.basename(sourceDir) === 'archive') {
|
|
504
|
+
throw new Error('不能归档 archive 目录本身');
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const archiveRoot = getArchiveRoot(normalizedRoot);
|
|
508
|
+
ensureDir(archiveRoot);
|
|
509
|
+
const archiveDate = options.date || today();
|
|
510
|
+
const archiveDir = nextAvailableDir(archiveRoot, `${archiveDate}-${changeName}`);
|
|
511
|
+
|
|
512
|
+
copyDirSync(sourceDir, archiveDir);
|
|
513
|
+
const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
|
|
514
|
+
reason: options.reason || '',
|
|
515
|
+
method: 'skywalk-full-spec-archive',
|
|
516
|
+
});
|
|
517
|
+
const manifest = manifestInfo.manifest;
|
|
518
|
+
|
|
519
|
+
if (!options.keepActive) {
|
|
520
|
+
fs.rmSync(sourceDir, { recursive: true, force: true });
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
return {
|
|
524
|
+
...manifest,
|
|
525
|
+
project_root: normalizedRoot,
|
|
526
|
+
archive_path: archiveDir,
|
|
527
|
+
active_change_exists: fs.existsSync(sourceDir),
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** 追加一行 JSONL 到事件文件(写入失败时抛出异常) */
|
|
532
|
+
function appendEvent(dataDir, changeName, event) {
|
|
533
|
+
const dir = path.join(dataDir, 'events', safeChangeName(changeName));
|
|
534
|
+
ensureDir(dir);
|
|
535
|
+
const file = path.join(dir, `${today()}.jsonl`);
|
|
536
|
+
const line = JSON.stringify(event) + '\n';
|
|
537
|
+
fs.appendFileSync(file, line, 'utf8');
|
|
538
|
+
if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
539
|
+
throw new Error(`事件文件写入验证失败: ${file}`);
|
|
540
|
+
}
|
|
541
|
+
appendMarkdownLog(dataDir, changeName, event);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** 把事件同步追加为人读 markdown 行到 openspec/changes/<change>/logs/execution-log.md
|
|
545
|
+
* (telemetry 副本;change 已归档时写入 archive 目录;失败不阻塞 telemetry 主流程) */
|
|
546
|
+
function appendMarkdownLog(dataDir, changeName, event) {
|
|
547
|
+
try {
|
|
548
|
+
const projectRoot = path.dirname(dataDir);
|
|
549
|
+
const activeChangeDir = getChangeDir(projectRoot, changeName);
|
|
550
|
+
let logsDir = path.join(activeChangeDir, 'logs');
|
|
551
|
+
if (!fs.existsSync(activeChangeDir)) {
|
|
552
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
553
|
+
if (archiveDir) logsDir = path.join(archiveDir, 'logs');
|
|
554
|
+
}
|
|
555
|
+
ensureDir(logsDir);
|
|
556
|
+
const file = path.join(logsDir, 'execution-log.md');
|
|
557
|
+
if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
558
|
+
const created = (event.timestamp || nowISO()).slice(0, 16).replace('T', ' ');
|
|
559
|
+
fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
|
|
560
|
+
}
|
|
561
|
+
const ts = (event.timestamp || nowISO()).replace('T', ' ').slice(0, 19);
|
|
562
|
+
const stage = event.command || event.stage || 'unknown';
|
|
563
|
+
const type = event.type || 'event';
|
|
564
|
+
const result = event.result || '';
|
|
565
|
+
const mark = RESULT_MARK[result] || '📝';
|
|
566
|
+
const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
|
|
567
|
+
fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
|
|
568
|
+
} catch (err) {
|
|
569
|
+
// markdown 副本失败不影响 telemetry 主流程,但输出 stderr 提示保留可观测性(避免持续性失败长期静默)
|
|
570
|
+
try { console.error(`[telemetry] execution-log 副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** 读取指定 change 的所有事件 */
|
|
575
|
+
function readEvents(dataDir, changeName) {
|
|
576
|
+
try {
|
|
577
|
+
const dir = path.join(dataDir, 'events', safeChangeName(changeName));
|
|
578
|
+
if (!fs.existsSync(dir)) return [];
|
|
579
|
+
|
|
580
|
+
const events = [];
|
|
581
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort();
|
|
582
|
+
for (const file of files) {
|
|
583
|
+
const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean);
|
|
584
|
+
for (const line of lines) {
|
|
585
|
+
try { events.push(JSON.parse(line)); } catch {}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
return events;
|
|
589
|
+
} catch (err) {
|
|
590
|
+
console.error('[sdd-telemetry] 事件读取失败:', err.message);
|
|
591
|
+
return [];
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/** 读取所有 change 的事件 */
|
|
596
|
+
function readAllEvents(dataDir) {
|
|
597
|
+
const eventsDir = path.join(dataDir, 'events');
|
|
598
|
+
if (!fs.existsSync(eventsDir)) return [];
|
|
599
|
+
|
|
600
|
+
const events = [];
|
|
601
|
+
const changeDirs = fs.readdirSync(eventsDir).filter(d => {
|
|
602
|
+
return fs.statSync(path.join(eventsDir, d)).isDirectory();
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
for (const changeDir of changeDirs) {
|
|
606
|
+
const changeEvents = readEvents(dataDir, changeDir);
|
|
607
|
+
events.push(...changeEvents);
|
|
608
|
+
}
|
|
609
|
+
return events;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// ── 四维度量指标计算 ──────────────────────────────────
|
|
613
|
+
|
|
614
|
+
/**
|
|
615
|
+
* 计算单个 change 的四维指标
|
|
616
|
+
*/
|
|
617
|
+
function computeChangeMetrics(changeName, events) {
|
|
618
|
+
const changeEvents = events.filter(e => e.change === changeName && !e.orphan);
|
|
619
|
+
if (changeEvents.length === 0) return null;
|
|
620
|
+
|
|
621
|
+
const starts = changeEvents.filter(e => e.type === 'stage_start');
|
|
622
|
+
const ends = changeEvents.filter(e => e.type === 'stage_end');
|
|
623
|
+
|
|
624
|
+
// 已执行的阶段(去重)
|
|
625
|
+
const executedStages = [...new Set(starts.map(e => e.command))];
|
|
626
|
+
const completedStages = [...new Set(ends.map(e => e.command))];
|
|
627
|
+
|
|
628
|
+
// ── 维度一:流程健康度 ──
|
|
629
|
+
const coveredCoreStages = CORE_STAGES.filter(s => executedStages.includes(s));
|
|
630
|
+
const criticalPathCoverage = coveredCoreStages.length / CORE_STAGES.length;
|
|
631
|
+
|
|
632
|
+
// 跳阶段检测
|
|
633
|
+
const stageSequence = starts.map(e => e.command);
|
|
634
|
+
const skippedStages = detectSkippedStages(stageSequence);
|
|
635
|
+
|
|
636
|
+
// 阶段回退检测
|
|
637
|
+
const regressions = detectRegressions(stageSequence);
|
|
638
|
+
const regressionRate = stageSequence.length > 1
|
|
639
|
+
? regressions.length / (stageSequence.length - 1)
|
|
640
|
+
: 0;
|
|
641
|
+
|
|
642
|
+
// Spec 存在率
|
|
643
|
+
const hasSpec = executedStages.includes('spec');
|
|
644
|
+
|
|
645
|
+
// ── 维度二:交付效率 ──
|
|
646
|
+
const proposeStart = starts.find(e => e.command === 'propose');
|
|
647
|
+
const archiveEnd = ends.find(e => e.command === 'archive');
|
|
648
|
+
const changeLeadTime = (proposeStart && archiveEnd)
|
|
649
|
+
? new Date(archiveEnd.timestamp).getTime() - new Date(proposeStart.timestamp).getTime()
|
|
650
|
+
: null;
|
|
651
|
+
|
|
652
|
+
const designEnd = ends.find(e => e.command === 'design');
|
|
653
|
+
const applyStart = starts.find(e => e.command === 'apply');
|
|
654
|
+
const designToCodeTime = (designEnd && applyStart)
|
|
655
|
+
? new Date(applyStart.timestamp).getTime() - new Date(designEnd.timestamp).getTime()
|
|
656
|
+
: null;
|
|
657
|
+
|
|
658
|
+
// 首次 Apply 成功率
|
|
659
|
+
const testEvents = getTestEvents(changeEvents);
|
|
660
|
+
const firstTestEvent = firstByTimestamp(testEvents);
|
|
661
|
+
const firstTestSuccess = firstTestEvent
|
|
662
|
+
? (firstTestEvent.result === 'success' || getTestResults(firstTestEvent)?.failed === 0)
|
|
663
|
+
: null;
|
|
664
|
+
|
|
665
|
+
// 阶段平均耗时
|
|
666
|
+
const stageDurations = {};
|
|
667
|
+
for (const end of ends) {
|
|
668
|
+
if (end.duration_ms) {
|
|
669
|
+
if (!stageDurations[end.command]) stageDurations[end.command] = [];
|
|
670
|
+
stageDurations[end.command].push(end.duration_ms);
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
const avgStageDurations = {};
|
|
674
|
+
for (const [stage, durations] of Object.entries(stageDurations)) {
|
|
675
|
+
avgStageDurations[stage] = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// ── 维度三:质量信号 ──
|
|
679
|
+
const stageTestEvent = firstByTimestamp(testEvents.filter(e => {
|
|
680
|
+
return e.command === 'test' || e.type === 'test_result';
|
|
681
|
+
}));
|
|
682
|
+
const firstTestPassRate = getTestPassRate(stageTestEvent || firstTestEvent);
|
|
683
|
+
|
|
684
|
+
const checkEvents = getCheckEvents(changeEvents);
|
|
685
|
+
const firstCheckEvent = firstByTimestamp(checkEvents);
|
|
686
|
+
const firstCheckPass = firstCheckEvent
|
|
687
|
+
? (getCheckResults(firstCheckEvent)?.errors || 0) === 0
|
|
688
|
+
: null;
|
|
689
|
+
|
|
690
|
+
// Apply-Test-Fix 循环次数
|
|
691
|
+
const applyCount = starts.filter(e => e.command === 'apply').length;
|
|
692
|
+
|
|
693
|
+
// 测试覆盖率趋势
|
|
694
|
+
const coverageTrend = testEvents
|
|
695
|
+
.map(e => ({ event: e, results: getTestResults(e) }))
|
|
696
|
+
.filter(item => item.results?.coverage != null)
|
|
697
|
+
.map(item => ({ timestamp: item.event.timestamp, coverage: item.results.coverage }));
|
|
698
|
+
|
|
699
|
+
const conformanceMetrics = computeConformanceMetrics(changeEvents);
|
|
700
|
+
const aiAdoptionMetrics = computeAiAdoptionMetrics(changeEvents);
|
|
701
|
+
const aiFirstPassMetrics = computeAiFirstPassMetrics(changeEvents);
|
|
702
|
+
const specTestCoverageMetrics = computeSpecTestCoverageMetrics(changeEvents);
|
|
703
|
+
const manualInsightMetrics = computeManualInsightMetrics(changeEvents);
|
|
704
|
+
|
|
705
|
+
// ── 维度四:团队洞察 ──
|
|
706
|
+
const agentTypes = [...new Set(changeEvents.filter(e => e.agent_type).map(e => e.agent_type))];
|
|
707
|
+
const isCompleted = !!archiveEnd;
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
change: changeName,
|
|
711
|
+
process_health: {
|
|
712
|
+
critical_path_coverage: criticalPathCoverage,
|
|
713
|
+
covered_core_stages: coveredCoreStages,
|
|
714
|
+
regression_rate: regressionRate,
|
|
715
|
+
regressions,
|
|
716
|
+
skipped_stages: skippedStages,
|
|
717
|
+
has_spec: hasSpec,
|
|
718
|
+
},
|
|
719
|
+
delivery_efficiency: {
|
|
720
|
+
change_lead_time_ms: changeLeadTime,
|
|
721
|
+
design_to_code_time_ms: designToCodeTime,
|
|
722
|
+
first_apply_success: firstTestSuccess,
|
|
723
|
+
avg_stage_durations_ms: avgStageDurations,
|
|
724
|
+
},
|
|
725
|
+
quality_signals: {
|
|
726
|
+
e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
|
|
727
|
+
first_test_pass_rate: firstTestPassRate,
|
|
728
|
+
first_check_pass: firstCheckPass,
|
|
729
|
+
apply_test_fix_cycles: applyCount,
|
|
730
|
+
coverage_trend: coverageTrend,
|
|
731
|
+
q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
|
|
732
|
+
q4_spec_driven_test_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
|
|
733
|
+
conformance_counts: conformanceMetrics.conformance_counts,
|
|
734
|
+
conformance_manual_confirmed: conformanceMetrics.manual_confirmed,
|
|
735
|
+
spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
|
|
736
|
+
p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
|
|
737
|
+
ai_adoption_level: aiAdoptionMetrics.adoption_level,
|
|
738
|
+
},
|
|
739
|
+
team_insights: {
|
|
740
|
+
agent_types: agentTypes,
|
|
741
|
+
is_completed: isCompleted,
|
|
742
|
+
total_stages_executed: stageSequence.length,
|
|
743
|
+
executed_stages: executedStages,
|
|
744
|
+
manual_insights: manualInsightMetrics,
|
|
745
|
+
},
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** 检测跳阶段 */
|
|
750
|
+
function detectSkippedStages(sequence) {
|
|
751
|
+
const skipped = [];
|
|
752
|
+
for (let i = 1; i < sequence.length; i++) {
|
|
753
|
+
const prevIdx = STAGE_ORDER.indexOf(sequence[i - 1]);
|
|
754
|
+
const currIdx = STAGE_ORDER.indexOf(sequence[i]);
|
|
755
|
+
if (prevIdx >= 0 && currIdx >= 0 && currIdx > prevIdx + 1) {
|
|
756
|
+
const missed = STAGE_ORDER.slice(prevIdx + 1, currIdx);
|
|
757
|
+
skipped.push({ from: sequence[i - 1], to: sequence[i], missed });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
return skipped;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** 检测阶段回退 */
|
|
764
|
+
function detectRegressions(sequence) {
|
|
765
|
+
const regressions = [];
|
|
766
|
+
for (let i = 1; i < sequence.length; i++) {
|
|
767
|
+
const prevIdx = STAGE_ORDER.indexOf(sequence[i - 1]);
|
|
768
|
+
const currIdx = STAGE_ORDER.indexOf(sequence[i]);
|
|
769
|
+
if (prevIdx >= 0 && currIdx >= 0 && currIdx < prevIdx) {
|
|
770
|
+
regressions.push({ from: sequence[i - 1], back_to: sequence[i] });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
return regressions;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* 计算全局概览指标
|
|
778
|
+
*/
|
|
779
|
+
function computeOverviewMetrics(events) {
|
|
780
|
+
const changeNames = [...new Set(events.map(e => e.change).filter(Boolean))];
|
|
781
|
+
|
|
782
|
+
const changeMetrics = changeNames
|
|
783
|
+
.map(name => computeChangeMetrics(name, events))
|
|
784
|
+
.filter(Boolean);
|
|
785
|
+
|
|
786
|
+
const totalChanges = changeMetrics.length;
|
|
787
|
+
if (totalChanges === 0) {
|
|
788
|
+
return { total_changes: 0, message: '暂无事件数据' };
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// 维度一汇总
|
|
792
|
+
const avgCriticalPath = changeMetrics.reduce((s, m) => s + m.process_health.critical_path_coverage, 0) / totalChanges;
|
|
793
|
+
const specRate = changeMetrics.filter(m => m.process_health.has_spec).length / totalChanges;
|
|
794
|
+
const avgRegressionRate = changeMetrics.reduce((s, m) => s + m.process_health.regression_rate, 0) / totalChanges;
|
|
795
|
+
|
|
796
|
+
// 维度二汇总
|
|
797
|
+
const completedChanges = changeMetrics.filter(m => m.delivery_efficiency.change_lead_time_ms != null);
|
|
798
|
+
const avgLeadTime = completedChanges.length > 0
|
|
799
|
+
? completedChanges.reduce((s, m) => s + m.delivery_efficiency.change_lead_time_ms, 0) / completedChanges.length
|
|
800
|
+
: null;
|
|
801
|
+
const firstApplySuccessRate = (() => {
|
|
802
|
+
const withTest = changeMetrics.filter(m => m.delivery_efficiency.first_apply_success != null);
|
|
803
|
+
return withTest.length > 0
|
|
804
|
+
? withTest.filter(m => m.delivery_efficiency.first_apply_success).length / withTest.length
|
|
805
|
+
: null;
|
|
806
|
+
})();
|
|
807
|
+
|
|
808
|
+
// 维度三汇总
|
|
809
|
+
const firstCheckPassRate = (() => {
|
|
810
|
+
const withCheck = changeMetrics.filter(m => m.quality_signals.first_check_pass != null);
|
|
811
|
+
return withCheck.length > 0
|
|
812
|
+
? withCheck.filter(m => m.quality_signals.first_check_pass).length / withCheck.length
|
|
813
|
+
: null;
|
|
814
|
+
})();
|
|
815
|
+
const avgApplyTestCycles = changeMetrics.reduce((s, m) => s + m.quality_signals.apply_test_fix_cycles, 0) / totalChanges;
|
|
816
|
+
const avgAiFirstPassRate = averageMetric(changeMetrics, m => m.quality_signals.e4_ai_code_first_pass_rate);
|
|
817
|
+
const avgConformanceScore = averageMetric(changeMetrics, m => m.quality_signals.q1_spec_conformance_score);
|
|
818
|
+
const avgSpecDrivenTestCoverage = averageMetric(changeMetrics, m => m.quality_signals.q4_spec_driven_test_coverage);
|
|
819
|
+
const avgAiAdoptionRate = averageMetric(changeMetrics, m => m.quality_signals.p2_ai_code_adoption_rate);
|
|
820
|
+
|
|
821
|
+
// 维度四汇总
|
|
822
|
+
const completedCount = changeMetrics.filter(m => m.team_insights.is_completed).length;
|
|
823
|
+
const changeCompletionRate = completedCount / totalChanges;
|
|
824
|
+
const activeChanges = totalChanges - completedCount;
|
|
825
|
+
const allAgents = [...new Set(changeMetrics.flatMap(m => m.team_insights.agent_types))];
|
|
826
|
+
const manualInsightMetrics = computeManualInsightMetrics(events.filter(e => !e.orphan));
|
|
827
|
+
|
|
828
|
+
return {
|
|
829
|
+
total_changes: totalChanges,
|
|
830
|
+
process_health: {
|
|
831
|
+
avg_critical_path_coverage: Math.round(avgCriticalPath * 100) / 100,
|
|
832
|
+
spec_existence_rate: Math.round(specRate * 100) / 100,
|
|
833
|
+
avg_regression_rate: Math.round(avgRegressionRate * 100) / 100,
|
|
834
|
+
},
|
|
835
|
+
delivery_efficiency: {
|
|
836
|
+
avg_lead_time_ms: avgLeadTime != null ? Math.round(avgLeadTime) : null,
|
|
837
|
+
first_apply_success_rate: firstApplySuccessRate != null ? Math.round(firstApplySuccessRate * 100) / 100 : null,
|
|
838
|
+
},
|
|
839
|
+
quality_signals: {
|
|
840
|
+
avg_e4_ai_code_first_pass_rate: avgAiFirstPassRate,
|
|
841
|
+
first_check_pass_rate: firstCheckPassRate != null ? Math.round(firstCheckPassRate * 100) / 100 : null,
|
|
842
|
+
avg_apply_test_fix_cycles: Math.round(avgApplyTestCycles * 10) / 10,
|
|
843
|
+
avg_q1_spec_conformance_score: avgConformanceScore,
|
|
844
|
+
avg_q4_spec_driven_test_coverage: avgSpecDrivenTestCoverage,
|
|
845
|
+
avg_p2_ai_code_adoption_rate: avgAiAdoptionRate,
|
|
846
|
+
},
|
|
847
|
+
team_insights: {
|
|
848
|
+
change_completion_rate: Math.round(changeCompletionRate * 100) / 100,
|
|
849
|
+
active_changes: activeChanges,
|
|
850
|
+
agent_distribution: allAgents,
|
|
851
|
+
manual_insights: manualInsightMetrics,
|
|
852
|
+
},
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
function roundMetric(value, digits = 2) {
|
|
857
|
+
if (value == null || Number.isNaN(value)) return null;
|
|
858
|
+
const factor = 10 ** digits;
|
|
859
|
+
return Math.round(value * factor) / factor;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function sumDurations(events, commands) {
|
|
863
|
+
return events
|
|
864
|
+
.filter(e => e.type === 'stage_end' && commands.includes(e.command) && e.duration_ms != null)
|
|
865
|
+
.reduce((sum, e) => sum + e.duration_ms, 0);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function sumAttemptDurations(attempts, commands) {
|
|
869
|
+
return attempts
|
|
870
|
+
.filter(attempt => commands.includes(attempt.command))
|
|
871
|
+
.reduce((sum, attempt) => sum + (Number.isFinite(attempt.duration_ms) ? attempt.duration_ms : 0), 0);
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function firstByTimestamp(events) {
|
|
875
|
+
return [...events].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime())[0] || null;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function latestByTimestamp(events) {
|
|
879
|
+
return [...events].sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] || null;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function timestampMs(event) {
|
|
883
|
+
const value = Date.parse(event?.timestamp || '');
|
|
884
|
+
return Number.isFinite(value) ? value : 0;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function stageAttemptKey(event) {
|
|
888
|
+
return [
|
|
889
|
+
event?.command || event?.stage || 'unknown',
|
|
890
|
+
event?.capability || '',
|
|
891
|
+
].join('|');
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function createReworkBucket(command, capability) {
|
|
895
|
+
return {
|
|
896
|
+
command,
|
|
897
|
+
capability: capability || null,
|
|
898
|
+
total_attempts: 0,
|
|
899
|
+
canonical_event_id: null,
|
|
900
|
+
successful_attempts: 0,
|
|
901
|
+
rework_attempts: 0,
|
|
902
|
+
completed_rework_attempts: 0,
|
|
903
|
+
superseded_open_stages: 0,
|
|
904
|
+
unresolved_open_stages: 0,
|
|
905
|
+
rework_duration_ms: 0,
|
|
906
|
+
reasons_by_category: createReasonCategory(),
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function summarizeStageExecutions(events) {
|
|
911
|
+
const stageEvents = events.filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end'));
|
|
912
|
+
const starts = stageEvents
|
|
913
|
+
.filter(e => e.type === 'stage_start')
|
|
914
|
+
.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
915
|
+
const ends = stageEvents
|
|
916
|
+
.filter(e => e.type === 'stage_end')
|
|
917
|
+
.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
918
|
+
const endsById = new Map();
|
|
919
|
+
for (const end of ends) {
|
|
920
|
+
if (!endsById.has(end.event_id)) endsById.set(end.event_id, []);
|
|
921
|
+
endsById.get(end.event_id).push(end);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
const attempts = starts.map(start => {
|
|
925
|
+
const end = latestByTimestamp(endsById.get(start.event_id) || []);
|
|
926
|
+
return {
|
|
927
|
+
key: stageAttemptKey(start),
|
|
928
|
+
command: start.command || start.stage || 'unknown',
|
|
929
|
+
capability: start.capability || null,
|
|
930
|
+
start,
|
|
931
|
+
end,
|
|
932
|
+
result: end?.result || null,
|
|
933
|
+
duration_ms: Number.isFinite(end?.duration_ms) ? end.duration_ms : 0,
|
|
934
|
+
canonical: false,
|
|
935
|
+
rework_reason: null,
|
|
936
|
+
};
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
const groups = new Map();
|
|
940
|
+
for (const attempt of attempts) {
|
|
941
|
+
if (!groups.has(attempt.key)) groups.set(attempt.key, []);
|
|
942
|
+
groups.get(attempt.key).push(attempt);
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
const canonicalAttempts = [];
|
|
946
|
+
const reworkAttempts = [];
|
|
947
|
+
const byStage = {};
|
|
948
|
+
let effectiveStageDurationMs = 0;
|
|
949
|
+
let reworkStageDurationMs = 0;
|
|
950
|
+
let completedReworkAttempts = 0;
|
|
951
|
+
let supersededOpenStages = 0;
|
|
952
|
+
let unresolvedOpenStages = 0;
|
|
953
|
+
|
|
954
|
+
for (const groupAttempts of groups.values()) {
|
|
955
|
+
const successfulAttempts = groupAttempts.filter(attempt => attempt.end && attempt.result === 'success');
|
|
956
|
+
const canonical = latestByTimestamp(successfulAttempts.map(attempt => attempt.end))
|
|
957
|
+
? successfulAttempts.find(attempt => {
|
|
958
|
+
const latestEnd = latestByTimestamp(successfulAttempts.map(item => item.end));
|
|
959
|
+
return attempt.end === latestEnd;
|
|
960
|
+
})
|
|
961
|
+
: null;
|
|
962
|
+
if (canonical) {
|
|
963
|
+
canonical.canonical = true;
|
|
964
|
+
canonicalAttempts.push(canonical);
|
|
965
|
+
effectiveStageDurationMs += canonical.duration_ms;
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
for (const attempt of groupAttempts) {
|
|
969
|
+
const bucketKey = stageAttemptKey(attempt.start);
|
|
970
|
+
if (!byStage[bucketKey]) {
|
|
971
|
+
byStage[bucketKey] = createReworkBucket(attempt.command, attempt.capability);
|
|
972
|
+
}
|
|
973
|
+
const bucket = byStage[bucketKey];
|
|
974
|
+
bucket.total_attempts += 1;
|
|
975
|
+
if (attempt.result === 'success') bucket.successful_attempts += 1;
|
|
976
|
+
if (canonical) bucket.canonical_event_id = canonical.start.event_id;
|
|
977
|
+
|
|
978
|
+
if (attempt === canonical) continue;
|
|
979
|
+
|
|
980
|
+
if (canonical && timestampMs(attempt.start) <= timestampMs(canonical.end || canonical.start)) {
|
|
981
|
+
if (attempt.end && attempt.end.rework_reason) {
|
|
982
|
+
attempt.rework_reason = attempt.end.rework_reason;
|
|
983
|
+
} else if (!attempt.end) {
|
|
984
|
+
attempt.rework_reason = 'incomplete';
|
|
985
|
+
} else if (attempt.result === 'failure') {
|
|
986
|
+
attempt.rework_reason = 'prev-failed';
|
|
987
|
+
} else {
|
|
988
|
+
attempt.rework_reason = 'completed_rework_success';
|
|
989
|
+
}
|
|
990
|
+
reworkAttempts.push(attempt);
|
|
991
|
+
bucket.rework_attempts += 1;
|
|
992
|
+
if (attempt.end) {
|
|
993
|
+
completedReworkAttempts += 1;
|
|
994
|
+
bucket.completed_rework_attempts += 1;
|
|
995
|
+
bucket.rework_duration_ms += attempt.duration_ms;
|
|
996
|
+
reworkStageDurationMs += attempt.duration_ms;
|
|
997
|
+
} else {
|
|
998
|
+
supersededOpenStages += 1;
|
|
999
|
+
bucket.superseded_open_stages += 1;
|
|
1000
|
+
}
|
|
1001
|
+
} else if (!attempt.end) {
|
|
1002
|
+
attempt.rework_reason = 'unresolved_open';
|
|
1003
|
+
unresolvedOpenStages += 1;
|
|
1004
|
+
bucket.unresolved_open_stages += 1;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return {
|
|
1010
|
+
attempts,
|
|
1011
|
+
canonicalAttempts,
|
|
1012
|
+
reworkAttempts,
|
|
1013
|
+
summary: {
|
|
1014
|
+
total_attempts: attempts.length,
|
|
1015
|
+
canonical_attempts: canonicalAttempts.length,
|
|
1016
|
+
total_rework_attempts: reworkAttempts.length,
|
|
1017
|
+
completed_rework_attempts: completedReworkAttempts,
|
|
1018
|
+
superseded_open_stages: supersededOpenStages,
|
|
1019
|
+
unresolved_open_stages: unresolvedOpenStages,
|
|
1020
|
+
effective_stage_duration_ms: effectiveStageDurationMs,
|
|
1021
|
+
rework_stage_duration_ms: reworkStageDurationMs,
|
|
1022
|
+
total_stage_duration_ms: effectiveStageDurationMs + reworkStageDurationMs,
|
|
1023
|
+
by_stage: Object.values(byStage),
|
|
1024
|
+
},
|
|
1025
|
+
};
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
function getCanonicalAttempt(executionSummary, command, capability) {
|
|
1029
|
+
const candidates = executionSummary.canonicalAttempts.filter(attempt => {
|
|
1030
|
+
if (attempt.command !== command) return false;
|
|
1031
|
+
if (capability && attempt.capability !== capability) return false;
|
|
1032
|
+
return true;
|
|
1033
|
+
});
|
|
1034
|
+
return latestByTimestamp(candidates.map(attempt => attempt.end))
|
|
1035
|
+
? candidates.find(attempt => {
|
|
1036
|
+
const latestEnd = latestByTimestamp(candidates.map(item => item.end));
|
|
1037
|
+
return attempt.end === latestEnd;
|
|
1038
|
+
})
|
|
1039
|
+
: null;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function hasOfficialSource(event) {
|
|
1043
|
+
return !event?.source || ['opsx-command', 'manual', 'ci'].includes(event.source);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function getFormalStageRelatedEvents(events, attempt, predicate) {
|
|
1047
|
+
const candidates = events
|
|
1048
|
+
.filter(e => !e.orphan)
|
|
1049
|
+
.filter(predicate)
|
|
1050
|
+
.filter(e => {
|
|
1051
|
+
if (attempt.capability && e.capability && e.capability !== attempt.capability) return false;
|
|
1052
|
+
return true;
|
|
1053
|
+
})
|
|
1054
|
+
.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
1055
|
+
if (!attempt || candidates.length === 0) return [];
|
|
1056
|
+
|
|
1057
|
+
const sessionId = attempt.start.session_id || attempt.end?.session_id;
|
|
1058
|
+
if (sessionId) {
|
|
1059
|
+
const sessionMatches = candidates.filter(e => e.session_id === sessionId);
|
|
1060
|
+
const officialSessionMatches = sessionMatches.filter(hasOfficialSource);
|
|
1061
|
+
if (officialSessionMatches.length > 0) return officialSessionMatches;
|
|
1062
|
+
if (sessionMatches.length > 0) return sessionMatches;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const startMs = timestampMs(attempt.start);
|
|
1066
|
+
const afterStart = candidates.filter(e => timestampMs(e) >= startMs);
|
|
1067
|
+
const officialAfterStart = afterStart.filter(hasOfficialSource);
|
|
1068
|
+
if (officialAfterStart.length > 0) return officialAfterStart;
|
|
1069
|
+
if (afterStart.length > 0) return afterStart;
|
|
1070
|
+
|
|
1071
|
+
const endMs = timestampMs(attempt.end);
|
|
1072
|
+
const beforeEnd = candidates.filter(e => timestampMs(e) <= endMs);
|
|
1073
|
+
const officialBeforeEnd = beforeEnd.filter(hasOfficialSource);
|
|
1074
|
+
if (officialBeforeEnd.length > 0) return [latestByTimestamp(officialBeforeEnd)];
|
|
1075
|
+
if (beforeEnd.length > 0) return [latestByTimestamp(beforeEnd)];
|
|
1076
|
+
return [];
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
function compactReworkSummary(summary, reasons) {
|
|
1080
|
+
return {
|
|
1081
|
+
total_attempts: summary.total_attempts,
|
|
1082
|
+
canonical_attempts: summary.canonical_attempts,
|
|
1083
|
+
total_rework_attempts: summary.total_rework_attempts,
|
|
1084
|
+
completed_rework_attempts: summary.completed_rework_attempts,
|
|
1085
|
+
superseded_open_stages: summary.superseded_open_stages,
|
|
1086
|
+
unresolved_open_stages: summary.unresolved_open_stages,
|
|
1087
|
+
effective_stage_duration_ms: summary.effective_stage_duration_ms,
|
|
1088
|
+
rework_stage_duration_ms: summary.rework_stage_duration_ms,
|
|
1089
|
+
total_stage_duration_ms: summary.total_stage_duration_ms,
|
|
1090
|
+
by_stage: summary.by_stage,
|
|
1091
|
+
reasons: reasons || { by_category: createReasonCategory(), details: [] },
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// report-time git 推导返工原因:对 summarizeStageExecutions 产出的 reworkAttempts 细化
|
|
1096
|
+
// - agent override 优先(stage_end.rework_reason 由 cmdEnd --reason 写入,summarizeStageExecutions 已保留)
|
|
1097
|
+
// - completed_rework_success(上次成功却重跑)按 git 推导:有 commit since end → code-changed-after-pass;
|
|
1098
|
+
// 无 commit 但 working tree 未提交 → code-changed-after-pass(has_uncommitted);都无 → recheck-no-change
|
|
1099
|
+
// - no git repo → unspecified
|
|
1100
|
+
// 注:用 `git log --all --since` 取所有 ref 上 commit date >= end.timestamp 的提交;reset 掉的 unreachable
|
|
1101
|
+
// commit 不计入(完全实现需 reflog,复杂且会 expire,本期务实按 ref 可达)
|
|
1102
|
+
function deriveReworkReasons(executionSummary, projectRoot) {
|
|
1103
|
+
const byCategory = createReasonCategory();
|
|
1104
|
+
const details = [];
|
|
1105
|
+
const reworkAttempts = executionSummary.reworkAttempts || [];
|
|
1106
|
+
const gitAvailable = projectRoot ? runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']) === 'true' : false;
|
|
1107
|
+
let uncommittedCache = null;
|
|
1108
|
+
const hasUncommitted = () => {
|
|
1109
|
+
if (uncommittedCache !== null) return uncommittedCache;
|
|
1110
|
+
if (!gitAvailable) { uncommittedCache = false; return false; }
|
|
1111
|
+
const status = runGit(projectRoot, ['status', '--porcelain']);
|
|
1112
|
+
uncommittedCache = Boolean(status && status.trim());
|
|
1113
|
+
return uncommittedCache;
|
|
1114
|
+
};
|
|
1115
|
+
const bucketByKey = new Map();
|
|
1116
|
+
for (const bucket of executionSummary.summary.by_stage || []) {
|
|
1117
|
+
if (!bucket.reasons_by_category) bucket.reasons_by_category = createReasonCategory();
|
|
1118
|
+
bucketByKey.set(`${bucket.command}|${bucket.capability || ''}`, bucket);
|
|
1119
|
+
}
|
|
1120
|
+
for (const attempt of reworkAttempts) {
|
|
1121
|
+
let reason = attempt.rework_reason;
|
|
1122
|
+
let detail = null;
|
|
1123
|
+
let hasUncommittedFlag = false;
|
|
1124
|
+
if (reason === 'completed_rework_success') {
|
|
1125
|
+
if (!gitAvailable) {
|
|
1126
|
+
reason = 'unspecified';
|
|
1127
|
+
} else {
|
|
1128
|
+
const endTs = attempt.end && attempt.end.timestamp ? Date.parse(attempt.end.timestamp) : null;
|
|
1129
|
+
let recentCommitMessage = null;
|
|
1130
|
+
if (endTs !== null && Number.isFinite(endTs)) {
|
|
1131
|
+
const sinceIso = new Date(endTs).toISOString();
|
|
1132
|
+
const log = runGit(projectRoot, ['log', '--all', '--since', sinceIso, '--format=%H%x09%cI%x09%s']);
|
|
1133
|
+
if (log) {
|
|
1134
|
+
const lines = log.split(/\r?\n/).filter(Boolean);
|
|
1135
|
+
if (lines.length > 0) {
|
|
1136
|
+
const parts = lines[0].split('\t');
|
|
1137
|
+
recentCommitMessage = parts[2] || '';
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
const uncommitted = hasUncommitted();
|
|
1142
|
+
if (recentCommitMessage !== null) {
|
|
1143
|
+
reason = 'code-changed-after-pass';
|
|
1144
|
+
detail = recentCommitMessage;
|
|
1145
|
+
hasUncommittedFlag = uncommitted;
|
|
1146
|
+
} else if (uncommitted) {
|
|
1147
|
+
reason = 'code-changed-after-pass';
|
|
1148
|
+
hasUncommittedFlag = true;
|
|
1149
|
+
} else {
|
|
1150
|
+
reason = 'recheck-no-change';
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
attempt.rework_reason = reason;
|
|
1155
|
+
byCategory[reason] = (byCategory[reason] || 0) + 1;
|
|
1156
|
+
const bucket = bucketByKey.get(`${attempt.command}|${attempt.capability || ''}`);
|
|
1157
|
+
if (bucket) {
|
|
1158
|
+
bucket.reasons_by_category[reason] = (bucket.reasons_by_category[reason] || 0) + 1;
|
|
1159
|
+
}
|
|
1160
|
+
details.push({
|
|
1161
|
+
command: attempt.command,
|
|
1162
|
+
attempt_event_id: (attempt.start && attempt.start.event_id) || null,
|
|
1163
|
+
reason,
|
|
1164
|
+
detail,
|
|
1165
|
+
has_uncommitted: hasUncommittedFlag,
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
return { by_category: byCategory, details };
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
function readSuccessSignal(event, detailKeys) {
|
|
1172
|
+
if (!event) return null;
|
|
1173
|
+
if (event.result === 'success') return true;
|
|
1174
|
+
if (event.result === 'failure') return false;
|
|
1175
|
+
for (const key of detailKeys) {
|
|
1176
|
+
const value = event.details?.[key];
|
|
1177
|
+
if (typeof value?.success === 'boolean') return value.success;
|
|
1178
|
+
if (typeof value?.passed === 'boolean') return value.passed;
|
|
1179
|
+
}
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
function getCheckResults(event) {
|
|
1184
|
+
const results = event?.details?.check_results;
|
|
1185
|
+
if (!results) return null;
|
|
1186
|
+
return {
|
|
1187
|
+
total: Number.isFinite(results.total) ? results.total : null,
|
|
1188
|
+
errors: Number.isFinite(results.errors) ? results.errors : 0,
|
|
1189
|
+
warnings: Number.isFinite(results.warnings) ? results.warnings : 0,
|
|
1190
|
+
suggestions: Number.isFinite(results.suggestions) ? results.suggestions : 0,
|
|
1191
|
+
fixed_before_apply: Number.isFinite(results.fixed_before_apply) ? results.fixed_before_apply : null,
|
|
1192
|
+
consistency_score: Number.isFinite(results.consistency_score) ? results.consistency_score : null,
|
|
1193
|
+
categories: results.categories || {},
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function getCheckEvents(events) {
|
|
1198
|
+
return events.filter(e => {
|
|
1199
|
+
return e.type === 'check_result'
|
|
1200
|
+
|| Boolean(e.details?.check_results)
|
|
1201
|
+
|| (e.command === 'check' && e.type === 'stage_end');
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
function getBuildResults(event) {
|
|
1206
|
+
const results = event?.details?.build_results || event?.details?.build;
|
|
1207
|
+
if (!results) return null;
|
|
1208
|
+
return {
|
|
1209
|
+
command: results.command || null,
|
|
1210
|
+
success: typeof results.success === 'boolean'
|
|
1211
|
+
? results.success
|
|
1212
|
+
: (event.result === 'success' ? true : (event.result === 'failure' ? false : null)),
|
|
1213
|
+
duration_ms: Number.isFinite(results.duration_ms) ? results.duration_ms : null,
|
|
1214
|
+
error_count: Number.isFinite(results.error_count) ? results.error_count : null,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
function getBuildEvents(events) {
|
|
1219
|
+
return events.filter(e => {
|
|
1220
|
+
return e.type === 'build_result'
|
|
1221
|
+
|| Boolean(e.details?.build_results)
|
|
1222
|
+
|| Boolean(e.details?.build);
|
|
1223
|
+
});
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function getTestResults(event) {
|
|
1227
|
+
const results = event?.details?.test_results || event?.details?.test;
|
|
1228
|
+
if (!results) return null;
|
|
1229
|
+
return {
|
|
1230
|
+
command: results.command || null,
|
|
1231
|
+
passed: Number.isFinite(results.passed) ? results.passed : 0,
|
|
1232
|
+
failed: Number.isFinite(results.failed) ? results.failed : 0,
|
|
1233
|
+
skipped: Number.isFinite(results.skipped) ? results.skipped : 0,
|
|
1234
|
+
coverage: Number.isFinite(results.coverage) ? results.coverage : null,
|
|
1235
|
+
duration_ms: Number.isFinite(results.duration_ms) ? results.duration_ms : null,
|
|
1236
|
+
};
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function getTestEvents(events) {
|
|
1240
|
+
return events.filter(e => {
|
|
1241
|
+
return e.type === 'test_result'
|
|
1242
|
+
|| Boolean(e.details?.test_results)
|
|
1243
|
+
|| Boolean(e.details?.test)
|
|
1244
|
+
|| (e.command === 'test' && e.type === 'stage_end');
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
function getTestPassRate(event) {
|
|
1249
|
+
const results = getTestResults(event);
|
|
1250
|
+
if (!results) return null;
|
|
1251
|
+
const total = results.passed + results.failed + results.skipped;
|
|
1252
|
+
if (total === 0) return null;
|
|
1253
|
+
return results.passed / total;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
function getTaskUpdateResult(event) {
|
|
1257
|
+
if (!event || event.type !== 'task_update') return null;
|
|
1258
|
+
const build = getBuildResults(event);
|
|
1259
|
+
const test = getTestResults(event);
|
|
1260
|
+
const buildOk = build?.success;
|
|
1261
|
+
const testOk = test ? test.failed === 0 : null;
|
|
1262
|
+
const resultOk = event.result === 'success'
|
|
1263
|
+
? true
|
|
1264
|
+
: (event.result === 'failure' ? false : null);
|
|
1265
|
+
const signals = [buildOk, testOk, resultOk].filter(value => value != null);
|
|
1266
|
+
return {
|
|
1267
|
+
task_id: event.task_id || null,
|
|
1268
|
+
success: signals.length > 0 ? signals.every(Boolean) : null,
|
|
1269
|
+
build,
|
|
1270
|
+
test,
|
|
1271
|
+
};
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
function computeAiFirstPassMetrics(events) {
|
|
1275
|
+
const taskEvents = events
|
|
1276
|
+
.filter(e => e.type === 'task_update' && e.task_id)
|
|
1277
|
+
.sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
|
|
1278
|
+
const firstByTask = new Map();
|
|
1279
|
+
for (const event of taskEvents) {
|
|
1280
|
+
if (!firstByTask.has(event.task_id)) {
|
|
1281
|
+
firstByTask.set(event.task_id, event);
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
const taskResults = Array.from(firstByTask.values())
|
|
1286
|
+
.map(getTaskUpdateResult)
|
|
1287
|
+
.filter(result => result?.success != null);
|
|
1288
|
+
if (taskResults.length === 0) {
|
|
1289
|
+
return {
|
|
1290
|
+
e4_ai_code_first_pass_rate: null,
|
|
1291
|
+
first_pass_tasks: 0,
|
|
1292
|
+
measured_tasks: 0,
|
|
1293
|
+
failed_tasks: [],
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const passedTasks = taskResults.filter(result => result.success);
|
|
1298
|
+
return {
|
|
1299
|
+
e4_ai_code_first_pass_rate: roundMetric(passedTasks.length / taskResults.length),
|
|
1300
|
+
first_pass_tasks: passedTasks.length,
|
|
1301
|
+
measured_tasks: taskResults.length,
|
|
1302
|
+
failed_tasks: taskResults.filter(result => !result.success).map(result => result.task_id).filter(Boolean),
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
function normalizeScenarioCoverageStatus(value) {
|
|
1307
|
+
if (!value) return null;
|
|
1308
|
+
const normalized = String(value).toLowerCase();
|
|
1309
|
+
if (['covered', 'partial', 'uncovered'].includes(normalized)) {
|
|
1310
|
+
return normalized;
|
|
1311
|
+
}
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
function getSpecTestCoverage(event) {
|
|
1316
|
+
const coverage = event?.details?.spec_test_coverage || event?.details?.scenario_coverage;
|
|
1317
|
+
if (!coverage) return null;
|
|
1318
|
+
|
|
1319
|
+
const mappings = Array.isArray(coverage.mappings) ? coverage.mappings : [];
|
|
1320
|
+
const normalizedMappings = mappings.map((mapping, index) => {
|
|
1321
|
+
const status = normalizeScenarioCoverageStatus(mapping.status);
|
|
1322
|
+
return {
|
|
1323
|
+
scenario_id: mapping.scenario_id || mapping.id || `SCENARIO-${index + 1}`,
|
|
1324
|
+
description: mapping.description || '',
|
|
1325
|
+
test_ids: Array.isArray(mapping.test_ids) ? mapping.test_ids : [],
|
|
1326
|
+
status,
|
|
1327
|
+
notes: mapping.notes || '',
|
|
1328
|
+
};
|
|
1329
|
+
}).filter(mapping => mapping.status);
|
|
1330
|
+
|
|
1331
|
+
const mappedCounts = {
|
|
1332
|
+
covered: normalizedMappings.filter(m => m.status === 'covered').length,
|
|
1333
|
+
partial: normalizedMappings.filter(m => m.status === 'partial').length,
|
|
1334
|
+
uncovered: normalizedMappings.filter(m => m.status === 'uncovered').length,
|
|
1335
|
+
};
|
|
1336
|
+
const mappedTotal = mappedCounts.covered + mappedCounts.partial + mappedCounts.uncovered;
|
|
1337
|
+
const totalScenarios = Number.isFinite(coverage.total_scenarios)
|
|
1338
|
+
? coverage.total_scenarios
|
|
1339
|
+
: mappedTotal;
|
|
1340
|
+
const coveredScenarios = Number.isFinite(coverage.covered_scenarios)
|
|
1341
|
+
? coverage.covered_scenarios
|
|
1342
|
+
: mappedCounts.covered;
|
|
1343
|
+
const partialScenarios = Number.isFinite(coverage.partial_scenarios)
|
|
1344
|
+
? coverage.partial_scenarios
|
|
1345
|
+
: mappedCounts.partial;
|
|
1346
|
+
const uncoveredScenarios = Number.isFinite(coverage.uncovered_scenarios)
|
|
1347
|
+
? coverage.uncovered_scenarios
|
|
1348
|
+
: mappedCounts.uncovered;
|
|
1349
|
+
const computedRate = totalScenarios > 0
|
|
1350
|
+
? roundMetric((coveredScenarios + partialScenarios * 0.5) / totalScenarios)
|
|
1351
|
+
: null;
|
|
1352
|
+
|
|
1353
|
+
return {
|
|
1354
|
+
total_scenarios: totalScenarios,
|
|
1355
|
+
covered_scenarios: coveredScenarios,
|
|
1356
|
+
partial_scenarios: partialScenarios,
|
|
1357
|
+
uncovered_scenarios: uncoveredScenarios,
|
|
1358
|
+
coverage_rate: Number.isFinite(coverage.coverage_rate) ? coverage.coverage_rate : computedRate,
|
|
1359
|
+
mappings: normalizedMappings,
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function getSpecTestCoverageEvents(events) {
|
|
1364
|
+
return events.filter(e => {
|
|
1365
|
+
return e.type === 'coverage_result'
|
|
1366
|
+
|| Boolean(e.details?.spec_test_coverage)
|
|
1367
|
+
|| Boolean(e.details?.scenario_coverage);
|
|
1368
|
+
});
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function computeSpecTestCoverageMetrics(events) {
|
|
1372
|
+
const coverageEvents = getSpecTestCoverageEvents(events);
|
|
1373
|
+
const latestCoverageEvent = latestByTimestamp(coverageEvents);
|
|
1374
|
+
const coverage = getSpecTestCoverage(latestCoverageEvent);
|
|
1375
|
+
if (!coverage) {
|
|
1376
|
+
return {
|
|
1377
|
+
q4_spec_driven_test_coverage: null,
|
|
1378
|
+
scenario_counts: { total: 0, covered: 0, partial: 0, uncovered: 0 },
|
|
1379
|
+
latest_review_at: null,
|
|
1380
|
+
};
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
q4_spec_driven_test_coverage: coverage.coverage_rate,
|
|
1385
|
+
scenario_counts: {
|
|
1386
|
+
total: coverage.total_scenarios,
|
|
1387
|
+
covered: coverage.covered_scenarios,
|
|
1388
|
+
partial: coverage.partial_scenarios,
|
|
1389
|
+
uncovered: coverage.uncovered_scenarios,
|
|
1390
|
+
},
|
|
1391
|
+
latest_review_at: latestCoverageEvent.timestamp || null,
|
|
1392
|
+
};
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
function normalizeConformanceStatus(value) {
|
|
1396
|
+
if (!value) return null;
|
|
1397
|
+
const normalized = String(value).toLowerCase();
|
|
1398
|
+
if (['matched', 'partial', 'missed'].includes(normalized)) {
|
|
1399
|
+
return normalized;
|
|
1400
|
+
}
|
|
1401
|
+
return null;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
function getConformanceReview(event) {
|
|
1405
|
+
const review = event?.details?.conformance_review || event?.details?.conformance;
|
|
1406
|
+
if (!review) return null;
|
|
1407
|
+
|
|
1408
|
+
const assertions = Array.isArray(review.assertions) ? review.assertions : [];
|
|
1409
|
+
const normalizedAssertions = assertions.map((assertion, index) => {
|
|
1410
|
+
const judgeStatus = normalizeConformanceStatus(assertion.judge_status);
|
|
1411
|
+
const humanStatus = normalizeConformanceStatus(assertion.human_status);
|
|
1412
|
+
const status = normalizeConformanceStatus(assertion.status) || humanStatus || judgeStatus;
|
|
1413
|
+
return {
|
|
1414
|
+
id: assertion.id || `ASSERT-${index + 1}`,
|
|
1415
|
+
description: assertion.description || assertion.text || '',
|
|
1416
|
+
status,
|
|
1417
|
+
judge_status: judgeStatus,
|
|
1418
|
+
human_status: humanStatus,
|
|
1419
|
+
evidence: assertion.evidence || '',
|
|
1420
|
+
files: Array.isArray(assertion.files) ? assertion.files : [],
|
|
1421
|
+
notes: assertion.notes || '',
|
|
1422
|
+
};
|
|
1423
|
+
}).filter(assertion => assertion.status);
|
|
1424
|
+
|
|
1425
|
+
const matched = normalizedAssertions.filter(a => a.status === 'matched').length;
|
|
1426
|
+
const partial = normalizedAssertions.filter(a => a.status === 'partial').length;
|
|
1427
|
+
const missed = normalizedAssertions.filter(a => a.status === 'missed').length;
|
|
1428
|
+
const total = matched + partial + missed;
|
|
1429
|
+
const computedScore = total > 0 ? roundMetric((matched + partial * 0.5) / total) : null;
|
|
1430
|
+
|
|
1431
|
+
return {
|
|
1432
|
+
total,
|
|
1433
|
+
matched,
|
|
1434
|
+
partial,
|
|
1435
|
+
missed,
|
|
1436
|
+
score: Number.isFinite(review.score) ? review.score : computedScore,
|
|
1437
|
+
method: review.method || null,
|
|
1438
|
+
reviewer: review.reviewer || null,
|
|
1439
|
+
manual_confirmed: review.manual_confirmed === true || normalizedAssertions.some(a => a.human_status),
|
|
1440
|
+
assertions: normalizedAssertions,
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function getConformanceReviewEvents(events) {
|
|
1445
|
+
return events.filter(e => {
|
|
1446
|
+
return e.type === 'conformance_review'
|
|
1447
|
+
|| Boolean(e.details?.conformance_review)
|
|
1448
|
+
|| Boolean(e.details?.conformance);
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
function computeConformanceMetrics(events) {
|
|
1453
|
+
const reviewEvents = getConformanceReviewEvents(events);
|
|
1454
|
+
const latestReviewEvent = latestByTimestamp(reviewEvents);
|
|
1455
|
+
const latestReview = getConformanceReview(latestReviewEvent);
|
|
1456
|
+
if (!latestReview) {
|
|
1457
|
+
return {
|
|
1458
|
+
q1_spec_conformance_score: null,
|
|
1459
|
+
conformance_counts: { total: 0, matched: 0, partial: 0, missed: 0 },
|
|
1460
|
+
manual_confirmed: false,
|
|
1461
|
+
latest_review_at: null,
|
|
1462
|
+
};
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
return {
|
|
1466
|
+
q1_spec_conformance_score: latestReview.score,
|
|
1467
|
+
conformance_counts: {
|
|
1468
|
+
total: latestReview.total,
|
|
1469
|
+
matched: latestReview.matched,
|
|
1470
|
+
partial: latestReview.partial,
|
|
1471
|
+
missed: latestReview.missed,
|
|
1472
|
+
},
|
|
1473
|
+
manual_confirmed: latestReview.manual_confirmed,
|
|
1474
|
+
latest_review_at: latestReviewEvent.timestamp || null,
|
|
1475
|
+
};
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
function getAiAdoptionReview(event) {
|
|
1479
|
+
const review = event?.details?.ai_adoption || event?.details?.ai_code_adoption;
|
|
1480
|
+
if (!review) return null;
|
|
1481
|
+
|
|
1482
|
+
const retainedLines = Number.isFinite(review.retained_lines) ? review.retained_lines : null;
|
|
1483
|
+
const rewrittenLines = Number.isFinite(review.rewritten_lines) ? review.rewritten_lines : null;
|
|
1484
|
+
const deletedLines = Number.isFinite(review.deleted_lines) ? review.deleted_lines : null;
|
|
1485
|
+
const denominator = [retainedLines, rewrittenLines, deletedLines]
|
|
1486
|
+
.filter(value => value != null)
|
|
1487
|
+
.reduce((sum, value) => sum + value, 0);
|
|
1488
|
+
const computedRate = retainedLines != null && denominator > 0
|
|
1489
|
+
? roundMetric(retainedLines / denominator)
|
|
1490
|
+
: null;
|
|
1491
|
+
const adoptionRate = Number.isFinite(review.adoption_rate)
|
|
1492
|
+
? review.adoption_rate
|
|
1493
|
+
: computedRate;
|
|
1494
|
+
let adoptionLevel = review.adoption_level || null;
|
|
1495
|
+
if (!adoptionLevel && adoptionRate != null) {
|
|
1496
|
+
if (adoptionRate >= 0.95) adoptionLevel = 'full';
|
|
1497
|
+
else if (adoptionRate >= 0.5) adoptionLevel = 'partial';
|
|
1498
|
+
else adoptionLevel = 'rewritten';
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
return {
|
|
1502
|
+
base_git_sha: review.base_git_sha || null,
|
|
1503
|
+
ai_git_sha: review.ai_git_sha || null,
|
|
1504
|
+
final_git_sha: review.final_git_sha || null,
|
|
1505
|
+
review_status: review.review_status || event.status || null,
|
|
1506
|
+
adoption_rate: adoptionRate,
|
|
1507
|
+
adoption_level: adoptionLevel,
|
|
1508
|
+
retained_lines: retainedLines,
|
|
1509
|
+
rewritten_lines: rewrittenLines,
|
|
1510
|
+
deleted_lines: deletedLines,
|
|
1511
|
+
ai_diff: review.ai_diff || {},
|
|
1512
|
+
final_diff: review.final_diff || {},
|
|
1513
|
+
notes: review.notes || '',
|
|
1514
|
+
};
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
function getAiAdoptionEvents(events) {
|
|
1518
|
+
return events.filter(e => {
|
|
1519
|
+
return e.type === 'ai_adoption_review'
|
|
1520
|
+
|| Boolean(e.details?.ai_adoption)
|
|
1521
|
+
|| Boolean(e.details?.ai_code_adoption);
|
|
1522
|
+
});
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
function computeAiAdoptionMetrics(events) {
|
|
1526
|
+
const adoptionEvents = getAiAdoptionEvents(events);
|
|
1527
|
+
const finalEvents = adoptionEvents.filter(e => {
|
|
1528
|
+
const review = getAiAdoptionReview(e);
|
|
1529
|
+
return review?.review_status === 'final' || e.status === 'final';
|
|
1530
|
+
});
|
|
1531
|
+
// 优先使用 final 事件,无 final 时 fallback 到 snapshot(AI 产出快照)
|
|
1532
|
+
const candidates = finalEvents.length > 0 ? finalEvents : adoptionEvents;
|
|
1533
|
+
const latestEvent = latestByTimestamp(candidates);
|
|
1534
|
+
const latestReview = getAiAdoptionReview(latestEvent);
|
|
1535
|
+
if (!latestReview) {
|
|
1536
|
+
return {
|
|
1537
|
+
p2_ai_code_adoption_rate: null,
|
|
1538
|
+
adoption_level: null,
|
|
1539
|
+
adoption_counts: { retained_lines: null, rewritten_lines: null, deleted_lines: null },
|
|
1540
|
+
latest_review_at: null,
|
|
1541
|
+
};
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
return {
|
|
1545
|
+
p2_ai_code_adoption_rate: latestReview.adoption_rate,
|
|
1546
|
+
adoption_level: latestReview.adoption_level,
|
|
1547
|
+
adoption_counts: {
|
|
1548
|
+
retained_lines: latestReview.retained_lines,
|
|
1549
|
+
rewritten_lines: latestReview.rewritten_lines,
|
|
1550
|
+
deleted_lines: latestReview.deleted_lines,
|
|
1551
|
+
},
|
|
1552
|
+
latest_review_at: latestEvent.timestamp || null,
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
function getSurveyResult(event) {
|
|
1557
|
+
const survey = event?.details?.survey_result || event?.details?.survey;
|
|
1558
|
+
if (!survey) return null;
|
|
1559
|
+
return {
|
|
1560
|
+
nps: Number.isFinite(survey.nps) ? survey.nps : null,
|
|
1561
|
+
cognitive_load: Number.isFinite(survey.cognitive_load) ? survey.cognitive_load : null,
|
|
1562
|
+
spec_fatigue_index: Number.isFinite(survey.spec_fatigue_index) ? survey.spec_fatigue_index : null,
|
|
1563
|
+
satisfaction: Number.isFinite(survey.satisfaction) ? survey.satisfaction : null,
|
|
1564
|
+
respondent_role: survey.respondent_role || null,
|
|
1565
|
+
collected_at: survey.collected_at || event.timestamp || null,
|
|
1566
|
+
source: event.source || 'manual',
|
|
1567
|
+
notes: survey.notes || '',
|
|
1568
|
+
};
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
function getSurveyEvents(events) {
|
|
1572
|
+
return events.filter(e => e.type === 'survey_result' || Boolean(e.details?.survey_result) || Boolean(e.details?.survey));
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
function getBaselineRecord(event) {
|
|
1576
|
+
const baseline = event?.details?.baseline_record || event?.details?.baseline;
|
|
1577
|
+
if (!baseline) return null;
|
|
1578
|
+
return {
|
|
1579
|
+
traditional_hours: Number.isFinite(baseline.traditional_hours) ? baseline.traditional_hours : null,
|
|
1580
|
+
sdd_hours: Number.isFinite(baseline.sdd_hours) ? baseline.sdd_hours : null,
|
|
1581
|
+
task_type: baseline.task_type || null,
|
|
1582
|
+
baseline_source: baseline.baseline_source || event.source || 'manual',
|
|
1583
|
+
collected_at: baseline.collected_at || event.timestamp || null,
|
|
1584
|
+
notes: baseline.notes || '',
|
|
1585
|
+
};
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
function getBaselineEvents(events) {
|
|
1589
|
+
return events.filter(e => e.type === 'baseline_record' || Boolean(e.details?.baseline_record) || Boolean(e.details?.baseline));
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
function computeManualInsightMetrics(events) {
|
|
1593
|
+
const surveyResults = getSurveyEvents(events).map(getSurveyResult).filter(Boolean);
|
|
1594
|
+
const baselineRecords = getBaselineEvents(events).map(getBaselineRecord).filter(Boolean);
|
|
1595
|
+
const latestSurveyEvent = latestByTimestamp(getSurveyEvents(events));
|
|
1596
|
+
const latestBaselineEvent = latestByTimestamp(getBaselineEvents(events));
|
|
1597
|
+
const latestSurvey = getSurveyResult(latestSurveyEvent);
|
|
1598
|
+
const latestBaseline = getBaselineRecord(latestBaselineEvent);
|
|
1599
|
+
const timeSavedRatio = latestBaseline?.traditional_hours > 0 && latestBaseline?.sdd_hours != null
|
|
1600
|
+
? roundMetric((latestBaseline.traditional_hours - latestBaseline.sdd_hours) / latestBaseline.traditional_hours)
|
|
1601
|
+
: null;
|
|
1602
|
+
|
|
1603
|
+
return {
|
|
1604
|
+
survey_count: surveyResults.length,
|
|
1605
|
+
baseline_count: baselineRecords.length,
|
|
1606
|
+
avg_nps: averageMetric(surveyResults, item => item.nps),
|
|
1607
|
+
avg_cognitive_load: averageMetric(surveyResults, item => item.cognitive_load),
|
|
1608
|
+
avg_spec_fatigue_index: averageMetric(surveyResults, item => item.spec_fatigue_index),
|
|
1609
|
+
latest_survey: latestSurvey,
|
|
1610
|
+
latest_baseline: latestBaseline,
|
|
1611
|
+
baseline_time_saved_ratio: timeSavedRatio,
|
|
1612
|
+
};
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
function runGit(projectRoot, args) {
|
|
1616
|
+
try {
|
|
1617
|
+
return execFileSync('git', args, {
|
|
1618
|
+
cwd: projectRoot,
|
|
1619
|
+
encoding: 'utf8',
|
|
1620
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1621
|
+
}).trim();
|
|
1622
|
+
} catch {
|
|
1623
|
+
return null;
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
function classifyOpenSpecDoc(filePath) {
|
|
1628
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
1629
|
+
const fileName = normalized.split('/').pop();
|
|
1630
|
+
if (fileName === 'proposal.md') return 'proposal';
|
|
1631
|
+
if (fileName === 'spec.md') return 'spec';
|
|
1632
|
+
if (fileName === 'design.md') return 'design';
|
|
1633
|
+
if (fileName === 'tasks.md' || fileName === 'task.md') return 'tasks';
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
function createDocFileMetrics() {
|
|
1638
|
+
return {
|
|
1639
|
+
proposal: { commit_count: 0, added_lines: 0, deleted_lines: 0 },
|
|
1640
|
+
spec: { commit_count: 0, added_lines: 0, deleted_lines: 0 },
|
|
1641
|
+
design: { commit_count: 0, added_lines: 0, deleted_lines: 0 },
|
|
1642
|
+
tasks: { commit_count: 0, added_lines: 0, deleted_lines: 0 },
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
|
|
1646
|
+
function computeGitDocumentMetrics(projectRoot, changeName) {
|
|
1647
|
+
const warnings = [];
|
|
1648
|
+
if (!projectRoot || !changeName) {
|
|
1649
|
+
return {
|
|
1650
|
+
git_available: false,
|
|
1651
|
+
spec_iteration_count: null,
|
|
1652
|
+
document_commit_count: null,
|
|
1653
|
+
total_added_lines: null,
|
|
1654
|
+
total_deleted_lines: null,
|
|
1655
|
+
files: createDocFileMetrics(),
|
|
1656
|
+
diff_trend: [],
|
|
1657
|
+
warnings: ['缺少 projectRoot 或 changeName,无法统计 Git 文档迭代'],
|
|
1658
|
+
};
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
const isRepo = runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']);
|
|
1662
|
+
if (isRepo !== 'true') {
|
|
1663
|
+
return {
|
|
1664
|
+
git_available: false,
|
|
1665
|
+
spec_iteration_count: null,
|
|
1666
|
+
document_commit_count: null,
|
|
1667
|
+
total_added_lines: null,
|
|
1668
|
+
total_deleted_lines: null,
|
|
1669
|
+
files: createDocFileMetrics(),
|
|
1670
|
+
diff_trend: [],
|
|
1671
|
+
warnings: ['当前项目不是 Git 仓库,无法统计文档迭代'],
|
|
1672
|
+
};
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
const changePath = `openspec/changes/${changeName}`;
|
|
1676
|
+
const output = runGit(projectRoot, [
|
|
1677
|
+
'log',
|
|
1678
|
+
'--numstat',
|
|
1679
|
+
'--format=commit:%H%x09%cI',
|
|
1680
|
+
'--',
|
|
1681
|
+
changePath,
|
|
1682
|
+
]);
|
|
1683
|
+
if (!output) {
|
|
1684
|
+
return {
|
|
1685
|
+
git_available: true,
|
|
1686
|
+
spec_iteration_count: null,
|
|
1687
|
+
document_commit_count: null,
|
|
1688
|
+
total_added_lines: 0,
|
|
1689
|
+
total_deleted_lines: 0,
|
|
1690
|
+
files: createDocFileMetrics(),
|
|
1691
|
+
diff_trend: [],
|
|
1692
|
+
warnings: [`未找到 ${changePath} 的 Git 提交历史`],
|
|
1693
|
+
};
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
const fileMetrics = createDocFileMetrics();
|
|
1697
|
+
const fileCommits = {
|
|
1698
|
+
proposal: new Set(),
|
|
1699
|
+
spec: new Set(),
|
|
1700
|
+
design: new Set(),
|
|
1701
|
+
tasks: new Set(),
|
|
1702
|
+
};
|
|
1703
|
+
const docCommits = new Set();
|
|
1704
|
+
const diffTrend = [];
|
|
1705
|
+
let currentCommit = null;
|
|
1706
|
+
|
|
1707
|
+
for (const line of output.split(/\r?\n/)) {
|
|
1708
|
+
if (!line.trim()) continue;
|
|
1709
|
+
if (line.startsWith('commit:')) {
|
|
1710
|
+
const [, hash, timestamp] = line.match(/^commit:([^\t]+)\t(.+)$/) || [];
|
|
1711
|
+
currentCommit = {
|
|
1712
|
+
commit: hash || line.slice('commit:'.length),
|
|
1713
|
+
timestamp: timestamp || null,
|
|
1714
|
+
added_lines: 0,
|
|
1715
|
+
deleted_lines: 0,
|
|
1716
|
+
files: [],
|
|
1717
|
+
};
|
|
1718
|
+
diffTrend.push(currentCommit);
|
|
1719
|
+
continue;
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
if (!currentCommit) continue;
|
|
1723
|
+
const [addedRaw, deletedRaw, filePath] = line.split('\t');
|
|
1724
|
+
const docType = filePath ? classifyOpenSpecDoc(filePath) : null;
|
|
1725
|
+
if (!docType) continue;
|
|
1726
|
+
|
|
1727
|
+
const added = addedRaw === '-' ? 0 : Number(addedRaw) || 0;
|
|
1728
|
+
const deleted = deletedRaw === '-' ? 0 : Number(deletedRaw) || 0;
|
|
1729
|
+
currentCommit.added_lines += added;
|
|
1730
|
+
currentCommit.deleted_lines += deleted;
|
|
1731
|
+
currentCommit.files.push({ path: filePath, doc_type: docType, added_lines: added, deleted_lines: deleted });
|
|
1732
|
+
|
|
1733
|
+
fileMetrics[docType].added_lines += added;
|
|
1734
|
+
fileMetrics[docType].deleted_lines += deleted;
|
|
1735
|
+
fileCommits[docType].add(currentCommit.commit);
|
|
1736
|
+
docCommits.add(currentCommit.commit);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
const filteredTrend = diffTrend.filter(item => item.files.length > 0);
|
|
1740
|
+
for (const [docType, commits] of Object.entries(fileCommits)) {
|
|
1741
|
+
fileMetrics[docType].commit_count = commits.size;
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (docCommits.size === 0) {
|
|
1745
|
+
warnings.push(`未找到 ${changePath} 下 proposal/spec/design/tasks 文档的提交历史`);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
return {
|
|
1749
|
+
git_available: true,
|
|
1750
|
+
spec_iteration_count: docCommits.size > 0 ? docCommits.size : null,
|
|
1751
|
+
document_commit_count: docCommits.size > 0 ? docCommits.size : null,
|
|
1752
|
+
total_added_lines: filteredTrend.reduce((sum, item) => sum + item.added_lines, 0),
|
|
1753
|
+
total_deleted_lines: filteredTrend.reduce((sum, item) => sum + item.deleted_lines, 0),
|
|
1754
|
+
files: fileMetrics,
|
|
1755
|
+
diff_trend: filteredTrend,
|
|
1756
|
+
warnings,
|
|
1757
|
+
};
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1760
|
+
function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
1761
|
+
const scopedEvents = events
|
|
1762
|
+
.filter(e => !e.orphan)
|
|
1763
|
+
.filter(e => !options.change || e.change === options.change)
|
|
1764
|
+
.filter(e => !options.capability || e.capability === options.capability);
|
|
1765
|
+
const executionSummary = summarizeStageExecutions(scopedEvents);
|
|
1766
|
+
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
|
|
1767
|
+
const starts = executionSummary.canonicalAttempts.map(attempt => attempt.start);
|
|
1768
|
+
const ends = executionSummary.canonicalAttempts.map(attempt => attempt.end).filter(Boolean);
|
|
1769
|
+
|
|
1770
|
+
const proposeStart = firstByTimestamp(starts.filter(e => e.command === 'propose'));
|
|
1771
|
+
const archiveEnd = latestByTimestamp(ends.filter(e => e.command === 'archive'));
|
|
1772
|
+
const leadTime = proposeStart && archiveEnd
|
|
1773
|
+
? new Date(archiveEnd.timestamp).getTime() - new Date(proposeStart.timestamp).getTime()
|
|
1774
|
+
: null;
|
|
1775
|
+
|
|
1776
|
+
const totalStageDuration = executionSummary.summary.effective_stage_duration_ms;
|
|
1777
|
+
const codingDuration = sumDurations(ends, ['apply']);
|
|
1778
|
+
const specDuration = sumDurations(ends, ['propose', 'spec', 'design', 'task']);
|
|
1779
|
+
const reworkCodingDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['apply']);
|
|
1780
|
+
const reworkSpecDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['propose', 'spec', 'design', 'task']);
|
|
1781
|
+
|
|
1782
|
+
const formalApplyAttempt = getCanonicalAttempt(executionSummary, 'apply', options.capability);
|
|
1783
|
+
const formalApplyEvents = formalApplyAttempt
|
|
1784
|
+
? getFormalStageRelatedEvents(scopedEvents, formalApplyAttempt, e => e.command === 'apply' || e.stage === 'apply')
|
|
1785
|
+
: [];
|
|
1786
|
+
const buildEvents = formalApplyAttempt
|
|
1787
|
+
? getFormalStageRelatedEvents(scopedEvents, formalApplyAttempt, e => getBuildEvents([e]).length > 0)
|
|
1788
|
+
: getBuildEvents(scopedEvents);
|
|
1789
|
+
const firstBuild = firstByTimestamp(buildEvents);
|
|
1790
|
+
const firstBuildSuccess = getBuildResults(firstBuild)?.success ?? readSuccessSignal(firstBuild, ['build_results', 'build']);
|
|
1791
|
+
|
|
1792
|
+
const checkEvents = getCheckEvents(scopedEvents);
|
|
1793
|
+
const latestCheckWithScore = latestByTimestamp(checkEvents.filter(e => {
|
|
1794
|
+
return getCheckResults(e)?.consistency_score != null;
|
|
1795
|
+
}));
|
|
1796
|
+
const latestCheckResults = getCheckResults(latestCheckWithScore);
|
|
1797
|
+
|
|
1798
|
+
const stageSpecIterationCount = starts.filter(e => {
|
|
1799
|
+
return ['propose', 'spec', 'design', 'task'].includes(e.command);
|
|
1800
|
+
}).length;
|
|
1801
|
+
const gitDocumentMetrics = options.projectRoot && options.change
|
|
1802
|
+
? computeGitDocumentMetrics(options.projectRoot, options.change)
|
|
1803
|
+
: null;
|
|
1804
|
+
const specIterationCount = gitDocumentMetrics?.spec_iteration_count != null
|
|
1805
|
+
? gitDocumentMetrics.spec_iteration_count
|
|
1806
|
+
: stageSpecIterationCount;
|
|
1807
|
+
|
|
1808
|
+
const firstApply = firstByTimestamp(starts.filter(e => e.command === 'apply'));
|
|
1809
|
+
const qualityGateBeforeApply = firstApply
|
|
1810
|
+
? checkEvents.some(e => new Date(e.timestamp).getTime() <= new Date(firstApply.timestamp).getTime())
|
|
1811
|
+
: null;
|
|
1812
|
+
const latestCheckWithFixRate = latestByTimestamp(checkEvents.filter(e => {
|
|
1813
|
+
const results = getCheckResults(e);
|
|
1814
|
+
return results?.total > 0 && results.fixed_before_apply != null;
|
|
1815
|
+
}));
|
|
1816
|
+
const fixRateResults = getCheckResults(latestCheckWithFixRate);
|
|
1817
|
+
const qualityGateRate = fixRateResults
|
|
1818
|
+
? roundMetric(fixRateResults.fixed_before_apply / fixRateResults.total)
|
|
1819
|
+
: (qualityGateBeforeApply == null ? null : (qualityGateBeforeApply ? 1 : 0));
|
|
1820
|
+
const conformanceMetrics = computeConformanceMetrics(scopedEvents);
|
|
1821
|
+
const aiAdoptionMetrics = computeAiAdoptionMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
|
|
1822
|
+
const aiFirstPassMetrics = computeAiFirstPassMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
|
|
1823
|
+
const specTestCoverageMetrics = computeSpecTestCoverageMetrics(scopedEvents);
|
|
1824
|
+
|
|
1825
|
+
return {
|
|
1826
|
+
efficiency: {
|
|
1827
|
+
e1_lead_time_ms: leadTime,
|
|
1828
|
+
e2_coding_time_ratio: totalStageDuration > 0 ? roundMetric(codingDuration / totalStageDuration) : null,
|
|
1829
|
+
e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(specDuration / totalStageDuration) : null,
|
|
1830
|
+
e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
|
|
1831
|
+
effective_stage_duration_ms: executionSummary.summary.effective_stage_duration_ms,
|
|
1832
|
+
rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
|
|
1833
|
+
total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
|
|
1834
|
+
e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
|
|
1835
|
+
? roundMetric((codingDuration + reworkCodingDuration) / executionSummary.summary.total_stage_duration_ms)
|
|
1836
|
+
: null,
|
|
1837
|
+
e3_spec_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
|
|
1838
|
+
? roundMetric((specDuration + reworkSpecDuration) / executionSummary.summary.total_stage_duration_ms)
|
|
1839
|
+
: null,
|
|
1840
|
+
},
|
|
1841
|
+
quality: {
|
|
1842
|
+
q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
|
|
1843
|
+
q3_build_first_pass_rate: firstBuildSuccess == null ? null : (firstBuildSuccess ? 1 : 0),
|
|
1844
|
+
q4_spec_driven_test_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
|
|
1845
|
+
q5_cross_doc_consistency_score: latestCheckResults?.consistency_score ?? null,
|
|
1846
|
+
conformance_counts: conformanceMetrics.conformance_counts,
|
|
1847
|
+
conformance_manual_confirmed: conformanceMetrics.manual_confirmed,
|
|
1848
|
+
spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
|
|
1849
|
+
},
|
|
1850
|
+
process: {
|
|
1851
|
+
p1_spec_iteration_count: specIterationCount,
|
|
1852
|
+
p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
|
|
1853
|
+
p2_ai_code_adoption_level: aiAdoptionMetrics.adoption_level,
|
|
1854
|
+
ai_adoption_counts: aiAdoptionMetrics.adoption_counts,
|
|
1855
|
+
p4_quality_gate_enforcement_rate: qualityGateRate,
|
|
1856
|
+
git_document_metrics: gitDocumentMetrics,
|
|
1857
|
+
rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
|
|
1858
|
+
},
|
|
1859
|
+
manual_insights: computeManualInsightMetrics(scopedEvents),
|
|
1860
|
+
telemetry_health: computeTelemetryHealthMetrics(scopedEvents),
|
|
1861
|
+
};
|
|
1862
|
+
}
|
|
1863
|
+
|
|
1864
|
+
function averageMetric(items, selector) {
|
|
1865
|
+
const values = items.map(selector).filter(v => v != null && !Number.isNaN(v));
|
|
1866
|
+
if (values.length === 0) return null;
|
|
1867
|
+
return roundMetric(values.reduce((sum, v) => sum + v, 0) / values.length);
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
function sumMetric(items, selector) {
|
|
1871
|
+
const values = items.map(selector).filter(v => v != null && !Number.isNaN(v));
|
|
1872
|
+
if (values.length === 0) return null;
|
|
1873
|
+
return values.reduce((sum, value) => sum + value, 0);
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function computeCapabilityMetrics(capabilityName, events, options = {}) {
|
|
1877
|
+
return computeSinglePdfMvpMetrics(events, {
|
|
1878
|
+
change: options.change,
|
|
1879
|
+
capability: capabilityName,
|
|
1880
|
+
projectRoot: options.projectRoot,
|
|
1881
|
+
});
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
function computeTelemetryHealthMetrics(events, options = {}) {
|
|
1885
|
+
const report = computeDoctorReport(events, options);
|
|
1886
|
+
return {
|
|
1887
|
+
telemetry_health_score: report.telemetry_health_score,
|
|
1888
|
+
matched_stage_rate: report.matched_stage_rate,
|
|
1889
|
+
open_stages: report.open_stages,
|
|
1890
|
+
superseded_open_stages: report.superseded_open_stages,
|
|
1891
|
+
orphan_events: report.orphan_events,
|
|
1892
|
+
unknown_command_events: report.unknown_command_events,
|
|
1893
|
+
rework_attempts: report.rework_attempts,
|
|
1894
|
+
warnings: report.warnings,
|
|
1895
|
+
};
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function computePdfMvpMetrics(events, options = {}) {
|
|
1899
|
+
if (options.level === 'capability' || options.capability) {
|
|
1900
|
+
return computeCapabilityMetrics(options.capability, events, options);
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
if (options.level === 'change' || options.change) {
|
|
1904
|
+
return computeSinglePdfMvpMetrics(events, {
|
|
1905
|
+
change: options.change,
|
|
1906
|
+
projectRoot: options.projectRoot,
|
|
1907
|
+
});
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
const changeNames = [...new Set(events.map(e => e.change).filter(Boolean))];
|
|
1911
|
+
const changeMetrics = changeNames
|
|
1912
|
+
.map(change => computeSinglePdfMvpMetrics(events, {
|
|
1913
|
+
change,
|
|
1914
|
+
projectRoot: options.projectRoot,
|
|
1915
|
+
}))
|
|
1916
|
+
.filter(Boolean);
|
|
1917
|
+
|
|
1918
|
+
return {
|
|
1919
|
+
efficiency: {
|
|
1920
|
+
e1_lead_time_ms: averageMetric(changeMetrics, m => m.efficiency.e1_lead_time_ms),
|
|
1921
|
+
e2_coding_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e2_coding_time_ratio),
|
|
1922
|
+
e3_spec_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e3_spec_time_ratio),
|
|
1923
|
+
e4_ai_code_first_pass_rate: averageMetric(changeMetrics, m => m.efficiency.e4_ai_code_first_pass_rate),
|
|
1924
|
+
},
|
|
1925
|
+
quality: {
|
|
1926
|
+
q1_spec_conformance_score: averageMetric(changeMetrics, m => m.quality.q1_spec_conformance_score),
|
|
1927
|
+
q3_build_first_pass_rate: averageMetric(changeMetrics, m => m.quality.q3_build_first_pass_rate),
|
|
1928
|
+
q4_spec_driven_test_coverage: averageMetric(changeMetrics, m => m.quality.q4_spec_driven_test_coverage),
|
|
1929
|
+
q5_cross_doc_consistency_score: averageMetric(changeMetrics, m => m.quality.q5_cross_doc_consistency_score),
|
|
1930
|
+
},
|
|
1931
|
+
process: {
|
|
1932
|
+
p1_spec_iteration_count: sumMetric(changeMetrics, m => m.process.p1_spec_iteration_count),
|
|
1933
|
+
p2_ai_code_adoption_rate: averageMetric(changeMetrics, m => m.process.p2_ai_code_adoption_rate),
|
|
1934
|
+
p4_quality_gate_enforcement_rate: averageMetric(changeMetrics, m => m.process.p4_quality_gate_enforcement_rate),
|
|
1935
|
+
},
|
|
1936
|
+
manual_insights: computeManualInsightMetrics(events.filter(e => !e.orphan)),
|
|
1937
|
+
telemetry_health: computeTelemetryHealthMetrics(events),
|
|
1938
|
+
};
|
|
1939
|
+
}
|
|
1940
|
+
|
|
1941
|
+
function renderPdfMvpMarkdown(metrics) {
|
|
1942
|
+
return [
|
|
1943
|
+
'# SDD PDF MVP Metrics',
|
|
1944
|
+
'',
|
|
1945
|
+
'## Efficiency',
|
|
1946
|
+
`- E1 Lead Time: ${metrics.efficiency.e1_lead_time_ms ?? 'null'} ms`,
|
|
1947
|
+
`- E2 Coding Time Ratio: ${metrics.efficiency.e2_coding_time_ratio ?? 'null'}`,
|
|
1948
|
+
`- E3 Spec Time Ratio: ${metrics.efficiency.e3_spec_time_ratio ?? 'null'}`,
|
|
1949
|
+
`- E4 AI Code First Pass Rate: ${metrics.efficiency.e4_ai_code_first_pass_rate ?? 'null'}`,
|
|
1950
|
+
`- Effective Stage Duration: ${metrics.efficiency.effective_stage_duration_ms ?? 'null'} ms`,
|
|
1951
|
+
`- Rework Stage Duration: ${metrics.efficiency.rework_stage_duration_ms ?? 'null'} ms`,
|
|
1952
|
+
'',
|
|
1953
|
+
'## Quality',
|
|
1954
|
+
`- Q1 Spec Conformance Score: ${metrics.quality.q1_spec_conformance_score ?? 'null'}`,
|
|
1955
|
+
`- Q3 Build First Pass Rate: ${metrics.quality.q3_build_first_pass_rate ?? 'null'}`,
|
|
1956
|
+
`- Q4 Spec-driven Test Coverage: ${metrics.quality.q4_spec_driven_test_coverage ?? 'null'}`,
|
|
1957
|
+
`- Q5 Cross Doc Consistency Score: ${metrics.quality.q5_cross_doc_consistency_score ?? 'null'}`,
|
|
1958
|
+
'',
|
|
1959
|
+
'## Process',
|
|
1960
|
+
`- P1 Spec Iteration Count: ${metrics.process.p1_spec_iteration_count ?? 'null'}`,
|
|
1961
|
+
`- P2 AI Code Adoption Rate: ${metrics.process.p2_ai_code_adoption_rate ?? 'null'}`,
|
|
1962
|
+
`- P4 Quality Gate Enforcement Rate: ${metrics.process.p4_quality_gate_enforcement_rate ?? 'null'}`,
|
|
1963
|
+
`- Rework Attempts: ${metrics.process.rework_summary?.total_rework_attempts ?? 'null'}`,
|
|
1964
|
+
`- Superseded Open Stages: ${metrics.process.rework_summary?.superseded_open_stages ?? 'null'}`,
|
|
1965
|
+
'',
|
|
1966
|
+
'## Manual Insights',
|
|
1967
|
+
`- Avg NPS: ${metrics.manual_insights?.avg_nps ?? 'null'}`,
|
|
1968
|
+
`- Avg Cognitive Load: ${metrics.manual_insights?.avg_cognitive_load ?? 'null'}`,
|
|
1969
|
+
`- Avg Spec Fatigue Index: ${metrics.manual_insights?.avg_spec_fatigue_index ?? 'null'}`,
|
|
1970
|
+
`- Baseline Time Saved Ratio: ${metrics.manual_insights?.baseline_time_saved_ratio ?? 'null'}`,
|
|
1971
|
+
'',
|
|
1972
|
+
'## Telemetry Health',
|
|
1973
|
+
`- Health Score: ${metrics.telemetry_health.telemetry_health_score ?? 'null'}`,
|
|
1974
|
+
`- Matched Stage Rate: ${metrics.telemetry_health.matched_stage_rate ?? 'null'}`,
|
|
1975
|
+
`- Open Stages: ${metrics.telemetry_health.open_stages ?? 'null'}`,
|
|
1976
|
+
`- Superseded Open Stages: ${metrics.telemetry_health.superseded_open_stages ?? 'null'}`,
|
|
1977
|
+
`- Orphan Events: ${metrics.telemetry_health.orphan_events ?? 'null'}`,
|
|
1978
|
+
].join('\n');
|
|
1979
|
+
}
|
|
1980
|
+
|
|
1981
|
+
function formatMsToSeconds(ms) {
|
|
1982
|
+
if (ms == null) return 'null';
|
|
1983
|
+
return `${(ms / 1000).toFixed(2)} s`;
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function formatRatioToPercent(ratio) {
|
|
1987
|
+
if (ratio == null) return 'null';
|
|
1988
|
+
return `${(ratio * 100).toFixed(1)}%`;
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
function formatReworkReasons(reasons) {
|
|
1992
|
+
if (!reasons || !reasons.by_category) return 'null';
|
|
1993
|
+
return REWORK_REASON_CATEGORIES.map(c => `${c}=${reasons.by_category[c] ?? 0}`).join(', ');
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function formatReworkReasonDetails(reasons) {
|
|
1997
|
+
if (!reasons || !Array.isArray(reasons.details) || reasons.details.length === 0) return [];
|
|
1998
|
+
return reasons.details.map(d => `- ${d.command} → ${d.reason}${d.detail ? ` [${String(d.detail).replace(/[\r\n\]]/g, ' ')}]` : ''}${d.has_uncommitted ? '(含未提交变更)' : ''}`);
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
function renderExecutiveReportMarkdown(report) {
|
|
2002
|
+
const metrics = report.metrics;
|
|
2003
|
+
const health = report.doctor;
|
|
2004
|
+
const archive = report.archive_result || null;
|
|
2005
|
+
const taskCompletion = archive?.task_completion || null;
|
|
2006
|
+
return [
|
|
2007
|
+
`# SDD 效果度量报告${report.change ? ` - ${report.change}` : ''}`,
|
|
2008
|
+
'',
|
|
2009
|
+
`- 生成时间:${report.generated_at}`,
|
|
2010
|
+
`- 项目路径:${report.project_root}`,
|
|
2011
|
+
`- 统计范围:${report.change ? `change/${report.change}` : 'project'}`,
|
|
2012
|
+
'',
|
|
2013
|
+
'## 执行摘要',
|
|
2014
|
+
`- Telemetry 健康分:${formatRatioToPercent(health.telemetry_health_score)}(0-100%,反映遥测数据的完整性,扣分项包括未闭环阶段、孤儿事件等)`,
|
|
2015
|
+
`- 阶段闭环率:${formatRatioToPercent(health.matched_stage_rate)}(已配对 start/end 的阶段占有效阶段总数的比例)`,
|
|
2016
|
+
`- 严重问题数:${health.severe_issues?.length || 0}(存在未闭环阶段、孤儿事件或未知命令等阻断级问题的数量)`,
|
|
2017
|
+
'',
|
|
2018
|
+
'## 效率指标',
|
|
2019
|
+
`- E1 需求到归档总时长:${formatMsToSeconds(metrics.efficiency?.e1_lead_time_ms)}`,
|
|
2020
|
+
`- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}`,
|
|
2021
|
+
`- E3 规约时间占比:${formatRatioToPercent(metrics.efficiency?.e3_spec_time_ratio)}`,
|
|
2022
|
+
`- E4 AI 一次成码率:${formatRatioToPercent(metrics.efficiency?.e4_ai_code_first_pass_rate)}`,
|
|
2023
|
+
`- 有效阶段总耗时:${formatMsToSeconds(metrics.efficiency?.effective_stage_duration_ms)}`,
|
|
2024
|
+
`- 返工阶段总耗时:${formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms)}`,
|
|
2025
|
+
'',
|
|
2026
|
+
'## 质量指标',
|
|
2027
|
+
`- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}`,
|
|
2028
|
+
`- Q3 构建一次通过率:${formatRatioToPercent(metrics.quality?.q3_build_first_pass_rate)}`,
|
|
2029
|
+
`- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}`,
|
|
2030
|
+
`- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}`,
|
|
2031
|
+
'',
|
|
2032
|
+
'## 过程指标',
|
|
2033
|
+
`- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}`,
|
|
2034
|
+
`- P2 AI 代码保留率:${formatRatioToPercent(metrics.process?.p2_ai_code_adoption_rate)}`,
|
|
2035
|
+
`- P4 质量门前置率:${formatRatioToPercent(metrics.process?.p4_quality_gate_enforcement_rate)}`,
|
|
2036
|
+
`- 返工次数:${metrics.process?.rework_summary?.total_rework_attempts ?? 'null'}`,
|
|
2037
|
+
`- 被后续成功执行覆盖的未闭环阶段数:${metrics.process?.rework_summary?.superseded_open_stages ?? 'null'}`,
|
|
2038
|
+
`- 返工原因分布:${formatReworkReasons(metrics.process?.rework_summary?.reasons)}`,
|
|
2039
|
+
...formatReworkReasonDetails(metrics.process?.rework_summary?.reasons),
|
|
2040
|
+
'',
|
|
2041
|
+
'## 归档结果',
|
|
2042
|
+
`- 归档原因:${archive?.reason || 'null'}`,
|
|
2043
|
+
`- 归档方式:${archive?.method || 'null'}`,
|
|
2044
|
+
`- 归档目录:${archive?.archive_path || 'null'}`,
|
|
2045
|
+
`- 归档清单:${archive?.manifest_path || 'null'}`,
|
|
2046
|
+
`- 最终报告:${archive?.report_path || 'null'}`,
|
|
2047
|
+
`- 已完成任务项:${taskCompletion?.completed ?? 'null'}`,
|
|
2048
|
+
`- 未勾选任务项:${taskCompletion?.incomplete ?? 'null'}`,
|
|
2049
|
+
`- 任务项总数:${taskCompletion?.total ?? 'null'}`,
|
|
2050
|
+
'',
|
|
2051
|
+
'## 说明',
|
|
2052
|
+
'- `null` 表示当前还没有采集到对应事件或该指标暂不适用。',
|
|
2053
|
+
'- E4 AI 一次成码率:依赖 `task_update` 事件中的 `task_id` 和首次执行结果,若 apply 阶段未正确记录则显示 null。',
|
|
2054
|
+
'- P1 文档迭代次数:优先使用 Git 提交历史统计,无 Git 仓库或无提交历史时使用阶段 start 事件数。',
|
|
2055
|
+
'- P2 AI 代码保留率:优先使用 `ai_adoption_review` 的 final 状态,无 final 时 fallback 到 AI 产出快照(ai_snapshot)。',
|
|
2056
|
+
'- Q4 规约驱动测试覆盖率:依赖 check/test 阶段记录 spec 断言到测试用例的映射数据(spec_test_coverage 字段),属高级功能。',
|
|
2057
|
+
'- Q1 规约符合度与人工反馈类指标属于评审信号,默认不作为强阻断门禁。',
|
|
2058
|
+
].join('\n');
|
|
2059
|
+
}
|
|
2060
|
+
|
|
2061
|
+
function renderExecutiveReportHtml(report) {
|
|
2062
|
+
const metrics = report.metrics;
|
|
2063
|
+
const health = report.doctor;
|
|
2064
|
+
const archive = report.archive_result || null;
|
|
2065
|
+
const taskCompletion = archive?.task_completion || null;
|
|
2066
|
+
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
2067
|
+
const badgeClass = (s) => {
|
|
2068
|
+
const v = String(s ?? '').toUpperCase();
|
|
2069
|
+
if (v.includes('FAIL') || v.includes('ERROR')) return 'bad';
|
|
2070
|
+
if (v.includes('WARN') || v.includes('PARTIAL')) return 'warn';
|
|
2071
|
+
return 'ok';
|
|
2072
|
+
};
|
|
2073
|
+
const badge = (s) => `<span class="badge ${badgeClass(s)}">${esc(s || 'N/A')}</span>`;
|
|
2074
|
+
const reasonDetails = formatReworkReasonDetails(metrics.process?.rework_summary?.reasons)
|
|
2075
|
+
.map(d => `<li>${esc(d)}</li>`).join('\n');
|
|
2076
|
+
const severeList = (health.severe_issues || []).map(i => `<li>${esc(typeof i === 'string' ? i : JSON.stringify(i))}</li>`).join('\n');
|
|
2077
|
+
const severeHtml = severeList ? `<ul class="severe">${severeList}</ul>` : '<span class="muted">无</span>';
|
|
2078
|
+
return `<!doctype html>
|
|
2079
|
+
<html lang="zh-CN">
|
|
2080
|
+
<head>
|
|
2081
|
+
<meta charset="utf-8" />
|
|
2082
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2083
|
+
<title>SDD 效果度量报告${report.change ? ` - ${esc(report.change)}` : ''}</title>
|
|
2084
|
+
<style>
|
|
2085
|
+
body { font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif; margin:0; background:#0b1020; color:#e5e7eb; }
|
|
2086
|
+
main { max-width:980px; margin:0 auto; padding:32px; }
|
|
2087
|
+
.card { background:rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.12); border-radius:16px; padding:20px; margin:16px 0; }
|
|
2088
|
+
h1 { margin:0 0 8px; font-size:28px; }
|
|
2089
|
+
h2 { margin:0 0 12px; font-size:18px; }
|
|
2090
|
+
.muted { color:#94a3b8; }
|
|
2091
|
+
table { width:100%; border-collapse:collapse; }
|
|
2092
|
+
th,td { border-bottom:1px solid rgba(255,255,255,.12); padding:10px; text-align:left; vertical-align:top; }
|
|
2093
|
+
th { color:#cbd5e1; width:40%; }
|
|
2094
|
+
code { color:#bfdbfe; word-break:break-all; }
|
|
2095
|
+
.badge { display:inline-block; padding:4px 8px; border-radius:999px; font-weight:700; font-size:12px; }
|
|
2096
|
+
.badge.ok { background:rgba(34,197,94,.16); color:#86efac; }
|
|
2097
|
+
.badge.warn { background:rgba(245,158,11,.18); color:#fcd34d; }
|
|
2098
|
+
.badge.bad { background:rgba(239,68,68,.18); color:#fca5a5; }
|
|
2099
|
+
ul { margin-top:8px; }
|
|
2100
|
+
.severe li { color:#fca5a5; }
|
|
2101
|
+
</style>
|
|
2102
|
+
</head>
|
|
2103
|
+
<body><main>
|
|
2104
|
+
<div class="card">
|
|
2105
|
+
<h1>SDD 效果度量报告${report.change ? ` - ${esc(report.change)}` : ''}</h1>
|
|
2106
|
+
<p class="muted">生成时间:${esc(report.generated_at)} 项目路径:${esc(report.project_root)} 统计范围:${esc(report.change ? `change/${report.change}` : 'project')}</p>
|
|
2107
|
+
</div>
|
|
2108
|
+
<div class="card"><h2>执行摘要</h2><table>
|
|
2109
|
+
<tr><th>Telemetry 健康分</th><td>${badge(formatRatioToPercent(health.telemetry_health_score))}</td></tr>
|
|
2110
|
+
<tr><th>阶段闭环率</th><td>${badge(formatRatioToPercent(health.matched_stage_rate))}</td></tr>
|
|
2111
|
+
<tr><th>严重问题数</th><td>${esc(health.severe_issues?.length || 0)}</td></tr>
|
|
2112
|
+
<tr><th>严重问题明细</th><td>${severeHtml}</td></tr>
|
|
2113
|
+
</table></div>
|
|
2114
|
+
<div class="card"><h2>效率指标</h2><table>
|
|
2115
|
+
<tr><th>E1 需求到归档总时长</th><td>${esc(formatMsToSeconds(metrics.efficiency?.e1_lead_time_ms))}</td></tr>
|
|
2116
|
+
<tr><th>E2 编码时间占比</th><td>${esc(formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio))}</td></tr>
|
|
2117
|
+
<tr><th>E3 规约时间占比</th><td>${esc(formatRatioToPercent(metrics.efficiency?.e3_spec_time_ratio))}</td></tr>
|
|
2118
|
+
<tr><th>E4 AI 一次成码率</th><td>${esc(formatRatioToPercent(metrics.efficiency?.e4_ai_code_first_pass_rate))}</td></tr>
|
|
2119
|
+
<tr><th>有效阶段总耗时</th><td>${esc(formatMsToSeconds(metrics.efficiency?.effective_stage_duration_ms))}</td></tr>
|
|
2120
|
+
<tr><th>返工阶段总耗时</th><td>${esc(formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms))}</td></tr>
|
|
2121
|
+
</table></div>
|
|
2122
|
+
<div class="card"><h2>质量指标</h2><table>
|
|
2123
|
+
<tr><th>Q1 规约符合度</th><td>${esc(metrics.quality?.q1_spec_conformance_score ?? 'null')}</td></tr>
|
|
2124
|
+
<tr><th>Q3 构建一次通过率</th><td>${esc(formatRatioToPercent(metrics.quality?.q3_build_first_pass_rate))}</td></tr>
|
|
2125
|
+
<tr><th>Q4 规约驱动测试覆盖率</th><td>${esc(formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage))}</td></tr>
|
|
2126
|
+
<tr><th>Q5 跨文档一致性得分</th><td>${esc(metrics.quality?.q5_cross_doc_consistency_score ?? 'null')}</td></tr>
|
|
2127
|
+
</table></div>
|
|
2128
|
+
<div class="card"><h2>过程指标</h2><table>
|
|
2129
|
+
<tr><th>P1 文档迭代次数</th><td>${esc(metrics.process?.p1_spec_iteration_count ?? 'null')}</td></tr>
|
|
2130
|
+
<tr><th>P2 AI 代码保留率</th><td>${esc(formatRatioToPercent(metrics.process?.p2_ai_code_adoption_rate))}</td></tr>
|
|
2131
|
+
<tr><th>P4 质量门前置率</th><td>${esc(formatRatioToPercent(metrics.process?.p4_quality_gate_enforcement_rate))}</td></tr>
|
|
2132
|
+
<tr><th>返工次数</th><td>${esc(metrics.process?.rework_summary?.total_rework_attempts ?? 'null')}</td></tr>
|
|
2133
|
+
<tr><th>被覆盖未闭环阶段数</th><td>${esc(metrics.process?.rework_summary?.superseded_open_stages ?? 'null')}</td></tr>
|
|
2134
|
+
<tr><th>返工原因分布</th><td>${esc(formatReworkReasons(metrics.process?.rework_summary?.reasons))}</td></tr>
|
|
2135
|
+
</table>
|
|
2136
|
+
${reasonDetails ? `<ul>${reasonDetails}</ul>` : ''}</div>
|
|
2137
|
+
<div class="card"><h2>归档结果</h2><table>
|
|
2138
|
+
<tr><th>归档原因</th><td>${esc(archive?.reason || 'null')}</td></tr>
|
|
2139
|
+
<tr><th>归档方式</th><td>${esc(archive?.method || 'null')}</td></tr>
|
|
2140
|
+
<tr><th>归档目录</th><td><code>${esc(archive?.archive_path || 'null')}</code></td></tr>
|
|
2141
|
+
<tr><th>归档清单</th><td><code>${esc(archive?.manifest_path || 'null')}</code></td></tr>
|
|
2142
|
+
<tr><th>最终报告(md)</th><td><code>${esc(archive?.report_path || 'null')}</code></td></tr>
|
|
2143
|
+
<tr><th>最终报告(html)</th><td><code>${esc(archive?.report_html_path || 'null')}</code></td></tr>
|
|
2144
|
+
<tr><th>已完成任务项</th><td>${esc(taskCompletion?.completed ?? 'null')}</td></tr>
|
|
2145
|
+
<tr><th>未勾选任务项</th><td>${esc(taskCompletion?.incomplete ?? 'null')}</td></tr>
|
|
2146
|
+
<tr><th>任务项总数</th><td>${esc(taskCompletion?.total ?? 'null')}</td></tr>
|
|
2147
|
+
</table></div>
|
|
2148
|
+
<div class="card"><h2>说明</h2><ul>
|
|
2149
|
+
<li><code>null</code> 表示当前还没有采集到对应事件或该指标暂不适用。</li>
|
|
2150
|
+
<li>E4 AI 一次成码率:依赖 <code>task_update</code> 事件中的 <code>task_id</code> 和首次执行结果,若 apply 阶段未正确记录则显示 null。</li>
|
|
2151
|
+
<li>P1 文档迭代次数:优先使用 Git 提交历史统计,无 Git 仓库或无提交历史时使用阶段 start 事件数。</li>
|
|
2152
|
+
<li>P2 AI 代码保留率:优先使用 <code>ai_adoption_review</code> 的 final 状态,无 final 时 fallback 到 ai_snapshot。</li>
|
|
2153
|
+
<li>Q4 规约驱动测试覆盖率:依赖 check/test 阶段记录 spec 断言到测试用例的映射数据(spec_test_coverage 字段),属高级功能。</li>
|
|
2154
|
+
<li>Q1 规约符合度与人工反馈类指标属于评审信号,默认不作为强阻断门禁。</li>
|
|
2155
|
+
</ul></div>
|
|
2156
|
+
</main></body></html>`;
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
function buildReport(projectRoot, events, options = {}) {
|
|
2160
|
+
const archiveEvent = latestByTimestamp(events.filter(e => {
|
|
2161
|
+
return e.command === 'archive' && e.type === 'stage_end' && !e.orphan;
|
|
2162
|
+
}));
|
|
2163
|
+
return {
|
|
2164
|
+
generated_at: nowISO(),
|
|
2165
|
+
project_root: projectRoot,
|
|
2166
|
+
change: options.change || null,
|
|
2167
|
+
level: options.level || (options.change ? 'change' : 'project'),
|
|
2168
|
+
metrics: computePdfMvpMetrics(events, {
|
|
2169
|
+
level: options.level || (options.change ? 'change' : 'project'),
|
|
2170
|
+
change: options.change,
|
|
2171
|
+
capability: options.capability,
|
|
2172
|
+
projectRoot,
|
|
2173
|
+
}),
|
|
2174
|
+
doctor: computeDoctorReport(events, { change: options.change, projectRoot }),
|
|
2175
|
+
archive_result: archiveEvent?.details?.archive_result || null,
|
|
2176
|
+
};
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
function filterEventsByDate(events, dateFrom, dateTo) {
|
|
2180
|
+
if (!dateFrom && !dateTo) return events;
|
|
2181
|
+
return events.filter(e => {
|
|
2182
|
+
const d = e.timestamp?.slice(0, 10);
|
|
2183
|
+
if (!d) return false;
|
|
2184
|
+
if (dateFrom && d < dateFrom) return false;
|
|
2185
|
+
if (dateTo && d > dateTo) return false;
|
|
2186
|
+
return true;
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
function computeDoctorReport(events, options = {}) {
|
|
2191
|
+
const scopedEvents = options.change
|
|
2192
|
+
? events.filter(e => e.change === options.change)
|
|
2193
|
+
: events;
|
|
2194
|
+
const executionSummary = summarizeStageExecutions(scopedEvents);
|
|
2195
|
+
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
|
|
2196
|
+
const stageEvents = scopedEvents.filter(e => e.type === 'stage_start' || e.type === 'stage_end');
|
|
2197
|
+
const starts = stageEvents.filter(e => e.type === 'stage_start');
|
|
2198
|
+
const ends = stageEvents.filter(e => e.type === 'stage_end');
|
|
2199
|
+
const startsById = new Map(starts.map(e => [e.event_id, e]));
|
|
2200
|
+
const endsById = new Map();
|
|
2201
|
+
for (const end of ends) {
|
|
2202
|
+
if (!endsById.has(end.event_id)) endsById.set(end.event_id, []);
|
|
2203
|
+
endsById.get(end.event_id).push(end);
|
|
2204
|
+
}
|
|
2205
|
+
|
|
2206
|
+
const matchedStarts = starts.filter(e => endsById.has(e.event_id));
|
|
2207
|
+
const supersededOpenIds = new Set(executionSummary.reworkAttempts
|
|
2208
|
+
.filter(attempt => attempt.rework_reason === 'incomplete')
|
|
2209
|
+
.map(attempt => attempt.start.event_id));
|
|
2210
|
+
const supersededOpenStages = starts.filter(e => supersededOpenIds.has(e.event_id));
|
|
2211
|
+
const openStages = starts.filter(e => !endsById.has(e.event_id) && !supersededOpenIds.has(e.event_id));
|
|
2212
|
+
const orphanEnds = ends.filter(e => e.orphan || !startsById.has(e.event_id));
|
|
2213
|
+
const effectiveStartCount = starts.length - supersededOpenStages.length;
|
|
2214
|
+
const matchedStageRate = effectiveStartCount === 0 ? 1 : matchedStarts.length / effectiveStartCount;
|
|
2215
|
+
|
|
2216
|
+
const unknownCommandEvents = stageEvents.filter(e => !e.command || e.command === 'unknown');
|
|
2217
|
+
const unknownAgentEvents = stageEvents.filter(e => !e.agent_type || e.agent_type === 'unknown');
|
|
2218
|
+
const nullDurationEnds = ends.filter(e => !e.orphan && e.duration_ms == null);
|
|
2219
|
+
const outdatedSchemaEvents = scopedEvents.filter(e => !e.schema_version || e.schema_version < SCHEMA_VERSION);
|
|
2220
|
+
const invalidStageEvents = stageEvents.filter(e => {
|
|
2221
|
+
const stage = e.stage || e.command;
|
|
2222
|
+
return !stage || !STAGE_ORDER.includes(stage);
|
|
2223
|
+
});
|
|
2224
|
+
const missingChangeEvents = stageEvents.filter(e => !e.change || e.change === 'general');
|
|
2225
|
+
const missingCapabilityEvents = stageEvents.filter(e => !e.capability);
|
|
2226
|
+
|
|
2227
|
+
const warnings = [];
|
|
2228
|
+
if (openStages.length) warnings.push(`${openStages.length} stage(s) have start events without matching end events`);
|
|
2229
|
+
if (supersededOpenStages.length) warnings.push(`${supersededOpenStages.length} open stage(s) were superseded by later successful runs and counted as rework`);
|
|
2230
|
+
if (orphanEnds.length) warnings.push(`${orphanEnds.length} end event(s) have no matching start event`);
|
|
2231
|
+
if (unknownCommandEvents.length) warnings.push(`${unknownCommandEvents.length} event(s) have unknown command`);
|
|
2232
|
+
if (unknownAgentEvents.length) warnings.push(`${unknownAgentEvents.length} event(s) have unknown agent_type`);
|
|
2233
|
+
if (nullDurationEnds.length) warnings.push(`${nullDurationEnds.length} end event(s) have null duration_ms`);
|
|
2234
|
+
if (outdatedSchemaEvents.length) warnings.push(`${outdatedSchemaEvents.length} event(s) use missing or outdated schema_version`);
|
|
2235
|
+
if (invalidStageEvents.length) warnings.push(`${invalidStageEvents.length} event(s) use invalid stage/command names`);
|
|
2236
|
+
if (missingChangeEvents.length) warnings.push(`${missingChangeEvents.length} event(s) have missing or general change`);
|
|
2237
|
+
if (missingCapabilityEvents.length) warnings.push(`${missingCapabilityEvents.length} event(s) are missing capability`);
|
|
2238
|
+
|
|
2239
|
+
const severeIssues = [];
|
|
2240
|
+
if (openStages.length) severeIssues.push('open_stages');
|
|
2241
|
+
if (orphanEnds.length) severeIssues.push('orphan_events');
|
|
2242
|
+
if (unknownCommandEvents.length) severeIssues.push('unknown_command_events');
|
|
2243
|
+
if (invalidStageEvents.length) severeIssues.push('invalid_stage_events');
|
|
2244
|
+
|
|
2245
|
+
const penalty =
|
|
2246
|
+
openStages.length * 0.18 +
|
|
2247
|
+
orphanEnds.length * 0.18 +
|
|
2248
|
+
unknownCommandEvents.length * 0.12 +
|
|
2249
|
+
invalidStageEvents.length * 0.12 +
|
|
2250
|
+
nullDurationEnds.length * 0.08 +
|
|
2251
|
+
outdatedSchemaEvents.length * 0.06 +
|
|
2252
|
+
missingChangeEvents.length * 0.04 +
|
|
2253
|
+
missingCapabilityEvents.length * 0.01;
|
|
2254
|
+
const denominator = Math.max(stageEvents.length, 1);
|
|
2255
|
+
const telemetryHealthScore = Math.max(0, Math.round((1 - penalty / denominator) * 100) / 100);
|
|
2256
|
+
|
|
2257
|
+
return {
|
|
2258
|
+
telemetry_health_score: telemetryHealthScore,
|
|
2259
|
+
matched_stage_rate: Math.round(matchedStageRate * 100) / 100,
|
|
2260
|
+
total_events: scopedEvents.length,
|
|
2261
|
+
stage_events: stageEvents.length,
|
|
2262
|
+
start_events: starts.length,
|
|
2263
|
+
end_events: ends.length,
|
|
2264
|
+
matched_stages: matchedStarts.length,
|
|
2265
|
+
open_stages: openStages.length,
|
|
2266
|
+
superseded_open_stages: supersededOpenStages.length,
|
|
2267
|
+
orphan_events: orphanEnds.length,
|
|
2268
|
+
unknown_command_events: unknownCommandEvents.length,
|
|
2269
|
+
unknown_agent_events: unknownAgentEvents.length,
|
|
2270
|
+
null_duration_stages: nullDurationEnds.length,
|
|
2271
|
+
outdated_schema_events: outdatedSchemaEvents.length,
|
|
2272
|
+
invalid_stage_events: invalidStageEvents.length,
|
|
2273
|
+
missing_change_events: missingChangeEvents.length,
|
|
2274
|
+
missing_capability_events: missingCapabilityEvents.length,
|
|
2275
|
+
rework_attempts: executionSummary.summary.total_rework_attempts,
|
|
2276
|
+
completed_rework_attempts: executionSummary.summary.completed_rework_attempts,
|
|
2277
|
+
rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
|
|
2278
|
+
rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
|
|
2279
|
+
severe_issues: severeIssues,
|
|
2280
|
+
warnings,
|
|
2281
|
+
};
|
|
2282
|
+
}
|
|
2283
|
+
|
|
2284
|
+
// ── CLI 参数解析 ──────────────────────────────────────
|
|
2285
|
+
|
|
2286
|
+
/** 解析 --key=value 格式的参数 */
|
|
2287
|
+
function parseArgs(argv) {
|
|
2288
|
+
const args = {};
|
|
2289
|
+
for (const arg of argv) {
|
|
2290
|
+
const match = arg.match(/^--([a-zA-Z_-]+)=(.*)$/);
|
|
2291
|
+
if (match) {
|
|
2292
|
+
args[match[1]] = match[2];
|
|
2293
|
+
} else if (arg.match(/^--([a-zA-Z_-]+)$/)) {
|
|
2294
|
+
args[arg.slice(2)] = true;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
return args;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
// ── CLI 命令实现 ─────────────────────────────────────
|
|
2301
|
+
|
|
2302
|
+
/**
|
|
2303
|
+
* log start: 记录阶段开始
|
|
2304
|
+
*/
|
|
2305
|
+
function cmdStart(args) {
|
|
2306
|
+
const command = args.command;
|
|
2307
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2308
|
+
const changeName = args.change || args['change-name'] || '';
|
|
2309
|
+
const agentType = inferAgentType(args);
|
|
2310
|
+
const source = args.source || 'opsx-command';
|
|
2311
|
+
const capability = args.capability || args['capability-name'];
|
|
2312
|
+
const taskId = args['task-id'] || args.task_id;
|
|
2313
|
+
const sessionId = args['session-id'] || args.session_id;
|
|
2314
|
+
const gitSha = args['git-sha'] || args.git_sha;
|
|
2315
|
+
|
|
2316
|
+
if (!command) {
|
|
2317
|
+
console.error('错误: 缺少 --command 参数');
|
|
2318
|
+
console.error('用法: node skywalk-sdd/log.cjs start --command=propose --project=/path');
|
|
2319
|
+
process.exit(1);
|
|
2320
|
+
}
|
|
2321
|
+
|
|
2322
|
+
const validCommands = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'test', 'archive', 'explore'];
|
|
2323
|
+
if (!validCommands.includes(command)) {
|
|
2324
|
+
console.error(`错误: 无效的 command "${command}",有效值: ${validCommands.join(', ')}`);
|
|
2325
|
+
process.exit(1);
|
|
2326
|
+
}
|
|
2327
|
+
|
|
2328
|
+
const dataDir = getDataDir(projectRoot);
|
|
2329
|
+
const eventId = generateEventId();
|
|
2330
|
+
const timestamp = nowISO();
|
|
2331
|
+
let context;
|
|
2332
|
+
try {
|
|
2333
|
+
context = parseJsonOption(args, 'context-json', 'context-file', projectRoot, {});
|
|
2334
|
+
} catch (err) {
|
|
2335
|
+
fail(`context JSON 解析失败: ${err.message}`);
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
const event = cleanOptionalFields({
|
|
2339
|
+
schema_version: SCHEMA_VERSION,
|
|
2340
|
+
event_id: eventId,
|
|
2341
|
+
type: 'stage_start',
|
|
2342
|
+
source,
|
|
2343
|
+
command,
|
|
2344
|
+
stage: command,
|
|
2345
|
+
change: changeName || 'general',
|
|
2346
|
+
capability,
|
|
2347
|
+
task_id: taskId,
|
|
2348
|
+
agent_type: agentType,
|
|
2349
|
+
project_root: projectRoot,
|
|
2350
|
+
session_id: sessionId,
|
|
2351
|
+
git_sha: gitSha,
|
|
2352
|
+
timestamp,
|
|
2353
|
+
context,
|
|
2354
|
+
});
|
|
2355
|
+
|
|
2356
|
+
appendEvent(dataDir, event.change, event);
|
|
2357
|
+
writeActiveStage(projectRoot, event);
|
|
2358
|
+
|
|
2359
|
+
// 输出 JSON,方便 AI Agent 解析 event_id
|
|
2360
|
+
const output = {
|
|
2361
|
+
event_id: eventId,
|
|
2362
|
+
started_at: timestamp,
|
|
2363
|
+
message: `SDD ${command} 阶段开始记录(change: ${event.change})`,
|
|
2364
|
+
};
|
|
2365
|
+
console.log(JSON.stringify(output, null, 2));
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* log end: 记录阶段结束
|
|
2370
|
+
*/
|
|
2371
|
+
function cmdEnd(args, options = {}) {
|
|
2372
|
+
let eventId = args['event-id'] || args.event_id;
|
|
2373
|
+
const result = args.result;
|
|
2374
|
+
const summary = args.summary || '';
|
|
2375
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2376
|
+
let reportOutput = args['report-output'] || args.report_output || '';
|
|
2377
|
+
|
|
2378
|
+
if (!result) {
|
|
2379
|
+
console.error('错误: 缺少 --result 参数(success/failure/partial)');
|
|
2380
|
+
process.exit(1);
|
|
2381
|
+
}
|
|
2382
|
+
|
|
2383
|
+
const reason = args.reason;
|
|
2384
|
+
if (reason !== undefined && reason !== null && !REWORK_REASON_CATEGORIES.includes(reason)) {
|
|
2385
|
+
console.error(`错误: --reason 非法枚举(${reason}),合法值:${REWORK_REASON_CATEGORIES.join('/')}`);
|
|
2386
|
+
process.exit(1);
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
const dataDir = getDataDir(projectRoot);
|
|
2390
|
+
const activeCriteria = {
|
|
2391
|
+
session_id: args['session-id'] || args.session_id,
|
|
2392
|
+
change: args.change || args['change-name'],
|
|
2393
|
+
command: args.command,
|
|
2394
|
+
agent_type: args.agent || args['agent-type'],
|
|
2395
|
+
};
|
|
2396
|
+
const startEvent = eventId
|
|
2397
|
+
? searchEventInDataDir(dataDir, eventId)
|
|
2398
|
+
: findActiveStage(projectRoot, activeCriteria);
|
|
2399
|
+
if (!eventId && startEvent) {
|
|
2400
|
+
eventId = startEvent.event_id;
|
|
2401
|
+
}
|
|
2402
|
+
const orphan = !startEvent;
|
|
2403
|
+
if (!eventId) {
|
|
2404
|
+
eventId = generateEventId();
|
|
2405
|
+
}
|
|
2406
|
+
const timestamp = nowISO();
|
|
2407
|
+
let details;
|
|
2408
|
+
try {
|
|
2409
|
+
details = args.details || parseJsonOption(args, 'details-json', 'details-file', projectRoot, {});
|
|
2410
|
+
} catch (err) {
|
|
2411
|
+
fail(`details JSON 解析失败: ${err.message}`);
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
const durationMs = startEvent
|
|
2415
|
+
? new Date(timestamp).getTime() - new Date(startEvent.timestamp).getTime()
|
|
2416
|
+
: null;
|
|
2417
|
+
|
|
2418
|
+
const command = startEvent?.command || args.command || 'unknown';
|
|
2419
|
+
const change = startEvent?.change || args.change || args['change-name'] || 'general';
|
|
2420
|
+
if (!reportOutput && args['generate-report']) {
|
|
2421
|
+
// archive success 时归档已把 change 移到 archive 目录,此时若默认活跃路径会让
|
|
2422
|
+
// ensureArchiveSuccessArtifacts 走显式分支 → 报告落活跃路径 + ensureDir 重建空 active。
|
|
2423
|
+
// 故 archive success 不设活跃默认,由 repaired.reportPath 兜底到 archive 目录。
|
|
2424
|
+
if (!(command === 'archive' && result === 'success')) {
|
|
2425
|
+
reportOutput = path.join('openspec', 'changes', change, 'reports', `${safeChangeName(change)}-report.md`);
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
let repaired = null;
|
|
2429
|
+
if (command === 'archive' && result === 'success') {
|
|
2430
|
+
repaired = ensureArchiveSuccessArtifacts(projectRoot, change, details, {
|
|
2431
|
+
reason: details.archive_result?.reason || '',
|
|
2432
|
+
reportOutput,
|
|
2433
|
+
});
|
|
2434
|
+
if (repaired.archive_result && Object.keys(repaired.archive_result).length > 0) {
|
|
2435
|
+
details = {
|
|
2436
|
+
...details,
|
|
2437
|
+
archive_result: {
|
|
2438
|
+
...(details.archive_result || {}),
|
|
2439
|
+
...repaired.archive_result,
|
|
2440
|
+
},
|
|
2441
|
+
};
|
|
2442
|
+
}
|
|
2443
|
+
}
|
|
2444
|
+
const event = cleanOptionalFields({
|
|
2445
|
+
schema_version: SCHEMA_VERSION,
|
|
2446
|
+
event_id: eventId,
|
|
2447
|
+
type: 'stage_end',
|
|
2448
|
+
source: args.source || startEvent?.source || 'opsx-command',
|
|
2449
|
+
command,
|
|
2450
|
+
stage: startEvent?.stage || command,
|
|
2451
|
+
change,
|
|
2452
|
+
capability: args.capability || args['capability-name'] || startEvent?.capability,
|
|
2453
|
+
task_id: args['task-id'] || args.task_id || startEvent?.task_id,
|
|
2454
|
+
agent_type: args.agent || args['agent-type'] || startEvent?.agent_type || 'unknown',
|
|
2455
|
+
project_root: projectRoot,
|
|
2456
|
+
session_id: startEvent?.session_id || args['session-id'] || args.session_id,
|
|
2457
|
+
git_sha: args['git-sha'] || args.git_sha || startEvent?.git_sha,
|
|
2458
|
+
timestamp,
|
|
2459
|
+
duration_ms: durationMs,
|
|
2460
|
+
result,
|
|
2461
|
+
summary,
|
|
2462
|
+
details,
|
|
2463
|
+
rework_reason: reason || undefined,
|
|
2464
|
+
orphan: orphan ? true : undefined,
|
|
2465
|
+
});
|
|
2466
|
+
|
|
2467
|
+
appendEvent(dataDir, event.change, event);
|
|
2468
|
+
if (startEvent) {
|
|
2469
|
+
clearActiveStage(projectRoot, startEvent);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
let resolvedReportOutput = '';
|
|
2473
|
+
if (reportOutput) {
|
|
2474
|
+
resolvedReportOutput = path.isAbsolute(reportOutput) ? reportOutput : path.resolve(projectRoot, reportOutput);
|
|
2475
|
+
} else if (repaired && repaired.reportPath) {
|
|
2476
|
+
resolvedReportOutput = repaired.reportPath;
|
|
2477
|
+
}
|
|
2478
|
+
if (resolvedReportOutput) {
|
|
2479
|
+
const events = readEvents(dataDir, event.change);
|
|
2480
|
+
const report = buildReport(projectRoot, events, {
|
|
2481
|
+
level: args['report-level'] || 'change',
|
|
2482
|
+
change: event.change,
|
|
2483
|
+
capability: args.capability || args['capability-name'] || startEvent?.capability,
|
|
2484
|
+
});
|
|
2485
|
+
const reportFormat = args['report-format'] || 'markdown';
|
|
2486
|
+
const rendered = reportFormat === 'json'
|
|
2487
|
+
? JSON.stringify(report, null, 2)
|
|
2488
|
+
: renderExecutiveReportMarkdown(report);
|
|
2489
|
+
ensureDir(path.dirname(resolvedReportOutput));
|
|
2490
|
+
fs.writeFileSync(resolvedReportOutput, rendered + '\n', 'utf8');
|
|
2491
|
+
if (reportFormat !== 'json') {
|
|
2492
|
+
// md 默认双输出:同目录 .html 副本,写入失败不阻塞 md 主产物。
|
|
2493
|
+
// 仅当主路径是 .md 时才派生 .html 副本,避免自定义非 .md 主路径(如 --report-output=xxx.txt)
|
|
2494
|
+
// 因 .replace(/\.md$/i,'.html') 不匹配而 htmlPath 与主路径相同,导致 html 内容静默覆盖 md 主产物。
|
|
2495
|
+
try {
|
|
2496
|
+
const htmlPath = resolvedReportOutput.replace(/\.md$/i, '.html');
|
|
2497
|
+
if (htmlPath !== resolvedReportOutput) {
|
|
2498
|
+
fs.writeFileSync(htmlPath, renderExecutiveReportHtml(report) + '\n', 'utf8');
|
|
2499
|
+
}
|
|
2500
|
+
} catch (err) {
|
|
2501
|
+
try { console.error(`[telemetry] html 报告副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
const output = {
|
|
2507
|
+
event_id: eventId,
|
|
2508
|
+
duration_ms: durationMs,
|
|
2509
|
+
recorded_at: timestamp,
|
|
2510
|
+
report_output: resolvedReportOutput || undefined,
|
|
2511
|
+
message: `SDD ${event.command} 阶段结束(${result},耗时 ${durationMs ? (durationMs / 1000).toFixed(1) + 's' : '未知'})`,
|
|
2512
|
+
};
|
|
2513
|
+
if (!options.silent) {
|
|
2514
|
+
console.log(JSON.stringify(output, null, 2));
|
|
2515
|
+
}
|
|
2516
|
+
return output;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/**
|
|
2520
|
+
* log record: 记录非阶段类结构化事件
|
|
2521
|
+
*/
|
|
2522
|
+
function cmdRecord(args) {
|
|
2523
|
+
const type = args.type || args['event-type'];
|
|
2524
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2525
|
+
const change = args.change || args['change-name'] || 'general';
|
|
2526
|
+
const source = args.source || 'opsx-command';
|
|
2527
|
+
const agentType = inferAgentType(args);
|
|
2528
|
+
const allowedTypes = [
|
|
2529
|
+
'task_update',
|
|
2530
|
+
'check_result',
|
|
2531
|
+
'build_result',
|
|
2532
|
+
'test_result',
|
|
2533
|
+
'coverage_result',
|
|
2534
|
+
'quality_gate_result',
|
|
2535
|
+
'conformance_review',
|
|
2536
|
+
'ai_adoption_review',
|
|
2537
|
+
'survey_result',
|
|
2538
|
+
'baseline_record',
|
|
2539
|
+
'telemetry_warning',
|
|
2540
|
+
'worktree_finish',
|
|
2541
|
+
];
|
|
2542
|
+
|
|
2543
|
+
if (!type) {
|
|
2544
|
+
console.error('错误: 缺少 --type 参数');
|
|
2545
|
+
console.error(`有效值: ${allowedTypes.join(', ')}`);
|
|
2546
|
+
process.exit(1);
|
|
2547
|
+
}
|
|
2548
|
+
if (!allowedTypes.includes(type)) {
|
|
2549
|
+
console.error(`错误: 无效的 type "${type}",有效值: ${allowedTypes.join(', ')}`);
|
|
2550
|
+
process.exit(1);
|
|
2551
|
+
}
|
|
2552
|
+
|
|
2553
|
+
let details;
|
|
2554
|
+
try {
|
|
2555
|
+
details = parseJsonOption(args, 'details-json', 'details-file', projectRoot, {});
|
|
2556
|
+
} catch (err) {
|
|
2557
|
+
fail(`details JSON 解析失败: ${err.message}`);
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
const dataDir = getDataDir(projectRoot);
|
|
2561
|
+
const activeStage = findActiveStage(projectRoot, {
|
|
2562
|
+
session_id: args['session-id'] || args.session_id,
|
|
2563
|
+
change,
|
|
2564
|
+
command: args.command,
|
|
2565
|
+
agent_type: agentType,
|
|
2566
|
+
});
|
|
2567
|
+
const eventId = args['event-id'] || args.event_id || generateEventId();
|
|
2568
|
+
const timestamp = nowISO();
|
|
2569
|
+
const event = cleanOptionalFields({
|
|
2570
|
+
schema_version: SCHEMA_VERSION,
|
|
2571
|
+
event_id: eventId,
|
|
2572
|
+
type,
|
|
2573
|
+
source,
|
|
2574
|
+
command: args.command,
|
|
2575
|
+
stage: args.stage || args.command,
|
|
2576
|
+
change,
|
|
2577
|
+
capability: args.capability || args['capability-name'],
|
|
2578
|
+
task_id: args['task-id'] || args.task_id,
|
|
2579
|
+
agent_type: agentType,
|
|
2580
|
+
project_root: projectRoot,
|
|
2581
|
+
session_id: activeStage?.session_id || args['session-id'] || args.session_id,
|
|
2582
|
+
git_sha: args['git-sha'] || args.git_sha,
|
|
2583
|
+
timestamp,
|
|
2584
|
+
result: args.result,
|
|
2585
|
+
status: args.status,
|
|
2586
|
+
summary: args.summary,
|
|
2587
|
+
details,
|
|
2588
|
+
});
|
|
2589
|
+
|
|
2590
|
+
appendEvent(dataDir, event.change, event);
|
|
2591
|
+
console.log(JSON.stringify({
|
|
2592
|
+
event_id: eventId,
|
|
2593
|
+
type,
|
|
2594
|
+
recorded_at: timestamp,
|
|
2595
|
+
message: `SDD ${type} 事件已记录(change: ${event.change})`,
|
|
2596
|
+
}, null, 2));
|
|
2597
|
+
}
|
|
2598
|
+
|
|
2599
|
+
/**
|
|
2600
|
+
* 从事件文件中查找 start 事件(因为 CLI 无状态,需要从文件回溯)
|
|
2601
|
+
*/
|
|
2602
|
+
function findStartEvent(eventId) {
|
|
2603
|
+
// 尝试从当前目录的 skywalk-sdd 查找
|
|
2604
|
+
const cwdDataDir = getDataDir(process.cwd());
|
|
2605
|
+
const found = searchEventInDataDir(cwdDataDir, eventId);
|
|
2606
|
+
if (found) return found;
|
|
2607
|
+
return null;
|
|
2608
|
+
}
|
|
2609
|
+
|
|
2610
|
+
function searchEventInDataDir(dataDir, eventId) {
|
|
2611
|
+
const eventsDir = path.join(dataDir, 'events');
|
|
2612
|
+
if (!fs.existsSync(eventsDir)) return null;
|
|
2613
|
+
|
|
2614
|
+
try {
|
|
2615
|
+
const changeDirs = fs.readdirSync(eventsDir).filter(d => {
|
|
2616
|
+
return fs.statSync(path.join(eventsDir, d)).isDirectory();
|
|
2617
|
+
});
|
|
2618
|
+
|
|
2619
|
+
for (const changeDir of changeDirs) {
|
|
2620
|
+
const dir = path.join(eventsDir, changeDir);
|
|
2621
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort().reverse();
|
|
2622
|
+
for (const file of files) {
|
|
2623
|
+
const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean);
|
|
2624
|
+
for (const line of lines) {
|
|
2625
|
+
try {
|
|
2626
|
+
const event = JSON.parse(line);
|
|
2627
|
+
if (event.event_id === eventId && event.type === 'stage_start') {
|
|
2628
|
+
return event;
|
|
2629
|
+
}
|
|
2630
|
+
} catch {}
|
|
2631
|
+
}
|
|
2632
|
+
}
|
|
2633
|
+
}
|
|
2634
|
+
} catch {}
|
|
2635
|
+
return null;
|
|
2636
|
+
}
|
|
2637
|
+
|
|
2638
|
+
/**
|
|
2639
|
+
* log metrics: 查询度量指标
|
|
2640
|
+
*/
|
|
2641
|
+
function cmdMetrics(args) {
|
|
2642
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2643
|
+
const changeName = args.change || args['change-name'];
|
|
2644
|
+
const capability = args.capability || args['capability-name'];
|
|
2645
|
+
const dateFrom = args['date-from'];
|
|
2646
|
+
const dateTo = args['date-to'];
|
|
2647
|
+
const format = args.format || 'json';
|
|
2648
|
+
const level = args.level || (capability ? 'capability' : (changeName ? 'change' : 'project'));
|
|
2649
|
+
const pdfMvp = Boolean(args['pdf-mvp']);
|
|
2650
|
+
|
|
2651
|
+
const dataDir = getDataDir(projectRoot);
|
|
2652
|
+
let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
|
|
2653
|
+
|
|
2654
|
+
events = filterEventsByDate(events, dateFrom, dateTo);
|
|
2655
|
+
|
|
2656
|
+
let metrics;
|
|
2657
|
+
if (pdfMvp) {
|
|
2658
|
+
metrics = computePdfMvpMetrics(events, {
|
|
2659
|
+
level,
|
|
2660
|
+
change: changeName,
|
|
2661
|
+
capability,
|
|
2662
|
+
projectRoot,
|
|
2663
|
+
});
|
|
2664
|
+
} else if (changeName) {
|
|
2665
|
+
metrics = computeChangeMetrics(changeName, events);
|
|
2666
|
+
if (!metrics) {
|
|
2667
|
+
console.log(JSON.stringify({ error: `未找到变更 "${changeName}" 的事件数据` }));
|
|
2668
|
+
return;
|
|
2669
|
+
}
|
|
2670
|
+
} else {
|
|
2671
|
+
metrics = computeOverviewMetrics(events);
|
|
2672
|
+
}
|
|
2673
|
+
|
|
2674
|
+
if (format === 'markdown' && pdfMvp) {
|
|
2675
|
+
console.log(renderPdfMvpMarkdown(metrics));
|
|
2676
|
+
} else {
|
|
2677
|
+
console.log(JSON.stringify(metrics, null, 2));
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* log report: 生成只读度量报告
|
|
2683
|
+
*/
|
|
2684
|
+
function cmdReport(args) {
|
|
2685
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2686
|
+
const changeName = args.change || args['change-name'];
|
|
2687
|
+
const capability = args.capability || args['capability-name'];
|
|
2688
|
+
const dateFrom = args['date-from'];
|
|
2689
|
+
const dateTo = args['date-to'];
|
|
2690
|
+
const format = args.format || 'markdown';
|
|
2691
|
+
const outputPath = args.output || args['output-file'];
|
|
2692
|
+
const level = args.level || (capability ? 'capability' : (changeName ? 'change' : 'project'));
|
|
2693
|
+
|
|
2694
|
+
const dataDir = getDataDir(projectRoot);
|
|
2695
|
+
let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
|
|
2696
|
+
events = filterEventsByDate(events, dateFrom, dateTo);
|
|
2697
|
+
|
|
2698
|
+
const report = buildReport(projectRoot, events, {
|
|
2699
|
+
level,
|
|
2700
|
+
change: changeName,
|
|
2701
|
+
capability,
|
|
2702
|
+
});
|
|
2703
|
+
const rendered = format === 'json'
|
|
2704
|
+
? JSON.stringify(report, null, 2)
|
|
2705
|
+
: format === 'html'
|
|
2706
|
+
? renderExecutiveReportHtml(report)
|
|
2707
|
+
: renderExecutiveReportMarkdown(report);
|
|
2708
|
+
|
|
2709
|
+
if (outputPath) {
|
|
2710
|
+
const resolvedOutput = path.isAbsolute(outputPath)
|
|
2711
|
+
? outputPath
|
|
2712
|
+
: path.resolve(projectRoot, outputPath);
|
|
2713
|
+
ensureDir(path.dirname(resolvedOutput));
|
|
2714
|
+
fs.writeFileSync(resolvedOutput, rendered + '\n', 'utf8');
|
|
2715
|
+
console.log(JSON.stringify({
|
|
2716
|
+
output: resolvedOutput,
|
|
2717
|
+
format,
|
|
2718
|
+
message: 'SDD report 已生成',
|
|
2719
|
+
}, null, 2));
|
|
2720
|
+
return;
|
|
2721
|
+
}
|
|
2722
|
+
|
|
2723
|
+
console.log(rendered);
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2726
|
+
/**
|
|
2727
|
+
* log doctor: 诊断 Telemetry 数据质量
|
|
2728
|
+
*/
|
|
2729
|
+
function cmdDoctor(args) {
|
|
2730
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2731
|
+
const changeName = args.change || args['change-name'];
|
|
2732
|
+
const dateFrom = args['date-from'];
|
|
2733
|
+
const dateTo = args['date-to'];
|
|
2734
|
+
|
|
2735
|
+
const dataDir = getDataDir(projectRoot);
|
|
2736
|
+
let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
|
|
2737
|
+
events = filterEventsByDate(events, dateFrom, dateTo);
|
|
2738
|
+
|
|
2739
|
+
const report = computeDoctorReport(events, { change: changeName, projectRoot });
|
|
2740
|
+
console.log(JSON.stringify(report, null, 2));
|
|
2741
|
+
if (report.severe_issues.length > 0) {
|
|
2742
|
+
process.exitCode = 1;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
|
|
2746
|
+
/**
|
|
2747
|
+
* log tasks-status: 扫描 Full/Simple 模式 tasks.md 勾选状态
|
|
2748
|
+
*/
|
|
2749
|
+
function cmdTasksStatus(args) {
|
|
2750
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2751
|
+
const changeName = args.change || args['change-name'];
|
|
2752
|
+
if (!changeName) {
|
|
2753
|
+
console.error('错误: 缺少 --change 参数');
|
|
2754
|
+
process.exit(1);
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
const status = scanTaskCompletion(projectRoot, changeName);
|
|
2758
|
+
console.log(JSON.stringify(status, null, 2));
|
|
2759
|
+
if (args['require-complete'] && (status.task_files.length === 0 || status.has_incomplete)) {
|
|
2760
|
+
process.exitCode = 1;
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
/**
|
|
2765
|
+
* log archive-docs: 真实归档 Simple/Full spec,结束 archive 阶段并生成最终报告
|
|
2766
|
+
*/
|
|
2767
|
+
function cmdArchiveDocs(args) {
|
|
2768
|
+
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
2769
|
+
const changeName = args.change || args['change-name'];
|
|
2770
|
+
const reportOutput = args['report-output'] || args.report_output || '';
|
|
2771
|
+
|
|
2772
|
+
try {
|
|
2773
|
+
let result;
|
|
2774
|
+
const activeChangeDir = getChangeDir(projectRoot, changeName);
|
|
2775
|
+
if (fs.existsSync(activeChangeDir)) {
|
|
2776
|
+
result = archiveChangeDocs(projectRoot, changeName, {
|
|
2777
|
+
reason: args.reason || '',
|
|
2778
|
+
date: args.date,
|
|
2779
|
+
keepActive: Boolean(args['keep-active']),
|
|
2780
|
+
});
|
|
2781
|
+
} else {
|
|
2782
|
+
const repaired = ensureArchiveSuccessArtifacts(projectRoot, changeName, {
|
|
2783
|
+
archive_result: {
|
|
2784
|
+
reason: args.reason || '',
|
|
2785
|
+
report_path: reportOutput,
|
|
2786
|
+
},
|
|
2787
|
+
}, {
|
|
2788
|
+
reason: args.reason || '',
|
|
2789
|
+
reportOutput,
|
|
2790
|
+
});
|
|
2791
|
+
if (!repaired.archive_result || !repaired.archive_result.archive_path) {
|
|
2792
|
+
throw new Error(`change directory not found and no archive directory was detected: ${changeName}`);
|
|
2793
|
+
}
|
|
2794
|
+
result = {
|
|
2795
|
+
project_root: projectRoot,
|
|
2796
|
+
...repaired.archive_result,
|
|
2797
|
+
archive_path: path.resolve(projectRoot, repaired.archive_result.archive_path),
|
|
2798
|
+
active_change_exists: false,
|
|
2799
|
+
};
|
|
2800
|
+
}
|
|
2801
|
+
|
|
2802
|
+
let stageEnd = null;
|
|
2803
|
+
if (!args['keep-active']) {
|
|
2804
|
+
stageEnd = cmdEnd({
|
|
2805
|
+
...args,
|
|
2806
|
+
// 归档原因(args.reason)走 details.archive_result.reason,不应作为 cmdEnd 的返工 --reason(枚举)传入
|
|
2807
|
+
reason: undefined,
|
|
2808
|
+
project: projectRoot,
|
|
2809
|
+
command: 'archive',
|
|
2810
|
+
change: changeName,
|
|
2811
|
+
result: args.result || 'success',
|
|
2812
|
+
summary: args.summary || '变更已真实归档,最终度量报告已生成',
|
|
2813
|
+
details: {
|
|
2814
|
+
...(args.details && typeof args.details === 'object' ? args.details : {}),
|
|
2815
|
+
archive_result: {
|
|
2816
|
+
reason: args.reason || '',
|
|
2817
|
+
method: result.method,
|
|
2818
|
+
archive_path: result.archive_path,
|
|
2819
|
+
report_path: reportOutput,
|
|
2820
|
+
},
|
|
2821
|
+
},
|
|
2822
|
+
'report-output': reportOutput,
|
|
2823
|
+
}, { silent: true });
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
console.log(JSON.stringify({
|
|
2827
|
+
...result,
|
|
2828
|
+
report_output: stageEnd ? stageEnd.report_output : reportOutput,
|
|
2829
|
+
stage_end_event_id: stageEnd ? stageEnd.event_id : undefined,
|
|
2830
|
+
}, null, 2));
|
|
2831
|
+
} catch (err) {
|
|
2832
|
+
console.error(`错误: ${err.message}`);
|
|
2833
|
+
process.exit(1);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
|
|
2837
|
+
// ── CLI 入口 ─────────────────────────────────────────
|
|
2838
|
+
|
|
2839
|
+
function main() {
|
|
2840
|
+
// 支持两种调用方式:
|
|
2841
|
+
// 1) npx kld-sdd log start ... → argv 含 'log'
|
|
2842
|
+
// 2) node skywalk-sdd/log.cjs start ... → argv 直接以子命令开头
|
|
2843
|
+
const argv = process.argv.slice(2);
|
|
2844
|
+
const logIdx = argv.indexOf('log');
|
|
2845
|
+
const subArgs = logIdx === -1 ? argv : argv.slice(logIdx + 1);
|
|
2846
|
+
|
|
2847
|
+
if (!subArgs.length || subArgs[0] === '-h' || subArgs[0] === '--help') {
|
|
2848
|
+
showHelp();
|
|
2849
|
+
process.exit(subArgs.length ? 0 : 1);
|
|
2850
|
+
}
|
|
2851
|
+
const subCommand = subArgs[0];
|
|
2852
|
+
const flags = parseArgs(subArgs.slice(1));
|
|
2853
|
+
|
|
2854
|
+
switch (subCommand) {
|
|
2855
|
+
case 'start':
|
|
2856
|
+
cmdStart(flags);
|
|
2857
|
+
break;
|
|
2858
|
+
case 'end':
|
|
2859
|
+
cmdEnd(flags);
|
|
2860
|
+
break;
|
|
2861
|
+
case 'record':
|
|
2862
|
+
cmdRecord(flags);
|
|
2863
|
+
break;
|
|
2864
|
+
case 'metrics':
|
|
2865
|
+
cmdMetrics(flags);
|
|
2866
|
+
break;
|
|
2867
|
+
case 'report':
|
|
2868
|
+
cmdReport(flags);
|
|
2869
|
+
break;
|
|
2870
|
+
case 'doctor':
|
|
2871
|
+
cmdDoctor(flags);
|
|
2872
|
+
break;
|
|
2873
|
+
case 'tasks-status':
|
|
2874
|
+
cmdTasksStatus(flags);
|
|
2875
|
+
break;
|
|
2876
|
+
case 'archive-docs':
|
|
2877
|
+
cmdArchiveDocs(flags);
|
|
2878
|
+
break;
|
|
2879
|
+
default:
|
|
2880
|
+
showHelp();
|
|
2881
|
+
process.exit(1);
|
|
2882
|
+
}
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
function showHelp() {
|
|
2886
|
+
console.log(`
|
|
2887
|
+
SDD Telemetry CLI - 流程度量采集工具
|
|
2888
|
+
|
|
2889
|
+
用法:
|
|
2890
|
+
node skywalk-sdd/log.cjs start --command=<cmd> --project=<path> [--change=<name>] [--agent=<type>]
|
|
2891
|
+
node skywalk-sdd/log.cjs end --event-id=<id> --result=<success|failure|partial> --summary="..."
|
|
2892
|
+
node skywalk-sdd/log.cjs metrics --project=<path> [--change=<name>] [--pdf-mvp] [--format=json|markdown]
|
|
2893
|
+
node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=json|markdown|html] [--output=<file>]
|
|
2894
|
+
node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
|
|
2895
|
+
node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
|
|
2896
|
+
|
|
2897
|
+
子命令:
|
|
2898
|
+
start 记录 SDD 阶段开始,返回 event_id
|
|
2899
|
+
end 记录 SDD 阶段结束,关联 event_id
|
|
2900
|
+
record 记录 task_update/check_result/test_result 等结构化事件
|
|
2901
|
+
metrics 查询度量指标(四维分析)
|
|
2902
|
+
report 生成只读度量报告(不写入事件)
|
|
2903
|
+
doctor 诊断 Telemetry 数据质量
|
|
2904
|
+
tasks-status 扫描 Full/Simple 模式 tasks.md 勾选状态
|
|
2905
|
+
archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
|
|
2906
|
+
|
|
2907
|
+
示例:
|
|
2908
|
+
node skywalk-sdd/log.cjs start --command=propose --project=/my/project --change=user-auth --agent=cursor
|
|
2909
|
+
node skywalk-sdd/log.cjs end --event-id=evt_abc123 --result=success --summary="创建 proposal.md"
|
|
2910
|
+
node skywalk-sdd/log.cjs record --type=task_update --command=apply --project=/my/project --change=user-auth --task-id=TASK-01 --status=completed
|
|
2911
|
+
node skywalk-sdd/log.cjs record --type=conformance_review --command=check --project=/my/project --change=user-auth --source=manual --details-file=conformance-review.json
|
|
2912
|
+
node skywalk-sdd/log.cjs record --type=ai_adoption_review --command=apply --project=/my/project --change=user-auth --status=final --details-file=ai-adoption.json
|
|
2913
|
+
node skywalk-sdd/log.cjs record --type=survey_result --project=/my/project --change=user-auth --source=manual --details-file=survey.json
|
|
2914
|
+
node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth
|
|
2915
|
+
node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth --pdf-mvp --format=markdown
|
|
2916
|
+
node skywalk-sdd/log.cjs report --project=/my/project --change=user-auth --format=markdown
|
|
2917
|
+
node skywalk-sdd/log.cjs doctor --project=/my/project --change=user-auth
|
|
2918
|
+
node skywalk-sdd/log.cjs tasks-status --project=/my/project --change=user-auth --require-complete
|
|
2919
|
+
node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive
|
|
2920
|
+
# archive-docs 最终报告默认生成到 openspec/changes/archive/<日期>-<change>/reports/<change>-report.md;可用 --report-output=<路径> 指定自定义路径
|
|
2921
|
+
node skywalk-sdd/log.cjs record --type=worktree_finish --command=apply --project=/my/project --change=user-auth --capability=user-auth --result=success --summary="merge+remove completed"
|
|
2922
|
+
|
|
2923
|
+
Apply worktree(主仓库根目录,非 log.cjs 子命令):
|
|
2924
|
+
node skywalk-sdd/apply-worktree-finish.cjs --record-base --change=<name> --capability=<name>
|
|
2925
|
+
node skywalk-sdd/apply-worktree-finish.cjs --change=<name> --capability=<name> [--target=<integration-base>] [--dry-run]
|
|
2926
|
+
`);
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
// 导出供测试使用
|
|
2930
|
+
module.exports = {
|
|
2931
|
+
SCHEMA_VERSION,
|
|
2932
|
+
main,
|
|
2933
|
+
parseArgs,
|
|
2934
|
+
cmdStart,
|
|
2935
|
+
cmdEnd,
|
|
2936
|
+
cmdRecord,
|
|
2937
|
+
cmdMetrics,
|
|
2938
|
+
cmdReport,
|
|
2939
|
+
cmdDoctor,
|
|
2940
|
+
cmdTasksStatus,
|
|
2941
|
+
cmdArchiveDocs,
|
|
2942
|
+
computeChangeMetrics,
|
|
2943
|
+
computeOverviewMetrics,
|
|
2944
|
+
computeCapabilityMetrics,
|
|
2945
|
+
computeTelemetryHealthMetrics,
|
|
2946
|
+
computePdfMvpMetrics,
|
|
2947
|
+
renderPdfMvpMarkdown,
|
|
2948
|
+
renderExecutiveReportMarkdown,
|
|
2949
|
+
renderExecutiveReportHtml,
|
|
2950
|
+
buildReport,
|
|
2951
|
+
computeGitDocumentMetrics,
|
|
2952
|
+
getCheckResults,
|
|
2953
|
+
getBuildResults,
|
|
2954
|
+
getTestResults,
|
|
2955
|
+
getTaskUpdateResult,
|
|
2956
|
+
computeAiFirstPassMetrics,
|
|
2957
|
+
getSpecTestCoverage,
|
|
2958
|
+
getSpecTestCoverageEvents,
|
|
2959
|
+
computeSpecTestCoverageMetrics,
|
|
2960
|
+
getConformanceReview,
|
|
2961
|
+
getConformanceReviewEvents,
|
|
2962
|
+
computeConformanceMetrics,
|
|
2963
|
+
getAiAdoptionReview,
|
|
2964
|
+
getAiAdoptionEvents,
|
|
2965
|
+
computeAiAdoptionMetrics,
|
|
2966
|
+
getSurveyResult,
|
|
2967
|
+
getBaselineRecord,
|
|
2968
|
+
computeManualInsightMetrics,
|
|
2969
|
+
computeDoctorReport,
|
|
2970
|
+
scanTaskCompletion,
|
|
2971
|
+
archiveChangeDocs,
|
|
2972
|
+
filterEventsByDate,
|
|
2973
|
+
readEvents,
|
|
2974
|
+
readAllEvents,
|
|
2975
|
+
appendEvent,
|
|
2976
|
+
getDataDir,
|
|
2977
|
+
getStateDir,
|
|
2978
|
+
safeChangeName,
|
|
2979
|
+
generateEventId,
|
|
2980
|
+
normalizeProjectRoot,
|
|
2981
|
+
parseJsonOption,
|
|
2982
|
+
writeActiveStage,
|
|
2983
|
+
findActiveStage,
|
|
2984
|
+
clearActiveStage,
|
|
2985
|
+
findStartEvent,
|
|
2986
|
+
};
|
|
2987
|
+
|
|
2988
|
+
// 直接运行时执行
|
|
2989
|
+
if (require.main === module) {
|
|
2990
|
+
main();
|
|
2991
|
+
}
|