kld-sdd 2.4.7 → 2.4.9

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 (30) hide show
  1. package/lib/init.js +201 -12
  2. package/package.json +1 -1
  3. package/skywalk-sdd/index.js +2241 -127
  4. package/templates/ci/github-actions-sdd.yml +67 -0
  5. package/templates/ci/gitlab-ci-sdd.yml +44 -0
  6. package/templates/git-hooks/pre-commit-sdd-check.js +155 -0
  7. package/templates/git-hooks/pre-push-sdd-check.js +41 -0
  8. package/templates/hooks/claude/hooks/sdd-post-tool.js +120 -0
  9. package/templates/hooks/claude/hooks/sdd-pre-tool.js +38 -0
  10. package/templates/hooks/claude/hooks/sdd-prompt.js +66 -0
  11. package/templates/hooks/claude/hooks/sdd-stop.js +82 -0
  12. package/templates/hooks/claude/settings.json +46 -0
  13. package/templates/opsx-commands/apply.md +75 -7
  14. package/templates/opsx-commands/archive.md +116 -55
  15. package/templates/opsx-commands/check.md +123 -4
  16. package/templates/opsx-commands/design.md +14 -4
  17. package/templates/opsx-commands/explore.md +14 -4
  18. package/templates/opsx-commands/propose.md +10 -4
  19. package/templates/opsx-commands/spec.md +14 -4
  20. package/templates/opsx-commands/task.md +15 -5
  21. package/templates/opsx-commands/test.md +41 -4
  22. package/templates/skills/opsx-apply/SKILL.md +63 -5
  23. package/templates/skills/opsx-archive/SKILL.md +94 -47
  24. package/templates/skills/opsx-check/SKILL.md +47 -3
  25. package/templates/skills/opsx-design/SKILL.md +8 -3
  26. package/templates/skills/opsx-explore/SKILL.md +8 -3
  27. package/templates/skills/opsx-propose/SKILL.md +8 -3
  28. package/templates/skills/opsx-spec/SKILL.md +8 -3
  29. package/templates/skills/opsx-task/SKILL.md +8 -3
  30. package/templates/skills/opsx-test/SKILL.md +8 -3
