@markjaquith/agency 2.17.0 → 2.19.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
@@ -342,8 +342,11 @@ conditions remain visible in `warnings` or `unresolved` with a suggested action.
342
342
 
343
343
  ```text
344
344
  agency init [path] [--json]
345
- agency workbase add <path> [--json]
345
+ agency workbase add <path> [--name <name>] [--json]
346
346
  agency workbase list [--json]
347
+ agency workbase remove <id|name|path> [--json]
348
+ agency workbase prune [--json]
349
+ agency workbase default [<id|name> | --clear] [--json]
347
350
  agency integration status [--json]
348
351
  agency integration sync [--json]
349
352
  agency repo add <alias> <remote> [--json]
@@ -353,6 +356,9 @@ agency repo list [--json]
353
356
 
354
357
  Registered workbases are stored in
355
358
  `$XDG_CONFIG_HOME/agency/workbases.json` (or `~/.config/agency/workbases.json`).
359
+ Each registration has a stable ID and may have a unique name. A default workbase
360
+ is used when the current directory is outside every workbase. `prune` removes
361
+ registrations whose workbase configuration no longer exists.
356
362
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
357
363
  repository. Alias names are then used by all documents and commands.
358
364
 
@@ -365,7 +371,7 @@ status, validation, graph export, reconciliation, and PR creation.
365
371
  ```text
366
372
  agency epic create <id> --ticket-url <url> [--description <text>] [--json]
367
373
  --repo <alias>:<ref> [--repo <alias>:<ref>...]
368
- agency epic list [--json]
374
+ agency epic list [filters] [--json]
369
375
  agency epic show <id> [--json]
370
376
  ```
371
377
 
@@ -404,14 +410,22 @@ agency task create <id> --multi-phase
404
410
 
405
411
  Agency never prompts when `--no-input` is set or stdin/stderr are not TTYs.
406
412
  `--json` also disables prompts and selectors, even when a TTY is available.
407
- Commands with explicit inputs continue normally. `task new` fails immediately;
408
- `work` requires an explicit directory, task ID, or `--epic` and must run from a
409
- workbase; `validate` requires an explicit path or must run from a workbase.
413
+ Commands with explicit inputs continue normally. `--workbase <id|name|path>`
414
+ selects a workbase directly; `--cwd <path>` performs the same inference Agency
415
+ would perform from that directory. These options are mutually exclusive and take
416
+ precedence over ambient cwd and the configured default.
417
+
418
+ Targeted commands accept `--epic`, `--task`, and `--phase` where those entity
419
+ kinds apply. A phase selector requires a task selector. Entity selectors cannot
420
+ be mixed with positional target IDs, and an epic selector cannot be mixed with
421
+ task or phase selectors. This makes commands such as
422
+ `agency phase status done --task ship --phase release --workbase primary --no-input`
423
+ fully independent of process cwd and prompts.
410
424
 
411
425
  Inspect tasks:
412
426
 
413
427
  ```text
414
- agency task list [--json]
428
+ agency task list [filters] [--json]
415
429
  agency task show <id> [--json]
416
430
  agency task status <id> <open|done|dropped> [--json]
417
431
  ```
@@ -438,7 +452,7 @@ agency phase create <task-id> <phase-id>
438
452
  [--description <text>] [--reference <alias>:<ref>...]
439
453
  [--depends-on <phase-id>...] [--first-phase <phase-id>] [--json]
440
454
 
441
- agency phase list <task-id> [--json]
455
+ agency phase list <task-id> [filters] [--json]
442
456
  agency phase show <task-id> <phase-id> [--json]
443
457
  agency phase status <task-id> <phase-id> <open|done|dropped> [--json]
444
458
  ```
@@ -456,6 +470,13 @@ work before changing its outcome.
456
470
  Delegation is now explicit: the claimant identifies the orchestrator and the
457
471
  runner identifies the assigned agent.
458
472
 
