@markjaquith/agency 2.16.0 → 2.17.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 +15 -0
- package/cli.ts +19 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +19 -0
- package/src/cli-parser.ts +23 -4
- package/src/cli.test.ts +50 -0
- 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/work.test.ts +84 -0
- package/src/commands/work.ts +25 -2
- 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/WorktreeService.test.ts +12 -0
- package/src/services/WorktreeService.ts +6 -2
- package/src/test-utils.ts +2 -0
package/README.md
CHANGED
|
@@ -304,6 +304,21 @@ inspection are opt-in include layers.
|
|
|
304
304
|
`end` record with counts. Combining the metadata with the streamed node and edge
|
|
305
305
|
records reconstructs the same result as `--json`.
|
|
306
306
|
|
|
307
|
+
### Next Ready Work
|
|
308
|
+
|
|
309
|
+
`agency next` lists ready execution units in descending unlock priority, with
|
|
310
|
+
their epic and task context. `agency next --select` returns only the highest-
|
|
311
|
+
priority ready unit in human output.
|
|
312
|
+
|
|
313
|
+
`agency next --json` returns the same ranked `ready` set plus every `excluded`
|
|
314
|
+
execution unit. Excluded entries retain status, terminal state, `blockedBy`, and
|
|
315
|
+
detailed dependency, validation, or status blockers for orchestrators.
|
|
316
|
+
|
|
317
|
+
`agency work` and `agency pr create` consult this shared readiness model before
|
|
318
|
+
materializing or pushing. Blocked, done, and dropped targets are rejected unless
|
|
319
|
+
`--force` is supplied explicitly. PR creation permits active `working` and
|
|
320
|
+
`delegated` targets when they have no dependency or validation blocker.
|
|
321
|
+
|
|
307
322
|
### Reconciliation
|
|
308
323
|
|
|
309
324
|
`agency sync` compares every execution declaration with local branch and worktree
|
package/cli.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { status, help as statusHelp } from "./src/commands/status"
|
|
|
10
10
|
import { validate, help as validateHelp } from "./src/commands/validate"
|
|
11
11
|
import { context, help as contextHelp } from "./src/commands/context"
|
|
12
12
|
import { graph, help as graphHelp } from "./src/commands/graph"
|
|
13
|
+
import { next, help as nextHelp } from "./src/commands/next"
|
|
13
14
|
import { sync, help as syncHelp } from "./src/commands/sync"
|
|
14
15
|
import { repo, help as repoHelp } from "./src/commands/repo"
|
|
15
16
|
import { epic, help as epicHelp } from "./src/commands/epic"
|
|
@@ -35,6 +36,7 @@ import { ContextService } from "./src/services/ContextService"
|
|
|
35
36
|
import { GraphService } from "./src/services/GraphService"
|
|
36
37
|
import { ClaimService } from "./src/services/ClaimService"
|
|
37
38
|
import { SyncService } from "./src/services/SyncService"
|
|
39
|
+
import { ReadinessService } from "./src/services/ReadinessService"
|
|
38
40
|
import {
|
|
39
41
|
claimCommand,
|
|
40
42
|
claimHelp,
|
|
@@ -64,6 +66,7 @@ const CliLayer = Layer.mergeAll(
|
|
|
64
66
|
GraphService.Default,
|
|
65
67
|
ClaimService.Default,
|
|
66
68
|
SyncService.Default,
|
|
69
|
+
ReadinessService.Default,
|
|
67
70
|
)
|
|
68
71
|
|
|
69
72
|
/**
|
|
@@ -213,6 +216,7 @@ const commands: Record<string, Command> = {
|
|
|
213
216
|
taskId: args[1],
|
|
214
217
|
phaseId: args[2],
|
|
215
218
|
draft: options.draft,
|
|
219
|
+
force: options.force,
|
|
216
220
|
json: options.json,
|
|
217
221
|
silent: options.silent,
|
|
218
222
|
verbose: options.verbose,
|
|
@@ -351,11 +355,25 @@ const commands: Record<string, Command> = {
|
|
|
351
355
|
verbose: options.verbose,
|
|
352
356
|
opencode: options.opencode,
|
|
353
357
|
claude: options.claude,
|
|
358
|
+
force: options.force,
|
|
354
359
|
inputAllowed: options.inputAllowed,
|
|
355
360
|
}),
|
|
356
361
|
)
|
|
357
362
|
},
|
|
358
363
|
},
|
|
364
|
+
next: {
|
|
365
|
+
run: async (_args: string[], options: Record<string, any>) => {
|
|
366
|
+
if (options.help) return console.log(nextHelp)
|
|
367
|
+
await runCommand(
|
|
368
|
+
next({
|
|
369
|
+
select: options.select,
|
|
370
|
+
json: options.json,
|
|
371
|
+
silent: options.silent,
|
|
372
|
+
verbose: options.verbose,
|
|
373
|
+
}),
|
|
374
|
+
)
|
|
375
|
+
},
|
|
376
|
+
},
|
|
359
377
|
status: {
|
|
360
378
|
run: async (_args: string[], options: Record<string, any>) => {
|
|
361
379
|
if (options.help) {
|
|
@@ -464,6 +482,7 @@ Commands:
|
|
|
464
482
|
archive <type> Archive a work item
|
|
465
483
|
task <subcommand> Manage tasks
|
|
466
484
|
work [directory|task] Work on an epic, task, or phase
|
|
485
|
+
next List or select ready execution units
|
|
467
486
|
pr create Create a pull request for an execution unit
|
|
468
487
|
repo <subcommand> Manage workbase repositories
|
|
469
488
|
status Show status for the current workbase
|
package/package.json
CHANGED
package/src/cli-parser.test.ts
CHANGED
|
@@ -141,6 +141,7 @@ describe("strict CLI parsing", () => {
|
|
|
141
141
|
[["validate", "one", "two"], "agency validate"],
|
|
142
142
|
[["context", "one", "two"], "agency context"],
|
|
143
143
|
[["graph", "extra"], "agency graph"],
|
|
144
|
+
[["next", "extra"], "agency next"],
|
|
144
145
|
[["sync", "extra"], "agency sync"],
|
|
145
146
|
[
|
|
146
147
|
[
|
|
@@ -192,6 +193,24 @@ describe("strict CLI parsing", () => {
|
|
|
192
193
|
}
|
|
193
194
|
})
|
|
194
195
|
|
|
196
|
+
test("parses readiness selection and explicit guard overrides", () => {
|
|
197
|
+
expect(parseCli(["next", "--select", "--json"])).toMatchObject({
|
|
198
|
+
commandName: "next",
|
|
199
|
+
values: { select: true, json: true },
|
|
200
|
+
})
|
|
201
|
+
expect(parseCli(["work", "example", "--force"])).toMatchObject({
|
|
202
|
+
commandName: "work",
|
|
203
|
+
values: { force: true },
|
|
204
|
+
})
|
|
205
|
+
expect(parseCli(["pr", "create", "example", "--force"])).toMatchObject({
|
|
206
|
+
commandName: "pr",
|
|
207
|
+
values: { force: true },
|
|
208
|
+
})
|
|
209
|
+
expect(() => parseCli(["work", "prepare", "example", "--force"])).toThrow(
|
|
210
|
+
"cannot be combined",
|
|
211
|
+
)
|
|
212
|
+
})
|
|
213
|
+
|
|
195
214
|
test("parses reconciliation modes and rejects conflicting modes", () => {
|
|
196
215
|
expect(parseCli(["sync", "--dry-run", "--json"])).toMatchObject({
|
|
197
216
|
commandName: "sync",
|
package/src/cli-parser.ts
CHANGED
|
@@ -368,13 +368,14 @@ const commands = {
|
|
|
368
368
|
epic: { type: "string" },
|
|
369
369
|
opencode: { type: "boolean" },
|
|
370
370
|
claude: { type: "boolean" },
|
|
371
|
+
force: { type: "boolean" },
|
|
371
372
|
},
|
|
372
373
|
command: {
|
|
373
374
|
usage:
|
|
374
375
|
"agency work [<directory-or-task-id> | --epic <epic-id>] | agency work prepare [target] [--dry-run] [--json]",
|
|
375
376
|
minArgs: 0,
|
|
376
377
|
maxArgs: 2,
|
|
377
|
-
options: ["json", "dry-run", "epic", "opencode", "claude"],
|
|
378
|
+
options: ["json", "dry-run", "epic", "opencode", "claude", "force"],
|
|
378
379
|
conflicts: [
|
|
379
380
|
["opencode", "claude"],
|
|
380
381
|
["epic", "$positional"],
|
|
@@ -386,16 +387,31 @@ const commands = {
|
|
|
386
387
|
options: {
|
|
387
388
|
...outputOptions,
|
|
388
389
|
draft: { type: "boolean" },
|
|
390
|
+
force: { type: "boolean" },
|
|
389
391
|
},
|
|
390
392
|
subcommands: {
|
|
391
393
|
create: {
|
|
392
|
-
usage:
|
|
394
|
+
usage:
|
|
395
|
+
"agency pr create <task-id> [phase-id] [--draft] [--force] [--json]",
|
|
393
396
|
minArgs: 1,
|
|
394
397
|
maxArgs: 2,
|
|
395
|
-
options: ["draft", "json"],
|
|
398
|
+
options: ["draft", "force", "json"],
|
|
396
399
|
},
|
|
397
400
|
},
|
|
398
401
|
},
|
|
402
|
+
next: {
|
|
403
|
+
usage: "agency next [--select] [--json]",
|
|
404
|
+
options: {
|
|
405
|
+
...outputOptions,
|
|
406
|
+
select: { type: "boolean" },
|
|
407
|
+
},
|
|
408
|
+
command: {
|
|
409
|
+
usage: "agency next [--select] [--json]",
|
|
410
|
+
minArgs: 0,
|
|
411
|
+
maxArgs: 0,
|
|
412
|
+
options: ["select", "json"],
|
|
413
|
+
},
|
|
414
|
+
},
|
|
399
415
|
status: {
|
|
400
416
|
usage: "agency status [--json]",
|
|
401
417
|
options: outputOptions,
|
|
@@ -744,7 +760,10 @@ export function parseCli(args: readonly string[]): ParsedCli {
|
|
|
744
760
|
if (
|
|
745
761
|
(!preparing && commandPositionals.length > 1) ||
|
|
746
762
|
(preparing &&
|
|
747
|
-
(parsed.values.epic ||
|
|
763
|
+
(parsed.values.epic ||
|
|
764
|
+
parsed.values.opencode ||
|
|
765
|
+
parsed.values.claude ||
|
|
766
|
+
parsed.values.force))
|
|
748
767
|
) {
|
|
749
768
|
throw usageError(
|
|
750
769
|
preparing
|
package/src/cli.test.ts
CHANGED
|
@@ -245,6 +245,7 @@ describe("CLI", () => {
|
|
|
245
245
|
["validate", "Usage: agency validate"],
|
|
246
246
|
["context", "Usage: agency context"],
|
|
247
247
|
["graph", "Usage: agency graph"],
|
|
248
|
+
["next", "Usage: agency next"],
|
|
248
249
|
] as const) {
|
|
249
250
|
const result = await runCli([command, "--help"])
|
|
250
251
|
expect(result.exitCode).toBe(0)
|
|
@@ -264,6 +265,55 @@ describe("CLI", () => {
|
|
|
264
265
|
|
|
265
266
|
const after = await runCli(["status", "--silent"], root)
|
|
266
267
|
expect(after).toEqual({ exitCode: 0, stdout: "", stderr: "" })
|
|
268
|
+
}, 10_000)
|
|
269
|
+
|
|
270
|
+
test("lists ready work and exposes excluded blockers through one result", async () => {
|
|
271
|
+
const root = await createTempDir()
|
|
272
|
+
tempDirs.push(root)
|
|
273
|
+
await Bun.write(join(root, "agency.json"), '{"version":2}\n')
|
|
274
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
275
|
+
for (const id of ["ready", "finished"]) {
|
|
276
|
+
parseJson(
|
|
277
|
+
await runCli(
|
|
278
|
+
["task", "create", id, "--repo", "agency", "--json"],
|
|
279
|
+
root,
|
|
280
|
+
),
|
|
281
|
+
)
|
|
282
|
+
}
|
|
283
|
+
parseJson(
|
|
284
|
+
await runCli(["task", "status", "finished", "done", "--json"], root),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const human = await runCli(["next"], root)
|
|
288
|
+
expect(human).toMatchObject({ exitCode: 0, stderr: "" })
|
|
289
|
+
expect(human.stdout).toContain("1. task/ready")
|
|
290
|
+
expect(human.stdout).not.toContain("task/finished")
|
|
291
|
+
|
|
292
|
+
const result = parseJson(await runCli(["next", "--select", "--json"], root))
|
|
293
|
+
expect(result.selected).toMatchObject({ key: "task/ready", rank: 1 })
|
|
294
|
+
expect(result.ready.map((item: any) => item.key)).toEqual(["task/ready"])
|
|
295
|
+
expect(result.excluded).toMatchObject([
|
|
296
|
+
{
|
|
297
|
+
key: "task/finished",
|
|
298
|
+
status: "done",
|
|
299
|
+
terminal: true,
|
|
300
|
+
blockers: [{ kind: "status", reason: "Task status is done" }],
|
|
301
|
+
},
|
|
302
|
+
])
|
|
303
|
+
|
|
304
|
+
const blockedPr = await runCli(["pr", "create", "finished", "--json"], root)
|
|
305
|
+
expect(blockedPr.exitCode).toBe(1)
|
|
306
|
+
expect(blockedPr.stderr).toBe("")
|
|
307
|
+
expect(JSON.parse(blockedPr.stdout)).toMatchObject({
|
|
308
|
+
ok: false,
|
|
309
|
+
error: {
|
|
310
|
+
code: "EXECUTION_BLOCKED",
|
|
311
|
+
fields: {
|
|
312
|
+
status: "done",
|
|
313
|
+
blockers: [{ kind: "status", reason: "Task status is done" }],
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
})
|
|
267
317
|
})
|
|
268
318
|
|
|
269
319
|
test("reports and synchronizes managed integration files", async () => {
|
|
@@ -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(
|
|
@@ -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[]
|
|
@@ -167,6 +171,34 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
167
171
|
return Effect.succeed({ revision: "1".repeat(64) })
|
|
168
172
|
},
|
|
169
173
|
}
|
|
174
|
+
const readiness = {
|
|
175
|
+
getReadyWorkTargetIds: () =>
|
|
176
|
+
Effect.succeed(
|
|
177
|
+
new Set(
|
|
178
|
+
options.readyTargetIds ?? [
|
|
179
|
+
...(options.epicRecords ?? []).map(
|
|
180
|
+
(record: any) => `epic:${record.id}`,
|
|
181
|
+
),
|
|
182
|
+
...(options.taskRecords ?? []).map((record: any) =>
|
|
183
|
+
"phases" in record.data
|
|
184
|
+
? `task:${record.id}`
|
|
185
|
+
: `execution-unit:task/${record.id}`,
|
|
186
|
+
),
|
|
187
|
+
...(options.phaseRecords ?? []).map(
|
|
188
|
+
(record: any) =>
|
|
189
|
+
`execution-unit:phase/${record.taskId}/${record.id}`,
|
|
190
|
+
),
|
|
191
|
+
],
|
|
192
|
+
),
|
|
193
|
+
),
|
|
194
|
+
guardWorkTarget: (target: string, _root: string, override?: boolean) => {
|
|
195
|
+
if (options.guardError || override) events.push("guard")
|
|
196
|
+
guards.push({ target, override })
|
|
197
|
+
return options.guardError && !override
|
|
198
|
+
? Effect.fail(options.guardError)
|
|
199
|
+
: Effect.void
|
|
200
|
+
},
|
|
201
|
+
}
|
|
170
202
|
const fs = {
|
|
171
203
|
isDirectory: (path: string) =>
|
|
172
204
|
Effect.succeed(options.existingDirectories?.includes(path) ?? true),
|
|
@@ -206,6 +238,7 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
206
238
|
Effect.provideService(TaskService, tasks as never),
|
|
207
239
|
Effect.provideService(PhaseService, phases as never),
|
|
208
240
|
Effect.provideService(ClaimService, claims as never),
|
|
241
|
+
Effect.provideService(ReadinessService, readiness as never),
|
|
209
242
|
) as Effect.Effect<void, unknown, never>,
|
|
210
243
|
)
|
|
211
244
|
const runPrepare = (commandOptions: Parameters<typeof workPrepare>[0]) =>
|
|
@@ -227,12 +260,63 @@ const createHarness = (options: HarnessOptions = {}) => {
|
|
|
227
260
|
statusUpdates,
|
|
228
261
|
shownTasks,
|
|
229
262
|
progressUpdates,
|
|
263
|
+
guards,
|
|
230
264
|
run,
|
|
231
265
|
runPrepare,
|
|
232
266
|
}
|
|
233
267
|
}
|
|
234
268
|
|
|
235
269
|
describe("work command", () => {
|
|
270
|
+
test("guards execution targets before materialization and honors --force", async () => {
|
|
271
|
+
const blocked = createHarness({ guardError: new Error("blocked") })
|
|
272
|
+
await expect(
|
|
273
|
+
blocked.run({ taskId: "example", opencode: true }),
|
|
274
|
+
).rejects.toThrow("blocked")
|
|
275
|
+
expect(blocked.events).toEqual(["guard"])
|
|
276
|
+
expect(blocked.guards).toEqual([
|
|
277
|
+
{ target: "execution-unit:task/example", override: undefined },
|
|
278
|
+
])
|
|
279
|
+
|
|
280
|
+
const forced = createHarness({ guardError: new Error("blocked") })
|
|
281
|
+
await forced.run({ taskId: "example", opencode: true, force: true })
|
|
282
|
+
expect(forced.events).toEqual([
|
|
283
|
+
"guard",
|
|
284
|
+
"materialize",
|
|
285
|
+
"probe:opencode",
|
|
286
|
+
"launch:opencode",
|
|
287
|
+
])
|
|
288
|
+
expect(forced.guards[0]).toEqual({
|
|
289
|
+
target: "execution-unit:task/example",
|
|
290
|
+
override: true,
|
|
291
|
+
})
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
test("offers only graph-ready targets to the interactive chooser", async () => {
|
|
295
|
+
const harness = createHarness({
|
|
296
|
+
taskRecords: [
|
|
297
|
+
{
|
|
298
|
+
id: "ready",
|
|
299
|
+
path: "/workbase/tasks/ready/TASK.md",
|
|
300
|
+
data: { status: "open" },
|
|
301
|
+
},
|
|
302
|
+
{
|
|
303
|
+
id: "blocked",
|
|
304
|
+
path: "/workbase/tasks/blocked/TASK.md",
|
|
305
|
+
data: { status: "open" },
|
|
306
|
+
},
|
|
307
|
+
],
|
|
308
|
+
readyTargetIds: ["execution-unit:task/ready"],
|
|
309
|
+
})
|
|
310
|
+
let labels: readonly string[] = []
|
|
311
|
+
const pick: PickWorkTarget = (choices) => {
|
|
312
|
+
labels = choices.map((choice) => choice.plainLabel)
|
|
313
|
+
return Effect.succeed(null)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
await harness.run({ cwd: "/workbase" }, pick)
|
|
317
|
+
expect(labels).toEqual(["[open] task ready"])
|
|
318
|
+
})
|
|
319
|
+
|
|
236
320
|
test("prepares without launching or changing lifecycle status", async () => {
|
|
237
321
|
const harness = createHarness({ existingDirectories: [] })
|
|
238
322
|
|
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
|
|
@@ -334,6 +356,7 @@ Options:
|
|
|
334
356
|
--epic <id> Work on an epic
|
|
335
357
|
--opencode Require OpenCode
|
|
336
358
|
--claude Require Claude Code
|
|
359
|
+
--force Override readiness and terminal-state guards
|
|
337
360
|
--no-input Never open an interactive selector
|
|
338
361
|
|
|
339
362
|
Without interactive input, provide a directory, task ID, or --epic and run the
|
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(
|
|
@@ -5,6 +5,7 @@ import { WorktreeService } from "./WorktreeService"
|
|
|
5
5
|
import type { BaseCommandOptions } from "../utils/command"
|
|
6
6
|
import { TaskService } from "./TaskService"
|
|
7
7
|
import { PhaseService } from "./PhaseService"
|
|
8
|
+
import { ReadinessService } from "./ReadinessService"
|
|
8
9
|
import {
|
|
9
10
|
formatMarkdownDocument,
|
|
10
11
|
parseFrontmatter,
|
|
@@ -16,6 +17,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
|
16
17
|
|
|
17
18
|
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
18
19
|
|
|
20
|
+
interface PullRequestOptions extends BaseCommandOptions {
|
|
21
|
+
readonly force?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
20
25
|
"PullRequestService",
|
|
21
26
|
{
|
|
@@ -60,7 +65,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
60
65
|
phaseId?: string,
|
|
61
66
|
draft = false,
|
|
62
67
|
startPath: string = process.cwd(),
|
|
63
|
-
options:
|
|
68
|
+
options: PullRequestOptions = {},
|
|
64
69
|
) =>
|
|
65
70
|
Effect.gen(function* () {
|
|
66
71
|
const service = yield* PullRequestService
|
|
@@ -68,6 +73,14 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
68
73
|
const tasks = yield* TaskService
|
|
69
74
|
const phases = yield* PhaseService
|
|
70
75
|
const worktrees = yield* WorktreeService
|
|
76
|
+
const readiness = yield* ReadinessService
|
|
77
|
+
yield* readiness.guard(
|
|
78
|
+
"pr",
|
|
79
|
+
taskId,
|
|
80
|
+
phaseId,
|
|
81
|
+
startPath,
|
|
82
|
+
options.force,
|
|
83
|
+
)
|
|
71
84
|
const workspace = yield* worktrees.materialize(
|
|
72
85
|
taskId,
|
|
73
86
|
phaseId,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { ReadinessService } from "./ReadinessService"
|
|
7
|
+
|
|
8
|
+
const write = async (root: string, path: string, content: string) => {
|
|
9
|
+
const fullPath = join(root, path)
|
|
10
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
11
|
+
await Bun.write(fullPath, content)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const execution = (status: string, branch: string) => `---
|
|
15
|
+
repo: agency
|
|
16
|
+
branch: ${branch}
|
|
17
|
+
base: main
|
|
18
|
+
pr: null
|
|
19
|
+
status: ${status}
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Execution
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const createWorkbase = async () => {
|
|
26
|
+
const root = await createTempDir()
|
|
27
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
28
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
29
|
+
await write(
|
|
30
|
+
root,
|
|
31
|
+
"epics/delivery/EPIC.md",
|
|
32
|
+
`---
|
|
33
|
+
ticketUrl: https://example.com/delivery
|
|
34
|
+
repos:
|
|
35
|
+
- repo: agency
|
|
36
|
+
ref: main
|
|
37
|
+
tasks:
|
|
38
|
+
- id: prepare
|
|
39
|
+
- id: ship
|
|
40
|
+
dependsOn: [prepare]
|
|
41
|
+
- id: deploy
|
|
42
|
+
dependsOn: [ship]
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Delivery
|
|
46
|
+
`,
|
|
47
|
+
)
|
|
48
|
+
await write(
|
|
49
|
+
root,
|
|
50
|
+
"tasks/prepare/TASK.md",
|
|
51
|
+
`---
|
|
52
|
+
ticketUrl: null
|
|
53
|
+
epic: delivery
|
|
54
|
+
repo: agency
|
|
55
|
+
branch: feat/prepare
|
|
56
|
+
base: main
|
|
57
|
+
pr: null
|
|
58
|
+
status: done
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# Prepare
|
|
62
|
+
`,
|
|
63
|
+
)
|
|
64
|
+
await write(
|
|
65
|
+
root,
|
|
66
|
+
"tasks/ship/TASK.md",
|
|
67
|
+
`---
|
|
68
|
+
ticketUrl: null
|
|
69
|
+
description: Ship the feature.
|
|
70
|
+
epic: delivery
|
|
71
|
+
phases:
|
|
72
|
+
- id: implement
|
|
73
|
+
- id: verify
|
|
74
|
+
dependsOn: [implement]
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Ship
|
|
78
|
+
`,
|
|
79
|
+
)
|
|
80
|
+
await write(
|
|
81
|
+
root,
|
|
82
|
+
"tasks/ship/phases/implement/PHASE.md",
|
|
83
|
+
execution("open", "feat/implement"),
|
|
84
|
+
)
|
|
85
|
+
await write(
|
|
86
|
+
root,
|
|
87
|
+
"tasks/ship/phases/verify/PHASE.md",
|
|
88
|
+
execution("open", "feat/verify"),
|
|
89
|
+
)
|
|
90
|
+
await write(
|
|
91
|
+
root,
|
|
92
|
+
"tasks/abandoned/TASK.md",
|
|
93
|
+
`---
|
|
94
|
+
ticketUrl: null
|
|
95
|
+
repo: agency
|
|
96
|
+
branch: feat/abandoned
|
|
97
|
+
base: main
|
|
98
|
+
pr: null
|
|
99
|
+
status: dropped
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
# Abandoned
|
|
103
|
+
`,
|
|
104
|
+
)
|
|
105
|
+
await write(
|
|
106
|
+
root,
|
|
107
|
+
"tasks/deploy/TASK.md",
|
|
108
|
+
`---
|
|
109
|
+
ticketUrl: null
|
|
110
|
+
epic: delivery
|
|
111
|
+
repo: agency
|
|
112
|
+
branch: feat/deploy
|
|
113
|
+
base: main
|
|
114
|
+
pr: null
|
|
115
|
+
status: open
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
# Deploy
|
|
119
|
+
`,
|
|
120
|
+
)
|
|
121
|
+
return root
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const service = <A>(
|
|
125
|
+
run: (readiness: ReadinessService) => Effect.Effect<A, unknown, any>,
|
|
126
|
+
) => runTestEffect(ReadinessService.pipe(Effect.flatMap(run)))
|
|
127
|
+
|
|
128
|
+
describe("ReadinessService", () => {
|
|
129
|
+
const roots: string[] = []
|
|
130
|
+
|
|
131
|
+
afterEach(async () => {
|
|
132
|
+
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("ranks ready work and explains every excluded execution unit", async () => {
|
|
136
|
+
const root = await createWorkbase()
|
|
137
|
+
roots.push(root)
|
|
138
|
+
|
|
139
|
+
const result = await service((readiness) => readiness.getNext(root, true))
|
|
140
|
+
|
|
141
|
+
expect(result.ready.map((item) => item.key)).toEqual([
|
|
142
|
+
"phase/ship/implement",
|
|
143
|
+
])
|
|
144
|
+
expect(result.selected).toMatchObject({
|
|
145
|
+
key: "phase/ship/implement",
|
|
146
|
+
parent: { epicId: "delivery", taskId: "ship" },
|
|
147
|
+
priority: { dependentCount: 1 },
|
|
148
|
+
})
|
|
149
|
+
expect(result.excluded.map((item) => item.key)).toEqual([
|
|
150
|
+
"task/prepare",
|
|
151
|
+
"phase/ship/verify",
|
|
152
|
+
"task/abandoned",
|
|
153
|
+
"task/deploy",
|
|
154
|
+
])
|
|
155
|
+
expect(
|
|
156
|
+
result.excluded.find((item) => item.key === "phase/ship/verify"),
|
|
157
|
+
).toMatchObject({
|
|
158
|
+
ready: false,
|
|
159
|
+
terminal: false,
|
|
160
|
+
blockedBy: ["phase:ship/implement"],
|
|
161
|
+
blockers: [
|
|
162
|
+
{
|
|
163
|
+
kind: "dependency",
|
|
164
|
+
status: "open",
|
|
165
|
+
reason: "Phase dependency is open",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
})
|
|
169
|
+
expect(
|
|
170
|
+
result.excluded.find((item) => item.key === "task/abandoned"),
|
|
171
|
+
).toMatchObject({ status: "dropped", terminal: true })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("includes cross-task unlocks in final-phase priority", async () => {
|
|
175
|
+
const root = await createWorkbase()
|
|
176
|
+
roots.push(root)
|
|
177
|
+
await Bun.write(
|
|
178
|
+
join(root, "tasks/ship/phases/implement/PHASE.md"),
|
|
179
|
+
execution("done", "feat/implement"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const result = await service((readiness) => readiness.getNext(root, true))
|
|
183
|
+
|
|
184
|
+
expect(result.selected).toMatchObject({
|
|
185
|
+
key: "phase/ship/verify",
|
|
186
|
+
priority: { dependentCount: 1 },
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("uses the same readiness for work and PR guards", async () => {
|
|
191
|
+
const root = await createWorkbase()
|
|
192
|
+
roots.push(root)
|
|
193
|
+
|
|
194
|
+
await service((readiness) =>
|
|
195
|
+
readiness.guard("work", "ship", "implement", root),
|
|
196
|
+
)
|
|
197
|
+
await expect(
|
|
198
|
+
service((readiness) => readiness.guard("work", "ship", "verify", root)),
|
|
199
|
+
).rejects.toThrow("Phase dependency is open")
|
|
200
|
+
await expect(
|
|
201
|
+
service((readiness) => readiness.guard("pr", "ship", "verify", root)),
|
|
202
|
+
).rejects.toThrow("Phase dependency is open")
|
|
203
|
+
await service((readiness) =>
|
|
204
|
+
readiness.guard("work", "ship", "verify", root, true),
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("allows active PR targets but rejects terminal outcomes", async () => {
|
|
209
|
+
const root = await createWorkbase()
|
|
210
|
+
roots.push(root)
|
|
211
|
+
const path = join(root, "tasks/ship/phases/implement/PHASE.md")
|
|
212
|
+
await Bun.write(path, execution("working", "feat/implement"))
|
|
213
|
+
|
|
214
|
+
await service((readiness) =>
|
|
215
|
+
readiness.guard("pr", "ship", "implement", root),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
await Bun.write(path, execution("done", "feat/implement"))
|
|
219
|
+
await expect(
|
|
220
|
+
service((readiness) => readiness.guard("pr", "ship", "implement", root)),
|
|
221
|
+
).rejects.toThrow("Phase status is done")
|
|
222
|
+
})
|
|
223
|
+
})
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import { Data, Effect } from "effect"
|
|
2
|
+
import type { AgencyGraph, GraphBlocker, GraphNode } from "../graph-schema"
|
|
3
|
+
import type { WorkStatus } from "../workbase/schemas"
|
|
4
|
+
import { GraphService } from "./GraphService"
|
|
5
|
+
|
|
6
|
+
type ExecutionNode = Extract<GraphNode, { readonly kind: "execution-unit" }>
|
|
7
|
+
|
|
8
|
+
interface NextItem {
|
|
9
|
+
readonly rank: number
|
|
10
|
+
readonly key: string
|
|
11
|
+
readonly taskId: string
|
|
12
|
+
readonly phaseId?: string
|
|
13
|
+
readonly description?: string
|
|
14
|
+
readonly parent: {
|
|
15
|
+
readonly taskId?: string
|
|
16
|
+
readonly epicId?: string
|
|
17
|
+
}
|
|
18
|
+
readonly status: WorkStatus
|
|
19
|
+
readonly repositories: readonly string[]
|
|
20
|
+
readonly priority: {
|
|
21
|
+
readonly dependentCount: number
|
|
22
|
+
}
|
|
23
|
+
readonly ready: boolean
|
|
24
|
+
readonly terminal: boolean
|
|
25
|
+
readonly blockedBy: readonly string[]
|
|
26
|
+
readonly blockers: readonly GraphBlocker[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface NextResult {
|
|
30
|
+
readonly ready: readonly NextItem[]
|
|
31
|
+
readonly excluded: readonly NextItem[]
|
|
32
|
+
readonly selected?: NextItem
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
class ExecutionGuardError extends Data.TaggedError("ExecutionGuardError")<{
|
|
36
|
+
readonly message: string
|
|
37
|
+
readonly action: "work" | "pr"
|
|
38
|
+
readonly target: string
|
|
39
|
+
readonly status: WorkStatus
|
|
40
|
+
readonly blockedBy: readonly string[]
|
|
41
|
+
readonly blockers: readonly GraphBlocker[]
|
|
42
|
+
}> {}
|
|
43
|
+
|
|
44
|
+
const executionNodeId = (taskId: string, phaseId?: string) =>
|
|
45
|
+
phaseId
|
|
46
|
+
? `execution-unit:phase/${taskId}/${phaseId}`
|
|
47
|
+
: `execution-unit:task/${taskId}`
|
|
48
|
+
|
|
49
|
+
const itemFor = (
|
|
50
|
+
node: ExecutionNode,
|
|
51
|
+
graph: AgencyGraph,
|
|
52
|
+
rank: number,
|
|
53
|
+
): NextItem => {
|
|
54
|
+
const task = graph.nodes.find(
|
|
55
|
+
(candidate) =>
|
|
56
|
+
candidate.kind === "task" && candidate.key === node.data.taskId,
|
|
57
|
+
)
|
|
58
|
+
const epicId =
|
|
59
|
+
task?.kind === "task" && typeof task.data.epic === "string"
|
|
60
|
+
? task.data.epic
|
|
61
|
+
: undefined
|
|
62
|
+
const dependentIds = new Set(node.dependents)
|
|
63
|
+
if (node.data.phaseId) {
|
|
64
|
+
const siblings = graph.nodes.filter(
|
|
65
|
+
(candidate): candidate is ExecutionNode =>
|
|
66
|
+
candidate.kind === "execution-unit" &&
|
|
67
|
+
candidate.data.taskId === node.data.taskId &&
|
|
68
|
+
candidate.id !== node.id,
|
|
69
|
+
)
|
|
70
|
+
if (siblings.every((sibling) => sibling.status === "done")) {
|
|
71
|
+
for (const dependent of task?.dependents ?? [])
|
|
72
|
+
dependentIds.add(dependent)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
rank,
|
|
77
|
+
key: node.key,
|
|
78
|
+
taskId: node.data.taskId,
|
|
79
|
+
...(node.data.phaseId ? { phaseId: node.data.phaseId } : {}),
|
|
80
|
+
...(node.data.description ? { description: node.data.description } : {}),
|
|
81
|
+
parent: {
|
|
82
|
+
...(node.data.phaseId ? { taskId: node.data.taskId } : {}),
|
|
83
|
+
...(epicId ? { epicId } : {}),
|
|
84
|
+
},
|
|
85
|
+
status: node.status,
|
|
86
|
+
repositories: node.repositories,
|
|
87
|
+
priority: { dependentCount: dependentIds.size },
|
|
88
|
+
ready: node.readiness.ready,
|
|
89
|
+
terminal: node.readiness.terminal,
|
|
90
|
+
blockedBy: node.readiness.blockedBy,
|
|
91
|
+
blockers: node.readiness.blockers,
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const rankedItems = (graph: AgencyGraph) =>
|
|
96
|
+
graph.nodes
|
|
97
|
+
.filter((node): node is ExecutionNode => node.kind === "execution-unit")
|
|
98
|
+
.map((node) => itemFor(node, graph, 0))
|
|
99
|
+
.sort(
|
|
100
|
+
(left, right) =>
|
|
101
|
+
right.priority.dependentCount - left.priority.dependentCount ||
|
|
102
|
+
left.key.localeCompare(right.key),
|
|
103
|
+
)
|
|
104
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
105
|
+
|
|
106
|
+
const guardMessage = (
|
|
107
|
+
action: "work" | "pr",
|
|
108
|
+
item: Pick<NextItem, "key" | "status" | "blockers">,
|
|
109
|
+
) => {
|
|
110
|
+
const reasons = item.blockers.map((blocker) => blocker.reason)
|
|
111
|
+
return `Cannot ${action === "work" ? "work on" : "create a pull request for"} '${item.key}': ${reasons.length > 0 ? reasons.join("; ") : `status is ${item.status}`}. Use --force to override.`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class ReadinessService extends Effect.Service<ReadinessService>()(
|
|
115
|
+
"ReadinessService",
|
|
116
|
+
{
|
|
117
|
+
sync: () => ({
|
|
118
|
+
getReadyWorkTargetIds: (cwd: string = process.cwd()) =>
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const graphs = yield* GraphService
|
|
121
|
+
const graph = yield* graphs.get({ cwd })
|
|
122
|
+
return new Set(
|
|
123
|
+
graph.nodes
|
|
124
|
+
.filter((node) => node.readiness?.ready)
|
|
125
|
+
.map((node) => node.id),
|
|
126
|
+
)
|
|
127
|
+
}),
|
|
128
|
+
|
|
129
|
+
getNext: (cwd: string = process.cwd(), select = false) =>
|
|
130
|
+
Effect.gen(function* () {
|
|
131
|
+
const graphs = yield* GraphService
|
|
132
|
+
const graph = yield* graphs.get({ cwd })
|
|
133
|
+
const items = rankedItems(graph)
|
|
134
|
+
const ready = items
|
|
135
|
+
.filter((item) => item.ready)
|
|
136
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
137
|
+
const excluded = items
|
|
138
|
+
.filter((item) => !item.ready)
|
|
139
|
+
.map((item, index) => ({ ...item, rank: index + 1 }))
|
|
140
|
+
return {
|
|
141
|
+
ready,
|
|
142
|
+
excluded,
|
|
143
|
+
...(select && ready[0] ? { selected: ready[0] } : {}),
|
|
144
|
+
} satisfies NextResult
|
|
145
|
+
}),
|
|
146
|
+
|
|
147
|
+
guardWorkTarget: (
|
|
148
|
+
target: string,
|
|
149
|
+
cwd: string = process.cwd(),
|
|
150
|
+
override = false,
|
|
151
|
+
) =>
|
|
152
|
+
Effect.gen(function* () {
|
|
153
|
+
if (override) return
|
|
154
|
+
const graphs = yield* GraphService
|
|
155
|
+
const graph = yield* graphs.get({ cwd })
|
|
156
|
+
const node = graph.nodes.find((candidate) => candidate.id === target)
|
|
157
|
+
if (!node || !node.readiness) {
|
|
158
|
+
return yield* new ExecutionGuardError({
|
|
159
|
+
message: `Work target '${target}' was not found in the work graph.`,
|
|
160
|
+
action: "work",
|
|
161
|
+
target,
|
|
162
|
+
status: "open",
|
|
163
|
+
blockedBy: [],
|
|
164
|
+
blockers: [],
|
|
165
|
+
})
|
|
166
|
+
}
|
|
167
|
+
if (!node.readiness.ready) {
|
|
168
|
+
const item = {
|
|
169
|
+
key: node.key,
|
|
170
|
+
status: node.status!,
|
|
171
|
+
blockers: node.readiness.blockers,
|
|
172
|
+
}
|
|
173
|
+
return yield* new ExecutionGuardError({
|
|
174
|
+
message: guardMessage("work", item),
|
|
175
|
+
action: "work",
|
|
176
|
+
target,
|
|
177
|
+
status: node.status!,
|
|
178
|
+
blockedBy: node.readiness.blockedBy,
|
|
179
|
+
blockers: node.readiness.blockers,
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}),
|
|
183
|
+
|
|
184
|
+
guard: (
|
|
185
|
+
action: "work" | "pr",
|
|
186
|
+
taskId: string,
|
|
187
|
+
phaseId?: string,
|
|
188
|
+
cwd: string = process.cwd(),
|
|
189
|
+
override = false,
|
|
190
|
+
) =>
|
|
191
|
+
Effect.gen(function* () {
|
|
192
|
+
if (override) return
|
|
193
|
+
const graphs = yield* GraphService
|
|
194
|
+
const graph = yield* graphs.get({ cwd })
|
|
195
|
+
const items = rankedItems(graph)
|
|
196
|
+
const key = phaseId ? `phase/${taskId}/${phaseId}` : `task/${taskId}`
|
|
197
|
+
const item = items.find((candidate) => candidate.key === key)
|
|
198
|
+
if (!item) {
|
|
199
|
+
return yield* new ExecutionGuardError({
|
|
200
|
+
message: `Execution unit '${key}' was not found in the work graph.`,
|
|
201
|
+
action,
|
|
202
|
+
target: executionNodeId(taskId, phaseId),
|
|
203
|
+
status: "open",
|
|
204
|
+
blockedBy: [],
|
|
205
|
+
blockers: [],
|
|
206
|
+
})
|
|
207
|
+
}
|
|
208
|
+
const actionable =
|
|
209
|
+
action === "work"
|
|
210
|
+
? item.ready
|
|
211
|
+
: !item.terminal &&
|
|
212
|
+
!item.blockers.some(
|
|
213
|
+
(blocker) =>
|
|
214
|
+
blocker.kind === "dependency" ||
|
|
215
|
+
blocker.kind === "validation",
|
|
216
|
+
)
|
|
217
|
+
if (!actionable) {
|
|
218
|
+
return yield* new ExecutionGuardError({
|
|
219
|
+
message: guardMessage(action, item),
|
|
220
|
+
action,
|
|
221
|
+
target: executionNodeId(taskId, phaseId),
|
|
222
|
+
status: item.status,
|
|
223
|
+
blockedBy: item.blockedBy,
|
|
224
|
+
blockers: item.blockers,
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
}),
|
|
228
|
+
}),
|
|
229
|
+
},
|
|
230
|
+
) {}
|
|
@@ -206,6 +206,18 @@ describe("WorktreeService", () => {
|
|
|
206
206
|
expect(
|
|
207
207
|
await Bun.file(join(root, "tasks", "valid-target", "code")).exists(),
|
|
208
208
|
).toBe(false)
|
|
209
|
+
|
|
210
|
+
await expect(
|
|
211
|
+
runTestEffect(
|
|
212
|
+
WorktreeService.pipe(
|
|
213
|
+
Effect.flatMap((service) =>
|
|
214
|
+
service.materialize("valid-target", undefined, root, {
|
|
215
|
+
force: true,
|
|
216
|
+
}),
|
|
217
|
+
),
|
|
218
|
+
),
|
|
219
|
+
),
|
|
220
|
+
).resolves.toMatchObject({ repo: "agency" })
|
|
209
221
|
})
|
|
210
222
|
|
|
211
223
|
test("uses a configured worktree creation command", async () => {
|
|
@@ -84,6 +84,10 @@ const isCommitId = (ref: string) => /^[0-9a-f]{40,64}$/i.test(ref)
|
|
|
84
84
|
const originRef = (ref: string) =>
|
|
85
85
|
ref.replace(/^refs\/remotes\/origin\//, "").replace(/^origin\//, "")
|
|
86
86
|
|
|
87
|
+
interface MaterializeOptions extends BaseCommandOptions {
|
|
88
|
+
readonly force?: boolean
|
|
89
|
+
}
|
|
90
|
+
|
|
87
91
|
export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
88
92
|
"WorktreeService",
|
|
89
93
|
{
|
|
@@ -92,7 +96,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
92
96
|
taskId: string,
|
|
93
97
|
phaseId?: string,
|
|
94
98
|
startPath: string = process.cwd(),
|
|
95
|
-
options:
|
|
99
|
+
options: MaterializeOptions = {},
|
|
96
100
|
) =>
|
|
97
101
|
Effect.gen(function* () {
|
|
98
102
|
const fs = yield* FileSystemService
|
|
@@ -105,7 +109,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
|
|
|
105
109
|
const { root, config } = yield* workbase.loadConfig(startPath)
|
|
106
110
|
const report = yield* workbase.validate(root)
|
|
107
111
|
const validationIssue = report.issues[0]
|
|
108
|
-
if (validationIssue) {
|
|
112
|
+
if (validationIssue && !options.force) {
|
|
109
113
|
return yield* new WorktreeError({
|
|
110
114
|
message: `${validationIssue.path}: ${validationIssue.message}`,
|
|
111
115
|
})
|
package/src/test-utils.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { ContextService } from "./services/ContextService"
|
|
|
17
17
|
import { GraphService } from "./services/GraphService"
|
|
18
18
|
import { ClaimService } from "./services/ClaimService"
|
|
19
19
|
import { SyncService } from "./services/SyncService"
|
|
20
|
+
import { ReadinessService } from "./services/ReadinessService"
|
|
20
21
|
|
|
21
22
|
export const createTempDir = () => mkdtemp(join(tmpdir(), "agency-test-"))
|
|
22
23
|
|
|
@@ -38,6 +39,7 @@ const TestLayer = Layer.mergeAll(
|
|
|
38
39
|
GraphService.Default,
|
|
39
40
|
ClaimService.Default,
|
|
40
41
|
SyncService.Default,
|
|
42
|
+
ReadinessService.Default,
|
|
41
43
|
)
|
|
42
44
|
|
|
43
45
|
export async function runTestEffect<A, E>(
|