@markjaquith/agency 2.52.1 → 2.52.2

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.52.1",
3
+ "version": "2.52.2",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -20,6 +20,7 @@
20
20
  "files": [
21
21
  "index.ts",
22
22
  "cli.ts",
23
+ "cli-main.ts",
23
24
  "src",
24
25
  "schemas",
25
26
  "fixtures/protocol",
@@ -34,7 +34,7 @@ class TaskError extends Data.TaggedError("TaskError")<{
34
34
  readonly message: string
35
35
  }> {}
36
36
 
37
- interface TaskRecord {
37
+ export interface TaskRecord {
38
38
  readonly id: string
39
39
  readonly path: string
40
40
  readonly content: string
@@ -7,6 +7,7 @@ import { ClaimService } from "./ClaimService"
7
7
  import { TaskService } from "./TaskService"
8
8
  import { VcsMigrationService } from "./VcsMigrationService"
9
9
  import { WorktreeService } from "./WorktreeService"
10
+ import { runVcsStatusFast } from "../vcs-status-fast"
10
11
 
11
12
  const run = async (args: string[], cwd?: string) => {
12
13
  const child = Bun.spawn(args, { cwd, stdout: "pipe", stderr: "pipe" })
@@ -72,6 +73,51 @@ describe("VcsMigrationService", () => {
72
73
 
73
74
  afterEach(async () => cleanupTempDir(root))
74
75
 
76
+ test("reports same-backend status for materialized workspaces", async () => {
77
+ const status = await runTestEffect(
78
+ VcsMigrationService.pipe(
79
+ Effect.flatMap((service) => service.status(root)),
80
+ ),
81
+ )
82
+ expect(status).toMatchObject({
83
+ configured: "git",
84
+ source: "git",
85
+ target: "git",
86
+ workspaceCount: 2,
87
+ blockers: [],
88
+ })
89
+ })
90
+
91
+ test("fast jj status matches the validated service result", async () => {
92
+ if (!Bun.which("jj")) return
93
+ await runTestEffect(
94
+ VcsMigrationService.pipe(
95
+ Effect.flatMap((service) =>
96
+ service.migrate("jj", root, { apply: true }),
97
+ ),
98
+ ),
99
+ )
100
+ const taskPath = join(root, "tasks/example/TASK.md")
101
+ const content = await Bun.file(taskPath).text()
102
+ const withoutReference = content.replace(
103
+ /\nrepos:\n(?: .+\n)+(?=branch:)/,
104
+ "\n",
105
+ )
106
+ expect(withoutReference).not.toBe(content)
107
+ await Bun.write(taskPath, withoutReference)
108
+
109
+ const validated = await runTestEffect(
110
+ VcsMigrationService.pipe(
111
+ Effect.flatMap((service) => service.status(root)),
112
+ ),
113
+ )
114
+ const output: string[] = []
115
+ expect(
116
+ await runVcsStatusFast(true, root, (line) => output.push(line)),
117
+ ).toBe(true)
118
+ expect(JSON.parse(output.join("\n")).result).toEqual(validated)
119
+ })
120
+
75
121
  test("migrates Git worktrees to jj workspaces and back", async () => {
76
122
  if (!Bun.which("jj")) return
77
123
  const checkout = join(root, "tasks/example/code/agency")
@@ -8,7 +8,7 @@ import {
8
8
  runLifecycleTransaction,
9
9
  } from "./LifecycleTransaction"
10
10
  import { FileSystemService } from "./FileSystemService"
11
- import { PhaseService } from "./PhaseService"
11
+ import { PhaseService, type PhaseRecord } from "./PhaseService"
12
12
  import { RepositoryService } from "./RepositoryService"
13
13
  import { TaskService } from "./TaskService"
14
14
  import {
@@ -182,15 +182,19 @@ const executionRecords = (root: string) =>
182
182
  Effect.gen(function* () {
183
183
  const tasks = yield* TaskService
184
184
  const phases = yield* PhaseService
185
+ const taskRecords = yield* tasks.list(root)
186
+ const phasesByTask = new Map<string, readonly PhaseRecord[]>()
185
187
  const records: {
186
188
  taskId: string
187
189
  phaseId?: string
188
190
  status: WorkStatus
189
191
  claimActive: boolean
190
192
  }[] = []
191
- for (const task of yield* tasks.list(root)) {
193
+ for (const task of taskRecords) {
192
194
  if ("phases" in task.data) {
193
- for (const phase of yield* phases.list(task.id, root)) {
195
+ const phaseRecords = yield* phases.list(task.id, root)
196
+ phasesByTask.set(task.id, phaseRecords)
197
+ for (const phase of phaseRecords) {
194
198
  records.push({
195
199
  taskId: task.id,
196
200
  phaseId: phase.id,
@@ -206,7 +210,7 @@ const executionRecords = (root: string) =>
206
210
  })
207
211
  }
208
212
  }
209
- return records
213
+ return { records, taskRecords, phasesByTask }
210
214
  })
211
215
 
212
216
  const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
@@ -241,7 +245,8 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
241
245
  })
242
246
  }
243
247
 
244
- const records = yield* executionRecords(root)
248
+ const execution = yield* executionRecords(root)
249
+ const records = execution.records
245
250
  for (const record of records) {
246
251
  if (record.claimActive) {
247
252
  const label = record.phaseId
@@ -339,7 +344,15 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
339
344
  }
340
345
 
341
346
  const workspacePlans: WorkspacePlan[] = []
342
- const inspected = yield* Effect.either(worktrees.list(root))
347
+ const inspected = yield* Effect.either(
348
+ worktrees.list(root, {
349
+ materializedOnly: !blockers.some(
350
+ (blocker) => blocker.kind === "repository",
351
+ ),
352
+ tasks: execution.taskRecords,
353
+ phasesByTask: execution.phasesByTask,
354
+ }),
355
+ )
343
356
  if (Either.isLeft(inspected)) {
344
357
  blockers.push({
345
358
  kind: "workspace-conflict",
@@ -377,26 +390,27 @@ const inspectMigration = (startPath: string, requestedTarget?: VcsKind) =>
377
390
  continue
378
391
  }
379
392
  const sourceName =
380
- source === "jj"
393
+ source !== target && source === "jj"
381
394
  ? ((yield* sourceBackend.listWorkspaces(
382
395
  join(root, "repos", checkout.repo),
383
396
  )).find((item) => item.path === checkout.registeredPath)
384
397
  ?.name ?? null)
385
398
  : null
386
- const previousBranchCommit = checkout.actualBranch
387
- ? yield* command(
388
- fs,
389
- [
390
- "git",
391
- "-C",
392
- join(root, "repos", checkout.repo),
393
- "rev-parse",
394
- "--verify",
395
- `${checkout.actualBranch}^{commit}`,
396
- ],
397
- `Failed to inspect branch '${checkout.actualBranch}'`,
398
- ).pipe(Effect.catchAll(() => Effect.succeed(null)))
399
- : null
399
+ const previousBranchCommit =
400
+ source !== target && checkout.actualBranch
401
+ ? yield* command(
402
+ fs,
403
+ [
404
+ "git",
405
+ "-C",
406
+ join(root, "repos", checkout.repo),
407
+ "rev-parse",
408
+ "--verify",
409
+ `${checkout.actualBranch}^{commit}`,
410
+ ],
411
+ `Failed to inspect branch '${checkout.actualBranch}'`,
412
+ ).pipe(Effect.catchAll(() => Effect.succeed(null)))
413
+ : null
400
414
  workspacePlans.push({
401
415
  taskId: inspection.owner.taskId,
402
416
  ...(inspection.owner.phaseId
@@ -3,11 +3,13 @@ import { FileSystemService } from "./FileSystemService"
3
3
  import { WorkbaseService } from "./WorkbaseService"
4
4
  import type { VersionControlKind } from "../workbase/version-control"
5
5
 
6
- interface RegisteredWorkspace {
6
+ export interface RegisteredWorkspace {
7
7
  readonly name: string | null
8
8
  readonly path: string
9
9
  readonly commit: string | null
10
10
  readonly branch: string | null
11
+ readonly head?: string | null
12
+ readonly dirty?: boolean
11
13
  }
12
14
 
13
15
  export interface VersionControlBackend {
@@ -305,7 +307,7 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
305
307
  "workspace",
306
308
  "list",
307
309
  "-T",
308
- 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\n"',
310
+ 'name ++ "\\t" ++ root ++ "\\t" ++ target.commit_id() ++ "\\t" ++ target.parents().map(|commit| commit.commit_id()).join(",") ++ "\\t" ++ target.empty() ++ "\\n"',
309
311
  ]),
310
312
  { captureOutput: true },
311
313
  ),
@@ -315,12 +317,16 @@ export class JjVersionControlService extends Effect.Service<JjVersionControlServ
315
317
  .split("\n")
316
318
  .filter(Boolean)
317
319
  .map((line) => {
318
- const [name, path, commit] = line.split("\t")
320
+ const [name, path, commit, parents, empty] = line.split("\t")
321
+ const parentCommits = parents?.split(",").filter(Boolean) ?? []
319
322
  return {
320
323
  name: name || null,
321
324
  path: path!,
322
325
  commit: commit || null,
323
326
  branch: null,
327
+ head:
328
+ parentCommits.length === 1 ? parentCommits[0]! : undefined,
329
+ dirty: empty === "false",
324
330
  }
325
331
  })
326
332
  }),
@@ -13,6 +13,12 @@ import type { BaseCommandOptions } from "../utils/command"
13
13
  import { createLoggers } from "../utils/effect"
14
14
  import { withWorktreeLocks } from "./WorktreeLock"
15
15
  import { VersionControlService } from "./VersionControlService"
16
+ import type {
17
+ RegisteredWorkspace,
18
+ VersionControlBackend,
19
+ } from "./VersionControlService"
20
+ import type { TaskRecord } from "./TaskService"
21
+ import type { PhaseRecord } from "./PhaseService"
16
22
 
17
23
  class WorktreeError extends Data.TaggedError("WorktreeError")<{
18
24
  readonly message: string
@@ -113,6 +119,19 @@ interface WorktreeInspection {
113
119
  readonly conflicts: readonly WorktreeConflict[]
114
120
  }
115
121
 
122
+ interface InspectionContext {
123
+ readonly root: string
124
+ readonly backend: VersionControlBackend
125
+ readonly tasks: readonly TaskRecord[]
126
+ readonly phasesByTask: ReadonlyMap<string, readonly PhaseRecord[]>
127
+ readonly ownership: ReadonlyMap<string, readonly WorktreeOwner[]>
128
+ readonly workspacesByRepository: ReadonlyMap<
129
+ string,
130
+ readonly RegisteredWorkspace[]
131
+ >
132
+ readonly skipWritableRevisionResolution: boolean
133
+ }
134
+
116
135
  interface WorktreeLifecycleResult {
117
136
  readonly operation: "remove" | "rebuild" | "repair"
118
137
  readonly dryRun: boolean
@@ -176,10 +195,17 @@ interface LifecycleOptions extends BaseCommandOptions {
176
195
  readonly lockHeld?: boolean
177
196
  }
178
197
 
198
+ interface ListOptions {
199
+ readonly materializedOnly?: boolean
200
+ readonly tasks?: readonly TaskRecord[]
201
+ readonly phasesByTask?: ReadonlyMap<string, readonly PhaseRecord[]>
202
+ }
203
+
179
204
  const inspectExecution = (
180
205
  taskId: string,
181
206
  phaseId: string | undefined,
182
207
  startPath: string,
208
+ context?: InspectionContext,
183
209
  ) =>
184
210
  Effect.gen(function* () {
185
211
  const fs = yield* FileSystemService
@@ -187,9 +213,17 @@ const inspectExecution = (
187
213
  const versionControl = yield* VersionControlService
188
214
  const tasks = yield* TaskService
189
215
  const phases = yield* PhaseService
190
- const root = yield* workbase.discover(startPath)
191
- const backend = yield* versionControl.forWorkbase(root)
192
- const task = yield* tasks.show(taskId, root)
216
+ const root = context?.root ?? (yield* workbase.discover(startPath))
217
+ const backend =
218
+ context?.backend ?? (yield* versionControl.forWorkbase(root))
219
+ const task = context
220
+ ? context.tasks.find((record) => record.id === taskId)
221
+ : yield* tasks.show(taskId, root)
222
+ if (!task) {
223
+ return yield* new WorktreeError({
224
+ message: `Task '${taskId}' does not exist`,
225
+ })
226
+ }
193
227
 
194
228
  let execution:
195
229
  | {
@@ -207,7 +241,16 @@ const inspectExecution = (
207
241
  message: `Task '${taskId}' has multiple phases; phase ID is required`,
208
242
  })
209
243
  }
210
- const phase = yield* phases.show(taskId, phaseId, root)
244
+ const phase = context
245
+ ? context.phasesByTask
246
+ .get(taskId)
247
+ ?.find((record) => record.id === phaseId)
248
+ : yield* phases.show(taskId, phaseId, root)
249
+ if (!phase) {
250
+ return yield* new WorktreeError({
251
+ message: `Phase '${phaseId}' does not exist on task '${taskId}'`,
252
+ })
253
+ }
211
254
  execution = phase.data
212
255
  owner = {
213
256
  kind: "phase",
@@ -227,31 +270,37 @@ const inspectExecution = (
227
270
  codePath = join(dirname(task.path), "code")
228
271
  }
229
272
 
230
- const ownership = new Map<string, WorktreeOwner[]>()
231
- for (const taskRecord of yield* tasks.list(root)) {
232
- if ("phases" in taskRecord.data) {
233
- for (const phaseRecord of yield* phases.list(taskRecord.id, root)) {
234
- const key = `${phaseRecord.data.repo}:${phaseRecord.data.branch}`
235
- const owners = ownership.get(key) ?? []
273
+ let ownership: ReadonlyMap<string, readonly WorktreeOwner[]>
274
+ if (context) {
275
+ ownership = context.ownership
276
+ } else {
277
+ const discoveredOwnership = new Map<string, WorktreeOwner[]>()
278
+ for (const taskRecord of yield* tasks.list(root)) {
279
+ if ("phases" in taskRecord.data) {
280
+ for (const phaseRecord of yield* phases.list(taskRecord.id, root)) {
281
+ const key = `${phaseRecord.data.repo}:${phaseRecord.data.branch}`
282
+ const owners = discoveredOwnership.get(key) ?? []
283
+ owners.push({
284
+ kind: "phase",
285
+ taskId: taskRecord.id,
286
+ phaseId: phaseRecord.id,
287
+ documentPath: phaseRecord.path,
288
+ })
289
+ discoveredOwnership.set(key, owners)
290
+ }
291
+ } else {
292
+ if (!("repo" in taskRecord.data)) continue
293
+ const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
294
+ const owners = discoveredOwnership.get(key) ?? []
236
295
  owners.push({
237
- kind: "phase",
296
+ kind: "task",
238
297
  taskId: taskRecord.id,
239
- phaseId: phaseRecord.id,
240
- documentPath: phaseRecord.path,
298
+ documentPath: taskRecord.path,
241
299
  })
242
- ownership.set(key, owners)
300
+ discoveredOwnership.set(key, owners)
243
301
  }
244
- } else {
245
- if (!("repo" in taskRecord.data)) continue
246
- const key = `${taskRecord.data.repo}:${taskRecord.data.branch}`
247
- const owners = ownership.get(key) ?? []
248
- owners.push({
249
- kind: "task",
250
- taskId: taskRecord.id,
251
- documentPath: taskRecord.path,
252
- })
253
- ownership.set(key, owners)
254
302
  }
303
+ ownership = discoveredOwnership
255
304
  }
256
305
 
257
306
  const declared: readonly (
@@ -318,19 +367,27 @@ const inspectExecution = (
318
367
  const expectedPath = exists
319
368
  ? yield* fs.realPath(checkoutPath)
320
369
  : resolve(checkoutPath)
321
- const registered = yield* backend.listWorkspaces(repositoryPath)
370
+ const registered =
371
+ context?.workspacesByRepository.get(repositoryPath) ??
372
+ (yield* backend.listWorkspaces(repositoryPath))
322
373
  const atPath = registered.find(
323
374
  (workspace) => workspace.path === expectedPath,
324
375
  )
325
376
  const actualCommit = atPath
326
- ? yield* backend.workspaceHead(checkoutPath)
377
+ ? atPath.head !== undefined
378
+ ? atPath.head
379
+ : yield* backend.workspaceHead(checkoutPath)
327
380
  : null
328
381
  const dirty =
329
- exists && atPath ? yield* backend.workspaceDirty(checkoutPath) : null
330
- const expectedCommit = yield* backend.resolveRevision(
331
- repositoryPath,
332
- requestedRef,
333
- )
382
+ exists && atPath
383
+ ? atPath.dirty !== undefined
384
+ ? atPath.dirty
385
+ : yield* backend.workspaceDirty(checkoutPath)
386
+ : null
387
+ const expectedCommit =
388
+ "branch" in checkout && context?.skipWritableRevisionResolution
389
+ ? actualCommit
390
+ : yield* backend.resolveRevision(repositoryPath, requestedRef)
334
391
 
335
392
  if (owners.length > 1) {
336
393
  conflict(
@@ -647,6 +704,95 @@ const jjWorkspaceName = (
647
704
  repo: string,
648
705
  ) => `agency-${taskId}-${phaseId ?? "task"}-${repo}`
649
706
 
707
+ const buildInspectionContext = (
708
+ startPath: string,
709
+ skipWritableRevisionResolution: boolean,
710
+ preloadedTasks?: readonly TaskRecord[],
711
+ preloadedPhasesByTask?: ReadonlyMap<string, readonly PhaseRecord[]>,
712
+ ) =>
713
+ Effect.gen(function* () {
714
+ const fs = yield* FileSystemService
715
+ const workbase = yield* WorkbaseService
716
+ const versionControl = yield* VersionControlService
717
+ const taskService = yield* TaskService
718
+ const phaseService = yield* PhaseService
719
+ const root = yield* workbase.discover(startPath)
720
+ const backend = yield* versionControl.forWorkbase(root)
721
+ const tasks = preloadedTasks ?? (yield* taskService.list(root))
722
+ const phasesByTask = new Map<string, readonly PhaseRecord[]>()
723
+ const ownership = new Map<string, WorktreeOwner[]>()
724
+ const repositoryAliases = new Set<string>()
725
+
726
+ const addExecution = (
727
+ execution:
728
+ | {
729
+ repo: string
730
+ repos?: readonly RepositoryReference[]
731
+ branch: string
732
+ }
733
+ | { review: { repo: string } },
734
+ owner: WorktreeOwner,
735
+ ) => {
736
+ if ("review" in execution) {
737
+ repositoryAliases.add(execution.review.repo)
738
+ return
739
+ }
740
+ repositoryAliases.add(execution.repo)
741
+ for (const reference of execution.repos ?? [])
742
+ repositoryAliases.add(reference.repo)
743
+ const key = `${execution.repo}:${execution.branch}`
744
+ const owners = ownership.get(key) ?? []
745
+ owners.push(owner)
746
+ ownership.set(key, owners)
747
+ }
748
+
749
+ for (const task of tasks) {
750
+ if ("phases" in task.data) {
751
+ const phaseRecords =
752
+ preloadedPhasesByTask?.get(task.id) ??
753
+ (yield* phaseService.list(task.id, root))
754
+ phasesByTask.set(task.id, phaseRecords)
755
+ for (const phase of phaseRecords) {
756
+ addExecution(phase.data, {
757
+ kind: "phase",
758
+ taskId: task.id,
759
+ phaseId: phase.id,
760
+ documentPath: phase.path,
761
+ })
762
+ }
763
+ } else {
764
+ addExecution(task.data, {
765
+ kind: "task",
766
+ taskId: task.id,
767
+ documentPath: task.path,
768
+ })
769
+ }
770
+ }
771
+
772
+ const workspacesByRepository = new Map<
773
+ string,
774
+ readonly RegisteredWorkspace[]
775
+ >()
776
+ for (const alias of repositoryAliases) {
777
+ const repositoryPath = join(root, "repos", alias)
778
+ if (!(yield* fs.exists(repositoryPath))) continue
779
+ workspacesByRepository.set(
780
+ repositoryPath,
781
+ yield* backend.listWorkspaces(repositoryPath),
782
+ )
783
+ }
784
+
785
+ return {
786
+ root,
787
+ backend,
788
+ tasks,
789
+ phasesByTask,
790
+ ownership,
791
+ workspacesByRepository,
792
+ skipWritableRevisionResolution,
793
+ } satisfies InspectionContext
794
+ })
795
+
650
796
  const materializeJj = (options: {
651
797
  readonly root: string
652
798
  readonly taskId: string
@@ -935,23 +1081,71 @@ export class WorktreeService extends Effect.Service<WorktreeService>()(
935
1081
  "WorktreeService",
936
1082
  {
937
1083
  sync: () => ({
938
- list: (startPath: string = process.cwd()) =>
1084
+ list: (startPath: string = process.cwd(), options: ListOptions = {}) =>
939
1085
  Effect.gen(function* () {
940
- const workbase = yield* WorkbaseService
941
- const tasks = yield* TaskService
942
- const phases = yield* PhaseService
943
- const root = yield* workbase.discover(startPath)
1086
+ const fs = yield* FileSystemService
1087
+ const context = yield* buildInspectionContext(
1088
+ startPath,
1089
+ options.materializedOnly === true,
1090
+ options.tasks,
1091
+ options.phasesByTask,
1092
+ )
944
1093
  const inspections: WorktreeInspection[] = []
945
- for (const task of yield* tasks.list(root)) {
1094
+ const shouldInspect = (
1095
+ documentPath: string,
1096
+ execution:
1097
+ | {
1098
+ repo: string
1099
+ repos?: readonly RepositoryReference[]
1100
+ }
1101
+ | { review: { repo: string } },
1102
+ ) =>
1103
+ Effect.gen(function* () {
1104
+ if (!options.materializedOnly) return true
1105
+ const codePath = join(dirname(documentPath), "code")
1106
+ const aliases =
1107
+ "review" in execution
1108
+ ? [execution.review.repo]
1109
+ : [
1110
+ execution.repo,
1111
+ ...(execution.repos ?? []).map(
1112
+ (reference) => reference.repo,
1113
+ ),
1114
+ ]
1115
+ for (const alias of aliases) {
1116
+ if (yield* fs.isDirectory(join(codePath, alias))) return true
1117
+ }
1118
+ return [...context.workspacesByRepository.values()].some(
1119
+ (workspaces) =>
1120
+ workspaces.some(
1121
+ (workspace) =>
1122
+ workspace.path === codePath ||
1123
+ workspace.path.startsWith(`${codePath}/`),
1124
+ ),
1125
+ )
1126
+ })
1127
+ for (const task of context.tasks) {
946
1128
  if ("phases" in task.data) {
947
- for (const phase of yield* phases.list(task.id, root)) {
1129
+ for (const phase of context.phasesByTask.get(task.id) ?? []) {
1130
+ if (!(yield* shouldInspect(phase.path, phase.data))) continue
948
1131
  inspections.push(
949
- yield* inspectExecution(task.id, phase.id, root),
1132
+ yield* inspectExecution(
1133
+ task.id,
1134
+ phase.id,
1135
+ context.root,
1136
+ context,
1137
+ ),
950
1138
  )
951
1139
  }
952
1140
  } else {
1141
+ if (!(yield* shouldInspect(task.path, task.data))) continue
953
1142
  inspections.push(
954
- yield* inspectExecution(task.id, undefined, root),
1143
+ yield* inspectExecution(
1144
+ task.id,
1145
+ undefined,
1146
+ context.root,
1147
+ context,
1148
+ ),
955
1149
  )
956
1150
  }
957
1151
  }