kld-sdd 2.6.15 → 2.6.17

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 (69) hide show
  1. package/lib/init.js +174 -76
  2. package/lib/skills-bundle.js +23 -1
  3. package/lib/tool-profiles.js +8 -0
  4. package/package.json +6 -1
  5. package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
  6. package/skywalk-sdd/context-client.cjs +38 -87
  7. package/skywalk-sdd/index.cjs +719 -108
  8. package/skywalk-sdd/kb-sync-identity.cjs +780 -0
  9. package/skywalk-sdd/kb-upload.cjs +505 -0
  10. package/skywalk-sdd/lib/shared.cjs +663 -0
  11. package/skywalk-sdd/metrics-v3.cjs +138 -8
  12. package/skywalk-sdd/ontology/archive-package.cjs +19 -34
  13. package/skywalk-sdd/ontology/change-lock.cjs +3 -7
  14. package/skywalk-sdd/ontology/external-key.cjs +18 -4
  15. package/skywalk-sdd/ontology/id.cjs +26 -5
  16. package/skywalk-sdd/ontology/identity-index.cjs +3 -7
  17. package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
  18. package/skywalk-sdd/ontology/runtime.cjs +16 -12
  19. package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
  20. package/skywalk-sdd/openspec-shim.cjs +48 -0
  21. package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
  22. package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
  23. package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
  24. package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
  25. package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
  26. package/skywalk-sdd/spec-root.cjs +31 -0
  27. package/templates/git-hooks/commit-msg +46 -0
  28. package/templates/git-hooks/pre-commit +57 -0
  29. package/templates/git-hooks/pre-commit-consistency-check.cjs +193 -0
  30. package/templates/git-hooks/pre-push +57 -0
  31. package/templates/git-hooks/pre-push-consistency-check.cjs +197 -0
  32. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +696 -0
  33. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +86 -0
  34. package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +165 -0
  35. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +36 -1
  36. package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +296 -0
  37. package/templates/hooks/codebuddy/settings.json +8 -0
  38. package/templates/openspec/proposal.md +7 -3
  39. package/templates/openspec/spec.md +3 -3
  40. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +21 -6
  41. package/templates/skills/kld-sdd/opsx-apply/checklist.md +17 -0
  42. package/templates/skills/kld-sdd/opsx-apply/reference.md +70 -7
  43. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
  44. package/templates/skills/kld-sdd/opsx-check/SKILL.md +48 -16
  45. package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
  46. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +592 -0
  47. package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
  48. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
  49. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +164 -0
  50. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +116 -0
  51. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
  52. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
  53. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
  54. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
  55. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
  56. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
  57. package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
  58. package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
  59. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
  60. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
  61. package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
  62. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
  63. package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
  64. package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
  65. package/templates/skills/kld-sdd/tdd-rules/SKILL.md +1 -0
  66. package/templates/skills/kld-sdd/tdd-rules/rules/tdd-rhythm-enforcement.md +101 -0
  67. package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
  68. package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
  69. package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
