@markjaquith/agency 2.71.26 → 2.72.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/README.md CHANGED
@@ -752,6 +752,7 @@ agency integration sync [--json]
752
752
  agency repo setup [--dry-run | --apply] [--json]
753
753
  agency repo add <alias> <remote> [--json]
754
754
  agency repo link <alias> <path> [--json]
755
+ agency repo materialize <alias> [--json]
755
756
  agency repo list [--json]
756
757
  agency repo show <alias> [--json]
757
758
  agency repo fetch <alias> [--json]
@@ -784,7 +785,11 @@ Each registration has a stable ID and may have a unique name. A default workbase
784
785
  is used when the current directory is outside every workbase. `prune` removes
785
786
  registrations whose workbase configuration no longer exists.
786
787
  `repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
787
- repository. Alias names are then used by all documents and commands. Remove,
788
+ repository. For Git workbases, `repo materialize` replaces a linked alias with a
789
+ managed bare clone while migrating its registered worktrees in place; active
790
+ Agency references continue to use the same alias. The command refuses remote
791
+ drift and stale worktree registrations. Alias names are then used by all
792
+ documents and commands. Remove,
788
793
  unlink, and rename refuse aliases referenced by active work or backed by linked
789
794
  worktrees, and report each blocker.
790
795
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.71.26",
3
+ "version": "2.72.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
package/src/cli.test.ts CHANGED
@@ -507,6 +507,49 @@ describe("CLI", () => {
507
507
  })
508
508
  })
509
509
 
510
+ test("preserves the JSON error contract when archiving an already archived task", async () => {
511
+ const root = await createTempDir()
512
+ tempDirs.push(root)
513
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
514
+ expect(
515
+ Bun.spawnSync(["git", "init", "--bare", join(root, "repos/agency")])
516
+ .exitCode,
517
+ ).toBe(0)
518
+ await mkdir(join(root, "tasks/example"), { recursive: true })
519
+ await Bun.write(
520
+ join(root, "tasks/example/TASK.md"),
521
+ `---
522
+ ticketUrl: null
523
+ repo: agency
524
+ branch: task/example
525
+ base: main
526
+ pr: null
527
+ status: dropped
528
+ ---
529
+
530
+ # Example
531
+ `,
532
+ )
533
+
534
+ expect((await runCli(["archive", "task", "example"], root)).exitCode).toBe(
535
+ 0,
536
+ )
537
+ const result = await runCli(["archive", "task", "example", "--json"], root)
538
+
539
+ expect(result.exitCode).toBe(1)
540
+ expect(result.stderr).toBe("")
541
+ expect(JSON.parse(result.stdout)).toEqual({
542
+ version: 1,
543
+ ok: false,
544
+ error: {
545
+ code: "TASK_ERROR",
546
+ message: "Task 'example' is already archived",
547
+ fields: {},
548
+ retryable: false,
549
+ },
550
+ })
551
+ })
552
+
510
553
  test("rejects malformed input before running a command", async () => {
511
554
  const parent = await createTempDir()
512
555
  tempDirs.push(parent)
@@ -93,6 +93,23 @@ describe("archive command", () => {
93
93
  )
94
94
  })
95
95
 
96
+ test("reports an already archived task", async () => {
97
+ await runTestEffect(
98
+ archive({ type: "task", args: ["example"], cwd: root, silent: true }),
99
+ )
100
+
101
+ await expect(
102
+ runTestEffect(
103
+ archive({
104
+ type: "task",
105
+ args: ["example"],
106
+ cwd: root,
107
+ silent: true,
108
+ }),
109
+ ),
110
+ ).rejects.toThrow("Task 'example' is already archived")
111
+ })
112
+
96
113
  test("outputs one deterministic bulk archive object and a concise human summary", async () => {
97
114
  const jsonLogs = await captureLogs(() =>
98
115
  runTestEffect(
@@ -23,7 +23,9 @@ describe("repo command", () => {
23
23
  test("requires a subcommand", async () => {
24
24
  await expect(
25
25
  runTestEffect(repo({ args: [], silent: true })),
26
- ).rejects.toThrow("Available subcommands: setup, add, link, list")
26
+ ).rejects.toThrow(
27
+ "Available subcommands: setup, add, link, materialize, list",
28
+ )
27
29
  })
28
30
 
29
31
  test("requires add arguments", async () => {
@@ -34,6 +36,14 @@ describe("repo command", () => {
34
36
  ).rejects.toThrow("Usage: agency repo add")
35
37
  })
36
38
 
39
+ test("requires a materialize alias", async () => {
40
+ await expect(
41
+ runTestEffect(
42
+ repo({ subcommand: "materialize", args: [], silent: true }),
43
+ ),
44
+ ).rejects.toThrow("Usage: agency repo materialize <alias>")
45
+ })
46
+
37
47
  test("lists repository metadata as JSON", async () => {
38
48
  const logs = await captureLogs(() =>
39
49
  runTestEffect(
@@ -74,6 +74,21 @@ export const repo = (options: RepoOptions) =>
74
74
  return
75
75
  }
76
76
 
77
+ case "materialize": {
78
+ const alias = yield* requireArg(
79
+ options.args,
80
+ 0,
81
+ "Usage: agency repo materialize <alias>",
82
+ )
83
+ const item = yield* repositories.materialize(alias, cwd)
84
+ log(
85
+ options.json
86
+ ? JSON.stringify(item, null, 2)
87
+ : `Materialized repository '${alias}'`,
88
+ )
89
+ return
90
+ }
91
+
77
92
  case "list": {
78
93
  const items = yield* repositories.list(cwd)
79
94
  if (options.json) {
@@ -187,7 +202,7 @@ export const repo = (options: RepoOptions) =>
187
202
  default:
188
203
  return yield* Effect.fail(
189
204
  new Error(
190
- "Subcommand is required. Available subcommands: setup, add, link, list, show, fetch, remove, unlink, rename, remote, verify",
205
+ "Subcommand is required. Available subcommands: setup, add, link, materialize, list, show, fetch, remove, unlink, rename, remote, verify",
191
206
  ),
192
207
  )
193
208
  }
@@ -200,6 +215,7 @@ Subcommands:
200
215
  setup Plan or apply portable repository setup
201
216
  add <alias> <remote> Create a bare clone
202
217
  link <alias> <path> Link an existing Git repository
218
+ materialize <alias> Replace a linked alias with a managed bare clone
203
219
  list List repository aliases
204
220
  show <alias> Show a repository alias
205
221
  fetch <alias> Fetch and prune a repository
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir, stat } from "node:fs/promises"
3
+ import { mkdir, realpath, stat } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { RepositoryService } from "./RepositoryService"
@@ -16,6 +16,18 @@ const runGit = async (args: string[]) => {
16
16
  }
17
17
  }
18
18
 
19
+ const gitOutput = async (args: string[]) => {
20
+ const process = Bun.spawn(["git", ...args], {
21
+ stdout: "pipe",
22
+ stderr: "pipe",
23
+ })
24
+ await process.exited
25
+ if (process.exitCode !== 0) {
26
+ throw new Error(await new Response(process.stderr).text())
27
+ }
28
+ return (await new Response(process.stdout).text()).trim()
29
+ }
30
+
19
31
  const portableRemote = (name: string) =>
20
32
  `https://example.com/agency-tests/${name}.git`
