taskplane 0.9.3 → 0.10.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.
@@ -1,4 +1,5 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { Type } from "@mariozechner/pi-ai";
2
3
 
3
4
  import { execSync, execFileSync } from "child_process";
4
5
  import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
@@ -1729,375 +1730,617 @@ export default function (pi: ExtensionAPI) {
1729
1730
  },
1730
1731
  });
1731
1732
 
1732
- pi.registerCommand("orch-status", {
1733
- description: "Show current batch progress",
1734
- handler: async (_args, ctx) => {
1735
- // ── TP-040: Disk fallback for idle in-memory state ────────
1736
- // When in-memory state is idle, try loading from persisted
1737
- // batch-state.json. This covers fresh-session queries (pi
1738
- // restarted while a batch was running in tmux lanes) and
1739
- // post-crash recovery where in-memory state was lost.
1740
- if (orchBatchState.phase === "idle") {
1741
- const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
1742
- let diskState: PersistedBatchState | null = null;
1743
- try {
1744
- diskState = loadBatchState(stateRoot);
1745
- } catch {
1746
- // Ignore errors — fall through to "no batch" message
1747
- }
1733
+ // ── TP-053: Shared helpers for command + tool handlers ────────────
1734
+ // Each helper extracts the core logic from its command handler so both
1735
+ // the slash command and the registered tool can call the same function.
1748
1736
 
1749
- if (!diskState) {
1750
- ctx.ui.notify("No batch is running. Use /orch <areas|paths|all> to start.", "info");
1751
- return;
1752
- }
1753
-
1754
- // Show status from persisted state
1755
- const elapsedSec = diskState.endedAt
1756
- ? Math.round((diskState.endedAt - diskState.startedAt) / 1000)
1757
- : Math.round((Date.now() - diskState.startedAt) / 1000);
1758
-
1759
- const lines: string[] = [
1760
- `📊 Batch ${diskState.batchId}${diskState.phase} (from disk)`,
1761
- ` Wave: ${diskState.currentWaveIndex + 1}/${diskState.totalWaves}`,
1762
- ` Tasks: ${diskState.succeededTasks} succeeded, ${diskState.failedTasks} failed, ${diskState.skippedTasks} skipped, ${diskState.blockedTasks} blocked / ${diskState.totalTasks} total`,
1763
- ` Elapsed: ${elapsedSec}s`,
1764
- ];
1765
-
1766
- if (diskState.errors.length > 0) {
1767
- lines.push(` Errors: ${diskState.errors.length}`);
1768
- }
1737
+ /**
1738
+ * Core logic for orch-status. Returns a formatted status string.
1739
+ * Reads in-memory state first, falls back to disk if idle.
1740
+ */
1741
+ function doOrchStatus(cwd: string): string {
1742
+ if (orchBatchState.phase === "idle") {
1743
+ const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? cwd;
1744
+ let diskState: PersistedBatchState | null = null;
1745
+ try {
1746
+ diskState = loadBatchState(stateRoot);
1747
+ } catch {
1748
+ // Ignore errorsfall through to "no batch" message
1749
+ }
1769
1750
 
1770
- ctx.ui.notify(lines.join("\n"), "info");
1771
- return;
1751
+ if (!diskState) {
1752
+ return "No batch is running. Use /orch <areas|paths|all> to start.";
1772
1753
  }
1773
1754
 
1774
- const elapsedSec = orchBatchState.endedAt
1775
- ? Math.round((orchBatchState.endedAt - orchBatchState.startedAt) / 1000)
1776
- : Math.round((Date.now() - orchBatchState.startedAt) / 1000);
1755
+ const elapsedSec = diskState.endedAt
1756
+ ? Math.round((diskState.endedAt - diskState.startedAt) / 1000)
1757
+ : Math.round((Date.now() - diskState.startedAt) / 1000);
1777
1758
 
1778
1759
  const lines: string[] = [
1779
- `📊 Batch ${orchBatchState.batchId} — ${orchBatchState.phase}`,
1780
- ` Wave: ${orchBatchState.currentWaveIndex + 1}/${orchBatchState.totalWaves}`,
1781
- ` Tasks: ${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ${orchBatchState.skippedTasks} skipped, ${orchBatchState.blockedTasks} blocked / ${orchBatchState.totalTasks} total`,
1760
+ `📊 Batch ${diskState.batchId} — ${diskState.phase} (from disk)`,
1761
+ ` Wave: ${diskState.currentWaveIndex + 1}/${diskState.totalWaves}`,
1762
+ ` Tasks: ${diskState.succeededTasks} succeeded, ${diskState.failedTasks} failed, ${diskState.skippedTasks} skipped, ${diskState.blockedTasks} blocked / ${diskState.totalTasks} total`,
1782
1763
  ` Elapsed: ${elapsedSec}s`,
1783
1764
  ];
1784
1765
 
1785
- if (orchBatchState.errors.length > 0) {
1786
- lines.push(` Errors: ${orchBatchState.errors.length}`);
1766
+ if (diskState.errors.length > 0) {
1767
+ lines.push(` Errors: ${diskState.errors.length}`);
1787
1768
  }
1788
1769
 
1789
- ctx.ui.notify(lines.join("\n"), "info");
1790
- },
1791
- });
1770
+ return lines.join("\n");
1771
+ }
1792
1772
 
1793
- pi.registerCommand("orch-pause", {
1794
- description: "Pause batch after current tasks finish",
1795
- handler: async (_args, ctx) => {
1796
- if (orchBatchState.phase === "idle" || orchBatchState.phase === "completed" || orchBatchState.phase === "failed" || orchBatchState.phase === "stopped") {
1797
- ctx.ui.notify(ORCH_MESSAGES.pauseNoBatch(), "warning");
1798
- return;
1799
- }
1800
- if (orchBatchState.phase === "paused" || orchBatchState.pauseSignal.paused) {
1801
- ctx.ui.notify(ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId), "warning");
1802
- return;
1803
- }
1804
- // Set pause signal — executeLane() checks this between tasks
1805
- orchBatchState.pauseSignal.paused = true;
1806
- ctx.ui.notify(ORCH_MESSAGES.pauseActivated(orchBatchState.batchId), "info");
1807
- updateOrchWidget();
1808
- },
1809
- });
1773
+ const elapsedSec = orchBatchState.endedAt
1774
+ ? Math.round((orchBatchState.endedAt - orchBatchState.startedAt) / 1000)
1775
+ : Math.round((Date.now() - orchBatchState.startedAt) / 1000);
1810
1776
 
1811
- pi.registerCommand("orch-resume", {
1812
- description: "Resume a paused or interrupted batch: /orch-resume [--force]",
1813
- handler: async (args, ctx) => {
1814
- if (!requireExecCtx(ctx)) return;
1777
+ const lines: string[] = [
1778
+ `📊 Batch ${orchBatchState.batchId} ${orchBatchState.phase}`,
1779
+ ` Wave: ${orchBatchState.currentWaveIndex + 1}/${orchBatchState.totalWaves}`,
1780
+ ` Tasks: ${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ${orchBatchState.skippedTasks} skipped, ${orchBatchState.blockedTasks} blocked / ${orchBatchState.totalTasks} total`,
1781
+ ` Elapsed: ${elapsedSec}s`,
1782
+ ];
1815
1783
 
1816
- // Parse arguments
1817
- const parsed = parseResumeArgs(args);
1818
- if ("error" in parsed) {
1819
- ctx.ui.notify(`❌ ${parsed.error}`, "error");
1820
- return;
1821
- }
1784
+ if (orchBatchState.errors.length > 0) {
1785
+ lines.push(` Errors: ${orchBatchState.errors.length}`);
1786
+ }
1822
1787
 
1823
- // Prevent resume if a batch is actively running (includes "launching" from non-blocking detach)
1824
- if (orchBatchState.phase === "launching" || orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
1825
- ctx.ui.notify(
1826
- `⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`,
1827
- "warning",
1828
- );
1829
- return;
1830
- }
1788
+ return lines.join("\n");
1789
+ }
1831
1790
 
1832
- // Reset batch state for resume
1833
- orchBatchState = freshOrchBatchState();
1834
- latestMonitorState = null;
1791
+ /**
1792
+ * Core logic for orch-pause. Returns a status message string.
1793
+ */
1794
+ function doOrchPause(): string {
1795
+ if (orchBatchState.phase === "idle" || orchBatchState.phase === "completed" || orchBatchState.phase === "failed" || orchBatchState.phase === "stopped") {
1796
+ return ORCH_MESSAGES.pauseNoBatch();
1797
+ }
1798
+ if (orchBatchState.phase === "paused" || orchBatchState.pauseSignal.paused) {
1799
+ return ORCH_MESSAGES.pauseAlreadyPaused(orchBatchState.batchId);
1800
+ }
1801
+ orchBatchState.pauseSignal.paused = true;
1802
+ updateOrchWidget();
1803
+ return ORCH_MESSAGES.pauseActivated(orchBatchState.batchId);
1804
+ }
1835
1805
 
1836
- // ── TP-040: Set launching phase synchronously ────────────
1837
- // Same as /orch mark as "launching" before setTimeout detach
1838
- // so commands issued immediately see an active batch.
1839
- orchBatchState.phase = "launching";
1840
- orchBatchState.startedAt = Date.now();
1841
- updateOrchWidget();
1806
+ /**
1807
+ * Core logic for orch-resume. Returns an immediate status message.
1808
+ * The actual batch resume runs asynchronously via startBatchAsync.
1809
+ * Returns null if execCtx is missing (caller must handle).
1810
+ */
1811
+ function doOrchResume(force: boolean, ctx: ExtensionContext): { message: string; error?: boolean } {
1812
+ if (!execCtx) {
1813
+ return {
1814
+ message: "❌ Orchestrator not initialized. Workspace configuration failed at startup.\nFix the workspace config or remove it to use repo mode, then restart.",
1815
+ error: true,
1816
+ };
1817
+ }
1842
1818
 
1843
- // ── TP-040: Non-blocking resume launch ───────────────────
1844
- // Same fire-and-forget pattern as /orch see startBatchAsync.
1845
- startBatchAsync(
1846
- () => resumeOrchBatch(
1847
- orchConfig,
1848
- runnerConfig,
1849
- execCtx!.repoRoot,
1850
- orchBatchState,
1851
- (message, level) => {
1852
- ctx.ui.notify(message, level);
1853
- updateOrchWidget();
1854
- },
1855
- (monState: MonitorState) => {
1856
- latestMonitorState = monState;
1857
- updateOrchWidget();
1858
- },
1859
- execCtx!.workspaceConfig,
1860
- execCtx!.workspaceRoot,
1861
- execCtx!.pointer?.agentRoot,
1862
- parsed.force,
1863
- ),
1864
- orchBatchState,
1865
- ctx,
1866
- updateOrchWidget,
1867
- // TP-043: Deferred supervisor deactivation (R002-1, parity with /orch).
1868
- // Only trigger integration on completed batches.
1869
- // TP-043 Step 2: Batch summary on all terminal paths.
1870
- () => {
1871
- const mode = orchConfig.orchestrator.integration;
1872
- const opId = resolveOperatorId(orchConfig);
1873
- const sDeps: SummaryDeps = {
1874
- opId,
1875
- diagnostics: orchBatchState.diagnostics ?? null,
1876
- mergeResults: (orchBatchState.mergeResults || []).map(mr => ({
1877
- waveIndex: mr.waveIndex,
1878
- status: mr.status,
1879
- failedLane: mr.failedLane,
1880
- failureReason: mr.failureReason,
1881
- })),
1882
- };
1883
- if (
1884
- orchBatchState.phase === "completed" &&
1885
- (mode === "supervised" || mode === "auto")
1886
- ) {
1887
- triggerSupervisorIntegration(
1888
- pi,
1889
- supervisorState,
1890
- orchBatchState,
1891
- mode,
1892
- execCtx!.repoRoot,
1893
- buildIntegrationExecutor(execCtx!.repoRoot, opId),
1894
- buildCiDeps(execCtx!.repoRoot),
1895
- sDeps,
1896
- );
1897
- return;
1898
- }
1899
- if (
1900
- (mode === "supervised" || mode === "auto") &&
1901
- orchBatchState.phase !== "completed"
1902
- ) {
1903
- pi.sendMessage(
1904
- {
1905
- customType: "supervisor-integration-skipped",
1906
- content: [{
1907
- type: "text",
1908
- text:
1909
- `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
1910
- `Integration skipped — only completed batches are eligible.\n` +
1911
- `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
1912
- }],
1913
- display: `Integration skipped — batch ${orchBatchState.phase}`,
1914
- },
1915
- { triggerTurn: false },
1916
- );
1917
- }
1918
- // TP-043: Generate summary before transition
1919
- presentBatchSummary(pi, orchBatchState, execCtx!.workspaceRoot, opId, orchBatchState.diagnostics, sDeps.mergeResults);
1920
- // TP-128: Transition to routing mode (same as /orch onTerminal)
1921
- const postBatchContext: SupervisorRoutingContext = orchBatchState.phase === "completed"
1922
- ? {
1923
- routingState: "completed-batch",
1924
- contextMessage:
1925
- `Batch **${orchBatchState.batchId}** completed — ` +
1926
- `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` +
1927
- `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` +
1928
- `Would you like me to integrate it, or would you prefer to review first?\n\n` +
1929
- `You can also:\n` +
1930
- `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` +
1931
- `• Create new tasks for the next batch\n` +
1932
- `• Run a health check`,
1933
- }
1934
- : {
1935
- routingState: "no-tasks",
1936
- contextMessage:
1937
- `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` +
1938
- `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` +
1939
- `${orchBatchState.skippedTasks} skipped.\n\n` +
1940
- `What would you like to do next?`,
1941
- };
1942
- transitionToRoutingMode(pi, supervisorState, postBatchContext);
1943
- },
1944
- );
1819
+ // Prevent resume if a batch is actively running
1820
+ if (orchBatchState.phase === "launching" || orchBatchState.phase === "executing" || orchBatchState.phase === "merging" || orchBatchState.phase === "planning") {
1821
+ return {
1822
+ message: `⚠️ A batch is currently ${orchBatchState.phase} (${orchBatchState.batchId}). Cannot resume.`,
1823
+ error: true,
1824
+ };
1825
+ }
1945
1826
 
1946
- // ── TP-041: Activate supervisor agent on resume ──────────
1947
- // supervisorConfig is loaded at session_start from unified config.
1948
- // Uses workspaceRoot so supervisor state root matches engine (R006-1).
1949
- activateSupervisor(
1950
- pi,
1951
- supervisorState,
1952
- orchBatchState,
1827
+ // Reset batch state for resume
1828
+ orchBatchState = freshOrchBatchState();
1829
+ latestMonitorState = null;
1830
+
1831
+ orchBatchState.phase = "launching";
1832
+ orchBatchState.startedAt = Date.now();
1833
+ updateOrchWidget();
1834
+
1835
+ // Fire-and-forget resume via startBatchAsync
1836
+ startBatchAsync(
1837
+ () => resumeOrchBatch(
1953
1838
  orchConfig,
1954
- supervisorConfig,
1839
+ runnerConfig,
1840
+ execCtx!.repoRoot,
1841
+ orchBatchState,
1842
+ (message, level) => {
1843
+ ctx.ui.notify(message, level);
1844
+ updateOrchWidget();
1845
+ },
1846
+ (monState: MonitorState) => {
1847
+ latestMonitorState = monState;
1848
+ updateOrchWidget();
1849
+ },
1850
+ execCtx!.workspaceConfig,
1955
1851
  execCtx!.workspaceRoot,
1956
- ctx,
1957
- );
1958
- },
1959
- });
1852
+ execCtx!.pointer?.agentRoot,
1853
+ force,
1854
+ ),
1855
+ orchBatchState,
1856
+ ctx,
1857
+ updateOrchWidget,
1858
+ () => {
1859
+ const mode = orchConfig.orchestrator.integration;
1860
+ const opId = resolveOperatorId(orchConfig);
1861
+ const sDeps: SummaryDeps = {
1862
+ opId,
1863
+ diagnostics: orchBatchState.diagnostics ?? null,
1864
+ mergeResults: (orchBatchState.mergeResults || []).map(mr => ({
1865
+ waveIndex: mr.waveIndex,
1866
+ status: mr.status,
1867
+ failedLane: mr.failedLane,
1868
+ failureReason: mr.failureReason,
1869
+ })),
1870
+ };
1871
+ if (
1872
+ orchBatchState.phase === "completed" &&
1873
+ (mode === "supervised" || mode === "auto")
1874
+ ) {
1875
+ triggerSupervisorIntegration(
1876
+ pi,
1877
+ supervisorState,
1878
+ orchBatchState,
1879
+ mode,
1880
+ execCtx!.repoRoot,
1881
+ buildIntegrationExecutor(execCtx!.repoRoot, opId),
1882
+ buildCiDeps(execCtx!.repoRoot),
1883
+ sDeps,
1884
+ );
1885
+ return;
1886
+ }
1887
+ if (
1888
+ (mode === "supervised" || mode === "auto") &&
1889
+ orchBatchState.phase !== "completed"
1890
+ ) {
1891
+ pi.sendMessage(
1892
+ {
1893
+ customType: "supervisor-integration-skipped",
1894
+ content: [{
1895
+ type: "text",
1896
+ text:
1897
+ `📋 **Batch ended** (phase: ${orchBatchState.phase}). ` +
1898
+ `Integration skipped — only completed batches are eligible.\n` +
1899
+ `Use \`/orch-resume\` to continue or \`/orch-integrate\` manually after resolving issues.`,
1900
+ }],
1901
+ display: `Integration skipped — batch ${orchBatchState.phase}`,
1902
+ },
1903
+ { triggerTurn: false },
1904
+ );
1905
+ }
1906
+ presentBatchSummary(pi, orchBatchState, execCtx!.workspaceRoot, opId, orchBatchState.diagnostics, sDeps.mergeResults);
1907
+ const postBatchContext: SupervisorRoutingContext = orchBatchState.phase === "completed"
1908
+ ? {
1909
+ routingState: "completed-batch",
1910
+ contextMessage:
1911
+ `Batch **${orchBatchState.batchId}** completed — ` +
1912
+ `${orchBatchState.succeededTasks}/${orchBatchState.totalTasks} tasks succeeded.\n\n` +
1913
+ `The orch branch \`${orchBatchState.orchBranch}\` is ready to integrate.\n` +
1914
+ `Would you like me to integrate it, or would you prefer to review first?\n\n` +
1915
+ `You can also:\n` +
1916
+ `• Run \`/orch-integrate\` (or \`/orch-integrate --pr\`) to integrate\n` +
1917
+ `• Create new tasks for the next batch\n` +
1918
+ `• Run a health check`,
1919
+ }
1920
+ : {
1921
+ routingState: "no-tasks",
1922
+ contextMessage:
1923
+ `Batch **${orchBatchState.batchId}** ended (${orchBatchState.phase}).\n\n` +
1924
+ `${orchBatchState.succeededTasks} succeeded, ${orchBatchState.failedTasks} failed, ` +
1925
+ `${orchBatchState.skippedTasks} skipped.\n\n` +
1926
+ `What would you like to do next?`,
1927
+ };
1928
+ transitionToRoutingMode(pi, supervisorState, postBatchContext);
1929
+ },
1930
+ );
1960
1931
 
1961
- pi.registerCommand("orch-abort", {
1962
- description: "Abort batch: /orch-abort [--hard]",
1963
- handler: async (args, ctx) => {
1964
- try {
1965
- const hard = args?.trim() === "--hard";
1966
- const mode: AbortMode = hard ? "hard" : "graceful";
1967
- const prefix = orchConfig.orchestrator.tmux_prefix;
1968
- const gracePeriodMs = orchConfig.orchestrator.abort_grace_period * 1000;
1969
-
1970
- // Abort must work even if execCtx failed to load (safety-critical).
1971
- // Fall back to ctx.cwd if no execution context is available.
1972
- // Uses repoRoot for consistency with engine/resume/execution
1973
- // which all persist state and poll abort signals from repoRoot.
1974
- const stateRoot = execCtx?.repoRoot ?? ctx.cwd;
1932
+ // Activate supervisor agent on resume
1933
+ activateSupervisor(
1934
+ pi,
1935
+ supervisorState,
1936
+ orchBatchState,
1937
+ orchConfig,
1938
+ supervisorConfig,
1939
+ execCtx!.workspaceRoot,
1940
+ ctx,
1941
+ );
1975
1942
 
1976
- ctx.ui.notify(`🛑 Abort requested (${mode} mode, prefix: ${prefix})...`, "info");
1943
+ return { message: `🔄 Resume initiated for batch. Phase: launching.` };
1944
+ }
1977
1945
 
1978
- // ── Step 1: Write abort signal file immediately ──────────
1979
- // This is the primary abort mechanism. The orchestrator's polling
1980
- // loop checks for this file on every cycle, so even if this command
1981
- // handler runs concurrently with /orch (or is queued behind it),
1982
- // the signal file will be detected.
1983
- const abortSignalFile = join(stateRoot, ".pi", "orch-abort-signal");
1984
- try {
1985
- mkdirSync(join(stateRoot, ".pi"), { recursive: true });
1986
- writeFileSync(abortSignalFile, `abort requested at ${new Date().toISOString()} (mode: ${mode})`, "utf-8");
1987
- ctx.ui.notify(" ✓ Abort signal file written (.pi/orch-abort-signal)", "info");
1988
- } catch (err) {
1989
- ctx.ui.notify(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`, "warning");
1990
- }
1946
+ /**
1947
+ * Core logic for orch-abort. Returns accumulated status messages.
1948
+ * Works even without execCtx (safety-critical).
1949
+ */
1950
+ function doOrchAbort(hard: boolean, ctx: ExtensionContext): string {
1951
+ const mode: AbortMode = hard ? "hard" : "graceful";
1952
+ const prefix = orchConfig.orchestrator.tmux_prefix;
1991
1953
 
1992
- // ── Step 2: Set pause signal immediately ─────────────────
1993
- // Belt-and-suspenders: if the /orch polling loop can see this
1994
- // shared object, it will stop on the next iteration.
1995
- if (orchBatchState.pauseSignal) {
1996
- orchBatchState.pauseSignal.paused = true;
1997
- ctx.ui.notify(" ✓ Pause signal set on in-memory batch state", "info");
1998
- }
1954
+ const stateRoot = execCtx?.repoRoot ?? ctx.cwd;
1955
+ const messages: string[] = [`🛑 Abort requested (${mode} mode, prefix: ${prefix})...`];
1956
+
1957
+ // Step 1: Write abort signal file
1958
+ const abortSignalFile = join(stateRoot, ".pi", "orch-abort-signal");
1959
+ try {
1960
+ mkdirSync(join(stateRoot, ".pi"), { recursive: true });
1961
+ writeFileSync(abortSignalFile, `abort requested at ${new Date().toISOString()} (mode: ${mode})`, "utf-8");
1962
+ messages.push(" ✓ Abort signal file written (.pi/orch-abort-signal)");
1963
+ } catch (err) {
1964
+ messages.push(` ⚠ Failed to write abort signal file: ${err instanceof Error ? err.message : String(err)}`);
1965
+ }
1966
+
1967
+ // Step 2: Set pause signal
1968
+ if (orchBatchState.pauseSignal) {
1969
+ orchBatchState.pauseSignal.paused = true;
1970
+ messages.push(" ✓ Pause signal set on in-memory batch state");
1971
+ }
1972
+
1973
+ // Step 3: Check what we're aborting
1974
+ const hasActiveBatch = orchBatchState.phase !== "idle" &&
1975
+ orchBatchState.phase !== "completed" &&
1976
+ orchBatchState.phase !== "failed" &&
1977
+ orchBatchState.phase !== "stopped";
1978
+
1979
+ let persistedState: PersistedBatchState | null = null;
1980
+ try {
1981
+ persistedState = loadBatchState(stateRoot);
1982
+ } catch {
1983
+ // Ignore
1984
+ }
1985
+
1986
+ messages.push(
1987
+ ` Batch state: in-memory=${hasActiveBatch ? orchBatchState.phase : "none"}, ` +
1988
+ `persisted=${persistedState ? persistedState.batchId : "none"}`,
1989
+ );
1990
+
1991
+ // If no batch AND no sessions, nothing to abort
1992
+ if (!hasActiveBatch && !persistedState) {
1993
+ // Still check for sessions below, but short-circuit if none
1994
+ let allSessionNames: string[] = [];
1995
+ try {
1996
+ const tmuxOutput = execSync('tmux list-sessions -F "#{session_name}"', {
1997
+ encoding: "utf-8",
1998
+ timeout: 5000,
1999
+ }).trim();
2000
+ const all = tmuxOutput ? tmuxOutput.split("\n").map(s => s.trim()).filter(Boolean) : [];
2001
+ allSessionNames = all.filter(name => name.startsWith(`${prefix}-`));
2002
+ } catch {
2003
+ // tmux not available
2004
+ }
2005
+ if (allSessionNames.length === 0) {
2006
+ try { unlinkSync(abortSignalFile); } catch {}
2007
+ return ORCH_MESSAGES.abortNoBatch();
2008
+ }
2009
+ }
1999
2010
 
2000
- // ── Step 3: Check what we're aborting ────────────────────
2001
- const hasActiveBatch = orchBatchState.phase !== "idle" &&
2002
- orchBatchState.phase !== "completed" &&
2003
- orchBatchState.phase !== "failed" &&
2004
- orchBatchState.phase !== "stopped";
2011
+ const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
2012
+
2013
+ // Step 5: Kill sessions
2014
+ let allSessionNames: string[] = [];
2015
+ try {
2016
+ const tmuxOutput = execSync('tmux list-sessions -F "#{session_name}"', {
2017
+ encoding: "utf-8",
2018
+ timeout: 5000,
2019
+ }).trim();
2020
+ const all = tmuxOutput ? tmuxOutput.split("\n").map(s => s.trim()).filter(Boolean) : [];
2021
+ allSessionNames = all.filter(name => name.startsWith(`${prefix}-`));
2022
+ messages.push(` Found ${allSessionNames.length} session(s) matching prefix "${prefix}-"`);
2023
+ } catch {
2024
+ messages.push(" ⚠ Could not list tmux sessions (tmux not available?)");
2025
+ }
2005
2026
 
2006
- let persistedState: PersistedBatchState | null = null;
2027
+ if (allSessionNames.length > 0) {
2028
+ messages.push(` Killing ${allSessionNames.length} tmux session(s)...`);
2029
+ let killed = 0;
2030
+ for (const name of allSessionNames) {
2031
+ try {
2032
+ execSync(`tmux kill-session -t "${name}-worker" 2>/dev/null`, { timeout: 3000 }).toString();
2033
+ } catch {}
2007
2034
  try {
2008
- persistedState = loadBatchState(stateRoot);
2035
+ execSync(`tmux kill-session -t "${name}-reviewer" 2>/dev/null`, { timeout: 3000 }).toString();
2036
+ } catch {}
2037
+ try {
2038
+ execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 3000 }).toString();
2039
+ killed++;
2040
+ messages.push(` ✓ Killed: ${name}`);
2009
2041
  } catch {
2010
- // Ignore we may still have in-memory state or orphan sessions
2042
+ messages.push(` · ${name} (already exited)`);
2043
+ killed++;
2011
2044
  }
2045
+ }
2046
+ messages.push(` ✓ ${killed}/${allSessionNames.length} session(s) terminated`);
2047
+ } else {
2048
+ messages.push(" No tmux sessions to kill");
2049
+ }
2012
2050
 
2013
- ctx.ui.notify(
2014
- ` Batch state: in-memory=${hasActiveBatch ? orchBatchState.phase : "none"}, ` +
2015
- `persisted=${persistedState ? persistedState.batchId : "none"}`,
2016
- "info",
2051
+ // Step 6: Clean up batch state
2052
+ deactivateSupervisor(pi, supervisorState);
2053
+
2054
+ try {
2055
+ orchBatchState.phase = "stopped";
2056
+ orchBatchState.endedAt = Date.now();
2057
+ updateOrchWidget();
2058
+ messages.push(" ✓ In-memory batch state set to 'stopped'");
2059
+ } catch (err) {
2060
+ messages.push(` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`);
2061
+ }
2062
+
2063
+ try {
2064
+ deleteBatchState(stateRoot);
2065
+ messages.push(" ✓ Batch state file deleted (.pi/batch-state.json)");
2066
+ } catch (err) {
2067
+ messages.push(` ⚠ Failed to delete batch state file: ${err instanceof Error ? err.message : String(err)}`);
2068
+ }
2069
+
2070
+ // Step 7: Clean up abort signal file
2071
+ try { unlinkSync(abortSignalFile); } catch {}
2072
+
2073
+ messages.push(
2074
+ `✅ Abort complete for batch ${batchId}. Sessions killed, state cleaned up.\n` +
2075
+ ` Worktrees and branches are preserved for inspection.`,
2076
+ );
2077
+
2078
+ return messages.join("\n");
2079
+ }
2080
+
2081
+ /**
2082
+ * Core logic for orch-integrate. Returns a result message string.
2083
+ * On error, returns an object with error flag.
2084
+ */
2085
+ async function doOrchIntegrate(
2086
+ args: string | undefined,
2087
+ ctx: ExtensionContext,
2088
+ ): Promise<{ message: string; error?: boolean; level?: "info" | "warning" | "error" }> {
2089
+ if (!execCtx) {
2090
+ return {
2091
+ message: "❌ Orchestrator not initialized. Workspace configuration failed at startup.\nFix the workspace config or remove it to use repo mode, then restart.",
2092
+ error: true,
2093
+ };
2094
+ }
2095
+
2096
+ // Parse arguments
2097
+ const parsed = parseIntegrateArgs(args);
2098
+ if ("error" in parsed) {
2099
+ return { message: `❌ ${parsed.error}\n\nRun /orch-integrate --help for usage.`, error: true };
2100
+ }
2101
+
2102
+ // Resolve integration context
2103
+ const { repoRoot } = execCtx!;
2104
+ const resolution = resolveIntegrationContext(parsed, {
2105
+ loadBatchState: () => loadBatchState(repoRoot),
2106
+ getCurrentBranch: () => getCurrentBranch(repoRoot),
2107
+ listOrchBranches: () => {
2108
+ const result = runGit(["branch", "--list", "orch/*"], repoRoot);
2109
+ return result.ok
2110
+ ? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean)
2111
+ : [];
2112
+ },
2113
+ orchBranchExists: (branch: string) => {
2114
+ return runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot).ok;
2115
+ },
2116
+ });
2117
+
2118
+ if ("error" in resolution) {
2119
+ const severity = (resolution as IntegrationContextError).severity;
2120
+ return { message: resolution.error, error: severity !== "info" };
2121
+ }
2122
+
2123
+ const { orchBranch, baseBranch, batchId, currentBranch, notices } = resolution as IntegrationContext;
2124
+ const outputLines: string[] = [];
2125
+ let hasWarning = false;
2126
+
2127
+ for (const notice of notices) {
2128
+ outputLines.push(notice);
2129
+ }
2130
+
2131
+ // Branch protection pre-check (TP-052)
2132
+ if (parsed.mode !== "pr") {
2133
+ const { detectBranchProtection } = await import("./supervisor.ts");
2134
+ const protectionStatus = detectBranchProtection(baseBranch, repoRoot);
2135
+ if (protectionStatus === "protected") {
2136
+ hasWarning = true;
2137
+ outputLines.push(
2138
+ `⚠️ Branch \`${baseBranch}\` has branch protection rules enabled.\n` +
2139
+ `Direct merges may be blocked by your repository settings.\n\n` +
2140
+ `Recommended: use \`/orch-integrate --pr\` to create a pull request instead.`,
2017
2141
  );
2142
+ }
2143
+ }
2018
2144
 
2019
- // ── Step 4: Scan for tmux sessions ──────────────────────
2020
- let allSessionNames: string[] = [];
2021
- try {
2022
- const tmuxOutput = execSync('tmux list-sessions -F "#{session_name}"', {
2023
- encoding: "utf-8",
2024
- timeout: 5000,
2025
- }).trim();
2026
- const all = tmuxOutput ? tmuxOutput.split("\n").map(s => s.trim()).filter(Boolean) : [];
2027
- allSessionNames = all.filter(name => name.startsWith(`${prefix}-`));
2028
- ctx.ui.notify(` Found ${allSessionNames.length} session(s) matching prefix "${prefix}-": ${allSessionNames.join(", ") || "(none)"}`, "info");
2029
- } catch {
2030
- ctx.ui.notify(" ⚠ Could not list tmux sessions (tmux not available?)", "warning");
2031
- }
2145
+ // Pre-integration summary
2146
+ const revListResult = runGit(
2147
+ ["rev-list", "--count", `${currentBranch}..${orchBranch}`],
2148
+ repoRoot,
2149
+ );
2150
+ const commitsAhead = revListResult.ok ? revListResult.stdout.trim() : "?";
2032
2151
 
2033
- // If no batch AND no sessions, nothing to abort
2034
- if (!hasActiveBatch && !persistedState && allSessionNames.length === 0) {
2035
- ctx.ui.notify(ORCH_MESSAGES.abortNoBatch(), "warning");
2036
- // Clean up signal file
2037
- try { unlinkSync(abortSignalFile); } catch {}
2038
- return;
2152
+ const diffStatResult = runGit(
2153
+ ["diff", "--stat", `${currentBranch}...${orchBranch}`],
2154
+ repoRoot,
2155
+ );
2156
+ const diffSummary = diffStatResult.ok ? diffStatResult.stdout.trim() : "(unable to compute diff)";
2157
+
2158
+ outputLines.push(
2159
+ `🔀 Integration Summary\n` +
2160
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
2161
+ ` Orch branch: ${orchBranch}\n` +
2162
+ ` Target: ${currentBranch}\n` +
2163
+ ` Commits: ${commitsAhead} ahead\n` +
2164
+ ` Mode: ${parsed.mode === "ff" ? "fast-forward" : parsed.mode === "merge" ? "merge commit" : "pull request"}\n` +
2165
+ (batchId ? ` Batch: ${batchId}\n` : "") +
2166
+ (parsed.force ? ` ⚠ Force: branch safety check skipped\n` : "") +
2167
+ `\n` +
2168
+ (diffSummary ? `${diffSummary}\n` : "") +
2169
+ `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
2170
+ );
2171
+
2172
+ // Execute integration
2173
+ const resolvedOrchBranch = (resolution as IntegrationContext).orchBranch;
2174
+ const wsConfig = execCtx!.workspaceConfig;
2175
+ const reposToIntegrate: { id: string; root: string }[] = [];
2176
+
2177
+ if (wsConfig) {
2178
+ for (const [repoId, repoConf] of wsConfig.repos) {
2179
+ const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${resolvedOrchBranch}`], repoConf.path);
2180
+ if (branchCheck.ok) {
2181
+ reposToIntegrate.push({ id: repoId, root: repoConf.path });
2039
2182
  }
2183
+ }
2184
+ } else {
2185
+ reposToIntegrate.push({ id: "(default)", root: repoRoot });
2186
+ }
2040
2187
 
2041
- const batchId = orchBatchState.batchId || persistedState?.batchId || "unknown";
2188
+ let totalCommits = 0;
2189
+ let allSucceeded = true;
2190
+ const repoMessages: string[] = [];
2042
2191
 
2043
- // ── Step 5: Kill sessions directly (fast path) ──────────
2044
- // For hard mode or when sessions are found, kill them immediately
2045
- // rather than waiting through the full executeAbort flow.
2046
- if (allSessionNames.length > 0) {
2047
- ctx.ui.notify(` Killing ${allSessionNames.length} tmux session(s)...`, "info");
2048
- let killed = 0;
2049
- for (const name of allSessionNames) {
2050
- try {
2051
- // Kill child sessions first (worker, reviewer)
2052
- execSync(`tmux kill-session -t "${name}-worker" 2>/dev/null`, { timeout: 3000 }).toString();
2053
- } catch {}
2054
- try {
2055
- execSync(`tmux kill-session -t "${name}-reviewer" 2>/dev/null`, { timeout: 3000 }).toString();
2056
- } catch {}
2057
- try {
2058
- execSync(`tmux kill-session -t "${name}" 2>/dev/null`, { timeout: 3000 }).toString();
2059
- killed++;
2060
- ctx.ui.notify(` ✓ Killed: ${name}`, "info");
2061
- } catch {
2062
- // Session may have already exited
2063
- ctx.ui.notify(` · ${name} (already exited)`, "info");
2064
- killed++;
2065
- }
2192
+ for (const repo of reposToIntegrate) {
2193
+ const preCountResult = runGit(["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], repo.root);
2194
+ const repoCommitsBefore = preCountResult.ok ? parseInt(preCountResult.stdout) || 0 : 0;
2195
+
2196
+ const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
2197
+ runGit: (gitArgs: string[]) => runGit(gitArgs, repo.root),
2198
+ runCommand: (cmd: string, cmdArgs: string[]) => {
2199
+ try {
2200
+ const stdout = execFileSync(cmd, cmdArgs, {
2201
+ encoding: "utf-8",
2202
+ timeout: 60_000,
2203
+ cwd: repo.root,
2204
+ stdio: ["pipe", "pipe", "pipe"],
2205
+ }).trim();
2206
+ return { ok: true, stdout, stderr: "" };
2207
+ } catch (err: unknown) {
2208
+ const e = err as { stdout?: string; stderr?: string; message?: string };
2209
+ return {
2210
+ ok: false,
2211
+ stdout: (e.stdout ?? "").toString().trim(),
2212
+ stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
2213
+ };
2066
2214
  }
2067
- ctx.ui.notify(` ✓ ${killed}/${allSessionNames.length} session(s) terminated`, "info");
2068
- } else {
2069
- ctx.ui.notify(" No tmux sessions to kill", "info");
2070
- }
2215
+ },
2216
+ deleteBatchState: () => { /* handled once after all repos */ },
2217
+ });
2071
2218
 
2072
- // ── Step 6: Clean up batch state ────────────────────────
2073
- // TP-041: Deactivate supervisor on abort
2074
- deactivateSupervisor(pi, supervisorState);
2219
+ if (!integrationResult.success) {
2220
+ return { message: `❌ Integration failed in ${repo.id}:\n${integrationResult.error}`, error: true };
2221
+ }
2075
2222
 
2076
- try {
2077
- orchBatchState.phase = "stopped";
2078
- orchBatchState.endedAt = Date.now();
2079
- updateOrchWidget();
2080
- ctx.ui.notify(" ✓ In-memory batch state set to 'stopped'", "info");
2081
- } catch (err) {
2082
- ctx.ui.notify(` ⚠ Failed to update in-memory state: ${err instanceof Error ? err.message : String(err)}`, "warning");
2083
- }
2223
+ totalCommits += repoCommitsBefore;
2224
+ repoMessages.push(` ${repo.id}: ${integrationResult.message}`);
2225
+ }
2084
2226
 
2085
- try {
2086
- deleteBatchState(stateRoot);
2087
- ctx.ui.notify(" ✓ Batch state file deleted (.pi/batch-state.json)", "info");
2088
- } catch (err) {
2089
- ctx.ui.notify(` ⚠ Failed to delete batch state file: ${err instanceof Error ? err.message : String(err)}`, "warning");
2227
+ // Post-integration cleanup & acceptance
2228
+ const allRepos: { id: string; root: string }[] = [];
2229
+ if (wsConfig) {
2230
+ for (const [repoId, repoConf] of wsConfig.repos) {
2231
+ allRepos.push({ id: repoId, root: repoConf.path });
2232
+ }
2233
+ } else {
2234
+ allRepos.push({ id: "(default)", root: repoRoot });
2235
+ }
2236
+
2237
+ const opId = resolveOperatorId(orchConfig);
2238
+ const orchPrefix = orchConfig.orchestrator.worktree_prefix;
2239
+
2240
+ for (const repo of allRepos) {
2241
+ dropBatchAutostash(repo.root, batchId);
2242
+ }
2243
+
2244
+ const branchCleanupLines: string[] = [];
2245
+ for (const repo of allRepos) {
2246
+ const branchCleanup = deleteStaleBranches(repo.root, opId, batchId);
2247
+ const totalDeleted = branchCleanup.deletedTaskBranches.length + branchCleanup.deletedSavedBranches.length;
2248
+ if (totalDeleted > 0 || branchCleanup.failedDeletes.length > 0) {
2249
+ const label = repo.id === "(default)" ? "" : ` (${repo.id})`;
2250
+ if (branchCleanup.deletedTaskBranches.length > 0) {
2251
+ branchCleanupLines.push(` 🗑️ Deleted ${branchCleanup.deletedTaskBranches.length} task branch(es)${label}`);
2252
+ }
2253
+ if (branchCleanup.deletedSavedBranches.length > 0) {
2254
+ branchCleanupLines.push(` 🗑️ Deleted ${branchCleanup.deletedSavedBranches.length} saved branch(es)${label}`);
2090
2255
  }
2256
+ if (branchCleanup.failedDeletes.length > 0) {
2257
+ branchCleanupLines.push(` ⚠️ Failed to delete ${branchCleanup.failedDeletes.length} branch(es)${label}: ${branchCleanup.failedDeletes.join(", ")}`);
2258
+ }
2259
+ }
2260
+ }
2261
+ if (branchCleanupLines.length > 0) {
2262
+ outputLines.push("Branch cleanup:\n" + branchCleanupLines.join("\n"));
2263
+ }
2091
2264
 
2092
- // ── Step 7: Clean up abort signal file ───────────────────
2093
- try { unlinkSync(abortSignalFile); } catch {}
2265
+ const skipOrchBranch = parsed.mode === "pr";
2266
+ const repoFindings: IntegrateCleanupRepoFindings[] = [];
2267
+ for (const repo of allRepos) {
2268
+ const findings = collectRepoCleanupFindings(
2269
+ repo.root, repo.id === "(default)" ? undefined : repo.id,
2270
+ opId, batchId, orchPrefix, resolvedOrchBranch, orchConfig,
2271
+ { skipOrchBranch },
2272
+ );
2273
+ repoFindings.push(findings);
2274
+ }
2094
2275
 
2095
- // ── Done ─────────────────────────────────────────────────
2096
- ctx.ui.notify(
2097
- `✅ Abort complete for batch ${batchId}. Sessions killed, state cleaned up.\n` +
2098
- ` Worktrees and branches are preserved for inspection.`,
2099
- "info",
2100
- );
2276
+ const cleanupResult = computeIntegrateCleanupResult(repoFindings);
2277
+ if (cleanupResult.notifyLevel === "warning") {
2278
+ hasWarning = true;
2279
+ }
2280
+
2281
+ try { deleteBatchState(repoRoot); } catch { /* best effort */ }
2282
+
2283
+ const integrationSummary = wsConfig
2284
+ ? `✅ Integrated ${resolvedOrchBranch} across ${reposToIntegrate.length} repo(s).\n${repoMessages.join("\n")}\n${totalCommits} total commit(s) applied.`
2285
+ : `${repoMessages[0] || "✅ Integrated."}\n${commitsAhead} commit(s) applied.`;
2286
+
2287
+ outputLines.push(integrationSummary + "\n" + cleanupResult.report);
2288
+
2289
+ // TP-043 R004: deferred batch summary
2290
+ if (supervisorState.active && supervisorState.pendingSummaryDeps) {
2291
+ const deps = supervisorState.pendingSummaryDeps;
2292
+ supervisorState.pendingSummaryDeps = null;
2293
+ if (supervisorState.batchStateRef && supervisorState.stateRoot) {
2294
+ presentBatchSummary(pi, supervisorState.batchStateRef, supervisorState.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults);
2295
+ }
2296
+ deactivateSupervisor(pi, supervisorState);
2297
+ }
2298
+
2299
+ return { message: outputLines.join("\n\n"), level: hasWarning ? "warning" : "info" };
2300
+ }
2301
+
2302
+ pi.registerCommand("orch-status", {
2303
+ description: "Show current batch progress",
2304
+ handler: async (_args, ctx) => {
2305
+ const result = doOrchStatus(ctx.cwd);
2306
+ ctx.ui.notify(result, "info");
2307
+ },
2308
+ });
2309
+
2310
+ pi.registerCommand("orch-pause", {
2311
+ description: "Pause batch after current tasks finish",
2312
+ handler: async (_args, ctx) => {
2313
+ const result = doOrchPause();
2314
+ // Determine notification level from result content
2315
+ const level = result.includes("No batch") || result.includes("already paused") ? "warning" : "info";
2316
+ ctx.ui.notify(result, level);
2317
+ },
2318
+ });
2319
+
2320
+ pi.registerCommand("orch-resume", {
2321
+ description: "Resume a paused or interrupted batch: /orch-resume [--force]",
2322
+ handler: async (args, ctx) => {
2323
+ if (!requireExecCtx(ctx)) return;
2324
+
2325
+ // Parse arguments
2326
+ const parsed = parseResumeArgs(args);
2327
+ if ("error" in parsed) {
2328
+ ctx.ui.notify(`❌ ${parsed.error}`, "error");
2329
+ return;
2330
+ }
2331
+
2332
+ const result = doOrchResume(parsed.force, ctx);
2333
+ ctx.ui.notify(result.message, result.error ? "warning" : "info");
2334
+ },
2335
+ });
2336
+
2337
+ pi.registerCommand("orch-abort", {
2338
+ description: "Abort batch: /orch-abort [--hard]",
2339
+ handler: async (args, ctx) => {
2340
+ try {
2341
+ const hard = args?.trim() === "--hard";
2342
+ const result = doOrchAbort(hard, ctx);
2343
+ ctx.ui.notify(result, "info");
2101
2344
  } catch (err) {
2102
2345
  // Top-level catch: ensure the user ALWAYS sees something
2103
2346
  ctx.ui.notify(
@@ -2354,238 +2597,177 @@ export default function (pi: ExtensionAPI) {
2354
2597
 
2355
2598
  if (!requireExecCtx(ctx)) return;
2356
2599
 
2357
- // Parse arguments
2358
- const parsed = parseIntegrateArgs(args);
2359
- if ("error" in parsed) {
2360
- ctx.ui.notify(`❌ ${parsed.error}\n\nRun /orch-integrate --help for usage.`, "error");
2361
- return;
2362
- }
2363
-
2364
- // ── Step 2: Resolve integration context ──────────────────
2365
- const { repoRoot } = execCtx!;
2366
- const resolution = resolveIntegrationContext(parsed, {
2367
- loadBatchState: () => loadBatchState(repoRoot),
2368
- getCurrentBranch: () => getCurrentBranch(repoRoot),
2369
- listOrchBranches: () => {
2370
- const result = runGit(["branch", "--list", "orch/*"], repoRoot);
2371
- return result.ok
2372
- ? result.stdout.split("\n").map(b => b.replace(/^\*?\s+/, "").trim()).filter(Boolean)
2373
- : [];
2374
- },
2375
- orchBranchExists: (branch: string) => {
2376
- return runGit(["rev-parse", "--verify", `refs/heads/${branch}`], repoRoot).ok;
2377
- },
2378
- });
2379
-
2380
- if ("error" in resolution) {
2381
- const severity = (resolution as IntegrationContextError).severity;
2382
- ctx.ui.notify(resolution.error, severity === "info" ? "info" : "error");
2383
- return;
2384
- }
2385
-
2386
- const { orchBranch, baseBranch, batchId, currentBranch, notices } = resolution as IntegrationContext;
2387
-
2388
- // Show any notices from resolution (auto-detection messages, warnings)
2389
- for (const notice of notices) {
2390
- ctx.ui.notify(notice, "info");
2391
- }
2392
-
2393
- // ── Step 2a: Branch protection pre-check (TP-052) ───────
2394
- // When using ff or merge mode (direct push), check if the target
2395
- // branch has protection rules. If protected, warn and suggest --pr.
2396
- // Graceful degradation: if gh is unavailable, skip the check.
2397
- if (parsed.mode !== "pr") {
2398
- const { detectBranchProtection } = await import("./supervisor.ts");
2399
- const protectionStatus = detectBranchProtection(baseBranch, repoRoot);
2400
- if (protectionStatus === "protected") {
2401
- ctx.ui.notify(
2402
- `⚠️ Branch \`${baseBranch}\` has branch protection rules enabled.\n` +
2403
- `Direct merges may be blocked by your repository settings.\n\n` +
2404
- `Recommended: use \`/orch-integrate --pr\` to create a pull request instead.`,
2405
- "warning",
2406
- );
2407
- // Don't block — proceed with the attempt. The merge will fail
2408
- // gracefully and show a clear error if protection blocks it.
2409
- }
2410
- }
2411
-
2412
- // ── Step 2b: Pre-integration summary ─────────────────────
2413
- // Count commits ahead
2414
- const revListResult = runGit(
2415
- ["rev-list", "--count", `${currentBranch}..${orchBranch}`],
2416
- repoRoot,
2417
- );
2418
- const commitsAhead = revListResult.ok ? revListResult.stdout.trim() : "?";
2419
-
2420
- // Get diff summary
2421
- const diffStatResult = runGit(
2422
- ["diff", "--stat", `${currentBranch}...${orchBranch}`],
2423
- repoRoot,
2424
- );
2425
- const diffSummary = diffStatResult.ok ? diffStatResult.stdout.trim() : "(unable to compute diff)";
2426
-
2427
- ctx.ui.notify(
2428
- `🔀 Integration Summary\n` +
2429
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n` +
2430
- ` Orch branch: ${orchBranch}\n` +
2431
- ` Target: ${currentBranch}\n` +
2432
- ` Commits: ${commitsAhead} ahead\n` +
2433
- ` Mode: ${parsed.mode === "ff" ? "fast-forward" : parsed.mode === "merge" ? "merge commit" : "pull request"}\n` +
2434
- (batchId ? ` Batch: ${batchId}\n` : "") +
2435
- (parsed.force ? ` ⚠ Force: branch safety check skipped\n` : "") +
2436
- `\n` +
2437
- (diffSummary ? `${diffSummary}\n` : "") +
2438
- `━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`,
2439
- "info",
2440
- );
2441
-
2442
- // ── Step 3: Execute integration mode ─────────────────
2443
- // In workspace mode, integrate in every repo that has the orch branch.
2444
- const resolvedOrchBranch = (resolution as IntegrationContext).orchBranch;
2445
- const wsConfig = execCtx!.workspaceConfig;
2446
- const reposToIntegrate: { id: string; root: string }[] = [];
2447
-
2448
- if (wsConfig) {
2449
- for (const [repoId, repoConf] of wsConfig.repos) {
2450
- // Check if orch branch exists in this repo
2451
- const branchCheck = runGit(["rev-parse", "--verify", `refs/heads/${resolvedOrchBranch}`], repoConf.path);
2452
- if (branchCheck.ok) {
2453
- reposToIntegrate.push({ id: repoId, root: repoConf.path });
2454
- }
2455
- }
2456
- } else {
2457
- reposToIntegrate.push({ id: "(default)", root: repoRoot });
2458
- }
2459
-
2460
- let totalCommits = 0;
2461
- let allSucceeded = true;
2462
- const repoMessages: string[] = [];
2463
-
2464
- for (const repo of reposToIntegrate) {
2465
- // Count commits BEFORE integration (after ff, HEAD === orch tip so count would be 0)
2466
- const preCountResult = runGit(["rev-list", "--count", `HEAD..${resolvedOrchBranch}`], repo.root);
2467
- const repoCommitsBefore = preCountResult.ok ? parseInt(preCountResult.stdout) || 0 : 0;
2468
-
2469
- const integrationResult = executeIntegration(parsed.mode, resolution as IntegrationContext, {
2470
- runGit: (gitArgs: string[]) => runGit(gitArgs, repo.root),
2471
- runCommand: (cmd: string, cmdArgs: string[]) => {
2472
- try {
2473
- const stdout = execFileSync(cmd, cmdArgs, {
2474
- encoding: "utf-8",
2475
- timeout: 60_000,
2476
- cwd: repo.root,
2477
- stdio: ["pipe", "pipe", "pipe"],
2478
- }).trim();
2479
- return { ok: true, stdout, stderr: "" };
2480
- } catch (err: unknown) {
2481
- const e = err as { stdout?: string; stderr?: string; message?: string };
2482
- return {
2483
- ok: false,
2484
- stdout: (e.stdout ?? "").toString().trim(),
2485
- stderr: (e.stderr ?? e.message ?? "unknown error").toString().trim(),
2486
- };
2487
- }
2488
- },
2489
- deleteBatchState: () => { /* handled once after all repos */ },
2490
- });
2491
-
2492
- if (!integrationResult.success) {
2493
- ctx.ui.notify(`❌ Integration failed in ${repo.id}:\n${integrationResult.error}`, "error");
2494
- allSucceeded = false;
2495
- break;
2496
- }
2497
-
2498
- totalCommits += repoCommitsBefore;
2499
- repoMessages.push(` ${repo.id}: ${integrationResult.message}`);
2500
- }
2501
-
2502
- if (!allSucceeded) return;
2503
-
2504
- // ── Step 4: Post-integration cleanup & acceptance ────────
2505
- // Run acceptance checks BEFORE deleting batch state so recovery
2506
- // context is still available if something goes wrong.
2600
+ const result = await doOrchIntegrate(args, ctx);
2601
+ ctx.ui.notify(result.message, result.error ? "error" : (result.level ?? "info"));
2602
+ },
2603
+ });
2507
2604
 
2508
- // Resolve all repos to verify (all workspace repos, not just those
2509
- // that had the orch branch — roadmap 2d requires "any workspace repo").
2510
- const allRepos: { id: string; root: string }[] = [];
2511
- if (wsConfig) {
2512
- for (const [repoId, repoConf] of wsConfig.repos) {
2513
- allRepos.push({ id: repoId, root: repoConf.path });
2514
- }
2515
- } else {
2516
- allRepos.push({ id: "(default)", root: repoRoot });
2605
+ // ── TP-053: Register orchestrator tools for supervisor agent ─────
2606
+
2607
+ pi.registerTool({
2608
+ name: "orch_status",
2609
+ label: "Orchestrator Status",
2610
+ description:
2611
+ "Check the current batch status. Returns batch phase, wave progress, " +
2612
+ "task counts, and elapsed time. Works even when no batch is running.",
2613
+ promptSnippet: "orch_status() check current batch status",
2614
+ promptGuidelines: [
2615
+ "Call orch_status to get a snapshot of the current batch.",
2616
+ "Use this when the operator asks 'how is the batch going?' or you need to check progress.",
2617
+ "If no batch is running, the result will say so.",
2618
+ ],
2619
+ parameters: Type.Object({}),
2620
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
2621
+ try {
2622
+ const result = doOrchStatus(ctx.cwd);
2623
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
2624
+ } catch (err) {
2625
+ return {
2626
+ content: [{ type: "text" as const, text: `Error checking status: ${err instanceof Error ? err.message : String(err)}` }],
2627
+ details: undefined,
2628
+ };
2517
2629
  }
2630
+ },
2631
+ });
2518
2632
 
2519
- const opId = resolveOperatorId(orchConfig);
2520
- const orchPrefix = orchConfig.orchestrator.worktree_prefix;
2521
-
2522
- // Drop batch-scoped autostash entries from all repos.
2523
- // Patterns: "orch-integrate-autostash-{batchId}" (from extension.ts)
2524
- // "merge-agent-autostash-w*-{batchId}" (from merge.ts)
2525
- for (const repo of allRepos) {
2526
- dropBatchAutostash(repo.root, batchId);
2633
+ pi.registerTool({
2634
+ name: "orch_pause",
2635
+ label: "Pause Batch",
2636
+ description:
2637
+ "Pause the running batch after current tasks finish. " +
2638
+ "Tasks already in progress will complete, but no new tasks will start.",
2639
+ promptSnippet: "orch_pause() pause the running batch",
2640
+ promptGuidelines: [
2641
+ "Call orch_pause to pause a running batch gracefully.",
2642
+ "Current tasks will finish, but no new tasks will be launched.",
2643
+ "Use this when you need to investigate an issue before more tasks run.",
2644
+ "After pausing, use orch_resume to continue.",
2645
+ ],
2646
+ parameters: Type.Object({}),
2647
+ async execute(_toolCallId, _params, _signal, _onUpdate, _ctx) {
2648
+ try {
2649
+ const result = doOrchPause();
2650
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
2651
+ } catch (err) {
2652
+ return {
2653
+ content: [{ type: "text" as const, text: `Error pausing batch: ${err instanceof Error ? err.message : String(err)}` }],
2654
+ details: undefined,
2655
+ };
2527
2656
  }
2657
+ },
2658
+ });
2528
2659
 
2529
- // TP-051: Delete stale task/* and saved/task/* branches from all repos.
2530
- // These accumulate after each batch and clutter `git branch` output.
2531
- // Deletes both current-batch branches and orphans from previous batches.
2532
- const branchCleanupLines: string[] = [];
2533
- for (const repo of allRepos) {
2534
- const branchCleanup = deleteStaleBranches(repo.root, opId, batchId);
2535
- const totalDeleted = branchCleanup.deletedTaskBranches.length + branchCleanup.deletedSavedBranches.length;
2536
- if (totalDeleted > 0 || branchCleanup.failedDeletes.length > 0) {
2537
- const label = repo.id === "(default)" ? "" : ` (${repo.id})`;
2538
- if (branchCleanup.deletedTaskBranches.length > 0) {
2539
- branchCleanupLines.push(` 🗑️ Deleted ${branchCleanup.deletedTaskBranches.length} task branch(es)${label}`);
2540
- }
2541
- if (branchCleanup.deletedSavedBranches.length > 0) {
2542
- branchCleanupLines.push(` 🗑️ Deleted ${branchCleanup.deletedSavedBranches.length} saved branch(es)${label}`);
2543
- }
2544
- if (branchCleanup.failedDeletes.length > 0) {
2545
- branchCleanupLines.push(` ⚠️ Failed to delete ${branchCleanup.failedDeletes.length} branch(es)${label}: ${branchCleanup.failedDeletes.join(", ")}`);
2546
- }
2547
- }
2548
- }
2549
- if (branchCleanupLines.length > 0) {
2550
- ctx.ui.notify("Branch cleanup:\n" + branchCleanupLines.join("\n"), "info");
2660
+ pi.registerTool({
2661
+ name: "orch_resume",
2662
+ label: "Resume Batch",
2663
+ description:
2664
+ "Resume a paused or interrupted batch. " +
2665
+ "The batch will continue from where it left off. " +
2666
+ "Use force=true to resume from a stopped or failed state.",
2667
+ promptSnippet: "orch_resume(force?) resume a paused batch",
2668
+ promptGuidelines: [
2669
+ "Call orch_resume to continue a paused or interrupted batch.",
2670
+ "Set force=true to resume from a stopped or failed state (runs pre-resume diagnostics).",
2671
+ "Cannot resume if a batch is already actively running (launching, executing, merging, planning).",
2672
+ "The resume happens asynchronously — the tool returns immediately with a status message.",
2673
+ ],
2674
+ parameters: Type.Object({
2675
+ force: Type.Optional(Type.Boolean({
2676
+ description: "Resume from stopped or failed state (default: false)",
2677
+ })),
2678
+ }),
2679
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2680
+ try {
2681
+ const result = doOrchResume(params.force ?? false, ctx);
2682
+ return { content: [{ type: "text" as const, text: result.message }], details: undefined };
2683
+ } catch (err) {
2684
+ return {
2685
+ content: [{ type: "text" as const, text: `Error resuming batch: ${err instanceof Error ? err.message : String(err)}` }],
2686
+ details: undefined,
2687
+ };
2551
2688
  }
2689
+ },
2690
+ });
2552
2691
 
2553
- // Run acceptance checks across all workspace repos.
2554
- // In PR mode, the orch branch is intentionally preserved for the PR,
2555
- // so we skip orch branch detection to avoid contradictory output.
2556
- const skipOrchBranch = parsed.mode === "pr";
2557
- const repoFindings: IntegrateCleanupRepoFindings[] = [];
2558
- for (const repo of allRepos) {
2559
- const findings = collectRepoCleanupFindings(
2560
- repo.root, repo.id === "(default)" ? undefined : repo.id,
2561
- opId, batchId, orchPrefix, resolvedOrchBranch, orchConfig,
2562
- { skipOrchBranch },
2563
- );
2564
- repoFindings.push(findings);
2692
+ pi.registerTool({
2693
+ name: "orch_abort",
2694
+ label: "Abort Batch",
2695
+ description:
2696
+ "Abort the running batch. Kills tmux sessions, cleans up state. " +
2697
+ "Use hard=true for immediate kill (no grace period). " +
2698
+ "Works even without execution context (safety-critical).",
2699
+ promptSnippet: "orch_abort(hard?) abort the running batch",
2700
+ promptGuidelines: [
2701
+ "Call orch_abort to stop a running batch.",
2702
+ "Default (hard=false) is graceful abort — writes signal file and kills sessions.",
2703
+ "Set hard=true for immediate termination without grace period.",
2704
+ "Use this when a batch is stuck, failing repeatedly, or the operator requests it.",
2705
+ "Worktrees and branches are preserved for inspection after abort.",
2706
+ ],
2707
+ parameters: Type.Object({
2708
+ hard: Type.Optional(Type.Boolean({
2709
+ description: "Hard abort — immediate kill without grace period (default: false)",
2710
+ })),
2711
+ }),
2712
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2713
+ try {
2714
+ const result = doOrchAbort(params.hard ?? false, ctx);
2715
+ return { content: [{ type: "text" as const, text: result }], details: undefined };
2716
+ } catch (err) {
2717
+ return {
2718
+ content: [{ type: "text" as const, text: `Error aborting batch: ${err instanceof Error ? err.message : String(err)}` }],
2719
+ details: undefined,
2720
+ };
2565
2721
  }
2722
+ },
2723
+ });
2566
2724
 
2567
- const cleanupResult = computeIntegrateCleanupResult(repoFindings);
2568
-
2569
- // NOW delete batch state (acceptance checks are done)
2570
- try { deleteBatchState(repoRoot); } catch { /* best effort */ }
2571
-
2572
- const integrationSummary = wsConfig
2573
- ? `✅ Integrated ${resolvedOrchBranch} across ${reposToIntegrate.length} repo(s).\n${repoMessages.join("\n")}\n${totalCommits} total commit(s) applied.`
2574
- : `${repoMessages[0] || "✅ Integrated."}\n${commitsAhead} commit(s) applied.`;
2575
-
2576
- const summary = integrationSummary + "\n" + cleanupResult.report;
2577
-
2578
- ctx.ui.notify(summary, cleanupResult.notifyLevel);
2579
-
2580
- // TP-043 R004: If supervisor has a deferred batch summary (supervised mode),
2581
- // present it now that integration is complete, then deactivate.
2582
- if (supervisorState.active && supervisorState.pendingSummaryDeps) {
2583
- const deps = supervisorState.pendingSummaryDeps;
2584
- supervisorState.pendingSummaryDeps = null;
2585
- if (supervisorState.batchStateRef && supervisorState.stateRoot) {
2586
- presentBatchSummary(pi, supervisorState.batchStateRef, supervisorState.stateRoot, deps.opId, deps.diagnostics, deps.mergeResults);
2587
- }
2588
- deactivateSupervisor(pi, supervisorState);
2725
+ pi.registerTool({
2726
+ name: "orch_integrate",
2727
+ label: "Integrate Batch",
2728
+ description:
2729
+ "Integrate a completed orch batch into the working branch. " +
2730
+ "Supports fast-forward (default), merge commit, or pull request modes.",
2731
+ promptSnippet: "orch_integrate(mode?, force?, branch?) integrate completed batch",
2732
+ promptGuidelines: [
2733
+ "Call orch_integrate after a batch completes to merge changes into the working branch.",
2734
+ "mode='fast-forward' (default) cleanest history, requires linear history.",
2735
+ "mode='merge' — creates a merge commit.",
2736
+ "mode='pr' — pushes orch branch and creates a pull request (safest for protected branches).",
2737
+ "Set force=true to skip branch safety checks.",
2738
+ "The branch parameter is optional auto-detected from batch state if omitted.",
2739
+ "If the target branch has protection rules, prefer mode='pr'.",
2740
+ ],
2741
+ parameters: Type.Object({
2742
+ mode: Type.Optional(Type.Union(
2743
+ [Type.Literal("fast-forward"), Type.Literal("merge"), Type.Literal("pr")],
2744
+ { description: 'Integration mode (default: "fast-forward")' },
2745
+ )),
2746
+ force: Type.Optional(Type.Boolean({
2747
+ description: "Skip branch safety check (default: false)",
2748
+ })),
2749
+ branch: Type.Optional(Type.String({
2750
+ description: "Orch branch name (auto-detected from batch state if omitted)",
2751
+ })),
2752
+ }),
2753
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2754
+ try {
2755
+ // Build args string from tool parameters to pass to doOrchIntegrate
2756
+ const argParts: string[] = [];
2757
+ if (params.branch) argParts.push(params.branch);
2758
+ const mode = params.mode ?? "fast-forward";
2759
+ if (mode === "merge") argParts.push("--merge");
2760
+ else if (mode === "pr") argParts.push("--pr");
2761
+ if (params.force) argParts.push("--force");
2762
+
2763
+ const argsStr = argParts.length > 0 ? argParts.join(" ") : undefined;
2764
+ const result = await doOrchIntegrate(argsStr, ctx);
2765
+ return { content: [{ type: "text" as const, text: result.message }], details: undefined };
2766
+ } catch (err) {
2767
+ return {
2768
+ content: [{ type: "text" as const, text: `Error integrating batch: ${err instanceof Error ? err.message : String(err)}` }],
2769
+ details: undefined,
2770
+ };
2589
2771
  }
2590
2772
  },
2591
2773
  });