@markjaquith/agency 2.71.4 → 2.71.6

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.4",
3
+ "version": "2.71.6",
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,7 +64,9 @@
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
+ "benchmark:doctor": "bun scripts/benchmark-doctor.ts",
69
70
  "benchmark:context": "bun scripts/benchmark-context.ts",
70
71
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
71
72
  "benchmark:task": "bun scripts/benchmark-task.ts",
@@ -100,10 +100,6 @@ status: open
100
100
  id: "ref.agency.main",
101
101
  status: "pass",
102
102
  }),
103
- expect.objectContaining({
104
- id: "worktree.task.example",
105
- status: "pass",
106
- }),
107
103
  ]),
108
104
  )
109
105
  for (const check of report.checks) {
@@ -2,12 +2,11 @@ import { Effect, Either } from "effect"
2
2
  import { constants } from "node:fs"
3
3
  import { access } from "node:fs/promises"
4
4
  import { isAbsolute, join, resolve } from "node:path"
5
- import { EpicService } from "./EpicService"
6
5
  import { FileSystemService } from "./FileSystemService"
7
6
  import { IntegrationService } from "./IntegrationService"
8
- import { PhaseService } from "./PhaseService"
7
+ import type { PhaseRecord } from "./PhaseService"
9
8
  import { RepositoryService } from "./RepositoryService"
10
- import { TaskService } from "./TaskService"
9
+ import type { TaskRecord } from "./TaskService"
11
10
  import { WorkbaseService } from "./WorkbaseService"
12
11
  import { WorktreeService } from "./WorktreeService"
13
12
  import { VersionControlService } from "./VersionControlService"
@@ -80,12 +79,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
80
79
  sync: () => ({
81
80
  inspect: (startPath: string = process.cwd()) =>
82
81
  Effect.gen(function* () {
83
- const epics = yield* EpicService
84
82
  const fs = yield* FileSystemService
85
83
  const integrations = yield* IntegrationService
86
- const phases = yield* PhaseService
87
84
  const repositories = yield* RepositoryService
88
- const tasks = yield* TaskService
89
85
  const workbases = yield* WorkbaseService
90
86
  const worktrees = yield* WorktreeService
91
87
  const versionControl = yield* VersionControlService
@@ -105,49 +101,60 @@ export class DoctorService extends Effect.Service<DoctorService>()(
105
101
  : null,
106
102
  })
107
103
 
108
- const tool = function* (
104
+ const tool = (
109
105
  id: string,
110
106
  executable: string,
111
107
  level: DoctorCheckLevel,
112
108
  label: string,
113
- ) {
114
- const available = yield* executableAvailable(executable, root)
115
- add({
116
- id,
117
- category: id.startsWith("tool.") ? "tool" : "integration",
118
- level,
119
- status: available ? "pass" : "fail",
120
- message: available
121
- ? `${label} executable '${executable}' is available`
122
- : `${label} executable '${executable}' is unavailable`,
123
- remediation:
124
- level === "optional"
125
- ? `Install '${executable}' to enable ${label.toLowerCase()}, or leave it unavailable if unused.`
126
- : `Install '${executable}' and ensure it is executable on PATH.`,
109
+ ) =>
110
+ Effect.gen(function* () {
111
+ const available = yield* executableAvailable(executable, root)
112
+ add({
113
+ id,
114
+ category: id.startsWith("tool.") ? "tool" : "integration",
115
+ level,
116
+ status: available ? "pass" : "fail",
117
+ message: available
118
+ ? `${label} executable '${executable}' is available`
119
+ : `${label} executable '${executable}' is unavailable`,
120
+ remediation:
121
+ level === "optional"
122
+ ? `Install '${executable}' to enable ${label.toLowerCase()}, or leave it unavailable if unused.`
123
+ : `Install '${executable}' and ensure it is executable on PATH.`,
124
+ })
125
+ return available
127
126
  })
128
- return available
129
- }
130
127
 
131
- const gitAvailable = yield* tool("tool.git", "git", "error", "Git")
132
- const jjAvailable = yield* tool(
133
- "tool.jj",
134
- "jj",
135
- config.vcs === "jj" ? "error" : "optional",
136
- "Jujutsu",
128
+ const [gitAvailable, jjAvailable] = yield* Effect.all(
129
+ [
130
+ tool("tool.git", "git", "error", "Git"),
131
+ tool(
132
+ "tool.jj",
133
+ "jj",
134
+ config.vcs === "jj" ? "error" : "optional",
135
+ "Jujutsu",
136
+ ),
137
+ ],
138
+ { concurrency: "unbounded", batching: true },
137
139
  )
138
140
  const versionControlAvailable =
139
141
  gitAvailable && (config.vcs !== "jj" || jjAvailable)
140
- yield* tool(
141
- "capability.agent.opencode",
142
- "opencode",
143
- "optional",
144
- "OpenCode agent",
145
- )
146
- yield* tool(
147
- "capability.agent.claude",
148
- "claude",
149
- "optional",
150
- "Claude agent",
142
+ yield* Effect.all(
143
+ [
144
+ tool(
145
+ "capability.agent.opencode",
146
+ "opencode",
147
+ "optional",
148
+ "OpenCode agent",
149
+ ),
150
+ tool(
151
+ "capability.agent.claude",
152
+ "claude",
153
+ "optional",
154
+ "Claude agent",
155
+ ),
156
+ ],
157
+ { concurrency: "unbounded", batching: true },
151
158
  )
152
159
 
153
160
  const configuredCommands: readonly (readonly [
@@ -250,11 +257,16 @@ export class DoctorService extends Effect.Service<DoctorService>()(
250
257
  ]
251
258
  : []),
252
259
  ]
253
- for (const [id, command, label] of configuredCommands) {
254
- yield* tool(id, command[0]!, "error", label)
255
- }
260
+ yield* Effect.all(
261
+ configuredCommands.map(([id, command, label]) =>
262
+ tool(id, command[0]!, "error", label),
263
+ ),
264
+ { concurrency: 16, batching: true },
265
+ )
256
266
 
257
- const validation = yield* workbases.validate(root)
267
+ const validation = yield* workbases.validate(root, {
268
+ includeDocuments: true,
269
+ })
258
270
  add({
259
271
  id: "workbase.validation",
260
272
  category: "workbase",
@@ -267,7 +279,7 @@ export class DoctorService extends Effect.Service<DoctorService>()(
267
279
  "Run 'agency validate' and correct every reported issue.",
268
280
  })
269
281
 
270
- for (const [id, mode, level, label, remediation] of [
282
+ const permissionChecks = [
271
283
  [
272
284
  "permission.workbase.read",
273
285
  constants.R_OK,
@@ -282,8 +294,16 @@ export class DoctorService extends Effect.Service<DoctorService>()(
282
294
  "writable",
283
295
  `Grant the current user write access to ${root} before running mutation commands.`,
284
296
  ],
285
- ] as const) {
286
- const available = yield* permissionAvailable(root, mode)
297
+ ] as const
298
+ const permissionResults = yield* Effect.all(
299
+ permissionChecks.map(([, mode]) => permissionAvailable(root, mode)),
300
+ { concurrency: "unbounded", batching: true },
301
+ )
302
+ for (const [
303
+ index,
304
+ [id, , level, label, remediation],
305
+ ] of permissionChecks.entries()) {
306
+ const available = permissionResults[index]!
287
307
  add({
288
308
  id,
289
309
  category: "permission",
@@ -321,12 +341,12 @@ export class DoctorService extends Effect.Service<DoctorService>()(
321
341
  values.add(ref)
322
342
  refs.set(repo, values)
323
343
  }
324
- if (validation.valid) {
325
- for (const epic of yield* epics.list(root)) {
344
+ if (validation.valid && validation.documents) {
345
+ for (const epic of validation.documents.epics) {
326
346
  for (const reference of epic.data.repos)
327
347
  declareRef(reference.repo, reference.ref)
328
348
  }
329
- for (const task of yield* tasks.list(root)) {
349
+ for (const task of validation.documents.tasks) {
330
350
  if ("review" in task.data) {
331
351
  declareRef(task.data.review.repo, task.data.review.commit)
332
352
  reviewSources.push({
@@ -344,7 +364,9 @@ export class DoctorService extends Effect.Service<DoctorService>()(
344
364
  for (const reference of task.data.repos ?? [])
345
365
  declareRef(reference.repo, reference.ref)
346
366
  } else {
347
- for (const phase of yield* phases.list(task.id, root)) {
367
+ for (const phase of validation.documents.phasesByTask.get(
368
+ task.id,
369
+ ) ?? []) {
348
370
  declareRef(phase.data.repo, phase.data.base)
349
371
  for (const reference of phase.data.repos ?? [])
350
372
  declareRef(reference.repo, reference.ref)
@@ -442,7 +464,36 @@ export class DoctorService extends Effect.Service<DoctorService>()(
442
464
  }
443
465
 
444
466
  if (validation.valid && versionControlAvailable) {
445
- const inspected = yield* Effect.either(worktrees.list(root))
467
+ const tasks = validation.documents?.tasks.map(
468
+ (record) =>
469
+ ({ ...record, content: "", revision: "" }) satisfies TaskRecord,
470
+ )
471
+ const phasesByTask = validation.documents
472
+ ? new Map(
473
+ [...validation.documents.phasesByTask].map(
474
+ ([taskId, records]) =>
475
+ [
476
+ taskId,
477
+ records.map(
478
+ (record) =>
479
+ ({
480
+ ...record,
481
+ taskId,
482
+ content: "",
483
+ revision: "",
484
+ }) satisfies PhaseRecord,
485
+ ),
486
+ ] as const,
487
+ ),
488
+ )
489
+ : undefined
490
+ const inspected = yield* Effect.either(
491
+ worktrees.list(root, {
492
+ materializedOnly: true,
493
+ tasks,
494
+ phasesByTask,
495
+ }),
496
+ )
446
497
  if (Either.isLeft(inspected)) {
447
498
  add({
448
499
  id: "worktree.inspection",
@@ -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
 
@@ -56,6 +56,7 @@ export interface ValidationReport {
56
56
  readonly taskCount: number
57
57
  readonly phaseCount: number
58
58
  readonly valid: boolean
59
+ readonly documents?: ValidationDocuments
59
60
  }
60
61
 
61
62
  interface DocumentRecord<T> {
@@ -64,6 +65,15 @@ interface DocumentRecord<T> {
64
65
  readonly data: T
65
66
  }
66
67
 
68
+ interface ValidationDocuments {
69
+ readonly epics: readonly DocumentRecord<EpicData>[]
70
+ readonly tasks: readonly DocumentRecord<TaskData>[]
71
+ readonly phasesByTask: ReadonlyMap<
72
+ string,
73
+ readonly DocumentRecord<PhaseData>[]
74
+ >
75
+ }
76
+
67
77
  type DecodeResult<T> =
68
78
  | { readonly success: true; readonly value: T }
69
79
  | { readonly success: false; readonly error: string }
@@ -653,7 +663,10 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
653
663
  ),
654
664
  ),
655
665
 
656
- validate: (startPath: string = process.cwd()) =>
666
+ validate: (
667
+ startPath: string = process.cwd(),
668
+ options: { readonly includeDocuments?: boolean } = {},
669
+ ) =>
657
670
  Effect.gen(function* () {
658
671
  const service = yield* WorkbaseService
659
672
  const fs = yield* FileSystemService
@@ -959,6 +972,14 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
959
972
  : a.path.localeCompare(b.path),
960
973
  )
961
974
 
975
+ const phasesByTask = new Map<string, DocumentRecord<PhaseData>[]>()
976
+ for (const [key, phase] of phases) {
977
+ const taskId = key.slice(0, key.indexOf("/"))
978
+ const records = phasesByTask.get(taskId) ?? []
979
+ records.push(phase)
980
+ phasesByTask.set(taskId, records)
981
+ }
982
+
962
983
  return {
963
984
  root,
964
985
  issues,
@@ -966,6 +987,15 @@ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
966
987
  taskCount: tasks.size,
967
988
  phaseCount: phases.size,
968
989
  valid: issues.length === 0,
990
+ ...(options.includeDocuments
991
+ ? {
992
+ documents: {
993
+ epics: [...epics.values()],
994
+ tasks: [...tasks.values()],
995
+ phasesByTask,
996
+ },
997
+ }
998
+ : {}),
969
999
  } satisfies ValidationReport
970
1000
  }),
971
1001
  }),
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,7 +264,7 @@ 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))
@@ -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
- }