@markjaquith/agency 2.4.0 → 2.6.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.
Files changed (37) hide show
  1. package/README.md +56 -20
  2. package/cli.ts +43 -2
  3. package/package.json +1 -1
  4. package/skills/agency/SKILL.md +41 -5
  5. package/src/cli.test.ts +31 -3
  6. package/src/commands/archive.test.ts +69 -0
  7. package/src/commands/archive.ts +70 -0
  8. package/src/commands/init.test.ts +3 -0
  9. package/src/commands/phase.ts +23 -1
  10. package/src/commands/task-phase.test.ts +46 -1
  11. package/src/commands/task.test.ts +94 -0
  12. package/src/commands/task.ts +160 -17
  13. package/src/commands/validate.test.ts +65 -0
  14. package/src/commands/validate.ts +19 -5
  15. package/src/commands/work.test.ts +100 -34
  16. package/src/commands/work.ts +30 -10
  17. package/src/commands/workbase.test.ts +62 -0
  18. package/src/commands/workbase.ts +58 -0
  19. package/src/services/ArchiveService.test.ts +334 -0
  20. package/src/services/ArchiveService.ts +246 -0
  21. package/src/services/PhaseService.ts +28 -0
  22. package/src/services/TaskPhaseService.test.ts +79 -0
  23. package/src/services/TaskService.ts +31 -1
  24. package/src/services/WorkbaseService.test.ts +99 -1
  25. package/src/services/WorkbaseService.ts +101 -2
  26. package/src/services/WorktreeService.test.ts +150 -1
  27. package/src/services/WorktreeService.ts +123 -1
  28. package/src/test-utils.ts +2 -0
  29. package/src/utils/progress.test.ts +37 -0
  30. package/src/utils/progress.ts +36 -0
  31. package/src/workbase/AGENTS.md +3 -0
  32. package/src/workbase/opencode-file.ts +37 -0
  33. package/src/workbase/schemas.test.ts +114 -0
  34. package/src/workbase/schemas.ts +18 -2
  35. package/src/workbase/work-target.test.ts +16 -9
  36. package/src/workbase/work-target.ts +37 -6
  37. package/src/workbase/workbase-choice.ts +71 -0
@@ -9,6 +9,7 @@ import {
9
9
  TaskFrontmatter,
10
10
  type RepositoryReference,
11
11
  type TaskFrontmatter as TaskData,
12
+ WorkStatus,
12
13
  } from "../workbase/schemas"
13
14
  import {
14
15
  formatMarkdownDocument,
@@ -28,7 +29,7 @@ interface TaskRecord {
28
29
 
29
30
  export interface CreateTaskInput {
30
31
  readonly id: string
31
- readonly ticketUrl: string
32
+ readonly ticketUrl: string | null
32
33
  readonly description?: string
33
34
  readonly epic?: string
34
35
  readonly multiPhase?: boolean
@@ -57,6 +58,13 @@ const decodeId = (id: string) => {
57
58
  : Effect.succeed(result.right)
58
59
  }
59
60
 
61
+ const decodeStatus = (status: string) => {
62
+ const result = Schema.decodeUnknownEither(WorkStatus)(status)
63
+ return Either.isLeft(result)
64
+ ? Effect.fail(new TaskError({ message: `Invalid work status '${status}'` }))
65
+ : Effect.succeed(result.right)
66
+ }
67
+
60
68
  export class TaskService extends Effect.Service<TaskService>()("TaskService", {
61
69
  sync: () => ({
62
70
  create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
@@ -188,5 +196,27 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
188
196
  }
189
197
  return record
190
198
  }),
199
+
200
+ setStatus: (
201
+ id: string,
202
+ status: string,
203
+ startPath: string = process.cwd(),
204
+ ) =>
205
+ Effect.gen(function* () {
206
+ const fs = yield* FileSystemService
207
+ const service = yield* TaskService
208
+ const validStatus = yield* decodeStatus(status)
209
+ const record = yield* service.show(id, startPath)
210
+ if ("phases" in record.data) {
211
+ return yield* new TaskError({
212
+ message: `Task '${id}' has multiple phases; set status on a phase instead`,
213
+ })
214
+ }
215
+ const parsed = yield* parseFrontmatter(record.content, record.path)
216
+ const data = { ...record.data, status: validStatus }
217
+ const content = formatMarkdownDocument(data, parsed.body)
218
+ yield* fs.writeFile(record.path, content)
219
+ return { ...record, content, data } satisfies TaskRecord
220
+ }),
191
221
  }),
192
222
  }) {}
