@markjaquith/agency 2.24.0 → 2.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -379,14 +379,24 @@ conditions remain visible in `warnings` or `unresolved` with a suggested action.
379
379
  agency init [path] [--json]
380
380
  agency workbase add <path> [--name <name>] [--json]
381
381
  agency workbase list [--json]
382
+ agency workbase show <id|name|path> [--json]
383
+ agency workbase name <id|name|path> <name> [--json]
384
+ agency workbase name <id|name|path> --clear [--json]
382
385
  agency workbase remove <id|name|path> [--json]
383
386
  agency workbase prune [--json]
384
- agency workbase default [<id|name> | --clear] [--json]
387
+ agency workbase default [<id|name|path> | --clear] [--json]
385
388
  agency integration status [--json]
386
389
  agency integration sync [--json]
387
390
  agency repo add <alias> <remote> [--json]
388
391
  agency repo link <alias> <path> [--json]
389
392
  agency repo list [--json]
393
+ agency repo show <alias> [--json]
394
+ agency repo fetch <alias> [--json]
395
+ agency repo remove <alias> [--json]
396
+ agency repo unlink <alias> [--json]
397
+ agency repo rename <alias> <new-alias> [--json]
398
+ agency repo remote <alias> [remote] [--json]
399
+ agency repo verify <alias> [--json]
390
400
  ```
391
401
 
392
402
  Registered workbases are stored in
@@ -395,7 +405,9 @@ Each registration has a stable ID and may have a unique name. A default workbase
395
405
  is used when the current directory is outside every workbase. `prune` removes
396
406
  registrations whose workbase configuration no longer exists.
397
407
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
398
- repository. Alias names are then used by all documents and commands.
408
+ repository. Alias names are then used by all documents and commands. Remove,
409
+ unlink, and rename refuse aliases referenced by active work or backed by linked
410
+ worktrees, and report each blocker.
399
411
 
400
412
  Commands that print Agency-owned results accept `--json`, including initialization,
401
413
  integration inspection/sync, repository mutations, entity creation/list/show,
package/cli.ts CHANGED
@@ -683,6 +683,7 @@ try {
683
683
  }
684
684
  if (error instanceof Error) {
685
685
  let message = error.message
686
+ let details: any = error
686
687
 
687
688
  // Handle Effect FiberFailure errors that wrap tagged errors
688
689
  // When the message is generic "An error has occurred", try to extract the actual error
@@ -695,6 +696,7 @@ try {
695
696
  const cause = (error as any)[causeSymbol]
696
697
  if (cause && cause._tag === "Fail" && cause.failure) {
697
698
  const failure = cause.failure
699
+ details = failure
698
700
  // Try common error message patterns
699
701
  message =
700
702
  failure.message ||
@@ -705,6 +707,14 @@ try {
705
707
  }
706
708
  }
707
709
  }
710
+ for (const [field, label] of [
711
+ ["completed", "Completed"],
712
+ ["rolledBack", "Rolled back"],
713
+ ["manualRecovery", "Manual recovery"],
714
+ ] as const) {
715
+ if (Array.isArray(details[field]) && details[field].length > 0)
716
+ message += `\n${label}: ${details[field].join("; ")}`
717
+ }
708
718
 
709
719
  console.error(`ⓘ ${message}`)
710
720
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.24.0",
3
+ "version": "2.26.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -65,7 +65,9 @@ Register and name known workbases so commands can select one from anywhere:
65
65
  ```bash
66
66
  agency workbase add <path> [--name <name>]
67
67
  agency workbase list
68
- agency workbase default [<id|name> | --clear]
68
+ agency workbase show <id|name|path>
69
+ agency workbase name <id|name|path> <name> | --clear
70
+ agency workbase default [<id|name|path> | --clear]
69
71
  agency workbase remove <id|name|path>
70
72
  agency workbase prune
71
73
  ```
@@ -85,6 +87,13 @@ Link an existing local Git repository:
85
87
 
86
88
  ```bash
87
89
  agency repo link <alias> <path>
