taskplane 0.1.14 → 0.1.16

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.
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Workspace configuration loading and validation.
3
+ *
4
+ * Detects workspace mode by checking for `.pi/taskplane-workspace.yaml`.
5
+ * When the file is absent, the orchestrator runs in repo mode (default).
6
+ * When the file is present, it must be valid — invalid files are fatal.
7
+ *
8
+ * Validation order (deterministic, fail-fast):
9
+ * 1. File existence check → absent = repo mode (return null)
10
+ * 2. File read → WORKSPACE_FILE_READ_ERROR
11
+ * 3. YAML parse → WORKSPACE_FILE_PARSE_ERROR
12
+ * 4. Top-level schema → WORKSPACE_SCHEMA_INVALID
13
+ * 5. repos map non-empty → WORKSPACE_MISSING_REPOS
14
+ * 6. Per-repo validation (sorted key order):
15
+ * a. path present → WORKSPACE_REPO_PATH_MISSING
16
+ * b. path exists on disk → WORKSPACE_REPO_PATH_NOT_FOUND
17
+ * c. path is git repo → WORKSPACE_REPO_NOT_GIT
18
+ * 7. Duplicate repo paths → WORKSPACE_DUPLICATE_REPO_PATH
19
+ * 8. routing.tasks_root present → WORKSPACE_MISSING_TASKS_ROOT
20
+ * 9. routing.tasks_root exists → WORKSPACE_TASKS_ROOT_NOT_FOUND
21
+ * 10. routing.default_repo present → WORKSPACE_MISSING_DEFAULT_REPO
22
+ * 11. routing.default_repo valid → WORKSPACE_DEFAULT_REPO_NOT_FOUND
23
+ *
24
+ * Path normalization rules:
25
+ * - Relative paths are resolved against workspaceRoot.
26
+ * - Existing paths are canonicalized via `fs.realpathSync.native()` to
27
+ * expand Windows 8.3 short names and resolve symlinks.
28
+ * - All paths are forward-slash normalized and lowercased for comparison.
29
+ * - This matches the precedent in `worktree.ts:normalizePath()`.
30
+ *
31
+ * Git repo validation:
32
+ * - Uses `git rev-parse --git-dir` run inside the repo path.
33
+ * - The path must be the repo root (not a subdirectory).
34
+ * We verify by checking that `git rev-parse --show-toplevel` matches
35
+ * the canonicalized path.
36
+ *
37
+ * @module orch/workspace
38
+ */
39
+ import { readFileSync, existsSync, realpathSync } from "fs";
40
+ import { resolve } from "path";
41
+ import { parse as yamlParse } from "yaml";
42
+
43
+ import { runGit } from "./git.ts";
44
+ import {
45
+ WorkspaceConfigError,
46
+ workspaceConfigPath,
47
+ type WorkspaceConfig,
48
+ type WorkspaceRepoConfig,
49
+ type WorkspaceRoutingConfig,
50
+ } from "./types.ts";
51
+
52
+
53
+ // ── Path Canonicalization ────────────────────────────────────────────
54
+
55
+ /**
56
+ * Canonicalize a filesystem path for comparison and storage.
57
+ *
58
+ * Reuses the normalization pattern from `worktree.ts:normalizePath()`:
59
+ * - `realpathSync.native()` expands Windows 8.3 short names when the path exists.
60
+ * - Falls back to `resolve()` for non-existent paths.
61
+ * - Forward-slash normalized and lowercased for platform-safe comparison.
62
+ *
63
+ * @param p - Path to canonicalize (absolute or relative)
64
+ * @param base - Base directory for resolving relative paths
65
+ * @returns Canonical absolute path (forward-slash, lowercased)
66
+ */
67
+ export function canonicalizePath(p: string, base: string): string {
68
+ const resolved = resolve(base, p);
69
+ let expanded: string;
70
+ try {
71
+ expanded = realpathSync.native(resolved);
72
+ } catch {
73
+ // Path doesn't exist yet — fall back to resolve()
74
+ expanded = resolved;
75
+ }
76
+ return expanded.replace(/\\/g, "/").toLowerCase();
77
+ }
78
+
79
+ /**
80
+ * Canonicalize a path for storage (absolute, native separators, resolved symlinks).
81
+ * Unlike canonicalizePath(), this preserves original case for display/config output.
82
+ *
83
+ * @param p - Path to resolve (absolute or relative)
84
+ * @param base - Base directory for resolving relative paths
85
+ * @returns Absolute resolved path (native separators preserved)
86
+ */
87
+ function resolveAbsolutePath(p: string, base: string): string {
88
+ const resolved = resolve(base, p);
89
+ try {
90
+ return realpathSync.native(resolved);
91
+ } catch {
92
+ return resolved;
93
+ }
94
+ }
95
+
96
+
97
+ // ── Workspace Config Loading ─────────────────────────────────────────
98
+
99
+ /**
100
+ * Load and validate workspace configuration from `.pi/taskplane-workspace.yaml`.
101
+ *
102
+ * Mode determination rules:
103
+ * 1. No config file → return null (repo mode, non-fatal, silent).
104
+ * 2. Config file present + invalid → throw WorkspaceConfigError (fatal).
105
+ * 3. Config file present + valid → return WorkspaceConfig (workspace mode).
106
+ *
107
+ * @param workspaceRoot - Absolute path to the workspace root directory
108
+ * @returns WorkspaceConfig if workspace mode, null if repo mode
109
+ * @throws WorkspaceConfigError when config file is present but invalid
110
+ */
111
+ export function loadWorkspaceConfig(workspaceRoot: string): WorkspaceConfig | null {
112
+ const configFile = workspaceConfigPath(workspaceRoot);
113
+
114
+ // ── 1. File existence check ──────────────────────────────────
115
+ if (!existsSync(configFile)) {
116
+ return null;
117
+ }
118
+
119
+ // ── 2. File read ─────────────────────────────────────────────
120
+ let rawContent: string;
121
+ try {
122
+ rawContent = readFileSync(configFile, "utf-8");
123
+ } catch (err: unknown) {
124
+ const msg = err instanceof Error ? err.message : String(err);
125
+ throw new WorkspaceConfigError(
126
+ "WORKSPACE_FILE_READ_ERROR",
127
+ `Cannot read workspace config file: ${msg}`,
128
+ undefined,
129
+ configFile,
130
+ );
131
+ }
132
+
133
+ // ── 3. YAML parse ────────────────────────────────────────────
134
+ let parsed: unknown;
135
+ try {
136
+ parsed = yamlParse(rawContent);
137
+ } catch (err: unknown) {
138
+ const msg = err instanceof Error ? err.message : String(err);
139
+ throw new WorkspaceConfigError(
140
+ "WORKSPACE_FILE_PARSE_ERROR",
141
+ `Invalid YAML in workspace config: ${msg}`,
142
+ undefined,
143
+ configFile,
144
+ );
145
+ }
146
+
147
+ // ── 4. Top-level schema validation ───────────────────────────
148
+ if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) {
149
+ throw new WorkspaceConfigError(
150
+ "WORKSPACE_SCHEMA_INVALID",
151
+ "Workspace config must be a YAML mapping (object), not a scalar or sequence.",
152
+ undefined,
153
+ configFile,
154
+ );
155
+ }
156
+ const doc = parsed as Record<string, unknown>;
157
+
158
+ if (!doc.repos || typeof doc.repos !== "object" || Array.isArray(doc.repos)) {
159
+ throw new WorkspaceConfigError(
160
+ "WORKSPACE_SCHEMA_INVALID",
161
+ "Workspace config must contain a 'repos' mapping.",
162
+ undefined,
163
+ configFile,
164
+ );
165
+ }
166
+ if (!doc.routing || typeof doc.routing !== "object" || Array.isArray(doc.routing)) {
167
+ throw new WorkspaceConfigError(
168
+ "WORKSPACE_SCHEMA_INVALID",
169
+ "Workspace config must contain a 'routing' mapping.",
170
+ undefined,
171
+ configFile,
172
+ );
173
+ }
174
+
175
+ // ── 5. Repos map non-empty ───────────────────────────────────
176
+ const rawRepos = doc.repos as Record<string, unknown>;
177
+ const repoKeys = Object.keys(rawRepos).sort(); // deterministic order
178
+ if (repoKeys.length === 0) {
179
+ throw new WorkspaceConfigError(
180
+ "WORKSPACE_MISSING_REPOS",
181
+ "Workspace config must define at least one repo under 'repos'.",
182
+ undefined,
183
+ configFile,
184
+ );
185
+ }
186
+
187
+ // ── 6. Per-repo validation ───────────────────────────────────
188
+ const repos = new Map<string, WorkspaceRepoConfig>();
189
+ const normalizedPaths = new Map<string, string>(); // normalized → repoId (for duplicate detection)
190
+
191
+ for (const repoId of repoKeys) {
192
+ const rawRepo = rawRepos[repoId];
193
+ if (rawRepo == null || typeof rawRepo !== "object" || Array.isArray(rawRepo)) {
194
+ throw new WorkspaceConfigError(
195
+ "WORKSPACE_SCHEMA_INVALID",
196
+ `Repo '${repoId}' must be a YAML mapping with at least a 'path' field.`,
197
+ repoId,
198
+ configFile,
199
+ );
200
+ }
201
+ const repoEntry = rawRepo as Record<string, unknown>;
202
+
203
+ // 6a. path present and non-empty
204
+ const rawPath = repoEntry.path;
205
+ if (!rawPath || typeof rawPath !== "string" || rawPath.trim() === "") {
206
+ throw new WorkspaceConfigError(
207
+ "WORKSPACE_REPO_PATH_MISSING",
208
+ `Repo '${repoId}' is missing a 'path' field.`,
209
+ repoId,
210
+ configFile,
211
+ );
212
+ }
213
+
214
+ // 6b. path exists on disk
215
+ const absolutePath = resolveAbsolutePath(rawPath.trim(), workspaceRoot);
216
+ const normalizedPath = canonicalizePath(rawPath.trim(), workspaceRoot);
217
+ if (!existsSync(absolutePath)) {
218
+ throw new WorkspaceConfigError(
219
+ "WORKSPACE_REPO_PATH_NOT_FOUND",
220
+ `Repo '${repoId}' path does not exist: ${absolutePath}`,
221
+ repoId,
222
+ absolutePath,
223
+ );
224
+ }
225
+
226
+ // 6c. path is a git repo root
227
+ const gitDirCheck = runGit(["rev-parse", "--git-dir"], absolutePath);
228
+ if (!gitDirCheck.ok) {
229
+ throw new WorkspaceConfigError(
230
+ "WORKSPACE_REPO_NOT_GIT",
231
+ `Repo '${repoId}' path is not a git repository: ${absolutePath}`,
232
+ repoId,
233
+ absolutePath,
234
+ );
235
+ }
236
+ // Verify we're at the root, not a subdirectory
237
+ const toplevelCheck = runGit(["rev-parse", "--show-toplevel"], absolutePath);
238
+ if (toplevelCheck.ok) {
239
+ const toplevelNormalized = canonicalizePath(toplevelCheck.stdout.trim(), "");
240
+ if (toplevelNormalized !== normalizedPath) {
241
+ throw new WorkspaceConfigError(
242
+ "WORKSPACE_REPO_NOT_GIT",
243
+ `Repo '${repoId}' path is a subdirectory of a git repo, not the repo root. Expected root: ${toplevelCheck.stdout.trim()}, got: ${absolutePath}`,
244
+ repoId,
245
+ absolutePath,
246
+ );
247
+ }
248
+ }
249
+
250
+ // 7. Collect for duplicate detection (checked after loop)
251
+ if (normalizedPaths.has(normalizedPath)) {
252
+ throw new WorkspaceConfigError(
253
+ "WORKSPACE_DUPLICATE_REPO_PATH",
254
+ `Repos '${normalizedPaths.get(normalizedPath)}' and '${repoId}' share the same path: ${absolutePath}`,
255
+ repoId,
256
+ absolutePath,
257
+ );
258
+ }
259
+ normalizedPaths.set(normalizedPath, repoId);
260
+
261
+ // Build repo config
262
+ const defaultBranch = typeof repoEntry.default_branch === "string" && repoEntry.default_branch.trim()
263
+ ? repoEntry.default_branch.trim()
264
+ : undefined;
265
+
266
+ repos.set(repoId, {
267
+ id: repoId,
268
+ path: absolutePath,
269
+ defaultBranch,
270
+ });
271
+ }
272
+
273
+ // ── 8–11. Routing validation ─────────────────────────────────
274
+ const rawRouting = doc.routing as Record<string, unknown>;
275
+
276
+ // 8. routing.tasks_root present
277
+ const rawTasksRoot = rawRouting.tasks_root;
278
+ if (!rawTasksRoot || typeof rawTasksRoot !== "string" || rawTasksRoot.trim() === "") {
279
+ throw new WorkspaceConfigError(
280
+ "WORKSPACE_MISSING_TASKS_ROOT",
281
+ "Workspace config 'routing.tasks_root' is missing or empty.",
282
+ undefined,
283
+ configFile,
284
+ );
285
+ }
286
+
287
+ // 9. routing.tasks_root exists on disk
288
+ const tasksRootAbsolute = resolveAbsolutePath(rawTasksRoot.trim(), workspaceRoot);
289
+ if (!existsSync(tasksRootAbsolute)) {
290
+ throw new WorkspaceConfigError(
291
+ "WORKSPACE_TASKS_ROOT_NOT_FOUND",
292
+ `routing.tasks_root path does not exist: ${tasksRootAbsolute}`,
293
+ undefined,
294
+ tasksRootAbsolute,
295
+ );
296
+ }
297
+
298
+ // 10. routing.default_repo present
299
+ const rawDefaultRepo = rawRouting.default_repo;
300
+ if (!rawDefaultRepo || typeof rawDefaultRepo !== "string" || rawDefaultRepo.trim() === "") {
301
+ throw new WorkspaceConfigError(
302
+ "WORKSPACE_MISSING_DEFAULT_REPO",
303
+ "Workspace config 'routing.default_repo' is missing or empty.",
304
+ undefined,
305
+ configFile,
306
+ );
307
+ }
308
+
309
+ // 11. routing.default_repo references a valid repo ID
310
+ const defaultRepoId = rawDefaultRepo.trim();
311
+ if (!repos.has(defaultRepoId)) {
312
+ throw new WorkspaceConfigError(
313
+ "WORKSPACE_DEFAULT_REPO_NOT_FOUND",
314
+ `routing.default_repo '${defaultRepoId}' does not match any repo ID. Available repos: ${Array.from(repos.keys()).join(", ")}`,
315
+ undefined,
316
+ configFile,
317
+ );
318
+ }
319
+
320
+ // ── Build routing config ─────────────────────────────────────
321
+ const routing: WorkspaceRoutingConfig = {
322
+ tasksRoot: tasksRootAbsolute,
323
+ defaultRepo: defaultRepoId,
324
+ };
325
+
326
+ // ── Build and return WorkspaceConfig ─────────────────────────
327
+ return {
328
+ mode: "workspace",
329
+ repos,
330
+ routing,
331
+ configPath: configFile,
332
+ };
333
+ }
334
+
335
+
336
+ // ── Execution Context Builder ────────────────────────────────────────
337
+
338
+ /**
339
+ * Build an ExecutionContext from the current working directory.
340
+ *
341
+ * This is the top-level entry point for Step 2 (wire orchestrator startup).
342
+ * It loads all configs, detects workspace mode, and returns a unified context.
343
+ *
344
+ * @param cwd - Current working directory
345
+ * @param loadOrchConfig - Orchestrator config loader (for testability)
346
+ * @param loadTaskConfig - Task runner config loader (for testability)
347
+ * @returns ExecutionContext ready for orchestrator consumption
348
+ * @throws WorkspaceConfigError if workspace config is present but invalid
349
+ */
350
+ export function buildExecutionContext(
351
+ cwd: string,
352
+ loadOrchConfig: (root: string) => import("./types.ts").OrchestratorConfig,
353
+ loadTaskConfig: (root: string) => import("./types.ts").TaskRunnerConfig,
354
+ ): import("./types.ts").ExecutionContext {
355
+ const orchestratorConfig = loadOrchConfig(cwd);
356
+ const taskRunnerConfig = loadTaskConfig(cwd);
357
+
358
+ const workspaceConfig = loadWorkspaceConfig(cwd);
359
+
360
+ if (workspaceConfig === null) {
361
+ // Repo mode: cwd is both workspace root and repo root
362
+ return {
363
+ workspaceRoot: cwd,
364
+ repoRoot: cwd,
365
+ mode: "repo",
366
+ workspaceConfig: null,
367
+ taskRunnerConfig,
368
+ orchestratorConfig,
369
+ };
370
+ }
371
+
372
+ // Workspace mode: workspace root is cwd, repo root is the default repo
373
+ const defaultRepo = workspaceConfig.repos.get(workspaceConfig.routing.defaultRepo)!;
374
+ return {
375
+ workspaceRoot: cwd,
376
+ repoRoot: defaultRepo.path,
377
+ mode: "workspace",
378
+ workspaceConfig,
379
+ taskRunnerConfig,
380
+ orchestratorConfig,
381
+ };
382
+ }
@@ -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 } from "fs";
5
+ import { existsSync, readdirSync, realpathSync, rmSync } from "fs";
6
6
  import { execSync } from "child_process";
