kld-sdd 2.6.16 → 2.6.17

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 (58) hide show
  1. package/lib/init.js +65 -6
  2. package/lib/skills-bundle.js +20 -1
  3. package/lib/tool-profiles.js +8 -0
  4. package/package.json +5 -1
  5. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  6. package/skywalk-sdd/context-client.cjs +38 -87
  7. package/skywalk-sdd/index.cjs +719 -108
  8. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  9. package/skywalk-sdd/kb-upload.cjs +505 -0
  10. package/skywalk-sdd/lib/shared.cjs +663 -0
  11. package/skywalk-sdd/metrics-v3.cjs +138 -8
  12. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  13. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  14. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  15. package/skywalk-sdd/ontology/id.cjs +26 -5
  16. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  17. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  18. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  19. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  20. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  21. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  22. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  23. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  24. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  25. package/skywalk-sdd/spec-root.cjs +31 -0
  26. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
  27. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
  28. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
  29. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
  30. package/templates/openspec/proposal.md +7 -3
  31. package/templates/openspec/spec.md +3 -3
  32. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
  33. package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
  34. package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
  35. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  36. package/templates/skills/kld-sdd/opsx-check/SKILL.md +48 -16
  37. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  38. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  39. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  40. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +164 -0
  41. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +116 -0
  42. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  43. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  44. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  45. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  46. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  47. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  48. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  49. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  50. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  51. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  52. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  53. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  54. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  55. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  56. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  57. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  58. package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
@@ -17,6 +17,16 @@ const { resolveProjectArg } = require('./ontology/resolve-spec-root.cjs');
17
17
  const crypto = require('crypto');
18
18
  const { execFileSync } = require('child_process');
19
19
  const metricsV3 = require('./metrics-v3.cjs');
20
+ const {
21
+ parseArgs,
22
+ ensureDir: sharedEnsureDir,
23
+ writeTextAtomic,
24
+ copyDirRecursive,
25
+ readJson: sharedReadJson,
26
+ stripBom,
27
+ today: sharedToday,
28
+ nowISO: sharedNowISO,
29
+ } = require('./lib/shared.cjs');
20
30
 
21
31
  const SCHEMA_VERSION = 2;
22
32
 