473
+ Human list output is a compact table with lifecycle, readiness, parent,
474
+ repository, branch, recorded PR, and worktree state where applicable. List and
475
+ status views accept composable `--status <status>` and `--repository <alias>`
476
+ filters, plus `--ready`, `--blocked`, `--pr`, and `--no-pr`. Status and repository
477
+ filters are repeatable. Rows follow task and phase declaration order; plain text
478
+ labels remain complete without color or icon fonts.
479
+
459
480
  ### Claims
460
481
 
461
482
  Claim mutations require the SHA-256 revision exposed by `agency context` or
@@ -541,7 +562,7 @@ the owning `TASK.md` or `PHASE.md`.
541
562
  ### Status and Validation
542
563
 
543
564
  ```text
544
- agency status [--json]
565
+ agency status [filters] [--json]
545
566
  agency validate [path] [--json]
546
567
  ```
547
568
 
package/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
 
3
3
  import { Effect, Either, Layer } from "effect"
4
+ import { join, resolve } from "node:path"
4
5
  import { parseCli } from "./src/cli-parser"
5
6
  import { init, help as initHelp } from "./src/commands/init"
6
7
  import { task, help as taskHelp } from "./src/commands/task"
@@ -72,11 +73,9 @@ const CliLayer = Layer.mergeAll(
72
73
  /**
73
74
  * Run a command Effect with all services provided
74
75
  */
75
- async function runCommand<E>(
76
- effect: Effect.Effect<void, E, any>,
77
- ): Promise<void> {
76
+ async function runEffect<A, E>(effect: Effect.Effect<A, E, any>): Promise<A> {
78
77
  const providedEffect = Effect.provide(effect, CliLayer) as Effect.Effect<
79
- void,
78
+ A,
80
79
  E,
81
80
  never
82
81
  >
@@ -103,8 +102,44 @@ async function runCommand<E>(
103
102
  ),
104
103
  )
105
104
  if (Either.isLeft(result)) throw result.left
105
+ return result.right
106
106
  }
107
107
 
108
+ const runCommand = <E>(effect: Effect.Effect<void, E, any>) => runEffect(effect)
109
+
110
+ const resolveInvocationCwd = (
111
+ commandName: string,
112
+ options: Record<string, any>,
113
+ ) =>
114
+ runEffect(
115
+ Effect.gen(function* () {
116
+ if (
117
+ options.help ||
118
+ commandName === "init" ||
119
+ commandName === "workbase"
120
+ ) {
121
+ return resolve(options.cwd ?? process.cwd())
122
+ }
123
+ const workbases = yield* WorkbaseService
124
+ if (options.workbase) {
125
+ return yield* workbases.resolveRegistered(options.workbase)
126
+ }
127
+ if (options.cwd) {
128
+ const selectedCwd = resolve(options.cwd)
129
+ yield* workbases.discover(selectedCwd)
130
+ return selectedCwd
131
+ }
132
+ return yield* workbases.discover(process.cwd()).pipe(
133
+ Effect.as(process.cwd()),
134
+ Effect.catchTag("WorkbaseNotFoundError", () =>
135
+ workbases
136
+ .getDefault()
137
+ .pipe(Effect.map((entry) => entry?.path ?? process.cwd())),
138
+ ),
139
+ )
140
+ }),
141
+ )
142
+
108
143
  // Read version from package.json
109
144
  const packageJson = await Bun.file(
110
145
  new URL("./package.json", import.meta.url),
@@ -129,6 +164,7 @@ const commands: Record<string, Command> = {
129
164
  json: options.json,
130
165
  silent: options.silent,
131
166
  verbose: options.verbose,
167
+ cwd: options.cwd,
132
168
  }),
133
169
  )
134
170
  },
@@ -146,6 +182,7 @@ const commands: Record<string, Command> = {
146
182
  json: options.json,
147
183
  silent: options.silent,
148
184
  verbose: options.verbose,
185
+ cwd: options.cwd,
149
186
  }),
