@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.16.0",
3
+ "version": "2.17.0",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -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: "agency pr create <task-id> [phase-id] [--draft] [--json]",
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 || parsed.values.opencode || parsed.values.claude))
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
+ `
@@ -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
 
@@ -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 choices = buildWorkTargetChoices(
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 epics, tasks, or phases found in this workbase"),
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: BaseCommandOptions = {},
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: BaseCommandOptions = {},
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>(