@markjaquith/agency 2.5.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.
package/README.md CHANGED
@@ -240,11 +240,15 @@ agency pr create refresh-copy
240
240
 
241
241
  ```text
242
242
  agency init [path] [--json]
243
+ agency workbase add <path> [--json]
244
+ agency workbase list [--json]
243
245
  agency repo add <alias> <remote> [--json]
244
246
  agency repo link <alias> <path> [--json]
245
247
  agency repo list [--json]
246
248
  ```
247
249
 
250
+ Registered workbases are stored in
251
+ `$XDG_CONFIG_HOME/agency/workbases.json` (or `~/.config/agency/workbases.json`).
248
252
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
249
253
  repository. Alias names are then used by all documents and commands.
250
254
 
@@ -294,7 +298,7 @@ Inspect tasks:
294
298
  ```text
295
299
  agency task list [--json]
296
300
  agency task show <id> [--json]
297
- agency task status <id> <open|working|done|dropped> [--json]
301
+ agency task status <id> <open|working|delegated|done|dropped> [--json]
298
302
  ```
299
303
 
300
304
  To add a phase to an existing single-phase task, name the phase that will own
@@ -321,14 +325,14 @@ agency phase create <task-id> <phase-id>
321
325
 
322
326
  agency phase list <task-id> [--json]
323
327
  agency phase show <task-id> <phase-id> [--json]
324
- agency phase status <task-id> <phase-id> <open|working|done|dropped> [--json]
328
+ agency phase status <task-id> <phase-id> <open|working|delegated|done|dropped> [--json]
325
329
  ```
326
330
 
327
331
  Single-phase tasks and phases store status in YAML. New execution units start
328
332
  `open`, and `agency work` marks the selected execution unit `working` immediately
329
- before launch. Use the status subcommands to mark work `done`, `dropped`, or open
330
- it again. The interactive work selector displays status markers before execution
331
- units.
333
+ before launch. Use the status subcommands to mark work `delegated`, `done`,
334
+ `dropped`, or open it again. The interactive work selector displays status
335
+ markers before execution units.
332
336
 
333
337
  ### Archive
334
338
 
@@ -351,8 +355,10 @@ agency pr create <task-id> [phase-id] [--draft] [--json]
351
355
  ```
352
356
 
353
357
  `agency work` infers an epic, task, or phase from the current directory. From
354
- elsewhere in the workbase, it presents the full hierarchy in `fzf`. If `fzf` is
355
- not installed, Agency prints the hierarchy and asks for an explicit target.
358
+ elsewhere in the workbase, it presents the full hierarchy in `fzf`. Outside a
359
+ workbase, it first presents the registered workbases, then the selected
360
+ workbase's hierarchy. If `fzf` is not installed, Agency prints the available
361
+ choices and asks for an explicit target.
356
362
 
357
363
  Epic and multi-phase task targets launch orchestration agents beside their
358
364
  documents. Single-phase tasks and phases fetch repositories, create or reuse
@@ -380,13 +386,14 @@ the owning `TASK.md` or `PHASE.md`.
380
386
 
381
387
  ```text
382
388
  agency status [--json]
383
- agency validate [--json]
389
+ agency validate [path] [--json]
384
390
  ```
385
391
 
386
392
  Validation checks JSON and YAML parsing, Effect Schema conformance, repository
387
393
  aliases, parent/child backlinks, phase directories, duplicate references,
388
394
  unknown dependencies, and dependency cycles. YAML duplicate keys, anchors,
389
- aliases, and custom tags are rejected.
395
+ aliases, and custom tags are rejected. When path is omitted outside a workbase,
396
+ Agency prompts for a registered workbase.
390
397
 
391
398
  ## Agent Skill
392
399
 
package/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { repo, help as repoHelp } from "./src/commands/repo"
12
12
  import { epic, help as epicHelp } from "./src/commands/epic"
13
13
  import { phase, help as phaseHelp } from "./src/commands/phase"
14
14
  import { archive, help as archiveHelp } from "./src/commands/archive"
15
+ import { workbase, help as workbaseHelp } from "./src/commands/workbase"
15
16
  import type { Command } from "./src/types"
16
17
  import { FileSystemService } from "./src/services/FileSystemService"
17
18
  import { WorkbaseService } from "./src/services/WorkbaseService"
@@ -173,6 +174,23 @@ const commands: Record<string, Command> = {
173
174
  )
174
175
  },
175
176
  },
