xtrm-cli 2.1.16 → 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 +1 -1
- package/src/commands/install-project.ts +95 -1
- package/src/utils/atomic-config.ts +23 -1
- package/test/atomic-config.test.ts +55 -0
- package/test/hooks.test.ts +152 -3
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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)`);
|
|
@@ -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
|
-
|
|
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
|
+
});
|
package/test/hooks.test.ts
CHANGED
|
@@ -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
|
-
|
|
191
|
-
expect(
|
|
192
|
-
|
|
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
|
}
|
|
@@ -704,3 +715,141 @@ process.exit(ok ? 0 : 1);
|
|
|
704
715
|
});
|
|
705
716
|
});
|
|
706
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
|
+
});
|