voyage/lib/util/session-orders.mjs
Kjell Tore Guttormsen d70e3d2af7
fix(execute): clear the wave record only on the main-merge gate's confirm (green)
1. Step 6 cleared the orders dir before Phase 8's main-merge gate asked,
   so a declined gate lost the merged-waves record and the next run
   prepared wave 1 again on merged code. The clear now sits only under
   the gate's "On confirm" (wave path, next-wave none, Step 4 passed);
   Step 6 and a declined gate keep the record, so a re-run goes
   next-wave none -> Step 4 -> back to the gate.
2. The gate's Decline row and Recovery surface now say what holds on the
   wave path: the waves are already merged on the current branch,
   nothing is finalized, the record is kept, and re-running the same
   command (not --resume, which Phase 2.5 runs as a single session)
   re-runs master verification and returns to the gate. The branch name
   is trek/{slug}/session-N; the push-before-cleanup note now holds only
   where the push succeeded. The single-session --resume line stays.
3. The report footer adds the multi-session way to continue.
4. wave.json stores the branch the wave was prepared on (write
   --branch); Step 3's status call passes the current branch and stops,
   touching nothing, when the main checkout is on another branch.

npm test: 1277 tests, 1275 pass, 0 fail, 2 skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:24:33 +02:00

350 lines
15 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> [--branch <b>]
// writes <dir>/orders/wave-<W>-session-<N>.md + <dir>/orders/wave.json
// (with the branch the wave is prepared on) and prints the starters
// status --plan <repo-relative plan> --wave <W> --session-dir <dir> [--branch <b>]
// prints JSON: each session's raw progress-file status and its state
// (completed | running | ended-bad), running, ended_bad, all_completed;
// refuses when the main checkout is on another branch than wave.json's
// archive --plan <repo-relative plan> --wave <W> --session-dir <dir>
// copies each progress file to <dir>/orders/wave-<W>-session-<N>.progress.json
// (the worktree copy goes with the worktree)
// record-merged --plan <repo-relative plan> --wave <W> --session-dir <dir>
// adds W to <dir>/orders/merged-waves.json
// wave.json and merged-waves.json carry the plan path and the sha256 of its
// content; status, record-merged and next-wave refuse another plan or another
// version of it (the slug is the file name, so plans can share a session dir).
// next-wave --plan <repo-relative plan> --session-dir <dir>
// prints the lowest wave not yet merged, or `none`
// clear --session-dir <dir>
// removes <dir>/orders (refused while wave.json exists)
import { readFileSync, writeFileSync, mkdirSync, existsSync, copyFileSync, rmSync } from 'node:fs';
import { createHash } from 'node:crypto';
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}`,
`- progress file: ${progressFile}`,
'',
'## 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, planSha256, branch = null, 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, plan_sha256: planSha256, original_branch: branch, 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 };
}
// A session that has not ended must never be treated as ended: an unknown
// status counts as running, so a collect run waits instead of tearing down a
// live worktree.
const ENDED_BAD = new Set(['failed', 'stopped', 'partial', 'blocked', 'unreadable']);
/** @returns {'completed'|'running'|'ended-bad'} */
export function classifyStatus(status) {
if (status === 'completed') return 'completed';
return ENDED_BAD.has(status) ? 'ended-bad' : 'running';
}
// A run's state file must belong to this plan, in this version.
function samePlan(file, rec, planPath, planSha256, what) {
if (rec.plan === planPath && rec.plan_sha256 === planSha256) return;
const short = (h) => (typeof h === 'string' ? h.slice(0, 12) : 'none');
throw new Error(`${file} ${what} ${rec.plan} (plan sha256 ${short(rec.plan_sha256)}), `
+ `not ${planPath} (plan sha256 ${short(planSha256)})`);
}
export function waveStatus({ strategy, wave, planPath, planSha256, branch = null, sessionDir }) {
const waveFile = join(sessionDir, 'orders', 'wave.json');
if (existsSync(waveFile)) {
const state = JSON.parse(readFileSync(waveFile, 'utf-8'));
samePlan(waveFile, state, planPath, planSha256, 'was written for another plan or another version of it:');
// The wave merges back into the branch it was prepared on, nowhere else.
if (state.original_branch && state.original_branch !== branch) {
throw new Error(`the wave in ${waveFile} was prepared on branch ${state.original_branch}, `
+ `but the main checkout is on ${branch || 'an unnamed branch'}. Switch back to ${state.original_branch} and re-run.`);
}
}
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, state: classifyStatus(status), progressFile };
});
return {
wave, sessions,
running: sessions.filter(s => s.state === 'running').map(s => s.session),
ended_bad: sessions.filter(s => s.state === 'ended-bad').map(s => s.session),
all_completed: sessions.every(s => s.state === 'completed'),
};
}
/** Copy each session's progress file out of its worktree, into the orders dir. */
export function archiveWaveProgress({ 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 dir = join(sessionDir, 'orders');
mkdirSync(dir, { recursive: true });
return members.map((s) => {
const from = progressFileFor({ strategy, n: s.n, planPath, sessionDir });
if (!existsSync(from)) return { session: s.n, from, to: null };
const to = join(dir, `wave-${wave}-session-${s.n}.progress.json`);
copyFileSync(from, to);
return { session: s.n, from, to };
});
}
function mergedRecord(sessionDir, planPath, planSha256) {
const dir = join(sessionDir, 'orders');
const file = join(dir, 'merged-waves.json');
if (!existsSync(file)) return { file, waves: [] };
const rec = JSON.parse(readFileSync(file, 'utf-8'));
try {
samePlan(file, rec, planPath, planSha256, 'belongs to');
} catch (err) {
throw new Error(`${dir} holds an unfinished multi-session run of ${rec.plan}: ${err.message}. `
+ `Finish that run, or clear it (session-orders.mjs clear --session-dir ${sessionDir}) and re-run.`);
}
return { file, waves: rec.waves };
}
/** Remember that `wave` is merged; survives Step 5, cleared by `clear`. */
export function recordMergedWave({ wave, planPath, planSha256, sessionDir, now = new Date() }) {
const { file, waves } = mergedRecord(sessionDir, planPath, planSha256);
const rec = { plan: planPath, plan_sha256: planSha256, waves: [...new Set([...waves, wave])].sort((a, b) => a - b), updated_at: now.toISOString() };
mkdirSync(dirname(file), { recursive: true });
writeFileSync(file, JSON.stringify(rec, null, 2) + '\n');
return rec;
}
/** The lowest wave of the plan not yet merged, or null when every wave is. */
export function nextWave({ strategy, planPath, planSha256, sessionDir }) {
const { waves } = mergedRecord(sessionDir, planPath, planSha256);
return strategy.waves.find(w => !waves.includes(w)) ?? null;
}
/** Remove the orders dir (orders, archived progress, merged record). */
export function clearOrders({ sessionDir }) {
const dir = join(sessionDir, 'orders');
if (existsSync(join(dir, 'wave.json'))) throw new Error(`${join(dir, 'wave.json')} exists: a wave is awaiting its sessions, nothing cleared`);
rmSync(dir, { recursive: true, force: true });
return { cleared: dir };
}
// ---- 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];
else if (a === '--branch') out.branch = argv[++i];
}
return out;
}
const NEEDS = {
write: ['plan', 'wave'], status: ['plan', 'wave'], archive: ['plan', 'wave'],
'record-merged': ['plan', 'wave'], 'next-wave': ['plan'], clear: [],
};
if (import.meta.url === `file://${process.argv[1]}`) {
const args = parseArgs(process.argv.slice(2));
const usage = 'usage: session-orders.mjs write|status|archive|record-merged --plan <repo-relative plan> --wave <W> --session-dir <dir> [--branch <b>]\n'
+ ' session-orders.mjs next-wave --plan <repo-relative plan> --session-dir <dir>\n'
+ ' session-orders.mjs clear --session-dir <dir>';
const needs = NEEDS[args.cmd];
if (!needs || !args.sessionDir || (needs.includes('plan') && !args.plan) || (needs.includes('wave') && !Number.isInteger(args.wave))) {
process.stderr.write(usage + '\n');
process.exit(1);
}
try {
if (args.cmd === 'clear') {
process.stdout.write(JSON.stringify(clearOrders({ sessionDir: args.sessionDir })) + '\n');
process.exit(0);
}
const planBytes = readFileSync(args.plan);
const strategy = parseExecutionStrategy(planBytes.toString('utf-8'));
const planSha256 = createHash('sha256').update(planBytes).digest('hex');
const opts = { strategy, wave: args.wave, planPath: args.plan, planSha256, branch: args.branch || null, sessionDir: args.sessionDir };
if (args.cmd === 'status') {
process.stdout.write(JSON.stringify(waveStatus(opts), null, 2) + '\n');
} else if (args.cmd === 'archive') {
process.stdout.write(JSON.stringify(archiveWaveProgress(opts), null, 2) + '\n');
} else if (args.cmd === 'record-merged') {
process.stdout.write(JSON.stringify(recordMergedWave(opts), null, 2) + '\n');
} else if (args.cmd === 'next-wave') {
process.stdout.write(String(nextWave(opts) ?? 'none') + '\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);
}
}