xtrm-cli 2.1.4 → 2.1.11

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.
@@ -20,11 +20,13 @@ function runHook(
20
20
  hookFile: string,
21
21
  input: Record<string, unknown>,
22
22
  env: Record<string, string> = {},
23
+ cwd?: string,
23
24
  ) {
24
25
  return spawnSync('node', [path.join(HOOKS_DIR, hookFile)], {
25
26
  input: JSON.stringify(input),
26
27
  encoding: 'utf8',
27
28
  env: { ...process.env, ...env },
29
+ cwd,
28
30
  });
29
31
  }
30
32
 
@@ -48,16 +50,13 @@ function withFakeBdDir(scriptBody: string) {
48
50
 
49
51
  describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
50
52
  it('blocks Write when current branch is listed in MAIN_GUARD_PROTECTED_BRANCHES', () => {
51
- // Set env var to the actual current branch — without this env var the
52
- // hook only checks hardcoded main/master, so a feature branch always exits 0.
53
53
  const r = runHook(
54
54
  'main-guard.mjs',
55
55
  { tool_name: 'Write', tool_input: { file_path: '/tmp/x' } },
56
56
  { MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
57
57
  );
58
- expect(r.status).toBe(0);
58
+ expect(r.status).toBe(2);
59
59
  const out = parseHookJson(r.stdout);
60
- expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
61
60
  expect(out?.systemMessage).toContain(CURRENT_BRANCH);
62
61
  });
63
62
 
@@ -76,9 +75,8 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
76
75
  { tool_name: 'Bash', tool_input: { command: 'cat > file.txt << EOF\nhello\nEOF' } },
77
76
  { MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
78
77
  );
79
- expect(r.status).toBe(0);
78
+ expect(r.status).toBe(2);
80
79
  const out = parseHookJson(r.stdout);
81
- expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
82
80
  expect(out?.systemMessage).toContain('Bash is restricted');
83
81
  });
84
82
 
@@ -116,9 +114,8 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
116
114
  { tool_name: 'Bash', tool_input: { command } },
117
115
  { MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
118
116
  );
119
- expect(r.status, `expected structured deny for: ${command}`).toBe(0);
117
+ expect(r.status, `expected exit 2 for: ${command}`).toBe(2);
120
118
  const out = parseHookJson(r.stdout);
121
- expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
122
119
  expect(out?.systemMessage).toContain('Bash is restricted');
123
120
  }
124
121
  });
@@ -138,13 +135,102 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
138
135
  { tool_name: 'Bash', tool_input: { command: 'git commit -m "oops"' } },
139
136
  { MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
140
137
  );
141
- expect(r.status).toBe(0);
138
+ expect(r.status).toBe(2);
142
139
  const out = parseHookJson(r.stdout);
143
- expect(out?.hookSpecificOutput?.permissionDecision).toBe('deny');
144
140
  expect(out?.systemMessage).toContain('feature branch');
145
141
  });
146
142
  });
147
143
 
