@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,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
- })