taskplane 0.1.15 → 0.1.16

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,22 +1,23 @@
1
- /**
2
- * Task Orchestrator — barrel re-export
3
- *
4
- * Provides a single import point for all orchestrator modules.
5
- * Usage: import { executeOrchBatch, ... } from "./taskplane/index.ts";
6
- */
7
-
8
- export * from "./types.ts";
9
- export * from "./config.ts";
10
- export * from "./git.ts";
11
- export * from "./worktree.ts";
12
- export * from "./discovery.ts";
13
- export * from "./waves.ts";
14
- export * from "./formatting.ts";
15
- export * from "./execution.ts";
16
- export * from "./merge.ts";
17
- export * from "./messages.ts";
18
- export * from "./sessions.ts";
19
- export * from "./persistence.ts";
20
- export * from "./engine.ts";
21
- export * from "./resume.ts";
22
- export * from "./abort.ts";
1
+ /**
2
+ * Task Orchestrator — barrel re-export
3
+ *
4
+ * Provides a single import point for all orchestrator modules.
5
+ * Usage: import { executeOrchBatch, ... } from "./taskplane/index.ts";
6
+ */
7
+
8
+ export * from "./types.ts";
9
+ export * from "./config.ts";
10
+ export * from "./git.ts";
11
+ export * from "./worktree.ts";
12
+ export * from "./discovery.ts";
13
+ export * from "./waves.ts";
14
+ export * from "./formatting.ts";
15
+ export * from "./execution.ts";
16
+ export * from "./merge.ts";
17
+ export * from "./messages.ts";
18
+ export * from "./sessions.ts";
19
+ export * from "./persistence.ts";
20
+ export * from "./engine.ts";
21
+ export * from "./resume.ts";
22
+ export * from "./abort.ts";
23
+ export * from "./workspace.ts";
@@ -1,134 +1,146 @@
1
- /**
2
- * User-facing message templates (ORCH_MESSAGES)
3
- * @module orch/messages
4
- */
5
- import type { AbortMode } from "./types.ts";
6
-
7
- // ── Message Templates ────────────────────────────────────────────────
8
-
9
- /**
10
- * Deterministic message templates for user-facing /orch commands.
11
- * Ensures consistent UX across invocations.
12
- */
13
- export const ORCH_MESSAGES = {
14
- // /orch
15
- orchStarting: (batchId: string, waves: number, tasks: number) =>
16
- `🚀 Starting batch ${batchId}: ${waves} wave(s), ${tasks} task(s)`,
17
- orchWaveStart: (waveNum: number, totalWaves: number, tasks: number, lanes: number) =>
18
- `\n🌊 Wave ${waveNum}/${totalWaves}: ${tasks} task(s) across ${lanes} lane(s)`,
19
- orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) =>
20
- `✅ Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
21
- orchMergeStart: (waveNum: number, laneCount: number) =>
22
- `🔀 [Wave ${waveNum}] Merging ${laneCount} lane(s) into develop...`,
23
- orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
24
- ` ✅ Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
25
- orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
26
- ` ⚡ Lane ${laneNum} merged with ${conflictCount} auto-resolved conflict(s) (${durationSec}s)`,
27
- orchMergeLaneFailed: (laneNum: number, reason: string) =>
28
- ` ❌ Lane ${laneNum} merge failed: ${reason}`,
29
- orchMergeComplete: (waveNum: number, mergedCount: number, totalSec: number) =>
30
- `🔀 [Wave ${waveNum}] Merge complete: ${mergedCount} lane(s) merged (${totalSec}s)`,
31
- orchMergeFailed: (waveNum: number, laneNum: number, reason: string) =>
32
- `❌ [Wave ${waveNum}] Merge failed at lane ${laneNum}: ${reason}`,
33
- orchMergeSkipped: (waveNum: number) =>
34
- `📝 [Wave ${waveNum}] No successful lanes to merge`,
35
- orchMergePlaceholder: (waveNum: number) =>
36
- `🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`,
37
- orchWorktreeReset: (waveNum: number, lanes: number) =>
38
- `🔄 Resetting ${lanes} worktree(s) to develop HEAD after wave ${waveNum}`,
39
- orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
40
- `\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`,
41
- orchBatchFailed: (batchId: string, reason: string) =>
42
- `\n❌ Batch ${batchId} failed: ${reason}`,
43
- orchBatchStopped: (batchId: string, policy: string) =>
44
- `\n⛔ Batch ${batchId} stopped by ${policy} policy`,
45
-
46
- // /orch-pause
47
- pauseNoBatch: () => "No active batch is running. Use /orch <areas|all> to start.",
48
- pauseAlreadyPaused: (batchId: string) => `Batch ${batchId} is already paused.`,
49
- pauseActivated: (batchId: string) =>
50
- `⏸️ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
51
-
52
- // /orch-sessions
53
- sessionsNone: () => "No orchestrator TMUX sessions found.",
54
- sessionsHeader: (count: number) => `🖥️ ${count} orchestrator session(s):`,
55
-
56
- // /orch orphan detection
57
- orphanDetectionResume: (batchId: string, sessionCount: number) =>
58
- `🔄 Found ${sessionCount} running orchestrator session(s) from batch ${batchId}.\n` +
59
- ` Use /orch-resume to continue, or /orch-abort to clean up.`,
60
- orphanDetectionAbort: (sessionCount: number) =>
61
- `⚠️ Found ${sessionCount} orphan orchestrator session(s) without usable state.\n` +
62
- ` Use /orch-abort to clean up before starting a new batch.`,
63
- orphanDetectionCleanup: () =>
64
- `🧹 Cleaned up stale batch state file. Starting fresh.`,
65
-
66
- // /orch-resume
67
- resumeStarting: (batchId: string, phase: string) =>
68
- `🔄 Resuming batch ${batchId} (was: ${phase})...`,
69
- resumeReconciled: (batchId: string, completed: number, pending: number, failed: number, reconnecting: number, reExecuting: number = 0) =>
70
- `📊 Batch ${batchId} reconciliation: ${completed} completed, ${pending} pending, ${failed} failed, ${reconnecting} reconnecting` +
71
- (reExecuting > 0 ? `, ${reExecuting} re-executing` : ""),
72
- resumeSkippedWaves: (skippedCount: number) =>
73
- `⏭️ Skipping ${skippedCount} completed wave(s)`,
74
- resumeReconnecting: (sessionCount: number) =>
75
- `🔗 Reconnecting to ${sessionCount} alive session(s)...`,
76
- resumeNoState: () =>
77
- `❌ No batch to resume. No batch-state.json file found.\n` +
78
- ` Use /orch <areas|all> to start a new batch.`,
79
- resumeInvalidState: (error: string) =>
80
- `❌ Cannot resume: batch state file is invalid.\n` +
81
- ` Error: ${error}\n` +
82
- ` Delete .pi/batch-state.json and start a new batch.`,
83
- resumePhaseNotResumable: (batchId: string, phase: string, reason: string) =>
84
- `❌ Cannot resume batch ${batchId} (phase: ${phase}).\n` +
85
- ` ${reason}`,
86
- resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
87
- `\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
88
-
89
- // /orch-abort
90
- abortGracefulStarting: (batchId: string, sessionCount: number) =>
91
- `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
92
- abortGracefulWaiting: (batchId: string, graceSec: number) =>
93
- `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`,
94
- abortGracefulForceKill: (count: number) =>
95
- `⚠️ Force-killing ${count} session(s) that did not exit within timeout`,
96
- abortGracefulComplete: (batchId: string, graceful: number, forceKilled: number, durationSec: number) =>
97
- `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`,
98
- abortHardStarting: (batchId: string, sessionCount: number) =>
99
- `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`,
100
- abortHardComplete: (batchId: string, killed: number, durationSec: number) =>
101
- `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`,
102
- abortPartialFailure: (failureCount: number) =>
103
- `⚠️ ${failureCount} error(s) during abort (see details above)`,
104
- abortNoBatch: () =>
105
- `No active batch to abort. Use /orch <areas|all> to start a batch.`,
106
- abortComplete: (mode: AbortMode, sessionsKilled: number) =>
107
- `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
108
- } as const;
109
-
110
-
111
- // ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
112
-
113
- // Note: These are added via extension to the ORCH_MESSAGES object below.
114
-
115
- // ── Resume Orchestration ─────────────────────────────────────────────
116
-
117
- /**
118
- * Resume an interrupted batch from persisted state.
119
- *
120
- * Flow:
121
- * 1. Load and validate batch-state.json
122
- * 2. Check phase eligibility (paused/executing/merging only)
123
- * 3. Check for alive TMUX sessions and .DONE files
124
- * 4. Reconcile persisted state against live signals
125
- * 5. Compute resume point (which wave to start from)
126
- * 6. Reconstruct runtime state and continue execution
127
- *
128
- * @param orchConfig - Orchestrator configuration
129
- * @param runnerConfig - Task runner configuration
130
- * @param cwd - Repository root
131
- * @param batchState - Mutable batch state (will be populated from persisted state)
132
- * @param onNotify - Callback for user-facing messages
133
- * @param onMonitorUpdate - Optional callback for dashboard updates
134
- */
1
+ /**
2
+ * User-facing message templates (ORCH_MESSAGES)
3
+ * @module orch/messages
4
+ */
5
+ import type { AbortMode } from "./types.ts";
6
+
7
+ // ── Message Templates ────────────────────────────────────────────────
8
+
9
+ /**
10
+ * Deterministic message templates for user-facing /orch commands.
11
+ * Ensures consistent UX across invocations.
12
+ */
13
+ export const ORCH_MESSAGES = {
14
+ // /orch
15
+ orchStarting: (batchId: string, waves: number, tasks: number) =>
16
+ `🚀 Starting batch ${batchId}: ${waves} wave(s), ${tasks} task(s)`,
17
+ orchWaveStart: (waveNum: number, totalWaves: number, tasks: number, lanes: number) =>
18
+ `\n🌊 Wave ${waveNum}/${totalWaves}: ${tasks} task(s) across ${lanes} lane(s)`,
19
+ orchWaveComplete: (waveNum: number, succeeded: number, failed: number, skipped: number, elapsedSec: number) =>
20
+ `✅ Wave ${waveNum} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped (${elapsedSec}s)`,
21
+ orchMergeStart: (waveNum: number, laneCount: number) =>
22
+ `🔀 [Wave ${waveNum}] Merging ${laneCount} lane(s) into develop...`,
23
+ orchMergeLaneSuccess: (laneNum: number, commit: string, durationSec: number) =>
24
+ ` ✅ Lane ${laneNum} merged (${commit.slice(0, 8)}, ${durationSec}s)`,
25
+ orchMergeLaneConflictResolved: (laneNum: number, conflictCount: number, durationSec: number) =>
26
+ ` ⚡ Lane ${laneNum} merged with ${conflictCount} auto-resolved conflict(s) (${durationSec}s)`,
27
+ orchMergeLaneFailed: (laneNum: number, reason: string) =>
28
+ ` ❌ Lane ${laneNum} merge failed: ${reason}`,
29
+ orchMergeComplete: (waveNum: number, mergedCount: number, totalSec: number) =>
30
+ `🔀 [Wave ${waveNum}] Merge complete: ${mergedCount} lane(s) merged (${totalSec}s)`,
31
+ orchMergeFailed: (waveNum: number, laneNum: number, reason: string) =>
32
+ `❌ [Wave ${waveNum}] Merge failed at lane ${laneNum}: ${reason}`,
33
+ orchMergeSkipped: (waveNum: number) =>
34
+ `📝 [Wave ${waveNum}] No successful lanes to merge`,
35
+ orchMergePlaceholder: (waveNum: number) =>
36
+ `🔀 [Wave ${waveNum}] Merge: placeholder — Step 3 (TS-008) will replace with mergeWave()`,
37
+ orchWorktreeReset: (waveNum: number, lanes: number) =>
38
+ `🔄 Resetting ${lanes} worktree(s) to develop HEAD after wave ${waveNum}`,
39
+ orchBatchComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) => {
40
+ const lines = [`\n🏁 Batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s)`];
41
+ if (failed > 0 || blocked > 0) {
42
+ lines.push("");
43
+ if (blocked > 0) {
44
+ lines.push(` ${blocked} task(s) were blocked because upstream tasks failed.`);
45
+ }
46
+ lines.push(" Next steps:");
47
+ lines.push(" /orch-status — review what failed and why");
48
+ lines.push(" • /orch-resume — retry from the failed wave");
49
+ lines.push(" • /orch-abort — clean up and start fresh");
50
+ }
51
+ return lines.join("\n");
52
+ },
53
+ orchBatchFailed: (batchId: string, reason: string) =>
54
+ `\n❌ Batch ${batchId} failed: ${reason}`,
55
+ orchBatchStopped: (batchId: string, policy: string) =>
56
+ `\n⛔ Batch ${batchId} stopped by ${policy} policy`,
57
+
58
+ // /orch-pause
59
+ pauseNoBatch: () => "No active batch is running. Use /orch <areas|all> to start.",
60
+ pauseAlreadyPaused: (batchId: string) => `Batch ${batchId} is already paused.`,
61
+ pauseActivated: (batchId: string) =>
62
+ `⏸️ Pausing batch ${batchId}... lanes will stop after their current tasks complete.`,
63
+
64
+ // /orch-sessions
65
+ sessionsNone: () => "No orchestrator TMUX sessions found.",
66
+ sessionsHeader: (count: number) => `🖥️ ${count} orchestrator session(s):`,
67
+
68
+ // /orch orphan detection
69
+ orphanDetectionResume: (batchId: string, sessionCount: number) =>
70
+ `🔄 Found ${sessionCount} running orchestrator session(s) from batch ${batchId}.\n` +
71
+ ` Use /orch-resume to continue, or /orch-abort to clean up.`,
72
+ orphanDetectionAbort: (sessionCount: number) =>
73
+ `⚠️ Found ${sessionCount} orphan orchestrator session(s) without usable state.\n` +
74
+ ` Use /orch-abort to clean up before starting a new batch.`,
75
+ orphanDetectionCleanup: () =>
76
+ `🧹 Cleaned up stale batch state file. Starting fresh.`,
77
+
78
+ // /orch-resume
79
+ resumeStarting: (batchId: string, phase: string) =>
80
+ `🔄 Resuming batch ${batchId} (was: ${phase})...`,
81
+ resumeReconciled: (batchId: string, completed: number, pending: number, failed: number, reconnecting: number, reExecuting: number = 0) =>
82
+ `📊 Batch ${batchId} reconciliation: ${completed} completed, ${pending} pending, ${failed} failed, ${reconnecting} reconnecting` +
83
+ (reExecuting > 0 ? `, ${reExecuting} re-executing` : ""),
84
+ resumeSkippedWaves: (skippedCount: number) =>
85
+ `⏭️ Skipping ${skippedCount} completed wave(s)`,
86
+ resumeReconnecting: (sessionCount: number) =>
87
+ `🔗 Reconnecting to ${sessionCount} alive session(s)...`,
88
+ resumeNoState: () =>
89
+ `❌ No batch to resume. No batch-state.json file found.\n` +
90
+ ` Use /orch <areas|all> to start a new batch.`,
91
+ resumeInvalidState: (error: string) =>
92
+ `❌ Cannot resume: batch state file is invalid.\n` +
93
+ ` Error: ${error}\n` +
94
+ ` Delete .pi/batch-state.json and start a new batch.`,
95
+ resumePhaseNotResumable: (batchId: string, phase: string, reason: string) =>
96
+ `❌ Cannot resume batch ${batchId} (phase: ${phase}).\n` +
97
+ ` ${reason}`,
98
+ resumeComplete: (batchId: string, succeeded: number, failed: number, skipped: number, blocked: number, elapsedSec: number) =>
99
+ `\n🏁 Resumed batch ${batchId} complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped, ${blocked} blocked (${elapsedSec}s total)`,
100
+
101
+ // /orch-abort
102
+ abortGracefulStarting: (batchId: string, sessionCount: number) =>
103
+ `⏳ Graceful abort of batch ${batchId}: signaling ${sessionCount} session(s) to checkpoint and exit...`,
104
+ abortGracefulWaiting: (batchId: string, graceSec: number) =>
105
+ `⏳ Waiting up to ${graceSec}s for sessions to checkpoint and exit...`,
106
+ abortGracefulForceKill: (count: number) =>
107
+ `⚠️ Force-killing ${count} session(s) that did not exit within timeout`,
108
+ abortGracefulComplete: (batchId: string, graceful: number, forceKilled: number, durationSec: number) =>
109
+ `✅ Graceful abort complete for batch ${batchId}: ${graceful} exited gracefully, ${forceKilled} force-killed (${durationSec}s)`,
110
+ abortHardStarting: (batchId: string, sessionCount: number) =>
111
+ `⚡ Hard abort of batch ${batchId}: killing ${sessionCount} session(s) immediately...`,
112
+ abortHardComplete: (batchId: string, killed: number, durationSec: number) =>
113
+ `✅ Hard abort complete for batch ${batchId}: ${killed} session(s) killed (${durationSec}s)`,
114
+ abortPartialFailure: (failureCount: number) =>
115
+ `⚠️ ${failureCount} error(s) during abort (see details above)`,
116
+ abortNoBatch: () =>
117
+ `No active batch to abort. Use /orch <areas|all> to start a batch.`,
118
+ abortComplete: (mode: AbortMode, sessionsKilled: number) =>
119
+ `🏁 Abort (${mode}) complete: ${sessionsKilled} session(s) terminated. Worktrees and branches preserved.`,
120
+ } as const;
121
+
122
+
123
+ // ── Resume ORCH_MESSAGES ─────────────────────────────────────────────
124
+
125
+ // Note: These are added via extension to the ORCH_MESSAGES object below.
126
+
127
+ // ── Resume Orchestration ─────────────────────────────────────────────
128
+
129
+ /**
130
+ * Resume an interrupted batch from persisted state.
131
+ *
132
+ * Flow:
133
+ * 1. Load and validate batch-state.json
134
+ * 2. Check phase eligibility (paused/executing/merging only)
135
+ * 3. Check for alive TMUX sessions and .DONE files
136
+ * 4. Reconcile persisted state against live signals
137
+ * 5. Compute resume point (which wave to start from)
138
+ * 6. Reconstruct runtime state and continue execution
139
+ *
140
+ * @param orchConfig - Orchestrator configuration
141
+ * @param runnerConfig - Task runner configuration
142
+ * @param cwd - Repository root
143
+ * @param batchState - Mutable batch state (will be populated from persisted state)
144
+ * @param onNotify - Callback for user-facing messages
145
+ * @param onMonitorUpdate - Optional callback for dashboard updates
146
+ */
@@ -14,9 +14,9 @@ import { mergeWave } from "./merge.ts";
14
14
  import { ORCH_MESSAGES } from "./messages.ts";