144
+ // ── main-guard-post-push.mjs ────────────────────────────────────────────────
145
+
146
+ describe('main-guard-post-push.mjs', () => {
147
+ function createTempGitRepo(branch: string): string {
148
+ const repoDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-post-push-'));
149
+ spawnSync('git', ['init'], { cwd: repoDir, stdio: 'pipe' });
150
+ spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, stdio: 'pipe' });
151
+ spawnSync('git', ['config', 'user.name', 'Test User'], { cwd: repoDir, stdio: 'pipe' });
152
+ writeFileSync(path.join(repoDir, 'README.md'), '# test\n', 'utf8');
153
+ spawnSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'pipe' });
154
+ spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, stdio: 'pipe' });
155
+ const current = spawnSync('git', ['branch', '--show-current'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).stdout.trim();
156
+ if (current !== branch) {
157
+ spawnSync('git', ['checkout', '-B', branch], { cwd: repoDir, stdio: 'pipe' });
158
+ }
159
+ return repoDir;
160
+ }
161
+
162
+ it('injects PR workflow reminder after successful feature-branch push command', () => {
163
+ const repoDir = createTempGitRepo('feature/test-push');
164
+ try {
165
+ const r = runHook(
166
+ 'main-guard-post-push.mjs',
167
+ { tool_name: 'Bash', tool_input: { command: 'git push -u origin feature/test-push' }, cwd: repoDir },
168
+ { MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
169
+ repoDir,
170
+ );
171
+ expect(r.status).toBe(0);
172
+ const out = parseHookJson(r.stdout);
173
+ expect(out?.systemMessage).toContain('gh pr create --fill');
174
+ expect(out?.systemMessage).toContain('gh pr merge --squash');
175
+ } finally {
176
+ rmSync(repoDir, { recursive: true, force: true });
177
+ }
178
+ });
179
+
180
+ it('does not emit reminder for non-push Bash commands', () => {
181
+ const repoDir = createTempGitRepo('feature/test-nopush');
182
+ try {
183
+ const r = runHook(
184
+ 'main-guard-post-push.mjs',
185
+ { tool_name: 'Bash', tool_input: { command: 'git status' }, cwd: repoDir },
186
+ { MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
187
+ repoDir,
188
+ );
189
+ expect(r.status).toBe(0);
190
+ expect(r.stdout.trim()).toBe('');
191
+ } finally {
192
+ rmSync(repoDir, { recursive: true, force: true });
193
+ }
194
+ });
195
+
196
+ it('does not emit reminder when current branch is protected', () => {
197
+ const repoDir = createTempGitRepo('main');
198
+ try {
199
+ const r = runHook(
200
+ 'main-guard-post-push.mjs',
201
+ { tool_name: 'Bash', tool_input: { command: 'git push -u origin main' }, cwd: repoDir },
202
+ { MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
203
+ repoDir,
204
+ );
205
+ expect(r.status).toBe(0);
206
+ expect(r.stdout.trim()).toBe('');
207
+ } finally {
208
+ rmSync(repoDir, { recursive: true, force: true });
209
+ }
210
+ });
211
+
212
+ it('does not emit reminder when push command reports failure', () => {
213
+ const repoDir = createTempGitRepo('feature/test-failed-push');
214
+ try {
215
+ const r = runHook(
216
+ 'main-guard-post-push.mjs',
217
+ {
218
+ tool_name: 'Bash',
219
+ tool_input: { command: 'git push -u origin feature/test-failed-push' },
220
+ tool_response: { exit_code: 1, stderr: 'remote rejected' },
221
+ cwd: repoDir,
222
+ },
223
+ { MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
224
+ repoDir,
225
+ );
226
+ expect(r.status).toBe(0);
227
+ expect(r.stdout.trim()).toBe('');
228
+ } finally {
229
+ rmSync(repoDir, { recursive: true, force: true });
230
+ }
231
+ });
232
+ });
233
+
148
234
  // ── beads-gate-utils.mjs ─────────────────────────────────────────────────────
149
235
 
150
236
  describe('beads-gate-utils.mjs — module integrity', () => {
@@ -236,42 +322,171 @@ describe('beads-stop-gate.mjs', () => {
236
322
  const content = readFileSync(path.join(HOOKS_DIR, 'beads-stop-gate.mjs'), 'utf8');
237
323
  expect(content).toContain("from './beads-gate-utils.mjs'");
238
324
  });
325
+
326
+ it('allows stop (exit 0) when session has a stale claim but no in_progress issues', () => {
327
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-stopgate-'));
328
+ mkdirSync(path.join(projectDir, '.beads'));
329
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
330
+ set -euo pipefail
331
+ if [[ "$1" == "kv" && "$2" == "get" ]]; then
332
+ echo "jaggers-stale-claim"
333
+ exit 0
334
+ fi
335
+ if [[ "$1" == "list" ]]; then
336
+ cat <<'EOF'
337
+
338
+ --------------------------------------------------------------------------------
339
+ Total: 0 issues (0 open, 0 in progress)
340
+ EOF
341
+ exit 0
342
+ fi
343
+ exit 1
344
+ `);
345
+
346
+ try {
347
+ const r = runHook(
348
+ 'beads-stop-gate.mjs',
349
+ { session_id: 'session-stale-claim', cwd: projectDir },
350
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
351
+ );
352
+ expect(r.status).toBe(0);
353
+ } finally {
354
+ rmSync(fake.tempDir, { recursive: true, force: true });
355
+ rmSync(projectDir, { recursive: true, force: true });
356
+ }
357
+ });
239
358
  });
240
359
 
241
- // ── beads-commit-gate.mjs ─────────────────────────────────────────────────────
242
360
 
243
- describe('beads-commit-gate.mjs', () => {
244
- it('fails open (exit 0) when no .beads directory exists', () => {
245
- const r = runHook('beads-commit-gate.mjs', {
246
- session_id: 'test',
247
- tool_name: 'Bash',
248
- tool_input: { command: 'git commit -m test' },
249
- cwd: '/tmp',
250
- });
251
- expect(r.status).toBe(0);
361
+ // ── tdd-guard-pretool-bridge.cjs ─────────────────────────────────────────────
362
+
363
+ const TDD_BRIDGE_DIR = path.join(__dirname, '../../project-skills/tdd-guard/.claude/hooks');
364
+
365
+ describe('tdd-guard-pretool-bridge.cjs', () => {
366
+ it('does not forward tdd-guard stderr when stdout already contains the message', () => {
367
+ const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-'));
368
+ const fakeBin = path.join(fakeDir, 'tdd-guard');
369
+ // Simulate tdd-guard writing the same message to both stdout and stderr (the bug)
370
+ writeFileSync(fakeBin, `#!/usr/bin/env bash\nMSG='{"reason":"Premature implementation"}'\necho "$MSG"\necho "$MSG" >&2\nexit 2\n`, { encoding: 'utf8' });
371
+ chmodSync(fakeBin, 0o755);
372
+
373
+ try {
374
+ const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
375
+ input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.ts' } }),
376
+ encoding: 'utf8',
377
+ env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
378
+ });
379
+ expect(r.stderr).toBe('');
380
+ } finally {
381
+ rmSync(fakeDir, { recursive: true, force: true });
382
+ }
252
383
  });
253
384
 
254
- it('imports from beads-gate-utils.mjs', () => {
255
- const content = readFileSync(path.join(HOOKS_DIR, 'beads-commit-gate.mjs'), 'utf8');
256
- expect(content).toContain("from './beads-gate-utils.mjs'");
385
+ it('forces sdk validation client and strips api-mode env vars', () => {
386
+ const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-env-'));
387
+ const fakeBin = path.join(fakeDir, 'tdd-guard');
388
+ writeFileSync(
389
+ fakeBin,
390
+ `#!/usr/bin/env bash
391
+ if [[ "$VALIDATION_CLIENT" != "sdk" ]]; then exit 12; fi
392
+ if [[ -n "\${MODEL_TYPE:-}" ]]; then exit 13; fi
393
+ if [[ -n "\${TDD_GUARD_ANTHROPIC_API_KEY:-}" ]]; then exit 14; fi
394
+ if [[ -n "\${ANTHROPIC_API_KEY:-}" ]]; then exit 15; fi
395
+ if [[ -n "\${ANTHROPIC_BASE_URL:-}" ]]; then exit 16; fi
396
+ exit 0
397
+ `,
398
+ { encoding: 'utf8' },
399
+ );
400
+ chmodSync(fakeBin, 0o755);
401
+
402
+ try {
403
+ const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
404
+ input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
405
+ encoding: 'utf8',
406
+ env: {
407
+ ...process.env,
408
+ PATH: `${fakeDir}:${process.env.PATH ?? ''}`,
409
+ VALIDATION_CLIENT: 'api',
410
+ MODEL_TYPE: 'anthropic_api',
411
+ TDD_GUARD_ANTHROPIC_API_KEY: 'x',
412
+ ANTHROPIC_API_KEY: 'y',
413
+ ANTHROPIC_BASE_URL: 'https://example.invalid',
414
+ },
415
+ });
416
+ expect(r.status).toBe(0);
417
+ } finally {
418
+ rmSync(fakeDir, { recursive: true, force: true });
419
+ }
420
+ });
421
+
422
+ it('fails open for known API response JSON-parse errors', () => {
423
+ const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-apierr-'));
424
+ const fakeBin = path.join(fakeDir, 'tdd-guard');
425
+ writeFileSync(
426
+ fakeBin,
427
+ `#!/usr/bin/env bash
428
+ echo 'Error during validation: Unexpected token '\\''A'\\'', "API Error:"... is not valid JSON'
429
+ exit 2
430
+ `,
431
+ { encoding: 'utf8' },
432
+ );
433
+ chmodSync(fakeBin, 0o755);
434
+
435
+ try {
436
+ const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
437
+ input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
438
+ encoding: 'utf8',
439
+ env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
440
+ });
441
+ expect(r.status).toBe(0);
442
+ } finally {
443
+ rmSync(fakeDir, { recursive: true, force: true });
444
+ }
257
445
  });