@@ -1,10 +1,11 @@
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"
8
+ import { managedWorkbaseOpencode } from "../workbase/opencode-file"
8
9
  import { WorkbaseService } from "./WorkbaseService"
9
10
 
10
11
  const write = async (root: string, path: string, content: string) => {
@@ -18,6 +19,11 @@ const managedAgents = (body: string) => {
18
19
  return `<!-- agency-managed: sha256=${checksum} -->\n\n${body}`
19
20
  }
20
21
 
22
+ const managedOpencode = (body: string) => {
23
+ const checksum = createHash("sha256").update(body).digest("hex")
24
+ return `// agency-managed: sha256=${checksum}\n\n${body}`
25
+ }
26
+
21
27
  describe("WorkbaseService", () => {
22
28
  let root: string
23
29
 
@@ -45,6 +51,98 @@ describe("WorkbaseService", () => {
45
51
  expect(await Bun.file(join(root, "AGENTS.md")).text()).toBe(
46
52
  managedWorkbaseAgents,
47
53
  )
54
+ expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
55
+ managedWorkbaseOpencode,
56
+ )
57
+ })
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
+
91
+ test("preserves an unmanaged workbase OpenCode config", async () => {
92
+ await write(root, "agency.json", '{"version":2}\n')
93
+ await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
94
+
95
+ await runTestEffect(
96
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
97
+ )
98
+
99
+ expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
100
+ '{"model":"test/model"}\n',
101
+ )
102
+ })
103
+
104
+ test("does not override an existing JSON OpenCode config", async () => {
105
+ await write(root, "agency.json", '{"version":2}\n')
106
+ await write(root, ".opencode/opencode.json", '{"model":"test/model"}\n')
107
+
108
+ await runTestEffect(
109
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
110
+ )
111
+
112
+ expect(
113
+ await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
114
+ ).toBe(false)
115
+ })
116
+
117
+ test("updates an unmodified managed workbase OpenCode config", async () => {
118
+ await write(root, "agency.json", '{"version":2}\n')
119
+ await write(
120
+ root,
121
+ ".opencode/opencode.jsonc",
122
+ managedOpencode('{"references":{}}\n'),
123
+ )
124
+
125
+ await runTestEffect(
126
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
127
+ )
128
+
129
+ expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
130
+ managedWorkbaseOpencode,
131
+ )
132
+ })
133
+
134
+ test("preserves a modified managed workbase OpenCode config", async () => {
135
+ await write(root, "agency.json", '{"version":2}\n')
136
+ const content = `${managedOpencode('{"references":{}}\n')}\n// User edit\n`
137
+ await write(root, ".opencode/opencode.jsonc", content)
138
+
139
+ await runTestEffect(
140
+ WorkbaseService.pipe(Effect.flatMap((service) => service.discover(root))),
141
+ )
142
+
143
+ expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
144
+ content,
145
+ )
48
146
  })
49
147
 
