kld-sdd 2.4.7 → 2.4.9

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