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