50
148
  test("preserves an unmanaged workbase AGENTS.md", async () => {
@@ -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,
@@ -19,6 +21,10 @@ import {
19
21
  canUpdateManagedWorkbaseAgents,
20
22
  managedWorkbaseAgents,
21
23
  } from "../workbase/agents-file"
24
+ import {
25
+ canUpdateManagedWorkbaseOpencode,
26
+ managedWorkbaseOpencode,
27
+ } from "../workbase/opencode-file"
22
28
 
23
29
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
24
30
  readonly message: string
@@ -30,6 +36,12 @@ class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
30
36
  readonly cause?: unknown
31
37
  }> {}
32
38
 
39
+ class WorkbaseRegistryError extends Data.TaggedError("WorkbaseRegistryError")<{
40
+ readonly message: string
41
+ readonly path: string
42
+ readonly cause?: unknown
43
+ }> {}
44
+
33
45
  interface ValidationIssue {
34
46
  readonly path: string
35
47
  readonly message: string
@@ -68,6 +80,45 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
68
80
  : { success: true, value: result.right }
69
81
  }
70
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
+
71
122
  const ensureWorkbaseAgents = (root: string) =>
72
123
  Effect.gen(function* () {
73
124
  const fs = yield* FileSystemService
@@ -86,6 +137,32 @@ const ensureWorkbaseAgents = (root: string) =>
86
137
  }
87
138
  })
88
139
 
140
+ const ensureWorkbaseOpencode = (root: string) =>
141
+ Effect.gen(function* () {
142
+ const fs = yield* FileSystemService
143
+ const directory = join(root, ".opencode")
144
+ const path = join(directory, "opencode.jsonc")
145
+ if (!(yield* fs.exists(path))) {
146
+ if (yield* fs.exists(join(directory, "opencode.json"))) return
147
+ yield* fs.createDirectory(directory)
148
+ yield* fs.writeFile(path, managedWorkbaseOpencode)
149
+ return
150
+ }
151
+
152
+ const content = yield* fs.readFile(path)
153
+ if (
154
+ content !== managedWorkbaseOpencode &&
155
+ canUpdateManagedWorkbaseOpencode(content)
156
+ ) {
157
+ yield* fs.writeFile(path, managedWorkbaseOpencode)
158
+ }
159
+ })
160
+
161
+ const ensureWorkbaseAgentFiles = (root: string) =>
162
+ Effect.all([ensureWorkbaseAgents(root), ensureWorkbaseOpencode(root)], {
163
+ concurrency: "unbounded",
164
+ })
165
+
89
166
  const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
90
167
  const dependencies = new Map(
91
168
  nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
@@ -164,7 +241,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
164
241
  `${existing}${prefix}${missing.join("\n")}\n`,
165
242
  )
166
243
  }
167
- yield* ensureWorkbaseAgents(root)
244
+ yield* ensureWorkbaseAgentFiles(root)
168
245
 
169
246
  return root
170
247
  }),
@@ -221,7 +298,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
221
298
  })
222
299
  }
223
300
  }
224
- yield* ensureWorkbaseAgents(current)
301
+ yield* ensureWorkbaseAgentFiles(current)
225
302
  return current
226
303
  }
227
304
  }
@@ -263,6 +340,28 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
263
340
  return { root, config: decoded.value }
264
341
  }),
265
342
 
343
+ register: (startPath: string, configDirectory?: string) =>
344
+ Effect.gen(function* () {
345
+ const service = yield* WorkbaseService
346
+ const fs = yield* FileSystemService
347
+ const discovered = yield* service.discover(startPath)
348
+ const root = yield* fs.realPath(discovered)
349
+ const { path, registry } = yield* readRegistry(configDirectory)
350
+ if (registry.workbases.includes(root)) return root
351
+
352
+ yield* fs.createDirectory(dirname(path))
353
+ yield* fs.writeJSON(path, {
354
+ version: 1,
355
+ workbases: [...registry.workbases, root],
356
+ })
357
+ return root
358
+ }),
359
+
360
+ listRegistered: (configDirectory?: string) =>
361
+ readRegistry(configDirectory).pipe(
362
+ Effect.map(({ registry }) => registry.workbases),
363
+ ),
364
+
266
365
  validate: (startPath: string = process.cwd()) =>
267
366
  Effect.gen(function* () {
268
367
  const service = yield* WorkbaseService
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import {
6
6
  captureLogs,
@@ -600,6 +600,155 @@ pr: null
600
600
  ).rejects.toThrow("is attached to branch 'main'")
601
601
  })
602
602
 
