@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.
- package/README.md +275 -0
- package/bin/kld-sdd-init.js +24 -0
- package/index.js +13 -0
- package/lib/init.js +1124 -0
- package/lib/skills-bundle.js +30 -0
- package/package.json +48 -0
- package/skywalk-sdd/apply-worktree-finish.cjs +551 -0
- package/skywalk-sdd/index.cjs +2991 -0
- package/templates/ci/github-actions-sdd.yml +67 -0
- package/templates/ci/gitlab-ci-sdd.yml +44 -0
- package/templates/git-hooks/pre-commit-sdd-check.cjs +155 -0
- package/templates/git-hooks/pre-push-sdd-check.cjs +56 -0
- package/templates/hooks/claude/hooks/sdd-apply-gate.cjs +173 -0
- package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +315 -0
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +146 -0
- package/templates/hooks/claude/hooks/sdd-pre-tool.cjs +41 -0
- package/templates/hooks/claude/hooks/sdd-prompt.cjs +88 -0
- package/templates/hooks/claude/hooks/sdd-skill-apply-gate.cjs +268 -0
- package/templates/hooks/claude/hooks/sdd-stop.cjs +108 -0
- package/templates/hooks/claude/settings.json +72 -0
- package/templates/openspec/design.md +290 -0
- package/templates/openspec/overview.md +143 -0
- package/templates/openspec/proposal.md +108 -0
- package/templates/openspec/spec.md +185 -0
- package/templates/openspec/tasks.md +287 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +251 -0
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
- package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +129 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +335 -0
- package/templates/skills/kld-sdd/opsx-apply/worktree-setup.md +104 -0
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +162 -0
- package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +197 -0
- package/templates/skills/kld-sdd/opsx-check/checklist.md +35 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +166 -0
- package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +104 -0
- package/templates/skills/kld-sdd/opsx-knowledge/SKILL.md +130 -0
- package/templates/skills/kld-sdd/opsx-knowledge/references/modules.md +26 -0
- package/templates/skills/kld-sdd/opsx-knowledge/scripts/config.json +39 -0
- package/templates/skills/kld-sdd/opsx-knowledge/scripts/retrieve.cjs +199 -0
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +201 -0
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +168 -0
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +199 -0
- package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
- package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +143 -0
|
@@ -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.cjs 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.cjs 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.cjs 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.cjs 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.cjs 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.cjs 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,56 @@
|
|
|
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 resolveLogCli(projectRoot) {
|
|
14
|
+
const logCjs = path.join(projectRoot, 'skywalk-sdd', 'log.cjs');
|
|
15
|
+
if (fs.existsSync(logCjs)) {
|
|
16
|
+
return logCjs;
|
|
17
|
+
}
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function main() {
|
|
22
|
+
const projectRoot = path.resolve(readArg('project') || process.env.SDD_PROJECT || process.cwd());
|
|
23
|
+
const changeName = readArg('change') || process.env.SDD_CHANGE || process.env.OPENSPEC_CHANGE || '';
|
|
24
|
+
const logCli = resolveLogCli(projectRoot);
|
|
25
|
+
|
|
26
|
+
if (!logCli) {
|
|
27
|
+
console.log('SDD pre-push: skywalk-sdd/log.cjs not found; doctor gate skipped.');
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const args = [logCli, 'doctor', `--project=${projectRoot}`];
|
|
32
|
+
if (changeName) {
|
|
33
|
+
args.push(`--change=${changeName}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const result = spawnSync(process.execPath, args, {
|
|
37
|
+
cwd: projectRoot,
|
|
38
|
+
stdio: 'inherit',
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (result.error) {
|
|
42
|
+
console.warn(`SDD pre-push: failed to run doctor: ${result.error.message}; push will continue.`);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (result.status && result.status !== 0) {
|
|
47
|
+
console.warn(`SDD pre-push: doctor reported issues with exit code ${result.status}; push will continue.`);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (result.signal) {
|
|
52
|
+
console.warn(`SDD pre-push: doctor stopped by signal ${result.signal}; push will continue.`);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
main();
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* SDD Apply Gate Hook
|
|
4
|
+
*
|
|
5
|
+
* 在 apply 阶段执行前进行门禁检查:必须先完成 check 阶段,才能执行 apply。
|
|
6
|
+
* 拦截 PreToolUse(Write/Edit) 事件,阻断越界写入操作。
|
|
7
|
+
*
|
|
8
|
+
* 检查逻辑:
|
|
9
|
+
* 1. 检测当前是否存在活跃的 apply 阶段(state 文件)
|
|
10
|
+
* 2. 若存在,回溯 events 目录确认 check 阶段是否已完成
|
|
11
|
+
* 3. 若 check 未完成,阻断本次写入并提示用户先执行 /opsx-check
|
|
12
|
+
*/
|
|
13
|
+
const fs = require('fs');
|
|
14
|
+
const path = require('path');
|
|
15
|
+
|
|
16
|
+
// ── 工具函数 ──────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function readStdin() {
|
|
19
|
+
try {
|
|
20
|
+
return fs.readFileSync(0, 'utf8');
|
|
21
|
+
} catch {
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parseInput(raw) {
|
|
27
|
+
try {
|
|
28
|
+
return JSON.parse(raw || '{}');
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hasTelemetryCli(dir) {
|
|
35
|
+
return fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.cjs')) ||
|
|
36
|
+
fs.existsSync(path.join(dir, 'skywalk-sdd', 'log.js'));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function findProjectRoot(startDir) {
|
|
40
|
+
if (!startDir) return '';
|
|
41
|
+
|
|
42
|
+
let current = path.resolve(startDir);
|
|
43
|
+
while (true) {
|
|
44
|
+
if (hasTelemetryCli(current)) return current;
|
|
45
|
+
const parent = path.dirname(current);
|
|
46
|
+
if (parent === current) return '';
|
|
47
|
+
current = parent;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getProjectRoot(input) {
|
|
52
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
53
|
+
const candidates = [
|
|
54
|
+
toolInput.cwd,
|
|
55
|
+
input.cwd,
|
|
56
|
+
input.project_root,
|
|
57
|
+
process.env.CLAUDE_PROJECT_DIR,
|
|
58
|
+
process.env.PWD,
|
|
59
|
+
process.cwd(),
|
|
60
|
+
].filter(Boolean);
|
|
61
|
+
for (const dir of candidates) {
|
|
62
|
+
const root = findProjectRoot(dir);
|
|
63
|
+
if (root) return root;
|
|
64
|
+
}
|
|
65
|
+
return process.cwd();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 获取当前活跃的 apply 阶段事件(从 state 目录)
|
|
70
|
+
*/
|
|
71
|
+
function findActiveApplyStage(projectRoot) {
|
|
72
|
+
const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
|
|
73
|
+
if (!fs.existsSync(stateDir)) return null;
|
|
74
|
+
|
|
75
|
+
const files = fs.readdirSync(stateDir).filter(f => f.endsWith('.json'));
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
try {
|
|
78
|
+
const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
|
|
79
|
+
const event = data.event || null;
|
|
80
|
+
if (event && event.command === 'apply') {
|
|
81
|
+
return event;
|
|
82
|
+
}
|
|
83
|
+
} catch {
|
|
84
|
+
// 跳过无法解析的 state 文件
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 检查指定 change 是否已有完成的 check 阶段
|
|
92
|
+
* 在 events 目录中搜索 check 的 stage_end 事件
|
|
93
|
+
*/
|
|
94
|
+
function hasCompletedCheck(projectRoot, changeName) {
|
|
95
|
+
const safeName = safeChangeName(changeName);
|
|
96
|
+
const eventsChangeDir = path.join(projectRoot, 'skywalk-sdd', 'events', safeName);
|
|
97
|
+
if (!fs.existsSync(eventsChangeDir)) return false;
|
|
98
|
+
|
|
99
|
+
const jsonlFiles = fs.readdirSync(eventsChangeDir)
|
|
100
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
101
|
+
.sort()
|
|
102
|
+
.reverse(); // 从最新文件开始搜索
|
|
103
|
+
|
|
104
|
+
for (const file of jsonlFiles) {
|
|
105
|
+
try {
|
|
106
|
+
const lines = fs.readFileSync(path.join(eventsChangeDir, file), 'utf-8')
|
|
107
|
+
.split('\n')
|
|
108
|
+
.filter(Boolean);
|
|
109
|
+
// 倒序遍历,优先命中最近的事件
|
|
110
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
111
|
+
try {
|
|
112
|
+
const event = JSON.parse(lines[i]);
|
|
113
|
+
if (event.type === 'stage_end' && event.command === 'check' && (event.result === 'success' || event.result === 'partial')) {
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// 跳过无法解析的行
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// 跳过无法读取的文件
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* 将 change 名称转为安全的目录名(与 L1 门禁 && log.cjs 中 safeChangeName 逻辑一致)
|
|
129
|
+
*
|
|
130
|
+
* 规则:转小写 → 空格/下划线转连字符 → 剔除非法字符 → 压缩连字符 → 去头尾连字符
|
|
131
|
+
*/
|
|
132
|
+
function safeChangeName(name) {
|
|
133
|
+
if (!name) return '';
|
|
134
|
+
return String(name)
|
|
135
|
+
.toLowerCase()
|
|
136
|
+
.replace(/[\s_]+/g, '-')
|
|
137
|
+
.replace(/[^a-z0-9一-鿿\-]/g, '-')
|
|
138
|
+
.replace(/-+/g, '-')
|
|
139
|
+
.replace(/^-|-$/g, '');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── 主逻辑 ─────────────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
const input = parseInput(readStdin());
|
|
145
|
+
|
|
146
|
+
// 仅拦截 Write 和 Edit 工具
|
|
147
|
+
const toolName = String(input.tool_name || input.toolName || '');
|
|
148
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const projectRoot = getProjectRoot(input);
|
|
153
|
+
|
|
154
|
+
// 检查是否存在活跃的 apply 阶段
|
|
155
|
+
const activeApply = findActiveApplyStage(projectRoot);
|
|
156
|
+
if (!activeApply) {
|
|
157
|
+
// 没有活跃的 apply 阶段,无需门禁
|
|
158
|
+
process.exit(0);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 检查 check 阶段是否已完成
|
|
162
|
+
if (hasCompletedCheck(projectRoot, activeApply.change)) {
|
|
163
|
+
// check 已完成,放行
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// check 未完成,阻断写入
|
|
168
|
+
const changeName = activeApply.change || 'unknown';
|
|
169
|
+
console.log(JSON.stringify({
|
|
170
|
+
decision: 'block',
|
|
171
|
+
reason: `[SDD Apply Gate] 检测到 apply 阶段正在执行(change: ${changeName}),但 check 阶段尚未完成。\n\n请先执行 /opsx-check 完成质量门禁检查,再执行 /opsx-apply 进行代码实施。\n\n操作顺序:/opsx-check → /opsx-apply`,
|
|
172
|
+
}));
|
|
173
|
+
process.exit(2);
|