@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,193 +1,193 @@
1
- import { join } from "node:path"
2
1
  import { Effect } from "effect"
2
+ import { dirname, isAbsolute, relative, resolve, sep } from "node:path"
3
3
  import type { BaseCommandOptions } from "../utils/command"
4
+ import { WorktreeService } from "../services/WorktreeService"
4
5
  import { FileSystemService } from "../services/FileSystemService"
5
- import { createLoggers, ensureGitRepo } from "../utils/effect"
6
- import { spawnProcess } from "../utils/process"
6
+ import { WorkbaseService } from "../services/WorkbaseService"
7
+ import { EpicService } from "../services/EpicService"
8
+ import { TaskService } from "../services/TaskService"
9
+ import { PhaseService } from "../services/PhaseService"
10
+ import { createLoggers } from "../utils/effect"
7
11
  import { execvp } from "../utils/exec"
12
+ import {
13
+ buildWorkTargetChoices,
14
+ pickWorkTarget,
15
+ type PickWorkTarget,
16
+ type WorkTarget,
17
+ } from "../workbase/work-target"
8
18
 
9
19
  interface WorkOptions extends BaseCommandOptions {
10
- /**
11
- * Force use of OpenCode CLI
12
- */
13
- opencode?: boolean
14
- /**
15
- * Force use of Claude Code CLI
16
- */
17
- claude?: boolean
18
- /**
19
- * Additional arguments to pass to the CLI tool
20
- */
21
- extraArgs?: string[]
22
- /**
23
- * Internal option to disable exec for testing.
24
- * When true, uses spawn instead of exec so tests can complete.
25
- */
26
- _noExec?: boolean
20
+ readonly taskId?: string
21
+ readonly phaseId?: string
22
+ readonly epicId?: string
23
+ readonly opencode?: boolean
24
+ readonly claude?: boolean
27
25
  }
28
26
 
