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