xtrm-cli 2.1.18 → 2.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xtrm-cli",
3
- "version": "2.1.18",
3
+ "version": "2.1.19",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -0,0 +1,323 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import fs from 'fs-extra';
4
+ import path from 'path';
5
+ import { homedir } from 'os';
6
+ import { t, sym } from '../utils/theme.js';
7
+
8
+ // Canonical hooks (files in ~/.claude/hooks/)
9
+ const CANONICAL_HOOKS = new Set([
10
+ 'agent_context.py',
11
+ 'serena-workflow-reminder.py',
12
+ 'main-guard.mjs',
13
+ 'main-guard-post-push.mjs',
14
+ 'beads-gate-core.mjs',
15
+ 'beads-gate-utils.mjs',
16
+ 'beads-gate-messages.mjs',
17
+ 'beads-edit-gate.mjs',
18
+ 'beads-commit-gate.mjs',
19
+ 'beads-stop-gate.mjs',
20
+ 'beads-memory-gate.mjs',
21
+ 'beads-compact-save.mjs',
22
+ 'beads-compact-restore.mjs',
23
+ 'gitnexus', // directory
24
+ 'statusline-starship.sh',
25
+ 'README.md',
26
+ ]);
27
+
28
+ // Canonical skills (directories in ~/.agents/skills/)
29
+ const CANONICAL_SKILLS = new Set([
30
+ 'clean-code',
31
+ 'delegating',
32
+ 'docker-expert',
33
+ 'documenting',
34
+ 'find-skills',
35
+ 'gitnexus-debugging',
36
+ 'gitnexus-exploring',
37
+ 'gitnexus-impact-analysis',
38
+ 'gitnexus-refactoring',
39
+ 'hook-development',
40
+ 'obsidian-cli',
41
+ 'orchestrating-agents',
42
+ 'prompt-improving',
43
+ 'python-testing',
44
+ 'senior-backend',
45
+ 'senior-data-scientist',
46
+ 'senior-devops',
47
+ 'senior-security',
48
+ 'skill-creator',
49
+ 'using-serena-lsp',
50
+ 'using-TDD',
51
+ 'using-xtrm',
52
+ ]);
53
+
54
+ // Directories/files to always ignore
55
+ const IGNORED_ITEMS = new Set([
56
+ '__pycache__',
57
+ '.DS_Store',
58
+ 'Thumbs.db',
59
+ '.gitkeep',
60
+ 'node_modules',
61
+ ]);
62
+
63
+ interface CleanResult {
64
+ hooksRemoved: string[];
65
+ skillsRemoved: string[];
66
+ cacheRemoved: string[];
67
+ }
68
+
69
+ async function cleanHooks(dryRun: boolean): Promise<{ removed: string[]; cache: string[] }> {
70
+ const hooksDir = path.join(homedir(), '.claude', 'hooks');
71
+ const removed: string[] = [];
72
+ const cache: string[] = [];
73
+
74
+ if (!await fs.pathExists(hooksDir)) {
75
+ return { removed, cache };
76
+ }
77
+
78
+ const entries = await fs.readdir(hooksDir);
79
+
80
+ for (const entry of entries) {
81
+ // Skip ignored items but track them for cache cleanup
82
+ if (IGNORED_ITEMS.has(entry)) {
83
+ if (!dryRun) {
84
+ const fullPath = path.join(hooksDir, entry);
85
+ await fs.remove(fullPath);
86
+ }
87
+ cache.push(entry);
88
+ continue;
89
+ }
90
+
91
+ // Check if it's canonical
92
+ if (CANONICAL_HOOKS.has(entry)) {
93
+ continue;
94
+ }
95
+
96
+ // Check if it's a file we should remove
97
+ const fullPath = path.join(hooksDir, entry);
98
+ const stat = await fs.stat(fullPath);
99
+
100
+ // Only remove files, not arbitrary directories (except cache dirs)
101
+ if (stat.isFile() || (stat.isDirectory() && IGNORED_ITEMS.has(entry))) {
102
+ if (!dryRun) {
103
+ await fs.remove(fullPath);
104
+ }
105
+ removed.push(entry);
106
+ }
107
+ }
108
+
109
+ return { removed, cache };
110
+ }
111
+
112
+ async function cleanSkills(dryRun: boolean): Promise<string[]> {
113
+ const skillsDir = path.join(homedir(), '.agents', 'skills');
114
+ const removed: string[] = [];
115
+
116
+ if (!await fs.pathExists(skillsDir)) {
117
+ return removed;
118
+ }
119
+
120
+ const entries = await fs.readdir(skillsDir);
121
+
122
+ for (const entry of entries) {
123
+ // Skip ignored items
124
+ if (IGNORED_ITEMS.has(entry)) {
125
+ continue;
126
+ }
127
+
128
+ // Skip README.txt
129
+ if (entry === 'README.txt') {
130
+ continue;
131
+ }
132
+
133
+ // Check if it's canonical
134
+ if (CANONICAL_SKILLS.has(entry)) {
135
+ continue;
136
+ }
137
+
138
+ // Remove non-canonical directory
139
+ const fullPath = path.join(skillsDir, entry);
140
+ const stat = await fs.stat(fullPath);
141
+
142
+ if (stat.isDirectory()) {
143
+ if (!dryRun) {
144
+ await fs.remove(fullPath);
145
+ }
146
+ removed.push(entry);
147
+ }
148
+ }
149
+
150
+ return removed;
151
+ }
152
+
153
+ async function cleanOrphanedHookEntries(dryRun: boolean): Promise<string[]> {
154
+ const settingsPath = path.join(homedir(), '.claude', 'settings.json');
155
+ const removed: string[] = [];
156
+
157
+ if (!await fs.pathExists(settingsPath)) {
158
+ return removed;
159
+ }
160
+
161
+ let settings: any = {};
162
+ try {
163
+ settings = await fs.readJson(settingsPath);
164
+ } catch {
165
+ return removed;
166
+ }
167
+
168
+ if (!settings.hooks || typeof settings.hooks !== 'object') {
169
+ return removed;
170
+ }
171
+
172
+ // Collect canonical script names from CANONICAL_HOOKS
173
+ const canonicalScripts = new Set<string>();
174
+ for (const hook of CANONICAL_HOOKS) {
175
+ if (hook.endsWith('.py') || hook.endsWith('.mjs') || hook.endsWith('.cjs') || hook.endsWith('.js')) {
176
+ canonicalScripts.add(hook);
177
+ }
178
+ }
179
+ // Add gitnexus hook
180
+ canonicalScripts.add('gitnexus/gitnexus-hook.cjs');
181
+
182
+ // Check each hook entry
183
+ let modified = false;
184
+ for (const [event, wrappers] of Object.entries(settings.hooks)) {
185
+ if (!Array.isArray(wrappers)) continue;
186
+
187
+ const keptWrappers: any[] = [];
188
+ for (const wrapper of wrappers) {
189
+ const innerHooks = wrapper.hooks || [wrapper];
190
+ const keptInner: any[] = [];
191
+
192
+ for (const hook of innerHooks) {
193
+ const cmd = hook?.command || '';
194
+ // Extract script filename
195
+ const m = cmd.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
196
+ const script = m?.[1];
197
+
198
+ if (!script || canonicalScripts.has(script)) {
199
+ keptInner.push(hook);
200
+ } else {
201
+ removed.push(`${event}:${script}`);
202
+ modified = true;
203
+ }
204
+ }
205
+
206
+ if (keptInner.length > 0) {
207
+ if (wrapper.hooks) {
208
+ keptWrappers.push({ ...wrapper, hooks: keptInner });
209
+ } else if (keptInner.length === 1) {
210
+ keptWrappers.push(keptInner[0]);
211
+ }
212
+ }
213
+ }
214
+
215
+ if (keptWrappers.length > 0) {
216
+ settings.hooks[event] = keptWrappers;
217
+ } else {
218
+ delete settings.hooks[event];
219
+ modified = true;
220
+ }
221
+ }
222
+
223
+ if (modified && !dryRun) {
224
+ await fs.writeJson(settingsPath, settings, { spaces: 2 });
225
+ }
226
+
227
+ return removed;
228
+ }
229
+
230
+ export function createCleanCommand(): Command {
231
+ return new Command('clean')
232
+ .description('Remove orphaned hooks and skills not in the canonical repository')
233
+ .option('--dry-run', 'Preview what would be removed without making changes', false)
234
+ .option('--hooks-only', 'Only clean hooks, skip skills', false)
235
+ .option('--skills-only', 'Only clean skills, skip hooks', false)
236
+ .option('-y, --yes', 'Skip confirmation prompt', false)
237
+ .action(async (opts) => {
238
+ const { dryRun, hooksOnly, skillsOnly, yes } = opts;
239
+
240
+ console.log(t.bold('\n XTRM Clean — Remove Orphaned Components\n'));
241
+
242
+ if (dryRun) {
243
+ console.log(kleur.yellow(' DRY RUN — No changes will be made\n'));
244
+ }
245
+
246
+ const result: CleanResult = {
247
+ hooksRemoved: [],
248
+ skillsRemoved: [],
249
+ cacheRemoved: [],
250
+ };
251
+
252
+ // Clean hooks
253
+ if (!skillsOnly) {
254
+ console.log(kleur.bold(' Scanning ~/.claude/hooks/...'));
255
+ const { removed, cache } = await cleanHooks(dryRun);
256
+ result.hooksRemoved = removed;
257
+ result.cacheRemoved = cache;
258
+
259
+ if (removed.length > 0) {
260
+ for (const f of removed) {
261
+ console.log(kleur.red(` ✗ ${f}`));
262
+ }
263
+ } else {
264
+ console.log(kleur.dim(' ✓ No orphaned hooks found'));
265
+ }
266
+
267
+ if (cache.length > 0) {
268
+ console.log(kleur.dim(` ↳ Cleaned ${cache.length} cache directory(ies)`));
269
+ }
270
+
271
+ // Clean orphaned hook entries in settings.json
272
+ console.log(kleur.bold('\n Scanning settings.json for orphaned hook entries...'));
273
+ const orphanedEntries = await cleanOrphanedHookEntries(dryRun);
274
+ if (orphanedEntries.length > 0) {
275
+ for (const entry of orphanedEntries) {
276
+ console.log(kleur.red(` ✗ ${entry}`));
277
+ }
278
+ } else {
279
+ console.log(kleur.dim(' ✓ No orphaned hook entries found'));
280
+ }
281
+ }
282
+
283
+ // Clean skills
284
+ if (!hooksOnly) {
285
+ console.log(kleur.bold('\n Scanning ~/.agents/skills/...'));
286
+ result.skillsRemoved = await cleanSkills(dryRun);
287
+
288
+ if (result.skillsRemoved.length > 0) {
289
+ for (const d of result.skillsRemoved) {
290
+ console.log(kleur.red(` ✗ ${d}/`));
291
+ }
292
+ } else {
293
+ console.log(kleur.dim(' ✓ No orphaned skills found'));
294
+ }
295
+ }
296
+
297
+ // Summary
298
+ const totalRemoved = result.hooksRemoved.length + result.skillsRemoved.length + result.cacheRemoved.length;
299
+
300
+ if (totalRemoved === 0) {
301
+ console.log(t.boldGreen('\n ✓ All components are canonical — nothing to clean\n'));
302
+ return;
303
+ }
304
+
305
+ console.log(kleur.bold('\n Summary:'));
306
+ if (result.hooksRemoved.length > 0) {
307
+ console.log(kleur.red(` ${result.hooksRemoved.length} orphaned hook(s)`));
308
+ }
309
+ if (result.skillsRemoved.length > 0) {
310
+ console.log(kleur.red(` ${result.skillsRemoved.length} orphaned skill(s)`));
311
+ }
312
+ if (result.cacheRemoved.length > 0) {
313
+ console.log(kleur.dim(` ${result.cacheRemoved.length} cache director(y/ies)`));
314
+ }
315
+
316
+ if (!dryRun) {
317
+ console.log(t.boldGreen('\n ✓ Cleanup complete\n'));
318
+ console.log(kleur.dim(' Run `xtrm install all -y` to reinstall canonical components\n'));
319
+ } else {
320
+ console.log(kleur.yellow('\n ℹ Dry run — run without --dry-run to apply changes\n'));
321
+ }
322
+ });
323
+ }
@@ -139,92 +139,6 @@ export async function getAvailableProjectSkills(): Promise<string[]> {
139
139
  * Deep merge settings.json hooks without overwriting existing user hooks.
140
140
  * Appends new hooks to existing events intelligently.
141
141
  */
142
- /**
143
- * Extract script filename from a hook command.
144
- */
145
- function getScriptFilename(hook: any): string | null {
146
- const cmd = hook.command || hook.hooks?.[0]?.command || '';
147
- if (typeof cmd !== 'string') return null;
148
- // Match script filename (e.g., "beads-edit-gate.mjs" or "gitnexus/gitnexus-hook.cjs")
149
- const m = cmd.match(/([A-Za-z0-9._/-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._/-]+\.(?:py|cjs|mjs|js))/);
150
- return m?.[1] ?? null;
151
- }
152
-
153
- /**
154
- * Prune hooks from settings.json that are NOT in the canonical config.
155
- * This removes stale entries from old versions before merging new ones.
156
- *
157
- * @param existing Current settings.json hooks
158
- * @param canonical Canonical hooks config from hooks.json
159
- * @returns Pruned settings with stale hooks removed
160
- */
161
- export function pruneStaleHooks(
162
- existing: Record<string, any>,
163
- canonical: Record<string, any>,
164
- ): { result: Record<string, any>; removed: string[] } {
165
- const result = { ...existing };
166
- const removed: string[] = [];
167
-
168
- if (!result.hooks || typeof result.hooks !== 'object') {
169
- return { result, removed };
170
- }
171
- if (!canonical.hooks || typeof canonical.hooks !== 'object') {
172
- return { result, removed };
173
- }
174
-
175
- // Collect all canonical script filenames
176
- const canonicalScripts = new Set<string>();
177
- for (const [event, hooks] of Object.entries(canonical.hooks)) {
178
- const hookList = Array.isArray(hooks) ? hooks : [hooks];
179
- for (const wrapper of hookList) {
180
- const innerHooks = wrapper.hooks || [wrapper];
181
- for (const hook of innerHooks) {
182
- const script = getScriptFilename(hook);
183
- if (script) canonicalScripts.add(script);
184
- }
185
- }
186
- }
187
-
188
- // Prune existing hooks not in canonical
189
- for (const [event, hooks] of Object.entries(result.hooks)) {
190
- if (!Array.isArray(hooks)) continue;
191
-
192
- const prunedWrappers: any[] = [];
193
- for (const wrapper of hooks) {
194
- const innerHooks = wrapper.hooks || [wrapper];
195
- const keptInner: any[] = [];
196
-
197
- for (const hook of innerHooks) {
198
- const script = getScriptFilename(hook);
199
- // Keep if: no script (not a file-based hook) OR script is canonical
200
- if (!script || canonicalScripts.has(script)) {
201
- keptInner.push(hook);
202
- } else {
203
- removed.push(`${event}:${script}`);
204
- }
205
- }
206
-
207
- if (keptInner.length > 0) {
208
- if (wrapper.hooks) {
209
- prunedWrappers.push({ ...wrapper, hooks: keptInner });
210
- } else if (keptInner.length === 1) {
211
- prunedWrappers.push(keptInner[0]);
212
- } else {
213
- prunedWrappers.push({ ...wrapper, hooks: keptInner });
214
- }
215
- }
216
- }
217
-
218
- if (prunedWrappers.length > 0) {
219
- result.hooks[event] = prunedWrappers;
220
- } else {
221
- delete result.hooks[event];
222
- }
223
- }
224
-
225
- return { result, removed };
226
- }
227
-
228
142
  export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
229
143
  const result = { ...existing };
230
144
 
@@ -352,15 +266,7 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
352
266
  }