150
187
  )
151
188
  },
@@ -164,6 +201,7 @@ const commands: Record<string, Command> = {
164
201
  json: options.json,
165
202
  silent: options.silent,
166
203
  verbose: options.verbose,
204
+ cwd: options.cwd,
167
205
  }),
168
206
  )
169
207
  },
@@ -180,6 +218,7 @@ const commands: Record<string, Command> = {
180
218
  json: options.json,
181
219
  silent: options.silent,
182
220
  verbose: options.verbose,
221
+ cwd: options.cwd,
183
222
  }),
184
223
  )
185
224
  },
@@ -198,8 +237,14 @@ const commands: Record<string, Command> = {
198
237
  description: options.description,
199
238
  repos: options.repo,
200
239
  json: options.json,
240
+ statuses: options.status,
241
+ repositories: options.repository,
242
+ ready: options.ready,
243
+ blocked: options.blocked,
244
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
201
245
  silent: options.silent,
202
246
  verbose: options.verbose,
247
+ cwd: options.cwd,
203
248
  }),
204
249
  )
205
250
  },
@@ -220,6 +265,7 @@ const commands: Record<string, Command> = {
220
265
  json: options.json,
221
266
  silent: options.silent,
222
267
  verbose: options.verbose,
268
+ cwd: options.cwd,
223
269
  }),
224
270
  )
225
271
  },
@@ -239,8 +285,14 @@ const commands: Record<string, Command> = {
239
285
  dependsOn: options["depends-on"],
240
286
  firstPhase: options["first-phase"],
241
287
  json: options.json,
288
+ statuses: options.status,
289
+ repositories: options.repository,
290
+ ready: options.ready,
291
+ blocked: options.blocked,
292
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
242
293
  silent: options.silent,
243
294
  verbose: options.verbose,
295
+ cwd: options.cwd,
244
296
  }),
245
297
  )
246
298
  },
@@ -258,6 +310,7 @@ const commands: Record<string, Command> = {
258
310
  json: options.json,
259
311
  silent: options.silent,
260
312
  verbose: options.verbose,
313
+ cwd: options.cwd,
261
314
  }),
262
315
  )
263
316
  },
@@ -273,8 +326,11 @@ const commands: Record<string, Command> = {
273
326
  subcommand: args[0],
274
327
  args: args.slice(1),
275
328
  json: options.json,
329
+ name: options.name,
330
+ clear: options.clear,
276
331
  silent: options.silent,
277
332
  verbose: options.verbose,
333
+ cwd: options.cwd,
278
334
  }),
279
335
  )
280
336
  },
@@ -291,6 +347,7 @@ const commands: Record<string, Command> = {
291
347
  json: options.json,
292
348
  silent: options.silent,
293
349
  verbose: options.verbose,
350
+ cwd: options.cwd,
294
351
  }),
295
352
  )
296
353
  },
@@ -308,6 +365,7 @@ const commands: Record<string, Command> = {
308
365
  silent: options.silent,
309
366
  verbose: options.verbose,
310
367
  json: options.json,
368
+ cwd: options.cwd,
311
369
  }),
312
370
  )
313
371
  },
@@ -331,9 +389,15 @@ const commands: Record<string, Command> = {
331
389
  base: options.base,
332
390
  multiPhase: options["multi-phase"],
333
391
  json: options.json,
392
+ statuses: options.status,
393
+ repositories: options.repository,
394
+ ready: options.ready,
395
+ blocked: options.blocked,
396
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
334
397
  silent: options.silent,
335
398
  verbose: options.verbose,
336
399
  inputAllowed: options.inputAllowed,
400
+ cwd: options.cwd,
337
401
  }),
338
402
  )
339
403
  },
