@markjaquith/agency 2.16.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
@@ -304,6 +304,21 @@ inspection are opt-in include layers.
304
304
  `end` record with counts. Combining the metadata with the streamed node and edge
305
305
  records reconstructs the same result as `--json`.
306
306
 
307
+ ### Next Ready Work
308
+
309
+ `agency next` lists ready execution units in descending unlock priority, with
310
+ their epic and task context. `agency next --select` returns only the highest-
311
+ priority ready unit in human output.
312
+
313
+ `agency next --json` returns the same ranked `ready` set plus every `excluded`
314
+ execution unit. Excluded entries retain status, terminal state, `blockedBy`, and
315
+ detailed dependency, validation, or status blockers for orchestrators.
316
+
317
+ `agency work` and `agency pr create` consult this shared readiness model before
318
+ materializing or pushing. Blocked, done, and dropped targets are rejected unless
319
+ `--force` is supplied explicitly. PR creation permits active `working` and
320
+ `delegated` targets when they have no dependency or validation blocker.
321
+
307
322
  ### Reconciliation
308
323
 
309
324
  `agency sync` compares every execution declaration with local branch and worktree
@@ -327,8 +342,11 @@ conditions remain visible in `warnings` or `unresolved` with a suggested action.
327
342
 
328
343
  ```text
329
344
  agency init [path] [--json]
330
- agency workbase add <path> [--json]
345
+ agency workbase add <path> [--name <name>] [--json]
331
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]
332
350
  agency integration status [--json]
333
351
  agency integration sync [--json]
334
352
  agency repo add <alias> <remote> [--json]
@@ -338,6 +356,9 @@ agency repo list [--json]
338
356
 
339
357
  Registered workbases are stored in
340
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.
341
362
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
342
363
  repository. Alias names are then used by all documents and commands.
343
364
 
@@ -389,9 +410,17 @@ agency task create <id> --multi-phase
389
410
 
390
411
  Agency never prompts when `--no-input` is set or stdin/stderr are not TTYs.
391
412
  `--json` also disables prompts and selectors, even when a TTY is available.
392
- Commands with explicit inputs continue normally. `task new` fails immediately;
393
- `work` requires an explicit directory, task ID, or `--epic` and must run from a
394
- 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.
395
424
 
396
425
  Inspect tasks:
397
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"
@@ -10,6 +11,7 @@ import { status, help as statusHelp } from "./src/commands/status"
10
11
  import { validate, help as validateHelp } from "./src/commands/validate"
11
12
  import { context, help as contextHelp } from "./src/commands/context"
12
13
  import { graph, help as graphHelp } from "./src/commands/graph"
14
+ import { next, help as nextHelp } from "./src/commands/next"
13
15
  import { sync, help as syncHelp } from "./src/commands/sync"
14
16
  import { repo, help as repoHelp } from "./src/commands/repo"
15
17
  import { epic, help as epicHelp } from "./src/commands/epic"
@@ -35,6 +37,7 @@ import { ContextService } from "./src/services/ContextService"
35
37
  import { GraphService } from "./src/services/GraphService"
36
38
  import { ClaimService } from "./src/services/ClaimService"
37
39
  import { SyncService } from "./src/services/SyncService"
40
+ import { ReadinessService } from "./src/services/ReadinessService"
38
41
  import {
39
42
  claimCommand,
40
43
  claimHelp,
@@ -64,16 +67,15 @@ const CliLayer = Layer.mergeAll(
64
67
  GraphService.Default,
65
68
  ClaimService.Default,
66
69
  SyncService.Default,
70
+ ReadinessService.Default,
67
71
  )
68
72
 
69
73
  /**
70
74
  * Run a command Effect with all services provided
71
75
  */
72
- async function runCommand<E>(
73
- effect: Effect.Effect<void, E, any>,
74
- ): Promise<void> {
76
+ async function runEffect<A, E>(effect: Effect.Effect<A, E, any>): Promise<A> {
75
77
  const providedEffect = Effect.provide(effect, CliLayer) as Effect.Effect<
76
- void,
78
+ A,
77
79
  E,
78
80
  never
79
81
  >
@@ -100,8 +102,44 @@ async function runCommand<E>(
100
102
  ),
101
103
  )
102
104
  if (Either.isLeft(result)) throw result.left
105
+ return result.right
103
106
  }
104
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
+
105
143
  // Read version from package.json
106
144
  const packageJson = await Bun.file(
107
145
  new URL("./package.json", import.meta.url),
@@ -126,6 +164,7 @@ const commands: Record<string, Command> = {
126
164
  json: options.json,
127
165
  silent: options.silent,
128
166
  verbose: options.verbose,
167
+ cwd: options.cwd,
129
168
  }),
130
169
  )
131
170
  },
@@ -143,6 +182,7 @@ const commands: Record<string, Command> = {
143
182
  json: options.json,
144
183
  silent: options.silent,
145
184
  verbose: options.verbose,
185
+ cwd: options.cwd,
146
186
  }),
147
187
  )
148
188
  },
@@ -161,6 +201,7 @@ const commands: Record<string, Command> = {
161
201
  json: options.json,
162
202
  silent: options.silent,
163
203
  verbose: options.verbose,
204
+ cwd: options.cwd,
164
205
  }),
165
206
  )
166
207
  },
@@ -177,6 +218,7 @@ const commands: Record<string, Command> = {
177
218
  json: options.json,
178
219
  silent: options.silent,
179
220
  verbose: options.verbose,
221
+ cwd: options.cwd,
180
222
  }),
181
223
  )
182
224
  },
@@ -197,6 +239,7 @@ const commands: Record<string, Command> = {
197
239
  json: options.json,
198
240
  silent: options.silent,
199
241
  verbose: options.verbose,
242
+ cwd: options.cwd,
200
243
  }),
201
244
  )
202
245
  },
@@ -213,9 +256,11 @@ const commands: Record<string, Command> = {
213
256
  taskId: args[1],
214
257
  phaseId: args[2],
215
258
  draft: options.draft,
259
+ force: options.force,
216
260
  json: options.json,
217
261
  silent: options.silent,
218
262
  verbose: options.verbose,
263
+ cwd: options.cwd,
219
264
  }),
220
265
  )
221
266
  },
@@ -237,6 +282,7 @@ const commands: Record<string, Command> = {
237
282
  json: options.json,
238
283
  silent: options.silent,
239
284
  verbose: options.verbose,
285
+ cwd: options.cwd,
240
286
  }),
241
287
  )
242
288
  },
@@ -254,6 +300,7 @@ const commands: Record<string, Command> = {
254
300
  json: options.json,
255
301
  silent: options.silent,
256
302
  verbose: options.verbose,
303
+ cwd: options.cwd,
257
304
  }),
258
305
  )
259
306
  },
@@ -269,8 +316,11 @@ const commands: Record<string, Command> = {
269
316
  subcommand: args[0],
270
317
  args: args.slice(1),
271
318
  json: options.json,
319
+ name: options.name,
320
+ clear: options.clear,
272
321
  silent: options.silent,
273
322
  verbose: options.verbose,
323
+ cwd: options.cwd,
274
324
  }),
275
325
  )
276
326
  },
@@ -287,6 +337,7 @@ const commands: Record<string, Command> = {
287
337
  json: options.json,
288
338
  silent: options.silent,
289
339
  verbose: options.verbose,
340
+ cwd: options.cwd,
290
341
  }),
291
342
  )
292
343
  },
@@ -304,6 +355,7 @@ const commands: Record<string, Command> = {
304
355
  silent: options.silent,
305
356
  verbose: options.verbose,
306
357
  json: options.json,
358
+ cwd: options.cwd,
307
359
  }),
308
360
  )
309
361
  },
@@ -330,6 +382,7 @@ const commands: Record<string, Command> = {
330
382
  silent: options.silent,
331
383
  verbose: options.verbose,
332
384
  inputAllowed: options.inputAllowed,
385
+ cwd: options.cwd,
333
386
  }),
334
387
  )
335
388
  },
@@ -351,7 +404,24 @@ const commands: Record<string, Command> = {
351
404
  verbose: options.verbose,
352
405
  opencode: options.opencode,
353
406
  claude: options.claude,
407
+ force: options.force,
354
408
  inputAllowed: options.inputAllowed,
409
+ cwd: options.cwd,
410
+ taskId: options.task,
411
+ phaseId: options.phase,
412
+ }),
413
+ )
414
+ },
415
+ },
416
+ next: {
417
+ run: async (_args: string[], options: Record<string, any>) => {
418
+ if (options.help) return console.log(nextHelp)
419
+ await runCommand(
420
+ next({
421
+ select: options.select,
422
+ json: options.json,
423
+ silent: options.silent,
424
+ verbose: options.verbose,
355
425
  }),
356
426
  )
357
427
  },
@@ -367,6 +437,7 @@ const commands: Record<string, Command> = {
367
437
  silent: options.silent,
368
438
  verbose: options.verbose,
369
439
  json: options.json,
440
+ cwd: options.cwd,
370
441
  }),
371
442
  )
372
443
  },
@@ -384,6 +455,7 @@ const commands: Record<string, Command> = {
384
455
  verbose: options.verbose,
385
456
  json: options.json,
386
457
  inputAllowed: options.inputAllowed,
458
+ cwd: options.cwd,
387
459
  }),
388
460
  )
389
461
  },
@@ -396,11 +468,18 @@ const commands: Record<string, Command> = {
396
468
  }
397
469
  await runCommand(
398
470
  context({
399
- 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],
400
478
  compact: options.compact,
401
479
  json: options.json,
402
480
  silent: options.silent,
403
481
  verbose: options.verbose,
482
+ cwd: options.cwd,
404
483
  }),
405
484
  )
406
485
  },
@@ -423,6 +502,7 @@ const commands: Record<string, Command> = {
423
502
  include: options.include,
424
503
  silent: options.silent,
425
504
  verbose: options.verbose,
505
+ cwd: options.cwd,
426
506
  }),
427
507
  )
428
508
  },
@@ -440,6 +520,7 @@ const commands: Record<string, Command> = {
440
520
  json: options.json,
441
521
  silent: options.silent,
442
522
  verbose: options.verbose,
523
+ cwd: options.cwd,
443
524
  }),
444
525
  )
445
526
  },
@@ -464,6 +545,7 @@ Commands:
464
545
  archive <type> Archive a work item
465
546
  task <subcommand> Manage tasks
466
547
  work [directory|task] Work on an epic, task, or phase
548
+ next List or select ready execution units
467
549
  pr create Create a pull request for an execution unit
468
550
  repo <subcommand> Manage workbase repositories
469
551
  status Show status for the current workbase
@@ -478,6 +560,8 @@ Global Options:
478
560
  -s, --silent Suppress output messages
479
561
  -v, --verbose Show verbose output including detailed debugging info
480
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
481
565
 
482
566
  Examples:
483
567
  agency init # Initialize the current directory
@@ -519,15 +603,16 @@ try {
519
603
  !values.json &&
520
604
  !values["no-input"] &&
521
605
  Boolean(process.stdin.isTTY && process.stderr.isTTY)
606
+ const cwd = await resolveInvocationCwd(commandName, values)
522
607
  if (values.json || (values.jsonl && values.help)) {
523
608
  const result = await collectCommandResult(() =>
524
- command.run(commandArgs, { ...values, inputAllowed }),
609
+ command.run(commandArgs, { ...values, cwd, inputAllowed }),
525
610
  )
526
611
  writeEnvelope(successEnvelope(result))
527
612
  } else if (values.jsonl) {
528
- await command.run(commandArgs, { ...values, inputAllowed: false })
613
+ await command.run(commandArgs, { ...values, cwd, inputAllowed: false })
529
614
  } else {
530
- await command.run(commandArgs, { ...values, inputAllowed })
615
+ await command.run(commandArgs, { ...values, cwd, inputAllowed })
531
616
  }
532
617
  } catch (error) {
533
618
  if (machineMode) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.16.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
@@ -141,6 +141,7 @@ describe("strict CLI parsing", () => {
141
141
  [["validate", "one", "two"], "agency validate"],
142
142
  [["context", "one", "two"], "agency context"],
143
143
  [["graph", "extra"], "agency graph"],
144
+ [["next", "extra"], "agency next"],
144
145
  [["sync", "extra"], "agency sync"],
145
146
  [
146
147
  [
@@ -192,6 +193,24 @@ describe("strict CLI parsing", () => {
192
193
  }
193
194
  })
194
195
 
196
+ test("parses readiness selection and explicit guard overrides", () => {
197
+ expect(parseCli(["next", "--select", "--json"])).toMatchObject({
198
+ commandName: "next",
199
+ values: { select: true, json: true },
200
+ })
201
+ expect(parseCli(["work", "example", "--force"])).toMatchObject({
202
+ commandName: "work",
203
+ values: { force: true },
204
+ })
205
+ expect(parseCli(["pr", "create", "example", "--force"])).toMatchObject({
206
+ commandName: "pr",
207
+ values: { force: true },
208
+ })
209
+ expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
210
+ "cannot be combined",
211
+ )
212
+ })
213
+
195
214
  test("parses reconciliation modes and rejects conflicting modes", () => {
196
215
  expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
197
216
  commandName: "sync",
@@ -365,6 +384,80 @@ describe("strict CLI parsing", () => {
365
384
  ).toBe(true)
366
385
  })
367
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
+
368
461
  test("accepts grouped global short options before a command", () => {
369
462
  const parsed = parseCli(["-sh", "task"])
370
463
  expect(parsed.values).toMatchObject({ silent: true, help: true })