@markjaquith/agency 2.3.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.
Files changed (37) hide show
  1. package/README.md +45 -16
  2. package/cli.ts +21 -0
  3. package/package.json +1 -1
  4. package/skills/agency/SKILL.md +31 -4
  5. package/src/cli.test.ts +1 -0
  6. package/src/commands/archive.test.ts +69 -0
  7. package/src/commands/archive.ts +70 -0
  8. package/src/commands/init.test.ts +3 -0
  9. package/src/commands/phase.ts +23 -1
  10. package/src/commands/pr.ts +1 -0
  11. package/src/commands/task-phase.test.ts +46 -1
  12. package/src/commands/task.test.ts +94 -0
  13. package/src/commands/task.ts +160 -17
  14. package/src/commands/work.test.ts +63 -34
  15. package/src/commands/work.ts +20 -3
  16. package/src/services/ArchiveService.test.ts +334 -0
  17. package/src/services/ArchiveService.ts +246 -0
  18. package/src/services/FileSystemService.ts +7 -2
  19. package/src/services/PhaseService.ts +28 -0
  20. package/src/services/PullRequestService.ts +3 -0
  21. package/src/services/TaskPhaseService.test.ts +79 -0
  22. package/src/services/TaskService.ts +31 -1
  23. package/src/services/WorkbaseService.test.ts +66 -0
  24. package/src/services/WorkbaseService.ts +32 -2
  25. package/src/services/WorktreeService.test.ts +210 -2
  26. package/src/services/WorktreeService.ts +143 -1
  27. package/src/test-utils.ts +2 -0
  28. package/src/utils/process.test.ts +38 -1
  29. package/src/utils/process.ts +30 -6
  30. package/src/utils/progress.test.ts +37 -0
  31. package/src/utils/progress.ts +36 -0
  32. package/src/workbase/AGENTS.md +3 -0
  33. package/src/workbase/opencode-file.ts +37 -0
  34. package/src/workbase/schemas.test.ts +50 -0
  35. package/src/workbase/schemas.ts +6 -2
  36. package/src/workbase/work-target.test.ts +9 -9
  37. package/src/workbase/work-target.ts +36 -6
@@ -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,14 +48,25 @@ 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[]
53
56
  cwd: string
54
57
  }> = []
58
+ const materializeOptions: Array<
59
+ Parameters<WorktreeService["materialize"]>[3]
60
+ > = []
55
61
  const worktrees = {
56
- materialize: () => {
62
+ materialize: (
63
+ _taskId: string,
64
+ _phaseId?: string,
65
+ _root?: string,
66
+ commandOptions?: Parameters<WorktreeService["materialize"]>[3],
67
+ ) => {
57
68
  events.push("materialize")
69
+ materializeOptions.push(commandOptions)
58
70
  return options.materializeError
59
71
  ? Effect.fail(options.materializeError)
60
72
  : Effect.succeed(options.workspace ?? singlePhaseWorkspace)
@@ -82,6 +94,10 @@ const createHarness = (options: HarnessOptions = {}) => {
82
94
  : { repo: "agency", branch: `task/${id}`, base: "main" },
83
95
  }),
84
96
  list: () => Effect.succeed(options.taskRecords ?? []),
97
+ setStatus: (id: string, status: string) => {
98
+ statusUpdates.push(`task:${id}:${status}`)
99
+ return Effect.void
100
+ },
85
101
  }
86
102
  const phases = {
87
103
  show: (taskId: string, id: string) =>
@@ -92,6 +108,10 @@ const createHarness = (options: HarnessOptions = {}) => {
92
108
  data: { repo: "agency", branch: `task/${id}`, base: "main" },
93
109
  }),
94
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
+ },
95
115
  }
