@os-eco/overstory-cli 0.6.1

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 (170) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +381 -0
  3. package/agents/builder.md +137 -0
  4. package/agents/coordinator.md +263 -0
  5. package/agents/lead.md +301 -0
  6. package/agents/merger.md +160 -0
  7. package/agents/monitor.md +214 -0
  8. package/agents/reviewer.md +140 -0
  9. package/agents/scout.md +119 -0
  10. package/agents/supervisor.md +423 -0
  11. package/package.json +47 -0
  12. package/src/agents/checkpoint.test.ts +88 -0
  13. package/src/agents/checkpoint.ts +101 -0
  14. package/src/agents/hooks-deployer.test.ts +2040 -0
  15. package/src/agents/hooks-deployer.ts +607 -0
  16. package/src/agents/identity.test.ts +603 -0
  17. package/src/agents/identity.ts +384 -0
  18. package/src/agents/lifecycle.test.ts +196 -0
  19. package/src/agents/lifecycle.ts +183 -0
  20. package/src/agents/manifest.test.ts +746 -0
  21. package/src/agents/manifest.ts +354 -0
  22. package/src/agents/overlay.test.ts +676 -0
  23. package/src/agents/overlay.ts +308 -0
  24. package/src/beads/client.test.ts +217 -0
  25. package/src/beads/client.ts +202 -0
  26. package/src/beads/molecules.test.ts +338 -0
  27. package/src/beads/molecules.ts +198 -0
  28. package/src/commands/agents.test.ts +322 -0
  29. package/src/commands/agents.ts +287 -0
  30. package/src/commands/clean.test.ts +670 -0
  31. package/src/commands/clean.ts +618 -0
  32. package/src/commands/completions.test.ts +342 -0
  33. package/src/commands/completions.ts +887 -0
  34. package/src/commands/coordinator.test.ts +1530 -0
  35. package/src/commands/coordinator.ts +733 -0
  36. package/src/commands/costs.test.ts +1119 -0
  37. package/src/commands/costs.ts +564 -0
  38. package/src/commands/dashboard.test.ts +308 -0
  39. package/src/commands/dashboard.ts +838 -0
  40. package/src/commands/doctor.test.ts +294 -0
  41. package/src/commands/doctor.ts +213 -0
  42. package/src/commands/errors.test.ts +647 -0
  43. package/src/commands/errors.ts +248 -0
  44. package/src/commands/feed.test.ts +578 -0
  45. package/src/commands/feed.ts +361 -0
  46. package/src/commands/group.test.ts +262 -0
  47. package/src/commands/group.ts +511 -0
  48. package/src/commands/hooks.test.ts +458 -0
  49. package/src/commands/hooks.ts +253 -0
  50. package/src/commands/init.test.ts +347 -0
  51. package/src/commands/init.ts +650 -0
  52. package/src/commands/inspect.test.ts +670 -0
  53. package/src/commands/inspect.ts +431 -0
  54. package/src/commands/log.test.ts +1454 -0
  55. package/src/commands/log.ts +724 -0
  56. package/src/commands/logs.test.ts +379 -0
  57. package/src/commands/logs.ts +546 -0
  58. package/src/commands/mail.test.ts +1270 -0
  59. package/src/commands/mail.ts +771 -0
  60. package/src/commands/merge.test.ts +670 -0
  61. package/src/commands/merge.ts +355 -0
  62. package/src/commands/metrics.test.ts +444 -0
  63. package/src/commands/metrics.ts +143 -0
  64. package/src/commands/monitor.test.ts +191 -0
  65. package/src/commands/monitor.ts +390 -0
  66. package/src/commands/nudge.test.ts +230 -0
  67. package/src/commands/nudge.ts +372 -0
  68. package/src/commands/prime.test.ts +470 -0
  69. package/src/commands/prime.ts +381 -0
  70. package/src/commands/replay.test.ts +741 -0
  71. package/src/commands/replay.ts +360 -0
  72. package/src/commands/run.test.ts +431 -0
  73. package/src/commands/run.ts +351 -0
  74. package/src/commands/sling.test.ts +657 -0
  75. package/src/commands/sling.ts +661 -0
  76. package/src/commands/spec.test.ts +203 -0
  77. package/src/commands/spec.ts +168 -0
  78. package/src/commands/status.test.ts +430 -0
  79. package/src/commands/status.ts +398 -0
  80. package/src/commands/stop.test.ts +420 -0
  81. package/src/commands/stop.ts +151 -0
  82. package/src/commands/supervisor.test.ts +187 -0
  83. package/src/commands/supervisor.ts +535 -0
  84. package/src/commands/trace.test.ts +745 -0
  85. package/src/commands/trace.ts +325 -0
  86. package/src/commands/watch.test.ts +145 -0
  87. package/src/commands/watch.ts +247 -0
  88. package/src/commands/worktree.test.ts +786 -0
  89. package/src/commands/worktree.ts +311 -0
  90. package/src/config.test.ts +822 -0
  91. package/src/config.ts +829 -0
  92. package/src/doctor/agents.test.ts +454 -0
  93. package/src/doctor/agents.ts +396 -0
  94. package/src/doctor/config-check.test.ts +190 -0
  95. package/src/doctor/config-check.ts +183 -0
  96. package/src/doctor/consistency.test.ts +651 -0
  97. package/src/doctor/consistency.ts +294 -0
  98. package/src/doctor/databases.test.ts +290 -0
  99. package/src/doctor/databases.ts +218 -0
  100. package/src/doctor/dependencies.test.ts +184 -0
  101. package/src/doctor/dependencies.ts +175 -0
  102. package/src/doctor/logs.test.ts +251 -0
  103. package/src/doctor/logs.ts +295 -0
  104. package/src/doctor/merge-queue.test.ts +216 -0
  105. package/src/doctor/merge-queue.ts +144 -0
  106. package/src/doctor/structure.test.ts +291 -0
  107. package/src/doctor/structure.ts +198 -0
  108. package/src/doctor/types.ts +37 -0
  109. package/src/doctor/version.test.ts +136 -0
  110. package/src/doctor/version.ts +129 -0
  111. package/src/e2e/init-sling-lifecycle.test.ts +277 -0
  112. package/src/errors.ts +217 -0
  113. package/src/events/store.test.ts +660 -0
  114. package/src/events/store.ts +369 -0
  115. package/src/events/tool-filter.test.ts +330 -0
  116. package/src/events/tool-filter.ts +126 -0
  117. package/src/index.ts +316 -0
  118. package/src/insights/analyzer.test.ts +466 -0
  119. package/src/insights/analyzer.ts +203 -0
  120. package/src/logging/color.test.ts +142 -0
  121. package/src/logging/color.ts +71 -0
  122. package/src/logging/logger.test.ts +813 -0
  123. package/src/logging/logger.ts +266 -0
  124. package/src/logging/reporter.test.ts +259 -0
  125. package/src/logging/reporter.ts +109 -0
  126. package/src/logging/sanitizer.test.ts +190 -0
  127. package/src/logging/sanitizer.ts +57 -0
  128. package/src/mail/broadcast.test.ts +203 -0
  129. package/src/mail/broadcast.ts +92 -0
  130. package/src/mail/client.test.ts +773 -0
  131. package/src/mail/client.ts +223 -0
  132. package/src/mail/store.test.ts +705 -0
  133. package/src/mail/store.ts +387 -0
  134. package/src/merge/queue.test.ts +359 -0
  135. package/src/merge/queue.ts +231 -0
  136. package/src/merge/resolver.test.ts +1345 -0
  137. package/src/merge/resolver.ts +645 -0
  138. package/src/metrics/store.test.ts +667 -0
  139. package/src/metrics/store.ts +445 -0
  140. package/src/metrics/summary.test.ts +398 -0
  141. package/src/metrics/summary.ts +178 -0
  142. package/src/metrics/transcript.test.ts +356 -0
  143. package/src/metrics/transcript.ts +175 -0
  144. package/src/mulch/client.test.ts +671 -0
  145. package/src/mulch/client.ts +332 -0
  146. package/src/sessions/compat.test.ts +280 -0
  147. package/src/sessions/compat.ts +104 -0
  148. package/src/sessions/store.test.ts +873 -0
  149. package/src/sessions/store.ts +494 -0
  150. package/src/test-helpers.test.ts +124 -0
  151. package/src/test-helpers.ts +126 -0
  152. package/src/tracker/beads.ts +56 -0
  153. package/src/tracker/factory.test.ts +80 -0
  154. package/src/tracker/factory.ts +64 -0
  155. package/src/tracker/seeds.ts +182 -0
  156. package/src/tracker/types.ts +52 -0
  157. package/src/types.ts +724 -0
  158. package/src/watchdog/daemon.test.ts +1975 -0
  159. package/src/watchdog/daemon.ts +671 -0
  160. package/src/watchdog/health.test.ts +431 -0
  161. package/src/watchdog/health.ts +264 -0
  162. package/src/watchdog/triage.test.ts +164 -0
  163. package/src/watchdog/triage.ts +179 -0
  164. package/src/worktree/manager.test.ts +439 -0
  165. package/src/worktree/manager.ts +198 -0
  166. package/src/worktree/tmux.test.ts +1009 -0
  167. package/src/worktree/tmux.ts +509 -0
  168. package/templates/CLAUDE.md.tmpl +89 -0
  169. package/templates/hooks.json.tmpl +105 -0
  170. package/templates/overlay.md.tmpl +81 -0
