voyage/lib/util/session-orders.mjs
Kjell Tore Guttormsen 219e40cd91
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>
2026-09-27 15:35:22 +02:00

227 lines
8.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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);
}
}