tmux-ide 1.3.1 → 2.0.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.
Files changed (236) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +27 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/attach.ts +20 -0
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/detect.ts +248 -0
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/inspect.ts +212 -0
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/output.ts +76 -0
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/session-options.ts +100 -0
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/sizes.ts +36 -0
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/yaml-io.ts +27 -0
  103. package/src/ls.test.ts +76 -0
  104. package/src/ls.ts +40 -0
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/dist/attach.d.ts +0 -3
  194. package/dist/attach.js +0 -14
  195. package/dist/config.d.ts +0 -5
  196. package/dist/config.js +0 -269
  197. package/dist/detect.d.ts +0 -15
  198. package/dist/detect.js +0 -228
  199. package/dist/doctor.d.ts +0 -3
  200. package/dist/doctor.js +0 -66
  201. package/dist/init.d.ts +0 -4
  202. package/dist/init.js +0 -67
  203. package/dist/inspect.d.ts +0 -54
  204. package/dist/inspect.js +0 -118
  205. package/dist/launch.d.ts +0 -22
  206. package/dist/launch.js +0 -174
  207. package/dist/lib/dot-path.d.ts +0 -2
  208. package/dist/lib/dot-path.js +0 -17
  209. package/dist/lib/errors.d.ts +0 -29
  210. package/dist/lib/errors.js +0 -37
  211. package/dist/lib/launch-plan.d.ts +0 -6
  212. package/dist/lib/launch-plan.js +0 -40
  213. package/dist/lib/output.d.ts +0 -9
  214. package/dist/lib/output.js +0 -65
  215. package/dist/lib/session-monitor.d.ts +0 -12
  216. package/dist/lib/session-monitor.js +0 -160
  217. package/dist/lib/session-options.d.ts +0 -13
  218. package/dist/lib/session-options.js +0 -85
  219. package/dist/lib/sizes.d.ts +0 -16
  220. package/dist/lib/sizes.js +0 -36
  221. package/dist/lib/tmux.d.ts +0 -42
  222. package/dist/lib/tmux.js +0 -225
  223. package/dist/lib/yaml-io.d.ts +0 -10
  224. package/dist/lib/yaml-io.js +0 -24
  225. package/dist/ls.d.ts +0 -3
  226. package/dist/ls.js +0 -35
  227. package/dist/restart.d.ts +0 -4
  228. package/dist/restart.js +0 -13
  229. package/dist/status.d.ts +0 -3
  230. package/dist/status.js +0 -29
  231. package/dist/stop.d.ts +0 -3
  232. package/dist/stop.js +0 -21
  233. package/dist/types.d.ts +0 -41
  234. package/dist/types.js +0 -1
  235. package/dist/validate.d.ts +0 -4
  236. package/dist/validate.js +0 -172
