@markjaquith/agency 2.6.0 → 2.7.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
@@ -230,7 +230,7 @@ agency repo link backend ~/Dev/backend
230
230
  agency task new
231
231
 
232
232
  agency validate
233
- agency work refresh-copy
233
+ agency work tasks/refresh-copy
234
234
  agency pr create refresh-copy
235
235
  ```
236
236
 
@@ -350,21 +350,21 @@ before moving files, refuses dirty worktrees, and preserves branches.
350
350
  ### Work and Pull Requests
351
351
 
352
352
  ```text
353
- agency work [<task-id> [phase-id] | --epic <epic-id>] [--opencode | --claude]
353
+ agency work [<directory> | --epic <epic-id>] [--opencode | --claude]
354
354
  agency pr create <task-id> [phase-id] [--draft] [--json]
355
355
  ```
356
356
 
357
- `agency work` infers an epic, task, or phase from the current directory. From
358
- elsewhere in the workbase, it presents the full hierarchy in `fzf`. Outside a
359
- workbase, it first presents the registered workbases, then the selected
357
+ `agency work` presents the full hierarchy in `fzf`. Pass a directory, including
358
+ `.` for the current directory, to infer its epic, task, or phase. Outside a
359
+ workbase, Agency first presents the registered workbases, then the selected
360
360
  workbase's hierarchy. If `fzf` is not installed, Agency prints the available
361
- choices and asks for an explicit target.
361
+ choices and asks for an explicit directory.
362
362
 
363
363
  Epic and multi-phase task targets launch orchestration agents beside their
364
364
  documents. Single-phase tasks and phases fetch repositories, create or reuse
365
365
  worktrees under `code/`, and launch an execution agent in the writable checkout
366
- with absolute context paths. Explicit task, phase, and `--epic` targets override
367
- current-directory inference.
366
+ with absolute context paths. An explicit directory or `--epic` target bypasses
367
+ the hierarchy chooser.
368
368
 
369
369
  Each writable `(repo, branch)` pair may belong to only one task or phase. Agency
370
370
  validation reports duplicate ownership, and `agency work` checks Git's worktree
package/cli.ts CHANGED
@@ -239,11 +239,13 @@ const commands: Record<string, Command> = {
239
239
  console.log(workHelp)
240
240
  return
241
241
  }
242
+ if (args.length > 1) {
243
+ throw new Error("Usage: agency work [<directory> | --epic <epic-id>]")
244
+ }
242
245
 
243
246
  await runCommand(
244
247
  work({
245
- taskId: args[0],
246
- phaseId: args[1],
248
+ directory: args[0],
247
249
  epicId: options.epic,
248
250
  silent: options.silent,
249
251
  verbose: options.verbose,
@@ -299,7 +301,7 @@ Commands:
299
301
  phase <subcommand> Manage task phases
300
302
  archive <type> Archive a work item
301
303
  task <subcommand> Manage tasks
302
- work [task] [phase] Work on an epic, task, or phase
304
+ work [directory] Work on an epic, task, or phase
303
305
  pr create Create a pull request for an execution unit
304
306
  repo <subcommand> Manage workbase repositories
305
307
  status Show status for the current workbase
@@ -314,7 +316,7 @@ Global Options:
314
316
  Examples:
315
317
  agency init # Initialize the current directory
316
318
  agency task list # List tasks
317
- agency work refresh-cli-copy # Start working on a task
319
+ agency work tasks/refresh-cli-copy # Start working on a task
318
320
 
319
321
  For more information about a command, run:
320
322
  agency <command> --help
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -246,15 +246,15 @@ Outside a workbase, omitting path opens the registered-workbase picker.
246
246
 
247
247
  ```bash
248
248
  agency work
249
- agency work <task-id> [phase-id]
249
+ agency work <directory>
250
250
  agency work --epic <epic-id>
