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.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- package/src/validate.js +0 -154
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { IdeError } from "./errors.
|
|
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]
|
|
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]
|
|
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(
|
|
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(
|
|
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
|
+
});
|