@markjaquith/agency 2.12.0 → 2.14.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/src/cli.test.ts CHANGED
@@ -81,6 +81,91 @@ describe("CLI", () => {
81
81
  expect(taggedError.stderr).not.toContain("An error has occurred")
82
82
  })
83
83
 
84
+ test("coordinates claims through revision-guarded machine commands", async () => {
85
+ const root = await createTempDir()
86
+ tempDirs.push(root)
87
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
88
+ await mkdir(join(root, "repos", "agency"), { recursive: true })
89
+ parseJson(
90
+ await runCli(
91
+ ["task", "create", "claimed", "--repo", "agency", "--json"],
92
+ root,
93
+ ),
94
+ )
95
+ const context = parseJson(
96
+ await runCli(["context", "tasks/claimed", "--json"], root),
97
+ )
98
+ const revision = context.documents.task.sha256
99
+ const claimed = parseJson(
100
+ await runCli(
101
+ [
102
+ "claim",
103
+ "claimed",
104
+ "--claimant",
105
+ "orchestrator",
106
+ "--runner",
107
+ "agent",
108
+ "--session-id",
109
+ "job-1",
110
+ "--revision",
111
+ revision,
112
+ "--json",
113
+ ],
114
+ root,
115
+ ),
116
+ )
117
+ expect(claimed.claim).toMatchObject({
118
+ claimant: "orchestrator",
119
+ runner: "agent",
120
+ sessionId: "job-1",
121
+ state: "active",
122
+ })
123
+
124
+ const conflict = await runCli(
125
+ [
126
+ "claim",
127
+ "claimed",
128
+ "--claimant",
129
+ "other",
130
+ "--runner",
131
+ "other-agent",
132
+ "--session-id",
133
+ "job-2",
134
+ "--revision",
135
+ claimed.revision,
136
+ "--json",
137
+ ],
138
+ root,
139
+ )
140
+ expect(conflict.exitCode).toBe(1)
141
+ expect(JSON.parse(conflict.stdout)).toMatchObject({
142
+ ok: false,
143
+ error: {
144
+ code: "CLAIM_CONFLICT",
145
+ retryable: true,
146
+ fields: { claim: { runner: "agent", sessionId: "job-1" } },
147
+ },
148
+ })
149
+
150
+ const finished = parseJson(
151
+ await runCli(
152
+ [
153
+ "finish",
154
+ "claimed",
155
+ "--session-id",
156
+ "job-1",
157
+ "--revision",
158
+ claimed.revision,
159
+ "--outcome",
160
+ "done",
161
+ "--json",
162
+ ],
163
+ root,
164
+ ),
165
+ )
166
+ expect(finished.claim).toMatchObject({ state: "finished", outcome: "done" })
167
+ })
168
+
84
169
  test("emits one versioned error envelope for usage and command failures", async () => {
85
170
  const usage = await runCli(["unknown", "--json"])
86
171
  expect(usage.exitCode).toBe(1)
@@ -301,6 +386,76 @@ status: open
301
386
  })
302
387
  })
303
388
 
