kld-sdd 2.6.12 → 2.6.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +20 -0
  2. package/kld-sdd-guide.html +429 -159
  3. package/lib/init.js +40 -7
  4. package/package.json +5 -2
  5. package/skywalk-sdd/index.cjs +2668 -415
  6. package/skywalk-sdd/metrics-v3.cjs +1153 -0
  7. package/skywalk-sdd/ontology/archive-package.cjs +114 -3
  8. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -80
  9. package/skywalk-sdd/ontology/working-artifacts.cjs +2 -1
  10. package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +39 -9
  11. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +87 -8
  12. package/templates/hooks/claude/hooks/sdd-prompt.cjs +3 -1
  13. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +39 -9
  14. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +87 -9
  15. package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +3 -1
  16. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +14 -13
  17. package/templates/skills/kld-sdd/opsx-apply/checklist.md +20 -20
  18. package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +12 -12
  19. package/templates/skills/kld-sdd/opsx-apply/reference.md +32 -31
  20. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +44 -6
  21. package/templates/skills/kld-sdd/opsx-archive/checklist.md +4 -1
  22. package/templates/skills/kld-sdd/opsx-check/SKILL.md +19 -11
  23. package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -6
  24. package/templates/skills/kld-sdd/opsx-design/SKILL.md +1 -1
  25. package/templates/skills/kld-sdd/opsx-design/checklist.md +1 -1
  26. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +9 -1
  27. package/templates/skills/kld-sdd/opsx-propose/reference.md +29 -3
  28. package/templates/skills/kld-sdd/opsx-rules/reference.md +3 -1
  29. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +1 -1
  30. package/templates/skills/kld-sdd/opsx-spec/checklist.md +1 -1
  31. package/templates/skills/kld-sdd/opsx-task/SKILL.md +10 -10
  32. package/templates/skills/kld-sdd/opsx-task/checklist.md +3 -3
  33. package/templates/skills/kld-sdd/opsx-task/reference.md +3 -3
  34. package/templates/skills/kld-sdd/opsx-test/SKILL.md +7 -5
  35. package/templates/skills/kld-sdd/{opsx-tdd-anti-patterns → tdd-anti-patterns}/SKILL.md +4 -4
  36. package/templates/skills/kld-sdd/{opsx-tdd-anti-patterns → tdd-anti-patterns}/reference.md +4 -4
  37. package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/SKILL.md +13 -13
  38. package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/checklist.md +5 -5
  39. package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/reference.md +12 -12
  40. package/templates/skills/kld-sdd/{opsx-tdd-metrics → tdd-metrics}/SKILL.md +3 -3
  41. package/templates/skills/kld-sdd/{opsx-tdd-metrics → tdd-metrics}/checklist.md +2 -2
  42. package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/SKILL.md +2 -2
  43. package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/parameterized-testing.md +1 -1
  44. package/templates/skills/kld-sdd/{opsx-tdd-review → tdd-review}/SKILL.md +5 -5
  45. package/templates/skills/kld-sdd/{opsx-tdd-review → tdd-review}/checklist.md +5 -5
  46. package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/SKILL.md +3 -3
  47. package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/non-tdd-modules.md +1 -1
  48. package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/refactor-checklist.md +1 -1
  49. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +19 -0
  50. package/templates/skills/kld-sdd/opsx-tdd-rules/rules/test-skeleton-telemetry.md +0 -19
  51. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/cause-effect-clarity.md +0 -0
  52. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/clean-test-data.md +0 -0
  53. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/existing-test-awareness.md +0 -0
  54. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/given-when-then.md +0 -0
  55. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/good-test-qualities.md +0 -0
  56. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/mock-boundary.md +0 -0
  57. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/naming-conventions.md +0 -0
  58. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/no-logic-in-tests.md +0 -0
  59. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/one-test-one-scenario.md +0 -0
  60. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/prefer-public-apis.md +0 -0
  61. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/test-behaviors-not-methods.md +0 -0
  62. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/argument-matching.md +0 -0
  63. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/controller-test-rules.md +0 -0
  64. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/domain-service-rules.md +0 -0
  65. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/java-test-template.md +0 -0
  66. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/json-serialization.md +0 -0
  67. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/logging-rules.md +0 -0
  68. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/post-generation/compilation-verification.md +0 -0
  69. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/post-generation/execution-verification.md +0 -0
  70. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/python/py-test-template.md +0 -0
  71. /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/typescript/ts-test-template.md +0 -0
  72. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/controller-strategy.md +0 -0
  73. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/dag-generation-rules.md +0 -0
  74. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/des-step-annotation.md +0 -0
  75. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/exception-path-coverage.md +0 -0
  76. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/green-scope-declaration.md +0 -0
  77. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/green-yagni-fence.md +0 -0
  78. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/multi-validation-split.md +0 -0
  79. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/task-type-definitions.md +0 -0
  80. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/tdd-strategy-selection.md +0 -0
  81. /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/test-execution-gate.md +0 -0
