@markjaquith/agency 2.20.0 → 2.22.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.
@@ -0,0 +1,952 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { open, rename, rm } from "node:fs/promises"
4
+ import { dirname, join, relative } from "node:path"
5
+ import { EpicService } from "./EpicService"
6
+ import { FileSystemService } from "./FileSystemService"
7
+ import { PhaseService } from "./PhaseService"
8
+ import { TaskService } from "./TaskService"
9
+ import { WorkbaseService } from "./WorkbaseService"
10
+ import {
11
+ EntityId,
12
+ EpicFrontmatter,
13
+ PhaseFrontmatter,
14
+ TaskFrontmatter,
15
+ type Dependency,
16
+ type EpicFrontmatter as EpicData,
17
+ type PhaseFrontmatter as PhaseData,
18
+ type RepositoryReference,
19
+ type TaskFrontmatter as TaskData,
20
+ } from "../workbase/schemas"
21
+ import {
22
+ formatMarkdownDocument,
23
+ parseFrontmatter,
24
+ } from "../workbase/frontmatter"
25
+ import { validateDependencies } from "../workbase/dependency-graph"
26
+
27
+ class GraphMutationError extends Data.TaggedError("GraphMutationError")<{
28
+ readonly message: string
29
+ readonly cause?: unknown
30
+ }> {}
31
+
32
+ interface MutationResult {
33
+ readonly operation: string
34
+ readonly changed: boolean
35
+ readonly entity: {
36
+ readonly kind: "epic" | "task" | "phase"
37
+ readonly id: string
38
+ }
39
+ readonly previousId?: string
40
+ readonly changedPaths: readonly string[]
41
+ readonly validation: {
42
+ readonly valid: true
43
+ readonly scope: readonly string[]
44
+ readonly checks: readonly string[]
45
+ }
46
+ }
47
+
48
+ interface WritePlan {
49
+ readonly root: string
50
+ readonly writes: readonly {
51
+ readonly path: string
52
+ readonly content: string
53
+ }[]
54
+ readonly move?: { readonly from: string; readonly to: string }
55
+ }
56
+
57
+ export interface EpicUpdates {
58
+ readonly description?: string | null
59
+ readonly ticketUrl?: string
60
+ readonly repos?: readonly RepositoryReference[]
61
+ }
62
+
63
+ export interface TaskUpdates {
64
+ readonly description?: string | null
65
+ readonly ticketUrl?: string | null
66
+ readonly repo?: string
67
+ readonly repos?: readonly RepositoryReference[] | null
68
+ readonly branch?: string
69
+ readonly base?: string
70
+ readonly pr?: string | null
71
+ }
72
+
73
+ export interface PhaseUpdates {
74
+ readonly description?: string | null
75
+ readonly repo?: string
76
+ readonly repos?: readonly RepositoryReference[] | null
77
+ readonly branch?: string
78
+ readonly base?: string
79
+ readonly pr?: string | null
80
+ }
81
+
82
+ const decode = <S extends Schema.Schema.AnyNoContext>(
83
+ schema: S,
84
+ input: unknown,
85
+ label: string,
86
+ ) => {
87
+ const result = Schema.decodeUnknownEither(schema, {
88
+ errors: "all",
89
+ onExcessProperty: "error",
90
+ })(input)
91
+ return Either.isLeft(result)
92
+ ? Effect.fail(
93
+ new GraphMutationError({
94
+ message: `Invalid ${label}: ${TreeFormatter.formatErrorSync(result.left)}`,
95
+ }),
96
+ )
97
+ : Effect.succeed(result.right)
98
+ }
99
+
100
+ const decodeId = (id: string, label: string) => {
101
+ const decoded = Schema.decodeUnknownEither(EntityId)(id)
102
+ return Either.isLeft(decoded)
103
+ ? Effect.fail(
104
+ new GraphMutationError({ message: `Invalid ${label} ID '${id}'` }),
105
+ )
106
+ : Effect.succeed(decoded.right)
107
+ }
108
+
109
+ const exists = async (path: string) => {
110
+ try {
111
+ return await Bun.file(path).exists()
112
+ } catch {
113
+ return false
114
+ }
115
+ }
116
+
117
+ const applyWritePlan = ({ root, writes, move }: WritePlan) =>
118
+ Effect.tryPromise({
119
+ try: async () => {
120
+ const lockPath = join(root, ".agency-graph-mutation.lock")
121
+ let lock: Awaited<ReturnType<typeof open>> | undefined
122
+ try {
123
+ lock = await open(lockPath, "wx")
124
+ } catch (cause) {
125
+ throw new GraphMutationError({
126
+ message:
127
+ "Another graph mutation is in progress; wait for it to finish and retry",
128
+ cause,
129
+ })
130
+ }
131
+
132
+ const token = `${process.pid}-${Date.now()}`
133
+ const staged = writes.map((write) => ({
134
+ ...write,
135
+ stage: `${write.path}.${token}.stage`,
136
+ backup: `${write.path}.${token}.backup`,
137
+ }))
138
+ const installed: typeof staged = []
139
+ let moved = false
140
+ let rollbackFailed = false
141
+ try {
142
+ for (const write of staged) await Bun.write(write.stage, write.content)
143
+ if (move) {
144
+ await rename(move.from, move.to)
145
+ moved = true
146
+ }
147
+ for (const write of staged) {
148
+ await rename(write.path, write.backup)
149
+ try {
150
+ await rename(write.stage, write.path)
151
+ installed.push(write)
152
+ } catch (cause) {
153
+ await rename(write.backup, write.path)
154
+ throw cause
155
+ }
156
+ }
157
+ await Promise.allSettled(
158
+ installed.map((write) => rm(write.backup, { force: true })),
159
+ )
160
+ } catch (cause) {
161
+ let rollbackCause: unknown
162
+ for (const write of [...installed].reverse()) {
163
+ try {
164
+ await rm(write.path, { force: true })
165
+ if (await exists(write.backup))
166
+ await rename(write.backup, write.path)
167
+ } catch (error) {
168
+ rollbackCause ??= error
169
+ }
170
+ }
171
+ if (moved && move) {
172
+ try {
173
+ await rename(move.to, move.from)
174
+ } catch (error) {
175
+ rollbackCause ??= error
176
+ }
177
+ }
178
+ if (rollbackCause) {
179
+ rollbackFailed = true
180
+ throw new GraphMutationError({
181
+ message: `Graph mutation rollback failed; recovery files with suffix '.${token}.backup' were preserved`,
182
+ cause: new AggregateError([cause, rollbackCause]),
183
+ })
184
+ }
185
+ throw cause
186
+ } finally {
187
+ await Promise.allSettled(
188
+ staged.flatMap((write) => [
189
+ rm(write.stage, { force: true }),
190
+ ...(rollbackFailed ? [] : [rm(write.backup, { force: true })]),
191
+ ]),
192
+ )
193
+ await lock.close().catch(() => undefined)
194
+ await rm(lockPath, { force: true }).catch(() => undefined)
195
+ }
196
+ },
197
+ catch: (cause) =>
198
+ cause instanceof GraphMutationError
199
+ ? cause
200
+ : new GraphMutationError({
201
+ message:
202
+ "Graph mutation failed; all staged changes were rolled back",
203
+ cause,
204
+ }),
205
+ })
206
+
207
+ const contentWith = (
208
+ record: { readonly content: string; readonly path: string },
209
+ data: EpicData | TaskData | PhaseData,
210
+ ) =>
211
+ parseFrontmatter(record.content, record.path).pipe(
212
+ Effect.map((parsed) => formatMarkdownDocument(data, parsed.body)),
213
+ )
214
+
215
+ const result = (
216
+ root: string,
217
+ operation: string,
218
+ kind: MutationResult["entity"]["kind"],
219
+ id: string,
220
+ paths: readonly string[],
221
+ previousId?: string,
222
+ ): MutationResult => {
223
+ const scope = paths.map((path) => relative(root, path))
224
+ return {
225
+ operation,
226
+ changed: paths.length > 0 || previousId !== undefined,
227
+ entity: { kind, id },
228
+ ...(previousId ? { previousId } : {}),
229
+ changedPaths: scope,
230
+ validation: {
231
+ valid: true,
232
+ scope,
233
+ checks: ["schema", "references", "dependencies"],
234
+ },
235
+ }
236
+ }
237
+
238
+ const assertDependencies = (nodes: readonly Dependency[], label: string) => {
239
+ const issue = validateDependencies(nodes, label)
240
+ return issue
241
+ ? Effect.fail(new GraphMutationError({ message: issue }))
242
+ : Effect.void
243
+ }
244
+
245
+ export class GraphMutationService extends Effect.Service<GraphMutationService>()(
246
+ "GraphMutationService",
247
+ {
248
+ sync: () => ({
249
+ updateEpic: (
250
+ id: string,
251
+ updates: EpicUpdates,
252
+ startPath: string = process.cwd(),
253
+ ) =>
254
+ Effect.gen(function* () {
255
+ const workbase = yield* WorkbaseService
256
+ const epics = yield* EpicService
257
+ const fs = yield* FileSystemService
258
+ const root = yield* workbase.discover(startPath)
259
+ const record = yield* epics.show(id, root)
260
+ const data: EpicData = yield* decode(
261
+ EpicFrontmatter,
262
+ {
263
+ ...record.data,
264
+ ...(updates.description === null
265
+ ? { description: undefined }
266
+ : updates.description !== undefined
267
+ ? { description: updates.description }
268
+ : {}),
269
+ ...(updates.ticketUrl !== undefined
270
+ ? { ticketUrl: updates.ticketUrl }
271
+ : {}),
272
+ ...(updates.repos !== undefined ? { repos: updates.repos } : {}),
273
+ },
274
+ "epic metadata",
275
+ )
276
+ for (const reference of data.repos) {
277
+ if (!(yield* fs.exists(join(root, "repos", reference.repo)))) {
278
+ return yield* new GraphMutationError({
279
+ message: `Unknown repository alias '${reference.repo}'`,
280
+ })
281
+ }
282
+ }
283
+ if (
284
+ new Set(data.repos.map((item) => item.repo)).size !==
285
+ data.repos.length
286
+ ) {
287
+ return yield* new GraphMutationError({
288
+ message: "Repository references must be unique",
289
+ })
290
+ }
291
+ const content = yield* contentWith(record, data)
292
+ if (content === record.content)
293
+ return result(root, "epic.update", "epic", id, [])
294
+ yield* applyWritePlan({
295
+ root,
296
+ writes: [{ path: record.path, content }],
297
+ })
298
+ return result(root, "epic.update", "epic", id, [record.path])
299
+ }),
300
+
301
+ updateTask: (
302
+ id: string,
303
+ updates: TaskUpdates,
304
+ startPath: string = process.cwd(),
305
+ ) =>
306
+ Effect.gen(function* () {
307
+ const workbase = yield* WorkbaseService
308
+ const tasks = yield* TaskService
309
+ const fs = yield* FileSystemService
310
+ const root = yield* workbase.discover(startPath)
311
+ const record = yield* tasks.show(id, root)
312
+ const executionChange = [
313
+ "repo",
314
+ "repos",
315
+ "branch",
316
+ "base",
317
+ "pr",
318
+ ].some((key) => updates[key as keyof TaskUpdates] !== undefined)
319
+ if ("phases" in record.data && executionChange) {
320
+ return yield* new GraphMutationError({
321
+ message: `Task '${id}' has multiple phases; update execution metadata on a phase instead`,
322
+ })
323
+ }
324
+ if (
325
+ executionChange &&
326
+ "claim" in record.data &&
327
+ record.data.claim?.state === "active"
328
+ ) {
329
+ return yield* new GraphMutationError({
330
+ message: `Task '${id}' has an active claim; release or finish it before changing execution metadata`,
331
+ })
332
+ }
333
+ if (
334
+ executionChange &&
335
+ (yield* fs.isDirectory(join(dirname(record.path), "code")))
336
+ ) {
337
+ return yield* new GraphMutationError({
338
+ message: `Task '${id}' has materialized code; remove its worktree with Agency before changing execution metadata`,
339
+ })
340
+ }
341
+ const data: TaskData = yield* decode(
342
+ TaskFrontmatter,
343
+ {
344
+ ...record.data,
345
+ ...(updates.description === null
346
+ ? { description: undefined }
347
+ : updates.description !== undefined
348
+ ? { description: updates.description }
349
+ : {}),
350
+ ...(updates.ticketUrl !== undefined
351
+ ? { ticketUrl: updates.ticketUrl }
352
+ : {}),
353
+ ...(updates.repo !== undefined ? { repo: updates.repo } : {}),
354
+ ...(updates.repos === null
355
+ ? { repos: undefined }
356
+ : updates.repos !== undefined
357
+ ? { repos: updates.repos }
358
+ : {}),
359
+ ...(updates.branch !== undefined
360
+ ? { branch: updates.branch }
361
+ : {}),
362
+ ...(updates.base !== undefined ? { base: updates.base } : {}),
363
+ ...(updates.pr !== undefined ? { pr: updates.pr } : {}),
364
+ },
365
+ "task metadata",
366
+ )
367
+ if ("repo" in data) {
368
+ const aliases = [
369
+ data.repo,
370
+ ...(data.repos ?? []).map((item) => item.repo),
371
+ ]
372
+ if (new Set(aliases).size !== aliases.length) {
373
+ return yield* new GraphMutationError({
374
+ message:
375
+ "Repository references must be unique and cannot include the writable repository",
376
+ })
377
+ }
378
+ for (const alias of aliases) {
379
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
380
+ return yield* new GraphMutationError({
381
+ message: `Unknown repository alias '${alias}'`,
382
+ })
383
+ }
384
+ }
385
+ for (const other of yield* tasks.list(root)) {
386
+ if (
387
+ other.id !== id &&
388
+ "repo" in other.data &&
389
+ other.data.repo === data.repo &&
390
+ other.data.branch === data.branch
391
+ ) {
392
+ return yield* new GraphMutationError({
393
+ message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by task '${other.id}'`,
394
+ })
395
+ }
396
+ if (!("phases" in other.data)) continue
397
+ for (const phase of yield* (yield* PhaseService).list(
398
+ other.id,
399
+ root,
400
+ )) {
401
+ if (
402
+ phase.data.repo === data.repo &&
403
+ phase.data.branch === data.branch
404
+ ) {
405
+ return yield* new GraphMutationError({
406
+ message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by phase '${other.id}/${phase.id}'`,
407
+ })
408
+ }
409
+ }
410
+ }
411
+ }
412
+ const content = yield* contentWith(record, data)
413
+ if (content === record.content)
414
+ return result(root, "task.update", "task", id, [])
415
+ yield* applyWritePlan({
416
+ root,
417
+ writes: [{ path: record.path, content }],
418
+ })
419
+ return result(root, "task.update", "task", id, [record.path])
420
+ }),
421
+
422
+ updatePhase: (
423
+ taskId: string,
424
+ id: string,
425
+ updates: PhaseUpdates,
426
+ startPath: string = process.cwd(),
427
+ ) =>
428
+ Effect.gen(function* () {
429
+ const workbase = yield* WorkbaseService
430
+ const phases = yield* PhaseService
431
+ const fs = yield* FileSystemService
432
+ const root = yield* workbase.discover(startPath)
433
+ const record = yield* phases.show(taskId, id, root)
434
+ const executionChange = [
435
+ "repo",
436
+ "repos",
437
+ "branch",
438
+ "base",
439
+ "pr",
440
+ ].some((key) => updates[key as keyof PhaseUpdates] !== undefined)
441
+ if (
442
+ executionChange &&
443
+ (yield* fs.isDirectory(join(dirname(record.path), "code")))
444
+ ) {
445
+ return yield* new GraphMutationError({
446
+ message: `Phase '${id}' has materialized code; remove its worktree with Agency before changing execution metadata`,
447
+ })
448
+ }
449
+ if (executionChange && record.data.claim?.state === "active") {
450
+ return yield* new GraphMutationError({
451
+ message: `Phase '${id}' has an active claim; release or finish it before changing execution metadata`,
452
+ })
453
+ }
454
+ const data: PhaseData = yield* decode(
455
+ PhaseFrontmatter,
456
+ {
457
+ ...record.data,
458
+ ...(updates.description === null
459
+ ? { description: undefined }
460
+ : updates.description !== undefined
461
+ ? { description: updates.description }
462
+ : {}),
463
+ ...(updates.repo !== undefined ? { repo: updates.repo } : {}),
464
+ ...(updates.repos === null
465
+ ? { repos: undefined }
466
+ : updates.repos !== undefined
467
+ ? { repos: updates.repos }
468
+ : {}),
469
+ ...(updates.branch !== undefined
470
+ ? { branch: updates.branch }
471
+ : {}),
472
+ ...(updates.base !== undefined ? { base: updates.base } : {}),
473
+ ...(updates.pr !== undefined ? { pr: updates.pr } : {}),
474
+ },
475
+ "phase metadata",
476
+ )
477
+ const aliases = [
478
+ data.repo,
479
+ ...(data.repos ?? []).map((item) => item.repo),
480
+ ]
481
+ if (new Set(aliases).size !== aliases.length) {
482
+ return yield* new GraphMutationError({
483
+ message:
484
+ "Repository references must be unique and cannot include the writable repository",
485
+ })
486
+ }
487
+ for (const alias of aliases) {
488
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
489
+ return yield* new GraphMutationError({
490
+ message: `Unknown repository alias '${alias}'`,
491
+ })
492
+ }
493
+ }
494
+ for (const task of yield* (yield* TaskService).list(root)) {
495
+ if (
496
+ "repo" in task.data &&
497
+ task.data.repo === data.repo &&
498
+ task.data.branch === data.branch
499
+ ) {
500
+ return yield* new GraphMutationError({
501
+ message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by task '${task.id}'`,
502
+ })
503
+ }
504
+ if (!("phases" in task.data)) continue
505
+ for (const other of yield* phases.list(task.id, root)) {
506
+ if (
507
+ (task.id !== taskId || other.id !== id) &&
508
+ other.data.repo === data.repo &&
509
+ other.data.branch === data.branch
510
+ ) {
511
+ return yield* new GraphMutationError({
512
+ message: `Writable branch '${data.branch}' for repository '${data.repo}' is already owned by phase '${task.id}/${other.id}'`,
513
+ })
514
+ }
515
+ }
516
+ }
517
+ const content = yield* contentWith(record, data)
518
+ if (content === record.content)
519
+ return result(root, "phase.update", "phase", id, [])
520
+ yield* applyWritePlan({
521
+ root,
522
+ writes: [{ path: record.path, content }],
523
+ })
524
+ return result(root, "phase.update", "phase", id, [record.path])
525
+ }),
526
+
527
+ mutateTaskDependency: (
528
+ operation: "add" | "remove",
529
+ id: string,
530
+ dependencyId: string,
531
+ startPath: string = process.cwd(),
532
+ ) =>
533
+ Effect.gen(function* () {
534
+ const workbase = yield* WorkbaseService
535
+ const tasks = yield* TaskService
536
+ const epics = yield* EpicService
537
+ const root = yield* workbase.discover(startPath)
538
+ const task = yield* tasks.show(id, root)
539
+ if (!task.data.epic)
540
+ return yield* new GraphMutationError({
541
+ message: `Task '${id}' has no parent epic; task dependencies are scoped to an epic`,
542
+ })
543
+ const epic = yield* epics.show(task.data.epic, root)
544
+ const declaration = epic.data.tasks.find((item) => item.id === id)
545
+ if (!declaration)
546
+ return yield* new GraphMutationError({
547
+ message: `Epic '${epic.id}' does not list task '${id}'; run agency validate for repair details`,
548
+ })
549
+ if (operation === "add") {
550
+ const dependency = yield* tasks.show(dependencyId, root)
551
+ if (dependency.data.epic !== epic.id) {
552
+ return yield* new GraphMutationError({
553
+ message: `Task dependency '${dependencyId}' does not belong to epic '${epic.id}'`,
554
+ })
555
+ }
556
+ }
557
+ const current = declaration.dependsOn ?? []
558
+ if (operation === "add" && current.includes(dependencyId))
559
+ return yield* new GraphMutationError({
560
+ message: `Task '${id}' already depends on '${dependencyId}'`,
561
+ })
562
+ if (operation === "remove" && !current.includes(dependencyId))
563
+ return yield* new GraphMutationError({
564
+ message: `Task '${id}' does not depend on '${dependencyId}'`,
565
+ })
566
+ const dependencies =
567
+ operation === "add"
568
+ ? [...current, dependencyId]
569
+ : current.filter((item) => item !== dependencyId)
570
+ const data = {
571
+ ...epic.data,
572
+ tasks: epic.data.tasks.map((item) =>
573
+ item.id === id
574
+ ? {
575
+ id: item.id,
576
+ ...(dependencies.length ? { dependsOn: dependencies } : {}),
577
+ }
578
+ : item,
579
+ ),
580
+ }
581
+ yield* assertDependencies(data.tasks, "Tasks")
582
+ const content = yield* contentWith(epic, data)
583
+ yield* applyWritePlan({
584
+ root,
585
+ writes: [{ path: epic.path, content }],
586
+ })
587
+ return result(root, `task.dependency.${operation}`, "task", id, [
588
+ epic.path,
589
+ ])
590
+ }),
591
+
592
+ mutatePhaseDependency: (
593
+ operation: "add" | "remove",
594
+ taskId: string,
595
+ id: string,
596
+ dependencyId: string,
597
+ startPath: string = process.cwd(),
598
+ ) =>
599
+ Effect.gen(function* () {
600
+ const workbase = yield* WorkbaseService
601
+ const tasks = yield* TaskService
602
+ const root = yield* workbase.discover(startPath)
603
+ const task = yield* tasks.show(taskId, root)
604
+ if (!("phases" in task.data))
605
+ return yield* new GraphMutationError({
606
+ message: `Task '${taskId}' does not have phases`,
607
+ })
608
+ const declaration = task.data.phases.find((item) => item.id === id)
609
+ if (!declaration)
610
+ return yield* new GraphMutationError({
611
+ message: `Phase '${id}' does not exist on task '${taskId}'`,
612
+ })
613
+ if (operation === "add") {
614
+ yield* (yield* PhaseService).show(taskId, dependencyId, root)
615
+ }
616
+ const current = declaration.dependsOn ?? []
617
+ if (operation === "add" && current.includes(dependencyId))
618
+ return yield* new GraphMutationError({
619
+ message: `Phase '${id}' already depends on '${dependencyId}'`,
620
+ })
621
+ if (operation === "remove" && !current.includes(dependencyId))
622
+ return yield* new GraphMutationError({
623
+ message: `Phase '${id}' does not depend on '${dependencyId}'`,
624
+ })
625
+ const dependencies =
626
+ operation === "add"
627
+ ? [...current, dependencyId]
628
+ : current.filter((item) => item !== dependencyId)
629
+ const data = {
630
+ ...task.data,
631
+ phases: task.data.phases.map((item) =>
632
+ item.id === id
633
+ ? {
634
+ id: item.id,
635
+ ...(dependencies.length ? { dependsOn: dependencies } : {}),
636
+ }
637
+ : item,
638
+ ),
639
+ }
640
+ yield* assertDependencies(data.phases, "Phases")
641
+ const content = yield* contentWith(task, data)
642
+ yield* applyWritePlan({
643
+ root,
644
+ writes: [{ path: task.path, content }],
645
+ })
646
+ return result(root, `phase.dependency.${operation}`, "phase", id, [
647
+ task.path,
648
+ ])
649
+ }),
650
+
651
+ renameEpic: (
652
+ id: string,
653
+ newId: string,
654
+ startPath: string = process.cwd(),
655
+ ) =>
656
+ Effect.gen(function* () {
657
+ const workbase = yield* WorkbaseService
658
+ const epics = yield* EpicService
659
+ const tasks = yield* TaskService
660
+ const fs = yield* FileSystemService
661
+ const root = yield* workbase.discover(startPath)
662
+ yield* decodeId(newId, "epic")
663
+ const epic = yield* epics.show(id, root)
664
+ const from = dirname(epic.path)
665
+ const to = join(root, "epics", newId)
666
+ if (yield* fs.exists(to))
667
+ return yield* new GraphMutationError({
668
+ message: `Epic '${newId}' already exists`,
669
+ })
670
+ const allTasks = yield* tasks.list(root)
671
+ const declared = new Set(epic.data.tasks.map((item) => item.id))
672
+ for (const declaration of epic.data.tasks) {
673
+ const child = allTasks.find((item) => item.id === declaration.id)
674
+ if (!child || child.data.epic !== id) {
675
+ return yield* new GraphMutationError({
676
+ message: `Cannot rename epic '${id}': task '${declaration.id}' does not have a matching parent backlink; run agency validate for details`,
677
+ })
678
+ }
679
+ }
680
+ const unlisted = allTasks.find(
681
+ (item) => item.data.epic === id && !declared.has(item.id),
682
+ )
683
+ if (unlisted) {
684
+ return yield* new GraphMutationError({
685
+ message: `Cannot rename epic '${id}': task '${unlisted.id}' is not listed by the epic; run agency validate for details`,
686
+ })
687
+ }
688
+ const writes: { path: string; content: string }[] = []
689
+ for (const task of allTasks) {
690
+ if (task.data.epic !== id) continue
691
+ const data = yield* decode(
692
+ TaskFrontmatter,
693
+ { ...task.data, epic: newId },
694
+ "task metadata",
695
+ )
696
+ writes.push({
697
+ path: task.path,
698
+ content: yield* contentWith(task, data),
699
+ })
700
+ }
701
+ yield* applyWritePlan({ root, writes, move: { from, to } })
702
+ return result(
703
+ root,
704
+ "epic.rename",
705
+ "epic",
706
+ newId,
707
+ [join(to, "EPIC.md"), ...writes.map((write) => write.path)],
708
+ id,
709
+ )
710
+ }),
711
+
712
+ renameTask: (
713
+ id: string,
714
+ newId: string,
715
+ startPath: string = process.cwd(),
716
+ ) =>
717
+ Effect.gen(function* () {
718
+ const workbase = yield* WorkbaseService
719
+ const epics = yield* EpicService
720
+ const tasks = yield* TaskService
721
+ const fs = yield* FileSystemService
722
+ const root = yield* workbase.discover(startPath)
723
+ yield* decodeId(newId, "task")
724
+ const task = yield* tasks.show(id, root)
725
+ const from = dirname(task.path)
726
+ const to = join(root, "tasks", newId)
727
+ if (yield* fs.exists(to))
728
+ return yield* new GraphMutationError({
729
+ message: `Task '${newId}' already exists`,
730
+ })
731
+ if ("claim" in task.data && task.data.claim?.state === "active")
732
+ return yield* new GraphMutationError({
733
+ message: `Task '${id}' has an active claim; release or finish it before renaming`,
734
+ })
735
+ if (yield* fs.isDirectory(join(from, "code")))
736
+ return yield* new GraphMutationError({
737
+ message: `Task '${id}' has a materialized worktree; remove it with Agency before renaming`,
738
+ })
739
+ if ("phases" in task.data) {
740
+ for (const phase of task.data.phases) {
741
+ const record = yield* (yield* PhaseService).show(
742
+ id,
743
+ phase.id,
744
+ root,
745
+ )
746
+ if (record.data.claim?.state === "active")
747
+ return yield* new GraphMutationError({
748
+ message: `Phase '${phase.id}' has an active claim; release or finish it before renaming task '${id}'`,
749
+ })
750
+ if (yield* fs.isDirectory(join(dirname(record.path), "code")))
751
+ return yield* new GraphMutationError({
752
+ message: `Phase '${phase.id}' has a materialized worktree; remove it with Agency before renaming task '${id}'`,
753
+ })
754
+ }
755
+ }
756
+ if (task.data.epic) {
757
+ const parent = yield* epics.show(task.data.epic, root)
758
+ if (!parent.data.tasks.some((item) => item.id === id)) {
759
+ return yield* new GraphMutationError({
760
+ message: `Cannot rename task '${id}': parent epic '${parent.id}' does not list it; run agency validate for details`,
761
+ })
762
+ }
763
+ }
764
+ const writes: { path: string; content: string }[] = []
765
+ for (const epic of yield* epics.list(root)) {
766
+ if (
767
+ !epic.data.tasks.some(
768
+ (item) => item.id === id || item.dependsOn?.includes(id),
769
+ )
770
+ )
771
+ continue
772
+ const data = {
773
+ ...epic.data,
774
+ tasks: epic.data.tasks.map((item) => ({
775
+ ...item,
776
+ id: item.id === id ? newId : item.id,
777
+ ...(item.dependsOn
778
+ ? {
779
+ dependsOn: item.dependsOn.map((dependency) =>
780
+ dependency === id ? newId : dependency,
781
+ ),
782
+ }
783
+ : {}),
784
+ })),
785
+ }
786
+ yield* assertDependencies(data.tasks, "Tasks")
787
+ writes.push({
788
+ path: epic.path,
789
+ content: yield* contentWith(epic, data),
790
+ })
791
+ }
792
+ yield* applyWritePlan({ root, writes, move: { from, to } })
793
+ return result(
794
+ root,
795
+ "task.rename",
796
+ "task",
797
+ newId,
798
+ [join(to, "TASK.md"), ...writes.map((write) => write.path)],
799
+ id,
800
+ )
801
+ }),
802
+
803
+ renamePhase: (
804
+ taskId: string,
805
+ id: string,
806
+ newId: string,
807
+ startPath: string = process.cwd(),
808
+ ) =>
809
+ Effect.gen(function* () {
810
+ const workbase = yield* WorkbaseService
811
+ const tasks = yield* TaskService
812
+ const phases = yield* PhaseService
813
+ const fs = yield* FileSystemService
814
+ const root = yield* workbase.discover(startPath)
815
+ yield* decodeId(newId, "phase")
816
+ const task = yield* tasks.show(taskId, root)
817
+ if (!("phases" in task.data))
818
+ return yield* new GraphMutationError({
819
+ message: `Task '${taskId}' does not have phases`,
820
+ })
821
+ if (!task.data.phases.some((item) => item.id === id)) {
822
+ return yield* new GraphMutationError({
823
+ message: `Cannot rename phase '${id}': task '${taskId}' does not list it; run agency validate for details`,
824
+ })
825
+ }
826
+ const phase = yield* phases.show(taskId, id, root)
827
+ if (phase.data.claim?.state === "active")
828
+ return yield* new GraphMutationError({
829
+ message: `Phase '${id}' has an active claim; release or finish it before renaming`,
830
+ })
831
+ const from = dirname(phase.path)
832
+ const to = join(dirname(from), newId)
833
+ if (yield* fs.exists(to))
834
+ return yield* new GraphMutationError({
835
+ message: `Phase '${newId}' already exists on task '${taskId}'`,
836
+ })
837
+ if (yield* fs.isDirectory(join(from, "code")))
838
+ return yield* new GraphMutationError({
839
+ message: `Phase '${id}' has a materialized worktree; remove it with Agency before renaming`,
840
+ })
841
+ const data = {
842
+ ...task.data,
843
+ phases: task.data.phases.map((item) => ({
844
+ ...item,
845
+ id: item.id === id ? newId : item.id,
846
+ ...(item.dependsOn
847
+ ? {
848
+ dependsOn: item.dependsOn.map((dependency) =>
849
+ dependency === id ? newId : dependency,
850
+ ),
851
+ }
852
+ : {}),
853
+ })),
854
+ }
855
+ yield* assertDependencies(data.phases, "Phases")
856
+ const content = yield* contentWith(task, data)
857
+ yield* applyWritePlan({
858
+ root,
859
+ writes: [{ path: task.path, content }],
860
+ move: { from, to },
861
+ })
862
+ return result(
863
+ root,
864
+ "phase.rename",
865
+ "phase",
866
+ newId,
867
+ [join(to, "PHASE.md"), task.path],
868
+ id,
869
+ )
870
+ }),
871
+
872
+ moveTask: (
873
+ id: string,
874
+ epicId: string | null,
875
+ startPath: string = process.cwd(),
876
+ ) =>
877
+ Effect.gen(function* () {
878
+ const workbase = yield* WorkbaseService
879
+ const tasks = yield* TaskService
880
+ const epics = yield* EpicService
881
+ const root = yield* workbase.discover(startPath)
882
+ const task = yield* tasks.show(id, root)
883
+ const sourceId = task.data.epic
884
+ if (sourceId === epicId)
885
+ return result(root, "task.move", "task", id, [])
886
+ const source = sourceId
887
+ ? yield* epics.show(sourceId, root)
888
+ : undefined
889
+ const target = epicId ? yield* epics.show(epicId, root) : undefined
890
+ const declaration = source?.data.tasks.find((item) => item.id === id)
891
+ if (source && !declaration)
892
+ return yield* new GraphMutationError({
893
+ message: `Epic '${source.id}' does not list task '${id}'; run agency validate for repair details`,
894
+ })
895
+ const dependent = source?.data.tasks.find((item) =>
896
+ item.dependsOn?.includes(id),
897
+ )
898
+ if ((declaration?.dependsOn?.length ?? 0) > 0 || dependent) {
899
+ return yield* new GraphMutationError({
900
+ message: `Cannot move task '${id}' while scoped dependencies exist; remove ${dependent ? `dependency from '${dependent.id}'` : `its dependencies (${declaration!.dependsOn!.join(", ")})`} first`,
901
+ })
902
+ }
903
+ if (target?.data.tasks.some((item) => item.id === id))
904
+ return yield* new GraphMutationError({
905
+ message: `Epic '${target.id}' already lists task '${id}'`,
906
+ })
907
+ const writes: { path: string; content: string }[] = []
908
+ if (source) {
909
+ const data = {
910
+ ...source.data,
911
+ tasks: source.data.tasks.filter((item) => item.id !== id),
912
+ }
913
+ yield* assertDependencies(data.tasks, "Tasks")
914
+ writes.push({
915
+ path: source.path,
916
+ content: yield* contentWith(source, data),
917
+ })
918
+ }
919
+ if (target) {
920
+ const data = {
921
+ ...target.data,
922
+ tasks: [...target.data.tasks, { id }],
923
+ }
924
+ yield* assertDependencies(data.tasks, "Tasks")
925
+ writes.push({
926
+ path: target.path,
927
+ content: yield* contentWith(target, data),
928
+ })
929
+ }
930
+ const taskData = yield* decode(
931
+ TaskFrontmatter,
932
+ epicId
933
+ ? { ...task.data, epic: epicId }
934
+ : { ...task.data, epic: undefined },
935
+ "task metadata",
936
+ )
937
+ writes.push({
938
+ path: task.path,
939
+ content: yield* contentWith(task, taskData),
940
+ })
941
+ yield* applyWritePlan({ root, writes })
942
+ return result(
943
+ root,
944
+ "task.move",
945
+ "task",
946
+ id,
947
+ writes.map((write) => write.path),
948
+ )
949
+ }),
950
+ }),
951
+ },
952
+ ) {}