An independent review of the 6.0 session-order flow found seven defects. These tests pin the fixed behavior: - status classifies each session as completed, running (no file, pending, in-progress, in_progress, or any unknown status) or ended-bad (failed, stopped, partial, blocked, unreadable), keeping the raw status; a failed session next to a running one reports running, and Step 3 must branch on running before any failure cleanup - archive copies each progress file into orders/ before worktrees go - a merged-waves record drives next-wave, survives Step 5, and clear resets it (refused while wave.json exists, and for another plan) - the Phase 2.5 guard is anchored at the git toplevel; Check 4's guard is executed from a subdirectory and must refuse while wave.json exists - Phase 7.6 names no deleted worktree and promises no unwired re-run - Hard rule 12 carries the awaiting-sessions exception - progress paths are asserted literally; the old self-referential check stayed green under a basename(planPath) mutation, the new one goes red Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
369 lines
19 KiB
JavaScript
369 lines
19 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.
|
|
|
|
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 { 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 });
|
|
}
|
|
}
|
|
|
|
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, 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, /belongs to \.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');
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
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 is unconditional.**', '13. **');
|
|
assert.match(rule12, /never while a wave awaits its sessions/);
|
|
});
|
|
|
|
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/);
|
|
});
|
|
});
|