@markjaquith/agency 2.17.0 → 2.18.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
 
@@ -404,9 +410,17 @@ 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
 
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
  },
@@ -200,6 +239,7 @@ const commands: Record<string, Command> = {
200
239
  json: options.json,
201
240
  silent: options.silent,
202
241
  verbose: options.verbose,
242
+ cwd: options.cwd,
203
243
  }),
204
244
  )
205
245
  },
@@ -220,6 +260,7 @@ const commands: Record<string, Command> = {
220
260
  json: options.json,
221
261
  silent: options.silent,
222
262
  verbose: options.verbose,
263
+ cwd: options.cwd,
223
264
  }),
224
265
  )
225
266
  },
@@ -241,6 +282,7 @@ const commands: Record<string, Command> = {
241
282
  json: options.json,
242
283
  silent: options.silent,
243
284
  verbose: options.verbose,
285
+ cwd: options.cwd,
244
286
  }),
245
287
  )
246
288
  },
@@ -258,6 +300,7 @@ const commands: Record<string, Command> = {
258
300
  json: options.json,
259
301
  silent: options.silent,
260
302
  verbose: options.verbose,
303
+ cwd: options.cwd,
261
304
  }),
262
305
  )
263
306
  },
@@ -273,8 +316,11 @@ const commands: Record<string, Command> = {
273
316
  subcommand: args[0],
274
317
  args: args.slice(1),
275
318
  json: options.json,
319
+ name: options.name,
320
+ clear: options.clear,
276
321
  silent: options.silent,
277
322
  verbose: options.verbose,
323
+ cwd: options.cwd,
278
324
  }),
279
325
  )
280
326
  },
@@ -291,6 +337,7 @@ const commands: Record<string, Command> = {
291
337
  json: options.json,
292
338
  silent: options.silent,
293
339
  verbose: options.verbose,
340
+ cwd: options.cwd,
294
341
  }),
295
342
  )
296
343
  },
@@ -308,6 +355,7 @@ const commands: Record<string, Command> = {
308
355
  silent: options.silent,
309
356
  verbose: options.verbose,
310
357
  json: options.json,
358
+ cwd: options.cwd,
311
359
  }),
312
360
  )
313
361
  },
@@ -334,6 +382,7 @@ const commands: Record<string, Command> = {
334
382
  silent: options.silent,
335
383
  verbose: options.verbose,
336
384
  inputAllowed: options.inputAllowed,
385
+ cwd: options.cwd,
337
386
  }),
338
387
  )
339
388
  },
@@ -357,6 +406,9 @@ const commands: Record<string, Command> = {
357
406
  claude: options.claude,
358
407
  force: options.force,
359
408
  inputAllowed: options.inputAllowed,
409
+ cwd: options.cwd,
410
+ taskId: options.task,
411
+ phaseId: options.phase,
360
412
  }),
361
413
  )
362
414
  },
@@ -385,6 +437,7 @@ const commands: Record<string, Command> = {
385
437
  silent: options.silent,
386
438
  verbose: options.verbose,
387
439
  json: options.json,
440
+ cwd: options.cwd,
388
441
  }),
389
442
  )
390
443
  },
@@ -402,6 +455,7 @@ const commands: Record<string, Command> = {
402
455
  verbose: options.verbose,
403
456
  json: options.json,
404
457
  inputAllowed: options.inputAllowed,
458
+ cwd: options.cwd,
405
459
  }),
406
460
  )
407
461
  },
@@ -414,11 +468,18 @@ const commands: Record<string, Command> = {
414
468
  }
415
469
  await runCommand(
416
470
  context({
417
- target: args[0],
471
+ target: options.epic
472
+ ? join("epics", options.epic)
473
+ : options.phase
474
+ ? join("tasks", options.task, "phases", options.phase)
475
+ : options.task
476
+ ? join("tasks", options.task)
477
+ : args[0],
418
478
  compact: options.compact,
419
479
  json: options.json,
420
480
  silent: options.silent,
421
481
  verbose: options.verbose,
482
+ cwd: options.cwd,
422
483
  }),
423
484
  )
424
485
  },
@@ -441,6 +502,7 @@ const commands: Record<string, Command> = {
441
502
  include: options.include,
442
503
  silent: options.silent,
443
504
  verbose: options.verbose,
505
+ cwd: options.cwd,
444
506
  }),
445
507
  )
446
508
  },
@@ -458,6 +520,7 @@ const commands: Record<string, Command> = {
458
520
  json: options.json,
459
521
  silent: options.silent,
460
522
  verbose: options.verbose,
523
+ cwd: options.cwd,
461
524
  }),
462
525
  )
