kld-sdd 2.7.8 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +26 -0
  2. package/USABILITY.md +72 -0
  3. package/bin/kld-sdd-init.js +718 -34
  4. package/lib/command-bridge.js +26 -17
  5. package/lib/deploy-codebuddy-hooks.js +19 -26
  6. package/lib/deploy-strategy.js +293 -0
  7. package/lib/hook-gate-core.js +7 -8
  8. package/lib/hook-install.js +75 -0
  9. package/lib/init.js +507 -726
  10. package/lib/maintenance.js +221 -0
  11. package/lib/managed-hook-hashes.json +24 -0
  12. package/lib/settings-merge.js +102 -25
  13. package/lib/skills-bundle.js +5 -5
  14. package/lib/spec-choice.js +85 -0
  15. package/lib/tool-profiles.js +2 -2
  16. package/lib/uninstall.js +453 -0
  17. package/package.json +7 -4
  18. package/skywalk-sdd/context-client.cjs +38 -7
  19. package/skywalk-sdd/index.cjs +153 -27
  20. package/skywalk-sdd/kb-upload.cjs +224 -82
  21. package/skywalk-sdd/lib/check-review.cjs +41 -0
  22. package/skywalk-sdd/lib/shared.cjs +62 -41
  23. package/skywalk-sdd/lib/test-execution.cjs +130 -0
  24. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  25. package/skywalk-sdd/ontology/archive-package.cjs +141 -0
  26. package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
  27. package/skywalk-sdd/ontology/cli.cjs +53 -0
  28. package/skywalk-sdd/ontology/id.cjs +1 -1
  29. package/skywalk-sdd/ontology/identity-index.cjs +5 -1
  30. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  31. package/skywalk-sdd/ontology/schema.cjs +6 -0
  32. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  33. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  34. package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
  35. package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
  36. package/templates/git-hooks/commit-msg +17 -85
  37. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  38. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  39. package/templates/git-hooks/consistency-check-core.cjs +92 -261
  40. package/templates/git-hooks/delivery-check.cjs +231 -0
  41. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  42. package/templates/git-hooks/hooks.config +8 -6
  43. package/templates/git-hooks/pre-commit +19 -5
  44. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  45. package/templates/git-hooks/pre-push +19 -5
  46. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  47. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
  48. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  49. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
  50. package/templates/openspec/proposal.md +1 -1
  51. package/templates/openspec/spec.md +8 -0
  52. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
  53. package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
  54. package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
  55. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
  56. package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
  57. package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
  58. package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
  59. package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
  60. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  61. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  62. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
  63. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
  64. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
  65. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
  66. package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
  67. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
  68. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
  69. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
  70. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
  71. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
  72. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
  73. package/index.js +0 -13
