@markjaquith/agency 2.71.5 → 2.71.7

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.5",
3
+ "version": "2.71.7",
4
4
  "description": "Manage agentic work across repositories with durable workbases",
5
5
  "keywords": [
6
6
  "agents",
@@ -66,7 +66,9 @@
66
66
  "preuninstall": "bun scripts/install-pi-extension.ts uninstall",
67
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",
71
+ "benchmark:finish": "bun scripts/benchmark-finish.ts",
70
72
  "benchmark:sync": "bun scripts/benchmark-sync.ts",
71
73
  "benchmark:task": "bun scripts/benchmark-task.ts",
72
74
  "test": "find src \\( -name '*.test.ts' -o -name '*.test.tsx' \\) -print0 | xargs -0 -n 1 -P 4 bun test",
@@ -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) {
@@ -234,6 +234,38 @@ describe("claim service", () => {
234
234
  )
235
235
  })
236
236
 
237
+ test("inspects a task without parsing unrelated task documents", async () => {
238
+ await mkdir(join(root, "tasks/broken"), { recursive: true })
239
+ await Bun.write(join(root, "tasks/broken/TASK.md"), "not frontmatter\n")
240
+
241
+ const inspected = await inspect()
242
+ expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
243
+ expect(inspected.data).toMatchObject({ branch: "task/single" })
244
+ })
245
+
246
+ test("inspects a phase without parsing unrelated phase documents", async () => {
247
+ await mkdir(join(root, "tasks/phased/phases/target"), { recursive: true })
248
+ await mkdir(join(root, "tasks/phased/phases/broken"), { recursive: true })
249
+ await Bun.write(
250
+ join(root, "tasks/phased/TASK.md"),
251
+ "---\nticketUrl: null\nphases:\n - id: target\n - id: broken\nstatus: working\n---\n",
252
+ )
253
+ await Bun.write(
254
+ join(root, "tasks/phased/phases/target/PHASE.md"),
255
+ "---\nrepo: agency\nbranch: phase/target\nbase: main\npr: null\nstatus: open\n---\n",
256
+ )
257
+ await Bun.write(
258
+ join(root, "tasks/phased/phases/broken/PHASE.md"),
259
+ "not frontmatter\n",
260
+ )
261
+
262
+ const inspected = await inspect("phased", "target")
263
+ expect(inspected.target.path).toBe(
264
+ join(root, "tasks/phased/phases/target/PHASE.md"),
265
+ )
266
+ expect(inspected.data).toMatchObject({ branch: "phase/target" })
267
+ })
268
+
237
269
  test("serializes concurrent claims and allows expired ownership replacement", async () => {
238
270
  const initial = await inspect()
239
271
  const attempts = await Promise.allSettled([
@@ -10,10 +10,7 @@ import {
10
10
  writeFile,
11
11
  } from "node:fs/promises"
12
12
  import { basename, dirname, join, relative } from "node:path"
13
- import { PhaseService } from "./PhaseService"
14
- import { TaskService } from "./TaskService"
15
13
  import { WorkbaseService } from "./WorkbaseService"
16
- import { FileSystemService } from "./FileSystemService"
17
14
  import {
18
15
  documentRevision,
19
16
  isDocumentRevision,
@@ -66,6 +63,47 @@ interface ClaimTarget {
66
63
  readonly label: string
67
64
  }
68
65
 
66
+ const resolveTarget = async (
67
+ root: string,
68
+ taskId: string,
69
+ phaseId?: string,
70
+ ): Promise<ClaimTarget> => {
71
+ const taskPath = join(root, "tasks", taskId, "TASK.md")
72
+ const path = phaseId
73
+ ? join(root, "tasks", taskId, "phases", phaseId, "PHASE.md")
74
+ : taskPath
75
+ try {
76
+ await stat(taskPath)
77
+ } catch {
78
+ throw new ClaimError({ message: `Task '${taskId}' does not exist` })
79
+ }
80
+ if (phaseId) {
81
+ try {
82
+ await stat(path)
83
+ } catch {
84
+ throw new ClaimError({
85
+ message: `Phase '${phaseId}' does not exist on task '${taskId}'`,
86
+ })
87
+ }
88
+ }
89
+ return phaseId
90
+ ? {
91
+ kind: "phase",
92
+ root,
93
+ taskId,
94
+ phaseId,
95
+ path,
96
+ label: `phase '${taskId}/${phaseId}'`,
97
+ }
98
+ : {
99
+ kind: "task",
100
+ root,
101
+ taskId,
102
+ path,
103
+ label: `task '${taskId}'`,
104
+ }
105
+ }
106
+
69
107
  interface ClaimInput {
70
108
  readonly taskId: string
71
109
  readonly phaseId?: string
@@ -276,43 +314,24 @@ export class ClaimService extends Effect.Service<ClaimService>()(
276
314
  startPath: string = process.cwd(),
277
315
  ) =>
278
316
  Effect.gen(function* () {
279
- const fs = yield* FileSystemService
280
317
  const workbase = yield* WorkbaseService
281
- const tasks = yield* TaskService
282
- const phases = yield* PhaseService
283
318
  const root = yield* workbase.discover(startPath)
284
- const task = yield* tasks.show(taskId, root)
285
- const phase = phaseId
286
- ? yield* phases.show(task.id, phaseId, root)
287
- : undefined
288
- const target: ClaimTarget = phaseId
289
- ? {
290
- kind: "phase",
291
- root,
292
- taskId: task.id,
293
- phaseId,
294
- path: phase!.path,
295
- label: `phase '${task.id}/${phaseId}'`,
296
- }
297
- : {
298
- kind: "task",
299
- root,
300
- taskId: task.id,
301
- path: task.path,
302
- label: `task '${task.id}'`,
303
- }
304
- if (!phaseId && "phases" in task.data) {
319
+ const target = yield* operation(() =>
320
+ resolveTarget(root, taskId, phaseId),
321
+ )
322
+ const content = yield* operation(() => readFile(target.path, "utf8"))
323
+ const parsed = parseFrontmatterSync(content, target.path)
324
+ const data = decodeExecution(target, parsed.data)
325
+ if (!phaseId && "phases" in data) {
305
326
  return yield* new ClaimError({
306
327
  target: target.label,
307
- message: `Task '${task.id}' has multiple phases; claim a phase instead`,
328
+ message: `Task '${taskId}' has multiple phases; claim a phase instead`,
308
329
  })
309
330
  }
310
- const content = yield* fs.readFile(target.path)
311
- const parsed = parseFrontmatterSync(content, target.path)
312
331
  return {
313
332
  target,
314
333
  revision: documentRevision(content),
315
- data: decodeExecution(target, parsed.data),
334
+ data,
316
335
  }
317
336
  }),
318
337
 
@@ -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",
@@ -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
  }),