603
+ test("removes worktrees without deleting branches", async () => {
604
+ await runTestEffect(
605
+ TaskService.pipe(
606
+ Effect.flatMap((service) =>
607
+ service.create(
608
+ {
609
+ id: "removable",
610
+ ticketUrl: "https://example.com/task",
611
+ repo: "agency",
612
+ repos: [{ repo: "effect", ref: "main" }],
613
+ branch: "task/removable",
614
+ base: "main",
615
+ },
616
+ root,
617
+ ),
618
+ ),
619
+ ),
620
+ )
621
+ const workspace = await runTestEffect(
622
+ WorktreeService.pipe(
623
+ Effect.flatMap((service) =>
624
+ service.materialize("removable", undefined, root),
625
+ ),
626
+ ),
627
+ )
628
+
629
+ const removed = await runTestEffect(
630
+ WorktreeService.pipe(
631
+ Effect.flatMap((service) =>
632
+ service.remove("removable", undefined, root),
633
+ ),
634
+ ),
635
+ )
636
+
637
+ expect(removed.sort()).toEqual(
638
+ [
639
+ join(workspace.codePath, "agency"),
640
+ join(workspace.codePath, "effect"),
641
+ ].sort(),
642
+ )
643
+ expect(await Bun.file(workspace.codePath).exists()).toBe(false)
644
+ const branch = Bun.spawnSync([
645
+ "git",
646
+ "-C",
647
+ join(root, "repos/agency"),
648
+ "show-ref",
649
+ "--verify",
650
+ "refs/heads/task/removable",
651
+ ])
652
+ expect(branch.exitCode).toBe(0)
653
+ })
654
+
655
+ test("refuses to remove a worktree with uncommitted changes", async () => {
656
+ await runTestEffect(
657
+ TaskService.pipe(
658
+ Effect.flatMap((service) =>
659
+ service.create(
660
+ {
661
+ id: "dirty",
662
+ ticketUrl: "https://example.com/task",
663
+ repo: "agency",
664
+ branch: "task/dirty",
665
+ base: "main",
666
+ },
667
+ root,
668
+ ),
669
+ ),
670
+ ),
671
+ )
672
+ const workspace = await runTestEffect(
673
+ WorktreeService.pipe(
674
+ Effect.flatMap((service) =>
675
+ service.materialize("dirty", undefined, root),
676
+ ),
677
+ ),
678
+ )
679
+ await Bun.write(
680
+ join(workspace.writablePath, "uncommitted.txt"),
681
+ "keep me\n",
682
+ )
683
+
684
+ await expect(
685
+ runTestEffect(
686
+ WorktreeService.pipe(
687
+ Effect.flatMap((service) => service.remove("dirty", undefined, root)),
688
+ ),
689
+ ),
690
+ ).rejects.toThrow("Failed to remove worktree for 'agency'")
691
+ expect(
692
+ await Bun.file(join(workspace.writablePath, "uncommitted.txt")).text(),
693
+ ).toBe("keep me\n")
694
+ })
695
+
696
+ test("handles a missing checkout without deleting its branch", async () => {
697
+ await runTestEffect(
698
+ TaskService.pipe(
699
+ Effect.flatMap((service) =>
700
+ service.create(
701
+ {
702
+ id: "stale",
703
+ ticketUrl: "https://example.com/task",
704
+ repo: "agency",
705
+ branch: "task/stale",
706
+ base: "main",
707
+ },
708
+ root,
709
+ ),
710
+ ),
711
+ ),
712
+ )
713
+ const workspace = await runTestEffect(
714
+ WorktreeService.pipe(
715
+ Effect.flatMap((service) =>
716
+ service.materialize("stale", undefined, root),
717
+ ),
718
+ ),
719
+ )
720
+ await rm(workspace.codePath, { recursive: true })
721
+
722
+ const removed = await runTestEffect(
723
+ WorktreeService.pipe(
724
+ Effect.flatMap((service) => service.remove("stale", undefined, root)),
725
+ ),
726
+ )
727
+
728
+ expect(removed).toEqual([])
729
+ const worktrees = Bun.spawnSync([
730
+ "git",
731
+ "-C",
732
+ join(root, "repos/agency"),
733
+ "worktree",
734
+ "list",
735
+ "--porcelain",
736
+ ])
737
+ expect(new TextDecoder().decode(worktrees.stdout)).not.toContain(
738
+ workspace.writablePath,
739
+ )
740
+ expect(
741
+ Bun.spawnSync([
742
+ "git",
743
+ "-C",
744
+ join(root, "repos/agency"),
745
+ "show-ref",
746
+ "--verify",
747
+ "refs/heads/task/stale",
748
+ ]).exitCode,
749
+ ).toBe(0)
750
+ })
751
+
603
752
  test("supports Worktrunk as the configured command", async () => {
604
753
  if (Bun.spawnSync(["which", "wt"], { stdout: "ignore" }).exitCode !== 0) {
605
754
  return
@@ -1,5 +1,5 @@
1
1
  import { Data, Effect } from "effect"
2
- import { dirname, join, resolve } from "node:path"
2
+ import { basename, dirname, join, resolve } from "node:path"
3
3
  import { FileSystemService } from "./FileSystemService"
4
4
  import { WorkbaseService } from "./WorkbaseService"
5
5
  import { TaskService } from "./TaskService"
@@ -372,6 +372,128 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
372
372
  repos: execution.repos ?? [],
373
373
  } satisfies ExecutionWorkspace
374
374
  }),
