taskplane 0.25.6 → 0.25.7

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.
@@ -79,7 +79,7 @@ function mergeV2LaneSnapshot(legacyLs, v2snap) {
79
79
  if (w) {
80
80
  // Map V2 agent status to legacy dashboard status strings
81
81
  if (w.status) {
82
- const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'error', timed_out: 'error', wrapping_up: 'running' };
82
+ const statusMap = { running: 'running', spawning: 'running', exited: 'done', crashed: 'error', killed: 'done', timed_out: 'error', wrapping_up: 'running' };
83
83
  base.workerStatus = statusMap[w.status] || w.status;
84
84
  }
85
85
  if (w.elapsedMs != null) base.workerElapsed = w.elapsedMs;
@@ -1062,9 +1062,15 @@ function renderAgentsPanel(registry) {
1062
1062
  let html = '<div class="agents-grid">';
1063
1063
 
1064
1064
  for (const agent of agents) {
1065
+ const isCrash = ['crashed', 'timed_out'].includes(agent.status);
1065
1066
  const isTerminal = ['exited', 'crashed', 'timed_out', 'killed'].includes(agent.status);
1066
- const statusClass = isTerminal ? 'agent-terminal' : 'agent-live';
1067
- const icon = isTerminal ? '\u{1F534}' : '\u{1F7E2}';
1067
+ const statusClass = isTerminal ? (isCrash ? 'agent-terminal agent-crashed' : 'agent-terminal') : 'agent-live';
1068
+ const icon = isCrash ? '\u{1F534}' : (isTerminal ? '\u26AA' : '\u{1F7E2}');
1069
+ // Display label: exited and killed both show as 'shutdown' — the mechanism is an
1070
+ // implementation detail. Only crashed/timed_out warrant a different label.
1071
+ const displayStatus = (agent.status === 'exited' || agent.status === 'killed') ? 'shutdown'
1072
+ : agent.status === 'timed_out' ? 'timed out'
1073
+ : agent.status;
1068
1074
  const elapsed = agent.startedAt ? Math.round((Date.now() - agent.startedAt) / 1000) : 0;
1069
1075
  const elapsedStr = elapsed > 0 ? formatDuration(elapsed * 1000) : '';
1070
1076
 
@@ -1074,7 +1080,7 @@ function renderAgentsPanel(registry) {
1074
1080
  html += `<span class="agent-badge">${escapeHtml(agent.role)}</span>`;
1075
1081
  if (agent.laneNumber != null) html += `<span class="agent-badge">lane ${agent.laneNumber}</span>`;
1076
1082
  if (agent.taskId) html += `<span class="agent-badge">${escapeHtml(agent.taskId)}</span>`;
1077
- html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(agent.status)}</span>`;
1083
+ html += `<span class="agent-badge agent-status-${agent.status}">${escapeHtml(displayStatus)}</span>`;
1078
1084
  if (elapsedStr && !isTerminal) html += `<span class="agent-badge">${elapsedStr}</span>`;
1079
1085
  html += `</div>`;
1080
1086
  html += `</div>`;
@@ -817,8 +817,9 @@ body {
817
817
  .worker-last-tool {
818
818
  color: var(--text-muted);
819
819
  max-width: 600px;
820
- overflow: hidden;
821
- text-overflow: ellipsis;
820
+ overflow-wrap: break-word;
821
+ word-break: break-all;
822
+ white-space: normal;
822
823
  }
823
824
 
824
825
  /* ─── Reviewer Sub-Row ─────────────────────────────────────────────── */
@@ -1774,10 +1775,10 @@ body {
1774
1775
  }
1775
1776
  .agent-status-running { color: var(--green); }
1776
1777
  .agent-status-spawning { color: var(--yellow); }
1777
- .agent-status-exited { color: var(--text-faint); }
1778
+ .agent-status-exited { color: var(--text-faint); } /* displayed as 'shutdown' */
1779
+ .agent-status-killed { color: var(--text-faint); } /* displayed as 'shutdown' */
1778
1780
  .agent-status-crashed { color: var(--red); }
1779
- .agent-status-killed { color: var(--red); }
1780
- .agent-status-timed_out { color: var(--yellow); }
1781
+ .agent-status-timed_out { color: var(--red); }
1781
1782
 
1782
1783
  /* TP-107: Messages Panel */
1783
1784
  .messages-list {
@@ -194,9 +194,21 @@ function parseStatusMd(taskFolder) {
194
194
  }
195
195
 
196
196
  function getActiveSessions() {
197
- // Runtime V2 no longer relies on external terminal multiplexers for liveness.
198
- // Dashboard session data is driven by runtime registry + persisted state.
199
- return [];
197
+ // Runtime V2: return active merger session names from the runtime registry
198
+ // so the dashboard merge pane can display live telemetry for running agents.
199
+ // Terminal statuses indicate the agent is no longer alive.
200
+ const TERMINAL_STATUSES = new Set(["exited", "killed", "crashed", "timed_out"]);
201
+ try {
202
+ const state = loadBatchState();
203
+ if (!state || !state.batchId) return [];
204
+ const registry = loadRuntimeRegistry(state.batchId);
205
+ if (!registry || !registry.agents) return [];
206
+ return Object.values(registry.agents)
207
+ .filter(a => a.role === "merger" && !TERMINAL_STATUSES.has(a.status))
208
+ .map(a => a.agentId);
209
+ } catch {
210
+ return [];
211
+ }
200
212
  }
201
213
 
202
214
  function checkDoneFile(taskFolder) {
@@ -406,6 +418,33 @@ function loadRuntimeLaneSnapshots(batchId) {
406
418
  return snapshots;
407
419
  }
408
420
 
421
+ /**
422
+ * Load Runtime V2 merge agent snapshots for the current batch.
423
+ *
424
+ * Reads all `merge-N.json` files from `.pi/runtime/{batchId}/lanes/`.
425
+ * Returns a map of mergeNumber (string) → snapshot data.
426
+ *
427
+ * Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
428
+ *
429
+ * @since TP-164
430
+ */
431
+ function loadRuntimeMergeSnapshots(batchId) {
432
+ if (!batchId) return {};
433
+ const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
434
+ const snapshots = {};
435
+ try {
436
+ if (!fs.existsSync(lanesDir)) return snapshots;
437
+ const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("merge-") && f.endsWith(".json"));
438
+ for (const file of files) {
439
+ try {
440
+ const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
441
+ if (data.mergeNumber != null) snapshots[data.mergeNumber] = data;
442
+ } catch { continue; }
443
+ }
444
+ } catch { /* dir missing */ }
445
+ return snapshots;
446
+ }
447
+
409
448
  /**
410
449
  * Load Runtime V2 agent events for a specific agent.
411
450
  * Returns the last N events from the agent's events.jsonl.
@@ -1077,6 +1116,46 @@ function buildDashboardState() {
1077
1116
  const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
1078
1117
  const mailboxData = loadMailboxData(state.batchId);
1079
1118
 
1119
+ // TP-164: Load merge agent snapshots for live dashboard telemetry.
1120
+ const runtimeMergeSnapshots = loadRuntimeMergeSnapshots(state.batchId);
1121
+
1122
+ // TP-164: Inject merge snapshot telemetry into the telemetry map so
1123
+ // `telemetry[sessionName]` resolves for the merge pane.
1124
+ // Only inject when a JSONL-backed entry is absent or stale — the snapshot
1125
+ // provides real-time tool-call / cost / context data even before the
1126
+ // JSONL accumulator has accumulated enough events.
1127
+ for (const snap of Object.values(runtimeMergeSnapshots)) {
1128
+ const key = snap.sessionName;
1129
+ if (!key) continue;
1130
+ const agent = snap.agent;
1131
+ if (!agent) continue;
1132
+ // Only inject if there is no existing telemetry entry, or if the snapshot
1133
+ // is more recent than the latest accumulator update.
1134
+ const existing = telemetry[key];
1135
+ if (!existing || (snap.updatedAt && snap.updatedAt > (existing._updatedAt || 0))) {
1136
+ telemetry[key] = {
1137
+ inputTokens: agent.inputTokens || 0,
1138
+ outputTokens: agent.outputTokens || 0,
1139
+ cacheReadTokens: agent.cacheReadTokens || 0,
1140
+ cacheWriteTokens: agent.cacheWriteTokens || 0,
1141
+ cost: agent.costUsd || 0,
1142
+ toolCalls: agent.toolCalls || 0,
1143
+ lastTool: agent.lastTool || "",
1144
+ currentTool: snap.status === "running" ? (agent.lastTool || "") : "",
1145
+ contextPct: agent.contextPct || 0,
1146
+ // startedAt is not in the snapshot; compute from elapsed if possible.
1147
+ startedAt: agent.elapsedMs > 0 ? snap.updatedAt - agent.elapsedMs : snap.updatedAt,
1148
+ retries: 0,
1149
+ retryActive: false,
1150
+ lastRetryError: "",
1151
+ compactions: 0,
1152
+ latestTotalTokens: (agent.inputTokens || 0) + (agent.outputTokens || 0),
1153
+ _updatedAt: snap.updatedAt,
1154
+ _source: "merge-snapshot",
1155
+ };
1156
+ }
1157
+ }
1158
+
1080
1159
  // TP-115: Synthesize laneStates from V2 snapshots so the dashboard
1081
1160
  // pipeline works without legacy lane-state-*.json sidecar files.
1082
1161
  // V2 snapshots are authoritative when present.
@@ -1099,6 +1178,8 @@ function buildDashboardState() {
1099
1178
  // Runtime V2 data (null/empty for legacy batches)
1100
1179
  runtimeRegistry,
1101
1180
  runtimeLaneSnapshots,
1181
+ // TP-164: Merge agent snapshots for live dashboard telemetry.
1182
+ runtimeMergeSnapshots,
1102
1183
  mailbox: mailboxData,
1103
1184
  batch: {
1104
1185
  batchId: state.batchId,
@@ -429,12 +429,22 @@ export default function (pi: ExtensionAPI) {
429
429
  // Pre-clean stale reviewer state from prior interrupted review
430
430
  removeReviewerState(taskFolder);
431
431
  return new Promise((resolve) => {
432
+ // Read reviewer config from env vars set by lane-runner from runnerConfig.reviewer.
433
+ // Empty string means inherit from session default (no flag passed to pi CLI).
434
+ const reviewerModel = process.env.TASKPLANE_REVIEWER_MODEL || "";
435
+ const reviewerThinking = process.env.TASKPLANE_REVIEWER_THINKING || "";
436
+ // Fall back to the schema default reviewer tool list (read-only + bash/grep).
437
+ // Must match config-schema.ts reviewer.tools default to avoid capability expansion.
438
+ const reviewerTools = process.env.TASKPLANE_REVIEWER_TOOLS || "read,bash,grep,find,ls";
439
+
432
440
  const cliPath = resolvePiCliPath();
433
441
  const args = [
434
442
  cliPath, "--mode", "rpc", "--no-session", "--no-extensions", "--no-skills",
435
- "--tools", "read,write,edit,bash,grep,find,ls",
443
+ "--tools", reviewerTools,
436
444
  "--system-prompt", systemPrompt,
437
445
  ];
446
+ if (reviewerModel) args.push("--model", reviewerModel);
447
+ if (reviewerThinking) args.push("--thinking", reviewerThinking);
438
448
  const proc = nodeSpawn(process.execPath, args, {
439
449
  shell: false,
440
450
  cwd,
@@ -561,8 +561,8 @@ export function spawnAgent(
561
561
  case "tool_execution_start": {
562
562
  toolCalls++;
563
563
  const toolName = event.toolName || "tool";
564
- const argPreview = typeof event.args === "string" ? event.args.slice(0, 80) :
565
- (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 80) : "");
564
+ const argPreview = typeof event.args === "string" ? event.args.slice(0, 300) :
565
+ (event.args && typeof Object.values(event.args)[0] === "string" ? String(Object.values(event.args)[0]).slice(0, 300) : "");
566
566
  lastTool = argPreview ? `${toolName}: ${argPreview}` : toolName;
567
567
  // TP-111: Bounded payload only — no raw args in durable event log
568
568
  const toolPath = event.args?.path ? String(event.args.path).slice(0, 200) : "";
@@ -1177,6 +1177,11 @@ export function toTaskRunnerConfig(config: TaskplaneConfig): import("./types.ts"
1177
1177
  reference_docs: { ...config.taskRunner.referenceDocs },
1178
1178
  ...(hasTestingCommands ? { testing_commands: { ...testingCommands } } : {}),
1179
1179
  model_fallback: config.taskRunner.modelFallback ?? "inherit",
1180
+ reviewer: {
1181
+ model: config.taskRunner.reviewer.model,
1182
+ thinking: config.taskRunner.reviewer.thinking,
1183
+ tools: config.taskRunner.reviewer.tools,
1184
+ },
1180
1185
  };
1181
1186
  }
1182
1187
 
@@ -6,7 +6,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, unlinkSync } from "f
6
6
  import { join, resolve } from "path";
7
7
 
8
8
  import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
9
- import { computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents } from "./execution.ts";
9
+ import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, killV2LaneAgents } from "./execution.ts";
10
10
  import type { RuntimeBackend } from "./execution.ts";
11
11
  import type { MonitorUpdateCallback } from "./execution.ts";
12
12
  // classifyExit no longer called directly — Tier 0 uses exitDiagnostic.classification
@@ -1307,7 +1307,7 @@ async function attemptWorkerCrashRetry(
1307
1307
  retryPauseSignal,
1308
1308
  wsRoot,
1309
1309
  isWsMode,
1310
- { ORCH_BATCH_ID: batchState.batchId }, // TP-089: ensure mailbox works for retries
1310
+ { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer) }, // TP-089: ensure mailbox works for retries
1311
1311
  );
1312
1312
 
1313
1313
  const retryOutcome = retryResult.tasks[0];
@@ -1566,7 +1566,7 @@ async function attemptModelFallbackRetry(
1566
1566
  // Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
1567
1567
  // the task-runner to use the session model instead of configured model.
1568
1568
  // TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
1569
- const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId };
1569
+ const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig?.reviewer) };
1570
1570
  const retryResult = await executeLaneV2(
1571
1571
  retryLane,
1572
1572
  orchConfig,
@@ -1706,6 +1706,7 @@ async function attemptStaleWorktreeRecovery(
1706
1706
  runtimeBackend?: RuntimeBackend,
1707
1707
  onSupervisorAlert?: SupervisorAlertCallback,
1708
1708
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1709
+ runnerConfig?: TaskRunnerConfig,
1709
1710
  ): Promise<WaveExecutionResult | null> {
1710
1711
  // Only attempt recovery for ALLOC_WORKTREE_FAILED
1711
1712
  if (!waveResult.allocationError || waveResult.allocationError.code !== "ALLOC_WORKTREE_FAILED") {
@@ -1808,6 +1809,11 @@ async function attemptStaleWorktreeRecovery(
1808
1809
  runtimeBackend,
1809
1810
  onSupervisorAlert,
1810
1811
  supervisorAutonomy,
1812
+ {
1813
+ model: runnerConfig?.reviewer?.model || "",
1814
+ thinking: runnerConfig?.reviewer?.thinking || "",
1815
+ tools: runnerConfig?.reviewer?.tools || "",
1816
+ },
1811
1817
  );
1812
1818
 
1813
1819
  return retryResult;
@@ -2376,6 +2382,11 @@ export async function executeOrchBatch(
2376
2382
  selectedBackend,
2377
2383
  emitAlert,
2378
2384
  supervisorAutonomy,
2385
+ {
2386
+ model: runnerConfig?.reviewer?.model || "",
2387
+ thinking: runnerConfig?.reviewer?.thinking || "",
2388
+ tools: runnerConfig?.reviewer?.tools || "",
2389
+ },
2379
2390
  );
2380
2391
 
2381
2392
  // ── TP-039: Tier 0 — Stale worktree recovery ────────────
@@ -2398,6 +2409,7 @@ export async function executeOrchBatch(
2398
2409
  selectedBackend,
2399
2410
  emitAlert,
2400
2411
  supervisorAutonomy,
2412
+ runnerConfig,
2401
2413
  );
2402
2414
  if (retryResult) {
2403
2415
  const staleRecovered = !retryResult.allocationError;
@@ -1411,6 +1411,7 @@ export function ensureTaskFilesCommitted(
1411
1411
  pending: Map<string, ParsedTask>,
1412
1412
  repoRoot: string,
1413
1413
  waveIndex: number,
1414
+ orchBranch?: string,
1414
1415
  ): void {
1415
1416
  // Collect task folder paths for this wave
1416
1417
  const foldersToCheck: { taskId: string; relPath: string }[] = [];
@@ -1477,6 +1478,121 @@ export function ensureTaskFilesCommitted(
1477
1478
  folders: foldersToStage,
1478
1479
  commit: commitResult.stdout.trim().split("\n")[0],
1479
1480
  });
1481
+
1482
+ // Fast-forward (or merge) the orch branch to include the staging commit so
1483
+ // that worktrees—which branch from orchBranch—see the new task files and
1484
+ // 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
+ if (orchBranch) {
1495
+ try {
1496
+ const headRes = runGit(["rev-parse", "HEAD"], repoRoot);
1497
+ const orchTipRes = runGit(["rev-parse", `refs/heads/${orchBranch}`], repoRoot);
1498
+
1499
+ if (headRes.ok && orchTipRes.ok) {
1500
+ const newHead = headRes.stdout.trim();
1501
+ const orchTip = orchTipRes.stdout.trim();
1502
+
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
+ const ancestorCheck = runGit(
1506
+ ["merge-base", "--is-ancestor", orchTip, newHead],
1507
+ repoRoot,
1508
+ );
1509
+
1510
+ 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
+ const ffResult = runGit(
1514
+ ["update-ref", `refs/heads/${orchBranch}`, newHead, orchTip],
1515
+ repoRoot,
1516
+ );
1517
+ if (ffResult.ok) {
1518
+ execLog("wave", `W${waveIndex}`, `fast-forwarded orch branch to include staging commit`, {
1519
+ orchBranch,
1520
+ from: orchTip.slice(0, 8),
1521
+ to: newHead.slice(0, 8),
1522
+ });
1523
+ } else {
1524
+ execLog("wave", `W${waveIndex}`, `warning: failed to fast-forward orch branch (non-fatal)`, {
1525
+ orchBranch,
1526
+ error: ffResult.stderr,
1527
+ });
1528
+ }
1529
+ } 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
+ const mergeTreeRes = runGit(
1535
+ ["merge-tree", "--write-tree", orchTip, newHead],
1536
+ repoRoot,
1537
+ );
1538
+ 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
+ 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`;
1551
+ const commitTreeRes = runGit(
1552
+ ["commit-tree", mergedTree, "-p", orchTip, "-p", newHead, "-m", mergeCommitMsg],
1553
+ repoRoot,
1554
+ );
1555
+ if (commitTreeRes.ok) {
1556
+ const mergeCommitSha = commitTreeRes.stdout.trim();
1557
+ const refUpdateRes = runGit(
1558
+ ["update-ref", `refs/heads/${orchBranch}`, mergeCommitSha, orchTip],
1559
+ repoRoot,
1560
+ );
1561
+ if (refUpdateRes.ok) {
1562
+ execLog("wave", `W${waveIndex}`, `merged staging commit into orch branch (non-FF wave)`, {
1563
+ orchBranch,
1564
+ orchTip: orchTip.slice(0, 8),
1565
+ newHead: newHead.slice(0, 8),
1566
+ mergeCommit: mergeCommitSha.slice(0, 8),
1567
+ });
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
+ }
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
+ }
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
+ });
1586
+ }
1587
+ }
1588
+ }
1589
+ } catch (refErr: unknown) {
1590
+ execLog("wave", `W${waveIndex}`, `warning: orch branch ref update threw unexpectedly (non-fatal)`, {
1591
+ orchBranch,
1592
+ error: refErr instanceof Error ? refErr.message : String(refErr),
1593
+ });
1594
+ }
1595
+ }
1480
1596
  }
