@hunterzheng/kld-sdd 2.4.19

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 (52) hide show
  1. package/README.md +275 -0
  2. package/bin/kld-sdd-init.js +24 -0
  3. package/index.js +13 -0
  4. package/lib/init.js +1124 -0
  5. package/lib/skills-bundle.js +30 -0
  6. package/package.json +48 -0
  7. package/skywalk-sdd/apply-worktree-finish.cjs +551 -0
  8. package/skywalk-sdd/index.cjs +2991 -0
  9. package/templates/ci/github-actions-sdd.yml +67 -0
  10. package/templates/ci/gitlab-ci-sdd.yml +44 -0
  11. package/templates/git-hooks/pre-commit-sdd-check.cjs +155 -0
  12. package/templates/git-hooks/pre-push-sdd-check.cjs +56 -0
  13. package/templates/hooks/claude/hooks/sdd-apply-gate.cjs +173 -0
  14. package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +315 -0
  15. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +146 -0
  16. package/templates/hooks/claude/hooks/sdd-pre-tool.cjs +41 -0
  17. package/templates/hooks/claude/hooks/sdd-prompt.cjs +88 -0
  18. package/templates/hooks/claude/hooks/sdd-skill-apply-gate.cjs +268 -0
  19. package/templates/hooks/claude/hooks/sdd-stop.cjs +108 -0
  20. package/templates/hooks/claude/settings.json +72 -0
  21. package/templates/openspec/design.md +290 -0
  22. package/templates/openspec/overview.md +143 -0
  23. package/templates/openspec/proposal.md +108 -0
  24. package/templates/openspec/spec.md +185 -0
  25. package/templates/openspec/tasks.md +287 -0
  26. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +251 -0
  27. package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
  28. package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +129 -0
  29. package/templates/skills/kld-sdd/opsx-apply/reference.md +335 -0
  30. package/templates/skills/kld-sdd/opsx-apply/worktree-setup.md +104 -0
  31. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +162 -0
  32. package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
  33. package/templates/skills/kld-sdd/opsx-check/SKILL.md +197 -0
  34. package/templates/skills/kld-sdd/opsx-check/checklist.md +35 -0
  35. package/templates/skills/kld-sdd/opsx-design/SKILL.md +166 -0
  36. package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
  37. package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
  38. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +104 -0
  39. package/templates/skills/kld-sdd/opsx-knowledge/SKILL.md +130 -0
  40. package/templates/skills/kld-sdd/opsx-knowledge/references/modules.md +26 -0
  41. package/templates/skills/kld-sdd/opsx-knowledge/scripts/config.json +39 -0
  42. package/templates/skills/kld-sdd/opsx-knowledge/scripts/retrieve.cjs +199 -0
  43. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +201 -0
  44. package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
  45. package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
  46. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +168 -0
  47. package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
  48. package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
  49. package/templates/skills/kld-sdd/opsx-task/SKILL.md +199 -0
  50. package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
  51. package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
  52. package/templates/skills/kld-sdd/opsx-test/SKILL.md +143 -0
