taskplane 0.4.2 → 0.4.3

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.
@@ -726,6 +726,7 @@ export function toOrchestratorConfig(config: TaskplaneConfig): import("./types.t
726
726
  spawn_mode: o.orchestrator.spawnMode,
727
727
  tmux_prefix: o.orchestrator.tmuxPrefix,
728
728
  operator_id: o.orchestrator.operatorId,
729
+ integration: o.orchestrator.integration,
729
730
  },
730
731
  dependencies: {
731
732
  source: o.dependencies.source,
@@ -216,6 +216,8 @@ export interface OrchestratorCoreConfig {
216
216
  tmuxPrefix: string;
217
217
  /** Operator identifier. Auto-detected from OS username if empty */
218
218
  operatorId: string;
219
+ /** How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion. */
220
+ integration: "manual" | "auto";
219
221
  }
220
222
 
221
223
  /** Dependency resolution settings */
@@ -427,6 +429,7 @@ export const DEFAULT_ORCHESTRATOR_SECTION: OrchestratorSection = {
427
429
  spawnMode: "subprocess",
428
430
  tmuxPrefix: "orch",
429
431
  operatorId: "",
432
+ integration: "manual",
430
433
  },
431
434
  dependencies: {
432
435
  source: "prompt",
@@ -366,6 +366,18 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
366
366
  );
367
367
  }
368
368
 
369
+ // ── Optional string fields: orchBranch ───────────────────────
370
+ // orchBranch was added after schema v2 shipped; default to "" if missing.
371
+ if (obj.orchBranch !== undefined && typeof obj.orchBranch !== "string") {
372
+ throw new StateFileError(
373
+ "STATE_SCHEMA_INVALID",
374
+ `Invalid "orchBranch" field (expected string, got ${typeof obj.orchBranch})`,
375
+ );
376
+ }
377
+ if (obj.orchBranch === undefined) {
378
+ obj.orchBranch = "";
379
+ }
380
+
369
381
  // ── v2: mode field ───────────────────────────────────────────
370
382
  // mode is required in v2, absent in v1 (defaults to "repo" via upconvert).
