test(execute): pin a multi-session path that never force-deletes (red)

Round 2 of the review of the session-order flow. One invariant closes the
class: Phase 2.55, 2.6 and 7.6 remove worktrees only without force and
branches only with -d; a refusal keeps the work and is reported.

These tests execute the bash blocks of commands/trekexecute.md in
throwaway repos with linked worktrees:

- Check 4 and Step 5 remove a clean worktree and a merged branch, keep an
  unmerged branch and a worktree with untracked files, and list them
- Step 1 stops on a branch or worktree left by an earlier attempt
- Step 3 stops on a dirty main checkout (B1) and tells a refused merge
  (no MERGE_HEAD) from a real conflict; after a merged wave it records
  the wave and drops only identical, untracked, archived progress copies
- Phase 2.5, Check 4 and Step 0 resolve the main checkout and refuse a
  linked worktree (B2); Step 0 derives a main-relative plan path
- Check 1 does not count .claude/trekplan-sessions/ as a dirty tree
- wave.json and merged-waves.json carry the plan sha256 and refuse
  another plan or version, naming the run and the clear command (B3);
  Step 6, not Phase 8, clears on every wave merged + Step 4 passed

One test is a structural guard over the text (no force flag in those
phases); the executed blocks pin the behavior.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-02 16:13:42 +02:00
commit b4a0283c94
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q

View file

@ -6,11 +6,20 @@
// 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 } from 'node:fs';
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';
@ -66,6 +75,78 @@ function withRepo(fn) {
}
}
// 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]);
@ -164,7 +245,7 @@ test('CLI status: pending until each session writes its progress file (at the li
putProgress(sessionDir, 2, { status: 'completed' });
const s = parseExecutionStrategy(readFileSync(FIXTURE, 'utf-8'));
st = waveStatus({ strategy: s, wave: 1, planPath: PLAN_REL, sessionDir });
st = waveStatus({ strategy: s, wave: 1, planPath: PLAN_REL, planSha256: sha256(join(root, PLAN_REL)), sessionDir });
assert.equal(st.all_completed, true);
});
});
@ -286,7 +367,7 @@ test('clear refuses while a wave awaits its sessions; the record refuses another
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, /belongs to \.claude\/plans\/demo\.md/);
assert.match(o.stderr, /unfinished multi-session run of \.claude\/plans\/demo\.md/);
});
});
@ -311,41 +392,12 @@ test('trekexecute.md Step 3 archives before any worktree goes, records the merge
}
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 clears the orders dir');
assert.match(step6, /`none`[\s\S]*Step 4 passed|Step 4 passed[\s\S]*`none`/,
'the clear is keyed on every wave merged AND Step 4 passed, not on the Phase 8 label');
const p8 = section('## Phase 8 — Final report', '## Phase 9 —');
assert.ok(p8.includes('lib/util/session-orders.mjs" clear'), 'Phase 8 states when the orders dir is cleared');
});
test('trekexecute.md: the wave-in-flight guard is anchored at the git toplevel, and Check 4 refuses to clean while it exists', () => {
const p25 = section('## Phase 2.5 —', '## Phase 2.55');
assert.ok(p25.includes('$(git rev-parse --show-toplevel)/.claude/trekplan-sessions/{slug}/orders/wave.json'),
'Phase 2.5 must look for wave.json under the git toplevel');
assert.doesNotMatch(p25, /`\.claude\/trekplan-sessions\/\{slug\}\/orders\/wave\.json`/, 'no cwd-relative wave.json check');
const c4 = section('### Check 4 —', 'After all 4 checks pass');
const fence = c4.indexOf('```bash');
assert.ok(fence >= 0 && fence < c4.indexOf('git worktree remove'), 'Check 4 opens with its guard block');
const body = c4.slice(c4.indexOf('\n', fence) + 1, c4.indexOf('```', fence + 7));
assert.match(body, /wave\.json/);
const repo = mkdtempSync(join(tmpdir(), 'check4-'));
try {
assert.equal(spawnSync('git', ['init', '-q'], { cwd: repo }).status, 0);
const sub = join(repo, 'plugins', 'demo');
mkdirSync(sub, { recursive: true });
const orders = join(repo, '.claude', 'trekplan-sessions', 'demo', 'orders');
mkdirSync(orders, { recursive: true });
const script = body.split('{slug}').join('demo');
writeFileSync(join(orders, 'wave.json'), '{}\n');
const refused = spawnSync('bash', ['-c', script], { cwd: sub, encoding: 'utf-8' });
assert.notEqual(refused.status, 0, 'from a subdirectory, a wave in flight must stop Check 4');
assert.match(refused.stderr, /wave\.json/);
rmSync(join(orders, 'wave.json'));
const passed = spawnSync('bash', ['-c', script], { cwd: sub, encoding: 'utf-8' });
assert.equal(passed.status, 0, passed.stderr);
} finally {
rmSync(repo, { recursive: true, force: true });
}
assert.ok(!p8.includes('lib/util/session-orders.mjs" clear'), 'Phase 8 no longer decides the clear');
});
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', () => {
@ -355,8 +407,276 @@ test('trekexecute.md: Phase 7.6 promises no unwired re-run and names a path that
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 is unconditional.**', '13. **');
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);
});
});
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 => l.includes('lib/util/session-orders.mjs"'));
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('CLI refuses a plan without an Execution Strategy, loudly', () => {