389
+ test("prepares a workspace and reports a non-mutating dry-run", async () => {
390
+ const parent = await createTempDir()
391
+ tempDirs.push(parent)
392
+ const root = join(parent, "workbase")
393
+ const source = join(parent, "source")
394
+ expect(
395
+ Bun.spawnSync(["git", "init", "--initial-branch=main", source]).exitCode,
396
+ ).toBe(0)
397
+ await Bun.write(join(source, "README.md"), "example\n")
398
+ for (const args of [
399
+ ["config", "user.email", "test@example.com"],
400
+ ["config", "user.name", "Test"],
401
+ ["add", "README.md"],
402
+ ["-c", "commit.gpgsign=false", "commit", "-m", "initial"],
403
+ ]) {
404
+ expect(Bun.spawnSync(["git", "-C", source, ...args]).exitCode).toBe(0)
405
+ }
406
+
407
+ parseJson(await runCli(["init", root, "--json"], parent))
408
+ parseJson(await runCli(["repo", "link", "agency", source, "--json"], root))
409
+ parseJson(
410
+ await runCli(
411
+ [
412
+ "task",
413
+ "create",
414
+ "example",
415
+ "--repo",
416
+ "agency",
417
+ "--branch",
418
+ "feat/example",
419
+ "--base",
420
+ "main",
421
+ "--json",
422
+ ],
423
+ root,
424
+ ),
425
+ )
426
+
427
+ const planned = parseJson(
428
+ await runCli(["work", "prepare", "example", "--dry-run", "--json"], root),
429
+ )
430
+ const workbaseRoot = await realpath(root)
431
+ expect(planned).toMatchObject({
432
+ dryRun: true,
433
+ taskPath: join(workbaseRoot, "tasks/example/TASK.md"),
434
+ phasePath: null,
435
+ checkouts: [
436
+ {
437
+ repo: "agency",
438
+ kind: "writable",
439
+ action: "created",
440
+ resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
441
+ },
442
+ ],
443
+ })
444
+ await expect(access(join(root, "tasks/example/code"))).rejects.toThrow()
445
+
446
+ const prepared = parseJson(
447
+ await runCli(["work", "prepare", "example", "--json"], root),
448
+ )
449
+ expect(prepared).toMatchObject({
450
+ dryRun: false,
451
+ checkouts: [{ action: "created", kind: "writable" }],
452
+ })
453
+ const task = parseJson(
454
+ await runCli(["task", "show", "example", "--json"], root),
455
+ )
456
+ expect(task.data.status).toBe("open")
457
+ })
458
+
304
459
  test("envelopes help and version output in machine mode", async () => {
305
460
  const help = await runCli(["status", "--help", "--json"])
306
461
  expect(parseJson(help)).toContain("Usage: agency status")
@@ -0,0 +1,97 @@
1
+ import { Effect } from "effect"
2
+ import { ClaimService } from "../services/ClaimService"
3
+ import type { BaseCommandOptions } from "../utils/command"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface ClaimCommandOptions extends BaseCommandOptions {
7
+ readonly operation: "claim" | "release" | "finish"
8
+ readonly taskId?: string
9
+ readonly phaseId?: string
10
+ readonly claimant?: string
11
+ readonly runner?: string
12
+ readonly sessionId?: string
13
+ readonly revision?: string
14
+ readonly expiresAt?: string
15
+ readonly outcome?: string
16
+ readonly json?: boolean
17
+ }
18
+
19
+ export const claimCommand = (options: ClaimCommandOptions) =>
20
+ Effect.gen(function* () {
21
+ const claims = yield* ClaimService
22
+ const { log } = createLoggers(options)
23
+ const cwd = options.cwd ?? process.cwd()
24
+ if (!options.taskId || !options.sessionId || !options.revision) {
25
+ return yield* Effect.fail(new Error("Missing required claim arguments"))
26
+ }
27
+ if (
28
+ options.operation === "claim" &&
29
+ (!options.claimant || !options.runner)
30
+ ) {
31
+ return yield* Effect.fail(
32
+ new Error("Claimant and runner identities are required"),
33
+ )
34
+ }
35
+ if (
36
+ options.operation === "finish" &&
37
+ options.outcome !== "done" &&
38
+ options.outcome !== "dropped"
39
+ ) {
40
+ return yield* Effect.fail(
41
+ new Error("Finish outcome must be done or dropped"),
42
+ )
43
+ }
44
+
45
+ const common = {
46
+ taskId: options.taskId,
47
+ ...(options.phaseId ? { phaseId: options.phaseId } : {}),
48
+ sessionId: options.sessionId,
49
+ revision: options.revision,
50
+ }
51
+ const result =
52
+ options.operation === "claim"
53
+ ? yield* claims.claim(
54
+ {
55
+ ...common,
56
+ claimant: options.claimant!,
57
+ runner: options.runner!,
58
+ ...(options.expiresAt ? { expiresAt: options.expiresAt } : {}),
59
+ },
60
+ cwd,
61
+ )
62
+ : options.operation === "release"
63
+ ? yield* claims.release(common, cwd)
64
+ : yield* claims.finish(
65
+ {
66
+ ...common,
67
+ outcome: options.outcome as "done" | "dropped",
68
+ },
69
+ cwd,
70
+ )
71
+
72
+ const { data: _, ...output } = result
73
+ log(
74
+ options.json
75
+ ? JSON.stringify(output, null, 2)
76
+ : `${options.operation === "claim" ? "Claimed" : options.operation === "release" ? "Released" : "Finished"} ${result.target} at revision ${result.revision}`,
77
+ )
78
+ })
79
+
80
+ export const claimHelp = `
81
+ Usage: agency claim <task-id> [phase-id] --claimant <id> --runner <id> --session-id <id> --revision <sha256>
82
+
83
+ Claim an execution unit. Use distinct claimant and runner identities for delegated
84
+ work. --expires-at accepts an optional future ISO-8601 timestamp.
85
+ `
86
+
87
+ export const releaseHelp = `
88
+ Usage: agency release <task-id> [phase-id] --session-id <id> --revision <sha256>
89
+
90
+ Release an execution unit owned by the session.
91
+ `
92
+
93
+ export const finishHelp = `
94
+ Usage: agency finish <task-id> [phase-id] --session-id <id> --revision <sha256> --outcome <done|dropped>
95
+
96
+ Finish an execution unit owned by the session.
97
+ `
@@ -125,7 +125,7 @@ Subcommands:
125
125
  list <task> List task phases
126
126
  show <task> <phase> Show a phase
127
127
  status <task> <phase> <status>
128
- Set open, working, delegated, done, or dropped
128
+ Set open, done, or dropped
129
129
 
130
130
  Create options:
131
131
  --description <text> Short description of the phase
@@ -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 () => {
@@ -269,7 +269,7 @@ Subcommands:
269
269
  create <id> Create a task without prompting
270
270
  list List tasks
271
271
  show <id> Show a task
272
- status <id> <status> Set open, working, delegated, done, or dropped
272
+ status <id> <status> Set open, done, or dropped
273
273
 
274
274
  Create options:
275
275
  --ticket-url <url> External ticket URL (optional)
@@ -6,8 +6,9 @@ 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
- import { work } from "./work"
11
+ import { work, workPrepare } from "./work"
11
12
  import type { PickWorkTarget } from "../workbase/work-target"
12
13
  import type { PickWorkbase } from "../workbase/workbase-choice"
13
14
  import type { Progress } from "../utils/progress"
@@ -24,6 +25,9 @@ const singlePhaseWorkspace: ExecutionWorkspace = {
24
25
  writablePath: "/workbase/tasks/example/code/agency",
25
26
  repo: "agency",
26
27
  repos: [],
28
+ dryRun: false,
29
+ checkouts: [],
30
+ operations: [],
27
31
  }
28
32
 
29
33
  const multiPhaseWorkspace: ExecutionWorkspace = {
@@ -34,6 +38,9 @@ const multiPhaseWorkspace: ExecutionWorkspace = {
34
38
  writablePath: "/workbase/tasks/example/phases/implementation/code/agency",
35
39
  repo: "agency",
36
40
  repos: [],
41
+ dryRun: false,
42
+ checkouts: [],
43
+ operations: [],
37
44
  }
38
45
 
39
46
  interface HarnessOptions {
@@ -127,6 +134,30 @@ const createHarness = (options: HarnessOptions = {}) => {
127
134
  return Effect.void
128
135
  },
129
136
  }
137
+ const claims = {
138
+ inspect: (taskId: string, phaseId?: string) =>
139
+ Effect.succeed({
140
+ target: {
141
+ kind: phaseId ? "phase" : "task",
142
+ taskId,
143
+ phaseId,
144
+ path: phaseId
145
+ ? `/workbase/tasks/${taskId}/phases/${phaseId}/PHASE.md`
146
+ : `/workbase/tasks/${taskId}/TASK.md`,
147
+ label: phaseId ? `phase '${taskId}/${phaseId}'` : `task '${taskId}'`,
148
+ },
149
+ revision: "0".repeat(64),
150
+ data: {},
151
+ }),
152
+ claim: (input: { taskId: string; phaseId?: string }) => {
153
+ statusUpdates.push(
154
+ input.phaseId
155
+ ? `phase:${input.taskId}:${input.phaseId}:working`
156
+ : `task:${input.taskId}:working`,
157
+ )
158
+ return Effect.succeed({ revision: "1".repeat(64) })
159
+ },
160
+ }
130
161
  const fs = {
131
162
  isDirectory: (path: string) =>
132
163
  Effect.succeed(options.existingDirectories?.includes(path) ?? true),
@@ -165,6 +196,17 @@ const createHarness = (options: HarnessOptions = {}) => {
165
196
  Effect.provideService(EpicService, epics as never),
166
197
  Effect.provideService(TaskService, tasks as never),
167
198
  Effect.provideService(PhaseService, phases as never),
199
+ Effect.provideService(ClaimService, claims as never),
200
+ ) as Effect.Effect<void, unknown, never>,
201
+ )
202
+ const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
203
+ Effect.runPromise(
204
+ workPrepare(commandOptions).pipe(
205
+ Effect.provideService(WorktreeService, worktrees as never),
206
+ Effect.provideService(FileSystemService, fs as never),
207
+ Effect.provideService(WorkbaseService, workbase as never),
208
+ Effect.provideService(TaskService, tasks as never),
209
+ Effect.provideService(PhaseService, phases as never),
168
210
  ) as Effect.Effect<void, unknown, never>,
169
211
  )
170
212
 
@@ -177,10 +219,32 @@ const createHarness = (options: HarnessOptions = {}) => {
177
219
  shownTasks,
178
220
  progressUpdates,
179
221
  run,
222
+ runPrepare,
180
223
  }
181
224
  }
182
225
 
183
226
  describe("work command", () => {
227
+ test("prepares without launching or changing lifecycle status", async () => {
228
+ const harness = createHarness({ existingDirectories: [] })
229
+
230
+ await captureLogs(() =>
231
+ harness.runPrepare({
232
+ cwd: "/workbase",
233
+ directory: "example",
234
+ json: true,
235
+ dryRun: true,
236
+ }),
237
+ )
238
+
239
+ expect(harness.events).toEqual(["materialize"])
240
+ expect(harness.launches).toEqual([])
241
+ expect(harness.statusUpdates).toEqual([])
242
+ expect(harness.materializeOptions[0]).toMatchObject({
243
+ json: true,
244
+ dryRun: true,
245
+ })
246
+ })
247
+
184
248
  test("launches an epic agent from an epic directory", async () => {
185
249
  const harness = createHarness()
186
250
 
@@ -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
@@ -229,10 +233,27 @@ export const work = (
229
233
  if (available.exitCode !== 0) {
230
234
  return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
231
235
  }
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)
236
+ if (
237
+ target.kind === "phase" ||
238
+ (target.kind === "task" && !target.multiPhase)
239
+ ) {
240
+ const phaseId = target.kind === "phase" ? target.phaseId : undefined
241
+ const current = yield* claims.inspect(target.taskId, phaseId, root)
242
+ const sessionId =
243
+ process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
244
+ const acquired = yield* claims.claim(
245
+ {
246
+ taskId: target.taskId,
247
+ ...(phaseId ? { phaseId } : {}),
248
+ claimant: process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency",
249
+ runner: process.env.AGENCY_RUNNER ?? cli,
250
+ sessionId,
251
+ revision: current.revision,
252
+ },
253
+ root,
254
+ )
255
+ process.env.AGENCY_SESSION_ID = sessionId
256
+ process.env.AGENCY_CLAIM_REVISION = acquired.revision
236
257
  }
237
258
 
238
259
  const args =
@@ -240,17 +261,85 @@ export const work = (
240
261
  verboseLog(
241
262
  `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
242
263
  )
243
- launch(cli, [cli, ...args], launchPath)
264
+ try {
265
+ launch(cli, [cli, ...args], launchPath)
266
+ } finally {
267
+ if (previousSessionId === undefined) delete process.env.AGENCY_SESSION_ID
268
+ else process.env.AGENCY_SESSION_ID = previousSessionId
269
+ if (previousClaimRevision === undefined)
270
+ delete process.env.AGENCY_CLAIM_REVISION
271
+ else process.env.AGENCY_CLAIM_REVISION = previousClaimRevision
272
+ }
273
+ })
274
+
275
+ export const workPrepare = (options: WorkOptions = {}) =>
276
+ Effect.gen(function* () {
277
+ const fs = yield* FileSystemService
278
+ const workbase = yield* WorkbaseService
279
+ const tasks = yield* TaskService
280
+ const phases = yield* PhaseService
281
+ const worktrees = yield* WorktreeService
282
+ const { log } = createLoggers(options)
283
+ const cwd = options.cwd ?? process.cwd()
284
+ const targetPath = options.directory ? resolve(cwd, options.directory) : cwd
285
+ const isDirectory = yield* fs.isDirectory(targetPath)
286
+ const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
287
+
288
+ let taskId: string | undefined
289
+ let phaseId: string | undefined
290
+ if (options.directory && !isDirectory) {
291
+ const task = yield* tasks.show(options.directory, root)
292
+ taskId = task.id
293
+ } else {
294
+ const path = relative(root, targetPath)
295
+ const parts =
296
+ !path || isAbsolute(path) || path.startsWith(`..${sep}`)
297
+ ? []
298
+ : path.split(sep)
299
+ if (parts[0] === "tasks" && parts[1]) {
300
+ const task = yield* tasks.show(parts[1], root)
301
+ taskId = task.id
302
+ if (parts[2] === "phases" && parts[3]) {
303
+ const phase = yield* phases.show(task.id, parts[3], root)
304
+ phaseId = phase.id
305
+ }
306
+ }
307
+ }
308
+
309
+ if (!taskId) {
310
+ return yield* Effect.fail(
311
+ new Error(
312
+ "Work preparation requires a task ID or a path inside an execution unit",
313
+ ),
314
+ )
315
+ }
316
+
317
+ const workspace = yield* worktrees.materialize(taskId, phaseId, root, {
318
+ ...options,
319
+ dryRun: options.dryRun,
320
+ })
321
+ if (options.json) {
322
+ log(JSON.stringify(workspace, null, 2))
323
+ } else {
324
+ log(
325
+ `${workspace.dryRun ? "Workspace plan" : "Workspace ready"}: ${workspace.writablePath}`,
326
+ )
327
+ }
244
328
  })
245
329
 
246
330
  export const help = `
247
331
  Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
332
+ agency work prepare [target] [--dry-run] [--json]
248
333
 
249
334
  Launch an agent for an epic, task, or phase. With no directory, select one
250
335
  with fzf. A positional argument resolves as a directory first, then as a task
251
336
  ID. Use '.' for the current directory. Outside a workbase, select a registered
252
337
  workbase first.
253
338
 
339
+ The prepare subcommand resolves and materializes an execution workspace without
340
+ launching an agent or changing lifecycle status. --dry-run reports planned Git
341
+ changes without fetching, creating branches, or creating worktrees.
342
+
254
343
  Options:
255
344
  --epic <id> Work on an epic
256
345
  --opencode Require OpenCode
@@ -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 },