kld-sdd 2.4.19 → 2.5.0

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 (27) hide show
  1. package/lib/init.js +9 -12
  2. package/package.json +1 -1
  3. package/skywalk-sdd/index.cjs +1808 -129
  4. package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +175 -28
  5. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +42 -21
  6. package/templates/openspec/proposal.md +0 -1
  7. package/templates/openspec/spec.md +2 -2
  8. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +64 -355
  9. package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
  10. package/templates/skills/kld-sdd/opsx-apply/reference.md +403 -0
  11. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +21 -5
  12. package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
  13. package/templates/skills/kld-sdd/opsx-check/SKILL.md +28 -4
  14. package/templates/skills/kld-sdd/opsx-check/checklist.md +37 -0
  15. package/templates/skills/kld-sdd/opsx-design/SKILL.md +46 -50
  16. package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
  17. package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
  18. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +51 -95
  19. package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
  20. package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
  21. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +46 -50
  22. package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
  23. package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
  24. package/templates/skills/kld-sdd/opsx-task/SKILL.md +42 -45
  25. package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
  26. package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
  27. package/templates/skills/kld-sdd/opsx-test/SKILL.md +12 -0
@@ -18,6 +18,26 @@ const { execFileSync } = require('child_process');
18
18
 
19
19
  const SCHEMA_VERSION = 2;
20
20
 
21
+ // result → 状态标记映射(execution-log.md 人读标记;checklist/SKILL 文档同步说明这套映射)
22
+ const RESULT_MARK = { success: '✅OK', partial: '🟡WARN', failure: '❌FAIL' };
23
+
24
+ // 返工原因 6 类(事件状态 + report-time git 推导 + agent 兜底);agent --reason 枚举校验白名单
25
+ // T3.6: 新增 pre-archive-recheck(check 在 test 后重跑,归档前强制复检,无 git diff 证据)
26
+ const REWORK_REASON_CATEGORIES = ['incomplete', 'prev-failed', 'code-changed-after-pass', 'recheck-no-change', 'pre-archive-recheck', 'unspecified'];
27
+ // 返工原因英文 code → 中文展示映射(报告面向人读,英文 code 不直观)
28
+ // 2.6.x 术语调整:将"返工"统一改为"重复执行/流程修正",避免误解为代码重做
29
+ const REWORK_REASON_CN = {
30
+ 'incomplete': '未闭环',
31
+ 'prev-failed': '前次未完成',
32
+ 'code-changed-after-pass': '通过后代码变更',
33
+ 'recheck-no-change': '复检无变更',
34
+ 'pre-archive-recheck': '归档前强制复检',
35
+ 'unspecified': '未归类',
36
+ };
37
+ function createReasonCategory() {
38
+ return { incomplete: 0, 'prev-failed': 0, 'code-changed-after-pass': 0, 'recheck-no-change': 0, 'pre-archive-recheck': 0, unspecified: 0 };
39
+ }
40
+
21
41
  // ── 配置 ──────────────────────────────────────────────
22
42
 
23
43
  /** 获取项目的 skywalk-sdd 数据目录 */
@@ -89,11 +109,25 @@ function parseJsonOption(args, inlineKey, fileKey, projectRoot, fallback = {}) {
89
109
  return JSON.parse(inlineValue);
90
110
  }
91
111
  if (fileValue) {
92
- return readJsonFile(fileValue, projectRoot);
112
+ const resolvedFileValue = normalizeDetailsFilePath(fileValue, projectRoot);
113
+ return readJsonFile(resolvedFileValue, projectRoot);
93
114
  }
94
115
  return fallback;
95
116
  }
96
117
 
