@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -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 +326 -399
  24. package/src/commands/work.ts +153 -153
  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/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -1,462 +1,389 @@
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 { WorkbaseService } from "../services/WorkbaseService"
5
+ import { EpicService } from "../services/EpicService"
6
+ import { TaskService } from "../services/TaskService"
7
+ import { PhaseService } from "../services/PhaseService"
8
+ import { WorktreeService } from "../services/WorktreeService"
9
+ import { captureLogs } from "../test-utils"
3
10
  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
-
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
- }
11
+ import type { PickWorkTarget } from "../workbase/work-target"
12
+
13
+ type ExecutionWorkspace = Effect.Effect.Success<
14
+ ReturnType<WorktreeService["materialize"]>
15
+ >
16
+
17
+ const singlePhaseWorkspace: ExecutionWorkspace = {
18
+ root: "/workbase",
19
+ taskPath: "/workbase/tasks/example/TASK.md",
20
+ phasePath: null,
21
+ codePath: "/workbase/tasks/example/code",
22
+ writablePath: "/workbase/tasks/example/code/agency",
23
+ repo: "agency",
24
+ repos: [],
25
+ }
49
26
 
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
- }
27
+ const multiPhaseWorkspace: ExecutionWorkspace = {
28
+ root: "/workbase",
29
+ taskPath: "/workbase/tasks/example/TASK.md",
30
+ phasePath: "/workbase/tasks/example/phases/implementation/PHASE.md",
31
+ codePath: "/workbase/tasks/example/phases/implementation/code",
32
+ writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
33
+ repo: "agency",
34
+ repos: [],
35
+ }
56
36
 
57
- // Call custom handler if provided
58
- if (onSpawn) {
59
- return onSpawn(args, options)
60
- }
37
+ interface HarnessOptions {
38
+ readonly workspace?: ExecutionWorkspace
39
+ readonly materializeError?: Error
40
+ readonly available?: Partial<Record<"opencode" | "claude" | "fzf", boolean>>
41
+ readonly multiPhaseTasks?: readonly string[]
42
+ readonly epicRecords?: readonly any[]
43
+ readonly taskRecords?: readonly any[]
44
+ readonly phaseRecords?: readonly any[]
45
+ }
61
46
 