353
267
 
354
268
  const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
355
-
356
- // First prune stale hooks not in canonical config
357
- const { result: prunedSettings, removed } = pruneStaleHooks(existingSettings, incomingSettings);
358
- if (removed.length > 0) {
359
- console.log(kleur.yellow(` ↳ Pruned ${removed.length} stale hook(s): ${removed.join(', ')}`));
360
- }
361
-
362
- // Then merge canonical hooks
363
- const mergedSettings = deepMergeHooks(prunedSettings, incomingSettings);
269
+ const mergedSettings = deepMergeHooks(existingSettings, incomingSettings);
364
270
 
365
271
  await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
366
272
  console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
@@ -53,6 +53,89 @@ function commandKey(command: string): string {
53
53
  return m?.[1] || command.trim();
54
54
  }
55
55
 
56
+ /**
57
+ * Extract script filename from a hook command for pruning purposes.
58
+ */
59
+ function scriptKey(command: string): string | null {
60
+ // Match the script path relative to hooks directory
61
+ // Pattern: /hooks/<optional-subdir>/<filename>.<ext>
62
+ const m = command.match(/\/hooks\/([A-Za-z0-9_/-]+\.(?:py|cjs|mjs|js))/);
63
+ if (m) return m[1];
64
+
65
+ // Fallback: match just the filename if no /hooks/ path
66
+ const m2 = command.match(/([A-Za-z0-9_-]+\.(?:py|cjs|mjs|js))(?!.*[A-Za-z0-9._-]+\.(?:py|cjs|mjs|js))/);
67
+ return m2?.[1] || null;
68
+ }
69
+
70
+ /**
71
+ * Collect all canonical script filenames from incoming hooks.
72
+ */
73
+ function collectCanonicalScripts(incomingHooks: any): Set<string> {
74
+ const scripts = new Set<string>();
75
+ if (!incomingHooks || typeof incomingHooks !== 'object') return scripts;
76
+
77
+ for (const wrappers of Object.values(incomingHooks)) {
78
+ if (!Array.isArray(wrappers)) continue;
79
+ for (const wrapper of wrappers) {
80
+ const commands = extractHookCommands(wrapper);
81
+ for (const cmd of commands) {
82
+ const script = scriptKey(cmd);
83
+ if (script) scripts.add(script);
84
+ }
85
+ }
86
+ }
87
+ return scripts;
88
+ }
89
+
90
+ /**
91
+ * Prune existing hook wrappers that reference scripts NOT in canonical set.
92
+ * Returns { pruned: wrappers[], removed: string[] }
93
+ */
94
+ function pruneStaleWrappers(existing: any[], canonicalScripts: Set<string>): { pruned: any[]; removed: string[] } {
95
+ if (canonicalScripts.size === 0) {
96
+ return { pruned: existing, removed: [] };
97
+ }
98
+
99
+ const removed: string[] = [];
100
+ const pruned: any[] = [];
101
+
102
+ for (const wrapper of existing) {
103
+ if (!Array.isArray(wrapper.hooks)) {
104
+ pruned.push(wrapper);
105
+ continue;
106
+ }
107
+
108
+ const keptHooks: any[] = [];
109
+ for (const hook of wrapper.hooks) {
110
+ const cmd = hook?.command;
111
+ if (typeof cmd !== 'string') {
112
+ keptHooks.push(hook);
113
+ continue;
114
+ }
115
+ // Only prune hooks that are clearly xtrm-managed (have /hooks/ in their path)
116
+ // User-local hooks from other directories are always preserved
117
+ const isXtrmManaged = /\/hooks\//.test(cmd);
118
+ if (!isXtrmManaged) {
119
+ keptHooks.push(hook);
120
+ continue;
121
+ }
122
+ const script = scriptKey(cmd);
123
+ // Keep if: no script (not a file-based hook) OR script is in canonical set
124
+ if (!script || canonicalScripts.has(script)) {
125
+ keptHooks.push(hook);
126
+ } else {
127
+ removed.push(script);
128
+ }
129
+ }
130
+
131
+ if (keptHooks.length > 0) {
132
+ pruned.push({ ...wrapper, hooks: keptHooks });
133
+ }
134
+ }
135
+
136
+ return { pruned, removed };
137
+ }
138
+
56
139
  function mergeMatcher(existingMatcher: string, incomingMatcher: string): string {
57
140
  const parts = [
58
141
  ...existingMatcher.split('|').map((s: string) => s.trim()),
@@ -130,7 +213,23 @@ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
130
213
  }
131
214
 
132
215
  function mergeHooksObject(existingHooks: any, incomingHooks: any): any {
133
- const result = { ...(existingHooks || {}) };
216
+ // Step 1: Collect canonical script filenames from incoming hooks
217
+ const canonicalScripts = collectCanonicalScripts(incomingHooks);
218
+
219
+ // Step 2: Prune existing hooks that reference non-canonical scripts
220
+ const result: any = {};
221
+ for (const [event, existingWrappers] of Object.entries(existingHooks || {})) {
222
+ if (!Array.isArray(existingWrappers)) {
223
+ result[event] = existingWrappers;
224
+ continue;
225
+ }
226
+ const { pruned } = pruneStaleWrappers(existingWrappers, canonicalScripts);
227
+ if (pruned.length > 0) {
228
+ result[event] = pruned;
229
+ }
230
+ }
231
+
232
+ // Step 3: Merge incoming hooks with pruned existing hooks
134
233
  for (const [event, incomingWrappers] of Object.entries(incomingHooks || {})) {
135
234
  const existingWrappers = Array.isArray(result[event]) ? result[event] : [];
136
235
  const incomingArray = Array.isArray(incomingWrappers) ? incomingWrappers : [];
@@ -77,7 +77,7 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
77
77
  );
78
78
  expect(r.status).toBe(2);
79
79
  const out = parseHookJson(r.stdout);
80
- expect(out?.systemMessage).toContain('Bash is restricted');
80
+ expect(out?.systemMessage).toContain('Bash restricted');
81
81
  });