251
251
  ```
252
252
 
253
253
  Use `--opencode` or `--claude` to require a specific agent. This command fetches
254
254
  repositories for execution targets, creates or reuses their worktrees, and
255
- replaces the current process with the selected agent. It infers the nearest
256
- epic, task, or phase from the current directory; otherwise it opens an `fzf`
257
- picker containing the workbase hierarchy.
255
+ replaces the current process with the selected agent. With no directory it opens
256
+ an `fzf` picker containing the workbase hierarchy. Pass `.`, or another
257
+ directory, to infer the nearest epic, task, or phase.
258
258
  Outside a workbase, it first opens a picker containing registered workbases.
259
259
 
260
260
  Epic and multi-phase task targets are orchestration sessions launched beside
@@ -39,6 +39,9 @@ describe("init command", () => {
39
39
  expect(
40
40
  await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
41
41
  ).toContain('"path": "../tasks"')
42
+ expect(
43
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).text(),
44
+ ).toContain(`"${join(root, "tasks")}/*": "allow"`)
42
45
  })
43
46
 
44
47
  test("preserves existing gitignore entries", async () => {
@@ -124,6 +124,7 @@ const createHarness = (options: HarnessOptions = {}) => {
124
124
  },
125
125
  }
126
126
  const fs = {
127
+ isDirectory: () => Effect.succeed(true),
127
128
  runCommand: (args: readonly string[]) => {
128
129
  const cli = args[1] as "opencode" | "claude"
129
130
  events.push(`probe:${cli}`)
@@ -177,7 +178,11 @@ describe("work command", () => {
177
178
  test("launches an epic agent from an epic directory", async () => {
178
179
  const harness = createHarness()
179
180
 
180
- await harness.run({ cwd: "/workbase/epics/delivery", opencode: true })
181
+ await harness.run({
182
+ cwd: "/workbase/epics/delivery",
183
+ directory: ".",
184
+ opencode: true,
185
+ })
181
186
 
182
187
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
183
188
  expect(harness.launches[0]).toEqual({
@@ -190,7 +195,11 @@ describe("work command", () => {
190
195
  test("launches a multi-phase task agent without materializing", async () => {
191
196
  const harness = createHarness({ multiPhaseTasks: ["delivery"] })
192
197
 
193
- await harness.run({ cwd: "/workbase/tasks/delivery", opencode: true })
198
+ await harness.run({
199
+ cwd: "/workbase/tasks/delivery",
200
+ directory: ".",
201
+ opencode: true,
202
+ })
194
203
 
195
204
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
196
205
  expect(harness.launches[0]).toEqual({
@@ -205,6 +214,7 @@ describe("work command", () => {
205
214
 
206
215
  await harness.run({
207
216
  cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
217
+ directory: ".",
208
218
  opencode: true,
209
219
  })
210
220
 
@@ -224,6 +234,7 @@ describe("work command", () => {
224
234
 
225
235
  await harness.run({
226
236
  cwd: "/workbase/tasks/example/code/agency/src",
237
+ directory: ".",
227
238
  opencode: true,
228
239
  })
229
240
 
@@ -231,7 +242,7 @@ describe("work command", () => {
231
242
  expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
232
243
  })
233
244
 
234
- test("selects a target with fzf outside an entity directory", async () => {
245
+ test("selects a target with fzf when no directory is provided", async () => {
235
246
  const phase = {
236
247
  taskId: "delivery",
237
248
  id: "build",
@@ -254,7 +265,7 @@ describe("work command", () => {
254
265
  choices.find((choice) => choice.label.includes("build"))!.target,
255
266
  )
256
267
 
257
- await harness.run({ cwd: "/workbase", opencode: true }, pick)
268
+ await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
258
269
 
259
270
  expect(harness.events).toEqual([
260
271
  "probe:fzf",
@@ -448,7 +459,7 @@ describe("work command", () => {
448
459
  verboseHarness.run({ taskId: "example", verbose: true }),
449
460
  )
450
461
  expect(verboseLogs).toEqual([
451
- "Launching opencode in /workbase/tasks/example",
462
+ "Launching command: opencode --continue (cwd: /workbase/tasks/example)",
452
463
  ])
453
464
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
454
465
 
@@ -23,6 +23,7 @@ import {
23
23
  } from "../workbase/workbase-choice"
24
24
 
25
25
  interface WorkOptions extends BaseCommandOptions {
26
+ readonly directory?: string
26
27
  readonly taskId?: string
27
28
  readonly phaseId?: string
28
29
  readonly epicId?: string
@@ -32,6 +33,15 @@ interface WorkOptions extends BaseCommandOptions {
32
33
 
33
34
  type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
34
35
 
36
+ const formatCommand = (args: readonly string[]) =>
37
+ args
38
+ .map((argument) =>
39
+ /^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
40
+ ? argument
41
+ : `'${argument.replaceAll("'", `'\\''`)}'`,
42
+ )
43
+ .join(" ")
44
+
35
45
  const launchAgent: LaunchAgent = (cli, args, cwd) => {
36
46
  process.chdir(cwd)
37
47
  execvp(cli, [...args])
@@ -50,9 +60,12 @@ export const work = (
50
60
  new Error("Cannot use both --opencode and --claude"),
51
61
  )
52
62
  }
