taskplane 0.5.11 → 0.6.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.
@@ -23,12 +23,28 @@ import { Container, Text, truncateToWidth } from "@mariozechner/pi-tui";
23
23
  import { spawn, spawnSync } from "child_process";
24
24
  import {
25
25
  readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, unlinkSync,
26
+ statSync, openSync, readSync, closeSync,
26
27
  } from "fs";
27
- import { tmpdir } from "os";
28
+ import { tmpdir, userInfo } from "os";
28
29
  import { join, dirname, basename, resolve } from "path";
29
30
  import { loadProjectConfig, toTaskConfig } from "./taskplane/config-loader.ts";
30
31
  import { loadWorkspaceConfig, resolvePointer } from "./taskplane/workspace.ts";
31
32
  import type { PointerResolution } from "./taskplane/types.ts";
33
+ import { classifyExit } from "./taskplane/diagnostics.ts";
34
+ import type { TaskExitDiagnostic, ExitSummary } from "./taskplane/diagnostics.ts";
35
+ import {
36
+ generateQualityGatePrompt,
37
+ generateFeedbackMd,
38
+ buildFixAgentPrompt,
39
+ readAndEvaluateVerdict,
40
+ VERDICT_FILENAME,
41
+ FEEDBACK_FILENAME,
42
+ applyStatusReconciliation,
43
+ type QualityGateContext,
44
+ type QualityGateResult,
45
+ type ReviewVerdict,
46
+ type VerdictEvaluation,
47
+ } from "./taskplane/quality-gate.ts";
32
48
 
33
49
 
34
50
  // ── Types ────────────────────────────────────────────────────────────
@@ -56,6 +72,13 @@ interface TaskConfig {
56
72
  no_progress_limit: number;
57
73
  max_worker_minutes?: number;
58
74
  };
75
+ quality_gate: {
76
+ enabled: boolean;
77
+ review_model: string;
78
+ max_review_cycles: number;
79
+ max_fix_cycles: number;
80
+ pass_threshold: "no_critical" | "no_important" | "all_clear";
81
+ };
59
82
  }
60
83
 
