taskplane 0.22.13 → 0.22.15

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.
@@ -28,7 +28,7 @@
28
28
  */
29
29
 
30
30
  import { spawn } from "node:child_process";
31
- import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
31
+ import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync, unlinkSync } from "node:fs";
32
32
  import { dirname, resolve, join, basename } from "node:path";
33
33
  import { StringDecoder } from "node:string_decoder";
34
34
 
@@ -46,6 +46,7 @@ function parseArgs(argv) {
46
46
  passthrough: [],
47
47
  help: false,
48
48
  mailboxDir: null,
49
+ steeringPendingPath: null,
49
50
  };
50
51
 
51
52
  let i = 2; // skip "node" and script path
@@ -78,6 +79,9 @@ function parseArgs(argv) {
78
79
  } else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
79
80
  args.mailboxDir = argv[++i];
80
81
  i++;
82
+ } else if (arg === "--steering-pending-path" && i + 1 < argv.length) {
83
+ args.steeringPendingPath = argv[++i];
84
+ i++;
81
85
  } else if (arg === "--") {
82
86
  args.passthrough = argv.slice(i + 1);
83
87
  break;
@@ -108,6 +112,7 @@ Optional:
108
112
  --tools <t1,t2,...> Comma-separated tool names
109
113
  --extensions <e1,e2,...> Comma-separated extension paths
110
114
  --mailbox-dir <path> Mailbox directory for agent steering (TP-089)
115
+ --steering-pending-path <p> Path to .steering-pending JSONL flag file (TP-090)
111
116
  -h, --help Show this help
112
117
  `
113
118
  );
@@ -509,9 +514,10 @@ const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply
509
514
  *
510
515
  * @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
511
516
  * @param {object} proc - The spawned pi process (must have writable stdin)
517
+ * @param {string|null} steeringPendingPath - Path to .steering-pending JSONL flag file (TP-090, worker-only)
512
518
  * @returns {{ delivered: number, skipped: number }} Delivery stats
513
519
  */
514
- function checkMailboxAndSteer(mailboxDir, proc) {
520
+ function checkMailboxAndSteer(mailboxDir, proc, steeringPendingPath) {
515
521
  const stats = { delivered: 0, skipped: 0 };
516
522
 
517
523
  // Derive expected values from path structure:
@@ -616,6 +622,17 @@ function checkMailboxAndSteer(mailboxDir, proc) {
616
622
 
617
623
  stats.delivered++;
618
624
  process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
625
+
626
+ // TP-090: Append to .steering-pending JSONL flag for task-runner STATUS.md annotation.
627
+ // Worker-only: steeringPendingPath is only set for worker sessions.
628
+ if (steeringPendingPath) {
629
+ try {
630
+ const entry = JSON.stringify({ ts: message.timestamp, content: message.content, id: message.id }) + "\n";
631
+ appendFileSync(steeringPendingPath, entry, "utf-8");
632
+ } catch (err) {
633
+ process.stderr.write(`\n[STEERING] WARNING: failed to write .steering-pending: ${err.message}\n`);
634
+ }
635
+ }
619
636
  } catch (err) {
620
637
  process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
621
638
  stats.skipped++;
@@ -759,6 +776,29 @@ const proc = spawn("pi", piArgs, {
759
776
  shell: true, // Required for Windows: resolves pi.cmd shim. Matches task-runner.ts pattern.
760
777
  });
761
778
 
779
+ // ── TP-097: Write PID file for orphan cleanup ──────────────────
780
+ // Write both the wrapper PID and the pi child PID alongside the sidecar file.
781
+ // The task-runner reads this on session end to kill orphan processes.
782
+ // Format: JSON with wrapperPid and childPid fields.
783
+ const pidFilePath = args.sidecarPath + ".pid";
784
+ try {
785
+ const pidData = {
786
+ wrapperPid: process.pid,
787
+ childPid: proc.pid ?? null,
788
+ startedAt: Date.now(),
789
+ };
790
+ writeFileSync(pidFilePath, JSON.stringify(pidData) + "\n", "utf-8");
791
+ process.stderr.write(`[rpc-wrapper] PID file written: ${pidFilePath} (wrapper=${process.pid}, child=${proc.pid})\n`);
792
+ } catch (err) {
793
+ process.stderr.write(`[rpc-wrapper] WARNING: failed to write PID file: ${err.message}\n`);
794
+ }
795
+
796
+ // Clean up PID file on process exit (best-effort)
797
+ function cleanupPidFile() {
798
+ try { unlinkSync(pidFilePath); } catch { /* ignore */ }
799
+ }
800
+ process.on("exit", cleanupPidFile);
801
+
762
802
  // ── Send prompt via JSONL stdin ──────────────────────────────────────
763
803
 
764
804
  const promptCmd = { type: "prompt", message: promptContent };
@@ -852,7 +892,7 @@ function handleEvent(event) {
852
892
  // Only active when --mailbox-dir is provided (backward compatible).
853
893
  if (args.mailboxDir) {
854
894
  try {
855
- checkMailboxAndSteer(args.mailboxDir, proc);
895
+ checkMailboxAndSteer(args.mailboxDir, proc, args.steeringPendingPath || null);
856
896
  } catch (err) {
857
897
  // Never crash on mailbox I/O errors
858
898
  process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
@@ -972,6 +972,16 @@ function logExecution(statusPath: string, action: string, outcome: string): void
972
972
  appendTableRow(statusPath, "Execution Log", `| ${ts} | ${action} | ${outcome} |`);
973
973
  }
974
974
 
975
+ /**
976
+ * TP-090: Sanitize steering message content for safe injection into a markdown table row.
977
+ * Collapses newlines to " / ", escapes pipe characters, and truncates to 200 chars.
978
+ */
979
+ function sanitizeSteeringContent(content: string): string {
980
+ let s = content.replace(/\r?\n/g, " / ").replace(/\|/g, "\\|");
981
+ if (s.length > 200) s = s.slice(0, 197) + "...";
982
+ return s;
983
+ }
984
+
975
985
  function logReview(statusPath: string, num: string, type: string, stepNum: number, verdict: string, file: string): void {
976
986
  appendTableRow(statusPath, "Reviews", `| ${num} | ${type} | Step ${stepNum} | ${verdict} | ${file} |`);
977
987
  }
@@ -1567,6 +1577,68 @@ export const _resolveContextWindow = resolveContextWindow;
1567
1577
  export const _FALLBACK_CONTEXT_WINDOW = FALLBACK_CONTEXT_WINDOW;
1568
1578
  export type { SidecarTailState, SidecarTelemetryDelta };
1569
1579
 
1580
+ // ── Stable Sidecar Path Generation (TP-097) ─────────────────────────
1581
+
1582
+ /**
1583
+ * Generate a deterministic telemetry basename for sidecar/exit-summary files.
1584
+ *
1585
+ * The basename is stable per session (not per spawn attempt). When called
1586
+ * once before the iteration loop and reused, it ensures that:
1587
+ * - Crash recovery writes to the same sidecar file (tailing resumes)
1588
+ * - Exit summaries overwrite the same file (latest wins)
1589
+ *
1590
+ * Naming contract:
1591
+ * {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
1592
+ *
1593
+ * @param sessionName — TMUX session name (e.g., "orch-lane-1-worker")
1594
+ * @param taskId — Optional task ID for enrichment (e.g., "TP-097")
1595
+ * @returns Object with sidecarPath and exitSummaryPath
1596
+ */
1597
+ function generateStableSidecarPaths(sessionName: string, taskId?: string): {
1598
+ sidecarPath: string;
1599
+ exitSummaryPath: string;
1600
+ } {
1601
+ // Resolve opId: same priority chain as naming.ts resolveOperatorId()
1602
+ let opId = "op";
1603
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1604
+ if (envOpId?.trim()) {
1605
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1606
+ } else {
1607
+ try {
1608
+ const username = userInfo().username;
1609
+ if (username?.trim()) {
1610
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1611
+ }
1612
+ } catch { /* userInfo() can throw on some platforms */ }
1613
+ }
1614
+
1615
+ // Use ORCH_BATCH_ID if available (orchestrated mode), otherwise fallback to timestamp
1616
+ const batchId = process.env.ORCH_BATCH_ID || String(Date.now());
1617
+ const repoId = process.env.TASKPLANE_REPO_ID || "default";
1618
+
1619
+ // Extract role (worker/reviewer) from sessionName, and optional lane component
1620
+ const role = sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1621
+ const laneMatch = sessionName.match(/lane-(\d+)/);
1622
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1623
+
1624
+ // Include taskId when available — sanitize to filesystem-safe characters
1625
+ const taskIdSegment = taskId
1626
+ ? `-${taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1627
+ : "";
1628
+
1629
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1630
+ const telemetryDir = join(getSidecarDir(), "telemetry");
1631
+ if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1632
+
1633
+ return {
1634
+ sidecarPath: join(telemetryDir, `${telemetryBasename}.jsonl`),
1635
+ exitSummaryPath: join(telemetryDir, `${telemetryBasename}-exit.json`),
1636
+ };
1637
+ }
1638
+
1639
+ /** Expose for testing. */
1640
+ export const _generateStableSidecarPaths = generateStableSidecarPaths;
1641
+
1570
1642
  // ── Exit Summary & Diagnostic ────────────────────────────────────────
1571
1643
 
1572
1644
  /**
@@ -1775,6 +1847,19 @@ function spawnAgentTmux(opts: {
1775
1847
  * Enables the tmux poll loop to update TaskState (tokens, cost, context%, tools, retries)
1776
1848
  * with the same signals that subprocess mode gets from onTokenUpdate/onContextPct/onToolCall. */
1777
1849
  onTelemetry?: (delta: SidecarTelemetryDelta) => void;
1850
+ /** TP-090: Path to .steering-pending JSONL flag file for STATUS.md annotation.
1851
+ * Only set for worker sessions (not reviewer/merger). */
1852
+ steeringPendingPath?: string;
1853
+ /** TP-097: Caller-provided sidecar path for stable identity across iterations.
1854
+ * When provided, spawnAgentTmux() skips internal path generation and uses this path.
1855
+ * The caller (runWorker) generates this ONCE before the iteration loop. */
1856
+ sidecarPath?: string;
1857
+ /** TP-097: Caller-provided exit summary path (paired with sidecarPath). */
1858
+ exitSummaryPath?: string;
1859
+ /** TP-097: Caller-provided tail state for resuming sidecar tailing across iterations.
1860
+ * When provided, the poll loop reuses this state instead of creating a fresh one.
1861
+ * This ensures tailing resumes from the last byte position after crash recovery. */
1862
+ tailState?: SidecarTailState;
1778
1863
  }): {
1779
1864
  promise: Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }>;