21
33
 
@@ -159,6 +171,143 @@ describe("RepositoryService", () => {
159
171
  })
160
172
  })
161
173
 
174
+ test("materializes a linked alias without invalidating active worktrees", async () => {
175
+ const target = join(root, "linked-repository")
176
+ const checkout = join(root, "tasks/active/code/linked")
177
+ await mkdir(target, { recursive: true })
178
+ await runGit(["init", "--initial-branch=main", target])
179
+ await runGit(["-C", target, "config", "user.email", "test@example.com"])
180
+ await runGit(["-C", target, "config", "user.name", "Test"])
181
+ await Bun.write(join(target, "README.md"), "linked\n")
182
+ await runGit(["-C", target, "add", "README.md"])
183
+ await runGit(["-C", target, "commit", "-m", "initial"])
184
+ await setPortableOrigin(target, "materialized")
185
+ await runTestEffect(
186
+ RepositoryService.pipe(
187
+ Effect.flatMap((service) => service.link("linked", target, root)),
188
+ ),
189
+ )
190
+ await write(
191
+ root,
192
+ "tasks/active/TASK.md",
193
+ `---
194
+ ticketUrl: null
195
+ repo: linked
196
+ branch: task/active
197
+ base: main
198
+ pr: null
199
+ status: working
200
+ ---
201
+ `,
202
+ )
203
+ await mkdir(dirname(checkout), { recursive: true })
204
+ await runGit([
205
+ "-C",
206
+ target,
207
+ "worktree",
208
+ "add",
209
+ "-b",
210
+ "task/active",
211
+ checkout,
212
+ "main",
213
+ ])
214
+ await Bun.write(join(checkout, "dirty.txt"), "preserved\n")
215
+
216
+ const result = await runTestEffect(
217
+ RepositoryService.pipe(
218
+ Effect.flatMap((service) => service.materialize("linked", root)),
219
+ ),
220
+ )
221
+
222
+ expect(result.kind).toBe("bare")
223
+ expect(result.target).toBeNull()
224
+ expect(result.states).toEqual(["declared", "materialized"])
225
+ expect(await Bun.file(join(checkout, "dirty.txt")).text()).toBe(
226
+ "preserved\n",
227
+ )
228
+ expect(await gitOutput(["-C", checkout, "branch", "--show-current"])).toBe(
229
+ "task/active",
230
+ )
231
+ expect(
232
+ await gitOutput(["-C", checkout, "rev-parse", "--git-common-dir"]),
233
+ ).toBe(await realpath(join(root, "repos/linked")))
234
+ expect(
235
+ await gitOutput(["-C", target, "worktree", "list", "--porcelain"]),
236
+ ).not.toContain(checkout)
237
+ expect(await Bun.file(join(target, ".git/HEAD")).exists()).toBe(true)
238
+ expect(await Bun.file(join(root, "tasks/active/TASK.md")).text()).toContain(
239
+ "repo: linked",
240
+ )
241
+ })
242
+
243
+ test("materializes an alias linked through an existing worktree", async () => {
244
+ const primary = join(root, "primary-repository")
245
+ const linkedWorktree = join(root, "linked-worktree")
246
+ await mkdir(primary, { recursive: true })
247
+ await runGit(["init", "--initial-branch=main", primary])
248
+ await runGit(["-C", primary, "config", "user.email", "test@example.com"])
249
+ await runGit(["-C", primary, "config", "user.name", "Test"])
250
+ await Bun.write(join(primary, "README.md"), "linked worktree\n")
251
+ await runGit(["-C", primary, "add", "README.md"])
252
+ await runGit(["-C", primary, "commit", "-m", "initial"])
253
+ await setPortableOrigin(primary, "linked-worktree")
254
+ await runGit([
255
+ "-C",
256
+ primary,
257
+ "worktree",
258
+ "add",
259
+ "-b",
260
+ "linked-branch",
261
+ linkedWorktree,
262
+ "main",
263
+ ])
264
+ await runTestEffect(
265
+ RepositoryService.pipe(
266
+ Effect.flatMap((service) =>
267
+ service.link("linked", linkedWorktree, root),
268
+ ),
269
+ ),
270
+ )
271
+
272
+ await runTestEffect(
273
+ RepositoryService.pipe(
274
+ Effect.flatMap((service) => service.materialize("linked", root)),
275
+ ),
276
+ )
277
+
278
+ expect(
279
+ await gitOutput(["-C", linkedWorktree, "rev-parse", "--git-common-dir"]),
280
+ ).toBe(await realpath(join(root, "repos/linked")))
281
+ expect(
282
+ await gitOutput(["-C", primary, "worktree", "list", "--porcelain"]),
283
+ ).not.toContain(linkedWorktree)
284
+ })
285
+
286
+ test("refuses to materialize linked aliases in jj workbases", async () => {
287
+ if (!Bun.which("jj")) return
288
+ await Bun.write(
289
+ join(root, "agency.json"),
290
+ JSON.stringify({ version: 2, vcs: "jj" }),
291
+ )
292
+ const target = join(root, "linked-jj-repository")
293
+ await mkdir(target, { recursive: true })
294
+ await runGit(["init", "--initial-branch=main", target])
295
+ await setPortableOrigin(target, "linked-jj")
296
+ await runTestEffect(
297
+ RepositoryService.pipe(
298
+ Effect.flatMap((service) => service.link("linked", target, root)),
299
+ ),
300
+ )
301
+
302
+ await expect(
303
+ runTestEffect(
304
+ RepositoryService.pipe(
305
+ Effect.flatMap((service) => service.materialize("linked", root)),
306
+ ),
307
+ ),
308
+ ).rejects.toThrow("only supported for Git workbases")
309
+ })
310
+
162
311
  test("rejects invalid and duplicate aliases", async () => {
163
312
  const source = join(root, "source.git")
164
313
  await runGit(["init", "--bare", "--initial-branch=main", source])
@@ -1,7 +1,7 @@
1
1
  import { Schema, TreeFormatter } from "@effect/schema"
2
2
  import { Data, Effect, Either } from "effect"
3
3
  import { join, resolve } from "node:path"
4
- import { lstat, rename, rm } from "node:fs/promises"
4
+ import { cp, lstat, realpath, rename, rm } from "node:fs/promises"
5
5
  import { FileSystemService } from "./FileSystemService"
6
6
  import { GraphService } from "./GraphService"
7
7
  import { WorkbaseService } from "./WorkbaseService"
@@ -327,6 +327,27 @@ const replaceWithMoveStep = (
327
327
  manualRecovery: `Restore ${backup} to ${current}`,
328
328
  })
329
329
 
330
+ const runGit = (
331
+ fs: Effect.Effect.Success<typeof FileSystemService>,
332
+ args: readonly string[],
333
+ label: string,
334
+ ) =>
335
+ Effect.runPromise(
336
+ fs
337
+ .runCommand(["git", ...args], { captureOutput: true })
338
+ .pipe(
339
+ Effect.flatMap((result) =>
340
+ result.exitCode === 0
341
+ ? Effect.void
342
+ : Effect.fail(
343
+ new Error(
344
+ `${label}: ${result.stderr.trim() || result.stdout.trim()}`,
345
+ ),
346
+ ),
347
+ ),
348
+ ) as Effect.Effect<void, unknown, never>,
349
+ )
350
+
330
351
  const runTransaction = (
331
352
  state: Effect.Effect.Success<ReturnType<typeof configState>>,
332
353
  config: WorkbaseConfig,
@@ -501,6 +522,274 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
501
522
  return destination
502
523
  }),
503
524
 
525
+ materialize: (alias: string, startPath: string = process.cwd()) =>
526
+ Effect.gen(function* () {
527
+ const fs = yield* FileSystemService
528
+ const versionControl = yield* VersionControlService
529
+ const repository = yield* find(alias, startPath)
530
+ if (repository.kind !== "symlink" || !repository.target) {
531
+ return yield* new RepositoryError({
532
+ message: `Repository alias '${alias}' is not linked`,
533
+ })
534
+ }
535
+ if (repository.states.includes("invalid")) {
536
+ return yield* new RepositoryError({
537
+ message: `Repository alias '${alias}' has an invalid linked repository`,
538
+ })
539
+ }
540
+ if (repository.states.includes("remote-drifted")) {
541
+ return yield* new RepositoryError({
542
+ message: `Repository alias '${alias}' cannot be materialized while its origin differs from the portable declaration`,
543
+ })
544
+ }
545
+ if (!repository.declaredRemote) {
546
+ return yield* new RepositoryError({
547
+ message: `Repository alias '${alias}' has no portable remote declaration`,
548
+ })
549
+ }
550
+
551
+ const state = yield* configState(startPath)
552
+ const backend = yield* versionControl.forWorkbase(state.root)
553
+ if (backend.kind !== "git") {
554
+ return yield* new RepositoryError({
555
+ message: `Repository alias materialization is only supported for Git workbases`,
556
+ })
557
+ }
558
+
559
+ const source = yield* fs.realPath(repository.path)
560
+ const registered = yield* backend.listWorkspaces(repository.path)
561
+ const registeredPaths = registered
562
+ .map((workspace) => workspace.path)
563
+ .sort()
564
+ const worktrees: (typeof registered)[number][] = []
565
+ for (const workspace of registered) {
566
+ if (!(yield* fs.isDirectory(workspace.path))) {
567
+ return yield* new RepositoryError({
568
+ message: `Repository alias '${alias}' has a stale worktree registration: ${workspace.path}`,
569
+ })
570
+ }
571
+ if (
572
+ (yield* fs.inspectFile(join(workspace.path, ".git"))).kind ===
573
+ "file"
574
+ )
575
+ worktrees.push(workspace)
576
+ }
577
+
578
+ const commonDirectory = yield* fs
579
+ .runCommand(
580
+ [
581
+ "git",
582
+ "-C",
583
+ source,
584
+ "rev-parse",
585
+ "--path-format=absolute",
586
+ "--git-common-dir",
587
+ ],
588
+ { captureOutput: true },
589
+ )
590
+ .pipe(
591
+ Effect.flatMap((result) =>
592
+ result.exitCode === 0
593
+ ? Effect.succeed(result.stdout.trim())
594
+ : Effect.fail(
595
+ new RepositoryError({
596
+ message: `Failed to locate Git metadata for repository alias '${alias}'`,
597
+ }),
598
+ ),
599
+ ),
600
+ )
601
+ const sourceWorktrees = join(commonDirectory, "worktrees")
602
+ const hasWorktreeMetadata = yield* fs.isDirectory(sourceWorktrees)
603
+ if (worktrees.length > 0 && !hasWorktreeMetadata) {
604
+ return yield* new RepositoryError({
605
+ message: `Repository alias '${alias}' is missing Git metadata for its registered worktrees`,
606
+ })
607
+ }
608
+
609
+ const suffix = `${process.pid}-${Date.now()}`
610
+ const staging = join(
611
+ state.root,
612
+ "repos",
613
+ `.agency-materialize-${repository.alias}-${suffix}`,
614
+ )
615
+ const aliasBackup = join(
616
+ state.root,
617
+ "repos",
618
+ `.agency-linked-${repository.alias}-${suffix}`,
619
+ )
620
+ const metadataBackup = `${sourceWorktrees}.agency-materialize-${suffix}`
621
+ yield* fs.createDirectory(join(state.root, "repos"))
622
+ yield* backend.cloneRepository(source, staging).pipe(
623
+ Effect.catchAll((cause) =>
624
+ fs.deleteDirectory(staging).pipe(
625
+ Effect.ignore,
626
+ Effect.zipRight(
627
+ Effect.fail(
628
+ new RepositoryError({
629
+ message: `Failed to materialize repository '${alias}': ${cause instanceof Error ? cause.message : String(cause)}`,
630
+ cause,
631
+ }),
632
+ ),
633
+ ),
634
+ ),
635
+ ),
636
+ )
637
+ yield* backend
638
+ .setRemoteUrl(staging, "origin", repository.declaredRemote)
639
+ .pipe(
640
+ Effect.catchAll((cause) =>
641
+ fs
642
+ .deleteDirectory(staging)
643
+ .pipe(Effect.ignore, Effect.zipRight(Effect.fail(cause))),
644
+ ),
645
+ )
646
+ for (const workspace of worktrees) {
647
+ if (!workspace.commit) continue
648
+ const object = yield* fs.runCommand(
649
+ [
650
+ "git",
651
+ "--git-dir",
652
+ staging,
653
+ "cat-file",
654
+ "-e",
655
+ `${workspace.commit}^{commit}`,
656
+ ],
657
+ { captureOutput: true },
658
+ )
659
+ if (object.exitCode !== 0) {
660
+ yield* fs.deleteDirectory(staging).pipe(Effect.ignore)
661
+ return yield* new RepositoryError({
662
+ message: `Registered worktree commit '${workspace.commit}' is missing from the materialized repository`,
663
+ })
664
+ }
665
+ }
666
+
667
+ let metadataMoved = false
668
+ let aliasMoved = false
669
+ let cloneInstalled = false
670
+ const workspacePaths = worktrees.map((workspace) => workspace.path)
671
+ const repair = (gitDirectory: string) =>
672
+ workspacePaths.length === 0
673
+ ? Promise.resolve()
674
+ : runGit(
675
+ fs,
676
+ [
677
+ "--git-dir",
678
+ gitDirectory,
679
+ "worktree",
680
+ "repair",
681
+ ...workspacePaths,
682
+ ],
683
+ "Failed to repair Git worktrees",
684
+ )
685
+ const rollbackMigration = async () => {
686
+ const errors: unknown[] = []
687
+ if (metadataMoved) {
688
+ try {
689
+ await rename(metadataBackup, sourceWorktrees)
690
+ metadataMoved = false
691
+ await repair(commonDirectory)
692
+ } catch (error) {
693
+ errors.push(error)
694
+ }
695
+ }
696
+ if (cloneInstalled) {
697
+ try {
698
+ await rename(repository.path, staging)
699
+ cloneInstalled = false
700
+ } catch (error) {
701
+ errors.push(error)
702
+ }
703
+ }
704
+ if (aliasMoved) {
705
+ try {
706
+ await rename(aliasBackup, repository.path)
707
+ aliasMoved = false
708
+ } catch (error) {
709
+ errors.push(error)
710
+ }
711
+ }
712
+ if (errors.length > 0) throw new AggregateError(errors)
713
+ }
714
+ const migration: TransactionStep = {
715
+ label: `materialize linked repository ${repository.alias}`,
716
+ preflight: async () => {
717
+ const stats = await lstat(repository.path)
718
+ if (
719
+ !stats.isSymbolicLink() ||
720
+ (await realpath(repository.path)) !== source
721
+ )
722
+ throw new Error(
723
+ `Repository alias '${repository.alias}' changed during materialization`,
724
+ )
725
+ const current = await Effect.runPromise(
726
+ backend
727
+ .listWorkspaces(repository.path)
728
+ .pipe(
729
+ Effect.provideService(FileSystemService, fs),
730
+ ) as unknown as Effect.Effect<
731
+ readonly { readonly path: string }[],
732
+ unknown,
733
+ never
734
+ >,
735
+ )
736
+ const currentPaths = current
737
+ .map((workspace) => workspace.path)
738
+ .sort()
739
+ if (
740
+ JSON.stringify(currentPaths) !== JSON.stringify(registeredPaths)
741
+ )
742
+ throw new Error(
743
+ `Git worktree registrations changed during materialization`,
744
+ )
745
+ },
746
+ apply: async () => {
747
+ try {
748
+ if (hasWorktreeMetadata) {
749
+ await rename(sourceWorktrees, metadataBackup)
750
+ metadataMoved = true
751
+ await cp(metadataBackup, join(staging, "worktrees"), {
752
+ recursive: true,
753
+ })
754
+ }
755
+ await rename(repository.path, aliasBackup)
756
+ aliasMoved = true
757
+ await rename(staging, repository.path)
758
+ cloneInstalled = true
759
+ await repair(repository.path)
760
+ } catch (cause) {
761
+ try {
762
+ await rollbackMigration()
763
+ } catch (rollbackCause) {
764
+ throw new Error(
765
+ `Repository materialization failed and rollback requires manual recovery: restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
766
+ { cause: new AggregateError([cause, rollbackCause]) },
767
+ )
768
+ }
769
+ throw cause
770
+ }
771
+ },
772
+ rollback: rollbackMigration,
773
+ finalize: async () => {
774
+ await rm(aliasBackup, { recursive: true, force: true })
775
+ await rm(metadataBackup, { recursive: true, force: true })
776
+ },
777
+ manualRecovery: `Restore ${aliasBackup} to ${repository.path} and ${metadataBackup} to ${sourceWorktrees}`,
778
+ }
779
+
780
+ yield* runLifecycleTransaction({
781
+ root: state.root,
782
+ preconditions: [{ path: state.path, revision: state.revision }],
783
+ steps: [migration],
784
+ }).pipe(
785
+ Effect.mapError(
786
+ (cause) => new RepositoryError({ message: cause.message, cause }),
787
+ ),
788
+ Effect.ensuring(fs.deleteDirectory(staging).pipe(Effect.ignore)),
789
+ )
790
+ return yield* find(alias, startPath)
791
+ }),
792
+
504
793
  list: (startPath: string = process.cwd()) =>
505
794
  Effect.gen(function* () {
506
795
  const fs = yield* FileSystemService
@@ -589,7 +589,9 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
589
589
  const path = join(root, "tasks", validId, "TASK.md")
590
590
  if (!(yield* fs.exists(path))) {
591
591
  return yield* new TaskError({
592
- message: `Task '${validId}' does not exist`,
592
+ message: (yield* fs.exists(archivedTaskDirectory(root, validId)))
593
+ ? `Task '${validId}' is already archived`
594
+ : `Task '${validId}' does not exist`,
593
595
  })
594
596
  }
595
597
  const content = yield* fs.readFile(path)