xtrm-cli 0.5.0
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/.gemini/settings.json +39 -0
- package/dist/index.cjs +57378 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2 -0
- package/extensions/beads.ts +109 -0
- package/extensions/core/adapter.ts +45 -0
- package/extensions/core/lib.ts +3 -0
- package/extensions/core/logger.ts +45 -0
- package/extensions/core/runner.ts +71 -0
- package/extensions/custom-footer.ts +160 -0
- package/extensions/main-guard-post-push.ts +44 -0
- package/extensions/main-guard.ts +126 -0
- package/extensions/minimal-mode.ts +201 -0
- package/extensions/quality-gates.ts +67 -0
- package/extensions/service-skills.ts +150 -0
- package/extensions/xtrm-loader.ts +89 -0
- package/hooks/gitnexus-impact-reminder.py +13 -0
- package/lib/atomic-config.js +236 -0
- package/lib/config-adapter.js +231 -0
- package/lib/config-injector.js +80 -0
- package/lib/context.js +73 -0
- package/lib/diff.js +142 -0
- package/lib/env-manager.js +160 -0
- package/lib/sync-mcp-cli.js +345 -0
- package/lib/sync.js +227 -0
- package/package.json +47 -0
- package/src/adapters/base.ts +29 -0
- package/src/adapters/claude.ts +38 -0
- package/src/adapters/registry.ts +21 -0
- package/src/commands/claude.ts +122 -0
- package/src/commands/clean.ts +371 -0
- package/src/commands/end.ts +239 -0
- package/src/commands/finish.ts +25 -0
- package/src/commands/help.ts +180 -0
- package/src/commands/init.ts +959 -0
- package/src/commands/install-pi.ts +276 -0
- package/src/commands/install-service-skills.ts +281 -0
- package/src/commands/install.ts +427 -0
- package/src/commands/pi-install.ts +119 -0
- package/src/commands/pi.ts +128 -0
- package/src/commands/reset.ts +12 -0
- package/src/commands/status.ts +170 -0
- package/src/commands/worktree.ts +193 -0
- package/src/core/context.ts +141 -0
- package/src/core/diff.ts +174 -0
- package/src/core/interactive-plan.ts +165 -0
- package/src/core/manifest.ts +26 -0
- package/src/core/preflight.ts +142 -0
- package/src/core/rollback.ts +32 -0
- package/src/core/session-state.ts +139 -0
- package/src/core/sync-executor.ts +427 -0
- package/src/core/xtrm-finish.ts +267 -0
- package/src/index.ts +87 -0
- package/src/tests/policy-parity.test.ts +204 -0
- package/src/tests/session-flow-parity.test.ts +118 -0
- package/src/tests/session-state.test.ts +124 -0
- package/src/tests/xtrm-finish.test.ts +148 -0
- package/src/types/config.ts +51 -0
- package/src/types/models.ts +52 -0
- package/src/utils/atomic-config.ts +467 -0
- package/src/utils/banner.ts +194 -0
- package/src/utils/config-adapter.ts +90 -0
- package/src/utils/config-injector.ts +81 -0
- package/src/utils/env-manager.ts +193 -0
- package/src/utils/hash.ts +42 -0
- package/src/utils/repo-root.ts +39 -0
- package/src/utils/sync-mcp-cli.ts +395 -0
- package/src/utils/theme.ts +37 -0
- package/src/utils/worktree-session.ts +93 -0
- package/test/atomic-config-prune.test.ts +101 -0
- package/test/atomic-config.test.ts +138 -0
- package/test/clean.test.ts +172 -0
- package/test/config-schema.test.ts +52 -0
- package/test/context.test.ts +33 -0
- package/test/end-worktree.test.ts +168 -0
- package/test/extensions/beads.test.ts +166 -0
- package/test/extensions/extension-harness.ts +85 -0
- package/test/extensions/main-guard.test.ts +77 -0
- package/test/extensions/minimal-mode.test.ts +107 -0
- package/test/extensions/quality-gates.test.ts +79 -0
- package/test/extensions/service-skills.test.ts +84 -0
- package/test/extensions/xtrm-loader.test.ts +53 -0
- package/test/hooks/quality-check-hooks.test.ts +45 -0
- package/test/hooks.test.ts +1075 -0
- package/test/install-pi.test.ts +185 -0
- package/test/install-project.test.ts +378 -0
- package/test/install-service-skills.test.ts +131 -0
- package/test/install-surface.test.ts +72 -0
- package/test/runtime-subcommands.test.ts +121 -0
- package/test/session-launcher.test.ts +139 -0
- package/tsconfig.json +22 -0
- package/tsup.config.ts +17 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,1075 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { spawnSync, execSync } from 'node:child_process';
|
|
3
|
+
import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, chmodSync, rmSync } from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
|
|
8
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const HOOKS_DIR = path.join(__dirname, '../../hooks');
|
|
10
|
+
|
|
11
|
+
const CURRENT_BRANCH = (() => {
|
|
12
|
+
try {
|
|
13
|
+
return execSync('git branch --show-current', { encoding: 'utf8' }).trim();
|
|
14
|
+
} catch {
|
|
15
|
+
return 'main';
|
|
16
|
+
}
|
|
17
|
+
})();
|
|
18
|
+
|
|
19
|
+
function runHook(
|
|
20
|
+
hookFile: string,
|
|
21
|
+
input: Record<string, unknown>,
|
|
22
|
+
env: Record<string, string> = {},
|
|
23
|
+
cwd?: string,
|
|
24
|
+
) {
|
|
25
|
+
return spawnSync('node', [path.join(HOOKS_DIR, hookFile)], {
|
|
26
|
+
input: JSON.stringify(input),
|
|
27
|
+
encoding: 'utf8',
|
|
28
|
+
env: { ...process.env, ...env },
|
|
29
|
+
cwd,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function parseHookJson(stdout: string) {
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(stdout);
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function withFakeBdDir(scriptBody: string) {
|
|
42
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fakebd-'));
|
|
43
|
+
const fakeBdPath = path.join(tempDir, 'bd');
|
|
44
|
+
writeFileSync(fakeBdPath, scriptBody, { encoding: 'utf8' });
|
|
45
|
+
chmodSync(fakeBdPath, 0o755);
|
|
46
|
+
return { tempDir, fakeBdPath };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES ──────────────────────────
|
|
50
|
+
|
|
51
|
+
describe('main-guard.mjs — MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
52
|
+
it('blocks Write when current branch is listed in MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
53
|
+
const r = runHook(
|
|
54
|
+
'main-guard.mjs',
|
|
55
|
+
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x' } },
|
|
56
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
57
|
+
);
|
|
58
|
+
expect(r.status).toBe(0);
|
|
59
|
+
const out = parseHookJson(r.stdout);
|
|
60
|
+
expect(out?.decision).toBe('block');
|
|
61
|
+
expect(out?.reason).toContain(CURRENT_BRANCH);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('allows Write when current branch is NOT in MAIN_GUARD_PROTECTED_BRANCHES', () => {
|
|
65
|
+
const r = runHook(
|
|
66
|
+
'main-guard.mjs',
|
|
67
|
+
{ tool_name: 'Write', tool_input: { file_path: '/tmp/x' } },
|
|
68
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'nonexistent-branch-xyz' },
|
|
69
|
+
);
|
|
70
|
+
expect(r.status).toBe(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('blocks Bash by default on protected branch (default-deny)', () => {
|
|
74
|
+
const r = runHook(
|
|
75
|
+
'main-guard.mjs',
|
|
76
|
+
{ tool_name: 'Bash', tool_input: { command: 'cat > file.txt << EOF\nhello\nEOF' } },
|
|
77
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
78
|
+
);
|
|
79
|
+
expect(r.status).toBe(0);
|
|
80
|
+
const out = parseHookJson(r.stdout);
|
|
81
|
+
expect(out?.decision).toBe('block');
|
|
82
|
+
expect(out?.reason).toContain('Bash restricted');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('allows safe Bash commands on protected branch', () => {
|
|
86
|
+
const safeCommands = [
|
|
87
|
+
'git status',
|
|
88
|
+
'git log --oneline -5',
|
|
89
|
+
'git diff HEAD',
|
|
90
|
+
'git checkout -b feature/x',
|
|
91
|
+
'git switch -c feature/y',
|
|
92
|
+
'git fetch origin',
|
|
93
|
+
'git pull',
|
|
94
|
+
'gh pr list',
|
|
95
|
+
'bd list',
|
|
96
|
+
`git reset --hard origin/${CURRENT_BRANCH}`,
|
|
97
|
+
];
|
|
98
|
+
for (const command of safeCommands) {
|
|
99
|
+
const r = runHook(
|
|
100
|
+
'main-guard.mjs',
|
|
101
|
+
{ tool_name: 'Bash', tool_input: { command } },
|
|
102
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
103
|
+
);
|
|
104
|
+
expect(r.status, `expected exit 0 for: ${command}`).toBe(0);
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('blocks mutating checkout forms on protected branch', () => {
|
|
109
|
+
const blockedCommands = [
|
|
110
|
+
'git checkout -- README.md',
|
|
111
|
+
'git checkout HEAD -- README.md',
|
|
112
|
+
'git switch --detach HEAD',
|
|
113
|
+
];
|
|
114
|
+
for (const command of blockedCommands) {
|
|
115
|
+
const r = runHook(
|
|
116
|
+
'main-guard.mjs',
|
|
117
|
+
{ tool_name: 'Bash', tool_input: { command } },
|
|
118
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
119
|
+
);
|
|
120
|
+
expect(r.status, `expected exit 0 for: ${command}`).toBe(0);
|
|
121
|
+
const out = parseHookJson(r.stdout);
|
|
122
|
+
expect(out?.decision).toBe('block');
|
|
123
|
+
expect(out?.reason).toContain('Bash restricted');
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('allows touch .beads/.memory-gate-done on protected branch', () => {
|
|
128
|
+
const r = runHook(
|
|
129
|
+
'main-guard.mjs',
|
|
130
|
+
{ tool_name: 'Bash', tool_input: { command: 'touch .beads/.memory-gate-done' } },
|
|
131
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
132
|
+
);
|
|
133
|
+
expect(r.status).toBe(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('allows Bash when MAIN_GUARD_ALLOW_BASH=1 is set', () => {
|
|
137
|
+
const r = runHook(
|
|
138
|
+
'main-guard.mjs',
|
|
139
|
+
{ tool_name: 'Bash', tool_input: { command: 'npm run build' } },
|
|
140
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH, MAIN_GUARD_ALLOW_BASH: '1' },
|
|
141
|
+
);
|
|
142
|
+
expect(r.status).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('blocks git commit in Bash with workflow guidance', () => {
|
|
146
|
+
const r = runHook(
|
|
147
|
+
'main-guard.mjs',
|
|
148
|
+
{ tool_name: 'Bash', tool_input: { command: 'git commit -m "oops"' } },
|
|
149
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: CURRENT_BRANCH },
|
|
150
|
+
);
|
|
151
|
+
expect(r.status).toBe(0);
|
|
152
|
+
const out = parseHookJson(r.stdout);
|
|
153
|
+
expect(out?.decision).toBe('block');
|
|
154
|
+
expect(out?.reason).toContain('feature branch');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('post-push hook sync guidance uses reset --hard, consistent with main-guard', () => {
|
|
158
|
+
const postPush = readFileSync(path.join(HOOKS_DIR, 'main-guard-post-push.mjs'), 'utf8');
|
|
159
|
+
expect(postPush).toContain('reset --hard');
|
|
160
|
+
expect(postPush).not.toContain('pull --ff-only');
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('hooks.json wires Bash to main-guard so git commit protection fires', () => {
|
|
164
|
+
const hooksJson = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
|
|
165
|
+
const mainGuardEntries = hooksJson.hooks.PreToolUse.filter(
|
|
166
|
+
(h: { script: string }) => h.script === 'main-guard.mjs',
|
|
167
|
+
);
|
|
168
|
+
const matchers: string[] = mainGuardEntries.map((h: { matcher: string }) => h.matcher ?? '');
|
|
169
|
+
const coversBash = matchers.some((m: string) => m.split('|').includes('Bash'));
|
|
170
|
+
expect(coversBash, 'main-guard.mjs must have a PreToolUse entry with Bash in its matcher').toBe(true);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// ── main-guard-post-push.mjs ────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
describe('main-guard-post-push.mjs', () => {
|
|
178
|
+
function createTempGitRepo(branch: string): string {
|
|
179
|
+
const repoDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-post-push-'));
|
|
180
|
+
spawnSync('git', ['init'], { cwd: repoDir, stdio: 'pipe' });
|
|
181
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: repoDir, stdio: 'pipe' });
|
|
182
|
+
spawnSync('git', ['config', 'user.name', 'Test User'], { cwd: repoDir, stdio: 'pipe' });
|
|
183
|
+
writeFileSync(path.join(repoDir, 'README.md'), '# test\n', 'utf8');
|
|
184
|
+
spawnSync('git', ['add', 'README.md'], { cwd: repoDir, stdio: 'pipe' });
|
|
185
|
+
spawnSync('git', ['commit', '-m', 'init'], { cwd: repoDir, stdio: 'pipe' });
|
|
186
|
+
const current = spawnSync('git', ['branch', '--show-current'], { cwd: repoDir, encoding: 'utf8', stdio: 'pipe' }).stdout.trim();
|
|
187
|
+
if (current !== branch) {
|
|
188
|
+
spawnSync('git', ['checkout', '-B', branch], { cwd: repoDir, stdio: 'pipe' });
|
|
189
|
+
}
|
|
190
|
+
return repoDir;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
it('injects PR workflow reminder after successful feature-branch push command', () => {
|
|
194
|
+
const repoDir = createTempGitRepo('feature/test-push');
|
|
195
|
+
try {
|
|
196
|
+
const r = runHook(
|
|
197
|
+
'main-guard-post-push.mjs',
|
|
198
|
+
{ tool_name: 'Bash', tool_input: { command: 'git push -u origin feature/test-push' }, cwd: repoDir },
|
|
199
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
200
|
+
repoDir,
|
|
201
|
+
);
|
|
202
|
+
expect(r.status).toBe(0);
|
|
203
|
+
const out = parseHookJson(r.stdout);
|
|
204
|
+
expect(out?.additionalContext).toContain('gh pr create --fill');
|
|
205
|
+
expect(out?.additionalContext).toContain('gh pr merge --squash');
|
|
206
|
+
} finally {
|
|
207
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('does not emit reminder for non-push Bash commands', () => {
|
|
212
|
+
const repoDir = createTempGitRepo('feature/test-nopush');
|
|
213
|
+
try {
|
|
214
|
+
const r = runHook(
|
|
215
|
+
'main-guard-post-push.mjs',
|
|
216
|
+
{ tool_name: 'Bash', tool_input: { command: 'git status' }, cwd: repoDir },
|
|
217
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
218
|
+
repoDir,
|
|
219
|
+
);
|
|
220
|
+
expect(r.status).toBe(0);
|
|
221
|
+
expect(r.stdout.trim()).toBe('');
|
|
222
|
+
} finally {
|
|
223
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('does not emit reminder when current branch is protected', () => {
|
|
228
|
+
const repoDir = createTempGitRepo('main');
|
|
229
|
+
try {
|
|
230
|
+
const r = runHook(
|
|
231
|
+
'main-guard-post-push.mjs',
|
|
232
|
+
{ tool_name: 'Bash', tool_input: { command: 'git push -u origin main' }, cwd: repoDir },
|
|
233
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
234
|
+
repoDir,
|
|
235
|
+
);
|
|
236
|
+
expect(r.status).toBe(0);
|
|
237
|
+
expect(r.stdout.trim()).toBe('');
|
|
238
|
+
} finally {
|
|
239
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it('does not emit reminder when push command reports failure', () => {
|
|
244
|
+
const repoDir = createTempGitRepo('feature/test-failed-push');
|
|
245
|
+
try {
|
|
246
|
+
const r = runHook(
|
|
247
|
+
'main-guard-post-push.mjs',
|
|
248
|
+
{
|
|
249
|
+
tool_name: 'Bash',
|
|
250
|
+
tool_input: { command: 'git push -u origin feature/test-failed-push' },
|
|
251
|
+
tool_response: { exit_code: 1, stderr: 'remote rejected' },
|
|
252
|
+
cwd: repoDir,
|
|
253
|
+
},
|
|
254
|
+
{ MAIN_GUARD_PROTECTED_BRANCHES: 'main,master' },
|
|
255
|
+
repoDir,
|
|
256
|
+
);
|
|
257
|
+
expect(r.status).toBe(0);
|
|
258
|
+
expect(r.stdout.trim()).toBe('');
|
|
259
|
+
} finally {
|
|
260
|
+
rmSync(repoDir, { recursive: true, force: true });
|
|
261
|
+
}
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// ── beads-gate-utils.mjs ─────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
describe('beads-gate-utils.mjs — module integrity', () => {
|
|
268
|
+
it('exports all required symbols without crashing', () => {
|
|
269
|
+
const r = spawnSync('node', ['--input-type=module'], {
|
|
270
|
+
input: `
|
|
271
|
+
import {
|
|
272
|
+
resolveCwd, isBeadsProject, getSessionClaim,
|
|
273
|
+
getTotalWork, getInProgress, clearSessionClaim, withSafeBdContext
|
|
274
|
+
} from '${HOOKS_DIR}/beads-gate-utils.mjs';
|
|
275
|
+
const ok = [resolveCwd, isBeadsProject, getSessionClaim, getTotalWork,
|
|
276
|
+
getInProgress, clearSessionClaim, withSafeBdContext]
|
|
277
|
+
.every(fn => typeof fn === 'function');
|
|
278
|
+
process.exit(ok ? 0 : 1);
|
|
279
|
+
`,
|
|
280
|
+
encoding: 'utf8',
|
|
281
|
+
});
|
|
282
|
+
expect(r.status).toBe(0);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ── beads-edit-gate.mjs ───────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
describe('beads-edit-gate.mjs', () => {
|
|
289
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
290
|
+
const r = runHook('beads-edit-gate.mjs', {
|
|
291
|
+
session_id: 'test-session',
|
|
292
|
+
tool_name: 'Write',
|
|
293
|
+
tool_input: { file_path: '/tmp/x' },
|
|
294
|
+
cwd: '/tmp',
|
|
295
|
+
});
|
|
296
|
+
expect(r.status).toBe(0);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('imports from beads-gate-utils.mjs (no inline duplicate logic)', () => {
|
|
300
|
+
const content = readFileSync(path.join(HOOKS_DIR, 'beads-edit-gate.mjs'), 'utf8');
|
|
301
|
+
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('blocks when session has no claim but open issues exist (regression guard)', () => {
|
|
305
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-project-'));
|
|
306
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
307
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
308
|
+
set -euo pipefail
|
|
309
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
310
|
+
exit 1
|
|
311
|
+
fi
|
|
312
|
+
if [[ "$1" == "list" ]]; then
|
|
313
|
+
cat <<'EOF'
|
|
314
|
+
○ issue-1 P2 Open issue
|
|
315
|
+
|
|
316
|
+
--------------------------------------------------------------------------------
|
|
317
|
+
Total: 1 issues (1 open, 0 in progress)
|
|
318
|
+
EOF
|
|
319
|
+
exit 0
|
|
320
|
+
fi
|
|
321
|
+
exit 1
|
|
322
|
+
`);
|
|
323
|
+
|
|
324
|
+
try {
|
|
325
|
+
const r = runHook(
|
|
326
|
+
'beads-edit-gate.mjs',
|
|
327
|
+
{
|
|
328
|
+
session_id: 'session-regression-test',
|
|
329
|
+
tool_name: 'Write',
|
|
330
|
+
tool_input: { file_path: '/tmp/x' },
|
|
331
|
+
cwd: projectDir,
|
|
332
|
+
},
|
|
333
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
334
|
+
);
|
|
335
|
+
expect(r.status).toBe(0);
|
|
336
|
+
const out = parseHookJson(r.stdout);
|
|
337
|
+
expect(out?.decision).toBe('block');
|
|
338
|
+
expect(out?.reason).toContain('active claim');
|
|
339
|
+
} finally {
|
|
340
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
341
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
// ── beads-stop-gate.mjs ───────────────────────────────────────────────────────
|
|
347
|
+
|
|
348
|
+
describe('beads-stop-gate.mjs', () => {
|
|
349
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
350
|
+
const r = runHook('beads-stop-gate.mjs', { session_id: 'test', cwd: '/tmp' });
|
|
351
|
+
expect(r.status).toBe(0);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('imports from beads-gate-utils.mjs', () => {
|
|
355
|
+
const content = readFileSync(path.join(HOOKS_DIR, 'beads-stop-gate.mjs'), 'utf8');
|
|
356
|
+
expect(content).toContain("from './beads-gate-utils.mjs'");
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('allows stop (exit 0) when session has a stale claim but no in_progress issues', () => {
|
|
360
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-stopgate-'));
|
|
361
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
362
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
363
|
+
set -euo pipefail
|
|
364
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
365
|
+
echo "jaggers-stale-claim"
|
|
366
|
+
exit 0
|
|
367
|
+
fi
|
|
368
|
+
if [[ "$1" == "list" ]]; then
|
|
369
|
+
cat <<'EOF'
|
|
370
|
+
|
|
371
|
+
--------------------------------------------------------------------------------
|
|
372
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
373
|
+
EOF
|
|
374
|
+
exit 0
|
|
375
|
+
fi
|
|
376
|
+
exit 1
|
|
377
|
+
`);
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const r = runHook(
|
|
381
|
+
'beads-stop-gate.mjs',
|
|
382
|
+
{ session_id: 'session-stale-claim', cwd: projectDir },
|
|
383
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
384
|
+
);
|
|
385
|
+
expect(r.status).toBe(0);
|
|
386
|
+
} finally {
|
|
387
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
388
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
389
|
+
}
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('blocks stop when session state phase is waiting-merge', () => {
|
|
393
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-stopgate-state-'));
|
|
394
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
395
|
+
writeFileSync(path.join(projectDir, '.xtrm-session-state.json'), JSON.stringify({
|
|
396
|
+
issueId: 'issue-123',
|
|
397
|
+
branch: 'feature/issue-123',
|
|
398
|
+
worktreePath: '/tmp/worktrees/issue-123',
|
|
399
|
+
prNumber: 77,
|
|
400
|
+
prUrl: 'https://example.invalid/pr/77',
|
|
401
|
+
phase: 'waiting-merge',
|
|
402
|
+
conflictFiles: [],
|
|
403
|
+
startedAt: new Date().toISOString(),
|
|
404
|
+
lastChecked: new Date().toISOString(),
|
|
405
|
+
}), 'utf8');
|
|
406
|
+
|
|
407
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
408
|
+
set -euo pipefail
|
|
409
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
410
|
+
exit 1
|
|
411
|
+
fi
|
|
412
|
+
if [[ "$1" == "list" ]]; then
|
|
413
|
+
cat <<'EOF'
|
|
414
|
+
|
|
415
|
+
--------------------------------------------------------------------------------
|
|
416
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
417
|
+
EOF
|
|
418
|
+
exit 0
|
|
419
|
+
fi
|
|
420
|
+
exit 1
|
|
421
|
+
`);
|
|
422
|
+
|
|
423
|
+
try {
|
|
424
|
+
const r = runHook(
|
|
425
|
+
'beads-stop-gate.mjs',
|
|
426
|
+
{ session_id: 'session-waiting-merge', cwd: projectDir },
|
|
427
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
428
|
+
);
|
|
429
|
+
expect(r.status).toBe(2);
|
|
430
|
+
expect(r.stderr).toContain('xtrm finish');
|
|
431
|
+
expect(r.stderr).toContain('#77');
|
|
432
|
+
} finally {
|
|
433
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
434
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it('blocks stop when session state phase is conflicting', () => {
|
|
439
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-beads-stopgate-state-'));
|
|
440
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
441
|
+
writeFileSync(path.join(projectDir, '.xtrm-session-state.json'), JSON.stringify({
|
|
442
|
+
issueId: 'issue-123',
|
|
443
|
+
branch: 'feature/issue-123',
|
|
444
|
+
worktreePath: '/tmp/worktrees/issue-123',
|
|
445
|
+
prNumber: 77,
|
|
446
|
+
prUrl: 'https://example.invalid/pr/77',
|
|
447
|
+
phase: 'conflicting',
|
|
448
|
+
conflictFiles: ['src/a.ts', 'src/b.ts'],
|
|
449
|
+
startedAt: new Date().toISOString(),
|
|
450
|
+
lastChecked: new Date().toISOString(),
|
|
451
|
+
}), 'utf8');
|
|
452
|
+
|
|
453
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
454
|
+
set -euo pipefail
|
|
455
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
456
|
+
exit 1
|
|
457
|
+
fi
|
|
458
|
+
if [[ "$1" == "list" ]]; then
|
|
459
|
+
cat <<'EOF'
|
|
460
|
+
|
|
461
|
+
--------------------------------------------------------------------------------
|
|
462
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
463
|
+
EOF
|
|
464
|
+
exit 0
|
|
465
|
+
fi
|
|
466
|
+
exit 1
|
|
467
|
+
`);
|
|
468
|
+
|
|
469
|
+
try {
|
|
470
|
+
const r = runHook(
|
|
471
|
+
'beads-stop-gate.mjs',
|
|
472
|
+
{ session_id: 'session-conflicting', cwd: projectDir },
|
|
473
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
474
|
+
);
|
|
475
|
+
expect(r.status).toBe(2);
|
|
476
|
+
expect(r.stderr).toContain('src/a.ts');
|
|
477
|
+
expect(r.stderr).toContain('xtrm finish');
|
|
478
|
+
} finally {
|
|
479
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
480
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
481
|
+
}
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
describe('beads-memory-gate.mjs', () => {
|
|
487
|
+
it('fails open (exit 0) when no .beads directory exists', () => {
|
|
488
|
+
const r = runHook('beads-memory-gate.mjs', { session_id: 'test', cwd: '/tmp' });
|
|
489
|
+
expect(r.status).toBe(0);
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
it('allows stop (exit 0) when marker file exists', () => {
|
|
493
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
494
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
495
|
+
writeFileSync(path.join(projectDir, '.beads', '.memory-gate-done'), '');
|
|
496
|
+
try {
|
|
497
|
+
const r = runHook('beads-memory-gate.mjs', { session_id: 'test', cwd: projectDir });
|
|
498
|
+
expect(r.status).toBe(0);
|
|
499
|
+
} finally {
|
|
500
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
it('allows stop (exit 0) when no closed issues exist', () => {
|
|
505
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
506
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
507
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
508
|
+
set -euo pipefail
|
|
509
|
+
if [[ "$1" == "list" ]]; then
|
|
510
|
+
cat <<'EOF'
|
|
511
|
+
|
|
512
|
+
--------------------------------------------------------------------------------
|
|
513
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
514
|
+
EOF
|
|
515
|
+
exit 0
|
|
516
|
+
fi
|
|
517
|
+
exit 1
|
|
518
|
+
`);
|
|
519
|
+
try {
|
|
520
|
+
const r = runHook(
|
|
521
|
+
'beads-memory-gate.mjs',
|
|
522
|
+
{ session_id: 'test', cwd: projectDir },
|
|
523
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
524
|
+
);
|
|
525
|
+
expect(r.status).toBe(0);
|
|
526
|
+
} finally {
|
|
527
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
528
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('allows stop (exit 0) when closed issues exist but no session claim', () => {
|
|
533
|
+
// New behaviour: closed issues alone don't trigger the gate — session must have a claim
|
|
534
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
535
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
536
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
537
|
+
set -euo pipefail
|
|
538
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
539
|
+
exit 1 # no claim stored
|
|
540
|
+
fi
|
|
541
|
+
if [[ "$1" == "list" ]]; then
|
|
542
|
+
cat <<'EOF'
|
|
543
|
+
✓ issue-abc P2 Fix the thing
|
|
544
|
+
|
|
545
|
+
--------------------------------------------------------------------------------
|
|
546
|
+
Total: 1 issues (0 open, 0 in progress, 1 closed)
|
|
547
|
+
EOF
|
|
548
|
+
exit 0
|
|
549
|
+
fi
|
|
550
|
+
exit 1
|
|
551
|
+
`);
|
|
552
|
+
try {
|
|
553
|
+
const r = runHook(
|
|
554
|
+
'beads-memory-gate.mjs',
|
|
555
|
+
{ session_id: 'test', cwd: projectDir },
|
|
556
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
557
|
+
);
|
|
558
|
+
expect(r.status).toBe(0);
|
|
559
|
+
} finally {
|
|
560
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
561
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it('blocks stop (exit 2) when session claim was closed this session', () => {
|
|
566
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-memgate-'));
|
|
567
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
568
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
569
|
+
set -euo pipefail
|
|
570
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then
|
|
571
|
+
echo "issue-abc"
|
|
572
|
+
exit 0
|
|
573
|
+
fi
|
|
574
|
+
if [[ "$1" == "list" ]]; then
|
|
575
|
+
cat <<'EOF'
|
|
576
|
+
✓ issue-abc P2 Fix the thing
|
|
577
|
+
|
|
578
|
+
--------------------------------------------------------------------------------
|
|
579
|
+
Total: 1 issues (0 open, 0 in progress, 1 closed)
|
|
580
|
+
EOF
|
|
581
|
+
exit 0
|
|
582
|
+
fi
|
|
583
|
+
exit 1
|
|
584
|
+
`);
|
|
585
|
+
try {
|
|
586
|
+
const r = runHook(
|
|
587
|
+
'beads-memory-gate.mjs',
|
|
588
|
+
{ session_id: 'test', cwd: projectDir },
|
|
589
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
590
|
+
);
|
|
591
|
+
expect(r.status).toBe(2);
|
|
592
|
+
expect(r.stderr).toContain('Memory gate');
|
|
593
|
+
} finally {
|
|
594
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
595
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
// ── tdd-guard-pretool-bridge.cjs ─────────────────────────────────────────────
|
|
602
|
+
|
|
603
|
+
const TDD_BRIDGE_DIR = path.join(__dirname, '../../project-skills/tdd-guard/.claude/hooks');
|
|
604
|
+
|
|
605
|
+
describe('tdd-guard-pretool-bridge.cjs', () => {
|
|
606
|
+
it('does not forward tdd-guard stderr when stdout already contains the message', () => {
|
|
607
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-'));
|
|
608
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
609
|
+
// Simulate tdd-guard writing the same message to both stdout and stderr (the bug)
|
|
610
|
+
writeFileSync(fakeBin, `#!/usr/bin/env bash\nMSG='{"reason":"Premature implementation"}'\necho "$MSG"\necho "$MSG" >&2\nexit 2\n`, { encoding: 'utf8' });
|
|
611
|
+
chmodSync(fakeBin, 0o755);
|
|
612
|
+
|
|
613
|
+
try {
|
|
614
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
615
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.ts' } }),
|
|
616
|
+
encoding: 'utf8',
|
|
617
|
+
env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
|
|
618
|
+
});
|
|
619
|
+
expect(r.stderr).toBe('');
|
|
620
|
+
} finally {
|
|
621
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
622
|
+
}
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('forces sdk validation client and strips api-mode env vars', () => {
|
|
626
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-env-'));
|
|
627
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
628
|
+
writeFileSync(
|
|
629
|
+
fakeBin,
|
|
630
|
+
`#!/usr/bin/env bash
|
|
631
|
+
if [[ "$VALIDATION_CLIENT" != "sdk" ]]; then exit 12; fi
|
|
632
|
+
if [[ -n "\${MODEL_TYPE:-}" ]]; then exit 13; fi
|
|
633
|
+
if [[ -n "\${TDD_GUARD_ANTHROPIC_API_KEY:-}" ]]; then exit 14; fi
|
|
634
|
+
if [[ -n "\${ANTHROPIC_API_KEY:-}" ]]; then exit 15; fi
|
|
635
|
+
if [[ -n "\${ANTHROPIC_BASE_URL:-}" ]]; then exit 16; fi
|
|
636
|
+
exit 0
|
|
637
|
+
`,
|
|
638
|
+
{ encoding: 'utf8' },
|
|
639
|
+
);
|
|
640
|
+
chmodSync(fakeBin, 0o755);
|
|
641
|
+
|
|
642
|
+
try {
|
|
643
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
644
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
|
|
645
|
+
encoding: 'utf8',
|
|
646
|
+
env: {
|
|
647
|
+
...process.env,
|
|
648
|
+
PATH: `${fakeDir}:${process.env.PATH ?? ''}`,
|
|
649
|
+
VALIDATION_CLIENT: 'api',
|
|
650
|
+
MODEL_TYPE: 'anthropic_api',
|
|
651
|
+
TDD_GUARD_ANTHROPIC_API_KEY: 'x',
|
|
652
|
+
ANTHROPIC_API_KEY: 'y',
|
|
653
|
+
ANTHROPIC_BASE_URL: 'https://example.invalid',
|
|
654
|
+
},
|
|
655
|
+
});
|
|
656
|
+
expect(r.status).toBe(0);
|
|
657
|
+
} finally {
|
|
658
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
it('fails open for known API response JSON-parse errors', () => {
|
|
663
|
+
const fakeDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fake-tddguard-apierr-'));
|
|
664
|
+
const fakeBin = path.join(fakeDir, 'tdd-guard');
|
|
665
|
+
writeFileSync(
|
|
666
|
+
fakeBin,
|
|
667
|
+
`#!/usr/bin/env bash
|
|
668
|
+
echo 'Error during validation: Unexpected token '\\''A'\\'', "API Error:"... is not valid JSON'
|
|
669
|
+
exit 2
|
|
670
|
+
`,
|
|
671
|
+
{ encoding: 'utf8' },
|
|
672
|
+
);
|
|
673
|
+
chmodSync(fakeBin, 0o755);
|
|
674
|
+
|
|
675
|
+
try {
|
|
676
|
+
const r = spawnSync('node', [path.join(TDD_BRIDGE_DIR, 'tdd-guard-pretool-bridge.cjs')], {
|
|
677
|
+
input: JSON.stringify({ tool_name: 'Write', tool_input: { file_path: 'test.py' } }),
|
|
678
|
+
encoding: 'utf8',
|
|
679
|
+
env: { ...process.env, PATH: `${fakeDir}:${process.env.PATH ?? ''}` },
|
|
680
|
+
});
|
|
681
|
+
expect(r.status).toBe(0);
|
|
682
|
+
} finally {
|
|
683
|
+
rmSync(fakeDir, { recursive: true, force: true });
|
|
684
|
+
}
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
// ── beads-gate-core.mjs — decision functions ──────────────────────────────────
|
|
691
|
+
|
|
692
|
+
describe('beads-gate-core.mjs — decision functions', () => {
|
|
693
|
+
const corePath = path.join(HOOKS_DIR, 'beads-gate-core.mjs');
|
|
694
|
+
|
|
695
|
+
it('exports all required decision functions', () => {
|
|
696
|
+
const r = spawnSync('node', ['--input-type=module'], {
|
|
697
|
+
input: `
|
|
698
|
+
import {
|
|
699
|
+
readHookInput,
|
|
700
|
+
resolveSessionContext,
|
|
701
|
+
resolveClaimAndWorkState,
|
|
702
|
+
decideEditGate,
|
|
703
|
+
decideCommitGate,
|
|
704
|
+
decideStopGate,
|
|
705
|
+
} from '${corePath}';
|
|
706
|
+
const ok = [readHookInput, resolveSessionContext, resolveClaimAndWorkState,
|
|
707
|
+
decideEditGate, decideCommitGate, decideStopGate]
|
|
708
|
+
.every(fn => typeof fn === 'function');
|
|
709
|
+
process.exit(ok ? 0 : 1);
|
|
710
|
+
`,
|
|
711
|
+
encoding: 'utf8',
|
|
712
|
+
});
|
|
713
|
+
expect(r.status).toBe(0);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
describe('decideEditGate', () => {
|
|
717
|
+
it('allows when not a beads project', async () => {
|
|
718
|
+
const { decideEditGate } = await import(corePath);
|
|
719
|
+
const decision = decideEditGate(
|
|
720
|
+
{ isBeadsProject: false },
|
|
721
|
+
{ claimed: false, claimId: null, totalWork: 0, inProgress: null }
|
|
722
|
+
);
|
|
723
|
+
expect(decision.allow).toBe(true);
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it('allows when session has a claim', async () => {
|
|
727
|
+
const { decideEditGate } = await import(corePath);
|
|
728
|
+
const decision = decideEditGate(
|
|
729
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
730
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 5, inProgress: null }
|
|
731
|
+
);
|
|
732
|
+
expect(decision.allow).toBe(true);
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
it('allows when no trackable work exists', async () => {
|
|
736
|
+
const { decideEditGate } = await import(corePath);
|
|
737
|
+
const decision = decideEditGate(
|
|
738
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
739
|
+
{ claimed: false, claimId: null, totalWork: 0, inProgress: null }
|
|
740
|
+
);
|
|
741
|
+
expect(decision.allow).toBe(true);
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
it('blocks when no claim but work exists', async () => {
|
|
745
|
+
const { decideEditGate } = await import(corePath);
|
|
746
|
+
const decision = decideEditGate(
|
|
747
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
748
|
+
{ claimed: false, claimId: null, totalWork: 3, inProgress: null }
|
|
749
|
+
);
|
|
750
|
+
expect(decision.allow).toBe(false);
|
|
751
|
+
expect(decision.reason).toBe('no_claim_with_work');
|
|
752
|
+
});
|
|
753
|
+
|
|
754
|
+
it('fails open when bd unavailable (state is null)', async () => {
|
|
755
|
+
const { decideEditGate } = await import(corePath);
|
|
756
|
+
const decision = decideEditGate(
|
|
757
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
758
|
+
null
|
|
759
|
+
);
|
|
760
|
+
expect(decision.allow).toBe(true);
|
|
761
|
+
});
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
describe('decideCommitGate', () => {
|
|
765
|
+
it('allows when no active claim', async () => {
|
|
766
|
+
const { decideCommitGate } = await import(corePath);
|
|
767
|
+
const decision = decideCommitGate(
|
|
768
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
769
|
+
{ claimed: false, claimId: null, totalWork: 3, inProgress: { count: 1, summary: 'test' } }
|
|
770
|
+
);
|
|
771
|
+
expect(decision.allow).toBe(true);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it('blocks when session has unclosed claim', async () => {
|
|
775
|
+
const { decideCommitGate } = await import(corePath);
|
|
776
|
+
const decision = decideCommitGate(
|
|
777
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
778
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 1, summary: 'test' } }
|
|
779
|
+
);
|
|
780
|
+
expect(decision.allow).toBe(false);
|
|
781
|
+
expect(decision.reason).toBe('unclosed_claim');
|
|
782
|
+
});
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
describe('decideStopGate', () => {
|
|
786
|
+
it('allows when claim is stale (no in_progress issues)', async () => {
|
|
787
|
+
const { decideStopGate } = await import(corePath);
|
|
788
|
+
const decision = decideStopGate(
|
|
789
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
790
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 0, summary: '' } }
|
|
791
|
+
);
|
|
792
|
+
expect(decision.allow).toBe(true);
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
it('blocks when claim exists and in_progress issues remain', async () => {
|
|
796
|
+
const { decideStopGate } = await import(corePath);
|
|
797
|
+
const decision = decideStopGate(
|
|
798
|
+
{ isBeadsProject: true, sessionId: 'test-session' },
|
|
799
|
+
{ claimed: true, claimId: 'issue-123', totalWork: 3, inProgress: { count: 2, summary: 'test' } }
|
|
800
|
+
);
|
|
801
|
+
expect(decision.allow).toBe(false);
|
|
802
|
+
expect(decision.reason).toBe('unclosed_claim');
|
|
803
|
+
});
|
|
804
|
+
});
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
|
|
808
|
+
// ── beads-compact-save.mjs ───────────────────────────────────────────────────
|
|
809
|
+
describe('beads-compact-save.mjs', () => {
|
|
810
|
+
it('exits 0 silently when no .beads directory exists', () => {
|
|
811
|
+
const r = runHook('beads-compact-save.mjs', { hook_event_name: 'PreCompact', cwd: '/tmp' });
|
|
812
|
+
expect(r.status).toBe(0);
|
|
813
|
+
expect(r.stdout).toBe('');
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
it('exits 0 silently and writes no file when no in_progress issues', () => {
|
|
817
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-save-'));
|
|
818
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
819
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
820
|
+
if [[ "$1" == "list" ]]; then
|
|
821
|
+
echo ""
|
|
822
|
+
echo "--------------------------------------------------------------------------------"
|
|
823
|
+
echo "Total: 2 issues (2 open, 0 in progress)"
|
|
824
|
+
exit 0
|
|
825
|
+
fi
|
|
826
|
+
exit 1
|
|
827
|
+
`);
|
|
828
|
+
try {
|
|
829
|
+
const r = runHook(
|
|
830
|
+
'beads-compact-save.mjs',
|
|
831
|
+
{ hook_event_name: 'PreCompact', cwd: projectDir },
|
|
832
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
833
|
+
);
|
|
834
|
+
expect(r.status).toBe(0);
|
|
835
|
+
const { existsSync } = require('node:fs');
|
|
836
|
+
expect(existsSync(path.join(projectDir, '.beads', '.last_active'))).toBe(false);
|
|
837
|
+
} finally {
|
|
838
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
839
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
it('writes .beads/.last_active JSON bundle with in_progress issue IDs', () => {
|
|
844
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-save-'));
|
|
845
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
846
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
847
|
+
if [[ "$1" == "list" ]]; then
|
|
848
|
+
cat <<'EOF'
|
|
849
|
+
◐ proj-abc123 ● P1 First in_progress issue
|
|
850
|
+
◐ proj-def456 ● P2 Second in_progress issue
|
|
851
|
+
|
|
852
|
+
--------------------------------------------------------------------------------
|
|
853
|
+
Total: 2 issues (0 open, 2 in progress)
|
|
854
|
+
EOF
|
|
855
|
+
exit 0
|
|
856
|
+
fi
|
|
857
|
+
exit 1
|
|
858
|
+
`);
|
|
859
|
+
try {
|
|
860
|
+
const r = runHook(
|
|
861
|
+
'beads-compact-save.mjs',
|
|
862
|
+
{ hook_event_name: 'PreCompact', cwd: projectDir },
|
|
863
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
864
|
+
);
|
|
865
|
+
expect(r.status).toBe(0);
|
|
866
|
+
const { readFileSync: rfs } = require('node:fs');
|
|
867
|
+
const saved = JSON.parse(rfs(path.join(projectDir, '.beads', '.last_active'), 'utf8'));
|
|
868
|
+
expect(saved.ids).toContain('proj-abc123');
|
|
869
|
+
expect(saved.ids).toContain('proj-def456');
|
|
870
|
+
expect(saved.sessionState).toBeNull();
|
|
871
|
+
} finally {
|
|
872
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
873
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
874
|
+
}
|
|
875
|
+
});
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
// ── beads-compact-restore.mjs ────────────────────────────────────────────────
|
|
879
|
+
describe('beads-compact-restore.mjs', () => {
|
|
880
|
+
it('exits 0 silently when no .beads/.last_active file exists', () => {
|
|
881
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-restore-'));
|
|
882
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
883
|
+
try {
|
|
884
|
+
const r = runHook(
|
|
885
|
+
'beads-compact-restore.mjs',
|
|
886
|
+
{ hook_event_name: 'SessionStart', cwd: projectDir },
|
|
887
|
+
);
|
|
888
|
+
expect(r.status).toBe(0);
|
|
889
|
+
expect(r.stdout).toBe('');
|
|
890
|
+
} finally {
|
|
891
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
|
|
895
|
+
it('restores in_progress status + session state bundle and injects additionalSystemPrompt', () => {
|
|
896
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-compact-restore-'));
|
|
897
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
898
|
+
const { writeFileSync: wfs } = require('node:fs');
|
|
899
|
+
wfs(
|
|
900
|
+
path.join(projectDir, '.beads', '.last_active'),
|
|
901
|
+
JSON.stringify({
|
|
902
|
+
ids: ['proj-abc123', 'proj-def456'],
|
|
903
|
+
sessionState: {
|
|
904
|
+
issueId: 'proj-abc123',
|
|
905
|
+
branch: 'feature/proj-abc123',
|
|
906
|
+
worktreePath: '/tmp/worktrees/proj-abc123',
|
|
907
|
+
prNumber: 42,
|
|
908
|
+
prUrl: 'https://github.com/example/repo/pull/42',
|
|
909
|
+
phase: 'waiting-merge',
|
|
910
|
+
conflictFiles: [],
|
|
911
|
+
startedAt: new Date().toISOString(),
|
|
912
|
+
lastChecked: new Date().toISOString(),
|
|
913
|
+
},
|
|
914
|
+
}),
|
|
915
|
+
'utf8',
|
|
916
|
+
);
|
|
917
|
+
|
|
918
|
+
const callLog = path.join(projectDir, 'bd-calls.log');
|
|
919
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
920
|
+
echo "$@" >> "${callLog}"
|
|
921
|
+
exit 0
|
|
922
|
+
`);
|
|
923
|
+
try {
|
|
924
|
+
const r = runHook(
|
|
925
|
+
'beads-compact-restore.mjs',
|
|
926
|
+
{ hook_event_name: 'SessionStart', cwd: projectDir },
|
|
927
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
928
|
+
);
|
|
929
|
+
expect(r.status).toBe(0);
|
|
930
|
+
// .last_active must be deleted
|
|
931
|
+
const { existsSync: exs, readFileSync: rfs } = require('node:fs');
|
|
932
|
+
expect(exs(path.join(projectDir, '.beads', '.last_active'))).toBe(false);
|
|
933
|
+
// bd update called for each ID
|
|
934
|
+
const calls = rfs(callLog, 'utf8');
|
|
935
|
+
expect(calls).toContain('proj-abc123');
|
|
936
|
+
expect(calls).toContain('proj-def456');
|
|
937
|
+
// session state restored
|
|
938
|
+
const restoredState = JSON.parse(rfs(path.join(projectDir, '.xtrm-session-state.json'), 'utf8'));
|
|
939
|
+
expect(restoredState.phase).toBe('waiting-merge');
|
|
940
|
+
expect(restoredState.prNumber).toBe(42);
|
|
941
|
+
// additionalSystemPrompt injected for agent
|
|
942
|
+
const out = parseHookJson(r.stdout);
|
|
943
|
+
expect(out?.hookSpecificOutput?.additionalSystemPrompt).toMatch(/Restored 2 in_progress issue/);
|
|
944
|
+
expect(out?.hookSpecificOutput?.additionalSystemPrompt).toMatch(/RESUME: Run xtrm finish/);
|
|
945
|
+
} finally {
|
|
946
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
947
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
});
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
// ── hooks.json wiring ────────────────────────────────────────────────────────
|
|
954
|
+
describe('hooks.json — beads-compact hooks wiring', () => {
|
|
955
|
+
it('wires beads-compact-save.mjs to PreCompact event', () => {
|
|
956
|
+
const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
|
|
957
|
+
const preCompact: Array<{ script: string }> = cfg.hooks.PreCompact ?? [];
|
|
958
|
+
expect(preCompact.some((h) => h.script === 'beads-compact-save.mjs')).toBe(true);
|
|
959
|
+
});
|
|
960
|
+
|
|
961
|
+
it('wires beads-compact-restore.mjs to SessionStart event', () => {
|
|
962
|
+
const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
|
|
963
|
+
const sessionStart: Array<{ script: string }> = cfg.hooks.SessionStart ?? [];
|
|
964
|
+
expect(sessionStart.some((h) => h.script === 'beads-compact-restore.mjs')).toBe(true);
|
|
965
|
+
});
|
|
966
|
+
});
|
|
967
|
+
|
|
968
|
+
// ── service-skills.ts — no tool_call territory activation ─────────────────
|
|
969
|
+
describe('service-skills.ts — no tool_call territory activation', () => {
|
|
970
|
+
it('does not register a tool_call handler (fires Python on every tool)', () => {
|
|
971
|
+
const src = readFileSync(
|
|
972
|
+
path.join(__dirname, '../../config/pi/extensions/service-skills.ts'),
|
|
973
|
+
'utf8',
|
|
974
|
+
);
|
|
975
|
+
expect(
|
|
976
|
+
src,
|
|
977
|
+
'service-skills.ts must not use pi.on("tool_call") — fires Python on every tool invocation',
|
|
978
|
+
).not.toContain('pi.on("tool_call"');
|
|
979
|
+
});
|
|
980
|
+
});
|
|
981
|
+
|
|
982
|
+
// ── beads-claim-sync.mjs — claim/close session lifecycle ───────────────
|
|
983
|
+
describe('beads-claim-sync.mjs — claim/close session lifecycle', () => {
|
|
984
|
+
it('creates worktree and session state on bd claim', () => {
|
|
985
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-claimsync-claim-'));
|
|
986
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
987
|
+
|
|
988
|
+
spawnSync('git', ['init'], { cwd: projectDir, stdio: 'pipe' });
|
|
989
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: projectDir, stdio: 'pipe' });
|
|
990
|
+
spawnSync('git', ['config', 'user.name', 'Test User'], { cwd: projectDir, stdio: 'pipe' });
|
|
991
|
+
writeFileSync(path.join(projectDir, 'README.md'), '# test\n', 'utf8');
|
|
992
|
+
spawnSync('git', ['add', 'README.md'], { cwd: projectDir, stdio: 'pipe' });
|
|
993
|
+
spawnSync('git', ['commit', '-m', 'init'], { cwd: projectDir, stdio: 'pipe' });
|
|
994
|
+
|
|
995
|
+
const fake = withFakeBdDir('#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$1" == "kv" && "$2" == "set" ]]; then exit 0; fi\nif [[ "$1" == "kv" && "$2" == "clear" ]]; then exit 0; fi\nexit 0\n');
|
|
996
|
+
try {
|
|
997
|
+
const r = runHook(
|
|
998
|
+
'beads-claim-sync.mjs',
|
|
999
|
+
{
|
|
1000
|
+
hook_event_name: 'PostToolUse',
|
|
1001
|
+
tool_name: 'Bash',
|
|
1002
|
+
tool_input: { command: 'bd update jaggers-test-001 --claim' },
|
|
1003
|
+
session_id: 'claim-test-session',
|
|
1004
|
+
cwd: projectDir,
|
|
1005
|
+
},
|
|
1006
|
+
{ PATH: fake.tempDir + ':' + (process.env.PATH || '') },
|
|
1007
|
+
);
|
|
1008
|
+
expect(r.status).toBe(0);
|
|
1009
|
+
const out = parseHookJson(r.stdout);
|
|
1010
|
+
expect(out?.additionalContext).toContain('claimed issue');
|
|
1011
|
+
expect(out?.additionalContext).toContain('Worktree');
|
|
1012
|
+
|
|
1013
|
+
const stateFile = path.join(projectDir, '.xtrm-session-state.json');
|
|
1014
|
+
const state = JSON.parse(readFileSync(stateFile, 'utf8'));
|
|
1015
|
+
expect(state.issueId).toBe('jaggers-test-001');
|
|
1016
|
+
expect(state.branch).toBe('feature/jaggers-test-001');
|
|
1017
|
+
expect(state.phase).toBe('claimed');
|
|
1018
|
+
const { existsSync } = require('node:fs');
|
|
1019
|
+
expect(existsSync(state.worktreePath)).toBe(true);
|
|
1020
|
+
} finally {
|
|
1021
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
1022
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
1023
|
+
}
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
it('clears the session kv claim when bd close runs successfully', () => {
|
|
1027
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-claimsync-close-'));
|
|
1028
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
1029
|
+
const fake = withFakeBdDir('#!/usr/bin/env bash\nset -euo pipefail\nexit 0\n');
|
|
1030
|
+
try {
|
|
1031
|
+
const r = runHook(
|
|
1032
|
+
'beads-claim-sync.mjs',
|
|
1033
|
+
{
|
|
1034
|
+
hook_event_name: 'PostToolUse',
|
|
1035
|
+
tool_name: 'Bash',
|
|
1036
|
+
tool_input: { command: 'bd close jaggers-test-001' },
|
|
1037
|
+
session_id: 'close-test-session',
|
|
1038
|
+
cwd: projectDir,
|
|
1039
|
+
},
|
|
1040
|
+
{ PATH: fake.tempDir + ':' + (process.env.PATH || '') },
|
|
1041
|
+
);
|
|
1042
|
+
expect(r.status).toBe(0);
|
|
1043
|
+
const out = parseHookJson(r.stdout);
|
|
1044
|
+
expect(out?.additionalContext).toMatch(/claim.*clear|clear.*claim|released|cleared/i);
|
|
1045
|
+
} finally {
|
|
1046
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
1047
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
1048
|
+
}
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
|
|
1052
|
+
// ── branch-state.mjs — UserPromptSubmit hook ─────────────────────
|
|
1053
|
+
describe('branch-state.mjs — UserPromptSubmit', () => {
|
|
1054
|
+
it('exits 0 silently when not in a git repo', () => {
|
|
1055
|
+
const r = runHook('branch-state.mjs', { hook_event_name: 'UserPromptSubmit', cwd: '/tmp' });
|
|
1056
|
+
expect(r.status).toBe(0);
|
|
1057
|
+
expect(r.stdout.trim()).toBe('');
|
|
1058
|
+
});
|
|
1059
|
+
|
|
1060
|
+
it('injects branch into additionalSystemPrompt', () => {
|
|
1061
|
+
const r = runHook(
|
|
1062
|
+
'branch-state.mjs',
|
|
1063
|
+
{ hook_event_name: 'UserPromptSubmit', session_id: 'test-session' },
|
|
1064
|
+
);
|
|
1065
|
+
expect(r.status).toBe(0);
|
|
1066
|
+
const out = parseHookJson(r.stdout);
|
|
1067
|
+
expect(out?.hookSpecificOutput?.additionalSystemPrompt).toMatch(/branch=/);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
it('is wired to UserPromptSubmit in hooks.json', () => {
|
|
1071
|
+
const cfg = JSON.parse(readFileSync(path.join(__dirname, '../../config/hooks.json'), 'utf8'));
|
|
1072
|
+
const ups: Array<{ script?: string }> = cfg.hooks.UserPromptSubmit ?? [];
|
|
1073
|
+
expect(ups.some((h) => h.script === 'branch-state.mjs')).toBe(true);
|
|
1074
|
+
});
|
|
1075
|
+
});
|