@markjaquith/agency 2.13.0 → 2.15.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.
@@ -172,13 +172,13 @@ describe("task and phase command JSON output", () => {
172
172
  runTestEffect(
173
173
  phase({
174
174
  subcommand: "status",
175
- args: ["multi", "first", "delegated"],
175
+ args: ["multi", "first", "done"],
176
176
  cwd: root,
177
177
  json: true,
178
178
  }),
179
179
  ),
180
180
  )
181
- expect(JSON.parse(phaseLogs[0]!).data.status).toBe("delegated")
181
+ expect(JSON.parse(phaseLogs[0]!).data.status).toBe("done")
182
182
 
183
183
  await runTestEffect(
184
184
  task({
@@ -196,13 +196,13 @@ describe("task and phase command JSON output", () => {
196
196
  runTestEffect(
197
197
  task({
198
198
  subcommand: "status",
199
- args: ["single-status", "delegated"],
199
+ args: ["single-status", "done"],
200
200
  cwd: root,
201
201
  json: true,
202
202
  }),
203
203
  ),
204
204
  )
205
- expect(JSON.parse(taskLogs[0]!).data.status).toBe("delegated")
205
+ expect(JSON.parse(taskLogs[0]!).data.status).toBe("done")
206
206
  })
207
207
 
208
208
  test("converts a single-phase task with an explicit first phase ID", async () => {
@@ -6,6 +6,8 @@ import { EpicService } from "../services/EpicService"
6
6
  import { RepositoryService } from "../services/RepositoryService"
7
7
  import { createLoggers } from "../utils/effect"
8
8
  import { parseRepositoryReferences } from "../workbase/repository-reference"
9
+ import { WorkbaseService } from "../services/WorkbaseService"
10
+ import { choose } from "../utils/chooser"
9
11
 
10
12
  interface TaskOptions extends BaseCommandOptions {
11
13
  readonly subcommand?: string
@@ -29,7 +31,9 @@ export interface TaskInteraction {
29
31
  ) => Effect.Effect<string | null, Error>
30
32
  }
31
33
 
32
- const defaultInteraction: TaskInteraction = {
34
+ const defaultInteraction = (
35
+ chooserCommand?: readonly string[],
36
+ ): TaskInteraction => ({
33
37
  text: (prompt) =>
34
38
  Effect.tryPromise({
35
39
  try: async () => {
@@ -46,37 +50,23 @@ const defaultInteraction: TaskInteraction = {
46
50
  catch: (cause) => new Error("Failed to read task input", { cause }),
47
51
  }),
48
52
  select: (prompt, choices) =>
49
- Effect.tryPromise({
50
- try: async () => {
51
- const process = Bun.spawn(
52
- ["fzf", `--prompt=${prompt}> `, "--height=~40%", "--reverse"],
53
- {
54
- stdin: new Blob([choices.join("\n")]),
55
- stdout: "pipe",
56
- stderr: "inherit",
57
- },
58
- )
59
- const [exitCode, output] = await Promise.all([
60
- process.exited,
61
- new Response(process.stdout).text(),
62
- ])
63
- if (exitCode === 1 || exitCode === 130) return null
64
- if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
65
- return output.trim() || null
66
- },
67
- catch: (cause) =>
68
- new Error("Failed to select task input with fzf", { cause }),
69
- }),
70
- }
53
+ choose(
54
+ prompt,
55
+ choices.map((choice, index) => ({
56
+ key: String(index),
57
+ label: choice,
58
+ value: choice,
59
+ })),
60
+ chooserCommand,
61
+ ),
62
+ })
71
63
 
72
- export const task = (
73
- options: TaskOptions,
74
- interaction: TaskInteraction = defaultInteraction,
75
- ) =>
64
+ export const task = (options: TaskOptions, interaction?: TaskInteraction) =>
76
65
  Effect.gen(function* () {
77
66
  const tasks = yield* TaskService
78
67
  const epics = yield* EpicService
79
68
  const repositories = yield* RepositoryService
69
+ const workbase = yield* WorkbaseService
80
70
  const { log } = createLoggers(options)
81
71
  const cwd = options.cwd ?? process.cwd()
82
72
 
@@ -89,8 +79,13 @@ export const task = (
89
79
  ),
90
80
  )
91
81
  }
82
+ const activeInteraction =
83
+ interaction ??
84
+ defaultInteraction(
85
+ (yield* workbase.loadConfig(cwd)).config.chooserCommand,
86
+ )
92
87
  const id =
93
- options.args[0] ?? (yield* interaction.text("Task ID: ")).trim()
88
+ options.args[0] ?? (yield* activeInteraction.text("Task ID: ")).trim()
94
89
  if (!id) {
95
90
  return yield* Effect.fail(new Error("Task ID is required"))
96
91
  }
@@ -103,18 +98,20 @@ export const task = (
103
98
 
104
99
  if (options.ticketUrl === undefined) {
105
100
  ticketUrl =
106
- (yield* interaction.text("Ticket URL (optional): ")).trim() || null
101
+ (yield* activeInteraction.text("Ticket URL (optional): ")).trim() ||
102
+ null
107
103
  }
108
104
  if (options.description === undefined) {
109
105
  description =
110
- (yield* interaction.text("Description (optional): ")).trim() ||
111
- undefined
106
+ (yield* activeInteraction.text(
107
+ "Description (optional): ",
108
+ )).trim() || undefined
112
109
  }
113
110
  if (options.epic === undefined) {
114
111
  const epicRecords = yield* epics.list(cwd)
115
112
  if (epicRecords.length > 0) {
116
113
  const none = "(none)"
117
- const selected = yield* interaction.select("Parent epic", [
114
+ const selected = yield* activeInteraction.select("Parent epic", [
118
115
  none,
119
116
  ...epicRecords.map((record) => record.id),
120
117
  ])
@@ -125,7 +122,7 @@ export const task = (
125
122
  }
126
123
  }
127
124
  if (options.multiPhase === undefined) {
128
- const selected = yield* interaction.select("Task type", [
125
+ const selected = yield* activeInteraction.select("Task type", [
129
126
  "single-phase",
130
127
  "multi-phase",
131
128
  ])
@@ -144,7 +141,7 @@ export const task = (
144
141
  ),
145
142
  )
146
143
  }
147
- const selected = yield* interaction.select(
144
+ const selected = yield* activeInteraction.select(
148
145
  "Writable repository",
149
146
  records.map((record) => record.alias),
150
147
  )
@@ -269,7 +266,7 @@ Subcommands:
269
266
  create <id> Create a task without prompting
270
267
  list List tasks
271
268
  show <id> Show a task
272
- status <id> <status> Set open, working, delegated, done, or dropped
269
+ status <id> <status> Set open, done, or dropped
273
270
 
274
271
  Create options:
275
272
  --ticket-url <url> External ticket URL (optional)
@@ -32,12 +32,7 @@ export const validate = (
32
32
  const startPath = options.path ?? options.cwd ?? process.cwd()
33
33
  const root = options.path
34
34
  ? yield* workbase.discover(startPath)
35
- : yield* resolveWorkbase(
36
- startPath,
37
- log,
38
- pick,
39
- options.inputAllowed ?? true,
40
- )
35
+ : yield* resolveWorkbase(startPath, pick, options.inputAllowed ?? true)
41
36
  if (!root) return
42
37
  const report = yield* workbase.validate(root)
43
38
 
@@ -6,6 +6,7 @@ import { EpicService } from "../services/EpicService"
6
6
  import { TaskService } from "../services/TaskService"
7
7
  import { PhaseService } from "../services/PhaseService"
8
8
  import { WorktreeService } from "../services/WorktreeService"
9
+ import { ClaimService } from "../services/ClaimService"
9
10
  import { captureErrors, captureLogs } from "../test-utils"
10
11
  import { work, workPrepare } from "./work"
11
12
  import type { PickWorkTarget } from "../workbase/work-target"
@@ -45,7 +46,8 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
45
46
  interface HarnessOptions {
46
47
  readonly workspace?: ExecutionWorkspace
47
48
  readonly materializeError?: Error
48
- readonly available?: Partial<Record<"opencode" | "claude" | "fzf", boolean>>
49
+ readonly available?: Partial<Record<"opencode" | "claude", boolean>>
50
+ readonly chooserCommand?: readonly string[]
49
51
  readonly multiPhaseTasks?: readonly string[]
50
52
  readonly epicRecords?: readonly any[]
51
53
  readonly taskRecords?: readonly any[]
@@ -92,6 +94,14 @@ const createHarness = (options: HarnessOptions = {}) => {
92
94
  })
93
95
  : Effect.succeed("/workbase"),
94
96
  listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
97
+ loadConfig: () =>
98
+ Effect.succeed({
99
+ root: "/workbase",
100
+ config: {
101
+ version: 2 as const,
102
+ chooserCommand: options.chooserCommand,
103
+ },
104
+ }),
95
105
  }
96
106
  const epics = {
97
107
  show: (id: string) =>
@@ -133,6 +143,30 @@ const createHarness = (options: HarnessOptions = {}) => {
133
143
  return Effect.void
134
144
  },
135
145
  }
146
+ const claims = {
147
+ inspect: (taskId: string, phaseId?: string) =>
148
+ Effect.succeed({
149
+ target: {
150
+ kind: phaseId ? "phase" : "task",
151
+ taskId,
152
+ phaseId,
153
+ path: phaseId
154
+ ? `/workbase/tasks/${taskId}/phases/${phaseId}/PHASE.md`
155
+ : `/workbase/tasks/${taskId}/TASK.md`,
156
+ label: phaseId ? `phase '${taskId}/${phaseId}'` : `task '${taskId}'`,
157
+ },
158
+ revision: "0".repeat(64),
159
+ data: {},
160
+ }),
161
+ claim: (input: { taskId: string; phaseId?: string }) => {
162
+ statusUpdates.push(
163
+ input.phaseId
164
+ ? `phase:${input.taskId}:${input.phaseId}:working`
165
+ : `task:${input.taskId}:working`,
166
+ )
167
+ return Effect.succeed({ revision: "1".repeat(64) })
168
+ },
169
+ }
136
170
  const fs = {
137
171
  isDirectory: (path: string) =>
138
172
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
@@ -171,6 +205,7 @@ const createHarness = (options: HarnessOptions = {}) => {
171
205
  Effect.provideService(EpicService, epics as never),
172
206
  Effect.provideService(TaskService, tasks as never),
173
207
  Effect.provideService(PhaseService, phases as never),
208
+ Effect.provideService(ClaimService, claims as never),
174
209
  ) as Effect.Effect<void, unknown, never>,
175
210
  )
176
211
  const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
@@ -333,7 +368,7 @@ describe("work command", () => {
333
368
  expect(harness.launches[0]?.cwd).toBe(singlePhaseWorkspace.writablePath)
334
369
  })
335
370
 
336
- test("selects a target with fzf when no directory is provided", async () => {
371
+ test("selects a target when no directory is provided", async () => {
337
372
  const phase = {
338
373
  taskId: "delivery",
339
374
  id: "build",
@@ -359,7 +394,6 @@ describe("work command", () => {
359
394
  await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
360
395
 
361
396
  expect(harness.events).toEqual([
362
- "probe:fzf",
363
397
  "materialize",
364
398
  "probe:opencode",
365
399
  "launch:opencode",
@@ -416,7 +450,6 @@ describe("work command", () => {
416
450
 
417
451
  expect(selections).toEqual([["/first", "/workbase"]])
418
452
  expect(harness.events).toEqual([
419
- "probe:fzf",
420
453
  "materialize",
421
454
  "probe:opencode",
422
455
  "launch:opencode",
@@ -450,9 +483,9 @@ describe("work command", () => {
450
483
  expect(harness.events).toEqual([])
451
484
  })
452
485
 
453
- test("prints the target tree when fzf is unavailable", async () => {
486
+ test("passes the configured chooser command to the shared picker", async () => {
454
487
  const harness = createHarness({
455
- available: { fzf: false },
488
+ chooserCommand: ["gum", "filter"],
456
489
  epicRecords: [
457
490
  {
458
491
  id: "delivery",
@@ -461,14 +494,15 @@ describe("work command", () => {
461
494
  },
462
495
  ],
463
496
  })
497
+ let command: readonly string[] | undefined
498
+ const pick: PickWorkTarget = (_choices, chooserCommand) => {
499
+ command = chooserCommand
500
+ return Effect.succeed(null)
501
+ }
464
502
 
465
- const logs = await captureLogs(async () => {
466
- await expect(harness.run({ cwd: "/workbase" })).rejects.toThrow(
467
- "fzf is required",
468
- )
469
- })
503
+ await harness.run({ cwd: "/workbase" }, pick)
470
504
 
471
- expect(logs).toEqual(["\x1b[35m\x1b[0m delivery"])
505
+ expect(command).toEqual(["gum", "filter"])
472
506
  expect(harness.launches).toEqual([])
473
507
  })
474
508
 
@@ -7,6 +7,7 @@ import { WorkbaseService } from "../services/WorkbaseService"
7
7
  import { EpicService } from "../services/EpicService"
8
8
  import { TaskService } from "../services/TaskService"
9
9
  import { PhaseService } from "../services/PhaseService"
10
+ import { ClaimService } from "../services/ClaimService"
10
11
  import { createLoggers } from "../utils/effect"
11
12
  import { execvp } from "../utils/exec"
12
13
  import { createProgress, type Progress } from "../utils/progress"
@@ -60,6 +61,8 @@ export const work = (
60
61
  new Error("Cannot use both --opencode and --claude"),
61
62
  )
62
63
  }
64
+ const previousSessionId = process.env.AGENCY_SESSION_ID
65
+ const previousClaimRevision = process.env.AGENCY_CLAIM_REVISION
63
66
  if (
64
67
  options.epicId &&
65
68
  (options.directory || options.taskId || options.phaseId)
@@ -75,6 +78,7 @@ export const work = (
75
78
  const epics = yield* EpicService
76
79
  const tasks = yield* TaskService
77
80
  const phases = yield* PhaseService
81
+ const claims = yield* ClaimService
78
82
  const { log, verboseLog } = createLoggers(options)
79
83
  const cwd = options.cwd ?? process.cwd()
80
84
  const directoryPath = options.directory
@@ -85,7 +89,7 @@ export const work = (
85
89
  : false
86
90
  const startPath = isDirectory && directoryPath ? directoryPath : cwd
87
91
  const inputAllowed = options.inputAllowed ?? true
88
- const root = yield* resolveWorkbase(startPath, log, pickBase, inputAllowed)
92
+ const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
89
93
  if (!root) return
90
94
 
91
95
  let target: WorkTarget | null = null
@@ -174,18 +178,8 @@ export const work = (
174
178
  new Error("No epics, tasks, or phases found in this workbase"),
175
179
  )
176
180
  }
177
- const fzf = yield* fs.runCommand(["which", "fzf"], {
178
- captureOutput: true,
179
- })
180
- if (fzf.exitCode !== 0) {
181
- for (const choice of choices) log(choice.label)
182
- return yield* Effect.fail(
183
- new Error(
184
- "fzf is required to select a work target; install fzf or provide a directory explicitly",
185
- ),
186
- )
187
- }
188
- target = yield* pick(choices)
181
+ const { config } = yield* workbase.loadConfig(root)
182
+ target = yield* pick(choices, config.chooserCommand)
189
183
  if (!target) return
190
184
  }
191
185
 
@@ -229,10 +223,27 @@ export const work = (
229
223
  if (available.exitCode !== 0) {
230
224
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
231
225
  }
232
- if (target.kind === "phase") {
233
- yield* phases.setStatus(target.taskId, target.phaseId, "working", root)
234
- } else if (target.kind === "task" && !target.multiPhase) {
235
- yield* tasks.setStatus(target.taskId, "working", root)
226
+ if (
227
+ target.kind === "phase" ||
228
+ (target.kind === "task" && !target.multiPhase)
229
+ ) {
230
+ const phaseId = target.kind === "phase" ? target.phaseId : undefined
231
+ const current = yield* claims.inspect(target.taskId, phaseId, root)
232
+ const sessionId =
233
+ process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
234
+ const acquired = yield* claims.claim(
235
+ {
236
+ taskId: target.taskId,
237
+ ...(phaseId ? { phaseId } : {}),
238
+ claimant: process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency",
239
+ runner: process.env.AGENCY_RUNNER ?? cli,
240
+ sessionId,
241
+ revision: current.revision,
242
+ },
243
+ root,
244
+ )
245
+ process.env.AGENCY_SESSION_ID = sessionId
246
+ process.env.AGENCY_CLAIM_REVISION = acquired.revision
236
247
  }
237
248
 
238
249
  const args =
@@ -240,7 +251,15 @@ export const work = (
240
251
  verboseLog(
241
252
  `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
242
253
  )
243
- launch(cli, [cli, ...args], launchPath)
254
+ try {
255
+ launch(cli, [cli, ...args], launchPath)
256
+ } finally {
257
+ if (previousSessionId === undefined) delete process.env.AGENCY_SESSION_ID
258
+ else process.env.AGENCY_SESSION_ID = previousSessionId
259
+ if (previousClaimRevision === undefined)
260
+ delete process.env.AGENCY_CLAIM_REVISION
261
+ else process.env.AGENCY_CLAIM_REVISION = previousClaimRevision
262
+ }
244
263
  })
245
264
 
246
265
  export const workPrepare = (options: WorkOptions = {}) =>
@@ -303,7 +322,7 @@ Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
303
322
  agency work prepare [target] [--dry-run] [--json]
304
323
 
305
324
  Launch an agent for an epic, task, or phase. With no directory, select one
306
- with fzf. A positional argument resolves as a directory first, then as a task
325
+ interactively. A positional argument resolves as a directory first, then as a task
307
326
  ID. Use '.' for the current directory. Outside a workbase, select a registered
308
327
  workbase first.
309
328
 
@@ -83,5 +83,30 @@ describe("machine protocol", () => {
83
83
  },
84
84
  },
85
85
  })
86
+ expect(
87
+ errorEnvelope({
88
+ _tag: "ClaimConflictError",
89
+ message: "already claimed",
90
+ target: "task 'example'",
91
+ currentRevision: "a".repeat(64),
92
+ claim: {
93
+ claimant: "orchestrator",
94
+ runner: "agent",
95
+ sessionId: "job-1",
96
+ startedAt: "2026-07-17T12:00:00.000Z",
97
+ targetRevision: "0".repeat(64),
98
+ state: "active",
99
+ },
100
+ }),
101
+ ).toMatchObject({
102
+ error: {
103
+ code: "CLAIM_CONFLICT",
104
+ retryable: true,
105
+ fields: {
106
+ target: "task 'example'",
107
+ claim: { runner: "agent", sessionId: "job-1" },
108
+ },
109
+ },
110
+ })
86
111
  })
87
112
  })
package/src/protocol.ts CHANGED
@@ -81,6 +81,22 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
81
81
  EpicError: { code: "EPIC_ERROR", retryable: false },
82
82
  TaskError: { code: "TASK_ERROR", retryable: false },
83
83
  PhaseError: { code: "PHASE_ERROR", retryable: false },
84
+ ClaimError: { code: "CLAIM_ERROR", retryable: false },
85
+ ClaimConflictError: {
86
+ code: "CLAIM_CONFLICT",
87
+ retryable: true,
88
+ remediation: "Inspect the current ownership details before retrying.",
89
+ },
90
+ RevisionConflictError: {
91
+ code: "REVISION_CONFLICT",
92
+ retryable: true,
93
+ remediation: "Read the current document revision and retry intentionally.",
94
+ },
95
+ ClaimOwnershipError: {
96
+ code: "CLAIM_OWNERSHIP",
97
+ retryable: false,
98
+ remediation: "Use the session that owns the claim.",
99
+ },
84
100
  ArchiveError: { code: "ARCHIVE_ERROR", retryable: false },
85
101
  WorktreeError: { code: "WORKTREE_ERROR", retryable: false },
86
102
  PullRequestError: { code: "PULL_REQUEST_ERROR", retryable: false },