@markjaquith/agency 2.71.17 → 2.71.19

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.17",
3
+ "version": "2.71.19",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -75,7 +75,9 @@
75
75
  "benchmark:context": "bun scripts/benchmark-context.ts",
76
76
  "benchmark:claim": "bun scripts/benchmark-claim.ts",
77
77
  "benchmark:finish": "bun scripts/benchmark-finish.ts",
78
+ "benchmark:epic": "bun scripts/benchmark-epic.ts",
78
79
  "benchmark:phase": "bun scripts/benchmark-phase.ts",
80
+ "benchmark:graph": "bun scripts/benchmark-graph.ts",
79
81
  "benchmark:push": "bun scripts/benchmark-push.ts",
80
82
  "benchmark:archive": "bun scripts/benchmark-archive.ts",
81
83
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
@@ -111,6 +111,35 @@ describe("epic command", () => {
111
111
  })
112
112
  })
113
113
 
114
+ test("reads each epic document once when listing JSON", async () => {
115
+ await runTestEffect(
116
+ epic({
117
+ subcommand: "create",
118
+ args: ["example"],
119
+ ticketUrl: "https://example.com/epic",
120
+ repos: ["agency:main"],
121
+ cwd: root,
122
+ silent: true,
123
+ }),
124
+ )
125
+ const original = Bun.file
126
+ let reads = 0
127
+ Bun.file = ((path: Parameters<typeof Bun.file>[0], ...args: never[]) => {
128
+ if (String(path).endsWith("/epics/example/EPIC.md")) reads += 1
129
+ return original(path as unknown as string, ...args)
130
+ }) as typeof Bun.file
131
+ try {
132
+ await captureLogs(() =>
133
+ runTestEffect(
134
+ epic({ subcommand: "list", args: [], cwd: root, json: true }),
135
+ ),
136
+ )
137
+ } finally {
138
+ Bun.file = original
139
+ }
140
+ expect(reads).toBe(2)
141
+ })
142
+
114
143
  test("renders a readable operational table", async () => {
115
144
  await runTestEffect(
116
145
  epic({
@@ -1,4 +1,5 @@
1
1
  import { Effect } from "effect"
2
+ import { join } from "node:path"
2
3
  import type { BaseCommandOptions } from "../utils/command"
3
4
  import { EpicService } from "../services/EpicService"
4
5
  import { createLoggers } from "../utils/effect"
@@ -71,7 +72,6 @@ export const epic = (options: EpicOptions, work: StartWork = startWork) =>
71
72
  }
72
73
 
73
74
  case "list": {
74
- const records = yield* epics.list(cwd)
75
75
  const { epicRows } = yield* getWorkViews({
76
76
  cwd,
77
77
  statuses: options.statuses,
@@ -80,14 +80,15 @@ export const epic = (options: EpicOptions, work: StartWork = startWork) =>
80
80
  blocked: options.blocked,
81
81
  pr: options.pr,
82
82
  })
83
- const ordered = epicRows.flatMap((row) => {
84
- const record = records.find((item) => item.id === row.key)
85
- return record ? [record] : []
86
- })
87
83
  if (options.json) {
88
84
  log(
89
85
  JSON.stringify(
90
- ordered.map(({ content: _, ...record }) => record),
86
+ epicRows.map((row) => ({
87
+ id: row.id,
88
+ path: join(cwd, "epics", row.id, "EPIC.md"),
89
+ revision: row.revision,
90
+ data: row.data,
91
+ })),
91
92
  null,
92
93
  2,
93
94
  ),
package/src/readiness.ts CHANGED
@@ -24,23 +24,27 @@ export const canTransitionStatus = (from: WorkStatus, to: WorkStatus) =>
24
24
  export const aggregateProgress = (statuses: readonly WorkStatus[]) => {
25
25
  const counts = {
26
26
  total: statuses.length,
27
- open: statuses.filter((status) => status === "open").length,
28
- working: statuses.filter((status) => status === "working").length,
29
- delegated: statuses.filter((status) => status === "delegated").length,
30
- done: statuses.filter((status) => status === "done").length,
31
- dropped: statuses.filter((status) => status === "dropped").length,
32
- terminal: statuses.filter(isTerminalStatus).length,
27
+ open: 0,
28
+ working: 0,
29
+ delegated: 0,
30
+ done: 0,
31
+ dropped: 0,
32
+ terminal: 0,
33
+ }
34
+ for (const status of statuses) {
35
+ counts[status] += 1
36
+ if (isTerminalStatus(status)) counts.terminal += 1
33
37
  }
34
38
  const status: WorkStatus =
35
- statuses.length === 0
39
+ counts.total === 0
36
40
  ? "open"
37
- : statuses.every((value) => value === "done")
41
+ : counts.done === counts.total
38
42
  ? "done"
39
- : statuses.every(isTerminalStatus)
43
+ : counts.terminal === counts.total
40
44
  ? "dropped"
41
- : statuses.includes("working")
45
+ : counts.working > 0
42
46
  ? "working"
43
- : statuses.includes("delegated")
47
+ : counts.delegated > 0
44
48
  ? "delegated"
45
49
  : "open"
46
50
  return { status, ...counts }
@@ -6,7 +6,10 @@ import { dirname, join } from "node:path"
6
6
  import { AgencyGraph } from "../graph-schema"
7
7
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
8
8
  import { GraphService } from "./GraphService"
9
- import { VersionControlService } from "./VersionControlService"
9
+ import {
10
+ VersionControlService,
11
+ type VersionControlBackend,
12
+ } from "./VersionControlService"
10
13
 
11
14
  const write = async (root: string, path: string, content: string) => {
12
15
  const fullPath = join(root, path)
@@ -364,6 +367,15 @@ status: open
364
367
 
365
368
  const detailed = await getGraph(root, {
366
369
  include: ["bodies", "workspace", "git", "pr"],
370
+ backend: {
371
+ kind: "git",
372
+ inspectRepository: () => Effect.succeed(null),
373
+ listWorkspaces: () => Effect.succeed([]),
374
+ resolveRevision: () => Effect.succeed(null),
375
+ workspaceHead: () => Effect.succeed(null),
376
+ workspaceDirty: () => Effect.succeed(null),
377
+ remoteUrl: () => Effect.succeed(null),
378
+ } as unknown as VersionControlBackend,
367
379
  })
368
380
  expect(detailed.includes).toEqual(["bodies", "git", "pr", "workspace"])
369
381
  expect(detailed.workbase.root).toBe(root)
@@ -386,4 +398,51 @@ status: open
386
398
  expect(execution?.pr).toEqual({ url: null, state: "none" })
387
399
  expect(Schema.decodeUnknownSync(AgencyGraph)(detailed)).toEqual(detailed)
388
400
  })
401
+
402
+ test("reuses repository metadata and revision lookups for git details", async () => {
403
+ const root = await createWorkbase()
404
+ roots.push(root)
405
+ const calls = {
406
+ listWorkspaces: 0,
407
+ remoteUrl: 0,
408
+ resolveRevision: new Map<string, number>(),
409
+ }
410
+ const backend = {
411
+ kind: "git",
412
+ inspectRepository: () => Effect.succeed(null),
413
+ listWorkspaces: () =>
414
+ Effect.sync(() => {
415
+ calls.listWorkspaces += 1
416
+ return []
417
+ }),
418
+ remoteUrl: () =>
419
+ Effect.sync(() => {
420
+ calls.remoteUrl += 1
421
+ return null
422
+ }),
423
+ resolveRevision: (_path: string, revision: string) =>
424
+ Effect.sync(() => {
425
+ calls.resolveRevision.set(
426
+ revision,
427
+ (calls.resolveRevision.get(revision) ?? 0) + 1,
428
+ )
429
+ return revision
430
+ }),
431
+ workspaceHead: () => Effect.succeed(null),
432
+ workspaceDirty: () => Effect.succeed(null),
433
+ } as unknown as VersionControlBackend
434
+
435
+ await getGraph(root, { include: ["git"], backend })
436
+
437
+ expect(calls.listWorkspaces).toBe(0)
438
+ expect(calls.remoteUrl).toBe(1)
439
+ expect(calls.resolveRevision).toEqual(
440
+ new Map([
441
+ ["feat/prepare", 1],
442
+ ["feat/implement", 1],
443
+ ["feat/verify", 1],
444
+ ["main", 1],
445
+ ]),
446
+ )
447
+ })
389
448
  })
@@ -298,22 +298,28 @@ export class GraphService extends Effect.Service<GraphService>()(
298
298
  }
299
299
  const taskDependencies = (taskId: string) =>
300
300
  taskDeclarations.get(taskId)?.dependsOn ?? []
301
+ const taskLeafStatusCache = new Map<string, WorkStatus[]>()
301
302
  const taskLeafStatuses = (taskId: string): WorkStatus[] => {
303
+ const cached = taskLeafStatusCache.get(taskId)
304
+ if (cached) return cached
302
305
  const task = tasks.get(taskId)
303
306
  if (!task) return []
304
- return "phases" in task.data
305
- ? task.data.phases.map(
306
- (item) =>
307
- phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
308
- )
309
- : [task.data.status]
307
+ const statuses =
308
+ "phases" in task.data
309
+ ? task.data.phases.map(
310
+ (item) =>
311
+ phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
312
+ )
313
+ : [task.data.status]
314
+ taskLeafStatusCache.set(taskId, statuses)
315
+ return statuses
310
316
  }
311
- const taskStatuses = new Map<string, WorkStatus>()
317
+ const taskStatusCache = new Map<string, WorkStatus>()
312
318
  const taskStatus = (taskId: string) => {
313
- const cached = taskStatuses.get(taskId)
319
+ const cached = taskStatusCache.get(taskId)
314
320
  if (cached) return cached
315
321
  const status = aggregateProgress(taskLeafStatuses(taskId)).status
316
- taskStatuses.set(taskId, status)
322
+ taskStatusCache.set(taskId, status)
317
323
  return status
318
324
  }
319
325
  const dependencyBlockers = (
@@ -668,6 +674,40 @@ export class GraphService extends Effect.Service<GraphService>()(
668
674
  }
669
675
  const dependents = (id: string) =>
670
676
  [...(reverseDependencies.get(id) ?? [])].sort()
677
+ const repositoryWorkspaces = new Map<
678
+ string,
679
+ ReturnType<VersionControlBackend["listWorkspaces"]>
680
+ >()
681
+ const listWorkspaces = (path: string) => {
682
+ if (!backend) return Effect.succeed([])
683
+ const cached = repositoryWorkspaces.get(path)
684
+ if (cached) return cached
685
+ const workspaces = backend
686
+ .listWorkspaces(path)
687
+ .pipe(Effect.catchAll(() => Effect.succeed([])))
688
+ repositoryWorkspaces.set(path, workspaces)
689
+ return workspaces
690
+ }
691
+ const repositoryRemotes = new Map<string, string | null>()
692
+ const remoteUrl = (path: string, remote: string) =>
693
+ Effect.gen(function* () {
694
+ if (!backend) return null
695
+ const key = `${path}\u0000${remote}`
696
+ if (repositoryRemotes.has(key)) return repositoryRemotes.get(key)!
697
+ const url = yield* backend.remoteUrl(path, remote)
698
+ repositoryRemotes.set(key, url)
699
+ return url
700
+ })
701
+ const resolvedRevisions = new Map<string, string | null>()
702
+ const resolveRevision = (path: string, revision: string) =>
703
+ Effect.gen(function* () {
704
+ if (!backend) return null
705
+ const key = `${path}\u0000${revision}`
706
+ if (resolvedRevisions.has(key)) return resolvedRevisions.get(key)!
707
+ const resolved = yield* backend.resolveRevision(path, revision)
708
+ resolvedRevisions.set(key, resolved)
709
+ return resolved
710
+ })
671
711
 
672
712
  const documentDetails = <T extends Record<string, unknown>>(
673
713
  document: Document<T>,
@@ -691,11 +731,7 @@ export class GraphService extends Effect.Service<GraphService>()(
691
731
  Effect.gen(function* () {
692
732
  if (!backend) return undefined
693
733
  const inspection = yield* backend.inspectRepository(path)
694
- const workspaces = inspection
695
- ? yield* backend
696
- .listWorkspaces(path)
697
- .pipe(Effect.catchAll(() => Effect.succeed([])))
698
- : []
734
+ const workspaces = inspection ? yield* listWorkspaces(path) : []
699
735
  const primary = workspaces.find(
700
736
  (workspace) => workspace.path === path,
701
737
  )
@@ -728,15 +764,12 @@ export class GraphService extends Effect.Service<GraphService>()(
728
764
  }
729
765
  if (include.has("git")) {
730
766
  if (!backend) return result
731
- const remote = yield* backend.remoteUrl(
732
- repositoryPath,
733
- "origin",
734
- )
767
+ const remote = yield* remoteUrl(repositoryPath, "origin")
735
768
  const canonicalCheckoutPath = materialized
736
769
  ? yield* fs.realPath(checkoutPath)
737
770
  : checkoutPath
738
771
  const actualBranch = materialized
739
- ? yield* backend.listWorkspaces(repositoryPath).pipe(
772
+ ? yield* listWorkspaces(repositoryPath).pipe(
740
773
  Effect.map(
741
774
  (workspaces) =>
742
775
  workspaces
@@ -777,11 +810,11 @@ export class GraphService extends Effect.Service<GraphService>()(
777
810
  : {
778
811
  branch: data.branch,
779
812
  base: data.base,
780
- branchCommit: yield* backend.resolveRevision(
813
+ branchCommit: yield* resolveRevision(
781
814
  repositoryPath,
782
815
  data.branch,
783
816
  ),
784
- baseCommit: yield* backend.resolveRevision(
817
+ baseCommit: yield* resolveRevision(
785
818
  repositoryPath,
786
819
  data.base,
787
820
  ),
package/src/work-view.ts CHANGED
@@ -34,6 +34,7 @@ export interface WorkViewRow {
34
34
  readonly pr: string
35
35
  readonly worktree: string
36
36
  readonly hasPr: boolean
37
+ readonly data: Readonly<Record<string, unknown>>
37
38
  }
38
39
 
39
40
  const allowedStatuses = new Set<WorkStatus>([
@@ -122,6 +123,9 @@ const rowFor = (
122
123
  hasPr: executions.some(
123
124
  (execution) => "pr" in execution.data && Boolean(execution.data.pr),
124
125
  ),
126
+ data: Object.fromEntries(
127
+ Object.entries(node.data).filter(([key]) => key !== "sha256"),
128
+ ),
125
129
  }
126
130
  }
127
131