@@ -0,0 +1,1792 @@
1
+ /**
2
+ * Autonomous task orchestration engine.
3
+ *
4
+ * Runs as a background polling loop that coordinates agent workloads across
5
+ * tmux panes. Core capabilities:
6
+ *
7
+ * - **Dispatch** — assigns unblocked todo tasks to idle agents, respecting
8
+ * dependency ordering, concurrency limits, and claim locks.
9
+ * - **Stall detection** — nudges agents that exceed the configured timeout
10
+ * without producing output.
11
+ * - **Completion handling** — records cost/time metrics, runs after-hooks,
12
+ * and notifies the master pane.
13
+ * - **Retry with backoff** — re-dispatches failed tasks up to maxRetries,
14
+ * using nextRetryAt to schedule exponential backoff.
15
+ * - **Reconciliation** — detects crashed or vanished agent panes and
16
+ * releases their in-progress tasks back to the queue.
17
+ * - **Graceful shutdown** — on SIGTERM/SIGINT, releases all in-progress
18
+ * tasks and persists orchestrator state for resume on next startup.
19
+ * - **Hot reload** — watches ide.yml for config changes and applies them
20
+ * without restarting the loop.
21
+ *
22
+ * @module orchestrator
23
+ */
24
+ import { execSync, execFileSync } from "node:child_process";
25
+ import {
26
+ existsSync,
27
+ readFileSync,
28
+ writeFileSync,
29
+ appendFileSync,
30
+ renameSync,
31
+ mkdirSync,
32
+ readdirSync,
33
+ watch,
34
+ type FSWatcher,
35
+ } from "node:fs";
36
+ import { join } from "node:path";
37
+ import yaml from "js-yaml";
38
+ import {
39
+ loadMission,
40
+ saveMission,
41
+ loadGoal,
42
+ loadGoals,
43
+ loadTasks,
44
+ saveTask,
45
+ saveGoal,
46
+ nextTaskId,
47
+ type Task,
48
+ type Goal,
49
+ type Mission,
50
+ type Milestone,
51
+ } from "./task-store.ts";
52
+ import { slugify } from "./slugify.ts";
53
+ import { recordTaskTime } from "./token-tracker.ts";
54
+ import { listSessionPanes, sendCommand, type PaneInfo } from "../widgets/lib/pane-comms.ts";
55
+ import { appendEvent, readEvents, type OrchestratorEvent } from "./event-log.ts";
56
+ import { loadSkill } from "./skill-registry.ts";
57
+ import { isGhAvailable, createMissionPr } from "./github-pr.ts";
58
+ import { computeAndSaveMetrics, appendMissionHistory } from "./metrics.ts";
59
+ import {
60
+ loadValidationContract,
61
+ loadValidationState,
62
+ saveValidationState,
63
+ checkCoverage,
64
+ } from "./validation.ts";
65
+ import {
66
+ dispatchResearch,
67
+ evaluateTriggers,
68
+ loadResearchState,
69
+ processResearchCompletion,
70
+ saveResearchState,
71
+ type ResearchConfigShape,
72
+ type ResearchState,
73
+ } from "./research.ts";
74
+
75
+ export interface OrchestratorConfig {
76
+ session: string;
77
+ dir: string;
78
+ autoDispatch: boolean;
79
+ stallTimeout: number;
80
+ pollInterval: number;
81
+ masterPane: string | null;
82
+ beforeRun: string | null;
83
+ afterRun: string | null;
84
+ maxConcurrentAgents: number;
85
+ dispatchMode: "tasks" | "goals" | "missions";
86
+ paneSpecialties: Map<string, string[]>;
87
+ services: Record<string, { command: string; port?: number; healthcheck?: string }>;
88
+ research?: ResearchConfigShape;
89
+ }
90
+
91
+ export interface OrchestratorState {
92
+ lastActivity: Map<string, number>;
93
+ previousTasks: Map<string, string>;
94
+ claimedTasks: Set<string>;
95
+ taskClaimTimes: Map<string, number>;
96
+ }
97
+
98
+ export function runHook(command: string, cwd: string): { ok: true } | { ok: false; error: string } {
99
+ try {
100
+ execSync(command, { cwd, timeout: 60000, stdio: "pipe" });
101
+ return { ok: true };
102
+ } catch (err: unknown) {
103
+ const message = err instanceof Error ? err.message : String(err);
104
+ return { ok: false, error: message };
105
+ }
106
+ }
107
+
108
+ // Agent detection: Claude Code reports its version (e.g. "2.1.80") as currentCommand,
109
+ // not "claude". Detect agents by checking both the command name and the pane title.
110
+ const SHELL_COMMANDS = new Set(["zsh", "bash", "sh", "fish"]);
111
+ const AGENT_PREFIXES = ["claude", "codex"];
112
+ const SPINNERS = /^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏⠂⠒⠢⠆⠐⠠⠄◐◓◑◒✳|/\\-] /;
113
+ const VERSION_PATTERN = /^\d+\.\d+/;
114
+
115
+ // Strip spinner/status prefix from pane title to get stable name
116
+ export function normalizePaneTitle(title: string): string {
117
+ return title.replace(SPINNERS, "").trim();
118
+ }
119
+
120
+ // French names for agents — fun, memorable, and stable across spinner changes
121
+ const AGENT_NAMES = [
122
+ "François",
123
+ "Amélie",
124
+ "Louis",
125
+ "Camille",
126
+ "Marcel",
127
+ "Colette",
128
+ "Henri",
129
+ "Margaux",
130
+ "René",
131
+ "Léonie",
132
+ "Étienne",
133
+ "Fleur",
134
+ "Gaston",
135
+ "Isabelle",
136
+ "Jacques",
137
+ "Lucienne",
138
+ "Nicolas",
139
+ "Odette",
140
+ "Pierre",
141
+ "Rosalie",
142
+ ];
143
+
144
+ // Get a stable, memorable identifier for an agent pane
145
+ export function agentIdentifier(pane: PaneInfo): string {
146
+ if (pane.name) return pane.name;
147
+ return AGENT_NAMES[pane.index % AGENT_NAMES.length] ?? `Agent ${pane.index}`;
148
+ }
149
+
150
+ export function isAgentPane(pane: PaneInfo): boolean {
151
+ const cmd = pane.currentCommand.toLowerCase();
152
+ if (AGENT_PREFIXES.some((p) => cmd.startsWith(p))) return true;
153
+ // Claude Code shows its version string as the command (e.g. "2.1.80")
154
+ if (VERSION_PATTERN.test(cmd)) return true;
155
+ // Title-based detection — check for Claude Code or French agent names
156
+ if (/claude\s*code/i.test(pane.title)) return true;
157
+ const name = normalizePaneTitle(pane.title);
158
+ if (AGENT_NAMES.includes(name)) return true;
159
+ return false;
160
+ }
161
+
162
+ export function isAgentBusy(pane: PaneInfo): boolean {
163
+ // Spinner chars in pane title indicate the agent is actively working
164
+ return SPINNERS.test(pane.title);
165
+ }
166
+
167
+ /**
168
+ * Check if a pane is at the agent prompt (❯) by capturing the last line.
169
+ * This is more reliable than title-based spinner detection since pane titles
170
+ * can show stale spinners after the agent finishes.
171
+ */
172
+ function isAtAgentPrompt(paneId: string): boolean {
173
+ try {
174
+ const lastLine = execFileSync("tmux", ["capture-pane", "-t", paneId, "-p", "-S", "-1"], {
175
+ encoding: "utf-8",
176
+ stdio: ["ignore", "pipe", "ignore"],
177
+ }).trim();
178
+ return lastLine.includes("❯");
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
183
+
184
+ export function isIdleForDispatch(pane: PaneInfo): boolean {
185
+ const cmd = pane.currentCommand.toLowerCase();
186
+ // Running a shell → idle
187
+ if (SHELL_COMMANDS.has(cmd)) return true;
188
+ // Agent pane: check spinner first (fast), then fall back to prompt detection (reliable)
189
+ if (isAgentPane(pane)) {
190
+ if (!isAgentBusy(pane)) return true;
191
+ // Spinner may be stale — check if agent is actually at the prompt
192
+ return isAtAgentPrompt(pane.id);
193
+ }
194
+ return false;
195
+ }
196
+
197
+ /**
198
+ * Load a library file excerpt. Returns content truncated to ~500 chars if needed.
199
+ */
200
+ function loadLibraryExcerpt(filePath: string, maxChars = 500): string | null {
201
+ if (!existsSync(filePath)) return null;
202
+ try {
203
+ const content = readFileSync(filePath, "utf-8").trim();
204
+ if (!content) return null;
205
+ if (content.length <= maxChars) return content;
206
+ return content.slice(0, maxChars) + `\n... (see full file at ${filePath})`;
207
+ } catch {
208
+ return null;
209
+ }
210
+ }
211
+
212
+ export function buildTaskPrompt(dir: string, task: Task, config?: OrchestratorConfig): string {
213
+ let prompt = "";
214
+
215
+ // 1. Mission narrative
216
+ const mission = loadMission(dir);
217
+ if (mission) {
218
+ prompt += `## Mission: ${mission.title}\n`;
219
+ if (mission.description) prompt += `${mission.description}\n`;
220
+ prompt += "\n";
221
+ }
222
+
223
+ // 2. Milestone context
224
+ if (task.milestone && mission) {
225
+ const milestone = mission.milestones.find((m) => m.id === task.milestone);
226
+ if (milestone) {
227
+ prompt += `## Milestone: ${milestone.title} (${milestone.id})\n`;
228
+ if (milestone.description) prompt += `${milestone.description}\n`;
229
+ prompt += "\n";
230
+ }
231
+ }
232
+
233
+ // 3. AGENTS.md — project boundaries
234
+ const agentsMdPath = join(dir, "AGENTS.md");
235
+ const agentsContent = loadLibraryExcerpt(agentsMdPath, 1000);
236
+ if (agentsContent) {
237
+ prompt += `## Agent Guidelines\n${agentsContent}\n\n`;
238
+ }
239
+
240
+ // 4. Skill context
241
+ if (task.specialty) {
242
+ const skill = loadSkill(dir, task.specialty);
243
+ if (skill?.body) {
244
+ prompt += `## Your Role: ${skill.name}\n`;
245
+ prompt += `${skill.body}\n\n`;
246
+ }
247
+ }
248
+
249
+ // 5. Goal context
250
+ if (task.goal) {
251
+ const goal = loadGoal(dir, task.goal);
252
+ if (goal) {
253
+ prompt += `## Goal: ${goal.title}\n`;
254
+ if (goal.acceptance) prompt += `Acceptance: ${goal.acceptance}\n`;
255
+ prompt += "\n";
256
+ }
257
+ }
258
+
259
+ // 6. Task details
260
+ prompt += `## Your Task: ${task.title}\n`;
261
+ if (task.description) prompt += `${task.description}\n`;
262
+ prompt += `\nPriority: P${task.priority}\n`;
263
+ if (task.tags?.length) prompt += `Tags: ${task.tags.join(", ")}\n`;
264
+ if (task.fulfills?.length) prompt += `Fulfills assertions: ${task.fulfills.join(", ")}\n`;
265
+ prompt += "\n";
266
+
267
+ // 7. Recent completions (same milestone, last 5)
268
+ if (task.milestone) {
269
+ const allTasks = loadTasks(dir);
270
+ const recentDone = allTasks
271
+ .filter((t) => t.milestone === task.milestone && t.status === "done" && t.id !== task.id)
272
+ .slice(-5);
273
+ if (recentDone.length > 0) {
274
+ prompt += `## Recent Completions (${task.milestone})\n`;
275
+ for (const t of recentDone) {
276
+ prompt += `- ${t.id}: ${t.title}`;
277
+ if (t.salientSummary) prompt += ` — ${t.salientSummary}`;
278
+ prompt += "\n";
279
+ }
280
+ prompt += "\n";
281
+ }
282
+ }
283
+
284
+ // 8. Library references
285
+ const libraryDir = join(dir, ".tmux-ide", "library");
286
+ if (existsSync(libraryDir)) {
287
+ // Always include architecture.md
288
+ const archExcerpt = loadLibraryExcerpt(join(libraryDir, "architecture.md"));
289
+ if (archExcerpt) {
290
+ prompt += `## Architecture\n${archExcerpt}\n\n`;
291
+ }
292
+
293
+ // Match library files by task tags
294
+ if (task.tags?.length) {
295
+ try {
296
+ const files = readdirSync(libraryDir).filter(
297
+ (f) => f.endsWith(".md") && f !== "architecture.md",
298
+ );
299
+ const tagSet = new Set(task.tags.map((t) => t.toLowerCase()));
300
+ for (const file of files) {
301
+ const stem = file.replace(/\.md$/, "").toLowerCase();
302
+ if (tagSet.has(stem)) {
303
+ const excerpt = loadLibraryExcerpt(join(libraryDir, file));
304
+ if (excerpt) {
305
+ prompt += `## Reference: ${file}\n${excerpt}\n\n`;
306
+ }
307
+ }
308
+ }
309
+ } catch {
310
+ // Library scan failed — skip
311
+ }
312
+ }
313
+ }
314
+
315
+ // 9. Services
316
+ if (config?.services && Object.keys(config.services).length > 0) {
317
+ prompt += `## Available Services\n`;
318
+ for (const [name, svc] of Object.entries(config.services)) {
319
+ prompt += `- **${name}**: \`${svc.command}\``;
320
+ if (svc.port) prompt += ` (port ${svc.port})`;
321
+ if (svc.healthcheck) prompt += ` — healthcheck: \`${svc.healthcheck}\``;
322
+ prompt += "\n";
323
+ }
324
+ prompt += "\n";
325
+ }
326
+
327
+ // 10. Workspace + completion instructions
328
+ prompt += `Workspace: ${dir}\n\n`;
329
+ prompt += `When done:\n`;
330
+ prompt += ` tmux-ide task done ${task.id} --proof "short summary of what you accomplished"\n`;
331
+
332
+ return prompt.trim();
333
+ }
334
+
335
+ // --- Milestone gating ---
336
+
337
+ /**
338
+ * Get the current (first non-done) milestone from the mission, sorted by order.
339
+ */
340
+ export function getCurrentMilestone(dir: string): Milestone | null {
341
+ const mission = loadMission(dir);
342
+ if (!mission || mission.milestones.length === 0) return null;
343
+ const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
344
+ return sorted.find((m) => m.status !== "done") ?? null;
345
+ }
346
+
347
+ /**
348
+ * Check that all milestones with lower order than the given one are 'done'.
349
+ */
350
+ export function isMilestoneReady(dir: string, milestoneId: string): boolean {
351
+ const mission = loadMission(dir);
352
+ if (!mission) return false;
353
+ const target = mission.milestones.find((m) => m.id === milestoneId);
354
+ if (!target) return false;
355
+ return mission.milestones.filter((m) => m.order < target.order).every((m) => m.status === "done");
356
+ }
357
+
358
+ /**
359
+ * Check if a task's milestone allows dispatch.
360
+ * Tasks without a milestone are always eligible (backward compat).
361
+ * Tasks with a milestone require it to be 'active' and all predecessors done.
362
+ */
363
+ function isTaskMilestoneEligible(dir: string, task: Task, mission: Mission | null): boolean {
364
+ if (!task.milestone) return true;
365
+ if (!mission) return false;
366
+ const milestone = mission.milestones.find((m) => m.id === task.milestone);
367
+ if (!milestone) return false;
368
+ if (milestone.status !== "active") return false;
369
+ return isMilestoneReady(dir, milestone.id);
370
+ }
371
+
372
+ /**
373
+ * Check active milestones for completion. When all tasks for a milestone are done,
374
+ * transition to 'validating' (if a validation contract exists) or straight to 'done'.
375
+ */
376
+ export function checkMilestoneCompletion(
377
+ config: OrchestratorConfig,
378
+ state: OrchestratorState,
379
+ tasks: Task[],
380
+ panes: PaneInfo[],
381
+ ): void {
382
+ const mission = loadMission(config.dir);
383
+ if (!mission || mission.milestones.length === 0) return;
384
+
385
+ let changed = false;
386
+ const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
387
+
388
+ for (const milestone of sorted) {
389
+ if (milestone.status !== "active") continue;
390
+
391
+ const milestoneTasks = tasks.filter((t) => t.milestone === milestone.id);
392
+ if (milestoneTasks.length === 0) continue;
393
+ const allDone = milestoneTasks.every((t) => t.status === "done");
394
+ if (!allDone) continue;
395
+
396
+ // Check if a validation contract exists — if so, go through validation
397
+ const contract = loadValidationContract(config.dir);
398
+ if (contract) {
399
+ const dispatched = dispatchValidation(config, state, milestone, milestoneTasks, panes);
400
+ if (!dispatched) continue;
401
+
402
+ milestone.status = "validating";
403
+ milestone.updated = new Date().toISOString();
404
+ changed = true;
405
+
406
+ appendEvent(config.dir, {
407
+ timestamp: new Date().toISOString(),
408
+ type: "milestone_validating",
409
+ milestoneId: milestone.id,
410
+ title: milestone.title,
411
+ message: `Milestone "${milestone.title}" (${milestone.id}) — all tasks done, dispatching validation`,
412
+ });
413
+ } else {
414
+ // No contract — skip straight to done
415
+ milestone.status = "done";
416
+ milestone.updated = new Date().toISOString();
417
+ changed = true;
418
+
419
+ appendEvent(config.dir, {
420
+ timestamp: new Date().toISOString(),
421
+ type: "milestone_complete",
422
+ milestoneId: milestone.id,
423
+ title: milestone.title,
424
+ message: `Milestone "${milestone.title}" (${milestone.id}) completed`,
425
+ });
426
+
427
+ activateNextMilestone(mission, milestone.id);
428
+ }
429
+ }
430
+
431
+ if (changed) {
432
+ mission.updated = new Date().toISOString();
433
+ saveMission(config.dir, mission);
434
+ }
435
+ }
436
+
437
+ function activateNextMilestone(mission: Mission, completedMilestoneId: string): boolean {
438
+ const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
439
+ const completed = sorted.find((milestone) => milestone.id === completedMilestoneId);
440
+ if (!completed || completed.status !== "done") return false;
441
+
442
+ const next = sorted.find(
443
+ (milestone) => milestone.order > completed.order && milestone.status === "locked",
444
+ );
445
+ if (!next) return false;
446
+
447
+ next.status = "active";
448
+ next.updated = new Date().toISOString();
449
+ return true;
450
+ }
451
+
452
+ /**
453
+ * Build a validation prompt for a milestone.
454
+ */
455
+ export function buildValidationPrompt(
456
+ dir: string,
457
+ milestone: Milestone,
458
+ completedTasks: Task[],
459
+ ): string {
460
+ const contract = loadValidationContract(dir);
461
+ let prompt = `# Validation: ${milestone.title} (${milestone.id})\n\n`;
462
+
463
+ if (contract) {
464
+ prompt += `## Validation Contract\n\n${contract}\n\n`;
465
+ }
466
+
467
+ prompt += `## Completed Tasks\n\n`;
468
+ for (const task of completedTasks) {
469
+ prompt += `### ${task.id}: ${task.title}\n`;
470
+ if (task.proof) prompt += `Proof: ${JSON.stringify(task.proof)}\n`;
471
+ if (task.fulfills.length > 0) prompt += `Fulfills assertions: ${task.fulfills.join(", ")}\n`;
472
+ if (task.salientSummary) prompt += `Summary: ${task.salientSummary}\n`;
473
+ prompt += "\n";
474
+ }
475
+
476
+ prompt += `## Instructions\n\n`;
477
+ prompt += `Verify each assertion in the validation contract. For each assertion:\n`;
478
+ prompt += `1. Check that the completed work actually satisfies the requirement\n`;
479
+ prompt += `2. Run any relevant tests or checks\n`;
480
+ prompt += `3. Report your finding:\n`;
481
+ prompt += ` tmux-ide validate assert <ASSERT_ID> --status passing --evidence "description"\n`;
482
+ prompt += ` tmux-ide validate assert <ASSERT_ID> --status failing --evidence "what's wrong"\n\n`;
483
+ prompt += `When done verifying all assertions, the orchestrator will automatically detect results.\n`;
484
+
485
+ return prompt.trim();
486
+ }
487
+
488
+ /**
489
+ * Dispatch validation to a validator pane after milestone completion.
490
+ */
491
+ export function dispatchValidation(
492
+ config: OrchestratorConfig,
493
+ state: OrchestratorState,
494
+ milestone: Milestone,
495
+ completedTasks: Task[],
496
+ panes: PaneInfo[],
497
+ ): boolean {
498
+ // Find validator pane, fall back to any idle non-lead agent
499
+ const validatorPane =
500
+ panes.find((p) => p.role === "validator" && isIdleForDispatch(p)) ??
501
+ panes.find((p) => {
502
+ if (p.role === "lead") return false;
503
+ if (config.masterPane && normalizePaneTitle(p.title) === config.masterPane) return false;
504
+ return isIdleForDispatch(p);
505
+ });
506
+
507
+ if (!validatorPane) {
508
+ appendEvent(config.dir, {
509
+ timestamp: new Date().toISOString(),
510
+ type: "error",
511
+ message: `No idle validator pane for milestone "${milestone.title}" — validation pending`,
512
+ });
513
+ return false;
514
+ }
515
+
516
+ // Initialize validation state with pending entries for all fulfills
517
+ const valState = loadValidationState(config.dir) ?? { assertions: {}, lastVerified: null };
518
+ for (const task of completedTasks) {
519
+ for (const assertionId of task.fulfills) {
520
+ if (!valState.assertions[assertionId]) {
521
+ valState.assertions[assertionId] = {
522
+ status: "pending",
523
+ verifiedBy: null,
524
+ verifiedAt: null,
525
+ evidence: null,
526
+ blockedBy: null,
527
+ };
528
+ }
529
+ }
530
+ }
531
+ saveValidationState(config.dir, valState);
532
+
533
+ // Build and write prompt
534
+ const prompt = buildValidationPrompt(config.dir, milestone, completedTasks);
535
+ const dispatchDir = join(config.dir, ".tasks", "dispatch");
536
+ if (!existsSync(dispatchDir)) mkdirSync(dispatchDir, { recursive: true });
537
+ const dispatchFile = join(dispatchDir, `validate-${milestone.id}.md`);
538
+ writeFileSync(dispatchFile, prompt);
539
+
540
+ try {
541
+ sendCommand(
542
+ config.session,
543
+ validatorPane.id,
544
+ `Read and execute: .tasks/dispatch/validate-${milestone.id}.md`,
545
+ );
546
+ } catch (error: unknown) {
547
+ const message = error instanceof Error ? error.message : String(error);
548
+ appendEvent(config.dir, {
549
+ timestamp: new Date().toISOString(),
550
+ type: "error",
551
+ message: `Failed to dispatch validation for milestone "${milestone.title}": ${message}`,
552
+ });
553
+ return false;
554
+ }
555
+
556
+ saveValidationState(config.dir, valState);
557
+
558
+ appendEvent(config.dir, {
559
+ timestamp: new Date().toISOString(),
560
+ type: "validation_dispatch",
561
+ milestoneId: milestone.id,
562
+ title: milestone.title,
563
+ target: validatorPane.title,
564
+ message: `Dispatched validation for "${milestone.title}" to ${validatorPane.title}`,
565
+ });
566
+
567
+ state.lastActivity.set(validatorPane.id, Date.now());
568
+ return true;
569
+ }
570
+
571
+ /**
572
+ * Check validation results for milestones in 'validating' state.
573
+ * If all assertions pass → mark done, activate next.
574
+ * If any fail → create remediation tasks, set milestone back to 'active'.
575
+ */
576
+ export function checkValidationResults(config: OrchestratorConfig, tasks: Task[]): void {
577
+ const mission = loadMission(config.dir);
578
+ if (!mission) return;
579
+
580
+ const valState = loadValidationState(config.dir);
581
+ if (!valState) return;
582
+
583
+ let changed = false;
584
+ const sorted = [...mission.milestones].sort((a, b) => a.order - b.order);
585
+
586
+ for (const milestone of sorted) {
587
+ if (milestone.status !== "validating") continue;
588
+
589
+ // Collect assertion IDs relevant to this milestone's tasks
590
+ const milestoneTasks = tasks.filter((t) => t.milestone === milestone.id);
591
+ const milestoneAssertionIds = new Set(milestoneTasks.flatMap((t) => t.fulfills));
592
+
593
+ if (milestoneAssertionIds.size === 0) {
594
+ milestone.status = "done";
595
+ milestone.updated = new Date().toISOString();
596
+ changed = true;
597
+
598
+ appendEvent(config.dir, {
599
+ timestamp: new Date().toISOString(),
600
+ type: "milestone_complete",
601
+ milestoneId: milestone.id,
602
+ title: milestone.title,
603
+ message: `Milestone "${milestone.title}" (${milestone.id}) — no assertions to validate`,
604
+ });
605
+
606
+ activateNextMilestone(mission, milestone.id);
607
+ continue;
608
+ }
609
+
610
+ // Check if all milestone assertions have been verified (no longer pending)
611
+ const allVerified = [...milestoneAssertionIds].every((id) => {
612
+ const entry = valState.assertions[id];
613
+ return entry && entry.status !== "pending";
614
+ });
615
+ if (!allVerified) continue;
616
+
617
+ const allPassing = [...milestoneAssertionIds].every(
618
+ (id) => valState.assertions[id]?.status === "passing",
619
+ );
620
+
621
+ if (allPassing) {
622
+ milestone.status = "done";
623
+ milestone.updated = new Date().toISOString();
624
+ changed = true;
625
+
626
+ appendEvent(config.dir, {
627
+ timestamp: new Date().toISOString(),
628
+ type: "milestone_complete",
629
+ milestoneId: milestone.id,
630
+ title: milestone.title,
631
+ message: `Milestone "${milestone.title}" (${milestone.id}) — validation passed`,
632
+ });
633
+
634
+ activateNextMilestone(mission, milestone.id);
635
+ } else {
636
+ // Create remediation tasks for failing assertions
637
+ const failedIds = [...milestoneAssertionIds].filter(
638
+ (id) => valState.assertions[id]?.status === "failing",
639
+ );
640
+
641
+ for (const assertionId of failedIds) {
642
+ const entry = valState.assertions[assertionId];
643
+ if (!entry || entry.status !== "failing") continue;
644
+
645
+ const id = nextTaskId(config.dir);
646
+ const now = new Date().toISOString();
647
+ const task: Task = {
648
+ id,
649
+ title: `Remediate: assertion ${assertionId} failing`,
650
+ description: `Assertion ${assertionId} failed validation.\nEvidence: ${entry.evidence ?? "none"}\n\nFix the issue and ensure the assertion passes.`,
651
+ goal: null,
652
+ status: "todo",
653
+ assignee: null,
654
+ priority: 1,
655
+ created: now,
656
+ updated: now,
657
+ tags: ["remediation"],
658
+ proof: null,
659
+ depends_on: [],
660
+ retryCount: 0,
661
+ maxRetries: 5,
662
+ lastError: null,
663
+ nextRetryAt: null,
664
+ milestone: milestone.id,
665
+ specialty: null,
666
+ fulfills: [assertionId],
667
+ discoveredIssues: [],
668
+ salientSummary: null,
669
+ };
670
+ saveTask(config.dir, task);
671
+
672
+ // Reset the assertion back to pending for re-verification
673
+ entry.status = "pending";
674
+ entry.verifiedBy = null;
675
+ entry.verifiedAt = null;
676
+ entry.evidence = null;
677
+ entry.blockedBy = null;
678
+
679
+ appendEvent(config.dir, {
680
+ timestamp: new Date().toISOString(),
681
+ type: "remediation",
682
+ taskId: id,
683
+ assertionId,
684
+ message: `Created remediation task ${id} for failing assertion ${assertionId}`,
685
+ });
686
+ }
687
+
688
+ saveValidationState(config.dir, valState);
689
+
690
+ // Set milestone back to active so remediation tasks can be dispatched
691
+ milestone.status = "active";
692
+ milestone.updated = new Date().toISOString();
693
+ changed = true;
694
+
695
+ appendEvent(config.dir, {
696
+ timestamp: new Date().toISOString(),
697
+ type: "validation_failed",
698
+ milestoneId: milestone.id,
699
+ title: milestone.title,
700
+ failedCount: failedIds.length,
701
+ message: `Milestone "${milestone.title}" (${milestone.id}) — ${failedIds.length} assertion(s) failed, remediation tasks created`,
702
+ });
703
+ }
704
+ }
705
+
706
+ if (changed) {
707
+ mission.updated = new Date().toISOString();
708
+ saveMission(config.dir, mission);
709
+ }
710
+ }
711
+
712
+ // --- Planning dispatch ---
713
+
714
+ const PLANNING_CLAIM = "__planning__";
715
+
716
+ /**
717
+ * Build the planning prompt for the lead/planner agent.
718
+ */
719
+ export function buildPlanningPrompt(dir: string): string {
720
+ const mission = loadMission(dir);
721
+ let prompt = `# Mission Planning\n\n`;
722
+
723
+ if (mission) {
724
+ prompt += `## Mission: ${mission.title}\n`;
725
+ if (mission.description) prompt += `${mission.description}\n`;
726
+ prompt += "\n";
727
+ }
728
+
729
+ // AGENTS.md
730
+ const agentsMdPath = join(dir, "AGENTS.md");
731
+ const agentsContent = loadLibraryExcerpt(agentsMdPath, 1000);
732
+ if (agentsContent) {
733
+ prompt += `## Agent Guidelines\n${agentsContent}\n\n`;
734
+ }
735
+
736
+ prompt += `## Instructions\n\n`;
737
+ prompt += `You are the mission planner. Analyze the mission scope and create an execution plan.\n\n`;
738
+ prompt += `1. Design milestones (sequential execution phases):\n`;
739
+ prompt += ` tmux-ide milestone create "title" --sequence N [-d "description"]\n\n`;
740
+ prompt += `2. Create a validation contract at .tasks/validation-contract.md with assertion IDs:\n`;
741
+ prompt += ` Each assertion is a testable claim (e.g., ASSERT01: "Auth endpoint returns 200")\n\n`;
742
+ prompt += `3. Create tasks linked to milestones:\n`;
743
+ prompt += ` tmux-ide task create "title" --milestone M1 --specialty frontend --fulfills "ASSERT01,ASSERT02" [-d "description"]\n\n`;
744
+ prompt += `4. Ensure every assertion in the contract is claimed by at least one task's --fulfills\n\n`;
745
+ prompt += `5. When planning is complete, signal:\n`;
746
+ prompt += ` tmux-ide mission plan-complete\n`;
747
+
748
+ return prompt.trim();
749
+ }
750
+
751
+ /**
752
+ * Dispatch the planning phase to the lead/planner pane.
753
+ * Only runs once per mission (guarded by __planning__ claim).
754
+ */
755
+ export function dispatchPlanning(
756
+ config: OrchestratorConfig,
757
+ state: OrchestratorState,
758
+ panes: PaneInfo[],
759
+ ): void {
760
+ if (state.claimedTasks.has(PLANNING_CLAIM)) return;
761
+
762
+ // Find the lead/planner pane
763
+ const plannerPane =
764
+ panes.find((p) => p.role === "lead") ??
765
+ panes.find((p) => config.masterPane && normalizePaneTitle(p.title) === config.masterPane) ??
766
+ panes.find((p) => isIdleForDispatch(p));
767
+
768
+ if (!plannerPane) {
769
+ appendEvent(config.dir, {
770
+ timestamp: new Date().toISOString(),
771
+ type: "error",
772
+ message: "Unable to dispatch mission planning: no planner pane available",
773
+ });
774
+ return;
775
+ }
776
+
777
+ state.claimedTasks.add(PLANNING_CLAIM);
778
+
779
+ const prompt = buildPlanningPrompt(config.dir);
780
+ const dispatchDir = join(config.dir, ".tasks", "dispatch");
781
+ if (!existsSync(dispatchDir)) mkdirSync(dispatchDir, { recursive: true });
782
+ const dispatchFile = join(dispatchDir, "planning.md");
783
+ writeFileSync(dispatchFile, prompt);
784
+
785
+ sendCommand(config.session, plannerPane.id, `Read and execute: .tasks/dispatch/planning.md`);
786
+
787
+ appendEvent(config.dir, {
788
+ timestamp: new Date().toISOString(),
789
+ type: "planning",
790
+ target: plannerPane.title,
791
+ message: `Dispatched mission planning to ${plannerPane.title}`,
792
+ });
793
+
794
+ state.lastActivity.set(plannerPane.id, Date.now());
795
+ }
796
+
797
+ // --- Mission completion ---
798
+
799
+ /**
800
+ * Handle mission completion: all milestones done and all validations passing.
801
+ */
802
+ export function handleMissionComplete(
803
+ config: OrchestratorConfig,
804
+ mission: Mission,
805
+ panes: PaneInfo[],
806
+ ): void {
807
+ if (!mission.milestones.every((milestone) => milestone.status === "done")) return;
808
+
809
+ mission.status = "complete";
810
+ mission.updated = new Date().toISOString();
811
+ saveMission(config.dir, mission);
812
+
813
+ // Try to create a mission-level PR
814
+ let prResult: { url: string; number: number } | null = null;
815
+ if (mission.branch && isGhAvailable()) {
816
+ prResult = createMissionPr(mission, config.dir);
817
+ }
818
+
819
+ // Build completion summary
820
+ const totalMilestones = mission.milestones.length;
821
+ const tasks = loadTasks(config.dir);
822
+ const doneTasks = tasks.filter((t) => t.status === "done").length;
823
+
824
+ let summary = `# Mission Complete: ${mission.title}\n\n`;
825
+ summary += `Milestones: ${totalMilestones} completed\n`;
826
+ summary += `Tasks: ${doneTasks}/${tasks.length} done\n`;
827
+ if (prResult) summary += `PR: ${prResult.url}\n`;
828
+ summary += `\nMission "${mission.title}" has been delivered.`;
829
+
830
+ // Write dispatch file and notify master
831
+ const dispatchDir = join(config.dir, ".tasks", "dispatch");
832
+ if (!existsSync(dispatchDir)) mkdirSync(dispatchDir, { recursive: true });
833
+ writeFileSync(join(dispatchDir, "mission-complete.md"), summary);
834
+
835
+ if (config.masterPane) {
836
+ const masterPane =
837
+ panes.find((p) => p.role === "lead") ?? panes.find((p) => p.title === config.masterPane);
838
+ if (masterPane) {
839
+ sendCommand(
840
+ config.session,
841
+ masterPane.id,
842
+ `Mission complete: "${mission.title}". Run: tmux-ide dispatch mission-complete`,
843
+ );
844
+ }
845
+ }
846
+
847
+ // Record mission in history for cross-mission comparison
848
+ appendMissionHistory(config.dir, mission, tasks);
849
+
850
+ appendEvent(config.dir, {
851
+ timestamp: new Date().toISOString(),
852
+ type: "mission_complete",
853
+ title: mission.title,
854
+ milestoneCount: totalMilestones,
855
+ taskCount: doneTasks,
856
+ ...(prResult ? { prNumber: prResult.number } : {}),
857
+ message: `Mission "${mission.title}" completed — ${totalMilestones} milestones, ${doneTasks} tasks${prResult ? `, PR #${prResult.number}` : ""}`,
858
+ });
859
+ }
860
+
861
+ export function dispatch(
862
+ config: OrchestratorConfig,
863
+ state: OrchestratorState,
864
+ tasks: Task[],
865
+ panes: PaneInfo[],
866
+ ): void {
867
+ // Coverage check: warn about unclaimed assertions (once per session)
868
+ if (config.dispatchMode === "missions" && !state.claimedTasks.has("__coverage_warned__")) {
869
+ const { unclaimed } = checkCoverage(config.dir);
870
+ if (unclaimed.length > 0) {
871
+ state.claimedTasks.add("__coverage_warned__");
872
+ appendEvent(config.dir, {
873
+ timestamp: new Date().toISOString(),
874
+ type: "error",
875
+ message: `Coverage gap: ${unclaimed.length} assertion(s) not claimed by any task: ${unclaimed.join(", ")}`,
876
+ });
877
+ }
878
+ }
879
+
880
+ // Find idle agent panes (not the master pane, not already assigned a task)
881
+ const idleAgents = panes.filter((p) => {
882
+ // Master pane check: use role when available, fall back to title
883
+ if (p.role === "lead") return false;
884
+ if (!p.role && config.masterPane && normalizePaneTitle(p.title) === config.masterPane)
885
+ return false;
886
+ if (!isIdleForDispatch(p)) return false;
887
+ // Don't dispatch to a pane that already has an in-progress task assigned
888
+ const name = agentIdentifier(p);
889
+ const hasActiveTask = tasks.some(
890
+ (t) => t.assignee === name && (t.status === "in-progress" || t.status === "review"),
891
+ );
892
+ if (hasActiveTask) return false;
893
+ return true;
894
+ });
895
+
896
+ // Concurrency control: don't exceed max concurrent agents
897
+ const maxConcurrent = config.maxConcurrentAgents ?? 10;
898
+ const inProgressCount = tasks.filter((t) => t.status === "in-progress").length;
899
+ const availableSlots = Math.max(0, maxConcurrent - inProgressCount);
900
+ if (availableSlots === 0) return;
901
+
902
+ // Find unassigned todo tasks that haven't been claimed, sorted by priority
903
+ // Also include retry-eligible tasks (nextRetryAt in the past)
904
+ const now = Date.now();
905
+ const mission = config.dispatchMode === "missions" ? loadMission(config.dir) : null;
906
+ const todoTasks = tasks
907
+ .filter((t) => {
908
+ // Skip if already claimed
909
+ if (state.claimedTasks.has(t.id)) return false;
910
+ // Regular todo tasks
911
+ if (t.status === "todo" && !t.assignee) {
912
+ // Skip if waiting for scheduled retry (nextRetryAt is in the future)
913
+ if (t.nextRetryAt && new Date(t.nextRetryAt).getTime() > now) return false;
914
+ // Check dependencies: all depends_on tasks must be done
915
+ if (t.depends_on && t.depends_on.length > 0) {
916
+ const allDepsDone = t.depends_on.every((depId) => {
917
+ const dep = tasks.find((d) => d.id === depId);
918
+ return dep?.status === "done";
919
+ });
920
+ if (!allDepsDone) return false;
921
+ }
922
+ // Milestone gating (missions mode only)
923
+ if (
924
+ config.dispatchMode === "missions" &&
925
+ !isTaskMilestoneEligible(config.dir, t, mission)
926
+ ) {
927
+ return false;
928
+ }
929
+ return true;
930
+ }
931
+ // Retry-eligible tasks (failed with nextRetryAt in the past)
932
+ if (t.nextRetryAt && t.retryCount < (t.maxRetries ?? 5)) {
933
+ const retryTime = new Date(t.nextRetryAt).getTime();
934
+ if (retryTime <= now) return true;
935
+ }
936
+ return false;
937
+ })
938
+ .sort((a, b) => a.priority - b.priority);
939
+
940
+ const assignedAgents = new Set<string>();
941
+ let dispatched = 0;
942
+ for (const task of todoTasks) {
943
+ if (dispatched >= availableSlots) break;
944
+ const agent = findBestAgent(config, idleAgents, task, assignedAgents);
945
+ if (!agent) continue; // no suitable agent — skip (specialist task waits)
946
+
947
+ // Verify the target pane still exists before dispatch
948
+ const currentPanes = listSessionPanes(config.session);
949
+ if (!currentPanes.find((p) => p.id === agent.id)) {
950
+ // Agent pane vanished — skip
951
+ continue;
952
+ }
953
+
954
+ const agentName = agentIdentifier(agent);
955
+
956
+ // Persist task as in-progress FIRST — prevents double-dispatch on crash
957
+ task.status = "in-progress";
958
+ task.assignee = agentName;
959
+ task.nextRetryAt = null; // Clear retry schedule on dispatch
960
+ task.updated = new Date().toISOString();
961
+ saveTask(config.dir, task);
962
+
963
+ // Claim lock: prevent double-dispatch across poll ticks
964
+ state.claimedTasks.add(task.id);
965
+
966
+ // Run before_run hook in project directory — abort dispatch for this task on failure
967
+ if (config.beforeRun) {
968
+ const result = runHook(config.beforeRun, config.dir);
969
+ if (!result.ok) {
970
+ task.status = "todo";
971
+ task.assignee = null;
972
+ task.updated = new Date().toISOString();
973
+ saveTask(config.dir, task);
974
+ state.claimedTasks.delete(task.id);
975
+ continue;
976
+ }
977
+ }
978
+
979
+ // Record claim time for cost tracking
980
+ state.taskClaimTimes.set(task.id, Date.now());
981
+
982
+ // Write the full prompt to a dispatch file and send a short command
983
+ // telling the agent to read it. This avoids the paste preview problem
984
+ // entirely — short commands (<200 chars) never trigger it.
985
+ const prompt = buildTaskPrompt(config.dir, task, config);
986
+ const dispatchDir = join(config.dir, ".tasks", "dispatch");
987
+ if (!existsSync(dispatchDir)) mkdirSync(dispatchDir, { recursive: true });
988
+ // Validate task ID to prevent path traversal
989
+ if (!/^\d{3,}$/.test(task.id)) {
990
+ task.status = "todo";
991
+ task.assignee = null;
992
+ task.updated = new Date().toISOString();
993
+ saveTask(config.dir, task);
994
+ state.claimedTasks.delete(task.id);
995
+ continue;
996
+ }
997
+ const dispatchFile = join(dispatchDir, `${task.id}.md`);
998
+ writeFileSync(dispatchFile, prompt);
999
+
1000
+ const shortCmd = `tmux-ide dispatch ${task.id}`;
1001
+ const sent = sendCommand(config.session, agent.id, shortCmd);
1002
+
1003
+ if (!sent) {
1004
+ // Roll back: reset task to todo, remove claim
1005
+ task.status = "todo";
1006
+ task.assignee = null;
1007
+ task.updated = new Date().toISOString();
1008
+ saveTask(config.dir, task);
1009
+ state.claimedTasks.delete(task.id);
1010
+ state.taskClaimTimes.delete(task.id);
1011
+
1012
+ appendEvent(config.dir, {
1013
+ timestamp: new Date().toISOString(),
1014
+ type: "error",
1015
+ taskId: task.id,
1016
+ agent: agent.title,
1017
+ message: `Failed to send command to ${agent.title} for task "${task.title}" — rolled back`,
1018
+ });
1019
+ continue;
1020
+ }
1021
+
1022
+ // Log dispatch event
1023
+ appendEvent(config.dir, {
1024
+ timestamp: new Date().toISOString(),
1025
+ type: "dispatch",
1026
+ taskId: task.id,
1027
+ agent: agent.title,
1028
+ message: `Dispatched "${task.title}" to ${agent.title}`,
1029
+ });
1030
+
1031
+ // Track agent assignment to prevent double-assignment
1032
+ assignedAgents.add(agent.id);
1033
+ dispatched++;
1034
+
1035
+ // Track activity
1036
+ state.lastActivity.set(agent.id, Date.now());
1037
+ }
1038
+ }
1039
+
1040
+ // --- Skill-based agent matching ---
1041
+
1042
+ function matchesSpecialty(specialty: string | null, agentSpecialties: string[]): boolean {
1043
+ if (!specialty) return true; // no specialty = any agent
1044
+ if (agentSpecialties.length === 0) return true; // no agent specialty = takes anything
1045
+ const tags = specialty.split(",").map((s) => s.trim().toLowerCase());
1046
+ return tags.some((tag) => agentSpecialties.includes(tag));
1047
+ }
1048
+
1049
+ /**
1050
+ * Find the best available agent for a task based on specialty matching.
1051
+ *
1052
+ * Priority: specialist match > generalist (no specialties) > null.
1053
+ * Tasks with no specialty go to the first available agent.
1054
+ */
1055
+ export function findBestAgent(
1056
+ config: OrchestratorConfig,
1057
+ idleAgents: PaneInfo[],
1058
+ task: Task,
1059
+ excludeIds: Set<string>,
1060
+ ): PaneInfo | null {
1061
+ const candidates = idleAgents.filter((p) => !excludeIds.has(p.id));
1062
+ if (candidates.length === 0) return null;
1063
+
1064
+ // No specialty required — any idle agent works
1065
+ if (!task.specialty) return candidates[0] ?? null;
1066
+
1067
+ // Prefer agents whose specialties match the task
1068
+ const specialist = candidates.find((p) => {
1069
+ const specs = getPaneSpecialties(config, p);
1070
+ return specs.length > 0 && matchesSpecialty(task.specialty, specs);
1071
+ });
1072
+ if (specialist) return specialist;
1073
+
1074
+ // Fall back to generalist agents (no specialties defined)
1075
+ const generalist = candidates.find((p) => {
1076
+ const specs = getPaneSpecialties(config, p);
1077
+ return specs.length === 0;
1078
+ });
1079
+ return generalist ?? null;
1080
+ }
1081
+
1082
+ // --- Goal-level dispatch (planner agents) ---
1083
+
1084
+ export function getPaneSpecialties(config: OrchestratorConfig, pane: PaneInfo): string[] {
1085
+ const key = pane.name ?? pane.title;
1086
+ return config.paneSpecialties.get(key) ?? [];
1087
+ }
1088
+
1089
+ function isPlannerPane(config: OrchestratorConfig, pane: PaneInfo): boolean {
1090
+ const key = pane.name ?? pane.title;
1091
+ if (config.paneSpecialties.has(key)) return true;
1092
+ if (pane.role === "lead") return false;
1093
+ return isAgentPane(pane);
1094
+ }
1095
+
1096
+ export function buildGoalPrompt(dir: string, goal: Goal, planner: PaneInfo): string {
1097
+ const mission = loadMission(dir);
1098
+ const name = agentIdentifier(planner);
1099
+ const specialtyLabel = goal.specialty ?? "general";
1100
+
1101
+ let prompt = `You are ${name}, a ${specialtyLabel} planner.\n\n`;
1102
+
1103
+ if (mission) {
1104
+ prompt += `## Mission: ${mission.title}\n`;
1105
+ if (mission.description) prompt += `${mission.description}\n`;
1106
+ prompt += "\n";
1107
+
1108
+ // Milestone context
1109
+ if (goal.milestone) {
1110
+ const milestone = mission.milestones.find((m) => m.id === goal.milestone);
1111
+ if (milestone) {
1112
+ prompt += `## Milestone: ${milestone.title} (${milestone.id})\n`;
1113
+ if (milestone.description) prompt += `${milestone.description}\n`;
1114
+ prompt += "\n";
1115
+ }
1116
+ }
1117
+ }
1118
+
1119
+ // AGENTS.md — project boundaries
1120
+ const agentsMdPath = join(dir, "AGENTS.md");
1121
+ const agentsContent = loadLibraryExcerpt(agentsMdPath, 1000);
1122
+ if (agentsContent) {
1123
+ prompt += `## Agent Guidelines\n${agentsContent}\n\n`;
1124
+ }
1125
+
1126
+ prompt += `Your Goal: ${goal.title}\n`;
1127
+ if (goal.acceptance) prompt += `Acceptance Criteria: ${goal.acceptance}\n`;
1128
+ prompt += `Priority: P${goal.priority}\n\n`;
1129
+
1130
+ prompt += `Instructions:\n`;
1131
+ prompt += `1. Read plans/MISSION.md for architectural context (if it exists)\n`;
1132
+ prompt += `2. Create your plan file: plans/goal-${goal.id}-${slugify(goal.title, 30)}.md\n`;
1133
+ prompt += ` Include: analysis, approach, task breakdown, risks\n`;
1134
+ prompt += `3. Create tasks for your goal:\n`;
1135
+ prompt += ` tmux-ide task create "task title" --goal ${goal.id} --priority N\n`;
1136
+ prompt += `4. Execute tasks using Claude Code subagents for parallel work\n`;
1137
+ prompt += `5. Update your plan file as tasks complete\n`;
1138
+ prompt += `6. Review all completed work for quality\n`;
1139
+ prompt += `7. When done: tmux-ide goal done ${goal.id} --proof "summary of what was accomplished"\n\n`;
1140
+ prompt += `You own this goal end-to-end. Plan it, execute it, deliver it.\n`;
1141
+
1142
+ return prompt.trim();
1143
+ }
1144
+
1145
+ export function dispatchGoals(
1146
+ config: OrchestratorConfig,
1147
+ state: OrchestratorState,
1148
+ goals: Goal[],
1149
+ tasks: Task[],
1150
+ panes: PaneInfo[],
1151
+ ): void {
1152
+ // Find idle planner panes (not the master pane, not already assigned)
1153
+ const idlePlanners = panes.filter((p) => {
1154
+ if (p.index === 0) return false;
1155
+ if (p.role === "lead") return false;
1156
+ if (!p.role && config.masterPane && normalizePaneTitle(p.title) === config.masterPane)
1157
+ return false;
1158
+ if (!isPlannerPane(config, p)) return false;
1159
+ if (!isIdleForDispatch(p)) return false;
1160
+ const name = agentIdentifier(p);
1161
+ const hasActiveGoal = goals.some((g) => g.assignee === name && g.status === "in-progress");
1162
+ if (hasActiveGoal) return false;
1163
+ return true;
1164
+ });
1165
+
1166
+ // Find unassigned goals sorted by priority
1167
+ const todoGoals = goals
1168
+ .filter((g) => g.status === "todo" && !g.assignee)
1169
+ .sort((a, b) => a.priority - b.priority);
1170
+
1171
+ for (const planner of idlePlanners) {
1172
+ const plannerSpecs = getPaneSpecialties(config, planner);
1173
+
1174
+ // Find best matching goal
1175
+ const goal = todoGoals.find((g) => matchesSpecialty(g.specialty, plannerSpecs));
1176
+ if (!goal) continue;
1177
+
1178
+ // Remove from candidates
1179
+ todoGoals.splice(todoGoals.indexOf(goal), 1);
1180
+
1181
+ // Assign goal to planner
1182
+ goal.assignee = agentIdentifier(planner);
1183
+ goal.status = "in-progress";
1184
+ goal.updated = new Date().toISOString();
1185
+ saveGoal(config.dir, goal);
1186
+
1187
+ // Write goal prompt to file and send short command
1188
+ const prompt = buildGoalPrompt(config.dir, goal, planner);
1189
+ const goalDispatchDir = join(config.dir, ".tasks", "dispatch");
1190
+ if (!existsSync(goalDispatchDir)) mkdirSync(goalDispatchDir, { recursive: true });
1191
+ const goalDispatchFile = join(goalDispatchDir, `goal-${goal.id}.md`);
1192
+ writeFileSync(goalDispatchFile, prompt);
1193
+ sendCommand(
1194
+ config.session,
1195
+ planner.id,
1196
+ `Read and execute the goal in .tasks/dispatch/goal-${goal.id}.md`,
1197
+ );
1198
+
1199
+ // Log event
1200
+ appendEvent(config.dir, {
1201
+ timestamp: new Date().toISOString(),
1202
+ type: "dispatch",
1203
+ taskId: goal.id,
1204
+ agent: planner.title,
1205
+ message: `Dispatched goal "${goal.title}" to ${planner.title}`,
1206
+ });
1207
+
1208
+ state.lastActivity.set(planner.id, Date.now());
1209
+ }
1210
+ }
1211
+
1212
+ export function detectStalls(
1213
+ config: OrchestratorConfig,
1214
+ state: OrchestratorState,
1215
+ tasks: Task[],
1216
+ panes: PaneInfo[],
1217
+ researchState?: ResearchState,
1218
+ ): void {
1219
+ const now = Date.now();
1220
+ for (const task of tasks.filter((t) => t.status === "in-progress" && t.assignee)) {
1221
+ if (
1222
+ task.tags.includes("research") &&
1223
+ (!researchState?.activeResearchTaskId || researchState.activeResearchTaskId !== task.id)
1224
+ ) {
1225
+ continue;
1226
+ }
1227
+
1228
+ const agentPane = panes.find((p) => agentIdentifier(p) === task.assignee);
1229
+ if (!agentPane) continue;
1230
+
1231
+ const lastSeen = state.lastActivity.get(agentPane.id) ?? now;
1232
+ const elapsed = now - lastSeen;
1233
+
1234
+ if (elapsed > config.stallTimeout) {
1235
+ sendCommand(
1236
+ config.session,
1237
+ agentPane.id,
1238
+ `You've been working on "${task.title}" for ${Math.floor(elapsed / 60000)} minutes. ` +
1239
+ `If done, run: tmux-ide task done ${task.id} --proof "describe what you did". ` +
1240
+ `If stuck, run: tmux-ide task update ${task.id} --status review`,
1241
+ );
1242
+ appendEvent(config.dir, {
1243
+ timestamp: new Date().toISOString(),
1244
+ type: "stall",
1245
+ taskId: task.id,
1246
+ agent: task.assignee!,
1247
+ message: `Stall detected: "${task.title}" (${Math.floor(elapsed / 60000)}m)`,
1248
+ });
1249
+ state.lastActivity.set(agentPane.id, now);
1250
+ }
1251
+ }
1252
+ }
1253
+
1254
+ export function detectCompletions(
1255
+ config: OrchestratorConfig,
1256
+ state: OrchestratorState,
1257
+ tasks: Task[],
1258
+ panes: PaneInfo[],
1259
+ researchState?: ResearchState,
1260
+ ): void {
1261
+ for (const task of tasks) {
1262
+ const prev = state.previousTasks.get(task.id);
1263
+
1264
+ // Task was explicitly marked as review while in-progress — treat as failure, schedule retry
1265
+ if (prev === "in-progress" && task.status === "review" && task.lastError) {
1266
+ state.taskClaimTimes.delete(task.id);
1267
+ const retried = scheduleRetry(config.dir, state, task, task.lastError);
1268
+
1269
+ // Notify master if retries exhausted
1270
+ if (!retried && config.masterPane) {
1271
+ const masterPaneInfo =
1272
+ panes.find((p) => p.role === "lead") ?? panes.find((p) => p.title === config.masterPane);
1273
+ if (masterPaneInfo) {
1274
+ const retryDispDir = join(config.dir, ".tasks", "dispatch");
1275
+ if (!existsSync(retryDispDir)) mkdirSync(retryDispDir, { recursive: true });
1276
+ const msgFile = join(retryDispDir, `retry-exhausted-${task.id}.md`);
1277
+ const msg = `# Task Failed: ${task.title}\n\nFailed after ${task.maxRetries ?? 5} retries.\nLast error: ${task.lastError}\n\nManual intervention required.`;
1278
+ writeFileSync(msgFile, msg);
1279
+ // Keep under 200 chars to avoid paste preview in Claude Code TUI
1280
+ sendCommand(
1281
+ config.session,
1282
+ masterPaneInfo.id,
1283
+ `Task ${task.id} failed after retries. Run: tmux-ide dispatch retry-exhausted-${task.id}`,
1284
+ );
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ if (prev === "in-progress" && task.status === "done") {
1290
+ // Record elapsed time for cost tracking
1291
+ const claimTime = state.taskClaimTimes.get(task.id);
1292
+ const elapsedMs = claimTime ? Date.now() - claimTime : undefined;
1293
+ if (claimTime && task.assignee) {
1294
+ recordTaskTime(config.dir, task.assignee, task.id, elapsedMs!);
1295
+ }
1296
+ state.taskClaimTimes.delete(task.id);
1297
+
1298
+ // Clear claim lock
1299
+ state.claimedTasks.delete(task.id);
1300
+
1301
+ appendEvent(config.dir, {
1302
+ timestamp: new Date().toISOString(),
1303
+ type: "completion",
1304
+ taskId: task.id,
1305
+ agent: task.assignee ?? undefined,
1306
+ durationMs: elapsedMs,
1307
+ message: `Completed "${task.title}" by ${task.assignee ?? "unknown"}${task.salientSummary ? ` — ${task.salientSummary}` : ""}`,
1308
+ } as OrchestratorEvent & { durationMs?: number });
1309
+
1310
+ if (task.tags.includes("research") && researchState) {
1311
+ processResearchCompletion(config, researchState, task);
1312
+ }
1313
+
1314
+ // Append salient summary to knowledge library
1315
+ if (task.salientSummary) {
1316
+ try {
1317
+ const libraryDir = join(config.dir, ".tmux-ide", "library");
1318
+ if (!existsSync(libraryDir)) mkdirSync(libraryDir, { recursive: true });
1319
+ const learningsPath = join(libraryDir, "learnings.md");
1320
+ const entry = `## Task ${task.id}: ${task.title}\n${task.salientSummary}\n---\n\n`;
1321
+ appendFileSync(learningsPath, entry);
1322
+ } catch (err: unknown) {
1323
+ const msg = err instanceof Error ? err.message : String(err);
1324
+ console.error(`[orchestrator] Failed to write learnings.md: ${msg}`);
1325
+ appendEvent(config.dir, {
1326
+ timestamp: new Date().toISOString(),
1327
+ type: "error",
1328
+ taskId: task.id,
1329
+ message: `Failed to append salientSummary to learnings.md: ${msg}`,
1330
+ });
1331
+ }
1332
+ }
1333
+
1334
+ // Structured handoff: auto-create follow-up tasks for discovered issues
1335
+ if (task.discoveredIssues && task.discoveredIssues.length > 0) {
1336
+ for (const issue of task.discoveredIssues) {
1337
+ const followUpId = nextTaskId(config.dir);
1338
+ const now = new Date().toISOString();
1339
+ const followUp: Task = {
1340
+ id: followUpId,
1341
+ title: issue.length > 80 ? issue.slice(0, 77) + "..." : issue,
1342
+ description: `Discovered during task ${task.id} ("${task.title}"):\n\n${issue}`,
1343
+ goal: task.goal,
1344
+ status: "todo",
1345
+ assignee: null,
1346
+ priority: 2,
1347
+ created: now,
1348
+ updated: now,
1349
+ tags: ["discovered-issue"],
1350
+ proof: null,
1351
+ depends_on: [],
1352
+ retryCount: 0,
1353
+ maxRetries: 5,
1354
+ lastError: null,
1355
+ nextRetryAt: null,
1356
+ milestone: task.milestone,
1357
+ specialty: null,
1358
+ fulfills: [],
1359
+ discoveredIssues: [],
1360
+ salientSummary: null,
1361
+ };
1362
+ saveTask(config.dir, followUp);
1363
+
1364
+ appendEvent(config.dir, {
1365
+ timestamp: new Date().toISOString(),
1366
+ type: "discovered_issue",
1367
+ taskId: followUpId,
1368
+ sourceTaskId: task.id,
1369
+ issue,
1370
+ message: `Auto-created follow-up task ${followUpId} from ${task.id}: ${issue}`,
1371
+ });
1372
+ }
1373
+ }
1374
+
1375
+ // Run after_run hook (failure is logged but ignored)
1376
+ if (config.afterRun) {
1377
+ runHook(config.afterRun, config.dir);
1378
+ }
1379
+
1380
+ if (config.masterPane) {
1381
+ const masterPaneInfo =
1382
+ panes.find((p) => p.role === "lead") ?? panes.find((p) => p.title === config.masterPane);
1383
+ if (masterPaneInfo) {
1384
+ const proofStr = task.proof ? JSON.stringify(task.proof, null, 2) : "no proof provided";
1385
+ const dispDir = join(config.dir, ".tasks", "dispatch");
1386
+ if (!existsSync(dispDir)) mkdirSync(dispDir, { recursive: true });
1387
+ const msgFile = join(dispDir, `completed-${task.id}.md`);
1388
+ const msg = `# Task Completed: ${task.title}\n\nBy: ${task.assignee}\nProof:\n${proofStr}\n\nReview and approve, or request changes.`;
1389
+ writeFileSync(msgFile, msg);
1390
+ // Keep under 200 chars to avoid paste preview in Claude Code TUI
1391
+ const shortTitle = task.title.length > 60 ? task.title.slice(0, 57) + "..." : task.title;
1392
+ sendCommand(
1393
+ config.session,
1394
+ masterPaneInfo.id,
1395
+ `Task done: "${shortTitle}" by ${task.assignee}. Run: tmux-ide dispatch completed-${task.id}`,
1396
+ );
1397
+ }
1398
+ }
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ /**
1404
+ * Schedule a failed task for retry with exponential backoff.
1405
+ *
1406
+ * Backoff formula: 10s * 2^attempt, capped at 300s (5 minutes).
1407
+ * Max 5 retries by default (configurable per task via maxRetries).
1408
+ *
1409
+ * Returns true if retry was scheduled, false if max retries exceeded.
1410
+ */
1411
+ export function scheduleRetry(
1412
+ dir: string,
1413
+ state: OrchestratorState,
1414
+ task: Task,
1415
+ reason: string,
1416
+ ): boolean {
1417
+ const maxRetries = task.maxRetries ?? 5;
1418
+
1419
+ if (task.retryCount >= maxRetries) {
1420
+ // Max retries exceeded — mark for review
1421
+ task.status = "review";
1422
+ task.lastError = reason;
1423
+ task.assignee = null;
1424
+ task.updated = new Date().toISOString();
1425
+ saveTask(dir, task);
1426
+ state.claimedTasks.delete(task.id);
1427
+
1428
+ appendEvent(dir, {
1429
+ timestamp: new Date().toISOString(),
1430
+ type: "error",
1431
+ taskId: task.id,
1432
+ message: `Max retries (${maxRetries}) exceeded for "${task.title}": ${reason}`,
1433
+ });
1434
+
1435
+ return false;
1436
+ }
1437
+
1438
+ // Exponential backoff: 10s * 2^attempt, capped at 300s
1439
+ const backoffMs = Math.min(10_000 * Math.pow(2, task.retryCount), 300_000);
1440
+ const nextRetryAt = new Date(Date.now() + backoffMs).toISOString();
1441
+
1442
+ task.retryCount += 1;
1443
+ task.lastError = reason;
1444
+ task.nextRetryAt = nextRetryAt;
1445
+ task.status = "todo";
1446
+ task.assignee = null;
1447
+ task.updated = new Date().toISOString();
1448
+ saveTask(dir, task);
1449
+ state.claimedTasks.delete(task.id);
1450
+
1451
+ appendEvent(dir, {
1452
+ timestamp: new Date().toISOString(),
1453
+ type: "retry",
1454
+ taskId: task.id,
1455
+ message: `Retry ${task.retryCount}/${maxRetries} scheduled for "${task.title}" in ${backoffMs / 1000}s: ${reason}`,
1456
+ });
1457
+
1458
+ return true;
1459
+ }
1460
+
1461
+ export function reconcile(
1462
+ config: OrchestratorConfig,
1463
+ state: OrchestratorState,
1464
+ tasks: Task[],
1465
+ panes: PaneInfo[],
1466
+ ): void {
1467
+ const agentIds = new Set(panes.map((p) => agentIdentifier(p)));
1468
+
1469
+ for (const task of tasks.filter((t) => t.status === "in-progress" && t.assignee)) {
1470
+ // Check if the assigned agent's pane still exists
1471
+ if (!agentIds.has(task.assignee!)) {
1472
+ const agent = task.assignee!;
1473
+ const reason = `Agent "${agent}" vanished (pane closed or crashed)`;
1474
+
1475
+ // Schedule retry with backoff instead of immediate re-queue
1476
+ scheduleRetry(config.dir, state, task, reason);
1477
+
1478
+ appendEvent(config.dir, {
1479
+ timestamp: new Date().toISOString(),
1480
+ type: "reconcile",
1481
+ taskId: task.id,
1482
+ agent,
1483
+ message: `Agent "${agent}" vanished, scheduled retry for "${task.title}"`,
1484
+ });
1485
+ }
1486
+ }
1487
+ }
1488
+
1489
+ // --- Orchestrator state persistence ---
1490
+
1491
+ interface PersistedState {
1492
+ claimedTasks: string[];
1493
+ taskClaimTimes: Record<string, number>;
1494
+ lastActivity?: Record<string, number>;
1495
+ }
1496
+
1497
+ function stateFilePath(dir: string): string {
1498
+ return join(dir, ".tasks", "orchestrator-state.json");
1499
+ }
1500
+
1501
+ export function saveOrchestratorState(dir: string, state: OrchestratorState): void {
1502
+ const tasksDir = join(dir, ".tasks");
1503
+ if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
1504
+ const data: PersistedState = {
1505
+ claimedTasks: [...state.claimedTasks],
1506
+ taskClaimTimes: Object.fromEntries(state.taskClaimTimes),
1507
+ lastActivity: Object.fromEntries(state.lastActivity),
1508
+ };
1509
+ // Atomic write: write to temp file, then rename
1510
+ const filePath = stateFilePath(dir);
1511
+ const tmpPath = filePath + ".tmp";
1512
+ writeFileSync(tmpPath, JSON.stringify(data, null, 2) + "\n");
1513
+ renameSync(tmpPath, filePath);
1514
+ }
1515
+
1516
+ export function loadOrchestratorState(dir: string, state: OrchestratorState): void {
1517
+ const path = stateFilePath(dir);
1518
+ if (!existsSync(path)) return;
1519
+ try {
1520
+ const data = JSON.parse(readFileSync(path, "utf-8")) as PersistedState;
1521
+ if (Array.isArray(data.claimedTasks)) {
1522
+ for (const id of data.claimedTasks) state.claimedTasks.add(id);
1523
+ }
1524
+ if (data.taskClaimTimes && typeof data.taskClaimTimes === "object") {
1525
+ for (const [id, time] of Object.entries(data.taskClaimTimes)) {
1526
+ state.taskClaimTimes.set(id, time);
1527
+ }
1528
+ }
1529
+ if (data.lastActivity && typeof data.lastActivity === "object") {
1530
+ for (const [id, time] of Object.entries(data.lastActivity)) {
1531
+ state.lastActivity.set(id, time);
1532
+ }
1533
+ }
1534
+ } catch {
1535
+ // Corrupted state file — start fresh
1536
+ }
1537
+ }
1538
+
1539
+ /**
1540
+ * Sync claimedTasks with the actual task store on startup.
1541
+ * Removes stale claims (task no longer in-progress) and adds missing claims
1542
+ * (task is in-progress but not in claimedTasks). Prevents stale claims from
1543
+ * blocking dispatch after a crash or manual state reset.
1544
+ */
1545
+ export function syncClaims(dir: string, state: OrchestratorState): void {
1546
+ const tasks = loadTasks(dir);
1547
+ const inProgressIds = new Set(tasks.filter((t) => t.status === "in-progress").map((t) => t.id));
1548
+ // Remove stale claims
1549
+ for (const id of state.claimedTasks) {
1550
+ if (!inProgressIds.has(id)) {
1551
+ state.claimedTasks.delete(id);
1552
+ }
1553
+ }
1554
+ // Add missing claims
1555
+ for (const id of inProgressIds) {
1556
+ state.claimedTasks.add(id);
1557
+ }
1558
+ }
1559
+
1560
+ export function gracefulShutdown(config: OrchestratorConfig, state: OrchestratorState): void {
1561
+ // Release all in-progress tasks back to todo
1562
+ const tasks = loadTasks(config.dir);
1563
+ for (const task of tasks) {
1564
+ if (task.status === "in-progress" && task.assignee) {
1565
+ task.assignee = null;
1566
+ task.status = "todo";
1567
+ task.updated = new Date().toISOString();
1568
+ saveTask(config.dir, task);
1569
+ }
1570
+ }
1571
+
1572
+ // Emit session_end and save metrics
1573
+ appendEvent(config.dir, {
1574
+ timestamp: new Date().toISOString(),
1575
+ type: "session_end",
1576
+ message: `Orchestrator shutting down`,
1577
+ });
1578
+
1579
+ try {
1580
+ computeAndSaveMetrics(config.dir);
1581
+ } catch {
1582
+ // Metrics save failure should not prevent shutdown
1583
+ }
1584
+
1585
+ // Persist state for resume
1586
+ saveOrchestratorState(config.dir, state);
1587
+ }
1588
+
1589
+ /** Apply hot-reloadable fields from a partial config onto a live config. */
1590
+ export function reloadConfig(
1591
+ target: OrchestratorConfig,
1592
+ patch: Partial<
1593
+ Pick<
1594
+ OrchestratorConfig,
1595
+ | "pollInterval"
1596
+ | "stallTimeout"
1597
+ | "maxConcurrentAgents"
1598
+ | "autoDispatch"
1599
+ | "beforeRun"
1600
+ | "afterRun"
1601
+ | "research"
1602
+ >
1603
+ >,
1604
+ ): void {
1605
+ if (patch.pollInterval !== undefined) target.pollInterval = patch.pollInterval;
1606
+ if (patch.stallTimeout !== undefined) target.stallTimeout = patch.stallTimeout;
1607
+ if (patch.maxConcurrentAgents !== undefined)
1608
+ target.maxConcurrentAgents = patch.maxConcurrentAgents;
1609
+ if (patch.autoDispatch !== undefined) target.autoDispatch = patch.autoDispatch;
1610
+ if (patch.beforeRun !== undefined) target.beforeRun = patch.beforeRun;
1611
+ if (patch.afterRun !== undefined) target.afterRun = patch.afterRun;
1612
+ if (patch.research !== undefined) target.research = patch.research;
1613
+ }
1614
+
1615
+ export function createOrchestrator(initialConfig: OrchestratorConfig): () => void {
1616
+ // Mutable config — reloadConfig updates fields between ticks
1617
+ const config: OrchestratorConfig = { ...initialConfig };
1618
+
1619
+ const state: OrchestratorState = {
1620
+ lastActivity: new Map(),
1621
+ previousTasks: new Map(),
1622
+ claimedTasks: new Set(),
1623
+ taskClaimTimes: new Map(),
1624
+ };
1625
+ const researchState = loadResearchState(config.dir);
1626
+
1627
+ // Load persisted state from previous run
1628
+ loadOrchestratorState(config.dir, state);
1629
+
1630
+ // Sync claims with actual task store to clear stale claims after crash
1631
+ syncClaims(config.dir, state);
1632
+
1633
+ // Initialize previous state
1634
+ for (const task of loadTasks(config.dir)) {
1635
+ state.previousTasks.set(task.id, task.status);
1636
+ }
1637
+
1638
+ // Emit session_start
1639
+ appendEvent(config.dir, {
1640
+ timestamp: new Date().toISOString(),
1641
+ type: "session_start",
1642
+ message: `Orchestrator started — mode: ${config.dispatchMode}, max agents: ${config.maxConcurrentAgents}`,
1643
+ });
1644
+
1645
+ let tickCount = 0;
1646
+
1647
+ function tick(): void {
1648
+ tickCount++;
1649
+ let tasks = loadTasks(config.dir);
1650
+ const panes = listSessionPanes(config.session);
1651
+
1652
+ // 1. Reconcile: detect crashed agents, unassign their tasks
1653
+ reconcile(config, state, tasks, panes);
1654
+
1655
+ // 2. Auto-dispatch: assign tasks or goals to idle agents
1656
+ if (config.autoDispatch) {
1657
+ if (config.dispatchMode === "goals") {
1658
+ const goals = loadGoals(config.dir);
1659
+ dispatchGoals(config, state, goals, tasks, panes);
1660
+ } else if (config.dispatchMode === "missions") {
1661
+ // Mission lifecycle: planning → active → complete
1662
+ const mission = loadMission(config.dir);
1663
+ if (mission?.status === "planning" && mission.milestones.length === 0) {
1664
+ dispatchPlanning(config, state, panes);
1665
+ } else if (mission?.status === "active") {
1666
+ dispatch(config, state, tasks, panes);
1667
+ }
1668
+ } else {
1669
+ dispatch(config, state, tasks, panes);
1670
+ }
1671
+ }
1672
+
1673
+ // 3. Detect stalls: nudge agents that haven't produced output
1674
+ detectStalls(config, state, tasks, panes, researchState);
1675
+
1676
+ // 4. Detect completions: notify master, run hooks, cleanup
1677
+ detectCompletions(config, state, tasks, panes, researchState);
1678
+ tasks = loadTasks(config.dir);
1679
+
1680
+ if (config.dispatchMode === "missions" && config.research?.enabled) {
1681
+ const triggers = evaluateTriggers(
1682
+ config,
1683
+ state,
1684
+ researchState,
1685
+ tasks,
1686
+ readEvents(config.dir),
1687
+ );
1688
+ if (triggers.length > 0) {
1689
+ dispatchResearch(config, state, researchState, tasks, panes, triggers[0]);
1690
+ tasks = loadTasks(config.dir);
1691
+ }
1692
+ }
1693
+
1694
+ // 5. Milestone completion + validation flow + mission completion
1695
+ if (config.dispatchMode === "missions") {
1696
+ checkMilestoneCompletion(config, state, tasks, panes);
1697
+ checkValidationResults(config, tasks);
1698
+
1699
+ // Check if mission is complete: all milestones done
1700
+ const mission = loadMission(config.dir);
1701
+ if (mission?.status === "active" && mission.milestones.length > 0) {
1702
+ const allMilestonesDone = mission.milestones.every((m) => m.status === "done");
1703
+ if (allMilestonesDone) {
1704
+ handleMissionComplete(config, mission, panes);
1705
+ }
1706
+ }
1707
+ }
1708
+
1709
+ // Agent heartbeat every 6th tick (~30s at default 5s poll)
1710
+ if (tickCount % 6 === 0) {
1711
+ const agentPanes = panes.filter((p) => {
1712
+ if (p.role === "lead") return false;
1713
+ return isAgentPane(p);
1714
+ });
1715
+ if (agentPanes.length > 0) {
1716
+ const statuses = agentPanes
1717
+ .map((p) => `${agentIdentifier(p)}=${isAgentBusy(p) ? "busy" : "idle"}`)
1718
+ .join(", ");
1719
+ appendEvent(config.dir, {
1720
+ timestamp: new Date().toISOString(),
1721
+ type: "agent_heartbeat",
1722
+ message: `agents: ${statuses}`,
1723
+ });
1724
+ }
1725
+ }
1726
+
1727
+ // Update previous state
1728
+ for (const task of tasks) {
1729
+ state.previousTasks.set(task.id, task.status);
1730
+ }
1731
+ }
1732
+
1733
+ let interval = setInterval(tick, config.pollInterval);
1734
+
1735
+ // Watch ide.yml for config changes — apply new settings without restart
1736
+ let watcher: FSWatcher | null = null;
1737
+ const configPath = join(config.dir, "ide.yml");
1738
+ if (existsSync(configPath)) {
1739
+ let debounce: ReturnType<typeof setTimeout> | null = null;
1740
+ watcher = watch(configPath, () => {
1741
+ if (debounce) clearTimeout(debounce);
1742
+ debounce = setTimeout(() => {
1743
+ try {
1744
+ const raw = readFileSync(configPath, "utf-8");
1745
+ const parsed = yaml.load(raw) as Record<string, unknown>;
1746
+ const orch = (parsed.orchestrator ?? {}) as Record<string, unknown>;
1747
+
1748
+ const oldPollInterval = config.pollInterval;
1749
+
1750
+ reloadConfig(config, {
1751
+ pollInterval: (orch.poll_interval as number | undefined) ?? config.pollInterval,
1752
+ stallTimeout: (orch.stall_timeout as number | undefined) ?? config.stallTimeout,
1753
+ maxConcurrentAgents:
1754
+ (orch.max_concurrent_agents as number | undefined) ?? config.maxConcurrentAgents,
1755
+ autoDispatch: (orch.auto_dispatch as boolean | undefined) ?? config.autoDispatch,
1756
+ beforeRun: (orch.before_run as string | undefined) ?? config.beforeRun,
1757
+ afterRun: (orch.after_run as string | undefined) ?? config.afterRun,
1758
+ research: (orch.research as ResearchConfigShape | undefined) ?? config.research,
1759
+ });
1760
+
1761
+ // Restart interval if pollInterval changed
1762
+ if (config.pollInterval !== oldPollInterval) {
1763
+ clearInterval(interval);
1764
+ interval = setInterval(tick, config.pollInterval);
1765
+ }
1766
+ } catch {
1767
+ // Config file might be mid-write or invalid — ignore
1768
+ }
1769
+ }, 300);
1770
+ });
1771
+ }
1772
+
1773
+ // Graceful shutdown on SIGTERM/SIGINT
1774
+ function onSignal() {
1775
+ clearInterval(interval);
1776
+ if (watcher) watcher.close();
1777
+ gracefulShutdown(config, state);
1778
+ saveResearchState(config.dir, researchState);
1779
+ process.exit(0);
1780
+ }
1781
+
1782
+ process.on("SIGTERM", onSignal);
1783
+ process.on("SIGINT", onSignal);
1784
+
1785
+ return () => {
1786
+ clearInterval(interval);
1787
+ if (watcher) watcher.close();
1788
+ saveResearchState(config.dir, researchState);
1789
+ process.removeListener("SIGTERM", onSignal);
1790
+ process.removeListener("SIGINT", onSignal);
1791
+ };
1792
+ }