15
15
  import { deleteBatchState, hasTaskDoneMarker, loadBatchState, persistRuntimeState, seedPendingOutcomesForAllocatedLanes, syncTaskOutcomesFromMonitor, upsertTaskOutcome } from "./persistence.ts";
16
16
  import { StateFileError } from "./types.ts";
17
- import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult } from "./types.ts";
17
+ import type { AllocatedLane, AllocatedTask, LaneExecutionResult, LaneTaskOutcome, LaneTaskStatus, MergeWaveResult, OrchBatchPhase, OrchBatchRuntimeState, OrchestratorConfig, ParsedTask, PersistedBatchState, ReconciledTaskState, ResumeEligibility, ResumePoint, TaskRunnerConfig, WaveExecutionResult, WorkspaceConfig } from "./types.ts";
18
18
  import { buildDependencyGraph } from "./waves.ts";
19
- import { deleteBranchBestEffort, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
19
+ import { deleteBranchBestEffort, forceCleanupWorktree, listWorktrees, removeAllWorktrees, removeWorktree, safeResetWorktree } from "./worktree.ts";
20
20
 
21
21
  // ── Resume Pure Functions ────────────────────────────────────────────
22
22
 
@@ -335,6 +335,7 @@ export async function resumeOrchBatch(
335
335
  batchState: OrchBatchRuntimeState,
336
336
  onNotify: (message: string, level: "info" | "warning" | "error") => void,
337
337
  onMonitorUpdate?: MonitorUpdateCallback,
338
+ workspaceConfig?: WorkspaceConfig | null,
338
339
  ): Promise<void> {
339
340
  const repoRoot = cwd;
340
341
  const prefix = orchConfig.orchestrator.tmux_prefix;
@@ -464,6 +465,7 @@ export async function resumeOrchBatch(
464
465
  refreshDependencies: false,
465
466
  dependencySource: orchConfig.dependencies.source,
466
467
  useDependencyCache: orchConfig.dependencies.cache,
468
+ workspaceConfig: workspaceConfig ?? null,
467
469
  });
468
470
 
469
471
  // Build dependency graph for skip-dependents policy
@@ -1041,7 +1043,11 @@ export async function resumeOrchBatch(
1041
1043
  for (const wt of existingWorktrees) {
1042
1044
  const resetResult = safeResetWorktree(wt, targetBranch, repoRoot);
1043
1045
  if (!resetResult.success) {
1044
- try { removeWorktree(wt, repoRoot); } catch { /* best effort */ }
1046
+ try {
1047
+ removeWorktree(wt, repoRoot);
1048
+ } catch {
1049
+ forceCleanupWorktree(wt, repoRoot, batchState.batchId);
1050
+ }
1045
1051
  }
1046
1052
  }
1047
1053
  }
@@ -59,6 +59,10 @@ export interface ParsedTask {
59
59
  promptPath: string;
60
60
  areaName: string;
61
61
  status: "pending" | "complete";
62
+ /** Repo ID declared in the PROMPT metadata (e.g., "api", "frontend"). Undefined if not declared. */
63
+ promptRepoId?: string;
64
+ /** Resolved repo ID after routing precedence (workspace mode only). Undefined in repo mode. */
65
+ resolvedRepoId?: string;
62
66
  }
63
67
 
64
68
  /** A wave: a group of tasks whose dependencies are all satisfied */
@@ -105,6 +109,8 @@ export interface TaskArea {
105
109
  path: string;
106
110
  prefix: string;
107
111
  context: string;
112
+ /** Optional repo ID for routing tasks in this area (workspace mode only). */
113
+ repoId?: string;
108
114
  }
109
115
 
110
116
  /** Subset of task-runner.yaml that the orchestrator needs */
@@ -359,12 +365,31 @@ export interface DiscoveryError {
359
365
  | "DEP_UNRESOLVED"
360
366
  | "DEP_PENDING"
361
367
  | "DEP_AMBIGUOUS"
362
- | "DEP_SOURCE_FALLBACK";
368
+ | "DEP_SOURCE_FALLBACK"
369
+ | "TASK_REPO_UNRESOLVED"
370
+ | "TASK_REPO_UNKNOWN";
363
371
  message: string;
364
372
  taskPath?: string;
365
373
  taskId?: string;
366
374
  }
367
375
 
376
+ /**
377
+ * Discovery error codes that are fatal (block planning/execution).
378
+ *
379
+ * Used by formatDiscoveryResults, extension.ts, and engine.ts for
380
+ * consistent fatal-error classification. Keep in sync with the
381
+ * DiscoveryError.code union above.
382
+ */
383
+ export const FATAL_DISCOVERY_CODES: ReadonlyArray<DiscoveryError["code"]> = [
384
+ "DUPLICATE_ID",
385
+ "DEP_UNRESOLVED",
386
+ "DEP_PENDING",
387
+ "DEP_AMBIGUOUS",
388
+ "PARSE_MISSING_ID",
389
+ "TASK_REPO_UNRESOLVED",
390
+ "TASK_REPO_UNKNOWN",
391
+ ] as const;
392
+
368
393
  /** Result of the full discovery pipeline */
369
394
  export interface DiscoveryResult {
370
395
  pending: Map<string, ParsedTask>;
@@ -1519,3 +1544,215 @@ export interface BatchHistorySummary {
1519
1544
  /** Max number of batch history entries to retain. */
1520
1545
  export const BATCH_HISTORY_MAX_ENTRIES = 100;
1521
1546
 
1547
+
1548
+ // ── Workspace Mode Types ─────────────────────────────────────────────
1549
+
1550
+ /**
1551
+ * Workspace execution mode.
1552
+ *
1553
+ * Mode behavior contract:
1554
+ * - **"repo"** (default): No workspace config file present. The orchestrator
1555
+ * treats `cwd` as both the workspace root and the single repo root.
1556
+ * All existing monorepo behavior is preserved unchanged.
1557
+ * - **"workspace"**: A `.pi/taskplane-workspace.yaml` file is present and
1558
+ * valid. The orchestrator runs from a non-git workspace root that
1559
+ * coordinates multiple repos and a shared task root.
1560
+ *
1561
+ * Mode determination rules:
1562
+ * 1. No workspace config file → repo mode (non-fatal default, silent).
1563
+ * 2. Workspace config file present + invalid → fatal error with actionable
1564
+ * `WorkspaceConfigError` (never silently falls back to repo mode).
1565
+ * 3. Workspace config file present + valid → workspace mode.
1566
+ */
1567
+ export type WorkspaceMode = "repo" | "workspace";
1568
+
1569
+ /**
1570
+ * Configuration for a single repository within a workspace.
1571
+ *
1572
+ * Each repo is identified by a stable ID (e.g., "api", "frontend")
1573
+ * that is used for routing tasks to repos and for display purposes.
1574
+ */
1575
+ export interface WorkspaceRepoConfig {
1576
+ /** Stable identifier for this repo (e.g., "api", "frontend") */
1577
+ id: string;
1578
+ /** Absolute filesystem path to the repo root (must be a git repo) */
1579
+ path: string;
1580
+ /** Optional default branch override (e.g., "develop", "main"). Falls back to repo HEAD. */
1581
+ defaultBranch?: string;
1582
+ }
1583
+
1584
+ /**
1585
+ * Routing configuration for workspace mode.
1586
+ *
1587
+ * Controls where tasks are discovered and which repo receives
1588
+ * unqualified operations.
1589
+ */
1590
+ export interface WorkspaceRoutingConfig {
1591
+ /**
1592
+ * Absolute path to the shared tasks root directory.
1593
+ * All task areas are resolved relative to this path.
1594
+ * Must exist on disk.
1595
+ */
1596
+ tasksRoot: string;
1597
+ /**
1598
+ * Default repo ID for operations that don't specify a repo.
1599
+ * Must reference a valid key in `WorkspaceConfig.repos`.
1600
+ */
1601
+ defaultRepo: string;
1602
+ }
1603
+
1604
+ /**
1605
+ * Top-level workspace configuration.
1606
+ *
1607
+ * Loaded from `.pi/taskplane-workspace.yaml` when present.
1608
+ * Immutable after initial validation — never mutated at runtime.
1609
+ */
1610
+ export interface WorkspaceConfig {
1611
+ /** Active workspace mode */
1612
+ mode: WorkspaceMode;
1613
+ /** Map of repo ID → repo configuration. At least one repo required in workspace mode. */
1614
+ repos: Map<string, WorkspaceRepoConfig>;
1615
+ /** Routing configuration (tasks root, default repo) */
1616
+ routing: WorkspaceRoutingConfig;
1617
+ /** Absolute path to the workspace config file that was loaded */
1618
+ configPath: string;
1619
+ }
1620
+
1621
+ /**
1622
+ * Canonical execution context for the orchestrator.
1623
+ *
1624
+ * This is the primary runtime context threaded through orchestrator
1625
+ * entry points. It replaces the previous pattern of passing raw `cwd`
1626
+ * as the sole repo root.
1627
+ *
1628
+ * In repo mode, `workspaceRoot` and `repoRoot` are the same directory.
1629
+ * In workspace mode, `workspaceRoot` is the non-git coordination root
1630
+ * and `repoRoot` is the default repo from the workspace config.
1631
+ *
1632
+ * Design rationale:
1633
+ * - Step 2 (wire orchestrator startup) will construct this from config
1634
+ * loading results and thread it into `executeOrchBatch()` and friends.
1635
+ * - `repoRoot` is always a git repository, preserving the invariant
1636
+ * that git operations (worktree, branch, merge) have a valid target.
1637
+ * - `workspaceConfig` is null in repo mode (no workspace file loaded).
1638
+ */
1639
+ export interface ExecutionContext {
1640
+ /** Absolute path to the workspace root (cwd in repo mode, workspace dir in workspace mode) */
1641
+ workspaceRoot: string;
1642
+ /** Absolute path to the default/primary git repo root */
1643
+ repoRoot: string;
1644
+ /** Active workspace mode */
1645
+ mode: WorkspaceMode;
1646
+ /** Workspace configuration (null in repo mode) */
1647
+ workspaceConfig: WorkspaceConfig | null;
1648
+ /** Loaded task runner configuration */
1649
+ taskRunnerConfig: TaskRunnerConfig;
1650
+ /** Loaded orchestrator configuration */
1651
+ orchestratorConfig: OrchestratorConfig;
1652
+ }
1653
+
1654
+
1655
+ // ── Workspace Validation Error Types ─────────────────────────────────
1656
+
1657
+ /**
1658
+ * Error codes for workspace configuration validation failures.
1659
+ *
1660
+ * Each code maps to a deterministic validation rule from the workspace
1661
+ * config loading pipeline. Codes are stable and machine-branchable.
1662
+ *
1663
+ * - WORKSPACE_FILE_READ_ERROR: Config file exists but cannot be read (permissions, encoding)
1664
+ * - WORKSPACE_FILE_PARSE_ERROR: Config file contains invalid YAML
1665
+ * - WORKSPACE_MISSING_REPOS: No repos defined in workspace config (at least one required)
1666
+ * - WORKSPACE_REPO_PATH_MISSING: A repo entry has no `path` field
1667
+ * - WORKSPACE_REPO_PATH_NOT_FOUND: A repo's `path` does not exist on disk
1668
+ * - WORKSPACE_REPO_NOT_GIT: A repo's `path` exists but is not a git repository
1669
+ * - WORKSPACE_MISSING_TASKS_ROOT: `routing.tasks_root` is missing or empty
1670
+ * - WORKSPACE_TASKS_ROOT_NOT_FOUND: `routing.tasks_root` path does not exist on disk
1671
+ * - WORKSPACE_MISSING_DEFAULT_REPO: `routing.default_repo` is missing or empty
1672
+ * - WORKSPACE_DEFAULT_REPO_NOT_FOUND: `routing.default_repo` references a repo ID not in the repos map
1673
+ * - WORKSPACE_DUPLICATE_REPO_PATH: Two or more repos share the same filesystem path
1674
+ * - WORKSPACE_SCHEMA_INVALID: Config file has valid YAML but missing/invalid top-level structure
1675
+ */
1676
+ export type WorkspaceConfigErrorCode =
1677
+ | "WORKSPACE_FILE_READ_ERROR"
1678
+ | "WORKSPACE_FILE_PARSE_ERROR"
1679
+ | "WORKSPACE_MISSING_REPOS"
1680
+ | "WORKSPACE_REPO_PATH_MISSING"
1681
+ | "WORKSPACE_REPO_PATH_NOT_FOUND"
1682
+ | "WORKSPACE_REPO_NOT_GIT"
1683
+ | "WORKSPACE_MISSING_TASKS_ROOT"
1684
+ | "WORKSPACE_TASKS_ROOT_NOT_FOUND"
1685
+ | "WORKSPACE_MISSING_DEFAULT_REPO"
1686
+ | "WORKSPACE_DEFAULT_REPO_NOT_FOUND"
1687
+ | "WORKSPACE_DUPLICATE_REPO_PATH"
1688
+ | "WORKSPACE_SCHEMA_INVALID";
1689
+
1690
+ /**
1691
+ * Typed error class for workspace configuration failures.
1692
+ *
1693
+ * Thrown during workspace config loading/validation when the config file
1694
+ * is present but invalid. Never thrown when no config file exists (that
1695
+ * case silently falls back to repo mode).
1696
+ *
1697
+ * Follows the established pattern of typed error classes in this module
1698
+ * (WorktreeError, ExecutionError, MergeError, StateFileError, ResumeError).
1699
+ */
1700
+ export class WorkspaceConfigError extends Error {
1701
+ code: WorkspaceConfigErrorCode;
1702
+ /** Optional repo ID that triggered the error (for repo-specific validation failures) */
1703
+ repoId?: string;
1704
+ /** Optional filesystem path related to the error */
1705
+ relatedPath?: string;
1706
+
1707
+ constructor(code: WorkspaceConfigErrorCode, message: string, repoId?: string, relatedPath?: string) {
1708
+ super(message);
1709
+ this.name = "WorkspaceConfigError";
1710
+ this.code = code;
1711
+ this.repoId = repoId;
1712
+ this.relatedPath = relatedPath;
1713
+ }
1714
+ }
1715
+
1716
+
1717
+ // ── Workspace Defaults ───────────────────────────────────────────────
1718
+
1719
+ /**
1720
+ * Canonical filename for workspace configuration.
1721
+ * Resolved relative to workspace root: `.pi/taskplane-workspace.yaml`
1722
+ */
1723
+ export const WORKSPACE_CONFIG_FILENAME = "taskplane-workspace.yaml";
1724
+
1725
+ /**
1726
+ * Resolve the absolute path to the workspace config file.
1727
+ * @param workspaceRoot - Absolute path to the workspace root
1728
+ */
1729
+ export function workspaceConfigPath(workspaceRoot: string): string {
1730
+ return join(workspaceRoot, ".pi", WORKSPACE_CONFIG_FILENAME);
1731
+ }
1732
+
1733
+ /**
1734
+ * Create a default ExecutionContext for repo mode.
1735
+ *
1736
+ * Used when no workspace config file is present. The workspace root
1737
+ * and repo root are the same directory (cwd), preserving existing
1738
+ * monorepo behavior exactly.
1739
+ *
1740
+ * @param cwd - Current working directory (treated as both workspace and repo root)
1741
+ * @param taskRunnerConfig - Loaded task runner config (or defaults)
1742
+ * @param orchestratorConfig - Loaded orchestrator config (or defaults)
1743
+ */
1744
+ export function createRepoModeContext(
1745
+ cwd: string,
1746
+ taskRunnerConfig: TaskRunnerConfig,
1747
+ orchestratorConfig: OrchestratorConfig,
1748
+ ): ExecutionContext {
1749
+ return {
1750
+ workspaceRoot: cwd,
1751
+ repoRoot: cwd,
1752
+ mode: "repo",
1753
+ workspaceConfig: null,
1754
+ taskRunnerConfig,
1755
+ orchestratorConfig,
1756
+ };
1757
+ }
1758
+