feat(execute)!: hand waves over as session orders, never start Claude Code (green)

Operator decision 27.09.2026: Voyage works the PM way, so nothing in it
starts Claude Code. Work that needs another session becomes an order plus
a starter line the user gives a session they open themselves; Voyage
never starts the session.

/trekexecute's multi-session path (Phase 2.6) now prepares one wave at a
time: worktree per session, then lib/util/session-orders.mjs writes one
order per session (worktree, steps, done-condition, starter) plus
orders/wave.json, and the run stops. Running the same command again reads
each session's progress file (status mode), merges sequentially with
push-before-cleanup as before, and prepares the next wave. Phase 2.5 skips
the destructive pre-flight while a wave is in flight. Phase 7.6 recovery is
handed over the same way. Hard rule 21: never start Claude Code.

Removed with it, because they existed only for launched children: the
ANTHROPIC_API_KEY billing check (and its dry-run line and Hard rule 1
exception), the hooks-fire version precheck, the safety preamble, the
scoped-tool/turn/budget flags, templates/headless-launch-template.md, the
decomposer's launch.sh (now orders.md), /trekplan's "launch", the README
billing and MCP tuning sections, and scripts/q3-cache-prefix-experiment.mjs
(an experiment that spawned the binary; its results stay in docs/).
/trekresearch's deep-research pre-gate no longer runs the binary for its
version; the post-hoc check was already the authoritative guard.

Chose orders + starters over in-session subagents because the order's done
criterion asks for exactly that, and because /trekexecute disallows the
Agent tool on purpose (Hard rule 10); reopening that is a separate change.

Gate: 54 -> 0 hit lines outside EXEMPT. npm test 1250 (1245 pass, 0 fail,
5 skipped: 3 are the retired-reference scan without its local list).
verify.sh 7/0. The fixture run (tests/lib/session-orders.test.mjs) writes
two orders and wave.json for wave 1 and leaves a trap binary untouched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-09-27 15:35:22 +02:00
commit 219e40cd91
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
18 changed files with 664 additions and 1108 deletions

227
lib/util/session-orders.mjs Normal file
View file