82
82
 
83
83
  it('allows safe Bash commands on protected branch', () => {
@@ -117,7 +117,7 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
117
117
  );
118
118
  expect(r.status, `expected exit 2 for: ${command}`).toBe(2);
119
119
  const out = parseHookJson(r.stdout);
120
- expect(out?.systemMessage).toContain('Bash is restricted');
120
+ expect(out?.systemMessage).toContain('Bash restricted');
121
121
  }
122
122
  });
123
123
 
@@ -331,7 +331,7 @@ exit 1
331
331
  { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
332
332
  );
333
333
  expect(r.status).toBe(2);
334
- expect(r.stderr).toContain('no active claim');
334
+ expect(r.stderr).toContain('active claim');
335
335
  } finally {
336
336
  rmSync(fake.tempDir, { recursive: true, force: true });
337
337
  rmSync(projectDir, { recursive: true, force: true });
@@ -456,7 +456,7 @@ exit 1
456
456
  { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
457
457
  );
458
458
  expect(r.status).toBe(2);
459
- expect(r.stderr).toContain('MEMORY GATE');
459
+ expect(r.stderr).toContain('Memory gate');
460
460
  } finally {
461
461
  rmSync(fake.tempDir, { recursive: true, force: true });
462
462
  rmSync(projectDir, { recursive: true, force: true });
@@ -553,51 +553,6 @@ exit 2
553
553
  });