62
- // Default mock response
63
- return {
64
- exited: Promise.resolve(0),
65
- exitCode: 0,
66
- stdout: new ReadableStream(),
67
- stderr: new ReadableStream(),
68
- }
47
+ const createHarness = (options: HarnessOptions = {}) => {
48
+ const events: string[] = []
49
+ const probes: string[] = []
50
+ const launches: Array<{
51
+ cli: string
52
+ args: readonly string[]
53
+ cwd: string
54
+ }> = []
55
+ const worktrees = {
56
+ materialize: () => {
57
+ events.push("materialize")
58
+ return options.materializeError
59
+ ? Effect.fail(options.materializeError)
60
+ : Effect.succeed(options.workspace ?? singlePhaseWorkspace)
61
+ },
69
62
  }
70
-
71
- // Return restore function
72
- return () => {
73
- // @ts-ignore - restore
74
- Bun.spawn = originalSpawn
75
- // @ts-ignore - restore
76
- Bun.spawnSync = originalSpawnSync
63
+ const workbase = {
64
+ discover: () => Effect.succeed("/workbase"),
65
+ }
66
+ const epics = {
67
+ show: (id: string) =>
68
+ Effect.succeed({
69
+ id,
70
+ path: `/workbase/epics/${id}/EPIC.md`,
71
+ data: { tasks: [] },
72
+ }),
73
+ list: () => Effect.succeed(options.epicRecords ?? []),
74
+ }
75
+ const tasks = {
76
+ show: (id: string) =>
77
+ Effect.succeed({
78
+ id,
79
+ path: `/workbase/tasks/${id}/TASK.md`,
80
+ data: options.multiPhaseTasks?.includes(id)
81
+ ? { phases: [] }
82
+ : { repo: "agency", branch: `task/${id}`, base: "main" },
83
+ }),
84
+ list: () => Effect.succeed(options.taskRecords ?? []),
85
+ }
86
+ const phases = {
87
+ show: (taskId: string, id: string) =>
88
+ Effect.succeed({
89
+ taskId,
90
+ id,
91
+ path: `/workbase/tasks/${taskId}/phases/${id}/PHASE.md`,
92
+ data: { repo: "agency", branch: `task/${id}`, base: "main" },
93
+ }),
94
+ list: () => Effect.succeed(options.phaseRecords ?? []),
77
95
  }
96
+ const fs = {
97
+ runCommand: (args: readonly string[]) => {
98
+ const cli = args[1] as "opencode" | "claude"
99
+ events.push(`probe:${cli}`)
100
+ probes.push(cli)
101
+ return Effect.succeed({
102
+ exitCode: options.available?.[cli] === false ? 1 : 0,
103
+ stdout: "",
104
+ stderr: "",
105
+ })
106
+ },
107
+ }
108
+ const launch = (cli: string, args: readonly string[], cwd: string) => {
109
+ events.push(`launch:${cli}`)
110
+ launches.push({ cli, args, cwd })
111
+ }
112
+ const defaultPick: PickWorkTarget = () => Effect.succeed(null)
113
+ const run = (
114
+ commandOptions: Parameters<typeof work>[0],
115
+ pick: PickWorkTarget = defaultPick,
116
+ ) =>
117
+ Effect.runPromise(
118
+ work(commandOptions, launch, pick).pipe(
119
+ Effect.provideService(WorktreeService, worktrees as never),
120
+ Effect.provideService(FileSystemService, fs as never),
121
+ Effect.provideService(WorkbaseService, workbase as never),
122
+ Effect.provideService(EpicService, epics as never),
123
+ Effect.provideService(TaskService, tasks as never),
124
+ Effect.provideService(PhaseService, phases as never),
125
+ ) as Effect.Effect<void, unknown, never>,
126
+ )
127
+
128
+ return { events, probes, launches, run }
78
129
  }
79
130
 
80
131
  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
- })
93
-
94
- afterEach(async () => {
95
- process.chdir(originalCwd)
96
- await cleanupTempDir(tempDir)
97
- })
98
-
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
- })
132
+ test("launches an epic agent from an epic directory", async () => {
133
+ const harness = createHarness()
107
134
 
108
- test("throws error when not in a git repository", async () => {
109
- const nonGitDir = await createTempDir()
110
- process.chdir(nonGitDir)
135
+ await harness.run({ cwd: "/workbase/epics/delivery", opencode: true })
111
136
 
112
- expect(
113
- runTestEffect(work({ silent: true, _noExec: true })),
114
- ).rejects.toThrow("Not in a git repository")
115
-
116
- await cleanupTempDir(nonGitDir)
137
+ expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
138
+ expect(harness.launches[0]).toEqual({
139
+ cli: "opencode",
140
+ args: [
141
+ "opencode",
142
+ "--prompt",
143
+ "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
144
+ ],
145
+ cwd: "/workbase/epics/delivery",
117
146
  })
118
147
  })
119
148
 
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 }))
149
+ test("launches a multi-phase task agent without materializing", async () => {
150
+ const harness = createHarness({ multiPhaseTasks: ["delivery"] })
140
151
 
141
- restore()
152
+ await harness.run({ cwd: "/workbase/tasks/delivery", opencode: true })
142
153
 
143
- expect(spawnCalled).toBe(true)
144
- expect(spawnArgs).toEqual(["opencode", "--prompt", "Start the task"])
154
+ expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
155
+ expect(harness.launches[0]).toEqual({
156
+ cli: "opencode",
157
+ args: [
158
+ "opencode",
159
+ "--prompt",
160
+ "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
161
+ ],
162
+ cwd: "/workbase/tasks/delivery",
145
163
  })
146
164
  })
147
165
 
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 }))
166
+ test("infers a phase from a nested checkout directory", async () => {
167
+ const harness = createHarness({ workspace: multiPhaseWorkspace })
171
168
 
172
- restore()
173
-
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")
169
+ await harness.run({
170
+ cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
171
+ opencode: true,
182
172
  })
183
173
 
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
- })
174
+ expect(harness.events).toEqual([
175
+ "materialize",
176
+ "probe:opencode",
177
+ "launch:opencode",
178
+ ])
179
+ expect(harness.launches[0]?.args).toContain(
180
+ "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
181
+ )
182
+ })
197
183
 
198
- expect(
199
- runTestEffect(work({ silent: true, _noExec: true })),
200
- ).rejects.toThrow("opencode exited with code 1")
184
+ test("infers a single-phase task from a nested checkout directory", async () => {
185
+ const harness = createHarness()
201
186
 
202
- restore()
187
+ await harness.run({
188
+ cwd: "/workbase/tasks/example/code/agency/src",
189
+ opencode: true,
203
190
  })
204
- })
205
-
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
191
 
