@markjaquith/agency 2.71.10 → 2.71.11

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": "2.71.10",
3
+ "version": "2.71.11",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -73,6 +73,7 @@
73
73
  "benchmark:push": "bun scripts/benchmark-push.ts",
74
74
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
75
75
  "benchmark:task": "bun scripts/benchmark-task.ts",
76
+ "benchmark:validate": "bun scripts/benchmark-validate.ts",
76
77
  "benchmark:worktree": "bun scripts/benchmark-worktree.ts",
77
78
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
78
79
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
@@ -3,6 +3,7 @@ import { Effect } from "effect"
3
3
  import { mkdir, realpath, rm, symlink } from "node:fs/promises"
4
4
  import { dirname, join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
+ import { FileSystemService } from "./FileSystemService"
6
7
  import { WorkbaseService } from "./WorkbaseService"
7
8
 
8
9
  const write = async (root: string, path: string, content: string) => {
@@ -104,6 +105,65 @@ pr: null
104
105
  expect(report.issues).toEqual([])
105
106
  })
106
107
 
108
+ test("reads configuration and documents once during validation", async () => {
109
+ await write(
110
+ root,
111
+ "agency.json",
112
+ JSON.stringify({
113
+ version: 2,
114
+ repositories: {
115
+ agency: { remote: "https://example.com/agency.git" },
116
+ },
117
+ }),
118
+ )
119
+ await write(
120
+ root,
121
+ "tasks/example/TASK.md",
122
+ `---
123
+ ticketUrl: null
124
+ repo: agency
125
+ branch: task/example
126
+ base: main
127
+ pr: null
128
+ ---
129
+ `,
130
+ )
131
+
132
+ const fs = await Effect.runPromise(
133
+ FileSystemService.pipe(Effect.provide(FileSystemService.Default)),
134
+ )
135
+ const reads: string[] = []
136
+ const exists: string[] = []
137
+ const trackedFs = {
138
+ ...fs,
139
+ readFile: (path: string) => {
140
+ reads.push(path)
141
+ return fs.readFile(path)
142
+ },
143
+ exists: (path: string) => {
144
+ exists.push(path)
145
+ return fs.exists(path)
146
+ },
147
+ }
148
+
149
+ const report = await Effect.runPromise(
150
+ WorkbaseService.pipe(
151
+ Effect.flatMap((service) => service.validate(root)),
152
+ Effect.provide(WorkbaseService.Default),
153
+ Effect.provideService(FileSystemService, trackedFs),
154
+ ) as Effect.Effect<unknown, unknown, never>,
155
+ )
156
+
157
+ expect(report).toMatchObject({ valid: true, taskCount: 1 })
158
+ expect(
159
+ reads.filter((path) => path === join(root, "agency.json")),
160
+ ).toHaveLength(2)
161
+ expect(
162
+ reads.filter((path) => path === join(root, "tasks/example/TASK.md")),
163
+ ).toHaveLength(1)
164
+ expect(exists).not.toContain(join(root, "tasks/example/TASK.md"))
165
+ })
166
+
107
167
  test("validates non-PR completion invariants without rejecting legacy done work", async () => {
108
168
  await write(
109
169
  root,
@@ -65,6 +65,8 @@ interface DocumentRecord<T> {
65
65
  readonly data: T
66
66
  }
67
67
 
68
+ const validationConcurrency = 32
69
+
68
70
  interface ValidationDocuments {
69
71
  readonly epics: readonly DocumentRecord<EpicData>[]
70
72
  readonly tasks: readonly DocumentRecord<TaskData>[]
@@ -671,10 +673,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
671
673
  const service = yield* WorkbaseService
672
674
  const fs = yield* FileSystemService
673
675
  const root = yield* service.discover(startPath)
676
+ const configPath = join(root, "agency.json")
677
+ const configInput = JSON.parse(
678
+ yield* fs.readFile(configPath),
679
+ ) as unknown
680
+ const configResult = decode(WorkbaseConfig, configInput)
681
+ if (!configResult.success) {
682
+ return yield* new WorkbaseConfigError({
683
+ path: configPath,
684
+ message: `Invalid workbase configuration in ${configPath}:\n${configResult.error}`,
685
+ })
686
+ }
687
+ const config = configResult.value
674
688
  const issues: ValidationIssue[] = []
675
689
  const epics = new Map<string, DocumentRecord<EpicData>>()
676
690
  const tasks = new Map<string, DocumentRecord<TaskData>>()
677
691
  const phases = new Map<string, DocumentRecord<PhaseData>>()
692
+ const phaseIdsByTask = new Map<string, Set<string>>()
678
693
 
679
694
  const issue = (path: string, message: string) => {
680
695
  issues.push({ path: relative(root, path) || ".", message })
@@ -691,7 +706,13 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
691
706
  Effect.catchAll(() => Effect.succeed([])),
692
707
  )
693
708
 
694
- const aliases = new Set(yield* service.repositoryAliases(root))
709
+ const aliases = new Set(Object.keys(config.repositories ?? {}))
710
+ const reposPath = join(root, "repos")
711
+ if (yield* fs.isDirectory(reposPath)) {
712
+ for (const entry of yield* fs.readDirectory(reposPath)) {
713
+ if (!entry.name.startsWith(".agency-")) aliases.add(entry.name)
714
+ }
715
+ }
695
716
 
696
717
  const readDocument = <S extends Schema.Schema.AnyNoContext>(
697
718
  path: string,
@@ -728,7 +749,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
728
749
  return data ? { id, path, data } : null
729
750
  }),
730
751
  ),
731
- { concurrency: "unbounded" },
752
+ { concurrency: validationConcurrency },
732
753
  )
733
754
  for (const document of epicDocuments) {
734
755
  if (document) epics.set(document.id, document)
@@ -744,6 +765,9 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
744
765
  const phaseIds = yield* readDirectories(
745
766
  join(taskPath, "phases"),
746
767
  )
768
+ if (phaseIds.length > 0) {
769
+ phaseIdsByTask.set(id, new Set(phaseIds))
770
+ }
747
771
  const taskPhases = yield* Effect.all(
748
772
  phaseIds.map((phaseId) =>
749
773
  Effect.gen(function* () {
@@ -762,7 +786,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
762
786
  : null
763
787
  }),
764
788
  ),
765
- { concurrency: "unbounded" },
789
+ { concurrency: validationConcurrency },
766
790
  )
767
791
  return {
768
792
  id,
@@ -771,7 +795,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
771
795
  }
772
796
  }),
773
797
  ),
774
- { concurrency: "unbounded" },
798
+ { concurrency: validationConcurrency },
775
799
  )
776
800
  for (const documents of taskDocuments) {
777
801
  if (documents.task) tasks.set(documents.id, documents.task)
@@ -922,10 +946,8 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
922
946
  }
923
947
  }
924
948
 
925
- const phasePrefix = `${task.id}/`
926
- const actualPhaseIds = [...phases.keys()]
927
- .filter((key) => key.startsWith(phasePrefix))
928
- .map((key) => key.slice(phasePrefix.length))
949
+ const actualPhaseIds =
950
+ phaseIdsByTask.get(task.id) ?? new Set<string>()
929
951
 
930
952
  if ("phases" in task.data) {
931
953
  const declaredIds = new Set(
@@ -952,7 +974,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
952
974
  for (const cycle of findDependencyCycles(task.data.phases)) {
953
975
  issue(task.path, `Phase dependency cycle includes '${cycle}'`)
954
976
  }
955
- } else if (actualPhaseIds.length > 0) {
977
+ } else if (actualPhaseIds.size > 0) {
956
978
  issue(
957
979
  task.path,
958
980
  "Single-phase task cannot contain phase directories",