@markjaquith/agency 2.71.9 → 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.9",
3
+ "version": "2.71.11",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -70,8 +70,10 @@
70
70
  "benchmark:doctor": "bun scripts/benchmark-doctor.ts",
71
71
  "benchmark:context": "bun scripts/benchmark-context.ts",
72
72
  "benchmark:finish": "bun scripts/benchmark-finish.ts",
73
+ "benchmark:push": "bun scripts/benchmark-push.ts",
73
74
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
74
75
  "benchmark:task": "bun scripts/benchmark-task.ts",
76
+ "benchmark:validate": "bun scripts/benchmark-validate.ts",
75
77
  "benchmark:worktree": "bun scripts/benchmark-worktree.ts",
76
78
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
77
79
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
@@ -5,6 +5,7 @@ import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { TaskService } from "./TaskService"
7
7
  import { PushService } from "./PushService"
8
+ import { parseGitCommits } from "./push-validation"
8
9
  import { WorktreeService } from "./WorktreeService"
9
10
 
10
11
  interface CommandResult {
@@ -39,6 +40,24 @@ const requireCommand = async (args: readonly string[], cwd?: string) => {
39
40
  }
40
41
 
41
42
  describe("PushService", () => {
43
+ test("parses and validates batched Git commit metadata", () => {
44
+ const output = [
45
+ "abc123\0Agency Test\0agency@example.com\0First change\0\x1e",
46
+ "def456\0Agency Test\0agency@example.com\0Second change\0\x1e",
47
+ ].join("\n")
48
+ const commits = parseGitCommits(output)
49
+
50
+ expect(
51
+ commits.map(({ commitId, description }) => ({ commitId, description })),
52
+ ).toEqual([
53
+ { commitId: "abc123", description: "First change" },
54
+ { commitId: "def456", description: "Second change" },
55
+ ])
56
+ expect(commits.every((commit) => commit.authorEmail.includes("@"))).toBe(
57
+ true,
58
+ )
59
+ })
60
+
42
61
  const roots: string[] = []
43
62
 
44
63
  afterEach(async () => {
@@ -4,6 +4,7 @@ import { FileSystemService } from "./FileSystemService"
4
4
  import { PhaseService } from "./PhaseService"
5
5
  import { TaskService } from "./TaskService"
6
6
  import { WorkbaseService } from "./WorkbaseService"
7
+ import { parseGitCommits, type PushCommitMetadata } from "./push-validation"
7
8
 
8
9
  class PushError extends Data.TaggedError("PushError")<{
9
10
  readonly message: string
@@ -15,16 +16,7 @@ interface CommandResult {
15
16
  readonly stderr: string
16
17
  }
17
18
 
18
- interface CommitMetadata {
19
- readonly commitId: string
20
- readonly changeId?: string
21
- readonly description: string
22
- readonly empty: boolean
23
- readonly authorName: string
24
- readonly authorEmail: string
25
- readonly conflict: boolean
26
- readonly parents: readonly string[]
27
- }
19
+ type CommitMetadata = PushCommitMetadata
28
20
 
29
21
  interface PushResult {
30
22
  readonly vcs: "git" | "jj"
@@ -105,29 +97,6 @@ const gitAncestor = (
105
97
  }),
106
98
  )
107
99
 
108
- const parseGitCommits = (output: string): readonly CommitMetadata[] =>
109
- output
110
- .split("\x1e")
111
- .map((record) => record.replace(/^\n+|\n+$/g, ""))
112
- .filter(Boolean)
113
- .map((record) => {
114
- const [
115
- commitId = "",
116
- authorName = "",
117
- authorEmail = "",
118
- description = "",
119
- ] = record.split("\0")
120
- return {
121
- commitId,
122
- description,
123
- empty: false,
124
- authorName,
125
- authorEmail,
126
- conflict: false,
127
- parents: [],
128
- }
129
- })
130
-
131
100
  const validateGitCommits = (
132
101
  commits: readonly CommitMetadata[],
133
102
  base: string,
@@ -194,11 +163,12 @@ const publishGit = (
194
163
  ["fetch", remote, `+refs/heads/*:refs/remotes/${remote}/*`],
195
164
  `Failed to fetch remote '${remote}'`,
196
165
  )
197
- const tip = yield* gitRevision(fs, checkout, "HEAD")
198
- const baseRevision = yield* gitRevision(
199
- fs,
200
- checkout,
201
- `refs/remotes/${remote}/${base}`,
166
+ const [tip, baseRevision] = yield* Effect.all(
167
+ [
168
+ gitRevision(fs, checkout, "HEAD"),
169
+ gitRevision(fs, checkout, `refs/remotes/${remote}/${base}`),
170
+ ],
171
+ { concurrency: "unbounded" },
202
172
  )
203
173
  if (!tip || !baseRevision) {
204
174
  return yield* new PushError({
@@ -239,12 +209,6 @@ const publishGit = (
239
209
  }
240
210
 
241
211
  onProgress?.("publish")
242
- yield* git(
243
- fs,
244
- checkout,
245
- ["push", remote, `HEAD:refs/heads/${branch}`],
246
- `Failed to push declared branch '${branch}'`,
247
- )
248
212
  yield* git(
249
213
  fs,
250
214
  checkout,
@@ -259,17 +223,13 @@ const publishGit = (
259
223
  fs,
260
224
  checkout,
261
225
  [
262
- "fetch",
226
+ "push",
227
+ "-u",
263
228
  remote,
264
- `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`,
229
+ `HEAD:refs/heads/${branch}`,
230
+ `--force-if-includes`,
265
231
  ],
266
- `Failed to refresh published branch '${branch}'`,
267
- )
268
- yield* git(
269
- fs,
270
- checkout,
271
- ["branch", "--set-upstream-to", `${remote}/${branch}`, branch],
272
- `Failed to establish upstream tracking for branch '${branch}'`,
232
+ `Failed to push declared branch '${branch}'`,
273
233
  )
274
234
  return { tip }
275
235
  })
@@ -403,10 +363,25 @@ const publishJj = (
403
363
  }
404
364
 
405
365
  const baseBookmark = `${base}@${remote}`
406
- const baseRevision = yield* optionalJjRevision(
407
- fs,
408
- checkout,
409
- `remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
366
+ const [baseRevision, localBookmark, remoteBookmark] = yield* Effect.all(
367
+ [
368
+ optionalJjRevision(
369
+ fs,
370
+ checkout,
371
+ `remote_bookmarks(exact:"${jjExact(base)}", exact:"${jjExact(remote)}")`,
372
+ ),
373
+ optionalJjRevision(
374
+ fs,
375
+ checkout,
376
+ `bookmarks(exact:"${jjExact(branch)}")`,
377
+ ),
378
+ optionalJjRevision(
379
+ fs,
380
+ checkout,
381
+ `remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
382
+ ),
383
+ ],
384
+ { concurrency: "unbounded" },
410
385
  )
411
386
  if (!baseRevision) {
412
387
  return yield* new PushError({
@@ -432,28 +407,24 @@ const publishJj = (
432
407
  catch: (cause) => cause as PushError,
433
408
  })
434
409
 
435
- const localBookmark = yield* optionalJjRevision(
436
- fs,
437
- checkout,
438
- `bookmarks(exact:"${jjExact(branch)}")`,
439
- )
440
- if (
441
- localBookmark &&
442
- !(yield* jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId))
443
- ) {
410
+ const [localBookmarkIsAncestor, remoteBookmarkIsAncestor] =
411
+ yield* Effect.all(
412
+ [
413
+ localBookmark
414
+ ? jjAncestor(fs, checkout, localBookmark.commitId, tip.commitId)
415
+ : Effect.succeed(true),
416
+ remoteBookmark
417
+ ? jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId)
418
+ : Effect.succeed(true),
419
+ ],
420
+ { concurrency: "unbounded" },
421
+ )
422
+ if (!localBookmarkIsAncestor) {
444
423
  return yield* new PushError({
445
424
  message: `Local bookmark '${branch}' is not an ancestor of jj tip ${tip.changeId}; refusing to move it`,
446
425
  })
447
426
  }
448
- const remoteBookmark = yield* optionalJjRevision(
449
- fs,
450
- checkout,
451
- `remote_bookmarks(exact:"${jjExact(branch)}", exact:"${jjExact(remote)}")`,
452
- )
453
- if (
454
- remoteBookmark &&
455
- !(yield* jjAncestor(fs, checkout, remoteBookmark.commitId, tip.commitId))
456
- ) {
427
+ if (!remoteBookmarkIsAncestor) {
457
428
  return yield* new PushError({
458
429
  message: `Remote bookmark '${branch}@${remote}' is not an ancestor of jj tip ${tip.changeId}; refusing a non-fast-forward update`,
459
430
  })
@@ -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",
@@ -0,0 +1,35 @@
1
+ export interface PushCommitMetadata {
2
+ readonly commitId: string
3
+ readonly changeId?: string
4
+ readonly description: string
5
+ readonly empty: boolean
6
+ readonly authorName: string
7
+ readonly authorEmail: string
8
+ readonly conflict: boolean
9
+ readonly parents: readonly string[]
10
+ }
11
+
12
+ export const parseGitCommits = (
13
+ output: string,
14
+ ): readonly PushCommitMetadata[] =>
15
+ output
16
+ .split("\x1e")
17
+ .map((record) => record.replace(/^\n+|\n+$/g, ""))
18
+ .filter(Boolean)
19
+ .map((record) => {
20
+ const [
21
+ commitId = "",
22
+ authorName = "",
23
+ authorEmail = "",
24
+ description = "",
25
+ ] = record.split("\0")
26
+ return {
27
+ commitId,
28
+ description,
29
+ empty: false,
30
+ authorName,
31
+ authorEmail,
32
+ conflict: false,
33
+ parents: [],
34
+ }
35
+ })