@markjaquith/agency 2.0.0 → 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.
- package/README.md +11 -4
- package/cli.ts +2 -1
- package/package.json +1 -1
- package/skills/agency/SKILL.md +11 -3
- package/src/cli.test.ts +1 -1
- package/src/commands/work.test.ts +180 -6
- package/src/commands/work.ts +135 -16
- package/src/workbase/work-target.test.ts +77 -0
- package/src/workbase/work-target.ts +180 -0
package/README.md
CHANGED
|
@@ -11,6 +11,7 @@ or write.
|
|
|
11
11
|
- Git
|
|
12
12
|
- [GitHub CLI](https://cli.github.com/) for `agency pr create`
|
|
13
13
|
- OpenCode or Claude Code for `agency work`
|
|
14
|
+
- [fzf](https://github.com/junegunn/fzf) for interactive work target selection
|
|
14
15
|
|
|
15
16
|
## Installation
|
|
16
17
|
|
|
@@ -310,13 +311,19 @@ agency phase show <task-id> <phase-id> [--json]
|
|
|
310
311
|
### Work and Pull Requests
|
|
311
312
|
|
|
312
313
|
```text
|
|
313
|
-
agency work <task-id> [phase-id] [--opencode | --claude]
|
|
314
|
+
agency work [<task-id> [phase-id] | --epic <epic-id>] [--opencode | --claude]
|
|
314
315
|
agency pr create <task-id> [phase-id] [--draft] [--json]
|
|
315
316
|
```
|
|
316
317
|
|
|
317
|
-
`agency work`
|
|
318
|
-
|
|
319
|
-
|
|
318
|
+
`agency work` infers an epic, task, or phase from the current directory. From
|
|
319
|
+
elsewhere in the workbase, it presents the full hierarchy in `fzf`. If `fzf` is
|
|
320
|
+
not installed, Agency prints the hierarchy and asks for an explicit target.
|
|
321
|
+
|
|
322
|
+
Epic and multi-phase task targets launch orchestration agents beside their
|
|
323
|
+
documents. Single-phase tasks and phases fetch repositories, create or reuse
|
|
324
|
+
worktrees under `code/`, and launch an execution agent in the writable checkout
|
|
325
|
+
with absolute context paths. Explicit task, phase, and `--epic` targets override
|
|
326
|
+
current-directory inference.
|
|
320
327
|
|
|
321
328
|
Each writable `(repo, branch)` pair may belong to only one task or phase. Agency
|
|
322
329
|
validation reports duplicate ownership, and `agency work` checks Git's worktree
|
package/cli.ts
CHANGED
|
@@ -206,6 +206,7 @@ const commands: Record<string, Command> = {
|
|
|
206
206
|
work({
|
|
207
207
|
taskId: args[0],
|
|
208
208
|
phaseId: args[1],
|
|
209
|
+
epicId: options.epic,
|
|
209
210
|
silent: options.silent,
|
|
210
211
|
verbose: options.verbose,
|
|
211
212
|
opencode: options.opencode,
|
|
@@ -257,7 +258,7 @@ Commands:
|
|
|
257
258
|
epic <subcommand> Manage epics
|
|
258
259
|
phase <subcommand> Manage task phases
|
|
259
260
|
task <subcommand> Manage tasks
|
|
260
|
-
work
|
|
261
|
+
work [task] [phase] Work on an epic, task, or phase
|
|
261
262
|
pr create Create a pull request for an execution unit
|
|
262
263
|
repo <subcommand> Manage workbase repositories
|
|
263
264
|
status Show status for the current workbase
|
package/package.json
CHANGED
package/skills/agency/SKILL.md
CHANGED
|
@@ -7,7 +7,7 @@ description: >
|
|
|
7
7
|
coordinate work across repositories; materialize task worktrees; or create a
|
|
8
8
|
PR through Agency.
|
|
9
9
|
license: MIT
|
|
10
|
-
compatibility: Requires the agency CLI
|
|
10
|
+
compatibility: Requires the agency CLI and Git, plus OpenCode or Claude Code. Interactive work selection uses fzf; PR creation uses gh.
|
|
11
11
|
---
|
|
12
12
|
|
|
13
13
|
# Agency
|
|
@@ -210,12 +210,20 @@ duplicate writable branch ownership, unknown dependencies, and dependency cycles
|
|
|
210
210
|
## Worktrees And Agent Launch
|
|
211
211
|
|
|
212
212
|
```bash
|
|
213
|
+
agency work
|
|
213
214
|
agency work <task-id> [phase-id]
|
|
215
|
+
agency work --epic <epic-id>
|
|
214
216
|
```
|
|
215
217
|
|
|
216
218
|
Use `--opencode` or `--claude` to require a specific agent. This command fetches
|
|
217
|
-
repositories, creates or reuses
|
|
218
|
-
|
|
219
|
+
repositories for execution targets, creates or reuses their worktrees, and
|
|
220
|
+
replaces the current process with the selected agent. It infers the nearest
|
|
221
|
+
epic, task, or phase from the current directory; otherwise it opens an `fzf`
|
|
222
|
+
picker containing the workbase hierarchy.
|
|
223
|
+
|
|
224
|
+
Epic and multi-phase task targets are orchestration sessions launched beside
|
|
225
|
+
their documents. Single-phase tasks and phases are execution sessions launched
|
|
226
|
+
in their writable checkout.
|
|
219
227
|
|
|
220
228
|
The workbase may delegate writable checkout creation through
|
|
221
229
|
`worktreeCreateCommand` in `agency.json`. Do not bypass that command or create a
|
package/src/cli.test.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { FileSystemService } from "../services/FileSystemService"
|
|
4
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
5
|
+
import { EpicService } from "../services/EpicService"
|
|
6
|
+
import { TaskService } from "../services/TaskService"
|
|
7
|
+
import { PhaseService } from "../services/PhaseService"
|
|
4
8
|
import { WorktreeService } from "../services/WorktreeService"
|
|
5
9
|
import { captureLogs } from "../test-utils"
|
|
6
10
|
import { work } from "./work"
|
|
11
|
+
import type { PickWorkTarget } from "../workbase/work-target"
|
|
7
12
|
|
|
8
13
|
type ExecutionWorkspace = Effect.Effect.Success<
|
|
9
14
|
ReturnType<WorktreeService["materialize"]>
|
|
@@ -32,7 +37,11 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
|
|
|
32
37
|
interface HarnessOptions {
|
|
33
38
|
readonly workspace?: ExecutionWorkspace
|
|
34
39
|
readonly materializeError?: Error
|
|
35
|
-
readonly available?: Partial<Record<"opencode" | "claude", boolean>>
|
|
40
|
+
readonly available?: Partial<Record<"opencode" | "claude" | "fzf", boolean>>
|
|
41
|
+
readonly multiPhaseTasks?: readonly string[]
|
|
42
|
+
readonly epicRecords?: readonly any[]
|
|
43
|
+
readonly taskRecords?: readonly any[]
|
|
44
|
+
readonly phaseRecords?: readonly any[]
|
|
36
45
|
}
|
|
37
46
|
|
|
38
47
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -51,6 +60,39 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
51
60
|
: Effect.succeed(options.workspace ?? singlePhaseWorkspace)
|
|
52
61
|
},
|
|
53
62
|
}
|
|
63
|
+
const workbase = {
|
|
64
|
+
discover: () => Effect.succeed("/workbase"),
|
|
65
|
+
}
|
|
66
|
+
const epics = {
|
|
67
|
+
show: (id: string) =>
|
|
68
|
+
Effect.succeed({
|
|
69
|
+
id,
|
|
70
|
+
path: `/workbase/epics/${id}/EPIC.md`,
|
|
71
|
+
data: { tasks: [] },
|
|
72
|
+
}),
|
|
73
|
+
list: () => Effect.succeed(options.epicRecords ?? []),
|
|
74
|
+
}
|
|
75
|
+
const tasks = {
|
|
76
|
+
show: (id: string) =>
|
|
77
|
+
Effect.succeed({
|
|
78
|
+
id,
|
|
79
|
+
path: `/workbase/tasks/${id}/TASK.md`,
|
|
80
|
+
data: options.multiPhaseTasks?.includes(id)
|
|
81
|
+
? { phases: [] }
|
|
82
|
+
: { repo: "agency", branch: `task/${id}`, base: "main" },
|
|
83
|
+
}),
|
|
84
|
+
list: () => Effect.succeed(options.taskRecords ?? []),
|
|
85
|
+
}
|
|
86
|
+
const phases = {
|
|
87
|
+
show: (taskId: string, id: string) =>
|
|
88
|
+
Effect.succeed({
|
|
89
|
+
taskId,
|
|
90
|
+
id,
|
|
91
|
+
path: `/workbase/tasks/${taskId}/phases/${id}/PHASE.md`,
|
|
92
|
+
data: { repo: "agency", branch: `task/${id}`, base: "main" },
|
|
93
|
+
}),
|
|
94
|
+
list: () => Effect.succeed(options.phaseRecords ?? []),
|
|
95
|
+
}
|
|
54
96
|
const fs = {
|
|
55
97
|
runCommand: (args: readonly string[]) => {
|
|
56
98
|
const cli = args[1] as "opencode" | "claude"
|
|
@@ -67,11 +109,19 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
67
109
|
events.push(`launch:${cli}`)
|
|
68
110
|
launches.push({ cli, args, cwd })
|
|
69
111
|
}
|
|
70
|
-
const
|
|
112
|
+
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
113
|
+
const run = (
|
|
114
|
+
commandOptions: Parameters<typeof work>[0],
|
|
115
|
+
pick: PickWorkTarget = defaultPick,
|
|
116
|
+
) =>
|
|
71
117
|
Effect.runPromise(
|
|
72
|
-
work(commandOptions, launch).pipe(
|
|
118
|
+
work(commandOptions, launch, pick).pipe(
|
|
73
119
|
Effect.provideService(WorktreeService, worktrees as never),
|
|
74
120
|
Effect.provideService(FileSystemService, fs as never),
|
|
121
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
122
|
+
Effect.provideService(EpicService, epics as never),
|
|
123
|
+
Effect.provideService(TaskService, tasks as never),
|
|
124
|
+
Effect.provideService(PhaseService, phases as never),
|
|
75
125
|
) as Effect.Effect<void, unknown, never>,
|
|
76
126
|
)
|
|
77
127
|
|
|
@@ -79,11 +129,126 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
79
129
|
}
|
|
80
130
|
|
|
81
131
|
describe("work command", () => {
|
|
82
|
-
test("
|
|
132
|
+
test("launches an epic agent from an epic directory", async () => {
|
|
83
133
|
const harness = createHarness()
|
|
84
134
|
|
|
85
|
-
await
|
|
86
|
-
|
|
135
|
+
await harness.run({ cwd: "/workbase/epics/delivery", opencode: true })
|
|
136
|
+
|
|
137
|
+
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
138
|
+
expect(harness.launches[0]).toEqual({
|
|
139
|
+
cli: "opencode",
|
|
140
|
+
args: [
|
|
141
|
+
"opencode",
|
|
142
|
+
"--prompt",
|
|
143
|
+
"Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
|
|
144
|
+
],
|
|
145
|
+
cwd: "/workbase/epics/delivery",
|
|
146
|
+
})
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
test("launches a multi-phase task agent without materializing", async () => {
|
|
150
|
+
const harness = createHarness({ multiPhaseTasks: ["delivery"] })
|
|
151
|
+
|
|
152
|
+
await harness.run({ cwd: "/workbase/tasks/delivery", opencode: true })
|
|
153
|
+
|
|
154
|
+
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
155
|
+
expect(harness.launches[0]).toEqual({
|
|
156
|
+
cli: "opencode",
|
|
157
|
+
args: [
|
|
158
|
+
"opencode",
|
|
159
|
+
"--prompt",
|
|
160
|
+
"Work on the task. Read /workbase/tasks/delivery/TASK.md.",
|
|
161
|
+
],
|
|
162
|
+
cwd: "/workbase/tasks/delivery",
|
|
163
|
+
})
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
test("infers a phase from a nested checkout directory", async () => {
|
|
167
|
+
const harness = createHarness({ workspace: multiPhaseWorkspace })
|
|
168
|
+
|
|
169
|
+
await harness.run({
|
|
170
|
+
cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
|
|
171
|
+
opencode: true,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
expect(harness.events).toEqual([
|
|
175
|
+
"materialize",
|
|
176
|
+
"probe:opencode",
|
|
177
|
+
"launch:opencode",
|
|
178
|
+
])
|
|
179
|
+
expect(harness.launches[0]?.args).toContain(
|
|
180
|
+
"Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
|
|
181
|
+
)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("infers a single-phase task from a nested checkout directory", async () => {
|
|
185
|
+
const harness = createHarness()
|
|
186
|
+
|
|
187
|
+
await harness.run({
|
|
188
|
+
cwd: "/workbase/tasks/example/code/agency/src",
|
|
189
|
+
opencode: true,
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
expect(harness.events[0]).toBe("materialize")
|
|
193
|
+
expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example/code/agency")
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
test("selects a target with fzf outside an entity directory", async () => {
|
|
197
|
+
const phase = {
|
|
198
|
+
taskId: "delivery",
|
|
199
|
+
id: "build",
|
|
200
|
+
path: "/workbase/tasks/delivery/phases/build/PHASE.md",
|
|
201
|
+
data: {},
|
|
202
|
+
}
|
|
203
|
+
const harness = createHarness({
|
|
204
|
+
workspace: multiPhaseWorkspace,
|
|
205
|
+
taskRecords: [
|
|
206
|
+
{
|
|
207
|
+
id: "delivery",
|
|
208
|
+
path: "/workbase/tasks/delivery/TASK.md",
|
|
209
|
+
data: { phases: [{ id: "build" }] },
|
|
210
|
+
},
|
|
211
|
+
],
|
|
212
|
+
phaseRecords: [phase],
|
|
213
|
+
})
|
|
214
|
+
const pick: PickWorkTarget = (choices) =>
|
|
215
|
+
Effect.succeed(
|
|
216
|
+
choices.find((choice) => choice.label.includes("build"))!.target,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
await harness.run({ cwd: "/workbase", opencode: true }, pick)
|
|
220
|
+
|
|
221
|
+
expect(harness.events).toEqual([
|
|
222
|
+
"probe:fzf",
|
|
223
|
+
"materialize",
|
|
224
|
+
"probe:opencode",
|
|
225
|
+
"launch:opencode",
|
|
226
|
+
])
|
|
227
|
+
expect(harness.launches[0]?.cwd).toBe(
|
|
228
|
+
"/workbase/tasks/example/phases/implementation/code/agency",
|
|
229
|
+
)
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
test("prints the target tree when fzf is unavailable", async () => {
|
|
233
|
+
const harness = createHarness({
|
|
234
|
+
available: { fzf: false },
|
|
235
|
+
epicRecords: [
|
|
236
|
+
{
|
|
237
|
+
id: "delivery",
|
|
238
|
+
path: "/workbase/epics/delivery/EPIC.md",
|
|
239
|
+
data: { tasks: [] },
|
|
240
|
+
},
|
|
241
|
+
],
|
|
242
|
+
})
|
|
243
|
+
|
|
244
|
+
const logs = await captureLogs(async () => {
|
|
245
|
+
await expect(harness.run({ cwd: "/workbase" })).rejects.toThrow(
|
|
246
|
+
"fzf is required",
|
|
247
|
+
)
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
expect(logs).toEqual(["epic delivery"])
|
|
251
|
+
expect(harness.launches).toEqual([])
|
|
87
252
|
})
|
|
88
253
|
|
|
89
254
|
test("rejects conflicting agent flags before materialization", async () => {
|
|
@@ -95,6 +260,15 @@ describe("work command", () => {
|
|
|
95
260
|
expect(harness.events).toEqual([])
|
|
96
261
|
})
|
|
97
262
|
|
|
263
|
+
test("rejects combining explicit epic and task targets", async () => {
|
|
264
|
+
const harness = createHarness()
|
|
265
|
+
|
|
266
|
+
await expect(
|
|
267
|
+
harness.run({ epicId: "delivery", taskId: "example" }),
|
|
268
|
+
).rejects.toThrow("Cannot combine --epic")
|
|
269
|
+
expect(harness.events).toEqual([])
|
|
270
|
+
})
|
|
271
|
+
|
|
98
272
|
test("launches OpenCode in the writable checkout with the single-phase prompt", async () => {
|
|
99
273
|
const harness = createHarness()
|
|
100
274
|
|
package/src/commands/work.ts
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
|
|
2
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
4
|
import { WorktreeService } from "../services/WorktreeService"
|
|
4
5
|
import { FileSystemService } from "../services/FileSystemService"
|
|
6
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
7
|
+
import { EpicService } from "../services/EpicService"
|
|
8
|
+
import { TaskService } from "../services/TaskService"
|
|
9
|
+
import { PhaseService } from "../services/PhaseService"
|
|
5
10
|
import { createLoggers } from "../utils/effect"
|
|
6
11
|
import { execvp } from "../utils/exec"
|
|
12
|
+
import {
|
|
13
|
+
buildWorkTargetChoices,
|
|
14
|
+
pickWorkTarget,
|
|
15
|
+
type PickWorkTarget,
|
|
16
|
+
type WorkTarget,
|
|
17
|
+
} from "../workbase/work-target"
|
|
7
18
|
|
|
8
19
|
interface WorkOptions extends BaseCommandOptions {
|
|
9
20
|
readonly taskId?: string
|
|
10
21
|
readonly phaseId?: string
|
|
22
|
+
readonly epicId?: string
|
|
11
23
|
readonly opencode?: boolean
|
|
12
24
|
readonly claude?: boolean
|
|
13
25
|
}
|
|
@@ -22,28 +34,133 @@ const launchAgent: LaunchAgent = (cli, args, cwd) => {
|
|
|
22
34
|
export const work = (
|
|
23
35
|
options: WorkOptions = {},
|
|
24
36
|
launch: LaunchAgent = launchAgent,
|
|
37
|
+
pick: PickWorkTarget = pickWorkTarget,
|
|
25
38
|
) =>
|
|
26
39
|
Effect.gen(function* () {
|
|
27
|
-
if (!options.taskId) {
|
|
28
|
-
return yield* Effect.fail(new Error("Task ID is required"))
|
|
29
|
-
}
|
|
30
40
|
if (options.opencode && options.claude) {
|
|
31
41
|
return yield* Effect.fail(
|
|
32
42
|
new Error("Cannot use both --opencode and --claude"),
|
|
33
43
|
)
|
|
34
44
|
}
|
|
45
|
+
if (options.epicId && (options.taskId || options.phaseId)) {
|
|
46
|
+
return yield* Effect.fail(
|
|
47
|
+
new Error("Cannot combine --epic with a task or phase ID"),
|
|
48
|
+
)
|
|
49
|
+
}
|
|
35
50
|
|
|
36
51
|
const worktrees = yield* WorktreeService
|
|
37
52
|
const fs = yield* FileSystemService
|
|
38
|
-
const
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
const workbase = yield* WorkbaseService
|
|
54
|
+
const epics = yield* EpicService
|
|
55
|
+
const tasks = yield* TaskService
|
|
56
|
+
const phases = yield* PhaseService
|
|
57
|
+
const { log, verboseLog } = createLoggers(options)
|
|
58
|
+
const cwd = options.cwd ?? process.cwd()
|
|
59
|
+
const root = yield* workbase.discover(cwd)
|
|
60
|
+
|
|
61
|
+
let target: WorkTarget | null = null
|
|
62
|
+
if (options.epicId) {
|
|
63
|
+
const epic = yield* epics.show(options.epicId, root)
|
|
64
|
+
target = { kind: "epic", epicId: epic.id, path: epic.path }
|
|
65
|
+
} else if (options.taskId) {
|
|
66
|
+
const task = yield* tasks.show(options.taskId, root)
|
|
67
|
+
if (options.phaseId) {
|
|
68
|
+
const phase = yield* phases.show(task.id, options.phaseId, root)
|
|
69
|
+
target = {
|
|
70
|
+
kind: "phase",
|
|
71
|
+
taskId: task.id,
|
|
72
|
+
phaseId: phase.id,
|
|
73
|
+
path: phase.path,
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
target = {
|
|
77
|
+
kind: "task",
|
|
78
|
+
taskId: task.id,
|
|
79
|
+
path: task.path,
|
|
80
|
+
multiPhase: "phases" in task.data,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
const path = relative(root, resolve(cwd))
|
|
85
|
+
const parts =
|
|
86
|
+
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
87
|
+
? []
|
|
88
|
+
: path.split(sep)
|
|
89
|
+
if (parts[0] === "epics" && parts[1]) {
|
|
90
|
+
const epic = yield* epics.show(parts[1], root)
|
|
91
|
+
target = { kind: "epic", epicId: epic.id, path: epic.path }
|
|
92
|
+
} else if (parts[0] === "tasks" && parts[1]) {
|
|
93
|
+
const task = yield* tasks.show(parts[1], root)
|
|
94
|
+
if (parts[2] === "phases" && parts[3]) {
|
|
95
|
+
const phase = yield* phases.show(task.id, parts[3], root)
|
|
96
|
+
target = {
|
|
97
|
+
kind: "phase",
|
|
98
|
+
taskId: task.id,
|
|
99
|
+
phaseId: phase.id,
|
|
100
|
+
path: phase.path,
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
target = {
|
|
104
|
+
kind: "task",
|
|
105
|
+
taskId: task.id,
|
|
106
|
+
path: task.path,
|
|
107
|
+
multiPhase: "phases" in task.data,
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (!target) {
|
|
114
|
+
const epicRecords = yield* epics.list(root)
|
|
115
|
+
const taskRecords = yield* tasks.list(root)
|
|
116
|
+
const phaseRecords = []
|
|
117
|
+
for (const task of taskRecords) {
|
|
118
|
+
if ("phases" in task.data) {
|
|
119
|
+
phaseRecords.push(...(yield* phases.list(task.id, root)))
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
const choices = buildWorkTargetChoices(
|
|
123
|
+
epicRecords,
|
|
124
|
+
taskRecords,
|
|
125
|
+
phaseRecords,
|
|
126
|
+
)
|
|
127
|
+
if (choices.length === 0) {
|
|
128
|
+
return yield* Effect.fail(
|
|
129
|
+
new Error("No epics, tasks, or phases found in this workbase"),
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
const fzf = yield* fs.runCommand(["which", "fzf"], {
|
|
133
|
+
captureOutput: true,
|
|
134
|
+
})
|
|
135
|
+
if (fzf.exitCode !== 0) {
|
|
136
|
+
for (const choice of choices) log(choice.label)
|
|
137
|
+
return yield* Effect.fail(
|
|
138
|
+
new Error(
|
|
139
|
+
"fzf is required to select a work target; install fzf or provide a target explicitly",
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
target = yield* pick(choices)
|
|
144
|
+
if (!target) return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
let prompt: string
|
|
148
|
+
let launchPath: string
|
|
149
|
+
if (target.kind === "epic") {
|
|
150
|
+
prompt = `Work on the epic. Read ${target.path}.`
|
|
151
|
+
launchPath = dirname(target.path)
|
|
152
|
+
} else if (target.kind === "task" && target.multiPhase) {
|
|
153
|
+
prompt = `Work on the task. Read ${target.path}.`
|
|
154
|
+
launchPath = dirname(target.path)
|
|
155
|
+
} else {
|
|
156
|
+
const taskId = target.taskId
|
|
157
|
+
const phaseId = target.kind === "phase" ? target.phaseId : undefined
|
|
158
|
+
const workspace = yield* worktrees.materialize(taskId, phaseId, root)
|
|
159
|
+
prompt = workspace.phasePath
|
|
160
|
+
? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
161
|
+
: `Start the task. Read ${workspace.taskPath}.`
|
|
162
|
+
launchPath = workspace.writablePath
|
|
163
|
+
}
|
|
47
164
|
|
|
48
165
|
const requested = options.claude ? "claude" : "opencode"
|
|
49
166
|
let cli = requested
|
|
@@ -58,17 +175,19 @@ export const work = (
|
|
|
58
175
|
return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
|
|
59
176
|
}
|
|
60
177
|
|
|
61
|
-
verboseLog(`Launching ${cli} in ${
|
|
178
|
+
verboseLog(`Launching ${cli} in ${launchPath}`)
|
|
62
179
|
const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
|
|
63
|
-
launch(cli, [cli, ...args],
|
|
180
|
+
launch(cli, [cli, ...args], launchPath)
|
|
64
181
|
})
|
|
65
182
|
|
|
66
183
|
export const help = `
|
|
67
|
-
Usage: agency work <task-id> [phase-id]
|
|
184
|
+
Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
|
|
68
185
|
|
|
69
|
-
|
|
186
|
+
Launch an agent for the current epic, task, or phase. Outside an entity
|
|
187
|
+
directory, select one with fzf.
|
|
70
188
|
|
|
71
189
|
Options:
|
|
190
|
+
--epic <id> Work on an epic
|
|
72
191
|
--opencode Require OpenCode
|
|
73
192
|
--claude Require Claude Code
|
|
74
193
|
`
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { buildWorkTargetChoices } from "./work-target"
|
|
3
|
+
|
|
4
|
+
describe("work target choices", () => {
|
|
5
|
+
test("builds an ordered hierarchy with standalone tasks", () => {
|
|
6
|
+
const choices = buildWorkTargetChoices(
|
|
7
|
+
[
|
|
8
|
+
{
|
|
9
|
+
id: "delivery",
|
|
10
|
+
path: "/workbase/epics/delivery/EPIC.md",
|
|
11
|
+
data: {
|
|
12
|
+
description: "Ship the release",
|
|
13
|
+
tasks: [{ id: "multi" }, { id: "single" }],
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
{
|
|
19
|
+
id: "multi",
|
|
20
|
+
path: "/workbase/tasks/multi/TASK.md",
|
|
21
|
+
data: {
|
|
22
|
+
phases: [{ id: "build" }, { id: "verify" }],
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: "single",
|
|
27
|
+
path: "/workbase/tasks/single/TASK.md",
|
|
28
|
+
data: {},
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
id: "standalone",
|
|
32
|
+
path: "/workbase/tasks/standalone/TASK.md",
|
|
33
|
+
data: { description: "Independent work" },
|
|
34
|
+
},
|
|
35
|
+
],
|
|
36
|
+
[
|
|
37
|
+
{
|
|
38
|
+
taskId: "multi",
|
|
39
|
+
id: "verify",
|
|
40
|
+
path: "/workbase/tasks/multi/phases/verify/PHASE.md",
|
|
41
|
+
data: {},
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
taskId: "multi",
|
|
45
|
+
id: "build",
|
|
46
|
+
path: "/workbase/tasks/multi/phases/build/PHASE.md",
|
|
47
|
+
data: {},
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
taskId: "multi",
|
|
51
|
+
id: "unlisted",
|
|
52
|
+
path: "/workbase/tasks/multi/phases/unlisted/PHASE.md",
|
|
53
|
+
data: {},
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
expect(choices.map((choice) => choice.label)).toEqual([
|
|
59
|
+
"epic delivery - Ship the release",
|
|
60
|
+
" task multi",
|
|
61
|
+
" phase build",
|
|
62
|
+
" phase verify",
|
|
63
|
+
" phase unlisted",
|
|
64
|
+
" task single",
|
|
65
|
+
"task standalone - Independent work",
|
|
66
|
+
])
|
|
67
|
+
expect(choices.map((choice) => choice.target.kind)).toEqual([
|
|
68
|
+
"epic",
|
|
69
|
+
"task",
|
|
70
|
+
"phase",
|
|
71
|
+
"phase",
|
|
72
|
+
"phase",
|
|
73
|
+
"task",
|
|
74
|
+
"task",
|
|
75
|
+
])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
|
|
3
|
+
export type WorkTarget =
|
|
4
|
+
| {
|
|
5
|
+
readonly kind: "epic"
|
|
6
|
+
readonly epicId: string
|
|
7
|
+
readonly path: string
|
|
8
|
+
}
|
|
9
|
+
| {
|
|
10
|
+
readonly kind: "task"
|
|
11
|
+
readonly taskId: string
|
|
12
|
+
readonly path: string
|
|
13
|
+
readonly multiPhase: boolean
|
|
14
|
+
}
|
|
15
|
+
| {
|
|
16
|
+
readonly kind: "phase"
|
|
17
|
+
readonly taskId: string
|
|
18
|
+
readonly phaseId: string
|
|
19
|
+
readonly path: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface EpicRecord {
|
|
23
|
+
readonly id: string
|
|
24
|
+
readonly path: string
|
|
25
|
+
readonly data: {
|
|
26
|
+
readonly description?: string
|
|
27
|
+
readonly tasks: readonly { readonly id: string }[]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface TaskRecord {
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly path: string
|
|
34
|
+
readonly data:
|
|
35
|
+
| {
|
|
36
|
+
readonly description?: string
|
|
37
|
+
readonly phases: readonly { readonly id: string }[]
|
|
38
|
+
}
|
|
39
|
+
| { readonly description?: string }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface PhaseRecord {
|
|
43
|
+
readonly taskId: string
|
|
44
|
+
readonly id: string
|
|
45
|
+
readonly path: string
|
|
46
|
+
readonly data: { readonly description?: string }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface WorkTargetChoice {
|
|
50
|
+
readonly label: string
|
|
51
|
+
readonly target: WorkTarget
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const label = (
|
|
55
|
+
indent: string,
|
|
56
|
+
kind: WorkTarget["kind"],
|
|
57
|
+
id: string,
|
|
58
|
+
description?: string,
|
|
59
|
+
) =>
|
|
60
|
+
`${indent}${kind} ${id}${description === undefined ? "" : ` - ${description}`}`
|
|
61
|
+
|
|
62
|
+
const taskChoices = (
|
|
63
|
+
task: TaskRecord,
|
|
64
|
+
phaseRecords: readonly PhaseRecord[],
|
|
65
|
+
indent: string,
|
|
66
|
+
): readonly WorkTargetChoice[] => {
|
|
67
|
+
const multiPhase = "phases" in task.data
|
|
68
|
+
const choices: WorkTargetChoice[] = [
|
|
69
|
+
{
|
|
70
|
+
label: label(indent, "task", task.id, task.data.description),
|
|
71
|
+
target: {
|
|
72
|
+
kind: "task",
|
|
73
|
+
taskId: task.id,
|
|
74
|
+
path: task.path,
|
|
75
|
+
multiPhase,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
if (!multiPhase) return choices
|
|
80
|
+
|
|
81
|
+
const phases = new Map(phaseRecords.map((phase) => [phase.id, phase]))
|
|
82
|
+
const renderedPhases = new Set<string>()
|
|
83
|
+
for (const phase of task.data.phases) {
|
|
84
|
+
const record = phases.get(phase.id)
|
|
85
|
+
if (!record) continue
|
|
86
|
+
renderedPhases.add(record.id)
|
|
87
|
+
choices.push({
|
|
88
|
+
label: label(`${indent} `, "phase", record.id, record.data.description),
|
|
89
|
+
target: {
|
|
90
|
+
kind: "phase",
|
|
91
|
+
taskId: task.id,
|
|
92
|
+
phaseId: record.id,
|
|
93
|
+
path: record.path,
|
|
94
|
+
},
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
for (const record of phaseRecords) {
|
|
98
|
+
if (renderedPhases.has(record.id)) continue
|
|
99
|
+
choices.push({
|
|
100
|
+
label: label(`${indent} `, "phase", record.id, record.data.description),
|
|
101
|
+
target: {
|
|
102
|
+
kind: "phase",
|
|
103
|
+
taskId: task.id,
|
|
104
|
+
phaseId: record.id,
|
|
105
|
+
path: record.path,
|
|
106
|
+
},
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
return choices
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export const buildWorkTargetChoices = (
|
|
113
|
+
epicRecords: readonly EpicRecord[],
|
|
114
|
+
taskRecords: readonly TaskRecord[],
|
|
115
|
+
phaseRecords: readonly PhaseRecord[],
|
|
116
|
+
): readonly WorkTargetChoice[] => {
|
|
117
|
+
const choices: WorkTargetChoice[] = []
|
|
118
|
+
const tasks = new Map(taskRecords.map((task) => [task.id, task]))
|
|
119
|
+
const phases = new Map<string, PhaseRecord[]>()
|
|
120
|
+
for (const phase of phaseRecords) {
|
|
121
|
+
const records = phases.get(phase.taskId) ?? []
|
|
122
|
+
records.push(phase)
|
|
123
|
+
phases.set(phase.taskId, records)
|
|
124
|
+
}
|
|
125
|
+
const nestedTasks = new Set<string>()
|
|
126
|
+
|
|
127
|
+
for (const epic of epicRecords) {
|
|
128
|
+
choices.push({
|
|
129
|
+
label: label("", "epic", epic.id, epic.data.description),
|
|
130
|
+
target: { kind: "epic", epicId: epic.id, path: epic.path },
|
|
131
|
+
})
|
|
132
|
+
for (const child of epic.data.tasks) {
|
|
133
|
+
const task = tasks.get(child.id)
|
|
134
|
+
if (!task || nestedTasks.has(task.id)) continue
|
|
135
|
+
nestedTasks.add(task.id)
|
|
136
|
+
choices.push(...taskChoices(task, phases.get(task.id) ?? [], " "))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (const task of taskRecords) {
|
|
141
|
+
if (nestedTasks.has(task.id)) continue
|
|
142
|
+
choices.push(...taskChoices(task, phases.get(task.id) ?? [], ""))
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return choices
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export type PickWorkTarget = (
|
|
149
|
+
choices: readonly WorkTargetChoice[],
|
|
150
|
+
) => Effect.Effect<WorkTarget | null, Error>
|
|
151
|
+
|
|
152
|
+
const parseWorkTargetSelection = (
|
|
153
|
+
output: string,
|
|
154
|
+
choices: readonly WorkTargetChoice[],
|
|
155
|
+
) => {
|
|
156
|
+
const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
|
|
157
|
+
return choices[index]?.target ?? null
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export const pickWorkTarget: PickWorkTarget = (choices) =>
|
|
161
|
+
Effect.tryPromise({
|
|
162
|
+
try: async () => {
|
|
163
|
+
const input = choices
|
|
164
|
+
.map((choice, index) => `${index}\t${choice.label}`)
|
|
165
|
+
.join("\n")
|
|
166
|
+
const process = Bun.spawn(
|
|
167
|
+
["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Work on> "],
|
|
168
|
+
{ stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
|
|
169
|
+
)
|
|
170
|
+
const [exitCode, output] = await Promise.all([
|
|
171
|
+
process.exited,
|
|
172
|
+
new Response(process.stdout).text(),
|
|
173
|
+
])
|
|
174
|
+
if (exitCode === 1 || exitCode === 130) return null
|
|
175
|
+
if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
|
|
176
|
+
return parseWorkTargetSelection(output, choices)
|
|
177
|
+
},
|
|
178
|
+
catch: (cause) =>
|
|
179
|
+
new Error("Failed to select a work target with fzf", { cause }),
|
|
180
|
+
})
|