@markjaquith/agency 2.5.0 → 2.6.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 +16 -9
- package/cli.ts +22 -2
- package/package.json +1 -1
- package/skills/agency/SKILL.md +14 -5
- package/src/cli.test.ts +30 -3
- 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 +49 -2
- package/src/commands/work.ts +10 -2
- 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 +33 -1
- package/src/services/WorkbaseService.ts +69 -0
- 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
package/README.md
CHANGED
|
@@ -240,11 +240,15 @@ agency pr create refresh-copy
|
|
|
240
240
|
|
|
241
241
|
```text
|
|
242
242
|
agency init [path] [--json]
|
|
243
|
+
agency workbase add <path> [--json]
|
|
244
|
+
agency workbase list [--json]
|
|
243
245
|
agency repo add <alias> <remote> [--json]
|
|
244
246
|
agency repo link <alias> <path> [--json]
|
|
245
247
|
agency repo list [--json]
|
|
246
248
|
```
|
|
247
249
|
|
|
250
|
+
Registered workbases are stored in
|
|
251
|
+
`$XDG_CONFIG_HOME/agency/workbases.json` (or `~/.config/agency/workbases.json`).
|
|
248
252
|
`repo add` creates a bare clone. `repo link` creates a symlink to an existing Git
|
|
249
253
|
repository. Alias names are then used by all documents and commands.
|
|
250
254
|
|
|
@@ -294,7 +298,7 @@ Inspect tasks:
|
|
|
294
298
|
```text
|
|
295
299
|
agency task list [--json]
|
|
296
300
|
agency task show <id> [--json]
|
|
297
|
-
agency task status <id> <open|working|done|dropped> [--json]
|
|
301
|
+
agency task status <id> <open|working|delegated|done|dropped> [--json]
|
|
298
302
|
```
|
|
299
303
|
|
|
300
304
|
To add a phase to an existing single-phase task, name the phase that will own
|
|
@@ -321,14 +325,14 @@ agency phase create <task-id> <phase-id>
|
|
|
321
325
|
|
|
322
326
|
agency phase list <task-id> [--json]
|
|
323
327
|
agency phase show <task-id> <phase-id> [--json]
|
|
324
|
-
agency phase status <task-id> <phase-id> <open|working|done|dropped> [--json]
|
|
328
|
+
agency phase status <task-id> <phase-id> <open|working|delegated|done|dropped> [--json]
|
|
325
329
|
```
|
|
326
330
|
|
|
327
331
|
Single-phase tasks and phases store status in YAML. New execution units start
|
|
328
332
|
`open`, and `agency work` marks the selected execution unit `working` immediately
|
|
329
|
-
before launch. Use the status subcommands to mark work `
|
|
330
|
-
it again. The interactive work selector displays status
|
|
331
|
-
units.
|
|
333
|
+
before launch. Use the status subcommands to mark work `delegated`, `done`,
|
|
334
|
+
`dropped`, or open it again. The interactive work selector displays status
|
|
335
|
+
markers before execution units.
|
|
332
336
|
|
|
333
337
|
### Archive
|
|
334
338
|
|
|
@@ -351,8 +355,10 @@ agency pr create <task-id> [phase-id] [--draft] [--json]
|
|
|
351
355
|
```
|
|
352
356
|
|
|
353
357
|
`agency work` infers an epic, task, or phase from the current directory. From
|
|
354
|
-
elsewhere in the workbase, it presents the full hierarchy in `fzf`.
|
|
355
|
-
|
|
358
|
+
elsewhere in the workbase, it presents the full hierarchy in `fzf`. Outside a
|
|
359
|
+
workbase, it first presents the registered workbases, then the selected
|
|
360
|
+
workbase's hierarchy. If `fzf` is not installed, Agency prints the available
|
|
361
|
+
choices and asks for an explicit target.
|
|
356
362
|
|
|
357
363
|
Epic and multi-phase task targets launch orchestration agents beside their
|
|
358
364
|
documents. Single-phase tasks and phases fetch repositories, create or reuse
|
|
@@ -380,13 +386,14 @@ the owning `TASK.md` or `PHASE.md`.
|
|
|
380
386
|
|
|
381
387
|
```text
|
|
382
388
|
agency status [--json]
|
|
383
|
-
agency validate [--json]
|
|
389
|
+
agency validate [path] [--json]
|
|
384
390
|
```
|
|
385
391
|
|
|
386
392
|
Validation checks JSON and YAML parsing, Effect Schema conformance, repository
|
|
387
393
|
aliases, parent/child backlinks, phase directories, duplicate references,
|
|
388
394
|
unknown dependencies, and dependency cycles. YAML duplicate keys, anchors,
|
|
389
|
-
aliases, and custom tags are rejected.
|
|
395
|
+
aliases, and custom tags are rejected. When path is omitted outside a workbase,
|
|
396
|
+
Agency prompts for a registered workbase.
|
|
390
397
|
|
|
391
398
|
## Agent Skill
|
|
392
399
|
|
package/cli.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { repo, help as repoHelp } from "./src/commands/repo"
|
|
|
12
12
|
import { epic, help as epicHelp } from "./src/commands/epic"
|
|
13
13
|
import { phase, help as phaseHelp } from "./src/commands/phase"
|
|
14
14
|
import { archive, help as archiveHelp } from "./src/commands/archive"
|
|
15
|
+
import { workbase, help as workbaseHelp } from "./src/commands/workbase"
|
|
15
16
|
import type { Command } from "./src/types"
|
|
16
17
|
import { FileSystemService } from "./src/services/FileSystemService"
|
|
17
18
|
import { WorkbaseService } from "./src/services/WorkbaseService"
|
|
@@ -173,6 +174,23 @@ const commands: Record<string, Command> = {
|
|
|
173
174
|
)
|
|
174
175
|
},
|
|
175
176
|
},
|
|
177
|
+
workbase: {
|
|
178
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
179
|
+
if (options.help) {
|
|
180
|
+
console.log(workbaseHelp)
|
|
181
|
+
return
|
|
182
|
+
}
|
|
183
|
+
await runCommand(
|
|
184
|
+
workbase({
|
|
185
|
+
subcommand: args[0],
|
|
186
|
+
args: args.slice(1),
|
|
187
|
+
json: options.json,
|
|
188
|
+
silent: options.silent,
|
|
189
|
+
verbose: options.verbose,
|
|
190
|
+
}),
|
|
191
|
+
)
|
|
192
|
+
},
|
|
193
|
+
},
|
|
176
194
|
repo: {
|
|
177
195
|
run: async (args: string[], options: Record<string, any>) => {
|
|
178
196
|
if (options.help) {
|
|
@@ -251,13 +269,14 @@ const commands: Record<string, Command> = {
|
|
|
251
269
|
},
|
|
252
270
|
},
|
|
253
271
|
validate: {
|
|
254
|
-
run: async (
|
|
272
|
+
run: async (args: string[], options: Record<string, any>) => {
|
|
255
273
|
if (options.help) {
|
|
256
274
|
console.log(validateHelp)
|
|
257
275
|
return
|
|
258
276
|
}
|
|
259
277
|
await runCommand(
|
|
260
278
|
validate({
|
|
279
|
+
path: args[0],
|
|
261
280
|
silent: options.silent,
|
|
262
281
|
verbose: options.verbose,
|
|
263
282
|
json: options.json,
|
|
@@ -275,6 +294,7 @@ Usage: agency <command> [options]
|
|
|
275
294
|
|
|
276
295
|
Commands:
|
|
277
296
|
init [path] Initialize an Agency workbase
|
|
297
|
+
workbase <subcommand> Manage registered workbases
|
|
278
298
|
epic <subcommand> Manage epics
|
|
279
299
|
phase <subcommand> Manage task phases
|
|
280
300
|
archive <type> Archive a work item
|
|
@@ -283,7 +303,7 @@ Commands:
|
|
|
283
303
|
pr create Create a pull request for an execution unit
|
|
284
304
|
repo <subcommand> Manage workbase repositories
|
|
285
305
|
status Show status for the current workbase
|
|
286
|
-
validate
|
|
306
|
+
validate [path] Validate a workbase
|
|
287
307
|
|
|
288
308
|
Global Options:
|
|
289
309
|
-h, --help Show help for a command
|
package/package.json
CHANGED
package/skills/agency/SKILL.md
CHANGED
|
@@ -58,6 +58,13 @@ If no workbase is found, do not initialize one without user intent. When asked:
|
|
|
58
58
|
agency init [path]
|
|
59
59
|
```
|
|
60
60
|
|
|
61
|
+
Register known workbases so `agency work` can select one when run elsewhere:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
agency workbase add <path>
|
|
65
|
+
agency workbase list
|
|
66
|
+
```
|
|
67
|
+
|
|
61
68
|
## Repository Aliases
|
|
62
69
|
|
|
63
70
|
Add a remote as an Agency-managed bare repository:
|
|
@@ -195,8 +202,8 @@ output, including mutations, entity inspection, status, validation, and PR creat
|
|
|
195
202
|
- Multi-phase task frontmatter owns the phase dependency graph.
|
|
196
203
|
- Epic frontmatter owns the child-task dependency graph.
|
|
197
204
|
- `pr` is either a GitHub PR URL string or `null`.
|
|
198
|
-
- Execution-unit `status` is `open`, `working`, `done`, or `dropped`.
|
|
199
|
-
starts open, and `agency work` marks it working before agent launch.
|
|
205
|
+
- Execution-unit `status` is `open`, `working`, `delegated`, `done`, or `dropped`.
|
|
206
|
+
New work starts open, and `agency work` marks it working before agent launch.
|
|
200
207
|
- Keep directory IDs stable; encode sequencing with `dependsOn`, not numeric
|
|
201
208
|
directory prefixes.
|
|
202
209
|
- Do not use YAML duplicate keys, anchors, aliases, or custom tags.
|
|
@@ -207,8 +214,8 @@ prose, preserve backlinks and run validation immediately afterward.
|
|
|
207
214
|
Update execution status with:
|
|
208
215
|
|
|
209
216
|
```bash
|
|
210
|
-
agency task status <task-id> <open|working|done|dropped>
|
|
211
|
-
agency phase status <task-id> <phase-id> <open|working|done|dropped>
|
|
217
|
+
agency task status <task-id> <open|working|delegated|done|dropped>
|
|
218
|
+
agency phase status <task-id> <phase-id> <open|working|delegated|done|dropped>
|
|
212
219
|
```
|
|
213
220
|
|
|
214
221
|
## Archive Completed Work
|
|
@@ -226,13 +233,14 @@ branches. It refuses dirty worktrees and active sibling dependencies.
|
|
|
226
233
|
## Validate Every Structural Change
|
|
227
234
|
|
|
228
235
|
```bash
|
|
229
|
-
agency validate
|
|
236
|
+
agency validate [path]
|
|
230
237
|
```
|
|
231
238
|
|
|
232
239
|
Use `--json` when diagnostics will be consumed programmatically. Resolve all
|
|
233
240
|
validation errors before materializing worktrees or creating PRs. Validation
|
|
234
241
|
checks schemas, aliases, backlinks, phase directories, duplicate references,
|
|
235
242
|
duplicate writable branch ownership, unknown dependencies, and dependency cycles.
|
|
243
|
+
Outside a workbase, omitting path opens the registered-workbase picker.
|
|
236
244
|
|
|
237
245
|
## Worktrees And Agent Launch
|
|
238
246
|
|
|
@@ -247,6 +255,7 @@ repositories for execution targets, creates or reuses their worktrees, and
|
|
|
247
255
|
replaces the current process with the selected agent. It infers the nearest
|
|
248
256
|
epic, task, or phase from the current directory; otherwise it opens an `fzf`
|
|
249
257
|
picker containing the workbase hierarchy.
|
|
258
|
+
Outside a workbase, it first opens a picker containing registered workbases.
|
|
250
259
|
|
|
251
260
|
Epic and multi-phase task targets are orchestration sessions launched beside
|
|
252
261
|
their documents. Single-phase tasks and phases are execution sessions launched
|
package/src/cli.test.ts
CHANGED
|
@@ -12,9 +12,14 @@ interface CliResult {
|
|
|
12
12
|
stderr: string
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
async function runCli(
|
|
15
|
+
async function runCli(
|
|
16
|
+
args: string[],
|
|
17
|
+
cwd = projectRoot,
|
|
18
|
+
env?: Record<string, string>,
|
|
19
|
+
): Promise<CliResult> {
|
|
16
20
|
const subprocess = Bun.spawn([process.execPath, cliPath, ...args], {
|
|
17
21
|
cwd,
|
|
22
|
+
env: env ? { ...process.env, ...env } : undefined,
|
|
18
23
|
stdout: "pipe",
|
|
19
24
|
stderr: "pipe",
|
|
20
25
|
})
|
|
@@ -76,6 +81,7 @@ describe("CLI", () => {
|
|
|
76
81
|
test("routes command help and global options on either side of commands", async () => {
|
|
77
82
|
for (const [command, usage] of [
|
|
78
83
|
["init", "Usage: agency init"],
|
|
84
|
+
["workbase", "Usage: agency workbase"],
|
|
79
85
|
["repo", "Usage: agency repo"],
|
|
80
86
|
["epic", "Usage: agency epic"],
|
|
81
87
|
["task", "Usage: agency task"],
|
|
@@ -106,6 +112,25 @@ describe("CLI", () => {
|
|
|
106
112
|
expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
|
|
107
113
|
})
|
|
108
114
|
|
|
115
|
+
test("registers and lists workbases", async () => {
|
|
116
|
+
const parent = await createTempDir()
|
|
117
|
+
tempDirs.push(parent)
|
|
118
|
+
const root = join(parent, "workbase")
|
|
119
|
+
const env = { XDG_CONFIG_HOME: join(parent, "config") }
|
|
120
|
+
|
|
121
|
+
expect(
|
|
122
|
+
parseJson(await runCli(["init", root, "--json"], parent, env)),
|
|
123
|
+
).toEqual({
|
|
124
|
+
root,
|
|
125
|
+
})
|
|
126
|
+
expect(
|
|
127
|
+
parseJson(await runCli(["workbase", "add", root, "--json"], parent, env)),
|
|
128
|
+
).toEqual({ path: await realpath(root) })
|
|
129
|
+
expect(
|
|
130
|
+
parseJson(await runCli(["workbase", "list", "--json"], parent, env)),
|
|
131
|
+
).toEqual([await realpath(root)])
|
|
132
|
+
})
|
|
133
|
+
|
|
109
134
|
test("runs a multi-phase domain workflow through subprocesses", async () => {
|
|
110
135
|
const parent = await createTempDir()
|
|
111
136
|
tempDirs.push(parent)
|
|
@@ -278,9 +303,11 @@ describe("CLI", () => {
|
|
|
278
303
|
issues: [],
|
|
279
304
|
})
|
|
280
305
|
|
|
281
|
-
const validation = parseJson(
|
|
306
|
+
const validation = parseJson(
|
|
307
|
+
await runCli(["validate", root, "--json"], parent),
|
|
308
|
+
)
|
|
282
309
|
expect(validation).toEqual({
|
|
283
|
-
root
|
|
310
|
+
root,
|
|
284
311
|
issues: [],
|
|
285
312
|
epicCount: 1,
|
|
286
313
|
taskCount: 1,
|
package/src/commands/phase.ts
CHANGED
|
@@ -125,7 +125,7 @@ Subcommands:
|
|
|
125
125
|
list <task> List task phases
|
|
126
126
|
show <task> <phase> Show a phase
|
|
127
127
|
status <task> <phase> <status>
|
|
128
|
-
Set open, working, done, or dropped
|
|
128
|
+
Set open, working, delegated, done, or dropped
|
|
129
129
|
|
|
130
130
|
Create options:
|
|
131
131
|
--description <text> Short description of the phase
|
|
@@ -172,13 +172,13 @@ describe("task and phase command JSON output", () => {
|
|
|
172
172
|
runTestEffect(
|
|
173
173
|
phase({
|
|
174
174
|
subcommand: "status",
|
|
175
|
-
args: ["multi", "first", "
|
|
175
|
+
args: ["multi", "first", "delegated"],
|
|
176
176
|
cwd: root,
|
|
177
177
|
json: true,
|
|
178
178
|
}),
|
|
179
179
|
),
|
|
180
180
|
)
|
|
181
|
-
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("
|
|
181
|
+
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("delegated")
|
|
182
182
|
|
|
183
183
|
await runTestEffect(
|
|
184
184
|
task({
|
|
@@ -196,13 +196,13 @@ describe("task and phase command JSON output", () => {
|
|
|
196
196
|
runTestEffect(
|
|
197
197
|
task({
|
|
198
198
|
subcommand: "status",
|
|
199
|
-
args: ["single-status", "
|
|
199
|
+
args: ["single-status", "delegated"],
|
|
200
200
|
cwd: root,
|
|
201
201
|
json: true,
|
|
202
202
|
}),
|
|
203
203
|
),
|
|
204
204
|
)
|
|
205
|
-
expect(JSON.parse(taskLogs[0]!).data.status).toBe("
|
|
205
|
+
expect(JSON.parse(taskLogs[0]!).data.status).toBe("delegated")
|
|
206
206
|
})
|
|
207
207
|
|
|
208
208
|
test("converts a single-phase task with an explicit first phase ID", async () => {
|
package/src/commands/task.ts
CHANGED
|
@@ -237,7 +237,7 @@ Subcommands:
|
|
|
237
237
|
create <id> Create a task; omitted metadata uses defaults
|
|
238
238
|
list List tasks
|
|
239
239
|
show <id> Show a task
|
|
240
|
-
status <id> <status> Set open, working, done, or dropped
|
|
240
|
+
status <id> <status> Set open, working, delegated, done, or dropped
|
|
241
241
|
|
|
242
242
|
Create options:
|
|
243
243
|
--ticket-url <url> External ticket URL (optional)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
2
3
|
import { mkdir } from "node:fs/promises"
|
|
3
4
|
import { join } from "node:path"
|
|
4
5
|
import {
|
|
@@ -7,6 +8,9 @@ import {
|
|
|
7
8
|
createTempDir,
|
|
8
9
|
runTestEffect,
|
|
9
10
|
} from "../test-utils"
|
|
11
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
12
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
13
|
+
import type { PickWorkbase } from "../workbase/workbase-choice"
|
|
10
14
|
import { validate } from "./validate"
|
|
11
15
|
|
|
12
16
|
describe("validate command", () => {
|
|
@@ -43,6 +47,67 @@ pr: null
|
|
|
43
47
|
).resolves.toBeUndefined()
|
|
44
48
|
})
|
|
45
49
|
|
|
50
|
+
test("validates an explicit workbase path", async () => {
|
|
51
|
+
await Bun.write(
|
|
52
|
+
join(root, "tasks/example/TASK.md"),
|
|
53
|
+
`---
|
|
54
|
+
ticketUrl: null
|
|
55
|
+
repo: agency
|
|
56
|
+
branch: task/example
|
|
57
|
+
base: main
|
|
58
|
+
pr: null
|
|
59
|
+
---
|
|
60
|
+
`,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
runTestEffect(
|
|
65
|
+
validate({ path: root, cwd: join(root, "outside"), silent: true }),
|
|
66
|
+
),
|
|
67
|
+
).resolves.toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
test("selects a registered workbase when local discovery fails", async () => {
|
|
71
|
+
const discovered: string[] = []
|
|
72
|
+
const workbase = {
|
|
73
|
+
discover: (path: string) => {
|
|
74
|
+
discovered.push(path)
|
|
75
|
+
return path === "/outside"
|
|
76
|
+
? Effect.fail({
|
|
77
|
+
_tag: "WorkbaseNotFoundError" as const,
|
|
78
|
+
message: "No Agency workbase found from /outside",
|
|
79
|
+
})
|
|
80
|
+
: Effect.succeed(path)
|
|
81
|
+
},
|
|
82
|
+
listRegistered: () => Effect.succeed(["/first", "/selected"]),
|
|
83
|
+
validate: (path: string) =>
|
|
84
|
+
Effect.succeed({
|
|
85
|
+
root: path,
|
|
86
|
+
issues: [],
|
|
87
|
+
epicCount: 0,
|
|
88
|
+
taskCount: 0,
|
|
89
|
+
phaseCount: 0,
|
|
90
|
+
valid: true,
|
|
91
|
+
}),
|
|
92
|
+
}
|
|
93
|
+
const fs = {
|
|
94
|
+
runCommand: () => Effect.succeed({ exitCode: 0, stdout: "", stderr: "" }),
|
|
95
|
+
}
|
|
96
|
+
const pick: PickWorkbase = (workbases) => {
|
|
97
|
+
expect(workbases).toEqual(["/first", "/selected"])
|
|
98
|
+
return Effect.succeed("/selected")
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await Effect.runPromise(
|
|
102
|
+
validate({ cwd: "/outside", silent: true }, pick).pipe(
|
|
103
|
+
Effect.provideService(WorkbaseService, workbase as never),
|
|
104
|
+
Effect.provideService(FileSystemService, fs as never),
|
|
105
|
+
) as Effect.Effect<void, unknown, never>,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
expect(discovered).toEqual(["/outside", "/selected"])
|
|
109
|
+
})
|
|
110
|
+
|
|
46
111
|
test("outputs the validation report as JSON", async () => {
|
|
47
112
|
await Bun.write(
|
|
48
113
|
join(root, "tasks/example/TASK.md"),
|
package/src/commands/validate.ts
CHANGED
|
@@ -2,8 +2,14 @@ import { Data, Effect } from "effect"
|
|
|
2
2
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
3
|
import { WorkbaseService } from "../services/WorkbaseService"
|
|
4
4
|
import { createLoggers } from "../utils/effect"
|
|
5
|
+
import {
|
|
6
|
+
pickWorkbase,
|
|
7
|
+
resolveWorkbase,
|
|
8
|
+
type PickWorkbase,
|
|
9
|
+
} from "../workbase/workbase-choice"
|
|
5
10
|
|
|
6
11
|
interface ValidateOptions extends BaseCommandOptions {
|
|
12
|
+
readonly path?: string
|
|
7
13
|
readonly json?: boolean
|
|
8
14
|
}
|
|
9
15
|
|
|
@@ -11,11 +17,19 @@ class ValidationFailedError extends Data.TaggedError("ValidationFailedError")<{
|
|
|
11
17
|
readonly message: string
|
|
12
18
|
}> {}
|
|
13
19
|
|
|
14
|
-
export const validate = (
|
|
20
|
+
export const validate = (
|
|
21
|
+
options: ValidateOptions = {},
|
|
22
|
+
pick: PickWorkbase = pickWorkbase,
|
|
23
|
+
) =>
|
|
15
24
|
Effect.gen(function* () {
|
|
16
25
|
const workbase = yield* WorkbaseService
|
|
17
26
|
const { log } = createLoggers(options)
|
|
18
|
-
const
|
|
27
|
+
const startPath = options.path ?? options.cwd ?? process.cwd()
|
|
28
|
+
const root = options.path
|
|
29
|
+
? yield* workbase.discover(startPath)
|
|
30
|
+
: yield* resolveWorkbase(startPath, log, pick)
|
|
31
|
+
if (!root) return
|
|
32
|
+
const report = yield* workbase.validate(root)
|
|
19
33
|
|
|
20
34
|
if (options.json) {
|
|
21
35
|
log(JSON.stringify(report, null, 2))
|
|
@@ -40,10 +54,10 @@ export const validate = (options: ValidateOptions = {}) =>
|
|
|
40
54
|
})
|
|
41
55
|
|
|
42
56
|
export const help = `
|
|
43
|
-
Usage: agency validate [options]
|
|
57
|
+
Usage: agency validate [path] [options]
|
|
44
58
|
|
|
45
|
-
Validate
|
|
46
|
-
|
|
59
|
+
Validate a workbase's configuration, frontmatter, references, and dependency
|
|
60
|
+
graphs. The current or selected registered workbase is used when path is omitted.
|
|
47
61
|
|
|
48
62
|
Options:
|
|
49
63
|
--json Output the validation report as JSON
|
|
@@ -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) =>
|
|
@@ -130,6 +140,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
130
140
|
launches.push({ cli, args, cwd })
|
|
131
141
|
}
|
|
132
142
|
const defaultPick: PickWorkTarget = () => Effect.succeed(null)
|
|
143
|
+
const defaultPickWorkbase: PickWorkbase = () => Effect.succeed(null)
|
|
133
144
|
const progress: Progress = {
|
|
134
145
|
start: (message) => progressUpdates.push(`start:${message}`),
|
|
135
146
|
succeed: (message) => progressUpdates.push(`succeed:${message}`),
|
|
@@ -138,9 +149,10 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
138
149
|
const run = (
|
|
139
150
|
commandOptions: Parameters<typeof work>[0],
|
|
140
151
|
pick: PickWorkTarget = defaultPick,
|
|
152
|
+
pickBase: PickWorkbase = defaultPickWorkbase,
|
|
141
153
|
) =>
|
|
142
154
|
Effect.runPromise(
|
|
143
|
-
work(commandOptions, launch, pick, progress).pipe(
|
|
155
|
+
work(commandOptions, launch, pick, progress, pickBase).pipe(
|
|
144
156
|
Effect.provideService(WorktreeService, worktrees as never),
|
|
145
157
|
Effect.provideService(FileSystemService, fs as never),
|
|
146
158
|
Effect.provideService(WorkbaseService, workbase as never),
|
|
@@ -255,6 +267,41 @@ describe("work command", () => {
|
|
|
255
267
|
)
|
|
256
268
|
})
|
|
257
269
|
|
|
270
|
+
test("selects a registered workbase when local discovery fails", async () => {
|
|
271
|
+
const harness = createHarness({
|
|
272
|
+
outsideWorkbase: true,
|
|
273
|
+
registeredWorkbases: ["/first", "/workbase"],
|
|
274
|
+
})
|
|
275
|
+
const selections: string[][] = []
|
|
276
|
+
const pickBase: PickWorkbase = (workbases) => {
|
|
277
|
+
selections.push([...workbases])
|
|
278
|
+
return Effect.succeed("/workbase")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
await harness.run(
|
|
282
|
+
{ cwd: "/outside", taskId: "example", opencode: true },
|
|
283
|
+
undefined,
|
|
284
|
+
pickBase,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
expect(selections).toEqual([["/first", "/workbase"]])
|
|
288
|
+
expect(harness.events).toEqual([
|
|
289
|
+
"probe:fzf",
|
|
290
|
+
"materialize",
|
|
291
|
+
"probe:opencode",
|
|
292
|
+
"launch:opencode",
|
|
293
|
+
])
|
|
294
|
+
})
|
|
295
|
+
|
|
296
|
+
test("explains how to register a workbase when none are known", async () => {
|
|
297
|
+
const harness = createHarness({ outsideWorkbase: true })
|
|
298
|
+
|
|
299
|
+
await expect(harness.run({ cwd: "/outside" })).rejects.toThrow(
|
|
300
|
+
"agency workbase add <path>",
|
|
301
|
+
)
|
|
302
|
+
expect(harness.events).toEqual([])
|
|
303
|
+
})
|
|
304
|
+
|
|
258
305
|
test("prints the target tree when fzf is unavailable", async () => {
|
|
259
306
|
const harness = createHarness({
|
|
260
307
|
available: { fzf: false },
|
package/src/commands/work.ts
CHANGED
|
@@ -16,6 +16,11 @@ 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 {
|
|
21
26
|
readonly taskId?: string
|
|
@@ -37,6 +42,7 @@ export const work = (
|
|
|
37
42
|
launch: LaunchAgent = launchAgent,
|
|
38
43
|
pick: PickWorkTarget = pickWorkTarget,
|
|
39
44
|
progress: Progress = createProgress(options),
|
|
45
|
+
pickBase: PickWorkbase = pickWorkbase,
|
|
40
46
|
) =>
|
|
41
47
|
Effect.gen(function* () {
|
|
42
48
|
if (options.opencode && options.claude) {
|
|
@@ -58,7 +64,8 @@ export const work = (
|
|
|
58
64
|
const phases = yield* PhaseService
|
|
59
65
|
const { log, verboseLog } = createLoggers(options)
|
|
60
66
|
const cwd = options.cwd ?? process.cwd()
|
|
61
|
-
const root = yield*
|
|
67
|
+
const root = yield* resolveWorkbase(cwd, log, pickBase)
|
|
68
|
+
if (!root) return
|
|
62
69
|
|
|
63
70
|
let target: WorkTarget | null = null
|
|
64
71
|
if (options.epicId) {
|
|
@@ -201,7 +208,8 @@ export const help = `
|
|
|
201
208
|
Usage: agency work [<task-id> [phase-id] | --epic <epic-id>]
|
|
202
209
|
|
|
203
210
|
Launch an agent for the current epic, task, or phase. Outside an entity
|
|
204
|
-
directory, select one with fzf.
|
|
211
|
+
directory, select one with fzf. Outside a workbase, select a registered
|
|
212
|
+
workbase first.
|
|
205
213
|
|
|
206
214
|
Options:
|
|
207
215
|
--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"
|
|
@@ -56,6 +56,38 @@ describe("WorkbaseService", () => {
|
|
|
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')
|
|
@@ -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
|
|
@@ -293,6 +340,28 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
|
|
|
293
340
|
return { root, config: decoded.value }
|
|
294
341
|
}),
|
|
295
342
|
|
|
343
|
+
register: (startPath: string, configDirectory?: string) =>
|
|
344
|
+
Effect.gen(function* () {
|
|
345
|
+
const service = yield* WorkbaseService
|
|
346
|
+
const fs = yield* FileSystemService
|
|
347
|
+
const discovered = yield* service.discover(startPath)
|
|
348
|
+
const root = yield* fs.realPath(discovered)
|
|
349
|
+
const { path, registry } = yield* readRegistry(configDirectory)
|
|
350
|
+
if (registry.workbases.includes(root)) return root
|
|
351
|
+
|
|
352
|
+
yield* fs.createDirectory(dirname(path))
|
|
353
|
+
yield* fs.writeJSON(path, {
|
|
354
|
+
version: 1,
|
|
355
|
+
workbases: [...registry.workbases, root],
|
|
356
|
+
})
|
|
357
|
+
return root
|
|
358
|
+
}),
|
|
359
|
+
|
|
360
|
+
listRegistered: (configDirectory?: string) =>
|
|
361
|
+
readRegistry(configDirectory).pipe(
|
|
362
|
+
Effect.map(({ registry }) => registry.workbases),
|
|
363
|
+
),
|
|
364
|
+
|
|
296
365
|
validate: (startPath: string = process.cwd()) =>
|
|
297
366
|
Effect.gen(function* () {
|
|
298
367
|
const service = yield* WorkbaseService
|
|
@@ -5,7 +5,9 @@ import {
|
|
|
5
5
|
EpicFrontmatter,
|
|
6
6
|
PhaseFrontmatter,
|
|
7
7
|
TaskFrontmatter,
|
|
8
|
+
WorkStatus,
|
|
8
9
|
WorkbaseConfig,
|
|
10
|
+
WorkbaseRegistry,
|
|
9
11
|
} from "./schemas"
|
|
10
12
|
|
|
11
13
|
describe("body-of-work descriptions", () => {
|
|
@@ -76,6 +78,14 @@ describe("body-of-work descriptions", () => {
|
|
|
76
78
|
})
|
|
77
79
|
|
|
78
80
|
describe("work status", () => {
|
|
81
|
+
const supportedStatuses: Record<WorkStatus, true> = {
|
|
82
|
+
open: true,
|
|
83
|
+
working: true,
|
|
84
|
+
delegated: true,
|
|
85
|
+
done: true,
|
|
86
|
+
dropped: true,
|
|
87
|
+
}
|
|
88
|
+
|
|
79
89
|
test("defaults execution units to open", () => {
|
|
80
90
|
const task = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
81
91
|
ticketUrl: "https://example.com/task",
|
|
@@ -95,17 +105,36 @@ describe("work status", () => {
|
|
|
95
105
|
expect(phase.status).toBe("open")
|
|
96
106
|
})
|
|
97
107
|
|
|
98
|
-
test("accepts supported
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
test("accepts every supported status on tasks and phases", () => {
|
|
109
|
+
for (const status of Object.keys(supportedStatuses) as WorkStatus[]) {
|
|
110
|
+
expect(Schema.decodeUnknownSync(WorkStatus)(status)).toBe(status)
|
|
111
|
+
|
|
112
|
+
const task = Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
113
|
+
ticketUrl: null,
|
|
114
|
+
repo: "agency",
|
|
115
|
+
branch: `task/${status}`,
|
|
116
|
+
base: "main",
|
|
117
|
+
pr: null,
|
|
118
|
+
status,
|
|
119
|
+
})
|
|
120
|
+
const phase = Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
121
|
+
repo: "agency",
|
|
122
|
+
branch: `phase/${status}`,
|
|
123
|
+
base: "main",
|
|
124
|
+
pr: null,
|
|
125
|
+
status,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect("status" in task && task.status).toBe(status)
|
|
129
|
+
expect(phase.status).toBe(status)
|
|
130
|
+
}
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
test("rejects unsupported statuses on tasks and phases", () => {
|
|
134
|
+
expect(() => Schema.decodeUnknownSync(WorkStatus)("blocked")).toThrow()
|
|
107
135
|
expect(() =>
|
|
108
|
-
Schema.decodeUnknownSync(
|
|
136
|
+
Schema.decodeUnknownSync(TaskFrontmatter)({
|
|
137
|
+
ticketUrl: null,
|
|
109
138
|
repo: "agency",
|
|
110
139
|
branch: "task/invalid",
|
|
111
140
|
base: "main",
|
|
@@ -113,6 +142,41 @@ describe("work status", () => {
|
|
|
113
142
|
status: "blocked",
|
|
114
143
|
}),
|
|
115
144
|
).toThrow()
|
|
145
|
+
expect(() =>
|
|
146
|
+
Schema.decodeUnknownSync(PhaseFrontmatter)({
|
|
147
|
+
repo: "agency",
|
|
148
|
+
branch: "phase/invalid",
|
|
149
|
+
base: "main",
|
|
150
|
+
pr: null,
|
|
151
|
+
status: "blocked",
|
|
152
|
+
}),
|
|
153
|
+
).toThrow()
|
|
154
|
+
})
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
describe("workbase registry", () => {
|
|
158
|
+
test("accepts registered paths", () => {
|
|
159
|
+
expect(
|
|
160
|
+
Schema.decodeUnknownSync(WorkbaseRegistry)({
|
|
161
|
+
version: 1,
|
|
162
|
+
workbases: ["/work/one", "/work/two"],
|
|
163
|
+
}),
|
|
164
|
+
).toEqual({ version: 1, workbases: ["/work/one", "/work/two"] })
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
test("rejects invalid versions and empty paths", () => {
|
|
168
|
+
expect(() =>
|
|
169
|
+
Schema.decodeUnknownSync(WorkbaseRegistry)({
|
|
170
|
+
version: 2,
|
|
171
|
+
workbases: [],
|
|
172
|
+
}),
|
|
173
|
+
).toThrow()
|
|
174
|
+
expect(() =>
|
|
175
|
+
Schema.decodeUnknownSync(WorkbaseRegistry)({
|
|
176
|
+
version: 1,
|
|
177
|
+
workbases: [""],
|
|
178
|
+
}),
|
|
179
|
+
).toThrow()
|
|
116
180
|
})
|
|
117
181
|
})
|
|
118
182
|
|
package/src/workbase/schemas.ts
CHANGED
|
@@ -15,7 +15,13 @@ export const RepositoryReference = Schema.Struct({
|
|
|
15
15
|
ref: NonEmptyString,
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
export const WorkStatus = Schema.Literal(
|
|
18
|
+
export const WorkStatus = Schema.Literal(
|
|
19
|
+
"open",
|
|
20
|
+
"working",
|
|
21
|
+
"delegated",
|
|
22
|
+
"done",
|
|
23
|
+
"dropped",
|
|
24
|
+
)
|
|
19
25
|
|
|
20
26
|
const Url = NonEmptyString.pipe(Schema.pattern(/^[a-zA-Z][a-zA-Z0-9+.-]*:/))
|
|
21
27
|
|
|
@@ -28,6 +34,11 @@ export const WorkbaseConfig = Schema.Struct({
|
|
|
28
34
|
worktreeCreateCommand: Schema.optional(Schema.NonEmptyArray(NonEmptyString)),
|
|
29
35
|
})
|
|
30
36
|
|
|
37
|
+
export const WorkbaseRegistry = Schema.Struct({
|
|
38
|
+
version: Schema.Literal(1),
|
|
39
|
+
workbases: Schema.Array(NonEmptyString),
|
|
40
|
+
})
|
|
41
|
+
|
|
31
42
|
export const Dependency = Schema.Struct({
|
|
32
43
|
id: EntityId,
|
|
33
44
|
dependsOn: Schema.optional(Schema.Array(EntityId)),
|
|
@@ -74,6 +85,7 @@ export const PhaseFrontmatter = Schema.Struct({
|
|
|
74
85
|
})
|
|
75
86
|
|
|
76
87
|
export type WorkbaseConfig = Schema.Schema.Type<typeof WorkbaseConfig>
|
|
88
|
+
export type WorkbaseRegistry = Schema.Schema.Type<typeof WorkbaseRegistry>
|
|
77
89
|
export type Dependency = Schema.Schema.Type<typeof Dependency>
|
|
78
90
|
export type RepositoryReference = Schema.Schema.Type<typeof RepositoryReference>
|
|
79
91
|
export type WorkStatus = Schema.Schema.Type<typeof WorkStatus>
|
|
@@ -32,6 +32,11 @@ describe("work target choices", () => {
|
|
|
32
32
|
path: "/workbase/tasks/standalone/TASK.md",
|
|
33
33
|
data: { description: "Independent work" },
|
|
34
34
|
},
|
|
35
|
+
{
|
|
36
|
+
id: "delegated",
|
|
37
|
+
path: "/workbase/tasks/delegated/TASK.md",
|
|
38
|
+
data: { status: "delegated" },
|
|
39
|
+
},
|
|
35
40
|
],
|
|
36
41
|
[
|
|
37
42
|
{
|
|
@@ -63,6 +68,7 @@ describe("work target choices", () => {
|
|
|
63
68
|
" \x1b[31m⊘\x1b[0m \x1b[33m\x1b[0m unlisted",
|
|
64
69
|
" \x1b[32m✓\x1b[0m \x1b[36m\x1b[0m single",
|
|
65
70
|
"\x1b[2m○\x1b[0m \x1b[36m\x1b[0m standalone\x1b[2m - Independent work\x1b[0m",
|
|
71
|
+
"\x1b[35m↗\x1b[0m \x1b[36m\x1b[0m delegated",
|
|
66
72
|
])
|
|
67
73
|
expect(choices.map((choice) => choice.target.kind)).toEqual([
|
|
68
74
|
"epic",
|
|
@@ -72,6 +78,7 @@ describe("work target choices", () => {
|
|
|
72
78
|
"phase",
|
|
73
79
|
"task",
|
|
74
80
|
"task",
|
|
81
|
+
"task",
|
|
75
82
|
])
|
|
76
83
|
})
|
|
77
84
|
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { resolve } from "node:path"
|
|
3
|
+
import { FileSystemService } from "../services/FileSystemService"
|
|
4
|
+
import { WorkbaseService } from "../services/WorkbaseService"
|
|
5
|
+
|
|
6
|
+
export type PickWorkbase = (
|
|
7
|
+
workbases: readonly string[],
|
|
8
|
+
) => Effect.Effect<string | null, Error>
|
|
9
|
+
|
|
10
|
+
export const pickWorkbase: PickWorkbase = (workbases) =>
|
|
11
|
+
Effect.tryPromise({
|
|
12
|
+
try: async () => {
|
|
13
|
+
const input = workbases
|
|
14
|
+
.map((workbase, index) => `${index}\t${workbase}`)
|
|
15
|
+
.join("\n")
|
|
16
|
+
const process = Bun.spawn(
|
|
17
|
+
["fzf", "--delimiter=\t", "--with-nth=2..", "--prompt=Workbase> "],
|
|
18
|
+
{ stdin: new Blob([input]), stdout: "pipe", stderr: "inherit" },
|
|
19
|
+
)
|
|
20
|
+
const [exitCode, output] = await Promise.all([
|
|
21
|
+
process.exited,
|
|
22
|
+
new Response(process.stdout).text(),
|
|
23
|
+
])
|
|
24
|
+
if (exitCode === 1 || exitCode === 130) return null
|
|
25
|
+
if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
|
|
26
|
+
const index = Number.parseInt(output.split("\t", 1)[0] ?? "", 10)
|
|
27
|
+
return workbases[index] ?? null
|
|
28
|
+
},
|
|
29
|
+
catch: (cause) =>
|
|
30
|
+
new Error("Failed to select a workbase with fzf", { cause }),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
export const resolveWorkbase = (
|
|
34
|
+
startPath: string,
|
|
35
|
+
log: (message: string) => void,
|
|
36
|
+
pick: PickWorkbase = pickWorkbase,
|
|
37
|
+
) =>
|
|
38
|
+
Effect.gen(function* () {
|
|
39
|
+
const fs = yield* FileSystemService
|
|
40
|
+
const workbase = yield* WorkbaseService
|
|
41
|
+
|
|
42
|
+
return yield* workbase.discover(startPath).pipe(
|
|
43
|
+
Effect.catchTag("WorkbaseNotFoundError", () =>
|
|
44
|
+
Effect.gen(function* () {
|
|
45
|
+
const registered = yield* workbase.listRegistered()
|
|
46
|
+
if (registered.length === 0) {
|
|
47
|
+
return yield* Effect.fail(
|
|
48
|
+
new Error(
|
|
49
|
+
`No Agency workbase found from ${resolve(startPath)}. Register one with 'agency workbase add <path>'.`,
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const fzf = yield* fs.runCommand(["which", "fzf"], {
|
|
55
|
+
captureOutput: true,
|
|
56
|
+
})
|
|
57
|
+
if (fzf.exitCode !== 0) {
|
|
58
|
+
for (const path of registered) log(path)
|
|
59
|
+
return yield* Effect.fail(
|
|
60
|
+
new Error(
|
|
61
|
+
"fzf is required to select a workbase; install fzf or run Agency from a registered workbase",
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const selected = yield* pick(registered)
|
|
67
|
+
return selected ? yield* workbase.discover(selected) : null
|
|
68
|
+
}),
|
|
69
|
+
),
|
|
70
|
+
)
|
|
71
|
+
})
|