@@ -0,0 +1,696 @@
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
+ // ═══════════════════════════════════════════════════════════════
350
+ // TDD 批量执行检测工具函数(E1/E2/E3 对应实现)
351
+ // ═══════════════════════════════════════════════════════════════
352
+
353
+ /**
354
+ * 从事件列表中提取 TDD 测试事件。
355
+ * 关键:对同一 task_id 只取最新的 test_result 事件("最新事件优先"机制),
356
+ * 这样 agent 重做产生的新事件会覆盖旧的批量事件。
357
+ */
358
+ function extractTddTestEvents(events, sinceTimestamp) {
359
+ const since = sinceTimestamp ? new Date(sinceTimestamp).getTime() : 0;
360
+ const all = (events || [])
361
+ .filter((e) => e && e.type === 'test_result' && e.details && e.details.test_results)
362
+ .filter((e) => {
363
+ const ts = new Date(e.timestamp || 0).getTime();
364
+ return ts >= since;
365
+ })
366
+ .map((e) => ({
367
+ task_id: e.task_id || '',
368
+ phase: (e.details.test_results.tdd_phase || 'unknown').toLowerCase(),
369
+ result: e.result || '',
370
+ timestamp_ms: new Date(e.timestamp || 0).getTime(),
371
+ timestamp: e.timestamp,
372
+ }))
373
+ .filter((e) => ['red', 'green', 'refactor'].includes(e.phase));
374
+
375
+ // "最新事件优先":对同一 task_id 只保留 timestamp 最大的事件
376
+ const latestPerTask = new Map();
377
+ for (const ev of all) {
378
+ const existing = latestPerTask.get(ev.task_id);
379
+ if (!existing || ev.timestamp_ms > existing.timestamp_ms) {
380
+ latestPerTask.set(ev.task_id, ev);
381
+ }
382
+ }
383
+
384
+ return Array.from(latestPerTask.values()).sort((a, b) => a.timestamp_ms - b.timestamp_ms);
385
+ }
386
+
387
+ /**
388
+ * 从 tasks.md 内容构建 RED→GREEN 配对映射。
389
+ * 优先从 dependsOn 字段解析,退化为编号推断。
390
+ */
391
+ function buildPairMap(tasksMdContent) {
392
+ const pairMap = {};
393
+ if (!tasksMdContent) return pairMap;
394
+
395
+ // 尝试从 dependsOn 字段解析
396
+ const taskBlocks = tasksMdContent.split(/^###\s+/m);
397
+ for (const block of taskBlocks) {
398
+ // 匹配 TASK-AUTH-RED-1 或 TASK-BOOK-GREEN-7 格式
399
+ const idMatch = block.match(/^\[?(TASK-[A-Z]+-(?:RED|GREEN|REFACTOR)-\d+)\]?/i);
400
+ if (!idMatch) continue;
401
+ const taskId = idMatch[1].toUpperCase();
402
+ // 匹配 dependsOn: TASK-AUTH-RED-1(处理 markdown ** 粗体标记)
403
+ const depMatch = block.match(/dependsOn[\s\*]*[::]\s*(TASK-[A-Z]+-RED-\d+)/i);
404
+ if (depMatch) {
405
+ pairMap[taskId] = depMatch[1].toUpperCase();
406
+ }
407
+ }
408
+
409
+ // 退化为编号推断:GREEN-N 配 RED-N
410
+ const redNums = new Set();
411
+ const greenNums = new Set();
412
+ const allTaskIds = tasksMdContent.match(/TASK-[A-Z]+-(?:RED|GREEN)-\d+/gi) || [];
413
+ for (const id of allTaskIds) {
414
+ const upper = id.toUpperCase();
415
+ if (upper.includes('-RED-')) {
416
+ const num = upper.match(/RED-(\d+)/);
417
+ if (num) redNums.add(parseInt(num[1]));
418
+ } else if (upper.includes('-GREEN-')) {
419
+ const num = upper.match(/GREEN-(\d+)/);
420
+ if (num) greenNums.add(parseInt(num[1]));
421
+ }
422
+ }
423
+ // 为没有 dependsOn 映射的 GREEN 补充编号推断
424
+ for (const num of greenNums) {
425
+ const greenId = `GREEN-${num}`;
426
+ const redId = `RED-${num}`;
427
+ const hasMapping = Object.keys(pairMap).some((k) => k.includes(greenId));
428
+ if (!hasMapping && redNums.has(num)) {
429
+ const greenPattern = new RegExp(`(TASK-[A-Z]+-\\d+-${greenId}-\\d+)`, 'i');
430
+ const redPattern = new RegExp(`(TASK-[A-Z]+-\\d+-${redId}-\\d+)`, 'i');
431
+ const greenMatch = tasksMdContent.match(greenPattern);
432
+ const redMatch = tasksMdContent.match(redPattern);
433
+ if (greenMatch && redMatch) {
434
+ pairMap[greenMatch[1].toUpperCase()] = redMatch[1].toUpperCase();
435
+ }
436
+ }
437
+ }
438
+
439
+ return pairMap;
440
+ }
441
+
442
+ /**
443
+ * 检测连续 RED 事件(无中间 GREEN)。
444
+ * 针对模式:RED-1→RED-2→RED-3→GREEN-1→GREEN-2→GREEN-3
445
+ * 注意:对"正确交错但极速"模式无效,保留防御其他批量模式。
446
+ */
447
+ function detectConsecutiveReds(tddEvents) {
448
+ const violations = [];
449
+ let consecutiveReds = [];
450
+
451
+ for (const ev of tddEvents) {
452
+ if (ev.phase === 'red') {
453
+ consecutiveReds.push(ev);
454
+ } else {
455
+ if (consecutiveReds.length >= 2) {
456
+ violations.push({
457
+ type: 'consecutive_reds',
458
+ severity: 'critical',
459
+ red_events: consecutiveReds.map((r) => ({
460
+ task_id: r.task_id,
461
+ timestamp: r.timestamp,
462
+ })),
463
+ message: `检测到 ${consecutiveReds.length} 个连续 RED 事件无中间 GREEN:` +
464
+ consecutiveReds.map((r) => r.task_id).join(', ') +
465
+ '。TDD 要求每个 RED 后立即实现 GREEN。',
466
+ });
467
+ }
468
+ consecutiveReds = [];
469
+ }
470
+ }
471
+ if (consecutiveReds.length >= 2) {
472
+ violations.push({
473
+ type: 'consecutive_reds',
474
+ severity: 'critical',
475
+ red_events: consecutiveReds.map((r) => ({
476
+ task_id: r.task_id,
477
+ timestamp: r.timestamp,
478
+ })),
479
+ message: `检测到 ${consecutiveReds.length} 个连续 RED 事件无中间 GREEN:` +
480
+ consecutiveReds.map((r) => r.task_id).join(', ') +
481
+ '。TDD 要求每个 RED 后立即实现 GREEN。',
482
+ });
483
+ }
484
+ return violations;
485
+ }
486
+
487
+ /**
488
+ * 检测交错违规:RED-M 出现在 RED-N 和 GREEN-N 之间。
489
+ * 针对模式:RED-1→RED-2→GREEN-1→GREEN-2
490
+ * 注意:对"正确交错但极速"模式无效,保留防御其他批量模式。
491
+ */
492
+ function detectInterleavingViolation(tddEvents, pairMap) {
493
+ const violations = [];
494
+ if (!pairMap || Object.keys(pairMap).length === 0) return violations;
495
+
496
+ for (const greenEv of tddEvents.filter((e) => e.phase === 'green')) {
497
+ const pairedRedId = pairMap[greenEv.task_id];
498
+ if (!pairedRedId) continue;
499
+
500
+ const redEv = tddEvents.find((e) => e.task_id === pairedRedId && e.phase === 'red');
501
+ if (!redEv) continue;
502
+
503
+ // 检查 RED-N 和 GREEN-N 之间是否有其他 RED 事件
504
+ const betweenReds = tddEvents.filter((e) =>
505
+ e.phase === 'red' &&
506
+ e.task_id !== pairedRedId &&
507
+ redEv.timestamp_ms < e.timestamp_ms &&
508
+ e.timestamp_ms < greenEv.timestamp_ms
509
+ );
510
+
511
+ if (betweenReds.length > 0) {
512
+ violations.push({
513
+ type: 'interleaving_violation',
514
+ severity: 'critical',
515
+ green_task: greenEv.task_id,
516
+ red_task: pairedRedId,
517
+ between_reds: betweenReds.map((r) => ({ task_id: r.task_id, timestamp: r.timestamp })),
518
+ message: `交错违规:${pairedRedId} 和 ${greenEv.task_id} 之间存在其他 RED 事件:` +
519
+ betweenReds.map((r) => r.task_id).join(', '),
520
+ });
521
+ }
522
+ }
523
+ return violations;
524
+ }
525
+
526
+ /**
527
+ * E3: 时间戳聚类检测(升级版)。
528
+ * 4+ 事件在 10 秒内 → critical (block)
529
+ * 3 事件在 10 秒内 → warning
530
+ */
531
+ function detectTimestampClustering(tddEvents) {
532
+ const CLUSTER_WINDOW_MS = 10_000;
533
+ const CLUSTER_BLOCK_THRESHOLD = 4;
534
+ const CLUSTER_WARN_THRESHOLD = 3;
535
+
536
+ for (let i = 0; i <= tddEvents.length - CLUSTER_WARN_THRESHOLD; i++) {
537
+ const windowStart = tddEvents[i].timestamp_ms;
538
+ let count = 0;
539
+ for (let j = i; j < tddEvents.length; j++) {
540
+ if (tddEvents[j].timestamp_ms - windowStart < CLUSTER_WINDOW_MS) {
541
+ count++;
542
+ } else {
543
+ break;
544
+ }
545
+ }
546
+ if (count >= CLUSTER_BLOCK_THRESHOLD) {
547
+ return [{
548
+ type: 'timestamp_clustering',
549
+ severity: 'critical',
550
+ event_count: count,
551
+ window_seconds: (tddEvents[i + count - 1].timestamp_ms - windowStart) / 1000,
552
+ events: tddEvents.slice(i, i + count).map((e) => ({
553
+ task_id: e.task_id,
554
+ phase: e.phase,
555
+ timestamp: e.timestamp,
556
+ })),
557
+ message: `时间戳聚类:${count} 个事件在 ${(tddEvents[i + count - 1].timestamp_ms - windowStart) / 1000} 秒内集中发生`,
558
+ }];
559
+ }
560
+ if (count >= CLUSTER_WARN_THRESHOLD) {
561
+ return [{
562
+ type: 'timestamp_clustering',
563
+ severity: 'warning',
564
+ event_count: count,
565
+ window_seconds: (tddEvents[i + count - 1].timestamp_ms - windowStart) / 1000,
566
+ events: tddEvents.slice(i, i + count).map((e) => ({
567
+ task_id: e.task_id,
568
+ phase: e.phase,
569
+ timestamp: e.timestamp,
570
+ })),
571
+ message: `时间戳聚类:${count} 个事件在 ${(tddEvents[i + count - 1].timestamp_ms - windowStart) / 1000} 秒内集中发生`,
572
+ }];
573
+ }
574
+ }
575
+ return [];
576
+ }
577
+
578
+ /**
579
+ * E2: 极速配对检测。
580
+ * 针对实际违规模式:"正确交错但极速完成"
581
+ * 3+ 对 RED→GREEN 间隔 < 5 秒 → critical (block)
582
+ * 2 对 → warning
583
+ */
584
+ function detectRapidPairing(tddEvents, pairMap) {
585
+ const RAPID_PAIR_THRESHOLD_MS = 5_000;
586
+ const RAPID_PAIR_MIN_COUNT = 3;
587
+
588
+ if (!pairMap || Object.keys(pairMap).length === 0) return [];
589
+
590
+ const pairs = [];
591
+ for (const greenEv of tddEvents.filter((e) => e.phase === 'green')) {
592
+ const pairedRedId = pairMap[greenEv.task_id];
593
+ if (!pairedRedId) continue;
594
+ const redEv = tddEvents.find((e) => e.task_id === pairedRedId && e.phase === 'red');
595
+ if (!redEv) continue;
596
+ const gap = greenEv.timestamp_ms - redEv.timestamp_ms;
597
+ if (gap >= 0) {
598
+ pairs.push({
599
+ red_task_id: pairedRedId,
600
+ green_task_id: greenEv.task_id,
601
+ gap_ms: gap,
602
+ gap_seconds: gap / 1000,
603
+ red_timestamp: redEv.timestamp,
604
+ green_timestamp: greenEv.timestamp,
605
+ });
606
+ }
607
+ }
608
+
609
+ const rapidPairs = pairs.filter((p) => p.gap_ms < RAPID_PAIR_THRESHOLD_MS);
610
+
611
+ if (rapidPairs.length >= RAPID_PAIR_MIN_COUNT) {
612
+ return [{
613
+ type: 'rapid_pairing',
614
+ severity: 'critical',
615
+ rapid_pair_count: rapidPairs.length,
616
+ rapid_pairs: rapidPairs.map((p) => ({
617
+ red: p.red_task_id,
618
+ green: p.green_task_id,
619
+ gap_seconds: p.gap_seconds,
620
+ })),
621
+ message: `检测到 ${rapidPairs.length} 个极速 RED→GREEN 配对(间隔 < 5 秒):` +
622
+ rapidPairs.map((p) => `${p.red_task_id}→${p.green_task_id}(${p.gap_seconds.toFixed(1)}秒)`).join(', '),
623
+ }];
624
+ }
625
+ if (rapidPairs.length >= 2) {
626
+ return [{
627
+ type: 'rapid_pairing',
628
+ severity: 'warning',
629
+ rapid_pair_count: rapidPairs.length,
630
+ rapid_pairs: rapidPairs.map((p) => ({
631
+ red: p.red_task_id,
632
+ green: p.green_task_id,
633
+ gap_seconds: p.gap_seconds,
634
+ })),
635
+ message: `检测到 ${rapidPairs.length} 个极速 RED→GREEN 配对(间隔 < 5 秒)`,
636
+ }];
637
+ }
638
+ return [];
639
+ }
640
+
641
+ /**
642
+ * 整合所有 TDD 批量执行检测。
643
+ * 调用此函数即可获得完整的违规分析。
644
+ */
645
+ function detectTddBatchPattern(events, sinceTimestamp, tasksMdContent) {
646
+ const tddEvents = extractTddTestEvents(events, sinceTimestamp);
647
+ const pairMap = buildPairMap(tasksMdContent || '');
648
+
649
+ const allViolations = [
650
+ ...detectConsecutiveReds(tddEvents),
651
+ ...detectInterleavingViolation(tddEvents, pairMap),
652
+ ...detectTimestampClustering(tddEvents),
653
+ ...detectRapidPairing(tddEvents, pairMap),
654
+ ];
655
+
656
+ const hasCritical = allViolations.some((v) => v.severity === 'critical');
657
+ const hasWarning = allViolations.some((v) => v.severity === 'warning');
658
+
659
+ return {
660
+ has_violation: allViolations.length > 0,
661
+ severity: hasCritical ? 'critical' : (hasWarning ? 'warning' : 'none'),
662
+ violations: allViolations,
663
+ tdd_events: tddEvents,
664
+ pair_map: pairMap,
665
+ };
666
+ }
667
+
668
+ module.exports = {
669
+ PROJECT_ENV_KEYS,
670
+ readStdin,
671
+ parseHookInput,
672
+ normalizeHookInput,
673
+ resolveSkywalkDir,
674
+ hasTelemetryCli,
675
+ findProjectRoot,
676
+ getProjectRoot,
677
+ safeChangeName,
678
+ findActiveApplyStage,
679
+ hasCompletedCheck,
680
+ getPassedChanges,
681
+ isOpsxApplySkill,
682
+ extractChangeName,
683
+ blockWithReason,
684
+ allowExit,
685
+ evaluateApplyWriteGate,
686
+ evaluateSkillApplyGate,
687
+ evaluatePreToolDangerGate,
688
+ // TDD 批量执行检测
689
+ extractTddTestEvents,
690
+ buildPairMap,
691
+ detectConsecutiveReds,
692
+ detectInterleavingViolation,
693
+ detectTimestampClustering,
694
+ detectRapidPairing,
695
+ detectTddBatchPattern,
696
+ };