kld-sdd 2.6.11 → 2.6.13

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 (87) hide show
  1. package/README.md +20 -0
  2. package/kld-sdd-guide.html +429 -159
  3. package/lib/hook-gate-core.js +41 -5
  4. package/lib/init.js +101 -95
  5. package/package.json +5 -2
  6. package/skywalk-sdd/index.cjs +2668 -415
  7. package/skywalk-sdd/metrics-v3.cjs +1153 -0
  8. package/skywalk-sdd/ontology/archive-package.cjs +114 -3
  9. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -80
  10. package/skywalk-sdd/ontology/working-artifacts.cjs +2 -1
  11. package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +39 -9
  12. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +87 -8
  13. package/templates/hooks/claude/hooks/sdd-prompt.cjs +25 -5
  14. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +39 -9
  15. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +87 -9
  16. package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +24 -3
  17. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +17 -16
  18. package/templates/skills/kld-sdd/opsx-apply/checklist.md +22 -22
  19. package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +12 -12
  20. package/templates/skills/kld-sdd/opsx-apply/reference.md +43 -42
  21. package/templates/skills/kld-sdd/opsx-apply/worktree-setup.md +3 -3
  22. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +58 -20
  23. package/templates/skills/kld-sdd/opsx-archive/checklist.md +6 -3
  24. package/templates/skills/kld-sdd/opsx-check/SKILL.md +30 -22
  25. package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -6
  26. package/templates/skills/kld-sdd/opsx-design/SKILL.md +5 -5
  27. package/templates/skills/kld-sdd/opsx-design/checklist.md +1 -1
  28. package/templates/skills/kld-sdd/opsx-design/reference.md +2 -2
  29. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +5 -5
  30. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +20 -12
  31. package/templates/skills/kld-sdd/opsx-propose/reference.md +31 -5
  32. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +3 -3
  33. package/templates/skills/kld-sdd/opsx-rules/reference.md +3 -1
  34. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +6 -6
  35. package/templates/skills/kld-sdd/opsx-spec/checklist.md +1 -1
  36. package/templates/skills/kld-sdd/opsx-spec/reference.md +2 -2
  37. package/templates/skills/kld-sdd/opsx-task/SKILL.md +14 -14
  38. package/templates/skills/kld-sdd/opsx-task/checklist.md +3 -3
  39. package/templates/skills/kld-sdd/opsx-task/reference.md +5 -5
  40. package/templates/skills/kld-sdd/opsx-test/SKILL.md +11 -9
  41. package/templates/skills/kld-sdd/{opsx-tdd-anti-patterns → tdd-anti-patterns}/SKILL.md +4 -4
  42. package/templates/skills/kld-sdd/{opsx-tdd-anti-patterns → tdd-anti-patterns}/reference.md +4 -4
  43. package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/SKILL.md +13 -13
  44. package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/checklist.md +5 -5
  45. package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/reference.md +12 -12
  46. package/templates/skills/kld-sdd/{opsx-tdd-metrics → tdd-metrics}/SKILL.md +3 -3
  47. package/templates/skills/kld-sdd/{opsx-tdd-metrics → tdd-metrics}/checklist.md +2 -2
  48. package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/SKILL.md +2 -2
  49. package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/parameterized-testing.md +1 -1
  50. package/templates/skills/kld-sdd/{opsx-tdd-review → tdd-review}/SKILL.md +5 -5
  51. package/templates/skills/kld-sdd/{opsx-tdd-review → tdd-review}/checklist.md +5 -5
  52. package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/SKILL.md +3 -3
  53. package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/non-tdd-modules.md +1 -1
  54. package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/refactor-checklist.md +1 -1
  55. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +19 -0
  56. package/templates/skills/kld-sdd/opsx-tdd-rules/rules/test-skeleton-telemetry.md +0 -19
  57. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/cause-effect-clarity.md +0 -0
  58. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/clean-test-data.md +0 -0
  59. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/existing-test-awareness.md +0 -0
  60. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/given-when-then.md +0 -0
  61. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/good-test-qualities.md +0 -0
  62. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/mock-boundary.md +0 -0
  63. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/naming-conventions.md +0 -0
  64. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/no-logic-in-tests.md +0 -0
  65. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/one-test-one-scenario.md +0 -0
  66. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/prefer-public-apis.md +0 -0
  67. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/test-behaviors-not-methods.md +0 -0
  68. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/argument-matching.md +0 -0
  69. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/controller-test-rules.md +0 -0
  70. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/domain-service-rules.md +0 -0
  71. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/java-test-template.md +0 -0
  72. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/json-serialization.md +0 -0
  73. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/logging-rules.md +0 -0
  74. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/post-generation/compilation-verification.md +0 -0
  75. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/post-generation/execution-verification.md +0 -0
  76. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/python/py-test-template.md +0 -0
  77. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/typescript/ts-test-template.md +0 -0
  78. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/controller-strategy.md +0 -0
  79. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/dag-generation-rules.md +0 -0
  80. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/des-step-annotation.md +0 -0
  81. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/exception-path-coverage.md +0 -0
  82. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/green-scope-declaration.md +0 -0
  83. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/green-yagni-fence.md +0 -0
  84. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/multi-validation-split.md +0 -0
  85. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/task-type-definitions.md +0 -0
  86. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/tdd-strategy-selection.md +0 -0
  87. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/test-execution-gate.md +0 -0
@@ -16,6 +16,7 @@ const path = require('path');
16
16
  const { resolveProjectArg } = require('./ontology/resolve-spec-root.cjs');
17
17
  const crypto = require('crypto');
18
18
  const { execFileSync } = require('child_process');
19
+ const metricsV3 = require('./metrics-v3.cjs');
19
20
 
20
21
  const SCHEMA_VERSION = 2;
21
22
 
@@ -53,6 +54,8 @@ function getStateDir(projectRoot) {
53
54
  // SDD 标准阶段顺序
54
55
  const STAGE_ORDER = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'test', 'archive', 'explore'];
55
56
  const CORE_STAGES = ['propose', 'spec', 'design', 'apply', 'test'];
57
+ const MEASUREMENT_STAGES = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'archive'];
58
+ const AUXILIARY_STAGES = ['test', 'explore'];
56
59
 
57
60
  // ── 工具函数 ──────────────────────────────────────────
58
61
  function ensureDir(dirPath) {
@@ -149,6 +152,37 @@ function safeChangeName(name) {
149
152
  return name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '') || 'general';
150
153
  }
151
154
 