118
+ /** 规范化 details-file 路径:相对路径自动归一化到 skywalk-sdd/state/,避免污染项目根目录 */
119
+ function normalizeDetailsFilePath(fileValue, projectRoot) {
120
+ if (!fileValue) return fileValue;
121
+ if (path.isAbsolute(fileValue)) return fileValue;
122
+ const normalized = fileValue.replace(/\\/g, '/');
123
+ // 已显式指向 skywalk-sdd/ 下任意目录 → 保持原样(兼容旧路径与新的 state/ 路径)
124
+ if (normalized.startsWith('skywalk-sdd/')) return fileValue;
125
+ // 默认写入 skywalk-sdd/state/,避免根目录污染
126
+ const stateDir = path.join(getDataDir(projectRoot), 'state');
127
+ ensureDir(stateDir);
128
+ return path.join(stateDir, path.basename(normalized));
129
+ }
130
+
97
131
  function cleanOptionalFields(event) {
98
132
  for (const key of Object.keys(event)) {
99
133
  if (event[key] == null || event[key] === '') {
@@ -299,6 +333,9 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
299
333
  completed += 1;
300
334
  fileCompleted += 1;
301
335
  } else if (/\[\s\]/.test(line)) {
336
+ // P2-1: `- [ ] 无` 是占位符(表示"无文档更新/无风险/无遗留"),不计入 incomplete
337
+ const afterCheckbox = line.replace(/^\s*-\s*\[\s\]\s*/, '').trim();
338
+ if (afterCheckbox === '无') return;
302
339
  incomplete += 1;
303
340
  fileIncomplete += 1;
304
341
  incompleteItems.push({
@@ -328,6 +365,18 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
328
365
  };
329
366
  }
330
367
 
368
+ // P0-3: 读取 check-result state 文件(check_result 事件 details 缺 consistency_score 时 fallback)
369
+ function readCheckResultState(projectRoot, changeName) {
370
+ if (!projectRoot || !changeName) return null;
371
+ const statePath = path.join(projectRoot, 'skywalk-sdd', 'state', `check-result-${changeName}.json`);
372
+ if (!fs.existsSync(statePath)) return null;
373
+ try {
374
+ return JSON.parse(fs.readFileSync(statePath, 'utf8'));
375
+ } catch {
376
+ return null;
377
+ }
378
+ }
379
+
331
380
  function scanTaskCompletion(projectRoot, changeName) {
332
381
  const normalizedRoot = normalizeProjectRoot(projectRoot);
333
382
  const taskFiles = discoverTaskFiles(normalizedRoot, changeName);
@@ -343,7 +392,77 @@ function scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir) {
343
392
  return scanTaskCompletionFromFiles(normalizedRoot, changeName, taskFiles);
344
393
  }
345
394
 
346
- function discoverFullSpecFiles(changeDir) {
395
+ // ── check-task 子命令辅助函数 ─────────────────────────────
396
+
397
+ /** 在 tasks.md 文件中定位并勾选指定 task_id 的 checkbox */
398
+ function checkTaskInFile(filePath, taskId, dryRun = false) {
399
+ const content = fs.readFileSync(filePath, 'utf8');
400
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
401
+ const lines = content.split(/\r?\n/);
402
+ const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
403
+ const taskHeaderRe = new RegExp(`^###\\s*\\[?${escapedTaskId}\\]?(?:\\s|$|\\])`);
404
+ let inTaskSection = false;
405
+ let changed = false;
406
+ const newLines = lines.map((line) => {
407
+ if (/^###\s/.test(line)) {
408
+ inTaskSection = taskHeaderRe.test(line);
409
+ }
410
+ if (inTaskSection && (/^\s*-\s*\[\s\]/.test(line) || /^\s*\*\*状态\*\*:\s*\[\s\]/.test(line))) {
411
+ changed = true;
412
+ return line.replace(/\[ \]/, '[x]');
413
+ }
414
+ return line;
415
+ });
416
+ if (changed && !dryRun) {
417
+ fs.writeFileSync(filePath, newLines.join(eol), 'utf8');
418
+ }
419
+ return { changed, linesChanged: changed ? 1 : 0 };
420
+ }
421
+
422
+ /** 同步执行 check-task:扫描变更目录下的 tasks.md 并勾选对应 task_id */
423
+ function runCheckTaskSync(projectRoot, changeName, taskId, dryRun = false) {
424
+ const normalizedRoot = normalizeProjectRoot(projectRoot);
425
+ const taskFiles = discoverTaskFiles(normalizedRoot, changeName);
426
+ if (taskFiles.length === 0) {
427
+ throw new Error(`未找到 change ${changeName} 的 tasks.md`);
428
+ }
429
+ let totalChanged = 0;
430
+ const changedFiles = [];
431
+ for (const filePath of taskFiles) {
432
+ const result = checkTaskInFile(filePath, taskId, dryRun);
433
+ if (result.changed) {
434
+ totalChanged += 1;
435
+ changedFiles.push(path.relative(normalizedRoot, filePath).replace(/\\/g, '/'));
436
+ }
437
+ }
438
+ return { changed: totalChanged > 0, changed_files: changedFiles, task_files_checked: taskFiles.length };
439
+ }
440
+
441
+ /** check-task 子命令 CLI */
442
+ function cmdCheckTask(args) {
443
+ const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
444
+ const changeName = args.change || args['change-name'];
445
+ const taskId = args['task-id'] || args.task_id;
446
+ const dryRun = Boolean(args['dry-run']) || Boolean(args.dry_run);
447
+
448
+ if (!changeName) {
449
+ console.error('错误: 缺少 --change 参数');
450
+ process.exit(1);
451
+ }
452
+ if (!taskId) {
453
+ console.error('错误: 缺少 --task-id 参数');
454
+ process.exit(1);
455
+ }
456
+
457
+ const result = runCheckTaskSync(projectRoot, changeName, taskId, dryRun);
458
+ console.log(JSON.stringify(result, null, 2));
459
+ if (!result.changed && !dryRun) {
460
+ process.exitCode = 1;
461
+ }
462
+ return result;
463
+ }
464
+
465
+ function discoverFullSpecFiles(changeDir, changeName = null) {
347
466
  const specsDir = path.join(changeDir, 'specs');
348
467
  const specs = [];
349
468
 
@@ -359,7 +478,8 @@ function discoverFullSpecFiles(changeDir) {
359
478
 
360
479
  const simpleSpecPath = path.join(changeDir, 'spec.md');
361
480
  if (fs.existsSync(simpleSpecPath)) {
362
- specs.push({ capability: path.basename(changeDir), source: simpleSpecPath });
481
+ // M3/N8: simple 模式 capability 优先用原始 changeName(稳定标识),避免归档后 archiveDir 带日期前缀污染
482
+ specs.push({ capability: changeName || path.basename(changeDir), source: simpleSpecPath });
363
483
  }
364
484
 
365
485
  return specs.sort((a, b) => a.capability.localeCompare(b.capability));
@@ -382,9 +502,9 @@ function findArchivedChangeDir(projectRoot, changeName) {
382
502
  return candidates[0] || null;
383
503
  }
384
504
 
385
- function syncArchivedSpecs(projectRoot, archiveDir) {
505
+ function syncArchivedSpecs(projectRoot, archiveDir, changeName) {
386
506
  const copiedSpecs = [];
387
- const specFiles = discoverFullSpecFiles(archiveDir);
507
+ const specFiles = discoverFullSpecFiles(archiveDir, changeName);
388
508
 
389
509
  for (const spec of specFiles) {
390
510
  const targetSpec = path.join(projectRoot, 'openspec', 'specs', spec.capability, 'spec.md');
@@ -402,15 +522,24 @@ function syncArchivedSpecs(projectRoot, archiveDir) {
402
522
 
403
523
  function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}) {
404
524
  const manifestPath = path.join(archiveDir, 'archive-manifest.json');
405
- const copiedSpecs = syncArchivedSpecs(projectRoot, archiveDir);
525
+ const copiedSpecs = syncArchivedSpecs(projectRoot, archiveDir, changeName);
526
+ // U4: 归档时拷 events jsonl 进 archive/evidence/events/,报告可从归档目录重建(源数据与产物不再分离)
527
+ const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(changeName));
528
+ let evidenceEventsPath = null;
529
+ if (fs.existsSync(eventsSrcDir)) {
530
+ const eventsDstDir = path.join(archiveDir, 'evidence', 'events');
531
+ copyDirSync(eventsSrcDir, eventsDstDir);
532
+ evidenceEventsPath = toProjectRelative(projectRoot, eventsDstDir);
533
+ }
406
534
  const manifest = {
407
535
  change: changeName,
408
536
  archived_at: options.archivedAt || nowISO(),
409
- reason: options.reason || '',
537
+ reason: options.reason || '变更已完成实施',
410
538
  method: options.method || 'skywalk-full-spec-archive',
411
539
  source_path: `openspec/changes/${changeName}`,
412
540
  archive_path: toProjectRelative(projectRoot, archiveDir),
413
541
  copied_specs: copiedSpecs,
542
+ evidence_events_path: evidenceEventsPath,
414
543
  };
415
544
  fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
416
545
  return {
@@ -423,10 +552,7 @@ function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}
423
552
  function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, options = {}) {
424
553
  const normalizedRoot = normalizeProjectRoot(projectRoot);
425
554
  const activeChangeDir = getChangeDir(normalizedRoot, changeName);
426
- const archiveReason = details.archive_result?.reason || options.reason || '';
427
- const reportPath = options.reportOutput
428
- ? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
429
- : '';
555
+ let archiveReason = details.archive_result?.reason || options.reason || '变更已完成实施';
430
556
 
431
557
  let archiveDir = details.archive_result?.archive_path
432
558
  ? path.resolve(normalizedRoot, details.archive_result.archive_path)
@@ -444,21 +570,34 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
444
570
  changed: false,
445
571
  archive_result: details.archive_result || {},
446
572
  task_completion: details.archive_result?.task_completion || null,
573
+ reportPath: '',
447
574
  };
448
575
  }
449
576
 
577
+ // 显式 reportOutput 优先;否则默认落归档后 archive 目录的 reports/ 子目录
578
+ const reportPath = options.reportOutput
579
+ ? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
580
+ : path.join(archiveDir, 'reports', `${safeChangeName(changeName)}-report.md`);
581
+
582
+ // P2-2: task_completion 提前计算,has_incomplete 时调整归档原因
583
+ const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
584
+ if (taskCompletion?.has_incomplete && !details.archive_result?.reason) {
585
+ archiveReason = `部分完成(${taskCompletion.incomplete} 项验收未勾选)`;
586
+ }
450
587
  const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
451
588
  reason: archiveReason,
452
589
  method: archiveMethod || 'skywalk-full-spec-archive',
453
590
  archivedAt: details.archive_result?.archived_at || nowISO(),
454
591
  });
455
- const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
456
592
 
457
593
  const archiveResult = {
458
594
  reason: archiveReason,
459
595
  method: manifestInfo.manifest.method,
460
596
  archive_path: manifestInfo.manifest.archive_path,
461
- report_path: reportPath ? toProjectRelative(normalizedRoot, reportPath) : (details.archive_result?.report_path || ''),
597
+ report_path: toProjectRelative(normalizedRoot, reportPath),
598
+ // report_html_path 为预期路径:由 cmdEnd 落盘阶段写入(try/catch 容错,失败时不阻塞 md 主产物)。
599
+ // 此处无条件派生,html 实际落盘失败时该路径可能不存在;语义为"预期路径",消费者不应假定文件已存在。
600
+ report_html_path: toProjectRelative(normalizedRoot, reportPath.replace(/\.md$/i, '.html')),
462
601
  manifest_path: toProjectRelative(normalizedRoot, manifestInfo.manifest_path),
463
602
  task_completion: taskCompletion,
464
603
  copied_specs: manifestInfo.copied_specs,
@@ -468,6 +607,7 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
468
607
  changed: true,
469
608
  archive_result: archiveResult,
470
609
  task_completion: taskCompletion,
610
+ reportPath,
471
611
  };
472
612
  }
473
613
 
@@ -494,8 +634,14 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
494
634
  const archiveDir = nextAvailableDir(archiveRoot, `${archiveDate}-${changeName}`);
495
635
 
496
636
  copyDirSync(sourceDir, archiveDir);
637
+ // P2-2: 根据 task_completion 调整归档原因,has_incomplete 时不写"已完成实施"
638
+ const tcForReason = scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
639
+ let archiveReason = options.reason || '变更已完成实施';
640
+ if (tcForReason?.has_incomplete) {
641
+ archiveReason = `部分完成(${tcForReason.incomplete} 项验收未勾选)`;
642
+ }
497
643
  const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
498
- reason: options.reason || '',
644
+ reason: archiveReason,
499
645
  method: 'skywalk-full-spec-archive',
500
646
  });
501
647
  const manifest = manifestInfo.manifest;
@@ -522,6 +668,89 @@ function appendEvent(dataDir, changeName, event) {
522
668
  if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
523
669
  throw new Error(`事件文件写入验证失败: ${file}`);
524
670
  }
671
+ appendMarkdownLog(dataDir, changeName, event);
672
+ }
673
+
674
+ /** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
675
+ * execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
676
+ function formatLocalTimestamp(iso) {
677
+ const d = new Date(iso || '');
678
+ if (Number.isNaN(d.getTime())) return String(iso || '');
679
+ const pad = (n) => String(n).padStart(2, '0');
680
+ const local = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ` +
681
+ `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
682
+ const offsetMin = -d.getTimezoneOffset();
683
+ const sign = offsetMin >= 0 ? '+' : '-';
684
+ const abs = Math.abs(offsetMin);
685
+ return `${local} (UTC${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)})`;
686
+ }
687
+
688
+ /** 把事件同步追加为人读 markdown 行到 openspec/changes/<change>/logs/execution-log.md
689
+ * (telemetry 副本;change 已归档时写入 archive 目录;失败不阻塞 telemetry 主流程) */
690
+ function appendMarkdownLog(dataDir, changeName, event) {
691
+ try {
692
+ const projectRoot = path.dirname(dataDir);
693
+ const activeChangeDir = getChangeDir(projectRoot, changeName);
694
+ // Q1: 未 openspec new change 前(无 .openspec.yaml 且无 archive-manifest.json)不创建 logs/,避免 propose 残留误报
695
+ const hasChangeMarker = fs.existsSync(path.join(activeChangeDir, '.openspec.yaml'))
696
+ || fs.existsSync(path.join(activeChangeDir, 'archive-manifest.json'));
697
+ if (!hasChangeMarker && !fs.existsSync(activeChangeDir)) {
698
+ // 目录本身不存在,检查是否为已归档变更
699
+ const archiveDir = findArchivedChangeDir(projectRoot, changeName);
700
+ if (!archiveDir) return; // 未 init 且未归档 → 跳过 logs/ 创建
701
+ const archiveLogsDir = path.join(archiveDir, 'logs');
702
+ ensureDir(archiveLogsDir);
703
+ const file = path.join(archiveLogsDir, 'execution-log.md');
704
+ if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
705
+ const created = formatLocalTimestamp(event.timestamp || nowISO());
706
+ fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
707
+ }
708
+ const ts = formatLocalTimestamp(event.timestamp || nowISO());
709
+ const stage = event.command || event.stage || 'unknown';
710
+ const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
711
+ if (event.type === 'process_note') {
712
+ const kind = event.details?.kind || 'note';
713
+ const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
714
+ const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
715
+ fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
716
+ return;
717
+ }
718
+ const type = event.type || 'event';
719
+ const result = event.result || '';
720
+ const mark = RESULT_MARK[result] || '📝';
721
+ fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
722
+ return;
723
+ }
724
+ let logsDir = path.join(activeChangeDir, 'logs');
725
+ if (!fs.existsSync(activeChangeDir)) {
726
+ const archiveDir = findArchivedChangeDir(projectRoot, changeName);
727
+ if (archiveDir) logsDir = path.join(archiveDir, 'logs');
728
+ }
729
+ ensureDir(logsDir);
730
+ const file = path.join(logsDir, 'execution-log.md');
731
+ if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
732
+ const created = formatLocalTimestamp(event.timestamp || nowISO());
733
+ fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
734
+ }
735
+ const ts = formatLocalTimestamp(event.timestamp || nowISO());
736
+ const stage = event.command || event.stage || 'unknown';
737
+ const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
738
+ // process_note 渲染为带 kind/round 的叙事行(U2 叙事信号源 + U3 修复可追溯)
739
+ if (event.type === 'process_note') {
740
+ const kind = event.details?.kind || 'note';
741
+ const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
742
+ const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
743
+ fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
744
+ return;
745
+ }
746
+ const type = event.type || 'event';
747
+ const result = event.result || '';
748
+ const mark = RESULT_MARK[result] || '📝';
749
+ fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
750
+ } catch (err) {
751
+ // markdown 副本失败不影响 telemetry 主流程,但输出 stderr 提示保留可观测性(避免持续性失败长期静默)
752
+ try { console.error(`[telemetry] execution-log 副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
753
+ }
525
754
  }
526
755
 
527
756
  /** 读取指定 change 的所有事件 */
@@ -659,8 +888,40 @@ function computeChangeMetrics(changeName, events) {
659
888
  const agentTypes = [...new Set(changeEvents.filter(e => e.agent_type).map(e => e.agent_type))];
660
889
  const isCompleted = !!archiveEnd;
661
890
 
891
+ // ── 变更摘要(execution-log 与报告共用,消除 1 vs 2 不一致;T1.3 + T4.4) ──
892
+ const stageStartCount = starts.length;
893
+ const stageEndCount = ends.length;
894
+ const distinctStartStages = new Set(starts.map((e) => e.command)).size;
895
+ const stageRepeatCount = Math.max(0, stageStartCount - distinctStartStages);
896
+ // A2(v3): 过滤空壳 test_result(passed=0 && failed=0 && duration_ms<=0/null),
897
+ // 只在真实测试结果中取最新一条。todo-cli 场景:claude-hook 空壳占位 + 真实 19/0 混合时,
898
+ // 旧逻辑取最晚空壳 → test_pass={0,0} 或 stage_end(无数值) → null,掩盖真实通过率。
899
+ // 不复用 isRealTestDetails(其要求 command 非空,会误过滤无 command 的真实 test_result)
900
+ const realTestEvents = testEvents.filter(e => {
901
+ const r = getTestResults(e);
902
+ if (!r) return false;
903
+ return r.passed > 0 || r.failed > 0 || (Number.isFinite(r.duration_ms) && r.duration_ms > 0);
904
+ });
905
+ const latestTestEvent = realTestEvents.length > 0 ? latestByTimestamp(realTestEvents) : null;
906
+ const latestTestResults = latestTestEvent ? getTestResults(latestTestEvent) : null;
907
+ const testPassSummary = latestTestResults
908
+ ? { passed: Number(latestTestResults.passed) || 0, failed: Number(latestTestResults.failed) || 0 }
909
+ : null;
910
+ const effectiveStageDurationMs = ends.reduce((s, e) => s + (Number(e.duration_ms) || 0), 0);
911
+ const idleTimeMs = changeLeadTime != null
912
+ ? Math.max(0, changeLeadTime - effectiveStageDurationMs)
913
+ : null;
914
+
662
915
  return {
663
916
  change: changeName,
917
+ change_summary: {
918
+ stage_start_count: stageStartCount,
919
+ stage_end_count: stageEndCount,
920
+ stage_repeat_count: stageRepeatCount,
921
+ test_pass: testPassSummary,
922
+ effective_stage_duration_ms: effectiveStageDurationMs,
923
+ idle_time_ms: idleTimeMs,
924
+ },
664
925
  process_health: {
665
926
  critical_path_coverage: criticalPathCoverage,
666
927
  covered_core_stages: coveredCoreStages,
@@ -682,9 +943,12 @@ function computeChangeMetrics(changeName, events) {
682
943
  apply_test_fix_cycles: applyCount,
683
944
  coverage_trend: coverageTrend,
684
945
  q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
946
+ q1_human_status: conformanceMetrics.human_status,
685
947
  q4_spec_driven_test_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
686
948
  conformance_counts: conformanceMetrics.conformance_counts,
687
- conformance_manual_confirmed: conformanceMetrics.manual_confirmed,
949
+ conformance_agent_confirmed: conformanceMetrics.agent_confirmed,
950
+ conformance_reviewer: conformanceMetrics.reviewer,
951
+ conformance_reviewer_independence: conformanceMetrics.reviewer_independence,
688
952
  spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
689
953
  p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
690
954
  ai_adoption_level: aiAdoptionMetrics.adoption_level,
@@ -856,6 +1120,7 @@ function createReworkBucket(command, capability) {
856
1120
  superseded_open_stages: 0,
857
1121
  unresolved_open_stages: 0,
858
1122
  rework_duration_ms: 0,
1123
+ reasons_by_category: createReasonCategory(),
859
1124
  };
860
1125
  }
861
1126
 
@@ -873,6 +1138,8 @@ function summarizeStageExecutions(events) {
873
1138
  endsById.get(end.event_id).push(end);
874
1139
  }
875
1140
 
1141
+ // N4: event_id 配对——cmdStart 默认每次生成新 event_id,cmdEnd 用 --event-id 配对;
1142
+ // 若手动传同 --event-id 重跑 start 会产出同 id 多 start,配对脆弱(两个 attempt 共享同一 end)。cmdStart 默认行为已规避。
876
1143
  const attempts = starts.map(start => {
877
1144
  const end = latestByTimestamp(endsById.get(start.event_id) || []);
878
1145
  return {
@@ -904,12 +1171,10 @@ function summarizeStageExecutions(events) {
904
1171
  let unresolvedOpenStages = 0;
905
1172
 
906
1173
  for (const groupAttempts of groups.values()) {
907
- const successfulAttempts = groupAttempts.filter(attempt => attempt.end && attempt.result === 'success');
908
- const canonical = latestByTimestamp(successfulAttempts.map(attempt => attempt.end))
909
- ? successfulAttempts.find(attempt => {
910
- const latestEnd = latestByTimestamp(successfulAttempts.map(item => item.end));
911
- return attempt.end === latestEnd;
912
- })
1174
+ // canonical:同 key 取最晚 end attempt(不限 result,含 partial/failure)—— 全 partial 场景也需 canonical 驱动 rework 判定
1175
+ const endedAttempts = groupAttempts.filter(attempt => attempt.end);
1176
+ const canonical = endedAttempts.length
1177
+ ? endedAttempts.reduce((a, b) => timestampMs(a.end) >= timestampMs(b.end) ? a : b)
913
1178
  : null;
914
1179
  if (canonical) {
915
1180
  canonical.canonical = true;
@@ -930,7 +1195,17 @@ function summarizeStageExecutions(events) {
930
1195
  if (attempt === canonical) continue;
931
1196
 
932
1197
  if (canonical && timestampMs(attempt.start) <= timestampMs(canonical.end || canonical.start)) {
933
- attempt.rework_reason = attempt.end ? 'completed_rework' : 'superseded_open';
1198
+ if (attempt.end && attempt.end.rework_reason) {
1199
+ attempt.rework_reason = attempt.end.rework_reason;
1200
+ } else if (!attempt.end) {
1201
+ attempt.rework_reason = 'incomplete';
1202
+ } else if (attempt.result === 'failure' || attempt.result === 'partial') {
1203
+ attempt.rework_reason = 'prev-failed';
1204
+ } else if (canonical.result !== 'success') {
1205
+ attempt.rework_reason = 'prev-failed';
1206
+ } else {
1207
+ attempt.rework_reason = 'completed_rework_success';
1208
+ }
934
1209
  reworkAttempts.push(attempt);
935
1210
  bucket.rework_attempts += 1;
936
1211
  if (attempt.end) {
@@ -1020,7 +1295,7 @@ function getFormalStageRelatedEvents(events, attempt, predicate) {
1020
1295
  return [];
1021
1296
  }
1022
1297
 
1023
- function compactReworkSummary(summary) {
1298
+ function compactReworkSummary(summary, reasons) {
1024
1299
  return {
1025
1300
  total_attempts: summary.total_attempts,
1026
1301
  canonical_attempts: summary.canonical_attempts,
@@ -1032,7 +1307,95 @@ function compactReworkSummary(summary) {
1032
1307
  rework_stage_duration_ms: summary.rework_stage_duration_ms,
1033
1308
  total_stage_duration_ms: summary.total_stage_duration_ms,
1034
1309
  by_stage: summary.by_stage,
1310
+ reasons: reasons || { by_category: createReasonCategory(), details: [] },
1311
+ };
1312
+ }
1313
+
1314
+ // report-time git 推导返工原因:对 summarizeStageExecutions 产出的 reworkAttempts 细化
1315
+ // - agent override 优先(stage_end.rework_reason 由 cmdEnd --reason 写入,summarizeStageExecutions 已保留)
1316
+ // - completed_rework_success(上次成功却重跑)按 git 推导:有 commit since end → code-changed-after-pass;
1317
+ // 无 commit 但 working tree 未提交 → code-changed-after-pass(has_uncommitted);都无 → recheck-no-change
1318
+ // - no git repo → unspecified
1319
+ // 注:用 `git log --all --since` 取所有 ref 上 commit date >= end.timestamp 的提交;reset 掉的 unreachable
1320
+ // commit 不计入(完全实现需 reflog,复杂且会 expire,本期务实按 ref 可达)
1321
+ function deriveReworkReasons(executionSummary, projectRoot) {
1322
+ const byCategory = createReasonCategory();
1323
+ const details = [];
1324
+ const reworkAttempts = executionSummary.reworkAttempts || [];
1325
+ const gitAvailable = projectRoot ? runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']) === 'true' : false;
1326
+ let uncommittedCache = null;
1327
+ const hasUncommitted = () => {
1328
+ if (uncommittedCache !== null) return uncommittedCache;
1329
+ if (!gitAvailable) { uncommittedCache = false; return false; }
1330
+ const status = runGit(projectRoot, ['status', '--porcelain']);
1331
+ uncommittedCache = Boolean(status && status.trim());
1332
+ return uncommittedCache;
1035
1333
  };
1334
+ const bucketByKey = new Map();
1335
+ for (const bucket of executionSummary.summary.by_stage || []) {
1336
+ if (!bucket.reasons_by_category) bucket.reasons_by_category = createReasonCategory();
1337
+ bucketByKey.set(`${bucket.command}|${bucket.capability || ''}`, bucket);
1338
+ }
1339
+ for (const attempt of reworkAttempts) {
1340
+ let reason = attempt.rework_reason;
1341
+ let detail = null;
1342
+ let hasUncommittedFlag = false;
1343
+ if (reason === 'completed_rework_success') {
1344
+ if (!gitAvailable) {
1345
+ reason = 'unspecified';
1346
+ } else {
1347
+ const endTs = attempt.end && attempt.end.timestamp ? Date.parse(attempt.end.timestamp) : null;
1348
+ let recentCommitMessage = null;
1349
+ if (endTs !== null && Number.isFinite(endTs)) {
1350
+ const sinceIso = new Date(endTs).toISOString();
1351
+ const log = runGit(projectRoot, ['log', '--all', '--since', sinceIso, '--format=%H%x09%cI%x09%s']);
1352
+ if (log) {
1353
+ const lines = log.split(/\r?\n/).filter(Boolean);
1354
+ if (lines.length > 0) {
1355
+ const parts = lines[0].split('\t');
1356
+ recentCommitMessage = parts[2] || '';
1357
+ }
1358
+ }
1359
+ }
1360
+ const uncommitted = hasUncommitted();
1361
+ if (recentCommitMessage !== null) {
1362
+ reason = 'code-changed-after-pass';
1363
+ detail = recentCommitMessage;
1364
+ hasUncommittedFlag = uncommitted;
1365
+ } else if (uncommitted) {
1366
+ reason = 'code-changed-after-pass';
1367
+ hasUncommittedFlag = true;
1368
+ } else {
1369
+ reason = 'recheck-no-change';
1370
+ }
1371
+ }
1372
+ }
1373
+ // T3.6 + Q6: check 在 test 之后重跑(归档前强制复检)→ pre-archive-recheck(不论原 reason)
1374
+ // 判定:attempt 为 check 且 check 的 canonical 开始时间晚于 test 的 canonical 结束时间 → 归档前强制复检
1375
+ if (attempt.command === 'check') {
1376
+ const canonicalCheck = getCanonicalAttempt(executionSummary, 'check', attempt.capability);
1377
+ // Y2 修复:test 也传 capability 避免跨 capability 取到错误 canonical(summarizeStageExecutions 按 command|capability 分桶)
1378
+ const canonicalTest = getCanonicalAttempt(executionSummary, 'test', attempt.capability);
1379
+ if (canonicalCheck && canonicalCheck.start && canonicalTest && canonicalTest.end
1380
+ && timestampMs(canonicalCheck.start) > timestampMs(canonicalTest.end)) {
1381
+ reason = 'pre-archive-recheck';
1382
+ }
1383
+ }
1384
+ attempt.rework_reason = reason;
1385
+ byCategory[reason] = (byCategory[reason] || 0) + 1;
1386
+ const bucket = bucketByKey.get(`${attempt.command}|${attempt.capability || ''}`);
1387
+ if (bucket) {
1388
+ bucket.reasons_by_category[reason] = (bucket.reasons_by_category[reason] || 0) + 1;
1389
+ }
1390
+ details.push({
1391
+ command: attempt.command,
1392
+ attempt_event_id: (attempt.start && attempt.start.event_id) || null,
1393
+ reason,
1394
+ detail,
1395
+ has_uncommitted: hasUncommittedFlag,
1396
+ });
1397
+ }
1398
+ return { by_category: byCategory, details };
1036
1399
  }
1037
1400
 
1038
1401
  function readSuccessSignal(event, detailKeys) {
@@ -1124,32 +1487,70 @@ function getTaskUpdateResult(event) {
1124
1487
  if (!event || event.type !== 'task_update') return null;
1125
1488
  const build = getBuildResults(event);
1126
1489
  const test = getTestResults(event);
1490
+ const taskKind = event?.details?.task_kind || event?.task_kind || null;
1491
+ // P3: TDD 测试骨架任务(task_kind='test-skeleton')的红灯是预期(实现尚未编写),
1492
+ // failed>0 不计为成码失败;骨架按 TDD 节奏完成即视为一次成码,避免 E4 系统性失真。
1493
+ if (taskKind === 'test-skeleton') {
1494
+ return {
1495
+ task_id: event.task_id || null,
1496
+ task_kind: taskKind,
1497
+ success: true,
1498
+ build,
1499
+ test,
1500
+ };
1501
+ }
1127
1502
  const buildOk = build?.success;
1128
1503
  const testOk = test ? test.failed === 0 : null;
1129
1504
  const resultOk = event.result === 'success'
1130
1505
  ? true
1131
1506
  : (event.result === 'failure' ? false : null);
1132
1507
  const signals = [buildOk, testOk, resultOk].filter(value => value != null);
1508
+ // Q3: 三信号均 null 时,若 status=completed 则兜底 success=true(agent 只记 status 也算 E4)
1509
+ const statusOk = event.status === 'completed' || event.details?.status === 'completed';
1133
1510
  return {
1134
1511
  task_id: event.task_id || null,
1135
- success: signals.length > 0 ? signals.every(Boolean) : null,
1512
+ task_kind: taskKind,
1513
+ success: signals.length > 0 ? signals.every(Boolean) : (statusOk ? true : null),
1136
1514
  build,
1137
1515
  test,
1138
1516
  };
1139
1517
  }
1140
1518
 
1141
1519
  function computeAiFirstPassMetrics(events) {
1520
+ // 仅对 implementation 任务计算一次成码率;test-skeleton 不计入分母
1521
+ // 未显式 task_kind 的 legacy 事件视为 implementation,保持向后兼容
1142
1522
  const taskEvents = events
1143
- .filter(e => e.type === 'task_update' && e.task_id)
1523
+ .filter(e => {
1524
+ if (e.type !== 'task_update' || !e.task_id) return false;
1525
+ const taskKind = e.details?.task_kind || e.task_kind;
1526
+ if (taskKind === 'test-skeleton') return false;
1527
+ return !taskKind || taskKind === 'implementation';
1528
+ })
1144
1529
  .sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
1145
- const firstByTask = new Map();
1530
+
1531
+ // A3(v3): 从 telemetry_warning(task_update_reuse) 提取复用 task_ids,
1532
+ // 被标记 task 不计入 first_pass 分子(分母不变)→ E4 反映复用扣分。
1533
+ // 旧 warning 无 details.task_ids → 不扣分(向后兼容)
1534
+ const reusedTaskIds = new Set();
1535
+ for (const e of events) {
1536
+ if (e?.type !== 'telemetry_warning') continue;
1537
+ if (e.details?.warning !== 'task_update_reuse') continue;
1538
+ const ids = e.details?.task_ids;
1539
+ if (Array.isArray(ids)) {
1540
+ for (const id of ids) { if (id) reusedTaskIds.add(id); }
1541
+ }
1542
+ }
1543
+
1544
+ // 按 task_id 取最终结果(时间最晚)
1545
+ const finalByTask = new Map();
1146
1546
  for (const event of taskEvents) {
1147
- if (!firstByTask.has(event.task_id)) {
1148
- firstByTask.set(event.task_id, event);
1547
+ const existing = finalByTask.get(event.task_id);
1548
+ if (!existing || new Date(event.timestamp).getTime() > new Date(existing.timestamp).getTime()) {
1549
+ finalByTask.set(event.task_id, event);
1149
1550
  }
1150
1551
  }
1151
1552
 
1152
- const taskResults = Array.from(firstByTask.values())
1553
+ const taskResults = Array.from(finalByTask.values())
1153
1554
  .map(getTaskUpdateResult)
1154
1555
  .filter(result => result?.success != null);
1155
1556
  if (taskResults.length === 0) {
@@ -1158,15 +1559,23 @@ function computeAiFirstPassMetrics(events) {
1158
1559
  first_pass_tasks: 0,
1159
1560
  measured_tasks: 0,
1160
1561
  failed_tasks: [],
1562
+ test_skeleton_tasks: 0,
1563
+ reused_task_ids: Array.from(reusedTaskIds),
1564
+ e4_reuse_impacted: false,
1161
1565
  };
1162
1566
  }
1163
1567
 
1164
- const passedTasks = taskResults.filter(result => result.success);
1568
+ // A3(v3): 被复用标记的 task 从分子扣除(仍计入分母)→ E4 下降而非归零
1569
+ const passedTasks = taskResults.filter(result => result.success && !reusedTaskIds.has(result.task_id));
1570
+ const e4ReuseImpacted = reusedTaskIds.size > 0 && taskResults.some(r => reusedTaskIds.has(r.task_id));
1165
1571
  return {
1166
1572
  e4_ai_code_first_pass_rate: roundMetric(passedTasks.length / taskResults.length),
1167
1573
  first_pass_tasks: passedTasks.length,
1168
1574
  measured_tasks: taskResults.length,
1169
1575
  failed_tasks: taskResults.filter(result => !result.success).map(result => result.task_id).filter(Boolean),
1576
+ test_skeleton_tasks: 0,
1577
+ reused_task_ids: Array.from(reusedTaskIds),
1578
+ e4_reuse_impacted: e4ReuseImpacted,
1170
1579
  };
1171
1580
  }
1172
1581
 
@@ -1239,23 +1648,39 @@ function computeSpecTestCoverageMetrics(events) {
1239
1648
  const coverageEvents = getSpecTestCoverageEvents(events);
1240
1649
  const latestCoverageEvent = latestByTimestamp(coverageEvents);
1241
1650
  const coverage = getSpecTestCoverage(latestCoverageEvent);
1242
- if (!coverage) {
1651
+ if (coverage) {
1652
+ return {
1653
+ q4_spec_driven_test_coverage: coverage.coverage_rate,
1654
+ scenario_counts: {
1655
+ total: coverage.total_scenarios,
1656
+ covered: coverage.covered_scenarios,
1657
+ partial: coverage.partial_scenarios,
1658
+ uncovered: coverage.uncovered_scenarios,
1659
+ },
1660
+ latest_review_at: latestCoverageEvent.timestamp || null,
1661
+ source: 'spec_test_coverage',
1662
+ };
1663
+ }
1664
+ // P4: 无 spec_test_coverage 事件时,fallback 到 conformance_review 断言覆盖率(近似值,标注 source)
1665
+ const confReview = getConformanceReview(latestByTimestamp(getConformanceReviewEvents(events)));
1666
+ if (confReview && confReview.total > 0) {
1243
1667
  return {
1244
- q4_spec_driven_test_coverage: null,
1245
- scenario_counts: { total: 0, covered: 0, partial: 0, uncovered: 0 },
1668
+ q4_spec_driven_test_coverage: roundMetric((confReview.matched + confReview.partial * 0.5) / confReview.total),
1669
+ scenario_counts: {
1670
+ total: confReview.total,
1671
+ covered: confReview.matched,
1672
+ partial: confReview.partial,
1673
+ uncovered: confReview.missed,
1674
+ },
1246
1675
  latest_review_at: null,
1676
+ source: 'conformance-fallback',
1247
1677
  };
1248
1678
  }
1249
-
1250
1679
  return {
1251
- q4_spec_driven_test_coverage: coverage.coverage_rate,
1252
- scenario_counts: {
1253
- total: coverage.total_scenarios,
1254
- covered: coverage.covered_scenarios,
1255
- partial: coverage.partial_scenarios,
1256
- uncovered: coverage.uncovered_scenarios,
1257
- },
1258
- latest_review_at: latestCoverageEvent.timestamp || null,
1680
+ q4_spec_driven_test_coverage: null,
1681
+ scenario_counts: { total: 0, covered: 0, partial: 0, uncovered: 0 },
1682
+ latest_review_at: null,
1683
+ source: null,
1259
1684
  };
1260
1685
  }
1261
1686
 
@@ -1268,7 +1693,7 @@ function normalizeConformanceStatus(value) {
1268
1693
  return null;
1269
1694
  }
1270
1695
 
1271
- function getConformanceReview(event) {
1696
+ function getConformanceReview(event, applyAgentType) {
1272
1697
  const review = event?.details?.conformance_review || event?.details?.conformance;
1273
1698
  if (!review) return null;
1274
1699
 
@@ -1295,6 +1720,13 @@ function getConformanceReview(event) {
1295
1720
  const total = matched + partial + missed;
1296
1721
  const computedScore = total > 0 ? roundMetric((matched + partial * 0.5) / total) : null;
1297
1722
 
1723
+ // reviewer 字段:优先取 review.reviewer,否则 fallback 到事件 agent_type
1724
+ const reviewer = review.reviewer || event?.agent_type || event?.source || null;
1725
+ let reviewerIndependence = null;
1726
+ if (reviewer && applyAgentType) {
1727
+ reviewerIndependence = reviewer === applyAgentType ? 'self-review' : 'independent-review';
1728
+ }
1729
+
1298
1730
  return {
1299
1731
  total,
1300
1732
  matched,
@@ -1302,8 +1734,11 @@ function getConformanceReview(event) {
1302
1734
  missed,
1303
1735
  score: Number.isFinite(review.score) ? review.score : computedScore,
1304
1736
  method: review.method || null,
1305
- reviewer: review.reviewer || null,
1306
- manual_confirmed: review.manual_confirmed === true || normalizedAssertions.some(a => a.human_status),
1737
+ reviewer,
1738
+ reviewer_independence: reviewerIndependence,
1739
+ // T3.4/T3.5: review 级 human_status — 仅当真实人工评审(review.human_status==='matched')才 matched,否则 unverified(AI 自评)
1740
+ human_status: review.human_status === 'matched' ? 'matched' : 'unverified',
1741
+ agent_confirmed: review.agent_confirmed === true || review.manual_confirmed === true || normalizedAssertions.some(a => a.human_status),
1307
1742
  assertions: normalizedAssertions,
1308
1743
  };
1309
1744
  }
@@ -1319,12 +1754,19 @@ function getConformanceReviewEvents(events) {
1319
1754
  function computeConformanceMetrics(events) {
1320
1755
  const reviewEvents = getConformanceReviewEvents(events);
1321
1756
  const latestReviewEvent = latestByTimestamp(reviewEvents);
1322
- const latestReview = getConformanceReview(latestReviewEvent);
1757
+ // apply 阶段最新的 agent_type 作为 reviewer 独立性比较基准
1758
+ const applyEvents = events.filter(e => !e.orphan && (e.command === 'apply' || e.stage === 'apply'));
1759
+ const latestApply = latestByTimestamp(applyEvents);
1760
+ const applyAgentType = latestApply?.agent_type || latestApply?.agent;
1761
+ const latestReview = getConformanceReview(latestReviewEvent, applyAgentType);
1323
1762
  if (!latestReview) {
1324
1763
  return {
1325
1764
  q1_spec_conformance_score: null,
1326
1765
  conformance_counts: { total: 0, matched: 0, partial: 0, missed: 0 },
1327
- manual_confirmed: false,
1766
+ agent_confirmed: false,
1767
+ reviewer: null,
1768
+ reviewer_independence: null,
1769
+ human_status: null,
1328
1770
  latest_review_at: null,
1329
1771
  };
1330
1772
  }
@@ -1337,13 +1779,17 @@ function computeConformanceMetrics(events) {
1337
1779
  partial: latestReview.partial,
1338
1780
  missed: latestReview.missed,
1339
1781
  },
1340
- manual_confirmed: latestReview.manual_confirmed,
1782
+ agent_confirmed: latestReview.agent_confirmed,
1783
+ reviewer: latestReview.reviewer,
1784
+ reviewer_independence: latestReview.reviewer_independence,
1785
+ human_status: latestReview.human_status,
1341
1786
  latest_review_at: latestReviewEvent.timestamp || null,
1342
1787
  };
1343
1788
  }
1344
1789
 
1345
1790
  function getAiAdoptionReview(event) {
1346
- const review = event?.details?.ai_adoption || event?.details?.ai_code_adoption;
1791
+ // Q4: 兼容三 key(ai_adoption / ai_code_adoption / ai_adoption_review)
1792
+ const review = event?.details?.ai_adoption || event?.details?.ai_code_adoption || event?.details?.ai_adoption_review;
1347
1793
  if (!review) return null;
1348
1794
 
1349
1795
  const retainedLines = Number.isFinite(review.retained_lines) ? review.retained_lines : null;
@@ -1385,7 +1831,8 @@ function getAiAdoptionEvents(events) {
1385
1831
  return events.filter(e => {
1386
1832
  return e.type === 'ai_adoption_review'
1387
1833
  || Boolean(e.details?.ai_adoption)
1388
- || Boolean(e.details?.ai_code_adoption);
1834
+ || Boolean(e.details?.ai_code_adoption)
1835
+ || Boolean(e.details?.ai_adoption_review);
1389
1836
  });
1390
1837
  }
1391
1838
 
@@ -1527,16 +1974,41 @@ function computeGitDocumentMetrics(projectRoot, changeName) {
1527
1974
 
1528
1975
  const isRepo = runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']);
1529
1976
  if (isRepo !== 'true') {
1530
- return {
1531
- git_available: false,
1532
- spec_iteration_count: null,
1533
- document_commit_count: null,
1534
- total_added_lines: null,
1535
- total_deleted_lines: null,
1536
- files: createDocFileMetrics(),
1537
- diff_trend: [],
1538
- warnings: ['当前项目不是 Git 仓库,无法统计文档迭代'],
1539
- };
1977
+ // 无 git 时 fallback 到 doc_update 事件按文件去重计数,并标注 source
1978
+ try {
1979
+ const dataDir = getDataDir(projectRoot);
1980
+ const events = readEvents(dataDir, changeName);
1981
+ const docFiles = new Set();
1982
+ for (const e of events) {
1983
+ if (e.type === 'doc_update' && e.change === changeName && e.details && e.details.file) {
1984
+ docFiles.add(e.details.file);
1985
+ }
1986
+ }
1987
+ const iterationCount = docFiles.size > 0 ? docFiles.size : null;
1988
+ return {
1989
+ git_available: false,
1990
+ spec_iteration_count: iterationCount,
1991
+ document_commit_count: iterationCount,
1992
+ total_added_lines: null,
1993
+ total_deleted_lines: null,
1994
+ files: createDocFileMetrics(),
1995
+ diff_trend: [],
1996
+ source: iterationCount != null ? 'event-fallback' : null,
1997
+ warnings: ['当前项目不是 Git 仓库,已 fallback 到 doc_update 事件按文件去重计数'],
1998
+ };
1999
+ } catch (err) {
2000
+ return {
2001
+ git_available: false,
2002
+ spec_iteration_count: null,
2003
+ document_commit_count: null,
2004
+ total_added_lines: null,
2005
+ total_deleted_lines: null,
2006
+ files: createDocFileMetrics(),
2007
+ diff_trend: [],
2008
+ source: null,
2009
+ warnings: ['当前项目不是 Git 仓库,无法统计文档迭代'],
2010
+ };
2011
+ }
1540
2012
  }
1541
2013
 
1542
2014
  const changePath = `openspec/changes/${changeName}`;
@@ -1630,6 +2102,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
1630
2102
  .filter(e => !options.change || e.change === options.change)
1631
2103
  .filter(e => !options.capability || e.capability === options.capability);
1632
2104
  const executionSummary = summarizeStageExecutions(scopedEvents);
2105
+ const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
1633
2106
  const starts = executionSummary.canonicalAttempts.map(attempt => attempt.start);
1634
2107
  const ends = executionSummary.canonicalAttempts.map(attempt => attempt.end).filter(Boolean);
1635
2108
 
@@ -1660,16 +2133,20 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
1660
2133
  return getCheckResults(e)?.consistency_score != null;
1661
2134
  }));
1662
2135
  const latestCheckResults = getCheckResults(latestCheckWithScore);
2136
+ // P0-3: check_result 事件 details 缺 consistency_score 时,fallback 读 state/check-result-<change>.json
2137
+ const consistencyFromState = !latestCheckResults && options.projectRoot && options.change
2138
+ ? (readCheckResultState(options.projectRoot, options.change)?.check_results?.consistency_score ?? null)
2139
+ : null;
2140
+ const consistencyScore = latestCheckResults?.consistency_score ?? consistencyFromState ?? null;
1663
2141
 
1664
- const stageSpecIterationCount = starts.filter(e => {
1665
- return ['propose', 'spec', 'design', 'task'].includes(e.command);
1666
- }).length;
1667
2142
  const gitDocumentMetrics = options.projectRoot && options.change
1668
2143
  ? computeGitDocumentMetrics(options.projectRoot, options.change)
1669
2144
  : null;
2145
+ // N5: 无 Git 提交历史时 P1=null(不适用),不再 fallback 到阶段 start 数(阶段数≠迭代次数,语义错误)
1670
2146
  const specIterationCount = gitDocumentMetrics?.spec_iteration_count != null
1671
2147
  ? gitDocumentMetrics.spec_iteration_count
1672
- : stageSpecIterationCount;
2148
+ : null;
2149
+ const specIterationSource = gitDocumentMetrics?.source || (gitDocumentMetrics?.git_available ? 'git' : null);
1673
2150
 
1674
2151
  const firstApply = firstByTimestamp(starts.filter(e => e.command === 'apply'));
1675
2152
  const qualityGateBeforeApply = firstApply
@@ -1680,9 +2157,10 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
1680
2157
  return results?.total > 0 && results.fixed_before_apply != null;
1681
2158
  }));
1682
2159
  const fixRateResults = getCheckResults(latestCheckWithFixRate);
1683
- const qualityGateRate = fixRateResults
1684
- ? roundMetric(fixRateResults.fixed_before_apply / fixRateResults.total)
1685
- : (qualityGateBeforeApply == null ? null : (qualityGateBeforeApply ? 1 : 0));
2160
+ // Q2: P4 主指标改为"apply 前是否 check"(布尔→1/0),不再依赖 fixRateResults
2161
+ // fixed_before_apply/total 作为辅助指标 p4b_check_fixed_rate,不覆盖 P4
2162
+ const qualityGateRate = qualityGateBeforeApply == null ? null : (qualityGateBeforeApply ? 1 : 0);
2163
+ const fixRate = fixRateResults ? roundMetric(fixRateResults.fixed_before_apply / fixRateResults.total) : null;
1686
2164
  const conformanceMetrics = computeConformanceMetrics(scopedEvents);
1687
2165
  const aiAdoptionMetrics = computeAiAdoptionMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
1688
2166
  const aiFirstPassMetrics = computeAiFirstPassMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
@@ -1695,6 +2173,9 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
1695
2173
  e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(specDuration / totalStageDuration) : null,
1696
2174
  e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
1697
2175
  effective_stage_duration_ms: executionSummary.summary.effective_stage_duration_ms,
2176
+ idle_time_ms: leadTime != null && executionSummary.summary.effective_stage_duration_ms != null
2177
+ ? Math.max(0, leadTime - executionSummary.summary.effective_stage_duration_ms)
2178
+ : null,
1698
2179
  rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
1699
2180
  total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
1700
2181
  e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
@@ -1706,21 +2187,27 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
1706
2187
  },
1707
2188
  quality: {
1708
2189
  q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
2190
+ q1_human_status: conformanceMetrics.human_status,
1709
2191
  q3_build_first_pass_rate: firstBuildSuccess == null ? null : (firstBuildSuccess ? 1 : 0),
1710
2192
  q4_spec_driven_test_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
1711
- q5_cross_doc_consistency_score: latestCheckResults?.consistency_score ?? null,
2193
+ q4_source: specTestCoverageMetrics.source,
2194
+ q5_cross_doc_consistency_score: consistencyScore,
1712
2195
  conformance_counts: conformanceMetrics.conformance_counts,
1713
- conformance_manual_confirmed: conformanceMetrics.manual_confirmed,
2196
+ conformance_agent_confirmed: conformanceMetrics.agent_confirmed,
2197
+ conformance_reviewer: conformanceMetrics.reviewer,
2198
+ conformance_reviewer_independence: conformanceMetrics.reviewer_independence,
1714
2199
  spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
1715
2200
  },
1716
2201
  process: {
1717
2202
  p1_spec_iteration_count: specIterationCount,
2203
+ p1_spec_iteration_source: specIterationSource,
1718
2204
  p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
1719
2205
  p2_ai_code_adoption_level: aiAdoptionMetrics.adoption_level,
1720
2206
  ai_adoption_counts: aiAdoptionMetrics.adoption_counts,
1721
2207
  p4_quality_gate_enforcement_rate: qualityGateRate,
2208
+ p4b_check_fixed_rate: fixRate,
1722
2209
  git_document_metrics: gitDocumentMetrics,
1723
- rework_summary: compactReworkSummary(executionSummary.summary),
2210
+ rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
1724
2211
  },
1725
2212
  manual_insights: computeManualInsightMetrics(scopedEvents),
1726
2213
  telemetry_health: computeTelemetryHealthMetrics(scopedEvents),
@@ -1787,6 +2274,7 @@ function computePdfMvpMetrics(events, options = {}) {
1787
2274
  e2_coding_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e2_coding_time_ratio),
1788
2275
  e3_spec_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e3_spec_time_ratio),
1789
2276
  e4_ai_code_first_pass_rate: averageMetric(changeMetrics, m => m.efficiency.e4_ai_code_first_pass_rate),
2277
+ idle_time_ms: averageMetric(changeMetrics, m => m.efficiency.idle_time_ms),
1790
2278
  },
1791
2279
  quality: {
1792
2280
  q1_spec_conformance_score: averageMetric(changeMetrics, m => m.quality.q1_spec_conformance_score),
@@ -1844,9 +2332,36 @@ function renderPdfMvpMarkdown(metrics) {
1844
2332
  ].join('\n');
1845
2333
  }
1846
2334
 
2335
+ function formatMsHuman(ms) {
2336
+ if (ms == null) return 'null';
2337
+ if (ms < 60000) return `${(ms / 1000).toFixed(2)} s`;
2338
+ if (ms < 3600000) return `≈${Math.round(ms / 60000)}分钟`;
2339
+ return `≈${Math.floor(ms / 3600000)}小时${Math.round((ms % 3600000) / 60000)}分钟`;
2340
+ }
2341
+
2342
+ /**
2343
+ * 格式化毫秒为秒数 + 人类可读时长
2344
+ * 输出格式:`{秒} s({人类可读})`,例如 `4482.91 s(≈1小时15分钟)`
2345
+ * ⚠️ 非向后兼容变更:旧版本仅输出 `{秒} s`,新版本追加人类可读括号。
2346
+ * 如有下游脚本解析此输出,需适配新的括号格式。
2347
+ */
1847
2348
  function formatMsToSeconds(ms) {
1848
2349
  if (ms == null) return 'null';
1849
- return `${(ms / 1000).toFixed(2)} s`;
2350
+ return `${(ms / 1000).toFixed(2)} s(${formatMsHuman(ms)})`;
2351
+ }
2352
+
2353
+ /**
2354
+ * 格式化毫秒为分钟(HTML 报告时间单位用)。返回 { minutes, sub } 或 null。
2355
+ * <60 分钟:sub=null(只显示「N 分钟」);≥60 分钟:sub="≈X小时Y分钟"。
2356
+ * 例如 3931000ms → { minutes:66, sub:"≈1小时6分钟" };1762000ms → { minutes:29, sub:null }。
2357
+ */
2358
+ function formatDuration(ms) {
2359
+ if (ms == null || !Number.isFinite(ms)) return null;
2360
+ const minutes = Math.round(ms / 60000);
2361
+ if (minutes < 60) return { minutes, sub: null };
2362
+ const hours = Math.floor(minutes / 60);
2363
+ const mins = minutes % 60;
2364
+ return { minutes, sub: `≈${hours}小时${mins}分钟` };
1850
2365
  }
1851
2366
 
1852
2367
  function formatRatioToPercent(ratio) {
@@ -1854,6 +2369,16 @@ function formatRatioToPercent(ratio) {
1854
2369
  return `${(ratio * 100).toFixed(1)}%`;
1855
2370
  }
1856
2371
 
2372
+ function formatReworkReasons(reasons) {
2373
+ if (!reasons || !reasons.by_category) return 'null';
2374
+ return REWORK_REASON_CATEGORIES.map(c => `${REWORK_REASON_CN[c] || c}=${reasons.by_category[c] ?? 0}`).join(', ');
2375
+ }
2376
+
2377
+ function formatReworkReasonDetails(reasons) {
2378
+ if (!reasons || !Array.isArray(reasons.details) || reasons.details.length === 0) return [];
2379
+ return reasons.details.map(d => `- ${d.command} → ${REWORK_REASON_CN[d.reason] || d.reason}${d.detail ? ` [${String(d.detail).replace(/[\r\n\]]/g, ' ')}]` : ''}${d.has_uncommitted ? '(含未提交变更)' : ''}`);
2380
+ }
2381
+
1857
2382
  function renderExecutiveReportMarkdown(report) {
1858
2383
  const metrics = report.metrics;
1859
2384
  const health = report.doctor;
@@ -1862,35 +2387,84 @@ function renderExecutiveReportMarkdown(report) {
1862
2387
  return [
1863
2388
  `# SDD 效果度量报告${report.change ? ` - ${report.change}` : ''}`,
1864
2389
  '',
1865
- `- 生成时间:${report.generated_at}`,
1866
- `- 项目路径:${report.project_root}`,
2390
+ `- 生成时间:${formatLocalTimestamp(report.generated_at)}`,
2391
+ `- 项目路径:${report.project_root ? String(report.project_root).replace(/[\\/]+$/, '').split(/[\\/]/).pop() : 'null'}`,
1867
2392
  `- 统计范围:${report.change ? `change/${report.change}` : 'project'}`,
1868
2393
  '',
1869
2394
  '## 执行摘要',
1870
2395
  `- Telemetry 健康分:${formatRatioToPercent(health.telemetry_health_score)}(0-100%,反映遥测数据的完整性,扣分项包括未闭环阶段、孤儿事件等)`,
1871
2396
  `- 阶段闭环率:${formatRatioToPercent(health.matched_stage_rate)}(已配对 start/end 的阶段占有效阶段总数的比例)`,
1872
- `- 严重问题数:${health.severe_issues?.length || 0}(存在未闭环阶段、孤儿事件或未知命令等阻断级问题的数量)`,
2397
+ `- 严重问题数:${health.severe_issues?.length ?? '无数据'}(存在未闭环阶段、孤儿事件或未知命令等阻断级问题的数量)`,
1873
2398
  '',
1874
2399
  '## 效率指标',
1875
2400
  `- E1 需求到归档总时长:${formatMsToSeconds(metrics.efficiency?.e1_lead_time_ms)}`,
1876
- `- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}`,
2401
+ `- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}(仅 apply 阶段;test 阶段耗时另见有效阶段总耗时)`,
1877
2402
  `- E3 规约时间占比:${formatRatioToPercent(metrics.efficiency?.e3_spec_time_ratio)}`,
1878
- `- E4 AI 一次成码率:${formatRatioToPercent(metrics.efficiency?.e4_ai_code_first_pass_rate)}`,
2403
+ `- E4 AI 一次成码率:${formatRatioToPercent(metrics.efficiency?.e4_ai_code_first_pass_rate)}(基于 task_update 信号判定,非代码 diff)${report.telemetry_warnings?.by_type?.task_update_reuse > 0 ? '(E4 受 task_update 复用影响,已扣分)' : ''}`,
1879
2404
  `- 有效阶段总耗时:${formatMsToSeconds(metrics.efficiency?.effective_stage_duration_ms)}`,
1880
- `- 返工阶段总耗时:${formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms)}`,
2405
+ `- 阶段间等待:${formatMsToSeconds(metrics.efficiency?.idle_time_ms)}`,
2406
+ `- 流程修正阶段总耗时:${formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms)}`,
1881
2407
  '',
1882
2408
  '## 质量指标',
1883
- `- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}`,
2409
+ `- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}${metrics.quality?.q1_human_status === 'unverified' ? '(自评,待人工确认)' : ''}`,
1884
2410
  `- Q3 构建一次通过率:${formatRatioToPercent(metrics.quality?.q3_build_first_pass_rate)}`,
1885
- `- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}`,
1886
- `- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}`,
2411
+ `- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}${metrics.quality?.q4_source === 'conformance-fallback' ? '(近似自 conformance_review)' : ''}`,
2412
+ `- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}(取最新 check 的 consistency_score)`,
1887
2413
  '',
1888
2414
  '## 过程指标',
1889
- `- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}`,
2415
+ `- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}${metrics.process?.p1_spec_iteration_source ? `(来源:${metrics.process.p1_spec_iteration_source})` : ''}`,
1890
2416
  `- P2 AI 代码保留率:${formatRatioToPercent(metrics.process?.p2_ai_code_adoption_rate)}`,
1891
2417
  `- P4 质量门前置率:${formatRatioToPercent(metrics.process?.p4_quality_gate_enforcement_rate)}`,
1892
- `- 返工次数:${metrics.process?.rework_summary?.total_rework_attempts ?? 'null'}`,
1893
- `- 被后续成功执行覆盖的未闭环阶段数:${metrics.process?.rework_summary?.superseded_open_stages ?? 'null'}`,
2418
+ `- 阶段重复执行次数:${metrics.process?.rework_summary?.total_rework_attempts ?? 'null'}`,
2419
+ `- 被覆盖的未闭环阶段数:${metrics.process?.rework_summary?.superseded_open_stages ?? 'null'}(start 后未 end,被后续成功执行覆盖)`,
2420
+ `- 重复执行原因分布:${formatReworkReasons(metrics.process?.rework_summary?.reasons)}`,
2421
+ ...formatReworkReasonDetails(metrics.process?.rework_summary?.reasons),
2422
+ '',
2423
+ '## 变更文件',
2424
+ `- 数据可用:${report.changed_files?._available ? '是' : '否'}`,
2425
+ `- 变更文件数:${report.changed_files?.files_changed ?? 'null'}${report.changed_files?.files_diff_note ? `(${report.changed_files.files_diff_note})` : ''}`,
2426
+ `- 新增行数:${report.changed_files?.added_lines ?? 'null'}`,
2427
+ `- 文件清单:${Array.isArray(report.changed_files?.files) ? report.changed_files.files.join(', ') : 'null'}`,
2428
+ '',
2429
+ '## 时间线',
2430
+ ...(report.timeline?._available && Array.isArray(report.timeline.nodes) && report.timeline.nodes.length > 0
2431
+ ? report.timeline.nodes.map(n => `- ${formatLocalTimestamp(n.timestamp)} [${n.type}] ${n.command || ''}${n.result ? ` → ${n.result}` : ''}`)
2432
+ : ['- 无时间线节点(数据不可用)']),
2433
+ '',
2434
+ '## 阶段状态',
2435
+ ...(report.stage_status?._available && Array.isArray(report.stage_status.stages) && report.stage_status.stages.length > 0
2436
+ ? report.stage_status.stages.map(s => `- ${s.command}${s.capability ? `/${s.capability}` : ''}:尝试 ${s.total_attempts} 次,成功 ${s.successful_attempts},重复执行 ${s.rework_attempts}`)
2437
+ : ['- 无阶段状态数据(数据不可用)']),
2438
+ '',
2439
+ '## 已知风险',
2440
+ `- 数据可用:${report.known_risks?._available ? '是' : '否'}`,
2441
+ `- 严重问题数:${report.known_risks?._available ? (report.known_risks.severe_issues?.length ?? 0) : '无数据'}`,
2442
+ ...(Array.isArray(report.known_risks?.warnings) && report.known_risks.warnings.length > 0
2443
+ ? report.known_risks.warnings.flatMap(w => {
2444
+ // T2.4: 渲染 warning_items 明细;旧事件无 items 时降级为数量(DC-101)
2445
+ const items = Array.isArray(w.items) ? w.items : null;
2446
+ const count = items ? items.length : (w.warnings || 0);
2447
+ // Q7: 标注已修复/待确认
2448
+ const statusLabel = w.resolved === true ? '✅已修复' : '⏳待确认';
2449
+ const head = `- 警告:${w.command || 'check'} 阶段 ${count} 个${statusLabel}`;
2450
+ if (!items || items.length === 0) return [`${head}(无明细)`];
2451
+ return [head, ...items.map(it => ` - ${it.description || '待确认项'}${it.target ? `(${it.target})` : ''}`)];
2452
+ })
2453
+ : ['- 无警告']),
2454
+ '',
2455
+ '## 过程记录',
2456
+ `- 数据可用:${report.process_notes?._available ? '是' : '否'}`,
2457
+ `- 过程事件总数:${report.process_notes?.total ?? 'null'}`,
2458
+ `- 按 kind 分布:${report.process_notes?.by_kind ? JSON.stringify(report.process_notes.by_kind) : 'null'}`,
2459
+ ...(Array.isArray(report.process_notes?.notes) && report.process_notes.notes.length > 0
2460
+ ? report.process_notes.notes.map(n => `- [${n.stage}] ${n.kind}${n.round ? ` R${n.round}` : ''}:${n.summary}${n.target ? ` → ${n.target}` : ''}`)
2461
+ : ['- 无过程事件']),
2462
+ '',
2463
+ '## 产物清单',
2464
+ `- 数据可用:${report.artifacts?._available ? '是' : '否'}`,
2465
+ `- 归档目录:${report.artifacts?.archive_path ?? 'null'}`,
2466
+ `- 归档清单:${report.artifacts?.manifest_path ?? 'null'}`,
2467
+ `- 迁移文件数:${Array.isArray(report.artifacts?.copied_specs) ? report.artifacts.copied_specs.length : 'null'}`,
1894
2468
  '',
1895
2469
  '## 归档结果',
1896
2470
  `- 归档原因:${archive?.reason || 'null'}`,
@@ -1898,37 +2472,674 @@ function renderExecutiveReportMarkdown(report) {
1898
2472
  `- 归档目录:${archive?.archive_path || 'null'}`,
1899
2473
  `- 归档清单:${archive?.manifest_path || 'null'}`,
1900
2474
  `- 最终报告:${archive?.report_path || 'null'}`,
1901
- `- 已完成任务项:${taskCompletion?.completed ?? 'null'}`,
1902
- `- 未勾选任务项:${taskCompletion?.incomplete ?? 'null'}`,
1903
- `- 任务项总数:${taskCompletion?.total ?? 'null'}`,
2475
+ `- 已完成任务项(含子项 checkbox):${taskCompletion?.completed ?? 'null'}`,
2476
+ `- 未勾选任务项(含子项 checkbox):${taskCompletion?.incomplete ?? 'null'}`,
2477
+ `- 任务项总数(含子项 checkbox):${taskCompletion?.total ?? 'null'}`,
1904
2478
  '',
1905
2479
  '## 说明',
1906
2480
  '- `null` 表示当前还没有采集到对应事件或该指标暂不适用。',
1907
2481
  '- E4 AI 一次成码率:依赖 `task_update` 事件中的 `task_id` 和首次执行结果,若 apply 阶段未正确记录则显示 null。',
1908
- '- P1 文档迭代次数:优先使用 Git 提交历史统计,无 Git 仓库或无提交历史时使用阶段 start 事件数。',
2482
+ '- P1 文档迭代次数:依赖 Git 提交历史统计;无 Git 仓库时 fallback 到 doc_update 事件计数并标注来源。apply 阶段遵循 Git 只读策略不自动 commit 时,该 change 无提交历史 → P1=null 属预期(非数据缺失)。',
1909
2483
  '- P2 AI 代码保留率:优先使用 `ai_adoption_review` 的 final 状态,无 final 时 fallback 到 AI 产出快照(ai_snapshot)。',
1910
- '- Q4 规约驱动测试覆盖率:依赖 check/test 阶段记录 spec 断言到测试用例的映射数据(spec_test_coverage 字段),属高级功能。',
2484
+ '- Q4 规约驱动测试覆盖率:依赖 `spec_test_coverage`(spec 断言→测试用例映射)精确统计;无该事件时 fallback conformance_review 断言覆盖率(近似值,报告标注 source=conformance-fallback)。',
1911
2485
  '- Q1 规约符合度与人工反馈类指标属于评审信号,默认不作为强阻断门禁。',
2486
+ '- M1 stage 时长含等待/故障耗时(API 错误、换模型、用户等待),未单独度量;过程记录的 api-error/model-switch 计数可作时长虚高旁证。',
2487
+ '- 过程记录(process_note):阶段内修复/澄清/决策/API 故障/换模型等事件,由 agent 在关键节点记录,可统计可追溯。',
1912
2488
  ].join('\n');
1913
2489
  }
1914
2490
 
2491
+ function renderExecutiveReportHtml(report) {
2492
+ const metrics = report.metrics || {};
2493
+ const eff = metrics.efficiency || {};
2494
+ const qual = metrics.quality || {};
2495
+ const proc = metrics.process || {};
2496
+ const health = report.doctor || {};
2497
+ const archive = report.archive_result || null;
2498
+ const taskCompletion = archive?.task_completion || null;
2499
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2500
+
2501
+ // T2.5: 分钟单位时长(<60 分无副,≥60 分加「≈X小时Y分钟」副)
2502
+ const durHtml = (ms) => {
2503
+ const d = formatDuration(ms);
2504
+ if (!d) return '<span class="metric-val muted-null">null</span>';
2505
+ return `${d.minutes}<span class="unit">分钟</span>${d.sub ? `<span class="sub">${esc(d.sub)}</span>` : ''}`;
2506
+ };
2507
+ const pctHtml = (ratio, sub) => {
2508
+ if (ratio == null || !Number.isFinite(ratio)) return '<span class="metric-val muted-null">null</span>';
2509
+ return `${(ratio * 100).toFixed(1)}<span class="unit">%</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
2510
+ };
2511
+ const numHtml = (v, unit, sub) => {
2512
+ if (v == null) return '<span class="metric-val muted-null">null</span>';
2513
+ return `${esc(v)}${unit ? `<span class="unit">${esc(unit)}</span>` : ''}${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
2514
+ };
2515
+ // 指标卡:name + 值 + 通俗化说明(info-btn 点击弹窗,不泄露内部术语)
2516
+ const metricCell = (name, valHtml, info) => `<div class="metric"><div class="metric-head"><span class="metric-name">${esc(name)}</span>${info ? `<span class="info-btn" data-title="${esc(name)}" data-info="${esc(info)}">i</span>` : ''}</div><div class="metric-val">${valHtml}</div></div>`;
2517
+
2518
+ // 健康分环形仪表盘
2519
+ const hs = health.telemetry_health_score;
2520
+ const gaugePct = hs == null ? 0 : Math.max(0, Math.min(1, hs));
2521
+ const gaugeR = 40, gaugeC = 2 * Math.PI * gaugeR;
2522
+ const gaugeOffset = gaugeC * (1 - gaugePct);
2523
+ const gaugeColor = gaugePct >= 0.8 ? '#22c55e' : (gaugePct >= 0.6 ? '#f59e0b' : '#ef4444');
2524
+ const gaugeHtml = `<div class="gauge"><svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="${gaugeR}" class="gauge-track"/><circle cx="50" cy="50" r="${gaugeR}" class="gauge-fill" style="stroke:${gaugeColor};stroke-dasharray:${gaugeC.toFixed(3)};stroke-dashoffset:${gaugeOffset.toFixed(3)}"/></svg><div class="gauge-text">${hs == null ? 'N/A' : Math.round(gaugePct * 100) + '%'}</div></div>`;
2525
+
2526
+ // T2.3: 横向时间线(阶段级,从 stage_timeline 派生)
2527
+ const tlNodes = report.stage_timeline?._available && Array.isArray(report.stage_timeline.nodes) ? report.stage_timeline.nodes : [];
2528
+ const maxDur = tlNodes.reduce((m, n) => Math.max(m, n.duration_ms || 0), 0);
2529
+ const fmtHM = (ts) => {
2530
+ if (!ts) return '--:--';
2531
+ const d = new Date(ts);
2532
+ if (isNaN(d.getTime())) return '--:--';
2533
+ return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
2534
+ };
2535
+ const tlHtml = tlNodes.length > 0
2536
+ ? `<div class="timeline-h">${tlNodes.map((n, idx) => {
2537
+ const dur = n.duration_ms || 0;
2538
+ const barH = maxDur > 0 ? Math.max(6, Math.round((dur / maxDur) * 54)) : 6;
2539
+ const failed = n.result === 'failure';
2540
+ const short = !failed && dur > 0 && dur < 60000;
2541
+ const barCls = failed ? 'bad' : (short ? 'short' : 'ok');
2542
+ const round = n.round || 1;
2543
+ const nameSuffix = round > 1 ? '²' : '';
2544
+ const nameCls = failed ? ' style="color:#fca5a5"' : '';
2545
+ const durMin = dur > 0 ? Math.round(dur / 60000) : 0;
2546
+ const durLabel = durMin > 0 ? `${durMin}分` : '&lt;1分';
2547
+ const taskLabel = n.task_count > 0 ? `·${n.task_count}任务` : '';
2548
+ const timeLabel = `${fmtHM(n.start_ts)}→${fmtHM(n.end_ts)}`;
2549
+ const repeatInfo = round > 1
2550
+ ? `<div class="tl-repeat" data-title="重复执行:${esc(n.stage)}" data-info="该阶段是第 ${round} 次执行。首次未通过或被门禁拦截后重跑。">R</div>`
2551
+ : '';
2552
+ const connector = idx < tlNodes.length - 1 ? '<div class="tl-connector"></div>' : '';
2553
+ return `<div class="tl-node"><div class="tl-bar-wrap"><div class="tl-bar ${barCls}" style="height:${barH}px"></div></div><div class="tl-dot${failed ? ' bad' : ''}"></div><div class="tl-name"${nameCls}>${esc(n.stage)}${nameSuffix}${failed ? ' ✗' : ''}</div><div class="tl-dur">${durLabel}${taskLabel}</div><div class="tl-time">${timeLabel}</div>${repeatInfo}${connector}</div>`;
2554
+ }).join('')}</div>`
2555
+ : '<p class="muted">无时间线数据</p>';
2556
+
2557
+ // Q7: warn-status 从 archive task_completion 对比,incomplete=0 标"已修复"
2558
+ const warns = (report.known_risks?._available && Array.isArray(report.known_risks.warnings)) ? report.known_risks.warnings : [];
2559
+ const warnHtml = warns.length > 0
2560
+ ? warns.map(w => {
2561
+ const items = Array.isArray(w.items) ? w.items : null;
2562
+ const count = items ? items.length : (w.warnings || 0);
2563
+ const detail = items && items.length > 0
2564
+ ? items.map(it => it.target ? `${it.description || '待确认项'}(${it.target})` : (it.description || '待确认项')).join(';')
2565
+ : `共 ${count} 个待确认项(无明细)`;
2566
+ const resolved = w.resolved === true;
2567
+ const statusText = resolved ? '已修复' : '待确认';
2568
+ const statusStyle = resolved
2569
+ ? 'background:rgba(34,197,94,.16);color:#86efac'
2570
+ : 'background:rgba(250,204,21,.16);color:#fde68a';
2571
+ return `<div class="warn-item"><div class="warn-icon">!</div><div class="warn-body"><div class="warn-title">${esc(w.command || 'check')} 阶段 ${count} 个待确认</div><div class="warn-detail">${esc(detail)}</div></div><span class="warn-status" style="${statusStyle}">${statusText}</span></div>`;
2572
+ }).join('')
2573
+ : `<div class="warn-item"><div class="warn-icon" style="background:rgba(96,165,250,.16);color:#bfdbfe">i</div><div class="warn-body"><div class="warn-title">严重问题:${health.severe_issues?.length || 0} 个</div><div class="warn-detail">无未闭环阶段、无孤儿事件、无未知命令(阻断级问题)</div></div><span class="warn-status" style="background:rgba(34,197,94,.16);color:#86efac">无需修复</span></div>`;
2574
+
2575
+ // 过程:重复执行原因
2576
+ const rs = proc.rework_summary || {};
2577
+ const reasonDetails = Array.isArray(rs.reasons?.details) ? rs.reasons.details : [];
2578
+ const reasonTag = (reason) => {
2579
+ if (reason === 'incomplete' || reason === 'prev-failed') return { cls: 'unfinished', text: reason === 'incomplete' ? '前次未完成' : '前次失败重跑' };
2580
+ if (reason === 'completed_rework_success') return { cls: 'rework', text: '通过后复检' };
2581
+ return { cls: 'rework', text: REWORK_REASON_CN[reason] || reason || '复检' };
2582
+ };
2583
+ const reasonHtml = reasonDetails.length > 0
2584
+ ? `<div class="reasons">${reasonDetails.map(d => {
2585
+ const t = reasonTag(d.reason);
2586
+ const note = d.detail ? String(d.detail).replace(/[\r\n]/g, ' ').slice(0, 60) : '';
2587
+ return `<div class="reason-item"><span class="reason-stage">${esc(d.command)}</span><span class="reason-arrow">→</span><span class="reason-tag ${t.cls}">${esc(t.text)}</span>${note ? `<span class="reason-note">${esc(note)}</span>` : ''}</div>`;
2588
+ }).join('')}</div>`
2589
+ : '';
2590
+ const reworkTotal = rs.total_rework_attempts ?? 0;
2591
+ const byCat = rs.reasons?.by_category || {};
2592
+ const catLine = REWORK_REASON_CATEGORIES.map(c => `${REWORK_REASON_CN[c] || c}=${byCat[c] ?? 0}`).join(' · ');
2593
+
2594
+ // 项目名(basename,不写本机绝对路径 —— T3.7 报告路径 basename 的 HTML 侧落地)
2595
+ const projName = report.project_root ? String(report.project_root).replace(/[\\/]+$/, '').split(/[\\/]/).pop() : '';
2596
+ const genTime = formatLocalTimestamp(report.generated_at);
2597
+ const severeCount = health.severe_issues?.length || 0;
2598
+ const matchedRate = health.matched_stage_rate;
2599
+
2600
+ // T3.2: 过程质量信号警示条(P4=0% / 过程记录缺失 / task_update 复用)—— 摘要区橙色警示
2601
+ const pqSignals = [];
2602
+ if (proc.p4_quality_gate_enforcement_rate === 0) {
2603
+ pqSignals.push('质量门前置率 0%(首次编码未先检查,被门禁拦下)');
2604
+ }
2605
+ const pnTotal = report.process_notes?._available ? (report.process_notes.total ?? 0) : 0;
2606
+ if (pnTotal === 0) {
2607
+ pqSignals.push('过程记录缺失(未采集到修复/决策/故障等过程事件)');
2608
+ }
2609
+ if (report.telemetry_warnings?.by_type?.task_update_reuse > 0) {
2610
+ pqSignals.push('检测到 task_update 复用(同一测试结果被多次记录)');
2611
+ }
2612
+ const qualityAlertHtml = pqSignals.length > 0
2613
+ ? `<div class="quality-alert"><span class="qa-icon">!</span><div class="qa-body"><div class="qa-title">过程质量信号</div><div class="qa-items">${pqSignals.map(s => `<div>${esc(s)}</div>`).join('')}</div></div></div>`
2614
+ : '';
2615
+
2616
+ // 归档与产物
2617
+ const cf = report.changed_files || {};
2618
+ const arts = report.artifacts || {};
2619
+ const movedCount = Array.isArray(arts.copied_specs) ? arts.copied_specs.length : null;
2620
+
2621
+ return `<!doctype html>
2622
+ <html lang="zh-CN">
2623
+ <head>
2624
+ <meta charset="utf-8" />
2625
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
2626
+ <title>SDD 效果度量报告${report.change ? ` - ${esc(report.change)}` : ''}</title>
2627
+ <style>
2628
+ :root{
2629
+ --bg:#0b1020;--card-bg:rgba(255,255,255,.05);--card-bg-2:rgba(255,255,255,.03);
2630
+ --border:rgba(255,255,255,.1);--border-2:rgba(255,255,255,.06);
2631
+ --text:#e5e7eb;--muted:#8b97a7;--muted-2:#64748b;
2632
+ --ok:#22c55e;--warn:#f59e0b;--bad:#ef4444;--accent:#60a5fa;
2633
+ }
2634
+ *{box-sizing:border-box}
2635
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;margin:0;background:var(--bg);color:var(--text);line-height:1.5;font-size:14px}
2636
+ main{max-width:1080px;margin:0 auto;padding:20px}
2637
+ .card{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin:12px 0;position:relative}
2638
+ h1{margin:0;font-size:22px;font-weight:600}
2639
+ h2{margin:0 0 14px;font-size:15px;font-weight:600;color:#cbd5e1;display:flex;align-items:center;gap:8px}
2640
+ h2::before{content:"";width:3px;height:14px;background:var(--accent);border-radius:2px}
2641
+ h3{margin:0;font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
2642
+ .muted{color:var(--muted)}
2643
+ .muted-2{color:var(--muted-2);font-size:11px}
2644
+ code{color:#bfdbfe;background:rgba(96,165,250,.1);padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
2645
+ .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:600;font-size:11px}
2646
+ .badge.ok{background:rgba(34,197,94,.16);color:#86efac}
2647
+ .badge.warn{background:rgba(245,158,11,.18);color:#fcd34d}
2648
+ .badge.bad{background:rgba(239,68,68,.18);color:#fca5a5}
2649
+ table{width:100%;border-collapse:collapse;font-size:13px}
2650
+ th,td{border-bottom:1px solid var(--border-2);padding:7px 8px;text-align:left;vertical-align:top}
2651
+ th{color:var(--muted);width:38%;font-weight:500}
2652
+ tr:last-child td,tr:last-child th{border-bottom:none}
2653
+ .header-row{display:flex;justify-content:space-between;align-items:flex-end;flex-wrap:wrap;gap:8px}
2654
+ .header-meta{font-size:11px;color:var(--muted-2);text-align:right}
2655
+ .summary{display:grid;grid-template-columns:auto 1fr 1fr;gap:14px;align-items:stretch}
2656
+ .summary .cell{background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;padding:14px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;min-height:110px}
2657
+ .gauge{width:96px;height:96px;position:relative}
2658
+ .gauge svg{transform:rotate(-90deg);width:100%;height:100%}
2659
+ .gauge-track{fill:none;stroke:rgba(255,255,255,.1);stroke-width:7}
2660
+ .gauge-fill{fill:none;stroke-width:7;stroke-linecap:round}
2661
+ .gauge-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700}
2662
+ .big-num{font-size:30px;font-weight:700;line-height:1}
2663
+ .big-num.ok{color:#86efac}
2664
+ .cell-label{font-size:11px;color:var(--muted);margin-top:6px;text-transform:uppercase;letter-spacing:.04em}
2665
+ .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
2666
+ .metric{background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;padding:12px 12px 10px;position:relative;min-height:84px;display:flex;flex-direction:column;justify-content:space-between}
2667
+ .metric-head{display:flex;justify-content:space-between;align-items:flex-start;gap:6px}
2668
+ .metric-name{font-size:11px;color:var(--muted);font-weight:500;line-height:1.3}
2669
+ .metric-val{font-size:20px;font-weight:700;margin-top:6px;line-height:1.1}
2670
+ .metric-val .unit{font-size:12px;font-weight:400;color:var(--muted);margin-left:2px}
2671
+ .metric-val .sub{display:block;font-size:11px;font-weight:400;color:var(--muted-2);margin-top:2px}
2672
+ .metric-val.muted-null{color:var(--muted-2);font-size:16px}
2673
+ .info-btn{cursor:pointer;color:var(--accent);font-size:12px;background:rgba(96,165,250,.12);border-radius:50%;width:16px;height:16px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s;line-height:1;user-select:none}
2674
+ .info-btn:hover{background:rgba(96,165,250,.28)}
2675
+ .modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}
2676
+ .modal-mask.show{display:flex}
2677
+ .modal{background:#131a2e;border:1px solid var(--border);border-radius:12px;max-width:440px;width:100%;padding:18px 20px}
2678
+ .modal h4{margin:0 0 8px;font-size:14px;color:#cbd5e1}
2679
+ .modal p{margin:0;font-size:13px;color:var(--text);line-height:1.6}
2680
+ .modal .close{margin-top:14px;text-align:right}
2681
+ .modal .close button{background:rgba(96,165,250,.16);color:#bfdbfe;border:none;border-radius:6px;padding:5px 16px;font-size:12px;cursor:pointer}
2682
+ .timeline-h{display:flex;align-items:stretch;gap:0;overflow-x:auto;padding:4px 0 6px}
2683
+ .tl-node{flex:0 0 auto;min-width:88px;position:relative;display:flex;flex-direction:column;align-items:center;padding:0 2px}
2684
+ .tl-bar-wrap{width:100%;height:54px;display:flex;align-items:flex-end;justify-content:center}
2685
+ .tl-bar{width:100%;max-width:46px;border-radius:4px 4px 0 0;min-height:6px}
2686
+ .tl-bar.ok{background:linear-gradient(180deg,rgba(34,197,94,.85),rgba(34,197,94,.4))}
2687
+ .tl-bar.bad{background:linear-gradient(180deg,rgba(239,68,68,.85),rgba(239,68,68,.4))}
2688
+ .tl-bar.short{background:rgba(34,197,94,.35);min-height:6px}
2689
+ .tl-dot{width:10px;height:10px;border-radius:50%;margin-top:6px;background:var(--ok);border:2px solid var(--bg)}
2690
+ .tl-dot.bad{background:var(--bad)}
2691
+ .tl-name{font-size:11px;color:#cbd5e1;margin-top:6px;font-weight:500;text-align:center;line-height:1.2}
2692
+ .tl-dur{font-size:10px;color:var(--muted);margin-top:2px;text-align:center}
2693
+ .tl-time{font-size:9px;color:var(--muted-2);margin-top:1px;text-align:center;white-space:nowrap;letter-spacing:-.02em}
2694
+ .tl-repeat{position:absolute;top:-6px;right:50%;transform:translateX(20px);font-size:10px;background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;cursor:pointer;line-height:14px;min-width:14px;text-align:center}
2695
+ .tl-repeat:hover{background:#fbbf24}
2696
+ .tl-connector{position:absolute;top:23px;right:calc(-50% + 6px);width:calc(100% - 12px);height:2px;background:var(--border-2);z-index:0}
2697
+ .tl-legend{display:flex;gap:14px;margin-top:10px;font-size:11px;color:var(--muted);flex-wrap:wrap;align-items:center}
2698
+ .tl-legend span{display:inline-flex;align-items:center;gap:5px}
2699
+ .tl-legend i{width:10px;height:10px;border-radius:2px;display:inline-block}
2700
+ .tl-legend .r-demo{background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;font-size:10px}
2701
+ .reasons{display:flex;flex-direction:column;gap:6px;margin-top:10px}
2702
+ .reason-item{display:flex;align-items:center;gap:8px;background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:8px;padding:8px 10px;font-size:12px}
2703
+ .reason-stage{font-weight:600;color:#cbd5e1;min-width:60px}
2704
+ .reason-arrow{color:var(--muted-2)}
2705
+ .reason-tag{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500}
2706
+ .reason-tag.unfinished{background:rgba(245,158,11,.18);color:#fcd34d}
2707
+ .reason-tag.rework{background:rgba(96,165,250,.16);color:#bfdbfe}
2708
+ .reason-note{color:var(--muted);margin-left:auto;font-size:11px}
2709
+ .warn-item{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-2);font-size:12px}
2710
+ .warn-item:last-child{border-bottom:none}
2711
+ .warn-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:rgba(245,158,11,.18);color:#fcd34d;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;margin-top:1px}
2712
+ .warn-body{flex:1}
2713
+ .warn-title{color:#e5e7eb;font-weight:500}
2714
+ .warn-detail{color:var(--muted);margin-top:2px}
2715
+ .warn-status{font-size:11px;padding:2px 8px;border-radius:999px;flex-shrink:0}
2716
+ .warn-status.fixed{background:rgba(34,197,94,.16);color:#86efac}
2717
+ .two-col{display:grid;grid-template-columns:1fr 1fr;gap:12px}
2718
+ @media(max-width:720px){.summary{grid-template-columns:1fr}.two-col{grid-template-columns:1fr}.grid{grid-template-columns:repeat(2,1fr)}}
2719
+ .quality-alert{display:flex;align-items:flex-start;gap:10px;margin-top:12px;padding:10px 12px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);border-radius:8px;font-size:12px}
2720
+ .quality-alert .qa-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:rgba(245,158,11,.25);color:#fcd34d;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;margin-top:1px}
2721
+ .quality-alert .qa-title{font-weight:600;color:#fcd34d;margin-bottom:2px}
2722
+ .quality-alert .qa-items div{color:#e5e7eb;line-height:1.6}
2723
+ .footer-note{font-size:11px;color:var(--muted-2);margin-top:16px;padding:12px 18px;background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;line-height:1.7}
2724
+ .footer-note strong{color:var(--muted)}
2725
+ .section-tag{font-size:10px;color:var(--muted-2);font-weight:400;margin-left:6px}
2726
+ </style>
2727
+ </head>
2728
+ <body><main>
2729
+
2730
+ <div class="card">
2731
+ <div class="header-row">
2732
+ <div>
2733
+ <h1>SDD 效果度量报告</h1>
2734
+ <div class="muted-2" style="margin-top:4px">变更:<code>${esc(report.change || 'project')}</code> · 统计范围:${esc(report.change ? `change/${report.change}` : 'project')}</div>
2735
+ </div>
2736
+ <div class="header-meta">生成时间 ${esc(genTime)}<br>项目 ${esc(projName)}</div>
2737
+ </div>
2738
+ </div>
2739
+
2740
+ <div class="card">
2741
+ <h2>执行摘要</h2>
2742
+ <div class="summary">
2743
+ <div class="cell">
2744
+ ${gaugeHtml}
2745
+ <div class="cell-label">健康分</div>
2746
+ </div>
2747
+ <div class="cell">
2748
+ <div class="big-num ok">${matchedRate == null ? 'null' : (matchedRate * 100).toFixed(0)}<span style="font-size:18px">%</span></div>
2749
+ <div class="cell-label">阶段闭环率</div>
2750
+ </div>
2751
+ <div class="cell">
2752
+ <div class="big-num ${severeCount > 0 ? 'bad' : 'ok'}" style="${severeCount > 0 ? 'color:#fca5a5' : ''}">${severeCount}</div>
2753
+ <div class="cell-label">严重问题</div>
2754
+ </div>
2755
+ </div>
2756
+ ${qualityAlertHtml}
2757
+ </div>
2758
+
2759
+ <div class="card">
2760
+ <h2>效率指标</h2>
2761
+ <div class="grid">
2762
+ ${metricCell('E1 总时长', durHtml(eff.e1_lead_time_ms), '从需求规划到归档完成的总耗时,包含阶段间等待和你确认的时间。')}
2763
+ ${metricCell('E2 编码时间占比', pctHtml(eff.e2_coding_time_ratio), '实际编码阶段占总有效耗时的比例。')}
2764
+ ${metricCell('E3 规约时间占比', pctHtml(eff.e3_spec_time_ratio), '需求规划、规格、设计、任务拆解四个阶段的耗时占比。注意:包含等待你确认的时间,可能偏高。')}
2765
+ ${metricCell('E4 一次成码率', pctHtml(eff.e4_ai_code_first_pass_rate), 'AI 一次性写对代码的比例。只统计实现类任务(不含测试骨架),且构建、测试、结果三项都通过才算成功。基于 task_update 信号判定,非代码 diff。' + (report.telemetry_warnings?.by_type?.task_update_reuse > 0 ? ' E4 受 task_update 复用影响,已扣分。' : ''))}
2766
+ ${metricCell('有效阶段总耗时', durHtml(eff.effective_stage_duration_ms), '各阶段实际耗时之和,不含阶段间的等待和会话切换。')}
2767
+ ${metricCell('阶段间等待', durHtml(eff.idle_time_ms), 'E1 总时长减去各阶段有效耗时之和,即阶段间等待、会话切换等未计入有效耗时的部分。')}
2768
+ ${metricCell('流程修正耗时', durHtml(eff.rework_stage_duration_ms), '因门禁拦截、重做等流程返工花费的时间。')}
2769
+ </div>
2770
+ </div>
2771
+
2772
+ <div class="card">
2773
+ <h2>质量指标</h2>
2774
+ <div class="grid">
2775
+ ${metricCell('Q1 规约符合度', numHtml(qual.q1_spec_conformance_score, '/1', qual.q1_human_status === 'unverified' ? '自评·待人工确认' : null), '实现与规格的符合程度,由 AI 评审断言判定。仅供参考,不作强制门禁。')}
2776
+ ${metricCell('Q3 构建一次通过率', pctHtml(qual.q3_build_first_pass_rate), '编码阶段第一次构建就成功的比例。')}
2777
+ ${metricCell('Q4 测试覆盖率', pctHtml(qual.q4_spec_driven_test_coverage, qual.q4_source === 'conformance-fallback' ? '近似' : null), '规格中的每条断言被测试用例覆盖的比例。本次为近似值。')}
2778
+ ${metricCell('Q5 跨文档一致性', numHtml(qual.q5_cross_doc_consistency_score, '/1'), '需求、规格、设计、任务四份文档之间是否一致、无矛盾的评分。')}
2779
+ </div>
2780
+ </div>
2781
+
2782
+ <div class="card">
2783
+ <h2>过程指标 <span class="section-tag">含过程记录状态</span></h2>
2784
+ <div class="grid">
2785
+ ${metricCell('P1 文档迭代次数', proc.p1_spec_iteration_count == null ? '<span class="metric-val muted-null">null<span class="unit">·无git</span></span>' : numHtml(proc.p1_spec_iteration_count, '次'), '文档被修改的次数。需要 Git 提交记录来统计,本次没有 Git 提交,所以缺失。')}
2786
+ ${metricCell('P2 AI 代码保留率', pctHtml(proc.p2_ai_code_adoption_rate), 'AI 生成的代码最终保留了多少比例。需要 Git 对比来计算,本次没有 Git 提交,所以缺失。')}
2787
+ ${metricCell('P4 质量门前置率', pctHtml(proc.p4_quality_gate_enforcement_rate), 'apply 前是否完成 check。本次 apply 前未执行 check 才为 0%。')}
2788
+ ${metricCell('阶段重复执行', numHtml(reworkTotal, '次'), '同一阶段被重复执行的次数。本次编码和检查各重做了一次。')}
2789
+ ${metricCell('被覆盖未闭环', numHtml(rs.superseded_open_stages ?? null), '开始后未正常结束、但被后续重新执行覆盖的阶段数。')}
2790
+ ${metricCell('过程记录事件', (report.process_notes?._available ? (report.process_notes.total ?? 0) : null) == null ? '<span class="metric-val muted-null">0<span class="unit">·未采集</span></span>' : numHtml(report.process_notes.total ?? 0, '条'), '过程中发生的修复、澄清、决策、故障等事件数。本次没有记录到任何过程事件。')}
2791
+ </div>
2792
+ ${reasonHtml ? `<h3 style="margin:14px 0 4px">重复执行原因分布</h3>${reasonHtml}<div class="muted-2" style="margin-top:8px">其余归类:${esc(catLine)}</div>` : ''}
2793
+ </div>
2794
+
2795
+ <div class="card">
2796
+ <h2>时间线 <span class="section-tag">横向 · 含起止时间${tlNodes.length > 0 ? ` · ${tlNodes.length} 个阶段执行` : ''}</span></h2>
2797
+ ${tlHtml}
2798
+ ${tlNodes.length > 0 ? `<div class="tl-legend"><span><i style="background:var(--ok)"></i>成功</span><span><i style="background:var(--bad)"></i>失败/拦截</span><span><i style="background:rgba(34,197,94,.35)"></i>短阶段</span><span><span class="r-demo">R</span>重复执行(可点击查看原因)</span><span>柱高 ∝ 耗时</span></div>` : ''}
2799
+ </div>
2800
+
2801
+ <div class="card">
2802
+ <h2>归档与产物 <span class="section-tag">变更文件 · 产物清单 · 归档结果</span></h2>
2803
+ <div class="two-col">
2804
+ <table>
2805
+ <tr><th>变更文件数</th><td>${cf._available ? esc(cf.files_changed ?? 'null') : '<span class="muted-2">无数据</span>'}${cf.files_diff_note ? `<div class="muted-2" style="margin-top:4px">${esc(cf.files_diff_note)}</div>` : ''}</td></tr>
2806
+ <tr><th>新增行数</th><td>${cf._available && cf.added_lines != null ? esc(cf.added_lines) : '<span class="muted-2">null(无 git 提交)</span>'}</td></tr>
2807
+ <tr><th>迁移文件数</th><td>${movedCount != null ? esc(movedCount) : '<span class="muted-2">null</span>'}</td></tr>
2808
+ <tr><th>任务项完成</th><td>${taskCompletion ? `${esc(taskCompletion.completed ?? 'null')} / ${esc(taskCompletion.total ?? 'null')}(含子项)` : '<span class="muted-2">null</span>'}</td></tr>
2809
+ </table>
2810
+ <table>
2811
+ <tr><th>归档原因</th><td>${esc(archive?.reason || 'null')}</td></tr>
2812
+ <tr><th>归档方式</th><td>${esc(archive?.method || 'null')}</td></tr>
2813
+ <tr><th>归档目录</th><td><code>${esc(archive?.archive_path || arts.archive_path || 'null')}</code></td></tr>
2814
+ <tr><th>最终报告</th><td><code>${esc(archive?.report_path || 'null')}</code></td></tr>
2815
+ </table>
2816
+ </div>
2817
+ </div>
2818
+
2819
+ <div class="card">
2820
+ <h2>已知风险</h2>
2821
+ ${warnHtml}
2822
+ </div>
2823
+
2824
+ <div class="footer-note">
2825
+ <strong>说明:</strong><code>null</code> 表示该指标未采集到或暂不适用(多为无 Git 提交所致)。E4 基于 task_update 信号判定,非代码 diff。阶段时长含等待/故障耗时,未单独度量。
2826
+ </div>
2827
+
2828
+ </main>
2829
+
2830
+ <div class="modal-mask" id="modalMask">
2831
+ <div class="modal">
2832
+ <h4 id="modalTitle">说明</h4>
2833
+ <p id="modalBody"></p>
2834
+ <div class="close"><button id="modalClose">关闭</button></div>
2835
+ </div>
2836
+ </div>
2837
+
2838
+ <script>
2839
+ (function(){
2840
+ var mask=document.getElementById('modalMask');
2841
+ var titleEl=document.getElementById('modalTitle');
2842
+ var bodyEl=document.getElementById('modalBody');
2843
+ function open(title,body){titleEl.textContent=title||'说明';bodyEl.textContent=body||'';mask.classList.add('show');}
2844
+ function close(){mask.classList.remove('show');}
2845
+ document.addEventListener('click',function(e){
2846
+ var btn=e.target.closest('.info-btn,.tl-repeat');
2847
+ if(btn){e.stopPropagation();open(btn.dataset.title,btn.dataset.info);return;}
2848
+ if(!e.target.closest('.modal')) close();
2849
+ });
2850
+ document.getElementById('modalClose').addEventListener('click',close);
2851
+ document.addEventListener('keydown',function(e){if(e.key==='Escape') close();});
2852
+ })();
2853
+ </script>
2854
+ </body></html>`;
2855
+ }
2856
+
2857
+ // ── U1 报告维度提取辅助:从 events/metrics/doctor/archive 提炼 5 维度,数据缺失诚实标注 _available: false ──
2858
+ function extractChangedFiles(events, projectRoot) {
2859
+ const adoptionEvents = events.filter(e => e.type === 'ai_adoption_review' || Boolean(e.details?.ai_adoption) || Boolean(e.details?.ai_code_adoption));
2860
+ const latest = latestByTimestamp(adoptionEvents);
2861
+ const review = latest ? getAiAdoptionReview(latest) : null;
2862
+ const aiDiff = review?.ai_diff || null;
2863
+ const hasFiles = Array.isArray(aiDiff?.files) && aiDiff.files.length > 0;
2864
+ // P0-4: ai_diff 无效时,从 assertions[].files 聚合兜底(补救存量 ai_adoption_review 无 ai_diff 的事件)
2865
+ if (!aiDiff || (aiDiff.files_changed == null && aiDiff.added_lines == null && !hasFiles)) {
2866
+ if (latest?.details) {
2867
+ const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
2868
+ const rawAssertions = rawReview.assertions;
2869
+ if (Array.isArray(rawAssertions)) {
2870
+ const filesFromAssertions = new Set();
2871
+ for (const a of rawAssertions) {
2872
+ if (Array.isArray(a.files)) {
2873
+ for (const f of a.files) {
2874
+ if (typeof f === 'string' && f.trim()) filesFromAssertions.add(f.trim());
2875
+ }
2876
+ }
2877
+ }
2878
+ if (filesFromAssertions.size > 0) {
2879
+ return { _available: true, files_changed: filesFromAssertions.size, added_lines: null, files: [...filesFromAssertions] };
2880
+ }
2881
+ }
2882
+ }
2883
+ return { _available: false, files_changed: null, added_lines: null, files: null };
2884
+ }
2885
+ let addedLines = Number.isFinite(aiDiff.added_lines) ? aiDiff.added_lines : null;
2886
+ const files = hasFiles ? aiDiff.files.slice() : null;
2887
+ // T3.3: ai_git_sha=null(无 git)时,用 fs 读 files 行数作 added_lines fallback(DC-104 无 git/路径缺失不崩溃)
2888
+ if (addedLines == null && review && review.ai_git_sha == null && hasFiles && projectRoot) {
2889
+ let sum = 0;
2890
+ let counted = 0;
2891
+ const projectRootResolved = path.resolve(projectRoot);
2892
+ for (const rel of aiDiff.files) {
2893
+ try {
2894
+ // Y1+Y6 修复:校验路径不穿越 projectRoot + 用 split 替代 match 减少正则数组开销
2895
+ const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(projectRoot, rel);
2896
+ if (!abs.startsWith(projectRootResolved + path.sep) && abs !== projectRootResolved) continue;
2897
+ const content = fs.readFileSync(abs, 'utf8');
2898
+ const newlines = content.split('\n').length - 1;
2899
+ sum += newlines + (content.length > 0 && !content.endsWith('\n') ? 1 : 0);
2900
+ counted += 1;
2901
+ } catch {
2902
+ // 文件不存在/不可读,跳过
2903
+ }
2904
+ }
2905
+ if (counted > 0) addedLines = sum;
2906
+ }
2907
+ // A6(v3): task_update 汇总文件数优先于 ai_diff 自报值(ai_diff 是 agent 一次性快照,
2908
+ // task_update 累加去重更准);两者不一致时 ai_diff 作辅助并标注差异。
2909
+ // 兼容 task_update.details.files / details.test_results.files / details.files_changed(数组形态)
2910
+ const taskUpdateFiles = new Set();
2911
+ for (const e of events) {
2912
+ if (e?.type !== 'task_update') continue;
2913
+ const filesField = e.details?.files
2914
+ || e.details?.test_results?.files
2915
+ || (Array.isArray(e.details?.files_changed) ? e.details.files_changed : null);
2916
+ if (Array.isArray(filesField)) {
2917
+ for (const f of filesField) {
2918
+ if (typeof f === 'string' && f.trim()) taskUpdateFiles.add(f.trim());
2919
+ }
2920
+ }
2921
+ }
2922
+ const taskUpdateFileCount = taskUpdateFiles.size;
2923
+ const aiDiffFileCount = Number.isFinite(aiDiff.files_changed) ? aiDiff.files_changed : (files ? files.length : null);
2924
+ let filesChanged;
2925
+ let aiDiffFilesChanged = null;
2926
+ let filesDiffNote = null;
2927
+ if (taskUpdateFileCount > 0) {
2928
+ filesChanged = taskUpdateFileCount;
2929
+ if (aiDiffFileCount != null && aiDiffFileCount !== taskUpdateFileCount) {
2930
+ aiDiffFilesChanged = aiDiffFileCount;
2931
+ filesDiffNote = `ai_diff 自报 ${aiDiffFileCount} 与 task_update 汇总 ${taskUpdateFileCount} 不一致,已采用 task_update 汇总值`;
2932
+ }
2933
+ } else {
2934
+ filesChanged = aiDiffFileCount;
2935
+ }
2936
+ return {
2937
+ _available: true,
2938
+ files_changed: filesChanged,
2939
+ added_lines: addedLines,
2940
+ files,
2941
+ ai_diff_files_changed: aiDiffFilesChanged,
2942
+ files_diff_note: filesDiffNote,
2943
+ };
2944
+ }
2945
+
2946
+ function extractTimeline(events) {
2947
+ const nodes = events
2948
+ .filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end' || e.type === 'build_result' || e.type === 'task_update'))
2949
+ .sort((a, b) => timestampMs(a) - timestampMs(b));
2950
+ if (nodes.length === 0) return { _available: false, nodes: null };
2951
+ return {
2952
+ _available: true,
2953
+ nodes: nodes.map(e => ({
2954
+ timestamp: e.timestamp || null,
2955
+ type: e.type,
2956
+ command: e.command || e.stage || null,
2957
+ result: e.result || null,
2958
+ })),
2959
+ };
2960
+ }
2961
+
2962
+ // ── T2.3: 阶段级时间线节点(横向时间线数据源)── 配对 stage_start/stage_end 为一次阶段执行,
2963
+ // 折叠其间的 task_update 数量,标记 round(>1 即重复执行)。按 event_id 配对(同 summarizeStageExecutions)。
2964
+ function extractStageTimeline(events) {
2965
+ const stageEvents = events.filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end'));
2966
+ const starts = stageEvents
2967
+ .filter(e => e.type === 'stage_start')
2968
+ .sort((a, b) => timestampMs(a) - timestampMs(b));
2969
+ const ends = stageEvents.filter(e => e.type === 'stage_end');
2970
+ const endsById = new Map();
2971
+ for (const end of ends) {
2972
+ if (!endsById.has(end.event_id)) endsById.set(end.event_id, []);
2973
+ endsById.get(end.event_id).push(end);
2974
+ }
2975
+ const roundCount = {};
2976
+ // Y7 修复:预排序 task_updates 供 starts.map 扫描区间(降 O(starts×events) 为 O(starts+events))
2977
+ const taskUpdates = events.filter(e => e.type === 'task_update').sort((a, b) => timestampMs(a) - timestampMs(b));
2978
+ const nodes = starts.map(start => {
2979
+ const end = latestByTimestamp(endsById.get(start.event_id) || []);
2980
+ const cmd = start.command || start.stage || 'unknown';
2981
+ roundCount[cmd] = (roundCount[cmd] || 0) + 1;
2982
+ const round = roundCount[cmd];
2983
+ const startMs = timestampMs(start);
2984
+ const endMs = end ? timestampMs(end) : null;
2985
+ // Y7 修复:用预排序 taskUpdates 单次扫描区间 O(n),替代全量 filter O(starts×events)
2986
+ let taskCount = 0;
2987
+ for (const t of taskUpdates) {
2988
+ const tMs = timestampMs(t);
2989
+ if (tMs < startMs) continue;
2990
+ if (endMs != null && tMs > endMs) break;
2991
+ taskCount++;
2992
+ }
2993
+ return {
2994
+ stage: cmd,
2995
+ round,
2996
+ start_ts: start.timestamp || null,
2997
+ end_ts: end?.timestamp || null,
2998
+ duration_ms: end && Number.isFinite(end.duration_ms) ? end.duration_ms : null,
2999
+ result: end?.result || null,
3000
+ task_count: taskCount,
3001
+ };
3002
+ });
3003
+ if (nodes.length === 0) return { _available: false, nodes: null };
3004
+ return { _available: true, nodes };
3005
+ }
3006
+
3007
+ function extractStageStatus(metrics) {
3008
+ const byStage = metrics?.process?.rework_summary?.by_stage || [];
3009
+ if (byStage.length === 0) return { _available: false, stages: null };
3010
+ return {
3011
+ _available: true,
3012
+ stages: byStage.map(b => ({
3013
+ command: b.command,
3014
+ capability: b.capability,
3015
+ total_attempts: b.total_attempts,
3016
+ successful_attempts: b.successful_attempts,
3017
+ rework_attempts: b.rework_attempts,
3018
+ canonical_event_id: b.canonical_event_id || null,
3019
+ })),
3020
+ };
3021
+ }
3022
+
3023
+ function extractKnownRisks(events, doctor, archiveResult) {
3024
+ const severeIssues = doctor?.severe_issues || [];
3025
+ const warnings = [];
3026
+ // Q7: 从 archive task_completion 判断 warn 是否已修复(incomplete=0 → resolved)
3027
+ const taskCompletion = archiveResult?.task_completion;
3028
+ const allResolved = taskCompletion && Number.isFinite(taskCompletion.incomplete) && taskCompletion.incomplete === 0;
3029
+ for (const e of events) {
3030
+ const cr = e.details?.check_results;
3031
+ if (cr && Number.isFinite(cr.warnings) && cr.warnings > 0) {
3032
+ // T2.1: 携带 warning_items 明细(若有);旧事件无该字段时降级为仅数量(DC-101/UT-202)
3033
+ const entry = { command: e.command || e.stage || null, warnings: cr.warnings };
3034
+ if (Array.isArray(cr.warning_items) && cr.warning_items.length > 0) {
3035
+ entry.items = cr.warning_items;
3036
+ }
3037
+ // Q7: 归档后 incomplete=0 → 标已修复
3038
+ if (allResolved) entry.resolved = true;
3039
+ warnings.push(entry);
3040
+ }
3041
+ const conf = getConformanceReview(e);
3042
+ if (conf && Array.isArray(conf.assertions)) {
3043
+ // normalizeConformanceStatus 归一化为 matched/partial/missed;missed 表示断言未匹配(等价失败)
3044
+ const failed = conf.assertions.filter(a => a.status === 'missed');
3045
+ if (failed.length > 0) {
3046
+ const confEntry = { command: e.command || 'conformance', failed_assertions: failed.length };
3047
+ if (allResolved) confEntry.resolved = true;
3048
+ warnings.push(confEntry);
3049
+ }
3050
+ }
3051
+ }
3052
+ if (severeIssues.length === 0 && warnings.length === 0) {
3053
+ return { _available: false, severe_issues: null, warnings: null };
3054
+ }
3055
+ return { _available: true, severe_issues: severeIssues, warnings };
3056
+ }
3057
+
3058
+ function extractArtifacts(archiveEvent) {
3059
+ const ar = archiveEvent?.details?.archive_result;
3060
+ if (!ar) return { _available: false, archive_path: null, manifest_path: null, moved_files: null, copied_specs: null };
3061
+ const movedFiles = Array.isArray(ar.moved_files) ? ar.moved_files : (Array.isArray(ar.movedFiles) ? ar.movedFiles : null);
3062
+ // P0-1: copied_specs 是归档时实际迁移的 spec 文件清单(archive-manifest 字段)
3063
+ const copiedSpecs = Array.isArray(ar.copied_specs) ? ar.copied_specs : (Array.isArray(ar.copiedSpecs) ? ar.copiedSpecs : null);
3064
+ return {
3065
+ _available: true,
3066
+ archive_path: ar.archive_path || null,
3067
+ manifest_path: ar.manifest_path || null,
3068
+ moved_files: movedFiles,
3069
+ copied_specs: copiedSpecs,
3070
+ };
3071
+ }
3072
+
3073
+ // ── U2/U3: 过程事件(process_note)提取 —— 阶段内修复/澄清/决策/API 故障/换模型等,可统计可追溯 ──
3074
+ function extractProcessNotes(events) {
3075
+ const notes = events.filter(e => !e.orphan && e.type === 'process_note');
3076
+ if (notes.length === 0) return { _available: false, total: 0, by_kind: null, by_stage: null, notes: null };
3077
+ const byKind = {};
3078
+ const byStage = {};
3079
+ for (const n of notes) {
3080
+ const kind = n.details?.kind || 'other';
3081
+ byKind[kind] = (byKind[kind] || 0) + 1;
3082
+ const stage = n.command || n.stage || 'unknown';
3083
+ byStage[stage] = (byStage[stage] || 0) + 1;
3084
+ }
3085
+ return {
3086
+ _available: true,
3087
+ total: notes.length,
3088
+ by_kind: byKind,
3089
+ by_stage: byStage,
3090
+ notes: notes.map(n => ({
3091
+ timestamp: n.timestamp || null,
3092
+ stage: n.command || n.stage || null,
3093
+ kind: n.details?.kind || 'other',
3094
+ summary: n.summary || n.details?.summary || '',
3095
+ target: n.details?.target || null,
3096
+ round: Number.isFinite(n.details?.round) ? n.details.round : null,
3097
+ })),
3098
+ };
3099
+ }
3100
+
3101
+ // ── T3.2: telemetry 警告提取 —— gate 记录的 task_update_reuse / process_note_missing / apply_test_missing
3102
+ // 供执行摘要「过程质量信号警示条」判定 task_update 复用。事件结构由 sdd-apply-test-gate.cjs recordWarning 写入。
3103
+ function extractTelemetryWarnings(events) {
3104
+ const warnings = events.filter(e => !e.orphan && e.type === 'telemetry_warning');
3105
+ if (warnings.length === 0) return { _available: false, total: 0, by_type: {}, items: [] };
3106
+ const byType = {};
3107
+ const items = [];
3108
+ for (const w of warnings) {
3109
+ const t = (w.details && (w.details.warning || w.details.type || w.details.warning_type)) || 'unknown';
3110
+ byType[t] = (byType[t] || 0) + 1;
3111
+ items.push({ type: t, timestamp: w.timestamp || null, message: w.summary || (w.details && w.details.message) || '' });
3112
+ }
3113
+ return { _available: true, total: warnings.length, by_type: byType, items };
3114
+ }
3115
+
1915
3116
  function buildReport(projectRoot, events, options = {}) {
1916
3117
  const archiveEvent = latestByTimestamp(events.filter(e => {
1917
3118
  return e.command === 'archive' && e.type === 'stage_end' && !e.orphan;
1918
3119
  }));
3120
+ const metrics = computePdfMvpMetrics(events, {
3121
+ level: options.level || (options.change ? 'change' : 'project'),
3122
+ change: options.change,
3123
+ capability: options.capability,
3124
+ projectRoot,
3125
+ });
3126
+ const doctor = computeDoctorReport(events, { change: options.change, projectRoot });
1919
3127
  return {
1920
3128
  generated_at: nowISO(),
1921
3129
  project_root: projectRoot,
1922
3130
  change: options.change || null,
1923
3131
  level: options.level || (options.change ? 'change' : 'project'),
1924
- metrics: computePdfMvpMetrics(events, {
1925
- level: options.level || (options.change ? 'change' : 'project'),
1926
- change: options.change,
1927
- capability: options.capability,
1928
- projectRoot,
1929
- }),
1930
- doctor: computeDoctorReport(events, { change: options.change }),
3132
+ metrics,
3133
+ doctor,
1931
3134
  archive_result: archiveEvent?.details?.archive_result || null,
3135
+ changed_files: extractChangedFiles(events, projectRoot),
3136
+ timeline: extractTimeline(events),
3137
+ stage_timeline: extractStageTimeline(events),
3138
+ stage_status: extractStageStatus(metrics),
3139
+ known_risks: extractKnownRisks(events, doctor, archiveEvent?.details?.archive_result),
3140
+ artifacts: extractArtifacts(archiveEvent),
3141
+ process_notes: extractProcessNotes(events),
3142
+ telemetry_warnings: extractTelemetryWarnings(events),
1932
3143
  };
1933
3144
  }
1934
3145
 
@@ -1948,6 +3159,7 @@ function computeDoctorReport(events, options = {}) {
1948
3159
  ? events.filter(e => e.change === options.change)
1949
3160
  : events;
1950
3161
  const executionSummary = summarizeStageExecutions(scopedEvents);
3162
+ const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
1951
3163
  const stageEvents = scopedEvents.filter(e => e.type === 'stage_start' || e.type === 'stage_end');
1952
3164
  const starts = stageEvents.filter(e => e.type === 'stage_start');
1953
3165
  const ends = stageEvents.filter(e => e.type === 'stage_end');
@@ -1960,13 +3172,13 @@ function computeDoctorReport(events, options = {}) {
1960
3172
 
1961
3173
  const matchedStarts = starts.filter(e => endsById.has(e.event_id));
1962
3174
  const supersededOpenIds = new Set(executionSummary.reworkAttempts
1963
- .filter(attempt => attempt.rework_reason === 'superseded_open')
3175
+ .filter(attempt => attempt.rework_reason === 'incomplete')
1964
3176
  .map(attempt => attempt.start.event_id));
1965
3177
  const supersededOpenStages = starts.filter(e => supersededOpenIds.has(e.event_id));
1966
3178
  const openStages = starts.filter(e => !endsById.has(e.event_id) && !supersededOpenIds.has(e.event_id));
1967
3179
  const orphanEnds = ends.filter(e => e.orphan || !startsById.has(e.event_id));
1968
3180
  const effectiveStartCount = starts.length - supersededOpenStages.length;
1969
- const matchedStageRate = effectiveStartCount === 0 ? 1 : matchedStarts.length / effectiveStartCount;
3181
+ const matchedStageRate = effectiveStartCount === 0 ? null : matchedStarts.length / effectiveStartCount;
1970
3182
 
1971
3183
  const unknownCommandEvents = stageEvents.filter(e => !e.command || e.command === 'unknown');
1972
3184
  const unknownAgentEvents = stageEvents.filter(e => !e.agent_type || e.agent_type === 'unknown');
@@ -2011,7 +3223,7 @@ function computeDoctorReport(events, options = {}) {
2011
3223
 
2012
3224
  return {
2013
3225
  telemetry_health_score: telemetryHealthScore,
2014
- matched_stage_rate: Math.round(matchedStageRate * 100) / 100,
3226
+ matched_stage_rate: matchedStageRate == null ? null : Math.round(matchedStageRate * 100) / 100,
2015
3227
  total_events: scopedEvents.length,
2016
3228
  stage_events: stageEvents.length,
2017
3229
  start_events: starts.length,
@@ -2030,7 +3242,7 @@ function computeDoctorReport(events, options = {}) {
2030
3242
  rework_attempts: executionSummary.summary.total_rework_attempts,
2031
3243
  completed_rework_attempts: executionSummary.summary.completed_rework_attempts,
2032
3244
  rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
2033
- rework_summary: compactReworkSummary(executionSummary.summary),
3245
+ rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
2034
3246
  severe_issues: severeIssues,
2035
3247
  warnings,
2036
3248
  };
@@ -2090,6 +3302,13 @@ function cmdStart(args) {
2090
3302
  fail(`context JSON 解析失败: ${err.message}`);
2091
3303
  }
2092
3304
 
3305
+ // apply 时记录 worktree_used/worktree_dir(从 SKILL 传入 --worktree-used --worktree-dir)
3306
+ // CLI parseArgs 把 --worktree-used=true 解析为字符串 "true",需同时接受布尔与字符串
3307
+ const isTrue = (v) => v === true || v === 'true';
3308
+ const worktreeUsed = isTrue(args['worktree-used']) || isTrue(args['worktree_used']);
3309
+ const worktreeDir = args['worktree-dir'] || args['worktree_dir'] || null;
3310
+ const worktreeDetails = worktreeUsed ? { worktree_used: true, worktree_dir: worktreeDir } : undefined;
3311
+
2093
3312
  const event = cleanOptionalFields({
2094
3313
  schema_version: SCHEMA_VERSION,
2095
3314
  event_id: eventId,
@@ -2106,6 +3325,7 @@ function cmdStart(args) {
2106
3325
  git_sha: gitSha,
2107
3326
  timestamp,
2108
3327
  context,
3328
+ details: worktreeDetails,
2109
3329
  });
2110
3330
 
2111
3331
  appendEvent(dataDir, event.change, event);
@@ -2125,16 +3345,22 @@ function cmdStart(args) {
2125
3345
  */
2126
3346
  function cmdEnd(args, options = {}) {
2127
3347
  let eventId = args['event-id'] || args.event_id;
2128
- const result = args.result;
2129
- const summary = args.summary || '';
3348
+ let result = args.result;
3349
+ let summary = args.summary || '';
2130
3350
  const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
2131
- 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`) : '');
3351
+ let reportOutput = args['report-output'] || args.report_output || '';
2132
3352
 
2133
3353
  if (!result) {
2134
3354
  console.error('错误: 缺少 --result 参数(success/failure/partial)');
2135
3355
  process.exit(1);
2136
3356
  }
2137
3357
 
3358
+ const reason = args.reason;
3359
+ if (reason !== undefined && reason !== null && !REWORK_REASON_CATEGORIES.includes(reason)) {
3360
+ console.error(`错误: --reason 非法枚举(${reason}),合法值:${REWORK_REASON_CATEGORIES.join('/')}`);
3361
+ process.exit(1);
3362
+ }
3363
+
2138
3364
  const dataDir = getDataDir(projectRoot);
2139
3365
  const activeCriteria = {
2140
3366
  session_id: args['session-id'] || args.session_id,
@@ -2148,6 +3374,42 @@ function cmdEnd(args, options = {}) {
2148
3374
  if (!eventId && startEvent) {
2149
3375
  eventId = startEvent.event_id;
2150
3376
  }
3377
+ // stage_end 幂等性保护:同一 event_id 已存在 stage_end 时,结果相同则跳过,不同则更新(archive 命令只要存在即跳过)
3378
+ const command = startEvent?.command || args.command || 'unknown';
3379
+ const change = startEvent?.change || args.change || args['change-name'] || 'general';
3380
+ const existingEnds = readAllEventEndsById(dataDir, eventId, change);
3381
+ if (existingEnds.length > 0) {
3382
+ const latestExisting = existingEnds.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
3383
+ // archive 命令已存在 stage_end 时直接跳过,避免 result 突变后重复写入
3384
+ if (command === 'archive') {
3385
+ const output = {
3386
+ event_id: eventId,
3387
+ duration_ms: latestExisting.duration_ms,
3388
+ recorded_at: latestExisting.timestamp,
3389
+ report_output: latestExisting.details?.archive_result?.report_path || '',
3390
+ message: `SDD ${latestExisting.command || command} 阶段结束(archive,已存在相同 event_id 的 stage_end,幂等跳过)`,
3391
+ };
3392
+ if (!options.silent) {
3393
+ console.log(JSON.stringify(output, null, 2));
3394
+ }
3395
+ return output;
3396
+ }
3397
+ if (latestExisting.result === result) {
3398
+ // 结果相同:幂等跳过,返回统一 shape
3399
+ const output = {
3400
+ event_id: eventId,
3401
+ duration_ms: latestExisting.duration_ms,
3402
+ recorded_at: latestExisting.timestamp,
3403
+ report_output: latestExisting.details?.archive_result?.report_path || '',
3404
+ message: `SDD ${latestExisting.command || command} 阶段结束(${result},已存在相同 event_id 的 stage_end,幂等跳过)`,
3405
+ };
3406
+ if (!options.silent) {
3407
+ console.log(JSON.stringify(output, null, 2));
3408
+ }
3409
+ return output;
3410
+ }
3411
+ // 结果不同:允许继续写入新的 stage_end(覆盖/更新)
3412
+ }
2151
3413
  const orphan = !startEvent;
2152
3414
  if (!eventId) {
2153
3415
  eventId = generateEventId();
@@ -2164,13 +3426,18 @@ function cmdEnd(args, options = {}) {
2164
3426
  ? new Date(timestamp).getTime() - new Date(startEvent.timestamp).getTime()
2165
3427
  : null;
2166
3428
 
2167
- const command = startEvent?.command || args.command || 'unknown';
2168
- const change = startEvent?.change || args.change || args['change-name'] || 'general';
2169
- if (command === 'archive' && result === 'success' && !reportOutput) {
2170
- reportOutput = path.join('skywalk-sdd', 'reports', `${safeChangeName(change)}-report.md`);
3429
+ // command change 已在幂等性检查前声明
3430
+ if (!reportOutput && args['generate-report']) {
3431
+ // archive success 时归档已把 change 移到 archive 目录,此时若默认活跃路径会让
3432
+ // ensureArchiveSuccessArtifacts 走显式分支 报告落活跃路径 + ensureDir 重建空 active。
3433
+ // 故 archive success 不设活跃默认,由 repaired.reportPath 兜底到 archive 目录。
3434
+ if (!(command === 'archive' && result === 'success')) {
3435
+ reportOutput = path.join('openspec', 'changes', change, 'reports', `${safeChangeName(change)}-report.md`);
3436
+ }
2171
3437
  }
3438
+ let repaired = null;
2172
3439
  if (command === 'archive' && result === 'success') {
2173
- const repaired = ensureArchiveSuccessArtifacts(projectRoot, change, details, {
3440
+ repaired = ensureArchiveSuccessArtifacts(projectRoot, change, details, {
2174
3441
  reason: details.archive_result?.reason || '',
2175
3442
  reportOutput,
2176
3443
  });
@@ -2184,6 +3451,22 @@ function cmdEnd(args, options = {}) {
2184
3451
  };
2185
3452
  }
2186
3453
  }
3454
+ // archive 归档完成后,扫描 task_completion,has_incomplete 降为 partial(仍归档,仅 result 标记提示未勾验收)
3455
+ if (command === 'archive' && result === 'success') {
3456
+ try {
3457
+ const archiveDirRel = repaired?.archive_path || details.archive_result?.archive_path || null;
3458
+ const archiveDirAbs = archiveDirRel ? (path.isAbsolute(archiveDirRel) ? archiveDirRel : path.join(projectRoot, archiveDirRel)) : null;
3459
+ const taskCompletion = archiveDirAbs
3460
+ ? scanTaskCompletionForArchiveDir(projectRoot, change, archiveDirAbs)
3461
+ : scanTaskCompletion(projectRoot, change);
3462
+ if (taskCompletion.has_incomplete) {
3463
+ result = 'partial';
3464
+ summary = `${summary}(${taskCompletion.incomplete} 项验收标准未勾选)`.trim();
3465
+ }
3466
+ } catch (err) {
3467
+ try { console.error(`[telemetry] archive 降级检查失败(不阻塞): ${err.message}`); } catch {}
3468
+ }
3469
+ }
2187
3470
  const event = cleanOptionalFields({
2188
3471
  schema_version: SCHEMA_VERSION,
2189
3472
  event_id: eventId,
@@ -2203,6 +3486,7 @@ function cmdEnd(args, options = {}) {
2203
3486
  result,
2204
3487
  summary,
2205
3488
  details,
3489
+ rework_reason: reason || undefined,
2206
3490
  orphan: orphan ? true : undefined,
2207
3491
  });
2208
3492
 
@@ -2211,23 +3495,69 @@ function cmdEnd(args, options = {}) {
2211
3495
  clearActiveStage(projectRoot, startEvent);
2212
3496
  }
2213
3497
 
3498
+ // archive 时追加"变更总结"段到 execution-log.md(T1.3:移到 appendEvent 之后,确保总结出现在 archive_end 行之后)
3499
+ if (command === 'archive') {
3500
+ try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
3501
+ try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
3502
+ }
3503
+ }
3504
+
2214
3505
  let resolvedReportOutput = '';
2215
3506
  if (reportOutput) {
3507
+ resolvedReportOutput = path.isAbsolute(reportOutput) ? reportOutput : path.resolve(projectRoot, reportOutput);
3508
+ } else if (repaired && repaired.reportPath) {
3509
+ resolvedReportOutput = repaired.reportPath;
3510
+ }
3511
+ if (resolvedReportOutput) {
2216
3512
  const events = readEvents(dataDir, event.change);
2217
3513
  const report = buildReport(projectRoot, events, {
2218
3514
  level: args['report-level'] || 'change',
2219
3515
  change: event.change,
2220
3516
  capability: args.capability || args['capability-name'] || startEvent?.capability,
2221
3517
  });
2222
- const reportFormat = args['report-format'] || 'markdown';
2223
- const rendered = reportFormat === 'json'
2224
- ? JSON.stringify(report, null, 2)
2225
- : renderExecutiveReportMarkdown(report);
2226
- resolvedReportOutput = path.isAbsolute(reportOutput)
2227
- ? reportOutput
2228
- : path.resolve(projectRoot, reportOutput);
2229
- ensureDir(path.dirname(resolvedReportOutput));
2230
- fs.writeFileSync(resolvedReportOutput, rendered + '\n', 'utf8');
3518
+ const reportFormat = args['report-format'] || 'markdown';
3519
+ const isHtmlPrimary = reportFormat === 'html';
3520
+ const rendered = reportFormat === 'json'
3521
+ ? JSON.stringify(report, null, 2)
3522
+ : (isHtmlPrimary ? renderExecutiveReportHtml(report) : renderExecutiveReportMarkdown(report));
3523
+ ensureDir(path.dirname(resolvedReportOutput));
3524
+ fs.writeFileSync(resolvedReportOutput, rendered + '\n', 'utf8');
3525
+
3526
+ // 默认双输出:md + html,避免只生成单一格式;但仅对 .md/.html 主路径派生副本,防止非标准路径被静默覆盖
3527
+ if (reportFormat !== 'json') {
3528
+ try {
3529
+ const primaryExt = path.extname(resolvedReportOutput).toLowerCase();
3530
+ const isMdPrimary = primaryExt === '.md';
3531
+ const isHtmlExt = primaryExt === '.html';
3532
+ if (isMdPrimary || isHtmlExt) {
3533
+ const companionSuffix = isHtmlExt ? '.md' : '.html';
3534
+ const companionRender = isHtmlExt ? renderExecutiveReportMarkdown(report) : renderExecutiveReportHtml(report);
3535
+ const htmlPath = resolvedReportOutput.replace(/\.[^.]+$/, companionSuffix);
3536
+ if (htmlPath !== resolvedReportOutput) {
3537
+ fs.writeFileSync(htmlPath, companionRender + '\n', 'utf8');
3538
+ }
3539
+ }
3540
+ } catch (err) {
3541
+ try { console.error(`[telemetry] ${isHtmlPrimary ? 'md' : 'html'} 报告副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
3542
+ }
3543
+ }
3544
+ }
3545
+
3546
+ // A1(v3): appendEvent 已把 archive stage_end 写入活跃 jsonl,但上方 ensureArchiveSuccessArtifacts
3547
+ // 在 appendEvent 之前拷贝 events → archive/evidence/jsonl 缺末条 stage_end。此处重拷覆盖,幂等。
3548
+ // 触发条件:archive 且归档产物已生成(repaired 非空,即原始 result=success,不含 failure/纯 partial)。
3549
+ if (command === 'archive' && repaired && details.archive_result?.archive_path) {
3550
+ try {
3551
+ const archiveDirAbs = path.isAbsolute(details.archive_result.archive_path)
3552
+ ? details.archive_result.archive_path
3553
+ : path.resolve(projectRoot, details.archive_result.archive_path);
3554
+ const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(change));
3555
+ if (fs.existsSync(eventsSrcDir) && fs.existsSync(archiveDirAbs)) {
3556
+ copyDirSync(eventsSrcDir, path.join(archiveDirAbs, 'evidence', 'events'));
3557
+ }
3558
+ } catch (err) {
3559
+ try { console.error(`[telemetry] archive events 重拷失败(不阻塞): ${err.message}`); } catch {}
3560
+ }
2231
3561
  }
2232
3562
 
2233
3563
  const output = {
@@ -2265,6 +3595,8 @@ function cmdRecord(args) {
2265
3595
  'baseline_record',
2266
3596
  'telemetry_warning',
2267
3597
  'worktree_finish',
3598
+ 'process_note',
3599
+ 'doc_update',
2268
3600
  ];
2269
3601
 
2270
3602
  if (!type) {
@@ -2278,12 +3610,75 @@ function cmdRecord(args) {
2278
3610
  }
2279
3611
 
2280
3612
  let details;
3613
+ let detailsFilePath = null;
2281
3614
  try {
2282
3615
  details = parseJsonOption(args, 'details-json', 'details-file', projectRoot, {});
3616
+ detailsFilePath = args['details-file'] || args.details_file || null;
3617
+ // 默认清理 details-file,避免项目根目录被临时 JSON 污染;--keep-details 保留调试用
3618
+ if (detailsFilePath && !args['keep-details'] && !args.keep_details) {
3619
+ try {
3620
+ const fileToRemove = normalizeDetailsFilePath(detailsFilePath, projectRoot);
3621
+ if (fs.existsSync(fileToRemove)) {
3622
+ fs.unlinkSync(fileToRemove);
3623
+ }
3624
+ } catch (cleanupErr) {
3625
+ // 清理失败不阻塞主流程
3626
+ try { console.error(`[telemetry] details-file 清理失败(不阻塞): ${cleanupErr.message}`); } catch {}
3627
+ }
3628
+ }
2283
3629
  } catch (err) {
2284
3630
  fail(`details JSON 解析失败: ${err.message}`);
2285
3631
  }
2286
3632
 
3633
+ // task_update 成功时自动同步 tasks.md checkbox
3634
+ if (type === 'task_update' && args.result === 'success') {
3635
+ try {
3636
+ const taskId = args['task-id'] || args.task_id || details?.task_id || null;
3637
+ if (taskId) {
3638
+ runCheckTaskSync(projectRoot, change, taskId);
3639
+ }
3640
+ } catch (taskErr) {
3641
+ // checkbox 同步失败不阻塞原流程
3642
+ try { console.error(`[telemetry] tasks.md checkbox 自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
3643
+ }
3644
+ }
3645
+
3646
+ // conformance_review evidence 校验——assertion 必须有可验证证据:
3647
+ // files 含测试文件路径(test/ | .test. | .spec.)→ 强证据;
3648
+ // 或 evidence 文本非空 → 降级证据(浏览器自动化/手动验收等无测试文件场景,与 SKILL.md schema 对齐:空 files[] 须配 evidence);
3649
+ // 两者皆无 → 空口断言拒绝。
3650
+ if (type === 'conformance_review') {
3651
+ const cr = details.conformance_review;
3652
+ if (cr && Array.isArray(cr.assertions)) {
3653
+ for (const a of cr.assertions) {
3654
+ const files = Array.isArray(a.files) ? a.files : [];
3655
+ const hasTestFile = files.some(f => /test[\\/]|\.test\.|\.spec\./.test(String(f)));
3656
+ const hasEvidence = typeof a.evidence === 'string' && a.evidence.trim().length > 0;
3657
+ if (!hasTestFile && !hasEvidence) {
3658
+ fail(`conformance_review assertion "${a.id || ''}" 缺少证据:files 须含测试文件路径(test/ | .test. | .spec.),或填 evidence 文本(如浏览器自动化/手动验收证据摘要)`);
3659
+ }
3660
+ }
3661
+ }
3662
+ }
3663
+
3664
+ // ai_adoption_review 校验——与 conformance_review 对称的记录时硬校验(doc 早已要求必填,见 opsx-apply/SKILL.md §P1-2):
3665
+ // ai_diff.files 必须填产出文件路径数组(非空),不得只发 files_changed 数字;
3666
+ // vcs_mode=readonly(git 仓库)时 added_lines 不得为 null——逼 agent 跑只读 "git diff --numstat HEAD"(apply Git 只读策略允许);
3667
+ // vcs_mode=no-git 时 added_lines 可为 null(无 diff 可统计)。缺失 vcs_mode 字段时放宽(兼容存量事件)。
3668
+ if (type === 'ai_adoption_review') {
3669
+ const adoption = details.ai_adoption || details.ai_adoption_review || details.ai_code_adoption;
3670
+ if (adoption) {
3671
+ const aiDiff = adoption.ai_diff || {};
3672
+ const files = Array.isArray(aiDiff.files) ? aiDiff.files : null;
3673
+ if (!files || files.length === 0) {
3674
+ fail(`ai_adoption_review 的 ai_diff.files 必须填产出文件路径数组(非空),不得只发 files_changed 数字;详见 opsx-apply/reference.md §5.1`);
3675
+ }
3676
+ if (adoption.vcs_mode === 'readonly' && (aiDiff.added_lines === null || aiDiff.added_lines === undefined)) {
3677
+ fail(`ai_adoption_review vcs_mode=readonly 时 ai_diff.added_lines 不得为 null——请用只读 "git diff --numstat HEAD" 取值(apply Git 只读策略允许);vcs_mode=no-git 时可填 null`);
3678
+ }
3679
+ }
3680
+ }
3681
+
2287
3682
  const dataDir = getDataDir(projectRoot);
2288
3683
  const activeStage = findActiveStage(projectRoot, {
2289
3684
  session_id: args['session-id'] || args.session_id,
@@ -2293,6 +3688,32 @@ function cmdRecord(args) {
2293
3688
  });
2294
3689
  const eventId = args['event-id'] || args.event_id || generateEventId();
2295
3690
  const timestamp = nowISO();
3691
+ // T1.2: task_update 复用标注 — 同 session 上一条 task_update 的 test_results.duration_ms 一致且时间戳相邻(<5s) → recorded_late(提示性,不阻断)
3692
+ if (type === 'task_update' && details && details.test_results) {
3693
+ try {
3694
+ const sessionId = activeStage?.session_id || args['session-id'] || args.session_id || null;
3695
+ const priorEvents = readEvents(dataDir, change);
3696
+ const priorUpdates = priorEvents.filter((e) =>
3697
+ e.type === 'task_update' && e.session_id === sessionId && e.details && e.details.test_results
3698
+ );
3699
+ if (priorUpdates.length > 0) {
3700
+ // Y3 修复:显式按时间排序取最新一条(原取数组末尾依赖 readEvents 文件排序=时间序,多 jsonl 文件名非时间前缀时可能错序)
3701
+ const sorted = priorUpdates.slice().sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
3702
+ const last = sorted[sorted.length - 1];
3703
+ const lastTr = last.details.test_results;
3704
+ const thisTr = details.test_results;
3705
+ const lastFp = `${Number(lastTr.passed)}|${Number(lastTr.failed)}|${Number(lastTr.duration_ms)}`;
3706
+ const thisFp = `${Number(thisTr.passed)}|${Number(thisTr.failed)}|${Number(thisTr.duration_ms)}`;
3707
+ // T1.5(v3): 去掉 gap<=5000 相邻限制,改用 passed+failed+duration_ms 三元组指纹(与 detectReusedTestResults 对齐)
3708
+ if (Number.isFinite(Number(lastTr.duration_ms)) && Number.isFinite(Number(thisTr.duration_ms)) && lastFp === thisFp) {
3709
+ details.recorded_late = true;
3710
+ details.reused_fingerprint = thisFp;
3711
+ }
3712
+ }
3713
+ } catch {
3714
+ // recorded_late 检测失败不阻塞记录
3715
+ }
3716
+ }
2296
3717
  const event = cleanOptionalFields({
2297
3718
  schema_version: SCHEMA_VERSION,
2298
3719
  event_id: eventId,
@@ -2323,6 +3744,189 @@ function cmdRecord(args) {
2323
3744
  }, null, 2));
2324
3745
  }
2325
3746
 
3747
+ /** 解析 node --test 的 TAP 输出为 {passed, failed, skipped}(opsx-apply agent 跑测试后传 --test-stdout) */
3748
+ function parseTestTap(output) {
3749
+ if (!output || typeof output !== 'string') return { passed: 0, failed: 0, skipped: 0 };
3750
+ let passed = 0;
3751
+ let failed = 0;
3752
+ let skipped = 0;
3753
+ for (const line of output.split(/\r?\n/)) {
3754
+ if (/^ok\s+\d+/.test(line)) {
3755
+ if (/#\s*SKIP/i.test(line)) { skipped += 1; continue; }
3756
+ passed += 1;
3757
+ } else if (/^not ok\s+\d+/.test(line)) {
3758
+ if (/#\s*TODO/i.test(line)) { skipped += 1; continue; }
3759
+ failed += 1;
3760
+ }
3761
+ }
3762
+ return { passed, failed, skipped };
3763
+ }
3764
+
3765
+ /** scan 子命令——输出全量 task_completion checkbox 统计(opsx-check 强制调用,禁止手填 completed/incomplete/total) */
3766
+ function cmdScan(args) {
3767
+ const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
3768
+ const changeName = args.change || args['change-name'];
3769
+ if (!changeName) {
3770
+ console.error('错误: 缺少 --change 参数');
3771
+ process.exit(1);
3772
+ }
3773
+ const result = scanTaskCompletion(projectRoot, changeName);
3774
+ console.log(JSON.stringify(result, null, 2));
3775
+ return result;
3776
+ }
3777
+
3778
+ /** 校验 spec 场景数 == 测试数(spec.md `##### 场景:` 计数 vs 最近 test_results.passed+failed) */
3779
+ function validateScenarioCoverage(projectRoot, changeName, capability) {
3780
+ const normalizedRoot = normalizeProjectRoot(projectRoot);
3781
+ const changeDir = getChangeDir(normalizedRoot, changeName);
3782
+ let specScenarios = 0;
3783
+ const specFiles = discoverFullSpecFiles(changeDir, changeName);
3784
+ for (const spec of specFiles) {
3785
+ if (capability && spec.capability !== capability) continue;
3786
+ let content = '';
3787
+ try { content = fs.readFileSync(spec.source, 'utf8'); } catch { continue; }
3788
+ const matches = content.match(/^#####\s*场景[::]/gm);
3789
+ if (matches) specScenarios += matches.length;
3790
+ }
3791
+ const dataDir = getDataDir(normalizedRoot);
3792
+ const events = readEvents(dataDir, changeName);
3793
+ const testEvents = getTestEvents(events);
3794
+ let testCount = 0;
3795
+ if (testEvents.length > 0) {
3796
+ // 优先取真正含 test_results 的事件(排除 stage_end(test) 等无 results 的事件);
3797
+ // 都无 results 时退回最新 test 事件(testCount 保持 0)
3798
+ const withResults = testEvents.filter(e => getTestResults(e) != null);
3799
+ const pool = withResults.length > 0 ? withResults : testEvents;
3800
+ const latest = pool.slice().sort((a, b) => timestampMs(a) - timestampMs(b)).pop();
3801
+ const tr = getTestResults(latest);
3802
+ if (tr) testCount = (Number(tr.passed) || 0) + (Number(tr.failed) || 0);
3803
+ }
3804
+ return { ok: specScenarios === testCount, spec: specScenarios, test: testCount };
3805
+ }
3806
+
3807
+ function cmdValidateScenario(args) {
3808
+ const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
3809
+ const changeName = args.change || args['change-name'];
3810
+ const capability = args.capability || args['capability-name'];
3811
+ if (!changeName) {
3812
+ console.error('错误: 缺少 --change 参数');
3813
+ process.exit(1);
3814
+ }
3815
+ const result = validateScenarioCoverage(projectRoot, changeName, capability);
3816
+ console.log(JSON.stringify(result, null, 2));
3817
+ return result;
3818
+ }
3819
+
3820
+ /** 扫描 src/+test/ 源文件快照(跳过 node_modules/dist/.git/build),返回 {relPath: {hash, lines}} */
3821
+ function scanSourceFiles(projectRoot) {
3822
+ const result = {};
3823
+ const scanDir = (dir, relBase) => {
3824
+ if (!fs.existsSync(dir)) return;
3825
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
3826
+ if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === '.git' || entry.name === 'build') continue;
3827
+ const full = path.join(dir, entry.name);
3828
+ const rel = relBase ? `${relBase}/${entry.name}` : entry.name;
3829
+ if (entry.isDirectory()) {
3830
+ scanDir(full, rel);
3831
+ } else if (/\.(js|cjs|mjs|ts|jsx|tsx|py|java|go|rs|rb|php)$/.test(entry.name)) {
3832
+ const content = fs.readFileSync(full, 'utf8');
3833
+ result[rel] = {
3834
+ hash: crypto.createHash('sha1').update(content).digest('hex').slice(0, 16),
3835
+ lines: content.split('\n').length,
3836
+ };
3837
+ }
3838
+ }
3839
+ };
3840
+ scanDir(path.join(projectRoot, 'src'), 'src');
3841
+ scanDir(path.join(projectRoot, 'test'), 'test');
3842
+ scanDir(path.join(projectRoot, 'tests'), 'tests');
3843
+ return result;
3844
+ }
3845
+
3846
+ /** snapshot 子命令——apply stage_start/end 扫描 src/+test/ 文件快照,end 时输出 added/modified/deleted diff */
3847
+ function cmdSnapshot(args) {
3848
+ const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
3849
+ const changeName = args.change || args['change-name'];
3850
+ const mode = args.mode || args['snapshot-mode'];
3851
+ if (!changeName) { console.error('错误: 缺少 --change 参数'); process.exit(1); }
3852
+ if (mode !== 'start' && mode !== 'end') { console.error('错误: 缺少 --mode=start|end'); process.exit(1); }
3853
+ const stateDir = getStateDir(projectRoot);
3854
+ ensureDir(stateDir);
3855
+ const snapshotFile = path.join(stateDir, `apply-${safeChangeName(changeName)}-snapshot.json`);
3856
+ const current = scanSourceFiles(projectRoot);
3857
+ if (mode === 'start') {
3858
+ fs.writeFileSync(snapshotFile, JSON.stringify(current, null, 2), 'utf8');
3859
+ const result = { mode: 'start', files_scanned: Object.keys(current).length };
3860
+ console.log(JSON.stringify(result, null, 2));
3861
+ return result;
3862
+ }
3863
+ let startSnap = {};
3864
+ try { startSnap = JSON.parse(fs.readFileSync(snapshotFile, 'utf8')); } catch {}
3865
+ const added = [], modified = [], deleted = [];
3866
+ let addedLines = 0, deletedLines = 0;
3867
+ for (const [f, info] of Object.entries(current)) {
3868
+ if (!startSnap[f]) {
3869
+ added.push(f);
3870
+ addedLines += info.lines;
3871
+ } else if (info.hash !== startSnap[f].hash) {
3872
+ modified.push(f);
3873
+ addedLines += Math.max(0, info.lines - (startSnap[f].lines || 0));
3874
+ deletedLines += Math.max(0, (startSnap[f].lines || 0) - info.lines);
3875
+ }
3876
+ }
3877
+ for (const f of Object.keys(startSnap)) {
3878
+ if (!current[f]) {
3879
+ deleted.push(f);
3880
+ deletedLines += startSnap[f].lines || 0;
3881
+ }
3882
+ }
3883
+ const result = { mode: 'end', added_files: added.length, modified_files: modified.length, deleted_files: deleted.length, added_lines: addedLines, deleted_lines: deletedLines, added, modified, deleted };
3884
+ console.log(JSON.stringify(result, null, 2));
3885
+ return result;
3886
+ }
3887
+
3888
+ /** archive 时聚合 events 生成"变更总结"段追加到 execution-log.md */
3889
+ function appendChangeSummary(projectRoot, changeName, events) {
3890
+ try {
3891
+ const changeDir = getChangeDir(projectRoot, changeName);
3892
+ let logsDir = path.join(changeDir, 'logs');
3893
+ if (!fs.existsSync(changeDir)) {
3894
+ const archiveDir = findArchivedChangeDir(projectRoot, changeName);
3895
+ if (archiveDir) logsDir = path.join(archiveDir, 'logs');
3896
+ }
3897
+ ensureDir(logsDir);
3898
+ const file = path.join(logsDir, 'execution-log.md');
3899
+ // 复用 computeChangeMetrics.change_summary,消除 execution-log 与报告的 1 vs 2 不一致(T1.3)
3900
+ const metrics = computeChangeMetrics(changeName, events);
3901
+ const cs = metrics && metrics.change_summary ? metrics.change_summary : null;
3902
+ const stageStartCount = cs ? cs.stage_start_count : events.filter(e => e.type === 'stage_start' && !e.orphan).length;
3903
+ const stageEndCount = cs ? cs.stage_end_count : events.filter(e => e.type === 'stage_end' && !e.orphan).length;
3904
+ const repeatCount = cs ? cs.stage_repeat_count : 0;
3905
+ const testPass = cs ? cs.test_pass : null;
3906
+ const testPassStr = testPass ? `${testPass.passed}/${testPass.passed + testPass.failed}` : '无测试数据';
3907
+ let unchecked = 0;
3908
+ try {
3909
+ const archiveDir = findArchivedChangeDir(projectRoot, changeName);
3910
+ const tc = archiveDir
3911
+ ? scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir)
3912
+ : scanTaskCompletion(projectRoot, changeName);
3913
+ unchecked = tc.has_incomplete ? tc.incomplete : 0;
3914
+ } catch {}
3915
+ const summary = [
3916
+ '',
3917
+ '## 变更总结',
3918
+ `- 阶段数:${stageStartCount} start / ${stageEndCount} end`,
3919
+ `- 阶段重复执行次数:${repeatCount}`,
3920
+ `- 测试通过率:${testPassStr}`,
3921
+ `- 遗留问题:${unchecked > 0 ? `${unchecked} 项验收标准未勾选` : '无'}`,
3922
+ '',
3923
+ ].join('\n');
3924
+ fs.appendFileSync(file, summary + '\n', 'utf8');
3925
+ } catch (err) {
3926
+ try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
3927
+ }
3928
+ }
3929
+
2326
3930
  /**
2327
3931
  * 从事件文件中查找 start 事件(因为 CLI 无状态,需要从文件回溯)
2328
3932
  */
@@ -2362,6 +3966,47 @@ function searchEventInDataDir(dataDir, eventId) {
2362
3966
  return null;
2363
3967
  }
2364
3968
 
3969
+ /** 按 event_id 读取所有 stage_end 事件(用于 cmdEnd 幂等性保护) */
3970
+ function readAllEventEndsById(dataDir, eventId, changeName) {
3971
+ if (!eventId) return [];
3972
+ const eventsDir = path.join(dataDir, 'events');
3973
+ if (!fs.existsSync(eventsDir)) return [];
3974
+ const ends = [];
3975
+
3976
+ const scanDir = (dir) => {
3977
+ if (!fs.existsSync(dir)) return;
3978
+ const files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')).sort().reverse();
3979
+ for (const file of files) {
3980
+ const lines = fs.readFileSync(path.join(dir, file), 'utf-8').split('\n').filter(Boolean);
3981
+ for (const line of lines) {
3982
+ try {
3983
+ const event = JSON.parse(line);
3984
+ if (event.event_id === eventId && event.type === 'stage_end') {
3985
+ ends.push(event);
3986
+ }
3987
+ } catch {}
3988
+ }
3989
+ }
3990
+ };
3991
+
3992
+ // 优先扫描指定 change 目录,命中即返回
3993
+ if (changeName) {
3994
+ scanDir(path.join(eventsDir, changeName));
3995
+ if (ends.length > 0) return ends;
3996
+ }
3997
+
3998
+ // 未命中时回退全量扫描
3999
+ try {
4000
+ const changeDirs = fs.readdirSync(eventsDir).filter(d => {
4001
+ return fs.statSync(path.join(eventsDir, d)).isDirectory();
4002
+ });
4003
+ for (const changeDir of changeDirs) {
4004
+ scanDir(path.join(eventsDir, changeDir));
4005
+ }
4006
+ } catch {}
4007
+ return ends;
4008
+ }
4009
+
2365
4010
  /**
2366
4011
  * log metrics: 查询度量指标
2367
4012
  */
@@ -2414,7 +4059,7 @@ function cmdReport(args) {
2414
4059
  const capability = args.capability || args['capability-name'];
2415
4060
  const dateFrom = args['date-from'];
2416
4061
  const dateTo = args['date-to'];
2417
- const format = args.format || 'markdown';
4062
+ const format = args.format || 'html';
2418
4063
  const outputPath = args.output || args['output-file'];
2419
4064
  const level = args.level || (capability ? 'capability' : (changeName ? 'change' : 'project'));
2420
4065
 
@@ -2429,7 +4074,9 @@ function cmdReport(args) {
2429
4074
  });
2430
4075
  const rendered = format === 'json'
2431
4076
  ? JSON.stringify(report, null, 2)
2432
- : renderExecutiveReportMarkdown(report);
4077
+ : format === 'html'
4078
+ ? renderExecutiveReportHtml(report)
4079
+ : renderExecutiveReportMarkdown(report);
2433
4080
 
2434
4081
  if (outputPath) {
2435
4082
  const resolvedOutput = path.isAbsolute(outputPath)
@@ -2461,7 +4108,7 @@ function cmdDoctor(args) {
2461
4108
  let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
2462
4109
  events = filterEventsByDate(events, dateFrom, dateTo);
2463
4110
 
2464
- const report = computeDoctorReport(events, { change: changeName });
4111
+ const report = computeDoctorReport(events, { change: changeName, projectRoot });
2465
4112
  console.log(JSON.stringify(report, null, 2));
2466
4113
  if (report.severe_issues.length > 0) {
2467
4114
  process.exitCode = 1;
@@ -2492,8 +4139,7 @@ function cmdTasksStatus(args) {
2492
4139
  function cmdArchiveDocs(args) {
2493
4140
  const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
2494
4141
  const changeName = args.change || args['change-name'];
2495
- const reportOutput = args['report-output'] || args.report_output ||
2496
- (changeName ? path.join('skywalk-sdd', 'reports', `${safeChangeName(changeName)}-report.md`) : undefined);
4142
+ const reportOutput = args['report-output'] || args.report_output || '';
2497
4143
 
2498
4144
  try {
2499
4145
  let result;
@@ -2507,7 +4153,7 @@ function cmdArchiveDocs(args) {
2507
4153
  } else {
2508
4154
  const repaired = ensureArchiveSuccessArtifacts(projectRoot, changeName, {
2509
4155
  archive_result: {
2510
- reason: args.reason || '',
4156
+ reason: '',
2511
4157
  report_path: reportOutput,
2512
4158
  },
2513
4159
  }, {
@@ -2529,6 +4175,8 @@ function cmdArchiveDocs(args) {
2529
4175
  if (!args['keep-active']) {
2530
4176
  stageEnd = cmdEnd({
2531
4177
  ...args,
4178
+ // 归档原因(args.reason)走 details.archive_result.reason,不应作为 cmdEnd 的返工 --reason(枚举)传入
4179
+ reason: undefined,
2532
4180
  project: projectRoot,
2533
4181
  command: 'archive',
2534
4182
  change: changeName,
@@ -2537,7 +4185,7 @@ function cmdArchiveDocs(args) {
2537
4185
  details: {
2538
4186
  ...(args.details && typeof args.details === 'object' ? args.details : {}),
2539
4187
  archive_result: {
2540
- reason: args.reason || '',
4188
+ reason: result.reason || args.reason || '',
2541
4189
  method: result.method,
2542
4190
  archive_path: result.archive_path,
2543
4191
  report_path: reportOutput,
@@ -2597,9 +4245,21 @@ function main() {
2597
4245
  case 'tasks-status':
2598
4246
  cmdTasksStatus(flags);
2599
4247
  break;
4248
+ case 'scan':
4249
+ cmdScan(flags);
4250
+ break;
4251
+ case 'validate-scenario':
4252
+ cmdValidateScenario(flags);
4253
+ break;
4254
+ case 'snapshot':
4255
+ cmdSnapshot(flags);
4256
+ break;
2600
4257
  case 'archive-docs':
2601
4258
  cmdArchiveDocs(flags);
2602
4259
  break;
4260
+ case 'check-task':
4261
+ cmdCheckTask(flags);
4262
+ break;
2603
4263
  default:
2604
4264
  showHelp();
2605
4265
  process.exit(1);
@@ -2614,8 +4274,9 @@ SDD Telemetry CLI - 流程度量采集工具
2614
4274
  node skywalk-sdd/log.cjs start --command=<cmd> --project=<path> [--change=<name>] [--agent=<type>]
2615
4275
  node skywalk-sdd/log.cjs end --event-id=<id> --result=<success|failure|partial> --summary="..."
2616
4276
  node skywalk-sdd/log.cjs metrics --project=<path> [--change=<name>] [--pdf-mvp] [--format=json|markdown]
2617
- node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=json|markdown] [--output=<file>]
4277
+ node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=html|markdown|json] [--output=<file>](默认 html)
2618
4278
  node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
4279
+ node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
2619
4280
  node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
2620
4281
 
2621
4282
  子命令:
@@ -2626,21 +4287,24 @@ SDD Telemetry CLI - 流程度量采集工具
2626
4287
  report 生成只读度量报告(不写入事件)
2627
4288
  doctor 诊断 Telemetry 数据质量
2628
4289
  tasks-status 扫描 Full/Simple 模式 tasks.md 勾选状态
4290
+ check-task 扫描变更目录 tasks.md 并勾选指定 task_id
2629
4291
  archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
2630
4292
 
2631
4293
  示例:
2632
4294
  node skywalk-sdd/log.cjs start --command=propose --project=/my/project --change=user-auth --agent=cursor
2633
4295
  node skywalk-sdd/log.cjs end --event-id=evt_abc123 --result=success --summary="创建 proposal.md"
2634
- node skywalk-sdd/log.cjs record --type=task_update --command=apply --project=/my/project --change=user-auth --task-id=TASK-01 --status=completed
2635
- node skywalk-sdd/log.cjs record --type=conformance_review --command=check --project=/my/project --change=user-auth --source=manual --details-file=conformance-review.json
2636
- node skywalk-sdd/log.cjs record --type=ai_adoption_review --command=apply --project=/my/project --change=user-auth --status=final --details-file=ai-adoption.json
2637
- node skywalk-sdd/log.cjs record --type=survey_result --project=/my/project --change=user-auth --source=manual --details-file=survey.json
4296
+ node skywalk-sdd/log.cjs record --type=task_update --command=apply --project=/my/project --change=user-auth --task-id=TASK-01 --status=completed --keep-details
4297
+ node skywalk-sdd/log.cjs record --type=conformance_review --command=check --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-conformance-review.json --keep-details
4298
+ node skywalk-sdd/log.cjs record --type=ai_adoption_review --command=apply --project=/my/project --change=user-auth --status=final --details-file=skywalk-sdd/state/user-auth-ai-adoption.json --keep-details
4299
+ node skywalk-sdd/log.cjs record --type=survey_result --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-survey.json --keep-details
2638
4300
  node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth
2639
4301
  node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth --pdf-mvp --format=markdown
2640
4302
  node skywalk-sdd/log.cjs report --project=/my/project --change=user-auth --format=markdown
2641
4303
  node skywalk-sdd/log.cjs doctor --project=/my/project --change=user-auth
2642
4304
  node skywalk-sdd/log.cjs tasks-status --project=/my/project --change=user-auth --require-complete
2643
- node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive --report-output=skywalk-sdd/reports/user-auth-report.md
4305
+ node skywalk-sdd/log.cjs check-task --project=/my/project --change=user-auth --task-id=TASK-01
4306
+ node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive
4307
+ # archive-docs 最终报告默认生成到 openspec/changes/archive/<日期>-<change>/reports/<change>-report.md;可用 --report-output=<路径> 指定自定义路径
2644
4308
  node skywalk-sdd/log.cjs record --type=worktree_finish --command=apply --project=/my/project --change=user-auth --capability=user-auth --result=success --summary="merge+remove completed"
2645
4309
 
2646
4310
  Apply worktree(主仓库根目录,非 log.cjs 子命令):
@@ -2656,6 +4320,7 @@ module.exports = {
2656
4320
  parseArgs,
2657
4321
  cmdStart,
2658
4322
  cmdEnd,
4323
+ cmdCheckTask,
2659
4324
  cmdRecord,
2660
4325
  cmdMetrics,
2661
4326
  cmdReport,
@@ -2669,6 +4334,11 @@ module.exports = {
2669
4334
  computePdfMvpMetrics,
2670
4335
  renderPdfMvpMarkdown,
2671
4336
  renderExecutiveReportMarkdown,
4337
+ renderExecutiveReportHtml,
4338
+ formatMsHuman,
4339
+ formatMsToSeconds,
4340
+ formatDuration,
4341
+ formatReworkReasons,
2672
4342
  buildReport,
2673
4343
  computeGitDocumentMetrics,
2674
4344
  getCheckResults,
@@ -2676,6 +4346,8 @@ module.exports = {
2676
4346
  getTestResults,
2677
4347
  getTaskUpdateResult,
2678
4348
  computeAiFirstPassMetrics,
4349
+ extractProcessNotes,
4350
+ extractStageTimeline,
2679
4351
  getSpecTestCoverage,
2680
4352
  getSpecTestCoverageEvents,
2681
4353
  computeSpecTestCoverageMetrics,
@@ -2690,6 +4362,12 @@ module.exports = {
2690
4362
  computeManualInsightMetrics,
2691
4363
  computeDoctorReport,
2692
4364
  scanTaskCompletion,
4365
+ cmdScan,
4366
+ cmdValidateScenario,
4367
+ validateScenarioCoverage,
4368
+ parseTestTap,
4369
+ cmdSnapshot,
4370
+ appendChangeSummary,
2693
4371
  archiveChangeDocs,
2694
4372
  filterEventsByDate,
2695
4373
  readEvents,
@@ -2705,6 +4383,7 @@ module.exports = {
2705
4383
  findActiveStage,
2706
4384
  clearActiveStage,
2707
4385
  findStartEvent,
4386
+ runCheckTaskSync,
2708
4387
  };
2709
4388
 
2710
4389
  // 直接运行时执行