258
446
  });
259
447
 
260
- // ── beads-close-memory-prompt.mjs ────────────────────────────────────────────
261
448
 
262
- describe('beads-close-memory-prompt.mjs', () => {
263
- it('exits 0 for non-bd-close bash commands', () => {
264
- const r = runHook('beads-close-memory-prompt.mjs', {
265
- session_id: 'test',
266
- tool_name: 'Bash',
267
- tool_input: { command: 'git status' },
268
- cwd: '/tmp',
449
+ // ── gitnexus-impact-reminder.py ──────────────────────────────────────────────
450
+
451
+ function runPythonHook(
452
+ hookFile: string,
453
+ input: Record<string, unknown>,
454
+ ) {
455
+ return spawnSync('python3', [path.join(HOOKS_DIR, hookFile)], {
456
+ input: JSON.stringify(input),
457
+ encoding: 'utf8',
458
+ env: { ...process.env },
459
+ });
460
+ }
461
+
462
+ describe('gitnexus-impact-reminder.py', () => {
463
+ it('injects additionalContext when prompt contains an edit-intent keyword', () => {
464
+ const r = runPythonHook('gitnexus-impact-reminder.py', {
465
+ hook_event_name: 'UserPromptSubmit',
466
+ prompt: 'fix the broken auth logic in login.ts',
269
467
  });
270
468
  expect(r.status).toBe(0);
469
+ const out = parseHookJson(r.stdout);
470
+ expect(out?.hookSpecificOutput?.additionalContext).toContain('gitnexus impact');
271
471
  });
272
472
 
273
- it('imports from beads-gate-utils.mjs', () => {
274
- const content = readFileSync(path.join(HOOKS_DIR, 'beads-close-memory-prompt.mjs'), 'utf8');
275
- expect(content).toContain("from './beads-gate-utils.mjs'");
473
+ it('does nothing (no output) when prompt has no edit-intent keywords', () => {
474
+ const r = runPythonHook('gitnexus-impact-reminder.py', {
475
+ hook_event_name: 'UserPromptSubmit',
476
+ prompt: 'explain how the beads gate works',
477
+ });
478
+ expect(r.status).toBe(0);
479
+ expect(r.stdout.trim()).toBe('');
480
+ });
481
+
482
+ it('does nothing for non-UserPromptSubmit events', () => {
483
+ const r = runPythonHook('gitnexus-impact-reminder.py', {
484
+ hook_event_name: 'PreToolUse',
485
+ tool_name: 'Edit',
486
+ tool_input: { file_path: 'foo.ts' },
487
+ prompt: 'fix something',
488
+ });
489
+ expect(r.status).toBe(0);
490
+ expect(r.stdout.trim()).toBe('');
276
491
  });
277
492
  });
@@ -0,0 +1,87 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { createInstallPiCommand } from '../src/commands/install-pi.js';
3
+
4
+ describe('createInstallPiCommand', () => {
5
+ it('exports a createInstallPiCommand function', () => {
6
+ expect(typeof createInstallPiCommand).toBe('function');
7
+ });
8
+
9
+ it('returns a Command named "pi"', () => {
10
+ const cmd = createInstallPiCommand();
11
+ expect((cmd as any).name()).toBe('pi');
12
+ });
13
+
14
+ it('fillTemplate replaces {{PLACEHOLDERS}} with values', async () => {
15
+ const { fillTemplate } = await import('../src/commands/install-pi.js');
16
+ expect(fillTemplate('{"k":"{{MY_KEY}}"}' , { MY_KEY: 'abc' })).toBe('{"k":"abc"}');
17
+ });
18
+
19
+ it('fillTemplate leaves missing placeholders empty', async () => {
20
+ const { fillTemplate } = await import('../src/commands/install-pi.js');
21
+ expect(fillTemplate('{"k":"{{MISSING}}"}', {})).toBe('{"k":""}');
22
+ });
23
+
24
+ it('models.json.template contains {{DASHSCOPE_API_KEY}}', () => {
25
+ const fs = require('node:fs');
26
+ const p = require('node:path');
27
+ const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'models.json.template'), 'utf8');
28
+ expect(content).toContain('{{DASHSCOPE_API_KEY}}');
29
+ });
30
+
31
+ it('auth.json.template contains {{DASHSCOPE_API_KEY}} and {{ZAI_API_KEY}}', () => {
32
+ const fs = require('node:fs');
33
+ const p = require('node:path');
34
+ const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'auth.json.template'), 'utf8');
35
+ expect(content).toContain('{{DASHSCOPE_API_KEY}}');
36
+ expect(content).toContain('{{ZAI_API_KEY}}');
37
+ });
38
+
39
+ it('auth.json.template contains no real API keys or tokens', () => {
40
+ const fs = require('node:fs');
41
+ const p = require('node:path');
42
+ const content = fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'auth.json.template'), 'utf8');
43
+ expect(content).not.toMatch(/sk-[a-zA-Z0-9]{20,}/);
44
+ expect(content).not.toMatch(/ya29\.[a-zA-Z0-9_-]{20,}/);
45
+ });
46
+
47
+ it('settings.json.template includes pi-serena-tools package', () => {
48
+ const fs = require('node:fs');
49
+ const p = require('node:path');
50
+ const settings = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'settings.json.template'), 'utf8'));
51
+ expect(settings.packages).toContain('npm:pi-serena-tools');
52
+ });
53
+
54
+ it('install-schema.json defines DASHSCOPE_API_KEY and ZAI_API_KEY fields', () => {
55
+ const fs = require('node:fs');
56
+ const p = require('node:path');
57
+ const schema = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'install-schema.json'), 'utf8'));
58
+ const keys = schema.fields.map((f) => f.key);
59
+ expect(keys).toContain('DASHSCOPE_API_KEY');
60
+ expect(keys).toContain('ZAI_API_KEY');
61
+ });
62
+
63
+ it('install-schema.json lists anthropic and qwen-cli as oauth_providers', () => {
64
+ const fs = require('node:fs');
65
+ const p = require('node:path');
66
+ const schema = JSON.parse(fs.readFileSync(p.resolve(__dirname, '..', '..', 'config', 'pi', 'install-schema.json'), 'utf8'));
67
+ const keys = schema.oauth_providers.map((o) => o.key);
68
+ expect(keys).toContain('anthropic');
69
+ expect(keys).toContain('qwen-cli');
70
+ });
71
+
72
+ it('extensions directory contains all expected .ts files', () => {
73
+ const fs = require('node:fs');
74
+ const p = require('node:path');
75
+ const extDir = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions');
76
+ const files = ['auto-session-name.ts','auto-update.ts','bg-process.ts','compact-header.ts','custom-footer.ts','git-checkpoint.ts','git-guard.ts','safe-guard.ts','todo.ts'];
77
+ for (const f of files) expect(fs.existsSync(p.join(extDir, f))).toBe(true);
78
+ });
79
+
80
+ it('custom-provider-qwen-cli extension has index.ts and package.json', () => {
81
+ const fs = require('node:fs');
82
+ const p = require('node:path');
83
+ const base = p.resolve(__dirname, '..', '..', 'config', 'pi', 'extensions', 'custom-provider-qwen-cli');
84
+ expect(fs.existsSync(p.join(base, 'index.ts'))).toBe(true);
85
+ expect(fs.existsSync(p.join(base, 'package.json'))).toBe(true);
86
+ });
87
+ });
@@ -15,13 +15,14 @@ import {
15
15
  } from '../src/commands/install-project.js';
