@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 +10 -0
- package/cli.ts +41 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +31 -0
- package/src/cli-parser.ts +63 -0
- package/src/cli.test.ts +43 -0
- package/src/commands/doctor.test.ts +156 -0
- package/src/commands/doctor.ts +47 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/worktree.ts +138 -0
- package/src/services/DoctorService.ts +419 -0
- package/src/services/FileSystemService.ts +26 -0
- package/src/services/WorktreeService.test.ts +453 -2
- package/src/services/WorktreeService.ts +1510 -655
- package/src/test-utils.ts +2 -0
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
package/src/cli-parser.test.ts
CHANGED
|
@@ -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
|
+
`
|