// 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 // writes /orders/wave--session-.md + /orders/wave.json // and prints the starters // status --plan --wave --session-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 ` 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 --wave --session-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); } }