kld-sdd 2.7.8-1 → 2.7.8-2

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.
package/USABILITY.md CHANGED
@@ -82,3 +82,23 @@ SDD 应帮助团队留下三类东西:明确的业务约定、能够证明实
82
82
  新增回归覆盖 Full/Simple 结果等价、正文增改删后的重新校验、跨 Change 身份冲突、strict profile、结束时重新解析、strict JSON 记录拒绝占位值,以及八种 Agent 的参考文件渲染。消费方需要重新安装/更新技能和 skywalk-sdd 运行时才能使用新参数;只更新技能文件会导致旧运行时缺少合并输出。
83
83
 
84
84
  验证结果(2026-09-08,本次 Check 优化):完整 `npm test` 通过,包含新增 6 组 Check 回归与全部既有用例;`npm run bench:check` 的 7 轮结论与任务计数等价断言全部通过。
85
+
86
+ ## 2026-09-10:真实执行与故障恢复
87
+
88
+ 本次承接已发布的 `2.7.8-1`,继续提交在 `codex/sdd-usability`。以下新增能力尚未重新发布 npm;原有身份、入库协议、七种 Skill 使用上报保持兼容。
89
+
90
+ - `kld-sdd log` 修正为实际打包的 `index.cjs` 入口。strict 记录失败会保留 details 文件,成功持久化后才清理。
91
+ - `test-run` 执行真实命令,保存退出码、耗时、原始 stdout/stderr 及 SHA-256。当前自动识别 Node TAP/原生测试摘要;未识别的 runner 仍保存输出,标记计数未知,不能自动完成任务。
92
+ - `test-run --completion-file` 为明确声明的任务关联实际测试事件并同步 checkbox。命令执行成功不等于业务覆盖充分,任务与测试的对应关系仍需评审核验;缺少测试、路径错误、超时不能记成有效 RED。
93
+ - `check-record` 接收实际检查项与真实评审身份,自动计算分类统计、任务缺口和结果,可通过 `--end-event-id` 一次记录并结束。按预检 profile 重新核对正文和历史身份;变化时拒绝旧评审。结束阶段复用本次刚完成的校验,避免再跑一次同样的对账。
94
+ - Archive 在启动归档阶段前运行 doctor;终态通知独立记为 `run_end`,旧 `stage_end + run_ended` 在计算时兼容归一化,避免虚增阶段或孤儿日志。通知保留,原始旧日志不改写。
95
+ - 修复进程锁发布窗口:完整锁文件原子发布,其他进程不能把尚未写完的锁当作失效锁抢走。
96
+ - 文档按实际业务决策与验收行为组织,不为填模板反复复制背景、堆空章节或按每个参数值拆一次任务。
97
+
98
+ 详细参数与示例在 [Apply 参考](templates/skills/kld-sdd/opsx-apply/reference.md) 和 [Check 指南](templates/skills/kld-sdd/opsx-check/SKILL.md)。新命令需要更新运行时和 Skills,旧 `record --strict` 仍可使用。
99
+
100
+ 验证:完整 `npm test` 通过;新增 19 个真实执行/恢复回归,涵盖测试输出篡改、零测试、超时、RED、任务关联、旧评审拒绝、单次最终复核、真实归档和六进程竞争。打包及初始化器均部署两个新模块,安装后的真实入口也有回归覆盖。预检基准七轮中位数由 294.6 ms 降至 156.3 ms,减少 46.9%;它仅测程序预检,不代表模型评审总耗时。
101
+
102
+ 本机独立后端联调确认七种 Skill 的使用事件均成功写入,断网事件保留并恢复补报,服务端统计为 8 次、覆盖 7 阶段。使用统计是独立的 usage 事件,不等于测试通过、归档完成或 Spec 已入库;当前服务端按提交次数计数,网络响应丢失后的重复提交不提供 exactly-once 保证。
103
+
104
+ 配置 Skill 与参考同步去除默认首目标、截短 UUID 和旧监控项目绑定步骤,修正 KB API 字段映射;切换查询目标不再引导改写团队项目身份,权限失败也不清空其他目标可能仍有效的密钥。
@@ -16,7 +16,7 @@ const args = process.argv.slice(2);
16
16
 
