voyage/lib/util/session-orders.mjs
Kjell Tore Guttormsen a915b9d4b0
fix(execute): the multi-session path never force-deletes (green)
One invariant closes the data-loss class: Phase 2.55, 2.6 and 7.6 remove
worktrees only with `git worktree remove` without force and branches only
with `git branch -d`. Whatever git refuses (a worktree with changes, an
unmerged or checked-out branch) is kept, reported with what the user can
do, and never retried with force; every unmerged trek/{slug}/* branch
survives every failure path. Step 1 stops on a branch or worktree left
by an earlier attempt instead of deleting it. After a merged wave, a
progress file is dropped from its worktree only when untracked and
byte-identical to its archived copy, so the happy path still cleans up.

B1: Step 3 refuses to merge into a dirty main checkout, and tells a
refused merge (no MERGE_HEAD: stop, keep wave.json) from a real conflict
(abort, Step 5 keeps the branches).
B2: Phase 2.5, Check 4 and Step 0 take the main checkout from the first
`git worktree list` entry and refuse to run from a linked worktree.
B3: wave.json and merged-waves.json carry the plan's sha256 and refuse
another plan or version, naming the run that holds the orders dir and
the clear command; Step 0 stops on that. Step 6 clears the orders dir
when every wave is merged AND Step 4 passed, no longer keyed on the
Phase 8 label the wave path cannot reach.
Also: every session-orders.mjs call runs from the main checkout with a
main-relative plan path; Check 1 no longer counts .claude/trekplan-sessions/
as a dirty tree (it blocked every re-run after a failed wave); a stuck
wave has a documented non-force way out; Hard rule 12 states the
invariant. The test file only narrows one call-site filter that also
matched Step 0's plugin-root existence check.

npm test: 1273 tests, 1271 pass, 0 fail, 2 skipped.

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

343 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>
// 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 raw progress-file status and its state
// (completed | running | ended-bad), running, ended_bad, all_completed
// 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, 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, 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, sessionDir }) {
const waveFile = join(sessionDir, 'orders', 'wave.json');
if (existsSync(waveFile)) {
samePlan(waveFile, JSON.parse(readFileSync(waveFile, 'utf-8')), planPath, planSha256,
'was written for another plan or another version of it:');
}
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];
}
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>\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, 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);
}
}