@markjaquith/agency 2.25.0 → 2.27.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
@@ -379,14 +379,24 @@ conditions remain visible in `warnings` or `unresolved` with a suggested action.
379
379
  agency init [path] [--json]
380
380
  agency workbase add <path> [--name <name>] [--json]
381
381
  agency workbase list [--json]
382
+ agency workbase show <id|name|path> [--json]
383
+ agency workbase name <id|name|path> <name> [--json]
384
+ agency workbase name <id|name|path> --clear [--json]
382
385
  agency workbase remove <id|name|path> [--json]
383
386
  agency workbase prune [--json]
384
- agency workbase default [<id|name> | --clear] [--json]
387
+ agency workbase default [<id|name|path> | --clear] [--json]
385
388
  agency integration status [--json]
386
389
  agency integration sync [--json]
387
390
  agency repo add <alias> <remote> [--json]
388
391
  agency repo link <alias> <path> [--json]
389
392
  agency repo list [--json]
393
+ agency repo show <alias> [--json]
394
+ agency repo fetch <alias> [--json]
395
+ agency repo remove <alias> [--json]
396
+ agency repo unlink <alias> [--json]
397
+ agency repo rename <alias> <new-alias> [--json]
398
+ agency repo remote <alias> [remote] [--json]
399
+ agency repo verify <alias> [--json]
390
400
  ```
391
401
 
392
402
  Registered workbases are stored in
@@ -395,7 +405,9 @@ Each registration has a stable ID and may have a unique name. A default workbase
395
405
  is used when the current directory is outside every workbase. `prune` removes
396
406
  registrations whose workbase configuration no longer exists.
397
407
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
398
- repository. Alias names are then used by all documents and commands.
408
+ repository. Alias names are then used by all documents and commands. Remove,
409
+ unlink, and rename refuse aliases referenced by active work or backed by linked
410
+ worktrees, and report each blocker.
399
411
 
400
412
  Commands that print Agency-owned results accept `--json`, including initialization,
401
413
  integration inspection/sync, repository mutations, entity creation/list/show,
@@ -598,6 +610,7 @@ for restoration. Archived IDs are reserved until restored.
598
610
  ```text
599
611
  agency work [<directory> | --epic <epic-id>] [--runner <name>] [--print-command]
600
612
  agency work prepare [target] [--dry-run] [--json]
613
+ agency worktree <list|inspect|prepare|remove|rebuild|repair>
601
614
  agency pr create <task-id> [phase-id] [--draft] [--json]
