@markjaquith/agency 1.11.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +326 -399
  24. package/src/commands/work.ts +153 -153
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -1,10 +1,13 @@
1
1
  import { Effect, Data, pipe } from "effect"
2
2
  import {
3
3
  mkdir,
4
- copyFile as fsCopyFile,
5
- unlink,
6
4
  lstat,
7
5
  readlink,
6
+ readdir,
7
+ realpath,
8
+ rename,
9
+ stat,
10
+ symlink,
8
11
  } from "node:fs/promises"
9
12
  import { spawnProcess } from "../utils/process"
10
13
 
@@ -26,8 +29,20 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
26
29
  exists: (path: string) =>
27
30
  Effect.tryPromise({
28
31
  try: async () => {
29
- const file = Bun.file(path)
30
- return await file.exists()
32
+ try {
33
+ await lstat(path)
34
+ return true
35
+ } catch (error) {
36
+ if (
37
+ typeof error === "object" &&
38
+ error !== null &&
39
+ "code" in error &&
40
+ error.code === "ENOENT"
41
+ ) {
42
+ return false
43
+ }
44
+ throw error
45
+ }
31
46
  },
32
47
  catch: () =>
33
48
  new FileSystemError({
@@ -38,16 +53,19 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
38
53
  isDirectory: (path: string) =>
39
54
  Effect.tryPromise({
40
55
  try: async () => {
41
- const file = Bun.file(path)
42
- const exists = await file.exists()
43
- if (!exists) {
44
- return false
56
+ try {
57
+ return (await stat(path)).isDirectory()
58
+ } catch (error) {
59
+ if (
60
+ typeof error === "object" &&
61
+ error !== null &&
62
+ "code" in error &&
63
+ error.code === "ENOENT"
64
+ ) {
65
+ return false
66
+ }
67
+ throw error
45
68
  }
46
- // Use stat to check if it's a directory
47
- const stat = await import("node:fs/promises").then((fs) =>
48
- fs.stat(path),
49
- )
50
- return stat.isDirectory()
51
69
  },
52
70
  catch: () =>
53
71
  new FileSystemError({
@@ -55,6 +73,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
55
73
  }),
56
74
  }),
57
75
 
76
+ realPath: (path: string) =>
77
+ Effect.tryPromise({
78
+ try: () => realpath(path),
79
+ catch: (error) =>
80
+ new FileSystemError({
81
+ message: `Failed to resolve path: ${path}`,
82
+ cause: error,
83
+ }),
84
+ }),
85
+
58
86
  readFile: (path: string) =>
59
87
  Effect.tryPromise({
60
88
  try: async () => {
@@ -78,19 +106,6 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
78
106
  }),
79
107
  }),
80
108
 
81
- readJSON: <T = unknown>(path: string) =>
82
- Effect.tryPromise({
83
- try: async (): Promise<T> => {
84
- const file = Bun.file(path)
85
- const exists = await file.exists()
86
- if (!exists) {
87
- throw new Error(`File not found: ${path}`)
88
- }
89
- return await file.json()
90
- },
91
- catch: () => new FileNotFoundError({ path }),
92
- }),
93
-
94
109
  writeJSON: <T = unknown>(path: string, data: T) =>
95
110
  Effect.tryPromise({
96
111
  try: () => Bun.write(path, JSON.stringify(data, null, 2) + "\n"),
@@ -111,22 +126,29 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
111
126
  }),
112
127
  }),
113
128
 
114
- deleteFile: (path: string) =>
129
+ moveDirectory: (from: string, to: string) =>
115
130
  Effect.tryPromise({
116
- try: () => unlink(path),
131
+ try: () => rename(from, to),
117
132
  catch: (error) =>
118
133
  new FileSystemError({
119
- message: `Failed to delete file: ${path}`,
134
+ message: `Failed to move directory from ${from} to ${to}`,
120
135
  cause: error,
121
136
  }),
122
137
  }),
123
138
 
124
- copyFile: (from: string, to: string) =>
139
+ readDirectory: (path: string) =>
125
140
  Effect.tryPromise({
126
- try: () => fsCopyFile(from, to),
141
+ try: async () => {
142
+ const entries = await readdir(path, { withFileTypes: true })
143
+ return entries.map((entry) => ({
144
+ name: entry.name,
145
+ isDirectory: entry.isDirectory(),
146
+ isSymlink: entry.isSymbolicLink(),
147
+ }))
148
+ },
127
149
  catch: (error) =>
128
150
  new FileSystemError({
129
- message: `Failed to copy file from ${from} to ${to}`,
151
+ message: `Failed to read directory: ${path}`,
130
152
  cause: error,
131
153
  }),
132
154
  }),
@@ -158,15 +180,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
158
180
  options?: {
159
181
  readonly cwd?: string
160
182
  readonly captureOutput?: boolean
161
- readonly interactive?: boolean
183
+ readonly env?: Record<string, string>
162
184
  },
163
185
  ) =>
164
186
  pipe(
165
187
  spawnProcess(args, {
166
188
  cwd: options?.cwd,
167
- stdin: options?.interactive ? "inherit" : "pipe",
189
+ stdin: "pipe",
168
190
  stdout: options?.captureOutput ? "pipe" : "inherit",
169
- stderr: options?.interactive ? "inherit" : "pipe",
191
+ stderr: "pipe",
192
+ env: options?.env,
170
193
  }),
171
194
  Effect.mapError(
172
195
  (processError) =>
@@ -177,70 +200,16 @@ export class FileSystemService extends Effect.Service<FileSystemService>()(
177
200
  ),
178
201
  ),
179
202
 
180
- /**
181
- * Recursively collect all files in a directory.
182
- * Returns paths relative to the directory (or relativeTo if specified).
183
- */
184
- collectFiles: (
185
- dirPath: string,
186
- options?: {
187
- readonly relativeTo?: string
188
- readonly exclude?: readonly string[]
189
- readonly sort?: boolean
190
- },
191
- ) =>
203
+ createSymlink: (target: string, path: string) =>
192
204
  Effect.tryPromise({
193
- try: async () => {
194
- const { relativeTo, exclude = [], sort = false } = options ?? {}
195
- const basePath = relativeTo ?? dirPath
196
-
197
- // Build find command with exclusions
198
- const findArgs = ["find", dirPath, "-type", "f"]
199
- for (const pattern of exclude) {
200
- findArgs.push("!", "-name", pattern)
201
- }
202
-
203
- const result = Bun.spawnSync(findArgs, {
204
- stdout: "pipe",
205
- stderr: "ignore",
206
- })
207
-
208
- const output = new TextDecoder().decode(result.stdout)
209
- if (!output) {
210
- return []
211
- }
212
-
213
- const files = output
214
- .trim()
215
- .split("\n")
216
- .filter((f: string) => f.length > 0)
217
- .map((file) => file.replace(basePath + "/", ""))
218
- .filter((f) => f.length > 0)
219
-
220
- return sort ? files.sort() : files
221
- },
205
+ try: () => symlink(target, path, "dir"),
222
206
  catch: (error) =>
223
207
  new FileSystemError({
224
- message: `Failed to collect files from ${dirPath}`,
208
+ message: `Failed to create symlink ${path} -> ${target}`,
225
209
  cause: error,
226
210
  }),
227
211
  }),
228
212
 
229
- /**
230
- * Check if a path is a symbolic link.
231
- * Returns false if the file doesn't exist or if it's not a symlink.
232
- */
233
- isSymlink: (path: string) =>
234
- Effect.tryPromise({
235
- try: async () => {
236
- const stats = await lstat(path)
237
- return stats.isSymbolicLink()
238
- },
239
- catch: () =>
240
- // If we can't lstat, it's not a symlink (or doesn't exist)
241
- false,
242
- }).pipe(Effect.catchAll(() => Effect.succeed(false))),
243
-
244
213
  /**
245
214
  * Read the target of a symbolic link.
246
215
  * Returns null if the file is not a symlink or doesn't exist.
@@ -0,0 +1,323 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join } from "node:path"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { WorkbaseService } from "./WorkbaseService"
6
+ import { TaskService } from "./TaskService"
7
+ import {
8
+ EntityId,
9
+ PhaseFrontmatter,
10
+ type PhaseFrontmatter as PhaseData,
11
+ type RepositoryReference,
12
+ } from "../workbase/schemas"
13
+ import {
14
+ formatMarkdownDocument,
15
+ parseFrontmatter,
16
+ } from "../workbase/frontmatter"
17
+
18
+ class PhaseError extends Data.TaggedError("PhaseError")<{
19
+ readonly message: string
20
+ }> {}
21
+
22
+ interface PhaseRecord {
23
+ readonly taskId: string
24
+ readonly id: string
25
+ readonly path: string
26
+ readonly content: string
27
+ readonly data: PhaseData
28
+ }
29
+
30
+ export interface CreatePhaseInput {
31
+ readonly taskId: string
32
+ readonly id: string
33
+ readonly description?: string
34
+ readonly repo: string
35
+ readonly repos?: readonly RepositoryReference[]
36
+ readonly branch: string
37
+ readonly base: string
38
+ readonly dependsOn?: readonly string[]
39
+ readonly firstPhase?: string
40
+ }
41
+
42
+ const decodeId = (id: string, label: string) => {
43
+ const result = Schema.decodeUnknownEither(EntityId)(id)
44
+ return Either.isLeft(result)
45
+ ? Effect.fail(new PhaseError({ message: `Invalid ${label} ID '${id}'` }))
46
+ : Effect.succeed(result.right)
47
+ }
48
+
49
+ const decodePhase = (input: unknown) => {
50
+ const result = Schema.decodeUnknownEither(PhaseFrontmatter, {
51
+ errors: "all",
52
+ onExcessProperty: "error",
53
+ })(input)
54
+ return Either.isLeft(result)
55
+ ? Effect.fail(
56
+ new PhaseError({ message: TreeFormatter.formatErrorSync(result.left) }),
57
+ )
58
+ : Effect.succeed(result.right)
59
+ }
60
+
61
+ export class PhaseService extends Effect.Service<PhaseService>()(
62
+ "PhaseService",
63
+ {
64
+ sync: () => ({
65
+ create: (input: CreatePhaseInput, startPath: string = process.cwd()) =>
66
+ Effect.gen(function* () {
67
+ const fs = yield* FileSystemService
68
+ const workbase = yield* WorkbaseService
69
+ const tasks = yield* TaskService
70
+ const root = yield* workbase.discover(startPath)
71
+ const taskId = yield* decodeId(input.taskId, "task")
72
+ const id = yield* decodeId(input.id, "phase")
73
+ const task = yield* tasks.show(taskId, root)
74
+ const isMultiPhase = "phases" in task.data
75
+ let firstPhaseId: string | undefined
76
+ if (!isMultiPhase) {
77
+ if (!input.firstPhase) {
78
+ return yield* new PhaseError({
79
+ message:
80
+ "Converting a single-phase task requires --first-phase <id>",
81
+ })
82
+ }
83
+ firstPhaseId = yield* decodeId(input.firstPhase, "first phase")
84
+ if (firstPhaseId === id) {
85
+ return yield* new PhaseError({
86
+ message: "The existing and new phase IDs must be different",
87
+ })
88
+ }
89
+ } else if (input.firstPhase) {
90
+ return yield* new PhaseError({
91
+ message:
92
+ "--first-phase is only valid when converting a single-phase task",
93
+ })
94
+ }
95
+
96
+ if (
97
+ isMultiPhase &&
98
+ task.data.phases.some((phase) => phase.id === id)
99
+ ) {
100
+ return yield* new PhaseError({
101
+ message: `Phase '${id}' already exists on task '${taskId}'`,
102
+ })
103
+ }
104
+ const knownPhases = new Set(
105
+ isMultiPhase
106
+ ? task.data.phases.map((phase) => phase.id)
107
+ : [firstPhaseId!],
108
+ )
109
+ for (const dependency of input.dependsOn ?? []) {
110
+ if (!knownPhases.has(dependency)) {
111
+ return yield* new PhaseError({
112
+ message: `Unknown phase dependency '${dependency}'`,
113
+ })
114
+ }
115
+ }
116
+
117
+ const data = yield* decodePhase({
118
+ ...(input.description !== undefined
119
+ ? { description: input.description }
120
+ : {}),
121
+ repo: input.repo,
122
+ ...(input.repos?.length ? { repos: input.repos } : {}),
123
+ branch: input.branch,
124
+ base: input.base,
125
+ pr: null,
126
+ })
127
+ const existingExecution = isMultiPhase ? undefined : task.data
128
+ const aliases = new Set([
129
+ data.repo,
130
+ ...(data.repos ?? []).map((reference) => reference.repo),
131
+ ...(existingExecution
132
+ ? [
133
+ existingExecution.repo,
134
+ ...(existingExecution.repos ?? []).map(
135
+ (reference) => reference.repo,
136
+ ),
137
+ ]
138
+ : []),
139
+ ])
140
+ for (const alias of aliases) {
141
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
142
+ return yield* new PhaseError({
143
+ message: `Unknown repository alias '${alias}'`,
144
+ })
145
+ }
146
+ }
147
+
148
+ const directory = join(root, "tasks", taskId, "phases", id)
149
+ const path = join(directory, "PHASE.md")
150
+ if (yield* fs.exists(directory)) {
151
+ return yield* new PhaseError({
152
+ message: `Phase directory already exists: ${id}`,
153
+ })
154
+ }
155
+
156
+ const parsedTask = yield* parseFrontmatter(task.content, task.path)
157
+ const title = id
158
+ .split("-")
159
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
160
+ .join(" ")
161
+ const content = formatMarkdownDocument(
162
+ data,
163
+ `# ${title}\n\nDescribe the phase outcome.`,
164
+ )
165
+
166
+ if (!isMultiPhase) {
167
+ const firstDirectory = join(
168
+ root,
169
+ "tasks",
170
+ taskId,
171
+ "phases",
172
+ firstPhaseId!,
173
+ )
174
+ if (yield* fs.exists(firstDirectory)) {
175
+ return yield* new PhaseError({
176
+ message: `Phase directory already exists: ${firstPhaseId}`,
177
+ })
178
+ }
179
+ const firstData = yield* decodePhase({
180
+ repo: task.data.repo,
181
+ ...(task.data.repos?.length ? { repos: task.data.repos } : {}),
182
+ branch: task.data.branch,
183
+ base: task.data.base,
184
+ pr: task.data.pr,
185
+ })
186
+ const firstTitle = firstPhaseId!
187
+ .split("-")
188
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
189
+ .join(" ")
190
+
191
+ yield* fs.createDirectory(firstDirectory)
192
+ yield* fs.createDirectory(directory)
193
+ yield* fs.writeFile(
194
+ join(firstDirectory, "PHASE.md"),
195
+ formatMarkdownDocument(
196
+ firstData,
197
+ `# ${firstTitle}\n\nDescribe the phase outcome.`,
198
+ ),
199
+ )
200
+ yield* fs.writeFile(path, content)
201
+
202
+ const oldCodePath = join(root, "tasks", taskId, "code")
203
+ if (yield* fs.isDirectory(oldCodePath)) {
204
+ const firstCodePath = join(firstDirectory, "code")
205
+ yield* fs.moveDirectory(oldCodePath, firstCodePath)
206
+ for (const alias of [
207
+ firstData.repo,
208
+ ...(firstData.repos ?? []).map((reference) => reference.repo),
209
+ ]) {
210
+ const checkoutPath = join(firstCodePath, alias)
211
+ if (!(yield* fs.isDirectory(checkoutPath))) continue
212
+ const repair = yield* fs.runCommand(
213
+ [
214
+ "git",
215
+ "-C",
216
+ join(root, "repos", alias),
217
+ "worktree",
218
+ "repair",
219
+ checkoutPath,
220
+ ],
221
+ { captureOutput: true },
222
+ )
223
+ if (repair.exitCode !== 0) {
224
+ return yield* new PhaseError({
225
+ message: `Failed to repair moved worktree for '${alias}': ${repair.stderr}`,
226
+ })
227
+ }
228
+ }
229
+ }
230
+
231
+ const convertedTaskData = {
232
+ ticketUrl: task.data.ticketUrl,
233
+ ...(task.data.description
234
+ ? { description: task.data.description }
235
+ : {}),
236
+ ...(task.data.epic ? { epic: task.data.epic } : {}),
237
+ phases: [
238
+ { id: firstPhaseId! },
239
+ {
240
+ id,
241
+ ...(input.dependsOn?.length
242
+ ? { dependsOn: input.dependsOn }
243
+ : {}),
244
+ },
245
+ ],
246
+ }
247
+ yield* fs.writeFile(
248
+ task.path,
249
+ formatMarkdownDocument(convertedTaskData, parsedTask.body),
250
+ )
251
+ return { taskId, id, path, content, data } satisfies PhaseRecord
252
+ }
253
+
254
+ yield* fs.createDirectory(directory)
255
+ yield* fs.writeFile(path, content)
256
+ const updatedTaskData = {
257
+ ...task.data,
258
+ phases: [
259
+ ...task.data.phases,
260
+ {
261
+ id,
262
+ ...(input.dependsOn?.length
263
+ ? { dependsOn: input.dependsOn }
264
+ : {}),
265
+ },
266
+ ],
267
+ }
268
+ yield* fs.writeFile(
269
+ task.path,
270
+ formatMarkdownDocument(updatedTaskData, parsedTask.body),
271
+ )
272
+
273
+ return { taskId, id, path, content, data } satisfies PhaseRecord
274
+ }),
275
+
276
+ list: (taskId: string, startPath: string = process.cwd()) =>
277
+ Effect.gen(function* () {
278
+ const fs = yield* FileSystemService
279
+ const workbase = yield* WorkbaseService
280
+ const tasks = yield* TaskService
281
+ const root = yield* workbase.discover(startPath)
282
+ const validTaskId = yield* decodeId(taskId, "task")
283
+ yield* tasks.show(validTaskId, root)
284
+ const directory = join(root, "tasks", validTaskId, "phases")
285
+ if (!(yield* fs.isDirectory(directory))) return [] as PhaseRecord[]
286
+ const entries = (yield* fs.readDirectory(directory))
287
+ .filter((entry) => entry.isDirectory)
288
+ .sort((a, b) => a.name.localeCompare(b.name))
289
+ const records: PhaseRecord[] = []
290
+ for (const entry of entries) {
291
+ const path = join(directory, entry.name, "PHASE.md")
292
+ if (!(yield* fs.exists(path))) continue
293
+ const content = yield* fs.readFile(path)
294
+ const parsed = yield* parseFrontmatter(content, path)
295
+ const data = yield* decodePhase(parsed.data)
296
+ records.push({
297
+ taskId: validTaskId,
298
+ id: entry.name,
299
+ path,
300
+ content,
301
+ data,
302
+ })
303
+ }
304
+ return records
305
+ }),
306
+
307
+ show: (taskId: string, id: string, startPath: string = process.cwd()) =>
308
+ Effect.gen(function* () {
309
+ const service = yield* PhaseService
310
+ const validId = yield* decodeId(id, "phase")
311
+ const record = (yield* service.list(taskId, startPath)).find(
312
+ (phase) => phase.id === validId,
313
+ )
314
+ if (!record) {
315
+ return yield* new PhaseError({
316
+ message: `Phase '${validId}' does not exist on task '${taskId}'`,
317
+ })
318
+ }
319
+ return record
320
+ }),
321
+ }),
322
+ },
323
+ ) {}