@markjaquith/agency 2.16.0 → 2.18.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 +33 -4
- package/cli.ts +93 -8
- package/package.json +1 -1
- package/skills/agency/SKILL.md +13 -6
- package/src/cli-parser.test.ts +93 -0
- package/src/cli-parser.ts +229 -29
- package/src/cli.test.ts +144 -4
- package/src/commands/context.ts +3 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/next.ts +64 -0
- package/src/commands/pr.ts +2 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/validate.test.ts +2 -0
- package/src/commands/work.test.ts +85 -0
- package/src/commands/work.ts +40 -9
- package/src/commands/workbase.test.ts +63 -2
- package/src/commands/workbase.ts +77 -10
- package/src/protocol.ts +6 -0
- package/src/services/GraphService.test.ts +36 -0
- package/src/services/GraphService.ts +8 -1
- package/src/services/PullRequestService.test.ts +20 -1
- package/src/services/PullRequestService.ts +14 -1
- package/src/services/ReadinessService.test.ts +223 -0
- package/src/services/ReadinessService.ts +230 -0
- package/src/services/WorkbaseService.test.ts +147 -4
- package/src/services/WorkbaseService.ts +214 -12
- package/src/services/WorktreeService.test.ts +12 -0
- package/src/services/WorktreeService.ts +6 -2
- package/src/test-utils.ts +2 -0
- package/src/workbase/schemas.test.ts +17 -6
- package/src/workbase/schemas.ts +16 -1
- package/src/workbase/workbase-choice.ts +2 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { ReadinessService } from "../services/ReadinessService"
|
|
3
|
+
import type { BaseCommandOptions } from "../utils/command"
|
|
4
|
+
import { createLoggers } from "../utils/effect"
|
|
5
|
+
|
|
6
|
+
interface NextOptions extends BaseCommandOptions {
|
|
7
|
+
readonly select?: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const context = (item: {
|
|
11
|
+
readonly parent: { readonly taskId?: string; readonly epicId?: string }
|
|
12
|
+
}) =>
|
|
13
|
+
[
|
|
14
|
+
item.parent.epicId ? `epic ${item.parent.epicId}` : undefined,
|
|
15
|
+
item.parent.taskId ? `task ${item.parent.taskId}` : undefined,
|
|
16
|
+
]
|
|
17
|
+
.filter(Boolean)
|
|
18
|
+
.join(" / ")
|
|
19
|
+
|
|
20
|
+
export const next = (options: NextOptions = {}) =>
|
|
21
|
+
Effect.gen(function* () {
|
|
22
|
+
const readiness = yield* ReadinessService
|
|
23
|
+
const { log } = createLoggers(options)
|
|
24
|
+
const result = yield* readiness.getNext(
|
|
25
|
+
options.cwd ?? process.cwd(),
|
|
26
|
+
options.select,
|
|
27
|
+
)
|
|
28
|
+
if (options.json) {
|
|
29
|
+
log(JSON.stringify(result, null, 2))
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
if (options.select) {
|
|
33
|
+
if (!result.selected) {
|
|
34
|
+
log("No execution units are ready.")
|
|
35
|
+
return
|
|
36
|
+
}
|
|
37
|
+
const parent = context(result.selected)
|
|
38
|
+
log(
|
|
39
|
+
`${result.selected.key}${parent ? ` (${parent})` : ""} - priority ${result.selected.priority.dependentCount}`,
|
|
40
|
+
)
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
if (result.ready.length === 0) {
|
|
44
|
+
log("No execution units are ready.")
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
for (const item of result.ready) {
|
|
48
|
+
const parent = context(item)
|
|
49
|
+
log(
|
|
50
|
+
`${item.rank}. ${item.key}${parent ? ` (${parent})` : ""} - priority ${item.priority.dependentCount}`,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const help = `
|
|
56
|
+
Usage: agency next [--select] [--json]
|
|
57
|
+
|
|
58
|
+
List ready execution units in priority order or select the highest-priority unit.
|
|
59
|
+
Structured output also includes excluded units and their blockers.
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
--select Return only the highest-priority ready unit in human output
|
|
63
|
+
--json Output ready and excluded execution units as JSON
|
|
64
|
+
`
|
package/src/commands/pr.ts
CHANGED
|
@@ -8,6 +8,7 @@ interface PrOptions extends BaseCommandOptions {
|
|
|
8
8
|
readonly taskId?: string
|
|
9
9
|
readonly phaseId?: string
|
|
10
10
|
readonly draft?: boolean
|
|
11
|
+
readonly force?: boolean
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export const pr = (options: PrOptions) =>
|
|
@@ -37,5 +38,6 @@ task or phase document.
|
|
|
37
38
|
|
|
38
39
|
Options:
|
|
39
40
|
--draft Create a draft pull request
|
|
41
|
+
--force Override readiness and terminal-state guards
|
|
40
42
|
--json Output the pull request URL as JSON
|
|
41
43
|
`
|
|
@@ -13,6 +13,7 @@ import { task } from "./task"
|
|
|
13
13
|
import { validate } from "./validate"
|
|
14
14
|
import { context } from "./context"
|
|
15
15
|
import { graph } from "./graph"
|
|
16
|
+
import { next } from "./next"
|
|
16
17
|
|
|
17
18
|
const write = async (root: string, path: string, content: string) => {
|
|
18
19
|
const fullPath = join(root, path)
|
|
@@ -145,6 +146,7 @@ status: open
|
|
|
145
146
|
}),
|
|
146
147
|
)
|
|
147
148
|
await runTestEffect(graph({ cwd: root, silent: true }))
|
|
149
|
+
await runTestEffect(next({ cwd: root, silent: true }))
|
|
148
150
|
|
|
149
151
|
expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
|
|
150
152
|
expect(
|
|
@@ -80,6 +80,7 @@ pr: null
|
|
|
80
80
|
: Effect.succeed(path)
|
|
81
81
|
},
|
|
82
82
|
listRegistered: () => Effect.succeed(["/first", "/selected"]),
|
|
83
|
+
getDefault: () => Effect.succeed(undefined),
|
|
83
84
|
validate: (path: string) =>
|
|
84
85
|
Effect.succeed({
|
|
85
86
|
root: path,
|
|
@@ -116,6 +117,7 @@ pr: null
|
|
|
116
117
|
message: "No Agency workbase found from /outside",
|
|
117
118
|
}),
|
|
118
119
|
listRegistered: () => Effect.succeed(["/selected"]),
|
|
120
|
+
getDefault: () => Effect.succeed(undefined),
|
|
119
121
|
}
|
|
120
122
|
const fs = {
|
|
121
123
|
runCommand: () => Effect.fail(new Error("unexpected fzf probe")),
|
|
@@ -7,6 +7,7 @@ import { TaskService } from "../services/TaskService"
|
|
|
7
7
|
import { PhaseService } from "../services/PhaseService"
|
|
8
8
|
import { WorktreeService } from "../services/WorktreeService"
|
|
9
9
|
import { ClaimService } from "../services/ClaimService"
|
|
10
|
+
import { ReadinessService } from "../services/ReadinessService"
|
|
10
11
|
import { captureErrors, captureLogs } from "../test-utils"
|
|
11
12
|
import { work, workPrepare } from "./work"
|
|
12
13
|
import type { PickWorkTarget } from "../workbase/work-target"
|
|
@@ -55,6 +56,8 @@ interface HarnessOptions {
|
|
|
55
56
|
readonly outsideWorkbase?: boolean
|
|
56
57
|
readonly registeredWorkbases?: readonly string[]
|
|
57
58
|
readonly existingDirectories?: readonly string[]
|
|
59
|
+
readonly guardError?: Error
|
|
60
|
+
readonly readyTargetIds?: readonly string[]
|
|
58
61
|
}
|
|
59
62
|
|
|
60
63
|
const createHarness = (options: HarnessOptions = {}) => {
|
|
@@ -63,6 +66,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
63
66
|
const statusUpdates: string[] = []
|
|
64
67
|
const shownTasks: string[] = []
|
|
65
68
|
const progressUpdates: string[] = []
|
|
69
|
+
const guards: Array<{ target: string; override?: boolean }> = []
|
|
66
70
|
const launches: Array<{
|
|
67
71
|
cli: string
|
|
68
72
|
args: readonly string[]
|
|
@@ -94,6 +98,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
94
98
|
})
|
|
95
99
|
: Effect.succeed("/workbase"),
|
|
96
100
|
listRegistered: () => Effect.succeed(options.registeredWorkbases ?? []),
|
|
101
|
+
getDefault: () => Effect.succeed(undefined),
|
|
97
102
|
loadConfig: () =>
|
|
98
103
|
Effect.succeed({
|
|
99
104
|
root: "/workbase",
|
|
@@ -167,6 +172,34 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
172
|
return Effect.succeed({ revision: "1".repeat(64) })
|
|
168
173
|
},
|
|
169
174
|
}
|
|
175
|
+
const readiness = {
|
|
176
|
+
getReadyWorkTargetIds: () =>
|
|
177
|
+
Effect.succeed(
|
|
178
|
+
new Set(
|
|
179
|
+
options.readyTargetIds ?? [
|
|
180
|
+
...(options.epicRecords ?? []).map(
|
|
181
|
+
(record: any) => `epic:${record.id}`,
|
|
182
|
+
),
|
|
183
|
+
...(options.taskRecords ?? []).map((record: any) =>
|
|
184
|
+
"phases" in record.data
|
|
185
|
+
? `task:${record.id}`
|
|
186
|
+
: `execution-unit:task/${record.id}`,
|
|
187
|
+
),
|
|
188
|
+
...(options.phaseRecords ?? []).map(
|
|
189
|
+
(record: any) =>
|
|
190
|
+
`execution-unit:phase/${record.taskId}/${record.id}`,
|
|
191
|
+
),
|
|
192
|
+
],
|
|
193
|
+
),
|
|
194
|
+
),
|
|
195
|
+
guardWorkTarget: (target: string, _root: string, override?: boolean) => {
|
|
196
|
+
if (options.guardError || override) events.push("guard")
|
|
197
|
+
guards.push({ target, override })
|
|
198
|
+
return options.guardError && !override
|
|
199
|
+
? Effect.fail(options.guardError)
|
|
200
|
+
: Effect.void
|
|
201
|
+
},
|
|
202
|
+
}
|
|
170
203
|
const fs = {
|
|
171
204
|
isDirectory: (path: string) =>
|
|
172
205
|
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
@@ -206,6 +239,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
206
239
|
Effect.provideService(TaskService, tasks as never),
|
|
207
240
|
Effect.provideService(PhaseService, phases as never),
|
|
208
241
|
Effect.provideService(ClaimService, claims as never),
|
|
242
|
+
Effect.provideService(ReadinessService, readiness as never),
|
|
209
243
|
) as Effect.Effect<void, unknown, never>,
|
|
210
244
|
)
|
|
211
245
|
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
@@ -227,12 +261,63 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
227
261
|
statusUpdates,
|
|
228
262
|
shownTasks,
|
|
229
263
|
progressUpdates,
|
|
264
|
+
guards,
|
|
230
265
|
run,
|
|
231
266
|
runPrepare,
|
|
232
267
|
}
|
|
233
268
|
}
|
|
234
269
|
|
|
235
270
|
describe("work command", () => {
|
|
271
|
+
test("guards execution targets before materialization and honors --force", async () => {
|
|
272
|
+
const blocked = createHarness({ guardError: new Error("blocked") })
|
|
273
|
+
await expect(
|
|
274
|
+
blocked.run({ taskId: "example", opencode: true }),
|
|
275
|
+
).rejects.toThrow("blocked")
|
|
276
|
+
expect(blocked.events).toEqual(["guard"])
|
|
277
|
+
expect(blocked.guards).toEqual([
|
|
278
|
+
{ target: "execution-unit:task/example", override: undefined },
|
|
279
|
+
])
|
|
280
|
+
|
|
281
|
+
const forced = createHarness({ guardError: new Error("blocked") })
|
|
282
|
+
await forced.run({ taskId: "example", opencode: true, force: true })
|
|
283
|
+
expect(forced.events).toEqual([
|
|
284
|
+
"guard",
|
|
285
|
+
"materialize",
|
|
286
|
+
"probe:opencode",
|
|
287
|
+
"launch:opencode",
|
|
288
|
+
])
|
|
289
|
+
expect(forced.guards[0]).toEqual({
|
|
290
|
+
target: "execution-unit:task/example",
|
|
291
|
+
override: true,
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test("offers only graph-ready targets to the interactive chooser", async () => {
|
|
296
|
+
const harness = createHarness({
|
|
297
|
+
taskRecords: [
|
|
298
|
+
{
|
|
299
|
+
id: "ready",
|
|
300
|
+
path: "/workbase/tasks/ready/TASK.md",
|
|
301
|
+
data: { status: "open" },
|
|
302
|
+
},
|
|
303
|
+
{
|
|
304
|
+
id: "blocked",
|
|
305
|
+
path: "/workbase/tasks/blocked/TASK.md",
|
|
306
|
+
data: { status: "open" },
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
readyTargetIds: ["execution-unit:task/ready"],
|
|
310
|
+
})
|
|
311
|
+
let labels: readonly string[] = []
|
|
312
|
+
const pick: PickWorkTarget = (choices) => {
|
|
313
|
+
labels = choices.map((choice) => choice.plainLabel)
|
|
314
|
+
return Effect.succeed(null)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
await harness.run({ cwd: "/workbase" }, pick)
|
|
318
|
+
expect(labels).toEqual(["[open] task ready"])
|
|
319
|
+
})
|
|
320
|
+
|
|
236
321
|
test("prepares without launching or changing lifecycle status", async () => {
|
|
237
322
|
const harness = createHarness({ existingDirectories: [] })
|
|
238
323
|
|
package/src/commands/work.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { EpicService } from "../services/EpicService"
|
|
|
8
8
|
import { TaskService } from "../services/TaskService"
|
|
9
9
|
import { PhaseService } from "../services/PhaseService"
|
|
10
10
|
import { ClaimService } from "../services/ClaimService"
|
|
11
|
+
import { ReadinessService } from "../services/ReadinessService"
|
|
11
12
|
import { createLoggers } from "../utils/effect"
|
|
12
13
|
import { execvp } from "../utils/exec"
|
|
13
14
|
import { createProgress, type Progress } from "../utils/progress"
|
|
@@ -30,6 +31,7 @@ interface WorkOptions extends BaseCommandOptions {
|
|
|
30
31
|
readonly epicId?: string
|
|
31
32
|
readonly opencode?: boolean
|
|
32
33
|
readonly claude?: boolean
|
|
34
|
+
readonly force?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
|
|
@@ -48,6 +50,16 @@ const launchAgent: LaunchAgent = (cli, args, cwd) => {
|
|
|
48
50
|
execvp(cli, [...args])
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
const targetNodeId = (target: WorkTarget) => {
|
|
54
|
+
if (target.kind === "epic") return `epic:${target.epicId}`
|
|
55
|
+
if (target.kind === "phase") {
|
|
56
|
+
return `execution-unit:phase/${target.taskId}/${target.phaseId}`
|
|
57
|
+
}
|
|
58
|
+
return target.multiPhase
|
|
59
|
+
? `task:${target.taskId}`
|
|
60
|
+
: `execution-unit:task/${target.taskId}`
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
export const work = (
|
|
52
64
|
options: WorkOptions = {},
|
|
53
65
|
launch: LaunchAgent = launchAgent,
|
|
@@ -79,6 +91,7 @@ export const work = (
|
|
|
79
91
|
const tasks = yield* TaskService
|
|
80
92
|
const phases = yield* PhaseService
|
|
81
93
|
const claims = yield* ClaimService
|
|
94
|
+
const readiness = yield* ReadinessService
|
|
82
95
|
const { log, verboseLog } = createLoggers(options)
|
|
83
96
|
const cwd = options.cwd ?? process.cwd()
|
|
84
97
|
const directoryPath = options.directory
|
|
@@ -168,20 +181,29 @@ export const work = (
|
|
|
168
181
|
phaseRecords.push(...(yield* phases.list(task.id, root)))
|
|
169
182
|
}
|
|
170
183
|
}
|
|
171
|
-
const
|
|
184
|
+
const allChoices = buildWorkTargetChoices(
|
|
172
185
|
epicRecords,
|
|
173
186
|
taskRecords,
|
|
174
187
|
phaseRecords,
|
|
175
188
|
)
|
|
189
|
+
const readyTargetIds = options.force
|
|
190
|
+
? null
|
|
191
|
+
: yield* readiness.getReadyWorkTargetIds(root)
|
|
192
|
+
const choices = options.force
|
|
193
|
+
? allChoices
|
|
194
|
+
: allChoices.filter((choice) =>
|
|
195
|
+
readyTargetIds!.has(targetNodeId(choice.target)),
|
|
196
|
+
)
|
|
176
197
|
if (choices.length === 0) {
|
|
177
198
|
return yield* Effect.fail(
|
|
178
|
-
new Error("No
|
|
199
|
+
new Error("No ready work targets found in this workbase"),
|
|
179
200
|
)
|
|
180
201
|
}
|
|
181
202
|
const { config } = yield* workbase.loadConfig(root)
|
|
182
203
|
target = yield* pick(choices, config.chooserCommand)
|
|
183
204
|
if (!target) return
|
|
184
205
|
}
|
|
206
|
+
yield* readiness.guardWorkTarget(targetNodeId(target), root, options.force)
|
|
185
207
|
|
|
186
208
|
let prompt: string
|
|
187
209
|
let launchPath: string
|
|
@@ -275,9 +297,13 @@ export const workPrepare = (options: WorkOptions = {}) =>
|
|
|
275
297
|
const isDirectory = yield* fs.isDirectory(targetPath)
|
|
276
298
|
const root = yield* workbase.discover(isDirectory ? targetPath : cwd)
|
|
277
299
|
|
|
278
|
-
let taskId
|
|
279
|
-
let phaseId
|
|
280
|
-
if (
|
|
300
|
+
let taskId = options.taskId
|
|
301
|
+
let phaseId = options.phaseId
|
|
302
|
+
if (taskId) {
|
|
303
|
+
const task = yield* tasks.show(taskId, root)
|
|
304
|
+
taskId = task.id
|
|
305
|
+
if (phaseId) phaseId = (yield* phases.show(task.id, phaseId, root)).id
|
|
306
|
+
} else if (options.directory && !isDirectory) {
|
|
281
307
|
const task = yield* tasks.show(options.directory, root)
|
|
282
308
|
taskId = task.id
|
|
283
309
|
} else {
|
|
@@ -331,11 +357,16 @@ launching an agent or changing lifecycle status. --dry-run reports planned Git
|
|
|
331
357
|
changes without fetching, creating branches, or creating worktrees.
|
|
332
358
|
|
|
333
359
|
Options:
|
|
334
|
-
--epic <id>
|
|
360
|
+
--epic <id> Work on an epic
|
|
361
|
+
--task <id> Work on a task
|
|
362
|
+
--phase <id> Work on a phase selected with --task
|
|
363
|
+
--workbase <target> Select a workbase by ID, name, or path
|
|
364
|
+
--cwd <path> Resolve context from a specific directory
|
|
335
365
|
--opencode Require OpenCode
|
|
336
366
|
--claude Require Claude Code
|
|
337
|
-
--
|
|
367
|
+
--force Override readiness and terminal-state guards
|
|
368
|
+
--no-input Never open an interactive selector
|
|
338
369
|
|
|
339
|
-
Without interactive input, provide
|
|
340
|
-
|
|
370
|
+
Without interactive input, provide an explicit workbase or cwd and an entity
|
|
371
|
+
selector. Workbase and target selection otherwise fail.
|
|
341
372
|
`
|
|
@@ -31,7 +31,7 @@ describe("workbase command", () => {
|
|
|
31
31
|
}),
|
|
32
32
|
),
|
|
33
33
|
)
|
|
34
|
-
const
|
|
34
|
+
const registration = JSON.parse(added[0]!)
|
|
35
35
|
|
|
36
36
|
const listed = await captureLogs(() =>
|
|
37
37
|
runTestEffect(
|
|
@@ -44,7 +44,68 @@ describe("workbase command", () => {
|
|
|
44
44
|
),
|
|
45
45
|
)
|
|
46
46
|
|
|
47
|
-
expect(JSON.parse(listed[0]!)).toEqual(
|
|
47
|
+
expect(JSON.parse(listed[0]!)).toEqual({
|
|
48
|
+
workbases: [registration],
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
test("sets, clears, and removes the default workbase", async () => {
|
|
53
|
+
const added = await captureLogs(() =>
|
|
54
|
+
runTestEffect(
|
|
55
|
+
workbase({
|
|
56
|
+
subcommand: "add",
|
|
57
|
+
args: [root],
|
|
58
|
+
name: "primary",
|
|
59
|
+
configDirectory,
|
|
60
|
+
json: true,
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
)
|
|
64
|
+
const registration = JSON.parse(added[0]!)
|
|
65
|
+
|
|
66
|
+
await runTestEffect(
|
|
67
|
+
workbase({
|
|
68
|
+
subcommand: "default",
|
|
69
|
+
args: ["primary"],
|
|
70
|
+
configDirectory,
|
|
71
|
+
silent: true,
|
|
72
|
+
}),
|
|
73
|
+
)
|
|
74
|
+
const listed = await captureLogs(() =>
|
|
75
|
+
runTestEffect(
|
|
76
|
+
workbase({
|
|
77
|
+
subcommand: "list",
|
|
78
|
+
args: [],
|
|
79
|
+
configDirectory,
|
|
80
|
+
json: true,
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
)
|
|
84
|
+
expect(JSON.parse(listed[0]!).defaultId).toBe(registration.id)
|
|
85
|
+
await runTestEffect(
|
|
86
|
+
workbase({
|
|
87
|
+
subcommand: "default",
|
|
88
|
+
args: [],
|
|
89
|
+
clear: true,
|
|
90
|
+
configDirectory,
|
|
91
|
+
silent: true,
|
|
92
|
+
}),
|
|
93
|
+
)
|
|
94
|
+
expect(
|
|
95
|
+
await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
|
|
96
|
+
).toEqual({ version: 2, workbases: [registration] })
|
|
97
|
+
|
|
98
|
+
await runTestEffect(
|
|
99
|
+
workbase({
|
|
100
|
+
subcommand: "remove",
|
|
101
|
+
args: [registration.id],
|
|
102
|
+
configDirectory,
|
|
103
|
+
silent: true,
|
|
104
|
+
}),
|
|
105
|
+
)
|
|
106
|
+
expect(
|
|
107
|
+
await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
|
|
108
|
+
).toEqual({ version: 2, workbases: [] })
|
|
48
109
|
})
|
|
49
110
|
|
|
50
111
|
test("requires an add path", async () => {
|
package/src/commands/workbase.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { resolve } from "node:path"
|
|
2
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
4
|
import { WorkbaseService } from "../services/WorkbaseService"
|
|
4
5
|
import { createLoggers } from "../utils/effect"
|
|
@@ -7,6 +8,8 @@ interface WorkbaseOptions extends BaseCommandOptions {
|
|
|
7
8
|
readonly subcommand?: string
|
|
8
9
|
readonly args: readonly string[]
|
|
9
10
|
readonly configDirectory?: string
|
|
11
|
+
readonly name?: string
|
|
12
|
+
readonly clear?: boolean
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
export const workbase = (options: WorkbaseOptions) =>
|
|
@@ -22,26 +25,85 @@ export const workbase = (options: WorkbaseOptions) =>
|
|
|
22
25
|
new Error("Usage: agency workbase add <path>"),
|
|
23
26
|
)
|
|
24
27
|
}
|
|
25
|
-
const
|
|
28
|
+
const registration = yield* service.register(
|
|
29
|
+
resolve(options.cwd ?? process.cwd(), path),
|
|
30
|
+
options.configDirectory,
|
|
31
|
+
options.name,
|
|
32
|
+
)
|
|
26
33
|
log(
|
|
27
34
|
options.json
|
|
28
|
-
? JSON.stringify(
|
|
29
|
-
: `Added workbase ${
|
|
35
|
+
? JSON.stringify(registration, null, 2)
|
|
36
|
+
: `Added workbase ${registration.name ?? registration.id} (${registration.path})`,
|
|
30
37
|
)
|
|
31
38
|
return
|
|
32
39
|
}
|
|
33
40
|
case "list": {
|
|
34
|
-
const
|
|
41
|
+
const registrations = yield* service.listRegistrations(
|
|
42
|
+
options.configDirectory,
|
|
43
|
+
)
|
|
35
44
|
if (options.json) {
|
|
36
|
-
log(JSON.stringify(
|
|
45
|
+
log(JSON.stringify(registrations, null, 2))
|
|
37
46
|
} else {
|
|
38
|
-
for (const
|
|
47
|
+
for (const entry of registrations.workbases) {
|
|
48
|
+
const marker = entry.id === registrations.defaultId ? "*" : " "
|
|
49
|
+
log(`${marker} ${entry.name ?? entry.id}\t${entry.path}`)
|
|
50
|
+
}
|
|
39
51
|
}
|
|
40
52
|
return
|
|
41
53
|
}
|
|
54
|
+
case "remove": {
|
|
55
|
+
const entry = yield* service.removeRegistered(
|
|
56
|
+
options.args[0]!,
|
|
57
|
+
options.configDirectory,
|
|
58
|
+
options.cwd,
|
|
59
|
+
)
|
|
60
|
+
log(
|
|
61
|
+
options.json
|
|
62
|
+
? JSON.stringify(entry, null, 2)
|
|
63
|
+
: `Removed workbase ${entry.name ?? entry.id}`,
|
|
64
|
+
)
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
case "prune": {
|
|
68
|
+
const removed = yield* service.pruneRegistered(options.configDirectory)
|
|
69
|
+
log(
|
|
70
|
+
options.json
|
|
71
|
+
? JSON.stringify(removed, null, 2)
|
|
72
|
+
: `Pruned ${removed.length} stale workbase${removed.length === 1 ? "" : "s"}`,
|
|
73
|
+
)
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
case "default": {
|
|
77
|
+
const selector = options.clear ? null : options.args[0]
|
|
78
|
+
if (selector === undefined) {
|
|
79
|
+
const entry = yield* service.getDefault(options.configDirectory)
|
|
80
|
+
log(
|
|
81
|
+
options.json
|
|
82
|
+
? JSON.stringify(entry ?? null, null, 2)
|
|
83
|
+
: entry
|
|
84
|
+
? `${entry.name ?? entry.id}\t${entry.path}`
|
|
85
|
+
: "No default workbase",
|
|
86
|
+
)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
const entry = yield* service.setDefault(
|
|
90
|
+
selector,
|
|
91
|
+
options.configDirectory,
|
|
92
|
+
)
|
|
93
|
+
log(
|
|
94
|
+
options.json
|
|
95
|
+
? JSON.stringify(entry, null, 2)
|
|
96
|
+
: entry
|
|
97
|
+
? `Default workbase is ${entry.name ?? entry.id}`
|
|
98
|
+
: "Cleared default workbase",
|
|
99
|
+
)
|
|
100
|
+
return
|
|
101
|
+
}
|
|
42
102
|
default:
|
|
43
103
|
return yield* Effect.fail(
|
|
44
|
-
new Error(
|
|
104
|
+
new Error(
|
|
105
|
+
"Subcommand is required. Available: add, list, remove, prune, default",
|
|
106
|
+
),
|
|
45
107
|
)
|
|
46
108
|
}
|
|
47
109
|
})
|
|
@@ -50,9 +112,14 @@ export const help = `
|
|
|
50
112
|
Usage: agency workbase <subcommand>
|
|
51
113
|
|
|
52
114
|
Subcommands:
|
|
53
|
-
add <path>
|
|
54
|
-
list
|
|
115
|
+
add <path> Register an Agency workbase
|
|
116
|
+
list List registered workbases
|
|
117
|
+
remove <selector> Remove a registered workbase
|
|
118
|
+
prune Remove registrations whose paths no longer exist
|
|
119
|
+
default [selector] Show or set the default workbase
|
|
55
120
|
|
|
56
121
|
Options:
|
|
57
|
-
--
|
|
122
|
+
--name <name> Name a registered workbase
|
|
123
|
+
--clear Clear the default workbase
|
|
124
|
+
--json Output results as JSON
|
|
58
125
|
`
|
package/src/protocol.ts
CHANGED
|
@@ -111,6 +111,12 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
111
111
|
retryable: false,
|
|
112
112
|
remediation: "Correct the workbase graph data or filters and retry.",
|
|
113
113
|
},
|
|
114
|
+
ExecutionGuardError: {
|
|
115
|
+
code: "EXECUTION_BLOCKED",
|
|
116
|
+
retryable: false,
|
|
117
|
+
remediation:
|
|
118
|
+
"Resolve the reported blockers or retry intentionally with --force.",
|
|
119
|
+
},
|
|
114
120
|
SyncError: {
|
|
115
121
|
code: "SYNC_ERROR",
|
|
116
122
|
retryable: false,
|
|
@@ -292,6 +292,42 @@ status: open
|
|
|
292
292
|
})
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
test("inherits parent epic validation blockers", async () => {
|
|
296
|
+
const root = await createWorkbase()
|
|
297
|
+
roots.push(root)
|
|
298
|
+
await write(
|
|
299
|
+
root,
|
|
300
|
+
"tasks/unlisted/TASK.md",
|
|
301
|
+
`---
|
|
302
|
+
ticketUrl: null
|
|
303
|
+
epic: delivery
|
|
304
|
+
repo: agency
|
|
305
|
+
branch: feat/unlisted
|
|
306
|
+
base: main
|
|
307
|
+
pr: null
|
|
308
|
+
status: open
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
# Unlisted
|
|
312
|
+
`,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
const graph = await getGraph(root)
|
|
316
|
+
const execution = graph.nodes.find(
|
|
317
|
+
(node) => node.id === "execution-unit:task/unlisted",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
expect(execution?.readiness).toMatchObject({
|
|
321
|
+
ready: false,
|
|
322
|
+
blockers: [
|
|
323
|
+
{
|
|
324
|
+
kind: "validation",
|
|
325
|
+
reason: "Epic does not list child task 'unlisted'",
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
295
331
|
test("adds body, workspace, git, and PR details only when requested", async () => {
|
|
296
332
|
const root = await createWorkbase()
|
|
297
333
|
roots.push(root)
|
|
@@ -279,6 +279,9 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
279
279
|
const phaseState = (taskId: string, phaseId: string) => {
|
|
280
280
|
const task = tasks.get(taskId)
|
|
281
281
|
const phase = phases.get(`${taskId}/${phaseId}`)
|
|
282
|
+
const parentEpic = task?.data.epic
|
|
283
|
+
? epics.get(task.data.epic)
|
|
284
|
+
: undefined
|
|
282
285
|
const declaration =
|
|
283
286
|
task && "phases" in task.data
|
|
284
287
|
? task.data.phases.find((item) => item.id === phaseId)
|
|
@@ -297,7 +300,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
297
300
|
"Parent task",
|
|
298
301
|
),
|
|
299
302
|
...validationBlockers(
|
|
300
|
-
[phase?.path, task?.path]
|
|
303
|
+
[phase?.path, task?.path, parentEpic?.path]
|
|
301
304
|
.filter((path): path is string => Boolean(path))
|
|
302
305
|
.map((path) => relative(root, path)),
|
|
303
306
|
),
|
|
@@ -323,11 +326,15 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
323
326
|
|
|
324
327
|
const taskState = (taskId: string) => {
|
|
325
328
|
const task = tasks.get(taskId)
|
|
329
|
+
const parentEpic = task?.data.epic
|
|
330
|
+
? epics.get(task.data.epic)
|
|
331
|
+
: undefined
|
|
326
332
|
const statuses = taskLeafStatuses(taskId)
|
|
327
333
|
const aggregate = aggregateProgress(statuses)
|
|
328
334
|
const descendantPaths = task
|
|
329
335
|
? [
|
|
330
336
|
relative(root, task.path),
|
|
337
|
+
...(parentEpic ? [relative(root, parentEpic.path)] : []),
|
|
331
338
|
...[...phases.entries()]
|
|
332
339
|
.filter(([key]) => key.startsWith(`${taskId}/`))
|
|
333
340
|
.map(([, phase]) => relative(root, phase.path)),
|
|
@@ -67,11 +67,12 @@ describe("PullRequestService", () => {
|
|
|
67
67
|
taskId = "example",
|
|
68
68
|
phaseId?: string,
|
|
69
69
|
draft = false,
|
|
70
|
+
force = false,
|
|
70
71
|
) =>
|
|
71
72
|
runTestEffect(
|
|
72
73
|
PullRequestService.pipe(
|
|
73
74
|
Effect.flatMap((service) =>
|
|
74
|
-
service.create(taskId, phaseId, draft, root),
|
|
75
|
+
service.create(taskId, phaseId, draft, root, { force }),
|
|
75
76
|
),
|
|
76
77
|
),
|
|
77
78
|
)
|
|
@@ -249,6 +250,24 @@ process.exit(${exitCode})
|
|
|
249
250
|
])
|
|
250
251
|
})
|
|
251
252
|
|
|
253
|
+
test("guards terminal targets before materializing unless forced", async () => {
|
|
254
|
+
await createTask()
|
|
255
|
+
await runTestEffect(
|
|
256
|
+
TaskService.pipe(
|
|
257
|
+
Effect.flatMap((service) => service.setStatus("example", "done", root)),
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
await expect(createPullRequest()).rejects.toThrow("Task status is done")
|
|
262
|
+
expect(
|
|
263
|
+
await Bun.file(join(root, "tasks/example/code/agency")).exists(),
|
|
264
|
+
).toBe(false)
|
|
265
|
+
|
|
266
|
+
const url = "https://github.com/example/agency/pull/49"
|
|
267
|
+
await writeFakeGh({ stdout: url })
|
|
268
|
+
expect(await createPullRequest("example", undefined, false, true)).toBe(url)
|
|
269
|
+
})
|
|
270
|
+
|
|
252
271
|
test("updates only PHASE.md for a phase PR", async () => {
|
|
253
272
|
await runTestEffect(
|
|
254
273
|
TaskService.pipe(
|