371
383
  if (!isV1 && obj.mode === undefined) {
@@ -776,6 +788,7 @@ export function serializeBatchState(
776
788
  phase: state.phase,
777
789
  batchId: state.batchId,
778
790
  baseBranch: state.baseBranch,
791
+ orchBranch: state.orchBranch ?? "",
779
792
  mode: state.mode ?? "repo",
780
793
  startedAt: state.startedAt,
781
794
  updatedAt: now,
@@ -612,6 +612,7 @@ export async function resumeOrchBatch(
612
612
  batchState.phase = "executing";
613
613
  batchState.batchId = persistedState.batchId;
614
614
  batchState.baseBranch = persistedState.baseBranch || "";
615
+ batchState.orchBranch = persistedState.orchBranch || "";
615
616
  batchState.mode = persistedState.mode;
616
617
  batchState.startedAt = persistedState.startedAt;
617
618
  batchState.pauseSignal = { paused: false };
@@ -98,9 +98,11 @@ export const SECTIONS: SectionDef[] = [
98
98
  { configPath: "orchestrator.orchestrator.worktreeLocation", label: "Worktree Location", control: "toggle", layer: "L1", fieldType: "enum", values: ["sibling", "subdirectory"], description: "Where lane worktree directories are created" },
99
99
  { configPath: "orchestrator.orchestrator.worktreePrefix", label: "Worktree Prefix", control: "input", layer: "L1", fieldType: "string", description: "Prefix for worktree directory names" },
100
100
  { configPath: "orchestrator.orchestrator.batchIdFormat", label: "Batch ID Format", control: "toggle", layer: "L1", fieldType: "enum", values: ["timestamp", "sequential"], description: "Batch ID format for logs/branch naming" },
101
- { configPath: "orchestrator.orchestrator.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1+L2", fieldType: "enum", values: ["tmux", "subprocess"], prefsKey: "spawnMode", description: "How lane sessions are spawned" },
101
+ // spawn_mode removed from Orchestrator section /orch always requires tmux.
102
+ // The user-facing spawn mode setting is under Worker (controls /task behavior).
102
103
  { configPath: "orchestrator.orchestrator.tmuxPrefix", label: "Tmux Prefix", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "tmuxPrefix", description: "Prefix for orchestrator tmux sessions" },
103
104
  { configPath: "orchestrator.orchestrator.operatorId", label: "Operator ID", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "operatorId", description: "Operator identifier (empty = auto-detect)" },
105
+ { configPath: "orchestrator.orchestrator.integration", label: "Integration", control: "toggle", layer: "L1", fieldType: "enum", values: ["manual", "auto"], description: "How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion." },
104
106
  ],
105
107
  },
106
108
  {
@@ -153,7 +155,7 @@ export const SECTIONS: SectionDef[] = [
153
155
  { configPath: "taskRunner.worker.model", label: "Worker Model", control: "input", layer: "L1+L2", fieldType: "string", prefsKey: "workerModel", description: "Worker model (empty = inherit session)" },
154
156
  { configPath: "taskRunner.worker.tools", label: "Worker Tools", control: "input", layer: "L1", fieldType: "string", description: "Worker tool allowlist" },
155
157
  { configPath: "taskRunner.worker.thinking", label: "Worker Thinking", control: "input", layer: "L1", fieldType: "string", description: "Worker thinking mode" },
156
- { configPath: "taskRunner.worker.spawnMode", label: "Worker Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["(inherit)", "subprocess", "tmux"], optional: true, description: "Worker spawn mode override (inherit = use orchestrator)" },
158
+ { configPath: "taskRunner.worker.spawnMode", label: "Spawn Mode", control: "toggle", layer: "L1", fieldType: "enum", values: ["subprocess", "tmux"], description: "How /task spawns workers and reviewers. subprocess = child process (simpler), tmux = named sessions (attachable for debugging)" },
157
159
  ],
158
160
  },
159
161
  {
@@ -17,6 +17,8 @@ export interface OrchestratorConfig {
17
17
  tmux_prefix: string;
18
18
  /** Optional operator identifier. Auto-detected from OS username if empty. */
19
19
  operator_id: string;
20
+ /** How completed batches are integrated. manual = user runs /orch-integrate. auto = fast-forward on completion. */
21
+ integration: "manual" | "auto";
20
22
  };
21
23
  dependencies: {
22
24
  source: "prompt" | "agent";
@@ -151,6 +153,7 @@ export const DEFAULT_ORCHESTRATOR_CONFIG: OrchestratorConfig = {
151
153
  spawn_mode: "subprocess",
152
154
  tmux_prefix: "orch",
153
155
  operator_id: "",
156
+ integration: "manual",
154
157
  },
155
158
  dependencies: {
156
159
  source: "prompt",
@@ -829,6 +832,8 @@ export interface OrchBatchRuntimeState {
829
832
  batchId: string;
830
833
  /** Branch that was active when /orch started — used as base for worktrees and merge target */
831
834
  baseBranch: string;
835
+ /** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
836
+ orchBranch: string;
832
837
  /** Workspace execution mode (v2). Defaults to "repo" for backward compatibility. */
833
838
  mode: WorkspaceMode;
834
839
  /** Shared pause signal — set by /orch-pause, read by executeLane/executeWave */
@@ -908,6 +913,7 @@ export function freshOrchBatchState(): OrchBatchRuntimeState {
908
913
  phase: "idle",
909
914
  batchId: "",
910
915
  baseBranch: "",
916
+ orchBranch: "",
911
917
  mode: "repo",
912
918
  pauseSignal: { paused: false },
913
919
  waveResults: [],
@@ -1367,6 +1373,8 @@ export interface PersistedBatchState {
1367
1373
  batchId: string;
1368
1374
  /** Branch that was active when /orch started — used as base for worktrees and merge target */
1369
1375
  baseBranch: string;
1376
+ /** Orchestrator-managed branch name (e.g., 'orch/henry-20260318T140000'). Empty = legacy mode (merge into baseBranch directly). */
1377
+ orchBranch: string;
1370
1378
  /**
1371
1379
  * Workspace execution mode at batch start (v2).
1372
1380
  * - "repo": Single-repo mode (default, backward-compatible).
@@ -2,7 +2,7 @@
2
2
  * Worktree CRUD, bulk ops, branch protection, preflight
3
3
  * @module orch/worktree
4
4
  */
5
- import { existsSync, readdirSync, realpathSync, rmSync } from "fs";
5
+ import { existsSync, mkdirSync, readdirSync, realpathSync, rmSync } from "fs";
6
6
  import { execSync } from "child_process";
7
7
  import { join, basename, resolve } from "path";
8
8
 
@@ -54,20 +54,71 @@ export function resolveWorktreeBasePath(
54
54
  return resolve(repoRoot, ".worktrees");
55
55
  }
56
56
 
57
+ /**
58
+ * Generate the batch container directory name.
59
+ *
60
+ * Format: `{opId}-{batchId}`
61
+ * Example: `henrylach-20260308T111750`
62
+ *
63
+ * This is the directory that holds all lane worktrees and the merge
64
+ * worktree for a single batch.
65
+ *
66
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
67
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
68
+ */
69
+ export function generateBatchContainerName(opId: string, batchId: string): string {
70
+ return `${opId}-${batchId}`;
71
+ }
72
+
73
+ /**
74
+ * Generate the absolute path to the batch container directory.
75
+ *
76
+ * All worktrees for a single batch (lanes + merge) live inside this container.
77
+ * Format: `{basePath}/{opId}-{batchId}`
78
+ *
79
+ * Uses `resolveWorktreeBasePath()` to respect `worktree_location` config
80
+ * (sibling vs subdirectory mode). Both `generateWorktreePath()` and
81
+ * `generateMergeWorktreePath()` delegate to this function, ensuring
82
+ * consistent base-path resolution.
83
+ *
84
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
85
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
86
+ * @param repoRoot - Absolute path to the main repository root
87
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
88
+ * @returns - Absolute path to the batch container directory
89
+ */
90
+ export function generateBatchContainerPath(
91
+ opId: string,
92
+ batchId: string,
93
+ repoRoot: string,
94
+ config?: OrchestratorConfig,
95
+ ): string {
96
+ const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
97
+ const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
98
+ return resolve(basePath, generateBatchContainerName(opId, batchId));
99
+ }
100
+
57
101
  /**
58
102
  * Generate worktree path based on config's worktree_location setting.
59
103
  *
60
- * Naming rule: basename = {prefix}-{opId}-{N}
61
- * Sibling mode: ../{prefix}-{opId}-{N} (e.g. ../taskplane-wt-henrylach-1)
62
- * Subdirectory mode: .worktrees/{prefix}-{opId}-{N} (e.g. .worktrees/taskplane-wt-henrylach-1)
104
+ * Naming rule: `{basePath}/{opId}-{batchId}/lane-{N}`
105
+ * Sibling mode: ../{opId}-{batchId}/lane-{N}
106
+ * Subdirectory mode: .worktrees/{opId}-{batchId}/lane-{N}
107
+ *
108
+ * Each batch gets its own container directory, preventing collisions
109
+ * between concurrent batches by the same operator.
110
+ *
111
+ * Uses `generateBatchContainerPath()` for the container directory,
112
+ * preserving `worktree_location` semantics (sibling vs subdirectory).
63
113
  *
64
114
  * Uses path.resolve() for Windows path normalization (R002 requirement).
65
115
  *
66
- * @param prefix - Directory prefix (e.g. "taskplane-wt")
116
+ * @param prefix - Directory prefix (unused in new scheme, kept for API compat)
67
117
  * @param laneNumber - Lane number (1-indexed)
68
118
  * @param repoRoot - Absolute path to the main repository root
69
119
  * @param opId - Operator identifier (sanitized, e.g., "henrylach")
70
120
  * @param config - Orchestrator config (optional; defaults to subdirectory mode)
121
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
71
122
  */
72
123
  export function generateWorktreePath(
73
124
  prefix: string,
@@ -75,12 +126,90 @@ export function generateWorktreePath(
75
126
  repoRoot: string,
76
127
  opId: string,
77
128
  config?: OrchestratorConfig,
129
+ batchId?: string,
78
130
  ): string {
131
+ if (batchId) {
132
+ // New batch-scoped container layout
133
+ const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
134
+ return resolve(containerPath, `lane-${laneNumber}`);
135
+ }
136
+
137
+ // Legacy fallback (no batchId) — flat layout for backward compatibility
79
138
  const effectiveConfig = config || DEFAULT_ORCHESTRATOR_CONFIG;
80
139
  const basePath = resolveWorktreeBasePath(repoRoot, effectiveConfig);
81
140
  return resolve(basePath, `${prefix}-${opId}-${laneNumber}`);
82
141
  }
83
142
 
143
+ /**
144
+ * Generate the merge worktree path inside a batch container.
145
+ *
146
+ * Format: `{basePath}/{opId}-{batchId}/merge`
147
+ *
148
+ * Uses `generateBatchContainerPath()` for config-aware, base-path-consistent
149
+ * path resolution (respects `worktree_location` setting). This ensures
150
+ * the merge worktree is co-located with lane worktrees in the same
151
+ * batch container for unified cleanup.
152
+ *
153
+ * @param repoRoot - Absolute path to the main repository root
154
+ * @param opId - Operator identifier (sanitized, e.g., "henrylach")
155
+ * @param batchId - Batch ID timestamp (e.g. "20260308T111750")
156
+ * @param config - Orchestrator config (optional; defaults to subdirectory mode)
157
+ */
158
+ export function generateMergeWorktreePath(
159
+ repoRoot: string,
160
+ opId: string,
161
+ batchId: string,
162
+ config?: OrchestratorConfig,
163
+ ): string {
164
+ const containerPath = generateBatchContainerPath(opId, batchId, repoRoot, config);
165
+ return resolve(containerPath, "merge");
166
+ }
167
+
168
+ /**
169
+ * Ensure the batch container directory exists, creating it if necessary.
170
+ *
171
+ * @param containerPath - Absolute path to the container directory
172
+ */
173
+ export function ensureBatchContainerDir(containerPath: string): void {
174
+ if (!existsSync(containerPath)) {
175
+ mkdirSync(containerPath, { recursive: true });
176
+ }
177
+ }
178
+
179
+ /**
180
+ * Remove a batch container directory if it exists and is empty.
181
+ *
182
+ * Safety rules:
183
+ * - Only removes the directory if it exists
184
+ * - Only removes the directory if it is empty (no files or subdirectories)
185
+ * - Never force-removes a non-empty container (partial failure safety)
186
+ * - Returns whether the container was removed
187
+ *
188
+ * Used after per-worktree removals in `removeAllWorktrees()` and
189
+ * `forceCleanupWorktree()` to clean up the container directory when
190
+ * all worktrees inside it have been removed.
191
+ *
192
+ * @param containerPath - Absolute path to the batch container directory
193
+ * @returns true if the container was removed, false otherwise
194
+ */
195
+ export function removeBatchContainerIfEmpty(containerPath: string): boolean {
196
+ if (!existsSync(containerPath)) {
197
+ return false; // Already gone — no-op
198
+ }
199
+
200
+ try {
201
+ const entries = readdirSync(containerPath);
202
+ if (entries.length > 0) {
203
+ return false; // Non-empty — do not remove (partial failure safety)
204
+ }
205
+ rmSync(containerPath, { recursive: false });
206
+ return true;
207
+ } catch {
208
+ // If we can't read or remove — leave it alone (safe default)
209
+ return false;
210
+ }
211
+ }
212
+
84
213
  /**
85
214
  * Parse `git worktree list --porcelain` output into structured entries.
86
215
  *
@@ -205,7 +334,7 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W
205
334
  const { laneNumber, batchId, baseBranch, prefix, opId, config } = opts;
206
335
 
207
336
  const branch = generateBranchName(laneNumber, batchId, opId);
208
- const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config);
337
+ const worktreePath = generateWorktreePath(prefix, laneNumber, repoRoot, opId, config, batchId);
209
338
 
210
339
  // ── Pre-check 1: Validate base branch exists ─────────────────
211
340
  const baseBranchCheck = runGit(
@@ -265,6 +394,12 @@ export function createWorktree(opts: CreateWorktreeOptions, repoRoot: string): W
265
394
  );
266
395
  }
267
396
 
397
+ // ── Ensure batch container directory exists ──────────────────
398
+ // Placed after pre-checks so no empty container is left behind on
399
+ // validation failure (R004 review feedback).
400
+ const containerDir = resolve(worktreePath, "..");
401
+ ensureBatchContainerDir(containerDir);
402
+
268
403
  // ── Create worktree ──────────────────────────────────────────
269
404
  const createResult = runGit(
270
405
  ["worktree", "add", "-b", branch, worktreePath, baseBranch],
@@ -1041,13 +1176,16 @@ export function preserveBranch(
1041
1176
  * Parses `git worktree list --porcelain` via parseWorktreeList() and filters
1042
1177
  * entries whose path basename matches `{prefix}-{opId}-{N}` (where N is a number).
1043
1178
  *
1044
- * Operator-scoped discovery: only returns worktrees belonging to the specified
1045
- * operator. This prevents one operator from accidentally reusing or removing
1046
- * another operator's active worktrees in concurrent team-scale scenarios.
1179
+ * **Batch-scoped discovery:** When `batchId` is provided, only returns worktrees
1180
+ * inside the specific batch container `{opId}-{batchId}/lane-{N}`. This prevents
1181
+ * cross-batch interference when the same operator runs concurrent batches.
1182
+ *
1183
+ * **Operator-scoped discovery:** When `batchId` is omitted, returns ALL worktrees
1184
+ * belonging to the operator (across all batches). This supports cleanup scenarios
1185
+ * that need to discover all operator worktrees regardless of batch.
1047
1186
  *
1048
- * For backward compatibility, also matches the legacy pattern `{prefix}-{N}`
1049
- * (worktrees from prior batches without operator IDs), but only when `opId`
1050
- * is `"op"` (the default fallback), to avoid capturing other operators' resources.
1187
+ * For backward compatibility, also matches the legacy flat pattern `{prefix}-{opId}-{N}`
1188
+ * and (when opId is "op") `{prefix}-{N}`. This supports transition from old naming.
1051
1189
  *
1052
1190
  * Lane number is extracted from the path basename pattern. Entries with
1053
1191
  * malformed/partial data (missing path, unparseable lane number) are
@@ -1056,12 +1194,15 @@ export function preserveBranch(
1056
1194
  * @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
1057
1195
  * @param repoRoot - Absolute path to the main repository root
1058
1196
  * @param opId - Operator identifier for scoping (e.g., "henrylach")
1197
+ * @param batchId - Optional batch ID for batch-scoped filtering; when provided,
1198
+ * only returns worktrees inside the `{opId}-{batchId}/` container
1059
1199
  * @returns - WorktreeInfo[] sorted by laneNumber (ascending)
1060
1200
  */
1061
- export function listWorktrees(prefix: string, repoRoot: string, opId: string): WorktreeInfo[] {
1201
+ export function listWorktrees(prefix: string, repoRoot: string, opId: string, batchId?: string): WorktreeInfo[] {
1062
1202
  const entries = parseWorktreeList(repoRoot);
1063
1203
  const results: WorktreeInfo[] = [];
1064
1204
 
1205
+ // ── Legacy flat patterns ─────────────────────────────────────
1065
1206
  // Primary pattern: {prefix}-{opId}-{N}
1066
1207
  // Example: "taskplane-wt-henrylach-1"
1067
1208
  const primaryPattern = new RegExp(`^${escapeRegex(prefix)}-${escapeRegex(opId)}-(\\d+)$`);
@@ -1072,27 +1213,60 @@ export function listWorktrees(prefix: string, repoRoot: string, opId: string): W
1072
1213
  ? new RegExp(`^${escapeRegex(prefix)}-(\\d+)$`)
1073
1214
  : null;
1074
1215
 
1216
+ // ── New batch-scoped nested pattern ──────────────────────────
1217
+ // Basename: lane-{N}
1218
+ // Parent directory: {opId}-{batchId} (e.g., "henrylach-20260308T111750")
1219
+ // Full: {basePath}/{opId}-{batchId}/lane-{N}
1220
+ const nestedLanePattern = /^lane-(\d+)$/;
1221
+ // When batchId is provided, match only the exact container for batch isolation.
1222
+ // When omitted, match any container belonging to this operator (all batches).
1223
+ const containerPattern = batchId
1224
+ ? new RegExp(`^${escapeRegex(generateBatchContainerName(opId, batchId))}$`)
1225
+ : new RegExp(`^${escapeRegex(opId)}-\\S+$`);
1226
+
1075
1227
  for (const entry of entries) {
1076
1228
  if (!entry.path) continue;
1077
1229
 
1078
- // Extract basename from the worktree path
1079
- const entryBasename = basename(resolve(entry.path));
1080
-
1081
- // Try primary pattern first
1082
- let match = entryBasename.match(primaryPattern);
1083
- if (!match && legacyPattern) {
1084
- match = entryBasename.match(legacyPattern);
1230
+ const resolvedPath = resolve(entry.path);
1231
+ const entryBasename = basename(resolvedPath);
1232
+
1233
+ // ── Try new nested pattern first ─────────────────────────
1234
+ const nestedMatch = entryBasename.match(nestedLanePattern);
1235
+ if (nestedMatch) {
1236
+ // Verify the parent directory matches the container pattern
1237
+ const parentDir = basename(resolve(resolvedPath, ".."));
1238
+ if (containerPattern.test(parentDir)) {
1239
+ const laneNumber = parseInt(nestedMatch[1], 10);
1240
+ if (!isNaN(laneNumber) && laneNumber >= 1) {
1241
+ results.push({
1242
+ path: resolvedPath,
1243
+ branch: entry.branch || "",
1244
+ laneNumber,
1245
+ });
1246
+ continue;
1247
+ }
1248
+ }
1085
1249
  }
1086
- if (!match) continue;
1087
1250
 
1088
- const laneNumber = parseInt(match[1], 10);
1089
- if (isNaN(laneNumber) || laneNumber < 1) continue;
1090
-
1091
- results.push({
1092
- path: resolve(entry.path),
1093
- branch: entry.branch || "",
1094
- laneNumber,
1095
- });
1251
+ // ── Try legacy flat patterns (only when not batch-scoped)
1252
+ // When batchId is provided, skip legacy matching — the caller
1253
+ // explicitly wants only this batch's worktrees.
1254
+ if (!batchId) {
1255
+ let match = entryBasename.match(primaryPattern);
1256
+ if (!match && legacyPattern) {
1257
+ match = entryBasename.match(legacyPattern);
1258
+ }
1259
+ if (match) {
1260
+ const laneNumber = parseInt(match[1], 10);
1261
+ if (!isNaN(laneNumber) && laneNumber >= 1) {
1262
+ results.push({
1263
+ path: resolvedPath,
1264
+ branch: entry.branch || "",
1265
+ laneNumber,
1266
+ });
1267
+ }
1268
+ }
1269
+ }
1096
1270
  }
1097
1271
 
1098
1272
  // Sort by laneNumber ascending (deterministic output)
@@ -1305,10 +1479,22 @@ export function ensureLaneWorktrees(
1305
1479
  * When `targetBranch` is provided, branches with unmerged commits are
1306
1480
  * preserved as `saved/<branch>` refs instead of being force-deleted.
1307
1481
  *
1482
+ * **Batch-scoped cleanup:** When `batchId` is provided, only removes
1483
+ * worktrees inside the specific batch container `{opId}-{batchId}/`.
1484
+ * After removing all worktrees, attempts to remove the empty container
1485
+ * directory. When `batchId` is omitted, removes all operator worktrees
1486
+ * (all batches, including legacy flat-layout).
1487
+ *
1488
+ * **Container cleanup:** After per-worktree removals, each touched batch
1489
+ * container directory is checked and removed if empty. Non-empty containers
1490
+ * (from partial failures or active worktrees) are left intact.
1491
+ *
1308
1492
  * @param prefix - Worktree directory prefix (e.g. "taskplane-wt")
1309
1493
  * @param repoRoot - Absolute path to the main repository root
1310
1494
  * @param opId - Operator identifier for scoping (e.g., "henrylach")
1311
1495
  * @param targetBranch - Optional target branch for unmerged commit detection (e.g. "develop")
1496
+ * @param batchId - Optional batch ID for batch-scoped cleanup
1497
+ * @param config - Optional orchestrator config (needed for container path resolution when batchId is provided)
1312
1498
  * @returns - RemoveAllWorktreesResult with per-worktree outcomes
1313
1499
  */
1314
1500
  export function removeAllWorktrees(
@@ -1316,8 +1502,10 @@ export function removeAllWorktrees(
1316
1502
  repoRoot: string,
1317
1503
  opId: string,
1318
1504
  targetBranch?: string,
1505
+ batchId?: string,
1506
+ config?: OrchestratorConfig,
1319
1507
  ): RemoveAllWorktreesResult {
1320
- const worktrees = listWorktrees(prefix, repoRoot, opId);
1508
+ const worktrees = listWorktrees(prefix, repoRoot, opId, batchId);
1321
1509
  const outcomes: RemoveWorktreeOutcome[] = [];
1322
1510
  const removed: WorktreeInfo[] = [];
1323
1511
  const failed: RemoveWorktreeOutcome[] = [];
@@ -1360,6 +1548,30 @@ export function removeAllWorktrees(
1360
1548
  }
1361
1549
  }
1362
1550
 
1551
+ // ── Container cleanup ────────────────────────────────────────
1552
+ // After removing worktrees, attempt to remove empty batch container
1553
+ // directories. Collect unique container paths from removed worktrees,
1554
+ // then remove each one only if empty (partial failure safety).
1555
+ const containerPaths = new Set<string>();
1556
+ for (const wt of removed) {
1557
+ const parentDir = resolve(wt.path, "..");
1558
+ // Only consider directories that look like batch containers
1559
+ // (i.e., parent is not the base worktree path itself)
1560
+ const parentName = basename(parentDir);
1561
+ if (parentName.startsWith(`${opId}-`)) {
1562
+ containerPaths.add(parentDir);
1563
+ }
1564
+ }
1565
+ // When batchId is explicitly provided, also add the expected container path
1566
+ // even if no worktrees were found (cleanup of empty containers from prior runs)
1567
+ if (batchId && config) {
1568
+ const expectedContainer = generateBatchContainerPath(opId, batchId, repoRoot, config);
1569
+ containerPaths.add(expectedContainer);
1570
+ }
1571
+ for (const containerPath of containerPaths) {
1572
+ removeBatchContainerIfEmpty(containerPath);
1573
+ }
1574
+
1363
1575
  return {
1364
1576
  totalAttempted: worktrees.length,
1365
1577
  removed,
@@ -1754,5 +1966,19 @@ export function forceCleanupWorktree(
1754
1966
  });
1755
1967
  }
1756
1968
  }
1969
+
1970
+ // Step 4: Attempt to remove the batch container directory if empty
1971
+ // The worktree path is {basePath}/{opId}-{batchId}/lane-{N}, so the
1972
+ // container is the parent directory.
1973
+ const containerDir = resolve(worktreePath, "..");
1974
+ const containerName = basename(containerDir);
1975
+ // Only attempt container cleanup if the parent looks like a batch container
1976
+ // (contains a hyphen, indicating {opId}-{batchId} naming)
1977
+ if (containerName.includes("-")) {
1978
+ const containerRemoved = removeBatchContainerIfEmpty(containerDir);
1979
+ if (containerRemoved) {
1980
+ execLog("cleanup", `lane-${laneNumber}`, `removed empty batch container`, { path: containerDir });
1981
+ }
1982
+ }
1757
1983
  }
1758
1984
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",