kld-sdd 2.4.16 → 2.4.18

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.
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SDD Apply Test Gate Hook
4
+ *
5
+ * 当 proposal 选择单元测试策略(test-strategy: tdd | impl-first)时,
6
+ * 在 apply 收尾前校验是否已有真实测试执行证据;缺失则阻断并提示模型执行测试。
7
+ *
8
+ * 触发点:
9
+ * - PreToolUse(Bash): apply-worktree-finish(非 --record-base)、log.cjs end(apply 阶段)
10
+ * - Stop: 存在活跃 apply 阶段且即将结束会话
11
+ *
12
+ * 退出码:0 放行 | 2 阻断(stdout JSON decision:block)
13
+ */
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { execFileSync } = require('child_process');
19
+
20
+ function readStdin() {
21
+ try {
22
+ return fs.readFileSync(0, 'utf8');
23
+ } catch {
24
+ return '';
25
+ }
26
+ }
27
+
28
+ function parseInput(raw) {
29
+ try {
30
+ return JSON.parse(raw || '{}');
31
+ } catch {
32
+ return {};
33
+ }
34
+ }
35
+
36
+ function hasTelemetryCli(dir) {
37
+ return fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.cjs')) ||
38
+ fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'));
39
+ }
40
+
41
+ function findProjectRoot(startDir) {
42
+ if (!startDir) return '';
43
+ let current = path.resolve(startDir);
44
+ for (let i = 0; i < 25; i++) {
45
+ if (hasTelemetryCli(current)) return current;
46
+ const parent = path.dirname(current);
47
+ if (parent === current) return '';
48
+ current = parent;
49
+ }
50
+ return '';
51
+ }
52
+
53
+ function getProjectRoot(input) {
54
+ const toolInput = input.tool_input || input.toolInput || {};
55
+ const candidates = [
56
+ toolInput.cwd,
57
+ input.cwd,
58
+ input.project_root,
59
+ process.env.CLAUDE_PROJECT_DIR,
60
+ process.env.PWD,
61
+ process.cwd(),
62
+ ].filter(Boolean);
63
+ for (const dir of candidates) {
64
+ const root = findProjectRoot(dir);
65
+ if (root) return root;
66
+ }
67
+ return process.cwd();
68
+ }
69
+
70
+ function safeChangeName(name) {
71
+ if (!name) return '';
72
+ return String(name)
73
+ .toLowerCase()
74
+ .replace(/[\s_]+/g, '-')
75
+ .replace(/[^a-z0-9一-鿿\-]/g, '-')
76
+ .replace(/-+/g, '-')
77
+ .replace(/^-|-$/g, '');
78
+ }
79
+
80
+ function findActiveApplyStage(projectRoot) {
81
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
82
+ if (!fs.existsSync(stateDir)) return null;
83
+
84
+ let latest = null;
85
+ for (const file of fs.readdirSync(stateDir).filter((f) => f.endsWith('.json'))) {
86
+ try {
87
+ const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
88
+ const event = data.event || null;
89
+ if (event && event.command === 'apply') {
90
+ if (!latest || new Date(event.timestamp) > new Date(latest.timestamp)) {
91
+ latest = event;
92
+ }
93
+ }
94
+ } catch {
95
+ // skip
96
+ }
97
+ }
98
+ return latest;
99
+ }
100
+
101
+ function findProposalPath(projectRoot, changeName) {
102
+ const candidates = [
103
+ path.join(projectRoot, 'openspec', 'changes', changeName, 'proposal.md'),
104
+ path.join(projectRoot, 'changes', changeName, 'proposal.md'),
105
+ ];
106
+ for (const p of candidates) {
107
+ if (fs.existsSync(p)) return p;
108
+ }
109
+ const safe = safeChangeName(changeName);
110
+ if (safe && safe !== changeName) {
111
+ return findProposalPath(projectRoot, safe);
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function readTestStrategy(projectRoot, changeName) {
117
+ const proposalPath = findProposalPath(projectRoot, changeName);
118
+ if (!proposalPath) return null;
119
+ try {
120
+ const content = fs.readFileSync(proposalPath, 'utf8');
121
+ const fm = content.match(/^---\s*[\r\n]+([\s\S]*?)[\r\n]+---/);
122
+ if (!fm) return null;
123
+ const m = fm[1].match(/^\s*test-strategy:\s*["']?([a-z-]+)["']?\s*$/im);
124
+ return m ? m[1].trim() : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+
130
+ function requiresUnitTests(strategy) {
131
+ return strategy === 'tdd' || strategy === 'impl-first';
132
+ }
133
+
134
+ function isRealTestDetails(testResults) {
135
+ if (!testResults || typeof testResults !== 'object') return false;
136
+ const command = String(testResults.command || '').trim();
137
+ if (!command) return false;
138
+ const passed = Number(testResults.passed);
139
+ const failed = Number(testResults.failed);
140
+ const duration = Number(testResults.duration_ms);
141
+ if (Number.isFinite(passed) && passed > 0) return true;
142
+ if (Number.isFinite(failed) && failed > 0) return true;
143
+ if (Number.isFinite(duration) && duration > 0) return true;
144
+ return false;
145
+ }
146
+
147
+ function loadChangeEvents(projectRoot, changeName) {
148
+ const safe = safeChangeName(changeName);
149
+ const dir = path.join(projectRoot, 'skywalk-sdd', 'events', safe);
150
+ if (!fs.existsSync(dir)) return [];
151
+
152
+ const events = [];
153
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
154
+ try {
155
+ const lines = fs.readFileSync(path.join(dir, file), 'utf8').split('\n').filter(Boolean);
156
+ for (const line of lines) {
157
+ try {
158
+ events.push(JSON.parse(line));
159
+ } catch {
160
+ // skip
161
+ }
162
+ }
163
+ } catch {
164
+ // skip
165
+ }
166
+ }
167
+ return events;
168
+ }
169
+
170
+ function hasRealTestExecution(projectRoot, changeName, capability, sinceTimestamp) {
171
+ const since = sinceTimestamp ? new Date(sinceTimestamp).getTime() : 0;
172
+ const events = loadChangeEvents(projectRoot, changeName);
173
+
174
+ for (const event of events) {
175
+ const ts = new Date(event.timestamp || 0).getTime();
176
+ if (since && ts < since) continue;
177
+
178
+ if (event.type === 'stage_end' && event.command === 'test' &&
179
+ (event.result === 'success' || event.result === 'failure')) {
180
+ return { kind: 'opsx-test', event };
181
+ }
182
+
183
+ if (event.type === 'test_result') {
184
+ const tr = event.details && event.details.test_results;
185
+ if (isRealTestDetails(tr)) {
186
+ return { kind: 'test_result', event };
187
+ }
188
+ }
189
+
190
+ if (event.type === 'task_update' && (event.command === 'apply' || !event.command)) {
191
+ const tr = event.details && event.details.test_results;
192
+ if (isRealTestDetails(tr)) {
193
+ if (!capability || !event.capability || event.capability === capability) {
194
+ return { kind: 'task_update', event };
195
+ }
196
+ }
197
+ }
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function isApplyCompletionBash(command) {
203
+ const cmd = String(command || '');
204
+ if (/apply-worktree-finish\.cjs/.test(cmd) && !/--record-base/.test(cmd)) {
205
+ return true;
206
+ }
207
+ if (/(?:skywalk-sdd\/)?log\.cjs\s+end\b/.test(cmd)) {
208
+ return true;
209
+ }
210
+ return false;
211
+ }
212
+
213
+ function block(decision, reason, extra) {
214
+ console.log(JSON.stringify({ decision, reason, ...extra }));
215
+ process.exit(2);
216
+ }
217
+
218
+ function recordWarning(projectRoot, applyEvent, code, message) {
219
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.cjs');
220
+ if (!fs.existsSync(logPath)) return;
221
+ try {
222
+ const args = [
223
+ logPath,
224
+ 'record',
225
+ '--type=telemetry_warning',
226
+ '--command=apply',
227
+ `--project=${projectRoot}`,
228
+ `--change=${applyEvent.change || 'general'}`,
229
+ '--agent=claude-code',
230
+ '--source=claude-hook',
231
+ '--result=partial',
232
+ `--summary=${message}`,
233
+ `--details-json=${JSON.stringify({ warning: code, test_strategy: applyEvent.test_strategy })}`,
234
+ ];
235
+ if (applyEvent.capability) args.push(`--capability=${applyEvent.capability}`);
236
+ if (applyEvent.session_id) args.push(`--session-id=${applyEvent.session_id}`);
237
+ execFileSync('node', args, { cwd: projectRoot, stdio: 'ignore' });
238
+ } catch {
239
+ // ignore
240
+ }
241
+ }
242
+
243
+ function buildReason(strategy, changeName, capability) {
244
+ const cap = capability ? ` / ${capability}` : '';
245
+ const strict = strategy === 'tdd';
246
+ const lines = [
247
+ `[SDD Apply Test Gate] 变更 ${changeName}${cap} 的 test-strategy=${strategy},但尚未检测到真实单元测试执行记录。`,
248
+ '',
249
+ '请先在本项目根目录(或 apply worktree)执行单元测试,例如:',
250
+ ' npm test / pnpm test / pytest / go test ./... / cargo test',
251
+ '',
252
+ '执行后应产生以下任一 telemetry 证据:',
253
+ ' - skywalk-sdd 事件 test_result(含实际 command 与 passed/failed/duration)',
254
+ ' - task_update 中 test_results 非空占位',
255
+ ' - 或运行 /opsx-test 完成 test 阶段',
256
+ '',
257
+ strict
258
+ ? 'tdd 策略:测试未执行前不得结束 apply 或执行 apply-worktree-finish。'
259
+ : 'impl-first 策略:实现后须补跑测试并确认通过,再结束 apply。',
260
+ '',
261
+ '完成后可重试当前操作。',
262
+ ];
263
+ return lines.join('\n');
264
+ }
265
+
266
+ function shouldGate(projectRoot, applyEvent) {
267
+ const strategy = readTestStrategy(projectRoot, applyEvent.change);
268
+ if (!requiresUnitTests(strategy)) {
269
+ return null;
270
+ }
271
+ const evidence = hasRealTestExecution(
272
+ projectRoot,
273
+ applyEvent.change,
274
+ applyEvent.capability,
275
+ applyEvent.timestamp
276
+ );
277
+ if (evidence) {
278
+ return null;
279
+ }
280
+ return { strategy, changeName: applyEvent.change, capability: applyEvent.capability };
281
+ }
282
+
283
+ // ── 主逻辑 ─────────────────────────────────────────────
284
+
285
+ const input = parseInput(readStdin());
286
+ const projectRoot = getProjectRoot(input);
287
+ const toolName = String(input.tool_name || input.toolName || '');
288
+ const toolInput = input.tool_input || input.toolInput || {};
289
+ const command = String(toolInput.command || '');
290
+
291
+ const activeApply = findActiveApplyStage(projectRoot);
292
+ if (!activeApply) {
293
+ process.exit(0);
294
+ }
295
+
296
+ const bashGate = toolName === 'Bash' && isApplyCompletionBash(command);
297
+ const stopGate = !toolName || toolName === 'Stop';
298
+
299
+ if (!bashGate && !stopGate) {
300
+ process.exit(0);
301
+ }
302
+
303
+ const gate = shouldGate(projectRoot, activeApply);
304
+ if (!gate) {
305
+ process.exit(0);
306
+ }
307
+
308
+ const reason = buildReason(gate.strategy, gate.changeName, gate.capability);
309
+ recordWarning(projectRoot, { ...activeApply, test_strategy: gate.strategy }, 'apply_test_missing', 'Apply test gate blocked: no unit test evidence');
310
+
311
+ block('block', reason, {
312
+ test_strategy: gate.strategy,
313
+ change: gate.changeName,
314
+ capability: gate.capability,
315
+ });
@@ -0,0 +1,268 @@
1
+ /**
2
+ * sdd-skill-apply-gate.cjs
3
+ *
4
+ * PreToolUse(Skill) 门禁 —— 在 opsx-apply Skill 被调用的瞬间阻断,不再等到 Write/Edit 阶段。
5
+ *
6
+ * 门禁体系:
7
+ * L1: sdd-skill-apply-gate (PreToolUse/Skill) ← 本文件 — check 前置
8
+ * L2: sdd-apply-gate (PreToolUse/Write|Edit)
9
+ * L3: SKILL.md Step 1.2 (Skill 自检)
10
+ * L4: sdd-apply-test-gate (PreToolUse/Bash + Stop) — apply 后单元测试真实执行
11
+ *
12
+ * 判定逻辑:
13
+ * - 非 opsx-apply 调用 → 放行
14
+ * - 提供了 change-name → 检查该变更是否已完成 check,未完成则阻断
15
+ * - 未提供 change-name → 扫描所有变更,只要有一个已完成 check 则放行,否则阻断
16
+ *
17
+ * 退出码约定(遵循 Claude Code hook 规范):
18
+ * 0 → 放行(stdout 可为空,或输出允许决策 JSON)
19
+ * 2 → 阻断(stdout 需包含 { decision: "block", reason: "..." })
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // 工具函数
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /** 读取 stdin(与 sdd-prompt.cjs 保持一致的简洁实现) */
32
+ function readStdin() {
33
+ try {
34
+ return fs.readFileSync(0, 'utf8');
35
+ } catch (_) {
36
+ return '';
37
+ }
38
+ }
39
+
40
+ /** 安全解析 hook 输入 JSON */
41
+ function parseInput(raw) {
42
+ if (!raw || !raw.trim()) return null;
43
+ try {
44
+ return JSON.parse(raw);
45
+ } catch (_) {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ /**
51
+ * 向上查找项目根目录 —— 以 skywalk-sdd/log.cjs 文件存在为标志
52
+ */
53
+ function findProjectRoot(startDir) {
54
+ let dir = startDir;
55
+ for (let i = 0; i < 20; i++) {
56
+ if (fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.cjs'))) {
57
+ return dir;
58
+ }
59
+ const parent = path.dirname(dir);
60
+ if (parent === dir) break;
61
+ dir = parent;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /** 从 hook 输入中提取项目根目录 */
67
+ function getProjectRoot(input) {
68
+ if (!input) return null;
69
+ const cwd = input.cwd || process.cwd();
70
+ return findProjectRoot(cwd);
71
+ }
72
+
73
+ /**
74
+ * 规范化变更名称 —— 与 openspec/changes/ 下的目录名保持一致
75
+ * 规则:
76
+ * - 转小写
77
+ * - 特殊字符 → 连字符
78
+ * - 中文保留原样
79
+ */
80
+ function safeChangeName(name) {
81
+ if (!name) return '';
82
+ return name
83
+ .toLowerCase()
84
+ .replace(/[\s_]+/g, '-')
85
+ .replace(/[^a-z0-9一-鿿\-]/g, '-')
86
+ .replace(/-+/g, '-')
87
+ .replace(/^-|-$/g, '');
88
+ }
89
+
90
+ /**
91
+ * 判断指定变更是否已完成 check 阶段。
92
+ *
93
+ * 检测方式:读取 skywalk-sdd/events/<changeName>/ 下的 JSONL 事件文件,
94
+ * 查找 stage_end + command=check + result=success 事件。
95
+ */
96
+ function hasCompletedCheck(projectRoot, changeName) {
97
+ if (!projectRoot || !changeName) return false;
98
+ const eventsDir = path.join(projectRoot, 'skywalk-sdd', 'events', changeName);
99
+ if (!fs.existsSync(eventsDir)) return false;
100
+
101
+ try {
102
+ const files = fs.readdirSync(eventsDir).filter(f => f.endsWith('.jsonl'));
103
+ for (const file of files) {
104
+ const filePath = path.join(eventsDir, file);
105
+ const content = fs.readFileSync(filePath, 'utf8');
106
+ const lines = content.split('\n').filter(l => l.trim());
107
+ for (const line of lines) {
108
+ try {
109
+ const evt = JSON.parse(line);
110
+ if (
111
+ evt.type === 'stage_end' &&
112
+ evt.command === 'check' &&
113
+ (evt.result === 'success' || evt.result === 'partial')
114
+ ) {
115
+ return true;
116
+ }
117
+ } catch (_) { /* skip malformed lines */ }
118
+ }
119
+ }
120
+ } catch (_) {
121
+ return false;
122
+ }
123
+ return false;
124
+ }
125
+
126
+ /**
127
+ * 扫描 openspec/changes/ 下所有已通过 check 的变更。
128
+ * 返回已通过 check 的变更名称列表。
129
+ */
130
+ function getPassedChanges(projectRoot) {
131
+ if (!projectRoot) return [];
132
+ const changesDir = path.join(projectRoot, 'openspec', 'changes');
133
+ if (!fs.existsSync(changesDir)) return [];
134
+
135
+ const passed = [];
136
+ try {
137
+ const entries = fs.readdirSync(changesDir, { withFileTypes: true });
138
+ for (const entry of entries) {
139
+ if (!entry.isDirectory()) continue;
140
+ if (entry.name.startsWith('.')) continue;
141
+ if (entry.name === 'archive') continue;
142
+ if (hasCompletedCheck(projectRoot, entry.name)) {
143
+ passed.push(entry.name);
144
+ }
145
+ }
146
+ } catch (_) { /* ignore */ }
147
+ return passed;
148
+ }
149
+
150
+ /**
151
+ * 判断给定的 tool_input 字符串是否为 opsx-apply Skill 调用。
152
+ *
153
+ * Skill 的 tool_input 格式为 JSON 字符串: {"skill": "opsx-apply", "args": "..."}
154
+ */
155
+ function isOpsxApplySkill(toolInput) {
156
+ if (!toolInput) return false;
157
+ let input;
158
+ if (typeof toolInput === 'string') {
159
+ try { input = JSON.parse(toolInput); } catch (_) { return false; }
160
+ } else {
161
+ input = toolInput;
162
+ }
163
+ if (!input || typeof input !== 'object') return false;
164
+ const skill = input.skill || '';
165
+ return skill === 'opsx-apply';
166
+ }
167
+
168
+ /**
169
+ * 从 tool_input 中提取 change-name。
170
+ * opsx-apply 的 args 格式为: "change-name" 或 "--change change-name"
171
+ */
172
+ function extractChangeName(toolInput) {
173
+ if (!toolInput) return null;
174
+ let input;
175
+ if (typeof toolInput === 'string') {
176
+ try { input = JSON.parse(toolInput); } catch (_) { return null; }
177
+ } else {
178
+ input = toolInput;
179
+ }
180
+ if (!input || typeof input !== 'object') return null;
181
+
182
+ const args = input.args || '';
183
+ if (!args.trim()) return null;
184
+
185
+ // args 可能是 "change-name" 或 "--change change-name ..." 格式
186
+ const trimmed = args.trim();
187
+
188
+ // 尝试 --change <name> 格式
189
+ const changeFlagMatch = trimmed.match(/^--change\s+(.+)$/);
190
+ if (changeFlagMatch) {
191
+ return safeChangeName(changeFlagMatch[1].trim().split(/\s+/)[0]);
192
+ }
193
+
194
+ // 否则整个 args 的第一个 token 就是 change-name
195
+ const firstToken = trimmed.split(/\s+/)[0];
196
+ return safeChangeName(firstToken);
197
+ }
198
+
199
+ // ---------------------------------------------------------------------------
200
+ // 主逻辑
201
+ // ---------------------------------------------------------------------------
202
+
203
+ function main() {
204
+ const raw = readStdin();
205
+ const input = parseInput(raw);
206
+
207
+ if (!input) {
208
+ // 无法解析输入,保守放行(避免阻断非 opsx-apply 操作)
209
+ process.exit(0);
210
+ }
211
+
212
+ // --- 仅处理 Skill 工具调用 ---
213
+ const toolName = input.tool_name || '';
214
+ if (toolName !== 'Skill') {
215
+ // 非 Skill 调用,放行
216
+ process.exit(0);
217
+ }
218
+
219
+ // --- 确认是否为 opsx-apply ---
220
+ const toolInput = input.tool_input;
221
+ if (!isOpsxApplySkill(toolInput)) {
222
+ // 不是 opsx-apply skill,放行
223
+ process.exit(0);
224
+ }
225
+
226
+ // --- 确定项目根目录 ---
227
+ const projectRoot = getProjectRoot(input);
228
+ if (!projectRoot) {
229
+ // 找不到项目根目录,说明不在 kld-sdd 项目中,放行
230
+ process.exit(0);
231
+ }
232
+
233
+ // --- 提取 change-name ---
234
+ const changeName = extractChangeName(toolInput);
235
+
236
+ if (changeName) {
237
+ // 场景 A:指定了 change-name → 精确检查
238
+ if (hasCompletedCheck(projectRoot, changeName)) {
239
+ process.exit(0); // 已通过 check,放行
240
+ }
241
+ // 未通过 check,阻断
242
+ const reason = `变更 "${changeName}" 尚未完成 check 阶段。请先执行 /opsx-check ${changeName} 完成检查后再 apply。`;
243
+ console.log(JSON.stringify({ decision: 'block', reason }));
244
+ process.exit(2);
245
+ } else {
246
+ // 场景 B:未指定 change-name → 扫描所有变更
247
+ const passedChanges = getPassedChanges(projectRoot);
248
+
249
+ if (passedChanges.length === 0) {
250
+ const reason = '当前项目没有任何变更已完成 check 阶段。请先执行 /opsx-check <change-name> 完成检查后再 apply。';
251
+ console.log(JSON.stringify({ decision: 'block', reason }));
252
+ process.exit(2);
253
+ }
254
+
255
+ // 有已通过 check 的变更,放行(后续由 Skill 自身引导用户选择)
256
+ if (passedChanges.length === 1) {
257
+ const msg = `检测到已通过 check 的变更: "${passedChanges[0]}",允许进入 apply。`;
258
+ console.log(JSON.stringify({ decision: 'allow', reason: msg }));
259
+ } else {
260
+ const msg = `检测到 ${passedChanges.length} 个已通过 check 的变更: ${passedChanges.join(', ')},允许进入 apply。`;
261
+ console.log(JSON.stringify({ decision: 'allow', reason: msg }));
262
+ }
263
+ process.exit(0);
264
+ }
265
+ }
266
+
267
+ // --- 入口 ---
268
+ main();
@@ -11,12 +11,25 @@
11
11
  }
12
12
  ],
13
13
  "PreToolUse": [
14
+ {
15
+ "matcher": "Skill",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/sdd-skill-apply-gate.cjs\""
20
+ }
21
+ ]
22
+ },
14
23
  {
15
24
  "matcher": "Bash",
16
25
  "hooks": [
17
26
  {
18
27
  "type": "command",
19
28
  "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/sdd-pre-tool.cjs\""
29
+ },
30
+ {
31
+ "type": "command",
32
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/sdd-apply-test-gate.cjs\""
20
33
  }
21
34
  ]
22
35
  },
@@ -44,6 +57,10 @@
44
57
  "Stop": [
45
58
  {
46
59
  "hooks": [
60
+ {
61
+ "type": "command",
62
+ "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/sdd-apply-test-gate.cjs\""
63
+ },
47
64
  {
48
65
  "type": "command",
49
66
  "command": "node \"${CLAUDE_PROJECT_DIR}/.claude/hooks/sdd-stop.cjs\""