@markjaquith/agency 2.26.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
@@ -610,6 +610,7 @@ for restoration. Archived IDs are reserved until restored.
610
610
  ```text
611
611
  agency work [<directory> | --epic <epic-id>] [--runner <name>] [--print-command]
612
612
  agency work prepare [target] [--dry-run] [--json]
613
+ agency worktree <list|inspect|prepare|remove|rebuild|repair>
613
614
  agency pr create <task-id> [phase-id] [--draft] [--json]
614
615
  ```
615
616
 
@@ -629,6 +630,15 @@ Its JSON result includes document and checkout paths, resolved commits, actions,
629
630
  and Git operations. Use `--dry-run` to report planned fetch, branch, and worktree
630
631
  changes without applying them.
631
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
+
632
642
  Epic and multi-phase task targets launch orchestration agents beside their
633
643
  documents. Single-phase tasks and phases fetch repositories, create or reuse
634
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.26.0",
3
+ "version": "2.27.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -404,6 +404,36 @@ describe("strict CLI parsing", () => {
404
404
  )
405
405
  })
406
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
+
407
437
  test("accepts runner selection and command inspection for work", () => {
408
438
  expect(
409
439
  parseCli(["work", "example", "--runner", "custom", "--print-command"]),
package/src/cli-parser.ts CHANGED
@@ -671,6 +671,56 @@ const commands = {
671
671
  },
672
672
  },
673
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
+ },
674
724
  work: {
675
725
  usage:
676
726
  "agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>] | agency work prepare [target] [--dry-run] [--json]",
@@ -902,6 +952,9 @@ const targetSlots = (
902
952
  : subcommand === "phase"
903
953
  ? ["task", "phase"]
904
954
  : []
955
+ if (commandName === "worktree" && subcommand !== "list") {
956
+ return ["task", "phase"]
957
+ }
905
958
  if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
906
959
  return []
907
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 () => {
@@ -0,0 +1,138 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { createLoggers } from "../utils/effect"
4
+ import { WorktreeService } from "../services/WorktreeService"
5
+
6
+ interface WorktreeOptions extends BaseCommandOptions {
7
+ readonly subcommand?: string
8
+ readonly args?: readonly string[]
9
+ }
10
+
11
+ const targetLabel = (owner: {
12
+ readonly kind: "task" | "phase"
13
+ readonly taskId: string
14
+ readonly phaseId?: string
15
+ }) =>
16
+ owner.kind === "phase"
17
+ ? `phase:${owner.taskId}/${owner.phaseId}`
18
+ : `task:${owner.taskId}`
19
+
20
+ export const worktree = (options: WorktreeOptions = {}) =>
21
+ Effect.gen(function* () {
22
+ const worktrees = yield* WorktreeService
23
+ const { log } = createLoggers(options)
24
+ const root = options.cwd ?? process.cwd()
25
+ const subcommand = options.subcommand
26
+ const taskId = options.args?.[0]
27
+ const phaseId = options.args?.[1]
28
+
29
+ if (subcommand === "list") {
30
+ const inspections = yield* worktrees.list(root)
31
+ if (options.json) return log(JSON.stringify(inspections, null, 2))
32
+ for (const inspection of inspections) {
33
+ for (const checkout of inspection.checkouts) {
34
+ const state = checkout.conflicts.length
35
+ ? `conflict:${checkout.conflicts.map(({ kind }) => kind).join(",")}`
36
+ : checkout.exists
37
+ ? checkout.dirty
38
+ ? "dirty"
39
+ : "ready"
40
+ : "missing"
41
+ log(
42
+ `${targetLabel(inspection.owner)}\t${checkout.kind}\t${checkout.repo}\t${state}\t${checkout.path}`,
43
+ )
44
+ }
45
+ }
46
+ return
47
+ }
48
+
49
+ if (!taskId) {
50
+ return yield* Effect.fail(
51
+ new Error("Worktree command requires a task ID"),
52
+ )
53
+ }
54
+ if (subcommand === "inspect") {
55
+ const inspection = yield* worktrees.inspect(taskId, phaseId, root)
56
+ if (options.json) return log(JSON.stringify(inspection, null, 2))
57
+ for (const checkout of inspection.checkouts) {
58
+ const owners = checkout.owners
59
+ .map((owner) => targetLabel(owner))
60
+ .join(",")
61
+ log(
62
+ `${checkout.kind} ${checkout.repo}: path=${checkout.path} registered=${checkout.registeredPath ?? "no"} branch=${checkout.actualBranch ?? "detached"} commit=${checkout.actualCommit ?? "unknown"} owner=${owners || "none"} dirty=${checkout.dirty ?? "unknown"}`,
63
+ )
64
+ for (const conflict of checkout.conflicts) {
65
+ log(` conflict ${conflict.kind}: ${conflict.message}`)
66
+ }
67
+ }
68
+ return
69
+ }
70
+ if (subcommand === "prepare") {
71
+ const workspace = yield* worktrees.materialize(
72
+ taskId,
73
+ phaseId,
74
+ root,
75
+ options,
76
+ )
77
+ return log(
78
+ options.json
79
+ ? JSON.stringify(workspace, null, 2)
80
+ : `${options.dryRun ? "Worktree plan" : "Worktrees ready"}: ${workspace.codePath}`,
81
+ )
82
+ }
83
+ if (subcommand === "remove") {
84
+ const inspection = yield* worktrees.inspect(taskId, phaseId, root)
85
+ const paths = yield* worktrees.remove(taskId, phaseId, root, options)
86
+ const result = {
87
+ operation: "remove",
88
+ dryRun: options.dryRun === true,
89
+ inspection,
90
+ actions: paths.map((path) => `remove ${path}`),
91
+ }
92
+ return log(
93
+ options.json
94
+ ? JSON.stringify(result, null, 2)
95
+ : `${options.dryRun ? "Would remove" : "Removed"} ${paths.length} worktree${paths.length === 1 ? "" : "s"}`,
96
+ )
97
+ }
98
+ if (subcommand === "rebuild") {
99
+ const result = yield* worktrees.rebuild(taskId, phaseId, root, options)
100
+ return log(
101
+ options.json
102
+ ? JSON.stringify(result, null, 2)
103
+ : `${options.dryRun ? "Would rebuild" : "Rebuilt"} ${result.inspection.codePath}`,
104
+ )
105
+ }
106
+ if (subcommand === "repair") {
107
+ const result = yield* worktrees.repair(taskId, phaseId, root, options)
108
+ return log(
109
+ options.json
110
+ ? JSON.stringify(result, null, 2)
111
+ : `${options.dryRun ? "Would repair" : "Repaired"} ${result.inspection.codePath}`,
112
+ )
113
+ }
114
+
115
+ return yield* Effect.fail(
116
+ new Error(`Unknown worktree subcommand '${subcommand ?? ""}'`),
117
+ )
118
+ })
119
+
120
+ export const help = `
121
+ Usage: agency worktree <list|inspect|prepare|remove|rebuild|repair>
122
+
123
+ Inspect and maintain Agency-managed writable and reference worktrees.
124
+
125
+ Commands:
126
+ list List every managed checkout
127
+ inspect <task-id> [phase-id] Show registration, branch, commit, ownership, and dirtiness
128
+ prepare <task-id> [phase-id] Create or reuse declared worktrees
129
+ remove <task-id> [phase-id] Remove clean worktrees while preserving branches
130
+ rebuild <task-id> [phase-id] Remove and recreate clean, conflict-free worktrees
131
+ repair <task-id> [phase-id] Repair safe registration issues or missing worktrees
132
+
133
+ Options:
134
+ --task <id> Select a task without positional IDs
135
+ --phase <id> Select a phase with --task
136
+ --dry-run Preflight and report changes without applying them
137
+ --json Print structured output
138
+ `
@@ -6,6 +6,7 @@ import {
6
6
  readdir,
7
7
  realpath,
8
8
  rename,
9
+ rmdir,
9
10
  stat,
10
11
  symlink,
11
12
  } from "node:fs/promises"
@@ -175,6 +176,31 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
175
176
  ),
176
177
  ),
177
178
 
179
+ deleteDirectoryIfEmpty: (path: string) =>
180
+ Effect.tryPromise({
181
+ try: async () => {
182
+ try {
183
+ await rmdir(path)
184
+ return true
185
+ } catch (error) {
186
+ if (
187
+ typeof error === "object" &&
188
+ error !== null &&
189
+ "code" in error &&
190
+ ["ENOENT", "ENOTEMPTY", "EEXIST"].includes(String(error.code))
191
+ ) {
192
+ return false
193
+ }
194
+ throw error
195
+ }
196
+ },
197
+ catch: (error) =>
198
+ new FileSystemError({
199
+ message: `Failed to delete empty directory: ${path}`,
200
+ cause: error,
201
+ }),
202
+ }),
203
+
178
204
  runCommand: (
179
205
  args: readonly string[],
180
206
  options?: {