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.
- package/bin/taskplane.mjs +317 -5
- package/extensions/taskplane/abort.ts +461 -466
- package/extensions/taskplane/config.ts +17 -12
- package/extensions/taskplane/discovery.ts +168 -32
- package/extensions/taskplane/engine.ts +22 -12
- package/extensions/taskplane/execution.ts +175 -48
- package/extensions/taskplane/extension.ts +780 -693
- package/extensions/taskplane/index.ts +23 -22
- package/extensions/taskplane/messages.ts +146 -134
- package/extensions/taskplane/resume.ts +9 -3
- package/extensions/taskplane/types.ts +238 -1
- package/extensions/taskplane/workspace.ts +382 -0
- package/extensions/taskplane/worktree.ts +107 -6
- package/package.json +1 -1
|
@@ -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
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
306
|
-
*
|
|
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
|
-
*
|
|
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
|
|
341
|
+
* @returns Resolved paths for task folder, .DONE, and STATUS.md
|
|
312
342
|
*/
|
|
313
|
-
export function
|
|
343
|
+
export function resolveCanonicalTaskPaths(
|
|
314
344
|
taskFolder: string,
|
|
315
345
|
worktreePath: string,
|
|
316
346
|
repoRoot: string,
|
|
317
|
-
):
|
|
347
|
+
): ResolvedTaskPaths {
|
|
318
348
|
const repoRootNorm = resolve(repoRoot).replace(/\\/g, "/");
|
|
319
349
|
const folderNorm = resolve(taskFolder).replace(/\\/g, "/");
|
|
320
350
|
|
|
321
|
-
let
|
|
351
|
+
let resolvedFolder: string;
|
|
352
|
+
|
|
322
353
|
if (folderNorm.startsWith(repoRootNorm + "/")) {
|
|
323
|
-
|
|
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
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
//
|
|
376
|
+
// Archive fallback: worker may have archived the task folder during the
|
|
332
377
|
// "Documentation & Delivery" step, moving it under `.../archive/TASK-ID/`.
|
|
333
|
-
|
|
334
|
-
const parts =
|
|
335
|
-
const taskDirName = parts[parts.length - 1];
|
|
336
|
-
const
|
|
337
|
-
const
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
471
|
-
const
|
|
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.
|
|
604
|
-
* 4.
|
|
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
|
-
//
|
|
792
|
-
const
|
|
793
|
-
const
|
|
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
|
-
|
|
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;
|