xtrm-cli 0.5.0 → 0.5.27

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.
Files changed (50) hide show
  1. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
  2. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
  3. package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
  4. package/dist/index.cjs +969 -1059
  5. package/dist/index.cjs.map +1 -1
  6. package/package.json +1 -1
  7. package/src/commands/clean.ts +7 -6
  8. package/src/commands/debug.ts +255 -0
  9. package/src/commands/docs.ts +180 -0
  10. package/src/commands/help.ts +92 -171
  11. package/src/commands/init.ts +9 -32
  12. package/src/commands/install-pi.ts +9 -16
  13. package/src/commands/install.ts +150 -2
  14. package/src/commands/pi-install.ts +10 -44
  15. package/src/core/context.ts +4 -52
  16. package/src/core/diff.ts +3 -16
  17. package/src/core/preflight.ts +0 -1
  18. package/src/index.ts +7 -4
  19. package/src/types/config.ts +0 -2
  20. package/src/utils/config-injector.ts +3 -3
  21. package/src/utils/pi-extensions.ts +41 -0
  22. package/src/utils/worktree-session.ts +86 -50
  23. package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
  24. package/test/extensions/beads-parity.test.ts +94 -0
  25. package/test/extensions/extension-harness.ts +5 -5
  26. package/test/extensions/quality-gates-parity.test.ts +89 -0
  27. package/test/extensions/session-flow.test.ts +91 -0
  28. package/test/extensions/xtrm-loader.test.ts +38 -20
  29. package/test/install-pi.test.ts +22 -11
  30. package/test/pi-extensions.test.ts +50 -0
  31. package/test/session-launcher.test.ts +28 -38
  32. package/extensions/beads.ts +0 -109
  33. package/extensions/core/adapter.ts +0 -45
  34. package/extensions/core/lib.ts +0 -3
  35. package/extensions/core/logger.ts +0 -45
  36. package/extensions/core/runner.ts +0 -71
  37. package/extensions/custom-footer.ts +0 -160
  38. package/extensions/main-guard-post-push.ts +0 -44
  39. package/extensions/main-guard.ts +0 -126
  40. package/extensions/minimal-mode.ts +0 -201
  41. package/extensions/quality-gates.ts +0 -67
  42. package/extensions/service-skills.ts +0 -150
  43. package/extensions/xtrm-loader.ts +0 -89
  44. package/hooks/gitnexus-impact-reminder.py +0 -13
  45. package/src/commands/finish.ts +0 -25
  46. package/src/core/session-state.ts +0 -139
  47. package/src/core/xtrm-finish.ts +0 -267
  48. package/src/tests/session-flow-parity.test.ts +0 -118
  49. package/src/tests/session-state.test.ts +0 -124
  50. package/src/tests/xtrm-finish.test.ts +0 -148
