Compare commits

..

4 Commits

Author SHA1 Message Date
7253e51fb9 feat: use stored review on resume if interrupted 2026-07-31 09:57:51 -04:00
284b3720af breaking:drop command part path 2026-07-31 09:57:40 -04:00
d7c3962bc8 fix: include prompt in ralpi-plan 2026-07-30 08:39:02 -04:00
6223ac84a5 feat: register subcommands, finalize-on-resume, reuse loop snapshot
- Register /ralpi-run, /ralpi-plan, /ralpi-resume, /ralpi-reset as
  separate Pi commands (dash namespace). /ralpi kept as back-compat
  dispatcher.
- /ralpi-resume now reuses mode + loop options from loop-active.json
  so interrupted loops resume non-interactively (only prompts when no
  snapshot exists).
- Add finalizeCommittedWorktrees in worktree.ts; resumeLoop calls it at
  start to merge already-completed task branches into main (the classic
  interrupted-between-commit-and-merge crash window), leaving dirty/
  conflicted worktrees for re-run.
- Switch process.cwd() -> ctx.cwd in all command handlers.
2026-07-28 15:14:11 -04:00
3 changed files with 1108 additions and 881 deletions

277
index.ts
View File

@@ -18,7 +18,10 @@ import { formatReflections } from "./src/reflection";
import { verdictGlyph, verdictSummary, formatFindings } from "./src/review"; import { verdictGlyph, verdictSummary, formatFindings } from "./src/review";
import type { ReviewResult } from "./src/types"; import type { ReviewResult } from "./src/types";
import { executeBatch, type SendChatMessage } from "./src/executor"; import { executeBatch, type SendChatMessage } from "./src/executor";
import { cleanupStaleWorktrees } from "./src/worktree"; import {
cleanupStaleWorktrees,
finalizeCommittedWorktrees,
} from "./src/worktree";
import { import {
loadConfig, loadConfig,
resolveTaskArg, resolveTaskArg,
@@ -32,8 +35,6 @@ import {
formatDuration, formatDuration,
} from "./src/utils"; } from "./src/utils";
const COMMANDS = ["plan", "resume", "reset"] as const;
type ExecutionMode = "parallel" | "sequential"; type ExecutionMode = "parallel" | "sequential";
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -160,9 +161,9 @@ async function selectLoopOptions(
saveReviews = config.execution.saveReviews; saveReviews = config.execution.saveReviews;
} else { } else {
const saveChoice = await ctx.ui.select( const saveChoice = await ctx.ui.select(
"Save full review output to disk?", "Save full review output to disk? (recommended — enables review feedback recovery when resuming interrupted loops)",
[ [
"Yes — write each review to .ralpi/reviews/<loop>/<task>.md", "Yes — write each review to .ralpi/reviews/<loop>/<task>.json",
"No — keep reviews in-chat only", "No — keep reviews in-chat only",
], ],
); );
@@ -220,9 +221,11 @@ async function selectPRDToResume(
const failed = Object.values(tasks).filter( const failed = Object.values(tasks).filter(
(t) => t.status === "failed", (t) => t.status === "failed",
).length; ).length;
const relPath = path.relative(process.cwd(), entry.prd.sourcePath); const relPath = path.relative(ctx.cwd, entry.prd.sourcePath);
const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString(); const updated = new Date(entry.prd.lastUpdatedAt).toLocaleString();
return `${relPath}${completed}/${total} done${failed ? `, ${failed} failed` : ""} · ${updated}`; return `${relPath}${completed}/${total} done${
failed ? `, ${failed} failed` : ""
} · ${updated}`;
}); });
const selected = await ctx.ui.select( const selected = await ctx.ui.select(
@@ -390,6 +393,31 @@ async function executePlanBatches(
} }
} }
// ─── Shared Helpers ─────────────────────────────────────────────────────────
/**
* Build a sendProgress closure that posts ralpi progress messages into the
* chat history for the expandable tool-call-tree renderer.
*
* Used by every registered command so they share one rendering path.
*/
function makeSendProgress(pi: ExtensionAPI): SendChatMessage {
return (content, meta) => {
pi.sendMessage({
customType: "ralpi-progress",
content,
display: true,
details: {
phase: "progress",
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
});
};
}
// ─── Extension Entry ──────────────────────────────────────────────────────── // ─── Extension Entry ────────────────────────────────────────────────────────
export default function ralpiLoopExtension(pi: ExtensionAPI): void { export default function ralpiLoopExtension(pi: ExtensionAPI): void {
@@ -618,35 +646,7 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
"Execute tasks from a task file using DAG-based dependency resolution", "Execute tasks from a task file using DAG-based dependency resolution",
handler: async (args: string, ctx: ExtensionContext) => { handler: async (args: string, ctx: ExtensionContext) => {
const parts = (args || "").trim().split(/\s+/).filter(Boolean); const parts = (args || "").trim().split(/\s+/).filter(Boolean);
const sendProgress = makeSendProgress(pi);
// Wraps pi.sendMessage() for posting status to the chat history.
// Uses "ralpi-progress" customType with a "progress" phase so the
// renderer omits the label prefix entirely (no [INFO] etc.).
// Accepts an optional meta object with toolCalls for the expandable view,
// and reviewText/reviewPath/reviewResult for review messages so the expanded
// (Ctrl+O) view can render the full review body without truncation.
const sendProgress: SendChatMessage = (
content: string,
meta?: {
toolCalls?: Array<{ name: string; label: string }>;
reviewText?: string;
reviewPath?: string;
reviewResult?: ReviewResult;
},
) => {
pi.sendMessage({
customType: "ralpi-progress",
content,
display: true,
details: {
phase: "progress",
toolCalls: meta?.toolCalls,
reviewText: meta?.reviewText,
reviewPath: meta?.reviewPath,
reviewResult: meta?.reviewResult,
},
});
};
// If no args, show plan. If first token looks like a path (@path, /path, ./path), // If no args, show plan. If first token looks like a path (@path, /path, ./path),
// route to run so the execution mode prompt fires. // route to run so the execution mode prompt fires.
@@ -663,50 +663,64 @@ export default function ralpiLoopExtension(pi: ExtensionAPI): void {
); );
} }
const command = parts[0]; // Subcommands (run/plan/resume/reset) are handled by the dash commands
switch (command) { // below — /ralpi only dispatches no-args → plan and path → run.
case "run":
return handleRun(
ctx,
parts.slice(1),
sendProgress,
ctx.model,
pi.getThinkingLevel(),
);
case "plan":
pi.sendUserMessage("@task-manager");
ctx.ui.notify("Opening Task Manager...", "info");
return;
case "resume":
return handleResume(
ctx,
parts.slice(1),
sendProgress,
ctx.model,
pi.getThinkingLevel(),
);
case "reset":
return handleReset(ctx, parts.slice(1));
default: {
// Auto-discover progress and offer resume
const found = findProgressFile(process.cwd());
if (found) {
ctx.ui.notify( ctx.ui.notify(
`Unknown command: ${command}\n\nFound existing progress in ${ `Unknown: ${parts[0]}. Use /ralpi-run, /ralpi-plan, /ralpi-resume, or /ralpi-reset`,
found.path
}\nUse /ralpi resume to continue.\n\nAvailable: ${COMMANDS.join(
", ",
)}`,
"warning",
);
} else {
ctx.ui.notify(
`Unknown command: ${command}\nAvailable: ${COMMANDS.join(", ")}`,
"error", "error",
); );
} },
} });
}
// ─── Dedicated subcommands (dash namespace) ──────────────────────────
//
// Each subcommand is registered as its own top-level Pi command so the
// slash-menu autocompletes it directly (`/ralpi-run`, `/ralpi-resume`, …)
// instead of requiring the user to type `/ralpi <subcommand>` and rely on
// raw-string dispatch. `/ralpi` above remains as a back-compat dispatcher.
pi.registerCommand("ralpi-run", {
description: "Run tasks from a task file (DAG-based execution)",
handler: async (args: string, ctx: ExtensionContext) => {
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
return handleRun(
ctx,
parts,
makeSendProgress(pi),
ctx.model,
pi.getThinkingLevel(),
);
},
});
pi.registerCommand("ralpi-plan", {
description: "Open the Task Manager to plan a ralpi run",
handler: async (args: string, ctx: ExtensionContext) => {
const prompt = (args || "").trim();
const message = prompt ? `@task-manager\n\n${prompt}` : "@task-manager";
pi.sendUserMessage(message);
ctx.ui.notify("Opening Task Manager...", "info");
},
});
pi.registerCommand("ralpi-resume", {
description: "Resume an interrupted ralpi loop from persisted progress",
handler: async (args: string, ctx: ExtensionContext) => {
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
return handleResume(
ctx,
parts,
makeSendProgress(pi),
ctx.model,
pi.getThinkingLevel(),
);
},
});
pi.registerCommand("ralpi-reset", {
description: "Reset ralpi progress for a task file",
handler: async (args: string, ctx: ExtensionContext) => {
const parts = (args || "").trim().split(/\s+/).filter(Boolean);
return handleReset(ctx, parts);
}, },
}); });
} }
@@ -717,7 +731,7 @@ async function handlePlan(
ctx: ExtensionContext, ctx: ExtensionContext,
args: string[], args: string[],
): Promise<void> { ): Promise<void> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
if (!Array.isArray(project.tasks)) { if (!Array.isArray(project.tasks)) {
throw new Error( throw new Error(
@@ -741,11 +755,11 @@ async function handleRun(
parentModel?: unknown, parentModel?: unknown,
parentThinkingLevel?: unknown, parentThinkingLevel?: unknown,
): Promise<void> { ): Promise<void> {
const taskFile = resolveTaskArg(args[0] || "README.md", process.cwd()); const taskFile = resolveTaskArg(args[0] || "README.md", ctx.cwd);
// If targeting a specific task file and there's existing progress for it, // If targeting a specific task file and there's existing progress for it,
// auto-resume instead of starting fresh // auto-resume instead of starting fresh
const existingProgress = findProgressFile(process.cwd(), taskFile); const existingProgress = findProgressFile(ctx.cwd, taskFile);
if (existingProgress) { if (existingProgress) {
return handleResume( return handleResume(
ctx, ctx,
@@ -757,7 +771,7 @@ async function handleRun(
} }
// No existing progress for this task — check for any progress at all // No existing progress for this task — check for any progress at all
const found = findProgressFile(process.cwd()); const found = findProgressFile(ctx.cwd);
if (found && !args[0]) { if (found && !args[0]) {
// Offer to resume instead of starting fresh // Offer to resume instead of starting fresh
const shouldResume = await ctx.ui.select( const shouldResume = await ctx.ui.select(
@@ -776,9 +790,7 @@ async function handleRun(
} }
} }
const projectDir = found const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
? path.dirname(path.dirname(found.path))
: process.cwd();
const project = parseTaskFile(taskFile); const project = parseTaskFile(taskFile);
const config = loadConfig(projectDir); const config = loadConfig(projectDir);
@@ -880,11 +892,57 @@ async function resumeLoop(
progress.setPaused(false); progress.setPaused(false);
// Any task left `in_progress` died with the previous session (ralpi runs // ── Self-heal: finalize tasks that finished but were never merged ──
// agents in-process). Reset them to `pending` so the DAG re-schedules //
// them cleanly. Without this they'd still be re-run (they're not in the // A review-gated task whose agent committed + reviewed successfully still
// completed set), but the progress.json would carry a stale in_progress // needs a final merge into main + worktree removal to be "done". If the
// state during the rebuild window. // loop was interrupted between that commit and the merge, the task is left
// `in_progress` with a clean, committed worktree branch. Resuming without
// finalizing would wastefully re-run finished work.
//
// finalizeCommittedWorktrees merges those branches into main now; the
// rest (dirty trees, nothing committed, conflicts) are left in_progress
// and reset to pending below for a real re-run.
const prdKeyForFinalize = progress.getKey();
const inProgressIds = Object.entries(progress.getState().tasks)
.filter(([, t]) => t.status === "in_progress")
.map(([id]) => id);
if (inProgressIds.length > 0) {
const fin = finalizeCommittedWorktrees(
projectDir,
config.paths.stateDir,
prdKeyForFinalize,
inProgressIds,
);
for (const id of fin.finalized) {
progress.markCompleted(id, 0);
try {
updateTaskInFile(taskFile, id, "completed");
} catch {
// Best-effort — progress.json is the source of truth for scheduling.
}
sendChatMessage?.(
`${id} — finalized on resume (committed branch merged into main)`,
);
}
const conflictIds = Object.keys(fin.conflicts);
if (conflictIds.length > 0) {
const detail = conflictIds
.map((id) => `${id}: ${fin.conflicts[id].slice(0, 3).join(", ")}`)
.join("; ");
sendChatMessage?.(
`${conflictIds.join(
", ",
)} — merge conflict on resume-finalize; re-running (${detail})`,
);
}
}
// Any task still `in_progress` (those NOT finalized above) died with the
// previous session (ralpi runs agents in-process). Reset them to `pending`
// so the DAG re-schedules them cleanly. Without this they'd still be
// re-run (they're not in the completed set), but the progress.json would
// carry a stale in_progress state during the rebuild window.
const resetIds = progress.resetInProgressToPending(); const resetIds = progress.resetInProgressToPending();
if (resetIds.length > 0) { if (resetIds.length > 0) {
// Keep the source-file checkboxes in sync so a later parse sees these // Keep the source-file checkboxes in sync so a later parse sees these
@@ -970,8 +1028,8 @@ async function handleResume(
let prdKey: string | undefined; let prdKey: string | undefined;
if (args[0]) { if (args[0]) {
taskFile = resolveTaskArg(args[0], process.cwd()); taskFile = resolveTaskArg(args[0], ctx.cwd);
const found = findProgressFile(process.cwd(), taskFile); const found = findProgressFile(ctx.cwd, taskFile);
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
`No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`, `No existing progress for ${args[0]}. Start with /ralpi run ${args[0]}`,
@@ -982,7 +1040,7 @@ async function handleResume(
projectDir = path.dirname(path.dirname(found.path)); projectDir = path.dirname(path.dirname(found.path));
prdKey = found.prdKey; prdKey = found.prdKey;
} else { } else {
const found = findProgressFile(process.cwd()); const found = findProgressFile(ctx.cwd);
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
"No .ralpi/progress.json found. Start with /ralpi run [task-file]", "No .ralpi/progress.json found. Start with /ralpi run [task-file]",
@@ -1003,6 +1061,30 @@ async function handleResume(
prdKey = selected.prdKey; prdKey = selected.prdKey;
} }
// Reuse the loop snapshot (mode + autoCommit/autoReview/saveReviews)
// persisted when the loop started, so an interrupted loop resumes
// non-interactively — matching the auto-resume-on-reload path. Only fall
// back to interactive prompts when no snapshot is present.
const snapshot = readLoopActive(projectDir);
const loopOpts = (() => {
if (
snapshot &&
snapshot.prdKey === prdKey &&
snapshot.mode &&
snapshot.autoCommit !== undefined &&
snapshot.autoReview !== undefined &&
snapshot.saveReviews !== undefined
) {
return {
mode: snapshot.mode as ExecutionMode,
autoCommit: snapshot.autoCommit,
autoReview: snapshot.autoReview,
saveReviews: snapshot.saveReviews,
};
}
return undefined;
})();
await resumeLoop( await resumeLoop(
ctx, ctx,
taskFile, taskFile,
@@ -1011,6 +1093,7 @@ async function handleResume(
sendChatMessage, sendChatMessage,
parentModel, parentModel,
parentThinkingLevel, parentThinkingLevel,
loopOpts,
); );
} }
@@ -1024,15 +1107,13 @@ async function handleReset(
args: string[], args: string[],
): Promise<void> { ): Promise<void> {
if (args[0]) { if (args[0]) {
const taskFile = resolveTaskArg(args[0], process.cwd()); const taskFile = resolveTaskArg(args[0], ctx.cwd);
const found = findProgressFile(process.cwd(), taskFile); const found = findProgressFile(ctx.cwd, taskFile);
const projectDir = found const projectDir = found ? path.dirname(path.dirname(found.path)) : ctx.cwd;
? path.dirname(path.dirname(found.path))
: process.cwd();
const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey); const progress = new ProgressTracker(projectDir, taskFile, found?.prdKey);
progress.reset(); progress.reset();
} else { } else {
const found = findProgressFile(process.cwd()); const found = findProgressFile(ctx.cwd);
if (!found) { if (!found) {
ctx.ui.notify( ctx.ui.notify(
"No .ralpi/progress.json found. Start with /ralpi run [task-file]", "No .ralpi/progress.json found. Start with /ralpi run [task-file]",

View File

@@ -23,6 +23,7 @@ import { extractReflection } from "./reflection";
import { import {
extractReview, extractReview,
saveReviewToFile as saveReviewJson, saveReviewToFile as saveReviewJson,
loadReview as loadReviewJson,
verdictGlyph, verdictGlyph,
verdictSummary, verdictSummary,
} from "./review"; } from "./review";
@@ -813,6 +814,27 @@ async function executeTask(
? captureGitHead(worktreeDir) ? captureGitHead(worktreeDir)
: undefined; : undefined;
// Load a prior review from disk when resuming an interrupted loop.
// If the previous run's review rejected the task (verdict 'fail') and the
// re-execution was lost to a crash/connection error, the findings would
// otherwise be orphaned. Injecting them here gives the fresh run the
// reviewer's feedback so it doesn't reintroduce the same blockers.
let priorReview: ReviewResult | undefined;
if (config.execution.autoReview) {
const loaded = loadReviewJson(
projectDir,
config.paths.reviewsDir,
task.id,
progress.getKey(),
);
if (loaded && loaded.verdict === "fail") {
priorReview = loaded;
sendChatMessage?.(
`${task.id} · ${task.title} — resuming with prior review feedback (${loaded.findings.length} findings)`,
);
}
}
// Run the task // Run the task
const result = await runTask( const result = await runTask(
task, task,
@@ -825,6 +847,7 @@ async function executeTask(
parallelState, parallelState,
currentModel, currentModel,
batchRender, batchRender,
priorReview,
); );
if (result.success) { if (result.success) {
@@ -1014,8 +1037,18 @@ async function executeTask(
`↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`, `↻ review for ${task.id} · ${task.title} — verdict ${review?.verdict ?? "unknown"}, re-executing with feedback (${attempt}/${maxRetries})...`,
); );
// Re-execute the task with review feedback injected. // Re-execute the task with review feedback injected, cycling
const fixResult = await runTask( // through failover models on connection errors so a flaky
// provider doesn't waste the review-fix attempt.
const fixModels = buildFailoverModels(currentModel, roundRobin);
let fixResult: Awaited<ReturnType<typeof runTask>> | undefined;
for (
let fixAttempt = 0;
fixAttempt < fixModels.length;
fixAttempt++
) {
const fixModel = fixModels[fixAttempt];
fixResult = await runTask(
task, task,
project, project,
config, config,
@@ -1024,14 +1057,22 @@ async function executeTask(
sendChatMessage, sendChatMessage,
worktreeDir, worktreeDir,
parallelState, parallelState,
currentModel, fixModel,
batchRender, batchRender,
review ?? undefined, review ?? undefined,
); );
if (fixResult.success) break;
if (!fixResult.success) { // Connection/error failover — try the next model.
if (fixAttempt < fixModels.length - 1) {
sendChatMessage?.( sendChatMessage?.(
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult.error}`, `~ re-execution for ${task.id} · ${task.title} — cycling to model ${fixAttempt + 2}/${fixModels.length} (previous: ${fixResult.error})`,
);
}
}
if (!fixResult || !fixResult.success) {
sendChatMessage?.(
`~ re-execution for ${task.id} · ${task.title} failed: ${fixResult?.error}`,
); );
break; // proceed with what we have break; // proceed with what we have
} }

View File

@@ -1,5 +1,6 @@
import * as fs from "node:fs";
import * as path from "node:path"; import * as path from "node:path";
import { ensureDir } from "./utils"; import { ensureDir, hasUncommittedChanges } from "./utils";
// ─── Types ─────────────────────────────────────────────────────────────────── // ─── Types ───────────────────────────────────────────────────────────────────
@@ -375,3 +376,107 @@ export function cleanupStaleWorktrees(
git("worktree prune", mainDir); git("worktree prune", mainDir);
return removed; return removed;
} }
/** Result of attempting to finalize a single in-progress worktree on resume. */
export interface FinalizeResult {
/** Task IDs whose committed branch was merged into main and cleaned up. */
finalized: string[];
/** Task IDs left to re-run (no worktree, dirty tree, nothing committed,
* or merge conflict — work is preserved for re-execution). */
rerun: string[];
/** Task IDs that hit a merge conflict; their committed branch + worktree
* are left intact for manual resolution. Excluded from `rerun` so the
* scheduler does not blindly re-execute conflicting work. */
conflicts: Record<string, string[]>;
}
/**
* Finalize in-progress tasks whose worktrees already hold committed, clean
* work that was never merged into main (typically because the loop was
* interrupted between the task commit and the merge/finalize step).
*
* For each task ID:
* - If no worktree exists / is registered → re-run (fresh worktree later).
* - If the worktree working tree is dirty (uncommitted edits) → re-run,
* preserving the worktree so `createWorktree` reuses it and the agent
* continues where it left off.
* - If the worktree is clean but has no commits ahead of main → re-run.
* - If the worktree is clean AND has ≥1 commit ahead of main → merge the
* branch into main (`--no-ff`), remove the worktree, and report finalized.
* On merge conflict the merge is aborted (main left clean), the worktree
* is preserved, and the task is reported in `conflicts`.
*
* This is the self-healing path for an interrupted review-gated loop:
* tasks that finished (commit + review already saved) but never got their
* merge are completed here, so `/ralpi-resume` does not wastefully re-run
* finished work.
*/
export function finalizeCommittedWorktrees(
mainDir: string,
stateDir: string,
prdKey: string,
taskIds: string[],
): FinalizeResult {
const result: FinalizeResult = { finalized: [], rerun: [], conflicts: {} };
const mainHead = getGitHead(mainDir);
for (const taskId of taskIds) {
const wtDir = worktreePath(mainDir, stateDir, prdKey, taskId);
// No worktree directory on disk → nothing to finalize.
if (!fs.existsSync(wtDir)) {
result.rerun.push(taskId);
continue;
}
// Confirm the worktree is actually registered with git (not a leftover
// dir from a half-cleaned-up run). If registered but broken, drop its
// metadata so a fresh worktree can be created on re-run.
const list = git("worktree list --porcelain", mainDir) ?? "";
if (!list.includes(`worktree ${wtDir}`)) {
result.rerun.push(taskId);
continue;
}
// Broken checkout → re-run (createWorktree will recreate it).
const branch = getCurrentBranch(wtDir);
if (!branch || branch === "HEAD" || branch === "detached") {
result.rerun.push(taskId);
continue;
}
// Dirty working tree (uncommitted edits, e.g. an interrupted agent) →
// re-run, keeping the worktree so the agent resumes in place.
if (hasUncommittedChanges(wtDir)) {
result.rerun.push(taskId);
continue;
}
// Clean tree but nothing committed ahead of main → nothing to merge.
const aheadStr =
mainHead !== null
? git(`rev-list --count ${mainHead}..HEAD`, wtDir)
: null;
const ahead = aheadStr !== null ? parseInt(aheadStr, 10) : 0;
if (Number.isNaN(ahead) || ahead <= 0) {
result.rerun.push(taskId);
continue;
}
// Committed + clean → finalize. mergeWorktree aborts on conflict,
// leaving main's working tree clean.
const merge = mergeWorktree(mainDir, branch);
if (merge.success) {
removeWorktree(mainDir, { dir: wtDir, branch, mainDir });
result.finalized.push(taskId);
continue;
}
// Conflict — preserve the worktree for manual resolution and report.
result.conflicts[taskId] = merge.conflicts;
}
git("worktree prune", mainDir);
return result;
}