@markjaquith/agency 2.4.0 → 2.5.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.
@@ -1,6 +1,9 @@
1
1
  import { Effect } from "effect"
2
+ import { createInterface } from "node:readline/promises"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { TaskService } from "../services/TaskService"
5
+ import { EpicService } from "../services/EpicService"
6
+ import { RepositoryService } from "../services/RepositoryService"
4
7
  import { createLoggers } from "../utils/effect"
5
8
  import { parseRepositoryReferences } from "../workbase/repository-reference"
6
9
 
@@ -18,31 +21,151 @@ interface TaskOptions extends BaseCommandOptions {
18
21
  readonly json?: boolean
19
22
  }
20
23
 
21
- export const task = (options: TaskOptions) =>
24
+ export interface TaskInteraction {
25
+ readonly text: (prompt: string) => Effect.Effect<string, Error>
26
+ readonly select: (
27
+ prompt: string,
28
+ choices: readonly string[],
29
+ ) => Effect.Effect<string | null, Error>
30
+ }
31
+
32
+ const defaultInteraction: TaskInteraction = {
33
+ text: (prompt) =>
34
+ Effect.tryPromise({
35
+ try: async () => {
36
+ const input = createInterface({
37
+ input: process.stdin,
38
+ output: process.stderr,
39
+ })
40
+ try {
41
+ return await input.question(prompt)
42
+ } finally {
43
+ input.close()
44
+ }
45
+ },
46
+ catch: (cause) => new Error("Failed to read task input", { cause }),
47
+ }),
48
+ select: (prompt, choices) =>
49
+ Effect.tryPromise({
50
+ try: async () => {
51
+ const process = Bun.spawn(
52
+ ["fzf", `--prompt=${prompt}> `, "--height=~40%", "--reverse"],
53
+ {
54
+ stdin: new Blob([choices.join("\n")]),
55
+ stdout: "pipe",
56
+ stderr: "inherit",
57
+ },
58
+ )
59
+ const [exitCode, output] = await Promise.all([
60
+ process.exited,
61
+ new Response(process.stdout).text(),
62
+ ])
63
+ if (exitCode === 1 || exitCode === 130) return null
64
+ if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
65
+ return output.trim() || null
66
+ },
67
+ catch: (cause) =>
68
+ new Error("Failed to select task input with fzf", { cause }),
69
+ }),
70
+ }
71
+
72
+ export const task = (
73
+ options: TaskOptions,
74
+ interaction: TaskInteraction = defaultInteraction,
75
+ ) =>
22
76
  Effect.gen(function* () {
23
77
  const tasks = yield* TaskService
78
+ const epics = yield* EpicService
79
+ const repositories = yield* RepositoryService
24
80
  const { log } = createLoggers(options)
25
81
  const cwd = options.cwd ?? process.cwd()
26
82
 
27
83
  switch (options.subcommand) {
84
+ case "new":
28
85
  case "create": {
29
- const id = options.args[0]
30
- if (!id || !options.ticketUrl) {
31
- return yield* Effect.fail(
32
- new Error("Usage: agency task create <id> --ticket-url <url>"),
86
+ const interactive = options.subcommand === "new" || !options.args[0]
87
+ const id =
88
+ options.args[0] ??
89
+ (interactive
90
+ ? (yield* interaction.text("Task ID: ")).trim()
91
+ : undefined)
92
+ if (!id) {
93
+ return yield* Effect.fail(new Error("Task ID is required"))
94
+ }
95
+
96
+ let ticketUrl = options.ticketUrl?.trim() || null
97
+ let description = options.description?.trim() || undefined
98
+ let epic = options.epic
99
+ let multiPhase = options.multiPhase ?? false
100
+ let repo = options.repo
101
+
102
+ if (interactive) {
103
+ if (options.ticketUrl === undefined) {
104
+ ticketUrl =
105
+ (yield* interaction.text("Ticket URL (optional): ")).trim() ||
106
+ 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),
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",
131
+ ])
132
+ if (selected === null) {
133
+ return yield* Effect.fail(new Error("Task creation cancelled"))
134
+ }
135
+ multiPhase = selected === "multi-phase"
136
+ }
137
+ }
138
+
139
+ if (!multiPhase && !repo) {
140
+ const records = yield* repositories.list(cwd)
141
+ if (records.length === 0) {
142
+ return yield* Effect.fail(
143
+ new Error(
144
+ "No repositories found; add or link a repository first",
145
+ ),
146
+ )
147
+ }
148
+ const selected = yield* interaction.select(
149
+ "Writable repository",
150
+ records.map((record) => record.alias),
33
151
  )
152
+ if (!selected) {
153
+ return yield* Effect.fail(new Error("Task creation cancelled"))
154
+ }
155
+ repo = selected
34
156
  }
157
+
35
158
  const record = yield* tasks.create(
36
159
  {
37
160
  id,
38
- ticketUrl: options.ticketUrl,
39
- description: options.description,
40
- epic: options.epic,
41
- multiPhase: options.multiPhase,
42
- repo: options.repo,
161
+ ticketUrl,
162
+ description,
163
+ epic,
164
+ multiPhase,
165
+ repo,
43
166
  repos: parseRepositoryReferences(options.references),
44
- branch: options.branch,
45
- base: options.base,
167
+ branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
168
+ base: multiPhase ? undefined : (options.base ?? "main"),
46
169
  },
47
170
  cwd,
48
171
  )
@@ -81,9 +204,27 @@ export const task = (options: TaskOptions) =>
81
204
  )
82
205
  return
83
206
  }
207
+ case "status": {
208
+ const [id, status] = options.args
209
+ if (!id || !status) {
210
+ return yield* Effect.fail(
211
+ new Error("Usage: agency task status <id> <status>"),
212
+ )
213
+ }
214
+ const record = yield* tasks.setStatus(id, status, cwd)
215
+ const { content: _, ...output } = record
216
+ log(
217
+ options.json
218
+ ? JSON.stringify(output, null, 2)
219
+ : `Marked task '${id}' as ${record.data.status}`,
220
+ )
221
+ return
222
+ }
84
223
  default:
85
224
  return yield* Effect.fail(
86
- new Error("Subcommand is required. Available: create, list, show"),
225
+ new Error(
226
+ "Subcommand is required. Available: new, create, list, show, status",
227
+ ),
87
228
  )
88
229
  }
