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