@markjaquith/agency 2.5.0 → 2.7.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
@@ -230,7 +230,7 @@ agency repo link backend ~/Dev/backend
230
230
  agency task new
231
231
 
232
232
  agency validate
233
- agency work refresh-copy
233
+ agency work tasks/refresh-copy
234
234
  agency pr create refresh-copy
235
235
  ```
236
236
 
@@ -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
 
@@ -346,19 +350,21 @@ before moving files, refuses dirty worktrees, and preserves branches.
346
350
  ### Work and Pull Requests
347
351
 
348
352
  ```text
349
- agency work [<task-id> [phase-id] | --epic <epic-id>] [--opencode | --claude]
353
+ agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
350
354
  agency pr create <task-id> [phase-id] [--draft] [--json]
351
355
  ```
352
356
 
353
- `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.
357
+ `agency work` presents the full hierarchy in `fzf`. Pass a directory, including
358
+ `.` for the current directory, to infer its epic, task, or phase. Outside a
359
+ workbase, Agency 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 directory.
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
359
365
  worktrees under `code/`, and launch an execution agent in the writable checkout
360
- with absolute context paths. Explicit task, phase, and `--epic` targets override
361
- current-directory inference.
366
+ with absolute context paths. An explicit directory or `--epic` target bypasses
367
+ the hierarchy chooser.
362
368
 
363
369
  Each writable `(repo, branch)` pair may belong to only one task or phase. Agency
364
370
  validation reports duplicate ownership, and `agency work` checks Git's worktree
@@ -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) {
@@ -221,11 +239,13 @@ const commands: Record<string, Command> = {
221
239
  console.log(workHelp)
222
240
  return
223
241
  }
242
+ if (args.length > 1) {
243
+ throw new Error("Usage: agency work [<directory> | --epic <epic-id>]")
244
+ }
224
245
 
225
246
  await runCommand(
226
247
  work({
227
- taskId: args[0],
228
- phaseId: args[1],
248
+ directory: args[0],
229
249
  epicId: options.epic,
230
250
  silent: options.silent,
231
251
  verbose: options.verbose,
@@ -251,13 +271,14 @@ const commands: Record<string, Command> = {
251
271
  },
252
272
  },
253
273
  validate: {
254
- run: async (_args: string[], options: Record<string, any>) => {
274
+ run: async (args: string[], options: Record<string, any>) => {
255
275
  if (options.help) {
256
276
  console.log(validateHelp)
257
277
  return
258
278
  }
259
279
  await runCommand(
260
280
  validate({
281
+ path: args[0],
261
282
  silent: options.silent,
262
283
  verbose: options.verbose,
263
284
  json: options.json,
@@ -275,15 +296,16 @@ Usage: agency <command> [options]
275
296
 
276
297
  Commands:
277
298
  init [path] Initialize an Agency workbase
299
+ workbase <subcommand> Manage registered workbases
278
300
  epic <subcommand> Manage epics
279
301
  phase <subcommand> Manage task phases
280
302
  archive <type> Archive a work item
281
303
  task <subcommand> Manage tasks
282
- work [task] [phase] Work on an epic, task, or phase
304
+ work [directory] Work on an epic, task, or phase
283
305
  pr create Create a pull request for an execution unit
284
306
  repo <subcommand> Manage workbase repositories
285
307
  status Show status for the current workbase
286
- validate Validate the current workbase
308
+ validate [path] Validate a workbase
287
309
 
288
310
  Global Options:
289
311
  -h, --help Show help for a command
@@ -294,7 +316,7 @@ Global Options:
294
316
  Examples:
295
317
  agency init # Initialize the current directory
296
318
  agency task list # List tasks
297
- agency work refresh-cli-copy # Start working on a task
319
+ agency work tasks/refresh-cli-copy # Start working on a task
298
320
 
299
321
  For more information about a command, run:
300
322
  agency <command> --help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.5.0",
3
+ "version": "2.7.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,27 +233,29 @@ 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
 
239
247
  ```bash
240
248
  agency work
241
- agency work <task-id> [phase-id]
249
+ agency work <directory>
242
250
  agency work --epic <epic-id>
243
251
  ```
244
252
 
245
253
  Use `--opencode` or `--claude` to require a specific agent. This command fetches
246
254
  repositories for execution targets, creates or reuses their worktrees, and
247
- replaces the current process with the selected agent. It infers the nearest
248
- epic, task, or phase from the current directory; otherwise it opens an `fzf`
249
- picker containing the workbase hierarchy.
255
+ replaces the current process with the selected agent. With no directory it opens
256
+ an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
257
+ directory, to infer the nearest epic, task, or phase.
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,
@@ -39,6 +39,9 @@ describe("init command", () => {
39
39
  expect(
40
40
  await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
41
41
  ).toContain('"path": "../tasks"')
42
+ expect(
43
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
44
+ ).toContain(`"${join(root, "tasks")}/*": "allow"`)
42
45
  })
43
46
 
44
47
  test("preserves existing gitignore entries", async () => {
@@ -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