96
116
  const fs = {
97
117
  runCommand: (args: readonly string[]) => {
@@ -110,12 +130,17 @@ const createHarness = (options: HarnessOptions = {}) => {
110
130
  launches.push({ cli, args, cwd })
111
131
  }
112
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
+ }
113
138
  const run = (
114
139
  commandOptions: Parameters<typeof work>[0],
115
140
  pick: PickWorkTarget = defaultPick,
116
141
  ) =>
117
142
  Effect.runPromise(
118
- work(commandOptions, launch, pick).pipe(
143
+ work(commandOptions, launch, pick, progress).pipe(
119
144
  Effect.provideService(WorktreeService, worktrees as never),
120
145
  Effect.provideService(FileSystemService, fs as never),
121
146
  Effect.provideService(WorkbaseService, workbase as never),
@@ -125,7 +150,15 @@ const createHarness = (options: HarnessOptions = {}) => {
125
150
  ) as Effect.Effect<void, unknown, never>,
126
151
  )
127
152
 
128
- return { events, probes, launches, run }
153
+ return {
154
+ events,
155
+ probes,
156
+ launches,
157
+ materializeOptions,
158
+ statusUpdates,
159
+ progressUpdates,
160
+ run,
161
+ }
129
162
  }
130
163
 
131
164
  describe("work command", () => {
@@ -137,11 +170,7 @@ describe("work command", () => {
137
170
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
138
171
  expect(harness.launches[0]).toEqual({
139
172
  cli: "opencode",
140
- args: [
141
- "opencode",
142
- "--prompt",
143
- "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
144
- ],
173
+ args: ["opencode", "--continue"],
145
174
  cwd: "/workbase/epics/delivery",
146
175
  })
147
176
  })
@@ -154,11 +183,7 @@ describe("work command", () => {
154
183
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
155
184
  expect(harness.launches[0]).toEqual({
156
185
  cli: "opencode",
157
- args: [
158
- "opencode",
159
- "--prompt",
160
- "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
161
- ],
186
+ args: ["opencode", "--continue"],
162
187
  cwd: "/workbase/tasks/delivery",
163
188
  })
164
189
  })
@@ -176,9 +201,10 @@ describe("work command", () => {
176
201
  "probe:opencode",
177
202
  "launch:opencode",
178
203
  ])
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
- )
204
+ expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
205
+ expect(harness.statusUpdates).toEqual([
206
+ "phase:example:implementation:working",
207
+ ])
182
208
  })
183
209
 
184
210
  test("infers a single-phase task from a nested checkout directory", async () => {
@@ -190,7 +216,7 @@ describe("work command", () => {
190
216
  })
191
217
 
192
218
  expect(harness.events[0]).toBe("materialize")
193
- expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example/code/agency")
219
+ expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
194
220
  })
195
221
 
196
222
  test("selects a target with fzf outside an entity directory", async () => {
@@ -225,7 +251,7 @@ describe("work command", () => {
225
251
  "launch:opencode",
226
252
  ])
227
253
  expect(harness.launches[0]?.cwd).toBe(
228
- "/workbase/tasks/example/phases/implementation/code/agency",
254
+ "/workbase/tasks/delivery/phases/build",
229
255
  )
230
256
  })
231
257
 
@@ -269,7 +295,7 @@ describe("work command", () => {
269
295
  expect(harness.events).toEqual([])
270
296
  })
271
297
 
272
- 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 () => {
273
299
  const harness = createHarness()
274
300
 
275
301
  await harness.run({ taskId: "example", opencode: true })
@@ -282,17 +308,18 @@ describe("work command", () => {
282
308
  expect(harness.launches).toEqual([
283
309
  {
284
310
  cli: "opencode",
285
- args: [
286
- "opencode",
287
- "--prompt",
288
- "Start the task. Read /workbase/tasks/example/TASK.md.",
289
- ],
290
- cwd: "/workbase/tasks/example/code/agency",
311
+ args: ["opencode", "--continue"],
312
+ cwd: "/workbase/tasks/example",
291
313
  },
292
314
  ])
315
+ expect(harness.statusUpdates).toEqual(["task:example:working"])
316
+ expect(harness.progressUpdates).toEqual([
317
+ "start:Preparing workspace...",
318
+ "succeed:Workspace ready",
319
+ ])
293
320
  })
294
321
 
295
- test("includes absolute task and phase paths in a multi-phase prompt", async () => {
322
+ test("continues OpenCode for a multi-phase task", async () => {
296
323
  const harness = createHarness({ workspace: multiPhaseWorkspace })
297
324
 
298
325
  await harness.run({
@@ -301,11 +328,7 @@ describe("work command", () => {
301
328
  opencode: true,
302
329
  })
303
330
 
304
- expect(harness.launches[0]?.args).toEqual([
305
- "opencode",
306
- "--prompt",
307
- "Start the task. Read /workbase/tasks/example/TASK.md and /workbase/tasks/example/phases/implementation/PHASE.md.",
308
- ])
331
+ expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
309
332
  })
310
333
 
311
334
  test("automatically falls back to Claude", async () => {
@@ -317,7 +340,7 @@ describe("work command", () => {
317
340
  expect(harness.launches[0]).toEqual({
318
341
  cli: "claude",
319
342
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
320
- cwd: "/workbase/tasks/example/code/agency",
343
+ cwd: "/workbase/tasks/example",
321
344
  })
322
345
  })
323
346
 
@@ -329,6 +352,7 @@ describe("work command", () => {
329
352
  ).rejects.toThrow("opencode CLI tool not found")
330
353
  expect(harness.probes).toEqual(["opencode"])
331
354
  expect(harness.launches).toEqual([])
355
+ expect(harness.statusUpdates).toEqual([])
332
356
  })
333
357
 
334
358
  test("launches explicitly requested Claude", async () => {
@@ -340,7 +364,7 @@ describe("work command", () => {
340
364
  expect(harness.launches[0]).toEqual({
341
365
  cli: "claude",
342
366
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
343
- cwd: "/workbase/tasks/example/code/agency",
367
+ cwd: "/workbase/tasks/example",
344
368
  })
345
369
  })
346
370
 
@@ -365,6 +389,10 @@ describe("work command", () => {
365
389
  "materialization failed",
366
390
  )
367
391
  expect(harness.events).toEqual(["materialize"])
392
+ expect(harness.progressUpdates).toEqual([
393
+ "start:Preparing workspace...",
394
+ "fail:Workspace preparation failed",
395
+ ])
368
396
  })
369
397
 
370
398
  test("respects silent and verbose logging options", async () => {
@@ -373,8 +401,9 @@ describe("work command", () => {
373
401
  verboseHarness.run({ taskId: "example", verbose: true }),
374
402
  )
375
403
  expect(verboseLogs).toEqual([
376
- "Launching opencode in /workbase/tasks/example/code/agency",
404
+ "Launching opencode in /workbase/tasks/example",
377
405
  ])
406
+ expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
378
407
 
379
408
  const silentHarness = createHarness()
380
409
  const silentLogs = await captureLogs(() =>
@@ -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,11 +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(taskId, phaseId, root)
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
+ )
159
171
  prompt = workspace.phasePath
160
172
  ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
161
173
  : `Start the task. Read ${workspace.taskPath}.`
162
- launchPath = workspace.writablePath
174
+ launchPath = dirname(target.path)
163
175
  }
164
176
 
165
177
  const requested = options.claude ? "claude" : "opencode"
@@ -174,9 +186,14 @@ export const work = (
174
186
  if (available.exitCode !== 0) {
175
187
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
176
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
+ }
177
194
 
178
195
  verboseLog(`Launching ${cli} in ${launchPath}`)
179
- const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
196
+ const args = cli === "opencode" ? ["--continue"] : [prompt]
180
197
  launch(cli, [cli, ...args], launchPath)
181
198
  })
182
199