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>
343 lines
15 KiB
JavaScript
343 lines
15 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
|
||
// 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);
|
||
}
|
||
}
|