@@ -0,0 +1,308 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { AgentError } from "../errors.ts";
4
+ import type { OverlayConfig, QualityGate } from "../types.ts";
5
+
6
+ /**
7
+ * Resolve the path to the overlay template file.
8
+ * The template lives at `templates/overlay.md.tmpl` relative to the repo root.
9
+ */
10
+ function getTemplatePath(): string {
11
+ // src/agents/overlay.ts -> repo root is ../../
12
+ return join(dirname(import.meta.dir), "..", "templates", "overlay.md.tmpl");
13
+ }
14
+
15
+ /**
16
+ * Format the file scope list as a markdown bullet list.
17
+ * Returns a human-readable fallback if no files are scoped.
18
+ */
19
+ function formatFileScope(fileScope: readonly string[]): string {
20
+ if (fileScope.length === 0) {
21
+ return "No file scope restrictions";
22
+ }
23
+ return fileScope.map((f) => `- \`${f}\``).join("\n");
24
+ }
25
+
26
+ /**
27
+ * Format mulch domains as a `mulch prime` command.
28
+ * Returns a human-readable fallback if no domains are configured.
29
+ */
30
+ function formatMulchDomains(domains: readonly string[]): string {
31
+ if (domains.length === 0) {
32
+ return "No specific expertise domains configured";
33
+ }
34
+ return `\`\`\`bash\nmulch prime ${domains.join(" ")}\n\`\`\``;
35
+ }
36
+
37
+ /**
38
+ * Format pre-fetched mulch expertise for embedding in the overlay.
39
+ * Returns empty string if no expertise was provided (omits the section entirely).
40
+ * When expertise IS provided, renders it under a 'Pre-loaded Expertise' heading
41
+ * with a brief intro explaining it was loaded at spawn time based on file scope.
42
+ */
43
+ function formatMulchExpertise(expertise: string | undefined): string {
44
+ if (!expertise || expertise.trim().length === 0) {
45
+ return "";
46
+ }
47
+ return [
48
+ "### Pre-loaded Expertise",
49
+ "",
50
+ "The following expertise was automatically loaded at spawn time based on your file scope:",
51
+ "",
52
+ expertise,
53
+ ].join("\n");
54
+ }
55
+
56
+ /** Capabilities that are read-only and should not get quality gates for commits/tests/lint. */
57
+ const READ_ONLY_CAPABILITIES = new Set(["scout", "reviewer"]);
58
+
59
+ /**
60
+ * The skip-scout section injected into lead overlays when --skip-scout is passed.
61
+ * Instructs the lead to bypass Phase 1 (exploration) and go straight to Phase 2 (build).
62
+ */
63
+ const SKIP_SCOUT_SECTION = `
64
+ ## Skip Scout Mode
65
+
66
+ **IMPORTANT**: You have been spawned with \`--skip-scout\`. Skip Phase 1 (Scout) entirely.
67
+ Go directly to Phase 2 (Build): write specs from your existing knowledge and the
68
+ pre-loaded expertise above, then spawn builders immediately.
69
+
70
+ Do NOT spawn scout agents. Do NOT explore the codebase extensively.
71
+ Your parent has already gathered the context you need.
72
+ `;
73
+
74
+ /**
75
+ * Format the quality gates section. Read-only agents (scout, reviewer) get
76
+ * a lightweight section that only tells them to close the issue and report.
77
+ * Writable agents get the full quality gates (tests, lint, build, commit).
78
+ */
79
+ /** Default quality gates used when none are configured. */
80
+ const DEFAULT_GATES: QualityGate[] = [
81
+ { name: "Tests", command: "bun test", description: "all tests must pass" },
82
+ { name: "Lint", command: "bun run lint", description: "zero errors" },
83
+ { name: "Typecheck", command: "bun run typecheck", description: "no TypeScript errors" },
84
+ ];
85
+
86
+ function formatQualityGates(config: OverlayConfig): string {
87
+ if (READ_ONLY_CAPABILITIES.has(config.capability)) {
88
+ return [
89
+ "## Completion",
90
+ "",
91
+ "Before reporting completion:",
92
+ "",
93
+ `1. **Record mulch learnings:** \`mulch record <domain> --type <convention|pattern|reference> --description "..."\` — capture reusable knowledge from your work`,
94
+ `2. **Close issue:** \`${config.trackerCli ?? "bd"} close ${config.beadId} --reason "summary of findings"\``,
95
+ `3. **Send results:** \`overstory mail send --to ${config.parentAgent ?? "coordinator"} --subject "done" --body "Summary" --type result --agent ${config.agentName}\``,
96
+ "",
97
+ "You are a read-only agent. Do NOT commit, modify files, or run quality gates.",
98
+ ].join("\n");
99
+ }
100
+
101
+ const gates =
102
+ config.qualityGates && config.qualityGates.length > 0 ? config.qualityGates : DEFAULT_GATES;
103
+
104
+ const gateLines = gates.map(
105
+ (gate, i) => `${i + 1}. **${gate.name}:** \`${gate.command}\` — ${gate.description}`,
106
+ );
107
+
108
+ return [
109
+ "## Quality Gates",
110
+ "",
111
+ "Before reporting completion, you MUST pass all quality gates:",
112
+ "",
113
+ ...gateLines,
114
+ `${gateLines.length + 1}. **Commit:** all changes committed to your branch (${config.branchName})`,
115
+ `${gateLines.length + 2}. **Record mulch learnings:** \`mulch record <domain> --type <convention|pattern|failure|decision> --description "..." --outcome-status success --outcome-agent ${config.agentName}\` — capture insights from your work`,
116
+ `${gateLines.length + 3}. **Signal completion:** send \`worker_done\` mail to ${config.parentAgent ?? "coordinator"}: \`overstory mail send --to ${config.parentAgent ?? "coordinator"} --subject "Worker done: ${config.beadId}" --body "Quality gates passed." --type worker_done --agent ${config.agentName}\``,
117
+ `${gateLines.length + 4}. **Close issue:** \`${config.trackerCli ?? "bd"} close ${config.beadId} --reason "summary of changes"\``,
118
+ "",
119
+ "Do NOT push to the canonical branch. Your work will be merged by the",
120
+ "coordinator via `overstory merge`.",
121
+ ].join("\n");
122
+ }
123
+
124
+ /**
125
+ * Format the constraints section. Read-only agents get read-only constraints.
126
+ * Writable agents get file-scope and branch constraints.
127
+ */
128
+ function formatConstraints(config: OverlayConfig): string {
129
+ if (READ_ONLY_CAPABILITIES.has(config.capability)) {
130
+ return [
131
+ "## Constraints",
132
+ "",
133
+ "- You are **read-only**: do NOT modify, create, or delete any files",
134
+ "- Do NOT commit, push, or make any git state changes",
135
+ `- Report completion via \`${config.trackerCli ?? "bd"} close\` AND \`overstory mail send --type result\``,
136
+ "- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
137
+ ].join("\n");
138
+ }
139
+
140
+ return [
141
+ "## Constraints",
142
+ "",
143
+ `- **WORKTREE ISOLATION**: All writes MUST target files within your worktree at \`${config.worktreePath}\``,
144
+ "- NEVER write to the canonical repo root — all writes go to your worktree copy",
145
+ "- Only modify files in your File Scope",
146
+ `- Commit only to your branch: ${config.branchName}`,
147
+ "- Never push to the canonical branch",
148
+ `- Report completion via \`${config.trackerCli ?? "bd"} close\` AND \`overstory mail send --type result\``,
149
+ "- If you encounter a blocking issue, send mail with `--priority urgent --type error`",
150
+ ].join("\n");
151
+ }
152
+
153
+ /**
154
+ * Format the can-spawn section. If the agent can spawn sub-workers,
155
+ * include an example sling command. Otherwise, state the restriction.
156
+ */
157
+ function formatCanSpawn(config: OverlayConfig): string {
158
+ if (!config.canSpawn) {
159
+ return "You may NOT spawn sub-workers.";
160
+ }
161
+ return [
162
+ "You may spawn sub-workers using `overstory sling`. Example:",
163
+ "",
164
+ "```bash",
165
+ "overstory sling <task-id> --capability builder --name <worker-name> \\",
166
+ ` --parent ${config.agentName} --depth ${config.depth + 1}`,
167
+ "```",
168
+ ].join("\n");
169
+ }
170
+
171
+ /**
172
+ * Generate a per-worker CLAUDE.md overlay from the template.
173
+ *
174
+ * Reads `templates/overlay.md.tmpl` and replaces all `{{VARIABLE}}`
175
+ * placeholders with values derived from the provided config.
176
+ *
177
+ * @param config - The overlay configuration for this agent/task
178
+ * @returns The rendered overlay content as a string
179
+ * @throws {AgentError} If the template file cannot be found or read
180
+ */
181
+ export async function generateOverlay(config: OverlayConfig): Promise<string> {
182
+ const templatePath = getTemplatePath();
183
+ const file = Bun.file(templatePath);
184
+ const exists = await file.exists();
185
+
186
+ if (!exists) {
187
+ throw new AgentError(`Overlay template not found: ${templatePath}`, {
188
+ agentName: config.agentName,
189
+ });
190
+ }
191
+
192
+ let template: string;
193
+ try {
194
+ template = await file.text();
195
+ } catch (err) {
196
+ throw new AgentError(`Failed to read overlay template: ${templatePath}`, {
197
+ agentName: config.agentName,
198
+ cause: err instanceof Error ? err : undefined,
199
+ });
200
+ }
201
+
202
+ const specInstruction = config.specPath
203
+ ? "Read your task spec at the path above. It contains the full description of\nwhat you need to build or review."
204
+ : "No task spec was provided. Check your mail or ask your parent agent for details.";
205
+
206
+ const replacements: Record<string, string> = {
207
+ "{{AGENT_NAME}}": config.agentName,
208
+ "{{BEAD_ID}}": config.beadId,
209
+ "{{SPEC_PATH}}": config.specPath ?? "No spec file provided",
210
+ "{{BRANCH_NAME}}": config.branchName,
211
+ "{{WORKTREE_PATH}}": config.worktreePath,
212
+ "{{PARENT_AGENT}}": config.parentAgent ?? "coordinator",
213
+ "{{DEPTH}}": String(config.depth),
214
+ "{{FILE_SCOPE}}": formatFileScope(config.fileScope),
215
+ "{{MULCH_DOMAINS}}": formatMulchDomains(config.mulchDomains),
216
+ "{{MULCH_EXPERTISE}}": formatMulchExpertise(config.mulchExpertise),
217
+ "{{CAN_SPAWN}}": formatCanSpawn(config),
218
+ "{{QUALITY_GATES}}": formatQualityGates(config),
219
+ "{{CONSTRAINTS}}": formatConstraints(config),
220
+ "{{SPEC_INSTRUCTION}}": specInstruction,
221
+ "{{SKIP_SCOUT}}": config.skipScout ? SKIP_SCOUT_SECTION : "",
222
+ "{{BASE_DEFINITION}}": config.baseDefinition,
223
+ "{{TRACKER_CLI}}": config.trackerCli ?? "bd",
224
+ "{{TRACKER_NAME}}": config.trackerName ?? "beads",
225
+ };
226
+
227
+ let result = template;
228
+ for (const [placeholder, value] of Object.entries(replacements)) {
229
+ // Replace all occurrences — some placeholders appear multiple times
230
+ while (result.includes(placeholder)) {
231
+ result = result.replace(placeholder, value);
232
+ }
233
+ }
234
+
235
+ return result;
236
+ }
237
+
238
+ /**
239
+ * Check whether a directory is the canonical project root by comparing resolved paths.
240
+ *
241
+ * Agent overlays must NEVER be written to the canonical repo root -- they belong
242
+ * in worktrees. Writing an overlay to the project root overwrites the orchestrator's
243
+ * `.claude/CLAUDE.md`, breaking the user's own Claude Code session (overstory-uwg4).
244
+ *
245
+ * Uses deterministic path comparison instead of checking for `.overstory/config.yaml`
246
+ * because when dogfooding (running overstory on its own repo), that file is tracked
247
+ * in git and appears in every worktree checkout (overstory-p4st).
248
+ *
249
+ * @param dir - Absolute path to check
250
+ * @param canonicalRoot - Absolute path to the canonical project root
251
+ * @returns true if dir resolves to the same path as canonicalRoot
252
+ */
253
+ export function isCanonicalRoot(dir: string, canonicalRoot: string): boolean {
254
+ return resolve(dir) === resolve(canonicalRoot);
255
+ }
256
+
257
+ /**
258
+ * Generate the overlay and write it to `{worktreePath}/.claude/CLAUDE.md`.
259
+ * Creates the `.claude/` directory if it does not exist.
260
+ *
261
+ * Includes a safety guard that prevents writing to the canonical project root.
262
+ * Agent overlays belong in worktrees, never at the orchestrator's root.
263
+ *
264
+ * @param worktreePath - Absolute path to the agent's git worktree
265
+ * @param config - The overlay configuration for this agent/task
266
+ * @param canonicalRoot - Absolute path to the canonical project root (for guard check)
267
+ * @throws {AgentError} If worktreePath is the canonical project root, or if
268
+ * the directory cannot be created or the file cannot be written
269
+ */
270
+ export async function writeOverlay(
271
+ worktreePath: string,
272
+ config: OverlayConfig,
273
+ canonicalRoot: string,
274
+ ): Promise<void> {
275
+ // Guard: never write agent overlays to the canonical project root.
276
+ // The project root's .claude/CLAUDE.md belongs to the orchestrator/user.
277
+ // Uses path comparison instead of file-existence heuristic to handle
278
+ // dogfooding scenarios where .overstory/config.yaml is tracked in git
279
+ // and appears in every worktree checkout (overstory-p4st).
280
+ if (isCanonicalRoot(worktreePath, canonicalRoot)) {
281
+ throw new AgentError(
282
+ `Refusing to write overlay to canonical project root: ${worktreePath}. Agent overlays must target a worktree, not the orchestrator's root directory. This prevents overwriting the user's .claude/CLAUDE.md.`,
283
+ { agentName: config.agentName },
284
+ );
285
+ }
286
+
287
+ const content = await generateOverlay(config);
288
+ const claudeDir = join(worktreePath, ".claude");
289
+ const outputPath = join(claudeDir, "CLAUDE.md");
290
+
291
+ try {
292
+ await mkdir(claudeDir, { recursive: true });
293
+ } catch (err) {
294
+ throw new AgentError(`Failed to create .claude/ directory at: ${claudeDir}`, {
295
+ agentName: config.agentName,
296
+ cause: err instanceof Error ? err : undefined,
297
+ });
298
+ }
299
+
300
+ try {
301
+ await Bun.write(outputPath, content);
302
+ } catch (err) {
303
+ throw new AgentError(`Failed to write overlay to: ${outputPath}`, {
304
+ agentName: config.agentName,
305
+ cause: err instanceof Error ? err : undefined,
306
+ });
307
+ }
308
+ }
@@ -0,0 +1,217 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { realpathSync } from "node:fs";
3
+ import { AgentError } from "../errors.ts";
4
+ import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
5
+ import { type BeadsClient, createBeadsClient } from "./client.ts";
6
+
7
+ /**
8
+ * Check if the bd CLI is available on this machine (synchronous).
9
+ * Uses Bun.spawnSync so the result is available at test registration time
10
+ * for use with test.skipIf().
11
+ */
12
+ function isBdAvailable(): boolean {
13
+ try {
14
+ const result = Bun.spawnSync(["bd", "--version"], {
15
+ stdout: "pipe",
16
+ stderr: "pipe",
17
+ });
18
+ return result.exitCode === 0;
19
+ } catch {
20
+ return false;
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Initialize beads in a git repo directory.
26
+ */
27
+ async function initBeads(cwd: string): Promise<void> {
28
+ const proc = Bun.spawn(["bd", "init"], {
29
+ cwd,
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ const exitCode = await proc.exited;
34
+ if (exitCode !== 0) {
35
+ const stderr = await new Response(proc.stderr).text();
36
+ throw new Error(`bd init failed: ${stderr}`);
37
+ }
38
+ }
39
+
40
+ const bdAvailable = isBdAvailable();
41
+
42
+ /**
43
+ * Optimized test suite: uses a single shared repo (beforeAll) instead of
44
+ * creating a fresh repo per test. All 16 original tests share one repo
45
+ * since they create issues with unique IDs and use toContain/not.toContain
46
+ * assertions. This reduces setup from ~96 subprocess spawns to ~6.
47
+ */
48
+ describe("createBeadsClient (integration)", () => {
49
+ let tempDir: string;
50
+ let client: BeadsClient;
51
+
52
+ // Pre-created issue IDs for tests that need existing issues
53
+ let openIssueId: string;
54
+ let claimedIssueId: string;
55
+ let closedIssueId: string;
56
+
57
+ beforeAll(async () => {
58
+ if (!bdAvailable) return;
59
+ // realpathSync resolves macOS /var -> /private/var symlink so paths match
60
+ tempDir = realpathSync(await createTempGitRepo());
61
+ await initBeads(tempDir);
62
+ client = createBeadsClient(tempDir);
63
+
64
+ // Pre-create issues used by read-only tests (list, ready, show)
65
+ openIssueId = await client.create("Pre-created open issue");
66
+ claimedIssueId = await client.create("Pre-created claimed issue");
67
+ await client.claim(claimedIssueId);
68
+ closedIssueId = await client.create("Pre-created closed issue");
69
+ await client.close(closedIssueId);
70
+ });
71
+
72
+ afterAll(async () => {
73
+ if (!bdAvailable) return;
74
+ await cleanupTempDir(tempDir);
75
+ });
76
+
77
+ describe("create", () => {
78
+ test.skipIf(!bdAvailable)("returns an issue ID", async () => {
79
+ const id = await client.create("Integration test issue");
80
+
81
+ expect(typeof id).toBe("string");
82
+ expect(id.length).toBeGreaterThan(0);
83
+ });
84
+
85
+ test.skipIf(!bdAvailable)("returns ID with type and priority options", async () => {
86
+ const id = await client.create("Typed issue", {
87
+ type: "bug",
88
+ priority: 1,
89
+ });
90
+
91
+ expect(typeof id).toBe("string");
92
+ expect(id.length).toBeGreaterThan(0);
93
+ });
94
+
95
+ test.skipIf(!bdAvailable)("returns ID with description option", async () => {
96
+ const id = await client.create("Described issue", {
97
+ description: "A detailed description",
98
+ });
99
+
100
+ expect(typeof id).toBe("string");
101
+ expect(id.length).toBeGreaterThan(0);
102
+ });
103
+ });
104
+
105
+ describe("show", () => {
106
+ test.skipIf(!bdAvailable)("returns issue details for a valid ID", async () => {
107
+ const id = await client.create("Show test issue", {
108
+ type: "task",
109
+ priority: 2,
110
+ });
111
+
112
+ const issue = await client.show(id);
113
+
114
+ expect(issue.id).toBe(id);
115
+ expect(issue.title).toBe("Show test issue");
116
+ expect(issue.status).toBe("open");
117
+ expect(issue.priority).toBe(2);
118
+ expect(issue.type).toBe("task");
119
+ });
120
+ });
121
+
122
+ describe("claim", () => {
123
+ test.skipIf(!bdAvailable)("changes issue status to in_progress and returns void", async () => {
124
+ const id = await client.create("Claim test issue");
125
+
126
+ const result = await client.claim(id);
127
+ expect(result).toBeUndefined();
128
+
129
+ const issue = await client.show(id);
130
+ expect(issue.status).toBe("in_progress");
131
+ });
132
+ });
133
+
134
+ describe("close", () => {
135
+ test.skipIf(!bdAvailable)("closes issues with and without reason", async () => {
136
+ const id1 = await client.create("Close test issue");
137
+ const id2 = await client.create("Close reason test");
138
+
139
+ await client.close(id1);
140
+ await client.close(id2, "Completed all acceptance criteria");
141
+
142
+ const issue1 = await client.show(id1);
143
+ expect(issue1.status).toBe("closed");
144
+
145
+ const issue2 = await client.show(id2);
146
+ expect(issue2.status).toBe("closed");
147
+ });
148
+ });
149
+
150
+ describe("list", () => {
151
+ test.skipIf(!bdAvailable)("returns all issues", async () => {
152
+ const issues = await client.list();
153
+
154
+ // Pre-created issues should be present (plus any from other tests)
155
+ expect(issues.length).toBeGreaterThanOrEqual(3);
156
+ const titles = issues.map((i) => i.title);
157
+ expect(titles).toContain("Pre-created open issue");
158
+ expect(titles).toContain("Pre-created claimed issue");
159
+ });
160
+
161
+ test.skipIf(!bdAvailable)("filters by status", async () => {
162
+ const openIssues = await client.list({ status: "open" });
163
+ const openIds = openIssues.map((i) => i.id);
164
+ expect(openIds).toContain(openIssueId);
165
+ expect(openIds).not.toContain(claimedIssueId);
166
+ expect(openIds).not.toContain(closedIssueId);
167
+
168
+ const inProgressIssues = await client.list({ status: "in_progress" });
169
+ const inProgressIds = inProgressIssues.map((i) => i.id);
170
+ expect(inProgressIds).toContain(claimedIssueId);
171
+ expect(inProgressIds).not.toContain(openIssueId);
172
+ });
173
+
174
+ test.skipIf(!bdAvailable)("respects limit option", async () => {
175
+ const limited = await client.list({ limit: 1 });
176
+ expect(limited).toHaveLength(1);
177
+ });
178
+ });
179
+
180
+ describe("ready", () => {
181
+ test.skipIf(!bdAvailable)(
182
+ "returns open unblocked issues but not claimed or closed",
183
+ async () => {
184
+ const readyIssues = await client.ready();
185
+ const readyIds = readyIssues.map((i) => i.id);
186
+
187
+ // Open issue should appear in ready
188
+ expect(readyIds).toContain(openIssueId);
189
+ // Claimed issue should not appear in ready
190
+ expect(readyIds).not.toContain(claimedIssueId);
191
+ // Closed issue should not appear in ready
192
+ expect(readyIds).not.toContain(closedIssueId);
193
+ },
194
+ );
195
+ });
196
+
197
+ describe("error handling", () => {
198
+ test.skipIf(!bdAvailable)("show throws AgentError for nonexistent ID", async () => {
199
+ await expect(client.show("nonexistent-id")).rejects.toThrow(AgentError);
200
+ });
201
+
202
+ test.skipIf(!bdAvailable)(
203
+ "throws AgentError when bd is run without beads initialized",
204
+ async () => {
205
+ // Create a git repo without bd init — independent from shared repo
206
+ const bareDir = realpathSync(await createTempGitRepo());
207
+ const bareClient = createBeadsClient(bareDir);
208
+
209
+ try {
210
+ await expect(bareClient.list()).rejects.toThrow(AgentError);
211
+ } finally {
212
+ await cleanupTempDir(bareDir);
213
+ }
214
+ },
215
+ );
216
+ });
217
+ });