602
615
  ```
603
616
 
@@ -617,6 +630,15 @@ Its JSON result includes document and checkout paths, resolved commits, actions,
617
630
  and Git operations. Use `--dry-run` to report planned fetch, branch, and worktree
618
631
  changes without applying them.
619
632
 
633
+ `agency worktree list` and `inspect` report each declared checkout's expected and
634
+ registered path, branch, commit, Agency owner, dirtiness, and conflicts. `prepare`
635
+ is the explicit lifecycle form of `agency work prepare`. `remove`, `rebuild`, and
636
+ `repair` preflight every writable and reference checkout before changing any of
637
+ them and accept `--dry-run`. Removal preserves branches. Rebuild rejects dirty or
638
+ conflicting worktrees. Repair is deliberately conservative: it repairs safe Git
639
+ registration issues and materializes missing checkouts, but never switches a
640
+ branch, resets a commit, or discards uncommitted work.
641
+
620
642
  Epic and multi-phase task targets launch orchestration agents beside their
621
643
  documents. Single-phase tasks and phases fetch repositories, create or reuse
622
644
  worktrees under `code/`, and launch an execution agent in the writable checkout
package/cli.ts CHANGED
@@ -7,6 +7,7 @@ import { init, help as initHelp } from "./src/commands/init"
7
7
  import { task, help as taskHelp } from "./src/commands/task"
8
8
  import { pr, help as prHelp } from "./src/commands/pr"
9
9
  import { work, workPrepare, help as workHelp } from "./src/commands/work"
10
+ import { worktree, help as worktreeHelp } from "./src/commands/worktree"
10
11
  import { status, help as statusHelp } from "./src/commands/status"
11
12
  import { validate, help as validateHelp } from "./src/commands/validate"
12
13
  import { context, help as contextHelp } from "./src/commands/context"
@@ -470,6 +471,25 @@ const commands: Record<string, Command> = {
470
471
  )
471
472
  },
472
473
  },
474
+ worktree: {
475
+ run: async (args: string[], options: Record<string, any>) => {
476
+ if (options.help) {
477
+ console.log(worktreeHelp)
478
+ return
479
+ }
480
+ await runCommand(
481
+ worktree({
482
+ subcommand: args[0],
483
+ args: args.slice(1),
484
+ dryRun: options["dry-run"],
485
+ json: options.json,
486
+ silent: options.silent,
487
+ verbose: options.verbose,
488
+ cwd: options.cwd,
489
+ }),
490
+ )
491
+ },
492
+ },
473
493
  next: {
474
494
  run: async (_args: string[], options: Record<string, any>) => {
475
495
  if (options.help) return console.log(nextHelp)
@@ -607,6 +627,7 @@ Commands:
607
627
  archive <type> Archive a work item
608
628
  task <subcommand> Manage tasks
609
629
  work [directory|task] Work on an epic, task, or phase
630
+ worktree <subcommand> Inspect and maintain managed worktrees
610
631
  next List or select ready execution units
611
632
  pr create Create a pull request for an execution unit
612
633
  repo <subcommand> Manage workbase repositories
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.25.0",
3
+ "version": "2.27.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -65,7 +65,9 @@ Register and name known workbases so commands can select one from anywhere:
65
65
  ```bash
66
66
  agency workbase add <path> [--name <name>]
67
67
  agency workbase list
68
- agency workbase default [<id|name> | --clear]
68
+ agency workbase show <id|name|path>
69
+ agency workbase name <id|name|path> <name> | --clear
70
+ agency workbase default [<id|name|path> | --clear]
69
71
  agency workbase remove <id|name|path>
70
72
  agency workbase prune
71
73
  ```
@@ -85,6 +87,13 @@ Link an existing local Git repository:
85
87
 
86
88
  ```bash
87
89
  agency repo link <alias> <path>
