@markjaquith/agency 2.71.22 → 2.71.24

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/README.md CHANGED
@@ -269,6 +269,11 @@ Custom commands own writable branch creation. Agency checks for conflicting
269
269
  worktrees first, invokes the command only when the branch is not checked out,
270
270
  and verifies that `{worktree}` exists afterward.
271
271
 
272
+ Agency also reconciles known tool-owned artifacts such as Worktrunk's
273
+ `.worktree.lock` into each managed worktree's local Git exclude file. This keeps
274
+ new and existing managed worktrees clean without changing the repository's
275
+ tracked ignore configuration or overwriting user-maintained local excludes.
276
+
272
277
  The configured command applies only to the writable checkout of a Git workbase.
273
278
  Supplemental read-only repositories remain detached Git worktrees at their
274
279
  declared refs so they do not acquire writable branches. Jj workbases always use
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markjaquith/agency",
3
- "version": "2.71.22",
3
+ "version": "2.71.24",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -83,6 +83,7 @@
83
83
  "benchmark:graph": "bun scripts/benchmark-graph.ts",
84
84
  "benchmark:push": "bun scripts/benchmark-push.ts",
85
85
  "benchmark:archive": "bun scripts/benchmark-archive.ts",
86
+ "benchmark:vcs": "bun scripts/benchmark-vcs.ts",
86
87
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
87
88
  "benchmark:task": "bun scripts/benchmark-task.ts",
88
89
  "benchmark:validate": "bun scripts/benchmark-validate.ts",
@@ -605,14 +605,14 @@ process.stdout.write(${JSON.stringify(JSON.stringify(providerRecord))})
605
605
  expect("pr" in task.data && task.data.pr).toBeNull()
606
606
  })
607
607
 
