@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.
- package/README.md +313 -117
- package/cli.ts +124 -420
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +266 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +326 -399
- package/src/commands/work.ts +153 -153
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/work-target.test.ts +77 -0
- package/src/workbase/work-target.ts +180 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -641
- package/src/commands/push.ts +0 -482
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1187
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
|
@@ -1,462 +1,389 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
109
|
-
const nonGitDir = await createTempDir()
|
|
110
|
-
process.chdir(nonGitDir)
|
|
135
|
+
await harness.run({ cwd: "/workbase/epics/delivery", opencode: true })
|
|
111
136
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
152
|
+
await harness.run({ cwd: "/workbase/tasks/delivery", opencode: true })
|
|
142
153
|
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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
|
-
|
|
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
|
-
|
|
192
|
+
expect(harness.events[0]).toBe("materialize")
|
|
193
|
+
expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example/code/agency")
|
|
194
|
+
})
|
|
213
195
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
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
|
-
|
|
224
|
-
|
|
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
|
-
|
|
227
|
-
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
263
|
+
test("rejects combining explicit epic and task targets", async () => {
|
|
264
|
+
const harness = createHarness()
|
|
252
265
|
|
|
253
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
295
|
+
test("includes absolute task and phase paths in a multi-phase prompt", async () => {
|
|
296
|
+
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
280
297
|
|
|
281
|
-
|
|
298
|
+
await harness.run({
|
|
299
|
+
taskId: "example",
|
|
300
|
+
phaseId: "implementation",
|
|
301
|
+
opencode: true,
|
|
282
302
|
})
|
|
283
303
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
311
|
+
test("automatically falls back to Claude", async () => {
|
|
312
|
+
const harness = createHarness({ available: { opencode: false } })
|
|
307
313
|
|
|
308
|
-
|
|
309
|
-
})
|
|
314
|
+
await harness.run({ taskId: "example" })
|
|
310
315
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
326
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
334
|
+
test("launches explicitly requested Claude", async () => {
|
|
335
|
+
const harness = createHarness()
|
|
347
336
|
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
})
|