taskplane 0.26.1 → 0.28.0

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.
@@ -13,7 +13,7 @@ import { resolvePacketPaths, buildRuntimeAgentId } from "./types.ts";
13
13
  import { readRegistrySnapshot, readLaneSnapshot, isTerminalStatus, isProcessAlive, detectOrphans, markOrphansCrashed, buildRegistrySnapshot, writeRegistrySnapshot } from "./process-registry.ts";
14
14
  import { allocateLanes } from "./waves.ts";
15
15
  import { resolveOperatorId } from "./naming.ts";
16
- import { runGit } from "./git.ts";
16
+ import { runGit, runGitWithEnv } from "./git.ts";
17
17
  import { resolveTaskplanePackageFile, resolveTaskplaneAgentTemplate } from "./path-resolver.ts";
18
18
  import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts";
19
19
 
@@ -662,6 +662,27 @@ export function parseWorktreeStatusMd(
662
662
  *
663
663
  * @since TP-070
664
664
  */
665
+
666
+ /**
667
+ * Parse STATUS.md directly from a known absolute path.
668
+ * Unlike parseWorktreeStatusMdAsync, this does NOT re-resolve the path —
669
+ * it reads exactly the file you point it to. Use this when the caller
670
+ * already has the authoritative statusPath (e.g., from buildExecutionUnit).
671
+ *
672
+ * @since TP-501
673
+ */
674
+ export async function parseStatusMdAtPath(
675
+ statusPath: string,
676
+ ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
677
+ return parseStatusMdContent(statusPath);
678
+ }
679
+
680
+ /**
681
+ * Parse STATUS.md by resolving the path from taskFolder + worktree context.
682
+ * Use parseStatusMdAtPath instead when the caller already has the authoritative path.
683
+ *
684
+ * @since TP-070
685
+ */
665
686
  export async function parseWorktreeStatusMdAsync(
666
687
  taskFolder: string,
667
688
  worktreePath: string,
@@ -669,8 +690,13 @@ export async function parseWorktreeStatusMdAsync(
669
690
  isWorkspaceMode?: boolean,
670
691
  ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
671
692
  const resolved = resolveCanonicalTaskPaths(taskFolder, worktreePath, repoRoot, isWorkspaceMode);
672
- const statusPath = resolved.statusPath;
693
+ return parseStatusMdContent(resolved.statusPath);
694
+ }
673
695
 
696
+ /** Shared STATUS.md content parser — reads and parses from a known path. Handles file-not-found. */
697
+ async function parseStatusMdContent(
698
+ statusPath: string,
699
+ ): Promise<{ parsed: ParsedWorktreeStatus | null; error: string | null }> {
674
700
  if (!(await fileExistsAsync(statusPath))) {
675
701
  return { parsed: null, error: `STATUS.md not found at ${statusPath}` };
676
702
  }
@@ -1188,7 +1214,7 @@ export async function monitorLanes(
1188
1214
  const unit = buildExecutionUnit(lane, task, repoRoot, isWorkspaceMode);
1189
1215
  const donePath = unit.packet.donePath;
1190
1216
  const statusPath = unit.packet.statusPath;
1191
- const statusResult = await parseWorktreeStatusMdAsync(dirname(statusPath), lane.worktreePath, repoRoot, false);
1217
+ const statusResult = await parseStatusMdAtPath(statusPath);
1192
1218
 
1193
1219
  const snapshot = await resolveTaskMonitorState(
1194
1220
  task.taskId,
@@ -1436,6 +1462,124 @@ export function ensureTaskFilesCommitted(
1436
1462
 
1437
1463
  if (foldersToStage.length === 0) return;
1438
1464
 
1465
+ // TP-169: When an orch branch is provided, commit task files directly on
1466
+ // the orch branch using a temporary git index file. This avoids polluting
1467
+ // the repo's current branch (e.g. main) with orchestrator-internal staging
1468
+ // commits, maintaining proper branch isolation in workspace mode.
1469
+ //
1470
+ // Approach:
1471
+ // 1. Read the orch branch's tree into a temporary index
1472
+ // 2. Add new/modified task files to the temporary index
1473
+ // 3. Write the combined tree
1474
+ // 4. Create a commit on the orch branch
1475
+ // 5. Update the orch branch ref
1476
+ // 6. Clean up the temporary index
1477
+ //
1478
+ // Fallback: if orch branch plumbing fails or orchBranch is not provided,
1479
+ // fall back to the legacy path of committing on HEAD.
1480
+ if (orchBranch) {
1481
+ const orchTipRes = runGit(["rev-parse", `refs/heads/${orchBranch}`], repoRoot);
1482
+ if (orchTipRes.ok) {
1483
+ const orchTip = orchTipRes.stdout.trim();
1484
+ const tmpIdx = join(repoRoot, ".git", `tmp-staging-idx-wave-${waveIndex}`);
1485
+
1486
+ try {
1487
+ // Read orch branch tree into temporary index
1488
+ const readTreeRes = runGitWithEnv(
1489
+ ["read-tree", orchTip],
1490
+ repoRoot,
1491
+ { GIT_INDEX_FILE: tmpIdx },
1492
+ );
1493
+ if (!readTreeRes.ok) {
1494
+ execLog("wave", `W${waveIndex}`, `orch branch staging: read-tree failed, falling back to HEAD commit`, {
1495
+ error: readTreeRes.stderr,
1496
+ });
1497
+ // Fall through to legacy path
1498
+ } else {
1499
+ // Add task files to temporary index
1500
+ let addFailed = false;
1501
+ for (const folder of foldersToStage) {
1502
+ const addRes = runGitWithEnv(
1503
+ ["add", "--", folder],
1504
+ repoRoot,
1505
+ { GIT_INDEX_FILE: tmpIdx },
1506
+ );
1507
+ if (!addRes.ok) {
1508
+ execLog("wave", `W${waveIndex}`, `orch branch staging: git add failed for ${folder}, falling back`, {
1509
+ error: addRes.stderr,
1510
+ });
1511
+ addFailed = true;
1512
+ break;
1513
+ }
1514
+ }
1515
+
1516
+ if (!addFailed) {
1517
+ // Write tree from temporary index
1518
+ const writeTreeRes = runGitWithEnv(
1519
+ ["write-tree"],
1520
+ repoRoot,
1521
+ { GIT_INDEX_FILE: tmpIdx },
1522
+ );
1523
+
1524
+ if (writeTreeRes.ok) {
1525
+ const tree = writeTreeRes.stdout.trim();
1526
+ const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", ");
1527
+ const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`;
1528
+
1529
+ // Create commit directly on orch branch
1530
+ const commitTreeRes = runGit(
1531
+ ["commit-tree", tree, "-p", orchTip, "-m", commitMsg],
1532
+ repoRoot,
1533
+ );
1534
+
1535
+ if (commitTreeRes.ok) {
1536
+ const newCommit = commitTreeRes.stdout.trim();
1537
+ const refUpdateRes = runGit(
1538
+ ["update-ref", `refs/heads/${orchBranch}`, newCommit, orchTip],
1539
+ repoRoot,
1540
+ );
1541
+
1542
+ if (refUpdateRes.ok) {
1543
+ execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) directly on orch branch`, {
1544
+ orchBranch,
1545
+ folders: foldersToStage,
1546
+ from: orchTip.slice(0, 8),
1547
+ to: newCommit.slice(0, 8),
1548
+ });
1549
+ // Clean up temp index and return — no need for legacy path
1550
+ try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1551
+ return;
1552
+ }
1553
+ execLog("wave", `W${waveIndex}`, `orch branch staging: ref update failed, falling back`, {
1554
+ error: refUpdateRes.stderr,
1555
+ });
1556
+ } else {
1557
+ execLog("wave", `W${waveIndex}`, `orch branch staging: commit-tree failed, falling back`, {
1558
+ error: commitTreeRes.stderr,
1559
+ });
1560
+ }
1561
+ } else {
1562
+ execLog("wave", `W${waveIndex}`, `orch branch staging: write-tree failed, falling back`, {
1563
+ error: writeTreeRes.stderr,
1564
+ });
1565
+ }
1566
+ }
1567
+ }
1568
+ } catch (err: unknown) {
1569
+ execLog("wave", `W${waveIndex}`, `orch branch staging: unexpected error, falling back to HEAD commit`, {
1570
+ error: err instanceof Error ? err.message : String(err),
1571
+ });
1572
+ } finally {
1573
+ // Always clean up temp index
1574
+ try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1575
+ }
1576
+ }
1577
+ }
1578
+
1579
+ // Legacy fallback: commit on HEAD and sync orch branch.
1580
+ // This path is used when orchBranch is not provided, or when the
1581
+ // plumbing-based approach above failed.
1582
+
1439
1583
  // Stage only the task folders
1440
1584
  for (const folder of foldersToStage) {
1441
1585
  const addResult = runGit(["add", "--", folder], repoRoot);
@@ -1472,15 +1616,6 @@ export function ensureTaskFilesCommitted(
1472
1616
  // Fast-forward (or merge) the orch branch to include the staging commit so
1473
1617
  // that worktrees—which branch from orchBranch—see the new task files and
1474
1618
  // workers can find their PROMPT.md / STATUS.md without an ENOENT crash.
1475
- //
1476
- // The orch branch was created from baseBranch before executeWave runs, so in
1477
- // wave 1 it is always an ancestor of HEAD and a plain fast-forward applies.
1478
- // In wave 2+ the orch branch may have advanced due to prior wave merges
1479
- // (commits from worker worktrees merged back). In that case we create a merge
1480
- // commit so the task files become visible without rewinding any wave history.
1481
- //
1482
- // Failure here is non-fatal: the commit already succeeded; if the ref update
1483
- // fails the subsequent worktree allocation will produce a clear error.
1484
1619
  if (orchBranch) {
1485
1620
  try {
1486
1621
  const headRes = runGit(["rev-parse", "HEAD"], repoRoot);
@@ -1490,16 +1625,12 @@ export function ensureTaskFilesCommitted(
1490
1625
  const newHead = headRes.stdout.trim();
1491
1626
  const orchTip = orchTipRes.stdout.trim();
1492
1627
 
1493
- // Check whether the orch branch tip is an ancestor of the new HEAD
1494
- // (i.e., a fast-forward is safe and sufficient).
1495
1628
  const ancestorCheck = runGit(
1496
1629
  ["merge-base", "--is-ancestor", orchTip, newHead],
1497
1630
  repoRoot,
1498
1631
  );
1499
1632
 
1500
1633
  if (ancestorCheck.ok) {
1501
- // FF case: orch branch is behind HEAD — move it forward.
1502
- // Expected-old-sha semantics guard against concurrent ref moves.
1503
1634
  const ffResult = runGit(
1504
1635
  ["update-ref", `refs/heads/${orchBranch}`, newHead, orchTip],
1505
1636
  repoRoot,
@@ -1517,29 +1648,16 @@ export function ensureTaskFilesCommitted(
1517
1648
  });
1518
1649
  }
1519
1650
  } else {
1520
- // Non-FF case: orch branch has advanced due to prior wave merges.
1521
- // Create a merge commit so the new task files become visible in
1522
- // worktrees without discarding any accumulated wave history.
1523
- // Requires git ≥ 2.38 for `merge-tree --write-tree`.
1524
1651
  const mergeTreeRes = runGit(
1525
1652
  ["merge-tree", "--write-tree", orchTip, newHead],
1526
1653
  repoRoot,
1527
1654
  );
1528
1655
  if (mergeTreeRes.ok) {
1529
- // First line of stdout is the merged tree SHA.
1530
- // git merge-tree --write-tree exits 0 on clean merge, non-zero on conflicts.
1531
- // Since it exited 0, the tree should be conflict-free, but validate
1532
- // the SHA looks like a valid 40-hex OID before using it.
1533
1656
  const mergedTree = mergeTreeRes.stdout.trim().split("\n")[0];
1534
- if (!/^[0-9a-f]{40}$/i.test(mergedTree)) {
1535
- execLog("wave", `W${waveIndex}`, `warning: merge-tree returned unexpected output (non-fatal)`, {
1536
- orchBranch,
1537
- output: mergedTree.slice(0, 60),
1538
- });
1539
- } else {
1540
- const mergeCommitMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
1657
+ if (/^[0-9a-f]{40}$/i.test(mergedTree)) {
1658
+ const mergeMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
1541
1659
  const commitTreeRes = runGit(
1542
- ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeCommitMsg],
1660
+ ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeMsg],
1543
1661
  repoRoot,
1544
1662
  );
1545
1663
  if (commitTreeRes.ok) {
@@ -1551,28 +1669,11 @@ export function ensureTaskFilesCommitted(
1551
1669
  if (refUpdateRes.ok) {
1552
1670
  execLog("wave", `W${waveIndex}`, `merged staging commit into orch branch (non-FF wave)`, {
1553
1671
  orchBranch,
1554
- orchTip: orchTip.slice(0, 8),
1555
- newHead: newHead.slice(0, 8),
1556
1672
  mergeCommit: mergeCommitSha.slice(0, 8),
1557
1673
  });
1558
- } else {
1559
- execLog("wave", `W${waveIndex}`, `warning: failed to update orch branch ref after merge-tree (non-fatal)`, {
1560
- orchBranch,
1561
- error: refUpdateRes.stderr,
1562
- });
1563
1674
  }
1564
- } else {
1565
- execLog("wave", `W${waveIndex}`, `warning: failed to create merge commit for orch branch (non-fatal)`, {
1566
- orchBranch,
1567
- error: commitTreeRes.stderr,
1568
- });
1569
1675
  }
1570
- } // end valid tree SHA
1571
- } else {
1572
- execLog("wave", `W${waveIndex}`, `warning: failed to compute merge-tree for orch branch (non-fatal; requires git ≥ 2.38)`, {
1573
- orchBranch,
1574
- error: mergeTreeRes.stderr,
1575
- });
1676
+ }
1576
1677
  }
1577
1678
  }
1578
1679
  }
@@ -2093,8 +2194,23 @@ export function buildExecutionUnit(
2093
2194
  repoRoot: string,
2094
2195
  isWorkspaceMode?: boolean,
2095
2196
  ): ExecutionUnit {
2197
+ // TP-169: Guard against missing taskFolder. This can happen when
2198
+ // reconstructAllocatedLanes creates task stubs from persisted state
2199
+ // where taskFolder enrichment failed (e.g., dynamically-expanded
2200
+ // segments whose persisted records had empty taskFolder).
2201
+ const taskFolder = task.task?.taskFolder;
2202
+ if (!taskFolder) {
2203
+ throw new ExecutionError(
2204
+ "EXEC_MISSING_TASK_FOLDER",
2205
+ `Cannot build execution unit for task ${task.taskId}: taskFolder is ${taskFolder === "" ? "empty" : "undefined"}. ` +
2206
+ `This typically means the task's persisted record was not enriched with discovery data. ` +
2207
+ `Re-run discovery or check that the task exists in the task area.`,
2208
+ "execution",
2209
+ task.taskId,
2210
+ );
2211
+ }
2096
2212
  const resolved = resolveCanonicalTaskPaths(
2097
- task.task.taskFolder,
2213
+ taskFolder,
2098
2214
  lane.worktreePath,
2099
2215
  repoRoot,
2100
2216
  isWorkspaceMode,
@@ -2426,6 +2542,7 @@ export async function executeLaneV2(
2426
2542
  // rules: checkpoint discipline, STATUS.md resume algorithm, review_step instructions.
2427
2543
  // The local file (.pi/agents/task-worker.md) adds project-specific guidance.
2428
2544
  let workerSystemPrompt = "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2545
+ let workerSegmentPrompt = "";
2429
2546
  try {
2430
2547
  const basePrompt = loadBaseAgentPrompt("task-worker");
2431
2548
  const localPrompt = loadLocalAgentPrompt(stateRoot, "task-worker");
@@ -2436,6 +2553,9 @@ export async function executeLaneV2(
2436
2553
  } else if (localPrompt) {
2437
2554
  workerSystemPrompt = localPrompt;
2438
2555
  }
2556
+ // Load segment-scoped prompt overlay (appended when isSegmentScoped)
2557
+ const segPrompt = loadBaseAgentPrompt("task-worker-segment");
2558
+ if (segPrompt) workerSegmentPrompt = segPrompt;
2439
2559
  } catch { /* use default */ }
2440
2560
 
2441
2561
  execLog(laneId, "LANE", `starting Runtime V2 execution of ${lane.tasks.length} task(s)`, {
@@ -2482,6 +2602,7 @@ export async function executeLaneV2(
2482
2602
  workerTools: "read,write,edit,bash,grep,find,ls",
2483
2603
  workerThinking: "",
2484
2604
  workerSystemPrompt,
2605
+ workerSegmentPrompt,
2485
2606
  reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",
2486
2607
  reviewerThinking: extraEnvVars?.TASKPLANE_REVIEWER_THINKING || "",
2487
2608
  reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "",