@markjaquith/agency 2.23.0 → 2.25.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.
@@ -1,52 +1,541 @@
1
- import { Data, Effect } from "effect"
2
- import { dirname, join } from "node:path"
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either, Layer } from "effect"
3
+ import { lstat, mkdir, open, rename, rm } from "node:fs/promises"
4
+ import { dirname, join, relative } from "node:path"
3
5
  import { EpicService, type EpicRecord } from "./EpicService"
4
6
  import { FileSystemService } from "./FileSystemService"
5
- import { PhaseService } from "./PhaseService"
7
+ import { PhaseService, type PhaseRecord } from "./PhaseService"
6
8
  import { TaskService } from "./TaskService"
7
9
  import { WorkbaseService } from "./WorkbaseService"
8
- import { WorktreeService } from "./WorktreeService"
10
+ import {
11
+ WorktreeService,
12
+ type WorktreeRemovalSnapshot,
13
+ } from "./WorktreeService"
9
14
  import {
10
15
  formatMarkdownDocument,
11
16
  parseFrontmatter,
12
17
  } from "../workbase/frontmatter"
13
- import type { TaskFrontmatter as TaskData } from "../workbase/schemas"
18
+ import {
19
+ EpicFrontmatter,
20
+ Dependency,
21
+ PhaseFrontmatter,
22
+ TaskFrontmatter,
23
+ type Dependency as DependencyData,
24
+ type EpicFrontmatter as EpicData,
25
+ type PhaseFrontmatter as PhaseData,
26
+ type TaskFrontmatter as TaskData,
27
+ } from "../workbase/schemas"
28
+ import { validateDependencies } from "../workbase/dependency-graph"
29
+ import {
30
+ archivedEpicDirectory,
31
+ archivedPhaseDirectory,
32
+ archivedTaskDirectory,
33
+ lifecycleManifestPath,
34
+ } from "../workbase/archive"
35
+ import {
36
+ directoryMoveStep,
37
+ documentWriteStep,
38
+ runLifecycleTransaction,
39
+ type TransactionStep,
40
+ } from "./LifecycleTransaction"
41
+ import { withWorktreeLocks } from "./WorktreeLock"
14
42
 
15
43
  class ArchiveError extends Data.TaggedError("ArchiveError")<{
16
44
  readonly message: string
45
+ readonly cause?: unknown
17
46
  }> {}
18
47
 
19
- interface ArchiveResult {
20
- readonly kind: "epic" | "task" | "phase"
48
+ export type ArchiveKind = "epic" | "task" | "phase"
49
+
50
+ const LifecycleEventSchema = Schema.Struct({
51
+ operation: Schema.Literal("archive", "restore"),
52
+ at: Schema.String,
53
+ from: Schema.String,
54
+ to: Schema.String,
55
+ })
56
+
57
+ const LifecycleManifestSchema = Schema.Struct({
58
+ version: Schema.Literal(1),
59
+ kind: Schema.Literal("epic", "task", "phase"),
60
+ id: Schema.String,
61
+ taskId: Schema.optional(Schema.String),
62
+ parent: Schema.optional(
63
+ Schema.Struct({
64
+ kind: Schema.Literal("epic", "task"),
65
+ id: Schema.String,
66
+ declaration: Dependency,
67
+ }),
68
+ ),
69
+ history: Schema.Array(LifecycleEventSchema),
70
+ })
71
+
72
+ type LifecycleEvent = Schema.Schema.Type<typeof LifecycleEventSchema>
73
+ type LifecycleManifest = Schema.Schema.Type<typeof LifecycleManifestSchema>
74
+
75
+ interface ArchivedRecord {
76
+ readonly kind: ArchiveKind
21
77
  readonly id: string
22
78
  readonly taskId?: string
23
79
  readonly path: string
24
- readonly archivedPaths: readonly string[]
80
+ readonly documentPath: string
81
+ readonly content: string
82
+ readonly data: EpicData | TaskData | PhaseData
83
+ readonly provenance?: LifecycleManifest
84
+ }
85
+
86
+ interface LifecycleResult {
87
+ readonly operation: "archive" | "restore"
88
+ readonly kind: ArchiveKind
89
+ readonly id: string
90
+ readonly taskId?: string
91
+ readonly path: string
92
+ readonly affectedPaths: readonly string[]
25
93
  readonly removedWorktrees: readonly string[]
94
+ readonly dryRun: boolean
95
+ readonly at: string
96
+ }
97
+
98
+ interface LifecycleOptions {
99
+ readonly dryRun?: boolean
100
+ }
101
+
102
+ export interface ArchiveFilters {
103
+ readonly kinds?: readonly string[]
104
+ readonly statuses?: readonly string[]
105
+ readonly repositories?: readonly string[]
26
106
  }
27
107
 
28
108
  interface TaskRecord {
29
109
  readonly id: string
30
110
  readonly path: string
31
111
  readonly content: string
112
+ readonly revision: string
32
113
  readonly data: TaskData
33
114
  }
34
115
 
35
- const rejectExistingDestination = (path: string) =>
116
+ interface Move {
117
+ readonly from: string
118
+ readonly to: string
119
+ }
120
+
121
+ interface Write {
122
+ readonly path: string
123
+ readonly content: string
124
+ }
125
+
126
+ const decode = <S extends Schema.Schema.AnyNoContext>(
127
+ schema: S,
128
+ input: unknown,
129
+ label: string,
130
+ ) => {
131
+ const result = Schema.decodeUnknownEither(schema, {
132
+ errors: "all",
133
+ onExcessProperty: "error",
134
+ })(input)
135
+ return Either.isLeft(result)
136
+ ? Effect.fail(
137
+ new ArchiveError({
138
+ message: `Invalid archived ${label}: ${TreeFormatter.formatErrorSync(result.left)}`,
139
+ }),
140
+ )
141
+ : Effect.succeed(result.right)
142
+ }
143
+
144
+ const readManifest = (directory: string) =>
145
+ Effect.gen(function* () {
146
+ const fs = yield* FileSystemService
147
+ const path = lifecycleManifestPath(directory)
148
+ if (!(yield* fs.exists(path))) return undefined
149
+ const content = yield* fs.readFile(path)
150
+ const input = yield* Effect.try({
151
+ try: () => JSON.parse(content) as unknown,
152
+ catch: (cause) =>
153
+ new ArchiveError({
154
+ message: `Invalid lifecycle provenance: ${path}`,
155
+ cause,
156
+ }),
157
+ })
158
+ const decoded = Schema.decodeUnknownEither(LifecycleManifestSchema, {
159
+ errors: "all",
160
+ onExcessProperty: "error",
161
+ })(input)
162
+ if (Either.isLeft(decoded)) {
163
+ return yield* new ArchiveError({
164
+ message: `Invalid lifecycle provenance ${path}: ${TreeFormatter.formatErrorSync(decoded.left)}`,
165
+ })
166
+ }
167
+ return decoded.right
168
+ })
169
+
170
+ const manifestFor = (
171
+ existing: LifecycleManifest | undefined,
172
+ entity: Omit<LifecycleManifest, "version" | "history">,
173
+ event: LifecycleEvent,
174
+ ): LifecycleManifest => ({
175
+ version: 1,
176
+ ...entity,
177
+ history: [...(existing?.history ?? []), event],
178
+ })
179
+
180
+ const json = (value: unknown) => JSON.stringify(value, null, 2) + "\n"
181
+
182
+ const withLifecycleLock = <A, E, R>(
183
+ root: string,
184
+ operation: Effect.Effect<A, E, R>,
185
+ ) => {
186
+ const lockPath = join(root, ".agency-archive.lock")
187
+ return Effect.acquireUseRelease(
188
+ Effect.tryPromise({
189
+ try: () => open(lockPath, "wx"),
190
+ catch: (cause) =>
191
+ new ArchiveError({
192
+ message:
193
+ "Another archive or restore operation is in progress; wait and retry",
194
+ cause,
195
+ }),
196
+ }),
197
+ () => operation,
198
+ (lock) =>
199
+ Effect.promise(async () => {
200
+ await lock.close().catch(() => undefined)
201
+ await rm(lockPath, { force: true }).catch(() => undefined)
202
+ }),
203
+ )
204
+ }
205
+
206
+ const applyMutation = (moves: readonly Move[], writes: readonly Write[]) =>
207
+ Effect.tryPromise({
208
+ try: async () => {
209
+ const completedMoves: Move[] = []
210
+ const completedWrites: {
211
+ path: string
212
+ existed: boolean
213
+ content?: string
214
+ }[] = []
215
+ try {
216
+ for (const move of moves) {
217
+ await mkdir(dirname(move.to), { recursive: true })
218
+ await rename(move.from, move.to)
219
+ completedMoves.push(move)
220
+ }
221
+ for (const write of writes) {
222
+ const file = Bun.file(write.path)
223
+ const existed = await file.exists()
224
+ completedWrites.push({
225
+ path: write.path,
226
+ existed,
227
+ ...(existed ? { content: await file.text() } : {}),
228
+ })
229
+ await Bun.write(write.path, write.content)
230
+ }
231
+ } catch (cause) {
232
+ let rollbackCause: unknown
233
+ for (const write of [...completedWrites].reverse()) {
234
+ try {
235
+ if (write.existed) await Bun.write(write.path, write.content!)
236
+ else await rm(write.path, { force: true })
237
+ } catch (error) {
238
+ rollbackCause ??= error
239
+ }
240
+ }
241
+ for (const move of [...completedMoves].reverse()) {
242
+ try {
243
+ await rename(move.to, move.from)
244
+ } catch (error) {
245
+ rollbackCause ??= error
246
+ }
247
+ }
248
+ if (rollbackCause) {
249
+ throw new ArchiveError({
250
+ message:
251
+ "Archive lifecycle rollback failed; manual recovery is required",
252
+ cause: new AggregateError([cause, rollbackCause]),
253
+ })
254
+ }
255
+ throw cause
256
+ }
257
+ },
258
+ catch: (cause) =>
259
+ cause instanceof ArchiveError
260
+ ? cause
261
+ : new ArchiveError({
262
+ message:
263
+ "Archive lifecycle operation failed; changes were rolled back",
264
+ cause,
265
+ }),
266
+ })
267
+
268
+ const WorktreeLayer = Layer.mergeAll(
269
+ FileSystemService.Default,
270
+ WorkbaseService.Default,
271
+ TaskService.Default,
272
+ PhaseService.Default,
273
+ WorktreeService.Default,
274
+ )
275
+
276
+ const runWorktreeEffect = <A, E>(effect: Effect.Effect<A, E, any>) =>
277
+ Effect.runPromise(
278
+ effect.pipe(Effect.provide(WorktreeLayer)) as Effect.Effect<A, E, never>,
279
+ )
280
+
281
+ const runGit = async (args: readonly string[]) => {
282
+ const process = Bun.spawn([...args], { stdout: "pipe", stderr: "pipe" })
283
+ const [exitCode, stdout, stderr] = await Promise.all([
284
+ process.exited,
285
+ new Response(process.stdout).text(),
286
+ new Response(process.stderr).text(),
287
+ ])
288
+ if (exitCode !== 0) throw new Error(stderr.trim() || args.join(" "))
289
+ return stdout.trim()
290
+ }
291
+
292
+ const restoreWorktreeSnapshots = async (
293
+ snapshots: readonly WorktreeRemovalSnapshot[],
294
+ ) => {
295
+ for (const snapshot of snapshots) {
296
+ try {
297
+ await lstat(snapshot.path)
298
+ continue
299
+ } catch {}
300
+ await mkdir(dirname(snapshot.path), { recursive: true })
301
+ await runGit(
302
+ snapshot.branch
303
+ ? [
304
+ "git",
305
+ "-C",
306
+ snapshot.repositoryPath,
307
+ "worktree",
308
+ "add",
309
+ snapshot.path,
310
+ snapshot.branch,
311
+ ]
312
+ : [
313
+ "git",
314
+ "-C",
315
+ snapshot.repositoryPath,
316
+ "worktree",
317
+ "add",
318
+ "--detach",
319
+ snapshot.path,
320
+ snapshot.head,
321
+ ],
322
+ )
323
+ }
324
+ }
325
+
326
+ const rejectExistingDestination = (
327
+ path: string,
328
+ operation: "Archive" | "Restore",
329
+ ) =>
36
330
  Effect.gen(function* () {
37
331
  const fs = yield* FileSystemService
38
332
  if (yield* fs.exists(path)) {
39
333
  return yield* new ArchiveError({
40
- message: `Archive destination already exists: ${path}`,
334
+ message: `${operation} destination already exists: ${path}`,
41
335
  })
42
336
  }
43
337
  })
44
338
 
339
+ const event = (
340
+ root: string,
341
+ operation: LifecycleEvent["operation"],
342
+ at: string,
343
+ from: string,
344
+ to: string,
345
+ ): LifecycleEvent => ({
346
+ operation,
347
+ at,
348
+ from: relative(root, from),
349
+ to: relative(root, to),
350
+ })
351
+
352
+ const declarationContent = (
353
+ record: { readonly content: string; readonly path: string },
354
+ data: EpicData | TaskData,
355
+ ) =>
356
+ parseFrontmatter(record.content, record.path).pipe(
357
+ Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
358
+ )
359
+
360
+ const repositoriesFor = (record: ArchivedRecord) => {
361
+ if (record.kind === "epic") {
362
+ return (record.data as EpicData).repos.map((reference) => reference.repo)
363
+ }
364
+ if ("repo" in record.data) {
365
+ return [
366
+ record.data.repo,
367
+ ...(record.data.repos ?? []).map((reference) => reference.repo),
368
+ ]
369
+ }
370
+ return []
371
+ }
372
+
373
+ const statusFor = (record: ArchivedRecord) =>
374
+ "status" in record.data ? record.data.status : undefined
375
+
45
376
  export class ArchiveService extends Effect.Service<ArchiveService>()(
46
377
  "ArchiveService",
47
378
  {
48
379
  sync: () => ({
49
- archiveEpic: (id: string, startPath: string = process.cwd()) =>
380
+ list: (filters: ArchiveFilters = {}, startPath: string = process.cwd()) =>
381
+ Effect.gen(function* () {
382
+ const fs = yield* FileSystemService
383
+ const workbase = yield* WorkbaseService
384
+ const root = yield* workbase.discover(startPath)
385
+ const kinds = filters.kinds?.length
386
+ ? new Set(filters.kinds)
387
+ : new Set<ArchiveKind>(["epic", "task", "phase"])
388
+ for (const kind of kinds) {
389
+ if (
390
+ !(["epic", "task", "phase"] as const).includes(
391
+ kind as ArchiveKind,
392
+ )
393
+ ) {
394
+ return yield* new ArchiveError({
395
+ message: `Unknown archive kind '${kind}'`,
396
+ })
397
+ }
398
+ }
399
+
400
+ const records: ArchivedRecord[] = []
401
+ const readRecord = (
402
+ kind: ArchiveKind,
403
+ id: string,
404
+ directory: string,
405
+ documentName: string,
406
+ schema: Schema.Schema.AnyNoContext,
407
+ taskId?: string,
408
+ ) =>
409
+ Effect.gen(function* () {
410
+ const documentPath = join(directory, documentName)
411
+ if (!(yield* fs.exists(documentPath))) return
412
+ const content = yield* fs.readFile(documentPath)
413
+ const parsed = yield* parseFrontmatter(content, documentPath)
414
+ const data = yield* decode(schema, parsed.data, `${kind} '${id}'`)
415
+ const provenance = yield* readManifest(directory)
416
+ if (
417
+ provenance &&
418
+ (provenance.kind !== kind ||
419
+ provenance.id !== id ||
420
+ (kind === "phase" && provenance.taskId !== taskId))
421
+ ) {
422
+ return yield* new ArchiveError({
423
+ message: `Lifecycle provenance does not match archived ${kind} '${id}'`,
424
+ })
425
+ }
426
+ records.push({
427
+ kind,
428
+ id,
429
+ ...(taskId ? { taskId } : {}),
430
+ path: directory,
431
+ documentPath,
432
+ content,
433
+ data: data as EpicData | TaskData | PhaseData,
434
+ ...(provenance ? { provenance } : {}),
435
+ })
436
+ })
437
+
438
+ if (kinds.has("epic")) {
439
+ const directory = join(root, "archive", "epics")
440
+ if (yield* fs.isDirectory(directory)) {
441
+ for (const entry of yield* fs.readDirectory(directory)) {
442
+ if (entry.isDirectory)
443
+ yield* readRecord(
444
+ "epic",
445
+ entry.name,
446
+ join(directory, entry.name),
447
+ "EPIC.md",
448
+ EpicFrontmatter,
449
+ )
450
+ }
451
+ }
452
+ }
453
+
454
+ const tasksDirectory = join(root, "archive", "tasks")
455
+ if (yield* fs.isDirectory(tasksDirectory)) {
456
+ for (const taskEntry of yield* fs.readDirectory(tasksDirectory)) {
457
+ if (!taskEntry.isDirectory) continue
458
+ const taskDirectory = join(tasksDirectory, taskEntry.name)
459
+ if (kinds.has("task")) {
460
+ yield* readRecord(
461
+ "task",
462
+ taskEntry.name,
463
+ taskDirectory,
464
+ "TASK.md",
465
+ TaskFrontmatter,
466
+ )
467
+ }
468
+ if (!kinds.has("phase")) continue
469
+ const phasesDirectory = join(taskDirectory, "phases")
470
+ if (!(yield* fs.isDirectory(phasesDirectory))) continue
471
+ for (const phaseEntry of yield* fs.readDirectory(
472
+ phasesDirectory,
473
+ )) {
474
+ if (phaseEntry.isDirectory)
475
+ yield* readRecord(
476
+ "phase",
477
+ phaseEntry.name,
478
+ join(phasesDirectory, phaseEntry.name),
479
+ "PHASE.md",
480
+ PhaseFrontmatter,
481
+ taskEntry.name,
482
+ )
483
+ }
484
+ }
485
+ }
486
+
487
+ return records
488
+ .filter(
489
+ (record) =>
490
+ !filters.statuses?.length ||
491
+ filters.statuses.includes(statusFor(record) ?? ""),
492
+ )
493
+ .filter(
494
+ (record) =>
495
+ !filters.repositories?.length ||
496
+ filters.repositories.some((repository) =>
497
+ repositoriesFor(record).includes(repository),
498
+ ),
499
+ )
500
+ .sort((a, b) =>
501
+ `${a.kind}:${a.taskId ?? ""}:${a.id}`.localeCompare(
502
+ `${b.kind}:${b.taskId ?? ""}:${b.id}`,
503
+ ),
504
+ )
505
+ }),
506
+
507
+ show: (
508
+ kind: ArchiveKind,
509
+ id: string,
510
+ taskId: string | undefined,
511
+ startPath: string = process.cwd(),
512
+ ) =>
513
+ Effect.gen(function* () {
514
+ const service = yield* ArchiveService
515
+ const record = (yield* service.list(
516
+ { kinds: [kind] },
517
+ startPath,
518
+ )).find(
519
+ (candidate) =>
520
+ candidate.id === id &&
521
+ (kind !== "phase" || candidate.taskId === taskId),
522
+ )
523
+ if (!record) {
524
+ return yield* new ArchiveError({
525
+ message:
526
+ kind === "phase"
527
+ ? `Archived phase '${id}' does not exist on task '${taskId}'`
528
+ : `Archived ${kind} '${id}' does not exist`,
529
+ })
530
+ }
531
+ return record
532
+ }),
533
+
534
+ archiveEpic: (
535
+ id: string,
536
+ startPath: string = process.cwd(),
537
+ options: LifecycleOptions = {},
538
+ ) =>
50
539
  Effect.gen(function* () {
51
540
  const fs = yield* FileSystemService
52
541
  const workbase = yield* WorkbaseService
@@ -65,51 +554,168 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
65
554
  }
66
555
  taskRecords.push(task)
67
556
  }
557
+ const declared = new Set(epic.data.tasks.map((task) => task.id))
558
+ const unlisted = (yield* tasks.list(root)).find(
559
+ (task) => task.data.epic === id && !declared.has(task.id),
560
+ )
561
+ if (unlisted) {
562
+ return yield* new ArchiveError({
563
+ message: `Task '${unlisted.id}' references epic '${id}' but is not listed by it`,
564
+ })
565
+ }
68
566
 
69
- const epicDestination = join(root, "archive", "epics", id)
70
- yield* rejectExistingDestination(epicDestination)
567
+ const destination = archivedEpicDirectory(root, id)
568
+ yield* rejectExistingDestination(destination, "Archive")
71
569
  for (const task of taskRecords) {
72
570
  yield* rejectExistingDestination(
73
- join(root, "archive", "tasks", task.id),
571
+ archivedTaskDirectory(root, task.id),
572
+ "Archive",
74
573
  )
75
574
  }
76
575
 
77
- const removedWorktrees: string[] = []
576
+ const executionUnits: { taskId: string; phaseId?: string }[] = []
577
+ const phaseRecords: PhaseRecord[] = []
78
578
  for (const task of taskRecords) {
579
+ if ("claim" in task.data && task.data.claim?.state === "active") {
580
+ return yield* new ArchiveError({
581
+ message: `Task '${task.id}' has an active claim; release or finish it before archiving`,
582
+ })
583
+ }
79
584
  if ("phases" in task.data) {
80
585
  for (const phase of task.data.phases) {
81
- removedWorktrees.push(
82
- ...(yield* worktrees.remove(task.id, phase.id, root)),
586
+ const record = yield* (yield* PhaseService).show(
587
+ task.id,
588
+ phase.id,
589
+ root,
83
590
  )
591
+ if (record.data.claim?.state === "active") {
592
+ return yield* new ArchiveError({
593
+ message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
594
+ })
595
+ }
596
+ phaseRecords.push(record)
597
+ executionUnits.push({ taskId: task.id, phaseId: phase.id })
84
598
  }
85
599
  } else {
86
- removedWorktrees.push(
87
- ...(yield* worktrees.remove(task.id, undefined, root)),
88
- )
600
+ executionUnits.push({ taskId: task.id })
89
601
  }
90
602
  }
603
+ const removedWorktrees: string[] = []
604
+ for (const unit of executionUnits) {
605
+ removedWorktrees.push(
606
+ ...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
607
+ dryRun: true,
608
+ })),
609
+ )
610
+ }
91
611
 
92
- const archivedPaths: string[] = []
612
+ const at = new Date().toISOString()
613
+ const moves: Move[] = taskRecords.map((task) => ({
614
+ from: dirname(task.path),
615
+ to: archivedTaskDirectory(root, task.id),
616
+ }))
617
+ moves.push({ from: dirname(epic.path), to: destination })
618
+ const writes: (Write & { create?: boolean })[] = []
93
619
  for (const task of taskRecords) {
94
- const destination = join(root, "archive", "tasks", task.id)
95
- yield* fs.createDirectory(dirname(destination))
96
- yield* fs.moveDirectory(dirname(task.path), destination)
97
- archivedPaths.push(destination)
620
+ const target = archivedTaskDirectory(root, task.id)
621
+ const manifestPath = lifecycleManifestPath(dirname(task.path))
622
+ const declaration = epic.data.tasks.find(
623
+ (child) => child.id === task.id,
624
+ )!
625
+ writes.push({
626
+ path: manifestPath,
627
+ create: !(yield* fs.exists(manifestPath)),
628
+ content: json(
629
+ manifestFor(
630
+ yield* readManifest(dirname(task.path)),
631
+ {
632
+ kind: "task",
633
+ id: task.id,
634
+ parent: { kind: "epic", id, declaration },
635
+ },
636
+ event(root, "archive", at, dirname(task.path), target),
637
+ ),
638
+ ),
639
+ })
640
+ }
641
+ const epicManifestPath = lifecycleManifestPath(dirname(epic.path))
642
+ writes.push({
643
+ path: epicManifestPath,
644
+ create: !(yield* fs.exists(epicManifestPath)),
645
+ content: json(
646
+ manifestFor(
647
+ yield* readManifest(dirname(epic.path)),
648
+ { kind: "epic", id },
649
+ event(root, "archive", at, dirname(epic.path), destination),
650
+ ),
651
+ ),
652
+ })
653
+ if (!options.dryRun) {
654
+ const snapshots: WorktreeRemovalSnapshot[] = []
655
+ const steps: TransactionStep[] = [
656
+ documentWriteStep(root, writes),
657
+ {
658
+ label: `remove worktrees for epic ${id}`,
659
+ apply: async () => {
660
+ try {
661
+ for (const unit of executionUnits)
662
+ await runWorktreeEffect(
663
+ worktrees.remove(unit.taskId, unit.phaseId, root, {
664
+ snapshots,
665
+ lockHeld: true,
666
+ }),
667
+ )
668
+ } catch (cause) {
669
+ await restoreWorktreeSnapshots(snapshots)
670
+ throw cause
671
+ }
672
+ },
673
+ rollback: () => restoreWorktreeSnapshots(snapshots),
674
+ manualRecovery: `Run agency work prepare for each execution unit in epic '${id}'`,
675
+ },
676
+ ]
677
+ for (const move of moves)
678
+ steps.push(directoryMoveStep(root, move.from, move.to))
679
+ yield* withLifecycleLock(
680
+ root,
681
+ withWorktreeLocks(
682
+ root,
683
+ executionUnits,
684
+ runLifecycleTransaction({
685
+ root,
686
+ preconditions: [
687
+ { path: epic.path, revision: epic.revision },
688
+ ...taskRecords.map((task) => ({
689
+ path: task.path,
690
+ revision: task.revision,
691
+ })),
692
+ ...phaseRecords.map((phase) => ({
693
+ path: phase.path,
694
+ revision: phase.revision,
695
+ })),
696
+ ],
697
+ steps,
698
+ }),
699
+ ),
700
+ )
98
701
  }
99
- yield* fs.createDirectory(dirname(epicDestination))
100
- yield* fs.moveDirectory(dirname(epic.path), epicDestination)
101
- archivedPaths.push(epicDestination)
102
-
103
702
  return {
703
+ operation: "archive",
104
704
  kind: "epic",
105
705
  id,
106
- path: epicDestination,
107
- archivedPaths,
706
+ path: destination,
707
+ affectedPaths: moves.map((move) => move.to),
108
708
  removedWorktrees,
109
- } satisfies ArchiveResult
709
+ dryRun: options.dryRun === true,
710
+ at,
711
+ } satisfies LifecycleResult
110
712
  }),
111
713
 
112
- archiveTask: (id: string, startPath: string = process.cwd()) =>
714
+ archiveTask: (
715
+ id: string,
716
+ startPath: string = process.cwd(),
717
+ options: LifecycleOptions = {},
718
+ ) =>
113
719
  Effect.gen(function* () {
114
720
  const fs = yield* FileSystemService
115
721
  const workbase = yield* WorkbaseService
@@ -118,12 +724,23 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
118
724
  const worktrees = yield* WorktreeService
119
725
  const root = yield* workbase.discover(startPath)
120
726
  const task = yield* tasks.show(id, root)
121
- const destination = join(root, "archive", "tasks", id)
122
- yield* rejectExistingDestination(destination)
123
-
727
+ if ("claim" in task.data && task.data.claim?.state === "active") {
728
+ return yield* new ArchiveError({
729
+ message: `Task '${id}' has an active claim; release or finish it before archiving`,
730
+ })
731
+ }
732
+ const destination = archivedTaskDirectory(root, id)
733
+ yield* rejectExistingDestination(destination, "Archive")
124
734
  let parentEpic: EpicRecord | undefined
735
+ let declaration: DependencyData | undefined
125
736
  if (task.data.epic) {
126
737
  parentEpic = yield* epics.show(task.data.epic, root)
738
+ declaration = parentEpic.data.tasks.find((child) => child.id === id)
739
+ if (!declaration) {
740
+ return yield* new ArchiveError({
741
+ message: `Epic '${task.data.epic}' does not declare task '${id}'`,
742
+ })
743
+ }
127
744
  const dependent = parentEpic.data.tasks.find((child) =>
128
745
  child.dependsOn?.includes(id),
129
746
  )
@@ -134,53 +751,137 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
134
751
  }
135
752
  }
136
753
 
137
- const removedWorktrees: string[] = []
754
+ const executionUnits: { taskId: string; phaseId?: string }[] = []
755
+ const phaseRecords: PhaseRecord[] = []
138
756
  if ("phases" in task.data) {
139
757
  for (const phase of task.data.phases) {
140
- removedWorktrees.push(
141
- ...(yield* worktrees.remove(id, phase.id, root)),
758
+ const record = yield* (yield* PhaseService).show(
759
+ id,
760
+ phase.id,
761
+ root,
142
762
  )
763
+ if (record.data.claim?.state === "active") {
764
+ return yield* new ArchiveError({
765
+ message: `Phase '${phase.id}' has an active claim; release or finish it before archiving`,
766
+ })
767
+ }
768
+ phaseRecords.push(record)
769
+ executionUnits.push({ taskId: id, phaseId: phase.id })
143
770
  }
144
771
  } else {
772
+ executionUnits.push({ taskId: id })
773
+ }
774
+ const removedWorktrees: string[] = []
775
+ for (const unit of executionUnits)
145
776
  removedWorktrees.push(
146
- ...(yield* worktrees.remove(id, undefined, root)),
777
+ ...(yield* worktrees.remove(unit.taskId, unit.phaseId, root, {
778
+ dryRun: true,
779
+ })),
147
780
  )
148
- }
149
-
781
+ const at = new Date().toISOString()
782
+ const writes: (Write & { create?: boolean })[] = []
150
783
  if (parentEpic) {
151
- const parsed = yield* parseFrontmatter(
152
- parentEpic.content,
153
- parentEpic.path,
154
- )
155
- yield* fs.writeFile(
156
- parentEpic.path,
157
- formatMarkdownDocument(
784
+ writes.push({
785
+ path: parentEpic.path,
786
+ content: yield* declarationContent(parentEpic, {
787
+ ...parentEpic.data,
788
+ tasks: parentEpic.data.tasks.filter((child) => child.id !== id),
789
+ }),
790
+ })
791
+ }
792
+ const manifestPath = lifecycleManifestPath(dirname(task.path))
793
+ writes.push({
794
+ path: manifestPath,
795
+ create: !(yield* fs.exists(manifestPath)),
796
+ content: json(
797
+ manifestFor(
798
+ yield* readManifest(dirname(task.path)),
158
799
  {
159
- ...parentEpic.data,
160
- tasks: parentEpic.data.tasks.filter(
161
- (child) => child.id !== id,
162
- ),
800
+ kind: "task",
801
+ id,
802
+ ...(task.data.epic && declaration
803
+ ? {
804
+ parent: {
805
+ kind: "epic" as const,
806
+ id: task.data.epic,
807
+ declaration,
808
+ },
809
+ }
810
+ : {}),
811
+ },
812
+ event(root, "archive", at, dirname(task.path), destination),
813
+ ),
814
+ ),
815
+ })
816
+ if (!options.dryRun) {
817
+ const snapshots: WorktreeRemovalSnapshot[] = []
818
+ const steps: TransactionStep[] = [
819
+ documentWriteStep(root, writes),
820
+ {
821
+ label: `remove worktrees for task ${id}`,
822
+ apply: async () => {
823
+ try {
824
+ for (const unit of executionUnits)
825
+ await runWorktreeEffect(
826
+ worktrees.remove(unit.taskId, unit.phaseId, root, {
827
+ snapshots,
828
+ lockHeld: true,
829
+ }),
830
+ )
831
+ } catch (cause) {
832
+ await restoreWorktreeSnapshots(snapshots)
833
+ throw cause
834
+ }
163
835
  },
164
- parsed.body,
836
+ rollback: () => restoreWorktreeSnapshots(snapshots),
837
+ manualRecovery: `Run agency work prepare for task '${id}'`,
838
+ },
839
+ directoryMoveStep(root, dirname(task.path), destination),
840
+ ]
841
+ yield* withLifecycleLock(
842
+ root,
843
+ withWorktreeLocks(
844
+ root,
845
+ executionUnits,
846
+ runLifecycleTransaction({
847
+ root,
848
+ preconditions: [
849
+ { path: task.path, revision: task.revision },
850
+ ...(parentEpic
851
+ ? [
852
+ {
853
+ path: parentEpic.path,
854
+ revision: parentEpic.revision,
855
+ },
856
+ ]
857
+ : []),
858
+ ...phaseRecords.map((phase) => ({
859
+ path: phase.path,
860
+ revision: phase.revision,
861
+ })),
862
+ ],
863
+ steps,
864
+ }),
165
865
  ),
166
866
  )
167
867
  }
168
-
169
- yield* fs.createDirectory(dirname(destination))
170
- yield* fs.moveDirectory(dirname(task.path), destination)
171
868
  return {
869
+ operation: "archive",
172
870
  kind: "task",
173
871
  id,
174
872
  path: destination,
175
- archivedPaths: [destination],
873
+ affectedPaths: [destination],
176
874
  removedWorktrees,
177
- } satisfies ArchiveResult
875
+ dryRun: options.dryRun === true,
876
+ at,
877
+ } satisfies LifecycleResult
178
878
  }),
