@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.
- package/README.md +313 -117
- package/cli.ts +124 -420
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +266 -0
- package/src/cli.test.ts +290 -0
- package/src/commands/description.test.ts +103 -0
- package/src/commands/epic.test.ts +103 -0
- package/src/commands/epic.ts +101 -0
- package/src/commands/init.test.ts +42 -290
- package/src/commands/init.ts +15 -123
- package/src/commands/phase.ts +120 -0
- package/src/commands/pr.test.ts +19 -170
- package/src/commands/pr.ts +23 -179
- package/src/commands/repo.test.ts +80 -0
- package/src/commands/repo.ts +83 -0
- package/src/commands/status.test.ts +35 -477
- package/src/commands/status.ts +24 -213
- package/src/commands/task-phase.test.ts +206 -0
- package/src/commands/task.ts +83 -1285
- package/src/commands/validate.test.ts +91 -0
- package/src/commands/validate.ts +50 -0
- package/src/commands/work.test.ts +326 -399
- package/src/commands/work.ts +153 -153
- package/src/services/EpicService.test.ts +71 -0
- package/src/services/EpicService.ts +135 -0
- package/src/services/FileSystemService.ts +61 -92
- package/src/services/PhaseService.ts +323 -0
- package/src/services/PullRequestService.test.ts +386 -0
- package/src/services/PullRequestService.ts +143 -0
- package/src/services/RepositoryService.test.ts +106 -0
- package/src/services/RepositoryService.ts +159 -0
- package/src/services/TaskPhaseService.test.ts +217 -0
- package/src/services/TaskService.ts +192 -0
- package/src/services/WorkbaseService.test.ts +446 -0
- package/src/services/WorkbaseService.ts +493 -0
- package/src/services/WorktreeService.test.ts +599 -0
- package/src/services/WorktreeService.ts +357 -0
- package/src/test-utils.ts +41 -438
- package/src/types.ts +1 -215
- package/src/utils/command.ts +1 -5
- package/src/utils/effect.test.ts +24 -0
- package/src/utils/effect.ts +3 -273
- package/src/utils/process.test.ts +0 -15
- package/src/utils/process.ts +1 -60
- package/src/workbase/frontmatter.test.ts +50 -0
- package/src/workbase/frontmatter.ts +77 -0
- package/src/workbase/repository-reference.test.ts +25 -0
- package/src/workbase/repository-reference.ts +18 -0
- package/src/workbase/schemas.test.ts +146 -0
- package/src/workbase/schemas.ts +78 -0
- package/src/workbase/work-target.test.ts +77 -0
- package/src/workbase/work-target.ts +180 -0
- package/src/workbase/worktree-command.test.ts +61 -0
- package/src/workbase/worktree-command.ts +52 -0
- package/src/commands/base.test.ts +0 -198
- package/src/commands/base.ts +0 -198
- package/src/commands/clean.test.ts +0 -299
- package/src/commands/clean.ts +0 -274
- package/src/commands/completions.test.ts +0 -51
- package/src/commands/completions.ts +0 -308
- package/src/commands/emit.integration.test.ts +0 -549
- package/src/commands/emit.test.ts +0 -576
- package/src/commands/emit.ts +0 -700
- package/src/commands/emitted.test.ts +0 -226
- package/src/commands/emitted.ts +0 -57
- package/src/commands/loop.test.ts +0 -621
- package/src/commands/loop.ts +0 -406
- package/src/commands/merge.integration.test.ts +0 -195
- package/src/commands/merge.test.ts +0 -246
- package/src/commands/merge.ts +0 -244
- package/src/commands/pull.test.ts +0 -395
- package/src/commands/pull.ts +0 -205
- package/src/commands/push.test.ts +0 -641
- package/src/commands/push.ts +0 -482
- package/src/commands/rebase.test.ts +0 -521
- package/src/commands/rebase.ts +0 -232
- package/src/commands/save.test.ts +0 -247
- package/src/commands/save.ts +0 -162
- package/src/commands/source.test.ts +0 -195
- package/src/commands/source.ts +0 -72
- package/src/commands/switch.test.ts +0 -194
- package/src/commands/switch.ts +0 -84
- package/src/commands/task-branching.test.ts +0 -633
- package/src/commands/task-continue.test.ts +0 -311
- package/src/commands/task-edit.test.ts +0 -235
- package/src/commands/task-main.test.ts +0 -1066
- package/src/commands/task-squash.test.ts +0 -266
- package/src/commands/tasks.test.ts +0 -391
- package/src/commands/tasks.ts +0 -161
- package/src/commands/template-delete.test.ts +0 -178
- package/src/commands/template-delete.ts +0 -98
- package/src/commands/template-list.test.ts +0 -135
- package/src/commands/template-list.ts +0 -87
- package/src/commands/template-view.test.ts +0 -158
- package/src/commands/template-view.ts +0 -86
- package/src/commands/template.test.ts +0 -32
- package/src/commands/template.ts +0 -96
- package/src/commands/use.test.ts +0 -87
- package/src/commands/use.ts +0 -97
- package/src/constants.ts +0 -10
- package/src/errors.ts +0 -17
- package/src/schemas.ts +0 -33
- package/src/services/AgencyMetadataService.ts +0 -311
- package/src/services/ClaudeService.test.ts +0 -184
- package/src/services/ClaudeService.ts +0 -91
- package/src/services/ConfigService.ts +0 -115
- package/src/services/FilterRepoService.ts +0 -164
- package/src/services/FormatterService.test.ts +0 -432
- package/src/services/FormatterService.ts +0 -219
- package/src/services/GitService.ts +0 -1187
- package/src/services/MockFilterRepoService.ts +0 -140
- package/src/services/OpencodeService.ts +0 -263
- package/src/services/PromptService.ts +0 -183
- package/src/services/TemplateService.ts +0 -81
- package/src/types/native-exec.d.ts +0 -8
- package/src/utils/colors.ts +0 -178
- package/src/utils/git-path.ts +0 -6
- package/src/utils/glob.test.ts +0 -161
- package/src/utils/glob.ts +0 -78
- package/src/utils/paths.ts +0 -51
- package/src/utils/pr-branch.test.ts +0 -376
- package/src/utils/pr-branch.ts +0 -508
- package/src/utils/spinner.test.ts +0 -45
- package/src/utils/spinner.ts +0 -96
- package/src/utils/task-parser.test.ts +0 -172
- package/src/utils/task-parser.ts +0 -93
- package/templates/AGENCY.md +0 -22
- package/templates/AGENTS.md +0 -11
- package/templates/CLAUDE.md +0 -3
- package/templates/TASK.md +0 -5
- package/templates/opencode.json +0 -4
package/src/commands/work.ts
CHANGED
|
@@ -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 {
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
29
|
+
const launchAgent: LaunchAgent = (cli, args, cwd) => {
|
|
30
|
+
process.chdir(cwd)
|
|
31
|
+
execvp(cli, [...args])
|
|
32
|
+
}
|
|
36
33
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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("
|
|
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
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
"
|
|
139
|
+
"fzf is required to select a work target; install fzf or provide a target explicitly",
|
|
111
140
|
),
|
|
112
141
|
)
|
|
113
142
|
}
|
|
114
|
-
|
|
115
|
-
|
|
143
|
+
target = yield* pick(choices)
|
|
144
|
+
if (!target) return
|
|
116
145
|
}
|
|
117
146
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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 [
|
|
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
|
-
|
|
169
|
-
|
|
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
|
-
--
|
|
174
|
-
--
|
|
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
|
+
}) {}
|