kld-sdd 2.4.19 → 2.5.1

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 (47) hide show
  1. package/kld-sdd-guide.html +1109 -0
  2. package/lib/command-bridge.js +156 -0
  3. package/lib/deploy-codebuddy-hooks.js +99 -0
  4. package/lib/hook-gate-core.js +333 -0
  5. package/lib/init.js +136 -93
  6. package/lib/settings-merge.js +85 -0
  7. package/lib/skills-bundle.js +142 -5
  8. package/lib/tool-profiles.js +270 -0
  9. package/package.json +3 -2
  10. package/skywalk-sdd/index.cjs +1808 -129
  11. package/templates/commands/kunlunzhima/skill-bridge.md +23 -0
  12. package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +175 -28
  13. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +42 -21
  14. package/templates/hooks/codebuddy/hooks/sdd-apply-gate.cjs +16 -0
  15. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +395 -0
  16. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +123 -0
  17. package/templates/hooks/codebuddy/hooks/sdd-pre-tool.cjs +16 -0
  18. package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +48 -0
  19. package/templates/hooks/codebuddy/hooks/sdd-skill-apply-gate.cjs +16 -0
  20. package/templates/hooks/codebuddy/hooks/sdd-stop.cjs +70 -0
  21. package/templates/hooks/codebuddy/settings.json +72 -0
  22. package/templates/openspec/proposal.md +0 -1
  23. package/templates/openspec/spec.md +2 -2
  24. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +65 -356
  25. package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
  26. package/templates/skills/kld-sdd/opsx-apply/reference.md +403 -0
  27. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +21 -5
  28. package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
  29. package/templates/skills/kld-sdd/opsx-check/SKILL.md +29 -5
  30. package/templates/skills/kld-sdd/opsx-check/checklist.md +37 -0
  31. package/templates/skills/kld-sdd/opsx-design/SKILL.md +47 -51
  32. package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
  33. package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
  34. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +1 -1
  35. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +52 -96
  36. package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
  37. package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
  38. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +131 -0
  39. package/templates/skills/kld-sdd/opsx-rules/checklist.md +27 -0
  40. package/templates/skills/kld-sdd/opsx-rules/reference.md +124 -0
  41. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +47 -51
  42. package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
  43. package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
  44. package/templates/skills/kld-sdd/opsx-task/SKILL.md +43 -46
  45. package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
  46. package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
  47. package/templates/skills/kld-sdd/opsx-test/SKILL.md +13 -1
