kld-sdd 2.6.13 → 2.6.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/README.md +16 -12
  2. package/kld-sdd-guide.html +4 -5
  3. package/lib/init.js +14 -11
  4. package/package.json +2 -2
  5. package/skywalk-sdd/index.cjs +936 -109
  6. package/skywalk-sdd/metrics-v3.cjs +103 -15
  7. package/skywalk-sdd/ontology/archive-package.cjs +6 -0
  8. package/skywalk-sdd/ontology/identity-index.cjs +9 -2
  9. package/skywalk-sdd/ontology/ontology-paths.cjs +73 -0
  10. package/skywalk-sdd/ontology/runtime.cjs +32 -22
  11. package/skywalk-sdd/ontology/structural-identity.cjs +11 -2
  12. package/skywalk-sdd/ontology/traceability-validator.cjs +16 -8
  13. package/skywalk-sdd/ontology/working-artifacts.cjs +2 -1
  14. package/skywalk-sdd/reporting/change-report-markdown.cjs +294 -0
  15. package/skywalk-sdd/reporting/change-report-model.cjs +452 -0
  16. package/skywalk-sdd/reporting/change-report-renderer.cjs +349 -0
  17. package/skywalk-sdd/reporting/change-report-view-model.cjs +340 -0
  18. package/skywalk-sdd/runtime-metadata.cjs +21 -0
  19. package/templates/skills/kld-sdd/opsx-apply/reference.md +4 -2
  20. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +1 -1
  21. package/templates/skills/kld-sdd/opsx-archive/checklist.md +1 -1
  22. package/templates/skills/kld-sdd/opsx-check/SKILL.md +2 -2
  23. package/templates/skills/kld-sdd/opsx-check/checklist.md +1 -1
  24. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +14 -0
  25. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +31 -0
  26. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +6 -6
  27. package/templates/skills/kld-sdd/opsx-propose/reference.md +6 -7
  28. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +1 -1
  29. package/templates/skills/kld-sdd/tdd-core/reference.md +3 -1
  30. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
@@ -2622,8 +2622,14 @@ function computeConformanceMetrics(events) {
2622
2622
  }
2623
2623
 
2624
2624
  function getAiAdoptionReview(event) {
2625
- // Q4: 兼容三 key(ai_adoption / ai_code_adoption / ai_adoption_review)
2626
- const review = event?.details?.ai_adoption || event?.details?.ai_code_adoption || event?.details?.ai_adoption_review;
2625
+ // Q4: 兼容四 key + 历史顶层 details.ai_diff(无嵌套 review 外壳)
2626
+ const nested = event?.details?.ai_adoption
2627
+ || event?.details?.ai_code_adoption
2628
+ || event?.details?.ai_adoption_review;
2629
+ const topLevelAiDiff = event?.details?.ai_diff && typeof event.details.ai_diff === 'object'
2630
+ ? event.details.ai_diff
2631
+ : null;
2632
+ const review = nested || (topLevelAiDiff ? { ai_diff: topLevelAiDiff } : null);
2627
2633
  if (!review) return null;
2628
2634
 
2629
2635
  const retainedLines = Number.isFinite(review.retained_lines) ? review.retained_lines : null;
@@ -2645,6 +2651,11 @@ function getAiAdoptionReview(event) {
2645
2651
  else adoptionLevel = 'rewritten';
2646
2652
  }
2647
2653
 
2654
+ const aiDiff = review.ai_diff && typeof review.ai_diff === 'object'
2655
+ ? review.ai_diff
2656
+ : (topLevelAiDiff || {});
2657
+ const legacyTopLevelShape = Boolean(topLevelAiDiff) && !nested;
2658
+
2648
2659
  return {
2649
2660
  base_git_sha: review.base_git_sha || null,
2650
2661
  ai_git_sha: review.ai_git_sha || null,
@@ -2655,9 +2666,10 @@ function getAiAdoptionReview(event) {
2655
2666
  retained_lines: retainedLines,
2656
2667
  rewritten_lines: rewrittenLines,
2657
2668
  deleted_lines: deletedLines,
2658
- ai_diff: review.ai_diff || {},
2669
+ ai_diff: aiDiff,
2659
2670
  final_diff: review.final_diff || {},
2660
2671
  notes: review.notes || '',
2672
+ legacy_shape: legacyTopLevelShape || undefined,
2661
2673
  };
2662
2674
  }
2663
2675
 
@@ -2666,7 +2678,8 @@ function getAiAdoptionEvents(events) {
2666
2678
  return e.type === 'ai_adoption_review'
2667
2679
  || Boolean(e.details?.ai_adoption)
2668
2680
  || Boolean(e.details?.ai_code_adoption)
2669
- || Boolean(e.details?.ai_adoption_review);
2681
+ || Boolean(e.details?.ai_adoption_review)
2682
+ || Boolean(e.details?.ai_diff);
2670
2683
  });
2671
2684
  }
2672
2685
 
@@ -3218,16 +3231,6 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3218
3231
  change_type: changeType,
3219
3232
  change_name: options.change || null,
3220
3233
  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
- },
3231
3234
  };
3232
3235
  }
3233
3236
 
