kld-sdd 2.6.16 → 2.6.21

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 (70) hide show
  1. package/bin/kld-sdd-init.js +10 -0
  2. package/kld-sdd-guide.html +22 -0
  3. package/lib/device-auth-cli.js +130 -0
  4. package/lib/device-auth.js +345 -0
  5. package/lib/init-account-binding.js +108 -0
  6. package/lib/init.js +238 -55
  7. package/lib/skills-bundle.js +20 -1
  8. package/lib/tool-profiles.js +8 -0
  9. package/package.json +7 -3
  10. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  11. package/skywalk-sdd/context-client.cjs +38 -87
  12. package/skywalk-sdd/index.cjs +860 -132
  13. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  14. package/skywalk-sdd/kb-upload.cjs +505 -0
  15. package/skywalk-sdd/lib/shared.cjs +811 -0
  16. package/skywalk-sdd/lib/usage-contract.cjs +276 -0
  17. package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
  18. package/skywalk-sdd/lib/user-config.cjs +157 -0
  19. package/skywalk-sdd/metrics-v3.cjs +138 -8
  20. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  21. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  22. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  23. package/skywalk-sdd/ontology/id.cjs +26 -5
  24. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  25. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  26. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  27. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  28. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  29. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  30. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  31. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  32. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  33. package/skywalk-sdd/spec-root.cjs +31 -0
  34. package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
  35. package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
  36. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
  37. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
  38. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
  39. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
  40. package/templates/openspec/proposal.md +7 -3
  41. package/templates/openspec/spec.md +3 -3
  42. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
  43. package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
  44. package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
  45. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  46. package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
  47. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  48. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
  49. package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
  50. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  51. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  52. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
  53. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
  54. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  55. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  56. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  57. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  58. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  59. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  60. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  61. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  62. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  63. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  64. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  65. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  66. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  67. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  68. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  69. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  70. 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,
@@ -1393,30 +1375,35 @@ function appendEventUnlocked(dataDir, changeName, event) {
1393
1375
  appendMarkdownLog(dataDir, changeName, event);
1394
1376
  }
1395
1377
 
1396
- /** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。 */
1397
- function appendEvent(dataDir, changeName, event, options = {}) {
1398
- if (options.lockHeld) {
1399
- return appendEventUnlocked(dataDir, changeName, event);
1400
- }
1401
- return withEventWriteLock(dataDir, changeName, () => {
1402
- if (event?.type === 'stage_end') {
1403
- const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
1404
- if (existing.length > 0) {
1405
- const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
1406
- if (latest.result !== event.result) {
1407
- const error = new Error(
1408
- `E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
1409
- );
1410
- error.code = 'E_STAGE_END_CONFLICT';
1411
- throw error;
1412
- }
1413
- return { idempotent: true, event: latest };
1414
- }
1415
- }
1416
- appendEventUnlocked(dataDir, changeName, event);
1417
- return { idempotent: false, event };
1418
- });
1419
- }
1378
+ /** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。
1379
+ * kld-T06:Progress Runtime 已删除,appendEvent 仅负责本地持久化,不再做事件富化、
1380
+ * agent 拉起、metadata refresh 或 artifact snapshot。
1381
+ */
1382
+ function appendEvent(dataDir, changeName, event, options = {}) {
1383
+ if (options.lockHeld) {
1384
+ appendEventUnlocked(dataDir, changeName, event);
1385
+ return { idempotent: false, event };
1386
+ }
1387
+ const result = withEventWriteLock(dataDir, changeName, () => {
1388
+ if (event?.type === 'stage_end') {
1389
+ const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
1390
+ if (existing.length > 0) {
1391
+ const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
1392
+ if (latest.result !== event.result) {
1393
+ const error = new Error(
1394
+ `E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
1395
+ );
1396
+ error.code = 'E_STAGE_END_CONFLICT';
1397
+ throw error;
1398
+ }
1399
+ return { idempotent: true, event: latest };
1400
+ }
1401
+ }
1402
+ appendEventUnlocked(dataDir, changeName, event);
1403
+ return { idempotent: false, event };
1404
+ });
1405
+ return result;
1406
+ }
1420
1407
 
