@markjaquith/agency 2.15.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.
@@ -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,17 @@ 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
+ },
120
+ SyncError: {
121
+ code: "SYNC_ERROR",
122
+ retryable: false,
123
+ remediation: "Resolve workbase validation errors before reconciling.",
124
+ },
114
125
  ProcessError: { code: "PROCESS_ERROR", retryable: true },
115
126
  ProtocolOutputError: {
116
127
  code: "PROTOCOL_OUTPUT_ERROR",
@@ -87,6 +87,23 @@ interface FinishInput extends OwnedClaimInput {
87
87
  readonly outcome: "done" | "dropped"
88
88
  }
89
89
 
90
+ interface ExpireClaimInput {
91
+ readonly taskId: string
92
+ readonly phaseId?: string
93
+ readonly revision: string
94
+ readonly now?: Date
95
+ }
96
+
97
+ interface ReconcileInput {
98
+ readonly taskId: string
99
+ readonly phaseId?: string
100
+ readonly revision: string
101
+ readonly pr?: string
102
+ readonly status?: "done"
103
+ }
104
+
105
+ const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
106
+
90
107
  type SingleTaskData = Extract<TaskData, { readonly repo: string }>
91
108
  type ExecutionData = SingleTaskData | PhaseData
92
109
 
@@ -286,6 +303,83 @@ export class ClaimService extends Effect.Service<ClaimService>()(
286
303
  }
287
304
  }),
288
305
 
306
+ expire: (input: ExpireClaimInput, startPath: string = process.cwd()) =>
307
+ Effect.gen(function* () {
308
+ const service = yield* ClaimService
309
+ const inspected = yield* service.inspect(
310
+ input.taskId,
311
+ input.phaseId,
312
+ startPath,
313
+ )
314
+ return yield* operation(() =>
315
+ updateAtomically(
316
+ inspected.target,
317
+ input.revision,
318
+ (data, now) => {
319
+ if (
320
+ data.claim?.state !== "active" ||
321
+ data.claim.expiresAt === undefined ||
322
+ Date.parse(data.claim.expiresAt) > now.getTime() ||
323
+ (data.status !== "working" && data.status !== "delegated")
324
+ ) {
325
+ throw new ClaimError({
326
+ target: inspected.target.label,
327
+ message: `${inspected.target.label} does not have an expired active claim`,
328
+ })
329
+ }
330
+ const claim: ClaimRecord = {
331
+ ...data.claim,
332
+ state: "released",
333
+ releasedAt: now.toISOString(),
334
+ }
335
+ return { data: { ...data, status: "open", claim }, claim }
336
+ },
337
+ input.now ?? new Date(),
338
+ ),
339
+ )
340
+ }),
341
+
342
+ reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
343
+ Effect.gen(function* () {
344
+ if (input.pr !== undefined && !PR_URL.test(input.pr)) {
345
+ return yield* new ClaimError({
346
+ message: `Invalid GitHub pull request URL: ${input.pr}`,
347
+ })
348
+ }
349
+ const service = yield* ClaimService
350
+ const inspected = yield* service.inspect(
351
+ input.taskId,
352
+ input.phaseId,
353
+ startPath,
354
+ )
355
+ return yield* operation(() =>
356
+ updateAtomically(
357
+ inspected.target,
358
+ input.revision,
359
+ (data) => {
360
+ if (input.status === "done" && data.claim?.state === "active") {
361
+ throw new ClaimConflictError({
362
+ target: inspected.target.label,
363
+ currentRevision: input.revision,
364
+ claim: data.claim,
365
+ message: `${inspected.target.label} has an active claim`,
366
+ })
367
+ }
368
+ return {
369
+ data: {
370
+ ...data,
371
+ ...(input.pr !== undefined ? { pr: input.pr } : {}),
372
+ ...(input.status !== undefined
373
+ ? { status: input.status }
374
+ : {}),
375
+ },
376
+ }
377
+ },
378
+ new Date(),
379
+ ),
380
+ )
381
+ }),
382
+
289
383
  claim: (input: ClaimInput, startPath: string = process.cwd()) =>
290
384
  Effect.gen(function* () {
291
385
  for (const [label, value] of [
@@ -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
+ })