taskplane 0.25.8 → 0.27.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,22 +13,13 @@ 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
+ import { resolvePointer, loadWorkspaceConfig } from "./workspace.ts";
18
19
 
19
20
  // ── Taskplane Package File Resolution ────────────────────────────────
20
21
  // getNpmGlobalRoot() and resolveTaskplanePackageFile() consolidated in path-resolver.ts (TP-157)
21
22
 
22
- // ── Task Runner Extension Path Resolution ────────────────────────────
23
-
24
- /**
25
- * Find the task-runner extension path for lane sessions.
26
- * @see resolveTaskplanePackageFile for resolution order
27
- */
28
- function resolveTaskRunnerExtensionPath(repoRoot: string): string {
29
- return resolveTaskplanePackageFile(repoRoot, join("extensions", "task-runner.ts"));
30
- }
31
-
32
23
  // ── RPC Wrapper Path Resolution ──────────────────────────────────────
33
24
 
34
25
  /**
@@ -50,7 +41,7 @@ function resolveTaskRunnerExtensionPath(repoRoot: string): string {
50
41
  /**
51
42
  * Structured log helper for lane execution.
52
43
  *
53
- * All execution logs go to stderr (same pattern as task-runner.ts).
44
+ * All execution logs go to stderr.
54
45
  * Format: [orch] {laneId}/{taskId}: {message}
55
46
  * Correlation fields: batchId, laneId, taskId, sessionName.
56
47
  * No PII — only IDs and paths.
@@ -378,9 +369,8 @@ export function resolveCanonicalTaskPaths(
378
369
 
379
370
  if (isWorkspaceMode) {
380
371
  // Workspace mode: use worktree-relative path when the task folder is
381
- // inside the lane's repo (same logic as TASK_AUTOSTART resolution).
382
- // The worker writes .DONE and STATUS.md in the worktree, so the engine
383
- // must look there too.
372
+ // inside the lane's repo. The worker writes .DONE and STATUS.md in
373
+ // the worktree, so the engine must look there too.
384
374
  if (folderNorm.startsWith(repoRootNorm + "/")) {
385
375
  const relPath = folderNorm.slice(repoRootNorm.length + 1);
386
376
  resolvedFolder = join(worktreePath, relPath);
@@ -1392,7 +1382,7 @@ export function computeTransitiveDependents(
1392
1382
  *
1393
1383
  * Git worktrees only contain tracked (committed) files. If a user creates
1394
1384
  * task folders (PROMPT.md, STATUS.md) but doesn't commit them, the worktree
1395
- * won't have those files and TASK_AUTOSTART will fail with "file not found".
1385
+ * won't have those files and the worker will fail with "file not found".
1396
1386
  *
1397
1387
  * This function checks each wave task's folder for untracked or modified files,
1398
1388
  * stages them, and creates a commit on the current branch. This must run BEFORE
@@ -1446,6 +1436,124 @@ export function ensureTaskFilesCommitted(
1446
1436
 
1447
1437
  if (foldersToStage.length === 0) return;
1448
1438
 
1439
+ // TP-169: When an orch branch is provided, commit task files directly on
1440
+ // the orch branch using a temporary git index file. This avoids polluting
1441
+ // the repo's current branch (e.g. main) with orchestrator-internal staging
1442
+ // commits, maintaining proper branch isolation in workspace mode.
1443
+ //
1444
+ // Approach:
1445
+ // 1. Read the orch branch's tree into a temporary index
1446
+ // 2. Add new/modified task files to the temporary index
1447
+ // 3. Write the combined tree
1448
+ // 4. Create a commit on the orch branch
1449
+ // 5. Update the orch branch ref
1450
+ // 6. Clean up the temporary index
1451
+ //
1452
+ // Fallback: if orch branch plumbing fails or orchBranch is not provided,
1453
+ // fall back to the legacy path of committing on HEAD.
1454
+ if (orchBranch) {
1455
+ const orchTipRes = runGit(["rev-parse", `refs/heads/${orchBranch}`], repoRoot);
1456
+ if (orchTipRes.ok) {
1457
+ const orchTip = orchTipRes.stdout.trim();
1458
+ const tmpIdx = join(repoRoot, ".git", `tmp-staging-idx-wave-${waveIndex}`);
1459
+
1460
+ try {
1461
+ // Read orch branch tree into temporary index
1462
+ const readTreeRes = runGitWithEnv(
1463
+ ["read-tree", orchTip],
1464
+ repoRoot,
1465
+ { GIT_INDEX_FILE: tmpIdx },
1466
+ );
1467
+ if (!readTreeRes.ok) {
1468
+ execLog("wave", `W${waveIndex}`, `orch branch staging: read-tree failed, falling back to HEAD commit`, {
1469
+ error: readTreeRes.stderr,
1470
+ });
1471
+ // Fall through to legacy path
1472
+ } else {
1473
+ // Add task files to temporary index
1474
+ let addFailed = false;
1475
+ for (const folder of foldersToStage) {
1476
+ const addRes = runGitWithEnv(
1477
+ ["add", "--", folder],
1478
+ repoRoot,
1479
+ { GIT_INDEX_FILE: tmpIdx },
1480
+ );
1481
+ if (!addRes.ok) {
1482
+ execLog("wave", `W${waveIndex}`, `orch branch staging: git add failed for ${folder}, falling back`, {
1483
+ error: addRes.stderr,
1484
+ });
1485
+ addFailed = true;
1486
+ break;
1487
+ }
1488
+ }
1489
+
1490
+ if (!addFailed) {
1491
+ // Write tree from temporary index
1492
+ const writeTreeRes = runGitWithEnv(
1493
+ ["write-tree"],
1494
+ repoRoot,
1495
+ { GIT_INDEX_FILE: tmpIdx },
1496
+ );
1497
+
1498
+ if (writeTreeRes.ok) {
1499
+ const tree = writeTreeRes.stdout.trim();
1500
+ const taskIds = foldersToStage.map(f => f.split("/").pop() || f).join(", ");
1501
+ const commitMsg = `chore: stage task files for orchestrator wave ${waveIndex} (${taskIds})`;
1502
+
1503
+ // Create commit directly on orch branch
1504
+ const commitTreeRes = runGit(
1505
+ ["commit-tree", tree, "-p", orchTip, "-m", commitMsg],
1506
+ repoRoot,
1507
+ );
1508
+
1509
+ if (commitTreeRes.ok) {
1510
+ const newCommit = commitTreeRes.stdout.trim();
1511
+ const refUpdateRes = runGit(
1512
+ ["update-ref", `refs/heads/${orchBranch}`, newCommit, orchTip],
1513
+ repoRoot,
1514
+ );
1515
+
1516
+ if (refUpdateRes.ok) {
1517
+ execLog("wave", `W${waveIndex}`, `committed ${foldersToStage.length} task folder(s) directly on orch branch`, {
1518
+ orchBranch,
1519
+ folders: foldersToStage,
1520
+ from: orchTip.slice(0, 8),
1521
+ to: newCommit.slice(0, 8),
1522
+ });
1523
+ // Clean up temp index and return — no need for legacy path
1524
+ try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1525
+ return;
1526
+ }
1527
+ execLog("wave", `W${waveIndex}`, `orch branch staging: ref update failed, falling back`, {
1528
+ error: refUpdateRes.stderr,
1529
+ });
1530
+ } else {
1531
+ execLog("wave", `W${waveIndex}`, `orch branch staging: commit-tree failed, falling back`, {
1532
+ error: commitTreeRes.stderr,
1533
+ });
1534
+ }
1535
+ } else {
1536
+ execLog("wave", `W${waveIndex}`, `orch branch staging: write-tree failed, falling back`, {
1537
+ error: writeTreeRes.stderr,
1538
+ });
1539
+ }
1540
+ }
1541
+ }
1542
+ } catch (err: unknown) {
1543
+ execLog("wave", `W${waveIndex}`, `orch branch staging: unexpected error, falling back to HEAD commit`, {
1544
+ error: err instanceof Error ? err.message : String(err),
1545
+ });
1546
+ } finally {
1547
+ // Always clean up temp index
1548
+ try { unlinkSync(tmpIdx); } catch { /* best effort */ }
1549
+ }
1550
+ }
1551
+ }
1552
+
1553
+ // Legacy fallback: commit on HEAD and sync orch branch.
1554
+ // This path is used when orchBranch is not provided, or when the
1555
+ // plumbing-based approach above failed.
1556
+
1449
1557
  // Stage only the task folders
