@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,406 +0,0 @@
1
- import { join } from "node:path"
2
- import { Effect } from "effect"
3
- import type { BaseCommandOptions } from "../utils/command"
4
- import { FileSystemService } from "../services/FileSystemService"
5
- import { GitService } from "../services/GitService"
6
- import { createLoggers, ensureGitRepo } from "../utils/effect"
7
- import { spawnProcess } from "../utils/process"
8
- import {
9
- parseTaskItems,
10
- countTasks,
11
- areAllTasksComplete,
12
- extractCompletionPromise,
13
- validateCompletion,
14
- } from "../utils/task-parser"
15
- import highlight, { done, info } from "../utils/colors"
16
-
17
- interface LoopOptions extends BaseCommandOptions {
18
- /**
19
- * Maximum number of iterations (undefined = unlimited)
20
- */
21
- maxLoops?: number
22
- /**
23
- * Minimum number of iterations to enforce
24
- */
25
- minLoops?: number
26
- /**
27
- * Force use of OpenCode CLI
28
- */
29
- opencode?: boolean
30
- /**
31
- * Force use of Claude Code CLI
32
- */
33
- claude?: boolean
34
- /**
35
- * Additional arguments to pass to the CLI tool
36
- */
37
- extraArgs?: string[]
38
- }
39
-
40
- interface LoopState {
41
- iteration: number
42
- tasksCompleted: number
43
- tasksIncomplete: number
44
- tasksTotalAtStart: number
45
- iterationStartTime: number
46
- harnessFailed: boolean
47
- }
48
-
49
- export const loop = (options: LoopOptions = {}) =>
50
- Effect.gen(function* () {
51
- const { log, verboseLog } = createLoggers(options)
52
- const fs = yield* FileSystemService
53
-
54
- const gitRoot = yield* ensureGitRepo()
55
-
56
- // Check if TASK.md exists
57
- const taskPath = join(gitRoot, "TASK.md")
58
- const taskExists = yield* fs.exists(taskPath)
59
- if (!taskExists) {
60
- return yield* Effect.fail(
61
- new Error("TASK.md not found. Run 'agency task' first to create it."),
62
- )
63
- }
64
-
65
- verboseLog(`Found TASK.md at: ${taskPath}`)
66
-
67
- // Change to git root before executing and restore afterwards
68
- // Do not change global cwd; pass gitRoot explicitly to subprocesses
69
-
70
- // Check for conflicting flags
71
- if (options.opencode && options.claude) {
72
- return yield* Effect.fail(
73
- new Error(
74
- "Cannot use both --opencode and --claude flags together. Choose one.",
75
- ),
76
- )
77
- }
78
-
79
- // Validate loop bounds
80
- if (
81
- typeof options.minLoops === "number" &&
82
- typeof options.maxLoops === "number" &&
83
- options.minLoops > options.maxLoops
84
- ) {
85
- return yield* Effect.fail(
86
- new Error("--min-loops cannot be greater than --max-loops."),
87
- )
88
- }
89
-
90
- // Check which CLI tool is available
91
- const hasOpencode = yield* Effect.tryPromise({
92
- try: async () => {
93
- const result = Bun.spawnSync(["which", "opencode"], {
94
- stdout: "ignore",
95
- stderr: "ignore",
96
- })
97
- return result.exitCode === 0
98
- },
99
- catch: () => false,
100
- })
101
-
102
- const hasClaude = yield* Effect.tryPromise({
103
- try: async () => {
104
- const result = Bun.spawnSync(["which", "claude"], {
105
- stdout: "ignore",
106
- stderr: "ignore",
107
- })
108
- return result.exitCode === 0
109
- },
110
- catch: () => false,
111
- })
112
-
113
- // Determine which CLI to use based on flags or auto-detection
114
- let useOpencode: boolean
115
- if (options.opencode) {
116
- if (!hasOpencode) {
117
- return yield* Effect.fail(
118
- new Error(
119
- "opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
120
- ),
121
- )
122
- }
123
- useOpencode = true
124
- verboseLog("Using opencode (explicitly requested)")
125
- } else if (options.claude) {
126
- if (!hasClaude) {
127
- return yield* Effect.fail(
128
- new Error(
129
- "claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
130
- ),
131
- )
132
- }
133
- useOpencode = false
134
- verboseLog("Using claude (explicitly requested)")
135
- } else {
136
- // Auto-detect
137
- if (!hasOpencode && !hasClaude) {
138
- return yield* Effect.fail(
139
- new Error(
140
- "Neither opencode nor claude CLI tool found. Please install OpenCode or Claude Code.",
141
- ),
142
- )
143
- }
144
- useOpencode = hasOpencode
145
- verboseLog(`Using ${useOpencode ? "opencode" : "claude"} (auto-detected)`)
146
- }
147
-
148
- const cliName = useOpencode ? "opencode" : "claude"
149
-
150
- // Parse initial task state
151
- let taskContent = yield* fs.readFile(taskPath)
152
- let taskItems = parseTaskItems(taskContent)
153
- let taskCounts = countTasks(taskItems)
154
- const tasksTotalAtStart = taskCounts.total
155
-
156
- // If there are no tasks at all, treat as complete but still respect minLoops
157
- const noTasks = tasksTotalAtStart === 0
158
-
159
- verboseLog(
160
- `Initial state: ${taskCounts.completed}/${taskCounts.total} tasks complete`,
161
- )
162
-
163
- const loopStartTime = Date.now()
164
- let loopState: LoopState = {
165
- iteration: 0,
166
- tasksCompleted: taskCounts.completed,
167
- tasksIncomplete: taskCounts.incomplete,
168
- tasksTotalAtStart,
169
- iterationStartTime: loopStartTime,
170
- harnessFailed: false,
171
- }
172
-
173
- // Main loop
174
- while (true) {
175
- loopState.iteration++
176
- loopState.iterationStartTime = Date.now()
177
- loopState.harnessFailed = false
178
-
179
- // Check if we've exceeded max loops
180
- if (options.maxLoops && loopState.iteration > options.maxLoops) {
181
- break
182
- }
183
-
184
- // Log iteration start
185
- const maxStr = options.maxLoops ? `/${options.maxLoops}` : ""
186
- const minStr = options.minLoops ? ` (min: ${options.minLoops})` : ""
187
- log(
188
- info(
189
- `Loop ${loopState.iteration}${maxStr}${minStr} - ${loopState.tasksCompleted}/${loopState.tasksTotalAtStart} tasks complete`,
190
- ),
191
- )
192
-
193
- // Prepare harness command
194
- const harnessPassed = yield* runHarness(
195
- cliName,
196
- useOpencode,
197
- gitRoot,
198
- options.verbose ?? false,
199
- verboseLog,
200
- options.extraArgs,
201
- ).pipe(
202
- Effect.map(() => true),
203
- Effect.catchAll(() => Effect.succeed(false)),
204
- )
205
-
206
- if (!harnessPassed) {
207
- verboseLog(
208
- `Harness failed on iteration ${loopState.iteration}, retrying...`,
209
- )
210
- loopState.harnessFailed = true
211
- // Retry once
212
- const harnessPassed2 = yield* runHarness(
213
- cliName,
214
- useOpencode,
215
- gitRoot,
216
- options.verbose ?? false,
217
- verboseLog,
218
- options.extraArgs,
219
- ).pipe(
220
- Effect.map(() => true),
221
- Effect.catchAll(() => Effect.succeed(false)),
222
- )
223
-
224
- if (!harnessPassed2) {
225
- return yield* Effect.fail(
226
- new Error(
227
- `Harness failed twice on iteration ${loopState.iteration}. Stopping loop.`,
228
- ),
229
- )
230
- }
231
- }
232
-
233
- // Re-read TASK.md and check status
234
- taskContent = yield* fs.readFile(taskPath)
235
- taskItems = parseTaskItems(taskContent)
236
- taskCounts = countTasks(taskItems)
237
-
238
- loopState.tasksCompleted = taskCounts.completed
239
- loopState.tasksIncomplete = taskCounts.incomplete
240
-
241
- const allComplete = noTasks ? true : areAllTasksComplete(taskItems)
242
- const completionPromise = extractCompletionPromise(taskContent)
243
-
244
- // Validate completion claim if agent says it's done
245
- if (completionPromise) {
246
- try {
247
- validateCompletion(completionPromise, allComplete)
248
- } catch (error) {
249
- return yield* Effect.fail(error)
250
- }
251
- }
252
-
253
- // Log iteration summary
254
- const iterationDuration = Date.now() - loopState.iterationStartTime
255
- const durationSecs = (iterationDuration / 1000).toFixed(1)
256
- log(
257
- done(
258
- `Loop ${loopState.iteration} complete (${durationSecs}s) - ${loopState.tasksCompleted}/${loopState.tasksTotalAtStart} tasks complete`,
259
- ),
260
- )
261
-
262
- // Check if we should exit
263
- if (
264
- allComplete &&
265
- (!options.minLoops || loopState.iteration >= options.minLoops)
266
- ) {
267
- // Ensure TASK.md does not contain the completion promise
268
- const fs = yield* FileSystemService
269
- const taskPath = `${gitRoot}/TASK.md`
270
- if (yield* fs.exists(taskPath)) {
271
- const contents = yield* fs.readFile(taskPath)
272
- const cleaned =
273
- contents.replace(/<promise>[^<]*<\/promise>/gi, "").trimEnd() + "\n"
274
- if (cleaned !== contents) {
275
- yield* fs.writeFile(taskPath, cleaned)
276
- // Commit cleanup so final TASK.md is clean
277
- const git = yield* GitService
278
- yield* git.gitAdd(["TASK.md"], gitRoot)
279
- yield* git.gitCommit(
280
- "fix(loop): remove completion promise from TASK.md",
281
- gitRoot,
282
- )
283
- }
284
- }
285
- log(done(`All tasks complete!`))
286
- break
287
- }
288
-
289
- // Check if we've hit max loops
290
- if (options.maxLoops && loopState.iteration >= options.maxLoops) {
291
- if (!allComplete) {
292
- log(
293
- info(
294
- `Max loops (${options.maxLoops}) reached but tasks remain. Stopping.`,
295
- ),
296
- )
297
- }
298
- break
299
- }
300
- }
301
-
302
- // Final summary
303
- const totalDuration = Date.now() - loopStartTime
304
- const totalDurationSecs = (totalDuration / 1000).toFixed(1)
305
- log(
306
- done(
307
- `Loop completed in ${loopState.iteration} iteration${loopState.iteration === 1 ? "" : "s"} (${totalDurationSecs}s)`,
308
- ),
309
- )
310
- log(
311
- done(
312
- `Final state: ${loopState.tasksCompleted}/${loopState.tasksTotalAtStart} tasks complete`,
313
- ),
314
- )
315
- })
316
-
317
- /**
318
- * Run the harness (opencode or claude run) for a single iteration
319
- */
320
- function runHarness(
321
- cliName: string,
322
- useOpencode: boolean,
323
- gitRoot: string,
324
- verbose: boolean,
325
- verboseLog: (msg: string) => void,
326
- extraArgs?: string[],
327
- ): Effect.Effect<void, Error> {
328
- return Effect.gen(function* () {
329
- const prompt =
330
- "Find the next logical task from TASK.md and work on it until complete. Update TASK.md with progress, and commit your changes. Once all tasks are done, output <promise>COMPLETE</promise>"
331
-
332
- // opencode: use `opencode run "prompt"` for non-interactive mode
333
- // claude: use `claude "prompt"` (positional argument)
334
- const baseArgs = useOpencode ? [cliName, "run", prompt] : [cliName, prompt]
335
-
336
- // Append extra args if provided
337
- const cliArgs =
338
- extraArgs && extraArgs.length > 0 ? [...baseArgs, ...extraArgs] : baseArgs
339
-
340
- // Log the command being run when verbose
341
- // Quote arguments that contain spaces for proper display
342
- const quotedArgs = cliArgs.map((arg) =>
343
- arg.includes(" ") ? `"${arg}"` : arg,
344
- )
345
- verboseLog(`Running: ${quotedArgs.join(" ")}`)
346
-
347
- // When verbose, stream output directly to the terminal
348
- // When not verbose, capture output silently
349
- // Always inherit stdin so the process doesn't hang waiting for input
350
- const result = yield* spawnProcess(cliArgs, {
351
- cwd: gitRoot,
352
- stdin: "inherit",
353
- stdout: verbose ? "inherit" : "pipe",
354
- stderr: verbose ? "inherit" : "pipe",
355
- }).pipe(
356
- Effect.catchAll((error) =>
357
- Effect.fail(new Error(`${cliName} exited with error: ${error.stderr}`)),
358
- ),
359
- )
360
-
361
- if (result.exitCode !== 0) {
362
- return yield* Effect.fail(
363
- new Error(`${cliName} exited with code ${result.exitCode}`),
364
- )
365
- }
366
- })
367
- }
368
-
369
- export const help = `
370
- Usage: agency loop [options] [-- extra-args...]
371
-
372
- Run a Ralph Wiggum loop that repeatedly invokes the harness (opencode or claude)
373
- to work on tasks defined in TASK.md until all tasks are complete.
374
-
375
- Each iteration:
376
- 1. Invokes the harness with a prompt to work on the next task
377
- 2. Checks TASK.md for task completion status
378
- 3. Validates completion claims against actual task status
379
- 4. Commits changes if any were made
380
- 5. Repeats until all tasks are done or max loops reached
381
-
382
- Options:
383
- --max-loops <num> Maximum number of iterations (default: unlimited)
384
- --min-loops <num> Minimum number of iterations (safety check)
385
- --opencode Force use of OpenCode CLI
386
- --claude Force use of Claude Code CLI
387
- -h, --help Show this help message
388
- -s, --silent Suppress output messages
389
- -v, --verbose Stream harness output to terminal
390
-
391
- Pass-through Arguments:
392
- Use -- to pass additional arguments to the underlying CLI tool.
393
- Everything after -- will be forwarded to opencode or claude.
394
-
395
- Examples:
396
- agency loop # Run until all tasks are done
397
- agency loop --max-loops 5 # Stop after 5 iterations
398
- agency loop -- --agent deep # Pass --agent deep to harness
399
-
400
- Notes:
401
- - Requires TASK.md to exist (run 'agency task' first)
402
- - Each iteration calls the harness which handles its own commit
403
- - The loop validates that completion claims match actual task status
404
- - Use --min-loops as a safeguard if you know the minimum work required
405
- - Arguments after -- are passed directly to the underlying tool
406
- `
@@ -1,195 +0,0 @@
1
- import { test, expect, describe, beforeEach, afterEach } from "bun:test"
2
- import { join } from "path"
3
- import { merge } from "./merge"
4
- import { emit } from "./emit"
5
- import { task } from "./task"
6
- import {
7
- createTempDir,
8
- cleanupTempDir,
9
- initGitRepo,
10
- initAgency,
11
- getGitOutput,
12
- getCurrentBranch,
13
- createCommit,
14
- branchExists,
15
- checkoutBranch,
16
- createBranch,
17
- addAndCommit,
18
- setupRemote,
19
- deleteBranch,
20
- runTestEffect,
21
- } from "../test-utils"
22
-
23
- // Cache the git-filter-repo availability check (it doesn't change during test run)
24
- let hasGitFilterRepoCache: boolean | null = null
25
- async function checkGitFilterRepo(): Promise<boolean> {
26
- if (hasGitFilterRepoCache === null) {
27
- const proc = Bun.spawn(["which", "git-filter-repo"], {
28
- stdout: "pipe",
29
- stderr: "pipe",
30
- })
31
- await proc.exited
32
- hasGitFilterRepoCache = proc.exitCode === 0
33
- }
34
- return hasGitFilterRepoCache
35
- }
36
-
37
- describe("merge command - integration tests (requires git-filter-repo)", () => {
38
- let tempDir: string
39
- let originalCwd: string
40
- let hasGitFilterRepo: boolean
41
-
42
- beforeEach(async () => {
43
- tempDir = await createTempDir()
44
- originalCwd = process.cwd()
45
- process.chdir(tempDir)
46
-
47
- // Set config path to non-existent file to use defaults
48
- process.env.AGENCY_CONFIG_PATH = join(tempDir, "non-existent-config.json")
49
- // Set config dir to temp dir to avoid picking up user's config files
50
- process.env.AGENCY_CONFIG_DIR = await createTempDir()
51
-
52
- // Check if git-filter-repo is available (cached)
53
- hasGitFilterRepo = await checkGitFilterRepo()
54
-
55
- // Initialize git repo with main branch (already includes initial commit)
56
- await initGitRepo(tempDir)
57
-
58
- // Set up origin for git-filter-repo
59
- await setupRemote(tempDir, "origin", tempDir)
60
-
61
- // Create a source branch (with agency-- prefix per new default config)
62
- await createBranch(tempDir, "agency--feature")
63
-
64
- // Initialize AGENTS.md on feature branch
65
- await initAgency(tempDir, "test")
66
-
67
- await runTestEffect(task({ silent: true, fromCurrent: true }))
68
-
69
- // Ensure agency.json has baseBranch set (task should auto-detect it, but ensure it's there)
70
- const agencyJsonPath = join(tempDir, "agency.json")
71
- const agencyJson = await Bun.file(agencyJsonPath).json()
72
- if (!agencyJson.baseBranch) {
73
- agencyJson.baseBranch = "origin/main"
74
- await Bun.write(
75
- agencyJsonPath,
76
- JSON.stringify(agencyJson, null, 2) + "\n",
77
- )
78
- }
79
-
80
- await addAndCommit(tempDir, "AGENTS.md agency.json", "Add AGENTS.md")
81
-
82
- // Create another commit on feature branch
83
- await createCommit(tempDir, "Feature work")
84
- })
85
-
86
- afterEach(async () => {
87
- process.chdir(originalCwd)
88
- delete process.env.AGENCY_CONFIG_PATH
89
- if (process.env.AGENCY_CONFIG_DIR) {
90
- await cleanupTempDir(process.env.AGENCY_CONFIG_DIR)
91
- delete process.env.AGENCY_CONFIG_DIR
92
- }
93
- await cleanupTempDir(tempDir)
94
- })
95
-
96
- describe("merge from source branch", () => {
97
- test("creates emit branch and merges when run from source branch", async () => {
98
- if (!hasGitFilterRepo) {
99
- console.log("Skipping test: git-filter-repo not installed")
100
- return
101
- }
102
-
103
- // We're on feature branch (source)
104
- const currentBranch = await getCurrentBranch(tempDir)
105
- expect(currentBranch).toBe("agency--feature")
106
-
107
- // Run merge - should create feature--PR and merge it to main
108
- await runTestEffect(merge({ silent: true }))
109
-
110
- // Should be on main branch after merge
111
- const afterMergeBranch = await getCurrentBranch(tempDir)
112
- expect(afterMergeBranch).toBe("main")
113
-
114
- // emit branch should exist
115
- const prExists = await branchExists(tempDir, "feature")
116
- expect(prExists).toBe(true)
117
-
118
- // Main should have the feature work but not AGENTS.md
119
- const files = await getGitOutput(tempDir, ["ls-files"])
120
- expect(files).not.toContain("AGENTS.md")
121
- })
122
-
123
- test("recreates emit branch if it already exists", async () => {
124
- if (!hasGitFilterRepo) {
125
- console.log("Skipping test: git-filter-repo not installed")
126
- return
127
- }
128
-
129
- // Create emit branch first
130
- await runTestEffect(emit({ silent: true }))
131
-
132
- // Go back to feature branch
133
- await checkoutBranch(tempDir, "agency--feature")
134
-
135
- // Make additional changes
136
- await createCommit(tempDir, "More feature work")
137
-
138
- // Run merge - should recreate emit branch with new changes
139
- await runTestEffect(merge({ silent: true }))
140
-
141
- // Should be on main after merge
142
- const currentBranch = await getCurrentBranch(tempDir)
143
- expect(currentBranch).toBe("main")
144
- })
145
- })
146
-
147
- describe("merge from emit branch", () => {
148
- test("merges emit branch directly when run from emit branch", async () => {
149
- if (!hasGitFilterRepo) {
150
- console.log("Skipping test: git-filter-repo not installed")
151
- return
152
- }
153
-
154
- // Create emit branch
155
- await runTestEffect(emit({ silent: true }))
156
-
157
- // emit() now stays on source branch, so we need to checkout to emit branch
158
- await checkoutBranch(tempDir, "feature")
159
-
160
- // We're on feature--PR now
161
- const currentBranch = await getCurrentBranch(tempDir)
162
- expect(currentBranch).toBe("feature")
163
-
164
- // Run merge - should merge feature--PR to main
165
- await runTestEffect(merge({ silent: true }))
166
-
167
- // Should be on main after merge
168
- const afterMergeBranch = await getCurrentBranch(tempDir)
169
- expect(afterMergeBranch).toBe("main")
170
-
171
- // Main should have the feature work but not AGENTS.md
172
- const files = await getGitOutput(tempDir, ["ls-files"])
173
- expect(files).not.toContain("AGENTS.md")
174
- })
175
-
176
- test("throws error if emit branch has no corresponding source branch", async () => {
177
- if (!hasGitFilterRepo) {
178
- console.log("Skipping test: git-filter-repo not installed")
179
- return
180
- }
181
-
182
- // Create emit branch
183
- await runTestEffect(emit({ silent: true }))
184
-
185
- // pr() now stays on source branch, so checkout to emit branch
186
- await checkoutBranch(tempDir, "feature")
187
-
188
- // Delete the source branch
189
- await deleteBranch(tempDir, "agency--feature", true)
190
-
191
- // Try to merge - should fail (error message may vary since source branch is deleted)
192
- await expect(runTestEffect(merge({ silent: true }))).rejects.toThrow()
193
- })
194
- })
195
- })