1481
1597
 
1482
1598
  // ── Wave Execution Core ──────────────────────────────────────────────
@@ -1515,7 +1631,7 @@ export function ensureTaskFilesCommitted(
1515
1631
  * @param batchId - Batch ID for naming
1516
1632
  * @param pauseSignal - Shared pause signal (mutated by stop-wave policy)
1517
1633
  * @param dependencyGraph - Dependency graph for computing transitive dependents
1518
- * @param baseBranch - Branch to base worktrees on (captured at batch start)
1634
+ * @param orchBranch - Orch branch to base worktrees on (and to update after staging commits)
1519
1635
  * @param onMonitorUpdate - Optional callback for dashboard updates during monitoring
1520
1636
  * @param onLanesAllocated - Optional callback fired after lane allocation succeeds
1521
1637
  * @param workspaceConfig - Workspace configuration for repo routing (null/undefined = repo mode)
@@ -1540,13 +1656,14 @@ export async function executeWave(
1540
1656
  batchId: string,
1541
1657
  pauseSignal: { paused: boolean },
1542
1658
  dependencyGraph: DependencyGraph,
1543
- baseBranch: string,
1659
+ orchBranch: string,
1544
1660
  onMonitorUpdate?: MonitorUpdateCallback,
1545
1661
  onLanesAllocated?: (lanes: AllocatedLane[]) => void,
1546
1662
  workspaceConfig?: WorkspaceConfig | null,
1547
1663
  runtimeBackend?: RuntimeBackend,
1548
1664
  onSupervisorAlert?: SupervisorAlertCallback,
1549
1665
  supervisorAutonomy: "interactive" | "supervised" | "autonomous" = "autonomous",
1666
+ reviewerConfig?: { model?: string; thinking?: string; tools?: string },
1550
1667
  ): Promise<WaveExecutionResult> {
1551
1668
  const startedAt = Date.now();
1552
1669
  const policy = config.failure.on_task_failure;
@@ -1561,8 +1678,10 @@ export async function executeWave(
1561
1678
  // Task folders may contain untracked files (PROMPT.md, STATUS.md) that
1562
1679
  // won't appear in worktrees unless committed. Stage and commit them now,
1563
1680
  // before worktree creation, so workers can find their TASK_AUTOSTART paths.
1681
+ // Pass orchBranch so the staging commit is reflected in the orch branch
1682
+ // before worktrees are allocated from it.
1564
1683
  try {
1565
- ensureTaskFilesCommitted(waveTasks, pending, repoRoot, waveIndex);
1684
+ ensureTaskFilesCommitted(waveTasks, pending, repoRoot, waveIndex, orchBranch);
1566
1685
  } catch (err: unknown) {
1567
1686
  const errMsg = err instanceof Error ? err.message : String(err);
1568
1687
  execLog("wave", `W${waveIndex}`, `task file commit failed: ${errMsg}`);
@@ -1586,7 +1705,7 @@ export async function executeWave(
1586
1705
  }
1587
1706
 
1588
1707
  // ── Stage 1: Allocate lanes ──────────────────────────────────
1589
- const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, baseBranch, workspaceConfig);
1708
+ const allocResult = allocateLanes(waveTasks, pending, config, repoRoot, batchId, orchBranch, workspaceConfig);
1590
1709
 
1591
1710
  if (!allocResult.success) {
1592
1711
  const errMsg = allocResult.error?.message || "Unknown allocation failure";
@@ -1651,6 +1770,7 @@ export async function executeWave(
1651
1770
  executeLaneV2(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, {
1652
1771
  ORCH_BATCH_ID: batchId,
1653
1772
  TASKPLANE_SUPERVISOR_AUTONOMY: supervisorAutonomy,
1773
+ ...buildReviewerEnv(reviewerConfig),
1654
1774
  }, onSupervisorAlert),
1655
1775
  );
1656
1776
 
@@ -2162,6 +2282,27 @@ import { executeTaskV2, type LaneRunnerConfig, type LaneRunnerTaskResult } from
2162
2282
  *
2163
2283
  * @since TP-105
2164
2284
  */
2285
+
2286
+ /**
2287
+ * Build reviewer env vars from a TaskRunnerConfig or reviewer config object.
2288
+ * Used to ensure reviewer config is consistently passed to executeLaneV2
2289
+ * across all call sites (initial waves, resume, retries).
2290
+ *
2291
+ * Returns only the keys that have non-empty values, so that empty/inherit
2292
+ * config does not override inherited env vars from the parent process.
2293
+ *
2294
+ * @since TP-160
2295
+ */
2296
+ export function buildReviewerEnv(
2297
+ reviewerConfig?: { model?: string; thinking?: string; tools?: string } | null,
2298
+ ): Record<string, string> {
2299
+ const env: Record<string, string> = {};
2300
+ if (reviewerConfig?.model) env.TASKPLANE_REVIEWER_MODEL = reviewerConfig.model;
2301
+ if (reviewerConfig?.thinking) env.TASKPLANE_REVIEWER_THINKING = reviewerConfig.thinking;
2302
+ if (reviewerConfig?.tools) env.TASKPLANE_REVIEWER_TOOLS = reviewerConfig.tools;
2303
+ return env;
2304
+ }
2305
+
2165
2306
  export async function executeLaneV2(
2166
2307
  lane: AllocatedLane,
2167
2308
  config: OrchestratorConfig,
@@ -2247,6 +2388,9 @@ export async function executeLaneV2(
2247
2388
  workerTools: "read,write,edit,bash,grep,find,ls",
2248
2389
  workerThinking: "",
2249
2390
  workerSystemPrompt,
2391
+ reviewerModel: extraEnvVars?.TASKPLANE_REVIEWER_MODEL || "",
2392
+ reviewerThinking: extraEnvVars?.TASKPLANE_REVIEWER_THINKING || "",
2393
+ reviewerTools: extraEnvVars?.TASKPLANE_REVIEWER_TOOLS || "",
2250
2394
  supervisorAutonomy,
2251
2395
  projectName: config.project?.name || "project",
2252
2396
  maxIterations: 20,
@@ -92,6 +92,22 @@ export interface LaneRunnerConfig {
92
92
  workerThinking: string;
93
93
  /** Worker system prompt */
94
94
  workerSystemPrompt: string;
95
+ /**
96
+ * Reviewer model (empty string = inherit session default).
97
+ * Set from TASKPLANE_REVIEWER_MODEL env var, sourced from runnerConfig.reviewer.model.
98
+ * @since TP-160
99
+ */
100
+ reviewerModel: string;
101
+ /**
102
+ * Reviewer thinking mode (empty string = inherit).
103
+ * @since TP-160
104
+ */
105
+ reviewerThinking: string;
106
+ /**
107
+ * Reviewer tool allowlist (comma-separated).
108
+ * @since TP-160
109
+ */
110
+ reviewerTools: string;
95
111
  /** Supervisor autonomy level for bridge-tool guards. */
96
112
  supervisorAutonomy?: "interactive" | "supervised" | "autonomous";
97
113
  /** Project name (for review request context) */
@@ -331,6 +347,9 @@ export async function executeTaskV2(
331
347
  TASKPLANE_ACTIVE_SEGMENT_ID: segmentId ?? "",
332
348
  TASKPLANE_SUPERVISOR_AUTONOMY: config.supervisorAutonomy || "autonomous",
333
349
  ORCH_BATCH_ID: config.batchId,
350
+ ...(config.reviewerModel ? { TASKPLANE_REVIEWER_MODEL: config.reviewerModel } : {}),
351
+ ...(config.reviewerThinking ? { TASKPLANE_REVIEWER_THINKING: config.reviewerThinking } : {}),
352
+ ...(config.reviewerTools ? { TASKPLANE_REVIEWER_TOOLS: config.reviewerTools } : {}),
334
353
  },
335
354
  };
336
355
 
@@ -10,9 +10,9 @@ import { join, dirname, resolve, relative } from "path";
10
10
  import { execLog, isV2AgentAlive, setV2LivenessRegistryCache } from "./execution.ts";
11
11
  import { resolveOperatorId } from "./naming.ts";
12
12
  import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
13
- import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase } from "./types.ts";
13
+ import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase, RuntimeMergeSnapshot, RuntimeAgentTelemetrySnapshot } from "./types.ts";
14
14
  import { resolveBaseBranch, resolveRepoRoot } from "./waves.ts";
15
- import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot } from "./process-registry.ts";
15
+ import { readManifest, writeManifest, buildRegistrySnapshot, writeRegistrySnapshot, readRegistrySnapshot, writeMergeSnapshot } from "./process-registry.ts";
16
16
  import { generateMergeWorktreePath, sleepAsync, sleepSync } from "./worktree.ts";
17
17
  import { getCurrentBranch, runGit } from "./git.ts";
18
18
  import { ORCH_MESSAGES } from "./messages.ts";
@@ -20,7 +20,7 @@ import { emitEngineEvent } from "./persistence.ts";
20
20
  import { loadOrchestratorConfig } from "./config.ts";
21
21
  import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
22
22
  import { spawnAgent } from "./agent-host.ts";
23
- import type { AgentHostOptions, AgentHostResult } from "./agent-host.ts";
23
+ import type { AgentHostOptions, AgentHostResult, AgentTelemetryCallback } from "./agent-host.ts";
24
24
  import type { RuntimeBackend } from "./execution.ts";
25
25
  import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
26
26
 
@@ -614,14 +614,69 @@ export async function spawnMergeAgentV2(
614
614
  },
615
615
  };
616
616
 
617
- const { promise, kill } = spawnAgent(opts);
617
+ // Derive the 1-indexed merge number from the session name
618
+ // (e.g. "orch-henry-merge-1" → 1, "orch-henry-merge-2" → 2).
619
+ const mergeNumberMatch = sessionName.match(/-merge-(\d+)$/);
620
+ const mergeNumber = mergeNumberMatch ? parseInt(mergeNumberMatch[1], 10) : 1;
621
+ const mergeStartedAt = Date.now();
622
+ const mergeStateRoot = stateRoot ?? repoRoot;
623
+
624
+ // Helper: build a RuntimeAgentTelemetrySnapshot from a partial AgentHostResult.
625
+ const buildAgentSnap = (tel: Partial<AgentHostResult>, status: RuntimeAgentTelemetrySnapshot["status"]): RuntimeAgentTelemetrySnapshot => ({
626
+ agentId: sessionName,
627
+ status,
628
+ elapsedMs: tel.durationMs ?? (Date.now() - mergeStartedAt),
629
+ toolCalls: tel.toolCalls ?? 0,
630
+ contextPct: tel.contextUsage?.percent ?? 0,
631
+ costUsd: tel.costUsd ?? 0,
632
+ lastTool: tel.lastTool ?? "",
633
+ inputTokens: tel.inputTokens ?? 0,
634
+ outputTokens: tel.outputTokens ?? 0,
635
+ cacheReadTokens: tel.cacheReadTokens ?? 0,
636
+ cacheWriteTokens: tel.cacheWriteTokens ?? 0,
637
+ });
638
+
639
+ // Telemetry callback: write a merge snapshot on every telemetry update.
640
+ // Non-fatal — snapshot writes must never interfere with merge execution.
641
+ const onMergeTelemetry: AgentTelemetryCallback = (tel) => {
642
+ try {
643
+ const snap: RuntimeMergeSnapshot = {
644
+ batchId: bid,
645
+ mergeNumber,
646
+ sessionName,
647
+ waveIndex: 0,
648
+ status: "running",
649
+ agent: buildAgentSnap(tel, "running"),
650
+ updatedAt: Date.now(),
651
+ };
652
+ writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
653
+ } catch { /* non-fatal */ }
654
+ };
655
+
656
+ const { promise, kill } = spawnAgent(opts, undefined, onMergeTelemetry);
657
+
658
+ // Write an initial "running" snapshot immediately so the dashboard row
659
+ // appears even when the first telemetry event is delayed.
660
+ try {
661
+ const initialSnap: RuntimeMergeSnapshot = {
662
+ batchId: bid,
663
+ mergeNumber,
664
+ sessionName,
665
+ waveIndex: 0,
666
+ status: "running",
667
+ agent: buildAgentSnap({}, "running"),
668
+ updatedAt: Date.now(),
669
+ };
670
+ writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, initialSnap);
671
+ } catch { /* non-fatal */ }
618
672
 
619
673
  // Store the kill handle for external cleanup (pause/abort).
620
674
  // The promise runs in background — caller uses waitForMergeResult()
621
675
  // to poll for the result file, same contract as the legacy session path.
622
- activeMergeAgents.set(sessionName, { promise, kill, stateRoot: stateRoot ?? repoRoot, batchId: bid });
676
+ activeMergeAgents.set(sessionName, { promise, kill, stateRoot: mergeStateRoot, batchId: bid });
623
677
 
624
- // Fire-and-forget: the background promise handles exit logging
678
+ // Fire-and-forget: the background promise handles exit logging and
679
+ // writes a terminal snapshot ("complete" or "failed") when the agent exits.
625
680
  promise.then(result => {
626
681
  activeMergeAgents.delete(sessionName);
627
682
  execLog("merge", sessionName, "merge agent exited (V2)", {
@@ -630,9 +685,39 @@ export async function spawnMergeAgentV2(
630
685
  costUsd: result.costUsd,
631
686
  killed: result.killed,
632
687
  });
688
+ // Write terminal snapshot. Promise resolves for both successful and
689
+ // failed exits, so derive status from result fields rather than
690
+ // relying on .catch to handle failures.
691
+ const terminalStatus: RuntimeMergeSnapshot["status"] =
692
+ (result.killed || result.exitCode !== 0 || !result.agentEnded) ? "failed" : "complete";
693
+ try {
694
+ const snap: RuntimeMergeSnapshot = {
695
+ batchId: bid,
696
+ mergeNumber,
697
+ sessionName,
698
+ waveIndex: 0,
699
+ status: terminalStatus,
700
+ agent: buildAgentSnap(result, terminalStatus === "complete" ? "exited" : "crashed"),
701
+ updatedAt: Date.now(),
702
+ };
703
+ writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
704
+ } catch { /* non-fatal */ }
633
705
  }).catch(err => {
634
706
  activeMergeAgents.delete(sessionName);
635
707
  execLog("merge", sessionName, `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`);
708
+ // Write a failed terminal snapshot on unexpected rejection.
709
+ try {
710
+ const snap: RuntimeMergeSnapshot = {
711
+ batchId: bid,
712
+ mergeNumber,
713
+ sessionName,
714
+ waveIndex: 0,
715
+ status: "failed",
716
+ agent: buildAgentSnap({}, "crashed"),
717
+ updatedAt: Date.now(),
718
+ };
719
+ writeMergeSnapshot(mergeStateRoot, bid, mergeNumber, snap);
720
+ } catch { /* non-fatal */ }
636
721
  });
637
722
  }
638
723
 
@@ -30,12 +30,14 @@ import {
30
30
  runtimeRegistryPath,
31
31
  runtimeAgentEventsPath,
32
32
  runtimeLaneSnapshotPath,
33
+ runtimeMergeSnapshotPath,
33
34
  validateAgentManifest,
34
35
  type RuntimeAgentId,
35
36
  type RuntimeAgentManifest,
36
37
  type RuntimeAgentRole,
37
38
  type RuntimeAgentStatus,
38
39
  type RuntimeRegistry,
40
+ type RuntimeMergeSnapshot,
39
41
  type PacketPaths,
40
42
  } from "./types.ts";
41
43
 
@@ -362,3 +364,53 @@ export function readLaneSnapshot(
362
364
  return null;
363
365
  }
364
366
  }