1780
1865
  kill: () => void;
@@ -1792,59 +1877,55 @@ function spawnAgentTmux(opts: {
1792
1877
  );
1793
1878
  }
1794
1879
 
1795
- // ── Generate telemetry file paths ───────────────────────────────
1796
- // Naming contract from resilience roadmap:
1797
- // .pi/telemetry/{opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.{ext}
1798
- //
1799
- // In standalone /task mode (no orchestrator):
1800
- // opId → TASKPLANE_OPERATOR_ID env, or OS username, or "op"
1801
- // batchId → timestamp (no batch concept in standalone mode)
1802
- // repoId → "default" (single-repo mode)
1803
- // taskId → from opts.taskId when provided (e.g., "tp-026"), omitted if absent
1804
- // lane → omitted (no lanes)
1805
- // role → derived from sessionName suffix (worker/reviewer)
1806
- //
1807
- // getSidecarDir() respects ORCH_SIDECAR_DIR for workspace mode.
1808
- const telemetryTs = Date.now();
1809
-
1810
- // Resolve opId: same priority chain as naming.ts resolveOperatorId()
1811
- let opId = "op";
1812
- const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1813
- if (envOpId?.trim()) {
1814
- opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1880
+ // ── Resolve telemetry file paths ───────────────────────────────
1881
+ // TP-097: When the caller provides sidecarPath + exitSummaryPath, reuse them
1882
+ // for stable identity across crash recovery iterations. Otherwise, generate
1883
+ // paths internally (backward compatible for reviewer/quality-gate/standalone).
1884
+ let sidecarPath: string;
1885
+ let exitSummaryPath: string;
1886
+ if (opts.sidecarPath && opts.exitSummaryPath) {
1887
+ // TP-097: Caller provided stable paths (worker iteration flow)
1888
+ sidecarPath = opts.sidecarPath;
1889
+ exitSummaryPath = opts.exitSummaryPath;
1815
1890
  } else {
1816
- try {
1817
- const username = userInfo().username;
1818
- if (username?.trim()) {
1819
- opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1820
- }
1821
- } catch { /* userInfo() can throw on some platforms */ }
1891
+ // Internal generation: use unique per-spawn paths (Date.now-based batchId)
1892
+ // to prevent reviewer/QG sessions from replaying old telemetry on respawn.
1893
+ // Only the worker iteration flow uses ORCH_BATCH_ID for stable identity.
1894
+ const telemetryTs = Date.now();
1895
+ let opId = "op";
1896
+ const envOpId = process.env.TASKPLANE_OPERATOR_ID;
1897
+ if (envOpId?.trim()) {
1898
+ opId = envOpId.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1899
+ } else {
1900
+ try {
1901
+ const username = userInfo().username;
1902
+ if (username?.trim()) {
1903
+ opId = username.trim().toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 12) || "op";
1904
+ }
1905
+ } catch { /* userInfo() can throw on some platforms */ }
1906
+ }
1907
+ const batchId = String(telemetryTs);
1908
+ const repoId = "default";
1909
+ const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1910
+ const laneMatch = opts.sessionName.match(/lane-(\d+)/);
1911
+ const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1912
+ const taskIdSegment = opts.taskId
1913
+ ? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1914
+ : "";
1915
+ const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1916
+ const internalTelemetryDir = join(getSidecarDir(), "telemetry");
1917
+ if (!existsSync(internalTelemetryDir)) mkdirSync(internalTelemetryDir, { recursive: true });
1918
+ sidecarPath = join(internalTelemetryDir, `${telemetryBasename}.jsonl`);
1919
+ exitSummaryPath = join(internalTelemetryDir, `${telemetryBasename}-exit.json`);
1822
1920
  }
