taskplane 0.1.15 → 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.
@@ -1,14 +1,14 @@
1
- /**
2
- * Config loading from YAML
3
- * @module orch/config
4
- */
5
- import { readFileSync, existsSync } from "fs";
6
- import { join } from "path";
7
- import { parse as yamlParse } from "yaml";
8
-
9
- import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } from "./types.ts";
10
- import type { OrchestratorConfig, TaskArea, TaskRunnerConfig } from "./types.ts";
11
-
1
+ /**
2
+ * Config loading from YAML
3
+ * @module orch/config
4
+ */
5
+ import { readFileSync, existsSync } from "fs";
6
+ import { join } from "path";
7
+ import { parse as yamlParse } from "yaml";
8
+
9
+ import { DEFAULT_ORCHESTRATOR_CONFIG, DEFAULT_TASK_RUNNER_CONFIG } from "./types.ts";
10
+ import type { OrchestratorConfig, TaskArea, TaskRunnerConfig } from "./types.ts";
11
+
12
12
  // ── Config Loading ───────────────────────────────────────────────────
13
13
 
14
14
  /**
@@ -84,11 +84,16 @@ export function loadTaskRunnerConfig(cwd: string): TaskRunnerConfig {
84
84
  if (loaded?.task_areas) {
85
85
  for (const [name, area] of Object.entries(loaded.task_areas)) {
86
86
  const a = area as any;
87
- taskAreas[name] = {
87
+ const ta: TaskArea = {
88
88
  path: a?.path || "",
89
89
  prefix: a?.prefix || "",
90
90
  context: a?.context || "",
91
91
  };
92
+ // Parse repo_id (snake_case YAML key) into repoId for routing
93
+ if (a?.repo_id && typeof a.repo_id === "string" && a.repo_id.trim()) {
94
+ ta.repoId = a.repo_id.trim();
95
+ }
96
+ taskAreas[name] = ta;
92
97
  }
93
98
  }
94
99
  return {
@@ -1,12 +1,13 @@
1
- /**
2
- * Task discovery, PROMPT.md parsing, dependency resolution
3
- * @module orch/discovery
4
- */
5
- import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
- import { join, dirname, basename, resolve } from "path";
7
-
8
- import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea } from "./types.ts";
9
-
1
+ /**
2
+ * Task discovery, PROMPT.md parsing, dependency resolution
3
+ * @module orch/discovery
4
+ */
5
+ import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from "fs";
6
+ import { join, dirname, basename, resolve } from "path";
7
+
8
+ import { FATAL_DISCOVERY_CODES } from "./types.ts";
9
+ import type { DiscoveryError, DiscoveryResult, ParsedTask, TaskArea, WorkspaceConfig } from "./types.ts";
10
+
10
11
  // ── PROMPT.md Parsing ────────────────────────────────────────────────
11
12
 