53
- if (options.epicId && (options.taskId || options.phaseId)) {
63
+ if (
64
+ options.epicId &&
65
+ (options.directory || options.taskId || options.phaseId)
66
+ ) {
54
67
  return yield* Effect.fail(
55
- new Error("Cannot combine --epic with a task or phase ID"),
68
+ new Error("Cannot combine --epic with a directory, task, or phase ID"),
56
69
  )
57
70
  }
58
71
 
@@ -64,7 +77,13 @@ export const work = (
64
77
  const phases = yield* PhaseService
65
78
  const { log, verboseLog } = createLoggers(options)
66
79
  const cwd = options.cwd ?? process.cwd()
67
- const root = yield* resolveWorkbase(cwd, log, pickBase)
80
+ const startPath = options.directory ? resolve(cwd, options.directory) : cwd
81
+ if (options.directory && !(yield* fs.isDirectory(startPath))) {
82
+ return yield* Effect.fail(
83
+ new Error(`Work directory does not exist: ${startPath}`),
84
+ )
85
+ }
86
+ const root = yield* resolveWorkbase(startPath, log, pickBase)
68
87
  if (!root) return
69
88
 
70
89
  let target: WorkTarget | null = null
@@ -89,8 +108,8 @@ export const work = (
89
108
  multiPhase: "phases" in task.data,
90
109
  }
91
110
  }
92
- } else {
93
- const path = relative(root, resolve(cwd))
111
+ } else if (options.directory) {
112
+ const path = relative(root, startPath)
94
113
  const parts =
95
114
  !path || isAbsolute(path) || path.startsWith(`..${sep}`)
96
115
  ? []
@@ -145,7 +164,7 @@ export const work = (
145
164
  for (const choice of choices) log(choice.label)
146
165
  return yield* Effect.fail(
147
166
  new Error(
148
- "fzf is required to select a work target; install fzf or provide a target explicitly",
167
+ "fzf is required to select a work target; install fzf or provide a directory explicitly",
149
168
  ),
150
169
  )
151
170
  }
@@ -199,17 +218,19 @@ export const work = (
199
218
  yield* tasks.setStatus(target.taskId, "working", root)
200
219
  }
201
220
 
202
- verboseLog(`Launching ${cli} in ${launchPath}`)
203
221
  const args = cli === "opencode" ? ["--continue"] : [prompt]
222
+ verboseLog(
223
+ `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
224
+ )
204
225
  launch(cli, [cli, ...args], launchPath)
205
226
  })
206
227
 
207
228
  export const help = `
208
- Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
229
+ Usage: agency work [<directory> | --epic <epic-id>]
209
230
 
210
- Launch an agent for the current epic, task, or phase. Outside an entity
211
- directory, select one with fzf. Outside a workbase, select a registered
212
- workbase first.
231
+ Launch an agent for an epic, task, or phase. With no directory, select one
232
+ with fzf. Use '.' for the current directory. Outside a workbase, select a
233
+ registered workbase first.
213
234
 
214
235
  Options:
215
236
  --epic <id> Work on an epic
@@ -52,7 +52,7 @@ describe("WorkbaseService", () => {
52
52
  managedWorkbaseAgents,
53
53
  )
54
54
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
55
- managedWorkbaseOpencode,
55
+ managedWorkbaseOpencode(root),
56
56
  )
57
57
  })
58
58
 
@@ -127,7 +127,7 @@ describe("WorkbaseService", () => {
127
127
  )
128
128
 
129
129
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
130
- managedWorkbaseOpencode,
130
+ managedWorkbaseOpencode(root),
131
131
  )
132
132
  })
133
133
 
@@ -142,19 +142,17 @@ const ensureWorkbaseOpencode = (root: string) =>
142
142
  const fs = yield* FileSystemService
143
143
  const directory = join(root, ".opencode")
144
144
  const path = join(directory, "opencode.jsonc")
145
+ const managed = managedWorkbaseOpencode(root)
145
146
  if (!(yield* fs.exists(path))) {
146
147
  if (yield* fs.exists(join(directory, "opencode.json"))) return
147
148
  yield* fs.createDirectory(directory)
148
- yield* fs.writeFile(path, managedWorkbaseOpencode)
149
+ yield* fs.writeFile(path, managed)
149
150
  return
150
151
  }
151
152
 
152
153
  const content = yield* fs.readFile(path)
153
- if (
154
- content !== managedWorkbaseOpencode &&
155
- canUpdateManagedWorkbaseOpencode(content)
156
- ) {
157
- yield* fs.writeFile(path, managedWorkbaseOpencode)
154
+ if (content !== managed && canUpdateManagedWorkbaseOpencode(content)) {
155
+ yield* fs.writeFile(path, managed)
158
156
  }
159
157
  })
160
158
 
@@ -85,6 +85,51 @@ describe("WorktreeService", () => {
85
85
  expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
86
86
  })
87
87
 
