@markjaquith/agency 2.4.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 +56 -20
- package/cli.ts +43 -2
- package/package.json +1 -1
- package/skills/agency/SKILL.md +41 -5
- package/src/cli.test.ts +31 -3
- package/src/commands/archive.test.ts +69 -0
- package/src/commands/archive.ts +70 -0
- package/src/commands/init.test.ts +3 -0
- package/src/commands/phase.ts +23 -1
- package/src/commands/task-phase.test.ts +46 -1
- package/src/commands/task.test.ts +94 -0
- package/src/commands/task.ts +160 -17
- package/src/commands/validate.test.ts +65 -0
- package/src/commands/validate.ts +19 -5
- package/src/commands/work.test.ts +100 -34
- package/src/commands/work.ts +30 -10
- package/src/commands/workbase.test.ts +62 -0
- package/src/commands/workbase.ts +58 -0
- package/src/services/ArchiveService.test.ts +334 -0
- package/src/services/ArchiveService.ts +246 -0
- package/src/services/PhaseService.ts +28 -0
- package/src/services/TaskPhaseService.test.ts +79 -0
- package/src/services/TaskService.ts +31 -1
- package/src/services/WorkbaseService.test.ts +99 -1
- package/src/services/WorkbaseService.ts +101 -2
- package/src/services/WorktreeService.test.ts +150 -1
- package/src/services/WorktreeService.ts +123 -1
- package/src/test-utils.ts +2 -0
- package/src/utils/progress.test.ts +37 -0
- package/src/utils/progress.ts +36 -0
- package/src/workbase/AGENTS.md +3 -0
- package/src/workbase/opencode-file.ts +37 -0
- package/src/workbase/schemas.test.ts +114 -0
- package/src/workbase/schemas.ts +18 -2
- package/src/workbase/work-target.test.ts +16 -9
- package/src/workbase/work-target.ts +37 -6
- package/src/workbase/workbase-choice.ts +71 -0
|
@@ -95,6 +95,7 @@ describe("task and phase command JSON output", () => {
|
|
|
95
95
|
branch: "task/first",
|
|
96
96
|
base: "main",
|
|
97
97
|
pr: null,
|
|
98
|
+
status: "open",
|
|
98
99
|
},
|
|
99
100
|
},
|
|
100
101
|
])
|
|
@@ -118,6 +119,7 @@ describe("task and phase command JSON output", () => {
|
|
|
118
119
|
branch: "task/first",
|
|
119
120
|
base: "main",
|
|
120
121
|
pr: null,
|
|
122
|
+
status: "open",
|
|
121
123
|
},
|
|
122
124
|
})
|
|
123
125
|
})
|
|
@@ -156,10 +158,53 @@ describe("task and phase command JSON output", () => {
|
|
|
156
158
|
expect(JSON.parse(phaseLogs[0]!)).toMatchObject({
|
|
157
159
|
taskId: "another",
|
|
158
160
|
id: "first",
|
|
159
|
-
data: {
|
|
161
|
+
data: {
|
|
162
|
+
repo: "agency",
|
|
163
|
+
branch: "task/another-first",
|
|
164
|
+
base: "main",
|
|
165
|
+
status: "open",
|
|
166
|
+
},
|
|
160
167
|
})
|
|
161
168
|
})
|
|
162
169
|
|
|
170
|
+
test("sets task and phase status", async () => {
|
|
171
|
+
const phaseLogs = await captureLogs(() =>
|
|
172
|
+
runTestEffect(
|
|
173
|
+
phase({
|
|
174
|
+
subcommand: "status",
|
|
175
|
+
args: ["multi", "first", "delegated"],
|
|
176
|
+
cwd: root,
|
|
177
|
+
json: true,
|
|
178
|
+
}),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
expect(JSON.parse(phaseLogs[0]!).data.status).toBe("delegated")
|
|
182
|
+
|
|
183
|
+
await runTestEffect(
|
|
184
|
+
task({
|
|
185
|
+
subcommand: "create",
|
|
186
|
+
args: ["single-status"],
|
|
187
|
+
ticketUrl: "https://example.com/task",
|
|
188
|
+
repo: "agency",
|
|
189
|
+
branch: "task/single-status",
|
|
190
|
+
base: "main",
|
|
191
|
+
cwd: root,
|
|
192
|
+
silent: true,
|
|
193
|
+
}),
|
|
194
|
+
)
|
|
195
|
+
const taskLogs = await captureLogs(() =>
|
|
196
|
+
runTestEffect(
|
|
197
|
+
task({
|
|
198
|
+
subcommand: "status",
|
|
199
|
+
args: ["single-status", "delegated"],
|
|
200
|
+
cwd: root,
|
|
201
|
+
json: true,
|
|
202
|
+
}),
|
|
203
|
+
),
|
|
204
|
+
)
|
|
205
|
+
expect(JSON.parse(taskLogs[0]!).data.status).toBe("delegated")
|
|
206
|
+
})
|
|
207
|
+
|
|
163
208
|
test("converts a single-phase task with an explicit first phase ID", async () => {
|
|
164
209
|
await runTestEffect(
|
|
165
210
|
task({
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { task, type TaskInteraction } from "./task"
|
|
7
|
+
|
|
8
|
+
describe("task creation input", () => {
|
|
9
|
+
let root: string
|
|
10
|
+
|
|
11
|
+
beforeEach(async () => {
|
|
12
|
+
root = await createTempDir()
|
|
13
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
14
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(async () => cleanupTempDir(root))
|
|
18
|
+
|
|
19
|
+
test("guides task creation and defaults branch metadata", async () => {
|
|
20
|
+
await mkdir(join(root, "epics/delivery"), { recursive: true })
|
|
21
|
+
await Bun.write(
|
|
22
|
+
join(root, "epics/delivery/EPIC.md"),
|
|
23
|
+
"---\nticketUrl: https://example.com/delivery\nrepos:\n - repo: agency\n ref: main\ntasks: []\n---\n\n# Delivery\n",
|
|
24
|
+
)
|
|
25
|
+
const textAnswers = ["guided-task", "", ""]
|
|
26
|
+
const prompts: string[] = []
|
|
27
|
+
const interaction: TaskInteraction = {
|
|
28
|
+
text: (prompt) => {
|
|
29
|
+
prompts.push(prompt)
|
|
30
|
+
return Effect.succeed(textAnswers.shift() ?? "")
|
|
31
|
+
},
|
|
32
|
+
select: (prompt, choices) => {
|
|
33
|
+
prompts.push(`${prompt}: ${choices.join(",")}`)
|
|
34
|
+
return Effect.succeed(
|
|
35
|
+
prompt === "Parent epic"
|
|
36
|
+
? "(none)"
|
|
37
|
+
: prompt === "Task type"
|
|
38
|
+
? "single-phase"
|
|
39
|
+
: "agency",
|
|
40
|
+
)
|
|
41
|
+
},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
await runTestEffect(
|
|
45
|
+
task(
|
|
46
|
+
{ subcommand: "new", args: [], cwd: root, silent: true },
|
|
47
|
+
interaction,
|
|
48
|
+
),
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const content = await Bun.file(
|
|
52
|
+
join(root, "tasks/guided-task/TASK.md"),
|
|
53
|
+
).text()
|
|
54
|
+
expect(content).toContain("ticketUrl: null")
|
|
55
|
+
expect(content).toContain("repo: agency")
|
|
56
|
+
expect(content).toContain("branch: task/guided-task")
|
|
57
|
+
expect(content).toContain("base: main")
|
|
58
|
+
expect(prompts).toEqual([
|
|
59
|
+
"Task ID: ",
|
|
60
|
+
"Ticket URL (optional): ",
|
|
61
|
+
"Description (optional): ",
|
|
62
|
+
"Parent epic: (none),delivery",
|
|
63
|
+
"Task type: single-phase,multi-phase",
|
|
64
|
+
"Writable repository: agency",
|
|
65
|
+
])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("keeps scripted creation non-interactive and permits no ticket URL", async () => {
|
|
69
|
+
const interaction: TaskInteraction = {
|
|
70
|
+
text: () => Effect.fail(new Error("unexpected text prompt")),
|
|
71
|
+
select: () => Effect.fail(new Error("unexpected selection prompt")),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
await runTestEffect(
|
|
75
|
+
task(
|
|
76
|
+
{
|
|
77
|
+
subcommand: "create",
|
|
78
|
+
args: ["scripted-task"],
|
|
79
|
+
repo: "agency",
|
|
80
|
+
cwd: root,
|
|
81
|
+
silent: true,
|
|
82
|
+
},
|
|
83
|
+
interaction,
|
|
84
|
+
),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const content = await Bun.file(
|
|
88
|
+
join(root, "tasks/scripted-task/TASK.md"),
|
|
89
|
+
).text()
|
|
90
|
+
expect(content).toContain("ticketUrl: null")
|
|
91
|
+
expect(content).toContain("branch: task/scripted-task")
|
|
92
|
+
expect(content).toContain("base: main")
|
|
93
|
+
})
|
|
94
|
+
})
|
package/src/commands/task.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { createInterface } from "node:readline/promises"
|
|
2
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
4
|
import { TaskService } from "../services/TaskService"
|
|
5
|
+
import { EpicService } from "../services/EpicService"
|
|
6
|
+
import { RepositoryService } from "../services/RepositoryService"
|
|
4
7
|
import { createLoggers } from "../utils/effect"
|
|
5
8
|
import { parseRepositoryReferences } from "../workbase/repository-reference"
|
|
6
9
|
|
|
@@ -18,31 +21,151 @@ interface TaskOptions extends BaseCommandOptions {
|
|
|
18
21
|
readonly json?: boolean
|
|
19
22
|
}
|
|
20
23
|
|
|
21
|
-
export
|
|
24
|
+
export interface TaskInteraction {
|
|
25
|
+
readonly text: (prompt: string) => Effect.Effect<string, Error>
|
|
26
|
+
readonly select: (
|
|
27
|
+
prompt: string,
|
|
28
|
+
choices: readonly string[],
|
|
29
|
+
) => Effect.Effect<string | null, Error>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const defaultInteraction: TaskInteraction = {
|
|
33
|
+
text: (prompt) =>
|
|
34
|
+
Effect.tryPromise({
|
|
35
|
+
try: async () => {
|
|
36
|
+
const input = createInterface({
|
|
37
|
+
input: process.stdin,
|
|
38
|
+
output: process.stderr,
|
|
39
|
+
})
|
|
40
|
+
try {
|
|
41
|
+
return await input.question(prompt)
|
|
42
|
+
} finally {
|
|
43
|
+
input.close()
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
catch: (cause) => new Error("Failed to read task input", { cause }),
|
|
47
|
+
}),
|
|
48
|
+
select: (prompt, choices) =>
|
|
49
|
+
Effect.tryPromise({
|
|
50
|
+
try: async () => {
|
|
51
|
+
const process = Bun.spawn(
|
|
52
|
+
["fzf", `--prompt=${prompt}> `, "--height=~40%", "--reverse"],
|
|
53
|
+
{
|
|
54
|
+
stdin: new Blob([choices.join("\n")]),
|
|
55
|
+
stdout: "pipe",
|
|
56
|
+
stderr: "inherit",
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
const [exitCode, output] = await Promise.all([
|
|
60
|
+
process.exited,
|
|
61
|
+
new Response(process.stdout).text(),
|
|
62
|
+
])
|
|
63
|
+
if (exitCode === 1 || exitCode === 130) return null
|
|
64
|
+
if (exitCode !== 0) throw new Error(`fzf exited with code ${exitCode}`)
|
|
65
|
+
return output.trim() || null
|
|
66
|
+
},
|
|
67
|
+
catch: (cause) =>
|
|
68
|
+
new Error("Failed to select task input with fzf", { cause }),
|
|
69
|
+
}),
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const task = (
|
|
73
|
+
options: TaskOptions,
|
|
74
|
+
interaction: TaskInteraction = defaultInteraction,
|
|
75
|
+
) =>
|
|
22
76
|
Effect.gen(function* () {
|
|
23
77
|
const tasks = yield* TaskService
|
|
78
|
+
const epics = yield* EpicService
|
|
79
|
+
const repositories = yield* RepositoryService
|
|
24
80
|
const { log } = createLoggers(options)
|
|
25
81
|
const cwd = options.cwd ?? process.cwd()
|
|
26
82
|
|
|
27
83
|
switch (options.subcommand) {
|
|
84
|
+
case "new":
|
|
28
85
|
case "create": {
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
86
|
+
const interactive = options.subcommand === "new" || !options.args[0]
|
|
87
|
+
const id =
|
|
88
|
+
options.args[0] ??
|
|
89
|
+
(interactive
|
|
90
|
+
? (yield* interaction.text("Task ID: ")).trim()
|
|
91
|
+
: undefined)
|
|
92
|
+
if (!id) {
|
|
93
|
+
return yield* Effect.fail(new Error("Task ID is required"))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let ticketUrl = options.ticketUrl?.trim() || null
|
|
97
|
+
let description = options.description?.trim() || undefined
|
|
98
|
+
let epic = options.epic
|
|
99
|
+
let multiPhase = options.multiPhase ?? false
|
|
100
|
+
let repo = options.repo
|
|
101
|
+
|
|
102
|
+
if (interactive) {
|
|
103
|
+
if (options.ticketUrl === undefined) {
|
|
104
|
+
ticketUrl =
|
|
105
|
+
(yield* interaction.text("Ticket URL (optional): ")).trim() ||
|
|
106
|
+
null
|
|
107
|
+
}
|
|
108
|
+
if (options.description === undefined) {
|
|
109
|
+
description =
|
|
110
|
+
(yield* interaction.text("Description (optional): ")).trim() ||
|
|
111
|
+
undefined
|
|
112
|
+
}
|
|
113
|
+
if (options.epic === undefined) {
|
|
114
|
+
const epicRecords = yield* epics.list(cwd)
|
|
115
|
+
if (epicRecords.length > 0) {
|
|
116
|
+
const none = "(none)"
|
|
117
|
+
const selected = yield* interaction.select("Parent epic", [
|
|
118
|
+
none,
|
|
119
|
+
...epicRecords.map((record) => record.id),
|
|
120
|
+
])
|
|
121
|
+
if (selected === null) {
|
|
122
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
123
|
+
}
|
|
124
|
+
epic = selected === none ? undefined : selected
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (options.multiPhase === undefined) {
|
|
128
|
+
const selected = yield* interaction.select("Task type", [
|
|
129
|
+
"single-phase",
|
|
130
|
+
"multi-phase",
|
|
131
|
+
])
|
|
132
|
+
if (selected === null) {
|
|
133
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
134
|
+
}
|
|
135
|
+
multiPhase = selected === "multi-phase"
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!multiPhase && !repo) {
|
|
140
|
+
const records = yield* repositories.list(cwd)
|
|
141
|
+
if (records.length === 0) {
|
|
142
|
+
return yield* Effect.fail(
|
|
143
|
+
new Error(
|
|
144
|
+
"No repositories found; add or link a repository first",
|
|
145
|
+
),
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const selected = yield* interaction.select(
|
|
149
|
+
"Writable repository",
|
|
150
|
+
records.map((record) => record.alias),
|
|
33
151
|
)
|
|
152
|
+
if (!selected) {
|
|
153
|
+
return yield* Effect.fail(new Error("Task creation cancelled"))
|
|
154
|
+
}
|
|
155
|
+
repo = selected
|
|
34
156
|
}
|
|
157
|
+
|
|
35
158
|
const record = yield* tasks.create(
|
|
36
159
|
{
|
|
37
160
|
id,
|
|
38
|
-
ticketUrl
|
|
39
|
-
description
|
|
40
|
-
epic
|
|
41
|
-
multiPhase
|
|
42
|
-
repo
|
|
161
|
+
ticketUrl,
|
|
162
|
+
description,
|
|
163
|
+
epic,
|
|
164
|
+
multiPhase,
|
|
165
|
+
repo,
|
|
43
166
|
repos: parseRepositoryReferences(options.references),
|
|
44
|
-
branch: options.branch,
|
|
45
|
-
base: options.base,
|
|
167
|
+
branch: multiPhase ? undefined : (options.branch ?? `task/${id}`),
|
|
168
|
+
base: multiPhase ? undefined : (options.base ?? "main"),
|
|
46
169
|
},
|
|
47
170
|
cwd,
|
|
48
171
|
)
|
|
@@ -81,9 +204,27 @@ export const task = (options: TaskOptions) =>
|
|
|
81
204
|
)
|
|
82
205
|
return
|
|
83
206
|
}
|
|
207
|
+
case "status": {
|
|
208
|
+
const [id, status] = options.args
|
|
209
|
+
if (!id || !status) {
|
|
210
|
+
return yield* Effect.fail(
|
|
211
|
+
new Error("Usage: agency task status <id> <status>"),
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
const record = yield* tasks.setStatus(id, status, cwd)
|
|
215
|
+
const { content: _, ...output } = record
|
|
216
|
+
log(
|
|
217
|
+
options.json
|
|
218
|
+
? JSON.stringify(output, null, 2)
|
|
219
|
+
: `Marked task '${id}' as ${record.data.status}`,
|
|
220
|
+
)
|
|
221
|
+
return
|
|
222
|
+
}
|
|
84
223
|
default:
|
|
85
224
|
return yield* Effect.fail(
|
|
86
|
-
new Error(
|
|
225
|
+
new Error(
|
|
226
|
+
"Subcommand is required. Available: new, create, list, show, status",
|
|
227
|
+
),
|
|
87
228
|
)
|
|
88
229
|
}
|
|
89
230
|
})
|
|
@@ -92,19 +233,21 @@ export const help = `
|
|
|
92
233
|
Usage: agency task <subcommand>
|
|
93
234
|
|
|
94
235
|
Subcommands:
|
|
95
|
-
|
|
236
|
+
new [id] Create a task with guided input
|
|
237
|
+
create <id> Create a task; omitted metadata uses defaults
|
|
96
238
|
list List tasks
|
|
97
239
|
show <id> Show a task
|
|
240
|
+
status <id> <status> Set open, working, delegated, done, or dropped
|
|
98
241
|
|
|
99
242
|
Create options:
|
|
100
|
-
--ticket-url <url> External ticket URL
|
|
243
|
+
--ticket-url <url> External ticket URL (optional)
|
|
101
244
|
--description <text> Short description of the task
|
|
102
245
|
--epic <id> Parent epic
|
|
103
246
|
--repo <alias> Writable repository
|
|
104
247
|
--reference <alias>:<ref>
|
|
105
248
|
Read-only repository reference; repeatable
|
|
106
|
-
--branch <name> Working branch
|
|
107
|
-
--base <name> Base branch
|
|
249
|
+
--branch <name> Working branch (default: task/<id>)
|
|
250
|
+
--base <name> Base branch (default: main)
|
|
108
251
|
--multi-phase Create a task container for phases
|
|
109
252
|
|
|
110
253
|
Options:
|
|
@@ -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
|