@markjaquith/agency 1.11.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +172 -419
- package/src/commands/work.ts +47 -166
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -518
- package/src/commands/push.ts +0 -434
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1183
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
package/src/commands/loop.ts
DELETED
|
@@ -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
|
-
})
|