367
+
368
+ /**
369
+ * Write a V2 merge agent snapshot to disk (atomic rename).
370
+ *
371
+ * Stored in the `lanes/` directory alongside lane snapshots so the dashboard
372
+ * server picks it up with the same scan that reads lane-N.json files.
373
+ *
374
+ * @param stateRoot - Repository root (where `.pi/` lives)
375
+ * @param batchId - Current batch identifier
376
+ * @param mergeNumber - 1-indexed merge agent number
377
+ * @param snapshot - Snapshot data to persist
378
+ *
379
+ * @since TP-164
380
+ */
381
+ export function writeMergeSnapshot(
382
+ stateRoot: string,
383
+ batchId: string,
384
+ mergeNumber: number,
385
+ snapshot: RuntimeMergeSnapshot,
386
+ ): void {
387
+ const path = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
388
+ mkdirSync(dirname(path), { recursive: true });
389
+ const tmpPath = path + ".tmp";
390
+ writeFileSync(tmpPath, JSON.stringify(snapshot, null, 2) + "\n", "utf-8");
391
+ renameSync(tmpPath, path);
392
+ }
393
+
394
+ /**
395
+ * Read a V2 merge agent snapshot from disk.
396
+ * Returns null if the file does not exist or is unreadable.
397
+ *
398
+ * @param stateRoot - Repository root (where `.pi/` lives)
399
+ * @param batchId - Current batch identifier
400
+ * @param mergeNumber - 1-indexed merge agent number
401
+ *
402
+ * @since TP-164
403
+ */
404
+ export function readMergeSnapshot(
405
+ stateRoot: string,
406
+ batchId: string,
407
+ mergeNumber: number,
408
+ ): RuntimeMergeSnapshot | null {
409
+ try {
410
+ const p = runtimeMergeSnapshotPath(stateRoot, batchId, mergeNumber);
411
+ if (!existsSync(p)) return null;
412
+ return JSON.parse(readFileSync(p, "utf-8")) as RuntimeMergeSnapshot;
413
+ } catch {
414
+ return null;
415
+ }
416
+ }
@@ -8,7 +8,7 @@ import { join } from "path";
8
8
  import { assembleDiagnosticInput, emitDiagnosticReports } from "./diagnostic-reports.ts";
