xtrm-cli 2.1.14 → 2.1.18

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.14",
3
+ "version": "2.1.18",
4
4
  "description": "Claude Code tools installer (skills, hooks, MCP servers)",
5
5
  "main": "./dist/index.js",
6
6
  "type": "module",
@@ -139,6 +139,92 @@ 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
+
142
228
  export function deepMergeHooks(existing: Record<string, any>, incoming: Record<string, any>): Record<string, any> {
143
229
  const result = { ...existing };
144
230
 
@@ -266,7 +352,15 @@ export async function installProjectSkill(toolName: string, projectRootOverride?
266
352
  }
267
353
 
268
354
  const incomingSettings = JSON.parse(await fs.readFile(skillSettingsPath, 'utf8'));
269
- const mergedSettings = deepMergeHooks(existingSettings, incomingSettings);
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);
270
364
 
271
365
  await fs.writeFile(targetSettingsPath, JSON.stringify(mergedSettings, null, 2) + '\n');
272
366
  console.log(`${kleur.green(' ✓')} settings.json (hooks merged)`);
@@ -125,6 +125,15 @@ function isDoltInstalled(): boolean {
125
125
  }
126
126
  }
127
127
 
128
+ function isGitnexusInstalled(): boolean {
129
+ try {
130
+ execSync('gitnexus --version', { stdio: 'ignore' });
131
+ return true;
132
+ } catch {
133
+ return false;
134
+ }
135
+ }
136
+
128
137
  interface GlobalInstallFlags {
129
138
  dryRun: boolean;
130
139
  yes: boolean;
@@ -221,6 +230,34 @@ async function runGlobalInstall(
221
230
  }
222
231
  }
223
232
 