179
879
 
180
880
  archivePhase: (
181
881
  taskId: string,
182
882
  id: string,
183
883
  startPath: string = process.cwd(),
884
+ options: LifecycleOptions = {},
184
885
  ) =>
185
886
  Effect.gen(function* () {
186
887
  const fs = yield* FileSystemService
@@ -196,6 +897,14 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
196
897
  })
197
898
  }
198
899
  const phase = yield* phases.show(taskId, id, root)
900
+ const declaration = task.data.phases.find(
901
+ (candidate) => candidate.id === id,
902
+ )!
903
+ if (phase.data.claim?.state === "active") {
904
+ return yield* new ArchiveError({
905
+ message: `Phase '${id}' has an active claim; release or finish it before archiving`,
906
+ })
907
+ }
199
908
  const dependent = task.data.phases.find((candidate) =>
200
909
  candidate.dependsOn?.includes(id),
201
910
  )
@@ -204,42 +913,382 @@ export class ArchiveService extends Effect.Service<ArchiveService>()(
204
913
  message: `Cannot archive phase '${id}'; phase '${dependent.id}' depends on it`,
205
914
  })
206
915
  }
207
-
208
- const destination = join(
209
- root,
210
- "archive",
211
- "tasks",
212
- taskId,
213
- "phases",
916
+ const destination = archivedPhaseDirectory(root, taskId, id)
917
+ yield* rejectExistingDestination(destination, "Archive")
918
+ const removedWorktrees = yield* worktrees.remove(taskId, id, root, {
919
+ dryRun: true,
920
+ })
921
+ const at = new Date().toISOString()
922
+ const content = yield* declarationContent(task, {
923
+ ...task.data,
924
+ phases: task.data.phases.filter((candidate) => candidate.id !== id),
925
+ })
926
+ const manifestPath = lifecycleManifestPath(dirname(phase.path))
927
+ const writes: (Write & { create?: boolean })[] = [
928
+ { path: task.path, content },
929
+ {
930
+ path: manifestPath,
931
+ create: !(yield* fs.exists(manifestPath)),
932
+ content: json(
933
+ manifestFor(
934
+ yield* readManifest(dirname(phase.path)),
935
+ {
936
+ kind: "phase",
937
+ id,
938
+ taskId,
939
+ parent: { kind: "task", id: taskId, declaration },
940
+ },
941
+ event(root, "archive", at, dirname(phase.path), destination),
942
+ ),
943
+ ),
944
+ },
945
+ ]
946
+ if (!options.dryRun) {
947
+ const snapshots: WorktreeRemovalSnapshot[] = []
948
+ yield* withLifecycleLock(
949
+ root,
950
+ withWorktreeLocks(
951
+ root,
952
+ [{ taskId, phaseId: id }],
953
+ runLifecycleTransaction({
954
+ root,
955
+ preconditions: [
956
+ { path: task.path, revision: task.revision },
957
+ { path: phase.path, revision: phase.revision },
958
+ ],
959
+ steps: [
960
+ documentWriteStep(root, writes),
961
+ {
962
+ label: `remove worktrees for phase ${taskId}/${id}`,
963
+ apply: async () => {
964
+ await runWorktreeEffect(
965
+ worktrees.remove(taskId, id, root, {
966
+ snapshots,
967
+ lockHeld: true,
968
+ }),
969
+ )
970
+ },
971
+ rollback: () => restoreWorktreeSnapshots(snapshots),
972
+ manualRecovery: `Run agency work prepare for phase '${taskId}/${id}'`,
973
+ },
974
+ directoryMoveStep(root, dirname(phase.path), destination),
975
+ ],
976
+ }),
977
+ ),
978
+ )
979
+ }
980
+ return {
981
+ operation: "archive",
982
+ kind: "phase",
214
983
  id,
984
+ taskId,
985
+ path: destination,
986
+ affectedPaths: [destination],
987
+ removedWorktrees,
988
+ dryRun: options.dryRun === true,
989
+ at,
990
+ } satisfies LifecycleResult
991
+ }),
992
+
993
+ restoreEpic: (
994
+ id: string,
995
+ startPath: string = process.cwd(),
996
+ options: LifecycleOptions = {},
997
+ ) =>
998
+ Effect.gen(function* () {
999
+ const workbase = yield* WorkbaseService
1000
+ const epics = yield* EpicService
1001
+ const service = yield* ArchiveService
1002
+ const root = yield* workbase.discover(startPath)
1003
+ const epic = yield* service.show("epic", id, undefined, root)
1004
+ const epicData = epic.data as EpicData
1005
+ const destination = join(root, "epics", id)
1006
+ yield* rejectExistingDestination(destination, "Restore")
1007
+ const activeEpics = yield* epics.list(root)
1008
+ const tasks: ArchivedRecord[] = []
1009
+ for (const child of epicData.tasks) {
1010
+ const conflictingEpic = activeEpics.find((candidate) =>
1011
+ candidate.data.tasks.some(
1012
+ (declaration) => declaration.id === child.id,
1013
+ ),
1014
+ )
1015
+ if (conflictingEpic) {
1016
+ return yield* new ArchiveError({
1017
+ message: `Active epic '${conflictingEpic.id}' already declares archived task '${child.id}'`,
1018
+ })
1019
+ }
1020
+ const task = yield* service.show("task", child.id, undefined, root)
1021
+ if ((task.data as TaskData).epic !== id) {
1022
+ return yield* new ArchiveError({
1023
+ message: `Archived task '${child.id}' does not backlink to epic '${id}'`,
1024
+ })
1025
+ }
1026
+ if (
1027
+ task.provenance?.parent &&
1028
+ (task.provenance.parent.kind !== "epic" ||
1029
+ task.provenance.parent.id !== id ||
1030
+ task.provenance.parent.declaration.id !== child.id)
1031
+ ) {
1032
+ return yield* new ArchiveError({
1033
+ message: `Archived task '${child.id}' has conflicting epic provenance`,
1034
+ })
1035
+ }
1036
+ yield* rejectExistingDestination(
1037
+ join(root, "tasks", child.id),
1038
+ "Restore",
1039
+ )
1040
+ tasks.push(task)
1041
+ }
1042
+ const dependencyIssue = validateDependencies(
1043
+ epicData.tasks,
1044
+ `epic '${id}'`,
215
1045
  )
216
- yield* rejectExistingDestination(destination)
217
- const removedWorktrees = yield* worktrees.remove(taskId, id, root)
1046
+ if (dependencyIssue)
1047
+ return yield* new ArchiveError({ message: dependencyIssue })
1048
+ const at = new Date().toISOString()
1049
+ const moves: Move[] = tasks.map((task) => ({
1050
+ from: task.path,
1051
+ to: join(root, "tasks", task.id),
1052
+ }))
1053
+ moves.push({ from: epic.path, to: destination })
1054
+ if (!options.dryRun) {
1055
+ const writes: Write[] = []
1056
+ for (const record of [...tasks, epic]) {
1057
+ const target =
1058
+ record.kind === "epic"
1059
+ ? destination
1060
+ : join(root, "tasks", record.id)
1061
+ const manifest = manifestFor(
1062
+ record.provenance,
1063
+ {
1064
+ kind: record.kind,
1065
+ id: record.id,
1066
+ ...(record.provenance?.parent
1067
+ ? { parent: record.provenance.parent }
1068
+ : {}),
1069
+ },
1070
+ event(root, "restore", at, record.path, target),
1071
+ )
1072
+ writes.push({
1073
+ path: lifecycleManifestPath(target),
1074
+ content: json(manifest),
1075
+ })
1076
+ }
1077
+ yield* withLifecycleLock(root, applyMutation(moves, writes))
1078
+ }
1079
+ return {
1080
+ operation: "restore",
1081
+ kind: "epic",
1082
+ id,
1083
+ path: destination,
1084
+ affectedPaths: moves.map((move) => move.to),
1085
+ removedWorktrees: [],
1086
+ dryRun: options.dryRun === true,
1087
+ at,
1088
+ } satisfies LifecycleResult
1089
+ }),
218
1090
 
