@markjaquith/agency 1.11.0 → 2.0.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 +302 -113
- package/cli.ts +123 -415
- package/index.ts +1 -1
- package/package.json +12 -17
- package/skills/agency/SKILL.md +258 -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 +172 -419
- package/src/commands/work.ts +47 -166
- 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/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 -518
- package/src/commands/push.ts +0 -434
- 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 -1183
- 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,74 @@
|
|
|
1
|
-
import { join } from "node:path"
|
|
2
1
|
import { Effect } from "effect"
|
|
3
2
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
|
+
import { WorktreeService } from "../services/WorktreeService"
|
|
4
4
|
import { FileSystemService } from "../services/FileSystemService"
|
|
5
|
-
import { createLoggers
|
|
6
|
-
import { spawnProcess } from "../utils/process"
|
|
5
|
+
import { createLoggers } from "../utils/effect"
|
|
7
6
|
import { execvp } from "../utils/exec"
|
|
8
7
|
|
|
9
8
|
interface WorkOptions extends BaseCommandOptions {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
|
9
|
+
readonly taskId?: string
|
|
10
|
+
readonly phaseId?: string
|
|
11
|
+
readonly opencode?: boolean
|
|
12
|
+
readonly claude?: boolean
|
|
27
13
|
}
|
|
28
14
|
|
|
29
|
-
|
|
30
|
-
Effect.gen(function* () {
|
|
31
|
-
const { verboseLog } = createLoggers(options)
|
|
32
|
-
|
|
33
|
-
const fs = yield* FileSystemService
|
|
15
|
+
type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
|
|
34
16
|
|
|
35
|
-
|
|
17
|
+
const launchAgent: LaunchAgent = (cli, args, cwd) => {
|
|
18
|
+
process.chdir(cwd)
|
|
19
|
+
execvp(cli, [...args])
|
|
20
|
+
}
|
|
36
21
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
)
|
|
22
|
+
export const work = (
|
|
23
|
+
options: WorkOptions = {},
|
|
24
|
+
launch: LaunchAgent = launchAgent,
|
|
25
|
+
) =>
|
|
26
|
+
Effect.gen(function* () {
|
|
27
|
+
if (!options.taskId) {
|
|
28
|
+
return yield* Effect.fail(new Error("Task ID is required"))
|
|
44
29
|
}
|
|
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
30
|
if (options.opencode && options.claude) {
|
|
53
31
|
return yield* Effect.fail(
|
|
54
|
-
new Error(
|
|
55
|
-
"Cannot use both --opencode and --claude flags together. Choose one.",
|
|
56
|
-
),
|
|
32
|
+
new Error("Cannot use both --opencode and --claude"),
|
|
57
33
|
)
|
|
58
34
|
}
|
|
59
35
|
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
stderr: "ignore",
|
|
77
|
-
})
|
|
78
|
-
return result.exitCode === 0
|
|
79
|
-
},
|
|
80
|
-
catch: () => false,
|
|
36
|
+
const worktrees = yield* WorktreeService
|
|
37
|
+
const fs = yield* FileSystemService
|
|
38
|
+
const { verboseLog } = createLoggers(options)
|
|
39
|
+
const workspace = yield* worktrees.materialize(
|
|
40
|
+
options.taskId,
|
|
41
|
+
options.phaseId,
|
|
42
|
+
options.cwd ?? process.cwd(),
|
|
43
|
+
)
|
|
44
|
+
const prompt = workspace.phasePath
|
|
45
|
+
? `Start the task. Read ${workspace.taskPath} and ${workspace.phasePath}.`
|
|
46
|
+
: `Start the task. Read ${workspace.taskPath}.`
|
|
47
|
+
|
|
48
|
+
const requested = options.claude ? "claude" : "opencode"
|
|
49
|
+
let cli = requested
|
|
50
|
+
let available = yield* fs.runCommand(["which", cli], {
|
|
51
|
+
captureOutput: true,
|
|
81
52
|
})
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
)
|
|
92
|
-
}
|
|
93
|
-
useOpencode = true
|
|
94
|
-
verboseLog("Using opencode (explicitly requested)")
|
|
95
|
-
} else if (options.claude) {
|
|
96
|
-
if (!hasClaude) {
|
|
97
|
-
return yield* Effect.fail(
|
|
98
|
-
new Error(
|
|
99
|
-
"claude CLI tool not found. Please install Claude Code or remove the --claude flag.",
|
|
100
|
-
),
|
|
101
|
-
)
|
|
102
|
-
}
|
|
103
|
-
useOpencode = false
|
|
104
|
-
verboseLog("Using claude (explicitly requested)")
|
|
105
|
-
} else {
|
|
106
|
-
// Auto-detect
|
|
107
|
-
if (!hasOpencode && !hasClaude) {
|
|
108
|
-
return yield* Effect.fail(
|
|
109
|
-
new Error(
|
|
110
|
-
"Neither opencode nor claude CLI tool found. Please install OpenCode or Claude Code.",
|
|
111
|
-
),
|
|
112
|
-
)
|
|
113
|
-
}
|
|
114
|
-
useOpencode = hasOpencode
|
|
115
|
-
verboseLog(`Using ${useOpencode ? "opencode" : "claude"} (auto-detected)`)
|
|
53
|
+
if (available.exitCode !== 0 && !options.opencode && !options.claude) {
|
|
54
|
+
cli = "claude"
|
|
55
|
+
available = yield* fs.runCommand(["which", cli], { captureOutput: true })
|
|
116
56
|
}
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
)
|
|
133
|
-
} else {
|
|
134
|
-
verboseLog(`Running ${cliName} with task prompt...`)
|
|
57
|
+
if (available.exitCode !== 0) {
|
|
58
|
+
return yield* Effect.fail(new Error(`${cli} CLI tool not found`))
|
|
135
59
|
}
|
|
136
60
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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)
|
|
160
|
-
}
|
|
61
|
+
verboseLog(`Launching ${cli} in ${workspace.writablePath}`)
|
|
62
|
+
const args = cli === "opencode" ? ["--prompt", prompt] : [prompt]
|
|
63
|
+
launch(cli, [cli, ...args], workspace.writablePath)
|
|
161
64
|
})
|
|
162
65
|
|
|
163
66
|
export const help = `
|
|
164
|
-
Usage: agency work
|
|
165
|
-
|
|
166
|
-
Start working on the task described in TASK.md using OpenCode or Claude Code.
|
|
67
|
+
Usage: agency work <task-id> [phase-id]
|
|
167
68
|
|
|
168
|
-
|
|
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.
|
|
69
|
+
Materialize repository worktrees and launch an agent in the writable checkout.
|
|
171
70
|
|
|
172
71
|
Options:
|
|
173
|
-
--opencode
|
|
174
|
-
--claude
|
|
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)
|
|
72
|
+
--opencode Require OpenCode
|
|
73
|
+
--claude Require Claude Code
|
|
193
74
|
`
|
|
@@ -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
|
+
}) {}
|
|
@@ -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
|
-
|
|
30
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
129
|
+
moveDirectory: (from: string, to: string) =>
|
|
115
130
|
Effect.tryPromise({
|
|
116
|
-
try: () =>
|
|
131
|
+
try: () => rename(from, to),
|
|
117
132
|
catch: (error) =>
|
|
118
133
|
new FileSystemError({
|
|
119
|
-
message: `Failed to
|
|
134
|
+
message: `Failed to move directory from ${from} to ${to}`,
|
|
120
135
|
cause: error,
|
|
121
136
|
}),
|
|
122
137
|
}),
|
|
123
138
|
|
|
124
|
-
|
|
139
|
+
readDirectory: (path: string) =>
|
|
125
140
|
Effect.tryPromise({
|
|
126
|
-
try: () =>
|
|
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
|
|
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
|
|
183
|
+
readonly env?: Record<string, string>
|
|
162
184
|
},
|
|
163
185
|
) =>
|
|
164
186
|
pipe(
|
|
165
187
|
spawnProcess(args, {
|
|
166
188
|
cwd: options?.cwd,
|
|
167
|
-
stdin:
|
|
189
|
+
stdin: "pipe",
|
|
168
190
|
stdout: options?.captureOutput ? "pipe" : "inherit",
|
|
169
|
-
stderr:
|
|
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:
|
|
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
|
|
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.
|