375
+
376
+ remove: (
377
+ taskId: string,
378
+ phaseId?: string,
379
+ startPath: string = process.cwd(),
380
+ ) =>
381
+ Effect.gen(function* () {
382
+ const fs = yield* FileSystemService
383
+ const workbase = yield* WorkbaseService
384
+ const tasks = yield* TaskService
385
+ const phases = yield* PhaseService
386
+ const root = yield* workbase.discover(startPath)
387
+ const task = yield* tasks.show(taskId, root)
388
+
389
+ let execution: {
390
+ repo: string
391
+ repos?: readonly RepositoryReference[]
392
+ }
393
+ let codePath: string
394
+ if ("phases" in task.data) {
395
+ if (!phaseId) {
396
+ return yield* new WorktreeError({
397
+ message: `Task '${taskId}' has multiple phases; phase ID is required`,
398
+ })
399
+ }
400
+ const phase = yield* phases.show(taskId, phaseId, root)
401
+ execution = phase.data
402
+ codePath = join(dirname(phase.path), "code")
403
+ } else {
404
+ if (phaseId) {
405
+ return yield* new WorktreeError({
406
+ message: `Task '${taskId}' is single-phase and does not accept a phase ID`,
407
+ })
408
+ }
409
+ execution = task.data
410
+ codePath = join(dirname(task.path), "code")
411
+ }
412
+
413
+ const codeDirectoryExists = yield* fs.isDirectory(codePath)
414
+ const removed: string[] = []
415
+ for (const alias of [
416
+ execution.repo,
417
+ ...(execution.repos ?? []).map((reference) => reference.repo),
418
+ ]) {
419
+ const repositoryPath = join(root, "repos", alias)
420
+ const checkoutPath = join(codePath, alias)
421
+ const listed = yield* fs.runCommand(
422
+ [
423
+ "git",
424
+ "-C",
425
+ repositoryPath,
426
+ "worktree",
427
+ "list",
428
+ "--porcelain",
429
+ "-z",
430
+ ],
431
+ { captureOutput: true },
432
+ )
433
+ if (listed.exitCode !== 0) {
434
+ return yield* new WorktreeError({
435
+ message: `Failed to inspect worktrees for '${alias}': ${listed.stderr}`,
436
+ })
437
+ }
438
+
439
+ const checkoutExists = yield* fs.isDirectory(checkoutPath)
440
+ const canonicalCheckoutPath = checkoutExists
441
+ ? yield* fs.realPath(checkoutPath)
442
+ : join(
443
+ yield* fs.realPath(dirname(codePath)),
444
+ basename(codePath),
445
+ alias,
446
+ )
447
+ let registeredPath: string | undefined
448
+ for (const worktree of parseWorktreeList(listed.stdout)) {
449
+ const worktreePath = (yield* fs.exists(worktree.path))
450
+ ? yield* fs.realPath(worktree.path)
451
+ : resolve(worktree.path)
452
+ if (worktreePath === canonicalCheckoutPath) {
453
+ registeredPath = worktreePath
454
+ break
455
+ }
456
+ }
457
+ if (!registeredPath) {
458
+ if (checkoutExists) {
459
+ return yield* new WorktreeError({
460
+ message: `Existing checkout ${checkoutPath} is not registered as a Git worktree`,
461
+ })
462
+ }
463
+ continue
464
+ }
465
+
466
+ const result = yield* fs.runCommand(
467
+ [
468
+ "git",
469
+ "-C",
470
+ repositoryPath,
471
+ "worktree",
472
+ "remove",
473
+ ...(!checkoutExists ? ["--force"] : []),
474
+ checkoutExists ? checkoutPath : registeredPath,
475
+ ],
476
+ { captureOutput: true },
477
+ )
478
+ if (result.exitCode !== 0) {
479
+ return yield* new WorktreeError({
480
+ message: `Failed to remove worktree for '${alias}': ${result.stderr}`,
481
+ })
482
+ }
483
+ if (checkoutExists) removed.push(checkoutPath)
484
+ }
485
+
486
+ if (codeDirectoryExists && (yield* fs.isDirectory(codePath))) {
487
+ const remaining = yield* fs.readDirectory(codePath)
488
+ if (remaining.length > 0) {
489
+ return yield* new WorktreeError({
490
+ message: `Cannot remove ${codePath}; it contains unmanaged entries: ${remaining.map((entry) => entry.name).join(", ")}`,
491
+ })
492
+ }
493
+ yield* fs.deleteDirectory(codePath)
494
+ }
495
+ return removed
496
+ }),
375
497
  }),
