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/dist/index.cjs +32 -0
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/install-project.ts +95 -1
- package/src/commands/install.ts +37 -0
- package/src/utils/atomic-config.ts +23 -1
- package/test/atomic-config.test.ts +55 -0
- package/test/hooks.test.ts +270 -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)`);
|
package/src/commands/install.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
|
@@ -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
|
+
});
|