463
526
  },
@@ -497,6 +560,8 @@ Global Options:
497
560
  -s, --silent Suppress output messages
498
561
  -v, --verbose Show verbose output including detailed debugging info
499
562
  --no-input Never open an interactive prompt or selector
563
+ --workbase <selector> Use a registered workbase ID, name, or path
564
+ --cwd <path> Resolve context from this directory
500
565
 
501
566
  Examples:
502
567
  agency init # Initialize the current directory
@@ -538,15 +603,16 @@ try {
538
603
  !values.json &&
539
604
  !values["no-input"] &&
540
605
  Boolean(process.stdin.isTTY && process.stderr.isTTY)
606
+ const cwd = await resolveInvocationCwd(commandName, values)
541
607
  if (values.json || (values.jsonl && values.help)) {
542
608
  const result = await collectCommandResult(() =>
543
- command.run(commandArgs, { ...values, inputAllowed }),
609
+ command.run(commandArgs, { ...values, cwd, inputAllowed }),
544
610
  )
545
611
  writeEnvelope(successEnvelope(result))
546
612
  } else if (values.jsonl) {
547
- await command.run(commandArgs, { ...values, inputAllowed: false })
613
+ await command.run(commandArgs, { ...values, cwd, inputAllowed: false })
548
614
  } else {
549
- await command.run(commandArgs, { ...values, inputAllowed })
615
+ await command.run(commandArgs, { ...values, cwd, inputAllowed })
550
616
  }
551
617
  } catch (error) {
552
618
  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.18.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
@@ -384,6 +384,80 @@ describe("strict CLI parsing", () => {
384
384
  ).toBe(true)
385
385
  })
386
386
 
387
+ test("normalizes explicit entity selectors into command targets", () => {
388
+ expect(
389
+ parseCli([
390
+ "phase",
391
+ "status",
392
+ "done",
393
+ "--task",
394
+ "ship",
395
+ "--phase",
396
+ "release",
397
+ ]),
398
+ ).toMatchObject({
399
+ commandName: "phase",
400
+ args: ["status", "ship", "release", "done"],
401
+ })
402
+ expect(
403
+ parseCli([
404
+ "claim",
405
+ "--task",
406
+ "ship",
407
+ "--phase",
408
+ "release",
409
+ "--claimant",
410
+ "agent",
411
+ "--runner",
412
+ "opencode",
413
+ "--session-id",
414
+ "session",
415
+ "--revision",
416
+ "0".repeat(64),
417
+ ]),
418
+ ).toMatchObject({ args: ["ship", "release"] })
419
+ expect(parseCli(["context", "--epic", "delivery"]).values.epic).toBe(
420
+ "delivery",
421
+ )
422
+ })
423
+
424
+ test("enforces explicit selector precedence and exclusions", () => {
425
+ expect(() =>
426
+ parseCli(["task", "show", "positional", "--task", "explicit"]),
427
+ ).toThrow("cannot be combined with positional target IDs")
428
+ expect(() => parseCli(["context", "--phase", "release"])).toThrow(
429
+ "--phase' requires '--task",
430
+ )
431
+ expect(() =>
432
+ parseCli(["work", "--epic", "delivery", "--task", "ship"]),
433
+ ).toThrow("cannot be combined with '--task'")
434
+ expect(() =>
435
+ parseCli(["status", "--workbase", "primary", "--cwd", "/tmp"]),
436
+ ).toThrow("--workbase' and '--cwd' cannot be combined")
437
+ })
438
+
439
+ test("accepts explicit workbase context before or after commands", () => {
440
+ expect(
441
+ parseCli(["--workbase", "primary", "task", "list", "--no-input"]).values
442
+ .workbase,
443
+ ).toBe("primary")
444
+ expect(parseCli(["status", "--cwd", "/tmp"]).values.cwd).toBe("/tmp")
445
+ expect(
446
+ parseCli(["--workbase=primary", "task", "list"]).values.workbase,
447
+ ).toBe("primary")
448
+ expect(parseCli(["--cwd=/tmp", "status"]).values.cwd).toBe("/tmp")
449
+ })
450
+
451
+ test("rejects empty selectors", () => {
452
+ for (const args of [
453
+ ["status", "--workbase="],
454
+ ["status", "--cwd="],
455
+ ["context", "--task="],
456
+ ]) {
457
+ expect(() => parseCli(args)).toThrow("requires a non-empty value")
458
+ }
459
+ })
460
+
387
461
  test("accepts grouped global short options before a command", () => {
388
462
  const parsed = parseCli(["-sh", "task"])
389
463
  expect(parsed.values).toMatchObject({ silent: true, help: true })