taskplane 0.4.3 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ The taskplane dashboard runs on a local port on your system and gives you elegan
18
18
 
19
19
  ### Key Features
20
20
 
21
- - **Task Orchestrator** — Parallel multi-task execution using git worktrees for full filesystem isolation. Dependency-aware wave scheduling. Automated merges with conflict resolution.
21
+ - **Task Orchestrator** — Parallel multi-task execution using git worktrees for full filesystem isolation. Dependency-aware wave scheduling. Automated merges into a dedicated orch branch — your working branch stays stable until you choose to integrate.
22
22
  - **Task Runner** — What the Orchestrator uses for autonomous single-task execution. Worker agents run in fresh-context loops with STATUS.md as persistent memory. Every checkbox gets a git checkpoint. Cross-model reviewer agents catch what the worker agents missed.
23
23
  - **Web Dashboard** — Live browser-based monitoring via `taskplane dashboard`. SSE streaming, lane/task progress, wave visualization, batch history.
24
24
  - **Structured Tasks** — PROMPT.md defines the mission, steps, and constraints. STATUS.md tracks progress. Agents follow the plan, not vibes.
@@ -128,7 +128,7 @@ The default scaffold includes two independent example tasks, so `/orch all` give
128
128
  Important distinction:
129
129
 
130
130
  - `/task` runs in your **current branch/worktree**.
131
- - `/orch` runs tasks in **isolated worktrees** and merges back.
131
+ - `/orch` runs tasks in **isolated worktrees** on a dedicated orch branch — your working branch is never touched until you integrate.
132
132
 
133
133
  Because workers checkpoint with git commits, `/task` can capture unrelated local edits if you're changing files in parallel. For safer isolation (even with one task), prefer:
134
134
 
@@ -156,6 +156,7 @@ Orchestrator lanes execute tasks through task-runner under the hood, so `/task`
156
156
  | `/orch-abort [--hard]` | Abort batch (graceful or immediate) |
157
157
  | `/orch-deps <areas\|paths\|all>` | Show dependency graph |
158
158
  | `/orch-sessions` | List active worker sessions |
159
+ | `/orch-integrate` | Integrate completed orch batch into your working branch |
159
160
  | `/taskplane-settings` | View and edit taskplane configuration interactively |
160
161
 
161
162
  ### CLI Commands
@@ -189,14 +190,18 @@ Orchestrator lanes execute tasks through task-runner under the hood, so `/task`
189
190
 
190
191
  ┌──────▼──────┐
191
192
  │ Merge Agent │ ← Conflict resolution
192
- Integration │ & verification
193
- │ Branch │
193
+ Orch Branch │ & verification
194
+ └──────┬──────┘
195
+
196
+ ┌──────▼──────┐
197
+ │ /orch- │ ← User integrates into
198
+ │ integrate │ working branch
194
199
  └─────────────┘
