@markjaquith/agency 2.71.11 → 2.71.13

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.11",
3
+ "version": "2.71.13",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -64,11 +64,14 @@
64
64
  "scripts": {
65
65
  "postinstall": "bun scripts/install-pi-extension.ts install",
66
66
  "preuninstall": "bun scripts/install-pi-extension.ts uninstall",
67
+ "benchmark:act": "bun scripts/benchmark-act.ts",
67
68
  "benchmark:pr": "bun scripts/benchmark-pr.ts",
68
69
  "benchmark:status": "bun scripts/benchmark-status.ts",
70
+ "benchmark:init": "bun scripts/benchmark-init.ts",
69
71
  "benchmark:workbase": "bun scripts/benchmark-workbase.ts",
70
72
  "benchmark:doctor": "bun scripts/benchmark-doctor.ts",
71
73
  "benchmark:context": "bun scripts/benchmark-context.ts",
74
+ "benchmark:claim": "bun scripts/benchmark-claim.ts",
72
75
  "benchmark:finish": "bun scripts/benchmark-finish.ts",
73
76
  "benchmark:push": "bun scripts/benchmark-push.ts",
74
77
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
@@ -111,31 +111,24 @@ const activeClaim = (node: EntityNode) =>
111
111
 
112
112
  const executionNode = (
113
113
  node: EntityNode,
114
- nodes: readonly GraphNode[],
114
+ executions: ReadonlyMap<
115
+ string,
116
+ Extract<GraphNode, { readonly kind: "execution-unit" }>
117
+ >,
115
118
  ): Extract<GraphNode, { readonly kind: "execution-unit" }> | undefined => {
116
119
  if (node.kind === "epic") return undefined
117
120
  if (node.kind === "task" && "phases" in node.data) return undefined
118
- const phaseId =
119
- node.kind === "phase"
120
- ? node.key.slice(node.key.indexOf("/") + 1)
121
- : undefined
122
- return nodes.find(
123
- (
124
- candidate,
125
- ): candidate is Extract<GraphNode, { readonly kind: "execution-unit" }> =>
126
- candidate.kind === "execution-unit" &&
127
- candidate.data.taskId ===
128
- (node.kind === "task" ? node.key : node.key.split("/", 1)[0]) &&
129
- (node.kind === "task" ||
130
- ("phaseId" in candidate.data && candidate.data.phaseId === phaseId)),
131
- )
121
+ return executions.get(node.id)
132
122
  }
133
123
 
134
- const canWork = (node: EntityNode, nodes: readonly GraphNode[]) => {
124
+ const canWork = (
125
+ node: EntityNode,
126
+ executions: Parameters<typeof executionNode>[1],
127
+ ) => {
135
128
  if (node.kind === "epic" || (node.kind === "task" && "phases" in node.data)) {
136
129
  return node.readiness.ready
137
130
  }
138
- const execution = executionNode(node, nodes)
131
+ const execution = executionNode(node, executions)
139
132
  return Boolean(
140
133
  execution &&
141
134
  !activeClaim(node) &&
@@ -147,8 +140,11 @@ const canWork = (node: EntityNode, nodes: readonly GraphNode[]) => {
147
140
  )
148
141
  }
149
142
 
150
- const canCreatePr = (node: EntityNode, nodes: readonly GraphNode[]) => {
151
- const execution = executionNode(node, nodes)
143
+ const canCreatePr = (
144
+ node: EntityNode,
145
+ executions: Parameters<typeof executionNode>[1],
146
+ ) => {
147
+ const execution = executionNode(node, executions)
152
148
  return Boolean(
153
149
  execution &&
154
150
  !execution.readiness.terminal &&
@@ -162,14 +158,14 @@ const canCreatePr = (node: EntityNode, nodes: readonly GraphNode[]) => {
162
158
 
163
159
  const actionChoices = (
164
160
  node: EntityNode,
165
- nodes: readonly GraphNode[],
161
+ executions: Parameters<typeof executionNode>[1],
166
162
  ): readonly Choice<ActAction>[] => {
167
163
  const choices: Choice<ActAction>[] = []
168
- const execution = executionNode(node, nodes)
169
- if (canWork(node, nodes)) {
164
+ const execution = executionNode(node, executions)
165
+ if (canWork(node, executions)) {
170
166
  choices.push({ key: "work", label: "Work on this item", value: "work" })
171
167
  }
172
- if (canCreatePr(node, nodes)) {
168
+ if (canCreatePr(node, executions)) {
173
169
  choices.push({ key: "pr", label: "Create pull request", value: "pr" })
174
170
  }
175
171
  if (execution && isTerminalStatus(node.status) && !activeClaim(node)) {
@@ -243,7 +239,7 @@ const shellCommand = (command: readonly string[]) =>
243
239
 
244
240
  const targetOutput = (
245
241
  node: EntityNode,
246
- nodes: readonly GraphNode[],
242
+ executions: Parameters<typeof executionNode>[1],
247
243
  options: Pick<ActOptions, "auto" | "draft">,
248
244
  ) => ({
249
245
  kind: node.kind,
@@ -252,13 +248,29 @@ const targetOutput = (
252
248
  status: node.status,
253
249
  readiness: node.readiness,
254
250
  revision: node.data.sha256,
255
- actions: actionChoices(node, nodes).map((choice) => ({
251
+ actions: actionChoices(node, executions).map((choice) => ({
256
252
  id: choice.value,
257
253
  label: choice.label,
258
254
  command: actionCommand(node, choice.value, options),
259
255
  })),
260
256
  })
261
257
 
258
+ const executionNodes = (nodes: readonly GraphNode[]) => {
259
+ const executions = new Map<
260
+ string,
261
+ Extract<GraphNode, { readonly kind: "execution-unit" }>
262
+ >()
263
+ for (const node of nodes) {
264
+ if (node.kind !== "execution-unit") continue
265
+ const key =
266
+ "phaseId" in node.data
267
+ ? `phase:${node.data.taskId}/${node.data.phaseId}`
268
+ : `task:${node.data.taskId}`
269
+ executions.set(key, node)
270
+ }
271
+ return executions
272
+ }
273
+
262
274
  const selectedEntityKey = (options: ActOptions) =>
263
275
  options.epicId
264
276
  ? `epic:${options.epicId}`
@@ -321,7 +333,11 @@ export const act = (
321
333
  : false
322
334
  const startPath = isDirectory && directoryPath ? directoryPath : cwd
323
335
  const { root, config } = yield* workbase.loadConfig(startPath)
324
- const graph = yield* graphs.get({ cwd: root })
336
+ const graph = yield* graphs.get({
337
+ cwd: root,
338
+ loadedConfig: { root, config },
339
+ })
340
+ const executions = executionNodes(graph.nodes)
325
341
  const nodes = graph.nodes.filter(
326
342
  (node): node is EntityNode =>
327
343
  node.kind === "epic" || node.kind === "task" || node.kind === "phase",
@@ -352,7 +368,7 @@ export const act = (
352
368
  JSON.stringify(
353
369
  {
354
370
  targets: matchingNodes.map((node) =>
355
- targetOutput(node, graph.nodes, options),
371
+ targetOutput(node, executions, options),
356
372
  ),
357
373
  },
358
374
  null,
@@ -363,7 +379,7 @@ export const act = (
363
379
  }
364
380
 
365
381
  const selectableNodes = nodes.filter(
366
- (node) => actionChoices(node, graph.nodes).length > 0,
382
+ (node) => actionChoices(node, executions).length > 0,
367
383
  )
368
384
  if (!requestedKey && selectableNodes.length === 0) {
369
385
  return yield* Effect.fail(
@@ -386,7 +402,7 @@ export const act = (
386
402
  new Error("Selected work item is no longer available"),
387
403
  )
388
404
  }
389
- const offeredActions = actionChoices(selected, graph.nodes)
405
+ const offeredActions = actionChoices(selected, executions)
390
406
  if (offeredActions.length === 0) {
391
407
  return yield* Effect.fail(
392
408
  new Error(
@@ -402,6 +418,7 @@ export const act = (
402
418
  if (action === null) return
403
419
 
404
420
  const refreshed = yield* graphs.get({ cwd })
421
+ const refreshedExecutions = executionNodes(refreshed.nodes)
405
422
  const current = refreshed.nodes.find(
406
423
  (node): node is EntityNode =>
407
424
  (node.kind === "epic" ||
@@ -418,7 +435,7 @@ export const act = (
418
435
  }
419
436
  if (
420
437
  current.data.sha256 !== selected.data.sha256 ||
421
- !sameActions(offeredActions, actionChoices(current, refreshed.nodes))
438
+ !sameActions(offeredActions, actionChoices(current, refreshedExecutions))
422
439
  ) {
423
440
  return yield* Effect.fail(
424
441
  new Error("Selected work item changed; run agency act again"),
@@ -1,6 +1,6 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
2
  import { Effect } from "effect"
3
- import { mkdir } from "node:fs/promises"
3
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
4
4
  import { join } from "node:path"
5
5
  import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
6
  import { ClaimService } from "./ClaimService"
@@ -358,4 +358,59 @@ describe("claim service", () => {
358
358
  )
359
359
  expect(acquired.target).toBe("phase 'multi/implementation'")
360
360
  })
361
+
362
+ test("inspects only the requested task on large workbases", async () => {
363
+ for (let index = 0; index < 100; index += 1) {
364
+ const taskRoot = join(root, "tasks", `unrelated-${index}`)
365
+ await mkdir(taskRoot, { recursive: true })
366
+ await writeFile(join(taskRoot, "TASK.md"), "not valid frontmatter\n")
367
+ }
368
+
369
+ const inspected = await inspect()
370
+ expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
371
+ expect(inspected.data.status).toBe("open")
372
+ })
373
+
374
+ test("inspects only the requested phase", async () => {
375
+ await runTestEffect(
376
+ TaskService.pipe(
377
+ Effect.flatMap((service) =>
378
+ service.create(
379
+ { id: "multi", ticketUrl: null, multiPhase: true },
380
+ root,
381
+ ),
382
+ ),
383
+ ),
384
+ )
385
+ await runTestEffect(
386
+ PhaseService.pipe(
387
+ Effect.flatMap((service) =>
388
+ service.create(
389
+ {
390
+ taskId: "multi",
391
+ id: "requested",
392
+ repo: "agency",
393
+ branch: "task/multi",
394
+ base: "main",
395
+ },
396
+ root,
397
+ ),
398
+ ),
399
+ ),
400
+ )
401
+ const taskPath = join(root, "tasks/multi/TASK.md")
402
+ await writeFile(
403
+ taskPath,
404
+ (await readFile(taskPath, "utf8")).replace("multi", "["),
405
+ )
406
+ const unrelatedPhase = join(root, "tasks/multi/phases/unrelated")
407
+ await mkdir(unrelatedPhase, { recursive: true })
408
+ await writeFile(join(unrelatedPhase, "PHASE.md"), "not valid frontmatter\n")
409
+
410
+ const inspected = await inspect("multi", "requested")
411
+ expect(inspected.target.path).toBe(
412
+ join(root, "tasks/multi/phases/requested/PHASE.md"),
413
+ )
414
+ expect(inspected.data.status).toBe("open")
415
+ })
361
416
  })
@@ -6,6 +6,7 @@ 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
10
 
10
11
  const write = async (root: string, path: string, content: string) => {
11
12
  const fullPath = join(root, path)
@@ -204,6 +205,27 @@ describe("GraphService", () => {
204
205
  expect(await getGraph(root)).toEqual(graph)
205
206
  })
206
207
 
208
+ test("does not resolve a VCS backend when git details are not requested", async () => {
209
+ const root = await createWorkbase()
210
+ roots.push(root)
211
+ let calls = 0
212
+ const graph = await runTestEffect(
213
+ GraphService.pipe(
214
+ Effect.flatMap((service) => service.get({ cwd: root })),
215
+ Effect.provideService(VersionControlService, {
216
+ _tag: "VersionControlService",
217
+ forWorkbase: () => {
218
+ calls += 1
219
+ throw new Error("unexpected VCS lookup")
220
+ },
221
+ }),
222
+ ),
223
+ )
224
+
225
+ expect(graph.nodes.length).toBeGreaterThan(0)
226
+ expect(calls).toBe(0)
227
+ })
228
+
207
229
  test("never reports claimed or terminal execution units as ready", async () => {
208
230
  const root = await createWorkbase()
209
231
  roots.push(root)
@@ -30,6 +30,7 @@ import {
30
30
  type EpicFrontmatter as EpicData,
31
31
  type PhaseFrontmatter as PhaseData,
32
32
  type TaskFrontmatter as TaskData,
33
+ type WorkbaseConfig as WorkbaseConfigData,
33
34
  type WorkStatus,
34
35
  } from "../workbase/schemas"
35
36
  import { FileSystemService } from "./FileSystemService"
@@ -65,6 +66,10 @@ type ExecutionData =
65
66
 
66
67
  export interface GraphOptions {
67
68
  readonly cwd?: string
69
+ readonly loadedConfig?: {
70
+ readonly root: string
71
+ readonly config: WorkbaseConfigData
72
+ }
68
73
  readonly validation?: ValidationReport
69
74
  readonly ready?: boolean
70
75
  readonly blocked?: boolean
@@ -131,14 +136,16 @@ export class GraphService extends Effect.Service<GraphService>()(
131
136
  Effect.gen(function* () {
132
137
  const fs = yield* FileSystemService
133
138
  const workbase = yield* WorkbaseService
134
- const { root, config } = yield* workbase.loadConfig(options.cwd)
135
- const backend =
136
- options.backend ??
137
- (yield* VersionControlService.pipe(
138
- Effect.flatMap((service) => service.forWorkbase(root)),
139
- ))
139
+ const { root, config } =
140
+ options.loadedConfig ?? (yield* workbase.loadConfig(options.cwd))
140
141
  const includes = [...new Set(options.include ?? [])].sort()
141
142
  const include = new Set(includes)
143
+ const backend = include.has("git")
144
+ ? (options.backend ??
145
+ (yield* VersionControlService.pipe(
146
+ Effect.flatMap((service) => service.forWorkbase(root)),
147
+ )))
148
+ : options.backend
142
149
  const epics = new Map<string, Document<EpicData>>()
143
150
  const tasks = new Map<string, Document<TaskData>>()
144
151
  const phases = new Map<string, Document<PhaseData>>()
@@ -681,6 +688,7 @@ export class GraphService extends Effect.Service<GraphService>()(
681
688
 
682
689
  const inspectGit = (path: string) =>
683
690
  Effect.gen(function* () {
691
+ if (!backend) return undefined
684
692
  const inspection = yield* backend.inspectRepository(path)
685
693
  const workspaces = inspection
686
694
  ? yield* backend
@@ -718,6 +726,7 @@ export class GraphService extends Effect.Service<GraphService>()(
718
726
  }
719
727
  }
720
728
  if (include.has("git")) {
729
+ if (!backend) return result
721
730
  const remote = yield* backend.remoteUrl(
722
731
  repositoryPath,
723
732
  "origin",
@@ -240,13 +240,18 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
240
240
  }
241
241
 
242
242
  yield* fs.createDirectory(root)
243
- yield* fs.writeJSON(configPath, {
244
- version: 2,
245
- vcs: preferredVersionControl(),
246
- })
247
- for (const directory of ["repos", "epics", "tasks"]) {
248
- yield* fs.createDirectory(join(root, directory))
249
- }
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
+ )
250
255
 
251
256
  const ignorePath = join(root, ".gitignore")
252
257
  const requiredPatterns = [