177
+ workbase: {
178
+ run: async (args: string[], options: Record<string, any>) => {
179
+ if (options.help) {
180
+ console.log(workbaseHelp)
181
+ return
182
+ }
183
+ await runCommand(
184
+ workbase({
185
+ subcommand: args[0],
186
+ args: args.slice(1),
187
+ json: options.json,
188
+ silent: options.silent,
189
+ verbose: options.verbose,
190
+ }),
191
+ )
192
+ },
193
+ },
176
194
  repo: {
177
195
  run: async (args: string[], options: Record<string, any>) => {
178
196
  if (options.help) {
@@ -251,13 +269,14 @@ const commands: Record<string, Command> = {
251
269
  },
252
270
  },
253
271
  validate: {
254
- run: async (_args: string[], options: Record<string, any>) => {
272
+ run: async (args: string[], options: Record<string, any>) => {
255
273
  if (options.help) {
256
274
  console.log(validateHelp)
257
275
  return
258
276
  }
259
277
  await runCommand(
260
278
  validate({
279
+ path: args[0],
261
280
  silent: options.silent,
262
281
  verbose: options.verbose,
263
282
  json: options.json,
@@ -275,6 +294,7 @@ Usage: agency <command> [options]
275
294
 
276
295
  Commands:
277
296
  init [path] Initialize an Agency workbase
297
+ workbase <subcommand> Manage registered workbases
278
298
  epic <subcommand> Manage epics
279
299
  phase <subcommand> Manage task phases
280
300
  archive <type> Archive a work item
@@ -283,7 +303,7 @@ Commands:
283
303
  pr create Create a pull request for an execution unit
284
304
  repo <subcommand> Manage workbase repositories
285
305
  status Show status for the current workbase
286
- validate Validate the current workbase
306
+ validate [path] Validate a workbase
287
307
 
288
308
  Global Options:
289
309
  -h, --help Show help for a command
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -58,6 +58,13 @@ If no workbase is found, do not initialize one without user intent. When asked:
58
58
  agency init [path]
59
59
  ```
60
60
 
61
+ Register known workbases so `agency work` can select one when run elsewhere:
62
+
63
+ ```bash
64
+ agency workbase add <path>
65
+ agency workbase list
66
+ ```
67
+
61
68
  ## Repository Aliases
62
69
 
63
70
  Add a remote as an Agency-managed bare repository:
@@ -195,8 +202,8 @@ output, including mutations, entity inspection, status, validation, and PR creat
195
202
  - Multi-phase task frontmatter owns the phase dependency graph.
196
203
  - Epic frontmatter owns the child-task dependency graph.
197
204
  - `pr` is either a GitHub PR URL string or `null`.
198
- - Execution-unit `status` is `open`, `working`, `done`, or `dropped`. New work
199
- starts open, and `agency work` marks it working before agent launch.
205
+ - Execution-unit `status` is `open`, `working`, `delegated`, `done`, or `dropped`.
206
+ New work starts open, and `agency work` marks it working before agent launch.
200
207
  - Keep directory IDs stable; encode sequencing with `dependsOn`, not numeric
201
208
  directory prefixes.
202
209
  - Do not use YAML duplicate keys, anchors, aliases, or custom tags.
@@ -207,8 +214,8 @@ prose, preserve backlinks and run validation immediately afterward.
207
214
  Update execution status with:
208
215
 
209
216
  ```bash
210
- agency task status <task-id> <open|working|done|dropped>
211
- agency phase status <task-id> <phase-id> <open|working|done|dropped>
217
+ agency task status <task-id> <open|working|delegated|done|dropped>
218
+ agency phase status <task-id> <phase-id> <open|working|delegated|done|dropped>
212
219
  ```
213
220
 
214
221
  ## Archive Completed Work
@@ -226,13 +233,14 @@ branches. It refuses dirty worktrees and active sibling dependencies.
226
233
  ## Validate Every Structural Change
227
234
 
228
235
  ```bash
229
- agency validate
236
+ agency validate [path]
230
237
  ```
231
238
 
232
239
  Use `--json` when diagnostics will be consumed programmatically. Resolve all
233
240
  validation errors before materializing worktrees or creating PRs. Validation
234
241
  checks schemas, aliases, backlinks, phase directories, duplicate references,
235
242
  duplicate writable branch ownership, unknown dependencies, and dependency cycles.
243
+ Outside a workbase, omitting path opens the registered-workbase picker.
236
244
 
237
245
  ## Worktrees And Agent Launch
238
246
 
@@ -247,6 +255,7 @@ repositories for execution targets, creates or reuses their worktrees, and
247
255
  replaces the current process with the selected agent. It infers the nearest
248
256
  epic, task, or phase from the current directory; otherwise it opens an `fzf`
249
257
  picker containing the workbase hierarchy.
258
+ Outside a workbase, it first opens a picker containing registered workbases.
250
259
 
251
260
  Epic and multi-phase task targets are orchestration sessions launched beside
252
261
  their documents. Single-phase tasks and phases are execution sessions launched
package/src/cli.test.ts CHANGED
@@ -12,9 +12,14 @@ interface CliResult {
12
12
  stderr: string
13
13
  }
14
14
 
15
- async function runCli(args: string[], cwd = projectRoot): Promise<CliResult> {
15
+ async function runCli(
16
+ args: string[],
17
+ cwd = projectRoot,
18
+ env?: Record<string, string>,
19
+ ): Promise<CliResult> {
16
20
  const subprocess = Bun.spawn([process.execPath, cliPath, ...args], {
17
21
  cwd,
22
+ env: env ? { ...process.env, ...env } : undefined,
18
23
  stdout: "pipe",
19
24
  stderr: "pipe",
20
25
  })
@@ -76,6 +81,7 @@ describe("CLI", () => {
76
81
  test("routes command help and global options on either side of commands", async () => {
77
82
  for (const [command, usage] of [
78
83
  ["init", "Usage: agency init"],
84
+ ["workbase", "Usage: agency workbase"],
79
85
  ["repo", "Usage: agency repo"],
80
86
  ["epic", "Usage: agency epic"],
81
87
  ["task", "Usage: agency task"],
@@ -106,6 +112,25 @@ describe("CLI", () => {
106
112
  expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
107
113
  })
108
114
 
115
+ test("registers and lists workbases", async () => {
116
+ const parent = await createTempDir()
117
+ tempDirs.push(parent)
118
+ const root = join(parent, "workbase")
119
+ const env = { XDG_CONFIG_HOME: join(parent, "config") }
120
+
121
+ expect(
122
+ parseJson(await runCli(["init", root, "--json"], parent, env)),
123
+ ).toEqual({
124
+ root,
125
+ })
126
+ expect(
127
+ parseJson(await runCli(["workbase", "add", root, "--json"], parent, env)),
128
+ ).toEqual({ path: await realpath(root) })
129
+ expect(
130
+ parseJson(await runCli(["workbase", "list", "--json"], parent, env)),
131
+ ).toEqual([await realpath(root)])
132
+ })
133
+
109
134
  test("runs a multi-phase domain workflow through subprocesses", async () => {
110
135
  const parent = await createTempDir()
111
136
  tempDirs.push(parent)
@@ -278,9 +303,11 @@ describe("CLI", () => {
278
303
  issues: [],
279
304
  })
280
305
 
281
- const validation = parseJson(await runCli(["validate", "--json"], root))
306
+ const validation = parseJson(
307
+ await runCli(["validate", root, "--json"], parent),
308
+ )
282
309
  expect(validation).toEqual({
283
- root: workbaseRoot,
310
+ root,
284
311
  issues: [],
285
312
  epicCount: 1,
286
313
  taskCount: 1,
@@ -125,7 +125,7 @@ Subcommands:
125
125
  list <task> List task phases
126
126
  show <task> <phase> Show a phase
127
127
  status <task> <phase> <status>
128
- Set open, working, done, or dropped
128
+ Set open, working, delegated, done, or dropped
129
129
 
130
130
  Create options:
131
131
  --description <text> Short description of the phase
@@ -172,13 +172,13 @@ describe("task and phase command JSON output", () => {
172
172
  runTestEffect(
173
173
  phase({
174
174
  subcommand: "status",
175
- args: ["multi", "first", "done"],
175
+ args: ["multi", "first", "delegated"],
176
176
  cwd: root,
177
177
  json: true,
178
178
  }),
179
179
  ),
180
180
  )
181
- expect(JSON.parse(phaseLogs[0]!).data.status).toBe("done")
181
+ expect(JSON.parse(phaseLogs[0]!).data.status).toBe("delegated")
182
182
 
183
183
  await runTestEffect(
184
184
  task({
@@ -196,13 +196,13 @@ describe("task and phase command JSON output", () => {
196
196
  runTestEffect(
197
197
  task({
198
198
  subcommand: "status",
199
- args: ["single-status", "dropped"],
199
+ args: ["single-status", "delegated"],
200
200
  cwd: root,
201
201
  json: true,
202
202
  }),
203
203
  ),
204
204
  )
205
- expect(JSON.parse(taskLogs[0]!).data.status).toBe("dropped")
205
+ expect(JSON.parse(taskLogs[0]!).data.status).toBe("delegated")
206
206
  })
207
207
 
208
208
  test("converts a single-phase task with an explicit first phase ID", async () => {
@@ -237,7 +237,7 @@ Subcommands:
237
237
  create <id> Create a task; omitted metadata uses defaults
238
238
  list List tasks
239
239
  show <id> Show a task
240
- status <id> <status> Set open, working, done, or dropped
240
+ status <id> <status> Set open, working, delegated, done, or dropped
241
241
 
242
242
  Create options:
243
243
  --ticket-url <url> External ticket URL (optional)
@@ -1,4 +1,5 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
2
3
  import { mkdir } from "node:fs/promises"
3
4
  import { join } from "node:path"
4
5
  import {
@@ -7,6 +8,9 @@ import {
7
8
  createTempDir,
8
9
  runTestEffect,
9
10
  } from "../test-utils"
11
+ import { FileSystemService } from "../services/FileSystemService"
12
+ import { WorkbaseService } from "../services/WorkbaseService"
13
+ import type { PickWorkbase } from "../workbase/workbase-choice"
10
14
  import { validate } from "./validate"
11
15
 
12
16
  describe("validate command", () => {
@@ -43,6 +47,67 @@ pr: null
43
47
  ).resolves.toBeUndefined()
44
48
  })
45
49
 
50
+ test("validates an explicit workbase path", async () => {
51
+ await Bun.write(
52
+ join(root, "tasks/example/TASK.md"),
53
+ `---
54
+ ticketUrl: null
55
+ repo: agency
56
+ branch: task/example
57
+ base: main
58
+ pr: null
59
+ ---
60
+ `,
61
+ )
62
+
63
+ await expect(
64
+ runTestEffect(
65
+ validate({ path: root, cwd: join(root, "outside"), silent: true }),
66
+ ),
67
+ ).resolves.toBeUndefined()
68
+ })
69
+
70
+ test("selects a registered workbase when local discovery fails", async () => {
71
+ const discovered: string[] = []
72
+ const workbase = {
73
+ discover: (path: string) => {
74
+ discovered.push(path)
75
+ return path === "/outside"
76
+ ? Effect.fail({
77
+ _tag: "WorkbaseNotFoundError" as const,
78
+ message: "No Agency workbase found from /outside",
79
+ })
80
+ : Effect.succeed(path)
81
+ },
82
+ listRegistered: () => Effect.succeed(["/first", "/selected"]),
83
+ validate: (path: string) =>
84
+ Effect.succeed({
85
+ root: path,
86
+ issues: [],
87
+ epicCount: 0,
88
+ taskCount: 0,
89
+ phaseCount: 0,
90
+ valid: true,
91
+ }),
92
+ }
93
+ const fs = {
94
+ runCommand: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }),
95
+ }
96
+ const pick: PickWorkbase = (workbases) => {
97
+ expect(workbases).toEqual(["/first", "/selected"])
98
+ return Effect.succeed("/selected")
99
+ }
100
+
101
+ await Effect.runPromise(
102
+ validate({ cwd: "/outside", silent: true }, pick).pipe(
103
+ Effect.provideService(WorkbaseService, workbase as never),
104
+ Effect.provideService(FileSystemService, fs as never),
105
+ ) as Effect.Effect<void, unknown, never>,
106
+ )
107
+
108
+ expect(discovered).toEqual(["/outside", "/selected"])
109
+ })
110
+
46
111
  test("outputs the validation report as JSON", async () => {
47
112
  await Bun.write(
48
113
  join(root, "tasks/example/TASK.md"),
@@ -2,8 +2,14 @@ import { Data, Effect } from "effect"
2
2
  import type { BaseCommandOptions } from "../utils/command"
3
3
  import { WorkbaseService } from "../services/WorkbaseService"
4
4
  import { createLoggers } from "../utils/effect"
5
+ import {
6
+ pickWorkbase,
7
+ resolveWorkbase,
8
+ type PickWorkbase,
9
+ } from "../workbase/workbase-choice"
5
10
 
6
11
  interface ValidateOptions extends BaseCommandOptions {
12
+ readonly path?: string
7
13
  readonly json?: boolean
8
14
  }
9
15
 
@@ -11,11 +17,19 @@ class ValidationFailedError extends Data.TaggedError("ValidationFailedError")<{
11
17
  readonly message: string
12
18
  }> {}
13
19
 
14
- export const validate = (options: ValidateOptions = {}) =>
20
+ export const validate = (
21
+ options: ValidateOptions = {},
22
+ pick: PickWorkbase = pickWorkbase,
23
+ ) =>
15
24
  Effect.gen(function* () {
16
25
  const workbase = yield* WorkbaseService
17
26
  const { log } = createLoggers(options)
18
- const report = yield* workbase.validate(options.cwd ?? process.cwd())
27
+ const startPath = options.path ?? options.cwd ?? process.cwd()
28
+ const root = options.path
29
+ ? yield* workbase.discover(startPath)
30
+ : yield* resolveWorkbase(startPath, log, pick)
31
+ if (!root) return
32
+ const report = yield* workbase.validate(root)
19
33
 
20
34
  if (options.json) {
21
35
  log(JSON.stringify(report, null, 2))
@@ -40,10 +54,10 @@ export const validate = (options: ValidateOptions = {}) =>
40
54
  })
41
55
 
42
56
  export const help = `
43
- Usage: agency validate [options]
57
+ Usage: agency validate [path] [options]
44
58
 
45
- Validate the current workbase configuration, frontmatter, references, and
46
- dependency graphs.
59
+ Validate a workbase's configuration, frontmatter, references, and dependency
60
+ graphs. The current or selected registered workbase is used when path is omitted.
47
61
 
48
62
  Options:
49
63
  --json Output the validation report as JSON
@@ -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 { PickWorkbase } from "../workbase/workbase-choice"
12
13
  import type { Progress } from "../utils/progress"
13
14
 
14
15
  type ExecutionWorkspace = Effect.Effect.Success<
@@ -43,6 +44,8 @@ interface HarnessOptions {
43
44
  readonly epicRecords?: readonly any[]
44
45
  readonly taskRecords?: readonly any[]
45
46
  readonly phaseRecords?: readonly any[]
47
+ readonly outsideWorkbase?: boolean
48
+ readonly registeredWorkbases?: readonly string[]
46
49
  }
47
50
 
48
51
  const createHarness = (options: HarnessOptions = {}) => {
@@ -73,7 +76,14 @@ const createHarness = (options: HarnessOptions = {}) => {
73
76
  },
74
77
  }
75
78
  const workbase = {
76
- 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 ?? []),
77
87
  }
78
88
  const epics = {
79
89
  show: (id: string) =>
@@ -130,6 +140,7 @@ const createHarness = (options: HarnessOptions = {}) => {
130
140
  launches.push({ cli, args, cwd })
131
141
  }
132
142
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
143
+ const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
133
144
  const progress: Progress = {
134
145
  start: (message) => progressUpdates.push(`start:${message}`),
135
146
  succeed: (message) => progressUpdates.push(`succeed:${message}`),
@@ -138,9 +149,10 @@ const createHarness = (options: HarnessOptions = {}) => {
138
149
  const run = (
139
150
  commandOptions: Parameters<typeof work>[0],
140
151
  pick: PickWorkTarget = defaultPick,
152
+ pickBase: PickWorkbase = defaultPickWorkbase,
141
153
  ) =>
142
154
  Effect.runPromise(
143
- work(commandOptions, launch, pick, progress).pipe(
155
+ work(commandOptions, launch, pick, progress, pickBase).pipe(
144
156
  Effect.provideService(WorktreeService, worktrees as never),
145
157
  Effect.provideService(FileSystemService, fs as never),
146
158
  Effect.provideService(WorkbaseService, workbase as never),
@@ -255,6 +267,41 @@ describe("work command", () => {
255
267
  )
256
268
  })
257
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,
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([])
303
+ })
304
+
258
305
  test("prints the target tree when fzf is unavailable", async () => {
259
306
  const harness = createHarness({
260
307
  available: { fzf: false },
@@ -16,6 +16,11 @@ import {
16
16
  type PickWorkTarget,
17
17
  type WorkTarget,
18
18
  } from "../workbase/work-target"
19
+ import {
20
+ pickWorkbase,
21
+ resolveWorkbase,
22
+ type PickWorkbase,
23
+ } from "../workbase/workbase-choice"
19
24
 
20
25
  interface WorkOptions extends BaseCommandOptions {
21
26
  readonly taskId?: string
@@ -37,6 +42,7 @@ export const work = (
37
42
  launch: LaunchAgent = launchAgent,
38
43
  pick: PickWorkTarget = pickWorkTarget,
39
44
  progress: Progress = createProgress(options),
45
+ pickBase: PickWorkbase = pickWorkbase,
40
46
  ) =>
41
47
  Effect.gen(function* () {
42
48
  if (options.opencode && options.claude) {
@@ -58,7 +64,8 @@ export const work = (
58
64
  const phases = yield* PhaseService
59
65
  const { log, verboseLog } = createLoggers(options)
60
66
  const cwd = options.cwd ?? process.cwd()
61
- const root = yield* workbase.discover(cwd)
67
+ const root = yield* resolveWorkbase(cwd, log, pickBase)
68
+ if (!root) return
62
69
 
63
70
  let target: WorkTarget | null = null
64
71
  if (options.epicId) {
@@ -201,7 +208,8 @@ export const help = `
201
208
  Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
202
209
 
203
210
  Launch an agent for the current epic, task, or phase. Outside an entity
204
- directory, select one with fzf.
211
+ directory, select one with fzf. Outside a workbase, select a registered
212
+ workbase first.
205
213
 
206
214
  Options:
207
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
+ `
@@ -237,12 +237,12 @@ describe("task and phase services", () => {
237
237
  const task = await runTestEffect(
238
238
  TaskService.pipe(
239
239
  Effect.flatMap((service) =>
240
- service.setStatus("single-status", "done", root),
240
+ service.setStatus("single-status", "delegated", root),
241
241
  ),
242
242
  ),
243
243
  )
244
- expect(task.data.status).toBe("done")
245
- expect(task.content).toContain("status: done")
244
+ expect(task.data.status).toBe("delegated")
245
+ expect(task.content).toContain("status: delegated")
246
246
  expect(task.content).toContain("Describe the task outcome.")
247
247
 
248
248
  await runTestEffect(
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { createHash } from "node:crypto"
4
- import { mkdir } from "node:fs/promises"
4
+ import { mkdir, realpath } from "node:fs/promises"
5
5
  import { dirname, join } from "node:path"
6
6
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
7
  import { managedWorkbaseAgents } from "../workbase/agents-file"
@@ -56,6 +56,38 @@ describe("WorkbaseService", () => {
56
56
  )
57
57
  })
58
58
 
59
+ test("registers canonical workbase paths without duplicates", async () => {
60
+ const workbaseRoot = join(root, "workbase")
61
+ const nested = join(workbaseRoot, "nested")
62
+ const configDirectory = join(root, "config")
63
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
64
+ await mkdir(nested, { recursive: true })
65
+
66
+ const first = await runTestEffect(
67
+ WorkbaseService.pipe(
68
+ Effect.flatMap((service) => service.register(nested, configDirectory)),
69
+ ),
70
+ )
71
+ await runTestEffect(
72
+ WorkbaseService.pipe(
73
+ Effect.flatMap((service) =>
74
+ service.register(workbaseRoot, configDirectory),
75
+ ),
76
+ ),
77
+ )
78
+ const registered = await runTestEffect(
79
+ WorkbaseService.pipe(
80
+ Effect.flatMap((service) => service.listRegistered(configDirectory)),
81
+ ),
82
+ )
83
+
84
+ expect(first).toBe(await realpath(workbaseRoot))
85
+ expect(registered).toEqual([first])
86
+ expect(
87
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
88
+ ).toEqual({ version: 1, workbases: [first] })
89
+ })
90
+
59
91
  test("preserves an unmanaged workbase OpenCode config", async () => {
60
92
  await write(root, "agency.json", '{"version":2}\n')
61
93
  await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "@effect/schema"
2
2
  import { TreeFormatter } from "@effect/schema"
3
3
  import { Data, Effect, Either } from "effect"
4
+ import { homedir } from "node:os"
4
5
  import { dirname, join, relative, resolve } from "node:path"
5
6
  import { FileSystemService } from "./FileSystemService"
6
7
  import { parseFrontmatter } from "../workbase/frontmatter"
@@ -9,6 +10,7 @@ import {
9
10
  PhaseFrontmatter,
10
11
  TaskFrontmatter,
11
12
  WorkbaseConfig,
13
+ WorkbaseRegistry,
12
14
  type Dependency,
13
15
  type EpicFrontmatter as EpicData,
14
16
  type PhaseFrontmatter as PhaseData,
@@ -34,6 +36,12 @@ class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
34
36
  readonly cause?: unknown
35
37
  }> {}
36
38
 
39
+ class WorkbaseRegistryError extends Data.TaggedError("WorkbaseRegistryError")<{
40
+ readonly message: string
41
+ readonly path: string
42
+ readonly cause?: unknown
43
+ }> {}
44
+
37
45
  interface ValidationIssue {
38
46
  readonly path: string
39
47
  readonly message: string
@@ -72,6 +80,45 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
72
80
  : { success: true, value: result.right }
73
81
  }
74
82
 
83
+ const registryPath = (configDirectory?: string) =>
84
+ join(
85
+ configDirectory ||
86
+ process.env.XDG_CONFIG_HOME ||
87
+ join(homedir(), ".config"),
88
+ "agency",
89
+ "workbases.json",
90
+ )
91
+
92
+ const readRegistry = (configDirectory?: string) =>
93
+ Effect.gen(function* () {
94
+ const fs = yield* FileSystemService
95
+ const path = registryPath(configDirectory)
96
+ if (!(yield* fs.exists(path))) {
97
+ return { path, registry: { version: 1, workbases: [] } as const }
98
+ }
99
+
100
+ const content = yield* fs.readFile(path)
101
+ let input: unknown
102
+ try {
103
+ input = JSON.parse(content)
104
+ } catch (cause) {
105
+ return yield* new WorkbaseRegistryError({
106
+ path,
107
+ message: `Invalid JSON in workbase registry ${path}`,
108
+ cause,
109
+ })
110
+ }
111
+
112
+ const decoded = decode(WorkbaseRegistry, input)
113
+ if (!decoded.success) {
114
+ return yield* new WorkbaseRegistryError({
115
+ path,
116
+ message: `Invalid workbase registry in ${path}:\n${decoded.error}`,
117
+ })
118
+ }
119
+ return { path, registry: decoded.value }
120
+ })
121
+
75
122
  const ensureWorkbaseAgents = (root: string) =>
76
123
  Effect.gen(function* () {
77
124
  const fs = yield* FileSystemService
@@ -293,6 +340,28 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
293
340
  return { root, config: decoded.value }
294
341
  }),
295
342
 
343
+ register: (startPath: string, configDirectory?: string) =>
344
+ Effect.gen(function* () {
345
+ const service = yield* WorkbaseService
346
+ const fs = yield* FileSystemService
347
+ const discovered = yield* service.discover(startPath)
348
+ const root = yield* fs.realPath(discovered)
349
+ const { path, registry } = yield* readRegistry(configDirectory)
350
+ if (registry.workbases.includes(root)) return root
351
+
352
+ yield* fs.createDirectory(dirname(path))
353
+ yield* fs.writeJSON(path, {
354
+ version: 1,
355
+ workbases: [...registry.workbases, root],
356
+ })
357
+ return root
358
+ }),
359
+
360
+ listRegistered: (configDirectory?: string) =>
361
+ readRegistry(configDirectory).pipe(
362
+ Effect.map(({ registry }) => registry.workbases),
363
+ ),
364
+
296
365
  validate: (startPath: string = process.cwd()) =>
297
366
  Effect.gen(function* () {
298
367
  const service = yield* WorkbaseService
@@ -5,7 +5,9 @@ import {
5
5
  EpicFrontmatter,
6
6
  PhaseFrontmatter,
7
7
  TaskFrontmatter,
8
+ WorkStatus,
8
9
  WorkbaseConfig,
10
+ WorkbaseRegistry,
9
11
  } from "./schemas"
10
12
 
11
13
  describe("body-of-work descriptions", () => {
@@ -76,6 +78,14 @@ describe("body-of-work descriptions", () => {
76
78
  })
77
79
 
78
80
  describe("work status", () => {
81
+ const supportedStatuses: Record<WorkStatus, true> = {
82
+ open: true,
83
+ working: true,
84
+ delegated: true,
85
+ done: true,
86
+ dropped: true,
87
+ }
88
+
79
89
  test("defaults execution units to open", () => {
80
90
  const task = Schema.decodeUnknownSync(TaskFrontmatter)({
81
91
  ticketUrl: "https://example.com/task",
@@ -95,17 +105,36 @@ describe("work status", () => {
95
105
  expect(phase.status).toBe("open")
96
106
  })
97
107
 
98
- test("accepts supported statuses and rejects other values", () => {
99
- const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
100
- repo: "agency",
101
- branch: "task/done",
102
- base: "main",
103
- pr: null,
104
- status: "done",
105
- })
106
- expect(phase.status).toBe("done")
108
+ test("accepts every supported status on tasks and phases", () => {
109
+ for (const status of Object.keys(supportedStatuses) as WorkStatus[]) {
110
+ expect(Schema.decodeUnknownSync(WorkStatus)(status)).toBe(status)
111
+
112
+ const task = Schema.decodeUnknownSync(TaskFrontmatter)({
113
+ ticketUrl: null,
114
+ repo: "agency",
115
+ branch: `task/${status}`,
116
+ base: "main",
117
+ pr: null,
118
+ status,
119
+ })
120
+ const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
121
+ repo: "agency",
122
+ branch: `phase/${status}`,
123
+ base: "main",
124
+ pr: null,
125
+ status,
126
+ })
127
+
128
+ expect("status" in task && task.status).toBe(status)
129
+ expect(phase.status).toBe(status)
130
+ }
131
+ })
132
+
133
+ test("rejects unsupported statuses on tasks and phases", () => {
134
+ expect(() => Schema.decodeUnknownSync(WorkStatus)("blocked")).toThrow()
107
135
  expect(() =>
108
- Schema.decodeUnknownSync(PhaseFrontmatter)({
136
+ Schema.decodeUnknownSync(TaskFrontmatter)({
137
+ ticketUrl: null,
109
138
  repo: "agency",
110
139
  branch: "task/invalid",
111
140
  base: "main",
@@ -113,6 +142,41 @@ describe("work status", () => {
113
142
  status: "blocked",
114
143
  }),
115
144
  ).toThrow()
145
+ expect(() =>
146
+ Schema.decodeUnknownSync(PhaseFrontmatter)({
147
+ repo: "agency",
148
+ branch: "phase/invalid",
149
+ base: "main",
150
+ pr: null,
151
+ status: "blocked",
152
+ }),
153
+ ).toThrow()
154
+ })
155
+ })
156
+
157
+ describe("workbase registry", () => {
158
+ test("accepts registered paths", () => {
159
+ expect(
160
+ Schema.decodeUnknownSync(WorkbaseRegistry)({
161
+ version: 1,
162
+ workbases: ["/work/one", "/work/two"],
163
+ }),
164
+ ).toEqual({ version: 1, workbases: ["/work/one", "/work/two"] })
165
+ })
166
+
167
+ test("rejects invalid versions and empty paths", () => {
168
+ expect(() =>
169
+ Schema.decodeUnknownSync(WorkbaseRegistry)({
170
+ version: 2,
171
+ workbases: [],
172
+ }),
173
+ ).toThrow()
174
+ expect(() =>
175
+ Schema.decodeUnknownSync(WorkbaseRegistry)({
176
+ version: 1,
177
+ workbases: [""],
178
+ }),
179
+ ).toThrow()
116
180
  })
117
181
  })
118
182
 
@@ -15,7 +15,13 @@ export const RepositoryReference = Schema.Struct({
15
15
  ref: NonEmptyString,
16
16
  })
17
17
 
18
- export const WorkStatus = Schema.Literal("open", "working", "done", "dropped")
18
+ export const WorkStatus = Schema.Literal(
19
+ "open",
20
+ "working",
21
+ "delegated",
22
+ "done",
23
+ "dropped",
24
+ )
19
25
 
20
26
  const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
21
27
 
@@ -28,6 +34,11 @@ export const WorkbaseConfig = Schema.Struct({
28
34
  worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
29
35
  })
30
36
 
37
+ export const WorkbaseRegistry = Schema.Struct({
38
+ version: Schema.Literal(1),
39
+ workbases: Schema.Array(NonEmptyString),
40
+ })
41
+
31
42
  export const Dependency = Schema.Struct({
32
43
  id: EntityId,
33
44
  dependsOn: Schema.optional(Schema.Array(EntityId)),
@@ -74,6 +85,7 @@ export const PhaseFrontmatter = Schema.Struct({
74
85
  })
75
86
 
76
87
  export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
88
+ export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
77
89
  export type Dependency = Schema.Schema.Type<typeof Dependency>
78
90
  export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
79
91
  export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
@@ -32,6 +32,11 @@ describe("work target choices", () => {
32
32
  path: "/workbase/tasks/standalone/TASK.md",
33
33
  data: { description: "Independent work" },
34
34
  },
35
+ {
36
+ id: "delegated",
37
+ path: "/workbase/tasks/delegated/TASK.md",
38
+ data: { status: "delegated" },
39
+ },
35
40
  ],
36
41
  [
37
42
  {
@@ -63,6 +68,7 @@ describe("work target choices", () => {
63
68
  " \x1b[31m⊘\x1b[0m \x1b[33m󰔚\x1b[0m unlisted",
64
69
  " \x1b[32m✓\x1b[0m \x1b[36m󰗡\x1b[0m single",
65
70
  "\x1b[2m○\x1b[0m \x1b[36m󰗡\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
71
+ "\x1b[35m↗\x1b[0m \x1b[36m󰗡\x1b[0m delegated",
66
72
  ])
67
73
  expect(choices.map((choice) => choice.target.kind)).toEqual([
68
74
  "epic",
@@ -72,6 +78,7 @@ describe("work target choices", () => {
72
78
  "phase",
73
79
  "task",
74
80
  "task",
81
+ "task",
75
82
  ])
76
83
  })
77
84
  })
@@ -58,6 +58,7 @@ export interface WorkTargetChoice {
58
58
  const statusIcons: Record<WorkStatus, string> = {
59
59
  open: "\x1b[2m○\x1b[0m",
60
60
  working: "\x1b[34m◐\x1b[0m",
61
+ delegated: "\x1b[35m↗\x1b[0m",
61
62
  done: "\x1b[32m✓\x1b[0m",
62
63
  dropped: "\x1b[31m⊘\x1b[0m",
63
64
  }
@@ -0,0 +1,71 @@
1
+ import { Effect } from "effect"
2
+ import { resolve } from "node:path"
3
+ import { FileSystemService } from "../services/FileSystemService"
4
+ import { WorkbaseService } from "../services/WorkbaseService"
5
+
6
+ export type PickWorkbase = (
7
+ workbases: readonly string[],
8
+ ) => Effect.Effect<string | null, Error>
9
+
10
+ export const pickWorkbase: PickWorkbase = (workbases) =>
11
+ Effect.tryPromise({
12
+ try: async () => {
13
+ const input = workbases
14
+ .map((workbase, index) => `${index}\t${workbase}`)
15
+ .join("\n")
16
+ const process = Bun.spawn(
17
+ ["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Workbase> "],
18
+ { stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
19
+ )
20
+ const [exitCode, output] = await Promise.all([
21
+ process.exited,
22
+ new Response(process.stdout).text(),
23
+ ])
24
+ if (exitCode === 1 || exitCode === 130) return null
25
+ if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
26
+ const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
27
+ return workbases[index] ?? null
28
+ },
29
+ catch: (cause) =>
30
+ new Error("Failed to select a workbase with fzf", { cause }),
31
+ })
32
+
33
+ export const resolveWorkbase = (
34
+ startPath: string,
35
+ log: (message: string) => void,
36
+ pick: PickWorkbase = pickWorkbase,
37
+ ) =>
38
+ Effect.gen(function* () {
39
+ const fs = yield* FileSystemService
40
+ const workbase = yield* WorkbaseService
41
+
42
+ return yield* workbase.discover(startPath).pipe(
43
+ Effect.catchTag("WorkbaseNotFoundError", () =>
44
+ Effect.gen(function* () {
45
+ const registered = yield* workbase.listRegistered()
46
+ if (registered.length === 0) {
47
+ return yield* Effect.fail(
48
+ new Error(
49
+ `No Agency workbase found from ${resolve(startPath)}. Register one with 'agency workbase add <path>'.`,
50
+ ),
51
+ )
52
+ }
53
+
54
+ const fzf = yield* fs.runCommand(["which", "fzf"], {
55
+ captureOutput: true,
56
+ })
57
+ if (fzf.exitCode !== 0) {
58
+ for (const path of registered) log(path)
59
+ return yield* Effect.fail(
60
+ new Error(
61
+ "fzf is required to select a workbase; install fzf or run Agency from a registered workbase",
62
+ ),
63
+ )
64
+ }
65
+
66
+ const selected = yield* pick(registered)
67
+ return selected ? yield* workbase.discover(selected) : null
68
+ }),
69
+ ),
70
+ )
71
+ })