@markjaquith/agency 2.5.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.
@@ -9,6 +9,7 @@ import { WorktreeService } from "../services/WorktreeService"
9
9
  import { captureLogs } from "../test-utils"
10
10
  import { work } from "./work"
11
11
  import type { PickWorkTarget } from "../workbase/work-target"
12
+ import type { PickWorkbase } from "../workbase/workbase-choice"
12
13
  import type { Progress } from "../utils/progress"
13
14
 
14
15
  type ExecutionWorkspace = Effect.Effect.Success<
@@ -43,6 +44,8 @@ interface HarnessOptions {
43
44
  readonly epicRecords?: readonly any[]
44
45
  readonly taskRecords?: readonly any[]
45
46
  readonly phaseRecords?: readonly any[]
47
+ readonly outsideWorkbase?: boolean
48
+ readonly registeredWorkbases?: readonly string[]
46
49
  }
47
50
 
48
51
  const createHarness = (options: HarnessOptions = {}) => {
@@ -73,7 +76,14 @@ const createHarness = (options: HarnessOptions = {}) => {
73
76
  },
74
77
  }
75
78
  const workbase = {
76
- discover: () => Effect.succeed("/workbase"),
79
+ discover: (path: string) =>
80
+ options.outsideWorkbase && path === "/outside"
81
+ ? Effect.fail({
82
+ _tag: "WorkbaseNotFoundError" as const,
83
+ message: "No Agency workbase found from /outside",
84
+ })
85
+ : Effect.succeed("/workbase"),
86
+ listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
77
87
  }
78
88
  const epics = {
79
89
  show: (id: string) =>
@@ -114,6 +124,7 @@ const createHarness = (options: HarnessOptions = {}) => {
114
124
  },
115
125
  }
