@markjaquith/agency 2.18.0 → 2.20.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.
@@ -23,6 +23,11 @@ import {
23
23
  resolveWorkbase,
24
24
  type PickWorkbase,
25
25
  } from "../workbase/workbase-choice"
26
+ import {
27
+ printableEnvironment,
28
+ resolveRunnerCommand,
29
+ runnerEnvironment,
30
+ } from "../workbase/runner-command"
26
31
 
27
32
  interface WorkOptions extends BaseCommandOptions {
28
33
  readonly directory?: string
@@ -31,10 +36,17 @@ interface WorkOptions extends BaseCommandOptions {
31
36
  readonly epicId?: string
32
37
  readonly opencode?: boolean
33
38
  readonly claude?: boolean
39
+ readonly runner?: string
40
+ readonly printCommand?: boolean
34
41
  readonly force?: boolean
35
42
  }
36
43
 
37
- type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
44
+ type LaunchAgent = (
45
+ cli: string,
46
+ args: readonly string[],
47
+ cwd: string,
48
+ environment: Readonly<Record<string, string>>,
49
+ ) => void
38
50
 
39
51
  const formatCommand = (args: readonly string[]) =>
40
52
  args
@@ -68,13 +80,15 @@ export const work = (
68
80
  pickBase: PickWorkbase = pickWorkbase,
69
81
  ) =>
70
82
  Effect.gen(function* () {
71
- if (options.opencode && options.claude) {
83
+ if (
84
+ (options.opencode && options.claude) ||
85
+ (options.runner && (options.opencode || options.claude))
86
+ ) {
72
87
  return yield* Effect.fail(
73
- new Error("Cannot use both --opencode and --claude"),
88
+ new Error("Cannot combine --runner, --opencode, and --claude"),
74
89
  )
75
90
  }
76
- const previousSessionId = process.env.AGENCY_SESSION_ID
77
- const previousClaimRevision = process.env.AGENCY_CLAIM_REVISION
91
+ const previousEnvironment = { ...process.env }
78
92
  if (
79
93
  options.epicId &&
80
94
  (options.directory || options.taskId || options.phaseId)
@@ -104,6 +118,7 @@ export const work = (
104
118
  const inputAllowed = options.inputAllowed ?? true
105
119
  const root = yield* resolveWorkbase(startPath, pickBase, inputAllowed)
106
120
  if (!root) return
121
+ const { config } = yield* workbase.loadConfig(root)
107
122
 
108
123
  let target: WorkTarget | null = null
109
124
  if (options.epicId) {
@@ -199,7 +214,6 @@ export const work = (
199
214
  new Error("No ready work targets found in this workbase"),
200
215
  )
201
216
  }
202
- const { config } = yield* workbase.loadConfig(root)
203
217
  target = yield* pick(choices, config.chooserCommand)
204
218
  if (!target) return
205
219
  }
@@ -233,13 +247,49 @@ export const work = (
233
247
  launchPath = workspace.writablePath
234
248
  }
235
249
 
236
- const requested = options.claude ? "claude" : "opencode"
237
- let cli = requested
250
+ const explicitlyRequested = Boolean(
251
+ options.runner ||
252
+ options.opencode ||
253
+ options.claude ||
254
+ process.env.AGENCY_RUNNER,
255
+ )
256
+ let runner =
257
+ options.runner ??
258
+ process.env.AGENCY_RUNNER ??
259
+ (options.claude ? "claude" : "opencode")
260
+ const claimant = process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency"
261
+ const sessionId =
262
+ process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
263
+ const resume = process.env.AGENCY_SESSION_ID !== undefined
264
+ let claimRevision = ""
265
+ let variables = {
266
+ prompt,
267
+ workbase: root,
268
+ target: targetNodeId(target),
269
+ task: target.kind === "epic" ? "" : target.taskId,
270
+ phase: target.kind === "phase" ? target.phaseId : "",
271
+ claimant,
272
+ sessionId,
273
+ claimRevision,
274
+ }
275
+ let resolved = resolveRunnerCommand(
276
+ runner,
277
+ config.runners,
278
+ variables,
279
+ resume,
280
+ )
281
+ let cli = resolved.argv[0]!
238
282
  let available = yield* fs.runCommand(["which", cli], {
239
283
  captureOutput: true,
240
284
  })
241
- if (available.exitCode !== 0 && !options.opencode && !options.claude) {
242
- cli = "claude"
285
+ if (
286
+ available.exitCode !== 0 &&
287
+ !explicitlyRequested &&
288
+ runner === "opencode"
289
+ ) {
290
+ runner = "claude"
291
+ resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
292
+ cli = resolved.argv[0]!
243
293
  available = yield* fs.runCommand(["which", cli], { captureOutput: true })
244
294
  }
245
295
  if (available.exitCode !== 0) {
@@ -251,36 +301,55 @@ export const work = (
251
301
  ) {
252
302
  const phaseId = target.kind === "phase" ? target.phaseId : undefined
253
303
  const current = yield* claims.inspect(target.taskId, phaseId, root)
254
- const sessionId =
255
- process.env.AGENCY_SESSION_ID ?? `${process.pid}-${Date.now()}`
256
304
  const acquired = yield* claims.claim(
257
305
  {
258
306
  taskId: target.taskId,
259
307
  ...(phaseId ? { phaseId } : {}),
260
- claimant: process.env.AGENCY_CLAIMANT ?? process.env.USER ?? "agency",
261
- runner: process.env.AGENCY_RUNNER ?? cli,
308
+ claimant,
309
+ runner,
262
310
  sessionId,
263
311
  revision: current.revision,
264
312
  },
265
313
  root,
266
314
  )
267
- process.env.AGENCY_SESSION_ID = sessionId
268
- process.env.AGENCY_CLAIM_REVISION = acquired.revision
315
+ claimRevision = acquired.revision
269
316
  }
270
317
 
271
- const args =
272
- cli === "opencode" ? ["--continue", "--prompt", prompt] : [prompt]
318
+ variables = { ...variables, claimRevision }
319
+ resolved = resolveRunnerCommand(runner, config.runners, variables, resume)
320
+ cli = resolved.argv[0]!
321
+ const environment = {
322
+ ...resolved.environment,
323
+ ...runnerEnvironment(runner, variables),
324
+ }
325
+ if (options.printCommand) {
326
+ log(
327
+ JSON.stringify(
328
+ {
329
+ cwd: launchPath,
330
+ argv: resolved.argv,
331
+ environment: printableEnvironment(environment),
332
+ },
333
+ null,
334
+ 2,
335
+ ),
336
+ )
337
+ return
338
+ }
339
+ for (const [key, value] of Object.entries(environment)) {
340
+ process.env[key] = value
341
+ }
273
342
  verboseLog(
274
- `Launching command: ${formatCommand([cli, ...args])} (cwd: ${launchPath})`,
343
+ `Launching command: ${formatCommand(resolved.argv)} (cwd: ${launchPath})`,
275
344
  )
276
345
  try {
277
- launch(cli, [cli, ...args], launchPath)
346
+ launch(cli, resolved.argv, launchPath, environment)
278
347
  } finally {
279
- if (previousSessionId === undefined) delete process.env.AGENCY_SESSION_ID
280
- else process.env.AGENCY_SESSION_ID = previousSessionId
281
- if (previousClaimRevision === undefined)
282
- delete process.env.AGENCY_CLAIM_REVISION
283
- else process.env.AGENCY_CLAIM_REVISION = previousClaimRevision
348
+ for (const key of Object.keys(environment)) {
349
+ const previous = previousEnvironment[key]
350
+ if (previous === undefined) delete process.env[key]
351
+ else process.env[key] = previous
352
+ }
284
353
  }
285
354
  })
286
355
 
@@ -344,7 +413,7 @@ export const workPrepare = (options: WorkOptions = {}) =>
344
413
  })
345
414
 
346
415
  export const help = `
347
- Usage: agency work [<directory-or-task-id> | --epic <epic-id>]
416
+ Usage: agency work [<directory-or-task-id> | --epic <epic-id>] [--runner <name>]
348
417
  agency work prepare [target] [--dry-run] [--json]
349
418
 
350
419
  Launch an agent for an epic, task, or phase. With no directory, select one
@@ -362,8 +431,10 @@ Options:
362
431
  --phase <id> Work on a phase selected with --task
363
432
  --workbase <target> Select a workbase by ID, name, or path
364
433
  --cwd <path> Resolve context from a specific directory
365
- --opencode Require OpenCode
366
- --claude Require Claude Code
434
+ --runner <name> Select a configured runner or built-in preset
435
+ --print-command Print cwd, argv, and non-secret environment without launch
436
+ --opencode Require the OpenCode preset
437
+ --claude Require the Claude Code preset
367
438
  --force Override readiness and terminal-state guards
368
439
  --no-input Never open an interactive selector
369
440
 
@@ -235,6 +235,25 @@ describe("WorkbaseService", () => {
235
235
  ).rejects.toThrow("{worktree}")
236
236
  })
237
237
 
238
+ test("rejects an unknown runner command placeholder", async () => {
239
+ await write(
240
+ root,
241
+ "agency.json",
242
+ JSON.stringify({
243
+ version: 2,
244
+ runners: { custom: { command: ["agent", "{unknown}"] } },
245
+ }),
246
+ )
247
+
248
+ await expect(
249
+ runTestEffect(
250
+ WorkbaseService.pipe(
251
+ Effect.flatMap((service) => service.discover(root)),
252
+ ),
253
+ ),
254
+ ).rejects.toThrow("{unknown}")
255
+ })
256
+
238
257
  test("validates a workbase with an epic and multi-phase task", async () => {
239
258
  await write(root, "agency.json", '{"version":2}\n')
240
259
  await mkdir(join(root, "repos/agency"), { recursive: true })
@@ -21,6 +21,7 @@ import {
21
21
  type WorkbaseRegistration,
22
22
  } from "../workbase/schemas"
23
23
  import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
24
+ import { validateRunners } from "../workbase/runner-command"
24
25
 
25
26
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
26
27
  readonly message: string
@@ -277,6 +278,17 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
277
278
  })
278
279
  }
279
280
  }
281
+ try {
282
+ validateRunners(decoded.value.runners)
283
+ } catch (cause) {
284
+ return yield* new WorkbaseConfigError({
285
+ path: configPath,
286
+ message:
287
+ cause instanceof Error
288
+ ? cause.message
289
+ : "Invalid runner configuration",
290
+ })
291
+ }
280
292
  return current
281
293
  }
282
294
  }
@@ -0,0 +1,19 @@
1
+ export const formatTable = (
2
+ headings: readonly string[],
3
+ rows: readonly (readonly string[])[],
4
+ ) => {
5
+ const widths = headings.map((heading, index) =>
6
+ Math.max(heading.length, ...rows.map((row) => row[index]?.length ?? 0)),
7
+ )
8
+ const line = (cells: readonly string[]) =>
9
+ cells
10
+ .map((cell, index) => cell.padEnd(widths[index] ?? cell.length))
11
+ .join(" ")
12
+ .trimEnd()
13
+
14
+ return [
15
+ line(headings),
16
+ line(widths.map((width) => "-".repeat(width))),
17
+ ...rows.map(line),
18
+ ].join("\n")
19
+ }
@@ -0,0 +1,151 @@
1
+ import { afterEach, beforeEach, 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 { getWorkViews } from "./work-view"
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
+ describe("work views", () => {
15
+ let root: string
16
+
17
+ beforeEach(async () => {
18
+ root = await createTempDir()
19
+ await write(root, "agency.json", '{"version":2}\n')
20
+ await mkdir(join(root, "repos/agency"), { recursive: true })
21
+ await write(
22
+ root,
23
+ "epics/delivery/EPIC.md",
24
+ `---
25
+ ticketUrl: https://example.com/delivery
26
+ repos:
27
+ - repo: agency
28
+ ref: main
29
+ tasks:
30
+ - id: zeta
31
+ - id: alpha
32
+ ---
33
+ `,
34
+ )
35
+ await write(
36
+ root,
37
+ "tasks/zeta/TASK.md",
38
+ `---
39
+ ticketUrl: null
40
+ epic: delivery
41
+ phases:
42
+ - id: verify
43
+ dependsOn: [implement]
44
+ - id: implement
45
+ ---
46
+ `,
47
+ )
48
+ await write(
49
+ root,
50
+ "tasks/zeta/phases/verify/PHASE.md",
51
+ `---
52
+ repo: agency
53
+ branch: feat/verify
54
+ base: main
55
+ pr: null
56
+ status: open
57
+ ---
58
+ `,
59
+ )
60
+ await write(
61
+ root,
62
+ "tasks/zeta/phases/implement/PHASE.md",
63
+ `---
64
+ repo: agency
65
+ branch: feat/implement
66
+ base: main
67
+ pr: https://github.com/example/agency/pull/1
68
+ status: dropped
69
+ ---
70
+ `,
71
+ )
72
+ await mkdir(join(root, "tasks/zeta/phases/implement/code/agency"), {
73
+ recursive: true,
74
+ })
75
+ await write(
76
+ root,
77
+ "tasks/alpha/TASK.md",
78
+ `---
79
+ ticketUrl: null
80
+ epic: delivery
81
+ repo: agency
82
+ branch: feat/alpha
83
+ base: main
84
+ pr: null
85
+ status: open
86
+ ---
87
+ `,
88
+ )
89
+ })
90
+
91
+ afterEach(async () => cleanupTempDir(root))
92
+
93
+ const views = (options: Record<string, unknown> = {}) =>
94
+ runTestEffect(Effect.suspend(() => getWorkViews({ cwd: root, ...options })))
95
+
96
+ test("uses declared graph order and exposes operational state", async () => {
97
+ const result = await views()
98
+
99
+ expect(result.taskRows.map((row) => row.id)).toEqual(["zeta", "alpha"])
100
+ expect(result.phaseRows.map((row) => row.id)).toEqual([
101
+ "verify",
102
+ "implement",
103
+ ])
104
+ expect(result.executionRows.map((row) => row.key)).toEqual([
105
+ "zeta/verify",
106
+ "zeta/implement",
107
+ "alpha",
108
+ ])
109
+ expect(result.phaseRows[0]).toMatchObject({
110
+ parent: "zeta",
111
+ status: "open",
112
+ readiness: "blocked",
113
+ repositories: "agency",
114
+ branch: "feat/verify",
115
+ pr: "absent",
116
+ worktree: "absent",
117
+ })
118
+ expect(result.phaseRows[1]).toMatchObject({
119
+ readiness: "terminal",
120
+ pr: "present",
121
+ worktree: "materialized",
122
+ })
123
+ expect(result.taskRows[0]).toMatchObject({
124
+ parent: "delivery",
125
+ branch: "multiple",
126
+ pr: "1/2 present",
127
+ worktree: "1/2 materialized",
128
+ })
129
+ })
130
+
131
+ test("composes lifecycle, repository, readiness, and PR filters", async () => {
132
+ expect(
133
+ (await views({ statuses: ["dropped"] })).executionRows.map(
134
+ (row) => row.key,
135
+ ),
136
+ ).toEqual(["zeta/implement"])
137
+ expect(
138
+ (await views({ repositories: ["agency"], blocked: true })).phaseRows.map(
139
+ (row) => row.id,
140
+ ),
141
+ ).toEqual(["verify"])
142
+ expect(
143
+ (await views({ ready: true, pr: false })).executionRows.map(
144
+ (row) => row.key,
145
+ ),
146
+ ).toEqual(["alpha"])
147
+ expect((await views({ pr: true })).phaseRows.map((row) => row.id)).toEqual([
148
+ "implement",
149
+ ])
150
+ })
151
+ })
@@ -0,0 +1,253 @@
1
+ import { Effect } from "effect"
2
+ import type { GraphNode } from "./graph-schema"
3
+ import { GraphService } from "./services/GraphService"
4
+ import type { WorkStatus } from "./workbase/schemas"
5
+
6
+ type WorkNode = Extract<
7
+ GraphNode,
8
+ { readonly kind: "epic" | "task" | "phase" | "execution-unit" }
9
+ >
10
+ type EntityNode = Exclude<WorkNode, { readonly kind: "execution-unit" }>
11
+ type ExecutionNode = Extract<WorkNode, { readonly kind: "execution-unit" }>
12
+
13
+ export interface WorkViewOptions {
14
+ readonly cwd?: string
15
+ readonly statuses?: readonly string[]
16
+ readonly repositories?: readonly string[]
17
+ readonly ready?: boolean
18
+ readonly blocked?: boolean
19
+ readonly pr?: boolean
20
+ }
21
+
22
+ export interface WorkViewRow {
23
+ readonly kind: "epic" | "task" | "phase"
24
+ readonly id: string
25
+ readonly key: string
26
+ readonly parent: string
27
+ readonly status: WorkStatus
28
+ readonly readiness: "ready" | "blocked" | "waiting" | "terminal"
29
+ readonly repositories: string
30
+ readonly branch: string
31
+ readonly pr: string
32
+ readonly worktree: string
33
+ readonly hasPr: boolean
34
+ }
35
+
36
+ const allowedStatuses = new Set<WorkStatus>([
37
+ "open",
38
+ "working",
39
+ "delegated",
40
+ "done",
41
+ "dropped",
42
+ ])
43
+
44
+ const validatedStatuses = (values: readonly string[] | undefined) =>
45
+ (values ?? []).map((value) => {
46
+ if (!allowedStatuses.has(value as WorkStatus)) {
47
+ throw new Error(
48
+ `Invalid --status value '${value}'. Expected one of: ${[...allowedStatuses].join(", ")}`,
49
+ )
50
+ }
51
+ return value as WorkStatus
52
+ })
53
+
54
+ const readinessLabel = (node: EntityNode): WorkViewRow["readiness"] =>
55
+ node.readiness.ready
56
+ ? "ready"
57
+ : node.readiness.terminal
58
+ ? "terminal"
59
+ : node.readiness.blocked
60
+ ? "blocked"
61
+ : "waiting"
62
+
63
+ const aggregateLabel = (
64
+ executions: readonly ExecutionNode[],
65
+ predicate: (node: ExecutionNode) => boolean,
66
+ singular: readonly [present: string, absent: string],
67
+ ) => {
68
+ if (executions.length === 0) return "-"
69
+ const count = executions.filter(predicate).length
70
+ if (executions.length === 1) return count === 1 ? singular[0] : singular[1]
71
+ return `${count}/${executions.length} ${singular[0]}`
72
+ }
73
+
74
+ const rowFor = (
75
+ node: EntityNode,
76
+ executions: readonly ExecutionNode[],
77
+ ): WorkViewRow => {
78
+ const branches = [
79
+ ...new Set(executions.map((execution) => execution.data.branch)),
80
+ ]
81
+ const parent =
82
+ node.kind === "task"
83
+ ? (node.data.epic ?? "-")
84
+ : node.kind === "phase"
85
+ ? node.key.slice(0, node.key.indexOf("/"))
86
+ : "-"
87
+ const id =
88
+ node.kind === "phase" ? node.key.slice(node.key.indexOf("/") + 1) : node.key
89
+
90
+ return {
91
+ kind: node.kind,
92
+ id,
93
+ key: node.key,
94
+ parent,
95
+ status: node.status,
96
+ readiness: readinessLabel(node),
97
+ repositories: node.repositories.join(",") || "-",
98
+ branch:
99
+ branches.length === 0
100
+ ? "-"
101
+ : branches.length === 1
102
+ ? branches[0]!
103
+ : "multiple",
104
+ pr: aggregateLabel(executions, (execution) => Boolean(execution.data.pr), [
105
+ "present",
106
+ "absent",
107
+ ]),
108
+ worktree: aggregateLabel(
109
+ executions,
110
+ (execution) => execution.workspace?.materialized === true,
111
+ ["materialized", "absent"],
112
+ ),
113
+ hasPr: executions.some((execution) => Boolean(execution.data.pr)),
114
+ }
115
+ }
116
+
117
+ const orderedTasks = (
118
+ epics: readonly Extract<EntityNode, { readonly kind: "epic" }>[],
119
+ tasks: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "task" }>>,
120
+ ) => {
121
+ const ordered: Extract<EntityNode, { readonly kind: "task" }>[] = []
122
+ const seen = new Set<string>()
123
+ for (const epic of epics) {
124
+ for (const item of epic.data.tasks) {
125
+ const task = tasks.get(item.id)
126
+ if (task && !seen.has(task.key)) {
127
+ ordered.push(task)
128
+ seen.add(task.key)
129
+ }
130
+ }
131
+ }
132
+ for (const task of [...tasks.values()].sort((a, b) =>
133
+ a.key.localeCompare(b.key),
134
+ )) {
135
+ if (!seen.has(task.key)) ordered.push(task)
136
+ }
137
+ return ordered
138
+ }
139
+
140
+ const orderedPhases = (
141
+ task: Extract<EntityNode, { readonly kind: "task" }>,
142
+ phases: ReadonlyMap<string, Extract<EntityNode, { readonly kind: "phase" }>>,
143
+ ) => {
144
+ const ordered: Extract<EntityNode, { readonly kind: "phase" }>[] = []
145
+ const seen = new Set<string>()
146
+ if ("phases" in task.data) {
147
+ for (const item of task.data.phases) {
148
+ const phase = phases.get(`${task.key}/${item.id}`)
149
+ if (phase) {
150
+ ordered.push(phase)
151
+ seen.add(phase.key)
152
+ }
153
+ }
154
+ }
155
+ for (const phase of [...phases.values()]
156
+ .filter((value) => value.key.startsWith(`${task.key}/`))
157
+ .sort((a, b) => a.key.localeCompare(b.key))) {
158
+ if (!seen.has(phase.key)) ordered.push(phase)
159
+ }
160
+ return ordered
161
+ }
162
+
163
+ export const getWorkViews = (options: WorkViewOptions = {}) =>
164
+ Effect.gen(function* () {
165
+ const graphService = yield* GraphService
166
+ const statuses = yield* Effect.sync(() =>
167
+ validatedStatuses(options.statuses),
168
+ )
169
+ const graph = yield* graphService.get({
170
+ cwd: options.cwd,
171
+ include: ["workspace"],
172
+ })
173
+ const workNodes = graph.nodes.filter(
174
+ (node): node is WorkNode => node.kind !== "repository",
175
+ )
176
+ const epics = workNodes
177
+ .filter(
178
+ (node): node is Extract<EntityNode, { readonly kind: "epic" }> =>
179
+ node.kind === "epic",
180
+ )
181
+ .sort((a, b) => a.key.localeCompare(b.key))
182
+ const tasks = new Map(
183
+ workNodes
184
+ .filter(
185
+ (node): node is Extract<EntityNode, { readonly kind: "task" }> =>
186
+ node.kind === "task",
187
+ )
188
+ .map((node) => [node.key, node]),
189
+ )
190
+ const phases = new Map(
191
+ workNodes
192
+ .filter(
193
+ (node): node is Extract<EntityNode, { readonly kind: "phase" }> =>
194
+ node.kind === "phase",
195
+ )
196
+ .map((node) => [node.key, node]),
197
+ )
198
+ const executions = workNodes.filter(
199
+ (node): node is ExecutionNode => node.kind === "execution-unit",
200
+ )
201
+ const taskOrder = orderedTasks(epics, tasks)
202
+ const phaseOrder = taskOrder.flatMap((task) => orderedPhases(task, phases))
203
+ const executionsFor = (node: EntityNode) => {
204
+ if (node.kind === "phase") {
205
+ const separator = node.key.indexOf("/")
206
+ const taskId = node.key.slice(0, separator)
207
+ const phaseId = node.key.slice(separator + 1)
208
+ return executions.filter(
209
+ (execution) =>
210
+ execution.data.taskId === taskId &&
211
+ execution.data.phaseId === phaseId,
212
+ )
213
+ }
214
+ if (node.kind === "task") {
215
+ return executions.filter(
216
+ (execution) => execution.data.taskId === node.key,
217
+ )
218
+ }
219
+ const taskIds = new Set(node.data.tasks.map((item) => item.id))
220
+ return executions.filter((execution) =>
221
+ taskIds.has(execution.data.taskId),
222
+ )
223
+ }
224
+ const makeRows = (nodes: readonly EntityNode[]) =>
225
+ nodes.map((node) => rowFor(node, executionsFor(node)))
226
+ const filterRows = (rows: readonly WorkViewRow[]) =>
227
+ rows
228
+ .filter((row) => statuses.length === 0 || statuses.includes(row.status))
229
+ .filter(
230
+ (row) =>
231
+ !options.repositories?.length ||
232
+ row.repositories
233
+ .split(",")
234
+ .some((repo) => options.repositories!.includes(repo)),
235
+ )
236
+ .filter((row) => options.ready !== true || row.readiness === "ready")
237
+ .filter(
238
+ (row) => options.blocked !== true || row.readiness === "blocked",
239
+ )
240
+ .filter((row) => options.pr === undefined || row.hasPr === options.pr)
241
+
242
+ const epicRows = filterRows(makeRows(epics))
243
+ const taskRows = filterRows(makeRows(taskOrder))
244
+ const phaseRows = filterRows(makeRows(phaseOrder))
245
+ const executionEntities: EntityNode[] = []
246
+ for (const task of taskOrder) {
247
+ const taskPhases = orderedPhases(task, phases)
248
+ executionEntities.push(...(taskPhases.length > 0 ? taskPhases : [task]))
249
+ }
250
+ const executionRows = filterRows(makeRows(executionEntities))
251
+
252
+ return { epicRows, taskRows, phaseRows, executionRows }
253
+ })