@markjaquith/agency 2.16.0 → 2.18.0

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