@@ -3499,7 +3502,13 @@ function reportStatusLabel(status) {
3499
3502
 
3500
3503
  function renderExecutiveReportMarkdown(report) {
3501
3504
  if (report?.level === 'change' && report.change) {
3502
- report = require('./reporting/change-report-model.cjs').buildChangeReportModel(report);
3505
+ if (!report.change_summary || !report.artifacts?.files) {
3506
+ report = require('./reporting/change-report-model.cjs').buildChangeReportModel(report);
3507
+ }
3508
+ const changeMarkdown = require('./reporting/change-report-markdown.cjs');
3509
+ if (typeof changeMarkdown.renderChangeReportMarkdown === 'function') {
3510
+ return changeMarkdown.renderChangeReportMarkdown(report);
3511
+ }
3503
3512
  }
3504
3513
  const metrics = report.metrics;
3505
3514
  const health = report.doctor || {};
@@ -3672,9 +3681,12 @@ function renderExecutiveReportMarkdown(report) {
3672
3681
  `- 归档目录:${archive?.archive_path || 'null'}`,
3673
3682
  `- 归档清单:${archive?.manifest_path || 'null'}`,
3674
3683
  `- 最终报告:${archive?.report_path || 'null'}`,
3675
- `- 已完成任务项(含子项 checkbox):${taskCompletion?.completed ?? 'null'}`,
3676
- `- 未勾选任务项(含子项 checkbox):${taskCompletion?.incomplete ?? 'null'}`,
3677
- `- 任务项总数(含子项 checkbox):${taskCompletion?.total ?? 'null'}`,
3684
+ `- 主任务完成:${taskCompletion?.primary_tasks
3685
+ ? `${taskCompletion.primary_tasks.completed}/${taskCompletion.primary_tasks.total}`
3686
+ : 'null'}`,
3687
+ `- 全部 checkbox 完成:${taskCompletion?.completed ?? 'null'}/${taskCompletion?.total ?? 'null'}(含验收子项;分母≠主任务数)`,
3688
+ `- 未勾选 checkbox:${taskCompletion?.incomplete ?? 'null'}`,
3689
+ `- 验收证据待确认:${taskCompletion?.acceptance_evidence?.unconfirmed ?? 'null'}/${taskCompletion?.acceptance_evidence?.total ?? 'null'}`,
3678
3690
  '',
3679
3691
  '## 说明',
3680
3692
  '- `null` 表示当前还没有采集到对应事件或该指标暂不适用。',
@@ -4219,10 +4231,32 @@ tr:last-child td,tr:last-child th{border-bottom:none}
4219
4231
  </body></html>`;
4220
4232
  }
4221
4233
 
4234
+ function sortedUniqueFileList(files) {
4235
+ return [...new Set((files || []).filter(file => typeof file === 'string' && file.trim()).map(file => file.trim()))]
4236
+ .sort((a, b) => a.localeCompare(b));
4237
+ }
4238
+
4239
+ function fileListSymmetricDiff(left, right) {
4240
+ const leftSet = new Set(left || []);
4241
+ const rightSet = new Set(right || []);
4242
+ return {
4243
+ only_in_task_events: [...leftSet].filter(file => !rightSet.has(file)).sort((a, b) => a.localeCompare(b)),
4244
+ only_in_final_output: [...rightSet].filter(file => !leftSet.has(file)).sort((a, b) => a.localeCompare(b)),
4245
+ };
4246
+ }
4247
+
4222
4248
  // ── U1 报告维度提取辅助:从 events/metrics/doctor/archive 提炼 5 维度,数据缺失诚实标注 _available: false ──
4223
4249
  function extractChangedFiles(events, projectRoot) {
4224
- const adoptionEvents = events.filter(e => e.type === 'ai_adoption_review' || Boolean(e.details?.ai_adoption) || Boolean(e.details?.ai_code_adoption));
4225
- const latest = latestByTimestamp(adoptionEvents);
4250
+ const adoptionEvents = getAiAdoptionEvents(events);
4251
+ const finalAdoptionEvents = adoptionEvents.filter((event) => {
4252
+ const review = getAiAdoptionReview(event);
4253
+ return review?.review_status === 'final'
4254
+ || event.status === 'final'
4255
+ || review?.ai_diff?.snapshot_kind === 'final';
4256
+ });
4257
+ const latest = latestByTimestamp(
4258
+ finalAdoptionEvents.length > 0 ? finalAdoptionEvents : adoptionEvents,
4259
+ );
4226
4260
  const review = latest ? getAiAdoptionReview(latest) : null;
4227
4261
  const aiDiff = review?.ai_diff || null;
4228
4262
  const taskUpdateFiles = new Set();
@@ -4243,7 +4277,10 @@ function extractChangedFiles(events, projectRoot) {
4243
4277
  : [],
4244
4278
  );
4245
4279
  if (finalOutputFiles.size === 0 && latest?.details) {
4246
- const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
4280
+ const rawReview = latest.details.ai_adoption
4281
+ || latest.details.ai_adoption_review
4282
+ || latest.details.ai_code_adoption
4283
+ || {};
4247
4284
  for (const assertion of rawReview.assertions || []) {
4248
4285
  for (const file of assertion.files || []) {
4249
4286
  if (typeof file === 'string' && file.trim()) finalOutputFiles.add(file.trim());
@@ -4251,12 +4288,17 @@ function extractChangedFiles(events, projectRoot) {
4251
4288
  }
4252
4289
  }
4253
4290
 
4254
- const taskUpdateFileCount = taskUpdateFiles.size;
4291
+ const taskEventFileList = sortedUniqueFileList([...taskUpdateFiles]);
4292
+ const finalOutputFileList = sortedUniqueFileList([...finalOutputFiles]);
4293
+ const taskUpdateFileCount = taskEventFileList.length;
4255
4294
  const finalOutputFileCount = Number.isFinite(aiDiff?.files_changed)
4256
4295
  ? 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');
4296
+ : (finalOutputFileList.length > 0 ? finalOutputFileList.length : null);
4297
+ const snapshotKind = aiDiff?.snapshot_kind
4298
+ || ((review?.review_status === 'final' || latest?.status === 'final') ? 'final' : null);
4299
+ const finalOutputStatus = snapshotKind === 'final'
4300
+ ? 'final'
4301
+ : (finalOutputFileCount == null ? 'unavailable' : 'provisional');
4260
4302
  const addedLines = Number.isFinite(aiDiff?.added_lines)
4261
4303
  ? aiDiff.added_lines
4262
4304
  : (Number.isFinite(review?.final_diff?.added_lines) ? review.final_diff.added_lines : null);
@@ -4264,11 +4306,11 @@ function extractChangedFiles(events, projectRoot) {
4264
4306
  ? aiDiff.deleted_lines
4265
4307
  : (Number.isFinite(review?.final_diff?.deleted_lines) ? review.final_diff.deleted_lines : null);
4266
4308
  let lineCountEstimate = null;
4267
- if (addedLines == null && review?.ai_git_sha == null && finalOutputFiles.size > 0 && projectRoot) {
4309
+ if (addedLines == null && review?.ai_git_sha == null && finalOutputFileList.length > 0 && projectRoot) {
4268
4310
  let lines = 0;
4269
4311
  let countedFiles = 0;
4270
4312
  const root = path.resolve(projectRoot);
4271
- for (const relativePath of finalOutputFiles) {
4313
+ for (const relativePath of finalOutputFileList) {
4272
4314
  try {
4273
4315
  const absolutePath = path.isAbsolute(relativePath)
4274
4316
  ? path.resolve(relativePath)
@@ -4289,29 +4331,56 @@ function extractChangedFiles(events, projectRoot) {
4289
4331
  };
4290
4332
  }
4291
4333
  }
4292
- const filesChanged = taskUpdateFileCount > 0 ? taskUpdateFileCount : finalOutputFileCount;
4334
+ // Prefer final AI snapshot for public files_changed when available.
4335
+ const filesChanged = finalOutputStatus === 'final' && finalOutputFileCount != null
4336
+ ? finalOutputFileCount
4337
+ : (taskUpdateFileCount > 0 ? taskUpdateFileCount : finalOutputFileCount);
4293
4338
  const countsDiffer = taskUpdateFileCount > 0
4294
4339
  && finalOutputFileCount != null
4295
4340
  && taskUpdateFileCount !== finalOutputFileCount;
4296
- const reconciliationNote = countsDiffer
4297
- ? `任务事件累计涉及 ${taskUpdateFileCount} 个文件;最终输出快照包含 ${finalOutputFileCount} 个文件。两者用途不同,不应合并为一个口径。`
4298
- : (filesChanged == null ? '没有可用的文件变更证据。' : '任务事件与最终输出文件口径一致。');
4341
+ const listDiff = fileListSymmetricDiff(taskEventFileList, finalOutputFileList);
4342
+ const listsDiffer = taskUpdateFileCount > 0
4343
+ && finalOutputFileList.length > 0
4344
+ && (listDiff.only_in_task_events.length > 0 || listDiff.only_in_final_output.length > 0);
4345
+ let reconciliationStatus = 'unavailable';
4346
+ let reconciliationNote = '没有可用的文件变更证据。';
4347
+ if (taskUpdateFileCount === 0 && finalOutputFileCount == null) {
4348
+ reconciliationStatus = 'unavailable';
4349
+ reconciliationNote = '没有可用的文件变更证据。';
4350
+ } else if (taskUpdateFileCount === 0 || finalOutputFileCount == null) {
4351
+ reconciliationStatus = 'unavailable';
4352
+ reconciliationNote = finalOutputFileCount == null
4353
+ ? '最终输出文件快照不可用,无法与任务事件对账。'
4354
+ : '任务事件未记录文件清单;仅展示最终输出快照。';
4355
+ } else if (countsDiffer) {
4356
+ reconciliationStatus = 'count-mismatch';
4357
+ reconciliationNote = `任务事件累计涉及 ${taskUpdateFileCount} 个文件;最终输出快照包含 ${finalOutputFileCount} 个文件。两者用途不同,不应合并为一个口径。`;
4358
+ } else if (listsDiffer) {
4359
+ reconciliationStatus = 'list-mismatch';
4360
+ reconciliationNote = `文件数量均为 ${taskUpdateFileCount},但清单不一致(任务独有 ${listDiff.only_in_task_events.length},最终独有 ${listDiff.only_in_final_output.length})。`;
4361
+ } else {
4362
+ reconciliationStatus = 'consistent';
4363
+ reconciliationNote = '任务事件与最终输出文件口径一致。';
4364
+ }
4299
4365
  return {
4300
4366
  _available: filesChanged != null,
4301
4367
  files_changed: filesChanged,
4302
4368
  added_lines: addedLines,
4303
4369
  deleted_lines: deletedLines,
4304
- files: finalOutputFiles.size > 0 ? [...finalOutputFiles] : null,
4305
- task_event_files: taskUpdateFileCount > 0 ? [...taskUpdateFiles] : null,
4370
+ files: finalOutputFileList.length > 0 ? finalOutputFileList : null,
4371
+ task_event_files: taskUpdateFileCount > 0 ? taskEventFileList : null,
4306
4372
  task_event_files_changed: taskUpdateFileCount > 0 ? taskUpdateFileCount : null,
4307
- final_output_files: finalOutputFiles.size > 0 ? [...finalOutputFiles] : null,
4373
+ final_output_files: finalOutputFileList.length > 0 ? finalOutputFileList : null,
4308
4374
  final_output_files_changed: finalOutputFileCount,
4309
4375
  final_output_status: finalOutputStatus,
4310
4376
  diff_source: snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'),
4311
4377
  line_count_estimate: lineCountEstimate,
4378
+ reconciliation_status: reconciliationStatus,
4312
4379
  reconciliation_note: reconciliationNote,
4313
- ai_diff_files_changed: countsDiffer ? finalOutputFileCount : null,
4314
- files_diff_note: countsDiffer ? reconciliationNote : null,
4380
+ only_in_task_events: listDiff.only_in_task_events,
4381
+ only_in_final_output: listDiff.only_in_final_output,
4382
+ ai_diff_files_changed: (countsDiffer || listsDiffer) ? finalOutputFileCount : null,
4383
+ files_diff_note: (countsDiffer || listsDiffer) ? reconciliationNote : null,
4315
4384
  };
4316
4385
  }
4317
4386
 
@@ -4564,9 +4633,101 @@ function validateMaturityEvidenceDetails(details) {
4564
4633
  };
4565
4634
  }
4566
4635
 
4636
+ function scopeEventsForChangeReport(events, change) {
4637
+ const input = Array.isArray(events) ? events : [];
4638
+ if (!change) {
4639
+ return {
4640
+ events: input,
4641
+ audit: {
4642
+ kind: 'project',
4643
+ change: null,
4644
+ input_event_count: input.length,
4645
+ included_event_count: input.length,
4646
+ excluded_event_count: 0,
4647
+ cross_change_data_included: false,
4648
+ },
4649
+ };
4650
+ }
4651
+ const scoped = input
4652
+ .filter((event) => (
4653
+ event?.change === change
4654
+ || event?.attribution?.target_change === change
4655
+ ))
4656
+ .map((event) => {
4657
+ if (event?.change === change) return event;
4658
+ return {
4659
+ ...event,
4660
+ change,
4661
+ attribution: {
4662
+ ...event.attribution,
4663
+ source_change: event.attribution?.source_change || event.change || null,
4664
+ target_change: change,
4665
+ },
4666
+ };
4667
+ });
4668
+ return {
4669
+ events: scoped,
4670
+ audit: {
4671
+ kind: 'single-change',
4672
+ change,
4673
+ input_event_count: input.length,
4674
+ included_event_count: scoped.length,
4675
+ excluded_event_count: input.length - scoped.length,
4676
+ cross_change_data_included: false,
4677
+ },
4678
+ };
4679
+ }
4680
+
4681
+ function tddPairFinding(pair) {
4682
+ const definitions = {
4683
+ 'red-did-not-fail': {
4684
+ code: 'TDD_PAIR_RED_DID_NOT_FAIL',
4685
+ expected: 'RED 测试必须以非零退出码失败,并至少包含 1 个失败用例。',
4686
+ actual: '已记录 RED 事件,但测试没有形成有效失败。',
4687
+ root_cause: 'RED 事件的 valid_red、exit_code 或 failed 计数不满足失败测试约束。',
4688
+ impact: '该 TDD 对不能证明实现前测试曾有效拦截缺失行为。',
4689
+ remediation: '修正 RED 用例并重新记录失败结果,再执行对应 GREEN 或回归测试。',
4690
+ },
4691
+ 'red-missing': {
4692
+ code: 'TDD_PAIR_RED_MISSING',
4693
+ expected: '每个 TDD 对都应先存在可定位的有效 RED 事件。',
4694
+ actual: '未找到该 TDD 对对应的 RED 事件。',
4695
+ root_cause: '任务与事件中没有可关联到该 pair 的 RED 证据。',
4696
+ impact: '无法证明测试先于实现并曾经失败。',
4697
+ remediation: '补充并记录该 pair 的 RED 失败测试;不能事后伪造历史证据。',
4698
+ },
4699
+ 'green-missing': {
4700
+ code: 'TDD_PAIR_GREEN_MISSING',
4701
+ expected: '有效 RED 之后应存在覆盖对应任务的 GREEN 或回归成功事件。',
4702
+ actual: '存在有效 RED,但没有找到其后的成功 GREEN/回归事件。',
4703
+ root_cause: '后续成功测试缺失、时间顺序错误,或未覆盖对应 GREEN 任务。',
4704
+ impact: '无法证明该 TDD 对已经由失败转为通过。',
4705
+ remediation: '执行对应 GREEN/回归测试并记录覆盖任务与成功结果。',
4706
+ },
4707
+ };
4708
+ const detail = definitions[pair.status] || {
4709
+ code: 'TDD_PAIR_UNCLASSIFIED',
4710
+ expected: 'TDD 对应具备可判定的 RED→GREEN 证据。',
4711
+ actual: `该 TDD 对状态为 ${pair.status || 'unclassified'}。`,
4712
+ root_cause: '现有任务或测试事件不足以完成配对。',
4713
+ impact: '该 TDD 对的完整性不可判定。',
4714
+ remediation: '补充 pair、任务和测试事件之间的明确关联。',
4715
+ };
4716
+ return {
4717
+ ...detail,
4718
+ severity: 'warning',
4719
+ verdict: pair.status,
4720
+ pair_id: pair.pair_id,
4721
+ task_id: pair.red_task_id || pair.green_task_id || null,
4722
+ message: `${pair.pair_id}:${detail.actual}`,
4723
+ };
4724
+ }
4725
+
4567
4726
  function buildReport(projectRoot, events, options = {}) {
4727
+ const scopedReport = scopeEventsForChangeReport(events, options.change);
4728
+ events = scopedReport.events;
4568
4729
  const archiveEvent = latestByTimestamp(events.filter(e => {
4569
- return e.command === 'archive' && e.type === 'stage_end' && !e.orphan;
4730
+ return e.command === 'archive' && e.type === 'stage_end';
4570
4731
  }));
4571
4732
  const gitCommitTimeline = resolveGitCommitTimeline(
4572
4733
  events,
@@ -4618,18 +4779,46 @@ function buildReport(projectRoot, events, options = {}) {
4618
4779
  });
4619
4780
  } catch { maturity = null; }
4620
4781
 
4621
- const archiveTaskCompletion = archiveEvent?.details?.archive_result?.task_completion || null;
4782
+ const archiveTaskCompletion = options.taskModel
4783
+ || archiveEvent?.details?.archive_result?.task_completion
4784
+ || null;
4622
4785
  const applyEvidenceAudit = options.change && archiveTaskCompletion
4623
4786
  ? buildApplyEvidenceAudit(options.change, archiveTaskCompletion, events, { mode: 'report-only' })
4624
4787
  : null;
4788
+ const taskEvidenceAudit = options.change && archiveTaskCompletion
4789
+ ? buildTaskEvidenceAudit(options.change, archiveTaskCompletion, events, { mode: 'report-only' })
4790
+ : null;
4791
+ const tddPairAudit = options.change && archiveTaskCompletion
4792
+ ? buildTddPairAudit(options.change, archiveTaskCompletion, events, { mode: 'report-only' })
4793
+ : null;
4794
+ if (applyEvidenceAudit && tddPairAudit) {
4795
+ applyEvidenceAudit.tdd = {
4796
+ required: tddPairAudit.required,
4797
+ red_green_complete: tddPairAudit.complete,
4798
+ };
4799
+ }
4625
4800
  const baseEvidenceAlerts = metricsV3.buildEvidenceAlerts(events);
4626
- const auditAlerts = (applyEvidenceAudit?.findings || []).map((finding) => ({
4627
- key: `${finding.code}:${finding.task_id || options.change || 'change'}`,
4801
+ const evidenceFindings = [
4802
+ ...(applyEvidenceAudit?.findings || []),
4803
+ ...((tddPairAudit?.pairs || [])
4804
+ .filter((pair) => pair.status !== 'complete')
4805
+ .map(tddPairFinding)),
4806
+ ];
4807
+ const auditAlerts = evidenceFindings.map((finding) => ({
4808
+ key: `${finding.code}:${finding.pair_id || finding.task_id || options.change || 'change'}`,
4628
4809
  category: 'evidence',
4629
4810
  warning: finding.code,
4630
- subject: finding.task_id || options.change || null,
4811
+ subject: finding.pair_id || finding.task_id || options.change || null,
4812
+ task_id: finding.task_id || null,
4813
+ pair_id: finding.pair_id || null,
4631
4814
  message: finding.message,
4632
4815
  severity: finding.severity || 'warning',
4816
+ verdict: finding.verdict || 'evidence-gap',
4817
+ expected: finding.expected || null,
4818
+ actual: finding.actual || finding.message || null,
4819
+ root_cause: finding.root_cause || null,
4820
+ impact: finding.impact || null,
4821
+ remediation: finding.remediation || null,
4633
4822
  status: 'open',
4634
4823
  occurrences: 1,
4635
4824
  opened_by_event_id: null,
@@ -4738,6 +4927,7 @@ function buildReport(projectRoot, events, options = {}) {
4738
4927
  active_links: [],
4739
4928
  unassigned_candidate_event_ids: [],
4740
4929
  },
4930
+ report_scope: scopedReport.audit,
4741
4931
  conclusion: {
4742
4932
  status: evidenceAlerts.open.length > 0 || trustRate < 1 || primaryTasks?.has_incomplete ? 'attention' : 'trusted',
4743
4933
  text: `${taskStatement};${trustStatement};${alertStatement}。`,
@@ -4746,9 +4936,9 @@ function buildReport(projectRoot, events, options = {}) {
4746
4936
  alert_statement: alertStatement,
4747
4937
  },
4748
4938
  metrics,
4749
- legacy_metrics: metrics.legacy_metrics || null,
4750
4939
  doctor,
4751
- archive_result: archiveEvent?.details?.archive_result || null,
4940
+ archive_result: archiveEvent?.details?.archive_result
4941
+ || (options.taskModel ? { task_completion: options.taskModel } : null),
4752
4942
  changed_files: extractChangedFiles(events, projectRoot),
4753
4943
  timeline: extractTimeline(events),
4754
4944
  stage_timeline: extractStageTimeline(events),
@@ -4765,12 +4955,115 @@ function buildReport(projectRoot, events, options = {}) {
4765
4955
  evidence_alerts: evidenceAlerts,
4766
4956
  check_warning_dispositions: checkWarningDispositions,
4767
4957
  apply_evidence_audit: applyEvidenceAudit,
4958
+ task_evidence_audit: taskEvidenceAudit,
4959
+ tdd_pair_audit: tddPairAudit,
4768
4960
  scheme_compliance: schemeCompliance,
4769
4961
  maturity,
4770
4962
  };
4771
4963
  return require('./reporting/change-report-model.cjs').buildChangeReportModel(baseReport, events);
4772
4964
  }
4773
4965
 
4966
+ function reportArtifactPaths(projectRoot, archiveResult = {}, explicitPaths = []) {
4967
+ const declared = [
4968
+ archiveResult.report_path,
4969
+ archiveResult.report_html_path,
4970
+ archiveResult.report_json_path,
4971
+ ...explicitPaths,
4972
+ ].filter(Boolean).map((filePath) => (
4973
+ path.isAbsolute(filePath) ? filePath : path.resolve(projectRoot, filePath)
4974
+ ));
4975
+ return [...new Set(declared)].filter((filePath) => ['.md', '.html', '.json'].includes(
4976
+ path.extname(filePath).toLowerCase(),
4977
+ ));
4978
+ }
4979
+
4980
+ function renderReportArtifact(report, filePath) {
4981
+ const extension = path.extname(filePath).toLowerCase();
4982
+ if (extension === '.json') return JSON.stringify(report, null, 2);
4983
+ if (extension === '.html') return renderExecutiveReportHtml(report);
4984
+ return renderExecutiveReportMarkdown(report);
4985
+ }
4986
+
4987
+ function artifactSizeVector(projectRoot, report) {
4988
+ const required = [
4989
+ 'markdown_report',
4990
+ 'html_report',
4991
+ 'json_report',
4992
+ 'execution_log',
4993
+ 'manifest',
4994
+ 'archive_zip',
4995
+ ];
4996
+ const embedded = {};
4997
+ const actual = {};
4998
+ for (const name of required) {
4999
+ const artifact = report.artifacts?.files?.[name];
5000
+ if (!artifact?.path) {
5001
+ throw new Error(`E_ARTIFACT_INVENTORY_INCOMPLETE: ${name} 缺少最终路径`);
5002
+ }
5003
+ const absolutePath = path.isAbsolute(artifact.path)
5004
+ ? artifact.path
5005
+ : path.resolve(projectRoot, artifact.path);
5006
+ if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
5007
+ throw new Error(`E_ARTIFACT_INVENTORY_INCOMPLETE: ${name} 未落盘 (${artifact.path})`);
5008
+ }
5009
+ embedded[name] = artifact.size_bytes;
5010
+ actual[name] = fs.statSync(absolutePath).size;
5011
+ }
5012
+ return { embedded, actual };
5013
+ }
5014
+
5015
+ function sameArtifactSizeVector(left, right) {
5016
+ return Object.keys(left).every(name => left[name] === right[name]);
5017
+ }
5018
+
5019
+ function finalizeArtifactInventory(options = {}) {
5020
+ const projectRoot = options.projectRoot;
5021
+ const change = options.change;
5022
+ const archiveResult = options.archiveResult || {};
5023
+ const dataDir = options.dataDir || getDataDir(projectRoot);
5024
+ const paths = reportArtifactPaths(projectRoot, archiveResult, options.reportPaths || []);
5025
+ if (paths.length !== 3) {
5026
+ throw new Error(`E_ARTIFACT_INVENTORY_INCOMPLETE: 需要 md/html/json 三份报告,实际 ${paths.length} 份`);
5027
+ }
5028
+ const archiveDirAbs = path.isAbsolute(archiveResult.archive_path)
5029
+ ? archiveResult.archive_path
5030
+ : path.resolve(projectRoot, archiveResult.archive_path);
5031
+ let previousActual = null;
5032
+ for (let round = 1; round <= 5; round += 1) {
5033
+ const report = buildReport(projectRoot, readEvents(dataDir, change), {
5034
+ level: options.level || 'change',
5035
+ change,
5036
+ capability: options.capability,
5037
+ formats: ['md', 'html', 'json'],
5038
+ });
5039
+ for (const filePath of paths) {
5040
+ atomicWriteText(filePath, `${renderReportArtifact(report, filePath)}\n`);
5041
+ }
5042
+ ensureArchiveManifest(projectRoot, change, archiveDirAbs, {
5043
+ reason: archiveResult.reason || '',
5044
+ method: archiveResult.method || 'skywalk-full-spec-archive',
5045
+ archivePath: archiveDirAbs,
5046
+ archivedAt: archiveResult.created_at || archiveResult.archived_at,
5047
+ copiedSpecs: archiveResult.copied_specs,
5048
+ finalizeAfterReports: true,
5049
+ runIdentity: options.runIdentity,
5050
+ });
5051
+ const vector = artifactSizeVector(projectRoot, report);
5052
+ const embeddedMatchesDisk = sameArtifactSizeVector(vector.embedded, vector.actual);
5053
+ const actualMatchesPrevious = previousActual
5054
+ && sameArtifactSizeVector(previousActual, vector.actual);
5055
+ if (embeddedMatchesDisk && actualMatchesPrevious) {
5056
+ return {
5057
+ stable: true,
5058
+ rounds: round,
5059
+ sizes: vector.actual,
5060
+ };
5061
+ }
5062
+ previousActual = vector.actual;
5063
+ }
5064
+ throw new Error('E_ARTIFACT_INVENTORY_NOT_STABLE: 六类产物大小在 5 轮内未收敛');
5065
+ }
5066
+
4774
5067
  function filterEventsByDate(events, dateFrom, dateTo) {
4775
5068
  if (!dateFrom && !dateTo) return events;
4776
5069
  return events.filter(e => {
@@ -5078,15 +5371,29 @@ function cmdEnd(args, options = {}) {
5078
5371
  const archiveDirAbs = path.isAbsolute(archiveResult.archive_path)
5079
5372
  ? archiveResult.archive_path
5080
5373
  : 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
- });
5374
+ const existingReportPaths = reportArtifactPaths(projectRoot, archiveResult);
5375
+ if (existingReportPaths.length === 3) {
5376
+ finalizeArtifactInventory({
5377
+ projectRoot,
5378
+ dataDir,
5379
+ change,
5380
+ archiveResult,
5381
+ reportPaths: existingReportPaths,
5382
+ level: args['report-level'] || 'change',
5383
+ capability: args.capability || args['capability-name'] || startEvent?.capability,
5384
+ runIdentity: latestExisting.event_id,
5385
+ });
5386
+ } else {
5387
+ ensureArchiveManifest(projectRoot, change, archiveDirAbs, {
5388
+ reason: archiveResult.reason || '',
5389
+ method: archiveResult.method || 'skywalk-full-spec-archive',
5390
+ archivePath: archiveDirAbs,
5391
+ archivedAt: archiveResult.created_at || archiveResult.archived_at,
5392
+ copiedSpecs: archiveResult.copied_specs,
5393
+ finalizeAfterReports: true,
5394
+ runIdentity: latestExisting.event_id,
5395
+ });
5396
+ }
5090
5397
  }
5091
5398
  const output = {
5092
5399
  event_id: eventId,
@@ -5339,13 +5646,14 @@ function cmdEnd(args, options = {}) {
5339
5646
  const archiveDirAbs = path.isAbsolute(finalArchiveResult.archive_path)
5340
5647
  ? finalArchiveResult.archive_path
5341
5648
  : 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,
5649
+ finalizeArtifactInventory({
5650
+ projectRoot,
5651
+ dataDir,
5652
+ change,
5653
+ archiveResult: finalArchiveResult,
5654
+ reportPaths: writtenReportPaths,
5655
+ level: args['report-level'] || 'change',
5656
+ capability: args.capability || args['capability-name'] || startEvent?.capability,
5349
5657
  runIdentity: event.event_id,
5350
5658
  });
5351
5659
  }
@@ -5468,6 +5776,153 @@ function isValidCompletionTestResults(testResults) {
5468
5776
  return true;
5469
5777
  }
5470
5778
 
5779
+ /**
5780
+ * Pure task↔test reference resolver.
5781
+ * Accepts mutually exclusive test_event_id or test_run_id.
5782
+ * Does not throw; callers decide whether to fail strict writes.
5783
+ */
5784
+ function resolveTaskVerificationReference(events, input = {}) {
5785
+ const taskId = input.task_id || input.taskId || null;
5786
+ const change = input.change || null;
5787
+ const inputEventId = input.test_event_id || null;
5788
+ const inputRunId = input.test_run_id || null;
5789
+ const verificationNotApplicable = input.verification_not_applicable === true;
5790
+ const verificationReason = String(
5791
+ input.verification_not_applicable_reason
5792
+ || input.verification_reason
5793
+ || '',
5794
+ ).trim();
5795
+
5796
+ const base = {
5797
+ status: 'missing',
5798
+ resolved_event_id: null,
5799
+ input_event_id: inputEventId,
5800
+ input_run_id: inputRunId,
5801
+ covered_task_ids: [],
5802
+ reason_code: null,
5803
+ };
5804
+
5805
+ if (verificationNotApplicable) {
5806
+ if (!verificationReason) {
5807
+ return {
5808
+ ...base,
5809
+ status: 'invalid',
5810
+ reason_code: 'TASK_VERIFICATION_REASON_REQUIRED',
5811
+ };
5812
+ }
5813
+ return {
5814
+ ...base,
5815
+ status: 'waived',
5816
+ reason_code: 'VERIFICATION_NOT_APPLICABLE',
5817
+ };
5818
+ }
5819
+
5820
+ if (inputEventId && inputRunId) {
5821
+ return {
5822
+ ...base,
5823
+ status: 'invalid',
5824
+ reason_code: 'TASK_TEST_REFERENCE_MUTEX',
5825
+ };
5826
+ }
5827
+
5828
+ if (!inputEventId && !inputRunId) {
5829
+ return {
5830
+ ...base,
5831
+ status: 'missing',
5832
+ reason_code: 'TASK_TEST_REFERENCE_MISSING',
5833
+ };
5834
+ }
5835
+
5836
+ const scoped = (events || []).filter((event) => {
5837
+ if (!change) return true;
5838
+ return event.change === change || event.attribution?.target_change === change;
5839
+ });
5840
+
5841
+ function evaluateCandidate(event) {
5842
+ if (!event) {
5843
+ return { ...base, status: 'invalid', reason_code: 'TASK_TEST_REFERENCE_MISSING' };
5844
+ }
5845
+ if (change
5846
+ && event.change
5847
+ && event.change !== change
5848
+ && event.attribution?.target_change !== change) {
5849
+ return { ...base, status: 'invalid', reason_code: 'TASK_TEST_REFERENCE_CROSS_CHANGE' };
5850
+ }
5851
+ if (event.type !== 'test_result') {
5852
+ return { ...base, status: 'invalid', reason_code: 'TASK_TEST_REFERENCE_NOT_TEST' };
5853
+ }
5854
+ const testResults = event.details?.test_results || {};
5855
+ const covered = collectVerifiedTaskIds(event);
5856
+ if (testResults.evidence_tier !== 'strict') {
5857
+ return {
5858
+ ...base,
5859
+ status: 'invalid',
5860
+ resolved_event_id: event.event_id || null,
5861
+ covered_task_ids: covered,
5862
+ reason_code: 'TASK_TEST_REFERENCE_NON_STRICT',
5863
+ };
5864
+ }
5865
+ if (!isValidCompletionTestResults(testResults)) {
5866
+ return {
5867
+ ...base,
5868
+ status: 'invalid',
5869
+ resolved_event_id: event.event_id || null,
5870
+ covered_task_ids: covered,
5871
+ reason_code: 'TASK_TEST_REFERENCE_INVALID',
5872
+ };
5873
+ }
5874
+ if (taskId && !covered.includes(taskId)) {
5875
+ return {
5876
+ ...base,
5877
+ status: 'invalid',
5878
+ resolved_event_id: event.event_id || null,
5879
+ covered_task_ids: covered,
5880
+ reason_code: 'E_TEST_IDENTITY_MISMATCH',
5881
+ };
5882
+ }
5883
+ return {
5884
+ ...base,
5885
+ status: 'verified',
5886
+ resolved_event_id: event.event_id || null,
5887
+ covered_task_ids: covered,
5888
+ reason_code: null,
5889
+ };
5890
+ }
5891
+
5892
+ if (inputEventId) {
5893
+ const referenced = scoped.find(event => event.event_id === inputEventId);
5894
+ return evaluateCandidate(referenced);
5895
+ }
5896
+
5897
+ const candidates = scoped.filter((event) => {
5898
+ if (event.type !== 'test_result') return false;
5899
+ if (event.run_id !== inputRunId) return false;
5900
+ const testResults = event.details?.test_results || {};
5901
+ if (testResults.evidence_tier !== 'strict') return false;
5902
+ if (!isValidCompletionTestResults(testResults)) return false;
5903
+ if (taskId && !collectVerifiedTaskIds(event).includes(taskId)) return false;
5904
+ return true;
5905
+ });
5906
+
5907
+ if (candidates.length === 0) {
5908
+ const anySameRun = scoped.filter(event => event.type === 'test_result' && event.run_id === inputRunId);
5909
+ if (anySameRun.length === 0) {
5910
+ return { ...base, status: 'invalid', reason_code: 'TASK_TEST_REFERENCE_MISSING' };
5911
+ }
5912
+ return evaluateCandidate(anySameRun[0]);
5913
+ }
5914
+ if (candidates.length > 1) {
5915
+ return {
5916
+ ...base,
5917
+ status: 'ambiguous',
5918
+ candidate_event_ids: candidates.map(event => event.event_id).filter(Boolean),
5919
+ covered_task_ids: [...new Set(candidates.flatMap(collectVerifiedTaskIds))],
5920
+ reason_code: 'TASK_TEST_REFERENCE_AMBIGUOUS',
5921
+ };
5922
+ }
5923
+ return evaluateCandidate(candidates[0]);
5924
+ }
5925
+
5471
5926
  function normalizeStrictProcessNote(args, details) {
5472
5927
  const input = details?.process_note && typeof details.process_note === 'object'
5473
5928
  ? details.process_note
@@ -5514,6 +5969,7 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
5514
5969
  'run_id',
5515
5970
  );
5516
5971
  const testEventId = input?.test_event_id || details?.test_event_id || null;
5972
+ const testRunId = input?.test_run_id || details?.test_run_id || null;
5517
5973
  const verificationNotApplicable = input?.verification_not_applicable === true
5518
5974
  || details?.verification_not_applicable === true;
5519
5975
  const verificationReason = String(
@@ -5524,33 +5980,35 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
5524
5980
  || '',
5525
5981
  ).trim();
5526
5982
 
5983
+ let resolvedTestEventId = testEventId;
5984
+ let resolvedTestRunId = testRunId || undefined;
5985
+
5527
5986
  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
- }
5987
+ const events = readEvents(dataDir, change);
5988
+ const resolution = resolveTaskVerificationReference(events, {
5989
+ task_id: taskId,
5990
+ change,
5991
+ test_event_id: testEventId,
5992
+ test_run_id: testRunId,
5993
+ verification_not_applicable: verificationNotApplicable,
5994
+ verification_not_applicable_reason: verificationReason,
5995
+ });
5996
+
5997
+ if (resolution.status === 'waived') {
5998
+ // ok
5999
+ } else if (resolution.status === 'verified') {
6000
+ resolvedTestEventId = resolution.resolved_event_id;
6001
+ if (testRunId) resolvedTestRunId = testRunId;
5551
6002
  if (typeof input?.tdd_required !== 'boolean') {
5552
6003
  fail('TASK_TDD_REQUIRED_MISSING: completed 任务引用测试时必须明确 tdd_required=true/false');
5553
6004
  }
6005
+ } else if (resolution.status === 'ambiguous') {
6006
+ fail(`TASK_TEST_REFERENCE_AMBIGUOUS: test_run_id "${testRunId}" 对应多个严格测试候选,拒绝猜测`);
6007
+ } else if (resolution.status === 'missing') {
6008
+ fail('TASK_TEST_REFERENCE_MISSING: completed 任务必须引用 test_event_id 或唯一 test_run_id,或声明 verification_not_applicable=true 并说明原因');
6009
+ } else {
6010
+ const code = resolution.reason_code || 'TASK_TEST_REFERENCE_INVALID';
6011
+ fail(`${code}: 测试引用无法验证任务 "${taskId}"`);
5554
6012
  }
5555
6013
  }
5556
6014
 
@@ -5564,7 +6022,8 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
5564
6022
  ...(details.task_update || {}),
5565
6023
  task_id: taskId,
5566
6024
  status,
5567
- test_event_id: testEventId,
6025
+ test_event_id: resolvedTestEventId,
6026
+ test_run_id: resolvedTestRunId,
5568
6027
  tdd_required: typeof input?.tdd_required === 'boolean' ? input.tdd_required : undefined,
5569
6028
  test_requirement: input?.test_requirement
5570
6029
  || (verificationNotApplicable ? 'not_applicable' : 'required'),
@@ -5594,6 +6053,325 @@ function eventIdempotencyFingerprint(event) {
5594
6053
  });
5595
6054
  }
5596
6055
 
6056
+ function collectVerifiedTaskIds(event) {
6057
+ const testResults = event?.details?.test_results || {};
6058
+ return [...new Set([
6059
+ event?.task_id,
6060
+ testResults.task_id,
6061
+ ...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
6062
+ ].filter(Boolean))];
6063
+ }
6064
+
6065
+ function isStrictCoveringTest(event, taskId) {
6066
+ const testResults = event?.details?.test_results || {};
6067
+ if (testResults.evidence_tier !== 'strict') return false;
6068
+ if (!isValidCompletionTestResults(testResults)) return false;
6069
+ return new Set(collectVerifiedTaskIds(event)).has(taskId);
6070
+ }
6071
+
6072
+ /**
6073
+ * Four-way task evidence audit (document / completion event / direct backlink / strict coverage).
6074
+ * Does not treat invalid direct backlinks as "no tests".
6075
+ */
6076
+ function buildTaskEvidenceAudit(change, taskModel, events = [], options = {}) {
6077
+ const completedTasks = (taskModel?.primary_tasks?.tasks || []).filter(task => task.completed);
6078
+ const latestTaskUpdates = new Map();
6079
+ const testEvents = new Map();
6080
+ const strictByTask = new Map();
6081
+
6082
+ for (const event of events) {
6083
+ if (event.change && change && event.change !== change) continue;
6084
+ if (event.type === 'task_update'
6085
+ && (event.status === 'completed' || event.details?.task_update?.status === 'completed')) {
6086
+ const existing = latestTaskUpdates.get(event.task_id);
6087
+ if (!existing || timestampMs(event) > timestampMs(existing)) {
6088
+ latestTaskUpdates.set(event.task_id, event);
6089
+ }
6090
+ continue;
6091
+ }
6092
+ if (event.type === 'test_result') {
6093
+ testEvents.set(event.event_id, event);
6094
+ for (const verifiedTaskId of collectVerifiedTaskIds(event)) {
6095
+ if (!isStrictCoveringTest(event, verifiedTaskId)) continue;
6096
+ const related = strictByTask.get(verifiedTaskId) || [];
6097
+ related.push(event);
6098
+ strictByTask.set(verifiedTaskId, related);
6099
+ }
6100
+ }
6101
+ }
6102
+
6103
+ let withUpdate = 0;
6104
+ let directVerified = 0;
6105
+ let strictVerified = 0;
6106
+ let waived = 0;
6107
+ const tasks = [];
6108
+
6109
+ for (const task of completedTasks) {
6110
+ const update = latestTaskUpdates.get(task.task_id);
6111
+ const updateDetails = update?.details?.task_update || update?.details || {};
6112
+ const notApplicable = updateDetails.verification_not_applicable === true
6113
+ && Boolean(String(updateDetails.verification_not_applicable_reason || '').trim());
6114
+ const testEventId = updateDetails.test_event_id || null;
6115
+ const testEvent = testEventId ? testEvents.get(testEventId) : null;
6116
+ const directOk = Boolean(testEvent) && isStrictCoveringTest(testEvent, task.task_id);
6117
+ const strictOk = (strictByTask.get(task.task_id) || []).length > 0;
6118
+
6119
+ if (update) withUpdate += 1;
6120
+ if (directOk) directVerified += 1;
6121
+ if (strictOk) strictVerified += 1;
6122
+ if (notApplicable) waived += 1;
6123
+
6124
+ let backlinkStatus = 'missing';
6125
+ if (notApplicable) backlinkStatus = 'waived';
6126
+ else if (!testEventId) backlinkStatus = 'missing';
6127
+ else if (!testEvent) backlinkStatus = 'invalid';
6128
+ else if (!directOk) backlinkStatus = 'invalid';
6129
+ else backlinkStatus = 'verified';
6130
+
6131
+ tasks.push({
6132
+ task_id: task.task_id,
6133
+ has_completion_event: Boolean(update),
6134
+ direct_backlink_status: backlinkStatus,
6135
+ test_event_id: testEventId,
6136
+ strict_covered: strictOk,
6137
+ verification_not_applicable: notApplicable,
6138
+ });
6139
+ }
6140
+
6141
+ return {
6142
+ mode: options.mode === 'report-only' ? 'report-only' : 'strict',
6143
+ document_completion: {
6144
+ completed: completedTasks.length,
6145
+ total: taskModel?.primary_tasks?.total ?? completedTasks.length,
6146
+ source: 'tasks-document',
6147
+ },
6148
+ completion_event_coverage: {
6149
+ verified: withUpdate,
6150
+ total: completedTasks.length,
6151
+ },
6152
+ direct_test_backlink: {
6153
+ verified: directVerified,
6154
+ total: completedTasks.length,
6155
+ },
6156
+ strict_test_coverage: {
6157
+ verified: strictVerified,
6158
+ total: completedTasks.length,
6159
+ },
6160
+ verification_waivers: {
6161
+ count: waived,
6162
+ total: completedTasks.length,
6163
+ },
6164
+ tasks,
6165
+ };
6166
+ }
6167
+
6168
+ function extractTddPairNumber(text) {
6169
+ if (!text) return null;
6170
+ const red = String(text).match(/\bRED-(\d+)\b/i) || String(text).match(/red(\d+)/i);
6171
+ if (red) return { role: 'red', pair: Number(red[1]) };
6172
+ const green = String(text).match(/\bGREEN-(\d+)\b/i) || String(text).match(/green(\d+)/i);
6173
+ if (green) return { role: 'green', pair: Number(green[1]) };
6174
+ return null;
6175
+ }
6176
+
6177
+ function buildTddPairDefinitions(taskModel, events = [], options = {}) {
6178
+ if (Array.isArray(options.pair_definitions) && options.pair_definitions.length > 0) {
6179
+ return options.pair_definitions.map((item) => ({
6180
+ pair_id: item.pair_id || `pair-${item.pair_number}`,
6181
+ pair_number: Number(item.pair_number),
6182
+ red_task_id: item.red_task_id || null,
6183
+ green_task_id: item.green_task_id || null,
6184
+ }));
6185
+ }
6186
+
6187
+ const byNumber = new Map();
6188
+ const ensure = (num) => {
6189
+ if (!byNumber.has(num)) {
6190
+ byNumber.set(num, {
6191
+ pair_id: `pair-${num}`,
6192
+ pair_number: num,
6193
+ red_task_id: null,
6194
+ green_task_id: null,
6195
+ });
6196
+ }
6197
+ return byNumber.get(num);
6198
+ };
6199
+
6200
+ for (const task of (taskModel?.primary_tasks?.tasks || [])) {
6201
+ const hint = extractTddPairNumber(task.title || task.summary || task.task_id);
6202
+ if (!hint || !Number.isFinite(hint.pair)) continue;
6203
+ const entry = ensure(hint.pair);
6204
+ if (hint.role === 'red') entry.red_task_id = task.task_id;
6205
+ if (hint.role === 'green') entry.green_task_id = task.task_id;
6206
+ }
6207
+
6208
+ for (const event of events) {
6209
+ if (event.type !== 'test_result') continue;
6210
+ const explicit = event.details?.test_results?.tdd_pair_id
6211
+ || event.details?.tdd_pair_id;
6212
+ if (explicit) {
6213
+ const numMatch = String(explicit).match(/(\d+)/);
6214
+ const num = numMatch ? Number(numMatch[1]) : null;
6215
+ if (!num) continue;
6216
+ const entry = ensure(num);
6217
+ const role = event.details?.test_results?.tdd_role
6218
+ || event.details?.test_results?.tdd_phase;
6219
+ if (role === 'red') entry.red_task_id = entry.red_task_id || event.task_id;
6220
+ if (role === 'green' || role === 'regression') {
6221
+ entry.green_task_id = entry.green_task_id || event.task_id;
6222
+ }
6223
+ continue;
6224
+ }
6225
+ const fromRun = extractTddPairNumber(event.run_id);
6226
+ const fromSummary = extractTddPairNumber(event.summary);
6227
+ const hint = fromRun || fromSummary || extractTddPairNumber(event.details?.test_results?.tdd_phase);
6228
+ if (!hint) continue;
6229
+ const entry = ensure(hint.pair);
6230
+ const phase = event.details?.test_results?.tdd_phase;
6231
+ if (hint.role === 'red' || phase === 'red') {
6232
+ entry.red_task_id = entry.red_task_id || event.task_id;
6233
+ }
6234
+ if (hint.role === 'green' || phase === 'green' || phase === 'regression') {
6235
+ // Prefer dedicated green task over regression umbrella task when already set.
6236
+ if (!entry.green_task_id || phase === 'green') {
6237
+ if (phase === 'green' || hint.role === 'green') {
6238
+ entry.green_task_id = entry.green_task_id || event.task_id;
6239
+ }
6240
+ }
6241
+ }
6242
+ }
6243
+
6244
+ // 兼容没有 RED-n/GREEN-n 编号、但 task_update 明确声明 tdd_required 的
6245
+ // 同任务链。仅在完全没有显式/可推导 pair 时启用,避免与编号 pair 重复计数。
6246
+ if (byNumber.size === 0) {
6247
+ const implicitTaskIds = [...new Set(events
6248
+ .filter(event => (
6249
+ event.type === 'task_update'
6250
+ && event.task_id
6251
+ && event.details?.task_update?.tdd_required === true
6252
+ ))
6253
+ .map(event => event.task_id))];
6254
+ return implicitTaskIds.map((taskId, index) => ({
6255
+ pair_id: `task-pair:${taskId}`,
6256
+ pair_number: index + 1,
6257
+ red_task_id: taskId,
6258
+ green_task_id: taskId,
6259
+ }));
6260
+ }
6261
+
6262
+ return [...byNumber.values()].sort((a, b) => a.pair_number - b.pair_number);
6263
+ }
6264
+
6265
+ /**
6266
+ * Pair-level TDD audit. Historical fallback uses RED-n/GREEN-n labels and run_ids.
6267
+ */
6268
+ function buildTddPairAudit(change, taskModel, events = [], options = {}) {
6269
+ const scopedEvents = events.filter(event => (
6270
+ !change
6271
+ || event.change === change
6272
+ || event.attribution?.target_change === change
6273
+ ));
6274
+ const definitions = buildTddPairDefinitions(taskModel, scopedEvents, options);
6275
+ const testsByTask = new Map();
6276
+
6277
+ for (const event of scopedEvents) {
6278
+ if (event.type !== 'test_result') continue;
6279
+ for (const taskId of collectVerifiedTaskIds(event)) {
6280
+ const related = testsByTask.get(taskId) || [];
6281
+ related.push(event);
6282
+ testsByTask.set(taskId, related);
6283
+ }
6284
+ if (event.task_id) {
6285
+ const related = testsByTask.get(event.task_id) || [];
6286
+ if (!related.includes(event)) related.push(event);
6287
+ testsByTask.set(event.task_id, related);
6288
+ }
6289
+ }
6290
+ for (const related of testsByTask.values()) {
6291
+ related.sort((a, b) => {
6292
+ const delta = timestampMs(a) - timestampMs(b);
6293
+ if (delta !== 0) return delta;
6294
+ return String(a.event_id || '').localeCompare(String(b.event_id || ''));
6295
+ });
6296
+ }
6297
+
6298
+ const pairs = [];
6299
+ let complete = 0;
6300
+
6301
+ for (const def of definitions) {
6302
+ const redEvents = (testsByTask.get(def.red_task_id) || [])
6303
+ .filter(event => event.details?.test_results?.tdd_phase === 'red');
6304
+ const greenOrRegression = [
6305
+ ...(testsByTask.get(def.green_task_id) || []),
6306
+ ...(testsByTask.get(def.red_task_id) || []),
6307
+ ].filter((event, index, arr) => arr.indexOf(event) === index)
6308
+ .filter(event => ['green', 'regression'].includes(event.details?.test_results?.tdd_phase)
6309
+ && event.details?.test_results?.exit_code === 0);
6310
+
6311
+ let status = 'unclassified';
6312
+ if (redEvents.length === 0 && def.green_task_id) {
6313
+ const hasGreenSide = greenOrRegression.some(event => collectVerifiedTaskIds(event).includes(def.green_task_id)
6314
+ || event.task_id === def.green_task_id);
6315
+ status = hasGreenSide || def.red_task_id ? 'red-missing' : 'unclassified';
6316
+ if (redEvents.length === 0) status = 'red-missing';
6317
+ }
6318
+
6319
+ if (redEvents.length > 0) {
6320
+ const firstRed = redEvents.find((event) => {
6321
+ const results = event.details?.test_results || {};
6322
+ return results.valid_red === true
6323
+ && results.exit_code !== 0
6324
+ && Number(results.failed || 0) > 0;
6325
+ });
6326
+ if (!firstRed) {
6327
+ status = 'red-did-not-fail';
6328
+ } else {
6329
+ const redTs = timestampMs(firstRed);
6330
+ const greenAfter = greenOrRegression.find(event => (
6331
+ timestampMs(event) > redTs
6332
+ && (
6333
+ !def.green_task_id
6334
+ || collectVerifiedTaskIds(event).includes(def.green_task_id)
6335
+ || event.task_id === def.green_task_id
6336
+ || event.details?.test_results?.tdd_phase === 'green'
6337
+ )
6338
+ ));
6339
+ // Accept later regression that covers the green task id.
6340
+ const regressionCover = greenOrRegression.find(event => (
6341
+ timestampMs(event) > redTs
6342
+ && def.green_task_id
6343
+ && collectVerifiedTaskIds(event).includes(def.green_task_id)
6344
+ ));
6345
+ if (greenAfter || regressionCover) {
6346
+ status = 'complete';
6347
+ complete += 1;
6348
+ } else {
6349
+ status = 'green-missing';
6350
+ }
6351
+ }
6352
+ } else if (!def.red_task_id && def.green_task_id) {
6353
+ status = 'red-missing';
6354
+ } else if (def.red_task_id && redEvents.length === 0) {
6355
+ status = 'red-missing';
6356
+ }
6357
+
6358
+ pairs.push({
6359
+ pair_id: def.pair_id,
6360
+ pair_number: def.pair_number,
6361
+ red_task_id: def.red_task_id,
6362
+ green_task_id: def.green_task_id,
6363
+ status,
6364
+ });
6365
+ }
6366
+
6367
+ return {
6368
+ mode: options.mode === 'report-only' ? 'report-only' : 'strict',
6369
+ required: pairs.length,
6370
+ complete,
6371
+ pairs,
6372
+ };
6373
+ }
6374
+
5597
6375
  function buildApplyEvidenceAudit(change, taskModel, events = [], options = {}) {
5598
6376
  const strict = options.mode !== 'report-only';
5599
6377
  const completedTasks = (taskModel?.primary_tasks?.tasks || []).filter(task => task.completed);
@@ -5644,6 +6422,12 @@ function buildApplyEvidenceAudit(change, taskModel, events = [], options = {}) {
5644
6422
  severity: strict ? 'error' : 'warning',
5645
6423
  task_id: task.task_id,
5646
6424
  message: '主任务已标完成,但缺少 completed task_update 事件',
6425
+ verdict: 'missing-task-update',
6426
+ expected: '已完成主任务应存在同一变更下的 completed task_update 事件。',
6427
+ actual: '任务文档标记已完成,但事件流中没有对应 completed task_update。',
6428
+ root_cause: '任务完成状态只写入了 tasks 文档,没有同步记录结构化 task_update 事件。',
6429
+ impact: '任务完成状态无法与测试事件建立直接追溯链。',
6430
+ remediation: '为该任务补录真实 completed task_update;若历史证据不存在,应明确标记为无法追溯。',
5647
6431
  });
5648
6432
  continue;
5649
6433
  }
@@ -5662,39 +6446,52 @@ function buildApplyEvidenceAudit(change, taskModel, events = [], options = {}) {
5662
6446
  && testResults.evidence_tier === 'strict'
5663
6447
  && verifiedTaskIds.has(task.task_id)
5664
6448
  && isValidCompletionTestResults(testResults);
6449
+ const strictCovered = (testsByTask.get(task.task_id) || [])
6450
+ .some(event => isStrictCoveringTest(event, task.task_id));
5665
6451
  if (validTest || notApplicable) {
5666
6452
  withTestEvidence += 1;
6453
+ } else if (strictCovered) {
6454
+ findings.push({
6455
+ code: 'TASK_TEST_LINKAGE_DEFECT',
6456
+ severity: 'warning',
6457
+ task_id: task.task_id,
6458
+ message: '任务存在严格测试覆盖,但 completed task_update 的直接引用无效或缺失(链路缺陷,不是缺测试)',
6459
+ verdict: 'linkage-defect',
6460
+ expected: 'completed task_update 应通过 test_event_id 精确引用覆盖该任务的严格成功测试。',
6461
+ actual: '存在严格测试覆盖,但 task_update 没有有效的直接测试引用。',
6462
+ root_cause: 'task_update 的 test_event_id 缺失、无效,或引用事件没有明确覆盖当前任务。',
6463
+ impact: '测试本身存在,但任务到测试的直接审计链不完整。',
6464
+ remediation: '修正 task_update 的 test_event_id,使其指向覆盖当前任务的 strict 成功测试事件。',
6465
+ });
5667
6466
  } else {
5668
6467
  findings.push({
5669
6468
  code: 'TASK_TEST_EVIDENCE_MISSING',
5670
6469
  severity: strict ? 'error' : 'warning',
5671
6470
  task_id: task.task_id,
5672
6471
  message: 'completed task_update 没有有效测试引用或不适用理由',
6472
+ verdict: 'missing-test-evidence',
6473
+ expected: 'completed task_update 应引用严格成功测试,或提供明确且非空的不适用理由。',
6474
+ actual: '没有有效直接引用,也没有发现可间接覆盖该任务的严格成功测试。',
6475
+ root_cause: '任务完成事件缺少测试证据,且未声明可审计的不适用理由。',
6476
+ impact: '无法验证该任务完成状态是否经过测试。',
6477
+ remediation: '执行覆盖该任务的严格测试并记录引用;确实不适用时填写具体原因。',
5673
6478
  });
5674
6479
  }
5675
6480
 
5676
6481
  if (updateDetails.tdd_required === true) {
5677
6482
  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
6483
  }
5696
6484
  }
5697
6485
 
6486
+ const pairAudit = buildTddPairAudit(change, taskModel, events, options);
6487
+ tddRequired = pairAudit.required;
6488
+ redGreenComplete = pairAudit.complete;
6489
+ if (strict) {
6490
+ findings.push(...pairAudit.pairs
6491
+ .filter(pair => pair.status !== 'complete')
6492
+ .map(pair => ({ ...tddPairFinding(pair), severity: 'error' })));
6493
+ }
6494
+
5698
6495
  return {
5699
6496
  mode: strict ? 'strict' : 'report-only',
5700
6497
  status: findings.some(finding => finding.severity === 'error') ? 'blocked' : 'pass',
@@ -6788,15 +7585,40 @@ function cmdArchiveDocs(args) {
6788
7585
  }
6789
7586
 
6790
7587
  const archiveDir = path.resolve(result.archive_path);
6791
- const finalPackage = ensureArchiveManifest(projectRoot, changeName, archiveDir, {
6792
- reason: result.reason || args.reason || '',
6793
- method: result.method || 'skywalk-full-spec-archive',
6794
- archivePath: archiveDir,
6795
- archivedAt: result.created_at || result.archived_at,
6796
- copiedSpecs: result.copied_specs,
6797
- finalizeAfterReports: true,
6798
- runIdentity: stageEnd?.event_id || `archive-docs:${changeName}`,
6799
- });
7588
+ let finalPackage;
7589
+ if (stageEnd) {
7590
+ // cmdEnd 已完成报告/Manifest/ZIP 的收敛封装;此处只读取终态,禁止再次
7591
+ // 物化导致报告中记录的 Manifest/ZIP 大小失真。
7592
+ const finalEvent = readAllEventEndsById(
7593
+ getDataDir(projectRoot),
7594
+ stageEnd.event_id,
7595
+ changeName,
7596
+ ).sort((a, b) => timestampMs(b) - timestampMs(a))[0];
7597
+ const finalArchiveResult = finalEvent?.details?.archive_result || {};
7598
+ const manifestPath = path.isAbsolute(finalArchiveResult.manifest_path || '')
7599
+ ? finalArchiveResult.manifest_path
7600
+ : path.resolve(projectRoot, finalArchiveResult.manifest_path || path.join(archiveDir, 'archive-manifest.json'));
7601
+ const packagePath = path.isAbsolute(finalArchiveResult.package_path || '')
7602
+ ? finalArchiveResult.package_path
7603
+ : path.resolve(projectRoot, finalArchiveResult.package_path || `${archiveDir}.zip`);
7604
+ finalPackage = {
7605
+ manifest: JSON.parse(fs.readFileSync(manifestPath, 'utf8')),
7606
+ manifest_path: manifestPath,
7607
+ canonical_facts_path: path.resolve(archiveDir, 'canonical-facts.json'),
7608
+ conversion_report_path: path.resolve(archiveDir, 'conversion-report.json'),
7609
+ package_path: packagePath,
7610
+ };
7611
+ } else {
7612
+ finalPackage = ensureArchiveManifest(projectRoot, changeName, archiveDir, {
7613
+ reason: result.reason || args.reason || '',
7614
+ method: result.method || 'skywalk-full-spec-archive',
7615
+ archivePath: archiveDir,
7616
+ archivedAt: result.created_at || result.archived_at,
7617
+ copiedSpecs: result.copied_specs,
7618
+ finalizeAfterReports: true,
7619
+ runIdentity: `archive-docs:${changeName}`,
7620
+ });
7621
+ }
6800
7622
  result = {
6801
7623
  ...result,
6802
7624
  schema_version: finalPackage.manifest.schema_version,
@@ -6994,7 +7816,12 @@ module.exports = {
6994
7816
  formatDuration,
6995
7817
  formatReworkReasons,
6996
7818
  buildReport,
7819
+ finalizeArtifactInventory,
6997
7820
  buildApplyEvidenceAudit,
7821
+ buildTaskEvidenceAudit,
7822
+ buildTddPairAudit,
7823
+ resolveTaskVerificationReference,
7824
+ extractChangedFiles,
6998
7825
  computeGitDocumentMetrics,
6999
7826
  getCheckResults,
7000
7827
  getBuildResults,