Seven defects from an independent review of the 6.0 session-order flow, all verified against the text before fixing: 1. Step 3 branched only on pending/failed/stopped/unreadable, so a session still writing in-progress (or in_progress) next to a failed one had its live worktree force-removed. status now classifies each session as completed, running (no file, pending, in-progress, in_progress, or any unknown status) or ended-bad (failed, stopped, partial, blocked, unreadable), keeping the raw status; Step 3 stops on any running FIRST and touches no worktree, branch or wave.json. 2. The Phase 2.5 wave-in-flight guard was cwd-relative; from a subdirectory it missed wave.json and Check 4 force-removed the wave. The guard is now anchored at the git toplevel, and Check 4 opens with a block that refuses to clean while wave.json exists. 3. Step 6 read progress files from worktrees that Step 3/5 had removed. A new archive subcommand copies each to orders/wave-W-session-N.progress.json before any worktree goes; Step 6 and Phase 8 read the copies. 4. NO_WAVE always prepared the lowest wave, so a re-run after a wave-2 failure re-ran wave 1. record-merged writes orders/merged-waves.json after each merged wave (Step 5 keeps it); next-wave returns the lowest unmerged wave or none (then Step 4). Phase 8 clears the orders dir only after a completed report; the record refuses another plan's path. 5. Phase 7.6 named a deleted worktree and promised an unwired re-run. The order now names the main checkout and says the recovery session's own audit is the check. 6. (test side, previous commit) progress paths are asserted literally. 7. Hard rule 12 now carries the awaiting-sessions exception. npm test: 1262 tests, 1260 pass, 0 fail, 2 skipped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
317 lines
13 KiB
JavaScript
317 lines
13 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 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
|
||
// 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 { 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 };
|
||
}
|
||
|
||
// 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';
|
||
}
|
||
|
||
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, 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) {
|
||
const file = join(sessionDir, 'orders', 'merged-waves.json');
|
||
if (!existsSync(file)) return { file, waves: [] };
|
||
const rec = JSON.parse(readFileSync(file, 'utf-8'));
|
||
if (rec.plan !== planPath) throw new Error(`${file} belongs to ${rec.plan}, not ${planPath}; finish that plan or clear its orders first`);
|
||
return { file, waves: rec.waves };
|
||
}
|
||
|
||
/** Remember that `wave` is merged; survives Step 5, cleared by `clear`. */
|
||
export function recordMergedWave({ wave, planPath, sessionDir, now = new Date() }) {
|
||
const { file, waves } = mergedRecord(sessionDir, planPath);
|
||
const rec = { plan: planPath, 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, sessionDir }) {
|
||
const { waves } = mergedRecord(sessionDir, planPath);
|
||
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 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 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);
|
||
}
|
||
}
|