@@ -58,11 +68,8 @@ const MEASUREMENT_STAGES = ['propose', 'spec', 'design', 'task', 'check', 'apply
58
68
  const AUXILIARY_STAGES = ['test', 'explore'];
59
69
 
60
70
  // ── 工具函数 ──────────────────────────────────────────
61
- function ensureDir(dirPath) {
62
- if (!fs.existsSync(dirPath)) {
63
- fs.mkdirSync(dirPath, { recursive: true });
64
- }
65
- }
71
+ // ensureDir 已迁移至 lib/shared.cjs
72
+ const ensureDir = sharedEnsureDir;
66
73
 
67
74
  function generateEventId() {
68
75
  return 'evt_' + crypto.randomBytes(6).toString('hex');
@@ -99,7 +106,7 @@ function readJsonFile(filePath, projectRoot) {
99
106
  const resolved = path.isAbsolute(filePath)
100
107
  ? filePath
101
108
  : path.resolve(projectRoot || process.cwd(), filePath);
102
- return JSON.parse(fs.readFileSync(resolved, 'utf8'));
109
+ return sharedReadJson(resolved);
103
110
  }
104
111
 
105
112
  function parseJsonOption(args, inlineKey, fileKey, projectRoot, fallback = {}) {
@@ -152,25 +159,8 @@ function safeChangeName(name) {
152
159
  return name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '') || 'general';
153
160
  }
154
161
 
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
- }
162
+ // atomicWriteText 已迁移至 lib/shared.cjs
163
+ const atomicWriteText = writeTextAtomic;
174
164
 
175
165
  function requireValidChangeName(name) {
176
166
  if (typeof name === 'string'
@@ -320,19 +310,8 @@ function walkFiles(dirPath, predicate, files = []) {
320
310
  return files;
321
311
  }
322
312
 
323
- function copyDirSync(sourceDir, targetDir) {
324
- ensureDir(targetDir);
325
- for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
326
- const sourcePath = path.join(sourceDir, entry.name);
327
- const targetPath = path.join(targetDir, entry.name);
328
- if (entry.isDirectory()) {
329
- copyDirSync(sourcePath, targetPath);
330
- } else {
331
- ensureDir(path.dirname(targetPath));
332
- fs.copyFileSync(sourcePath, targetPath);
333
- }
334
- }
335
- }
313
+ // copyDirSync 已迁移至 lib/shared.cjs(copyDirRecursive)
314
+ const copyDirSync = copyDirRecursive;
336
315
 
337
316
  function nextAvailableDir(baseDir, preferredName) {
338
317
  let candidate = path.join(baseDir, preferredName);
@@ -849,9 +828,10 @@ function countSpecScenarios(projectRoot, changeName) {
849
828
  seen.add(abs);
850
829
  let text = '';
851
830
  try { text = fs.readFileSync(abs, 'utf8'); } catch { text = ''; }
852
- const count = metricsV3.countScenariosInMarkdown(text);
831
+ const scenarios = metricsV3.extractScenariosInMarkdown(text);
832
+ const count = scenarios.length;
853
833
  total += count;
854
- files.push({ path: toProjectRelative(projectRoot, abs), count });
834
+ files.push({ path: toProjectRelative(projectRoot, abs), count, scenarios });
855
835
  }
856
836
  return { scenario_count: total, files, source, root: toProjectRelative(projectRoot, root) };
857
837
  }
@@ -893,7 +873,9 @@ function buildV3ChangeContext(events, executionSummary, options = {}) {
893
873
  return {
894
874
  change_name: options.change_name || null,
895
875
  change_type: options.change_type || 'unknown',
896
- total_stage_duration_ms: options.total_stage_duration_ms ?? summary.total_stage_duration_ms ?? null,
876
+ total_stage_duration_ms: Object.prototype.hasOwnProperty.call(options, 'total_stage_duration_ms')
877
+ ? options.total_stage_duration_ms
878
+ : (summary.total_stage_duration_ms ?? null),
897
879
  canonical_stage_duration_ms: options.canonical_stage_duration_ms ?? summary.effective_stage_duration_ms ?? null,
898
880
  rework_stage_duration_ms: options.rework_stage_duration_ms ?? summary.rework_stage_duration_ms ?? null,
899
881
  scenario_count: options.scenario_count ?? 0,
@@ -1905,7 +1887,9 @@ function summarizeStageExecutions(events) {
1905
1887
  start,
1906
1888
  end,
1907
1889
  result: end?.result || null,
1908
- duration_ms: Number.isFinite(end?.duration_ms) ? end.duration_ms : 0,
1890
+ duration_ms: Number.isFinite(end?.duration_ms) && end.duration_ms >= 0
1891
+ ? end.duration_ms
1892
+ : null,
1909
1893
  canonical: false,
1910
1894
  rework_reason: null,
1911
1895
  };
@@ -1935,7 +1919,7 @@ function summarizeStageExecutions(events) {
1935
1919
  if (canonical) {
1936
1920
  canonical.canonical = true;
1937
1921
  canonicalAttempts.push(canonical);
1938
- effectiveStageDurationMs += canonical.duration_ms;
1922
+ effectiveStageDurationMs += Number.isFinite(canonical.duration_ms) ? canonical.duration_ms : 0;
1939
1923
  }
1940
1924
 
1941
1925
  for (const attempt of groupAttempts) {
@@ -1967,8 +1951,9 @@ function summarizeStageExecutions(events) {
1967
1951
  if (attempt.end) {
1968
1952
  completedReworkAttempts += 1;
1969
1953
  bucket.completed_rework_attempts += 1;
1970
- bucket.rework_duration_ms += attempt.duration_ms;
1971
- reworkStageDurationMs += attempt.duration_ms;
1954
+ const reworkDurationMs = Number.isFinite(attempt.duration_ms) ? attempt.duration_ms : 0;
1955
+ bucket.rework_duration_ms += reworkDurationMs;
1956
+ reworkStageDurationMs += reworkDurationMs;
1972
1957
  } else {
1973
1958
  supersededOpenStages += 1;
1974
1959
  bucket.superseded_open_stages += 1;
@@ -2250,9 +2235,14 @@ function getCheckResults(event) {
2250
2235
  categories: results.categories || {},
2251
2236
  // V3 Q3: reviewer 元信息,用于评估 reviewer_independence/score_status(缺失时保持 null,不影响旧字段)
2252
2237
  reviewer: results.reviewer || null,
2238
+ review_execution_mode: results.review_execution_mode || null,
2239
+ reviewer_agent_id: results.reviewer_agent_id || null,
2253
2240
  review_session_id: results.review_session_id || null,
2241
+ reviewer_session_id: results.reviewer_session_id || null,
2254
2242
  author_session_id: results.author_session_id || null,
2255
2243
  reviewer_independence: results.reviewer_independence || null,
2244
+ fallback_reason_code: results.fallback_reason_code || null,
2245
+ fallback_reason: results.fallback_reason || null,
2256
2246
  warning_items: Array.isArray(results.warning_items) ? results.warning_items : [],
2257
2247
  warning_dispositions: Array.isArray(results.warning_dispositions) ? results.warning_dispositions : [],
2258
2248
  };
@@ -2967,7 +2957,20 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2967
2957
  ? new Date(archiveEnd.timestamp).getTime() - new Date(proposeStart.timestamp).getTime()
2968
2958
  : null;
2969
2959
 
2970
- const totalStageDuration = executionSummary.summary.total_stage_duration_ms;
2960
+ // E1 只在全部纳入加总的已结束阶段都有合法耗时时发布:
2961
+ // 没有配对记录或任一耗时缺失都代表“没有足够证据”,不能解释为真实耗时为 0。
2962
+ const e1DurationAttempts = [
2963
+ ...executionSummary.canonicalAttempts,
2964
+ ...executionSummary.reworkAttempts.filter(attempt => attempt.end),
2965
+ ];
2966
+ const hasCompleteE1DurationEvidence = e1DurationAttempts.length > 0
2967
+ && e1DurationAttempts.every(attempt => (
2968
+ Number.isFinite(attempt.end?.duration_ms)
2969
+ && attempt.end.duration_ms >= 0
2970
+ ));
2971
+ const totalStageDuration = hasCompleteE1DurationEvidence
2972
+ ? executionSummary.summary.total_stage_duration_ms
2973
+ : null;
2971
2974
  const codingDuration = sumDurations(ends, ['apply']);
2972
2975
  const specDuration = sumDurations(ends, ['propose', 'spec', 'design', 'task']);
2973
2976
  const reworkCodingDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['apply']);
@@ -3020,6 +3023,26 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3020
3023
  && timestampMs(attempt.end) <= timestampMs(firstApply)
3021
3024
  ))
3022
3025
  : null;
3026
+ const qualifyingCheckAttempts = firstApply
3027
+ ? executionSummary.attempts.filter(attempt => (
3028
+ attempt.command === 'check'
3029
+ && attempt.end
3030
+ && ['success', 'partial'].includes(attempt.result)
3031
+ && timestampMs(attempt.end) <= timestampMs(firstApply)
3032
+ ))
3033
+ : [];
3034
+ const p4Evidence = {
3035
+ first_apply_started_at: firstApply?.timestamp || null,
3036
+ first_apply_event_id: firstApply?.event_id || null,
3037
+ qualifying_check_count: qualifyingCheckAttempts.length,
3038
+ qualifying_checks: qualifyingCheckAttempts.map(attempt => ({
3039
+ result: attempt.result,
3040
+ started_at: attempt.start?.timestamp || null,
3041
+ ended_at: attempt.end?.timestamp || null,
3042
+ start_event_id: attempt.start?.event_id || null,
3043
+ end_event_id: attempt.end?.event_id || null,
3044
+ })),
3045
+ };
3023
3046
  const latestCheckWithFixRate = latestByTimestamp(checkEvents.filter(e => {
3024
3047
  const results = getCheckResults(e);
3025
3048
  return results?.total > 0 && results.fixed_before_apply != null;
@@ -3049,8 +3072,13 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3049
3072
  reviewer: latestCheckResultsV3?.reviewer || null,
3050
3073
  author: formalApplyAttempt?.start?.session_id || formalApplyAttempt?.start?.agent_type || null,
3051
3074
  reviewer_independence: latestCheckResultsV3?.reviewer_independence || null,
3075
+ review_execution_mode: latestCheckResultsV3?.review_execution_mode || null,
3076
+ reviewer_agent_id: latestCheckResultsV3?.reviewer_agent_id || null,
3052
3077
  review_session_id: latestCheckResultsV3?.review_session_id || null,
3078
+ reviewer_session_id: latestCheckResultsV3?.reviewer_session_id || null,
3053
3079
  author_session_id: latestCheckResultsV3?.author_session_id || formalApplyAttempt?.start?.session_id || null,
3080
+ fallback_reason_code: latestCheckResultsV3?.fallback_reason_code || null,
3081
+ fallback_reason: latestCheckResultsV3?.fallback_reason || null,
3054
3082
  };
3055
3083
  const q3Result = metricsV3.computeSpecQualityScore(latestCheckResultsV3, checkMeta);
3056
3084
 
@@ -3074,11 +3102,21 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3074
3102
  completed_stages: completedStages,
3075
3103
  missing: missingRequiredStages,
3076
3104
  };
3077
- const pHTelemetryHealthScore = Number.isFinite(telemetryHealth.telemetry_health_score)
3078
- ? metricsV3.round(telemetryHealth.telemetry_health_score * 100, 2)
3079
- : null;
3105
+ const pHTelemetryHealthScore = measurementEvents.length === 0
3106
+ ? null
3107
+ : (Number.isFinite(telemetryHealth.telemetry_health_score_percent)
3108
+ ? telemetryHealth.telemetry_health_score_percent
3109
+ : (Number.isFinite(telemetryHealth.telemetry_health_score)
3110
+ ? metricsV3.round(telemetryHealth.telemetry_health_score * 100, 2)
3111
+ : null));
3080
3112
 
3081
- const reworkSummary = compactReworkSummary(executionSummary.summary, reworkReasons);
3113
+ const compactedReworkSummary = compactReworkSummary(executionSummary.summary, reworkReasons);
3114
+ const reworkSummary = executionSummary.attempts.length === 0
3115
+ ? {
3116
+ ...compactedReworkSummary,
3117
+ total_rework_attempts: null,
3118
+ }
3119
+ : compactedReworkSummary;
3082
3120
 
3083
3121
  const v3Context = buildV3ChangeContext(scopedEvents, executionSummary, {
3084
3122
  change_name: options.change || null,
@@ -3117,6 +3155,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3117
3155
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3118
3156
  reason: e1AvailabilityReason || incompleteJourneyReason,
3119
3157
  source_event_count: measurementEvents.length,
3158
+ event_ids: measurementEvents.map(event => event.event_id).filter(Boolean),
3120
3159
  },
3121
3160
  q3: {
3122
3161
  status: v3.quality.q3_score_status === 'verified'
@@ -3125,6 +3164,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3125
3164
  : (v3.quality.q3_availability ? 'provisional' : 'missing'),
3126
3165
  reason: v3.quality.q3_availability_reason,
3127
3166
  source_event_count: checkEvents.length,
3167
+ event_ids: checkEvents.map(event => event.event_id).filter(Boolean),
3128
3168
  },
3129
3169
  p4: {
3130
3170
  status: !firstApply
@@ -3132,6 +3172,10 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3132
3172
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3133
3173
  reason: firstApply ? incompleteJourneyReason : 'missing-apply-stage',
3134
3174
  source_event_count: executionSummary.attempts.filter(attempt => attempt.command === 'check').length,
3175
+ event_ids: [
3176
+ p4Evidence.first_apply_event_id,
3177
+ ...p4Evidence.qualifying_checks.flatMap(item => [item.start_event_id, item.end_event_id]),
3178
+ ].filter(Boolean),
3135
3179
  },
3136
3180
  p_r: {
3137
3181
  status: executionSummary.attempts.length === 0
@@ -3139,6 +3183,9 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3139
3183
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3140
3184
  reason: executionSummary.attempts.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
3141
3185
  source_event_count: executionSummary.attempts.length,
3186
+ event_ids: executionSummary.attempts
3187
+ .flatMap(attempt => [attempt.start?.event_id, attempt.end?.event_id])
3188
+ .filter(Boolean),
3142
3189
  },
3143
3190
  p_h: {
3144
3191
  status: measurementEvents.length === 0
@@ -3146,6 +3193,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3146
3193
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3147
3194
  reason: measurementEvents.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
3148
3195
  source_event_count: measurementEvents.length,
3196
+ event_ids: measurementEvents.map(event => event.event_id).filter(Boolean),
3149
3197
  },
3150
3198
  };
3151
3199
 
@@ -3167,7 +3215,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3167
3215
  ? Math.max(0, leadTime - executionSummary.summary.effective_stage_duration_ms)
3168
3216
  : null,
3169
3217
  rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
3170
- total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
3218
+ total_stage_duration_ms: totalStageDuration,
3171
3219
  measurement_stage_duration_ms: totalStageDuration,
3172
3220
  e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
3173
3221
  ? roundMetric((codingDuration + reworkCodingDuration) / executionSummary.summary.total_stage_duration_ms)
@@ -3199,6 +3247,11 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3199
3247
  q3_score_status: v3.quality.q3_score_status,
3200
3248
  q3_reviewer_independence: v3.quality.q3_reviewer_independence,
3201
3249
  q3_category_scores: v3.quality.q3_category_scores,
3250
+ q3_total: v3.quality.q3_total,
3251
+ q3_errors: v3.quality.q3_errors,
3252
+ q3_warnings: v3.quality.q3_warnings,
3253
+ q3_suggestions: v3.quality.q3_suggestions,
3254
+ q3_review: v3.quality.q3_review,
3202
3255
  q3_availability: v3.quality.q3_availability,
3203
3256
  // Q1/Q4 上游信号未就绪时用 pending-upstream 标注,不把 null 误转成 0
3204
3257
  q1_status: v3.quality.q1_spec_conformance.status,
@@ -3211,6 +3264,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3211
3264
  p2_ai_code_adoption_level: aiAdoptionMetrics.adoption_level,
3212
3265
  ai_adoption_counts: aiAdoptionMetrics.adoption_counts,
3213
3266
  p4_quality_gate_enforcement_rate: qualityGateRate,
3267
+ p4_evidence: p4Evidence,
3214
3268
  p4b_check_fixed_rate: fixRate,
3215
3269
  git_document_metrics: gitDocumentMetrics,
3216
3270
  rework_summary: reworkSummary,
@@ -3354,9 +3408,11 @@ function computePdfMvpMetrics(events, options = {}) {
3354
3408
  const health = computeTelemetryHealthMetrics(events);
3355
3409
  return {
3356
3410
  ...health,
3357
- p_h_telemetry_health_score: Number.isFinite(health.telemetry_health_score)
3358
- ? metricsV3.round(health.telemetry_health_score * 100, 2)
3359
- : null,
3411
+ p_h_telemetry_health_score: Number.isFinite(health.telemetry_health_score_percent)
3412
+ ? health.telemetry_health_score_percent
3413
+ : (Number.isFinite(health.telemetry_health_score)
3414
+ ? metricsV3.round(health.telemetry_health_score * 100, 2)
3415
+ : null),
3360
3416
  };
3361
3417
  })(),
3362
3418
  by_change: byChange,
@@ -4247,6 +4303,17 @@ function fileListSymmetricDiff(left, right) {
4247
4303
 
4248
4304
  // ── U1 报告维度提取辅助:从 events/metrics/doctor/archive 提炼 5 维度,数据缺失诚实标注 _available: false ──
4249
4305
  function extractChangedFiles(events, projectRoot) {
4306
+ const snapshotEvents = events.filter(event => (
4307
+ event?.type === 'final_output_snapshot'
4308
+ && event.details?.final_output_snapshot
4309
+ && typeof event.details.final_output_snapshot === 'object'
4310
+ ));
4311
+ const latestSnapshotEvent = latestByTimestamp(snapshotEvents);
4312
+ const snapshotValidation = latestSnapshotEvent
4313
+ ? validateFinalOutputSnapshot(latestSnapshotEvent.details.final_output_snapshot)
4314
+ : null;
4315
+ const finalSnapshot = snapshotValidation?.ok ? snapshotValidation.snapshot : null;
4316
+ const invalidFinalSnapshot = Boolean(latestSnapshotEvent && !snapshotValidation?.ok);
4250
4317
  const adoptionEvents = getAiAdoptionEvents(events);
4251
4318
  const finalAdoptionEvents = adoptionEvents.filter((event) => {
4252
4319
  const review = getAiAdoptionReview(event);
@@ -4258,25 +4325,47 @@ function extractChangedFiles(events, projectRoot) {
4258
4325
  finalAdoptionEvents.length > 0 ? finalAdoptionEvents : adoptionEvents,
4259
4326
  );
4260
4327
  const review = latest ? getAiAdoptionReview(latest) : null;
4261
- const aiDiff = review?.ai_diff || null;
4328
+ const aiDiff = latestSnapshotEvent ? null : (review?.ai_diff || null);
4262
4329
  const taskUpdateFiles = new Set();
4330
+ const taskEventIds = [];
4331
+ const taskIds = [];
4332
+ const noFileChangeReasons = [];
4333
+ let taskEventLatestAt = null;
4263
4334
  for (const e of events) {
4264
4335
  if (e?.type !== 'task_update') continue;
4265
- const filesField = e.details?.files
4336
+ const taskUpdate = e.details?.task_update || {};
4337
+ const filesField = taskUpdate.files
4338
+ || e.details?.files
4266
4339
  || e.details?.test_results?.files
4267
4340
  || (Array.isArray(e.details?.files_changed) ? e.details.files_changed : null);
4268
- if (Array.isArray(filesField)) {
4341
+ const reason = taskUpdate.no_file_change_reason || e.details?.no_file_change_reason;
4342
+ if (typeof reason === 'string' && reason.trim()) {
4343
+ noFileChangeReasons.push({
4344
+ task_id: e.task_id || taskUpdate.task_id || null,
4345
+ event_id: e.event_id || null,
4346
+ reason: reason.trim(),
4347
+ });
4348
+ }
4349
+ if (Array.isArray(filesField) && filesField.length > 0) {
4350
+ if (e.event_id) taskEventIds.push(e.event_id);
4351
+ const taskId = e.task_id || taskUpdate.task_id;
4352
+ if (taskId) taskIds.push(taskId);
4353
+ if (!taskEventLatestAt || timestampMs(e) > Date.parse(taskEventLatestAt)) {
4354
+ taskEventLatestAt = e.timestamp || null;
4355
+ }
4269
4356
  for (const f of filesField) {
4270
4357
  if (typeof f === 'string' && f.trim()) taskUpdateFiles.add(f.trim());
4271
4358
  }
4272
4359
  }
4273
4360
  }
4274
4361
  const finalOutputFiles = new Set(
4275
- Array.isArray(aiDiff?.files)
4276
- ? aiDiff.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4277
- : [],
4362
+ Array.isArray(finalSnapshot?.files)
4363
+ ? finalSnapshot.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4364
+ : (Array.isArray(aiDiff?.files)
4365
+ ? aiDiff.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4366
+ : [])
4278
4367
  );
4279
- if (finalOutputFiles.size === 0 && latest?.details) {
4368
+ if (!finalSnapshot && finalOutputFiles.size === 0 && latest?.details) {
4280
4369
  const rawReview = latest.details.ai_adoption
4281
4370
  || latest.details.ai_adoption_review
4282
4371
  || latest.details.ai_code_adoption
@@ -4290,23 +4379,40 @@ function extractChangedFiles(events, projectRoot) {
4290
4379
 
4291
4380
  const taskEventFileList = sortedUniqueFileList([...taskUpdateFiles]);
4292
4381
  const finalOutputFileList = sortedUniqueFileList([...finalOutputFiles]);
4293
- const taskUpdateFileCount = taskEventFileList.length;
4294
- const finalOutputFileCount = Number.isFinite(aiDiff?.files_changed)
4295
- ? aiDiff.files_changed
4296
- : (finalOutputFileList.length > 0 ? finalOutputFileList.length : null);
4297
- const snapshotKind = aiDiff?.snapshot_kind
4298
- || ((review?.review_status === 'final' || latest?.status === 'final') ? 'final' : null);
4382
+ const taskEvidenceAvailable = taskEventFileList.length > 0 || noFileChangeReasons.length > 0;
4383
+ const taskUpdateFileCount = taskEvidenceAvailable ? taskEventFileList.length : null;
4384
+ const finalOutputFileCount = finalSnapshot
4385
+ ? (Number.isFinite(finalSnapshot.files_changed)
4386
+ ? finalSnapshot.files_changed
4387
+ : finalOutputFileList.length)
4388
+ : (Number.isFinite(aiDiff?.files_changed)
4389
+ ? aiDiff.files_changed
4390
+ : (finalOutputFileList.length > 0 ? finalOutputFileList.length : null));
4391
+ const snapshotKind = finalSnapshot
4392
+ ? finalSnapshot.snapshot_kind
4393
+ : (latestSnapshotEvent
4394
+ ? null
4395
+ : (aiDiff?.snapshot_kind
4396
+ || ((review?.review_status === 'final' || latest?.status === 'final') ? 'final' : null)));
4299
4397
  const finalOutputStatus = snapshotKind === 'final'
4300
4398
  ? 'final'
4301
4399
  : (finalOutputFileCount == null ? 'unavailable' : 'provisional');
4302
- const addedLines = Number.isFinite(aiDiff?.added_lines)
4303
- ? aiDiff.added_lines
4304
- : (Number.isFinite(review?.final_diff?.added_lines) ? review.final_diff.added_lines : null);
4305
- const deletedLines = Number.isFinite(aiDiff?.deleted_lines)
4306
- ? aiDiff.deleted_lines
4307
- : (Number.isFinite(review?.final_diff?.deleted_lines) ? review.final_diff.deleted_lines : null);
4400
+ const addedLines = finalSnapshot
4401
+ ? (Number.isFinite(finalSnapshot.added_lines) ? finalSnapshot.added_lines : null)
4402
+ : (Number.isFinite(aiDiff?.added_lines)
4403
+ ? aiDiff.added_lines
4404
+ : (Number.isFinite(review?.final_diff?.added_lines) ? review.final_diff.added_lines : null));
4405
+ const deletedLines = finalSnapshot
4406
+ ? (Number.isFinite(finalSnapshot.deleted_lines) ? finalSnapshot.deleted_lines : null)
4407
+ : (Number.isFinite(aiDiff?.deleted_lines)
4408
+ ? aiDiff.deleted_lines
4409
+ : (Number.isFinite(review?.final_diff?.deleted_lines) ? review.final_diff.deleted_lines : null));
4308
4410
  let lineCountEstimate = null;
4309
- if (addedLines == null && review?.ai_git_sha == null && finalOutputFileList.length > 0 && projectRoot) {
4411
+ if (!finalSnapshot
4412
+ && addedLines == null
4413
+ && review?.ai_git_sha == null
4414
+ && finalOutputFileList.length > 0
4415
+ && projectRoot) {
4310
4416
  let lines = 0;
4311
4417
  let countedFiles = 0;
4312
4418
  const root = path.resolve(projectRoot);
@@ -4331,27 +4437,26 @@ function extractChangedFiles(events, projectRoot) {
4331
4437
  };
4332
4438
  }
4333
4439
  }
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);
4338
- const countsDiffer = taskUpdateFileCount > 0
4440
+ // 对外的 files_changed 只代表最终实际交付;任务事件文件始终使用独立字段。
4441
+ const filesChanged = finalOutputStatus === 'final' ? finalOutputFileCount : null;
4442
+ const countsDiffer = taskUpdateFileCount != null
4339
4443
  && finalOutputFileCount != null
4340
4444
  && taskUpdateFileCount !== finalOutputFileCount;
4341
4445
  const listDiff = fileListSymmetricDiff(taskEventFileList, finalOutputFileList);
4342
- const listsDiffer = taskUpdateFileCount > 0
4343
- && finalOutputFileList.length > 0
4446
+ const listsDiffer = taskUpdateFileCount != null
4447
+ && finalOutputFileCount != null
4344
4448
  && (listDiff.only_in_task_events.length > 0 || listDiff.only_in_final_output.length > 0);
4345
4449
  let reconciliationStatus = 'unavailable';
4346
4450
  let reconciliationNote = '没有可用的文件变更证据。';
4347
- if (taskUpdateFileCount === 0 && finalOutputFileCount == null) {
4451
+ if (taskUpdateFileCount == null && finalOutputFileCount == null) {
4348
4452
  reconciliationStatus = 'unavailable';
4349
4453
  reconciliationNote = '没有可用的文件变更证据。';
4350
- } else if (taskUpdateFileCount === 0 || finalOutputFileCount == null) {
4351
- reconciliationStatus = 'unavailable';
4352
- reconciliationNote = finalOutputFileCount == null
4353
- ? '最终输出文件快照不可用,无法与任务事件对账。'
4354
- : '任务事件未记录文件清单;仅展示最终输出快照。';
4454
+ } else if (finalOutputFileCount == null) {
4455
+ reconciliationStatus = 'final-snapshot-missing';
4456
+ reconciliationNote = '最终输出文件快照不可用,无法与任务事件对账。';
4457
+ } else if (taskUpdateFileCount == null) {
4458
+ reconciliationStatus = 'task-evidence-missing';
4459
+ reconciliationNote = '任务事件未记录文件清单或无文件原因;仅展示最终输出快照。';
4355
4460
  } else if (countsDiffer) {
4356
4461
  reconciliationStatus = 'count-mismatch';
4357
4462
  reconciliationNote = `任务事件累计涉及 ${taskUpdateFileCount} 个文件;最终输出快照包含 ${finalOutputFileCount} 个文件。两者用途不同,不应合并为一个口径。`;
@@ -4363,17 +4468,37 @@ function extractChangedFiles(events, projectRoot) {
4363
4468
  reconciliationNote = '任务事件与最终输出文件口径一致。';
4364
4469
  }
4365
4470
  return {
4366
- _available: filesChanged != null,
4471
+ _available: filesChanged != null || taskUpdateFileCount != null,
4367
4472
  files_changed: filesChanged,
4368
4473
  added_lines: addedLines,
4369
4474
  deleted_lines: deletedLines,
4370
4475
  files: finalOutputFileList.length > 0 ? finalOutputFileList : null,
4371
- task_event_files: taskUpdateFileCount > 0 ? taskEventFileList : null,
4372
- task_event_files_changed: taskUpdateFileCount > 0 ? taskUpdateFileCount : null,
4476
+ task_event_files: taskUpdateFileCount != null ? taskEventFileList : null,
4477
+ task_event_files_changed: taskUpdateFileCount,
4478
+ task_event_source: 'task_update.files',
4479
+ task_event_task_ids: [...new Set(taskIds)],
4480
+ task_event_ids: [...new Set(taskEventIds)],
4481
+ task_event_latest_at: taskEventLatestAt,
4482
+ no_file_change_reasons: noFileChangeReasons,
4373
4483
  final_output_files: finalOutputFileList.length > 0 ? finalOutputFileList : null,
4374
4484
  final_output_files_changed: finalOutputFileCount,
4375
4485
  final_output_status: finalOutputStatus,
4376
- diff_source: snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'),
4486
+ final_output_source: finalSnapshot
4487
+ ? 'final_output_snapshot.files'
4488
+ : (snapshotKind === 'final' ? 'ai-diff-final(旧事件兼容)' : null),
4489
+ final_output_event_id: latestSnapshotEvent?.event_id || latest?.event_id || null,
4490
+ final_output_captured_at: latestSnapshotEvent?.timestamp || latest?.timestamp || null,
4491
+ final_output_no_output_reason: finalSnapshot?.no_output_reason || null,
4492
+ final_output_vcs_mode: finalSnapshot?.vcs_mode || null,
4493
+ final_output_validation_status: invalidFinalSnapshot ? 'invalid' : (finalSnapshot ? 'valid' : 'unavailable'),
4494
+ final_output_validation_reason: invalidFinalSnapshot
4495
+ ? `${snapshotValidation.code}: ${snapshotValidation.message}`
4496
+ : null,
4497
+ diff_source: finalSnapshot
4498
+ ? 'final-output-snapshot'
4499
+ : (invalidFinalSnapshot
4500
+ ? 'invalid-final-output-snapshot'
4501
+ : (snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'))),
4377
4502
  line_count_estimate: lineCountEstimate,
4378
4503
  reconciliation_status: reconciliationStatus,
4379
4504
  reconciliation_note: reconciliationNote,
@@ -4434,6 +4559,8 @@ function extractStageTimeline(events) {
4434
4559
  return {
4435
4560
  stage: cmd,
4436
4561
  round,
4562
+ start_event_id: start.event_id || null,
4563
+ end_event_id: end?.event_id || null,
4437
4564
  start_ts: start.timestamp || null,
4438
4565
  end_ts: end?.timestamp || null,
4439
4566
  duration_ms: end && Number.isFinite(end.duration_ms) ? end.duration_ms : null,
@@ -5141,11 +5268,14 @@ function computeDoctorReport(events, options = {}) {
5141
5268
  outdatedSchemaEvents.length * 0.06 +
5142
5269
  missingChangeEvents.length * 0.04 +
5143
5270
  missingCapabilityEvents.length * 0.01;
5144
- const denominator = Math.max(stageEvents.length, 1);
5145
- const telemetryHealthScore = Math.max(0, Math.round((1 - penalty / denominator) * 100) / 100);
5271
+ const telemetryHealth = metricsV3.computeTelemetryHealthScore({
5272
+ weighted_issue_value: penalty,
5273
+ stage_event_count: stageEvents.length,
5274
+ });
5146
5275
 
5147
5276
  return {
5148
- telemetry_health_score: telemetryHealthScore,
5277
+ telemetry_health_score: telemetryHealth.ratio,
5278
+ telemetry_health_score_percent: telemetryHealth.score,
5149
5279
  matched_stage_rate: matchedStageRate == null ? null : Math.round(matchedStageRate * 100) / 100,
5150
5280
  total_events: scopedEvents.length,
5151
5281
  stage_events: stageEvents.length,
@@ -5173,19 +5303,8 @@ function computeDoctorReport(events, options = {}) {
5173
5303
 
5174
5304
  // ── CLI 参数解析 ──────────────────────────────────────
5175
5305
 
5176
- /** 解析 --key=value 格式的参数 */
5177
- function parseArgs(argv) {
5178
- const args = {};
5179
- for (const arg of argv) {
5180
- const match = arg.match(/^--([a-zA-Z_-]+)=(.*)$/);
5181
- if (match) {
5182
- args[match[1]] = match[2];
5183
- } else if (arg.match(/^--([a-zA-Z_-]+)$/)) {
5184
- args[arg.slice(2)] = true;
5185
- }
5186
- }
5187
- return args;
5188
- }
5306
+ // parseArgs 已迁移至 lib/shared.cjs(统一 5 份实现)
5307
+ // 本文件顶部已通过 require 引入,此处保留 const 别名以兼容内部引用
5189
5308
 
5190
5309
  // ── CLI 命令实现 ─────────────────────────────────────
5191
5310
 
@@ -5348,7 +5467,21 @@ function cmdEnd(args, options = {}) {
5348
5467
  fail('E_CHANGE_REQUIRED: 无法解析 start 的 end 必须显式提供 --change');
5349
5468
  }
5350
5469
  if (startEvent && requestedChange && startEvent.change !== requestedChange) {
5351
- fail(`E_CHANGE_CONFLICT: end 指定 change "${requestedChange}",但 active stage 属于 "${startEvent.change}"`);
5470
+ // BUGFIX: 允许 start 使用占位 change(如 pending)时 end 指定真实 change-key 自动迁移
5471
+ // 场景:propose 阶段初期不知道最终 change-key,start 使用 --change=pending 占位,
5472
+ // 后期生成 change-key 后 end 使用真实名称。此时应允许并自动迁移事件文件。
5473
+ const PLACEHOLDER_CHANGES = new Set(['pending']);
5474
+ if (PLACEHOLDER_CHANGES.has(startEvent.change)) {
5475
+ // 将 start 事件复制到新的 change 文件下,保持 event_id 连续性
5476
+ if (process.env.SDD_DEBUG_TELEMETRY) {
5477
+ console.error(`[telemetry] migrating start event from placeholder "${startEvent.change}" → "${requestedChange}" (event_id=${eventId})`);
5478
+ }
5479
+ appendEvent(dataDir, requestedChange, startEvent, { lockHeld: false });
5480
+ // 更新内存中的 startEvent.change 供后续使用
5481
+ startEvent = { ...startEvent, change: requestedChange };
5482
+ } else {
5483
+ fail(`E_CHANGE_CONFLICT: end 指定 change "${requestedChange}",但 active stage 属于 "${startEvent.change}"`);
5484
+ }
5352
5485
  }
5353
5486
  // stage_end 幂等性保护:同一 event_id 已存在 stage_end 时,结果相同则跳过,不同则更新(archive 命令只要存在即跳过)
5354
5487
  const command = startEvent?.command || args.command || 'unknown';
@@ -5693,6 +5826,426 @@ function strictInteger(value, code, fieldName, minimum = 0) {
5693
5826
  return number;
5694
5827
  }
5695
5828
 
5829
+ function strictStructuredInteger(value, code, fieldName, minimum = 0) {
5830
+ if (!Number.isInteger(value) || value < minimum) {
5831
+ fail(`${code}: ${fieldName} 必须是大于等于 ${minimum} 的整数`);
5832
+ }
5833
+ return value;
5834
+ }
5835
+
5836
+ const CHECK_FALLBACK_REASON_CODES = new Set([
5837
+ 'SUBAGENT_CAPABILITY_UNAVAILABLE',
5838
+ 'SUBAGENT_PERMISSION_DENIED',
5839
+ 'SUBAGENT_START_FAILED',
5840
+ 'SUBAGENT_TIMEOUT',
5841
+ 'SUBAGENT_RESULT_INVALID',
5842
+ ]);
5843
+
5844
+ const CHECK_IDENTITY_PLACEHOLDERS = new Set([
5845
+ 'unknown',
5846
+ 'none',
5847
+ 'null',
5848
+ 'n/a',
5849
+ 'na',
5850
+ '未记录',
5851
+ '未知',
5852
+ ]);
5853
+
5854
+ function isMeaningfulCheckIdentity(value) {
5855
+ const normalized = String(value || '').trim();
5856
+ if (!normalized) return false;
5857
+ if (CHECK_IDENTITY_PLACEHOLDERS.has(normalized.toLowerCase())) return false;
5858
+ if (/^<[^>]+>$/.test(normalized) || /^\[[^\]]+\]$/.test(normalized)) return false;
5859
+ return true;
5860
+ }
5861
+
5862
+ function normalizeRepositoryRelativeFiles(files, {
5863
+ requiredCode,
5864
+ invalidCode,
5865
+ fieldName,
5866
+ } = {}) {
5867
+ if (!Array.isArray(files)) {
5868
+ const code = requiredCode || invalidCode || 'REPOSITORY_FILE_PATH_INVALID';
5869
+ throw new Error(`${code}: ${fieldName || 'files'} 必须是数组`);
5870
+ }
5871
+ if (files.length === 0) {
5872
+ if (requiredCode) {
5873
+ throw new Error(`${requiredCode}: ${fieldName || 'files'} 必须是非空数组`);
5874
+ }
5875
+ return [];
5876
+ }
5877
+ const normalized = [];
5878
+ for (const value of files) {
5879
+ if (typeof value !== 'string') {
5880
+ throw new Error(`${invalidCode || 'REPOSITORY_FILE_PATH_INVALID'}: ${fieldName || 'files'} 只能包含字符串路径`);
5881
+ }
5882
+ const raw = value.trim().replace(/\\/g, '/');
5883
+ const segments = raw.split('/');
5884
+ if (!raw
5885
+ || path.isAbsolute(raw)
5886
+ || /^[A-Za-z]:/.test(raw)
5887
+ || segments.includes('..')
5888
+ || segments.includes('.')
5889
+ || raw.startsWith('/')) {
5890
+ throw new Error(`${invalidCode || 'REPOSITORY_FILE_PATH_INVALID'}: 非法仓库相对路径 "${raw || String(value)}"`);
5891
+ }
5892
+ normalized.push(raw);
5893
+ }
5894
+ return [...new Set(normalized)].sort();
5895
+ }
5896
+
5897
+ function normalizeStrictCheckResult(details, result) {
5898
+ const input = details?.check_results;
5899
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
5900
+ fail('CHECK_RESULT_REQUIRED: strict check_result 缺少 details.check_results');
5901
+ }
5902
+ const total = strictStructuredInteger(
5903
+ input.total,
5904
+ 'CHECK_RESULT_TOTAL_REQUIRED',
5905
+ 'check_results.total',
5906
+ 1,
5907
+ );
5908
+ const errors = strictStructuredInteger(
5909
+ input.errors,
5910
+ 'CHECK_RESULT_ERRORS_REQUIRED',
5911
+ 'check_results.errors',
5912
+ );
5913
+ if (errors > total) {
5914
+ fail('CHECK_RESULT_ERRORS_INVALID: check_results.errors 不能大于 check_results.total');
5915
+ }
5916
+ const warnings = strictStructuredInteger(
5917
+ input.warnings,
5918
+ 'CHECK_RESULT_WARNINGS_REQUIRED',
5919
+ 'check_results.warnings',
5920
+ );
5921
+ const suggestions = strictStructuredInteger(
5922
+ input.suggestions,
5923
+ 'CHECK_RESULT_SUGGESTIONS_REQUIRED',
5924
+ 'check_results.suggestions',
5925
+ );
5926
+ const fixedBeforeApply = strictStructuredInteger(
5927
+ input.fixed_before_apply,
5928
+ 'CHECK_RESULT_FIXED_REQUIRED',
5929
+ 'check_results.fixed_before_apply',
5930
+ );
5931
+ if (fixedBeforeApply > total) {
5932
+ fail('CHECK_RESULT_FIXED_INVALID: fixed_before_apply 不能大于 check_results.total');
5933
+ }
5934
+ const categoryNames = [
5935
+ 'completeness',
5936
+ 'consistency',
5937
+ 'executability',
5938
+ 'tdd_compliance',
5939
+ ];
5940
+ const categoryInput = input.categories;
5941
+ if (!categoryInput || typeof categoryInput !== 'object' || Array.isArray(categoryInput)) {
5942
+ fail('CHECK_RESULT_CATEGORIES_REQUIRED: strict check_result 缺少 check_results.categories');
5943
+ }
5944
+ const categories = {};
5945
+ let categoryTotalSum = 0;
5946
+ let categoryErrorSum = 0;
5947
+ for (const categoryName of categoryNames) {
5948
+ const category = categoryInput[categoryName];
5949
+ if (!category || typeof category !== 'object' || Array.isArray(category)) {
5950
+ fail(`CHECK_RESULT_CATEGORY_REQUIRED: check_results.categories.${categoryName} 必须是对象`);
5951
+ }
5952
+ const categoryTotal = strictStructuredInteger(
5953
+ category.total,
5954
+ 'CHECK_RESULT_CATEGORY_TOTAL_REQUIRED',
5955
+ `check_results.categories.${categoryName}.total`,
5956
+ );
5957
+ const categoryPassed = strictStructuredInteger(
5958
+ category.passed,
5959
+ 'CHECK_RESULT_CATEGORY_PASSED_REQUIRED',
5960
+ `check_results.categories.${categoryName}.passed`,
5961
+ );
5962
+ if (categoryPassed > categoryTotal) {
5963
+ fail(`CHECK_RESULT_CATEGORY_INVALID: ${categoryName}.passed 不能大于 total`);
5964
+ }
5965
+ categories[categoryName] = {
5966
+ ...category,
5967
+ passed: categoryPassed,
5968
+ total: categoryTotal,
5969
+ };
5970
+ categoryTotalSum += categoryTotal;
5971
+ categoryErrorSum += categoryTotal - categoryPassed;
5972
+ }
5973
+ if (categoryTotalSum !== total) {
5974
+ fail('CHECK_RESULT_CATEGORY_TOTAL_MISMATCH: 四类 categories.total 合计必须等于 check_results.total');
5975
+ }
5976
+ if (categoryErrorSum !== errors) {
5977
+ fail('CHECK_RESULT_CATEGORY_ERRORS_MISMATCH: 四类未通过数合计必须等于 check_results.errors');
5978
+ }
5979
+ const taskInput = input.task_completion;
5980
+ if (!taskInput || typeof taskInput !== 'object' || Array.isArray(taskInput)) {
5981
+ fail('CHECK_RESULT_TASK_COMPLETION_REQUIRED: strict check_result 缺少 check_results.task_completion');
5982
+ }
5983
+ const taskCompleted = strictStructuredInteger(
5984
+ taskInput.completed,
5985
+ 'CHECK_RESULT_TASK_COMPLETED_REQUIRED',
5986
+ 'check_results.task_completion.completed',
5987
+ );
5988
+ const taskIncomplete = strictStructuredInteger(
5989
+ taskInput.incomplete,
5990
+ 'CHECK_RESULT_TASK_INCOMPLETE_REQUIRED',
5991
+ 'check_results.task_completion.incomplete',
5992
+ );
5993
+ const taskTotal = strictStructuredInteger(
5994
+ taskInput.total,
5995
+ 'CHECK_RESULT_TASK_TOTAL_REQUIRED',
5996
+ 'check_results.task_completion.total',
5997
+ );
5998
+ if (taskCompleted + taskIncomplete !== taskTotal) {
5999
+ fail('CHECK_RESULT_TASK_COUNTS_INVALID: completed + incomplete 必须等于 task_completion.total');
6000
+ }
6001
+ if (typeof taskInput.has_incomplete !== 'boolean'
6002
+ || taskInput.has_incomplete !== (taskIncomplete > 0)) {
6003
+ fail('CHECK_RESULT_TASK_FLAG_INVALID: has_incomplete 必须与 incomplete 是否大于 0 一致');
6004
+ }
6005
+ if (typeof taskInput.checked_for_archive_readiness !== 'boolean') {
6006
+ fail('CHECK_RESULT_ARCHIVE_READINESS_REQUIRED: checked_for_archive_readiness 必须是布尔值');
6007
+ }
6008
+ const normalizedResult = requireStrictString(
6009
+ result,
6010
+ 'CHECK_RESULT_STATUS_REQUIRED',
6011
+ 'result',
6012
+ );
6013
+ if (!['success', 'partial', 'failure'].includes(normalizedResult)) {
6014
+ fail(`CHECK_RESULT_STATUS_INVALID: 非法 check_result.result "${normalizedResult}"`);
6015
+ }
6016
+ if (taskIncomplete > 0 && normalizedResult === 'success') {
6017
+ fail('CHECK_RESULT_TASK_STATUS_INVALID: 存在未完成任务时 check_result.result 不能是 success');
6018
+ }
6019
+ const warningItems = input.warning_items;
6020
+ if (taskIncomplete > 0 && (
6021
+ !Array.isArray(warningItems)
6022
+ || !warningItems.some(item => item?.category === 'task_completion')
6023
+ )) {
6024
+ fail('CHECK_RESULT_TASK_WARNING_REQUIRED: 存在未完成任务时 warning_items 必须包含 task_completion 明细');
6025
+ }
6026
+ const taskCompletion = {
6027
+ ...taskInput,
6028
+ completed: taskCompleted,
6029
+ incomplete: taskIncomplete,
6030
+ total: taskTotal,
6031
+ has_incomplete: taskInput.has_incomplete,
6032
+ checked_for_archive_readiness: taskInput.checked_for_archive_readiness,
6033
+ };
6034
+ const executionMode = requireStrictString(
6035
+ input.review_execution_mode,
6036
+ 'CHECK_REVIEW_EXECUTION_MODE_REQUIRED',
6037
+ 'review_execution_mode',
6038
+ );
6039
+ if (!['subagent', 'main-agent-fallback'].includes(executionMode)) {
6040
+ fail(`CHECK_REVIEW_EXECUTION_MODE_INVALID: 非法 review_execution_mode "${executionMode}"`);
6041
+ }
6042
+ const reviewerIndependence = requireStrictString(
6043
+ input.reviewer_independence,
6044
+ 'CHECK_REVIEW_INDEPENDENCE_REQUIRED',
6045
+ 'reviewer_independence',
6046
+ );
6047
+ if (!['independent-review', 'self-review', 'unknown'].includes(reviewerIndependence)) {
6048
+ fail(`CHECK_REVIEW_INDEPENDENCE_INVALID: 非法 reviewer_independence "${reviewerIndependence}"`);
6049
+ }
6050
+ const reviewSessionId = requireStrictString(
6051
+ input.review_session_id || input.reviewer_session_id,
6052
+ 'CHECK_REVIEW_SESSION_REQUIRED',
6053
+ 'review_session_id',
6054
+ );
6055
+ const authorSessionId = requireStrictString(
6056
+ input.author_session_id,
6057
+ 'CHECK_AUTHOR_SESSION_REQUIRED',
6058
+ 'author_session_id',
6059
+ );
6060
+ const parentSessionId = String(input.parent_session_id || '').trim();
6061
+ const fallbackReasonCode = String(input.fallback_reason_code || '').trim();
6062
+ const fallbackReason = String(input.fallback_reason || '').trim();
6063
+
6064
+ if (executionMode === 'subagent') {
6065
+ if (!parentSessionId) {
6066
+ fail('CHECK_PARENT_SESSION_REQUIRED: 子代理评审必须记录 parent_session_id');
6067
+ }
6068
+ if (!isMeaningfulCheckIdentity(input.reviewer_agent_id)
6069
+ || !isMeaningfulCheckIdentity(parentSessionId)
6070
+ || !isMeaningfulCheckIdentity(reviewSessionId)
6071
+ || !isMeaningfulCheckIdentity(authorSessionId)) {
6072
+ fail('CHECK_REVIEW_IDENTITY_INVALID: 子代理评审必须记录真实且非占位的评审者、父会话、评审会话和作者会话身份');
6073
+ }
6074
+ if (reviewerIndependence !== 'independent-review'
6075
+ || reviewSessionId === authorSessionId
6076
+ || fallbackReasonCode
6077
+ || fallbackReason) {
6078
+ fail('CHECK_REVIEW_METADATA_INVALID: 子代理评审必须具有独立评审标记、不同会话、评审者身份且不能带回退原因');
6079
+ }
6080
+ } else {
6081
+ if (!fallbackReasonCode || !fallbackReason) {
6082
+ fail('CHECK_FALLBACK_REASON_REQUIRED: 主 Agent 回退检查必须记录 fallback_reason_code 和 fallback_reason');
6083
+ }
6084
+ if (!CHECK_FALLBACK_REASON_CODES.has(fallbackReasonCode)) {
6085
+ fail(`CHECK_FALLBACK_REASON_INVALID: 非法回退原因 "${fallbackReasonCode}"`);
6086
+ }
6087
+ if (!['self-review', 'unknown'].includes(reviewerIndependence)) {
6088
+ fail('CHECK_REVIEW_METADATA_INVALID: 主 Agent 回退检查只能声明为 self-review 或 unknown');
6089
+ }
6090
+ }
6091
+
6092
+ return {
6093
+ ...details,
6094
+ check_results: {
6095
+ ...input,
6096
+ total,
6097
+ errors,
6098
+ warnings,
6099
+ suggestions,
6100
+ fixed_before_apply: fixedBeforeApply,
6101
+ categories,
6102
+ task_completion: taskCompletion,
6103
+ review_execution_mode: executionMode,
6104
+ reviewer_independence: reviewerIndependence,
6105
+ reviewer_agent_id: executionMode === 'subagent'
6106
+ ? String(input.reviewer_agent_id).trim()
6107
+ : undefined,
6108
+ parent_session_id: parentSessionId || undefined,
6109
+ review_session_id: reviewSessionId,
6110
+ author_session_id: authorSessionId,
6111
+ fallback_reason_code: fallbackReasonCode || undefined,
6112
+ fallback_reason: fallbackReason || undefined,
6113
+ },
6114
+ };
6115
+ }
6116
+
6117
+ function validateFinalOutputSnapshot(input) {
6118
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
6119
+ return {
6120
+ ok: false,
6121
+ code: 'FINAL_OUTPUT_SNAPSHOT_REQUIRED',
6122
+ message: 'strict final_output_snapshot 缺少 details.final_output_snapshot',
6123
+ };
6124
+ }
6125
+ const snapshotKind = typeof input.snapshot_kind === 'string' ? input.snapshot_kind.trim() : '';
6126
+ if (!snapshotKind) {
6127
+ return {
6128
+ ok: false,
6129
+ code: 'FINAL_OUTPUT_SNAPSHOT_KIND_REQUIRED',
6130
+ message: 'strict 事件缺少 final_output_snapshot.snapshot_kind',
6131
+ };
6132
+ }
6133
+ if (snapshotKind !== 'final') {
6134
+ return {
6135
+ ok: false,
6136
+ code: 'FINAL_OUTPUT_SNAPSHOT_KIND_INVALID',
6137
+ message: `snapshot_kind 必须是 final,收到 "${snapshotKind}"`,
6138
+ };
6139
+ }
6140
+ const vcsMode = typeof input.vcs_mode === 'string' ? input.vcs_mode.trim() : '';
6141
+ if (!vcsMode) {
6142
+ return {
6143
+ ok: false,
6144
+ code: 'FINAL_OUTPUT_VCS_MODE_REQUIRED',
6145
+ message: 'strict 事件缺少 final_output_snapshot.vcs_mode',
6146
+ };
6147
+ }
6148
+ if (!['readonly', 'no-git'].includes(vcsMode)) {
6149
+ return {
6150
+ ok: false,
6151
+ code: 'FINAL_OUTPUT_VCS_MODE_INVALID',
6152
+ message: `vcs_mode 必须是 readonly 或 no-git,收到 "${vcsMode}"`,
6153
+ };
6154
+ }
6155
+ if (!Array.isArray(input.files)) {
6156
+ return {
6157
+ ok: false,
6158
+ code: 'FINAL_OUTPUT_PATH_INVALID',
6159
+ message: 'final_output_snapshot.files 必须显式提供数组;没有交付文件时请使用空数组并填写 no_output_reason',
6160
+ };
6161
+ }
6162
+ let files;
6163
+ try {
6164
+ files = normalizeRepositoryRelativeFiles(input.files, {
6165
+ invalidCode: 'FINAL_OUTPUT_PATH_INVALID',
6166
+ fieldName: 'final_output_snapshot.files',
6167
+ });
6168
+ } catch (error) {
6169
+ return {
6170
+ ok: false,
6171
+ code: 'FINAL_OUTPUT_PATH_INVALID',
6172
+ message: String(error?.message || error).replace(/^FINAL_OUTPUT_PATH_INVALID:\s*/, ''),
6173
+ };
6174
+ }
6175
+ const noOutputReason = String(input.no_output_reason || '').trim();
6176
+ if (files.length === 0 && !noOutputReason) {
6177
+ return {
6178
+ ok: false,
6179
+ code: 'FINAL_OUTPUT_FILES_OR_REASON_REQUIRED',
6180
+ message: '最终快照必须提供 files,或说明 no_output_reason',
6181
+ };
6182
+ }
6183
+ if (files.length > 0 && noOutputReason) {
6184
+ return {
6185
+ ok: false,
6186
+ code: 'FINAL_OUTPUT_REASON_CONFLICT',
6187
+ message: '有最终交付文件时不能同时填写 no_output_reason',
6188
+ };
6189
+ }
6190
+ const filesChanged = input.files_changed;
6191
+ if (typeof filesChanged !== 'number'
6192
+ || !Number.isInteger(filesChanged)
6193
+ || filesChanged < 0) {
6194
+ return {
6195
+ ok: false,
6196
+ code: 'FINAL_OUTPUT_FILES_CHANGED_INVALID',
6197
+ message: 'final_output_snapshot.files_changed 必须是大于等于 0 的整数',
6198
+ };
6199
+ }
6200
+ if (filesChanged !== files.length) {
6201
+ return {
6202
+ ok: false,
6203
+ code: 'FINAL_OUTPUT_FILE_COUNT_MISMATCH',
6204
+ message: `files_changed=${filesChanged} 与去重后的 files 数量 ${files.length} 不一致`,
6205
+ };
6206
+ }
6207
+ const normalizeLineCount = (fieldName, code) => {
6208
+ if (vcsMode === 'no-git' && (input[fieldName] === undefined || input[fieldName] === null)) {
6209
+ return { ok: true, value: null };
6210
+ }
6211
+ const value = input[fieldName];
6212
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
6213
+ return {
6214
+ ok: false,
6215
+ code,
6216
+ message: `final_output_snapshot.${fieldName} 必须是大于等于 0 的整数`,
6217
+ };
6218
+ }
6219
+ return { ok: true, value };
6220
+ };
6221
+ const addedLines = normalizeLineCount('added_lines', 'FINAL_OUTPUT_ADDED_LINES_INVALID');
6222
+ if (!addedLines.ok) return addedLines;
6223
+ const deletedLines = normalizeLineCount('deleted_lines', 'FINAL_OUTPUT_DELETED_LINES_INVALID');
6224
+ if (!deletedLines.ok) return deletedLines;
6225
+ return {
6226
+ ok: true,
6227
+ snapshot: {
6228
+ ...input,
6229
+ snapshot_kind: snapshotKind,
6230
+ vcs_mode: vcsMode,
6231
+ files,
6232
+ files_changed: filesChanged,
6233
+ added_lines: addedLines.value,
6234
+ deleted_lines: deletedLines.value,
6235
+ no_output_reason: files.length === 0 ? noOutputReason : undefined,
6236
+ },
6237
+ };
6238
+ }
6239
+
6240
+ function normalizeStrictFinalOutputSnapshot(details) {
6241
+ const validation = validateFinalOutputSnapshot(details?.final_output_snapshot);
6242
+ if (!validation.ok) fail(`${validation.code}: ${validation.message}`);
6243
+ return {
6244
+ ...details,
6245
+ final_output_snapshot: validation.snapshot,
6246
+ };
6247
+ }
6248
+
5696
6249
  function normalizeStrictTestResult(args, details) {
5697
6250
  const input = details?.test_results;
5698
6251
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
@@ -5979,6 +6532,20 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
5979
6532
  || details?.verification_reason
5980
6533
  || '',
5981
6534
  ).trim();
6535
+ const rawTaskFiles = input && Object.hasOwn(input, 'files') ? input.files : [];
6536
+ let taskFiles;
6537
+ try {
6538
+ taskFiles = normalizeRepositoryRelativeFiles(rawTaskFiles, {
6539
+ invalidCode: 'TASK_FILE_PATH_INVALID',
6540
+ fieldName: 'task_update.files',
6541
+ });
6542
+ } catch (error) {
6543
+ fail(String(error?.message || error));
6544
+ }
6545
+ const noFileChangeReason = String(input?.no_file_change_reason || '').trim();
6546
+ if (taskFiles.length === 0 && !noFileChangeReason) {
6547
+ fail('TASK_FILES_OR_REASON_REQUIRED: strict task_update 必须提供 files,或说明 no_file_change_reason');
6548
+ }
5982
6549
 
5983
6550
  let resolvedTestEventId = testEventId;
5984
6551
  let resolvedTestRunId = testRunId || undefined;
@@ -6031,6 +6598,8 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
6031
6598
  || (verificationNotApplicable ? verificationReason : undefined),
6032
6599
  verification_not_applicable: verificationNotApplicable,
6033
6600
  verification_not_applicable_reason: verificationNotApplicable ? verificationReason : undefined,
6601
+ files: taskFiles,
6602
+ no_file_change_reason: taskFiles.length === 0 ? noFileChangeReason : undefined,
6034
6603
  },
6035
6604
  },
6036
6605
  };
@@ -6624,6 +7193,7 @@ function cmdRecord(args) {
6624
7193
  'quality_gate_result',
6625
7194
  'conformance_review',
6626
7195
  'ai_adoption_review',
7196
+ 'final_output_snapshot',
6627
7197
  'survey_result',
6628
7198
  'baseline_record',
6629
7199
  'telemetry_warning',
@@ -6787,6 +7357,12 @@ function cmdRecord(args) {
6787
7357
  strictTaskId = taskUpdate.taskId;
6788
7358
  eventStatus = taskUpdate.status;
6789
7359
  details = taskUpdate.details;
7360
+ } else if (strict && type === 'check_result') {
7361
+ strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
7362
+ details = normalizeStrictCheckResult(details, args.result);
7363
+ } else if (strict && type === 'final_output_snapshot') {
7364
+ strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
7365
+ details = normalizeStrictFinalOutputSnapshot(details);
6790
7366
  } else if (strict) {
6791
7367
  strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
6792
7368
  }
@@ -7521,6 +8097,33 @@ function cmdSemanticObserve(args) {
7521
8097
  return observer;
7522
8098
  }
7523
8099
 
8100
+ /**
8101
+ * kb-upload: 知识库上传(文件夹 / zip)
8102
+ * 委托给 skywalk-sdd/kb-upload.cjs 执行
8103
+ */
8104
+ function cmdKbUpload(args) {
8105
+ // 将 args 还原为 --key=value 格式,传递给 kb-upload.cjs
8106
+ const passThrough = [];
8107
+ for (const [key, value] of Object.entries(args)) {
8108
+ if (value === true) {
8109
+ passThrough.push(`--${key}`);
8110
+ } else if (value !== undefined && value !== null && value !== false) {
8111
+ passThrough.push(`--${key}=${value}`);
8112
+ }
8113
+ }
8114
+
8115
+ const scriptPath = path.join(__dirname, 'kb-upload.cjs');
8116
+ try {
8117
+ const output = execFileSync(process.execPath, [scriptPath, ...passThrough], {
8118
+ encoding: 'utf8',
8119
+ stdio: 'inherit',
8120
+ });
8121
+ return output;
8122
+ } catch (err) {
8123
+ process.exitCode = 1;
8124
+ }
8125
+ }
8126
+
7524
8127
  /**
7525
8128
  * log archive-docs: 真实归档 Simple/Full spec,结束 archive 阶段并生成最终报告
7526
8129
  */
@@ -7716,6 +8319,9 @@ function main() {
7716
8319
  case 'check-task':
7717
8320
  cmdCheckTask(flags);
7718
8321
  break;
8322
+ case 'kb-upload':
8323
+ cmdKbUpload(flags);
8324
+ break;
7719
8325
  default:
7720
8326
  showHelp();
7721
8327
  process.exit(1);
@@ -7734,6 +8340,8 @@ SDD Telemetry CLI - 流程度量采集工具
7734
8340
  node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
7735
8341
  node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
7736
8342
  node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
8343
+ node skywalk-sdd/log.cjs kb-upload --folder=<path> [--minimum-level=propose-spec] [--validate-only] [--project=<spec-root>]
8344
+ node skywalk-sdd/log.cjs kb-upload --package=<zip-path> [--project=<spec-root>]
7737
8345
  node skywalk-sdd/log.cjs semantic-identity --delta-state=added
7738
8346
  node skywalk-sdd/log.cjs semantic-identity --delta-state=modified --entity-id=<uuid> --predecessor-version=<uuid>
7739
8347
  node skywalk-sdd/log.cjs semantic-identity --delta-state=unchanged --entity-id=<uuid> --version-id=<uuid>
@@ -7752,6 +8360,7 @@ SDD Telemetry CLI - 流程度量采集工具
7752
8360
  tasks-status 扫描 Full/Simple 模式 tasks.md 勾选状态
7753
8361
  check-task 扫描变更目录 tasks.md 并勾选指定 task_id
7754
8362
  archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
8363
+ kb-upload 知识库上传(文件夹自动打包 / zip 直传),支持随时入库
7755
8364
  semantic-identity 生成或复用实体/版本 ID(8 位十六进制);无需网络和中央 ID 服务
7756
8365
  semantic-scan 只读解析并校验当前 Change
7757
8366
  semantic-reconcile 全量对账文件与本地工作态本体实例
@@ -7775,6 +8384,8 @@ SDD Telemetry CLI - 流程度量采集工具
7775
8384
  node skywalk-sdd/log.cjs check-task --project=/my/project --change=user-auth --task-id=TASK-01
7776
8385
  node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive
7777
8386
  # archive-docs 最终报告默认生成到 openspec/changes/archive/<日期>-<change>/reports/<change>-report.md;可用 --report-output=<路径> 指定自定义路径
8387
+ node skywalk-sdd/log.cjs kb-upload --folder=openspec/changes/user-auth --minimum-level=propose-spec --project=/my/project
8388
+ node skywalk-sdd/log.cjs kb-upload --package=openspec/changes/archive/2026-07-31-user-auth.zip --project=/my/project
7778
8389
  node skywalk-sdd/log.cjs record --type=worktree_finish --command=apply --project=/my/project --change=user-auth --capability=user-auth --result=success --summary="merge+remove completed"
7779
8390
 
7780
8391
  Apply worktree(主仓库根目录,非 log.cjs 子命令):