@markjaquith/agency 2.5.0 → 2.7.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 +21 -14
- package/cli.ts +28 -6
- package/package.json +1 -1
- package/skills/agency/SKILL.md +18 -9
- package/src/cli.test.ts +30 -3
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +1 -1
- package/src/commands/task-phase.test.ts +4 -4
- package/src/commands/task.ts +1 -1
- package/src/commands/validate.test.ts +65 -0
- package/src/commands/validate.ts +19 -5
- package/src/commands/work.test.ts +65 -7
- package/src/commands/work.ts +39 -10
- package/src/commands/workbase.test.ts +62 -0
- package/src/commands/workbase.ts +58 -0
- package/src/services/TaskPhaseService.test.ts +3 -3
- package/src/services/WorkbaseService.test.ts +35 -3
- package/src/services/WorkbaseService.ts +73 -6
- package/src/services/WorktreeService.test.ts +45 -0
- package/src/services/WorktreeService.ts +10 -6
- package/src/workbase/opencode-file.ts +25 -16
- package/src/workbase/schemas.test.ts +74 -10
- package/src/workbase/schemas.ts +13 -1
- package/src/workbase/work-target.test.ts +7 -0
- package/src/workbase/work-target.ts +1 -0
- package/src/workbase/workbase-choice.ts +71 -0
|
@@ -9,6 +9,7 @@ import { WorktreeService } from "../services/WorktreeService"
|
|
|
9
9
|
import { captureLogs } from "../test-utils"
|
|
10
10
|
import { work } from "./work"
|
|
11
11
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
12
|
+
import type { PickWorkbase } from "../workbase/workbase-choice"
|
|
12
13
|
import type { Progress } from "../utils/progress"
|
|
13
14
|
|
|
14
15
|
type ExecutionWorkspace = Effect.Effect.Success<
|
|
@@ -43,6 +44,8 @@ interface HarnessOptions {
|
|
|
43
44
|
readonly epicRecords?: readonly any[]
|
|
44
45
|
readonly taskRecords?: readonly any[]
|
|
45
46
|
readonly phaseRecords?: readonly any[]
|
|
47
|
+
readonly outsideWorkbase?: boolean
|
|
48
|
+
readonly registeredWorkbases?: readonly string[]
|
|
46
49
|
}
|
|
47
50
|
|
|
48
51
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -73,7 +76,14 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
73
76
|
},
|
|
74
77
|
}
|
|
75
78
|
const workbase = {
|
|
76
|
-
discover: () =>
|
|
79
|
+
discover: (path: string) =>
|
|
80
|
+
options.outsideWorkbase && path === "/outside"
|
|
81
|
+
? Effect.fail({
|
|
82
|
+
_tag: "WorkbaseNotFoundError" as const,
|
|
83
|
+
message: "No Agency workbase found from /outside",
|
|
84
|
+
})
|
|
85
|
+
: Effect.succeed("/workbase"),
|
|
86
|
+
listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
|
|
77
87
|
}
|
|
78
88
|
const epics = {
|
|
79
89
|
show: (id: string) =>
|
|
@@ -114,6 +124,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
114
124
|
},
|
|
115
125
|
}
|
|
116
126
|
const fs = {
|
|
127
|
+
isDirectory: () => Effect.succeed(true),
|
|
117
128
|
runCommand: (args: readonly string[]) => {
|
|
118
129
|
const cli = args[1] as "opencode" | "claude"
|
|
119
130
|
events.push(`probe:${cli}`)
|
|
@@ -130,6 +141,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
130
141
|
launches.push({ cli, args, cwd })
|
|
131
142
|
}
|
|
132
143
|
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
144
|
+
const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
|
|
133
145
|
const progress: Progress = {
|
|
134
146
|
start: (message) => progressUpdates.push(`start:${message}`),
|
|
135
147
|
succeed: (message) => progressUpdates.push(`succeed:${message}`),
|
|
@@ -138,9 +150,10 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
138
150
|
const run = (
|
|
139
151
|
commandOptions: Parameters<typeof work>[0],
|
|
140
152
|
pick: PickWorkTarget = defaultPick,
|
|
153
|
+
pickBase: PickWorkbase = defaultPickWorkbase,
|
|
141
154
|
) =>
|
|
142
155
|
Effect.runPromise(
|
|
143
|
-
work(commandOptions, launch, pick, progress).pipe(
|
|
156
|
+
work(commandOptions, launch, pick, progress, pickBase).pipe(
|
|
144
157
|
Effect.provideService(WorktreeService, worktrees as never),
|
|
145
158
|
Effect.provideService(FileSystemService, fs as never),
|
|
146
159
|
Effect.provideService(WorkbaseService, workbase as never),
|
|
@@ -165,7 +178,11 @@ describe("work command", () => {
|
|
|
165
178
|
test("launches an epic agent from an epic directory", async () => {
|
|
166
179
|
const harness = createHarness()
|
|
167
180
|
|
|
168
|
-
await harness.run({
|
|
181
|
+
await harness.run({
|
|
182
|
+
cwd: "/workbase/epics/delivery",
|
|
183
|
+
directory: ".",
|
|
184
|
+
opencode: true,
|
|
185
|
+
})
|
|
169
186
|
|
|
170
187
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
171
188
|
expect(harness.launches[0]).toEqual({
|
|
@@ -178,7 +195,11 @@ describe("work command", () => {
|
|
|
178
195
|
test("launches a multi-phase task agent without materializing", async () => {
|
|
179
196
|
const harness = createHarness({ multiPhaseTasks: ["delivery"] })
|
|
180
197
|
|
|
181
|
-
await harness.run({
|
|
198
|
+
await harness.run({
|
|
199
|
+
cwd: "/workbase/tasks/delivery",
|
|
200
|
+
directory: ".",
|
|
201
|
+
opencode: true,
|
|
202
|
+
})
|
|
182
203
|
|
|
183
204
|
expect(harness.events).toEqual(["probe:opencode", "launch:opencode"])
|
|
184
205
|
expect(harness.launches[0]).toEqual({
|
|
@@ -193,6 +214,7 @@ describe("work command", () => {
|
|
|
193
214
|
|
|
194
215
|
await harness.run({
|
|
195
216
|
cwd: "/workbase/tasks/example/phases/implementation/code/agency/src",
|
|
217
|
+
directory: ".",
|
|
196
218
|
opencode: true,
|
|
197
219
|
})
|
|
198
220
|
|
|
@@ -212,6 +234,7 @@ describe("work command", () => {
|
|
|
212
234
|
|
|
213
235
|
await harness.run({
|
|
214
236
|
cwd: "/workbase/tasks/example/code/agency/src",
|
|
237
|
+
directory: ".",
|
|
215
238
|
opencode: true,
|
|
216
239
|
})
|
|
217
240
|
|
|
@@ -219,7 +242,7 @@ describe("work command", () => {
|
|
|
219
242
|
expect(harness.launches[0]?.cwd).toBe("/workbase/tasks/example")
|
|
220
243
|
})
|
|
221
244
|
|
|
222
|
-
test("selects a target with fzf
|
|
245
|
+
test("selects a target with fzf when no directory is provided", async () => {
|
|
223
246
|
const phase = {
|
|
224
247
|
taskId: "delivery",
|
|
225
248
|
id: "build",
|
|
@@ -242,7 +265,7 @@ describe("work command", () => {
|
|
|
242
265
|
choices.find((choice) => choice.label.includes("build"))!.target,
|
|
243
266
|
)
|
|
244
267
|
|
|
245
|
-
await harness.run({ cwd: "/workbase", opencode: true }, pick)
|
|
268
|
+
await harness.run({ cwd: "/workbase/tasks/example", opencode: true }, pick)
|
|
246
269
|
|
|
247
270
|
expect(harness.events).toEqual([
|
|
248
271
|
"probe:fzf",
|
|
@@ -255,6 +278,41 @@ describe("work command", () => {
|
|
|
255
278
|
)
|
|
256
279
|
})
|
|
257
280
|
|
|
281
|
+
test("selects a registered workbase when local discovery fails", async () => {
|
|
282
|
+
const harness = createHarness({
|
|
283
|
+
outsideWorkbase: true,
|
|
284
|
+
registeredWorkbases: ["/first", "/workbase"],
|
|
285
|
+
})
|
|
286
|
+
const selections: string[][] = []
|
|
287
|
+
const pickBase: PickWorkbase = (workbases) => {
|
|
288
|
+
selections.push([...workbases])
|
|
289
|
+
return Effect.succeed("/workbase")
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
await harness.run(
|
|
293
|
+
{ cwd: "/outside", taskId: "example", opencode: true },
|
|
294
|
+
undefined,
|
|
295
|
+
pickBase,
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
expect(selections).toEqual([["/first", "/workbase"]])
|
|
299
|
+
expect(harness.events).toEqual([
|
|
300
|
+
"probe:fzf",
|
|
301
|
+
"materialize",
|
|
302
|
+
"probe:opencode",
|
|
303
|
+
"launch:opencode",
|
|
304
|
+
])
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
test("explains how to register a workbase when none are known", async () => {
|
|
308
|
+
const harness = createHarness({ outsideWorkbase: true })
|
|
309
|
+
|
|
310
|
+
await expect(harness.run({ cwd: "/outside" })).rejects.toThrow(
|
|
311
|
+
"agency workbase add <path>",
|
|
312
|
+
)
|
|
313
|
+
expect(harness.events).toEqual([])
|
|
314
|
+
})
|
|
315
|
+
|
|
258
316
|
test("prints the target tree when fzf is unavailable", async () => {
|
|
259
317
|
const harness = createHarness({
|
|
260
318
|
available: { fzf: false },
|
|
@@ -401,7 +459,7 @@ describe("work command", () => {
|
|
|
401
459
|
verboseHarness.run({ taskId: "example", verbose: true }),
|
|
402
460
|
)
|
|
403
461
|
expect(verboseLogs).toEqual([
|
|
404
|
-
"Launching opencode
|
|
462
|
+
"Launching command: opencode --continue (cwd: /workbase/tasks/example)",
|
|
405
463
|
])
|
|
406
464
|
expect(verboseHarness.materializeOptions[0]?.verbose).toBe(true)
|
|
407
465
|
|
package/src/commands/work.ts
CHANGED
|
@@ -16,8 +16,14 @@ import {
|
|
|
16
16
|
type PickWorkTarget,
|
|
17
17
|
type WorkTarget,
|
|
18
18
|
} from "../workbase/work-target"
|
|
19
|
+
import {
|
|
20
|
+
pickWorkbase,
|
|
21
|
+
resolveWorkbase,
|
|
22
|
+
type PickWorkbase,
|
|
23
|
+
} from "../workbase/workbase-choice"
|
|
19
24
|
|
|
20
25
|
interface WorkOptions extends BaseCommandOptions {
|
|
26
|
+
readonly directory?: string
|
|
21
27
|
readonly taskId?: string
|
|
22
28
|
readonly phaseId?: string
|
|
23
29
|
readonly epicId?: string
|
|
@@ -27,6 +33,15 @@ interface WorkOptions extends BaseCommandOptions {
|
|
|
27
33
|
|
|
28
34
|
type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
|
|
29
35
|
|
|
36
|
+
const formatCommand = (args: readonly string[]) =>
|
|
37
|
+
args
|
|
38
|
+
.map((argument) =>
|
|
39
|
+
/^[A-Za-z0-9_./:=+@%-]+$/.test(argument)
|
|
40
|
+
? argument
|
|
41
|
+
: `'${argument.replaceAll("'", `'\\''`)}'`,
|
|
42
|
+
)
|
|
43
|
+
.join(" ")
|
|
44
|
+
|
|
30
45
|
const launchAgent: LaunchAgent = (cli, args, cwd) => {
|
|
31
46
|
process.chdir(cwd)
|
|
32
47
|
execvp(cli, [...args])
|
|
@@ -37,6 +52,7 @@ export const work = (
|
|
|
37
52
|
launch: LaunchAgent = launchAgent,
|
|
38
53
|
pick: PickWorkTarget = pickWorkTarget,
|
|
39
54
|
progress: Progress = createProgress(options),
|
|
55
|
+
pickBase: PickWorkbase = pickWorkbase,
|
|
40
56
|
) =>
|
|
41
57
|
Effect.gen(function* () {
|
|
42
58
|
if (options.opencode && options.claude) {
|
|
@@ -44,9 +60,12 @@ export const work = (
|
|
|
44
60
|
new Error("Cannot use both --opencode and --claude"),
|
|
45
61
|
)
|
|
46
62
|
}
|
|
47
|
-
if (
|
|
63
|
+
if (
|
|
64
|
+
options.epicId &&
|
|
65
|
+
(options.directory || options.taskId || options.phaseId)
|
|
66
|
+
) {
|
|
48
67
|
return yield* Effect.fail(
|
|
49
|
-
new Error("Cannot combine --epic with a task or phase ID"),
|
|
68
|
+
new Error("Cannot combine --epic with a directory, task, or phase ID"),
|
|
50
69
|
)
|
|
51
70
|
}
|
|
52
71
|
|
|
@@ -58,7 +77,14 @@ export const work = (
|
|
|
58
77
|
const phases = yield* PhaseService
|
|
59
78
|
const { log, verboseLog } = createLoggers(options)
|
|
60
79
|
const cwd = options.cwd ?? process.cwd()
|
|
61
|
-
const
|
|
80
|
+
const startPath = options.directory ? resolve(cwd, options.directory) : cwd
|
|
81
|
+
if (options.directory && !(yield* fs.isDirectory(startPath))) {
|
|
82
|
+
return yield* Effect.fail(
|
|
83
|
+
new Error(`Work directory does not exist: ${startPath}`),
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
const root = yield* resolveWorkbase(startPath, log, pickBase)
|
|
87
|
+
if (!root) return
|
|
62
88
|
|
|
63
89
|
let target: WorkTarget | null = null
|
|
64
90
|
if (options.epicId) {
|
|
@@ -82,8 +108,8 @@ export const work = (
|
|
|
82
108
|
multiPhase: "phases" in task.data,
|
|
83
109
|
}
|
|
84
110
|
}
|
|
85
|
-
} else {
|
|
86
|
-
const path = relative(root,
|
|
111
|
+
} else if (options.directory) {
|
|
112
|
+
const path = relative(root, startPath)
|
|
87
113
|
const parts =
|
|
88
114
|
!path || isAbsolute(path) || path.startsWith(`..${sep}`)
|
|
89
115
|
? []
|
|
@@ -138,7 +164,7 @@ export const work = (
|
|
|
138
164
|
for (const choice of choices) log(choice.label)
|
|
139
165
|
return yield* Effect.fail(
|
|
140
166
|
new Error(
|
|
141
|
-
"fzf is required to select a work target; install fzf or provide a
|
|
167
|
+
"fzf is required to select a work target; install fzf or provide a directory explicitly",
|
|
142
168
|
),
|
|
143
169
|
)
|
|
144
170
|
}
|
|
@@ -192,16 +218,19 @@ export const work = (
|
|
|
192
218
|
yield* tasks.setStatus(target.taskId, "working", root)
|
|
193
219
|
}
|
|
194
220
|
|
|
195
|
-
verboseLog(`Launching ${cli} in ${launchPath}`)
|
|
196
221
|
const args = cli === "opencode" ? ["--continue"] : [prompt]
|
|
222
|
+
verboseLog(
|
|
223
|
+
`Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
|
|
224
|
+
)
|
|
197
225
|
launch(cli, [cli, ...args], launchPath)
|
|
198
226
|
})
|
|
199
227
|
|
|
200
228
|
export const help = `
|
|
201
|
-
Usage: agency work [<
|
|
229
|
+
Usage: agency work [<directory> | --epic <epic-id>]
|
|
202
230
|
|
|
203
|
-
Launch an agent for
|
|
204
|
-
directory, select
|
|
231
|
+
Launch an agent for an epic, task, or phase. With no directory, select one
|
|
232
|
+
with fzf. Use '.' for the current directory. Outside a workbase, select a
|
|
233
|
+
registered workbase first.
|
|
205
234
|
|
|
206
235
|
Options:
|
|
207
236
|
--epic <id> Work on an epic
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { join } from "node:path"
|
|
3
|
+
import {
|
|
4
|
+
captureLogs,
|
|
5
|
+
cleanupTempDir,
|
|
6
|
+
createTempDir,
|
|
7
|
+
runTestEffect,
|
|
8
|
+
} from "../test-utils"
|
|
9
|
+
import { workbase } from "./workbase"
|
|
10
|
+
|
|
11
|
+
describe("workbase command", () => {
|
|
12
|
+
let root: string
|
|
13
|
+
let configDirectory: string
|
|
14
|
+
|
|
15
|
+
beforeEach(async () => {
|
|
16
|
+
root = await createTempDir()
|
|
17
|
+
configDirectory = join(root, "config")
|
|
18
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(async () => cleanupTempDir(root))
|
|
22
|
+
|
|
23
|
+
test("adds and lists workbases as JSON", async () => {
|
|
24
|
+
const added = await captureLogs(() =>
|
|
25
|
+
runTestEffect(
|
|
26
|
+
workbase({
|
|
27
|
+
subcommand: "add",
|
|
28
|
+
args: [root],
|
|
29
|
+
configDirectory,
|
|
30
|
+
json: true,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
const path = JSON.parse(added[0]!).path
|
|
35
|
+
|
|
36
|
+
const listed = await captureLogs(() =>
|
|
37
|
+
runTestEffect(
|
|
38
|
+
workbase({
|
|
39
|
+
subcommand: "list",
|
|
40
|
+
args: [],
|
|
41
|
+
configDirectory,
|
|
42
|
+
json: true,
|
|
43
|
+
}),
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(JSON.parse(listed[0]!)).toEqual([path])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
test("requires an add path", async () => {
|
|
51
|
+
await expect(
|
|
52
|
+
runTestEffect(
|
|
53
|
+
workbase({
|
|
54
|
+
subcommand: "add",
|
|
55
|
+
args: [],
|
|
56
|
+
configDirectory,
|
|
57
|
+
silent: true,
|
|
58
|
+
}),
|
|
59
|
+
),
|
|
60
|
+
).rejects.toThrow("Usage: agency workbase add <path>")
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
3
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface WorkbaseOptions extends BaseCommandOptions {
|
|
7
|
+
readonly subcommand?: string
|
|
8
|
+
readonly args: readonly string[]
|
|
9
|
+
readonly configDirectory?: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const workbase = (options: WorkbaseOptions) =>
|
|
13
|
+
Effect.gen(function* () {
|
|
14
|
+
const service = yield* WorkbaseService
|
|
15
|
+
const { log } = createLoggers(options)
|
|
16
|
+
|
|
17
|
+
switch (options.subcommand) {
|
|
18
|
+
case "add": {
|
|
19
|
+
const path = options.args[0]
|
|
20
|
+
if (!path) {
|
|
21
|
+
return yield* Effect.fail(
|
|
22
|
+
new Error("Usage: agency workbase add <path>"),
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
const root = yield* service.register(path, options.configDirectory)
|
|
26
|
+
log(
|
|
27
|
+
options.json
|
|
28
|
+
? JSON.stringify({ path: root }, null, 2)
|
|
29
|
+
: `Added workbase ${root}`,
|
|
30
|
+
)
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
case "list": {
|
|
34
|
+
const workbases = yield* service.listRegistered(options.configDirectory)
|
|
35
|
+
if (options.json) {
|
|
36
|
+
log(JSON.stringify(workbases, null, 2))
|
|
37
|
+
} else {
|
|
38
|
+
for (const path of workbases) log(path)
|
|
39
|
+
}
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
default:
|
|
43
|
+
return yield* Effect.fail(
|
|
44
|
+
new Error("Subcommand is required. Available: add, list"),
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
export const help = `
|
|
50
|
+
Usage: agency workbase <subcommand>
|
|
51
|
+
|
|
52
|
+
Subcommands:
|
|
53
|
+
add <path> Register an Agency workbase
|
|
54
|
+
list List registered workbases
|
|
55
|
+
|
|
56
|
+
Options:
|
|
57
|
+
--json Output results as JSON
|
|
58
|
+
`
|
|
@@ -237,12 +237,12 @@ describe("task and phase services", () => {
|
|
|
237
237
|
const task = await runTestEffect(
|
|
238
238
|
TaskService.pipe(
|
|
239
239
|
Effect.flatMap((service) =>
|
|
240
|
-
service.setStatus("single-status", "
|
|
240
|
+
service.setStatus("single-status", "delegated", root),
|
|
241
241
|
),
|
|
242
242
|
),
|
|
243
243
|
)
|
|
244
|
-
expect(task.data.status).toBe("
|
|
245
|
-
expect(task.content).toContain("status:
|
|
244
|
+
expect(task.data.status).toBe("delegated")
|
|
245
|
+
expect(task.content).toContain("status: delegated")
|
|
246
246
|
expect(task.content).toContain("Describe the task outcome.")
|
|
247
247
|
|
|
248
248
|
await runTestEffect(
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
2
|
import { Effect } from "effect"
|
|
3
3
|
import { createHash } from "node:crypto"
|
|
4
|
-
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { mkdir, realpath } from "node:fs/promises"
|
|
5
5
|
import { dirname, join } from "node:path"
|
|
6
6
|
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
7
7
|
import { managedWorkbaseAgents } from "../workbase/agents-file"
|
|
@@ -52,10 +52,42 @@ describe("WorkbaseService", () => {
|
|
|
52
52
|
managedWorkbaseAgents,
|
|
53
53
|
)
|
|
54
54
|
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
55
|
-
managedWorkbaseOpencode,
|
|
55
|
+
managedWorkbaseOpencode(root),
|
|
56
56
|
)
|
|
57
57
|
})
|
|
58
58
|
|
|
59
|
+
test("registers canonical workbase paths without duplicates", async () => {
|
|
60
|
+
const workbaseRoot = join(root, "workbase")
|
|
61
|
+
const nested = join(workbaseRoot, "nested")
|
|
62
|
+
const configDirectory = join(root, "config")
|
|
63
|
+
await write(workbaseRoot, "agency.json", '{"version":2}\n')
|
|
64
|
+
await mkdir(nested, { recursive: true })
|
|
65
|
+
|
|
66
|
+
const first = await runTestEffect(
|
|
67
|
+
WorkbaseService.pipe(
|
|
68
|
+
Effect.flatMap((service) => service.register(nested, configDirectory)),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
await runTestEffect(
|
|
72
|
+
WorkbaseService.pipe(
|
|
73
|
+
Effect.flatMap((service) =>
|
|
74
|
+
service.register(workbaseRoot, configDirectory),
|
|
75
|
+
),
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
const registered = await runTestEffect(
|
|
79
|
+
WorkbaseService.pipe(
|
|
80
|
+
Effect.flatMap((service) => service.listRegistered(configDirectory)),
|
|
81
|
+
),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
expect(first).toBe(await realpath(workbaseRoot))
|
|
85
|
+
expect(registered).toEqual([first])
|
|
86
|
+
expect(
|
|
87
|
+
await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
|
|
88
|
+
).toEqual({ version: 1, workbases: [first] })
|
|
89
|
+
})
|
|
90
|
+
|
|
59
91
|
test("preserves an unmanaged workbase OpenCode config", async () => {
|
|
60
92
|
await write(root, "agency.json", '{"version":2}\n')
|
|
61
93
|
await write(root, ".opencode/opencode.jsonc", '{"model":"test/model"}\n')
|
|
@@ -95,7 +127,7 @@ describe("WorkbaseService", () => {
|
|
|
95
127
|
)
|
|
96
128
|
|
|
97
129
|
expect(await Bun.file(join(root, ".opencode/opencode.jsonc")).text()).toBe(
|
|
98
|
-
managedWorkbaseOpencode,
|
|
130
|
+
managedWorkbaseOpencode(root),
|
|
99
131
|
)
|
|
100
132
|
})
|
|
101
133
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Schema } from "@effect/schema"
|
|
2
2
|
import { TreeFormatter } from "@effect/schema"
|
|
3
3
|
import { Data, Effect, Either } from "effect"
|
|
4
|
+
import { homedir } from "node:os"
|
|
4
5
|
import { dirname, join, relative, resolve } from "node:path"
|
|
5
6
|
import { FileSystemService } from "./FileSystemService"
|
|
6
7
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
@@ -9,6 +10,7 @@ import {
|
|
|
9
10
|
PhaseFrontmatter,
|
|
10
11
|
TaskFrontmatter,
|
|
11
12
|
WorkbaseConfig,
|
|
13
|
+
WorkbaseRegistry,
|
|
12
14
|
type Dependency,
|
|
13
15
|
type EpicFrontmatter as EpicData,
|
|
14
16
|
type PhaseFrontmatter as PhaseData,
|
|
@@ -34,6 +36,12 @@ class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
|
|
|
34
36
|
readonly cause?: unknown
|
|
35
37
|
}> {}
|
|
36
38
|
|
|
39
|
+
class WorkbaseRegistryError extends Data.TaggedError("WorkbaseRegistryError")<{
|
|
40
|
+
readonly message: string
|
|
41
|
+
readonly path: string
|
|
42
|
+
readonly cause?: unknown
|
|
43
|
+
}> {}
|
|
44
|
+
|
|
37
45
|
interface ValidationIssue {
|
|
38
46
|
readonly path: string
|
|
39
47
|
readonly message: string
|
|
@@ -72,6 +80,45 @@ const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
|
72
80
|
: { success: true, value: result.right }
|
|
73
81
|
}
|
|
74
82
|
|
|
83
|
+
const registryPath = (configDirectory?: string) =>
|
|
84
|
+
join(
|
|
85
|
+
configDirectory ||
|
|
86
|
+
process.env.XDG_CONFIG_HOME ||
|
|
87
|
+
join(homedir(), ".config"),
|
|
88
|
+
"agency",
|
|
89
|
+
"workbases.json",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
const readRegistry = (configDirectory?: string) =>
|
|
93
|
+
Effect.gen(function* () {
|
|
94
|
+
const fs = yield* FileSystemService
|
|
95
|
+
const path = registryPath(configDirectory)
|
|
96
|
+
if (!(yield* fs.exists(path))) {
|
|
97
|
+
return { path, registry: { version: 1, workbases: [] } as const }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = yield* fs.readFile(path)
|
|
101
|
+
let input: unknown
|
|
102
|
+
try {
|
|
103
|
+
input = JSON.parse(content)
|
|
104
|
+
} catch (cause) {
|
|
105
|
+
return yield* new WorkbaseRegistryError({
|
|
106
|
+
path,
|
|
107
|
+
message: `Invalid JSON in workbase registry ${path}`,
|
|
108
|
+
cause,
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const decoded = decode(WorkbaseRegistry, input)
|
|
113
|
+
if (!decoded.success) {
|
|
114
|
+
return yield* new WorkbaseRegistryError({
|
|
115
|
+
path,
|
|
116
|
+
message: `Invalid workbase registry in ${path}:\n${decoded.error}`,
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
return { path, registry: decoded.value }
|
|
120
|
+
})
|
|
121
|
+
|
|
75
122
|
const ensureWorkbaseAgents = (root: string) =>
|
|
76
123
|
Effect.gen(function* () {
|
|
77
124
|
const fs = yield* FileSystemService
|
|
@@ -95,19 +142,17 @@ const ensureWorkbaseOpencode = (root: string) =>
|
|
|
95
142
|
const fs = yield* FileSystemService
|
|
96
143
|
const directory = join(root, ".opencode")
|
|
97
144
|
const path = join(directory, "opencode.jsonc")
|
|
145
|
+
const managed = managedWorkbaseOpencode(root)
|
|
98
146
|
if (!(yield* fs.exists(path))) {
|
|
99
147
|
if (yield* fs.exists(join(directory, "opencode.json"))) return
|
|
100
148
|
yield* fs.createDirectory(directory)
|
|
101
|
-
yield* fs.writeFile(path,
|
|
149
|
+
yield* fs.writeFile(path, managed)
|
|
102
150
|
return
|
|
103
151
|
}
|
|
104
152
|
|
|
105
153
|
const content = yield* fs.readFile(path)
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
canUpdateManagedWorkbaseOpencode(content)
|
|
109
|
-
) {
|
|
110
|
-
yield* fs.writeFile(path, managedWorkbaseOpencode)
|
|
154
|
+
if (content !== managed && canUpdateManagedWorkbaseOpencode(content)) {
|
|
155
|
+
yield* fs.writeFile(path, managed)
|
|
111
156
|
}
|
|
112
157
|
})
|
|
113
158
|
|
|
@@ -293,6 +338,28 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
293
338
|
return { root, config: decoded.value }
|
|
294
339
|
}),
|
|
295
340
|
|
|
341
|
+
register: (startPath: string, configDirectory?: string) =>
|
|
342
|
+
Effect.gen(function* () {
|
|
343
|
+
const service = yield* WorkbaseService
|
|
344
|
+
const fs = yield* FileSystemService
|
|
345
|
+
const discovered = yield* service.discover(startPath)
|
|
346
|
+
const root = yield* fs.realPath(discovered)
|
|
347
|
+
const { path, registry } = yield* readRegistry(configDirectory)
|
|
348
|
+
if (registry.workbases.includes(root)) return root
|
|
349
|
+
|
|
350
|
+
yield* fs.createDirectory(dirname(path))
|
|
351
|
+
yield* fs.writeJSON(path, {
|
|
352
|
+
version: 1,
|
|
353
|
+
workbases: [...registry.workbases, root],
|
|
354
|
+
})
|
|
355
|
+
return root
|
|
356
|
+
}),
|
|
357
|
+
|
|
358
|
+
listRegistered: (configDirectory?: string) =>
|
|
359
|
+
readRegistry(configDirectory).pipe(
|
|
360
|
+
Effect.map(({ registry }) => registry.workbases),
|
|
361
|
+
),
|
|
362
|
+
|
|
296
363
|
validate: (startPath: string = process.cwd()) =>
|
|
297
364
|
Effect.gen(function* () {
|
|
298
365
|
const service = yield* WorkbaseService
|
|
@@ -85,6 +85,51 @@ describe("WorktreeService", () => {
|
|
|
85
85
|
expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
|
|
86
86
|
})
|
|
87
87
|
|
|
88
|
+
test("does not fetch origins for existing worktrees", async () => {
|
|
89
|
+
await runTestEffect(
|
|
90
|
+
TaskService.pipe(
|
|
91
|
+
Effect.flatMap((service) =>
|
|
92
|
+
service.create(
|
|
93
|
+
{
|
|
94
|
+
id: "existing",
|
|
95
|
+
ticketUrl: "https://example.com/task",
|
|
96
|
+
repo: "agency",
|
|
97
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
98
|
+
branch: "task/existing",
|
|
99
|
+
base: "main",
|
|
100
|
+
},
|
|
101
|
+
root,
|
|
102
|
+
),
|
|
103
|
+
),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
await runTestEffect(
|
|
107
|
+
WorktreeService.pipe(
|
|
108
|
+
Effect.flatMap((service) =>
|
|
109
|
+
service.materialize("existing", undefined, root),
|
|
110
|
+
),
|
|
111
|
+
),
|
|
112
|
+
)
|
|
113
|
+
await git(
|
|
114
|
+
["remote", "set-url", "origin", join(root, "missing")],
|
|
115
|
+
join(root, "repos/agency"),
|
|
116
|
+
)
|
|
117
|
+
await git(
|
|
118
|
+
["remote", "set-url", "origin", join(root, "missing")],
|
|
119
|
+
join(root, "repos/effect"),
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
await expect(
|
|
123
|
+
runTestEffect(
|
|
124
|
+
WorktreeService.pipe(
|
|
125
|
+
Effect.flatMap((service) =>
|
|
126
|
+
service.materialize("existing", undefined, root),
|
|
127
|
+
),
|
|
128
|
+
),
|
|
129
|
+
),
|
|
130
|
+
).resolves.toMatchObject({ repo: "agency" })
|
|
131
|
+
})
|
|
132
|
+
|
|
88
133
|
test("uses a configured worktree creation command", async () => {
|
|
89
134
|
await Bun.write(
|
|
90
135
|
join(root, "agency.json"),
|