// 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 --wave --session-dir [--branch ] // writes /orders/wave--session-.md + /orders/wave.json // (with the branch the wave is prepared on) and prints the starters // status --plan --wave --session-dir [--branch ] // 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 --wave --session-dir // copies each progress file to /orders/wave--session-.progress.json // (the worktree copy goes with the worktree) // record-merged --plan --wave --session-dir // adds W to /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 --session-dir // prints the lowest wave not yet merged, or `none` // clear --session-dir // removes /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 ` 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 --wave --session-dir [--branch ]\n' + ' session-orders.mjs next-wave --plan --session-dir \n' + ' session-orders.mjs clear --session-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); } }