195
200
  ```
196
201
 
197
202
  **Single task** (`/task`): Worker iterates in fresh-context loops. STATUS.md is persistent memory. Each checkbox → git checkpoint. Reviewer validates on completion.
198
203
 
199
- **Parallel batch** (`/orch`): Tasks are sorted into dependency waves. Each wave runs in parallel across lanes (git worktrees). Completed lanes merge into the integration branch before the next wave starts.
204
+ **Parallel batch** (`/orch`): Tasks are sorted into dependency waves. Each wave runs in parallel across lanes (git worktrees). Completed lanes merge into a dedicated orch branch. When the batch completes, use `/orch-integrate` to bring the results into your working branch (or configure auto-integration).
200
205
 
201
206
  ## Documentation
202
207
 
@@ -9,7 +9,7 @@ import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
9
  import { execLog, executeWave, tmuxKillSession } from "./execution.ts";
10
10
  import type { MonitorUpdateCallback } from "./execution.ts";
11
11
  import { getCurrentBranch, runGit } from "./git.ts";
12
- import { mergeWaveByRepo } from "./merge.ts";
12
+ import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
13
13
  import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
14
14
  import { resolveOperatorId } from "./naming.ts";
15
15
  import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
@@ -182,6 +182,26 @@ export async function executeOrchBatch(
182
182
  wavePlan = rawWaves;
183
183
  discoveryRef = discovery;
184
184
 
185
+ // ── Create orchestrator-managed branch ───────────────────────
186
+ // Created after all planning validations pass (preflight, discovery,
187
+ // graph validation, wave computation) to avoid orphan branches on
188
+ // planning-phase early exits.
189
+ // The orch branch isolates all batch work from the user's current branch.
190
+ // Worktrees branch from it; merges target it via update-ref.
191
+ const opId = resolveOperatorId(orchConfig);
192
+ const orchBranch = `orch/${opId}-${batchState.batchId}`;
193
+ const branchResult = runGit(["branch", orchBranch, batchState.baseBranch], repoRoot);
194
+ if (!branchResult.ok) {
195
+ batchState.phase = "failed";
196
+ batchState.endedAt = Date.now();
197
+ const errDetail = branchResult.stderr || branchResult.stdout || "unknown error";
198
+ batchState.errors.push(`Failed to create orch branch '${orchBranch}': ${errDetail}`);
199
+ onNotify(`❌ Failed to create orch branch '${orchBranch}': ${errDetail}`, "error");
200
+ return;
201
+ }
202
+ batchState.orchBranch = orchBranch;
203
+ execLog("batch", batchState.batchId, "created orch branch", { orchBranch, baseBranch: batchState.baseBranch });
204
+
185
205
  onNotify(
186
206
  ORCH_MESSAGES.orchStarting(batchState.batchId, rawWaves.length, batchState.totalTasks),
187
207
  "info",
@@ -253,7 +273,7 @@ export async function executeOrchBatch(
253
273
  batchState.batchId,
254
274
  batchState.pauseSignal,
255
275
  depGraph,
256
- batchState.baseBranch,
276
+ batchState.orchBranch,
257
277
  handleWaveMonitorUpdate,
258
278
  (lanes) => {
259
279
  latestAllocatedLanes = lanes;
@@ -361,7 +381,7 @@ export async function executeOrchBatch(
361
381
  orchConfig,
362
382
  repoRoot,
363
383
  batchState.batchId,
364
- batchState.baseBranch,
384
+ batchState.orchBranch,
365
385
  workspaceConfig,
366
386
  stateRoot,
367
387
  agentRoot,
@@ -481,7 +501,7 @@ export async function executeOrchBatch(
481
501
  if (waveIdx < rawWaves.length - 1 && !batchState.pauseSignal.paused) {
482
502
  const prefix = orchConfig.orchestrator.worktree_prefix;
483
503
  const resetOpId = resolveOperatorId(orchConfig);
484
- const existingWorktrees = listWorktrees(prefix, repoRoot, resetOpId);
504
+ const existingWorktrees = listWorktrees(prefix, repoRoot, resetOpId, batchState.batchId);
485
505
 
486
506
  if (existingWorktrees.length > 0) {
487
507
  onNotify(
@@ -489,7 +509,7 @@ export async function executeOrchBatch(
489
509
  "info",
490
510
  );
491
511
 
492
- const targetBranch = batchState.baseBranch;
512
+ const targetBranch = batchState.orchBranch;
493
513
  for (const wt of existingWorktrees) {
494
514
  const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
495
515
  if (!resetResult.success) {
@@ -672,11 +692,13 @@ export async function executeOrchBatch(
672
692
  }
673
693
  } catch { /* .pi dir may not exist */ }
674
694
 
675
- // Clean up worktrees — pass base branch to protect unmerged work
676
- const targetBranch = batchState.baseBranch;
695
+ // Clean up worktrees — use orchBranch to protect unmerged work.
696
+ // Lane branches were merged into orchBranch (not baseBranch), so
697
+ // unmerged-branch detection must compare against orchBranch.
698
+ const targetBranch = batchState.orchBranch;
677
699
  const cleanupOpId = resolveOperatorId(orchConfig);
678
700
  execLog("batch", batchState.batchId, "cleaning up worktrees");
679
- const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch);
701
+ const removeResult = removeAllWorktrees(prefix, repoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
680
702
 
681
703
  // Log preserved branches
682
704
  for (const p of removeResult.preserved) {
@@ -750,6 +772,35 @@ export async function executeOrchBatch(
750
772
  }
751
773
  }
752
774
 
775
+ // ── Auto-Integration & Orch Branch Preservation (TP-022 Step 4) ──
776
+ // After all waves are done, optionally fast-forward baseBranch to orchBranch.
777
+ // Auto-integration never converts a successful batch into "failed" — failures
778
+ // are warnings that preserve the orch branch for manual integration.
779
+ // Gate: only run for terminal phases (completed/failed). Paused/stopped batches
780
+ // are not yet done — integration would mutate refs prematurely.
781
+ let autoIntegrated = false;
782
+ const mergedTaskCount = batchState.succeededTasks;
783
+ const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed";
784
+ if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) {
785
+ if (orchConfig.orchestrator.integration === "auto") {
786
+ autoIntegrated = attemptAutoIntegration(
787
+ batchState.orchBranch,
788
+ batchState.baseBranch,
789
+ repoRoot,
790
+ batchState.batchId,
791
+ "batch",
792
+ onNotify,
793
+ );
794
+ }
795
+ // Manual mode (default) or auto-integration skipped: show integration guidance
796
+ if (!autoIntegrated) {
797
+ onNotify(
798
+ ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount),
799
+ "info",
800
+ );
801
+ }
802
+ }
803
+
753
804
  // ── TS-009: Persist terminal state ──
754
805
  persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discoveryRef, stateRoot);
755
806
 
@@ -783,5 +834,6 @@ export async function executeOrchBatch(
783
834
  }
784
835
  }
785
836
 
837
+
786
838
  // ── Dashboard Widget (Step 6) ────────────────────────────────────────
787
839
 
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
2
 
3
- import { execSync } from "child_process";
3
+ import { execSync, execFileSync } from "child_process";
4
4
  import { writeFileSync, unlinkSync, mkdirSync } from "fs";
5
5
  import { join } from "path";
6
6
 
@@ -9,6 +9,7 @@ import {
9
9
  DEFAULT_TASK_RUNNER_CONFIG,
10
10
  FATAL_DISCOVERY_CODES,
11
11
  ORCH_MESSAGES,
12
+ StateFileError,
12
13
  WorkspaceConfigError,
13
14
  computeWaveAssignments,
14
15
  createOrchWidget,
@@ -22,6 +23,7 @@ import {
22
23
  formatPreflightResults,
23
24
  formatWavePlan,
24
25
  freshOrchBatchState,
26
+ getCurrentBranch,
25
27
  listOrchSessions,
26
28
  loadBatchState,
27
29
  loadOrchestratorConfig,
@@ -29,6 +31,7 @@ import {
29
31
  parseOrchSessionNames,
30
32
  resumeOrchBatch,
31
33
  runDiscovery,
34
+ runGit,
32
35
  runPreflight,
33
36
  } from "./index.ts";
34
37
  import { buildExecutionContext } from "./workspace.ts";
@@ -42,6 +45,425 @@ import type {
42
45
  TaskRunnerConfig,
43
46
  } from "./index.ts";
44
47
 
48
+ // ── Integrate Args Parsing ────────────────────────────────────────────
49
+
50
+ export type IntegrateMode = "ff" | "merge" | "pr";
51
+
52
+ export interface IntegrateArgs {
53
+ mode: IntegrateMode;
54
+ force: boolean;
55
+ orchBranchArg?: string;
56
+ }
57
+
58
+ /**
59
+ * Parse `/orch-integrate` command arguments.
60
+ *
61
+ * Supported flags: --merge, --pr, --force
62
+ * Optional positional: orch branch name (e.g., orch/op-batchid)
63
+ *
64
+ * Returns parsed args or an error string if arguments are invalid.
65
+ */
66
+ export function parseIntegrateArgs(raw: string | undefined): IntegrateArgs | { error: string } {
67
+ const input = raw?.trim() ?? "";
68
+ const tokens = input.split(/\s+/).filter(Boolean);
69
+
70
+ let mode: IntegrateMode = "ff";
71
+ let force = false;
72
+ const positionals: string[] = [];
73
+ let hasMerge = false;
74
+ let hasPr = false;
75
+
76
+ for (const token of tokens) {
77
+ if (token === "--merge") {
78
+ hasMerge = true;
79
+ } else if (token === "--pr") {
80
+ hasPr = true;
81
+ } else if (token === "--force") {
82
+ force = true;
83
+ } else if (token.startsWith("--")) {
84
+ return { error: `Unknown flag: ${token}` };
85
+ } else {
86
+ positionals.push(token);
87
+ }
88
+ }
89
+
90
+ // Mutual exclusion: --merge and --pr cannot be used together
91
+ if (hasMerge && hasPr) {
92
+ return { error: "Cannot use --merge and --pr together. Choose one integration mode." };
93
+ }
94
+
95
+ if (hasMerge) mode = "merge";
96
+ if (hasPr) mode = "pr";
97
+
98
+ if (positionals.length > 1) {
99
+ return { error: `Expected at most one branch argument, got ${positionals.length}: ${positionals.join(", ")}` };
100
+ }
101
+
102
+ return {
103
+ mode,
104
+ force,
105
+ orchBranchArg: positionals[0],
106
+ };
107
+ }
108
+
109
+ // ── Integration Context Resolution ────────────────────────────────────
110
+
111
+ /**
112
+ * Successful result from resolveIntegrationContext.
113
+ */
114
+ export interface IntegrationContext {
115
+ orchBranch: string;
116
+ baseBranch: string;
117
+ batchId: string;
118
+ currentBranch: string;
119
+ /** Informational messages generated during resolution (e.g., auto-detect notices) */
120
+ notices: string[];
121
+ }
122
+
123
+ /**
124
+ * Error result from resolveIntegrationContext.
125
+ */
126
+ export interface IntegrationContextError {
127
+ error: string;
128
+ /** "info" for non-error states (legacy mode), "error" for real failures */
129
+ severity: "info" | "error";
130
+ }
131
+
132
+ /**
133
+ * Dependencies injected into resolveIntegrationContext for testability.
134
+ */
135
+ export interface IntegrationDeps {
136
+ loadBatchState: () => PersistedBatchState | null;
137
+ getCurrentBranch: () => string | null;
138
+ listOrchBranches: () => string[];
139
+ orchBranchExists: (branch: string) => boolean;
140
+ }
141
+
142
+ /**
143
+ * Pure function to resolve all context needed for /orch-integrate.
144
+ *
145
+ * Resolution order:
146
+ * 1. Try loading persisted batch state → extract orchBranch/baseBranch
147
+ * 2. If state unavailable, use positional CLI arg
148
+ * 3. If neither, scan for orch/* branches
149
+ *
150
+ * Also performs: phase gating, legacy mode detection, branch existence check,
151
+ * detached HEAD check, and branch safety validation.
152
+ *
153
+ * Returns either a fully-resolved IntegrationContext or an IntegrationContextError.
154
+ */
155
+ export function resolveIntegrationContext(
156
+ parsed: IntegrateArgs,
157
+ deps: IntegrationDeps,
158
+ ): IntegrationContext | IntegrationContextError {
159
+ let orchBranch = "";
160
+ let baseBranch = "";
161
+ let batchId = "";
162
+ const notices: string[] = [];
163
+
164
+ // Source 1: Try loading batch state
165
+ try {
166
+ const state = deps.loadBatchState();
167
+ if (state) {
168
+ orchBranch = state.orchBranch ?? "";
169
+ baseBranch = state.baseBranch ?? "";
170
+ batchId = state.batchId;
171
+
172
+ // Phase gate: batch must be completed before integration
173
+ if (state.phase !== "completed") {
174
+ return {
175
+ error:
176
+ `⏳ Batch ${batchId} is currently in "${state.phase}" phase.\n` +
177
+ `Integration requires a completed batch.\n` +
178
+ `Run /orch-status to check progress, or wait for the batch to finish.`,
179
+ severity: "info",
180
+ };
181
+ }
182
+
183
+ // Legacy merge mode check
184
+ if (!orchBranch) {
185
+ return {
186
+ error:
187
+ `ℹ️ Batch ${batchId} used legacy merge mode — work was already merged directly into ${baseBranch || "the base branch"}.\n` +
188
+ `There is no separate orch branch to integrate.`,
189
+ severity: "info",
190
+ };
191
+ }
192
+ }
193
+ } catch (err: unknown) {
194
+ // Capture the error but don't return yet — user may have provided a branch arg
195
+ const msg = err instanceof StateFileError
196
+ ? (err.code === "STATE_FILE_IO_ERROR"
197
+ ? `Could not read batch state file: ${err.message}`
198
+ : err.code === "STATE_FILE_PARSE_ERROR"
199
+ ? `Batch state file contains invalid JSON: ${err.message}`
200
+ : `Batch state file has invalid schema: ${err.message}`)
201
+ : `Unexpected error loading batch state: ${(err as Error).message}`;
202
+ if (!parsed.orchBranchArg) {
203
+ return {
204
+ error: `⚠️ ${msg}\nYou can specify the orch branch directly: /orch-integrate <orch-branch>`,
205
+ severity: "error",
206
+ };
207
+ }
208
+ notices.push(`⚠️ ${msg} — using provided branch arg instead.`);
209
+ }
210
+
211
+ // Source 2: CLI positional branch arg overrides or fills in
212
+ if (parsed.orchBranchArg) {
213
+ orchBranch = parsed.orchBranchArg;
214
+ }
215
+
216
+ // Source 3: Neither state nor arg — scan for orch/* branches
217
+ if (!orchBranch) {
218
+ const candidates = deps.listOrchBranches();
219
+ if (candidates.length === 0) {
220
+ return {
221
+ error:
222
+ "❌ No completed batch found and no orch branches exist.\n" +
223
+ "Run /orch first to create a batch, or specify a branch: /orch-integrate <orch-branch>",
224
+ severity: "error",
225
+ };
226
+ }
227
+ if (candidates.length === 1) {
228
+ orchBranch = candidates[0];
229
+ notices.push(`ℹ️ No batch state found. Auto-detected orch branch: ${orchBranch}`);
230
+ } else {
231
+ return {
232
+ error:
233
+ `❌ No batch state found and multiple orch branches exist:\n` +
234
+ candidates.map(b => ` • ${b}`).join("\n") +
235
+ `\n\nSpecify which branch to integrate: /orch-integrate <orch-branch>`,
236
+ severity: "error",
237
+ };
238
+ }
239
+ }
240
+
241
+ // Verify orch branch exists
242
+ if (!deps.orchBranchExists(orchBranch)) {
243
+ return {
244
+ error: `❌ Branch "${orchBranch}" does not exist locally.\nCheck the branch name and try again.`,
245
+ severity: "error",
246
+ };
247
+ }
248
+
249
+ // Detached HEAD check
250
+ const currentBranch = deps.getCurrentBranch();
251
+ if (currentBranch === null) {
252
+ return {
253
+ error:
254
+ "❌ HEAD is detached — cannot integrate.\n" +
255
+ "Check out a branch first (e.g., `git checkout main`), then retry.",
256
+ severity: "error",
257
+ };
258
+ }
259
+
260
+ // Infer baseBranch from current branch when state is unavailable
261
+ if (!baseBranch) {
262
+ baseBranch = currentBranch;
263
+ }
264
+
265
+ // Branch safety: current branch must match baseBranch (unless --force)
266
+ if (currentBranch !== baseBranch && !parsed.force) {
267
+ return {
268
+ error:
269
+ `⚠️ Batch was started from ${baseBranch}, but you're on ${currentBranch}.\n` +
270
+ `Switch to ${baseBranch} first, or use /orch-integrate --force to skip this check.`,
271
+ severity: "error",
272
+ };
273
+ }
274
+
275
+ return {
276
+ orchBranch,
277
+ baseBranch,
278
+ batchId,
279
+ currentBranch,
280
+ notices,
281
+ };
282
+ }
283
+
284
+ // ── Integration Execution ─────────────────────────────────────────────
285
+
286
+ /**
287
+ * Result of an integration attempt.
288
+ */
289
+ export interface IntegrationResult {
290
+ /** Whether the integration succeeded overall */
291
+ success: boolean;
292
+ /** True if work was integrated locally (ff/merge) — controls cleanup eligibility */
293
+ integratedLocally: boolean;
294
+ /** Number of commits applied (informational) */
295
+ commitCount: string;
296
+ /** User-facing success message */
297
+ message: string;
298
+ /** User-facing error message (only when success=false) */
299
+ error?: string;
300
+ }
301
+
302
+ /**
303
+ * Dependencies injected into executeIntegration for testability.
304
+ */
305
+ export interface IntegrationExecDeps {
306
+ runGit: (args: string[]) => { ok: boolean; stdout: string; stderr: string };
307
+ runCommand: (cmd: string, args: string[]) => { ok: boolean; stdout: string; stderr: string };
308
+ deleteBatchState: () => void;
309
+ }
310
+
311
+ /**
312
+ * Execute the integration operation for the resolved context.
313
+ *
314
+ * Mode-specific behavior:
315
+ * - ff: `git merge --ff-only {orchBranch}`. On failure → suggest --merge/--pr.
316
+ * - merge: `git merge {orchBranch} --no-edit`. On failure → show stderr.
317
+ * - pr: `git push origin {orchBranch}` then `gh pr create`. Never cleans up locally.
318
+ *
319
+ * Cleanup (local branch deletion + state file removal) is gated on integratedLocally === true.
320
+ * Cleanup failures are non-fatal (included as warnings in the message).
321
+ */
322
+ export function executeIntegration(
323
+ mode: IntegrateMode,
324
+ context: IntegrationContext,
325
+ deps: IntegrationExecDeps,
326
+ ): IntegrationResult {
327
+ const { orchBranch, currentBranch, batchId } = context;
328
+
329
+ if (mode === "ff") {
330
+ // Fast-forward merge
331
+ const result = deps.runGit(["merge", "--ff-only", orchBranch]);
332
+ if (!result.ok) {
333
+ return {
334
+ success: false,
335
+ integratedLocally: false,
336
+ commitCount: "0",
337
+ message: "",
338
+ error:
339
+ `❌ Fast-forward failed — branches have diverged.\n` +
340
+ `${result.stderr}\n\n` +
341
+ `Try:\n` +
342
+ ` /orch-integrate --merge Create a merge commit\n` +
343
+ ` /orch-integrate --pr Create a pull request instead`,
344
+ };
345
+ }
346
+ // Count commits that were applied
347
+ const countResult = deps.runGit(["rev-list", "--count", `${orchBranch}..HEAD`]);
348
+ // After ff, HEAD === orchBranch tip so we use a different measurement
349
+ // The rev-list before the merge was computed in the handler; pass commitCount through context
350
+ // Actually, for ff: commits applied = what was ahead before merge.
351
+ // After ff merge HEAD moved forward, so we measure from the merge-base.
352
+ // Simplest: use "merge was successful" and the pre-computed count from the handler.
353
+ return performCleanup(deps, orchBranch, {
354
+ success: true,
355
+ integratedLocally: true,
356
+ commitCount: "?", // Overridden by caller with pre-computed count
357
+ message: `✅ Fast-forwarded ${currentBranch} to ${orchBranch}.`,
358
+ });
359
+ }
360
+
361
+ if (mode === "merge") {
362
+ const result = deps.runGit(["merge", orchBranch, "--no-edit"]);
363
+ if (!result.ok) {
364
+ return {
365
+ success: false,
366
+ integratedLocally: false,
367
+ commitCount: "0",
368
+ message: "",
369
+ error:
370
+ `❌ Merge failed — there may be conflicts.\n` +
371
+ `${result.stderr}\n\n` +
372
+ `Resolve conflicts manually, or try:\n` +
373
+ ` /orch-integrate --pr Create a pull request instead`,
374
+ };
375
+ }
376
+ return performCleanup(deps, orchBranch, {
377
+ success: true,
378
+ integratedLocally: true,
379
+ commitCount: "?",
380
+ message: `✅ Merged ${orchBranch} into ${currentBranch} (merge commit created).`,
381
+ });
382
+ }
383
+
384
+ // PR mode
385
+ // Step 1: Push the orch branch to origin
386
+ const pushResult = deps.runGit(["push", "origin", orchBranch]);
387
+ if (!pushResult.ok) {
388
+ return {
389
+ success: false,
390
+ integratedLocally: false,
391
+ commitCount: "0",
392
+ message: "",
393
+ error:
394
+ `❌ Failed to push ${orchBranch} to origin.\n` +
395
+ `${pushResult.stderr}\n\n` +
396
+ `Check your remote configuration and try again.`,
397
+ };
398
+ }
399
+
400
+ // Step 2: Create pull request via gh CLI
401
+ const prTitle = batchId
402
+ ? `Integrate orch batch ${batchId}`
403
+ : `Integrate ${orchBranch}`;
404
+ const ghResult = deps.runCommand("gh", [
405
+ "pr", "create",
406
+ "--base", currentBranch,
407
+ "--head", orchBranch,
408
+ "--title", prTitle,
409
+ "--fill",
410
+ ]);
411
+ if (!ghResult.ok) {
412
+ return {
413
+ success: false,
414
+ integratedLocally: false,
415
+ commitCount: "0",
416
+ message: "",
417
+ error:
418
+ `❌ Branch pushed but PR creation failed.\n` +
419
+ `${ghResult.stderr}\n\n` +
420
+ `The branch ${orchBranch} is on origin — create the PR manually.`,
421
+ };
422
+ }
423
+
424
+ const prUrl = ghResult.stdout.trim();
425
+ return {
426
+ success: true,
427
+ integratedLocally: false, // PR mode: branch must survive
428
+ commitCount: "0",
429
+ message:
430
+ `✅ Pull request created for ${orchBranch} → ${currentBranch}.\n` +
431
+ (prUrl ? ` ${prUrl}\n` : "") +
432
+ `\nThe orch branch has been kept (needed for the PR).`,
433
+ };
434
+ }
435
+
436
+ /**
437
+ * Perform post-integration cleanup: delete local orch branch and batch state.
438
+ * Cleanup failures are non-fatal — warnings are appended to the result message.
439
+ */
440
+ function performCleanup(
441
+ deps: IntegrationExecDeps,
442
+ orchBranch: string,
443
+ result: IntegrationResult,
444
+ ): IntegrationResult {
445
+ const warnings: string[] = [];
446
+
447
+ // Delete local orch branch
448
+ const branchDelete = deps.runGit(["branch", "-D", orchBranch]);
449
+ if (!branchDelete.ok) {
450
+ warnings.push(`⚠️ Could not delete local branch ${orchBranch}: ${branchDelete.stderr}`);
451
+ }
452
+
453
+ // Delete batch state file
454
+ try {
455
+ deps.deleteBatchState();
456
+ } catch (err: unknown) {
457
+ warnings.push(`⚠️ Could not clean up batch state: ${(err as Error).message}`);
458
+ }
459
+
460
+ if (warnings.length > 0) {
461
+ result.message += "\n" + warnings.join("\n");
462
+ }
463
+
464
+ return result;
465
+ }
466
+
45
467
  // ── Extension ────────────────────────────────────────────────────────
