@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.
Files changed (112) hide show
  1. package/CHANGELOG.md +61 -3
  2. package/README.md +21 -10
  3. package/agents/builder.md +11 -10
  4. package/agents/coordinator.md +36 -27
  5. package/agents/cto.md +9 -8
  6. package/agents/gateway.md +28 -12
  7. package/agents/lead.md +45 -30
  8. package/agents/merger.md +4 -4
  9. package/agents/monitor.md +10 -9
  10. package/agents/reviewer.md +8 -8
  11. package/agents/scout.md +10 -10
  12. package/agents/supervisor.md +60 -45
  13. package/package.json +2 -2
  14. package/src/agents/hooks-deployer.test.ts +46 -41
  15. package/src/agents/hooks-deployer.ts +10 -9
  16. package/src/agents/manifest.test.ts +6 -2
  17. package/src/agents/overlay.test.ts +9 -7
  18. package/src/agents/overlay.ts +29 -7
  19. package/src/commands/agents.test.ts +1 -5
  20. package/src/commands/clean.test.ts +2 -5
  21. package/src/commands/clean.ts +25 -1
  22. package/src/commands/completions.test.ts +1 -1
  23. package/src/commands/completions.ts +26 -7
  24. package/src/commands/coordinator.test.ts +87 -82
  25. package/src/commands/coordinator.ts +94 -48
  26. package/src/commands/costs.test.ts +2 -6
  27. package/src/commands/dashboard.test.ts +2 -5
  28. package/src/commands/doctor.test.ts +2 -6
  29. package/src/commands/down.ts +3 -3
  30. package/src/commands/errors.test.ts +2 -6
  31. package/src/commands/feed.test.ts +2 -6
  32. package/src/commands/gateway.test.ts +43 -17
  33. package/src/commands/gateway.ts +101 -11
  34. package/src/commands/hooks.test.ts +2 -5
  35. package/src/commands/init.test.ts +4 -13
  36. package/src/commands/inspect.test.ts +2 -6
  37. package/src/commands/log.test.ts +2 -6
  38. package/src/commands/logs.test.ts +2 -9
  39. package/src/commands/mail.test.ts +76 -215
  40. package/src/commands/mail.ts +43 -187
  41. package/src/commands/metrics.test.ts +3 -10
  42. package/src/commands/nudge.ts +15 -0
  43. package/src/commands/prime.test.ts +4 -11
  44. package/src/commands/replay.test.ts +2 -6
  45. package/src/commands/server.test.ts +1 -5
  46. package/src/commands/server.ts +1 -1
  47. package/src/commands/sling.test.ts +6 -1
  48. package/src/commands/sling.ts +42 -17
  49. package/src/commands/spec.test.ts +2 -5
  50. package/src/commands/status.test.ts +2 -4
  51. package/src/commands/stop.test.ts +2 -5
  52. package/src/commands/supervisor.ts +6 -6
  53. package/src/commands/trace.test.ts +2 -6
  54. package/src/commands/up.test.ts +43 -9
  55. package/src/commands/up.ts +15 -11
  56. package/src/commands/watchman.ts +327 -0
  57. package/src/commands/worktree.test.ts +2 -6
  58. package/src/config.test.ts +34 -104
  59. package/src/config.ts +120 -32
  60. package/src/doctor/agents.test.ts +52 -2
  61. package/src/doctor/agents.ts +4 -2
  62. package/src/doctor/config-check.test.ts +7 -2
  63. package/src/doctor/consistency.test.ts +7 -2
  64. package/src/doctor/databases.test.ts +6 -2
  65. package/src/doctor/dependencies.test.ts +18 -13
  66. package/src/doctor/dependencies.ts +23 -94
  67. package/src/doctor/logs.test.ts +7 -2
  68. package/src/doctor/merge-queue.test.ts +6 -2
  69. package/src/doctor/structure.test.ts +7 -2
  70. package/src/doctor/version.test.ts +7 -2
  71. package/src/e2e/init-sling-lifecycle.test.ts +2 -5
  72. package/src/index.ts +7 -7
  73. package/src/mail/pending.ts +120 -0
  74. package/src/mail/store.test.ts +89 -0
  75. package/src/mail/store.ts +11 -0
  76. package/src/merge/resolver.test.ts +518 -489
  77. package/src/server/index.ts +33 -2
  78. package/src/server/public/app.js +3 -3
  79. package/src/server/public/components/message-bubble.js +11 -1
  80. package/src/server/public/components/terminal-panel.js +66 -74
  81. package/src/server/public/views/chat.js +18 -2
  82. package/src/server/public/views/costs.js +5 -5
  83. package/src/server/public/views/dashboard.js +80 -51
  84. package/src/server/public/views/gateway-chat.js +37 -131
  85. package/src/server/public/views/inspect.js +16 -4
  86. package/src/server/public/views/issues.js +16 -12
  87. package/src/server/routes.test.ts +55 -39
  88. package/src/server/routes.ts +38 -26
  89. package/src/test-helpers.ts +6 -3
  90. package/src/tracker/beads.ts +159 -0
  91. package/src/tracker/exec.ts +44 -0
  92. package/src/tracker/factory.test.ts +283 -0
  93. package/src/tracker/factory.ts +59 -0
  94. package/src/tracker/seeds.ts +156 -0
  95. package/src/tracker/types.ts +46 -0
  96. package/src/types.ts +11 -2
  97. package/src/{watchdog → watchman}/daemon.test.ts +421 -515
  98. package/src/watchman/daemon.ts +940 -0
  99. package/src/worktree/tmux.test.ts +2 -1
  100. package/src/worktree/tmux.ts +4 -4
  101. package/templates/hooks.json.tmpl +17 -17
  102. package/src/beads/client.test.ts +0 -210
  103. package/src/commands/merge.test.ts +0 -676
  104. package/src/commands/watch.test.ts +0 -152
  105. package/src/commands/watch.ts +0 -238
  106. package/src/test-helpers.test.ts +0 -97
  107. package/src/watchdog/daemon.ts +0 -533
  108. package/src/watchdog/health.test.ts +0 -371
  109. package/src/watchdog/triage.test.ts +0 -162
  110. package/src/worktree/manager.test.ts +0 -444
  111. /package/src/{watchdog → watchman}/health.ts +0 -0
  112. /package/src/{watchdog → watchman}/triage.ts +0 -0
@@ -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 = createBeadsClient(projectRoot);
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("--watchdog");
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 = createBeadsClient(projectRoot);
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 = createBeadsClient(projectRoot);
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
- return jsonResponse(store.getAll());
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 = createBeadsClient(projectRoot);
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 = createBeadsClient(projectRoot);
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 = createBeadsClient(projectRoot);
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 terminal log file first (pipe-pane streaming)
1787
+ // Try capture-pane first (clean rendered output)
1775
1788
  let output: string | null = null;
1776
- const { store: captureStore } = openSessionStore(legioDir);
1777
- try {
1778
- const agentSession = captureStore.getByName(agentName);
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 capture-pane if no terminal log available
1794
+ // Fall back to pipe-pane log file if capture-pane fails (session dead)
1787
1795
  if (output === null) {
1788
- const tmuxSession = await resolveTerminalSession(legioDir, projectRoot, agentName);
1789
- if (!tmuxSession) {
1790
- return errorResponse(`Cannot resolve tmux session for agent "${agentName}"`, 404);
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
- output = await captureTmuxPane(tmuxSession, lines);
1794
- if (output === null) {
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({
@@ -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
- // Set git identity at repo level so code that doesn't use GIT_TEST_ENV
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
+ });