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,76 @@
1
+ import { IdeError } from "./errors.ts";
2
+ import type { IdeConfig } from "../types.ts";
3
+
4
+ export function printLayout(config: IdeConfig): void {
5
+ const INNER = 40;
6
+ const rows = config.rows ?? [];
7
+ if (rows.length === 0) return;
8
+
9
+ for (let r = 0; r < rows.length; r++) {
10
+ const panes = rows[r]!.panes ?? [];
11
+ const count = panes.length || 1;
12
+ const widths: number[] = [];
13
+ let remaining = INNER;
14
+ for (let i = 0; i < count; i++) {
15
+ const w = i < count - 1 ? Math.floor(INNER / count) : remaining;
16
+ widths.push(w);
17
+ remaining -= w;
18
+ }
19
+
20
+ // Top border or mid divider
21
+ if (r === 0) {
22
+ let top = " \u250c";
23
+ for (let i = 0; i < count; i++) {
24
+ top += "\u2500".repeat(widths[i]!);
25
+ top += i < count - 1 ? "\u252c" : "\u2510";
26
+ }
27
+ console.log(top);
28
+ } else {
29
+ console.log(" \u251c" + "\u2500".repeat(INNER + count - 1) + "\u2524");
30
+ }
31
+
32
+ // Content line
33
+ const sizeLabel = rows[r]!.size ?? "";
34
+ let line = " \u2502";
35
+ for (let i = 0; i < count; i++) {
36
+ const title = panes[i]?.title ?? "";
37
+ const w = widths[i]!;
38
+ const pad = Math.max(0, w - title.length);
39
+ const left = Math.floor(pad / 2);
40
+ const right = pad - left;
41
+ line += " ".repeat(left) + title + " ".repeat(right) + "\u2502";
42
+ }
43
+ if (sizeLabel) line += " " + sizeLabel;
44
+ console.log(line);
45
+
46
+ // Bottom border (last row only)
47
+ if (r === rows.length - 1) {
48
+ let bot = " \u2514";
49
+ for (let i = 0; i < count; i++) {
50
+ bot += "\u2500".repeat(widths[i]!);
51
+ bot += i < count - 1 ? "\u2534" : "\u2518";
52
+ }
53
+ console.log(bot);
54
+ }
55
+ }
56
+ }
57
+
58
+ export function outputError(
59
+ message: string,
60
+ code: string,
61
+ { exitCode = 1 }: { exitCode?: number } = {},
62
+ ): never {
63
+ throw new IdeError(message, { code, exitCode });
64
+ }
65
+
66
+ export function printCommandError(
67
+ error: IdeError,
68
+ { json = false }: { json?: boolean } = {},
69
+ ): never {
70
+ if (json) {
71
+ console.error(JSON.stringify(error.toJSON(), null, 2));
72
+ } else {
73
+ console.error(error.message);
74
+ }
75
+ process.exit(error.exitCode ?? 1);
76
+ }
@@ -0,0 +1,148 @@
1
+ import { describe, it, beforeEach, afterEach, expect } from "bun:test";
2
+ import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { loadPlans, listPlans, markPlanDone, getPlan } from "./plan-store.ts";
6
+
7
+ let tmpDir: string;
8
+
9
+ function writePlan(name: string, content: string) {
10
+ const dir = join(tmpDir, "plans");
11
+ mkdirSync(dir, { recursive: true });
12
+ writeFileSync(join(dir, `${name}.md`), content);
13
+ }
14
+
15
+ beforeEach(() => {
16
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-plans-test-"));
17
+ });
18
+
19
+ afterEach(() => {
20
+ rmSync(tmpDir, { recursive: true, force: true });
21
+ });
22
+
23
+ describe("loadPlans", () => {
24
+ it("returns empty when plans/ does not exist", () => {
25
+ expect(loadPlans(tmpDir)).toEqual([]);
26
+ });
27
+
28
+ it("loads plans and parses status", () => {
29
+ writePlan(
30
+ "01-mouse-support",
31
+ "# Plan 01: Mouse Support\n\n**Status:** `done`\n**Effort:** Low\n",
32
+ );
33
+ writePlan(
34
+ "70-hierarchical-agents",
35
+ "# Plan 70: Hierarchical Agents\n\n**Status:** `in-progress`\n**Effort:** High\n**Gate:** agents work\n",
36
+ );
37
+
38
+ const plans = loadPlans(tmpDir);
39
+ expect(plans.length).toBe(2);
40
+ expect(plans[0]!.name).toBe("01-mouse-support");
41
+ expect(plans[0]!.status).toBe("done");
42
+ expect(plans[0]!.effort).toBe("Low");
43
+ expect(plans[1]!.name).toBe("70-hierarchical-agents");
44
+ expect(plans[1]!.status).toBe("in-progress");
45
+ expect(plans[1]!.gate).toBe("agents work");
46
+ });
47
+
48
+ it("skips ROADMAP.md", () => {
49
+ writePlan("ROADMAP", "# Roadmap\n");
50
+ writePlan("01-test", "# Test\n\n**Status:** `pending`\n");
51
+
52
+ const plans = loadPlans(tmpDir);
53
+ expect(plans.length).toBe(1);
54
+ expect(plans[0]!.name).toBe("01-test");
55
+ });
56
+
57
+ it("defaults to pending when no status found", () => {
58
+ writePlan("99-no-status", "# No Status Plan\n\nJust content.\n");
59
+ const plans = loadPlans(tmpDir);
60
+ expect(plans[0]!.status).toBe("pending");
61
+ });
62
+ });
63
+
64
+ describe("listPlans", () => {
65
+ it("filters by status", () => {
66
+ writePlan("01-done", "# Done\n\n**Status:** `done`\n");
67
+ writePlan("02-pending", "# Pending\n\n**Status:** `pending`\n");
68
+ writePlan("03-active", "# Active\n\n**Status:** `in-progress`\n");
69
+
70
+ const done = listPlans(tmpDir, { status: "done" });
71
+ expect(done.length).toBe(1);
72
+ expect(done[0]!.name).toBe("01-done");
73
+
74
+ const active = listPlans(tmpDir, { status: "in-progress" });
75
+ expect(active.length).toBe(1);
76
+ expect(active[0]!.name).toBe("03-active");
77
+ });
78
+
79
+ it("returns all when no filter", () => {
80
+ writePlan("01-a", "# A\n\n**Status:** `done`\n");
81
+ writePlan("02-b", "# B\n\n**Status:** `pending`\n");
82
+
83
+ expect(listPlans(tmpDir).length).toBe(2);
84
+ });
85
+ });
86
+
87
+ describe("markPlanDone", () => {
88
+ it("updates status to done and adds completed date", () => {
89
+ writePlan(
90
+ "50-task-cli",
91
+ "# Plan 50: Task CLI\n\n**Status:** `in-progress`\n**Effort:** Medium\n",
92
+ );
93
+
94
+ const result = markPlanDone(tmpDir, "50");
95
+ expect(result).toBeTruthy();
96
+ expect(result!.status).toBe("done");
97
+ expect(result!.completed).toBeTruthy();
98
+
99
+ // Verify file was updated
100
+ const content = readFileSync(join(tmpDir, "plans", "50-task-cli.md"), "utf-8");
101
+ expect(content.includes("**Status:** `done`")).toBeTruthy();
102
+ expect(content.includes("**Completed:**")).toBeTruthy();
103
+ });
104
+
105
+ it("matches by full name", () => {
106
+ writePlan("my-plan", "# My Plan\n\n**Status:** `pending`\n");
107
+ const result = markPlanDone(tmpDir, "my-plan");
108
+ expect(result).toBeTruthy();
109
+ expect(result!.status).toBe("done");
110
+ });
111
+
112
+ it("matches by number prefix", () => {
113
+ writePlan("70-hierarchical-agents", "# Plan 70\n\n**Status:** `in-progress`\n");
114
+ const result = markPlanDone(tmpDir, "70");
115
+ expect(result).toBeTruthy();
116
+ });
117
+
118
+ it("returns null for non-existent plan", () => {
119
+ expect(markPlanDone(tmpDir, "nonexistent")).toBe(null);
120
+ });
121
+
122
+ it("preserves existing completed date format on re-mark", () => {
123
+ writePlan("01-test", "# Test\n\n**Status:** `done`\n**Completed:** 2026-01-01\n");
124
+ const result = markPlanDone(tmpDir, "01");
125
+ expect(result).toBeTruthy();
126
+ // Should update the date
127
+ expect(result!.completed).not.toBe("2026-01-01");
128
+ });
129
+ });
130
+
131
+ describe("getPlan", () => {
132
+ it("finds by name", () => {
133
+ writePlan("50-task-cli", "# Plan 50\n\n**Status:** `done`\n");
134
+ const plan = getPlan(tmpDir, "50-task-cli");
135
+ expect(plan).toBeTruthy();
136
+ expect(plan!.name).toBe("50-task-cli");
137
+ });
138
+
139
+ it("finds by number prefix", () => {
140
+ writePlan("50-task-cli", "# Plan 50\n\n**Status:** `done`\n");
141
+ const plan = getPlan(tmpDir, "50");
142
+ expect(plan).toBeTruthy();
143
+ });
144
+
145
+ it("returns null when not found", () => {
146
+ expect(getPlan(tmpDir, "99")).toBe(null);
147
+ });
148
+ });
@@ -0,0 +1,131 @@
1
+ import { readdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ export type PlanStatus = "pending" | "in-progress" | "done" | "archived";
5
+
6
+ export interface PlanMeta {
7
+ name: string; // filename without .md
8
+ path: string; // relative path from project root
9
+ title: string; // first # heading
10
+ status: PlanStatus;
11
+ effort?: string;
12
+ gate?: string;
13
+ completed?: string; // ISO date when marked done
14
+ }
15
+
16
+ /**
17
+ * Parse plan status from the **Status:** `xxx` pattern used in plan files.
18
+ */
19
+ function parseInlineStatus(content: string): PlanStatus {
20
+ const match = content.match(/\*\*Status:\*\*\s*`([^`]+)`/);
21
+ if (!match) return "pending";
22
+ const raw = match[1]!.toLowerCase().trim();
23
+ if (raw === "done" || raw === "completed") return "done";
24
+ if (raw === "in-progress" || raw === "in progress" || raw === "active") return "in-progress";
25
+ if (raw === "archived") return "archived";
26
+ return "pending";
27
+ }
28
+
29
+ function parseInlineField(content: string, field: string): string | undefined {
30
+ const re = new RegExp(`\\*\\*${field}:\\*\\*\\s*(.+?)\\s*$`, "m");
31
+ const match = content.match(re);
32
+ return match ? match[1]!.replace(/^`|`$/g, "").trim() : undefined;
33
+ }
34
+
35
+ function parseTitle(content: string): string {
36
+ const match = content.match(/^#\s+(.+)/m);
37
+ return match ? match[1]!.trim() : "(untitled)";
38
+ }
39
+
40
+ /**
41
+ * Load all plan files from plans/ directory.
42
+ */
43
+ export function loadPlans(dir: string): PlanMeta[] {
44
+ const plansDir = join(dir, "plans");
45
+ if (!existsSync(plansDir)) return [];
46
+
47
+ return readdirSync(plansDir)
48
+ .filter((f) => f.endsWith(".md") && f !== "ROADMAP.md")
49
+ .sort()
50
+ .map((f) => {
51
+ const content = readFileSync(join(plansDir, f), "utf-8");
52
+ return {
53
+ name: f.replace(/\.md$/, ""),
54
+ path: `plans/${f}`,
55
+ title: parseTitle(content),
56
+ status: parseInlineStatus(content),
57
+ effort: parseInlineField(content, "Effort"),
58
+ gate: parseInlineField(content, "Gate"),
59
+ completed: parseInlineField(content, "Completed"),
60
+ };
61
+ });
62
+ }
63
+
64
+ /**
65
+ * List plans filtered by status.
66
+ */
67
+ export function listPlans(dir: string, filter?: { status?: PlanStatus }): PlanMeta[] {
68
+ const plans = loadPlans(dir);
69
+ if (filter?.status) {
70
+ return plans.filter((p) => p.status === filter.status);
71
+ }
72
+ return plans;
73
+ }
74
+
75
+ /**
76
+ * Mark a plan as done. Updates the **Status:** line and adds **Completed:** date.
77
+ */
78
+ export function markPlanDone(dir: string, nameOrNumber: string): PlanMeta | null {
79
+ const plansDir = join(dir, "plans");
80
+ if (!existsSync(plansDir)) return null;
81
+
82
+ // Find the plan file — match by name, number prefix, or full filename
83
+ const files = readdirSync(plansDir).filter((f) => f.endsWith(".md"));
84
+ const target = files.find(
85
+ (f) =>
86
+ f === `${nameOrNumber}.md` ||
87
+ f.replace(/\.md$/, "") === nameOrNumber ||
88
+ f.startsWith(`${nameOrNumber}-`),
89
+ );
90
+
91
+ if (!target) return null;
92
+
93
+ const filePath = join(plansDir, target);
94
+ let content = readFileSync(filePath, "utf-8");
95
+ const today = new Date().toISOString().split("T")[0]!;
96
+
97
+ // Update status
98
+ if (content.match(/\*\*Status:\*\*\s*`[^`]+`/)) {
99
+ content = content.replace(/\*\*Status:\*\*\s*`[^`]+`/, "**Status:** `done`");
100
+ }
101
+
102
+ // Add or update completed date
103
+ if (content.match(/\*\*Completed:\*\*/)) {
104
+ content = content.replace(/\*\*Completed:\*\*\s*.+/, `**Completed:** ${today}`);
105
+ } else {
106
+ // Insert after the Status line
107
+ content = content.replace(/(\*\*Status:\*\*\s*`done`)/, `$1\n**Completed:** ${today}`);
108
+ }
109
+
110
+ writeFileSync(filePath, content);
111
+
112
+ return {
113
+ name: target.replace(/\.md$/, ""),
114
+ path: `plans/${target}`,
115
+ title: parseTitle(content),
116
+ status: "done",
117
+ effort: parseInlineField(content, "Effort"),
118
+ gate: parseInlineField(content, "Gate"),
119
+ completed: today,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Get a single plan by name or number.
125
+ */
126
+ export function getPlan(dir: string, nameOrNumber: string): PlanMeta | null {
127
+ const plans = loadPlans(dir);
128
+ return (
129
+ plans.find((p) => p.name === nameOrNumber || p.name.startsWith(`${nameOrNumber}-`)) ?? null
130
+ );
131
+ }
@@ -0,0 +1,208 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import {
6
+ makeMission,
7
+ makeOrchestratorConfig,
8
+ makeOrchestratorState,
9
+ makePane,
10
+ makeTask,
11
+ } from "../__tests__/support.ts";
12
+ import { _setExecutor, type PaneInfo } from "../widgets/lib/pane-comms.ts";
13
+ import { saveMission, loadTasks, ensureTasksDir } from "./task-store.ts";
14
+ import {
15
+ buildResearchPrompt,
16
+ dispatchResearch,
17
+ evaluateTriggers,
18
+ loadResearchState,
19
+ type ResearchState,
20
+ } from "./research.ts";
21
+
22
+ let tmpDir: string;
23
+ let restoreTmux: () => void;
24
+ let tmuxCalls: { args: string[] }[];
25
+ let mockPanes: PaneInfo[];
26
+
27
+ beforeEach(() => {
28
+ tmpDir = mkdtempSync(join(tmpdir(), "tmux-ide-research-test-"));
29
+ ensureTasksDir(tmpDir);
30
+ tmuxCalls = [];
31
+ mockPanes = [];
32
+ restoreTmux = _setExecutor((_cmd: string, args: string[]) => {
33
+ tmuxCalls.push({ args });
34
+ if (args[0] === "list-panes") {
35
+ return mockPanes
36
+ .map(
37
+ (pane) =>
38
+ `${pane.id}\t${pane.index}\t${pane.title}\t${pane.currentCommand}\t${pane.width}\t${pane.height}\t${pane.active ? "1" : "0"}\t${pane.role ?? ""}\t${pane.name ?? ""}\t${pane.type ?? ""}`,
39
+ )
40
+ .join("\n");
41
+ }
42
+ return "";
43
+ });
44
+ });
45
+
46
+ afterEach(() => {
47
+ restoreTmux();
48
+ rmSync(tmpDir, { recursive: true, force: true });
49
+ });
50
+
51
+ function makeResearchState(overrides: Partial<ResearchState> = {}): ResearchState {
52
+ return {
53
+ lastResearchAt: {},
54
+ missionStartAnalyzed: false,
55
+ milestoneTaskCounts: {},
56
+ activeResearchTaskId: null,
57
+ retryWindow: [],
58
+ ...overrides,
59
+ };
60
+ }
61
+
62
+ describe("evaluateTriggers", () => {
63
+ it("fires mission_start only once", () => {
64
+ saveMission(tmpDir, makeMission({ status: "active" }));
65
+ const config = {
66
+ ...makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" }),
67
+ research: { enabled: true, triggers: { mission_start: true } },
68
+ };
69
+ const state = makeOrchestratorState();
70
+ const researchState = makeResearchState();
71
+
72
+ const first = evaluateTriggers(config, state, researchState, [], []);
73
+ const second = evaluateTriggers(config, state, researchState, [], []);
74
+
75
+ expect(first).toHaveLength(1);
76
+ expect(first[0]?.type).toBe("mission_start");
77
+ expect(second).toEqual([]);
78
+ });
79
+
80
+ it("fires milestone_progress after N completed tasks", () => {
81
+ saveMission(
82
+ tmpDir,
83
+ makeMission({
84
+ milestones: [
85
+ {
86
+ id: "M1",
87
+ title: "Phase 1",
88
+ description: "",
89
+ status: "active",
90
+ order: 1,
91
+ created: "2026-01-01T00:00:00Z",
92
+ updated: "2026-01-01T00:00:00Z",
93
+ },
94
+ ],
95
+ }),
96
+ );
97
+ const config = {
98
+ ...makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" }),
99
+ research: { enabled: true, triggers: { milestone_progress: 2 } },
100
+ };
101
+ const state = makeOrchestratorState();
102
+ const researchState = makeResearchState({ milestoneTaskCounts: { M1: 1 } });
103
+ const tasks = [
104
+ makeTask({ id: "001", milestone: "M1", status: "done" }),
105
+ makeTask({ id: "002", milestone: "M1", status: "done" }),
106
+ ];
107
+
108
+ const triggers = evaluateTriggers(config, state, researchState, tasks, []);
109
+
110
+ expect(triggers).toHaveLength(1);
111
+ expect(triggers[0]?.type).toBe("milestone_progress");
112
+ expect(researchState.milestoneTaskCounts.M1).toBe(2);
113
+ });
114
+
115
+ it("respects periodic cooldowns", () => {
116
+ const config = {
117
+ ...makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" }),
118
+ research: { enabled: true, triggers: { periodic_minutes: 10 } },
119
+ };
120
+ const state = makeOrchestratorState();
121
+ const researchState = makeResearchState({
122
+ lastResearchAt: { periodic: new Date().toISOString() },
123
+ });
124
+
125
+ const triggers = evaluateTriggers(config, state, researchState, [], []);
126
+ expect(triggers).toEqual([]);
127
+ });
128
+
129
+ it("skips triggers when a research agent is already busy", () => {
130
+ saveMission(tmpDir, makeMission({ status: "active" }));
131
+ const config = {
132
+ ...makeOrchestratorConfig(tmpDir, { dispatchMode: "missions" }),
133
+ research: { enabled: true, triggers: { mission_start: true } },
134
+ };
135
+ const state = makeOrchestratorState();
136
+ const researchState = makeResearchState({ activeResearchTaskId: "099" });
137
+
138
+ const triggers = evaluateTriggers(config, state, researchState, [], []);
139
+ expect(triggers).toEqual([]);
140
+ });
141
+ });
142
+
143
+ describe("buildResearchPrompt", () => {
144
+ it("includes mission context", () => {
145
+ saveMission(
146
+ tmpDir,
147
+ makeMission({ title: "Ship v2.0", description: "Audit the mission system" }),
148
+ );
149
+
150
+ const prompt = buildResearchPrompt(tmpDir, "mission_start", {
151
+ taskId: "099",
152
+ reason: "Mission just started",
153
+ });
154
+
155
+ expect(prompt).toContain("Mission: Ship v2.0");
156
+ expect(prompt).toContain("Audit the mission system");
157
+ expect(prompt).toContain("tmux-ide task done 099");
158
+ });
159
+ });
160
+
161
+ describe("dispatchResearch", () => {
162
+ it("dispatches when a researcher pane is available", () => {
163
+ saveMission(tmpDir, makeMission({ status: "active" }));
164
+ const config = {
165
+ ...makeOrchestratorConfig(tmpDir, { dispatchMode: "missions", maxConcurrentAgents: 4 }),
166
+ research: { enabled: true, triggers: { mission_start: true } },
167
+ };
168
+ const state = makeOrchestratorState();
169
+ const researchState = makeResearchState();
170
+ const pane = makePane({
171
+ id: "%2",
172
+ index: 1,
173
+ title: "Researcher",
174
+ role: "researcher",
175
+ currentCommand: "zsh",
176
+ });
177
+ mockPanes = [pane];
178
+
179
+ const task = dispatchResearch(config, state, researchState, [], [pane], {
180
+ type: "mission_start",
181
+ reason: "Mission started",
182
+ });
183
+
184
+ expect(task).not.toBeNull();
185
+ expect(task?.tags).toEqual(["research", "mission_start"]);
186
+ expect(task?.specialty).toBe("researcher");
187
+ expect(researchState.activeResearchTaskId).toBe(task?.id ?? null);
188
+ expect(existsSync(join(tmpDir, ".tasks", "dispatch", `research-${task?.id}.md`))).toBe(true);
189
+ expect(loadTasks(tmpDir).some((saved) => saved.id === task?.id)).toBe(true);
190
+ expect(tmuxCalls.some((call) => call.args.includes("send-keys"))).toBe(true);
191
+ });
192
+
193
+ it("does not dispatch without a researcher or fallback pane", () => {
194
+ const config = {
195
+ ...makeOrchestratorConfig(tmpDir, { dispatchMode: "missions", maxConcurrentAgents: 4 }),
196
+ research: { enabled: true, triggers: { periodic_minutes: 1 } },
197
+ };
198
+ const state = makeOrchestratorState();
199
+ const researchState = loadResearchState(tmpDir);
200
+
201
+ const task = dispatchResearch(config, state, researchState, [], [], {
202
+ type: "periodic",
203
+ reason: "Periodic interval elapsed",
204
+ });
205
+
206
+ expect(task).toBeNull();
207
+ });
208
+ });