@markjaquith/agency 2.29.0 → 2.30.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 +60 -10
- package/cli.ts +3 -0
- package/fixtures/protocol/skill-setup-commands.json +18 -0
- package/package.json +1 -1
- package/skills/agency/SKILL.md +25 -15
- package/skills/agency/references/commands.md +32 -16
- package/skills/agency/references/contracts.md +46 -19
- package/skills/agency/references/recipes.md +50 -12
- package/src/cli-parser.test.ts +7 -0
- package/src/cli-parser.ts +13 -2
- package/src/cli.test.ts +205 -3
- package/src/commands/pr.test.ts +20 -1
- package/src/commands/repo.test.ts +66 -1
- package/src/commands/repo.ts +35 -8
- package/src/commands/status.test.ts +22 -0
- package/src/commands/status.ts +1 -0
- package/src/commands/sync.ts +5 -3
- package/src/graph-schema.test.ts +52 -4
- package/src/protocol.test.ts +41 -8
- package/src/readiness.test.ts +75 -17
- package/src/services/DoctorService.ts +22 -13
- package/src/services/EpicService.ts +1 -1
- package/src/services/GraphMutationService.ts +3 -3
- package/src/services/GraphService.ts +13 -4
- package/src/services/PhaseService.ts +1 -1
- package/src/services/ReadinessService.test.ts +47 -0
- package/src/services/RepositoryService.test.ts +299 -5
- package/src/services/RepositoryService.ts +725 -98
- package/src/services/SyncService.test.ts +36 -0
- package/src/services/SyncService.ts +20 -1
- package/src/services/TaskService.ts +1 -1
- package/src/services/WorkbaseService.test.ts +32 -0
- package/src/services/WorkbaseService.ts +22 -14
- package/src/services/WorktreeLock.test.ts +122 -0
- package/src/services/WorktreeService.test.ts +17 -2
- package/src/services/WorktreeService.ts +3 -3
- package/src/utils/process.test.ts +4 -3
- package/src/workbase/AGENTS.md +5 -0
- package/src/workbase/dependency-graph.test.ts +50 -0
- package/src/workbase/schemas.test.ts +52 -0
- package/src/workbase/schemas.ts +19 -0
package/src/cli.test.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test"
|
|
1
|
+
import { afterAll, afterEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { access, mkdir, realpath } from "node:fs/promises"
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import errorFixture from "../fixtures/protocol/error.json"
|
|
@@ -7,6 +7,9 @@ import { cleanupTempDir, createTempDir } from "./test-utils"
|
|
|
7
7
|
|
|
8
8
|
const projectRoot = join(import.meta.dir, "..")
|
|
9
9
|
const cliPath = join(projectRoot, "cli.ts")
|
|
10
|
+
const isolatedConfigHome = await createTempDir()
|
|
11
|
+
|
|
12
|
+
afterAll(() => cleanupTempDir(isolatedConfigHome))
|
|
10
13
|
|
|
11
14
|
interface CliResult {
|
|
12
15
|
exitCode: number
|
|
@@ -21,7 +24,11 @@ async function runCli(
|
|
|
21
24
|
): Promise<CliResult> {
|
|
22
25
|
const subprocess = Bun.spawn([process.execPath, cliPath, ...args], {
|
|
23
26
|
cwd,
|
|
24
|
-
env:
|
|
27
|
+
env: {
|
|
28
|
+
...process.env,
|
|
29
|
+
XDG_CONFIG_HOME: isolatedConfigHome,
|
|
30
|
+
...env,
|
|
31
|
+
},
|
|
25
32
|
stdout: "pipe",
|
|
26
33
|
stderr: "pipe",
|
|
27
34
|
})
|
|
@@ -34,13 +41,55 @@ async function runCli(
|
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
function parseJson(result: CliResult) {
|
|
37
|
-
expect(result.exitCode).toBe(0)
|
|
44
|
+
expect(result.exitCode, JSON.stringify(result)).toBe(0)
|
|
38
45
|
expect(result.stderr).toBe("")
|
|
39
46
|
const envelope = JSON.parse(result.stdout)
|
|
40
47
|
expect(envelope).toMatchObject({ version: 1, ok: true })
|
|
41
48
|
return envelope.result
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
async function runGit(args: string[]) {
|
|
52
|
+
const subprocess = Bun.spawn(["git", ...args], {
|
|
53
|
+
stdout: "pipe",
|
|
54
|
+
stderr: "pipe",
|
|
55
|
+
})
|
|
56
|
+
const [exitCode, stdout, stderr] = await Promise.all([
|
|
57
|
+
subprocess.exited,
|
|
58
|
+
new Response(subprocess.stdout).text(),
|
|
59
|
+
new Response(subprocess.stderr).text(),
|
|
60
|
+
])
|
|
61
|
+
if (exitCode !== 0) throw new Error(stderr)
|
|
62
|
+
return stdout
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function startGitDaemon(basePath: string) {
|
|
66
|
+
const port = 20000 + Math.floor(Math.random() * 20000)
|
|
67
|
+
const process = Bun.spawn(
|
|
68
|
+
[
|
|
69
|
+
"git",
|
|
70
|
+
"daemon",
|
|
71
|
+
"--reuseaddr",
|
|
72
|
+
"--export-all",
|
|
73
|
+
`--base-path=${basePath}`,
|
|
74
|
+
"--listen=127.0.0.1",
|
|
75
|
+
`--port=${port}`,
|
|
76
|
+
basePath,
|
|
77
|
+
],
|
|
78
|
+
{ stdout: "pipe", stderr: "pipe" },
|
|
79
|
+
)
|
|
80
|
+
const remote = `git://127.0.0.1:${port}/source.git`
|
|
81
|
+
for (let attempt = 0; attempt < 40; attempt++) {
|
|
82
|
+
const probe = Bun.spawn(["git", "ls-remote", remote], {
|
|
83
|
+
stdout: "ignore",
|
|
84
|
+
stderr: "ignore",
|
|
85
|
+
})
|
|
86
|
+
if ((await probe.exited) === 0) return { process, remote }
|
|
87
|
+
await Bun.sleep(25)
|
|
88
|
+
}
|
|
89
|
+
process.kill()
|
|
90
|
+
throw new Error("Git daemon did not start")
|
|
91
|
+
}
|
|
92
|
+
|
|
44
93
|
describe("CLI", () => {
|
|
45
94
|
const tempDirs: string[] = []
|
|
46
95
|
|
|
@@ -550,6 +599,22 @@ describe("CLI", () => {
|
|
|
550
599
|
kind: "task",
|
|
551
600
|
taskId: "explicit",
|
|
552
601
|
})
|
|
602
|
+
const nextByCwd = parseJson(
|
|
603
|
+
await runCli(
|
|
604
|
+
["next", "--cwd", root, "--select", "--json"],
|
|
605
|
+
projectRoot,
|
|
606
|
+
env,
|
|
607
|
+
),
|
|
608
|
+
)
|
|
609
|
+
expect(nextByCwd.selected.key).toBe("task/explicit")
|
|
610
|
+
const nextByRegistration = parseJson(
|
|
611
|
+
await runCli(
|
|
612
|
+
["next", "--workbase", "primary", "--select", "--json"],
|
|
613
|
+
projectRoot,
|
|
614
|
+
env,
|
|
615
|
+
),
|
|
616
|
+
)
|
|
617
|
+
expect(nextByRegistration.selected.key).toBe("task/explicit")
|
|
553
618
|
|
|
554
619
|
parseJson(
|
|
555
620
|
await runCli(["workbase", "default", "primary", "--json"], parent, env),
|
|
@@ -662,11 +727,23 @@ status: open
|
|
|
662
727
|
["config", "user.name", "Test"],
|
|
663
728
|
["add", "README.md"],
|
|
664
729
|
["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
|
|
730
|
+
["remote", "add", "origin", source],
|
|
665
731
|
]) {
|
|
666
732
|
expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
|
|
667
733
|
}
|
|
668
734
|
|
|
669
735
|
parseJson(await runCli(["init", root, "--json"], parent))
|
|
736
|
+
await Bun.write(
|
|
737
|
+
join(root, "agency.json"),
|
|
738
|
+
JSON.stringify({
|
|
739
|
+
version: 2,
|
|
740
|
+
repositories: {
|
|
741
|
+
agency: {
|
|
742
|
+
remote: "https://example.com/agency-tests/source.git",
|
|
743
|
+
},
|
|
744
|
+
},
|
|
745
|
+
}),
|
|
746
|
+
)
|
|
670
747
|
parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
|
|
671
748
|
parseJson(
|
|
672
749
|
await runCli(
|
|
@@ -789,6 +866,14 @@ status: open
|
|
|
789
866
|
stderr: "pipe",
|
|
790
867
|
})
|
|
791
868
|
expect(await git.exited).toBe(0)
|
|
869
|
+
await runGit([
|
|
870
|
+
"-C",
|
|
871
|
+
source,
|
|
872
|
+
"remote",
|
|
873
|
+
"add",
|
|
874
|
+
"origin",
|
|
875
|
+
"https://example.com/agency-tests/source.git",
|
|
876
|
+
])
|
|
792
877
|
|
|
793
878
|
expect(parseJson(await runCli(["init", root, "--json"]))).toEqual({
|
|
794
879
|
root,
|
|
@@ -981,4 +1066,121 @@ status: open
|
|
|
981
1066
|
valid: true,
|
|
982
1067
|
})
|
|
983
1068
|
}, 30_000)
|
|
1069
|
+
|
|
1070
|
+
test("restores portable repositories in a fresh workbase clone", async () => {
|
|
1071
|
+
const parent = await createTempDir()
|
|
1072
|
+
tempDirs.push(parent)
|
|
1073
|
+
const sourceWorktree = join(parent, "source-worktree")
|
|
1074
|
+
const source = join(parent, "source.git")
|
|
1075
|
+
const root = join(parent, "workbase")
|
|
1076
|
+
const restored = join(parent, "restored")
|
|
1077
|
+
|
|
1078
|
+
await runGit(["init", "--initial-branch=main", sourceWorktree])
|
|
1079
|
+
await Bun.write(join(sourceWorktree, "README.md"), "portable\n")
|
|
1080
|
+
await runGit([
|
|
1081
|
+
"-C",
|
|
1082
|
+
sourceWorktree,
|
|
1083
|
+
"config",
|
|
1084
|
+
"user.email",
|
|
1085
|
+
"test@example.com",
|
|
1086
|
+
])
|
|
1087
|
+
await runGit(["-C", sourceWorktree, "config", "user.name", "Test"])
|
|
1088
|
+
await runGit(["-C", sourceWorktree, "add", "README.md"])
|
|
1089
|
+
await runGit([
|
|
1090
|
+
"-C",
|
|
1091
|
+
sourceWorktree,
|
|
1092
|
+
"-c",
|
|
1093
|
+
"commit.gpgsign=false",
|
|
1094
|
+
"commit",
|
|
1095
|
+
"-m",
|
|
1096
|
+
"initial",
|
|
1097
|
+
])
|
|
1098
|
+
await runGit(["clone", "--bare", sourceWorktree, source])
|
|
1099
|
+
const daemon = await startGitDaemon(parent)
|
|
1100
|
+
|
|
1101
|
+
try {
|
|
1102
|
+
parseJson(await runCli(["init", root, "--json"], parent))
|
|
1103
|
+
parseJson(
|
|
1104
|
+
await runCli(["repo", "add", "agency", daemon.remote, "--json"], root),
|
|
1105
|
+
)
|
|
1106
|
+
parseJson(
|
|
1107
|
+
await runCli(
|
|
1108
|
+
[
|
|
1109
|
+
"task",
|
|
1110
|
+
"create",
|
|
1111
|
+
"portable",
|
|
1112
|
+
"--repo",
|
|
1113
|
+
"agency",
|
|
1114
|
+
"--branch",
|
|
1115
|
+
"feat/portable",
|
|
1116
|
+
"--base",
|
|
1117
|
+
"main",
|
|
1118
|
+
"--json",
|
|
1119
|
+
],
|
|
1120
|
+
root,
|
|
1121
|
+
),
|
|
1122
|
+
)
|
|
1123
|
+
|
|
1124
|
+
await runGit(["init", "--initial-branch=main", root])
|
|
1125
|
+
await runGit(["-C", root, "config", "user.email", "test@example.com"])
|
|
1126
|
+
await runGit(["-C", root, "config", "user.name", "Test"])
|
|
1127
|
+
await runGit(["-C", root, "add", "."])
|
|
1128
|
+
await runGit([
|
|
1129
|
+
"-C",
|
|
1130
|
+
root,
|
|
1131
|
+
"-c",
|
|
1132
|
+
"commit.gpgsign=false",
|
|
1133
|
+
"commit",
|
|
1134
|
+
"-m",
|
|
1135
|
+
"portable workbase",
|
|
1136
|
+
])
|
|
1137
|
+
const tracked = await runGit(["-C", root, "ls-files"])
|
|
1138
|
+
expect(tracked).toContain("agency.json")
|
|
1139
|
+
expect(tracked).not.toContain("repos/agency")
|
|
1140
|
+
|
|
1141
|
+
await runGit(["clone", root, restored])
|
|
1142
|
+
const planned = parseJson(
|
|
1143
|
+
await runCli(["repo", "setup", "--dry-run", "--json"], restored),
|
|
1144
|
+
)
|
|
1145
|
+
expect(planned.actions).toEqual([
|
|
1146
|
+
expect.objectContaining({
|
|
1147
|
+
alias: "agency",
|
|
1148
|
+
kind: "materialize",
|
|
1149
|
+
status: "planned",
|
|
1150
|
+
}),
|
|
1151
|
+
])
|
|
1152
|
+
expect(await Bun.file(join(restored, "repos/agency/HEAD")).exists()).toBe(
|
|
1153
|
+
false,
|
|
1154
|
+
)
|
|
1155
|
+
|
|
1156
|
+
const applied = parseJson(
|
|
1157
|
+
await runCli(["repo", "setup", "--apply", "--json"], restored),
|
|
1158
|
+
)
|
|
1159
|
+
expect(applied.actions[0]).toMatchObject({
|
|
1160
|
+
alias: "agency",
|
|
1161
|
+
status: "applied",
|
|
1162
|
+
})
|
|
1163
|
+
expect(await Bun.file(join(restored, "repos/agency/HEAD")).exists()).toBe(
|
|
1164
|
+
true,
|
|
1165
|
+
)
|
|
1166
|
+
|
|
1167
|
+
const prepared = parseJson(
|
|
1168
|
+
await runCli(["work", "prepare", "portable", "--json"], restored),
|
|
1169
|
+
)
|
|
1170
|
+
expect(prepared.checkouts).toEqual([
|
|
1171
|
+
expect.objectContaining({
|
|
1172
|
+
repo: "agency",
|
|
1173
|
+
action: "created",
|
|
1174
|
+
}),
|
|
1175
|
+
])
|
|
1176
|
+
expect(
|
|
1177
|
+
await Bun.file(
|
|
1178
|
+
join(restored, "tasks/portable/code/agency/README.md"),
|
|
1179
|
+
).text(),
|
|
1180
|
+
).toBe("portable\n")
|
|
1181
|
+
} finally {
|
|
1182
|
+
daemon.process.kill()
|
|
1183
|
+
await daemon.process.exited
|
|
1184
|
+
}
|
|
1185
|
+
}, 30_000)
|
|
984
1186
|
})
|
package/src/commands/pr.test.ts
CHANGED
|
@@ -7,20 +7,39 @@ import { pr } from "./pr"
|
|
|
7
7
|
describe("pr command", () => {
|
|
8
8
|
test("outputs the created pull request URL as JSON", async () => {
|
|
9
9
|
const url = "https://github.com/markjaquith/agency/pull/123"
|
|
10
|
+
let received: unknown[] = []
|
|
10
11
|
const logs = await captureLogs(() =>
|
|
11
12
|
Effect.runPromise(
|
|
12
13
|
pr({
|
|
13
14
|
subcommand: "create",
|
|
14
15
|
taskId: "example",
|
|
16
|
+
phaseId: "implementation",
|
|
17
|
+
draft: true,
|
|
18
|
+
force: true,
|
|
19
|
+
cwd: "/workbase",
|
|
15
20
|
json: true,
|
|
16
21
|
}).pipe(
|
|
17
22
|
Effect.provideService(PullRequestService, {
|
|
18
|
-
create: () =>
|
|
23
|
+
create: (...args: unknown[]) => {
|
|
24
|
+
received = args
|
|
25
|
+
return Effect.succeed(url)
|
|
26
|
+
},
|
|
19
27
|
} as never),
|
|
20
28
|
) as Effect.Effect<void, unknown, never>,
|
|
21
29
|
),
|
|
22
30
|
)
|
|
23
31
|
|
|
24
32
|
expect(JSON.parse(logs[0]!)).toEqual({ url })
|
|
33
|
+
expect(received).toEqual([
|
|
34
|
+
"example",
|
|
35
|
+
"implementation",
|
|
36
|
+
true,
|
|
37
|
+
"/workbase",
|
|
38
|
+
expect.objectContaining({
|
|
39
|
+
force: true,
|
|
40
|
+
draft: true,
|
|
41
|
+
json: true,
|
|
42
|
+
}),
|
|
43
|
+
])
|
|
25
44
|
})
|
|
26
45
|
})
|
|
@@ -23,7 +23,7 @@ describe("repo command", () => {
|
|
|
23
23
|
test("requires a subcommand", async () => {
|
|
24
24
|
await expect(
|
|
25
25
|
runTestEffect(repo({ args: [], silent: true })),
|
|
26
|
-
).rejects.toThrow("Available subcommands: add, link, list")
|
|
26
|
+
).rejects.toThrow("Available subcommands: setup, add, link, list")
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
test("requires add arguments", async () => {
|
|
@@ -47,7 +47,9 @@ describe("repo command", () => {
|
|
|
47
47
|
path: join(root, "repos/agency"),
|
|
48
48
|
kind: "repository",
|
|
49
49
|
remote: null,
|
|
50
|
+
declaredRemote: null,
|
|
50
51
|
target: null,
|
|
52
|
+
states: ["materialized", "invalid"],
|
|
51
53
|
},
|
|
52
54
|
])
|
|
53
55
|
})
|
|
@@ -75,6 +77,19 @@ describe("repo command", () => {
|
|
|
75
77
|
stderr: "ignore",
|
|
76
78
|
})
|
|
77
79
|
expect(await git.exited).toBe(0)
|
|
80
|
+
const remote = Bun.spawn(
|
|
81
|
+
[
|
|
82
|
+
"git",
|
|
83
|
+
"-C",
|
|
84
|
+
target,
|
|
85
|
+
"remote",
|
|
86
|
+
"add",
|
|
87
|
+
"origin",
|
|
88
|
+
"https://example.com/linked.git",
|
|
89
|
+
],
|
|
90
|
+
{ stdout: "ignore", stderr: "ignore" },
|
|
91
|
+
)
|
|
92
|
+
expect(await remote.exited).toBe(0)
|
|
78
93
|
|
|
79
94
|
const logs = await captureLogs(() =>
|
|
80
95
|
runTestEffect(
|
|
@@ -101,6 +116,19 @@ describe("repo command", () => {
|
|
|
101
116
|
stderr: "ignore",
|
|
102
117
|
})
|
|
103
118
|
expect(await git.exited).toBe(0)
|
|
119
|
+
const remote = Bun.spawn(
|
|
120
|
+
[
|
|
121
|
+
"git",
|
|
122
|
+
"-C",
|
|
123
|
+
target,
|
|
124
|
+
"remote",
|
|
125
|
+
"add",
|
|
126
|
+
"origin",
|
|
127
|
+
"https://example.com/unlink.git",
|
|
128
|
+
],
|
|
129
|
+
{ stdout: "ignore", stderr: "ignore" },
|
|
130
|
+
)
|
|
131
|
+
expect(await remote.exited).toBe(0)
|
|
104
132
|
await runTestEffect(
|
|
105
133
|
repo({
|
|
106
134
|
subcommand: "link",
|
|
@@ -120,5 +148,42 @@ describe("repo command", () => {
|
|
|
120
148
|
)
|
|
121
149
|
|
|
122
150
|
expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
|
|
151
|
+
expect(await Bun.file(join(root, "repos/linked")).exists()).toBe(false)
|
|
152
|
+
const logs = await captureLogs(() =>
|
|
153
|
+
runTestEffect(
|
|
154
|
+
repo({ subcommand: "list", args: [], cwd: root, json: true }),
|
|
155
|
+
),
|
|
156
|
+
)
|
|
157
|
+
expect(
|
|
158
|
+
JSON.parse(logs[0]!).map(({ alias }: { alias: string }) => alias),
|
|
159
|
+
).toEqual(["agency", "linked"])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("reports setup plans as JSON without mutating", async () => {
|
|
163
|
+
await Bun.write(
|
|
164
|
+
join(root, "agency.json"),
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
version: 2,
|
|
167
|
+
repositories: {
|
|
168
|
+
missing: { remote: "https://example.com/missing.git" },
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
)
|
|
172
|
+
const logs = await captureLogs(() =>
|
|
173
|
+
runTestEffect(
|
|
174
|
+
repo({ subcommand: "setup", args: [], cwd: root, json: true }),
|
|
175
|
+
),
|
|
176
|
+
)
|
|
177
|
+
const result = JSON.parse(logs[0]!)
|
|
178
|
+
expect(result.mode).toBe("dry-run")
|
|
179
|
+
expect(result.actions).toEqual([
|
|
180
|
+
{
|
|
181
|
+
kind: "materialize",
|
|
182
|
+
alias: "missing",
|
|
183
|
+
remote: "https://example.com/missing.git",
|
|
184
|
+
status: "planned",
|
|
185
|
+
},
|
|
186
|
+
])
|
|
187
|
+
expect(await Bun.file(join(root, "repos/missing")).exists()).toBe(false)
|
|
123
188
|
})
|
|
124
189
|
})
|
package/src/commands/repo.ts
CHANGED
|
@@ -7,6 +7,8 @@ interface RepoOptions extends BaseCommandOptions {
|
|
|
7
7
|
readonly subcommand?: string
|
|
8
8
|
readonly args: readonly string[]
|
|
9
9
|
readonly json?: boolean
|
|
10
|
+
readonly apply?: boolean
|
|
11
|
+
readonly dryRun?: boolean
|
|
10
12
|
}
|
|
11
13
|
|
|
12
14
|
const requireArg = (args: readonly string[], index: number, usage: string) =>
|
|
@@ -19,6 +21,27 @@ export const repo = (options: RepoOptions) =>
|
|
|
19
21
|
const cwd = options.cwd ?? process.cwd()
|
|
20
22
|
|
|
21
23
|
switch (options.subcommand) {
|
|
24
|
+
case "setup": {
|
|
25
|
+
const result = yield* repositories.setup({
|
|
26
|
+
cwd,
|
|
27
|
+
apply: options.apply === true,
|
|
28
|
+
})
|
|
29
|
+
if (options.json) {
|
|
30
|
+
log(JSON.stringify(result, null, 2))
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
if (result.actions.length === 0) log("Repository setup is current")
|
|
34
|
+
for (const action of result.actions) {
|
|
35
|
+
log(
|
|
36
|
+
`${action.status === "applied" ? "Applied" : "Planned"} ${action.kind} '${action.alias}' from ${action.remote}`,
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
for (const issue of result.unresolved) {
|
|
40
|
+
log(`Unresolved '${issue.alias}': ${issue.message}. ${issue.action}`)
|
|
41
|
+
}
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
case "add": {
|
|
23
46
|
const [alias, remote] = options.args
|
|
24
47
|
if (!alias || !remote) {
|
|
@@ -58,8 +81,9 @@ export const repo = (options: RepoOptions) =>
|
|
|
58
81
|
return
|
|
59
82
|
}
|
|
60
83
|
for (const item of items) {
|
|
61
|
-
const detail =
|
|
62
|
-
|
|
84
|
+
const detail =
|
|
85
|
+
item.target ?? item.remote ?? item.declaredRemote ?? item.path
|
|
86
|
+
log(`${item.alias}\t${item.states.join(",")}\t${detail}`)
|
|
63
87
|
}
|
|
64
88
|
return
|
|
65
89
|
}
|
|
@@ -74,7 +98,7 @@ export const repo = (options: RepoOptions) =>
|
|
|
74
98
|
log(
|
|
75
99
|
options.json
|
|
76
100
|
? JSON.stringify(item, null, 2)
|
|
77
|
-
: `${item.alias}\t${item.
|
|
101
|
+
: `${item.alias}\t${item.states.join(",")}\t${item.target ?? item.remote ?? item.declaredRemote ?? item.path}`,
|
|
78
102
|
)
|
|
79
103
|
return
|
|
80
104
|
}
|
|
@@ -136,7 +160,7 @@ export const repo = (options: RepoOptions) =>
|
|
|
136
160
|
log(
|
|
137
161
|
options.json
|
|
138
162
|
? JSON.stringify(item, null, 2)
|
|
139
|
-
: (item.
|
|
163
|
+
: (item.declaredRemote ?? "No portable remote declared"),
|
|
140
164
|
)
|
|
141
165
|
return
|
|
142
166
|
}
|
|
@@ -163,7 +187,7 @@ export const repo = (options: RepoOptions) =>
|
|
|
163
187
|
default:
|
|
164
188
|
return yield* Effect.fail(
|
|
165
189
|
new Error(
|
|
166
|
-
"Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
|
|
190
|
+
"Subcommand is required. Available subcommands: setup, add, link, list, show, fetch, remove, unlink, rename, remote, verify",
|
|
167
191
|
),
|
|
168
192
|
)
|
|
169
193
|
}
|
|
@@ -173,17 +197,20 @@ export const help = `
|
|
|
173
197
|
Usage: agency repo <subcommand>
|
|
174
198
|
|
|
175
199
|
Subcommands:
|
|
200
|
+
setup Plan or apply portable repository setup
|
|
176
201
|
add <alias> <remote> Create a bare clone
|
|
177
202
|
link <alias> <path> Link an existing Git repository
|
|
178
203
|
list List repository aliases
|
|
179
204
|
show <alias> Show a repository alias
|
|
180
205
|
fetch <alias> Fetch and prune a repository
|
|
181
|
-
remove <alias> Remove
|
|
182
|
-
unlink <alias> Remove
|
|
206
|
+
remove <alias> Remove a declaration and local materialization
|
|
207
|
+
unlink <alias> Remove only this machine's linked checkout
|
|
183
208
|
rename <old> <new> Rename an unused repository alias
|
|
184
|
-
|
|
209
|
+
remote <alias> [url] Show or update the portable remote declaration
|
|
185
210
|
verify <alias> Verify repository operation
|
|
186
211
|
|
|
187
212
|
Options:
|
|
213
|
+
--dry-run Report setup changes without applying them (default)
|
|
214
|
+
--apply Apply safe setup changes
|
|
188
215
|
--json Output repository aliases as JSON
|
|
189
216
|
`
|
|
@@ -41,7 +41,9 @@ describe("status command", () => {
|
|
|
41
41
|
path: join(root, "repos/agency"),
|
|
42
42
|
kind: "repository",
|
|
43
43
|
remote: null,
|
|
44
|
+
declaredRemote: null,
|
|
44
45
|
target: null,
|
|
46
|
+
states: ["materialized", "invalid"],
|
|
45
47
|
},
|
|
46
48
|
])
|
|
47
49
|
})
|
|
@@ -58,6 +60,25 @@ describe("status command", () => {
|
|
|
58
60
|
silent: true,
|
|
59
61
|
}),
|
|
60
62
|
)
|
|
63
|
+
await runTestEffect(
|
|
64
|
+
task({
|
|
65
|
+
subcommand: "create",
|
|
66
|
+
args: ["finished"],
|
|
67
|
+
repo: "agency",
|
|
68
|
+
branch: "feat/finished",
|
|
69
|
+
base: "main",
|
|
70
|
+
cwd: root,
|
|
71
|
+
silent: true,
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
await runTestEffect(
|
|
75
|
+
task({
|
|
76
|
+
subcommand: "status",
|
|
77
|
+
args: ["finished", "done"],
|
|
78
|
+
cwd: root,
|
|
79
|
+
silent: true,
|
|
80
|
+
}),
|
|
81
|
+
)
|
|
61
82
|
|
|
62
83
|
const logs = await captureLogs(() =>
|
|
63
84
|
runTestEffect(
|
|
@@ -71,6 +92,7 @@ describe("status command", () => {
|
|
|
71
92
|
expect(logs.at(-1)).toContain(
|
|
72
93
|
"task example - open ready agency feat/example absent absent",
|
|
73
94
|
)
|
|
95
|
+
expect(logs.at(-1)).not.toContain("finished")
|
|
74
96
|
})
|
|
75
97
|
|
|
76
98
|
test("reports validation issues without requiring a decodable graph", async () => {
|
package/src/commands/status.ts
CHANGED
|
@@ -42,6 +42,7 @@ export const status = (options: StatusOptions = {}) =>
|
|
|
42
42
|
|
|
43
43
|
log(`Workbase: ${report.root}`)
|
|
44
44
|
log(`Repositories: ${repos.length}`)
|
|
45
|
+
for (const repo of repos) log(` ${repo.alias}: ${repo.states.join(", ")}`)
|
|
45
46
|
log(`Epics: ${report.epicCount}`)
|
|
46
47
|
log(`Tasks: ${report.taskCount}`)
|
|
47
48
|
log(`Phases: ${report.phaseCount}`)
|
package/src/commands/sync.ts
CHANGED
|
@@ -22,15 +22,17 @@ export const sync = (options: SyncCommandOptions = {}) =>
|
|
|
22
22
|
export const help = `
|
|
23
23
|
Usage: agency sync [--dry-run | --apply] [--json]
|
|
24
24
|
|
|
25
|
-
Compare
|
|
26
|
-
|
|
25
|
+
Compare portable repository declarations and execution state with local Git
|
|
26
|
+
repositories, worktrees, branches, references, claims, and pull requests.
|
|
27
|
+
Dry-run is the default.
|
|
27
28
|
|
|
28
29
|
Options:
|
|
29
30
|
--dry-run Report planned safe transitions without changing state
|
|
30
31
|
--apply Apply safe reconciliation transitions
|
|
31
32
|
--json Output one versioned machine result
|
|
32
33
|
|
|
33
|
-
Apply may materialize unambiguous missing checkouts,
|
|
34
|
+
Apply may materialize declared repositories and unambiguous missing checkouts,
|
|
35
|
+
adopt legacy repositories with portable origins, release expired claims,
|
|
34
36
|
record a uniquely matched PR, and mark merged work done. Dirty, stale, or
|
|
35
37
|
conflicting checkouts are always left unresolved.
|
|
36
38
|
`
|
package/src/graph-schema.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import { Schema } from "@effect/schema"
|
|
2
3
|
import jsonSchema from "../schemas/agency-graph-v1.schema.json"
|
|
3
|
-
import {
|
|
4
|
+
import { AgencyGraph, graphJsonlRecords } from "./graph-schema"
|
|
4
5
|
|
|
5
6
|
describe("graph contract", () => {
|
|
6
7
|
test("publishes the v1 JSON Schema", () => {
|
|
@@ -29,6 +30,38 @@ describe("graph contract", () => {
|
|
|
29
30
|
})
|
|
30
31
|
|
|
31
32
|
test("streams records that reconstruct graph semantics", () => {
|
|
33
|
+
const nodes = [
|
|
34
|
+
{
|
|
35
|
+
id: "repository:agency",
|
|
36
|
+
key: "agency",
|
|
37
|
+
kind: "repository" as const,
|
|
38
|
+
dependents: ["repository:effect"],
|
|
39
|
+
repositories: ["agency"],
|
|
40
|
+
status: null,
|
|
41
|
+
readiness: null,
|
|
42
|
+
aggregate: null,
|
|
43
|
+
data: { alias: "agency" },
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "repository:effect",
|
|
47
|
+
key: "effect",
|
|
48
|
+
kind: "repository" as const,
|
|
49
|
+
dependents: [],
|
|
50
|
+
repositories: ["effect"],
|
|
51
|
+
status: null,
|
|
52
|
+
readiness: null,
|
|
53
|
+
aggregate: null,
|
|
54
|
+
data: { alias: "effect" },
|
|
55
|
+
},
|
|
56
|
+
]
|
|
57
|
+
const edges = [
|
|
58
|
+
{
|
|
59
|
+
id: "references:repository:agency:repository:effect",
|
|
60
|
+
kind: "references" as const,
|
|
61
|
+
from: "repository:agency",
|
|
62
|
+
to: "repository:effect",
|
|
63
|
+
},
|
|
64
|
+
]
|
|
32
65
|
const graph = {
|
|
33
66
|
version: 1,
|
|
34
67
|
workbase: { version: 2 },
|
|
@@ -40,8 +73,8 @@ describe("graph contract", () => {
|
|
|
40
73
|
kinds: [],
|
|
41
74
|
},
|
|
42
75
|
includes: [],
|
|
43
|
-
nodes
|
|
44
|
-
edges
|
|
76
|
+
nodes,
|
|
77
|
+
edges,
|
|
45
78
|
summary: {
|
|
46
79
|
status: "open",
|
|
47
80
|
total: 0,
|
|
@@ -54,6 +87,11 @@ describe("graph contract", () => {
|
|
|
54
87
|
},
|
|
55
88
|
validation: { valid: true, issues: [] },
|
|
56
89
|
} satisfies AgencyGraph
|
|
90
|
+
expect(
|
|
91
|
+
Schema.decodeUnknownSync(AgencyGraph, { onExcessProperty: "error" })(
|
|
92
|
+
graph,
|
|
93
|
+
),
|
|
94
|
+
).toEqual(graph)
|
|
57
95
|
const records = [...graphJsonlRecords(graph)]
|
|
58
96
|
const { nodes: _nodes, edges: _edges, ...metadata } = graph
|
|
59
97
|
expect(records).toEqual([
|
|
@@ -62,7 +100,17 @@ describe("graph contract", () => {
|
|
|
62
100
|
type: "meta",
|
|
63
101
|
graph: metadata,
|
|
64
102
|
},
|
|
65
|
-
|
|
103
|
+
...nodes.map((node) => ({
|
|
104
|
+
version: 1 as const,
|
|
105
|
+
type: "node" as const,
|
|
106
|
+
node,
|
|
107
|
+
})),
|
|
108
|
+
...edges.map((edge) => ({
|
|
109
|
+
version: 1 as const,
|
|
110
|
+
type: "edge" as const,
|
|
111
|
+
edge,
|
|
112
|
+
})),
|
|
113
|
+
{ version: 1, type: "end", nodeCount: 2, edgeCount: 1 },
|
|
66
114
|
])
|
|
67
115
|
})
|
|
68
116
|
})
|