7
7
  import { join, basename, resolve } from "path";
8
8
 
@@ -1103,8 +1103,9 @@ export function escapeRegex(str: string): string {
1103
1103
  *
1104
1104
  * @param count - Number of worktrees to create (1-indexed: lane 1..count)
1105
1105
  * @param batchId - Batch ID timestamp for branch naming
1106
- * @param config - Orchestrator config (prefix, baseBranch extracted from it)
1106
+ * @param config - Orchestrator config (prefix extracted from it)
1107
1107
  * @param repoRoot - Absolute path to the main repository root
1108
+ * @param baseBranch - Branch to base worktrees on (captured at batch start)
1108
1109
  * @returns - CreateLaneWorktreesResult with success flag and details
1109
1110
  */
1110
1111
  export function createLaneWorktrees(
@@ -1112,9 +1113,9 @@ export function createLaneWorktrees(
1112
1113
  batchId: string,
1113
1114
  config: OrchestratorConfig,
1114
1115
  repoRoot: string,
1116
+ baseBranch: string,
1115
1117
  ): CreateLaneWorktreesResult {
1116
1118
  const prefix = config.orchestrator.worktree_prefix;
1117
- const baseBranch = config.orchestrator.integration_branch;
1118
1119
  const created: WorktreeInfo[] = [];
1119
1120
  const errors: BulkWorktreeError[] = [];
1120
1121
 
@@ -1175,7 +1176,7 @@ export function createLaneWorktrees(
1175
1176
  * Ensure required lane worktrees exist for the current wave.
1176
1177
  *
1177
1178
  * Reuses existing worktrees when present (multi-wave behavior), resetting
1178
- * them to integration HEAD before use, and only creates missing lanes.
1179
+ * them to the base branch HEAD before use, and only creates missing lanes.
1179
1180
  * If creation of a missing lane fails, newly-created lanes in this call are
1180
1181
  * rolled back.
1181
1182
  *
@@ -1187,9 +1188,9 @@ export function ensureLaneWorktrees(
1187
1188
  batchId: string,
1188
1189
  config: OrchestratorConfig,
1189
1190
  repoRoot: string,
1191
+ baseBranch: string,
1190
1192
  ): CreateLaneWorktreesResult {
1191
1193
  const prefix = config.orchestrator.worktree_prefix;
1192
- const baseBranch = config.orchestrator.integration_branch;
1193
1194
 
1194
1195
  const existing = listWorktrees(prefix, repoRoot);
1195
1196
  const existingByLane = new Map<number, WorktreeInfo>();
@@ -1205,7 +1206,7 @@ export function ensureLaneWorktrees(
1205
1206
  for (const lane of needed) {
1206
1207
  const reused = existingByLane.get(lane);
1207
1208
  if (reused) {
1208
- // Reused worktrees must be reset to integration branch HEAD before use.
1209
+ // Reused worktrees must be reset to base branch HEAD before use.
1209
1210
  // This covers normal multi-wave reuse and stale leftovers from prior batches.
1210
1211
  const resetResult = safeResetWorktree(reused, baseBranch, repoRoot);
1211
1212
  if (resetResult.success) {
@@ -1594,13 +1595,36 @@ export function safeResetWorktree(
1594
1595
  };
1595
1596
  }
1596
1597
 
1597
- // Remove untracked files
1598
+ // Remove untracked files.
1599
+ // git clean may warn about files it can't delete (e.g., Windows reserved
1600
+ // names like "nul", "con", "aux") but still clean everything else.
1601
+ // We treat this as non-fatal: check porcelain status afterward instead
1602
+ // of failing on the exit code.
1598
1603
  const cleanResult = runGit(["clean", "-fd"], worktree.path);
1599
1604
  if (!cleanResult.ok) {
1600
- return {
1601
- success: false,
1602
- error: `git clean -fd failed: ${cleanResult.stderr}`,
1603
- };
1605
+ execLog("reset", `lane-${worktree.laneNumber}`, "git clean -fd returned non-zero (may be partial)", {
1606
+ stderr: cleanResult.stderr.slice(0, 200),
1607
+ });
1608
+ }
1609
+
1610
+ // Check if the worktree is clean enough to proceed.
1611
+ // If git status --porcelain shows no tracked changes, the reset can work
1612
+ // even if some untracked files couldn't be deleted.
1613
+ const statusCheck = runGit(["status", "--porcelain"], worktree.path);
1614
+ if (statusCheck.ok && statusCheck.stdout.length > 0) {
1615
+ // Still dirty after cleaning — check if only untracked files remain
1616
+ const lines = statusCheck.stdout.split("\n").filter(l => l.trim());
1617
+ const onlyUntracked = lines.every(l => l.startsWith("??"));
1618
+ if (!onlyUntracked) {
1619
+ return {
1620
+ success: false,
1621
+ error: `Worktree still dirty after clean: ${statusCheck.stdout.slice(0, 200)}`,
1622
+ };
1623
+ }
1624
+ // Only untracked files remain (e.g., undeletable "nul") — safe to proceed
1625
+ execLog("reset", `lane-${worktree.laneNumber}`, "untracked files remain after clean (non-blocking)", {
1626
+ files: lines.map(l => l.slice(3)).join(", "),
1627
+ });
1604
1628
  }
1605
1629
 
1606
1630
  // Retry reset after cleaning
@@ -1622,3 +1646,81 @@ export function safeResetWorktree(
1622
1646
  }
1623
1647
  }
1624
1648
 
1649
+
1650
+ // ── Force Cleanup ────────────────────────────────────────────────────
1651
+
1652
+ /**
1653
+ * Last-resort worktree cleanup: force-remove the directory and prune git state.
1654
+ *
1655
+ * Used when both `safeResetWorktree()` and `removeWorktree()` fail — typically
1656
+ * because undeletable files (e.g., Windows reserved names like "nul", "con")
1657
+ * block `git clean` and `git worktree remove`, leaving git in an inconsistent state.
1658
+ *
1659
+ * Recovery steps:
1660
+ * 1. Force-remove the worktree directory (`rm -rf` equivalent)
1661
+ * 2. Prune stale git worktree references (`git worktree prune`)
1662
+ * 3. Delete the lane branch if it exists (`git branch -D`)
1663
+ *
1664
+ * This allows the next wave to recreate the worktree from scratch.
1665
+ *
1666
+ * @param worktree - WorktreeInfo for the failed worktree
1667
+ * @param repoRoot - Main repository root
1668
+ * @param batchId - Batch ID for logging context
1669
+ */
1670
+ export function forceCleanupWorktree(
1671
+ worktree: WorktreeInfo,
1672
+ repoRoot: string,
1673
+ batchId: string,
1674
+ ): void {
1675
+ const { path: worktreePath, branch, laneNumber } = worktree;
1676
+
1677
+ // Step 1: Force-remove the directory
1678
+ if (existsSync(worktreePath)) {
1679
+ try {
1680
+ // On Windows, undeletable reserved-name files (nul, con, aux) need
1681
+ // special handling. Try rmSync first, then fall back to OS-specific
1682
+ // removal for stubborn files.
1683
+ rmSync(worktreePath, { recursive: true, force: true });
1684
+ execLog("cleanup", `lane-${laneNumber}`, `force-removed worktree directory`, { path: worktreePath });
1685
+ } catch (rmErr: unknown) {
1686
+ // If Node's rmSync fails (e.g., Windows reserved names), try platform-specific
1687
+ const rmMsg = rmErr instanceof Error ? rmErr.message : String(rmErr);
1688
+ execLog("cleanup", `lane-${laneNumber}`, `rmSync failed, trying OS-level removal`, { error: rmMsg });
1689
+
1690
+ try {
1691
+ if (process.platform === "win32") {
1692
+ // rd /s /q handles Windows reserved names that Node.js cannot delete
1693
+ execSync(`rd /s /q "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
1694
+ } else {
1695
+ execSync(`rm -rf "${worktreePath}"`, { stdio: "pipe", timeout: 30_000 });
1696
+ }
1697
+ execLog("cleanup", `lane-${laneNumber}`, `OS-level removal succeeded`, { path: worktreePath });
1698
+ } catch (osErr: unknown) {
1699
+ const osMsg = osErr instanceof Error ? osErr.message : String(osErr);
1700
+ execLog("cleanup", `lane-${laneNumber}`, `OS-level removal also failed — manual cleanup needed`, {
1701
+ path: worktreePath,
1702
+ error: osMsg,
1703
+ });
1704
+ }
1705
+ }
1706
+ }
1707
+
1708
+ // Step 2: Prune stale worktree references
1709
+ runGit(["worktree", "prune"], repoRoot);
1710
+ execLog("cleanup", `lane-${laneNumber}`, `pruned stale worktree references`);
1711
+
1712
+ // Step 3: Delete the lane branch if it still exists
1713
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot);
1714
+ if (branchCheck.ok) {
1715
+ const deleteResult = runGit(["branch", "-D", branch], repoRoot);
1716
+ if (deleteResult.ok) {
1717
+ execLog("cleanup", `lane-${laneNumber}`, `deleted stale lane branch`, { branch });
1718
+ } else {
1719
+ execLog("cleanup", `lane-${laneNumber}`, `could not delete lane branch`, {
1720
+ branch,
1721
+ error: deleteResult.stderr,
1722
+ });
1723
+ }
1724
+ }
1725
+ }
1726
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",