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.
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
- package/dist/index.cjs +969 -1059
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/clean.ts +7 -6
- package/src/commands/debug.ts +255 -0
- package/src/commands/docs.ts +180 -0
- package/src/commands/help.ts +92 -171
- package/src/commands/init.ts +9 -32
- package/src/commands/install-pi.ts +9 -16
- package/src/commands/install.ts +150 -2
- package/src/commands/pi-install.ts +10 -44
- package/src/core/context.ts +4 -52
- package/src/core/diff.ts +3 -16
- package/src/core/preflight.ts +0 -1
- package/src/index.ts +7 -4
- package/src/types/config.ts +0 -2
- package/src/utils/config-injector.ts +3 -3
- package/src/utils/pi-extensions.ts +41 -0
- package/src/utils/worktree-session.ts +86 -50
- package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
- package/test/extensions/beads-parity.test.ts +94 -0
- package/test/extensions/extension-harness.ts +5 -5
- package/test/extensions/quality-gates-parity.test.ts +89 -0
- package/test/extensions/session-flow.test.ts +91 -0
- package/test/extensions/xtrm-loader.test.ts +38 -20
- package/test/install-pi.test.ts +22 -11
- package/test/pi-extensions.test.ts +50 -0
- package/test/session-launcher.test.ts +28 -38
- package/extensions/beads.ts +0 -109
- package/extensions/core/adapter.ts +0 -45
- package/extensions/core/lib.ts +0 -3
- package/extensions/core/logger.ts +0 -45
- package/extensions/core/runner.ts +0 -71
- package/extensions/custom-footer.ts +0 -160
- package/extensions/main-guard-post-push.ts +0 -44
- package/extensions/main-guard.ts +0 -126
- package/extensions/minimal-mode.ts +0 -201
- package/extensions/quality-gates.ts +0 -67
- package/extensions/service-skills.ts +0 -150
- package/extensions/xtrm-loader.ts +0 -89
- package/hooks/gitnexus-impact-reminder.py +0 -13
- package/src/commands/finish.ts +0 -25
- package/src/core/session-state.ts +0 -139
- package/src/core/xtrm-finish.ts +0 -267
- package/src/tests/session-flow-parity.test.ts +0 -118
- package/src/tests/session-state.test.ts +0 -124
- package/src/tests/xtrm-finish.test.ts +0 -148
package/src/core/xtrm-finish.ts
DELETED
|
@@ -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
|
-
});
|