212
- const restore = mockCliTools()
192
+ expect(harness.events[0]).toBe("materialize")
193
+ expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example/code/agency")
194
+ })
213
195
 
214
- // Capture console.log
215
- const originalLog = console.log
216
- let logMessages: string[] = []
217
- console.log = (msg: string) => {
218
- logMessages.push(msg)
219
- }
196
+ test("selects a target with fzf outside an entity directory", async () => {
197
+ const phase = {
198
+ taskId: "delivery",
199
+ id: "build",
200
+ path: "/workbase/tasks/delivery/phases/build/PHASE.md",
201
+ data: {},
202
+ }
203
+ const harness = createHarness({
204
+ workspace: multiPhaseWorkspace,
205
+ taskRecords: [
206
+ {
207
+ id: "delivery",
208
+ path: "/workbase/tasks/delivery/TASK.md",
209
+ data: { phases: [{ id: "build" }] },
210
+ },
211
+ ],
212
+ phaseRecords: [phase],
213
+ })
214
+ const pick: PickWorkTarget = (choices) =>
215
+ Effect.succeed(
216
+ choices.find((choice) => choice.label.includes("build"))!.target,
217
+ )
220
218
 
221
- await runTestEffect(work({ silent: false, verbose: true, _noExec: true }))
219
+ await harness.run({ cwd: "/workbase", opencode: true }, pick)
220
+
221
+ expect(harness.events).toEqual([
222
+ "probe:fzf",
223
+ "materialize",
224
+ "probe:opencode",
225
+ "launch:opencode",
226
+ ])
227
+ expect(harness.launches[0]?.cwd).toBe(
228
+ "/workbase/tasks/example/phases/implementation/code/agency",
229
+ )
230
+ })
222
231
 
223
- console.log = originalLog
224
- restore()
232
+ test("prints the target tree when fzf is unavailable", async () => {
233
+ const harness = createHarness({
234
+ available: { fzf: false },
235
+ epicRecords: [
236
+ {
237
+ id: "delivery",
238
+ path: "/workbase/epics/delivery/EPIC.md",
239
+ data: { tasks: [] },
240
+ },
241
+ ],
242
+ })
225
243
 
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,
244
+ const logs = await captureLogs(async () => {
245
+ await expect(harness.run({ cwd: "/workbase" })).rejects.toThrow(
246
+ "fzf is required",
231
247
  )
232
248
  })
233
249
 
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()
250
+ expect(logs).toEqual(["epic delivery"])
251
+ expect(harness.launches).toEqual([])
252
+ })
240
253
 
241
- // Capture console.log
242
- const originalLog = console.log
243
- let logCalled = false
244
- console.log = () => {
245
- logCalled = true
246
- }
254
+ test("rejects conflicting agent flags before materialization", async () => {
255
+ const harness = createHarness()
247
256
 
248
- await work({ silent: true, verbose: true, _noExec: true })
257
+ await expect(
258
+ harness.run({ taskId: "example", opencode: true, claude: true }),
259
+ ).rejects.toThrow("Cannot use both --opencode and --claude")
260
+ expect(harness.events).toEqual([])
261
+ })
249
262
 
250
- console.log = originalLog
251
- restore()
263
+ test("rejects combining explicit epic and task targets", async () => {
264
+ const harness = createHarness()
252
265
 
253
- expect(logCalled).toBe(false)
254
- })
266
+ await expect(
267
+ harness.run({ epicId: "delivery", taskId: "example" }),
268
+ ).rejects.toThrow("Cannot combine --epic")
269
+ expect(harness.events).toEqual([])
255
270
  })
256
271
 
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")
262
-
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 }))
272
+ test("launches OpenCode in the writable checkout with the single-phase prompt", async () => {
273
+ const harness = createHarness()
274
+
275
+ await harness.run({ taskId: "example", opencode: true })
276
+
277
+ expect(harness.events).toEqual([
278
+ "materialize",
279
+ "probe:opencode",
280
+ "launch:opencode",
281
+ ])
282
+ expect(harness.launches).toEqual([
283
+ {
284
+ cli: "opencode",
285
+ args: [
286
+ "opencode",
287
+ "--prompt",
288
+ "Start the task. Read /workbase/tasks/example/TASK.md.",
289
+ ],
290
+ cwd: "/workbase/tasks/example/code/agency",
291
+ },
292
+ ])
293
+ })
278
294
 
