@markjaquith/agency 2.4.0 → 2.6.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 +56 -20
  2. package/cli.ts +43 -2
  3. package/package.json +1 -1
  4. package/skills/agency/SKILL.md +41 -5
  5. package/src/cli.test.ts +31 -3
  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/task-phase.test.ts +46 -1
  11. package/src/commands/task.test.ts +94 -0
  12. package/src/commands/task.ts +160 -17
  13. package/src/commands/validate.test.ts +65 -0
  14. package/src/commands/validate.ts +19 -5
  15. package/src/commands/work.test.ts +100 -34
  16. package/src/commands/work.ts +30 -10
  17. package/src/commands/workbase.test.ts +62 -0
  18. package/src/commands/workbase.ts +58 -0
  19. package/src/services/ArchiveService.test.ts +334 -0
  20. package/src/services/ArchiveService.ts +246 -0
  21. package/src/services/PhaseService.ts +28 -0
  22. package/src/services/TaskPhaseService.test.ts +79 -0
  23. package/src/services/TaskService.ts +31 -1
  24. package/src/services/WorkbaseService.test.ts +99 -1
  25. package/src/services/WorkbaseService.ts +101 -2
  26. package/src/services/WorktreeService.test.ts +150 -1
  27. package/src/services/WorktreeService.ts +123 -1
  28. package/src/test-utils.ts +2 -0
  29. package/src/utils/progress.test.ts +37 -0
  30. package/src/utils/progress.ts +36 -0
  31. package/src/workbase/AGENTS.md +3 -0
  32. package/src/workbase/opencode-file.ts +37 -0
  33. package/src/workbase/schemas.test.ts +114 -0
  34. package/src/workbase/schemas.ts +18 -2
  35. package/src/workbase/work-target.test.ts +16 -9
  36. package/src/workbase/work-target.ts +37 -6
  37. package/src/workbase/workbase-choice.ts +71 -0
@@ -9,6 +9,8 @@ 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 { PickWorkbase } from "../workbase/workbase-choice"
13
+ import type { Progress } from "../utils/progress"
12
14
 
13
15
  type ExecutionWorkspace = Effect.Effect.Success<
14
16
  ReturnType<WorktreeService["materialize"]>
@@ -42,11 +44,15 @@ interface HarnessOptions {
42
44
  readonly epicRecords?: readonly any[]
43
45
  readonly taskRecords?: readonly any[]
44
46
  readonly phaseRecords?: readonly any[]
47
+ readonly outsideWorkbase?: boolean
48
+ readonly registeredWorkbases?: readonly string[]
45
49
  }
46
50
 
47
51
  const createHarness = (options: HarnessOptions = {}) => {
48
52
  const events: string[] = []
49
53
  const probes: string[] = []
54
+ const statusUpdates: string[] = []
55
+ const progressUpdates: string[] = []
50
56
  const launches: Array<{
51
57
  cli: string
52
58
  args: readonly string[]
@@ -70,7 +76,14 @@ const createHarness = (options: HarnessOptions = {}) => {
70
76
  },
71
77
  }
72
78
  const workbase = {
73
- discover: () => Effect.succeed("/workbase"),
79
+ discover: (path: string) =>
80
+ options.outsideWorkbase && path === "/outside"
81
+ ? Effect.fail({
82
+ _tag: "WorkbaseNotFoundError" as const,
83
+ message: "No Agency workbase found from /outside",
84
+ })
85
+ : Effect.succeed("/workbase"),
86
+ listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
74
87
  }
75
88
  const epics = {
76
89
  show: (id: string) =>
@@ -91,6 +104,10 @@ const createHarness = (options: HarnessOptions = {}) => {
91
104
  : { repo: "agency", branch: `task/${id}`, base: "main" },
92
105
  }),
93
106
  list: () => Effect.succeed(options.taskRecords ?? []),
107
+ setStatus: (id: string, status: string) => {
108
+ statusUpdates.push(`task:${id}:${status}`)
109
+ return Effect.void
110
+ },
94
111
  }
