@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.
Files changed (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  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 -518
  73. package/src/commands/push.ts +0 -434
  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 -1183
  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,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
- })