@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,1066 +0,0 @@
1
- import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
- import { join } from "path"
3
- import { symlink } from "fs/promises"
4
- import { task } from "../commands/task"
5
- import {
6
- createTempDir,
7
- cleanupTempDir,
8
- initGitRepo,
9
- initAgency,
10
- createSubdir,
11
- fileExists,
12
- readFile,
13
- runTestEffect,
14
- runGitCommand,
15
- getCurrentBranch,
16
- getGitOutput,
17
- addAndCommit,
18
- } from "../test-utils"
19
-
20
- describe("task command", () => {
21
- let tempDir: string
22
- let originalCwd: string
23
- let originalConfigDir: string | undefined
24
-
25
- beforeEach(async () => {
26
- tempDir = await createTempDir()
27
- originalCwd = process.cwd()
28
- originalConfigDir = process.env.AGENCY_CONFIG_DIR
29
- // Use a temp config dir to avoid interference from user's actual config
30
- process.env.AGENCY_CONFIG_DIR = await createTempDir()
31
- })
32
-
33
- afterEach(async () => {
34
- process.chdir(originalCwd)
35
- if (originalConfigDir !== undefined) {
36
- process.env.AGENCY_CONFIG_DIR = originalConfigDir
37
- } else {
38
- delete process.env.AGENCY_CONFIG_DIR
39
- }
40
- if (
41
- process.env.AGENCY_CONFIG_DIR &&
42
- process.env.AGENCY_CONFIG_DIR !== originalConfigDir
43
- ) {
44
- await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
45
- }
46
- await cleanupTempDir(tempDir)
47
- })
48
-
49
- describe("without path argument", () => {
50
- test("creates AGENTS.md at git root", async () => {
51
- await initGitRepo(tempDir)
52
- process.chdir(tempDir)
53
-
54
- await initAgency(tempDir, "test")
55
-
56
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
57
-
58
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
59
- })
60
-
61
- test("creates AGENTS.md with default content", async () => {
62
- await initGitRepo(tempDir)
63
- process.chdir(tempDir)
64
-
65
- await initAgency(tempDir, "test")
66
-
67
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
68
-
69
- const content = await readFile(join(tempDir, "AGENTS.md"))
70
- expect(content).toContain("Repo Instructions")
71
- expect(content).toContain("AGENCY.md")
72
- })
73
-
74
- test("creates file at git root even when run from subdirectory", async () => {
75
- await initGitRepo(tempDir)
76
- const subdir = await createSubdir(tempDir, "subdir")
77
- process.chdir(subdir)
78
-
79
- await initAgency(tempDir, "test")
80
-
81
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
82
-
83
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
84
- expect(await fileExists(join(subdir, "AGENTS.md"))).toBe(false)
85
- })
86
-
87
- test("does not overwrite existing AGENTS.md", async () => {
88
- await initGitRepo(tempDir)
89
- process.chdir(tempDir)
90
-
91
- const existingContent = "# Existing content"
92
- await Bun.write(join(tempDir, "AGENTS.md"), existingContent)
93
-
94
- await initAgency(tempDir, "test")
95
-
96
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
97
-
98
- const content = await readFile(join(tempDir, "AGENTS.md"))
99
- expect(content).toBe(existingContent)
100
- })
101
-
102
- test("throws error when not in a git repository", async () => {
103
- process.chdir(tempDir)
104
-
105
- await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
106
- "Not in a git repository",
107
- )
108
- })
109
- })
110
-
111
- describe("with path argument", () => {
112
- test("creates file at specified git root", async () => {
113
- await initGitRepo(tempDir)
114
-
115
- await initAgency(tempDir, "test")
116
- await runTestEffect(
117
- task({
118
- path: tempDir,
119
- silent: true,
120
- emit: "test-feature",
121
- }),
122
- )
123
-
124
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
125
- })
126
-
127
- test("throws error when path is not a git repository root", async () => {
128
- await initGitRepo(tempDir)
129
- const subdir = await createSubdir(tempDir, "subdir")
130
-
131
- expect(
132
- runTestEffect(task({ path: subdir, silent: true })),
133
- ).rejects.toThrow("not the root of a git repository")
134
- })
135
-
136
- test("throws error when path is not a git repository at all", async () => {
137
- expect(
138
- runTestEffect(task({ path: tempDir, silent: true })),
139
- ).rejects.toThrow("not the root of a git repository")
140
- })
141
-
142
- test("resolves relative paths correctly", async () => {
143
- await initGitRepo(tempDir)
144
- const subdir = await createSubdir(tempDir, "subdir")
145
- process.chdir(subdir)
146
-
147
- await initAgency(tempDir, "test")
148
- await runTestEffect(
149
- task({
150
- path: "..",
151
- silent: true,
152
- emit: "test-feature",
153
- }),
154
- )
155
-
156
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
157
- })
158
- })
159
-
160
- describe("opencode.json support", () => {
161
- test("creates opencode.json at git root", async () => {
162
- await initGitRepo(tempDir)
163
- process.chdir(tempDir)
164
-
165
- await initAgency(tempDir, "test")
166
-
167
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
168
-
169
- expect(await fileExists(join(tempDir, "opencode.json"))).toBe(true)
170
- })
171
-
172
- test("creates opencode.json with default content", async () => {
173
- await initGitRepo(tempDir)
174
- process.chdir(tempDir)
175
-
176
- await initAgency(tempDir, "test")
177
-
178
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
179
-
180
- const content = await readFile(join(tempDir, "opencode.json"))
181
- const parsed = JSON.parse(content)
182
-
183
- expect(parsed.$schema).toBe("https://opencode.ai/config.json")
184
- expect(parsed.instructions).toEqual(["AGENCY.md", "TASK.md"])
185
- })
186
-
187
- test("merges with existing opencode.json", async () => {
188
- await initGitRepo(tempDir)
189
- process.chdir(tempDir)
190
-
191
- const existingConfig = {
192
- custom: "config",
193
- }
194
- await Bun.write(
195
- join(tempDir, "opencode.json"),
196
- JSON.stringify(existingConfig),
197
- )
198
-
199
- await initAgency(tempDir, "test")
200
-
201
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
202
-
203
- const content = await readFile(join(tempDir, "opencode.json"))
204
- const parsed = JSON.parse(content)
205
-
206
- // Should preserve existing properties
207
- expect(parsed.custom).toBe("config")
208
-
209
- // Should add our instructions
210
- expect(parsed.instructions).toContain("AGENCY.md")
211
- expect(parsed.instructions).toContain("TASK.md")
212
- })
213
-
214
- test("uses opencode.json from template directory if it exists", async () => {
215
- await initGitRepo(tempDir)
216
- process.chdir(tempDir)
217
-
218
- const configDir = process.env.AGENCY_CONFIG_DIR!
219
- const templateDir = join(configDir, "templates", "custom-template")
220
- await Bun.spawn(["mkdir", "-p", templateDir], {
221
- stdout: "pipe",
222
- stderr: "pipe",
223
- }).exited
224
-
225
- const customConfig = JSON.stringify({
226
- $schema: "https://opencode.ai/config.json",
227
- instructions: ["CUSTOM.md"],
228
- })
229
- await Bun.write(join(templateDir, "opencode.json"), customConfig)
230
-
231
- await initAgency(tempDir, "custom-template")
232
-
233
- await runTestEffect(
234
- task({
235
- silent: true,
236
- emit: "test-feature",
237
- }),
238
- )
239
-
240
- const content = await readFile(join(tempDir, "opencode.json"))
241
- expect(content).toBe(customConfig)
242
- })
243
-
244
- test("merges with existing opencode.jsonc", async () => {
245
- await initGitRepo(tempDir)
246
- process.chdir(tempDir)
247
-
248
- const existingConfig = {
249
- custom: "config",
250
- existing: "property",
251
- }
252
- await Bun.write(
253
- join(tempDir, "opencode.jsonc"),
254
- JSON.stringify(existingConfig, null, 2),
255
- )
256
-
257
- await initAgency(tempDir, "test")
258
-
259
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
260
-
261
- // Should update opencode.jsonc, not create opencode.json
262
- expect(await fileExists(join(tempDir, "opencode.jsonc"))).toBe(true)
263
- expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
264
-
265
- const content = await readFile(join(tempDir, "opencode.jsonc"))
266
- const parsed = JSON.parse(content)
267
-
268
- // Should preserve existing properties
269
- expect(parsed.custom).toBe("config")
270
- expect(parsed.existing).toBe("property")
271
-
272
- // Should add our instructions
273
- expect(parsed.instructions).toContain("AGENCY.md")
274
- expect(parsed.instructions).toContain("TASK.md")
275
- })
276
-
277
- test("prefers opencode.jsonc over opencode.json when both exist", async () => {
278
- await initGitRepo(tempDir)
279
- process.chdir(tempDir)
280
-
281
- // Create both files
282
- const jsoncConfig = {
283
- custom: "jsonc-config",
284
- }
285
- const jsonConfig = {
286
- custom: "json-config",
287
- }
288
- await Bun.write(
289
- join(tempDir, "opencode.jsonc"),
290
- JSON.stringify(jsoncConfig, null, 2),
291
- )
292
- await Bun.write(
293
- join(tempDir, "opencode.json"),
294
- JSON.stringify(jsonConfig, null, 2),
295
- )
296
-
297
- await initAgency(tempDir, "test")
298
-
299
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
300
-
301
- // Should merge with opencode.jsonc (not opencode.json)
302
- const jsoncContent = await readFile(join(tempDir, "opencode.jsonc"))
303
- const jsoncParsed = JSON.parse(jsoncContent)
304
-
305
- expect(jsoncParsed.custom).toBe("jsonc-config")
306
- expect(jsoncParsed.instructions).toContain("AGENCY.md")
307
-
308
- // opencode.json should remain unchanged
309
- const jsonContent = await readFile(join(tempDir, "opencode.json"))
310
- expect(jsonContent).toBe(JSON.stringify(jsonConfig, null, 2))
311
- })
312
-
313
- test("handles opencode.jsonc with comments", async () => {
314
- await initGitRepo(tempDir)
315
- process.chdir(tempDir)
316
-
317
- const jsoncWithComments = `{
318
- // This is a comment
319
- "custom": "config",
320
- /* Multi-line
321
- comment */
322
- "existing": "property"
323
- }`
324
- await Bun.write(join(tempDir, "opencode.jsonc"), jsoncWithComments)
325
-
326
- await initAgency(tempDir, "test")
327
-
328
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
329
-
330
- const content = await readFile(join(tempDir, "opencode.jsonc"))
331
- const parsed = JSON.parse(content)
332
-
333
- // Should preserve existing properties
334
- expect(parsed.custom).toBe("config")
335
- expect(parsed.existing).toBe("property")
336
-
337
- // Should add our instructions
338
- expect(parsed.instructions).toContain("AGENCY.md")
339
- expect(parsed.instructions).toContain("TASK.md")
340
- })
341
-
342
- test("merges with .opencode/opencode.json", async () => {
343
- await initGitRepo(tempDir)
344
- process.chdir(tempDir)
345
-
346
- // Create .opencode directory with opencode.json
347
- const dotOpencodeDir = join(tempDir, ".opencode")
348
- await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
349
- stdout: "pipe",
350
- stderr: "pipe",
351
- }).exited
352
-
353
- const existingConfig = {
354
- custom: "config-in-dotdir",
355
- }
356
- await Bun.write(
357
- join(dotOpencodeDir, "opencode.json"),
358
- JSON.stringify(existingConfig, null, 2),
359
- )
360
-
361
- await initAgency(tempDir, "test")
362
-
363
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
364
-
365
- // Should update .opencode/opencode.json, not create root opencode.json
366
- expect(await fileExists(join(dotOpencodeDir, "opencode.json"))).toBe(true)
367
- expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
368
-
369
- const content = await readFile(join(dotOpencodeDir, "opencode.json"))
370
- const parsed = JSON.parse(content)
371
-
372
- // Should preserve existing properties
373
- expect(parsed.custom).toBe("config-in-dotdir")
374
-
375
- // Should add our instructions
376
- expect(parsed.instructions).toContain("AGENCY.md")
377
- expect(parsed.instructions).toContain("TASK.md")
378
- })
379
-
380
- test("merges with .opencode/opencode.jsonc", async () => {
381
- await initGitRepo(tempDir)
382
- process.chdir(tempDir)
383
-
384
- // Create .opencode directory with opencode.jsonc
385
- const dotOpencodeDir = join(tempDir, ".opencode")
386
- await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
387
- stdout: "pipe",
388
- stderr: "pipe",
389
- }).exited
390
-
391
- const existingConfig = {
392
- custom: "jsonc-in-dotdir",
393
- }
394
- await Bun.write(
395
- join(dotOpencodeDir, "opencode.jsonc"),
396
- JSON.stringify(existingConfig, null, 2),
397
- )
398
-
399
- await initAgency(tempDir, "test")
400
-
401
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
402
-
403
- // Should update .opencode/opencode.jsonc, not create root opencode.json
404
- expect(await fileExists(join(dotOpencodeDir, "opencode.jsonc"))).toBe(
405
- true,
406
- )
407
- expect(await fileExists(join(tempDir, "opencode.json"))).toBe(false)
408
-
409
- const content = await readFile(join(dotOpencodeDir, "opencode.jsonc"))
410
- const parsed = JSON.parse(content)
411
-
412
- // Should preserve existing properties
413
- expect(parsed.custom).toBe("jsonc-in-dotdir")
414
-
415
- // Should add our instructions
416
- expect(parsed.instructions).toContain("AGENCY.md")
417
- expect(parsed.instructions).toContain("TASK.md")
418
- })
419
-
420
- test("prefers .opencode/opencode.jsonc over root opencode.json", async () => {
421
- await initGitRepo(tempDir)
422
- process.chdir(tempDir)
423
-
424
- // Create .opencode directory with opencode.jsonc
425
- const dotOpencodeDir = join(tempDir, ".opencode")
426
- await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
427
- stdout: "pipe",
428
- stderr: "pipe",
429
- }).exited
430
-
431
- const dotOpencodeConfig = {
432
- custom: "dotdir-jsonc",
433
- }
434
- const rootJsonConfig = {
435
- custom: "root-json",
436
- }
437
-
438
- await Bun.write(
439
- join(dotOpencodeDir, "opencode.jsonc"),
440
- JSON.stringify(dotOpencodeConfig, null, 2),
441
- )
442
- await Bun.write(
443
- join(tempDir, "opencode.json"),
444
- JSON.stringify(rootJsonConfig, null, 2),
445
- )
446
-
447
- await initAgency(tempDir, "test")
448
-
449
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
450
-
451
- // Should merge with .opencode/opencode.jsonc (not root opencode.json)
452
- const dotDirContent = await readFile(
453
- join(dotOpencodeDir, "opencode.jsonc"),
454
- )
455
- const dotDirParsed = JSON.parse(dotDirContent)
456
-
457
- expect(dotDirParsed.custom).toBe("dotdir-jsonc")
458
- expect(dotDirParsed.instructions).toContain("AGENCY.md")
459
-
460
- // root opencode.json should remain unchanged
461
- const rootContent = await readFile(join(tempDir, "opencode.json"))
462
- expect(rootContent).toBe(JSON.stringify(rootJsonConfig, null, 2))
463
- })
464
-
465
- test("prefers .opencode/opencode.json over root opencode.jsonc", async () => {
466
- await initGitRepo(tempDir)
467
- process.chdir(tempDir)
468
-
469
- // Create .opencode directory with opencode.json
470
- const dotOpencodeDir = join(tempDir, ".opencode")
471
- await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
472
- stdout: "pipe",
473
- stderr: "pipe",
474
- }).exited
475
-
476
- const dotOpencodeConfig = {
477
- custom: "dotdir-json",
478
- }
479
- const rootJsoncConfig = {
480
- custom: "root-jsonc",
481
- }
482
-
483
- await Bun.write(
484
- join(dotOpencodeDir, "opencode.json"),
485
- JSON.stringify(dotOpencodeConfig, null, 2),
486
- )
487
- await Bun.write(
488
- join(tempDir, "opencode.jsonc"),
489
- JSON.stringify(rootJsoncConfig, null, 2),
490
- )
491
-
492
- await initAgency(tempDir, "test")
493
-
494
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
495
-
496
- // Should merge with .opencode/opencode.json (not root opencode.jsonc)
497
- const dotDirContent = await readFile(
498
- join(dotOpencodeDir, "opencode.json"),
499
- )
500
- const dotDirParsed = JSON.parse(dotDirContent)
501
-
502
- expect(dotDirParsed.custom).toBe("dotdir-json")
503
- expect(dotDirParsed.instructions).toContain("AGENCY.md")
504
-
505
- // root opencode.jsonc should remain unchanged
506
- const rootContent = await readFile(join(tempDir, "opencode.jsonc"))
507
- expect(rootContent).toBe(JSON.stringify(rootJsoncConfig, null, 2))
508
- })
509
-
510
- test("adds .opencode/opencode.json to injectedFiles", async () => {
511
- await initGitRepo(tempDir)
512
- process.chdir(tempDir)
513
-
514
- // Create .opencode directory with opencode.json
515
- const dotOpencodeDir = join(tempDir, ".opencode")
516
- await Bun.spawn(["mkdir", "-p", dotOpencodeDir], {
517
- stdout: "pipe",
518
- stderr: "pipe",
519
- }).exited
520
-
521
- await Bun.write(
522
- join(dotOpencodeDir, "opencode.json"),
523
- JSON.stringify({ custom: "config" }, null, 2),
524
- )
525
-
526
- await initAgency(tempDir, "test")
527
-
528
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
529
-
530
- // Check agency.json to verify .opencode/opencode.json is in injectedFiles
531
- const agencyJsonContent = await readFile(join(tempDir, "agency.json"))
532
- const metadata = JSON.parse(agencyJsonContent)
533
-
534
- expect(metadata.injectedFiles).toContain(".opencode/opencode.json")
535
- })
536
- })
537
-
538
- describe("TASK.md support", () => {
539
- test("creates TASK.md at git root", async () => {
540
- await initGitRepo(tempDir)
541
- process.chdir(tempDir)
542
-
543
- await initAgency(tempDir, "test")
544
-
545
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
546
-
547
- expect(await fileExists(join(tempDir, "TASK.md"))).toBe(true)
548
- })
549
-
550
- test("creates TASK.md with placeholder when no task provided", async () => {
551
- await initGitRepo(tempDir)
552
- process.chdir(tempDir)
553
-
554
- await initAgency(tempDir, "test")
555
-
556
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
557
-
558
- const content = await readFile(join(tempDir, "TASK.md"))
559
- expect(content).toContain("{task}")
560
- })
561
-
562
- test("creates TASK.md with task description when provided", async () => {
563
- await initGitRepo(tempDir)
564
- process.chdir(tempDir)
565
-
566
- await initAgency(tempDir, "test")
567
- await runTestEffect(
568
- task({
569
- silent: true,
570
- task: "Build new feature",
571
- emit: "test-feature",
572
- }),
573
- )
574
-
575
- const content = await readFile(join(tempDir, "TASK.md"))
576
- expect(content).toContain("Build new feature")
577
- expect(content).not.toContain("{task}")
578
- })
579
-
580
- test("skips TASK.md if it already exists", async () => {
581
- await initGitRepo(tempDir)
582
- process.chdir(tempDir)
583
-
584
- const existingContent = "# Existing TASK"
585
- await Bun.write(join(tempDir, "TASK.md"), existingContent)
586
-
587
- await initAgency(tempDir, "test")
588
-
589
- // Should succeed but skip TASK.md
590
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
591
-
592
- // TASK.md should not be overwritten
593
- const content = await readFile(join(tempDir, "TASK.md"))
594
- expect(content).toBe(existingContent)
595
-
596
- // Other files should still be created
597
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
598
- })
599
-
600
- test("uses TASK.md from template directory if it exists", async () => {
601
- await initGitRepo(tempDir)
602
- process.chdir(tempDir)
603
-
604
- const configDir = process.env.AGENCY_CONFIG_DIR!
605
- const templateDir = join(configDir, "templates", "custom-template")
606
- await Bun.spawn(["mkdir", "-p", templateDir], {
607
- stdout: "pipe",
608
- stderr: "pipe",
609
- }).exited
610
-
611
- const customTask = "# Custom Task Content"
612
- await Bun.write(join(templateDir, "TASK.md"), customTask)
613
-
614
- await initAgency(tempDir, "custom-template")
615
-
616
- await runTestEffect(
617
- task({
618
- silent: true,
619
- emit: "test-feature",
620
- }),
621
- )
622
-
623
- const content = await readFile(join(tempDir, "TASK.md"))
624
- expect(content).toBe(customTask)
625
- })
626
- })
627
-
628
- describe("silent mode", () => {
629
- test("silent flag suppresses output", async () => {
630
- await initGitRepo(tempDir)
631
- process.chdir(tempDir)
632
-
633
- // Capture console.log calls
634
- const logs: string[] = []
635
- const originalLog = console.log
636
- console.log = (...args: any[]) => logs.push(args.join(" "))
637
-
638
- await initAgency(tempDir, "test")
639
-
640
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
641
-
642
- console.log = originalLog
643
-
644
- expect(logs.length).toBe(0)
645
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
646
- })
647
-
648
- test("without silent flag produces output", async () => {
649
- await initGitRepo(tempDir)
650
- process.chdir(tempDir)
651
-
652
- // Create a feature branch to avoid the main branch check
653
- await Bun.spawn(["git", "checkout", "-b", "test-feature"], {
654
- cwd: tempDir,
655
- stdout: "pipe",
656
- stderr: "pipe",
657
- }).exited
658
-
659
- // Capture console.log calls
660
- const logs: string[] = []
661
- const originalLog = console.log
662
- console.log = (...args: any[]) => logs.push(args.join(" "))
663
-
664
- // Provide task to avoid interactive prompt
665
- await initAgency(tempDir, "test")
666
- await runTestEffect(
667
- task({
668
- silent: false,
669
- task: "Test task",
670
- emit: "test-feature",
671
- }),
672
- )
673
-
674
- console.log = originalLog
675
-
676
- expect(logs.length).toBeGreaterThan(0)
677
- expect(logs.some((log) => log.includes("Created"))).toBe(true)
678
- })
679
- })
680
-
681
- describe("branch handling", () => {
682
- test("fails when no branch name provided (silent mode)", async () => {
683
- await initGitRepo(tempDir)
684
- process.chdir(tempDir)
685
-
686
- await initAgency(tempDir, "test")
687
- await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
688
- "Branch name is required",
689
- )
690
- })
691
-
692
- test("creates branch when on main branch with branch name provided", async () => {
693
- await initGitRepo(tempDir)
694
- process.chdir(tempDir)
695
-
696
- await initAgency(tempDir, "test")
697
-
698
- await runTestEffect(task({ silent: true, emit: "my-feature" }))
699
-
700
- // Verify we're now on the new branch
701
- const proc = Bun.spawn(["git", "branch", "--show-current"], {
702
- cwd: tempDir,
703
- stdout: "pipe",
704
- stderr: "pipe",
705
- })
706
- await proc.exited
707
- const currentBranch = await new Response(proc.stdout).text()
708
- expect(currentBranch.trim()).toBe("agency--my-feature")
709
-
710
- // Verify files were created
711
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
712
- })
713
-
714
- test("fails early when branch already exists", async () => {
715
- await initGitRepo(tempDir)
716
- process.chdir(tempDir)
717
-
718
- // Create a feature branch first (using source pattern)
719
- await Bun.spawn(["git", "checkout", "-b", "agency--existing-branch"], {
720
- cwd: tempDir,
721
- stdout: "pipe",
722
- stderr: "pipe",
723
- }).exited
724
-
725
- // Switch back to main
726
- await Bun.spawn(["git", "checkout", "main"], {
727
- cwd: tempDir,
728
- stdout: "pipe",
729
- stderr: "pipe",
730
- }).exited
731
-
732
- // Try to create a branch with the same name
733
- await expect(
734
- (async () => {
735
- await initAgency(tempDir, "test")
736
- return await runTestEffect(
737
- task({
738
- silent: true,
739
- emit: "existing-branch",
740
- task: "This should not be asked for",
741
- }),
742
- )
743
- })(),
744
- ).rejects.toThrow("already exists")
745
- })
746
-
747
- test("succeeds when already on a feature branch", async () => {
748
- await initGitRepo(tempDir)
749
- process.chdir(tempDir)
750
-
751
- // Create and switch to feature branch
752
- await Bun.spawn(["git", "checkout", "-b", "feature-branch"], {
753
- cwd: tempDir,
754
- stdout: "pipe",
755
- stderr: "pipe",
756
- }).exited
757
-
758
- // Should succeed without needing branch option
759
- await initAgency(tempDir, "test")
760
-
761
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
762
-
763
- expect(await fileExists(join(tempDir, "AGENTS.md"))).toBe(true)
764
- })
765
-
766
- test("fails when on agency source branch without branch name in silent mode", async () => {
767
- await initGitRepo(tempDir)
768
- process.chdir(tempDir)
769
-
770
- await initAgency(tempDir, "test")
771
-
772
- // Create a task branch with agency files
773
- await runTestEffect(task({ silent: true, emit: "original-feature" }))
774
-
775
- // Verify we're on the source branch with agency.json
776
- const currentBranch = await getCurrentBranch(tempDir)
777
- expect(currentBranch).toBe("agency--original-feature")
778
- expect(await fileExists(join(tempDir, "agency.json"))).toBe(true)
779
-
780
- // Try to run agency task again without providing a branch name
781
- // This should fail because we're on an agency source branch
782
- await expect(runTestEffect(task({ silent: true }))).rejects.toThrow(
783
- "agency source branch",
784
- )
785
- })
786
-
787
- test("prompts for branch name when on agency source branch", async () => {
788
- await initGitRepo(tempDir)
789
- process.chdir(tempDir)
790
-
791
- await initAgency(tempDir, "test")
792
-
793
- // Create a task branch with agency files
794
- await runTestEffect(task({ silent: true, emit: "original-feature" }))
795
-
796
- // Verify we're on the source branch with agency.json
797
- const currentBranch = await getCurrentBranch(tempDir)
798
- expect(currentBranch).toBe("agency--original-feature")
799
- expect(await fileExists(join(tempDir, "agency.json"))).toBe(true)
800
-
801
- // With a branch name provided, it should work
802
- await runTestEffect(task({ silent: true, emit: "new-feature" }))
803
-
804
- // Verify we're now on the new branch
805
- const newBranch = await getCurrentBranch(tempDir)
806
- expect(newBranch).toBe("agency--new-feature")
807
- })
808
- })
809
-
810
- describe("template-based source files", () => {
811
- let configDir: string
812
- let originalEnv: string | undefined
813
-
814
- beforeEach(async () => {
815
- // Create a temporary config directory
816
- configDir = await createTempDir()
817
- originalEnv = process.env.AGENCY_CONFIG_DIR
818
- process.env.AGENCY_CONFIG_DIR = configDir
819
- })
820
-
821
- afterEach(async () => {
822
- // Restore original env
823
- if (originalEnv !== undefined) {
824
- process.env.AGENCY_CONFIG_DIR = originalEnv
825
- } else {
826
- delete process.env.AGENCY_CONFIG_DIR
827
- }
828
- await cleanupTempDir(configDir)
829
- })
830
-
831
- test("copies files in subdirectories from template", async () => {
832
- await initGitRepo(tempDir)
833
- process.chdir(tempDir)
834
-
835
- // Create template with files in subdirectories (including dotdirectories)
836
- const templateDir = join(configDir, "templates", "custom")
837
- await Bun.spawn(
838
- ["mkdir", "-p", join(templateDir, ".agents", "skills", "my-skill")],
839
- {
840
- stdout: "pipe",
841
- stderr: "pipe",
842
- },
843
- ).exited
844
- await Bun.spawn(["mkdir", "-p", join(templateDir, "docs")], {
845
- stdout: "pipe",
846
- stderr: "pipe",
847
- }).exited
848
-
849
- const skillContent = "# My Skill\n\nSkill instructions here"
850
- const docContent = "# Docs\n\nDocs content"
851
- await Bun.write(
852
- join(templateDir, ".agents", "skills", "my-skill", "SKILL.md"),
853
- skillContent,
854
- )
855
- await Bun.write(join(templateDir, "docs", "guide.md"), docContent)
856
-
857
- await initAgency(tempDir, "custom")
858
-
859
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
860
-
861
- // Files in dotdirectory should be created
862
- expect(
863
- await fileExists(
864
- join(tempDir, ".agents", "skills", "my-skill", "SKILL.md"),
865
- ),
866
- ).toBe(true)
867
- const skillFileContent = await readFile(
868
- join(tempDir, ".agents", "skills", "my-skill", "SKILL.md"),
869
- )
870
- expect(skillFileContent).toBe(skillContent)
871
-
872
- // Files in regular subdirectory should also be created
873
- expect(await fileExists(join(tempDir, "docs", "guide.md"))).toBe(true)
874
- const docFileContent = await readFile(join(tempDir, "docs", "guide.md"))
875
- expect(docFileContent).toBe(docContent)
876
- })
877
-
878
- test("uses AGENTS.md from template directory if it exists", async () => {
879
- await initGitRepo(tempDir)
880
- process.chdir(tempDir)
881
-
882
- // Create template with custom content
883
- const templateDir = join(configDir, "templates", "custom")
884
- await Bun.spawn(["mkdir", "-p", templateDir], {
885
- stdout: "pipe",
886
- stderr: "pipe",
887
- }).exited
888
-
889
- const sourceContent = "# Custom AGENTS.md content\nThis is from template"
890
- await Bun.write(join(templateDir, "AGENTS.md"), sourceContent)
891
-
892
- await initAgency(tempDir, "custom")
893
-
894
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
895
-
896
- const content = await readFile(join(tempDir, "AGENTS.md"))
897
- expect(content).toBe(sourceContent)
898
- })
899
-
900
- test("uses default content when template doesn't exist yet", async () => {
901
- await initGitRepo(tempDir)
902
- process.chdir(tempDir)
903
-
904
- await initAgency(tempDir, "custom-template")
905
-
906
- await runTestEffect(
907
- task({
908
- silent: true,
909
- emit: "test-feature",
910
- }),
911
- )
912
-
913
- const agentsContent = await readFile(join(tempDir, "AGENTS.md"))
914
-
915
- expect(agentsContent).toContain("Repo Instructions")
916
- })
917
-
918
- test("does not populate template directory automatically on first use", async () => {
919
- await initGitRepo(tempDir)
920
- process.chdir(tempDir)
921
-
922
- await initAgency(tempDir, "auto-created")
923
-
924
- await runTestEffect(
925
- task({
926
- silent: true,
927
- emit: "test-feature",
928
- }),
929
- )
930
-
931
- // Template should NOT have AGENTS.md (not populated automatically)
932
- const templateDir = join(configDir, "templates", "auto-created")
933
- const templateAgentsExists = await fileExists(
934
- join(templateDir, "AGENTS.md"),
935
- )
936
- expect(templateAgentsExists).toBe(false)
937
-
938
- // Files in repo should use default content (not from template)
939
- const agentsContent = await readFile(join(tempDir, "AGENTS.md"))
940
- expect(agentsContent).toContain("Repo Instructions")
941
- })
942
-
943
- test("excludes AGENCY.md and TASK.md from injectedFiles metadata", async () => {
944
- await initGitRepo(tempDir)
945
- process.chdir(tempDir)
946
-
947
- await initAgency(tempDir, "test")
948
-
949
- await runTestEffect(
950
- task({
951
- silent: true,
952
- emit: "test-feature",
953
- }),
954
- )
955
-
956
- // Read agency.json metadata
957
- const metadata = JSON.parse(await readFile(join(tempDir, "agency.json")))
958
-
959
- // AGENCY.md and TASK.md should NOT be in injectedFiles
960
- expect(metadata.injectedFiles).not.toContain("AGENCY.md")
961
- expect(metadata.injectedFiles).not.toContain("TASK.md")
962
-
963
- // opencode.json should be in injectedFiles
964
- expect(metadata.injectedFiles).toContain("opencode.json")
965
- })
966
-
967
- test("supports deprecated --branch flag for backward compatibility", async () => {
968
- await initGitRepo(tempDir)
969
- process.chdir(tempDir)
970
-
971
- await initAgency(tempDir, "test")
972
-
973
- // Use deprecated --branch flag instead of --emit
974
- await runTestEffect(
975
- task({
976
- silent: true,
977
- branch: "backward-compat-test",
978
- }),
979
- )
980
-
981
- // Should have created the branch with agency-- prefix
982
- const currentBranch = await getCurrentBranch(tempDir)
983
- expect(currentBranch).toBe("agency--backward-compat-test")
984
-
985
- // Should have correct emitBranch in agency.json
986
- const metadata = JSON.parse(await readFile(join(tempDir, "agency.json")))
987
- expect(metadata.emitBranch).toBe("backward-compat-test")
988
- })
989
- })
990
-
991
- describe("CLAUDE.md symlink handling", () => {
992
- test("commits CLAUDE.md modification when it is a symlink to another file", async () => {
993
- await initGitRepo(tempDir)
994
- process.chdir(tempDir)
995
-
996
- // Create a pre-existing AGENTS.md and CLAUDE.md as a symlink to it
997
- await Bun.write(
998
- join(tempDir, "AGENTS.md"),
999
- "# Agents\n\nOriginal content",
1000
- )
1001
- await symlink("AGENTS.md", join(tempDir, "CLAUDE.md"))
1002
- await addAndCommit(
1003
- tempDir,
1004
- ["AGENTS.md", "CLAUDE.md"],
1005
- "Add AGENTS.md and CLAUDE.md symlink",
1006
- )
1007
-
1008
- await initAgency(tempDir, "test")
1009
-
1010
- // Run task - this will modify CLAUDE.md (which is a symlink to AGENTS.md)
1011
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
1012
-
1013
- // Check that the commit was created and includes both files
1014
- const commitLog = await getGitOutput(tempDir, [
1015
- "log",
1016
- "--oneline",
1017
- "-n",
1018
- "2",
1019
- ])
1020
-
1021
- // Should have the CLAUDE.md edit commit
1022
- expect(commitLog).toContain("agency edit CLAUDE.md")
1023
-
1024
- // Verify that both AGENTS.md and CLAUDE.md were staged in the CLAUDE.md commit
1025
- // by checking the diff of the last commit
1026
- const lastCommitDiff = await getGitOutput(tempDir, [
1027
- "diff-tree",
1028
- "--no-commit-id",
1029
- "--name-only",
1030
- "-r",
1031
- "HEAD",
1032
- ])
1033
-
1034
- // The CLAUDE.md commit should include AGENTS.md (the symlink target)
1035
- expect(lastCommitDiff.trim().split("\n")).toContain("AGENTS.md")
1036
- })
1037
-
1038
- test("commits CLAUDE.md modification normally when not a symlink", async () => {
1039
- await initGitRepo(tempDir)
1040
- process.chdir(tempDir)
1041
-
1042
- // Create a pre-existing CLAUDE.md (not a symlink)
1043
- await Bun.write(
1044
- join(tempDir, "CLAUDE.md"),
1045
- "# Claude\n\nOriginal content",
1046
- )
1047
- await addAndCommit(tempDir, "CLAUDE.md", "Add CLAUDE.md")
1048
-
1049
- await initAgency(tempDir, "test")
1050
-
1051
- // Run task - this will modify CLAUDE.md
1052
- await runTestEffect(task({ silent: true, emit: "test-feature" }))
1053
-
1054
- // Check that the commit was created
1055
- const commitLog = await getGitOutput(tempDir, [
1056
- "log",
1057
- "--oneline",
1058
- "-n",
1059
- "2",
1060
- ])
1061
-
1062
- // Should have the CLAUDE.md edit commit
1063
- expect(commitLog).toContain("agency edit CLAUDE.md")
1064
- })
1065
- })
1066
- })