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
package/src/index.ts
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import kleur from 'kleur';
|
|
5
|
+
|
|
6
|
+
// __dirname is available in CJS output (tsup target: cjs)
|
|
7
|
+
declare const __dirname: string;
|
|
8
|
+
let version = '0.0.0';
|
|
9
|
+
try { version = JSON.parse(readFileSync(resolve(__dirname, '../package.json'), 'utf8')).version; } catch { /* fallback */ }
|
|
10
|
+
|
|
11
|
+
import { createInstallCommand } from './commands/install.js';
|
|
12
|
+
import { createClaudeCommand } from './commands/claude.js';
|
|
13
|
+
import { createPiCommand } from './commands/pi.js';
|
|
14
|
+
import { runProjectInit } from './commands/init.js';
|
|
15
|
+
import { createStatusCommand } from './commands/status.js';
|
|
16
|
+
import { createResetCommand } from './commands/reset.js';
|
|
17
|
+
import { createHelpCommand } from './commands/help.js';
|
|
18
|
+
import { createCleanCommand } from './commands/clean.js';
|
|
19
|
+
import { createFinishCommand } from './commands/finish.js';
|
|
20
|
+
import { createEndCommand } from './commands/end.js';
|
|
21
|
+
import { createWorktreeCommand } from './commands/worktree.js';
|
|
22
|
+
import { printBanner } from './utils/banner.js';
|
|
23
|
+
|
|
24
|
+
const program = new Command();
|
|
25
|
+
|
|
26
|
+
program
|
|
27
|
+
.name('xtrm')
|
|
28
|
+
.description('Claude Code tools installer (skills, hooks, MCP servers)')
|
|
29
|
+
.version(version);
|
|
30
|
+
|
|
31
|
+
// Add exit override for cleaner unknown command error
|
|
32
|
+
program.exitOverride((err) => {
|
|
33
|
+
if (err.code === 'commander.unknownCommand') {
|
|
34
|
+
console.error(kleur.red(`\n✗ Unknown command. Run 'xtrm --help'\n`));
|
|
35
|
+
process.exit(1);
|
|
36
|
+
}
|
|
37
|
+
// Preserve exit code for help (0) and version (0); default to 1 for real errors
|
|
38
|
+
process.exit(err.exitCode ?? 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
// Main commands
|
|
42
|
+
program.addCommand(createInstallCommand());
|
|
43
|
+
program.addCommand(createClaudeCommand());
|
|
44
|
+
program.addCommand(createPiCommand());
|
|
45
|
+
program
|
|
46
|
+
.command('init')
|
|
47
|
+
.description('Alias for xtrm project init')
|
|
48
|
+
.action(async () => {
|
|
49
|
+
await runProjectInit();
|
|
50
|
+
});
|
|
51
|
+
program.addCommand(createStatusCommand());
|
|
52
|
+
program.addCommand(createResetCommand());
|
|
53
|
+
program.addCommand(createCleanCommand());
|
|
54
|
+
program.addCommand(createFinishCommand());
|
|
55
|
+
program.addCommand(createEndCommand());
|
|
56
|
+
program.addCommand(createWorktreeCommand());
|
|
57
|
+
program.addCommand(createHelpCommand());
|
|
58
|
+
|
|
59
|
+
// Default action: show help
|
|
60
|
+
program
|
|
61
|
+
.action(async () => {
|
|
62
|
+
program.help();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// Global error handlers
|
|
66
|
+
process.on('uncaughtException', (err) => {
|
|
67
|
+
if ((err as any).code?.startsWith('commander.')) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
console.error(kleur.red(`\n✗ ${err.message}\n`));
|
|
71
|
+
process.exit(1);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
process.on('unhandledRejection', (reason) => {
|
|
75
|
+
console.error(kleur.red(`\n✗ ${String(reason)}\n`));
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// Show startup banner unless --help or --version flag is present
|
|
80
|
+
const isHelpOrVersion = process.argv.some(a => a === '--help' || a === '-h' || a === '--version' || a === '-V');
|
|
81
|
+
|
|
82
|
+
(async () => {
|
|
83
|
+
if (!isHelpOrVersion) {
|
|
84
|
+
await printBanner(version);
|
|
85
|
+
}
|
|
86
|
+
program.parseAsync(process.argv);
|
|
87
|
+
})();
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-runtime policy parity tests (79m)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that:
|
|
5
|
+
* 1. Each policy file passes structural validation
|
|
6
|
+
* 2. Policies with runtime:both have both Claude hooks and Pi extension metadata
|
|
7
|
+
* 3. All referenced hook scripts and Pi extension files exist on disk
|
|
8
|
+
* 4. The policy compiler produces up-to-date hooks/hooks.json (--check passes)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect } from 'vitest';
|
|
12
|
+
import { readFileSync, existsSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
13
|
+
import { join, resolve, basename } from 'node:path';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
|
|
17
|
+
// Resolve repo root from cli/src/tests/
|
|
18
|
+
const ROOT = resolve(__dirname, '..', '..', '..');
|
|
19
|
+
const POLICIES_DIR = join(ROOT, 'policies');
|
|
20
|
+
|
|
21
|
+
interface PolicyHook {
|
|
22
|
+
event: string;
|
|
23
|
+
matcher?: string;
|
|
24
|
+
command: string;
|
|
25
|
+
timeout?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Policy {
|
|
29
|
+
id: string;
|
|
30
|
+
description: string;
|
|
31
|
+
version: string;
|
|
32
|
+
runtime?: 'claude' | 'pi' | 'both';
|
|
33
|
+
order?: number;
|
|
34
|
+
claude?: { hooks: PolicyHook[] };
|
|
35
|
+
pi?: { extension: string; events?: string[] };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Load all policies (skip schema.json)
|
|
39
|
+
const policyFiles = readdirSync(POLICIES_DIR)
|
|
40
|
+
.filter(f => f.endsWith('.json') && f !== 'schema.json')
|
|
41
|
+
.sort();
|
|
42
|
+
|
|
43
|
+
const policies: Array<{ file: string; policy: Policy }> = policyFiles.map(file => ({
|
|
44
|
+
file,
|
|
45
|
+
policy: JSON.parse(readFileSync(join(POLICIES_DIR, file), 'utf8')) as Policy,
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
// ── Structural validation ─────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
describe('policy structure', () => {
|
|
52
|
+
it.each(policyFiles)('%s has required fields', (file) => {
|
|
53
|
+
const { policy } = policies.find(p => p.file === file)!;
|
|
54
|
+
expect(policy.id, 'missing id').toBeTruthy();
|
|
55
|
+
expect(policy.description, 'missing description').toBeTruthy();
|
|
56
|
+
expect(policy.version, 'missing version').toBeTruthy();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it.each(policyFiles)('%s has valid runtime value', (file) => {
|
|
60
|
+
const { policy } = policies.find(p => p.file === file)!;
|
|
61
|
+
const validRuntimes = ['claude', 'pi', 'both', undefined];
|
|
62
|
+
expect(validRuntimes).toContain(policy.runtime);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it.each(policyFiles)('%s has at least one runtime target', (file) => {
|
|
66
|
+
const { policy } = policies.find(p => p.file === file)!;
|
|
67
|
+
const hasClaude = (policy.claude?.hooks?.length ?? 0) > 0;
|
|
68
|
+
const hasPi = !!policy.pi?.extension;
|
|
69
|
+
expect(hasClaude || hasPi, 'policy has no claude hooks and no pi extension').toBe(true);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// ── Cross-runtime parity ──────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
const bothPolicies = policies.filter(({ policy }) => policy.runtime === 'both');
|
|
76
|
+
|
|
77
|
+
describe('runtime:both parity', () => {
|
|
78
|
+
it('at least one policy targets both runtimes', () => {
|
|
79
|
+
expect(bothPolicies.length).toBeGreaterThan(0);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it.each(bothPolicies.map(p => p.file))('%s has claude.hooks', (file) => {
|
|
83
|
+
const { policy } = policies.find(p => p.file === file)!;
|
|
84
|
+
expect(policy.claude?.hooks?.length ?? 0).toBeGreaterThan(0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it.each(bothPolicies.map(p => p.file))('%s has pi.extension', (file) => {
|
|
88
|
+
const { policy } = policies.find(p => p.file === file)!;
|
|
89
|
+
expect(policy.pi?.extension, 'runtime:both policy missing pi.extension').toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// ── Matcher macro expansion parity ────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
describe('matcher macro expansion parity', () => {
|
|
96
|
+
it('compiled hooks contain no unresolved matcher macros', () => {
|
|
97
|
+
const compiledHooks = JSON.parse(readFileSync(join(ROOT, 'hooks', 'hooks.json'), 'utf8'));
|
|
98
|
+
const allGroups = Object.values(compiledHooks?.hooks ?? {}).flat() as Array<{ matcher?: string }>;
|
|
99
|
+
const unresolved = allGroups.filter((group) => typeof group.matcher === 'string' && group.matcher.includes('$'));
|
|
100
|
+
expect(unresolved, 'found unresolved matcher macros in hooks/hooks.json').toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// ── File existence ────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
describe('referenced files exist', () => {
|
|
107
|
+
// Resolve ${CLAUDE_PLUGIN_ROOT}/hooks/foo.mjs → hooks/foo.mjs (repo-relative)
|
|
108
|
+
const resolveCommand = (command: string): string =>
|
|
109
|
+
command.replace('node ${CLAUDE_PLUGIN_ROOT}/', '').replace('python3 ${CLAUDE_PLUGIN_ROOT}/', '');
|
|
110
|
+
|
|
111
|
+
const allHooks = policies.flatMap(({ file, policy }) =>
|
|
112
|
+
(policy.claude?.hooks ?? []).map(hook => ({ file, command: hook.command })),
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
it.each(allHooks)('$file: command "$command" references existing file', ({ command }) => {
|
|
116
|
+
const relativePath = resolveCommand(command);
|
|
117
|
+
const absolutePath = join(ROOT, relativePath);
|
|
118
|
+
expect(existsSync(absolutePath), `Hook script not found: ${relativePath}`).toBe(true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const piPolicies = policies.filter(({ policy }) => policy.pi?.extension);
|
|
122
|
+
|
|
123
|
+
it.each(piPolicies.map(p => p.file))('%s: pi.extension file exists', (file) => {
|
|
124
|
+
const { policy } = policies.find(p => p.file === file)!;
|
|
125
|
+
const absPath = join(ROOT, policy.pi!.extension);
|
|
126
|
+
expect(existsSync(absPath), `Pi extension not found: ${policy.pi!.extension}`).toBe(true);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('pi extension guard-rules imports', () => {
|
|
131
|
+
it('main-guard and adapter use shared guard-rules constants', () => {
|
|
132
|
+
const mainGuard = readFileSync(join(ROOT, 'config', 'pi', 'extensions', 'main-guard.ts'), 'utf8');
|
|
133
|
+
const adapter = readFileSync(join(ROOT, 'config', 'pi', 'extensions', 'core', 'adapter.ts'), 'utf8');
|
|
134
|
+
|
|
135
|
+
expect(mainGuard).toContain('from "./core/guard-rules"');
|
|
136
|
+
expect(adapter).toContain('from "./guard-rules"');
|
|
137
|
+
|
|
138
|
+
expect(mainGuard).not.toMatch(/^\s*const\s+SAFE_BASH_PREFIXES\s*=\s*\[/m);
|
|
139
|
+
expect(mainGuard).not.toMatch(/^\s*const\s+DANGEROUS_BASH_PATTERNS\s*=\s*\[/m);
|
|
140
|
+
expect(adapter).not.toMatch(/const\s+tools\s*=\s*\[/m);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ── Compiler consistency ──────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
describe('compiler', () => {
|
|
147
|
+
it('hooks/hooks.json is up to date with policies/', () => {
|
|
148
|
+
const result = spawnSync(
|
|
149
|
+
'node',
|
|
150
|
+
[join(ROOT, 'scripts', 'compile-policies.mjs'), '--check'],
|
|
151
|
+
{ cwd: ROOT, encoding: 'utf8' },
|
|
152
|
+
);
|
|
153
|
+
expect(
|
|
154
|
+
result.status,
|
|
155
|
+
`hooks.json drift detected — run: npm run compile-policies\n${result.stdout}${result.stderr}`,
|
|
156
|
+
).toBe(0);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('--check-pi validates policy-declared extension deployment set', () => {
|
|
160
|
+
const tmpAgent = mkdtempSync(join(tmpdir(), 'xtrm-pi-agent-'));
|
|
161
|
+
const tmpExtDir = join(tmpAgent, 'extensions');
|
|
162
|
+
mkdirSync(tmpExtDir, { recursive: true });
|
|
163
|
+
|
|
164
|
+
try {
|
|
165
|
+
const declared = policies
|
|
166
|
+
.filter(({ policy }) => ['pi', 'both'].includes(policy.runtime ?? 'both'))
|
|
167
|
+
.map(({ policy }) => policy.pi?.extension)
|
|
168
|
+
.filter(Boolean) as string[];
|
|
169
|
+
|
|
170
|
+
for (const rel of declared) {
|
|
171
|
+
const src = join(ROOT, rel);
|
|
172
|
+
const dst = join(tmpExtDir, basename(rel));
|
|
173
|
+
writeFileSync(dst, readFileSync(src));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = spawnSync(
|
|
177
|
+
'node',
|
|
178
|
+
[join(ROOT, 'scripts', 'compile-policies.mjs'), '--check-pi'],
|
|
179
|
+
{
|
|
180
|
+
cwd: ROOT,
|
|
181
|
+
encoding: 'utf8',
|
|
182
|
+
env: { ...process.env, PI_AGENT_DIR: tmpAgent },
|
|
183
|
+
},
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
expect(result.status, `check-pi failed:\n${result.stdout}${result.stderr}`).toBe(0);
|
|
187
|
+
} finally {
|
|
188
|
+
rmSync(tmpAgent, { recursive: true, force: true });
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('all policy ids are unique', () => {
|
|
193
|
+
const ids = policies.map(({ policy }) => policy.id);
|
|
194
|
+
const unique = new Set(ids);
|
|
195
|
+
expect(unique.size).toBe(ids.length);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('policy order values are unique or explicitly equal', () => {
|
|
199
|
+
// Multiple policies can share an order value — just document it
|
|
200
|
+
const orders = policies.map(({ file, policy }) => ({ file, order: policy.order ?? 50 }));
|
|
201
|
+
// No assertion — informational only, logged to help debug ordering issues
|
|
202
|
+
expect(orders.length).toBeGreaterThan(0);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { readFileSync, existsSync, mkdtempSync, mkdirSync, writeFileSync, chmodSync, rmSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
const ROOT = path.resolve(__dirname, '..', '..', '..');
|
|
8
|
+
const HOOKS_DIR = path.join(ROOT, 'hooks');
|
|
9
|
+
|
|
10
|
+
function runHook(hookFile: string, input: Record<string, unknown>, env: Record<string, string> = {}) {
|
|
11
|
+
return spawnSync('node', [path.join(HOOKS_DIR, hookFile)], {
|
|
12
|
+
input: JSON.stringify(input),
|
|
13
|
+
encoding: 'utf8',
|
|
14
|
+
env: { ...process.env, ...env },
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function withFakeBdDir(scriptBody: string) {
|
|
19
|
+
const tempDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-fakebd-'));
|
|
20
|
+
const fakeBdPath = path.join(tempDir, 'bd');
|
|
21
|
+
writeFileSync(fakeBdPath, scriptBody, { encoding: 'utf8' });
|
|
22
|
+
chmodSync(fakeBdPath, 0o755);
|
|
23
|
+
return { tempDir, fakeBdPath };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe('session-flow policy parity', () => {
|
|
27
|
+
it('declares runtime:both with Claude hooks and Pi extension', () => {
|
|
28
|
+
const policy = JSON.parse(readFileSync(path.join(ROOT, 'policies', 'session-flow.json'), 'utf8'));
|
|
29
|
+
expect(policy.runtime).toBe('both');
|
|
30
|
+
expect(policy.order).toBeLessThan(20); // must run before beads stop memory gate
|
|
31
|
+
expect(policy.claude?.hooks?.length).toBeGreaterThan(0);
|
|
32
|
+
expect(policy.pi?.extension).toBe('config/pi/extensions/session-flow.ts');
|
|
33
|
+
expect(existsSync(path.join(ROOT, policy.pi.extension))).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('compiled hooks run stop gate before memory gate', () => {
|
|
37
|
+
const hooks = JSON.parse(readFileSync(path.join(ROOT, 'hooks', 'hooks.json'), 'utf8'));
|
|
38
|
+
const stopGroups = hooks?.hooks?.Stop ?? [];
|
|
39
|
+
const commands: string[] = stopGroups.flatMap((g: any) => (g.hooks ?? []).map((h: any) => String(h.command)));
|
|
40
|
+
const stopIdx = commands.findIndex((c) => c.includes('beads-stop-gate.mjs'));
|
|
41
|
+
const memIdx = commands.findIndex((c) => c.includes('beads-memory-gate.mjs'));
|
|
42
|
+
expect(stopIdx).toBeGreaterThanOrEqual(0);
|
|
43
|
+
expect(memIdx).toBeGreaterThanOrEqual(0);
|
|
44
|
+
expect(stopIdx).toBeLessThan(memIdx);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('Claude stop hook enforces phase blocking contract', () => {
|
|
48
|
+
const projectDir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-session-flow-stop-'));
|
|
49
|
+
mkdirSync(path.join(projectDir, '.beads'));
|
|
50
|
+
|
|
51
|
+
const fake = withFakeBdDir(`#!/usr/bin/env bash
|
|
52
|
+
set -euo pipefail
|
|
53
|
+
if [[ "$1" == "kv" && "$2" == "get" ]]; then exit 1; fi
|
|
54
|
+
if [[ "$1" == "list" ]]; then
|
|
55
|
+
cat <<'EOF'
|
|
56
|
+
|
|
57
|
+
--------------------------------------------------------------------------------
|
|
58
|
+
Total: 0 issues (0 open, 0 in progress)
|
|
59
|
+
EOF
|
|
60
|
+
exit 0
|
|
61
|
+
fi
|
|
62
|
+
exit 1
|
|
63
|
+
`);
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const cases = [
|
|
67
|
+
{ phase: 'waiting-merge', blocked: true },
|
|
68
|
+
{ phase: 'pending-cleanup', blocked: true },
|
|
69
|
+
{ phase: 'conflicting', blocked: true },
|
|
70
|
+
{ phase: 'cleanup-done', blocked: false },
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
for (const c of cases) {
|
|
74
|
+
writeFileSync(path.join(projectDir, '.xtrm-session-state.json'), JSON.stringify({
|
|
75
|
+
issueId: 'jaggers-agent-tools-1xa2',
|
|
76
|
+
branch: 'feature/jaggers-agent-tools-1xa2',
|
|
77
|
+
worktreePath: '/tmp/worktrees/jaggers-agent-tools-1xa2',
|
|
78
|
+
prNumber: 101,
|
|
79
|
+
prUrl: 'https://example.invalid/pr/101',
|
|
80
|
+
phase: c.phase,
|
|
81
|
+
conflictFiles: c.phase === 'conflicting' ? ['src/a.ts'] : [],
|
|
82
|
+
startedAt: new Date().toISOString(),
|
|
83
|
+
lastChecked: new Date().toISOString(),
|
|
84
|
+
}), 'utf8');
|
|
85
|
+
|
|
86
|
+
const r = runHook(
|
|
87
|
+
'beads-stop-gate.mjs',
|
|
88
|
+
{ hook_event_name: 'Stop', session_id: 'parity-session', cwd: projectDir },
|
|
89
|
+
{ PATH: `${fake.tempDir}:${process.env.PATH ?? ''}` },
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (c.blocked) {
|
|
93
|
+
expect(r.status, `expected block for ${c.phase}`).toBe(2);
|
|
94
|
+
} else {
|
|
95
|
+
expect(r.status, `expected allow for ${c.phase}`).toBe(0);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
} finally {
|
|
99
|
+
rmSync(fake.tempDir, { recursive: true, force: true });
|
|
100
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('Pi extension encodes same phase guards and claim detection semantics', () => {
|
|
105
|
+
const src = readFileSync(path.join(ROOT, 'config', 'pi', 'extensions', 'session-flow.ts'), 'utf8');
|
|
106
|
+
|
|
107
|
+
// claim detection path
|
|
108
|
+
expect(src).toContain('bd\\s+update');
|
|
109
|
+
expect(src).toContain('--claim');
|
|
110
|
+
|
|
111
|
+
// phase parity with Claude stop-gate
|
|
112
|
+
expect(src).toContain('waiting-merge');
|
|
113
|
+
expect(src).toContain('pending-cleanup');
|
|
114
|
+
expect(src).toContain('conflicting');
|
|
115
|
+
expect(src).toContain('phase1-done');
|
|
116
|
+
expect(src).toContain('xtrm finish');
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
findSessionStateFile,
|
|
8
|
+
readSessionState,
|
|
9
|
+
writeSessionState,
|
|
10
|
+
updateSessionPhase,
|
|
11
|
+
} from '../core/session-state.js';
|
|
12
|
+
|
|
13
|
+
describe('session-state.ts', () => {
|
|
14
|
+
it('returns null when no state file exists', () => {
|
|
15
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-session-state-none-'));
|
|
16
|
+
try {
|
|
17
|
+
expect(findSessionStateFile(dir)).toBeNull();
|
|
18
|
+
expect(readSessionState(dir)).toBeNull();
|
|
19
|
+
} finally {
|
|
20
|
+
rmSync(dir, { recursive: true, force: true });
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('writes and reads state roundtrip', () => {
|
|
25
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-session-state-roundtrip-'));
|
|
26
|
+
try {
|
|
27
|
+
writeSessionState({
|
|
28
|
+
issueId: 'jaggers-agent-tools-1xa2',
|
|
29
|
+
branch: 'feature/jaggers-agent-tools-1xa2',
|
|
30
|
+
worktreePath: '/tmp/worktrees/jaggers-agent-tools-1xa2',
|
|
31
|
+
prNumber: null,
|
|
32
|
+
prUrl: null,
|
|
33
|
+
phase: 'claimed',
|
|
34
|
+
conflictFiles: [],
|
|
35
|
+
startedAt: new Date().toISOString(),
|
|
36
|
+
lastChecked: new Date().toISOString(),
|
|
37
|
+
}, dir);
|
|
38
|
+
|
|
39
|
+
const state = readSessionState(dir);
|
|
40
|
+
expect(state).not.toBeNull();
|
|
41
|
+
expect(state?.issueId).toBe('jaggers-agent-tools-1xa2');
|
|
42
|
+
expect(state?.phase).toBe('claimed');
|
|
43
|
+
expect(Array.isArray(state?.conflictFiles)).toBe(true);
|
|
44
|
+
} finally {
|
|
45
|
+
rmSync(dir, { recursive: true, force: true });
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('findSessionStateFile walks up directory tree', () => {
|
|
50
|
+
const root = mkdtempSync(path.join(os.tmpdir(), 'xtrm-session-state-walk-'));
|
|
51
|
+
const nested = path.join(root, 'a', 'b', 'c');
|
|
52
|
+
mkdirSync(nested, { recursive: true });
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
writeSessionState({
|
|
56
|
+
issueId: 'walk-1',
|
|
57
|
+
branch: 'feature/walk-1',
|
|
58
|
+
worktreePath: '/tmp/worktrees/walk-1',
|
|
59
|
+
prNumber: null,
|
|
60
|
+
prUrl: null,
|
|
61
|
+
phase: 'claimed',
|
|
62
|
+
conflictFiles: [],
|
|
63
|
+
startedAt: new Date().toISOString(),
|
|
64
|
+
lastChecked: new Date().toISOString(),
|
|
65
|
+
}, root);
|
|
66
|
+
|
|
67
|
+
const found = findSessionStateFile(nested);
|
|
68
|
+
expect(found).toBe(path.join(root, '.xtrm-session-state.json'));
|
|
69
|
+
} finally {
|
|
70
|
+
rmSync(root, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('enforces phase transition validation', () => {
|
|
75
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-session-state-phase-'));
|
|
76
|
+
try {
|
|
77
|
+
writeSessionState({
|
|
78
|
+
issueId: 'phase-1',
|
|
79
|
+
branch: 'feature/phase-1',
|
|
80
|
+
worktreePath: '/tmp/worktrees/phase-1',
|
|
81
|
+
prNumber: null,
|
|
82
|
+
prUrl: null,
|
|
83
|
+
phase: 'claimed',
|
|
84
|
+
conflictFiles: [],
|
|
85
|
+
startedAt: new Date().toISOString(),
|
|
86
|
+
lastChecked: new Date().toISOString(),
|
|
87
|
+
}, dir);
|
|
88
|
+
|
|
89
|
+
const waiting = updateSessionPhase('waiting-merge', dir, { prNumber: 12 });
|
|
90
|
+
expect(waiting.phase).toBe('waiting-merge');
|
|
91
|
+
|
|
92
|
+
const done = updateSessionPhase('cleanup-done', dir);
|
|
93
|
+
expect(done.phase).toBe('cleanup-done');
|
|
94
|
+
|
|
95
|
+
expect(() => updateSessionPhase('claimed', dir)).toThrow(/Invalid phase transition/);
|
|
96
|
+
} finally {
|
|
97
|
+
rmSync(dir, { recursive: true, force: true });
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('updates persisted file on phase changes', () => {
|
|
102
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-session-state-persist-'));
|
|
103
|
+
try {
|
|
104
|
+
writeSessionState({
|
|
105
|
+
issueId: 'persist-1',
|
|
106
|
+
branch: 'feature/persist-1',
|
|
107
|
+
worktreePath: '/tmp/worktrees/persist-1',
|
|
108
|
+
prNumber: null,
|
|
109
|
+
prUrl: null,
|
|
110
|
+
phase: 'claimed',
|
|
111
|
+
conflictFiles: [],
|
|
112
|
+
startedAt: new Date().toISOString(),
|
|
113
|
+
lastChecked: new Date().toISOString(),
|
|
114
|
+
}, dir);
|
|
115
|
+
|
|
116
|
+
updateSessionPhase('conflicting', dir, { conflictFiles: ['src/core.ts'] });
|
|
117
|
+
const raw = JSON.parse(readFileSync(path.join(dir, '.xtrm-session-state.json'), 'utf8'));
|
|
118
|
+
expect(raw.phase).toBe('conflicting');
|
|
119
|
+
expect(raw.conflictFiles).toContain('src/core.ts');
|
|
120
|
+
} finally {
|
|
121
|
+
rmSync(dir, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
import { runXtrmFinish } from '../core/xtrm-finish.js';
|
|
8
|
+
|
|
9
|
+
function initRepo(dir: string) {
|
|
10
|
+
spawnSync('git', ['init'], { cwd: dir, stdio: 'pipe' });
|
|
11
|
+
spawnSync('git', ['config', 'user.email', 'test@example.com'], { cwd: dir, stdio: 'pipe' });
|
|
12
|
+
spawnSync('git', ['config', 'user.name', 'Test User'], { cwd: dir, stdio: 'pipe' });
|
|
13
|
+
writeFileSync(path.join(dir, 'README.md'), '# test\n', 'utf8');
|
|
14
|
+
spawnSync('git', ['add', 'README.md'], { cwd: dir, stdio: 'pipe' });
|
|
15
|
+
spawnSync('git', ['commit', '-m', 'init'], { cwd: dir, stdio: 'pipe' });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('runXtrmFinish', () => {
|
|
19
|
+
it('fails when session state file is missing', async () => {
|
|
20
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-finish-none-'));
|
|
21
|
+
try {
|
|
22
|
+
const result = await runXtrmFinish({ cwd: dir, pollIntervalMs: 1, timeoutMs: 5 });
|
|
23
|
+
expect(result.ok).toBe(false);
|
|
24
|
+
expect(result.message).toContain('.xtrm-session-state.json');
|
|
25
|
+
} finally {
|
|
26
|
+
rmSync(dir, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('re-entrant waiting-merge path reaches cleanup-done on merged PR', async () => {
|
|
31
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-finish-merge-'));
|
|
32
|
+
const fakeDir = path.join(dir, 'fakebin');
|
|
33
|
+
mkdirSync(fakeDir, { recursive: true });
|
|
34
|
+
initRepo(dir);
|
|
35
|
+
|
|
36
|
+
writeFileSync(path.join(dir, '.xtrm-session-state.json'), JSON.stringify({
|
|
37
|
+
issueId: 'jaggers-agent-tools-1xa2',
|
|
38
|
+
branch: 'feature/jaggers-agent-tools-1xa2',
|
|
39
|
+
worktreePath: '/tmp/nonexistent-worktree-path',
|
|
40
|
+
prNumber: 42,
|
|
41
|
+
prUrl: 'https://example.invalid/pr/42',
|
|
42
|
+
phase: 'waiting-merge',
|
|
43
|
+
conflictFiles: [],
|
|
44
|
+
startedAt: new Date().toISOString(),
|
|
45
|
+
lastChecked: new Date().toISOString(),
|
|
46
|
+
}), 'utf8');
|
|
47
|
+
|
|
48
|
+
const ghPath = path.join(fakeDir, 'gh');
|
|
49
|
+
writeFileSync(ghPath, `#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$1" == "pr" && "$2" == "view" ]]; then\n echo '{"state":"MERGED","mergeStateStatus":"CLEAN","mergeable":"MERGEABLE"}'\n exit 0\nfi\nexit 1\n`);
|
|
50
|
+
chmodSync(ghPath, 0o755);
|
|
51
|
+
|
|
52
|
+
const oldPath = process.env.PATH;
|
|
53
|
+
process.env.PATH = `${fakeDir}:${oldPath ?? ''}`;
|
|
54
|
+
try {
|
|
55
|
+
const result = await runXtrmFinish({ cwd: dir, pollIntervalMs: 1, timeoutMs: 20 });
|
|
56
|
+
expect(result.ok).toBe(true);
|
|
57
|
+
expect(result.message).toContain('merged');
|
|
58
|
+
const state = JSON.parse(readFileSync(path.join(dir, '.xtrm-session-state.json'), 'utf8'));
|
|
59
|
+
expect(state.phase).toBe('cleanup-done');
|
|
60
|
+
} finally {
|
|
61
|
+
process.env.PATH = oldPath;
|
|
62
|
+
rmSync(dir, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('uses claimed worktree path for gh/git phase steps when run from repo root', async () => {
|
|
67
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-finish-worktree-cwd-'));
|
|
68
|
+
const fakeDir = path.join(dir, 'fakebin');
|
|
69
|
+
mkdirSync(fakeDir, { recursive: true });
|
|
70
|
+
initRepo(dir);
|
|
71
|
+
|
|
72
|
+
const worktreePath = path.join(dir, '.worktrees', 'jaggers-agent-tools-1xa2');
|
|
73
|
+
spawnSync('git', ['worktree', 'add', worktreePath, '-b', 'feature/jaggers-agent-tools-1xa2'], {
|
|
74
|
+
cwd: dir,
|
|
75
|
+
stdio: 'pipe',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
writeFileSync(path.join(dir, '.xtrm-session-state.json'), JSON.stringify({
|
|
79
|
+
issueId: 'jaggers-agent-tools-1xa2',
|
|
80
|
+
branch: 'feature/jaggers-agent-tools-1xa2',
|
|
81
|
+
worktreePath,
|
|
82
|
+
prNumber: 77,
|
|
83
|
+
prUrl: 'https://example.invalid/pr/77',
|
|
84
|
+
phase: 'waiting-merge',
|
|
85
|
+
conflictFiles: [],
|
|
86
|
+
startedAt: new Date().toISOString(),
|
|
87
|
+
lastChecked: new Date().toISOString(),
|
|
88
|
+
}), 'utf8');
|
|
89
|
+
|
|
90
|
+
const markerPath = path.join(dir, 'gh-cwd.txt');
|
|
91
|
+
const ghPath = path.join(fakeDir, 'gh');
|
|
92
|
+
writeFileSync(
|
|
93
|
+
ghPath,
|
|
94
|
+
`#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$1" == "pr" && "$2" == "view" ]]; then\n pwd > "${markerPath}"\n echo '{"state":"MERGED","mergeStateStatus":"CLEAN","mergeable":"MERGEABLE"}'\n exit 0\nfi\nexit 1\n`,
|
|
95
|
+
);
|
|
96
|
+
chmodSync(ghPath, 0o755);
|
|
97
|
+
|
|
98
|
+
const oldPath = process.env.PATH;
|
|
99
|
+
process.env.PATH = `${fakeDir}:${oldPath ?? ''}`;
|
|
100
|
+
try {
|
|
101
|
+
const result = await runXtrmFinish({ cwd: dir, pollIntervalMs: 1, timeoutMs: 20 });
|
|
102
|
+
expect(result.ok).toBe(true);
|
|
103
|
+
expect(readFileSync(markerPath, 'utf8').trim()).toBe(worktreePath);
|
|
104
|
+
expect(spawnSync('git', ['worktree', 'list'], { cwd: dir, encoding: 'utf8' }).stdout).not.toContain(worktreePath);
|
|
105
|
+
const state = JSON.parse(readFileSync(path.join(dir, '.xtrm-session-state.json'), 'utf8'));
|
|
106
|
+
expect(state.phase).toBe('cleanup-done');
|
|
107
|
+
} finally {
|
|
108
|
+
process.env.PATH = oldPath;
|
|
109
|
+
rmSync(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('sets pending-cleanup when PR is not merged before timeout', async () => {
|
|
114
|
+
const dir = mkdtempSync(path.join(os.tmpdir(), 'xtrm-finish-timeout-'));
|
|
115
|
+
const fakeDir = path.join(dir, 'fakebin');
|
|
116
|
+
mkdirSync(fakeDir, { recursive: true });
|
|
117
|
+
initRepo(dir);
|
|
118
|
+
|
|
119
|
+
writeFileSync(path.join(dir, '.xtrm-session-state.json'), JSON.stringify({
|
|
120
|
+
issueId: 'jaggers-agent-tools-1xa2',
|
|
121
|
+
branch: 'feature/jaggers-agent-tools-1xa2',
|
|
122
|
+
worktreePath: '/tmp/nonexistent-worktree-path',
|
|
123
|
+
prNumber: 55,
|
|
124
|
+
prUrl: 'https://example.invalid/pr/55',
|
|
125
|
+
phase: 'waiting-merge',
|
|
126
|
+
conflictFiles: [],
|
|
127
|
+
startedAt: new Date().toISOString(),
|
|
128
|
+
lastChecked: new Date().toISOString(),
|
|
129
|
+
}), 'utf8');
|
|
130
|
+
|
|
131
|
+
const ghPath = path.join(fakeDir, 'gh');
|
|
132
|
+
writeFileSync(ghPath, `#!/usr/bin/env bash\nset -euo pipefail\nif [[ "$1" == "pr" && "$2" == "view" ]]; then\n echo '{"state":"OPEN","mergeStateStatus":"BLOCKED","mergeable":"UNKNOWN"}'\n exit 0\nfi\nexit 1\n`);
|
|
133
|
+
chmodSync(ghPath, 0o755);
|
|
134
|
+
|
|
135
|
+
const oldPath = process.env.PATH;
|
|
136
|
+
process.env.PATH = `${fakeDir}:${oldPath ?? ''}`;
|
|
137
|
+
try {
|
|
138
|
+
const result = await runXtrmFinish({ cwd: dir, pollIntervalMs: 1, timeoutMs: 10 });
|
|
139
|
+
expect(result.ok).toBe(false);
|
|
140
|
+
expect(result.message).toContain('Run xtrm finish when ready');
|
|
141
|
+
const state = JSON.parse(readFileSync(path.join(dir, '.xtrm-session-state.json'), 'utf8'));
|
|
142
|
+
expect(state.phase).toBe('pending-cleanup');
|
|
143
|
+
} finally {
|
|
144
|
+
process.env.PATH = oldPath;
|
|
145
|
+
rmSync(dir, { recursive: true, force: true });
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
});
|