608
- test("reports git status failure before push or gh", async () => {
608
+ test("reports invalid reused Git checkout before push or gh", async () => {
609
609
  await createTask()
610
610
  const workspace = await materialize()
611
611
  await rm(join(workspace.writablePath!, ".git"))
612
612
  await writeFakeGh({ stdout: "https://github.com/example/agency/pull/48" })
613
613
 
614
614
  await expect(createPullRequest()).rejects.toThrow(
615
- "Failed to inspect worktree status",
615
+ "Failed to locate local excludes",
616
616
  )
617
617
 
618
618
  await expectRemoteBranch("task/example", false)
@@ -1,4 +1,4 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { chmod, mkdir, rm } from "node:fs/promises"
4
4
  import { join } from "node:path"
@@ -1115,6 +1115,54 @@ exec ${JSON.stringify(realGit)} "$@"
1115
1115
  )
1116
1116
  })
1117
1117
 
1118
+ test("reuses documents parsed during validation", async () => {
1119
+ await runTestEffect(
1120
+ TaskService.pipe(
1121
+ Effect.flatMap((service) =>
1122
+ service.create(
1123
+ {
1124
+ id: "single-read",
1125
+ ticketUrl: null,
1126
+ repo: "agency",
1127
+ branch: "feat/single-read",
1128
+ base: "main",
1129
+ },
1130
+ root,
1131
+ ),
1132
+ ),
1133
+ ),
1134
+ )
1135
+ await runTestEffect(
1136
+ TaskService.pipe(
1137
+ Effect.flatMap((service) =>
1138
+ service.setStatus("single-read", "done", root, {
1139
+ summary: "Completed without a pull request",
1140
+ }),
1141
+ ),
1142
+ ),
1143
+ )
1144
+ const taskPath = join(root, "tasks/single-read/TASK.md")
1145
+ const taskContent = await Bun.file(taskPath).text()
1146
+ const originalFile = Bun.file
1147
+ let reads = 0
1148
+ const fileSpy = spyOn(Bun, "file").mockImplementation(((path: string) => {
1149
+ if (path === taskPath) reads += 1
1150
+ return path === taskPath ? new Blob([taskContent]) : originalFile(path)
1151
+ }) as typeof Bun.file)
1152
+
1153
+ try {
1154
+ await runTestEffect(
1155
+ SyncService.pipe(
1156
+ Effect.flatMap((service) => service.reconcile({ cwd: root })),
1157
+ ),
1158
+ )
1159
+ } finally {
1160
+ fileSpy.mockRestore()
1161
+ }
1162
+
1163
+ expect(reads).toBe(1)
1164
+ })
1165
+
1118
1166
  test("queries pull request providers concurrently", async () => {
1119
1167
  for (const id of ["first", "second", "third"]) {
1120
1168
  await runTestEffect(
@@ -1,6 +1,5 @@
1
1
  import { Data, Effect, Either } from "effect"
2
2
  import { dirname, join, resolve } from "node:path"
3
- import { documentRevision } from "../workbase/document-revision"
4
3
  import type {
5
4
  ClaimRecord,
6
5
  PhaseFrontmatter,
@@ -17,8 +16,6 @@ import {
17
16
  } from "../workbase/delivery-command"
18
17
  import { ClaimService } from "./ClaimService"
19
18
  import { FileSystemService } from "./FileSystemService"
20
- import { PhaseService } from "./PhaseService"
21
- import { TaskService } from "./TaskService"
22
19
  import { WorkbaseService } from "./WorkbaseService"
23
20
  import { WorktreeService } from "./WorktreeService"
24
21
  import {
@@ -217,15 +214,15 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
217
214
  Effect.gen(function* () {
218
215
  const fs = yield* FileSystemService
219
216
  const workbase = yield* WorkbaseService
220
- const tasks = yield* TaskService
221
- const phases = yield* PhaseService
222
217
  const worktrees = yield* WorktreeService
223
218
  const claims = yield* ClaimService
224
219
  const repositories = yield* RepositoryService
225
220
  const versionControl = yield* VersionControlService
226
221
  const { root, config } = yield* workbase.loadConfig(options.cwd)
227
222
  const backend = yield* versionControl.forWorkbase(root)
228
- const validation = yield* workbase.validate(root)
223
+ const validation = yield* workbase.validate(root, {
224
+ includeDocuments: true,
225
+ })
229
226
  if (!validation.valid) {
230
227
  return yield* new SyncError({
231
228
  message: validation.issues
@@ -238,7 +235,8 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
238
235
  message: "A phase sync scope requires a task ID",
239
236
  })
240
237
  }
241
- const allTaskRecords = yield* tasks.list(root)
238
+ const documents = validation.documents!
239
+ const allTaskRecords = documents.tasks
242
240
  const taskRecords = options.taskId
243
241
  ? allTaskRecords.filter((task) => task.id === options.taskId)
244
242
  : allTaskRecords
@@ -250,14 +248,14 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
250
248
  const records: ExecutionRecord[] = []
251
249
  for (const task of taskRecords) {
252
250
  if ("phases" in task.data) {
253
- for (const phase of yield* phases.list(task.id, root)) {
251
+ for (const phase of documents.phasesByTask.get(task.id) ?? []) {
254
252
  if (options.phaseId && phase.id !== options.phaseId) continue
255
253
  records.push({
256
254
  key: `phase:${task.id}/${phase.id}`,
257
255
  taskId: task.id,
258
256
  phaseId: phase.id,
259
257
  path: phase.path,
260
- revision: documentRevision(phase.content),
258
+ revision: phase.revision,
261
259
  data: phase.data,
262
260
  })
263
261
  }
@@ -266,7 +264,7 @@ export class SyncService extends Effect.Service<SyncService>()("SyncService", {
266
264
  key: `task:${task.id}`,
267
265
  taskId: task.id,
268
266
  path: task.path,
269
- revision: documentRevision(task.content),
267
+ revision: task.revision,
270
268
  data: task.data,
271
269
  })
272
270
  }
@@ -263,15 +263,24 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
263
263
  const repositoryRecords = yield* repositories.list(root)
264
264
  const repositoryPlans: RepositoryPlan[] = []
265
265
  const repositoryStatus: MigrationState["repositories"][number][] = []
266
- for (const repository of repositoryRecords) {
267
- const initialized = yield* fs.exists(
268
- join(
269
- repository.kind === "symlink"
270
- ? yield* fs.realPath(repository.path)
271
- : repository.path,
272
- ".jj",
273
- ),
274
- )
266
+ const repositoryInspections = yield* Effect.forEach(
267
+ repositoryRecords,
268
+ (repository) =>
269
+ Effect.gen(function* () {
270
+ const targetPath =
271
+ repository.kind === "symlink"
272
+ ? yield* fs.realPath(repository.path)
273
+ : repository.path
274
+ const initialized = yield* fs.exists(join(targetPath, ".jj"))
275
+ return { repository, initialized, targetPath }
276
+ }),
277
+ { concurrency: 8 },
278
+ )
279
+ for (const {
280
+ repository,
281
+ initialized,
282
+ targetPath,
283
+ } of repositoryInspections) {
275
284
  repositoryStatus.push({
276
285
  alias: repository.alias,
277
286
  path: repository.path,
@@ -297,10 +306,6 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
297
306
  message: `Repository '${repository.alias}' is not initialized for the configured jj backend`,
298
307
  })
299
308
  }
300
- const targetPath =
301
- repository.kind === "symlink"
302
- ? yield* fs.realPath(repository.path)
303
- : repository.path
304
309
  repositoryPlans.push({
305
310
  alias: repository.alias,
306
311
  path: repository.path,
@@ -353,6 +358,23 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
353
358
  }
354
359
 
355
360
  const workspacePlans: WorkspacePlan[] = []
361
+ const sourceWorkspaces = new Map<
362
+ string,
363
+ Effect.Effect<
364
+ readonly import("./VersionControlService").RegisteredWorkspace[],
365
+ unknown,
366
+ any
367
+ >
368
+ >()
369
+ const listSourceWorkspaces = (repositoryPath: string) => {
370
+ const existing = sourceWorkspaces.get(repositoryPath)
371
+ if (existing) return existing
372
+ const workspaces = sourceBackend
373
+ .listWorkspaces(repositoryPath)
374
+ .pipe(Effect.cached, Effect.flatten)
375
+ sourceWorkspaces.set(repositoryPath, workspaces)
376
+ return workspaces
377
+ }
356
378
  const inspected = yield* Effect.either(
357
379
  worktrees.list(root, {
358
380
  materializedOnly: !blockers.some(
@@ -400,7 +422,7 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
400
422
  }
401
423
  const sourceName =
402
424
  source !== target && source === "jj"
403
- ? ((yield* sourceBackend.listWorkspaces(
425
+ ? ((yield* listSourceWorkspaces(
404
426
  join(root, "repos", checkout.repo),
405
427
  )).find((item) => item.path === checkout.registeredPath)
406
428
  ?.name ?? null)
@@ -27,6 +27,7 @@ import { validateAgents } from "../workbase/agent-command"
27
27
  import { findDependencyCycles } from "../workbase/dependency-graph"
28
28
  import { validateDelivery } from "../workbase/delivery-command"
29
29
  import { preferredVersionControl } from "../workbase/version-control"
30
+ import { documentRevision } from "../workbase/document-revision"
30
31
 
31
32
  class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
32
33
  readonly message: string
@@ -62,6 +63,8 @@ export interface ValidationReport {
62
63
  interface DocumentRecord<T> {
63
64
  readonly id: string
64
65
  readonly path: string
66
+ readonly content: string
67
+ readonly revision: string
65
68
  readonly data: T
66
69
  }
67
70
 
@@ -729,8 +732,9 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
729
732
  issue(path, "Required document is missing")
730
733
  return null
731
734
  }
735
+ const documentContent = content.value
732
736
  const parsed = yield* Effect.either(
733
- parseFrontmatter(content.value, path),
737
+ parseFrontmatter(documentContent, path),
734
738
  )
735
739
  if (Either.isLeft(parsed)) {
736
740
  issue(path, parsed.left.message)
@@ -742,7 +746,11 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
742
746
  issue(path, decoded.error)
743
747
  return null
744
748
  }
745
- return decoded.value
749
+ return {
750
+ content: documentContent,
751
+ revision: documentRevision(documentContent),
752
+ data: decoded.value,
753
+ }
746
754
  })
747
755
 
748
756
  const epicIds = yield* readDirectories(join(root, "epics"))
@@ -750,8 +758,8 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
750
758
  epicIds.map((id) =>
751
759
  Effect.gen(function* () {
752
760
  const path = join(root, "epics", id, "EPIC.md")
753
- const data = yield* readDocument(path, EpicFrontmatter)
754
- return data ? { id, path, data } : null
761
+ const document = yield* readDocument(path, EpicFrontmatter)
762
+ return document ? { id, path, ...document } : null
755
763
  }),
756
764
  ),
757
765
  { concurrency: validationConcurrency },
@@ -766,7 +774,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
766
774
  Effect.gen(function* () {
767
775
  const taskPath = join(root, "tasks", id)
768
776
  const path = join(taskPath, "TASK.md")
769
- const data = yield* readDocument(path, TaskFrontmatter)
777
+ const document = yield* readDocument(path, TaskFrontmatter)
770
778
  const phaseIds = yield* readDirectories(
771
779
  join(taskPath, "phases"),
772
780
  )
@@ -787,7 +795,12 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
787
795
  PhaseFrontmatter,
788
796
  )
789
797
  return phase
790
- ? { id: phaseId, path: phasePath, data: phase }
798
+ ? {
799
+ id: phaseId,
800
+ taskId: id,
801
+ path: phasePath,
802
+ ...phase,
803
+ }
791
804
  : null
792
805
  }),
793
806
  ),
@@ -795,7 +808,7 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
795
808
  )
796
809
  return {
797
810
  id,
798
- task: data ? { id, path, data } : null,
811
+ task: document ? { id, path, ...document } : null,
799
812
  phases: taskPhases,
800
813
  }
801
814
  }),
@@ -1,7 +1,7 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
3
  import { chmod, mkdir, realpath, rename, rm, stat } from "node:fs/promises"
4
- import { join } from "node:path"
4
+ import { join, resolve } from "node:path"
5
5
  import {
6
6
  captureErrors,
7
7
  cleanupTempDir,
@@ -23,6 +23,21 @@ const git = async (args: string[], cwd?: string) => {
23
23
  throw new Error(await new Response(process.stderr).text())
24
24
  }
25
25
 
26
+ const gitOutput = async (args: string[], cwd?: string) => {
27
+ const process = Bun.spawn(["git", ...args], {
28
+ cwd,
29
+ stdout: "pipe",
30
+ stderr: "pipe",
31
+ })
32
+ const [exitCode, stdout, stderr] = await Promise.all([
33
+ process.exited,
34
+ new Response(process.stdout).text(),
35
+ new Response(process.stderr).text(),
36
+ ])
37
+ if (exitCode !== 0) throw new Error(stderr)
38
+ return stdout.trim()
39
+ }
40
+
26
41
  const jj = async (args: string[], cwd?: string) => {
27
42
  const process = Bun.spawn(["jj", ...args], {
28
43
  cwd,
@@ -117,6 +132,63 @@ describe("WorktreeService", () => {
117
132
  { stdout: "pipe" },
118
133
  )
119
134
  expect(new TextDecoder().decode(branch.stdout).trim()).toBe("task/example")
135
+ for (const checkout of [
136
+ workspace.writablePath!,
137
+ join(workspace.codePath, "effect"),
138
+ ]) {
139
+ await Bun.write(join(checkout, ".worktree.lock"), "")
140
+ expect(await gitOutput(["status", "--porcelain"], checkout)).toBe("")
141
+ }
142
+ })
143
+
144
+ test("reconciles local worktree excludes without overwriting user entries", async () => {
145
+ await runTestEffect(
146
+ TaskService.pipe(
147
+ Effect.flatMap((service) =>
148
+ service.create(
149
+ {
150
+ id: "existing-ignore",
151
+ ticketUrl: null,
152
+ repo: "agency",
153
+ branch: "task/existing-ignore",
154
+ base: "main",
155
+ },
156
+ root,
157
+ ),
158
+ ),
159
+ ),
160
+ )
161
+ const first = await runTestEffect(
162
+ WorktreeService.pipe(
163
+ Effect.flatMap((service) =>
164
+ service.materialize("existing-ignore", undefined, root),
165
+ ),
166
+ ),
167
+ )
168
+ const checkout = first.writablePath!
169
+ const excludePath = resolve(
170
+ checkout,
171
+ await gitOutput(["rev-parse", "--git-path", "info/exclude"], checkout),
172
+ )
173
+ await Bun.write(excludePath, "user-entry")
174
+
175
+ await runTestEffect(
176
+ WorktreeService.pipe(
177
+ Effect.flatMap((service) =>
178
+ service.materialize("existing-ignore", undefined, root),
179
+ ),
180
+ ),
181
+ )
182
+ await runTestEffect(
183
+ WorktreeService.pipe(
184
+ Effect.flatMap((service) =>
185
+ service.materialize("existing-ignore", undefined, root),
186
+ ),
187
+ ),
188
+ )
189
+ const excludes = await Bun.file(excludePath).text()
190
+ expect(excludes).toBe("user-entry\n/.worktree.lock\n")
191
+ expect(excludes.match(/^\/\.worktree\.lock$/gm)).toHaveLength(1)
120
192
  })
121
193
 
122
194
  test("runs repository hooks for new writable and reference checkouts", async () => {
@@ -2720,5 +2792,14 @@ pr: null
2720
2792
  expect(
2721
2793
  await Bun.file(join(workspace.writablePath!, "README.md")).text(),
2722
2794
  ).toBe("example\n")
2795
+ expect(
2796
+ await gitOutput(["status", "--porcelain"], workspace.writablePath!),
2797
+ ).toBe("")
2798
+ expect(
2799
+ await gitOutput(
2800
+ ["check-ignore", "--no-index", ".worktree.lock"],
2801
+ workspace.writablePath!,
2802
+ ),
2803
+ ).toBe(".worktree.lock")
2723
2804
  })
2724
2805
  })
@@ -80,6 +80,8 @@ interface GitWorktree {
80
80
  readonly branch?: string
81
81
  }
82
82
 
83
+ const managedWorktreeIgnorePatterns = ["/.worktree.lock"] as const
84
+
83
85
  interface WorktreeOwner {
84
86
  readonly kind: "task" | "phase"
85
87
  readonly taskId: string
@@ -2325,6 +2327,42 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2325
2327
  const registeredAtPath = worktrees.find(
2326
2328
  (worktree) => worktree.path === canonicalCheckoutPath,
2327
2329
  )
2330
+ const reconcileManagedWorktreeIgnores = (path: string) =>
2331
+ Effect.gen(function* () {
2332
+ const resolved = yield* fs.runCommand(
2333
+ [
2334
+ "git",
2335
+ "-C",
2336
+ path,
2337
+ "rev-parse",
2338
+ "--git-path",
2339
+ "info/exclude",
2340
+ ],
2341
+ { captureOutput: true },
2342
+ )
2343
+ if (resolved.exitCode !== 0) {
2344
+ return yield* new WorktreeError({
2345
+ message: `Failed to locate local excludes for ${path}: ${resolved.stderr}`,
2346
+ })
2347
+ }
2348
+ const excludePath = resolve(path, resolved.stdout.trim())
2349
+ const existing = (yield* fs.exists(excludePath))
2350
+ ? yield* fs.readFile(excludePath)
2351
+ : ""
2352
+ const existingLines = new Set(existing.split(/\r?\n/))
2353
+ const missing = managedWorktreeIgnorePatterns.filter(
2354
+ (pattern) => !existingLines.has(pattern),
2355
+ )
2356
+ if (missing.length === 0) return
2357
+ const prefix =
2358
+ existing.length > 0 && !existing.endsWith("\n")
2359
+ ? "\n"
2360
+ : ""
2361
+ yield* fs.writeFile(
2362
+ excludePath,
2363
+ `${existing}${prefix}${missing.join("\n")}\n`,
2364
+ )
2365
+ })
2328
2366
 
2329
2367
  if ("branch" in checkout) {
2330
2368
  const branchRef = `refs/heads/${checkout.branch}`
@@ -2341,6 +2379,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2341
2379
  }
2342
2380
  if (yield* fs.isDirectory(checkoutPath)) {
2343
2381
  if (registeredAtPath?.branch === branchRef) {
2382
+ if (!options.dryRun) {
2383
+ yield* reconcileManagedWorktreeIgnores(checkoutPath)
2384
+ }
2344
2385
  checkoutReports.push({
2345
2386
  repo: alias,
2346
2387
  kind: "writable",
@@ -2545,6 +2586,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2545
2586
  message: `Created worktree for '${alias}' failed validation for branch '${checkout.branch}'`,
2546
2587
  })
2547
2588
  }
2589
+ yield* reconcileManagedWorktreeIgnores(checkoutPath)
2548
2590
  yield* runPostCheckoutHook({
2549
2591
  command: config.repositories?.[alias]?.postCheckoutCommand,
2550
2592
  variables: {
@@ -2620,6 +2662,9 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2620
2662
  currentHead.exitCode === 0 &&
2621
2663
  currentHead.stdout.trim() === commit
2622
2664
  ) {
2665
+ if (!options.dryRun) {
2666
+ yield* reconcileManagedWorktreeIgnores(checkoutPath)
2667
+ }
2623
2668
  checkoutReports.push({
2624
2669
  repo: alias,
2625
2670
  kind: "reference",
@@ -2709,6 +2754,7 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
2709
2754
  message: `Created reference checkout for '${alias}' failed validation for '${checkout.ref}'`,
2710
2755
  })
2711
2756
  }
2757
+ yield* reconcileManagedWorktreeIgnores(checkoutPath)
2712
2758
  yield* runPostCheckoutHook({
2713
2759
  command: config.repositories?.[alias]?.postCheckoutCommand,
2714
2760
  variables: {