@markjaquith/agency 1.11.1 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -641
  73. package/src/commands/push.ts +0 -482
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1187
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -1,462 +1,215 @@
1
- import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
- import { join } from "path"
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { FileSystemService } from "../services/FileSystemService"
4
+ import { WorktreeService } from "../services/WorktreeService"
5
+ import { captureLogs } from "../test-utils"
3
6
  import { work } from "./work"
4
- import {
5
- createTempDir,
6
- cleanupTempDir,
7
- initGitRepo,
8
- initAgency,
9
- runTestEffect,
10
- createCommit,
11
- } from "../test-utils"
12
- import { writeFileSync } from "node:fs"
13
7
 
14
- /**
15
- * Helper to mock CLI tool detection and execution for work command tests.
16
- * Returns restore function to clean up mocks.
17
- *
18
- * @param options Configuration for the mock
19
- * @param options.hasOpencode Whether 'which opencode' should succeed (default: true)
20
- * @param options.hasClaude Whether 'which claude' should succeed (default: false)
21
- * @param options.onSpawn Callback when Bun.spawn is called (non-git commands)
22
- * @returns Restore function to clean up mocks
23
- */
24
- function mockCliTools(
25
- options: {
26
- hasOpencode?: boolean
27
- hasClaude?: boolean
28
- onSpawn?: (args: string[], options: any) => any
29
- } = {},
30
- ) {
31
- const { hasOpencode = true, hasClaude = false, onSpawn } = options
32
-
33
- const originalSpawn = Bun.spawn
34
- const originalSpawnSync = Bun.spawnSync
35
-
36
- // @ts-ignore - mocking for test
37
- Bun.spawnSync = (args: any, options: any) => {
38
- // Mock which command to return success/failure based on config
39
- if (Array.isArray(args) && args[0] === "which") {
40
- if (args[1] === "opencode") {
41
- return { exitCode: hasOpencode ? 0 : 1 }
42
- }
43
- if (args[1] === "claude") {
44
- return { exitCode: hasClaude ? 0 : 1 }
45
- }
46
- }
47
- return originalSpawnSync(args, options)
48
- }
8
+ type ExecutionWorkspace = Effect.Effect.Success<
9
+ ReturnType<WorktreeService["materialize"]>
10
+ >
11
+
12
+ const singlePhaseWorkspace: ExecutionWorkspace = {
13
+ root: "/workbase",
14
+ taskPath: "/workbase/tasks/example/TASK.md",
15
+ phasePath: null,
16
+ codePath: "/workbase/tasks/example/code",
17
+ writablePath: "/workbase/tasks/example/code/agency",
18
+ repo: "agency",
19
+ repos: [],
20
+ }
49
21
 
50
- // @ts-ignore - mocking for test
51
- Bun.spawn = (args: any, options: any) => {
52
- // Allow git commands to pass through
53
- if (Array.isArray(args) && args[0] === "git") {
54
- return originalSpawn(args, options)
55
- }
22
+ const multiPhaseWorkspace: ExecutionWorkspace = {
23
+ root: "/workbase",
24
+ taskPath: "/workbase/tasks/example/TASK.md",
25
+ phasePath: "/workbase/tasks/example/phases/implementation/PHASE.md",
26
+ codePath: "/workbase/tasks/example/phases/implementation/code",
27
+ writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
28
+ repo: "agency",
29
+ repos: [],
30
+ }
56
31
 
57
- // Call custom handler if provided
58
- if (onSpawn) {
59
- return onSpawn(args, options)
60
- }
32
+ interface HarnessOptions {
33
+ readonly workspace?: ExecutionWorkspace
34
+ readonly materializeError?: Error
35
+ readonly available?: Partial<Record<"opencode" | "claude", boolean>>
36
+ }
61
37
 
62
- // Default mock response
63
- return {
64
- exited: Promise.resolve(0),
65
- exitCode: 0,
66
- stdout: new ReadableStream(),
67
- stderr: new ReadableStream(),
68
- }
38
+ const createHarness = (options: HarnessOptions = {}) => {
39
+ const events: string[] = []
40
+ const probes: string[] = []
41
+ const launches: Array<{
42
+ cli: string
43
+ args: readonly string[]
44
+ cwd: string
45
+ }> = []
46
+ const worktrees = {
47
+ materialize: () => {
48
+ events.push("materialize")
49
+ return options.materializeError
50
+ ? Effect.fail(options.materializeError)
51
+ : Effect.succeed(options.workspace ?? singlePhaseWorkspace)
52
+ },
69
53
  }
70
-
71
- // Return restore function
72
- return () => {
73
- // @ts-ignore - restore
74
- Bun.spawn = originalSpawn
75
- // @ts-ignore - restore
76
- Bun.spawnSync = originalSpawnSync
54
+ const fs = {
55
+ runCommand: (args: readonly string[]) => {
56
+ const cli = args[1] as "opencode" | "claude"
57
+ events.push(`probe:${cli}`)
58
+ probes.push(cli)
59
+ return Effect.succeed({
60
+ exitCode: options.available?.[cli] === false ? 1 : 0,
61
+ stdout: "",
62
+ stderr: "",
63
+ })
64
+ },
65
+ }
66
+ const launch = (cli: string, args: readonly string[], cwd: string) => {
67
+ events.push(`launch:${cli}`)
68
+ launches.push({ cli, args, cwd })
77
69
  }
70
+ const run = (commandOptions: Parameters<typeof work>[0]) =>
71
+ Effect.runPromise(
72
+ work(commandOptions, launch).pipe(
73
+ Effect.provideService(WorktreeService, worktrees as never),
74
+ Effect.provideService(FileSystemService, fs as never),
75
+ ) as Effect.Effect<void, unknown, never>,
76
+ )
77
+
78
+ return { events, probes, launches, run }
78
79
  }
79
80
 
80
81
  describe("work command", () => {
81
- let tempDir: string
82
- let originalCwd: string
83
-
84
- beforeEach(async () => {
85
- tempDir = await createTempDir()
86
- originalCwd = process.cwd()
87
- process.chdir(tempDir)
88
-
89
- // Initialize git repo
90
- await initGitRepo(tempDir)
91
- await createCommit(tempDir, "Initial commit")
92
- })
82
+ test("requires a task ID before materialization", async () => {
83
+ const harness = createHarness()
93
84
 
94
- afterEach(async () => {
95
- process.chdir(originalCwd)
96
- await cleanupTempDir(tempDir)
85
+ await expect(harness.run({})).rejects.toThrow("Task ID is required")
86
+ expect(harness.events).toEqual([])
97
87
  })
98
88
 
99
- describe("error handling", () => {
100
- test("throws error when TASK.md doesn't exist", async () => {
101
- expect(
102
- runTestEffect(work({ silent: true, _noExec: true })),
103
- ).rejects.toThrow(
104
- "TASK.md not found. Run 'agency task' first to create it.",
105
- )
106
- })
107
-
108
- test("throws error when not in a git repository", async () => {
109
- const nonGitDir = await createTempDir()
110
- process.chdir(nonGitDir)
89
+ test("rejects conflicting agent flags before materialization", async () => {
90
+ const harness = createHarness()
111
91
 
112
- expect(
113
- runTestEffect(work({ silent: true, _noExec: true })),
114
- ).rejects.toThrow("Not in a git repository")
115
-
116
- await cleanupTempDir(nonGitDir)
117
- })
92
+ await expect(
93
+ harness.run({ taskId: "example", opencode: true, claude: true }),
94
+ ).rejects.toThrow("Cannot use both --opencode and --claude")
95
+ expect(harness.events).toEqual([])
118
96
  })
119
97
 
120
- describe("TASK.md validation", () => {
121
- test("finds TASK.md in git root", async () => {
122
- // Create TASK.md
123
- const taskPath = join(tempDir, "TASK.md")
124
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
125
-
126
- let spawnCalled = false
127
- let spawnArgs: any[] = []
128
-
129
- const restore = mockCliTools({
130
- onSpawn: (args) => {
131
- spawnCalled = true
132
- spawnArgs = args
133
- return {
134
- exited: Promise.resolve(0),
135
- }
136
- },
137
- })
138
-
139
- await runTestEffect(work({ silent: true, _noExec: true }))
140
-
141
- restore()
142
-
143
- expect(spawnCalled).toBe(true)
144
- expect(spawnArgs).toEqual(["opencode", "--prompt", "Start the task"])
145
- })
98
+ test("launches OpenCode in the writable checkout with the single-phase prompt", async () => {
99
+ const harness = createHarness()
100
+
101
+ await harness.run({ taskId: "example", opencode: true })
102
+
103
+ expect(harness.events).toEqual([
104
+ "materialize",
105
+ "probe:opencode",
106
+ "launch:opencode",
107
+ ])
108
+ expect(harness.launches).toEqual([
109
+ {
110
+ cli: "opencode",
111
+ args: [
112
+ "opencode",
113
+ "--prompt",
114
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
115
+ ],
116
+ cwd: "/workbase/tasks/example/code/agency",
117
+ },
118
+ ])
146
119
  })
147
120
 
148
- describe("opencode execution", () => {
149
- test("passes correct arguments to opencode", async () => {
150
- // Create TASK.md
151
- const taskPath = join(tempDir, "TASK.md")
152
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
153
-
154
- let capturedArgs: string[] = []
155
- let capturedOptions: any = null
156
-
157
- const restore = mockCliTools({
158
- onSpawn: (args, options) => {
159
- capturedArgs = args
160
- capturedOptions = options
161
- return {
162
- exited: Promise.resolve(0),
163
- exitCode: 0,
164
- stdout: new ReadableStream(),
165
- stderr: new ReadableStream(),
166
- }
167
- },
168
- })
169
-
170
- await runTestEffect(work({ silent: true, _noExec: true }))
171
-
172
- restore()
121
+ test("includes absolute task and phase paths in a multi-phase prompt", async () => {
122
+ const harness = createHarness({ workspace: multiPhaseWorkspace })
173
123
 
174
- expect(capturedArgs).toEqual(["opencode", "--prompt", "Start the task"])
175
- // On macOS, temp directories can have /private prefix
176
- expect(
177
- capturedOptions.cwd === tempDir ||
178
- capturedOptions.cwd === `/private${tempDir}`,
179
- ).toBe(true)
180
- expect(capturedOptions.stdout).toEqual("inherit")
181
- expect(capturedOptions.stderr).toEqual("inherit")
124
+ await harness.run({
125
+ taskId: "example",
126
+ phaseId: "implementation",
127
+ opencode: true,
182
128
  })
183
129
 
184
- test("throws error when opencode exits with non-zero code", async () => {
185
- // Create TASK.md
186
- const taskPath = join(tempDir, "TASK.md")
187
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
188
-
189
- const restore = mockCliTools({
190
- onSpawn: () => ({
191
- exited: Promise.resolve(1),
192
- exitCode: 1,
193
- stdout: new ReadableStream(),
194
- stderr: new ReadableStream(),
195
- }),
196
- })
197
-
198
- expect(
199
- runTestEffect(work({ silent: true, _noExec: true })),
200
- ).rejects.toThrow("opencode exited with code 1")
201
-
202
- restore()
203
- })
130
+ expect(harness.launches[0]?.args).toEqual([
131
+ "opencode",
132
+ "--prompt",
133
+ "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
134
+ ])
204
135
  })
205
136
 
206
- describe("silent mode", () => {
207
- test("verbose mode logs debug information", async () => {
208
- // Create TASK.md
209
- const taskPath = join(tempDir, "TASK.md")
210
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
211
-
212
- const restore = mockCliTools()
213
-
214
- // Capture console.log
215
- const originalLog = console.log
216
- let logMessages: string[] = []
217
- console.log = (msg: string) => {
218
- logMessages.push(msg)
219
- }
220
-
221
- await runTestEffect(work({ silent: false, verbose: true, _noExec: true }))
222
-
223
- console.log = originalLog
224
- restore()
225
-
226
- expect(logMessages.some((msg) => msg.includes("Found TASK.md"))).toBe(
227
- true,
228
- )
229
- expect(logMessages.some((msg) => msg.includes("Running opencode"))).toBe(
230
- true,
231
- )
232
- })
233
-
234
- test("silent flag suppresses verbose output", async () => {
235
- // Create TASK.md
236
- const taskPath = join(tempDir, "TASK.md")
237
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
238
-
239
- const restore = mockCliTools()
240
-
241
- // Capture console.log
242
- const originalLog = console.log
243
- let logCalled = false
244
- console.log = () => {
245
- logCalled = true
246
- }
247
-
248
- await work({ silent: true, verbose: true, _noExec: true })
137
+ test("automatically falls back to Claude", async () => {
138
+ const harness = createHarness({ available: { opencode: false } })
249
139
 
250
- console.log = originalLog
251
- restore()
140
+ await harness.run({ taskId: "example" })
252
141
 
253
- expect(logCalled).toBe(false)
142
+ expect(harness.probes).toEqual(["opencode", "claude"])
143
+ expect(harness.launches[0]).toEqual({
144
+ cli: "claude",
145
+ args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
146
+ cwd: "/workbase/tasks/example/code/agency",
254
147
  })
255
148
  })
256
149
 
257
- describe("CLI selection flags", () => {
258
- test("--opencode flag forces use of OpenCode", async () => {
259
- // Create TASK.md
260
- const taskPath = join(tempDir, "TASK.md")
261
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
150
+ test("does not fall back when OpenCode is explicitly required", async () => {
151
+ const harness = createHarness({ available: { opencode: false } })
262
152
 
263
- let capturedArgs: string[] = []
264
-
265
- const restore = mockCliTools({
266
- onSpawn: (args) => {
267
- capturedArgs = args
268
- return {
269
- exited: Promise.resolve(0),
270
- exitCode: 0,
271
- stdout: new ReadableStream(),
272
- stderr: new ReadableStream(),
273
- }
274
- },
275
- })
276
-
277
- await runTestEffect(work({ silent: true, _noExec: true, opencode: true }))
278
-
279
- restore()
280
-
281
- expect(capturedArgs).toEqual(["opencode", "--prompt", "Start the task"])
282
- })
283
-
284
- test("--claude flag forces use of Claude Code", async () => {
285
- // Create TASK.md
286
- const taskPath = join(tempDir, "TASK.md")
287
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
288
-
289
- let capturedArgs: string[] = []
290
-
291
- const restore = mockCliTools({
292
- hasClaude: true,
293
- onSpawn: (args) => {
294
- capturedArgs = args
295
- return {
296
- exited: Promise.resolve(0),
297
- exitCode: 0,
298
- stdout: new ReadableStream(),
299
- stderr: new ReadableStream(),
300
- }
301
- },
302
- })
303
-
304
- await runTestEffect(work({ silent: true, _noExec: true, claude: true }))
305
-
306
- restore()
307
-
308
- expect(capturedArgs).toEqual(["claude", "Start the task"])
309
- })
310
-
311
- test("throws error when both --opencode and --claude flags are used", async () => {
312
- // Create TASK.md
313
- const taskPath = join(tempDir, "TASK.md")
314
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
315
-
316
- expect(
317
- runTestEffect(
318
- work({ silent: true, _noExec: true, opencode: true, claude: true }),
319
- ),
320
- ).rejects.toThrow(
321
- "Cannot use both --opencode and --claude flags together. Choose one.",
322
- )
323
- })
324
-
325
- test("throws error when --opencode is used but opencode is not installed", async () => {
326
- // Create TASK.md
327
- const taskPath = join(tempDir, "TASK.md")
328
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
329
-
330
- const restore = mockCliTools({ hasOpencode: false })
331
-
332
- expect(
333
- runTestEffect(work({ silent: true, _noExec: true, opencode: true })),
334
- ).rejects.toThrow(
335
- "opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
336
- )
337
-
338
- restore()
339
- })
340
-
341
- test("throws error when --claude is used but claude is not installed", async () => {
342
- // Create TASK.md
343
- const taskPath = join(tempDir, "TASK.md")
344
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
153
+ await expect(
154
+ harness.run({ taskId: "example", opencode: true }),
155
+ ).rejects.toThrow("opencode CLI tool not found")
156
+ expect(harness.probes).toEqual(["opencode"])
157
+ expect(harness.launches).toEqual([])
158
+ })
345
159
 
346
- const restore = mockCliTools({ hasClaude: false })
160
+ test("launches explicitly requested Claude", async () => {
161
+ const harness = createHarness()
347
162
 
348
- expect(
349
- runTestEffect(work({ silent: true, _noExec: true, claude: true })),
350
- ).rejects.toThrow(
351
- "claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
352
- )
163
+ await harness.run({ taskId: "example", claude: true })
353
164
 
354
- restore()
165
+ expect(harness.probes).toEqual(["claude"])
166
+ expect(harness.launches[0]).toEqual({
167
+ cli: "claude",
168
+ args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
169
+ cwd: "/workbase/tasks/example/code/agency",
355
170
  })
356
171
  })
357
172
 
358
- describe("extra arguments", () => {
359
- test("passes extra args to opencode", async () => {
360
- // Create TASK.md
361
- const taskPath = join(tempDir, "TASK.md")
362
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
363
-
364
- let capturedArgs: string[] = []
365
-
366
- const restore = mockCliTools({
367
- onSpawn: (args) => {
368
- capturedArgs = args
369
- return {
370
- exited: Promise.resolve(0),
371
- exitCode: 0,
372
- stdout: new ReadableStream(),
373
- stderr: new ReadableStream(),
374
- }
375
- },
376
- })
377
-
378
- await runTestEffect(
379
- work({
380
- silent: true,
381
- _noExec: true,
382
- extraArgs: ["--model", "claude-sonnet-4-20250514"],
383
- }),
384
- )
385
-
386
- restore()
387
-
388
- expect(capturedArgs).toEqual([
389
- "opencode",
390
- "--prompt",
391
- "Start the task",
392
- "--model",
393
- "claude-sonnet-4-20250514",
394
- ])
173
+ test("fails when neither agent tool is available", async () => {
174
+ const harness = createHarness({
175
+ available: { opencode: false, claude: false },
395
176
  })
396
177
 
397
- test("passes extra args to claude", async () => {
398
- // Create TASK.md
399
- const taskPath = join(tempDir, "TASK.md")
400
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
401
-
402
- let capturedArgs: string[] = []
403
-
404
- const restore = mockCliTools({
405
- hasClaude: true,
406
- onSpawn: (args) => {
407
- capturedArgs = args
408
- return {
409
- exited: Promise.resolve(0),
410
- exitCode: 0,
411
- stdout: new ReadableStream(),
412
- stderr: new ReadableStream(),
413
- }
414
- },
415
- })
416
-
417
- await runTestEffect(
418
- work({
419
- silent: true,
420
- _noExec: true,
421
- claude: true,
422
- extraArgs: ["--arbitrary", "switches"],
423
- }),
424
- )
425
-
426
- restore()
178
+ await expect(harness.run({ taskId: "example" })).rejects.toThrow(
179
+ "claude CLI tool not found",
180
+ )
181
+ expect(harness.probes).toEqual(["opencode", "claude"])
182
+ expect(harness.launches).toEqual([])
183
+ })
427
184
 
428
- expect(capturedArgs).toEqual([
429
- "claude",
430
- "Start the task",
431
- "--arbitrary",
432
- "switches",
433
- ])
185
+ test("does not probe or launch when materialization fails", async () => {
186
+ const harness = createHarness({
187
+ materializeError: new Error("materialization failed"),
434
188
  })
435
189
 
436
- test("works without extra args", async () => {
437
- // Create TASK.md
438
- const taskPath = join(tempDir, "TASK.md")
439
- writeFileSync(taskPath, "# Test Task\n\nSome task content")
440
-
441
- let capturedArgs: string[] = []
442
-
443
- const restore = mockCliTools({
444
- onSpawn: (args) => {
445
- capturedArgs = args
446
- return {
447
- exited: Promise.resolve(0),
448
- exitCode: 0,
449
- stdout: new ReadableStream(),
450
- stderr: new ReadableStream(),
451
- }
452
- },
453
- })
454
-
455
- await runTestEffect(work({ silent: true, _noExec: true }))
456
-
457
- restore()
190
+ await expect(harness.run({ taskId: "example" })).rejects.toThrow(
191
+ "materialization failed",
192
+ )
193
+ expect(harness.events).toEqual(["materialize"])
194
+ })
458
195
 
459
- expect(capturedArgs).toEqual(["opencode", "--prompt", "Start the task"])
460
- })
196
+ test("respects silent and verbose logging options", async () => {
197
+ const verboseHarness = createHarness()
198
+ const verboseLogs = await captureLogs(() =>
199
+ verboseHarness.run({ taskId: "example", verbose: true }),
200
+ )
201
+ expect(verboseLogs).toEqual([
202
+ "Launching opencode in /workbase/tasks/example/code/agency",
203
+ ])
204
+
205
+ const silentHarness = createHarness()
206
+ const silentLogs = await captureLogs(() =>
207
+ silentHarness.run({
208
+ taskId: "example",
209
+ verbose: true,
210
+ silent: true,
211
+ }),
212
+ )
213
+ expect(silentLogs).toEqual([])
461
214
  })
462
215
  })