taskplane 0.23.12 → 0.23.14

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.
@@ -64,6 +64,61 @@ function emitTier0Escalation(
64
64
  });
65
65
  }
66
66
 
67
+ /** Zero-token sentinel used for task/wave/batch aggregation. */
68
+ const ZERO_TOKENS: TokenCounts = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
69
+
70
+ /** Map embedded outcome telemetry to the batch-history TokenCounts shape. */
71
+ export function taskTokensFromOutcomeTelemetry(outcome: LaneTaskOutcome): TokenCounts {
72
+ const telemetry = outcome.telemetry;
73
+ if (!telemetry) return { ...ZERO_TOKENS };
74
+ return {
75
+ input: telemetry.inputTokens,
76
+ output: telemetry.outputTokens,
77
+ cacheRead: telemetry.cacheReadTokens,
78
+ cacheWrite: telemetry.cacheWriteTokens,
79
+ costUsd: telemetry.costUsd,
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Resolve per-task token counts for batch history.
85
+ *
86
+ * Priority:
87
+ * 1) Embedded `LaneTaskOutcome.telemetry` (authoritative Runtime V2 path)
88
+ * 2) V2 lane snapshot fallback by numeric laneNumber (legacy outcomes)
89
+ * 3) Legacy lane-state sidecar keys by sessionName prefix
90
+ * 4) Zero tokens
91
+ */
92
+ export function resolveBatchHistoryTaskTokens(
93
+ outcome: LaneTaskOutcome,
94
+ laneNumber: number,
95
+ v2LaneTokensByNumber: Map<number, TokenCounts>,
96
+ legacyLaneTokensByKey: Map<string, TokenCounts>,
97
+ ): TokenCounts {
98
+ // Skipped tasks did not run an agent process.
99
+ if (outcome.status === "skipped") return { ...ZERO_TOKENS };
100
+
101
+ if (outcome.telemetry) {
102
+ return taskTokensFromOutcomeTelemetry(outcome);
103
+ }
104
+
105
+ if (laneNumber > 0) {
106
+ const v2 = v2LaneTokensByNumber.get(laneNumber);
107
+ if (v2) return v2;
108
+ }
109
+
110
+ const bySession = legacyLaneTokensByKey.get(outcome.sessionName)
111
+ || legacyLaneTokensByKey.get(outcome.sessionName?.replace(/-(?:worker|reviewer)$/, ""));
112
+ if (bySession) return bySession;
113
+
114
+ if (laneNumber > 0) {
115
+ const byLaneKey = legacyLaneTokensByKey.get(`lane-${laneNumber}`);
116
+ if (byLaneKey) return byLaneKey;
117
+ }
118
+
119
+ return { ...ZERO_TOKENS };
120
+ }
121
+
67
122
  /**
68
123
  * Attempt automatic retry for failed tasks with retryable exit classifications.
69
124
  *
@@ -2253,11 +2308,13 @@ export async function executeOrchBatch(
2253
2308
 
2254
2309
  // ── Save batch history (before cleanup deletes sidecar files) ────
2255
2310
  try {
2256
- // Read token data from sidecar files or V2 lane snapshots
2311
+ // Read fallback token data from V2 lane snapshots and legacy sidecars.
2312
+ // Primary source for Runtime V2 is now `LaneTaskOutcome.telemetry`.
2257
2313
  const piDir = join(stateRoot, ".pi");
2258
- const laneTokens = new Map<string, TokenCounts>();
2314
+ const v2LaneTokensByNumber = new Map<number, TokenCounts>();
2315
+ const legacyLaneTokensByKey = new Map<string, TokenCounts>();
2259
2316
 
2260
- // TP-115: Try V2 lane snapshots first (authoritative for Runtime V2)
2317
+ // V2 snapshot fallback (used only when outcome.telemetry is absent).
2261
2318
  try {
2262
2319
  const lanesDir = join(piDir, "runtime", batchState.batchId, "lanes");
2263
2320
  if (existsSync(lanesDir)) {
@@ -2265,11 +2322,11 @@ export async function executeOrchBatch(
2265
2322
  for (const f of files) {
2266
2323
  try {
2267
2324
  const snap = JSON.parse(readFileSync(join(lanesDir, f), "utf-8"));
2325
+ const laneNumber = typeof snap.laneNumber === "number" ? snap.laneNumber : 0;
2326
+ if (laneNumber <= 0) continue;
2268
2327
  const w = snap.worker || {};
2269
2328
  const r = snap.reviewer || {};
2270
- // Key by lane number for reliable per-task lookup (TP-115)
2271
- const key = `lane-${snap.laneNumber}`;
2272
- laneTokens.set(key, {
2329
+ v2LaneTokensByNumber.set(laneNumber, {
2273
2330
  input: (w.inputTokens || 0) + (r.inputTokens || 0),
2274
2331
  output: (w.outputTokens || 0) + (r.outputTokens || 0),
2275
2332
  cacheRead: (w.cacheReadTokens || 0) + (r.cacheReadTokens || 0),
@@ -2281,50 +2338,50 @@ export async function executeOrchBatch(
2281
2338
  }
2282
2339
  } catch { /* runtime dir may not exist */ }
