diff --git a/extensions/taskplane/engine.ts b/extensions/taskplane/engine.ts index 046b7059..1573cc29 100644 --- a/extensions/taskplane/engine.ts +++ b/extensions/taskplane/engine.ts @@ -817,18 +817,30 @@ export async function executeOrchBatch( batchState.skippedTasks, batchState.blockedTasks, totalElapsedSec, + batchState.orchBranch, + batchState.baseBranch, ), batchState.failedTasks > 0 ? "warning" : "info", ); - // ── TS-009: Delete state file on clean completion (no failures) ── + // ── Preserve state for /orch-integrate when orch branch exists ── + // If integration is "manual" and we have an orch branch, keep the + // state file so /orch-integrate can find orchBranch and baseBranch. + // Only delete state if there's no orch branch to integrate. if (batchState.phase === "completed") { - try { - deleteBatchState(stateRoot); - execLog("state", batchState.batchId, "state file deleted on clean completion"); - } catch (err: unknown) { - const msg = err instanceof Error ? err.message : String(err); - execLog("state", batchState.batchId, `failed to delete state file: ${msg}`); + if (batchState.orchBranch) { + execLog("state", batchState.batchId, "state file preserved for /orch-integrate", { + orchBranch: batchState.orchBranch, + }); + } else { + // Legacy mode (no orch branch) — clean up state + try { + deleteBatchState(stateRoot); + execLog("state", batchState.batchId, "state file deleted on clean completion"); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : String(err); + execLog("state", batchState.batchId, `failed to delete state file: ${msg}`); + } } } } diff --git a/extensions/taskplane/execution.ts b/extensions/taskplane/execution.ts index 1653c79c..a28e204e 100644 --- a/extensions/taskplane/execution.ts +++ b/extensions/taskplane/execution.ts @@ -11,6 +11,53 @@ import type { AllocatedLane, AllocatedTask, DependencyGraph, LaneExecutionResult import { allocateLanes } from "./waves.ts"; import { runGit } from "./git.ts"; +// ── Task Runner Extension Path Resolution ──────────────────────────── + +/** + * Find the task-runner extension path for lane sessions. + * + * Resolution order: + * 1. Local project: {repoRoot}/extensions/task-runner.ts (for taskplane dev) + * 2. Global npm (Windows): {APPDATA}/npm/node_modules/taskplane/extensions/task-runner.ts + * 3. Global npm (Unix): /usr/local/lib/node_modules/taskplane/extensions/task-runner.ts + * 4. npm peer: resolve from pi's location + * + * @throws ExecutionError if task-runner.ts cannot be found anywhere + */ +function resolveTaskRunnerExtensionPath(repoRoot: string): string { + const extFile = join("extensions", "task-runner.ts"); + + // 1. Local project (taskplane development) + const localPath = join(resolve(repoRoot), extFile); + if (existsSync(localPath)) return localPath; + + // 2. Global npm install paths + const home = process.env.HOME || process.env.USERPROFILE || ""; + const candidates: string[] = []; + if (process.env.APPDATA) { + candidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane", extFile)); + } + if (home) { + candidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane", extFile)); + candidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane", extFile)); + } + candidates.push(join("/usr", "local", "lib", "node_modules", "taskplane", extFile)); + + // 3. Peer of pi's package + try { + const piPath = process.argv[1] || ""; + const piPkgDir = resolve(piPath, "..", ".."); + candidates.push(join(piPkgDir, "..", "taskplane", extFile)); + } catch { /* ignore */ } + + for (const candidate of candidates) { + if (existsSync(candidate)) return candidate; + } + + // Fallback: return the local path (will fail at spawn time with a clear error) + return localPath; +} + // ── Execution Helpers ──────────────────────────────────────────────── /** @@ -206,7 +253,7 @@ export function buildTmuxSpawnArgs( .map(([key, val]) => `${key}=${shellQuote(val)}`) .join(" "); - const taskRunnerExtPath = join(resolve(repoRoot), "extensions", "task-runner.ts"); + const taskRunnerExtPath = resolveTaskRunnerExtensionPath(repoRoot); const basePiCommand = `${envParts} pi --no-session -e ${shellQuote(taskRunnerExtPath)}`; // NOTE: Do not redirect lane output here. Shell redirection has proven diff --git a/extensions/taskplane/messages.ts b/extensions/taskplane/messages.ts index bd0a992c..bd079b77 100644 --- a/extensions/taskplane/messages.ts +++ b/extensions/taskplane/messages.ts @@ -36,7 +36,7 @@ export const ORCH_MESSAGES = { `🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`, orchWorktreeReset: (waveNum: number, lanes: number) => `🔄 Resetting ${lanes} worktree(s) to target branch HEAD after wave ${waveNum}`, - orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) => { + orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number, orchBranch?: string, baseBranch?: string) => { const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`]; if (failed > 0 || blocked > 0) { lines.push(""); @@ -48,6 +48,17 @@ export const ORCH_MESSAGES = { lines.push(" • /orch-resume — retry from the failed wave"); lines.push(" • /orch-abort — clean up and start fresh"); } + if (orchBranch && succeeded > 0) { + lines.push(""); + lines.push(` ℹ Orch branch: ${orchBranch}`); + if (baseBranch) { + lines.push(` Review changes: git log ${baseBranch}..${orchBranch}`); + } + lines.push(" Next steps:"); + lines.push(" • /orch-integrate — fast-forward into your branch"); + lines.push(" • /orch-integrate --merge — merge (if branches diverged)"); + lines.push(" • /orch-integrate --pr — push and open a PR"); + } return lines.join("\n"); }, orchBatchFailed: (batchId: string, reason: string) =>