@markjaquith/agency 1.11.0 → 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.
- package/README.md +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -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 +172 -419
- package/src/commands/work.ts +47 -166
- 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/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 -518
- package/src/commands/push.ts +0 -434
- 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 -1183
- 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,621 +0,0 @@
|
|
|
1
|
-
import { test, expect, describe, beforeEach, afterEach } from "bun:test"
|
|
2
|
-
import { join } from "path"
|
|
3
|
-
import { loop } from "./loop"
|
|
4
|
-
import {
|
|
5
|
-
createTempDir,
|
|
6
|
-
cleanupTempDir,
|
|
7
|
-
initGitRepo,
|
|
8
|
-
runTestEffect,
|
|
9
|
-
createCommit,
|
|
10
|
-
} from "../test-utils"
|
|
11
|
-
import { writeFileSync } from "node:fs"
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Create a ReadableStream that immediately closes (for mock responses)
|
|
15
|
-
*/
|
|
16
|
-
function createEmptyStream() {
|
|
17
|
-
return new ReadableStream({
|
|
18
|
-
start(controller) {
|
|
19
|
-
controller.close()
|
|
20
|
-
},
|
|
21
|
-
})
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Helper to mock CLI tool detection and execution for loop command tests.
|
|
26
|
-
* Returns restore function to clean up mocks.
|
|
27
|
-
*/
|
|
28
|
-
function mockCliTools(
|
|
29
|
-
options: {
|
|
30
|
-
hasOpencode?: boolean
|
|
31
|
-
hasClaude?: boolean
|
|
32
|
-
onSpawn?: (args: string[], options: any) => any
|
|
33
|
-
} = {},
|
|
34
|
-
) {
|
|
35
|
-
const { hasOpencode = true, hasClaude = false, onSpawn } = options
|
|
36
|
-
|
|
37
|
-
const originalSpawn = Bun.spawn
|
|
38
|
-
const originalSpawnSync = Bun.spawnSync
|
|
39
|
-
|
|
40
|
-
// @ts-ignore - mocking for test
|
|
41
|
-
Bun.spawnSync = (args: any, options: any) => {
|
|
42
|
-
if (Array.isArray(args) && args[0] === "which") {
|
|
43
|
-
if (args[1] === "opencode") {
|
|
44
|
-
return { exitCode: hasOpencode ? 0 : 1 }
|
|
45
|
-
}
|
|
46
|
-
if (args[1] === "claude") {
|
|
47
|
-
return { exitCode: hasClaude ? 0 : 1 }
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return originalSpawnSync(args, options)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// @ts-ignore - mocking for test
|
|
54
|
-
Bun.spawn = (args: any, options: any) => {
|
|
55
|
-
// Allow git commands to pass through (check both "git" and "/usr/bin/git")
|
|
56
|
-
if (
|
|
57
|
-
Array.isArray(args) &&
|
|
58
|
-
(args[0] === "git" || args[0] === "/usr/bin/git")
|
|
59
|
-
) {
|
|
60
|
-
return originalSpawn(args, options)
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Call custom handler if provided
|
|
64
|
-
if (onSpawn) {
|
|
65
|
-
return onSpawn(args, options)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Default mock response - use properly closing streams
|
|
69
|
-
return {
|
|
70
|
-
exited: Promise.resolve(0),
|
|
71
|
-
exitCode: 0,
|
|
72
|
-
stdout: createEmptyStream(),
|
|
73
|
-
stderr: createEmptyStream(),
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return () => {
|
|
78
|
-
// @ts-ignore - restore
|
|
79
|
-
Bun.spawn = originalSpawn
|
|
80
|
-
// @ts-ignore - restore
|
|
81
|
-
Bun.spawnSync = originalSpawnSync
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
describe("loop command", () => {
|
|
86
|
-
let tempDir: string
|
|
87
|
-
let originalCwd: string
|
|
88
|
-
|
|
89
|
-
beforeEach(async () => {
|
|
90
|
-
tempDir = await createTempDir()
|
|
91
|
-
originalCwd = process.cwd()
|
|
92
|
-
process.chdir(tempDir)
|
|
93
|
-
|
|
94
|
-
await initGitRepo(tempDir)
|
|
95
|
-
await createCommit(tempDir, "Initial commit")
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
afterEach(async () => {
|
|
99
|
-
process.chdir(originalCwd)
|
|
100
|
-
await cleanupTempDir(tempDir)
|
|
101
|
-
})
|
|
102
|
-
|
|
103
|
-
describe("error handling", () => {
|
|
104
|
-
test("throws error when TASK.md doesn't exist", async () => {
|
|
105
|
-
const restore = mockCliTools()
|
|
106
|
-
|
|
107
|
-
await expect(
|
|
108
|
-
runTestEffect(loop({ silent: true, maxLoops: 1 })),
|
|
109
|
-
).rejects.toThrow(
|
|
110
|
-
"TASK.md not found. Run 'agency task' first to create it.",
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
restore()
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
test("throws error when not in a git repository", async () => {
|
|
117
|
-
const nonGitDir = await createTempDir()
|
|
118
|
-
process.chdir(nonGitDir)
|
|
119
|
-
|
|
120
|
-
const restore = mockCliTools()
|
|
121
|
-
|
|
122
|
-
await expect(
|
|
123
|
-
runTestEffect(loop({ silent: true, maxLoops: 1 })),
|
|
124
|
-
).rejects.toThrow("Not in a git repository")
|
|
125
|
-
|
|
126
|
-
restore()
|
|
127
|
-
await cleanupTempDir(nonGitDir)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
test("throws error when both --opencode and --claude flags are used", async () => {
|
|
131
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
132
|
-
writeFileSync(taskPath, "# Test Task\n\n- [ ] Do something")
|
|
133
|
-
|
|
134
|
-
await expect(
|
|
135
|
-
runTestEffect(loop({ silent: true, opencode: true, claude: true })),
|
|
136
|
-
).rejects.toThrow(
|
|
137
|
-
"Cannot use both --opencode and --claude flags together. Choose one.",
|
|
138
|
-
)
|
|
139
|
-
})
|
|
140
|
-
|
|
141
|
-
test("throws error when --opencode is used but opencode is not installed", async () => {
|
|
142
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
143
|
-
writeFileSync(taskPath, "# Test Task\n\n- [ ] Do something")
|
|
144
|
-
|
|
145
|
-
const restore = mockCliTools({ hasOpencode: false })
|
|
146
|
-
|
|
147
|
-
await expect(
|
|
148
|
-
runTestEffect(loop({ silent: true, opencode: true })),
|
|
149
|
-
).rejects.toThrow(
|
|
150
|
-
"opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
|
|
151
|
-
)
|
|
152
|
-
|
|
153
|
-
restore()
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
test("throws error when --claude is used but claude is not installed", async () => {
|
|
157
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
158
|
-
writeFileSync(taskPath, "# Test Task\n\n- [ ] Do something")
|
|
159
|
-
|
|
160
|
-
const restore = mockCliTools({ hasClaude: false })
|
|
161
|
-
|
|
162
|
-
await expect(
|
|
163
|
-
runTestEffect(loop({ silent: true, claude: true })),
|
|
164
|
-
).rejects.toThrow(
|
|
165
|
-
"claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
|
|
166
|
-
)
|
|
167
|
-
|
|
168
|
-
restore()
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
test("throws error when neither opencode nor claude is installed", async () => {
|
|
172
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
173
|
-
writeFileSync(taskPath, "# Test Task\n\n- [ ] Do something")
|
|
174
|
-
|
|
175
|
-
const restore = mockCliTools({ hasOpencode: false, hasClaude: false })
|
|
176
|
-
|
|
177
|
-
await expect(
|
|
178
|
-
runTestEffect(loop({ silent: true, maxLoops: 1 })),
|
|
179
|
-
).rejects.toThrow(
|
|
180
|
-
"Neither opencode nor claude CLI tool found. Please install OpenCode or Claude Code.",
|
|
181
|
-
)
|
|
182
|
-
|
|
183
|
-
restore()
|
|
184
|
-
})
|
|
185
|
-
|
|
186
|
-
test("throws error when minLoops is greater than maxLoops", async () => {
|
|
187
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
188
|
-
writeFileSync(taskPath, "# Test Task")
|
|
189
|
-
|
|
190
|
-
const restore = mockCliTools()
|
|
191
|
-
|
|
192
|
-
await expect(
|
|
193
|
-
runTestEffect(loop({ silent: true, minLoops: 5, maxLoops: 3 })),
|
|
194
|
-
).rejects.toThrow("--min-loops cannot be greater than --max-loops")
|
|
195
|
-
|
|
196
|
-
restore()
|
|
197
|
-
})
|
|
198
|
-
})
|
|
199
|
-
|
|
200
|
-
describe("CLI selection", () => {
|
|
201
|
-
test("uses opencode by default when available", async () => {
|
|
202
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
203
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
204
|
-
|
|
205
|
-
let capturedArgs: string[] = []
|
|
206
|
-
const restore = mockCliTools({
|
|
207
|
-
onSpawn: (args) => {
|
|
208
|
-
capturedArgs = args
|
|
209
|
-
return {
|
|
210
|
-
exited: Promise.resolve(0),
|
|
211
|
-
exitCode: 0,
|
|
212
|
-
stdout: createEmptyStream(),
|
|
213
|
-
stderr: createEmptyStream(),
|
|
214
|
-
}
|
|
215
|
-
},
|
|
216
|
-
})
|
|
217
|
-
|
|
218
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1 }))
|
|
219
|
-
restore()
|
|
220
|
-
|
|
221
|
-
expect(capturedArgs[0]).toBe("opencode")
|
|
222
|
-
})
|
|
223
|
-
|
|
224
|
-
test("uses claude when --claude flag is set", async () => {
|
|
225
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
226
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
227
|
-
|
|
228
|
-
let capturedArgs: string[] = []
|
|
229
|
-
const restore = mockCliTools({
|
|
230
|
-
hasClaude: true,
|
|
231
|
-
onSpawn: (args) => {
|
|
232
|
-
capturedArgs = args
|
|
233
|
-
return {
|
|
234
|
-
exited: Promise.resolve(0),
|
|
235
|
-
exitCode: 0,
|
|
236
|
-
stdout: createEmptyStream(),
|
|
237
|
-
stderr: createEmptyStream(),
|
|
238
|
-
}
|
|
239
|
-
},
|
|
240
|
-
})
|
|
241
|
-
|
|
242
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1, claude: true }))
|
|
243
|
-
restore()
|
|
244
|
-
|
|
245
|
-
expect(capturedArgs[0]).toBe("claude")
|
|
246
|
-
})
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
describe("loop behavior", () => {
|
|
250
|
-
test("stops when all tasks are complete", async () => {
|
|
251
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
252
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
253
|
-
|
|
254
|
-
let spawnCount = 0
|
|
255
|
-
const restore = mockCliTools({
|
|
256
|
-
onSpawn: () => {
|
|
257
|
-
spawnCount++
|
|
258
|
-
return {
|
|
259
|
-
exited: Promise.resolve(0),
|
|
260
|
-
exitCode: 0,
|
|
261
|
-
stdout: createEmptyStream(),
|
|
262
|
-
stderr: createEmptyStream(),
|
|
263
|
-
}
|
|
264
|
-
},
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
await runTestEffect(loop({ silent: true }))
|
|
268
|
-
restore()
|
|
269
|
-
|
|
270
|
-
// Should run exactly once and then stop since task is complete
|
|
271
|
-
expect(spawnCount).toBe(1)
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
test("respects maxLoops option", async () => {
|
|
275
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
276
|
-
writeFileSync(taskPath, "# Test Task\n\n- [ ] Never done")
|
|
277
|
-
|
|
278
|
-
let spawnCount = 0
|
|
279
|
-
const restore = mockCliTools({
|
|
280
|
-
onSpawn: () => {
|
|
281
|
-
spawnCount++
|
|
282
|
-
return {
|
|
283
|
-
exited: Promise.resolve(0),
|
|
284
|
-
exitCode: 0,
|
|
285
|
-
stdout: createEmptyStream(),
|
|
286
|
-
stderr: createEmptyStream(),
|
|
287
|
-
}
|
|
288
|
-
},
|
|
289
|
-
})
|
|
290
|
-
|
|
291
|
-
await runTestEffect(loop({ silent: true, maxLoops: 3 }))
|
|
292
|
-
restore()
|
|
293
|
-
|
|
294
|
-
expect(spawnCount).toBe(3)
|
|
295
|
-
})
|
|
296
|
-
|
|
297
|
-
test("respects minLoops option even when tasks complete early", async () => {
|
|
298
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
299
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
300
|
-
|
|
301
|
-
let spawnCount = 0
|
|
302
|
-
const restore = mockCliTools({
|
|
303
|
-
onSpawn: () => {
|
|
304
|
-
spawnCount++
|
|
305
|
-
return {
|
|
306
|
-
exited: Promise.resolve(0),
|
|
307
|
-
exitCode: 0,
|
|
308
|
-
stdout: createEmptyStream(),
|
|
309
|
-
stderr: createEmptyStream(),
|
|
310
|
-
}
|
|
311
|
-
},
|
|
312
|
-
})
|
|
313
|
-
|
|
314
|
-
await runTestEffect(loop({ silent: true, minLoops: 3 }))
|
|
315
|
-
restore()
|
|
316
|
-
|
|
317
|
-
expect(spawnCount).toBe(3)
|
|
318
|
-
})
|
|
319
|
-
|
|
320
|
-
test("passes correct prompt to opencode", async () => {
|
|
321
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
322
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
323
|
-
|
|
324
|
-
let capturedArgs: string[] = []
|
|
325
|
-
const restore = mockCliTools({
|
|
326
|
-
onSpawn: (args) => {
|
|
327
|
-
capturedArgs = args
|
|
328
|
-
return {
|
|
329
|
-
exited: Promise.resolve(0),
|
|
330
|
-
exitCode: 0,
|
|
331
|
-
stdout: createEmptyStream(),
|
|
332
|
-
stderr: createEmptyStream(),
|
|
333
|
-
}
|
|
334
|
-
},
|
|
335
|
-
})
|
|
336
|
-
|
|
337
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1 }))
|
|
338
|
-
restore()
|
|
339
|
-
|
|
340
|
-
// opencode uses `opencode run "prompt"` for non-interactive mode
|
|
341
|
-
expect(capturedArgs).toEqual([
|
|
342
|
-
"opencode",
|
|
343
|
-
"run",
|
|
344
|
-
expect.stringContaining("Find the next logical task from TASK.md"),
|
|
345
|
-
])
|
|
346
|
-
})
|
|
347
|
-
|
|
348
|
-
test("passes correct prompt to claude", async () => {
|
|
349
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
350
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
351
|
-
|
|
352
|
-
let capturedArgs: string[] = []
|
|
353
|
-
const restore = mockCliTools({
|
|
354
|
-
hasClaude: true,
|
|
355
|
-
onSpawn: (args) => {
|
|
356
|
-
capturedArgs = args
|
|
357
|
-
return {
|
|
358
|
-
exited: Promise.resolve(0),
|
|
359
|
-
exitCode: 0,
|
|
360
|
-
stdout: createEmptyStream(),
|
|
361
|
-
stderr: createEmptyStream(),
|
|
362
|
-
}
|
|
363
|
-
},
|
|
364
|
-
})
|
|
365
|
-
|
|
366
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1, claude: true }))
|
|
367
|
-
restore()
|
|
368
|
-
|
|
369
|
-
// Claude doesn't use --prompt flag
|
|
370
|
-
expect(capturedArgs[0]).toBe("claude")
|
|
371
|
-
expect(capturedArgs[1]).toContain("Find the next logical task")
|
|
372
|
-
})
|
|
373
|
-
|
|
374
|
-
test("handles empty TASK.md as complete and respects minLoops", async () => {
|
|
375
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
376
|
-
writeFileSync(taskPath, "# Empty Tasks")
|
|
377
|
-
|
|
378
|
-
let spawnCount = 0
|
|
379
|
-
const restore = mockCliTools({
|
|
380
|
-
onSpawn: () => {
|
|
381
|
-
spawnCount++
|
|
382
|
-
return {
|
|
383
|
-
exited: Promise.resolve(0),
|
|
384
|
-
exitCode: 0,
|
|
385
|
-
stdout: createEmptyStream(),
|
|
386
|
-
stderr: createEmptyStream(),
|
|
387
|
-
}
|
|
388
|
-
},
|
|
389
|
-
})
|
|
390
|
-
|
|
391
|
-
await runTestEffect(loop({ silent: true, minLoops: 2 }))
|
|
392
|
-
restore()
|
|
393
|
-
|
|
394
|
-
expect(spawnCount).toBe(2)
|
|
395
|
-
})
|
|
396
|
-
})
|
|
397
|
-
|
|
398
|
-
describe("harness failure handling", () => {
|
|
399
|
-
test("retries once on harness failure", async () => {
|
|
400
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
401
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
402
|
-
|
|
403
|
-
let spawnCount = 0
|
|
404
|
-
const restore = mockCliTools({
|
|
405
|
-
onSpawn: () => {
|
|
406
|
-
spawnCount++
|
|
407
|
-
// First call fails, second succeeds
|
|
408
|
-
if (spawnCount === 1) {
|
|
409
|
-
return {
|
|
410
|
-
exited: Promise.resolve(1),
|
|
411
|
-
exitCode: 1,
|
|
412
|
-
stdout: createEmptyStream(),
|
|
413
|
-
stderr: createEmptyStream(),
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
return {
|
|
417
|
-
exited: Promise.resolve(0),
|
|
418
|
-
exitCode: 0,
|
|
419
|
-
stdout: createEmptyStream(),
|
|
420
|
-
stderr: createEmptyStream(),
|
|
421
|
-
}
|
|
422
|
-
},
|
|
423
|
-
})
|
|
424
|
-
|
|
425
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1 }))
|
|
426
|
-
restore()
|
|
427
|
-
|
|
428
|
-
// Should have called twice: first failed, retry succeeded
|
|
429
|
-
expect(spawnCount).toBe(2)
|
|
430
|
-
})
|
|
431
|
-
|
|
432
|
-
test("fails after two consecutive harness failures", async () => {
|
|
433
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
434
|
-
writeFileSync(taskPath, "# Test Task\n\n- [ ] Not done")
|
|
435
|
-
|
|
436
|
-
const restore = mockCliTools({
|
|
437
|
-
onSpawn: () => ({
|
|
438
|
-
exited: Promise.resolve(1),
|
|
439
|
-
exitCode: 1,
|
|
440
|
-
stdout: createEmptyStream(),
|
|
441
|
-
stderr: createEmptyStream(),
|
|
442
|
-
}),
|
|
443
|
-
})
|
|
444
|
-
|
|
445
|
-
await expect(
|
|
446
|
-
runTestEffect(loop({ silent: true, maxLoops: 1 })),
|
|
447
|
-
).rejects.toThrow("Harness failed twice on iteration 1")
|
|
448
|
-
|
|
449
|
-
restore()
|
|
450
|
-
})
|
|
451
|
-
})
|
|
452
|
-
|
|
453
|
-
describe("verbose output streaming", () => {
|
|
454
|
-
test("uses inherit for stdout/stderr when verbose is true", async () => {
|
|
455
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
456
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
457
|
-
|
|
458
|
-
let capturedOptions: any = null
|
|
459
|
-
const restore = mockCliTools({
|
|
460
|
-
onSpawn: (_args, options) => {
|
|
461
|
-
capturedOptions = options
|
|
462
|
-
return {
|
|
463
|
-
exited: Promise.resolve(0),
|
|
464
|
-
exitCode: 0,
|
|
465
|
-
stdout: createEmptyStream(),
|
|
466
|
-
stderr: createEmptyStream(),
|
|
467
|
-
}
|
|
468
|
-
},
|
|
469
|
-
})
|
|
470
|
-
|
|
471
|
-
await runTestEffect(loop({ silent: true, verbose: true, maxLoops: 1 }))
|
|
472
|
-
restore()
|
|
473
|
-
|
|
474
|
-
expect(capturedOptions.stdout).toBe("inherit")
|
|
475
|
-
expect(capturedOptions.stderr).toBe("inherit")
|
|
476
|
-
})
|
|
477
|
-
|
|
478
|
-
test("uses pipe for stdout/stderr when verbose is false", async () => {
|
|
479
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
480
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
481
|
-
|
|
482
|
-
let capturedOptions: any = null
|
|
483
|
-
const restore = mockCliTools({
|
|
484
|
-
onSpawn: (_args, options) => {
|
|
485
|
-
capturedOptions = options
|
|
486
|
-
return {
|
|
487
|
-
exited: Promise.resolve(0),
|
|
488
|
-
exitCode: 0,
|
|
489
|
-
stdout: createEmptyStream(),
|
|
490
|
-
stderr: createEmptyStream(),
|
|
491
|
-
}
|
|
492
|
-
},
|
|
493
|
-
})
|
|
494
|
-
|
|
495
|
-
await runTestEffect(loop({ silent: true, verbose: false, maxLoops: 1 }))
|
|
496
|
-
restore()
|
|
497
|
-
|
|
498
|
-
expect(capturedOptions.stdout).toBe("pipe")
|
|
499
|
-
expect(capturedOptions.stderr).toBe("pipe")
|
|
500
|
-
})
|
|
501
|
-
|
|
502
|
-
test("defaults to pipe (non-streaming) when verbose is not specified", async () => {
|
|
503
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
504
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
505
|
-
|
|
506
|
-
let capturedOptions: any = null
|
|
507
|
-
const restore = mockCliTools({
|
|
508
|
-
onSpawn: (_args, options) => {
|
|
509
|
-
capturedOptions = options
|
|
510
|
-
return {
|
|
511
|
-
exited: Promise.resolve(0),
|
|
512
|
-
exitCode: 0,
|
|
513
|
-
stdout: createEmptyStream(),
|
|
514
|
-
stderr: createEmptyStream(),
|
|
515
|
-
}
|
|
516
|
-
},
|
|
517
|
-
})
|
|
518
|
-
|
|
519
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1 }))
|
|
520
|
-
restore()
|
|
521
|
-
|
|
522
|
-
expect(capturedOptions.stdout).toBe("pipe")
|
|
523
|
-
expect(capturedOptions.stderr).toBe("pipe")
|
|
524
|
-
})
|
|
525
|
-
})
|
|
526
|
-
|
|
527
|
-
describe("extra args pass-through", () => {
|
|
528
|
-
test("passes extra args to opencode", async () => {
|
|
529
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
530
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
531
|
-
|
|
532
|
-
let capturedArgs: string[] = []
|
|
533
|
-
const restore = mockCliTools({
|
|
534
|
-
onSpawn: (args) => {
|
|
535
|
-
capturedArgs = args
|
|
536
|
-
return {
|
|
537
|
-
exited: Promise.resolve(0),
|
|
538
|
-
exitCode: 0,
|
|
539
|
-
stdout: createEmptyStream(),
|
|
540
|
-
stderr: createEmptyStream(),
|
|
541
|
-
}
|
|
542
|
-
},
|
|
543
|
-
})
|
|
544
|
-
|
|
545
|
-
await runTestEffect(
|
|
546
|
-
loop({
|
|
547
|
-
silent: true,
|
|
548
|
-
maxLoops: 1,
|
|
549
|
-
extraArgs: ["--agent", "deep"],
|
|
550
|
-
}),
|
|
551
|
-
)
|
|
552
|
-
restore()
|
|
553
|
-
|
|
554
|
-
expect(capturedArgs[0]).toBe("opencode")
|
|
555
|
-
expect(capturedArgs[1]).toBe("run")
|
|
556
|
-
expect(capturedArgs[2]).toContain("Find the next logical task")
|
|
557
|
-
expect(capturedArgs.slice(-2)).toEqual(["--agent", "deep"])
|
|
558
|
-
})
|
|
559
|
-
|
|
560
|
-
test("passes extra args to claude", async () => {
|
|
561
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
562
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
563
|
-
|
|
564
|
-
let capturedArgs: string[] = []
|
|
565
|
-
const restore = mockCliTools({
|
|
566
|
-
hasClaude: true,
|
|
567
|
-
onSpawn: (args) => {
|
|
568
|
-
capturedArgs = args
|
|
569
|
-
return {
|
|
570
|
-
exited: Promise.resolve(0),
|
|
571
|
-
exitCode: 0,
|
|
572
|
-
stdout: createEmptyStream(),
|
|
573
|
-
stderr: createEmptyStream(),
|
|
574
|
-
}
|
|
575
|
-
},
|
|
576
|
-
})
|
|
577
|
-
|
|
578
|
-
await runTestEffect(
|
|
579
|
-
loop({
|
|
580
|
-
silent: true,
|
|
581
|
-
maxLoops: 1,
|
|
582
|
-
claude: true,
|
|
583
|
-
extraArgs: ["--arbitrary", "switches"],
|
|
584
|
-
}),
|
|
585
|
-
)
|
|
586
|
-
restore()
|
|
587
|
-
|
|
588
|
-
expect(capturedArgs[0]).toBe("claude")
|
|
589
|
-
expect(capturedArgs[1]).toContain("Find the next logical task")
|
|
590
|
-
expect(capturedArgs.slice(-2)).toEqual(["--arbitrary", "switches"])
|
|
591
|
-
})
|
|
592
|
-
|
|
593
|
-
test("works without extra args", async () => {
|
|
594
|
-
const taskPath = join(tempDir, "TASK.md")
|
|
595
|
-
writeFileSync(taskPath, "# Test Task\n\n- [x] Already done")
|
|
596
|
-
|
|
597
|
-
let capturedArgs: string[] = []
|
|
598
|
-
const restore = mockCliTools({
|
|
599
|
-
onSpawn: (args) => {
|
|
600
|
-
capturedArgs = args
|
|
601
|
-
return {
|
|
602
|
-
exited: Promise.resolve(0),
|
|
603
|
-
exitCode: 0,
|
|
604
|
-
stdout: createEmptyStream(),
|
|
605
|
-
stderr: createEmptyStream(),
|
|
606
|
-
}
|
|
607
|
-
},
|
|
608
|
-
})
|
|
609
|
-
|
|
610
|
-
await runTestEffect(loop({ silent: true, maxLoops: 1 }))
|
|
611
|
-
restore()
|
|
612
|
-
|
|
613
|
-
// Should just have the base args without extra args
|
|
614
|
-
expect(capturedArgs).toEqual([
|
|
615
|
-
"opencode",
|
|
616
|
-
"run",
|
|
617
|
-
expect.stringContaining("Find the next logical task"),
|
|
618
|
-
])
|
|
619
|
-
})
|
|
620
|
-
})
|
|
621
|
-
})
|