2283
2340
 
2284
-
2285
- // Legacy fallback: lane-state-*.json sidecars (only if V2 found nothing)
2286
- if (laneTokens.size === 0) {
2287
- try {
2288
- const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
2289
- for (const f of files) {
2290
- try {
2291
- const raw = readFileSync(join(piDir, f), "utf-8").trim();
2292
- if (!raw) continue;
2293
- const data = JSON.parse(raw);
2294
- if (data.prefix) {
2295
- laneTokens.set(data.prefix, {
2296
- input: data.workerInputTokens || 0,
2297
- output: data.workerOutputTokens || 0,
2298
- cacheRead: data.workerCacheReadTokens || 0,
2299
- cacheWrite: data.workerCacheWriteTokens || 0,
2300
- costUsd: data.workerCostUsd || 0,
2301
- });
2302
- }
2303
- } catch { /* skip invalid files */ }
2304
- }
2305
- } catch { /* .pi dir may not exist */ }
2306
- }
2341
+ // Legacy fallback: lane-state-*.json sidecars (pre-V2).
2342
+ try {
2343
+ const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
2344
+ for (const f of files) {
2345
+ try {
2346
+ const raw = readFileSync(join(piDir, f), "utf-8").trim();
2347
+ if (!raw) continue;
2348
+ const data = JSON.parse(raw);
2349
+ if (data.prefix) {
2350
+ legacyLaneTokensByKey.set(data.prefix, {
2351
+ input: data.workerInputTokens || 0,
2352
+ output: data.workerOutputTokens || 0,
2353
+ cacheRead: data.workerCacheReadTokens || 0,
2354
+ cacheWrite: data.workerCacheWriteTokens || 0,
2355
+ costUsd: data.workerCostUsd || 0,
2356
+ });
2357
+ }
2358
+ } catch { /* skip invalid files */ }
2359
+ }
2360
+ } catch { /* .pi dir may not exist */ }
2307
2361
 
2308
2362
  // Build per-task summaries from allTaskOutcomes + wave plan
