@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 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` fetches repositories, creates or reuses worktrees under `code/`,
318
- and launches an agent in the writable checkout with absolute task and phase
319
- context paths.
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 <task> [phase] Materialize worktrees and launch an agent
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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, Git, and optionally gh plus OpenCode or Claude Code.
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 the execution worktrees, changes into the
218
- writable checkout, and replaces the current process with the selected agent.
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
@@ -43,7 +43,7 @@ describe("CLI", () => {
43
43
  const version = await runCli(["--version"])
44
44
  expect(version).toEqual({
45
45
  exitCode: 0,
46
- stdout: "v2.0.0\n",
46
+ stdout: "v0.0.0-development\n",
47
47
  stderr: "",
48
48
  })
49
49
 
@@ -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 run = (commandOptions: Parameters<typeof work>[0]) =>
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("requires a task ID before materialization", async () => {
132
+ test("launches an epic agent from an epic directory", async () => {
83
133
  const harness = createHarness()
84
134
 
85
- await expect(harness.run({})).rejects.toThrow("Task ID is required")
86
- expect(harness.events).toEqual([])
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
 
@@ -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 { verboseLog } = createLoggers(options)
39
- const workspace = yield* worktrees.materialize(
40
- options.taskId,
41
- options.phaseId,
42
- options.cwd ?? process.cwd(),
43
- )
44
- const prompt = workspace.phasePath
45
- ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
46
- : `Start the task. Read ${workspace.taskPath}.`
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 ${workspace.writablePath}`)
178
+ verboseLog(`Launching ${cli} in ${launchPath}`)
62
179
  const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
63
- launch(cli, [cli, ...args], workspace.writablePath)
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
- Materialize repository worktrees and launch an agent in the writable checkout.
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
+ })