46
468
 
47
469
  export default function (pi: ExtensionAPI) {
@@ -647,6 +1069,143 @@ export default function (pi: ExtensionAPI) {
647
1069
  },
648
1070
  });
649
1071
 
1072
+ pi.registerCommand("orch-integrate", {
1073
+ description: "Integrate completed orch batch into your working branch",
1074
+ handler: async (args, ctx) => {
1075
+ // Show usage if no args and no active batch state to infer from
1076
+ if (args?.trim() === "--help" || args?.trim() === "-h") {
1077
+ ctx.ui.notify(
1078
+ "Usage: /orch-integrate [<orch-branch>] [--merge] [--pr] [--force]\n\n" +
1079
+ "Integrate a completed orch batch into your working branch.\n\n" +
1080
+ "Modes:\n" +
1081
+ " (default) Fast-forward merge (cleanest history)\n" +
1082
+ " --merge Create a real merge commit\n" +
1083
+ " --pr Push orch branch and create a pull request\n\n" +
1084
+ "Options:\n" +
1085
+ " --force Skip branch safety check\n" +
1086
+ " <branch> Orch branch name (auto-detected from batch state if omitted)\n\n" +
1087
+ "Examples:\n" +
1088
+ " /orch-integrate Auto-detect and fast-forward\n" +
1089
+ " /orch-integrate --merge Auto-detect with merge commit\n" +
1090
+ " /orch-integrate orch/op-abc123 --pr Specific branch, create PR\n" +
1091
+ " /orch-integrate --force Skip branch safety check",
1092
+ "info",
1093
+ );
1094
+ return;
1095
+ }
1096
+
1097
+ if (!requireExecCtx(ctx)) return;
1098
+
1099
+ // Parse arguments
1100
+ const parsed = parseIntegrateArgs(args);
1101
+ if ("error" in parsed) {
1102
+ ctx.ui.notify(`❌ ${parsed.error}\n\nRun /orch-integrate --help for usage.`, "error");
1103
+ return;
1104
+ }
1105
+
1106
+ // ── Step 2: Resolve integration context ──────────────────
1107
+ const { repoRoot } = execCtx!;
1108
+ const resolution = resolveIntegrationContext(parsed, {
1109
+ loadBatchState: () => loadBatchState(repoRoot),
1110
+ getCurrentBranch: () => getCurrentBranch(repoRoot),
1111
+ listOrchBranches: () => {
1112
+ const result = runGit(["branch", "--list", "orch/*"], repoRoot);
1113
+ return result.ok
1114
+ ? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean)
1115
+ : [];
1116
+ },
1117
+ orchBranchExists: (branch: string) => {
1118
+ return runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot).ok;
1119
+ },
1120
+ });
1121
+
1122
+ if ("error" in resolution) {
1123
+ const severity = (resolution as IntegrationContextError).severity;
1124
+ ctx.ui.notify(resolution.error, severity === "info" ? "info" : "error");
1125
+ return;
1126
+ }
1127
+
1128
+ const { orchBranch, baseBranch, batchId, currentBranch, notices } = resolution as IntegrationContext;
1129
+
1130
+ // Show any notices from resolution (auto-detection messages, warnings)
1131
+ for (const notice of notices) {
1132
+ ctx.ui.notify(notice, "info");
1133
+ }
1134
+
1135
+ // ── Step 2: Pre-integration summary ──────────────────────
1136
+ // Count commits ahead
1137
+ const revListResult = runGit(
1138
+ ["rev-list", "--count", `${currentBranch}..${orchBranch}`],
1139
+ repoRoot,
1140
+ );
1141
+ const commitsAhead = revListResult.ok ? revListResult.stdout.trim() : "?";
1142
+
1143
+ // Get diff summary
1144
+ const diffStatResult = runGit(
1145
+ ["diff", "--stat", `${currentBranch}...${orchBranch}`],
1146
+ repoRoot,
1147
+ );
1148
+ const diffSummary = diffStatResult.ok ? diffStatResult.stdout.trim() : "(unable to compute diff)";
1149
+
1150
+ ctx.ui.notify(
1151
+ `🔀 Integration Summary\n` +
1152
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
1153
+ ` Orch branch: ${orchBranch}\n` +
1154
+ ` Target: ${currentBranch}\n` +
1155
+ ` Commits: ${commitsAhead} ahead\n` +
1156
+ ` Mode: ${parsed.mode === "ff" ? "fast-forward" : parsed.mode === "merge" ? "merge commit" : "pull request"}\n` +
1157
+ (batchId ? ` Batch: ${batchId}\n` : "") +
1158
+ (parsed.force ? ` ⚠ Force: branch safety check skipped\n` : "") +
1159
+ `\n` +
1160
+ (diffSummary ? `${diffSummary}\n` : "") +
1161
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
1162
+ "info",
1163
+ );
1164
+
1165
+ // ── Step 3: Execute integration mode ─────────────────
1166
+ const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
1167
+ runGit: (gitArgs: string[]) => runGit(gitArgs, repoRoot),
1168
+ runCommand: (cmd: string, cmdArgs: string[]) => {
1169
+ try {
1170
+ const stdout = execFileSync(cmd, cmdArgs, {
1171
+ encoding: "utf-8",
1172
+ timeout: 60_000,
1173
+ cwd: repoRoot,
1174
+ stdio: ["pipe", "pipe", "pipe"],
1175
+ }).trim();
1176
+ return { ok: true, stdout, stderr: "" };
1177
+ } catch (err: unknown) {
1178
+ const e = err as { stdout?: string; stderr?: string; message?: string };
1179
+ return {
1180
+ ok: false,
1181
+ stdout: (e.stdout ?? "").toString().trim(),
1182
+ stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
1183
+ };
1184
+ }
1185
+ },
1186
+ deleteBatchState: () => deleteBatchState(repoRoot),
1187
+ });
1188
+
1189
+ if (!integrationResult.success) {
1190
+ ctx.ui.notify(integrationResult.error!, "error");
1191
+ return;
1192
+ }
1193
+
1194
+ // Override commit count with pre-computed value for local integrations
1195
+ if (integrationResult.integratedLocally) {
1196
+ integrationResult.commitCount = commitsAhead;
1197
+ }
1198
+
1199
+ ctx.ui.notify(
1200
+ integrationResult.message +
1201
+ (integrationResult.integratedLocally
1202
+ ? `\n${integrationResult.commitCount} commit(s) applied.`
1203
+ : ""),
1204
+ "info",
1205
+ );
1206
+ },
1207
+ });
1208
+
650
1209
  // ── Settings TUI ─────────────────────────────────────────────────
