voyage/tests/lib/session-orders.test.mjs
Kjell Tore Guttormsen a61cde2b38
test(execute): Check 4 and Step 5 must survive a first run under zsh (red)
The Bash tool runs the user's shell; on macOS that is zsh, whose NOMATCH
option fails an unmatched session-* glob with exit 1. A first run has no
worktrees yet, so Check 4 stops every first multi-session run and every
re-run after a declined main-merge gate. Red under /bin/zsh, green under
/bin/bash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:40:05 +02:00

789 lines
42 KiB
JavaScript

// tests/lib/session-orders.test.mjs
// /trekexecute's multi-session path turns each wave into orders plus starters
// and stops: the user opens every session themselves. Voyage starts nothing.
// These tests run the module and its CLI on a small fixture plan and prove
// both halves: the orders land, and no process is started. The later tests
// pin the collect side: a running session is never torn down, progress files
// outlive their worktrees, merged waves are remembered, and the prose in
// commands/trekexecute.md calls these helpers in that order.
//
// The git side of the multi-session path is pinned by EXECUTING the bash
// blocks of commands/trekexecute.md in throwaway repos (with linked worktrees):
// nothing is force-deleted, a refusal keeps the work and says so, a dirty main
// checkout or a refused merge stops before anything changes, and a linked
// worktree is never mistaken for the main checkout. One test is a STRUCTURAL
// guard over the text (no force flag anywhere in Phase 2.55/2.6/7.6); the
// executed blocks are what pin the behavior.
import { test } from 'node:test';
import { strict as assert } from 'node:assert';
import { spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync, rmSync, chmodSync, cpSync, realpathSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { join, dirname } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import {
parseExecutionStrategy, buildWaveOrders, progressFileFor, waveStatus,
} from '../../lib/util/session-orders.mjs';
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, '..', '..');
const MODULE = join(ROOT, 'lib', 'util', 'session-orders.mjs');
const FIXTURE = join(ROOT, 'tests', 'fixtures', 'session-orders', 'plan.md');
const PLAN_REL = '.claude/plans/demo.md';
const EXEC = readFileSync(join(ROOT, 'commands', 'trekexecute.md'), 'utf-8');
// The file `/trekexecute --session N .claude/plans/demo.md` writes, spelled out
// (Phase 3: {plan-dir}/.trekexecute-progress-{slug}-session-{N}.json, seen from
// the session's cwd inside its worktree). Literal on purpose: the tests write
// fake progress files HERE, never at whatever progressFileFor returns.
const PROGRESS_REL = {
1: 'worktrees/session-1/.claude/plans/.trekexecute-progress-demo-session-1.json',
2: 'worktrees/session-2/plugins/demo/.claude/plans/.trekexecute-progress-demo-session-2.json',
};
function putProgress(sessionDir, n, body) {
const f = join(sessionDir, PROGRESS_REL[n]);
mkdirSync(dirname(f), { recursive: true });
writeFileSync(f, typeof body === 'string' ? body : JSON.stringify(body));
return f;
}
function cli(root, sessionDir, ...a) {
return spawnSync(process.execPath, [MODULE, ...a, '--session-dir', sessionDir], { cwd: root, encoding: 'utf-8' });
}
// The text of commands/trekexecute.md from `start` up to `end` (exclusive).
function section(start, end) {
const a = EXEC.indexOf(start);
assert.ok(a >= 0, `trekexecute.md has no "${start}"`);
const b = EXEC.indexOf(end, a + start.length);
assert.ok(b > a, `trekexecute.md has no "${end}" after "${start}"`);
return EXEC.slice(a, b);
}
function withRepo(fn) {
const root = mkdtempSync(join(tmpdir(), 'session-orders-'));
try {
mkdirSync(join(root, '.claude', 'plans'), { recursive: true });
cpSync(FIXTURE, join(root, PLAN_REL));
return fn(root);
} finally {
rmSync(root, { recursive: true, force: true });
}
}
// The nth ```bash fence after `anchor` (and before `end`, when given).
function bashBlock(anchor, n = 0, end = null) {
let at = EXEC.indexOf(anchor);
assert.ok(at >= 0, `trekexecute.md has no "${anchor}"`);
const limit = end ? EXEC.indexOf(end, at + anchor.length) : EXEC.length;
let body = null;
for (let i = 0; i <= n; i++) {
const open = EXEC.indexOf('```bash', at);
assert.ok(open >= 0 && open < limit, `no bash block #${i} after "${anchor}"${end ? ` before "${end}"` : ''}`);
const start = EXEC.indexOf('\n', open) + 1;
const close = EXEC.indexOf('```', start);
body = EXEC.slice(start, close);
at = close + 3;
}
return body;
}
function fill(script, values) {
let out = script;
for (const [k, v] of Object.entries(values)) out = out.split(`{${k}}`).join(v);
return out;
}
const BASH = existsSync('/bin/bash') ? '/bin/bash' : 'bash';
function runBash(script, cwd, env = {}) {
return spawnSync(BASH, ['-c', script], { cwd, encoding: 'utf-8', env: { ...process.env, ...env } });
}
function gitc(cwd, ...args) {
const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
assert.equal(r.status, 0, `git ${args.join(' ')}: ${r.stderr}`);
return r.stdout.trim();
}
function branches(root) {
return gitc(root, 'for-each-ref', '--format=%(refname:short)', 'refs/heads/trek/demo/').split('\n').filter(Boolean);
}
function hasMergeHead(root) {
return spawnSync('git', ['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: root }).status === 0;
}
// withRepo, as a git repo: the fixture plan committed, a local identity, no hooks.
function withGitRepo(fn) {
return withRepo((dir) => {
const root = realpathSync(dir);
gitc(root, 'init', '-q');
gitc(root, 'config', 'user.email', 'test@example.invalid');
gitc(root, 'config', 'user.name', 'test');
gitc(root, 'config', 'core.hooksPath', '/dev/null');
gitc(root, 'add', '.');
gitc(root, 'commit', '-q', '-m', 'init');
return fn(root);
});
}
// A session worktree + branch the way Step 1 makes them, optionally with a
// committed file (unmerged work) or an untracked file (a dirty worktree).
function addSession(root, n, { commit = null, untracked = null } = {}) {
const wt = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees', `session-${n}`);
gitc(root, 'worktree', 'add', '-q', '-b', `trek/demo/session-${n}`, wt, 'HEAD');
if (commit) {
writeFileSync(join(wt, commit), `session ${n}\n`);
gitc(wt, 'add', commit);
gitc(wt, 'commit', '-q', '-m', `session ${n}`);
}
if (untracked) writeFileSync(join(wt, untracked), 'notes\n');
return wt;
}
const sha256 = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
test('parseExecutionStrategy reads sessions, waves, step ranges, dependencies and cwd', () => {
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
assert.deepEqual(s.waves, [1, 2]);
assert.deepEqual(s.sessions.map(x => [x.n, x.title, x.wave, x.steps, x.dependsOn, x.cwd]), [
[1, 'Parser', 1, [1, 2], [], '.'],
[2, 'Plugin subdir', 1, [3, 4], [], 'plugins/demo'],
[3, 'Integration', 2, [5], [1, 2], '.'],
]);
});
test('parseExecutionStrategy refuses a plan without a strategy, and a session without a wave', () => {
assert.throws(() => parseExecutionStrategy('# Plan\n\n## Verification\n'), /no ## Execution Strategy/);
assert.throws(
() => parseExecutionStrategy('## Execution Strategy\n\n### Session 1: X\n- **Steps:** 1\n'),
/Session 1 has no Wave/,
);
});
test('buildWaveOrders gives one order per session in the wave, each with its own worktree and a starter', () => {
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
const orders = buildWaveOrders({ strategy: s, wave: 1, planPath: PLAN_REL, sessionDir: '/r/.claude/trekplan-sessions/demo' });
assert.equal(orders.length, 2);
assert.deepEqual(orders.map(o => o.starter), [
`/trekexecute --session 1 ${PLAN_REL}`,
`/trekexecute --session 2 ${PLAN_REL}`,
]);
assert.deepEqual(orders.map(o => o.openIn), [
'/r/.claude/trekplan-sessions/demo/worktrees/session-1',
'/r/.claude/trekplan-sessions/demo/worktrees/session-2/plugins/demo',
]);
assert.equal(new Set(orders.map(o => o.worktreePath)).size, 2, 'two sessions never share a worktree');
for (const o of orders) {
assert.match(o.body, /Voyage does not start this session/);
assert.ok(o.body.includes(o.starter));
assert.ok(o.body.includes(`/r/.claude/trekplan-sessions/demo/${PROGRESS_REL[o.session]}`),
`order ${o.session} must name the literal progress file the session writes`);
}
assert.throws(() => buildWaveOrders({ strategy: s, wave: 3, planPath: PLAN_REL, sessionDir: '/r' }), /no sessions in wave 3/);
assert.throws(() => buildWaveOrders({ strategy: s, wave: 1, planPath: '/abs/plan.md', sessionDir: '/r' }), /repo-relative/);
});
test('the module cannot start a process: it imports nothing that could', () => {
const src = readFileSync(MODULE, 'utf-8');
assert.doesNotMatch(src, /child_process|worker_threads|\bspawn\(|\bexec(File)?(Sync)?\(/);
});
test('CLI on the fixture: wave 1 writes two orders and wave.json, prints the starters, and starts nothing', () => {
withRepo((root) => {
// Trap: a `claude` first on PATH that leaves a marker if anything runs it.
const bin = join(root, 'trap-bin');
const marker = join(root, 'claude-was-started');
mkdirSync(bin);
writeFileSync(join(bin, 'claude'), `#!/bin/sh\ntouch "${marker}"\n`);
chmodSync(join(bin, 'claude'), 0o755);
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const r = spawnSync(process.execPath, [MODULE, 'write', '--plan', PLAN_REL, '--wave', '1', '--session-dir', sessionDir], {
cwd: root, encoding: 'utf-8', env: { ...process.env, PATH: `${bin}:${process.env.PATH}` },
});
assert.equal(r.status, 0, r.stderr);
assert.equal(existsSync(marker), false, 'nothing may start Claude Code');
const ordersDir = join(sessionDir, 'orders');
assert.deepEqual(readdirSync(ordersDir).sort(), ['wave-1-session-1.md', 'wave-1-session-2.md', 'wave.json']);
const wave = JSON.parse(readFileSync(join(ordersDir, 'wave.json'), 'utf-8'));
assert.deepEqual([wave.wave, wave.sessions, wave.status, wave.plan], [1, [1, 2], 'awaiting-sessions', PLAN_REL]);
assert.match(r.stdout, /\/trekexecute --session 1 \.claude\/plans\/demo\.md/);
assert.match(r.stdout, /\/trekexecute --session 2 \.claude\/plans\/demo\.md/);
assert.match(r.stdout, /Voyage starts none of them/);
});
});
test('progressFileFor is the literal Phase 3 path inside the session cwd, extension stripped from the slug', () => {
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
const sessionDir = '/r/.claude/trekplan-sessions/demo';
for (const n of [1, 2]) {
assert.equal(progressFileFor({ strategy: s, n, planPath: PLAN_REL, sessionDir }), `${sessionDir}/${PROGRESS_REL[n]}`);
}
});
test('CLI status: pending until each session writes its progress file (at the literal path), then completed', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
assert.equal(run('write', '--wave', '1').status, 0);
let st = JSON.parse(run('status', '--wave', '1').stdout);
assert.deepEqual(st.sessions.map(x => x.status), ['pending', 'pending']);
assert.equal(st.all_completed, false);
putProgress(sessionDir, 1, { status: 'completed' });
putProgress(sessionDir, 2, { status: 'failed' });
st = JSON.parse(run('status', '--wave', '1').stdout);
assert.deepEqual(st.sessions.map(x => x.status), ['completed', 'failed']);
assert.equal(st.all_completed, false);
putProgress(sessionDir, 2, { status: 'completed' });
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
st = waveStatus({ strategy: s, wave: 1, planPath: PLAN_REL, planSha256: sha256(join(root, PLAN_REL)), sessionDir });
assert.equal(st.all_completed, true);
});
});
test('CLI status classifies every session as completed, running or ended-bad and keeps the raw status', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
assert.equal(run('write', '--wave', '1').status, 0);
putProgress(sessionDir, 2, { status: 'completed' });
// [progress body for session 1 (null = no file yet), raw status, state]
const cases = [
[null, 'pending', 'running'],
[{ status: 'pending' }, 'pending', 'running'],
[{ status: 'in-progress' }, 'in-progress', 'running'],
[{ status: 'in_progress' }, 'in_progress', 'running'],
[{ status: 'waiting-on-the-moon' }, 'waiting-on-the-moon', 'running'],
[{ status: 'completed' }, 'completed', 'completed'],
[{ status: 'failed' }, 'failed', 'ended-bad'],
[{ status: 'stopped' }, 'stopped', 'ended-bad'],
[{ status: 'partial' }, 'partial', 'ended-bad'],
[{ status: 'blocked' }, 'blocked', 'ended-bad'],
['{ not json', 'unreadable', 'ended-bad'],
];
for (const [body, raw, state] of cases) {
rmSync(join(sessionDir, PROGRESS_REL[1]), { force: true });
if (body !== null) putProgress(sessionDir, 1, body);
const r = run('status', '--wave', '1');
assert.equal(r.status, 0, r.stderr);
const st = JSON.parse(r.stdout);
assert.deepEqual([st.sessions[0].status, st.sessions[0].state], [raw, state], `progress ${JSON.stringify(body)}`);
assert.deepEqual(st.running, state === 'running' ? [1] : [], `running list for ${raw}`);
assert.deepEqual(st.ended_bad, state === 'ended-bad' ? [1] : [], `ended_bad list for ${raw}`);
assert.equal(st.all_completed, state === 'completed');
}
});
});
test('a failed session next to a running one: status reports it running, and Step 3 stops on running before any failure cleanup', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
assert.equal(run('write', '--wave', '1').status, 0);
putProgress(sessionDir, 1, { status: 'failed' });
putProgress(sessionDir, 2, { status: 'in-progress' });
const st = JSON.parse(run('status', '--wave', '1').stdout);
assert.deepEqual([st.running, st.ended_bad, st.all_completed], [[2], [1], false]);
});
const step3 = section('### Step 3 — Collect wave W', '### Step 4 —');
const iRunning = step3.indexOf('**Any `running`');
const iBad = step3.indexOf('**Any `ended-bad`');
const iMerge = step3.indexOf('git merge --no-ff');
assert.ok(iRunning >= 0 && iBad > iRunning && iMerge > iBad,
'Step 3 must branch on running FIRST, then ended-bad, and merge only after both');
const runningBranch = step3.slice(iRunning, iBad);
assert.match(runningBranch, /wave\.json/, 'the running branch must say wave.json is left alone');
assert.doesNotMatch(runningBranch, /Jump to Step 5/, 'the running branch must never reach Step 5 cleanup');
for (const raw of ['in-progress', 'in_progress', 'partial', 'blocked', 'unreadable']) {
assert.ok(step3.includes(`\`${raw}\``), `Step 3 must name the raw status \`${raw}\``);
}
});
test('archive copies every progress file into orders/ before the worktrees go, and the copies outlive them', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
assert.equal(run('write', '--wave', '1').status, 0);
const one = JSON.stringify({ status: 'completed', steps: { 1: { status: 'passed' } } });
const two = JSON.stringify({ status: 'failed', steps: { 3: { status: 'failed' } } });
putProgress(sessionDir, 1, one);
putProgress(sessionDir, 2, two);
const r = run('archive', '--wave', '1');
assert.equal(r.status, 0, r.stderr);
const copies = { 1: join(sessionDir, 'orders', 'wave-1-session-1.progress.json'), 2: join(sessionDir, 'orders', 'wave-1-session-2.progress.json') };
assert.deepEqual(JSON.parse(r.stdout).map(x => x.to), [copies[1], copies[2]]);
// Step 5 / the merge remove the worktrees and wave.json; the copies stay.
rmSync(join(sessionDir, 'worktrees'), { recursive: true, force: true });
rmSync(join(sessionDir, 'orders', 'wave.json'));
assert.equal(readFileSync(copies[1], 'utf-8'), one);
assert.equal(readFileSync(copies[2], 'utf-8'), two);
});
});
test('merged-waves record: next-wave skips merged waves, survives Step 5, says none when all are merged, and clear resets it', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
const next = () => { const r = run('next-wave'); assert.equal(r.status, 0, r.stderr); return r.stdout.trim(); };
assert.equal(next(), '1', 'nothing merged: the lowest wave');
assert.equal(run('write', '--wave', '1').status, 0);
assert.equal(run('record-merged', '--wave', '1').status, 0);
rmSync(join(sessionDir, 'orders', 'wave.json')); // Step 5 deletes the wave marker, never the record
assert.equal(next(), '2', 'wave 1 merged: a re-run after a wave-2 failure prepares wave 2, not wave 1');
assert.equal(run('record-merged', '--wave', '2').status, 0);
assert.equal(next(), 'none', 'every wave merged');
assert.equal(run('clear').status, 0);
assert.equal(existsSync(join(sessionDir, 'orders')), false, 'clear removes orders, copies and the record');
assert.equal(next(), '1', 'after clear a fresh run starts at the lowest wave');
});
});
test('clear refuses while a wave awaits its sessions; the record refuses another plan with the same slug', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
assert.equal(run('write', '--wave', '1').status, 0);
const r = run('clear');
assert.equal(r.status, 1);
assert.match(r.stderr, /wave\.json/);
assert.ok(existsSync(join(sessionDir, 'orders', 'wave.json')), 'nothing removed while a wave is in flight');
assert.equal(run('record-merged', '--wave', '1').status, 0);
mkdirSync(join(root, 'other'));
cpSync(FIXTURE, join(root, 'other', 'demo.md'));
const o = cli(root, sessionDir, 'next-wave', '--plan', 'other/demo.md');
assert.equal(o.status, 1);
assert.match(o.stderr, /unfinished multi-session run of \.claude\/plans\/demo\.md/);
});
});
test('trekexecute.md Step 3 archives before any worktree goes, records the merge, and Step 6 reads the copies', () => {
const step3 = section('### Step 3 — Collect wave W', '### Step 4 —');
const iArchive = step3.indexOf('lib/util/session-orders.mjs" archive');
assert.ok(iArchive > step3.indexOf('**Any `running`'), 'archive runs once no session is running');
assert.ok(iArchive < step3.indexOf('**Any `ended-bad`'), 'archive runs before the failure branch jumps to Step 5');
assert.ok(iArchive < step3.indexOf('git worktree remove'), 'archive runs before the merged wave is removed');
const iRecord = step3.indexOf('lib/util/session-orders.mjs" record-merged');
assert.ok(iRecord > step3.lastIndexOf('git merge --no-ff'), 'the wave is recorded after its merges');
assert.ok(iRecord < step3.indexOf('git worktree remove'), 'the wave is recorded before its worktrees go');
const step0 = section('### Step 0 — Resolve paths', '### Step 1 —');
assert.ok(step0.includes('lib/util/session-orders.mjs" next-wave'), 'NO_WAVE asks the record for the next wave');
assert.match(step0, /not yet merged/);
const step5 = section('### Step 5 — Cleanup', '### Step 6 —');
for (const line of step5.split('\n').filter(l => /^\s*rm\b/.test(l))) {
assert.ok(!/ORDERS_DIR/.test(line) || /ORDERS_DIR\/wave\.json"$/.test(line.trim()),
`Step 5 may delete only wave.json from the orders dir: ${line}`);
}
const step6 = section('### Step 6 — Aggregate results', '### --session N mode');
assert.ok(step6.includes('$ORDERS_DIR/wave-*-session-*.progress.json'), 'Step 6 reads the archived copies');
assert.ok(!step6.includes('lib/util/session-orders.mjs" clear'),
'Step 6 keeps the orders dir: a declined main-merge gate must still find the merged-waves record');
});
test('trekexecute.md: Phase 7.6 promises no unwired re-run and names a path that still exists; Hard rule 12 carries the awaiting-sessions exception', () => {
const p76 = section('## Phase 7.6 —', '### Iteration caps');
assert.ok(!p76.includes('{WORKTREE_PATH}'), 'the session worktree is gone by Phase 7.6');
assert.ok(p76.includes('{REPO_ROOT}'), 'the recovery order names the main checkout');
assert.doesNotMatch(p76, /run this same \/trekexecute command again|On that later run/,
'nothing routes a later run to a recovery in progress');
const rule12 = section('12. **Worktree cleanup', '13. **');
assert.match(rule12, /never while a wave awaits its sessions/);
assert.match(rule12, /never with force/i, 'Hard rule 12 carries the no-force invariant');
});
test('wave.json and the merged-waves record carry the plan sha256; a changed plan is refused, naming the run that holds the orders dir and how to clear it', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
const sha = sha256(join(root, PLAN_REL));
assert.equal(run('write', '--wave', '1').status, 0);
assert.equal(JSON.parse(readFileSync(join(sessionDir, 'orders', 'wave.json'), 'utf-8')).plan_sha256, sha);
assert.ok(readFileSync(join(sessionDir, 'orders', 'wave-1-session-1.md'), 'utf-8')
.includes(`- progress file: ${join(sessionDir, PROGRESS_REL[1])}`), 'the order names its progress file on a line of its own');
assert.equal(run('record-merged', '--wave', '1').status, 0);
assert.equal(JSON.parse(readFileSync(join(sessionDir, 'orders', 'merged-waves.json'), 'utf-8')).plan_sha256, sha);
writeFileSync(join(root, PLAN_REL), readFileSync(FIXTURE, 'utf-8') + '\n<!-- edited -->\n');
const st = run('status', '--wave', '1');
assert.equal(st.status, 1, 'a wave written for another version of the plan is not collected');
assert.match(st.stderr, /wave\.json/);
rmSync(join(sessionDir, 'orders', 'wave.json'));
const nx = run('next-wave');
assert.equal(nx.status, 1, 'a record written for another version of the plan is refused');
assert.ok(nx.stderr.includes(join(sessionDir, 'orders')), 'the message names the orders dir');
assert.match(nx.stderr, /unfinished multi-session run of \.claude\/plans\/demo\.md/);
assert.match(nx.stderr, /clear --session-dir/);
});
});
// STRUCTURAL GUARD over the text, not a behavior test: the invariant is that
// the multi-session path never force-deletes. The executed-block tests below
// pin the behavior; this one only catches a force flag creeping back in.
test('structural guard: Phase 2.55, 2.6 and 7.6 carry no force-delete of a worktree, a branch or a directory', () => {
const text = [section('## Phase 2.55 —', '## Phase 3 '), section('## Phase 7.6 —', '## Phase 8 —')]
.join('\n').replace(/\\\n\s*/g, ' ');
const FORBIDDEN = [
/worktree\s+remove\b.*(--force|\s-f\b)/,
/\bbranch\b.*(\s-[a-zA-Z]*D\b|\s--force\b|\s-f\b)/,
/\brm\s+-[a-zA-Z]*r/,
/\bgit\s+clean\b/,
/\breset\s+--hard\b/,
];
assert.deepEqual(text.split('\n').filter(l => FORBIDDEN.some(re => re.test(l))), []);
});
test('Phase 2.5 finds a wave in flight from any subdirectory of the main checkout and refuses a linked worktree', () => {
withGitRepo((root) => {
const block = fill(bashBlock('## Phase 2.5 —', 0, '## Phase 2.55'), { slug: 'demo' });
assert.doesNotMatch(block, /--show-toplevel/, 'a linked worktree has its own toplevel');
const sub = join(root, 'plugins', 'demo');
mkdirSync(sub, { recursive: true });
let r = runBash(block, sub);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout.trim(), 'NO_WAVE');
const orders = join(root, '.claude', 'trekplan-sessions', 'demo', 'orders');
mkdirSync(orders, { recursive: true });
writeFileSync(join(orders, 'wave.json'), '{}\n');
r = runBash(block, sub);
assert.equal(r.stdout.trim(), 'WAVE_IN_FLIGHT');
const wt = addSession(root, 1);
r = runBash(block, wt);
assert.equal(r.status, 1, 'run from a session worktree');
assert.match(r.stderr, /main checkout/);
assert.ok(r.stderr.includes(root), 'the message names the main checkout');
});
});
test('Phase 2.55 Check 1 does not count Voyage\'s own session state as uncommitted work', () => {
const c1 = section('### Check 1 —', '### Check 2 —');
assert.ok(c1.includes("git status --porcelain -- ':(top,exclude).claude/trekplan-sessions/'"));
withGitRepo((root) => {
addSession(root, 1);
mkdirSync(join(root, '.claude', 'trekplan-sessions', 'demo', 'orders'), { recursive: true });
writeFileSync(join(root, '.claude', 'trekplan-sessions', 'demo', 'orders', 'merged-waves.json'), '{}\n');
const sub = join(root, 'plugins');
mkdirSync(sub);
const r = spawnSync('git', ['status', '--porcelain', '--', ':(top,exclude).claude/trekplan-sessions/'], { cwd: sub, encoding: 'utf-8' });
assert.equal(r.stdout, '', 'orders and session worktrees are not a dirty tree');
writeFileSync(join(root, PLAN_REL), 'edited\n');
const d = spawnSync('git', ['status', '--porcelain', '--', ':(top,exclude).claude/trekplan-sessions/'], { cwd: sub, encoding: 'utf-8' });
assert.match(d.stdout, /demo\.md/, 'a real change anywhere in the repo still is');
});
});
test('Check 4 refuses from a linked worktree and while a wave is in flight, and removes only what holds no work', () => {
withGitRepo((root) => {
const block = fill(bashBlock('### Check 4 —'), { slug: 'demo' });
const s1 = addSession(root, 1, { commit: 's1.txt' }); // clean worktree, unmerged commit
const s2 = addSession(root, 2, { untracked: 'notes.txt' }); // untracked file, branch at HEAD
gitc(root, 'branch', 'trek/demo/session-3'); // merged, no worktree
const orders = join(root, '.claude', 'trekplan-sessions', 'demo', 'orders');
mkdirSync(orders, { recursive: true });
writeFileSync(join(orders, 'wave.json'), '{}\n');
const sub = join(root, 'plugins', 'demo');
mkdirSync(sub, { recursive: true });
let r = runBash(block, s2);
assert.equal(r.status, 1, 'run from a session worktree');
assert.match(r.stderr, /main checkout/);
r = runBash(block, sub);
assert.equal(r.status, 1, 'from a subdirectory, a wave in flight stops Check 4');
assert.match(r.stderr, /wave\.json/);
assert.ok(existsSync(s1) && existsSync(s2), 'nothing removed');
assert.equal(branches(root).length, 3);
rmSync(join(orders, 'wave.json'));
r = runBash(block, sub);
assert.equal(r.status, 1, 'kept work stops the pre-flight');
assert.equal(existsSync(s1), false, 'a clean worktree goes; its branch keeps the commit');
assert.ok(existsSync(join(s2, 'notes.txt')), 'a worktree with untracked files is kept');
assert.deepEqual(branches(root), ['trek/demo/session-1', 'trek/demo/session-2'],
'the unmerged branch and the one checked out in a kept worktree stay; the merged one goes');
const out = r.stdout + r.stderr;
assert.match(out, /KEPT worktree .*session-2/);
assert.match(out, /KEPT branch trek\/demo\/session-1/);
});
});
test('Step 5 removes only what holds no work, lists every kept worktree and branch, and drops the wave marker', () => {
withGitRepo((root) => {
const sd = join(root, '.claude', 'trekplan-sessions', 'demo');
const s1 = addSession(root, 1, { commit: 's1.txt' });
const s2 = addSession(root, 2, { untracked: 'notes.txt' });
gitc(root, 'branch', 'trek/demo/session-3');
mkdirSync(join(sd, 'orders'), { recursive: true });
writeFileSync(join(sd, 'orders', 'wave.json'), '{}\n');
const block = fill(bashBlock('### Step 5 — Cleanup'), { slug: 'demo' });
const r = runBash(block, root, { REPO_ROOT: root, WORKTREE_DIR: join(sd, 'worktrees'), ORDERS_DIR: join(sd, 'orders') });
assert.equal(r.status, 0, r.stderr);
assert.equal(existsSync(s1), false);
assert.ok(existsSync(join(s2, 'notes.txt')));
assert.deepEqual(branches(root), ['trek/demo/session-1', 'trek/demo/session-2']);
assert.match(r.stdout, /KEPT worktree .*session-2/);
assert.match(r.stdout, /KEPT branch trek\/demo\/session-1/);
assert.equal(existsSync(join(sd, 'orders', 'wave.json')), false);
});
});
// The Bash tool runs the user's shell, and on macOS that is zsh, whose NOMATCH
// option turns an unmatched `session-*` glob into an error (exit 1) instead of
// a literal word. A first run has no worktrees yet, so every glob loop must
// survive zsh as well as bash.
const ZSH = existsSync('/bin/zsh') ? '/bin/zsh' : null;
test('Check 4 and Step 5 pass a first run (no worktrees yet) under zsh as well as bash', { skip: ZSH ? false : 'no /bin/zsh' }, () => {
withGitRepo((root) => {
const sd = join(root, '.claude', 'trekplan-sessions', 'demo');
const env = { REPO_ROOT: root, WORKTREE_DIR: join(sd, 'worktrees'), ORDERS_DIR: join(sd, 'orders') };
const check4 = fill(bashBlock('### Check 4 —'), { slug: 'demo' });
const step5 = fill(bashBlock('### Step 5 — Cleanup'), { slug: 'demo' });
for (const shell of [BASH, ZSH]) {
for (const [name, block] of [['Check 4', check4], ['Step 5', step5]]) {
const r = spawnSync(shell, ['-c', block], { cwd: root, encoding: 'utf-8', env: { ...process.env, ...env } });
assert.equal(r.status, 0, `${name} under ${shell}: ${r.stderr}`);
assert.doesNotMatch(r.stderr, /no matches found/, `${name} under ${shell}`);
}
}
});
});
test('Step 0 resolves the main checkout and a main-relative plan path from a subdirectory, and refuses a linked worktree', () => {
withGitRepo((root) => {
const env = { CLAUDE_PLUGIN_ROOT: ROOT };
const show = '\nprintf "%s\\n" "$REPO_ROOT" "$PLAN_REL"\n';
const sub = join(root, 'plugins', 'demo');
mkdirSync(sub, { recursive: true });
const r = runBash(fill(bashBlock('### Step 0 — Resolve paths'), { slug: 'demo', 'plan-path': '../../.claude/plans/demo.md' }) + show, sub, env);
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(r.stdout.trim().split('\n').slice(-2), [root, PLAN_REL]);
const wt = addSession(root, 1);
const l = runBash(fill(bashBlock('### Step 0 — Resolve paths'), { slug: 'demo', 'plan-path': PLAN_REL }) + show, wt, env);
assert.equal(l.status, 1, 'run from a session worktree');
assert.match(l.stderr, /main checkout/);
});
});
test('Step 1 stops on a session branch or worktree left by an earlier attempt, creating and deleting nothing', () => {
withGitRepo((root) => {
const wd = join(root, '.claude', 'trekplan-sessions', 'demo', 'worktrees');
const block = fill(bashBlock('**2a. Create worktrees'), { slug: 'demo', 'wave W session numbers': '1 2' });
const env = { WORKTREE_DIR: wd };
gitc(root, 'branch', 'trek/demo/session-2');
const tip = gitc(root, 'rev-parse', 'trek/demo/session-2');
let r = runBash(block, root, env);
assert.equal(r.status, 1);
assert.match(r.stderr, /trek\/demo\/session-2/);
assert.equal(gitc(root, 'rev-parse', 'trek/demo/session-2'), tip, 'the existing branch is untouched');
assert.equal(existsSync(join(wd, 'session-1')), false, 'nothing created');
gitc(root, 'branch', '-d', 'trek/demo/session-2');
mkdirSync(join(wd, 'session-1'), { recursive: true });
r = runBash(block, root, env);
assert.equal(r.status, 1);
assert.match(r.stderr, /session-1/);
rmSync(join(wd, 'session-1'), { recursive: true });
r = runBash(block, root, env);
assert.equal(r.status, 0, r.stderr);
});
});
test('Step 3 merges only into a clean main checkout and tells a refused merge from a conflict', () => {
withGitRepo((root) => {
const env = { REPO_ROOT: root };
const clean = bashBlock('**Merge session branches back');
const merge = (n) => fill(bashBlock('**Merge session branches back', 1), { slug: 'demo', N: String(n), 'session-title': `Session ${n}` });
addSession(root, 1, { commit: 's1.txt' });
addSession(root, 2, { commit: 's2.txt' });
const head = () => gitc(root, 'rev-parse', 'HEAD');
const h0 = head();
writeFileSync(join(root, PLAN_REL), 'edited\n');
let r = runBash(clean, root, env);
assert.equal(r.status, 1, 'a dirty main checkout stops the merge');
assert.match(r.stderr, /commit or stash your changes in/i);
assert.ok(r.stderr.includes(root));
gitc(root, 'checkout', '--', PLAN_REL);
assert.equal(runBash(clean, root, env).status, 0);
writeFileSync(join(root, 's1.txt'), 'mine\n'); // untracked, in the merge's way
r = runBash(merge(1), root, env);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /MERGE_REFUSED/);
assert.equal(head(), h0, 'nothing merged');
assert.equal(hasMergeHead(root), false);
assert.equal(readFileSync(join(root, 's1.txt'), 'utf-8'), 'mine\n', 'the user file is untouched');
rmSync(join(root, 's1.txt'));
writeFileSync(join(root, 's1.txt'), 'main\n');
gitc(root, 'add', 's1.txt');
gitc(root, 'commit', '-q', '-m', 'main has its own s1');
const h1 = head();
r = runBash(merge(1), root, env);
assert.notEqual(r.status, 0);
assert.match(r.stdout, /MERGE_CONFLICT/);
assert.equal(head(), h1);
assert.equal(hasMergeHead(root), false, 'a real conflict is aborted');
assert.deepEqual(branches(root), ['trek/demo/session-1', 'trek/demo/session-2']);
r = runBash(merge(2), root, env);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /MERGED/);
assert.equal(spawnSync('git', ['merge-base', '--is-ancestor', 'trek/demo/session-2', 'HEAD'], { cwd: root }).status, 0);
});
});
test('Step 3 after a merged wave: records it, drops only archived untracked progress copies, removes worktrees and merged branches without force', () => {
withGitRepo((root) => {
const sd = join(root, '.claude', 'trekplan-sessions', 'demo');
const s1 = addSession(root, 1, { commit: 's1.txt' });
const s2 = addSession(root, 2, { commit: 's2.txt' });
assert.equal(cli(root, sd, 'write', '--plan', PLAN_REL, '--wave', '1').status, 0);
putProgress(sd, 1, { status: 'completed' });
putProgress(sd, 2, { status: 'completed' });
assert.equal(cli(root, sd, 'archive', '--plan', PLAN_REL, '--wave', '1').status, 0);
gitc(root, 'merge', '--no-ff', '-q', '-m', 'merge 1', 'trek/demo/session-1');
gitc(root, 'merge', '--no-ff', '-q', '-m', 'merge 2', 'trek/demo/session-2');
// Session 2's worktree copy changed after the archive: it is not the record, so it stays.
putProgress(sd, 2, { status: 'completed', note: 'written after the archive' });
const env = { REPO_ROOT: root, VOYAGE_ROOT: ROOT, SESSION_DIR: sd, ORDERS_DIR: join(sd, 'orders'), WORKTREE_DIR: join(sd, 'worktrees'), PLAN_REL };
const record = runBash(fill(bashBlock('**Record the merged wave'), { W: '1' }), root, env);
assert.equal(record.status, 0, record.stderr);
const r = runBash(fill(bashBlock('**Record the merged wave', 1), { W: '1', slug: 'demo', 'wave W session numbers': '1 2' }), root, env);
assert.equal(r.status, 0, r.stderr);
assert.equal(existsSync(s1), false, 'session 1: identical archived copy dropped, clean worktree removed');
assert.ok(existsSync(join(sd, 'orders', 'wave-1-session-1.progress.json')), 'the archived copy stays');
assert.ok(existsSync(join(sd, PROGRESS_REL[2])), 'a progress file that differs from its archive is never deleted');
assert.match(r.stdout, /KEPT worktree .*session-2/);
assert.deepEqual(branches(root), ['trek/demo/session-2'], 'merged branch 1 deleted; branch 2 stays with its kept worktree');
assert.equal(existsSync(join(sd, 'orders', 'wave.json')), false);
assert.deepEqual(JSON.parse(readFileSync(join(sd, 'orders', 'merged-waves.json'), 'utf-8')).waves, [1]);
});
});
test('every session-orders.mjs call runs from the main checkout with the main-relative plan path', () => {
const calls = EXEC.replace(/\\\n\s*/g, ' ').split('\n')
.filter(l => /lib\/util\/session-orders\.mjs" (write|status|archive|record-merged|next-wave|clear)\b/.test(l));
assert.ok(calls.length >= 7, `expected the write/status/archive/record-merged/next-wave/clear calls, saw ${calls.length}`);
for (const l of calls) {
assert.match(l, /\(cd "\$REPO_ROOT" && node "\$VOYAGE_ROOT\/lib\/util\/session-orders\.mjs"/, l);
if (!/session-orders\.mjs" clear/.test(l)) assert.match(l, /--plan "\$PLAN_REL"/, l);
}
});
test('trekexecute.md: the stop paths name their way out', () => {
const step0 = section('### Step 0 — Resolve paths', '### Step 1 —');
assert.match(step0, /`next-wave` exits 1[\s\S]*unfinished multi-session run[\s\S]*session-orders\.mjs" clear/,
'Step 0 tells the user which run holds the orders dir and how to clear it');
const step3 = section('### Step 3 — Collect wave W', '### Step 4 —');
assert.match(step3, /\*\*Way out of a stuck wave\*\*/);
assert.match(step3, /MERGE_REFUSED[\s\S]*keep `wave\.json`/i, 'a refused merge stops and keeps the wave in flight');
});
test('the orders dir is cleared only under the main-merge gate\'s confirm; decline keeps the merged-waves record', () => {
const p26 = section('## Phase 2.6 —', '## Phase 3 ');
const step0 = section('### Step 0 — Resolve paths', '### Step 1 —');
const clears = p26.split('lib/util/session-orders.mjs" clear').length - 1;
const step0Clears = step0.split('lib/util/session-orders.mjs" clear').length - 1;
assert.equal(clears, step0Clears, 'Phase 2.6 clears nothing itself; Step 0 only names the command for a record of another plan');
const gate = section('### Main-merge gate', '### Progress + state-file writes');
const iConfirm = gate.indexOf('On confirm:');
const iDecline = gate.indexOf('On decline:');
const iClear = gate.indexOf('lib/util/session-orders.mjs" clear');
assert.ok(iConfirm >= 0 && iClear > iConfirm && iDecline > iClear, 'the clear sits under On confirm, before On decline');
assert.equal(gate.split('lib/util/session-orders.mjs" clear').length - 1, 1, 'exactly one clear, under confirm');
const confirm = gate.slice(iConfirm, iDecline);
assert.match(confirm, /`none`/, 'only when next-wave said none');
assert.match(confirm, /Step 4 passed/, 'only when master verification passed');
const decline = gate.slice(iDecline);
assert.match(decline, /merged-waves record is kept/, 'decline keeps the record');
});
test('the main-merge gate tells the truth on the wave path: merged on the current branch, nothing finalized, re-run returns to the gate', () => {
const gate = section('### Main-merge gate', '### Progress + state-file writes');
assert.doesNotMatch(gate, /Wave commits remain on their session branches/, 'by the gate the waves are merged');
assert.doesNotMatch(gate, /trekplan\/<slug>\/session-N/, 'the branches are trek/{slug}/session-N');
assert.match(gate, /already merged on the current branch/);
assert.match(gate, /Nothing is finalized/);
assert.match(gate, /re-run the same `\/trekexecute` command[\s\S]*not `--resume`/,
'the wave path continues by re-running the command (Phase 2.5 runs --resume as a single session)');
assert.match(gate, /returns to this gate/);
assert.ok(gate.includes('--resume re-enters'), 'the single-session --resume statement stays');
const footer = section('### What Remains', '**Result vocabulary');
assert.ok(footer.includes('To resume (single session): /trekexecute --resume {path}'));
assert.match(footer, /multi-session[^\n]*re-run the same \/trekexecute command/);
});
test('wave.json remembers the branch the wave was prepared on; status refuses another branch', () => {
withRepo((root) => {
const sessionDir = join(root, '.claude', 'trekplan-sessions', 'demo');
const run = (...a) => cli(root, sessionDir, ...a, '--plan', PLAN_REL);
assert.equal(run('write', '--wave', '1', '--branch', 'main').status, 0);
assert.equal(JSON.parse(readFileSync(join(sessionDir, 'orders', 'wave.json'), 'utf-8')).original_branch, 'main');
assert.equal(run('status', '--wave', '1', '--branch', 'main').status, 0);
const r = run('status', '--wave', '1', '--branch', 'feature-x');
assert.equal(r.status, 1);
assert.match(r.stderr, /switch back to main/i);
assert.match(r.stderr, /feature-x/);
});
});
test('Step 3 stops, touching nothing, when the main checkout is on another branch than the wave was prepared on', () => {
withGitRepo((root) => {
const env = { CLAUDE_PLUGIN_ROOT: ROOT };
const step0 = fill(bashBlock('### Step 0 — Resolve paths'), { slug: 'demo', 'plan-path': PLAN_REL })
+ '\nprintf "%s\\n" "$ORIGINAL_BRANCH"\n';
const vars = () => {
const r = runBash(step0, root, env);
assert.equal(r.status, 0, r.stderr);
const sd = join(root, '.claude', 'trekplan-sessions', 'demo');
return { REPO_ROOT: root, VOYAGE_ROOT: ROOT, PLAN_REL, SESSION_DIR: sd, ORIGINAL_BRANCH: r.stdout.trim().split('\n').pop() };
};
const prepared = vars();
const write = runBash(fill(bashBlock('### Step 2 — Write the session orders'), { W: '1' }), root, prepared);
assert.equal(write.status, 0, write.stderr);
const waveFile = join(prepared.SESSION_DIR, 'orders', 'wave.json');
assert.equal(JSON.parse(readFileSync(waveFile, 'utf-8')).original_branch, prepared.ORIGINAL_BRANCH);
const status = fill(bashBlock('### Step 3 — Collect wave W'), { W: '1' });
gitc(root, 'checkout', '-q', '-b', 'elsewhere');
let r = runBash(status, root, vars());
assert.equal(r.status, 1, 'collect on another branch stops');
assert.ok(r.stderr.includes(prepared.ORIGINAL_BRANCH), 'the message names the branch to switch back to');
assert.ok(existsSync(waveFile), 'wave.json is kept');
gitc(root, 'checkout', '-q', prepared.ORIGINAL_BRANCH);
r = runBash(status, root, vars());
assert.equal(r.status, 0, r.stderr);
});
const step3 = section('### Step 3 — Collect wave W', '### Step 4 —');
assert.match(step3, /another branch/, 'Step 3 says what the exit-1 stop covers');
});
test('CLI refuses a plan without an Execution Strategy, loudly', () => {
withRepo((root) => {
writeFileSync(join(root, PLAN_REL), '# Plan\n\n## Verification\n');
const r = spawnSync(process.execPath, [MODULE, 'write', '--plan', PLAN_REL, '--wave', '1', '--session-dir', join(root, 's')], { cwd: root, encoding: 'utf-8' });
assert.equal(r.status, 1);
assert.match(r.stderr, /no ## Execution Strategy/);
});
});