9
9
  import { runDiscovery } from "./discovery.ts";
10
10
  import { executeOrchBatch } from "./engine.ts";
11
- import { computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
11
+ import { buildReviewerEnv, computeTransitiveDependents, execLog, executeLaneV2, executeWave, resolveCanonicalTaskPaths } from "./execution.ts";
12
12
  import type { MonitorUpdateCallback, RuntimeBackend } from "./execution.ts";
13
13
  import { selectRuntimeBackend } from "./engine.ts";
14
14
  import { readRegistrySnapshot, isTerminalStatus, isProcessAlive } from "./process-registry.ts";
@@ -1461,7 +1461,7 @@ export async function resumeOrchBatch(
1461
1461
  const laneResult = await executeLaneV2(
1462
1462
  lane, orchConfig, laneRepoRoot, batchState.pauseSignal,
1463
1463
  workspaceRoot, !!workspaceConfig,
1464
- { ORCH_BATCH_ID: batchState.batchId },
1464
+ { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer) },
1465
1465
  emitAlert,
1466
1466
  );
1467
1467
  const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
@@ -1543,7 +1543,7 @@ export async function resumeOrchBatch(
1543
1543
  const laneResult = await executeLaneV2(
1544
1544
  lane, orchConfig, reExecRepoRoot, batchState.pauseSignal,
1545
1545
  workspaceRoot, !!workspaceConfig,
1546
- { ORCH_BATCH_ID: batchState.batchId },
1546
+ { ORCH_BATCH_ID: batchState.batchId, ...buildReviewerEnv(runnerConfig.reviewer) },
1547
1547
  emitAlert,
1548
1548
  );
1549
1549
  const taskResult = laneResult.tasks.find(t => t.taskId === task.taskId);
@@ -1994,6 +1994,7 @@ export async function resumeOrchBatch(
1994
1994
  resumeBackend,
1995
1995
  emitAlert,
1996
1996
  supervisorAutonomy,
1997
+ runnerConfig.reviewer,
1997
1998
  );
1998
1999
 
1999
2000
  batchState.waveResults.push(waveResult);
@@ -291,6 +291,19 @@ export interface TaskRunnerConfig {
291
291
  * @since TP-055
292
292
  */
293
293
  model_fallback?: "inherit" | "fail";
294
+ /**
295
+ * Reviewer agent model/thinking/tools configuration.
296
+ * Threaded through to `spawnReviewer()` via env vars.
297
+ * @since TP-160
298
+ */
299
+ reviewer?: {
300
+ /** Model string (empty = inherit session default) */
301
+ model: string;
302
+ /** Thinking mode ("on" | "off" | budget string, empty = inherit) */
303
+ thinking: string;
304
+ /** Comma-separated tool allowlist */
305
+ tools: string;
306
+ };
294
307
  }
295
308
 
296
309
  /** Result of a preflight check */
@@ -4062,6 +4075,50 @@ export function runtimeLaneSnapshotPath(stateRoot: string, batchId: string, lane
4062
4075
  return `${stateRoot}/.pi/runtime/${batchId}/lanes/lane-${laneNumber}.json`;
4063
4076
  }
4064
4077
 
4078
+ /**
4079
+ * Telemetry snapshot for a merge agent.
4080
+ *
4081
+ * Written to `.pi/runtime/{batchId}/lanes/merge-{mergeNumber}.json` alongside
4082
+ * lane snapshots so the dashboard can display live merge-phase telemetry.
4083
+ * Follows the same file-backed pattern as {@link RuntimeLaneSnapshot} but is
4084
+ * simpler — merge agents have no reviewer, progress tracking, or repoId.
4085
+ *
4086
+ * @since TP-164
4087
+ */
4088
+ export interface RuntimeMergeSnapshot {
4089
+ /** Batch this merge agent belongs to */
4090
+ batchId: string;
4091
+ /** 1-indexed merge agent number (e.g. 1 for "orch-henry-merge-1") */
4092
+ mergeNumber: number;
4093
+ /** Stable agent session name (e.g. "orch-henry-merge-1") */
4094
+ sessionName: string;
4095
+ /** Wave index this merge agent is processing (0-indexed, 0 when unknown) */
4096
+ waveIndex: number;
4097
+ /** Merge agent lifecycle status */
4098
+ status: "running" | "complete" | "failed";
4099
+ /** Live telemetry snapshot for the merge agent (null when not yet started) */
4100
+ agent: RuntimeAgentTelemetrySnapshot | null;
4101
+ /** Epoch ms when this snapshot was last updated */
4102
+ updatedAt: number;
4103
+ }
4104
+
4105
+ /**
4106
+ * Resolve the path for a merge agent snapshot file.
4107
+ *
4108
+ * Snapshots are stored alongside lane snapshots in the `lanes/` directory so
4109
+ * the dashboard server's directory scan picks them up automatically.
4110
+ *
4111
+ * @param stateRoot - Repository root (where `.pi/` lives)
4112
+ * @param batchId - Current batch identifier
4113
+ * @param mergeNumber - 1-indexed merge agent number
4114
+ * @returns Absolute path to the merge snapshot JSON file
4115
+ *
4116
+ * @since TP-164
4117
+ */
4118
+ export function runtimeMergeSnapshotPath(stateRoot: string, batchId: string, mergeNumber: number): string {
4119
+ return `${stateRoot}/.pi/runtime/${batchId}/lanes/merge-${mergeNumber}.json`;
4120
+ }
4121
+
4065
4122
  /**
4066
4123
  * Resolve the path for the batch runtime registry.
4067
4124
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.25.6",
3
+ "version": "0.25.7",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",