@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
|
@@ -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.
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { Schema, TreeFormatter } from "@effect/schema"
|
|
2
|
+
import { Data, Effect, Either } from "effect"
|
|
3
|
+
import { join } from "node:path"
|
|
4
|
+
import { FileSystemService } from "./FileSystemService"
|
|
5
|
+
import { WorkbaseService } from "./WorkbaseService"
|
|
6
|
+
import { TaskService } from "./TaskService"
|
|
7
|
+
import {
|
|
8
|
+
EntityId,
|
|
9
|
+
PhaseFrontmatter,
|
|
10
|
+
type PhaseFrontmatter as PhaseData,
|
|
11
|
+
type RepositoryReference,
|
|
12
|
+
} from "../workbase/schemas"
|
|
13
|
+
import {
|
|
14
|
+
formatMarkdownDocument,
|
|
15
|
+
parseFrontmatter,
|
|
16
|
+
} from "../workbase/frontmatter"
|
|
17
|
+
|
|
18
|
+
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
19
|
+
readonly message: string
|
|
20
|
+
}> {}
|
|
21
|
+
|
|
22
|
+
interface PhaseRecord {
|
|
23
|
+
readonly taskId: string
|
|
24
|
+
readonly id: string
|
|
25
|
+
readonly path: string
|
|
26
|
+
readonly content: string
|
|
27
|
+
readonly data: PhaseData
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CreatePhaseInput {
|
|
31
|
+
readonly taskId: string
|
|
32
|
+
readonly id: string
|
|
33
|
+
readonly description?: string
|
|
34
|
+
readonly repo: string
|
|
35
|
+
readonly repos?: readonly RepositoryReference[]
|
|
36
|
+
readonly branch: string
|
|
37
|
+
readonly base: string
|
|
38
|
+
readonly dependsOn?: readonly string[]
|
|
39
|
+
readonly firstPhase?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const decodeId = (id: string, label: string) => {
|
|
43
|
+
const result = Schema.decodeUnknownEither(EntityId)(id)
|
|
44
|
+
return Either.isLeft(result)
|
|
45
|
+
? Effect.fail(new PhaseError({ message: `Invalid ${label} ID '${id}'` }))
|
|
46
|
+
: Effect.succeed(result.right)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const decodePhase = (input: unknown) => {
|
|
50
|
+
const result = Schema.decodeUnknownEither(PhaseFrontmatter, {
|
|
51
|
+
errors: "all",
|
|
52
|
+
onExcessProperty: "error",
|
|
53
|
+
})(input)
|
|
54
|
+
return Either.isLeft(result)
|
|
55
|
+
? Effect.fail(
|
|
56
|
+
new PhaseError({ message: TreeFormatter.formatErrorSync(result.left) }),
|
|
57
|
+
)
|
|
58
|
+
: Effect.succeed(result.right)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class PhaseService extends Effect.Service<PhaseService>()(
|
|
62
|
+
"PhaseService",
|
|
63
|
+
{
|
|
64
|
+
sync: () => ({
|
|
65
|
+
create: (input: CreatePhaseInput, startPath: string = process.cwd()) =>
|
|
66
|
+
Effect.gen(function* () {
|
|
67
|
+
const fs = yield* FileSystemService
|
|
68
|
+
const workbase = yield* WorkbaseService
|
|
69
|
+
const tasks = yield* TaskService
|
|
70
|
+
const root = yield* workbase.discover(startPath)
|
|
71
|
+
const taskId = yield* decodeId(input.taskId, "task")
|
|
72
|
+
const id = yield* decodeId(input.id, "phase")
|
|
73
|
+
const task = yield* tasks.show(taskId, root)
|
|
74
|
+
const isMultiPhase = "phases" in task.data
|
|
75
|
+
let firstPhaseId: string | undefined
|
|
76
|
+
if (!isMultiPhase) {
|
|
77
|
+
if (!input.firstPhase) {
|
|
78
|
+
return yield* new PhaseError({
|
|
79
|
+
message:
|
|
80
|
+
"Converting a single-phase task requires --first-phase <id>",
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
firstPhaseId = yield* decodeId(input.firstPhase, "first phase")
|
|
84
|
+
if (firstPhaseId === id) {
|
|
85
|
+
return yield* new PhaseError({
|
|
86
|
+
message: "The existing and new phase IDs must be different",
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
} else if (input.firstPhase) {
|
|
90
|
+
return yield* new PhaseError({
|
|
91
|
+
message:
|
|
92
|
+
"--first-phase is only valid when converting a single-phase task",
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (
|
|
97
|
+
isMultiPhase &&
|
|
98
|
+
task.data.phases.some((phase) => phase.id === id)
|
|
99
|
+
) {
|
|
100
|
+
return yield* new PhaseError({
|
|
101
|
+
message: `Phase '${id}' already exists on task '${taskId}'`,
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
const knownPhases = new Set(
|
|
105
|
+
isMultiPhase
|
|
106
|
+
? task.data.phases.map((phase) => phase.id)
|
|
107
|
+
: [firstPhaseId!],
|
|
108
|
+
)
|
|
109
|
+
for (const dependency of input.dependsOn ?? []) {
|
|
110
|
+
if (!knownPhases.has(dependency)) {
|
|
111
|
+
return yield* new PhaseError({
|
|
112
|
+
message: `Unknown phase dependency '${dependency}'`,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const data = yield* decodePhase({
|
|
118
|
+
...(input.description !== undefined
|
|
119
|
+
? { description: input.description }
|
|
120
|
+
: {}),
|
|
121
|
+
repo: input.repo,
|
|
122
|
+
...(input.repos?.length ? { repos: input.repos } : {}),
|
|
123
|
+
branch: input.branch,
|
|
124
|
+
base: input.base,
|
|
125
|
+
pr: null,
|
|
126
|
+
})
|
|
127
|
+
const existingExecution = isMultiPhase ? undefined : task.data
|
|
128
|
+
const aliases = new Set([
|
|
129
|
+
data.repo,
|
|
130
|
+
...(data.repos ?? []).map((reference) => reference.repo),
|
|
131
|
+
...(existingExecution
|
|
132
|
+
? [
|
|
133
|
+
existingExecution.repo,
|
|
134
|
+
...(existingExecution.repos ?? []).map(
|
|
135
|
+
(reference) => reference.repo,
|
|
136
|
+
),
|
|
137
|
+
]
|
|
138
|
+
: []),
|
|
139
|
+
])
|
|
140
|
+
for (const alias of aliases) {
|
|
141
|
+
if (!(yield* fs.exists(join(root, "repos", alias)))) {
|
|
142
|
+
return yield* new PhaseError({
|
|
143
|
+
message: `Unknown repository alias '${alias}'`,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const directory = join(root, "tasks", taskId, "phases", id)
|
|
149
|
+
const path = join(directory, "PHASE.md")
|
|
150
|
+
if (yield* fs.exists(directory)) {
|
|
151
|
+
return yield* new PhaseError({
|
|
152
|
+
message: `Phase directory already exists: ${id}`,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const parsedTask = yield* parseFrontmatter(task.content, task.path)
|
|
157
|
+
const title = id
|
|
158
|
+
.split("-")
|
|
159
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
160
|
+
.join(" ")
|
|
161
|
+
const content = formatMarkdownDocument(
|
|
162
|
+
data,
|
|
163
|
+
`# ${title}\n\nDescribe the phase outcome.`,
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
if (!isMultiPhase) {
|
|
167
|
+
const firstDirectory = join(
|
|
168
|
+
root,
|
|
169
|
+
"tasks",
|
|
170
|
+
taskId,
|
|
171
|
+
"phases",
|
|
172
|
+
firstPhaseId!,
|
|
173
|
+
)
|
|
174
|
+
if (yield* fs.exists(firstDirectory)) {
|
|
175
|
+
return yield* new PhaseError({
|
|
176
|
+
message: `Phase directory already exists: ${firstPhaseId}`,
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
const firstData = yield* decodePhase({
|
|
180
|
+
repo: task.data.repo,
|
|
181
|
+
...(task.data.repos?.length ? { repos: task.data.repos } : {}),
|
|
182
|
+
branch: task.data.branch,
|
|
183
|
+
base: task.data.base,
|
|
184
|
+
pr: task.data.pr,
|
|
185
|
+
})
|
|
186
|
+
const firstTitle = firstPhaseId!
|
|
187
|
+
.split("-")
|
|
188
|
+
.map((part) => part[0]?.toUpperCase() + part.slice(1))
|
|
189
|
+
.join(" ")
|
|
190
|
+
|
|
191
|
+
yield* fs.createDirectory(firstDirectory)
|
|
192
|
+
yield* fs.createDirectory(directory)
|
|
193
|
+
yield* fs.writeFile(
|
|
194
|
+
join(firstDirectory, "PHASE.md"),
|
|
195
|
+
formatMarkdownDocument(
|
|
196
|
+
firstData,
|
|
197
|
+
`# ${firstTitle}\n\nDescribe the phase outcome.`,
|
|
198
|
+
),
|
|
199
|
+
)
|
|
200
|
+
yield* fs.writeFile(path, content)
|
|
201
|
+
|
|
202
|
+
const oldCodePath = join(root, "tasks", taskId, "code")
|
|
203
|
+
if (yield* fs.isDirectory(oldCodePath)) {
|
|
204
|
+
const firstCodePath = join(firstDirectory, "code")
|
|
205
|
+
yield* fs.moveDirectory(oldCodePath, firstCodePath)
|
|
206
|
+
for (const alias of [
|
|
207
|
+
firstData.repo,
|
|
208
|
+
...(firstData.repos ?? []).map((reference) => reference.repo),
|
|
209
|
+
]) {
|
|
210
|
+
const checkoutPath = join(firstCodePath, alias)
|
|
211
|
+
if (!(yield* fs.isDirectory(checkoutPath))) continue
|
|
212
|
+
const repair = yield* fs.runCommand(
|
|
213
|
+
[
|
|
214
|
+
"git",
|
|
215
|
+
"-C",
|
|
216
|
+
join(root, "repos", alias),
|
|
217
|
+
"worktree",
|
|
218
|
+
"repair",
|
|
219
|
+
checkoutPath,
|
|
220
|
+
],
|
|
221
|
+
{ captureOutput: true },
|
|
222
|
+
)
|
|
223
|
+
if (repair.exitCode !== 0) {
|
|
224
|
+
return yield* new PhaseError({
|
|
225
|
+
message: `Failed to repair moved worktree for '${alias}': ${repair.stderr}`,
|
|
226
|
+
})
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const convertedTaskData = {
|
|
232
|
+
ticketUrl: task.data.ticketUrl,
|
|
233
|
+
...(task.data.description
|
|
234
|
+
? { description: task.data.description }
|
|
235
|
+
: {}),
|
|
236
|
+
...(task.data.epic ? { epic: task.data.epic } : {}),
|
|
237
|
+
phases: [
|
|
238
|
+
{ id: firstPhaseId! },
|
|
239
|
+
{
|
|
240
|
+
id,
|
|
241
|
+
...(input.dependsOn?.length
|
|
242
|
+
? { dependsOn: input.dependsOn }
|
|
243
|
+
: {}),
|
|
244
|
+
},
|
|
245
|
+
],
|
|
246
|
+
}
|
|
247
|
+
yield* fs.writeFile(
|
|
248
|
+
task.path,
|
|
249
|
+
formatMarkdownDocument(convertedTaskData, parsedTask.body),
|
|
250
|
+
)
|
|
251
|
+
return { taskId, id, path, content, data } satisfies PhaseRecord
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
yield* fs.createDirectory(directory)
|
|
255
|
+
yield* fs.writeFile(path, content)
|
|
256
|
+
const updatedTaskData = {
|
|
257
|
+
...task.data,
|
|
258
|
+
phases: [
|
|
259
|
+
...task.data.phases,
|
|
260
|
+
{
|
|
261
|
+
id,
|
|
262
|
+
...(input.dependsOn?.length
|
|
263
|
+
? { dependsOn: input.dependsOn }
|
|
264
|
+
: {}),
|
|
265
|
+
},
|
|
266
|
+
],
|
|
267
|
+
}
|
|
268
|
+
yield* fs.writeFile(
|
|
269
|
+
task.path,
|
|
270
|
+
formatMarkdownDocument(updatedTaskData, parsedTask.body),
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return { taskId, id, path, content, data } satisfies PhaseRecord
|
|
274
|
+
}),
|
|
275
|
+
|
|
276
|
+
list: (taskId: string, startPath: string = process.cwd()) =>
|
|
277
|
+
Effect.gen(function* () {
|
|
278
|
+
const fs = yield* FileSystemService
|
|
279
|
+
const workbase = yield* WorkbaseService
|
|
280
|
+
const tasks = yield* TaskService
|
|
281
|
+
const root = yield* workbase.discover(startPath)
|
|
282
|
+
const validTaskId = yield* decodeId(taskId, "task")
|
|
283
|
+
yield* tasks.show(validTaskId, root)
|
|
284
|
+
const directory = join(root, "tasks", validTaskId, "phases")
|
|
285
|
+
if (!(yield* fs.isDirectory(directory))) return [] as PhaseRecord[]
|
|
286
|
+
const entries = (yield* fs.readDirectory(directory))
|
|
287
|
+
.filter((entry) => entry.isDirectory)
|
|
288
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
289
|
+
const records: PhaseRecord[] = []
|
|
290
|
+
for (const entry of entries) {
|
|
291
|
+
const path = join(directory, entry.name, "PHASE.md")
|
|
292
|
+
if (!(yield* fs.exists(path))) continue
|
|
293
|
+
const content = yield* fs.readFile(path)
|
|
294
|
+
const parsed = yield* parseFrontmatter(content, path)
|
|
295
|
+
const data = yield* decodePhase(parsed.data)
|
|
296
|
+
records.push({
|
|
297
|
+
taskId: validTaskId,
|
|
298
|
+
id: entry.name,
|
|
299
|
+
path,
|
|
300
|
+
content,
|
|
301
|
+
data,
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
return records
|
|
305
|
+
}),
|
|
306
|
+
|
|
307
|
+
show: (taskId: string, id: string, startPath: string = process.cwd()) =>
|
|
308
|
+
Effect.gen(function* () {
|
|
309
|
+
const service = yield* PhaseService
|
|
310
|
+
const validId = yield* decodeId(id, "phase")
|
|
311
|
+
const record = (yield* service.list(taskId, startPath)).find(
|
|
312
|
+
(phase) => phase.id === validId,
|
|
313
|
+
)
|
|
314
|
+
if (!record) {
|
|
315
|
+
return yield* new PhaseError({
|
|
316
|
+
message: `Phase '${validId}' does not exist on task '${taskId}'`,
|
|
317
|
+
})
|
|
318
|
+
}
|
|
319
|
+
return record
|
|
320
|
+
}),
|
|
321
|
+
}),
|
|
322
|
+
},
|
|
323
|
+
) {}
|