1823
-
1824
- const batchId = String(telemetryTs);
1825
- const repoId = "default";
1826
-
1827
- // Extract role (worker/reviewer) from sessionName, and optional lane component
1828
- // sessionName patterns: "task-worker", "task-reviewer", "orch-lane-1-worker"
1829
- const role = opts.sessionName.endsWith("-reviewer") ? "reviewer" : "worker";
1830
- const laneMatch = opts.sessionName.match(/lane-(\d+)/);
1831
- const laneSuffix = laneMatch ? `-lane-${laneMatch[1]}` : "";
1832
-
1833
- // Include taskId when available — sanitize to filesystem-safe characters.
1834
- // Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}
1835
- const taskIdSegment = opts.taskId
1836
- ? `-${opts.taskId.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30)}`
1837
- : "";
1838
- const telemetryBasename = `${opId}-${batchId}-${repoId}${taskIdSegment}${laneSuffix}-${role}`;
1839
- const telemetryDir = join(getSidecarDir(), "telemetry");
1921
+ // Ensure telemetry directory exists
1922
+ const telemetryDir = dirname(sidecarPath);
1840
1923
  if (!existsSync(telemetryDir)) mkdirSync(telemetryDir, { recursive: true });
1841
- const sidecarPath = join(telemetryDir, `${telemetryBasename}.jsonl`);
1842
- const exitSummaryPath = join(telemetryDir, `${telemetryBasename}-exit.json`);
1843
1924
 
1844
1925
  // ── Write prompts to temp files ─────────────────────────────────
1845
1926
  // Same pattern as spawnAgent() — avoids shell escaping issues with
1846
1927
  // backticks, quotes, and special characters in markdown content.
1847
- const id = `${telemetryTs}-${Math.random().toString(36).slice(2, 8)}`;
1928
+ const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1848
1929
  const sysTmpFile = join(tmpdir(), `pi-task-sys-${id}.txt`);
1849
1930
  const promptTmpFile = join(tmpdir(), `pi-task-prompt-${id}.txt`);
1850
1931
  writeFileSync(sysTmpFile, opts.systemPrompt);