@@ -0,0 +1,67 @@
1
+ name: SDD Quality Gate
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches:
7
+ - main
8
+ - master
9
+
10
+ jobs:
11
+ sdd-quality-gate:
12
+ runs-on: ubuntu-latest
13
+ env:
14
+ SDD_PROJECT: ${{ github.workspace }}
15
+ SDD_CHANGE: ${{ github.head_ref || github.ref_name }}
16
+ SDD_AGENT: github-actions
17
+ SDD_SESSION: github-${{ github.run_id }}
18
+ SDD_BUILD_COMMAND: npm run build --if-present
19
+ SDD_TEST_COMMAND: npm test --if-present
20
+ steps:
21
+ - uses: actions/checkout@v4
22
+
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: 20
26
+
27
+ - name: Install dependencies
28
+ run: npm install
29
+
30
+ - name: SDD doctor
31
+ run: node skywalk-sdd/log.js doctor --project="$SDD_PROJECT" --change="$SDD_CHANGE"
32
+
33
+ - name: Build and record SDD result
34
+ shell: bash
35
+ run: |
36
+ set +e
37
+ start_ms=$(date +%s%3N)
38
+ bash -lc "$SDD_BUILD_COMMAND"
39
+ status=$?
40
+ end_ms=$(date +%s%3N)
41
+ duration=$((end_ms - start_ms))
42
+ result="success"
43
+ success="true"
44
+ if [ "$status" -ne 0 ]; then
45
+ result="failure"
46
+ success="false"
47
+ fi
48
+ node skywalk-sdd/log.js record --type=build_result --command=ci --project="$SDD_PROJECT" --change="$SDD_CHANGE" --agent="$SDD_AGENT" --source=ci --session-id="$SDD_SESSION" --result="$result" --summary="CI build $result" --details-json="{\"build_results\":{\"command\":\"$SDD_BUILD_COMMAND\",\"success\":$success,\"duration_ms\":$duration,\"error_count\":0}}"
49
+ exit "$status"
50
+
51
+ - name: Test and record SDD result
52
+ shell: bash
53
+ run: |
54
+ set +e
55
+ start_ms=$(date +%s%3N)
56
+ bash -lc "$SDD_TEST_COMMAND"
57
+ status=$?
58
+ end_ms=$(date +%s%3N)
59
+ duration=$((end_ms - start_ms))
60
+ result="success"
61
+ failed=0
62
+ if [ "$status" -ne 0 ]; then
63
+ result="failure"
64
+ failed=1
65
+ fi
66
+ node skywalk-sdd/log.js record --type=test_result --command=ci --project="$SDD_PROJECT" --change="$SDD_CHANGE" --agent="$SDD_AGENT" --source=ci --session-id="$SDD_SESSION" --result="$result" --summary="CI test $result" --details-json="{\"test_results\":{\"command\":\"$SDD_TEST_COMMAND\",\"passed\":0,\"failed\":$failed,\"skipped\":0,\"coverage\":null,\"duration_ms\":$duration}}"
67
+ exit "$status"
@@ -0,0 +1,44 @@
1
+ sdd_quality_gate:
2
+ image: node:20
3
+ stage: test
4
+ variables:
5
+ SDD_PROJECT: "$CI_PROJECT_DIR"
6
+ SDD_CHANGE: "$CI_COMMIT_REF_NAME"
7
+ SDD_AGENT: "gitlab-ci"
8
+ SDD_SESSION: "gitlab-$CI_PIPELINE_ID"
9
+ SDD_BUILD_COMMAND: "npm run build --if-present"
10
+ SDD_TEST_COMMAND: "npm test --if-present"
11
+ before_script:
12
+ - npm install
13
+ script:
14
+ - node skywalk-sdd/log.js doctor --project="$SDD_PROJECT" --change="$SDD_CHANGE"
15
+ - |
16
+ set +e
17
+ start_ms=$(date +%s%3N)
18
+ sh -lc "$SDD_BUILD_COMMAND"
19
+ status=$?
20
+ end_ms=$(date +%s%3N)
21
+ duration=$((end_ms - start_ms))
22
+ result="success"
23
+ success="true"
24
+ if [ "$status" -ne 0 ]; then
25
+ result="failure"
26
+ success="false"
27
+ fi
28
+ node skywalk-sdd/log.js record --type=build_result --command=ci --project="$SDD_PROJECT" --change="$SDD_CHANGE" --agent="$SDD_AGENT" --source=ci --session-id="$SDD_SESSION" --result="$result" --summary="CI build $result" --details-json="{\"build_results\":{\"command\":\"$SDD_BUILD_COMMAND\",\"success\":$success,\"duration_ms\":$duration,\"error_count\":0}}"
29
+ if [ "$status" -ne 0 ]; then exit "$status"; fi
30
+ - |
31
+ set +e
32
+ start_ms=$(date +%s%3N)
33
+ sh -lc "$SDD_TEST_COMMAND"
34
+ status=$?
35
+ end_ms=$(date +%s%3N)
36
+ duration=$((end_ms - start_ms))
37
+ result="success"
38
+ failed=0
39
+ if [ "$status" -ne 0 ]; then
40
+ result="failure"
41
+ failed=1
42
+ fi
43
+ node skywalk-sdd/log.js record --type=test_result --command=ci --project="$SDD_PROJECT" --change="$SDD_CHANGE" --agent="$SDD_AGENT" --source=ci --session-id="$SDD_SESSION" --result="$result" --summary="CI test $result" --details-json="{\"test_results\":{\"command\":\"$SDD_TEST_COMMAND\",\"passed\":0,\"failed\":$failed,\"skipped\":0,\"coverage\":null,\"duration_ms\":$duration}}"
44
+ if [ "$status" -ne 0 ]; then exit "$status"; fi
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ function readArg(name) {
8
+ const prefix = `--${name}=`;
9
+ const found = process.argv.find(arg => arg.startsWith(prefix));
10
+ return found ? found.slice(prefix.length) : '';
11
+ }
12
+
13
+ function hasFlag(name) {
14
+ return process.argv.includes(`--${name}`);
15
+ }
16
+
17
+ function getStagedFiles(projectRoot) {
18
+ try {
19
+ return execFileSync('git', ['diff', '--cached', '--name-only'], {
20
+ cwd: projectRoot,
21
+ encoding: 'utf8',
22
+ stdio: ['ignore', 'pipe', 'ignore'],
23
+ }).split(/\r?\n/).filter(Boolean);
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ function normalizePath(filePath) {
30
+ return filePath.replace(/\\/g, '/');
31
+ }
32
+
33
+ function getChangeNameFromPath(filePath) {
34
+ const normalized = normalizePath(filePath);
35
+ const match = normalized.match(/^openspec\/changes\/([^/]+)\//);
36
+ return match ? match[1] : '';
37
+ }
38
+
39
+ function hasArchiveIntent(files) {
40
+ return files.some(file => {
41
+ const normalized = normalizePath(file);
42
+ return /^openspec\/changes\/[^/]+\/archive/i.test(normalized) ||
43
+ /^openspec\/archive\//i.test(normalized);
44
+ });
45
+ }
46
+
47
+ function discoverChanges(projectRoot, explicitChange, archiveMode) {
48
+ if (explicitChange) {
49
+ return [explicitChange];
50
+ }
51
+
52
+ const stagedFiles = getStagedFiles(projectRoot);
53
+ if (!archiveMode && !hasArchiveIntent(stagedFiles)) {
54
+ return [];
55
+ }
56
+
57
+ const changes = new Set();
58
+ for (const file of stagedFiles) {
59
+ const changeName = getChangeNameFromPath(file);
60
+ if (changeName) {
61
+ changes.add(changeName);
62
+ }
63
+ }
64
+
65
+ if (changes.size > 0) {
66
+ return Array.from(changes);
67
+ }
68
+
69
+ const changesDir = path.join(projectRoot, 'openspec', 'changes');
70
+ if (!fs.existsSync(changesDir)) {
71
+ return [];
72
+ }
73
+
74
+ return fs.readdirSync(changesDir)
75
+ .filter(name => fs.statSync(path.join(changesDir, name)).isDirectory());
76
+ }
77
+
78
+ function walkFiles(dirPath, predicate, files = []) {
79
+ if (!fs.existsSync(dirPath)) {
80
+ return files;
81
+ }
82
+
83
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
84
+ const fullPath = path.join(dirPath, entry.name);
85
+ if (entry.isDirectory()) {
86
+ walkFiles(fullPath, predicate, files);
87
+ } else if (!predicate || predicate(fullPath)) {
88
+ files.push(fullPath);
89
+ }
90
+ }
91
+
92
+ return files;
93
+ }
94
+
95
+ function findIncompleteTasks(projectRoot, changeName) {
96
+ const changePath = path.join(projectRoot, 'openspec', 'changes', changeName);
97
+ const taskFiles = walkFiles(changePath, filePath => {
98
+ const fileName = path.basename(filePath).toLowerCase();
99
+ return fileName === 'tasks.md' || fileName === 'task.md';
100
+ });
101
+
102
+ const incomplete = [];
103
+ for (const tasksPath of taskFiles) {
104
+ const relPath = path.relative(projectRoot, tasksPath).replace(/\\/g, '/');
105
+ const items = fs.readFileSync(tasksPath, 'utf8')
106
+ .split(/\r?\n/)
107
+ .map((line, index) => ({ file: relPath, line, lineNumber: index + 1 }))
108
+ .filter(item => /\[\s\]/.test(item.line));
109
+ incomplete.push(...items);
110
+ }
111
+
112
+ return incomplete;
113
+ }
114
+
115
+ function main() {
116
+ const projectRoot = path.resolve(readArg('project') || process.env.SDD_PROJECT || process.cwd());
117
+ const explicitChange = readArg('change') || process.env.SDD_CHANGE || process.env.OPENSPEC_CHANGE || '';
118
+ const archiveMode = hasFlag('archive') || process.env.SDD_ARCHIVE_CHECK === '1';
119
+ const changes = discoverChanges(projectRoot, explicitChange, archiveMode);
120
+
121
+ if (changes.length === 0) {
122
+ console.log('SDD pre-commit: no archive intent detected; tasks gate skipped.');
123
+ return;
124
+ }
125
+
126
+ const failures = [];
127
+ for (const changeName of changes) {
128
+ const incomplete = findIncompleteTasks(projectRoot, changeName);
129
+ if (incomplete.length > 0) {
130
+ failures.push({ changeName, incomplete });
131
+ }
132
+ }
133
+
134
+ if (failures.length === 0) {
135
+ console.log(`SDD pre-commit: tasks gate passed for ${changes.length} change(s).`);
136
+ return;
137
+ }
138
+
139
+ console.warn('SDD pre-commit: tasks.md still has incomplete items. Archive is allowed, but these items must be shown as unfinished/unchecked in telemetry and reports.');
140
+ for (const failure of failures) {
141
+ console.warn(`- ${failure.changeName}`);
142
+ for (const item of failure.incomplete.slice(0, 10)) {
143
+ console.warn(` ${item.file}:${item.lineNumber}: ${item.line.trim()}`);
144
+ }
145
+ if (failure.incomplete.length > 10) {
146
+ console.warn(` ... ${failure.incomplete.length - 10} more`);
147
+ }
148
+ }
149
+ if (hasFlag('strict-tasks') || process.env.SDD_STRICT_TASKS === '1') {
150
+ console.error('SDD pre-commit: strict task mode enabled; blocking archive commit.');
151
+ process.exit(1);
152
+ }
153
+ }
154
+
155
+ main();
@@ -0,0 +1,41 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ function readArg(name) {
8
+ const prefix = `--${name}=`;
9
+ const found = process.argv.find(arg => arg.startsWith(prefix));
10
+ return found ? found.slice(prefix.length) : '';
11
+ }
12
+
13
+ function main() {
14
+ const projectRoot = path.resolve(readArg('project') || process.env.SDD_PROJECT || process.cwd());
15
+ const changeName = readArg('change') || process.env.SDD_CHANGE || process.env.OPENSPEC_CHANGE || '';
16
+ const logCli = path.join(projectRoot, 'skywalk-sdd', 'log.js');
17
+
18
+ if (!fs.existsSync(logCli)) {
19
+ console.log('SDD pre-push: skywalk-sdd/log.js not found; doctor gate skipped.');
20
+ return;
21
+ }
22
+
23
+ const args = [logCli, 'doctor', `--project=${projectRoot}`];
24
+ if (changeName) {
25
+ args.push(`--change=${changeName}`);
26
+ }
27
+
28
+ const result = spawnSync(process.execPath, args, {
29
+ cwd: projectRoot,
30
+ stdio: 'inherit',
31
+ });
32
+
33
+ if (result.error) {
34
+ console.error(`SDD pre-push: failed to run doctor: ${result.error.message}`);
35
+ process.exit(1);
36
+ }
37
+
38
+ process.exit(result.status || 0);
39
+ }
40
+
41
+ main();
@@ -0,0 +1,120 @@
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 findProjectRoot(input) {
23
+ const toolInput = input.tool_input || input.toolInput || {};
24
+ const candidates = [
25
+ toolInput.cwd,
26
+ input.cwd,
27
+ input.project_root,
28
+ process.env.PWD,
29
+ process.cwd(),
30
+ ].filter(Boolean);
31
+ return candidates.find(dir => fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'))) || process.cwd();
32
+ }
33
+
34
+ function latestActiveStage(projectRoot) {
35
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
36
+ if (!fs.existsSync(stateDir)) return null;
37
+ return fs.readdirSync(stateDir)
38
+ .filter(file => file.endsWith('.json'))
39
+ .map(file => {
40
+ try {
41
+ const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
42
+ return data.event || null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ })
47
+ .filter(Boolean)
48
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] || null;
49
+ }
50
+
51
+ function inferResult(input) {
52
+ const response = input.tool_response || input.toolResponse || {};
53
+ if (typeof response.exit_code === 'number') return response.exit_code === 0 ? 'success' : 'failure';
54
+ if (typeof response.exitCode === 'number') return response.exitCode === 0 ? 'success' : 'failure';
55
+ if (typeof response.success === 'boolean') return response.success ? 'success' : 'failure';
56
+ return 'partial';
57
+ }
58
+
59
+ function inferRecord(command) {
60
+ 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)) {
61
+ return {
62
+ type: 'test_result',
63
+ detailsKey: 'test_results',
64
+ details: { command, passed: 0, failed: 0, skipped: 0, coverage: null, duration_ms: null },
65
+ };
66
+ }
67
+ 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)) {
68
+ return {
69
+ type: 'build_result',
70
+ detailsKey: 'build_results',
71
+ details: { command, success: null, duration_ms: null, error_count: null },
72
+ };
73
+ }
74
+ return null;
75
+ }
76
+
77
+ function recordTelemetry(projectRoot, activeStage, record, result) {
78
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.js');
79
+ const details = { [record.detailsKey]: { ...record.details } };
80
+ if (record.type === 'build_result') {
81
+ details.build_results.success = result === 'success';
82
+ details.build_results.error_count = result === 'success' ? 0 : null;
83
+ }
84
+
85
+ const args = [
86
+ logPath,
87
+ 'record',
88
+ `--type=${record.type}`,
89
+ `--command=${activeStage.command || activeStage.stage || 'unknown'}`,
90
+ `--project=${projectRoot}`,
91
+ `--change=${activeStage.change || 'general'}`,
92
+ `--agent=${activeStage.agent_type || 'claude-code'}`,
93
+ '--source=claude-hook',
94
+ `--result=${result}`,
95
+ `--summary=Claude hook captured ${record.type}`,
96
+ `--details-json=${JSON.stringify(details)}`,
97
+ ];
98
+ if (activeStage.capability) args.push(`--capability=${activeStage.capability}`);
99
+ if (activeStage.task_id) args.push(`--task-id=${activeStage.task_id}`);
100
+ if (activeStage.session_id) args.push(`--session-id=${activeStage.session_id}`);
101
+ execFileSync('node', args, { cwd: projectRoot, stdio: 'ignore' });
102
+ }
103
+
104
+ const input = parseInput(readStdin());
105
+ const toolInput = input.tool_input || input.toolInput || {};
106
+ const command = String(toolInput.command || input.command || '');
107
+ const record = inferRecord(command);
108
+ if (!record) process.exit(0);
109
+
110
+ const projectRoot = findProjectRoot(input);
111
+ const activeStage = latestActiveStage(projectRoot);
112
+ if (!activeStage || !activeStage.change || activeStage.change === 'general') {
113
+ process.exit(0);
114
+ }
115
+
116
+ try {
117
+ recordTelemetry(projectRoot, activeStage, record, inferResult(input));
118
+ } catch {
119
+ process.exit(0);
120
+ }
@@ -0,0 +1,38 @@
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
+ }
@@ -0,0 +1,66 @@
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 findProjectRoot(input) {
22
+ const candidates = [
23
+ input.cwd,
24
+ input.project_root,
25
+ process.env.PWD,
26
+ process.cwd(),
27
+ ].filter(Boolean);
28
+ return candidates.find(dir => fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'))) || process.cwd();
29
+ }
30
+
31
+ function readActiveStages(projectRoot) {
32
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
33
+ if (!fs.existsSync(stateDir)) return [];
34
+ return fs.readdirSync(stateDir)
35
+ .filter(file => file.endsWith('.json'))
36
+ .map(file => {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')).event;
39
+ } catch {
40
+ return null;
41
+ }
42
+ })
43
+ .filter(Boolean);
44
+ }
45
+
46
+ const input = parseInput(readStdin());
47
+ const prompt = input.prompt || input.message || input.user_prompt || '';
48
+ if (!/\/opsx:/.test(prompt)) process.exit(0);
49
+
50
+ const projectRoot = findProjectRoot(input);
51
+ const activeStages = readActiveStages(projectRoot)
52
+ .map(event => `${event.change || 'general'}:${event.command || event.stage || 'unknown'}`)
53
+ .join(', ');
54
+
55
+ const lines = [
56
+ 'SDD Telemetry reminder:',
57
+ '- Run skywalk-sdd/log.js start before the OPSX stage work begins.',
58
+ '- Run skywalk-sdd/log.js end before stopping the stage.',
59
+ '- Hooks are only an enhancement; OPSX command instructions remain authoritative.',
60
+ ];
61
+
62
+ if (activeStages) {
63
+ lines.push(`- Active stage state: ${activeStages}`);
64
+ }
65
+
66
+ console.log(lines.join('\n'));
@@ -0,0 +1,82 @@
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 findProjectRoot(input) {
23
+ const candidates = [
24
+ input.cwd,
25
+ input.project_root,
26
+ process.env.PWD,
27
+ process.cwd(),
28
+ ].filter(Boolean);
29
+ return candidates.find(dir => fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'))) || process.cwd();
30
+ }
31
+
32
+ function readActiveStages(projectRoot) {
33
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
34
+ if (!fs.existsSync(stateDir)) return [];
35
+ return fs.readdirSync(stateDir)
36
+ .filter(file => file.endsWith('.json'))
37
+ .map(file => {
38
+ try {
39
+ return JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')).event;
40
+ } catch {
41
+ return null;
42
+ }
43
+ })
44
+ .filter(Boolean);
45
+ }
46
+
47
+ function writeWarning(projectRoot, event) {
48
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.js');
49
+ const args = [
50
+ logPath,
51
+ 'record',
52
+ '--type=telemetry_warning',
53
+ `--command=${event.command || event.stage || 'unknown'}`,
54
+ `--project=${projectRoot}`,
55
+ `--change=${event.change || 'general'}`,
56
+ `--agent=${event.agent_type || 'claude-code'}`,
57
+ '--source=claude-hook',
58
+ '--result=partial',
59
+ '--summary=Claude hook detected an open SDD stage at stop',
60
+ `--details-json=${JSON.stringify({ warning: 'open_stage_at_stop', event_id: event.event_id })}`,
61
+ ];
62
+ if (event.capability) args.push(`--capability=${event.capability}`);
63
+ if (event.session_id) args.push(`--session-id=${event.session_id}`);
64
+ execFileSync('node', args, { cwd: projectRoot, stdio: 'ignore' });
65
+ }
66
+
67
+ const input = parseInput(readStdin());
68
+ const projectRoot = findProjectRoot(input);
69
+ const activeStages = readActiveStages(projectRoot);
70
+ if (activeStages.length === 0) process.exit(0);
71
+
72
+ for (const event of activeStages) {
73
+ try {
74
+ writeWarning(projectRoot, event);
75
+ } catch {}
76
+ }
77
+
78
+ console.log([
79
+ 'SDD Telemetry warning: open stage(s) detected.',
80
+ ...activeStages.map(event => `- ${event.change || 'general'}:${event.command || event.stage || 'unknown'} event_id=${event.event_id}`),
81
+ 'Run skywalk-sdd/log.js end before closing the OPSX stage, or explicitly mark it partial/failure.',
82
+ ].join('\n'));
@@ -0,0 +1,46 @@
1
+ {
2
+ "hooks": {
3
+ "UserPromptSubmit": [
4
+ {
5
+ "hooks": [
6
+ {
7
+ "type": "command",
8
+ "command": "node .claude/hooks/sdd-prompt.js"
9
+ }
10
+ ]
11
+ }
12
+ ],
13
+ "PreToolUse": [
14
+ {
15
+ "matcher": "Bash",
16
+ "hooks": [
17
+ {
18
+ "type": "command",
19
+ "command": "node .claude/hooks/sdd-pre-tool.js"
20
+ }
21
+ ]
22
+ }
23
+ ],
24
+ "PostToolUse": [
25
+ {
26
+ "matcher": "Bash",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "node .claude/hooks/sdd-post-tool.js"
31
+ }
32
+ ]
33
+ }
34
+ ],
35
+ "Stop": [
36
+ {
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "node .claude/hooks/sdd-stop.js"
41
+ }
42
+ ]
43
+ }
44
+ ]
45
+ }
46
+ }