@ -0,0 +1,227 @@
// lib/util/session-orders.mjs
// /trekexecute's multi-session path, the PM way: every session a wave needs
// becomes an ORDER (a file naming its worktree, steps and done-condition) plus
// a STARTER (the one line the user gives a Claude Code session they open
// themselves in that worktree). Voyage never starts a session; this module
// only reads the plan, writes order files and reads progress files.
//
// Subcommands (CLI shim at the bottom):
// write --plan <repo-relative plan> --wave <W> --session-dir <dir>
// writes <dir>/orders/wave-<W>-session-<N>.md + <dir>/orders/wave.json
// and prints the starters
// status --plan <repo-relative plan> --wave <W> --session-dir <dir>
// prints JSON: each session's progress-file status, all_completed
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
import { join, dirname, basename, extname, isAbsolute } from 'node:path';
function sectionBody(text, heading) {
const lines = text.split('\n');
const start = lines.findIndex(l => l.trim() === heading);
if (start === -1) return null;
const out = [];
for (let i = start + 1; i < lines.length; i++) {
if (/^## /.test(lines[i])) break;
out.push(lines[i]);
}
return out;
}
function field(block, name) {
const re = new RegExp(`^\\s*-\\s*\\*\\*${name}:\\*\\*\\s*(.*)$`, 'i');
for (const l of block) {
const m = l.match(re);
if (m) return m[1].trim();
}
return null;
}
function numberList(raw) {
if (!raw || /^none$/i.test(raw)) return [];
const out = [];
for (const part of raw.split(',')) {
const p = part.replace(/session|^s/gi, '').trim();
const range = p.match(/^(\d+)\s*[-–]\s*(\d+)$/);
if (range) {
for (let n = Number(range[1]); n <= Number(range[2]); n++) out.push(n);
} else if (/^\d+$/.test(p)) {
out.push(Number(p));
}
}
return out;
}
/**
* Parse a plan's `## Execution Strategy` section.
* @returns {{sessions: {n:number,title:string,wave:number,steps:number[],dependsOn:number[],cwd:string}[], waves:number[]}}
*/
export function parseExecutionStrategy(planText) {
const body = sectionBody(planText, '## Execution Strategy');
if (!body) throw new Error('plan has no ## Execution Strategy section');
const sessions = [];
let cur = null;
for (const line of body) {
const h = line.match(/^###\s+Session\s+(\d+):\s*(.*)$/);
if (h) {
cur = { n: Number(h[1]), title: h[2].trim(), block: [] };
sessions.push(cur);
} else if (/^###\s/.test(line)) {
cur = null;
} else if (cur) {
cur.block.push(line);
}
}
if (sessions.length === 0) throw new Error('## Execution Strategy names no ### Session N: blocks');
const parsed = sessions.map(({ n, title, block }) => {
const wave = Number(field(block, 'Wave'));
if (!Number.isInteger(wave) || wave < 1) throw new Error(`Session ${n} has no Wave`);
return {
n, title, wave,
steps: numberList(field(block, 'Steps')),
dependsOn: numberList(field(block, 'Depends on')),
cwd: field(block, 'cwd') || '.',
};
});
return { sessions: parsed, waves: [...new Set(parsed.map(s => s.wave))].sort((a, b) => a - b) };
}
function session(strategy, n) {
const s = strategy.sessions.find(x => x.n === n);
if (!s) throw new Error(`no Session ${n} in the Execution Strategy`);
return s;
}
function worktreePathFor(sessionDir, n) {
return join(sessionDir, 'worktrees', `session-${n}`);
}
function openInFor(sessionDir, s) {
const wt = worktreePathFor(sessionDir, s.n);
return s.cwd === '.' ? wt : join(wt, s.cwd);
}
/**
* The progress file `/trekexecute --session N <plan>` writes, as seen from the
* session's working directory inside its worktree (Phase 3 session naming).
*/
export function progressFileFor({ strategy, n, planPath, sessionDir }) {
const s = session(strategy, n);
const slug = basename(planPath, extname(planPath));
return join(openInFor(sessionDir, s), dirname(planPath), `.trekexecute-progress-${slug}-session-${n}.json`);
}
/** One order per session in `wave`: worktree, starter, done-condition. */
export function buildWaveOrders({ strategy, wave, planPath, sessionDir }) {
if (isAbsolute(planPath)) throw new Error(`plan path must be repo-relative (each session resolves it inside its own worktree): ${planPath}`);
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
return members.map((s) => {
const worktreePath = worktreePathFor(sessionDir, s.n);
const openIn = openInFor(sessionDir, s);
const starter = `/trekexecute --session ${s.n} ${planPath}`;
const progressFile = progressFileFor({ strategy, n: s.n, planPath, sessionDir });
const body = [
`# Order: session ${s.n}, wave ${wave} - ${s.title}`,
'',
`- plan: ${planPath}`,
`- steps: ${s.steps.join(', ') || '-'}`,
`- depends on: ${s.dependsOn.length ? s.dependsOn.map(d => `session ${d}`).join(', ') : 'none'}`,
`- worktree: ${worktreePath}`,
`- open the session in: ${openIn}`,
'',
'## Starter',
'',
`Open a Claude Code session yourself in \`${openIn}\` and give it this line:`,
'',
` ${starter}`,
'',
'Voyage does not start this session. It runs only this session\'s steps, inside',
'its own worktree; never run two sessions in the same working directory.',
'',
'## Done when',
'',
`\`${progressFile}\` has \`"status": "completed"\`. Then run the same`,
'`/trekexecute` command as before in the main checkout: it reads every',
'session\'s progress file and merges the wave.',
'',
].join('\n');
return { session: s.n, title: s.title, wave, worktreePath, openIn, starter, progressFile, orderFile: `wave-${wave}-session-${s.n}.md`, body };
});
}
export function writeWaveOrders({ strategy, wave, planPath, sessionDir, now = new Date() }) {
const orders = buildWaveOrders({ strategy, wave, planPath, sessionDir });
const dir = join(sessionDir, 'orders');
mkdirSync(dir, { recursive: true });
for (const o of orders) writeFileSync(join(dir, o.orderFile), o.body);
const state = {
wave, plan: planPath, sessions: orders.map(o => o.session),
status: 'awaiting-sessions', created_at: now.toISOString(),
};
writeFileSync(join(dir, 'wave.json'), JSON.stringify(state, null, 2) + '\n');
return { dir, orders, state };
}
export function waveStatus({ strategy, wave, planPath, sessionDir }) {
const members = strategy.sessions.filter(s => s.wave === wave);
if (members.length === 0) throw new Error(`no sessions in wave ${wave}`);
const sessions = members.map((s) => {
const progressFile = progressFileFor({ strategy, n: s.n, planPath, sessionDir });
let status = 'pending';
if (existsSync(progressFile)) {
try {
status = JSON.parse(readFileSync(progressFile, 'utf-8')).status || 'unreadable';
} catch {
status = 'unreadable';
}
}
return { session: s.n, status, progressFile };
});
return { wave, sessions, all_completed: sessions.every(s => s.status === 'completed') };
}
// ---- CLI shim ----------------------------------------------------------------
function parseArgs(argv) {
const out = { cmd: argv[0] };
for (let i = 1; i < argv.length; i++) {
const a = argv[i];
if (a === '--plan') out.plan = argv[++i];
else if (a === '--wave') out.wave = Number(argv[++i]);
else if (a === '--session-dir') out.sessionDir = argv[++i];
}
return out;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
const usage = 'usage: session-orders.mjs write|status --plan <repo-relative plan> --wave <W> --session-dir <dir>';
if (!['write', 'status'].includes(args.cmd) || !args.plan || !Number.isInteger(args.wave) || !args.sessionDir) {
process.stderr.write(usage + '\n');
process.exit(1);
}
try {
const strategy = parseExecutionStrategy(readFileSync(args.plan, 'utf-8'));
const opts = { strategy, wave: args.wave, planPath: args.plan, sessionDir: args.sessionDir };
if (args.cmd === 'status') {
process.stdout.write(JSON.stringify(waveStatus(opts), null, 2) + '\n');
} else {
const { dir, orders } = writeWaveOrders(opts);
const lines = [
`Wave ${args.wave}: ${orders.length} session order(s) in ${dir}`,
'Voyage starts none of them. For each session, open Claude Code yourself in the',
'named directory and give it the starter line:',
'',
];
for (const o of orders) {
lines.push(` Session ${o.session} (${o.title})`, ` open in: ${o.openIn}`, ` starter: ${o.starter}`, '');
}
lines.push('When every session is done, run the same /trekexecute command again here to merge the wave.');
process.stdout.write(lines.join('\n') + '\n');
}
process.exit(0);
} catch (err) {
process.stderr.write(`session-orders: ${err.message}\n`);
process.exit(1);
}
}