1421
1408
  /** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
1422
1409
  * execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
@@ -1905,7 +1892,9 @@ function summarizeStageExecutions(events) {
1905
1892
  start,
1906
1893
  end,
1907
1894
  result: end?.result || null,
1908
- duration_ms: Number.isFinite(end?.duration_ms) ? end.duration_ms : 0,
1895
+ duration_ms: Number.isFinite(end?.duration_ms) && end.duration_ms >= 0
1896
+ ? end.duration_ms
1897
+ : null,
1909
1898
  canonical: false,
1910
1899
  rework_reason: null,
1911
1900
  };
@@ -1935,7 +1924,7 @@ function summarizeStageExecutions(events) {
1935
1924
  if (canonical) {
1936
1925
  canonical.canonical = true;
1937
1926
  canonicalAttempts.push(canonical);
1938
- effectiveStageDurationMs += canonical.duration_ms;
1927
+ effectiveStageDurationMs += Number.isFinite(canonical.duration_ms) ? canonical.duration_ms : 0;
1939
1928
  }
1940
1929
 
1941
1930
  for (const attempt of groupAttempts) {
@@ -1967,8 +1956,9 @@ function summarizeStageExecutions(events) {
1967
1956
  if (attempt.end) {
1968
1957
  completedReworkAttempts += 1;
1969
1958
  bucket.completed_rework_attempts += 1;
1970
- bucket.rework_duration_ms += attempt.duration_ms;
1971
- reworkStageDurationMs += attempt.duration_ms;
1959
+ const reworkDurationMs = Number.isFinite(attempt.duration_ms) ? attempt.duration_ms : 0;
1960
+ bucket.rework_duration_ms += reworkDurationMs;
1961
+ reworkStageDurationMs += reworkDurationMs;
1972
1962
  } else {
1973
1963
  supersededOpenStages += 1;
1974
1964
  bucket.superseded_open_stages += 1;
@@ -2250,9 +2240,14 @@ function getCheckResults(event) {
2250
2240
  categories: results.categories || {},
2251
2241
  // V3 Q3: reviewer 元信息,用于评估 reviewer_independence/score_status(缺失时保持 null,不影响旧字段)
2252
2242
  reviewer: results.reviewer || null,
2243
+ review_execution_mode: results.review_execution_mode || null,
2244
+ reviewer_agent_id: results.reviewer_agent_id || null,
2253
2245
  review_session_id: results.review_session_id || null,
2246
+ reviewer_session_id: results.reviewer_session_id || null,
2254
2247
  author_session_id: results.author_session_id || null,
2255
2248
  reviewer_independence: results.reviewer_independence || null,
2249
+ fallback_reason_code: results.fallback_reason_code || null,
2250
+ fallback_reason: results.fallback_reason || null,
2256
2251
  warning_items: Array.isArray(results.warning_items) ? results.warning_items : [],
2257
2252
  warning_dispositions: Array.isArray(results.warning_dispositions) ? results.warning_dispositions : [],
2258
2253
  };
@@ -2967,7 +2962,20 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
2967
2962
  ? new Date(archiveEnd.timestamp).getTime() - new Date(proposeStart.timestamp).getTime()
2968
2963
  : null;
2969
2964
 
2970
- const totalStageDuration = executionSummary.summary.total_stage_duration_ms;
2965
+ // E1 只在全部纳入加总的已结束阶段都有合法耗时时发布:
2966
+ // 没有配对记录或任一耗时缺失都代表“没有足够证据”,不能解释为真实耗时为 0。
2967
+ const e1DurationAttempts = [
2968
+ ...executionSummary.canonicalAttempts,
2969
+ ...executionSummary.reworkAttempts.filter(attempt => attempt.end),
2970
+ ];
2971
+ const hasCompleteE1DurationEvidence = e1DurationAttempts.length > 0
2972
+ && e1DurationAttempts.every(attempt => (
2973
+ Number.isFinite(attempt.end?.duration_ms)
2974
+ && attempt.end.duration_ms >= 0
2975
+ ));
2976
+ const totalStageDuration = hasCompleteE1DurationEvidence
2977
+ ? executionSummary.summary.total_stage_duration_ms
2978
+ : null;
2971
2979
  const codingDuration = sumDurations(ends, ['apply']);
2972
2980
  const specDuration = sumDurations(ends, ['propose', 'spec', 'design', 'task']);
2973
2981
  const reworkCodingDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['apply']);
@@ -3020,6 +3028,26 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3020
3028
  && timestampMs(attempt.end) <= timestampMs(firstApply)
3021
3029
  ))
3022
3030
  : null;
3031
+ const qualifyingCheckAttempts = firstApply
3032
+ ? executionSummary.attempts.filter(attempt => (
3033
+ attempt.command === 'check'
3034
+ && attempt.end
3035
+ && ['success', 'partial'].includes(attempt.result)
3036
+ && timestampMs(attempt.end) <= timestampMs(firstApply)
3037
+ ))
3038
+ : [];
3039
+ const p4Evidence = {
3040
+ first_apply_started_at: firstApply?.timestamp || null,
3041
+ first_apply_event_id: firstApply?.event_id || null,
3042
+ qualifying_check_count: qualifyingCheckAttempts.length,
3043
+ qualifying_checks: qualifyingCheckAttempts.map(attempt => ({
3044
+ result: attempt.result,
3045
+ started_at: attempt.start?.timestamp || null,
3046
+ ended_at: attempt.end?.timestamp || null,
3047
+ start_event_id: attempt.start?.event_id || null,
3048
+ end_event_id: attempt.end?.event_id || null,
3049
+ })),
3050
+ };
3023
3051
  const latestCheckWithFixRate = latestByTimestamp(checkEvents.filter(e => {
3024
3052
  const results = getCheckResults(e);
3025
3053
  return results?.total > 0 && results.fixed_before_apply != null;
@@ -3049,8 +3077,13 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3049
3077
  reviewer: latestCheckResultsV3?.reviewer || null,
3050
3078
  author: formalApplyAttempt?.start?.session_id || formalApplyAttempt?.start?.agent_type || null,
3051
3079
  reviewer_independence: latestCheckResultsV3?.reviewer_independence || null,
3080
+ review_execution_mode: latestCheckResultsV3?.review_execution_mode || null,
3081
+ reviewer_agent_id: latestCheckResultsV3?.reviewer_agent_id || null,
3052
3082
  review_session_id: latestCheckResultsV3?.review_session_id || null,
3083
+ reviewer_session_id: latestCheckResultsV3?.reviewer_session_id || null,
3053
3084
  author_session_id: latestCheckResultsV3?.author_session_id || formalApplyAttempt?.start?.session_id || null,
3085
+ fallback_reason_code: latestCheckResultsV3?.fallback_reason_code || null,
3086
+ fallback_reason: latestCheckResultsV3?.fallback_reason || null,
3054
3087
  };
3055
3088
  const q3Result = metricsV3.computeSpecQualityScore(latestCheckResultsV3, checkMeta);
3056
3089
 
@@ -3074,11 +3107,21 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3074
3107
  completed_stages: completedStages,
3075
3108
  missing: missingRequiredStages,
3076
3109
  };
3077
- const pHTelemetryHealthScore = Number.isFinite(telemetryHealth.telemetry_health_score)
3078
- ? metricsV3.round(telemetryHealth.telemetry_health_score * 100, 2)
3079
- : null;
3110
+ const pHTelemetryHealthScore = measurementEvents.length === 0
3111
+ ? null
3112
+ : (Number.isFinite(telemetryHealth.telemetry_health_score_percent)
3113
+ ? telemetryHealth.telemetry_health_score_percent
3114
+ : (Number.isFinite(telemetryHealth.telemetry_health_score)
3115
+ ? metricsV3.round(telemetryHealth.telemetry_health_score * 100, 2)
3116
+ : null));
3080
3117
 
3081
- const reworkSummary = compactReworkSummary(executionSummary.summary, reworkReasons);
3118
+ const compactedReworkSummary = compactReworkSummary(executionSummary.summary, reworkReasons);
3119
+ const reworkSummary = executionSummary.attempts.length === 0
3120
+ ? {
3121
+ ...compactedReworkSummary,
3122
+ total_rework_attempts: null,
3123
+ }
3124
+ : compactedReworkSummary;
3082
3125
 
3083
3126
  const v3Context = buildV3ChangeContext(scopedEvents, executionSummary, {
3084
3127
  change_name: options.change || null,
@@ -3117,6 +3160,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3117
3160
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3118
3161
  reason: e1AvailabilityReason || incompleteJourneyReason,
3119
3162
  source_event_count: measurementEvents.length,
3163
+ event_ids: measurementEvents.map(event => event.event_id).filter(Boolean),
3120
3164
  },
3121
3165
  q3: {
3122
3166
  status: v3.quality.q3_score_status === 'verified'
@@ -3125,6 +3169,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3125
3169
  : (v3.quality.q3_availability ? 'provisional' : 'missing'),
3126
3170
  reason: v3.quality.q3_availability_reason,
3127
3171
  source_event_count: checkEvents.length,
3172
+ event_ids: checkEvents.map(event => event.event_id).filter(Boolean),
3128
3173
  },
3129
3174
  p4: {
3130
3175
  status: !firstApply
@@ -3132,6 +3177,10 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3132
3177
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3133
3178
  reason: firstApply ? incompleteJourneyReason : 'missing-apply-stage',
3134
3179
  source_event_count: executionSummary.attempts.filter(attempt => attempt.command === 'check').length,
3180
+ event_ids: [
3181
+ p4Evidence.first_apply_event_id,
3182
+ ...p4Evidence.qualifying_checks.flatMap(item => [item.start_event_id, item.end_event_id]),
3183
+ ].filter(Boolean),
3135
3184
  },
3136
3185
  p_r: {
3137
3186
  status: executionSummary.attempts.length === 0
@@ -3139,6 +3188,9 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3139
3188
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3140
3189
  reason: executionSummary.attempts.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
3141
3190
  source_event_count: executionSummary.attempts.length,
3191
+ event_ids: executionSummary.attempts
3192
+ .flatMap(attempt => [attempt.start?.event_id, attempt.end?.event_id])
3193
+ .filter(Boolean),
3142
3194
  },
3143
3195
  p_h: {
3144
3196
  status: measurementEvents.length === 0
@@ -3146,6 +3198,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3146
3198
  : (completeMeasurementJourney ? 'trusted' : 'provisional'),
3147
3199
  reason: measurementEvents.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
3148
3200
  source_event_count: measurementEvents.length,
3201
+ event_ids: measurementEvents.map(event => event.event_id).filter(Boolean),
3149
3202
  },
3150
3203
  };
3151
3204
 
@@ -3167,7 +3220,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3167
3220
  ? Math.max(0, leadTime - executionSummary.summary.effective_stage_duration_ms)
3168
3221
  : null,
3169
3222
  rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
3170
- total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
3223
+ total_stage_duration_ms: totalStageDuration,
3171
3224
  measurement_stage_duration_ms: totalStageDuration,
3172
3225
  e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
3173
3226
  ? roundMetric((codingDuration + reworkCodingDuration) / executionSummary.summary.total_stage_duration_ms)
@@ -3199,6 +3252,11 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3199
3252
  q3_score_status: v3.quality.q3_score_status,
3200
3253
  q3_reviewer_independence: v3.quality.q3_reviewer_independence,
3201
3254
  q3_category_scores: v3.quality.q3_category_scores,
3255
+ q3_total: v3.quality.q3_total,
3256
+ q3_errors: v3.quality.q3_errors,
3257
+ q3_warnings: v3.quality.q3_warnings,
3258
+ q3_suggestions: v3.quality.q3_suggestions,
3259
+ q3_review: v3.quality.q3_review,
3202
3260
  q3_availability: v3.quality.q3_availability,
3203
3261
  // Q1/Q4 上游信号未就绪时用 pending-upstream 标注,不把 null 误转成 0
3204
3262
  q1_status: v3.quality.q1_spec_conformance.status,
@@ -3211,6 +3269,7 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
3211
3269
  p2_ai_code_adoption_level: aiAdoptionMetrics.adoption_level,
3212
3270
  ai_adoption_counts: aiAdoptionMetrics.adoption_counts,
3213
3271
  p4_quality_gate_enforcement_rate: qualityGateRate,
3272
+ p4_evidence: p4Evidence,
3214
3273
  p4b_check_fixed_rate: fixRate,
3215
3274
  git_document_metrics: gitDocumentMetrics,
3216
3275
  rework_summary: reworkSummary,
@@ -3354,9 +3413,11 @@ function computePdfMvpMetrics(events, options = {}) {
3354
3413
  const health = computeTelemetryHealthMetrics(events);
3355
3414
  return {
3356
3415
  ...health,
3357
- p_h_telemetry_health_score: Number.isFinite(health.telemetry_health_score)
3358
- ? metricsV3.round(health.telemetry_health_score * 100, 2)
3359
- : null,
3416
+ p_h_telemetry_health_score: Number.isFinite(health.telemetry_health_score_percent)
3417
+ ? health.telemetry_health_score_percent
3418
+ : (Number.isFinite(health.telemetry_health_score)
3419
+ ? metricsV3.round(health.telemetry_health_score * 100, 2)
3420
+ : null),
3360
3421
  };
3361
3422
  })(),
3362
3423
  by_change: byChange,
@@ -4247,6 +4308,17 @@ function fileListSymmetricDiff(left, right) {
4247
4308
 
4248
4309
  // ── U1 报告维度提取辅助:从 events/metrics/doctor/archive 提炼 5 维度,数据缺失诚实标注 _available: false ──
4249
4310
  function extractChangedFiles(events, projectRoot) {
4311
+ const snapshotEvents = events.filter(event => (
4312
+ event?.type === 'final_output_snapshot'
4313
+ && event.details?.final_output_snapshot
4314
+ && typeof event.details.final_output_snapshot === 'object'
4315
+ ));
4316
+ const latestSnapshotEvent = latestByTimestamp(snapshotEvents);
4317
+ const snapshotValidation = latestSnapshotEvent
4318
+ ? validateFinalOutputSnapshot(latestSnapshotEvent.details.final_output_snapshot)
4319
+ : null;
4320
+ const finalSnapshot = snapshotValidation?.ok ? snapshotValidation.snapshot : null;
4321
+ const invalidFinalSnapshot = Boolean(latestSnapshotEvent && !snapshotValidation?.ok);
4250
4322
  const adoptionEvents = getAiAdoptionEvents(events);
4251
4323
  const finalAdoptionEvents = adoptionEvents.filter((event) => {
4252
4324
  const review = getAiAdoptionReview(event);
@@ -4258,25 +4330,47 @@ function extractChangedFiles(events, projectRoot) {
4258
4330
  finalAdoptionEvents.length > 0 ? finalAdoptionEvents : adoptionEvents,
4259
4331
  );
4260
4332
  const review = latest ? getAiAdoptionReview(latest) : null;
4261
- const aiDiff = review?.ai_diff || null;
4333
+ const aiDiff = latestSnapshotEvent ? null : (review?.ai_diff || null);
4262
4334
  const taskUpdateFiles = new Set();
4335
+ const taskEventIds = [];
4336
+ const taskIds = [];
4337
+ const noFileChangeReasons = [];
4338
+ let taskEventLatestAt = null;
4263
4339
  for (const e of events) {
4264
4340
  if (e?.type !== 'task_update') continue;
4265
- const filesField = e.details?.files
4341
+ const taskUpdate = e.details?.task_update || {};
4342
+ const filesField = taskUpdate.files
4343
+ || e.details?.files
4266
4344
  || e.details?.test_results?.files
4267
4345
  || (Array.isArray(e.details?.files_changed) ? e.details.files_changed : null);
4268
- if (Array.isArray(filesField)) {
4346
+ const reason = taskUpdate.no_file_change_reason || e.details?.no_file_change_reason;
4347
+ if (typeof reason === 'string' && reason.trim()) {
4348
+ noFileChangeReasons.push({
4349
+ task_id: e.task_id || taskUpdate.task_id || null,
4350
+ event_id: e.event_id || null,
4351
+ reason: reason.trim(),
4352
+ });
4353
+ }
4354
+ if (Array.isArray(filesField) && filesField.length > 0) {
4355
+ if (e.event_id) taskEventIds.push(e.event_id);
4356
+ const taskId = e.task_id || taskUpdate.task_id;
4357
+ if (taskId) taskIds.push(taskId);
4358
+ if (!taskEventLatestAt || timestampMs(e) > Date.parse(taskEventLatestAt)) {
4359
+ taskEventLatestAt = e.timestamp || null;
4360
+ }
4269
4361
  for (const f of filesField) {
4270
4362
  if (typeof f === 'string' && f.trim()) taskUpdateFiles.add(f.trim());
4271
4363
  }
4272
4364
  }
4273
4365
  }
4274
4366
  const finalOutputFiles = new Set(
4275
- Array.isArray(aiDiff?.files)
4276
- ? aiDiff.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4277
- : [],
4367
+ Array.isArray(finalSnapshot?.files)
4368
+ ? finalSnapshot.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4369
+ : (Array.isArray(aiDiff?.files)
4370
+ ? aiDiff.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
4371
+ : [])
4278
4372
  );
4279
- if (finalOutputFiles.size === 0 && latest?.details) {
4373
+ if (!finalSnapshot && finalOutputFiles.size === 0 && latest?.details) {
4280
4374
  const rawReview = latest.details.ai_adoption
4281
4375
  || latest.details.ai_adoption_review
4282
4376
  || latest.details.ai_code_adoption
@@ -4290,23 +4384,40 @@ function extractChangedFiles(events, projectRoot) {
4290
4384
 
4291
4385
  const taskEventFileList = sortedUniqueFileList([...taskUpdateFiles]);
4292
4386
  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);
4387
+ const taskEvidenceAvailable = taskEventFileList.length > 0 || noFileChangeReasons.length > 0;
4388
+ const taskUpdateFileCount = taskEvidenceAvailable ? taskEventFileList.length : null;
4389
+ const finalOutputFileCount = finalSnapshot
4390
+ ? (Number.isFinite(finalSnapshot.files_changed)
4391
+ ? finalSnapshot.files_changed
4392
+ : finalOutputFileList.length)
4393
+ : (Number.isFinite(aiDiff?.files_changed)
4394
+ ? aiDiff.files_changed
4395
+ : (finalOutputFileList.length > 0 ? finalOutputFileList.length : null));
4396
+ const snapshotKind = finalSnapshot
4397
+ ? finalSnapshot.snapshot_kind
4398
+ : (latestSnapshotEvent
4399
+ ? null
4400
+ : (aiDiff?.snapshot_kind
4401
+ || ((review?.review_status === 'final' || latest?.status === 'final') ? 'final' : null)));
4299
4402
  const finalOutputStatus = snapshotKind === 'final'
4300
4403
  ? 'final'
4301
4404
  : (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);
4405
+ const addedLines = finalSnapshot
4406
+ ? (Number.isFinite(finalSnapshot.added_lines) ? finalSnapshot.added_lines : null)
4407
+ : (Number.isFinite(aiDiff?.added_lines)
4408
+ ? aiDiff.added_lines
4409
+ : (Number.isFinite(review?.final_diff?.added_lines) ? review.final_diff.added_lines : null));
4410
+ const deletedLines = finalSnapshot
4411
+ ? (Number.isFinite(finalSnapshot.deleted_lines) ? finalSnapshot.deleted_lines : null)
4412
+ : (Number.isFinite(aiDiff?.deleted_lines)
4413
+ ? aiDiff.deleted_lines
4414
+ : (Number.isFinite(review?.final_diff?.deleted_lines) ? review.final_diff.deleted_lines : null));
4308
4415
  let lineCountEstimate = null;
4309
- if (addedLines == null && review?.ai_git_sha == null && finalOutputFileList.length > 0 && projectRoot) {
4416
+ if (!finalSnapshot
4417
+ && addedLines == null
4418
+ && review?.ai_git_sha == null
4419
+ && finalOutputFileList.length > 0
4420
+ && projectRoot) {
4310
4421
  let lines = 0;
4311
4422
  let countedFiles = 0;
4312
4423
  const root = path.resolve(projectRoot);
@@ -4331,27 +4442,26 @@ function extractChangedFiles(events, projectRoot) {
4331
4442
  };
4332
4443
  }
4333
4444
  }
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
4445
+ // 对外的 files_changed 只代表最终实际交付;任务事件文件始终使用独立字段。
4446
+ const filesChanged = finalOutputStatus === 'final' ? finalOutputFileCount : null;
4447
+ const countsDiffer = taskUpdateFileCount != null
4339
4448
  && finalOutputFileCount != null
4340
4449
  && taskUpdateFileCount !== finalOutputFileCount;
4341
4450
  const listDiff = fileListSymmetricDiff(taskEventFileList, finalOutputFileList);
4342
- const listsDiffer = taskUpdateFileCount > 0
4343
- && finalOutputFileList.length > 0
4451
+ const listsDiffer = taskUpdateFileCount != null
4452
+ && finalOutputFileCount != null
4344
4453
  && (listDiff.only_in_task_events.length > 0 || listDiff.only_in_final_output.length > 0);
4345
4454
  let reconciliationStatus = 'unavailable';
4346
4455
  let reconciliationNote = '没有可用的文件变更证据。';
4347
- if (taskUpdateFileCount === 0 && finalOutputFileCount == null) {
4456
+ if (taskUpdateFileCount == null && finalOutputFileCount == null) {
4348
4457
  reconciliationStatus = 'unavailable';
4349
4458
  reconciliationNote = '没有可用的文件变更证据。';
4350
- } else if (taskUpdateFileCount === 0 || finalOutputFileCount == null) {
4351
- reconciliationStatus = 'unavailable';
4352
- reconciliationNote = finalOutputFileCount == null
4353
- ? '最终输出文件快照不可用,无法与任务事件对账。'
4354
- : '任务事件未记录文件清单;仅展示最终输出快照。';
4459
+ } else if (finalOutputFileCount == null) {
4460
+ reconciliationStatus = 'final-snapshot-missing';
4461
+ reconciliationNote = '最终输出文件快照不可用,无法与任务事件对账。';
4462
+ } else if (taskUpdateFileCount == null) {
4463
+ reconciliationStatus = 'task-evidence-missing';
4464
+ reconciliationNote = '任务事件未记录文件清单或无文件原因;仅展示最终输出快照。';
4355
4465
  } else if (countsDiffer) {
4356
4466
  reconciliationStatus = 'count-mismatch';
4357
4467
  reconciliationNote = `任务事件累计涉及 ${taskUpdateFileCount} 个文件;最终输出快照包含 ${finalOutputFileCount} 个文件。两者用途不同,不应合并为一个口径。`;
@@ -4363,17 +4473,37 @@ function extractChangedFiles(events, projectRoot) {
4363
4473
  reconciliationNote = '任务事件与最终输出文件口径一致。';
4364
4474
  }
4365
4475
  return {
4366
- _available: filesChanged != null,
4476
+ _available: filesChanged != null || taskUpdateFileCount != null,
4367
4477
  files_changed: filesChanged,
4368
4478
  added_lines: addedLines,
4369
4479
  deleted_lines: deletedLines,
4370
4480
  files: finalOutputFileList.length > 0 ? finalOutputFileList : null,
4371
- task_event_files: taskUpdateFileCount > 0 ? taskEventFileList : null,
4372
- task_event_files_changed: taskUpdateFileCount > 0 ? taskUpdateFileCount : null,
4481
+ task_event_files: taskUpdateFileCount != null ? taskEventFileList : null,
4482
+ task_event_files_changed: taskUpdateFileCount,
4483
+ task_event_source: 'task_update.files',
4484
+ task_event_task_ids: [...new Set(taskIds)],
4485
+ task_event_ids: [...new Set(taskEventIds)],
4486
+ task_event_latest_at: taskEventLatestAt,
4487
+ no_file_change_reasons: noFileChangeReasons,
4373
4488
  final_output_files: finalOutputFileList.length > 0 ? finalOutputFileList : null,
4374
4489
  final_output_files_changed: finalOutputFileCount,
4375
4490
  final_output_status: finalOutputStatus,
4376
- diff_source: snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'),
4491
+ final_output_source: finalSnapshot
4492
+ ? 'final_output_snapshot.files'
4493
+ : (snapshotKind === 'final' ? 'ai-diff-final(旧事件兼容)' : null),
4494
+ final_output_event_id: latestSnapshotEvent?.event_id || latest?.event_id || null,
4495
+ final_output_captured_at: latestSnapshotEvent?.timestamp || latest?.timestamp || null,
4496
+ final_output_no_output_reason: finalSnapshot?.no_output_reason || null,
4497
+ final_output_vcs_mode: finalSnapshot?.vcs_mode || null,
4498
+ final_output_validation_status: invalidFinalSnapshot ? 'invalid' : (finalSnapshot ? 'valid' : 'unavailable'),
4499
+ final_output_validation_reason: invalidFinalSnapshot
4500
+ ? `${snapshotValidation.code}: ${snapshotValidation.message}`
4501
+ : null,
4502
+ diff_source: finalSnapshot
4503
+ ? 'final-output-snapshot'
4504
+ : (invalidFinalSnapshot
4505
+ ? 'invalid-final-output-snapshot'
4506
+ : (snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'))),
4377
4507
  line_count_estimate: lineCountEstimate,
4378
4508
  reconciliation_status: reconciliationStatus,
4379
4509
  reconciliation_note: reconciliationNote,
@@ -4434,6 +4564,8 @@ function extractStageTimeline(events) {
4434
4564
  return {
4435
4565
  stage: cmd,
4436
4566
  round,
4567
+ start_event_id: start.event_id || null,
4568
+ end_event_id: end?.event_id || null,
4437
4569
  start_ts: start.timestamp || null,
4438
4570
  end_ts: end?.timestamp || null,
4439
4571
  duration_ms: end && Number.isFinite(end.duration_ms) ? end.duration_ms : null,
@@ -5141,11 +5273,14 @@ function computeDoctorReport(events, options = {}) {
5141
5273
  outdatedSchemaEvents.length * 0.06 +
5142
5274
  missingChangeEvents.length * 0.04 +
5143
5275
  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);
5276
+ const telemetryHealth = metricsV3.computeTelemetryHealthScore({
5277
+ weighted_issue_value: penalty,
5278
+ stage_event_count: stageEvents.length,
5279
+ });
5146
5280
 
5147
5281
  return {
5148
- telemetry_health_score: telemetryHealthScore,
5282
+ telemetry_health_score: telemetryHealth.ratio,
5283
+ telemetry_health_score_percent: telemetryHealth.score,
5149
5284
  matched_stage_rate: matchedStageRate == null ? null : Math.round(matchedStageRate * 100) / 100,
5150
5285
  total_events: scopedEvents.length,
5151
5286
  stage_events: stageEvents.length,
@@ -5173,19 +5308,8 @@ function computeDoctorReport(events, options = {}) {
5173
5308
 
5174
5309
  // ── CLI 参数解析 ──────────────────────────────────────
5175
5310
 
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
- }
5311
+ // parseArgs 已迁移至 lib/shared.cjs(统一 5 份实现)
5312
+ // 本文件顶部已通过 require 引入,此处保留 const 别名以兼容内部引用
5189
5313
 
5190
5314
  // ── CLI 命令实现 ─────────────────────────────────────
5191
5315
 
@@ -5266,6 +5390,20 @@ function cmdStart(args) {
5266
5390
  appendEvent(dataDir, event.change, event);
5267
5391
  writeActiveStage(projectRoot, event);
5268
5392
 
5393
+ // kld-T05: 七值命令成功写入 stage_start 后,异步上报使用事件。
5394
+ // test/explore 与失败路径不上报;上报失败不影响本地事件与退出码。
5395
+ const usagePromise = (() => {
5396
+ try {
5397
+ const { mapCommandToUsageSkill } = require('./lib/usage-contract.cjs');
5398
+ const skill = mapCommandToUsageSkill(command);
5399
+ if (!skill) return null;
5400
+ const { reportUsageOnce } = require('./lib/usage-reporter.cjs');
5401
+ return reportUsageOnce({ skill, startedAt: timestamp }).catch(() => null);
5402
+ } catch {
5403
+ return null;
5404
+ }
5405
+ })();
5406
+
5269
5407
  // 输出 JSON,方便 AI Agent 解析 event_id
5270
5408
  const output = {
5271
5409
  event_id: eventId,
@@ -5273,6 +5411,9 @@ function cmdStart(args) {
5273
5411
  message: `SDD ${command} 阶段开始记录(change: ${event.change})`,
5274
5412
  };
5275
5413
  console.log(JSON.stringify(output, null, 2));
5414
+ // 返回 usage 上报 promise(可能为 null),便于测试或调用方 await;
5415
+ // 现有 main() 不读返回值,行为兼容。
5416
+ return usagePromise;
5276
5417
  }
5277
5418
 
5278
5419
  const ONTOLOGY_AUTHORING_STAGES = new Set(['propose', 'spec', 'design', 'task', 'check']);
@@ -5348,7 +5489,21 @@ function cmdEnd(args, options = {}) {
5348
5489
  fail('E_CHANGE_REQUIRED: 无法解析 start 的 end 必须显式提供 --change');
5349
5490
  }
5350
5491
  if (startEvent && requestedChange && startEvent.change !== requestedChange) {
5351
- fail(`E_CHANGE_CONFLICT: end 指定 change "${requestedChange}",但 active stage 属于 "${startEvent.change}"`);
5492
+ // BUGFIX: 允许 start 使用占位 change(如 pending)时 end 指定真实 change-key 自动迁移
5493
+ // 场景:propose 阶段初期不知道最终 change-key,start 使用 --change=pending 占位,
5494
+ // 后期生成 change-key 后 end 使用真实名称。此时应允许并自动迁移事件文件。
5495
+ const PLACEHOLDER_CHANGES = new Set(['pending']);
5496
+ if (PLACEHOLDER_CHANGES.has(startEvent.change)) {
5497
+ // 将 start 事件复制到新的 change 文件下,保持 event_id 连续性
5498
+ if (process.env.SDD_DEBUG_TELEMETRY) {
5499
+ console.error(`[telemetry] migrating start event from placeholder "${startEvent.change}" → "${requestedChange}" (event_id=${eventId})`);
5500
+ }
5501
+ appendEvent(dataDir, requestedChange, startEvent, { lockHeld: false });
5502
+ // 更新内存中的 startEvent.change 供后续使用
5503
+ startEvent = { ...startEvent, change: requestedChange };
5504
+ } else {
5505
+ fail(`E_CHANGE_CONFLICT: end 指定 change "${requestedChange}",但 active stage 属于 "${startEvent.change}"`);
5506
+ }
5352
5507
  }
5353
5508
  // stage_end 幂等性保护:同一 event_id 已存在 stage_end 时,结果相同则跳过,不同则更新(archive 命令只要存在即跳过)
5354
5509
  const command = startEvent?.command || args.command || 'unknown';
@@ -5605,6 +5760,9 @@ function cmdEnd(args, options = {}) {
5605
5760
  ...event.details.archive_result,
5606
5761
  ...actualPaths,
5607
5762
  };
5763
+ if (event.details.archive_result.archive_id && !event.archive_id) {
5764
+ event.archive_id = event.details.archive_result.archive_id;
5765
+ }
5608
5766
  }
5609
5767
 
5610
5768
  appendEvent(dataDir, event.change, event);
@@ -5612,6 +5770,37 @@ function cmdEnd(args, options = {}) {
5612
5770
  clearActiveStage(projectRoot, startEvent);
5613
5771
  }
5614
5772
 
5773
+ // Archive stage_end is remapped to archive_linked on the wire. Also emit an explicit
5774
+ // run_ended so monitors that only listen for terminal kinds still close the run.
5775
+ if (command === 'archive' && (result === 'success' || result === 'partial' || result === 'failed' || result === 'failure')) {
5776
+ try {
5777
+ const terminalResult = result === 'failure' ? 'failed' : result;
5778
+ appendEvent(dataDir, event.change, cleanOptionalFields({
5779
+ schema_version: SCHEMA_VERSION,
5780
+ event_id: generateEventId(),
5781
+ type: 'stage_end',
5782
+ event_kind: 'run_ended',
5783
+ source: event.source,
5784
+ command: 'archive',
5785
+ stage: 'archive',
5786
+ change: event.change,
5787
+ agent_type: event.agent_type,
5788
+ project_root: projectRoot,
5789
+ session_id: event.session_id,
5790
+ git_sha: event.git_sha,
5791
+ timestamp: nowISO(),
5792
+ result: terminalResult,
5793
+ summary: event.summary || '变更归档完成,运行已结束',
5794
+ details: {
5795
+ terminal_of: event.event_id,
5796
+ archive_id: event.details?.archive_result?.archive_id,
5797
+ },
5798
+ }));
5799
+ } catch (err) {
5800
+ try { console.error(`[telemetry] run_ended 补发失败(不阻塞): ${err.message}`); } catch {}
5801
+ }
5802
+ }
5803
+
5615
5804
  // 终态摘要必须基于已经落库的 archive stage_end。
5616
5805
  if (command === 'archive') {
5617
5806
  try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
@@ -5693,6 +5882,484 @@ function strictInteger(value, code, fieldName, minimum = 0) {
5693
5882
  return number;
5694
5883
  }
5695
5884
 
5885
+ function strictStructuredInteger(value, code, fieldName, minimum = 0) {
5886
+ if (!Number.isInteger(value) || value < minimum) {
5887
+ fail(`${code}: ${fieldName} 必须是大于等于 ${minimum} 的整数`);
5888
+ }
5889
+ return value;
5890
+ }
5891
+
5892
+ const CHECK_FALLBACK_REASON_CODES = new Set([
5893
+ 'SUBAGENT_CAPABILITY_UNAVAILABLE',
5894
+ 'SUBAGENT_PERMISSION_DENIED',
5895
+ 'SUBAGENT_START_FAILED',
5896
+ 'SUBAGENT_TIMEOUT',
5897
+ 'SUBAGENT_RESULT_INVALID',
5898
+ ]);
5899
+
5900
+ const CHECK_IDENTITY_PLACEHOLDERS = new Set([
5901
+ 'unknown',
5902
+ 'none',
5903
+ 'null',
5904
+ 'n/a',
5905
+ 'na',
5906
+ '未记录',
5907
+ '未知',
5908
+ ]);
5909
+
5910
+ function isMeaningfulCheckIdentity(value) {
5911
+ const normalized = String(value || '').trim();
5912
+ if (!normalized) return false;
5913
+ if (CHECK_IDENTITY_PLACEHOLDERS.has(normalized.toLowerCase())) return false;
5914
+ if (/^<[^>]+>$/.test(normalized) || /^\[[^\]]+\]$/.test(normalized)) return false;
5915
+ return true;
5916
+ }
5917
+
5918
+ function normalizeRepositoryRelativeFiles(files, {
5919
+ requiredCode,
5920
+ invalidCode,
5921
+ fieldName,
5922
+ } = {}) {
5923
+ if (!Array.isArray(files)) {
5924
+ const code = requiredCode || invalidCode || 'REPOSITORY_FILE_PATH_INVALID';
5925
+ throw new Error(`${code}: ${fieldName || 'files'} 必须是数组`);
5926
+ }
5927
+ if (files.length === 0) {
5928
+ if (requiredCode) {
5929
+ throw new Error(`${requiredCode}: ${fieldName || 'files'} 必须是非空数组`);
5930
+ }
5931
+ return [];
5932
+ }
5933
+ const normalized = [];
5934
+ for (const value of files) {
5935
+ if (typeof value !== 'string') {
5936
+ throw new Error(`${invalidCode || 'REPOSITORY_FILE_PATH_INVALID'}: ${fieldName || 'files'} 只能包含字符串路径`);
5937
+ }
5938
+ const raw = value.trim().replace(/\\/g, '/');
5939
+ const segments = raw.split('/');
5940
+ if (!raw
5941
+ || path.isAbsolute(raw)
5942
+ || /^[A-Za-z]:/.test(raw)
5943
+ || segments.includes('..')
5944
+ || segments.includes('.')
5945
+ || raw.startsWith('/')) {
5946
+ throw new Error(`${invalidCode || 'REPOSITORY_FILE_PATH_INVALID'}: 非法仓库相对路径 "${raw || String(value)}"`);
5947
+ }
5948
+ normalized.push(raw);
5949
+ }
5950
+ return [...new Set(normalized)].sort();
5951
+ }
5952
+
5953
+ function normalizeStrictCheckResult(details, result) {
5954
+ const input = details?.check_results;
5955
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
5956
+ fail('CHECK_RESULT_REQUIRED: strict check_result 缺少 details.check_results');
5957
+ }
5958
+ const total = strictStructuredInteger(
5959
+ input.total,
5960
+ 'CHECK_RESULT_TOTAL_REQUIRED',
5961
+ 'check_results.total',
5962
+ 1,
5963
+ );
5964
+ const errors = strictStructuredInteger(
5965
+ input.errors,
5966
+ 'CHECK_RESULT_ERRORS_REQUIRED',
5967
+ 'check_results.errors',
5968
+ );
5969
+ if (errors > total) {
5970
+ fail('CHECK_RESULT_ERRORS_INVALID: check_results.errors 不能大于 check_results.total');
5971
+ }
5972
+ const warnings = strictStructuredInteger(
5973
+ input.warnings,
5974
+ 'CHECK_RESULT_WARNINGS_REQUIRED',
5975
+ 'check_results.warnings',
5976
+ );
5977
+ const suggestions = strictStructuredInteger(
5978
+ input.suggestions,
5979
+ 'CHECK_RESULT_SUGGESTIONS_REQUIRED',
5980
+ 'check_results.suggestions',
5981
+ );
5982
+ const fixedBeforeApply = strictStructuredInteger(
5983
+ input.fixed_before_apply,
5984
+ 'CHECK_RESULT_FIXED_REQUIRED',
5985
+ 'check_results.fixed_before_apply',
5986
+ );
5987
+ if (fixedBeforeApply > total) {
5988
+ fail('CHECK_RESULT_FIXED_INVALID: fixed_before_apply 不能大于 check_results.total');
5989
+ }
5990
+ const categoryNames = [
5991
+ 'completeness',
5992
+ 'consistency',
5993
+ 'executability',
5994
+ 'tdd_compliance',
5995
+ ];
5996
+ const categoryInput = input.categories;
5997
+ if (!categoryInput || typeof categoryInput !== 'object' || Array.isArray(categoryInput)) {
5998
+ fail('CHECK_RESULT_CATEGORIES_REQUIRED: strict check_result 缺少 check_results.categories');
5999
+ }
6000
+ const categories = {};
6001
+ let categoryTotalSum = 0;
6002
+ let categoryErrorSum = 0;
6003
+ for (const categoryName of categoryNames) {
6004
+ const category = categoryInput[categoryName];
6005
+ if (!category || typeof category !== 'object' || Array.isArray(category)) {
6006
+ fail(`CHECK_RESULT_CATEGORY_REQUIRED: check_results.categories.${categoryName} 必须是对象`);
6007
+ }
6008
+ const categoryTotal = strictStructuredInteger(
6009
+ category.total,
6010
+ 'CHECK_RESULT_CATEGORY_TOTAL_REQUIRED',
6011
+ `check_results.categories.${categoryName}.total`,
6012
+ );
6013
+ const categoryPassed = strictStructuredInteger(
6014
+ category.passed,
6015
+ 'CHECK_RESULT_CATEGORY_PASSED_REQUIRED',
6016
+ `check_results.categories.${categoryName}.passed`,
6017
+ );
6018
+ if (categoryPassed > categoryTotal) {
6019
+ fail(`CHECK_RESULT_CATEGORY_INVALID: ${categoryName}.passed 不能大于 total`);
6020
+ }
6021
+ categories[categoryName] = {
6022
+ ...category,
6023
+ passed: categoryPassed,
6024
+ total: categoryTotal,
6025
+ };
6026
+ categoryTotalSum += categoryTotal;
6027
+ categoryErrorSum += categoryTotal - categoryPassed;
6028
+ }
6029
+ if (categoryTotalSum !== total) {
6030
+ fail('CHECK_RESULT_CATEGORY_TOTAL_MISMATCH: 四类 categories.total 合计必须等于 check_results.total');
6031
+ }
6032
+ if (categoryErrorSum !== errors) {
6033
+ fail('CHECK_RESULT_CATEGORY_ERRORS_MISMATCH: 四类未通过数合计必须等于 check_results.errors');
6034
+ }
6035
+ const taskInput = input.task_completion;
6036
+ if (!taskInput || typeof taskInput !== 'object' || Array.isArray(taskInput)) {
6037
+ fail('CHECK_RESULT_TASK_COMPLETION_REQUIRED: strict check_result 缺少 check_results.task_completion');
6038
+ }
6039
+ const taskCompleted = strictStructuredInteger(
6040
+ taskInput.completed,
6041
+ 'CHECK_RESULT_TASK_COMPLETED_REQUIRED',
6042
+ 'check_results.task_completion.completed',
6043
+ );
6044
+ const taskIncomplete = strictStructuredInteger(
6045
+ taskInput.incomplete,
6046
+ 'CHECK_RESULT_TASK_INCOMPLETE_REQUIRED',
6047
+ 'check_results.task_completion.incomplete',
6048
+ );
6049
+ const taskTotal = strictStructuredInteger(
6050
+ taskInput.total,
6051
+ 'CHECK_RESULT_TASK_TOTAL_REQUIRED',
6052
+ 'check_results.task_completion.total',
6053
+ );
6054
+ if (taskCompleted + taskIncomplete !== taskTotal) {
6055
+ fail('CHECK_RESULT_TASK_COUNTS_INVALID: completed + incomplete 必须等于 task_completion.total');
6056
+ }
6057
+ if (typeof taskInput.has_incomplete !== 'boolean'
6058
+ || taskInput.has_incomplete !== (taskIncomplete > 0)) {
6059
+ fail('CHECK_RESULT_TASK_FLAG_INVALID: has_incomplete 必须与 incomplete 是否大于 0 一致');
6060
+ }
6061
+ if (typeof taskInput.checked_for_archive_readiness !== 'boolean') {
6062
+ fail('CHECK_RESULT_ARCHIVE_READINESS_REQUIRED: checked_for_archive_readiness 必须是布尔值');
6063
+ }
6064
+ const normalizedResult = requireStrictString(
6065
+ result,
6066
+ 'CHECK_RESULT_STATUS_REQUIRED',
6067
+ 'result',
6068
+ );
6069
+ if (!['success', 'partial', 'failure'].includes(normalizedResult)) {
6070
+ fail(`CHECK_RESULT_STATUS_INVALID: 非法 check_result.result "${normalizedResult}"`);
6071
+ }
6072
+ if (taskIncomplete > 0 && normalizedResult === 'success') {
6073
+ fail('CHECK_RESULT_TASK_STATUS_INVALID: 存在未完成任务时 check_result.result 不能是 success');
6074
+ }
6075
+ const warningItems = input.warning_items;
6076
+ if (taskIncomplete > 0 && (
6077
+ !Array.isArray(warningItems)
6078
+ || !warningItems.some(item => item?.category === 'task_completion')
6079
+ )) {
6080
+ fail('CHECK_RESULT_TASK_WARNING_REQUIRED: 存在未完成任务时 warning_items 必须包含 task_completion 明细');
6081
+ }
6082
+ const taskCompletion = {
6083
+ ...taskInput,
6084
+ completed: taskCompleted,
6085
+ incomplete: taskIncomplete,
6086
+ total: taskTotal,
6087
+ has_incomplete: taskInput.has_incomplete,
6088
+ checked_for_archive_readiness: taskInput.checked_for_archive_readiness,
6089
+ };
6090
+ const executionMode = requireStrictString(
6091
+ input.review_execution_mode,
6092
+ 'CHECK_REVIEW_EXECUTION_MODE_REQUIRED',
6093
+ 'review_execution_mode',
6094
+ );
6095
+ if (!['subagent', 'main-agent-fallback'].includes(executionMode)) {
6096
+ fail(`CHECK_REVIEW_EXECUTION_MODE_INVALID: 非法 review_execution_mode "${executionMode}"`);
6097
+ }
6098
+ const reviewerIndependence = requireStrictString(
6099
+ input.reviewer_independence,
6100
+ 'CHECK_REVIEW_INDEPENDENCE_REQUIRED',
6101
+ 'reviewer_independence',
6102
+ );
6103
+ if (!['independent-review', 'self-review', 'unknown'].includes(reviewerIndependence)) {
6104
+ fail(`CHECK_REVIEW_INDEPENDENCE_INVALID: 非法 reviewer_independence "${reviewerIndependence}"`);
6105
+ }
6106
+ const reviewSessionId = requireStrictString(
6107
+ input.review_session_id || input.reviewer_session_id,
6108
+ 'CHECK_REVIEW_SESSION_REQUIRED',
6109
+ 'review_session_id',
6110
+ );
6111
+ const authorSessionId = requireStrictString(
6112
+ input.author_session_id,
6113
+ 'CHECK_AUTHOR_SESSION_REQUIRED',
6114
+ 'author_session_id',
6115
+ );
6116
+ const parentSessionId = String(input.parent_session_id || '').trim();
6117
+ const fallbackReasonCode = String(input.fallback_reason_code || '').trim();
6118
+ const fallbackReason = String(input.fallback_reason || '').trim();
6119
+
6120
+ if (executionMode === 'subagent') {
6121
+ if (!parentSessionId) {
6122
+ fail('CHECK_PARENT_SESSION_REQUIRED: 子代理评审必须记录 parent_session_id');
6123
+ }
6124
+ if (!isMeaningfulCheckIdentity(input.reviewer_agent_id)
6125
+ || !isMeaningfulCheckIdentity(parentSessionId)
6126
+ || !isMeaningfulCheckIdentity(reviewSessionId)
6127
+ || !isMeaningfulCheckIdentity(authorSessionId)) {
6128
+ fail('CHECK_REVIEW_IDENTITY_INVALID: 子代理评审必须记录真实且非占位的评审者、父会话、评审会话和作者会话身份');
6129
+ }
6130
+ if (reviewerIndependence !== 'independent-review'
6131
+ || reviewSessionId === authorSessionId
6132
+ || fallbackReasonCode
6133
+ || fallbackReason) {
6134
+ fail('CHECK_REVIEW_METADATA_INVALID: 子代理评审必须具有独立评审标记、不同会话、评审者身份且不能带回退原因');
6135
+ }
6136
+ } else {
6137
+ if (!fallbackReasonCode || !fallbackReason) {
6138
+ fail('CHECK_FALLBACK_REASON_REQUIRED: 主 Agent 回退检查必须记录 fallback_reason_code 和 fallback_reason');
6139
+ }
6140
+ if (!CHECK_FALLBACK_REASON_CODES.has(fallbackReasonCode)) {
6141
+ fail(`CHECK_FALLBACK_REASON_INVALID: 非法回退原因 "${fallbackReasonCode}"`);
6142
+ }
6143
+ if (!['self-review', 'unknown'].includes(reviewerIndependence)) {
6144
+ fail('CHECK_REVIEW_METADATA_INVALID: 主 Agent 回退检查只能声明为 self-review 或 unknown');
6145
+ }
6146
+ }
6147
+
6148
+ return {
6149
+ ...details,
6150
+ check_results: {
6151
+ ...input,
6152
+ total,
6153
+ errors,
6154
+ warnings,
6155
+ suggestions,
6156
+ fixed_before_apply: fixedBeforeApply,
6157
+ categories,
6158
+ task_completion: taskCompletion,
6159
+ review_execution_mode: executionMode,
6160
+ reviewer_independence: reviewerIndependence,
6161
+ reviewer_agent_id: executionMode === 'subagent'
6162
+ ? String(input.reviewer_agent_id).trim()
6163
+ : undefined,
6164
+ parent_session_id: parentSessionId || undefined,
6165
+ review_session_id: reviewSessionId,
6166
+ author_session_id: authorSessionId,
6167
+ fallback_reason_code: fallbackReasonCode || undefined,
6168
+ fallback_reason: fallbackReason || undefined,
6169
+ },
6170
+ };
6171
+ }
6172
+
6173
+ function validateFinalOutputSnapshot(input) {
6174
+ if (!input || typeof input !== 'object' || Array.isArray(input)) {
6175
+ return {
6176
+ ok: false,
6177
+ code: 'FINAL_OUTPUT_SNAPSHOT_REQUIRED',
6178
+ message: 'strict final_output_snapshot 缺少 details.final_output_snapshot',
6179
+ };
6180
+ }
6181
+ const snapshotKind = typeof input.snapshot_kind === 'string' ? input.snapshot_kind.trim() : '';
6182
+ if (!snapshotKind) {
6183
+ return {
6184
+ ok: false,
6185
+ code: 'FINAL_OUTPUT_SNAPSHOT_KIND_REQUIRED',
6186
+ message: 'strict 事件缺少 final_output_snapshot.snapshot_kind',
6187
+ };
6188
+ }
6189
+ if (snapshotKind !== 'final') {
6190
+ return {
6191
+ ok: false,
6192
+ code: 'FINAL_OUTPUT_SNAPSHOT_KIND_INVALID',
6193
+ message: `snapshot_kind 必须是 final,收到 "${snapshotKind}"`,
6194
+ };
6195
+ }
6196
+ const vcsMode = typeof input.vcs_mode === 'string' ? input.vcs_mode.trim() : '';
6197
+ if (!vcsMode) {
6198
+ return {
6199
+ ok: false,
6200
+ code: 'FINAL_OUTPUT_VCS_MODE_REQUIRED',
6201
+ message: 'strict 事件缺少 final_output_snapshot.vcs_mode',
6202
+ };
6203
+ }
6204
+ if (!['readonly', 'no-git'].includes(vcsMode)) {
6205
+ return {
6206
+ ok: false,
6207
+ code: 'FINAL_OUTPUT_VCS_MODE_INVALID',
6208
+ message: `vcs_mode 必须是 readonly 或 no-git,收到 "${vcsMode}"`,
6209
+ };
6210
+ }
6211
+ if (!Array.isArray(input.files)) {
6212
+ return {
6213
+ ok: false,
6214
+ code: 'FINAL_OUTPUT_PATH_INVALID',
6215
+ message: 'final_output_snapshot.files 必须显式提供数组;没有交付文件时请使用空数组并填写 no_output_reason',
6216
+ };
6217
+ }
6218
+ let files;
6219
+ try {
6220
+ files = normalizeRepositoryRelativeFiles(input.files, {
6221
+ invalidCode: 'FINAL_OUTPUT_PATH_INVALID',
6222
+ fieldName: 'final_output_snapshot.files',
6223
+ });
6224
+ } catch (error) {
6225
+ return {
6226
+ ok: false,
6227
+ code: 'FINAL_OUTPUT_PATH_INVALID',
6228
+ message: String(error?.message || error).replace(/^FINAL_OUTPUT_PATH_INVALID:\s*/, ''),
6229
+ };
6230
+ }
6231
+ const noOutputReason = String(input.no_output_reason || '').trim();
6232
+ if (files.length === 0 && !noOutputReason) {
6233
+ return {
6234
+ ok: false,
6235
+ code: 'FINAL_OUTPUT_FILES_OR_REASON_REQUIRED',
6236
+ message: '最终快照必须提供 files,或说明 no_output_reason',
6237
+ };
6238
+ }
6239
+ if (files.length > 0 && noOutputReason) {
6240
+ return {
6241
+ ok: false,
6242
+ code: 'FINAL_OUTPUT_REASON_CONFLICT',
6243
+ message: '有最终交付文件时不能同时填写 no_output_reason',
6244
+ };
6245
+ }
6246
+ const filesChanged = input.files_changed;
6247
+ if (typeof filesChanged !== 'number'
6248
+ || !Number.isInteger(filesChanged)
6249
+ || filesChanged < 0) {
6250
+ return {
6251
+ ok: false,
6252
+ code: 'FINAL_OUTPUT_FILES_CHANGED_INVALID',
6253
+ message: 'final_output_snapshot.files_changed 必须是大于等于 0 的整数',
6254
+ };
6255
+ }
6256
+ if (filesChanged !== files.length) {
6257
+ return {
6258
+ ok: false,
6259
+ code: 'FINAL_OUTPUT_FILE_COUNT_MISMATCH',
6260
+ message: `files_changed=${filesChanged} 与去重后的 files 数量 ${files.length} 不一致`,
6261
+ };
6262
+ }
6263
+ const normalizeLineCount = (fieldName, code) => {
6264
+ if (vcsMode === 'no-git' && (input[fieldName] === undefined || input[fieldName] === null)) {
6265
+ return { ok: true, value: null };
6266
+ }
6267
+ const value = input[fieldName];
6268
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
6269
+ return {
6270
+ ok: false,
6271
+ code,
6272
+ message: `final_output_snapshot.${fieldName} 必须是大于等于 0 的整数`,
6273
+ };
6274
+ }
6275
+ return { ok: true, value };
6276
+ };
6277
+ const addedLines = normalizeLineCount('added_lines', 'FINAL_OUTPUT_ADDED_LINES_INVALID');
6278
+ if (!addedLines.ok) return addedLines;
6279
+ const deletedLines = normalizeLineCount('deleted_lines', 'FINAL_OUTPUT_DELETED_LINES_INVALID');
6280
+ if (!deletedLines.ok) return deletedLines;
6281
+ return {
6282
+ ok: true,
6283
+ snapshot: {
6284
+ ...input,
6285
+ snapshot_kind: snapshotKind,
6286
+ vcs_mode: vcsMode,
6287
+ files,
6288
+ files_changed: filesChanged,
6289
+ added_lines: addedLines.value,
6290
+ deleted_lines: deletedLines.value,
6291
+ no_output_reason: files.length === 0 ? noOutputReason : undefined,
6292
+ },
6293
+ };
6294
+ }
6295
+
6296
+ function normalizeStrictFinalOutputSnapshot(details) {
6297
+ const validation = validateFinalOutputSnapshot(details?.final_output_snapshot);
6298
+ if (!validation.ok) fail(`${validation.code}: ${validation.message}`);
6299
+ return {
6300
+ ...details,
6301
+ final_output_snapshot: validation.snapshot,
6302
+ };
6303
+ }
6304
+
6305
+ /**
6306
+ * Best-effort per-file byte sizes for apply delivery snapshots.
6307
+ * Looks under projectRoot first, then its parent (split specs/code workspace).
6308
+ * Never fails the record path — missing files simply omit size entries.
6309
+ */
6310
+ function attachFinalOutputFileSizes(details, projectRoot) {
6311
+ const snapshot = details?.final_output_snapshot;
6312
+ if (!snapshot || !Array.isArray(snapshot.files) || snapshot.files.length === 0) {
6313
+ return details;
6314
+ }
6315
+ const existing = (snapshot.file_sizes && typeof snapshot.file_sizes === 'object'
6316
+ && !Array.isArray(snapshot.file_sizes))
6317
+ ? { ...snapshot.file_sizes }
6318
+ : {};
6319
+ const roots = [];
6320
+ const pushRoot = (candidate) => {
6321
+ if (!candidate || typeof candidate !== 'string') return;
6322
+ const resolved = path.resolve(candidate);
6323
+ if (!roots.some((item) => item.toLowerCase?.() === resolved.toLowerCase() || item === resolved)) {
6324
+ roots.push(resolved);
6325
+ }
6326
+ };
6327
+ pushRoot(projectRoot);
6328
+ if (projectRoot) pushRoot(path.dirname(projectRoot));
6329
+
6330
+ let changed = false;
6331
+ for (const relative of snapshot.files) {
6332
+ const rel = String(relative || '').trim().replace(/\\/g, '/');
6333
+ if (!rel || Number.isInteger(existing[rel])) continue;
6334
+ for (const root of roots) {
6335
+ try {
6336
+ const absolute = path.resolve(root, ...rel.split('/'));
6337
+ const rootKey = process.platform === 'win32' ? root.toLowerCase() : root;
6338
+ const absKey = process.platform === 'win32' ? absolute.toLowerCase() : absolute;
6339
+ const prefix = rootKey.endsWith(path.sep) ? rootKey : `${rootKey}${path.sep}`;
6340
+ if (absKey !== rootKey && !absKey.startsWith(prefix)) continue;
6341
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
6342
+ const bytes = fs.statSync(absolute).size;
6343
+ if (Number.isInteger(bytes) && bytes >= 0) {
6344
+ existing[rel] = bytes;
6345
+ changed = true;
6346
+ break;
6347
+ }
6348
+ } catch {
6349
+ // try next root
6350
+ }
6351
+ }
6352
+ }
6353
+ if (!changed && Object.keys(existing).length === 0) return details;
6354
+ return {
6355
+ ...details,
6356
+ final_output_snapshot: {
6357
+ ...snapshot,
6358
+ file_sizes: existing,
6359
+ },
6360
+ };
6361
+ }
6362
+
5696
6363
  function normalizeStrictTestResult(args, details) {
5697
6364
  const input = details?.test_results;
5698
6365
  if (!input || typeof input !== 'object' || Array.isArray(input)) {
@@ -5979,6 +6646,20 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
5979
6646
  || details?.verification_reason
5980
6647
  || '',
5981
6648
  ).trim();
6649
+ const rawTaskFiles = input && Object.hasOwn(input, 'files') ? input.files : [];
6650
+ let taskFiles;
6651
+ try {
6652
+ taskFiles = normalizeRepositoryRelativeFiles(rawTaskFiles, {
6653
+ invalidCode: 'TASK_FILE_PATH_INVALID',
6654
+ fieldName: 'task_update.files',
6655
+ });
6656
+ } catch (error) {
6657
+ fail(String(error?.message || error));
6658
+ }
6659
+ const noFileChangeReason = String(input?.no_file_change_reason || '').trim();
6660
+ if (taskFiles.length === 0 && !noFileChangeReason) {
6661
+ fail('TASK_FILES_OR_REASON_REQUIRED: strict task_update 必须提供 files,或说明 no_file_change_reason');
6662
+ }
5982
6663
 
5983
6664
  let resolvedTestEventId = testEventId;
5984
6665
  let resolvedTestRunId = testRunId || undefined;
@@ -6031,6 +6712,8 @@ function normalizeStrictTaskUpdate(args, details, dataDir, change) {
6031
6712
  || (verificationNotApplicable ? verificationReason : undefined),
6032
6713
  verification_not_applicable: verificationNotApplicable,
6033
6714
  verification_not_applicable_reason: verificationNotApplicable ? verificationReason : undefined,
6715
+ files: taskFiles,
6716
+ no_file_change_reason: taskFiles.length === 0 ? noFileChangeReason : undefined,
6034
6717
  },
6035
6718
  },