@@ -4,9 +4,11 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { generateUuidV7 } = require('./id.cjs');
7
+ const { resolveRuntimeMetadata } = require('../runtime-metadata.cjs');
7
8
 
8
- const PRODUCER_VERSION = require('../../package.json').version;
9
- const PRODUCER = `kld-sdd@${PRODUCER_VERSION}`;
9
+ const RUNTIME_METADATA = resolveRuntimeMetadata();
10
+ const PRODUCER_VERSION = RUNTIME_METADATA.version;
11
+ const PRODUCER = RUNTIME_METADATA.producer;
10
12
  const MANIFEST_SCHEMA_VERSION = 'kld-sdd-archive-manifest/v2';
11
13
  const CANONICAL_FACTS_SCHEMA_VERSION = 'kld-sdd-canonical-facts/v2';
12
14
  const CONVERSION_REPORT_SCHEMA_VERSION = 'kld-sdd-conversion-report/v1';
@@ -15,7 +17,7 @@ const MANIFEST_PATH = 'archive-manifest.json';
15
17
  const ONTOLOGY_PATH = 'archive-ontology.json';
16
18
  const CANONICAL_FACTS_PATH = 'canonical-facts.json';
17
19
  const CONVERSION_REPORT_PATH = 'conversion-report.json';
18
- const PACKAGE_SUFFIXES = new Set(['.md', '.json', '.jsonl', '.yaml', '.yml']);
20
+ const PACKAGE_SUFFIXES = new Set(['.md', '.html', '.json', '.jsonl', '.yaml', '.yml']);
19
21
 
