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