@markjaquith/agency 3.2.0 → 3.2.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "3.2.0",
3
+ "version": "3.2.1",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -282,6 +282,20 @@ status: dropped
282
282
  })
283
283
 
284
284
  test("reads each workbase document at most once per invocation", async () => {
285
+ const unrelatedArchive = join(root, "archive/tasks/unrelated/TASK.md")
286
+ await write(
287
+ root,
288
+ "archive/tasks/unrelated/TASK.md",
289
+ `---
290
+ ticketUrl: null
291
+ repo: agency
292
+ branch: unrelated
293
+ base: main
294
+ pr: null
295
+ status: done
296
+ ---
297
+ `,
298
+ )
285
299
  const reads = new Map<string, number>()
286
300
  const originalFile = Bun.file
287
301
  Bun.file = mock((path: string) => {
@@ -304,6 +318,48 @@ status: dropped
304
318
  )
305
319
  expect(documents.length).toBeGreaterThan(0)
306
320
  expect(documents.every(([, count]) => count === 1)).toBe(true)
321
+ expect(reads.has(unrelatedArchive)).toBe(false)
322
+ })
323
+
324
+ test("limits repository inspection and overlaps independent Git observations", async () => {
325
+ const commands: string[][] = []
326
+ let activeGitCommands = 0
327
+ let maxActiveGitCommands = 0
328
+ const originalSpawn = Bun.spawn
329
+ ;(Bun as { spawn: unknown }).spawn = mock(((
330
+ args: string[],
331
+ options: Parameters<typeof Bun.spawn>[1],
332
+ ) => {
333
+ const command = [...args]
334
+ const child = originalSpawn(command, options)
335
+ if (command[0] !== "git") return child
336
+ commands.push(command)
337
+ activeGitCommands += 1
338
+ maxActiveGitCommands = Math.max(maxActiveGitCommands, activeGitCommands)
339
+ const exited = child.exited.finally(() => {
340
+ activeGitCommands -= 1
341
+ })
342
+ return new Proxy(child, {
343
+ get(target, property) {
344
+ return property === "exited"
345
+ ? exited
346
+ : Reflect.get(target, property, target)
347
+ },
348
+ })
349
+ }) as unknown as typeof Bun.spawn)
350
+ try {
351
+ await readContext(root, "tasks/agent-contract/phases/context-command")
352
+ } finally {
353
+ ;(Bun as { spawn: unknown }).spawn = originalSpawn
354
+ }
355
+
356
+ expect(
357
+ commands.some(
358
+ (command) =>
359
+ command.includes("config") && command.includes("--show-origin"),
360
+ ),
361
+ ).toBe(false)
362
+ expect(maxActiveGitCommands).toBeGreaterThanOrEqual(3)
307
363
  })
308
364
 
309
365
  test("resolves a bare task ID and returns root discovery context", async () => {
@@ -271,78 +271,57 @@ export class ContextService extends Effect.Service<ContextService>()(
271
271
 
272
272
  if (relative(root, candidate) === "") {
273
273
  const compact = !options.full
274
- const discover = <S extends Schema.Schema.AnyNoContext>(
275
- id: string,
276
- path: string,
277
- schema: S,
274
+ const validation = yield* provideContextServices(
275
+ workbase.validate(root, { includeDocuments: true }),
276
+ )
277
+ const projectDiscoveryDocument = <T>(
278
+ document: {
279
+ readonly id: string
280
+ readonly path: string
281
+ readonly content: string
282
+ readonly revision: string
283
+ readonly data: T
284
+ },
278
285
  extra: Record<string, string> = {},
279
286
  ) =>
280
287
  Effect.gen(function* () {
281
- if (!(yield* fs.exists(path))) return null
282
- const content = yield* fs.readFile(path)
283
- const parsed = yield* Effect.either(
284
- parseFrontmatter(content, path),
285
- )
286
- if (Either.isLeft(parsed)) return null
287
- const decoded = decode(schema, parsed.right.data)
288
- if (!decoded.ok) return null
288
+ const body = compact
289
+ ? {}
290
+ : {
291
+ body: (yield* parseFrontmatter(
292
+ document.content,
293
+ document.path,
294
+ )).body,
295
+ }
289
296
  return {
290
297
  ...extra,
291
- id,
292
- path,
293
- sha256: documentRevision(content),
294
- data: decoded.value,
295
- ...(compact ? {} : { body: parsed.right.body }),
298
+ id: document.id,
299
+ path: document.path,
300
+ sha256: document.revision,
301
+ data: document.data,
302
+ ...body,
296
303
  }
297
304
  })
298
-
299
- const epics: unknown[] = []
300
- const tasks: unknown[] = []
301
- const phases: unknown[] = []
302
- const epicRoot = join(root, "epics")
303
- if (yield* fs.isDirectory(epicRoot)) {
304
- for (const entry of (yield* fs.readDirectory(epicRoot))
305
- .filter((item) => item.isDirectory)
306
- .sort((a, b) => a.name.localeCompare(b.name))) {
307
- const document = yield* discover(
308
- entry.name,
309
- join(epicRoot, entry.name, "EPIC.md"),
310
- EpicFrontmatter,
311
- )
312
- if (document) epics.push(document)
313
- }
314
- }
315
-
316
- const taskRoot = join(root, "tasks")
317
- if (yield* fs.isDirectory(taskRoot)) {
318
- for (const entry of (yield* fs.readDirectory(taskRoot))
319
- .filter((item) => item.isDirectory)
320
- .sort((a, b) => a.name.localeCompare(b.name))) {
321
- const document = yield* discover(
322
- entry.name,
323
- join(taskRoot, entry.name, "TASK.md"),
324
- TaskFrontmatter,
325
- )
326
- if (document) tasks.push(document)
327
-
328
- const phaseRoot = join(taskRoot, entry.name, "phases")
329
- if (!(yield* fs.isDirectory(phaseRoot))) continue
330
- for (const phaseEntry of (yield* fs.readDirectory(phaseRoot))
331
- .filter((item) => item.isDirectory)
332
- .sort((a, b) => a.name.localeCompare(b.name))) {
333
- const phase = yield* discover(
334
- phaseEntry.name,
335
- join(phaseRoot, phaseEntry.name, "PHASE.md"),
336
- PhaseFrontmatter,
337
- { taskId: entry.name },
338
- )
339
- if (phase) phases.push(phase)
340
- }
341
- }
342
- }
343
-
344
- const validation = yield* provideContextServices(
345
- workbase.validate(root),
305
+ const documents = validation.documents!
306
+ const epics = yield* Effect.all(
307
+ documents.epics.map((document) =>
308
+ projectDiscoveryDocument(document),
309
+ ),
310
+ { concurrency: "unbounded" },
311
+ )
312
+ const tasks = yield* Effect.all(
313
+ documents.tasks.map((document) =>
314
+ projectDiscoveryDocument(document),
315
+ ),
316
+ { concurrency: "unbounded" },
317
+ )
318
+ const phases = yield* Effect.all(
319
+ [...documents.phasesByTask].flatMap(([taskId, records]) =>
320
+ records.map((document) =>
321
+ projectDiscoveryDocument(document, { taskId }),
322
+ ),
323
+ ),
324
+ { concurrency: "unbounded" },
346
325
  )
347
326
  return {
348
327
  projection: compact ? "compact" : "complete",
@@ -556,104 +535,74 @@ export class ContextService extends Effect.Service<ContextService>()(
556
535
  })
557
536
  }
558
537
 
559
- const taskDocuments = new Map<string, Document<TaskData>>()
538
+ const validation = yield* provideContextServices(
539
+ workbase.validate(root, { includeDocuments: true }),
540
+ )
541
+ const validationDocuments = validation.documents!
542
+ const taskDocuments = new Map<string, Document<TaskData>>(
543
+ validationDocuments.tasks.map((document) => [
544
+ document.id,
545
+ {
546
+ id: document.id,
547
+ path: document.path,
548
+ sha256: document.revision,
549
+ data: document.data,
550
+ body: "",
551
+ },
552
+ ]),
553
+ )
560
554
  const phaseDocuments = new Map<string, Document<PhaseData>>()
561
- for (const taskRoot of [
562
- join(root, "tasks"),
563
- join(root, "archive", "tasks"),
564
- ]) {
565
- if (!(yield* fs.isDirectory(taskRoot))) continue
566
- const entries = (yield* fs.readDirectory(taskRoot))
567
- .filter((entry) => entry.isDirectory)
568
- .sort((a, b) => a.name.localeCompare(b.name))
569
- const documents = yield* Effect.all(
570
- entries.map((entry) =>
571
- Effect.gen(function* () {
572
- const path = join(taskRoot, entry.name, "TASK.md")
573
- let taskDocument: Document<TaskData> | null = null
574
- if (yield* fs.exists(path)) {
575
- const content = yield* fs.readFile(path)
576
- const parsed = yield* Effect.either(
577
- parseFrontmatter(content, path),
578
- )
579
- if (Either.isRight(parsed)) {
580
- const decoded = decode(TaskFrontmatter, parsed.right.data)
581
- if (decoded.ok) {
582
- taskDocument = {
583
- id: entry.name,
584
- path,
585
- sha256: documentRevision(content),
586
- data: decoded.value,
587
- body: parsed.right.body,
588
- }
589
- }
590
- }
591
- }
592
-
593
- const phasesPath = join(taskRoot, entry.name, "phases")
594
- if (!(yield* fs.isDirectory(phasesPath))) {
595
- return { taskDocument, phaseDocuments: [] }
596
- }
597
- const phaseEntries = (yield* fs.readDirectory(phasesPath))
598
- .filter((item) => item.isDirectory)
599
- .sort((a, b) => a.name.localeCompare(b.name))
600
- const childDocuments = yield* Effect.all(
601
- phaseEntries.map((phaseEntry) =>
602
- Effect.gen(function* () {
603
- const phasePath = join(
604
- phasesPath,
605
- phaseEntry.name,
606
- "PHASE.md",
607
- )
608
- if (!(yield* fs.exists(phasePath))) return null
609
- const content = yield* fs.readFile(phasePath)
610
- const parsed = yield* Effect.either(
611
- parseFrontmatter(content, phasePath),
612
- )
613
- if (Either.isLeft(parsed)) return null
614
- const decoded = decode(
615
- PhaseFrontmatter,
616
- parsed.right.data,
617
- )
618
- if (!decoded.ok) return null
619
- return [
620
- `${entry.name}/${phaseEntry.name}`,
621
- {
622
- id: phaseEntry.name,
623
- path: phasePath,
624
- sha256: documentRevision(content),
625
- data: decoded.value,
626
- body: parsed.right.body,
627
- },
628
- ] as const
629
- }),
630
- ),
631
- { concurrency: "unbounded" },
632
- )
633
- return {
634
- taskDocument,
635
- phaseDocuments: childDocuments.filter(
636
- (document): document is NonNullable<typeof document> =>
637
- document !== null,
638
- ),
639
- }
640
- }),
641
- ),
642
- { concurrency: "unbounded" },
643
- )
555
+ for (const [taskId, documents] of validationDocuments.phasesByTask) {
644
556
  for (const document of documents) {
645
- if (
646
- document.taskDocument &&
647
- !taskDocuments.has(document.taskDocument.id)
648
- ) {
649
- taskDocuments.set(
650
- document.taskDocument.id,
651
- document.taskDocument,
652
- )
653
- }
654
- for (const [key, phaseDocument] of document.phaseDocuments) {
655
- if (!phaseDocuments.has(key))
656
- phaseDocuments.set(key, phaseDocument)
557
+ phaseDocuments.set(`${taskId}/${document.id}`, {
558
+ id: document.id,
559
+ path: document.path,
560
+ sha256: document.revision,
561
+ data: document.data,
562
+ body: "",
563
+ })
564
+ }
565
+ }
566
+ if (task && !taskDocuments.has(task.id))
567
+ taskDocuments.set(task.id, task)
568
+ if (phase && target.taskId) {
569
+ phaseDocuments.set(`${target.taskId}/${phase.id}`, phase)
570
+ }
571
+ const relevantTaskIds = new Set([
572
+ ...(target.taskId ? [target.taskId] : []),
573
+ ...(epic?.data.tasks.map((child: Dependency) => child.id) ?? []),
574
+ ])
575
+ for (const taskId of relevantTaskIds) {
576
+ let document = taskDocuments.get(taskId)
577
+ if (!document) {
578
+ const archived = yield* Effect.either(
579
+ readOptionalDocument(
580
+ taskId,
581
+ join(archivedTaskDirectory(root, taskId), "TASK.md"),
582
+ TaskFrontmatter,
583
+ ),
584
+ )
585
+ document = Either.isRight(archived)
586
+ ? (archived.right ?? undefined)
587
+ : undefined
588
+ if (document) taskDocuments.set(taskId, document)
589
+ }
590
+ if (!document || !("phases" in document.data)) continue
591
+ for (const child of document.data.phases) {
592
+ const key = `${taskId}/${child.id}`
593
+ if (phaseDocuments.has(key)) continue
594
+ const archived = yield* Effect.either(
595
+ readOptionalDocument(
596
+ child.id,
597
+ join(
598
+ archivedPhaseDirectory(root, taskId, child.id),
599
+ "PHASE.md",
600
+ ),
601
+ PhaseFrontmatter,
602
+ ),
603
+ )
604
+ if (Either.isRight(archived) && archived.right) {
605
+ phaseDocuments.set(key, archived.right)
657
606
  }
658
607
  }
659
608
  }
@@ -720,9 +669,6 @@ export class ContextService extends Effect.Service<ContextService>()(
720
669
  })
721
670
  }
722
671
 
723
- const validation = yield* provideContextServices(
724
- workbase.validate(root),
725
- )
726
672
  const relevantPaths = new Set<string>(
727
673
  [epic?.path, task?.path, phase?.path]
728
674
  .filter((path): path is string => Boolean(path))
@@ -1017,9 +963,11 @@ export class ContextService extends Effect.Service<ContextService>()(
1017
963
  const codePath = join(entityDirectory, "code")
1018
964
  const inspectionWarnings: string[] = []
1019
965
  const repositories = new Map(
1020
- (yield* provideContextServices(repositoryService.list(root))).map(
1021
- (repository) => [repository.alias, repository],
1022
- ),
966
+ reviewData
967
+ ? (yield* provideContextServices(
968
+ repositoryService.list(root),
969
+ )).map((repository) => [repository.alias, repository])
970
+ : [],
1023
971
  )
1024
972
 
1025
973
  const inspectCheckout = (
@@ -1027,12 +975,18 @@ export class ContextService extends Effect.Service<ContextService>()(
1027
975
  checkoutPath: string,
1028
976
  ) =>
1029
977
  Effect.gen(function* (): Generator<any, CheckoutInspection, any> {
1030
- const materialized = yield* fs.isDirectory(checkoutPath)
1031
- const listed = yield* runGit(fs, repositoryPath, [
1032
- "worktree",
1033
- "list",
1034
- "--porcelain",
1035
- ])
978
+ const [materialized, listed, canonicalRoot] = yield* Effect.all(
979
+ [
980
+ fs.isDirectory(checkoutPath),
981
+ runGit(fs, repositoryPath, [
982
+ "worktree",
983
+ "list",
984
+ "--porcelain",
985
+ ]),
986
+ fs.realPath(root),
987
+ ],
988
+ { concurrency: "unbounded" },
989
+ )
1036
990
  if (listed === null) {
1037
991
  inspectionWarnings.push(
1038
992
  `Unable to inspect worktree registrations for ${repositoryPath}`,
@@ -1040,7 +994,7 @@ export class ContextService extends Effect.Service<ContextService>()(
1040
994
  }
1041
995
  const listedPaths = worktreePaths(listed)
1042
996
  const canonicalCheckoutPath = join(
1043
- yield* fs.realPath(root),
997
+ canonicalRoot,
1044
998
  relative(root, checkoutPath),
1045
999
  )
1046
1000
  let registered =
@@ -1056,21 +1010,25 @@ export class ContextService extends Effect.Service<ContextService>()(
1056
1010
  dirty: null,
1057
1011
  }
1058
1012
  }
1059
- const checkoutCommit = yield* runGit(fs, checkoutPath, [
1060
- "rev-parse",
1061
- "HEAD",
1062
- ])
1063
- const checkoutBranch = yield* runGit(fs, checkoutPath, [
1064
- "symbolic-ref",
1065
- "--quiet",
1066
- "--short",
1067
- "HEAD",
1068
- ])
1069
- const checkoutStatus = yield* runGitText(fs, checkoutPath, [
1070
- "status",
1071
- "--porcelain",
1072
- ])
1073
- const resolvedCheckoutPath = yield* fs.realPath(checkoutPath)
1013
+ const [
1014
+ checkoutCommit,
1015
+ checkoutBranch,
1016
+ checkoutStatus,
1017
+ resolvedCheckoutPath,
1018
+ ] = yield* Effect.all(
1019
+ [
1020
+ runGit(fs, checkoutPath, ["rev-parse", "HEAD"]),
1021
+ runGit(fs, checkoutPath, [
1022
+ "symbolic-ref",
1023
+ "--quiet",
1024
+ "--short",
1025
+ "HEAD",
1026
+ ]),
1027
+ runGitText(fs, checkoutPath, ["status", "--porcelain"]),
1028
+ fs.realPath(checkoutPath),
1029
+ ],
1030
+ { concurrency: "unbounded" },
1031
+ )
1074
1032
  registered = registered || listedPaths.has(resolvedCheckoutPath)
1075
1033
  if (checkoutCommit === null) {
1076
1034
  inspectionWarnings.push(
@@ -1094,17 +1052,16 @@ export class ContextService extends Effect.Service<ContextService>()(
1094
1052
  const repositoryPath =
1095
1053
  repository?.path ?? join(root, "repos", executionData.repo)
1096
1054
  const checkoutPath = join(codePath, executionData.repo)
1097
- const branchCommit = yield* backend.resolveRevision(
1098
- repositoryPath,
1099
- executionData.branch,
1100
- )
1101
- const baseCommit = yield* backend.resolveRevision(
1102
- repositoryPath,
1103
- executionData.base,
1104
- )
1105
- const checkout = yield* inspectCheckout(
1106
- repositoryPath,
1107
- checkoutPath,
1055
+ const [branchCommit, baseCommit, checkout] = yield* Effect.all(
1056
+ [
1057
+ backend.resolveRevision(
1058
+ repositoryPath,
1059
+ executionData.branch,
1060
+ ),
1061
+ backend.resolveRevision(repositoryPath, executionData.base),
1062
+ inspectCheckout(repositoryPath, checkoutPath),
1063
+ ],
1064
+ { concurrency: "unbounded" },
1108
1065
  )
1109
1066
  if (branchCommit === null) {
1110
1067
  inspectionWarnings.push(