kld-sdd 2.4.19 → 2.5.1

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 (47) hide show
  1. package/kld-sdd-guide.html +1109 -0
  2. package/lib/command-bridge.js +156 -0
  3. package/lib/deploy-codebuddy-hooks.js +99 -0
  4. package/lib/hook-gate-core.js +333 -0
  5. package/lib/init.js +136 -93
  6. package/lib/settings-merge.js +85 -0
  7. package/lib/skills-bundle.js +142 -5
  8. package/lib/tool-profiles.js +270 -0
  9. package/package.json +3 -2
  10. package/skywalk-sdd/index.cjs +1808 -129
  11. package/templates/commands/kunlunzhima/skill-bridge.md +23 -0
  12. package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +175 -28
  13. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +42 -21
  14. package/templates/hooks/codebuddy/hooks/sdd-apply-gate.cjs +16 -0
  15. package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +395 -0
  16. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +123 -0
  17. package/templates/hooks/codebuddy/hooks/sdd-pre-tool.cjs +16 -0
  18. package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +48 -0
  19. package/templates/hooks/codebuddy/hooks/sdd-skill-apply-gate.cjs +16 -0
  20. package/templates/hooks/codebuddy/hooks/sdd-stop.cjs +70 -0
  21. package/templates/hooks/codebuddy/settings.json +72 -0
  22. package/templates/openspec/proposal.md +0 -1
  23. package/templates/openspec/spec.md +2 -2
  24. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +65 -356
  25. package/templates/skills/kld-sdd/opsx-apply/checklist.md +94 -0
  26. package/templates/skills/kld-sdd/opsx-apply/reference.md +403 -0
  27. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +21 -5
  28. package/templates/skills/kld-sdd/opsx-archive/checklist.md +33 -0
  29. package/templates/skills/kld-sdd/opsx-check/SKILL.md +29 -5
  30. package/templates/skills/kld-sdd/opsx-check/checklist.md +37 -0
  31. package/templates/skills/kld-sdd/opsx-design/SKILL.md +47 -51
  32. package/templates/skills/kld-sdd/opsx-design/checklist.md +46 -0
  33. package/templates/skills/kld-sdd/opsx-design/reference.md +44 -0
  34. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +1 -1
  35. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +52 -96
  36. package/templates/skills/kld-sdd/opsx-propose/checklist.md +44 -0
  37. package/templates/skills/kld-sdd/opsx-propose/reference.md +94 -0
  38. package/templates/skills/kld-sdd/opsx-rules/SKILL.md +131 -0
  39. package/templates/skills/kld-sdd/opsx-rules/checklist.md +27 -0
  40. package/templates/skills/kld-sdd/opsx-rules/reference.md +124 -0
  41. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +47 -51
  42. package/templates/skills/kld-sdd/opsx-spec/checklist.md +46 -0
  43. package/templates/skills/kld-sdd/opsx-spec/reference.md +49 -0
  44. package/templates/skills/kld-sdd/opsx-task/SKILL.md +43 -46
  45. package/templates/skills/kld-sdd/opsx-task/checklist.md +46 -0
  46. package/templates/skills/kld-sdd/opsx-task/reference.md +40 -0
  47. package/templates/skills/kld-sdd/opsx-test/SKILL.md +13 -1