1450
1558
  for (const folder of foldersToStage) {
1451
1559
  const addResult = runGit(["add", "--", folder], repoRoot);
@@ -1482,15 +1590,6 @@ export function ensureTaskFilesCommitted(
1482
1590
  // Fast-forward (or merge) the orch branch to include the staging commit so
1483
1591
  // that worktrees—which branch from orchBranch—see the new task files and
1484
1592
  // workers can find their PROMPT.md / STATUS.md without an ENOENT crash.
1485
- //
1486
- // The orch branch was created from baseBranch before executeWave runs, so in
1487
- // wave 1 it is always an ancestor of HEAD and a plain fast-forward applies.
1488
- // In wave 2+ the orch branch may have advanced due to prior wave merges
1489
- // (commits from worker worktrees merged back). In that case we create a merge
1490
- // commit so the task files become visible without rewinding any wave history.
1491
- //
1492
- // Failure here is non-fatal: the commit already succeeded; if the ref update
1493
- // fails the subsequent worktree allocation will produce a clear error.
1494
1593
  if (orchBranch) {
1495
1594
  try {
1496
1595
  const headRes = runGit(["rev-parse", "HEAD"], repoRoot);
@@ -1500,16 +1599,12 @@ export function ensureTaskFilesCommitted(
1500
1599
  const newHead = headRes.stdout.trim();
1501
1600
  const orchTip = orchTipRes.stdout.trim();
1502
1601
 
1503
- // Check whether the orch branch tip is an ancestor of the new HEAD
1504
- // (i.e., a fast-forward is safe and sufficient).
1505
1602
  const ancestorCheck = runGit(
1506
1603
  ["merge-base", "--is-ancestor", orchTip, newHead],
1507
1604
  repoRoot,
1508
1605
  );
1509
1606
 
1510
1607
  if (ancestorCheck.ok) {
1511
- // FF case: orch branch is behind HEAD — move it forward.
1512
- // Expected-old-sha semantics guard against concurrent ref moves.
1513
1608
  const ffResult = runGit(
1514
1609
  ["update-ref", `refs/heads/${orchBranch}`, newHead, orchTip],
1515
1610
  repoRoot,
@@ -1527,29 +1622,16 @@ export function ensureTaskFilesCommitted(
1527
1622
  });
1528
1623
  }
1529
1624
  } else {
1530
- // Non-FF case: orch branch has advanced due to prior wave merges.
1531
- // Create a merge commit so the new task files become visible in
1532
- // worktrees without discarding any accumulated wave history.
1533
- // Requires git ≥ 2.38 for `merge-tree --write-tree`.
1534
1625
  const mergeTreeRes = runGit(
1535
1626
  ["merge-tree", "--write-tree", orchTip, newHead],
1536
1627
  repoRoot,
1537
1628
  );
1538
1629
  if (mergeTreeRes.ok) {
1539
- // First line of stdout is the merged tree SHA.
1540
- // git merge-tree --write-tree exits 0 on clean merge, non-zero on conflicts.
1541
- // Since it exited 0, the tree should be conflict-free, but validate
1542
- // the SHA looks like a valid 40-hex OID before using it.
1543
1630
  const mergedTree = mergeTreeRes.stdout.trim().split("\n")[0];
1544
- if (!/^[0-9a-f]{40}$/i.test(mergedTree)) {
1545
- execLog("wave", `W${waveIndex}`, `warning: merge-tree returned unexpected output (non-fatal)`, {
1546
- orchBranch,
1547
- output: mergedTree.slice(0, 60),
1548
- });
1549
- } else {
1550
- const mergeCommitMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
1631
+ if (/^[0-9a-f]{40}$/i.test(mergedTree)) {
1632
+ const mergeMsg = `merge: include staged task files for wave ${waveIndex} into orch branch`;
1551
1633
  const commitTreeRes = runGit(
1552
- ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeCommitMsg],
1634
+ ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeMsg],
1553
1635
  repoRoot,
1554
1636
  );
1555
1637
  if (commitTreeRes.ok) {
@@ -1561,28 +1643,11 @@ export function ensureTaskFilesCommitted(
1561
1643
  if (refUpdateRes.ok) {
1562
1644
  execLog("wave", `W${waveIndex}`, `merged staging commit into orch branch (non-FF wave)`, {
1563
1645
  orchBranch,
1564
- orchTip: orchTip.slice(0, 8),
1565
- newHead: newHead.slice(0, 8),
1566
1646
  mergeCommit: mergeCommitSha.slice(0, 8),
1567
1647
  });
1568
- } else {
1569
- execLog("wave", `W${waveIndex}`, `warning: failed to update orch branch ref after merge-tree (non-fatal)`, {
1570
- orchBranch,
1571
- error: refUpdateRes.stderr,
1572
- });
1573
1648
  }
1574
- } else {
1575
- execLog("wave", `W${waveIndex}`, `warning: failed to create merge commit for orch branch (non-fatal)`, {
1576
- orchBranch,
1577
- error: commitTreeRes.stderr,
1578
- });
1579
1649
  }
1580
- } // end valid tree SHA
1581
- } else {
1582
- execLog("wave", `W${waveIndex}`, `warning: failed to compute merge-tree for orch branch (non-fatal; requires git ≥ 2.38)`, {
1583
- orchBranch,
1584
- error: mergeTreeRes.stderr,
1585
- });
1650
+ }
1586
1651
  }
1587
1652
  }
1588
1653
  }
@@ -1640,7 +1705,7 @@ export function ensureTaskFilesCommitted(
1640
1705
  /**
1641
1706
  * Runtime backend selector for lane execution.
1642
1707
  *
1643
- * - `"legacy"`: Session-backed path (spawnLaneSession → task-runner TASK_AUTOSTART)
1708
+ * - `"legacy"`: Session-backed path (spawnLaneSession, deprecated)
1644
1709
  * - `"v2"`: Direct-child path (lane-runner → agent-host → pi --mode rpc)
1645
1710
  *
1646
1711
  * @since TP-105
@@ -1677,7 +1742,7 @@ export async function executeWave(
1677
1742
  // ── Stage 0: Ensure task files are committed ────────────────
1678
1743
  // Task folders may contain untracked files (PROMPT.md, STATUS.md) that
1679
1744
  // won't appear in worktrees unless committed. Stage and commit them now,
1680
- // before worktree creation, so workers can find their TASK_AUTOSTART paths.
1745
+ // before worktree creation, so workers can find their task files.
1681
1746
  // Pass orchBranch so the staging commit is reflected in the orch branch
1682
1747
  // before worktrees are allocated from it.
1683
1748
  try {
@@ -2103,8 +2168,23 @@ export function buildExecutionUnit(
2103
2168
  repoRoot: string,
2104
2169
  isWorkspaceMode?: boolean,
2105
2170
  ): ExecutionUnit {
2171
+ // TP-169: Guard against missing taskFolder. This can happen when
2172
+ // reconstructAllocatedLanes creates task stubs from persisted state
2173
+ // where taskFolder enrichment failed (e.g., dynamically-expanded
2174
+ // segments whose persisted records had empty taskFolder).
2175
+ const taskFolder = task.task?.taskFolder;
2176
+ if (!taskFolder) {
2177
+ throw new ExecutionError(
2178
+ "EXEC_MISSING_TASK_FOLDER",
2179
+ `Cannot build execution unit for task ${task.taskId}: taskFolder is ${taskFolder === "" ? "empty" : "undefined"}. ` +
2180
+ `This typically means the task's persisted record was not enriched with discovery data. ` +
2181
+ `Re-run discovery or check that the task exists in the task area.`,
2182
+ "execution",
2183
+ task.taskId,
2184
+ );
2185
+ }
2106
2186
  const resolved = resolveCanonicalTaskPaths(
2107
- task.task.taskFolder,
2187
+ taskFolder,
2108
2188
  lane.worktreePath,
2109
2189
  repoRoot,
2110
2190
  isWorkspaceMode,
@@ -2257,6 +2337,110 @@ function loadLocalAgentPrompt(stateRoot: string, agentName: string): string {
2257
2337
  return "";
2258
2338
  }
2259
2339
 
2340
+ // ── Agent Definition Loading ─────────────────────────────────────────
2341
+
2342
+ /** Track whether an agent pointer warning has been logged this session (log once). */
2343
+ let _execPointerWarningLogged = false;
2344
+
2345
+ /**
2346
+ * Reset agent pointer warning state for testing.
2347
+ * @since TP-161
2348
+ */
2349
+ export function resetPointerWarning(): void {
2350
+ _execPointerWarningLogged = false;
2351
+ }
2352
+
2353
+ /**
2354
+ * Resolve agent files using the workspace pointer (workspace mode only).
2355
+ * Returns the agentRoot from the pointer, or null in repo mode / on failure.
2356
+ */
2357
+ function resolveAgentPointerRoot(): string | null {
2358
+ const wsRoot = process.env.TASKPLANE_WORKSPACE_ROOT;
2359
+ if (!wsRoot) return null;
2360
+ try {
2361
+ const wsConfig = loadWorkspaceConfig(wsRoot);
2362
+ const result = resolvePointer(wsRoot, wsConfig);
2363
+ if (result?.warning && !_execPointerWarningLogged) {
2364
+ _execPointerWarningLogged = true;
2365
+ console.error(`[execution] pointer: ${result.warning}`);
2366
+ }
2367
+ return result?.agentRoot ?? null;
2368
+ } catch {
2369
+ return null;
2370
+ }
2371
+ }
2372
+
2373
+ /**
2374
+ * Load a complete agent definition (systemPrompt + tools + model) by name.
2375
+ *
2376
+ * Resolution order:
2377
+ * 1. cwd/.pi/agents/<name>.md
2378
+ * 2. cwd/agents/<name>.md
2379
+ * 3. pointer.agentRoot/<name>.md (workspace mode only)
2380
+ * 4. Base package templates/agents/<name>.md
2381
+ *
2382
+ * If a local file has `standalone: true` in frontmatter, it is used as-is
2383
+ * (no base composition). Otherwise, base + local are composed.
2384
+ *
2385
+ * @param cwd - Working directory (project root) to search for local agent files
2386
+ * @param name - Agent name (e.g., "task-worker", "task-reviewer")
2387
+ * @returns Composed agent definition, or null if no base and no local file found
2388
+ * @since TP-161
2389
+ */
2390
+ export function loadAgentDef(cwd: string, name: string): { systemPrompt: string; tools: string; model: string } | null {
2391
+ const localPaths = [
2392
+ join(cwd, ".pi", "agents", `${name}.md`),
2393
+ join(cwd, "agents", `${name}.md`),
2394
+ ];
2395
+
2396
+ // In workspace mode, add pointer-resolved agent root as fallback
2397
+ const agentRoot = resolveAgentPointerRoot();
2398
+ if (agentRoot) {
2399
+ localPaths.push(join(agentRoot, `${name}.md`));
2400
+ }
2401
+
2402
+ // Load base from package
2403
+ let baseDef: { fm: Record<string, string>; body: string } | null = null;
2404
+ try {
2405
+ const basePath = resolveTaskplaneAgentTemplate(name);
2406
+ if (existsSync(basePath)) {
2407
+ baseDef = parseAgentFile(basePath);
2408
+ }
2409
+ } catch { /* fall through */ }
2410
+
2411
+ // Load local override (first found wins)
2412
+ let localDef: { fm: Record<string, string>; body: string } | null = null;
2413
+ for (const p of localPaths) {
2414
+ localDef = parseAgentFile(p);
2415
+ if (localDef) break;
2416
+ }
2417
+
2418
+ // No base and no local → null
2419
+ if (!baseDef && !localDef) return null;
2420
+
2421
+ // Local with standalone: true → use local as-is, ignore base
2422
+ if (localDef?.fm.standalone === "true") {
2423
+ return {
2424
+ systemPrompt: localDef.body,
2425
+ tools: localDef.fm.tools || "read,grep,find,ls",
2426
+ model: localDef.fm.model || "",
2427
+ };
2428
+ }
2429
+
2430
+ // Compose base + local
2431
+ const basePrompt = baseDef?.body || "";
2432
+ const localPrompt = localDef?.body || "";
2433
+ const composedPrompt = localPrompt
2434
+ ? basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt
2435
+ : basePrompt;
2436
+
2437
+ // Local frontmatter overrides base (tools, model)
2438
+ const tools = localDef?.fm.tools || baseDef?.fm.tools || "read,grep,find,ls";
2439
+ const model = localDef?.fm.model || baseDef?.fm.model || "";
2440
+
2441
+ return { systemPrompt: composedPrompt.trim(), tools, model };
2442
+ }
2443
+
2260
2444
  export function resolveRuntimeStateRoot(
2261
2445
  repoRoot: string,
2262
2446
  workspaceRoot?: string,