17
17
  if (args[0] === 'log') {
18
18
  // Telemetry CLI 模式
19
- require('../skywalk-sdd/index.js').main();
19
+ require('../skywalk-sdd/index.cjs').main();
20
20
  } else if (args[0] === 'link-spec') {
21
21
  const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
22
22
  const pathArg = args.find((item) => item.startsWith('--path='));
package/lib/init.js CHANGED
@@ -1018,6 +1018,13 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
1018
1018
  if (!fs.existsSync(sharedLibDir)) {
1019
1019
  fs.mkdirSync(sharedLibDir, { recursive: true });
1020
1020
  }
1021
+ for (const name of ['test-execution.cjs', 'check-review.cjs']) {
1022
+ const src = path.join(pkgPath, 'skywalk-sdd', 'lib', name);
1023
+ if (!fs.existsSync(src)) throw new Error(`E_INIT_MISSING_CRITICAL: lib/${name} 源文件缺失`);
1024
+ fs.copyFileSync(src, path.join(sharedLibDir, name));
1025
+ deployedModules += 1;
1026
+ vlog(` ✓ 部署 skywalk-sdd/lib/${name}`);
1027
+ }
1021
1028
  const sharedSrc = path.join(pkgPath, 'skywalk-sdd', 'lib', 'shared.cjs');
1022
1029
  const sharedDst = path.join(sharedLibDir, 'shared.cjs');
1023
1030
  if (fs.existsSync(sharedSrc)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kld-sdd",
3
- "version": "2.7.8-1",
3
+ "version": "2.7.8-2",
4
4
  "description": "KLD SDD OpenSpec 项目初始化工具 - 一键部署 SDD skills",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "bench:check": "node test/benchmark-check.cjs",
12
- "test": "node --test test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs skywalk-sdd/usage-reporter.test.cjs skywalk-sdd/usage-reporting.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
12
+ "test": "node --test test/execution-friction.test.cjs test/kb-fact-lifecycle.test.cjs test/workflow-usability.test.cjs test/check-efficiency.test.cjs && node test/external-key.cjs && node test/change-key.cjs && node test/modules-and-sdd-config.cjs && node test/active-changes.cjs && node test/hook-layouts.cjs && node test/spec-package-init.cjs && node test/prompt-spec-path.cjs && node test/workspace-layout.cjs && node test/ontology-release-blockers.cjs && node test/ontology-semantic-core.cjs && node test/ontology-identity-versioning.cjs && node test/ontology-identity-continuity.cjs && node test/ontology-state-transaction.cjs && node test/ontology-process-concurrency.cjs && node test/ontology-observer-convergence.cjs && node test/ontology-working-runtime.cjs && node test/ontology-stage-materialization.cjs && node test/ontology-template-contract.cjs && node test/ontology-cli-archive.cjs && node test/archive-package-producer.cjs && node --test test/evidence-integrity-oracle.cjs test/evidence-integrity-wiring.cjs test/change-report-correctness.cjs test/report-metrics-correctness.cjs test/change-report-model-ui.cjs && node test/validate-skills-bundle.cjs && node test/tool-profiles.cjs && node test/settings-merge.cjs && node test/command-bridge.cjs && node test/codebuddy-hooks.cjs && node test/skill-content-contract.cjs && node test/init-agent-profiles.cjs && node test/init-report-runtime.cjs && node test/init-output-verbosity.cjs && node --test test/usage-contract.test.cjs test/user-config.test.cjs test/usage-runtime-removal.test.cjs skywalk-sdd/usage-reporter.test.cjs skywalk-sdd/usage-reporting.test.cjs && node test/progress-package-artifact.test.cjs && node --test test/progress-package-install.e2e.cjs && node test/consistency-check-core.cjs"
13
13
  },
14
14
  "keywords": [
15
15
  "kld",
@@ -1313,28 +1313,32 @@ function acquireEventWriteLock(dataDir, changeName, options = {}) {
1313
1313
  created_at: nowISO(),
1314
1314
  change: safeChangeName(changeName),
1315
1315
  };
1316
- let descriptor;
1316
+ const candidatePath = `${lockPath}.${token}.tmp`;
1317
1317
  try {
1318
- descriptor = fs.openSync(lockPath, 'wx');
1319
- fs.writeFileSync(descriptor, JSON.stringify(payload), 'utf8');
1320
- fs.closeSync(descriptor);
1318
+ fs.writeFileSync(candidatePath, JSON.stringify(payload), { encoding: 'utf8', flag: 'wx', mode: 0o600 });
1319
+ // Publish a complete payload atomically. A contender must never observe an
1320
+ // empty lock and remove it while its owner is still initializing it.
1321
+ fs.linkSync(candidatePath, lockPath);
1321
1322
  return { ...payload, path: lockPath };
1322
1323
  } catch (error) {
1323
- if (descriptor !== undefined) fs.closeSync(descriptor);
1324
1324
  if (error?.code !== 'EEXIST') throw error;
1325
1325
  let existing = null;
1326
1326
  try {
1327
1327
  existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
1328
1328
  } catch {}
1329
1329
  const createdAt = Date.parse(existing?.created_at || '');
1330
- const stale = !Number.isFinite(createdAt)
1331
- || Date.now() - createdAt > staleMs
1332
- || !isProcessAlive(Number(existing?.pid));
1330
+ let age = 0;
1331
+ try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch {}
1332
+ const stale = existing && Number.isFinite(createdAt)
1333
+ ? !isProcessAlive(Number(existing.pid))
1334
+ : age > staleMs;
1333
1335
  if (stale) {
1334
1336
  try { fs.rmSync(lockPath, { force: true }); } catch {}
1335
1337
  continue;
1336
1338
  }
1337
1339
  Atomics.wait(EVENT_LOCK_WAIT_BUFFER, 0, 0, 10);
1340
+ } finally {
1341
+ try { fs.unlinkSync(candidatePath); } catch {}
1338
1342
  }
1339
1343
  }
1340
1344
  const error = new Error(`EVENT_WRITE_LOCK_TIMEOUT: 等待 change "${changeName}" 的事件写锁超时`);
@@ -1539,7 +1543,15 @@ function readAllEvents(dataDir) {
1539
1543
  /**
1540
1544
  * 计算单个 change 的四维指标
1541
1545
  */
1546
+ function normalizeLifecycleEvents(events) {
1547
+ // Older clients stored run notifications as stage_end with a different event ID.
1548
+ // Preserve the event and its wire kind without counting it as another stage.
1549
+ return events.map(event => event.type === 'stage_end' && event.event_kind === 'run_ended'
1550
+ ? { ...event, type: 'run_end' } : event);
1551
+ }
1552
+
1542
1553
  function computeChangeMetrics(changeName, events) {
1554
+ events = normalizeLifecycleEvents(events);
1543
1555
  const changeEvents = events.filter(e => e.change === changeName && !e.orphan);
1544
1556
  if (changeEvents.length === 0) return null;
1545
1557
 
@@ -1868,6 +1880,7 @@ function createReworkBucket(command, capability) {
1868
1880
  }
1869
1881
 
1870
1882
  function summarizeStageExecutions(events) {
1883
+ events = normalizeLifecycleEvents(events);
1871
1884
  const stageEvents = events.filter(e => !e.orphan && (e.type === 'stage_start' || e.type === 'stage_end'));
1872
1885
  const starts = stageEvents
1873
1886
  .filter(e => e.type === 'stage_start')
@@ -3329,6 +3342,7 @@ function computeTelemetryHealthMetrics(events, options = {}) {
3329
3342
  }
3330
3343
 
3331
3344
  function computePdfMvpMetrics(events, options = {}) {
3345
+ events = normalizeLifecycleEvents(events);
3332
3346
  if (options.level === 'capability' || options.capability) {
3333
3347
  return computeCapabilityMetrics(options.capability, events, options);
3334
3348
  }
@@ -4856,6 +4870,7 @@ function tddPairFinding(pair) {
4856
4870
  }
4857
4871
 
4858
4872
  function buildReport(projectRoot, events, options = {}) {
4873
+ events = normalizeLifecycleEvents(events);
4859
4874
  const scopedReport = scopeEventsForChangeReport(events, options.change);
4860
4875
  events = scopedReport.events;
4861
4876
  const archiveEvent = latestByTimestamp(events.filter(e => {
@@ -5208,6 +5223,7 @@ function filterEventsByDate(events, dateFrom, dateTo) {
5208
5223
  }
5209
5224
 
5210
5225
  function computeDoctorReport(events, options = {}) {
5226
+ events = normalizeLifecycleEvents(events);
5211
5227
  const scopedEvents = options.change
5212
5228
  ? events.filter(e => e.change === options.change)
5213
5229
  : events;
@@ -5418,12 +5434,12 @@ function cmdStart(args) {
5418
5434
 
5419
5435
  const ONTOLOGY_AUTHORING_STAGES = new Set(['propose', 'spec', 'design', 'task', 'check']);
5420
5436
 
5421
- function syncWorkingOntologyForStage(projectRoot, changeName, command, result) {
5437
+ function syncWorkingOntologyForStage(projectRoot, changeName, command, result, verifiedReconciliation = null) {
5422
5438
  if (!ONTOLOGY_AUTHORING_STAGES.has(command) || result === 'failure') return null;
5423
5439
  if (!changeName || changeName === 'general') return null;
5424
5440
  const changeDir = getChangeDir(projectRoot, changeName);
5425
5441
  if (!fs.existsSync(changeDir)) return null;
5426
- const reconciled = require('./ontology/runtime.cjs').reconcileChange(
5442
+ const reconciled = verifiedReconciliation || require('./ontology/runtime.cjs').reconcileChange(
5427
5443
  projectRoot,
5428
5444
  changeName,
5429
5445
  {
@@ -5592,7 +5608,8 @@ function cmdEnd(args, options = {}) {
5592
5608
 
5593
5609
  let semanticState = null;
5594
5610
  try {
5595
- semanticState = syncWorkingOntologyForStage(projectRoot, change, command, result);
5611
+ semanticState = syncWorkingOntologyForStage(projectRoot, change, command, result,
5612
+ command === 'check' ? options.verifiedReconciliation : null);
5596
5613
  if (semanticState) {
5597
5614
  details = {
5598
5615
  ...details,
@@ -5779,7 +5796,7 @@ function cmdEnd(args, options = {}) {
5779
5796
  appendEvent(dataDir, event.change, cleanOptionalFields({
5780
5797
  schema_version: SCHEMA_VERSION,
5781
5798
  event_id: generateEventId(),
5782
- type: 'stage_end',
5799
+ type: 'run_end',
5783
5800
  event_kind: 'run_ended',
5784
5801
  source: event.source,
5785
5802
  command: 'archive',
@@ -6434,8 +6451,10 @@ function normalizeStrictTestResult(args, details) {
6434
6451
  failure_type: failureType,
6435
6452
  expected_failure: expectedFailure,
6436
6453
  evidence_tier: 'strict',
6454
+ ...(input.execution ? { execution: input.execution } : {}),
6437
6455
  valid_red: tddPhase === 'red'
6438
6456
  ? expectedFailure && exitCode !== 0 && ['assertion', 'contract'].includes(failureType)
6457
+ && (!input.execution || (input.counts_known && Number(input.failed) > 0))
6439
6458
  : false,
6440
6459
  };
6441
6460
  if (input.counts_known) {
@@ -6453,6 +6472,7 @@ function isValidCompletionTestResults(testResults) {
6453
6472
  if (!testResults || typeof testResults !== 'object') return false;
6454
6473
  if (!['green', 'refactor', 'regression'].includes(testResults.tdd_phase)) return false;
6455
6474
  if (testResults.exit_code !== 0 || testResults.failure_type !== 'none') return false;
6475
+ if (testResults.execution && (!testResults.counts_known || !(testResults.passed > 0))) return false;
6456
6476
  if (testResults.counts_known === true && Number(testResults.failed) !== 0) return false;
6457
6477
  return true;
6458
6478
  }
@@ -7348,18 +7368,6 @@ function cmdRecord(args) {
7348
7368
  try {
7349
7369
  details = parseJsonOption(args, 'details-json', 'details-file', projectRoot, {});
7350
7370
  detailsFilePath = args['details-file'] || args.details_file || null;
7351
- // 默认清理 details-file,避免项目根目录被临时 JSON 污染;--keep-details 保留调试用
7352
- if (detailsFilePath && !args['keep-details'] && !args.keep_details) {
7353
- try {
7354
- const fileToRemove = normalizeDetailsFilePath(detailsFilePath, projectRoot);
7355
- if (fs.existsSync(fileToRemove)) {
7356
- fs.unlinkSync(fileToRemove);
7357
- }
7358
- } catch (cleanupErr) {
7359
- // 清理失败不阻塞主流程
7360
- try { console.error(`[telemetry] details-file 清理失败(不阻塞): ${cleanupErr.message}`); } catch {}
7361
- }
7362
- }
7363
7371
  } catch (err) {
7364
7372
  fail(`details JSON 解析失败: ${err.message}`);
7365
7373
  }
@@ -7467,6 +7475,9 @@ function cmdRecord(args) {
7467
7475
  };
7468
7476
  }
7469
7477
  if (strict && type === 'test_result') {
7478
+ if (details?.test_results?.execution) {
7479
+ require('./lib/test-execution.cjs').verifyCapture(details.test_results, projectRoot);
7480
+ }
7470
7481
  const testResults = normalizeStrictTestResult(args, details);
7471
7482
  strictRunId = testResults.run_id;
7472
7483
  strictTaskId = testResults.task_id;
@@ -7560,6 +7571,7 @@ function cmdRecord(args) {
7560
7571
  const taskId = strictTaskId || details?.task_id || null;
7561
7572
  if (taskId) runCheckTaskSync(projectRoot, change, taskId);
7562
7573
  } catch (taskErr) {
7574
+ if (args['require-projection']) throw taskErr;
7563
7575
  try { console.error(`[telemetry] tasks.md 主任务状态自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
7564
7576
  }
7565
7577
  };
@@ -7636,6 +7648,85 @@ function cmdRecord(args) {
7636
7648
  } catch (error) {
7637
7649
  fail(error.message || String(error));
7638
7650
  }
7651
+ // Keep retry input until validation, persistence and task projection all succeed.
7652
+ if (detailsFilePath && !args['keep-details'] && !args.keep_details) {
7653
+ try { fs.unlinkSync(normalizeDetailsFilePath(detailsFilePath, projectRoot)); }
7654
+ catch (error) {
7655
+ if (error.code !== 'ENOENT') console.error(`[telemetry] details-file 清理失败(不阻塞): ${error.message}`);
7656
+ }
7657
+ }
7658
+ if (!args.quiet) console.log(JSON.stringify(output, null, 2));
7659
+ return output;
7660
+ }
7661
+
7662
+ function cmdTestRun(args) {
7663
+ const projectRoot = normalizeProjectRoot(args.project || process.cwd());
7664
+ requireValidChangeName(args.change);
7665
+ requireStrictString(args['session-id'], 'TEST_SESSION_REQUIRED', 'session-id');
7666
+ const completion = args['completion-file']
7667
+ ? JSON.parse(fs.readFileSync(path.resolve(args['completion-file']), 'utf8')) : [];
7668
+ const declared = String(args['task-ids'] || args['task-id'] || '').split(',').map(x => x.trim());
7669
+ if (!Array.isArray(completion) || completion.some(item => !declared.includes(item.task_id)
7670
+ || typeof item.tdd_required !== 'boolean'
7671
+ || (!Array.isArray(item.files) && !item.no_file_change_reason))) {
7672
+ throw new Error('TEST_COMPLETION_INVALID: 完成信息须关联本轮明确任务,并包含实际文件及 tdd_required');
7673
+ }
7674
+ for (const item of completion) {
7675
+ normalizeRepositoryRelativeFiles(item.files || []);
7676
+ runCheckTaskSync(projectRoot, args.change, item.task_id, true);
7677
+ }
7678
+ const captured = require('./lib/test-execution.cjs').captureTest(args, projectRoot, args.change);
7679
+ const record = cmdRecord({ ...args, quiet: true, strict: true, type: 'test_result',
7680
+ command: 'test', project: projectRoot, change: args.change, 'run-id': captured.runId,
7681
+ source: args.source || 'test-run', summary: args.summary || `执行测试:${captured.testResults.command}`,
7682
+ result: captured.testResults.result, details: undefined,
7683
+ 'details-file': captured.receipt, 'keep-details': true,
7684
+ });
7685
+ const completed = [];
7686
+ if (isValidCompletionTestResults(captured.testResults)) {
7687
+ for (const item of completion) {
7688
+ const updated = cmdRecord({ ...args, quiet: true, strict: true, type: 'task_update', command: 'apply',
7689
+ 'require-projection': true,
7690
+ project: projectRoot, 'run-id': `${captured.runId}:${item.task_id}`, 'task-id': item.task_id,
7691
+ status: 'completed', result: 'success', source: args.source || 'test-run', summary: `${item.task_id} 通过本轮测试`,
7692
+ 'details-json': JSON.stringify({ task_update: { ...item, test_event_id: record.event_id } }),
7693
+ });
7694
+ completed.push({ task_id: item.task_id, event_id: updated.event_id, test_event_id: record.event_id });
7695
+ }
7696
+ }
7697
+ const output = { ...record, test_results: captured.testResults, receipt: captured.receipt, completed_tasks: completed };
7698
+ console.log(JSON.stringify(output, null, 2));
7699
+ process.exitCode = captured.testResults.exit_code;
7700
+ return output;
7701
+ }
7702
+
7703
+ function cmdCheckRecord(args) {
7704
+ const projectRoot = normalizeProjectRoot(args.project || process.cwd());
7705
+ requireValidChangeName(args.change);
7706
+ const preflight = JSON.parse(fs.readFileSync(path.resolve(args['preflight-file']), 'utf8'));
7707
+ const review = JSON.parse(fs.readFileSync(path.resolve(args['review-file']), 'utf8'));
7708
+ const state = require('./ontology/runtime.cjs').reconcileChange(projectRoot, args.change, { profile: preflight.profile || 'auto', markPending: true });
7709
+ if (!preflight.valid || !state.valid || preflight.revision !== state.revision
7710
+ || preflight.review_inputs?.identity_history_hash !== state.state.identity_history_hash
7711
+ || JSON.stringify(preflight.review_inputs?.files) !== JSON.stringify(state.state.files)) {
7712
+ throw new Error('CHECK_REVIEW_STALE: 文档或历史身份已变化,重新预检并评审受影响内容');
7713
+ }
7714
+ const events = readEvents(getDataDir(projectRoot), args.change);
7715
+ const beforeApply = !events.some(e => (e.type === 'stage_start' && ['apply', 'test', 'archive'].includes(e.command)) || ['test_result', 'task_update', 'build_result'].includes(e.type));
7716
+ const previous = events.filter(e => e.type === 'check_result').slice(-1)[0]?.details?.check_results;
7717
+ const assembled = require('./lib/check-review.cjs').assembleReview(review, scanTaskCompletion(projectRoot, args.change), {
7718
+ beforeApply, previousBeforeApply: previous?.fixed_before_apply, revision: preflight.revision,
7719
+ });
7720
+ const record = cmdRecord({ ...args, quiet: true, strict: true, type: 'check_result', command: 'check',
7721
+ project: projectRoot, result: assembled.result, 'run-id': args['run-id'] || generateEventId(),
7722
+ source: args.source || 'opsx-command', summary: args.summary || '按实际评审项生成 Check 结果',
7723
+ 'session-id': args['session-id'] || review.reviewer?.parent_session_id || review.reviewer?.review_session_id,
7724
+ 'details-json': JSON.stringify(assembled.details),
7725
+ });
7726
+ const ended = args['end-event-id'] ? cmdEnd({ ...args, 'event-id': args['end-event-id'], command: 'check',
7727
+ project: projectRoot, result: assembled.result, summary: args.summary || 'Check 已记录,复核文档状态',
7728
+ }, { silent: true, verifiedReconciliation: state }) : null;
7729
+ const output = { ...record, result: assembled.result, check_results: assembled.details.check_results, stage_end: ended };
7639
7730
  console.log(JSON.stringify(output, null, 2));
7640
7731
  return output;
7641
7732
  }
@@ -8424,6 +8515,12 @@ function main() {
8424
8515
  case 'record':
8425
8516
  cmdRecord(flags);
8426
8517
  break;
8518
+ case 'test-run':
8519
+ cmdTestRun(flags);
8520
+ break;
8521
+ case 'check-record':
8522
+ cmdCheckRecord(flags);
8523
+ break;
8427
8524
  case 'metrics':
8428
8525
  cmdMetrics(flags);
8429
8526
  break;
@@ -8504,6 +8601,8 @@ SDD Telemetry CLI - 流程度量采集工具
8504
8601
  start 记录 SDD 阶段开始,返回 event_id
8505
8602
  end 记录 SDD 阶段结束,关联 event_id
8506
8603
  record 记录 task_update/check_result/test_result 等结构化事件
8604
+ test-run 执行 --command-json 参数数组,采集退出码、测试计数、原始输出与任务关联
8605
+ check-record 从 --review-file 与 --preflight-file 计算并记录 Check 结果
8507
8606
  metrics 查询度量指标(四维分析)
8508
8607
  report 生成只读度量报告(不写入事件)
8509
8608
  doctor 诊断 Telemetry 数据质量
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ const CATEGORIES = ['completeness', 'consistency', 'executability', 'tdd_compliance'];
4
+
5
+ function assembleReview(review, taskCompletion, options = {}) {
6
+ if (!review || !Array.isArray(review.items) || !review.items.length) throw new Error('CHECK_ITEMS_REQUIRED: 提供实际评审项,不能提交空模板');
7
+ const categories = Object.fromEntries(CATEGORIES.map(key => [key, { passed: 0, total: 0 }]));
8
+ const warnings = [], suggestions = [], ids = new Set();
9
+ for (const item of review.items) {
10
+ if (!item.id || ids.has(item.id)) throw new Error('CHECK_ITEM_ID_INVALID: 检查项必须有唯一 ID');
11
+ ids.add(item.id);
12
+ if (!CATEGORIES.includes(item.category) || !['passed', 'error', 'warning', 'suggestion', 'na'].includes(item.status)) throw new Error('CHECK_ITEM_STATUS_INVALID');
13
+ if (typeof item.evidence !== 'string' || !item.evidence.trim()) throw new Error(`CHECK_ITEM_EVIDENCE_REQUIRED: ${item.id} 缺少来源证据或不适用原因`);
14
+ if (item.status === 'na') continue;
15
+ categories[item.category].total++;
16
+ if (item.status !== 'error') categories[item.category].passed++;
17
+ if (item.status === 'warning') warnings.push({ category: item.category, warning: item.id, description: item.description || item.evidence, target: item.evidence });
18
+ if (item.status === 'suggestion') suggestions.push(item);
19
+ }
20
+ const total = Object.values(categories).reduce((n, c) => n + c.total, 0);
21
+ const passed = Object.values(categories).reduce((n, c) => n + c.passed, 0);
22
+ if (!total) throw new Error('CHECK_ITEMS_REQUIRED: 至少有一个实际适用的评审项');
23
+ if (taskCompletion.has_incomplete) warnings.push({ category: 'task_completion', warning: 'TASKS_INCOMPLETE', description: `${taskCompletion.incomplete} 个任务尚未完成`, target: 'tasks.md' });
24
+ const result = total !== passed ? 'failure' : (warnings.length ? 'partial' : 'success');
25
+ return {
26
+ result,
27
+ details: { check_results: {
28
+ ...review.reviewer,
29
+ total, errors: total - passed, warnings: warnings.length, suggestions: suggestions.length,
30
+ categories, warning_items: warnings, warning_dispositions: review.warning_dispositions || [],
31
+ // This field means items satisfied before implementation, not defect fixes.
32
+ fixed_before_apply: options.beforeApply ? passed : Math.min(total, options.previousBeforeApply || 0),
33
+ consistency_score: null,
34
+ task_completion: { ...taskCompletion, checked_for_archive_readiness: !options.beforeApply },
35
+ review_items: review.items,
36
+ review_revision: options.revision,
37
+ } },
38
+ };
39
+ }
40
+
41
+ module.exports = { assembleReview };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+ const crypto = require('node:crypto');
6
+ const { spawnSync } = require('node:child_process');
7
+
8
+ function parseCounts(output) {
9
+ // Prefer runner totals: counting TAP parent suites as tests double-counts nested tests.
10
+ const value = name => {
11
+ const matches = [...output.matchAll(new RegExp(`^(?:#|ℹ)\\s+${name}\\s+(\\d+)\\s*$`, 'gm'))];
12
+ return matches.length ? Number(matches[matches.length - 1][1]) : null;
13
+ };
14
+ const total = value('tests'), passed = value('pass'), failed = value('fail');
15
+ const skipped = value('skipped'), todo = value('todo');
16
+ if ([total, passed, failed].every(Number.isInteger)
17
+ && total === passed + failed + (skipped || 0) + (todo || 0)) {
18
+ return { counts_known: true, passed, failed, skipped: (skipped || 0) + (todo || 0) };
19
+ }
20
+ return { counts_known: false };
21
+ }
22
+
23
+ function classifyFailure(output, exitCode, error, signal) {
24
+ if (error?.code === 'ETIMEDOUT') return 'timeout';
25
+ if (error || signal) return 'infrastructure';
26
+ if (exitCode === 0) return 'none';
27
+ // A missing executable/module/test path is not evidence of a failing business assertion.
28
+ if (/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find module|Could not find|ENOENT|SyntaxError|ERR_TEST_FAILURE.*cancelled/i.test(output)) return 'infrastructure';
29
+ if (/ERR_ASSERTION|AssertionError|AssertionFailedError|org\.opentest4j\.AssertionFailedError/.test(output)) return 'assertion';
30
+ return 'infrastructure';
31
+ }
32
+
33
+ function captureTest(args, projectRoot, change) {
34
+ let command;
35
+ try { command = JSON.parse(args['command-json'] || 'null'); } catch { /* validated below */ }
36
+ if (!Array.isArray(command) || !command.length || command.some(x => typeof x !== 'string' || x.includes('\0')) || !command[0]) {
37
+ throw new Error('TEST_COMMAND_REQUIRED: --command-json 必须是非空命令参数数组,不通过 shell 执行');
38
+ }
39
+ const phase = args['tdd-phase'] || 'regression';
40
+ if (!['red', 'green', 'refactor', 'regression', 'not-applicable'].includes(phase)) throw new Error('TEST_PHASE_INVALID');
41
+ const taskIds = [...new Set(String(args['task-ids'] || args['task-id'] || '').split(',').map(x => x.trim()).filter(Boolean))];
42
+ if (!taskIds.length) throw new Error('TEST_TASKS_REQUIRED: 明确指定 --task-ids,工具不猜测测试覆盖的任务');
43
+ const timeout = Number(args['timeout-ms'] || 120000);
44
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) throw new Error('TEST_TIMEOUT_INVALID');
45
+ const cwd = path.resolve(args.cwd || projectRoot);
46
+ if (!fs.statSync(cwd).isDirectory()) throw new Error('TEST_CWD_INVALID');
47
+ const runId = args['run-id'] || crypto.randomUUID();
48
+ const key = crypto.createHash('sha256').update(`${change}:${runId}`).digest('hex');
49
+ const dir = path.join(projectRoot, 'skywalk-sdd', 'state', 'test-runs', key);
50
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
51
+ // A run ID identifies one execution. Never re-execute a command under an old receipt.
52
+ try { fs.mkdirSync(dir); } catch (error) {
53
+ if (error.code === 'EEXIST') throw new Error('TEST_RUN_EXISTS: 此 run-id 已执行;使用保存的 details.json 重试记录,重新执行须使用新 run-id');
54
+ throw error;
55
+ }
56
+ const startedAt = new Date().toISOString();
57
+ const started = process.hrtime.bigint();
58
+ const childEnv = { ...process.env };
59
+ // The wrapper can itself run inside node:test; its child is a fresh test runner.
60
+ delete childEnv.NODE_TEST_CONTEXT;
61
+ const child = spawnSync(command[0], command.slice(1), {
62
+ cwd, env: childEnv, shell: false, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
63
+ });
64
+ const duration = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
65
+ const stdout = child.stdout || '', stderr = child.stderr || '';
66
+ const output = stdout + '\n' + stderr;
67
+ const exitCode = Number.isInteger(child.status) ? child.status : (child.error?.code === 'ETIMEDOUT' ? 124 : 1);
68
+ const failureType = classifyFailure(output, exitCode, child.error, child.signal);
69
+ const counts = parseCounts(output);
70
+ const files = {};
71
+ for (const [name, content] of Object.entries({ 'stdout.log': stdout, 'stderr.log': stderr })) {
72
+ fs.writeFileSync(path.join(dir, name), content, { mode: 0o600 });
73
+ files[name] = crypto.createHash('sha256').update(content).digest('hex');
74
+ }
75
+ const execution = {
76
+ origin: 'command-execution', argv: command, cwd, started_at: startedAt,
77
+ finished_at: new Date().toISOString(), signal: child.signal || null,
78
+ error: child.error ? { code: child.error.code, message: child.error.message } : null,
79
+ output_files: Object.entries(files).map(([name, sha256]) => ({ path: path.join(dir, name), sha256 })),
80
+ task_association: 'explicit-caller-declaration',
81
+ receipt_path: path.join(dir, 'execution.json'),
82
+ };
83
+ const testResults = {
84
+ run_id: runId, verified_task_ids: taskIds, tdd_phase: phase,
85
+ command: JSON.stringify(command), result: exitCode === 0 ? 'success' : 'failure',
86
+ exit_code: exitCode, duration_ms: duration, ...counts, failure_type: failureType,
87
+ expected_failure: phase === 'red' && args['expected-failure'] === true,
88
+ execution,
89
+ };
90
+ const receipt = path.join(dir, 'details.json');
91
+ fs.writeFileSync(execution.receipt_path, JSON.stringify(testResults, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
92
+ fs.writeFileSync(receipt, JSON.stringify({ test_results: testResults }, null, 2) + '\n', { mode: 0o600 });
93
+ return { testResults, runId, receipt };
94
+ }
95
+
96
+ function verifyCapture(input, projectRoot) {
97
+ const base = fs.realpathSync(path.join(projectRoot, 'skywalk-sdd/state/test-runs')) + path.sep;
98
+ const receiptPath = fs.realpathSync(input.execution.receipt_path);
99
+ if (input.execution.origin !== 'command-execution' || !receiptPath.startsWith(base)) throw new Error('TEST_CAPTURE_INVALID: 执行回执不在当前项目');
100
+ const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
101
+ for (const key of Object.keys(receipt)) {
102
+ if (JSON.stringify(receipt[key]) !== JSON.stringify(input[key])) throw new Error(`TEST_CAPTURE_MISMATCH: ${key} 与真实执行回执不一致`);
103
+ }
104
+ for (const output of receipt.execution.output_files) {
105
+ const file = fs.realpathSync(output.path);
106
+ if (!file.startsWith(base) || crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== output.sha256) throw new Error('TEST_CAPTURE_HASH_MISMATCH: 原始测试输出已变化');
107
+ }
108
+ }
109
+
110
+ module.exports = { captureTest, parseCounts, classifyFailure, verifyCapture };
@@ -10,13 +10,24 @@ git_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
10
10
  exit 1
11
11
  }
12
12
  hooks_dir=""
13
+ # 统一反斜杠为正斜杠,保证 Windows 盘符路径(P:\a\b → P:/a/b)能被 case 判定为绝对路径
14
+ normalize_path() {
15
+ printf '%s' "$1" | tr '\\' '/'
16
+ }
17
+ # 绝对路径(/ 开头或盘符 X: 开头)直接使用;相对路径基于 git 根拼接
18
+ resolve_spec_dir() {
19
+ raw=$(normalize_path "$1")
20
+ case "$raw" in
21
+ /*|[A-Za-z]:*) printf '%s' "$raw" ;;
22
+ *) printf '%s' "$git_root/$raw" ;;
23
+ esac
24
+ }
13
25
  spec_path=$(git -C "$git_root" config --local sdd.specPath 2>/dev/null)
14
26
  if [ -n "$spec_path" ]; then
15
- case "$spec_path" in /*) ;; *) spec_path="$git_root/$spec_path" ;; esac
16
- hooks_dir="$spec_path/skywalk-sdd/git-hooks"
27
+ hooks_dir="$(resolve_spec_dir "$spec_path")/skywalk-sdd/git-hooks"
17
28
  elif [ -f "$git_root/.sdd-spec-root" ]; then
18
29
  spec_rel=$(head -1 "$git_root/.sdd-spec-root" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
19
- case "$spec_rel" in /*) hooks_dir="$spec_rel/skywalk-sdd/git-hooks" ;; *) hooks_dir="$git_root/$spec_rel/skywalk-sdd/git-hooks" ;; esac
30
+ hooks_dir="$(resolve_spec_dir "$spec_rel")/skywalk-sdd/git-hooks"
20
31
  fi
21
32
  if [ -n "$hooks_dir" ] && [ ! -d "$hooks_dir" ]; then
22
33
  echo "[SDD] 已配置的 Spec 路径失效:$hooks_dir"
@@ -27,12 +38,15 @@ fi
27
38
  # 方法3: 从 git 根向上搜索 skywalk-sdd/git-hooks(spec 仓本身或父级)
28
39
  if [ -z "$hooks_dir" ]; then
29
40
  search_dir="$git_root"
30
- while [ "$search_dir" != "/" ] && [ "$search_dir" != "" ]; do
41
+ while [ -n "$search_dir" ] && [ "$search_dir" != "/" ]; do
31
42
  if [ -d "$search_dir/skywalk-sdd/git-hooks" ]; then
32
43
  hooks_dir="$search_dir/skywalk-sdd/git-hooks"
33
44
  break
34
45
  fi
35
- search_dir="$(dirname "$search_dir")"
46
+ # Windows 盘符根(P:/)dirname 后不变,需显式收敛,避免死循环
47
+ parent_dir="$(dirname "$search_dir")"
48
+ [ "$parent_dir" = "$search_dir" ] && break
49
+ search_dir="$parent_dir"
36
50
  done
37
51
  fi
38
52