@markjaquith/agency 2.9.0 → 2.11.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,886 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join, relative } from "node:path"
4
+ import {
5
+ GRAPH_VERSION,
6
+ type AgencyGraph,
7
+ type GraphBlocker,
8
+ type GraphEdge,
9
+ type GraphExecutionGit,
10
+ type GraphExecutionWorkspace,
11
+ type GraphInclude,
12
+ type GraphNode,
13
+ type GraphNodeKind,
14
+ type GraphPr,
15
+ type GraphProgress,
16
+ type GraphRepositoryGit,
17
+ } from "../graph-schema"
18
+ import { parseFrontmatter } from "../workbase/frontmatter"
19
+ import {
20
+ EpicFrontmatter,
21
+ PhaseFrontmatter,
22
+ TaskFrontmatter,
23
+ type Dependency,
24
+ type EpicFrontmatter as EpicData,
25
+ type PhaseFrontmatter as PhaseData,
26
+ type TaskFrontmatter as TaskData,
27
+ type WorkStatus,
28
+ } from "../workbase/schemas"
29
+ import { FileSystemService } from "./FileSystemService"
30
+ import { WorkbaseService } from "./WorkbaseService"
31
+
32
+ class GraphError extends Data.TaggedError("GraphError")<{
33
+ readonly message: string
34
+ readonly path?: string
35
+ }> {}
36
+
37
+ interface Document<T> {
38
+ readonly id: string
39
+ readonly path: string
40
+ readonly sha256: string
41
+ readonly data: T
42
+ readonly body: string
43
+ }
44
+
45
+ interface RepositoryRecord {
46
+ readonly alias: string
47
+ readonly path: string
48
+ readonly target: string | null
49
+ }
50
+
51
+ type ExecutionData = PhaseData | Extract<TaskData, { readonly repo: string }>
52
+
53
+ export interface GraphOptions {
54
+ readonly cwd?: string
55
+ readonly ready?: boolean
56
+ readonly blocked?: boolean
57
+ readonly statuses?: readonly WorkStatus[]
58
+ readonly repositories?: readonly string[]
59
+ readonly kinds?: readonly GraphNodeKind[]
60
+ readonly include?: readonly GraphInclude[]
61
+ }
62
+
63
+ const epicNodeId = (id: string) => `epic:${id}`
64
+ const taskNodeId = (id: string) => `task:${id}`
65
+ const phaseNodeId = (taskId: string, phaseId: string) =>
66
+ `phase:${taskId}/${phaseId}`
67
+ const repositoryNodeId = (alias: string) => `repository:${alias}`
68
+ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
69
+ const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
70
+ `execution-unit:phase/${taskId}/${phaseId}`
71
+
72
+ const progress = (statuses: readonly WorkStatus[]): GraphProgress => {
73
+ const counts = {
74
+ total: statuses.length,
75
+ open: statuses.filter((status) => status === "open").length,
76
+ working: statuses.filter((status) => status === "working").length,
77
+ delegated: statuses.filter((status) => status === "delegated").length,
78
+ done: statuses.filter((status) => status === "done").length,
79
+ dropped: statuses.filter((status) => status === "dropped").length,
80
+ terminal: statuses.filter(
81
+ (status) => status === "done" || status === "dropped",
82
+ ).length,
83
+ }
84
+ const status: WorkStatus =
85
+ statuses.length === 0
86
+ ? "open"
87
+ : statuses.every((value) => value === "done")
88
+ ? "done"
89
+ : statuses.every((value) => value === "done" || value === "dropped")
90
+ ? "dropped"
91
+ : statuses.includes("working")
92
+ ? "working"
93
+ : statuses.includes("delegated")
94
+ ? "delegated"
95
+ : "open"
96
+ return { status, ...counts }
97
+ }
98
+
99
+ const hash = (content: string) =>
100
+ new Bun.CryptoHasher("sha256").update(content).digest("hex")
101
+
102
+ const decode = <S extends Schema.Schema.AnyNoContext>(
103
+ schema: S,
104
+ input: unknown,
105
+ ) => {
106
+ const result = Schema.decodeUnknownEither(schema, {
107
+ errors: "all",
108
+ onExcessProperty: "error",
109
+ })(input)
110
+ return Either.isLeft(result)
111
+ ? { ok: false as const, error: TreeFormatter.formatErrorSync(result.left) }
112
+ : { ok: true as const, value: result.right }
113
+ }
114
+
115
+ const run = (fs: FileSystemService, args: readonly string[], cwd?: string) =>
116
+ fs.runCommand(args, { cwd, captureOutput: true }).pipe(
117
+ Effect.map((result) =>
118
+ result.exitCode === 0 ? result.stdout.trim() || null : null,
119
+ ),
120
+ Effect.catchAll(() => Effect.succeed(null)),
121
+ )
122
+
123
+ const runText = (
124
+ fs: FileSystemService,
125
+ args: readonly string[],
126
+ cwd?: string,
127
+ ) =>
128
+ fs.runCommand(args, { cwd, captureOutput: true }).pipe(
129
+ Effect.map((result) =>
130
+ result.exitCode === 0 ? result.stdout.trim() : null,
131
+ ),
132
+ Effect.catchAll(() => Effect.succeed(null)),
133
+ )
134
+
135
+ const edge = (
136
+ kind: GraphEdge["kind"],
137
+ from: string,
138
+ to: string,
139
+ ): GraphEdge => ({ id: `${kind}:${from}->${to}`, kind, from, to })
140
+
141
+ export class GraphService extends Effect.Service<GraphService>()(
142
+ "GraphService",
143
+ {
144
+ sync: () => ({
145
+ get: (options: GraphOptions = {}) =>
146
+ Effect.gen(function* () {
147
+ const fs = yield* FileSystemService
148
+ const workbase = yield* WorkbaseService
149
+ const { root, config } = yield* workbase.loadConfig(options.cwd)
150
+ const includes = [...new Set(options.include ?? [])].sort()
151
+ const include = new Set(includes)
152
+ const epics = new Map<string, Document<EpicData>>()
153
+ const tasks = new Map<string, Document<TaskData>>()
154
+ const phases = new Map<string, Document<PhaseData>>()
155
+
156
+ const directories = (path: string) =>
157
+ Effect.gen(function* () {
158
+ if (!(yield* fs.isDirectory(path))) return []
159
+ return (yield* fs.readDirectory(path))
160
+ .filter((entry) => entry.isDirectory)
161
+ .map((entry) => entry.name)
162
+ .sort()
163
+ })
164
+
165
+ const readDocument = <S extends Schema.Schema.AnyNoContext>(
166
+ id: string,
167
+ path: string,
168
+ schema: S,
169
+ ) =>
170
+ Effect.gen(function* () {
171
+ const content = yield* fs.readFile(path)
172
+ const parsed = yield* parseFrontmatter(content, path).pipe(
173
+ Effect.mapError(
174
+ (error) => new GraphError({ path, message: error.message }),
175
+ ),
176
+ )
177
+ const decoded = decode(schema, parsed.data)
178
+ if (!decoded.ok) {
179
+ return yield* new GraphError({
180
+ path,
181
+ message: `Invalid graph document ${path}:\n${decoded.error}`,
182
+ })
183
+ }
184
+ return {
185
+ id,
186
+ path,
187
+ sha256: hash(content),
188
+ data: decoded.value,
189
+ body: parsed.body,
190
+ } satisfies Document<Schema.Schema.Type<S>>
191
+ })
192
+
193
+ for (const id of yield* directories(join(root, "epics"))) {
194
+ const path = join(root, "epics", id, "EPIC.md")
195
+ if (yield* fs.exists(path)) {
196
+ epics.set(id, yield* readDocument(id, path, EpicFrontmatter))
197
+ }
198
+ }
199
+ for (const id of yield* directories(join(root, "tasks"))) {
200
+ const path = join(root, "tasks", id, "TASK.md")
201
+ if (yield* fs.exists(path)) {
202
+ tasks.set(id, yield* readDocument(id, path, TaskFrontmatter))
203
+ }
204
+ for (const phaseId of yield* directories(
205
+ join(root, "tasks", id, "phases"),
206
+ )) {
207
+ const phasePath = join(
208
+ root,
209
+ "tasks",
210
+ id,
211
+ "phases",
212
+ phaseId,
213
+ "PHASE.md",
214
+ )
215
+ if (yield* fs.exists(phasePath)) {
216
+ phases.set(
217
+ `${id}/${phaseId}`,
218
+ yield* readDocument(phaseId, phasePath, PhaseFrontmatter),
219
+ )
220
+ }
221
+ }
222
+ }
223
+
224
+ const repositoryRecords: RepositoryRecord[] = []
225
+ const reposPath = join(root, "repos")
226
+ if (yield* fs.isDirectory(reposPath)) {
227
+ for (const entry of (yield* fs.readDirectory(reposPath))
228
+ .filter((item) => item.isDirectory || item.isSymlink)
229
+ .sort((a, b) => a.name.localeCompare(b.name))) {
230
+ repositoryRecords.push({
231
+ alias: entry.name,
232
+ path: join(reposPath, entry.name),
233
+ target: entry.isSymlink
234
+ ? yield* fs.readSymlinkTarget(join(reposPath, entry.name))
235
+ : null,
236
+ })
237
+ }
238
+ }
239
+
240
+ const taskDeclarations = new Map<string, Dependency>()
241
+ for (const epic of epics.values()) {
242
+ for (const declaration of epic.data.tasks) {
243
+ taskDeclarations.set(declaration.id, declaration)
244
+ }
245
+ }
246
+ const taskDependencies = (taskId: string) =>
247
+ taskDeclarations.get(taskId)?.dependsOn ?? []
248
+ const taskLeafStatuses = (taskId: string): WorkStatus[] => {
249
+ const task = tasks.get(taskId)
250
+ if (!task) return []
251
+ return "phases" in task.data
252
+ ? task.data.phases.map(
253
+ (item) =>
254
+ phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
255
+ )
256
+ : [task.data.status]
257
+ }
258
+ const taskStatus = (taskId: string) =>
259
+ progress(taskLeafStatuses(taskId)).status
260
+ const dependencyBlockers = (
261
+ dependencies: readonly string[],
262
+ toId: (id: string) => string,
263
+ status: (id: string) => WorkStatus | undefined,
264
+ label: string,
265
+ ): GraphBlocker[] =>
266
+ dependencies.flatMap((dependency) => {
267
+ const value = status(dependency)
268
+ return value === "done"
269
+ ? []
270
+ : [
271
+ {
272
+ kind: "dependency" as const,
273
+ id: toId(dependency),
274
+ ...(value ? { status: value } : {}),
275
+ reason: value
276
+ ? `${label} dependency is ${value}`
277
+ : `${label} dependency is missing`,
278
+ },
279
+ ]
280
+ })
281
+
282
+ const validation = yield* workbase.validate(root)
283
+ const validationBlockers = (
284
+ paths: readonly string[],
285
+ ): GraphBlocker[] =>
286
+ validation.issues
287
+ .filter((issue) => paths.includes(issue.path))
288
+ .map((issue) => ({
289
+ kind: "validation" as const,
290
+ id: issue.path,
291
+ reason: issue.message,
292
+ }))
293
+ const uniqueBlockers = (blockers: readonly GraphBlocker[]) => [
294
+ ...new Map(
295
+ blockers.map((item) => [
296
+ `${item.kind}:${item.id}:${item.reason}`,
297
+ item,
298
+ ]),
299
+ ).values(),
300
+ ]
301
+
302
+ const phaseState = (taskId: string, phaseId: string) => {
303
+ const task = tasks.get(taskId)
304
+ const phase = phases.get(`${taskId}/${phaseId}`)
305
+ const declaration =
306
+ task && "phases" in task.data
307
+ ? task.data.phases.find((item) => item.id === phaseId)
308
+ : undefined
309
+ const blockers = [
310
+ ...dependencyBlockers(
311
+ declaration?.dependsOn ?? [],
312
+ (id) => phaseNodeId(taskId, id),
313
+ (id) => phases.get(`${taskId}/${id}`)?.data.status,
314
+ "Phase",
315
+ ),
316
+ ...dependencyBlockers(
317
+ taskDependencies(taskId),
318
+ taskNodeId,
319
+ (id) => (tasks.has(id) ? taskStatus(id) : undefined),
320
+ "Parent task",
321
+ ),
322
+ ...validationBlockers(
323
+ [phase?.path, task?.path]
324
+ .filter((path): path is string => Boolean(path))
325
+ .map((path) => relative(root, path)),
326
+ ),
327
+ ]
328
+ const status = phase?.data.status ?? "open"
329
+ if (status !== "open") {
330
+ blockers.push({
331
+ kind: "status",
332
+ id: phaseNodeId(taskId, phaseId),
333
+ status,
334
+ reason: `Phase status is ${status}`,
335
+ })
336
+ }
337
+ return {
338
+ status,
339
+ aggregate: progress([status]),
340
+ readiness: {
341
+ ready: status === "open" && blockers.length === 0,
342
+ blocked: status === "open" && blockers.length > 0,
343
+ blockers: uniqueBlockers(blockers),
344
+ },
345
+ }
346
+ }
347
+
348
+ const taskState = (taskId: string) => {
349
+ const task = tasks.get(taskId)
350
+ const statuses = taskLeafStatuses(taskId)
351
+ const aggregate = progress(statuses)
352
+ const descendantPaths = task
353
+ ? [
354
+ relative(root, task.path),
355
+ ...[...phases.entries()]
356
+ .filter(([key]) => key.startsWith(`${taskId}/`))
357
+ .map(([, phase]) => relative(root, phase.path)),
358
+ ]
359
+ : []
360
+ const blockers = [
361
+ ...dependencyBlockers(
362
+ taskDependencies(taskId),
363
+ taskNodeId,
364
+ (id) => (tasks.has(id) ? taskStatus(id) : undefined),
365
+ "Task",
366
+ ),
367
+ ...validationBlockers(descendantPaths),
368
+ ]
369
+ let ready = false
370
+ if (task && "phases" in task.data) {
371
+ ready =
372
+ blockers.every((item) => item.kind !== "validation") &&
373
+ taskDependencies(taskId).every(
374
+ (id) => taskStatus(id) === "done",
375
+ ) &&
376
+ task.data.phases.some(
377
+ (item) => phaseState(taskId, item.id).readiness.ready,
378
+ )
379
+ for (const item of task.data.phases) {
380
+ const state = phaseState(taskId, item.id)
381
+ blockers.push(
382
+ ...state.readiness.blockers.filter(
383
+ (blocker) =>
384
+ blocker.kind === "dependency" ||
385
+ blocker.kind === "validation",
386
+ ),
387
+ )
388
+ if (state.status === "dropped") {
389
+ blockers.push({
390
+ kind: "status",
391
+ id: phaseNodeId(taskId, item.id),
392
+ status: "dropped",
393
+ reason: "Child phase is dropped",
394
+ })
395
+ }
396
+ }
397
+ } else if (task && "status" in task.data) {
398
+ ready = task.data.status === "open" && blockers.length === 0
399
+ if (task.data.status !== "open") {
400
+ blockers.push({
401
+ kind: "status",
402
+ id: taskNodeId(taskId),
403
+ status: task.data.status,
404
+ reason: `Task status is ${task.data.status}`,
405
+ })
406
+ }
407
+ }
408
+ const taskBlockers = uniqueBlockers(blockers)
409
+ return {
410
+ status: aggregate.status,
411
+ aggregate,
412
+ readiness: {
413
+ ready,
414
+ blocked: !ready && taskBlockers.length > 0,
415
+ blockers: taskBlockers,
416
+ },
417
+ }
418
+ }
419
+
420
+ const epicState = (epicId: string) => {
421
+ const epic = epics.get(epicId)
422
+ const statuses =
423
+ epic?.data.tasks.flatMap((item) => taskLeafStatuses(item.id)) ??
424
+ []
425
+ const aggregate = progress(statuses)
426
+ const paths = epic
427
+ ? [
428
+ relative(root, epic.path),
429
+ ...epic.data.tasks.flatMap((item) => {
430
+ const task = tasks.get(item.id)
431
+ return [
432
+ ...(task ? [relative(root, task.path)] : []),
433
+ ...[...phases.entries()]
434
+ .filter(([key]) => key.startsWith(`${item.id}/`))
435
+ .map(([, phase]) => relative(root, phase.path)),
436
+ ]
437
+ }),
438
+ ]
439
+ : []
440
+ const blockers = validationBlockers(paths)
441
+ for (const item of epic?.data.tasks ?? []) {
442
+ const childState = taskState(item.id)
443
+ blockers.push(
444
+ ...childState.readiness.blockers.filter(
445
+ (blocker) =>
446
+ blocker.kind !== "status" || blocker.status === "dropped",
447
+ ),
448
+ )
449
+ if (taskStatus(item.id) === "dropped") {
450
+ blockers.push({
451
+ kind: "status",
452
+ id: taskNodeId(item.id),
453
+ status: "dropped",
454
+ reason: "Child task is dropped",
455
+ })
456
+ }
457
+ }
458
+ const ready =
459
+ !blockers.some((item) => item.kind === "validation") &&
460
+ Boolean(
461
+ epic?.data.tasks.some(
462
+ (item) => taskState(item.id).readiness.ready,
463
+ ),
464
+ )
465
+ const epicBlockers = uniqueBlockers(blockers)
466
+ return {
467
+ status: aggregate.status,
468
+ aggregate,
469
+ readiness: {
470
+ ready,
471
+ blocked: !ready && epicBlockers.length > 0,
472
+ blockers: epicBlockers,
473
+ },
474
+ }
475
+ }
476
+
477
+ const edges: GraphEdge[] = []
478
+ for (const epic of epics.values()) {
479
+ for (const item of epic.data.tasks) {
480
+ edges.push(edge("owns", epicNodeId(epic.id), taskNodeId(item.id)))
481
+ for (const dependency of item.dependsOn ?? []) {
482
+ edges.push(
483
+ edge(
484
+ "depends_on",
485
+ taskNodeId(item.id),
486
+ taskNodeId(dependency),
487
+ ),
488
+ )
489
+ }
490
+ }
491
+ }
492
+ for (const task of tasks.values()) {
493
+ if ("phases" in task.data) {
494
+ for (const item of task.data.phases) {
495
+ edges.push(
496
+ edge(
497
+ "owns",
498
+ taskNodeId(task.id),
499
+ phaseNodeId(task.id, item.id),
500
+ ),
501
+ )
502
+ for (const dependency of item.dependsOn ?? []) {
503
+ edges.push(
504
+ edge(
505
+ "depends_on",
506
+ phaseNodeId(task.id, item.id),
507
+ phaseNodeId(task.id, dependency),
508
+ ),
509
+ )
510
+ }
511
+ }
512
+ } else {
513
+ edges.push(
514
+ edge("owns", taskNodeId(task.id), taskExecutionNodeId(task.id)),
515
+ )
516
+ }
517
+ }
518
+ for (const [key, phase] of phases) {
519
+ const taskId = key.slice(0, key.indexOf("/"))
520
+ edges.push(
521
+ edge(
522
+ "owns",
523
+ phaseNodeId(taskId, phase.id),
524
+ phaseExecutionNodeId(taskId, phase.id),
525
+ ),
526
+ )
527
+ }
528
+
529
+ const executionRepositories = (
530
+ data: TaskData | PhaseData,
531
+ ): readonly string[] =>
532
+ "repo" in data
533
+ ? [data.repo, ...(data.repos ?? []).map((item) => item.repo)]
534
+ : []
535
+ const executionEdges = (id: string, data: TaskData | PhaseData) => {
536
+ if (!("repo" in data)) return
537
+ edges.push(edge("writes", id, repositoryNodeId(data.repo)))
538
+ for (const reference of data.repos ?? []) {
539
+ edges.push(
540
+ edge("references", id, repositoryNodeId(reference.repo)),
541
+ )
542
+ }
543
+ }
544
+ for (const epic of epics.values()) {
545
+ for (const reference of epic.data.repos) {
546
+ edges.push(
547
+ edge(
548
+ "references",
549
+ epicNodeId(epic.id),
550
+ repositoryNodeId(reference.repo),
551
+ ),
552
+ )
553
+ }
554
+ }
555
+ for (const task of tasks.values()) {
556
+ if (!("phases" in task.data)) {
557
+ executionEdges(taskExecutionNodeId(task.id), task.data)
558
+ }
559
+ }
560
+ for (const [key, phase] of phases) {
561
+ const taskId = key.slice(0, key.indexOf("/"))
562
+ executionEdges(phaseExecutionNodeId(taskId, phase.id), phase.data)
563
+ }
564
+
565
+ const reverseDependencies = new Map<string, string[]>()
566
+ for (const item of edges.filter(
567
+ (value) => value.kind === "depends_on",
568
+ )) {
569
+ const dependents = reverseDependencies.get(item.to) ?? []
570
+ dependents.push(item.from)
571
+ reverseDependencies.set(item.to, dependents)
572
+ }
573
+ const dependents = (id: string) =>
574
+ [...(reverseDependencies.get(id) ?? [])].sort()
575
+
576
+ const documentDetails = <T extends Record<string, unknown>>(
577
+ document: Document<T>,
578
+ ) => ({
579
+ data: { ...document.data, sha256: document.sha256 },
580
+ ...(include.has("bodies") ? { body: document.body } : {}),
581
+ ...(include.has("workspace")
582
+ ? {
583
+ workspace: {
584
+ documentPath: document.path,
585
+ directory: document.path.replace(
586
+ /\/(?:EPIC|TASK|PHASE)\.md$/,
587
+ "",
588
+ ),
589
+ },
590
+ }
591
+ : {}),
592
+ })
593
+
594
+ const inspectGit = (path: string) =>
595
+ Effect.gen(function* () {
596
+ const bare = yield* run(fs, [
597
+ "git",
598
+ "-C",
599
+ path,
600
+ "rev-parse",
601
+ "--is-bare-repository",
602
+ ])
603
+ return {
604
+ kind:
605
+ bare === null
606
+ ? null
607
+ : bare === "true"
608
+ ? "bare"
609
+ : "repository",
610
+ remote: yield* run(fs, [
611
+ "git",
612
+ "-C",
613
+ path,
614
+ "remote",
615
+ "get-url",
616
+ "origin",
617
+ ]),
618
+ head: yield* run(fs, ["git", "-C", path, "rev-parse", "HEAD"]),
619
+ branch: yield* run(fs, [
620
+ "git",
621
+ "-C",
622
+ path,
623
+ "symbolic-ref",
624
+ "--quiet",
625
+ "--short",
626
+ "HEAD",
627
+ ]),
628
+ } satisfies GraphRepositoryGit
629
+ })
630
+
631
+ const executionDetails = (entityPath: string, data: ExecutionData) =>
632
+ Effect.gen(function* () {
633
+ const directory = entityPath.replace(/\/(?:TASK|PHASE)\.md$/, "")
634
+ const checkoutPath = join(directory, "code", data.repo)
635
+ const repositoryPath = join(root, "repos", data.repo)
636
+ const materialized = yield* fs.isDirectory(checkoutPath)
637
+ const result: {
638
+ workspace?: GraphExecutionWorkspace
639
+ git?: GraphExecutionGit
640
+ pr?: GraphPr
641
+ } = {}
642
+ if (include.has("workspace")) {
643
+ result.workspace = {
644
+ codePath: join(directory, "code"),
645
+ checkoutPath,
646
+ materialized,
647
+ }
648
+ }
649
+ if (include.has("git")) {
650
+ result.git = {
651
+ branch: data.branch,
652
+ base: data.base,
653
+ branchCommit: yield* run(fs, [
654
+ "git",
655
+ "-C",
656
+ repositoryPath,
657
+ "rev-parse",
658
+ `${data.branch}^{commit}`,
659
+ ]),
660
+ baseCommit: yield* run(fs, [
661
+ "git",
662
+ "-C",
663
+ repositoryPath,
664
+ "rev-parse",
665
+ `${data.base}^{commit}`,
666
+ ]),
667
+ checkoutCommit: materialized
668
+ ? yield* run(fs, [
669
+ "git",
670
+ "-C",
671
+ checkoutPath,
672
+ "rev-parse",
673
+ "HEAD",
674
+ ])
675
+ : null,
676
+ checkoutBranch: materialized
677
+ ? yield* run(fs, [
678
+ "git",
679
+ "-C",
680
+ checkoutPath,
681
+ "symbolic-ref",
682
+ "--quiet",
683
+ "--short",
684
+ "HEAD",
685
+ ])
686
+ : null,
687
+ dirty: materialized
688
+ ? ((status) =>
689
+ status === null ? null : status.length > 0)(
690
+ yield* runText(fs, [
691
+ "git",
692
+ "-C",
693
+ checkoutPath,
694
+ "status",
695
+ "--porcelain",
696
+ ]),
697
+ )
698
+ : null,
699
+ }
700
+ }
701
+ if (include.has("pr")) {
702
+ if (!data.pr) {
703
+ result.pr = { url: null, state: "none" }
704
+ } else {
705
+ const detail = yield* run(fs, [
706
+ "gh",
707
+ "pr",
708
+ "view",
709
+ data.pr,
710
+ "--json",
711
+ "number,state,title,isDraft,headRefName,baseRefName,url",
712
+ ])
713
+ result.pr = detail
714
+ ? { ...JSON.parse(detail), recordedUrl: data.pr }
715
+ : { url: data.pr, state: "unavailable" }
716
+ }
717
+ }
718
+ return result
719
+ })
720
+
721
+ const nodes: GraphNode[] = []
722
+ for (const epic of epics.values()) {
723
+ const state = epicState(epic.id)
724
+ nodes.push({
725
+ id: epicNodeId(epic.id),
726
+ kind: "epic",
727
+ key: epic.id,
728
+ ...state,
729
+ dependents: dependents(epicNodeId(epic.id)),
730
+ repositories: epic.data.repos.map((item) => item.repo).sort(),
731
+ ...documentDetails(epic),
732
+ })
733
+ }
734
+ for (const task of tasks.values()) {
735
+ const state = taskState(task.id)
736
+ const repositories =
737
+ "phases" in task.data
738
+ ? [
739
+ ...new Set(
740
+ task.data.phases.flatMap((item) => {
741
+ const phase = phases.get(`${task.id}/${item.id}`)
742
+ return phase ? executionRepositories(phase.data) : []
743
+ }),
744
+ ),
745
+ ].sort()
746
+ : [...executionRepositories(task.data)].sort()
747
+ nodes.push({
748
+ id: taskNodeId(task.id),
749
+ kind: "task",
750
+ key: task.id,
751
+ ...state,
752
+ dependents: dependents(taskNodeId(task.id)),
753
+ repositories,
754
+ ...documentDetails(task),
755
+ })
756
+ if (!("phases" in task.data)) {
757
+ nodes.push({
758
+ id: taskExecutionNodeId(task.id),
759
+ kind: "execution-unit",
760
+ key: `task/${task.id}`,
761
+ ...state,
762
+ dependents: dependents(taskNodeId(task.id)),
763
+ repositories,
764
+ data: { taskId: task.id, ...task.data },
765
+ ...(yield* executionDetails(task.path, task.data)),
766
+ })
767
+ }
768
+ }
769
+ for (const [key, phase] of phases) {
770
+ const taskId = key.slice(0, key.indexOf("/"))
771
+ const state = phaseState(taskId, phase.id)
772
+ const repositories = [...executionRepositories(phase.data)].sort()
773
+ nodes.push({
774
+ id: phaseNodeId(taskId, phase.id),
775
+ kind: "phase",
776
+ key,
777
+ ...state,
778
+ dependents: dependents(phaseNodeId(taskId, phase.id)),
779
+ repositories,
780
+ ...documentDetails(phase),
781
+ })
782
+ nodes.push({
783
+ id: phaseExecutionNodeId(taskId, phase.id),
784
+ kind: "execution-unit",
785
+ key: `phase/${key}`,
786
+ ...state,
787
+ dependents: dependents(phaseNodeId(taskId, phase.id)),
788
+ repositories,
789
+ data: { taskId, phaseId: phase.id, ...phase.data },
790
+ ...(yield* executionDetails(phase.path, phase.data)),
791
+ })
792
+ }
793
+ for (const repository of repositoryRecords) {
794
+ nodes.push({
795
+ id: repositoryNodeId(repository.alias),
796
+ kind: "repository",
797
+ key: repository.alias,
798
+ status: null,
799
+ readiness: null,
800
+ aggregate: null,
801
+ dependents: [],
802
+ repositories: [repository.alias],
803
+ data: { alias: repository.alias },
804
+ ...(include.has("workspace")
805
+ ? {
806
+ workspace: {
807
+ path: repository.path,
808
+ target: repository.target,
809
+ },
810
+ }
811
+ : {}),
812
+ ...(include.has("git")
813
+ ? { git: yield* inspectGit(repository.path) }
814
+ : {}),
815
+ })
816
+ }
817
+
818
+ const filters = {
819
+ ready: options.ready ?? null,
820
+ blocked: options.blocked ?? null,
821
+ statuses: [...(options.statuses ?? [])].sort(),
822
+ repositories: [...(options.repositories ?? [])].sort(),
823
+ kinds: [...(options.kinds ?? [])].sort(),
824
+ }
825
+ const filteredNodes = nodes
826
+ .filter(
827
+ (node) =>
828
+ options.ready === undefined ||
829
+ node.readiness?.ready === options.ready,
830
+ )
831
+ .filter(
832
+ (node) =>
833
+ options.blocked === undefined ||
834
+ node.readiness?.blocked === options.blocked,
835
+ )
836
+ .filter(
837
+ (node) =>
838
+ !options.statuses?.length ||
839
+ (node.status !== null &&
840
+ options.statuses.includes(node.status)),
841
+ )
842
+ .filter(
843
+ (node) =>
844
+ !options.repositories?.length ||
845
+ node.repositories.some((alias) =>
846
+ options.repositories!.includes(alias),
847
+ ),
848
+ )
849
+ .filter(
850
+ (node) =>
851
+ !options.kinds?.length || options.kinds.includes(node.kind),
852
+ )
853
+ .sort((a, b) => a.id.localeCompare(b.id))
854
+ const nodeIds = new Set(filteredNodes.map((node) => node.id))
855
+ const filteredEdges = edges
856
+ .filter((item) => nodeIds.has(item.from) && nodeIds.has(item.to))
857
+ .sort((a, b) => a.id.localeCompare(b.id))
858
+ const leafStatuses = [
859
+ ...tasks
860
+ .values()
861
+ .flatMap((task) =>
862
+ "phases" in task.data ? [] : [task.data.status],
863
+ ),
864
+ ...phases.values().map((phase) => phase.data.status),
865
+ ]
866
+
867
+ return {
868
+ version: GRAPH_VERSION,
869
+ workbase: {
870
+ version: config.version,
871
+ ...(include.has("workspace") ? { root } : {}),
872
+ },
873
+ filters,
874
+ includes,
875
+ nodes: filteredNodes,
876
+ edges: filteredEdges,
877
+ summary: progress(leafStatuses),
878
+ validation: {
879
+ valid: validation.valid,
880
+ issues: validation.issues,
881
+ },
882
+ } satisfies AgencyGraph
883
+ }),
884
+ }),
885
+ },
886
+ ) {}