@@ -357,6 +421,9 @@ const commands: Record<string, Command> = {
357
421
  claude: options.claude,
358
422
  force: options.force,
359
423
  inputAllowed: options.inputAllowed,
424
+ cwd: options.cwd,
425
+ taskId: options.task,
426
+ phaseId: options.phase,
360
427
  }),
361
428
  )
362
429
  },
@@ -385,6 +452,12 @@ const commands: Record<string, Command> = {
385
452
  silent: options.silent,
386
453
  verbose: options.verbose,
387
454
  json: options.json,
455
+ statuses: options.status,
456
+ repositories: options.repository,
457
+ ready: options.ready,
458
+ blocked: options.blocked,
459
+ pr: options.pr ? true : options["no-pr"] ? false : undefined,
460
+ cwd: options.cwd,
388
461
  }),
389
462
  )
390
463
  },
@@ -402,6 +475,7 @@ const commands: Record<string, Command> = {
402
475
  verbose: options.verbose,
403
476
  json: options.json,
404
477
  inputAllowed: options.inputAllowed,
478
+ cwd: options.cwd,
405
479
  }),
406
480
  )
407
481
  },
@@ -414,11 +488,18 @@ const commands: Record<string, Command> = {
414
488
  }
415
489
  await runCommand(
416
490
  context({
417
- target: args[0],
491
+ target: options.epic
492
+ ? join("epics", options.epic)
493
+ : options.phase
494
+ ? join("tasks", options.task, "phases", options.phase)
495
+ : options.task
496
+ ? join("tasks", options.task)
497
+ : args[0],
418
498
  compact: options.compact,
419
499
  json: options.json,
420
500
  silent: options.silent,
421
501
  verbose: options.verbose,
502
+ cwd: options.cwd,
422
503
  }),
423
504
  )
424
505
  },
@@ -441,6 +522,7 @@ const commands: Record<string, Command> = {
441
522
  include: options.include,
442
523
  silent: options.silent,
443
524
  verbose: options.verbose,
525
+ cwd: options.cwd,
444
526
  }),
445
527
  )
446
528
  },
@@ -458,6 +540,7 @@ const commands: Record<string, Command> = {
458
540
  json: options.json,
459
541
  silent: options.silent,
460
542
  verbose: options.verbose,
543
+ cwd: options.cwd,
461
544
  }),
462
545
  )
463
546
  },
@@ -497,6 +580,8 @@ Global Options:
497
580
  -s, --silent Suppress output messages
498
581
  -v, --verbose Show verbose output including detailed debugging info
499
582
  --no-input Never open an interactive prompt or selector
583
+ --workbase <selector> Use a registered workbase ID, name, or path
584
+ --cwd <path> Resolve context from this directory
500
585
 
501
586
  Examples:
502
587
  agency init # Initialize the current directory
