@markjaquith/agency 2.26.0 → 2.28.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,7 +7,9 @@ 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"
12
+ import { doctor, help as doctorHelp } from "./src/commands/doctor"
11
13
  import { validate, help as validateHelp } from "./src/commands/validate"
12
14
  import { context, help as contextHelp } from "./src/commands/context"
13
15
  import { graph, help as graphHelp } from "./src/commands/graph"
@@ -40,6 +42,7 @@ import { ClaimService } from "./src/services/ClaimService"
40
42
  import { SyncService } from "./src/services/SyncService"
41
43
  import { ReadinessService } from "./src/services/ReadinessService"
42
44
  import { GraphMutationService } from "./src/services/GraphMutationService"
45
+ import { DoctorService } from "./src/services/DoctorService"
43
46
  import {
44
47
  claimCommand,
45
48
  claimHelp,
@@ -71,6 +74,7 @@ const CliLayer = Layer.mergeAll(
71
74
  SyncService.Default,
72
75
  ReadinessService.Default,
73
76
  GraphMutationService.Default,
77
+ DoctorService.Default,
74
78
  )
75
79
 
76
80
  /**
@@ -470,6 +474,25 @@ const commands: Record<string, Command> = {
470
474
  )
471
475
  },
472
476
  },
477
+ worktree: {
478
+ run: async (args: string[], options: Record<string, any>) => {
479
+ if (options.help) {
480
+ console.log(worktreeHelp)
481
+ return
482
+ }
483
+ await runCommand(
484
+ worktree({
485
+ subcommand: args[0],
486
+ args: args.slice(1),
487
+ dryRun: options["dry-run"],
488
+ json: options.json,
489
+ silent: options.silent,
490
+ verbose: options.verbose,
491
+ cwd: options.cwd,
492
+ }),
493
+ )
494
+ },
495
+ },
473
496
  next: {
474
497
  run: async (_args: string[], options: Record<string, any>) => {
475
498
  if (options.help) return console.log(nextHelp)
@@ -504,6 +527,22 @@ const commands: Record<string, Command> = {
504
527
  )
505
528
  },
506
529
  },
530
+ doctor: {
531
+ run: async (_args: string[], options: Record<string, any>) => {
532
+ if (options.help) {
533
+ console.log(doctorHelp)
534
+ return
535
+ }
536
+ await runCommand(
537
+ doctor({
538
+ silent: options.silent,
539
+ verbose: options.verbose,
540
+ json: options.json,
541
+ cwd: options.cwd,
542
+ }),
543
+ )
544
+ },
545
+ },
507
546
  validate: {
508
547
  run: async (args: string[], options: Record<string, any>) => {
509
548
  if (options.help) {
@@ -607,10 +646,12 @@ Commands:
607
646
  archive <type> Archive a work item
608
647
  task <subcommand> Manage tasks
609
648
  work [directory|task] Work on an epic, task, or phase
649
+ worktree <subcommand> Inspect and maintain managed worktrees
610
650
  next List or select ready execution units
611
651
  pr create Create a pull request for an execution unit
612
652
  repo <subcommand> Manage workbase repositories
613
653
  status Show status for the current workbase
654
+ doctor Diagnose workbase health and integrations
614
655
  validate [path] Validate a workbase
615
656
  context [target] Return complete target context
616
657
  graph Export the complete workbase graph
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.26.0",
3
+ "version": "2.28.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -123,6 +123,7 @@ describe("strict CLI parsing", () => {
123
123
 
124
124
  test("parses addressable resource maintenance commands", () => {
125
125
  for (const args of [
126
+ ["doctor", "--json"],
126
127
  ["repo", "show", "agency", "--json"],
127
128
  ["repo", "fetch", "agency"],
128
129
  ["repo", "remove", "agency"],
@@ -404,6 +405,36 @@ describe("strict CLI parsing", () => {
404
405
  )
405
406
  })
406
407
 
408
+ test("parses worktree lifecycle commands and selectors", () => {
409
+ expect(parseCli(["worktree", "list", "--json"])).toMatchObject({
410
+ commandName: "worktree",
411
+ args: ["list"],
412
+ values: { json: true },
413
+ })
414
+ expect(
415
+ parseCli([
416
+ "worktree",
417
+ "rebuild",
418
+ "--task",
419
+ "example",
420
+ "--phase",
421
+ "verify",
422
+ "--dry-run",
423
+ ]),
424
+ ).toMatchObject({
425
+ commandName: "worktree",
426
+ args: ["rebuild", "example", "verify"],
427
+ values: { task: "example", phase: "verify", "dry-run": true },
428
+ })
429
+ expectUsageError(
430
+ ["worktree", "inspect", "example", "--dry-run"],
431
+ "agency worktree inspect",
432
+ )
433
+ expect(() => parseCli(["worktree", "repair", "--phase", "verify"])).toThrow(
434
+ "requires '--task'",
435
+ )
436
+ })
437
+
407
438
  test("accepts runner selection and command inspection for work", () => {
408
439
  expect(
409
440
  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]",
@@ -754,6 +804,16 @@ const commands = {
754
804
  conflicts: viewConflicts,
755
805
  },
756
806
  },
807
+ doctor: {
808
+ usage: "agency doctor [--json]",
809
+ options: outputOptions,
810
+ command: {
811
+ usage: "agency doctor [--json]",
812
+ minArgs: 0,
813
+ maxArgs: 0,
814
+ options: ["json"],
815
+ },
816
+ },
757
817
  validate: {
758
818
  usage: "agency validate [path] [--json] [--no-input]",
759
819
  options: {
@@ -902,6 +962,9 @@ const targetSlots = (
902
962
  : subcommand === "phase"
903
963
  ? ["task", "phase"]
904
964
  : []
965
+ if (commandName === "worktree" && subcommand !== "list") {
966
+ return ["task", "phase"]
967
+ }
905
968
  if (commandName === "pr" && subcommand === "create") return ["task", "phase"]
906
969
  return []
907
970
  }
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 () => {
@@ -901,6 +935,15 @@ status: open
901
935
  valid: true,
902
936
  issues: [],
903
937
  })
938
+ const doctor = parseJson(await runCli(["doctor", "--json"], root))
939
+ expect(doctor).toMatchObject({
940
+ version: 1,
941
+ root: workbaseRoot,
942
+ checks: expect.arrayContaining([
943
+ expect.objectContaining({ id: "tool.git", status: "pass" }),
944
+ expect.objectContaining({ id: "workbase.validation", status: "pass" }),
945
+ ]),
946
+ })
904
947
 
905
948
  const validation = parseJson(
906
949
  await runCli(["validate", root, "--json"], parent),
@@ -0,0 +1,156 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { createHash } from "node:crypto"
3
+ import { chmod, mkdir } from "node:fs/promises"
4
+ import { dirname, join } from "node:path"
5
+ import {
6
+ captureLogs,
7
+ cleanupTempDir,
8
+ createTempDir,
9
+ runTestEffect,
10
+ } from "../test-utils"
11
+ import { doctor } from "./doctor"
12
+
13
+ const write = async (root: string, path: string, content: string) => {
14
+ const fullPath = join(root, path)
15
+ await mkdir(dirname(fullPath), { recursive: true })
16
+ await Bun.write(fullPath, content)
17
+ }
18
+
19
+ const managedAgents = (body: string) =>
20
+ `<!-- agency-managed: sha256=${createHash("sha256").update(body).digest("hex")} -->\n\n${body}`
21
+
22
+ describe("doctor command", () => {
23
+ let root: string
24
+ let repository: string
25
+
26
+ beforeEach(async () => {
27
+ root = await createTempDir()
28
+ repository = join(root, "source")
29
+ await mkdir(repository)
30
+ await Bun.$`git init -q -b main ${repository}`
31
+ await Bun.$`git -C ${repository} config user.email test@example.com`
32
+ await Bun.$`git -C ${repository} config user.name Test`
33
+ await write(repository, "README.md", "test\n")
34
+ await Bun.$`git -C ${repository} add README.md`
35
+ await Bun.$`git -C ${repository} commit -q -m initial`
36
+ await Bun.$`git -C ${repository} remote add origin https://example.com/agency.git`
37
+ await write(
38
+ root,
39
+ "agency.json",
40
+ JSON.stringify({
41
+ version: 2,
42
+ runners: { missing: { command: ["definitely-not-installed"] } },
43
+ }),
44
+ )
45
+ await mkdir(join(root, "repos"), { recursive: true })
46
+ await Bun.$`ln -s ${repository} ${join(root, "repos/agency")}`
47
+ await write(
48
+ root,
49
+ "tasks/example/TASK.md",
50
+ `---
51
+ ticketUrl: null
52
+ repo: agency
53
+ branch: feat/example
54
+ base: main
55
+ pr: null
56
+ status: open
57
+ ---
58
+
59
+ # Example
60
+ `,
61
+ )
62
+ })
63
+
64
+ afterEach(async () => cleanupTempDir(root))
65
+
66
+ test("returns stable checks, severities, and remediation in JSON", async () => {
67
+ const logs = await captureLogs(() =>
68
+ runTestEffect(doctor({ cwd: root, json: true })),
69
+ )
70
+ const report = JSON.parse(logs[0]!)
71
+
72
+ expect(report).toMatchObject({
73
+ version: 1,
74
+ root,
75
+ healthy: false,
76
+ })
77
+ expect(report.checks).toEqual(
78
+ expect.arrayContaining([
79
+ expect.objectContaining({
80
+ id: "tool.git",
81
+ level: "error",
82
+ status: "pass",
83
+ }),
84
+ expect.objectContaining({
85
+ id: "integration.runner.missing",
86
+ level: "error",
87
+ status: "fail",
88
+ }),
89
+ expect.objectContaining({
90
+ id: "ref.agency.main",
91
+ status: "pass",
92
+ }),
93
+ expect.objectContaining({
94
+ id: "worktree.task.example",
95
+ status: "pass",
96
+ }),
97
+ ]),
98
+ )
99
+ for (const check of report.checks) {
100
+ if (check.status === "fail") expect(check.remediation).toBeTruthy()
101
+ }
102
+ })
103
+
104
+ test("reports repository, ref, remote, and managed-file failures", async () => {
105
+ await Bun.$`git -C ${repository} remote remove origin`
106
+ await Bun.$`git -C ${repository} branch -m other`
107
+ await write(root, "AGENTS.md", managedAgents("old\n"))
108
+
109
+ const logs = await captureLogs(() =>
110
+ runTestEffect(doctor({ cwd: root, json: true })),
111
+ )
112
+ const checks = JSON.parse(logs[0]!).checks
113
+
114
+ expect(checks).toEqual(
115
+ expect.arrayContaining([
116
+ expect.objectContaining({
117
+ id: "repository.agency.remote",
118
+ status: "fail",
119
+ }),
120
+ expect.objectContaining({
121
+ id: "ref.agency.main",
122
+ status: "fail",
123
+ }),
124
+ expect.objectContaining({
125
+ id: "integration.file.agents",
126
+ status: "fail",
127
+ }),
128
+ ]),
129
+ )
130
+ })
131
+
132
+ test("is safe when the workbase is read-only", async () => {
133
+ for (const path of [
134
+ join(root, "agency.json"),
135
+ join(root, "tasks/example/TASK.md"),
136
+ ]) {
137
+ await chmod(path, 0o444)
138
+ }
139
+ await chmod(join(root, "tasks/example"), 0o555)
140
+ await chmod(join(root, "tasks"), 0o555)
141
+ await chmod(root, 0o555)
142
+
143
+ try {
144
+ await runTestEffect(doctor({ cwd: root, silent: true }))
145
+ } finally {
146
+ await chmod(root, 0o755)
147
+ await chmod(join(root, "tasks"), 0o755)
148
+ await chmod(join(root, "tasks/example"), 0o755)
149
+ }
150
+
151
+ expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
152
+ expect(
153
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
154
+ ).toBe(false)
155
+ })
156
+ })
@@ -0,0 +1,47 @@
1
+ import { Effect } from "effect"
2
+ import { DoctorService } from "../services/DoctorService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface DoctorOptions extends BaseCommandOptions {
7
+ readonly json?: boolean
8
+ }
9
+
10
+ export const doctor = (options: DoctorOptions = {}) =>
11
+ Effect.gen(function* () {
12
+ const service = yield* DoctorService
13
+ const { log } = createLoggers(options)
14
+ const report = yield* service.inspect(options.cwd ?? process.cwd())
15
+
16
+ if (options.json) {
17
+ log(JSON.stringify(report, null, 2))
18
+ return
19
+ }
20
+
21
+ for (const check of report.checks) {
22
+ const marker =
23
+ check.status === "pass"
24
+ ? "PASS"
25
+ : check.level === "error"
26
+ ? "ERROR"
27
+ : check.level === "warning"
28
+ ? "WARN"
29
+ : "OPTIONAL"
30
+ log(`${marker}\t${check.id}\t${check.message}`)
31
+ if (check.remediation) log(` Remediation: ${check.remediation}`)
32
+ }
33
+ log("")
34
+ log(
35
+ `${report.healthy ? "Healthy" : "Unhealthy"}: ${report.summary.errors} error(s), ${report.summary.warnings} warning(s), ${report.summary.optional} unavailable optional capability(s)`,
36
+ )
37
+ })
38
+
39
+ export const help = `
40
+ Usage: agency doctor [options]
41
+
42
+ Diagnose tools, integrations, repositories, refs, worktrees, permissions, and
43
+ managed-file drift without changing the workbase.
44
+
45
+ Options:
46
+ --json Output the health report as JSON
47
+ `
@@ -14,6 +14,7 @@ import { validate } from "./validate"
14
14
  import { context } from "./context"
15
15
  import { graph } from "./graph"
16
16
  import { next } from "./next"
17
+ import { doctor } from "./doctor"
17
18
 
18
19
  const write = async (root: string, path: string, content: string) => {
19
20
  const fullPath = join(root, path)
@@ -147,6 +148,7 @@ status: open
147
148
  )
148
149
  await runTestEffect(graph({ cwd: root, silent: true }))
149
150
  await runTestEffect(next({ cwd: root, silent: true }))
151
+ await runTestEffect(doctor({ cwd: root, silent: true }))
150
152
 
151
153
  expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
152
154
  expect(
@@ -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
+ `