219
- const parsed = yield* parseFrontmatter(task.content, task.path)
220
- yield* fs.writeFile(
221
- task.path,
222
- formatMarkdownDocument(
1091
+ restoreTask: (
1092
+ id: string,
1093
+ startPath: string = process.cwd(),
1094
+ options: LifecycleOptions = {},
1095
+ ) =>
1096
+ Effect.gen(function* () {
1097
+ const workbase = yield* WorkbaseService
1098
+ const epics = yield* EpicService
1099
+ const service = yield* ArchiveService
1100
+ const root = yield* workbase.discover(startPath)
1101
+ const task = yield* service.show("task", id, undefined, root)
1102
+ const taskData = task.data as TaskData
1103
+ const destination = join(root, "tasks", id)
1104
+ yield* rejectExistingDestination(destination, "Restore")
1105
+ const activeEpics = yield* epics.list(root)
1106
+ const conflictingEpic = activeEpics.find(
1107
+ (candidate) =>
1108
+ candidate.id !== taskData.epic &&
1109
+ candidate.data.tasks.some((declaration) => declaration.id === id),
1110
+ )
1111
+ if (conflictingEpic) {
1112
+ return yield* new ArchiveError({
1113
+ message: `Active epic '${conflictingEpic.id}' already declares archived task '${id}'`,
1114
+ })
1115
+ }
1116
+ if (!taskData.epic && task.provenance?.parent) {
1117
+ return yield* new ArchiveError({
1118
+ message: `Archived task '${id}' is missing its epic backlink`,
1119
+ })
1120
+ }
1121
+ let parent: EpicRecord | undefined
1122
+ let declaration: DependencyData | undefined
1123
+ if (taskData.epic) {
1124
+ parent = yield* epics.show(taskData.epic, root)
1125
+ if (
1126
+ task.provenance?.parent &&
1127
+ (task.provenance.parent.kind !== "epic" ||
1128
+ task.provenance.parent.id !== taskData.epic)
1129
+ ) {
1130
+ return yield* new ArchiveError({
1131
+ message: `Archived task '${id}' has conflicting epic backlink provenance`,
1132
+ })
1133
+ }
1134
+ declaration = task.provenance?.parent?.declaration ?? { id }
1135
+ if (declaration.id !== id) {
1136
+ return yield* new ArchiveError({
1137
+ message: `Archived task '${id}' has a conflicting parent declaration ID '${declaration.id}'`,
1138
+ })
1139
+ }
1140
+ if (parent.data.tasks.some((child) => child.id === id)) {
1141
+ return yield* new ArchiveError({
1142
+ message: `Epic '${taskData.epic}' already declares task '${id}'`,
1143
+ })
1144
+ }
1145
+ const nodes = [...parent.data.tasks, declaration]
1146
+ const dependencyIssue = validateDependencies(
1147
+ nodes,
1148
+ `epic '${taskData.epic}'`,
1149
+ )
1150
+ if (dependencyIssue)
1151
+ return yield* new ArchiveError({ message: dependencyIssue })
1152
+ }
1153
+ const at = new Date().toISOString()
1154
+ if (!options.dryRun) {
1155
+ const writes: Write[] = [
223
1156
  {
224
- ...task.data,
225
- phases: task.data.phases.filter(
226
- (candidate) => candidate.id !== id,
1157
+ path: lifecycleManifestPath(destination),
1158
+ content: json(
1159
+ manifestFor(
1160
+ task.provenance,
1161
+ {
1162
+ kind: "task",
1163
+ id,
1164
+ ...(task.provenance?.parent
1165
+ ? { parent: task.provenance.parent }
1166
+ : {}),
1167
+ },
1168
+ event(root, "restore", at, task.path, destination),
1169
+ ),
227
1170
  ),
228
1171
  },
229
- parsed.body,
230
- ),
231
- )
232
- yield* fs.createDirectory(dirname(destination))
233
- yield* fs.moveDirectory(dirname(phase.path), destination)
1172
+ ]
1173
+ if (parent && declaration) {
1174
+ writes.push({
1175
+ path: parent.path,
1176
+ content: yield* declarationContent(parent, {
1177
+ ...parent.data,
1178
+ tasks: [...parent.data.tasks, declaration],
1179
+ }),
1180
+ })
1181
+ }
1182
+ yield* withLifecycleLock(
1183
+ root,
1184
+ applyMutation([{ from: task.path, to: destination }], writes),
1185
+ )
1186
+ }
1187
+ return {
1188
+ operation: "restore",
1189
+ kind: "task",
1190
+ id,
1191
+ path: destination,
1192
+ affectedPaths: [destination],
1193
+ removedWorktrees: [],
1194
+ dryRun: options.dryRun === true,
1195
+ at,
1196
+ } satisfies LifecycleResult
1197
+ }),
234
1198
 
1199
+ restorePhase: (
1200
+ taskId: string,
1201
+ id: string,
1202
+ startPath: string = process.cwd(),
1203
+ options: LifecycleOptions = {},
1204
+ ) =>
1205
+ Effect.gen(function* () {
1206
+ const workbase = yield* WorkbaseService
1207
+ const tasks = yield* TaskService
1208
+ const service = yield* ArchiveService
1209
+ const root = yield* workbase.discover(startPath)
1210
+ const task = yield* tasks.show(taskId, root)
1211
+ if (!("phases" in task.data)) {
1212
+ return yield* new ArchiveError({
1213
+ message: `Task '${taskId}' is single-phase and cannot receive a phase`,
1214
+ })
1215
+ }
1216
+ const phase = yield* service.show("phase", id, taskId, root)
1217
+ if (
1218
+ phase.provenance?.parent &&
1219
+ (phase.provenance.parent.kind !== "task" ||
1220
+ phase.provenance.parent.id !== taskId)
1221
+ ) {
1222
+ return yield* new ArchiveError({
1223
+ message: `Archived phase '${id}' has conflicting task backlink provenance`,
1224
+ })
1225
+ }
1226
+ if (task.data.phases.some((candidate) => candidate.id === id)) {
1227
+ return yield* new ArchiveError({
1228
+ message: `Task '${taskId}' already declares phase '${id}'`,
1229
+ })
1230
+ }
1231
+ const destination = join(root, "tasks", taskId, "phases", id)
1232
+ yield* rejectExistingDestination(destination, "Restore")
1233
+ const declaration = phase.provenance?.parent?.declaration ?? { id }
1234
+ if (declaration.id !== id) {
1235
+ return yield* new ArchiveError({
1236
+ message: `Archived phase '${id}' has a conflicting parent declaration ID '${declaration.id}'`,
1237
+ })
1238
+ }
1239
+ const nodes = [...task.data.phases, declaration]
1240
+ const dependencyIssue = validateDependencies(
1241
+ nodes,
1242
+ `task '${taskId}'`,
1243
+ )
1244
+ if (dependencyIssue)
1245
+ return yield* new ArchiveError({ message: dependencyIssue })
1246
+ const at = new Date().toISOString()
1247
+ if (!options.dryRun) {
1248
+ yield* withLifecycleLock(
1249
+ root,
1250
+ applyMutation(
1251
+ [{ from: phase.path, to: destination }],
1252
+ [
1253
+ {
1254
+ path: task.path,
1255
+ content: yield* declarationContent(task, {
1256
+ ...task.data,
1257
+ phases: [...task.data.phases, declaration],
1258
+ }),
1259
+ },
1260
+ {
1261
+ path: lifecycleManifestPath(destination),
1262
+ content: json(
1263
+ manifestFor(
1264
+ phase.provenance,
1265
+ {
1266
+ kind: "phase",
1267
+ id,
1268
+ taskId,
1269
+ ...(phase.provenance?.parent
1270
+ ? { parent: phase.provenance.parent }
1271
+ : {}),
1272
+ },
1273
+ event(root, "restore", at, phase.path, destination),
1274
+ ),
1275
+ ),
1276
+ },
1277
+ ],
1278
+ ),
1279
+ )
1280
+ }
235
1281
  return {
1282
+ operation: "restore",
236
1283
  kind: "phase",
237
1284
  id,
238
1285
  taskId,
239
1286
  path: destination,
240
- archivedPaths: [destination],
241
- removedWorktrees,
242
- } satisfies ArchiveResult
1287
+ affectedPaths: [destination],
1288
+ removedWorktrees: [],
1289
+ dryRun: options.dryRun === true,
1290
+ at,
1291
+ } satisfies LifecycleResult
243
1292
  }),
244
1293
  }),
245
1294
  },