20
22
  function sha256(content) {
21
23
  return crypto.createHash('sha256').update(content).digest('hex');
@@ -495,6 +497,77 @@ function writeArchiveZip(archiveDir, packagePath, manifest) {
495
497
  return packagePath;
496
498
  }
497
499
 
500
+ function readStoredZipEntries(packagePath) {
501
+ const bytes = fs.readFileSync(packagePath);
502
+ const entries = new Map();
503
+ let offset = 0;
504
+ while (offset + 4 <= bytes.length && bytes.readUInt32LE(offset) === 0x04034b50) {
505
+ if (offset + 30 > bytes.length) {
506
+ throw new Error('Archive Package ZIP local header 不完整');
507
+ }
508
+ const flags = bytes.readUInt16LE(offset + 6);
509
+ const method = bytes.readUInt16LE(offset + 8);
510
+ const checksum = bytes.readUInt32LE(offset + 14);
511
+ const compressedSize = bytes.readUInt32LE(offset + 18);
512
+ const uncompressedSize = bytes.readUInt32LE(offset + 22);
513
+ const nameLength = bytes.readUInt16LE(offset + 26);
514
+ const extraLength = bytes.readUInt16LE(offset + 28);
515
+ const nameStart = offset + 30;
516
+ const contentStart = nameStart + nameLength + extraLength;
517
+ const contentEnd = contentStart + compressedSize;
518
+ if ((flags & 0x0800) !== 0x0800 || method !== 0 || contentEnd > bytes.length) {
519
+ throw new Error('Archive Package ZIP 只允许完整的 UTF-8 stored entry');
520
+ }
521
+ const name = bytes.subarray(nameStart, nameStart + nameLength).toString('utf8');
522
+ const safeName = safePackagePath(name);
523
+ if (entries.has(safeName)) {
524
+ throw new Error(`Archive Package ZIP 包含重复路径: ${safeName}`);
525
+ }
526
+ const content = bytes.subarray(contentStart, contentEnd);
527
+ if (compressedSize !== uncompressedSize || crc32(content) !== checksum) {
528
+ throw new Error(`Archive Package ZIP entry 校验失败: ${safeName}`);
529
+ }
530
+ entries.set(safeName, content);
531
+ offset = contentEnd;
532
+ }
533
+ if (entries.size === 0 || offset + 4 > bytes.length || bytes.readUInt32LE(offset) !== 0x02014b50) {
534
+ throw new Error('Archive Package ZIP 缺少 central directory');
535
+ }
536
+ return entries;
537
+ }
538
+
539
+ function verifyArchivePackage(archiveDir, packagePath, manifest) {
540
+ const expectedPaths = [MANIFEST_PATH, ...(manifest.files || []).map((item) => safePackagePath(item.path))];
541
+ const expectedUnique = new Set(expectedPaths);
542
+ if (expectedUnique.size !== expectedPaths.length) {
543
+ throw new Error('Archive Package Manifest 包含重复路径');
544
+ }
545
+ const entries = readStoredZipEntries(packagePath);
546
+ const actualPaths = [...entries.keys()];
547
+ if (
548
+ actualPaths.length !== expectedPaths.length
549
+ || expectedPaths.some((entryPath) => !entries.has(entryPath))
550
+ ) {
551
+ throw new Error('Archive Package ZIP 与 Manifest 文件清单不一致');
552
+ }
553
+ const manifestBytes = fs.readFileSync(path.join(archiveDir, MANIFEST_PATH));
554
+ if (!entries.get(MANIFEST_PATH)?.equals(manifestBytes)) {
555
+ throw new Error('Archive Package ZIP 中的 Manifest 不是最终落盘版本');
556
+ }
557
+ for (const item of manifest.files || []) {
558
+ const content = entries.get(item.path);
559
+ const expectedHash = `sha256:${sha256(content)}`;
560
+ if (item.content_hash !== expectedHash) {
561
+ throw new Error(`Archive Package 内容哈希不一致: ${item.path}`);
562
+ }
563
+ }
564
+ return {
565
+ entry_count: entries.size,
566
+ package_hash: `sha256:${sha256(fs.readFileSync(packagePath))}`,
567
+ paths: actualPaths,
568
+ };
569
+ }
570
+
498
571
  function materializeArchivePackage(options = {}) {
499
572
  const projectRoot = path.resolve(options.projectRoot || process.cwd());
500
573
  const archiveDir = path.resolve(requiredText(options.archiveDir, 'archiveDir', 'Archive Package'));
@@ -588,6 +661,41 @@ function materializeArchivePackage(options = {}) {
588
661
  };
589
662
  }
590
663
 
664
+ function finalizeArchivePackageAfterReports(options = {}) {
665
+ const projectRoot = path.resolve(options.projectRoot || process.cwd());
666
+ const changeName = requiredText(options.changeName, 'changeName', 'Archive finalizer');
667
+ const runIdentity = requiredText(options.runIdentity, 'runIdentity', 'Archive finalizer');
668
+ const { withChangeLock } = require('./change-lock.cjs');
669
+ return withChangeLock(projectRoot, changeName, options.lockOptions || {}, (lock) => {
670
+ const prepared = typeof options.prepare === 'function'
671
+ ? options.prepare(lock)
672
+ : {};
673
+ const produced = materializeArchivePackage({
674
+ ...options,
675
+ ...prepared,
676
+ projectRoot,
677
+ changeName,
678
+ });
679
+ if (!produced.package_path) {
680
+ throw new Error('Archive finalizer 必须生成 ZIP');
681
+ }
682
+ const verification = verifyArchivePackage(
683
+ path.resolve(prepared.archiveDir || options.archiveDir),
684
+ produced.package_path,
685
+ produced.manifest,
686
+ );
687
+ return {
688
+ ...produced,
689
+ finalizer: {
690
+ run_identity: runIdentity,
691
+ lock_token: lock.token,
692
+ verified: true,
693
+ ...verification,
694
+ },
695
+ };
696
+ });
697
+ }
698
+
591
699
  module.exports = {
592
700
  PRODUCER_VERSION,
593
701
  PRODUCER,
@@ -605,5 +713,8 @@ module.exports = {
605
713
  buildCanonicalFacts,
606
714
  collectPackageFilePaths,
607
715
  writeArchiveZip,
716
+ readStoredZipEntries,
717
+ verifyArchivePackage,
608
718
  materializeArchivePackage,
719
+ finalizeArchivePackageAfterReports,
609
720
  };
@@ -5,98 +5,38 @@ const path = require('path');
5
5
  const sddConfig = require('./sdd-config.cjs');
6
6
  const workspaceLayout = require('./workspace-layout.cjs');
7
7
 
8
- function isSpecPackageRoot(dir) {
9
- const root = path.resolve(dir);
10
- return (
11
- fs.existsSync(path.join(root, 'openspec', 'changes'))
12
- || fs.existsSync(path.join(root, 'modules.yaml'))
13
- || fs.existsSync(path.join(root, 'sdd.config.yaml'))
14
- );
8
+ function resolveHint(anchorRoot) {
9
+ const hintPath = path.join(anchorRoot, '.sdd-spec-root');
10
+ if (!fs.existsSync(hintPath)) return null;
11
+ const hint = fs.readFileSync(hintPath, 'utf8').trim();
12
+ if (!hint) return null;
13
+ const resolved = path.resolve(anchorRoot, hint);
14
+ return fs.existsSync(resolved) ? resolved : null;
15
15
  }
16
16
 
17
- /**
18
- * 解析 SDD 文档根(*-sdd-specs 包裹包或旧平铺 spec 根)。
19
- * openspec CLI 只认 cwd 下的 openspec/;本函数供 log.cjs / ontology cli 自动定位包裹包。
20
- *
21
- * 解析顺序:
22
- * 1. start 本身已是 spec 包根
23
- * 2. KLD_SDD_SPEC_ROOT(绝对或相对 start)
24
- * 3. 单仓 layout: mono → spec_path / 子目录 *-sdd-specs
25
- * 4. 工作区 layout → specRepo
26
- * 5. start 下嵌套包裹包
27
- * 6. 代码仓 git config sdd.specPath(含仓内 nested 包裹)
28
- * 7. 向上最多 3 层找 mono / 嵌套包(Git 根、工作区根常见)
29
- * 8. 回退 start
30
- */
31
- function resolveSpecProjectRoot(startDir = process.cwd(), options = {}) {
32
- const start = path.resolve(startDir || process.cwd());
33
- if (options.skipResolve || options.forCodeRepo) return start;
34
- if (isSpecPackageRoot(start)) return start;
17
+ function resolveSpecProjectRoot(projectRoot = process.cwd()) {
18
+ const root = path.resolve(projectRoot);
19
+ const hinted = resolveHint(root);
20
+ if (hinted) return hinted;
35
21
 
36
- const envRoot = process.env.KLD_SDD_SPEC_ROOT && String(process.env.KLD_SDD_SPEC_ROOT).trim();
37
- if (envRoot) {
38
- const candidate = path.isAbsolute(envRoot) ? envRoot : path.resolve(start, envRoot);
39
- if (fs.existsSync(candidate)) return candidate;
22
+ if (sddConfig.isMonoLayout(root)) {
23
+ return sddConfig.resolveMonoSpecRoot(root);
40
24
  }
41
25
 
42
- if (sddConfig.isMonoLayout(start)) {
43
- const monoRoot = sddConfig.resolveMonoSpecRoot(start);
44
- if (fs.existsSync(monoRoot)) return monoRoot;
45
- }
46
-
47
- const layout = workspaceLayout.detectWorkspaceLayout(start);
48
- if (layout.specRepo && fs.existsSync(layout.specRepo.abs)) {
49
- return layout.specRepo.abs;
50
- }
26
+ const layout = workspaceLayout.detectWorkspaceLayout(root);
27
+ if (layout.specRepo) return layout.specRepo.abs;
51
28
 
52
- const nested = workspaceLayout.findNestedSpecPackage(start);
29
+ const nested = workspaceLayout.findNestedSpecPackage(root);
53
30
  if (nested) return nested.abs;
54
31
 
55
- const specPathResult = sddConfig.resolveSpecPath(start);
56
- if (specPathResult.ok) {
57
- const valid = sddConfig.validateSpecPath(specPathResult.path, { allowNestedPackage: true });
58
- if (valid.ok) return valid.path;
59
- }
60
-
61
- let dir = start;
62
- for (let depth = 0; depth < 3; depth += 1) {
63
- const parent = path.dirname(dir);
64
- if (parent === dir) break;
65
- if (sddConfig.isMonoLayout(parent)) {
66
- const monoRoot = sddConfig.resolveMonoSpecRoot(parent);
67
- if (fs.existsSync(monoRoot)) return monoRoot;
68
- }
69
- const parentNested = workspaceLayout.findNestedSpecPackage(parent);
70
- if (parentNested) return parentNested.abs;
71
- const parentLayout = workspaceLayout.detectWorkspaceLayout(parent);
72
- if (parentLayout.specRepo && fs.existsSync(parentLayout.specRepo.abs)) {
73
- return parentLayout.specRepo.abs;
74
- }
75
- dir = parent;
76
- }
77
-
78
- return start;
32
+ return root;
79
33
  }
80
34
 
81
- /**
82
- * CLI / skills 用:解析 --project 参数。
83
- * '.' 或未显式绝对 spec 根时走 resolve;link-spec 等代码仓命令传 forCodeRepo:true。
84
- */
85
- function resolveProjectArg(projectArg, startDir = process.cwd(), options = {}) {
86
- const raw = projectArg == null || projectArg === '' ? '.' : String(projectArg);
87
- const resolved = path.resolve(startDir, raw);
88
- if (options.forCodeRepo) return resolved;
89
- if (raw !== '.' && raw !== './' && path.isAbsolute(raw)) {
90
- return isSpecPackageRoot(resolved) ? resolved : resolveSpecProjectRoot(resolved, options);
91
- }
92
- if (raw !== '.' && raw !== './') {
93
- if (isSpecPackageRoot(resolved)) return resolved;
94
- }
95
- return resolveSpecProjectRoot(startDir, options);
35
+ function resolveProjectArg(projectArg = '.', cwd = process.cwd()) {
36
+ return resolveSpecProjectRoot(path.resolve(cwd, projectArg));
96
37
  }
97
38
 
98
39
  module.exports = {
99
- isSpecPackageRoot,
100
- resolveSpecProjectRoot,
101
40
  resolveProjectArg,
41
+ resolveSpecProjectRoot,
102
42
  };
@@ -2,6 +2,7 @@
2
2
 
3
3
  const crypto = require('crypto');
4
4
  const path = require('path');
5
+ const { resolveRuntimeMetadata } = require('../runtime-metadata.cjs');
5
6
 
6
7
  const WORKING_ARTIFACT_SCHEMA_VERSION = 'kld-sdd-working-artifact-facts/v1';
7
8
  const ARTIFACT_INDEX_SCHEMA_VERSION = 'kld-sdd-working-artifact-index/v1';
@@ -141,7 +142,7 @@ function compareFacts(left, right) {
141
142
 
142
143
  function buildWorkingArtifactFacts(result, options = {}) {
143
144
  const state = result.state || {};
144
- const producerVersion = String(options.producerVersion || require('../../package.json').version);
145
+ const producerVersion = String(options.producerVersion || resolveRuntimeMetadata().version);
145
146
  const reviewStatus = state.review_status === 'pending' ? 'pending' : 'draft';
146
147
  const rawEntities = Array.isArray(state.entities) ? state.entities : [];
147
148
  const entityByAnchor = new Map(rawEntities.map((entity) => [
@@ -144,6 +144,26 @@ function isRealTestDetails(testResults) {
144
144
  return false;
145
145
  }
146
146
 
147
+ function isPassingTestDetails(testResults) {
148
+ if (!isRealTestDetails(testResults)) return false;
149
+ const failed = Number(testResults.failed);
150
+ const exitCode = Number(testResults.exit_code);
151
+ return (!Number.isFinite(failed) || failed === 0)
152
+ && (!Number.isFinite(exitCode) || exitCode === 0);
153
+ }
154
+
155
+ function isStrictTestEvidence(event, changeName) {
156
+ const testResults = event?.details?.test_results;
157
+ if (event?.type !== 'test_result' || event.change !== changeName) return false;
158
+ if (!event.run_id || !event.source || testResults?.evidence_tier !== 'strict') return false;
159
+ if (!['green', 'refactor', 'regression'].includes(testResults.tdd_phase)) return false;
160
+ if (!Number.isInteger(testResults.exit_code) || testResults.counts_known !== true) return false;
161
+ return event.result === 'success'
162
+ && testResults.exit_code === 0
163
+ && Number(testResults.failed) === 0
164
+ && isPassingTestDetails(testResults);
165
+ }
166
+
147
167
  function loadChangeEvents(projectRoot, changeName) {
148
168
  const safe = safeChangeName(changeName);
149
169
  const dir = path.join(projectRoot, 'skywalk-sdd', 'events', safe);
@@ -170,33 +190,36 @@ function loadChangeEvents(projectRoot, changeName) {
170
190
  function hasRealTestExecution(projectRoot, changeName, capability, sinceTimestamp) {
171
191
  const since = sinceTimestamp ? new Date(sinceTimestamp).getTime() : 0;
172
192
  const events = loadChangeEvents(projectRoot, changeName);
193
+ let legacyEvidence = null;
173
194
 
174
195
  for (const event of events) {
175
196
  const ts = new Date(event.timestamp || 0).getTime();
176
197
  if (since && ts < since) continue;
177
198
 
178
- if (event.type === 'stage_end' && event.command === 'test' &&
179
- (event.result === 'success' || event.result === 'failure')) {
180
- return { kind: 'opsx-test', event };
199
+ if (event.type === 'stage_end' && event.command === 'test' && event.result === 'success') {
200
+ legacyEvidence ||= { kind: 'legacy-opsx-test', event };
181
201
  }
182
202
 
183
203
  if (event.type === 'test_result') {
184
204
  const tr = event.details && event.details.test_results;
185
- if (isRealTestDetails(tr)) {
186
- return { kind: 'test_result', event };
205
+ if (isStrictTestEvidence(event, changeName)) {
206
+ return { kind: 'strict-test-result', event };
207
+ }
208
+ if (event.result === 'success' && isPassingTestDetails(tr)) {
209
+ legacyEvidence ||= { kind: 'legacy-test-result', event };
187
210
  }
188
211
  }
189
212
 
190
213
  if (event.type === 'task_update' && (event.command === 'apply' || !event.command)) {
191
214
  const tr = event.details && event.details.test_results;
192
- if (isRealTestDetails(tr)) {
215
+ if (event.result !== 'failure' && isPassingTestDetails(tr)) {
193
216
  if (!capability || !event.capability || event.capability === capability) {
194
- return { kind: 'task_update', event };
217
+ legacyEvidence ||= { kind: 'legacy-task-update', event };
195
218
  }
196
219
  }
197
220
  }
198
221
  }
199
- return null;
222
+ return legacyEvidence;
200
223
  }
201
224
 
202
225
  function isApplyCompletionBash(command) {
@@ -281,9 +304,10 @@ function shouldGate(projectRoot, applyEvent) {
281
304
  applyEvent.capability,
282
305
  applyEvent.timestamp
283
306
  );
284
- if (evidence) {
307
+ if (evidence?.kind === 'strict-test-result') {
285
308
  return null;
286
309
  }
310
+ if (evidence) return { strategy, changeName: applyEvent.change, capability: applyEvent.capability, legacyEvidence: evidence };
287
311
  return { strategy, changeName: applyEvent.change, capability: applyEvent.capability };
288
312
  }
289
313
 
@@ -441,6 +465,11 @@ if (require.main === module) {
441
465
  if (!gate) {
442
466
  process.exit(0);
443
467
  }
468
+ if (gate.legacyEvidence) {
469
+ recordWarning(projectRoot, { ...activeApply, test_strategy: gate.strategy }, 'legacy_test_evidence',
470
+ '仅检测到旧版测试证据;本次兼容放行,请补录包含 run-id、exit code、TDD phase 和严格身份字段的 test_result');
471
+ process.exit(0);
472
+ }
444
473
 
445
474
  const reason = buildReason(gate.strategy, gate.changeName, gate.capability);
446
475
  recordWarning(projectRoot, { ...activeApply, test_strategy: gate.strategy }, 'apply_test_missing', 'Apply test gate blocked: no unit test evidence');
@@ -458,5 +487,6 @@ module.exports = {
458
487
  detectMissingTddRed,
459
488
  hasExistingWarning,
460
489
  isRealTestDetails,
490
+ isStrictTestEvidence,
461
491
  requiresUnitTests,
462
492
  };
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const crypto = require('crypto');
4
5
  const { execFileSync } = require('child_process');
5
6
 
6
7
  function readStdin() {
@@ -75,13 +76,48 @@ function latestActiveStage(projectRoot) {
75
76
  // v2.5 契约变更:返回类型从 string 变为对象(含 exit_code_known 标注);旧调用方 `inferResult(input) === 'success'` 需改为 `.result === 'success'`
76
77
  function inferResult(input) {
77
78
  const response = input.tool_response || input.toolResponse || {};
78
- if (typeof response.exit_code === 'number') return { result: response.exit_code === 0 ? 'success' : 'failure', exit_code_known: true };
79
- if (typeof response.exitCode === 'number') return { result: response.exitCode === 0 ? 'success' : 'failure', exit_code_known: true };
80
- if (typeof response.success === 'boolean') return { result: response.success ? 'success' : 'failure', exit_code_known: true };
79
+ if (typeof response.exit_code === 'number') return { result: response.exit_code === 0 ? 'success' : 'failure', exit_code_known: true, exit_code: response.exit_code };
80
+ if (typeof response.exitCode === 'number') return { result: response.exitCode === 0 ? 'success' : 'failure', exit_code_known: true, exit_code: response.exitCode };
81
+ if (typeof response.success === 'boolean') return { result: response.success ? 'success' : 'failure', exit_code_known: true, exit_code: response.success ? 0 : 1 };
81
82
  // fallback:从 stdout/stderr 识别构建失败信号;无法判定时 partial 且 exit_code_known=false
82
83
  const out = String(response.stdout || response.stderr || '');
83
- if (/BUILD FAILED|error TS\d+|ERR_/i.test(out)) return { result: 'failure', exit_code_known: false };
84
- return { result: 'partial', exit_code_known: false };
84
+ if (/BUILD FAILED|error TS\d+|ERR_/i.test(out)) return { result: 'failure', exit_code_known: false, exit_code: null };
85
+ return { result: 'partial', exit_code_known: false, exit_code: null };
86
+ }
87
+
88
+ function parseTestCounts(output) {
89
+ const text = String(output || '');
90
+ const tapPass = text.match(/#\s*pass(?:ed)?\s+(\d+)/i);
91
+ const tapFail = text.match(/#\s*fail(?:ed)?\s+(\d+)/i);
92
+ const tapSkip = text.match(/#\s*skip(?:ped)?\s+(\d+)/i);
93
+ if (tapPass || tapFail) {
94
+ return {
95
+ counts_known: true,
96
+ passed: Number(tapPass?.[1] || 0),
97
+ failed: Number(tapFail?.[1] || 0),
98
+ skipped: Number(tapSkip?.[1] || 0),
99
+ };
100
+ }
101
+ const summary = text.match(/Tests?\s+.*?(\d+)\s+passed(?:.*?(\d+)\s+failed)?(?:.*?(\d+)\s+skipped)?/i);
102
+ if (summary) {
103
+ return {
104
+ counts_known: true,
105
+ passed: Number(summary[1] || 0),
106
+ failed: Number(summary[2] || 0),
107
+ skipped: Number(summary[3] || 0),
108
+ };
109
+ }
110
+ return { counts_known: false };
111
+ }
112
+
113
+ function stableRunId(input, activeStage, command) {
114
+ const explicit = input.tool_use_id || input.toolUseId || input.tool_call_id || input.toolCallId;
115
+ if (explicit) return String(explicit);
116
+ const response = input.tool_response || input.toolResponse || {};
117
+ return `hook-${crypto.createHash('sha256')
118
+ .update(JSON.stringify([activeStage.event_id || '', activeStage.session_id || '', command, response]))
119
+ .digest('hex')
120
+ .slice(0, 20)}`;
85
121
  }
86
122
 
87
123
  function inferRecord(command) {
@@ -113,6 +149,34 @@ function recordTelemetry(projectRoot, activeStage, record, result, exitCodeKnown
113
149
  const toolInput = (input && (input.tool_input || input.toolInput)) || {};
114
150
  const toolResponse = (input && (input.tool_response || input.toolResponse)) || {};
115
151
  const details = { [record.detailsKey]: { ...record.details } };
152
+ let strictTestEvent = false;
153
+ let runId = null;
154
+ if (record.type === 'test_result') {
155
+ const output = String(toolResponse.stdout || '') + '\n' + String(toolResponse.stderr || '');
156
+ const counts = parseTestCounts(output);
157
+ const exitCode = typeof toolResponse.exit_code === 'number'
158
+ ? toolResponse.exit_code
159
+ : (typeof toolResponse.exitCode === 'number'
160
+ ? toolResponse.exitCode
161
+ : (typeof toolResponse.success === 'boolean' ? (toolResponse.success ? 0 : 1) : null));
162
+ const tddPhase = toolInput.tdd_phase || input.tdd_phase || 'regression';
163
+ details.test_results = {
164
+ command: toolInput.command || record.details.command,
165
+ tdd_phase: tddPhase,
166
+ exit_code: exitCode,
167
+ counts_known: counts.counts_known,
168
+ ...(counts.counts_known ? {
169
+ passed: counts.passed,
170
+ failed: counts.failed,
171
+ skipped: counts.skipped,
172
+ } : {}),
173
+ duration_ms: Number.isFinite(toolResponse.duration_ms) ? toolResponse.duration_ms : 0,
174
+ failure_type: result === 'failure' ? (exitCode == null ? 'infrastructure' : 'assertion') : 'none',
175
+ expected_failure: tddPhase === 'red' && result === 'failure' && exitCode != null,
176
+ };
177
+ strictTestEvent = exitCode != null && Boolean(activeStage.task_id);
178
+ runId = stableRunId(input || {}, activeStage, details.test_results.command);
179
+ }
116
180
  if (record.type === 'build_result') {
117
181
  details.build_results.success = result === 'success';
118
182
  details.build_results.error_count = result === 'success' ? 0 : null;
@@ -126,7 +190,7 @@ function recordTelemetry(projectRoot, activeStage, record, result, exitCodeKnown
126
190
  logCli,
127
191
  'record',
128
192
  `--type=${record.type}`,
129
- `--command=${toolInput.command || activeStage.command || activeStage.stage || 'unknown'}`,
193
+ `--command=${activeStage.command || activeStage.stage || 'unknown'}`,
130
194
  `--project=${projectRoot}`,
131
195
  `--change=${activeStage.change || 'general'}`,
132
196
  `--agent=${activeStage.agent_type || 'claude-code'}`,
@@ -135,9 +199,16 @@ function recordTelemetry(projectRoot, activeStage, record, result, exitCodeKnown
135
199
  `--summary=Claude hook captured ${record.type}`,
136
200
  `--details-json=${JSON.stringify(details)}`,
137
201
  ];
202
+ if (strictTestEvent) {
203
+ args.push(
204
+ '--strict',
205
+ `--run-id=${runId}`,
206
+ `--session-id=${activeStage.session_id || `stage-${activeStage.event_id}`}`,
207
+ );
208
+ }
138
209
  if (activeStage.capability) args.push(`--capability=${activeStage.capability}`);
139
210
  if (activeStage.task_id) args.push(`--task-id=${activeStage.task_id}`);
140
- if (activeStage.session_id) args.push(`--session-id=${activeStage.session_id}`);
211
+ if (!strictTestEvent && activeStage.session_id) args.push(`--session-id=${activeStage.session_id}`);
141
212
  const run = runner || ((a) => execFileSync('node', a, { cwd: projectRoot, stdio: 'ignore' }));
142
213
  run(args);
143
214
  }
@@ -164,4 +235,12 @@ if (require.main === module) {
164
235
  }
165
236
  }
166
237
 
167
- module.exports = { inferResult, inferRecord, recordTelemetry, findProjectRoot, latestActiveStage };
238
+ module.exports = {
239
+ inferResult,
240
+ inferRecord,
241
+ parseTestCounts,
242
+ stableRunId,
243
+ recordTelemetry,
244
+ findProjectRoot,
245
+ latestActiveStage,
246
+ };
@@ -94,8 +94,10 @@ const activeStages = readActiveStages(projectRoot)
94
94
 
95
95
  const lines = [
96
96
  'SDD Telemetry reminder:',
97
+ '- Resolve the exact change key first; every standard OPSX start/end command must include --change=<change-key>.',
97
98
  '- Run node "$(cat .sdd-spec-root)/skywalk-sdd/log.cjs" start before the OPSX stage work begins.',
98
- '- Run node "$(cat .sdd-spec-root)/skywalk-sdd/log.cjs" end before stopping the stage.',
99
+ '- Run node "$(cat .sdd-spec-root)/skywalk-sdd/log.cjs" end with the same change, stage, session and event identity before stopping.',
100
+ '- change=general is reserved for explicitly allowed explore events and must later use scope_link for historical attribution.',
99
101
  '- Hooks are only an enhancement; OPSX skill instructions remain authoritative.',
100
102
  ];
101
103
 
@@ -74,6 +74,26 @@ function isRealTestDetails(testResults) {
74
74
  return false;
75
75
  }
76
76
 
77
+ function isPassingTestDetails(testResults) {
78
+ if (!isRealTestDetails(testResults)) return false;
79
+ const failed = Number(testResults.failed);
80
+ const exitCode = Number(testResults.exit_code);
81
+ return (!Number.isFinite(failed) || failed === 0)
82
+ && (!Number.isFinite(exitCode) || exitCode === 0);
83
+ }
84
+
85
+ function isStrictTestEvidence(event, changeName) {
86
+ const testResults = event?.details?.test_results;
87
+ if (event?.type !== 'test_result' || event.change !== changeName) return false;
88
+ if (!event.run_id || !event.source || testResults?.evidence_tier !== 'strict') return false;
89
+ if (!['green', 'refactor', 'regression'].includes(testResults.tdd_phase)) return false;
90
+ if (!Number.isInteger(testResults.exit_code) || testResults.counts_known !== true) return false;
91
+ return event.result === 'success'
92
+ && testResults.exit_code === 0
93
+ && Number(testResults.failed) === 0
94
+ && isPassingTestDetails(testResults);
95
+ }
96
+
77
97
  function loadChangeEvents(projectRoot, changeName) {
78
98
  const safe = safeChangeName(changeName);
79
99
  const dir = path.join(projectRoot, 'skywalk-sdd', 'events', safe);
@@ -100,33 +120,36 @@ function loadChangeEvents(projectRoot, changeName) {
100
120
  function hasRealTestExecution(projectRoot, changeName, capability, sinceTimestamp) {
101
121
  const since = sinceTimestamp ? new Date(sinceTimestamp).getTime() : 0;
102
122
  const events = loadChangeEvents(projectRoot, changeName);
123
+ let legacyEvidence = null;
103
124
 
104
125
  for (const event of events) {
105
126
  const ts = new Date(event.timestamp || 0).getTime();
106
127
  if (since && ts < since) continue;
107
128
 
108
- if (event.type === 'stage_end' && event.command === 'test' &&
109
- (event.result === 'success' || event.result === 'failure')) {
110
- return { kind: 'opsx-test', event };
129
+ if (event.type === 'stage_end' && event.command === 'test' && event.result === 'success') {
130
+ legacyEvidence ||= { kind: 'legacy-opsx-test', event };
111
131
  }
112
132
 
113
133
  if (event.type === 'test_result') {
114
134
  const tr = event.details && event.details.test_results;
115
- if (isRealTestDetails(tr)) {
116
- return { kind: 'test_result', event };
135
+ if (isStrictTestEvidence(event, changeName)) {
136
+ return { kind: 'strict-test-result', event };
137
+ }
138
+ if (event.result === 'success' && isPassingTestDetails(tr)) {
139
+ legacyEvidence ||= { kind: 'legacy-test-result', event };
117
140
  }
118
141
  }
119
142
 
120
143
  if (event.type === 'task_update' && (event.command === 'apply' || !event.command)) {
121
144
  const tr = event.details && event.details.test_results;
122
- if (isRealTestDetails(tr)) {
145
+ if (event.result !== 'failure' && isPassingTestDetails(tr)) {
123
146
  if (!capability || !event.capability || event.capability === capability) {
124
- return { kind: 'task_update', event };
147
+ legacyEvidence ||= { kind: 'legacy-task-update', event };
125
148
  }
126
149
  }
127
150
  }
128
151
  }
129
- return null;
152
+ return legacyEvidence;
130
153
  }
131
154
 
132
155
  function isApplyCompletionBash(command) {
@@ -210,9 +233,10 @@ function shouldGate(projectRoot, applyEvent) {
210
233
  applyEvent.capability,
211
234
  applyEvent.timestamp
212
235
  );
213
- if (evidence) {
236
+ if (evidence?.kind === 'strict-test-result') {
214
237
  return null;
215
238
  }
239
+ if (evidence) return { strategy, changeName: applyEvent.change, capability: applyEvent.capability, legacyEvidence: evidence };
216
240
  return { strategy, changeName: applyEvent.change, capability: applyEvent.capability };
217
241
  }
218
242
 
@@ -374,6 +398,11 @@ if (require.main === module) {
374
398
  if (!gate) {
375
399
  process.exit(0);
376
400
  }
401
+ if (gate.legacyEvidence) {
402
+ recordWarning(projectRoot, { ...activeApply, test_strategy: gate.strategy }, 'legacy_test_evidence',
403
+ '仅检测到旧版测试证据;本次兼容放行,请补录包含 run-id、exit code、TDD phase 和严格身份字段的 test_result');
404
+ process.exit(0);
405
+ }
377
406
 
378
407
  const reason = buildReason(gate.strategy, gate.changeName, gate.capability);
379
408
  recordWarning(projectRoot, { ...activeApply, test_strategy: gate.strategy }, 'apply_test_missing', 'Apply test gate blocked: no unit test evidence');
@@ -391,5 +420,6 @@ module.exports = {
391
420
  detectMissingTddRed,
392
421
  hasExistingWarning,
393
422
  isRealTestDetails,
423
+ isStrictTestEvidence,
394
424
  requiresUnitTests,
395
425
  };