376
498
  },
377
499
  ) {}
package/src/test-utils.ts CHANGED
@@ -11,6 +11,7 @@ import { TaskService } from "./services/TaskService"
11
11
  import { PhaseService } from "./services/PhaseService"
12
12
  import { WorktreeService } from "./services/WorktreeService"
13
13
  import { PullRequestService } from "./services/PullRequestService"
14
+ import { ArchiveService } from "./services/ArchiveService"
14
15
 
15
16
  export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
16
17
 
@@ -26,6 +27,7 @@ const TestLayer = Layer.mergeAll(
26
27
  PhaseService.Default,
27
28
  WorktreeService.Default,
28
29
  PullRequestService.Default,
30
+ ArchiveService.Default,
29
31
  )
30
32
 
31
33
  export async function runTestEffect<A, E>(
@@ -0,0 +1,37 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { createProgress } from "./progress"
3
+
4
+ describe("progress", () => {
5
+ test("replaces an active TTY line with its completed state", () => {
6
+ const output: string[] = []
7
+ const progress = createProgress(
8
+ {},
9
+ { isTTY: true, write: (text) => output.push(text) },
10
+ )
11
+
12
+ progress.start("Preparing workspace...")
13
+ progress.succeed("Workspace ready")
14
+
15
+ expect(output).toEqual([
16
+ "\r\x1b[2K\x1b[2m○\x1b[0m Preparing workspace...",
17
+ "\r\x1b[2K\x1b[32m✓\x1b[0m Workspace ready\n",
18
+ ])
19
+ })
20
+
21
+ test("stays quiet for silent or non-TTY output", () => {
22
+ const output: string[] = []
23
+ for (const [silent, isTTY] of [
24
+ [true, true],
25
+ [false, false],
26
+ ] as const) {
27
+ const progress = createProgress(
28
+ { silent },
29
+ { isTTY, write: (text) => output.push(text) },
30
+ )
31
+ progress.start("Preparing workspace...")
32
+ progress.fail("Workspace preparation failed")
33
+ }
34
+
35
+ expect(output).toEqual([])
36
+ })
37
+ })