@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
@@ -0,0 +1,159 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join, resolve } from "node:path"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { WorkbaseService } from "./WorkbaseService"
6
+ import { RepositoryAlias } from "../workbase/schemas"
7
+
8
+ class RepositoryError extends Data.TaggedError("RepositoryError")<{
9
+ readonly message: string
10
+ readonly cause?: unknown
11
+ }> {}
12
+
13
+ interface RepositoryInfo {
14
+ readonly alias: string
15
+ readonly path: string
16
+ readonly kind: "bare" | "repository" | "symlink"
17
+ readonly remote: string | null
18
+ readonly target: string | null
19
+ }
20
+
21
+ const validateAlias = (alias: string) => {
22
+ const result = Schema.decodeUnknownEither(RepositoryAlias)(alias)
23
+ return Either.isLeft(result)
24
+ ? Effect.fail(
25
+ new RepositoryError({
26
+ message: `Invalid repository alias '${alias}': ${TreeFormatter.formatErrorSync(result.left)}`,
27
+ }),
28
+ )
29
+ : Effect.succeed(result.right)
30
+ }
31
+
32
+ export class RepositoryService extends Effect.Service<RepositoryService>()(
33
+ "RepositoryService",
34
+ {
35
+ sync: () => ({
36
+ add: (alias: string, remote: string, startPath: string = process.cwd()) =>
37
+ Effect.gen(function* () {
38
+ const fs = yield* FileSystemService
39
+ const workbase = yield* WorkbaseService
40
+ const validAlias = yield* validateAlias(alias)
41
+ const root = yield* workbase.discover(startPath)
42
+ const reposPath = join(root, "repos")
43
+ const destination = join(reposPath, validAlias)
44
+
45
+ if (!remote.trim()) {
46
+ return yield* new RepositoryError({
47
+ message: "Repository remote is required",
48
+ })
49
+ }
50
+ if (yield* fs.exists(destination)) {
51
+ return yield* new RepositoryError({
52
+ message: `Repository alias '${validAlias}' already exists`,
53
+ })
54
+ }
55
+
56
+ yield* fs.createDirectory(reposPath)
57
+ const result = yield* fs.runCommand(
58
+ ["git", "clone", "--bare", remote, destination],
59
+ { captureOutput: true },
60
+ )
61
+ if (result.exitCode !== 0) {
62
+ yield* fs.deleteDirectory(destination).pipe(Effect.ignore)
63
+ return yield* new RepositoryError({
64
+ message: `Failed to clone repository '${remote}': ${result.stderr.trim()}`,
65
+ })
66
+ }
67
+
68
+ return destination
69
+ }),
70
+
71
+ link: (
72
+ alias: string,
73
+ target: string,
74
+ startPath: string = process.cwd(),
75
+ ) =>
76
+ Effect.gen(function* () {
77
+ const fs = yield* FileSystemService
78
+ const workbase = yield* WorkbaseService
79
+ const validAlias = yield* validateAlias(alias)
80
+ const root = yield* workbase.discover(startPath)
81
+ const reposPath = join(root, "repos")
82
+ const destination = join(reposPath, validAlias)
83
+ const resolvedTarget = resolve(startPath, target)
84
+
85
+ if (yield* fs.exists(destination)) {
86
+ return yield* new RepositoryError({
87
+ message: `Repository alias '${validAlias}' already exists`,
88
+ })
89
+ }
90
+ if (!(yield* fs.isDirectory(resolvedTarget))) {
91
+ return yield* new RepositoryError({
92
+ message: `Repository path does not exist: ${resolvedTarget}`,
93
+ })
94
+ }
95
+
96
+ const result = yield* fs.runCommand(
97
+ ["git", "-C", resolvedTarget, "rev-parse", "--git-dir"],
98
+ { captureOutput: true },
99
+ )
100
+ if (result.exitCode !== 0) {
101
+ return yield* new RepositoryError({
102
+ message: `Path is not a Git repository: ${resolvedTarget}`,
103
+ })
104
+ }
105
+
106
+ yield* fs.createDirectory(reposPath)
107
+ yield* fs.createSymlink(resolvedTarget, destination)
108
+ return destination
109
+ }),
110
+
111
+ list: (startPath: string = process.cwd()) =>
112
+ Effect.gen(function* () {
113
+ const fs = yield* FileSystemService
114
+ const workbase = yield* WorkbaseService
115
+ const root = yield* workbase.discover(startPath)
116
+ const reposPath = join(root, "repos")
117
+
118
+ if (!(yield* fs.isDirectory(reposPath))) {
119
+ return [] as RepositoryInfo[]
120
+ }
121
+
122
+ const entries = (yield* fs.readDirectory(reposPath))
123
+ .filter((entry) => entry.isDirectory || entry.isSymlink)
124
+ .sort((a, b) => a.name.localeCompare(b.name))
125
+ const repositories: RepositoryInfo[] = []
126
+
127
+ for (const entry of entries) {
128
+ const path = join(reposPath, entry.name)
129
+ const target = entry.isSymlink
130
+ ? yield* fs.readSymlinkTarget(path)
131
+ : null
132
+ const bareResult = yield* fs.runCommand(
133
+ ["git", "-C", path, "rev-parse", "--is-bare-repository"],
134
+ { captureOutput: true },
135
+ )
136
+ const remoteResult = yield* fs.runCommand(
137
+ ["git", "-C", path, "remote", "get-url", "origin"],
138
+ { captureOutput: true },
139
+ )
140
+
141
+ repositories.push({
142
+ alias: entry.name,
143
+ path,
144
+ kind: entry.isSymlink
145
+ ? "symlink"
146
+ : bareResult.stdout.trim() === "true"
147
+ ? "bare"
148
+ : "repository",
149
+ remote:
150
+ remoteResult.exitCode === 0 ? remoteResult.stdout.trim() : null,
151
+ target,
152
+ })
153
+ }
154
+
155
+ return repositories
156
+ }),
157
+ }),
158
+ },
159
+ ) {}
@@ -0,0 +1,217 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { Effect } from "effect"
3
+ import { mkdir } from "node:fs/promises"
4
+ import { join } from "node:path"
5
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { EpicService } from "./EpicService"
7
+ import { TaskService } from "./TaskService"
8
+ import { PhaseService } from "./PhaseService"
9
+ import { PullRequestService } from "./PullRequestService"
10
+
11
+ describe("task and phase services", () => {
12
+ let root: string
13
+
14
+ beforeEach(async () => {
15
+ root = await createTempDir()
16
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
17
+ await mkdir(join(root, "repos/agency"), { recursive: true })
18
+ await mkdir(join(root, "repos/effect"), { recursive: true })
19
+ })
20
+
21
+ afterEach(async () => cleanupTempDir(root))
22
+
23
+ test("creates a single-phase task and updates its epic", async () => {
24
+ await runTestEffect(
25
+ EpicService.pipe(
26
+ Effect.flatMap((service) =>
27
+ service.create(
28
+ "example",
29
+ "https://example.com/epic",
30
+ [{ repo: "agency", ref: "main" }],
31
+ root,
32
+ ),
33
+ ),
34
+ ),
35
+ )
36
+ await runTestEffect(
37
+ TaskService.pipe(
38
+ Effect.flatMap((service) =>
39
+ service.create(
40
+ {
41
+ id: "task-one",
42
+ ticketUrl: "https://example.com/task",
43
+ epic: "example",
44
+ repo: "agency",
45
+ repos: [{ repo: "effect", ref: "main" }],
46
+ branch: "task/one",
47
+ base: "main",
48
+ },
49
+ root,
50
+ ),
51
+ ),
52
+ ),
53
+ )
54
+
55
+ const epic = await runTestEffect(
56
+ EpicService.pipe(
57
+ Effect.flatMap((service) => service.show("example", root)),
58
+ ),
59
+ )
60
+ expect(epic.data.tasks).toEqual([{ id: "task-one" }])
61
+ })
62
+
63
+ test("creates and sequences phases on a multi-phase task", async () => {
64
+ await runTestEffect(
65
+ TaskService.pipe(
66
+ Effect.flatMap((service) =>
67
+ service.create(
68
+ {
69
+ id: "multi",
70
+ ticketUrl: "https://example.com/task",
71
+ multiPhase: true,
72
+ },
73
+ root,
74
+ ),
75
+ ),
76
+ ),
77
+ )
78
+ for (const phase of [
79
+ { id: "first", dependsOn: undefined },
80
+ { id: "second", dependsOn: ["first"] },
81
+ ]) {
82
+ await runTestEffect(
83
+ PhaseService.pipe(
84
+ Effect.flatMap((service) =>
85
+ service.create(
86
+ {
87
+ taskId: "multi",
88
+ id: phase.id,
89
+ repo: "agency",
90
+ branch: `task/${phase.id}`,
91
+ base: "main",
92
+ dependsOn: phase.dependsOn,
93
+ },
94
+ root,
95
+ ),
96
+ ),
97
+ ),
98
+ )
99
+ }
100
+
101
+ const task = await runTestEffect(
102
+ TaskService.pipe(
103
+ Effect.flatMap((service) => service.show("multi", root)),
104
+ ),
105
+ )
106
+ expect("phases" in task.data && task.data.phases).toEqual([
107
+ { id: "first" },
108
+ { id: "second", dependsOn: ["first"] },
109
+ ])
110
+ const phases = await runTestEffect(
111
+ PhaseService.pipe(
112
+ Effect.flatMap((service) => service.list("multi", root)),
113
+ ),
114
+ )
115
+ expect(phases.map((phase) => phase.id)).toEqual(["first", "second"])
116
+ })
117
+
118
+ test("converts a single-phase task when the existing phase ID is provided", async () => {
119
+ await runTestEffect(
120
+ TaskService.pipe(
121
+ Effect.flatMap((service) =>
122
+ service.create(
123
+ {
124
+ id: "single",
125
+ ticketUrl: "https://example.com/task",
126
+ description: "Deliver the complete task.",
127
+ repo: "agency",
128
+ repos: [{ repo: "effect", ref: "main" }],
129
+ branch: "task/single",
130
+ base: "main",
131
+ },
132
+ root,
133
+ ),
134
+ ),
135
+ ),
136
+ )
137
+ await runTestEffect(
138
+ PullRequestService.pipe(
139
+ Effect.flatMap((service) =>
140
+ service.setUrl(
141
+ "single",
142
+ undefined,
143
+ "https://github.com/example/agency/pull/42",
144
+ root,
145
+ ),
146
+ ),
147
+ ),
148
+ )
149
+ await expect(
150
+ runTestEffect(
151
+ PhaseService.pipe(
152
+ Effect.flatMap((service) =>
153
+ service.create(
154
+ {
155
+ taskId: "single",
156
+ id: "extra",
157
+ repo: "agency",
158
+ branch: "task/extra",
159
+ base: "main",
160
+ },
161
+ root,
162
+ ),
163
+ ),
164
+ ),
165
+ ),
166
+ ).rejects.toThrow("requires --first-phase")
167
+
168
+ await runTestEffect(
169
+ PhaseService.pipe(
170
+ Effect.flatMap((service) =>
171
+ service.create(
172
+ {
173
+ taskId: "single",
174
+ id: "extra",
175
+ firstPhase: "implementation",
176
+ repo: "agency",
177
+ branch: "task/extra",
178
+ base: "main",
179
+ dependsOn: ["implementation"],
180
+ },
181
+ root,
182
+ ),
183
+ ),
184
+ ),
185
+ )
186
+
187
+ const task = await runTestEffect(
188
+ TaskService.pipe(
189
+ Effect.flatMap((service) => service.show("single", root)),
190
+ ),
191
+ )
192
+ expect(task.data).toEqual({
193
+ ticketUrl: "https://example.com/task",
194
+ description: "Deliver the complete task.",
195
+ phases: [
196
+ { id: "implementation" },
197
+ { id: "extra", dependsOn: ["implementation"] },
198
+ ],
199
+ })
200
+ expect(task.content).toContain("Describe the task outcome.")
201
+
202
+ const firstPhase = await runTestEffect(
203
+ PhaseService.pipe(
204
+ Effect.flatMap((service) =>
205
+ service.show("single", "implementation", root),
206
+ ),
207
+ ),
208
+ )
209
+ expect(firstPhase.data).toEqual({
210
+ repo: "agency",
211
+ repos: [{ repo: "effect", ref: "main" }],
212
+ branch: "task/single",
213
+ base: "main",
214
+ pr: "https://github.com/example/agency/pull/42",
215
+ })
216
+ })
217
+ })
@@ -0,0 +1,192 @@
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 { EpicService, type EpicRecord } from "./EpicService"
7
+ import {
8
+ EntityId,
9
+ TaskFrontmatter,
10
+ type RepositoryReference,
11
+ type TaskFrontmatter as TaskData,
12
+ } from "../workbase/schemas"
13
+ import {
14
+ formatMarkdownDocument,
15
+ parseFrontmatter,
16
+ } from "../workbase/frontmatter"
17
+
18
+ class TaskError extends Data.TaggedError("TaskError")<{
19
+ readonly message: string
20
+ }> {}
21
+
22
+ interface TaskRecord {
23
+ readonly id: string
24
+ readonly path: string
25
+ readonly content: string
26
+ readonly data: TaskData
27
+ }
28
+
29
+ export interface CreateTaskInput {
30
+ readonly id: string
31
+ readonly ticketUrl: string
32
+ readonly description?: string
33
+ readonly epic?: string
34
+ readonly multiPhase?: boolean
35
+ readonly repo?: string
36
+ readonly repos?: readonly RepositoryReference[]
37
+ readonly branch?: string
38
+ readonly base?: string
39
+ }
40
+
41
+ const decodeTask = (input: unknown) => {
42
+ const result = Schema.decodeUnknownEither(TaskFrontmatter, {
43
+ errors: "all",
44
+ onExcessProperty: "error",
45
+ })(input)
46
+ return Either.isLeft(result)
47
+ ? Effect.fail(
48
+ new TaskError({ message: TreeFormatter.formatErrorSync(result.left) }),
49
+ )
50
+ : Effect.succeed(result.right)
51
+ }
52
+
53
+ const decodeId = (id: string) => {
54
+ const result = Schema.decodeUnknownEither(EntityId)(id)
55
+ return Either.isLeft(result)
56
+ ? Effect.fail(new TaskError({ message: `Invalid task ID '${id}'` }))
57
+ : Effect.succeed(result.right)
58
+ }
59
+
60
+ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
61
+ sync: () => ({
62
+ create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
63
+ Effect.gen(function* () {
64
+ const fs = yield* FileSystemService
65
+ const workbase = yield* WorkbaseService
66
+ const epics = yield* EpicService
67
+ const root = yield* workbase.discover(startPath)
68
+ const id = yield* decodeId(input.id)
69
+ const directory = join(root, "tasks", id)
70
+ const path = join(directory, "TASK.md")
71
+
72
+ if (yield* fs.exists(directory)) {
73
+ return yield* new TaskError({
74
+ message: `Task '${id}' already exists`,
75
+ })
76
+ }
77
+
78
+ let data: TaskData
79
+ if (input.multiPhase) {
80
+ data = yield* decodeTask({
81
+ ticketUrl: input.ticketUrl,
82
+ ...(input.description !== undefined
83
+ ? { description: input.description }
84
+ : {}),
85
+ ...(input.epic ? { epic: input.epic } : {}),
86
+ phases: [],
87
+ })
88
+ } else {
89
+ if (!input.repo || !input.branch || !input.base) {
90
+ return yield* new TaskError({
91
+ message: "Single-phase tasks require repo, branch, and base",
92
+ })
93
+ }
94
+ data = yield* decodeTask({
95
+ ticketUrl: input.ticketUrl,
96
+ ...(input.description !== undefined
97
+ ? { description: input.description }
98
+ : {}),
99
+ ...(input.epic ? { epic: input.epic } : {}),
100
+ repo: input.repo,
101
+ ...(input.repos?.length ? { repos: input.repos } : {}),
102
+ branch: input.branch,
103
+ base: input.base,
104
+ pr: null,
105
+ })
106
+ }
107
+
108
+ const referencedRepos =
109
+ "repo" in data
110
+ ? [
111
+ data.repo,
112
+ ...(data.repos ?? []).map((reference) => reference.repo),
113
+ ]
114
+ : []
115
+ for (const alias of referencedRepos) {
116
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
117
+ return yield* new TaskError({
118
+ message: `Unknown repository alias '${alias}'`,
119
+ })
120
+ }
121
+ }
122
+
123
+ let parentEpic: EpicRecord | undefined
124
+ if (input.epic) {
125
+ parentEpic = yield* epics.show(input.epic, root)
126
+ }
127
+
128
+ const title = id
129
+ .split("-")
130
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
131
+ .join(" ")
132
+ const content = formatMarkdownDocument(
133
+ data,
134
+ `# ${title}\n\nDescribe the task outcome.`,
135
+ )
136
+ yield* fs.createDirectory(directory)
137
+ yield* fs.writeFile(path, content)
138
+
139
+ if (input.epic && parentEpic) {
140
+ const parsed = yield* parseFrontmatter(
141
+ parentEpic.content,
142
+ parentEpic.path,
143
+ )
144
+ const epicData = {
145
+ ...parentEpic.data,
146
+ tasks: [...parentEpic.data.tasks, { id }],
147
+ }
148
+ const updated = formatMarkdownDocument(epicData, parsed.body)
149
+ yield* fs.writeFile(parentEpic.path, updated)
150
+ }
151
+
152
+ return { id, path, content, data } satisfies TaskRecord
153
+ }),
154
+
155
+ list: (startPath: string = process.cwd()) =>
156
+ Effect.gen(function* () {
157
+ const fs = yield* FileSystemService
158
+ const workbase = yield* WorkbaseService
159
+ const root = yield* workbase.discover(startPath)
160
+ const directory = join(root, "tasks")
161
+ if (!(yield* fs.isDirectory(directory))) return [] as TaskRecord[]
162
+ const entries = (yield* fs.readDirectory(directory))
163
+ .filter((entry) => entry.isDirectory)
164
+ .sort((a, b) => a.name.localeCompare(b.name))
165
+ const records: TaskRecord[] = []
166
+ for (const entry of entries) {
167
+ const path = join(directory, entry.name, "TASK.md")
168
+ if (!(yield* fs.exists(path))) continue
169
+ const content = yield* fs.readFile(path)
170
+ const parsed = yield* parseFrontmatter(content, path)
171
+ const data = yield* decodeTask(parsed.data)
172
+ records.push({ id: entry.name, path, content, data })
173
+ }
174
+ return records
175
+ }),
176
+
177
+ show: (id: string, startPath: string = process.cwd()) =>
178
+ Effect.gen(function* () {
179
+ const service = yield* TaskService
180
+ const validId = yield* decodeId(id)
181
+ const record = (yield* service.list(startPath)).find(
182
+ (task) => task.id === validId,
183
+ )
184
+ if (!record) {
185
+ return yield* new TaskError({
186
+ message: `Task '${validId}' does not exist`,
187
+ })
188
+ }
189
+ return record
190
+ }),
191
+ }),
192
+ }) {}