kld-sdd 2.7.8-2 → 2.7.8-4

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 (41) hide show
  1. package/README.md +2 -0
  2. package/USABILITY.md +52 -0
  3. package/bin/kld-sdd-init.js +700 -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 +450 -711
  10. package/lib/managed-hook-hashes.json +24 -0
  11. package/lib/settings-merge.js +102 -25
  12. package/lib/skills-bundle.js +2 -4
  13. package/lib/spec-choice.js +85 -0
  14. package/lib/tool-profiles.js +2 -2
  15. package/lib/uninstall.js +764 -0
  16. package/package.json +3 -3
  17. package/skywalk-sdd/index.cjs +1 -1
  18. package/skywalk-sdd/kb-upload.cjs +64 -49
  19. package/skywalk-sdd/lib/shared.cjs +62 -41
  20. package/skywalk-sdd/lib/test-execution.cjs +20 -0
  21. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  22. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  23. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  24. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  25. package/skywalk-sdd/ontology/workspace-layout.cjs +46 -16
  26. package/templates/git-hooks/commit-msg +17 -99
  27. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  28. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  29. package/templates/git-hooks/consistency-check-core.cjs +61 -240
  30. package/templates/git-hooks/delivery-check.cjs +231 -0
  31. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  32. package/templates/git-hooks/hooks.config +8 -6
  33. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  34. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  35. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  36. package/templates/openspec/proposal.md +1 -1
  37. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +24 -22
  38. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  39. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  40. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +157 -11
  41. package/index.js +0 -13
package/lib/init.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * 5. 更新 .gitignore
10
10
  */
11
11
 
12
+ 'use strict';
13
+
12
14
  const fs = require('fs');
13
15
  const path = require('path');
14
16
  const { execSync } = require('child_process');
@@ -16,7 +18,6 @@ const readline = require('readline');
16
18
  const os = require('os');