@@ -1898,6 +1979,10 @@ function spawnAgentTmux(opts: {
1898
1979
  mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
1899
1980
  wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
1900
1981
  }
1982
+ // TP-090: Pass steering-pending path to rpc-wrapper (worker-only).
1983
+ if (opts.steeringPendingPath) {
1984
+ wrapperArgs.push("--steering-pending-path", quoteArg(opts.steeringPendingPath));
1985
+ }
1901
1986
  // Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
1902
1987
  // Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
1903
1988
  wrapperArgs.push("--");
@@ -1948,10 +2033,11 @@ function spawnAgentTmux(opts: {
1948
2033
  // On Windows/MSYS2, rapid sequential tmux session creation is unreliable.
1949
2034
  // Pi process can exit with code 1 in 0 seconds on the first 3-5 attempts.
1950
2035
  // Verify the session is alive after a brief delay, and retry if it died.
1951
- const SPAWN_VERIFY_DELAY_MS = 300;
2036
+ // TP-097: Increased from 300→500ms and 2→5 retries for reliability (#335)
2037
+ const SPAWN_VERIFY_DELAY_MS = 500;
1952
2038
  const SPAWN_VERIFY_POLL_ATTEMPTS = 3;
1953
2039
  const SPAWN_VERIFY_POLL_INTERVAL_MS = 200;
1954
- const SPAWN_MAX_RETRIES = 2;
2040
+ const SPAWN_MAX_RETRIES = 5;
1955
2041
 
1956
2042
  const verifySessionAlive = (): boolean => {
1957
2043
  for (let poll = 0; poll < SPAWN_VERIFY_POLL_ATTEMPTS; poll++) {
@@ -1973,9 +2059,18 @@ function spawnAgentTmux(opts: {
1973
2059
  let spawnRetries = 0;
1974
2060
  while (!verifySessionAlive() && spawnRetries < SPAWN_MAX_RETRIES) {
1975
2061
  spawnRetries++;
1976
- console.error(`[task-runner] tmux: session '${opts.sessionName}' died on startup retrying (${spawnRetries}/${SPAWN_MAX_RETRIES}). Stderr log: ${stderrLogHint}`);
2062
+ // TP-097: Log stderr from the failed session for diagnostics
2063
+ let failedStderr = "";
2064
+ try {
2065
+ if (existsSync(stderrLogHint)) {
2066
+ const raw = readFileSync(stderrLogHint, "utf-8");
2067
+ // Take last 500 chars to capture the most recent error
2068
+ failedStderr = raw.length > 500 ? "..." + raw.slice(-500) : raw;
2069
+ }
2070
+ } catch { /* best effort */ }
2071
+ console.error(`[task-runner] tmux: session '${opts.sessionName}' died on startup — retrying (${spawnRetries}/${SPAWN_MAX_RETRIES}).${failedStderr ? ` Last stderr: ${failedStderr.trim().slice(0, 200)}` : ""} Stderr log: ${stderrLogHint}`);
1977
2072
 
1978
- // Brief delay before retry (increases with each attempt)
2073
+ // Brief delay before retry (increases with each attempt: 500ms, 1000ms, 1500ms, ...)
1979
2074
  const retryDelay = spawnRetries * 500;
1980
2075
  sleepSyncMs(retryDelay);
1981
2076
 
@@ -2017,7 +2112,10 @@ function spawnAgentTmux(opts: {
2017
2112
  // ── Poll until session ends ─────────────────────────────────────
2018
2113
  let killed = false;
2019
2114
  const startTime = Date.now();
2020
- const tailState = createSidecarTailState();
2115
+ // TP-097: Reuse caller-provided tailState for cross-iteration tailing resume.
2116
+ // When the caller (runWorker) passes tailState, byte offset is preserved
2117
+ // across iterations so tailing resumes from the last position after crash recovery.
2118
+ const tailState = opts.tailState ?? createSidecarTailState();
2021
2119
 
2022
2120
  const promise = (async (): Promise<{ output: string; exitCode: number; elapsed: number; killed: boolean }> => {
2023
2121
  try {
@@ -2059,10 +2157,12 @@ function spawnAgentTmux(opts: {
2059
2157
  };
2060
2158
  }
2061
2159
 
2062
- // Normal completion — clean up temp files
2160
+ // Normal completion — clean up temp files and orphan processes
2063
2161
  const elapsed = Date.now() - startTime;
2064
2162
  console.error(`[task-runner] tmux: session '${opts.sessionName}' ended after ${Math.round(elapsed / 1000)}s${killed ? " (killed)" : ""}`);
2065
2163
  cleanupTmp();
2164
+ // TP-097: Clean up orphan rpc-wrapper/pi processes after session ends
2165
+ cleanupOrphanProcesses(sidecarPath);
2066
2166
  console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}'`);
2067
2167
  return {
2068
2168
  output: "", // No captured output in TMUX mode
@@ -2081,6 +2181,8 @@ function spawnAgentTmux(opts: {
2081
2181
  // Session may have already exited — not an error
2082
2182
  console.error(`[task-runner] tmux: session '${opts.sessionName}' already exited (kill was no-op)`);
2083
2183
  }
2184
+ // TP-097: Clean up orphan rpc-wrapper/pi processes on explicit kill
2185
+ cleanupOrphanProcesses(sidecarPath);
2084
2186
  cleanupTmp();
2085
2187
  console.error(`[task-runner] tmux: cleanup done for '${opts.sessionName}' (killed)`);
2086
2188
  };
@@ -2088,6 +2190,68 @@ function spawnAgentTmux(opts: {
2088
2190
  return { promise, kill, sidecarPath, exitSummaryPath };
2089
2191
  }
2090
2192
 
2193
+ // ── Orphan Process Cleanup (TP-097) ───────────────────────────────────
2194
+
2195
+ /**
2196
+ * Read the PID file written by rpc-wrapper.mjs and kill orphan processes.
2197
+ *
2198
+ * The PID file is at `{sidecarPath}.pid` and contains JSON with wrapperPid
2199
+ * and childPid fields. After a tmux session ends, the rpc-wrapper child
2200
+ * process may still be alive (e.g., if the tmux session was killed externally
2201
+ * or the wrapper didn't get a clean shutdown signal).
2202
+ *
2203
+ * Best-effort: failures are logged but never throw.
2204
+ *
2205
+ * @param sidecarPath - Path to the sidecar JSONL file (PID file is at sidecarPath + ".pid")
2206
+ */
2207
+ function cleanupOrphanProcesses(sidecarPath: string): void {
2208
+ const pidFilePath = sidecarPath + ".pid";
2209
+ try {
2210
+ if (!existsSync(pidFilePath)) return;
2211
+
2212
+ const raw = readFileSync(pidFilePath, "utf-8").trim();
2213
+ if (!raw) return;
2214
+
2215
+ const pidData = JSON.parse(raw);
2216
+ const pidsToCheck = new Set<number>();
2217
+ if (typeof pidData.childPid === "number" && pidData.childPid > 0) {
2218
+ pidsToCheck.add(pidData.childPid);
2219
+ }
2220
+ if (typeof pidData.wrapperPid === "number" && pidData.wrapperPid > 0) {
2221
+ pidsToCheck.add(pidData.wrapperPid);
2222
+ }
2223
+
2224
+ // Safety: never kill ourselves or PID 1 (init)
2225
+ const selfPid = process.pid;
2226
+ pidsToCheck.delete(selfPid);
2227
+ pidsToCheck.delete(1);
2228
+
2229
+ for (const pid of pidsToCheck) {
2230
+ try {
2231
+ // Check if process is still alive (signal 0 = no-op, just check existence)
2232
+ process.kill(pid, 0);
2233
+ // Process is alive — send SIGTERM
2234
+ console.error(`[task-runner] TP-097: killing orphan process PID ${pid}`);
2235
+ try {
2236
+ process.kill(pid, "SIGTERM");
2237
+ } catch (killErr: any) {
2238
+ console.error(`[task-runner] TP-097: failed to kill PID ${pid}: ${killErr?.message}`);
2239
+ }
2240
+ } catch {
2241
+ // Process already dead — expected path
2242
+ }
2243
+ }
2244
+
2245
+ // Clean up the PID file
2246
+ try { unlinkSync(pidFilePath); } catch {}
2247
+ } catch (err: any) {
2248
+ console.error(`[task-runner] TP-097: orphan cleanup error: ${err?.message}`);
2249
+ }
2250
+ }
2251
+
2252
+ /** Expose for testing. */
2253
+ export const _cleanupOrphanProcesses = cleanupOrphanProcesses;
2254
+
2091
2255
  // ── Display Helpers ──────────────────────────────────────────────────
2092
2256
 
2093
2257
  function displayName(name: string): string {
@@ -2866,7 +3030,14 @@ export default function (pi: ExtensionAPI) {
2866
3030
 
2867
3031
  updateStatusField(statusPath, "Status", "🟡 In Progress");
2868
3032
  updateStatusField(statusPath, "Last Updated", new Date().toISOString().slice(0, 10));
2869
- logExecution(statusPath, "Task started", "Extension-driven execution");
3033
+
3034
+ // TP-098: Distinguish first start from restart/resume to prevent
3035
+ // duplicate "Task started" entries in the execution log (#348).
3036
+ if (state.totalIterations === 0) {
3037
+ logExecution(statusPath, "Task started", "Extension-driven execution");
3038
+ } else {
3039
+ logExecution(statusPath, "Task resumed", `Resuming from iteration ${state.totalIterations}`);
3040
+ }
2870
3041
 
2871
3042
  // ── Per-task worker loop ─────────────────────────────────────
2872
3043
  // Spawn one worker per iteration; each worker handles ALL remaining
@@ -2883,10 +3054,14 @@ export default function (pi: ExtensionAPI) {
2883
3054
  const ss = currentStatus.steps.find(s => s.number === step.number);
2884
3055
  if (ss?.status === "complete") continue;
2885
3056
 
2886
- if (!foundFirstIncomplete) {
3057
+ if (!foundFirstIncomplete) {
2887
3058
  // Mark the first incomplete step as in-progress
2888
- updateStepStatus(statusPath, step.number, "in-progress");
2889
- logExecution(statusPath, `Step ${step.number} started`, step.name);
3059
+ // TP-098: Only log "Step N started" if the step was not already
3060
+ // in-progress, preventing duplicate entries on restart (#348).
3061
+ if (ss?.status !== "in-progress") {
3062
+ updateStepStatus(statusPath, step.number, "in-progress");
3063
+ logExecution(statusPath, `Step ${step.number} started`, step.name);
3064
+ }
2890
3065
  foundFirstIncomplete = true;
2891
3066
  } else {
2892
3067
  // Ensure future steps show as not-started, not in-progress
@@ -2905,11 +3080,26 @@ export default function (pi: ExtensionAPI) {
2905
3080
  return ss.totalChecked === ss.totalItems && ss.totalItems > 0;
2906
3081
  }
2907
3082
 
3083
+ // ── TP-097: Generate stable sidecar paths ONCE before the iteration loop ──
3084
+ // These paths are reused across all worker iterations so that:
3085
+ // 1. After crash recovery, the new worker writes to the SAME sidecar file
3086
+ // 2. tailState preserves byte offset, so tailing resumes from last position
3087
+ // 3. Exit summary overwrites the same file (latest iteration wins)
3088
+ const spawnMode = getSpawnMode(config);
3089
+ let workerStableSidecar: { sidecarPath: string; exitSummaryPath: string } | null = null;
3090
+ let workerTailState: SidecarTailState | null = null;
3091
+ if (spawnMode === "tmux") {
3092
+ const sessionName = `${getTmuxPrefix()}-worker`;
3093
+ workerStableSidecar = generateStableSidecarPaths(sessionName, task.taskId);
3094
+ workerTailState = createSidecarTailState();
3095
+ console.error(`[task-runner] TP-097: stable sidecar path: ${workerStableSidecar.sidecarPath}`);
3096
+ }
3097
+
2908
3098
  let noProgressCount = 0;
2909
3099
  for (let iter = 0; iter < config.context.max_worker_iterations; iter++) {
2910
3100
  if (state.phase === "paused") {
2911
- logExecution(statusPath, "Paused", `User paused at iteration ${iter + 1}`);
2912
- ctx.ui.notify(`Task paused at iteration ${iter + 1}`, "info");
3101
+ logExecution(statusPath, "Paused", `User paused at iteration ${state.totalIterations}`);
3102
+ ctx.ui.notify(`Task paused at iteration ${state.totalIterations}`, "info");
2913
3103
  await shutdownPersistentReviewer("task paused");
2914
3104
  return;
2915
3105
  }
@@ -2963,12 +3153,39 @@ export default function (pi: ExtensionAPI) {
2963
3153
  writeLaneState(state);
2964
3154
  }
2965
3155
 
2966
- await runWorker(remainingSteps, ctx);
3156
+ await runWorker(remainingSteps, ctx, workerStableSidecar, workerTailState);
2967
3157
 
2968
3158
  // Write context % snapshot at iteration boundary (TP-094)
2969
3159
  const { contextWindow: snapshotContextWindow } = resolveContextWindow(config, ctx);
2970
3160
  writeContextSnapshot(state, snapshotContextWindow);
2971
3161
 
3162
+ // ── TP-090: Annotate STATUS.md with delivered steering messages ──
3163
+ // Check for .steering-pending JSONL flag written by rpc-wrapper.
3164
+ // Must happen BEFORE the error-return so messages are not dropped.
3165
+ const steeringFlagPath = join(task.taskFolder, ".steering-pending");
3166
+ try {
3167
+ if (existsSync(steeringFlagPath)) {
3168
+ const raw = readFileSync(steeringFlagPath, "utf-8");
3169
+ const lines = raw.split("\n").filter(l => l.trim());
3170
+ for (const line of lines) {
3171
+ try {
3172
+ const entry = JSON.parse(line) as { ts: number; content: string; id: string };
3173
+ const sanitized = sanitizeSteeringContent(entry.content);
3174
+ // Use the delivered message timestamp, not current time
3175
+ const ts = new Date(entry.ts).toISOString().slice(0, 16).replace("T", " ");
3176
+ appendTableRow(statusPath, "Execution Log", `| ${ts} | \u26a0\ufe0f Steering | ${sanitized} |`);
3177
+ console.error(`[task-runner] steering message annotated: ${entry.id}`);
3178
+ } catch {
3179
+ // Skip malformed JSONL lines
3180
+ }
3181
+ }
3182
+ unlinkSync(steeringFlagPath);
3183
+ }
3184
+ } catch (err: any) {
3185
+ // Non-fatal: steering annotation is supplementary
3186
+ console.error(`[task-runner] steering-pending annotation error: ${err?.message || err}`);
3187
+ }
3188
+
2972
3189
  if (state.phase === "error") {
2973
3190
  await shutdownPersistentReviewer("worker error");
2974
3191
  return;
@@ -2982,8 +3199,10 @@ export default function (pi: ExtensionAPI) {
2982
3199
  const progressDelta = afterTotalChecked - prevTotalChecked;
2983
3200
  if (progressDelta <= 0) {
2984
3201
  noProgressCount++;
2985
- logExecution(statusPath, "No progress", `Iteration ${iter + 1}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
2986
- ctx.ui.notify(`⚠️ No progress in iteration ${iter + 1} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
3202
+ // TP-098: Use state.totalIterations (global) instead of iter+1
3203
+ // (loop-local) to avoid label collision across restarts (#348).
3204
+ logExecution(statusPath, "No progress", `Iteration ${state.totalIterations}: 0 new checkboxes (${noProgressCount}/${config.context.no_progress_limit} stall limit)`);
3205
+ ctx.ui.notify(`⚠️ No progress in iteration ${state.totalIterations} (${noProgressCount}/${config.context.no_progress_limit})`, "warning");
2987
3206
  if (noProgressCount >= config.context.no_progress_limit) {
2988
3207
  logExecution(statusPath, "Task blocked", `No progress after ${noProgressCount} iterations`);
2989
3208
  ctx.ui.notify(`⚠️ Task blocked — no progress after ${noProgressCount} iterations`, "error");
@@ -3030,11 +3249,13 @@ export default function (pi: ExtensionAPI) {
3030
3249
  // Log iteration summary with progress delta and completed steps
3031
3250
  const completedNames = newlyCompleted.map(s => `Step ${s.number}`).join(", ");
3032
3251
  if (newlyCompleted.length > 0) {
3033
- logExecution(statusPath, `Iteration ${iter + 1} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
3034
- ctx.ui.notify(`Iteration ${iter + 1}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
3252
+ // TP-098: Use state.totalIterations (global) instead of iter+1
3253
+ // (loop-local) to avoid label collision across restarts (#348).
3254
+ logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, completed: ${completedNames}`);
3255
+ ctx.ui.notify(`Iteration ${state.totalIterations}: completed ${completedNames} (+${progressDelta} checkboxes)`, "info");
3035
3256
  } else if (progressDelta > 0) {
3036
- logExecution(statusPath, `Iteration ${iter + 1} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
3037
- ctx.ui.notify(`Iteration ${iter + 1}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
3257
+ logExecution(statusPath, `Iteration ${state.totalIterations} summary`, `+${progressDelta} checkboxes, no steps fully completed`);
3258
+ ctx.ui.notify(`Iteration ${state.totalIterations}: +${progressDelta} checkboxes (no steps fully completed)`, "info");
3038
3259
  }
3039
3260
 
3040
3261
  // Reviews are now driven inline by the worker via the review_step
@@ -3257,25 +3478,29 @@ export default function (pi: ExtensionAPI) {
3257
3478
 
3258
3479
  // ── Worker ───────────────────────────────────────────────────────
3259
3480
 
3260
- async function runWorker(remainingSteps: StepInfo[], ctx: ExtensionContext): Promise<void> {
3481
+ /** Pre-generated sidecar paths for stable identity across iterations (TP-097). */
3482
+ type StableSidecarPaths = { sidecarPath: string; exitSummaryPath: string };
3483
+
3484
+ async function runWorker(
3485
+ remainingSteps: StepInfo[],
3486
+ ctx: ExtensionContext,
3487
+ stableSidecar?: StableSidecarPaths | null,
3488
+ sharedTailState?: SidecarTailState | null,
3489
+ ): Promise<void> {
3261
3490
  if (!state.task || !state.config) return;
3262
3491
 
3263
3492
  const task = state.task;
3264
3493
  const config = state.config;
3265
3494
  const statusPath = join(task.taskFolder, "STATUS.md");
3266
3495
  const wrapUpFile = join(task.taskFolder, ".task-wrap-up");
3267
- const legacyWrapUpFile = join(task.taskFolder, ".wiggum-wrap-up");
3268
3496
 
3269
3497
  const clearWrapUpSignals = () => {
3270
3498
  if (existsSync(wrapUpFile)) try { unlinkSync(wrapUpFile); } catch {}
3271
- if (existsSync(legacyWrapUpFile)) try { unlinkSync(legacyWrapUpFile); } catch {}
3272
3499
  };
3273
3500
 
3274
3501
  const writeWrapUpSignal = (reason: string) => {
3275
3502
  const msg = `${reason} at ${new Date().toISOString()}`;
3276
3503
  if (!existsSync(wrapUpFile)) writeFileSync(wrapUpFile, msg);
3277
- // Backward compatibility: write legacy signal too until all workers migrate.
3278
- if (!existsSync(legacyWrapUpFile)) writeFileSync(legacyWrapUpFile, msg);
3279
3504
  };
3280
3505
 
3281
3506
  clearWrapUpSignals();
@@ -3363,8 +3588,8 @@ export default function (pi: ExtensionAPI) {
3363
3588
  `5. Check for wrap-up signal files before starting the next step`,
3364
3589
  `6. Proceed to the next incomplete step`,
3365
3590
  ``,
3366
- `Wrap-up signal files: ${wrapUpFile} (primary), ${legacyWrapUpFile} (legacy)`,
3367
- `Check for either file after each checkpoint. If one exists, stop.`,
3591
+ `Wrap-up signal file: ${wrapUpFile}`,
3592
+ `Check for this file after each checkpoint. If it exists, stop.`,
3368
3593
  archiveSuppression,
3369
3594
  contextDocsList,
3370
3595
  ].join("\n");
@@ -3414,6 +3639,12 @@ export default function (pi: ExtensionAPI) {
3414
3639
  // Kill via wall-clock timeout (context-% wrap-up also available via sidecar).
3415
3640
  const sessionName = `${getTmuxPrefix()}-worker`;
3416
3641
 
3642
+ // TP-090: Construct .steering-pending path for worker-only STATUS.md annotation.
3643
+ // Only set when running under orchestrator (mailbox is orch-only).
3644
+ const steeringPendingPath = isOrchestratedMode()
3645
+ ? join(task.taskFolder, ".steering-pending")
3646
+ : undefined;
3647
+
3417
3648
  const spawned = spawnAgentTmux({
3418
3649
  sessionName,
3419
3650
  cwd: ctx.cwd,
@@ -3423,6 +3654,11 @@ export default function (pi: ExtensionAPI) {
3423
3654
  tools: config.worker.tools || workerDef?.tools || "read,write,edit,bash,grep,find,ls",
3424
3655
  thinking: config.worker.thinking || "off",
3425
3656
  taskId: task.taskId,
3657
+ steeringPendingPath,
3658
+ // TP-097: Pass stable sidecar paths and shared tailState for cross-iteration identity
3659
+ sidecarPath: stableSidecar?.sidecarPath,
3660
+ exitSummaryPath: stableSidecar?.exitSummaryPath,
3661
+ tailState: sharedTailState ?? undefined,
3426
3662
  onTelemetry: (delta) => {
3427
3663
  // Accumulate tokens and cost (same as subprocess onTokenUpdate)
3428
3664
  state.workerInputTokens += delta.inputTokens;
@@ -148,9 +148,8 @@ export function planAbortActions(
148
148
  /**
149
149
  * Write wrap-up signal files to each lane's task folder.
150
150
  *
151
- * Writes both `.task-wrap-up` (primary) and `.wiggum-wrap-up` (legacy)
152
- * for backward compatibility. Continues on partial failure — aggregates
153
- * errors per lane.
151
+ * Writes `.task-wrap-up` signal file to each lane's task folder.
152
+ * Continues on partial failure — aggregates errors per lane.
154
153
  *
155
154
  * @param targets - Target sessions with resolved task folders
156
155
  * @returns Updated target results with wrapUpWritten/wrapUpError
@@ -176,7 +175,6 @@ export function writeWrapUpFiles(
176
175
 
177
176
  try {
178
177
  const primaryPath = join(target.taskFolderInWorktree, ".task-wrap-up");
179
- const legacyPath = join(target.taskFolderInWorktree, ".wiggum-wrap-up");
180
178
 
181
179
  // Ensure directory exists
182
180
  if (!existsSync(target.taskFolderInWorktree)) {
@@ -185,7 +183,6 @@ export function writeWrapUpFiles(
185
183
  }
186
184
 
187
185
  writeFileSync(primaryPath, content, "utf-8");
188
- writeFileSync(legacyPath, content, "utf-8");
189
186
  results.push({ sessionName: target.sessionName, written: true, error: null });
190
187
  } catch (err) {
191
188
  results.push({
@@ -588,6 +588,11 @@ export function buildTmuxSpawnArgs(
588
588
  "--exit-summary-path", shellQuote(exitSummaryPath),
589
589
  "--prompt-file", shellQuote(promptTmpFile),
590
590
  "--extensions", shellQuote(taskRunnerExtPath),
591
+ // Prevent pi from auto-discovering extensions from the worktree CWD.
592
+ // Without this, pi loads BOTH the explicit -e extension AND any
593
+ // extensions/ in the worktree, causing duplicate tool registration
594
+ // and unpredictable behavior (two copies of task-runner compete).
595
+ "--", "--no-extensions",
591
596
  ].filter(Boolean).join(" ");
592
597
  } else {
593
598
  // ── Legacy mode: direct pi spawn (no telemetry) ─────────
@@ -2,7 +2,7 @@
2
2
  * Merge orchestration, merge agents, merge worktree
3
3
  * @module orch/merge
4
4
  */
5
- import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync } from "fs";
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync, readdirSync } from "fs";
6
6
  import { readFile as fsReadFile } from "fs/promises";
7
7
  import { execSync, spawnSync } from "child_process";
8
8
  import { join, dirname, resolve, relative } from "path";
@@ -1841,16 +1841,48 @@ export async function mergeWave(
1841
1841
  // ── Stage workspace task artifacts into merge worktree ──────────
1842
1842
  // TP-035: Tightened artifact staging — only allowlisted task-owned files
1843
1843
  // are staged. The allowlist is derived per-task-folder from completed lanes:
1844
- // exactly `.DONE`, `STATUS.md`, and `REVIEW_VERDICT.json` (when present).
1844
+ // `.DONE`, `STATUS.md`, `REVIEW_VERDICT.json`, and `.reviews/**` files.
1845
1845
  // Files outside known task folders, worktree internals, and repo-escape
1846
1846
  // paths are rejected. Uses resolve+relative path containment consistent
1847
1847
  // with ensureTaskFilesCommitted() in execution.ts.
1848
1848
  if (mergeWorkDir) {
1849
1849
  // Build the set of allowed artifact paths (repo-root-relative) from
1850
1850
  // the completed lanes' task folders.
1851
+ //
1852
+ // Allowlist policy:
1853
+ // - task marker files: .DONE, STATUS.md, REVIEW_VERDICT.json
1854
+ // - review outputs under task-local .reviews/**
1851
1855
  const ALLOWED_ARTIFACT_NAMES = [".DONE", "STATUS.md", "REVIEW_VERDICT.json"];
1856
+ const ALLOWED_ARTIFACT_DIRS = [".reviews"];
1852
1857
  const resolvedRepoRoot = resolve(repoRoot);
1853
1858
  const allowedRelPaths = new Set<string>();
1859
+ const relPathToWorktree = new Map<string, string>();
1860
+
1861
+ const listFilesRecursively = (rootDir: string): string[] => {
1862
+ if (!existsSync(rootDir)) return [];
1863
+ const files: string[] = [];
1864
+ const walk = (dir: string): void => {
1865
+ let entries;
1866
+ try {
1867
+ entries = readdirSync(dir, { withFileTypes: true });
1868
+ } catch {
1869
+ return;
1870
+ }
1871
+ for (const entry of entries) {
1872
+ const absPath = join(dir, entry.name);
1873
+ if (entry.isDirectory()) {
1874
+ walk(absPath);
1875
+ continue;
1876
+ }
1877
+ if (!entry.isFile()) continue;
1878
+ const relPath = relative(rootDir, absPath).replace(/\\/g, "/");
1879
+ if (!relPath || relPath.startsWith("..") || relPath.startsWith("/")) continue;
1880
+ files.push(relPath);
1881
+ }
1882
+ };
1883
+ walk(rootDir);
1884
+ return files;
1885
+ };
1854
1886
 
1855
1887
  for (const lane of orderedLanes) {
1856
1888
  for (const allocTask of lane.tasks) {
@@ -1867,7 +1899,24 @@ export async function mergeWave(
1867
1899
  }
1868
1900
 
1869
1901
  for (const name of ALLOWED_ARTIFACT_NAMES) {
1870
- allowedRelPaths.add(`${relFolder}/${name}`);
1902
+ const rp = `${relFolder}/${name}`;
1903
+ allowedRelPaths.add(rp);
1904
+ relPathToWorktree.set(rp, join(lane.worktreePath, rp));
1905
+ }
1906
+
1907
+ for (const dirName of ALLOWED_ARTIFACT_DIRS) {
1908
+ const laneDir = join(lane.worktreePath, relFolder, dirName);
1909
+ for (const relFile of listFilesRecursively(laneDir)) {
1910
+ const rp = `${relFolder}/${dirName}/${relFile}`;
1911
+ allowedRelPaths.add(rp);
1912
+ relPathToWorktree.set(rp, join(lane.worktreePath, rp));
1913
+ }
1914
+
1915
+ const repoDir = join(repoRoot, relFolder, dirName);
1916
+ for (const relFile of listFilesRecursively(repoDir)) {
1917
+ const rp = `${relFolder}/${dirName}/${relFile}`;
1918
+ allowedRelPaths.add(rp);
1919
+ }
1871
1920
  }
1872
1921
  }
1873
1922
  }
@@ -1875,12 +1924,44 @@ export async function mergeWave(
1875
1924
  if (allowedRelPaths.size > 0) {
1876
1925
  let staged = 0;
1877
1926
  let skipped = 0;
1927
+ let preserved = 0;
1878
1928
 
1879
1929
  for (const relPath of allowedRelPaths) {
1880
- const srcPath = join(repoRoot, relPath);
1881
- if (!existsSync(srcPath)) continue; // File not present (e.g., no REVIEW_VERDICT.json) — skip silently
1882
-
1883
1930
  const destPath = join(mergeWorkDir, relPath);
1931
+
1932
+ // TP-099: If the file already exists in mergeWorkDir (from lane merge),
1933
+ // do NOT overwrite it — the lane merge brought the correct worker-updated
1934
+ // version (e.g., STATUS.md with checked items, execution log, discoveries).
1935
+ // Overwriting from repoRoot would revert to the pre-execution template.
1936
+ if (existsSync(destPath)) {
1937
+ preserved++;
1938
+ continue;
1939
+ }
1940
+
1941
+ // File missing from mergeWorkDir — backfill from best available source.
1942
+ // Primary: lane worktree (has worker-generated .DONE/STATUS/.reviews content).
1943
+ // Fallback: repoRoot (original task folder, with path containment check).
1944
+ const worktreeSrc = relPathToWorktree.get(relPath);
1945
+ let srcPath: string | null = null;
1946
+
1947
+ // Try lane worktree first (trusted engine-allocated path)
1948
+ if (worktreeSrc && existsSync(worktreeSrc)) {
1949
+ srcPath = worktreeSrc;
1950
+ } else {
1951
+ // Fallback to repoRoot with path containment check (TP-035 hardening)
1952
+ const repoRootSrc = join(repoRoot, relPath);
1953
+ if (existsSync(repoRootSrc)) {
1954
+ const resolvedSrc = resolve(repoRootSrc);
1955
+ const srcRelToRepo = relative(resolvedRepoRoot, resolvedSrc).replace(/\\/g, "/");
1956
+ if (srcRelToRepo.startsWith("..") || srcRelToRepo.startsWith("/")) {
1957
+ execLog("merge", `W${waveIndex}`, `skipping artifact source outside repo root`, { path: relPath, src: repoRootSrc });
1958
+ continue;
1959
+ }
1960
+ srcPath = repoRootSrc;
1961
+ }
1962
+ }
1963
+ if (!srcPath) continue; // File not present anywhere — skip silently
1964
+
1884
1965
  try {
1885
1966
  mkdirSync(dirname(destPath), { recursive: true });
1886
1967
  copyFileSync(srcPath, destPath);
@@ -1894,13 +1975,14 @@ export async function mergeWave(
1894
1975
  }
1895
1976
 
1896
1977
  if (staged > 0) {
1897
- spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json)`], { cwd: mergeWorkDir });
1978
+ spawnSync("git", ["commit", "-m", `checkpoint: wave ${waveIndex} task artifacts (.DONE, STATUS.md, REVIEW_VERDICT.json, .reviews/*)`], { cwd: mergeWorkDir });
1898
1979
  execLog("merge", `W${waveIndex}`, `committed ${staged} task artifact(s) to merge worktree`, {
1899
1980
  skipped,
1981
+ preserved,
1900
1982
  allowedCandidates: allowedRelPaths.size,
1901
1983
  });
1902
1984
  } else {
1903
- execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed)`);
1985
+ execLog("merge", `W${waveIndex}`, `no task artifacts to stage (0 of ${allowedRelPaths.size} candidates present/changed, ${preserved} preserved from lane merge)`);
1904
1986
  }
1905
1987
 
1906
1988
  // Keep both .DONE and STATUS.md in develop's working tree:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskplane",
3
- "version": "0.22.13",
3
+ "version": "0.22.15",
4
4
  "description": "AI agent orchestration for pi — parallel task execution with checkpoint discipline",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -53,12 +53,11 @@ edit STATUS.md
53
53
 
54
54
  Then **check for wrap-up signal:**
55
55
  ```bash
56
- if test -f "<TASK_FOLDER>/.task-wrap-up" || test -f "<TASK_FOLDER>/.wiggum-wrap-up"; then
56
+ if test -f "<TASK_FOLDER>/.task-wrap-up"; then
57
57
  echo "WRAP_UP_SIGNAL"
58
58
  fi
59
59
  ```
60
- Primary signal file is `.task-wrap-up`; `.wiggum-wrap-up` is legacy and still supported.
61
- If either signal exists, STOP immediately after this checkpoint.
60
+ If the signal exists, STOP immediately after this checkpoint.
62
61
 
63
62
  If you do work but don't edit STATUS.md, that work is INVISIBLE to the
64
63
  orchestrator and you will be re-spawned to do it again.
@@ -262,6 +261,19 @@ Do NOT:
262
261
  - Modify docs listed in `task-runner.yaml → protected_docs` without explicit approval
263
262
  - Expand task scope — add tech debt instead
264
263
 
264
+ ## Steering Messages
265
+
266
+ During orchestrated runs, the supervisor may send steering messages to adjust
267
+ your approach. These messages appear in your conversation as user messages at
268
+ turn boundaries. They are also logged in the STATUS.md execution log as
269
+ `⚠️ Steering` entries for audit visibility.
270
+
271
+ When you receive a steering message:
272
+ 1. **Read it carefully** — it contains course corrections from the supervisor
273
+ 2. **Adjust your approach** as directed
274
+ 3. **Continue working** — do not stop or restart; incorporate the guidance naturally
275
+ 4. Steering messages are authoritative — treat them like direct instructions
276
+
265
277
  ## Error Handling
266
278
 
267
279
  - If stuck on the same issue after 3 attempts, document the blocker in STATUS.md