tmux-ide 1.2.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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  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.js → detect.ts} +31 -15
  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.js → inspect.ts} +95 -10
  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.js → output.ts} +18 -10
  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.js → session-options.ts} +11 -6
  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.js → sizes.ts} +4 -4
  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.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  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/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,2358 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ dispatch,
7
+ detectStalls,
8
+ detectCompletions,
9
+ buildTaskPrompt,
10
+ buildGoalPrompt,
11
+ runHook,
12
+ createOrchestrator,
13
+ isAgentPane,
14
+ isAgentBusy,
15
+ isIdleForDispatch,
16
+ saveOrchestratorState,
17
+ loadOrchestratorState,
18
+ syncClaims,
19
+ gracefulShutdown,
20
+ reloadConfig,
21
+ reconcile,
22
+ agentIdentifier,
23
+ normalizePaneTitle,
24
+ getPaneSpecialties,
25
+ dispatchGoals,
26
+ findBestAgent,
27
+ getCurrentMilestone,
28
+ isMilestoneReady,
29
+ checkMilestoneCompletion,
30
+ checkValidationResults,
31
+ buildValidationPrompt,
32
+ dispatchPlanning,
33
+ buildPlanningPrompt,
34
+ handleMissionComplete,
35
+ } from "./orchestrator.ts";
36
+ import { readEvents } from "./event-log.ts";
37
+ import { loadResearchState, saveResearchState } from "./research.ts";
38
+ import {
39
+ ensureTasksDir,
40
+ saveMission,
41
+ loadMission,
42
+ saveGoal,
43
+ saveTask,
44
+ loadTask,
45
+ loadTasks,
46
+ loadGoal,
47
+ type Goal,
48
+ type Mission,
49
+ } from "./task-store.ts";
50
+ import { loadValidationState, saveValidationState } from "./validation.ts";
51
+ import { _setExecutor, type PaneInfo } from "../widgets/lib/pane-comms.ts";
52
+ import {
53
+ makeTask,
54
+ makePane,
55
+ makeOrchestratorConfig,
56
+ makeOrchestratorState,
57
+ } from "../__tests__/support.ts";
58
+
59
+ let tmpDir: string;
60
+ let restoreTmux: () => void;
61
+ let tmuxCalls: { args: string[] }[];
62
+ let mockPanes: PaneInfo[];
63
+
64
+ beforeEach(() => {
65
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-orch-test-"));
66
+ ensureTasksDir(tmpDir);
67
+ tmuxCalls = [];
68
+ mockPanes = [];
69
+
70
+ restoreTmux = _setExecutor((_cmd: string, args: string[]) => {
71
+ tmuxCalls.push({ args });
72
+ // Mock list-panes to return mockPanes as tab-separated output
73
+ if (args[0] === "list-panes") {
74
+ return mockPanes
75
+ .map(
76
+ (p) =>
77
+ `${p.id}\t${p.index}\t${p.title}\t${p.currentCommand}\t${p.width}\t${p.height}\t${p.active ? "1" : "0"}\t${p.role ?? ""}\t${p.name ?? ""}\t${p.type ?? ""}`,
78
+ )
79
+ .join("\n");
80
+ }
81
+ return "";
82
+ });
83
+ });
84
+
85
+ afterEach(() => {
86
+ restoreTmux();
87
+ rmSync(tmpDir, { recursive: true, force: true });
88
+ });
89
+
90
+ describe("dispatch", () => {
91
+ it("assigns highest-priority task to idle agent", () => {
92
+ const task1 = makeTask({ id: "001", title: "Low priority", priority: 2 });
93
+ const task2 = makeTask({ id: "002", title: "High priority", priority: 1 });
94
+ saveTask(tmpDir, task1);
95
+ saveTask(tmpDir, task2);
96
+
97
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
98
+ const expectedName = agentIdentifier(pane);
99
+ const panes: PaneInfo[] = [pane];
100
+ mockPanes = panes;
101
+
102
+ const config = makeOrchestratorConfig(tmpDir);
103
+ const state = makeOrchestratorState();
104
+
105
+ dispatch(config, state, [task1, task2], panes);
106
+
107
+ // Task 002 (higher priority) should be assigned first
108
+ const assigned = loadTask(tmpDir, "002");
109
+ expect(assigned?.assignee).toBe(expectedName);
110
+ expect(assigned?.status).toBe("in-progress");
111
+ });
112
+
113
+ it("skips master pane", () => {
114
+ const task = makeTask();
115
+ saveTask(tmpDir, task);
116
+
117
+ const agentPane = makePane({ id: "%1", index: 1, title: "Agent 1", currentCommand: "zsh" });
118
+ const expectedName = agentIdentifier(agentPane);
119
+ const panes: PaneInfo[] = [
120
+ makePane({ id: "%0", index: 0, title: "Master", currentCommand: "zsh", role: "lead" }),
121
+ agentPane,
122
+ ];
123
+ mockPanes = panes;
124
+
125
+ const config = makeOrchestratorConfig(tmpDir);
126
+ const state = makeOrchestratorState();
127
+
128
+ dispatch(config, state, [task], panes);
129
+
130
+ const assigned = loadTask(tmpDir, "001");
131
+ expect(assigned?.assignee).toBe(expectedName);
132
+ });
133
+
134
+ it("does not assign when no idle agents", () => {
135
+ const task = makeTask();
136
+ saveTask(tmpDir, task);
137
+
138
+ // Agent pane is busy (spinner in title indicates active work)
139
+ const panes: PaneInfo[] = [
140
+ makePane({ id: "%1", title: "⠙ Agent 1", currentCommand: "claude" }),
141
+ ];
142
+ mockPanes = panes;
143
+
144
+ const config = makeOrchestratorConfig(tmpDir);
145
+ const state = makeOrchestratorState();
146
+
147
+ dispatch(config, state, [task], panes);
148
+
149
+ const notAssigned = loadTask(tmpDir, "001");
150
+ expect(notAssigned?.assignee).toBe(null);
151
+ });
152
+
153
+ it("does not assign when no todo tasks", () => {
154
+ const task = makeTask({ status: "in-progress", assignee: "Agent 1" });
155
+ saveTask(tmpDir, task);
156
+
157
+ const panes: PaneInfo[] = [makePane({ id: "%2", title: "Agent 2", currentCommand: "zsh" })];
158
+ mockPanes = panes;
159
+
160
+ const config = makeOrchestratorConfig(tmpDir);
161
+ const state = makeOrchestratorState();
162
+
163
+ dispatch(config, state, [task], panes);
164
+
165
+ // No send-keys calls for Agent 2
166
+ const sendCalls = tmuxCalls.filter(
167
+ (c) => c.args.includes("send-keys") && c.args.includes("%2"),
168
+ );
169
+ expect(sendCalls.length).toBe(0);
170
+ });
171
+ });
172
+
173
+ describe("detectStalls", () => {
174
+ it("sends nudge after timeout", () => {
175
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1" });
176
+ const name = agentIdentifier(pane);
177
+ const task = makeTask({ status: "in-progress", assignee: name });
178
+
179
+ const panes: PaneInfo[] = [pane];
180
+
181
+ const config = makeOrchestratorConfig(tmpDir, { stallTimeout: 1000 });
182
+ const state = makeOrchestratorState({
183
+ lastActivity: new Map([["%1", Date.now() - 2000]]),
184
+ });
185
+
186
+ detectStalls(config, state, [task], panes);
187
+
188
+ // Should have sent a nudge via send-keys
189
+ const sendCalls = tmuxCalls.filter((c) => c.args.includes("send-keys"));
190
+ expect(sendCalls.length > 0).toBeTruthy();
191
+ });
192
+
193
+ it("does not nudge before timeout", () => {
194
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1" });
195
+ const name = agentIdentifier(pane);
196
+ const task = makeTask({ status: "in-progress", assignee: name });
197
+
198
+ const panes: PaneInfo[] = [pane];
199
+
200
+ const config = makeOrchestratorConfig(tmpDir, { stallTimeout: 300000 });
201
+ const state = makeOrchestratorState({
202
+ lastActivity: new Map([["%1", Date.now()]]),
203
+ });
204
+
205
+ detectStalls(config, state, [task], panes);
206
+
207
+ const sendCalls = tmuxCalls.filter((c) => c.args.includes("send-keys"));
208
+ expect(sendCalls.length).toBe(0);
209
+ });
210
+ });
211
+
212
+ describe("detectCompletions", () => {
213
+ it("notifies master when task goes from in-progress to done", () => {
214
+ const task = makeTask({ status: "done", assignee: "Agent 1", proof: { notes: "tests pass" } });
215
+
216
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
217
+
218
+ const config = makeOrchestratorConfig(tmpDir);
219
+ const state = makeOrchestratorState({
220
+ previousTasks: new Map([["001", "in-progress"]]),
221
+ });
222
+
223
+ detectCompletions(config, state, [task], panes);
224
+
225
+ // Should have sent notification to master
226
+ const sendCalls = tmuxCalls.filter(
227
+ (c) => c.args.includes("send-keys") && c.args.includes("%0"),
228
+ );
229
+ expect(sendCalls.length > 0).toBeTruthy();
230
+ });
231
+
232
+ it("does not notify when task was already done", () => {
233
+ const task = makeTask({ status: "done" });
234
+
235
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
236
+
237
+ const config = makeOrchestratorConfig(tmpDir);
238
+ const state = makeOrchestratorState({
239
+ previousTasks: new Map([["001", "done"]]),
240
+ });
241
+
242
+ detectCompletions(config, state, [task], panes);
243
+
244
+ const sendCalls = tmuxCalls.filter((c) => c.args.includes("send-keys"));
245
+ expect(sendCalls.length).toBe(0);
246
+ });
247
+
248
+ it("processes completed research tasks into the library and clears active state", () => {
249
+ const task = makeTask({
250
+ id: "099",
251
+ title: "Research: periodic",
252
+ status: "done",
253
+ assignee: "Researcher",
254
+ tags: ["research", "periodic"],
255
+ updated: "2026-01-03T00:00:00Z",
256
+ salientSummary: "Found a contract gap",
257
+ });
258
+ saveResearchState(tmpDir, {
259
+ lastResearchAt: {},
260
+ missionStartAnalyzed: false,
261
+ milestoneTaskCounts: {},
262
+ activeResearchTaskId: "099",
263
+ retryWindow: [],
264
+ });
265
+
266
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
267
+ const config = makeOrchestratorConfig(tmpDir);
268
+ const state = makeOrchestratorState({
269
+ previousTasks: new Map([["099", "in-progress"]]),
270
+ });
271
+ const researchState = loadResearchState(tmpDir);
272
+
273
+ detectCompletions(config, state, [task], panes, researchState);
274
+
275
+ expect(loadResearchState(tmpDir).activeResearchTaskId).toBe(null);
276
+ expect(existsSync(join(tmpDir, ".tmux-ide", "library", "research-findings.md"))).toBeTruthy();
277
+ expect(
278
+ readFileSync(join(tmpDir, ".tmux-ide", "library", "research-findings.md"), "utf-8"),
279
+ ).toContain("Found a contract gap");
280
+ expect(readEvents(tmpDir).some((event) => event.type === "research_finding")).toBeTruthy();
281
+ });
282
+ });
283
+
284
+ describe("buildTaskPrompt", () => {
285
+ it("includes mission + goal + task context", () => {
286
+ saveMission(tmpDir, {
287
+ title: "Build auth",
288
+ description: "Before Q2 audit",
289
+ status: "active",
290
+ branch: null,
291
+ milestones: [],
292
+ created: "2026-01-01T00:00:00Z",
293
+ updated: "2026-01-01T00:00:00Z",
294
+ });
295
+ saveGoal(tmpDir, {
296
+ id: "01",
297
+ title: "Token storage",
298
+ description: "",
299
+ status: "in-progress",
300
+ acceptance: "All tokens encrypted",
301
+ priority: 1,
302
+ created: "2026-01-01T00:00:00Z",
303
+ updated: "2026-01-01T00:00:00Z",
304
+ assignee: null,
305
+ specialty: null,
306
+ milestone: null,
307
+ });
308
+
309
+ const task = makeTask({
310
+ title: "Fix middleware",
311
+ goal: "01",
312
+ tags: ["security"],
313
+ priority: 1,
314
+ });
315
+
316
+ const prompt = buildTaskPrompt(tmpDir, task);
317
+
318
+ expect(prompt.includes("Mission: Build auth")).toBeTruthy();
319
+ expect(prompt.includes("Goal: Token storage")).toBeTruthy();
320
+ expect(prompt.includes("Acceptance: All tokens encrypted")).toBeTruthy();
321
+ expect(prompt.includes("Your Task: Fix middleware")).toBeTruthy();
322
+ expect(prompt.includes("Priority: P1")).toBeTruthy();
323
+ expect(prompt.includes("Tags: security")).toBeTruthy();
324
+ expect(prompt.includes(`Workspace: ${tmpDir}`)).toBeTruthy();
325
+ expect(prompt.includes("tmux-ide task done 001")).toBeTruthy();
326
+ });
327
+
328
+ it("works without mission or goal", () => {
329
+ const task = makeTask({ title: "Standalone task" });
330
+ const prompt = buildTaskPrompt(tmpDir, task);
331
+ expect(prompt.includes("Your Task: Standalone task")).toBeTruthy();
332
+ expect(!prompt.includes("Mission:")).toBeTruthy();
333
+ expect(!prompt.includes("Goal:")).toBeTruthy();
334
+ });
335
+ });
336
+
337
+ describe("runHook", () => {
338
+ it("returns ok:true for successful command", () => {
339
+ const result = runHook("true", tmpDir);
340
+ expect(result).toEqual({ ok: true });
341
+ });
342
+
343
+ it("returns ok:false with error for failing command", () => {
344
+ const result = runHook("false", tmpDir);
345
+ expect(result.ok).toBe(false);
346
+ expect("error" in result && result.error.length > 0).toBeTruthy();
347
+ });
348
+
349
+ it("runs command in the specified cwd", () => {
350
+ // Create a marker file, then check it exists via the hook
351
+ writeFileSync(join(tmpDir, "marker.txt"), "hello");
352
+ const result = runHook("test -f marker.txt", tmpDir);
353
+ expect(result).toEqual({ ok: true });
354
+ });
355
+
356
+ it("fails when cwd does not contain expected file", () => {
357
+ const result = runHook("test -f nonexistent.txt", tmpDir);
358
+ expect(result.ok).toBe(false);
359
+ });
360
+ });
361
+
362
+ describe("dispatch with hooks", () => {
363
+ it("skips task when before_run fails", () => {
364
+ const task = makeTask();
365
+ saveTask(tmpDir, task);
366
+
367
+ const panes: PaneInfo[] = [makePane({ id: "%1", title: "Agent 1", currentCommand: "zsh" })];
368
+ mockPanes = panes;
369
+
370
+ // before_run that always fails
371
+ const config = makeOrchestratorConfig(tmpDir, { beforeRun: "false" });
372
+ const state = makeOrchestratorState();
373
+
374
+ dispatch(config, state, [task], panes);
375
+
376
+ // Task should NOT be assigned
377
+ const loaded = loadTask(tmpDir, "001");
378
+ expect(loaded?.assignee).toBe(null);
379
+ expect(loaded?.status).toBe("todo");
380
+ });
381
+
382
+ it("dispatches task when before_run succeeds", () => {
383
+ const task = makeTask();
384
+ saveTask(tmpDir, task);
385
+
386
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
387
+ const expectedName = agentIdentifier(pane);
388
+ const panes: PaneInfo[] = [pane];
389
+ mockPanes = panes;
390
+
391
+ // before_run that always succeeds
392
+ const config = makeOrchestratorConfig(tmpDir, { beforeRun: "true" });
393
+ const state = makeOrchestratorState();
394
+
395
+ dispatch(config, state, [task], panes);
396
+
397
+ const loaded = loadTask(tmpDir, "001");
398
+ expect(loaded?.assignee).toBe(expectedName);
399
+ expect(loaded?.status).toBe("in-progress");
400
+ });
401
+ });
402
+
403
+ describe("detectCompletions with after_run", () => {
404
+ it("runs after_run hook when task completes", () => {
405
+ const task = makeTask({
406
+ status: "done",
407
+ assignee: "Agent 1",
408
+ });
409
+
410
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
411
+
412
+ // after_run creates a marker file — proves it ran in the project dir
413
+ const config = makeOrchestratorConfig(tmpDir, { afterRun: "touch after_run_marker" });
414
+ const state = makeOrchestratorState({
415
+ previousTasks: new Map([["001", "in-progress"]]),
416
+ });
417
+
418
+ detectCompletions(config, state, [task], panes);
419
+
420
+ expect(existsSync(join(tmpDir, "after_run_marker"))).toBeTruthy();
421
+ });
422
+
423
+ it("does not crash when after_run fails", () => {
424
+ const task = makeTask({
425
+ status: "done",
426
+ assignee: "Agent 1",
427
+ });
428
+
429
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
430
+
431
+ // after_run that fails — should not throw
432
+ const config = makeOrchestratorConfig(tmpDir, { afterRun: "false" });
433
+ const state = makeOrchestratorState({
434
+ previousTasks: new Map([["001", "in-progress"]]),
435
+ });
436
+
437
+ // Should not throw
438
+ detectCompletions(config, state, [task], panes);
439
+
440
+ // Master should still get notified despite hook failure
441
+ const sendCalls = tmuxCalls.filter(
442
+ (c) => c.args.includes("send-keys") && c.args.includes("%0"),
443
+ );
444
+ expect(sendCalls.length > 0).toBeTruthy();
445
+ });
446
+ });
447
+
448
+ describe("isAgentPane", () => {
449
+ it("detects claude command", () => {
450
+ expect(isAgentPane(makePane({ currentCommand: "claude" }))).toBeTruthy();
451
+ });
452
+
453
+ it("detects codex command", () => {
454
+ expect(isAgentPane(makePane({ currentCommand: "codex" }))).toBeTruthy();
455
+ });
456
+
457
+ it("detects Claude Code via version string + title", () => {
458
+ expect(isAgentPane(makePane({ currentCommand: "2.1.80", title: "Claude Code" }))).toBeTruthy();
459
+ });
460
+
461
+ it("detects Claude Code via title pattern", () => {
462
+ expect(isAgentPane(makePane({ currentCommand: "node", title: "Claude Code" }))).toBeTruthy();
463
+ });
464
+
465
+ it("does not match a plain shell", () => {
466
+ expect(!isAgentPane(makePane({ currentCommand: "zsh", title: "Shell" }))).toBeTruthy();
467
+ });
468
+
469
+ it("matches version string regardless of title (Claude Code reports version as command)", () => {
470
+ expect(isAgentPane(makePane({ currentCommand: "2.1.80", title: "Dev Server" }))).toBeTruthy();
471
+ });
472
+ });
473
+
474
+ describe("isAgentBusy", () => {
475
+ it("returns true when spinner in title", () => {
476
+ expect(isAgentBusy(makePane({ title: "⠙ Working..." }))).toBeTruthy();
477
+ });
478
+
479
+ it("returns false for normal title", () => {
480
+ expect(!isAgentBusy(makePane({ title: "Claude Code" }))).toBeTruthy();
481
+ });
482
+ });
483
+
484
+ describe("isIdleForDispatch", () => {
485
+ it("returns true for shell command", () => {
486
+ expect(isIdleForDispatch(makePane({ currentCommand: "zsh" }))).toBeTruthy();
487
+ expect(isIdleForDispatch(makePane({ currentCommand: "bash" }))).toBeTruthy();
488
+ expect(isIdleForDispatch(makePane({ currentCommand: "fish" }))).toBeTruthy();
489
+ });
490
+
491
+ it("returns true for agent pane without spinner", () => {
492
+ expect(
493
+ isIdleForDispatch(makePane({ currentCommand: "claude", title: "Agent 1" })),
494
+ ).toBeTruthy();
495
+ });
496
+
497
+ it("returns true for Claude with version command and no spinner", () => {
498
+ expect(
499
+ isIdleForDispatch(makePane({ currentCommand: "2.1.80", title: "Claude Code" })),
500
+ ).toBeTruthy();
501
+ });
502
+
503
+ it("returns false for agent pane with spinner", () => {
504
+ expect(
505
+ !isIdleForDispatch(makePane({ currentCommand: "claude", title: "⠹ Thinking..." })),
506
+ ).toBeTruthy();
507
+ });
508
+
509
+ it("returns false for non-agent non-shell command", () => {
510
+ expect(!isIdleForDispatch(makePane({ currentCommand: "vim", title: "Editor" }))).toBeTruthy();
511
+ });
512
+ });
513
+
514
+ describe("claim locking", () => {
515
+ it("prevents double-dispatch of same task across two calls", () => {
516
+ const task = makeTask();
517
+ saveTask(tmpDir, task);
518
+
519
+ const panes: PaneInfo[] = [
520
+ makePane({ id: "%1", title: "Agent 1", currentCommand: "zsh" }),
521
+ makePane({ id: "%2", title: "Agent 2", currentCommand: "zsh" }),
522
+ ];
523
+ mockPanes = panes;
524
+
525
+ const config = makeOrchestratorConfig(tmpDir);
526
+ const state = makeOrchestratorState();
527
+
528
+ // First dispatch claims the task
529
+ dispatch(config, state, [task], panes);
530
+ expect(state.claimedTasks.has("001")).toBeTruthy();
531
+
532
+ // Reset task to todo to simulate a race (file not yet updated)
533
+ const raceTask = makeTask({ status: "todo", assignee: null });
534
+
535
+ // Second dispatch should skip claimed task
536
+ dispatch(config, state, [raceTask], panes);
537
+
538
+ // Only one send-keys call with the task prompt (not two)
539
+ const sendCalls = tmuxCalls.filter((c) => c.args.includes("send-keys"));
540
+ // First dispatch: send-keys text + send-keys Enter = 2 calls
541
+ expect(sendCalls.length).toBe(2);
542
+ });
543
+
544
+ it("clears claim on task completion", () => {
545
+ const config = makeOrchestratorConfig(tmpDir);
546
+ const state = makeOrchestratorState({
547
+ previousTasks: new Map([["001", "in-progress"]]),
548
+ claimedTasks: new Set(["001"]),
549
+ });
550
+
551
+ const task = makeTask({
552
+ status: "done",
553
+ assignee: "Agent 1",
554
+ });
555
+
556
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
557
+
558
+ detectCompletions(config, state, [task], panes);
559
+
560
+ expect(!state.claimedTasks.has("001")).toBeTruthy();
561
+ });
562
+
563
+ it("releases claim when before_run hook fails", () => {
564
+ const task = makeTask();
565
+ saveTask(tmpDir, task);
566
+
567
+ const panes: PaneInfo[] = [makePane({ id: "%1", title: "Agent 1", currentCommand: "zsh" })];
568
+ mockPanes = panes;
569
+
570
+ const config = makeOrchestratorConfig(tmpDir, { beforeRun: "false" });
571
+ const state = makeOrchestratorState();
572
+
573
+ dispatch(config, state, [task], panes);
574
+
575
+ // Claim should be released so the task can be retried
576
+ expect(!state.claimedTasks.has("001")).toBeTruthy();
577
+ });
578
+ });
579
+
580
+ describe("createOrchestrator timer", () => {
581
+ it("auto-assigns a task within poll interval", async () => {
582
+ const task = makeTask();
583
+ saveTask(tmpDir, task);
584
+
585
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
586
+ const expectedName = agentIdentifier(pane);
587
+ mockPanes = [pane];
588
+
589
+ const config = makeOrchestratorConfig(tmpDir, { pollInterval: 50, masterPane: null });
590
+
591
+ const stop = createOrchestrator(config);
592
+
593
+ // Wait for a few ticks
594
+ await new Promise((resolve) => setTimeout(resolve, 200));
595
+
596
+ stop();
597
+
598
+ const loaded = loadTask(tmpDir, "001");
599
+ expect(loaded?.status).toBe("in-progress");
600
+ expect(loaded?.assignee).toBe(expectedName);
601
+ });
602
+
603
+ it("stops polling when returned function is called", async () => {
604
+ mockPanes = [];
605
+
606
+ const config = makeOrchestratorConfig(tmpDir, { pollInterval: 50 });
607
+
608
+ const stop = createOrchestrator(config);
609
+ stop();
610
+
611
+ // Create a task after stopping — it should not be assigned
612
+ const task = makeTask();
613
+ saveTask(tmpDir, task);
614
+
615
+ await new Promise((resolve) => setTimeout(resolve, 200));
616
+
617
+ const loaded = loadTask(tmpDir, "001");
618
+ expect(loaded?.status).toBe("todo");
619
+ expect(loaded?.assignee).toBe(null);
620
+ });
621
+ });
622
+
623
+ describe("dispatch with version-string agent", () => {
624
+ it("assigns task to agent reporting version string as command", () => {
625
+ const task = makeTask();
626
+ saveTask(tmpDir, task);
627
+
628
+ // Simulate Claude Code showing version as command
629
+ const pane = makePane({ id: "%1", index: 0, title: "Claude Code", currentCommand: "2.1.80" });
630
+ const expectedName = agentIdentifier(pane);
631
+ const panes: PaneInfo[] = [pane];
632
+ mockPanes = panes;
633
+
634
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: null });
635
+ const state = makeOrchestratorState();
636
+
637
+ dispatch(config, state, [task], panes);
638
+
639
+ const loaded = loadTask(tmpDir, "001");
640
+ expect(loaded?.assignee).toBe(expectedName);
641
+ expect(loaded?.status).toBe("in-progress");
642
+ });
643
+
644
+ it("does not assign to busy agent with spinner", () => {
645
+ const task = makeTask();
646
+ saveTask(tmpDir, task);
647
+
648
+ const panes: PaneInfo[] = [
649
+ makePane({ id: "%1", title: "⠙ Thinking...", currentCommand: "2.1.80" }),
650
+ ];
651
+ mockPanes = panes;
652
+
653
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: null });
654
+ const state = makeOrchestratorState();
655
+
656
+ dispatch(config, state, [task], panes);
657
+
658
+ const loaded = loadTask(tmpDir, "001");
659
+ expect(loaded?.assignee).toBe(null);
660
+ expect(loaded?.status).toBe("todo");
661
+ });
662
+ });
663
+
664
+ describe("reloadConfig", () => {
665
+ it("updates pollInterval", () => {
666
+ const config = makeOrchestratorConfig(tmpDir, { pollInterval: 5000 });
667
+ reloadConfig(config, { pollInterval: 1000 });
668
+ expect(config.pollInterval).toBe(1000);
669
+ });
670
+
671
+ it("updates stallTimeout", () => {
672
+ const config = makeOrchestratorConfig(tmpDir, { stallTimeout: 300000 });
673
+ reloadConfig(config, { stallTimeout: 60000 });
674
+ expect(config.stallTimeout).toBe(60000);
675
+ });
676
+
677
+ it("updates maxConcurrentAgents", () => {
678
+ const config = makeOrchestratorConfig(tmpDir);
679
+ reloadConfig(config, { maxConcurrentAgents: 3 });
680
+ expect(config.maxConcurrentAgents).toBe(3);
681
+ });
682
+
683
+ it("updates multiple fields at once", () => {
684
+ const config = makeOrchestratorConfig(tmpDir);
685
+ reloadConfig(config, {
686
+ pollInterval: 2000,
687
+ stallTimeout: 120000,
688
+ autoDispatch: false,
689
+ });
690
+ expect(config.pollInterval).toBe(2000);
691
+ expect(config.stallTimeout).toBe(120000);
692
+ expect(config.autoDispatch).toBe(false);
693
+ });
694
+
695
+ it("preserves fields not in the patch", () => {
696
+ const config = makeOrchestratorConfig(tmpDir, { pollInterval: 5000, stallTimeout: 300000 });
697
+ reloadConfig(config, { pollInterval: 1000 });
698
+ expect(config.stallTimeout).toBe(300000); // unchanged
699
+ expect(config.session).toBe("test"); // unchanged
700
+ });
701
+ });
702
+
703
+ describe("event logging integration", () => {
704
+ it("logs dispatch events", () => {
705
+ const task = makeTask();
706
+ saveTask(tmpDir, task);
707
+
708
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
709
+ const panes: PaneInfo[] = [pane];
710
+ mockPanes = panes;
711
+
712
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: null });
713
+ const state = makeOrchestratorState();
714
+
715
+ dispatch(config, state, [task], panes);
716
+
717
+ const events = readEvents(tmpDir);
718
+ const dispatchEvent = events.find((e) => e.type === "dispatch");
719
+ expect(dispatchEvent).toBeTruthy();
720
+ expect(dispatchEvent!.taskId).toBe("001");
721
+ expect(dispatchEvent!.agent).toBe(pane.title);
722
+ expect(dispatchEvent!.message.includes("Test task")).toBeTruthy();
723
+ });
724
+
725
+ it("logs stall events", () => {
726
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1" });
727
+ const name = agentIdentifier(pane);
728
+ const task = makeTask({ status: "in-progress", assignee: name });
729
+ const panes: PaneInfo[] = [pane];
730
+
731
+ const config = makeOrchestratorConfig(tmpDir, { stallTimeout: 1000 });
732
+ const state = makeOrchestratorState({
733
+ lastActivity: new Map([["%1", Date.now() - 2000]]),
734
+ });
735
+
736
+ detectStalls(config, state, [task], panes);
737
+
738
+ const events = readEvents(tmpDir);
739
+ const stallEvent = events.find((e) => e.type === "stall");
740
+ expect(stallEvent).toBeTruthy();
741
+ expect(stallEvent!.taskId).toBe("001");
742
+ expect(stallEvent!.agent).toBe(name);
743
+ });
744
+
745
+ it("logs completion events", () => {
746
+ const task = makeTask({
747
+ status: "done",
748
+ assignee: "Agent 1",
749
+ });
750
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
751
+
752
+ const config = makeOrchestratorConfig(tmpDir);
753
+ const state = makeOrchestratorState({
754
+ previousTasks: new Map([["001", "in-progress"]]),
755
+ });
756
+
757
+ detectCompletions(config, state, [task], panes);
758
+
759
+ const events = readEvents(tmpDir);
760
+ const completionEvent = events.find((e) => e.type === "completion");
761
+ expect(completionEvent).toBeTruthy();
762
+ expect(completionEvent!.taskId).toBe("001");
763
+ expect(completionEvent!.agent).toBe("Agent 1");
764
+ });
765
+
766
+ it("logs reconcile events when agent vanishes", () => {
767
+ const task = makeTask({ status: "in-progress", assignee: "Agent 1" });
768
+ saveTask(tmpDir, task);
769
+
770
+ // No panes — agent has vanished
771
+ const panes: PaneInfo[] = [];
772
+
773
+ const config = makeOrchestratorConfig(tmpDir);
774
+ const state = makeOrchestratorState();
775
+
776
+ reconcile(config, state, [task], panes);
777
+
778
+ const events = readEvents(tmpDir);
779
+ const reconcileEvent = events.find((e) => e.type === "reconcile");
780
+ expect(reconcileEvent).toBeTruthy();
781
+ expect(reconcileEvent!.taskId).toBe("001");
782
+ expect(reconcileEvent!.agent).toBe("Agent 1");
783
+ expect(reconcileEvent!.message.includes("vanished")).toBeTruthy();
784
+ });
785
+ });
786
+
787
+ describe("saveOrchestratorState / loadOrchestratorState", () => {
788
+ it("round-trips claimed tasks and claim times", () => {
789
+ const state = makeOrchestratorState({
790
+ claimedTasks: new Set(["001", "003"]),
791
+ taskClaimTimes: new Map([
792
+ ["001", 1700000000000],
793
+ ["003", 1700000060000],
794
+ ]),
795
+ });
796
+
797
+ saveOrchestratorState(tmpDir, state);
798
+
799
+ const restored = makeOrchestratorState();
800
+ loadOrchestratorState(tmpDir, restored);
801
+
802
+ expect(restored.claimedTasks.has("001")).toBeTruthy();
803
+ expect(restored.claimedTasks.has("003")).toBeTruthy();
804
+ expect(restored.taskClaimTimes.get("001")).toBe(1700000000000);
805
+ expect(restored.taskClaimTimes.get("003")).toBe(1700000060000);
806
+ });
807
+
808
+ it("handles missing state file gracefully", () => {
809
+ const state = makeOrchestratorState();
810
+ loadOrchestratorState(tmpDir, state);
811
+ expect(state.claimedTasks.size).toBe(0);
812
+ expect(state.taskClaimTimes.size).toBe(0);
813
+ });
814
+
815
+ it("handles corrupted state file gracefully", () => {
816
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
817
+ writeFileSync(join(tmpDir, ".tasks", "orchestrator-state.json"), "not json");
818
+
819
+ const state = makeOrchestratorState();
820
+ loadOrchestratorState(tmpDir, state);
821
+ expect(state.claimedTasks.size).toBe(0);
822
+ });
823
+ });
824
+
825
+ describe("syncClaims", () => {
826
+ it("removes stale claims for tasks no longer in-progress", () => {
827
+ saveTask(tmpDir, makeTask({ id: "001", status: "done", assignee: "Agent 1" }));
828
+ saveTask(tmpDir, makeTask({ id: "002", status: "todo" }));
829
+ saveTask(tmpDir, makeTask({ id: "003", status: "in-progress", assignee: "Agent 2" }));
830
+
831
+ const state = makeOrchestratorState({
832
+ claimedTasks: new Set(["001", "002", "003"]),
833
+ });
834
+
835
+ syncClaims(tmpDir, state);
836
+
837
+ expect(!state.claimedTasks.has("001")).toBeTruthy();
838
+ expect(!state.claimedTasks.has("002")).toBeTruthy();
839
+ expect(state.claimedTasks.has("003")).toBeTruthy();
840
+ });
841
+
842
+ it("adds missing claims for in-progress tasks", () => {
843
+ saveTask(tmpDir, makeTask({ id: "001", status: "in-progress", assignee: "Agent 1" }));
844
+ saveTask(tmpDir, makeTask({ id: "002", status: "in-progress", assignee: "Agent 2" }));
845
+
846
+ const state = makeOrchestratorState({
847
+ claimedTasks: new Set(),
848
+ });
849
+
850
+ syncClaims(tmpDir, state);
851
+
852
+ expect(state.claimedTasks.has("001")).toBeTruthy();
853
+ expect(state.claimedTasks.has("002")).toBeTruthy();
854
+ });
855
+
856
+ it("rebuilds claims from mixed task statuses", () => {
857
+ saveTask(tmpDir, makeTask({ id: "001", status: "done", assignee: "Agent 1" }));
858
+ saveTask(tmpDir, makeTask({ id: "002", status: "in-progress", assignee: "Agent 2" }));
859
+ saveTask(tmpDir, makeTask({ id: "003", status: "todo" }));
860
+ saveTask(tmpDir, makeTask({ id: "004", status: "in-progress", assignee: "Agent 3" }));
861
+ saveTask(tmpDir, makeTask({ id: "005", status: "review", assignee: "Agent 1" }));
862
+
863
+ // Stale state: claims 001 (done) and 003 (todo), missing 004 (in-progress)
864
+ const state = makeOrchestratorState({
865
+ claimedTasks: new Set(["001", "003"]),
866
+ });
867
+
868
+ syncClaims(tmpDir, state);
869
+
870
+ expect(state.claimedTasks.size).toBe(2);
871
+ expect(state.claimedTasks.has("002")).toBeTruthy();
872
+ expect(state.claimedTasks.has("004")).toBeTruthy();
873
+ expect(!state.claimedTasks.has("001")).toBeTruthy();
874
+ expect(!state.claimedTasks.has("003")).toBeTruthy();
875
+ expect(!state.claimedTasks.has("005")).toBeTruthy();
876
+ });
877
+
878
+ it("handles empty task store", () => {
879
+ const state = makeOrchestratorState({
880
+ claimedTasks: new Set(["001", "002"]),
881
+ });
882
+
883
+ syncClaims(tmpDir, state);
884
+
885
+ expect(state.claimedTasks.size).toBe(0);
886
+ });
887
+ });
888
+
889
+ describe("gracefulShutdown", () => {
890
+ it("releases in-progress tasks back to todo", () => {
891
+ const task = makeTask({
892
+ status: "in-progress",
893
+ assignee: "Agent 1",
894
+ });
895
+ saveTask(tmpDir, task);
896
+
897
+ const config = makeOrchestratorConfig(tmpDir);
898
+ const state = makeOrchestratorState({
899
+ claimedTasks: new Set(["001"]),
900
+ });
901
+
902
+ gracefulShutdown(config, state);
903
+
904
+ const loaded = loadTask(tmpDir, "001")!;
905
+ expect(loaded.status).toBe("todo");
906
+ expect(loaded.assignee).toBe(null);
907
+ });
908
+
909
+ it("saves orchestrator state to disk", () => {
910
+ saveTask(tmpDir, makeTask());
911
+
912
+ const config = makeOrchestratorConfig(tmpDir);
913
+ const state = makeOrchestratorState({
914
+ claimedTasks: new Set(["002"]),
915
+ taskClaimTimes: new Map([["002", Date.now()]]),
916
+ });
917
+
918
+ gracefulShutdown(config, state);
919
+
920
+ expect(existsSync(join(tmpDir, ".tasks", "orchestrator-state.json"))).toBeTruthy();
921
+
922
+ const restored = makeOrchestratorState();
923
+ loadOrchestratorState(tmpDir, restored);
924
+ expect(restored.claimedTasks.has("002")).toBeTruthy();
925
+ });
926
+
927
+ it("leaves done and todo tasks untouched", () => {
928
+ const done = makeTask({ id: "001", status: "done", assignee: "Agent 1" });
929
+ const todo = makeTask({ id: "002", status: "todo", assignee: null });
930
+ saveTask(tmpDir, done);
931
+ saveTask(tmpDir, todo);
932
+
933
+ const config = makeOrchestratorConfig(tmpDir);
934
+ const state = makeOrchestratorState();
935
+
936
+ gracefulShutdown(config, state);
937
+
938
+ expect(loadTask(tmpDir, "001")!.status).toBe("done");
939
+ expect(loadTask(tmpDir, "002")!.status).toBe("todo");
940
+ });
941
+ });
942
+
943
+ describe("normalizePaneTitle", () => {
944
+ it("strips spinner prefix from pane title", () => {
945
+ expect(normalizePaneTitle("⠂ Claude Code")).toBe("Claude Code");
946
+ expect(normalizePaneTitle("⠋ Working...")).toBe("Working...");
947
+ expect(normalizePaneTitle("✳ Claude Code")).toBe("Claude Code");
948
+ expect(normalizePaneTitle("◐ Thinking")).toBe("Thinking");
949
+ });
950
+
951
+ it("returns title unchanged when no spinner prefix", () => {
952
+ expect(normalizePaneTitle("Agent 1")).toBe("Agent 1");
953
+ expect(normalizePaneTitle("Claude Code")).toBe("Claude Code");
954
+ });
955
+
956
+ it("handles empty string", () => {
957
+ expect(normalizePaneTitle("")).toBe("");
958
+ });
959
+ });
960
+
961
+ describe("getPaneSpecialties", () => {
962
+ it("returns specialties from config map", () => {
963
+ const config = makeOrchestratorConfig(tmpDir, {
964
+ paneSpecialties: new Map([["Frontend Agent", ["frontend", "css", "react"]]]),
965
+ });
966
+ const pane = makePane({ title: "Frontend Agent" });
967
+ const specs = getPaneSpecialties(config, pane);
968
+ expect(specs).toEqual(["frontend", "css", "react"]);
969
+ });
970
+
971
+ it("returns empty array for pane with no specialties", () => {
972
+ const config = makeOrchestratorConfig(tmpDir, { paneSpecialties: new Map() });
973
+ const pane = makePane({ title: "Generic Agent" });
974
+ const specs = getPaneSpecialties(config, pane);
975
+ expect(specs).toEqual([]);
976
+ });
977
+
978
+ it("matches by pane title", () => {
979
+ const config = makeOrchestratorConfig(tmpDir, {
980
+ paneSpecialties: new Map([
981
+ ["Backend", ["api", "database"]],
982
+ ["Frontend", ["ui", "css"]],
983
+ ]),
984
+ });
985
+ expect(getPaneSpecialties(config, makePane({ title: "Backend" }))).toEqual(["api", "database"]);
986
+ expect(getPaneSpecialties(config, makePane({ title: "Frontend" }))).toEqual(["ui", "css"]);
987
+ expect(getPaneSpecialties(config, makePane({ title: "Other" }))).toEqual([]);
988
+ });
989
+ });
990
+
991
+ describe("buildGoalPrompt", () => {
992
+ it("includes goal title, acceptance criteria, and planner name", () => {
993
+ saveMission(tmpDir, {
994
+ title: "Ship v2",
995
+ description: "Major release",
996
+ status: "active",
997
+ branch: null,
998
+ milestones: [],
999
+ created: "2026-01-01T00:00:00Z",
1000
+ updated: "2026-01-01T00:00:00Z",
1001
+ });
1002
+
1003
+ const goal: Goal = {
1004
+ id: "01",
1005
+ title: "Build REST API",
1006
+ description: "Create CRUD endpoints",
1007
+ status: "todo",
1008
+ acceptance: "All endpoints return 200 with correct data",
1009
+ priority: 1,
1010
+ created: "2026-01-01T00:00:00Z",
1011
+ updated: "2026-01-01T00:00:00Z",
1012
+ assignee: null,
1013
+ specialty: "backend",
1014
+ };
1015
+
1016
+ const planner = makePane({ id: "%2", index: 1, title: "Backend Planner" });
1017
+ const prompt = buildGoalPrompt(tmpDir, goal, planner);
1018
+
1019
+ expect(prompt.includes("Build REST API")).toBeTruthy();
1020
+ expect(prompt.includes("All endpoints return 200 with correct data")).toBeTruthy();
1021
+ expect(prompt.includes("backend planner")).toBeTruthy();
1022
+ expect(prompt.includes("Ship v2")).toBeTruthy();
1023
+ expect(prompt.includes(agentIdentifier(planner))).toBeTruthy();
1024
+ });
1025
+
1026
+ it("works without mission", () => {
1027
+ const goal: Goal = {
1028
+ id: "01",
1029
+ title: "Setup CI",
1030
+ description: "",
1031
+ status: "todo",
1032
+ acceptance: "CI passes",
1033
+ priority: 1,
1034
+ created: "2026-01-01T00:00:00Z",
1035
+ updated: "2026-01-01T00:00:00Z",
1036
+ assignee: null,
1037
+ specialty: null,
1038
+ milestone: null,
1039
+ };
1040
+
1041
+ const planner = makePane({ id: "%1", index: 0 });
1042
+ const prompt = buildGoalPrompt(tmpDir, goal, planner);
1043
+
1044
+ expect(prompt.includes("Setup CI")).toBeTruthy();
1045
+ expect(prompt.includes("general planner")).toBeTruthy();
1046
+ expect(!prompt.includes("Mission:")).toBeTruthy();
1047
+ });
1048
+ });
1049
+
1050
+ describe("dispatchGoals", () => {
1051
+ it("assigns goal to idle planner pane", () => {
1052
+ const goal: Goal = {
1053
+ id: "01",
1054
+ title: "Build Frontend",
1055
+ description: "React components",
1056
+ status: "todo",
1057
+ acceptance: "UI renders",
1058
+ priority: 1,
1059
+ created: "2026-01-01T00:00:00Z",
1060
+ updated: "2026-01-01T00:00:00Z",
1061
+ assignee: null,
1062
+ specialty: null,
1063
+ milestone: null,
1064
+ };
1065
+ saveGoal(tmpDir, goal);
1066
+
1067
+ const planner = makePane({ id: "%2", index: 1, title: "Planner", currentCommand: "claude" });
1068
+ const config = makeOrchestratorConfig(tmpDir, {
1069
+ paneSpecialties: new Map([["Planner", ["frontend"]]]),
1070
+ });
1071
+ const state = makeOrchestratorState();
1072
+
1073
+ dispatchGoals(config, state, [goal], [], [planner]);
1074
+
1075
+ const updated = loadGoal(tmpDir, "01")!;
1076
+ expect(updated.status).toBe("in-progress");
1077
+ expect(updated.assignee).toBe(agentIdentifier(planner));
1078
+
1079
+ const events = readEvents(tmpDir);
1080
+ expect(events.length).toBe(1);
1081
+ expect(events[0]!.type).toBe("dispatch");
1082
+ expect(events[0]!.message.includes("Build Frontend")).toBeTruthy();
1083
+ });
1084
+
1085
+ it("skips master pane", () => {
1086
+ const goal: Goal = {
1087
+ id: "01",
1088
+ title: "Test Goal",
1089
+ description: "",
1090
+ status: "todo",
1091
+ acceptance: "",
1092
+ priority: 1,
1093
+ created: "2026-01-01T00:00:00Z",
1094
+ updated: "2026-01-01T00:00:00Z",
1095
+ assignee: null,
1096
+ specialty: null,
1097
+ milestone: null,
1098
+ };
1099
+ saveGoal(tmpDir, goal);
1100
+
1101
+ // Only pane is the master — should not be used as planner
1102
+ const masterPane = makePane({
1103
+ id: "%1",
1104
+ index: 0,
1105
+ title: "Master",
1106
+ currentCommand: "claude",
1107
+ role: "lead",
1108
+ });
1109
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: "Master" });
1110
+ const state = makeOrchestratorState();
1111
+
1112
+ dispatchGoals(config, state, [goal], [], [masterPane]);
1113
+
1114
+ const unchanged = loadGoal(tmpDir, "01")!;
1115
+ expect(unchanged.status).toBe("todo");
1116
+ expect(unchanged.assignee).toBe(null);
1117
+ });
1118
+
1119
+ it("does not assign already-assigned goals", () => {
1120
+ const goal: Goal = {
1121
+ id: "01",
1122
+ title: "Already assigned",
1123
+ description: "",
1124
+ status: "todo",
1125
+ acceptance: "",
1126
+ priority: 1,
1127
+ created: "2026-01-01T00:00:00Z",
1128
+ updated: "2026-01-01T00:00:00Z",
1129
+ assignee: "Someone",
1130
+ specialty: null,
1131
+ milestone: null,
1132
+ };
1133
+ saveGoal(tmpDir, goal);
1134
+
1135
+ const planner = makePane({ id: "%2", index: 1, title: "Planner", currentCommand: "claude" });
1136
+ const config = makeOrchestratorConfig(tmpDir, {
1137
+ paneSpecialties: new Map([["Planner", []]]),
1138
+ });
1139
+ const state = makeOrchestratorState();
1140
+
1141
+ dispatchGoals(config, state, [goal], [], [planner]);
1142
+
1143
+ // Goal should remain unchanged — assignee filter excludes it
1144
+ const events = readEvents(tmpDir);
1145
+ expect(events.length).toBe(0);
1146
+ });
1147
+ });
1148
+
1149
+ describe("findBestAgent", () => {
1150
+ it("returns first agent when task has no specialty", () => {
1151
+ const agents = [
1152
+ makePane({ id: "%1", title: "Agent A", currentCommand: "zsh" }),
1153
+ makePane({ id: "%2", title: "Agent B", currentCommand: "zsh" }),
1154
+ ];
1155
+ const config = makeOrchestratorConfig(tmpDir, {
1156
+ paneSpecialties: new Map([["Agent A", ["frontend"]]]),
1157
+ });
1158
+ const task = makeTask({ specialty: null });
1159
+ const result = findBestAgent(config, agents, task, new Set());
1160
+ expect(result?.id).toBe("%1");
1161
+ });
1162
+
1163
+ it("prefers specialist agent for matching specialty", () => {
1164
+ const agents = [
1165
+ makePane({ id: "%1", title: "Generalist", currentCommand: "zsh" }),
1166
+ makePane({ id: "%2", title: "Frontend", currentCommand: "zsh" }),
1167
+ ];
1168
+ const config = makeOrchestratorConfig(tmpDir, {
1169
+ paneSpecialties: new Map([["Frontend", ["frontend", "css"]]]),
1170
+ });
1171
+ const task = makeTask({ specialty: "frontend" });
1172
+ const result = findBestAgent(config, agents, task, new Set());
1173
+ expect(result?.id).toBe("%2");
1174
+ });
1175
+
1176
+ it("falls back to generalist when no specialist matches", () => {
1177
+ const agents = [
1178
+ makePane({ id: "%1", title: "Generalist", currentCommand: "zsh" }),
1179
+ makePane({ id: "%2", title: "Backend", currentCommand: "zsh" }),
1180
+ ];
1181
+ const config = makeOrchestratorConfig(tmpDir, {
1182
+ paneSpecialties: new Map([["Backend", ["api", "database"]]]),
1183
+ });
1184
+ const task = makeTask({ specialty: "frontend" });
1185
+ const result = findBestAgent(config, agents, task, new Set());
1186
+ // Generalist has no specialties → takes anything
1187
+ expect(result?.id).toBe("%1");
1188
+ });
1189
+
1190
+ it("returns null when no suitable agent (all are wrong specialists)", () => {
1191
+ const agents = [
1192
+ makePane({ id: "%1", title: "Backend", currentCommand: "zsh" }),
1193
+ makePane({ id: "%2", title: "DevOps", currentCommand: "zsh" }),
1194
+ ];
1195
+ const config = makeOrchestratorConfig(tmpDir, {
1196
+ paneSpecialties: new Map([
1197
+ ["Backend", ["api", "database"]],
1198
+ ["DevOps", ["infra", "ci"]],
1199
+ ]),
1200
+ });
1201
+ const task = makeTask({ specialty: "frontend" });
1202
+ const result = findBestAgent(config, agents, task, new Set());
1203
+ expect(result).toBeNull();
1204
+ });
1205
+
1206
+ it("excludes agents in excludeIds set", () => {
1207
+ const agents = [
1208
+ makePane({ id: "%1", title: "Agent A", currentCommand: "zsh" }),
1209
+ makePane({ id: "%2", title: "Agent B", currentCommand: "zsh" }),
1210
+ ];
1211
+ const config = makeOrchestratorConfig(tmpDir);
1212
+ const task = makeTask({ specialty: null });
1213
+ const result = findBestAgent(config, agents, task, new Set(["%1"]));
1214
+ expect(result?.id).toBe("%2");
1215
+ });
1216
+ });
1217
+
1218
+ describe("skill-based dispatch integration", () => {
1219
+ it("dispatches task with specialty to matching specialist agent", () => {
1220
+ const task = makeTask({ id: "001", specialty: "frontend" });
1221
+ saveTask(tmpDir, task);
1222
+
1223
+ const generalist = makePane({ id: "%1", index: 0, title: "Generalist", currentCommand: "zsh" });
1224
+ const specialist = makePane({ id: "%2", index: 1, title: "Frontend", currentCommand: "zsh" });
1225
+ const panes = [generalist, specialist];
1226
+ mockPanes = panes;
1227
+
1228
+ const config = makeOrchestratorConfig(tmpDir, {
1229
+ masterPane: null,
1230
+ paneSpecialties: new Map([["Frontend", ["frontend", "css"]]]),
1231
+ });
1232
+ const state = makeOrchestratorState();
1233
+
1234
+ dispatch(config, state, [task], panes);
1235
+
1236
+ const loaded = loadTask(tmpDir, "001");
1237
+ expect(loaded?.assignee).toBe(agentIdentifier(specialist));
1238
+ expect(loaded?.status).toBe("in-progress");
1239
+ });
1240
+
1241
+ it("dispatches task with no specialty to any idle agent", () => {
1242
+ const task = makeTask({ id: "001", specialty: null });
1243
+ saveTask(tmpDir, task);
1244
+
1245
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
1246
+ const panes = [pane];
1247
+ mockPanes = panes;
1248
+
1249
+ const config = makeOrchestratorConfig(tmpDir, {
1250
+ masterPane: null,
1251
+ paneSpecialties: new Map([["Agent 1", ["backend"]]]),
1252
+ });
1253
+ const state = makeOrchestratorState();
1254
+
1255
+ dispatch(config, state, [task], panes);
1256
+
1257
+ const loaded = loadTask(tmpDir, "001");
1258
+ expect(loaded?.assignee).toBe(agentIdentifier(pane));
1259
+ expect(loaded?.status).toBe("in-progress");
1260
+ });
1261
+
1262
+ it("skips specialist task when no matching agent available", () => {
1263
+ const task = makeTask({ id: "001", specialty: "frontend" });
1264
+ saveTask(tmpDir, task);
1265
+
1266
+ // Only agent is a backend specialist — not a match and not a generalist
1267
+ const panes = [makePane({ id: "%1", index: 0, title: "Backend", currentCommand: "zsh" })];
1268
+ mockPanes = panes;
1269
+
1270
+ const config = makeOrchestratorConfig(tmpDir, {
1271
+ masterPane: null,
1272
+ paneSpecialties: new Map([["Backend", ["api", "database"]]]),
1273
+ });
1274
+ const state = makeOrchestratorState();
1275
+
1276
+ dispatch(config, state, [task], panes);
1277
+
1278
+ const loaded = loadTask(tmpDir, "001");
1279
+ expect(loaded?.assignee).toBe(null);
1280
+ expect(loaded?.status).toBe("todo");
1281
+ });
1282
+ });
1283
+
1284
+ describe("buildTaskPrompt with skill injection", () => {
1285
+ it("includes skill body when task has specialty and skill file exists", () => {
1286
+ // Create skill file
1287
+ const skillsDir = join(tmpDir, ".tmux-ide", "skills");
1288
+ mkdirSync(skillsDir, { recursive: true });
1289
+ writeFileSync(
1290
+ join(skillsDir, "frontend.md"),
1291
+ `---
1292
+ name: frontend
1293
+ specialties: [frontend, css]
1294
+ role: teammate
1295
+ description: Frontend specialist
1296
+ ---
1297
+ You are a frontend expert. Focus on React components and CSS.`,
1298
+ );
1299
+
1300
+ const task = makeTask({ specialty: "frontend" });
1301
+ const prompt = buildTaskPrompt(tmpDir, task);
1302
+
1303
+ expect(prompt.includes("Your Role: frontend")).toBeTruthy();
1304
+ expect(prompt.includes("You are a frontend expert")).toBeTruthy();
1305
+ });
1306
+
1307
+ it("does not include skill section when task has no specialty", () => {
1308
+ const task = makeTask({ specialty: null });
1309
+ const prompt = buildTaskPrompt(tmpDir, task);
1310
+ expect(!prompt.includes("Your Role:")).toBeTruthy();
1311
+ });
1312
+
1313
+ it("does not include skill section when skill file is missing", () => {
1314
+ const task = makeTask({ specialty: "nonexistent" });
1315
+ const prompt = buildTaskPrompt(tmpDir, task);
1316
+ expect(!prompt.includes("Your Role:")).toBeTruthy();
1317
+ });
1318
+ });
1319
+
1320
+ describe("milestone gating", () => {
1321
+ function setupMission(milestones: Mission["milestones"]): void {
1322
+ saveMission(tmpDir, {
1323
+ title: "Ship v2",
1324
+ description: "",
1325
+ status: "active",
1326
+ branch: null,
1327
+ milestones,
1328
+ created: "2026-01-01T00:00:00Z",
1329
+ updated: "2026-01-01T00:00:00Z",
1330
+ });
1331
+ }
1332
+
1333
+ it("getCurrentMilestone returns first non-done milestone by order", () => {
1334
+ setupMission([
1335
+ {
1336
+ id: "M2",
1337
+ title: "Phase 2",
1338
+ description: "",
1339
+ status: "locked",
1340
+ order: 2,
1341
+ created: "2026-01-01T00:00:00Z",
1342
+ updated: "2026-01-01T00:00:00Z",
1343
+ },
1344
+ {
1345
+ id: "M1",
1346
+ title: "Phase 1",
1347
+ description: "",
1348
+ status: "active",
1349
+ order: 1,
1350
+ created: "2026-01-01T00:00:00Z",
1351
+ updated: "2026-01-01T00:00:00Z",
1352
+ },
1353
+ ]);
1354
+ const current = getCurrentMilestone(tmpDir);
1355
+ expect(current?.id).toBe("M1");
1356
+ });
1357
+
1358
+ it("isMilestoneReady returns true when all predecessors are done", () => {
1359
+ setupMission([
1360
+ {
1361
+ id: "M1",
1362
+ title: "Phase 1",
1363
+ description: "",
1364
+ status: "done",
1365
+ order: 1,
1366
+ created: "2026-01-01T00:00:00Z",
1367
+ updated: "2026-01-01T00:00:00Z",
1368
+ },
1369
+ {
1370
+ id: "M2",
1371
+ title: "Phase 2",
1372
+ description: "",
1373
+ status: "active",
1374
+ order: 2,
1375
+ created: "2026-01-01T00:00:00Z",
1376
+ updated: "2026-01-01T00:00:00Z",
1377
+ },
1378
+ ]);
1379
+ expect(isMilestoneReady(tmpDir, "M2")).toBe(true);
1380
+ });
1381
+
1382
+ it("isMilestoneReady returns false when predecessor is not done", () => {
1383
+ setupMission([
1384
+ {
1385
+ id: "M1",
1386
+ title: "Phase 1",
1387
+ description: "",
1388
+ status: "active",
1389
+ order: 1,
1390
+ created: "2026-01-01T00:00:00Z",
1391
+ updated: "2026-01-01T00:00:00Z",
1392
+ },
1393
+ {
1394
+ id: "M2",
1395
+ title: "Phase 2",
1396
+ description: "",
1397
+ status: "locked",
1398
+ order: 2,
1399
+ created: "2026-01-01T00:00:00Z",
1400
+ updated: "2026-01-01T00:00:00Z",
1401
+ },
1402
+ ]);
1403
+ expect(isMilestoneReady(tmpDir, "M2")).toBe(false);
1404
+ });
1405
+
1406
+ it("dispatches task with active milestone M1", () => {
1407
+ setupMission([
1408
+ {
1409
+ id: "M1",
1410
+ title: "Phase 1",
1411
+ description: "",
1412
+ status: "active",
1413
+ order: 1,
1414
+ created: "2026-01-01T00:00:00Z",
1415
+ updated: "2026-01-01T00:00:00Z",
1416
+ },
1417
+ {
1418
+ id: "M2",
1419
+ title: "Phase 2",
1420
+ description: "",
1421
+ status: "locked",
1422
+ order: 2,
1423
+ created: "2026-01-01T00:00:00Z",
1424
+ updated: "2026-01-01T00:00:00Z",
1425
+ },
1426
+ ]);
1427
+
1428
+ const task = makeTask({ id: "001", milestone: "M1" });
1429
+ saveTask(tmpDir, task);
1430
+
1431
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
1432
+ const panes = [pane];
1433
+ mockPanes = panes;
1434
+
1435
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: null, dispatchMode: "missions" });
1436
+ const state = makeOrchestratorState();
1437
+
1438
+ dispatch(config, state, [task], panes);
1439
+
1440
+ const loaded = loadTask(tmpDir, "001");
1441
+ expect(loaded?.status).toBe("in-progress");
1442
+ expect(loaded?.assignee).toBe(agentIdentifier(pane));
1443
+ });
1444
+
1445
+ it("skips task with locked milestone M2 when M1 is still active", () => {
1446
+ setupMission([
1447
+ {
1448
+ id: "M1",
1449
+ title: "Phase 1",
1450
+ description: "",
1451
+ status: "active",
1452
+ order: 1,
1453
+ created: "2026-01-01T00:00:00Z",
1454
+ updated: "2026-01-01T00:00:00Z",
1455
+ },
1456
+ {
1457
+ id: "M2",
1458
+ title: "Phase 2",
1459
+ description: "",
1460
+ status: "locked",
1461
+ order: 2,
1462
+ created: "2026-01-01T00:00:00Z",
1463
+ updated: "2026-01-01T00:00:00Z",
1464
+ },
1465
+ ]);
1466
+
1467
+ const task = makeTask({ id: "001", milestone: "M2" });
1468
+ saveTask(tmpDir, task);
1469
+
1470
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
1471
+ const panes = [pane];
1472
+ mockPanes = panes;
1473
+
1474
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: null, dispatchMode: "missions" });
1475
+ const state = makeOrchestratorState();
1476
+
1477
+ dispatch(config, state, [task], panes);
1478
+
1479
+ const loaded = loadTask(tmpDir, "001");
1480
+ expect(loaded?.status).toBe("todo");
1481
+ expect(loaded?.assignee).toBe(null);
1482
+ });
1483
+
1484
+ it("tasks without milestone field always dispatch in missions mode", () => {
1485
+ setupMission([
1486
+ {
1487
+ id: "M1",
1488
+ title: "Phase 1",
1489
+ description: "",
1490
+ status: "active",
1491
+ order: 1,
1492
+ created: "2026-01-01T00:00:00Z",
1493
+ updated: "2026-01-01T00:00:00Z",
1494
+ },
1495
+ ]);
1496
+
1497
+ const task = makeTask({ id: "001", milestone: null });
1498
+ saveTask(tmpDir, task);
1499
+
1500
+ const pane = makePane({ id: "%1", index: 0, title: "Agent 1", currentCommand: "zsh" });
1501
+ const panes = [pane];
1502
+ mockPanes = panes;
1503
+
1504
+ const config = makeOrchestratorConfig(tmpDir, { masterPane: null, dispatchMode: "missions" });
1505
+ const state = makeOrchestratorState();
1506
+
1507
+ dispatch(config, state, [task], panes);
1508
+
1509
+ const loaded = loadTask(tmpDir, "001");
1510
+ expect(loaded?.status).toBe("in-progress");
1511
+ });
1512
+
1513
+ it("checkMilestoneCompletion marks M1 done and activates M2 when all M1 tasks complete (no contract)", () => {
1514
+ setupMission([
1515
+ {
1516
+ id: "M1",
1517
+ title: "Phase 1",
1518
+ description: "",
1519
+ status: "active",
1520
+ order: 1,
1521
+ created: "2026-01-01T00:00:00Z",
1522
+ updated: "2026-01-01T00:00:00Z",
1523
+ },
1524
+ {
1525
+ id: "M2",
1526
+ title: "Phase 2",
1527
+ description: "",
1528
+ status: "locked",
1529
+ order: 2,
1530
+ created: "2026-01-01T00:00:00Z",
1531
+ updated: "2026-01-01T00:00:00Z",
1532
+ },
1533
+ ]);
1534
+
1535
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", assignee: "Agent 1" });
1536
+ const task2 = makeTask({ id: "002", milestone: "M1", status: "done", assignee: "Agent 2" });
1537
+ saveTask(tmpDir, task1);
1538
+ saveTask(tmpDir, task2);
1539
+
1540
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
1541
+ const state = makeOrchestratorState();
1542
+ mockPanes = [];
1543
+ checkMilestoneCompletion(config, state, [task1, task2], []);
1544
+
1545
+ const mission = loadMission(tmpDir)!;
1546
+ const m1 = mission.milestones.find((m) => m.id === "M1")!;
1547
+ const m2 = mission.milestones.find((m) => m.id === "M2")!;
1548
+ expect(m1.status).toBe("done");
1549
+ expect(m2.status).toBe("active");
1550
+
1551
+ // Check event logged
1552
+ const events = readEvents(tmpDir);
1553
+ const milestoneEvent = events.find((e) => e.type === "milestone_complete");
1554
+ expect(milestoneEvent).toBeTruthy();
1555
+ expect(milestoneEvent!.message.includes("Phase 1")).toBeTruthy();
1556
+ });
1557
+
1558
+ it("checkMilestoneCompletion does not advance when tasks are still in progress", () => {
1559
+ setupMission([
1560
+ {
1561
+ id: "M1",
1562
+ title: "Phase 1",
1563
+ description: "",
1564
+ status: "active",
1565
+ order: 1,
1566
+ created: "2026-01-01T00:00:00Z",
1567
+ updated: "2026-01-01T00:00:00Z",
1568
+ },
1569
+ {
1570
+ id: "M2",
1571
+ title: "Phase 2",
1572
+ description: "",
1573
+ status: "locked",
1574
+ order: 2,
1575
+ created: "2026-01-01T00:00:00Z",
1576
+ updated: "2026-01-01T00:00:00Z",
1577
+ },
1578
+ ]);
1579
+
1580
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", assignee: "Agent 1" });
1581
+ const task2 = makeTask({
1582
+ id: "002",
1583
+ milestone: "M1",
1584
+ status: "in-progress",
1585
+ assignee: "Agent 2",
1586
+ });
1587
+ saveTask(tmpDir, task1);
1588
+ saveTask(tmpDir, task2);
1589
+
1590
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
1591
+ const state = makeOrchestratorState();
1592
+ checkMilestoneCompletion(config, state, [task1, task2], []);
1593
+
1594
+ const mission = loadMission(tmpDir)!;
1595
+ const m1 = mission.milestones.find((m) => m.id === "M1")!;
1596
+ const m2 = mission.milestones.find((m) => m.id === "M2")!;
1597
+ expect(m1.status).toBe("active");
1598
+ expect(m2.status).toBe("locked");
1599
+ });
1600
+ });
1601
+
1602
+ describe("validation flow", () => {
1603
+ function setupMission(milestones: Mission["milestones"]): void {
1604
+ saveMission(tmpDir, {
1605
+ title: "Ship v2",
1606
+ description: "",
1607
+ status: "active",
1608
+ branch: null,
1609
+ milestones,
1610
+ created: "2026-01-01T00:00:00Z",
1611
+ updated: "2026-01-01T00:00:00Z",
1612
+ });
1613
+ }
1614
+
1615
+ function writeContract(content: string): void {
1616
+ mkdirSync(join(tmpDir, ".tasks"), { recursive: true });
1617
+ writeFileSync(join(tmpDir, ".tasks", "validation-contract.md"), content);
1618
+ }
1619
+
1620
+ it("milestone completion triggers validating state when contract exists", () => {
1621
+ setupMission([
1622
+ {
1623
+ id: "M1",
1624
+ title: "Phase 1",
1625
+ description: "",
1626
+ status: "active",
1627
+ order: 1,
1628
+ created: "2026-01-01T00:00:00Z",
1629
+ updated: "2026-01-01T00:00:00Z",
1630
+ },
1631
+ {
1632
+ id: "M2",
1633
+ title: "Phase 2",
1634
+ description: "",
1635
+ status: "locked",
1636
+ order: 2,
1637
+ created: "2026-01-01T00:00:00Z",
1638
+ updated: "2026-01-01T00:00:00Z",
1639
+ },
1640
+ ]);
1641
+ writeContract("# Assertions\n- A1: Auth works\n- A2: Tests pass");
1642
+
1643
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", fulfills: ["A1"] });
1644
+ const task2 = makeTask({ id: "002", milestone: "M1", status: "done", fulfills: ["A2"] });
1645
+ saveTask(tmpDir, task1);
1646
+ saveTask(tmpDir, task2);
1647
+
1648
+ const validatorPane = makePane({
1649
+ id: "%3",
1650
+ index: 2,
1651
+ title: "Validator",
1652
+ currentCommand: "zsh",
1653
+ role: "validator",
1654
+ });
1655
+ mockPanes = [validatorPane];
1656
+
1657
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions", masterPane: null });
1658
+ const state = makeOrchestratorState();
1659
+
1660
+ checkMilestoneCompletion(config, state, [task1, task2], [validatorPane]);
1661
+
1662
+ // Milestone should be in validating, not done
1663
+ const mission = loadMission(tmpDir)!;
1664
+ const m1 = mission.milestones.find((m) => m.id === "M1")!;
1665
+ expect(m1.status).toBe("validating");
1666
+
1667
+ // Validation state should have pending entries
1668
+ const valState = loadValidationState(tmpDir)!;
1669
+ expect(valState.assertions["A1"]?.status).toBe("pending");
1670
+ expect(valState.assertions["A2"]?.status).toBe("pending");
1671
+
1672
+ // Should have dispatched to validator pane
1673
+ const sendCalls = tmuxCalls.filter((c) => c.args.includes("send-keys"));
1674
+ expect(sendCalls.length).toBeGreaterThan(0);
1675
+
1676
+ // Check event
1677
+ const events = readEvents(tmpDir);
1678
+ expect(events.find((e) => e.type === "milestone_validating")).toBeTruthy();
1679
+ expect(events.find((e) => e.type === "validation_dispatch")).toBeTruthy();
1680
+ });
1681
+
1682
+ it("all assertions passing marks milestone done and activates next", () => {
1683
+ setupMission([
1684
+ {
1685
+ id: "M1",
1686
+ title: "Phase 1",
1687
+ description: "",
1688
+ status: "validating",
1689
+ order: 1,
1690
+ created: "2026-01-01T00:00:00Z",
1691
+ updated: "2026-01-01T00:00:00Z",
1692
+ },
1693
+ {
1694
+ id: "M2",
1695
+ title: "Phase 2",
1696
+ description: "",
1697
+ status: "locked",
1698
+ order: 2,
1699
+ created: "2026-01-01T00:00:00Z",
1700
+ updated: "2026-01-01T00:00:00Z",
1701
+ },
1702
+ ]);
1703
+
1704
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", fulfills: ["A1"] });
1705
+ const task2 = makeTask({ id: "002", milestone: "M1", status: "done", fulfills: ["A2"] });
1706
+ saveTask(tmpDir, task1);
1707
+ saveTask(tmpDir, task2);
1708
+
1709
+ // All assertions passing
1710
+ saveValidationState(tmpDir, {
1711
+ assertions: {
1712
+ A1: {
1713
+ status: "passing",
1714
+ verifiedBy: "Validator",
1715
+ verifiedAt: "2026-01-01T00:00:00Z",
1716
+ evidence: "works",
1717
+ blockedBy: null,
1718
+ },
1719
+ A2: {
1720
+ status: "passing",
1721
+ verifiedBy: "Validator",
1722
+ verifiedAt: "2026-01-01T00:00:00Z",
1723
+ evidence: "tests pass",
1724
+ blockedBy: null,
1725
+ },
1726
+ },
1727
+ lastVerified: "2026-01-01T00:00:00Z",
1728
+ });
1729
+
1730
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
1731
+ checkValidationResults(config, [task1, task2]);
1732
+
1733
+ const mission = loadMission(tmpDir)!;
1734
+ const m1 = mission.milestones.find((m) => m.id === "M1")!;
1735
+ const m2 = mission.milestones.find((m) => m.id === "M2")!;
1736
+ expect(m1.status).toBe("done");
1737
+ expect(m2.status).toBe("active");
1738
+
1739
+ const events = readEvents(tmpDir);
1740
+ expect(events.find((e) => e.type === "milestone_complete")).toBeTruthy();
1741
+ });
1742
+
1743
+ it("zero-assertion milestone advances to done", () => {
1744
+ setupMission([
1745
+ {
1746
+ id: "M1",
1747
+ title: "Phase 1",
1748
+ description: "",
1749
+ status: "validating",
1750
+ order: 1,
1751
+ created: "2026-01-01T00:00:00Z",
1752
+ updated: "2026-01-01T00:00:00Z",
1753
+ },
1754
+ {
1755
+ id: "M2",
1756
+ title: "Phase 2",
1757
+ description: "",
1758
+ status: "locked",
1759
+ order: 2,
1760
+ created: "2026-01-01T00:00:00Z",
1761
+ updated: "2026-01-01T00:00:00Z",
1762
+ },
1763
+ ]);
1764
+
1765
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", fulfills: [] });
1766
+ saveTask(tmpDir, task1);
1767
+ saveValidationState(tmpDir, { assertions: {}, lastVerified: "2026-01-01T00:00:00Z" });
1768
+
1769
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
1770
+ checkValidationResults(config, [task1]);
1771
+
1772
+ const mission = loadMission(tmpDir)!;
1773
+ expect(mission.milestones.find((m) => m.id === "M1")?.status).toBe("done");
1774
+ expect(mission.milestones.find((m) => m.id === "M2")?.status).toBe("active");
1775
+ });
1776
+
1777
+ it("failed assertion creates remediation task and resets milestone to active", () => {
1778
+ setupMission([
1779
+ {
1780
+ id: "M1",
1781
+ title: "Phase 1",
1782
+ description: "",
1783
+ status: "validating",
1784
+ order: 1,
1785
+ created: "2026-01-01T00:00:00Z",
1786
+ updated: "2026-01-01T00:00:00Z",
1787
+ },
1788
+ ]);
1789
+
1790
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", fulfills: ["A1", "A2"] });
1791
+ saveTask(tmpDir, task1);
1792
+
1793
+ saveValidationState(tmpDir, {
1794
+ assertions: {
1795
+ A1: {
1796
+ status: "passing",
1797
+ verifiedBy: "V",
1798
+ verifiedAt: "2026-01-01T00:00:00Z",
1799
+ evidence: "ok",
1800
+ blockedBy: null,
1801
+ },
1802
+ A2: {
1803
+ status: "failing",
1804
+ verifiedBy: "V",
1805
+ verifiedAt: "2026-01-01T00:00:00Z",
1806
+ evidence: "auth broken",
1807
+ blockedBy: null,
1808
+ },
1809
+ },
1810
+ lastVerified: "2026-01-01T00:00:00Z",
1811
+ });
1812
+
1813
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
1814
+ checkValidationResults(config, [task1]);
1815
+
1816
+ // Milestone should be back to active
1817
+ const mission = loadMission(tmpDir)!;
1818
+ const m1 = mission.milestones.find((m) => m.id === "M1")!;
1819
+ expect(m1.status).toBe("active");
1820
+
1821
+ // Remediation task should exist
1822
+ const allTasks = loadTasks(tmpDir);
1823
+ const remediation = allTasks.find((t) => t.tags.includes("remediation"));
1824
+ expect(remediation).toBeTruthy();
1825
+ expect(remediation!.milestone).toBe("M1");
1826
+ expect(remediation!.fulfills).toContain("A2");
1827
+ expect(remediation!.description).toContain("auth broken");
1828
+
1829
+ // Failed assertion should be reset to pending
1830
+ const valState = loadValidationState(tmpDir)!;
1831
+ expect(valState.assertions["A2"]?.status).toBe("pending");
1832
+
1833
+ // Events
1834
+ const events = readEvents(tmpDir);
1835
+ expect(events.find((e) => e.type === "remediation")).toBeTruthy();
1836
+ expect(events.find((e) => e.type === "validation_failed")).toBeTruthy();
1837
+ });
1838
+
1839
+ it("remediation skips missing assertion entries without crashing", () => {
1840
+ setupMission([
1841
+ {
1842
+ id: "M1",
1843
+ title: "Phase 1",
1844
+ description: "",
1845
+ status: "validating",
1846
+ order: 1,
1847
+ created: "2026-01-01T00:00:00Z",
1848
+ updated: "2026-01-01T00:00:00Z",
1849
+ },
1850
+ ]);
1851
+
1852
+ const task1 = makeTask({ id: "001", milestone: "M1", status: "done", fulfills: ["A1", "A2"] });
1853
+ saveTask(tmpDir, task1);
1854
+ writeFileSync(
1855
+ join(tmpDir, ".tasks", "validation-state.json"),
1856
+ JSON.stringify({
1857
+ assertions: {
1858
+ A1: {
1859
+ status: "failing",
1860
+ verifiedBy: "V",
1861
+ verifiedAt: "2026-01-01T00:00:00Z",
1862
+ evidence: "broken",
1863
+ blockedBy: null,
1864
+ },
1865
+ A2: null,
1866
+ },
1867
+ lastVerified: "2026-01-01T00:00:00Z",
1868
+ }),
1869
+ );
1870
+
1871
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
1872
+ expect(() => checkValidationResults(config, [task1])).not.toThrow();
1873
+ });
1874
+
1875
+ it("buildValidationPrompt includes contract and task details", () => {
1876
+ writeContract("# Assertions\n- A1: Auth endpoint returns 200");
1877
+
1878
+ const milestone = {
1879
+ id: "M1",
1880
+ title: "Phase 1",
1881
+ description: "",
1882
+ status: "validating" as const,
1883
+ order: 1,
1884
+ created: "2026-01-01T00:00:00Z",
1885
+ updated: "2026-01-01T00:00:00Z",
1886
+ };
1887
+ const tasks = [
1888
+ makeTask({
1889
+ id: "001",
1890
+ title: "Fix auth",
1891
+ fulfills: ["A1"],
1892
+ proof: { notes: "done" },
1893
+ salientSummary: "Patched JWT",
1894
+ }),
1895
+ ];
1896
+
1897
+ const prompt = buildValidationPrompt(tmpDir, milestone, tasks);
1898
+ expect(prompt.includes("Validation: Phase 1")).toBeTruthy();
1899
+ expect(prompt.includes("Auth endpoint returns 200")).toBeTruthy();
1900
+ expect(prompt.includes("Fix auth")).toBeTruthy();
1901
+ expect(prompt.includes("A1")).toBeTruthy();
1902
+ expect(prompt.includes("Patched JWT")).toBeTruthy();
1903
+ expect(prompt.includes("tmux-ide validate assert")).toBeTruthy();
1904
+ });
1905
+ });
1906
+
1907
+ describe("structured handoff", () => {
1908
+ it("logs salientSummary in completion event", () => {
1909
+ const task = makeTask({
1910
+ status: "done",
1911
+ assignee: "Agent 1",
1912
+ salientSummary: "Discovered JWT needs rotation",
1913
+ });
1914
+
1915
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
1916
+
1917
+ const config = makeOrchestratorConfig(tmpDir);
1918
+ const state = makeOrchestratorState({
1919
+ previousTasks: new Map([["001", "in-progress"]]),
1920
+ });
1921
+
1922
+ detectCompletions(config, state, [task], panes);
1923
+
1924
+ const events = readEvents(tmpDir);
1925
+ const completionEvent = events.find((e) => e.type === "completion");
1926
+ expect(completionEvent).toBeTruthy();
1927
+ expect(completionEvent!.message.includes("Discovered JWT needs rotation")).toBeTruthy();
1928
+ });
1929
+
1930
+ it("auto-creates follow-up tasks for discoveredIssues", () => {
1931
+ const task = makeTask({
1932
+ status: "done",
1933
+ assignee: "Agent 1",
1934
+ discoveredIssues: ["CSS z-index conflict in modal", "Missing rate limiting on /api/auth"],
1935
+ });
1936
+ saveTask(tmpDir, task);
1937
+
1938
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
1939
+
1940
+ const config = makeOrchestratorConfig(tmpDir);
1941
+ const state = makeOrchestratorState({
1942
+ previousTasks: new Map([["001", "in-progress"]]),
1943
+ });
1944
+
1945
+ detectCompletions(config, state, [task], panes);
1946
+
1947
+ // Two follow-up tasks should be created
1948
+ const allTasks = loadTasks(tmpDir);
1949
+ const followUps = allTasks.filter((t) => t.tags.includes("discovered-issue"));
1950
+ expect(followUps.length).toBe(2);
1951
+ expect(followUps[0]!.description).toContain("CSS z-index");
1952
+ expect(followUps[1]!.description).toContain("rate limiting");
1953
+
1954
+ // Events
1955
+ const events = readEvents(tmpDir);
1956
+ const issueEvents = events.filter((e) => e.type === "discovered_issue");
1957
+ expect(issueEvents.length).toBe(2);
1958
+ });
1959
+ });
1960
+
1961
+ describe("rich dispatch prompts", () => {
1962
+ it("includes milestone context when task has milestone", () => {
1963
+ saveMission(tmpDir, {
1964
+ title: "Ship v2",
1965
+ description: "",
1966
+ status: "active",
1967
+ branch: null,
1968
+ milestones: [
1969
+ {
1970
+ id: "M1",
1971
+ title: "Foundation",
1972
+ description: "Core infra setup",
1973
+ status: "active",
1974
+ order: 1,
1975
+ created: "2026-01-01T00:00:00Z",
1976
+ updated: "2026-01-01T00:00:00Z",
1977
+ },
1978
+ ],
1979
+ created: "2026-01-01T00:00:00Z",
1980
+ updated: "2026-01-01T00:00:00Z",
1981
+ });
1982
+
1983
+ const task = makeTask({ milestone: "M1" });
1984
+ const prompt = buildTaskPrompt(tmpDir, task);
1985
+
1986
+ expect(prompt.includes("Milestone: Foundation (M1)")).toBeTruthy();
1987
+ expect(prompt.includes("Core infra setup")).toBeTruthy();
1988
+ });
1989
+
1990
+ it("includes AGENTS.md content when file exists", () => {
1991
+ writeFileSync(join(tmpDir, "AGENTS.md"), "Do not modify the auth module.\nStay in your lane.");
1992
+
1993
+ const task = makeTask();
1994
+ const prompt = buildTaskPrompt(tmpDir, task);
1995
+
1996
+ expect(prompt.includes("Agent Guidelines")).toBeTruthy();
1997
+ expect(prompt.includes("Do not modify the auth module")).toBeTruthy();
1998
+ });
1999
+
2000
+ it("includes recent completions from same milestone", () => {
2001
+ saveMission(tmpDir, {
2002
+ title: "Ship v2",
2003
+ description: "",
2004
+ status: "active",
2005
+ branch: null,
2006
+ milestones: [
2007
+ {
2008
+ id: "M1",
2009
+ title: "Phase 1",
2010
+ description: "",
2011
+ status: "active",
2012
+ order: 1,
2013
+ created: "2026-01-01T00:00:00Z",
2014
+ updated: "2026-01-01T00:00:00Z",
2015
+ },
2016
+ ],
2017
+ created: "2026-01-01T00:00:00Z",
2018
+ updated: "2026-01-01T00:00:00Z",
2019
+ });
2020
+
2021
+ // Save a completed task in the same milestone
2022
+ saveTask(
2023
+ tmpDir,
2024
+ makeTask({
2025
+ id: "099",
2026
+ title: "Setup database",
2027
+ milestone: "M1",
2028
+ status: "done",
2029
+ assignee: "Agent 1",
2030
+ salientSummary: "Used Postgres with pgvector",
2031
+ }),
2032
+ );
2033
+
2034
+ const task = makeTask({ id: "100", milestone: "M1" });
2035
+ const prompt = buildTaskPrompt(tmpDir, task);
2036
+
2037
+ expect(prompt.includes("Recent Completions")).toBeTruthy();
2038
+ expect(prompt.includes("Setup database")).toBeTruthy();
2039
+ expect(prompt.includes("Used Postgres with pgvector")).toBeTruthy();
2040
+ });
2041
+
2042
+ it("includes library architecture.md when present", () => {
2043
+ const libraryDir = join(tmpDir, ".tmux-ide", "library");
2044
+ mkdirSync(libraryDir, { recursive: true });
2045
+ writeFileSync(join(libraryDir, "architecture.md"), "Monorepo with pnpm workspaces");
2046
+
2047
+ const task = makeTask();
2048
+ const prompt = buildTaskPrompt(tmpDir, task);
2049
+
2050
+ expect(prompt.includes("Architecture")).toBeTruthy();
2051
+ expect(prompt.includes("Monorepo with pnpm workspaces")).toBeTruthy();
2052
+ });
2053
+
2054
+ it("includes library files matching task tags", () => {
2055
+ const libraryDir = join(tmpDir, ".tmux-ide", "library");
2056
+ mkdirSync(libraryDir, { recursive: true });
2057
+ writeFileSync(join(libraryDir, "security.md"), "Always sanitize inputs");
2058
+
2059
+ const task = makeTask({ tags: ["security"] });
2060
+ const prompt = buildTaskPrompt(tmpDir, task);
2061
+
2062
+ expect(prompt.includes("Reference: security.md")).toBeTruthy();
2063
+ expect(prompt.includes("Always sanitize inputs")).toBeTruthy();
2064
+ });
2065
+
2066
+ it("truncates large library files", () => {
2067
+ const libraryDir = join(tmpDir, ".tmux-ide", "library");
2068
+ mkdirSync(libraryDir, { recursive: true });
2069
+ writeFileSync(join(libraryDir, "architecture.md"), "x".repeat(600));
2070
+
2071
+ const task = makeTask();
2072
+ const prompt = buildTaskPrompt(tmpDir, task);
2073
+
2074
+ expect(prompt.includes("... (see full file at")).toBeTruthy();
2075
+ });
2076
+
2077
+ it("includes services when config provides them", () => {
2078
+ const task = makeTask();
2079
+ const config = makeOrchestratorConfig(tmpDir, {
2080
+ services: {
2081
+ dev: { command: "pnpm dev", port: 3000 },
2082
+ test: { command: "pnpm test" },
2083
+ },
2084
+ });
2085
+ const prompt = buildTaskPrompt(tmpDir, task, config);
2086
+
2087
+ expect(prompt.includes("Available Services")).toBeTruthy();
2088
+ expect(prompt.includes("pnpm dev")).toBeTruthy();
2089
+ expect(prompt.includes("port 3000")).toBeTruthy();
2090
+ expect(prompt.includes("pnpm test")).toBeTruthy();
2091
+ });
2092
+
2093
+ it("includes fulfills in task details", () => {
2094
+ const task = makeTask({ fulfills: ["A1", "A2"] });
2095
+ const prompt = buildTaskPrompt(tmpDir, task);
2096
+ expect(prompt.includes("Fulfills assertions: A1, A2")).toBeTruthy();
2097
+ });
2098
+ });
2099
+
2100
+ describe("knowledge library", () => {
2101
+ it("appends salientSummary to learnings.md on task completion", () => {
2102
+ const task = makeTask({
2103
+ status: "done",
2104
+ assignee: "Agent 1",
2105
+ salientSummary: "JWT refresh tokens need 15min expiry",
2106
+ });
2107
+
2108
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
2109
+
2110
+ const config = makeOrchestratorConfig(tmpDir);
2111
+ const state = makeOrchestratorState({
2112
+ previousTasks: new Map([["001", "in-progress"]]),
2113
+ });
2114
+
2115
+ detectCompletions(config, state, [task], panes);
2116
+
2117
+ const learningsPath = join(tmpDir, ".tmux-ide", "library", "learnings.md");
2118
+ expect(existsSync(learningsPath)).toBeTruthy();
2119
+ const content = readFileSync(learningsPath, "utf-8");
2120
+ expect(content.includes("Task 001: Test task")).toBeTruthy();
2121
+ expect(content.includes("JWT refresh tokens need 15min expiry")).toBeTruthy();
2122
+ });
2123
+
2124
+ it("does not create learnings.md when no salientSummary", () => {
2125
+ const task = makeTask({
2126
+ status: "done",
2127
+ assignee: "Agent 1",
2128
+ salientSummary: null,
2129
+ });
2130
+
2131
+ const panes: PaneInfo[] = [makePane({ id: "%0", title: "Master" })];
2132
+
2133
+ const config = makeOrchestratorConfig(tmpDir);
2134
+ const state = makeOrchestratorState({
2135
+ previousTasks: new Map([["001", "in-progress"]]),
2136
+ });
2137
+
2138
+ detectCompletions(config, state, [task], panes);
2139
+
2140
+ const learningsPath = join(tmpDir, ".tmux-ide", "library", "learnings.md");
2141
+ expect(existsSync(learningsPath)).toBe(false);
2142
+ });
2143
+ });
2144
+
2145
+ describe("mission lifecycle", () => {
2146
+ it("dispatches planning when mission has no milestones", () => {
2147
+ saveMission(tmpDir, {
2148
+ title: "Ship v2",
2149
+ description: "Major release",
2150
+ status: "planning",
2151
+ branch: null,
2152
+ milestones: [],
2153
+ created: "2026-01-01T00:00:00Z",
2154
+ updated: "2026-01-01T00:00:00Z",
2155
+ });
2156
+
2157
+ const leadPane = makePane({
2158
+ id: "%0",
2159
+ index: 0,
2160
+ title: "Lead",
2161
+ currentCommand: "zsh",
2162
+ role: "lead",
2163
+ });
2164
+ mockPanes = [leadPane];
2165
+
2166
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions", masterPane: "Lead" });
2167
+ const state = makeOrchestratorState();
2168
+
2169
+ dispatchPlanning(config, state, [leadPane]);
2170
+
2171
+ // Should have claimed __planning__
2172
+ expect(state.claimedTasks.has("__planning__")).toBeTruthy();
2173
+
2174
+ // Should have sent command to lead pane
2175
+ const sendCalls = tmuxCalls.filter((c) => c.args.includes("send-keys"));
2176
+ expect(sendCalls.length).toBeGreaterThan(0);
2177
+
2178
+ // Dispatch file should exist
2179
+ expect(existsSync(join(tmpDir, ".tasks", "dispatch", "planning.md"))).toBeTruthy();
2180
+
2181
+ // Event should be logged
2182
+ const events = readEvents(tmpDir);
2183
+ expect(events.find((e) => e.type === "planning")).toBeTruthy();
2184
+ });
2185
+
2186
+ it("does not dispatch planning twice", () => {
2187
+ saveMission(tmpDir, {
2188
+ title: "Ship v2",
2189
+ description: "",
2190
+ status: "planning",
2191
+ branch: null,
2192
+ milestones: [],
2193
+ created: "2026-01-01T00:00:00Z",
2194
+ updated: "2026-01-01T00:00:00Z",
2195
+ });
2196
+
2197
+ const leadPane = makePane({
2198
+ id: "%0",
2199
+ index: 0,
2200
+ title: "Lead",
2201
+ currentCommand: "zsh",
2202
+ role: "lead",
2203
+ });
2204
+ mockPanes = [leadPane];
2205
+
2206
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
2207
+ const state = makeOrchestratorState();
2208
+
2209
+ dispatchPlanning(config, state, [leadPane]);
2210
+ const callCount1 = tmuxCalls.filter((c) => c.args.includes("send-keys")).length;
2211
+
2212
+ dispatchPlanning(config, state, [leadPane]);
2213
+ const callCount2 = tmuxCalls.filter((c) => c.args.includes("send-keys")).length;
2214
+
2215
+ // Second call should not generate additional send-keys
2216
+ expect(callCount2).toBe(callCount1);
2217
+ });
2218
+
2219
+ it("logs an error when planning cannot be dispatched", () => {
2220
+ saveMission(tmpDir, {
2221
+ title: "Ship v2",
2222
+ description: "",
2223
+ status: "planning",
2224
+ branch: null,
2225
+ milestones: [],
2226
+ created: "2026-01-01T00:00:00Z",
2227
+ updated: "2026-01-01T00:00:00Z",
2228
+ });
2229
+
2230
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
2231
+ const state = makeOrchestratorState();
2232
+
2233
+ dispatchPlanning(config, state, []);
2234
+
2235
+ const events = readEvents(tmpDir);
2236
+ const errorEvent = events.find((e) => e.type === "error");
2237
+ expect(errorEvent).toBeTruthy();
2238
+ expect(errorEvent?.message).toContain("no planner pane available");
2239
+ expect(state.claimedTasks.has("__planning__")).toBe(false);
2240
+ });
2241
+
2242
+ it("buildPlanningPrompt includes mission context and instructions", () => {
2243
+ saveMission(tmpDir, {
2244
+ title: "Ship v2",
2245
+ description: "Complete rewrite of auth",
2246
+ status: "planning",
2247
+ branch: null,
2248
+ milestones: [],
2249
+ created: "2026-01-01T00:00:00Z",
2250
+ updated: "2026-01-01T00:00:00Z",
2251
+ });
2252
+
2253
+ const prompt = buildPlanningPrompt(tmpDir);
2254
+
2255
+ expect(prompt.includes("Mission Planning")).toBeTruthy();
2256
+ expect(prompt.includes("Ship v2")).toBeTruthy();
2257
+ expect(prompt.includes("Complete rewrite of auth")).toBeTruthy();
2258
+ expect(prompt.includes("milestone create")).toBeTruthy();
2259
+ expect(prompt.includes("validation-contract.md")).toBeTruthy();
2260
+ expect(prompt.includes("plan-complete")).toBeTruthy();
2261
+ });
2262
+
2263
+ it("handleMissionComplete sets status and logs event", () => {
2264
+ const mission = {
2265
+ title: "Ship v2",
2266
+ description: "Major release",
2267
+ status: "active" as const,
2268
+ branch: null,
2269
+ milestones: [
2270
+ {
2271
+ id: "M1",
2272
+ title: "Phase 1",
2273
+ description: "",
2274
+ status: "done" as const,
2275
+ order: 1,
2276
+ created: "2026-01-01T00:00:00Z",
2277
+ updated: "2026-01-01T00:00:00Z",
2278
+ },
2279
+ ],
2280
+ created: "2026-01-01T00:00:00Z",
2281
+ updated: "2026-01-01T00:00:00Z",
2282
+ };
2283
+ saveMission(tmpDir, mission);
2284
+
2285
+ const masterPane = makePane({ id: "%0", title: "Master", role: "lead" });
2286
+ mockPanes = [masterPane];
2287
+
2288
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
2289
+ handleMissionComplete(config, mission, [masterPane]);
2290
+
2291
+ // Mission should be complete
2292
+ const updated = loadMission(tmpDir)!;
2293
+ expect(updated.status).toBe("complete");
2294
+
2295
+ // Dispatch file should exist
2296
+ expect(existsSync(join(tmpDir, ".tasks", "dispatch", "mission-complete.md"))).toBeTruthy();
2297
+
2298
+ // Event should be logged
2299
+ const events = readEvents(tmpDir);
2300
+ const missionEvent = events.find((e) => e.type === "mission_complete");
2301
+ expect(missionEvent).toBeTruthy();
2302
+ expect(missionEvent!.message.includes("Ship v2")).toBeTruthy();
2303
+
2304
+ // Master pane should be notified
2305
+ const sendCalls = tmuxCalls.filter(
2306
+ (c) => c.args.includes("send-keys") && c.args.includes("%0"),
2307
+ );
2308
+ expect(sendCalls.length).toBeGreaterThan(0);
2309
+ });
2310
+
2311
+ it("handleMissionComplete returns early when milestones are not done", () => {
2312
+ const mission = {
2313
+ title: "Ship v2",
2314
+ description: "Major release",
2315
+ status: "active" as const,
2316
+ branch: null,
2317
+ milestones: [
2318
+ {
2319
+ id: "M1",
2320
+ title: "Phase 1",
2321
+ description: "",
2322
+ status: "active" as const,
2323
+ order: 1,
2324
+ created: "2026-01-01T00:00:00Z",
2325
+ updated: "2026-01-01T00:00:00Z",
2326
+ },
2327
+ ],
2328
+ created: "2026-01-01T00:00:00Z",
2329
+ updated: "2026-01-01T00:00:00Z",
2330
+ };
2331
+ saveMission(tmpDir, mission);
2332
+
2333
+ const config = makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" });
2334
+ handleMissionComplete(config, mission, []);
2335
+
2336
+ expect(loadMission(tmpDir)?.status).toBe("active");
2337
+ expect(existsSync(join(tmpDir, ".tasks", "dispatch", "mission-complete.md"))).toBe(false);
2338
+ });
2339
+
2340
+ it("mission create sets status to planning", () => {
2341
+ // This tests the data model — mission create in task.ts sets status: "planning"
2342
+ // Verify via direct mission construction (CLI test would need process spawn)
2343
+ const now = new Date().toISOString();
2344
+ saveMission(tmpDir, {
2345
+ title: "New Project",
2346
+ description: "Big thing",
2347
+ status: "planning",
2348
+ branch: null,
2349
+ milestones: [],
2350
+ created: now,
2351
+ updated: now,
2352
+ });
2353
+
2354
+ const mission = loadMission(tmpDir)!;
2355
+ expect(mission.status).toBe("planning");
2356
+ expect(mission.milestones.length).toBe(0);
2357
+ });
2358
+ });