88
+ test("does not fetch origins for existing worktrees", async () => {
89
+ await runTestEffect(
90
+ TaskService.pipe(
91
+ Effect.flatMap((service) =>
92
+ service.create(
93
+ {
94
+ id: "existing",
95
+ ticketUrl: "https://example.com/task",
96
+ repo: "agency",
97
+ repos: [{ repo: "effect", ref: "main" }],
98
+ branch: "task/existing",
99
+ base: "main",
100
+ },
101
+ root,
102
+ ),
103
+ ),
104
+ ),
105
+ )
106
+ await runTestEffect(
107
+ WorktreeService.pipe(
108
+ Effect.flatMap((service) =>
109
+ service.materialize("existing", undefined, root),
110
+ ),
111
+ ),
112
+ )
113
+ await git(
114
+ ["remote", "set-url", "origin", join(root, "missing")],
115
+ join(root, "repos/agency"),
116
+ )
117
+ await git(
118
+ ["remote", "set-url", "origin", join(root, "missing")],
119
+ join(root, "repos/effect"),
120
+ )
121
+
122
+ await expect(
123
+ runTestEffect(
124
+ WorktreeService.pipe(
125
+ Effect.flatMap((service) =>
126
+ service.materialize("existing", undefined, root),
127
+ ),
128
+ ),
129
+ ),
130
+ ).resolves.toMatchObject({ repo: "agency" })
131
+ })
132
+
88
133
  test("uses a configured worktree creation command", async () => {
89
134
  await Bun.write(
90
135
  join(root, "agency.json"),
@@ -136,11 +136,13 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
136
136
  })
137
137
  }
138
138
 
139
- const remote = yield* fs.runCommand(
140
- ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
141
- { captureOutput: true },
142
- )
143
- if (remote.exitCode === 0) {
139
+ const fetchOrigin = Effect.gen(function* () {
140
+ const remote = yield* fs.runCommand(
141
+ ["git", "-C", repositoryPath, "remote", "get-url", "origin"],
142
+ { captureOutput: true },
143
+ )
144
+ if (remote.exitCode !== 0) return
145
+
144
146
  const fetch = yield* fs.runCommand(
145
147
  ["git", "-C", repositoryPath, "fetch", "origin"],
146
148
  { captureOutput: true },
@@ -150,7 +152,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
150
152
  message: `Failed to fetch '${alias}': ${fetch.stderr}`,
151
153
  })
152
154
  }
153
- }
155
+ })
154
156
 
155
157
  const listed = yield* fs.runCommand(
156
158
  [
@@ -208,6 +210,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
208
210
  message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
209
211
  })
210
212
  }
213
+ yield* fetchOrigin
211
214
 
212
215
  let args: string[]
213
216
  let env: Record<string, string> | undefined
@@ -341,6 +344,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
341
344
  message: `Worktree registry contains a missing checkout at ${checkoutPath}`,
342
345
  })
343
346
  }
347
+ yield* fetchOrigin
344
348
  const result = yield* fs.runCommand(
345
349
  [
346
350
  "git",
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto"
2
+ import { join } from "node:path"
2
3
 
3
4
  const managedHeaderPattern =
4
5
  /^\/\/ agency-managed: sha256=([a-f0-9]{64})\r?\n\r?\n/
@@ -6,28 +7,36 @@ const managedHeaderPattern =
6
7
  const checksum = (content: string) =>
7
8
  createHash("sha256").update(content).digest("hex")
8
9
 
9
- const body = `${JSON.stringify(
10
- {
11
- $schema: "https://opencode.ai/config.json",
12
- references: {
13
- tasks: {
14
- path: "../tasks",
15
- description: "Agency task definitions and execution context",
10
+ const body = (root: string) =>
11
+ `${JSON.stringify(
12
+ {
13
+ $schema: "https://opencode.ai/config.json",
14
+ references: {
15
+ tasks: {
16
+ path: "../tasks",
17
+ description: "Agency task definitions and execution context",
18
+ },
19
+ epics: {
20
+ path: "../epics",
21
+ description: "Agency epic definitions and orchestration context",
22
+ },
16
23
  },
17
- epics: {
18
- path: "../epics",
19
- description: "Agency epic definitions and orchestration context",
24
+ permission: {
25
+ external_directory: {
26
+ [`${join(root, "tasks")}/*`]: "allow",
27
+ [`${join(root, "epics")}/*`]: "allow",
28
+ },
20
29
  },
21
30
  },
22
- },
23
- null,
24
- 2,
25
- )}\n`
31
+ null,
32
+ 2,
33
+ )}\n`
26
34
 
27
- const renderManagedWorkbaseOpencode = (content: string = body) =>
35
+ const renderManagedWorkbaseOpencode = (content: string) =>
28
36
  `// agency-managed: sha256=${checksum(content)}\n\n${content}`
29
37
 
30
- export const managedWorkbaseOpencode = renderManagedWorkbaseOpencode()
38
+ export const managedWorkbaseOpencode = (root: string) =>
39
+ renderManagedWorkbaseOpencode(body(root))
31
40
 
32
41
  export const canUpdateManagedWorkbaseOpencode = (content: string) => {
33
42
  const match = content.match(managedHeaderPattern)