233
+ // Gitnexus global install (for MCP server + CLI tools)
234
+ console.log(t.bold('\n ⚙ gitnexus (code intelligence)'));
235
+ console.log(t.muted(' GitNexus provides knowledge graph queries for impact analysis, execution flows, and symbol context.'));
236
+
237
+ const gitnexusOk = isGitnexusInstalled();
238
+ if (gitnexusOk) {
239
+ console.log(t.success(' ✓ gitnexus already installed\n'));
240
+ } else {
241
+ let doInstallGitnexus = effectiveYes;
242
+ if (!effectiveYes) {
243
+ const { install } = await prompts({
244
+ type: 'confirm',
245
+ name: 'install',
246
+ message: 'Install gitnexus globally? (recommended for MCP server and CLI tools)',
247
+ initial: true,
248
+ });
249
+ doInstallGitnexus = install;
250
+ }
251
+
252
+ if (doInstallGitnexus) {
253
+ console.log(t.muted('\n Installing gitnexus...'));
254
+ spawnSync('npm', ['install', '-g', 'gitnexus'], { stdio: 'inherit' });
255
+ console.log(t.success(' ✓ gitnexus installed\n'));
256
+ } else {
257
+ console.log(t.muted(' ℹ Skipped. Install later with: npm install -g gitnexus\n'));
258
+ }
259
+ }
260
+
224
261
  const diffTasks = new Listr<DiffCtx>(
225
262
  targets.map(target => ({
226
263
  title: formatTargetLabel(target),
@@ -72,9 +72,31 @@ function mergeHookWrappers(existing: any[], incoming: any[]): any[] {
72
72
  }
73
73
 
74
74
  const incomingKeys = new Set(incomingCommands.map(commandKey));
75
+ const incomingTokens = new Set(
76
+ typeof incomingWrapper.matcher === 'string'
77
+ ? incomingWrapper.matcher.split('|').map((s: string) => s.trim()).filter(Boolean)
78
+ : [],
79
+ );
80
+
75
81
  const existingIndex = merged.findIndex((existingWrapper: any) => {
76
82
  const existingCommands = extractHookCommands(existingWrapper);
77
- return existingCommands.some((c: string) => incomingKeys.has(commandKey(c)));
83
+ if (!existingCommands.some((c: string) => incomingKeys.has(commandKey(c)))) return false;
84
+
85
+ // Only merge with entries whose matchers overlap (share at least one token).
86
+ // Disjoint matchers (e.g. "Write|Edit" vs "Bash") intentionally serve
87
+ // different purposes and must remain as separate entries.
88
+ if (
89
+ typeof existingWrapper.matcher === 'string' &&
90
+ typeof incomingWrapper.matcher === 'string' &&
91
+ incomingTokens.size > 0
92
+ ) {
93
+ const existingTokens = existingWrapper.matcher
94
+ .split('|').map((s: string) => s.trim()).filter(Boolean);
95
+ const hasOverlap = existingTokens.some((t: string) => incomingTokens.has(t));
96
+ if (!hasOverlap) return false;
97
+ }
98
+
99
+ return true;
78
100
  });
79
101
 
80
102
  if (existingIndex === -1) {
@@ -81,3 +81,58 @@ describe('deepMergeWithProtection (hooks merge behavior)', () => {
81
81
  expect(merged.hooks.SessionStart).toHaveLength(2);
82
82
  });
83
83
  });
84
+
85
+
86
+ describe('deepMergeWithProtection (hooks merge behavior) — matcher dedup', () => {
87
+ it('keeps two same-script entries separate when their matchers are disjoint', () => {
88
+ // Simulates config/hooks.json having main-guard wired for write-tools
89
+ // AND separately for Bash — they must not be merged into one entry
90
+ const local = {
91
+ hooks: {
92
+ PreToolUse: [] as any[],
93
+ },
94
+ };
95
+ const incoming = {
96
+ hooks: {
97
+ PreToolUse: [
98
+ {
99
+ matcher: 'Write|Edit|MultiEdit',
100
+ hooks: [{ command: 'node "/hooks/main-guard.mjs"', timeout: 5000 }],
101
+ },
102
+ {
103
+ matcher: 'Bash',
104
+ hooks: [{ command: 'node "/hooks/main-guard.mjs"', timeout: 5000 }],
105
+ },
106
+ ],
107
+ },
108
+ };
109
+ const merged = deepMergeWithProtection(local, incoming);
110
+ const wrappers = merged.hooks.PreToolUse;
111
+ expect(wrappers).toHaveLength(2);
112
+ expect(wrappers[0].matcher).toBe('Write|Edit|MultiEdit');
113
+ expect(wrappers[1].matcher).toBe('Bash');
114
+ });
115
+
116
+ it('still upgrades matcher when entries share at least one common token', () => {
117
+ const local = {
118
+ hooks: {
119
+ PreToolUse: [{
120
+ matcher: 'Write|Edit',
121
+ hooks: [{ command: 'node "/hooks/main-guard.mjs"' }],
122
+ }],
123
+ },
124
+ };
125
+ const incoming = {
126
+ hooks: {
127
+ PreToolUse: [{
128
+ matcher: 'Write|Edit|MultiEdit',
129
+ hooks: [{ command: 'node "/hooks/main-guard.mjs"' }],
130
+ }],
131
+ },
132
+ };
133
+ const merged = deepMergeWithProtection(local, incoming);
134
+ const wrappers = merged.hooks.PreToolUse;
135
+ expect(wrappers).toHaveLength(1);
136
+ expect(wrappers[0].matcher).toContain('MultiEdit');
137
+ });
138
+ });
@@ -121,6 +121,15 @@ describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
121
121
  }
122
122
  });
123
123
 
