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
@@ -1,14 +1,15 @@
1
- import { IdeError } from "./errors.js";
1
+ import { IdeError } from "./errors.ts";
2
+ import type { IdeConfig } from "../types.ts";
2
3
 
3
- export function printLayout(config) {
4
+ export function printLayout(config: IdeConfig): void {
4
5
  const INNER = 40;
5
6
  const rows = config.rows ?? [];
6
7
  if (rows.length === 0) return;
7
8
 
8
9
  for (let r = 0; r < rows.length; r++) {
9
- const panes = rows[r].panes ?? [];
10
+ const panes = rows[r]!.panes ?? [];
10
11
  const count = panes.length || 1;
11
- const widths = [];
12
+ const widths: number[] = [];
12
13
  let remaining = INNER;
13
14
  for (let i = 0; i < count; i++) {
14
15
  const w = i < count - 1 ? Math.floor(INNER / count) : remaining;
@@ -20,7 +21,7 @@ export function printLayout(config) {
20
21
  if (r === 0) {
21
22
  let top = " \u250c";
22
23
  for (let i = 0; i < count; i++) {
23
- top += "\u2500".repeat(widths[i]);
24
+ top += "\u2500".repeat(widths[i]!);
24
25
  top += i < count - 1 ? "\u252c" : "\u2510";
25
26
  }
26
27
  console.log(top);
@@ -29,11 +30,11 @@ export function printLayout(config) {
29
30
  }
30
31
 
31
32
  // Content line
32
- const sizeLabel = rows[r].size ?? "";
33
+ const sizeLabel = rows[r]!.size ?? "";
33
34
  let line = " \u2502";
34
35
  for (let i = 0; i < count; i++) {
35
36
  const title = panes[i]?.title ?? "";
36
- const w = widths[i];
37
+ const w = widths[i]!;
37
38
  const pad = Math.max(0, w - title.length);
38
39
  const left = Math.floor(pad / 2);
39
40
  const right = pad - left;
@@ -46,7 +47,7 @@ export function printLayout(config) {
46
47
  if (r === rows.length - 1) {
47
48
  let bot = " \u2514";
48
49
  for (let i = 0; i < count; i++) {
49
- bot += "\u2500".repeat(widths[i]);
50
+ bot += "\u2500".repeat(widths[i]!);
50
51
  bot += i < count - 1 ? "\u2534" : "\u2518";
51
52
  }
52
53
  console.log(bot);
@@ -54,11 +55,18 @@ export function printLayout(config) {
54
55
  }
55
56
  }
56
57
 
57
- export function outputError(message, code, { exitCode = 1 } = {}) {
58
+ export function outputError(
59
+ message: string,
60
+ code: string,
61
+ { exitCode = 1 }: { exitCode?: number } = {},
62
+ ): never {
58
63
  throw new IdeError(message, { code, exitCode });
59
64
  }
60
65
 
61
- export function printCommandError(error, { json = false } = {}) {
66
+ export function printCommandError(
67
+ error: IdeError,
68
+ { json = false }: { json?: boolean } = {},
69
+ ): never {
62
70
  if (json) {
63
71
  console.error(JSON.stringify(error.toJSON(), null, 2));
64
72
  } else {
@@ -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
+ });