@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 +10 -0
- package/cli.ts +21 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +30 -0
- package/src/cli-parser.ts +53 -0
- package/src/cli.test.ts +34 -0
- package/src/commands/worktree.ts +138 -0
- package/src/services/FileSystemService.ts +26 -0
- package/src/services/WorktreeService.test.ts +453 -2
- package/src/services/WorktreeService.ts +1510 -655
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
package/src/cli-parser.test.ts
CHANGED
|
@@ -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?: {
|