kld-sdd 2.7.8 → 2.8.0

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 (73) hide show
  1. package/README.md +26 -0
  2. package/USABILITY.md +72 -0
  3. package/bin/kld-sdd-init.js +718 -34
  4. package/lib/command-bridge.js +26 -17
  5. package/lib/deploy-codebuddy-hooks.js +19 -26
  6. package/lib/deploy-strategy.js +293 -0
  7. package/lib/hook-gate-core.js +7 -8
  8. package/lib/hook-install.js +75 -0
  9. package/lib/init.js +507 -726
  10. package/lib/maintenance.js +221 -0
  11. package/lib/managed-hook-hashes.json +24 -0
  12. package/lib/settings-merge.js +102 -25
  13. package/lib/skills-bundle.js +5 -5
  14. package/lib/spec-choice.js +85 -0
  15. package/lib/tool-profiles.js +2 -2
  16. package/lib/uninstall.js +453 -0
  17. package/package.json +7 -4
  18. package/skywalk-sdd/context-client.cjs +38 -7
  19. package/skywalk-sdd/index.cjs +153 -27
  20. package/skywalk-sdd/kb-upload.cjs +224 -82
  21. package/skywalk-sdd/lib/check-review.cjs +41 -0
  22. package/skywalk-sdd/lib/shared.cjs +62 -41
  23. package/skywalk-sdd/lib/test-execution.cjs +130 -0
  24. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  25. package/skywalk-sdd/ontology/archive-package.cjs +141 -0
  26. package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
  27. package/skywalk-sdd/ontology/cli.cjs +53 -0
  28. package/skywalk-sdd/ontology/id.cjs +1 -1
  29. package/skywalk-sdd/ontology/identity-index.cjs +5 -1
  30. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  31. package/skywalk-sdd/ontology/schema.cjs +6 -0
  32. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  33. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  34. package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
  35. package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
  36. package/templates/git-hooks/commit-msg +17 -85
  37. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  38. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  39. package/templates/git-hooks/consistency-check-core.cjs +92 -261
  40. package/templates/git-hooks/delivery-check.cjs +231 -0
  41. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  42. package/templates/git-hooks/hooks.config +8 -6
  43. package/templates/git-hooks/pre-commit +19 -5
  44. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  45. package/templates/git-hooks/pre-push +19 -5
  46. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  47. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
  48. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  49. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
  50. package/templates/openspec/proposal.md +1 -1
  51. package/templates/openspec/spec.md +8 -0
  52. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
  53. package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
  54. package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
  55. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
  56. package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
  57. package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
  58. package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
  59. package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
  60. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  61. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  62. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
  63. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
  64. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
  65. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
  66. package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
  67. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
  68. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
  69. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
  70. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
  71. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
  72. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
  73. package/index.js +0 -13
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ // Deterministic evidence checking. This command never performs or invents an AI review.
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const snap = require('./delivery-snapshot.cjs');
8
+ const DIMENSIONS = ['interfaceContract', 'businessRules', 'validationRules', 'errorCodes'];
9
+
10
+ function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
11
+ function optionalJson(file) { try { return readJson(file); } catch { return null; } }
12
+
13
+ function context(args) {
14
+ const project = fs.realpathSync(path.resolve(args.project || process.cwd()));
15
+ const specRoot = fs.realpathSync(path.resolve(args['spec-root'] || path.join(__dirname, '..', '..')));
16
+ const projectTop = fs.realpathSync(snap.git(project, ['rev-parse', '--show-toplevel']).trim());
17
+ if (projectTop !== project) throw new Error('PROJECT_ROOT_REQUIRED: --project 请指定代码仓 Git 根');
18
+ const change = args.change;
19
+ if (!change || !/^[a-z0-9][a-z0-9-]+$/.test(change)) throw new Error('CHANGE_REQUIRED: --change 必须明确指定');
20
+ const scope = args.scope || 'worktree';
21
+ const ref = args.ref || 'HEAD';
22
+ const identity = snap.repoIdentity(project);
23
+ const key = snap.hashJson({ identity, change });
24
+ const dir = path.join(specRoot, 'skywalk-sdd/state/delivery', key);
25
+ const prepared = optionalJson(path.join(dir, 'prepared.json'));
26
+ let base = args.base || prepared?.base;
27
+ if (scope === 'commit' && !args.base) throw new Error('BASE_REQUIRED: --scope=commit 必须明确提供 --base');
28
+ if (!base) {
29
+ try { base = snap.git(project, ['rev-parse', '--verify', 'HEAD']).trim(); }
30
+ catch { base = snap.git(project, ['mktree'], { input: '' }).trim(); }
31
+ }
32
+ base = snap.git(project, ['rev-parse', '--verify', '--end-of-options', `${base}^{object}`]).trim();
33
+ const baseType = snap.git(project, ['cat-file', '-t', base]).trim();
34
+ if (!['tree', 'commit'].includes(baseType)) throw new Error('BASE_INVALID');
35
+ // Freeze named refs before reading any files, including a CI target that is not HEAD.
36
+ const resolvedRef = scope === 'commit' ? snap.git(project, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim() : ref;
37
+ const relative = path.relative(project, specRoot);
38
+ let specRepository = `local:${specRoot}`, specTop = '';
39
+ try { specTop = fs.realpathSync(snap.git(specRoot, ['rev-parse', '--show-toplevel']).trim()); specRepository = snap.repoIdentity(specTop); } catch { /* uncommitted standalone Spec folder */ }
40
+ const mono = specTop === project;
41
+ if (!mono && fs.existsSync(path.join(project, '.sdd.yaml'))) {
42
+ const configModule = [path.join(__dirname, '../ontology/sdd-config.cjs'), path.join(__dirname, '../../skywalk-sdd/ontology/sdd-config.cjs')].find(fs.existsSync);
43
+ if (!configModule) throw new Error('SDD_CONFIG_RUNTIME_MISSING');
44
+ const sdd = require(configModule);
45
+ const declared = sdd.loadSddYaml(project);
46
+ if (!declared.ok) throw new Error('SDD_CONFIG_INVALID');
47
+ const actual = snap.git(specRoot, ['remote', 'get-url', 'origin']).trim();
48
+ if (!sdd.remotesMatch(declared.spec_repository, actual)) throw new Error('SPEC_REMOTE_MISMATCH');
49
+ }
50
+ let specOptions = {};
51
+ if (scope !== 'worktree') {
52
+ if (mono) specOptions = { root: project, scope, ref: resolvedRef };
53
+ else {
54
+ if (scope === 'commit' && !args['spec-ref']) throw new Error('SPEC_REF_REQUIRED: 多仓提交校验必须提供 --spec-ref 的完整提交 SHA');
55
+ const specRef = args['spec-ref'] || 'HEAD';
56
+ if (scope === 'commit' && !/^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(specRef)) throw new Error('SPEC_REF_INVALID: 需要完整 SHA');
57
+ specOptions = { root: specRoot, scope: 'commit', ref: specRef };
58
+ }
59
+ }
60
+ const code = snap.codeSnapshot(project, specRoot, scope, resolvedRef);
61
+ const spec = snap.specSnapshot(specRoot, change, specOptions);
62
+ const core = require('./consistency-check-core.cjs');
63
+ const configFile = path.join(specRoot, 'skywalk-sdd/git-hooks/hooks.config');
64
+ const config = core.loadBusinessCodeExts(configFile);
65
+ if (config.source !== 'config') throw new Error('BUSINESS_SCOPE_UNCONFIGURED: hooks.config 缺少业务文件扩展名');
66
+ const inputs = { ruleVersion: snap.RULE_VERSION, ruleConfig: snap.fileMeta('hooks.config', fs.readFileSync(configFile)).sha256, repository: identity, specRepository, change, code, spec };
67
+ const fingerprint = snap.hashJson(inputs);
68
+ const required = snap.changedFiles(project, scope, base, resolvedRef).filter(file => core.isBusinessCode(file, config.exts) && !snap.excluded(file, relative.startsWith('..') ? '' : relative.replace(/\\/g, '/')));
69
+ return { project, specRoot, change, scope, ref: resolvedRef, mono, identity, dir, base, inputs, fingerprint, required };
70
+ }
71
+
72
+ function reviewPath(ctx) {
73
+ return path.join(ctx.specRoot, 'openspec/changes', ctx.change, 'consistency-reviews', `${snap.digest(ctx.identity)}.json`);
74
+ }
75
+
76
+ function reviewErrors(ctx, review) {
77
+ if (!review) return ['REVIEW_MISSING: 缺少代码与 Spec 一致性检查回执'];
78
+ const errors = [];
79
+ if (review.inputFingerprint !== ctx.fingerprint || !review.inputs || snap.hashJson(review.inputs) !== ctx.fingerprint) errors.push('REVIEW_STALE: 代码、Spec、依赖或规则版本已变化');
80
+ const covered = new Set(Array.isArray(review.reviewedFiles) ? review.reviewedFiles : []);
81
+ const uncovered = ctx.required.filter(file => !covered.has(file));
82
+ if (uncovered.length) errors.push(`UNCOVERED_FILES: ${uncovered.join(', ')}`);
83
+ for (const name of DIMENSIONS) {
84
+ const item = review.dimensions?.[name];
85
+ if (!item || !['pass', 'na'].includes(item.status)) errors.push(`REVIEW_INCOMPLETE: ${name}=${item?.status || 'not_checked'}`);
86
+ else if (item.status === 'na' && !String(item.reason || '').trim()) errors.push(`NA_REASON_REQUIRED: ${name}`);
87
+ else if (item.status === 'pass' && (!Array.isArray(item.evidence) || !item.evidence.length || item.evidence.some(e => typeof e !== 'string' || !e.trim()))) errors.push(`EVIDENCE_REQUIRED: ${name}`);
88
+ }
89
+ if (review.issues != null && !Array.isArray(review.issues)) errors.push('REVIEW_ISSUES_INVALID');
90
+ if (Array.isArray(review.issues) && review.issues.length) errors.push('REVIEW_ISSUES: 存在未解决问题');
91
+ return errors;
92
+ }
93
+
94
+ function saveAssociation(ctx, review) {
95
+ const file = path.join(ctx.specRoot, 'skywalk-sdd/state/delivery-associations', `${snap.digest(ctx.identity)}.json`);
96
+ const previous = optionalJson(file) || { repository: ctx.identity, changes: {} };
97
+ const byPath = new Map(ctx.inputs.code.map(f => [f.path, f]));
98
+ previous.changes[ctx.change] = {
99
+ coveredFiles: review.reviewedFiles.map(file => byPath.get(file) || { path: file, deleted: true }),
100
+ };
101
+ snap.atomicJson(file, previous);
102
+ }
103
+
104
+ function prepare(args) {
105
+ const ctx = context(args);
106
+ const old = optionalJson(reviewPath(ctx));
107
+ const errors = reviewErrors(ctx, old);
108
+ const value = {
109
+ base: ctx.base, inputFingerprint: ctx.fingerprint, inputs: ctx.inputs,
110
+ requiredFiles: ctx.required, scope: ctx.scope, ref: ctx.ref,
111
+ };
112
+ const file = path.join(ctx.dir, 'prepared.json');
113
+ snap.atomicJson(file, value);
114
+ return { ...value, preparedFile: file, reviewFile: reviewPath(ctx), reuseReview: errors.length === 0, reviewErrors: errors };
115
+ }
116
+
117
+ function recordReview(args) {
118
+ const ctx = context(args);
119
+ const prepared = readJson(path.join(ctx.dir, 'prepared.json'));
120
+ const input = readJson(args.input);
121
+ if (prepared.inputFingerprint !== ctx.fingerprint || input.inputFingerprint !== ctx.fingerprint) throw new Error('REVIEW_STALE: 取证后输入已改变;重新 prepare 并复核变化');
122
+ if (!Array.isArray(input.reviewedFiles) || input.reviewedFiles.some(f => typeof f !== 'string')) throw new Error('REVIEW_FILES_REQUIRED');
123
+ const allowed = new Set([...ctx.inputs.code.map(f => f.path), ...ctx.required]);
124
+ if (input.reviewedFiles.some(f => !allowed.has(f))) throw new Error('REVIEW_FILE_OUT_OF_SCOPE');
125
+ const review = {
126
+ changeName: ctx.change, generatedAt: new Date().toISOString(),
127
+ origin: 'agent-review', inputFingerprint: ctx.fingerprint, inputs: ctx.inputs,
128
+ reviewedFiles: [...new Set(input.reviewedFiles)], dimensions: input.dimensions,
129
+ issues: input.issues || [], tests: input.tests || null, base: ctx.base,
130
+ };
131
+ // Save incomplete/failed reviews as such; never relabel them as passed.
132
+ snap.atomicJson(reviewPath(ctx), review);
133
+ const errors = reviewErrors(ctx, review);
134
+ if (!errors.length) saveAssociation(ctx, review);
135
+ return { ok: errors.length === 0, status: errors.length ? 'pending' : 'reviewed', errors, reviewFile: reviewPath(ctx) };
136
+ }
137
+
138
+ function testRuntime() {
139
+ for (const candidate of [path.join(__dirname, '../lib/test-execution.cjs'), path.join(__dirname, '../../skywalk-sdd/lib/test-execution.cjs')]) {
140
+ if (fs.existsSync(candidate)) return require(candidate);
141
+ }
142
+ throw new Error('TEST_RUNTIME_MISSING');
143
+ }
144
+
145
+ function runTests(args) {
146
+ if ((args.scope || 'worktree') !== 'worktree') throw new Error('TEST_WORKTREE_REQUIRED: 请在待测提交的独立 checkout/worktree 执行测试');
147
+ const ctx = context(args);
148
+ const command = args['command-file'] ? JSON.stringify(readJson(args['command-file'])) : args['command-json'];
149
+ if (!args['session-id']) throw new Error('TEST_SESSION_REQUIRED: --session-id');
150
+ const logger = [path.join(__dirname, '../log.cjs'), path.join(__dirname, '../../skywalk-sdd/index.cjs')].find(fs.existsSync);
151
+ if (!logger) throw new Error('TEST_LOGGER_MISSING');
152
+ const { spawnSync } = require('child_process');
153
+ const child = spawnSync(process.execPath, [logger, 'test-run', `--project=${ctx.specRoot}`,
154
+ `--change=${ctx.change}`, `--session-id=${args['session-id']}`, `--cwd=${ctx.project}`,
155
+ `--task-ids=${args['task-ids'] || ''}`, `--command-json=${command}`,
156
+ `--timeout-ms=${args['timeout-ms'] || 120000}`], {
157
+ cwd: ctx.project, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
158
+ timeout: Number(args['timeout-ms'] || 120000) + 30000,
159
+ });
160
+ let output;
161
+ try { output = JSON.parse(child.stdout); }
162
+ catch { throw new Error(`TEST_RECORD_FAILED: ${(child.stderr || child.error?.message || '').slice(-2000)}`); }
163
+ if (!output.test_results || !output.event_id) throw new Error('TEST_RECORD_FAILED: 未保存严格测试事件');
164
+ const result = { testResults: output.test_results, receipt: output.receipt };
165
+ const after = context(args);
166
+ const stable = ctx.fingerprint === after.fingerprint;
167
+ const changedInputs = {};
168
+ for (const kind of ['code', 'spec']) {
169
+ const beforeFiles = kind === 'code' ? ctx.inputs.code : ctx.inputs.spec.files;
170
+ const afterFiles = kind === 'code' ? after.inputs.code : after.inputs.spec.files;
171
+ const before = new Map(beforeFiles.map(f => [f.path, snap.hashJson(f)]));
172
+ const next = new Map(afterFiles.map(f => [f.path, snap.hashJson(f)]));
173
+ changedInputs[kind] = [...new Set([...before.keys(), ...next.keys()])].filter(p => before.get(p) !== next.get(p));
174
+ }
175
+ const value = { inputFingerprint: ctx.fingerprint, stable, changedInputs, testResults: result.testResults, receipt: result.receipt };
176
+ snap.atomicJson(path.join(ctx.dir, 'tests.json'), value);
177
+ const ok = stable && result.testResults.exit_code === 0 && result.testResults.failure_type === 'none'
178
+ && result.testResults.counts_known && result.testResults.passed > 0;
179
+ return { ok, status: ok ? 'tested' : 'pending', ...value, errors: stable ? [] : ['INPUT_CHANGED_DURING_TEST'] };
180
+ }
181
+
182
+ function verify(args) {
183
+ const ctx = context(args);
184
+ const review = optionalJson(reviewPath(ctx));
185
+ const errors = reviewErrors(ctx, review);
186
+ const tests = optionalJson(path.join(ctx.dir, 'tests.json'));
187
+ const testsNotApplicable = ctx.required.length === 0 && review?.tests?.status === 'na'
188
+ && typeof review.tests.reason === 'string' && review.tests.reason.trim().length > 0
189
+ && DIMENSIONS.every(name => review.dimensions?.[name]?.status === 'na');
190
+ if (testsNotApplicable) { /* explicitly reviewed as no implementation/contract change */ }
191
+ else if (!tests || tests.inputFingerprint !== ctx.fingerprint || !tests.stable) errors.push('TESTS_MISSING_OR_STALE: 需要对当前输入执行测试');
192
+ else {
193
+ try { testRuntime().verifyCapture(tests.testResults, ctx.specRoot); }
194
+ catch (error) { errors.push(error.message); }
195
+ if (tests.testResults.exit_code !== 0 || tests.testResults.failure_type !== 'none') errors.push('TESTS_FAILED');
196
+ if (!tests.testResults.counts_known || tests.testResults.passed < 1) errors.push('NO_PASSING_TESTS');
197
+ }
198
+ const result = {
199
+ ok: !errors.length, status: errors.length ? 'pending' : 'ready', errors,
200
+ change: ctx.change, repository: ctx.identity, scope: ctx.scope,
201
+ codeRevision: ctx.scope === 'commit' ? ctx.ref : null,
202
+ specRevision: ctx.scope === 'commit' ? (ctx.mono ? ctx.ref : args['spec-ref']) : null,
203
+ inputFingerprint: ctx.fingerprint,
204
+ testStatus: testsNotApplicable ? 'not_applicable' : errors.some(e => /TEST|CAPTURE/.test(e)) ? 'pending' : 'passed',
205
+ testNotApplicableReason: testsNotApplicable ? review.tests.reason : null,
206
+ testReceipt: testsNotApplicable ? null : tests?.receipt || null, reviewFile: reviewPath(ctx),
207
+ };
208
+ snap.atomicJson(path.join(ctx.dir, 'verification.json'), result);
209
+ return result;
210
+ }
211
+
212
+ function main(argv = process.argv.slice(2)) {
213
+ const command = argv.shift();
214
+ const args = {};
215
+ for (const arg of argv) {
216
+ const match = /^--([\w-]+)=(.*)$/s.exec(arg);
217
+ if (!match) throw new Error(`ARG_INVALID: 使用 --参数=值: ${arg}`);
218
+ args[match[1]] = match[2];
219
+ }
220
+ const handlers = { prepare, 'record-review': recordReview, test: runTests, verify };
221
+ if (!handlers[command]) throw new Error('用法: delivery-check.cjs prepare|record-review|test|verify --project=<代码仓> --change=<变更> [--scope=worktree|index|commit]');
222
+ const result = handlers[command](args);
223
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
224
+ if (result.ok === false) process.exitCode = 1;
225
+ }
226
+
227
+ if (require.main === module) {
228
+ try { main(); }
229
+ catch (error) { console.error(`[SDD delivery] ${error.message}`); process.exitCode = 1; }
230
+ }
231
+ module.exports = { context, prepare, recordReview, runTests, verify, reviewPath, reviewErrors, DIMENSIONS };
@@ -0,0 +1,181 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { execFileSync } = require('child_process');
7
+
8
+ const RULE_VERSION = 'sdd-delivery-v1';
9
+ const digest = value => crypto.createHash('sha256').update(value).digest('hex');
10
+ const hashJson = value => digest(JSON.stringify(value));
11
+ const slash = value => value.replace(/\\/g, '/');
12
+
13
+ // Git exports these during hooks. A different cwd alone does not switch repositories.
14
+ function gitEnv() {
15
+ const env = { ...process.env };
16
+ for (const key of Object.keys(env)) {
17
+ if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|PREFIX)$/.test(key)) delete env[key];
18
+ }
19
+ return env;
20
+ }
21
+
22
+ function git(root, args, options = {}) {
23
+ return execFileSync('git', args, {
24
+ cwd: root, env: { ...gitEnv(), ...(fs.realpathSync(root) === fs.realpathSync(process.cwd()) && process.env.GIT_INDEX_FILE ? { GIT_INDEX_FILE: path.resolve(process.env.GIT_INDEX_FILE) } : {}) }, encoding: 'utf8', timeout: 10000,
25
+ maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], ...options,
26
+ });
27
+ }
28
+
29
+ function repoIdentity(root) {
30
+ let remote = '';
31
+ try { remote = git(root, ['remote', 'get-url', 'origin']).trim(); } catch { /* local repo */ }
32
+ // Do not persist credentials from a remote URL.
33
+ remote = remote.replace(/(https?:\/\/)[^/@]+@/i, '$1').replace(/\.git$/, '').replace(/\/$/, '');
34
+ remote = remote.replace(/^git@([^:]+):/, '$1/').replace(/^https?:\/\//i, '');
35
+ const common = git(root, ['rev-parse', '--git-common-dir']).trim();
36
+ return remote || `local:${fs.realpathSync(path.resolve(root, common))}`;
37
+ }
38
+
39
+ function entriesAt(root, scope = 'worktree', ref = 'HEAD') {
40
+ if (scope === 'commit') {
41
+ const sha = git(root, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
42
+ return git(root, ['ls-tree', '-r', '-z', '--full-tree', sha]).split('\0').filter(Boolean).map(row => {
43
+ const tab = row.indexOf('\t'), [mode, type, oid] = row.slice(0, tab).split(' ');
44
+ return { path: row.slice(tab + 1), mode, oid, type };
45
+ });
46
+ }
47
+ const entries = git(root, ['ls-files', '--stage', '-z']).split('\0').filter(Boolean).map(row => {
48
+ const tab = row.indexOf('\t'), [mode, oid, stage] = row.slice(0, tab).split(' ');
49
+ if (stage !== '0') throw new Error('UNMERGED_INDEX: 请先解决 Git 冲突');
50
+ return { path: row.slice(tab + 1), mode, oid, type: mode === '160000' ? 'commit' : 'blob' };
51
+ });
52
+ if (scope === 'worktree') {
53
+ for (const file of git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
54
+ entries.push({ path: file, mode: '100644', type: 'blob' });
55
+ }
56
+ }
57
+ return entries;
58
+ }
59
+
60
+ function readBlobs(root, entries) {
61
+ const blobs = entries.filter(e => e.type === 'blob');
62
+ const result = new Map();
63
+ if (!blobs.length) return result;
64
+ const out = git(root, ['cat-file', '--batch'], { input: blobs.map(e => e.oid).join('\n') + '\n', encoding: null });
65
+ let offset = 0;
66
+ for (const entry of blobs) {
67
+ const end = out.indexOf(10, offset);
68
+ const header = out.subarray(offset, end).toString('ascii').split(' ');
69
+ if (end < 0 || header[1] !== 'blob' || !/^\d+$/.test(header[2])) throw new Error('GIT_BLOB_UNREADABLE');
70
+ const size = Number(header[2]);
71
+ offset = end + 1;
72
+ if (offset + size >= out.length) throw new Error('GIT_BLOB_TRUNCATED');
73
+ result.set(entry.path, out.subarray(offset, offset + size));
74
+ offset += size + 1;
75
+ }
76
+ return result;
77
+ }
78
+
79
+ function fileMeta(file, content, mode = '100644') {
80
+ // Git autocrlf changes text line endings between the worktree and index.
81
+ // Normalize valid UTF-8 text only; binary bytes and symlink targets stay exact.
82
+ const text = content.toString('utf8');
83
+ const isText = mode !== '120000' && !content.includes(0) && Buffer.from(text, 'utf8').equals(content);
84
+ const bytes = isText ? Buffer.from(text.replace(/\r\n/g, '\n'), 'utf8') : content;
85
+ return { path: file, mode, normalization: isText ? 'utf8-lf' : 'bytes', size: bytes.length, sha256: digest(bytes) };
86
+ }
87
+
88
+ function excluded(file, specRelative) {
89
+ if (specRelative && specRelative !== '.' && (file === specRelative || file.startsWith(specRelative + '/'))) return true;
90
+ return /(^|\/)(\.git|node_modules|target|dist|build|coverage|skywalk-sdd)(\/|$)/.test(file)
91
+ || /(^|\/)(consistency-report(?:-result)?\.(md|json)|consistency-reviews)(\/|$)/.test(file)
92
+ || /^(openspec|\.claude|\.cursor|\.codex|\.agents|\.qoder)\//.test(file);
93
+ }
94
+
95
+ function codeSnapshot(root, specRoot, scope = 'worktree', ref = 'HEAD') {
96
+ if (!['worktree', 'index', 'commit'].includes(scope)) throw new Error('SCOPE_INVALID');
97
+ const relative = slash(path.relative(root, specRoot));
98
+ const entries = entriesAt(root, scope, ref).filter(e => !excluded(e.path, relative.startsWith('..') ? '' : relative));
99
+ const blobs = scope === 'worktree' ? null : readBlobs(root, entries);
100
+ let trustFileMode = true;
101
+ try { trustFileMode = git(root, ['config', '--bool', 'core.filemode']).trim() !== 'false'; } catch { /* Git default */ }
102
+ const files = [];
103
+ for (const entry of entries) {
104
+ if (entry.type === 'commit') {
105
+ // Submodules require a separate delivery check in that repository.
106
+ files.push({ path: entry.path, mode: entry.mode, gitlink: entry.oid });
107
+ continue;
108
+ }
109
+ let content, mode = entry.mode;
110
+ if (scope === 'worktree') {
111
+ const fp = path.join(root, entry.path);
112
+ if (!fs.existsSync(fp) && !fs.lstatSync(fp, { throwIfNoEntry: false })) continue;
113
+ const stat = fs.lstatSync(fp);
114
+ if (stat.isSymbolicLink()) { content = Buffer.from(fs.readlinkSync(fp)); mode = '120000'; }
115
+ else { content = fs.readFileSync(fp); mode = trustFileMode ? (stat.mode & 0o111 ? '100755' : '100644') : entry.mode; }
116
+ } else content = blobs.get(entry.path);
117
+ if (!content) throw new Error(`UNREADABLE: ${entry.path}`);
118
+ files.push(fileMeta(entry.path, content, mode));
119
+ }
120
+ return files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
121
+ }
122
+
123
+ function isSpecInput(file, change) {
124
+ if (file.startsWith(`openspec/changes/${change}/logs/`) || file.startsWith(`openspec/changes/${change}/reports/`)) return false;
125
+ if (/(^|\/)(consistency-report(?:-result)?\.(json|md)|consistency-reviews)(\/|$)/.test(file)) return false;
126
+ return file === 'modules.yaml' || file === 'sdd.config.yaml' || file === 'openspec/config.yaml'
127
+ || file.startsWith('openspec/specs/') || file.startsWith(`openspec/changes/${change}/`);
128
+ }
129
+
130
+ function specSnapshot(specRoot, change, options = {}) {
131
+ if (!/^[a-z0-9][a-z0-9-]+$/.test(change)) throw new Error('CHANGE_REQUIRED: 必须明确指定 change');
132
+ const files = [];
133
+ if (options.scope === 'commit' || options.scope === 'index') {
134
+ const root = options.root || specRoot;
135
+ const prefix = slash(path.relative(root, specRoot));
136
+ const start = prefix ? prefix + '/' : '';
137
+ const entries = entriesAt(root, options.scope, options.ref).filter(e => e.path.startsWith(start) && isSpecInput(e.path.slice(start.length), change));
138
+ const blobs = readBlobs(root, entries);
139
+ for (const entry of entries) {
140
+ if (entry.mode === '120000' || entry.type !== 'blob') throw new Error('SPEC_INPUT_SYMLINK_OR_SUBMODULE');
141
+ files.push(fileMeta(entry.path.slice(start.length), blobs.get(entry.path), entry.mode));
142
+ }
143
+ } else {
144
+ const walk = (relative) => {
145
+ const fp = path.join(specRoot, relative);
146
+ if (!fs.existsSync(fp)) return;
147
+ const stat = fs.lstatSync(fp);
148
+ if (stat.isSymbolicLink()) throw new Error(`SPEC_INPUT_SYMLINK: ${relative}`);
149
+ if (stat.isDirectory()) {
150
+ for (const name of fs.readdirSync(fp)) walk(relative ? `${relative}/${name}` : name);
151
+ } else if (isSpecInput(relative, change)) files.push(fileMeta(relative, fs.readFileSync(fp)));
152
+ };
153
+ for (const relative of ['modules.yaml', 'sdd.config.yaml', 'openspec/config.yaml', 'openspec/specs', `openspec/changes/${change}`]) walk(relative);
154
+ }
155
+ if (!files.some(f => f.path === `openspec/changes/${change}/proposal.md`)) throw new Error(`CHANGE_MISSING: ${change}`);
156
+ files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
157
+ return { files, sha256: hashJson(files) };
158
+ }
159
+
160
+ function changedFiles(root, scope, base, ref = 'HEAD') {
161
+ const args = ['diff', '--name-only', '--no-renames', '-z'];
162
+ if (scope === 'index') args.push('--cached');
163
+ if (base) args.push(base);
164
+ if (scope === 'commit') {
165
+ if (!base) throw new Error('BASE_REQUIRED: 提交校验必须明确提供比较基线');
166
+ args.push(ref);
167
+ }
168
+ args.push('--');
169
+ const result = git(root, args).split('\0').filter(Boolean);
170
+ if (scope === 'worktree') result.push(...git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean));
171
+ return [...new Set(result)].sort();
172
+ }
173
+
174
+ function atomicJson(file, value) {
175
+ fs.mkdirSync(path.dirname(file), { recursive: true });
176
+ const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
177
+ fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n');
178
+ fs.renameSync(temp, file);
179
+ }
180
+
181
+ module.exports = { RULE_VERSION, digest, hashJson, git, gitEnv, repoIdentity, entriesAt, readBlobs, fileMeta, codeSnapshot, specSnapshot, changedFiles, atomicJson, excluded };
@@ -1,3 +1,5 @@
1
+ # 默认仅安装 commit-msg;pre-commit/pre-push 段仅供已有显式集成使用。
2
+ # Apply 自动运行 delivery-check;安装不会替团队开启 CI 门禁。
1
3
  # KLD SDD Hook 插件配置文件
2
4
  # 每行一个脚本名,空行和 # 开头的行会被忽略
3
5
  # 注释某行 = 禁用该插件,取消注释 = 启用该插件
@@ -9,13 +11,13 @@ commit-msg-sdd-trailer.cjs
9
11
  [pre-commit]
10
12
  # 检查 tasks.md 中是否有未完成的归档任务
11
13
  pre-commit-sdd-check.cjs
12
- # 检查 Spec 一致性校验报告(置信度、过期、人工确认等)
14
+ # 检查 Spec 一致性校验报告(输入指纹、覆盖与实际检查结果)
13
15
  pre-commit-consistency-check.cjs
14
16
 
15
17
  [pre-push]
16
18
  # 运行 doctor 完整诊断(配置、Trailer 协议、活动变更等)
17
19
  pre-push-sdd-check.cjs
18
- # 检查待推送范围的一致性报告(存在性、过期;置信度由 pre-commit 负责)
20
+ # 检查待推送范围的一致性报告(实际 push refs;需显式 Change 和真实测试回执)
19
21
  pre-push-consistency-check.cjs
20
22
 
21
23
  [consistency]
@@ -24,15 +26,15 @@ pre-push-consistency-check.cjs
24
26
  # Go / Kotlin 等项目按需修改:
25
27
  # 注意:新增语言的语言特定构建文件(如 Rust 的 build.rs)不在内置排除规则内,需自行确认
26
28
  # 本配置同时被 git hook(isBusinessCode)与 opsx-consistency-check skill 读取,单一来源
27
- # ⚠️ 清空本行 = 不拦截任何业务代码文件(一致性门禁对提交/推送失效,仅剩报告存在性/过期检查)
29
+ # 清空本行时严格交付检查会失败,不能将范围未知视为通过。
28
30
  business_code_exts = .java,.ts,.tsx,.js,.jsx,.vue,.py
29
31
 
30
32
  # 一致性校验模式(项目级默认值,环境变量优先覆盖;三个互斥,只能启用一个)
31
33
  # 环境变量:SDD_SKIP_CONSISTENCY_CHECK=1 / SDD_STRICT_CONSISTENCY=1 / SDD_WARN_ONLY=1
32
34
  # - skip = 1:跳过一致性校验(紧急绕过,如 hotfix 仓不需要 spec 校验)
33
- # - strict = 1:严格模式,medium 置信度也阻止提交(仅 pre-commit 生效,pre-push 忽略)
35
+ # - strict = 1:保留旧项目显式门禁选择;交付检查始终要求完整事实证据
34
36
  # - warn_only = 1:仅告警不拦截(CI 调试期,问题全列但不阻断提交/推送)
35
- # 三项均不设或均设为 0 = 默认模式(low 阻止,medium 放行,high 放行)
37
+ # 三项均不设或均设为 0 = 检查回执、输入指纹与覆盖;不按置信度放行
36
38
  skip = 0
37
39
  strict = 0
38
- warn_only = 0
40
+ warn_only = 0
@@ -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
 
@@ -1,45 +1,27 @@
1
1
  #!/usr/bin/env node
2
- /**
3
- * pre-commit-consistency-check.cjs
4
- *
5
- * Pre-commit 门禁:检查本次提交 diff 涉及的 Spec 一致性校验报告。
6
- * - diff 驱动:仅检查 diff 文件与报告 coveredFiles 有交集的 change,无关 change 跳过
7
- * - 报告存在性:活跃 change 无报告 → 阻止提交
8
- * - 报告过期:代码在报告生成后被修改 → 阻止提交
9
- * - 置信度门禁:confidence=low → 阻止提交;strict 模式下 medium 也阻止
10
- *
11
- * 通过环境变量控制:
12
- * - SDD_SKIP_CONSISTENCY_CHECK=1 → 跳过校验
13
- * - SDD_STRICT_CONSISTENCY=1 → 严格模式(medium 也阻止)
14
- * - SDD_WARN_ONLY=1 → 仅告警不拦截
15
- * - 注意:三个环境变量互斥,只能启用一个
16
- *
17
- * 共享逻辑见同目录 consistency-check-core.cjs。
2
+ /** Optional strict index verification. Not installed on daily commits by default.
3
+ * Checks complete coverage, input fingerprints and actual review dimensions.
4
+ * SDD_SKIP_CONSISTENCY_CHECK / SDD_WARN_ONLY remain explicit legacy overrides.
18
5
  */
19
6
  'use strict';
20
7
 
21
8
  const core = require('./consistency-check-core.cjs');
22
- const { gitDiffNameStatus } = core;
23
9
 
24
- /**
25
- * 单仓 diff 采集:工作区相对 HEAD 的变更(含 staged 与未 staged)。
26
- * files=修改/新增(排除删除);deleted=删除文件。
27
- * 空仓(无任何 commit)时 HEAD 不存在:降级用 staged 文件列表。
28
- * @returns {{files: string[], deleted: string[], ok: boolean}} ok=false 表示无法确定变更范围
29
- */
10
+ /** Inspect the actual staged content, including an unborn repository and deletions. */
30
11
  function gitDiffHead(repoRoot) {
31
- const bases = [['diff', 'HEAD'], ['diff', '--cached']];
32
- for (const base of bases) {
33
- const r = gitDiffNameStatus(repoRoot, base);
34
- if (r.ok) return r;
35
- }
36
- return { files: [], deleted: [], ok: false };
12
+ try {
13
+ const snap = require('./delivery-snapshot.cjs');
14
+ const changed = snap.changedFiles(repoRoot, 'index');
15
+ const indexed = new Set(snap.entriesAt(repoRoot, 'index').map(e => e.path));
16
+ return { files: changed.filter(f => indexed.has(f)), deleted: changed.filter(f => !indexed.has(f)), ok: true };
17
+ } catch { return { files: [], deleted: [], ok: false }; }
37
18
  }
38
19
 
39
20
  core.runConsistencyCheck({
40
21
  label: '[SDD commit-consistency-check]',
41
22
  actionNoun: '提交',
42
23
  diffFn: gitDiffHead,
43
- checkConfidence: true, // 置信度校验由 pre-commit 完成
44
- includeUntracked: true, // 新文件将随本次提交入库,需检查
24
+ checkConfidence: true, // Legacy option; factual review dimensions now decide readiness.
25
+ includeUntracked: false, // Only the index is part of this commit.
26
+ staleFromIndex: true,
45
27
  });
@@ -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