279
- restore()
295
+ test("includes absolute task and phase paths in a multi-phase prompt", async () => {
296
+ const harness = createHarness({ workspace: multiPhaseWorkspace })
280
297
 
281
- expect(capturedArgs).toEqual(["opencode", "--prompt", "Start the task"])
298
+ await harness.run({
299
+ taskId: "example",
300
+ phaseId: "implementation",
301
+ opencode: true,
282
302
  })
283
303
 
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 }))
304
+ expect(harness.launches[0]?.args).toEqual([
305
+ "opencode",
306
+ "--prompt",
307
+ "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
308
+ ])
309
+ })
305
310
 
306
- restore()
311
+ test("automatically falls back to Claude", async () => {
312
+ const harness = createHarness({ available: { opencode: false } })
307
313
 
308
- expect(capturedArgs).toEqual(["claude", "Start the task"])
309
- })
314
+ await harness.run({ taskId: "example" })
310
315
 
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
- )
316
+ expect(harness.probes).toEqual(["opencode", "claude"])
317
+ expect(harness.launches[0]).toEqual({
318
+ cli: "claude",
319
+ args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
320
+ cwd: "/workbase/tasks/example/code/agency",
323
321
  })
322
+ })
324
323
 
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
- })
324
+ test("does not fall back when OpenCode is explicitly required", async () => {
325
+ const harness = createHarness({ available: { opencode: false } })
340
326
 
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")
327
+ await expect(
328
+ harness.run({ taskId: "example", opencode: true }),
329
+ ).rejects.toThrow("opencode CLI tool not found")
330
+ expect(harness.probes).toEqual(["opencode"])
331
+ expect(harness.launches).toEqual([])
332
+ })
345
333
 
346
- const restore = mockCliTools({ hasClaude: false })
334
+ test("launches explicitly requested Claude", async () => {
335
+ const harness = createHarness()
347
336
 
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
- )
337
+ await harness.run({ taskId: "example", claude: true })
353
338
 
354
- restore()
339
+ expect(harness.probes).toEqual(["claude"])
340
+ expect(harness.launches[0]).toEqual({
341
+ cli: "claude",
342
+ args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
343
+ cwd: "/workbase/tasks/example/code/agency",
355
344
  })
356
345
  })
357
346
 
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
- ])
347
+ test("fails when neither agent tool is available", async () => {
348
+ const harness = createHarness({
349
+ available: { opencode: false, claude: false },
395
350
  })
396
351
 
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()
352
+ await expect(harness.run({ taskId: "example" })).rejects.toThrow(
353
+ "claude CLI tool not found",
354
+ )
355
+ expect(harness.probes).toEqual(["opencode", "claude"])
356
+ expect(harness.launches).toEqual([])
357
+ })
427
358
 
428
- expect(capturedArgs).toEqual([
429
- "claude",
430
- "Start the task",
431
- "--arbitrary",
432
- "switches",
433
- ])
359
+ test("does not probe or launch when materialization fails", async () => {
360
+ const harness = createHarness({
361
+ materializeError: new Error("materialization failed"),
434
362
  })
435
363
 
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()
364
+ await expect(harness.run({ taskId: "example" })).rejects.toThrow(
365
+ "materialization failed",
366
+ )
367
+ expect(harness.events).toEqual(["materialize"])
368
+ })
458
369
 
459
- expect(capturedArgs).toEqual(["opencode", "--prompt", "Start the task"])
460
- })
370
+ test("respects silent and verbose logging options", async () => {
371
+ const verboseHarness = createHarness()
372
+ const verboseLogs = await captureLogs(() =>
373
+ verboseHarness.run({ taskId: "example", verbose: true }),
374
+ )
375
+ expect(verboseLogs).toEqual([
376
+ "Launching opencode in /workbase/tasks/example/code/agency",
377
+ ])
378
+
379
+ const silentHarness = createHarness()
380
+ const silentLogs = await captureLogs(() =>
381
+ silentHarness.run({
382
+ taskId: "example",
383
+ verbose: true,
384
+ silent: true,
385
+ }),
386
+ )
387
+ expect(silentLogs).toEqual([])
461
388
  })
462
389
  })