16
16
 
17
17
  describe('buildProjectInitGuide', () => {
18
- it('includes recommended quality-gate skills and required config checks', () => {
18
+ it('includes complete onboarding guidance (quality gates, beads workflow, and git workflow)', () => {
19
19
  const guide = buildProjectInitGuide();
20
- expect(guide).toContain('ts-quality-gate');
21
- expect(guide).toContain('py-quality-gate');
20
+ expect(guide).toContain('quality-gates');
22
21
  expect(guide).toContain('tdd-guard');
23
- expect(guide.toLowerCase()).toContain('lint');
24
- expect(guide.toLowerCase()).toContain('mypy');
22
+ expect(guide).toContain('service-skills-set');
23
+ expect(guide.toLowerCase()).toContain('beads workflow');
24
+ expect(guide).toContain('bd ready --json');
25
+ expect(guide).toContain('gh pr create --fill');
25
26
  expect(guide.toLowerCase()).toContain('service-skills-set');
26
27
  });
27
28
  });
@@ -86,6 +87,34 @@ describe('deepMergeHooks', () => {
86
87
  expect(matcher).toContain('mcp__serena__insert_after_symbol');
87
88
  expect(matcher).toContain('mcp__serena__insert_before_symbol');
88
89
  });
90
+
91
+ it('upgrades matcher when command path differs but hook script is the same', () => {
92
+ const existing = {
93
+ hooks: {
94
+ PostToolUse: [{
95
+ matcher: 'Write|Edit|MultiEdit',
96
+ hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/hooks/quality-check.py"' }],
97
+ }],
98
+ },
99
+ };
100
+
101
+ const incoming = {
102
+ hooks: {
103
+ PostToolUse: [{
104
+ matcher: 'Write|Edit|MultiEdit|mcp__serena__rename_symbol|mcp__serena__replace_symbol_body|mcp__serena__insert_after_symbol|mcp__serena__insert_before_symbol',
105
+ hooks: [{ command: 'python3 "$CLAUDE_PROJECT_DIR/.claude/hooks/quality-check.py"' }],
106
+ }],
107
+ },
108
+ };
109
+
110
+ const merged = deepMergeHooks(existing, incoming);
111
+ expect(merged.hooks.PostToolUse).toHaveLength(1);
112
+ const matcher = merged.hooks.PostToolUse[0].matcher as string;
113
+ expect(matcher).toContain('mcp__serena__rename_symbol');
114
+ expect(matcher).toContain('mcp__serena__replace_symbol_body');
115
+ expect(matcher).toContain('mcp__serena__insert_after_symbol');
116
+ expect(matcher).toContain('mcp__serena__insert_before_symbol');
117
+ });
89
118
  });