@@ -1,267 +0,0 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { existsSync } from 'node:fs';
3
- import {
4
- readSessionState,
5
- updateSessionPhase,
6
- type SessionState,
7
- } from './session-state.js';
8
-
9
- export interface FinishOptions {
10
- cwd?: string;
11
- pollIntervalMs?: number;
12
- timeoutMs?: number;
13
- }
14
-
15
- export interface FinishResult {
16
- ok: boolean;
17
- message: string;
18
- }
19
-
20
- interface CmdResult {
21
- code: number;
22
- stdout: string;
23
- stderr: string;
24
- }
25
-
26
- const DEFAULT_POLL_INTERVAL_MS = 5000;
27
- const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
28
-
29
- function run(cmd: string, args: string[], cwd: string): CmdResult {
30
- const r = spawnSync(cmd, args, {
31
- cwd,
32
- encoding: 'utf8',
33
- stdio: ['pipe', 'pipe', 'pipe'],
34
- });
35
- return {
36
- code: r.status ?? 1,
37
- stdout: (r.stdout ?? '').trim(),
38
- stderr: (r.stderr ?? '').trim(),
39
- };
40
- }
41
-
42
- function getControlRepoRoot(cwd: string): string {
43
- const commonDir = run('git', ['rev-parse', '--path-format=absolute', '--git-common-dir'], cwd);
44
- if (commonDir.code === 0 && commonDir.stdout) {
45
- return commonDir.stdout.replace(/\/.git$/, '');
46
- }
47
- return cwd;
48
- }
49
-
50
- function resolveExecutionCwd(controlCwd: string, state: SessionState): string {
51
- if (state.worktreePath && existsSync(state.worktreePath)) {
52
- return state.worktreePath;
53
- }
54
- return controlCwd;
55
- }
56
-
57
- function parsePrCreate(stdout: string): { prNumber: number | null; prUrl: string | null } {
58
- const urlMatch = stdout.match(/https?:\/\/\S+\/pull\/(\d+)/);
59
- if (urlMatch) {
60
- return { prNumber: Number(urlMatch[1]), prUrl: urlMatch[0] };
61
- }
62
-
63
- const numberMatch = stdout.match(/#(\d+)/);
64
- if (numberMatch) {
65
- return { prNumber: Number(numberMatch[1]), prUrl: null };
66
- }
67
-
68
- return { prNumber: null, prUrl: null };
69
- }
70
-
71
- function getConflictFiles(cwd: string): string[] {
72
- const out = run('git', ['diff', '--name-only', '--diff-filter=U'], cwd);
73
- if (out.code !== 0 || !out.stdout) return [];
74
- return out.stdout.split('\n').map((s) => s.trim()).filter(Boolean);
75
- }
76
-
77
- async function delay(ms: number): Promise<void> {
78
- if (ms <= 0) return;
79
- await new Promise((resolve) => setTimeout(resolve, ms));
80
- }
81
-
82
- function ensureCleanPhaseTransition(cwd: string, phase: SessionState['phase'], patch: Partial<SessionState> = {}) {
83
- try {
84
- updateSessionPhase(phase, cwd, patch);
85
- } catch {
86
- // non-fatal for re-entrant paths
87
- }
88
- }
89
-
90
- function handleRebaseAndPush(cwd: string): { ok: boolean; conflicts?: string[]; error?: string } {
91
- const fetch = run('git', ['fetch', 'origin'], cwd);
92
- if (fetch.code !== 0) return { ok: false, error: fetch.stderr || fetch.stdout };
93
-
94
- const rebase = run('git', ['rebase', 'origin/main'], cwd);
95
- if (rebase.code !== 0) {
96
- const conflicts = getConflictFiles(cwd);
97
- return { ok: false, conflicts, error: rebase.stderr || rebase.stdout };
98
- }
99
-
100
- const push = run('git', ['push', '--force-with-lease'], cwd);
101
- if (push.code !== 0) {
102
- return { ok: false, error: push.stderr || push.stdout };
103
- }
104
-
105
- return { ok: true };
106
- }
107
-
108
- function cleanupPhase(controlCwd: string, state: SessionState): FinishResult {
109
- if (existsSync(state.worktreePath)) {
110
- const rm = run('git', ['worktree', 'remove', state.worktreePath, '--force'], controlCwd);
111
- if (rm.code !== 0) {
112
- return { ok: false, message: rm.stderr || rm.stdout || `Failed to remove worktree ${state.worktreePath}` };
113
- }
114
- }
115
-
116
- run('git', ['fetch', '--prune'], controlCwd);
117
- ensureCleanPhaseTransition(controlCwd, 'cleanup-done');
118
-
119
- const prLabel = state.prNumber != null ? `#${state.prNumber}` : '(unknown PR)';
120
- return { ok: true, message: `Done. PR ${prLabel} merged. Worktree removed.` };
121
- }
122
-
123
- async function pollUntilMerged(controlCwd: string, executionCwd: string, state: SessionState, opts: Required<FinishOptions>): Promise<FinishResult> {
124
- if (state.prNumber == null) {
125
- return { ok: false, message: 'Session state missing prNumber. Re-run phase 1 before polling.' };
126
- }
127
-
128
- const started = Date.now();
129
- while ((Date.now() - started) < opts.timeoutMs) {
130
- const view = run('gh', ['pr', 'view', String(state.prNumber), '--json', 'state,mergeStateStatus,mergeable'], executionCwd);
131
-
132
- if (view.code !== 0) {
133
- return { ok: false, message: view.stderr || view.stdout || `Failed to inspect PR #${state.prNumber}` };
134
- }
135
-
136
- let payload: any = null;
137
- try {
138
- payload = JSON.parse(view.stdout);
139
- } catch {
140
- return { ok: false, message: `Unable to parse gh pr view output for #${state.prNumber}` };
141
- }
142
-
143
- ensureCleanPhaseTransition(controlCwd, 'waiting-merge');
144
-
145
- if (payload.state === 'MERGED') {
146
- ensureCleanPhaseTransition(controlCwd, 'merged');
147
- const latest = readSessionState(controlCwd) ?? state;
148
- return cleanupPhase(controlCwd, latest);
149
- }
150
-
151
- if (payload.mergeStateStatus === 'BEHIND') {
152
- run('git', ['fetch', 'origin'], executionCwd);
153
- run('git', ['push'], executionCwd);
154
- await delay(opts.pollIntervalMs);
155
- continue;
156
- }
157
-
158
- if (payload.mergeable === 'CONFLICTING') {
159
- const rebased = handleRebaseAndPush(executionCwd);
160
- if (!rebased.ok) {
161
- const conflictFiles = rebased.conflicts ?? getConflictFiles(executionCwd);
162
- ensureCleanPhaseTransition(controlCwd, 'conflicting', { conflictFiles });
163
- return {
164
- ok: false,
165
- message: `Conflicts in: ${conflictFiles.join(', ') || 'unknown files'}. Resolve, push, then re-run xtrm finish.`,
166
- };
167
- }
168
-
169
- ensureCleanPhaseTransition(controlCwd, 'waiting-merge', { conflictFiles: [] });
170
- await delay(opts.pollIntervalMs);
171
- continue;
172
- }
173
-
174
- await delay(opts.pollIntervalMs);
175
- }
176
-
177
- ensureCleanPhaseTransition(controlCwd, 'pending-cleanup');
178
- return {
179
- ok: false,
180
- message: `PR #${state.prNumber} not yet merged. Run xtrm finish when ready.`,
181
- };
182
- }
183
-
184
- function isWorkingTreeDirty(cwd: string): boolean {
185
- const st = run('git', ['status', '--porcelain'], cwd);
186
- return st.code === 0 && st.stdout.length > 0;
187
- }
188
-
189
- function runPhase1(controlCwd: string, executionCwd: string, state: SessionState): FinishResult {
190
- if (isWorkingTreeDirty(executionCwd)) {
191
- const add = run('git', ['add', '-A'], executionCwd);
192
- if (add.code !== 0) return { ok: false, message: add.stderr || add.stdout };
193
-
194
- const msg = `feat(${state.issueId}): ${state.branch}`;
195
- const commit = run('git', ['commit', '-m', msg], executionCwd);
196
- if (commit.code !== 0) {
197
- return { ok: false, message: commit.stderr || commit.stdout || 'git commit failed' };
198
- }
199
- }
200
-
201
- const push = run('git', ['push', '-u', 'origin', state.branch], executionCwd);
202
- if (push.code !== 0) return { ok: false, message: push.stderr || push.stdout || 'git push failed' };
203
-
204
- const create = run('gh', ['pr', 'create', '--fill'], executionCwd);
205
- if (create.code !== 0) return { ok: false, message: create.stderr || create.stdout || 'gh pr create failed' };
206
- const parsed = parsePrCreate(create.stdout);
207
-
208
- const merge = run('gh', ['pr', 'merge', '--squash', '--auto'], executionCwd);
209
- if (merge.code !== 0) return { ok: false, message: merge.stderr || merge.stdout || 'gh pr merge failed' };
210
-
211
- ensureCleanPhaseTransition(controlCwd, 'phase1-done', {
212
- prNumber: parsed.prNumber,
213
- prUrl: parsed.prUrl,
214
- });
215
- ensureCleanPhaseTransition(controlCwd, 'waiting-merge', {
216
- prNumber: parsed.prNumber,
217
- prUrl: parsed.prUrl,
218
- });
219
-
220
- return { ok: true, message: 'phase1 complete' };
221
- }
222
-
223
- export async function runXtrmFinish(options: FinishOptions = {}): Promise<FinishResult> {
224
- const cwd = options.cwd ?? process.cwd();
225
- const controlCwd = getControlRepoRoot(cwd);
226
-
227
- const state = readSessionState(controlCwd);
228
- if (!state) {
229
- return { ok: false, message: 'No .xtrm-session-state.json found. Claim an issue first (bd update <id> --claim).' };
230
- }
231
-
232
- const executionCwd = resolveExecutionCwd(controlCwd, state);
233
-
234
- const opts: Required<FinishOptions> = {
235
- cwd: controlCwd,
236
- pollIntervalMs: options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS,
237
- timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
238
- };
239
-
240
- if (state.phase === 'cleanup-done') {
241
- return { ok: true, message: 'Session is already cleanup-done.' };
242
- }
243
-
244
- if (state.phase === 'conflicting') {
245
- const resolved = handleRebaseAndPush(executionCwd);
246
- if (!resolved.ok) {
247
- const files = resolved.conflicts ?? getConflictFiles(executionCwd);
248
- ensureCleanPhaseTransition(controlCwd, 'conflicting', { conflictFiles: files });
249
- return { ok: false, message: `Conflicts in: ${files.join(', ') || 'unknown files'}. Resolve, push, then re-run xtrm finish.` };
250
- }
251
- ensureCleanPhaseTransition(controlCwd, 'waiting-merge', { conflictFiles: [] });
252
- const refreshed = readSessionState(controlCwd) ?? state;
253
- return pollUntilMerged(controlCwd, executionCwd, refreshed, opts);
254
- }
255
-
256
- if (state.phase === 'waiting-merge' || state.phase === 'pending-cleanup' || state.phase === 'merged') {
257
- const refreshed = readSessionState(controlCwd) ?? state;
258
- if (refreshed.phase === 'merged') return cleanupPhase(controlCwd, refreshed);
259
- return pollUntilMerged(controlCwd, executionCwd, refreshed, opts);
260
- }
261
-
262
- const phase1 = runPhase1(controlCwd, executionCwd, state);
263
- if (!phase1.ok) return phase1;
264
-
265
- const refreshed = readSessionState(controlCwd) ?? state;
266
- return pollUntilMerged(controlCwd, executionCwd, refreshed, opts);
267
- }
@@ -1,118 +0,0 @@
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
- });
@@ -1,124 +0,0 @@
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
- });
@@ -1,148 +0,0 @@
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
- });