@markjaquith/agency 2.71.10 → 2.71.12

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.12",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -66,6 +66,7 @@
66
66
  "preuninstall": "bun scripts/install-pi-extension.ts uninstall",
67
67
  "benchmark:pr": "bun scripts/benchmark-pr.ts",
68
68
  "benchmark:status": "bun scripts/benchmark-status.ts",
69
+ "benchmark:init": "bun scripts/benchmark-init.ts",
69
70
  "benchmark:workbase": "bun scripts/benchmark-workbase.ts",
70
71
  "benchmark:doctor": "bun scripts/benchmark-doctor.ts",
71
72
  "benchmark:context": "bun scripts/benchmark-context.ts",
@@ -73,6 +74,7 @@
73
74
  "benchmark:push": "bun scripts/benchmark-push.ts",
74
75
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
75
76
  "benchmark:task": "bun scripts/benchmark-task.ts",
77
+ "benchmark:validate": "bun scripts/benchmark-validate.ts",
76
78
  "benchmark:worktree": "bun scripts/benchmark-worktree.ts",
77
79
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
78
80
  "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>[]
@@ -238,13 +240,18 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
238
240
  }
239
241
 
240
242
  yield* fs.createDirectory(root)
241
- yield* fs.writeJSON(configPath, {
242
- version: 2,
243
- vcs: preferredVersionControl(),
244
- })
245
- for (const directory of ["repos", "epics", "tasks"]) {
246
- yield* fs.createDirectory(join(root, directory))
247
- }
243
+ yield* Effect.all(
244
+ [
245
+ fs.writeJSON(configPath, {
246
+ version: 2,
247
+ vcs: preferredVersionControl(),
248
+ }),
249
+ ...["repos", "epics", "tasks"].map((directory) =>
250
+ fs.createDirectory(join(root, directory)),
251
+ ),
252
+ ],
253
+ { concurrency: "unbounded" },
254
+ )
248
255
 
249
256
  const ignorePath = join(root, ".gitignore")
250
257
  const requiredPatterns = [
@@ -671,10 +678,23 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
671
678
  const service = yield* WorkbaseService
672
679
  const fs = yield* FileSystemService
673
680
  const root = yield* service.discover(startPath)
681
+ const configPath = join(root, "agency.json")
682
+ const configInput = JSON.parse(
683
+ yield* fs.readFile(configPath),
684
+ ) as unknown
685
+ const configResult = decode(WorkbaseConfig, configInput)
686
+ if (!configResult.success) {
687
+ return yield* new WorkbaseConfigError({
688
+ path: configPath,
689
+ message: `Invalid workbase configuration in ${configPath}:\n${configResult.error}`,
690
+ })
691
+ }
692
+ const config = configResult.value
674
693
  const issues: ValidationIssue[] = []
675
694
  const epics = new Map<string, DocumentRecord<EpicData>>()
676
695
  const tasks = new Map<string, DocumentRecord<TaskData>>()
677
696
  const phases = new Map<string, DocumentRecord<PhaseData>>()
697
+ const phaseIdsByTask = new Map<string, Set<string>>()
678
698
 
679
699
  const issue = (path: string, message: string) => {
680
700
  issues.push({ path: relative(root, path) || ".", message })
@@ -691,7 +711,13 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
691
711
  Effect.catchAll(() => Effect.succeed([])),
692
712
  )
693
713
 
694
- const aliases = new Set(yield* service.repositoryAliases(root))
714
+ const aliases = new Set(Object.keys(config.repositories ?? {}))
715
+ const reposPath = join(root, "repos")
716
+ if (yield* fs.isDirectory(reposPath)) {
717
+ for (const entry of yield* fs.readDirectory(reposPath)) {
718
+ if (!entry.name.startsWith(".agency-")) aliases.add(entry.name)
719
+ }
720
+ }
695
721
 
696
722
  const readDocument = <S extends Schema.Schema.AnyNoContext>(
697
723
  path: string,
@@ -728,7 +754,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
728
754
  return data ? { id, path, data } : null
729
755
  }),
730
756
  ),
731
- { concurrency: "unbounded" },
757
+ { concurrency: validationConcurrency },
732
758
  )
733
759
  for (const document of epicDocuments) {
734
760
  if (document) epics.set(document.id, document)
@@ -744,6 +770,9 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
744
770
  const phaseIds = yield* readDirectories(
745
771
  join(taskPath, "phases"),
746
772
  )
773
+ if (phaseIds.length > 0) {
774
+ phaseIdsByTask.set(id, new Set(phaseIds))
775
+ }
747
776
  const taskPhases = yield* Effect.all(
748
777
  phaseIds.map((phaseId) =>
749
778
  Effect.gen(function* () {
@@ -762,7 +791,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
762
791
  : null
763
792
  }),
764
793
  ),
765
- { concurrency: "unbounded" },
794
+ { concurrency: validationConcurrency },
766
795
  )
767
796
  return {
768
797
  id,
@@ -771,7 +800,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
771
800
  }
772
801
  }),
773
802
  ),
774
- { concurrency: "unbounded" },
803
+ { concurrency: validationConcurrency },
775
804
  )
776
805
  for (const documents of taskDocuments) {
777
806
  if (documents.task) tasks.set(documents.id, documents.task)
@@ -922,10 +951,8 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
922
951
  }
923
952
  }
924
953
 
925
- const phasePrefix = `${task.id}/`
926
- const actualPhaseIds = [...phases.keys()]
927
- .filter((key) => key.startsWith(phasePrefix))
928
- .map((key) => key.slice(phasePrefix.length))
954
+ const actualPhaseIds =
955
+ phaseIdsByTask.get(task.id) ?? new Set<string>()
929
956
 
930
957
  if ("phases" in task.data) {
931
958
  const declaredIds = new Set(
@@ -952,7 +979,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
952
979
  for (const cycle of findDependencyCycles(task.data.phases)) {
953
980
  issue(task.path, `Phase dependency cycle includes '${cycle}'`)
954
981
  }
955
- } else if (actualPhaseIds.length > 0) {
982
+ } else if (actualPhaseIds.size > 0) {
956
983
  issue(
957
984
  task.path,
958
985
  "Single-phase task cannot contain phase directories",