124
+ it('allows touch .beads/.memory-gate-done on protected branch', () => {
125
+ const r = runHook(
126
+ 'main-guard.mjs',
127
+ { tool_name: 'Bash', tool_input: { command: 'touch .beads/.memory-gate-done' } },
128
+ { MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
129
+ );
130
+ expect(r.status).toBe(0);
131
+ });
132
+
124
133
  it('allows Bash when MAIN_GUARD_ALLOW_BASH=1 is set', () => {
125
134
  const r = runHook(
126
135
  'main-guard.mjs',
@@ -187,9 +196,11 @@ describe('main-guard-post-push.mjs', () => {
187
196
  repoDir,
188
197
  );
189
198
  expect(r.status).toBe(0);
190
- const out = parseHookJson(r.stdout);
191
- expect(out?.systemMessage).toContain('gh pr create --fill');
192
- expect(out?.systemMessage).toContain('gh pr merge --squash');
199
+ expect(r.stdout).toContain('gh pr create --fill');
200
+ expect(r.stdout).toContain('gh pr merge --squash');
201
+ // output must be plain text (agent-only), not a JSON systemMessage banner
202
+ expect(parseHookJson(r.stdout)).toBeNull();
203
+
193
204
  } finally {
194
205
  rmSync(repoDir, { recursive: true, force: true });
195
206
  }
@@ -586,3 +597,259 @@ describe('gitnexus-impact-reminder.py', () => {
586
597
  expect(r.stdout.trim()).toBe('');
587
598
  });
588
599
  });
600
+
601
+
602
+ // ── beads-gate-core.mjs — decision functions ──────────────────────────────────
603
+
604
+ describe('beads-gate-core.mjs — decision functions', () => {
605
+ const corePath = path.join(HOOKS_DIR, 'beads-gate-core.mjs');
606
+
607
+ it('exports all required decision functions', () => {
608
+ const r = spawnSync('node', ['--input-type=module'], {
609
+ input: `
610
+ import {
611
+ readHookInput,
612
+ resolveSessionContext,
613
+ resolveClaimAndWorkState,
614
+ decideEditGate,
615
+ decideCommitGate,
616
+ decideStopGate,
617
+ } from '${corePath}';
618
+ const ok = [readHookInput, resolveSessionContext, resolveClaimAndWorkState,
619
+ decideEditGate, decideCommitGate, decideStopGate]
620
+ .every(fn => typeof fn === 'function');
621
+ process.exit(ok ? 0 : 1);
622
+ `,
623
+ encoding: 'utf8',
624
+ });
625
+ expect(r.status).toBe(0);
626
+ });
627
+
628
+ describe('decideEditGate', () => {
629
+ it('allows when not a beads project', async () => {
630
+ const { decideEditGate } = await import(corePath);
631
+ const decision = decideEditGate(
632
+ { isBeadsProject: false },
633
+ { claimed: false, claimId: null, totalWork: 0, inProgress: null }
634
+ );
635
+ expect(decision.allow).toBe(true);
636
+ });
637
+
638
+ it('allows when session has a claim', async () => {
639
+ const { decideEditGate } = await import(corePath);
640
+ const decision = decideEditGate(
641
+ { isBeadsProject: true, sessionId: 'test-session' },
642
+ { claimed: true, claimId: 'issue-123', totalWork: 5, inProgress: null }
643
+ );
644
+ expect(decision.allow).toBe(true);
645
+ });
646
+
647
+ it('allows when no trackable work exists', async () => {
648
+ const { decideEditGate } = await import(corePath);
649
+ const decision = decideEditGate(
650
+ { isBeadsProject: true, sessionId: 'test-session' },
651
+ { claimed: false, claimId: null, totalWork: 0, inProgress: null }
652
+ );
653
+ expect(decision.allow).toBe(true);
654
+ });
655
+
656
+ it('blocks when no claim but work exists', async () => {
657
+ const { decideEditGate } = await import(corePath);
658
+ const decision = decideEditGate(
659
+ { isBeadsProject: true, sessionId: 'test-session' },
660
+ { claimed: false, claimId: null, totalWork: 3, inProgress: null }
661
+ );
662
+ expect(decision.allow).toBe(false);
663
+ expect(decision.reason).toBe('no_claim_with_work');
664
+ });
665
+
666
+ it('fails open when bd unavailable (state is null)', async () => {
667
+ const { decideEditGate } = await import(corePath);
668
+ const decision = decideEditGate(
669
+ { isBeadsProject: true, sessionId: 'test-session' },
670
+ null
671
+ );
672
+ expect(decision.allow).toBe(true);
673
+ });
674
+ });
675
+
676
+ describe('decideCommitGate', () => {
677
+ it('allows when no active claim', async () => {
678
+ const { decideCommitGate } = await import(corePath);
679
+ const decision = decideCommitGate(
680
+ { isBeadsProject: true, sessionId: 'test-session' },
681
+ { claimed: false, claimId: null, totalWork: 3, inProgress: { count: 1, summary: 'test' } }
682
+ );
683
+ expect(decision.allow).toBe(true);
684
+ });
685
+
686
+ it('blocks when session has unclosed claim', async () => {
687
+ const { decideCommitGate } = await import(corePath);
688
+ const decision = decideCommitGate(
689
+ { isBeadsProject: true, sessionId: 'test-session' },
690
+ { claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 1, summary: 'test' } }
691
+ );
692
+ expect(decision.allow).toBe(false);
693
+ expect(decision.reason).toBe('unclosed_claim');
694
+ });
695
+ });
696
+
697
+ describe('decideStopGate', () => {
698
+ it('allows when claim is stale (no in_progress issues)', async () => {
699
+ const { decideStopGate } = await import(corePath);
700
+ const decision = decideStopGate(
701
+ { isBeadsProject: true, sessionId: 'test-session' },
702
+ { claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 0, summary: '' } }
703
+ );
704
+ expect(decision.allow).toBe(true);
705
+ });
706
+
707
+ it('blocks when claim exists and in_progress issues remain', async () => {
708
+ const { decideStopGate } = await import(corePath);
709
+ const decision = decideStopGate(
710
+ { isBeadsProject: true, sessionId: 'test-session' },
711
+ { claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 2, summary: 'test' } }
712
+ );
713
+ expect(decision.allow).toBe(false);
714
+ expect(decision.reason).toBe('unclosed_claim');
715
+ });
716
+ });
717
+ });
718
+
719
+
720
+ // ── beads-compact-save.mjs ───────────────────────────────────────────────────
721
+ describe('beads-compact-save.mjs', () => {
722
+ it('exits 0 silently when no .beads directory exists', () => {
723
+ const r = runHook('beads-compact-save.mjs', { hook_event_name: 'PreCompact', cwd: '/tmp' });
724
+ expect(r.status).toBe(0);
725
+ expect(r.stdout).toBe('');
726
+ });
727
+
728
+ it('exits 0 silently and writes no file when no in_progress issues', () => {
729
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-save-'));
730
+ mkdirSync(path.join(projectDir, '.beads'));
731
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
732
+ if [[ "$1" == "list" ]]; then
733
+ echo ""
734
+ echo "--------------------------------------------------------------------------------"
735
+ echo "Total: 2 issues (2 open, 0 in progress)"
736
+ exit 0
737
+ fi
738
+ exit 1
739
+ `);
740
+ try {
741
+ const r = runHook(
742
+ 'beads-compact-save.mjs',
743
+ { hook_event_name: 'PreCompact', cwd: projectDir },
744
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
745
+ );
746
+ expect(r.status).toBe(0);
747
+ const { existsSync } = require('node:fs');
748
+ expect(existsSync(path.join(projectDir, '.beads', '.last_active'))).toBe(false);
749
+ } finally {
750
+ rmSync(fake.tempDir, { recursive: true, force: true });
751
+ rmSync(projectDir, { recursive: true, force: true });
752
+ }
753
+ });
754
+
755
+ it('writes .beads/.last_active with in_progress issue IDs', () => {
756
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-save-'));
757
+ mkdirSync(path.join(projectDir, '.beads'));
758
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
759
+ if [[ "$1" == "list" ]]; then
760
+ cat <<'EOF'
761
+ ◐ proj-abc123 ● P1 First in_progress issue
762
+ ◐ proj-def456 ● P2 Second in_progress issue
763
+
764
+ --------------------------------------------------------------------------------
765
+ Total: 2 issues (0 open, 2 in progress)
766
+ EOF
767
+ exit 0
768
+ fi
769
+ exit 1
770
+ `);
771
+ try {
772
+ const r = runHook(
773
+ 'beads-compact-save.mjs',
774
+ { hook_event_name: 'PreCompact', cwd: projectDir },
775
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
776
+ );
777
+ expect(r.status).toBe(0);
778
+ const { readFileSync: rfs } = require('node:fs');
779
+ const saved = rfs(path.join(projectDir, '.beads', '.last_active'), 'utf8').trim().split('\n');
780
+ expect(saved).toContain('proj-abc123');
781
+ expect(saved).toContain('proj-def456');
782
+ } finally {
783
+ rmSync(fake.tempDir, { recursive: true, force: true });
784
+ rmSync(projectDir, { recursive: true, force: true });
785
+ }
786
+ });
787
+ });
788
+
789
+ // ── beads-compact-restore.mjs ────────────────────────────────────────────────
790
+ describe('beads-compact-restore.mjs', () => {
791
+ it('exits 0 silently when no .beads/.last_active file exists', () => {
792
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-restore-'));
793
+ mkdirSync(path.join(projectDir, '.beads'));
794
+ try {
795
+ const r = runHook(
796
+ 'beads-compact-restore.mjs',
797
+ { hook_event_name: 'SessionStart', cwd: projectDir },
798
+ );
799
+ expect(r.status).toBe(0);
800
+ expect(r.stdout).toBe('');
801
+ } finally {
802
+ rmSync(projectDir, { recursive: true, force: true });
803
+ }
804
+ });
805
+
806
+ it('restores in_progress status, deletes .last_active, and injects additionalSystemPrompt', () => {
807
+ const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-restore-'));
808
+ mkdirSync(path.join(projectDir, '.beads'));
809
+ const { writeFileSync: wfs } = require('node:fs');
810
+ wfs(path.join(projectDir, '.beads', '.last_active'), 'proj-abc123\nproj-def456\n', 'utf8');
811
+
812
+ const callLog = path.join(projectDir, 'bd-calls.log');
813
+ const fake = withFakeBdDir(`#!/usr/bin/env bash
814
+ echo "$@" >> "${callLog}"
815
+ exit 0
816
+ `);
817
+ try {
818
+ const r = runHook(
819
+ 'beads-compact-restore.mjs',
820
+ { hook_event_name: 'SessionStart', cwd: projectDir },
821
+ { PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
822
+ );
823
+ expect(r.status).toBe(0);
824
+ // .last_active must be deleted
825
+ const { existsSync: exs, readFileSync: rfs } = require('node:fs');
826
+ expect(exs(path.join(projectDir, '.beads', '.last_active'))).toBe(false);
827
+ // bd update called for each ID
828
+ const calls = rfs(callLog, 'utf8');
829
+ expect(calls).toContain('proj-abc123');
830
+ expect(calls).toContain('proj-def456');
831
+ // additionalSystemPrompt injected for agent
832
+ const out = parseHookJson(r.stdout);
833
+ expect(out?.hookSpecificOutput?.additionalSystemPrompt).toMatch(/Restored 2 in_progress issue/);
834
+ } finally {
835
+ rmSync(fake.tempDir, { recursive: true, force: true });
836
+ rmSync(projectDir, { recursive: true, force: true });
837
+ }
838
+ });
839
+ });
840
+
841
+
842
+ // ── hooks.json wiring ────────────────────────────────────────────────────────
843
+ describe('hooks.json — beads-compact hooks wiring', () => {
844
+ it('wires beads-compact-save.mjs to PreCompact event', () => {
845
+ const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
846
+ const preCompact: Array<{ script: string }> = cfg.hooks.PreCompact ?? [];
847
+ expect(preCompact.some((h) => h.script === 'beads-compact-save.mjs')).toBe(true);
848
+ });
849
+
850
+ it('wires beads-compact-restore.mjs to SessionStart event', () => {
851
+ const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
852
+ const sessionStart: Array<{ script: string }> = cfg.hooks.SessionStart ?? [];
853
+ expect(sessionStart.some((h) => h.script === 'beads-compact-restore.mjs')).toBe(true);
854
+ });
855
+ });