@markjaquith/agency 2.71.3 → 2.71.5

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.3",
3
+ "version": "2.71.5",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -21,9 +21,8 @@
21
21
  "index.ts",
22
22
  "cli.ts",
23
23
  "cli-main.ts",
24
- "src",
25
24
  "pi-extensions",
26
- "scripts/install-pi-extension.ts",
25
+ "src",
27
26
  "schemas",
28
27
  "fixtures/protocol",
29
28
  "README.md",
@@ -65,9 +64,11 @@
65
64
  "scripts": {
66
65
  "postinstall": "bun scripts/install-pi-extension.ts install",
67
66
  "preuninstall": "bun scripts/install-pi-extension.ts uninstall",
67
+ "benchmark:status": "bun scripts/benchmark-status.ts",
68
68
  "benchmark:workbase": "bun scripts/benchmark-workbase.ts",
69
69
  "benchmark:context": "bun scripts/benchmark-context.ts",
70
70
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
71
+ "benchmark:task": "bun scripts/benchmark-task.ts",
71
72
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
72
73
  "test:opencode": "AGENCY_TEST_OPENCODE=1 bun test src/cli.test.ts --test-name-pattern 'provides effective whole-workbase OpenCode access'",
73
74
  "format": "oxfmt",
@@ -360,8 +360,7 @@ export const task = (
360
360
  return
361
361
  }
362
362
  case "list": {
363
- const records = yield* tasks.list(cwd)
364
- const { taskRows } = yield* getWorkViews({
363
+ const { taskRows, tasks: taskNodes } = yield* getWorkViews({
365
364
  cwd,
366
365
  statuses: options.statuses,
367
366
  repositories: options.repositories,
@@ -369,14 +368,22 @@ export const task = (
369
368
  blocked: options.blocked,
370
369
  pr: options.pr,
371
370
  })
372
- const ordered = taskRows.flatMap((row) => {
373
- const record = records.find((item) => item.id === row.key)
374
- return record ? [record] : []
375
- })
376
371
  if (options.json) {
377
372
  log(
378
373
  JSON.stringify(
379
- ordered.map(({ content: _, ...record }) => record),
374
+ taskRows.flatMap((row) => {
375
+ const node = taskNodes.get(row.key)
376
+ if (!node?.workspace) return []
377
+ const { sha256, ...data } = node.data
378
+ return [
379
+ {
380
+ id: row.key,
381
+ path: node.workspace.documentPath,
382
+ revision: sha256,
383
+ data,
384
+ },
385
+ ]
386
+ }),
380
387
  null,
381
388
  2,
382
389
  ),
@@ -277,6 +277,13 @@ export class GraphService extends Effect.Service<GraphService>()(
277
277
  }
278
278
 
279
279
  const taskDeclarations = new Map<string, Dependency>()
280
+ const phasesByTask = new Map<string, Document<PhaseData>[]>()
281
+ for (const [key, phase] of phases) {
282
+ const taskId = key.slice(0, key.indexOf("/"))
283
+ const values = phasesByTask.get(taskId) ?? []
284
+ values.push(phase)
285
+ phasesByTask.set(taskId, values)
286
+ }
280
287
  for (const epic of epics.values()) {
281
288
  for (const declaration of epic.data.tasks) {
282
289
  taskDeclarations.set(declaration.id, declaration)
@@ -294,8 +301,14 @@ export class GraphService extends Effect.Service<GraphService>()(
294
301
  )
295
302
  : [task.data.status]
296
303
  }
297
- const taskStatus = (taskId: string) =>
298
- aggregateProgress(taskLeafStatuses(taskId)).status
304
+ const taskStatuses = new Map<string, WorkStatus>()
305
+ const taskStatus = (taskId: string) => {
306
+ const cached = taskStatuses.get(taskId)
307
+ if (cached) return cached
308
+ const status = aggregateProgress(taskLeafStatuses(taskId)).status
309
+ taskStatuses.set(taskId, status)
310
+ return status
311
+ }
299
312
  const dependencyBlockers = (
300
313
  dependencies: readonly string[],
301
314
  toId: (id: string) => string,
@@ -320,16 +333,20 @@ export class GraphService extends Effect.Service<GraphService>()(
320
333
 
321
334
  const validation =
322
335
  options.validation ?? (yield* workbase.validate(root))
336
+ const validationIssuesByPath = Map.groupBy(
337
+ validation.issues,
338
+ (issue) => issue.path,
339
+ )
323
340
  const validationBlockers = (
324
341
  paths: readonly string[],
325
342
  ): GraphBlocker[] =>
326
- validation.issues
327
- .filter((issue) => paths.includes(issue.path))
328
- .map((issue) => ({
343
+ paths.flatMap((path) =>
344
+ (validationIssuesByPath.get(path) ?? []).map((issue) => ({
329
345
  kind: "validation" as const,
330
346
  id: issue.path,
331
347
  reason: issue.message,
332
- }))
348
+ })),
349
+ )
333
350
  const uniqueBlockers = (blockers: readonly GraphBlocker[]) => [
334
351
  ...new Map(
335
352
  blockers.map((item) => [
@@ -339,7 +356,9 @@ export class GraphService extends Effect.Service<GraphService>()(
339
356
  ).values(),
340
357
  ]
341
358
 
342
- const phaseState = (taskId: string, phaseId: string) => {
359
+ type PhaseState = ReturnType<typeof createPhaseState>
360
+ const phaseStates = new Map<string, PhaseState>()
361
+ function createPhaseState(taskId: string, phaseId: string) {
343
362
  const task = tasks.get(taskId)
344
363
  const phase = phases.get(`${taskId}/${phaseId}`)
345
364
  const parentEpic = task?.data.epic
@@ -386,8 +405,18 @@ export class GraphService extends Effect.Service<GraphService>()(
386
405
  },
387
406
  }
388
407
  }
408
+ const phaseState = (taskId: string, phaseId: string) => {
409
+ const key = `${taskId}/${phaseId}`
410
+ const cached = phaseStates.get(key)
411
+ if (cached) return cached
412
+ const state = createPhaseState(taskId, phaseId)
413
+ phaseStates.set(key, state)
414
+ return state
415
+ }
389
416
 
390
- const taskState = (taskId: string) => {
417
+ type TaskState = ReturnType<typeof createTaskState>
418
+ const taskStates = new Map<string, TaskState>()
419
+ function createTaskState(taskId: string) {
391
420
  const task = tasks.get(taskId)
392
421
  const parentEpic = task?.data.epic
393
422
  ? epics.get(task.data.epic)
@@ -398,9 +427,9 @@ export class GraphService extends Effect.Service<GraphService>()(
398
427
  ? [
399
428
  relative(root, task.path),
400
429
  ...(parentEpic ? [relative(root, parentEpic.path)] : []),
401
- ...[...phases.entries()]
402
- .filter(([key]) => key.startsWith(`${taskId}/`))
403
- .map(([, phase]) => relative(root, phase.path)),
430
+ ...(phasesByTask.get(taskId) ?? []).map((phase) =>
431
+ relative(root, phase.path),
432
+ ),
404
433
  ]
405
434
  : []
406
435
  const blockers = [
@@ -461,6 +490,13 @@ export class GraphService extends Effect.Service<GraphService>()(
461
490
  },
462
491
  }
463
492
  }
493
+ const taskState = (taskId: string) => {
494
+ const cached = taskStates.get(taskId)
495
+ if (cached) return cached
496
+ const state = createTaskState(taskId)
497
+ taskStates.set(taskId, state)
498
+ return state
499
+ }
464
500
 
465
501
  const epicState = (epicId: string) => {
466
502
  const epic = epics.get(epicId)
@@ -475,9 +511,9 @@ export class GraphService extends Effect.Service<GraphService>()(
475
511
  const task = tasks.get(item.id)
476
512
  return [
477
513
  ...(task ? [relative(root, task.path)] : []),
478
- ...[...phases.entries()]
479
- .filter(([key]) => key.startsWith(`${item.id}/`))
480
- .map(([, phase]) => relative(root, phase.path)),
514
+ ...(phasesByTask.get(item.id) ?? []).map((phase) =>
515
+ relative(root, phase.path),
516
+ ),
481
517
  ]
482
518
  }),
483
519
  ]
@@ -761,6 +797,36 @@ export class GraphService extends Effect.Service<GraphService>()(
761
797
  return result
762
798
  })
763
799
 
800
+ const executionDetailsByKey = new Map<
801
+ string,
802
+ Effect.Effect.Success<ReturnType<typeof executionDetails>>
803
+ >()
804
+ const executionDocuments: readonly (readonly [
805
+ string,
806
+ string,
807
+ ExecutionData,
808
+ ])[] = [
809
+ ...[...tasks.values()].flatMap((task) =>
810
+ "phases" in task.data
811
+ ? []
812
+ : ([[task.id, task.path, task.data]] as const),
813
+ ),
814
+ ...[...phases].map(
815
+ ([key, phase]) => [key, phase.path, phase.data] as const,
816
+ ),
817
+ ]
818
+ const inspectedExecutions = yield* Effect.all(
819
+ executionDocuments.map(([key, path, data]) =>
820
+ executionDetails(path, data).pipe(
821
+ Effect.map((details) => [key, details] as const),
822
+ ),
823
+ ),
824
+ { concurrency: 16 },
825
+ )
826
+ for (const [key, details] of inspectedExecutions) {
827
+ executionDetailsByKey.set(key, details)
828
+ }
829
+
764
830
  const nodes: GraphNode[] = []
765
831
  for (const epic of epics.values()) {
766
832
  const state = epicState(epic.id)
@@ -805,7 +871,7 @@ export class GraphService extends Effect.Service<GraphService>()(
805
871
  dependents: dependents(taskNodeId(task.id)),
806
872
  repositories,
807
873
  data: { taskId: task.id, ...task.data },
808
- ...(yield* executionDetails(task.path, task.data)),
874
+ ...executionDetailsByKey.get(task.id),
809
875
  })
810
876
  }
811
877
  }
@@ -830,7 +896,7 @@ export class GraphService extends Effect.Service<GraphService>()(
830
896
  dependents: dependents(phaseNodeId(taskId, phase.id)),
831
897
  repositories,
832
898
  data: { taskId, phaseId: phase.id, ...phase.data },
833
- ...(yield* executionDetails(phase.path, phase.data)),
899
+ ...executionDetailsByKey.get(key),
834
900
  })
835
901
  }
836
902
  for (const repository of [...repositoryRecords.values()].sort(
@@ -520,62 +520,64 @@ export class RepositoryService extends Effect.Service<RepositoryService>()(
520
520
  ...Object.keys(config.repositories ?? {}),
521
521
  ...local.keys(),
522
522
  ])
523
- const repositories: RepositoryInfo[] = []
524
-
525
- for (const alias of [...aliases].sort()) {
526
- const path = join(reposPath, alias)
527
- const entry = local.get(alias)
528
- const declaredRemote = config.repositories?.[alias]?.remote ?? null
529
- if (!entry) {
530
- repositories.push({
531
- alias,
532
- path,
533
- kind: null,
534
- remote: null,
535
- declaredRemote,
536
- target: null,
537
- states: ["declared", "missing"],
538
- })
539
- continue
540
- }
541
- if (!entry.isDirectory && !entry.isSymlink) {
542
- repositories.push({
543
- alias,
544
- path,
545
- kind: null,
546
- remote: null,
547
- declaredRemote,
548
- target: null,
549
- states: [
550
- ...(declaredRemote ? (["declared"] as const) : []),
551
- "invalid",
552
- ],
553
- })
554
- continue
555
- }
556
- const target = entry.isSymlink
557
- ? yield* fs.readSymlinkTarget(path)
558
- : null
559
- const inspection = yield* backend.inspectRepository(path)
560
- const remote = inspection?.remote ?? null
561
- const states: RepositoryState[] = []
562
- if (declaredRemote) states.push("declared")
563
- states.push(entry.isSymlink ? "linked" : "materialized")
564
- if (!inspection) states.push("invalid")
565
- if (declaredRemote && remote !== declaredRemote)
566
- states.push("remote-drifted")
567
- repositories.push({
568
- alias,
569
- path,
570
- kind: entry.isSymlink
571
- ? "symlink"
572
- : (inspection?.kind ?? "repository"),
573
- remote,
574
- declaredRemote,
575
- target,
576
- states,
577
- })
578
- }
523
+ const repositories = yield* Effect.all(
524
+ [...aliases].sort().map((alias) =>
525
+ Effect.gen(function* () {
526
+ const path = join(reposPath, alias)
527
+ const entry = local.get(alias)
528
+ const declaredRemote =
529
+ config.repositories?.[alias]?.remote ?? null
530
+ if (!entry) {
531
+ return {
532
+ alias,
533
+ path,
534
+ kind: null,
535
+ remote: null,
536
+ declaredRemote,
537
+ target: null,
538
+ states: ["declared", "missing"] as RepositoryState[],
539
+ } satisfies RepositoryInfo
540
+ }
541
+ if (!entry.isDirectory && !entry.isSymlink) {
542
+ return {
543
+ alias,
544
+ path,
545
+ kind: null,
546
+ remote: null,
547
+ declaredRemote,
548
+ target: null,
549
+ states: [
550
+ ...(declaredRemote ? (["declared"] as const) : []),
551
+ "invalid",
552
+ ] as RepositoryState[],
553
+ } satisfies RepositoryInfo
554
+ }
555
+ const target = entry.isSymlink
556
+ ? yield* fs.readSymlinkTarget(path)
557
+ : null
558
+ const inspection = yield* backend.inspectRepository(path)
559
+ const remote = inspection?.remote ?? null
560
+ const states: RepositoryState[] = []
561
+ if (declaredRemote) states.push("declared")
562
+ states.push(entry.isSymlink ? "linked" : "materialized")
563
+ if (!inspection) states.push("invalid")
564
+ if (declaredRemote && remote !== declaredRemote)
565
+ states.push("remote-drifted")
566
+ return {
567
+ alias,
568
+ path,
569
+ kind: entry.isSymlink
570
+ ? "symlink"
571
+ : (inspection?.kind ?? "repository"),
572
+ remote,
573
+ declaredRemote,
574
+ target,
575
+ states,
576
+ } satisfies RepositoryInfo
577
+ }),
578
+ ),
579
+ { concurrency: 4 },
580
+ )
579
581
  return repositories
580
582
  }),
581
583
 
package/src/work-view.ts CHANGED
@@ -211,27 +211,34 @@ export const getWorkViews = (options: WorkViewOptions = {}) =>
211
211
  (node): node is ExecutionNode => node.kind === "execution-unit",
212
212
  )
213
213
  const taskOrder = orderedTasks(epics, tasks)
214
- const phaseOrder = taskOrder.flatMap((task) => orderedPhases(task, phases))
214
+ const phasesByTask = new Map(
215
+ taskOrder.map((task) => [task.key, orderedPhases(task, phases)]),
216
+ )
217
+ const phaseOrder = taskOrder.flatMap(
218
+ (task) => phasesByTask.get(task.key) ?? [],
219
+ )
220
+ const executionsByTask = new Map<string, ExecutionNode[]>()
221
+ const executionsByPhase = new Map<string, ExecutionNode[]>()
222
+ for (const execution of executions) {
223
+ const taskExecutions = executionsByTask.get(execution.data.taskId) ?? []
224
+ taskExecutions.push(execution)
225
+ executionsByTask.set(execution.data.taskId, taskExecutions)
226
+ if ("phaseId" in execution.data) {
227
+ const key = `${execution.data.taskId}/${execution.data.phaseId}`
228
+ const phaseExecutions = executionsByPhase.get(key) ?? []
229
+ phaseExecutions.push(execution)
230
+ executionsByPhase.set(key, phaseExecutions)
231
+ }
232
+ }
215
233
  const executionsFor = (node: EntityNode) => {
216
234
  if (node.kind === "phase") {
217
- const separator = node.key.indexOf("/")
218
- const taskId = node.key.slice(0, separator)
219
- const phaseId = node.key.slice(separator + 1)
220
- return executions.filter(
221
- (execution) =>
222
- execution.data.taskId === taskId &&
223
- "phaseId" in execution.data &&
224
- execution.data.phaseId === phaseId,
225
- )
235
+ return executionsByPhase.get(node.key) ?? []
226
236
  }
227
237
  if (node.kind === "task") {
228
- return executions.filter(
229
- (execution) => execution.data.taskId === node.key,
230
- )
238
+ return executionsByTask.get(node.key) ?? []
231
239
  }
232
- const taskIds = new Set(node.data.tasks.map((item) => item.id))
233
- return executions.filter((execution) =>
234
- taskIds.has(execution.data.taskId),
240
+ return node.data.tasks.flatMap(
241
+ (item) => executionsByTask.get(item.id) ?? [],
235
242
  )
236
243
  }
237
244
  const makeRows = (nodes: readonly EntityNode[]) =>
@@ -257,10 +264,10 @@ export const getWorkViews = (options: WorkViewOptions = {}) =>
257
264
  const phaseRows = filterRows(makeRows(phaseOrder))
258
265
  const executionEntities: EntityNode[] = []
259
266
  for (const task of taskOrder) {
260
- const taskPhases = orderedPhases(task, phases)
267
+ const taskPhases = phasesByTask.get(task.key) ?? []
261
268
  executionEntities.push(...(taskPhases.length > 0 ? taskPhases : [task]))
262
269
  }
263
270
  const executionRows = filterRows(makeRows(executionEntities))
264
271
 
265
- return { epicRows, taskRows, phaseRows, executionRows }
272
+ return { epicRows, taskRows, phaseRows, executionRows, tasks }
266
273
  })
@@ -1,25 +0,0 @@
1
- import { cp, mkdir, rm } from "node:fs/promises"
2
- import { homedir } from "node:os"
3
- import { dirname, join } from "node:path"
4
-
5
- export const piExtensionPath = (home = homedir()) =>
6
- join(home, ".pi", "agent", "extensions", "agency.ts")
7
-
8
- export const installPiExtension = async (
9
- source = join(import.meta.dir, "..", "pi-extensions", "agency.ts"),
10
- destination = piExtensionPath(),
11
- ) => {
12
- await mkdir(dirname(destination), { recursive: true })
13
- await cp(source, destination)
14
- }
15
-
16
- export const uninstallPiExtension = async (destination = piExtensionPath()) => {
17
- await rm(destination, { force: true })
18
- }
19
-
20
- if (import.meta.main) {
21
- const command = process.argv[2] ?? "install"
22
- if (command === "install") await installPiExtension()
23
- else if (command === "uninstall") await uninstallPiExtension()
24
- else throw new Error(`Unknown Pi extension lifecycle command: ${command}`)
25
- }