6036
6719
  };
@@ -6624,6 +7307,7 @@ function cmdRecord(args) {
6624
7307
  'quality_gate_result',
6625
7308
  'conformance_review',
6626
7309
  'ai_adoption_review',
7310
+ 'final_output_snapshot',
6627
7311
  'survey_result',
6628
7312
  'baseline_record',
6629
7313
  'telemetry_warning',
@@ -6787,6 +7471,15 @@ function cmdRecord(args) {
6787
7471
  strictTaskId = taskUpdate.taskId;
6788
7472
  eventStatus = taskUpdate.status;
6789
7473
  details = taskUpdate.details;
7474
+ } else if (strict && type === 'check_result') {
7475
+ strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
7476
+ details = normalizeStrictCheckResult(details, args.result);
7477
+ } else if (strict && type === 'final_output_snapshot') {
7478
+ strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
7479
+ details = attachFinalOutputFileSizes(
7480
+ normalizeStrictFinalOutputSnapshot(details),
7481
+ projectRoot,
7482
+ );
6790
7483
  } else if (strict) {
6791
7484
  strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
6792
7485
  }
@@ -7521,6 +8214,33 @@ function cmdSemanticObserve(args) {
7521
8214
  return observer;
7522
8215
  }
7523
8216
 
8217
+ /**
8218
+ * kb-upload: 知识库上传(文件夹 / zip)
8219
+ * 委托给 skywalk-sdd/kb-upload.cjs 执行
8220
+ */
8221
+ function cmdKbUpload(args) {
8222
+ // 将 args 还原为 --key=value 格式,传递给 kb-upload.cjs
8223
+ const passThrough = [];
8224
+ for (const [key, value] of Object.entries(args)) {
8225
+ if (value === true) {
8226
+ passThrough.push(`--${key}`);
8227
+ } else if (value !== undefined && value !== null && value !== false) {
8228
+ passThrough.push(`--${key}=${value}`);
8229
+ }
8230
+ }
8231
+
8232
+ const scriptPath = path.join(__dirname, 'kb-upload.cjs');
8233
+ try {
8234
+ const output = execFileSync(process.execPath, [scriptPath, ...passThrough], {
8235
+ encoding: 'utf8',
8236
+ stdio: 'inherit',
8237
+ });
8238
+ return output;
8239
+ } catch (err) {
8240
+ process.exitCode = 1;
8241
+ }
8242
+ }
8243
+
7524
8244
  /**
7525
8245
  * log archive-docs: 真实归档 Simple/Full spec,结束 archive 阶段并生成最终报告
7526
8246
  */
@@ -7716,6 +8436,9 @@ function main() {
7716
8436
  case 'check-task':
7717
8437
  cmdCheckTask(flags);
7718
8438
  break;
8439
+ case 'kb-upload':
8440
+ cmdKbUpload(flags);
8441
+ break;
7719
8442
  default:
7720
8443
  showHelp();
7721
8444
  process.exit(1);
@@ -7734,6 +8457,8 @@ SDD Telemetry CLI - 流程度量采集工具
7734
8457
  node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
7735
8458
  node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
7736
8459
  node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
8460
+ node skywalk-sdd/log.cjs kb-upload --folder=<path> [--minimum-level=propose-spec] [--validate-only] [--project=<spec-root>]
8461
+ node skywalk-sdd/log.cjs kb-upload --package=<zip-path> [--project=<spec-root>]
7737
8462
  node skywalk-sdd/log.cjs semantic-identity --delta-state=added
7738
8463
  node skywalk-sdd/log.cjs semantic-identity --delta-state=modified --entity-id=<uuid> --predecessor-version=<uuid>
7739
8464
  node skywalk-sdd/log.cjs semantic-identity --delta-state=unchanged --entity-id=<uuid> --version-id=<uuid>
@@ -7752,6 +8477,7 @@ SDD Telemetry CLI - 流程度量采集工具
7752
8477
  tasks-status 扫描 Full/Simple 模式 tasks.md 勾选状态
7753
8478
  check-task 扫描变更目录 tasks.md 并勾选指定 task_id
7754
8479
  archive-docs 将 Simple/Full spec 变更真实移动到 openspec/changes/archive/,并可结束 archive 阶段生成报告
8480
+ kb-upload 知识库上传(文件夹自动打包 / zip 直传),支持随时入库
7755
8481
  semantic-identity 生成或复用实体/版本 ID(8 位十六进制);无需网络和中央 ID 服务
7756
8482
  semantic-scan 只读解析并校验当前 Change
7757
8483
  semantic-reconcile 全量对账文件与本地工作态本体实例
@@ -7775,6 +8501,8 @@ SDD Telemetry CLI - 流程度量采集工具
7775
8501
  node skywalk-sdd/log.cjs check-task --project=/my/project --change=user-auth --task-id=TASK-01
7776
8502
  node skywalk-sdd/log.cjs archive-docs --project=/my/project --change=user-auth --reason="变更已完成实施" --event-id=evt_archive
7777
8503
  # archive-docs 最终报告默认生成到 openspec/changes/archive/<日期>-<change>/reports/<change>-report.md;可用 --report-output=<路径> 指定自定义路径
8504
+ node skywalk-sdd/log.cjs kb-upload --folder=openspec/changes/user-auth --minimum-level=propose-spec --project=/my/project
8505
+ node skywalk-sdd/log.cjs kb-upload --package=openspec/changes/archive/2026-07-31-user-auth.zip --project=/my/project
7778
8506
  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
8507
 
7780
8508
  Apply worktree(主仓库根目录,非 log.cjs 子命令):