@markjaquith/agency 2.6.0 → 2.7.1

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,15 @@ const commands: Record<string, Command> = {
239
239
  console.log(workHelp)
240
240
  return
241
241
  }
242
+ if (args.length > 1) {
243
+ throw new Error(
244
+ "Usage: agency work [<directory-or-task-id> | --epic <epic-id>]",
245
+ )
246
+ }
242
247
 
243
248
  await runCommand(
244
249
  work({
245
- taskId: args[0],
246
- phaseId: args[1],
250
+ directory: args[0],
247
251
  epicId: options.epic,
248
252
  silent: options.silent,
249
253
  verbose: options.verbose,
@@ -299,7 +303,7 @@ Commands:
299
303
  phase <subcommand> Manage task phases
300
304
  archive <type> Archive a work item
301
305
  task <subcommand> Manage tasks
302
- work [task] [phase] Work on an epic, task, or phase
306
+ work [directory|task] Work on an epic, task, or phase
303
307
  pr create Create a pull request for an execution unit
304
308
  repo <subcommand> Manage workbase repositories
305
309
  status Show status for the current workbase
@@ -314,7 +318,7 @@ Global Options:
314
318
  Examples:
315
319
  agency init # Initialize the current directory
316
320
  agency task list # List tasks
317
- agency work refresh-cli-copy # Start working on a task
321
+ agency work tasks/refresh-cli-copy # Start working on a task
318
322
 
319
323
  For more information about a command, run:
320
324
  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.1",
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 () => {
@@ -46,12 +46,14 @@ interface HarnessOptions {
46
46
  readonly phaseRecords?: readonly any[]
47
47
  readonly outsideWorkbase?: boolean
48
48
  readonly registeredWorkbases?: readonly string[]
49
+ readonly existingDirectories?: readonly string[]
49
50
  }
50
51
 
51
52
  const createHarness = (options: HarnessOptions = {}) => {
52
53
  const events: string[] = []
53
54
  const probes: string[] = []
54
55
  const statusUpdates: string[] = []
56
+ const shownTasks: string[] = []
55
57
  const progressUpdates: string[] = []
56
58
  const launches: Array<{
57
59
  cli: string
@@ -95,14 +97,16 @@ const createHarness = (options: HarnessOptions = {}) => {
95
97
  list: () => Effect.succeed(options.epicRecords ?? []),
96
98
  }
97
99
  const tasks = {
98
- show: (id: string) =>
99
- Effect.succeed({
100
+ show: (id: string) => {
101
+ shownTasks.push(id)
102
+ return Effect.succeed({
100
103
  id,
101
104
  path: `/workbase/tasks/${id}/TASK.md`,
102
105
  data: options.multiPhaseTasks?.includes(id)
103
106
  ? { phases: [] }
104
107
  : { repo: "agency", branch: `task/${id}`, base: "main" },
105
- }),
108
+ })
109
+ },
106
110
  list: () => Effect.succeed(options.taskRecords ?? []),
107
111
  setStatus: (id: string, status: string) => {
108
112
  statusUpdates.push(`task:${id}:${status}`)
@@ -124,6 +128,8 @@ const createHarness = (options: HarnessOptions = {}) => {
124
128
  },
125
129
  }
126
130
  const fs = {
131
+ isDirectory: (path: string) =>
132
+ Effect.succeed(options.existingDirectories?.includes(path) ?? true),
127
133
  runCommand: (args: readonly string[]) => {
128
134
  const cli = args[1] as "opencode" | "claude"
129
135
  events.push(`probe:${cli}`)
@@ -168,6 +174,7 @@ const createHarness = (options: HarnessOptions = {}) => {
168
174
  launches,
169
175
  materializeOptions,
170
176
  statusUpdates,
177
+ shownTasks,
171
178
  progressUpdates,
172
179
  run,
173
180
  }
@@ -177,7 +184,11 @@ describe("work command", () => {
177
184
  test("launches an epic agent from an epic directory", async () => {
178
185
  const harness = createHarness()
179
186
 
180
- await harness.run({ cwd: "/workbase/epics/delivery", opencode: true })
187
+ await harness.run({
188
+ cwd: "/workbase/epics/delivery",
189
+ directory: ".",
190
+ opencode: true,
191
+ })
181
192
 
182
193
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
183
194
  expect(harness.launches[0]).toEqual({
@@ -187,10 +198,42 @@ describe("work command", () => {
187
198
  })
188
199
  })
189
200
 
201
+ test("resolves an existing positional path before treating it as a task ID", async () => {
202
+ const harness = createHarness({
203
+ existingDirectories: ["/workbase/tasks/delivery"],
204
+ })
205
+
206
+ await harness.run({
207
+ cwd: "/workbase/tasks/delivery",
208
+ directory: ".",
209
+ opencode: true,
210
+ })
211
+
212
+ expect(harness.shownTasks).toEqual(["delivery"])
213
+ expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/delivery")
214
+ })
215
+
216
+ test("treats a positional value as a task ID when it is not a directory", async () => {
217
+ const harness = createHarness({ existingDirectories: [] })
218
+
219
+ await harness.run({
220
+ cwd: "/workbase",
221
+ directory: "delivery",
222
+ opencode: true,
223
+ })
224
+
225
+ expect(harness.shownTasks).toEqual(["delivery"])
226
+ expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/delivery")
227
+ })
228
+
190
229
  test("launches a multi-phase task agent without materializing", async () => {
191
230
  const harness = createHarness({ multiPhaseTasks: ["delivery"] })
192
231
 
193
- await harness.run({ cwd: "/workbase/tasks/delivery", opencode: true })
232
+ await harness.run({
233
+ cwd: "/workbase/tasks/delivery",
234
+ directory: ".",
235
+ opencode: true,
236
+ })
194
237
 
195
238
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
196
239
  expect(harness.launches[0]).toEqual({
@@ -205,6 +248,7 @@ describe("work command", () => {
205
248
 
206
249
  await harness.run({
207
250
  cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
251
+ directory: ".",
208
252
  opencode: true,
209
253
  })
210
254
 
@@ -224,6 +268,7 @@ describe("work command", () => {
224
268
 
225
269
  await harness.run({
226
270
  cwd: "/workbase/tasks/example/code/agency/src",
271
+ directory: ".",
227
272
  opencode: true,
228
273
  })
229
274
 
@@ -231,7 +276,7 @@ describe("work command", () => {
231
276
  expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
232
277
  })
233
278
 
234
- test("selects a target with fzf outside an entity directory", async () => {
279
+ test("selects a target with fzf when no directory is provided", async () => {
235
280
  const phase = {
236
281
  taskId: "delivery",
237
282
  id: "build",
@@ -254,7 +299,7 @@ describe("work command", () => {
254
299
  choices.find((choice) => choice.label.includes("build"))!.target,
255
300
  )
256
301
 
257
- await harness.run({ cwd: "/workbase", opencode: true }, pick)
302
+ await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
258
303
 
259
304
  expect(harness.events).toEqual([
260
305
  "probe:fzf",
@@ -448,7 +493,7 @@ describe("work command", () => {
448
493
  verboseHarness.run({ taskId: "example", verbose: true }),
449
494
  )
450
495
  expect(verboseLogs).toEqual([
451
- "Launching opencode in /workbase/tasks/example",
496
+ "Launching command: opencode --continue (cwd: /workbase/tasks/example)",
452
497
  ])
453
498
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
454
499
 
@@ -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,14 @@ 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 directoryPath = options.directory
81
+ ? resolve(cwd, options.directory)
82
+ : undefined
83
+ const isDirectory = directoryPath
84
+ ? yield* fs.isDirectory(directoryPath)
85
+ : false
86
+ const startPath = isDirectory && directoryPath ? directoryPath : cwd
87
+ const root = yield* resolveWorkbase(startPath, log, pickBase)
68
88
  if (!root) return
69
89
 
70
90
  let target: WorkTarget | null = null
@@ -89,8 +109,16 @@ export const work = (
89
109
  multiPhase: "phases" in task.data,
90
110
  }
91
111
  }
92
- } else {
93
- const path = relative(root, resolve(cwd))
112
+ } else if (options.directory && !isDirectory) {
113
+ const task = yield* tasks.show(options.directory, root)
114
+ target = {
115
+ kind: "task",
116
+ taskId: task.id,
117
+ path: task.path,
118
+ multiPhase: "phases" in task.data,
119
+ }
120
+ } else if (directoryPath) {
121
+ const path = relative(root, startPath)
94
122
  const parts =
95
123
  !path || isAbsolute(path) || path.startsWith(`..${sep}`)
96
124
  ? []
@@ -145,7 +173,7 @@ export const work = (
145
173
  for (const choice of choices) log(choice.label)
146
174
  return yield* Effect.fail(
147
175
  new Error(
148
- "fzf is required to select a work target; install fzf or provide a target explicitly",
176
+ "fzf is required to select a work target; install fzf or provide a directory explicitly",
149
177
  ),
150
178
  )
151
179
  }
@@ -199,16 +227,19 @@ export const work = (
199
227
  yield* tasks.setStatus(target.taskId, "working", root)
200
228
  }
201
229
 
202
- verboseLog(`Launching ${cli} in ${launchPath}`)
203
230
  const args = cli === "opencode" ? ["--continue"] : [prompt]
231
+ verboseLog(
232
+ `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
233
+ )
204
234
  launch(cli, [cli, ...args], launchPath)
205
235
  })
206
236
 
207
237
  export const help = `
208
- Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
238
+ Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
209
239
 
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
240
+ Launch an agent for an epic, task, or phase. With no directory, select one
241
+ with fzf. A positional argument resolves as a directory first, then as a task
242
+ ID. Use '.' for the current directory. Outside a workbase, select a registered
212
243
  workbase first.
213
244
 
214
245
  Options:
@@ -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)