@markjaquith/agency 2.7.1 → 2.7.3
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 +10 -1
- package/cli.ts +10 -98
- package/package.json +1 -1
- package/skills/agency/SKILL.md +6 -1
- package/src/cli-parser.test.ts +203 -0
- package/src/cli-parser.ts +555 -0
- package/src/cli.test.ts +26 -2
- package/src/commands/task.test.ts +43 -0
- package/src/commands/task.ts +75 -38
- package/src/commands/validate.test.ts +28 -0
- package/src/commands/validate.ts +10 -1
- package/src/commands/work.test.ts +96 -17
- package/src/commands/work.ts +16 -3
- package/src/services/PullRequestService.test.ts +14 -0
- package/src/services/WorktreeService.test.ts +85 -9
- package/src/services/WorktreeService.ts +39 -24
- package/src/utils/command.ts +2 -0
- package/src/workbase/workbase-choice.ts +8 -0
package/src/commands/task.ts
CHANGED
|
@@ -81,14 +81,16 @@ export const task = (
|
|
|
81
81
|
const cwd = options.cwd ?? process.cwd()
|
|
82
82
|
|
|
83
83
|
switch (options.subcommand) {
|
|
84
|
-
case "new":
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
case "new": {
|
|
85
|
+
if (options.inputAllowed === false) {
|
|
86
|
+
return yield* Effect.fail(
|
|
87
|
+
new Error(
|
|
88
|
+
"task new requires interactive input; use 'agency task create <id> --repo <alias>' for automation",
|
|
89
|
+
),
|
|
90
|
+
)
|
|
91
|
+
}
|
|
87
92
|
const id =
|
|
88
|
-
options.args[0] ??
|
|
89
|
-
(interactive
|
|
90
|
-
? (yield* interaction.text("Task ID: ")).trim()
|
|
91
|
-
: undefined)
|
|
93
|
+
options.args[0] ?? (yield* interaction.text("Task ID: ")).trim()
|
|
92
94
|
if (!id) {
|
|
93
95
|
return yield* Effect.fail(new Error("Task ID is required"))
|
|
94
96
|
}
|
|
@@ -99,42 +101,39 @@ export const task = (
|
|
|
99
101
|
let multiPhase = options.multiPhase ?? false
|
|
100
102
|
let repo = options.repo
|
|
101
103
|
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
if (
|
|
114
|
-
const
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
none,
|
|
119
|
-
...epicRecords.map((record) => record.id),
|
|
120
|
-
])
|
|
121
|
-
if (selected === null) {
|
|
122
|
-
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
123
|
-
}
|
|
124
|
-
epic = selected === none ? undefined : selected
|
|
125
|
-
}
|
|
126
|
-
}
|
|
127
|
-
if (options.multiPhase === undefined) {
|
|
128
|
-
const selected = yield* interaction.select("Task type", [
|
|
129
|
-
"single-phase",
|
|
130
|
-
"multi-phase",
|
|
104
|
+
if (options.ticketUrl === undefined) {
|
|
105
|
+
ticketUrl =
|
|
106
|
+
(yield* interaction.text("Ticket URL (optional): ")).trim() || null
|
|
107
|
+
}
|
|
108
|
+
if (options.description === undefined) {
|
|
109
|
+
description =
|
|
110
|
+
(yield* interaction.text("Description (optional): ")).trim() ||
|
|
111
|
+
undefined
|
|
112
|
+
}
|
|
113
|
+
if (options.epic === undefined) {
|
|
114
|
+
const epicRecords = yield* epics.list(cwd)
|
|
115
|
+
if (epicRecords.length > 0) {
|
|
116
|
+
const none = "(none)"
|
|
117
|
+
const selected = yield* interaction.select("Parent epic", [
|
|
118
|
+
none,
|
|
119
|
+
...epicRecords.map((record) => record.id),
|
|
131
120
|
])
|
|
132
121
|
if (selected === null) {
|
|
133
122
|
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
134
123
|
}
|
|
135
|
-
|
|
124
|
+
epic = selected === none ? undefined : selected
|
|
136
125
|
}
|
|
137
126
|
}
|
|
127
|
+
if (options.multiPhase === undefined) {
|
|
128
|
+
const selected = yield* interaction.select("Task type", [
|
|
129
|
+
"single-phase",
|
|
130
|
+
"multi-phase",
|
|
131
|
+
])
|
|
132
|
+
if (selected === null) {
|
|
133
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
134
|
+
}
|
|
135
|
+
multiPhase = selected === "multi-phase"
|
|
136
|
+
}
|
|
138
137
|
|
|
139
138
|
if (!multiPhase && !repo) {
|
|
140
139
|
const records = yield* repositories.list(cwd)
|
|
@@ -177,6 +176,39 @@ export const task = (
|
|
|
177
176
|
)
|
|
178
177
|
return
|
|
179
178
|
}
|
|
179
|
+
case "create": {
|
|
180
|
+
const id = options.args[0]
|
|
181
|
+
if (!id) {
|
|
182
|
+
return yield* Effect.fail(new Error("Task ID is required"))
|
|
183
|
+
}
|
|
184
|
+
const multiPhase = options.multiPhase ?? false
|
|
185
|
+
if (!multiPhase && !options.repo) {
|
|
186
|
+
return yield* Effect.fail(
|
|
187
|
+
new Error("Writable repository is required for task create"),
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
const record = yield* tasks.create(
|
|
191
|
+
{
|
|
192
|
+
id,
|
|
193
|
+
ticketUrl: options.ticketUrl?.trim() || null,
|
|
194
|
+
description: options.description?.trim() || undefined,
|
|
195
|
+
epic: options.epic,
|
|
196
|
+
multiPhase,
|
|
197
|
+
repo: options.repo,
|
|
198
|
+
repos: parseRepositoryReferences(options.references),
|
|
199
|
+
branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
|
|
200
|
+
base: multiPhase ? undefined : (options.base ?? "main"),
|
|
201
|
+
},
|
|
202
|
+
cwd,
|
|
203
|
+
)
|
|
204
|
+
const { content: _, ...output } = record
|
|
205
|
+
log(
|
|
206
|
+
options.json
|
|
207
|
+
? JSON.stringify(output, null, 2)
|
|
208
|
+
: `Created task '${record.id}'`,
|
|
209
|
+
)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
180
212
|
case "list": {
|
|
181
213
|
const records = yield* tasks.list(cwd)
|
|
182
214
|
if (options.json) {
|
|
@@ -234,7 +266,7 @@ Usage: agency task <subcommand>
|
|
|
234
266
|
|
|
235
267
|
Subcommands:
|
|
236
268
|
new [id] Create a task with guided input
|
|
237
|
-
create <id> Create a task
|
|
269
|
+
create <id> Create a task without prompting
|
|
238
270
|
list List tasks
|
|
239
271
|
show <id> Show a task
|
|
240
272
|
status <id> <status> Set open, working, delegated, done, or dropped
|
|
@@ -250,6 +282,11 @@ Create options:
|
|
|
250
282
|
--base <name> Base branch (default: main)
|
|
251
283
|
--multi-phase Create a task container for phases
|
|
252
284
|
|
|
285
|
+
Task creation is noninteractive. Single-phase tasks require --repo; use
|
|
286
|
+
--multi-phase instead for a task container. Guided input is available only
|
|
287
|
+
through task new, which fails when --no-input is set or no TTY is available.
|
|
288
|
+
|
|
253
289
|
Options:
|
|
254
290
|
--json Output results as JSON
|
|
291
|
+
--no-input Never open interactive task creation
|
|
255
292
|
`
|
|
@@ -108,6 +108,34 @@ pr: null
|
|
|
108
108
|
expect(discovered).toEqual(["/outside", "/selected"])
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
test("does not select a registered workbase when input is disabled", async () => {
|
|
112
|
+
const workbase = {
|
|
113
|
+
discover: () =>
|
|
114
|
+
Effect.fail({
|
|
115
|
+
_tag: "WorkbaseNotFoundError" as const,
|
|
116
|
+
message: "No Agency workbase found from /outside",
|
|
117
|
+
}),
|
|
118
|
+
listRegistered: () => Effect.succeed(["/selected"]),
|
|
119
|
+
}
|
|
120
|
+
const fs = {
|
|
121
|
+
runCommand: () => Effect.fail(new Error("unexpected fzf probe")),
|
|
122
|
+
}
|
|
123
|
+
const pick: PickWorkbase = () =>
|
|
124
|
+
Effect.fail(new Error("unexpected workbase selection"))
|
|
125
|
+
|
|
126
|
+
await expect(
|
|
127
|
+
Effect.runPromise(
|
|
128
|
+
validate(
|
|
129
|
+
{ cwd: "/outside", silent: true, inputAllowed: false },
|
|
130
|
+
pick,
|
|
131
|
+
).pipe(
|
|
132
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
133
|
+
Effect.provideService(FileSystemService, fs as never),
|
|
134
|
+
) as Effect.Effect<void, unknown, never>,
|
|
135
|
+
),
|
|
136
|
+
).rejects.toThrow("provide an explicit path or run Agency from a workbase")
|
|
137
|
+
})
|
|
138
|
+
|
|
111
139
|
test("outputs the validation report as JSON", async () => {
|
|
112
140
|
await Bun.write(
|
|
113
141
|
join(root, "tasks/example/TASK.md"),
|
package/src/commands/validate.ts
CHANGED
|
@@ -27,7 +27,12 @@ export const validate = (
|
|
|
27
27
|
const startPath = options.path ?? options.cwd ?? process.cwd()
|
|
28
28
|
const root = options.path
|
|
29
29
|
? yield* workbase.discover(startPath)
|
|
30
|
-
: yield* resolveWorkbase(
|
|
30
|
+
: yield* resolveWorkbase(
|
|
31
|
+
startPath,
|
|
32
|
+
log,
|
|
33
|
+
pick,
|
|
34
|
+
options.inputAllowed ?? true,
|
|
35
|
+
)
|
|
31
36
|
if (!root) return
|
|
32
37
|
const report = yield* workbase.validate(root)
|
|
33
38
|
|
|
@@ -61,4 +66,8 @@ graphs. The current or selected registered workbase is used when path is omitted
|
|
|
61
66
|
|
|
62
67
|
Options:
|
|
63
68
|
--json Output the validation report as JSON
|
|
69
|
+
--no-input Never open an interactive workbase selector
|
|
70
|
+
|
|
71
|
+
When interactive input is unavailable, pass a path or run this command from a
|
|
72
|
+
workbase. Registered-workbase selection otherwise fails.
|
|
64
73
|
`
|
|
@@ -193,7 +193,12 @@ describe("work command", () => {
|
|
|
193
193
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
194
194
|
expect(harness.launches[0]).toEqual({
|
|
195
195
|
cli: "opencode",
|
|
196
|
-
args: [
|
|
196
|
+
args: [
|
|
197
|
+
"opencode",
|
|
198
|
+
"--continue",
|
|
199
|
+
"--prompt",
|
|
200
|
+
"Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
|
|
201
|
+
],
|
|
197
202
|
cwd: "/workbase/epics/delivery",
|
|
198
203
|
})
|
|
199
204
|
})
|
|
@@ -210,7 +215,7 @@ describe("work command", () => {
|
|
|
210
215
|
})
|
|
211
216
|
|
|
212
217
|
expect(harness.shownTasks).toEqual(["delivery"])
|
|
213
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
218
|
+
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
214
219
|
})
|
|
215
220
|
|
|
216
221
|
test("treats a positional value as a task ID when it is not a directory", async () => {
|
|
@@ -223,7 +228,7 @@ describe("work command", () => {
|
|
|
223
228
|
})
|
|
224
229
|
|
|
225
230
|
expect(harness.shownTasks).toEqual(["delivery"])
|
|
226
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
231
|
+
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
227
232
|
})
|
|
228
233
|
|
|
229
234
|
test("launches a multi-phase task agent without materializing", async () => {
|
|
@@ -238,7 +243,12 @@ describe("work command", () => {
|
|
|
238
243
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
239
244
|
expect(harness.launches[0]).toEqual({
|
|
240
245
|
cli: "opencode",
|
|
241
|
-
args: [
|
|
246
|
+
args: [
|
|
247
|
+
"opencode",
|
|
248
|
+
"--continue",
|
|
249
|
+
"--prompt",
|
|
250
|
+
"Work on the task. Read /workbase/tasks/delivery/TASK.md.",
|
|
251
|
+
],
|
|
242
252
|
cwd: "/workbase/tasks/delivery",
|
|
243
253
|
})
|
|
244
254
|
})
|
|
@@ -257,7 +267,16 @@ describe("work command", () => {
|
|
|
257
267
|
"probe:opencode",
|
|
258
268
|
"launch:opencode",
|
|
259
269
|
])
|
|
260
|
-
expect(harness.launches[0]
|
|
270
|
+
expect(harness.launches[0]).toEqual({
|
|
271
|
+
cli: "opencode",
|
|
272
|
+
args: [
|
|
273
|
+
"opencode",
|
|
274
|
+
"--continue",
|
|
275
|
+
"--prompt",
|
|
276
|
+
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
277
|
+
],
|
|
278
|
+
cwd: multiPhaseWorkspace.writablePath,
|
|
279
|
+
})
|
|
261
280
|
expect(harness.statusUpdates).toEqual([
|
|
262
281
|
"phase:example:implementation:working",
|
|
263
282
|
])
|
|
@@ -273,7 +292,7 @@ describe("work command", () => {
|
|
|
273
292
|
})
|
|
274
293
|
|
|
275
294
|
expect(harness.events[0]).toBe("materialize")
|
|
276
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
295
|
+
expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
|
|
277
296
|
})
|
|
278
297
|
|
|
279
298
|
test("selects a target with fzf when no directory is provided", async () => {
|
|
@@ -307,9 +326,37 @@ describe("work command", () => {
|
|
|
307
326
|
"probe:opencode",
|
|
308
327
|
"launch:opencode",
|
|
309
328
|
])
|
|
310
|
-
expect(harness.launches[0]?.cwd).toBe(
|
|
311
|
-
|
|
312
|
-
|
|
329
|
+
expect(harness.launches[0]?.cwd).toBe(multiPhaseWorkspace.writablePath)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test("requires an explicit target when input is disabled", async () => {
|
|
333
|
+
const harness = createHarness()
|
|
334
|
+
|
|
335
|
+
await expect(
|
|
336
|
+
harness.run({
|
|
337
|
+
cwd: "/workbase",
|
|
338
|
+
opencode: true,
|
|
339
|
+
inputAllowed: false,
|
|
340
|
+
}),
|
|
341
|
+
).rejects.toThrow("provide a directory, task ID, or --epic")
|
|
342
|
+
expect(harness.events).toEqual([])
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
test("runs an explicit target when input is disabled", async () => {
|
|
346
|
+
const harness = createHarness({ existingDirectories: [] })
|
|
347
|
+
|
|
348
|
+
await harness.run({
|
|
349
|
+
cwd: "/workbase",
|
|
350
|
+
directory: "example",
|
|
351
|
+
opencode: true,
|
|
352
|
+
inputAllowed: false,
|
|
353
|
+
})
|
|
354
|
+
|
|
355
|
+
expect(harness.events).toEqual([
|
|
356
|
+
"materialize",
|
|
357
|
+
"probe:opencode",
|
|
358
|
+
"launch:opencode",
|
|
359
|
+
])
|
|
313
360
|
})
|
|
314
361
|
|
|
315
362
|
test("selects a registered workbase when local discovery fails", async () => {
|
|
@@ -338,6 +385,24 @@ describe("work command", () => {
|
|
|
338
385
|
])
|
|
339
386
|
})
|
|
340
387
|
|
|
388
|
+
test("does not select a registered workbase when input is disabled", async () => {
|
|
389
|
+
const harness = createHarness({
|
|
390
|
+
outsideWorkbase: true,
|
|
391
|
+
registeredWorkbases: ["/workbase"],
|
|
392
|
+
existingDirectories: [],
|
|
393
|
+
})
|
|
394
|
+
|
|
395
|
+
await expect(
|
|
396
|
+
harness.run({
|
|
397
|
+
cwd: "/outside",
|
|
398
|
+
directory: "example",
|
|
399
|
+
opencode: true,
|
|
400
|
+
inputAllowed: false,
|
|
401
|
+
}),
|
|
402
|
+
).rejects.toThrow("provide an explicit path or run Agency from a workbase")
|
|
403
|
+
expect(harness.events).toEqual([])
|
|
404
|
+
})
|
|
405
|
+
|
|
341
406
|
test("explains how to register a workbase when none are known", async () => {
|
|
342
407
|
const harness = createHarness({ outsideWorkbase: true })
|
|
343
408
|
|
|
@@ -387,7 +452,7 @@ describe("work command", () => {
|
|
|
387
452
|
expect(harness.events).toEqual([])
|
|
388
453
|
})
|
|
389
454
|
|
|
390
|
-
test("launches OpenCode in the
|
|
455
|
+
test("launches OpenCode in the writable checkout with explicit context", async () => {
|
|
391
456
|
const harness = createHarness()
|
|
392
457
|
|
|
393
458
|
await harness.run({ taskId: "example", opencode: true })
|
|
@@ -400,8 +465,13 @@ describe("work command", () => {
|
|
|
400
465
|
expect(harness.launches).toEqual([
|
|
401
466
|
{
|
|
402
467
|
cli: "opencode",
|
|
403
|
-
args: [
|
|
404
|
-
|
|
468
|
+
args: [
|
|
469
|
+
"opencode",
|
|
470
|
+
"--continue",
|
|
471
|
+
"--prompt",
|
|
472
|
+
"Start the task. Read /workbase/tasks/example/TASK.md.",
|
|
473
|
+
],
|
|
474
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
405
475
|
},
|
|
406
476
|
])
|
|
407
477
|
expect(harness.statusUpdates).toEqual(["task:example:working"])
|
|
@@ -411,7 +481,7 @@ describe("work command", () => {
|
|
|
411
481
|
])
|
|
412
482
|
})
|
|
413
483
|
|
|
414
|
-
test("continues OpenCode
|
|
484
|
+
test("continues OpenCode with explicit task and phase context", async () => {
|
|
415
485
|
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
416
486
|
|
|
417
487
|
await harness.run({
|
|
@@ -420,7 +490,16 @@ describe("work command", () => {
|
|
|
420
490
|
opencode: true,
|
|
421
491
|
})
|
|
422
492
|
|
|
423
|
-
expect(harness.launches[0]
|
|
493
|
+
expect(harness.launches[0]).toEqual({
|
|
494
|
+
cli: "opencode",
|
|
495
|
+
args: [
|
|
496
|
+
"opencode",
|
|
497
|
+
"--continue",
|
|
498
|
+
"--prompt",
|
|
499
|
+
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
500
|
+
],
|
|
501
|
+
cwd: multiPhaseWorkspace.writablePath,
|
|
502
|
+
})
|
|
424
503
|
})
|
|
425
504
|
|
|
426
505
|
test("automatically falls back to Claude", async () => {
|
|
@@ -432,7 +511,7 @@ describe("work command", () => {
|
|
|
432
511
|
expect(harness.launches[0]).toEqual({
|
|
433
512
|
cli: "claude",
|
|
434
513
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
435
|
-
cwd:
|
|
514
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
436
515
|
})
|
|
437
516
|
})
|
|
438
517
|
|
|
@@ -456,7 +535,7 @@ describe("work command", () => {
|
|
|
456
535
|
expect(harness.launches[0]).toEqual({
|
|
457
536
|
cli: "claude",
|
|
458
537
|
args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
|
|
459
|
-
cwd:
|
|
538
|
+
cwd: singlePhaseWorkspace.writablePath,
|
|
460
539
|
})
|
|
461
540
|
})
|
|
462
541
|
|
|
@@ -493,7 +572,7 @@ describe("work command", () => {
|
|
|
493
572
|
verboseHarness.run({ taskId: "example", verbose: true }),
|
|
494
573
|
)
|
|
495
574
|
expect(verboseLogs).toEqual([
|
|
496
|
-
"Launching command: opencode --continue (cwd: /workbase/tasks/example)",
|
|
575
|
+
"Launching command: opencode --continue --prompt 'Start the task. Read /workbase/tasks/example/TASK.md.' (cwd: /workbase/tasks/example/code/agency)",
|
|
497
576
|
])
|
|
498
577
|
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
499
578
|
|
package/src/commands/work.ts
CHANGED
|
@@ -84,7 +84,8 @@ export const work = (
|
|
|
84
84
|
? yield* fs.isDirectory(directoryPath)
|
|
85
85
|
: false
|
|
86
86
|
const startPath = isDirectory && directoryPath ? directoryPath : cwd
|
|
87
|
-
const
|
|
87
|
+
const inputAllowed = options.inputAllowed ?? true
|
|
88
|
+
const root = yield* resolveWorkbase(startPath, log, pickBase, inputAllowed)
|
|
88
89
|
if (!root) return
|
|
89
90
|
|
|
90
91
|
let target: WorkTarget | null = null
|
|
@@ -148,6 +149,13 @@ export const work = (
|
|
|
148
149
|
}
|
|
149
150
|
|
|
150
151
|
if (!target) {
|
|
152
|
+
if (!inputAllowed) {
|
|
153
|
+
return yield* Effect.fail(
|
|
154
|
+
new Error(
|
|
155
|
+
"Work target selection requires interactive input; provide a directory, task ID, or --epic <id>",
|
|
156
|
+
),
|
|
157
|
+
)
|
|
158
|
+
}
|
|
151
159
|
const epicRecords = yield* epics.list(root)
|
|
152
160
|
const taskRecords = yield* tasks.list(root)
|
|
153
161
|
const phaseRecords = []
|
|
@@ -206,7 +214,7 @@ export const work = (
|
|
|
206
214
|
prompt = workspace.phasePath
|
|
207
215
|
? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
208
216
|
: `Start the task. Read ${workspace.taskPath}.`
|
|
209
|
-
launchPath =
|
|
217
|
+
launchPath = workspace.writablePath
|
|
210
218
|
}
|
|
211
219
|
|
|
212
220
|
const requested = options.claude ? "claude" : "opencode"
|
|
@@ -227,7 +235,8 @@ export const work = (
|
|
|
227
235
|
yield* tasks.setStatus(target.taskId, "working", root)
|
|
228
236
|
}
|
|
229
237
|
|
|
230
|
-
const args =
|
|
238
|
+
const args =
|
|
239
|
+
cli === "opencode" ? ["--continue", "--prompt", prompt] : [prompt]
|
|
231
240
|
verboseLog(
|
|
232
241
|
`Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
|
|
233
242
|
)
|
|
@@ -246,4 +255,8 @@ Options:
|
|
|
246
255
|
--epic <id> Work on an epic
|
|
247
256
|
--opencode Require OpenCode
|
|
248
257
|
--claude Require Claude Code
|
|
258
|
+
--no-input Never open an interactive selector
|
|
259
|
+
|
|
260
|
+
Without interactive input, provide a directory, task ID, or --epic and run the
|
|
261
|
+
command from a workbase. Workbase and target selection otherwise fail.
|
|
249
262
|
`
|
|
@@ -314,6 +314,20 @@ process.exit(${exitCode})
|
|
|
314
314
|
expect(await Bun.file(ghCallPath).exists()).toBe(false)
|
|
315
315
|
})
|
|
316
316
|
|
|
317
|
+
test("blocks PR creation when workbase validation fails", async () => {
|
|
318
|
+
await createTask()
|
|
319
|
+
await materialize()
|
|
320
|
+
await mkdir(join(root, "tasks", "missing-document"), { recursive: true })
|
|
321
|
+
await writeFakeGh({ stdout: "https://github.com/example/agency/pull/45" })
|
|
322
|
+
|
|
323
|
+
await expect(createPullRequest()).rejects.toThrow(
|
|
324
|
+
"Required document is missing",
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
await expectRemoteBranch("task/example", false)
|
|
328
|
+
expect(await Bun.file(ghCallPath).exists()).toBe(false)
|
|
329
|
+
})
|
|
330
|
+
|
|
317
331
|
test("reports push failure and does not invoke gh", async () => {
|
|
318
332
|
await createTask()
|
|
319
333
|
await materialize()
|
|
@@ -85,7 +85,7 @@ describe("WorktreeService", () => {
|
|
|
85
85
|
expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
-
test("does not fetch
|
|
88
|
+
test("does not fetch the origin for an existing writable worktree", async () => {
|
|
89
89
|
await runTestEffect(
|
|
90
90
|
TaskService.pipe(
|
|
91
91
|
Effect.flatMap((service) =>
|
|
@@ -94,7 +94,6 @@ describe("WorktreeService", () => {
|
|
|
94
94
|
id: "existing",
|
|
95
95
|
ticketUrl: "https://example.com/task",
|
|
96
96
|
repo: "agency",
|
|
97
|
-
repos: [{ repo: "effect", ref: "main" }],
|
|
98
97
|
branch: "task/existing",
|
|
99
98
|
base: "main",
|
|
100
99
|
},
|
|
@@ -114,6 +113,50 @@ describe("WorktreeService", () => {
|
|
|
114
113
|
["remote", "set-url", "origin", join(root, "missing")],
|
|
115
114
|
join(root, "repos/agency"),
|
|
116
115
|
)
|
|
116
|
+
await expect(
|
|
117
|
+
runTestEffect(
|
|
118
|
+
WorktreeService.pipe(
|
|
119
|
+
Effect.flatMap((service) =>
|
|
120
|
+
service.materialize("existing", undefined, root),
|
|
121
|
+
),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
).resolves.toMatchObject({ repo: "agency" })
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
test("reuses an immutable reference checkout without fetching", async () => {
|
|
128
|
+
const commit = new TextDecoder()
|
|
129
|
+
.decode(
|
|
130
|
+
Bun.spawnSync(
|
|
131
|
+
["git", "-C", join(root, "repos/effect"), "rev-parse", "main"],
|
|
132
|
+
{ stdout: "pipe" },
|
|
133
|
+
).stdout,
|
|
134
|
+
)
|
|
135
|
+
.trim()
|
|
136
|
+
await runTestEffect(
|
|
137
|
+
TaskService.pipe(
|
|
138
|
+
Effect.flatMap((service) =>
|
|
139
|
+
service.create(
|
|
140
|
+
{
|
|
141
|
+
id: "immutable-reference",
|
|
142
|
+
ticketUrl: "https://example.com/task",
|
|
143
|
+
repo: "agency",
|
|
144
|
+
repos: [{ repo: "effect", ref: commit }],
|
|
145
|
+
branch: "task/immutable-reference",
|
|
146
|
+
base: "main",
|
|
147
|
+
},
|
|
148
|
+
root,
|
|
149
|
+
),
|
|
150
|
+
),
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
await runTestEffect(
|
|
154
|
+
WorktreeService.pipe(
|
|
155
|
+
Effect.flatMap((service) =>
|
|
156
|
+
service.materialize("immutable-reference", undefined, root),
|
|
157
|
+
),
|
|
158
|
+
),
|
|
159
|
+
)
|
|
117
160
|
await git(
|
|
118
161
|
["remote", "set-url", "origin", join(root, "missing")],
|
|
119
162
|
join(root, "repos/effect"),
|
|
@@ -123,11 +166,44 @@ describe("WorktreeService", () => {
|
|
|
123
166
|
runTestEffect(
|
|
124
167
|
WorktreeService.pipe(
|
|
125
168
|
Effect.flatMap((service) =>
|
|
126
|
-
service.materialize("
|
|
169
|
+
service.materialize("immutable-reference", undefined, root),
|
|
127
170
|
),
|
|
128
171
|
),
|
|
129
172
|
),
|
|
130
|
-
).resolves.toMatchObject({ repo: "
|
|
173
|
+
).resolves.toMatchObject({ repos: [{ repo: "effect", ref: commit }] })
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
test("stops before materializing when workbase validation fails", async () => {
|
|
177
|
+
await runTestEffect(
|
|
178
|
+
TaskService.pipe(
|
|
179
|
+
Effect.flatMap((service) =>
|
|
180
|
+
service.create(
|
|
181
|
+
{
|
|
182
|
+
id: "valid-target",
|
|
183
|
+
ticketUrl: "https://example.com/task",
|
|
184
|
+
repo: "agency",
|
|
185
|
+
branch: "task/valid-target",
|
|
186
|
+
base: "main",
|
|
187
|
+
},
|
|
188
|
+
root,
|
|
189
|
+
),
|
|
190
|
+
),
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
await mkdir(join(root, "tasks", "missing-document"), { recursive: true })
|
|
194
|
+
|
|
195
|
+
await expect(
|
|
196
|
+
runTestEffect(
|
|
197
|
+
WorktreeService.pipe(
|
|
198
|
+
Effect.flatMap((service) =>
|
|
199
|
+
service.materialize("valid-target", undefined, root),
|
|
200
|
+
),
|
|
201
|
+
),
|
|
202
|
+
),
|
|
203
|
+
).rejects.toThrow("Required document is missing")
|
|
204
|
+
expect(
|
|
205
|
+
await Bun.file(join(root, "tasks", "valid-target", "code")).exists(),
|
|
206
|
+
).toBe(false)
|
|
131
207
|
})
|
|
132
208
|
|
|
133
209
|
test("uses a configured worktree creation command", async () => {
|
|
@@ -330,7 +406,7 @@ describe("WorktreeService", () => {
|
|
|
330
406
|
repo: "missing",
|
|
331
407
|
base: "main",
|
|
332
408
|
config: { version: 2 },
|
|
333
|
-
expected: "
|
|
409
|
+
expected: "Unknown repository alias 'missing'",
|
|
334
410
|
},
|
|
335
411
|
{
|
|
336
412
|
id: "bad-base",
|
|
@@ -392,6 +468,7 @@ pr: null
|
|
|
392
468
|
),
|
|
393
469
|
),
|
|
394
470
|
).rejects.toThrow(fixture.expected)
|
|
471
|
+
await rm(taskDirectory, { recursive: true, force: true })
|
|
395
472
|
}
|
|
396
473
|
})
|
|
397
474
|
|
|
@@ -561,7 +638,7 @@ pr: null
|
|
|
561
638
|
).rejects.toThrow("is not registered to branch 'task/expected'")
|
|
562
639
|
})
|
|
563
640
|
|
|
564
|
-
test("
|
|
641
|
+
test("fetches a moving ref before checking a reused reference checkout", async () => {
|
|
565
642
|
await runTestEffect(
|
|
566
643
|
TaskService.pipe(
|
|
567
644
|
Effect.flatMap((service) =>
|
|
@@ -570,7 +647,7 @@ pr: null
|
|
|
570
647
|
id: "pinned-reference",
|
|
571
648
|
ticketUrl: "https://example.com/task",
|
|
572
649
|
repo: "agency",
|
|
573
|
-
repos: [{ repo: "effect", ref: "main" }],
|
|
650
|
+
repos: [{ repo: "effect", ref: "origin/main" }],
|
|
574
651
|
branch: "task/pinned-reference",
|
|
575
652
|
base: "main",
|
|
576
653
|
},
|
|
@@ -590,7 +667,6 @@ pr: null
|
|
|
590
667
|
await Bun.write(join(source, "README.md"), "updated\n")
|
|
591
668
|
await git(["add", "README.md"], source)
|
|
592
669
|
await git(["-c", "commit.gpgsign=false", "commit", "-m", "update"], source)
|
|
593
|
-
await git(["push", join(root, "repos/effect"), "main"], source)
|
|
594
670
|
|
|
595
671
|
await expect(
|
|
596
672
|
runTestEffect(
|
|
@@ -600,7 +676,7 @@ pr: null
|
|
|
600
676
|
),
|
|
601
677
|
),
|
|
602
678
|
),
|
|
603
|
-
).rejects.toThrow("does not match reference 'main'")
|
|
679
|
+
).rejects.toThrow("does not match reference 'origin/main'")
|
|
604
680
|
})
|
|
605
681
|
|
|
606
682
|
test("rejects a reference checkout attached to a branch", async () => {
|