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>
227 lines
8.9 KiB
JavaScript
227 lines
8.9 KiB
JavaScript
// 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);
|
||
}
|
||
}
|