@@ -0,0 +1,156 @@
1
+ /**
2
+ * KunlunZhima OPSX Command Bridge 生成与管理
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { renderTemplate, validateRenderedContent } = require('./skills-bundle');
8
+
9
+ const BRIDGE_MARKER = '# SDD KunlunZhima Skill Bridge';
10
+ const BRIDGE_MAX_BYTES = 4096;
11
+
12
+ function posixJoin(...segments) {
13
+ return segments.filter(Boolean).join('/').replace(/\\/g, '/');
14
+ }
15
+
16
+ function loadBridgeTemplate(pkgPath) {
17
+ const templatePath = path.join(pkgPath, 'templates', 'commands', 'kunlunzhima', 'skill-bridge.md');
18
+ if (!fs.existsSync(templatePath)) {
19
+ throw new Error(`缺少 Kunlun bridge 模板: ${templatePath}`);
20
+ }
21
+ return fs.readFileSync(templatePath, 'utf8');
22
+ }
23
+
24
+ function escapeYamlString(value) {
25
+ return String(value || '').replace(/\\/g, '\\\\').replace(/"/g, '\\"');
26
+ }
27
+
28
+ function renderBridgeTemplate(template, metadata, profile) {
29
+ const skillRelativePath = posixJoin(profile.skillsDir, metadata.skillDir, 'SKILL.md');
30
+ const rendered = template
31
+ .replace(/\$\{BRIDGE_NAME\}/g, escapeYamlString(metadata.displayName))
32
+ .replace(/\$\{BRIDGE_DESCRIPTION\}/g, escapeYamlString(metadata.description))
33
+ .replace(/\$\{BRIDGE_ARGUMENT_HINT\}/g, escapeYamlString(metadata.argumentHint))
34
+ .replace(/\$\{SKILL_RELATIVE_PATH\}/g, skillRelativePath)
35
+ .replace(/\$\{SKILL_DIR\}/g, posixJoin(profile.skillsDir, metadata.skillDir));
36
+
37
+ validateRenderedContent(rendered, profile, `bridge:${metadata.commandName}`);
38
+ return rendered;
39
+ }
40
+
41
+ function generateKunlunBridge(metadata, profile, projectRoot, pkgPath = path.resolve(__dirname, '..')) {
42
+ if (!profile || !profile.requiresCommandBridge) {
43
+ throw new Error('当前 profile 不需要 Kunlun command bridge');
44
+ }
45
+
46
+ const skillPath = path.join(projectRoot, profile.skillsDir, metadata.skillDir, 'SKILL.md');
47
+ if (!fs.existsSync(skillPath)) {
48
+ throw new Error(`缺少 Skill 文件,无法生成 bridge: ${posixJoin(profile.skillsDir, metadata.skillDir, 'SKILL.md')}`);
49
+ }
50
+
51
+ const template = loadBridgeTemplate(pkgPath || path.resolve(__dirname, '..'));
52
+ const content = renderBridgeTemplate(template, metadata, profile);
53
+
54
+ if (Buffer.byteLength(content, 'utf8') > BRIDGE_MAX_BYTES) {
55
+ throw new Error(`bridge 文件过大 (${metadata.commandName}),可能复制了 Skill 正文`);
56
+ }
57
+
58
+ if (!content.includes(BRIDGE_MARKER)) {
59
+ throw new Error(`生成的 bridge 缺少受管标记: ${metadata.commandName}`);
60
+ }
61
+
62
+ return content;
63
+ }
64
+
65
+ function getBridgeCommandPath(profile, projectRoot, commandName) {
66
+ return path.join(projectRoot, profile.opsxCommandsDir, `${commandName}.md`);
67
+ }
68
+
69
+ function isManagedBridgeFile(content) {
70
+ return typeof content === 'string' && content.includes(BRIDGE_MARKER);
71
+ }
72
+
73
+ function assertNoUserCommandConflict(commandPath, commandName) {
74
+ if (!fs.existsSync(commandPath)) {
75
+ return;
76
+ }
77
+
78
+ const existing = fs.readFileSync(commandPath, 'utf8');
79
+ if (!isManagedBridgeFile(existing)) {
80
+ throw new Error(
81
+ `命令文件冲突: ${commandName}.md 已存在且不含 SDD 受管标记。` +
82
+ '请手动备份后删除或重命名该文件,再重新初始化。'
83
+ );
84
+ }
85
+ }
86
+
87
+ function deployKunlunCommandBridges({ profile, projectRoot, pkgPath, skillMetadata }) {
88
+ if (!profile) {
89
+ throw new Error('缺少 profile');
90
+ }
91
+ if (!profile.requiresCommandBridge) {
92
+ return { deployed: [], skipped: true };
93
+ }
94
+ if (!projectRoot) {
95
+ throw new Error('缺少 projectRoot');
96
+ }
97
+ if (!Array.isArray(skillMetadata) || skillMetadata.length === 0) {
98
+ throw new Error('缺少 OPSX skill metadata');
99
+ }
100
+
101
+ const opsxDir = path.join(projectRoot, profile.opsxCommandsDir);
102
+ if (!fs.existsSync(opsxDir)) {
103
+ fs.mkdirSync(opsxDir, { recursive: true });
104
+ }
105
+
106
+ const deployed = [];
107
+
108
+ for (const metadata of skillMetadata) {
109
+ const commandPath = getBridgeCommandPath(profile, projectRoot, metadata.commandName);
110
+ assertNoUserCommandConflict(commandPath, metadata.commandName);
111
+
112
+ const content = generateKunlunBridge(metadata, profile, projectRoot, pkgPath);
113
+ fs.writeFileSync(commandPath, content, 'utf8');
114
+ deployed.push({
115
+ commandName: metadata.commandName,
116
+ path: commandPath,
117
+ skillPath: posixJoin(profile.skillsDir, metadata.skillDir, 'SKILL.md'),
118
+ });
119
+ }
120
+
121
+ return { deployed, skipped: false };
122
+ }
123
+
124
+ function cleanupManagedKunlunBridges(profile, projectRoot) {
125
+ if (!profile || !profile.requiresCommandBridge || !projectRoot) {
126
+ return { removed: [] };
127
+ }
128
+
129
+ const opsxDir = path.join(projectRoot, profile.opsxCommandsDir);
130
+ if (!fs.existsSync(opsxDir)) {
131
+ return { removed: [] };
132
+ }
133
+
134
+ const removed = [];
135
+ for (const file of fs.readdirSync(opsxDir)) {
136
+ if (!file.endsWith('.md')) continue;
137
+ const filePath = path.join(opsxDir, file);
138
+ const content = fs.readFileSync(filePath, 'utf8');
139
+ if (isManagedBridgeFile(content)) {
140
+ fs.unlinkSync(filePath);
141
+ removed.push(filePath);
142
+ }
143
+ }
144
+
145
+ return { removed };
146
+ }
147
+
148
+ module.exports = {
149
+ BRIDGE_MARKER,
150
+ BRIDGE_MAX_BYTES,
151
+ generateKunlunBridge,
152
+ deployKunlunCommandBridges,
153
+ cleanupManagedKunlunBridges,
154
+ isManagedBridgeFile,
155
+ getBridgeCommandPath,
156
+ };
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Deploy CodeBuddy SDD Hook Pack to a target project.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const { mergeCodebuddySettings } = require('./settings-merge');
8
+
9
+ function copyFileSync(src, dest) {
10
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
11
+ fs.copyFileSync(src, dest);
12
+ }
13
+
14
+ function copyDirSync(srcDir, destDir) {
15
+ if (!fs.existsSync(srcDir)) return;
16
+ fs.mkdirSync(destDir, { recursive: true });
17
+ for (const entry of fs.readdirSync(srcDir, { withFileTypes: true })) {
18
+ const srcPath = path.join(srcDir, entry.name);
19
+ const destPath = path.join(destDir, entry.name);
20
+ if (entry.isDirectory()) {
21
+ copyDirSync(srcPath, destPath);
22
+ } else {
23
+ copyFileSync(srcPath, destPath);
24
+ }
25
+ }
26
+ }
27
+
28
+ function validateExistingSettings(settingsPath) {
29
+ if (!fs.existsSync(settingsPath)) {
30
+ return { ok: true };
31
+ }
32
+ const originalBytes = fs.readFileSync(settingsPath, 'utf8');
33
+ try {
34
+ JSON.parse(originalBytes);
35
+ return { ok: true, originalBytes };
36
+ } catch {
37
+ return {
38
+ ok: false,
39
+ error: `${path.basename(settingsPath)} 不是合法 JSON,请修复后重试(路径: ${settingsPath})`,
40
+ originalBytes,
41
+ };
42
+ }
43
+ }
44
+
45
+ function deployCodebuddyHookPack(profile, cwd, pkgPath) {
46
+ if (!profile || profile.hookProvider !== 'codebuddy') {
47
+ return { ok: true, skipped: true };
48
+ }
49
+
50
+ const hookTemplateDir = path.join(pkgPath, 'templates', 'hooks', 'codebuddy');
51
+ const hookSourceDir = path.join(hookTemplateDir, 'hooks');
52
+ const settingsTemplatePath = path.join(hookTemplateDir, 'settings.json');
53
+ const coreSourcePath = path.join(pkgPath, 'lib', 'hook-gate-core.js');
54
+
55
+ if (!fs.existsSync(hookSourceDir) || !fs.existsSync(settingsTemplatePath)) {
56
+ return {
57
+ ok: false,
58
+ error: `CodeBuddy Hook 模板缺失: ${hookTemplateDir}`,
59
+ };
60
+ }
61
+
62
+ const codebuddyDir = path.join(cwd, profile.configDir || '.codebuddy');
63
+ const targetSettingsPath = path.join(codebuddyDir, 'settings.json');
64
+ const settingsCheck = validateExistingSettings(targetSettingsPath);
65
+ if (!settingsCheck.ok) {
66
+ return settingsCheck;
67
+ }
68
+
69
+ const targetHookDir = path.join(codebuddyDir, 'hooks');
70
+ fs.mkdirSync(targetHookDir, { recursive: true });
71
+
72
+ for (const file of fs.readdirSync(hookSourceDir)) {
73
+ if (file === 'hook-gate-core.cjs') continue;
74
+ copyFileSync(path.join(hookSourceDir, file), path.join(targetHookDir, file));
75
+ }
76
+
77
+ if (fs.existsSync(coreSourcePath)) {
78
+ copyFileSync(coreSourcePath, path.join(targetHookDir, 'hook-gate-core.cjs'));
79
+ } else {
80
+ return {
81
+ ok: false,
82
+ error: `共享 hook 核心缺失: ${coreSourcePath}`,
83
+ };
84
+ }
85
+
86
+ const templateSettings = JSON.parse(fs.readFileSync(settingsTemplatePath, 'utf8'));
87
+ const mergeResult = mergeCodebuddySettings(targetSettingsPath, templateSettings);
88
+ if (!mergeResult.ok) {
89
+ return mergeResult;
90
+ }
91
+
92
+ return { ok: true, hookDir: targetHookDir, settingsPath: targetSettingsPath };
93
+ }
94
+
95
+ module.exports = {
96
+ deployCodebuddyHookPack,
97
+ copyDirSync,
98
+ validateExistingSettings,
99
+ };
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Shared SDD hook gate utilities for Claude and CodeBuddy adapters.
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ const PROJECT_ENV_KEYS = {
9
+ claude: 'CLAUDE_PROJECT_DIR',
10
+ codebuddy: 'CODEBUDDY_PROJECT_DIR',
11
+ };
12
+
13
+ function readStdin() {
14
+ try {
15
+ return fs.readFileSync(0, 'utf8');
16
+ } catch {
17
+ return '';
18
+ }
19
+ }
20
+
21
+ function parseHookInput(raw, options = {}) {
22
+ const { strict = false } = options;
23
+ if (!raw || !String(raw).trim()) {
24
+ if (strict) {
25
+ return { ok: false, error: 'stdin 为空,缺少 hook 输入 JSON' };
26
+ }
27
+ return { ok: true, input: {} };
28
+ }
29
+ try {
30
+ return { ok: true, input: JSON.parse(raw) };
31
+ } catch {
32
+ if (strict) {
33
+ return { ok: false, error: 'stdin 不是合法 JSON,无法解析 hook 输入' };
34
+ }
35
+ return { ok: true, input: {} };
36
+ }
37
+ }
38
+
39
+ function normalizeHookInput(input) {
40
+ const normalized = { ...(input || {}) };
41
+ normalized.tool_name = String(
42
+ normalized.tool_name || normalized.toolName || ''
43
+ );
44
+ normalized.tool_input = normalized.tool_input != null
45
+ ? normalized.tool_input
46
+ : (normalized.toolInput != null ? normalized.toolInput : {});
47
+ normalized.tool_response = normalized.tool_response != null
48
+ ? normalized.tool_response
49
+ : (normalized.toolResponse != null ? normalized.toolResponse : {});
50
+ return normalized;
51
+ }
52
+
53
+ function hasTelemetryCli(dir) {
54
+ return fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.cjs')) ||
55
+ fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'));
56
+ }
57
+
58
+ function findProjectRoot(startDir) {
59
+ if (!startDir) return '';
60
+ let current = path.resolve(startDir);
61
+ for (let i = 0; i < 25; i++) {
62
+ if (hasTelemetryCli(current)) return current;
63
+ const parent = path.dirname(current);
64
+ if (parent === current) return '';
65
+ current = parent;
66
+ }
67
+ return '';
68
+ }
69
+
70
+ function getProjectRoot(input, provider = 'codebuddy') {
71
+ const toolInput = input.tool_input || input.toolInput || {};
72
+ const envKey = PROJECT_ENV_KEYS[provider] || PROJECT_ENV_KEYS.codebuddy;
73
+ const candidates = [
74
+ toolInput.cwd,
75
+ input.cwd,
76
+ input.project_root,
77
+ process.env[envKey],
78
+ process.env.PWD,
79
+ process.cwd(),
80
+ ].filter(Boolean);
81
+ for (const dir of candidates) {
82
+ const root = findProjectRoot(dir);
83
+ if (root) return root;
84
+ }
85
+ return process.cwd();
86
+ }
87
+
88
+ function safeChangeName(name) {
89
+ if (!name) return '';
90
+ return String(name)
91
+ .toLowerCase()
92
+ .replace(/[\s_]+/g, '-')
93
+ .replace(/[^a-z0-9一-鿿\-]/g, '-')
94
+ .replace(/-+/g, '-')
95
+ .replace(/^-|-$/g, '');
96
+ }
97
+
98
+ function findActiveApplyStage(projectRoot) {
99
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
100
+ if (!fs.existsSync(stateDir)) return null;
101
+
102
+ let latest = null;
103
+ for (const file of fs.readdirSync(stateDir).filter((f) => f.endsWith('.json'))) {
104
+ try {
105
+ const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
106
+ const event = data.event || null;
107
+ if (event && event.command === 'apply') {
108
+ if (!latest || new Date(event.timestamp) > new Date(latest.timestamp)) {
109
+ latest = event;
110
+ }
111
+ }
112
+ } catch {
113
+ // skip
114
+ }
115
+ }
116
+ return latest;
117
+ }
118
+
119
+ function hasCompletedCheck(projectRoot, changeName) {
120
+ const safeName = safeChangeName(changeName);
121
+ const eventsChangeDir = path.join(projectRoot, 'skywalk-sdd', 'events', safeName);
122
+ if (!fs.existsSync(eventsChangeDir)) return false;
123
+
124
+ const jsonlFiles = fs.readdirSync(eventsChangeDir)
125
+ .filter((f) => f.endsWith('.jsonl'))
126
+ .sort()
127
+ .reverse();
128
+
129
+ for (const file of jsonlFiles) {
130
+ try {
131
+ const lines = fs.readFileSync(path.join(eventsChangeDir, file), 'utf-8')
132
+ .split('\n')
133
+ .filter(Boolean);
134
+ for (let i = lines.length - 1; i >= 0; i--) {
135
+ try {
136
+ const event = JSON.parse(lines[i]);
137
+ if (
138
+ event.type === 'stage_end' &&
139
+ event.command === 'check' &&
140
+ (event.result === 'success' || event.result === 'partial')
141
+ ) {
142
+ return true;
143
+ }
144
+ } catch {
145
+ // skip
146
+ }
147
+ }
148
+ } catch {
149
+ // skip
150
+ }
151
+ }
152
+ return false;
153
+ }
154
+
155
+ function getPassedChanges(projectRoot) {
156
+ if (!projectRoot) return [];
157
+ const changesDir = path.join(projectRoot, 'openspec', 'changes');
158
+ if (!fs.existsSync(changesDir)) return [];
159
+
160
+ const passed = [];
161
+ try {
162
+ for (const entry of fs.readdirSync(changesDir, { withFileTypes: true })) {
163
+ if (!entry.isDirectory()) continue;
164
+ if (entry.name.startsWith('.') || entry.name === 'archive') continue;
165
+ if (hasCompletedCheck(projectRoot, entry.name)) {
166
+ passed.push(entry.name);
167
+ }
168
+ }
169
+ } catch {
170
+ // ignore
171
+ }
172
+ return passed;
173
+ }
174
+
175
+ function isOpsxApplySkill(toolInput) {
176
+ if (!toolInput) return false;
177
+ let input;
178
+ if (typeof toolInput === 'string') {
179
+ try { input = JSON.parse(toolInput); } catch { return false; }
180
+ } else {
181
+ input = toolInput;
182
+ }
183
+ if (!input || typeof input !== 'object') return false;
184
+ return (input.skill || '') === 'opsx-apply';
185
+ }
186
+
187
+ function extractChangeName(toolInput) {
188
+ if (!toolInput) return null;
189
+ let input;
190
+ if (typeof toolInput === 'string') {
191
+ try { input = JSON.parse(toolInput); } catch { return null; }
192
+ } else {
193
+ input = toolInput;
194
+ }
195
+ if (!input || typeof input !== 'object') return null;
196
+
197
+ const args = input.args || '';
198
+ if (!args.trim()) return null;
199
+ const trimmed = args.trim();
200
+ const changeFlagMatch = trimmed.match(/^--change\s+(.+)$/);
201
+ if (changeFlagMatch) {
202
+ return safeChangeName(changeFlagMatch[1].trim().split(/\s+/)[0]);
203
+ }
204
+ return safeChangeName(trimmed.split(/\s+/)[0]);
205
+ }
206
+
207
+ function blockWithReason(reason, extra) {
208
+ console.log(JSON.stringify({ decision: 'block', reason, ...(extra || {}) }));
209
+ process.exit(2);
210
+ }
211
+
212
+ function allowExit(extra) {
213
+ if (extra) {
214
+ console.log(JSON.stringify({ decision: 'allow', ...extra }));
215
+ }
216
+ process.exit(0);
217
+ }
218
+
219
+ function evaluateApplyWriteGate(input, provider = 'codebuddy') {
220
+ const toolName = String(input.tool_name || input.toolName || '');
221
+ if (!toolName) {
222
+ return {
223
+ action: 'block',
224
+ reason: 'stdin 缺少必要字段 tool_name,无法执行 apply 写入门禁检查',
225
+ };
226
+ }
227
+ if (toolName !== 'Write' && toolName !== 'Edit') {
228
+ return { action: 'allow' };
229
+ }
230
+
231
+ const projectRoot = getProjectRoot(input, provider);
232
+ const activeApply = findActiveApplyStage(projectRoot);
233
+ if (!activeApply) {
234
+ return { action: 'allow' };
235
+ }
236
+ if (hasCompletedCheck(projectRoot, activeApply.change)) {
237
+ return { action: 'allow' };
238
+ }
239
+
240
+ const changeName = activeApply.change || 'unknown';
241
+ return {
242
+ action: 'block',
243
+ reason: `[SDD Apply Gate] 检测到 apply 阶段正在执行(change: ${changeName}),但 check 阶段尚未完成。\n\n请先执行 /opsx-check 完成质量门禁检查,再执行 /opsx-apply 进行代码实施。\n\n操作顺序:/opsx-check → /opsx-apply`,
244
+ };
245
+ }
246
+
247
+ function evaluateSkillApplyGate(input, provider = 'codebuddy') {
248
+ const toolName = String(input.tool_name || input.toolName || '');
249
+ if (!toolName) {
250
+ return {
251
+ action: 'block',
252
+ reason: 'stdin 缺少必要字段 tool_name,无法执行 Skill apply 门禁检查',
253
+ };
254
+ }
255
+ if (toolName !== 'Skill') {
256
+ return { action: 'allow' };
257
+ }
258
+
259
+ const toolInput = input.tool_input;
260
+ if (!isOpsxApplySkill(toolInput)) {
261
+ return { action: 'allow' };
262
+ }
263
+
264
+ const projectRoot = getProjectRoot(input, provider);
265
+ if (!findProjectRoot(projectRoot)) {
266
+ return { action: 'allow' };
267
+ }
268
+
269
+ const changeName = extractChangeName(toolInput);
270
+ if (changeName) {
271
+ if (hasCompletedCheck(projectRoot, changeName)) {
272
+ return { action: 'allow' };
273
+ }
274
+ return {
275
+ action: 'block',
276
+ reason: `变更 "${changeName}" 尚未完成 check 阶段。请先执行 /opsx-check ${changeName} 完成检查后再 apply。`,
277
+ };
278
+ }
279
+
280
+ const passedChanges = getPassedChanges(projectRoot);
281
+ if (passedChanges.length === 0) {
282
+ return {
283
+ action: 'block',
284
+ reason: '当前项目没有任何变更已完成 check 阶段。请先执行 /opsx-check <change-name> 完成检查后再 apply。',
285
+ };
286
+ }
287
+
288
+ const msg = passedChanges.length === 1
289
+ ? `检测到已通过 check 的变更: "${passedChanges[0]}",允许进入 apply。`
290
+ : `检测到 ${passedChanges.length} 个已通过 check 的变更: ${passedChanges.join(', ')},允许进入 apply。`;
291
+ return { action: 'allow', reason: msg };
292
+ }
293
+
294
+ function evaluatePreToolDangerGate(input) {
295
+ const toolInput = input.tool_input || input.toolInput || {};
296
+ const command = String(toolInput.command || input.command || '');
297
+ const dangerousPatterns = [
298
+ /\brm\s+-rf\b/i,
299
+ /\brmdir\s+\/s\b/i,
300
+ /\bdel\s+\/[fsq]/i,
301
+ /\bgit\s+reset\s+--hard\b/i,
302
+ /\bgit\s+clean\s+-fdx\b/i,
303
+ /\bRemove-Item\b.*\b-Recurse\b.*\b-Force\b/i,
304
+ ];
305
+ if (dangerousPatterns.some((pattern) => pattern.test(command))) {
306
+ return {
307
+ action: 'block',
308
+ reason: 'Blocked by SDD hook: destructive command requires explicit user approval.',
309
+ };
310
+ }
311
+ return { action: 'allow' };
312
+ }
313
+
314
+ module.exports = {
315
+ PROJECT_ENV_KEYS,
316
+ readStdin,
317
+ parseHookInput,
318
+ normalizeHookInput,
319
+ hasTelemetryCli,
320
+ findProjectRoot,
321
+ getProjectRoot,
322
+ safeChangeName,
323
+ findActiveApplyStage,
324
+ hasCompletedCheck,
325
+ getPassedChanges,
326
+ isOpsxApplySkill,
327
+ extractChangeName,
328
+ blockWithReason,
329
+ allowExit,
330
+ evaluateApplyWriteGate,
331
+ evaluateSkillApplyGate,
332
+ evaluatePreToolDangerGate,
333
+ };