61
84
  interface StepInfo {
@@ -98,6 +121,11 @@ interface TaskState {
98
121
  workerCostUsd: number;
99
122
  workerProc: any;
100
123
  workerTimer: any;
124
+ workerRetryActive: boolean;
125
+ workerRetryCount: number;
126
+ workerLastRetryError: string;
127
+ /** Structured exit diagnostic from the most recent tmux worker iteration (null in subprocess mode or before first completion). */
128
+ workerExitDiagnostic: TaskExitDiagnostic | null;
101
129
  reviewerStatus: "idle" | "running" | "done" | "error";
102
130
  reviewerType: string;
103
131
  reviewerElapsed: number;
@@ -116,6 +144,8 @@ function freshState(): TaskState {
116
144
  workerContextPct: 0, workerLastTool: "", workerToolCount: 0,
117
145
  workerInputTokens: 0, workerOutputTokens: 0, workerCacheReadTokens: 0, workerCacheWriteTokens: 0, workerCostUsd: 0,
118
146
  workerProc: null, workerTimer: null,
147
+ workerRetryActive: false, workerRetryCount: 0, workerLastRetryError: "",
148
+ workerExitDiagnostic: null,
119
149
  reviewerStatus: "idle", reviewerType: "", reviewerElapsed: 0,
120
150
  reviewerLastTool: "", reviewerProc: null, reviewerTimer: null,
121
151
  reviewCounter: 0, totalIterations: 0, stepStatuses: new Map(),
@@ -137,6 +167,13 @@ const DEFAULT_CONFIG: TaskConfig = {
137
167
  worker_context_window: 200000, warn_percent: 70, kill_percent: 85,
138
168
  max_worker_iterations: 20, max_review_cycles: 2, no_progress_limit: 3,
139
169
  },
170
+ quality_gate: {
171
+ enabled: false,
172
+ review_model: "",
173
+ max_review_cycles: 2,
174
+ max_fix_cycles: 1,
175
+ pass_threshold: "no_critical",
176
+ },
140
177
  };
141
178
 
142
179
  // ── Pointer Resolution (Workspace Mode) ──────────────────────────────
@@ -326,6 +363,10 @@ function writeLaneState(state: TaskState): void {
326
363
  workerCacheReadTokens: state.workerCacheReadTokens,
327
364
  workerCacheWriteTokens: state.workerCacheWriteTokens,
328
365
  workerCostUsd: state.workerCostUsd,
366
+ workerRetryActive: state.workerRetryActive,
367
+ workerRetryCount: state.workerRetryCount,
368
+ workerLastRetryError: state.workerLastRetryError,
369
+ workerExitDiagnostic: state.workerExitDiagnostic || undefined,
329
370
  reviewerStatus: state.reviewerStatus || "idle",
330
371
  timestamp: Date.now(),
331
372
  };
@@ -446,6 +487,88 @@ function resolveBaseAgentPath(name: string): string {
446
487
  return join(root, "templates", "agents", `${name}.md`);
447
488
  }
448
489
 
490
+ /**
491
+ * Resolve the path to rpc-wrapper.mjs from the installed taskplane package.
492
+ *
493
+ * Resolution strategy (first match wins):
494
+ * 1. Package root via findPackageRoot() (covers global npm, workspace, pi peer)
495
+ * 2. Project-local node_modules/taskplane (for non-workspace local installs)
496
+ * 3. Global npm paths (explicit fallback for layouts findPackageRoot may miss)
497
+ * 4. Extension-file-relative: derive package root from the `-e` arg that loaded
498
+ * this extension (handles dev scenarios where cwd differs from checkout)
499
+ * 5. Development fallback: cwd/bin/rpc-wrapper.mjs (running from taskplane repo)
500
+ *
501
+ * @returns Absolute path to rpc-wrapper.mjs
502
+ * @throws Error if rpc-wrapper.mjs cannot be found
503
+ */
504
+ function resolveRpcWrapperPath(): string {
505
+ const wrapperRelPath = join("bin", "rpc-wrapper.mjs");
506
+ const searched: string[] = [];
507
+
508
+ const tryPath = (dir: string): string | null => {
509
+ const p = join(dir, wrapperRelPath);
510
+ searched.push(p);
511
+ return existsSync(p) ? p : null;
512
+ };
513
+
514
+ // 1. Package root (installed npm package — covers global, workspace, peer)
515
+ const root = findPackageRoot();
516
+ if (root) {
517
+ const found = tryPath(root);
518
+ if (found) return found;
519
+ }
520
+
521
+ // 2. Project-local node_modules (non-workspace local installs)
522
+ const cwdLocal = join(process.cwd(), "node_modules", "taskplane");
523
+ if (existsSync(cwdLocal)) {
524
+ const found = tryPath(cwdLocal);
525
+ if (found) return found;
526
+ }
527
+
528
+ // 3. Global npm paths (explicit check for layouts findPackageRoot may miss)
529
+ const home = process.env.HOME || process.env.USERPROFILE || "";
530
+ const globalCandidates: string[] = [];
531
+ if (process.env.APPDATA) {
532
+ globalCandidates.push(join(process.env.APPDATA, "npm", "node_modules", "taskplane"));
533
+ }
534
+ if (home) {
535
+ globalCandidates.push(join(home, "AppData", "Roaming", "npm", "node_modules", "taskplane"));
536
+ globalCandidates.push(join(home, ".npm-global", "lib", "node_modules", "taskplane"));
537
+ }
538
+ globalCandidates.push(join("/usr", "local", "lib", "node_modules", "taskplane"));
539
+ for (const dir of globalCandidates) {
540
+ const found = tryPath(dir);
541
+ if (found) return found;
542
+ }
543
+
544
+ // 4. Extension-file-relative: derive package root from the -e argument
545
+ // that loaded this file. This covers dev scenarios where the extension
546
+ // is loaded from a local checkout but cwd is a different directory
547
+ // (e.g., a worktree or integration test working directory).
548
+ // This file lives at <package-root>/extensions/task-runner.ts, so walk up two levels.
549
+ try {
550
+ const args = process.argv;
551
+ for (let i = 0; i < args.length - 1; i++) {
552
+ if (args[i] === "-e" && args[i + 1]?.includes("task-runner")) {
553
+ const extPath = resolve(args[i + 1]);
554
+ const derivedRoot = resolve(extPath, "..", "..");
555
+ const found = tryPath(derivedRoot);
556
+ if (found) return found;
557
+ }
558
+ }
559
+ } catch { /* ignore argv parsing errors */ }
560
+
561
+ // 5. Development fallback: running from the taskplane repo directly
562
+ const cwdDev = process.cwd();
563
+ const devFound = tryPath(cwdDev);
564
+ if (devFound) return devFound;
565
+
566
+ throw new Error(
567
+ "Cannot find rpc-wrapper.mjs. Ensure taskplane is installed correctly. " +
568
+ `Searched: ${searched.join(", ")}`
569
+ );
570
+ }
571
+
449
572
  /**
450
573
  * Load an agent definition with prompt inheritance.
451
574
  *
@@ -991,6 +1114,298 @@ function spawnAgent(opts: {
991
1114
  return { promise, kill: () => killFn() };
992
1115
  }
993
1116
 
1117
+ // ── Sidecar JSONL Tailing ────────────────────────────────────────────
1118
+
1119
+ /**
1120
+ * Mutable state for incremental byte-offset sidecar JSONL reading.
1121
+ * One instance per sidecar file, persists across poll ticks within a session.
1122
+ */
1123
+ interface SidecarTailState {
1124
+ /** Byte offset of the next unread position in the sidecar file */
1125
+ offset: number;
1126
+ /** Partial trailing line from the last read (incomplete JSONL line) */
1127
+ partial: string;
1128
+ /** Whether a retry is currently active (persisted across ticks) */
1129
+ retryActive: boolean;
1130
+ }
1131
+
1132
+ function createSidecarTailState(): SidecarTailState {
1133
+ return { offset: 0, partial: "", retryActive: false };
1134
+ }
1135
+
1136
+ /**
1137
+ * Parsed telemetry accumulated from sidecar JSONL events.
1138
+ * Returned by tailSidecarJsonl() on each tick.
1139
+ */
1140
+ interface SidecarTelemetryDelta {
1141
+ /** Per-turn input tokens (sum of new message_end events in this tick) */
1142
+ inputTokens: number;
1143
+ outputTokens: number;
1144
+ cacheReadTokens: number;
1145
+ cacheWriteTokens: number;
1146
+ /** Incremental cost from new message_end events */
1147
+ cost: number;
1148
+ /** Most recent totalTokens from message_end usage (cumulative, for context %) */
1149
+ latestTotalTokens: number;
1150
+ /** Tool calls observed in this tick */
1151
+ toolCalls: number;
1152
+ /** Last tool description from tool_execution_start */
1153
+ lastTool: string;
1154
+ /** Whether a retry is currently active (persisted across ticks via SidecarTailState) */
1155
+ retryActive: boolean;
1156
+ /** Total retries started in this tick */
1157
+ retriesStarted: number;
1158
+ /** Error message from the most recent auto_retry_start */
1159
+ lastRetryError: string;
1160
+ /** Whether any sidecar events were parsed in this tick (used for callback gating) */
1161
+ hadEvents: boolean;
1162
+ }
1163
+
1164
+ /**
1165
+ * Incrementally read new lines from a sidecar JSONL file and parse telemetry events.
1166
+ *
1167
+ * O(new) per call — only reads bytes after the previous offset. Handles:
1168
+ * - File not yet created (returns zero delta)
1169
+ * - Empty reads (no new data since last tick)
1170
+ * - Partial trailing lines (buffered for next call)
1171
+ * - Malformed JSON lines (skipped with stderr warning, does not break iteration)
1172
+ *
1173
+ * The caller (poll loop) accumulates the returned deltas into TaskState.
1174
+ */
1175
+ function tailSidecarJsonl(filePath: string, tailState: SidecarTailState): SidecarTelemetryDelta {
1176
+ const delta: SidecarTelemetryDelta = {
1177
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
1178
+ cost: 0, latestTotalTokens: 0, toolCalls: 0, lastTool: "",
1179
+ retryActive: tailState.retryActive, retriesStarted: 0, lastRetryError: "",
1180
+ hadEvents: false,
1181
+ };
1182
+
1183
+ // Gracefully handle missing file (wrapper hasn't written yet)
1184
+ let fileSize: number;
1185
+ try {
1186
+ fileSize = statSync(filePath).size;
1187
+ } catch {
1188
+ return delta; // File doesn't exist yet — no-op
1189
+ }
1190
+
1191
+ if (fileSize <= tailState.offset) {
1192
+ return delta; // No new data
1193
+ }
1194
+
1195
+ // Read new bytes from offset to end of file
1196
+ const bytesToRead = fileSize - tailState.offset;
1197
+ const buf = Buffer.alloc(bytesToRead);
1198
+ let fd: number;
1199
+ try {
1200
+ fd = openSync(filePath, "r");
1201
+ } catch {
1202
+ return delta; // File became inaccessible between stat and open
1203
+ }
1204
+ try {
1205
+ readSync(fd, buf, 0, bytesToRead, tailState.offset);
1206
+ } catch {
1207
+ closeSync(fd);
1208
+ return delta; // Read error — try again next tick
1209
+ }
1210
+ closeSync(fd);
1211
+ tailState.offset = fileSize;
1212
+
1213
+ // Split into lines, preserving any partial trailing line
1214
+ const chunk = tailState.partial + buf.toString("utf-8");
1215
+ const lines = chunk.split("\n");
1216
+ // Last element is either "" (if chunk ended with \n) or a partial line
1217
+ tailState.partial = lines.pop() || "";
1218
+
1219
+ for (const line of lines) {
1220
+ const trimmed = line.trim();
1221
+ if (!trimmed) continue;
1222
+
1223
+ let event: any;
1224
+ try {
1225
+ event = JSON.parse(trimmed);
1226
+ } catch {
1227
+ // Malformed JSON — skip silently (concurrent write race, truncated line)
1228
+ continue;
1229
+ }
1230
+
1231
+ if (!event || !event.type) continue;
1232
+
1233
+ delta.hadEvents = true;
1234
+
1235
+ switch (event.type) {
1236
+ case "message_end": {
1237
+ const usage = event.message?.usage;
1238
+ if (usage) {
1239
+ delta.inputTokens += usage.input || 0;
1240
+ delta.outputTokens += usage.output || 0;
1241
+ delta.cacheReadTokens += usage.cacheRead || 0;
1242
+ delta.cacheWriteTokens += usage.cacheWrite || 0;
1243
+ if (usage.cost) {
1244
+ delta.cost += typeof usage.cost === "object"
1245
+ ? (usage.cost.total || 0)
1246
+ : (typeof usage.cost === "number" ? usage.cost : 0);
1247
+ }
1248
+ // totalTokens is cumulative (grows each turn) — use latest value
1249
+ const totalTokens = usage.totalTokens
1250
+ || ((usage.input || 0) + (usage.output || 0));
1251
+ if (totalTokens > delta.latestTotalTokens) {
1252
+ delta.latestTotalTokens = totalTokens;
1253
+ }
1254
+ }
1255
+ break;
1256
+ }
1257
+
1258
+ case "tool_execution_start": {
1259
+ delta.toolCalls++;
1260
+ const toolDesc = event.toolName || "unknown";
1261
+ let argPreview = "";
1262
+ if (event.args) {
1263
+ if (typeof event.args === "string") {
1264
+ argPreview = event.args.slice(0, 80);
1265
+ } else if (typeof event.args === "object") {
1266
+ const firstVal = Object.values(event.args)[0];
1267
+ if (typeof firstVal === "string") {
1268
+ argPreview = (firstVal as string).slice(0, 80);
1269
+ }
1270
+ }
1271
+ }
1272
+ delta.lastTool = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
1273
+ break;
1274
+ }
1275
+
1276
+ case "auto_retry_start": {
1277
+ delta.retriesStarted++;
1278
+ delta.lastRetryError = event.errorMessage || event.error || "unknown";
1279
+ tailState.retryActive = true;
1280
+ break;
1281
+ }
1282
+
1283
+ case "auto_retry_end": {
1284
+ tailState.retryActive = false;
1285
+ break;
1286
+ }
1287
+ }
1288
+ }
1289
+
1290
+ // Reflect persisted retry state into the delta for the caller
1291
+ delta.retryActive = tailState.retryActive;
1292
+ return delta;
1293
+ }
1294
+
1295
+ /** Expose sidecar tailing internals for testing (not part of public API). */
1296
+ export const _tailSidecarJsonl = tailSidecarJsonl;
1297
+ export const _createSidecarTailState = createSidecarTailState;
1298
+ export const _getSidecarDir = getSidecarDir;
1299
+ export type { SidecarTailState, SidecarTelemetryDelta };
1300
+
1301
+ // ── Exit Summary & Diagnostic ────────────────────────────────────────
1302
+
1303
+ /**
1304
+ * Read the exit summary JSON file written by rpc-wrapper.mjs.
1305
+ *
1306
+ * Returns null if the file is missing (session vanished) or malformed
1307
+ * (wrapper crashed mid-write). Logs a warning on parse failure but
1308
+ * never throws — the caller should treat null as "session_vanished".
1309
+ */
1310
+ function readExitSummary(exitSummaryPath: string): ExitSummary | null {
1311
+ try {
1312
+ if (!existsSync(exitSummaryPath)) {
1313
+ return null;
1314
+ }
1315
+ const raw = readFileSync(exitSummaryPath, "utf-8").trim();
1316
+ if (!raw) return null;
1317
+ const parsed = JSON.parse(raw);
1318
+ // Minimal shape validation: must be a plain object (not array, not null)
1319
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
1320
+ console.error(`[task-runner] exit summary is not a plain object: ${exitSummaryPath}`);
1321
+ return null;
1322
+ }
1323
+ return parsed as ExitSummary;
1324
+ } catch (err: any) {
1325
+ console.error(`[task-runner] failed to read exit summary: ${err.message}`);
1326
+ return null;
1327
+ }
1328
+ }
1329
+
1330
+ /**
1331
+ * Input parameters for `buildExitDiagnostic()`.
1332
+ *
1333
+ * Bridges the task-runner's runtime state into `classifyExit()` input
1334
+ * and populates the full `TaskExitDiagnostic` with progress metadata.
1335
+ */
1336
+ interface BuildExitDiagnosticInput {
1337
+ /** Exit summary from rpc-wrapper.mjs (null if file missing) */
1338
+ exitSummary: ExitSummary | null;
1339
+ /** Whether .DONE file was found */
1340
+ doneFileFound: boolean;
1341
+ /** Whether the wall-clock timer killed the session */
1342
+ timerKilled: boolean;
1343
+ /** Whether the context-limit kill was triggered */
1344
+ contextKilled: boolean;
1345
+ /** Whether the user manually killed the session */
1346
+ userKilled: boolean;
1347
+ /** Estimated context utilization % from sidecar tailing (0-100) */
1348
+ contextPct: number;
1349
+ /** Wall-clock duration in seconds */
1350
+ durationSec: number;
1351
+ /** Repo identifier ("default" in repo mode, repo key in workspace mode) */
1352
+ repoId: string;
1353
+ /** Last known step number from STATUS.md (null if not parsed) */
1354
+ lastKnownStep: number | null;
1355
+ /** Last known checkbox text from STATUS.md (null if not parsed) */
1356
+ lastKnownCheckbox: string | null;
1357
+ /** Number of commits representing partial progress (0 if none) */
1358
+ partialProgressCommits: number;
1359
+ /** Branch name holding partial progress (null if no branch) */
1360
+ partialProgressBranch: string | null;
1361
+ }
1362
+
1363
+ /**
1364
+ * Build a structured `TaskExitDiagnostic` from task-runner runtime state.
1365
+ *
1366
+ * Calls `classifyExit()` with the appropriate signal mapping, then
1367
+ * enriches the result with progress metadata (commits, step, repo).
1368
+ *
1369
+ * Signal mapping:
1370
+ * - `stallDetected` in ExitClassificationInput ← not directly available in
1371
+ * task-runner's tmux mode (stall detection is orchestrator-level), so
1372
+ * always false here. Stall classification may still occur via orchestrator.
1373
+ * - `contextKilled` ← when the task-runner explicitly kills the session
1374
+ * due to context limit. Passed to classifyExit() so it can produce
1375
+ * `context_overflow` even when exit summary is missing or lacks
1376
+ * compaction events (e.g., wrapper crashed before writing summary).
1377
+ */
1378
+ function buildExitDiagnostic(input: BuildExitDiagnosticInput): TaskExitDiagnostic {
1379
+ const classification = classifyExit({
1380
+ exitSummary: input.exitSummary,
1381
+ doneFileFound: input.doneFileFound,
1382
+ timerKilled: input.timerKilled,
1383
+ contextKilled: input.contextKilled,
1384
+ stallDetected: false, // Stall detection is orchestrator-level, not available in /task mode
1385
+ userKilled: input.userKilled,
1386
+ contextPct: input.contextPct,
1387
+ });
1388
+
1389
+ return {
1390
+ classification,
1391
+ exitCode: input.exitSummary?.exitCode ?? null,
1392
+ errorMessage: input.exitSummary?.error ?? null,
1393
+ tokensUsed: input.exitSummary?.tokens ?? null,
1394
+ contextPct: input.contextPct,
1395
+ partialProgressCommits: input.partialProgressCommits,
1396
+ partialProgressBranch: input.partialProgressBranch,
1397
+ durationSec: input.durationSec,
1398
+ lastKnownStep: input.lastKnownStep,
1399
+ lastKnownCheckbox: input.lastKnownCheckbox,
1400
+ repoId: input.repoId,
1401
+ };
1402
+ }
1403
+
1404
+ /** Expose exit summary/diagnostic helpers for testing. */
1405
+ export const _readExitSummary = readExitSummary;
1406
+ export const _buildExitDiagnostic = buildExitDiagnostic;
1407
+ export type { BuildExitDiagnosticInput };
1408
+
994
1409
  // ── TMUX Agent Spawner ───────────────────────────────────────────────
995
1410
 
996
1411
  /**
@@ -1011,7 +1426,9 @@ function spawnAgent(opts: {
1011
1426
  * - Session creation failure (throws after cleanup)
1012
1427
  *
1013
1428
  * Parity with spawnAgent():
1014
- * - Return shape: identical — { promise, kill }
1429
+ * - Return shape: extended — { promise, kill, sidecarPath, exitSummaryPath }
1430
+ * (promise and kill are drop-in compatible; sidecarPath and exitSummaryPath
1431
+ * are additions for RPC telemetry consumption in Steps 2/3)
1015
1432
  * - Promise result: identical fields — { output, exitCode, elapsed, killed }
1016
1433
  * - Kill semantics: sets killed=true, terminates session, cleans temp files
1017
1434
  * - Elapsed calc: Date.now() - startTime (same pattern)
@@ -1019,6 +1436,15 @@ function spawnAgent(opts: {
1019
1436
  * - output: always "" (no JSON stream in TMUX mode)
1020
1437
  * - exitCode: 0 on normal completion, 1 on poll error (TMUX doesn't forward exit codes)
1021
1438
  *
1439
+ * RPC Wrapper Integration (TP-026):
1440
+ * Instead of spawning `pi -p` directly, this function now spawns `rpc-wrapper.mjs`
1441
+ * which runs pi in RPC mode and produces:
1442
+ * - Sidecar JSONL file with real-time telemetry (tokens, cost, tool calls, retries)
1443
+ * - Exit summary JSON with structured exit data for classification
1444
+ *
1445
+ * The telemetry file paths are returned alongside the promise/kill handles so that
1446
+ * Steps 2 (sidecar tailing) and 3 (exit diagnostic) can read them.
1447
+ *
1022
1448
  * @param opts.sessionName — TMUX session name (e.g., "orch-lane-1-worker")
1023
1449
  * @param opts.cwd — Working directory for the TMUX session
1024
1450
  * @param opts.systemPrompt — System prompt content (written to temp file)
@@ -1026,6 +1452,7 @@ function spawnAgent(opts: {
1026
1452
  * @param opts.model — Model identifier (e.g., "anthropic/claude-sonnet-4-20250514")
1027
1453
  * @param opts.tools — Comma-separated tool list
1028
1454
  * @param opts.thinking — Thinking mode ("off", "on", etc.)
1455
+ * @param opts.taskId — Optional task ID for telemetry filename enrichment (e.g., "TP-026")
1029
1456
  */
1030
1457
  function spawnAgentTmux(opts: {
1031
1458
  sessionName: string;
@@ -1035,7 +1462,17 @@ function spawnAgentTmux(opts: {
1035
1462
  model: string;
1036
1463
  tools: string;
1037
1464
  thinking: string;
1038
- }): { promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>; kill: () => void } {
1465
+ taskId?: string;
1466
+ /** Called on each poll tick with accumulated telemetry from the sidecar JSONL.
1467
+ * Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
1468
+ * with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
1469
+ onTelemetry?: (delta: SidecarTelemetryDelta) => void;
1470
+ }): {
1471
+ promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1472
+ kill: () => void;
1473
+ sidecarPath: string;
1474
+ exitSummaryPath: string;
1475
+ } {
1039
1476
 
1040
1477
  // ── Preflight: verify tmux is available ──────────────────────────
1041
1478
  const tmuxCheck = spawnSync("tmux", ["-V"], { shell: true });
@@ -1047,10 +1484,59 @@ function spawnAgentTmux(opts: {
1047
1484
  );
1048
1485
  }
1049
1486
 
1487
+ // ── Generate telemetry file paths ───────────────────────────────
1488
+ // Naming contract from resilience roadmap:
1489
+ // .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
1490
+ //
1491
+ // In standalone /task mode (no orchestrator):
1492
+ // opId → TASKPLANE_OPERATOR_ID env, or OS username, or "op"
1493
+ // batchId → timestamp (no batch concept in standalone mode)
1494
+ // repoId → "default" (single-repo mode)
1495
+ // taskId → from opts.taskId when provided (e.g., "tp-026"), omitted if absent
1496
+ // lane → omitted (no lanes)
1497
+ // role → derived from sessionName suffix (worker/reviewer)
1498
+ //
1499
+ // getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
1500
+ const telemetryTs = Date.now();
1501
+
1502
+ // Resolve opId: same priority chain as naming.ts resolveOperatorId()
1503
+ let opId = "op";
1504
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1505
+ if (envOpId?.trim()) {
1506
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1507
+ } else {
1508
+ try {
1509
+ const username = userInfo().username;
1510
+ if (username?.trim()) {
1511
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1512
+ }
1513
+ } catch { /* userInfo() can throw on some platforms */ }
1514
+ }
1515
+
1516
+ const batchId = String(telemetryTs);
1517
+ const repoId = "default";
1518
+
1519
+ // Extract role (worker/reviewer) from sessionName, and optional lane component
1520
+ // sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
1521
+ const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1522
+ const laneMatch = opts.sessionName.match(/lane-(\d+)/);
1523
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1524
+
1525
+ // Include taskId when available — sanitize to filesystem-safe characters.
1526
+ // Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
1527
+ const taskIdSegment = opts.taskId
1528
+ ? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1529
+ : "";
1530
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1531
+ const telemetryDir = join(getSidecarDir(), "telemetry");
1532
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1533
+ const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
1534
+ const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
1535
+
1050
1536
  // ── Write prompts to temp files ─────────────────────────────────
1051
1537
  // Same pattern as spawnAgent() — avoids shell escaping issues with
1052
1538
  // backticks, quotes, and special characters in markdown content.
1053
- const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1539
+ const id = `${telemetryTs}-${Math.random().toString(36).slice(2, 8)}`;
1054
1540
  const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
1055
1541
  const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
1056
1542
  writeFileSync(sysTmpFile, opts.systemPrompt);
@@ -1061,10 +1547,14 @@ function spawnAgentTmux(opts: {
1061
1547
  try { unlinkSync(promptTmpFile); } catch {}
1062
1548
  };
1063
1549
 
1064
- // ── Build Pi command ─────────────────────────────────────────────
1065
- // Use an array of arguments and quote each one individually to handle
1066
- // paths with spaces (Windows paths, temp dir, etc.). The command is
1067
- // passed as a single string to tmux new-session, so we shell-quote it.
1550
+ // ── Build RPC Wrapper command ────────────────────────────────────
1551
+ // Spawns `node rpc-wrapper.mjs` instead of `pi -p`. The wrapper runs
1552
+ // pi in RPC mode, captures telemetry to the sidecar JSONL, and writes
1553
+ // a structured exit summary JSON on process exit.
1554
+ //
1555
+ // Shell quoting: use quoteArg() for all path arguments — same quoting
1556
+ // guarantees as the previous `pi -p` command since both execute as a
1557
+ // single shell string via tmux new-session.
1068
1558
  const quoteArg = (s: string): string => {
1069
1559
  // If the arg contains spaces, quotes, or shell metacharacters, wrap in single quotes.
1070
1560
  // Inside single quotes, escape existing single quotes as '\'' (end quote, escaped quote, restart quote).
@@ -1074,17 +1564,24 @@ function spawnAgentTmux(opts: {
1074
1564
  return s;
1075
1565
  };
1076
1566
 
1077
- const piArgs = [
1078
- "pi",
1079
- "-p", // Non-interactive: process prompt and exit (without this, pi waits for more input)
1080
- "--no-session", "--no-extensions", "--no-skills",
1567
+ // Resolve rpc-wrapper.mjs path from the installed package
1568
+ const rpcWrapperPath = resolveRpcWrapperPath();
1569
+
1570
+ const wrapperArgs = [
1571
+ "node", quoteArg(rpcWrapperPath),
1572
+ "--sidecar-path", quoteArg(sidecarPath),
1573
+ "--exit-summary-path", quoteArg(exitSummaryPath),
1081
1574
  "--model", quoteArg(opts.model),
1575
+ "--system-prompt-file", quoteArg(sysTmpFile),
1576
+ "--prompt-file", quoteArg(promptTmpFile),
1082
1577
  "--tools", quoteArg(opts.tools),
1578
+ // Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
1579
+ // Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
1580
+ "--",
1083
1581
  "--thinking", quoteArg(opts.thinking),
1084
- "--append-system-prompt", quoteArg(sysTmpFile),
1085
- `@${quoteArg(promptTmpFile)}`,
1582
+ "--no-extensions", "--no-skills",
1086
1583
  ];
1087
- const piCommand = piArgs.join(" ");
1584
+ const wrapperCommand = wrapperArgs.join(" ");
1088
1585
 
1089
1586
  // ── Handle stale session ─────────────────────────────────────────
1090
1587
  // Session names are fixed per role (e.g., "orch-lane-1-worker").
@@ -1101,7 +1598,7 @@ function spawnAgentTmux(opts: {
1101
1598
  // Pi's ink/react TUI hangs with TERM=tmux-256color (tmux default), so we
1102
1599
  // force xterm-256color.
1103
1600
  const tmuxCwd = opts.cwd.replace(/^([A-Za-z]):\\/, (_, d: string) => `/${d.toLowerCase()}/`).replace(/\\/g, "/");
1104
- const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${piCommand}`;
1601
+ const wrappedCommand = `cd ${quoteArg(tmuxCwd)} && TERM=xterm-256color ${wrapperCommand}`;
1105
1602
  const createResult = spawnSync("tmux", [
1106
1603
  "new-session", "-d",
1107
1604
  "-s", opts.sessionName,
@@ -1124,14 +1621,32 @@ function spawnAgentTmux(opts: {
1124
1621
  // ── Poll until session ends ─────────────────────────────────────
1125
1622
  let killed = false;
1126
1623
  const startTime = Date.now();
1624
+ const tailState = createSidecarTailState();
1127
1625
 
1128
1626
  const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
1129
1627
  try {
1130
1628
  while (true) {
1131
1629
  await new Promise(r => setTimeout(r, 2000));
1630
+
1631
+ // Tail sidecar JSONL for telemetry updates on each tick
1632
+ if (opts.onTelemetry) {
1633
+ const delta = tailSidecarJsonl(sidecarPath, tailState);
1634
+ // Call back whenever events were parsed (including retry state transitions)
1635
+ if (delta.hadEvents) {
1636
+ opts.onTelemetry(delta);
1637
+ }
1638
+ }
1639
+
1132
1640
  const result = spawnSync("tmux", ["has-session", "-t", opts.sessionName]);
1133
1641
  if (result.status !== 0) {
1134
1642
  // Session no longer exists — Pi exited, TMUX closed
1643
+ // Final tail to catch any events written since last tick
1644
+ if (opts.onTelemetry) {
1645
+ const finalDelta = tailSidecarJsonl(sidecarPath, tailState);
1646
+ if (finalDelta.hadEvents) {
1647
+ opts.onTelemetry(finalDelta);
1648
+ }
1649
+ }
1135
1650
  break;
1136
1651
  }
1137
1652
  }
@@ -1174,7 +1689,7 @@ function spawnAgentTmux(opts: {
1174
1689
  console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
1175
1690
  };
1176
1691
 
1177
- return { promise, kill };
1692
+ return { promise, kill, sidecarPath, exitSummaryPath };
1178
1693
  }
1179
1694
 
1180
1695
  // ── Display Helpers ──────────────────────────────────────────────────
@@ -1403,11 +1918,127 @@ export default function (pi: ExtensionAPI) {
1403
1918
  if (state.phase === "error" || state.phase === "paused") return;
1404
1919
  }
1405
1920
 
1406
- // All done
1407
- const donePath = join(task.taskFolder, ".DONE");
1408
- writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
1409
- updateStatusField(statusPath, "Status", "✅ Complete");
1410
- logExecution(statusPath, "Task complete", ".DONE created");
1921
+ // All steps done — run quality gate if enabled, then create .DONE
1922
+ if (config.quality_gate.enabled) {
1923
+ // ── Quality Gate Enabled ─────────────────────────────────
1924
+ // Run structured review cycles with remediation. .DONE only
1925
+ // created after PASS verdict — never delete/recreate.
1926
+ const maxReviewCycles = config.quality_gate.max_review_cycles;
1927
+ const maxFixCycles = config.quality_gate.max_fix_cycles;
1928
+ let reviewCycle = 0;
1929
+ let fixCyclesUsed = 0;
1930
+ let gatePassed = false;
1931
+ let lastVerdict: ReviewVerdict | null = null;
1932
+
1933
+ const gateContext: QualityGateContext = {
1934
+ taskFolder: task.taskFolder,
1935
+ promptPath: task.promptPath,
1936
+ taskId: task.taskId,
1937
+ projectName: config.project.name,
1938
+ passThreshold: config.quality_gate.pass_threshold,
1939
+ };
1940
+
1941
+ logExecution(statusPath, "Quality gate", `Enabled (threshold: ${config.quality_gate.pass_threshold}, max reviews: ${maxReviewCycles}, max fixes: ${maxFixCycles})`);
1942
+
1943
+ while (reviewCycle < maxReviewCycles) {
1944
+ reviewCycle++;
1945
+ const result = await doQualityGateReview(ctx, reviewCycle);
1946
+ lastVerdict = result.verdict;
1947
+
1948
+ if (result.passed) {
1949
+ gatePassed = true;
1950
+ break;
1951
+ }
1952
+
1953
+ // NEEDS_FIXES — check if we can still do a fix cycle
1954
+ if (reviewCycle >= maxReviewCycles) {
1955
+ // No more review cycles left — terminal failure
1956
+ logExecution(statusPath, "Quality gate", `Max review cycles (${maxReviewCycles}) exhausted — no more reviews allowed`);
1957
+ break;
1958
+ }
1959
+
1960
+ if (fixCyclesUsed >= maxFixCycles) {
1961
+ // No more fix cycles allowed
1962
+ logExecution(statusPath, "Quality gate", `Max fix cycles (${maxFixCycles}) exhausted — cannot remediate`);
1963
+ break;
1964
+ }
1965
+
1966
+ // ── Remediation: write feedback, spawn fix agent ─────
1967
+ fixCyclesUsed++;
1968
+
1969
+ // Write REVIEW_FEEDBACK.md with blocking findings
1970
+ const feedbackContent = generateFeedbackMd(result.verdict, reviewCycle, maxReviewCycles, config.quality_gate.pass_threshold);
1971
+ const feedbackPath = join(task.taskFolder, FEEDBACK_FILENAME);
1972
+ try {
1973
+ writeFileSync(feedbackPath, feedbackContent);
1974
+ logExecution(statusPath, "Quality gate", `Wrote ${FEEDBACK_FILENAME} (fix cycle ${fixCyclesUsed}/${maxFixCycles})`);
1975
+ } catch (err: any) {
1976
+ logExecution(statusPath, "Quality gate", `Failed to write ${FEEDBACK_FILENAME}: ${err?.message} — skipping remediation`);
1977
+ break;
1978
+ }
1979
+
1980
+ // Build fix agent prompt
1981
+ const fixPrompt = buildFixAgentPrompt(gateContext, feedbackContent, fixCyclesUsed);
1982
+
1983
+ // Spawn fix agent (reuses worker spawn pattern)
1984
+ const fixResult = await doQualityGateFixAgent(ctx, fixPrompt, fixCyclesUsed);
1985
+
1986
+ if (fixResult.timedOut) {
1987
+ // Fix agent hit wall-clock timeout — budget consumed deterministically
1988
+ logExecution(statusPath, "Quality gate", `Fix agent timed out (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — budget consumed, proceeding to re-review`);
1989
+ } else if (fixResult.exitCode !== 0) {
1990
+ // Fix agent abnormal exit — consumes fix budget, log and continue to re-review
1991
+ logExecution(statusPath, "Quality gate", `Fix agent exited with code ${fixResult.exitCode} (cycle ${fixCyclesUsed}) — budget consumed, proceeding to re-review`);
1992
+ } else {
1993
+ logExecution(statusPath, "Quality gate", `Fix agent completed (cycle ${fixCyclesUsed}, ${Math.round(fixResult.elapsed / 1000)}s) — proceeding to re-review`);
1994
+ }
1995
+
1996
+ // Loop back to the top for re-review
1997
+ }
1998
+
1999
+ if (gatePassed) {
2000
+ // PASS → create .DONE
2001
+ const donePath = join(task.taskFolder, ".DONE");
2002
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\nQuality gate: PASS (cycle ${reviewCycle})\n`);
2003
+ updateStatusField(statusPath, "Status", "✅ Complete");
2004
+ logExecution(statusPath, "Task complete", `.DONE created (quality gate PASS, cycle ${reviewCycle})`);
2005
+ } else {
2006
+ // Gate failed — do NOT create .DONE
2007
+ // Persist blocking findings summary for operator visibility
2008
+ if (lastVerdict) {
2009
+ const criticals = lastVerdict.findings.filter(f => f.severity === "critical");
2010
+ const importants = lastVerdict.findings.filter(f => f.severity === "important");
2011
+ const suggestions = lastVerdict.findings.filter(f => f.severity === "suggestion");
2012
+ const summaryParts = [
2013
+ criticals.length > 0 ? `${criticals.length} critical` : "",
2014
+ importants.length > 0 ? `${importants.length} important` : "",
2015
+ // Include suggestion counts when they are blocking (all_clear threshold)
2016
+ (config.quality_gate.pass_threshold === "all_clear" && suggestions.length > 0)
2017
+ ? `${suggestions.length} suggestion` : "",
2018
+ ].filter(Boolean);
2019
+ const findingsSummary = summaryParts.join(", ");
2020
+ logExecution(statusPath, "Quality gate failed",
2021
+ `${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). ` +
2022
+ `Blocking findings: ${findingsSummary || "none extracted"}. ` +
2023
+ `Summary: ${lastVerdict.summary}`);
2024
+ } else {
2025
+ logExecution(statusPath, "Quality gate failed", `Task did not pass after ${reviewCycle} review cycle(s)`);
2026
+ }
2027
+
2028
+ state.phase = "error";
2029
+ updateStatusField(statusPath, "Status", "❌ Quality gate failed");
2030
+ ctx.ui.notify(`❌ Quality gate failed after ${reviewCycle} review cycle(s), ${fixCyclesUsed} fix cycle(s). .DONE not created.`, "error");
2031
+ updateWidgets();
2032
+ return;
2033
+ }
2034
+ } else {
2035
+ // ── Quality Gate Disabled (default) ──────────────────────
2036
+ // Unchanged behavior — create .DONE immediately.
2037
+ const donePath = join(task.taskFolder, ".DONE");
2038
+ writeFileSync(donePath, `Completed: ${new Date().toISOString()}\nTask: ${task.taskId}\n`);
2039
+ updateStatusField(statusPath, "Status", "✅ Complete");
2040
+ logExecution(statusPath, "Task complete", ".DONE created");
2041
+ }
1411
2042
 
1412
2043
  // Auto-archive: move task folder to tasks/archive/.
1413
2044
  // In orchestrated runs, do NOT archive here — the orchestrator polls
@@ -1591,6 +2222,9 @@ export default function (pi: ExtensionAPI) {
1591
2222
  state.workerContextPct = 0;
1592
2223
  state.workerLastTool = "";
1593
2224
  state.workerToolCount = 0;
2225
+ state.workerRetryActive = false;
2226
+ state.workerRetryCount = 0;
2227
+ state.workerLastRetryError = "";
1594
2228
  updateWidgets();
1595
2229
 
1596
2230
  const startTime = Date.now();
@@ -1604,12 +2238,22 @@ export default function (pi: ExtensionAPI) {
1604
2238
  let kill: () => void;
1605
2239
  let wallClockWarnTimer: ReturnType<typeof setTimeout> | null = null;
1606
2240
  let wallClockKillTimer: ReturnType<typeof setTimeout> | null = null;
2241
+ // Track why the session was killed for exit classification.
2242
+ // "timer" = wall-clock timeout, "context" = context % limit, "user" = manual kill.
2243
+ let killReason: "timer" | "context" | "user" | null = null;
2244
+ // Exit summary path — set only in tmux mode (rpc-wrapper produces this file).
2245
+ let exitSummaryPath: string | null = null;
1607
2246
 
1608
2247
  if (spawnMode === "tmux") {
1609
2248
  // ── TMUX mode ────────────────────────────────────────
1610
- // No JSON stream no onToolCall/onContextPct callbacks.
1611
- // Kill via wall-clock timeout instead of context-%.
2249
+ // Sidecar JSONL provides telemetry parity: tokens, cost, context%,
2250
+ // tool calls, and retry events same signals as subprocess mode.
2251
+ // Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
1612
2252
  const sessionName = `${getTmuxPrefix()}-worker`;
2253
+ const contextWindow = config.context.worker_context_window;
2254
+ const warnPct = config.context.warn_percent;
2255
+ const killPct = config.context.kill_percent;
2256
+
1613
2257
  const spawned = spawnAgentTmux({
1614
2258
  sessionName,
1615
2259
  cwd: ctx.cwd,
@@ -1618,12 +2262,53 @@ export default function (pi: ExtensionAPI) {
1618
2262
  model,
1619
2263
  tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
1620
2264
  thinking: config.worker.thinking || "off",
2265
+ taskId: task.taskId,
2266
+ onTelemetry: (delta) => {
2267
+ // Accumulate tokens and cost (same as subprocess onTokenUpdate)
2268
+ state.workerInputTokens += delta.inputTokens;
2269
+ state.workerOutputTokens += delta.outputTokens;
2270
+ state.workerCacheReadTokens += delta.cacheReadTokens;
2271
+ state.workerCacheWriteTokens += delta.cacheWriteTokens;
2272
+ state.workerCostUsd += delta.cost;
2273
+
2274
+ // Tool tracking (same as subprocess onToolCall)
2275
+ state.workerToolCount += delta.toolCalls;
2276
+ if (delta.lastTool) {
2277
+ state.workerLastTool = delta.lastTool;
2278
+ }
2279
+
2280
+ // Retry tracking
2281
+ state.workerRetryCount += delta.retriesStarted;
2282
+ state.workerRetryActive = delta.retryActive;
2283
+ if (delta.lastRetryError) {
2284
+ state.workerLastRetryError = delta.lastRetryError;
2285
+ }
2286
+
2287
+ // Context % (same as subprocess onContextPct)
2288
+ // totalTokens is cumulative from the most recent message_end
2289
+ if (delta.latestTotalTokens > 0 && contextWindow > 0) {
2290
+ const pct = (delta.latestTotalTokens / contextWindow) * 100;
2291
+ state.workerContextPct = pct;
2292
+ if (pct >= warnPct) {
2293
+ writeWrapUpSignal(`Wrap up (context ${Math.round(pct)}%)`);
2294
+ }
2295
+ if (pct >= killPct && state.workerStatus === "running") {
2296
+ console.error(`[task-runner] tmux worker: context limit (${Math.round(pct)}%) — killing session '${sessionName}'`);
2297
+ killReason = "context";
2298
+ spawned.kill();
2299
+ }
2300
+ }
2301
+
2302
+ updateWidgets();
2303
+ },
1621
2304
  });
1622
2305
  promise = spawned.promise;
1623
2306
  kill = spawned.kill;
2307
+ exitSummaryPath = spawned.exitSummaryPath;
1624
2308
 
1625
2309
  // Wall-clock timeout: write wrap-up file at 80% of limit,
1626
- // hard kill at 100%. No context telemetry in TMUX mode.
2310
+ // hard kill at 100%. Context-% based wrap-up/kill is also active
2311
+ // via sidecar telemetry (above), providing dual safety nets.
1627
2312
  const maxMinutes = getMaxWorkerMinutes(config);
1628
2313
  const warnMs = Math.round(maxMinutes * 0.8 * 60_000);
1629
2314
  const killMs = maxMinutes * 60_000;
@@ -1643,6 +2328,7 @@ export default function (pi: ExtensionAPI) {
1643
2328
  wallClockKillTimer = setTimeout(() => {
1644
2329
  if (state.workerStatus === "running" && state.totalIterations === iterationMarker) {
1645
2330
  console.error(`[task-runner] tmux worker: wall-clock timeout (${maxMinutes}min) — killing session '${sessionName}'`);
2331
+ killReason = "timer";
1646
2332
  kill();
1647
2333
  }
1648
2334
  }, killMs);
@@ -1718,12 +2404,57 @@ export default function (pi: ExtensionAPI) {
1718
2404
 
1719
2405
  clearWrapUpSignals();
1720
2406
 
1721
- // Log with mode-appropriate detail: subprocess has context%, TMUX does not
1722
- const killedMsg = spawnMode === "tmux" ? "killed (wall-clock timeout)" : "killed (context limit)";
1723
- const statusMsg = result.killed ? killedMsg : (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
1724
- const ctxDetail = spawnMode === "tmux" ? "" : `, ctx: ${Math.round(state.workerContextPct)}%`;
2407
+ // ── Exit Diagnostic (tmux mode only) ─────────────────────
2408
+ // Read the exit summary JSON written by rpc-wrapper.mjs, classify
2409
+ // the exit, and build a structured diagnostic for persistence.
2410
+ // Subprocess mode doesn't produce exit summaries (it uses JSON
2411
+ // event stream directly), so this path is tmux-only.
2412
+ if (spawnMode === "tmux" && exitSummaryPath) {
2413
+ const exitSummary = readExitSummary(exitSummaryPath);
2414
+ const donePath = join(task.taskFolder, ".DONE");
2415
+ const doneFileFound = existsSync(donePath);
2416
+
2417
+ // Determine userKilled: killed is true but not by timer or context
2418
+ const userKilled = result.killed && killReason === null;
2419
+
2420
+ const diagnostic = buildExitDiagnostic({
2421
+ exitSummary,
2422
+ doneFileFound,
2423
+ timerKilled: killReason === "timer",
2424
+ contextKilled: killReason === "context",
2425
+ userKilled,
2426
+ contextPct: state.workerContextPct,
2427
+ durationSec: Math.round(state.workerElapsed / 1000),
2428
+ repoId: process.env.TASKPLANE_REPO_ID || "default",
2429
+ lastKnownStep: state.currentStep || null,
2430
+ lastKnownCheckbox: null, // Not parsed in task-runner; available via STATUS.md
2431
+ partialProgressCommits: 0, // Computed by orchestrator after commit
2432
+ partialProgressBranch: null,
2433
+ });
2434
+
2435
+ // Store diagnostic on state for lane-state sidecar and logging
2436
+ state.workerExitDiagnostic = diagnostic;
2437
+
2438
+ console.error(`[task-runner] exit diagnostic: ${diagnostic.classification}` +
2439
+ (diagnostic.exitCode !== null ? ` (exit ${diagnostic.exitCode})` : "") +
2440
+ (exitSummary ? `` : " (no exit summary)"));
2441
+
2442
+ // Log telemetry file paths for operator visibility (files preserved for dashboard)
2443
+ const sidecarPath = exitSummaryPath.replace(/-exit\.json$/, ".jsonl");
2444
+ console.error(`[task-runner] telemetry files preserved:` +
2445
+ `\n sidecar: ${sidecarPath}` +
2446
+ `\n exit summary: ${exitSummaryPath}`);
2447
+ }
2448
+
2449
+ // Log with telemetry detail — both subprocess and TMUX now have context%
2450
+ const killedMsg = result.killed
2451
+ ? (spawnMode === "tmux"
2452
+ ? `killed (${killReason === "context" ? "context limit" : killReason === "timer" ? "wall-clock timeout" : "user"})`
2453
+ : "killed (context limit)")
2454
+ : "";
2455
+ const statusMsg = killedMsg || (result.exitCode === 0 ? "done" : `error (code ${result.exitCode})`);
1725
2456
  logExecution(statusPath, `Worker iter ${state.totalIterations}`,
1726
- `${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s${ctxDetail}, tools: ${state.workerToolCount}`);
2457
+ `${statusMsg} in ${Math.round(state.workerElapsed / 1000)}s, ctx: ${Math.round(state.workerContextPct)}%, tools: ${state.workerToolCount}`);
1727
2458
 
1728
2459
  updateWidgets();
1729
2460
  }
@@ -1783,6 +2514,7 @@ export default function (pi: ExtensionAPI) {
1783
2514
  model: reviewerModel,
1784
2515
  tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
1785
2516
  thinking: config.reviewer.thinking || "on",
2517
+ taskId: state.task?.taskId,
1786
2518
  });
1787
2519
  reviewPromise = spawned.promise;
1788
2520
  state.reviewerProc = { kill: spawned.kill };
@@ -1833,6 +2565,355 @@ export default function (pi: ExtensionAPI) {
1833
2565
  return verdict;
1834
2566
  }
1835
2567
 
2568
+ // ── Quality Gate ─────────────────────────────────────────────────
2569
+
2570
+ /**
2571
+ * Run a single quality gate review cycle.
2572
+ *
2573
+ * Spawns a review agent with a structured prompt that includes task evidence
2574
+ * (PROMPT.md, STATUS.md, git diff, file list). The agent writes a JSON verdict
2575
+ * to REVIEW_VERDICT.json in the task folder. This function reads/parses that
2576
+ * file and applies verdict rules.
2577
+ *
2578
+ * Fail-open on all error paths:
2579
+ * - Agent crash / non-zero exit → synthetic PASS
2580
+ * - Missing verdict file → synthetic PASS
2581
+ * - Malformed JSON → synthetic PASS
2582
+ *
2583
+ * @param ctx - Extension context
2584
+ * @param cycleNum - Current review cycle number (1-based)
2585
+ * @returns Quality gate result with pass/fail, verdict, and evaluation
2586
+ */
2587
+ async function doQualityGateReview(ctx: ExtensionContext, cycleNum: number): Promise<QualityGateResult> {
2588
+ if (!state.task || !state.config) {
2589
+ return {
2590
+ passed: true, skipped: true, cyclesUsed: cycleNum,
2591
+ verdict: { verdict: "PASS", confidence: "low", summary: "No task/config — skipped", findings: [], statusReconciliation: [] },
2592
+ evaluation: { pass: true, failReasons: [] },
2593
+ };
2594
+ }
2595
+
2596
+ const task = state.task;
2597
+ const config = state.config;
2598
+ const statusPath = join(task.taskFolder, "STATUS.md");
2599
+
2600
+ // Delete any previous verdict file so we can detect agent failure
2601
+ const verdictPath = join(task.taskFolder, VERDICT_FILENAME);
2602
+ try { if (existsSync(verdictPath)) unlinkSync(verdictPath); } catch { /* ignore */ }
2603
+
2604
+ // Build the quality gate context and prompt
2605
+ const gateContext: QualityGateContext = {
2606
+ taskFolder: task.taskFolder,
2607
+ promptPath: task.promptPath,
2608
+ taskId: task.taskId,
2609
+ projectName: config.project.name,
2610
+ passThreshold: config.quality_gate.pass_threshold,
2611
+ };
2612
+
2613
+ const prompt = generateQualityGatePrompt(gateContext, ctx.cwd);
2614
+
2615
+ // Determine review model with fallback chain:
2616
+ // quality_gate.review_model → reviewer.model → agent def → default
2617
+ const reviewerDef = loadAgentDef(ctx.cwd, "task-reviewer");
2618
+ const reviewModel = config.quality_gate.review_model
2619
+ || config.reviewer.model
2620
+ || reviewerDef?.model
2621
+ || "openai/gpt-5.3-codex";
2622
+
2623
+ const reviewerPrompt = reviewerDef?.systemPrompt
2624
+ || "You are a quality gate reviewer. Read the review request and write your JSON verdict to the specified file.";
2625
+ const systemPrompt = reviewerPrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
2626
+
2627
+ // Update UI state
2628
+ state.reviewerStatus = "running";
2629
+ state.reviewerType = `quality-gate cycle ${cycleNum}`;
2630
+ state.reviewerElapsed = 0;
2631
+ state.reviewerLastTool = "";
2632
+ updateWidgets();
2633
+
2634
+ const startTime = Date.now();
2635
+ state.reviewerTimer = setInterval(() => {
2636
+ state.reviewerElapsed = Date.now() - startTime;
2637
+ updateWidgets();
2638
+ }, 1000);
2639
+
2640
+ logExecution(statusPath, `Quality gate`, `Starting review cycle ${cycleNum}`);
2641
+
2642
+ const spawnMode = getSpawnMode(config);
2643
+ let reviewPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
2644
+
2645
+ try {
2646
+ if (spawnMode === "tmux") {
2647
+ const sessionName = `${getTmuxPrefix()}-qg-reviewer`;
2648
+ const spawned = spawnAgentTmux({
2649
+ sessionName,
2650
+ cwd: ctx.cwd,
2651
+ systemPrompt,
2652
+ prompt,
2653
+ model: reviewModel,
2654
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2655
+ thinking: config.reviewer.thinking || "on",
2656
+ taskId: task.taskId,
2657
+ });
2658
+ reviewPromise = spawned.promise;
2659
+ state.reviewerProc = { kill: spawned.kill };
2660
+ } else {
2661
+ const spawned = spawnAgent({
2662
+ model: reviewModel,
2663
+ tools: config.reviewer.tools || reviewerDef?.tools || "read,write,bash,grep,find,ls",
2664
+ thinking: config.reviewer.thinking || "on",
2665
+ systemPrompt,
2666
+ prompt,
2667
+ onToolCall: (toolName, args) => {
2668
+ const path = args?.path || args?.command || "";
2669
+ const shortPath = typeof path === "string" && path.length > 40
2670
+ ? "..." + path.slice(-37) : path;
2671
+ state.reviewerLastTool = `${toolName} ${shortPath}`.trim();
2672
+ updateWidgets();
2673
+ },
2674
+ });
2675
+ reviewPromise = spawned.promise;
2676
+ state.reviewerProc = { kill: spawned.kill };
2677
+ }
2678
+
2679
+ const result = await reviewPromise;
2680
+
2681
+ clearInterval(state.reviewerTimer);
2682
+ state.reviewerElapsed = Date.now() - startTime;
2683
+ state.reviewerStatus = result.exitCode === 0 ? "done" : "error";
2684
+ state.reviewerProc = null;
2685
+ updateWidgets();
2686
+
2687
+ // If agent exited non-zero, fail-open
2688
+ if (result.exitCode !== 0) {
2689
+ logExecution(statusPath, `Quality gate`, `Review agent exited with code ${result.exitCode} — fail-open → PASS`);
2690
+ ctx.ui.notify(`Quality gate: review agent error (exit ${result.exitCode}) — fail-open PASS`, "warning");
2691
+ return {
2692
+ passed: true, skipped: false, cyclesUsed: cycleNum,
2693
+ verdict: { verdict: "PASS", confidence: "low", summary: `Review agent exited with code ${result.exitCode} — fail-open`, findings: [], statusReconciliation: [] },
2694
+ evaluation: { pass: true, failReasons: [] },
2695
+ };
2696
+ }
2697
+ } catch (err: any) {
2698
+ // Agent crash — fail-open
2699
+ clearInterval(state.reviewerTimer);
2700
+ state.reviewerStatus = "error";
2701
+ state.reviewerProc = null;
2702
+ updateWidgets();
2703
+
2704
+ logExecution(statusPath, `Quality gate`, `Review agent crashed: ${err?.message || err} — fail-open → PASS`);
2705
+ ctx.ui.notify(`Quality gate: review agent crashed — fail-open PASS`, "warning");
2706
+ return {
2707
+ passed: true, skipped: false, cyclesUsed: cycleNum,
2708
+ verdict: { verdict: "PASS", confidence: "low", summary: `Review agent crashed — fail-open`, findings: [], statusReconciliation: [] },
2709
+ evaluation: { pass: true, failReasons: [] },
2710
+ };
2711
+ }
2712
+
2713
+ // Read and evaluate the verdict file
2714
+ const { verdict, evaluation } = readAndEvaluateVerdict(
2715
+ task.taskFolder,
2716
+ config.quality_gate.pass_threshold,
2717
+ );
2718
+
2719
+ // Apply STATUS.md reconciliation if verdict has entries
2720
+ if (verdict.statusReconciliation.length > 0) {
2721
+ const reconResult = applyStatusReconciliation(statusPath, verdict.statusReconciliation);
2722
+ if (reconResult.changed > 0 || reconResult.unmatched > 0) {
2723
+ logExecution(statusPath, `Reconciliation`,
2724
+ `${reconResult.changed} changed, ${reconResult.alreadyCorrect} already correct, ${reconResult.unmatched} unmatched`);
2725
+ }
2726
+ }
2727
+
2728
+ const passed = evaluation.pass;
2729
+ const verdictLabel = passed ? "PASS" : "NEEDS_FIXES";
2730
+ const findingsSummary = verdict.findings.length > 0
2731
+ ? ` (${verdict.findings.length} findings: ${verdict.findings.filter(f => f.severity === "critical").length}C/${verdict.findings.filter(f => f.severity === "important").length}I/${verdict.findings.filter(f => f.severity === "suggestion").length}S)`
2732
+ : "";
2733
+
2734
+ logExecution(statusPath, `Quality gate`, `Cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`);
2735
+ ctx.ui.notify(
2736
+ `Quality gate cycle ${cycleNum}: ${verdictLabel}${findingsSummary}`,
2737
+ passed ? "success" : "warning",
2738
+ );
2739
+
2740
+ return {
2741
+ passed,
2742
+ skipped: false,
2743
+ cyclesUsed: cycleNum,
2744
+ verdict,
2745
+ evaluation,
2746
+ };
2747
+ }
2748
+
2749
+ // ── Quality Gate Fix Agent ───────────────────────────────────────
2750
+
2751
+ /** Default wall-clock timeout for fix agents (15 minutes). */
2752
+ const FIX_AGENT_TIMEOUT_MS = 15 * 60 * 1000;
2753
+
2754
+ /**
2755
+ * Spawn a fix agent to address quality gate findings.
2756
+ *
2757
+ * Reuses the worker spawn pattern (subprocess or tmux). The fix agent
2758
+ * receives REVIEW_FEEDBACK.md content and makes targeted code fixes.
2759
+ *
2760
+ * Handles abnormal exits deterministically:
2761
+ * - Agent crash → returns non-zero exit code (caller consumes fix budget)
2762
+ * - Agent timeout → kills agent, returns non-zero (caller consumes fix budget)
2763
+ * - Agent exits normally but makes no changes → still returns 0 (re-review will catch)
2764
+ *
2765
+ * Wall-clock timeout: 15 minutes (or getMaxWorkerMinutes if configured).
2766
+ * This prevents a hung fix agent from stalling the task permanently.
2767
+ *
2768
+ * @param ctx - Extension context
2769
+ * @param fixPrompt - Prompt for the fix agent (includes REVIEW_FEEDBACK.md)
2770
+ * @param fixCycleNum - Current fix cycle number (1-based)
2771
+ * @returns Exit code, elapsed time, and whether timeout was hit
2772
+ */
2773
+ async function doQualityGateFixAgent(
2774
+ ctx: ExtensionContext,
2775
+ fixPrompt: string,
2776
+ fixCycleNum: number,
2777
+ ): Promise<{ exitCode: number; elapsed: number; timedOut: boolean }> {
2778
+ if (!state.task || !state.config) {
2779
+ return { exitCode: 1, elapsed: 0, timedOut: false };
2780
+ }
2781
+
2782
+ const task = state.task;
2783
+ const config = state.config;
2784
+ const statusPath = join(task.taskFolder, "STATUS.md");
2785
+
2786
+ // Use worker model and tools for fix agent (it needs to edit code)
2787
+ const workerDef = loadAgentDef(ctx.cwd, "task-worker");
2788
+ const fixModel = config.worker.model
2789
+ || workerDef?.model
2790
+ || "anthropic/claude-sonnet-4-20250514";
2791
+
2792
+ const basePrompt = workerDef?.systemPrompt
2793
+ || "You are a fix agent addressing quality gate findings. Read the feedback and make targeted code fixes.";
2794
+ const systemPrompt = basePrompt + "\n\n" + buildProjectContext(config, task.taskFolder);
2795
+
2796
+ // Wall-clock timeout: use half of worker limit (fix agents should be quick),
2797
+ // with a floor of 15 minutes.
2798
+ const workerMinutes = getMaxWorkerMinutes(config);
2799
+ const timeoutMs = Math.max(FIX_AGENT_TIMEOUT_MS, Math.floor(workerMinutes / 2) * 60 * 1000);
2800
+
2801
+ // Update UI state
2802
+ state.workerStatus = "running";
2803
+ state.workerElapsed = 0;
2804
+ state.workerContextPct = 0;
2805
+ state.workerLastTool = "";
2806
+ state.workerToolCount = 0;
2807
+ state.workerRetryActive = false;
2808
+ state.workerRetryCount = 0;
2809
+ state.workerLastRetryError = "";
2810
+ updateWidgets();
2811
+
2812
+ const startTime = Date.now();
2813
+ state.workerTimer = setInterval(() => {
2814
+ state.workerElapsed = Date.now() - startTime;
2815
+ updateWidgets();
2816
+ }, 1000);
2817
+
2818
+ logExecution(statusPath, "Quality gate", `Starting fix agent (cycle ${fixCycleNum}, timeout: ${Math.round(timeoutMs / 60000)}min)`);
2819
+
2820
+ const spawnMode = getSpawnMode(config);
2821
+ let fixPromise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
2822
+ let killFn: (() => void) | null = null;
2823
+ let tmuxExitSummaryPath: string | null = null;
2824
+
2825
+ try {
2826
+ if (spawnMode === "tmux") {
2827
+ const sessionName = `${getTmuxPrefix()}-qg-fix`;
2828
+ const spawned = spawnAgentTmux({
2829
+ sessionName,
2830
+ cwd: ctx.cwd,
2831
+ systemPrompt,
2832
+ prompt: fixPrompt,
2833
+ model: fixModel,
2834
+ tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
2835
+ thinking: config.worker.thinking || "off",
2836
+ taskId: task.taskId,
2837
+ });
2838
+ fixPromise = spawned.promise;
2839
+ killFn = spawned.kill;
2840
+ tmuxExitSummaryPath = spawned.exitSummaryPath;
2841
+ state.workerProc = { kill: spawned.kill };
2842
+ } else {
2843
+ const spawned = spawnAgent({
2844
+ model: fixModel,
2845
+ tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
2846
+ thinking: config.worker.thinking || "off",
2847
+ systemPrompt,
2848
+ prompt: fixPrompt,
2849
+ onToolCall: (toolName, args) => {
2850
+ state.workerToolCount++;
2851
+ const path = args?.path || args?.command || "";
2852
+ const shortPath = typeof path === "string" && path.length > 80
2853
+ ? "..." + path.slice(-77) : path;
2854
+ state.workerLastTool = `${toolName} ${shortPath}`.trim();
2855
+ updateWidgets();
2856
+ },
2857
+ });
2858
+ fixPromise = spawned.promise;
2859
+ killFn = spawned.kill;
2860
+ state.workerProc = { kill: spawned.kill };
2861
+ }
2862
+
2863
+ // Race the agent against a wall-clock timeout
2864
+ let timedOut = false;
2865
+ const timeoutPromise = new Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>((resolve) => {
2866
+ const timer = setTimeout(() => {
2867
+ timedOut = true;
2868
+ logExecution(statusPath, "Quality gate", `Fix agent wall-clock timeout (${Math.round(timeoutMs / 60000)}min) — killing agent`);
2869
+ if (killFn) killFn();
2870
+ // Resolve after a brief delay to allow kill to take effect
2871
+ setTimeout(() => {
2872
+ resolve({ output: "timeout", exitCode: 1, elapsed: Date.now() - startTime, killed: true });
2873
+ }, 5000);
2874
+ }, timeoutMs);
2875
+ // Clean up timer if agent finishes first
2876
+ fixPromise.then(() => clearTimeout(timer)).catch(() => clearTimeout(timer));
2877
+ });
2878
+
2879
+ const result = await Promise.race([fixPromise, timeoutPromise]);
2880
+
2881
+ // ── TMUX exit classification ─────────────────────────
2882
+ // spawnAgentTmux always reports exitCode: 0 on session end.
2883
+ // Read the exit summary written by rpc-wrapper to get the
2884
+ // real Pi process exit code (same pattern as worker flow).
2885
+ let effectiveExitCode = result.exitCode;
2886
+ if (spawnMode === "tmux" && tmuxExitSummaryPath && !timedOut) {
2887
+ const exitSummary = readExitSummary(tmuxExitSummaryPath);
2888
+ if (exitSummary && typeof exitSummary.exitCode === "number") {
2889
+ effectiveExitCode = exitSummary.exitCode;
2890
+ if (effectiveExitCode !== 0) {
2891
+ console.error(`[task-runner] qg-fix: tmux exit summary reports exit code ${effectiveExitCode}`);
2892
+ }
2893
+ }
2894
+ // If no exit summary exists, keep the tmux-reported code (0).
2895
+ // This is fail-open: missing exit summary ≠ crash.
2896
+ }
2897
+
2898
+ clearInterval(state.workerTimer);
2899
+ state.workerElapsed = Date.now() - startTime;
2900
+ state.workerStatus = (effectiveExitCode === 0 && !timedOut) ? "done" : "error";
2901
+ state.workerProc = null;
2902
+ updateWidgets();
2903
+
2904
+ return { exitCode: timedOut ? 1 : effectiveExitCode, elapsed: Date.now() - startTime, timedOut };
2905
+ } catch (err: any) {
2906
+ // Fix agent crashed — return non-zero to consume fix budget
2907
+ clearInterval(state.workerTimer);
2908
+ state.workerStatus = "error";
2909
+ state.workerProc = null;
2910
+ updateWidgets();
2911
+
2912
+ logExecution(statusPath, "Quality gate", `Fix agent crashed: ${err?.message || err} — fix cycle ${fixCycleNum} consumed`);
2913
+ return { exitCode: 1, elapsed: Date.now() - startTime, timedOut: false };
2914
+ }
2915
+ }
2916
+
1836
2917
  // ── Commands ─────────────────────────────────────────────────────
1837
2918
 
1838
2919
  // ── Shared Task Initialization ───────────────────────────────────