2309
2363
  const taskSummaries: BatchTaskSummary[] = allTaskOutcomes.map((to) => {
2310
2364
  // Find which wave and lane this task ran in
2311
- let wave = 0, lane = 0;
2365
+ let wave = 0;
2312
2366
  for (let wi = 0; wi < wavePlan.length; wi++) {
2313
2367
  if (wavePlan[wi].includes(to.taskId)) { wave = wi + 1; break; }
2314
2368
  }
2315
- // Match lane via tmux session name
2316
- const laneMatch = to.sessionName?.match(/lane-(\d+)/);
2317
- if (laneMatch) lane = parseInt(laneMatch[1]);
2369
+ const lane = to.laneNumber
2370
+ ?? (() => {
2371
+ const laneMatch = to.sessionName?.match(/lane-(\d+)/);
2372
+ return laneMatch ? parseInt(laneMatch[1], 10) : 0;
2373
+ })();
2318
2374
 
2319
2375
  // Compute duration from start/end times
2320
2376
  const durationMs = (to.startTime && to.endTime) ? (to.endTime - to.startTime) : 0;
2321
2377
 
2322
- // Get tokens for this lane (cumulative shared across tasks in same lane)
2323
- // TP-115: Try lane number key first (V2), then sessionName variants (legacy).
2324
- const tokens = laneTokens.get(`lane-${lane}`)
2325
- || laneTokens.get(to.sessionName)
2326
- || laneTokens.get(to.sessionName?.replace(/-(?:worker|reviewer)$/, ""))
2327
- || { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, costUsd: 0 };
2378
+ // TP-116: Resolve tokens from outcome telemetry first; only fallback for legacy outcomes.
2379
+ const tokens = resolveBatchHistoryTaskTokens(
2380
+ to,
2381
+ lane,
2382
+ v2LaneTokensByNumber,
2383
+ legacyLaneTokensByKey,
2384
+ );
2328
2385
 
2329
2386
  return {
2330
2387
  taskId: to.taskId,
@@ -1369,6 +1369,7 @@ export async function executeLane(
1369
1369
  exitReason: reason,
1370
1370
  sessionName: lane.tmuxSessionName,
1371
1371
  doneFileFound: false,
1372
+ laneNumber: lane.laneNumber,
1372
1373
  });
1373
1374
  continue;
1374
1375
  }
@@ -1399,6 +1400,7 @@ export async function executeLane(
1399
1400
  exitReason: pollResult.exitReason,
1400
1401
  sessionName: lane.tmuxSessionName,
1401
1402
  doneFileFound: pollResult.doneFileFound,
1403
+ laneNumber: lane.laneNumber,
1402
1404
  };
1403
1405
 
1404
1406
  // After task succeeds, commit any uncommitted artifacts (.DONE, final
@@ -1446,6 +1448,7 @@ export async function executeLane(
1446
1448
  exitReason: errMsg,
1447
1449
  sessionName: lane.tmuxSessionName,
1448
1450
  doneFileFound: false,
1451
+ laneNumber: lane.laneNumber,
1449
1452
  };
1450
1453
 
1451
1454
  shouldSkipRemaining = true;
@@ -2577,6 +2580,7 @@ export async function executeWave(
2577
2580
  exitReason: `Lane promise rejected: ${errMsg}`,
2578
2581
  sessionName: lanes[idx].tmuxSessionName,
2579
2582
  doneFileFound: false,
2583
+ laneNumber: lanes[idx].laneNumber,
2580
2584
  })),
2581
2585
  overallStatus: "failed" as const,
2582
2586
  startTime: startedAt,
@@ -2779,6 +2783,7 @@ export async function executeWithStopAll(
2779
2783
  exitReason: `Lane aborted: ${errMsg}`,
2780
2784
  sessionName: lanes[idx].tmuxSessionName,
2781
2785
  doneFileFound: false,
2786
+ laneNumber: lanes[idx].laneNumber,
2782
2787
  })),
2783
2788
  overallStatus: "failed",
2784
2789
  startTime: Date.now(),