12
13
  /**
@@ -184,6 +185,54 @@ export function parsePromptForOrchestrator(
184
185
  }
185
186
  }
186
187
 
188
+ // ── Extract execution target (repo ID) ──────────────────────
189
+ // Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum
190
+ const REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
191
+
192
+ let promptRepoId: string | undefined;
193
+
194
+ // Priority 1: Section-based "## Execution Target" with "Repo: <id>" line
195
+ // Capture everything from section header to the next heading or --- divider.
196
+ // We avoid \n$ (which in multiline mode matches blank lines) by using a two-pass
197
+ // approach: find the section start, then slice to the next section boundary.
198
+ const execTargetHeaderIdx = content.search(/^##\s+Execution Target\s*$/m);
199
+ let execTargetSectionBody: string | null = null;
200
+ if (execTargetHeaderIdx !== -1) {
201
+ const afterHeader = content.indexOf("\n", execTargetHeaderIdx);
202
+ if (afterHeader !== -1) {
203
+ const rest = content.slice(afterHeader + 1);
204
+ const nextSectionMatch = rest.search(/^##\s|^---/m);
205
+ execTargetSectionBody = nextSectionMatch !== -1
206
+ ? rest.slice(0, nextSectionMatch)
207
+ : rest;
208
+ }
209
+ }
210
+ if (execTargetSectionBody !== null) {
211
+ // Match "Repo: api" or "**Repo:** api" or "Repo: api" with whitespace
212
+ const repoLineMatch = execTargetSectionBody.match(
213
+ /^\s*\*?\*?Repo:?\*?\*?\s+(\S+)/mi,
214
+ );
215
+ if (repoLineMatch) {
216
+ const candidate = repoLineMatch[1].trim().toLowerCase();
217
+ if (REPO_ID_PATTERN.test(candidate)) {
218
+ promptRepoId = candidate;
219
+ }
220
+ }
221
+ }
222
+
223
+ // Priority 2 (fallback): Inline "**Repo:** <id>" anywhere in content
224
+ if (!promptRepoId) {
225
+ const inlineRepoMatch = content.match(
226
+ /^\*\*Repo:\*\*\s+(\S+)/m,
227
+ );
228
+ if (inlineRepoMatch) {
229
+ const candidate = inlineRepoMatch[1].trim().toLowerCase();
230
+ if (REPO_ID_PATTERN.test(candidate)) {
231
+ promptRepoId = candidate;
232
+ }
233
+ }
234
+ }
235
+
187
236
  // ── Extract file scope ───────────────────────────────────────
188
237
  const fileScope: string[] = [];
189
238
  const fileScopeMatch = content.match(
@@ -214,12 +263,13 @@ export function parsePromptForOrchestrator(
214
263
  promptPath: resolve(promptPath),
215
264
  areaName,
216
265
  status: "pending",
266
+ ...(promptRepoId ? { promptRepoId } : {}),
217
267
  },
218
268
  error: null,
219
269
  };
220
270
  }
221
271
 
222
-
272
+
223
273
  // ── Area Scanning ────────────────────────────────────────────────────
224
274
 
225
275
  /**
@@ -291,7 +341,7 @@ export function scanAreaForTasks(
291
341
  return { tasks, errors };
292
342
  }
293
343
 
294
-
344
+
295
345
  // ── Completed Task Set ───────────────────────────────────────────────
296
346
 
297
347
  /**
@@ -361,7 +411,7 @@ export function buildCompletedTaskSet(areaPaths: string[]): Set<string> {
361
411
  return completed;
362
412
  }
363
413
 
364
-
414
+
365
415
  // ── Argument Resolution ──────────────────────────────────────────────
366
416
 
367
417
  /**
@@ -440,6 +490,8 @@ export interface DiscoveryOptions {
440
490
  refreshDependencies?: boolean;
441
491
  dependencySource?: "prompt" | "agent";
442
492
  useDependencyCache?: boolean;
493
+ /** Workspace config for repo routing (null/undefined = repo mode, no routing). */
494
+ workspaceConfig?: WorkspaceConfig | null;
443
495
  }
444
496
 