@@ -0,0 +1,395 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * SDD Apply Test Gate Hook
4
+ *
5
+ * 当 proposal 选择单元测试策略(test-strategy: tdd | impl-first)时,
6
+ * 在 apply 收尾前校验是否已有真实测试执行证据;缺失则阻断并提示模型执行测试。
7
+ *
8
+ * 触发点:
9
+ * - PreToolUse(Bash): apply-worktree-finish(非 --record-base)、log.cjs end(apply 阶段)
10
+ * - Stop: 存在活跃 apply 阶段且即将结束会话
11
+ *
12
+ * 退出码:0 放行 | 2 阻断(stdout JSON decision:block)
13
+ */
14
+ 'use strict';
15
+
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const { execFileSync } = require('child_process');
19
+ const core = require('./hook-gate-core.cjs');
20
+
21
+ const {
22
+ readStdin,
23
+ parseHookInput,
24
+ normalizeHookInput,
25
+ getProjectRoot,
26
+ safeChangeName,
27
+ findActiveApplyStage,
28
+ blockWithReason,
29
+ } = core;
30
+
31
+ function findProposalPath(projectRoot, changeName) {
32
+ const candidates = [
33
+ path.join(projectRoot, 'openspec', 'changes', changeName, 'proposal.md'),
34
+ path.join(projectRoot, 'changes', changeName, 'proposal.md'),
35
+ ];
36
+ for (const p of candidates) {
37
+ if (fs.existsSync(p)) return p;
38
+ }
39
+ const safe = safeChangeName(changeName);
40
+ if (safe && safe !== changeName) {
41
+ return findProposalPath(projectRoot, safe);
42
+ }
43
+ return null;
44
+ }
45
+
46
+ function readTestStrategy(projectRoot, changeName) {
47
+ const proposalPath = findProposalPath(projectRoot, changeName);
48
+ if (!proposalPath) return null;
49
+ try {
50
+ const content = fs.readFileSync(proposalPath, 'utf8');
51
+ const fm = content.match(/^---\s*[\r\n]+([\s\S]*?)[\r\n]+---/);
52
+ if (!fm) return null;
53
+ const m = fm[1].match(/^\s*test-strategy:\s*["']?([a-z-]+)["']?\s*$/im);
54
+ return m ? m[1].trim() : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function requiresUnitTests(strategy) {
61
+ return strategy === 'tdd' || strategy === 'impl-first';
62
+ }
63
+
64
+ function isRealTestDetails(testResults) {
65
+ if (!testResults || typeof testResults !== 'object') return false;
66
+ const command = String(testResults.command || '').trim();
67
+ if (!command) return false;
68
+ const passed = Number(testResults.passed);
69
+ const failed = Number(testResults.failed);
70
+ const duration = Number(testResults.duration_ms);
71
+ if (Number.isFinite(passed) && passed > 0) return true;
72
+ if (Number.isFinite(failed) && failed > 0) return true;
73
+ if (Number.isFinite(duration) && duration > 0) return true;
74
+ return false;
75
+ }
76
+
77
+ function loadChangeEvents(projectRoot, changeName) {
78
+ const safe = safeChangeName(changeName);
79
+ const dir = path.join(projectRoot, 'skywalk-sdd', 'events', safe);
80
+ if (!fs.existsSync(dir)) return [];
81
+
82
+ const events = [];
83
+ for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))) {
84
+ try {
85
+ const lines = fs.readFileSync(path.join(dir, file), 'utf8').split('\n').filter(Boolean);
86
+ for (const line of lines) {
87
+ try {
88
+ events.push(JSON.parse(line));
89
+ } catch {
90
+ // skip
91
+ }
92
+ }
93
+ } catch {
94
+ // skip
95
+ }
96
+ }
97
+ return events;
98
+ }
99
+
100
+ function hasRealTestExecution(projectRoot, changeName, capability, sinceTimestamp) {
101
+ const since = sinceTimestamp ? new Date(sinceTimestamp).getTime() : 0;
102
+ const events = loadChangeEvents(projectRoot, changeName);
103
+
104
+ for (const event of events) {
105
+ const ts = new Date(event.timestamp || 0).getTime();
106
+ if (since && ts < since) continue;
107
+
108
+ if (event.type === 'stage_end' && event.command === 'test' &&
109
+ (event.result === 'success' || event.result === 'failure')) {
110
+ return { kind: 'opsx-test', event };
111
+ }
112
+
113
+ if (event.type === 'test_result') {
114
+ const tr = event.details && event.details.test_results;
115
+ if (isRealTestDetails(tr)) {
116
+ return { kind: 'test_result', event };
117
+ }
118
+ }
119
+
120
+ if (event.type === 'task_update' && (event.command === 'apply' || !event.command)) {
121
+ const tr = event.details && event.details.test_results;
122
+ if (isRealTestDetails(tr)) {
123
+ if (!capability || !event.capability || event.capability === capability) {
124
+ return { kind: 'task_update', event };
125
+ }
126
+ }
127
+ }
128
+ }
129
+ return null;
130
+ }
131
+
132
+ function isApplyCompletionBash(command) {
133
+ const cmd = String(command || '');
134
+ if (/apply-worktree-finish\.cjs/.test(cmd) && !/--record-base/.test(cmd)) {
135
+ return true;
136
+ }
137
+ if (/(?:skywalk-sdd\/)?log\.cjs\s+end\b/.test(cmd)) {
138
+ return true;
139
+ }
140
+ return false;
141
+ }
142
+
143
+ function block(decision, reason, extra) {
144
+ blockWithReason(reason, { decision, ...extra });
145
+ }
146
+
147
+ function recordWarning(projectRoot, applyEvent, code, message, taskIds) {
148
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.cjs');
149
+ if (!fs.existsSync(logPath)) {
150
+ // Y8 修复:log.cjs 缺失时 stderr 提示(本 repo 无 log.cjs 属预期——脚手架部署时 deployTelemetryDataDir 复制 index.cjs→log.cjs)
151
+ try { console.error(`[sdd-apply-test-gate] log.cjs 不存在(${logPath}),telemetry_warning 未记录;确认 deployTelemetryDataDir 已执行`); } catch {}
152
+ return;
153
+ }
154
+ try {
155
+ // T1.5b(v3): task_update_reuse 写入复用 task_ids,供 E4(A3) 扣分识别
156
+ const details = { warning: code, test_strategy: applyEvent.test_strategy };
157
+ if (Array.isArray(taskIds) && taskIds.length > 0) details.task_ids = taskIds;
158
+ const args = [
159
+ logPath,
160
+ 'record',
161
+ '--type=telemetry_warning',
162
+ '--command=apply',
163
+ `--project=${projectRoot}`,
164
+ `--change=${applyEvent.change || 'general'}`,
165
+ '--agent=codebuddy',
166
+ '--source=codebuddy-hook',
167
+ '--result=partial',
168
+ `--summary=${message}`,
169
+ `--details-json=${JSON.stringify(details)}`,
170
+ ];
171
+ if (applyEvent.capability) args.push(`--capability=${applyEvent.capability}`);
172
+ if (applyEvent.session_id) args.push(`--session-id=${applyEvent.session_id}`);
173
+ execFileSync('node', args, { cwd: projectRoot, stdio: 'ignore' });
174
+ } catch {
175
+ // ignore
176
+ }
177
+ }
178
+
179
+ function buildReason(strategy, changeName, capability) {
180
+ const cap = capability ? ` / ${capability}` : '';
181
+ const strict = strategy === 'tdd';
182
+ const lines = [
183
+ `[SDD Apply Test Gate] 变更 ${changeName}${cap} 的 test-strategy=${strategy},但尚未检测到真实单元测试执行记录。`,
184
+ '',
185
+ '请先在本项目根目录(或 apply worktree)执行单元测试,例如:',
186
+ ' npm test / pnpm test / pytest / go test ./... / cargo test',
187
+ '',
188
+ '执行后应产生以下任一 telemetry 证据:',
189
+ ' - skywalk-sdd 事件 test_result(含实际 command 与 passed/failed/duration)',
190
+ ' - task_update 中 test_results 非空占位',
191
+ ' - 或运行 /opsx-test 完成 test 阶段',
192
+ '',
193
+ strict
194
+ ? 'tdd 策略:测试未执行前不得结束 apply 或执行 apply-worktree-finish。'
195
+ : 'impl-first 策略:实现后须补跑测试并确认通过,再结束 apply。',
196
+ '',
197
+ '完成后可重试当前操作。',
198
+ ];
199
+ return lines.join('\n');
200
+ }
201
+
202
+ function shouldGate(projectRoot, applyEvent) {
203
+ const strategy = readTestStrategy(projectRoot, applyEvent.change);
204
+ if (!requiresUnitTests(strategy)) {
205
+ return null;
206
+ }
207
+ const evidence = hasRealTestExecution(
208
+ projectRoot,
209
+ applyEvent.change,
210
+ applyEvent.capability,
211
+ applyEvent.timestamp
212
+ );
213
+ if (evidence) {
214
+ return null;
215
+ }
216
+ return { strategy, changeName: applyEvent.change, capability: applyEvent.capability };
217
+ }
218
+
219
+ // ── T1.1: task_update 复用检测(不阻断,仅记 warning) ──
220
+ /**
221
+ * 检测同一 change 的 implementation task_update 是否复用同一次测试结果。
222
+ * 判定:同 session_id + 同 test_results 三元组(passed+failed+duration_ms) 的 group 内 >1 条(不再要求时间戳相邻)。
223
+ * 测试确实跑过,只是 task_update 记录不实 → 记 telemetry_warning(task_update_reuse),不阻断。
224
+ * 返回 { reused, groups: [{ duration_ms, fingerprint, session_id, task_ids }] }
225
+ */
226
+ function detectReusedTestResults(events) {
227
+ const taskUpdates = (events || []).filter((e) =>
228
+ e && e.type === 'task_update' &&
229
+ e.details && e.details.test_results &&
230
+ isRealTestDetails(e.details.test_results)
231
+ );
232
+ const bySession = new Map();
233
+ for (const e of taskUpdates) {
234
+ const sid = e.session_id || '_no_session_';
235
+ if (!bySession.has(sid)) bySession.set(sid, []);
236
+ bySession.get(sid).push(e);
237
+ }
238
+ const groups = [];
239
+ let reused = false;
240
+ for (const [, group] of bySession) {
241
+ const byFingerprint = new Map();
242
+ for (const e of group) {
243
+ const tr = e.details.test_results;
244
+ const passed = Number(tr.passed);
245
+ const failed = Number(tr.failed);
246
+ const dur = Number(tr.duration_ms);
247
+ if (!Number.isFinite(dur)) continue;
248
+ const fp = `${passed}|${failed}|${dur}`;
249
+ if (!byFingerprint.has(fp)) byFingerprint.set(fp, []);
250
+ byFingerprint.get(fp).push(e);
251
+ }
252
+ for (const [fp, evs] of byFingerprint) {
253
+ if (evs.length < 2) continue;
254
+ const sorted = evs.slice().sort((a, b) => new Date(a.timestamp || 0) - new Date(b.timestamp || 0));
255
+ // T1.5(v3): 去掉 <5s 相邻限制 — 同 session + 同三元组即复用,无论时间间隔
256
+ // todo-cli 真实场景:实现任务与验证任务间隔 15-17s,<5s 阈值漏检 3/4 组
257
+ reused = true;
258
+ const first = sorted[0];
259
+ groups.push({
260
+ duration_ms: Number(first.details.test_results.duration_ms),
261
+ fingerprint: fp,
262
+ session_id: first.session_id || null,
263
+ task_ids: sorted.map((e) => e.task_id || e.event_id).filter(Boolean),
264
+ });
265
+ }
266
+ }
267
+ return { reused, groups };
268
+ }
269
+
270
+ /** T1.6: 检测本阶段是否有失败/门禁拦截但无 process_note 过程记录。
271
+ * Y4 修复:when 参数过滤时间窗口(仅检测 sinceTimestamp 后的事件),避免 change 级误报。
272
+ * 返回 { gap, hasFailure, hasGateBlock, hasProcessNote } */
273
+ function detectProcessNoteGap(events, sinceTimestamp) {
274
+ const evs = events || [];
275
+ const filtered = sinceTimestamp ? evs.filter(e => new Date(e.timestamp || 0).getTime() >= new Date(sinceTimestamp).getTime()) : evs;
276
+ const stageEnds = filtered.filter((e) => e.type === 'stage_end');
277
+ const hasFailure = stageEnds.some((e) => e.result === 'failure' || e.result === 'partial');
278
+ const hasGateBlock = filtered.some((e) =>
279
+ e.type === 'telemetry_warning' && e.details && e.details.warning === 'apply_test_missing'
280
+ );
281
+ const hasProcessNote = filtered.some((e) => e.type === 'process_note');
282
+ const gap = (hasFailure || hasGateBlock) && !hasProcessNote;
283
+ return { gap, hasFailure, hasGateBlock, hasProcessNote };
284
+ }
285
+
286
+ /** B2(v3): 检测 tdd 策略下 apply 阶段是否缺失"红灯"(失败的测试结果)。
287
+ * TDD 节奏要求测试骨架先 RED(failed>0)再实现转 GREEN;若 apply 阶段有真实测试信号
288
+ * 但全部 failed===0,说明骨架可能未经 RED 验证即标记完成 → 记 telemetry_warning(tdd_red_missing),不阻断。
289
+ * 不复用 isRealTestDetails(其要求 command 非空,会漏掉无 command 的骨架 test_results)。
290
+ * 返回 { missing, hasRed, signalCount } */
291
+ function detectMissingTddRed(events, sinceTimestamp) {
292
+ const evs = events || [];
293
+ const filtered = sinceTimestamp ? evs.filter(e => new Date(e.timestamp || 0).getTime() >= new Date(sinceTimestamp).getTime()) : evs;
294
+ let signalCount = 0;
295
+ let hasRed = false;
296
+ for (const e of filtered) {
297
+ const tr = e && e.details && (e.details.test_results || e.details.test);
298
+ if (!tr || typeof tr !== 'object') continue;
299
+ const passed = Number(tr.passed);
300
+ const failed = Number(tr.failed);
301
+ const dur = Number(tr.duration_ms);
302
+ const isReal = (Number.isFinite(passed) && passed > 0)
303
+ || (Number.isFinite(failed) && failed > 0)
304
+ || (Number.isFinite(dur) && dur > 0);
305
+ if (!isReal) continue;
306
+ signalCount++;
307
+ if (Number.isFinite(failed) && failed > 0) hasRed = true;
308
+ }
309
+ if (signalCount === 0) return { missing: false, hasRed: false, signalCount: 0, reason: 'no-real-test-signals' };
310
+ return { missing: !hasRed, hasRed, signalCount };
311
+ }
312
+
313
+ /** 是否已存在某 code 的 telemetry_warning(去重,避免每次 hook 触发都重复记录) */
314
+ function hasExistingWarning(events, code) {
315
+ return (events || []).some((e) =>
316
+ e.type === 'telemetry_warning' && e.details && e.details.warning === code
317
+ );
318
+ }
319
+
320
+ // ── 主逻辑(仅直接执行时运行;被 require 时跳过,便于单元测试) ──
321
+ if (require.main === module) {
322
+ const parsed = parseHookInput(readStdin(), { strict: true });
323
+ if (!parsed.ok) {
324
+ blockWithReason(parsed.error);
325
+ }
326
+ const input = normalizeHookInput(parsed.input);
327
+ const projectRoot = getProjectRoot(input, 'codebuddy');
328
+ const toolName = String(input.tool_name || input.toolName || '');
329
+ const toolInput = input.tool_input || input.toolInput || {};
330
+ const command = String(toolInput.command || '');
331
+
332
+ const activeApply = findActiveApplyStage(projectRoot);
333
+ if (!activeApply) {
334
+ process.exit(0);
335
+ }
336
+
337
+ const bashGate = toolName === 'Bash' && isApplyCompletionBash(command);
338
+ const stopGate = !toolName || toolName === 'Stop';
339
+
340
+ if (!bashGate && !stopGate) {
341
+ process.exit(0);
342
+ }
343
+
344
+ // 非阻断警告检测(T1.1 task_update 复用 / T1.6 process_note 缺失):放行但记录 warning
345
+ try {
346
+ const warningEvents = loadChangeEvents(projectRoot, activeApply.change);
347
+ const warningCtx = { ...activeApply, test_strategy: readTestStrategy(projectRoot, activeApply.change) };
348
+ const reuse = detectReusedTestResults(warningEvents);
349
+ if (reuse.reused && !hasExistingWarning(warningEvents, 'task_update_reuse')) {
350
+ const taskCount = reuse.groups.reduce((s, g) => s + g.task_ids.length, 0);
351
+ const reusedTaskIds = reuse.groups.flatMap((g) => g.task_ids).filter(Boolean);
352
+ recordWarning(projectRoot, warningCtx, 'task_update_reuse',
353
+ `检测到 ${reuse.groups.length} 组 task_update 复用同一次测试结果(共 ${taskCount} 条),测试确实跑过但记录不实`,
354
+ reusedTaskIds);
355
+ }
356
+ const noteGap = detectProcessNoteGap(warningEvents, activeApply.timestamp);
357
+ if (noteGap.gap && !hasExistingWarning(warningEvents, 'process_note_missing')) {
358
+ recordWarning(projectRoot, warningCtx, 'process_note_missing',
359
+ '本阶段存在失败/门禁拦截但无 process_note 过程记录,建议补记');
360
+ }
361
+ // B2(v3): tdd 策略下 apply 阶段无红灯(失败 test_result)→ 软警告,不阻断
362
+ if (warningCtx.test_strategy === 'tdd') {
363
+ const redGap = detectMissingTddRed(warningEvents, activeApply.timestamp);
364
+ if (redGap.missing && !hasExistingWarning(warningEvents, 'tdd_red_missing')) {
365
+ recordWarning(projectRoot, warningCtx, 'tdd_red_missing',
366
+ 'tdd 策略下 apply 阶段未检测到失败的测试结果(红灯),测试骨架可能未经 RED 验证即标记完成');
367
+ }
368
+ }
369
+ } catch {
370
+ // 警告检测失败不阻断主流程
371
+ }
372
+
373
+ const gate = shouldGate(projectRoot, activeApply);
374
+ if (!gate) {
375
+ process.exit(0);
376
+ }
377
+
378
+ const reason = buildReason(gate.strategy, gate.changeName, gate.capability);
379
+ recordWarning(projectRoot, { ...activeApply, test_strategy: gate.strategy }, 'apply_test_missing', 'Apply test gate blocked: no unit test evidence');
380
+
381
+ block('block', reason, {
382
+ test_strategy: gate.strategy,
383
+ change: gate.changeName,
384
+ capability: gate.capability,
385
+ });
386
+ }
387
+
388
+ module.exports = {
389
+ detectReusedTestResults,
390
+ detectProcessNoteGap,
391
+ detectMissingTddRed,
392
+ hasExistingWarning,
393
+ isRealTestDetails,
394
+ requiresUnitTests,
395
+ };
@@ -0,0 +1,123 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const core = require('./hook-gate-core.cjs');
8
+
9
+ function latestActiveStage(projectRoot) {
10
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
11
+ if (!fs.existsSync(stateDir)) return null;
12
+ return fs.readdirSync(stateDir)
13
+ .filter((file) => file.endsWith('.json'))
14
+ .map((file) => {
15
+ try {
16
+ const data = JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8'));
17
+ return data.event || null;
18
+ } catch {
19
+ return null;
20
+ }
21
+ })
22
+ .filter(Boolean)
23
+ .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime())[0] || null;
24
+ }
25
+
26
+ function inferResult(input) {
27
+ const response = input.tool_response || input.toolResponse || {};
28
+ if (typeof response.exit_code === 'number') {
29
+ return { result: response.exit_code === 0 ? 'success' : 'failure', exit_code_known: true };
30
+ }
31
+ if (typeof response.exitCode === 'number') {
32
+ return { result: response.exitCode === 0 ? 'success' : 'failure', exit_code_known: true };
33
+ }
34
+ if (typeof response.success === 'boolean') {
35
+ return { result: response.success ? 'success' : 'failure', exit_code_known: true };
36
+ }
37
+ const out = String(response.stdout || response.stderr || '');
38
+ if (/BUILD FAILED|error TS\d+|ERR_/i.test(out)) {
39
+ return { result: 'failure', exit_code_known: false };
40
+ }
41
+ return { result: 'partial', exit_code_known: false };
42
+ }
43
+
44
+ function inferRecord(command) {
45
+ if (/\b(log\.cjs|log\.js)\s+record\b/.test(command)) return null;
46
+ 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)) {
47
+ return {
48
+ type: 'test_result',
49
+ detailsKey: 'test_results',
50
+ details: { command, passed: 0, failed: 0, skipped: 0, coverage: null, duration_ms: null },
51
+ };
52
+ }
53
+ 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)) {
54
+ return {
55
+ type: 'build_result',
56
+ detailsKey: 'build_results',
57
+ details: { command, success: null, duration_ms: null, error_count: null },
58
+ };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function recordTelemetry(projectRoot, activeStage, record, result, exitCodeKnown, input, runner) {
64
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.cjs');
65
+ const legacyLogPath = path.join(projectRoot, 'skywalk-sdd', 'log.js');
66
+ const logCli = fs.existsSync(logPath) ? logPath : legacyLogPath;
67
+ if (!fs.existsSync(logCli)) return;
68
+
69
+ const toolInput = (input && (input.tool_input || input.toolInput)) || {};
70
+ const toolResponse = (input && (input.tool_response || input.toolResponse)) || {};
71
+ const details = { [record.detailsKey]: { ...record.details } };
72
+ if (record.type === 'build_result') {
73
+ details.build_results.success = result === 'success';
74
+ details.build_results.error_count = result === 'success' ? 0 : null;
75
+ details.build_results.exit_code_known = Boolean(exitCodeKnown);
76
+ const durationMs = typeof toolResponse.duration_ms === 'number' ? toolResponse.duration_ms : null;
77
+ details.build_results.duration_ms = durationMs;
78
+ details.build_results.duration_known = durationMs !== null;
79
+ }
80
+
81
+ const args = [
82
+ logCli,
83
+ 'record',
84
+ `--type=${record.type}`,
85
+ `--command=${toolInput.command || activeStage.command || activeStage.stage || 'unknown'}`,
86
+ `--project=${projectRoot}`,
87
+ `--change=${activeStage.change || 'general'}`,
88
+ `--agent=${activeStage.agent_type || 'codebuddy'}`,
89
+ '--source=codebuddy-hook',
90
+ `--result=${result}`,
91
+ `--summary=CodeBuddy hook captured ${record.type}`,
92
+ `--details-json=${JSON.stringify(details)}`,
93
+ ];
94
+ if (activeStage.capability) args.push(`--capability=${activeStage.capability}`);
95
+ if (activeStage.task_id) args.push(`--task-id=${activeStage.task_id}`);
96
+ if (activeStage.session_id) args.push(`--session-id=${activeStage.session_id}`);
97
+ const run = runner || ((a) => execFileSync('node', a, { cwd: projectRoot, stdio: 'ignore' }));
98
+ run(args);
99
+ }
100
+
101
+ if (require.main === module) {
102
+ const parsed = core.parseHookInput(core.readStdin(), { strict: false });
103
+ const input = core.normalizeHookInput(parsed.input || {});
104
+ const toolInput = input.tool_input || {};
105
+ const command = String(toolInput.command || input.command || '');
106
+ const record = inferRecord(command);
107
+ if (!record) process.exit(0);
108
+
109
+ const projectRoot = core.getProjectRoot(input, 'codebuddy');
110
+ const activeStage = latestActiveStage(projectRoot);
111
+ if (!activeStage || !activeStage.change || activeStage.change === 'general') {
112
+ process.exit(0);
113
+ }
114
+
115
+ try {
116
+ const inferred = inferResult(input);
117
+ recordTelemetry(projectRoot, activeStage, record, inferred.result, inferred.exit_code_known, input);
118
+ } catch {
119
+ process.exit(0);
120
+ }
121
+ }
122
+
123
+ module.exports = { inferResult, inferRecord, recordTelemetry, latestActiveStage };
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const core = require('./hook-gate-core.cjs');
5
+
6
+ const parsed = core.parseHookInput(core.readStdin(), { strict: true });
7
+ if (!parsed.ok) {
8
+ core.blockWithReason(parsed.error);
9
+ }
10
+
11
+ const input = core.normalizeHookInput(parsed.input);
12
+ const result = core.evaluatePreToolDangerGate(input);
13
+ if (result.action === 'block') {
14
+ core.blockWithReason(result.reason);
15
+ }
16
+ core.allowExit();
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const core = require('./hook-gate-core.cjs');
7
+
8
+ const parsed = core.parseHookInput(core.readStdin(), { strict: false });
9
+ const input = core.normalizeHookInput(parsed.input || {});
10
+
11
+ const prompt = input.prompt || input.message || input.user_prompt || '';
12
+ if (!/\/opsx[:|-]/.test(prompt)) {
13
+ process.exit(0);
14
+ }
15
+
16
+ function readActiveStages(projectRoot) {
17
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
18
+ if (!fs.existsSync(stateDir)) return [];
19
+ return fs.readdirSync(stateDir)
20
+ .filter((file) => file.endsWith('.json'))
21
+ .map((file) => {
22
+ try {
23
+ return JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')).event;
24
+ } catch {
25
+ return null;
26
+ }
27
+ })
28
+ .filter(Boolean);
29
+ }
30
+
31
+ const projectRoot = core.getProjectRoot(input, 'codebuddy');
32
+ const activeStages = readActiveStages(projectRoot)
33
+ .map((event) => `${event.change || 'general'}:${event.command || event.stage || 'unknown'}`)
34
+ .join(', ');
35
+
36
+ const lines = [
37
+ 'SDD Telemetry reminder:',
38
+ '- Run skywalk-sdd/log.cjs start before the OPSX stage work begins.',
39
+ '- Run skywalk-sdd/log.cjs end before stopping the stage.',
40
+ '- Hooks are only an enhancement; OPSX skill instructions remain authoritative.',
41
+ ];
42
+
43
+ if (activeStages) {
44
+ lines.push(`- Active stage state: ${activeStages}`);
45
+ }
46
+
47
+ console.log(lines.join('\n'));
48
+ process.exit(0);
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const core = require('./hook-gate-core.cjs');
5
+
6
+ const parsed = core.parseHookInput(core.readStdin(), { strict: true });
7
+ if (!parsed.ok) {
8
+ core.blockWithReason(parsed.error);
9
+ }
10
+
11
+ const input = core.normalizeHookInput(parsed.input);
12
+ const result = core.evaluateSkillApplyGate(input, 'codebuddy');
13
+ if (result.action === 'block') {
14
+ core.blockWithReason(result.reason);
15
+ }
16
+ core.allowExit(result.reason ? { reason: result.reason } : undefined);
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const path = require('path');
6
+ const { execFileSync } = require('child_process');
7
+ const core = require('./hook-gate-core.cjs');
8
+
9
+ const parsed = core.parseHookInput(core.readStdin(), { strict: false });
10
+ const input = core.normalizeHookInput(parsed.input || {});
11
+
12
+ function readActiveStages(projectRoot) {
13
+ const stateDir = path.join(projectRoot, 'skywalk-sdd', 'state');
14
+ if (!fs.existsSync(stateDir)) return [];
15
+ return fs.readdirSync(stateDir)
16
+ .filter((file) => file.endsWith('.json'))
17
+ .map((file) => {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(path.join(stateDir, file), 'utf8')).event;
20
+ } catch {
21
+ return null;
22
+ }
23
+ })
24
+ .filter(Boolean);
25
+ }
26
+
27
+ function writeWarning(projectRoot, event) {
28
+ const logPath = path.join(projectRoot, 'skywalk-sdd', 'log.cjs');
29
+ const legacyLogPath = path.join(projectRoot, 'skywalk-sdd', 'log.js');
30
+ const logCli = fs.existsSync(logPath) ? logPath : legacyLogPath;
31
+ if (!fs.existsSync(logCli)) return;
32
+
33
+ const args = [
34
+ logCli,
35
+ 'record',
36
+ '--type=telemetry_warning',
37
+ `--command=${event.command || event.stage || 'unknown'}`,
38
+ `--project=${projectRoot}`,
39
+ `--change=${event.change || 'general'}`,
40
+ `--agent=${event.agent_type || 'codebuddy'}`,
41
+ '--source=codebuddy-hook',
42
+ '--result=partial',
43
+ '--summary=CodeBuddy hook detected an open SDD stage at stop',
44
+ `--details-json=${JSON.stringify({ warning: 'open_stage_at_stop', event_id: event.event_id })}`,
45
+ ];
46
+ if (event.capability) args.push(`--capability=${event.capability}`);
47
+ if (event.session_id) args.push(`--session-id=${event.session_id}`);
48
+ execFileSync('node', args, { cwd: projectRoot, stdio: 'ignore' });
49
+ }
50
+
51
+ const projectRoot = core.getProjectRoot(input, 'codebuddy');
52
+ const activeStages = readActiveStages(projectRoot);
53
+ if (activeStages.length === 0) {
54
+ process.exit(0);
55
+ }
56
+
57
+ for (const event of activeStages) {
58
+ try {
59
+ writeWarning(projectRoot, event);
60
+ } catch {
61
+ // ignore
62
+ }
63
+ }
64
+
65
+ console.log([
66
+ 'SDD Telemetry warning: open stage(s) detected.',
67
+ ...activeStages.map((event) => `- ${event.change || 'general'}:${event.command || event.stage || 'unknown'} event_id=${event.event_id}`),
68
+ 'Run skywalk-sdd/log.cjs end before closing the OPSX stage, or explicitly mark it partial/failure.',
69
+ ].join('\n'));
70
+ process.exit(0);