@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.
|
|
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",
|
|
@@ -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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
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 '${
|
|
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
|
|
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 {
|
|
7
|
+
import type { PhaseRecord } from "./PhaseService"
|
|
9
8
|
import { RepositoryService } from "./RepositoryService"
|
|
10
|
-
import {
|
|
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 =
|
|
104
|
+
const tool = (
|
|
109
105
|
id: string,
|
|
110
106
|
executable: string,
|
|
111
107
|
level: DoctorCheckLevel,
|
|
112
108
|
label: string,
|
|
113
|
-
)
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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*
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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*
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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: (
|
|
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
|
}),
|