651
1210
 
652
1211
  pi.registerCommand("taskplane-settings", {
@@ -719,7 +1278,8 @@ export default function (pi: ExtensionAPI) {
719
1278
  "/orch <areas|all> Start batch execution\n" +
720
1279
  "/orch-plan <areas|all> Preview execution plan\n" +
721
1280
  "/orch-deps <areas|all> Show dependency graph\n" +
722
- "/orch-sessions List TMUX sessions",
1281
+ "/orch-sessions List TMUX sessions\n" +
1282
+ "/orch-integrate Integrate orch branch into working branch",
723
1283
  "info",
724
1284
  );
725
1285
 
@@ -11,7 +11,9 @@ import { resolveOperatorId } from "./naming.ts";
11
11
  import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MS, MergeError, VALID_MERGE_STATUSES } from "./types.ts";
12
12
  import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
13
13
  import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
14
- import { sleepSync } from "./worktree.ts";
14
+ import { generateMergeWorktreePath, sleepSync } from "./worktree.ts";
15
+ import { getCurrentBranch, runGit } from "./git.ts";
16
+ import { ORCH_MESSAGES } from "./messages.ts";
15
17
 
16
18
  // ── Merge Implementation ─────────────────────────────────────────────
17
19
 
@@ -567,9 +569,10 @@ export function mergeWave(
567
569
  // ── Create isolated merge worktree ──────────────────────────────
568
570
  // Merging in a dedicated worktree prevents dirty-worktree failures
569
571
  // caused by user edits or orchestrator-generated files in the main repo.
570
- // Include opId to prevent collisions between concurrent operators.
572
+ // The merge worktree lives inside the batch container alongside lane worktrees:
573
+ // {basePath}/{opId}-{batchId}/merge
571
574
  const tempBranch = `_merge-temp-${opId}-${batchId}`;
572
- const mergeWorkDir = join(repoRoot, ".worktrees", `merge-workspace-${opId}`);
575
+ const mergeWorkDir = generateMergeWorktreePath(repoRoot, opId, batchId, config);
573
576
 
574
577
  // Clean up stale merge worktree/branch from prior failed attempt
575
578
  try {
@@ -752,37 +755,87 @@ export function mergeWave(
752
755
  }
753
756
  }
754
757
 
755
- // ── Fast-forward develop and clean up merge worktree ────────────
758
+ // ── Update target branch ref and clean up merge worktree ────────
756
759
  const anySuccess = laneResults.some(
757
760
  r => r.result?.status === "SUCCESS" || r.result?.status === "CONFLICT_RESOLVED",
758
761
  );
759
762
 
760
763
  if (anySuccess) {
761
- // Fast-forward the real target branch to the temp merge branch.
762
- // The main repo may have dirty files (user edits) — stash if needed.
763
- const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
764
-
765
- if (ffResult.status !== 0) {
766
- // Dirty working tree may block ff try stash + ff + pop
767
- execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
768
- const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
769
- spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
770
-
771
- const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
772
-
773
- // Always pop stash, regardless of ff result
774
- spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
775
-
776
- if (ffRetry.status !== 0) {
777
- const err = ffRetry.stderr?.toString().trim() || "unknown error";
778
- execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
779
- failedLane = failedLane ?? -1;
780
- failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
764
+ // Get the temp branch HEAD commit — this is the merged result.
765
+ const revParseResult = spawnSync("git", ["rev-parse", tempBranch], { cwd: repoRoot });
766
+
767
+ if (revParseResult.status !== 0) {
768
+ const err = revParseResult.stderr?.toString().trim() || "unknown error";
769
+ execLog("merge", `W${waveIndex}`, `failed to resolve temp branch HEAD: ${err}`, { tempBranch });
770
+ failedLane = failedLane ?? -1;
771
+ failureReason = `Failed to resolve merge temp branch HEAD (${tempBranch}): ${err}`;
772
+ } else {
773
+ const tempBranchHead = revParseResult.stdout.toString().trim();
774
+
775
+ // Gate advancement strategy:
776
+ // - If targetBranch is NOT checked out in repoRoot, use update-ref
777
+ // (safe, does not touch the working tree). This is the common case
778
+ // for the orch branch in repo mode.
779
+ // - If targetBranch IS checked out in repoRoot (workspace mode, where
780
+ // resolveBaseBranch returns the repo's current branch), use
781
+ // git merge --ff-only to advance HEAD+index+worktree together.
782
+ const checkedOutBranch = getCurrentBranch(repoRoot);
783
+ const targetIsCheckedOut = checkedOutBranch === targetBranch;
784
+
785
+ if (targetIsCheckedOut) {
786
+ // Checked-out branch — must use ff-only to keep HEAD/index/worktree in sync.
787
+ // Dirty working tree may block ff — stash if needed.
788
+ const ffResult = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
789
+
790
+ if (ffResult.status !== 0) {
791
+ // Dirty working tree may block ff — try stash + ff + pop
792
+ execLog("merge", `W${waveIndex}`, "fast-forward blocked — stashing user changes");
793
+ const stashMsg = `merge-agent-autostash-w${waveIndex}-${batchId}`;
794
+ spawnSync("git", ["stash", "push", "--include-untracked", "-m", stashMsg], { cwd: repoRoot });
795
+
796
+ const ffRetry = spawnSync("git", ["merge", "--ff-only", tempBranch], { cwd: repoRoot });
797
+
798
+ // Always pop stash, regardless of ff result
799
+ spawnSync("git", ["stash", "pop"], { cwd: repoRoot });
800
+
801
+ if (ffRetry.status !== 0) {
802
+ const err = ffRetry.stderr?.toString().trim() || "unknown error";
803
+ execLog("merge", `W${waveIndex}`, `fast-forward failed even after stash: ${err}`);
804
+ failedLane = failedLane ?? -1;
805
+ failureReason = `Fast-forward of ${targetBranch} failed: ${err}`;
806
+ } else {
807
+ execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
808
+ }
809
+ } else {
810
+ execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
811
+ }
781
812
  } else {
782
- execLog("merge", `W${waveIndex}`, "fast-forward succeeded after stash/pop");
813
+ // Not checked out — safe to use update-ref without touching the worktree.
814
+ // Use compare-and-swap (3-arg form) to guard against concurrent branch movement.
815
+ const oldRefResult = spawnSync("git", ["rev-parse", `refs/heads/${targetBranch}`], { cwd: repoRoot });
816
+ const oldRef = oldRefResult.status === 0 ? oldRefResult.stdout.toString().trim() : "";
817
+
818
+ const updateRefArgs = oldRef
819
+ ? ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead, oldRef]
820
+ : ["update-ref", `refs/heads/${targetBranch}`, tempBranchHead];
821
+
822
+ const updateRefResult = spawnSync("git", updateRefArgs, { cwd: repoRoot });
823
+
824
+ if (updateRefResult.status !== 0) {
825
+ const err = updateRefResult.stderr?.toString().trim() || "unknown error";
826
+ execLog("merge", `W${waveIndex}`, `update-ref failed for ${targetBranch}: ${err}`, {
827
+ targetBranch,
828
+ tempBranchHead: tempBranchHead.slice(0, 8),
829
+ });
830
+ failedLane = failedLane ?? -1;
831
+ failureReason = `update-ref of ${targetBranch} to ${tempBranchHead.slice(0, 8)} failed: ${err}`;
832
+ } else {
833
+ execLog("merge", `W${waveIndex}`, `updated ${targetBranch} ref to merge result`, {
834
+ targetBranch,
835
+ commit: tempBranchHead.slice(0, 8),
836
+ });
837
+ }
783
838
  }
784
- } else {
785
- execLog("merge", `W${waveIndex}`, `fast-forwarded ${targetBranch} to merge result`);
786
839
  }
787
840
  }
788
841
 
@@ -1068,3 +1121,105 @@ export function mergeWaveByRepo(
1068
1121
  };
1069
1122
  }
1070
1123
 
1124
+ // ── Auto-Integration ─────────────────────────────────────────────────
1125
+
1126
+ /**
1127
+ * Attempt to fast-forward baseBranch to orchBranch in the main repo.
1128
+ *
1129
+ * Shared by engine.ts (fresh batch) and resume.ts (resumed batch).
1130
+ * The `logCategory` parameter distinguishes the calling context in execLog.
1131
+ *
1132
+ * Failure matrix — all failures are warnings, never batch-fatal:
1133
+ * - **Diverged**: baseBranch has commits not in orchBranch (not fast-forwardable)
1134
+ * - **Detached HEAD / missing base**: baseBranch not resolvable
1135
+ * - **Dirty worktree**: baseBranch is checked out with uncommitted changes
1136
+ * - **Branch not checked out**: baseBranch is not the current branch;
1137
+ * use update-ref (no worktree impact) with compare-and-swap
1138
+ *
1139
+ * @param orchBranch - The orch branch to integrate from
1140
+ * @param baseBranch - The user's branch to advance
1141
+ * @param repoRoot - Absolute path to the primary repo root
1142
+ * @param batchId - Batch identifier for logging
1143
+ * @param logCategory - execLog category ("batch" for engine, "resume" for resume)
1144
+ * @param onNotify - Notification callback
1145
+ * @returns true if integration succeeded, false otherwise
1146
+ */
1147
+ export function attemptAutoIntegration(
1148
+ orchBranch: string,
1149
+ baseBranch: string,
1150
+ repoRoot: string,
1151
+ batchId: string,
1152
+ logCategory: string,
1153
+ onNotify: (message: string, level: "info" | "warning" | "error") => void,
1154
+ ): boolean {
1155
+ // 1. Verify orchBranch exists
1156
+ const orchExists = runGit(["rev-parse", "--verify", `refs/heads/${orchBranch}`], repoRoot);
1157
+ if (!orchExists.ok) {
1158
+ const reason = `orch branch '${orchBranch}' not found`;
1159
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
1160
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
1161
+ return false;
1162
+ }
1163
+
1164
+ // 2. Verify baseBranch exists
1165
+ const baseExists = runGit(["rev-parse", "--verify", `refs/heads/${baseBranch}`], repoRoot);
1166
+ if (!baseExists.ok) {
1167
+ const reason = `base branch '${baseBranch}' not found`;
1168
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
1169
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
1170
+ return false;
1171
+ }
1172
+
1173
+ // 3. Check fast-forwardability: baseBranch must be an ancestor of orchBranch
1174
+ const isAncestor = runGit(["merge-base", "--is-ancestor", baseBranch, orchBranch], repoRoot);
1175
+ if (!isAncestor.ok) {
1176
+ const reason = `branches have diverged (${baseBranch} is not an ancestor of ${orchBranch})`;
1177
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
1178
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
1179
+ return false;
1180
+ }
1181
+
1182
+ // 4. Gate on whether baseBranch is checked out (same pattern as merge advancement)
1183
+ const checkedOutBranch = getCurrentBranch(repoRoot);
1184
+ const baseIsCheckedOut = checkedOutBranch === baseBranch;
1185
+
1186
+ const orchHead = runGit(["rev-parse", orchBranch], repoRoot).stdout.trim();
1187
+
1188
+ if (baseIsCheckedOut) {
1189
+ // baseBranch is checked out — use merge --ff-only (updates worktree)
1190
+ // Check for dirty worktree first
1191
+ const statusCheck = runGit(["status", "--porcelain"], repoRoot);
1192
+ if (statusCheck.ok && statusCheck.stdout.trim()) {
1193
+ const reason = `working tree is dirty (${baseBranch} is checked out with uncommitted changes)`;
1194
+ execLog(logCategory, batchId, `auto-integration skipped: ${reason}`);
1195
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
1196
+ return false;
1197
+ }
1198
+
1199
+ const ffResult = runGit(["merge", "--ff-only", orchBranch], repoRoot);
1200
+ if (!ffResult.ok) {
1201
+ const reason = `fast-forward failed: ${ffResult.stderr || ffResult.stdout || "unknown"}`;
1202
+ execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
1203
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
1204
+ return false;
1205
+ }
1206
+ } else {
1207
+ // baseBranch is NOT checked out — use update-ref with compare-and-swap
1208
+ const baseOldRef = runGit(["rev-parse", baseBranch], repoRoot).stdout.trim();
1209
+ const updateResult = runGit(
1210
+ ["update-ref", `refs/heads/${baseBranch}`, orchHead, baseOldRef],
1211
+ repoRoot,
1212
+ );
1213
+ if (!updateResult.ok) {
1214
+ const reason = `update-ref failed: ${updateResult.stderr || updateResult.stdout || "unknown"}`;
1215
+ execLog(logCategory, batchId, `auto-integration failed: ${reason}`);
1216
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoFailed(orchBranch, baseBranch, reason), "warning");
1217
+ return false;
1218
+ }
1219
+ }
1220
+
1221
+ execLog(logCategory, batchId, `auto-integrated: ${baseBranch} advanced to ${orchBranch}`, { orchHead });
1222
+ onNotify(ORCH_MESSAGES.orchIntegrationAutoSuccess(orchBranch, baseBranch), "info");
1223
+ return true;
1224
+ }
1225
+
@@ -120,6 +120,24 @@ export const ORCH_MESSAGES = {
120
120
  // /orch merge — repo-scoped partial summary (TP-005 Step 1)
121
121
  orchMergePartialRepoSummary: (waveNum: number, repoLines: string[]) =>
122
122
  `⚠️ [Wave ${waveNum}] Merge partially succeeded — repo outcomes diverged:\n${repoLines.join("\n")}`,
123
+
124
+ // /orch integration — post-batch integration guidance (TP-022 Step 4)
125
+ orchIntegrationAutoSuccess: (orchBranch: string, baseBranch: string) =>
126
+ `✅ Auto-integrated: ${baseBranch} fast-forwarded to ${orchBranch}.`,
127
+ orchIntegrationAutoFailed: (orchBranch: string, baseBranch: string, reason: string) =>
128
+ `⚠️ Auto-integration skipped: ${reason}\n` +
129
+ ` Orch branch ${orchBranch} preserved. Integrate manually:\n` +
130
+ ` git log ${baseBranch}..${orchBranch}\n` +
131
+ ` git merge ${orchBranch}`,
132
+ orchIntegrationManual: (orchBranch: string, baseBranch: string, mergedTaskCount: number) => {
133
+ const lines = [
134
+ `ℹ️ Batch complete. Orch branch ${orchBranch} has ${mergedTaskCount} merged task(s).`,
135
+ ` Review and integrate:`,
136
+ ` git log ${baseBranch}..${orchBranch}`,
137
+ ` git merge ${orchBranch}`,
138
+ ];
139
+ return lines.join("\n");
140
+ },
123
141
  } as const;
124
142
 
125
143
 
@@ -9,14 +9,14 @@ import { runDiscovery } from "./discovery.ts";
9
9
  import { executeOrchBatch } from "./engine.ts";
10
10
  import { computeTransitiveDependents, execLog, executeWave, pollUntilTaskComplete, spawnLaneSession, tmuxHasSession } from "./execution.ts";
11
11
  import type { MonitorUpdateCallback } from "./execution.ts";
12
- import { runGit } from "./git.ts";
13
- import { mergeWaveByRepo } from "./merge.ts";
12
+ import { getCurrentBranch, runGit } from "./git.ts";
13
+ import { attemptAutoIntegration, mergeWaveByRepo } from "./merge.ts";
14
14
  import { computeMergeFailurePolicy, formatRepoMergeSummary, ORCH_MESSAGES } from "./messages.ts";
15
15
  import { resolveOperatorId } from "./naming.ts";
16
16
  import { deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
17
17
  import { StateFileError } from "./types.ts";
18
18
  import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, PersistedLaneRecord, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
19
- import { buildDependencyGraph, resolveRepoRoot } from "./waves.ts";
19
+ import { buildDependencyGraph, resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
20
20
  import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
21
21
 
22
22
  // ── Resume Repo Helpers ──────────────────────────────────────────────
@@ -56,6 +56,37 @@ export function collectRepoRoots(
56
56
  return [...roots];
57
57
  }
58
58
 
59
+ /**
60
+ * Resolve a repoId from a resolved repo root path.
61
+ *
62
+ * In workspace mode, workspace config maps repoId → path. This performs
63
+ * the reverse lookup: given a resolved absolute path, find the repoId.
64
+ * Returns `undefined` if no workspace config or no matching repo is found
65
+ * (which is correct for repo mode or the primary/default repo).
66
+ *
67
+ * Used during cleanup to call `resolveBaseBranch()` per-repo with the
68
+ * correct repoId, ensuring unmerged-branch protection checks against
69
+ * the right target branch in workspace mode.
70
+ *
71
+ * @param repoRoot - Resolved absolute path of the repo
72
+ * @param workspaceConfig - Workspace configuration (null in repo mode)
73
+ * @returns The repoId or undefined if not found / not in workspace mode
74
+ */
75
+ export function resolveRepoIdFromRoot(
76
+ repoRoot: string,
77
+ workspaceConfig?: WorkspaceConfig | null,
78
+ ): string | undefined {
79
+ if (!workspaceConfig) return undefined;
80
+
81
+ for (const [repoId, repoConfig] of workspaceConfig.repos) {
82
+ if (repoConfig.path === repoRoot) {
83
+ return repoId;
84
+ }
85
+ }
86
+
87
+ return undefined;
88
+ }
89
+
59
90
  /**
60
91
  * Reconstruct AllocatedLane[] from persisted lane records.
61
92
  *
@@ -609,10 +640,26 @@ export async function resumeOrchBatch(
609
640
  }
610
641
 
611
642
  // ── 6. Reconstruct runtime state ─────────────────────────────
643
+
644
+ // Guard: orchBranch must be present for routing. Persisted states from
645
+ // pre-TP-022 runs may have orchBranch="" (TP-020 defaults).
646
+ // Check BEFORE mutating batchState so phase/batchId remain idle on rejection,
647
+ // allowing future /orch-resume or /orch-abort to proceed.
648
+ if (!persistedState.orchBranch) {
649
+ onNotify(
650
+ `❌ Cannot resume batch ${persistedState.batchId}: persisted state has no orch branch. ` +
651
+ `This batch was created before orch-branch routing was implemented. ` +
652
+ `Use /orch-abort to clean up, then start a new batch.`,
653
+ "error",
654
+ );
655
+ return;
656
+ }
657
+
612
658
  batchState.phase = "executing";
613
659
  batchState.batchId = persistedState.batchId;
614
660
  batchState.baseBranch = persistedState.baseBranch || "";
615
- batchState.orchBranch = persistedState.orchBranch || "";
661
+ batchState.orchBranch = persistedState.orchBranch;
662
+
616
663
  batchState.mode = persistedState.mode;
617
664
  batchState.startedAt = persistedState.startedAt;
618
665
  batchState.pauseSignal = { paused: false };
@@ -902,7 +949,7 @@ export async function resumeOrchBatch(
902
949
  orchConfig,
903
950
  repoRoot,
904
951
  batchState.batchId,
905
- batchState.baseBranch,
952
+ batchState.orchBranch,
906
953
  workspaceConfig,
907
954
  stateRoot,
908
955
  agentRoot,
@@ -1066,7 +1113,7 @@ export async function resumeOrchBatch(
1066
1113
  batchState.batchId,
1067
1114
  batchState.pauseSignal,
1068
1115
  depGraph,
1069
- batchState.baseBranch,
1116
+ batchState.orchBranch,
1070
1117
  handleResumeMonitorUpdate,
1071
1118
  (lanes) => {
1072
1119
  latestAllocatedLanes = lanes;
@@ -1181,7 +1228,7 @@ export async function resumeOrchBatch(
1181
1228
  orchConfig,
1182
1229
  repoRoot,
1183
1230
  batchState.batchId,
1184
- batchState.baseBranch,
1231
+ batchState.orchBranch,
1185
1232
  workspaceConfig,
1186
1233
  stateRoot,
1187
1234
  agentRoot,
@@ -1291,10 +1338,24 @@ export async function resumeOrchBatch(
1291
1338
  // Use encounteredRepoRoots which includes both persisted lanes
1292
1339
  // AND newly allocated lanes from resumed waves, ensuring repos
1293
1340
  // introduced after resume starts are covered.
1341
+ // Per-repo target branch: primary repo uses orchBranch, secondary
1342
+ // repos resolve their own branch (same as cleanup — see section 11).
1294
1343
  for (const perRepoRoot of encounteredRepoRoots) {
1295
- const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId);
1344
+ const existingWorktrees = listWorktrees(wtPrefix, perRepoRoot, resetOpId, batchState.batchId);
1296
1345
  if (existingWorktrees.length > 0) {
1297
- const targetBranch = batchState.baseBranch;
1346
+ let targetBranch: string;
1347
+ if (perRepoRoot === repoRoot) {
1348
+ targetBranch = batchState.orchBranch;
1349
+ } else {
1350
+ const repoId = resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
1351
+ try {
1352
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
1353
+ } catch {
1354
+ // If resolution fails, fall back to orchBranch (reset will
1355
+ // fail gracefully and trigger worktree removal)
1356
+ targetBranch = batchState.orchBranch;
1357
+ }
1358
+ }
1298
1359
  for (const wt of existingWorktrees) {
1299
1360
  const resetResult = safeResetWorktree(wt, targetBranch, perRepoRoot);
1300
1361
  if (!resetResult.success) {
@@ -1314,13 +1375,39 @@ export async function resumeOrchBatch(
1314
1375
  if (!preserveWorktreesForResume) {
1315
1376
  const wtPrefix = orchConfig.orchestrator.worktree_prefix;
1316
1377
  const cleanupOpId = resolveOperatorId(orchConfig);
1317
- const targetBranch = batchState.baseBranch;
1318
1378
 
1319
1379
  // Use encounteredRepoRoots which includes both persisted lanes
1320
1380
  // AND newly allocated lanes from resumed waves, ensuring repos
1321
1381
  // introduced after resume starts are cleaned up.
1382
+ //
1383
+ // Per-repo target branch resolution (workspace-mode correctness):
1384
+ // In repo mode, orchBranch is the correct target for all worktrees.
1385
+ // In workspace mode, the orchBranch only exists in the primary repo.
1386
+ // Secondary repos were merged against their own resolved base branch
1387
+ // (via resolveBaseBranch in mergeWaveByRepo), so unmerged-branch
1388
+ // protection must compare against that same per-repo branch.
1322
1389
  for (const perRepoRoot of encounteredRepoRoots) {
1323
- removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch);
1390
+ let targetBranch: string | undefined;
1391
+ if (perRepoRoot === repoRoot) {
1392
+ // Primary repo: lane branches were merged into orchBranch
1393
+ targetBranch = batchState.orchBranch;
1394
+ } else {
1395
+ // Secondary repo (workspace mode): resolve the repo's own branch
1396
+ // using the same logic as mergeWaveByRepo. Find repoId by matching
1397
+ // the resolved path back to workspace config.
1398
+ const repoId = resolveRepoIdFromRoot(perRepoRoot, workspaceConfig);
1399
+ try {
1400
+ targetBranch = resolveBaseBranch(repoId, perRepoRoot, batchState.orchBranch, workspaceConfig);
1401
+ } catch {
1402
+ // resolveBaseBranch may throw if HEAD is detached and no
1403
+ // defaultBranch is configured. Fall back to undefined which
1404
+ // skips branch protection (branches are deleted without
1405
+ // merge-status check — safe because successfully merged
1406
+ // branches were already cleaned up in post-merge steps).
1407
+ targetBranch = undefined;
1408
+ }
1409
+ }
1410
+ removeAllWorktrees(wtPrefix, perRepoRoot, cleanupOpId, targetBranch, batchState.batchId, orchConfig);
1324
1411
  }
1325
1412
  }
1326
1413
 
@@ -1335,6 +1422,32 @@ export async function resumeOrchBatch(
1335
1422
  }
1336
1423
  }
1337
1424
 
1425
+ // ── Auto-Integration & Orch Branch Preservation (TP-022 Step 4) ──
1426
+ // Parity with engine.ts: auto-integrate if configured, else show manual guidance.
1427
+ // Gate: only run for terminal phases (completed/failed). Paused/stopped batches
1428
+ // are not yet done — integration would mutate refs prematurely.
1429
+ let autoIntegrated = false;
1430
+ const mergedTaskCount = batchState.succeededTasks;
1431
+ const isTerminalPhase = batchState.phase === "completed" || batchState.phase === "failed";
1432
+ if (isTerminalPhase && !preserveWorktreesForResume && batchState.orchBranch && mergedTaskCount > 0) {
1433
+ if (orchConfig.orchestrator.integration === "auto") {
1434
+ autoIntegrated = attemptAutoIntegration(
1435
+ batchState.orchBranch,
1436
+ batchState.baseBranch,
1437
+ repoRoot,
1438
+ batchState.batchId,
1439
+ "resume",
1440
+ onNotify,
1441
+ );
1442
+ }
1443
+ if (!autoIntegrated) {
1444
+ onNotify(
1445
+ ORCH_MESSAGES.orchIntegrationManual(batchState.orchBranch, batchState.baseBranch, mergedTaskCount),
1446
+ "info",
1447
+ );
1448
+ }
1449
+ }
1450
+
1338
1451
  persistRuntimeState("batch-terminal", batchState, wavePlan, latestAllocatedLanes, allTaskOutcomes, discovery, stateRoot);
1339
1452
 
1340
1453
  if (batchState.phase === "paused" || batchState.phase === "stopped") {
@@ -1363,3 +1476,7 @@ export async function resumeOrchBatch(
1363
1476
  }
1364
1477
  }
1365
1478
 
1479
+
1480
+ // attemptAutoIntegration is now a shared helper in merge.ts (TP-022 Step 4).
1481
+ // Both engine.ts and resume.ts import it from there to eliminate parity drift.
1482
+
@@ -588,7 +588,20 @@ export function resolveBaseBranch(
588
588
  }
589
589
  }
590
590
 
591
- // Step 3: Ultimate fallback — batch-level base branch
591
+ // Step 3: Ultimate fallback — batch-level base branch.
592
+ // In workspace mode the batch base branch is the orch branch (e.g.
593
+ // "orch/op-batch123"), which only exists in the primary repo. Using it
594
+ // for a secondary repo would cause worktree creation failure because the
595
+ // ref doesn't exist there. Fail fast with an actionable message instead.
596
+ if (repoId && batchBaseBranch.startsWith("orch/")) {
597
+ throw new Error(
598
+ `Cannot resolve base branch for repo "${repoId}" at ${repoRoot}: ` +
599
+ `HEAD is detached and no defaultBranch is configured. ` +
600
+ `The batch base branch "${batchBaseBranch}" is an orch branch that does not exist in this repo. ` +
601
+ `Configure a defaultBranch for this repo in task-orchestrator.yaml workspace settings.`,
602
+ );
603
+ }
604
+
592
605
  return batchBaseBranch;
593
606
  }
594
607
 
@@ -1070,10 +1083,11 @@ export function allocateLanes(
1070
1083
  // This should never happen if ensureLaneWorktrees and assignTasksToLanes
1071
1084
  // agree on lane numbers, but handle defensively.
1072
1085
  // Roll back all worktrees across all repos on this unexpected failure.
1086
+ // Pass batchId + config for batch-scoped cleanup (only remove this batch's worktrees).
1073
1087
  for (const groupKey of createdGroupKeys) {
1074
1088
  const groupRepoId = repoIdForGroup.get(groupKey);
1075
1089
  const groupRepoRoot = resolveRepoRoot(groupRepoId, repoRoot, workspaceConfig);
1076
- removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId);
1090
+ removeAllWorktrees(config.orchestrator.worktree_prefix, groupRepoRoot, opId, undefined, batchId, config);
1077
1091
  }
1078
1092
  return {
1079
1093
  success: false,
@@ -2,7 +2,7 @@
2
2
  * Worktree CRUD, bulk ops, branch protection, preflight
3
3
  * @module orch/worktree
4
4
  */
5
- import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "fs";
5
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmdirSync, rmSync } from "fs";
6
6
  import { execSync } from "child_process";
7
7
  import { join, basename, resolve } from "path";
8
8
 
@@ -202,7 +202,7 @@ export function removeBatchContainerIfEmpty(containerPath: string): boolean {
202
202
  if (entries.length > 0) {
203
203
  return false; // Non-empty — do not remove (partial failure safety)
204
204
  }
205
- rmSync(containerPath, { recursive: false });
205
+ rmdirSync(containerPath);
206
206
  return true;
207
207
  } catch {
208
208
  // If we can't read or remove — leave it alone (safe default)
@@ -1390,7 +1390,7 @@ export function ensureLaneWorktrees(
1390
1390
  const prefix = config.orchestrator.worktree_prefix;
1391
1391
  const opId = resolveOperatorId(config);
1392
1392
 
1393
- const existing = listWorktrees(prefix, repoRoot, opId);
1393
+ const existing = listWorktrees(prefix, repoRoot, opId, batchId);
1394
1394
  const existingByLane = new Map<number, WorktreeInfo>();
1395
1395
  for (const wt of existing) {
1396
1396
  existingByLane.set(wt.laneNumber, wt);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.4.3",
3
+ "version": "0.5.0",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",