@@ -538,15 +623,16 @@ try {
538
623
  !values.json &&
539
624
  !values["no-input"] &&
540
625
  Boolean(process.stdin.isTTY && process.stderr.isTTY)
626
+ const cwd = await resolveInvocationCwd(commandName, values)
541
627
  if (values.json || (values.jsonl && values.help)) {
542
628
  const result = await collectCommandResult(() =>
543
- command.run(commandArgs, { ...values, inputAllowed }),
629
+ command.run(commandArgs, { ...values, cwd, inputAllowed }),
544
630
  )
545
631
  writeEnvelope(successEnvelope(result))
546
632
  } else if (values.jsonl) {
547
- await command.run(commandArgs, { ...values, inputAllowed: false })
633
+ await command.run(commandArgs, { ...values, cwd, inputAllowed: false })
548
634
  } else {
549
- await command.run(commandArgs, { ...values, inputAllowed })
635
+ await command.run(commandArgs, { ...values, cwd, inputAllowed })
550
636
  }
551
637
  } catch (error) {
552
638
  if (machineMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.17.0",
3
+ "version": "2.19.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -60,13 +60,19 @@ agency init [path]
60
60
  agency integration sync
61
61
  ```
62
62
 
63
- Register known workbases so `agency work` can select one when run elsewhere:
63
+ Register and name known workbases so commands can select one from anywhere:
64
64
 
65
65
  ```bash
66
- agency workbase add <path>
66
+ agency workbase add <path> [--name <name>]
67
67
  agency workbase list
68
+ agency workbase default [<id|name> | --clear]
69
+ agency workbase remove <id|name|path>
70
+ agency workbase prune
68
71
  ```
69
72
 
73
+ Use `--workbase <id|name|path>` to bypass cwd inference, or `--cwd <path>` to
74
+ infer context from a specific directory. The options are mutually exclusive.
75
+
70
76
  ## Repository Aliases
71
77
 
72
78
  Add a remote as an Agency-managed bare repository:
@@ -261,8 +267,8 @@ Use `--json` when diagnostics will be consumed programmatically. Resolve all
261
267
  validation errors before materializing worktrees or creating PRs. Validation
262
268
  checks schemas, aliases, backlinks, phase directories, duplicate references,
263
269
  duplicate writable branch ownership, unknown dependencies, and dependency cycles.
264
- Outside a workbase, omitting path opens the registered-workbase picker.
265
- With `--no-input` or without a TTY, pass a path or run from a workbase instead.
270
+ Outside a workbase, Agency uses the configured default or opens the registered
271
+ workbase picker. With `--no-input` or without a TTY, pass `--workbase` or `--cwd`.
266
272
 
267
273
  ## Worktrees And Agent Launch
268
274
 
@@ -270,6 +276,7 @@ With `--no-input` or without a TTY, pass a path or run from a workbase instead.
270
276
  agency work
271
277
  agency work <directory>
272
278
  agency work --epic <epic-id>
279
+ agency work --task <task-id> [--phase <phase-id>] --workbase <selector>
273
280
  ```
274
281
 
275
282
  Use `--opencode` or `--claude` to require a specific agent. This command fetches
@@ -278,8 +285,8 @@ replaces the current process with the selected agent. With no directory it opens
278
285
  an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
279
286
  directory, to infer the nearest epic, task, or phase.
280
287
  Outside a workbase, it first opens a picker containing registered workbases.
281
- With `--no-input` or without a TTY, run from a workbase and provide an explicit
282
- directory, task ID, or `--epic` so no picker is needed.
288
+ With `--no-input` or without a TTY, provide an explicit workbase or cwd and an
289
+ `--epic`, `--task`, or `--task` plus `--phase` selector so no picker is needed.
283
290
 
284
291
  Epic and multi-phase task targets are orchestration sessions launched beside
285
292
  their documents. Single-phase tasks and phases are execution sessions launched
@@ -10,9 +10,9 @@ describe("strict CLI parsing", () => {
10
10
  expectUsageError(["task", "list", "--josn"], "agency task")
11
11
  expectUsageError(
12
12
  ["task", "list", "--repo", "agency"],
13
- "agency task list [--json]",
13
+ "agency task list [filters] [--json]",
14
14
  )
15
- expectUsageError(["status", "--draft"], "agency status [--json]")
15
+ expectUsageError(["status", "--draft"], "agency status [filters] [--json]")
16
16
  })
17
17
 
18
18
  test("rejects duplicate scalar, boolean, and single-value multiple options", () => {
@@ -77,6 +77,41 @@ describe("strict CLI parsing", () => {
77
77
  })
78
78
  })
79
79
 
80
+ test("parses composable view filters", () => {
81
+ expect(
82
+ parseCli([
83
+ "task",
84
+ "list",
85
+ "--status",
86
+ "open",
87
+ "--status",
88
+ "working",
89
+ "--repository",
90
+ "agency",
91
+ "--ready",
92
+ "--pr",
93
+ ]).values,
94
+ ).toMatchObject({
95
+ status: ["open", "working"],
96
+ repository: ["agency"],
97
+ ready: true,
98
+ pr: true,
99
+ })
100
+ expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
101
+ })
102
+
103
+ test("validates view filter values and conflicts", () => {
104
+ expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
105
+ "Invalid '--status' value",
106
+ )
107
+ expect(() =>
108
+ parseCli(["phase", "list", "task", "--ready", "--blocked"]),
109
+ ).toThrow("cannot be combined")
110
+ expect(() => parseCli(["status", "--pr", "--no-pr"])).toThrow(
111
+ "cannot be combined",
112
+ )
113
+ })
114
+
80
115
  test("enforces exact maximum positional arity for every leaf command", () => {
81
116
  for (const [args, usage] of [
82
117
  [["init", "one", "two"], "agency init"],
@@ -384,6 +419,80 @@ describe("strict CLI parsing", () => {
384
419
  ).toBe(true)
385
420
  })
386
421
 
422
+ test("normalizes explicit entity selectors into command targets", () => {
423
+ expect(
424
+ parseCli([
425
+ "phase",
426
+ "status",
427
+ "done",
428
+ "--task",
429
+ "ship",
430
+ "--phase",
431
+ "release",
432
+ ]),
433
+ ).toMatchObject({
434
+ commandName: "phase",
435
+ args: ["status", "ship", "release", "done"],
436
+ })
437
+ expect(
438
+ parseCli([
439
+ "claim",
440
+ "--task",
441
+ "ship",
442
+ "--phase",
443
+ "release",
444
+ "--claimant",
445
+ "agent",
446
+ "--runner",
447
+ "opencode",
448
+ "--session-id",
449
+ "session",
450
+ "--revision",
451
+ "0".repeat(64),
452
+ ]),
453
+ ).toMatchObject({ args: ["ship", "release"] })
454
+ expect(parseCli(["context", "--epic", "delivery"]).values.epic).toBe(
455
+ "delivery",
456
+ )
457
+ })
458
+
459
+ test("enforces explicit selector precedence and exclusions", () => {
460
+ expect(() =>
461
+ parseCli(["task", "show", "positional", "--task", "explicit"]),
462
+ ).toThrow("cannot be combined with positional target IDs")
463
+ expect(() => parseCli(["context", "--phase", "release"])).toThrow(
464
+ "--phase' requires '--task",
465
+ )
466
+ expect(() =>
467
+ parseCli(["work", "--epic", "delivery", "--task", "ship"]),
468
+ ).toThrow("cannot be combined with '--task'")
469
+ expect(() =>
470
+ parseCli(["status", "--workbase", "primary", "--cwd", "/tmp"]),
471
+ ).toThrow("--workbase' and '--cwd' cannot be combined")
472
+ })
473
+
474
+ test("accepts explicit workbase context before or after commands", () => {
475
+ expect(
476
+ parseCli(["--workbase", "primary", "task", "list", "--no-input"]).values
477
+ .workbase,
478
+ ).toBe("primary")
479
+ expect(parseCli(["status", "--cwd", "/tmp"]).values.cwd).toBe("/tmp")
480
+ expect(
481
+ parseCli(["--workbase=primary", "task", "list"]).values.workbase,
482
+ ).toBe("primary")
483
+ expect(parseCli(["--cwd=/tmp", "status"]).values.cwd).toBe("/tmp")
484
+ })
485
+
486
+ test("rejects empty selectors", () => {
487
+ for (const args of [
488
+ ["status", "--workbase="],
489
+ ["status", "--cwd="],
490
+ ["context", "--task="],
491
+ ]) {
492
+ expect(() => parseCli(args)).toThrow("requires a non-empty value")
493
+ }
494
+ })
495
+
387
496
  test("accepts grouped global short options before a command", () => {
388
497
  const parsed = parseCli(["-sh", "task"])
389
498
  expect(parsed.values).toMatchObject({ silent: true, help: true })