kld-sdd 2.6.15 → 2.6.16
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/lib/init.js +112 -73
- package/lib/skills-bundle.js +3 -0
- package/package.json +2 -1
- package/skywalk-sdd/openspec-shim.cjs +48 -0
- package/templates/git-hooks/commit-msg +46 -0
- package/templates/git-hooks/pre-commit +57 -0
- package/templates/git-hooks/pre-commit-consistency-check.cjs +193 -0
- package/templates/git-hooks/pre-push +57 -0
- package/templates/git-hooks/pre-push-consistency-check.cjs +197 -0
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +369 -0
- package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +41 -0
- package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +108 -0
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +36 -1
- package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +248 -0
- package/templates/hooks/codebuddy/settings.json +8 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +13 -0
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +15 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +41 -0
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +592 -0
- package/templates/skills/kld-sdd/tdd-rules/SKILL.md +1 -0
- package/templates/skills/kld-sdd/tdd-rules/rules/tdd-rhythm-enforcement.md +101 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# KLD SDD Pre-Push Hook
|
|
3
|
+
# 远程引用 spec 仓 skywalk-sdd/git-hooks/ 下的 .cjs 脚本
|
|
4
|
+
# 兼容单仓库/多仓库/单仓mono布局
|
|
5
|
+
# marker: KLD SDD quality gate
|
|
6
|
+
|
|
7
|
+
# 从 hook 所在目录推算 git 根目录
|
|
8
|
+
hook_dir="$(cd "$(dirname "$0")" && pwd)"
|
|
9
|
+
git_dir="$(dirname "$hook_dir")"
|
|
10
|
+
git_root="$(dirname "$git_dir")"
|
|
11
|
+
|
|
12
|
+
hooks_dir=""
|
|
13
|
+
|
|
14
|
+
# 方法1: git config sdd.specPath(多仓布局:spec 是独立 clone)
|
|
15
|
+
spec_path=$(git config --local sdd.specPath 2>/dev/null)
|
|
16
|
+
if [ -n "$spec_path" ] && [ -d "$spec_path/skywalk-sdd/git-hooks" ]; then
|
|
17
|
+
hooks_dir="$spec_path/skywalk-sdd/git-hooks"
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
# 方法2: .sdd-spec-root(单仓 mono 布局:skywalk-sdd 在 spec 包裹包子目录内)
|
|
21
|
+
if [ -z "$hooks_dir" ] && [ -f "$git_root/.sdd-spec-root" ]; then
|
|
22
|
+
spec_rel=$(cat "$git_root/.sdd-spec-root" | head -1 | tr -d '[:space:]')
|
|
23
|
+
if [ -n "$spec_rel" ] && [ -d "$git_root/$spec_rel/skywalk-sdd/git-hooks" ]; then
|
|
24
|
+
hooks_dir="$git_root/$spec_rel/skywalk-sdd/git-hooks"
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# 方法3: 从 git 根向上搜索 skywalk-sdd/git-hooks(spec 仓本身或父级)
|
|
29
|
+
if [ -z "$hooks_dir" ]; then
|
|
30
|
+
search_dir="$git_root"
|
|
31
|
+
while [ "$search_dir" != "/" ] && [ "$search_dir" != "" ]; do
|
|
32
|
+
if [ -d "$search_dir/skywalk-sdd/git-hooks" ]; then
|
|
33
|
+
hooks_dir="$search_dir/skywalk-sdd/git-hooks"
|
|
34
|
+
break
|
|
35
|
+
fi
|
|
36
|
+
search_dir="$(dirname "$search_dir")"
|
|
37
|
+
done
|
|
38
|
+
fi
|
|
39
|
+
|
|
40
|
+
if [ -z "$hooks_dir" ]; then
|
|
41
|
+
echo "[SDD] 未找到 spec 仓的 skywalk-sdd/git-hooks 目录,跳过 pre-push 检查"
|
|
42
|
+
exit 0
|
|
43
|
+
fi
|
|
44
|
+
|
|
45
|
+
echo "[SDD] 执行推送前检查..."
|
|
46
|
+
|
|
47
|
+
# 1. doctor 质量门禁检查
|
|
48
|
+
if [ -f "$hooks_dir/pre-push-sdd-check.cjs" ]; then
|
|
49
|
+
node "$hooks_dir/pre-push-sdd-check.cjs" --project="$git_root" "$@" || exit 1
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
# 2. 代码-spec 一致性检查(推送前更严格)
|
|
53
|
+
if [ -f "$hooks_dir/pre-push-consistency-check.cjs" ]; then
|
|
54
|
+
node "$hooks_dir/pre-push-consistency-check.cjs" --project="$git_root" "$@" || exit 1
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
echo "[SDD] ✅ 所有 pre-push 检查通过"
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pre-push-consistency-check.cjs
|
|
4
|
+
*
|
|
5
|
+
* Pre-push 门禁:检查活跃变更的 Spec 一致性校验报告。
|
|
6
|
+
* 与 pre-commit-consistency-check.cjs 类似,但更严格:
|
|
7
|
+
* - confidence=low → 阻止推送
|
|
8
|
+
* - 缺少报告 → 警告(默认放行);strict 模式下阻止推送
|
|
9
|
+
*
|
|
10
|
+
* 用法: node pre-push-consistency-check.cjs --project=<project-root> [--change=<change-name>]
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const path = require('path');
|
|
17
|
+
|
|
18
|
+
function readArg(name) {
|
|
19
|
+
const prefix = `--${name}=`;
|
|
20
|
+
const found = process.argv.find(arg => arg.startsWith(prefix));
|
|
21
|
+
return found ? found.slice(prefix.length) : '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function hasFlag(name) {
|
|
25
|
+
return process.argv.includes(`--${name}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 从 .sdd-spec-root 文件读取 spec 包裹包相对路径(单仓 mono 布局)
|
|
30
|
+
*/
|
|
31
|
+
function resolveSpecRoot(projectRoot) {
|
|
32
|
+
// 1. 检查 .sdd-spec-root(单仓 mono 模式)
|
|
33
|
+
const hintPath = path.join(projectRoot, '.sdd-spec-root');
|
|
34
|
+
if (fs.existsSync(hintPath)) {
|
|
35
|
+
const rel = fs.readFileSync(hintPath, 'utf8').trim();
|
|
36
|
+
if (rel) {
|
|
37
|
+
const specAbs = path.resolve(projectRoot, rel);
|
|
38
|
+
if (fs.existsSync(specAbs)) {
|
|
39
|
+
return specAbs;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// 2. 查找 *-sdd-specs 子目录(多仓工作区模式)
|
|
45
|
+
const entries = fs.existsSync(projectRoot) ? fs.readdirSync(projectRoot) : [];
|
|
46
|
+
for (const name of entries) {
|
|
47
|
+
if (/-sdd-specs$/i.test(name)) {
|
|
48
|
+
const candidate = path.join(projectRoot, name);
|
|
49
|
+
if (fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'openspec'))) {
|
|
50
|
+
return candidate;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// 3. 项目根本身就是 spec 仓
|
|
56
|
+
if (fs.existsSync(path.join(projectRoot, 'openspec', 'changes'))) {
|
|
57
|
+
return projectRoot;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 发现活跃变更列表
|
|
65
|
+
*/
|
|
66
|
+
function discoverActiveChanges(specRoot, explicitChange) {
|
|
67
|
+
if (explicitChange) {
|
|
68
|
+
return [explicitChange];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const changesDir = path.join(specRoot, 'openspec', 'changes');
|
|
72
|
+
if (!fs.existsSync(changesDir)) {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return fs.readdirSync(changesDir)
|
|
77
|
+
.filter(name => {
|
|
78
|
+
const fullPath = path.join(changesDir, name);
|
|
79
|
+
return fs.statSync(fullPath).isDirectory()
|
|
80
|
+
&& !name.startsWith('.')
|
|
81
|
+
&& name !== 'archive';
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 查找变更的一致性报告 JSON
|
|
87
|
+
*/
|
|
88
|
+
function findConsistencyReport(specRoot, changeName) {
|
|
89
|
+
const patterns = [
|
|
90
|
+
'consistency-report-result.json',
|
|
91
|
+
'consistency-report-self-review-result.json',
|
|
92
|
+
];
|
|
93
|
+
|
|
94
|
+
for (const pattern of patterns) {
|
|
95
|
+
const reportPath = path.join(specRoot, 'openspec', 'changes', changeName, pattern);
|
|
96
|
+
if (fs.existsSync(reportPath)) {
|
|
97
|
+
return reportPath;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 解析一致性报告,返回置信度
|
|
106
|
+
*/
|
|
107
|
+
function parseReport(reportPath) {
|
|
108
|
+
try {
|
|
109
|
+
const content = fs.readFileSync(reportPath, 'utf8');
|
|
110
|
+
const data = JSON.parse(content);
|
|
111
|
+
return {
|
|
112
|
+
confidence: (data.confidence || '').toLowerCase(),
|
|
113
|
+
overallResult: (data.overallResult || '').toLowerCase(),
|
|
114
|
+
changeName: data.changeName || '',
|
|
115
|
+
reportPath,
|
|
116
|
+
};
|
|
117
|
+
} catch (err) {
|
|
118
|
+
return { confidence: '', overallResult: '', changeName: '', reportPath, parseError: err.message };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function main() {
|
|
123
|
+
const projectRoot = path.resolve(readArg('project') || process.env.SDD_PROJECT || process.cwd());
|
|
124
|
+
const explicitChange = readArg('change') || process.env.SDD_CHANGE || process.env.OPENSPEC_CHANGE || '';
|
|
125
|
+
const strictMode = hasFlag('strict') || process.env.SDD_STRICT_CONSISTENCY === '1';
|
|
126
|
+
const looseMode = hasFlag('no-strict') || process.env.SDD_LOOSE_CONSISTENCY === '1';
|
|
127
|
+
|
|
128
|
+
const specRoot = resolveSpecRoot(projectRoot);
|
|
129
|
+
if (!specRoot) {
|
|
130
|
+
console.log('SDD consistency-check (pre-push): 未找到 spec 仓库,跳过一致性校验');
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const changes = discoverActiveChanges(specRoot, explicitChange);
|
|
135
|
+
if (changes.length === 0) {
|
|
136
|
+
console.log('SDD consistency-check (pre-push): 无活跃变更,跳过一致性校验');
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const blockers = [];
|
|
141
|
+
const missing = [];
|
|
142
|
+
const passed = [];
|
|
143
|
+
|
|
144
|
+
for (const changeName of changes) {
|
|
145
|
+
const reportPath = findConsistencyReport(specRoot, changeName);
|
|
146
|
+
if (!reportPath) {
|
|
147
|
+
missing.push(changeName);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const report = parseReport(reportPath);
|
|
152
|
+
if (report.parseError) {
|
|
153
|
+
console.warn(`SDD consistency-check (pre-push): ⚠️ ${changeName} 报告解析失败: ${report.parseError}`);
|
|
154
|
+
missing.push(changeName);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (report.confidence === 'low' || report.overallResult === 'fail') {
|
|
159
|
+
blockers.push({ changeName, report });
|
|
160
|
+
} else {
|
|
161
|
+
passed.push({ changeName, report });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 输出摘要
|
|
166
|
+
if (passed.length > 0) {
|
|
167
|
+
for (const item of passed) {
|
|
168
|
+
console.log(`SDD consistency-check (pre-push): ✅ ${item.changeName} 置信度=${item.report.confidence}`);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (missing.length > 0) {
|
|
173
|
+
console.warn(`SDD consistency-check (pre-push): ⚠️ 以下变更缺少一致性校验报告:`);
|
|
174
|
+
for (const name of missing) {
|
|
175
|
+
console.warn(` - ${name}`);
|
|
176
|
+
}
|
|
177
|
+
if (strictMode && !looseMode) {
|
|
178
|
+
console.error('SDD consistency-check (pre-push): ❌ strict 模式已启用,缺少报告的变更不允许推送。');
|
|
179
|
+
console.error('请先执行 opsx-consistency-check 生成报告,或使用 --no-verify 跳过(不推荐)。');
|
|
180
|
+
process.exit(1);
|
|
181
|
+
}
|
|
182
|
+
console.warn(' → 推送已放行,建议尽快执行 opsx-consistency-check');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (blockers.length > 0) {
|
|
186
|
+
console.error(`SDD consistency-check (pre-push): ❌ 以下变更的一致性校验未通过(confidence=low),阻止推送:`);
|
|
187
|
+
for (const item of blockers) {
|
|
188
|
+
console.error(` - ${item.changeName} (confidence=${item.report.confidence}, result=${item.report.overallResult})`);
|
|
189
|
+
console.error(` 报告路径: ${item.report.reportPath}`);
|
|
190
|
+
}
|
|
191
|
+
console.error('');
|
|
192
|
+
console.error('请修复不一致项后重新执行 opsx-consistency-check,或使用 --no-verify 跳过(不推荐)。');
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
main();
|
|
@@ -0,0 +1,369 @@
|
|
|
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 resolveSkywalkDir(dir) {
|
|
54
|
+
const root = path.resolve(dir || '.');
|
|
55
|
+
const direct = path.join(root, 'skywalk-sdd');
|
|
56
|
+
if (
|
|
57
|
+
fs.existsSync(path.join(direct, 'log.cjs'))
|
|
58
|
+
|| fs.existsSync(path.join(direct, 'log.js'))
|
|
59
|
+
) {
|
|
60
|
+
return direct;
|
|
61
|
+
}
|
|
62
|
+
const hintFile = path.join(root, '.sdd-spec-root');
|
|
63
|
+
if (fs.existsSync(hintFile)) {
|
|
64
|
+
const rel = String(fs.readFileSync(hintFile, 'utf8') || '')
|
|
65
|
+
.trim()
|
|
66
|
+
.split(/\r?\n/)[0]
|
|
67
|
+
.trim();
|
|
68
|
+
if (rel) {
|
|
69
|
+
const nested = path.join(root, rel, 'skywalk-sdd');
|
|
70
|
+
if (
|
|
71
|
+
fs.existsSync(path.join(nested, 'log.cjs'))
|
|
72
|
+
|| fs.existsSync(path.join(nested, 'log.js'))
|
|
73
|
+
) {
|
|
74
|
+
return nested;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function hasTelemetryCli(dir) {
|
|
82
|
+
return Boolean(resolveSkywalkDir(dir));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function findProjectRoot(startDir) {
|
|
86
|
+
if (!startDir) return '';
|
|
87
|
+
let current = path.resolve(startDir);
|
|
88
|
+
for (let i = 0; i < 25; i++) {
|
|
89
|
+
if (hasTelemetryCli(current) || fs.existsSync(path.join(current, '.sdd-spec-root'))) {
|
|
90
|
+
return current;
|
|
91
|
+
}
|
|
92
|
+
const parent = path.dirname(current);
|
|
93
|
+
if (parent === current) return '';
|
|
94
|
+
current = parent;
|
|
95
|
+
}
|
|
96
|
+
return '';
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function getProjectRoot(input, provider = 'codebuddy') {
|
|
100
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
101
|
+
const envKey = PROJECT_ENV_KEYS[provider] || PROJECT_ENV_KEYS.codebuddy;
|
|
102
|
+
const candidates = [
|
|
103
|
+
toolInput.cwd,
|
|
104
|
+
input.cwd,
|
|
105
|
+
input.project_root,
|
|
106
|
+
process.env[envKey],
|
|
107
|
+
process.env.PWD,
|
|
108
|
+
process.cwd(),
|
|
109
|
+
].filter(Boolean);
|
|
110
|
+
for (const dir of candidates) {
|
|
111
|
+
const root = findProjectRoot(dir);
|
|
112
|
+
if (root) return root;
|
|
113
|
+
}
|
|
114
|
+
return process.cwd();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function safeChangeName(name) {
|
|
118
|
+
if (!name) return '';
|
|
119
|
+
return String(name)
|
|
120
|
+
.toLowerCase()
|
|
121
|
+
.replace(/[\s_]+/g, '-')
|
|
122
|
+
.replace(/[^a-z0-9一-鿿\-]/g, '-')
|
|
123
|
+
.replace(/-+/g, '-')
|
|
124
|
+
.replace(/^-|-$/g, '');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function findActiveApplyStage(projectRoot) {
|
|
128
|
+
const skywalk = resolveSkywalkDir(projectRoot);
|
|
129
|
+
const stateDir = skywalk
|
|
130
|
+
? path.join(skywalk, 'state')
|
|
131
|
+
: path.join(projectRoot, 'skywalk-sdd', 'state');
|
|
132
|
+
if (!fs.existsSync(stateDir)) return null;
|
|
133
|
+
|
|
134
|
+
let latest = null;
|
|
135
|
+
for (const file of fs.readdirSync(stateDir).filter((f) => f.endsWith('.json'))) {
|
|
136
|
+
try {
|
|
137
|
+
const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
|
|
138
|
+
const event = data.event || null;
|
|
139
|
+
if (event && event.command === 'apply') {
|
|
140
|
+
if (!latest || new Date(event.timestamp) > new Date(latest.timestamp)) {
|
|
141
|
+
latest = event;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
} catch {
|
|
145
|
+
// skip
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return latest;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function hasCompletedCheck(projectRoot, changeName) {
|
|
152
|
+
const safeName = safeChangeName(changeName);
|
|
153
|
+
const skywalk = resolveSkywalkDir(projectRoot);
|
|
154
|
+
const eventsChangeDir = skywalk
|
|
155
|
+
? path.join(skywalk, 'events', safeName)
|
|
156
|
+
: path.join(projectRoot, 'skywalk-sdd', 'events', safeName);
|
|
157
|
+
if (!fs.existsSync(eventsChangeDir)) return false;
|
|
158
|
+
|
|
159
|
+
const jsonlFiles = fs.readdirSync(eventsChangeDir)
|
|
160
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
161
|
+
.sort()
|
|
162
|
+
.reverse();
|
|
163
|
+
|
|
164
|
+
for (const file of jsonlFiles) {
|
|
165
|
+
try {
|
|
166
|
+
const lines = fs.readFileSync(path.join(eventsChangeDir, file), 'utf-8')
|
|
167
|
+
.split('\n')
|
|
168
|
+
.filter(Boolean);
|
|
169
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
170
|
+
try {
|
|
171
|
+
const event = JSON.parse(lines[i]);
|
|
172
|
+
if (
|
|
173
|
+
event.type === 'stage_end' &&
|
|
174
|
+
event.command === 'check' &&
|
|
175
|
+
(event.result === 'success' || event.result === 'partial')
|
|
176
|
+
) {
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// skip
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// skip
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function getPassedChanges(projectRoot) {
|
|
191
|
+
if (!projectRoot) return [];
|
|
192
|
+
const changesDir = path.join(projectRoot, 'openspec', 'changes');
|
|
193
|
+
if (!fs.existsSync(changesDir)) return [];
|
|
194
|
+
|
|
195
|
+
const passed = [];
|
|
196
|
+
try {
|
|
197
|
+
for (const entry of fs.readdirSync(changesDir, { withFileTypes: true })) {
|
|
198
|
+
if (!entry.isDirectory()) continue;
|
|
199
|
+
if (entry.name.startsWith('.') || entry.name === 'archive') continue;
|
|
200
|
+
if (hasCompletedCheck(projectRoot, entry.name)) {
|
|
201
|
+
passed.push(entry.name);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// ignore
|
|
206
|
+
}
|
|
207
|
+
return passed;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isOpsxApplySkill(toolInput) {
|
|
211
|
+
if (!toolInput) return false;
|
|
212
|
+
let input;
|
|
213
|
+
if (typeof toolInput === 'string') {
|
|
214
|
+
try { input = JSON.parse(toolInput); } catch { return false; }
|
|
215
|
+
} else {
|
|
216
|
+
input = toolInput;
|
|
217
|
+
}
|
|
218
|
+
if (!input || typeof input !== 'object') return false;
|
|
219
|
+
return (input.skill || '') === 'opsx-apply';
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function extractChangeName(toolInput) {
|
|
223
|
+
if (!toolInput) return null;
|
|
224
|
+
let input;
|
|
225
|
+
if (typeof toolInput === 'string') {
|
|
226
|
+
try { input = JSON.parse(toolInput); } catch { return null; }
|
|
227
|
+
} else {
|
|
228
|
+
input = toolInput;
|
|
229
|
+
}
|
|
230
|
+
if (!input || typeof input !== 'object') return null;
|
|
231
|
+
|
|
232
|
+
const args = input.args || '';
|
|
233
|
+
if (!args.trim()) return null;
|
|
234
|
+
const trimmed = args.trim();
|
|
235
|
+
const changeFlagMatch = trimmed.match(/^--change\s+(.+)$/);
|
|
236
|
+
if (changeFlagMatch) {
|
|
237
|
+
return safeChangeName(changeFlagMatch[1].trim().split(/\s+/)[0]);
|
|
238
|
+
}
|
|
239
|
+
return safeChangeName(trimmed.split(/\s+/)[0]);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function blockWithReason(reason, extra) {
|
|
243
|
+
console.log(JSON.stringify({ decision: 'block', reason, ...(extra || {}) }));
|
|
244
|
+
process.exit(2);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function allowExit(extra) {
|
|
248
|
+
if (extra) {
|
|
249
|
+
console.log(JSON.stringify({ decision: 'allow', ...extra }));
|
|
250
|
+
}
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function evaluateApplyWriteGate(input, provider = 'codebuddy') {
|
|
255
|
+
const toolName = String(input.tool_name || input.toolName || '');
|
|
256
|
+
if (!toolName) {
|
|
257
|
+
return {
|
|
258
|
+
action: 'block',
|
|
259
|
+
reason: 'stdin 缺少必要字段 tool_name,无法执行 apply 写入门禁检查',
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
if (toolName !== 'Write' && toolName !== 'Edit') {
|
|
263
|
+
return { action: 'allow' };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const projectRoot = getProjectRoot(input, provider);
|
|
267
|
+
const activeApply = findActiveApplyStage(projectRoot);
|
|
268
|
+
if (!activeApply) {
|
|
269
|
+
return { action: 'allow' };
|
|
270
|
+
}
|
|
271
|
+
if (hasCompletedCheck(projectRoot, activeApply.change)) {
|
|
272
|
+
return { action: 'allow' };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const changeName = activeApply.change || 'unknown';
|
|
276
|
+
return {
|
|
277
|
+
action: 'block',
|
|
278
|
+
reason: `[SDD Apply Gate] 检测到 apply 阶段正在执行(change: ${changeName}),但 check 阶段尚未完成。\n\n请先执行 /opsx-check 完成质量门禁检查,再执行 /opsx-apply 进行代码实施。\n\n操作顺序:/opsx-check → /opsx-apply`,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function evaluateSkillApplyGate(input, provider = 'codebuddy') {
|
|
283
|
+
const toolName = String(input.tool_name || input.toolName || '');
|
|
284
|
+
if (!toolName) {
|
|
285
|
+
return {
|
|
286
|
+
action: 'block',
|
|
287
|
+
reason: 'stdin 缺少必要字段 tool_name,无法执行 Skill apply 门禁检查',
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
if (toolName !== 'Skill') {
|
|
291
|
+
return { action: 'allow' };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const toolInput = input.tool_input;
|
|
295
|
+
if (!isOpsxApplySkill(toolInput)) {
|
|
296
|
+
return { action: 'allow' };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const projectRoot = getProjectRoot(input, provider);
|
|
300
|
+
if (!findProjectRoot(projectRoot)) {
|
|
301
|
+
return { action: 'allow' };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const changeName = extractChangeName(toolInput);
|
|
305
|
+
if (changeName) {
|
|
306
|
+
if (hasCompletedCheck(projectRoot, changeName)) {
|
|
307
|
+
return { action: 'allow' };
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
action: 'block',
|
|
311
|
+
reason: `变更 "${changeName}" 尚未完成 check 阶段。请先执行 /opsx-check ${changeName} 完成检查后再 apply。`,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const passedChanges = getPassedChanges(projectRoot);
|
|
316
|
+
if (passedChanges.length === 0) {
|
|
317
|
+
return {
|
|
318
|
+
action: 'block',
|
|
319
|
+
reason: '当前项目没有任何变更已完成 check 阶段。请先执行 /opsx-check <change-name> 完成检查后再 apply。',
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const msg = passedChanges.length === 1
|
|
324
|
+
? `检测到已通过 check 的变更: "${passedChanges[0]}",允许进入 apply。`
|
|
325
|
+
: `检测到 ${passedChanges.length} 个已通过 check 的变更: ${passedChanges.join(', ')},允许进入 apply。`;
|
|
326
|
+
return { action: 'allow', reason: msg };
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function evaluatePreToolDangerGate(input) {
|
|
330
|
+
const toolInput = input.tool_input || input.toolInput || {};
|
|
331
|
+
const command = String(toolInput.command || input.command || '');
|
|
332
|
+
const dangerousPatterns = [
|
|
333
|
+
/\brm\s+-rf\b/i,
|
|
334
|
+
/\brmdir\s+\/s\b/i,
|
|
335
|
+
/\bdel\s+\/[fsq]/i,
|
|
336
|
+
/\bgit\s+reset\s+--hard\b/i,
|
|
337
|
+
/\bgit\s+clean\s+-fdx\b/i,
|
|
338
|
+
/\bRemove-Item\b.*\b-Recurse\b.*\b-Force\b/i,
|
|
339
|
+
];
|
|
340
|
+
if (dangerousPatterns.some((pattern) => pattern.test(command))) {
|
|
341
|
+
return {
|
|
342
|
+
action: 'block',
|
|
343
|
+
reason: 'Blocked by SDD hook: destructive command requires explicit user approval.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return { action: 'allow' };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
module.exports = {
|
|
350
|
+
PROJECT_ENV_KEYS,
|
|
351
|
+
readStdin,
|
|
352
|
+
parseHookInput,
|
|
353
|
+
normalizeHookInput,
|
|
354
|
+
resolveSkywalkDir,
|
|
355
|
+
hasTelemetryCli,
|
|
356
|
+
findProjectRoot,
|
|
357
|
+
getProjectRoot,
|
|
358
|
+
safeChangeName,
|
|
359
|
+
findActiveApplyStage,
|
|
360
|
+
hasCompletedCheck,
|
|
361
|
+
getPassedChanges,
|
|
362
|
+
isOpsxApplySkill,
|
|
363
|
+
extractChangeName,
|
|
364
|
+
blockWithReason,
|
|
365
|
+
allowExit,
|
|
366
|
+
evaluateApplyWriteGate,
|
|
367
|
+
evaluateSkillApplyGate,
|
|
368
|
+
evaluatePreToolDangerGate,
|
|
369
|
+
};
|
|
@@ -341,6 +341,38 @@ function hasExistingWarning(events, code) {
|
|
|
341
341
|
);
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
/** B3: 检测 TDD 时间异常(每任务平均耗时 <120 秒) */
|
|
345
|
+
function detectTimingAnomaly(events, sinceTimestamp) {
|
|
346
|
+
const since = sinceTimestamp ? new Date(sinceTimestamp).getTime() : 0;
|
|
347
|
+
const filtered = (events || []).filter(
|
|
348
|
+
(e) => new Date(e.timestamp || 0).getTime() >= since
|
|
349
|
+
);
|
|
350
|
+
const taskUpdates = filtered.filter(
|
|
351
|
+
(e) => e.type === 'task_update' && e.status === 'completed'
|
|
352
|
+
);
|
|
353
|
+
if (taskUpdates.length < 10) return null;
|
|
354
|
+
const timestamps = filtered
|
|
355
|
+
.map((e) => new Date(e.timestamp || 0).getTime())
|
|
356
|
+
.filter((t) => t > 0);
|
|
357
|
+
if (timestamps.length === 0) return null;
|
|
358
|
+
const firstTs = Math.min(...timestamps);
|
|
359
|
+
const lastTs = Math.max(...timestamps);
|
|
360
|
+
const totalDurationSec = (lastTs - firstTs) / 1000;
|
|
361
|
+
const perTaskSec = totalDurationSec / taskUpdates.length;
|
|
362
|
+
const TDD_MIN_PER_TASK = 120;
|
|
363
|
+
if (perTaskSec < TDD_MIN_PER_TASK) {
|
|
364
|
+
return {
|
|
365
|
+
anomaly: true,
|
|
366
|
+
perTask: Math.round(perTaskSec),
|
|
367
|
+
totalTasks: taskUpdates.length,
|
|
368
|
+
totalDuration: Math.round(totalDurationSec),
|
|
369
|
+
testEvidence: filtered.filter((e) => e.type === 'test_result').length,
|
|
370
|
+
threshold: TDD_MIN_PER_TASK,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
return null;
|
|
374
|
+
}
|
|
375
|
+
|
|
344
376
|
// ── 主逻辑(仅直接执行时运行;被 require 时跳过,便于单元测试) ──
|
|
345
377
|
if (require.main === module) {
|
|
346
378
|
const parsed = parseHookInput(readStdin(), { strict: true });
|
|
@@ -390,6 +422,14 @@ if (require.main === module) {
|
|
|
390
422
|
'tdd 策略下 apply 阶段未检测到失败的测试结果(红灯),测试骨架可能未经 RED 验证即标记完成');
|
|
391
423
|
}
|
|
392
424
|
}
|
|
425
|
+
// B3: TDD 时间异常检测
|
|
426
|
+
const timingAnomaly = detectTimingAnomaly(warningEvents, activeApply.timestamp);
|
|
427
|
+
if (timingAnomaly && !hasExistingWarning(warningEvents, 'tdd_timing_anomaly')) {
|
|
428
|
+
recordWarning(projectRoot, warningCtx, 'tdd_timing_anomaly',
|
|
429
|
+
'TDD 时间异常:' + timingAnomaly.totalTasks + ' 个任务在 ' + timingAnomaly.totalDuration +
|
|
430
|
+
' 秒内完成(每任务 ' + timingAnomaly.perTask + ' 秒,阈值 ' + timingAnomaly.threshold +
|
|
431
|
+
' 秒),测试证据 ' + timingAnomaly.testEvidence + ' 条。可能存在批量执行或跳过 TDD 红绿循环的情况。');
|
|
432
|
+
}
|
|
393
433
|
} catch {
|
|
394
434
|
// 警告检测失败不阻断主流程
|
|
395
435
|
}
|
|
@@ -418,6 +458,7 @@ module.exports = {
|
|
|
418
458
|
detectReusedTestResults,
|
|
419
459
|
detectProcessNoteGap,
|
|
420
460
|
detectMissingTddRed,
|
|
461
|
+
detectTimingAnomaly,
|
|
421
462
|
hasExistingWarning,
|
|
422
463
|
isRealTestDetails,
|
|
423
464
|
isStrictTestEvidence,
|