89
230
  })
@@ -92,19 +233,21 @@ export const help = `
92
233
  Usage: agency task <subcommand>
93
234
 
94
235
  Subcommands:
95
- create <id> Create a task
236
+ new [id] Create a task with guided input
237
+ create <id> Create a task; omitted metadata uses defaults
96
238
  list List tasks
97
239
  show <id> Show a task
240
+ status <id> <status> Set open, working, done, or dropped
98
241
 
99
242
  Create options:
100
- --ticket-url <url> External ticket URL
243
+ --ticket-url <url> External ticket URL (optional)
101
244
  --description <text> Short description of the task
102
245
  --epic <id> Parent epic
103
246
  --repo <alias> Writable repository
104
247
  --reference <alias>:<ref>
105
248
  Read-only repository reference; repeatable
106
- --branch <name> Working branch
107
- --base <name> Base branch
249
+ --branch <name> Working branch (default: task/<id>)
250
+ --base <name> Base branch (default: main)
108
251
  --multi-phase Create a task container for phases
109
252
 
110
253
  Options:
@@ -9,6 +9,7 @@ import { WorktreeService } from "../services/WorktreeService"
9
9
  import { captureLogs } from "../test-utils"
10
10
  import { work } from "./work"
11
11
  import type { PickWorkTarget } from "../workbase/work-target"
12
+ import type { Progress } from "../utils/progress"
12
13
 
13
14
  type ExecutionWorkspace = Effect.Effect.Success<
14
15
  ReturnType<WorktreeService["materialize"]>
@@ -47,6 +48,8 @@ interface HarnessOptions {
47
48
  const createHarness = (options: HarnessOptions = {}) => {
48
49
  const events: string[] = []
49
50
  const probes: string[] = []
51
+ const statusUpdates: string[] = []
52
+ const progressUpdates: string[] = []
50
53
  const launches: Array<{
51
54
  cli: string
52
55
  args: readonly string[]
@@ -91,6 +94,10 @@ const createHarness = (options: HarnessOptions = {}) => {
91
94
  : { repo: "agency", branch: `task/${id}`, base: "main" },
92
95
  }),
93
96
  list: () => Effect.succeed(options.taskRecords ?? []),
97
+ setStatus: (id: string, status: string) => {
98
+ statusUpdates.push(`task:${id}:${status}`)
99
+ return Effect.void
100
+ },
94
101
  }
95
102
  const phases = {
96
103
  show: (taskId: string, id: string) =>
@@ -101,6 +108,10 @@ const createHarness = (options: HarnessOptions = {}) => {
101
108
  data: { repo: "agency", branch: `task/${id}`, base: "main" },
102
109
  }),
103
110
  list: () => Effect.succeed(options.phaseRecords ?? []),
111
+ setStatus: (taskId: string, id: string, status: string) => {
112
+ statusUpdates.push(`phase:${taskId}:${id}:${status}`)
113
+ return Effect.void
114
+ },
104
115
  }
105
116
  const fs = {
106
117
  runCommand: (args: readonly string[]) => {
@@ -119,12 +130,17 @@ const createHarness = (options: HarnessOptions = {}) => {
119
130
  launches.push({ cli, args, cwd })
120
131
  }
121
132
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
133
+ const progress: Progress = {
134
+ start: (message) => progressUpdates.push(`start:${message}`),
135
+ succeed: (message) => progressUpdates.push(`succeed:${message}`),
136
+ fail: (message) => progressUpdates.push(`fail:${message}`),
137
+ }
122
138
  const run = (
123
139
  commandOptions: Parameters<typeof work>[0],
124
140
  pick: PickWorkTarget = defaultPick,
125
141
  ) =>
126
142
  Effect.runPromise(
127
- work(commandOptions, launch, pick).pipe(
143
+ work(commandOptions, launch, pick, progress).pipe(
128
144
  Effect.provideService(WorktreeService, worktrees as never),
129
145
  Effect.provideService(FileSystemService, fs as never),
130
146
  Effect.provideService(WorkbaseService, workbase as never),
@@ -134,7 +150,15 @@ const createHarness = (options: HarnessOptions = {}) => {
134
150
  ) as Effect.Effect<void, unknown, never>,
135
151
  )
136
152
 
137
- return { events, probes, launches, materializeOptions, run }
153
+ return {
154
+ events,
155
+ probes,
156
+ launches,
157
+ materializeOptions,
158
+ statusUpdates,
159
+ progressUpdates,
160
+ run,
161
+ }
138
162
  }
139
163
 
140
164
  describe("work command", () => {
@@ -146,11 +170,7 @@ describe("work command", () => {
146
170
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
147
171
  expect(harness.launches[0]).toEqual({
148
172
  cli: "opencode",
149
- args: [
150
- "opencode",
151
- "--prompt",
152
- "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
153
- ],
173
+ args: ["opencode", "--continue"],
154
174
  cwd: "/workbase/epics/delivery",
155
175
  })
156
176
  })
@@ -163,11 +183,7 @@ describe("work command", () => {
163
183
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
164
184
  expect(harness.launches[0]).toEqual({
165
185
  cli: "opencode",
166
- args: [
167
- "opencode",
168
- "--prompt",
169
- "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
170
- ],
186
+ args: ["opencode", "--continue"],
171
187
  cwd: "/workbase/tasks/delivery",
172
188
  })
173
189
  })
@@ -185,9 +201,10 @@ describe("work command", () => {
185
201
  "probe:opencode",
186
202
  "launch:opencode",
187
203
  ])
188
- expect(harness.launches[0]?.args).toContain(
189
- "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
190
- )
204
+ expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
205
+ expect(harness.statusUpdates).toEqual([
206
+ "phase:example:implementation:working",
207
+ ])
191
208
  })
192
209
 
193
210
  test("infers a single-phase task from a nested checkout directory", async () => {
@@ -199,7 +216,7 @@ describe("work command", () => {
199
216
  })
200
217
 
201
218
  expect(harness.events[0]).toBe("materialize")
202
- expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example/code/agency")
219
+ expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
203
220
  })
204
221
 
205
222
  test("selects a target with fzf outside an entity directory", async () => {
@@ -234,7 +251,7 @@ describe("work command", () => {
234
251
  "launch:opencode",
235
252
  ])
236
253
  expect(harness.launches[0]?.cwd).toBe(
237
- "/workbase/tasks/example/phases/implementation/code/agency",
254
+ "/workbase/tasks/delivery/phases/build",
238
255
  )
239
256
  })
240
257
 
@@ -278,7 +295,7 @@ describe("work command", () => {
278
295
  expect(harness.events).toEqual([])
279
296
  })
280
297
 
281
- test("launches OpenCode in the writable checkout with the single-phase prompt", async () => {
298
+ test("launches OpenCode in the task directory and continues its session", async () => {
282
299
  const harness = createHarness()
283
300
 
284
301
  await harness.run({ taskId: "example", opencode: true })
@@ -291,17 +308,18 @@ describe("work command", () => {
291
308
  expect(harness.launches).toEqual([
292
309
  {
293
310
  cli: "opencode",
294
- args: [
295
- "opencode",
296
- "--prompt",
297
- "Start the task. Read /workbase/tasks/example/TASK.md.",
298
- ],
299
- cwd: "/workbase/tasks/example/code/agency",
311
+ args: ["opencode", "--continue"],
312
+ cwd: "/workbase/tasks/example",
300
313
  },
301
314
  ])
315
+ expect(harness.statusUpdates).toEqual(["task:example:working"])
316
+ expect(harness.progressUpdates).toEqual([
317
+ "start:Preparing workspace...",
318
+ "succeed:Workspace ready",
319
+ ])
302
320
  })
303
321
 
304
- test("includes absolute task and phase paths in a multi-phase prompt", async () => {
322
+ test("continues OpenCode for a multi-phase task", async () => {
305
323
  const harness = createHarness({ workspace: multiPhaseWorkspace })
306
324
 
307
325
  await harness.run({
@@ -310,11 +328,7 @@ describe("work command", () => {
310
328
  opencode: true,
311
329
  })
312
330
 
313
- expect(harness.launches[0]?.args).toEqual([
314
- "opencode",
315
- "--prompt",
316
- "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
317
- ])
331
+ expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
318
332
  })
319
333
 
320
334
  test("automatically falls back to Claude", async () => {
@@ -326,7 +340,7 @@ describe("work command", () => {
326
340
  expect(harness.launches[0]).toEqual({
327
341
  cli: "claude",
328
342
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
329
- cwd: "/workbase/tasks/example/code/agency",
343
+ cwd: "/workbase/tasks/example",
330
344
  })
331
345
  })
332
346
 
@@ -338,6 +352,7 @@ describe("work command", () => {
338
352
  ).rejects.toThrow("opencode CLI tool not found")
339
353
  expect(harness.probes).toEqual(["opencode"])
340
354
  expect(harness.launches).toEqual([])
355
+ expect(harness.statusUpdates).toEqual([])
341
356
  })
342
357
 
343
358
  test("launches explicitly requested Claude", async () => {
@@ -349,7 +364,7 @@ describe("work command", () => {
349
364
  expect(harness.launches[0]).toEqual({
350
365
  cli: "claude",
351
366
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
352
- cwd: "/workbase/tasks/example/code/agency",
367
+ cwd: "/workbase/tasks/example",
353
368
  })
354
369
  })
355
370
 
@@ -374,6 +389,10 @@ describe("work command", () => {
374
389
  "materialization failed",
375
390
  )
376
391
  expect(harness.events).toEqual(["materialize"])
392
+ expect(harness.progressUpdates).toEqual([
393
+ "start:Preparing workspace...",
394
+ "fail:Workspace preparation failed",
395
+ ])
377
396
  })
378
397
 
379
398
  test("respects silent and verbose logging options", async () => {
@@ -382,7 +401,7 @@ describe("work command", () => {
382
401
  verboseHarness.run({ taskId: "example", verbose: true }),
383
402
  )
384
403
  expect(verboseLogs).toEqual([
385
- "Launching opencode in /workbase/tasks/example/code/agency",
404
+ "Launching opencode in /workbase/tasks/example",
386
405
  ])
387
406
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
388
407
 
@@ -9,6 +9,7 @@ import { TaskService } from "../services/TaskService"
9
9
  import { PhaseService } from "../services/PhaseService"
10
10
  import { createLoggers } from "../utils/effect"
11
11
  import { execvp } from "../utils/exec"
12
+ import { createProgress, type Progress } from "../utils/progress"
12
13
  import {
13
14
  buildWorkTargetChoices,
14
15
  pickWorkTarget,
@@ -35,6 +36,7 @@ export const work = (
35
36
  options: WorkOptions = {},
36
37
  launch: LaunchAgent = launchAgent,
37
38
  pick: PickWorkTarget = pickWorkTarget,
39
+ progress: Progress = createProgress(options),
38
40
  ) =>
39
41
  Effect.gen(function* () {
40
42
  if (options.opencode && options.claude) {
@@ -155,16 +157,21 @@ export const work = (
155
157
  } else {
156
158
  const taskId = target.taskId
157
159
  const phaseId = target.kind === "phase" ? target.phaseId : undefined
158
- const workspace = yield* worktrees.materialize(
159
- taskId,
160
- phaseId,
161
- root,
162
- options,
163
- )
160
+ progress.start("Preparing workspace...")
161
+ const workspace = yield* worktrees
162
+ .materialize(taskId, phaseId, root, options)
163
+ .pipe(
164
+ Effect.tap(() =>
165
+ Effect.sync(() => progress.succeed("Workspace ready")),
166
+ ),
167
+ Effect.tapError(() =>
168
+ Effect.sync(() => progress.fail("Workspace preparation failed")),
169
+ ),
170
+ )
164
171
  prompt = workspace.phasePath
165
172
  ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
166
173
  : `Start the task. Read ${workspace.taskPath}.`
167
- launchPath = workspace.writablePath
174
+ launchPath = dirname(target.path)
168
175
  }
169
176
 
170
177
  const requested = options.claude ? "claude" : "opencode"
@@ -179,9 +186,14 @@ export const work = (
179
186
  if (available.exitCode !== 0) {
180
187
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
181
188
  }
189
+ if (target.kind === "phase") {
190
+ yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
191
+ } else if (target.kind === "task" && !target.multiPhase) {
192
+ yield* tasks.setStatus(target.taskId, "working", root)
193
+ }
182
194
 
183
195
  verboseLog(`Launching ${cli} in ${launchPath}`)
184
- const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
196
+ const args = cli === "opencode" ? ["--continue"] : [prompt]
185
197
  launch(cli, [cli, ...args], launchPath)
186
198
  })
187
199