17
19
  const {
18
20
  SKILLS_BUNDLE_NAME,
19
- OPSX_SKILL_DIRS,
20
21
  OPSX_SKILL_METADATA,
21
22
  renderTemplate,
22
23
  validateRenderedContent,
@@ -28,8 +29,19 @@ const {
28
29
  BRIDGE_MARKER,
29
30
  } = require('./command-bridge');
30
31
  const { deployCodebuddyHookPack } = require('./deploy-codebuddy-hooks');
32
+ const { PROVIDERS, computeMergedSettings } = require('./settings-merge');
31
33
  const workspaceLayout = require('./workspace-layout');
32
34
  const sddConfig = require('../skywalk-sdd/ontology/sdd-config.cjs');
35
+ // spec 判据与候选枚举的唯一入口(tier 分级 + 排序)
36
+ const specDetect = require('../skywalk-sdd/ontology/spec-detect.cjs');
37
+ // spec 候选单选交互(init / uninstall / sync-repos 共用)
38
+ const { selectSpecRoot } = require('./spec-choice');
39
+ const {
40
+ STRATEGY,
41
+ ACTION,
42
+ deployFile,
43
+ deployDir,
44
+ } = require('./deploy-strategy');
33
45
 
34
46
  let rl = null;
35
47
 
@@ -63,14 +75,19 @@ function ask(question) {
63
75
  }
64
76
 
65
77
  /**
66
- * 输出详细度判定(惰性读取,便于测试在运行期注入)
67
- * 触发条件:argv 含 --verbose,或 KLD_SDD_VERBOSE 为 1/true(大小写不敏感)
68
- * 注意:-v 已被 --version 占用,不复用
78
+ * 输出详细度开关。
79
+ *
80
+ * 判定(--verbose / KLD_SDD_VERBOSE)属 CLI 输入层职责,由 bin/kld-sdd-init.js 解析后
81
+ * 通过 setVerbose 注入;lib 不读取 process.argv。
69
82
  */
83
+ let verboseEnabled = false;
84
+
85
+ function setVerbose(value) {
86
+ verboseEnabled = Boolean(value);
87
+ }
88
+
70
89
  function isVerbose() {
71
- if (process.argv.includes('--verbose')) return true;
72
- const env = process.env.KLD_SDD_VERBOSE;
73
- return typeof env === 'string' && /^(1|true)$/i.test(env);
90
+ return verboseEnabled;
74
91
  }
75
92
 
76
93
  /**
@@ -82,57 +99,6 @@ function vlog(...args) {
82
99
  }
83
100
  }
84
101
 
85
- /**
86
- * 从命令行参数读取指定编辑器
87
- * 支持:--tool codex、--tool=codex、--tool cursor,codex、--tool all
88
- */
89
- function parseSelectedTools(args) {
90
- const toolArgIndex = args.findIndex(arg => arg === '--tool' || arg.startsWith('--tool='));
91
- if (toolArgIndex === -1) return null;
92
-
93
- const rawValue = args[toolArgIndex] === '--tool'
94
- ? args[toolArgIndex + 1]
95
- : args[toolArgIndex].slice('--tool='.length);
96
-
97
- if (!rawValue) {
98
- throw new Error('缺少 --tool 参数值,请指定编辑器名称');
99
- }
100
-
101
- const normalized = rawValue.trim().toLowerCase();
102
- if (normalized === 'all' || normalized === '全部') {
103
- return Object.keys(TOOL_CONFIGS);
104
- }
105
-
106
- const selected = normalized.split(',').map(item => item.trim()).filter(Boolean);
107
- const invalid = selected.filter(tool => !TOOL_CONFIGS[tool]);
108
- if (invalid.length > 0) {
109
- throw new Error(`不支持的编辑器: ${invalid.join(', ')}。可用值: ${Object.keys(TOOL_CONFIGS).join(', ')}, all`);
110
- }
111
-
112
- return selected;
113
- }
114
-
115
- /**
116
- * 从命令行参数读取自定义 spec 目录
117
- * 支持:--spec-path erp-sdd-specs、--spec-path=./specs、--spec-path=/abs/path/specs
118
- * 返回相对/绝对路径字符串,未指定时返回 null。
119
- */
120
- function parseSpecPathArg(args) {
121
- const index = args.findIndex(
122
- (arg) => arg === '--spec-path' || arg.startsWith('--spec-path='),
123
- );
124
- if (index === -1) return null;
125
-
126
- const rawValue = args[index] === '--spec-path'
127
- ? args[index + 1]
128
- : args[index].slice('--spec-path='.length);
129
-
130
- if (!rawValue || rawValue.startsWith('--')) {
131
- throw new Error('缺少 --spec-path 参数值,请指定 spec 仓库目录(相对或绝对路径)');
132
- }
133
- return rawValue.trim();
134
- }
135
-
136
102
  /**
137
103
  * 将 --spec-path 解析为 { name, abs }。
138
104
  * - 绝对路径:直接使用
@@ -156,82 +122,76 @@ function resolveSpecPathOption(specPathArg, workspaceRoot) {
156
122
  }
157
123
 
158
124
  /**
159
- * 交互提示前的 spec 目录兜底检测:
160
- * detectWorkspaceLayout 的 specRepo 只覆盖「子目录是 Git 仓」的场景,
161
- * 这里补充两种常见形态,避免已有 spec 产物被漏检:
162
- * 1. .sdd-spec-root 指针文件(历史初始化产物)
163
- * 2. 非 Git 子目录的 spec 产物(findNestedSpecPackage 不要求子目录是 Git 仓)
125
+ * 枚举交互提示用的 spec 目录候选(与卸载共用同一套识别)。
126
+ *
127
+ * 委托 `spec-detect.detectSpecCandidatesWithGit` —— 判据与「读 git config」
128
+ * 均在全仓唯一入口内完成,本函数只是 init 侧的命名别名。
129
+ * 历史上 init 与 uninstall 各写一份相同实现,是「一处改、一处漏」的来源。
130
+ *
131
+ * @returns {Array<{abs: string, name: string, source: string, tier: number, evidence: string[]}>}
164
132
  */
165
- function detectSpecCandidateForPrompt(workspaceRoot) {
166
- try {
167
- const hintPath = path.join(workspaceRoot, '.sdd-spec-root');
168
- if (fs.existsSync(hintPath)) {
169
- const rel = String(fs.readFileSync(hintPath, 'utf8') || '')
170
- .trim()
171
- .split(/\r?\n/)[0]
172
- .trim();
173
- if (rel) {
174
- const abs = path.resolve(workspaceRoot, rel);
175
- if (fs.existsSync(abs) && workspaceLayout.looksLikeSpecRepo(abs)) {
176
- return { name: path.basename(abs), abs };
177
- }
178
- }
179
- }
180
- } catch { /* 指针读取异常时继续兜底 */ }
181
-
182
- const nested = workspaceLayout.findNestedSpecPackage(workspaceRoot);
183
- return nested ? { name: nested.name, abs: nested.abs } : null;
133
+ function listSpecCandidatesForInit(workspaceRoot) {
134
+ return specDetect.detectSpecCandidatesWithGit(workspaceRoot);
184
135
  }
185
136
 
186
137
  /**
187
138
  * 交互式确定 spec 包裹包目录(--spec-path 未显式指定时):
188
- * 1. 自动检测到已有 spec 产物(modules.yaml / openspec/changes / *-sdd-specs)→ 提醒用户确认是否使用
189
- * 2. 未检测到 → 要求用户输入相对路径指定(回车则用默认推导名 <当前目录名>-sdd-specs)
139
+ * 1. 检测到候选 → 单选确认(0 个 / 1 个 / N 个 的行为见 lib/spec-choice.js)
140
+ * 2. 未检测到、或用户拒绝候选 → 要求输入相对路径
141
+ * (回车则用默认推导名 `<当前目录名>-sdd-specs`)
190
142
  *
191
143
  * 非交互环境(CI / 管道 / --tool 自动化)不询问,保持原自动推导行为。
192
144
  *
193
- * @param {object} [options] - 测试注入:{ askFn, interactive }
145
+ * 单选交互委托 `lib/spec-choice.js#selectSpecRoot` —— 与 uninstall /
146
+ * sync-repos 共用同一实现。历史上这里另写了一套「确认检测目录」逻辑,
147
+ * 导致 init 只能确认单个候选(多候选时静默取第一个),与另两个场景不一致。
148
+ *
149
+ * @param {Array<{abs: string, name: string, source: string, tier?: number}>} candidates
150
+ * spec 候选集合(由 spec-detect 枚举,已按 tier 排序);空数组表示未检测到
151
+ * @param {object} [options] - 测试/调用方注入:{ askFn, interactive, hasToolArg, isTTY }
152
+ * - hasToolArg:命令行是否显式指定了编辑器(自动化场景,不询问)。由 bin 层解析后注入,
153
+ * 本函数不读取 process.argv。
154
+ * - isTTY:覆盖 TTY 判定,便于测试
194
155
  * @returns {{ name: string, abs: string, rel: string, display: string } | null}
195
156
  * null 表示未选择自定义目录,走默认推导逻辑
196
157
  */
197
- async function promptSpecPath(workspaceRoot, detectedSpec, options = {}) {
158
+ async function promptSpecPath(workspaceRoot, candidates, options = {}) {
198
159
  const askUser = options.askFn || ask;
199
160
  let interactive = options.interactive;
200
161
  if (typeof interactive !== 'boolean') {
201
162
  // 非交互环境:无 TTY 或显式给了 --tool(自动化场景),不询问
202
- const isTTY = process.stdin && process.stdin.isTTY;
203
- const hasToolArg = process.argv.slice(2).some((arg) => arg === '--tool' || arg.startsWith('--tool='));
204
- interactive = Boolean(isTTY) && !hasToolArg;
163
+ const isTTY = options.isTTY !== undefined
164
+ ? options.isTTY
165
+ : Boolean(process.stdin && process.stdin.isTTY);
166
+ interactive = Boolean(isTTY) && !options.hasToolArg;
205
167
  }
206
168
  if (!interactive) return null;
207
169
 
208
- if (detectedSpec) {
209
- console.log(`\n🔍 检测到已有 spec 目录(命名约定或完整结构标记): ${detectedSpec.name}`);
210
- console.log(` 位置: ${detectedSpec.abs}`);
211
- while (true) {
212
- const answer = await askUser(`是否使用该 spec 仓库?(Y=使用 / n=重新指定路径,默认 Y): `);
213
- const normalized = (answer || '').trim().toLowerCase();
214
- if (!normalized || normalized === 'y' || normalized === 'yes') {
215
- const relPosix = path.relative(workspaceRoot, detectedSpec.abs).replace(/\\/g, '/') || detectedSpec.name;
216
- return { name: relPosix, abs: detectedSpec.abs, rel: relPosix, display: detectedSpec.name };
217
- }
218
- if (normalized === 'n' || normalized === 'no') break;
219
- console.log(`❌ 无效输入 "${answer}",请输入 Y 或 n`);
170
+ const list = Array.isArray(candidates) ? candidates : (candidates ? [candidates] : []);
171
+ const hasCandidate = list.length > 0;
172
+
173
+ if (hasCandidate) {
174
+ const chosen = await selectSpecRoot({ ask: askUser }, list, { actionLabel: '使用' });
175
+ if (chosen) {
176
+ const name = path.basename(chosen);
177
+ const relPosix = path.relative(workspaceRoot, chosen).replace(/\\/g, '/') || name;
178
+ return { name: relPosix, abs: chosen, rel: relPosix, display: name };
220
179
  }
180
+ // 取消(多候选选 0 / 唯一候选拒绝)→ 转入手动输入路径
221
181
  } else {
222
182
  console.log('\n🔍 未检测到已有 spec 目录(目录名 *-sdd-specs,或 modules.yaml + sdd.config.yaml + openspec/changes + openspec/specs/overview.md 齐全)');
223
183
  }
224
184
 
225
- // 未确认使用检测目录(或未检测到):要求输入相对路径
185
+ // 未确认使用候选(或未检测到):要求输入相对路径
226
186
  const defaultName = workspaceLayout.deriveSpecPackageName(workspaceRoot);
227
187
  while (true) {
228
188
  const input = await askUser(`请输入 spec 目录的相对路径(直接回车使用默认 ${defaultName}): `);
229
189
  const trimmed = (input || '').trim();
230
190
  if (!trimmed) {
231
191
  // 回车 = 使用默认推导名。
232
- // 若用户刚用 n 拒绝了检测到的目录,必须显式返回默认目录:
192
+ // 若此前拒绝了检测到的候选,必须显式返回默认目录:
233
193
  // 返回 null 会让主流程走自动探测,重新命中刚被拒绝的目录。
234
- if (detectedSpec) {
194
+ if (hasCandidate) {
235
195
  return resolveSpecPathOption(defaultName, workspaceRoot);
236
196
  }
237
197
  return null; // 未检测到场景:走主流程默认创建
@@ -245,49 +205,6 @@ async function promptSpecPath(workspaceRoot, detectedSpec, options = {}) {
245
205
  }
246
206
  }
247
207
 
248
- /**
249
- * 让用户选择编辑器
250
- * 无效输入时重新询问,不允许默认
251
- */
252
- async function selectEditor() {
253
- const mapping = {
254
- '1': ['cursor'],
255
- '2': ['claude'],
256
- '3': ['codebuddy'],
257
- '4': ['qoder'],
258
- '5': ['opencode'],
259
- '6': ['kunlunzhima'],
260
- '7': ['workbuddy'],
261
- '8': ['codex'],
262
- '9': ['cursor', 'claude', 'codebuddy', 'qoder', 'opencode', 'kunlunzhima', 'workbuddy', 'codex']
263
- };
264
-
265
- while (true) {
266
- console.log('\n请选择您使用的 AI 编辑器:');
267
- console.log(' 1. Cursor');
268
- console.log(' 2. Claude Code');
269
- console.log(' 3. CodeBuddy');
270
- console.log(' 4. Qoder');
271
- console.log(' 5. OpenCode');
272
- console.log(' 6. KunlunZhima');
273
- console.log(' 7. WorkBuddy');
274
- console.log(' 8. Codex');
275
- console.log(' 9. 全部(为所有编辑器生成 skills)');
276
-
277
- const answer = await ask('请输入选项 (1-9): ');
278
- const selected = mapping[answer];
279
-
280
- if (!selected) {
281
- console.log(`❌ 无效选项 "${answer}",请输入 1-9 之间的数字\n`);
282
- continue; // 重新循环询问
283
- }
284
-
285
- const names = selected.map(k => TOOL_CONFIGS[k].name).join(', ');
286
- console.log(`✓ 已选择: ${names}\n`);
287
- return selected;
288
- }
289
- }
290
-
291
208
  /**
292
209
  * 检查命令是否可用
293
210
  */
@@ -319,14 +236,6 @@ function getPackagePath() {
319
236
  }
320
237
  }
321
238
 
322
- /**
323
- * 获取模版路径
324
- */
325
- function getTemplatePath() {
326
- const pkgPath = getPackagePath();
327
- return path.join(pkgPath, 'templates', 'openspec');
328
- }
329
-
330
239
  /**
331
240
  * 自动安装 openspec
332
241
  */
@@ -420,48 +329,18 @@ async function runOpenspecInit(selectedTools = []) {
420
329
  }
421
330
 
422
331
  /**
423
- * 递归复制目录
332
+ * 递归复制并渲染模板目录(工具产物 → OVERWRITE)
424
333
  */
425
- function copyDir(source, target) {
426
- if (!fs.existsSync(target)) {
427
- fs.mkdirSync(target, { recursive: true });
428
- }
429
-
430
- const files = fs.readdirSync(source);
431
- for (const file of files) {
432
- const srcPath = path.join(source, file);
433
- const tgtPath = path.join(target, file);
434
-
435
- const stat = fs.statSync(srcPath);
436
- if (stat.isDirectory()) {
437
- copyDir(srcPath, tgtPath);
438
- } else {
439
- fs.copyFileSync(srcPath, tgtPath);
440
- }
441
- }
442
- }
443
-
444
334
  function copyDirRendered(source, target, config, toolKey) {
445
335
  const profile = getToolProfile(toolKey);
446
-
447
- if (!fs.existsSync(target)) {
448
- fs.mkdirSync(target, { recursive: true });
449
- }
450
-
451
- const files = fs.readdirSync(source);
452
- for (const file of files) {
453
- const srcPath = path.join(source, file);
454
- const tgtPath = path.join(target, file);
455
-
456
- const stat = fs.statSync(srcPath);
457
- if (stat.isDirectory()) {
458
- copyDirRendered(srcPath, tgtPath, config, toolKey);
459
- } else {
336
+ return deployDir(source, target, {
337
+ strategy: STRATEGY.OVERWRITE,
338
+ render: (srcPath, tgtPath) => {
460
339
  const rendered = renderTemplate(fs.readFileSync(srcPath, 'utf8'), config, toolKey, profile);
461
340
  validateRenderedContent(rendered, profile, tgtPath);
462
- fs.writeFileSync(tgtPath, rendered, 'utf8');
463
- }
464
- }
341
+ return rendered;
342
+ },
343
+ });
465
344
  }
466
345
 
467
346
  /** OpenSpec init 生成的 4 个官方 slash command(KLD-SDD 使用 skills,不保留 command) */
@@ -715,37 +594,10 @@ function deployProfileArtifacts(selectedTools = Object.keys(TOOL_CONFIGS), cwd =
715
594
  /**
716
595
  * 部署 Claude Code Hook Pack(可选增强)
717
596
  * Hook 只调用 skywalk-sdd/log.cjs,不写私有日志,不替代 OPSX 模板采集。
597
+ *
598
+ * SDD 受管 hook 的识别/合并/摘除统一由 lib/settings-merge.js 提供,
599
+ * 保证安装与卸载共用同一套归属判定(装得进去的一定摘得掉)。
718
600
  */
719
- function hookCommandText(hook) {
720
- const args = Array.isArray(hook.args) ? hook.args : [];
721
- return [hook.command, ...args].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
722
- }
723
-
724
- function isSddClaudeHook(hook) {
725
- const text = hookCommandText(hook);
726
- return /(?:^|\s|["'])\.claude[\\/]+hooks[\\/]+sdd-(?:prompt|pre-tool|post-tool|stop|apply-gate|apply-test-gate|skill-apply-gate)\.(?:cjs|js)(?=$|\s|["'])/i.test(text) ||
727
- /(?:^|\s|["'])\$\{CLAUDE_PROJECT_DIR\}[\\/]+\.claude[\\/]+hooks[\\/]+sdd-(?:prompt|pre-tool|post-tool|stop|apply-gate|apply-test-gate|skill-apply-gate)\.(?:cjs|js)(?=$|\s|["'])/i.test(text);
728
- }
729
-
730
- function sameHookCommand(left, right) {
731
- return hookCommandText(left) === hookCommandText(right) &&
732
- String(left.type || '') === String(right.type || '');
733
- }
734
-
735
- function removeManagedSddHooks(entries) {
736
- return entries
737
- .map(entry => {
738
- if (!Array.isArray(entry.hooks)) {
739
- return entry;
740
- }
741
- return {
742
- ...entry,
743
- hooks: entry.hooks.filter(hook => !isSddClaudeHook(hook)),
744
- };
745
- })
746
- .filter(entry => !Array.isArray(entry.hooks) || entry.hooks.length > 0);
747
- }
748
-
749
601
  function deployClaudeHookPack(selectedTools = Object.keys(TOOL_CONFIGS)) {
750
602
  if (!selectedTools.includes('claude')) {
751
603
  return true;
@@ -770,41 +622,45 @@ function deployClaudeHookPack(selectedTools = Object.keys(TOOL_CONFIGS)) {
770
622
  fs.mkdirSync(targetHookDir, { recursive: true });
771
623
  }
772
624
 
773
- copyDir(hookSourceDir, targetHookDir);
625
+ deployDir(hookSourceDir, targetHookDir, { strategy: STRATEGY.OVERWRITE });
774
626
  console.log(' ✓ 部署 .claude/hooks/sdd-*.cjs');
775
627
 
776
628
  const targetSettingsPath = path.join(claudeDir, 'settings.json');
777
- const templateSettings = JSON.parse(fs.readFileSync(settingsTemplatePath, 'utf8'));
778
- let existingSettings = {};
779
- if (fs.existsSync(targetSettingsPath)) {
780
- try {
781
- existingSettings = JSON.parse(fs.readFileSync(targetSettingsPath, 'utf8'));
782
- } catch {
783
- const backupPath = `${targetSettingsPath}.backup`;
784
- fs.copyFileSync(targetSettingsPath, backupPath);
785
- console.log(` ℹ️ settings.json 不是合法 JSON,已备份到 ${backupPath}`);
786
- existingSettings = {};
787
- }
788
- }
629
+ // 结构化配置 → MERGE:保留用户已有字段,只剔除并重装 SDD 受管 hook,幂等
630
+ const mergeResult = deployFile(settingsTemplatePath, targetSettingsPath, {
631
+ strategy: STRATEGY.MERGE,
632
+ label: 'settings.json',
633
+ mergeFn: ({ readSource, readTarget }) => {
634
+ const templateSettings = JSON.parse(readSource());
635
+ const rawExisting = readTarget();
636
+ // 非法 JSON:备份后按空配置重建,避免覆盖用户其它配置时无迹可循
637
+ if (rawExisting !== null) {
638
+ try {
639
+ JSON.parse(rawExisting);
640
+ } catch {
641
+ const backupPath = `${targetSettingsPath}.backup`;
642
+ fs.copyFileSync(targetSettingsPath, backupPath);
643
+ console.log(` ℹ️ settings.json 不是合法 JSON,已备份到 ${backupPath} 后重建`);
644
+ }
645
+ }
789
646
 
790
- existingSettings.hooks = existingSettings.hooks || {};
791
- for (const [eventName, entries] of Object.entries(templateSettings.hooks || {})) {
792
- existingSettings.hooks[eventName] = removeManagedSddHooks(existingSettings.hooks[eventName] || []);
793
- for (const entry of entries) {
794
- const templateHooks = entry.hooks || [];
795
- const exists = existingSettings.hooks[eventName].some(existingEntry => {
796
- const existingHooks = existingEntry.hooks || [];
797
- return templateHooks.every(templateHook => {
798
- return existingHooks.some(existingHook => sameHookCommand(existingHook, templateHook));
799
- });
800
- });
801
- if (!exists) {
802
- existingSettings.hooks[eventName].push(entry);
647
+ const computed = computeMergedSettings(rawExisting, templateSettings, PROVIDERS.CLAUDE);
648
+ if (!computed.ok) {
649
+ return { ok: false, error: `${computed.error}: ${targetSettingsPath}` };
803
650
  }
804
- }
805
- }
806
651
 
807
- fs.writeFileSync(targetSettingsPath, JSON.stringify(existingSettings, null, 2) + '\n', 'utf8');
652
+ fs.writeFileSync(targetSettingsPath, JSON.stringify(computed.merged, null, 2) + '\n', 'utf8');
653
+ return { ok: true, action: ACTION.MERGED };
654
+ },
655
+ });
656
+
657
+ if (!mergeResult.ok) {
658
+ console.log(` ⚠️ 合并 .claude/settings.json 失败: ${mergeResult.message}`);
659
+ return false;
660
+ }
661
+ if (mergeResult.message) {
662
+ console.log(` ℹ️ ${mergeResult.message}`);
663
+ }
808
664
  console.log(' ✓ 合并 .claude/settings.json hooks 配置');
809
665
  console.log('✅ Claude Code SDD Hook Pack 已就绪(可选增强,核心采集仍由 OPSX skills 完成)');
810
666
  return true;
@@ -855,27 +711,32 @@ function cleanupNativeOpenspecSkills(selectedTools = Object.keys(TOOL_CONFIGS))
855
711
 
856
712
  /**
857
713
  * 复制标准文档模版到项目(openspec-templates/)
714
+ *
715
+ * 定位:**只读模板**。skills 强制以它作为文档骨架(opsx-propose §4「不得使用
716
+ * openspec instructions 返回的简化 template」),因此每次 init 全量覆盖更新。
717
+ * 项目若需自定义文档结构,应改 skill 或另建文件,不要就地修改本目录。
858
718
  */
859
719
  function copyTemplatesToProject(cwd = process.cwd()) {
860
720
  console.log('📄 正在复制标准文档模版到项目...');
861
-
721
+
862
722
  const pkgPath = getPackagePath();
863
723
  const templatePath = path.join(pkgPath, 'templates', 'openspec');
864
724
  const targetDir = path.join(cwd, 'openspec-templates');
865
-
725
+
866
726
  if (!fs.existsSync(templatePath)) {
867
727
  console.log(`⚠️ 模版源目录不存在: ${templatePath}`);
868
728
  return false;
869
729
  }
870
-
871
- if (!fs.existsSync(targetDir)) {
872
- fs.mkdirSync(targetDir, { recursive: true });
873
- }
874
-
875
- copyDir(templatePath, targetDir);
730
+
731
+ // 只读模板 → OVERWRITE:每次覆盖更新,保证文档骨架与当前 skills 版本一致
732
+ deployDir(templatePath, targetDir, {
733
+ strategy: STRATEGY.OVERWRITE,
734
+ label: 'openspec-templates',
735
+ });
876
736
 
877
737
  const files = fs.readdirSync(targetDir);
878
738
  console.log(`✅ 标准文档模版已复制到: ${targetDir}(${files.length} 个文件)`);
739
+ vlog(' ℹ️ 只读模板:每次 init 全量覆盖更新,请勿就地修改本目录');
879
740
  vlog(' 包含文件:');
880
741
  files.forEach(file => {
881
742
  vlog(` - ${file}`);
@@ -886,45 +747,38 @@ function copyTemplatesToProject(cwd = process.cwd()) {
886
747
 
887
748
  /**
888
749
  * 初始化全局 overview.md 到 openspec/specs/ 目录
889
- * overview.md 是全局架构约束,所有 Capability 的设计都必须遵守
750
+ *
751
+ * overview.md 是全局架构约束,也是**用户产物**:由项目团队按 opsx-propose §3.5
752
+ * 的引导填充(技术栈 / 数据字典 / 接口规范 / 错误码),AI 在 design / task 阶段读取。
753
+ * 因此首次创建模板,之后**绝不覆盖**——否则一次重跑就会清空已填充的全局契约。
890
754
  */
891
755
  function initGlobalOverview(cwd = process.cwd()) {
892
756
  console.log('🌐 正在初始化全局架构约束 (overview.md)...');
893
-
757
+
894
758
  const pkgPath = getPackagePath();
895
759
  const overviewSource = path.join(pkgPath, 'templates', 'openspec', 'overview.md');
896
760
  const targetDir = path.join(cwd, 'openspec', 'specs');
897
761
  const targetFile = path.join(targetDir, 'overview.md');
898
-
762
+
899
763
  if (!fs.existsSync(overviewSource)) {
900
764
  console.log(`⚠️ overview.md 源文件不存在: ${overviewSource}`);
901
765
  return false;
902
766
  }
903
-
904
- if (!fs.existsSync(targetDir)) {
905
- fs.mkdirSync(targetDir, { recursive: true });
906
- }
907
-
908
- if (fs.existsSync(targetFile)) {
909
- const existingContent = fs.readFileSync(targetFile, 'utf-8');
910
- const newContent = fs.readFileSync(overviewSource, 'utf-8');
911
-
912
- if (existingContent === newContent) {
913
- console.log(' ✓ overview.md 已是最新版本,跳过');
914
- return true;
915
- }
916
-
917
- const backupFile = path.join(targetDir, 'overview.md.backup');
918
- fs.copyFileSync(targetFile, backupFile);
919
- console.log(` ℹ️ 已备份现有 overview.md 到 ${backupFile}`);
767
+
768
+ const result = deployFile(overviewSource, targetFile, {
769
+ strategy: STRATEGY.SKIP_IF_EXISTS,
770
+ label: 'overview.md',
771
+ });
772
+
773
+ if (result.action === ACTION.SKIPPED) {
774
+ console.log(' ✓ overview.md 已存在(用户产物),保留不覆盖');
775
+ return true;
920
776
  }
921
-
922
- fs.copyFileSync(overviewSource, targetFile);
923
-
777
+
924
778
  console.log(`✅ 全局架构约束已初始化: ${targetFile}`);
925
779
  console.log(' ℹ️ 此文件定义了全局数据字典、接口规范、共享实体等约束');
926
780
  console.log(' ℹ️ 执行 opsx-design 或 opsx-task 时会自动读取此文件');
927
-
781
+
928
782
  return true;
929
783
  }
930
784
 
@@ -943,15 +797,43 @@ function deploySddGuideManual(cwd = process.cwd(), pkgPath = getPackagePath()) {
943
797
  return false;
944
798
  }
945
799
 
946
- if (!fs.existsSync(targetDir)) {
947
- fs.mkdirSync(targetDir, { recursive: true });
948
- }
949
-
950
- fs.copyFileSync(source, targetFile);
800
+ deployFile(source, targetFile, {
801
+ strategy: STRATEGY.OVERWRITE,
802
+ label: 'kld-sdd操作手册.html',
803
+ });
951
804
  console.log(`✅ SDD 操作手册已部署: openspec/kld-sdd操作手册.html`);
952
805
  return true;
953
806
  }
954
807
 
808
+ /**
809
+ * 部署 skywalk-sdd/ 下的单个运行时模块(工具产物 → OVERWRITE)。
810
+ *
811
+ * 统一「源存在则覆盖部署并计数、缺失则告警」这一重复模式,
812
+ * 避免每个模块各写一遍 existsSync + copyFileSync。
813
+ *
814
+ * @param {string} src 源文件绝对路径
815
+ * @param {string} dst 目标文件绝对路径
816
+ * @param {string} desc 日志用描述(verbose 下输出)
817
+ * @param {string} [missingHint] 源缺失时的告警文案
818
+ * @returns {boolean} 是否成功部署
819
+ */
820
+ function deployRuntimeModule(src, dst, desc, missingHint) {
821
+ if (!fs.existsSync(src)) {
822
+ console.log(` ⚠️ ${missingHint || `${desc} 源文件缺失`}: ${src}`);
823
+ return false;
824
+ }
825
+ const result = deployFile(src, dst, {
826
+ strategy: STRATEGY.OVERWRITE,
827
+ label: path.basename(dst),
828
+ });
829
+ if (!result.ok) {
830
+ console.log(` ⚠️ 部署 ${desc} 失败: ${result.message}`);
831
+ return false;
832
+ }
833
+ vlog(` ✓ 部署 ${desc}`);
834
+ return true;
835
+ }
836
+
955
837
  /**
956
838
  * 部署 SDD Telemetry 数据目录
957
839
  * 创建本地数据目录用于存储事件和活跃状态(最终报告落 openspec/changes/<change>/reports/,不再写 skywalk-sdd/reports/)
@@ -984,33 +866,21 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
984
866
  // 部署项目内 Telemetry CLI(不依赖 npm 发布)
985
867
  const telemetrySrc = path.join(pkgPath, 'skywalk-sdd', 'index.cjs');
986
868
  const telemetryDst = path.join(dataDir, 'log.cjs');
987
- if (fs.existsSync(telemetrySrc)) {
988
- fs.copyFileSync(telemetrySrc, telemetryDst);
869
+ if (deployRuntimeModule(telemetrySrc, telemetryDst, 'skywalk-sdd/log.cjs(本地 Telemetry CLI)', 'Telemetry CLI 源文件缺失')) {
989
870
  deployedModules += 1;
990
- vlog(' ✓ 部署 skywalk-sdd/log.cjs(本地 Telemetry CLI)');
991
- } else {
992
- console.log(` ⚠️ Telemetry CLI 源文件缺失: ${telemetrySrc}`);
993
871
  }
994
872
 
995
873
  const metricsV3Src = path.join(pkgPath, 'skywalk-sdd', 'metrics-v3.cjs');
996
874
  const metricsV3Dst = path.join(dataDir, 'metrics-v3.cjs');
997
- if (fs.existsSync(metricsV3Src)) {
998
- fs.copyFileSync(metricsV3Src, metricsV3Dst);
875
+ if (deployRuntimeModule(metricsV3Src, metricsV3Dst, 'skywalk-sdd/metrics-v3.cjs(V3 指标运行时)', 'V3 指标模块源文件缺失(report/metrics 可能无法加载 metrics-v3)')) {
999
876
  deployedModules += 1;
1000
- vlog(' ✓ 部署 skywalk-sdd/metrics-v3.cjs(V3 指标运行时)');
1001
- } else {
1002
- console.log(` ⚠️ V3 指标模块源文件缺失: ${metricsV3Src}(report/metrics 可能无法加载 metrics-v3)`);
1003
877
  }
1004
878
 
1005
879
  // 部署 spec-root.cjs(替代脆弱的 $(cat .sdd-spec-root),CWD 无关)
1006
880
  const specRootSrc = path.join(pkgPath, 'skywalk-sdd', 'spec-root.cjs');
1007
881
  const specRootDst = path.join(dataDir, 'spec-root.cjs');
1008
- if (fs.existsSync(specRootSrc)) {
1009
- fs.copyFileSync(specRootSrc, specRootDst);
882
+ if (deployRuntimeModule(specRootSrc, specRootDst, 'skywalk-sdd/spec-root.cjs(CWD 无关的 spec 根路径解析器)', 'spec-root.cjs 源文件缺失,$(cat .sdd-spec-root) 模式可能受影响')) {
1010
883
  deployedModules += 1;
1011
- vlog(' ✓ 部署 skywalk-sdd/spec-root.cjs(CWD 无关的 spec 根路径解析器)');
1012
- } else {
1013
- console.log(' ⚠️ spec-root.cjs 源文件缺失,$(cat .sdd-spec-root) 模式可能受影响');
1014
884
  }
1015
885
 
1016
886
  // 部署 lib/shared.cjs(共用工具模块:parseArgs/readJson/atomicWrite/HTTP/specRoot/KB状态)
@@ -1027,12 +897,8 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
1027
897
  }
1028
898
  const sharedSrc = path.join(pkgPath, 'skywalk-sdd', 'lib', 'shared.cjs');
1029
899
  const sharedDst = path.join(sharedLibDir, 'shared.cjs');
1030
- if (fs.existsSync(sharedSrc)) {
1031
- fs.copyFileSync(sharedSrc, sharedDst);
900
+ if (deployRuntimeModule(sharedSrc, sharedDst, 'skywalk-sdd/lib/shared.cjs(共用工具:parseArgs/JSON/BOM/HTTP/specRoot/KB)', 'lib/shared.cjs 源文件缺失,多个脚本将缺少共用工具依赖')) {
1032
901
  deployedModules += 1;
1033
- vlog(' ✓ 部署 skywalk-sdd/lib/shared.cjs(共用工具:parseArgs/JSON/BOM/HTTP/specRoot/KB)');
1034
- } else {
1035
- console.log(' ⚠️ lib/shared.cjs 源文件缺失,多个脚本将缺少共用工具依赖');
1036
902
  }
1037
903
 
1038
904
  // kld-fixback: 部署使用统计上报模块(usage-contract/usage-reporter/user-config/git-identity)
@@ -1049,40 +915,33 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
1049
915
  for (const mod of usageModules) {
1050
916
  const src = path.join(pkgPath, 'skywalk-sdd', 'lib', mod.src);
1051
917
  const dst = path.join(sharedLibDir, mod.src);
1052
- if (fs.existsSync(src)) {
1053
- fs.copyFileSync(src, dst);
918
+ if (deployRuntimeModule(src, dst, `skywalk-sdd/lib/${mod.src}(${mod.desc})`, `${mod.src} 源文件缺失,使用统计上报将不可用`)) {
1054
919
  deployedModules += 1;
1055
- vlog(` ✓ 部署 skywalk-sdd/lib/${mod.src}(${mod.desc})`);
1056
- } else {
1057
- console.log(` ⚠️ ${mod.src} 源文件缺失,使用统计上报将不可用`);
1058
920
  }
1059
921
  }
1060
922
 
1061
923
  // 部署 scale-thresholds.json(Simple/Full 模式判定阈值,opsx-propose §6.8 自动规模判定依赖)
1062
924
  const thresholdsSrc = path.join(pkgPath, 'lib', 'scale-thresholds.json');
1063
925
  const thresholdsDst = path.join(sharedLibDir, 'scale-thresholds.json');
1064
- if (fs.existsSync(thresholdsSrc)) {
1065
- fs.copyFileSync(thresholdsSrc, thresholdsDst);
926
+ if (deployRuntimeModule(thresholdsSrc, thresholdsDst, 'skywalk-sdd/lib/scale-thresholds.json(Simple/Full 判定阈值)', 'scale-thresholds.json 源文件缺失,propose 自动规模判定将使用内置默认值')) {
1066
927
  deployedModules += 1;
1067
- vlog(' ✓ 部署 skywalk-sdd/lib/scale-thresholds.json(Simple/Full 判定阈值)');
1068
- } else {
1069
- console.log(' ⚠️ scale-thresholds.json 源文件缺失,propose 自动规模判定将使用内置默认值');
1070
928
  }
1071
929
 
930
+ // 运行时身份:内容由生成器产出(版本号写死),升级时必须重写以反映当前 kld-sdd 版本
1072
931
  const packageVersion = require(path.join(pkgPath, 'package.json')).version;
1073
932
  const runtimeMetadataDst = path.join(dataDir, 'runtime-metadata.cjs');
1074
- fs.writeFileSync(
1075
- runtimeMetadataDst,
1076
- `'use strict';\nmodule.exports = { resolveRuntimeMetadata() { return { version: '${packageVersion}', producer: 'kld-sdd@${packageVersion}' }; } };\n`,
1077
- 'utf8',
1078
- );
933
+ deployFile(null, runtimeMetadataDst, {
934
+ strategy: STRATEGY.GENERATE,
935
+ label: 'runtime-metadata.cjs',
936
+ generate: () => `'use strict';\nmodule.exports = { resolveRuntimeMetadata() { return { version: '${packageVersion}', producer: 'kld-sdd@${packageVersion}' }; } };\n`,
937
+ });
1079
938
  deployedModules += 1;
1080
939
  vlog(` ✓ 部署 kld-sdd 运行时身份(${packageVersion})`);
1081
940
 
1082
941
  const reportingSrc = path.join(pkgPath, 'skywalk-sdd', 'reporting');
1083
942
  const reportingDst = path.join(dataDir, 'reporting');
1084
943
  if (fs.existsSync(reportingSrc)) {
1085
- copyDir(reportingSrc, reportingDst);
944
+ deployDir(reportingSrc, reportingDst, { strategy: STRATEGY.OVERWRITE });
1086
945
  deployedModules += 1;
1087
946
  vlog(' ✓ 部署 skywalk-sdd/reporting/(V3 单次变更报告)');
1088
947
  } else {
@@ -1091,18 +950,14 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
1091
950
 
1092
951
  const contextClientSrc = path.join(pkgPath, 'skywalk-sdd', 'context-client.cjs');
1093
952
  const contextClientDst = path.join(dataDir, 'context-client.cjs');
1094
- if (fs.existsSync(contextClientSrc)) {
1095
- fs.copyFileSync(contextClientSrc, contextClientDst);
953
+ if (deployRuntimeModule(contextClientSrc, contextClientDst, 'skywalk-sdd/context-client.cjs(工程 Spec 上下文客户端)', '工程 Spec 上下文客户端缺失')) {
1096
954
  deployedModules += 1;
1097
- vlog(' ✓ 部署 skywalk-sdd/context-client.cjs(工程 Spec 上下文客户端)');
1098
- } else {
1099
- console.log(` ⚠️ 工程 Spec 上下文客户端缺失: ${contextClientSrc}`);
1100
955
  }
1101
956
 
1102
957
  const ontologySrc = path.join(pkgPath, 'skywalk-sdd', 'ontology');
1103
958
  const ontologyDst = path.join(dataDir, 'ontology');
1104
959
  if (fs.existsSync(ontologySrc)) {
1105
- copyDir(ontologySrc, ontologyDst);
960
+ deployDir(ontologySrc, ontologyDst, { strategy: STRATEGY.OVERWRITE });
1106
961
  deployedModules += 1;
1107
962
  vlog(' ✓ 部署 skywalk-sdd/ontology/(本地本体语义运行时)');
1108
963
  } else {
@@ -1111,46 +966,32 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
1111
966
 
1112
967
  const worktreeFinishSrc = path.join(pkgPath, 'skywalk-sdd', 'apply-worktree-finish.cjs');
1113
968
  const worktreeFinishDst = path.join(dataDir, 'apply-worktree-finish.cjs');
1114
- if (fs.existsSync(worktreeFinishSrc)) {
1115
- fs.copyFileSync(worktreeFinishSrc, worktreeFinishDst);
969
+ if (deployRuntimeModule(worktreeFinishSrc, worktreeFinishDst, 'skywalk-sdd/apply-worktree-finish.cjs(Apply 收尾脚本)', 'apply-worktree-finish.cjs 源文件缺失,Apply 阶段收尾需要手动执行')) {
1116
970
  deployedModules += 1;
1117
- vlog(' ✓ 部署 skywalk-sdd/apply-worktree-finish.cjs(Apply 收尾脚本)');
1118
- } else {
1119
- console.log(' ⚠️ apply-worktree-finish.cjs 源文件缺失,Apply 阶段收尾需要手动执行');
1120
971
  }
1121
972
 
1122
973
  const openspecShimSrc = path.join(pkgPath, 'skywalk-sdd', 'openspec-shim.cjs');
1123
974
  const openspecShimDst = path.join(dataDir, 'openspec-shim.cjs');
1124
- if (fs.existsSync(openspecShimSrc)) {
1125
- fs.copyFileSync(openspecShimSrc, openspecShimDst);
975
+ if (deployRuntimeModule(openspecShimSrc, openspecShimDst, 'skywalk-sdd/openspec-shim.cjs(openspec 包裹包路径 shim)', 'openspec-shim.cjs 源文件缺失,openspec 命令需要手动 cd 到 spec 包执行')) {
1126
976
  deployedModules += 1;
1127
- vlog(' ✓ 部署 skywalk-sdd/openspec-shim.cjs(openspec 包裹包路径 shim)');
1128
- } else {
1129
- console.log(' ⚠️ openspec-shim.cjs 源文件缺失,openspec 命令需要手动 cd 到 spec 包执行');
1130
977
  }
1131
978
 
1132
979
  // 部署 kb-upload.cjs(知识库上传脚本,支持文件夹/zip 双模式随时入库)
1133
980
  const kbUploadSrc = path.join(pkgPath, 'skywalk-sdd', 'kb-upload.cjs');
1134
981
  const kbUploadDst = path.join(dataDir, 'kb-upload.cjs');
1135
- if (fs.existsSync(kbUploadSrc)) {
1136
- fs.copyFileSync(kbUploadSrc, kbUploadDst);
982
+ if (deployRuntimeModule(kbUploadSrc, kbUploadDst, 'skywalk-sdd/kb-upload.cjs(知识库上传:文件夹/zip 双模式)', 'kb-upload.cjs 源文件缺失,随时入库功能不可用')) {
1137
983
  deployedModules += 1;
1138
- vlog(' ✓ 部署 skywalk-sdd/kb-upload.cjs(知识库上传:文件夹/zip 双模式)');
1139
- } else {
1140
- console.log(' ⚠️ kb-upload.cjs 源文件缺失,随时入库功能不可用');
1141
984
  }
1142
985
 
1143
986
  // 部署 kb-sync-identity.cjs(KB 身份同步脚本,重新入库前同步 version-id)
1144
987
  const kbSyncIdentitySrc = path.join(pkgPath, 'skywalk-sdd', 'kb-sync-identity.cjs');
1145
988
  const kbSyncIdentityDst = path.join(dataDir, 'kb-sync-identity.cjs');
1146
- if (fs.existsSync(kbSyncIdentitySrc)) {
1147
- fs.copyFileSync(kbSyncIdentitySrc, kbSyncIdentityDst);
989
+ if (deployRuntimeModule(kbSyncIdentitySrc, kbSyncIdentityDst, 'skywalk-sdd/kb-sync-identity.cjs(KB 身份同步:重新入库前同步 version-id)', 'kb-sync-identity.cjs 源文件缺失,重新入库身份同步不可用')) {
1148
990
  deployedModules += 1;
1149
- vlog(' ✓ 部署 skywalk-sdd/kb-sync-identity.cjs(KB 身份同步:重新入库前同步 version-id)');
1150
- } else {
1151
- console.log(' ⚠️ kb-sync-identity.cjs 源文件缺失,重新入库身份同步不可用');
1152
991
  }
1153
992
 
993
+ require('./hook-install.js').deployRuntime(cwd, pkgPath);
994
+
1154
995
  console.log(` ✓ 部署 skywalk-sdd/ 运行时(events/state 数据目录 + ${deployedModules} 个模块)`);
1155
996
  vlog(' ✓ 调用方式: node skywalk-sdd/log.cjs start|end|metrics|semantic-identity|semantic-reconcile|semantic-check');
1156
997
  vlog(' ✓ openspec: node skywalk-sdd/openspec-shim.cjs list(自动 cd 到 *-sdd-specs)');
@@ -1161,76 +1002,12 @@ function deployTelemetryDataDir(targetCwd = process.cwd()) {
1161
1002
  return true;
1162
1003
  }
1163
1004
 
1164
- /**
1165
- * 安装 SDD shell hook(多脚本调用型 hook,如 pre-commit / pre-push / commit-msg)。
1166
- * 直接部署完整的 shell 脚本到 .git/hooks/,脚本内部远程引用 spec 仓的 .cjs 脚本。
1167
- *
1168
- * shell hook 运行时通过 git config sdd.specPath / .sdd-spec-root / 向上搜索
1169
- * 定位 spec 仓的 skywalk-sdd/git-hooks/ 目录,兼容单仓/多仓/mono 布局。
1170
- *
1171
- * @param {string} cwd - Git 仓库根目录
1172
- * @param {string} hookName - hook 名称(如 'pre-commit'、'pre-push')
1173
- * @param {string} pkgPath - kld-sdd 包路径(用于读取模板)
1174
- */
1175
- function installSddShellHook(cwd, hookName, pkgPath = getPackagePath()) {
1176
- const hooksDir = path.join(cwd, '.git', 'hooks');
1177
- if (!fs.existsSync(hooksDir)) {
1178
- return;
1179
- }
1180
-
1181
- const templatePath = path.join(pkgPath, 'templates', 'git-hooks', hookName);
1182
- if (!fs.existsSync(templatePath)) {
1183
- console.log(` ⚠️ Hook 模板不存在: ${templatePath}`);
1184
- return;
1185
- }
1186
-
1187
- const hookPath = path.join(hooksDir, hookName);
1188
- const marker = 'KLD SDD quality gate';
1189
- const content = fs.readFileSync(templatePath, 'utf8');
1190
-
1191
- if (fs.existsSync(hookPath)) {
1192
- const existing = fs.readFileSync(hookPath, 'utf8');
1193
- if (existing.includes(marker)) {
1194
- // 已是 SDD hook:允许覆盖升级
1195
- fs.writeFileSync(hookPath, content, 'utf8');
1196
- try {
1197
- // 0o700:仅属主可执行(git 以属主身份执行 hook),避免 S2612 对组/其他用户放权的告警
1198
- fs.chmodSync(hookPath, 0o700);
1199
- } catch {
1200
- // ignore
1201
- }
1202
- console.log(` ✓ 更新 .git/hooks/${hookName} SDD 质量门禁(多脚本)`);
1203
- return;
1204
- }
1205
- console.log(` ℹ️ .git/hooks/${hookName} 已存在且非 SDD 受管 hook,已保留不覆盖`);
1206
- return;
1207
- }
1208
-
1209
- fs.writeFileSync(hookPath, content, 'utf8');
1210
- try {
1211
- fs.chmodSync(hookPath, 0o755);
1212
- } catch {
1213
- // Windows 上 chmod 可能没有实际效果
1214
- }
1215
- console.log(` ✓ 安装 .git/hooks/${hookName} SDD 质量门禁(多脚本)`);
1005
+ /** 安全升级 commit-msg、备份并退役受管旧门禁,保留用户 Hook 与管理器。 */
1006
+ function installSddShellHook(cwd, hookName, pkgPath = getPackagePath(), configFile) {
1007
+ return require('./hook-install.js').updateHook(cwd, hookName, pkgPath, configFile);
1216
1008
  }
1217
1009
 
1218
- /**
1219
- * 部署代码仓全部 Git hooks(commit-msg + pre-commit + pre-push)。
1220
- *
1221
- * 统一入口,封装三种 hook 的部署逻辑。代码仓**不落 skywalk-sdd/**,
1222
- * 只在 .git/hooks/ 安装 shell hook 模板,远程引用 spec 仓的 .cjs 脚本:
1223
- *
1224
- * - **spec 仓**:确保 skywalk-sdd/git-hooks/ 下有全部 .cjs 脚本
1225
- * (commit-msg-sdd-trailer + pre-commit-* + pre-push-* + ontology)。
1226
- * - **代码仓 .git/hooks/**:仅安装 3 个 shell hook 模板,运行时通过
1227
- * git config sdd.specPath / .sdd-spec-root / 向上搜索定位 spec 仓的脚本。
1228
- *
1229
- * @param {string} codeRepoRoot - 代码仓根目录
1230
- * @param {string} specRepoRoot - spec 包裹包根目录
1231
- * @param {object} options - { pkgPath }
1232
- * @returns {{ ok: boolean, message?: string }}
1233
- */
1010
+ /** 代码仓只安装 commit-msg;验证脚本集中部署在 Spec 包。 */
1234
1011
  function deployCodeRepoHooks(codeRepoRoot, specRepoRoot, options = {}) {
1235
1012
  const cwd = path.resolve(codeRepoRoot);
1236
1013
  const specRoot = path.resolve(specRepoRoot);
@@ -1240,32 +1017,11 @@ function deployCodeRepoHooks(codeRepoRoot, specRepoRoot, options = {}) {
1240
1017
  return { ok: false, message: '不是 Git 仓库' };
1241
1018
  }
1242
1019
 
1243
- // ── 1. 确保 spec 仓有全部 hook 脚本 + ontology ──
1244
- // commit-msg .cjs + ontology(ensureSpecCommitMsgScript 已处理)
1245
1020
  ensureSpecCommitMsgScript(specRoot, pkgPath);
1246
- // pre-commit / pre-push .cjs 脚本
1247
- const specHooksDir = path.join(specRoot, 'skywalk-sdd', 'git-hooks');
1248
- const gitHooksTemplateDir = path.join(pkgPath, 'templates', 'git-hooks');
1249
- if (!fs.existsSync(specHooksDir)) {
1250
- fs.mkdirSync(specHooksDir, { recursive: true });
1251
- }
1252
- // 复制全部 .cjs 脚本 + hooks.config 配置文件
1253
- const hookScripts = fs.existsSync(gitHooksTemplateDir)
1254
- ? fs.readdirSync(gitHooksTemplateDir).filter(f => f.endsWith('.cjs') || f === 'hooks.config')
1255
- : [];
1256
- for (const script of hookScripts) {
1257
- const src = path.join(gitHooksTemplateDir, script);
1258
- const dst = path.join(specHooksDir, script);
1259
- // 始终覆盖,与 shell hook 的 marker 覆盖策略保持一致
1260
- fs.copyFileSync(src, dst);
1261
- }
1262
- console.log(` ✓ 确保 spec 仓 skywalk-sdd/git-hooks/ 脚本齐全(${hookScripts.length} 个文件)`);
1263
-
1264
- // ── 2. 安装三个 shell hooks 到代码仓 .git/hooks/ ──
1265
- // 模板 hook 运行时自动定位 spec 仓的 .cjs 脚本
1266
- installSddShellHook(cwd, 'commit-msg', pkgPath);
1267
- installSddShellHook(cwd, 'pre-commit', pkgPath);
1268
- installSddShellHook(cwd, 'pre-push', pkgPath);
1021
+ const configFile = require('./hook-install.js').deployRuntime(specRoot, pkgPath);
1022
+ for (const name of ['commit-msg', 'pre-commit', 'pre-push']) {
1023
+ installSddShellHook(cwd, name, pkgPath, configFile);
1024
+ }
1269
1025
 
1270
1026
  return { ok: true };
1271
1027
  }
@@ -1285,71 +1041,28 @@ function ensureSpecCommitMsgScript(specRepoRoot, pkgPath = getPackagePath()) {
1285
1041
  const ontologySrc = path.join(pkgPath, 'skywalk-sdd', 'ontology');
1286
1042
  const ontologyDst = path.join(specRoot, 'skywalk-sdd', 'ontology');
1287
1043
  if (fs.existsSync(ontologySrc) && !fs.existsSync(path.join(ontologyDst, 'change-key.cjs'))) {
1288
- copyDir(ontologySrc, ontologyDst);
1044
+ deployDir(ontologySrc, ontologyDst, { strategy: STRATEGY.OVERWRITE });
1289
1045
  }
1290
1046
  fs.mkdirSync(path.dirname(scriptPath), { recursive: true });
1291
1047
  fs.copyFileSync(src, scriptPath);
1292
1048
  return { ok: true, scriptPath, deployed: true };
1293
1049
  }
1294
1050
 
1295
- /**
1296
- * 部署 Git hooks / CI 兜底模板
1297
- * 这些脚本只做质量门禁和 CI 补充记录,不替代 OPSX 主采集链路。
1298
- *
1299
- * @param {string} [targetCwd]
1300
- * @param {{ mode?: 'full'|'commit-msg-only' }} [options]
1301
- * full: spec 包裹包(pre-commit/pre-push/commit-msg + CI)
1302
- * commit-msg-only: 仅安装 commit-msg hook(跳过 pre-commit/pre-push)
1303
- */
1051
+ /** 部署验证运行时与轻量 Hook;不自动启用 CI。options 为旧调用兼容保留。 */
1304
1052
  function deployQualityGateTemplates(targetCwd = process.cwd(), options = {}) {
1305
1053
  const pkgPath = getPackagePath();
1306
1054
  const cwd = path.resolve(targetCwd);
1307
- const mode = options.mode || 'full';
1308
- // U4: 非 Git 项目跳过 Git hooks / CI 兜底模板(按需部署,不污染非 Git 项目根)
1309
- if (!fs.existsSync(path.join(cwd, '.git'))) {
1310
- console.log('🧰 非 Git 项目,跳过 Git hooks / CI 兜底模板部署');
1311
- return false;
1312
- }
1313
- console.log(`🧰 正在部署 SDD Git hooks(${mode}): ${cwd}`);
1314
- const dataDir = path.join(cwd, 'skywalk-sdd');
1315
- const gitHooksTemplateDir = path.join(pkgPath, 'templates', 'git-hooks');
1316
- const ciTemplateDir = path.join(pkgPath, 'templates', 'ci');
1317
- const targetGitHooksDir = path.join(dataDir, 'git-hooks');
1318
- const targetCiDir = path.join(dataDir, 'ci');
1319
-
1320
- if (fs.existsSync(gitHooksTemplateDir)) {
1321
- // 复制 .cjs 脚本 + hooks.config 到 skywalk-sdd/git-hooks/
1322
- // shell hook 模板(commit-msg/pre-commit/pre-push)不落此目录
1323
- if (!fs.existsSync(targetGitHooksDir)) {
1324
- fs.mkdirSync(targetGitHooksDir, { recursive: true });
1325
- }
1326
- for (const file of fs.readdirSync(gitHooksTemplateDir)) {
1327
- if (file.endsWith('.cjs') || file === 'hooks.config') {
1328
- fs.copyFileSync(path.join(gitHooksTemplateDir, file), path.join(targetGitHooksDir, file));
1329
- }
1330
- }
1331
- console.log(' ✓ 部署 skywalk-sdd/git-hooks/(.cjs 脚本 + hooks.config)');
1332
- } else {
1333
- console.log(` ⚠️ Git hooks 模板缺失: ${gitHooksTemplateDir}`);
1334
- }
1335
-
1336
- if (mode === 'full' && fs.existsSync(ciTemplateDir)) {
1337
- copyDir(ciTemplateDir, targetCiDir);
1338
- console.log(' ✓ 部署 skywalk-sdd/ci/');
1055
+ const configFile = require('./hook-install.js').deployRuntime(cwd, pkgPath);
1056
+ if (!workspaceLayout.isGitRepo(cwd)) return false;
1057
+ for (const name of ['commit-msg', 'pre-commit', 'pre-push']) {
1058
+ installSddShellHook(cwd, name, pkgPath, configFile);
1339
1059
  }
1340
-
1341
- if (mode === 'full') {
1342
- installSddShellHook(cwd, 'pre-commit', pkgPath);
1343
- installSddShellHook(cwd, 'pre-push', pkgPath);
1344
- }
1345
- installSddShellHook(cwd, 'commit-msg', pkgPath);
1346
-
1347
- console.log('✅ Git hooks 已就绪');
1060
+ // CI is an explicit team integration; installing SDD does not enable a merge gate.
1348
1061
  return true;
1349
1062
  }
1350
1063
 
1351
1064
  /**
1352
- * 代码仓轻量接入:写 .sdd.yaml + sdd.specPath + .git/hooks/(commit-msg + pre-commit + pre-push)。
1065
+ * 代码仓轻量接入:写 .sdd.yaml + sdd.specPath + 轻量 commit-msg。
1353
1066
  * 代码仓不落 skywalk-sdd/,shell hook 远程引用 spec 仓 skywalk-sdd/git-hooks/ 下的 .cjs 脚本。
1354
1067
  */
1355
1068
  function attachCodeRepoLite(codeRepoRoot, specRepoRoot, options = {}) {
@@ -1364,21 +1077,22 @@ function attachCodeRepoLite(codeRepoRoot, specRepoRoot, options = {}) {
1364
1077
  return { ok: false, repo: label, message: `spec 路径不是 Git 仓库: ${specRoot}` };
1365
1078
  }
1366
1079
 
1367
- console.log(`🔗 轻量接入代码仓(commit-msg + pre-commit + pre-push): ${label}`);
1080
+ console.log(`🔗 轻量接入代码仓(本地关联 Hook): ${label}`);
1368
1081
 
1369
1082
  const remote = sddConfig.gitRemoteUrl(specRoot) || 'git@gitlab.example.com:biz/xxx-sdd-specs.git';
1370
1083
  const sddYamlPath = path.join(cwd, '.sdd.yaml');
1371
- if (!fs.existsSync(sddYamlPath)) {
1372
- fs.writeFileSync(
1373
- sddYamlPath,
1374
- [
1375
- '# 代码仓库关联团队 spec Git 仓库(个人绝对路径勿写入本文件)',
1376
- 'version: 1',
1377
- `spec_repository: ${remote}`,
1378
- '',
1379
- ].join('\n'),
1380
- 'utf8',
1381
- );
1084
+ // 用户产物 → SKIP_IF_EXISTS:代码仓的 spec_repository 由团队填写,升级不覆盖
1085
+ const yamlResult = deployFile(null, sddYamlPath, {
1086
+ strategy: STRATEGY.SKIP_IF_EXISTS,
1087
+ label: '.sdd.yaml',
1088
+ generate: () => [
1089
+ '# 代码仓库关联团队 spec Git 仓库(个人绝对路径勿写入本文件)',
1090
+ 'version: 1',
1091
+ `spec_repository: ${remote}`,
1092
+ '',
1093
+ ].join('\n'),
1094
+ });
1095
+ if (yamlResult.action === ACTION.CREATED) {
1382
1096
  console.log(` ✓ 写入 .sdd.yaml → ${remote}`);
1383
1097
  } else {
1384
1098
  console.log(' ✓ .sdd.yaml 已存在,跳过覆盖');
@@ -1390,14 +1104,15 @@ function attachCodeRepoLite(codeRepoRoot, specRepoRoot, options = {}) {
1390
1104
  }
1391
1105
  console.log(` ✓ sdd.specPath = ${linked.path}`);
1392
1106
 
1393
- // 部署代码仓全部 Git hooks(commit-msg + pre-commit + pre-push)
1107
+ // 部署轻量关联 Hook,并迁移可识别的旧门禁
1394
1108
  deployCodeRepoHooks(cwd, specRoot, options);
1395
1109
 
1396
1110
  return { ok: true, repo: label, path: cwd, specPath: linked.path };
1397
1111
  }
1398
1112
 
1399
1113
  /**
1400
- * 部署 modules.yaml 到 spec 仓库根目录(若不存在)
1114
+ * 部署 modules.yaml 到 spec 仓库根目录(用户产物 → SKIP_IF_EXISTS)
1115
+ * 模块代号由项目团队维护,升级不得覆盖。
1401
1116
  */
1402
1117
  function deployModulesYaml(cwd = process.cwd(), pkgPath = getPackagePath()) {
1403
1118
  console.log('📦 正在检查 modules.yaml...');
@@ -1407,18 +1122,22 @@ function deployModulesYaml(cwd = process.cwd(), pkgPath = getPackagePath()) {
1407
1122
  console.log(`⚠️ modules.yaml 模板缺失: ${source}`);
1408
1123
  return false;
1409
1124
  }
1410
- if (fs.existsSync(target)) {
1411
- console.log(' ✓ modules.yaml 已存在,跳过');
1125
+ const result = deployFile(source, target, {
1126
+ strategy: STRATEGY.SKIP_IF_EXISTS,
1127
+ label: 'modules.yaml',
1128
+ });
1129
+ if (result.action === ACTION.SKIPPED) {
1130
+ console.log(' ✓ modules.yaml 已存在(用户产物),跳过');
1412
1131
  return true;
1413
1132
  }
1414
- fs.copyFileSync(source, target);
1415
1133
  console.log(`✅ 已创建 modules.yaml: ${target}`);
1416
1134
  return true;
1417
1135
  }
1418
1136
 
1419
1137
  /**
1420
- * 部署 sdd.config.yaml(活动变更登记表)到 spec 仓库根目录(若不存在)
1421
- * propose 隐式写入、archive 隐式移除;代码仓 AI 与 commit-msg Hook 读取
1138
+ * 部署 sdd.config.yaml(活动变更登记表)到 spec 仓库根目录(用户产物 → SKIP_IF_EXISTS)
1139
+ * propose 隐式写入、archive 隐式移除;代码仓 AI 与 commit-msg Hook 读取。
1140
+ * 存在即跳过:避免清空 active_changes 导致活跃变更登记静默丢失。
1422
1141
  */
1423
1142
  function deploySddConfigYaml(cwd = process.cwd(), pkgPath = getPackagePath()) {
1424
1143
  console.log('📦 正在检查 sdd.config.yaml...');
@@ -1428,40 +1147,25 @@ function deploySddConfigYaml(cwd = process.cwd(), pkgPath = getPackagePath()) {
1428
1147
  console.log(`⚠️ sdd.config.yaml 模板缺失: ${source}`);
1429
1148
  return false;
1430
1149
  }
1431
- if (fs.existsSync(target)) {
1432
- console.log(' ✓ sdd.config.yaml 已存在,跳过');
1150
+ const result = deployFile(source, target, {
1151
+ strategy: STRATEGY.SKIP_IF_EXISTS,
1152
+ label: 'sdd.config.yaml',
1153
+ });
1154
+ if (result.action === ACTION.SKIPPED) {
1155
+ console.log(' ✓ sdd.config.yaml 已存在(含活跃变更登记),跳过');
1433
1156
  return true;
1434
1157
  }
1435
- fs.copyFileSync(source, target);
1436
1158
  console.log(`✅ 已创建 sdd.config.yaml: ${target}`);
1437
1159
  return true;
1438
1160
  }
1439
1161
 
1440
- /**
1441
- * 部署 .sdd.yaml 示例到代码仓库(若不存在且不像 spec 仓)
1442
- * spec 仓特征:存在 openspec/changes 或 modules.yaml
1443
- */
1444
- function deployDotSddYaml(cwd = process.cwd(), pkgPath = getPackagePath()) {
1445
- const hasSpecLayout =
1446
- fs.existsSync(path.join(cwd, 'openspec', 'changes')) ||
1447
- fs.existsSync(path.join(cwd, 'modules.yaml'));
1448
- if (hasSpecLayout) {
1449
- return false;
1450
- }
1451
- const source = path.join(pkgPath, 'templates', 'dot-sdd.yaml');
1452
- const target = path.join(cwd, '.sdd.yaml');
1453
- if (!fs.existsSync(source) || fs.existsSync(target)) {
1454
- return false;
1455
- }
1456
- console.log('🔗 检测到代码仓库布局,部署 .sdd.yaml 示例...');
1457
- fs.copyFileSync(source, target);
1458
- console.log(`✅ 已创建 .sdd.yaml(请修改 spec_repository): ${target}`);
1459
- return true;
1460
- }
1461
-
1462
1162
  /**
1463
1163
  * 单仓声明 layout: mono,并记录仓内统一包裹目录相对路径。
1464
1164
  * commit-msg Hook 靠它跳过「spec 工作区干净」检查并只写 Spec-Change。
1165
+ *
1166
+ * 策略为 MERGE:`.sdd.yaml` 是用户产物,只在缺失时创建;
1167
+ * 已存在时仅当「已声明 mono 但 spec_path 缺失/不符」才定向补写该字段,
1168
+ * 其余内容(用户注释、自定义键)原样保留。
1465
1169
  */
1466
1170
  function deployMonoSddYaml(cwd = process.cwd(), options = {}) {
1467
1171
  const target = path.join(cwd, '.sdd.yaml');
@@ -1469,27 +1173,6 @@ function deployMonoSddYaml(cwd = process.cwd(), options = {}) {
1469
1173
  ? String(options.specPathRelative).replace(/\\/g, '/')
1470
1174
  : '';
1471
1175
 
1472
- if (fs.existsSync(target)) {
1473
- const existing = fs.readFileSync(target, 'utf8');
1474
- if (/^layout:\s*mono\s*$/m.test(existing)) {
1475
- if (specRel && !new RegExp(`^spec_path:\\s*${specRel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`, 'm').test(existing)) {
1476
- // 已有 mono 但缺/错 spec_path:补写相对路径
1477
- let next = existing.replace(/\s*$/, '');
1478
- if (/^spec_path:\s*.+$/m.test(next)) {
1479
- next = next.replace(/^spec_path:\s*.+$/m, `spec_path: ${specRel}`);
1480
- } else {
1481
- next = `${next}\nspec_path: ${specRel}`;
1482
- }
1483
- fs.writeFileSync(target, `${next}\n`, 'utf8');
1484
- console.log(` ✓ 已更新 .sdd.yaml spec_path → ${specRel}`);
1485
- return true;
1486
- }
1487
- console.log(' ✓ .sdd.yaml 已声明 layout: mono,跳过');
1488
- return true;
1489
- }
1490
- console.log('⚠️ .sdd.yaml 已存在但未声明 layout: mono;单仓请手动补 `layout: mono`');
1491
- return false;
1492
- }
1493
1176
  const lines = [
1494
1177
  '# 单仓布局:业务代码与 SDD 包裹包在同一个 Git 仓库',
1495
1178
  '# SDD 内容统一在 spec_path 目录内(openspec / modules / sdd.config / 模版)',
@@ -1499,7 +1182,67 @@ function deployMonoSddYaml(cwd = process.cwd(), options = {}) {
1499
1182
  ];
1500
1183
  if (specRel) lines.push(`spec_path: ${specRel}`);
1501
1184
  lines.push('');
1502
- fs.writeFileSync(target, lines.join('\n'), 'utf8');
1185
+
1186
+ const result = deployFile(null, target, {
1187
+ strategy: STRATEGY.MERGE,
1188
+ label: '.sdd.yaml',
1189
+ mergeFn: ({ readTarget }) => {
1190
+ let existing = readTarget();
1191
+ if (existing === null) {
1192
+ // 不存在 → 首次创建
1193
+ fs.writeFileSync(target, lines.join('\n'), 'utf8');
1194
+ return { ok: true, action: ACTION.CREATED };
1195
+ }
1196
+
1197
+ // 仍是模板占位(代码仓示例)→ 备份后转为单仓声明。
1198
+ // 必须先于 mono 判定:占位内容不含 layout,否则会被下面的
1199
+ // 「未声明 mono」分支挡掉,单仓初始化直接失败。
1200
+ const placeholder = fs.readFileSync(path.join(getPackagePath(), 'templates/dot-sdd.yaml'), 'utf8');
1201
+ if (existing === placeholder && specRel) {
1202
+ fs.copyFileSync(target, `${target}.pre-mono.bak`);
1203
+ existing = 'version: 1\nlayout: mono\n';
1204
+ fs.writeFileSync(target, existing, 'utf8');
1205
+ }
1206
+
1207
+ if (!/^layout:\s*mono\s*$/m.test(existing)) {
1208
+ return {
1209
+ ok: false,
1210
+ error: '.sdd.yaml 已存在但未声明 layout: mono;单仓请手动补 `layout: mono`',
1211
+ };
1212
+ }
1213
+
1214
+ const specPathPattern = new RegExp(
1215
+ `^spec_path:\\s*${specRel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
1216
+ 'm',
1217
+ );
1218
+ if (!specRel || specPathPattern.test(existing)) {
1219
+ return { ok: true, action: ACTION.SKIPPED };
1220
+ }
1221
+
1222
+ // 已有 mono 但缺/错 spec_path:只定向补写该字段,保留其余内容
1223
+ let next = existing.replace(/\s*$/, '');
1224
+ if (/^spec_path:\s*.+$/m.test(next)) {
1225
+ next = next.replace(/^spec_path:\s*.+$/m, `spec_path: ${specRel}`);
1226
+ } else {
1227
+ next = `${next}\nspec_path: ${specRel}`;
1228
+ }
1229
+ fs.writeFileSync(target, `${next}\n`, 'utf8');
1230
+ return { ok: true, action: ACTION.MERGED, message: `已更新 .sdd.yaml spec_path → ${specRel}` };
1231
+ },
1232
+ });
1233
+
1234
+ if (!result.ok) {
1235
+ console.log(`⚠️ ${result.message}`);
1236
+ return false;
1237
+ }
1238
+ if (result.action === ACTION.MERGED) {
1239
+ console.log(` ✓ ${result.message}`);
1240
+ return true;
1241
+ }
1242
+ if (result.action === ACTION.SKIPPED) {
1243
+ console.log(' ✓ .sdd.yaml 已声明 layout: mono,跳过');
1244
+ return true;
1245
+ }
1503
1246
  console.log(`✅ 已创建 .sdd.yaml(layout: mono${specRel ? `, spec_path: ${specRel}` : ''}): ${target}`);
1504
1247
  return true;
1505
1248
  }
@@ -1540,6 +1283,7 @@ async function populateSpecPackage(specRoot, options = {}) {
1540
1283
 
1541
1284
  /**
1542
1285
  * 在工作区 / Git 根写入 .sdd-spec-root,供 shell 与 skills 引用相对包裹包路径。
1286
+ * 内容完全由目录关系推导(派生状态)→ GENERATE,总是重写。
1543
1287
  */
1544
1288
  function writeSddSpecRootHint(anchorDir, specPackageAbs) {
1545
1289
  const anchor = path.resolve(anchorDir);
@@ -1547,7 +1291,11 @@ function writeSddSpecRootHint(anchorDir, specPackageAbs) {
1547
1291
  const rel = path.relative(anchor, specAbs).replace(/\\/g, '/');
1548
1292
  if (!rel || rel.startsWith('..')) return null;
1549
1293
  const filePath = path.join(anchor, '.sdd-spec-root');
1550
- fs.writeFileSync(filePath, `${rel}\n`, 'utf8');
1294
+ deployFile(null, filePath, {
1295
+ strategy: STRATEGY.GENERATE,
1296
+ label: '.sdd-spec-root',
1297
+ generate: () => `${rel}\n`,
1298
+ });
1551
1299
  console.log(` ✓ 已写入 .sdd-spec-root → ${rel}`);
1552
1300
  return filePath;
1553
1301
  }
@@ -1612,20 +1360,6 @@ function removeOuterSkywalk(anchorDir) {
1612
1360
  }
1613
1361
  }
1614
1362
 
1615
- /**
1616
- * 检测当前项目已初始化的 AI 编辑器
1617
- */
1618
- function detectTools() {
1619
- const detected = [];
1620
- const cwd = process.cwd();
1621
- for (const [key, config] of Object.entries(TOOL_CONFIGS)) {
1622
- if (fs.existsSync(path.join(cwd, config.configDir))) {
1623
- detected.push(key);
1624
- }
1625
- }
1626
- return detected;
1627
- }
1628
-
1629
1363
  /**
1630
1364
  * 创建 / 更新 .gitignore
1631
1365
  * @param {string} [cwd=process.cwd()]
@@ -1643,8 +1377,6 @@ function updateGitignore(cwd = process.cwd()) {
1643
1377
  '.agents/commands/personal-*',
1644
1378
  'kb-state.json',
1645
1379
  '**/kb-state.json',
1646
- 'skywalk-sdd/kb-state.json',
1647
- '**/skywalk-sdd/kb-state.json',
1648
1380
  'skywalk-sdd/events/',
1649
1381
  'skywalk-sdd/state/',
1650
1382
  '**/skywalk-sdd/events/',
@@ -1662,11 +1394,9 @@ function updateGitignore(cwd = process.cwd()) {
1662
1394
  .workbuddy/commands/personal-*
1663
1395
  .agents/commands/personal-*
1664
1396
 
1665
- # KB 绑定状态(含 API Key,勿提交)
1397
+ # KB 绑定状态(含 API Key,勿提交;唯一权威位置 = spec 包裹包根目录)
1666
1398
  kb-state.json
1667
1399
  **/kb-state.json
1668
- skywalk-sdd/kb-state.json
1669
- **/skywalk-sdd/kb-state.json
1670
1400
 
1671
1401
  # SDD Telemetry 数据(实体在 *-sdd-specs/skywalk-sdd/;工作区可能是 symlink)
1672
1402
  skywalk-sdd/events/
@@ -1678,138 +1408,161 @@ skywalk-sdd/state/
1678
1408
  .worktrees/
1679
1409
  `;
1680
1410
 
1681
- if (fs.existsSync(gitignorePath)) {
1682
- const content = fs.readFileSync(gitignorePath, 'utf-8');
1683
- if (!content.includes('KLD SDD')) {
1684
- fs.appendFileSync(gitignorePath, sddConfig);
1685
- console.log('✅ 已更新 .gitignore');
1686
- } else {
1687
- const missingLines = requiredLines.filter(line => !content.includes(line));
1688
- if (missingLines.length > 0) {
1689
- fs.appendFileSync(gitignorePath, `\n# KLD SDD 补充忽略规则\n${missingLines.join('\n')}\n`);
1690
- console.log('✅ 已补充 .gitignore');
1691
- }
1692
- }
1693
- } else {
1694
- fs.writeFileSync(gitignorePath, sddConfig.trim());
1411
+ const result = deployFile(null, gitignorePath, {
1412
+ strategy: STRATEGY.APPEND_LINES,
1413
+ label: '.gitignore',
1414
+ lines: requiredLines,
1415
+ block: sddConfig,
1416
+ requiresMarker: 'KLD SDD',
1417
+ supplementHeader: '# KLD SDD 补充忽略规则',
1418
+ });
1419
+
1420
+ if (!result.ok) {
1421
+ console.log(`⚠️ 更新 .gitignore 失败: ${result.message}`);
1422
+ return result;
1423
+ }
1424
+ if (result.action === ACTION.CREATED) {
1695
1425
  console.log('✅ 已创建 .gitignore');
1426
+ } else if (result.action === ACTION.APPENDED) {
1427
+ console.log(`✅ 已${result.message && result.message.includes('完整') ? '更新' : '补充'} .gitignore`);
1696
1428
  }
1697
- }
1698
-
1699
- /**
1700
- * 显示帮助信息
1701
- */
1702
- function showHelp() {
1703
- console.log(`
1704
- KLD SDD 项目初始化工具
1705
-
1706
- 用法:
1707
- kld-sdd-init [选项]
1708
- npx kld-sdd [选项]
1709
- kld-sdd sync-repos # 工作目录下发现新增代码仓并轻量接入
1710
-
1711
- 选项:
1712
- -h, --help 显示帮助信息
1713
- -v, --version 显示版本号
1714
- --verbose 输出每个安装文件/目录的明细日志(默认仅摘要,亦可用 KLD_SDD_VERBOSE=1)
1715
- --skip-openspec 跳过 openspec init 步骤
1716
- --skip-template 跳过复制内置模版
1717
- --tool <name> 指定编辑器,可用值: cursor, claude, codebuddy, qoder, opencode, kunlunzhima, workbuddy, codex, all
1718
- --spec-path <dir> 指定 spec 包裹包目录(必须是当前目录的子目录)。
1719
- 不指定时(交互终端):自动检测已有 spec 目录(目录名 *-sdd-specs,
1720
- 或 modules.yaml + sdd.config.yaml + openspec/changes + openspec/specs/overview.md 齐全),
1721
- 检测到则询问是否使用;未检测到则要求输入相对路径(回车用默认 <当前目录名>-sdd-specs)。
1722
- 例: 在 erp-klny-all 下 --spec-path erp-sdd-specs
1723
-
1724
- 工作区模式(在个人工作目录执行):
1725
- - skills / 编辑器配置只部署一次到当前工作目录
1726
- - 自动发现子目录 Git 仓库
1727
- - spec 仓:modules.yaml / openspec 模板 / 文档门禁
1728
- - 代码仓:仅 commit-msg Hook + .sdd.yaml + link-spec(不装 skills)
1729
- - Trailer 数据仍在代码 commit 时由 Hook 写入
1730
-
1731
- 示例:
1732
- kld-sdd-init # 完整初始化流程
1733
- kld-sdd-init --skip-openspec # 跳过 openspec,直接部署 skills
1734
- kld-sdd-init --tool codex # 仅部署 Codex 配置
1735
- kld-sdd-init --spec-path erp-sdd-specs # 指定 spec 目录(当前目录的子目录)
1736
- kld-sdd sync-repos # 新代码仓加入后同步接入
1737
- `);
1429
+ return result;
1738
1430
  }
1739
1431
 
1740
1432
  /**
1741
1433
  * 工作区:为已发现的代码仓做轻量接入,并刷新 .sdd-workspace.yaml
1434
+ *
1435
+ * spec 定位遵循与 init / uninstall 相同的「候选集合 → 用户单选」契约:
1436
+ * - `options.specRoot` 已由 CLI 层确认 → 直接采用(不重新探测)
1437
+ * - 未提供 → 枚举候选;**唯一候选**自动采用,**多候选**返回错误交由调用方
1438
+ * 裁决(不静默挑一个)
1439
+ *
1440
+ * 为什么多候选必须停下:本函数会把 spec 根路径**永久写进每个代码仓**的
1441
+ * `git config sdd.specPath`(见 attachCodeRepoLite),挑错等于污染所有代码仓
1442
+ * 的关联关系。历史上这里直接取 `layout.specRepo`(多候选时静默挑第一个),
1443
+ * 与 init / uninstall 的交互契约不一致。
1444
+ *
1445
+ * @param {string} [workspaceRoot]
1446
+ * @param {{onlyNew?: boolean, specRoot?: string}} [options]
1447
+ * @returns {{ok: boolean, attached: Array, failed?: Array, layout: object,
1448
+ * specRoot?: string|null, filePath?: string, message?: string}}
1742
1449
  */
1743
1450
  function syncWorkspaceRepos(workspaceRoot = process.cwd(), options = {}) {
1744
1451
  const root = path.resolve(workspaceRoot);
1745
1452
  const { layout, newcomers, saved } = workspaceLayout.findNewCodeRepos(root);
1746
1453
  const onlyNew = options.onlyNew !== false;
1747
1454
 
1748
- if (!layout.specRepo) {
1749
- console.log('❌ 未发现 spec 仓库(需子目录含 modules.yaml / openspec/changes / *-sdd-specs)');
1750
- return { ok: false, attached: [], layout };
1455
+ // ── spec 定位:显式指定 > 唯一候选自动采用 > 多候选交调用方裁决 ──
1456
+ let specRootAbs = options.specRoot ? path.resolve(options.specRoot) : null;
1457
+ if (!specRootAbs) {
1458
+ const candidates = specDetect.detectSpecCandidatesWithGit(root);
1459
+ if (candidates.length === 0) {
1460
+ console.log('❌ 未发现 spec 仓库(需子目录含 modules.yaml / openspec/changes / *-sdd-specs)');
1461
+ return { ok: false, attached: [], layout, specRoot: null };
1462
+ }
1463
+ if (candidates.length > 1) {
1464
+ // 交由 CLI 层让用户单选后带 specRoot 重新调用;此处不猜。
1465
+ return {
1466
+ ok: false,
1467
+ attached: [],
1468
+ layout,
1469
+ specRoot: null,
1470
+ candidates,
1471
+ message: `检测到 ${candidates.length} 个 spec 仓库候选,需用户单选`,
1472
+ };
1473
+ }
1474
+ specRootAbs = candidates[0].abs;
1751
1475
  }
1752
1476
 
1477
+ const specName = path.basename(specRootAbs);
1478
+
1479
+ // 代码仓清单必须排除**全部** spec 候选,而不只是被选中的那个。
1480
+ //
1481
+ // 原因:`detectWorkspaceLayout` 的 codeRepos 由 spec 反推
1482
+ //(`children.filter(c => c.abs !== spec.abs)`),只排除它自己挑中的那一个。
1483
+ // 其余候选(如用户复制的 `erp-sdd-specs copy`、未被选中的另一个 spec 包)
1484
+ // 会落进 codeRepos,进而被 attachCodeRepoLite 写入 `.sdd.yaml` 并挂代码仓
1485
+ // hook —— 那不是代码仓,是 spec 仓库/备份。uninstall 侧已有同样的排除逻辑
1486
+ //(见 lib/uninstall.js 的 specDirs 集合)。
1487
+ const specDirs = new Set(
1488
+ specDetect.detectSpecCandidatesWithGit(root).map((c) => path.resolve(c.abs)),
1489
+ );
1490
+ specDirs.add(specRootAbs);
1491
+ const codeRepos = layout.codeRepos.filter(
1492
+ (c) => !specDirs.has(path.resolve(c.abs)),
1493
+ );
1494
+
1753
1495
  console.log(`📂 工作目录: ${root}`);
1754
- console.log(`📄 Spec 仓: ${layout.specRepo.name}`);
1755
- console.log(`🔎 已发现代码仓: ${layout.codeRepos.map((c) => c.name).join(', ') || '(无)'}`);
1496
+ console.log(`📄 Spec 仓: ${specName}`);
1497
+ console.log(`🔎 已发现代码仓: ${codeRepos.map((c) => c.name).join(', ') || '(无)'}`);
1756
1498
 
1757
- const targets = onlyNew && saved.ok
1758
- ? newcomers
1759
- : layout.codeRepos;
1499
+ const targets = onlyNew && saved.ok ? newcomers : codeRepos;
1500
+ const filteredTargets = targets.filter(
1501
+ (c) => !specDirs.has(path.resolve(c.abs)),
1502
+ );
1760
1503
 
1761
1504
  if (onlyNew && saved.ok) {
1762
- console.log(`🆕 待接入新仓: ${newcomers.map((c) => c.name).join(', ') || '(无)'}`);
1505
+ console.log(`🆕 待接入新仓: ${filteredTargets.map((c) => c.name).join(', ') || '(无)'}`);
1763
1506
  }
1764
1507
 
1765
1508
  const attached = [];
1766
1509
  const failed = [];
1767
- for (const repo of targets) {
1768
- const result = attachCodeRepoLite(repo.abs, layout.specRepo.abs);
1510
+ for (const repo of filteredTargets) {
1511
+ const result = attachCodeRepoLite(repo.abs, specRootAbs);
1769
1512
  if (result.ok) attached.push(result);
1770
1513
  else failed.push(result);
1771
1514
  }
1772
1515
 
1773
- // 刷新清单(包含本次发现的全部代码仓)
1774
- const filePath = workspaceLayout.writeWorkspaceFile(root, layout);
1516
+ // 刷新清单(spec 指向用户确认的那个;代码仓排除 spec 包自身)
1517
+ const layoutForFile = {
1518
+ ...layout,
1519
+ specRepo: { name: specName, abs: specRootAbs },
1520
+ codeRepos,
1521
+ };
1522
+ const filePath = workspaceLayout.writeWorkspaceFile(root, layoutForFile);
1775
1523
  console.log(`✅ 已更新 ${path.basename(filePath)}`);
1776
1524
 
1777
- return { ok: failed.length === 0, attached, failed, layout, filePath };
1525
+ return {
1526
+ ok: failed.length === 0,
1527
+ attached,
1528
+ failed,
1529
+ layout: layoutForFile,
1530
+ specRoot: specRootAbs,
1531
+ filePath,
1532
+ };
1778
1533
  }
1779
1534
 
1780
1535
  /**
1781
1536
  * 主函数
1537
+ *
1538
+ * CLI 参数解析(argv / 帮助 / 版本 / 编辑器交互选择)全部在 bin/ 层完成,
1539
+ * 本函数只接收已解析的选项对象,不读取 process.argv。
1540
+ * 参数非法时抛错,由 bin 层统一转成 stderr + 非零退出码。
1541
+ *
1542
+ * @param {object} [options]
1543
+ * @param {string[]} [options.tools] 已选编辑器 id 列表;缺省时调用 selectTools 交互选择
1544
+ * @param {Function} [options.selectTools] 交互式选择编辑器(由 bin 注入)
1545
+ * @param {boolean} [options.hasToolArg] 命令行是否显式指定了编辑器(抑制 spec 路径交互询问)
1546
+ * @param {string} [options.specPath] spec 包裹包目录原始值(相对/绝对),在此做语义校验
1547
+ * @param {boolean} [options.skipOpenspec] 跳过 openspec init 步骤
1548
+ * @param {boolean} [options.skipTemplate] 跳过复制内置模版
1549
+ * @param {string} [options.cwd] 工作目录,缺省 process.cwd()
1782
1550
  */
1783
- async function main() {
1784
- const args = process.argv.slice(2);
1785
-
1786
- // 解析参数
1787
- if (args.includes('-h') || args.includes('--help')) {
1788
- showHelp();
1789
- closeRl();
1790
- return;
1791
- }
1792
-
1793
- if (args.includes('-v') || args.includes('--version')) {
1794
- const pkg = require('../package.json');
1795
- console.log(pkg.version);
1796
- closeRl();
1797
- return;
1798
- }
1799
-
1800
- const skipOpenspec = args.includes('--skip-openspec');
1801
- const skipTemplate = args.includes('--skip-template');
1551
+ async function main(options = {}) {
1552
+ const workspaceRoot = path.resolve(options.cwd || process.cwd());
1553
+ const skipOpenspec = Boolean(options.skipOpenspec);
1554
+ const skipTemplate = Boolean(options.skipTemplate);
1555
+ const hasToolArg = options.hasToolArg !== undefined
1556
+ ? Boolean(options.hasToolArg)
1557
+ : Boolean(options.tools && options.tools.length);
1802
1558
 
1803
1559
  // 自定义 spec 目录(--spec-path):未指定时交互询问(自动检测确认 / 输入相对路径)
1804
- let customSpec = null;
1805
- try {
1806
- const specPathArg = parseSpecPathArg(args);
1807
- if (specPathArg) customSpec = resolveSpecPathOption(specPathArg, process.cwd());
1808
- } catch (error) {
1809
- console.error(`❌ ${error.message}`);
1810
- closeRl();
1811
- process.exit(1);
1812
- }
1560
+ // 路径语义校验(必须落在工作目录内)属业务规则,在此完成;非法时抛错交由 bin 出口处理。
1561
+ // 注意:下面交互分支会回填 customSpec,故必须用 let(const 会抛
1562
+ // "Assignment to constant variable",导致交互输入路径时初始化直接失败)。
1563
+ let customSpec = options.specPath
1564
+ ? resolveSpecPathOption(options.specPath, workspaceRoot)
1565
+ : null;
1813
1566
 
1814
1567
  console.log('╔════════════════════════════════════════╗');
1815
1568
  console.log('║ KLD SDD 项目初始化工具 ║');
@@ -1817,17 +1570,12 @@ async function main() {
1817
1570
  console.log('╚════════════════════════════════════════╝');
1818
1571
  console.log();
1819
1572
 
1820
- // 0. 选择编辑器
1821
- let selectedTools;
1822
- try {
1823
- selectedTools = parseSelectedTools(args) || await selectEditor();
1824
- } catch (error) {
1825
- console.error(`❌ ${error.message}`);
1826
- closeRl();
1827
- process.exit(1);
1828
- }
1829
-
1830
- const workspaceRoot = process.cwd();
1573
+ // 0. 选择编辑器(--tool 已指定时直接用;否则由 bin 注入的 selectTools 交互选择)
1574
+ const selectedTools = options.tools && options.tools.length
1575
+ ? options.tools
1576
+ : await (typeof options.selectTools === 'function'
1577
+ ? options.selectTools()
1578
+ : Promise.reject(new Error('未指定编辑器且未注入 selectTools,CLI 入口应传入 bin/kld-sdd-init.js 的 selectEditor')));
1831
1579
  let layout = workspaceLayout.detectWorkspaceLayout(workspaceRoot);
1832
1580
  let specPackage = layout.specRepo
1833
1581
  ? { name: layout.specRepo.name, abs: layout.specRepo.abs, created: false, isGit: true }
@@ -1848,11 +1596,11 @@ async function main() {
1848
1596
  console.log(`📌 使用自定义 spec 目录: ${customSpec.rel}`);
1849
1597
  console.log();
1850
1598
  } else {
1851
- // 交互提示前兜底检测:Git 仓探测(layout.specRepo)漏掉的非 Git spec 产物
1852
- const promptCandidate = layout.specRepo
1853
- ? { name: layout.specRepo.name, abs: layout.specRepo.abs }
1854
- : detectSpecCandidateForPrompt(workspaceRoot);
1855
- const interactiveSpec = await promptSpecPath(workspaceRoot, promptCandidate);
1599
+ // 交互提示:传入**完整候选集合**(多候选让用户单选),而不是只传一个。
1600
+ // 集合由 spec-detect 枚举(含 hint/git/mono 权威来源与结构/名称扫描),
1601
+ // 与 uninstall / sync-repos 同源;Git 仓探测漏掉的非 Git spec 产物也在其中。
1602
+ const candidates = listSpecCandidatesForInit(workspaceRoot);
1603
+ const interactiveSpec = await promptSpecPath(workspaceRoot, candidates, { hasToolArg });
1856
1604
  if (interactiveSpec) {
1857
1605
  customSpec = interactiveSpec;
1858
1606
  specPackage = workspaceLayout.ensureSpecPackage(workspaceRoot, {
@@ -1927,7 +1675,7 @@ async function main() {
1927
1675
  removeOuterSkywalk(workspaceRoot);
1928
1676
 
1929
1677
  deployMonoSddYaml(workspaceRoot, { specPathRelative: specPackage.name });
1930
- // 部署代码仓全部 Git hooks(commit-msg + pre-commit + pre-push)
1678
+ // 部署轻量关联 Hook,并迁移可识别的旧门禁
1931
1679
  // 单仓 mono:hook 通过 .sdd-spec-root 定位包裹包的 skywalk-sdd/git-hooks/
1932
1680
  deployCodeRepoHooks(workspaceRoot, specPackage.abs);
1933
1681
  const linked = sddConfig.setSpecPath(workspaceRoot, specPackage.abs, {
@@ -1947,7 +1695,7 @@ async function main() {
1947
1695
  });
1948
1696
  writeSddSpecRootHint(workspaceRoot, specPackage.abs);
1949
1697
  removeOuterSkywalk(workspaceRoot);
1950
- deployDotSddYaml(workspaceRoot);
1698
+ deployMonoSddYaml(workspaceRoot, { specPathRelative: specPackage.name });
1951
1699
  }
1952
1700
  }
1953
1701
  } else if (!skipOpenspec) {
@@ -2075,35 +1823,26 @@ async function main() {
2075
1823
  // kld-T06: Progress Runtime 已删除,用户级 workspace 注册函数一并移除。
2076
1824
  // 使用统计按本机 git 身份(user.name/user.email)上报,无需任何账号绑定。
2077
1825
 
1826
+ /**
1827
+ * 对外导出面(仅 CLI 入口与测试实际消费的符号)。
1828
+ * 内部实现函数不导出,避免被外部当作稳定契约而阻碍重构。
1829
+ */
2078
1830
  module.exports = {
2079
1831
  main,
2080
- detectTools,
2081
- getTemplatePath,
2082
- getPackagePath,
2083
- selectEditor,
1832
+ setVerbose,
2084
1833
  isVerbose,
2085
- vlog,
2086
1834
  deployOpsxSkills,
1835
+ copyDirRendered,
2087
1836
  copyTemplatesToProject,
2088
- deployProfileArtifacts,
2089
- deploySddGuideManual,
2090
- deployModulesYaml,
2091
- deploySddConfigYaml,
2092
- deployDotSddYaml,
2093
1837
  deployMonoSddYaml,
2094
1838
  populateSpecPackage,
2095
1839
  writeSddSpecRootHint,
2096
1840
  removeOuterSkywalk,
2097
1841
  deployQualityGateTemplates,
2098
1842
  deployTelemetryDataDir,
2099
- updateGitignore,
2100
1843
  attachCodeRepoLite,
2101
1844
  syncWorkspaceRepos,
2102
- copyDirRendered,
2103
- parseSelectedTools,
2104
1845
  promptSpecPath,
2105
- detectSpecCandidateForPrompt,
2106
- installSddShellHook,
1846
+ listSpecCandidatesForInit,
2107
1847
  deployCodeRepoHooks,
2108
- TOOL_CONFIGS,
2109
1848
  };