116
126
  const fs = {
127
+ isDirectory: () => Effect.succeed(true),
117
128
  runCommand: (args: readonly string[]) => {
118
129
  const cli = args[1] as "opencode" | "claude"
119
130
  events.push(`probe:${cli}`)
@@ -130,6 +141,7 @@ const createHarness = (options: HarnessOptions = {}) => {
130
141
  launches.push({ cli, args, cwd })
131
142
  }
132
143
  const defaultPick: PickWorkTarget = () => Effect.succeed(null)
144
+ const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
133
145
  const progress: Progress = {
134
146
  start: (message) => progressUpdates.push(`start:${message}`),
135
147
  succeed: (message) => progressUpdates.push(`succeed:${message}`),
@@ -138,9 +150,10 @@ const createHarness = (options: HarnessOptions = {}) => {
138
150
  const run = (
139
151
  commandOptions: Parameters<typeof work>[0],
140
152
  pick: PickWorkTarget = defaultPick,
153
+ pickBase: PickWorkbase = defaultPickWorkbase,
141
154
  ) =>
142
155
  Effect.runPromise(
143
- work(commandOptions, launch, pick, progress).pipe(
156
+ work(commandOptions, launch, pick, progress, pickBase).pipe(
144
157
  Effect.provideService(WorktreeService, worktrees as never),
145
158
  Effect.provideService(FileSystemService, fs as never),
146
159
  Effect.provideService(WorkbaseService, workbase as never),
@@ -165,7 +178,11 @@ describe("work command", () => {
165
178
  test("launches an epic agent from an epic directory", async () => {
166
179
  const harness = createHarness()
167
180
 
168
- await harness.run({ cwd: "/workbase/epics/delivery", opencode: true })
181
+ await harness.run({
182
+ cwd: "/workbase/epics/delivery",
183
+ directory: ".",
184
+ opencode: true,
185
+ })
169
186
 
170
187
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
171
188
  expect(harness.launches[0]).toEqual({
@@ -178,7 +195,11 @@ describe("work command", () => {
178
195
  test("launches a multi-phase task agent without materializing", async () => {
179
196
  const harness = createHarness({ multiPhaseTasks: ["delivery"] })
180
197
 
181
- await harness.run({ cwd: "/workbase/tasks/delivery", opencode: true })
198
+ await harness.run({
199
+ cwd: "/workbase/tasks/delivery",
200
+ directory: ".",
201
+ opencode: true,
202
+ })
182
203
 
183
204
  expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
184
205
  expect(harness.launches[0]).toEqual({
@@ -193,6 +214,7 @@ describe("work command", () => {
193
214
 
194
215
  await harness.run({
195
216
  cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
217
+ directory: ".",
196
218
  opencode: true,
197
219
  })
198
220
 
@@ -212,6 +234,7 @@ describe("work command", () => {
212
234
 
213
235
  await harness.run({
214
236
  cwd: "/workbase/tasks/example/code/agency/src",
237
+ directory: ".",
215
238
  opencode: true,
216
239
  })
217
240
 
@@ -219,7 +242,7 @@ describe("work command", () => {
219
242
  expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
220
243
  })
221
244
 
222
- test("selects a target with fzf outside an entity directory", async () => {
245
+ test("selects a target with fzf when no directory is provided", async () => {
223
246
  const phase = {
224
247
  taskId: "delivery",
225
248
  id: "build",
@@ -242,7 +265,7 @@ describe("work command", () => {
242
265
  choices.find((choice) => choice.label.includes("build"))!.target,
243
266
  )
244
267
 
245
- await harness.run({ cwd: "/workbase", opencode: true }, pick)
268
+ await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
246
269
 
247
270
  expect(harness.events).toEqual([
248
271
  "probe:fzf",
@@ -255,6 +278,41 @@ describe("work command", () => {
255
278
  )
256
279
  })
257
280
 
281
+ test("selects a registered workbase when local discovery fails", async () => {
282
+ const harness = createHarness({
283
+ outsideWorkbase: true,
284
+ registeredWorkbases: ["/first", "/workbase"],
285
+ })
286
+ const selections: string[][] = []
287
+ const pickBase: PickWorkbase = (workbases) => {
288
+ selections.push([...workbases])
289
+ return Effect.succeed("/workbase")
290
+ }
291
+
292
+ await harness.run(
293
+ { cwd: "/outside", taskId: "example", opencode: true },
294
+ undefined,
295
+ pickBase,
296
+ )
297
+
298
+ expect(selections).toEqual([["/first", "/workbase"]])
299
+ expect(harness.events).toEqual([
300
+ "probe:fzf",
301
+ "materialize",
302
+ "probe:opencode",
303
+ "launch:opencode",
304
+ ])
305
+ })
306
+
307
+ test("explains how to register a workbase when none are known", async () => {
308
+ const harness = createHarness({ outsideWorkbase: true })
309
+
310
+ await expect(harness.run({ cwd: "/outside" })).rejects.toThrow(
311
+ "agency workbase add <path>",
312
+ )
313
+ expect(harness.events).toEqual([])
314
+ })
315
+
258
316
  test("prints the target tree when fzf is unavailable", async () => {
259
317
  const harness = createHarness({
260
318
  available: { fzf: false },
@@ -401,7 +459,7 @@ describe("work command", () => {
401
459
  verboseHarness.run({ taskId: "example", verbose: true }),
402
460
  )
403
461
  expect(verboseLogs).toEqual([
404
- "Launching opencode in /workbase/tasks/example",
462
+ "Launching command: opencode --continue (cwd: /workbase/tasks/example)",
405
463
  ])
406
464
  expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
407
465
 
@@ -16,8 +16,14 @@ import {
16
16
  type PickWorkTarget,
17
17
  type WorkTarget,
18
18
  } from "../workbase/work-target"
19
+ import {
20
+ pickWorkbase,
21
+ resolveWorkbase,
22
+ type PickWorkbase,
23
+ } from "../workbase/workbase-choice"
19
24
 
20
25
  interface WorkOptions extends BaseCommandOptions {
26
+ readonly directory?: string
21
27
  readonly taskId?: string
22
28
  readonly phaseId?: string
23
29
  readonly epicId?: string
@@ -27,6 +33,15 @@ interface WorkOptions extends BaseCommandOptions {
27
33
 
28
34
  type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
29
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
+
30
45
  const launchAgent: LaunchAgent = (cli, args, cwd) => {
31
46
  process.chdir(cwd)
32
47
  execvp(cli, [...args])
@@ -37,6 +52,7 @@ export const work = (
37
52
  launch: LaunchAgent = launchAgent,
38
53
  pick: PickWorkTarget = pickWorkTarget,
39
54
  progress: Progress = createProgress(options),
55
+ pickBase: PickWorkbase = pickWorkbase,
40
56
  ) =>
41
57
  Effect.gen(function* () {
42
58
  if (options.opencode && options.claude) {
@@ -44,9 +60,12 @@ export const work = (
44
60
  new Error("Cannot use both --opencode and --claude"),
45
61
  )
46
62
  }
47
- if (options.epicId && (options.taskId || options.phaseId)) {
63
+ if (
64
+ options.epicId &&
65
+ (options.directory || options.taskId || options.phaseId)
66
+ ) {
48
67
  return yield* Effect.fail(
49
- new Error("Cannot combine --epic with a task or phase ID"),
68
+ new Error("Cannot combine --epic with a directory, task, or phase ID"),
50
69
  )
51
70
  }
52
71
 
@@ -58,7 +77,14 @@ export const work = (
58
77
  const phases = yield* PhaseService
59
78
  const { log, verboseLog } = createLoggers(options)
60
79
  const cwd = options.cwd ?? process.cwd()
61
- const root = yield* workbase.discover(cwd)
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)
87
+ if (!root) return
62
88
 
63
89
  let target: WorkTarget | null = null
64
90
  if (options.epicId) {
@@ -82,8 +108,8 @@ export const work = (
82
108
  multiPhase: "phases" in task.data,
83
109
  }
84
110
  }
85
- } else {
86
- const path = relative(root, resolve(cwd))
111
+ } else if (options.directory) {
112
+ const path = relative(root, startPath)
87
113
  const parts =
88
114
  !path || isAbsolute(path) || path.startsWith(`..${sep}`)
89
115
  ? []
@@ -138,7 +164,7 @@ export const work = (
138
164
  for (const choice of choices) log(choice.label)
139
165
  return yield* Effect.fail(
140
166
  new Error(
141
- "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",
142
168
  ),
143
169
  )
144
170
  }
@@ -192,16 +218,19 @@ export const work = (
192
218
  yield* tasks.setStatus(target.taskId, "working", root)
193
219
  }
194
220
 
195
- verboseLog(`Launching ${cli} in ${launchPath}`)
196
221
  const args = cli === "opencode" ? ["--continue"] : [prompt]
222
+ verboseLog(
223
+ `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
224
+ )
197
225
  launch(cli, [cli, ...args], launchPath)
198
226
  })
199
227
 
200
228
  export const help = `
201
- Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
229
+ Usage: agency work [<directory> | --epic <epic-id>]
202
230
 
203
- Launch an agent for the current epic, task, or phase. Outside an entity
204
- directory, select one with fzf.
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.
205
234
 
206
235
  Options:
207
236
  --epic <id> Work on an epic
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { join } from "node:path"
3
+ import {
4
+ captureLogs,
5
+ cleanupTempDir,
6
+ createTempDir,
7
+ runTestEffect,
8
+ } from "../test-utils"
9
+ import { workbase } from "./workbase"
10
+
11
+ describe("workbase command", () => {
12
+ let root: string
13
+ let configDirectory: string
14
+
15
+ beforeEach(async () => {
16
+ root = await createTempDir()
17
+ configDirectory = join(root, "config")
18
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
19
+ })
20
+
21
+ afterEach(async () => cleanupTempDir(root))
22
+
23
+ test("adds and lists workbases as JSON", async () => {
24
+ const added = await captureLogs(() =>
25
+ runTestEffect(
26
+ workbase({
27
+ subcommand: "add",
28
+ args: [root],
29
+ configDirectory,
30
+ json: true,
31
+ }),
32
+ ),
33
+ )
34
+ const path = JSON.parse(added[0]!).path
35
+
36
+ const listed = await captureLogs(() =>
37
+ runTestEffect(
38
+ workbase({
39
+ subcommand: "list",
40
+ args: [],
41
+ configDirectory,
42
+ json: true,
43
+ }),
44
+ ),
45
+ )
46
+
47
+ expect(JSON.parse(listed[0]!)).toEqual([path])
48
+ })
49
+
50
+ test("requires an add path", async () => {
51
+ await expect(
52
+ runTestEffect(
53
+ workbase({
54
+ subcommand: "add",
55
+ args: [],
56
+ configDirectory,
57
+ silent: true,
58
+ }),
59
+ ),
60
+ ).rejects.toThrow("Usage: agency workbase add <path>")
61
+ })
62
+ })
@@ -0,0 +1,58 @@
1
+ import { Effect } from "effect"
2
+ import type { BaseCommandOptions } from "../utils/command"
3
+ import { WorkbaseService } from "../services/WorkbaseService"
4
+ import { createLoggers } from "../utils/effect"
5
+
6
+ interface WorkbaseOptions extends BaseCommandOptions {
7
+ readonly subcommand?: string
8
+ readonly args: readonly string[]
9
+ readonly configDirectory?: string
10
+ }
11
+
12
+ export const workbase = (options: WorkbaseOptions) =>
13
+ Effect.gen(function* () {
14
+ const service = yield* WorkbaseService
15
+ const { log } = createLoggers(options)
16
+
17
+ switch (options.subcommand) {
18
+ case "add": {
19
+ const path = options.args[0]
20
+ if (!path) {
21
+ return yield* Effect.fail(
22
+ new Error("Usage: agency workbase add <path>"),
23
+ )
24
+ }
25
+ const root = yield* service.register(path, options.configDirectory)
26
+ log(
27
+ options.json
28
+ ? JSON.stringify({ path: root }, null, 2)
29
+ : `Added workbase ${root}`,
30
+ )
31
+ return
32
+ }
33
+ case "list": {
34
+ const workbases = yield* service.listRegistered(options.configDirectory)
35
+ if (options.json) {
36
+ log(JSON.stringify(workbases, null, 2))
37
+ } else {
38
+ for (const path of workbases) log(path)
39
+ }
40
+ return
41
+ }
42
+ default:
43
+ return yield* Effect.fail(
44
+ new Error("Subcommand is required. Available: add, list"),
45
+ )
46
+ }
47
+ })
48
+
49
+ export const help = `
50
+ Usage: agency workbase <subcommand>
51
+
52
+ Subcommands:
53
+ add <path> Register an Agency workbase
54
+ list List registered workbases
55
+
56
+ Options:
57
+ --json Output results as JSON
58
+ `
@@ -237,12 +237,12 @@ describe("task and phase services", () => {
237
237
  const task = await runTestEffect(
238
238
  TaskService.pipe(
239
239
  Effect.flatMap((service) =>
240
- service.setStatus("single-status", "done", root),
240
+ service.setStatus("single-status", "delegated", root),
241
241
  ),
242
242
  ),
243
243
  )
244
- expect(task.data.status).toBe("done")
245
- expect(task.content).toContain("status: done")
244
+ expect(task.data.status).toBe("delegated")
245
+ expect(task.content).toContain("status: delegated")
246
246
  expect(task.content).toContain("Describe the task outcome.")
247
247
 
248
248
  await runTestEffect(
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { createHash } from "node:crypto"
4
- import { mkdir } from "node:fs/promises"
4
+ import { mkdir, realpath } from "node:fs/promises"
5
5
  import { dirname, join } from "node:path"
6
6
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
7
7
  import { managedWorkbaseAgents } from "../workbase/agents-file"
@@ -52,10 +52,42 @@ 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
 
59
+ test("registers canonical workbase paths without duplicates", async () => {
60
+ const workbaseRoot = join(root, "workbase")
61
+ const nested = join(workbaseRoot, "nested")
62
+ const configDirectory = join(root, "config")
63
+ await write(workbaseRoot, "agency.json", '{"version":2}\n')
64
+ await mkdir(nested, { recursive: true })
65
+
66
+ const first = await runTestEffect(
67
+ WorkbaseService.pipe(
68
+ Effect.flatMap((service) => service.register(nested, configDirectory)),
69
+ ),
70
+ )
71
+ await runTestEffect(
72
+ WorkbaseService.pipe(
73
+ Effect.flatMap((service) =>
74
+ service.register(workbaseRoot, configDirectory),
75
+ ),
76
+ ),
77
+ )
78
+ const registered = await runTestEffect(
79
+ WorkbaseService.pipe(
80
+ Effect.flatMap((service) => service.listRegistered(configDirectory)),
81
+ ),
82
+ )
83
+
84
+ expect(first).toBe(await realpath(workbaseRoot))
85
+ expect(registered).toEqual([first])
86
+ expect(
87
+ await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
88
+ ).toEqual({ version: 1, workbases: [first] })
89
+ })
90
+
59
91
  test("preserves an unmanaged workbase OpenCode config", async () => {
60
92
  await write(root, "agency.json", '{"version":2}\n')
61
93
  await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
@@ -95,7 +127,7 @@ describe("WorkbaseService", () => {
95
127
  )
96
128
 
97
129
  expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
98
- managedWorkbaseOpencode,
130
+ managedWorkbaseOpencode(root),
99
131
  )
100
132
  })
101
133
 
@@ -1,6 +1,7 @@
1
1
  import { Schema } from "@effect/schema"
2
2
  import { TreeFormatter } from "@effect/schema"
3
3
  import { Data, Effect, Either } from "effect"
4
+ import { homedir } from "node:os"
4
5
  import { dirname, join, relative, resolve } from "node:path"
5
6
  import { FileSystemService } from "./FileSystemService"
6
7
  import { parseFrontmatter } from "../workbase/frontmatter"
@@ -9,6 +10,7 @@ import {
9
10
  PhaseFrontmatter,
10
11
  TaskFrontmatter,
11
12
  WorkbaseConfig,
13
+ WorkbaseRegistry,
12
14
  type Dependency,
13
15
  type EpicFrontmatter as EpicData,
14
16
  type PhaseFrontmatter as PhaseData,
@@ -34,6 +36,12 @@ class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
34
36
  readonly cause?: unknown
35
37
  }> {}
36
38
 
39
+ class WorkbaseRegistryError extends Data.TaggedError("WorkbaseRegistryError")<{
40
+ readonly message: string
41
+ readonly path: string
42
+ readonly cause?: unknown
43
+ }> {}
44
+
37
45
  interface ValidationIssue {
38
46
  readonly path: string
39
47
  readonly message: string
@@ -72,6 +80,45 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
72
80
  : { success: true, value: result.right }
73
81
  }
74
82
 
83
+ const registryPath = (configDirectory?: string) =>
84
+ join(
85
+ configDirectory ||
86
+ process.env.XDG_CONFIG_HOME ||
87
+ join(homedir(), ".config"),
88
+ "agency",
89
+ "workbases.json",
90
+ )
91
+
92
+ const readRegistry = (configDirectory?: string) =>
93
+ Effect.gen(function* () {
94
+ const fs = yield* FileSystemService
95
+ const path = registryPath(configDirectory)
96
+ if (!(yield* fs.exists(path))) {
97
+ return { path, registry: { version: 1, workbases: [] } as const }
98
+ }
99
+
100
+ const content = yield* fs.readFile(path)
101
+ let input: unknown
102
+ try {
103
+ input = JSON.parse(content)
104
+ } catch (cause) {
105
+ return yield* new WorkbaseRegistryError({
106
+ path,
107
+ message: `Invalid JSON in workbase registry ${path}`,
108
+ cause,
109
+ })
110
+ }
111
+
112
+ const decoded = decode(WorkbaseRegistry, input)
113
+ if (!decoded.success) {
114
+ return yield* new WorkbaseRegistryError({
115
+ path,
116
+ message: `Invalid workbase registry in ${path}:\n${decoded.error}`,
117
+ })
118
+ }
119
+ return { path, registry: decoded.value }
120
+ })
121
+
75
122
  const ensureWorkbaseAgents = (root: string) =>
76
123
  Effect.gen(function* () {
77
124
  const fs = yield* FileSystemService
@@ -95,19 +142,17 @@ const ensureWorkbaseOpencode = (root: string) =>
95
142
  const fs = yield* FileSystemService
96
143
  const directory = join(root, ".opencode")
97
144
  const path = join(directory, "opencode.jsonc")
145
+ const managed = managedWorkbaseOpencode(root)
98
146
  if (!(yield* fs.exists(path))) {
99
147
  if (yield* fs.exists(join(directory, "opencode.json"))) return
100
148
  yield* fs.createDirectory(directory)
101
- yield* fs.writeFile(path, managedWorkbaseOpencode)
149
+ yield* fs.writeFile(path, managed)
102
150
  return
103
151
  }
104
152
 
105
153
  const content = yield* fs.readFile(path)
106
- if (
107
- content !== managedWorkbaseOpencode &&
108
- canUpdateManagedWorkbaseOpencode(content)
109
- ) {
110
- yield* fs.writeFile(path, managedWorkbaseOpencode)
154
+ if (content !== managed && canUpdateManagedWorkbaseOpencode(content)) {
155
+ yield* fs.writeFile(path, managed)
111
156
  }
112
157
  })
113
158
 
@@ -293,6 +338,28 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
293
338
  return { root, config: decoded.value }
294
339
  }),
295
340
 
341
+ register: (startPath: string, configDirectory?: string) =>
342
+ Effect.gen(function* () {
343
+ const service = yield* WorkbaseService
344
+ const fs = yield* FileSystemService
345
+ const discovered = yield* service.discover(startPath)
346
+ const root = yield* fs.realPath(discovered)
347
+ const { path, registry } = yield* readRegistry(configDirectory)
348
+ if (registry.workbases.includes(root)) return root
349
+
350
+ yield* fs.createDirectory(dirname(path))
351
+ yield* fs.writeJSON(path, {
352
+ version: 1,
353
+ workbases: [...registry.workbases, root],
354
+ })
355
+ return root
356
+ }),
357
+
358
+ listRegistered: (configDirectory?: string) =>
359
+ readRegistry(configDirectory).pipe(
360
+ Effect.map(({ registry }) => registry.workbases),
361
+ ),
362
+
296
363
  validate: (startPath: string = process.cwd()) =>
297
364
  Effect.gen(function* () {
298
365
  const service = yield* WorkbaseService
@@ -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"),