554
554
 
555
555
 
556
- // ── gitnexus-impact-reminder.py ──────────────────────────────────────────────
557
-
558
- function runPythonHook(
559
- hookFile: string,
560
- input: Record<string, unknown>,
561
- ) {
562
- return spawnSync('python3', [path.join(HOOKS_DIR, hookFile)], {
563
- input: JSON.stringify(input),
564
- encoding: 'utf8',
565
- env: { ...process.env },
566
- });
567
- }
568
-
569
- describe('gitnexus-impact-reminder.py', () => {
570
- it('injects additionalContext when prompt contains an edit-intent keyword', () => {
571
- const r = runPythonHook('gitnexus-impact-reminder.py', {
572
- hook_event_name: 'UserPromptSubmit',
573
- prompt: 'fix the broken auth logic in login.ts',
574
- });
575
- expect(r.status).toBe(0);
576
- const out = parseHookJson(r.stdout);
577
- expect(out?.hookSpecificOutput?.additionalContext).toContain('gitnexus impact');
578
- });
579
-
580
- it('does nothing (no output) when prompt has no edit-intent keywords', () => {
581
- const r = runPythonHook('gitnexus-impact-reminder.py', {
582
- hook_event_name: 'UserPromptSubmit',
583
- prompt: 'explain how the beads gate works',
584
- });
585
- expect(r.status).toBe(0);
586
- expect(r.stdout.trim()).toBe('');
587
- });
588
-
589
- it('does nothing for non-UserPromptSubmit events', () => {
590
- const r = runPythonHook('gitnexus-impact-reminder.py', {
591
- hook_event_name: 'PreToolUse',
592
- tool_name: 'Edit',
593
- tool_input: { file_path: 'foo.ts' },
594
- prompt: 'fix something',
595
- });
596
- expect(r.status).toBe(0);
597
- expect(r.stdout.trim()).toBe('');
598
- });
599
- });
600
-
601
556
 
602
557
  // ── beads-gate-core.mjs — decision functions ──────────────────────────────────
603
558