95
112
  const phases = {
96
113
  show: (taskId: string, id: string) =>
@@ -101,6 +118,10 @@ const createHarness = (options: HarnessOptions = {}) => {
101
118
  data: { repo: "agency", branch: `task/${id}`, base: "main" },
102
119
  }),
103
120
  list: () => Effect.succeed(options.phaseRecords ?? []),
121
+ setStatus: (taskId: string, id: string, status: string) => {
122
+ statusUpdates.push(`phase:${taskId}:${id}:${status}`)
123
+ return Effect.void
124
+ },
104
125
  }
105
126
  const fs = {
106
127
  runCommand: (args: readonly string[]) => {
@@ -119,12 +140,19 @@ const createHarness = (options: HarnessOptions = {}) => {
119
140
  launches.push({ cli, args, cwd })
120
141
  }
121
142
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
143
+ const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
144
+ const progress: Progress = {
145
+ start: (message) => progressUpdates.push(`start:${message}`),
146
+ succeed: (message) => progressUpdates.push(`succeed:${message}`),
147
+ fail: (message) => progressUpdates.push(`fail:${message}`),
148
+ }
122
149
  const run = (
123
150
  commandOptions: Parameters<typeof work>[0],
124
151
  pick: PickWorkTarget = defaultPick,
152
+ pickBase: PickWorkbase = defaultPickWorkbase,
125
153
  ) =>
126
154
  Effect.runPromise(
127
- work(commandOptions, launch, pick).pipe(
155
+ work(commandOptions, launch, pick, progress, pickBase).pipe(
128
156
  Effect.provideService(WorktreeService, worktrees as never),
129
157
  Effect.provideService(FileSystemService, fs as never),
130
158
  Effect.provideService(WorkbaseService, workbase as never),
@@ -134,7 +162,15 @@ const createHarness = (options: HarnessOptions = {}) => {
134
162
  ) as Effect.Effect<void, unknown, never>,
135
163
  )
136
164
 
137
- return { events, probes, launches, materializeOptions, run }
165
+ return {
166
+ events,
167
+ probes,
168
+ launches,
169
+ materializeOptions,
170
+ statusUpdates,
171
+ progressUpdates,
172
+ run,
173
+ }
138
174
  }
139
175
 
140
176
  describe("work command", () => {
@@ -146,11 +182,7 @@ describe("work command", () => {
146
182
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
147
183
  expect(harness.launches[0]).toEqual({
148
184
  cli: "opencode",
149
- args: [
150
- "opencode",
151
- "--prompt",
152
- "Work on the epic. Read /workbase/epics/delivery/EPIC.md.",
153
- ],
185
+ args: ["opencode", "--continue"],
154
186
  cwd: "/workbase/epics/delivery",
155
187
  })
156
188
  })
@@ -163,11 +195,7 @@ describe("work command", () => {
163
195
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
164
196
  expect(harness.launches[0]).toEqual({
165
197
  cli: "opencode",
166
- args: [
167
- "opencode",
168
- "--prompt",
169
- "Work on the task. Read /workbase/tasks/delivery/TASK.md.",
170
- ],
198
+ args: ["opencode", "--continue"],
171
199
  cwd: "/workbase/tasks/delivery",
172
200
  })
173
201
  })
@@ -185,9 +213,10 @@ describe("work command", () => {
185
213
  "probe:opencode",
186
214
  "launch:opencode",
187
215
  ])
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
- )
216
+ expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
217
+ expect(harness.statusUpdates).toEqual([
218
+ "phase:example:implementation:working",
219
+ ])
191
220
  })
192
221
 
193
222
  test("infers a single-phase task from a nested checkout directory", async () => {
@@ -199,7 +228,7 @@ describe("work command", () => {
199
228
  })
200
229
 
201
230
  expect(harness.events[0]).toBe("materialize")
202
- expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example/code/agency")
231
+ expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
203
232
  })
204
233
 
205
234
  test("selects a target with fzf outside an entity directory", async () => {
@@ -234,8 +263,43 @@ describe("work command", () => {
234
263
  "launch:opencode",
235
264
  ])
236
265
  expect(harness.launches[0]?.cwd).toBe(
237
- "/workbase/tasks/example/phases/implementation/code/agency",
266
+ "/workbase/tasks/delivery/phases/build",
267
+ )
268
+ })
269
+
270
+ test("selects a registered workbase when local discovery fails", async () => {
271
+ const harness = createHarness({
272
+ outsideWorkbase: true,
273
+ registeredWorkbases: ["/first", "/workbase"],
274
+ })
275
+ const selections: string[][] = []
276
+ const pickBase: PickWorkbase = (workbases) => {
277
+ selections.push([...workbases])
278
+ return Effect.succeed("/workbase")
279
+ }
280
+
281
+ await harness.run(
282
+ { cwd: "/outside", taskId: "example", opencode: true },
283
+ undefined,
284
+ pickBase,
238
285
  )
286
+
287
+ expect(selections).toEqual([["/first", "/workbase"]])
288
+ expect(harness.events).toEqual([
289
+ "probe:fzf",
290
+ "materialize",
291
+ "probe:opencode",
292
+ "launch:opencode",
293
+ ])
294
+ })
295
+
296
+ test("explains how to register a workbase when none are known", async () => {
297
+ const harness = createHarness({ outsideWorkbase: true })
298
+
299
+ await expect(harness.run({ cwd: "/outside" })).rejects.toThrow(
300
+ "agency workbase add <path>",
301
+ )
302
+ expect(harness.events).toEqual([])
239
303
  })
240
304
 
241
305
  test("prints the target tree when fzf is unavailable", async () => {
@@ -278,7 +342,7 @@ describe("work command", () => {
278
342
  expect(harness.events).toEqual([])
279
343
  })
280
344
 
281
- test("launches OpenCode in the writable checkout with the single-phase prompt", async () => {
345
+ test("launches OpenCode in the task directory and continues its session", async () => {
282
346
  const harness = createHarness()
283
347
 
284
348
  await harness.run({ taskId: "example", opencode: true })
@@ -291,17 +355,18 @@ describe("work command", () => {
291
355
  expect(harness.launches).toEqual([
292
356
  {
293
357
  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",
358
+ args: ["opencode", "--continue"],
359
+ cwd: "/workbase/tasks/example",
300
360
  },
301
361
  ])
362
+ expect(harness.statusUpdates).toEqual(["task:example:working"])
363
+ expect(harness.progressUpdates).toEqual([
364
+ "start:Preparing workspace...",
365
+ "succeed:Workspace ready",
366
+ ])
302
367
  })
303
368
 
304
- test("includes absolute task and phase paths in a multi-phase prompt", async () => {
369
+ test("continues OpenCode for a multi-phase task", async () => {
305
370
  const harness = createHarness({ workspace: multiPhaseWorkspace })
306
371
 
307
372
  await harness.run({
@@ -310,11 +375,7 @@ describe("work command", () => {
310
375
  opencode: true,
311
376
  })
312
377
 
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
- ])
378
+ expect(harness.launches[0]?.args).toEqual(["opencode", "--continue"])
318
379
  })
319
380
 
320
381
  test("automatically falls back to Claude", async () => {
@@ -326,7 +387,7 @@ describe("work command", () => {
326
387
  expect(harness.launches[0]).toEqual({
327
388
  cli: "claude",
328
389
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
329
- cwd: "/workbase/tasks/example/code/agency",
390
+ cwd: "/workbase/tasks/example",
330
391
  })
331
392
  })
332
393
 
@@ -338,6 +399,7 @@ describe("work command", () => {
338
399
  ).rejects.toThrow("opencode CLI tool not found")
339
400
  expect(harness.probes).toEqual(["opencode"])
340
401
  expect(harness.launches).toEqual([])
402
+ expect(harness.statusUpdates).toEqual([])
341
403
  })
342
404
 
343
405
  test("launches explicitly requested Claude", async () => {
@@ -349,7 +411,7 @@ describe("work command", () => {
349
411
  expect(harness.launches[0]).toEqual({
350
412
  cli: "claude",
351
413
  args: ["claude", "Start the task. Read /workbase/tasks/example/TASK.md."],
352
- cwd: "/workbase/tasks/example/code/agency",
414
+ cwd: "/workbase/tasks/example",
353
415
  })
354
416
  })
355
417
 
@@ -374,6 +436,10 @@ describe("work command", () => {
374
436
  "materialization failed",
375
437
  )
376
438
  expect(harness.events).toEqual(["materialize"])
439
+ expect(harness.progressUpdates).toEqual([
440
+ "start:Preparing workspace...",
441
+ "fail:Workspace preparation failed",
442
+ ])
377
443
  })
378
444
 
379
445
  test("respects silent and verbose logging options", async () => {
@@ -382,7 +448,7 @@ describe("work command", () => {
382
448
  verboseHarness.run({ taskId: "example", verbose: true }),
383
449
  )
384
450
  expect(verboseLogs).toEqual([
385
- "Launching opencode in /workbase/tasks/example/code/agency",
451
+ "Launching opencode in /workbase/tasks/example",
386
452
  ])
387
453
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
388
454
 
@@ -9,12 +9,18 @@ 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,
15
16
  type PickWorkTarget,
16
17
  type WorkTarget,
17
18
  } from "../workbase/work-target"
19
+ import {
20
+ pickWorkbase,
21
+ resolveWorkbase,
22
+ type PickWorkbase,
23
+ } from "../workbase/workbase-choice"
18
24
 
19
25
  interface WorkOptions extends BaseCommandOptions {
20
26
  readonly taskId?: string
@@ -35,6 +41,8 @@ export const work = (
35
41
  options: WorkOptions = {},
36
42
  launch: LaunchAgent = launchAgent,
37
43
  pick: PickWorkTarget = pickWorkTarget,
44
+ progress: Progress = createProgress(options),
45
+ pickBase: PickWorkbase = pickWorkbase,
38
46
  ) =>
39
47
  Effect.gen(function* () {
40
48
  if (options.opencode && options.claude) {
@@ -56,7 +64,8 @@ export const work = (
56
64
  const phases = yield* PhaseService
57
65
  const { log, verboseLog } = createLoggers(options)
58
66
  const cwd = options.cwd ?? process.cwd()
59
- const root = yield* workbase.discover(cwd)
67
+ const root = yield* resolveWorkbase(cwd, log, pickBase)
68
+ if (!root) return
60
69
 
61
70
  let target: WorkTarget | null = null
62
71
  if (options.epicId) {
@@ -155,16 +164,21 @@ export const work = (
155
164
  } else {
156
165
  const taskId = target.taskId
157
166
  const phaseId = target.kind === "phase" ? target.phaseId : undefined
158
- const workspace = yield* worktrees.materialize(
159
- taskId,
160
- phaseId,
161
- root,
162
- options,
163
- )
167
+ progress.start("Preparing workspace...")
168
+ const workspace = yield* worktrees
169
+ .materialize(taskId, phaseId, root, options)
170
+ .pipe(
171
+ Effect.tap(() =>
172
+ Effect.sync(() => progress.succeed("Workspace ready")),
173
+ ),
174
+ Effect.tapError(() =>
175
+ Effect.sync(() => progress.fail("Workspace preparation failed")),
176
+ ),
177
+ )
164
178
  prompt = workspace.phasePath
165
179
  ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
166
180
  : `Start the task. Read ${workspace.taskPath}.`
167
- launchPath = workspace.writablePath
181
+ launchPath = dirname(target.path)
168
182
  }
169
183
 
170
184
  const requested = options.claude ? "claude" : "opencode"
@@ -179,9 +193,14 @@ export const work = (
179
193
  if (available.exitCode !== 0) {
180
194
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
181
195
  }
196
+ if (target.kind === "phase") {
197
+ yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
198
+ } else if (target.kind === "task" && !target.multiPhase) {
199
+ yield* tasks.setStatus(target.taskId, "working", root)
200
+ }
182
201
 
183
202
  verboseLog(`Launching ${cli} in ${launchPath}`)
184
- const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
203
+ const args = cli === "opencode" ? ["--continue"] : [prompt]
185
204
  launch(cli, [cli, ...args], launchPath)
186
205
  })
187
206
 
@@ -189,7 +208,8 @@ export const help = `
189
208
  Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
190
209
 
191
210
  Launch an agent for the current epic, task, or phase. Outside an entity
192
- directory, select one with fzf.
211
+ directory, select one with fzf. Outside a workbase, select a registered
212
+ workbase first.
193
213
 
194
214
  Options:
195
215
  --epic <id> Work on an epic
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { join } from "node:path"
3
+ import {
4
+ captureLogs,
5
+ cleanupTempDir,
6
+ createTempDir,
7
+ runTestEffect,
8
+ } from "../test-utils"
9
+ import { workbase } from "./workbase"
10
+
11
+ describe("workbase command", () => {
12
+ let root: string
13
+ let configDirectory: string
14
+
15
+ beforeEach(async () => {
16
+ root = await createTempDir()
17
+ configDirectory = join(root, "config")
18
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
19
+ })
20
+
21
+ afterEach(async () => cleanupTempDir(root))
22
+
23
+ test("adds and lists workbases as JSON", async () => {
24
+ const added = await captureLogs(() =>
25
+ runTestEffect(
26
+ workbase({
27
+ subcommand: "add",
28
+ args: [root],
29
+ configDirectory,
30
+ json: true,
31
+ }),
32
+ ),
33
+ )
34
+ const path = JSON.parse(added[0]!).path
35
+
36
+ const listed = await captureLogs(() =>
37
+ runTestEffect(
38
+ workbase({
39
+ subcommand: "list",
40
+ args: [],
41
+ configDirectory,
42
+ json: true,
43
+ }),
44
+ ),
45
+ )
46
+
47
+ expect(JSON.parse(listed[0]!)).toEqual([path])
48
+ })
49
+
50
+ test("requires an add path", async () => {
51
+ await expect(
52
+ runTestEffect(
53
+ workbase({
54
+ subcommand: "add",
55
+ args: [],
56
+ configDirectory,
57
+ silent: true,
58
+ }),
59
+ ),
60
+ ).rejects.toThrow("Usage: agency workbase add <path>")
61
+ })
62
+ })
@@ -0,0 +1,58 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { WorkbaseService } from "../services/WorkbaseService"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface WorkbaseOptions extends BaseCommandOptions {
7
+ readonly subcommand?: string
8
+ readonly args: readonly string[]
9
+ readonly configDirectory?: string
10
+ }
11
+
12
+ export const workbase = (options: WorkbaseOptions) =>
13
+ Effect.gen(function* () {
14
+ const service = yield* WorkbaseService
15
+ const { log } = createLoggers(options)
16
+
17
+ switch (options.subcommand) {
18
+ case "add": {
19
+ const path = options.args[0]
20
+ if (!path) {
21
+ return yield* Effect.fail(
22
+ new Error("Usage: agency workbase add <path>"),
23
+ )
24
+ }
25
+ const root = yield* service.register(path, options.configDirectory)
26
+ log(
27
+ options.json
28
+ ? JSON.stringify({ path: root }, null, 2)
29
+ : `Added workbase ${root}`,
30
+ )
31
+ return
32
+ }
33
+ case "list": {
34
+ const workbases = yield* service.listRegistered(options.configDirectory)
35
+ if (options.json) {
36
+ log(JSON.stringify(workbases, null, 2))
37
+ } else {
38
+ for (const path of workbases) log(path)
39
+ }
40
+ return
41
+ }
42
+ default:
43
+ return yield* Effect.fail(
44
+ new Error("Subcommand is required. Available: add, list"),
45
+ )
46
+ }
47
+ })
48
+
49
+ export const help = `
50
+ Usage: agency workbase <subcommand>
51
+
52
+ Subcommands:
53
+ add <path> Register an Agency workbase
54
+ list List registered workbases
55
+
56
+ Options:
57
+ --json Output results as JSON
58
+ `