155
+ function atomicWriteText(filePath, content) {
156
+ ensureDir(path.dirname(filePath));
157
+ const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
158
+ let fd;
159
+ try {
160
+ fd = fs.openSync(tempPath, 'w');
161
+ fs.writeFileSync(fd, content, 'utf8');
162
+ fs.fsyncSync(fd);
163
+ fs.closeSync(fd);
164
+ fd = null;
165
+ fs.renameSync(tempPath, filePath);
166
+ } catch (error) {
167
+ if (fd != null) {
168
+ try { fs.closeSync(fd); } catch {}
169
+ }
170
+ try { fs.rmSync(tempPath, { force: true }); } catch {}
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ function requireValidChangeName(name) {
176
+ if (typeof name === 'string'
177
+ && (path.isAbsolute(name) || /[\\/]/.test(name) || name === '.' || name === '..')) {
178
+ fail('E_PATH_OUTSIDE_CHANGE: change 路径不能超出 openspec/changes');
179
+ }
180
+ if (typeof name !== 'string' || !name || safeChangeName(name) !== name) {
181
+ fail(`E_CHANGE_NAME_INVALID: change 名称仅允许字母、数字、下划线和连字符,且不能以连字符开头或结尾`);
182
+ }
183
+ return name;
184
+ }
185
+
152
186
  function getActiveStageKey(criteria) {
153
187
  if (criteria.session_id) {
154
188
  return `session-${safeChangeName(criteria.session_id)}`;
@@ -188,7 +222,7 @@ function findActiveStage(projectRoot, criteria) {
188
222
  const exactFile = getActiveStageFile(projectRoot, criteria);
189
223
  if (fs.existsSync(exactFile)) {
190
224
  const exact = readStateFile(exactFile);
191
- if (exact) return exact;
225
+ if (exact && activeStageMatches(exact, criteria)) return exact;
192
226
  }
193
227
 
194
228
  const stateDir = getStateDir(projectRoot);
@@ -246,6 +280,16 @@ function findActiveStage(projectRoot, criteria) {
246
280
  return null;
247
281
  }
248
282
 
283
+ function activeStageMatches(event, criteria = {}) {
284
+ if (!event) return false;
285
+ if (criteria.session_id && event.session_id !== criteria.session_id) return false;
286
+ if (criteria.change && event.change !== criteria.change) return false;
287
+ if (criteria.command && event.command !== criteria.command) return false;
288
+ if (criteria.agent_type && event.agent_type !== criteria.agent_type) return false;
289
+ if (criteria.capability && event.capability !== criteria.capability) return false;
290
+ return true;
291
+ }
292
+
249
293
  function clearActiveStage(projectRoot, event) {
250
294
  const files = [
251
295
  getActiveStageFile(projectRoot, event),
@@ -301,7 +345,28 @@ function nextAvailableDir(baseDir, preferredName) {
301
345
  }
302
346
 
303
347
  function getChangeDir(projectRoot, changeName) {
304
- return path.join(projectRoot, 'openspec', 'changes', changeName);
348
+ const changesRoot = path.resolve(projectRoot, 'openspec', 'changes');
349
+ const changeDir = path.resolve(changesRoot, changeName);
350
+ if (changeDir === changesRoot || !changeDir.startsWith(`${changesRoot}${path.sep}`)) {
351
+ throw new Error('E_PATH_OUTSIDE_CHANGE: change 路径超出 openspec/changes');
352
+ }
353
+ for (const candidate of [
354
+ path.resolve(projectRoot, 'openspec'),
355
+ changesRoot,
356
+ changeDir,
357
+ ]) {
358
+ if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
359
+ throw new Error(`E_PATH_OUTSIDE_CHANGE: change 路径不能经过符号链接或 junction: ${candidate}`);
360
+ }
361
+ }
362
+ if (fs.existsSync(changesRoot) && fs.existsSync(changeDir)) {
363
+ const canonicalRoot = fs.realpathSync.native(changesRoot);
364
+ const canonicalChange = fs.realpathSync.native(changeDir);
365
+ if (!canonicalChange.startsWith(`${canonicalRoot}${path.sep}`)) {
366
+ throw new Error('E_PATH_OUTSIDE_CHANGE: change 的真实路径超出 openspec/changes');
367
+ }
368
+ }
369
+ return changeDir;
305
370
  }
306
371
 
307
372
  function getArchiveRoot(projectRoot) {
@@ -316,36 +381,142 @@ function discoverTaskFiles(projectRoot, changeName) {
316
381
  }).sort();
317
382
  }
318
383
 
384
+ function checklistEntriesOutsideCodeFences(lines) {
385
+ const entries = [];
386
+ let inCodeFence = false;
387
+ lines.forEach((line, index) => {
388
+ if (/^\s*```/.test(line)) {
389
+ inCodeFence = !inCodeFence;
390
+ return;
391
+ }
392
+ if (inCodeFence) return;
393
+ const match = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)$/);
394
+ if (!match || match[2].trim() === '无') return;
395
+ entries.push({
396
+ checked: /[xX]/.test(match[1]),
397
+ line: index + 1,
398
+ text: match[2].trim(),
399
+ raw: line,
400
+ });
401
+ });
402
+ return entries;
403
+ }
404
+
405
+ function checkboxTokensOutsideCodeFences(lines) {
406
+ const entries = [];
407
+ let inCodeFence = false;
408
+ lines.forEach((line, index) => {
409
+ if (/^\s*```/.test(line)) {
410
+ inCodeFence = !inCodeFence;
411
+ return;
412
+ }
413
+ if (inCodeFence) return;
414
+ if (/\[[xX]\]/.test(line)) {
415
+ entries.push({ checked: true, line: index + 1, raw: line });
416
+ return;
417
+ }
418
+ if (/\[\s\]/.test(line)) {
419
+ const afterCheckbox = line.replace(/^\s*-\s*\[\s\]\s*/, '').trim();
420
+ if (afterCheckbox !== '无') {
421
+ entries.push({ checked: false, line: index + 1, raw: line });
422
+ }
423
+ }
424
+ });
425
+ return entries;
426
+ }
427
+
319
428
  function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
320
429
  const normalizedRoot = normalizeProjectRoot(projectRoot);
321
430
  const files = [];
322
431
  let completed = 0;
323
432
  let incomplete = 0;
324
433
  const incompleteItems = [];
434
+ const primaryTasks = [];
435
+ const acceptanceItems = [];
436
+ let structuredTaskCount = 0;
325
437
 
326
438
  for (const filePath of taskFiles) {
327
439
  const relPath = path.relative(normalizedRoot, filePath).replace(/\\/g, '/');
328
440
  const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
329
441
  let fileCompleted = 0;
330
442
  let fileIncomplete = 0;
443
+ let currentTask = null;
444
+ let inCodeFence = false;
445
+
446
+ const finishTask = () => {
447
+ if (!currentTask) return;
448
+ if (currentTask.statuses.length > 0) structuredTaskCount += 1;
449
+ const completedStatus = currentTask.statuses.length === 1 && currentTask.statuses[0].checked;
450
+ primaryTasks.push({
451
+ task_id: currentTask.taskId,
452
+ file: relPath,
453
+ line: currentTask.line,
454
+ completed: completedStatus,
455
+ ambiguous: currentTask.statuses.length !== 1,
456
+ status_count: currentTask.statuses.length,
457
+ });
458
+ currentTask = null;
459
+ };
331
460
 
332
- lines.forEach((line, index) => {
333
- if (/\[[xX]\]/.test(line)) {
461
+ const checkboxTokens = checkboxTokensOutsideCodeFences(lines);
462
+ for (const entry of checkboxTokens) {
463
+ if (entry.checked) {
334
464
  completed += 1;
335
465
  fileCompleted += 1;
336
- } else if (/\[\s\]/.test(line)) {
337
- // P2-1: `- [ ] 无` 是占位符(表示"无文档更新/无风险/无遗留"),不计入 incomplete
338
- const afterCheckbox = line.replace(/^\s*-\s*\[\s\]\s*/, '').trim();
339
- if (afterCheckbox === '无') return;
466
+ } else {
340
467
  incomplete += 1;
341
468
  fileIncomplete += 1;
342
469
  incompleteItems.push({
343
470
  file: relPath,
471
+ line: entry.line,
472
+ text: entry.raw.trim(),
473
+ });
474
+ }
475
+ }
476
+
477
+ lines.forEach((line, index) => {
478
+ if (/^\s*```/.test(line)) {
479
+ inCodeFence = !inCodeFence;
480
+ return;
481
+ }
482
+ if (inCodeFence) return;
483
+ const taskHeader = line.match(/^###\s*\[([^\]]+)\]/);
484
+ if (taskHeader) {
485
+ finishTask();
486
+ currentTask = {
487
+ taskId: taskHeader[1].trim(),
488
+ line: index + 1,
489
+ statuses: [],
490
+ };
491
+ return;
492
+ }
493
+ if (/^###\s/.test(line)) {
494
+ finishTask();
495
+ return;
496
+ }
497
+ if (!currentTask) return;
498
+
499
+ const statusMatch = line.match(/^\s*(?:-\s*)?\*\*状态\*\*\s*[::]\s*\[([ xX])\]\s*(.*)$/);
500
+ if (statusMatch) {
501
+ currentTask.statuses.push({
502
+ checked: /[xX]/.test(statusMatch[1]),
344
503
  line: index + 1,
345
504
  text: line.trim(),
346
505
  });
506
+ return;
507
+ }
508
+ const acceptanceMatch = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)$/);
509
+ if (acceptanceMatch && acceptanceMatch[2].trim() !== '无') {
510
+ acceptanceItems.push({
511
+ task_id: currentTask.taskId,
512
+ file: relPath,
513
+ line: index + 1,
514
+ confirmed: /[xX]/.test(acceptanceMatch[1]),
515
+ text: acceptanceMatch[2].trim(),
516
+ });
347
517
  }
348
518
  });
519
+ finishTask();
349
520
 
350
521
  files.push({
351
522
  path: relPath,
@@ -355,6 +526,37 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
355
526
  });
356
527
  }
357
528
 
529
+ const taskIdCounts = new Map();
530
+ for (const task of primaryTasks) {
531
+ taskIdCounts.set(task.task_id, (taskIdCounts.get(task.task_id) || 0) + 1);
532
+ }
533
+ for (const task of primaryTasks) {
534
+ if ((taskIdCounts.get(task.task_id) || 0) > 1) {
535
+ task.completed = false;
536
+ task.ambiguous = true;
537
+ task.ambiguity_reason = 'duplicate-task-id';
538
+ }
539
+ }
540
+
541
+ const useLegacyFallback = structuredTaskCount === 0;
542
+ const normalizedPrimaryTasks = useLegacyFallback
543
+ ? files.flatMap((file) => {
544
+ const sourceLines = fs.readFileSync(path.resolve(normalizedRoot, file.path), 'utf8').split(/\r?\n/);
545
+ return checklistEntriesOutsideCodeFences(sourceLines).map(entry => ({
546
+ task_id: null,
547
+ file: file.path,
548
+ line: entry.line,
549
+ completed: entry.checked,
550
+ ambiguous: false,
551
+ status_count: 1,
552
+ }));
553
+ })
554
+ : primaryTasks;
555
+ const primaryCompleted = normalizedPrimaryTasks.filter(task => task.completed).length;
556
+ const primaryIncomplete = normalizedPrimaryTasks.length - primaryCompleted;
557
+ const acceptanceConfirmed = acceptanceItems.filter(item => item.confirmed).length;
558
+ const acceptanceUnconfirmed = acceptanceItems.length - acceptanceConfirmed;
559
+
358
560
  return {
359
561
  change: changeName,
360
562
  task_files: files,
@@ -363,6 +565,23 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
363
565
  total: completed + incomplete,
364
566
  has_incomplete: incomplete > 0,
365
567
  incomplete_items: incompleteItems,
568
+ primary_tasks: {
569
+ completed: primaryCompleted,
570
+ incomplete: primaryIncomplete,
571
+ total: normalizedPrimaryTasks.length,
572
+ has_incomplete: primaryIncomplete > 0,
573
+ status: primaryIncomplete > 0 ? 'incomplete' : 'complete',
574
+ ambiguous: normalizedPrimaryTasks.filter(task => task.ambiguous).length,
575
+ parser: useLegacyFallback ? 'legacy-fallback' : 'structured-status',
576
+ tasks: normalizedPrimaryTasks,
577
+ },
578
+ acceptance_evidence: {
579
+ confirmed: acceptanceConfirmed,
580
+ unconfirmed: acceptanceUnconfirmed,
581
+ total: acceptanceItems.length,
582
+ status: acceptanceUnconfirmed > 0 ? 'pending' : 'confirmed',
583
+ items: acceptanceItems,
584
+ },
366
585
  };
367
586
  }
368
587
 
@@ -395,25 +614,60 @@ function scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir) {
395
614
 
396
615
  // ── check-task 子命令辅助函数 ─────────────────────────────
397
616
 
398
- /** tasks.md 文件中定位并勾选指定 task_id 的 checkbox */
399
- function checkTaskInFile(filePath, taskId, dryRun = false) {
617
+ function inspectTaskInFile(filePath, taskId) {
400
618
  const content = fs.readFileSync(filePath, 'utf8');
401
619
  const eol = content.includes('\r\n') ? '\r\n' : '\n';
402
620
  const lines = content.split(/\r?\n/);
403
621
  const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
404
622
  const taskHeaderRe = new RegExp(`^###\\s*\\[?${escapedTaskId}\\]?(?:\\s|$|\\])`);
405
623
  let inTaskSection = false;
406
- let changed = false;
407
- const newLines = lines.map((line) => {
624
+ let inCodeFence = false;
625
+ const headerIndexes = [];
626
+ const statusIndexes = [];
627
+ lines.forEach((line, index) => {
628
+ if (/^\s*```/.test(line)) {
629
+ inCodeFence = !inCodeFence;
630
+ return;
631
+ }
632
+ if (inCodeFence) return;
408
633
  if (/^###\s/.test(line)) {
409
634
  inTaskSection = taskHeaderRe.test(line);
635
+ if (inTaskSection) headerIndexes.push(index);
410
636
  }
411
- if (inTaskSection && (/^\s*-\s*\[\s\]/.test(line) || /^\s*\*\*状态\*\*:\s*\[\s\]/.test(line))) {
412
- changed = true;
413
- return line.replace(/\[ \]/, '[x]');
637
+ if (inTaskSection && /^\s*(?:-\s*)?\*\*状态\*\*\s*[::]\s*\[[ xX]\]/.test(line)) {
638
+ statusIndexes.push(index);
414
639
  }
415
- return line;
416
640
  });
641
+ return { content, eol, lines, headerIndexes, statusIndexes };
642
+ }
643
+
644
+ /** 在 tasks.md 文件中定位并勾选指定 task_id 的唯一状态行 */
645
+ function checkTaskInFile(filePath, taskId, dryRun = false) {
646
+ const inspection = inspectTaskInFile(filePath, taskId);
647
+ const { content, eol, lines, headerIndexes, statusIndexes } = inspection;
648
+ if (headerIndexes.length !== 1) {
649
+ return {
650
+ changed: false,
651
+ linesChanged: 0,
652
+ reason: headerIndexes.length === 0 ? 'task-id-not-found' : 'task-id-ambiguous',
653
+ };
654
+ }
655
+ if (statusIndexes.length !== 1) {
656
+ return {
657
+ changed: false,
658
+ linesChanged: 0,
659
+ reason: statusIndexes.length === 0 ? 'status-line-missing' : 'status-line-ambiguous',
660
+ };
661
+ }
662
+ const statusIndex = statusIndexes[0];
663
+ if (/\[[xX]\]/.test(lines[statusIndex])) {
664
+ return { changed: false, linesChanged: 0, reason: 'already-completed' };
665
+ }
666
+ const newLines = lines.slice();
667
+ newLines[statusIndex] = newLines[statusIndex]
668
+ .replace(/\[ \]/, '[x]')
669
+ .replace(/(\[x\])(?:\s+未完成)?\s*$/, '$1 已完成');
670
+ const changed = true;
417
671
  if (changed && !dryRun) {
418
672
  fs.writeFileSync(filePath, newLines.join(eol), 'utf8');
419
673
  }
@@ -427,16 +681,42 @@ function runCheckTaskSync(projectRoot, changeName, taskId, dryRun = false) {
427
681
  if (taskFiles.length === 0) {
428
682
  throw new Error(`未找到 change ${changeName} 的 tasks.md`);
429
683
  }
430
- let totalChanged = 0;
431
- const changedFiles = [];
432
- for (const filePath of taskFiles) {
433
- const result = checkTaskInFile(filePath, taskId, dryRun);
434
- if (result.changed) {
435
- totalChanged += 1;
436
- changedFiles.push(path.relative(normalizedRoot, filePath).replace(/\\/g, '/'));
437
- }
438
- }
439
- return { changed: totalChanged > 0, changed_files: changedFiles, task_files_checked: taskFiles.length };
684
+ const inspections = taskFiles.map(filePath => ({
685
+ filePath,
686
+ inspection: inspectTaskInFile(filePath, taskId),
687
+ }));
688
+ const headerCount = inspections.reduce(
689
+ (total, item) => total + item.inspection.headerIndexes.length,
690
+ 0,
691
+ );
692
+ if (headerCount === 0) {
693
+ const error = new Error(`TASK_ID_NOT_FOUND: 未找到任务 "${taskId}"`);
694
+ error.code = 'TASK_ID_NOT_FOUND';
695
+ throw error;
696
+ }
697
+ if (headerCount !== 1) {
698
+ const error = new Error(`TASK_ID_AMBIGUOUS: 任务 "${taskId}" 在 change 中出现 ${headerCount} 次`);
699
+ error.code = 'TASK_ID_AMBIGUOUS';
700
+ throw error;
701
+ }
702
+ const target = inspections.find(item => item.inspection.headerIndexes.length === 1);
703
+ if (target.inspection.statusIndexes.length !== 1) {
704
+ const code = target.inspection.statusIndexes.length === 0
705
+ ? 'TASK_STATUS_MISSING'
706
+ : 'TASK_STATUS_AMBIGUOUS';
707
+ const error = new Error(`${code}: 任务 "${taskId}" 必须恰好有一条状态行`);
708
+ error.code = code;
709
+ throw error;
710
+ }
711
+ const result = checkTaskInFile(target.filePath, taskId, dryRun);
712
+ return {
713
+ changed: result.changed,
714
+ changed_files: result.changed
715
+ ? [path.relative(normalizedRoot, target.filePath).replace(/\\/g, '/')]
716
+ : [],
717
+ task_files_checked: taskFiles.length,
718
+ reason: result.reason,
719
+ };
440
720
  }
441
721
 
442
722
  /** check-task 子命令 CLI */
@@ -494,13 +774,146 @@ function findArchivedChangeDir(projectRoot, changeName) {
494
774
  const archiveRoot = getArchiveRoot(projectRoot);
495
775
  if (!fs.existsSync(archiveRoot)) return null;
496
776
 
497
- const prefix = `-${changeName}`;
777
+ const escapedChangeName = String(changeName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
778
+ const exactArchiveName = new RegExp(`^(\\d{4}-\\d{2}-\\d{2})-${escapedChangeName}(?:-(\\d+))?$`);
498
779
  const candidates = fs.readdirSync(archiveRoot, { withFileTypes: true })
499
- .filter(entry => entry.isDirectory() && entry.name.includes(prefix))
500
- .map(entry => path.join(archiveRoot, entry.name))
501
- .sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
780
+ .filter(entry => entry.isDirectory())
781
+ .map(entry => {
782
+ const match = entry.name.match(exactArchiveName);
783
+ if (!match) return null;
784
+ const archivePath = path.join(archiveRoot, entry.name);
785
+ return {
786
+ path: archivePath,
787
+ date: match[1],
788
+ suffix: match[2] ? Number(match[2]) : 1,
789
+ mtimeMs: fs.statSync(archivePath).mtimeMs,
790
+ };
791
+ })
792
+ .filter(Boolean)
793
+ .sort((a, b) => (
794
+ b.date.localeCompare(a.date)
795
+ || b.suffix - a.suffix
796
+ || b.mtimeMs - a.mtimeMs
797
+ ));
798
+
799
+ return candidates[0]?.path || null;
800
+ }
801
+
802
+ // V3: 统一解析变更文档根目录(active 优先,找不到再找 archive),供场景计数/变更类型读取复用
803
+ function resolveChangeDocumentRoot(projectRoot, changeName) {
804
+ const activeDir = getChangeDir(projectRoot, changeName);
805
+ if (fs.existsSync(activeDir)) {
806
+ return { root: activeDir, source: 'active' };
807
+ }
808
+ const archiveDir = findArchivedChangeDir(projectRoot, changeName);
809
+ if (archiveDir) {
810
+ return { root: archiveDir, source: 'archive' };
811
+ }
812
+ return { root: null, source: null };
813
+ }
814
+
815
+ // V3: 收集一个变更目录下的 spec.md 文件(优先 specs/**/spec.md,Simple 模式 fallback 根 spec.md)
816
+ function collectSpecFiles(changeRoot) {
817
+ if (!changeRoot || !fs.existsSync(changeRoot)) return [];
818
+ const specsDir = path.join(changeRoot, 'specs');
819
+ if (fs.existsSync(specsDir) && fs.statSync(specsDir).isDirectory()) {
820
+ return walkFiles(specsDir, filePath => path.basename(filePath).toLowerCase() === 'spec.md').sort();
821
+ }
822
+ const rootSpec = path.join(changeRoot, 'spec.md');
823
+ if (fs.existsSync(rootSpec)) return [rootSpec];
824
+ return [];
825
+ }
826
+
827
+ // V3 E1: 统计变更规约文档中的场景数(##### 场景/Scenario:标题计数),按绝对路径去重
828
+ function countSpecScenarios(projectRoot, changeName) {
829
+ const { root, source } = resolveChangeDocumentRoot(projectRoot, changeName);
830
+ if (!root) {
831
+ return { scenario_count: 0, files: [], source: null, root: null, availability_reason: 'change-not-found' };
832
+ }
833
+ const specFiles = collectSpecFiles(root);
834
+ if (specFiles.length === 0) {
835
+ return {
836
+ scenario_count: 0,
837
+ files: [],
838
+ source,
839
+ root: toProjectRelative(projectRoot, root),
840
+ availability_reason: 'no-spec-files',
841
+ };
842
+ }
843
+ const seen = new Set();
844
+ const files = [];
845
+ let total = 0;
846
+ for (const filePath of specFiles) {
847
+ const abs = path.resolve(filePath);
848
+ if (seen.has(abs)) continue;
849
+ seen.add(abs);
850
+ let text = '';
851
+ try { text = fs.readFileSync(abs, 'utf8'); } catch { text = ''; }
852
+ const count = metricsV3.countScenariosInMarkdown(text);
853
+ total += count;
854
+ files.push({ path: toProjectRelative(projectRoot, abs), count });
855
+ }
856
+ return { scenario_count: total, files, source, root: toProjectRelative(projectRoot, root) };
857
+ }
858
+
859
+ // 极简 yaml-like frontmatter 解析:仅支持 --- 围栏内的单行 key: value(够用,不引入外部 yaml 依赖)
860
+ function parseSimpleFrontmatter(text) {
861
+ if (typeof text !== 'string' || !text) return {};
862
+ const normalized = text.replace(/\r\n/g, '\n');
863
+ const match = /^---\n([\s\S]*?)\n---/.exec(normalized);
864
+ if (!match) return {};
865
+ const result = {};
866
+ for (const line of match[1].split('\n')) {
867
+ const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line.trim());
868
+ if (!kv) continue;
869
+ let value = kv[2].trim();
870
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
871
+ value = value.slice(1, -1);
872
+ }
873
+ result[kv[1]] = value;
874
+ }
875
+ return result;
876
+ }
877
+
878
+ // V3: 从 proposal.md frontmatter 读取 change-type(active 优先,缺失/非法/找不到 → unknown)
879
+ function readChangeType(projectRoot, changeName) {
880
+ const { root } = resolveChangeDocumentRoot(projectRoot, changeName);
881
+ if (!root) return 'unknown';
882
+ const proposalPath = path.join(root, 'proposal.md');
883
+ if (!fs.existsSync(proposalPath)) return 'unknown';
884
+ let text = '';
885
+ try { text = fs.readFileSync(proposalPath, 'utf8'); } catch { return 'unknown'; }
886
+ const frontmatter = parseSimpleFrontmatter(text);
887
+ return metricsV3.validateChangeType(frontmatter['change-type']);
888
+ }
502
889
 
503
- return candidates[0] || null;
890
+ // V3: 组装喂给 metricsV3.computeV3ChangeMetrics 的 context;executionSummary 提供阶段耗时兜底
891
+ function buildV3ChangeContext(events, executionSummary, options = {}) {
892
+ const summary = executionSummary?.summary || {};
893
+ return {
894
+ change_name: options.change_name || null,
895
+ change_type: options.change_type || 'unknown',
896
+ total_stage_duration_ms: options.total_stage_duration_ms ?? summary.total_stage_duration_ms ?? null,
897
+ canonical_stage_duration_ms: options.canonical_stage_duration_ms ?? summary.effective_stage_duration_ms ?? null,
898
+ rework_stage_duration_ms: options.rework_stage_duration_ms ?? summary.rework_stage_duration_ms ?? null,
899
+ scenario_count: options.scenario_count ?? 0,
900
+ scenario_sources: options.scenario_sources || [],
901
+ check_results: options.check_results || null,
902
+ check_meta: options.check_meta || {},
903
+ q3: options.q3 || undefined,
904
+ coding_duration_ms: options.coding_duration_ms ?? 0,
905
+ spec_duration_ms: options.spec_duration_ms ?? 0,
906
+ rework_coding_duration_ms: options.rework_coding_duration_ms ?? 0,
907
+ rework_spec_duration_ms: options.rework_spec_duration_ms ?? 0,
908
+ p4_quality_gate_enforcement_rate: options.p4_quality_gate_enforcement_rate ?? null,
909
+ p_c_stage_closure_rate: options.p_c_stage_closure_rate ?? null,
910
+ p_h_telemetry_health_score: options.p_h_telemetry_health_score ?? null,
911
+ rework_summary: options.rework_summary || {},
912
+ q1_spec_conformance: options.q1_spec_conformance ?? null,
913
+ q1_source: options.q1_source || null,
914
+ q4_tdd_coverage: options.q4_tdd_coverage ?? null,
915
+ q4_source: options.q4_source || null,
916
+ };
504
917
  }
505
918
 
506
919
  function syncArchivedSpecs(projectRoot, archiveDir, changeName) {
@@ -601,43 +1014,85 @@ function assertConfirmedArchive(projectRoot, changeName, archiveDir, options = {
601
1014
  }
602
1015
 
603
1016
  function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}) {
604
- const copiedSpecs = Array.isArray(options.copiedSpecs)
605
- ? options.copiedSpecs
606
- : syncArchivedSpecs(projectRoot, archiveDir, changeName);
607
1017
  const committedArchiveDir = options.archivePath || archiveDir;
608
- // U4: 归档时拷 events jsonl 进 archive/evidence/events/,报告可从归档目录重建(源数据与产物不再分离)
609
- const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(changeName));
610
- let evidenceEventsPath = null;
611
- if (fs.existsSync(eventsSrcDir)) {
612
- const eventsDstDir = path.join(archiveDir, 'evidence', 'events');
613
- copyDirSync(eventsSrcDir, eventsDstDir);
614
- evidenceEventsPath = toProjectRelative(
1018
+ const prepare = () => {
1019
+ const copiedSpecs = Array.isArray(options.copiedSpecs)
1020
+ ? options.copiedSpecs
1021
+ : syncArchivedSpecs(projectRoot, archiveDir, changeName);
1022
+ // U4: 归档时拷 events jsonl 进 archive/evidence/events/,报告可从归档目录重建(源数据与产物不再分离)
1023
+ const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(changeName));
1024
+ let evidenceEventsPath = null;
1025
+ if (fs.existsSync(eventsSrcDir)) {
1026
+ const eventsDstDir = path.join(archiveDir, 'evidence', 'events');
1027
+ copyDirSync(eventsSrcDir, eventsDstDir);
1028
+ evidenceEventsPath = toProjectRelative(
1029
+ projectRoot,
1030
+ path.join(committedArchiveDir, 'evidence', 'events'),
1031
+ );
1032
+ }
1033
+ return {
1034
+ archiveDir,
1035
+ archivePath: committedArchiveDir,
1036
+ packagePath: Object.prototype.hasOwnProperty.call(options, 'packagePath')
1037
+ ? options.packagePath
1038
+ : `${committedArchiveDir}.zip`,
1039
+ archivedAt: options.archivedAt,
1040
+ reason: options.reason || '变更已完成实施',
1041
+ method: options.method || 'skywalk-full-spec-archive',
1042
+ sourcePath: `openspec/changes/${changeName}`,
1043
+ copiedSpecs,
1044
+ evidenceEventsPath,
1045
+ };
1046
+ };
1047
+ const {
1048
+ finalizeArchivePackageAfterReports,
1049
+ materializeArchivePackage,
1050
+ } = require('./ontology/archive-package.cjs');
1051
+ const produced = options.finalizeAfterReports
1052
+ ? finalizeArchivePackageAfterReports({
615
1053
  projectRoot,
616
- path.join(committedArchiveDir, 'evidence', 'events'),
617
- );
618
- }
619
- const { materializeArchivePackage } = require('./ontology/archive-package.cjs');
620
- const produced = materializeArchivePackage({
621
- projectRoot,
622
- changeName,
623
- archiveDir,
624
- archivePath: committedArchiveDir,
625
- packagePath: Object.prototype.hasOwnProperty.call(options, 'packagePath')
626
- ? options.packagePath
627
- : `${committedArchiveDir}.zip`,
628
- archivedAt: options.archivedAt,
629
- reason: options.reason || '变更已完成实施',
630
- method: options.method || 'skywalk-full-spec-archive',
631
- sourcePath: `openspec/changes/${changeName}`,
632
- copiedSpecs,
633
- evidenceEventsPath,
634
- });
1054
+ changeName,
1055
+ runIdentity: options.runIdentity,
1056
+ lockOptions: options.lockOptions,
1057
+ prepare,
1058
+ })
1059
+ : materializeArchivePackage({
1060
+ projectRoot,
1061
+ changeName,
1062
+ ...prepare(),
1063
+ });
635
1064
  return {
636
1065
  ...produced,
637
- copied_specs: copiedSpecs,
1066
+ copied_specs: produced.manifest.copied_specs || [],
638
1067
  };
639
1068
  }
640
1069
 
1070
+ function normalizeKnownReportExtension(reportPath, expectedExtension) {
1071
+ const currentExtension = path.extname(reportPath).toLowerCase();
1072
+ if (['.md', '.html', '.json'].includes(currentExtension) && currentExtension !== expectedExtension) {
1073
+ return reportPath.slice(0, -currentExtension.length) + expectedExtension;
1074
+ }
1075
+ return reportPath;
1076
+ }
1077
+
1078
+ function taskCompletionArchiveReason(taskCompletion, fallbackReason) {
1079
+ const primary = taskCompletion?.primary_tasks;
1080
+ if (primary?.parser === 'structured-status') {
1081
+ if (primary.has_incomplete) {
1082
+ return `尚有 ${primary.incomplete} 个主任务未完成`;
1083
+ }
1084
+ const pendingEvidence = taskCompletion?.acceptance_evidence?.unconfirmed || 0;
1085
+ if (pendingEvidence > 0) {
1086
+ return `任务已完成;仍有 ${pendingEvidence} 项验收证据待确认`;
1087
+ }
1088
+ return fallbackReason;
1089
+ }
1090
+ if (taskCompletion?.has_incomplete) {
1091
+ return `部分完成(${taskCompletion.incomplete} 项验收未勾选)`;
1092
+ }
1093
+ return fallbackReason;
1094
+ }
1095
+
641
1096
  function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, options = {}) {
642
1097
  const normalizedRoot = normalizeProjectRoot(projectRoot);
643
1098
  const activeChangeDir = getChangeDir(normalizedRoot, changeName);
@@ -666,14 +1121,15 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
666
1121
  assertConfirmedArchive(normalizedRoot, changeName, archiveDir);
667
1122
 
668
1123
  // 显式 reportOutput 优先;否则默认落归档后 archive 目录的 reports/ 子目录
669
- const reportPath = options.reportOutput
1124
+ const requestedReportPath = options.reportOutput
670
1125
  ? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
671
1126
  : path.join(archiveDir, 'reports', `${safeChangeName(changeName)}-report.md`);
1127
+ const reportPath = normalizeKnownReportExtension(requestedReportPath, '.md');
672
1128
 
673
1129
  // P2-2: task_completion 提前计算,has_incomplete 时调整归档原因
674
1130
  const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
675
- if (taskCompletion?.has_incomplete && !details.archive_result?.reason) {
676
- archiveReason = `部分完成(${taskCompletion.incomplete} 项验收未勾选)`;
1131
+ if (!details.archive_result?.reason) {
1132
+ archiveReason = taskCompletionArchiveReason(taskCompletion, archiveReason);
677
1133
  }
678
1134
  const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
679
1135
  reason: archiveReason,
@@ -692,9 +1148,6 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
692
1148
  created_at: manifestInfo.manifest.created_at,
693
1149
  archive_path: manifestInfo.manifest.archive_path,
694
1150
  report_path: toProjectRelative(normalizedRoot, reportPath),
695
- // report_html_path 为预期路径:由 cmdEnd 落盘阶段写入(try/catch 容错,失败时不阻塞 md 主产物)。
696
- // 此处无条件派生,html 实际落盘失败时该路径可能不存在;语义为"预期路径",消费者不应假定文件已存在。
697
- report_html_path: toProjectRelative(normalizedRoot, reportPath.replace(/\.md$/i, '.html')),
698
1151
  manifest_path: toProjectRelative(normalizedRoot, manifestInfo.manifest_path),
699
1152
  canonical_facts_path: toProjectRelative(normalizedRoot, manifestInfo.canonical_facts_path),
700
1153
  conversion_report_path: toProjectRelative(normalizedRoot, manifestInfo.conversion_report_path),
@@ -772,9 +1225,7 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
772
1225
 
773
1226
  const tcForReason = scanTaskCompletionForArchiveDir(normalizedRoot, changeName, stagingDir);
774
1227
  let archiveReason = options.reason || '变更已完成实施';
775
- if (tcForReason?.has_incomplete) {
776
- archiveReason = `部分完成(${tcForReason.incomplete} 项验收未勾选)`;
777
- }
1228
+ archiveReason = taskCompletionArchiveReason(tcForReason, archiveReason);
778
1229
  specTransaction = prepareArchivedSpecSync(
779
1230
  normalizedRoot,
780
1231
  stagingDir,
@@ -852,8 +1303,85 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
852
1303
  }
853
1304
  }
854
1305
 
855
- /** 追加一行 JSONL 到事件文件(写入失败时抛出异常) */
856
- function appendEvent(dataDir, changeName, event) {
1306
+ const EVENT_LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
1307
+
1308
+ function isProcessAlive(pid) {
1309
+ if (!Number.isInteger(pid) || pid <= 0) return false;
1310
+ try {
1311
+ process.kill(pid, 0);
1312
+ return true;
1313
+ } catch (error) {
1314
+ return error?.code === 'EPERM';
1315
+ }
1316
+ }
1317
+
1318
+ function acquireEventWriteLock(dataDir, changeName, options = {}) {
1319
+ const locksDir = path.join(dataDir, 'events', '.locks');
1320
+ ensureDir(locksDir);
1321
+ const lockPath = path.join(locksDir, `${safeChangeName(changeName)}.lock`);
1322
+ const timeoutMs = Math.max(1000, Number(options.timeoutMs || 10000));
1323
+ const staleMs = Math.max(timeoutMs, Number(options.staleMs || 5 * 60 * 1000));
1324
+ const deadline = Date.now() + timeoutMs;
1325
+
1326
+ while (Date.now() <= deadline) {
1327
+ const token = crypto.randomUUID();
1328
+ const payload = {
1329
+ token,
1330
+ pid: process.pid,
1331
+ created_at: nowISO(),
1332
+ change: safeChangeName(changeName),
1333
+ };
1334
+ let descriptor;
1335
+ try {
1336
+ descriptor = fs.openSync(lockPath, 'wx');
1337
+ fs.writeFileSync(descriptor, JSON.stringify(payload), 'utf8');
1338
+ fs.closeSync(descriptor);
1339
+ return { ...payload, path: lockPath };
1340
+ } catch (error) {
1341
+ if (descriptor !== undefined) fs.closeSync(descriptor);
1342
+ if (error?.code !== 'EEXIST') throw error;
1343
+ let existing = null;
1344
+ try {
1345
+ existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
1346
+ } catch {}
1347
+ const createdAt = Date.parse(existing?.created_at || '');
1348
+ const stale = !Number.isFinite(createdAt)
1349
+ || Date.now() - createdAt > staleMs
1350
+ || !isProcessAlive(Number(existing?.pid));
1351
+ if (stale) {
1352
+ try { fs.rmSync(lockPath, { force: true }); } catch {}
1353
+ continue;
1354
+ }
1355
+ Atomics.wait(EVENT_LOCK_WAIT_BUFFER, 0, 0, 10);
1356
+ }
1357
+ }
1358
+ const error = new Error(`EVENT_WRITE_LOCK_TIMEOUT: 等待 change "${changeName}" 的事件写锁超时`);
1359
+ error.code = 'EVENT_WRITE_LOCK_TIMEOUT';
1360
+ throw error;
1361
+ }
1362
+
1363
+ function releaseEventWriteLock(lock) {
1364
+ if (!lock?.path || !lock.token) return false;
1365
+ try {
1366
+ const current = JSON.parse(fs.readFileSync(lock.path, 'utf8'));
1367
+ if (current.token !== lock.token) return false;
1368
+ fs.rmSync(lock.path, { force: true });
1369
+ return true;
1370
+ } catch {
1371
+ return false;
1372
+ }
1373
+ }
1374
+
1375
+ function withEventWriteLock(dataDir, changeName, action) {
1376
+ const lock = acquireEventWriteLock(dataDir, changeName);
1377
+ try {
1378
+ return action(lock);
1379
+ } finally {
1380
+ releaseEventWriteLock(lock);
1381
+ }
1382
+ }
1383
+
1384
+ function appendEventUnlocked(dataDir, changeName, event) {
857
1385
  const dir = path.join(dataDir, 'events', safeChangeName(changeName));
858
1386
  ensureDir(dir);
859
1387
  const file = path.join(dir, `${today()}.jsonl`);
@@ -865,6 +1393,31 @@ function appendEvent(dataDir, changeName, event) {
865
1393
  appendMarkdownLog(dataDir, changeName, event);
866
1394
  }
867
1395
 
1396
+ /** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。 */
1397
+ function appendEvent(dataDir, changeName, event, options = {}) {
1398
+ if (options.lockHeld) {
1399
+ return appendEventUnlocked(dataDir, changeName, event);
1400
+ }
1401
+ return withEventWriteLock(dataDir, changeName, () => {
1402
+ if (event?.type === 'stage_end') {
1403
+ const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
1404
+ if (existing.length > 0) {
1405
+ const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
1406
+ if (latest.result !== event.result) {
1407
+ const error = new Error(
1408
+ `E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
1409
+ );
1410
+ error.code = 'E_STAGE_END_CONFLICT';
1411
+ throw error;
1412
+ }
1413
+ return { idempotent: true, event: latest };
1414
+ }
1415
+ }
1416
+ appendEventUnlocked(dataDir, changeName, event);
1417
+ return { idempotent: false, event };
1418
+ });
1419
+ }
1420
+
868
1421
  /** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
869
1422
  * execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
870
1423
  function formatLocalTimestamp(iso) {
@@ -881,66 +1434,75 @@ function formatLocalTimestamp(iso) {
881
1434
 
882
1435
  /** 把事件同步追加为人读 markdown 行到 openspec/changes/<change>/logs/execution-log.md
883
1436
  * (telemetry 副本;change 已归档时写入 archive 目录;失败不阻塞 telemetry 主流程) */
1437
+ function executionLogEventMarker(event) {
1438
+ return event?.event_id ? `<!-- event:${event.type || 'event'}:${event.event_id} -->` : '';
1439
+ }
1440
+
1441
+ function formatExecutionLogEntry(event) {
1442
+ const ts = formatLocalTimestamp(event.timestamp || nowISO());
1443
+ const stage = event.command || event.stage || 'unknown';
1444
+ const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
1445
+ const marker = executionLogEventMarker(event);
1446
+ if (event.type === 'process_note') {
1447
+ const kind = event.details?.kind || event.details?.process_note?.kind || 'note';
1448
+ const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
1449
+ const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
1450
+ return `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}${marker ? ` ${marker}` : ''}\n`;
1451
+ }
1452
+ if (event.type === 'check_result') {
1453
+ const dispositions = event.details?.check_results?.warning_dispositions;
1454
+ if (Array.isArray(dispositions) && dispositions.length > 0) {
1455
+ const counts = dispositions.reduce((result, item) => {
1456
+ const key = item.disposition || 'open';
1457
+ result[key] = (result[key] || 0) + 1;
1458
+ return result;
1459
+ }, {});
1460
+ const dispositionText = Object.entries(counts)
1461
+ .map(([key, count]) => `${key}=${count}`)
1462
+ .join(', ');
1463
+ return `- [${ts}] ${stage} check_result → ${RESULT_MARK[event.result] || '📝'}${event.result ? `(${event.result})` : ''} | ${summary} | 告警处置:${dispositionText}${marker ? ` ${marker}` : ''}\n`;
1464
+ }
1465
+ }
1466
+ const type = event.type || 'event';
1467
+ const result = event.result || '';
1468
+ const mark = RESULT_MARK[result] || '📝';
1469
+ return `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}${marker ? ` ${marker}` : ''}\n`;
1470
+ }
1471
+
1472
+ function renderExecutionLog(changeName, events) {
1473
+ const sorted = events.slice().sort((a, b) => timestampMs(a) - timestampMs(b));
1474
+ const created = formatLocalTimestamp(sorted[0]?.timestamp || nowISO());
1475
+ return `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`
1476
+ + sorted.map(formatExecutionLogEntry).join('');
1477
+ }
1478
+
884
1479
  function appendMarkdownLog(dataDir, changeName, event) {
885
1480
  try {
886
1481
  const projectRoot = path.dirname(dataDir);
887
1482
  const activeChangeDir = getChangeDir(projectRoot, changeName);
888
- // Q1: openspec new change 前(无 .openspec.yaml 且无 archive-manifest.json)不创建 logs/,避免 propose 残留误报
889
- const hasChangeMarker = fs.existsSync(path.join(activeChangeDir, '.openspec.yaml'))
890
- || fs.existsSync(path.join(activeChangeDir, 'archive-manifest.json'));
891
- if (!hasChangeMarker && !fs.existsSync(activeChangeDir)) {
892
- // 目录本身不存在,检查是否为已归档变更
893
- const archiveDir = findArchivedChangeDir(projectRoot, changeName);
894
- if (!archiveDir) return; // 未 init 且未归档 → 跳过 logs/ 创建
895
- const archiveLogsDir = path.join(archiveDir, 'logs');
896
- ensureDir(archiveLogsDir);
897
- const file = path.join(archiveLogsDir, 'execution-log.md');
898
- if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
899
- const created = formatLocalTimestamp(event.timestamp || nowISO());
900
- fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
901
- }
902
- const ts = formatLocalTimestamp(event.timestamp || nowISO());
903
- const stage = event.command || event.stage || 'unknown';
904
- const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
905
- if (event.type === 'process_note') {
906
- const kind = event.details?.kind || 'note';
907
- const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
908
- const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
909
- fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
910
- return;
911
- }
912
- const type = event.type || 'event';
913
- const result = event.result || '';
914
- const mark = RESULT_MARK[result] || '📝';
915
- fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
916
- return;
917
- }
918
- let logsDir = path.join(activeChangeDir, 'logs');
919
- if (!fs.existsSync(activeChangeDir)) {
920
- const archiveDir = findArchivedChangeDir(projectRoot, changeName);
921
- if (archiveDir) logsDir = path.join(archiveDir, 'logs');
922
- }
1483
+ const archiveDir = findArchivedChangeDir(projectRoot, changeName);
1484
+ if (!fs.existsSync(activeChangeDir) && !archiveDir) return;
1485
+
1486
+ const targetChangeDir = fs.existsSync(activeChangeDir) ? activeChangeDir : archiveDir;
1487
+ const logsDir = path.join(targetChangeDir, 'logs');
923
1488
  ensureDir(logsDir);
924
1489
  const file = path.join(logsDir, 'execution-log.md');
925
- if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
926
- const created = formatLocalTimestamp(event.timestamp || nowISO());
927
- fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
1490
+ if (fs.existsSync(file) && !fs.statSync(file).isFile()) {
1491
+ throw new Error(`execution-log 路径不是文件: ${file}`);
928
1492
  }
929
- const ts = formatLocalTimestamp(event.timestamp || nowISO());
930
- const stage = event.command || event.stage || 'unknown';
931
- const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
932
- // process_note 渲染为带 kind/round 的叙事行(U2 叙事信号源 + U3 修复可追溯)
933
- if (event.type === 'process_note') {
934
- const kind = event.details?.kind || 'note';
935
- const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
936
- const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
937
- fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
1493
+
1494
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
1495
+ const hasMarkers = /<!-- event:[^>]+ -->/.test(current);
1496
+ if (!current || !hasMarkers) {
1497
+ const allEvents = readEvents(dataDir, changeName);
1498
+ fs.writeFileSync(file, renderExecutionLog(changeName, allEvents), 'utf8');
938
1499
  return;
939
1500
  }
940
- const type = event.type || 'event';
941
- const result = event.result || '';
942
- const mark = RESULT_MARK[result] || '📝';
943
- fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
1501
+
1502
+ const marker = executionLogEventMarker(event);
1503
+ if (!marker || !current.includes(marker)) {
1504
+ fs.appendFileSync(file, formatExecutionLogEntry(event), 'utf8');
1505
+ }
944
1506
  } catch (err) {
945
1507
  // markdown 副本失败不影响 telemetry 主流程,但输出 stderr 提示保留可观测性(避免持续性失败长期静默)
946
1508
  try { console.error(`[telemetry] execution-log 副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
@@ -948,7 +1510,7 @@ function appendMarkdownLog(dataDir, changeName, event) {
948
1510
  }
949
1511
 
950
1512
  /** 读取指定 change 的所有事件 */
951
- function readEvents(dataDir, changeName) {
1513
+ function readEventsRaw(dataDir, changeName) {
952
1514
  try {
953
1515
  const dir = path.join(dataDir, 'events', safeChangeName(changeName));
954
1516
  if (!fs.existsSync(dir)) return [];
@@ -1505,76 +2067,143 @@ function compactReworkSummary(summary, reasons) {
1505
2067
  };
1506
2068
  }
1507
2069
 
1508
- // report-time git 推导返工原因:对 summarizeStageExecutions 产出的 reworkAttempts 细化
1509
- // - agent override 优先(stage_end.rework_reason 由 cmdEnd --reason 写入,summarizeStageExecutions 已保留)
1510
- // - completed_rework_success(上次成功却重跑)按 git 推导:有 commit since end code-changed-after-pass;
1511
- // commit working tree 未提交 → code-changed-after-pass(has_uncommitted);都无 → recheck-no-change
1512
- // - no git repounspecified
1513
- // 注:用 `git log --all --since` 取所有 ref 上 commit date >= end.timestamp 的提交;reset 掉的 unreachable
1514
- // commit 不计入(完全实现需 reflog,复杂且会 expire,本期务实按 ref 可达)
1515
- function deriveReworkReasons(executionSummary, projectRoot) {
2070
+ // report-time 推导返工原因:对 summarizeStageExecutions 产出的 reworkAttempts 细化
2071
+ // - incomplete/prev-failed/pre-archive-recheck 为确定性判定(不依赖 git/telemetry),保持不变
2072
+ // - completed_rework_success(上次成功却重跑)按窗口 [本次 end, key 下一次 start) 推导证据:
2073
+ // 1) agent override(stage_end.rework_reason 合法枚举且非占位符 completed_rework_success)优先
2074
+ // 2) git log --all --since=window_start [--until=window_end]:窗口内有 commit code-changed-after-pass
2075
+ // 3) git 证据时退化到 metricsV3.findDevelopmentActivities 扫描 scopedEvents 同窗口的开发活动事件
2076
+ // 4) 都无证据 recheck-no-change;委托 metricsV3.deriveSuccessfulReworkReason 统一裁决
2077
+ // 注:不再用 working tree 是否 dirty 作为全局证据(当前 dirty 状态无法归因到具体某次窗口,误报风险高)
2078
+ function readGitCommitTimeline(projectRoot) {
2079
+ if (!projectRoot || runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']) !== 'true') {
2080
+ return null;
2081
+ }
2082
+ const log = runGit(projectRoot, ['log', '--all', '--format=%H%x09%cI%x09%s']);
2083
+ if (!log) return [];
2084
+ return log.split(/\r?\n/).filter(Boolean).map(line => {
2085
+ const parts = line.split('\t');
2086
+ return {
2087
+ sha: parts[0] || '',
2088
+ timestamp: parts[1] || null,
2089
+ timestamp_ms: Date.parse(parts[1]),
2090
+ subject: parts.slice(2).join('\t'),
2091
+ };
2092
+ }).filter(commit => Number.isFinite(commit.timestamp_ms));
2093
+ }
2094
+
2095
+ function findGitCommitInWindow(timeline, windowStart, windowEnd) {
2096
+ if (!Array.isArray(timeline) || !windowStart) return null;
2097
+ const startMs = Date.parse(windowStart);
2098
+ const endMs = windowEnd ? Date.parse(windowEnd) : null;
2099
+ if (!Number.isFinite(startMs)) return null;
2100
+ return timeline.find(commit => (
2101
+ commit.timestamp_ms >= startMs
2102
+ && (!Number.isFinite(endMs) || commit.timestamp_ms <= endMs)
2103
+ )) || null;
2104
+ }
2105
+
2106
+ function resolveGitCommitTimeline(events, projectRoot, providedTimeline) {
2107
+ if (providedTimeline !== undefined) return providedTimeline;
2108
+ const startCounts = new Map();
2109
+ let hasRepeatedStage = false;
2110
+ for (const event of events) {
2111
+ if (event.orphan || event.type !== 'stage_start') continue;
2112
+ const key = `${event.change || ''}|${event.command || ''}|${event.capability || ''}`;
2113
+ const count = (startCounts.get(key) || 0) + 1;
2114
+ startCounts.set(key, count);
2115
+ if (count > 1) {
2116
+ hasRepeatedStage = true;
2117
+ break;
2118
+ }
2119
+ }
2120
+ return hasRepeatedStage ? readGitCommitTimeline(projectRoot) : null;
2121
+ }
2122
+
2123
+ function deriveReworkReasons(executionSummary, projectRoot, scopedEvents = [], options = {}) {
1516
2124
  const byCategory = createReasonCategory();
1517
2125
  const details = [];
1518
2126
  const reworkAttempts = executionSummary.reworkAttempts || [];
1519
- const gitAvailable = projectRoot ? runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']) === 'true' : false;
1520
- let uncommittedCache = null;
1521
- const hasUncommitted = () => {
1522
- if (uncommittedCache !== null) return uncommittedCache;
1523
- if (!gitAvailable) { uncommittedCache = false; return false; }
1524
- const status = runGit(projectRoot, ['status', '--porcelain']);
1525
- uncommittedCache = Boolean(status && status.trim());
1526
- return uncommittedCache;
1527
- };
2127
+ const auxiliaryExecutionSummary = summarizeStageExecutions(
2128
+ scopedEvents.filter(event => AUXILIARY_STAGES.includes(event.command || event.stage)),
2129
+ );
2130
+ const needsGitEvidence = reworkAttempts.some(attempt => attempt.rework_reason === 'completed_rework_success');
2131
+ const gitCommitTimeline = options.gitCommitTimeline !== undefined
2132
+ ? options.gitCommitTimeline
2133
+ : (needsGitEvidence ? readGitCommitTimeline(projectRoot) : null);
2134
+
1528
2135
  const bucketByKey = new Map();
1529
2136
  for (const bucket of executionSummary.summary.by_stage || []) {
1530
2137
  if (!bucket.reasons_by_category) bucket.reasons_by_category = createReasonCategory();
1531
2138
  bucketByKey.set(`${bucket.command}|${bucket.capability || ''}`, bucket);
1532
2139
  }
2140
+
2141
+ const attemptsByKey = new Map();
2142
+ for (const attempt of executionSummary.attempts || []) {
2143
+ const key = attempt.key || stageAttemptKey(attempt.start);
2144
+ if (!attemptsByKey.has(key)) attemptsByKey.set(key, []);
2145
+ attemptsByKey.get(key).push(attempt);
2146
+ }
2147
+ for (const list of attemptsByKey.values()) {
2148
+ list.sort((a, b) => timestampMs(a.start) - timestampMs(b.start));
2149
+ }
2150
+
1533
2151
  for (const attempt of reworkAttempts) {
1534
2152
  let reason = attempt.rework_reason;
1535
2153
  let detail = null;
1536
- let hasUncommittedFlag = false;
2154
+ let reasonSource = 'deterministic';
2155
+ let windowStart = null;
2156
+ let windowEnd = null;
2157
+ let evidenceEventIds = [];
2158
+ let evidenceTypes = [];
2159
+
1537
2160
  if (reason === 'completed_rework_success') {
1538
- if (!gitAvailable) {
1539
- reason = 'unspecified';
1540
- } else {
1541
- const endTs = attempt.end && attempt.end.timestamp ? Date.parse(attempt.end.timestamp) : null;
1542
- let recentCommitMessage = null;
1543
- if (endTs !== null && Number.isFinite(endTs)) {
1544
- const sinceIso = new Date(endTs).toISOString();
1545
- const log = runGit(projectRoot, ['log', '--all', '--since', sinceIso, '--format=%H%x09%cI%x09%s']);
1546
- if (log) {
1547
- const lines = log.split(/\r?\n/).filter(Boolean);
1548
- if (lines.length > 0) {
1549
- const parts = lines[0].split('\t');
1550
- recentCommitMessage = parts[2] || '';
1551
- }
1552
- }
1553
- }
1554
- const uncommitted = hasUncommitted();
1555
- if (recentCommitMessage !== null) {
1556
- reason = 'code-changed-after-pass';
1557
- detail = recentCommitMessage;
1558
- hasUncommittedFlag = uncommitted;
1559
- } else if (uncommitted) {
1560
- reason = 'code-changed-after-pass';
1561
- hasUncommittedFlag = true;
1562
- } else {
1563
- reason = 'recheck-no-change';
1564
- }
2161
+ const endIso = attempt.end && attempt.end.timestamp ? attempt.end.timestamp : null;
2162
+ const endMs = endIso ? Date.parse(endIso) : null;
2163
+ windowStart = Number.isFinite(endMs) ? endIso : null;
2164
+
2165
+ const key = attempt.key || stageAttemptKey(attempt.start);
2166
+ const siblings = attemptsByKey.get(key) || [];
2167
+ const idx = siblings.indexOf(attempt);
2168
+ const nextAttempt = idx >= 0 ? siblings[idx + 1] : null;
2169
+ windowEnd = nextAttempt ? nextAttempt.start.timestamp : null;
2170
+
2171
+ const commit = findGitCommitInWindow(gitCommitTimeline, windowStart, windowEnd);
2172
+ const gitHasCommit = Boolean(commit);
2173
+ const commitMessage = commit?.subject || null;
2174
+
2175
+ let devActivities = [];
2176
+ if (!gitHasCommit) {
2177
+ const endMsForScan = windowEnd ? Date.parse(windowEnd) : null;
2178
+ devActivities = metricsV3.findDevelopmentActivities(scopedEvents, endMs, endMsForScan);
1565
2179
  }
2180
+
2181
+ const derived = metricsV3.deriveSuccessfulReworkReason({
2182
+ base_reason: 'completed_rework_success',
2183
+ agent_override_reason: attempt.end?.rework_reason || null,
2184
+ git_has_commit_in_window: gitHasCommit,
2185
+ dev_activities: devActivities,
2186
+ window: { start: windowStart, end: windowEnd },
2187
+ });
2188
+
2189
+ reason = derived.reason;
2190
+ reasonSource = derived.reason_source;
2191
+ evidenceEventIds = derived.evidence_event_ids || [];
2192
+ evidenceTypes = devActivities.map(e => e.type || e.event_type).filter(Boolean);
2193
+ detail = commitMessage;
1566
2194
  }
1567
- // T3.6 + Q6: check 在 test 之后重跑(归档前强制复检)→ pre-archive-recheck(不论原 reason)
1568
- // 判定:attempt check check canonical 开始时间晚于 test canonical 结束时间 → 归档前强制复检
2195
+
2196
+ // T3.6 + Q6: check test 之后重跑(归档前强制复检)→ pre-archive-recheck(不论原 reason,确定性,优先级最高)
1569
2197
  if (attempt.command === 'check') {
1570
2198
  const canonicalCheck = getCanonicalAttempt(executionSummary, 'check', attempt.capability);
1571
- // Y2 修复:test 也传 capability 避免跨 capability 取到错误 canonical(summarizeStageExecutions 按 command|capability 分桶)
1572
- const canonicalTest = getCanonicalAttempt(executionSummary, 'test', attempt.capability);
2199
+ const canonicalTest = getCanonicalAttempt(auxiliaryExecutionSummary, 'test', attempt.capability);
1573
2200
  if (canonicalCheck && canonicalCheck.start && canonicalTest && canonicalTest.end
1574
2201
  && timestampMs(canonicalCheck.start) > timestampMs(canonicalTest.end)) {
1575
2202
  reason = 'pre-archive-recheck';
2203
+ reasonSource = 'deterministic';
1576
2204
  }
1577
2205
  }
2206
+
1578
2207
  attempt.rework_reason = reason;
1579
2208
  byCategory[reason] = (byCategory[reason] || 0) + 1;
1580
2209
  const bucket = bucketByKey.get(`${attempt.command}|${attempt.capability || ''}`);
@@ -1585,8 +2214,12 @@ function deriveReworkReasons(executionSummary, projectRoot) {
1585
2214
  command: attempt.command,
1586
2215
  attempt_event_id: (attempt.start && attempt.start.event_id) || null,
1587
2216
  reason,
2217
+ reason_source: reasonSource,
2218
+ window_start: windowStart,
2219
+ window_end: windowEnd,
2220
+ evidence_event_ids: evidenceEventIds,
2221
+ evidence_types: evidenceTypes,
1588
2222
  detail,
1589
- has_uncommitted: hasUncommittedFlag,
1590
2223
  });
1591
2224
  }
1592
2225
  return { by_category: byCategory, details };
@@ -1615,6 +2248,13 @@ function getCheckResults(event) {
1615
2248
  fixed_before_apply: Number.isFinite(results.fixed_before_apply) ? results.fixed_before_apply : null,
1616
2249
  consistency_score: Number.isFinite(results.consistency_score) ? results.consistency_score : null,
1617
2250
  categories: results.categories || {},
2251
+ // V3 Q3: reviewer 元信息,用于评估 reviewer_independence/score_status(缺失时保持 null,不影响旧字段)
2252
+ reviewer: results.reviewer || null,
2253
+ review_session_id: results.review_session_id || null,
2254
+ author_session_id: results.author_session_id || null,
2255
+ reviewer_independence: results.reviewer_independence || null,
2256
+ warning_items: Array.isArray(results.warning_items) ? results.warning_items : [],
2257
+ warning_dispositions: Array.isArray(results.warning_dispositions) ? results.warning_dispositions : [],
1618
2258
  };
1619
2259
  }
1620
2260
 
@@ -2293,10 +2933,18 @@ function computeGitDocumentMetrics(projectRoot, changeName) {
2293
2933
  function computeSinglePdfMvpMetrics(events, options = {}) {
2294
2934
  const scopedEvents = events
2295
2935
  .filter(e => !e.orphan)
2296
- .filter(e => !options.change || e.change === options.change)
2936
+ .filter(e => !options.change
2937
+ || e.change === options.change
2938
+ || e.attribution?.target_change === options.change)
2297
2939
  .filter(e => !options.capability || e.capability === options.capability);
2298
- const executionSummary = summarizeStageExecutions(scopedEvents);
2299
- const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
2940
+ const measurementEvents = scopedEvents.filter((event) => {
2941
+ const stage = event.command || event.stage;
2942
+ return MEASUREMENT_STAGES.includes(stage);
2943
+ });
2944
+ const executionSummary = summarizeStageExecutions(measurementEvents);
2945
+ const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot, scopedEvents, {
2946
+ gitCommitTimeline: options.gitCommitTimeline,
2947
+ });
2300
2948
  const starts = executionSummary.canonicalAttempts.map(attempt => attempt.start);
2301
2949
  const ends = executionSummary.canonicalAttempts.map(attempt => attempt.end).filter(Boolean);
2302
2950
 
@@ -2306,11 +2954,13 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2306
2954
  ? new Date(archiveEnd.timestamp).getTime() - new Date(proposeStart.timestamp).getTime()
2307
2955
  : null;
2308
2956
 
2309
- const totalStageDuration = executionSummary.summary.effective_stage_duration_ms;
2957
+ const totalStageDuration = executionSummary.summary.total_stage_duration_ms;
2310
2958
  const codingDuration = sumDurations(ends, ['apply']);
2311
2959
  const specDuration = sumDurations(ends, ['propose', 'spec', 'design', 'task']);
2312
2960
  const reworkCodingDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['apply']);
2313
2961
  const reworkSpecDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['propose', 'spec', 'design', 'task']);
2962
+ const codingDurationIncludingRework = codingDuration + reworkCodingDuration;
2963
+ const specDurationIncludingRework = specDuration + reworkSpecDuration;
2314
2964
 
2315
2965
  const formalApplyAttempt = getCanonicalAttempt(executionSummary, 'apply', options.capability);
2316
2966
  const formalApplyEvents = formalApplyAttempt
@@ -2342,9 +2992,20 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2342
2992
  : null;
2343
2993
  const specIterationSource = gitDocumentMetrics?.source || (gitDocumentMetrics?.git_available ? 'git' : null);
2344
2994
 
2345
- const firstApply = firstByTimestamp(starts.filter(e => e.command === 'apply'));
2995
+ // P4 的边界必须是第一次实际进入 apply,而不是“最终 canonical apply”。
2996
+ // 否则先 apply 失败、后补 check、再 apply 成功会被误判为门禁前置。
2997
+ const firstApply = firstByTimestamp(
2998
+ executionSummary.attempts
2999
+ .filter(attempt => attempt.command === 'apply')
3000
+ .map(attempt => attempt.start),
3001
+ );
2346
3002
  const qualityGateBeforeApply = firstApply
2347
- ? checkEvents.some(e => new Date(e.timestamp).getTime() <= new Date(firstApply.timestamp).getTime())
3003
+ ? executionSummary.attempts.some(attempt => (
3004
+ attempt.command === 'check'
3005
+ && attempt.end
3006
+ && ['success', 'partial'].includes(attempt.result)
3007
+ && timestampMs(attempt.end) <= timestampMs(firstApply)
3008
+ ))
2348
3009
  : null;
2349
3010
  const latestCheckWithFixRate = latestByTimestamp(checkEvents.filter(e => {
2350
3011
  const results = getCheckResults(e);
@@ -2360,11 +3021,133 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2360
3021
  const aiFirstPassMetrics = computeAiFirstPassMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
2361
3022
  const specTestCoverageMetrics = computeSpecTestCoverageMetrics(scopedEvents);
2362
3023
 
3024
+ // ── V3 ────────────────────────────────────────────────────────────
3025
+ const scenarioData = (options.projectRoot && options.change)
3026
+ ? countSpecScenarios(options.projectRoot, options.change)
3027
+ : { scenario_count: 0, files: [], source: null, root: null, availability_reason: 'no-change-scope' };
3028
+ const changeType = (options.projectRoot && options.change)
3029
+ ? readChangeType(options.projectRoot, options.change)
3030
+ : 'unknown';
3031
+
3032
+ // Q3: 优先取最新一次 total>0 的 check(而非仅要求带 consistency_score,兼容旧 Q5 逻辑保持不变)
3033
+ const latestCheckWithTotal = latestByTimestamp(checkEvents.filter(e => (getCheckResults(e)?.total ?? 0) > 0));
3034
+ const latestCheckResultsV3 = getCheckResults(latestCheckWithTotal);
3035
+ const checkMeta = {
3036
+ reviewer: latestCheckResultsV3?.reviewer || null,
3037
+ author: formalApplyAttempt?.start?.session_id || formalApplyAttempt?.start?.agent_type || null,
3038
+ reviewer_independence: latestCheckResultsV3?.reviewer_independence || null,
3039
+ review_session_id: latestCheckResultsV3?.review_session_id || null,
3040
+ author_session_id: latestCheckResultsV3?.author_session_id || formalApplyAttempt?.start?.session_id || null,
3041
+ };
3042
+ const q3Result = metricsV3.computeSpecQualityScore(latestCheckResultsV3, checkMeta);
3043
+
3044
+ const telemetryHealth = computeTelemetryHealthMetrics(measurementEvents);
3045
+ const pairedMeasurementAttempts = executionSummary.attempts.filter(attempt => attempt.end).length;
3046
+ const observedPairRate = executionSummary.attempts.length > 0
3047
+ ? pairedMeasurementAttempts / executionSummary.attempts.length
3048
+ : null;
3049
+ const completedStages = MEASUREMENT_STAGES.filter(stage => (
3050
+ executionSummary.attempts.some(attempt => (
3051
+ attempt.command === stage
3052
+ && attempt.end
3053
+ && ['success', 'partial'].includes(attempt.result)
3054
+ ))
3055
+ ));
3056
+ const missingRequiredStages = MEASUREMENT_STAGES.filter(stage => !completedStages.includes(stage));
3057
+ const requiredStageCompleteness = {
3058
+ completed: completedStages.length,
3059
+ required: MEASUREMENT_STAGES.length,
3060
+ rate: completedStages.length / MEASUREMENT_STAGES.length,
3061
+ completed_stages: completedStages,
3062
+ missing: missingRequiredStages,
3063
+ };
3064
+ const pHTelemetryHealthScore = Number.isFinite(telemetryHealth.telemetry_health_score)
3065
+ ? metricsV3.round(telemetryHealth.telemetry_health_score * 100, 2)
3066
+ : null;
3067
+
3068
+ const reworkSummary = compactReworkSummary(executionSummary.summary, reworkReasons);
3069
+
3070
+ const v3Context = buildV3ChangeContext(scopedEvents, executionSummary, {
3071
+ change_name: options.change || null,
3072
+ change_type: changeType,
3073
+ scenario_count: scenarioData.scenario_count,
3074
+ scenario_sources: scenarioData.files,
3075
+ check_results: latestCheckResultsV3,
3076
+ check_meta: checkMeta,
3077
+ q3: q3Result,
3078
+ total_stage_duration_ms: totalStageDuration,
3079
+ coding_duration_ms: codingDurationIncludingRework,
3080
+ spec_duration_ms: specDurationIncludingRework,
3081
+ rework_coding_duration_ms: 0,
3082
+ rework_spec_duration_ms: 0,
3083
+ p4_quality_gate_enforcement_rate: qualityGateRate,
3084
+ p_c_stage_closure_rate: observedPairRate,
3085
+ p_h_telemetry_health_score: pHTelemetryHealthScore,
3086
+ rework_summary: reworkSummary,
3087
+ q1_spec_conformance: conformanceMetrics.q1_spec_conformance_score,
3088
+ q1_source: 'conformance_review',
3089
+ q4_tdd_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
3090
+ q4_source: specTestCoverageMetrics.source,
3091
+ });
3092
+ const v3 = metricsV3.computeV3ChangeMetrics(v3Context);
3093
+ const e1AvailabilityReason = v3.efficiency.e1_availability_reason === 'no-scenarios' && scenarioData.availability_reason
3094
+ ? scenarioData.availability_reason
3095
+ : v3.efficiency.e1_availability_reason;
3096
+ const completeMeasurementJourney = missingRequiredStages.length === 0;
3097
+ const incompleteJourneyReason = completeMeasurementJourney
3098
+ ? null
3099
+ : `missing-required-stages:${missingRequiredStages.join(',')}`;
3100
+ const metricEvidence = {
3101
+ e1: {
3102
+ status: !v3.efficiency.e1_availability
3103
+ ? 'missing'
3104
+ : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3105
+ reason: e1AvailabilityReason || incompleteJourneyReason,
3106
+ source_event_count: measurementEvents.length,
3107
+ },
3108
+ q3: {
3109
+ status: v3.quality.q3_score_status === 'verified'
3110
+ && v3.quality.q3_reviewer_independence === 'independent-review'
3111
+ ? 'trusted'
3112
+ : (v3.quality.q3_availability ? 'provisional' : 'missing'),
3113
+ reason: v3.quality.q3_availability_reason,
3114
+ source_event_count: checkEvents.length,
3115
+ },
3116
+ p4: {
3117
+ status: !firstApply
3118
+ ? 'missing'
3119
+ : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3120
+ reason: firstApply ? incompleteJourneyReason : 'missing-apply-stage',
3121
+ source_event_count: executionSummary.attempts.filter(attempt => attempt.command === 'check').length,
3122
+ },
3123
+ p_r: {
3124
+ status: executionSummary.attempts.length === 0
3125
+ ? 'missing'
3126
+ : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3127
+ reason: executionSummary.attempts.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
3128
+ source_event_count: executionSummary.attempts.length,
3129
+ },
3130
+ p_h: {
3131
+ status: measurementEvents.length === 0
3132
+ ? 'missing'
3133
+ : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3134
+ reason: measurementEvents.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
3135
+ source_event_count: measurementEvents.length,
3136
+ },
3137
+ };
3138
+
2363
3139
  return {
3140
+ measurement_scope: {
3141
+ stages: MEASUREMENT_STAGES,
3142
+ excluded_auxiliary_stages: AUXILIARY_STAGES,
3143
+ source_event_count: measurementEvents.length,
3144
+ attribution: 'direct-or-explicit-link',
3145
+ },
3146
+ metric_evidence: metricEvidence,
2364
3147
  efficiency: {
2365
3148
  e1_lead_time_ms: leadTime,
2366
- e2_coding_time_ratio: totalStageDuration > 0 ? roundMetric(codingDuration / totalStageDuration) : null,
2367
- e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(specDuration / totalStageDuration) : null,
3149
+ e2_coding_time_ratio: totalStageDuration > 0 ? roundMetric(codingDurationIncludingRework / totalStageDuration, 4) : null,
3150
+ e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(specDurationIncludingRework / totalStageDuration, 4) : null,
2368
3151
  e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
2369
3152
  effective_stage_duration_ms: executionSummary.summary.effective_stage_duration_ms,
2370
3153
  idle_time_ms: leadTime != null && executionSummary.summary.effective_stage_duration_ms != null
@@ -2372,12 +3155,19 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2372
3155
  : null,
2373
3156
  rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
2374
3157
  total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
3158
+ measurement_stage_duration_ms: totalStageDuration,
2375
3159
  e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
2376
3160
  ? roundMetric((codingDuration + reworkCodingDuration) / executionSummary.summary.total_stage_duration_ms)
2377
3161
  : null,
2378
3162
  e3_spec_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
2379
3163
  ? roundMetric((specDuration + reworkSpecDuration) / executionSummary.summary.total_stage_duration_ms)
2380
3164
  : null,
3165
+ // V3 主指标:场景交付效率(ms/场景)
3166
+ e1_scenario_delivery_efficiency_ms: v3.efficiency.e1_scenario_delivery_efficiency_ms,
3167
+ scenario_count: v3.efficiency.scenario_count,
3168
+ scenario_sources: v3.efficiency.scenario_sources,
3169
+ e1_availability: v3.efficiency.e1_availability,
3170
+ e1_availability_reason: e1AvailabilityReason,
2381
3171
  },
2382
3172
  quality: {
2383
3173
  q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
@@ -2391,6 +3181,15 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2391
3181
  conformance_reviewer: conformanceMetrics.reviewer,
2392
3182
  conformance_reviewer_independence: conformanceMetrics.reviewer_independence,
2393
3183
  spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
3184
+ // V3 主指标:规约质量评分(0-100)
3185
+ q3_spec_quality_score: v3.quality.q3_spec_quality_score,
3186
+ q3_score_status: v3.quality.q3_score_status,
3187
+ q3_reviewer_independence: v3.quality.q3_reviewer_independence,
3188
+ q3_category_scores: v3.quality.q3_category_scores,
3189
+ q3_availability: v3.quality.q3_availability,
3190
+ // Q1/Q4 上游信号未就绪时用 pending-upstream 标注,不把 null 误转成 0
3191
+ q1_status: v3.quality.q1_spec_conformance.status,
3192
+ q4_status: v3.quality.q4_tdd_coverage.status,
2394
3193
  },
2395
3194
  process: {
2396
3195
  p1_spec_iteration_count: specIterationCount,
@@ -2401,10 +3200,34 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2401
3200
  p4_quality_gate_enforcement_rate: qualityGateRate,
2402
3201
  p4b_check_fixed_rate: fixRate,
2403
3202
  git_document_metrics: gitDocumentMetrics,
2404
- rework_summary: compactReworkSummary(executionSummary.summary, reworkReasons),
3203
+ rework_summary: reworkSummary,
3204
+ // V3: 阶段闭环率别名(来自 telemetry_health.matched_stage_rate)
3205
+ p_c_stage_closure_rate: observedPairRate,
3206
+ observed_stage_pairing: {
3207
+ paired: pairedMeasurementAttempts,
3208
+ starts: executionSummary.attempts.length,
3209
+ rate: observedPairRate,
3210
+ },
3211
+ required_stage_completeness: requiredStageCompleteness,
2405
3212
  },
2406
3213
  manual_insights: computeManualInsightMetrics(scopedEvents),
2407
- telemetry_health: computeTelemetryHealthMetrics(scopedEvents),
3214
+ telemetry_health: {
3215
+ ...telemetryHealth,
3216
+ p_h_telemetry_health_score: pHTelemetryHealthScore,
3217
+ },
3218
+ change_type: changeType,
3219
+ change_name: options.change || null,
3220
+ v3,
3221
+ legacy_metrics: {
3222
+ e1_lead_time_ms: leadTime,
3223
+ e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
3224
+ p1_spec_iteration_count: specIterationCount,
3225
+ p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
3226
+ q3_build_first_pass_rate: firstBuildSuccess == null ? null : (firstBuildSuccess ? 1 : 0),
3227
+ q5_cross_doc_consistency_score: consistencyScore,
3228
+ deprecated: true,
3229
+ notes: '旧版指标,保留用于兼容。新版主指标见 v3 字段(E1=场景交付效率 ms/场景,Q3=规约质量评分 0-100)。',
3230
+ },
2408
3231
  };
2409
3232
  }
2410
3233
 
@@ -2431,6 +3254,7 @@ function computeCapabilityMetrics(capabilityName, events, options = {}) {
2431
3254
  function computeTelemetryHealthMetrics(events, options = {}) {
2432
3255
  const report = computeDoctorReport(events, options);
2433
3256
  return {
3257
+ ...report,
2434
3258
  telemetry_health_score: report.telemetry_health_score,
2435
3259
  matched_stage_rate: report.matched_stage_rate,
2436
3260
  open_stages: report.open_stages,
@@ -2448,20 +3272,52 @@ function computePdfMvpMetrics(events, options = {}) {
2448
3272
  }
2449
3273
 
2450
3274
  if (options.level === 'change' || options.change) {
3275
+ const gitCommitTimeline = resolveGitCommitTimeline(
3276
+ events,
3277
+ options.projectRoot,
3278
+ options.gitCommitTimeline,
3279
+ );
2451
3280
  return computeSinglePdfMvpMetrics(events, {
2452
3281
  change: options.change,
2453
3282
  projectRoot: options.projectRoot,
3283
+ gitCommitTimeline,
2454
3284
  });
2455
3285
  }
2456
3286
 
3287
+ const gitCommitTimeline = resolveGitCommitTimeline(
3288
+ events,
3289
+ options.projectRoot,
3290
+ options.gitCommitTimeline,
3291
+ );
2457
3292
  const changeNames = [...new Set(events.map(e => e.change).filter(Boolean))];
2458
3293
  const changeMetrics = changeNames
2459
3294
  .map(change => computeSinglePdfMvpMetrics(events, {
2460
3295
  change,
2461
3296
  projectRoot: options.projectRoot,
3297
+ gitCommitTimeline,
2462
3298
  }))
2463
3299
  .filter(Boolean);
2464
3300
 
3301
+ const byChange = changeMetrics.map(m => ({
3302
+ change_name: m.change_name,
3303
+ change_type: m.change_type,
3304
+ e1_scenario_delivery_efficiency_ms: m.efficiency.e1_scenario_delivery_efficiency_ms,
3305
+ q3_spec_quality_score: m.quality.q3_spec_quality_score,
3306
+ p4_quality_gate_enforcement_rate: m.process.p4_quality_gate_enforcement_rate,
3307
+ p_c_stage_closure_rate: m.process.p_c_stage_closure_rate,
3308
+ p_h_telemetry_health_score: m.telemetry_health.p_h_telemetry_health_score,
3309
+ rework_summary: m.process.rework_summary,
3310
+ }));
3311
+ const v3ChangeMetrics = changeMetrics.map(m => m.v3).filter(Boolean);
3312
+ const aggregated = metricsV3.aggregateV3Metrics(v3ChangeMetrics);
3313
+ const totalReworkAttempts = sumMetric(
3314
+ changeMetrics,
3315
+ m => m.process.rework_summary?.total_rework_attempts,
3316
+ );
3317
+ const avgReworkCount = changeMetrics.length > 0
3318
+ ? metricsV3.round(totalReworkAttempts / changeMetrics.length)
3319
+ : null;
3320
+
2465
3321
  return {
2466
3322
  efficiency: {
2467
3323
  e1_lead_time_ms: averageMetric(changeMetrics, m => m.efficiency.e1_lead_time_ms),
@@ -2469,20 +3325,42 @@ function computePdfMvpMetrics(events, options = {}) {
2469
3325
  e3_spec_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e3_spec_time_ratio),
2470
3326
  e4_ai_code_first_pass_rate: averageMetric(changeMetrics, m => m.efficiency.e4_ai_code_first_pass_rate),
2471
3327
  idle_time_ms: averageMetric(changeMetrics, m => m.efficiency.idle_time_ms),
3328
+ // V3 主指标:场景交付效率均值(跨变更)
3329
+ e1_scenario_delivery_efficiency_ms: averageMetric(changeMetrics, m => m.efficiency.e1_scenario_delivery_efficiency_ms),
2472
3330
  },
2473
3331
  quality: {
2474
3332
  q1_spec_conformance_score: averageMetric(changeMetrics, m => m.quality.q1_spec_conformance_score),
2475
3333
  q3_build_first_pass_rate: averageMetric(changeMetrics, m => m.quality.q3_build_first_pass_rate),
2476
3334
  q4_spec_driven_test_coverage: averageMetric(changeMetrics, m => m.quality.q4_spec_driven_test_coverage),
2477
3335
  q5_cross_doc_consistency_score: averageMetric(changeMetrics, m => m.quality.q5_cross_doc_consistency_score),
3336
+ // V3 主指标:规约质量评分均值(跨变更)
3337
+ q3_spec_quality_score: averageMetric(changeMetrics, m => m.quality.q3_spec_quality_score),
2478
3338
  },
2479
3339
  process: {
2480
3340
  p1_spec_iteration_count: sumMetric(changeMetrics, m => m.process.p1_spec_iteration_count),
2481
3341
  p2_ai_code_adoption_rate: averageMetric(changeMetrics, m => m.process.p2_ai_code_adoption_rate),
2482
3342
  p4_quality_gate_enforcement_rate: averageMetric(changeMetrics, m => m.process.p4_quality_gate_enforcement_rate),
3343
+ p_c_stage_closure_rate: averageMetric(changeMetrics, m => m.process.p_c_stage_closure_rate),
3344
+ rework_summary: {
3345
+ total_rework_attempts: totalReworkAttempts,
3346
+ avg_rework_count: avgReworkCount,
3347
+ },
2483
3348
  },
2484
3349
  manual_insights: computeManualInsightMetrics(events.filter(e => !e.orphan)),
2485
- telemetry_health: computeTelemetryHealthMetrics(events),
3350
+ telemetry_health: (() => {
3351
+ const health = computeTelemetryHealthMetrics(events);
3352
+ return {
3353
+ ...health,
3354
+ p_h_telemetry_health_score: Number.isFinite(health.telemetry_health_score)
3355
+ ? metricsV3.round(health.telemetry_health_score * 100, 2)
3356
+ : null,
3357
+ };
3358
+ })(),
3359
+ by_change: byChange,
3360
+ by_change_type: aggregated.by_change_type,
3361
+ distribution: aggregated.distribution,
3362
+ unknown_change_type_count: aggregated.unknown_change_type_count,
3363
+ change_count: aggregated.change_count,
2486
3364
  };
2487
3365
  }
2488
3366
 
@@ -2573,24 +3451,146 @@ function formatReworkReasonDetails(reasons) {
2573
3451
  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 ? '(含未提交变更)' : ''}`);
2574
3452
  }
2575
3453
 
3454
+ // V3: 最小指标集完整性 section
3455
+ function formatMinimumMetricSetMarkdown(minSet) {
3456
+ if (!minSet) return ['- 数据不可用'];
3457
+ const available = Array.isArray(minSet.available) ? minSet.available : [];
3458
+ const missing = Array.isArray(minSet.missing) ? minSet.missing : [];
3459
+ const minimumMetrics = Array.isArray(minSet.minimum_metrics) ? minSet.minimum_metrics : [];
3460
+ const labels = {
3461
+ e1: '每个场景平均用时(E1)',
3462
+ q3: '规约检查得分(Q3)',
3463
+ p4: '编码前已完成检查(P4)',
3464
+ p_r: '阶段重复次数(P-R)',
3465
+ p_h: '执行记录完整度(P-H)',
3466
+ };
3467
+ return [
3468
+ `- 覆盖率:${formatRatioToPercent(minSet.coverage_rate)}(${available.length}/${minimumMetrics.length})`,
3469
+ `- 已覆盖:${available.map(k => labels[k] || k).join(', ') || '无'}`,
3470
+ `- 缺失:${missing.map(k => labels[k] || k).join(', ') || '无'}`,
3471
+ ];
3472
+ }
3473
+
3474
+ function formatAlertsMarkdown(alerts) {
3475
+ if (!Array.isArray(alerts) || alerts.length === 0) return ['- 无预警'];
3476
+ return alerts.map(a => `- [${(a.level || 'info').toUpperCase()}] ${a.metric || ''}:${a.reason || ''}`);
3477
+ }
3478
+
3479
+ function formatMaturityMarkdown(maturity) {
3480
+ if (!maturity) return ['- 数据不可用'];
3481
+ return [
3482
+ `- 当前成熟度等级:${maturity.current_level}`,
3483
+ `- 可达成熟度上限:${maturity.eligible_max_level}`,
3484
+ `- 未满足条件:${(maturity.unmet_criteria || []).join(', ') || '无'}`,
3485
+ ];
3486
+ }
3487
+
3488
+ function reportStatusLabel(status) {
3489
+ return ({
3490
+ implemented: '已实现',
3491
+ provisional: '部分可用',
3492
+ 'pending-upstream': '待补数据',
3493
+ 'not-applicable': '本报告不适用',
3494
+ verified: '已验证',
3495
+ trusted: '可信',
3496
+ missing: '缺失',
3497
+ }[status] || status || '未知');
3498
+ }
3499
+
2576
3500
  function renderExecutiveReportMarkdown(report) {
3501
+ if (report?.level === 'change' && report.change) {
3502
+ report = require('./reporting/change-report-model.cjs').buildChangeReportModel(report);
3503
+ }
2577
3504
  const metrics = report.metrics;
2578
- const health = report.doctor;
3505
+ const health = report.doctor || {};
2579
3506
  const archive = report.archive_result || null;
2580
3507
  const taskCompletion = archive?.task_completion || null;
3508
+ const v3 = metrics?.v3 || null;
3509
+ const eff = metrics?.efficiency || {};
3510
+ const qual = metrics?.quality || {};
3511
+ const proc = metrics?.process || {};
3512
+ const th = metrics?.telemetry_health || {};
3513
+ const e2IncludingRework = eff.e2_coding_time_ratio_including_rework ?? eff.e2_coding_time_ratio;
3514
+ const e3IncludingRework = eff.e3_spec_time_ratio_including_rework ?? eff.e3_spec_time_ratio;
3515
+ const q1Status = qual.q1_status || (qual.q1_spec_conformance_score != null ? 'available' : 'pending-upstream');
3516
+ const q4Status = qual.q4_status || (qual.q4_spec_driven_test_coverage != null ? 'available' : 'pending-upstream');
2581
3517
  return [
2582
3518
  `# SDD 效果度量报告${report.change ? ` - ${report.change}` : ''}`,
2583
3519
  '',
2584
3520
  `- 生成时间:${formatLocalTimestamp(report.generated_at)}`,
2585
3521
  `- 项目路径:${report.project_root ? String(report.project_root).replace(/[\\/]+$/, '').split(/[\\/]/).pop() : 'null'}`,
2586
3522
  `- 统计范围:${report.change ? `change/${report.change}` : 'project'}`,
3523
+ `- 报告 Schema:${report.schema_version || metricsV3.REPORT_SCHEMA_VERSION}(指标契约 ${report.metrics_contract || metricsV3.METRICS_CONTRACT_VERSION})`,
3524
+ ...(report.window?.period ? [`- 统计周期:${report.window.period}(${report.window.date_from || ''} ~ ${report.window.date_to || ''})`] : []),
3525
+ '',
3526
+ '## 一句话结论',
3527
+ `> ${report.conclusion?.text || '当前证据不足,暂不能形成可信结论。'}`,
3528
+ '',
3529
+ '## 摘要',
3530
+ `- 任务完成:${report.conclusion?.task_statement || '尚无结构化主任务结论'}`,
3531
+ `- 证据可信度:${report.conclusion?.trust_statement || '尚无可信度结论'}`,
3532
+ `- 方案覆盖:${report.scheme_compliance?.counts?.implemented ?? 0}/${report.scheme_compliance?.items?.length ?? 0} 项已完整实现`,
3533
+ ...(report.change_summary ? [
3534
+ `- 文档任务完成:${report.change_summary.tasks?.document?.completed ?? '暂无数据'}/${report.change_summary.tasks?.document?.total ?? '暂无数据'}`,
3535
+ `- 事件可追溯:${report.change_summary.tasks?.traceability?.verified ?? '暂无数据'}/${report.change_summary.tasks?.traceability?.total ?? '暂无数据'}`,
3536
+ `- 最终测试:${report.change_summary.tests ? `${report.change_summary.tests.passed}/${report.change_summary.tests.total}(${report.change_summary.tests.status})` : '暂无数据'}`,
3537
+ `- 问题与证据:${report.change_summary.evidence?.category_count ?? 0} 类问题,${report.change_summary.evidence?.occurrence_count ?? 0} 次规则命中`,
3538
+ ] : []),
3539
+ '',
3540
+ '## 需要处理',
3541
+ ...(report.evidence_alerts?.open?.length > 0
3542
+ ? report.evidence_alerts.open.map(alert => `- [${alert.severity === 'error' ? '阻断' : '提醒'}] ${alert.warning}(${alert.subject || '本次变更'},出现 ${alert.occurrences} 次)`)
3543
+ : ['- 当前没有未解决的证据问题。']),
3544
+ ...((report.metric_alerts || []).map(alert => `- [指标] ${alert.metric || ''}:${alert.reason || ''}`)),
3545
+ ...((report.warning_dispositions || []).map(item => (
3546
+ `- [告警处置] ${item.warning}:${item.disposition}${item.reason ? `(${item.reason})` : ''}`
3547
+ ))),
3548
+ '',
3549
+ '## 本次变更的关键指标',
3550
+ ...formatMinimumMetricSetMarkdown(report.minimum_metric_set),
3551
+ `- 数值覆盖:${formatRatioToPercent(report.minimum_metric_set?.numeric_coverage_rate)}(${report.minimum_metric_set?.numeric_available ?? 0}/${report.minimum_metric_set?.numeric_total ?? 5})`,
3552
+ `- 可信覆盖:${formatRatioToPercent(report.minimum_metric_set?.trusted_coverage_rate)}(${report.minimum_metric_set?.trusted_available ?? 0}/${report.minimum_metric_set?.numeric_total ?? 5};临时 ${report.minimum_metric_set?.provisional ?? 0} 项)`,
3553
+ '',
3554
+ '## 核心指标',
3555
+ '## 效率 E1/E2/E3',
3556
+ `- 每个场景平均用时(E1):${eff.e1_scenario_delivery_efficiency_ms != null ? `${formatMsHuman(eff.e1_scenario_delivery_efficiency_ms)}/场景` : '暂无数据'}(场景数:${eff.scenario_count ?? 0}${eff.e1_availability === false ? `,原因:${eff.e1_availability_reason || 'unknown'}` : ''})`,
3557
+ `- 编码时间占比(E2,含流程修正):${formatRatioToPercent(e2IncludingRework)}(不含流程修正:${formatRatioToPercent(eff.e2_coding_time_ratio)})`,
3558
+ `- 写需求、方案和任务的时间占比(E3,含流程修正):${formatRatioToPercent(e3IncludingRework)}(不含流程修正:${formatRatioToPercent(eff.e3_spec_time_ratio)})`,
3559
+ '',
3560
+ '## 质量 Q1/Q3/Q4',
3561
+ `- Q1 规约符合度:${qual.q1_spec_conformance_score ?? 'null'}(状态:${q1Status})`,
3562
+ `- 规约检查得分(Q3):${qual.q3_spec_quality_score ?? '暂无数据'}${qual.q3_spec_quality_score != null ? '/100' : ''}(${qual.q3_score_status === 'verified' ? '已验证' : '临时'},评审独立性:${qual.q3_reviewer_independence || 'unknown'})`,
3563
+ `- Q4 规约驱动测试覆盖率:${formatRatioToPercent(qual.q4_spec_driven_test_coverage)}(状态:${q4Status})`,
3564
+ '',
3565
+ '## 过程 P4/P-R/P-C',
3566
+ `- 编码前是否先检查(P4):${formatRatioToPercent(proc.p4_quality_gate_enforcement_rate)}`,
3567
+ `- 阶段重复次数(P-R):${proc.rework_summary?.total_rework_attempts ?? '暂无数据'}`,
3568
+ `- 步骤是否有始有终(P-C):${formatRatioToPercent(proc.p_c_stage_closure_rate)}`,
3569
+ '',
3570
+ '## 遥测健康 P-H',
3571
+ `- 执行记录是否完整(P-H):${th.p_h_telemetry_health_score ?? '暂无数据'}${th.p_h_telemetry_health_score != null ? '/100' : ''}`,
3572
+ ...(Array.isArray(metrics?.by_change_type) || metrics?.by_change_type
3573
+ ? ['', '## 分层统计与趋势', `- 按变更类型分布:${metrics.by_change_type ? JSON.stringify(metrics.by_change_type) : 'null'}`, `- 未分类变更数:${metrics.unknown_change_type_count ?? 'null'}`]
3574
+ : []),
3575
+ '',
3576
+ '## 预警与成熟度',
3577
+ '### 预警',
3578
+ ...formatAlertsMarkdown(report.alerts),
3579
+ '### 成熟度',
3580
+ ...formatMaturityMarkdown(report.maturity),
3581
+ '',
3582
+ '## 3.3.4.2 方案对齐',
3583
+ ...(report.scheme_compliance?.items || []).map(item => `- ${item.label}:${reportStatusLabel(item.status)}(${item.evidence})`),
2587
3584
  '',
2588
3585
  '## 执行摘要',
2589
3586
  `- Telemetry 健康分:${formatRatioToPercent(health.telemetry_health_score)}(0-100%,反映遥测数据的完整性,扣分项包括未闭环阶段、孤儿事件等)`,
2590
3587
  `- 阶段闭环率:${formatRatioToPercent(health.matched_stage_rate)}(已配对 start/end 的阶段占有效阶段总数的比例)`,
2591
3588
  `- 严重问题数:${health.severe_issues?.length ?? '无数据'}(存在未闭环阶段、孤儿事件或未知命令等阻断级问题的数量)`,
2592
3589
  '',
2593
- '## 效率指标',
3590
+ '<details>',
3591
+ '<summary>历史兼容数据(旧版指标,仅供已有消费者对照)</summary>',
3592
+ '',
3593
+ '## Legacy 效率指标(v2,兼容展示,非主指标)',
2594
3594
  `- E1 需求到归档总时长:${formatMsToSeconds(metrics.efficiency?.e1_lead_time_ms)}`,
2595
3595
  `- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}(仅 apply 阶段;test 阶段耗时另见有效阶段总耗时)`,
2596
3596
  `- E3 规约时间占比:${formatRatioToPercent(metrics.efficiency?.e3_spec_time_ratio)}`,
@@ -2599,13 +3599,13 @@ function renderExecutiveReportMarkdown(report) {
2599
3599
  `- 阶段间等待:${formatMsToSeconds(metrics.efficiency?.idle_time_ms)}`,
2600
3600
  `- 流程修正阶段总耗时:${formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms)}`,
2601
3601
  '',
2602
- '## 质量指标',
3602
+ '## Legacy 质量指标(v2,兼容展示,非主指标)',
2603
3603
  `- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}${metrics.quality?.q1_human_status === 'unverified' ? '(自评,待人工确认)' : ''}`,
2604
3604
  `- Q3 构建一次通过率:${formatRatioToPercent(metrics.quality?.q3_build_first_pass_rate)}`,
2605
3605
  `- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}${metrics.quality?.q4_source === 'conformance-fallback' ? '(近似自 conformance_review)' : ''}`,
2606
3606
  `- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}(取最新 check 的 consistency_score)`,
2607
3607
  '',
2608
- '## 过程指标',
3608
+ '## Legacy 过程指标(v2,兼容展示,非主指标)',
2609
3609
  `- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}${metrics.process?.p1_spec_iteration_source ? `(来源:${metrics.process.p1_spec_iteration_source})` : ''}`,
2610
3610
  `- P2 AI 代码保留率:${formatRatioToPercent(metrics.process?.p2_ai_code_adoption_rate)}`,
2611
3611
  `- P4 质量门前置率:${formatRatioToPercent(metrics.process?.p4_quality_gate_enforcement_rate)}`,
@@ -2614,6 +3614,8 @@ function renderExecutiveReportMarkdown(report) {
2614
3614
  `- 重复执行原因分布:${formatReworkReasons(metrics.process?.rework_summary?.reasons)}`,
2615
3615
  ...formatReworkReasonDetails(metrics.process?.rework_summary?.reasons),
2616
3616
  '',
3617
+ '</details>',
3618
+ '',
2617
3619
  '## 变更文件',
2618
3620
  `- 数据可用:${report.changed_files?._available ? '是' : '否'}`,
2619
3621
  `- 变更文件数:${report.changed_files?.files_changed ?? 'null'}${report.changed_files?.files_diff_note ? `(${report.changed_files.files_diff_note})` : ''}`,
@@ -2656,8 +3658,12 @@ function renderExecutiveReportMarkdown(report) {
2656
3658
  '',
2657
3659
  '## 产物清单',
2658
3660
  `- 数据可用:${report.artifacts?._available ? '是' : '否'}`,
2659
- `- 归档目录:${report.artifacts?.archive_path ?? 'null'}`,
2660
- `- 归档清单:${report.artifacts?.manifest_path ?? 'null'}`,
3661
+ ...((report.artifacts?.files && Object.keys(report.artifacts.files).length > 0)
3662
+ ? Object.entries(report.artifacts.files).map(([name, item]) => `- ${name}:${item.path ?? 'null'}(${item.exists ? '存在' : '缺失'})`)
3663
+ : [
3664
+ `- 归档目录:${report.artifacts?.archive_path ?? 'null'}`,
3665
+ `- 归档清单:${report.artifacts?.manifest_path ?? 'null'}`,
3666
+ ]),
2661
3667
  `- 迁移文件数:${Array.isArray(report.artifacts?.copied_specs) ? report.artifacts.copied_specs.length : 'null'}`,
2662
3668
  '',
2663
3669
  '## 归档结果',
@@ -2683,15 +3689,35 @@ function renderExecutiveReportMarkdown(report) {
2683
3689
  }
2684
3690
 
2685
3691
  function renderExecutiveReportHtml(report) {
3692
+ if (report?.level === 'change' && report?.change) {
3693
+ return require('./reporting/change-report-renderer.cjs').renderChangeReportHtml(report);
3694
+ }
2686
3695
  const metrics = report.metrics || {};
2687
3696
  const eff = metrics.efficiency || {};
2688
3697
  const qual = metrics.quality || {};
2689
3698
  const proc = metrics.process || {};
3699
+ const th = metrics.telemetry_health || {};
2690
3700
  const health = report.doctor || {};
2691
3701
  const archive = report.archive_result || null;
2692
3702
  const taskCompletion = archive?.task_completion || null;
2693
3703
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
2694
3704
 
3705
+ // V3 主指标衍生值
3706
+ const v3Eff = metrics.v3?.efficiency || eff;
3707
+ const e2IncludingRework = eff.e2_coding_time_ratio_including_rework ?? eff.e2_coding_time_ratio;
3708
+ const e3IncludingRework = eff.e3_spec_time_ratio_including_rework ?? eff.e3_spec_time_ratio;
3709
+ const q1Status = qual.q1_status || (qual.q1_spec_conformance_score != null ? 'available' : 'pending-upstream');
3710
+ const q4Status = qual.q4_status || (qual.q4_spec_driven_test_coverage != null ? 'available' : 'pending-upstream');
3711
+ const minMetricSet = report.minimum_metric_set || null;
3712
+ const alertsList = Array.isArray(report.alerts) ? report.alerts : [];
3713
+ const alertsHtml = alertsList.length > 0
3714
+ ? `<h3 style="margin:0 0 6px">预警</h3><div class="reasons">${alertsList.map(a => `<div class="reason-item"><span class="reason-tag ${a.level === 'red' ? 'unfinished' : 'rework'}">${esc((a.level || 'info').toUpperCase())}</span><span class="reason-stage">${esc(a.metric || '')}</span><span class="reason-note">${esc(a.reason || '')}</span></div>`).join('')}</div>`
3715
+ : `<h3 style="margin:0 0 6px">预警</h3><p class="muted">无预警</p>`;
3716
+ const maturityInfo = report.maturity || null;
3717
+ const maturityHtml = maturityInfo
3718
+ ? `<h3 style="margin:14px 0 6px">成熟度</h3><p>当前等级 <strong>${esc(maturityInfo.current_level)}</strong>(可达上限 ${esc(maturityInfo.eligible_max_level)})</p>`
3719
+ : `<h3 style="margin:14px 0 6px">成熟度</h3><p class="muted">数据不可用</p>`;
3720
+
2695
3721
  // T2.5: 分钟单位时长(<60 分无副,≥60 分加「≈X小时Y分钟」副)
2696
3722
  const durHtml = (ms) => {
2697
3723
  const d = formatDuration(ms);
@@ -2702,12 +3728,27 @@ function renderExecutiveReportHtml(report) {
2702
3728
  if (ratio == null || !Number.isFinite(ratio)) return '<span class="metric-val muted-null">null</span>';
2703
3729
  return `${(ratio * 100).toFixed(1)}<span class="unit">%</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
2704
3730
  };
3731
+ const pctHtmlFriendly = (ratio, sub) => (
3732
+ ratio == null || !Number.isFinite(ratio)
3733
+ ? '<span class="metric-val muted-null">暂无数据</span>'
3734
+ : pctHtml(ratio, sub)
3735
+ );
2705
3736
  const numHtml = (v, unit, sub) => {
2706
3737
  if (v == null) return '<span class="metric-val muted-null">null</span>';
2707
3738
  return `${esc(v)}${unit ? `<span class="unit">${esc(unit)}</span>` : ''}${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
2708
3739
  };
2709
- // 指标卡:name + + 通俗化说明(info-btn 点击弹窗,不泄露内部术语)
2710
- 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>`;
3740
+ // 指标卡:真实 button 打开可访问对话框;打印/无 JS title 仍保留关键解释。
3741
+ const metricCell = (name, valHtml, info) => {
3742
+ const explanation = info
3743
+ ? `${info} 本次可信状态会根据数据来源、复核独立性和缺失项标记为“已验证 / 临时 / 缺失”。关注异常下降或长期缺失;“查看证据”可定位到对应阶段和事件。`
3744
+ : '';
3745
+ return `<div class="metric"><div class="metric-head"><span class="metric-name">${esc(name)}</span>${explanation ? `<button type="button" class="info-btn" aria-haspopup="dialog" data-title="${esc(name)}" data-info="${esc(explanation)}" title="${esc(explanation)}">?</button>` : ''}</div><div class="metric-val">${valHtml}</div></div>`;
3746
+ };
3747
+ const numHtmlFriendly = (v, unit, sub) => (
3748
+ v == null
3749
+ ? '<span class="metric-val muted-null">暂无数据</span>'
3750
+ : numHtml(v, unit, sub)
3751
+ );
2711
3752
 
2712
3753
  // 健康分环形仪表盘
2713
3754
  const hs = health.telemetry_health_score;
@@ -2735,7 +3776,7 @@ function renderExecutiveReportHtml(report) {
2735
3776
  const barCls = failed ? 'bad' : (short ? 'short' : 'ok');
2736
3777
  const round = n.round || 1;
2737
3778
  const nameSuffix = round > 1 ? '²' : '';
2738
- const nameCls = failed ? ' style="color:#fca5a5"' : '';
3779
+ const nameCls = failed ? ' style="color:#a72c2c"' : '';
2739
3780
  const durMin = dur > 0 ? Math.round(dur / 60000) : 0;
2740
3781
  const durLabel = durMin > 0 ? `${durMin}分` : '&lt;1分';
2741
3782
  const taskLabel = n.task_count > 0 ? `·${n.task_count}任务` : '';
@@ -2760,11 +3801,11 @@ function renderExecutiveReportHtml(report) {
2760
3801
  const resolved = w.resolved === true;
2761
3802
  const statusText = resolved ? '已修复' : '待确认';
2762
3803
  const statusStyle = resolved
2763
- ? 'background:rgba(34,197,94,.16);color:#86efac'
2764
- : 'background:rgba(250,204,21,.16);color:#fde68a';
3804
+ ? 'background:#e9f7ee;color:#116b32'
3805
+ : 'background:#fff3db;color:#92530a';
2765
3806
  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>`;
2766
3807
  }).join('')
2767
- : `<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>`;
3808
+ : `<div class="warn-item"><div class="warn-icon" style="background:#edf4ff;color:#2459a8">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:#e9f7ee;color:#116b32">无需修复</span></div>`;
2768
3809
 
2769
3810
  // 过程:重复执行原因
2770
3811
  const rs = proc.rework_summary || {};
@@ -2811,35 +3852,53 @@ function renderExecutiveReportHtml(report) {
2811
3852
  const cf = report.changed_files || {};
2812
3853
  const arts = report.artifacts || {};
2813
3854
  const movedCount = Array.isArray(arts.copied_specs) ? arts.copied_specs.length : null;
3855
+ const trustRate = minMetricSet?.trusted_coverage_rate ?? 0;
3856
+ const numericRate = minMetricSet?.numeric_coverage_rate ?? minMetricSet?.coverage_rate ?? 0;
3857
+ const evidenceOpen = report.evidence_alerts?.open || [];
3858
+ const evidenceLabel = (warning) => ({
3859
+ apply_test_missing: '缺少任务测试证据',
3860
+ tdd_red_missing: 'TDD 缺少有效 RED 证据',
3861
+ process_note_missing: '缺少过程记录',
3862
+ task_update_reuse: '测试结果被重复引用',
3863
+ TASK_UPDATE_MISSING: '已完成任务缺少状态事件',
3864
+ TASK_TEST_EVIDENCE_MISSING: '已完成任务缺少有效验证',
3865
+ TDD_RED_GREEN_INCOMPLETE: 'TDD 缺少 RED→GREEN 证据',
3866
+ LEGACY_TASK_PARSER: '任务文档仍使用旧格式',
3867
+ }[warning] || warning || '证据问题');
3868
+ const needAttentionHtml = evidenceOpen.length > 0
3869
+ ? evidenceOpen.map(alert => `<li><span class="status-text ${alert.severity === 'error' ? 'bad' : 'warn'}">${alert.severity === 'error' ? '阻断' : '提醒'}</span><strong>${esc(evidenceLabel(alert.warning))}</strong><span>${esc(alert.message || alert.subject || '本次变更')}${alert.occurrences > 1 ? ` · 出现 ${esc(alert.occurrences)} 次` : ''}</span></li>`).join('')
3870
+ : '<li class="empty-state">当前没有未解决的证据问题。</li>';
3871
+ const complianceItems = report.scheme_compliance?.items || [];
3872
+ const complianceHtml = complianceItems.map(item => `<tr><th>${esc(item.label)}</th><td><span class="status-text ${item.status === 'implemented' ? 'ok' : (item.status === 'provisional' ? 'warn' : '')}">${esc(reportStatusLabel(item.status))}</span><div class="muted-2">${esc(item.evidence)}</div></td></tr>`).join('');
2814
3873
 
2815
3874
  return `<!doctype html>
2816
- <html lang="zh-CN">
3875
+ <html lang="zh-CN" data-theme="light">
2817
3876
  <head>
2818
3877
  <meta charset="utf-8" />
2819
3878
  <meta name="viewport" content="width=device-width, initial-scale=1" />
2820
3879
  <title>SDD 效果度量报告${report.change ? ` - ${esc(report.change)}` : ''}</title>
2821
3880
  <style>
2822
3881
  :root{
2823
- --bg:#0b1020;--card-bg:rgba(255,255,255,.05);--card-bg-2:rgba(255,255,255,.03);
2824
- --border:rgba(255,255,255,.1);--border-2:rgba(255,255,255,.06);
2825
- --text:#e5e7eb;--muted:#8b97a7;--muted-2:#64748b;
2826
- --ok:#22c55e;--warn:#f59e0b;--bad:#ef4444;--accent:#60a5fa;
3882
+ --bg:#f3f6fb;--card-bg:#fff;--card-bg-2:#f8fafc;
3883
+ --border:#dbe3ef;--border-2:#e8edf4;
3884
+ --text:#172033;--muted:#5e6c82;--muted-2:#7b8798;
3885
+ --ok:#16803a;--warn:#b66a08;--bad:#c73737;--accent:#2768d7;
2827
3886
  }
2828
3887
  *{box-sizing:border-box}
2829
- 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}
2830
- main{max-width:1080px;margin:0 auto;padding:20px}
2831
- .card{background:var(--card-bg);border:1px solid var(--border);border-radius:12px;padding:16px 18px;margin:12px 0;position:relative}
2832
- h1{margin:0;font-size:22px;font-weight:600}
2833
- h2{margin:0 0 14px;font-size:15px;font-weight:600;color:#cbd5e1;display:flex;align-items:center;gap:8px}
2834
- h2::before{content:"";width:3px;height:14px;background:var(--accent);border-radius:2px}
2835
- h3{margin:0;font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
3888
+ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei','PingFang SC',sans-serif;margin:0;background:var(--bg);color:var(--text);line-height:1.6;font-size:14px}
3889
+ main{max-width:1180px;margin:0 auto;padding:28px 22px 48px}
3890
+ .card{background:var(--card-bg);border:1px solid var(--border);border-radius:16px;padding:20px 22px;margin:14px 0;position:relative;box-shadow:0 8px 28px rgba(41,58,86,.06)}
3891
+ h1{margin:0;font-size:26px;font-weight:720;letter-spacing:-.02em}
3892
+ h2{margin:0 0 14px;font-size:17px;font-weight:680;color:#25324a;display:flex;align-items:center;gap:8px}
3893
+ h2::before{content:"";width:4px;height:16px;background:var(--accent);border-radius:3px}
3894
+ h3{margin:0;font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.03em}
2836
3895
  .muted{color:var(--muted)}
2837
3896
  .muted-2{color:var(--muted-2);font-size:11px}
2838
- code{color:#bfdbfe;background:rgba(96,165,250,.1);padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
3897
+ code{color:#2459a8;background:#edf4ff;padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
2839
3898
  .badge{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:600;font-size:11px}
2840
- .badge.ok{background:rgba(34,197,94,.16);color:#86efac}
2841
- .badge.warn{background:rgba(245,158,11,.18);color:#fcd34d}
2842
- .badge.bad{background:rgba(239,68,68,.18);color:#fca5a5}
3899
+ .badge.ok{background:#e9f7ee;color:#116b32}
3900
+ .badge.warn{background:#fff3db;color:#92530a}
3901
+ .badge.bad{background:#fdecec;color:#a72c2c}
2843
3902
  table{width:100%;border-collapse:collapse;font-size:13px}
2844
3903
  th,td{border-bottom:1px solid var(--border-2);padding:7px 8px;text-align:left;vertical-align:top}
2845
3904
  th{color:var(--muted);width:38%;font-weight:500}
@@ -2850,11 +3909,11 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2850
3909
  .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}
2851
3910
  .gauge{width:96px;height:96px;position:relative}
2852
3911
  .gauge svg{transform:rotate(-90deg);width:100%;height:100%}
2853
- .gauge-track{fill:none;stroke:rgba(255,255,255,.1);stroke-width:7}
3912
+ .gauge-track{fill:none;stroke:#e5eaf1;stroke-width:7}
2854
3913
  .gauge-fill{fill:none;stroke-width:7;stroke-linecap:round}
2855
3914
  .gauge-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700}
2856
3915
  .big-num{font-size:30px;font-weight:700;line-height:1}
2857
- .big-num.ok{color:#86efac}
3916
+ .big-num.ok{color:var(--ok)}
2858
3917
  .cell-label{font-size:11px;color:var(--muted);margin-top:6px;text-transform:uppercase;letter-spacing:.04em}
2859
3918
  .grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
2860
3919
  .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}
@@ -2864,15 +3923,15 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2864
3923
  .metric-val .unit{font-size:12px;font-weight:400;color:var(--muted);margin-left:2px}
2865
3924
  .metric-val .sub{display:block;font-size:11px;font-weight:400;color:var(--muted-2);margin-top:2px}
2866
3925
  .metric-val.muted-null{color:var(--muted-2);font-size:16px}
2867
- .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}
2868
- .info-btn:hover{background:rgba(96,165,250,.28)}
3926
+ .info-btn{cursor:pointer;color:var(--accent);font-size:12px;font-weight:700;background:#edf4ff;border:1px solid #cfe0fb;border-radius:50%;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,box-shadow .15s;line-height:1}
3927
+ .info-btn:hover,.info-btn:focus-visible{background:#dceafe;box-shadow:0 0 0 3px rgba(39,104,215,.16);outline:none}
2869
3928
  .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}
2870
3929
  .modal-mask.show{display:flex}
2871
- .modal{background:#131a2e;border:1px solid var(--border);border-radius:12px;max-width:440px;width:100%;padding:18px 20px}
2872
- .modal h4{margin:0 0 8px;font-size:14px;color:#cbd5e1}
3930
+ .modal{background:#fff;border:1px solid var(--border);border-radius:16px;max-width:560px;width:100%;padding:22px 24px;box-shadow:0 24px 80px rgba(17,32,58,.22)}
3931
+ .modal h4{margin:0 0 8px;font-size:18px;color:#25324a}
2873
3932
  .modal p{margin:0;font-size:13px;color:var(--text);line-height:1.6}
2874
3933
  .modal .close{margin-top:14px;text-align:right}
2875
- .modal .close button{background:rgba(96,165,250,.16);color:#bfdbfe;border:none;border-radius:6px;padding:5px 16px;font-size:12px;cursor:pointer}
3934
+ .modal .close button{background:#2768d7;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:13px;cursor:pointer}
2876
3935
  .timeline-h{display:flex;align-items:stretch;gap:0;overflow-x:auto;padding:4px 0 6px}
2877
3936
  .tl-node{flex:0 0 auto;min-width:88px;position:relative;display:flex;flex-direction:column;align-items:center;padding:0 2px}
2878
3937
  .tl-bar-wrap{width:100%;height:54px;display:flex;align-items:flex-end;justify-content:center}
@@ -2882,7 +3941,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2882
3941
  .tl-bar.short{background:rgba(34,197,94,.35);min-height:6px}
2883
3942
  .tl-dot{width:10px;height:10px;border-radius:50%;margin-top:6px;background:var(--ok);border:2px solid var(--bg)}
2884
3943
  .tl-dot.bad{background:var(--bad)}
2885
- .tl-name{font-size:11px;color:#cbd5e1;margin-top:6px;font-weight:500;text-align:center;line-height:1.2}
3944
+ .tl-name{font-size:11px;color:#34425a;margin-top:6px;font-weight:600;text-align:center;line-height:1.2}
2886
3945
  .tl-dur{font-size:10px;color:var(--muted);margin-top:2px;text-align:center}
2887
3946
  .tl-time{font-size:9px;color:var(--muted-2);margin-top:1px;text-align:center;white-space:nowrap;letter-spacing:-.02em}
2888
3947
  .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}
@@ -2894,29 +3953,46 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2894
3953
  .tl-legend .r-demo{background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;font-size:10px}
2895
3954
  .reasons{display:flex;flex-direction:column;gap:6px;margin-top:10px}
2896
3955
  .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}
2897
- .reason-stage{font-weight:600;color:#cbd5e1;min-width:60px}
3956
+ .reason-stage{font-weight:600;color:#34425a;min-width:60px}
2898
3957
  .reason-arrow{color:var(--muted-2)}
2899
3958
  .reason-tag{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500}
2900
- .reason-tag.unfinished{background:rgba(245,158,11,.18);color:#fcd34d}
2901
- .reason-tag.rework{background:rgba(96,165,250,.16);color:#bfdbfe}
3959
+ .reason-tag.unfinished{background:#fff3db;color:#92530a}
3960
+ .reason-tag.rework{background:#eaf2ff;color:#2459a8}
2902
3961
  .reason-note{color:var(--muted);margin-left:auto;font-size:11px}
2903
3962
  .warn-item{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-2);font-size:12px}
2904
3963
  .warn-item:last-child{border-bottom:none}
2905
- .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}
3964
+ .warn-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#fff3db;color:#92530a;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;margin-top:1px}
2906
3965
  .warn-body{flex:1}
2907
- .warn-title{color:#e5e7eb;font-weight:500}
3966
+ .warn-title{color:#25324a;font-weight:600}
2908
3967
  .warn-detail{color:var(--muted);margin-top:2px}
2909
3968
  .warn-status{font-size:11px;padding:2px 8px;border-radius:999px;flex-shrink:0}
2910
- .warn-status.fixed{background:rgba(34,197,94,.16);color:#86efac}
3969
+ .warn-status.fixed{background:#e9f7ee;color:#116b32}
2911
3970
  .two-col{display:grid;grid-template-columns:1fr 1fr;gap:12px}
2912
- @media(max-width:720px){.summary{grid-template-columns:1fr}.two-col{grid-template-columns:1fr}.grid{grid-template-columns:repeat(2,1fr)}}
3971
+ @media(max-width:720px){main{padding:14px}.summary{grid-template-columns:1fr}.two-col{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.header-meta{text-align:left}.card{overflow:hidden}table{display:block;overflow-x:auto;white-space:normal}}
2913
3972
  .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}
2914
- .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}
2915
- .quality-alert .qa-title{font-weight:600;color:#fcd34d;margin-bottom:2px}
2916
- .quality-alert .qa-items div{color:#e5e7eb;line-height:1.6}
3973
+ .quality-alert .qa-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#fff3db;color:#92530a;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;margin-top:1px}
3974
+ .quality-alert .qa-title{font-weight:600;color:#8b520b;margin-bottom:2px}
3975
+ .quality-alert .qa-items div{color:#473719;line-height:1.6}
2917
3976
  .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}
2918
3977
  .footer-note strong{color:var(--muted)}
2919
3978
  .section-tag{font-size:10px;color:var(--muted-2);font-weight:400;margin-left:6px}
3979
+ .conclusion{background:linear-gradient(135deg,#f7fbff,#eef5ff);border-color:#cfe0fb}
3980
+ .conclusion h2{margin-bottom:8px}.conclusion-text{font-size:18px;font-weight:650;color:#203b69;margin:0}
3981
+ .summary-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
3982
+ .summary-card{background:#fff;border:1px solid var(--border);border-radius:13px;padding:16px}
3983
+ .summary-card strong{display:block;font-size:20px;font-variant-numeric:tabular-nums;margin-top:5px}
3984
+ .status-text{display:inline-block;border-radius:999px;padding:2px 8px;margin-right:8px;font-size:11px;font-weight:700;background:#eef2f7;color:#516078}
3985
+ .status-text.ok{background:#e9f7ee;color:#116b32}.status-text.warn{background:#fff3db;color:#92530a}.status-text.bad{background:#fdecec;color:#a72c2c}
3986
+ .attention-list{list-style:none;padding:0;margin:0}.attention-list li{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border-2)}.attention-list li:last-child{border-bottom:0}.attention-list .empty-state{color:var(--muted)}
3987
+ .legacy-appendix{margin:14px 0;border:1px solid var(--border);border-radius:16px;background:#fff}
3988
+ .legacy-appendix>summary{cursor:pointer;padding:16px 22px;font-weight:650;color:#42516a}.legacy-appendix[open]>summary{border-bottom:1px solid var(--border)}
3989
+ .legacy-appendix .card{border:0;border-radius:0;box-shadow:none;margin:0;border-bottom:1px solid var(--border-2)}
3990
+ @media(max-width:720px){.summary-cards{grid-template-columns:1fr}}
3991
+ @media print{
3992
+ body{background:#fff;color:#111;font-size:11pt}main{max-width:none;padding:0}.card,.legacy-appendix{box-shadow:none;break-inside:avoid;border-color:#bbb}
3993
+ .info-btn,.modal-mask{display:none!important}.legacy-appendix>summary{display:none}.legacy-appendix:not([open])>*{display:block}.legacy-appendix:not([open])>summary{display:none}
3994
+ .muted,.muted-2{color:#444}
3995
+ }
2920
3996
  </style>
2921
3997
  </head>
2922
3998
  <body><main>
@@ -2931,6 +4007,78 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2931
4007
  </div>
2932
4008
  </div>
2933
4009
 
4010
+ <div class="card conclusion">
4011
+ <h2>一句话结论</h2>
4012
+ <p class="conclusion-text">${esc(report.conclusion?.text || '当前证据不足,暂不能形成可信结论。')}</p>
4013
+ </div>
4014
+
4015
+ <div class="summary-cards" aria-label="报告摘要">
4016
+ <div class="summary-card"><span class="muted">任务完成</span><strong>${esc(report.conclusion?.task_statement || '尚无结论')}</strong></div>
4017
+ <div class="summary-card"><span class="muted">证据可信度</span><strong>${(trustRate * 100).toFixed(0)}%</strong><span class="muted-2">数值覆盖 ${(numericRate * 100).toFixed(0)}%</span></div>
4018
+ <div class="summary-card"><span class="muted">方案覆盖</span><strong>${esc(report.scheme_compliance?.counts?.implemented ?? 0)}/${esc(complianceItems.length)}</strong><span class="muted-2">按 3.3.4.2 V3.0 逐项检查</span></div>
4019
+ </div>
4020
+
4021
+ <div class="card">
4022
+ <h2>需要处理</h2>
4023
+ <ul class="attention-list">${needAttentionHtml}</ul>
4024
+ </div>
4025
+
4026
+ <div class="card">
4027
+ <h2>最小指标集完整性 <span class="section-tag">E1 · Q3 · P4 · P-R · P-H</span></h2>
4028
+ <div class="grid">
4029
+ ${metricCell('数值覆盖', pctHtml(numericRate, `${minMetricSet?.numeric_available ?? 0}/${minMetricSet?.numeric_total ?? 5}`), '五个最小指标中,已经能计算出数字的比例;有数字不等于已经独立验证。')}
4030
+ ${metricCell('可信覆盖', pctHtml(trustRate, `${minMetricSet?.trusted_available ?? 0}/${minMetricSet?.numeric_total ?? 5}`), '五个最小指标中,证据来源和复核条件均满足要求的比例。')}
4031
+ ${metricCell('临时数据', numHtml(minMetricSet?.provisional ?? 0, '项'), '已有数值但仍需独立复核或补充证据的指标数量。')}
4032
+ ${metricCell('缺失数据', numHtml((minMetricSet?.missing || []).length, '项'), (minMetricSet?.missing || []).join(', ') || '无')}
4033
+ </div>
4034
+ <noscript><div class="footer-note"><strong>指标怎么读:</strong>“数值覆盖”只表示已经算出数字;“可信覆盖”还要求证据来源和独立复核都满足要求;“临时”表示可参考但仍需补证据,“缺失”表示当前不能下结论。</div></noscript>
4035
+ </div>
4036
+
4037
+ <div class="card">
4038
+ <h2>效率 E1/E2/E3 <span class="section-tag">V3 主指标</span></h2>
4039
+ <div class="grid">
4040
+ ${metricCell('每个场景平均用时(E1)', v3Eff.e1_scenario_delivery_efficiency_ms != null ? `${esc(formatMsHuman(v3Eff.e1_scenario_delivery_efficiency_ms))}<span class="unit">/场景</span>` : '<span class="metric-val muted-null">暂无数据</span>', `有效阶段耗时除以场景数。场景数:${v3Eff.scenario_count ?? 0}。没有历史基线时只显示当前值,不推断提效。`)}
4041
+ ${metricCell('编码时间占比(E2)', pctHtmlFriendly(e2IncludingRework, '含流程修正'), '实际编码阶段(含流程修正)占总耗时的比例。')}
4042
+ ${metricCell('写需求、方案和任务的时间占比(E3)', pctHtmlFriendly(e3IncludingRework, '含流程修正'), '需求规划到任务拆解阶段(含流程修正)占总耗时的比例。')}
4043
+ </div>
4044
+ </div>
4045
+
4046
+ <div class="card">
4047
+ <h2>质量 Q1/Q3/Q4 <span class="section-tag">V3 主指标</span></h2>
4048
+ <div class="grid">
4049
+ ${metricCell('Q1 规约符合度', numHtmlFriendly(qual.q1_spec_conformance_score, '/1', q1Status === 'pending-upstream' ? '待补数据' : null), '实现与规格的符合程度。')}
4050
+ ${metricCell('规约检查得分(Q3)', qual.q3_spec_quality_score != null ? numHtml(qual.q3_spec_quality_score, '/100', `${qual.q3_score_status === 'verified' ? '已验证' : '临时'}·${qual.q3_reviewer_independence || 'unknown'}`) : '<span class="metric-val muted-null">暂无数据</span>', '基于最新一次检查结果计算;只有独立 reviewer 且复核会话与执行会话不同,才计为已验证。')}
4051
+ ${metricCell('Q4 测试覆盖率', pctHtmlFriendly(qual.q4_spec_driven_test_coverage, q4Status === 'pending-upstream' ? '待补数据' : null), '规格中的每条断言被测试用例覆盖的比例。')}
4052
+ </div>
4053
+ </div>
4054
+
4055
+ <div class="card">
4056
+ <h2>过程 P4/P-R/P-C <span class="section-tag">V3 主指标</span></h2>
4057
+ <div class="grid">
4058
+ ${metricCell('编码前是否先检查(P4)', pctHtmlFriendly(proc.p4_quality_gate_enforcement_rate), '进入编码前是否已经完成质量检查。')}
4059
+ ${metricCell('阶段重复次数(P-R)', numHtml(proc.rework_summary?.total_rework_attempts ?? null, '次'), '同一阶段被重复执行的次数。')}
4060
+ ${metricCell('步骤是否有始有终(P-C)', pctHtmlFriendly(proc.p_c_stage_closure_rate), '有开始且有结束记录的步骤,占所有有效步骤的比例。')}
4061
+ </div>
4062
+ </div>
4063
+
4064
+ <div class="card">
4065
+ <h2>遥测健康 P-H <span class="section-tag">V3 主指标</span></h2>
4066
+ <div class="grid">
4067
+ ${metricCell('执行记录是否完整(P-H)', th.p_h_telemetry_health_score != null ? numHtml(th.p_h_telemetry_health_score, '/100') : '<span class="metric-val muted-null">暂无数据</span>', '检查阶段是否闭环、事件是否能配对、命令是否可识别的综合完整性评分。')}
4068
+ </div>
4069
+ </div>
4070
+
4071
+ <div class="card">
4072
+ <h2>预警与成熟度</h2>
4073
+ ${alertsHtml}
4074
+ ${maturityHtml}
4075
+ </div>
4076
+
4077
+ <div class="card">
4078
+ <h2>3.3.4.2 方案对齐</h2>
4079
+ <table>${complianceHtml || '<tr><td class="muted">暂无合规检查结果</td></tr>'}</table>
4080
+ </div>
4081
+
2934
4082
  <div class="card">
2935
4083
  <h2>执行摘要</h2>
2936
4084
  <div class="summary">
@@ -2939,19 +4087,21 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2939
4087
  <div class="cell-label">健康分</div>
2940
4088
  </div>
2941
4089
  <div class="cell">
2942
- <div class="big-num ok">${matchedRate == null ? 'null' : (matchedRate * 100).toFixed(0)}<span style="font-size:18px">%</span></div>
4090
+ <div class="big-num ok">${matchedRate == null ? '暂无数据' : `${(matchedRate * 100).toFixed(0)}<span style="font-size:18px">%</span>`}</div>
2943
4091
  <div class="cell-label">阶段闭环率</div>
2944
4092
  </div>
2945
4093
  <div class="cell">
2946
- <div class="big-num ${severeCount > 0 ? 'bad' : 'ok'}" style="${severeCount > 0 ? 'color:#fca5a5' : ''}">${severeCount}</div>
4094
+ <div class="big-num ${severeCount > 0 ? 'bad' : 'ok'}" style="${severeCount > 0 ? 'color:#a72c2c' : ''}">${severeCount}</div>
2947
4095
  <div class="cell-label">严重问题</div>
2948
4096
  </div>
2949
4097
  </div>
2950
4098
  ${qualityAlertHtml}
2951
4099
  </div>
2952
4100
 
4101
+ <details class="legacy-appendix">
4102
+ <summary>历史兼容数据(旧版指标,仅供已有消费者对照)</summary>
2953
4103
  <div class="card">
2954
- <h2>效率指标</h2>
4104
+ <h2>Legacy 效率指标 <span class="section-tag">v2,兼容展示</span></h2>
2955
4105
  <div class="grid">
2956
4106
  ${metricCell('E1 总时长', durHtml(eff.e1_lead_time_ms), '从需求规划到归档完成的总耗时,包含阶段间等待和你确认的时间。')}
2957
4107
  ${metricCell('E2 编码时间占比', pctHtml(eff.e2_coding_time_ratio), '实际编码阶段占总有效耗时的比例。')}
@@ -2964,7 +4114,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2964
4114
  </div>
2965
4115
 
2966
4116
  <div class="card">
2967
- <h2>质量指标</h2>
4117
+ <h2>Legacy 质量指标 <span class="section-tag">v2,兼容展示</span></h2>
2968
4118
  <div class="grid">
2969
4119
  ${metricCell('Q1 规约符合度', numHtml(qual.q1_spec_conformance_score, '/1', qual.q1_human_status === 'unverified' ? '自评·待人工确认' : null), '实现与规格的符合程度,由 AI 评审断言判定。仅供参考,不作强制门禁。')}
2970
4120
  ${metricCell('Q3 构建一次通过率', pctHtml(qual.q3_build_first_pass_rate), '编码阶段第一次构建就成功的比例。')}
@@ -2974,7 +4124,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2974
4124
  </div>
2975
4125
 
2976
4126
  <div class="card">
2977
- <h2>过程指标 <span class="section-tag">含过程记录状态</span></h2>
4127
+ <h2>Legacy 过程指标 <span class="section-tag">v2,兼容展示 · 含过程记录状态</span></h2>
2978
4128
  <div class="grid">
2979
4129
  ${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 提交,所以缺失。')}
2980
4130
  ${metricCell('P2 AI 代码保留率', pctHtml(proc.p2_ai_code_adoption_rate), 'AI 生成的代码最终保留了多少比例。需要 Git 对比来计算,本次没有 Git 提交,所以缺失。')}
@@ -2985,6 +4135,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
2985
4135
  </div>
2986
4136
  ${reasonHtml ? `<h3 style="margin:14px 0 4px">重复执行原因分布</h3>${reasonHtml}<div class="muted-2" style="margin-top:8px">其余归类:${esc(catLine)}</div>` : ''}
2987
4137
  </div>
4138
+ </details>
2988
4139
 
2989
4140
  <div class="card">
2990
4141
  <h2>时间线 <span class="section-tag">横向 · 含起止时间${tlNodes.length > 0 ? ` · ${tlNodes.length} 个阶段执行` : ''}</span></h2>
@@ -3005,7 +4156,9 @@ tr:last-child td,tr:last-child th{border-bottom:none}
3005
4156
  <tr><th>归档原因</th><td>${esc(archive?.reason || 'null')}</td></tr>
3006
4157
  <tr><th>归档方式</th><td>${esc(archive?.method || 'null')}</td></tr>
3007
4158
  <tr><th>归档目录</th><td><code>${esc(archive?.archive_path || arts.archive_path || 'null')}</code></td></tr>
3008
- <tr><th>最终报告</th><td><code>${esc(archive?.report_path || 'null')}</code></td></tr>
4159
+ <tr><th>最终报告 Markdown</th><td><code>${esc(archive?.report_path || 'null')}</code></td></tr>
4160
+ <tr><th>最终报告 HTML</th><td><code>${esc(archive?.report_html_path || 'null')}</code></td></tr>
4161
+ <tr><th>最终报告 JSON</th><td><code>${esc(archive?.report_json_path || 'null')}</code></td></tr>
3009
4162
  </table>
3010
4163
  </div>
3011
4164
  </div>
@@ -3022,9 +4175,10 @@ tr:last-child td,tr:last-child th{border-bottom:none}
3022
4175
  </main>
3023
4176
 
3024
4177
  <div class="modal-mask" id="modalMask">
3025
- <div class="modal">
4178
+ <div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalBody" tabindex="-1">
3026
4179
  <h4 id="modalTitle">说明</h4>
3027
4180
  <p id="modalBody"></p>
4181
+ <p class="muted-2" style="margin-top:12px">查看证据:请在“时间线”“需要处理”及 JSON 报告的事件引用中定位对应阶段。</p>
3028
4182
  <div class="close"><button id="modalClose">关闭</button></div>
3029
4183
  </div>
3030
4184
  </div>
@@ -3034,15 +4188,32 @@ tr:last-child td,tr:last-child th{border-bottom:none}
3034
4188
  var mask=document.getElementById('modalMask');
3035
4189
  var titleEl=document.getElementById('modalTitle');
3036
4190
  var bodyEl=document.getElementById('modalBody');
3037
- function open(title,body){titleEl.textContent=title||'说明';bodyEl.textContent=body||'';mask.classList.add('show');}
3038
- function close(){mask.classList.remove('show');}
4191
+ var modal=mask.querySelector('.modal');
4192
+ var lastFocus=null;
4193
+ function open(title,body){lastFocus=document.activeElement;titleEl.textContent=title||'说明';bodyEl.textContent=body||'';mask.classList.add('show');modal.focus();}
4194
+ function close(){if(!mask.classList.contains('show'))return;mask.classList.remove('show');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
3039
4195
  document.addEventListener('click',function(e){
3040
4196
  var btn=e.target.closest('.info-btn,.tl-repeat');
3041
4197
  if(btn){e.stopPropagation();open(btn.dataset.title,btn.dataset.info);return;}
3042
4198
  if(!e.target.closest('.modal')) close();
3043
4199
  });
3044
4200
  document.getElementById('modalClose').addEventListener('click',close);
3045
- document.addEventListener('keydown',function(e){if(e.key==='Escape') close();});
4201
+ document.addEventListener('keydown',function(e){
4202
+ if(e.key==='Escape'){close();return;}
4203
+ if(e.key==='Tab'&&mask.classList.contains('show')){
4204
+ var focusable=modal.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])');
4205
+ if(!focusable.length){e.preventDefault();modal.focus();return;}
4206
+ var first=focusable[0],last=focusable[focusable.length-1];
4207
+ if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
4208
+ else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
4209
+ }
4210
+ });
4211
+ var printOpened=[];
4212
+ window.addEventListener('beforeprint',function(){
4213
+ printOpened=[];
4214
+ document.querySelectorAll('details').forEach(function(item){if(!item.open){printOpened.push(item);item.open=true;}});
4215
+ });
4216
+ window.addEventListener('afterprint',function(){printOpened.forEach(function(item){item.open=false;});printOpened=[];});
3046
4217
  })();
3047
4218
  </script>
3048
4219
  </body></html>`;
@@ -3054,53 +4225,6 @@ function extractChangedFiles(events, projectRoot) {
3054
4225
  const latest = latestByTimestamp(adoptionEvents);
3055
4226
  const review = latest ? getAiAdoptionReview(latest) : null;
3056
4227
  const aiDiff = review?.ai_diff || null;
3057
- const hasFiles = Array.isArray(aiDiff?.files) && aiDiff.files.length > 0;
3058
- // P0-4: ai_diff 无效时,从 assertions[].files 聚合兜底(补救存量 ai_adoption_review 无 ai_diff 的事件)
3059
- if (!aiDiff || (aiDiff.files_changed == null && aiDiff.added_lines == null && !hasFiles)) {
3060
- if (latest?.details) {
3061
- const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
3062
- const rawAssertions = rawReview.assertions;
3063
- if (Array.isArray(rawAssertions)) {
3064
- const filesFromAssertions = new Set();
3065
- for (const a of rawAssertions) {
3066
- if (Array.isArray(a.files)) {
3067
- for (const f of a.files) {
3068
- if (typeof f === 'string' && f.trim()) filesFromAssertions.add(f.trim());
3069
- }
3070
- }
3071
- }
3072
- if (filesFromAssertions.size > 0) {
3073
- return { _available: true, files_changed: filesFromAssertions.size, added_lines: null, files: [...filesFromAssertions] };
3074
- }
3075
- }
3076
- }
3077
- return { _available: false, files_changed: null, added_lines: null, files: null };
3078
- }
3079
- let addedLines = Number.isFinite(aiDiff.added_lines) ? aiDiff.added_lines : null;
3080
- const files = hasFiles ? aiDiff.files.slice() : null;
3081
- // T3.3: ai_git_sha=null(无 git)时,用 fs 读 files 行数作 added_lines fallback(DC-104 无 git/路径缺失不崩溃)
3082
- if (addedLines == null && review && review.ai_git_sha == null && hasFiles && projectRoot) {
3083
- let sum = 0;
3084
- let counted = 0;
3085
- const projectRootResolved = path.resolve(projectRoot);
3086
- for (const rel of aiDiff.files) {
3087
- try {
3088
- // Y1+Y6 修复:校验路径不穿越 projectRoot + 用 split 替代 match 减少正则数组开销
3089
- const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(projectRoot, rel);
3090
- if (!abs.startsWith(projectRootResolved + path.sep) && abs !== projectRootResolved) continue;
3091
- const content = fs.readFileSync(abs, 'utf8');
3092
- const newlines = content.split('\n').length - 1;
3093
- sum += newlines + (content.length > 0 && !content.endsWith('\n') ? 1 : 0);
3094
- counted += 1;
3095
- } catch {
3096
- // 文件不存在/不可读,跳过
3097
- }
3098
- }
3099
- if (counted > 0) addedLines = sum;
3100
- }
3101
- // A6(v3): task_update 汇总文件数优先于 ai_diff 自报值(ai_diff 是 agent 一次性快照,
3102
- // task_update 累加去重更准);两者不一致时 ai_diff 作辅助并标注差异。
3103
- // 兼容 task_update.details.files / details.test_results.files / details.files_changed(数组形态)
3104
4228
  const taskUpdateFiles = new Set();
3105
4229
  for (const e of events) {
3106
4230
  if (e?.type !== 'task_update') continue;
@@ -3113,27 +4237,81 @@ function extractChangedFiles(events, projectRoot) {
3113
4237
  }
3114
4238
  }
3115
4239
  }
4240
+ const finalOutputFiles = new Set(
4241
+ Array.isArray(aiDiff?.files)
4242
+ ? aiDiff.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4243
+ : [],
4244
+ );
4245
+ if (finalOutputFiles.size === 0 && latest?.details) {
4246
+ const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
4247
+ for (const assertion of rawReview.assertions || []) {
4248
+ for (const file of assertion.files || []) {
4249
+ if (typeof file === 'string' && file.trim()) finalOutputFiles.add(file.trim());
4250
+ }
4251
+ }
4252
+ }
4253
+
3116
4254
  const taskUpdateFileCount = taskUpdateFiles.size;
3117
- const aiDiffFileCount = Number.isFinite(aiDiff.files_changed) ? aiDiff.files_changed : (files ? files.length : null);
3118
- let filesChanged;
3119
- let aiDiffFilesChanged = null;
3120
- let filesDiffNote = null;
3121
- if (taskUpdateFileCount > 0) {
3122
- filesChanged = taskUpdateFileCount;
3123
- if (aiDiffFileCount != null && aiDiffFileCount !== taskUpdateFileCount) {
3124
- aiDiffFilesChanged = aiDiffFileCount;
3125
- filesDiffNote = `ai_diff 自报 ${aiDiffFileCount} 与 task_update 汇总 ${taskUpdateFileCount} 不一致,已采用 task_update 汇总值`;
4255
+ const finalOutputFileCount = Number.isFinite(aiDiff?.files_changed)
4256
+ ? aiDiff.files_changed
4257
+ : (finalOutputFiles.size > 0 ? finalOutputFiles.size : null);
4258
+ const snapshotKind = aiDiff?.snapshot_kind || null;
4259
+ const finalOutputStatus = snapshotKind === 'final' ? 'final' : (finalOutputFileCount == null ? 'unavailable' : 'provisional');
4260
+ const addedLines = Number.isFinite(aiDiff?.added_lines)
4261
+ ? aiDiff.added_lines
4262
+ : (Number.isFinite(review?.final_diff?.added_lines) ? review.final_diff.added_lines : null);
4263
+ const deletedLines = Number.isFinite(aiDiff?.deleted_lines)
4264
+ ? aiDiff.deleted_lines
4265
+ : (Number.isFinite(review?.final_diff?.deleted_lines) ? review.final_diff.deleted_lines : null);
4266
+ let lineCountEstimate = null;
4267
+ if (addedLines == null && review?.ai_git_sha == null && finalOutputFiles.size > 0 && projectRoot) {
4268
+ let lines = 0;
4269
+ let countedFiles = 0;
4270
+ const root = path.resolve(projectRoot);
4271
+ for (const relativePath of finalOutputFiles) {
4272
+ try {
4273
+ const absolutePath = path.isAbsolute(relativePath)
4274
+ ? path.resolve(relativePath)
4275
+ : path.resolve(root, relativePath);
4276
+ if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) continue;
4277
+ const content = fs.readFileSync(absolutePath, 'utf8');
4278
+ lines += content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
4279
+ countedFiles += 1;
4280
+ } catch {
4281
+ // 无 Git 时只统计可安全读取的当前文件;不可读文件不伪造行数。
4282
+ }
4283
+ }
4284
+ if (countedFiles > 0) {
4285
+ lineCountEstimate = {
4286
+ current_lines: lines,
4287
+ counted_files: countedFiles,
4288
+ note: '无 Git diff,仅统计当前文件总行数;不能解释为新增行数。',
4289
+ };
3126
4290
  }
3127
- } else {
3128
- filesChanged = aiDiffFileCount;
3129
4291
  }
4292
+ const filesChanged = taskUpdateFileCount > 0 ? taskUpdateFileCount : finalOutputFileCount;
4293
+ const countsDiffer = taskUpdateFileCount > 0
4294
+ && finalOutputFileCount != null
4295
+ && taskUpdateFileCount !== finalOutputFileCount;
4296
+ const reconciliationNote = countsDiffer
4297
+ ? `任务事件累计涉及 ${taskUpdateFileCount} 个文件;最终输出快照包含 ${finalOutputFileCount} 个文件。两者用途不同,不应合并为一个口径。`
4298
+ : (filesChanged == null ? '没有可用的文件变更证据。' : '任务事件与最终输出文件口径一致。');
3130
4299
  return {
3131
- _available: true,
4300
+ _available: filesChanged != null,
3132
4301
  files_changed: filesChanged,
3133
4302
  added_lines: addedLines,
3134
- files,
3135
- ai_diff_files_changed: aiDiffFilesChanged,
3136
- files_diff_note: filesDiffNote,
4303
+ deleted_lines: deletedLines,
4304
+ files: finalOutputFiles.size > 0 ? [...finalOutputFiles] : null,
4305
+ task_event_files: taskUpdateFileCount > 0 ? [...taskUpdateFiles] : null,
4306
+ task_event_files_changed: taskUpdateFileCount > 0 ? taskUpdateFileCount : null,
4307
+ final_output_files: finalOutputFiles.size > 0 ? [...finalOutputFiles] : null,
4308
+ final_output_files_changed: finalOutputFileCount,
4309
+ final_output_status: finalOutputStatus,
4310
+ diff_source: snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'),
4311
+ line_count_estimate: lineCountEstimate,
4312
+ reconciliation_note: reconciliationNote,
4313
+ ai_diff_files_changed: countsDiffer ? finalOutputFileCount : null,
4314
+ files_diff_note: countsDiffer ? reconciliationNote : null,
3137
4315
  };
3138
4316
  }
3139
4317
 
@@ -3217,19 +4395,34 @@ function extractStageStatus(metrics) {
3217
4395
  function extractKnownRisks(events, doctor, archiveResult) {
3218
4396
  const severeIssues = doctor?.severe_issues || [];
3219
4397
  const warnings = [];
3220
- // Q7: 从 archive task_completion 判断 warn 是否已修复(incomplete=0 → resolved)
3221
- const taskCompletion = archiveResult?.task_completion;
3222
- const allResolved = taskCompletion && Number.isFinite(taskCompletion.incomplete) && taskCompletion.incomplete === 0;
3223
4398
  for (const e of events) {
3224
4399
  const cr = e.details?.check_results;
3225
4400
  if (cr && Number.isFinite(cr.warnings) && cr.warnings > 0) {
3226
4401
  // T2.1: 携带 warning_items 明细(若有);旧事件无该字段时降级为仅数量(DC-101/UT-202)
3227
4402
  const entry = { command: e.command || e.stage || null, warnings: cr.warnings };
3228
4403
  if (Array.isArray(cr.warning_items) && cr.warning_items.length > 0) {
3229
- entry.items = cr.warning_items;
4404
+ const dispositionsByWarning = new Map((cr.warning_dispositions || []).map(item => [
4405
+ item.warning || item.id || item.code,
4406
+ item,
4407
+ ]));
4408
+ entry.items = cr.warning_items.map(item => {
4409
+ const disposition = dispositionsByWarning.get(item.warning || item.id || item.code);
4410
+ return disposition
4411
+ ? {
4412
+ ...item,
4413
+ disposition: disposition.disposition,
4414
+ disposition_reason: disposition.reason || null,
4415
+ }
4416
+ : item;
4417
+ });
4418
+ }
4419
+ const dispositions = Array.isArray(cr.warning_dispositions) ? cr.warning_dispositions : [];
4420
+ if (dispositions.length > 0) {
4421
+ entry.warning_dispositions = dispositions;
4422
+ entry.resolved = dispositions.every(item => (
4423
+ ['fixed', 'accepted', 'waived'].includes(item.disposition)
4424
+ ));
3230
4425
  }
3231
- // Q7: 归档后 incomplete=0 → 标已修复
3232
- if (allResolved) entry.resolved = true;
3233
4426
  warnings.push(entry);
3234
4427
  }
3235
4428
  const conf = getConformanceReview(e);
@@ -3238,7 +4431,6 @@ function extractKnownRisks(events, doctor, archiveResult) {
3238
4431
  const failed = conf.assertions.filter(a => a.status === 'missed');
3239
4432
  if (failed.length > 0) {
3240
4433
  const confEntry = { command: e.command || 'conformance', failed_assertions: failed.length };
3241
- if (allResolved) confEntry.resolved = true;
3242
4434
  warnings.push(confEntry);
3243
4435
  }
3244
4436
  }
@@ -3307,23 +4499,254 @@ function extractTelemetryWarnings(events) {
3307
4499
  return { _available: true, total: warnings.length, by_type: byType, items };
3308
4500
  }
3309
4501
 
4502
+ function validateMaturityEvidenceDetails(details) {
4503
+ const evidence = details?.maturity_evidence;
4504
+ const invalid = error => ({ valid: false, evidence: null, error });
4505
+ if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
4506
+ return invalid('details.maturity_evidence 必须是对象');
4507
+ }
4508
+
4509
+ const changeType = String(evidence.change_type || '').trim().toLowerCase();
4510
+ if (!metricsV3.CHANGE_TYPES.includes(changeType)) {
4511
+ return invalid(`change_type 必须是 ${metricsV3.CHANGE_TYPES.join('|')} 之一`);
4512
+ }
4513
+ if (!Number.isFinite(evidence.e1_actual_ms) || evidence.e1_actual_ms < 0) {
4514
+ return invalid('e1_actual_ms 必须是大于等于 0 的有限数字');
4515
+ }
4516
+ if (!Number.isFinite(evidence.e1_target_ms) || evidence.e1_target_ms <= 0) {
4517
+ return invalid('e1_target_ms 必须是大于 0 的有限数字');
4518
+ }
4519
+ if (typeof evidence.e1_type_target_met !== 'boolean') {
4520
+ return invalid('e1_type_target_met 必须是布尔值');
4521
+ }
4522
+ const derivedTargetMet = evidence.e1_actual_ms <= evidence.e1_target_ms;
4523
+ if (evidence.e1_type_target_met !== derivedTargetMet) {
4524
+ return invalid('e1_type_target_met 必须与 e1_actual_ms <= e1_target_ms 的计算结果一致');
4525
+ }
4526
+ if (typeof evidence.code_fully_spec_generated !== 'boolean') {
4527
+ return invalid('code_fully_spec_generated 必须是布尔值');
4528
+ }
4529
+
4530
+ if (evidence.code_fully_spec_generated) {
4531
+ const generatedFiles = Array.isArray(evidence.generated_files)
4532
+ ? evidence.generated_files.filter(file => typeof file === 'string' && file.trim())
4533
+ : [];
4534
+ if (generatedFiles.length === 0) {
4535
+ return invalid('code_fully_spec_generated=true 时 generated_files 必须是非空文件路径数组');
4536
+ }
4537
+ if (evidence.manual_code_lines !== 0) {
4538
+ return invalid('code_fully_spec_generated=true 时 manual_code_lines 必须严格等于 0');
4539
+ }
4540
+ if (typeof evidence.evidence_summary !== 'string' || !evidence.evidence_summary.trim()) {
4541
+ return invalid('code_fully_spec_generated=true 时 evidence_summary 必须非空');
4542
+ }
4543
+ if (typeof evidence.reviewer !== 'string' || !evidence.reviewer.trim()) {
4544
+ return invalid('code_fully_spec_generated=true 时 reviewer 必须非空');
4545
+ }
4546
+ if (evidence.reviewer_independence !== 'independent-review') {
4547
+ return invalid('code_fully_spec_generated=true 时 reviewer_independence 必须是 independent-review');
4548
+ }
4549
+ const reviewSessionId = String(evidence.review_session_id || '').trim();
4550
+ const authorSessionId = String(evidence.author_session_id || '').trim();
4551
+ if (!reviewSessionId || !authorSessionId || reviewSessionId === authorSessionId) {
4552
+ return invalid('独立评审要求 review_session_id 与 author_session_id 均非空且不同');
4553
+ }
4554
+ }
4555
+
4556
+ return {
4557
+ valid: true,
4558
+ error: '',
4559
+ evidence: {
4560
+ ...evidence,
4561
+ change_type: changeType,
4562
+ e1_type_target_met: derivedTargetMet,
4563
+ },
4564
+ };
4565
+ }
4566
+
3310
4567
  function buildReport(projectRoot, events, options = {}) {
3311
4568
  const archiveEvent = latestByTimestamp(events.filter(e => {
3312
4569
  return e.command === 'archive' && e.type === 'stage_end' && !e.orphan;
3313
4570
  }));
4571
+ const gitCommitTimeline = resolveGitCommitTimeline(
4572
+ events,
4573
+ projectRoot,
4574
+ options.gitCommitTimeline,
4575
+ );
3314
4576
  const metrics = computePdfMvpMetrics(events, {
3315
4577
  level: options.level || (options.change ? 'change' : 'project'),
3316
4578
  change: options.change,
3317
4579
  capability: options.capability,
3318
4580
  projectRoot,
4581
+ gitCommitTimeline,
3319
4582
  });
3320
- const doctor = computeDoctorReport(events, { change: options.change, projectRoot });
3321
- return {
4583
+ const doctor = computeDoctorReport(events, {
4584
+ change: options.change,
4585
+ projectRoot,
4586
+ gitCommitTimeline,
4587
+ });
4588
+ const level = options.level || (options.change ? 'change' : 'project');
4589
+ const isProjectLevel = level === 'project';
4590
+
4591
+ let alerts = [];
4592
+ let maturity = null;
4593
+ if (isProjectLevel && options.trackingPeriods && options.trackingPeriods.length > 0) {
4594
+ alerts = metricsV3.evaluateAlerts(options.trackingPeriods);
4595
+ }
4596
+ const maturityEvidence = events
4597
+ .filter(e => (
4598
+ !e.orphan
4599
+ && e.type === 'maturity_evidence'
4600
+ && (!options.change || e.change === options.change)
4601
+ ))
4602
+ .sort((a, b) => timestampMs(b) - timestampMs(a))
4603
+ .map(event => validateMaturityEvidenceDetails(event.details).evidence)
4604
+ .find(Boolean) || {};
4605
+ try {
4606
+ maturity = metricsV3.evaluateMaturity({
4607
+ p4_quality_gate_enforcement_rate: metrics.process?.p4_quality_gate_enforcement_rate ?? null,
4608
+ p_c_stage_closure_rate: metrics.process?.p_c_stage_closure_rate ?? null,
4609
+ q1_spec_conformance: metrics.quality?.q1_spec_conformance_score ?? null,
4610
+ q3_spec_quality_score: metrics.quality?.q3_spec_quality_score ?? null,
4611
+ p_r_avg_rework_count: metrics.process?.rework_summary?.avg_rework_count
4612
+ ?? metrics.process?.rework_summary?.total_rework_attempts
4613
+ ?? null,
4614
+ }, {
4615
+ has_sdd_flow: (metrics.change_count ?? (options.change ? 1 : (metrics.by_change?.length || 0))) !== 0,
4616
+ e1_type_target_met: maturityEvidence.e1_type_target_met === true,
4617
+ code_fully_spec_generated: maturityEvidence.code_fully_spec_generated === true,
4618
+ });
4619
+ } catch { maturity = null; }
4620
+
4621
+ const archiveTaskCompletion = archiveEvent?.details?.archive_result?.task_completion || null;
4622
+ const applyEvidenceAudit = options.change && archiveTaskCompletion
4623
+ ? buildApplyEvidenceAudit(options.change, archiveTaskCompletion, events, { mode: 'report-only' })
4624
+ : null;
4625
+ const baseEvidenceAlerts = metricsV3.buildEvidenceAlerts(events);
4626
+ const auditAlerts = (applyEvidenceAudit?.findings || []).map((finding) => ({
4627
+ key: `${finding.code}:${finding.task_id || options.change || 'change'}`,
4628
+ category: 'evidence',
4629
+ warning: finding.code,
4630
+ subject: finding.task_id || options.change || null,
4631
+ message: finding.message,
4632
+ severity: finding.severity || 'warning',
4633
+ status: 'open',
4634
+ occurrences: 1,
4635
+ opened_by_event_id: null,
4636
+ first_seen_at: null,
4637
+ last_seen_event_id: null,
4638
+ last_seen_at: null,
4639
+ resolved_by_event_id: null,
4640
+ resolved_at: null,
4641
+ }));
4642
+ if (archiveTaskCompletion?.primary_tasks?.parser === 'legacy-fallback') {
4643
+ auditAlerts.push({
4644
+ key: `LEGACY_TASK_PARSER:${options.change || 'change'}`,
4645
+ category: 'evidence',
4646
+ warning: 'LEGACY_TASK_PARSER',
4647
+ subject: options.change || null,
4648
+ message: '任务文档仍使用旧复选框格式,报告已兼容读取;建议迁移为每个任务一条“状态”行。',
4649
+ severity: 'warning',
4650
+ status: 'open',
4651
+ occurrences: 1,
4652
+ opened_by_event_id: null,
4653
+ first_seen_at: null,
4654
+ last_seen_event_id: null,
4655
+ last_seen_at: null,
4656
+ resolved_by_event_id: null,
4657
+ resolved_at: null,
4658
+ });
4659
+ }
4660
+ const evidenceHistory = [...baseEvidenceAlerts.history, ...auditAlerts];
4661
+ const evidenceOpen = evidenceHistory.filter(alert => alert.status === 'open');
4662
+ const evidenceResolved = evidenceHistory.filter(alert => alert.status === 'resolved');
4663
+ const evidenceAlerts = {
4664
+ open: evidenceOpen,
4665
+ resolved: evidenceResolved,
4666
+ history: evidenceHistory,
4667
+ counts: {
4668
+ open: evidenceOpen.length,
4669
+ resolved: evidenceResolved.length,
4670
+ total: evidenceHistory.length,
4671
+ },
4672
+ };
4673
+ const latestCheckWithDispositions = latestByTimestamp(
4674
+ getCheckEvents(events).filter(event => (
4675
+ Array.isArray(event.details?.check_results?.warning_dispositions)
4676
+ && event.details.check_results.warning_dispositions.length > 0
4677
+ )),
4678
+ );
4679
+ const checkWarningDispositions = latestCheckWithDispositions
4680
+ ? latestCheckWithDispositions.details.check_results.warning_dispositions.map(item => ({
4681
+ warning: item.warning || item.id || item.code || 'UNKNOWN',
4682
+ disposition: item.disposition || 'open',
4683
+ reason: item.reason || null,
4684
+ evidence_event_id: latestCheckWithDispositions.event_id || null,
4685
+ }))
4686
+ : [];
4687
+ const minimumMetricSet = metricsV3.buildMinimumMetricStatus(metrics);
4688
+ const metricAlerts = alerts;
4689
+ const latestArchiveResult = [...events].reverse()
4690
+ .map(item => item?.details?.archive_result)
4691
+ .find(Boolean) || {};
4692
+ const observedFormats = options.formats || [
4693
+ latestArchiveResult.report_path && 'md',
4694
+ latestArchiveResult.report_html_path && 'html',
4695
+ latestArchiveResult.report_json_path && 'json',
4696
+ ].filter(Boolean);
4697
+ const schemeCompliance = metricsV3.buildSchemeCompliance({
4698
+ minimum_metric_set: minimumMetricSet,
4699
+ metrics,
4700
+ level,
4701
+ period: options.period || null,
4702
+ formats: observedFormats,
4703
+ evidence_alerts: evidenceAlerts,
4704
+ check_warning_dispositions: checkWarningDispositions,
4705
+ event_count: events.length,
4706
+ });
4707
+ const primaryTasks = archiveTaskCompletion?.primary_tasks || null;
4708
+ const taskStatement = primaryTasks
4709
+ ? (primaryTasks.has_incomplete
4710
+ ? `仍有 ${primaryTasks.incomplete} 个主任务未完成`
4711
+ : `主任务已完成 ${primaryTasks.completed}/${primaryTasks.total}`)
4712
+ : '尚未读取到结构化任务状态';
4713
+ const trustRate = minimumMetricSet.trusted_coverage_rate;
4714
+ const trustStatement = trustRate === 1
4715
+ ? '核心指标证据已全部验证'
4716
+ : `核心指标可信覆盖 ${Math.round((trustRate || 0) * 100)}%`;
4717
+ const alertStatement = evidenceAlerts.open.length > 0
4718
+ ? `有 ${evidenceAlerts.open.length} 个证据问题需要处理`
4719
+ : '没有未解决的证据问题';
4720
+
4721
+ const baseReport = {
4722
+ schema_version: metricsV3.REPORT_SCHEMA_VERSION,
4723
+ metrics_contract: metricsV3.METRICS_CONTRACT_VERSION,
3322
4724
  generated_at: nowISO(),
3323
4725
  project_root: projectRoot,
3324
4726
  change: options.change || null,
3325
- level: options.level || (options.change ? 'change' : 'project'),
4727
+ level,
4728
+ window: {
4729
+ period: options.period || null,
4730
+ date_from: options.dateFrom || null,
4731
+ date_to: options.dateTo || null,
4732
+ },
4733
+ minimum_metric_set: minimumMetricSet,
4734
+ measurement_scope: metrics.measurement_scope || null,
4735
+ metric_evidence: metrics.metric_evidence || null,
4736
+ attribution_audit: options.attributionAudit || {
4737
+ linked_source_event_count: 0,
4738
+ active_links: [],
4739
+ unassigned_candidate_event_ids: [],
4740
+ },
4741
+ conclusion: {
4742
+ status: evidenceAlerts.open.length > 0 || trustRate < 1 || primaryTasks?.has_incomplete ? 'attention' : 'trusted',
4743
+ text: `${taskStatement};${trustStatement};${alertStatement}。`,
4744
+ task_statement: taskStatement,
4745
+ trust_statement: trustStatement,
4746
+ alert_statement: alertStatement,
4747
+ },
3326
4748
  metrics,
4749
+ legacy_metrics: metrics.legacy_metrics || null,
3327
4750
  doctor,
3328
4751
  archive_result: archiveEvent?.details?.archive_result || null,
3329
4752
  changed_files: extractChangedFiles(events, projectRoot),
@@ -3334,7 +4757,18 @@ function buildReport(projectRoot, events, options = {}) {
3334
4757
  artifacts: extractArtifacts(archiveEvent),
3335
4758
  process_notes: extractProcessNotes(events),
3336
4759
  telemetry_warnings: extractTelemetryWarnings(events),
4760
+ distribution: isProjectLevel ? (metrics.distribution || null) : null,
4761
+ by_change_type: isProjectLevel ? (metrics.by_change_type || null) : null,
4762
+ by_change: isProjectLevel ? (metrics.by_change || null) : null,
4763
+ alerts,
4764
+ metric_alerts: metricAlerts,
4765
+ evidence_alerts: evidenceAlerts,
4766
+ check_warning_dispositions: checkWarningDispositions,
4767
+ apply_evidence_audit: applyEvidenceAudit,
4768
+ scheme_compliance: schemeCompliance,
4769
+ maturity,
3337
4770
  };
4771
+ return require('./reporting/change-report-model.cjs').buildChangeReportModel(baseReport, events);
3338
4772
  }
3339
4773
 
3340
4774
  function filterEventsByDate(events, dateFrom, dateTo) {
@@ -3353,7 +4787,9 @@ function computeDoctorReport(events, options = {}) {
3353
4787
  ? events.filter(e => e.change === options.change)
3354
4788
  : events;
3355
4789
  const executionSummary = summarizeStageExecutions(scopedEvents);
3356
- const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot);
4790
+ const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot, scopedEvents, {
4791
+ gitCommitTimeline: options.gitCommitTimeline,
4792
+ });
3357
4793
  const stageEvents = scopedEvents.filter(e => e.type === 'stage_start' || e.type === 'stage_end');
3358
4794
  const starts = stageEvents.filter(e => e.type === 'stage_start');
3359
4795
  const ends = stageEvents.filter(e => e.type === 'stage_end');
@@ -3480,6 +4916,15 @@ function cmdStart(args) {
3480
4916
  process.exit(1);
3481
4917
  }
3482
4918
 
4919
+ if (!changeName) {
4920
+ fail('E_CHANGE_REQUIRED: start 必须显式提供 --change');
4921
+ }
4922
+ requireValidChangeName(changeName);
4923
+ if (changeName === 'general'
4924
+ && (command !== 'explore' || !isTrueOption(args['allow-general'] || args.allow_general))) {
4925
+ fail('E_GENERAL_SCOPE_FORBIDDEN: general 仅允许 explore 配合 --allow-general 显式使用');
4926
+ }
4927
+
3483
4928
  const validCommands = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'test', 'archive', 'explore'];
3484
4929
  if (!validCommands.includes(command)) {
3485
4930
  console.error(`错误: 无效的 command "${command}",有效值: ${validCommands.join(', ')}`);
@@ -3510,7 +4955,7 @@ function cmdStart(args) {
3510
4955
  source,
3511
4956
  command,
3512
4957
  stage: command,
3513
- change: changeName || 'general',
4958
+ change: changeName,
3514
4959
  capability,
3515
4960
  task_id: taskId,
3516
4961
  agent_type: agentType,
@@ -3519,7 +4964,10 @@ function cmdStart(args) {
3519
4964
  git_sha: gitSha,
3520
4965
  timestamp,
3521
4966
  context,
3522
- details: worktreeDetails,
4967
+ details: {
4968
+ ...(worktreeDetails || {}),
4969
+ ...(changeName === 'general' ? { scope: 'explicit-general' } : {}),
4970
+ },
3523
4971
  });
3524
4972
 
3525
4973
  appendEvent(dataDir, event.change, event);
@@ -3599,14 +5047,47 @@ function cmdEnd(args, options = {}) {
3599
5047
  if (!eventId && startEvent) {
3600
5048
  eventId = startEvent.event_id;
3601
5049
  }
5050
+ const requestedChange = args.change || args['change-name'];
5051
+ if (requestedChange) {
5052
+ requireValidChangeName(requestedChange);
5053
+ }
5054
+ if (!startEvent && !requestedChange) {
5055
+ fail('E_CHANGE_REQUIRED: 无法解析 start 的 end 必须显式提供 --change');
5056
+ }
5057
+ if (startEvent && requestedChange && startEvent.change !== requestedChange) {
5058
+ fail(`E_CHANGE_CONFLICT: end 指定 change "${requestedChange}",但 active stage 属于 "${startEvent.change}"`);
5059
+ }
3602
5060
  // stage_end 幂等性保护:同一 event_id 已存在 stage_end 时,结果相同则跳过,不同则更新(archive 命令只要存在即跳过)
3603
5061
  const command = startEvent?.command || args.command || 'unknown';
3604
5062
  const change = startEvent?.change || args.change || args['change-name'] || 'general';
5063
+ requireValidChangeName(change);
5064
+ if (change === 'general'
5065
+ && (command !== 'explore' || !isTrueOption(args['allow-general'] || args.allow_general))) {
5066
+ fail('E_GENERAL_SCOPE_FORBIDDEN: general 仅允许 explore 配合 --allow-general 显式使用');
5067
+ }
3605
5068
  const existingEnds = readAllEventEndsById(dataDir, eventId, change);
3606
5069
  if (existingEnds.length > 0) {
3607
5070
  const latestExisting = existingEnds.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
5071
+ if (latestExisting.result !== result) {
5072
+ fail(`E_STAGE_END_CONFLICT: event_id "${eventId}" 已记录 result=${latestExisting.result},不能改为 ${result}`);
5073
+ }
3608
5074
  // archive 命令已存在 stage_end 时直接跳过,避免 result 突变后重复写入
3609
5075
  if (command === 'archive') {
5076
+ const archiveResult = latestExisting.details?.archive_result || {};
5077
+ if (archiveResult.archive_path) {
5078
+ const archiveDirAbs = path.isAbsolute(archiveResult.archive_path)
5079
+ ? archiveResult.archive_path
5080
+ : path.resolve(projectRoot, archiveResult.archive_path);
5081
+ ensureArchiveManifest(projectRoot, change, archiveDirAbs, {
5082
+ reason: archiveResult.reason || '',
5083
+ method: archiveResult.method || 'skywalk-full-spec-archive',
5084
+ archivePath: archiveDirAbs,
5085
+ archivedAt: archiveResult.created_at || archiveResult.archived_at,
5086
+ copiedSpecs: archiveResult.copied_specs,
5087
+ finalizeAfterReports: true,
5088
+ runIdentity: latestExisting.event_id,
5089
+ });
5090
+ }
3610
5091
  const output = {
3611
5092
  event_id: eventId,
3612
5093
  duration_ms: latestExisting.duration_ms,
@@ -3633,7 +5114,6 @@ function cmdEnd(args, options = {}) {
3633
5114
  }
3634
5115
  return output;
3635
5116
  }
3636
- // 结果不同:允许继续写入新的 stage_end(覆盖/更新)
3637
5117
  }
3638
5118
  const orphan = !startEvent;
3639
5119
  if (!eventId) {
@@ -3700,7 +5180,7 @@ function cmdEnd(args, options = {}) {
3700
5180
  };
3701
5181
  }
3702
5182
  }
3703
- // archive 归档完成后,扫描 task_completion,has_incomplete 降为 partial(仍归档,仅 result 标记提示未勾验收)
5183
+ // archive 归档完成后,仅主任务未完成时降为 partial;验收证据单独提示,不再冒充任务进度。
3704
5184
  if (command === 'archive' && result === 'success') {
3705
5185
  try {
3706
5186
  const archiveDirRel = repaired?.archive_path || details.archive_result?.archive_path || null;
@@ -3708,7 +5188,13 @@ function cmdEnd(args, options = {}) {
3708
5188
  const taskCompletion = archiveDirAbs
3709
5189
  ? scanTaskCompletionForArchiveDir(projectRoot, change, archiveDirAbs)
3710
5190
  : scanTaskCompletion(projectRoot, change);
3711
- if (taskCompletion.has_incomplete) {
5191
+ const primary = taskCompletion.primary_tasks;
5192
+ if (primary?.has_incomplete) {
5193
+ result = 'partial';
5194
+ summary = `${summary}(尚有 ${primary.incomplete} 个主任务未完成)`.trim();
5195
+ } else if ((taskCompletion.acceptance_evidence?.unconfirmed || 0) > 0) {
5196
+ summary = `${summary}(主任务已完成;仍有 ${taskCompletion.acceptance_evidence.unconfirmed} 项验收证据待确认)`.trim();
5197
+ } else if (!primary && taskCompletion.has_incomplete) {
3712
5198
  result = 'partial';
3713
5199
  summary = `${summary}(${taskCompletion.incomplete} 项验收标准未勾选)`.trim();
3714
5200
  }
@@ -3739,76 +5225,131 @@ function cmdEnd(args, options = {}) {
3739
5225
  orphan: orphan ? true : undefined,
3740
5226
  });
3741
5227
 
3742
- appendEvent(dataDir, event.change, event);
3743
- if (startEvent) {
3744
- clearActiveStage(projectRoot, startEvent);
3745
- }
3746
-
3747
- // archive 时追加"变更总结"段到 execution-log.md(T1.3:移到 appendEvent 之后,确保总结出现在 archive_end 行之后)
3748
- if (command === 'archive') {
3749
- try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
3750
- try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
3751
- }
3752
- }
3753
-
3754
5228
  let resolvedReportOutput = '';
5229
+ const writtenReportPaths = [];
3755
5230
  if (reportOutput) {
3756
5231
  resolvedReportOutput = path.isAbsolute(reportOutput) ? reportOutput : path.resolve(projectRoot, reportOutput);
3757
5232
  } else if (repaired && repaired.reportPath) {
3758
5233
  resolvedReportOutput = repaired.reportPath;
3759
5234
  }
3760
5235
  if (resolvedReportOutput) {
3761
- const events = readEvents(dataDir, event.change);
5236
+ // archive stage_end 尚未入库时,先用待写事件生成报告;实际成功路径随后写回事件。
5237
+ const events = [...readEvents(dataDir, event.change), event];
3762
5238
  const report = buildReport(projectRoot, events, {
3763
5239
  level: args['report-level'] || 'change',
3764
5240
  change: event.change,
3765
5241
  capability: args.capability || args['capability-name'] || startEvent?.capability,
3766
5242
  });
3767
- const reportFormat = args['report-format'] || 'markdown';
5243
+ const requestedReportFormat = args['report-format'] || 'markdown';
5244
+ const reportFormat = command === 'archive' ? 'markdown' : requestedReportFormat;
5245
+ const expectedExtension = reportFormat === 'json' ? '.json' : reportFormat === 'html' ? '.html' : '.md';
5246
+ resolvedReportOutput = normalizeKnownReportExtension(resolvedReportOutput, expectedExtension);
3768
5247
  const isHtmlPrimary = reportFormat === 'html';
3769
5248
  const rendered = reportFormat === 'json'
3770
5249
  ? JSON.stringify(report, null, 2)
3771
5250
  : (isHtmlPrimary ? renderExecutiveReportHtml(report) : renderExecutiveReportMarkdown(report));
3772
- ensureDir(path.dirname(resolvedReportOutput));
3773
- fs.writeFileSync(resolvedReportOutput, rendered + '\n', 'utf8');
5251
+ atomicWriteText(resolvedReportOutput, rendered + '\n');
5252
+ writtenReportPaths.push(resolvedReportOutput);
3774
5253
 
3775
- // 默认双输出:md + html,避免只生成单一格式;但仅对 .md/.html 主路径派生副本,防止非标准路径被静默覆盖
5254
+ // 默认三输出:md + html + json(同源 Report Model);显式 format=json 时只写用户指定文件
5255
+ // 仅对 .md/.html 主路径派生 companion,防止非标准路径被静默覆盖
3776
5256
  if (reportFormat !== 'json') {
3777
- try {
3778
- const primaryExt = path.extname(resolvedReportOutput).toLowerCase();
3779
- const isMdPrimary = primaryExt === '.md';
3780
- const isHtmlExt = primaryExt === '.html';
3781
- if (isMdPrimary || isHtmlExt) {
3782
- const companionSuffix = isHtmlExt ? '.md' : '.html';
3783
- const companionRender = isHtmlExt ? renderExecutiveReportMarkdown(report) : renderExecutiveReportHtml(report);
3784
- const htmlPath = resolvedReportOutput.replace(/\.[^.]+$/, companionSuffix);
3785
- if (htmlPath !== resolvedReportOutput) {
3786
- fs.writeFileSync(htmlPath, companionRender + '\n', 'utf8');
5257
+ const primaryExt = path.extname(resolvedReportOutput).toLowerCase();
5258
+ const isMdPrimary = primaryExt === '.md';
5259
+ const isHtmlExt = primaryExt === '.html';
5260
+ if (isMdPrimary || isHtmlExt) {
5261
+ const companionSuffix = isHtmlExt ? '.md' : '.html';
5262
+ const companionRender = isHtmlExt ? renderExecutiveReportMarkdown(report) : renderExecutiveReportHtml(report);
5263
+ const companionPath = resolvedReportOutput.replace(/\.[^.]+$/, companionSuffix);
5264
+ if (companionPath !== resolvedReportOutput) {
5265
+ try {
5266
+ atomicWriteText(companionPath, companionRender + '\n');
5267
+ writtenReportPaths.push(companionPath);
5268
+ } catch (err) {
5269
+ if (command === 'archive') {
5270
+ throw new Error(`E_REPORT_ARTIFACT_WRITE: ${companionSuffix.slice(1)} 报告写入失败: ${err.message}`);
5271
+ }
5272
+ try { console.error(`[telemetry] 报告 companion 写入失败(${companionSuffix.slice(1)},不阻塞主流程): ${err.message}`); } catch {}
5273
+ }
5274
+ }
5275
+ const jsonPath = resolvedReportOutput.replace(/\.[^.]+$/, '.json');
5276
+ if (jsonPath !== resolvedReportOutput) {
5277
+ try {
5278
+ atomicWriteText(jsonPath, JSON.stringify(report, null, 2) + '\n');
5279
+ writtenReportPaths.push(jsonPath);
5280
+ } catch (err) {
5281
+ if (command === 'archive') {
5282
+ throw new Error(`E_REPORT_ARTIFACT_WRITE: json 报告写入失败: ${err.message}`);
5283
+ }
5284
+ try { console.error(`[telemetry] 报告 companion 写入失败(json,不阻塞主流程): ${err.message}`); } catch {}
3787
5285
  }
3788
5286
  }
3789
- } catch (err) {
3790
- try { console.error(`[telemetry] ${isHtmlPrimary ? 'md' : 'html'} 报告副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
3791
5287
  }
3792
5288
  }
3793
5289
  }
3794
5290
 
3795
- // A1(v3): appendEvent 已把 archive stage_end 写入活跃 jsonl,但上方 ensureArchiveSuccessArtifacts
3796
- // appendEvent 之前拷贝 events archive/evidence/jsonl 缺末条 stage_end。此处重拷覆盖,幂等。
3797
- // 触发条件:archive 且归档产物已生成(repaired 非空,即原始 result=success,不含 failure/纯 partial)。
3798
- if (command === 'archive' && repaired && details.archive_result?.archive_path) {
3799
- try {
3800
- const archiveDirAbs = path.isAbsolute(details.archive_result.archive_path)
3801
- ? details.archive_result.archive_path
3802
- : path.resolve(projectRoot, details.archive_result.archive_path);
3803
- const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(change));
3804
- if (fs.existsSync(eventsSrcDir) && fs.existsSync(archiveDirAbs)) {
3805
- copyDirSync(eventsSrcDir, path.join(archiveDirAbs, 'evidence', 'events'));
3806
- }
3807
- } catch (err) {
3808
- try { console.error(`[telemetry] archive events 重拷失败(不阻塞): ${err.message}`); } catch {}
5291
+ if (command === 'archive' && event.details?.archive_result) {
5292
+ const byExtension = Object.fromEntries(writtenReportPaths.map((filePath) => [path.extname(filePath).toLowerCase(), filePath]));
5293
+ const actualPaths = {};
5294
+ if (byExtension['.md']) actualPaths.report_path = toProjectRelative(projectRoot, byExtension['.md']);
5295
+ if (byExtension['.html']) actualPaths.report_html_path = toProjectRelative(projectRoot, byExtension['.html']);
5296
+ if (byExtension['.json']) actualPaths.report_json_path = toProjectRelative(projectRoot, byExtension['.json']);
5297
+ event.details.archive_result = {
5298
+ ...event.details.archive_result,
5299
+ ...actualPaths,
5300
+ };
5301
+ }
5302
+
5303
+ appendEvent(dataDir, event.change, event);
5304
+ if (startEvent) {
5305
+ clearActiveStage(projectRoot, startEvent);
5306
+ }
5307
+
5308
+ // 终态摘要必须基于已经落库的 archive stage_end。
5309
+ if (command === 'archive') {
5310
+ try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
5311
+ try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
3809
5312
  }
3810
5313
  }
3811
5314
 
5315
+ // 报告初稿生成时 archive stage_end 尚未入库。事件落库后使用同一模型重写已成功
5316
+ // 生成的格式,使 md/html/json 与最终 JSONL 保持一致;失败的 companion 不会被虚构。
5317
+ if (command === 'archive' && writtenReportPaths.length > 0) {
5318
+ const finalEvents = readEvents(dataDir, event.change);
5319
+ const finalReport = buildReport(projectRoot, finalEvents, {
5320
+ level: args['report-level'] || 'change',
5321
+ change: event.change,
5322
+ capability: args.capability || args['capability-name'] || startEvent?.capability,
5323
+ });
5324
+ for (const filePath of writtenReportPaths) {
5325
+ const extension = path.extname(filePath).toLowerCase();
5326
+ const content = extension === '.json'
5327
+ ? JSON.stringify(finalReport, null, 2)
5328
+ : (extension === '.html'
5329
+ ? renderExecutiveReportHtml(finalReport)
5330
+ : renderExecutiveReportMarkdown(finalReport));
5331
+ atomicWriteText(filePath, content + '\n');
5332
+ }
5333
+ }
5334
+
5335
+ // 最终封装统一发生在事件、摘要和三格式报告都落盘之后,保证 manifest/ZIP
5336
+ // 收录的是同一次变更的真实终态。
5337
+ if (command === 'archive' && repaired && event.details?.archive_result?.archive_path) {
5338
+ const finalArchiveResult = event.details.archive_result;
5339
+ const archiveDirAbs = path.isAbsolute(finalArchiveResult.archive_path)
5340
+ ? finalArchiveResult.archive_path
5341
+ : path.resolve(projectRoot, finalArchiveResult.archive_path);
5342
+ ensureArchiveManifest(projectRoot, change, archiveDirAbs, {
5343
+ reason: finalArchiveResult.reason || '',
5344
+ method: finalArchiveResult.method || 'skywalk-full-spec-archive',
5345
+ archivePath: archiveDirAbs,
5346
+ archivedAt: finalArchiveResult.created_at || finalArchiveResult.archived_at,
5347
+ copiedSpecs: finalArchiveResult.copied_specs,
5348
+ finalizeAfterReports: true,
5349
+ runIdentity: event.event_id,
5350
+ });
5351
+ }
5352
+
3812
5353
  const output = {
3813
5354
  event_id: eventId,
3814
5355
  duration_ms: durationMs,
@@ -3826,11 +5367,456 @@ function cmdEnd(args, options = {}) {
3826
5367
  /**
3827
5368
  * log record: 记录非阶段类结构化事件
3828
5369
  */
5370
+ function isTrueOption(value) {
5371
+ return value === true || String(value || '').toLowerCase() === 'true';
5372
+ }
5373
+
5374
+ function requireStrictString(value, code, fieldName) {
5375
+ const normalized = String(value || '').trim();
5376
+ if (!normalized) fail(`${code}: strict 事件缺少 ${fieldName}`);
5377
+ return normalized;
5378
+ }
5379
+
5380
+ function strictInteger(value, code, fieldName, minimum = 0) {
5381
+ const number = Number(value);
5382
+ if (!Number.isInteger(number) || number < minimum) {
5383
+ fail(`${code}: ${fieldName} 必须是大于等于 ${minimum} 的整数`);
5384
+ }
5385
+ return number;
5386
+ }
5387
+
5388
+ function normalizeStrictTestResult(args, details) {
5389
+ const input = details?.test_results;
5390
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
5391
+ fail('TEST_RESULT_REQUIRED: strict test_result 缺少 details.test_results');
5392
+ }
5393
+ const tddPhase = requireStrictString(input.tdd_phase, 'TEST_RESULT_TDD_PHASE_REQUIRED', 'tdd_phase');
5394
+ if (!['red', 'green', 'refactor', 'regression', 'not-applicable'].includes(tddPhase)) {
5395
+ fail(`TEST_RESULT_TDD_PHASE_INVALID: 非法 tdd_phase "${tddPhase}"`);
5396
+ }
5397
+ const command = requireStrictString(input.command || args.command, 'TEST_RESULT_COMMAND_REQUIRED', 'command');
5398
+ const result = requireStrictString(input.result || args.result, 'E_STRICT_FIELD_REQUIRED', 'result');
5399
+ if (!['success', 'failure'].includes(result)) {
5400
+ fail(`TEST_RESULT_INVALID: 非法 result "${result}"`);
5401
+ }
5402
+ const exitCode = strictInteger(input.exit_code, 'TEST_RESULT_EXIT_CODE_INVALID', 'exit_code', 0);
5403
+ const durationMs = strictInteger(input.duration_ms, 'TEST_RESULT_DURATION_INVALID', 'duration_ms', 0);
5404
+ if (typeof input.counts_known !== 'boolean') {
5405
+ fail('TEST_RESULT_COUNTS_KNOWN_REQUIRED: counts_known 必须是 boolean');
5406
+ }
5407
+ const failureType = requireStrictString(input.failure_type, 'TEST_RESULT_FAILURE_TYPE_REQUIRED', 'failure_type');
5408
+ if (!['assertion', 'contract', 'compile', 'infrastructure', 'timeout', 'none'].includes(failureType)) {
5409
+ fail(`TEST_RESULT_FAILURE_TYPE_INVALID: 非法 failure_type "${failureType}"`);
5410
+ }
5411
+ const expectedFailure = input.expected_failure === true;
5412
+ if (expectedFailure && tddPhase !== 'red') {
5413
+ fail('TEST_RESULT_EXPECTED_FAILURE_INVALID: expected_failure 仅允许用于 red 阶段');
5414
+ }
5415
+ if (exitCode === 0 && failureType !== 'none') {
5416
+ fail('TEST_RESULT_FAILURE_TYPE_CONFLICT: exit_code=0 时 failure_type 必须为 none');
5417
+ }
5418
+ if (exitCode !== 0 && failureType === 'none') {
5419
+ fail('TEST_RESULT_FAILURE_TYPE_CONFLICT: exit_code 非 0 时 failure_type 不能为 none');
5420
+ }
5421
+ if ((exitCode === 0 && result !== 'success') || (exitCode !== 0 && result !== 'failure')) {
5422
+ fail(`E_RESULT_EXIT_CONFLICT: result=${result} 与 exit_code=${exitCode} 不一致`);
5423
+ }
5424
+
5425
+ const taskId = String(args['task-id'] || args.task_id || input.task_id || '').trim();
5426
+ const verifiedTaskIds = Array.isArray(input.verified_task_ids)
5427
+ ? [...new Set(input.verified_task_ids.map(value => String(value || '').trim()).filter(Boolean))].sort()
5428
+ : [];
5429
+ if (!taskId && verifiedTaskIds.length === 0) {
5430
+ fail('E_STRICT_FIELD_REQUIRED: strict test_result 必须提供 task_id 或 verified_task_ids');
5431
+ }
5432
+ if (taskId && !verifiedTaskIds.includes(taskId)) verifiedTaskIds.push(taskId);
5433
+ verifiedTaskIds.sort();
5434
+
5435
+ const normalized = {
5436
+ run_id: requireStrictString(args['run-id'] || args.run_id || input.run_id, 'EVENT_RUN_ID_REQUIRED', 'run_id'),
5437
+ task_id: taskId || undefined,
5438
+ verified_task_ids: verifiedTaskIds,
5439
+ tdd_phase: tddPhase,
5440
+ command,
5441
+ result,
5442
+ exit_code: exitCode,
5443
+ counts_known: input.counts_known,
5444
+ duration_ms: durationMs,
5445
+ failure_type: failureType,
5446
+ expected_failure: expectedFailure,
5447
+ evidence_tier: 'strict',
5448
+ valid_red: tddPhase === 'red'
5449
+ ? expectedFailure && exitCode !== 0 && ['assertion', 'contract'].includes(failureType)
5450
+ : false,
5451
+ };
5452
+ if (input.counts_known) {
5453
+ normalized.passed = strictInteger(input.passed, 'TEST_RESULT_COUNTS_INVALID', 'passed', 0);
5454
+ normalized.failed = strictInteger(input.failed, 'TEST_RESULT_COUNTS_INVALID', 'failed', 0);
5455
+ normalized.skipped = strictInteger(input.skipped, 'TEST_RESULT_COUNTS_INVALID', 'skipped', 0);
5456
+ if (normalized.exit_code === 0 && normalized.failed > 0) {
5457
+ fail('TEST_RESULT_COUNTS_CONFLICT: exit_code=0 时 failed 不能大于 0');
5458
+ }
5459
+ }
5460
+ return normalized;
5461
+ }
5462
+
5463
+ function isValidCompletionTestResults(testResults) {
5464
+ if (!testResults || typeof testResults !== 'object') return false;
5465
+ if (!['green', 'refactor', 'regression'].includes(testResults.tdd_phase)) return false;
5466
+ if (testResults.exit_code !== 0 || testResults.failure_type !== 'none') return false;
5467
+ if (testResults.counts_known === true && Number(testResults.failed) !== 0) return false;
5468
+ return true;
5469
+ }
5470
+
5471
+ function normalizeStrictProcessNote(args, details) {
5472
+ const input = details?.process_note && typeof details.process_note === 'object'
5473
+ ? details.process_note
5474
+ : details;
5475
+ const kind = requireStrictString(input?.kind, 'PROCESS_NOTE_KIND_REQUIRED', 'kind');
5476
+ if (!['user_decision', 'scope_change', 'api_error', 'model_error', 'recovery', 'test_exception'].includes(kind)) {
5477
+ fail(`PROCESS_NOTE_KIND_INVALID: 非法 kind "${kind}"`);
5478
+ }
5479
+ const summary = String(args.summary || input?.summary || '').trim();
5480
+ if (!summary) {
5481
+ fail('PROCESS_NOTE_SUMMARY_REQUIRED: strict process_note 必须提供 summary');
5482
+ }
5483
+ return {
5484
+ details: {
5485
+ ...details,
5486
+ kind,
5487
+ process_note: {
5488
+ ...(details.process_note || {}),
5489
+ kind,
5490
+ summary,
5491
+ },
5492
+ },
5493
+ summary,
5494
+ runId: requireStrictString(args['run-id'] || args.run_id || input?.run_id, 'EVENT_RUN_ID_REQUIRED', 'run_id'),
5495
+ };
5496
+ }
5497
+
5498
+ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
5499
+ const input = details?.task_update && typeof details.task_update === 'object'
5500
+ ? details.task_update
5501
+ : details;
5502
+ const taskId = requireStrictString(
5503
+ args['task-id'] || args.task_id || input?.task_id,
5504
+ 'EVENT_TASK_ID_REQUIRED',
5505
+ 'task_id',
5506
+ );
5507
+ const status = requireStrictString(args.status || input?.status, 'TASK_STATUS_REQUIRED', 'status');
5508
+ if (!['started', 'blocked', 'completed', 'reopened'].includes(status)) {
5509
+ fail(`TASK_STATUS_INVALID: 非法任务状态 "${status}"`);
5510
+ }
5511
+ const runId = requireStrictString(
5512
+ args['run-id'] || args.run_id || input?.run_id,
5513
+ 'EVENT_RUN_ID_REQUIRED',
5514
+ 'run_id',
5515
+ );
5516
+ const testEventId = input?.test_event_id || details?.test_event_id || null;
5517
+ const verificationNotApplicable = input?.verification_not_applicable === true
5518
+ || details?.verification_not_applicable === true;
5519
+ const verificationReason = String(
5520
+ input?.verification_not_applicable_reason
5521
+ || input?.verification_reason
5522
+ || details?.verification_not_applicable_reason
5523
+ || details?.verification_reason
5524
+ || '',
5525
+ ).trim();
5526
+
5527
+ if (status === 'completed') {
5528
+ if (!testEventId && !verificationNotApplicable) {
5529
+ fail('TASK_TEST_REFERENCE_MISSING: completed 任务必须引用 test_event_id,或声明 verification_not_applicable=true 并说明原因');
5530
+ }
5531
+ if (verificationNotApplicable && !verificationReason) {
5532
+ fail('TASK_VERIFICATION_REASON_REQUIRED: verification_not_applicable 必须说明原因');
5533
+ }
5534
+ if (testEventId) {
5535
+ const referenced = readEvents(dataDir, change).find(event => event.event_id === testEventId);
5536
+ if (!referenced || referenced.type !== 'test_result') {
5537
+ fail(`TASK_TEST_REFERENCE_MISSING: 同一 change 中不存在 test_result "${testEventId}"`);
5538
+ }
5539
+ const testResults = referenced.details?.test_results || {};
5540
+ if (!isValidCompletionTestResults(testResults)) {
5541
+ fail(`TASK_TEST_REFERENCE_INVALID: test_result "${testEventId}" 不能证明任务完成`);
5542
+ }
5543
+ const verifiedTaskIds = new Set([
5544
+ referenced.task_id,
5545
+ testResults.task_id,
5546
+ ...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
5547
+ ].filter(Boolean));
5548
+ if (testResults.evidence_tier !== 'strict' || !verifiedTaskIds.has(taskId)) {
5549
+ fail(`E_TEST_IDENTITY_MISMATCH: test_result "${testEventId}" 未严格验证任务 "${taskId}"`);
5550
+ }
5551
+ if (typeof input?.tdd_required !== 'boolean') {
5552
+ fail('TASK_TDD_REQUIRED_MISSING: completed 任务引用测试时必须明确 tdd_required=true/false');
5553
+ }
5554
+ }
5555
+ }
5556
+
5557
+ return {
5558
+ taskId,
5559
+ status,
5560
+ runId,
5561
+ details: {
5562
+ ...details,
5563
+ task_update: {
5564
+ ...(details.task_update || {}),
5565
+ task_id: taskId,
5566
+ status,
5567
+ test_event_id: testEventId,
5568
+ tdd_required: typeof input?.tdd_required === 'boolean' ? input.tdd_required : undefined,
5569
+ test_requirement: input?.test_requirement
5570
+ || (verificationNotApplicable ? 'not_applicable' : 'required'),
5571
+ test_waiver_reason: input?.test_waiver_reason
5572
+ || (verificationNotApplicable ? verificationReason : undefined),
5573
+ verification_not_applicable: verificationNotApplicable,
5574
+ verification_not_applicable_reason: verificationNotApplicable ? verificationReason : undefined,
5575
+ },
5576
+ },
5577
+ };
5578
+ }
5579
+
5580
+ function eventIdempotencyFingerprint(event) {
5581
+ return JSON.stringify({
5582
+ type: event.type,
5583
+ source: event.source,
5584
+ command: event.command,
5585
+ stage: event.stage,
5586
+ change: event.change,
5587
+ capability: event.capability,
5588
+ task_id: event.task_id,
5589
+ run_id: event.run_id,
5590
+ result: event.result,
5591
+ status: event.status,
5592
+ summary: event.summary,
5593
+ details: event.details,
5594
+ });
5595
+ }
5596
+
5597
+ function buildApplyEvidenceAudit(change, taskModel, events = [], options = {}) {
5598
+ const strict = options.mode !== 'report-only';
5599
+ const completedTasks = (taskModel?.primary_tasks?.tasks || []).filter(task => task.completed);
5600
+ const latestTaskUpdates = new Map();
5601
+ const testEvents = new Map();
5602
+ const testsByTask = new Map();
5603
+ const uniqueTestRuns = new Set();
5604
+ for (const event of events) {
5605
+ if (event.change !== change) continue;
5606
+ if (event.type === 'task_update'
5607
+ && (event.status === 'completed' || event.details?.task_update?.status === 'completed')) {
5608
+ const existing = latestTaskUpdates.get(event.task_id);
5609
+ if (!existing || timestampMs(event) > timestampMs(existing)) {
5610
+ latestTaskUpdates.set(event.task_id, event);
5611
+ }
5612
+ continue;
5613
+ }
5614
+ if (event.type === 'test_result') {
5615
+ testEvents.set(event.event_id, event);
5616
+ uniqueTestRuns.add(event.event_id);
5617
+ const testResults = event.details?.test_results || {};
5618
+ const verifiedTaskIds = [...new Set([
5619
+ event.task_id,
5620
+ testResults.task_id,
5621
+ ...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
5622
+ ].filter(Boolean))];
5623
+ for (const verifiedTaskId of verifiedTaskIds) {
5624
+ const related = testsByTask.get(verifiedTaskId) || [];
5625
+ related.push(event);
5626
+ testsByTask.set(verifiedTaskId, related);
5627
+ }
5628
+ }
5629
+ }
5630
+ for (const related of testsByTask.values()) {
5631
+ related.sort((a, b) => timestampMs(a) - timestampMs(b));
5632
+ }
5633
+ let withUpdate = 0;
5634
+ let withTestEvidence = 0;
5635
+ let tddRequired = 0;
5636
+ let redGreenComplete = 0;
5637
+ const findings = [];
5638
+
5639
+ for (const task of completedTasks) {
5640
+ const update = latestTaskUpdates.get(task.task_id);
5641
+ if (!update) {
5642
+ findings.push({
5643
+ code: 'TASK_UPDATE_MISSING',
5644
+ severity: strict ? 'error' : 'warning',
5645
+ task_id: task.task_id,
5646
+ message: '主任务已标完成,但缺少 completed task_update 事件',
5647
+ });
5648
+ continue;
5649
+ }
5650
+ withUpdate += 1;
5651
+ const updateDetails = update.details?.task_update || update.details || {};
5652
+ const notApplicable = updateDetails.verification_not_applicable === true
5653
+ && Boolean(String(updateDetails.verification_not_applicable_reason || '').trim());
5654
+ const testEvent = updateDetails.test_event_id ? testEvents.get(updateDetails.test_event_id) : null;
5655
+ const testResults = testEvent?.details?.test_results || {};
5656
+ const verifiedTaskIds = new Set([
5657
+ testEvent?.task_id,
5658
+ testResults.task_id,
5659
+ ...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
5660
+ ].filter(Boolean));
5661
+ const validTest = Boolean(testEvent)
5662
+ && testResults.evidence_tier === 'strict'
5663
+ && verifiedTaskIds.has(task.task_id)
5664
+ && isValidCompletionTestResults(testResults);
5665
+ if (validTest || notApplicable) {
5666
+ withTestEvidence += 1;
5667
+ } else {
5668
+ findings.push({
5669
+ code: 'TASK_TEST_EVIDENCE_MISSING',
5670
+ severity: strict ? 'error' : 'warning',
5671
+ task_id: task.task_id,
5672
+ message: 'completed task_update 没有有效测试引用或不适用理由',
5673
+ });
5674
+ }
5675
+
5676
+ if (updateDetails.tdd_required === true) {
5677
+ tddRequired += 1;
5678
+ const related = testsByTask.get(task.task_id) || [];
5679
+ const redIndex = related.findIndex(event => event.details?.test_results?.valid_red === true);
5680
+ const greenIndex = related.findIndex((event, index) => (
5681
+ index > redIndex
5682
+ && event.details?.test_results?.exit_code === 0
5683
+ && ['green', 'regression'].includes(event.details?.test_results?.tdd_phase)
5684
+ ));
5685
+ if (redIndex >= 0 && greenIndex > redIndex) {
5686
+ redGreenComplete += 1;
5687
+ } else {
5688
+ findings.push({
5689
+ code: 'TDD_RED_GREEN_INCOMPLETE',
5690
+ severity: strict ? 'error' : 'warning',
5691
+ task_id: task.task_id,
5692
+ message: 'TDD 任务缺少有效 RED→GREEN 顺序证据',
5693
+ });
5694
+ }
5695
+ }
5696
+ }
5697
+
5698
+ return {
5699
+ mode: strict ? 'strict' : 'report-only',
5700
+ status: findings.some(finding => finding.severity === 'error') ? 'blocked' : 'pass',
5701
+ tasks: {
5702
+ completed: completedTasks.length,
5703
+ with_update: withUpdate,
5704
+ with_test_evidence: withTestEvidence,
5705
+ },
5706
+ tdd: {
5707
+ required: tddRequired,
5708
+ red_green_complete: redGreenComplete,
5709
+ },
5710
+ tests: {
5711
+ unique_runs: uniqueTestRuns.size,
5712
+ },
5713
+ findings,
5714
+ };
5715
+ }
5716
+
5717
+ function readEvents(dataDir, changeName) {
5718
+ return readEventsRaw(dataDir, changeName)
5719
+ .filter(event => event.change === changeName);
5720
+ }
5721
+
5722
+ function scopeAttributionKey(sourceChange, targetEventId) {
5723
+ return `${sourceChange}:${targetEventId}`;
5724
+ }
5725
+
5726
+ function buildScopeAttributionState(events) {
5727
+ const active = new Map();
5728
+ const history = events
5729
+ .filter(event => event.type === 'scope_link' || event.type === 'scope_unlink')
5730
+ .sort((a, b) => timestampMs(a) - timestampMs(b));
5731
+ for (const event of history) {
5732
+ const details = event.details?.scope_attribution || {};
5733
+ const sourceChange = details.source_change;
5734
+ const targetEventId = details.target_event_id;
5735
+ if (!sourceChange || !targetEventId) continue;
5736
+ const key = scopeAttributionKey(sourceChange, targetEventId);
5737
+ if (event.type === 'scope_link') {
5738
+ active.set(key, {
5739
+ source_change: sourceChange,
5740
+ target_event_id: targetEventId,
5741
+ target_change: event.change,
5742
+ link_event_id: event.event_id,
5743
+ });
5744
+ } else {
5745
+ const current = active.get(key);
5746
+ if (current && current.target_change === event.change) active.delete(key);
5747
+ }
5748
+ }
5749
+ return { active, history };
5750
+ }
5751
+
5752
+ function resolveChangeEventsWithAttribution(dataDir, change) {
5753
+ const allEvents = readAllEvents(dataDir);
5754
+ const rawDirect = readEventsRaw(dataDir, change);
5755
+ const direct = rawDirect.filter(event => event.change === change);
5756
+ const rejectedDirectCollisions = rawDirect
5757
+ .filter(event => event.change !== change)
5758
+ .map(event => event.event_id);
5759
+ const attribution = buildScopeAttributionState(allEvents);
5760
+ const links = [...attribution.active.values()]
5761
+ .filter(link => link.target_change === change);
5762
+ const linked = [];
5763
+ for (const link of links) {
5764
+ linked.push(...readEvents(dataDir, link.source_change)
5765
+ .filter(event => event.event_id === link.target_event_id)
5766
+ .map(event => ({
5767
+ ...event,
5768
+ attribution: {
5769
+ provenance: 'explicit-link',
5770
+ source_change: link.source_change,
5771
+ target_change: link.target_change,
5772
+ link_event_id: link.link_event_id,
5773
+ },
5774
+ })));
5775
+ }
5776
+ const seen = new Set();
5777
+ const events = [...direct, ...linked].filter((event) => {
5778
+ const key = `${event.type}:${event.event_id}:${event.timestamp}`;
5779
+ if (seen.has(key)) return false;
5780
+ seen.add(key);
5781
+ return true;
5782
+ });
5783
+ const assignedSourceIds = new Set(
5784
+ [...attribution.active.values()].map(link => scopeAttributionKey(
5785
+ link.source_change,
5786
+ link.target_event_id,
5787
+ )),
5788
+ );
5789
+ const unassignedCandidates = readEvents(dataDir, 'general')
5790
+ .filter(event => !assignedSourceIds.has(scopeAttributionKey('general', event.event_id)))
5791
+ .map(event => event.event_id);
5792
+ return {
5793
+ events,
5794
+ audit: {
5795
+ linked_source_event_count: new Set(linked.map(event => event.event_id)).size,
5796
+ active_links: links,
5797
+ rejected_direct_collision_event_ids: [...new Set(rejectedDirectCollisions)],
5798
+ unassigned_candidate_event_ids: [...new Set(unassignedCandidates)],
5799
+ },
5800
+ };
5801
+ }
5802
+
3829
5803
  function cmdRecord(args) {
3830
5804
  const type = args.type || args['event-type'];
3831
5805
  const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
3832
- const change = args.change || args['change-name'] || 'general';
3833
- const source = args.source || 'opsx-command';
5806
+ const strict = isTrueOption(args.strict);
5807
+ const requestedChange = args.change || args['change-name'];
5808
+ if (strict && !requestedChange) {
5809
+ fail('EVENT_CHANGE_REQUIRED: strict 流程事件必须显式提供 --change');
5810
+ }
5811
+ if (requestedChange) {
5812
+ requireValidChangeName(requestedChange);
5813
+ }
5814
+ if (strict && requestedChange === 'general') {
5815
+ fail('E_GENERAL_SCOPE_FORBIDDEN: strict 流程事件不能写入 general');
5816
+ }
5817
+ const change = requestedChange || 'general';
5818
+ const requestedSource = args.source;
5819
+ const source = requestedSource || 'opsx-command';
3834
5820
  const agentType = inferAgentType(args);
3835
5821
  const allowedTypes = [
3836
5822
  'task_update',
@@ -3847,6 +5833,9 @@ function cmdRecord(args) {
3847
5833
  'worktree_finish',
3848
5834
  'process_note',
3849
5835
  'doc_update',
5836
+ 'maturity_evidence',
5837
+ 'scope_link',
5838
+ 'scope_unlink',
3850
5839
  ];
3851
5840
 
3852
5841
  if (!type) {
@@ -3880,19 +5869,6 @@ function cmdRecord(args) {
3880
5869
  fail(`details JSON 解析失败: ${err.message}`);
3881
5870
  }
3882
5871
 
3883
- // task_update 成功时自动同步 tasks.md checkbox
3884
- if (type === 'task_update' && args.result === 'success') {
3885
- try {
3886
- const taskId = args['task-id'] || args.task_id || details?.task_id || null;
3887
- if (taskId) {
3888
- runCheckTaskSync(projectRoot, change, taskId);
3889
- }
3890
- } catch (taskErr) {
3891
- // checkbox 同步失败不阻塞原流程
3892
- try { console.error(`[telemetry] tasks.md checkbox 自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
3893
- }
3894
- }
3895
-
3896
5872
  // conformance_review evidence 校验——assertion 必须有可验证证据:
3897
5873
  // files 含测试文件路径(test/ | .test. | .spec.)→ 强证据;
3898
5874
  // 或 evidence 文本非空 → 降级证据(浏览器自动化/手动验收等无测试文件场景,与 SKILL.md schema 对齐:空 files[] 须配 evidence);
@@ -3929,7 +5905,95 @@ function cmdRecord(args) {
3929
5905
  }
3930
5906
  }
3931
5907
 
5908
+ if (type === 'maturity_evidence') {
5909
+ const maturityValidation = validateMaturityEvidenceDetails(details);
5910
+ if (!maturityValidation.valid) {
5911
+ fail(`maturity_evidence 校验失败: ${maturityValidation.error}`);
5912
+ }
5913
+ details = {
5914
+ ...details,
5915
+ maturity_evidence: maturityValidation.evidence,
5916
+ };
5917
+ }
5918
+
3932
5919
  const dataDir = getDataDir(projectRoot);
5920
+ let strictRunId = args['run-id'] || args.run_id || details?.run_id;
5921
+ let strictTaskId = args['task-id'] || args.task_id || details?.task_id;
5922
+ let eventSummary = args.summary;
5923
+ let eventStatus = args.status;
5924
+ if (strict) {
5925
+ const missingIdentity = [
5926
+ ['session_id', args['session-id'] || args.session_id],
5927
+ ['stage', args.stage || args.command],
5928
+ ['source', requestedSource],
5929
+ ['summary', args.summary],
5930
+ ['run_id', strictRunId],
5931
+ ].find(([, value]) => !String(value || '').trim());
5932
+ if (missingIdentity) {
5933
+ fail(`E_STRICT_FIELD_REQUIRED: strict 事件缺少 ${missingIdentity[0]}`);
5934
+ }
5935
+ }
5936
+ if (type === 'scope_link' || type === 'scope_unlink') {
5937
+ if (!strict) fail('E_SCOPE_ATTRIBUTION_STRICT_REQUIRED: scope 归属事件必须使用 --strict');
5938
+ const sourceChange = requireStrictString(
5939
+ args['source-change'] || args.source_change,
5940
+ 'E_STRICT_FIELD_REQUIRED',
5941
+ 'source_change',
5942
+ );
5943
+ requireValidChangeName(sourceChange);
5944
+ const targetEventId = requireStrictString(
5945
+ args['target-event-id'] || args.target_event_id,
5946
+ 'E_STRICT_FIELD_REQUIRED',
5947
+ 'target_event_id',
5948
+ );
5949
+ const attributionReason = requireStrictString(args.reason, 'E_STRICT_FIELD_REQUIRED', 'reason');
5950
+ const sourceEvents = readEvents(dataDir, sourceChange)
5951
+ .filter(event => event.event_id === targetEventId);
5952
+ if (sourceEvents.length === 0) {
5953
+ fail(`E_SCOPE_SOURCE_NOT_FOUND: ${sourceChange} 中不存在事件 "${targetEventId}"`);
5954
+ }
5955
+ const attribution = buildScopeAttributionState(readAllEvents(dataDir));
5956
+ const key = scopeAttributionKey(sourceChange, targetEventId);
5957
+ const current = attribution.active.get(key);
5958
+ if (type === 'scope_link' && current && current.target_change !== change) {
5959
+ fail(`E_SCOPE_LINK_CONFLICT: 事件已关联到 "${current.target_change}",须先追加 scope_unlink`);
5960
+ }
5961
+ if (type === 'scope_unlink' && (!current || current.target_change !== change)) {
5962
+ fail(`E_SCOPE_UNLINK_NOT_ACTIVE: 事件当前未关联到 "${change}"`);
5963
+ }
5964
+ details = {
5965
+ ...details,
5966
+ scope_attribution: {
5967
+ source_change: sourceChange,
5968
+ target_event_id: targetEventId,
5969
+ target_change: change,
5970
+ reason: attributionReason,
5971
+ },
5972
+ };
5973
+ }
5974
+ if (strict && type === 'test_result') {
5975
+ const testResults = normalizeStrictTestResult(args, details);
5976
+ strictRunId = testResults.run_id;
5977
+ strictTaskId = testResults.task_id;
5978
+ details = {
5979
+ ...details,
5980
+ test_results: testResults,
5981
+ };
5982
+ } else if (strict && type === 'process_note') {
5983
+ const processNote = normalizeStrictProcessNote(args, details);
5984
+ strictRunId = processNote.runId;
5985
+ eventSummary = processNote.summary;
5986
+ details = processNote.details;
5987
+ } else if (strict && type === 'task_update') {
5988
+ const taskUpdate = normalizeStrictTaskUpdate(args, details, dataDir, change);
5989
+ strictRunId = taskUpdate.runId;
5990
+ strictTaskId = taskUpdate.taskId;
5991
+ eventStatus = taskUpdate.status;
5992
+ details = taskUpdate.details;
5993
+ } else if (strict) {
5994
+ strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
5995
+ }
5996
+
3933
5997
  const activeStage = findActiveStage(projectRoot, {
3934
5998
  session_id: args['session-id'] || args.session_id,
3935
5999
  change,
@@ -3973,25 +6037,103 @@ function cmdRecord(args) {
3973
6037
  stage: args.stage || args.command,
3974
6038
  change,
3975
6039
  capability: args.capability || args['capability-name'],
3976
- task_id: args['task-id'] || args.task_id,
6040
+ task_id: strictTaskId,
6041
+ run_id: strictRunId,
3977
6042
  agent_type: agentType,
3978
6043
  project_root: projectRoot,
3979
6044
  session_id: activeStage?.session_id || args['session-id'] || args.session_id,
3980
6045
  git_sha: args['git-sha'] || args.git_sha,
3981
6046
  timestamp,
3982
6047
  result: args.result,
3983
- status: args.status,
3984
- summary: args.summary,
6048
+ status: eventStatus,
6049
+ summary: eventSummary,
3985
6050
  details,
3986
6051
  });
3987
6052
 
3988
- appendEvent(dataDir, event.change, event);
3989
- console.log(JSON.stringify({
3990
- event_id: eventId,
3991
- type,
3992
- recorded_at: timestamp,
3993
- message: `SDD ${type} 事件已记录(change: ${event.change})`,
3994
- }, null, 2));
6053
+ const syncTaskProjection = () => {
6054
+ if (type !== 'task_update' || (args.result !== 'success' && eventStatus !== 'completed')) return;
6055
+ try {
6056
+ const taskId = strictTaskId || details?.task_id || null;
6057
+ if (taskId) runCheckTaskSync(projectRoot, change, taskId);
6058
+ } catch (taskErr) {
6059
+ try { console.error(`[telemetry] tasks.md 主任务状态自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
6060
+ }
6061
+ };
6062
+
6063
+ const persistEvent = (lockHeld = false) => {
6064
+ if (strict && event.run_id) {
6065
+ const existing = readEvents(dataDir, change)
6066
+ .find(candidate => candidate.type === event.type && candidate.run_id === event.run_id);
6067
+ if (existing) {
6068
+ if (eventIdempotencyFingerprint(existing) !== eventIdempotencyFingerprint(event)) {
6069
+ const error = new Error(
6070
+ `EVENT_RUN_ID_CONFLICT: ${event.type} 的 run_id "${event.run_id}" 已存在且内容不同`,
6071
+ );
6072
+ error.code = 'EVENT_RUN_ID_CONFLICT';
6073
+ throw error;
6074
+ }
6075
+ syncTaskProjection();
6076
+ return {
6077
+ event_id: existing.event_id,
6078
+ type,
6079
+ recorded_at: existing.timestamp,
6080
+ idempotent: true,
6081
+ message: `SDD ${type} 事件已存在,幂等返回(change: ${existing.change})`,
6082
+ };
6083
+ }
6084
+ }
6085
+
6086
+ // scope_link/scope_unlink 的冲突判断必须与追加事实共用同一事件锁;
6087
+ // 否则两个不同 run_id 的并发 link 都可能在锁外看到“未关联”并同时写入。
6088
+ if (type === 'scope_link' || type === 'scope_unlink') {
6089
+ const scopeDetails = event.details?.scope_attribution || {};
6090
+ const key = scopeAttributionKey(scopeDetails.source_change, scopeDetails.target_event_id);
6091
+ const current = buildScopeAttributionState(readAllEvents(dataDir)).active.get(key);
6092
+ if (type === 'scope_link' && current && current.target_change !== change) {
6093
+ const error = new Error(
6094
+ `E_SCOPE_LINK_CONFLICT: 事件已关联到 "${current.target_change}",须先追加 scope_unlink`,
6095
+ );
6096
+ error.code = 'E_SCOPE_LINK_CONFLICT';
6097
+ throw error;
6098
+ }
6099
+ if (type === 'scope_unlink' && (!current || current.target_change !== change)) {
6100
+ const error = new Error(`E_SCOPE_UNLINK_NOT_ACTIVE: 事件当前未关联到 "${change}"`);
6101
+ error.code = 'E_SCOPE_UNLINK_NOT_ACTIVE';
6102
+ throw error;
6103
+ }
6104
+ }
6105
+
6106
+ appendEvent(dataDir, event.change, event, { lockHeld });
6107
+ // 事实先持久化,任务文档作为可重放投影;幂等重试会再次尝试补齐投影。
6108
+ syncTaskProjection();
6109
+ return {
6110
+ event_id: eventId,
6111
+ type,
6112
+ recorded_at: timestamp,
6113
+ idempotent: false,
6114
+ message: `SDD ${type} 事件已记录(change: ${event.change})`,
6115
+ };
6116
+ };
6117
+
6118
+ let output;
6119
+ try {
6120
+ if (type === 'scope_link' || type === 'scope_unlink') {
6121
+ const scopeDetails = event.details.scope_attribution;
6122
+ const scopeLockName = `scope-${crypto.createHash('sha256')
6123
+ .update(scopeAttributionKey(scopeDetails.source_change, scopeDetails.target_event_id))
6124
+ .digest('hex')
6125
+ .slice(0, 24)}`;
6126
+ output = withEventWriteLock(dataDir, scopeLockName, () => persistEvent(false));
6127
+ } else {
6128
+ output = strict && event.run_id
6129
+ ? withEventWriteLock(dataDir, change, () => persistEvent(true))
6130
+ : persistEvent(false);
6131
+ }
6132
+ } catch (error) {
6133
+ fail(error.message || String(error));
6134
+ }
6135
+ console.log(JSON.stringify(output, null, 2));
6136
+ return output;
3995
6137
  }
3996
6138
 
3997
6139
  /** 解析 node --test 的 TAP 输出为 {passed, failed, skipped}(opsx-apply agent 跑测试后传 --test-stdout) */
@@ -4135,7 +6277,7 @@ function cmdSnapshot(args) {
4135
6277
  return result;
4136
6278
  }
4137
6279
 
4138
- /** archive 时聚合 events 生成"变更总结"段追加到 execution-log.md */
6280
+ /** archive 时从统一 Report Model 生成终态摘要,并通过受控 marker 幂等替换。 */
4139
6281
  function appendChangeSummary(projectRoot, changeName, events) {
4140
6282
  try {
4141
6283
  const changeDir = getChangeDir(projectRoot, changeName);
@@ -4146,34 +6288,49 @@ function appendChangeSummary(projectRoot, changeName, events) {
4146
6288
  }
4147
6289
  ensureDir(logsDir);
4148
6290
  const file = path.join(logsDir, 'execution-log.md');
4149
- // 复用 computeChangeMetrics.change_summary,消除 execution-log 与报告的 1 vs 2 不一致(T1.3)
4150
- const metrics = computeChangeMetrics(changeName, events);
4151
- const cs = metrics && metrics.change_summary ? metrics.change_summary : null;
4152
- const stageStartCount = cs ? cs.stage_start_count : events.filter(e => e.type === 'stage_start' && !e.orphan).length;
4153
- const stageEndCount = cs ? cs.stage_end_count : events.filter(e => e.type === 'stage_end' && !e.orphan).length;
4154
- const repeatCount = cs ? cs.stage_repeat_count : 0;
4155
- const testPass = cs ? cs.test_pass : null;
4156
- const testPassStr = testPass ? `${testPass.passed}/${testPass.passed + testPass.failed}` : '无测试数据';
4157
- let unchecked = 0;
4158
- try {
4159
- const archiveDir = findArchivedChangeDir(projectRoot, changeName);
4160
- const tc = archiveDir
4161
- ? scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir)
4162
- : scanTaskCompletion(projectRoot, changeName);
4163
- unchecked = tc.has_incomplete ? tc.incomplete : 0;
4164
- } catch {}
6291
+ const report = buildReport(projectRoot, events, { level: 'change', change: changeName });
6292
+ const execution = computeChangeMetrics(changeName, events)?.change_summary || {};
6293
+ const tests = report.change_summary?.tests || {};
6294
+ const legacyTestEvent = tests.total == null
6295
+ ? [...events].reverse().find((event) => {
6296
+ const result = event?.details?.test_results;
6297
+ return event?.type === 'test_result'
6298
+ && Number.isFinite(Number(result?.passed))
6299
+ && Number.isFinite(Number(result?.failed));
6300
+ })
6301
+ : null;
6302
+ const documentTasks = report.change_summary?.tasks?.document || {};
6303
+ const traceability = report.change_summary?.tasks?.traceability || {};
6304
+ const evidence = report.change_summary?.evidence || {};
6305
+ const legacyTests = legacyTestEvent?.details?.test_results;
6306
+ const testPassStr = tests.total != null
6307
+ ? `${tests.passed}/${tests.total}`
6308
+ : (legacyTests
6309
+ ? `${Number(legacyTests.passed)}/${Number(legacyTests.passed) + Number(legacyTests.failed) + Number(legacyTests.skipped || 0)}(历史事件兼容口径)`
6310
+ : '无权威测试数据');
6311
+ const markerStart = '<!-- change-summary:start -->';
6312
+ const markerEnd = '<!-- change-summary:end -->';
4165
6313
  const summary = [
4166
6314
  '',
6315
+ markerStart,
4167
6316
  '## 变更总结',
4168
- `- 阶段数:${stageStartCount} start / ${stageEndCount} end`,
4169
- `- 阶段重复执行次数:${repeatCount}`,
4170
- `- 测试通过率:${testPassStr}`,
4171
- `- 遗留问题:${unchecked > 0 ? `${unchecked} 项验收标准未勾选` : ''}`,
6317
+ `- 阶段数:${execution.stage_start_count ?? 0} start / ${execution.stage_end_count ?? 0} end`,
6318
+ `- 阶段重复执行次数:${execution.stage_repeat_count ?? 0}`,
6319
+ `- 最终测试(测试通过率):${testPassStr}`,
6320
+ `- 文档任务完成:${documentTasks.completed ?? '无数据'}/${documentTasks.total ?? '无数据'}`,
6321
+ `- 事件可追溯:${traceability.verified ?? '无数据'}/${traceability.total ?? '无数据'}`,
6322
+ `- 证据问题:${evidence.category_count ?? 0} 类,${evidence.occurrence_count ?? 0} 次规则命中`,
6323
+ `- 结论:${report.conclusion?.text || '证据不足'}`,
6324
+ markerEnd,
4172
6325
  '',
4173
6326
  ].join('\n');
4174
- fs.appendFileSync(file, summary + '\n', 'utf8');
6327
+ const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : renderExecutionLog(changeName, events);
6328
+ const escapedStart = markerStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6329
+ const escapedEnd = markerEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
6330
+ const withoutOldSummary = current.replace(new RegExp(`\\n?${escapedStart}[\\s\\S]*?${escapedEnd}\\n?`, 'g'), '\n');
6331
+ fs.writeFileSync(file, `${withoutOldSummary.trimEnd()}\n${summary}\n`, 'utf8');
4175
6332
  } catch (err) {
4176
- try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
6333
+ try { console.error(`[telemetry] 变更总结更新失败(不阻塞): ${err.message}`); } catch {}
4177
6334
  }
4178
6335
  }
4179
6336
 
@@ -4271,7 +6428,9 @@ function cmdMetrics(args) {
4271
6428
  const pdfMvp = Boolean(args['pdf-mvp']);
4272
6429
 
4273
6430
  const dataDir = getDataDir(projectRoot);
4274
- let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
6431
+ let events = changeName
6432
+ ? resolveChangeEventsWithAttribution(dataDir, changeName).events
6433
+ : readAllEvents(dataDir);
4275
6434
 
4276
6435
  events = filterEventsByDate(events, dateFrom, dateTo);
4277
6436
 
@@ -4303,25 +6462,105 @@ function cmdMetrics(args) {
4303
6462
  /**
4304
6463
  * log report: 生成只读度量报告
4305
6464
  */
6465
+ function buildPeriodMetricSnapshot(metrics) {
6466
+ return {
6467
+ q3: metrics?.quality?.q3_spec_quality_score ?? null,
6468
+ p4: metrics?.process?.p4_quality_gate_enforcement_rate ?? null,
6469
+ p_h: metrics?.telemetry_health?.p_h_telemetry_health_score ?? null,
6470
+ p_c: metrics?.process?.p_c_stage_closure_rate ?? null,
6471
+ q1: metrics?.quality?.q1_spec_conformance_score ?? null,
6472
+ };
6473
+ }
6474
+
4306
6475
  function cmdReport(args) {
4307
6476
  const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
4308
6477
  const changeName = args.change || args['change-name'];
4309
6478
  const capability = args.capability || args['capability-name'];
4310
6479
  const dateFrom = args['date-from'];
4311
6480
  const dateTo = args['date-to'];
6481
+ const period = args.period || null;
4312
6482
  const format = args.format || 'html';
4313
6483
  const outputPath = args.output || args['output-file'];
4314
6484
  const level = args.level || (capability ? 'capability' : (changeName ? 'change' : 'project'));
4315
6485
 
4316
6486
  const dataDir = getDataDir(projectRoot);
4317
- let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
4318
- events = filterEventsByDate(events, dateFrom, dateTo);
6487
+ if (level === 'change' && !changeName) {
6488
+ fail('E_CHANGE_REQUIRED: change 级报告必须显式提供 --change');
6489
+ }
6490
+ if (changeName) {
6491
+ requireValidChangeName(changeName);
6492
+ const hasChangeData = fs.existsSync(getChangeDir(projectRoot, changeName))
6493
+ || Boolean(findArchivedChangeDir(projectRoot, changeName))
6494
+ || readEvents(dataDir, changeName).length > 0;
6495
+ if (!hasChangeData) {
6496
+ fail(`E_CHANGE_NOT_FOUND: 未找到变更 "${changeName}" 的目录、归档或事件`);
6497
+ }
6498
+ }
6499
+ // 周期比较前保留完整事件集;日期过滤只作用于主报告窗口
6500
+ const attributionResolution = changeName
6501
+ ? resolveChangeEventsWithAttribution(dataDir, changeName)
6502
+ : null;
6503
+ const allEvents = attributionResolution ? attributionResolution.events : readAllEvents(dataDir);
6504
+ let events = allEvents;
6505
+ let trackingPeriods = null;
6506
+
6507
+ if (period && period !== 'change') {
6508
+ if (period !== 'biweekly' && period !== 'monthly') {
6509
+ console.error(JSON.stringify({
6510
+ error: 'invalid-period',
6511
+ message: `不支持的 period=${period},仅支持 change|biweekly|monthly`,
6512
+ }, null, 2));
6513
+ process.exitCode = 1;
6514
+ return;
6515
+ }
6516
+ if (!dateFrom || !dateTo) {
6517
+ console.error(JSON.stringify({
6518
+ error: 'invalid-window',
6519
+ message: `${period} 报告需要同时提供 --date-from 与 --date-to`,
6520
+ }, null, 2));
6521
+ process.exitCode = 1;
6522
+ return;
6523
+ }
6524
+ let windows;
6525
+ try {
6526
+ windows = metricsV3.buildTrackingPeriods({ period, dateFrom, dateTo });
6527
+ } catch (err) {
6528
+ console.error(JSON.stringify({
6529
+ error: err.code || 'invalid-window',
6530
+ message: err.message || String(err),
6531
+ }, null, 2));
6532
+ process.exitCode = 1;
6533
+ return;
6534
+ }
6535
+ trackingPeriods = windows.map((window) => {
6536
+ const windowEvents = filterEventsByDate(allEvents, window.date_from, window.date_to);
6537
+ const windowMetrics = computePdfMvpMetrics(windowEvents, {
6538
+ level: 'project',
6539
+ projectRoot,
6540
+ });
6541
+ return {
6542
+ ...window,
6543
+ metrics: buildPeriodMetricSnapshot(windowMetrics),
6544
+ };
6545
+ });
6546
+ events = filterEventsByDate(allEvents, dateFrom, dateTo);
6547
+ } else {
6548
+ events = filterEventsByDate(allEvents, dateFrom, dateTo);
6549
+ }
4319
6550
 
4320
6551
  const report = buildReport(projectRoot, events, {
4321
6552
  level,
4322
6553
  change: changeName,
4323
6554
  capability,
6555
+ period: period || null,
6556
+ dateFrom: dateFrom || null,
6557
+ dateTo: dateTo || null,
6558
+ trackingPeriods,
6559
+ attributionAudit: attributionResolution?.audit || null,
4324
6560
  });
6561
+ if (trackingPeriods) {
6562
+ report.tracking_periods = trackingPeriods;
6563
+ }
4325
6564
  const rendered = format === 'json'
4326
6565
  ? JSON.stringify(report, null, 2)
4327
6566
  : format === 'html'
@@ -4355,7 +6594,9 @@ function cmdDoctor(args) {
4355
6594
  const dateTo = args['date-to'];
4356
6595
 
4357
6596
  const dataDir = getDataDir(projectRoot);
4358
- let events = changeName ? readEvents(dataDir, changeName) : readAllEvents(dataDir);
6597
+ let events = changeName
6598
+ ? resolveChangeEventsWithAttribution(dataDir, changeName).events
6599
+ : readAllEvents(dataDir);
4359
6600
  events = filterEventsByDate(events, dateFrom, dateTo);
4360
6601
 
4361
6602
  const report = computeDoctorReport(events, { change: changeName, projectRoot });
@@ -4553,6 +6794,8 @@ function cmdArchiveDocs(args) {
4553
6794
  archivePath: archiveDir,
4554
6795
  archivedAt: result.created_at || result.archived_at,
4555
6796
  copiedSpecs: result.copied_specs,
6797
+ finalizeAfterReports: true,
6798
+ runIdentity: stageEnd?.event_id || `archive-docs:${changeName}`,
4556
6799
  });
4557
6800
  result = {
4558
6801
  ...result,
@@ -4665,7 +6908,7 @@ SDD Telemetry CLI - 流程度量采集工具
4665
6908
  node skywalk-sdd/log.cjs start --command=<cmd> --project=<path> [--change=<name>] [--agent=<type>]
4666
6909
  node skywalk-sdd/log.cjs end --event-id=<id> --result=<success|failure|partial> --summary="..."
4667
6910
  node skywalk-sdd/log.cjs metrics --project=<path> [--change=<name>] [--pdf-mvp] [--format=json|markdown]
4668
- node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=html|markdown|json] [--output=<file>](默认 html)
6911
+ node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=html|markdown|json] [--output=<file>] [--period=change|biweekly|monthly] [--date-from=YYYY-MM-DD] [--date-to=YYYY-MM-DD](默认 html)
4669
6912
  node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
4670
6913
  node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
4671
6914
  node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
@@ -4700,6 +6943,7 @@ SDD Telemetry CLI - 流程度量采集工具
4700
6943
  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
4701
6944
  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
4702
6945
  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
6946
+ node skywalk-sdd/log.cjs record --type=maturity_evidence --command=archive --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-maturity-evidence.json
4703
6947
  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
4704
6948
  node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth
4705
6949
  node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth --pdf-mvp --format=markdown
@@ -4750,6 +6994,7 @@ module.exports = {
4750
6994
  formatDuration,
4751
6995
  formatReworkReasons,
4752
6996
  buildReport,
6997
+ buildApplyEvidenceAudit,
4753
6998
  computeGitDocumentMetrics,
4754
6999
  getCheckResults,
4755
7000
  getBuildResults,
@@ -4791,9 +7036,17 @@ module.exports = {
4791
7036
  parseJsonOption,
4792
7037
  writeActiveStage,
4793
7038
  findActiveStage,
7039
+ resolveChangeEventsWithAttribution,
4794
7040
  clearActiveStage,
4795
7041
  findStartEvent,
4796
7042
  runCheckTaskSync,
7043
+ resolveChangeDocumentRoot,
7044
+ collectSpecFiles,
7045
+ countSpecScenarios,
7046
+ readChangeType,
7047
+ buildV3ChangeContext,
7048
+ parseSimpleFrontmatter,
7049
+ buildPeriodMetricSnapshot,
4797
7050
  };
4798
7051
 
4799
7052
  // 直接运行时执行