@@ -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,146 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { execFileSync } = require('child_process');
5
+
6
+ function readStdin() {
7
+ try {
8
+ return fs.readFileSync(0, 'utf8');
9
+ } catch {
10
+ return '';
11
+ }
12
+ }
13
+
14
+ function parseInput(raw) {
15
+ try {
16
+ return JSON.parse(raw || '{}');
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ function hasTelemetryCli(dir) {
23
+ return fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.cjs')) ||
24
+ fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'));
25
+ }
26
+
27
+ function findTelemetryRoot(startDir) {
28
+ if (!startDir) return '';
29
+
30
+ let current = path.resolve(startDir);
31
+ while (true) {
32
+ if (hasTelemetryCli(current)) return current;
33
+ const parent = path.dirname(current);
34
+ if (parent === current) return '';
35
+ current = parent;
36
+ }
37
+ }
38
+
39
+ function findProjectRoot(input) {
40
+ const toolInput = input.tool_input || input.toolInput || {};
41
+ const candidates = [
42
+ toolInput.cwd,
43
+ input.cwd,
44
+ input.project_root,
45
+ process.env.CLAUDE_PROJECT_DIR,
46
+ process.env.PWD,
47
+ process.cwd(),
48
+ ].filter(Boolean);
49
+ for (const dir of candidates) {
50
+ const root = findTelemetryRoot(dir);
51
+ if (root) return root;
52
+ }
53
+ return process.cwd();
54
+ }
55
+
56
+ function latestActiveStage(projectRoot) {
57
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
58
+ if (!fs.existsSync(stateDir)) return null;
59
+ return fs.readdirSync(stateDir)
60
+ .filter(file => file.endsWith('.json'))
61
+ .map(file => {
62
+ try {
63
+ const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
64
+ return data.event || null;
65
+ } catch {
66
+ return null;
67
+ }
68
+ })
69
+ .filter(Boolean)
70
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] || null;
71
+ }
72
+
73
+ function inferResult(input) {
74
+ const response = input.tool_response || input.toolResponse || {};
75
+ if (typeof response.exit_code === 'number') return response.exit_code === 0 ? 'success' : 'failure';
76
+ if (typeof response.exitCode === 'number') return response.exitCode === 0 ? 'success' : 'failure';
77
+ if (typeof response.success === 'boolean') return response.success ? 'success' : 'failure';
78
+ return 'partial';
79
+ }
80
+
81
+ function inferRecord(command) {
82
+ if (/\b(npm|pnpm|yarn)\s+(test|run\s+test)\b|\bpytest\b|\bmvn\b.*\btest\b|\bgo\s+test\b|\bcargo\s+test\b/i.test(command)) {
83
+ return {
84
+ type: 'test_result',
85
+ detailsKey: 'test_results',
86
+ details: { command, passed: 0, failed: 0, skipped: 0, coverage: null, duration_ms: null },
87
+ };
88
+ }
89
+ if (/\b(npm|pnpm|yarn)\s+(run\s+build|build)\b|\btsc\b|\bmvn\b.*\bcompile\b|\bgradle\b.*\bcompile|\bgo\s+build\b|\bcargo\s+check\b/i.test(command)) {
90
+ return {
91
+ type: 'build_result',
92
+ detailsKey: 'build_results',
93
+ details: { command, success: null, duration_ms: null, error_count: null },
94
+ };
95
+ }
96
+ return null;
97
+ }
98
+
99
+ function recordTelemetry(projectRoot, activeStage, record, result) {
100
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.cjs');
101
+ const legacyLogPath = path.join(projectRoot, 'skywalk-sdd', 'log.js');
102
+ const logCli = fs.existsSync(logPath) ? logPath : legacyLogPath;
103
+ if (!fs.existsSync(logCli)) return;
104
+
105
+ const details = { [record.detailsKey]: { ...record.details } };
106
+ if (record.type === 'build_result') {
107
+ details.build_results.success = result === 'success';
108
+ details.build_results.error_count = result === 'success' ? 0 : null;
109
+ }
110
+
111
+ const args = [
112
+ logCli,
113
+ 'record',
114
+ `--type=${record.type}`,
115
+ `--command=${activeStage.command || activeStage.stage || 'unknown'}`,
116
+ `--project=${projectRoot}`,
117
+ `--change=${activeStage.change || 'general'}`,
118
+ `--agent=${activeStage.agent_type || 'claude-code'}`,
119
+ '--source=claude-hook',
120
+ `--result=${result}`,
121
+ `--summary=Claude hook captured ${record.type}`,
122
+ `--details-json=${JSON.stringify(details)}`,
123
+ ];
124
+ if (activeStage.capability) args.push(`--capability=${activeStage.capability}`);
125
+ if (activeStage.task_id) args.push(`--task-id=${activeStage.task_id}`);
126
+ if (activeStage.session_id) args.push(`--session-id=${activeStage.session_id}`);
127
+ execFileSync('node', args, { cwd: projectRoot, stdio: 'ignore' });
128
+ }
129
+
130
+ const input = parseInput(readStdin());
131
+ const toolInput = input.tool_input || input.toolInput || {};
132
+ const command = String(toolInput.command || input.command || '');
133
+ const record = inferRecord(command);
134
+ if (!record) process.exit(0);
135
+
136
+ const projectRoot = findProjectRoot(input);
137
+ const activeStage = latestActiveStage(projectRoot);
138
+ if (!activeStage || !activeStage.change || activeStage.change === 'general') {
139
+ process.exit(0);
140
+ }
141
+
142
+ try {
143
+ recordTelemetry(projectRoot, activeStage, record, inferResult(input));
144
+ } catch {
145
+ process.exit(0);
146
+ }
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+
4
+ function readStdin() {
5
+ try {
6
+ return fs.readFileSync(0, 'utf8');
7
+ } catch {
8
+ return '';
9
+ }
10
+ }
11
+
12
+ function parseInput(raw) {
13
+ try {
14
+ return JSON.parse(raw || '{}');
15
+ } catch {
16
+ return {};
17
+ }
18
+ }
19
+
20
+ const input = parseInput(readStdin());
21
+ const toolInput = input.tool_input || input.toolInput || {};
22
+ const command = String(toolInput.command || input.command || '');
23
+
24
+ const dangerousPatterns = [
25
+ /\brm\s+-rf\b/i,
26
+ /\brmdir\s+\/s\b/i,
27
+ /\bdel\s+\/[fsq]/i,
28
+ /\bgit\s+reset\s+--hard\b/i,
29
+ /\bgit\s+clean\s+-fdx\b/i,
30
+ /\bRemove-Item\b.*\b-Recurse\b.*\b-Force\b/i,
31
+ ];
32
+
33
+ if (dangerousPatterns.some(pattern => pattern.test(command))) {
34
+ console.log(JSON.stringify({
35
+ decision: 'block',
36
+ reason: 'Blocked by SDD hook: destructive command requires explicit user approval.',
37
+ }));
38
+ process.exit(2);
39
+ }
40
+
41
+ process.exit(0);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+
5
+ function readStdin() {
6
+ try {
7
+ return fs.readFileSync(0, 'utf8');
8
+ } catch {
9
+ return '';
10
+ }
11
+ }
12
+
13
+ function parseInput(raw) {
14
+ try {
15
+ return JSON.parse(raw || '{}');
16
+ } catch {
17
+ return {};
18
+ }
19
+ }
20
+
21
+ function hasTelemetryCli(dir) {
22
+ return fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.cjs')) ||
23
+ fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'));
24
+ }
25
+
26
+ function findTelemetryRoot(startDir) {
27
+ if (!startDir) return '';
28
+
29
+ let current = path.resolve(startDir);
30
+ while (true) {
31
+ if (hasTelemetryCli(current)) return current;
32
+ const parent = path.dirname(current);
33
+ if (parent === current) return '';
34
+ current = parent;
35
+ }
36
+ }
37
+
38
+ function findProjectRoot(input) {
39
+ const candidates = [
40
+ input.cwd,
41
+ input.project_root,
42
+ process.env.CLAUDE_PROJECT_DIR,
43
+ process.env.PWD,
44
+ process.cwd(),
45
+ ].filter(Boolean);
46
+ for (const dir of candidates) {
47
+ const root = findTelemetryRoot(dir);
48
+ if (root) return root;
49
+ }
50
+ return process.cwd();
51
+ }
52
+
53
+ function readActiveStages(projectRoot) {
54
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
55
+ if (!fs.existsSync(stateDir)) return [];
56
+ return fs.readdirSync(stateDir)
57
+ .filter(file => file.endsWith('.json'))
58
+ .map(file => {
59
+ try {
60
+ return JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')).event;
61
+ } catch {
62
+ return null;
63
+ }
64
+ })
65
+ .filter(Boolean);
66
+ }
67
+
68
+ const input = parseInput(readStdin());
69
+ const prompt = input.prompt || input.message || input.user_prompt || '';
70
+ if (!/\/opsx[:|-]/.test(prompt)) process.exit(0);
71
+
72
+ const projectRoot = findProjectRoot(input);
73
+ const activeStages = readActiveStages(projectRoot)
74
+ .map(event => `${event.change || 'general'}:${event.command || event.stage || 'unknown'}`)
75
+ .join(', ');
76
+
77
+ const lines = [
78
+ 'SDD Telemetry reminder:',
79
+ '- Run skywalk-sdd/log.cjs start before the OPSX stage work begins.',
80
+ '- Run skywalk-sdd/log.cjs end before stopping the stage.',
81
+ '- Hooks are only an enhancement; OPSX skill instructions remain authoritative.',
82
+ ];
83
+
84
+ if (activeStages) {
85
+ lines.push(`- Active stage state: ${activeStages}`);
86
+ }
87
+
88
+ console.log(lines.join('\n'));