90
+ agency repo show <alias>
91
+ agency repo fetch <alias>
92
+ agency repo remove <alias>
93
+ agency repo unlink <alias>
94
+ agency repo rename <alias> <new-alias>
95
+ agency repo remote <alias> [remote]
96
+ agency repo verify <alias>
88
97
  ```
89
98
 
90
99
  Use aliases, never absolute paths or Git URLs, in epic/task/phase frontmatter.
@@ -121,6 +121,23 @@ describe("strict CLI parsing", () => {
121
121
  expect(parseCli(["status", "--no-pr"]).values["no-pr"]).toBe(true)
122
122
  })
123
123
 
124
+ test("parses addressable resource maintenance commands", () => {
125
+ for (const args of [
126
+ ["repo", "show", "agency", "--json"],
127
+ ["repo", "fetch", "agency"],
128
+ ["repo", "remove", "agency"],
129
+ ["repo", "unlink", "agency"],
130
+ ["repo", "rename", "agency", "renamed"],
131
+ ["repo", "remote", "agency", "https://example.com/repo.git"],
132
+ ["repo", "verify", "agency"],
133
+ ["workbase", "show", "primary", "--json"],
134
+ ["workbase", "name", "primary", "renamed"],
135
+ ["workbase", "name", "primary", "--clear"],
136
+ ]) {
137
+ expect(() => parseCli(args)).not.toThrow()
138
+ }
139
+ })
140
+
124
141
  test("validates view filter values and conflicts", () => {
125
142
  expect(() => parseCli(["epic", "list", "--status", "invalid"])).toThrow(
126
143
  "Invalid '--status' value",
@@ -292,6 +309,15 @@ describe("strict CLI parsing", () => {
292
309
  )
293
310
  })
294
311
 
312
+ test("accepts archive dry-run", () => {
313
+ expect(parseCli(["archive", "task", "example", "--dry-run"])).toMatchObject(
314
+ {
315
+ commandName: "archive",
316
+ values: { "dry-run": true },
317
+ },
318
+ )
319
+ })
320
+
295
321
  test("validates revision-guarded claim operations", () => {
296
322
  const revision = "0".repeat(64)
297
323
  expect(
package/src/cli-parser.ts CHANGED
@@ -127,7 +127,7 @@ const commands = {
127
127
  },
128
128
  },
129
129
  workbase: {
130
- usage: "agency workbase <add|list|remove|prune|default>",
130
+ usage: "agency workbase <add|list|show|name|remove|prune|default>",
131
131
  options: {
132
132
  ...outputOptions,
133
133
  name: { type: "string" },
@@ -146,6 +146,18 @@ const commands = {
146
146
  maxArgs: 0,
147
147
  options: ["json"],
148
148
  },
149
+ show: {
150
+ usage: "agency workbase show <selector> [--json]",
151
+ minArgs: 1,
152
+ maxArgs: 1,
153
+ options: ["json"],
154
+ },
155
+ name: {
156
+ usage: "agency workbase name <selector> <name> | --clear [--json]",
157
+ minArgs: 1,
158
+ maxArgs: 2,
159
+ options: ["json", "clear"],
160
+ },
149
161
  remove: {
150
162
  usage: "agency workbase remove <selector> [--json]",
151
163
  minArgs: 1,
@@ -186,7 +198,8 @@ const commands = {
186
198
  },
187
199
  },
188
200
  repo: {
189
- usage: "agency repo <add|link|list>",
201
+ usage:
202
+ "agency repo <add|link|list|show|fetch|remove|unlink|rename|remote|verify>",
190
203
  options: outputOptions,
191
204
  subcommands: {
192
205
  add: {
@@ -207,6 +220,48 @@ const commands = {
207
220
  maxArgs: 0,
208
221
  options: ["json"],
209
222
  },
223
+ show: {
224
+ usage: "agency repo show <alias> [--json]",
225
+ minArgs: 1,
226
+ maxArgs: 1,
227
+ options: ["json"],
228
+ },
229
+ fetch: {
230
+ usage: "agency repo fetch <alias> [--json]",
231
+ minArgs: 1,
232
+ maxArgs: 1,
233
+ options: ["json"],
234
+ },
235
+ remove: {
236
+ usage: "agency repo remove <alias> [--json]",
237
+ minArgs: 1,
238
+ maxArgs: 1,
239
+ options: ["json"],
240
+ },
241
+ unlink: {
242
+ usage: "agency repo unlink <alias> [--json]",
243
+ minArgs: 1,
244
+ maxArgs: 1,
245
+ options: ["json"],
246
+ },
247
+ rename: {
248
+ usage: "agency repo rename <alias> <new-alias> [--json]",
249
+ minArgs: 2,
250
+ maxArgs: 2,
251
+ options: ["json"],
252
+ },
253
+ remote: {
254
+ usage: "agency repo remote <alias> [remote] [--json]",
255
+ minArgs: 1,
256
+ maxArgs: 2,
257
+ options: ["json"],
258
+ },
259
+ verify: {
260
+ usage: "agency repo verify <alias> [--json]",
261
+ minArgs: 1,
262
+ maxArgs: 1,
263
+ options: ["json"],
264
+ },
210
265
  },
211
266
  },
212
267
  epic: {
@@ -63,6 +63,28 @@ describe("archive command", () => {
63
63
  })
64
64
  })
65
65
 
66
+ test("preflights an archive without moving the task", async () => {
67
+ const logs = await captureLogs(() =>
68
+ runTestEffect(
69
+ archive({
70
+ type: "task",
71
+ args: ["example"],
72
+ cwd: root,
73
+ dryRun: true,
74
+ json: true,
75
+ }),
76
+ ),
77
+ )
78
+
79
+ expect(JSON.parse(logs[0]!).dryRun).toBe(true)
80
+ expect(await Bun.file(join(root, "tasks/example/TASK.md")).exists()).toBe(
81
+ true,
82
+ )
83
+ expect(await Bun.file(join(root, "archive/tasks/example")).exists()).toBe(
84
+ false,
85
+ )
86
+ })
87
+
66
88
  test("requires a supported work item type", async () => {
67
89
  await expect(
68
90
  runTestEffect(archive({ args: [], cwd: root, silent: true })),
@@ -112,7 +112,9 @@ export const archive = (options: ArchiveOptions) =>
112
112
  log(
113
113
  options.json
114
114
  ? JSON.stringify(result, null, 2)
115
- : `${result.dryRun ? "Would archive" : "Archived"} ${result.kind} '${result.id}' to ${result.path}`,
115
+ : result.dryRun
116
+ ? `Would archive ${result.kind} '${result.id}' to ${result.path}`
117
+ : `Archived ${result.kind} '${result.id}' to ${result.path}`,
116
118
  )
117
119
  })
118
120
 
@@ -52,6 +52,21 @@ describe("repo command", () => {
52
52
  ])
53
53
  })
54
54
 
55
+ test("shows a repository by alias as JSON", async () => {
56
+ const logs = await captureLogs(() =>
57
+ runTestEffect(
58
+ repo({
59
+ subcommand: "show",
60
+ args: ["agency"],
61
+ cwd: root,
62
+ json: true,
63
+ }),
64
+ ),
65
+ )
66
+
67
+ expect(JSON.parse(logs[0]!).alias).toBe("agency")
68
+ })
69
+
55
70
  test("outputs a linked repository as JSON", async () => {
56
71
  const target = join(root, "source")
57
72
  await mkdir(target)
@@ -77,4 +92,33 @@ describe("repo command", () => {
77
92
  path: join(root, "repos/linked"),
78
93
  })
79
94
  })
95
+
96
+ test("unlinks a linked repository by alias", async () => {
97
+ const target = join(root, "unlink-source")
98
+ await mkdir(target)
99
+ const git = Bun.spawn(["git", "init", target], {
100
+ stdout: "ignore",
101
+ stderr: "ignore",
102
+ })
103
+ expect(await git.exited).toBe(0)
104
+ await runTestEffect(
105
+ repo({
106
+ subcommand: "link",
107
+ args: ["linked", target],
108
+ cwd: root,
109
+ silent: true,
110
+ }),
111
+ )
112
+
113
+ await runTestEffect(
114
+ repo({
115
+ subcommand: "unlink",
116
+ args: ["linked"],
117
+ cwd: root,
118
+ silent: true,
119
+ }),
120
+ )
121
+
122
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
123
+ })
80
124
  })
@@ -9,6 +9,9 @@ interface RepoOptions extends BaseCommandOptions {
9
9
  readonly json?: boolean
10
10
  }
11
11
 
12
+ const requireArg = (args: readonly string[], index: number, usage: string) =>
13
+ args[index] ? Effect.succeed(args[index]) : Effect.fail(new Error(usage))
14
+
12
15
  export const repo = (options: RepoOptions) =>
13
16
  Effect.gen(function* () {
14
17
  const repositories = yield* RepositoryService
@@ -61,10 +64,106 @@ export const repo = (options: RepoOptions) =>
61
64
  return
62
65
  }
63
66
 
67
+ case "show": {
68
+ const alias = yield* requireArg(
69
+ options.args,
70
+ 0,
71
+ "Usage: agency repo show <alias>",
72
+ )
73
+ const item = yield* repositories.show(alias, cwd)
74
+ log(
75
+ options.json
76
+ ? JSON.stringify(item, null, 2)
77
+ : `${item.alias}\t${item.kind}\t${item.target ?? item.remote ?? item.path}`,
78
+ )
79
+ return
80
+ }
81
+
82
+ case "fetch": {
83
+ const alias = yield* requireArg(
84
+ options.args,
85
+ 0,
86
+ "Usage: agency repo fetch <alias>",
87
+ )
88
+ const item = yield* repositories.fetch(alias, cwd)
89
+ log(options.json ? JSON.stringify(item, null, 2) : `Fetched '${alias}'`)
90
+ return
91
+ }
92
+
93
+ case "remove":
94
+ case "unlink": {
95
+ const alias = yield* requireArg(
96
+ options.args,
97
+ 0,
98
+ `Usage: agency repo ${options.subcommand} <alias>`,
99
+ )
100
+ const item = yield* repositories[options.subcommand](alias, cwd)
101
+ log(
102
+ options.json
103
+ ? JSON.stringify(item, null, 2)
104
+ : `${options.subcommand === "unlink" ? "Unlinked" : "Removed"} '${alias}'`,
105
+ )
106
+ return
107
+ }
108
+
109
+ case "rename": {
110
+ const alias = yield* requireArg(
111
+ options.args,
112
+ 0,
113
+ "Usage: agency repo rename <alias> <new-alias>",
114
+ )
115
+ const newAlias = yield* requireArg(
116
+ options.args,
117
+ 1,
118
+ "Usage: agency repo rename <alias> <new-alias>",
119
+ )
120
+ const item = yield* repositories.rename(alias, newAlias, cwd)
121
+ log(
122
+ options.json
123
+ ? JSON.stringify(item, null, 2)
124
+ : `Renamed '${alias}' to '${newAlias}'`,
125
+ )
126
+ return
127
+ }
128
+
129
+ case "remote": {
130
+ const alias = yield* requireArg(
131
+ options.args,
132
+ 0,
133
+ "Usage: agency repo remote <alias> [remote]",
134
+ )
135
+ const item = yield* repositories.remote(alias, options.args[1], cwd)
136
+ log(
137
+ options.json
138
+ ? JSON.stringify(item, null, 2)
139
+ : (item.remote ?? "No origin remote configured"),
140
+ )
141
+ return
142
+ }
143
+
144
+ case "verify": {
145
+ const alias = yield* requireArg(
146
+ options.args,
147
+ 0,
148
+ "Usage: agency repo verify <alias>",
149
+ )
150
+ const report = yield* repositories.verify(alias, cwd)
151
+ if (options.json) log(JSON.stringify(report, null, 2))
152
+ if (!report.valid) {
153
+ return yield* Effect.fail(
154
+ new Error(
155
+ `Repository '${alias}' verification failed:\n${report.issues.map((issue) => `- ${issue}`).join("\n")}`,
156
+ ),
157
+ )
158
+ }
159
+ if (!options.json) log(`Verified repository '${alias}'`)
160
+ return
161
+ }
162
+
64
163
  default:
65
164
  return yield* Effect.fail(
66
165
  new Error(
67
- "Subcommand is required. Available subcommands: add, link, list",
166
+ "Subcommand is required. Available subcommands: add, link, list, show, fetch, remove, unlink, rename, remote, verify",
68
167
  ),
69
168
  )
70
169
  }
@@ -77,6 +176,13 @@ Subcommands:
77
176
  add <alias> <remote> Create a bare clone
78
177
  link <alias> <path> Link an existing Git repository
79
178
  list List repository aliases
179
+ show <alias> Show a repository alias
180
+ fetch <alias> Fetch and prune a repository
181
+ remove <alias> Remove an unused repository alias
182
+ unlink <alias> Remove an unused linked alias
183
+ rename <old> <new> Rename an unused repository alias
184
+ remote <alias> [url] Show or update the origin remote
185
+ verify <alias> Verify repository operation
80
186
 
81
187
  Options:
82
188
  --json Output repository aliases as JSON
@@ -108,6 +108,53 @@ describe("workbase command", () => {
108
108
  ).toEqual({ version: 2, workbases: [] })
109
109
  })
110
110
 
111
+ test("names, shows, and clears a workbase name", async () => {
112
+ const added = await captureLogs(() =>
113
+ runTestEffect(
114
+ workbase({
115
+ subcommand: "add",
116
+ args: [root],
117
+ configDirectory,
118
+ json: true,
119
+ }),
120
+ ),
121
+ )
122
+ const registration = JSON.parse(added[0]!)
123
+ await runTestEffect(
124
+ workbase({
125
+ subcommand: "name",
126
+ args: [registration.id, "primary"],
127
+ configDirectory,
128
+ silent: true,
129
+ }),
130
+ )
131
+
132
+ const shown = await captureLogs(() =>
133
+ runTestEffect(
134
+ workbase({
135
+ subcommand: "show",
136
+ args: ["primary"],
137
+ configDirectory,
138
+ json: true,
139
+ }),
140
+ ),
141
+ )
142
+ expect(JSON.parse(shown[0]!).name).toBe("primary")
143
+
144
+ await runTestEffect(
145
+ workbase({
146
+ subcommand: "name",
147
+ args: [registration.id],
148
+ clear: true,
149
+ configDirectory,
150
+ silent: true,
151
+ }),
152
+ )
153
+ expect(
154
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
155
+ ).toEqual({ version: 2, workbases: [registration] })
156
+ })
157
+
111
158
  test("requires an add path", async () => {
112
159
  await expect(
113
160
  runTestEffect(
@@ -51,6 +51,54 @@ export const workbase = (options: WorkbaseOptions) =>
51
51
  }
52
52
  return
53
53
  }
54
+ case "show": {
55
+ const selector = options.args[0]
56
+ if (!selector) {
57
+ return yield* Effect.fail(
58
+ new Error("Usage: agency workbase show <selector>"),
59
+ )
60
+ }
61
+ const entry = yield* service.showRegistered(
62
+ selector,
63
+ options.configDirectory,
64
+ options.cwd,
65
+ )
66
+ log(
67
+ options.json
68
+ ? JSON.stringify(entry, null, 2)
69
+ : `${entry.name ?? entry.id}\t${entry.path}`,
70
+ )
71
+ return
72
+ }
73
+ case "name": {
74
+ const selector = options.args[0]
75
+ const name = options.clear ? null : options.args[1]
76
+ if (
77
+ !selector ||
78
+ name === undefined ||
79
+ (options.clear && options.args[1] !== undefined)
80
+ ) {
81
+ return yield* Effect.fail(
82
+ new Error(
83
+ "Usage: agency workbase name <selector> <name> | --clear",
84
+ ),
85
+ )
86
+ }
87
+ const entry = yield* service.nameRegistered(
88
+ selector,
89
+ name,
90
+ options.configDirectory,
91
+ options.cwd,
92
+ )
93
+ log(
94
+ options.json
95
+ ? JSON.stringify(entry, null, 2)
96
+ : name === null
97
+ ? `Cleared name for ${entry.id}`
98
+ : `Named workbase ${name}`,
99
+ )
100
+ return
101
+ }
54
102
  case "remove": {
55
103
  const entry = yield* service.removeRegistered(
56
104
  options.args[0]!,
@@ -89,6 +137,7 @@ export const workbase = (options: WorkbaseOptions) =>
89
137
  const entry = yield* service.setDefault(
90
138
  selector,
91
139
  options.configDirectory,
140
+ options.cwd,
92
141
  )
93
142
  log(
94
143
  options.json
@@ -102,7 +151,7 @@ export const workbase = (options: WorkbaseOptions) =>
102
151
  default:
103
152
  return yield* Effect.fail(
104
153
  new Error(
105
- "Subcommand is required. Available: add, list, remove, prune, default",
154
+ "Subcommand is required. Available: add, list, show, name, remove, prune, default",
106
155
  ),
107
156
  )
108
157
  }
@@ -114,6 +163,8 @@ Usage: agency workbase <subcommand>
114
163
  Subcommands:
115
164
  add <path> Register an Agency workbase
116
165
  list List registered workbases
166
+ show <selector> Show a registered workbase
167
+ name <selector> Set or clear a registered workbase name
117
168
  remove <selector> Remove a registered workbase
118
169
  prune Remove registrations whose paths no longer exist
119
170
  default [selector] Show or set the default workbase
@@ -850,4 +850,63 @@ describe("ArchiveService", () => {
850
850
  ),
851
851
  ).rejects.toThrow("is archived; restore it")
852
852
  })
853
+
854
+ test("preflights every phase worktree before removing any of them", async () => {
855
+ await runTestEffect(
856
+ TaskService.pipe(
857
+ Effect.flatMap((service) =>
858
+ service.create(
859
+ {
860
+ id: "multi-dirty",
861
+ ticketUrl: "https://example.com/task",
862
+ multiPhase: true,
863
+ },
864
+ root,
865
+ ),
866
+ ),
867
+ ),
868
+ )
869
+ for (const id of ["clean", "dirty"]) {
870
+ await runTestEffect(
871
+ PhaseService.pipe(
872
+ Effect.flatMap((service) =>
873
+ service.create(
874
+ {
875
+ taskId: "multi-dirty",
876
+ id,
877
+ repo: "agency",
878
+ branch: `task/${id}`,
879
+ base: "main",
880
+ },
881
+ root,
882
+ ),
883
+ ),
884
+ ),
885
+ )
886
+ await runTestEffect(
887
+ WorktreeService.pipe(
888
+ Effect.flatMap((service) =>
889
+ service.materialize("multi-dirty", id, root),
890
+ ),
891
+ ),
892
+ )
893
+ }
894
+ await Bun.write(
895
+ join(root, "tasks/multi-dirty/phases/dirty/code/agency/dirty.txt"),
896
+ "keep me\n",
897
+ )
898
+
899
+ await expect(
900
+ runTestEffect(
901
+ ArchiveService.pipe(
902
+ Effect.flatMap((service) => service.archiveTask("multi-dirty", root)),
903
+ ),
904
+ ),
905
+ ).rejects.toThrow("uncommitted changes")
906
+ expect(
907
+ await Bun.file(
908
+ join(root, "tasks/multi-dirty/phases/clean/code/agency/README.md"),
909
+ ).exists(),
910
+ ).toBe(true)
911
+ })
853
912
  })