taskplane 0.1.15 → 0.1.17

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.
305
+ *
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.
304
320
  *
305
- * The task folder path from ParsedTask is absolute (main repo).
306
- * We need to translate it to the equivalent path in the worktree.
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:
307
324
  *
308
- * @param taskFolder - Absolute task folder path (from main repo)
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", {
@@ -594,14 +674,72 @@ export async function pollUntilTaskComplete(
594
674
  }
595
675
  }
596
676
 
677
+
678
+ // ── Post-Task Commit ─────────────────────────────────────────────────
679
+
680
+ /**
681
+ * Commit any uncommitted task artifacts to the lane branch after task completion.
682
+ *
683
+ * The task-runner creates `.DONE` and updates `STATUS.md` via `writeFileSync`,
684
+ * but these changes are never committed to git by the task-runner or the worker.
685
+ * Without this commit, these files are lost when the worktree is reset or removed,
686
+ * and they don't appear in the merge to the base branch.
687
+ *
688
+ * Best-effort: failures are logged but don't fail the task (the work is already done).
689
+ *
690
+ * @param lane - Allocated lane containing the worktree path
691
+ * @param task - The task that just completed
692
+ * @param laneId - Lane identifier for logging
693
+ */
694
+ function commitTaskArtifacts(
695
+ lane: AllocatedLane,
696
+ task: AllocatedTask,
697
+ laneId: string,
698
+ ): void {
699
+ const worktreePath = lane.worktreePath;
700
+
701
+ // Check if there are any uncommitted changes in the worktree
702
+ const statusResult = runGit(["status", "--porcelain"], worktreePath);
703
+ if (!statusResult.ok || !statusResult.stdout.trim()) {
704
+ // Nothing to commit (worker already committed everything, or git error)
705
+ return;
706
+ }
707
+
708
+ // Stage all changes in the worktree
709
+ const addResult = runGit(["add", "-A"], worktreePath);
710
+ if (!addResult.ok) {
711
+ execLog(laneId, task.taskId, `post-task stage failed (non-fatal): ${addResult.stderr.slice(0, 200)}`);
712
+ return;
713
+ }
714
+
715
+ // Commit with task ID for traceability
716
+ const commitResult = runGit(
717
+ ["commit", "-m", `checkpoint: ${task.taskId} task artifacts (.DONE, STATUS.md)`],
718
+ worktreePath,
719
+ );
720
+ if (!commitResult.ok) {
721
+ // "nothing to commit" is not an error — worker may have already committed
722
+ if (!commitResult.stderr.includes("nothing to commit")) {
723
+ execLog(laneId, task.taskId, `post-task commit failed (non-fatal): ${commitResult.stderr.slice(0, 200)}`);
724
+ }
725
+ return;
726
+ }
727
+
728
+ execLog(laneId, task.taskId, `committed task artifacts to lane branch`, {
729
+ commit: commitResult.stdout.trim().split("\n")[0],
730
+ });
731
+ }
732
+
733
+
597
734
  /**
598
735
  * Execute all tasks in a lane sequentially.
599
736
  *
600
737
  * For each task in the lane (in order):
601
738
  * 1. Spawn a TMUX session with TASK_AUTOSTART pointing to the task's PROMPT.md
602
739
  * 2. Poll until the task completes (or fails)
603
- * 3. Record the outcome
604
- * 4. If the task failed, skip remaining tasks in the lane
740
+ * 3. Commit any uncommitted task artifacts (.DONE, STATUS.md) to the lane branch
741
+ * 4. Record the outcome
742
+ * 5. If the task failed, skip remaining tasks in the lane
605
743
  *
606
744
  * The lane reuses the same worktree and TMUX session name across tasks.
607
745
  * Each new task gets a fresh TMUX session (the previous one has exited).
@@ -680,6 +818,13 @@ export async function executeLane(
680
818
  doneFileFound: pollResult.doneFileFound,
681
819
  };
682
820
 
821
+ // After task succeeds, commit any uncommitted artifacts (.DONE, final
822
+ // STATUS.md update) to the lane branch so they survive the merge.
823
+ // The task-runner writes .DONE via writeFileSync but never commits it.
824
+ if (pollResult.status === "succeeded") {
825
+ commitTaskArtifacts(lane, task, laneId);
826
+ }
827
+
683
828
  // If task failed or was paused, skip remaining tasks
684
829
  if (pollResult.status === "failed" || pollResult.status === "stalled") {
685
830
  shouldSkipRemaining = true;
@@ -788,30 +933,12 @@ export function parseWorktreeStatusMd(
788
933
  worktreePath: string,
789
934
  repoRoot: string,
790
935
  ): { 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");
936
+ // Use canonical resolver for consistent path translation
937
+ const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot);
938
+ const statusPath = resolved.statusPath;
803
939
 
804
940
  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
- }
941
+ return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
815
942
  }
816
943
 
817
944
  let content: string;