29
- export const work = (options: WorkOptions = {}) =>
30
- Effect.gen(function* () {
31
- const { verboseLog } = createLoggers(options)
32
-
33
- const fs = yield* FileSystemService
27
+ type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
34
28
 
35
- const gitRoot = yield* ensureGitRepo()
29
+ const launchAgent: LaunchAgent = (cli, args, cwd) => {
30
+ process.chdir(cwd)
31
+ execvp(cli, [...args])
32
+ }
36
33
 
37
- // Check if TASK.md exists
38
- const taskPath = join(gitRoot, "TASK.md")
39
- const taskExists = yield* fs.exists(taskPath)
40
- if (!taskExists) {
34
+ export const work = (
35
+ options: WorkOptions = {},
36
+ launch: LaunchAgent = launchAgent,
37
+ pick: PickWorkTarget = pickWorkTarget,
38
+ ) =>
39
+ Effect.gen(function* () {
40
+ if (options.opencode && options.claude) {
41
41
  return yield* Effect.fail(
42
- new Error("TASK.md not found. Run 'agency task' first to create it."),
42
+ new Error("Cannot use both --opencode and --claude"),
43
43
  )
44
44
  }
45
-
46
- verboseLog(`Found TASK.md at: ${taskPath}`)
47
-
48
- // Change to git root before executing
49
- process.chdir(gitRoot)
50
-
51
- // Check for conflicting flags
52
- if (options.opencode && options.claude) {
45
+ if (options.epicId && (options.taskId || options.phaseId)) {
53
46
  return yield* Effect.fail(
54
- new Error(
55
- "Cannot use both --opencode and --claude flags together. Choose one.",
56
- ),
47
+ new Error("Cannot combine --epic with a task or phase ID"),
57
48
  )
58
49
  }
59
50
 
60
- // Check which CLI tool is available
61
- const hasOpencode = yield* Effect.tryPromise({
62
- try: async () => {
63
- const result = Bun.spawnSync(["which", "opencode"], {
64
- stdout: "ignore",
65
- stderr: "ignore",
66
- })
67
- return result.exitCode === 0
68
- },
69
- catch: () => false,
70
- })
71
-
72
- const hasClaude = yield* Effect.tryPromise({
73
- try: async () => {
74
- const result = Bun.spawnSync(["which", "claude"], {
75
- stdout: "ignore",
76
- stderr: "ignore",
77
- })
78
- return result.exitCode === 0
79
- },
80
- catch: () => false,
81
- })
51
+ const worktrees = yield* WorktreeService
52
+ const fs = yield* FileSystemService
53
+ const workbase = yield* WorkbaseService
54
+ const epics = yield* EpicService
55
+ const tasks = yield* TaskService
56
+ const phases = yield* PhaseService
57
+ const { log, verboseLog } = createLoggers(options)
58
+ const cwd = options.cwd ?? process.cwd()
59
+ const root = yield* workbase.discover(cwd)
60
+
61
+ let target: WorkTarget | null = null
62
+ if (options.epicId) {
63
+ const epic = yield* epics.show(options.epicId, root)
64
+ target = { kind: "epic", epicId: epic.id, path: epic.path }
65
+ } else if (options.taskId) {
66
+ const task = yield* tasks.show(options.taskId, root)
67
+ if (options.phaseId) {
68
+ const phase = yield* phases.show(task.id, options.phaseId, root)
69
+ target = {
70
+ kind: "phase",
71
+ taskId: task.id,
72
+ phaseId: phase.id,
73
+ path: phase.path,
74
+ }
75
+ } else {
76
+ target = {
77
+ kind: "task",
78
+ taskId: task.id,
79
+ path: task.path,
80
+ multiPhase: "phases" in task.data,
81
+ }
82
+ }
83
+ } else {
84
+ const path = relative(root, resolve(cwd))
85
+ const parts =
86
+ !path || isAbsolute(path) || path.startsWith(`..${sep}`)
87
+ ? []
88
+ : path.split(sep)
89
+ if (parts[0] === "epics" && parts[1]) {
90
+ const epic = yield* epics.show(parts[1], root)
91
+ target = { kind: "epic", epicId: epic.id, path: epic.path }
92
+ } else if (parts[0] === "tasks" && parts[1]) {
93
+ const task = yield* tasks.show(parts[1], root)
94
+ if (parts[2] === "phases" && parts[3]) {
95
+ const phase = yield* phases.show(task.id, parts[3], root)
96
+ target = {
97
+ kind: "phase",
98
+ taskId: task.id,
99
+ phaseId: phase.id,
100
+ path: phase.path,
101
+ }
102
+ } else {
103
+ target = {
104
+ kind: "task",
105
+ taskId: task.id,
106
+ path: task.path,
107
+ multiPhase: "phases" in task.data,
108
+ }
109
+ }
110
+ }
111
+ }
82
112
 
83
- // Determine which CLI to use based on flags or auto-detection
84
- let useOpencode: boolean
85
- if (options.opencode) {
86
- if (!hasOpencode) {
87
- return yield* Effect.fail(
88
- new Error(
89
- "opencode CLI tool not found. Please install OpenCode or remove the --opencode flag.",
90
- ),
91
- )
113
+ if (!target) {
114
+ const epicRecords = yield* epics.list(root)
115
+ const taskRecords = yield* tasks.list(root)
116
+ const phaseRecords = []
117
+ for (const task of taskRecords) {
118
+ if ("phases" in task.data) {
119
+ phaseRecords.push(...(yield* phases.list(task.id, root)))
120
+ }
92
121
  }
93
- useOpencode = true
94
- verboseLog("Using opencode (explicitly requested)")
95
- } else if (options.claude) {
96
- if (!hasClaude) {
122
+ const choices = buildWorkTargetChoices(
123
+ epicRecords,
124
+ taskRecords,
125
+ phaseRecords,
126
+ )
127
+ if (choices.length === 0) {
97
128
  return yield* Effect.fail(
98
- new Error(
99
- "claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
100
- ),
129
+ new Error("No epics, tasks, or phases found in this workbase"),
101
130
  )
102
131
  }
103
- useOpencode = false
104
- verboseLog("Using claude (explicitly requested)")
105
- } else {
106
- // Auto-detect
107
- if (!hasOpencode && !hasClaude) {
132
+ const fzf = yield* fs.runCommand(["which", "fzf"], {
133
+ captureOutput: true,
134
+ })
135
+ if (fzf.exitCode !== 0) {
136
+ for (const choice of choices) log(choice.label)
108
137
  return yield* Effect.fail(
109
138
  new Error(
110
- "Neither opencode nor claude CLI tool found. Please install OpenCode or Claude Code.",
139
+ "fzf is required to select a work target; install fzf or provide a target explicitly",
111
140
  ),
112
141
  )
113
142
  }
114
- useOpencode = hasOpencode
115
- verboseLog(`Using ${useOpencode ? "opencode" : "claude"} (auto-detected)`)
143
+ target = yield* pick(choices)
144
+ if (!target) return
116
145
  }
117
146
 
118
- const cliName = useOpencode ? "opencode" : "claude"
119
- const baseArgs = useOpencode
120
- ? [cliName, "--prompt", "Start the task"]
121
- : [cliName, "Start the task"]
122
-
123
- // Append extra args if provided
124
- const cliArgs =
125
- options.extraArgs && options.extraArgs.length > 0
126
- ? [...baseArgs, ...options.extraArgs]
127
- : baseArgs
128
-
129
- if (options.extraArgs && options.extraArgs.length > 0) {
130
- verboseLog(
131
- `Running ${cliName} with extra args: ${options.extraArgs.join(" ")}`,
132
- )
147
+ let prompt: string
148
+ let launchPath: string
149
+ if (target.kind === "epic") {
150
+ prompt = `Work on the epic. Read ${target.path}.`
151
+ launchPath = dirname(target.path)
152
+ } else if (target.kind === "task" && target.multiPhase) {
153
+ prompt = `Work on the task. Read ${target.path}.`
154
+ launchPath = dirname(target.path)
133
155
  } else {
134
- verboseLog(`Running ${cliName} with task prompt...`)
156
+ const taskId = target.taskId
157
+ const phaseId = target.kind === "phase" ? target.phaseId : undefined
158
+ const workspace = yield* worktrees.materialize(taskId, phaseId, root)
159
+ prompt = workspace.phasePath
160
+ ? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
161
+ : `Start the task. Read ${workspace.taskPath}.`
162
+ launchPath = workspace.writablePath
135
163
  }
136
164
 
137
- // For testing, we need to use spawn instead of exec since exec never returns
138
- if (options._noExec) {
139
- const result = yield* spawnProcess(cliArgs, {
140
- cwd: gitRoot,
141
- stdout: "inherit",
142
- stderr: "inherit",
143
- }).pipe(
144
- Effect.catchAll((error) =>
145
- Effect.fail(
146
- new Error(`${cliName} exited with code ${error.exitCode}`),
147
- ),
148
- ),
149
- )
150
-
151
- if (result.exitCode !== 0) {
152
- return yield* Effect.fail(
153
- new Error(`${cliName} exited with code ${result.exitCode}`),
154
- )
155
- }
156
- } else {
157
- // Use execvp to replace the current process with the CLI tool
158
- // This will never return - the process is completely replaced
159
- execvp(cliName, cliArgs)
165
+ const requested = options.claude ? "claude" : "opencode"
166
+ let cli = requested
167
+ let available = yield* fs.runCommand(["which", cli], {
168
+ captureOutput: true,
169
+ })
170
+ if (available.exitCode !== 0 && !options.opencode && !options.claude) {
171
+ cli = "claude"
172
+ available = yield* fs.runCommand(["which", cli], { captureOutput: true })
173
+ }
174
+ if (available.exitCode !== 0) {
175
+ return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
160
176
  }
177
+
178
+ verboseLog(`Launching ${cli} in ${launchPath}`)
179
+ const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
180
+ launch(cli, [cli, ...args], launchPath)
161
181
  })
162
182
 
163
183
  export const help = `
164
- Usage: agency work [options] [-- extra-args...]
165
-
166
- Start working on the task described in TASK.md using OpenCode or Claude Code.
184
+ Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
167
185
 
168
- This command replaces the current process with OpenCode (if available) or
169
- Claude Code (if OpenCode is not available), launching it with a prompt to
170
- get started on the task described in your TASK.md file.
186
+ Launch an agent for the current epic, task, or phase. Outside an entity
187
+ directory, select one with fzf.
171
188
 
172
189
  Options:
173
- --opencode Force use of OpenCode CLI
174
- --claude Force use of Claude Code CLI
175
-
176
- Pass-through Arguments:
177
- Use -- to pass additional arguments to the underlying CLI tool.
178
- Everything after -- will be forwarded to opencode or claude.
179
-
180
- Examples:
181
- agency work # Auto-detect (prefers opencode)
182
- agency work --opencode # Explicitly use OpenCode
183
- agency work --claude # Explicitly use Claude Code
184
- agency work -- --model claude-sonnet-4-20250514 # Pass custom args to CLI
185
-
186
- Notes:
187
- - Requires TASK.md to exist (run 'agency task' first)
188
- - Requires either opencode or claude to be installed and available in PATH
189
- - By default, prefers opencode if both are available
190
- - Use --opencode or --claude to override auto-detection
191
- - Arguments after -- are passed directly to the underlying tool
192
- - Replaces the current process (agency exits and the tool takes over)
190
+ --epic <id> Work on an epic
191
+ --opencode Require OpenCode
192
+ --claude Require Claude Code
193
193
  `
@@ -0,0 +1,71 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
+ import { mkdir } from "node:fs/promises"
3
+ import { join } from "node:path"
4
+ import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
5
+ import { Effect } from "effect"
6
+ import { EpicService } from "./EpicService"
7
+
8
+ describe("EpicService", () => {
9
+ let root: string
10
+
11
+ beforeEach(async () => {
12
+ root = await createTempDir()
13
+ await Bun.write(join(root, "agency.json"), '{"version":2}\n')
14
+ await mkdir(join(root, "repos/agency"), { recursive: true })
15
+ })
16
+
17
+ afterEach(async () => {
18
+ await cleanupTempDir(root)
19
+ })
20
+
21
+ test("creates, lists, and shows epics", async () => {
22
+ const created = await runTestEffect(
23
+ EpicService.pipe(
24
+ Effect.flatMap((service) =>
25
+ service.create(
26
+ "workspace-orchestration",
27
+ "https://example.com/tickets/epic",
28
+ [{ repo: "agency", ref: "main" }],
29
+ root,
30
+ ),
31
+ ),
32
+ ),
33
+ )
34
+
35
+ expect(created.content).toContain("ticketUrl:")
36
+ expect(created.content).toContain("tasks: []")
37
+
38
+ const records = await runTestEffect(
39
+ EpicService.pipe(Effect.flatMap((service) => service.list(root))),
40
+ )
41
+ expect(records.map((record) => record.id)).toEqual([
42
+ "workspace-orchestration",
43
+ ])
44
+
45
+ const shown = await runTestEffect(
46
+ EpicService.pipe(
47
+ Effect.flatMap((service) =>
48
+ service.show("workspace-orchestration", root),
49
+ ),
50
+ ),
51
+ )
52
+ expect(shown.path).toBe(join(root, "epics/workspace-orchestration/EPIC.md"))
53
+ })
54
+
55
+ test("rejects unknown repository aliases", async () => {
56
+ await expect(
57
+ runTestEffect(
58
+ EpicService.pipe(
59
+ Effect.flatMap((service) =>
60
+ service.create(
61
+ "example",
62
+ "https://example.com/tickets/epic",
63
+ [{ repo: "missing", ref: "main" }],
64
+ root,
65
+ ),
66
+ ),
67
+ ),
68
+ ),
69
+ ).rejects.toThrow("Unknown repository alias 'missing'")
70
+ })
71
+ })
@@ -0,0 +1,135 @@
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 {
7
+ EpicFrontmatter,
8
+ EntityId,
9
+ type RepositoryReference,
10
+ } from "../workbase/schemas"
11
+ import {
12
+ formatMarkdownDocument,
13
+ parseFrontmatter,
14
+ } from "../workbase/frontmatter"
15
+
16
+ class EpicError extends Data.TaggedError("EpicError")<{
17
+ readonly message: string
18
+ }> {}
19
+
20
+ export interface EpicRecord {
21
+ readonly id: string
22
+ readonly path: string
23
+ readonly content: string
24
+ readonly data: Schema.Schema.Type<typeof EpicFrontmatter>
25
+ }
26
+
27
+ const decodeEpic = (input: unknown) => {
28
+ const result = Schema.decodeUnknownEither(EpicFrontmatter, {
29
+ errors: "all",
30
+ onExcessProperty: "error",
31
+ })(input)
32
+ return Either.isLeft(result)
33
+ ? Effect.fail(
34
+ new EpicError({ message: TreeFormatter.formatErrorSync(result.left) }),
35
+ )
36
+ : Effect.succeed(result.right)
37
+ }
38
+
39
+ const decodeId = (id: string) => {
40
+ const result = Schema.decodeUnknownEither(EntityId)(id)
41
+ return Either.isLeft(result)
42
+ ? Effect.fail(new EpicError({ message: `Invalid epic ID '${id}'` }))
43
+ : Effect.succeed(result.right)
44
+ }
45
+
46
+ export class EpicService extends Effect.Service<EpicService>()("EpicService", {
47
+ sync: () => ({
48
+ create: (
49
+ id: string,
50
+ ticketUrl: string,
51
+ repos: readonly RepositoryReference[],
52
+ startPath: string = process.cwd(),
53
+ description?: string,
54
+ ) =>
55
+ Effect.gen(function* () {
56
+ const fs = yield* FileSystemService
57
+ const workbase = yield* WorkbaseService
58
+ const root = yield* workbase.discover(startPath)
59
+ const validId = yield* decodeId(id)
60
+ const data = yield* decodeEpic({
61
+ ticketUrl,
62
+ ...(description !== undefined ? { description } : {}),
63
+ repos,
64
+ tasks: [],
65
+ })
66
+ const directory = join(root, "epics", validId)
67
+ const path = join(directory, "EPIC.md")
68
+
69
+ if (yield* fs.exists(directory)) {
70
+ return yield* new EpicError({
71
+ message: `Epic '${validId}' already exists`,
72
+ })
73
+ }
74
+
75
+ for (const { repo: alias } of data.repos) {
76
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
77
+ return yield* new EpicError({
78
+ message: `Unknown repository alias '${alias}'`,
79
+ })
80
+ }
81
+ }
82
+
83
+ yield* fs.createDirectory(directory)
84
+ const title = validId
85
+ .split("-")
86
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
87
+ .join(" ")
88
+ const content = formatMarkdownDocument(
89
+ data,
90
+ `# ${title}\n\nDescribe the epic outcome.`,
91
+ )
92
+ yield* fs.writeFile(path, content)
93
+ return { id: validId, path, content, data } satisfies EpicRecord
94
+ }),
95
+
96
+ list: (startPath: string = process.cwd()) =>
97
+ Effect.gen(function* () {
98
+ const fs = yield* FileSystemService
99
+ const workbase = yield* WorkbaseService
100
+ const root = yield* workbase.discover(startPath)
101
+ const directory = join(root, "epics")
102
+ if (!(yield* fs.isDirectory(directory))) {
103
+ return [] as EpicRecord[]
104
+ }
105
+
106
+ const entries = (yield* fs.readDirectory(directory))
107
+ .filter((entry) => entry.isDirectory)
108
+ .sort((a, b) => a.name.localeCompare(b.name))
109
+ const records: EpicRecord[] = []
110
+ for (const entry of entries) {
111
+ const path = join(directory, entry.name, "EPIC.md")
112
+ if (!(yield* fs.exists(path))) continue
113
+ const content = yield* fs.readFile(path)
114
+ const parsed = yield* parseFrontmatter(content, path)
115
+ const data = yield* decodeEpic(parsed.data)
116
+ records.push({ id: entry.name, path, content, data })
117
+ }
118
+ return records
119
+ }),
120
+
121
+ show: (id: string, startPath: string = process.cwd()) =>
122
+ Effect.gen(function* () {
123
+ const service = yield* EpicService
124
+ const validId = yield* decodeId(id)
125
+ const records = yield* service.list(startPath)
126
+ const record = records.find((epic) => epic.id === validId)
127
+ if (!record) {
128
+ return yield* new EpicError({
129
+ message: `Epic '${validId}' does not exist`,
130
+ })
131
+ }
132
+ return record
133
+ }),
134
+ }),
135
+ }) {}