@markjaquith/agency 2.71.12 → 2.71.14
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.14",
|
|
4
4
|
"description": "Manage agentic work across repositories with durable workbases",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -64,12 +64,15 @@
|
|
|
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",
|
|
69
|
+
"benchmark:release": "bun scripts/benchmark-release.ts",
|
|
68
70
|
"benchmark:status": "bun scripts/benchmark-status.ts",
|
|
69
71
|
"benchmark:init": "bun scripts/benchmark-init.ts",
|
|
70
72
|
"benchmark:workbase": "bun scripts/benchmark-workbase.ts",
|
|
71
73
|
"benchmark:doctor": "bun scripts/benchmark-doctor.ts",
|
|
72
74
|
"benchmark:context": "bun scripts/benchmark-context.ts",
|
|
75
|
+
"benchmark:claim": "bun scripts/benchmark-claim.ts",
|
|
73
76
|
"benchmark:finish": "bun scripts/benchmark-finish.ts",
|
|
74
77
|
"benchmark:push": "bun scripts/benchmark-push.ts",
|
|
75
78
|
"benchmark:sync": "bun scripts/benchmark-sync.ts",
|
package/src/commands/act.ts
CHANGED
|
@@ -111,31 +111,24 @@ const activeClaim = (node: EntityNode) =>
|
|
|
111
111
|
|
|
112
112
|
const executionNode = (
|
|
113
113
|
node: EntityNode,
|
|
114
|
-
|
|
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
|
-
|
|
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 = (
|
|
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,
|
|
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 = (
|
|
151
|
-
|
|
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
|
-
|
|
161
|
+
executions: Parameters<typeof executionNode>[1],
|
|
166
162
|
): readonly Choice<ActAction>[] => {
|
|
167
163
|
const choices: Choice<ActAction>[] = []
|
|
168
|
-
const execution = executionNode(node,
|
|
169
|
-
if (canWork(node,
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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({
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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"
|
|
@@ -265,7 +265,6 @@ describe("claim service", () => {
|
|
|
265
265
|
)
|
|
266
266
|
expect(inspected.data).toMatchObject({ branch: "phase/target" })
|
|
267
267
|
})
|
|
268
|
-
|
|
269
268
|
test("serializes concurrent claims and allows expired ownership replacement", async () => {
|
|
270
269
|
const initial = await inspect()
|
|
271
270
|
const attempts = await Promise.allSettled([
|
|
@@ -358,4 +357,59 @@ describe("claim service", () => {
|
|
|
358
357
|
)
|
|
359
358
|
expect(acquired.target).toBe("phase 'multi/implementation'")
|
|
360
359
|
})
|
|
360
|
+
|
|
361
|
+
test("inspects only the requested task on large workbases", async () => {
|
|
362
|
+
for (let index = 0; index < 100; index += 1) {
|
|
363
|
+
const taskRoot = join(root, "tasks", `unrelated-${index}`)
|
|
364
|
+
await mkdir(taskRoot, { recursive: true })
|
|
365
|
+
await writeFile(join(taskRoot, "TASK.md"), "not valid frontmatter\n")
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const inspected = await inspect()
|
|
369
|
+
expect(inspected.target.path).toBe(join(root, "tasks/single/TASK.md"))
|
|
370
|
+
expect(inspected.data.status).toBe("open")
|
|
371
|
+
})
|
|
372
|
+
|
|
373
|
+
test("inspects only the requested phase", async () => {
|
|
374
|
+
await runTestEffect(
|
|
375
|
+
TaskService.pipe(
|
|
376
|
+
Effect.flatMap((service) =>
|
|
377
|
+
service.create(
|
|
378
|
+
{ id: "multi", ticketUrl: null, multiPhase: true },
|
|
379
|
+
root,
|
|
380
|
+
),
|
|
381
|
+
),
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
await runTestEffect(
|
|
385
|
+
PhaseService.pipe(
|
|
386
|
+
Effect.flatMap((service) =>
|
|
387
|
+
service.create(
|
|
388
|
+
{
|
|
389
|
+
taskId: "multi",
|
|
390
|
+
id: "requested",
|
|
391
|
+
repo: "agency",
|
|
392
|
+
branch: "task/multi",
|
|
393
|
+
base: "main",
|
|
394
|
+
},
|
|
395
|
+
root,
|
|
396
|
+
),
|
|
397
|
+
),
|
|
398
|
+
),
|
|
399
|
+
)
|
|
400
|
+
const taskPath = join(root, "tasks/multi/TASK.md")
|
|
401
|
+
await writeFile(
|
|
402
|
+
taskPath,
|
|
403
|
+
(await readFile(taskPath, "utf8")).replace("multi", "["),
|
|
404
|
+
)
|
|
405
|
+
const unrelatedPhase = join(root, "tasks/multi/phases/unrelated")
|
|
406
|
+
await mkdir(unrelatedPhase, { recursive: true })
|
|
407
|
+
await writeFile(join(unrelatedPhase, "PHASE.md"), "not valid frontmatter\n")
|
|
408
|
+
|
|
409
|
+
const inspected = await inspect("multi", "requested")
|
|
410
|
+
expect(inspected.target.path).toBe(
|
|
411
|
+
join(root, "tasks/multi/phases/requested/PHASE.md"),
|
|
412
|
+
)
|
|
413
|
+
expect(inspected.data.status).toBe("open")
|
|
414
|
+
})
|
|
361
415
|
})
|
|
@@ -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 } =
|
|
135
|
-
|
|
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",
|