@katyella/legio 0.1.3 → 0.2.2
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/CHANGELOG.md +61 -3
- package/README.md +21 -10
- package/agents/builder.md +11 -10
- package/agents/coordinator.md +36 -27
- package/agents/cto.md +9 -8
- package/agents/gateway.md +28 -12
- package/agents/lead.md +45 -30
- package/agents/merger.md +4 -4
- package/agents/monitor.md +10 -9
- package/agents/reviewer.md +8 -8
- package/agents/scout.md +10 -10
- package/agents/supervisor.md +60 -45
- package/package.json +2 -2
- package/src/agents/hooks-deployer.test.ts +46 -41
- package/src/agents/hooks-deployer.ts +10 -9
- package/src/agents/manifest.test.ts +6 -2
- package/src/agents/overlay.test.ts +9 -7
- package/src/agents/overlay.ts +29 -7
- package/src/commands/agents.test.ts +1 -5
- package/src/commands/clean.test.ts +2 -5
- package/src/commands/clean.ts +25 -1
- package/src/commands/completions.test.ts +1 -1
- package/src/commands/completions.ts +26 -7
- package/src/commands/coordinator.test.ts +87 -82
- package/src/commands/coordinator.ts +94 -48
- package/src/commands/costs.test.ts +2 -6
- package/src/commands/dashboard.test.ts +2 -5
- package/src/commands/doctor.test.ts +2 -6
- package/src/commands/down.ts +3 -3
- package/src/commands/errors.test.ts +2 -6
- package/src/commands/feed.test.ts +2 -6
- package/src/commands/gateway.test.ts +43 -17
- package/src/commands/gateway.ts +101 -11
- package/src/commands/hooks.test.ts +2 -5
- package/src/commands/init.test.ts +4 -13
- package/src/commands/inspect.test.ts +2 -6
- package/src/commands/log.test.ts +2 -6
- package/src/commands/logs.test.ts +2 -9
- package/src/commands/mail.test.ts +76 -215
- package/src/commands/mail.ts +43 -187
- package/src/commands/metrics.test.ts +3 -10
- package/src/commands/nudge.ts +15 -0
- package/src/commands/prime.test.ts +4 -11
- package/src/commands/replay.test.ts +2 -6
- package/src/commands/server.test.ts +1 -5
- package/src/commands/server.ts +1 -1
- package/src/commands/sling.test.ts +6 -1
- package/src/commands/sling.ts +42 -17
- package/src/commands/spec.test.ts +2 -5
- package/src/commands/status.test.ts +2 -4
- package/src/commands/stop.test.ts +2 -5
- package/src/commands/supervisor.ts +6 -6
- package/src/commands/trace.test.ts +2 -6
- package/src/commands/up.test.ts +43 -9
- package/src/commands/up.ts +15 -11
- package/src/commands/watchman.ts +327 -0
- package/src/commands/worktree.test.ts +2 -6
- package/src/config.test.ts +34 -104
- package/src/config.ts +120 -32
- package/src/doctor/agents.test.ts +52 -2
- package/src/doctor/agents.ts +4 -2
- package/src/doctor/config-check.test.ts +7 -2
- package/src/doctor/consistency.test.ts +7 -2
- package/src/doctor/databases.test.ts +6 -2
- package/src/doctor/dependencies.test.ts +18 -13
- package/src/doctor/dependencies.ts +23 -94
- package/src/doctor/logs.test.ts +7 -2
- package/src/doctor/merge-queue.test.ts +6 -2
- package/src/doctor/structure.test.ts +7 -2
- package/src/doctor/version.test.ts +7 -2
- package/src/e2e/init-sling-lifecycle.test.ts +2 -5
- package/src/index.ts +7 -7
- package/src/mail/pending.ts +120 -0
- package/src/mail/store.test.ts +89 -0
- package/src/mail/store.ts +11 -0
- package/src/merge/resolver.test.ts +518 -489
- package/src/server/index.ts +33 -2
- package/src/server/public/app.js +3 -3
- package/src/server/public/components/message-bubble.js +11 -1
- package/src/server/public/components/terminal-panel.js +66 -74
- package/src/server/public/views/chat.js +18 -2
- package/src/server/public/views/costs.js +5 -5
- package/src/server/public/views/dashboard.js +80 -51
- package/src/server/public/views/gateway-chat.js +37 -131
- package/src/server/public/views/inspect.js +16 -4
- package/src/server/public/views/issues.js +16 -12
- package/src/server/routes.test.ts +55 -39
- package/src/server/routes.ts +38 -26
- package/src/test-helpers.ts +6 -3
- package/src/tracker/beads.ts +159 -0
- package/src/tracker/exec.ts +44 -0
- package/src/tracker/factory.test.ts +283 -0
- package/src/tracker/factory.ts +59 -0
- package/src/tracker/seeds.ts +156 -0
- package/src/tracker/types.ts +46 -0
- package/src/types.ts +11 -2
- package/src/{watchdog → watchman}/daemon.test.ts +421 -515
- package/src/watchman/daemon.ts +940 -0
- package/src/worktree/tmux.test.ts +2 -1
- package/src/worktree/tmux.ts +4 -4
- package/templates/hooks.json.tmpl +17 -17
- package/src/beads/client.test.ts +0 -210
- package/src/commands/merge.test.ts +0 -676
- package/src/commands/watch.test.ts +0 -152
- package/src/commands/watch.ts +0 -238
- package/src/test-helpers.test.ts +0 -97
- package/src/watchdog/daemon.ts +0 -533
- package/src/watchdog/health.test.ts +0 -371
- package/src/watchdog/triage.test.ts +0 -162
- package/src/worktree/manager.test.ts +0 -444
- /package/src/{watchdog → watchman}/health.ts +0 -0
- /package/src/{watchdog → watchman}/triage.ts +0 -0
package/src/server/routes.ts
CHANGED
|
@@ -11,7 +11,6 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { constants } from "node:fs";
|
|
12
12
|
import { access, readFile, writeFile } from "node:fs/promises";
|
|
13
13
|
import { join } from "node:path";
|
|
14
|
-
import { createBeadsClient } from "../beads/client.ts";
|
|
15
14
|
import { gatherInspectData } from "../commands/inspect.ts";
|
|
16
15
|
import { gatherStatus } from "../commands/status.ts";
|
|
17
16
|
import { loadConfig } from "../config.ts";
|
|
@@ -21,6 +20,7 @@ import { createMergeQueue } from "../merge/queue.ts";
|
|
|
21
20
|
import { createMetricsStore } from "../metrics/store.ts";
|
|
22
21
|
import { openSessionStore } from "../sessions/compat.ts";
|
|
23
22
|
import { createRunStore, createSessionStore } from "../sessions/store.ts";
|
|
23
|
+
import { createTrackerClient } from "../tracker/factory.ts";
|
|
24
24
|
import type {
|
|
25
25
|
EventLevel,
|
|
26
26
|
HeadlessCoordinatorConfig,
|
|
@@ -635,7 +635,7 @@ export async function handleApiRequest(
|
|
|
635
635
|
const idea = data.ideas.find((i) => i.id === id);
|
|
636
636
|
if (!idea) return errorResponse(`Idea not found: ${id}`, 404);
|
|
637
637
|
|
|
638
|
-
const client =
|
|
638
|
+
const client = createTrackerClient("auto", projectRoot);
|
|
639
639
|
const issueId = await client.create(idea.title, { description: idea.body });
|
|
640
640
|
|
|
641
641
|
idea.status = "backlog";
|
|
@@ -703,7 +703,7 @@ export async function handleApiRequest(
|
|
|
703
703
|
}
|
|
704
704
|
|
|
705
705
|
const args = ["coordinator", "start", "--no-attach", "--json"];
|
|
706
|
-
if (parsed.watchdog === true) args.push("--
|
|
706
|
+
if (parsed.watchdog === true) args.push("--watchman");
|
|
707
707
|
if (parsed.monitor === true) args.push("--monitor");
|
|
708
708
|
const result = await runLegio(args, projectRoot);
|
|
709
709
|
if (result.ok) {
|
|
@@ -1106,7 +1106,7 @@ export async function handleApiRequest(
|
|
|
1106
1106
|
const { id } = params;
|
|
1107
1107
|
if (!id) return errorResponse("Missing issue ID", 400);
|
|
1108
1108
|
try {
|
|
1109
|
-
const client =
|
|
1109
|
+
const client = createTrackerClient("auto", projectRoot);
|
|
1110
1110
|
const issue = await client.show(id);
|
|
1111
1111
|
const body = issue.description ? `${issue.title}\n\n${issue.description}` : issue.title;
|
|
1112
1112
|
const store = createMailStore(join(legioDir, "mail.db"));
|
|
@@ -1143,7 +1143,7 @@ export async function handleApiRequest(
|
|
|
1143
1143
|
try {
|
|
1144
1144
|
const body = (await request.json().catch(() => ({}))) as { reason?: string };
|
|
1145
1145
|
const reason = typeof body.reason === "string" ? body.reason : "Closed from dashboard";
|
|
1146
|
-
const client =
|
|
1146
|
+
const client = createTrackerClient("auto", projectRoot);
|
|
1147
1147
|
await client.close(id, reason);
|
|
1148
1148
|
return jsonResponse({ success: true, id });
|
|
1149
1149
|
} catch (err) {
|
|
@@ -1245,7 +1245,20 @@ export async function handleApiRequest(
|
|
|
1245
1245
|
}
|
|
1246
1246
|
const { store } = openSessionStore(legioDir);
|
|
1247
1247
|
try {
|
|
1248
|
-
|
|
1248
|
+
// Scope to current run if one exists, otherwise show only active agents
|
|
1249
|
+
// (avoids flooding the dashboard with completed agents from old runs)
|
|
1250
|
+
const runIdPath = join(legioDir, "current-run.txt");
|
|
1251
|
+
let runId: string | null = null;
|
|
1252
|
+
try {
|
|
1253
|
+
const text = await readFile(runIdPath, "utf-8");
|
|
1254
|
+
runId = text.trim() || null;
|
|
1255
|
+
} catch {
|
|
1256
|
+
// No current run file
|
|
1257
|
+
}
|
|
1258
|
+
if (runId) {
|
|
1259
|
+
return jsonResponse(store.getByRunIncludeOrphans(runId));
|
|
1260
|
+
}
|
|
1261
|
+
return jsonResponse(store.getActive());
|
|
1249
1262
|
} finally {
|
|
1250
1263
|
store.close();
|
|
1251
1264
|
}
|
|
@@ -1711,7 +1724,7 @@ export async function handleApiRequest(
|
|
|
1711
1724
|
}
|
|
1712
1725
|
|
|
1713
1726
|
try {
|
|
1714
|
-
const client =
|
|
1727
|
+
const client = createTrackerClient("auto", projectRoot);
|
|
1715
1728
|
const issues = await client.list({ status: statusParam, limit, all });
|
|
1716
1729
|
if (isDefaultRequest) {
|
|
1717
1730
|
issuesCacheData = issues;
|
|
@@ -1725,7 +1738,7 @@ export async function handleApiRequest(
|
|
|
1725
1738
|
|
|
1726
1739
|
if (path === "/api/issues/ready") {
|
|
1727
1740
|
try {
|
|
1728
|
-
const client =
|
|
1741
|
+
const client = createTrackerClient("auto", projectRoot);
|
|
1729
1742
|
const issues = await client.ready();
|
|
1730
1743
|
return jsonResponse(issues);
|
|
1731
1744
|
} catch {
|
|
@@ -1740,7 +1753,7 @@ export async function handleApiRequest(
|
|
|
1740
1753
|
const { id } = params;
|
|
1741
1754
|
if (!id) return errorResponse("Missing issue ID", 400);
|
|
1742
1755
|
try {
|
|
1743
|
-
const client =
|
|
1756
|
+
const client = createTrackerClient("auto", projectRoot);
|
|
1744
1757
|
const issue = await client.show(id);
|
|
1745
1758
|
return jsonResponse(issue);
|
|
1746
1759
|
} catch {
|
|
@@ -1771,29 +1784,28 @@ export async function handleApiRequest(
|
|
|
1771
1784
|
});
|
|
1772
1785
|
}
|
|
1773
1786
|
|
|
1774
|
-
// Try
|
|
1787
|
+
// Try capture-pane first (clean rendered output)
|
|
1775
1788
|
let output: string | null = null;
|
|
1776
|
-
const
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
if (agentSession?.terminalLogPath) {
|
|
1780
|
-
output = await readTerminalLog(agentSession.terminalLogPath, lines);
|
|
1781
|
-
}
|
|
1782
|
-
} finally {
|
|
1783
|
-
captureStore.close();
|
|
1789
|
+
const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, agentName);
|
|
1790
|
+
if (tmuxSession) {
|
|
1791
|
+
output = await captureTmuxPane(tmuxSession, lines);
|
|
1784
1792
|
}
|
|
1785
1793
|
|
|
1786
|
-
// Fall back to
|
|
1794
|
+
// Fall back to pipe-pane log file if capture-pane fails (session dead)
|
|
1787
1795
|
if (output === null) {
|
|
1788
|
-
const
|
|
1789
|
-
|
|
1790
|
-
|
|
1796
|
+
const { store: captureStore } = openSessionStore(legioDir);
|
|
1797
|
+
try {
|
|
1798
|
+
const agentSession = captureStore.getByName(agentName);
|
|
1799
|
+
if (agentSession?.terminalLogPath) {
|
|
1800
|
+
output = await readTerminalLog(agentSession.terminalLogPath, lines);
|
|
1801
|
+
}
|
|
1802
|
+
} finally {
|
|
1803
|
+
captureStore.close();
|
|
1791
1804
|
}
|
|
1805
|
+
}
|
|
1792
1806
|
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
return errorResponse(`Failed to capture tmux pane for session "${tmuxSession}"`);
|
|
1796
|
-
}
|
|
1807
|
+
if (output === null) {
|
|
1808
|
+
return errorResponse(`Cannot resolve tmux session for agent "${agentName}"`, 404);
|
|
1797
1809
|
}
|
|
1798
1810
|
|
|
1799
1811
|
return jsonResponse({
|
package/src/test-helpers.ts
CHANGED
|
@@ -30,6 +30,10 @@ async function getTemplateRepo(): Promise<string> {
|
|
|
30
30
|
|
|
31
31
|
const dir = await mkdtemp(join(tmpdir(), "legio-template-"));
|
|
32
32
|
await runGitInDir(dir, ["init", "-b", "main"]);
|
|
33
|
+
// Bake identity into the template so clones inherit it without extra
|
|
34
|
+
// per-repo `git config` subprocess calls.
|
|
35
|
+
await runGitInDir(dir, ["config", "user.name", "Legio Test"]);
|
|
36
|
+
await runGitInDir(dir, ["config", "user.email", "test@legio.dev"]);
|
|
33
37
|
await writeFile(join(dir, ".gitkeep"), "");
|
|
34
38
|
await runGitInDir(dir, ["add", ".gitkeep"]);
|
|
35
39
|
await runGitInDir(dir, ["commit", "-m", "initial commit"]);
|
|
@@ -52,9 +56,7 @@ export async function createTempGitRepo(): Promise<string> {
|
|
|
52
56
|
const dir = await mkdtemp(join(tmpdir(), "legio-test-"));
|
|
53
57
|
// Clone into the empty dir. Avoid --local (hardlinks trigger EFAULT in Bun's rm).
|
|
54
58
|
await runGitInDir(".", ["clone", template, dir]);
|
|
55
|
-
//
|
|
56
|
-
// (e.g., resolver's runGit) can still commit. Locally this is covered by
|
|
57
|
-
// ~/.gitconfig, but CI runners have no global git identity.
|
|
59
|
+
// git clone does not copy local config — set identity so merge commits work on CI
|
|
58
60
|
await runGitInDir(dir, ["config", "user.name", "Legio Test"]);
|
|
59
61
|
await runGitInDir(dir, ["config", "user.email", "test@legio.dev"]);
|
|
60
62
|
return dir;
|
|
@@ -74,6 +76,7 @@ export async function cloneFixtureRepo(): Promise<string> {
|
|
|
74
76
|
const dir = await mkdtemp(join(tmpdir(), "legio-test-"));
|
|
75
77
|
// Avoid --local (hardlinks trigger EFAULT in Bun's rm).
|
|
76
78
|
await runGitInDir(".", ["clone", fixturePath, dir]);
|
|
79
|
+
// git clone does not copy local config — set identity so merge commits work on CI
|
|
77
80
|
await runGitInDir(dir, ["config", "user.name", "Legio Test"]);
|
|
78
81
|
await runGitInDir(dir, ["config", "user.email", "test@legio.dev"]);
|
|
79
82
|
return dir;
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Beads adapter for the tracker abstraction layer.
|
|
3
|
+
*
|
|
4
|
+
* Wraps the bd CLI to implement TrackerClient. Reuses the same
|
|
5
|
+
* normalizeIssue/parseJsonOutput patterns as seeds.ts for consistency.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { AgentError } from "../errors.ts";
|
|
9
|
+
import { runTrackerCommand } from "./exec.ts";
|
|
10
|
+
import type { TrackerClient, TrackerIssue } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Parse JSON output from a bd command.
|
|
14
|
+
*/
|
|
15
|
+
function parseJsonOutput<T>(stdout: string, context: string): T {
|
|
16
|
+
const trimmed = stdout.trim();
|
|
17
|
+
if (trimmed === "") {
|
|
18
|
+
throw new AgentError(`Empty output from bd ${context}`);
|
|
19
|
+
}
|
|
20
|
+
try {
|
|
21
|
+
return JSON.parse(trimmed) as T;
|
|
22
|
+
} catch {
|
|
23
|
+
throw new AgentError(
|
|
24
|
+
`Failed to parse JSON output from bd ${context}: ${trimmed.slice(0, 200)}`,
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Raw issue shape from the bd CLI (snake_case fields).
|
|
31
|
+
*/
|
|
32
|
+
interface RawBeadIssue {
|
|
33
|
+
id: string;
|
|
34
|
+
title: string;
|
|
35
|
+
status: string;
|
|
36
|
+
priority: number;
|
|
37
|
+
issue_type?: string;
|
|
38
|
+
type?: string;
|
|
39
|
+
owner?: string;
|
|
40
|
+
assignee?: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
blocks?: string[];
|
|
43
|
+
blocked_by?: string[];
|
|
44
|
+
blockedBy?: string[];
|
|
45
|
+
closed_at?: string;
|
|
46
|
+
close_reason?: string;
|
|
47
|
+
created_at?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Normalize a raw bd issue into a TrackerIssue (camelCase).
|
|
52
|
+
*/
|
|
53
|
+
function normalizeIssue(raw: RawBeadIssue): TrackerIssue {
|
|
54
|
+
return {
|
|
55
|
+
id: raw.id,
|
|
56
|
+
title: raw.title,
|
|
57
|
+
status: raw.status,
|
|
58
|
+
priority: raw.priority,
|
|
59
|
+
type: raw.issue_type ?? raw.type ?? "unknown",
|
|
60
|
+
assignee: raw.owner ?? raw.assignee,
|
|
61
|
+
description: raw.description,
|
|
62
|
+
blocks: raw.blocks,
|
|
63
|
+
blockedBy: raw.blocked_by ?? raw.blockedBy,
|
|
64
|
+
closedAt: raw.closed_at,
|
|
65
|
+
closeReason: raw.close_reason,
|
|
66
|
+
createdAt: raw.created_at,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Create a TrackerClient backed by the beads (bd) CLI.
|
|
72
|
+
*
|
|
73
|
+
* @param cwd - Working directory where bd commands should run
|
|
74
|
+
*/
|
|
75
|
+
export function createBeadsTrackerClient(cwd: string): TrackerClient {
|
|
76
|
+
async function runBd(
|
|
77
|
+
args: string[],
|
|
78
|
+
context: string,
|
|
79
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
80
|
+
const { stdout, stderr, exitCode } = await runTrackerCommand(["bd", ...args], cwd);
|
|
81
|
+
if (exitCode !== 0) {
|
|
82
|
+
throw new AgentError(`bd ${context} failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
83
|
+
}
|
|
84
|
+
return { stdout, stderr };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
async ready(options): Promise<TrackerIssue[]> {
|
|
89
|
+
const args = ["ready", "--json"];
|
|
90
|
+
if (options?.mol) {
|
|
91
|
+
args.push("--mol", options.mol);
|
|
92
|
+
}
|
|
93
|
+
const { stdout } = await runBd(args, "ready");
|
|
94
|
+
const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "ready");
|
|
95
|
+
return raw.map(normalizeIssue);
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
async show(id): Promise<TrackerIssue> {
|
|
99
|
+
const { stdout } = await runBd(["show", id, "--json"], `show ${id}`);
|
|
100
|
+
const raw = parseJsonOutput<RawBeadIssue[]>(stdout, `show ${id}`);
|
|
101
|
+
const first = raw[0];
|
|
102
|
+
if (!first) {
|
|
103
|
+
throw new AgentError(`bd show ${id} returned empty array`);
|
|
104
|
+
}
|
|
105
|
+
return normalizeIssue(first);
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async create(title, options): Promise<string> {
|
|
109
|
+
const args = ["create", title, "--json"];
|
|
110
|
+
if (options?.type) {
|
|
111
|
+
args.push("--type", options.type);
|
|
112
|
+
}
|
|
113
|
+
if (options?.priority !== undefined) {
|
|
114
|
+
args.push("--priority", String(options.priority));
|
|
115
|
+
}
|
|
116
|
+
if (options?.description) {
|
|
117
|
+
args.push("--description", options.description);
|
|
118
|
+
}
|
|
119
|
+
const { stdout } = await runBd(args, "create");
|
|
120
|
+
const result = parseJsonOutput<{ id: string }>(stdout, "create");
|
|
121
|
+
return result.id;
|
|
122
|
+
},
|
|
123
|
+
|
|
124
|
+
async claim(id): Promise<void> {
|
|
125
|
+
await runBd(["update", id, "--status", "in_progress"], `claim ${id}`);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
async close(id, reason): Promise<void> {
|
|
129
|
+
const args = ["close", id];
|
|
130
|
+
if (reason) {
|
|
131
|
+
args.push("--reason", reason);
|
|
132
|
+
}
|
|
133
|
+
await runBd(args, `close ${id}`);
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
async list(options): Promise<TrackerIssue[]> {
|
|
137
|
+
const args = ["list", "--json"];
|
|
138
|
+
if (options?.status) {
|
|
139
|
+
args.push("--status", options.status);
|
|
140
|
+
}
|
|
141
|
+
if (options?.limit !== undefined) {
|
|
142
|
+
args.push("--limit", String(options.limit));
|
|
143
|
+
}
|
|
144
|
+
if (options?.all) {
|
|
145
|
+
args.push("--all");
|
|
146
|
+
}
|
|
147
|
+
const { stdout } = await runBd(args, "list");
|
|
148
|
+
const raw = parseJsonOutput<RawBeadIssue[]>(stdout, "list");
|
|
149
|
+
return raw.map(normalizeIssue);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
async sync(): Promise<void> {
|
|
153
|
+
const { exitCode, stderr } = await runTrackerCommand(["bd", "sync"], cwd);
|
|
154
|
+
if (exitCode !== 0) {
|
|
155
|
+
throw new AgentError(`bd sync failed (exit ${exitCode}): ${stderr.trim()}`);
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared subprocess execution for tracker adapters.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { AgentError } from "../errors.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run a CLI command and capture its output.
|
|
10
|
+
*
|
|
11
|
+
* @param cmd - Command and arguments array (e.g., ["bd", "sync"])
|
|
12
|
+
* @param cwd - Working directory for the subprocess
|
|
13
|
+
* @param context - Human-readable context for error messages (e.g., "bd sync")
|
|
14
|
+
*/
|
|
15
|
+
export async function runTrackerCommand(
|
|
16
|
+
cmd: string[],
|
|
17
|
+
cwd: string,
|
|
18
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
19
|
+
const [command, ...args] = cmd;
|
|
20
|
+
if (!command) throw new Error("Empty command");
|
|
21
|
+
return new Promise((resolve, reject) => {
|
|
22
|
+
const proc = spawn(command, args, {
|
|
23
|
+
cwd,
|
|
24
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
25
|
+
});
|
|
26
|
+
const chunks: { stdout: Buffer[]; stderr: Buffer[] } = { stdout: [], stderr: [] };
|
|
27
|
+
proc.stdout.on("data", (data: Buffer) => chunks.stdout.push(data));
|
|
28
|
+
proc.stderr.on("data", (data: Buffer) => chunks.stderr.push(data));
|
|
29
|
+
proc.on("error", (err: NodeJS.ErrnoException) => {
|
|
30
|
+
if (err.code === "ENOENT") {
|
|
31
|
+
reject(new AgentError(`CLI tool "${command}" not found. Is it installed and on PATH?`));
|
|
32
|
+
} else {
|
|
33
|
+
reject(err);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
proc.on("close", (code) => {
|
|
37
|
+
resolve({
|
|
38
|
+
stdout: Buffer.concat(chunks.stdout).toString(),
|
|
39
|
+
stderr: Buffer.concat(chunks.stderr).toString(),
|
|
40
|
+
exitCode: code ?? 1,
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the tracker factory, adapters, and normalizeIssue logic.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories (createTempGitRepo pattern).
|
|
5
|
+
* Does NOT test the seeds/beads adapters with real CLIs (they may not be installed).
|
|
6
|
+
* DOES test normalizeIssue which is pure logic requiring no CLI.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { mkdir } from "node:fs/promises";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
12
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
13
|
+
import { createBeadsTrackerClient } from "./beads.ts";
|
|
14
|
+
import { createTrackerClient, resolveBackend } from "./factory.ts";
|
|
15
|
+
import { normalizeIssue } from "./seeds.ts";
|
|
16
|
+
|
|
17
|
+
describe("resolveBackend", () => {
|
|
18
|
+
let tmpDir: string;
|
|
19
|
+
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
tmpDir = await createTempGitRepo();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
await cleanupTempDir(tmpDir);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("returns 'seeds' when .seeds/ directory exists", async () => {
|
|
29
|
+
await mkdir(join(tmpDir, ".seeds"), { recursive: true });
|
|
30
|
+
expect(resolveBackend(tmpDir)).toBe("seeds");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns 'beads' when .beads/ directory exists (no .seeds/)", async () => {
|
|
34
|
+
await mkdir(join(tmpDir, ".beads"), { recursive: true });
|
|
35
|
+
expect(resolveBackend(tmpDir)).toBe("beads");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("prefers .seeds/ over .beads/ when both exist", async () => {
|
|
39
|
+
await mkdir(join(tmpDir, ".seeds"), { recursive: true });
|
|
40
|
+
await mkdir(join(tmpDir, ".beads"), { recursive: true });
|
|
41
|
+
expect(resolveBackend(tmpDir)).toBe("seeds");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults to 'seeds' when neither directory exists", () => {
|
|
45
|
+
expect(resolveBackend(tmpDir)).toBe("seeds");
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("createTrackerClient", () => {
|
|
50
|
+
let tmpDir: string;
|
|
51
|
+
|
|
52
|
+
beforeEach(async () => {
|
|
53
|
+
tmpDir = await createTempGitRepo();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(async () => {
|
|
57
|
+
await cleanupTempDir(tmpDir);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns a TrackerClient object for 'beads' backend", () => {
|
|
61
|
+
const client = createTrackerClient("beads", tmpDir);
|
|
62
|
+
expect(typeof client.ready).toBe("function");
|
|
63
|
+
expect(typeof client.show).toBe("function");
|
|
64
|
+
expect(typeof client.create).toBe("function");
|
|
65
|
+
expect(typeof client.claim).toBe("function");
|
|
66
|
+
expect(typeof client.close).toBe("function");
|
|
67
|
+
expect(typeof client.list).toBe("function");
|
|
68
|
+
expect(typeof client.sync).toBe("function");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("returns a TrackerClient object for 'seeds' backend", () => {
|
|
72
|
+
const client = createTrackerClient("seeds", tmpDir);
|
|
73
|
+
expect(typeof client.ready).toBe("function");
|
|
74
|
+
expect(typeof client.show).toBe("function");
|
|
75
|
+
expect(typeof client.create).toBe("function");
|
|
76
|
+
expect(typeof client.claim).toBe("function");
|
|
77
|
+
expect(typeof client.close).toBe("function");
|
|
78
|
+
expect(typeof client.list).toBe("function");
|
|
79
|
+
expect(typeof client.sync).toBe("function");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("returns a TrackerClient object for 'auto' backend (no marker dirs)", () => {
|
|
83
|
+
const client = createTrackerClient("auto", tmpDir);
|
|
84
|
+
expect(typeof client.ready).toBe("function");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("auto-detects beads when .beads/ exists", async () => {
|
|
88
|
+
await mkdir(join(tmpDir, ".beads"), { recursive: true });
|
|
89
|
+
const client = createTrackerClient("auto", tmpDir);
|
|
90
|
+
expect(typeof client.ready).toBe("function");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("auto-detects seeds when .seeds/ exists", async () => {
|
|
94
|
+
await mkdir(join(tmpDir, ".seeds"), { recursive: true });
|
|
95
|
+
const client = createTrackerClient("auto", tmpDir);
|
|
96
|
+
expect(typeof client.ready).toBe("function");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("normalizeIssue", () => {
|
|
101
|
+
it("maps snake_case fields to camelCase", () => {
|
|
102
|
+
const result = normalizeIssue({
|
|
103
|
+
id: "test-1",
|
|
104
|
+
title: "Test issue",
|
|
105
|
+
status: "open",
|
|
106
|
+
priority: 2,
|
|
107
|
+
issue_type: "task",
|
|
108
|
+
owner: "alice",
|
|
109
|
+
blocked_by: ["test-0"],
|
|
110
|
+
closed_at: "2026-01-01T00:00:00Z",
|
|
111
|
+
close_reason: "done",
|
|
112
|
+
created_at: "2025-12-01T00:00:00Z",
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
expect(result.id).toBe("test-1");
|
|
116
|
+
expect(result.title).toBe("Test issue");
|
|
117
|
+
expect(result.status).toBe("open");
|
|
118
|
+
expect(result.priority).toBe(2);
|
|
119
|
+
expect(result.type).toBe("task");
|
|
120
|
+
expect(result.assignee).toBe("alice");
|
|
121
|
+
expect(result.blockedBy).toEqual(["test-0"]);
|
|
122
|
+
expect(result.closedAt).toBe("2026-01-01T00:00:00Z");
|
|
123
|
+
expect(result.closeReason).toBe("done");
|
|
124
|
+
expect(result.createdAt).toBe("2025-12-01T00:00:00Z");
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("prefers issue_type over type", () => {
|
|
128
|
+
const result = normalizeIssue({
|
|
129
|
+
id: "t-1",
|
|
130
|
+
title: "T",
|
|
131
|
+
status: "open",
|
|
132
|
+
priority: 1,
|
|
133
|
+
issue_type: "bug",
|
|
134
|
+
type: "task",
|
|
135
|
+
});
|
|
136
|
+
expect(result.type).toBe("bug");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("falls back to type when issue_type is absent", () => {
|
|
140
|
+
const result = normalizeIssue({
|
|
141
|
+
id: "t-1",
|
|
142
|
+
title: "T",
|
|
143
|
+
status: "open",
|
|
144
|
+
priority: 1,
|
|
145
|
+
type: "feature",
|
|
146
|
+
});
|
|
147
|
+
expect(result.type).toBe("feature");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("defaults type to 'unknown' when both are absent", () => {
|
|
151
|
+
const result = normalizeIssue({
|
|
152
|
+
id: "t-1",
|
|
153
|
+
title: "T",
|
|
154
|
+
status: "open",
|
|
155
|
+
priority: 1,
|
|
156
|
+
});
|
|
157
|
+
expect(result.type).toBe("unknown");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("prefers owner over assignee", () => {
|
|
161
|
+
const result = normalizeIssue({
|
|
162
|
+
id: "t-1",
|
|
163
|
+
title: "T",
|
|
164
|
+
status: "open",
|
|
165
|
+
priority: 1,
|
|
166
|
+
owner: "bob",
|
|
167
|
+
assignee: "alice",
|
|
168
|
+
});
|
|
169
|
+
expect(result.assignee).toBe("bob");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("falls back to assignee when owner is absent", () => {
|
|
173
|
+
const result = normalizeIssue({
|
|
174
|
+
id: "t-1",
|
|
175
|
+
title: "T",
|
|
176
|
+
status: "open",
|
|
177
|
+
priority: 1,
|
|
178
|
+
assignee: "alice",
|
|
179
|
+
});
|
|
180
|
+
expect(result.assignee).toBe("alice");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("prefers blocked_by over blockedBy", () => {
|
|
184
|
+
const result = normalizeIssue({
|
|
185
|
+
id: "t-1",
|
|
186
|
+
title: "T",
|
|
187
|
+
status: "open",
|
|
188
|
+
priority: 1,
|
|
189
|
+
blocked_by: ["a"],
|
|
190
|
+
blockedBy: ["b"],
|
|
191
|
+
});
|
|
192
|
+
expect(result.blockedBy).toEqual(["a"]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("handles minimal input with only required fields", () => {
|
|
196
|
+
const result = normalizeIssue({
|
|
197
|
+
id: "t-1",
|
|
198
|
+
title: "Minimal",
|
|
199
|
+
status: "open",
|
|
200
|
+
priority: 3,
|
|
201
|
+
});
|
|
202
|
+
expect(result.id).toBe("t-1");
|
|
203
|
+
expect(result.type).toBe("unknown");
|
|
204
|
+
expect(result.assignee).toBeUndefined();
|
|
205
|
+
expect(result.blockedBy).toBeUndefined();
|
|
206
|
+
expect(result.closedAt).toBeUndefined();
|
|
207
|
+
expect(result.closeReason).toBeUndefined();
|
|
208
|
+
expect(result.createdAt).toBeUndefined();
|
|
209
|
+
expect(result.description).toBeUndefined();
|
|
210
|
+
expect(result.blocks).toBeUndefined();
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
describe("createBeadsTrackerClient", () => {
|
|
215
|
+
let tmpDir: string;
|
|
216
|
+
|
|
217
|
+
beforeEach(async () => {
|
|
218
|
+
tmpDir = await createTempGitRepo();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
afterEach(async () => {
|
|
222
|
+
await cleanupTempDir(tmpDir);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns an object implementing TrackerClient interface", () => {
|
|
226
|
+
const client = createBeadsTrackerClient(tmpDir);
|
|
227
|
+
expect(typeof client.ready).toBe("function");
|
|
228
|
+
expect(typeof client.show).toBe("function");
|
|
229
|
+
expect(typeof client.create).toBe("function");
|
|
230
|
+
expect(typeof client.claim).toBe("function");
|
|
231
|
+
expect(typeof client.close).toBe("function");
|
|
232
|
+
expect(typeof client.list).toBe("function");
|
|
233
|
+
expect(typeof client.sync).toBe("function");
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("ready() throws when bd is not available", async () => {
|
|
237
|
+
const client = createBeadsTrackerClient(tmpDir);
|
|
238
|
+
await expect(client.ready()).rejects.toThrow();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("sync() throws when bd is not available", async () => {
|
|
242
|
+
const client = createBeadsTrackerClient(tmpDir);
|
|
243
|
+
await expect(client.sync()).rejects.toThrow();
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it("show() throws when bd is not available", async () => {
|
|
247
|
+
const client = createBeadsTrackerClient(tmpDir);
|
|
248
|
+
await expect(client.show("test-id")).rejects.toThrow();
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("list() throws when bd is not available", async () => {
|
|
252
|
+
const client = createBeadsTrackerClient(tmpDir);
|
|
253
|
+
await expect(client.list()).rejects.toThrow();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe("tracker module exports", () => {
|
|
258
|
+
it("types.ts exports TrackerIssue, TrackerBackend, TrackerClient", async () => {
|
|
259
|
+
const mod = await import("./types.ts");
|
|
260
|
+
expect(mod).toBeDefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("factory.ts exports resolveBackend and createTrackerClient", async () => {
|
|
264
|
+
const mod = await import("./factory.ts");
|
|
265
|
+
expect(typeof mod.resolveBackend).toBe("function");
|
|
266
|
+
expect(typeof mod.createTrackerClient).toBe("function");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("beads.ts exports createBeadsTrackerClient", async () => {
|
|
270
|
+
const mod = await import("./beads.ts");
|
|
271
|
+
expect(typeof mod.createBeadsTrackerClient).toBe("function");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("seeds.ts exports createSeedsTrackerClient", async () => {
|
|
275
|
+
const mod = await import("./seeds.ts");
|
|
276
|
+
expect(typeof mod.createSeedsTrackerClient).toBe("function");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("exec.ts exports runTrackerCommand", async () => {
|
|
280
|
+
const mod = await import("./exec.ts");
|
|
281
|
+
expect(typeof mod.runTrackerCommand).toBe("function");
|
|
282
|
+
});
|
|
283
|
+
});
|