445
497
  export interface DependencyCacheFile {
@@ -531,7 +583,7 @@ export function applyDependenciesFromCache(
531
583
  return { applied };
532
584
  }
533
585
 
534
-
586
+
535
587
  // ── Task Registry ────────────────────────────────────────────────────
536
588
 
537
589
  /**
@@ -637,7 +689,7 @@ export function buildTaskRegistry(
637
689
  return { pending, completed, errors };
638
690
  }
639
691
 
640
-
692
+
641
693
  // ── Cross-Area Dependency Resolution ─────────────────────────────────
642
694
 
643
695
  /** Candidate match for a dependency reference found in task areas. */
@@ -806,7 +858,93 @@ export function resolveDependencies(
806
858
  return errors;
807
859
  }
808
860
 
809
-
861
+
862
+ // ── Task-to-Repo Routing ─────────────────────────────────────────────
863
+
864
+ /** Repo ID validation: lowercase alphanumeric + hyphens, starting with alnum */
865
+ const ROUTING_REPO_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
866
+
867
+ /**
868
+ * Resolve the target repo for each discovered task using the routing
869
+ * precedence chain:
870
+ *
871
+ * 1. `task.promptRepoId` — declared in PROMPT.md metadata
872
+ * 2. `taskArea.repoId` — area-level config from task-runner.yaml
873
+ * 3. `workspaceConfig.routing.defaultRepo` — workspace-level default
874
+ *
875
+ * Only applied in workspace mode (when `workspaceConfig` is provided).
876
+ * In repo mode this function is never called.
877
+ *
878
+ * Returns an array of DiscoveryError for routing failures:
879
+ * - TASK_REPO_UNRESOLVED: no source provided a repo ID
880
+ * - TASK_REPO_UNKNOWN: resolved repo ID is not in workspace repos map
881
+ */
882
+ export function resolveTaskRouting(
883
+ discovery: DiscoveryResult,
884
+ taskAreas: Record<string, TaskArea>,
885
+ workspaceConfig: WorkspaceConfig,
886
+ ): DiscoveryError[] {
887
+ const errors: DiscoveryError[] = [];
888
+ const validRepoIds = workspaceConfig.repos;
889
+
890
+ for (const task of discovery.pending.values()) {
891
+ // Precedence 1: prompt-declared repo
892
+ let resolvedId = task.promptRepoId;
893
+ let source = "prompt";
894
+
895
+ // Precedence 2: area-level repo
896
+ if (!resolvedId) {
897
+ const area = taskAreas[task.areaName];
898
+ if (area?.repoId) {
899
+ const candidate = area.repoId.trim().toLowerCase();
900
+ if (ROUTING_REPO_ID_PATTERN.test(candidate)) {
901
+ resolvedId = candidate;
902
+ source = "area";
903
+ }
904
+ }
905
+ }
906
+
907
+ // Precedence 3: workspace default repo
908
+ if (!resolvedId) {
909
+ resolvedId = workspaceConfig.routing.defaultRepo;
910
+ source = "default";
911
+ }
912
+
913
+ // Validate resolution
914
+ if (!resolvedId) {
915
+ errors.push({
916
+ code: "TASK_REPO_UNRESOLVED",
917
+ message:
918
+ `Task ${task.taskId} has no resolved repo. ` +
919
+ `Add a Repo: field to the PROMPT, set repo_id on area "${task.areaName}", ` +
920
+ `or set routing.default_repo in the workspace config.`,
921
+ taskId: task.taskId,
922
+ taskPath: task.promptPath,
923
+ });
924
+ continue;
925
+ }
926
+
927
+ if (!validRepoIds.has(resolvedId)) {
928
+ errors.push({
929
+ code: "TASK_REPO_UNKNOWN",
930
+ message:
931
+ `Task ${task.taskId} resolved to repo "${resolvedId}" (via ${source}), ` +
932
+ `but no repo with that ID exists in the workspace config. ` +
933
+ `Known repos: ${[...validRepoIds.keys()].join(", ")}`,
934
+ taskId: task.taskId,
935
+ taskPath: task.promptPath,
936
+ });
937
+ continue;
938
+ }
939
+
940
+ // Attach resolved repo to the task
941
+ task.resolvedRepoId = resolvedId;
942
+ }
943
+
944
+ return errors;
945
+ }
946
+
947
+
810
948
  // ── Discovery Pipeline (Public) ──────────────────────────────────────
811
949
 
812
950
  /**
@@ -901,6 +1039,13 @@ export function runDiscovery(
901
1039
  }
902
1040
  }
903
1041
 
1042
+ // Step 6: Task-to-repo routing (workspace mode only)
1043
+ const workspaceConfig = options.workspaceConfig;
1044
+ if (workspaceConfig && workspaceConfig.mode === "workspace") {
1045
+ const routingErrors = resolveTaskRouting(discovery, taskAreas, workspaceConfig);
1046
+ discovery.errors.push(...routingErrors);
1047
+ }
1048
+
904
1049
  return discovery;
905
1050
  }
906
1051
 
@@ -939,8 +1084,12 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
939
1084
  task.dependencies.length > 0
940
1085
  ? ` → depends on: ${task.dependencies.join(", ")}`
941
1086
  : "";
1087
+ const repo =
1088
+ task.resolvedRepoId
1089
+ ? ` → repo: ${task.resolvedRepoId}`
1090
+ : "";
942
1091
  lines.push(
943
- ` ${task.taskId} [${task.size}] ${task.taskName}${deps}`,
1092
+ ` ${task.taskId} [${task.size}] ${task.taskName}${deps}${repo}`,
944
1093
  );
945
1094
  }
946
1095
  }
@@ -949,22 +1098,9 @@ export function formatDiscoveryResults(result: DiscoveryResult): string {
949
1098
 
950
1099
  // Show errors
951
1100
  if (result.errors.length > 0) {
952
- const fatalErrors = result.errors.filter(
953
- (e) =>
954
- e.code === "DUPLICATE_ID" ||
955
- e.code === "DEP_UNRESOLVED" ||
956
- e.code === "DEP_PENDING" ||
957
- e.code === "DEP_AMBIGUOUS" ||
958
- e.code === "PARSE_MISSING_ID",
959
- );
960
- const warnings = result.errors.filter(
961
- (e) =>
962
- e.code !== "DUPLICATE_ID" &&
963
- e.code !== "DEP_UNRESOLVED" &&
964
- e.code !== "DEP_PENDING" &&
965
- e.code !== "DEP_AMBIGUOUS" &&
966
- e.code !== "PARSE_MISSING_ID",
967
- );
1101
+ const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
1102
+ const fatalErrors = result.errors.filter((e) => fatalCodes.has(e.code));
1103
+ const warnings = result.errors.filter((e) => !fatalCodes.has(e.code));
968
1104
 
969
1105
  if (fatalErrors.length > 0) {
970
1106
  lines.push("❌ Errors:");
@@ -13,10 +13,10 @@ import { mergeWave } from "./merge.ts";
13
13
  import { ORCH_MESSAGES } from "./messages.ts";
14
14
  import { deleteBatchState, loadBatchHistory, persistRuntimeState, saveBatchHistory, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
15
15
  import { listOrchSessions } from "./sessions.ts";
16
- import { generateBatchId } from "./types.ts";
17
- import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts } from "./types.ts";
16
+ import { FATAL_DISCOVERY_CODES, generateBatchId } from "./types.ts";
17
+ import type { AllocatedLane, BatchHistorySummary, BatchTaskSummary, BatchWaveSummary, DiscoveryResult, LaneExecutionResult, LaneTaskOutcome, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, TaskRunnerConfig, TokenCounts, WorkspaceConfig } from "./types.ts";
18
18
  import { buildDependencyGraph, computeWaves, validateGraph } from "./waves.ts";
19
- import { deleteBranchBestEffort, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
19
+ import { deleteBranchBestEffort, forceCleanupWorktree, formatPreflightResults, listWorktrees, removeAllWorktrees, removeWorktree, runPreflight, safeResetWorktree, sleepSync } from "./worktree.ts";
20
20
 
21
21
  // ── /orch Execution Engine ───────────────────────────────────────────
22
22
 
@@ -32,6 +32,7 @@ import { deleteBranchBestEffort, formatPreflightResults, listWorktrees, removeAl
32
32
  * @param batchState - Mutable batch state (updated throughout execution)
33
33
  * @param onNotify - Callback for user-facing messages
34
34
  * @param onMonitorUpdate - Optional callback for dashboard updates
35
+ * @param workspaceConfig - Workspace configuration for repo routing (null = repo mode)
35
36
  */
36
37
  export async function executeOrchBatch(
37
38
  args: string,
@@ -41,6 +42,7 @@ export async function executeOrchBatch(
41
42
  batchState: OrchBatchRuntimeState,
42
43
  onNotify: (message: string, level: "info" | "warning" | "error") => void,
43
44
  onMonitorUpdate?: MonitorUpdateCallback,
45
+ workspaceConfig?: WorkspaceConfig | null,
44
46
  ): Promise<void> {
45
47
  const repoRoot = cwd;
46
48
 
@@ -95,23 +97,27 @@ export async function executeOrchBatch(
95
97
  refreshDependencies: false,
96
98
  dependencySource: orchConfig.dependencies.source,
97
99
  useDependencyCache: orchConfig.dependencies.cache,
100
+ workspaceConfig: workspaceConfig ?? null,
98
101
  });
99
102
  onNotify(formatDiscoveryResults(discovery), discovery.errors.length > 0 ? "warning" : "info");
100
103
 
101
104
  // Check for fatal errors
102
- const fatalErrors = discovery.errors.filter(
103
- (e) =>
104
- e.code === "DUPLICATE_ID" ||
105
- e.code === "DEP_UNRESOLVED" ||
106
- e.code === "DEP_PENDING" ||
107
- e.code === "DEP_AMBIGUOUS" ||
108
- e.code === "PARSE_MISSING_ID",
109
- );
105
+ const fatalCodes = new Set<string>(FATAL_DISCOVERY_CODES);
106
+ const fatalErrors = discovery.errors.filter((e) => fatalCodes.has(e.code));
110
107
  if (fatalErrors.length > 0) {
111
108
  batchState.phase = "failed";
112
109
  batchState.endedAt = Date.now();
113
110
  batchState.errors.push("Discovery had fatal errors — cannot proceed");
114
111
  onNotify("❌ Cannot execute due to discovery errors above.", "error");
112
+ const hasRoutingErrors = fatalErrors.some(
113
+ (e) => e.code === "TASK_REPO_UNRESOLVED" || e.code === "TASK_REPO_UNKNOWN",
114
+ );
115
+ if (hasRoutingErrors) {
116
+ onNotify(
117
+ "💡 Check PROMPT Repo: fields, area repo_id config, and routing.default_repo in workspace config.",
118
+ "info",
119
+ );
120
+ }
115
121
  return;
116
122
  }
117
123
 
@@ -500,10 +506,14 @@ export async function executeOrchBatch(
500
506
  removeWorktree(wt, repoRoot);
501
507
  execLog("batch", batchState.batchId, `removed unrecoverable worktree for lane ${wt.laneNumber}`);
502
508
  } catch (removeErr: unknown) {
503
- execLog("batch", batchState.batchId, `failed to remove unrecoverable worktree for lane ${wt.laneNumber}`, {
509
+ execLog("batch", batchState.batchId, `removeWorktree failed for lane ${wt.laneNumber}, attempting force cleanup`, {
504
510
  error: removeErr instanceof Error ? removeErr.message : String(removeErr),
505
511
  path: wt.path,
506
512
  });
513
+ // Last resort: force-remove the directory and prune git worktree state.
514
+ // This handles cases where git has partially deregistered the worktree
515
+ // or undeletable files (e.g., Windows reserved names like "nul") block removal.
516
+ forceCleanupWorktree(wt, repoRoot, batchState.batchId);
507
517
  }
508
518
  } else {
509
519
  execLog("batch", batchState.batchId, `worktree reset OK for lane ${wt.laneNumber}`);
@@ -118,7 +118,8 @@ export function buildLaneEnvVars(
118
118
  if (promptNorm.startsWith(repoRootNorm + "/")) {
119
119
  relativePath = promptNorm.slice(repoRootNorm.length + 1);
120
120
  } else {
121
- // Fallback: use the path as-is (shouldn't happen in normal use)
121
+ // External task folder (workspace mode): prompt path is outside repo root.
122
+ // Use the absolute path as-is — task-runner accepts absolute TASK_AUTOSTART paths.
122
123
  relativePath = promptPath;
123
124
  }
124
125
 
@@ -300,44 +301,122 @@ export function readTaskStatusTail(
300
301
  }
301
302
 
302
303
  /**
303
- * Resolve the path to a task's .DONE file inside a worktree.
304
+ * Result of canonical task-folder path resolution.
304
305
  *
305
- * The task folder path from ParsedTask is absolute (main repo).
306
- * We need to translate it to the equivalent path in the worktree.
306
+ * Encapsulates the resolved task folder, .DONE path, and STATUS.md path
307
+ * so callers don't need to re-derive them with inconsistent logic.
308
+ */
309
+ export interface ResolvedTaskPaths {
310
+ /** Absolute path to the resolved task folder (may be in worktree or external) */
311
+ taskFolderResolved: string;
312
+ /** Absolute path to the .DONE file */
313
+ donePath: string;
314
+ /** Absolute path to the STATUS.md file */
315
+ statusPath: string;
316
+ }
317
+
318
+ /**
319
+ * Canonical task-folder path resolver.
307
320
  *
308
- * @param taskFolder - Absolute task folder path (from main repo)
321
+ * Single source of truth for translating a task folder path (as stored in
322
+ * ParsedTask) into the correct filesystem paths for .DONE and STATUS.md
323
+ * probing. Handles two cases:
324
+ *
325
+ * 1. **Task folder inside repoRoot** (monorepo / repo mode):
326
+ * Strip the repoRoot prefix to get a relative path, then join with
327
+ * worktreePath. This is the existing behavior — worktrees mirror the
328
+ * repo structure so the relative path is the same.
329
+ *
330
+ * 2. **Task folder outside repoRoot** (workspace mode with external tasks root):
331
+ * The task folder is not inside the execution repo. Use the absolute
332
+ * task folder path directly — the .DONE and STATUS.md files live in
333
+ * the canonical task folder, not in any worktree.
334
+ *
335
+ * Both branches include archive fallback: if the primary location doesn't
336
+ * exist, check `<parent>/archive/<taskDirName>/` for relocated task folders.
337
+ *
338
+ * @param taskFolder - Absolute task folder path (from ParsedTask.taskFolder)
309
339
  * @param worktreePath - Absolute path to the lane worktree
310
340
  * @param repoRoot - Absolute path to the main repository root
311
- * @returns Absolute path to the .DONE file in the worktree
341
+ * @returns Resolved paths for task folder, .DONE, and STATUS.md
312
342
  */
313
- export function resolveTaskDonePath(
343
+ export function resolveCanonicalTaskPaths(
314
344
  taskFolder: string,
315
345
  worktreePath: string,
316
346
  repoRoot: string,
317
- ): string {
347
+ ): ResolvedTaskPaths {
318
348
  const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
319
349
  const folderNorm = resolve(taskFolder).replace(/\\/g, "/");
320
350
 
321
- let relativePath: string;
351
+ let resolvedFolder: string;
352
+
322
353
  if (folderNorm.startsWith(repoRootNorm + "/")) {
323
- relativePath = folderNorm.slice(repoRootNorm.length + 1);
354
+ // Case 1: Task folder is inside the repo root.
355
+ // Translate to equivalent path in the worktree.
356
+ const relativePath = folderNorm.slice(repoRootNorm.length + 1);
357
+ resolvedFolder = join(worktreePath, relativePath);
324
358
  } else {
325
- relativePath = taskFolder;
359
+ // Case 2: Task folder is outside the repo root (workspace mode).
360
+ // Use the absolute path directly — task state lives in the
361
+ // canonical task folder, not mirrored in any worktree.
362
+ resolvedFolder = resolve(taskFolder);
326
363
  }
327
364
 
328
- const primaryPath = join(worktreePath, relativePath, ".DONE");
329
- if (existsSync(primaryPath)) return primaryPath;
365
+ // Check primary location
366
+ const primaryDone = join(resolvedFolder, ".DONE");
367
+ const primaryStatus = join(resolvedFolder, "STATUS.md");
368
+ if (existsSync(primaryDone) || existsSync(primaryStatus)) {
369
+ return {
370
+ taskFolderResolved: resolvedFolder,
371
+ donePath: primaryDone,
372
+ statusPath: primaryStatus,
373
+ };
374
+ }
330
375
 
331
- // Fallback: worker may have archived the task folder during the
376
+ // Archive fallback: worker may have archived the task folder during the
332
377
  // "Documentation & Delivery" step, moving it under `.../archive/TASK-ID/`.
333
- // Check the archive sibling path.
334
- const parts = relativePath.replace(/\\/g, "/").split("/");
335
- const taskDirName = parts[parts.length - 1]; // e.g. "PM-011-template-seed-data-permissions"
336
- const parentParts = parts.slice(0, -1); // e.g. [..., "tasks"]
337
- const archivePath = join(worktreePath, ...parentParts, "archive", taskDirName, ".DONE");
338
- if (existsSync(archivePath)) return archivePath;
339
-
340
- return primaryPath; // Return primary even if missing (caller checks existsSync)
378
+ const resolvedNorm = resolve(resolvedFolder).replace(/\\/g, "/");
379
+ const parts = resolvedNorm.split("/");
380
+ const taskDirName = parts[parts.length - 1];
381
+ const parentDir = parts.slice(0, -1).join("/");
382
+ const archiveFolder = join(parentDir, "archive", taskDirName);
383
+ const archiveDone = join(archiveFolder, ".DONE");
384
+ const archiveStatus = join(archiveFolder, "STATUS.md");
385
+
386
+ if (existsSync(archiveDone) || existsSync(archiveStatus)) {
387
+ return {
388
+ taskFolderResolved: archiveFolder,
389
+ donePath: archiveDone,
390
+ statusPath: archiveStatus,
391
+ };
392
+ }
393
+
394
+ // Return primary paths even if nothing exists yet (caller probes existsSync)
395
+ return {
396
+ taskFolderResolved: resolvedFolder,
397
+ donePath: primaryDone,
398
+ statusPath: primaryStatus,
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Resolve the path to a task's .DONE file inside a worktree.
404
+ *
405
+ * Delegates to `resolveCanonicalTaskPaths` for consistent path resolution
406
+ * across repo mode (task folder inside repo) and workspace mode (external
407
+ * task folder).
408
+ *
409
+ * @param taskFolder - Absolute task folder path (from main repo)
410
+ * @param worktreePath - Absolute path to the lane worktree
411
+ * @param repoRoot - Absolute path to the main repository root
412
+ * @returns Absolute path to the .DONE file in the worktree
413
+ */
414
+ export function resolveTaskDonePath(
415
+ taskFolder: string,
416
+ worktreePath: string,
417
+ repoRoot: string,
418
+ ): string {
419
+ return resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot).donePath;
341
420
  }
342
421
 
343
422
  /**
@@ -467,8 +546,9 @@ export async function pollUntilTaskComplete(
467
546
  ): Promise<{ status: LaneTaskStatus; exitReason: string; doneFileFound: boolean }> {
468
547
  const sessionName = lane.tmuxSessionName;
469
548
  const laneId = lane.laneId;
470
- const donePath = resolveTaskDonePath(task.task.taskFolder, lane.worktreePath, repoRoot);
471
- const statusPath = join(dirname(donePath), "STATUS.md");
549
+ const resolved = resolveCanonicalTaskPaths(task.task.taskFolder, lane.worktreePath, repoRoot);
550
+ const donePath = resolved.donePath;
551
+ const statusPath = resolved.statusPath;
472
552
  const laneLogPath = resolveLaneLogPath(lane, task);
473
553
 
474
554
  execLog(laneId, task.taskId, "polling for completion", {
@@ -788,30 +868,12 @@ export function parseWorktreeStatusMd(
788
868
  worktreePath: string,
789
869
  repoRoot: string,
790
870
  ): { parsed: ParsedWorktreeStatus | null; error: string | null } {
791
- // Translate the task folder path from main repo to worktree
792
- const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
793
- const folderNorm = resolve(taskFolder).replace(/\\/g, "/");
794
-
795
- let relativePath: string;
796
- if (folderNorm.startsWith(repoRootNorm + "/")) {
797
- relativePath = folderNorm.slice(repoRootNorm.length + 1);
798
- } else {
799
- relativePath = taskFolder;
800
- }
801
-
802
- let statusPath = join(worktreePath, relativePath, "STATUS.md");
871
+ // Use canonical resolver for consistent path translation
872
+ const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot);
873
+ const statusPath = resolved.statusPath;
803
874
 
804
875
  if (!existsSync(statusPath)) {
805
- // Fallback: worker may have archived the task folder
806
- const parts = relativePath.replace(/\\/g, "/").split("/");
807
- const taskDirName = parts[parts.length - 1];
808
- const parentParts = parts.slice(0, -1);
809
- const archiveStatusPath = join(worktreePath, ...parentParts, "archive", taskDirName, "STATUS.md");
810
- if (existsSync(archiveStatusPath)) {
811
- statusPath = archiveStatusPath;
812
- } else {
813
- return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
814
- }
876
+ return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
815
877
  }
816
878
 
817
879
  let content: string;