@@ -0,0 +1,221 @@
1
+ 'use strict';
2
+
3
+ // 安装、升级、卸载共用的边界;业务文档和关联配置不属于可清理的工具文件。
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+ const sdd = require('../skywalk-sdd/ontology/sdd-config.cjs');
8
+ const layout = require('./workspace-layout');
9
+ const detect = require('../skywalk-sdd/ontology/spec-detect.cjs');
10
+ const pkg = path.resolve(__dirname, '..');
11
+
12
+ function posix(relative) {
13
+ return String(relative || '').replace(/\\/g, '/').replace(/^\.\//, '');
14
+ }
15
+
16
+ const MANAGED_ROOT_TOOLS = new Set([
17
+ 'apply-worktree-finish.cjs',
18
+ 'context-client.cjs',
19
+ 'kb-sync-identity.cjs',
20
+ 'kb-upload.cjs',
21
+ 'log.cjs',
22
+ 'metrics-v3.cjs',
23
+ 'openspec-shim.cjs',
24
+ 'runtime-metadata.cjs',
25
+ 'spec-root.cjs',
26
+ ]);
27
+
28
+ const MANAGED_HOOK_TOOLS = new Set([
29
+ 'commit-msg-runner.cjs',
30
+ 'commit-msg-sdd-trailer.cjs',
31
+ 'consistency-check-core.cjs',
32
+ 'delivery-check.cjs',
33
+ 'delivery-snapshot.cjs',
34
+ 'pre-commit-consistency-check.cjs',
35
+ 'pre-commit-sdd-check.cjs',
36
+ 'pre-push-consistency-check.cjs',
37
+ 'pre-push-sdd-check.cjs',
38
+ ]);
39
+
40
+ function specPackageIgnoreLines() {
41
+ return [
42
+ ...[...MANAGED_ROOT_TOOLS].sort().map((name) => `/skywalk-sdd/${name}`),
43
+ '/skywalk-sdd/lib/',
44
+ '/skywalk-sdd/ontology/',
45
+ '/skywalk-sdd/reporting/',
46
+ '/skywalk-sdd/ci/github-actions-sdd.yml',
47
+ '/skywalk-sdd/ci/gitlab-ci-sdd.yml',
48
+ ...[...MANAGED_HOOK_TOOLS].sort().map((name) => `/skywalk-sdd/git-hooks/${name}`),
49
+ '/openspec-templates/',
50
+ '/openspec/kld-sdd操作手册.html',
51
+ '/_tmp_*',
52
+ '/*-start.log',
53
+ ];
54
+ }
55
+
56
+ /**
57
+ * 只识别能够由 kld-sdd 安装器重新生成的文件。
58
+ * 业务 Spec、项目身份、Hook 参数、KB 配置和历史数据都不属于工具文件。
59
+ */
60
+ function isManagedSpecToolPath(relative) {
61
+ const rel = posix(relative);
62
+ if (rel === 'openspec/kld-sdd操作手册.html' || rel.startsWith('openspec-templates/')) return true;
63
+ if (rel.startsWith('skywalk-sdd/') && MANAGED_ROOT_TOOLS.has(rel.slice('skywalk-sdd/'.length))) return true;
64
+ if (/^skywalk-sdd\/(?:lib|ontology|reporting)\//.test(rel)) return true;
65
+ if (/^skywalk-sdd\/ci\/(?:github-actions-sdd|gitlab-ci-sdd)\.yml$/.test(rel)) return true;
66
+ const hookPrefix = 'skywalk-sdd/git-hooks/';
67
+ return rel.startsWith(hookPrefix) && MANAGED_HOOK_TOOLS.has(rel.slice(hookPrefix.length));
68
+ }
69
+
70
+ function git(cwd, args) {
71
+ const result = spawnSync('git', args, {
72
+ cwd,
73
+ encoding: 'utf8',
74
+ maxBuffer: 10 * 1024 * 1024,
75
+ });
76
+ if (result.status !== 0) {
77
+ return {
78
+ ok: false,
79
+ error: String(result.stderr || result.stdout || 'git 命令失败').trim(),
80
+ output: '',
81
+ };
82
+ }
83
+ return { ok: true, output: String(result.stdout || '') };
84
+ }
85
+
86
+ /** 列出当前 Spec 包中仍被 Git 跟踪的安装器工具文件。 */
87
+ function trackedManagedSpecTools(specRoot) {
88
+ const requestedRoot = path.resolve(specRoot);
89
+ const root = fs.existsSync(requestedRoot) ? fs.realpathSync(requestedRoot) : requestedRoot;
90
+ const top = git(root, ['rev-parse', '--show-toplevel']);
91
+ if (!top.ok) return { ok: false, specRoot: root, files: [], error: '当前 Spec 目录不在 Git 仓库中' };
92
+ const gitRoot = path.resolve(top.output.trim());
93
+ const rootRelative = posix(path.relative(gitRoot, root));
94
+ if (rootRelative.startsWith('../') || path.isAbsolute(rootRelative)) {
95
+ return { ok: false, specRoot: root, gitRoot, files: [], error: 'Spec 目录不属于检测到的 Git 仓库' };
96
+ }
97
+ const pathspec = rootRelative ? `${rootRelative}/` : '.';
98
+ const listed = git(gitRoot, ['ls-files', '-z', '--', pathspec]);
99
+ if (!listed.ok) return { ok: false, specRoot: root, gitRoot, files: [], error: listed.error };
100
+ const prefix = rootRelative ? `${rootRelative}/` : '';
101
+ const files = listed.output.split('\0').filter(Boolean).map(posix).flatMap((gitPath) => {
102
+ if (prefix && !gitPath.startsWith(prefix)) return [];
103
+ const relative = prefix ? gitPath.slice(prefix.length) : gitPath;
104
+ return isManagedSpecToolPath(relative) ? [{ gitPath, relative }] : [];
105
+ });
106
+ return { ok: true, specRoot: root, gitRoot, files };
107
+ }
108
+
109
+ /**
110
+ * 将安装器工具从 Git 索引移除,文件仍留在磁盘供本地运行。
111
+ * 调用方必须先把对应规则写入 Spec 根 .gitignore。
112
+ */
113
+ function untrackManagedSpecTools(specRoot) {
114
+ const plan = trackedManagedSpecTools(specRoot);
115
+ if (!plan.ok || plan.files.length === 0) return plan;
116
+ for (let offset = 0; offset < plan.files.length; offset += 100) {
117
+ const batch = plan.files.slice(offset, offset + 100).map((file) => file.gitPath);
118
+ const removed = git(plan.gitRoot, ['update-index', '--force-remove', '--', ...batch]);
119
+ if (!removed.ok) return { ...plan, ok: false, error: removed.error };
120
+ }
121
+ return { ...plan, untracked: plan.files.length };
122
+ }
123
+
124
+ function filesUnder(root, prefix = '') {
125
+ if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) return [];
126
+ return fs.readdirSync(root).flatMap(name => {
127
+ const file = path.join(root, name);
128
+ const rel = path.join(prefix, name);
129
+ const stat = fs.lstatSync(file);
130
+ return stat.isDirectory() ? filesUnder(file, rel) : stat.isFile() ? [rel] : [];
131
+ });
132
+ }
133
+
134
+ function regularFileWithin(root, relative) {
135
+ const parts = relative.split(path.sep);
136
+ if (path.isAbsolute(relative) || parts.includes('..')) return false;
137
+ let target = root;
138
+ if (!fs.existsSync(root) || !fs.lstatSync(root).isDirectory()) return false;
139
+ for (let i = 0; i < parts.length; i++) {
140
+ target = path.join(target, parts[i]);
141
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
142
+ if (!stat || stat.isSymbolicLink() || (i < parts.length - 1 && !stat.isDirectory())) return false;
143
+ if (i === parts.length - 1) return stat.isFile();
144
+ }
145
+ return false;
146
+ }
147
+
148
+ function runtimeToolFiles(root) {
149
+ const names = filesUnder(path.join(pkg, 'skywalk-sdd'))
150
+ .filter(rel => /\.(cjs|js|json)$/.test(rel) && !/^(events|state)[/\\]/.test(rel) && path.basename(rel) !== 'project-identity.json')
151
+ .map(rel => rel === 'index.cjs' ? 'log.cjs' : rel);
152
+ names.push(path.join('lib', 'scale-thresholds.json'));
153
+ for (const name of filesUnder(path.join(pkg, 'templates/git-hooks'))) {
154
+ if (name.endsWith('.cjs')) names.push(path.join('git-hooks', name));
155
+ }
156
+ return [...new Set(names)].filter(rel => regularFileWithin(root, rel)).map(rel => path.join(root, rel));
157
+ }
158
+
159
+ function samePath(a, b) {
160
+ const canonical = value => fs.existsSync(value) ? fs.realpathSync(value) : path.resolve(value);
161
+ return canonical(a) === canonical(b);
162
+ }
163
+
164
+ // 返回冲突原因,不自动迁移已有关系;明确换仓应通过 link-spec / 配置处理。
165
+ function associationConflict(repo, spec) {
166
+ const bindings = [sdd.gitConfig(repo, 'sdd.specPath')];
167
+ const hint = path.join(repo, '.sdd-spec-root');
168
+ if (fs.existsSync(hint)) bindings.push(fs.readFileSync(hint, 'utf8').trim());
169
+ if (bindings.some(value => value && !samePath(path.resolve(repo, value), spec))) {
170
+ return '已有本地 Spec 关联指向其他目录,请先核对关联';
171
+ }
172
+ const yaml = sdd.loadSddYaml(repo);
173
+ if (fs.existsSync(yaml.path) && !yaml.ok) return '已有 .sdd.yaml 无法解析,请先修复配置';
174
+ if (yaml.ok && yaml.layout === 'mono') {
175
+ if (yaml.spec_path ? !samePath(path.resolve(repo, yaml.spec_path), spec) : !bindings.some(value => value && samePath(path.resolve(repo, value), spec))) return '此仓使用独立的单仓 Spec 配置';
176
+ }
177
+ if (yaml.ok && yaml.spec_repository && !yaml.spec_repository.includes('gitlab.example.com:biz/xxx-sdd-specs.git')) {
178
+ const remote = sdd.gitRemoteUrl(spec);
179
+ if (!remote || !sdd.remotesMatch(yaml.spec_repository, remote)) return '.sdd.yaml 的 Spec 远程仓库与所选目录不一致';
180
+ }
181
+ return null;
182
+ }
183
+
184
+ function workspaceCodeRepos(root, spec, current = layout.detectWorkspaceLayout(root), options = {}) {
185
+ const excluded = new Set(detect.detectSpecCandidatesWithGit(root).map(c => path.resolve(c.abs)));
186
+ excluded.add(path.resolve(spec));
187
+ const candidates = current.codeRepos.filter(repo => !excluded.has(path.resolve(repo.abs)));
188
+ const saved = options.respectManifest && layout.loadWorkspaceFile(root);
189
+ if (!saved || !saved.ok) return candidates;
190
+ const declared = saved.data.code_repos.map(value => path.resolve(root, value.replace(/^(["'])(.*)\1$/, '$2')));
191
+ const missing = declared.filter(dir => !candidates.some(repo => samePath(repo.abs, dir)));
192
+ if (missing.length) throw new Error('多仓清单包含未发现或非代码仓目录,请先核对 .sdd-workspace.yaml:' + missing.map(p => path.basename(p)).join('、'));
193
+ return candidates.filter(repo => declared.some(dir => samePath(dir, repo.abs)));
194
+ }
195
+
196
+ function maintenanceConflicts(root, spec, repos) {
197
+ const failures = [];
198
+ const saved = layout.loadWorkspaceFile(root);
199
+ if (saved.ok && saved.data.spec_path && !samePath(path.resolve(root, saved.data.spec_path), spec)) {
200
+ failures.push({ repo: path.basename(root), message: '.sdd-workspace.yaml 指向其他 Spec,升级不会重新绑定' });
201
+ }
202
+ for (const repo of [{ abs: root, name: path.basename(root) }, ...repos]) {
203
+ const message = associationConflict(repo.abs, spec);
204
+ if (message) failures.push({ repo: repo.name, message });
205
+ }
206
+ return failures;
207
+ }
208
+
209
+ module.exports = {
210
+ filesUnder,
211
+ regularFileWithin,
212
+ runtimeToolFiles,
213
+ specPackageIgnoreLines,
214
+ isManagedSpecToolPath,
215
+ trackedManagedSpecTools,
216
+ untrackManagedSpecTools,
217
+ samePath,
218
+ associationConflict,
219
+ workspaceCodeRepos,
220
+ maintenanceConflicts,
221
+ };
@@ -0,0 +1,24 @@
1
+ {
2
+ "pre-commit": [
3
+ "497f012da027df5c26af3c501126e9a1c26276b038ee3b1edaaf5ab4401aa95c",
4
+ "7322d9aecafb497a5f40171a6bd7daeaadd7d2056b4a253cb0ee1a128faea36e",
5
+ "8fcdd74a3d2afaa823f9038c477c9dfd6fe53e2bccbe48cecd01535794862e4a",
6
+ "c8546c6c96adc3ecc32a15a689c59189543c2454f47998c46fbd5a0bf810cf30",
7
+ "e3b6776af51df76ff04b528152d0049a6c45d94d96a19bd2bc5ca2cc9786b22c"
8
+ ],
9
+ "pre-push": [
10
+ "405debb5e6f9186e95b727e6013b06ef3e06c598b6d2b96678d34554d3530e8c",
11
+ "461abd16663757c5a092103ac2ecae8453891c0c817d1c763369e0cdd0a8ec7b",
12
+ "80f710ff647ce8b216fb1c081fb3e22f6ee8be25847b6d02921668ba83fb1965",
13
+ "d49eb8794fde4b0d7847a876cc7083d161b290a44990ff8a036c2ebb8a00ec21",
14
+ "e6e125cbf9d7424c01b249ce03197cef9e1923ed2974a53656084488a0de60c4",
15
+ "ebf628481ae93ac0c4c6649b204449f6825291dc6b6550a71563bebec1f3ad92"
16
+ ],
17
+ "commit-msg": [
18
+ "15083d5165bff965ab2feba9a55dfedcecd54cb577c06fd11f4d1b9672d978b6",
19
+ "3b0ee15f5a126d159e14c5a3681cb7820cbf9c5e0c134ee6812053bc34330b85",
20
+ "6db7adbf17905bf14915d7e57a3c5ac46030ee3282fea5d5b211c80065406d48",
21
+ "9c5a86761b36d71b655f804a822eff675a244175799736c803e1f7379c638609",
22
+ "fc390776720393f100712fa02d5e143c2927fbb0d605a4a0242824ff7ea84574"
23
+ ]
24
+ }
@@ -1,19 +1,40 @@
1
1
  /**
2
- * Conservative merge for CodeBuddy .codebuddy/settings.json hooks.
2
+ * 编辑器 settings.json 中 SDD 受管 hook 的识别、合并与摘除。
3
+ *
4
+ * 策略归属:`.claude/settings.json` 与 `.codebuddy/settings.json` 都是**用户配置** → MERGE。
5
+ * - 安装:保留用户未知字段与自定义 hook,只剔除并重装 SDD 受管 hook(幂等)
6
+ * - 卸载:只摘除 SDD 受管 hook,文件其余内容原样保留
7
+ *
8
+ * 两个 provider 的 hook 判定集中在此模块,保证「装得进去的一定摘得掉」。
3
9
  */
4
10
 
11
+ 'use strict';
12
+
5
13
  const fs = require('fs');
6
14
  const path = require('path');
15
+ const { STRATEGY, ACTION, deployFile } = require('./deploy-strategy');
16
+
17
+ /** CodeBuddy 受管 hook:$CODEBUDDY_PROJECT_DIR/.codebuddy/hooks/sdd-*.cjs */
18
+ const SDD_CODEBUDDY_HOOK_COMMAND_RE = /(?:^|\s|["'])\$?CODEBUDDY_PROJECT_DIR[\\/]+\.codebuddy[\\/]+hooks[\\/]+sdd-[\w-]+\.cjs(?=$|\s|["'])/i;
19
+
20
+ /** Claude 受管 hook:.claude/hooks/sdd-*.cjs(含 ${CLAUDE_PROJECT_DIR} 形式) */
21
+ const SDD_CLAUDE_HOOK_COMMAND_RE = /(?:^|\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;
7
22
 
8
- const SDD_HOOK_COMMAND_RE = /(?:^|\s|["'])\$?CODEBUDDY_PROJECT_DIR[\\/]+\.codebuddy[\\/]+hooks[\\/]+sdd-[\w-]+\.cjs(?=$|\s|["'])/i;
23
+ const PROVIDERS = Object.freeze({
24
+ CLAUDE: 'claude',
25
+ CODEBUDDY: 'codebuddy',
26
+ });
9
27
 
10
28
  function hookCommandText(hook) {
11
29
  const args = Array.isArray(hook.args) ? hook.args : [];
12
30
  return [hook.command, ...args].filter(Boolean).join(' ').replace(/\s+/g, ' ').trim();
13
31
  }
14
32
 
15
- function isSddCodebuddyHook(hook) {
16
- return SDD_HOOK_COMMAND_RE.test(hookCommandText(hook));
33
+ /** 判断单个 hook 是否 SDD 受管(按 provider 选择判定规则) */
34
+ function isSddHook(hook, provider) {
35
+ const text = hookCommandText(hook);
36
+ if (provider === PROVIDERS.CLAUDE) return SDD_CLAUDE_HOOK_COMMAND_RE.test(text);
37
+ return SDD_CODEBUDDY_HOOK_COMMAND_RE.test(text);
17
38
  }
18
39
 
19
40
  function sameHookCommand(left, right) {
@@ -21,36 +42,56 @@ function sameHookCommand(left, right) {
21
42
  String(left.type || '') === String(right.type || '');
22
43
  }
23
44
 
24
- function removeManagedSddHooks(entries) {
25
- return entries
45
+ /**
46
+ * 从 hook 条目数组中剔除 SDD 受管 hook,保留用户自定义条目。
47
+ * 条目内 hooks 被清空时整条移除。
48
+ */
49
+ function removeManagedHooks(entries, provider) {
50
+ return (entries || [])
26
51
  .map(entry => {
27
52
  if (!Array.isArray(entry.hooks)) {
28
53
  return entry;
29
54
  }
30
55
  return {
31
56
  ...entry,
32
- hooks: entry.hooks.filter(hook => !isSddCodebuddyHook(hook)),
57
+ hooks: entry.hooks.filter(hook => !isSddHook(hook, provider)),
33
58
  };
34
59
  })
35
60
  .filter(entry => !Array.isArray(entry.hooks) || entry.hooks.length > 0);
36
61
  }
37
62
 
38
- function mergeCodebuddySettings(settingsPath, templateSettings) {
39
- const displayPath = path.basename(settingsPath);
40
- let originalBytes = '';
41
- if (fs.existsSync(settingsPath)) {
42
- originalBytes = fs.readFileSync(settingsPath, 'utf8');
63
+ /**
64
+ * 纯计算:摘除 settings 对象中全部 SDD 受管 hook(卸载路径)。
65
+ * 返回新对象,不修改入参;hooks 变空时保留空对象结构,避免下游读取报错。
66
+ */
67
+ function stripManagedHooks(settings, provider) {
68
+ const next = { ...settings };
69
+ if (!next.hooks || typeof next.hooks !== 'object') {
70
+ return next;
71
+ }
72
+ const hooks = {};
73
+ for (const [eventName, entries] of Object.entries(next.hooks)) {
74
+ hooks[eventName] = removeManagedHooks(entries, provider);
43
75
  }
76
+ next.hooks = hooks;
77
+ return next;
78
+ }
44
79
 
80
+ /**
81
+ * 纯计算:把模板 hooks 合并进已有配置,保留用户未知字段与自定义 hook。
82
+ *
83
+ * @param {string|null} existingRaw 现有 settings.json 原文(不存在传 null)
84
+ * @param {object} templateSettings 模板 settings 对象
85
+ * @param {string} [provider] 受管 hook 归属,默认 codebuddy
86
+ * @returns {{ok: true, merged: object} | {ok: false, error: string}}
87
+ */
88
+ function computeMergedSettings(existingRaw, templateSettings, provider = PROVIDERS.CODEBUDDY) {
45
89
  let existingSettings = {};
46
- if (originalBytes) {
90
+ if (existingRaw) {
47
91
  try {
48
- existingSettings = JSON.parse(originalBytes);
92
+ existingSettings = JSON.parse(existingRaw);
49
93
  } catch {
50
- return {
51
- ok: false,
52
- error: `${displayPath} 不是合法 JSON,请修复后重试(路径: ${settingsPath})`,
53
- };
94
+ return { ok: false, error: '不是合法 JSON' };
54
95
  }
55
96
  }
56
97
 
@@ -58,7 +99,7 @@ function mergeCodebuddySettings(settingsPath, templateSettings) {
58
99
  merged.hooks = merged.hooks || {};
59
100
 
60
101
  for (const [eventName, entries] of Object.entries(templateSettings.hooks || {})) {
61
- merged.hooks[eventName] = removeManagedSddHooks(merged.hooks[eventName] || []);
102
+ merged.hooks[eventName] = removeManagedHooks(merged.hooks[eventName], provider);
62
103
  for (const entry of entries) {
63
104
  const templateHooks = entry.hooks || [];
64
105
  const exists = merged.hooks[eventName].some(existingEntry => {
@@ -73,13 +114,49 @@ function mergeCodebuddySettings(settingsPath, templateSettings) {
73
114
  }
74
115
  }
75
116
 
76
- fs.writeFileSync(settingsPath, JSON.stringify(merged, null, 2) + '\n', 'utf8');
77
- return { ok: true };
117
+ return { ok: true, merged };
118
+ }
119
+
120
+ /**
121
+ * 从模板文件部署 settings.json(用户配置 → MERGE)。
122
+ * 所有写盘经 deploy-strategy,非法 JSON 时 fail-closed 并保持原字节不变。
123
+ *
124
+ * @returns {{ok: boolean, action?: string, error?: string}}
125
+ */
126
+ function mergeCodebuddySettingsFromTemplate(sourcePath, targetPath) {
127
+ const displayPath = path.basename(targetPath);
128
+ let templateSettings;
129
+ try {
130
+ templateSettings = JSON.parse(fs.readFileSync(sourcePath, 'utf8'));
131
+ } catch (error) {
132
+ return { ok: false, error: `模板 ${displayPath} 无法解析: ${error.message}` };
133
+ }
134
+
135
+ const result = deployFile(sourcePath, targetPath, {
136
+ strategy: STRATEGY.MERGE,
137
+ label: displayPath,
138
+ mergeFn: ({ readTarget }) => {
139
+ const computed = computeMergedSettings(readTarget(), templateSettings, PROVIDERS.CODEBUDDY);
140
+ if (!computed.ok) {
141
+ return {
142
+ ok: false,
143
+ error: `${displayPath} 不是合法 JSON,请修复后重试(路径: ${targetPath})`,
144
+ };
145
+ }
146
+ fs.writeFileSync(targetPath, JSON.stringify(computed.merged, null, 2) + '\n', 'utf8');
147
+ return { ok: true, action: ACTION.MERGED };
148
+ },
149
+ });
150
+
151
+ if (!result.ok) {
152
+ return { ok: false, error: result.message };
153
+ }
154
+ return { ok: true, action: result.action };
78
155
  }
79
156
 
80
157
  module.exports = {
81
- mergeCodebuddySettings,
82
- isSddCodebuddyHook,
83
- hookCommandText,
84
- removeManagedSddHooks,
158
+ PROVIDERS,
159
+ mergeCodebuddySettingsFromTemplate,
160
+ computeMergedSettings,
161
+ stripManagedHooks,
85
162
  };
@@ -2,6 +2,8 @@
2
2
  * SDD skills bundle 共享配置与模板渲染
3
3
  */
4
4
 
5
+ 'use strict';
6
+
5
7
  const fs = require('fs');
6
8
  const path = require('path');
7
9
 
@@ -16,7 +18,7 @@ const POWERSHELL_TABOOS = [
16
18
  '',
17
19
  '> - **PowerShell 禁忌**(Windows 用户必读):',
18
20
  '> - 禁止 `| cat`(PowerShell 将 cat 解释为 Get-Content,不接受管道输入)→ 直接执行命令',
19
- '> - 禁止 `node -e "..."` 内联脚本(PowerShell 对 `||`/`{}`/`()` 有特殊解析,导致 JS 语法被破坏)→ 用 `write_to_file` 创建临时 `.cjs` 脚本执行后删除',
21
+ '> - 禁止 `node -e "..."` 内联脚本(PowerShell 对 `||`/`{}`/`()` 有特殊解析,导致 JS 语法被破坏)→ 用 `write_to_file` 创建临时脚本执行后删除;脚本**必须 `.cjs` 后缀、只用 `require`**(项目 package.json 声明 `type: module` 时 `.js` 会被误判 ESM 报 ERR_REQUIRE_ESM)',
20
22
  '> - 禁止 cmd.exe 语法(如 `for /L %i in (1,1,N) do ...`)→ PowerShell 不支持,改用逐个 `execute_command` 调用或 PowerShell `foreach`',
21
23
  '> - 警告 CLIXML:PowerShell 捕获 `node`/`npx` 子进程输出时可能包装为 `#< CLIXML <Objs...>` 格式 → 检测到 CLIXML 时以文件系统实际产物为准,忽略 stdout',
22
24
  '> - 禁止 `curl`(PowerShell 将 curl 别名为 Invoke-WebRequest)→ HTTP 请求走 `context-client.cjs` / `kb-upload.cjs`,或用 `Invoke-RestMethod`',
@@ -38,6 +40,7 @@ const OPSX_SKILL_DIRS = [
38
40
  // Engineering KB 依赖技能(随 init 部署;propose/spec/archive 硬依赖)
39
41
  'opsx-kb-config',
40
42
  'opsx-ontology-query',
43
+ 'opsx-ontology-ingest',
41
44
  'opsx-kb-ingest',
42
45
  // Spec 一致性校验技能
43
46
  'opsx-consistency-check',
@@ -120,6 +123,7 @@ const KUNLUN_SLASH_COMMANDS = [
120
123
  'archive',
121
124
  'explore',
122
125
  'ontology-query',
126
+ 'ontology-ingest',
123
127
  'kb-ingest',
124
128
  'kb-config',
125
129
  'consistency-check',
@@ -186,10 +190,6 @@ module.exports = {
186
190
  OPSX_SKILL_METADATA,
187
191
  buildOpsxSkillMetadata,
188
192
  commandNameFromSkillDir,
189
- displayNameFromCommandName,
190
- parseSkillFrontmatter,
191
193
  renderTemplate,
192
- replaceProfileTokens,
193
- transformKunlunSlashCommands,
194
194
  validateRenderedContent,
195
195
  };
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * spec 候选的单选交互 —— init / uninstall / sync-repos 三个场景**共用**。
5
+ *
6
+ * 为什么需要用户裁决:判据无法区分「当前项目的 spec 仓」与「用户手动复制的
7
+ * 备份」(如 `erp-sdd-specs copy`)—— 二者都命中。故由用户单选,而不是静默
8
+ * 挑一个:挑错的代价是 init 把内容灌进错误目录、uninstall 删错仓、
9
+ * sync-repos 把错误路径写进每个代码仓的 `git config`。
10
+ *
11
+ * 归属 lib/ 而非 bin/:init 的交互发生在 `lib/init.js#main()` 内部,uninstall
12
+ * 与 sync-repos 的交互在 bin/ 层。放在 lib/ 使两侧都能复用,且依赖方向正确
13
+ *(bin → lib,不反向)。
14
+ *
15
+ * 交互约定(三个场景一致):
16
+ * - 0 个候选 → 返回 undefined(调用方决定「要求输入路径」还是「报错退出」)
17
+ * - 1 个候选 → 确认(回车默认 Y)
18
+ * - N 个候选 → 列出(标注来源)让用户单选
19
+ *
20
+ * 实现约束:候选信息必须**并入 question 字符串**,不能靠提问前的 console.log。
21
+ * readline 重绘提示行时会发 \x1b[0J(清除光标之后内容),会把前置输出抹掉,
22
+ * 导致用户看不到自己在确认哪个目录。
23
+ */
24
+
25
+ const { specSourceLabel } = require('../skywalk-sdd/ontology/spec-detect.cjs');
26
+
27
+ /**
28
+ * 让用户在 spec 候选集合中单选。
29
+ *
30
+ * @param {{ask: (q: string) => Promise<string>}} prompt readline 会话(只需 ask)
31
+ * @param {Array<{abs: string, name: string, source: string, tier?: number}>} candidates
32
+ * 候选集合(由 spec-detect 枚举,已按 tier 排序)
33
+ * @param {{actionLabel?: string}} [options]
34
+ * actionLabel:提问文案里的动作词(默认「卸载」;init 用「使用」、
35
+ * sync-repos 用「接入」)
36
+ * @returns {Promise<string|null|undefined>}
37
+ * string=选中的绝对路径;null=用户拒绝/取消;undefined=无候选
38
+ */
39
+ async function selectSpecRoot(prompt, candidates, options = {}) {
40
+ if (!candidates || candidates.length === 0) return undefined;
41
+ const action = options.actionLabel || '卸载';
42
+
43
+ if (candidates.length === 1) {
44
+ const only = candidates[0];
45
+ const question = [
46
+ `\n🔍 检测到 spec 仓库: ${only.name} [${specSourceLabel(only.source)}]`,
47
+ ` 位置: ${only.abs}`,
48
+ `是否${action}该 spec 仓库?(Y=是 / n=重新选择,默认 Y): `,
49
+ ].join('\n');
50
+ while (true) {
51
+ const answer = await prompt.ask(question);
52
+ const normalized = String(answer || '').trim().toLowerCase();
53
+ if (!normalized || normalized === 'y' || normalized === 'yes') return only.abs;
54
+ if (normalized === 'n' || normalized === 'no') break;
55
+ console.log(`❌ 无效输入 "${answer}",请输入 Y 或 n`);
56
+ }
57
+ // 拒绝唯一候选 → 无其它候选可选,交由调用方决定后续(重新选择 / 输入路径)
58
+ return null;
59
+ }
60
+
61
+ while (true) {
62
+ const question = [
63
+ `\n检测到多个 spec 仓库,请选择要${action}的一个:`,
64
+ ...candidates.map(
65
+ (c, i) => ` ${i + 1}. ${c.name} [${specSourceLabel(c.source)}]\n ${c.abs}`,
66
+ ),
67
+ ' 0. 取消',
68
+ `\n请输入选项 (0-${candidates.length}): `,
69
+ ].join('\n');
70
+
71
+ const answer = await prompt.ask(question);
72
+ const trimmed = String(answer || '').trim();
73
+ if (trimmed === '0' || /^(q|quit|exit)$/i.test(trimmed)) return null;
74
+
75
+ const index = Number(trimmed);
76
+ if (Number.isInteger(index) && index >= 1 && index <= candidates.length) {
77
+ const chosen = candidates[index - 1];
78
+ console.log(`✓ 已选择: ${chosen.name}\n`);
79
+ return chosen.abs;
80
+ }
81
+ console.log(`❌ 无效选项 "${answer}",请输入 0-${candidates.length} 之间的数字\n`);
82
+ }
83
+ }
84
+
85
+ module.exports = { selectSpecRoot };
@@ -3,6 +3,8 @@
3
3
  * Single source of truth for per-agent paths and capabilities.
4
4
  */
5
5
 
6
+ 'use strict';
7
+
6
8
  const path = require('path');
7
9
 
8
10
  const TOOL_PROFILES = Object.freeze({
@@ -272,7 +274,5 @@ module.exports = {
272
274
  getToolProfile,
273
275
  listToolProfileIds,
274
276
  validateToolProfiles,
275
- isSafeRelativePath,
276
277
  buildToolConfigs,
277
- DEFAULT_TOOL_DISPLAY_NAMES,
278
278
  };