kld-sdd 2.4.7 → 2.4.8

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