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.
@@ -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;
@@ -1403,7 +1465,7 @@ export function computeTransitiveDependents(
1403
1465
  *
1404
1466
  * This function checks each wave task's folder for untracked or modified files,
1405
1467
  * stages them, and creates a commit on the current branch. This must run BEFORE
1406
- * allocateLanes() so that worktrees (which are based on the integration branch)
1468
+ * allocateLanes() so that worktrees (which are based on the batch's base branch)
1407
1469
  * include the task files.
1408
1470
  *
1409
1471
  * Only task-specific folders are staged — no other working tree changes are touched.
@@ -1522,6 +1584,7 @@ export function ensureTaskFilesCommitted(
1522
1584
  * @param batchId - Batch ID for naming
1523
1585
  * @param pauseSignal - Shared pause signal (mutated by stop-wave policy)
1524
1586
  * @param dependencyGraph - Dependency graph for computing transitive dependents
1587
+ * @param baseBranch - Branch to base worktrees on (captured at batch start)
1525
1588
  * @param onMonitorUpdate - Optional callback for dashboard updates during monitoring
1526
1589
  * @param onLanesAllocated - Optional callback fired after lane allocation succeeds
1527
1590
  * @returns WaveExecutionResult with outcomes and blocked task IDs
@@ -1535,6 +1598,7 @@ export async function executeWave(
1535
1598
  batchId: string,
1536
1599
  pauseSignal: { paused: boolean },
1537
1600
  dependencyGraph: DependencyGraph,
1601
+ baseBranch: string,
1538
1602
  onMonitorUpdate?: MonitorUpdateCallback,
1539
1603
  onLanesAllocated?: (lanes: AllocatedLane[]) => void,
1540
1604
  ): Promise<WaveExecutionResult> {
@@ -1576,7 +1640,7 @@ export async function executeWave(
1576
1640
  }
1577
1641
 
1578
1642
  // ── Stage 1: Allocate lanes ──────────────────────────────────
1579
- const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId);
1643
+ const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, baseBranch);
1580
1644
 
1581
1645
  if (!allocResult.success) {
1582
1646
  const errMsg = allocResult.error?.message || "Unknown allocation failure";