90
119
 
91
120
  describe('extractReadmeDescription', () => {
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
2
  import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { spawnSync } from 'node:child_process';
3
4
  import { tmpdir } from 'node:os';
4
5
  import path from 'node:path';
5
6
  import fsExtra from 'fs-extra';
@@ -108,4 +109,23 @@ describe('installGitHooks', () => {
108
109
  const count = (content.match(/# \[jaggers\] doc-reminder/g) ?? []).length;
109
110
  expect(count).toBe(1);
110
111
  });
112
+
113
+ it('chains hooks into configured core.hooksPath when beads owns hooks path', async () => {
114
+ spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
115
+ spawnSync('git', ['config', 'core.hooksPath', '.beads/hooks'], { cwd: tmpDir, stdio: 'pipe' });
116
+
117
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
118
+
119
+ const beadsPreCommit = path.join(tmpDir, '.beads', 'hooks', 'pre-commit');
120
+ const beadsPrePush = path.join(tmpDir, '.beads', 'hooks', 'pre-push');
121
+ expect(await fsExtra.pathExists(beadsPreCommit)).toBe(true);
122
+ expect(await fsExtra.pathExists(beadsPrePush)).toBe(true);
123
+
124
+ const preCommitContent = await fsExtra.readFile(beadsPreCommit, 'utf8');
125
+ const prePushContent = await fsExtra.readFile(beadsPrePush, 'utf8');
126
+ expect(preCommitContent).toContain('# [jaggers] chain-githooks');
127
+ expect(prePushContent).toContain('# [jaggers] chain-githooks');
128
+ expect(preCommitContent).toContain(path.join(tmpDir, '.githooks', 'pre-commit'));
129
+ expect(prePushContent).toContain(path.join(tmpDir, '.githooks', 'pre-push'));
130
+ });
111
131
  });