@markjaquith/agency 2.28.1 → 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.
Files changed (42) hide show
  1. package/README.md +63 -12
  2. package/cli.ts +15 -0
  3. package/fixtures/protocol/skill-setup-commands.json +18 -0
  4. package/package.json +1 -1
  5. package/skills/agency/SKILL.md +25 -15
  6. package/skills/agency/references/commands.md +34 -17
  7. package/skills/agency/references/contracts.md +46 -19
  8. package/skills/agency/references/recipes.md +50 -12
  9. package/src/cli-parser.test.ts +9 -0
  10. package/src/cli-parser.ts +20 -3
  11. package/src/cli.test.ts +205 -3
  12. package/src/commands/pr.test.ts +20 -1
  13. package/src/commands/repo.test.ts +66 -1
  14. package/src/commands/repo.ts +35 -8
  15. package/src/commands/status.test.ts +22 -0
  16. package/src/commands/status.ts +1 -0
  17. package/src/commands/sync.ts +5 -3
  18. package/src/commands/workbase.ts +2 -1
  19. package/src/graph-schema.test.ts +52 -4
  20. package/src/protocol.test.ts +41 -8
  21. package/src/readiness.test.ts +75 -17
  22. package/src/services/DoctorService.ts +22 -13
  23. package/src/services/EpicService.ts +1 -1
  24. package/src/services/GraphMutationService.ts +3 -3
  25. package/src/services/GraphService.ts +13 -4
  26. package/src/services/PhaseService.ts +1 -1
  27. package/src/services/ReadinessService.test.ts +47 -0
  28. package/src/services/RepositoryService.test.ts +299 -5
  29. package/src/services/RepositoryService.ts +725 -98
  30. package/src/services/SyncService.test.ts +36 -0
  31. package/src/services/SyncService.ts +20 -1
  32. package/src/services/TaskService.ts +1 -1
  33. package/src/services/WorkbaseService.test.ts +32 -0
  34. package/src/services/WorkbaseService.ts +22 -14
  35. package/src/services/WorktreeLock.test.ts +122 -0
  36. package/src/services/WorktreeService.test.ts +17 -2
  37. package/src/services/WorktreeService.ts +3 -3
  38. package/src/utils/process.test.ts +4 -3
  39. package/src/workbase/AGENTS.md +5 -0
  40. package/src/workbase/dependency-graph.test.ts +50 -0
  41. package/src/workbase/schemas.test.ts +52 -0
  42. package/src/workbase/schemas.ts +19 -0
package/src/cli-parser.ts CHANGED
@@ -127,13 +127,19 @@ const commands = {
127
127
  },
128
128
  },
129
129
  workbase: {
130
- usage: "agency workbase <add|list|show|name|remove|prune|default>",
130
+ usage: "agency workbase <init|add|list|show|name|remove|prune|default>",
131
131
  options: {
132
132
  ...outputOptions,
133
133
  name: { type: "string" },
134
134
  clear: { type: "boolean" },
135
135
  },
136
136
  subcommands: {
137
+ init: {
138
+ usage: "agency workbase init [path] [--json]",
139
+ minArgs: 0,
140
+ maxArgs: 1,
141
+ options: ["json"],
142
+ },
137
143
  add: {
138
144
  usage: "agency workbase add <path> [--json]",
139
145
  minArgs: 1,
@@ -199,9 +205,20 @@ const commands = {
199
205
  },
200
206
  repo: {
201
207
  usage:
202
- "agency repo <add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
203
- options: outputOptions,
208
+ "agency repo <setup|add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
209
+ options: {
210
+ ...outputOptions,
211
+ "dry-run": { type: "boolean" },
212
+ apply: { type: "boolean" },
213
+ },
204
214
  subcommands: {
215
+ setup: {
216
+ usage: "agency repo setup [--dry-run | --apply] [--json]",
217
+ minArgs: 0,
218
+ maxArgs: 0,
219
+ options: ["dry-run", "apply", "json"],
220
+ conflicts: [["dry-run", "apply"]],
221
+ },
205
222
  add: {
206
223
  usage: "agency repo add <alias> <remote> [--json]",
207
224
  minArgs: 2,
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: env ? { ...process.env, ...env } : undefined,
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
  })
@@ -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: () => Effect.succeed(url),
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
  })
@@ -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 = item.target ?? item.remote ?? item.path
62
- log(`${item.alias}\t${item.kind}\t${detail}`)
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.kind}\t${item.target ?? item.remote ?? item.path}`,
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.remote ?? "No origin remote configured"),
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 an unused repository alias
182
- unlink <alias> Remove an unused linked alias
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
- remote <alias> [url] Show or update the origin remote
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 () => {
@@ -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}`)
@@ -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 declared execution state with Git worktrees, branches, references, claims,
26
- and GitHub pull requests. Dry-run is the default.
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, release expired claims,
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
  `
@@ -151,7 +151,7 @@ export const workbase = (options: WorkbaseOptions) =>
151
151
  default:
152
152
  return yield* Effect.fail(
153
153
  new Error(
154
- "Subcommand is required. Available: add, list, show, name, remove, prune, default",
154
+ "Subcommand is required. Available: init, add, list, show, name, remove, prune, default",
155
155
  ),
156
156
  )
157
157
  }
@@ -161,6 +161,7 @@ export const help = `
161
161
  Usage: agency workbase <subcommand>
162
162
 
163
163
  Subcommands:
164
+ init [path] Initialize an Agency workbase
164
165
  add <path> Register an Agency workbase
165
166
  list List registered workbases
166
167
  show <selector> Show a registered workbase