90
+ agency repo show <alias>
91
+ agency repo fetch <alias>
92
+ agency repo remove <alias>
93
+ agency repo unlink <alias>
94
+ agency repo rename <alias> <new-alias>
95
+ agency repo remote <alias> [remote]
96
+ agency repo verify <alias>
88
97
  ```
89
98
 
90
99
  Use aliases, never absolute paths or Git URLs, in epic/task/phase frontmatter.
@@ -121,6 +121,23 @@ describe("strict CLI parsing", () => {
121
121
  expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
122
122
  })
123
123
 
124
+ test("parses addressable resource maintenance commands", () => {
125
+ for (const args of [
126
+ ["repo", "show", "agency", "--json"],
127
+ ["repo", "fetch", "agency"],
128
+ ["repo", "remove", "agency"],
129
+ ["repo", "unlink", "agency"],
130
+ ["repo", "rename", "agency", "renamed"],
131
+ ["repo", "remote", "agency", "https://example.com/repo.git"],
132
+ ["repo", "verify", "agency"],
133
+ ["workbase", "show", "primary", "--json"],
134
+ ["workbase", "name", "primary", "renamed"],
135
+ ["workbase", "name", "primary", "--clear"],
136
+ ]) {
137
+ expect(() => parseCli(args)).not.toThrow()
138
+ }
139
+ })
140
+
124
141
  test("validates view filter values and conflicts", () => {
125
142
  expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
126
143
  "Invalid '--status' value",
@@ -387,6 +404,36 @@ describe("strict CLI parsing", () => {
387
404
  )
388
405
  })
389
406
 
407
+ test("parses worktree lifecycle commands and selectors", () => {
408
+ expect(parseCli(["worktree", "list", "--json"])).toMatchObject({
409
+ commandName: "worktree",
410
+ args: ["list"],
411
+ values: { json: true },
412
+ })
413
+ expect(
414
+ parseCli([
415
+ "worktree",
416
+ "rebuild",
417
+ "--task",
418
+ "example",
419
+ "--phase",
420
+ "verify",
421
+ "--dry-run",
422
+ ]),
423
+ ).toMatchObject({
424
+ commandName: "worktree",
425
+ args: ["rebuild", "example", "verify"],
426
+ values: { task: "example", phase: "verify", "dry-run": true },
427
+ })
428
+ expectUsageError(
429
+ ["worktree", "inspect", "example", "--dry-run"],
430
+ "agency worktree inspect",
431
+ )
432
+ expect(() => parseCli(["worktree", "repair", "--phase", "verify"])).toThrow(
433
+ "requires '--task'",
434
+ )
435
+ })
436
+
390
437
  test("accepts runner selection and command inspection for work", () => {
391
438
  expect(
392
439
  parseCli(["work", "example", "--runner", "custom", "--print-command"]),
package/src/cli-parser.ts CHANGED
@@ -127,7 +127,7 @@ const commands = {
127
127
  },
128
128
  },
129
129
  workbase: {
130
- usage: "agency workbase <add|list|remove|prune|default>",
130
+ usage: "agency workbase <add|list|show|name|remove|prune|default>",
131
131
  options: {
132
132
  ...outputOptions,
133
133
  name: { type: "string" },
@@ -146,6 +146,18 @@ const commands = {
146
146
  maxArgs: 0,
147
147
  options: ["json"],
148
148
  },
149
+ show: {
150
+ usage: "agency workbase show <selector> [--json]",
151
+ minArgs: 1,
152
+ maxArgs: 1,
153
+ options: ["json"],
154
+ },
155
+ name: {
156
+ usage: "agency workbase name <selector> <name> | --clear [--json]",
157
+ minArgs: 1,
158
+ maxArgs: 2,
159
+ options: ["json", "clear"],
160
+ },
149
161
  remove: {
150
162
  usage: "agency workbase remove <selector> [--json]",
151
163
  minArgs: 1,
@@ -186,7 +198,8 @@ const commands = {
186
198
  },
187
199
  },
188
200
  repo: {
189
- usage: "agency repo <add|link|list>",
201
+ usage:
202
+ "agency repo <add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
190
203
  options: outputOptions,
191
204
  subcommands: {
192
205
  add: {
@@ -207,6 +220,48 @@ const commands = {
207
220
  maxArgs: 0,
208
221
  options: ["json"],
209
222
  },
223
+ show: {
224
+ usage: "agency repo show <alias> [--json]",
225
+ minArgs: 1,
226
+ maxArgs: 1,
227
+ options: ["json"],
228
+ },
229
+ fetch: {
230
+ usage: "agency repo fetch <alias> [--json]",
231
+ minArgs: 1,
232
+ maxArgs: 1,
233
+ options: ["json"],
234
+ },
235
+ remove: {
236
+ usage: "agency repo remove <alias> [--json]",
237
+ minArgs: 1,
238
+ maxArgs: 1,
239
+ options: ["json"],
240
+ },
241
+ unlink: {
242
+ usage: "agency repo unlink <alias> [--json]",
243
+ minArgs: 1,
244
+ maxArgs: 1,
245
+ options: ["json"],
246
+ },
247
+ rename: {
248
+ usage: "agency repo rename <alias> <new-alias> [--json]",
249
+ minArgs: 2,
250
+ maxArgs: 2,
251
+ options: ["json"],
252
+ },
253
+ remote: {
254
+ usage: "agency repo remote <alias> [remote] [--json]",
255
+ minArgs: 1,
256
+ maxArgs: 2,
257
+ options: ["json"],
258
+ },
259
+ verify: {
260
+ usage: "agency repo verify <alias> [--json]",
261
+ minArgs: 1,
262
+ maxArgs: 1,
263
+ options: ["json"],
264
+ },
210
265
  },
211
266
  },
212
267
  epic: {
@@ -616,6 +671,56 @@ const commands = {
616
671
  },
617
672
  },
618
673
  },
674
+ worktree: {
675
+ usage: "agency worktree <list|inspect|prepare|remove|rebuild|repair>",
676
+ options: {
677
+ ...outputOptions,
678
+ ...entitySelectorOptions,
679
+ "dry-run": { type: "boolean" },
680
+ },
681
+ subcommands: {
682
+ list: {
683
+ usage: "agency worktree list [--json]",
684
+ minArgs: 0,
685
+ maxArgs: 0,
686
+ options: ["json"],
687
+ },
688
+ inspect: {
689
+ usage: "agency worktree inspect <task-id> [phase-id] [--json]",
690
+ minArgs: 1,
691
+ maxArgs: 2,
692
+ options: ["json", "task", "phase"],
693
+ },
694
+ prepare: {
695
+ usage:
696
+ "agency worktree prepare <task-id> [phase-id] [--dry-run] [--json]",
697
+ minArgs: 1,
698
+ maxArgs: 2,
699
+ options: ["dry-run", "json", "task", "phase"],
700
+ },
701
+ remove: {
702
+ usage:
703
+ "agency worktree remove <task-id> [phase-id] [--dry-run] [--json]",
704
+ minArgs: 1,
705
+ maxArgs: 2,
706
+ options: ["dry-run", "json", "task", "phase"],
707
+ },
708
+ rebuild: {
709
+ usage:
710
+ "agency worktree rebuild <task-id> [phase-id] [--dry-run] [--json]",
711
+ minArgs: 1,
712
+ maxArgs: 2,
713
+ options: ["dry-run", "json", "task", "phase"],
714
+ },
715
+ repair: {
716
+ usage:
717
+ "agency worktree repair <task-id> [phase-id] [--dry-run] [--json]",
718
+ minArgs: 1,
719
+ maxArgs: 2,
720
+ options: ["dry-run", "json", "task", "phase"],
721
+ },
722
+ },
723
+ },
619
724
  work: {
620
725
  usage:
621
726
  "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] | agency work prepare [target] [--dry-run] [--json]",
@@ -847,6 +952,9 @@ const targetSlots = (
847
952
  : subcommand === "phase"
848
953
  ? ["task", "phase"]
849
954
  : []
955
+ if (commandName === "worktree" && subcommand !== "list") {
956
+ return ["task", "phase"]
957
+ }
850
958
  if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
851
959
  return []
852
960
  }
package/src/cli.test.ts CHANGED
@@ -692,6 +692,40 @@ status: open
692
692
  await runCli(["task", "show", "example", "--json"], root),
693
693
  )
694
694
  expect(task.data.status).toBe("open")
695
+
696
+ const listed = parseJson(await runCli(["worktree", "list", "--json"], root))
697
+ expect(listed).toEqual([
698
+ expect.objectContaining({
699
+ owner: expect.objectContaining({ kind: "task", taskId: "example" }),
700
+ checkouts: [
701
+ expect.objectContaining({
702
+ repo: "agency",
703
+ kind: "writable",
704
+ registered: true,
705
+ actualBranch: "feat/example",
706
+ actualCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
707
+ dirty: false,
708
+ }),
709
+ ],
710
+ }),
711
+ ])
712
+ const rebuild = parseJson(
713
+ await runCli(
714
+ ["worktree", "rebuild", "example", "--dry-run", "--json"],
715
+ root,
716
+ ),
717
+ )
718
+ expect(rebuild).toMatchObject({
719
+ operation: "rebuild",
720
+ dryRun: true,
721
+ actions: [
722
+ `remove ${join(workbaseRoot, "tasks/example/code/agency")}`,
723
+ `create ${join(workbaseRoot, "tasks/example/code/agency")}`,
724
+ ],
725
+ })
726
+ expect(
727
+ await Bun.file(join(root, "tasks/example/code/agency/README.md")).text(),
728
+ ).toBe("example\n")
695
729
  })
696
730
 
697
731
  test("envelopes help and version output in machine mode", async () => {
@@ -52,6 +52,21 @@ describe("repo command", () => {
52
52
  ])
53
53
  })
54
54
 
55
+ test("shows a repository by alias as JSON", async () => {
56
+ const logs = await captureLogs(() =>
57
+ runTestEffect(
58
+ repo({
59
+ subcommand: "show",
60
+ args: ["agency"],
61
+ cwd: root,
62
+ json: true,
63
+ }),
64
+ ),
65
+ )
66
+
67
+ expect(JSON.parse(logs[0]!).alias).toBe("agency")
68
+ })
69
+
55
70
  test("outputs a linked repository as JSON", async () => {
56
71
  const target = join(root, "source")
57
72
  await mkdir(target)
@@ -77,4 +92,33 @@ describe("repo command", () => {
77
92
  path: join(root, "repos/linked"),
78
93
  })
79
94
  })
95
+
96
+ test("unlinks a linked repository by alias", async () => {
97
+ const target = join(root, "unlink-source")
98
+ await mkdir(target)
99
+ const git = Bun.spawn(["git", "init", target], {
100
+ stdout: "ignore",
101
+ stderr: "ignore",
102
+ })
103
+ expect(await git.exited).toBe(0)
104
+ await runTestEffect(
105
+ repo({
106
+ subcommand: "link",
107
+ args: ["linked", target],
108
+ cwd: root,
109
+ silent: true,
110
+ }),
111
+ )
112
+
113
+ await runTestEffect(
114
+ repo({
115
+ subcommand: "unlink",
116
+ args: ["linked"],
117
+ cwd: root,
118
+ silent: true,
119
+ }),
120
+ )
121
+
122
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
123
+ })
80
124
  })
@@ -9,6 +9,9 @@ interface RepoOptions extends BaseCommandOptions {
9
9
  readonly json?: boolean
10
10
  }
11
11
 
12
+ const requireArg = (args: readonly string[], index: number, usage: string) =>
13
+ args[index] ? Effect.succeed(args[index]) : Effect.fail(new Error(usage))
14
+
12
15
  export const repo = (options: RepoOptions) =>
13
16
  Effect.gen(function* () {
14
17
  const repositories = yield* RepositoryService
@@ -61,10 +64,106 @@ export const repo = (options: RepoOptions) =>
61
64
  return
62
65
  }
63
66
 
67
+ case "show": {
68
+ const alias = yield* requireArg(
69
+ options.args,
70
+ 0,
71
+ "Usage: agency repo show <alias>",
72
+ )
73
+ const item = yield* repositories.show(alias, cwd)
74
+ log(
75
+ options.json
76
+ ? JSON.stringify(item, null, 2)
77
+ : `${item.alias}\t${item.kind}\t${item.target ?? item.remote ?? item.path}`,
78
+ )
79
+ return
80
+ }
81
+
82
+ case "fetch": {
83
+ const alias = yield* requireArg(
84
+ options.args,
85
+ 0,
86
+ "Usage: agency repo fetch <alias>",
87
+ )
88
+ const item = yield* repositories.fetch(alias, cwd)
89
+ log(options.json ? JSON.stringify(item, null, 2) : `Fetched '${alias}'`)
90
+ return
91
+ }
92
+
93
+ case "remove":
94
+ case "unlink": {
95
+ const alias = yield* requireArg(
96
+ options.args,
97
+ 0,
98
+ `Usage: agency repo ${options.subcommand} <alias>`,
99
+ )
100
+ const item = yield* repositories[options.subcommand](alias, cwd)
101
+ log(
102
+ options.json
103
+ ? JSON.stringify(item, null, 2)
104
+ : `${options.subcommand === "unlink" ? "Unlinked" : "Removed"} '${alias}'`,
105
+ )
106
+ return
107
+ }
108
+
109
+ case "rename": {
110
+ const alias = yield* requireArg(
111
+ options.args,
112
+ 0,
113
+ "Usage: agency repo rename <alias> <new-alias>",
114
+ )
115
+ const newAlias = yield* requireArg(
116
+ options.args,
117
+ 1,
118
+ "Usage: agency repo rename <alias> <new-alias>",
119
+ )
120
+ const item = yield* repositories.rename(alias, newAlias, cwd)
121
+ log(
122
+ options.json
123
+ ? JSON.stringify(item, null, 2)
124
+ : `Renamed '${alias}' to '${newAlias}'`,
125
+ )
126
+ return
127
+ }
128
+
129
+ case "remote": {
130
+ const alias = yield* requireArg(
131
+ options.args,
132
+ 0,
133
+ "Usage: agency repo remote <alias> [remote]",
134
+ )
135
+ const item = yield* repositories.remote(alias, options.args[1], cwd)
136
+ log(
137
+ options.json
138
+ ? JSON.stringify(item, null, 2)
139
+ : (item.remote ?? "No origin remote configured"),
140
+ )
141
+ return
142
+ }
143
+
144
+ case "verify": {
145
+ const alias = yield* requireArg(
146
+ options.args,
147
+ 0,
148
+ "Usage: agency repo verify <alias>",
149
+ )
150
+ const report = yield* repositories.verify(alias, cwd)
151
+ if (options.json) log(JSON.stringify(report, null, 2))
152
+ if (!report.valid) {
153
+ return yield* Effect.fail(
154
+ new Error(
155
+ `Repository '${alias}' verification failed:\n${report.issues.map((issue) => `- ${issue}`).join("\n")}`,
156
+ ),
157
+ )
158
+ }
159
+ if (!options.json) log(`Verified repository '${alias}'`)
160
+ return
161
+ }
162
+
64
163
  default:
65
164
  return yield* Effect.fail(
66
165
  new Error(
67
- "Subcommand is required. Available subcommands: add, link, list",
166
+ "Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
68
167
  ),
69
168
  )
70
169
  }
@@ -77,6 +176,13 @@ Subcommands:
77
176
  add <alias> <remote> Create a bare clone
78
177
  link <alias> <path> Link an existing Git repository
79
178
  list List repository aliases
179
+ show <alias> Show a repository alias
180
+ fetch <alias> Fetch and prune a repository
181
+ remove <alias> Remove an unused repository alias
182
+ unlink <alias> Remove an unused linked alias
183
+ rename <old> <new> Rename an unused repository alias
184
+ remote <alias> [url] Show or update the origin remote
185
+ verify <alias> Verify repository operation
80
186
 
81
187
  Options:
82
188
  --json Output repository aliases as JSON
@@ -108,6 +108,53 @@ describe("workbase command", () => {
108
108
  ).toEqual({ version: 2, workbases: [] })
109
109
  })
110
110
 
111
+ test("names, shows, and clears a workbase name", async () => {
112
+ const added = await captureLogs(() =>
113
+ runTestEffect(
114
+ workbase({
115
+ subcommand: "add",
116
+ args: [root],
117
+ configDirectory,
118
+ json: true,
119
+ }),
120
+ ),
121
+ )
122
+ const registration = JSON.parse(added[0]!)
123
+ await runTestEffect(
124
+ workbase({
125
+ subcommand: "name",
126
+ args: [registration.id, "primary"],
127
+ configDirectory,
128
+ silent: true,
129
+ }),
130
+ )
131
+
132
+ const shown = await captureLogs(() =>
133
+ runTestEffect(
134
+ workbase({
135
+ subcommand: "show",
136
+ args: ["primary"],
137
+ configDirectory,
138
+ json: true,
139
+ }),
140
+ ),
141
+ )
142
+ expect(JSON.parse(shown[0]!).name).toBe("primary")
143
+
144
+ await runTestEffect(
145
+ workbase({
146
+ subcommand: "name",
147
+ args: [registration.id],
148
+ clear: true,
149
+ configDirectory,
150
+ silent: true,
151
+ }),
152
+ )
153
+ expect(
154
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
155
+ ).toEqual({ version: 2, workbases: [registration] })
156
+ })
157
+
111
158
  test("requires an add path", async () => {
112
159
  await expect(
113
160
  runTestEffect(