@@ -2933,6 +2938,83 @@ export function buildAgentIdFromLane(
2933
2938
  *
2934
2939
  * @since TP-102
2935
2940
  */
2941
+ /**
2942
+ * Parse an agent .md file: extract frontmatter and body.
2943
+ * Returns null if file doesn't exist or is malformed.
2944
+ * @since TP-117
2945
+ */
2946
+ function parseAgentFile(filePath: string): { fm: Record<string, string>; body: string } | null {
2947
+ try {
2948
+ if (!existsSync(filePath)) return null;
2949
+ const raw = readFileSync(filePath, "utf-8");
2950
+ const fmEnd = raw.indexOf("---", 4);
2951
+ if (fmEnd < 0) return { fm: {}, body: raw.trim() };
2952
+ const fmBlock = raw.slice(4, fmEnd).trim();
2953
+ const fm: Record<string, string> = {};
2954
+ for (const line of fmBlock.split("\n")) {
2955
+ const m = line.match(/^([\w-]+)\s*:\s*(.+)/);
2956
+ if (m) fm[m[1]] = m[2].trim();
2957
+ }
2958
+ return { fm, body: raw.slice(fmEnd + 3).trim() };
2959
+ } catch { return null; }
2960
+ }
2961
+
2962
+ /**
2963
+ * Load the base agent prompt from the taskplane package's templates/ directory.
2964
+ * Resolves the package root via well-known npm global paths.
2965
+ * @since TP-117
2966
+ */
2967
+ function loadBaseAgentPrompt(agentName: string): string {
2968
+ const relPath = join("node_modules", "taskplane", "templates", "agents", `${agentName}.md`);
2969
+ const candidates: string[] = [];
2970
+
2971
+ // Global npm paths
2972
+ const home = process.env.HOME || process.env.USERPROFILE || "";
2973
+ if (process.env.APPDATA) candidates.push(join(process.env.APPDATA, "npm", relPath));
2974
+ if (home) {
2975
+ candidates.push(join(home, "AppData", "Roaming", "npm", relPath));
2976
+ candidates.push(join(home, ".npm-global", "lib", relPath));
2977
+ }
2978
+ candidates.push(join("/usr", "local", "lib", relPath));
2979
+ candidates.push(join("/opt", "homebrew", "lib", relPath));
2980
+
2981
+ // Dynamic: npm root -g
2982
+ try {
2983
+ const result = spawnSync("npm", ["root", "-g"], { encoding: "utf-8", timeout: 5000, shell: true });
2984
+ if (result.stdout?.trim()) {
2985
+ candidates.push(join(result.stdout.trim(), "taskplane", "templates", "agents", `${agentName}.md`));
2986
+ }
2987
+ } catch { /* ignore */ }
2988
+
2989
+ for (const p of candidates) {
2990
+ const def = parseAgentFile(p);
2991
+ if (def?.body) return def.body;
2992
+ }
2993
+ return "";
2994
+ }
2995
+
2996
+ /**
2997
+ * Load local project agent prompt from .pi/agents/ or agents/ directory.
2998
+ * Supports standalone mode (local replaces base entirely).
2999
+ * @since TP-117
3000
+ */
3001
+ function loadLocalAgentPrompt(stateRoot: string, agentName: string): string {
3002
+ const paths = [
3003
+ join(stateRoot, ".pi", "agents", `${agentName}.md`),
3004
+ join(stateRoot, "agents", `${agentName}.md`),
3005
+ ];
3006
+ for (const p of paths) {
3007
+ const def = parseAgentFile(p);
3008
+ if (def) {
3009
+ // standalone: true → use local as-is (body only, replaces base)
3010
+ if (def.fm.standalone === "true") return def.body;
3011
+ // Otherwise return body as project-specific guidance to append
3012
+ if (def.body) return def.body;
3013
+ }
3014
+ }
3015
+ return "";
3016
+ }
3017
+
2936
3018
  export function resolveRuntimeStateRoot(
2937
3019
  repoRoot: string,
2938
3020
  workspaceRoot?: string,
@@ -2982,16 +3064,20 @@ export async function executeLaneV2(
2982
3064
  const opId = resolveOperatorId(config);
2983
3065
  const agentIdPrefix = `${tmuxPrefix}-${opId}`;
2984
3066
 
2985
- // Load worker agent definition for system prompt
3067
+ // Load worker agent definition: compose base template + local project guidance.
3068
+ // The base template (templates/agents/task-worker.md) contains critical behavioral
3069
+ // rules: checkpoint discipline, STATUS.md resume algorithm, review_step instructions.
3070
+ // The local file (.pi/agents/task-worker.md) adds project-specific guidance.
2986
3071
  let workerSystemPrompt = "You are a task execution agent. Read STATUS.md first, find unchecked items, work on them, checkpoint after each.";
2987
3072
  try {
2988
- const agentPath = join(stateRoot, ".pi", "agents", "task-worker.md");
2989
- if (existsSync(agentPath)) {
2990
- const raw = readFileSync(agentPath, "utf-8");
2991
- const fmEnd = raw.indexOf("---", 4);
2992
- if (fmEnd > 0) {
2993
- workerSystemPrompt = raw.slice(fmEnd + 3).trim();
2994
- }
3073
+ const basePrompt = loadBaseAgentPrompt("task-worker");
3074
+ const localPrompt = loadLocalAgentPrompt(stateRoot, "task-worker");
3075
+ if (basePrompt && localPrompt) {
3076
+ workerSystemPrompt = basePrompt + "\n\n---\n\n## Project-Specific Guidance\n\n" + localPrompt;
3077
+ } else if (basePrompt) {
3078
+ workerSystemPrompt = basePrompt;
3079
+ } else if (localPrompt) {
3080
+ workerSystemPrompt = localPrompt;
2995
3081
  }
2996
3082
  } catch { /* use default */ }
2997
3083
 
@@ -3011,6 +3097,7 @@ export async function executeLaneV2(
3011
3097
  exitReason: reason,
3012
3098
  sessionName: buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"),
3013
3099
  doneFileFound: false,
3100
+ laneNumber: lane.laneNumber,
3014
3101
  });
3015
3102
  continue;
3016
3103
  }
@@ -3040,7 +3127,10 @@ export async function executeLaneV2(
3040
3127
 
3041
3128
  try {
3042
3129
  const result = await executeTaskV2(unit, laneRunnerConfig, pauseSignal);
3043
- outcomes.push(result.outcome);
3130
+ outcomes.push({
3131
+ ...result.outcome,
3132
+ laneNumber: result.outcome.laneNumber ?? lane.laneNumber,
3133
+ });
3044
3134
 
3045
3135
  // Commit artifacts after success (same as legacy path)
3046
3136
  if (result.outcome.status === "succeeded") {
@@ -3066,6 +3156,7 @@ export async function executeLaneV2(
3066
3156
  exitReason: `Runtime V2 execution error: ${errMsg}`,
3067
3157
  sessionName: buildRuntimeAgentId(agentIdPrefix, lane.laneNumber, "worker"),
3068
3158
  doneFileFound: false,
3159
+ laneNumber: lane.laneNumber,
3069
3160
  });
3070
3161
  shouldSkipRemaining = true;
3071
3162
  }
@@ -496,6 +496,18 @@ function makeResult(
496
496
  statusPath?: string,
497
497
  finalTelemetry?: Partial<AgentHostResult>,
498
498
  ): LaneRunnerTaskResult {
499
+ const telemetry = status === "skipped"
500
+ ? undefined
501
+ : {
502
+ inputTokens: finalTelemetry?.inputTokens ?? 0,
503
+ outputTokens: finalTelemetry?.outputTokens ?? 0,
504
+ cacheReadTokens: finalTelemetry?.cacheReadTokens ?? 0,
505
+ cacheWriteTokens: finalTelemetry?.cacheWriteTokens ?? 0,
506
+ costUsd: finalTelemetry?.costUsd ?? 0,
507
+ toolCalls: finalTelemetry?.toolCalls ?? 0,
508
+ durationMs: finalTelemetry?.durationMs ?? 0,
509
+ };
510
+
499
511
  const result: LaneRunnerTaskResult = {
500
512
  outcome: {
501
513
  taskId,
@@ -505,6 +517,8 @@ function makeResult(
505
517
  exitReason,
506
518
  sessionName,
507
519
  doneFileFound,
520
+ laneNumber: config?.laneNumber,
521
+ telemetry,
508
522
  },
509
523
  iterations,
510
524
  costUsd,
@@ -50,6 +50,21 @@ export function hasTaskDoneMarker(taskFolder: string): boolean {
50
50
  return false;
51
51
  }
52
52
 
53
+ /**
54
+ * Compare optional embedded outcome telemetry.
55
+ */
56
+ function sameOutcomeTelemetry(a: LaneTaskOutcome["telemetry"], b: LaneTaskOutcome["telemetry"]): boolean {
57
+ if (!a && !b) return true;
58
+ if (!a || !b) return false;
59
+ return a.inputTokens === b.inputTokens
60
+ && a.outputTokens === b.outputTokens
61
+ && a.cacheReadTokens === b.cacheReadTokens
62
+ && a.cacheWriteTokens === b.cacheWriteTokens
63
+ && a.costUsd === b.costUsd
64
+ && a.toolCalls === b.toolCalls
65
+ && a.durationMs === b.durationMs;
66
+ }
67
+
53
68
  /**
54
69
  * Upsert a task outcome in-place. Returns true if changed.
55
70
  */
@@ -61,19 +76,27 @@ export function upsertTaskOutcome(outcomes: LaneTaskOutcome[], next: LaneTaskOut
61
76
  }
62
77
 
63
78
  const prev = outcomes[idx];
79
+ const mergedNext: LaneTaskOutcome = {
80
+ ...next,
81
+ laneNumber: next.laneNumber ?? prev.laneNumber,
82
+ telemetry: next.telemetry ?? prev.telemetry,
83
+ };
84
+
64
85
  const changed =
65
- prev.status !== next.status ||
66
- prev.startTime !== next.startTime ||
67
- prev.endTime !== next.endTime ||
68
- prev.exitReason !== next.exitReason ||
69
- prev.sessionName !== next.sessionName ||
70
- prev.doneFileFound !== next.doneFileFound ||
71
- prev.partialProgressCommits !== next.partialProgressCommits ||
72
- prev.partialProgressBranch !== next.partialProgressBranch ||
73
- prev.exitDiagnostic !== next.exitDiagnostic;
86
+ prev.status !== mergedNext.status ||
87
+ prev.startTime !== mergedNext.startTime ||
88
+ prev.endTime !== mergedNext.endTime ||
89
+ prev.exitReason !== mergedNext.exitReason ||
90
+ prev.sessionName !== mergedNext.sessionName ||
91
+ prev.doneFileFound !== mergedNext.doneFileFound ||
92
+ prev.laneNumber !== mergedNext.laneNumber ||
93
+ !sameOutcomeTelemetry(prev.telemetry, mergedNext.telemetry) ||
94
+ prev.partialProgressCommits !== mergedNext.partialProgressCommits ||
95
+ prev.partialProgressBranch !== mergedNext.partialProgressBranch ||
96
+ prev.exitDiagnostic !== mergedNext.exitDiagnostic;
74
97
 
75
98
  if (changed) {
76
- outcomes[idx] = next;
99
+ outcomes[idx] = mergedNext;
77
100
  }
78
101
  return changed;
79
102
  }
@@ -130,6 +153,7 @@ export function seedPendingOutcomesForAllocatedLanes(
130
153
  exitReason: "Pending execution",
131
154
  sessionName: lane.tmuxSessionName,
132
155
  doneFileFound: false,
156
+ laneNumber: lane.laneNumber,
133
157
  }) || changed;
134
158
  }
135
159
  }
@@ -163,6 +187,8 @@ export function syncTaskOutcomesFromMonitor(
163
187
  exitReason: existing?.exitReason || "Pending execution",
164
188
  sessionName: existing?.sessionName || lane.sessionName,
165
189
  doneFileFound: false,
190
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
191
+ telemetry: existing?.telemetry,
166
192
  partialProgressCommits: existing?.partialProgressCommits,
167
193
  partialProgressBranch: existing?.partialProgressBranch,
168
194
  exitDiagnostic: existing?.exitDiagnostic,
@@ -182,6 +208,8 @@ export function syncTaskOutcomesFromMonitor(
182
208
  exitReason: existing?.exitReason || ".DONE file created by task-runner",
183
209
  sessionName: existing?.sessionName || lane.sessionName,
184
210
  doneFileFound: true,
211
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
212
+ telemetry: existing?.telemetry,
185
213
  partialProgressCommits: existing?.partialProgressCommits,
186
214
  partialProgressBranch: existing?.partialProgressBranch,
187
215
  exitDiagnostic: existing?.exitDiagnostic,
@@ -199,6 +227,8 @@ export function syncTaskOutcomesFromMonitor(
199
227
  exitReason: existing?.exitReason || "Task failed or stalled",
200
228
  sessionName: existing?.sessionName || lane.sessionName,
201
229
  doneFileFound: false,
230
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
231
+ telemetry: existing?.telemetry,
202
232
  partialProgressCommits: existing?.partialProgressCommits,
203
233
  partialProgressBranch: existing?.partialProgressBranch,
204
234
  exitDiagnostic: existing?.exitDiagnostic,
@@ -233,6 +263,8 @@ export function syncTaskOutcomesFromMonitor(
233
263
  exitReason: existing?.exitReason || (mappedStatus === "running" ? "Task in progress" : (snap.stallReason || "Task reached terminal state")),
234
264
  sessionName: existing?.sessionName || lane.sessionName,
235
265
  doneFileFound: snap.doneFileFound,
266
+ laneNumber: existing?.laneNumber ?? lane.laneNumber,
267
+ telemetry: existing?.telemetry,
236
268
  partialProgressCommits: existing?.partialProgressCommits,
237
269
  partialProgressBranch: existing?.partialProgressBranch,
238
270
  exitDiagnostic: existing?.exitDiagnostic,
@@ -1164,7 +1196,7 @@ export function serializeBatchState(
1164
1196
 
1165
1197
  const record: PersistedTaskRecord = {
1166
1198
  taskId,
1167
- laneNumber: lane?.laneNumber ?? 0,
1199
+ laneNumber: lane?.laneNumber ?? outcome?.laneNumber ?? 0,
1168
1200
  sessionName: outcome?.sessionName || lane?.tmuxSessionName || "",
1169
1201
  status: outcome?.status ?? "pending",
1170
1202
  taskFolder: "", // Enriched by caller from discovery
@@ -1356,6 +1356,7 @@ export async function resumeOrchBatch(
1356
1356
  exitReason: "Re-executed task completed successfully",
1357
1357
  sessionName: lane.tmuxSessionName,
1358
1358
  doneFileFound: true,
1359
+ laneNumber: lane.laneNumber,
1359
1360
  })),
1360
1361
  overallStatus: "succeeded" as const,
1361
1362
  startTime: Date.now(),
@@ -1474,6 +1475,7 @@ export async function resumeOrchBatch(
1474
1475
  : persistedTask?.exitReason ?? "",
1475
1476
  sessionName: persistedTask?.sessionName ?? "",
1476
1477
  doneFileFound: status === "succeeded" ? true : task.doneFileFound,
1478
+ laneNumber: persistedTask?.laneNumber,
1477
1479
  // Carry forward partial progress from persisted state (TP-028)
1478
1480
  partialProgressCommits: persistedTask?.partialProgressCommits,
1479
1481
  partialProgressBranch: persistedTask?.partialProgressBranch,
@@ -1604,6 +1606,7 @@ export async function resumeOrchBatch(
1604
1606
  : "Task failed (merge retry)",
1605
1607
  sessionName: lane.tmuxSessionName,
1606
1608
  doneFileFound: status === "succeeded",
1609
+ laneNumber: lane.laneNumber,
1607
1610
  };
1608
1611
  });
1609
1612
 
@@ -649,6 +649,30 @@ export interface AllocatedLane {
649
649
  */
650
650
  export type LaneTaskStatus = "pending" | "running" | "succeeded" | "failed" | "stalled" | "skipped";
651
651
 
652
+ /**
653
+ * Embedded telemetry attached to a lane task outcome.
654
+ *
655
+ * Populated by Runtime V2 lane-runner at emission time so downstream
656
+ * consumers (batch history, diagnostics) can read authoritative usage
657
+ * without reconstructing task↔lane joins from snapshot keys.
658
+ */
659
+ export interface LaneTaskOutcomeTelemetry {
660
+ /** Total input tokens for this task outcome. */
661
+ inputTokens: number;
662
+ /** Total output tokens for this task outcome. */
663
+ outputTokens: number;
664
+ /** Total cache-read tokens for this task outcome. */
665
+ cacheReadTokens: number;
666
+ /** Total cache-write tokens for this task outcome. */
667
+ cacheWriteTokens: number;
668
+ /** Cumulative cost in USD for this task outcome. */
669
+ costUsd: number;
670
+ /** Number of tool calls made while producing this outcome. */
671
+ toolCalls: number;
672
+ /** End-to-end duration in milliseconds for this outcome. */
673
+ durationMs: number;
674
+ }
675
+
652
676
  /**
653
677
  * Outcome of a single task execution within a lane.
654
678
  *
@@ -670,6 +694,19 @@ export interface LaneTaskOutcome {
670
694
  sessionName: string;
671
695
  /** Whether .DONE file was found */
672
696
  doneFileFound: boolean;
697
+ /**
698
+ * Lane number that produced this task outcome (1-indexed).
699
+ *
700
+ * Optional for backward compatibility with pre-TP-116 persisted state.
701
+ */
702
+ laneNumber?: number;
703
+ /**
704
+ * Embedded task-level telemetry (authoritative for Runtime V2).
705
+ *
706
+ * Optional for backward compatibility and non-agent outcomes
707
+ * (for example skipped tasks).
708
+ */
709
+ telemetry?: LaneTaskOutcomeTelemetry;
673
710
  /**
674
711
  * Number of commits preserved as partial progress for a failed task.
675
712
  * 0 when no partial progress was saved (succeeded tasks, no commits, etc.).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.23.12",
3
+ "version": "0.23.14",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",