@markjaquith/agency 2.71.17 → 2.71.19
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 +3 -1
- package/src/commands/epic.test.ts +29 -0
- package/src/commands/epic.ts +7 -6
- package/src/readiness.ts +15 -11
- package/src/services/GraphService.test.ts +60 -1
- package/src/services/GraphService.ts +54 -21
- package/src/work-view.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markjaquith/agency",
|
|
3
|
-
"version": "2.71.
|
|
3
|
+
"version": "2.71.19",
|
|
4
4
|
"description": "Manage agentic work across repositories with durable workbases",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|
|
@@ -75,7 +75,9 @@
|
|
|
75
75
|
"benchmark:context": "bun scripts/benchmark-context.ts",
|
|
76
76
|
"benchmark:claim": "bun scripts/benchmark-claim.ts",
|
|
77
77
|
"benchmark:finish": "bun scripts/benchmark-finish.ts",
|
|
78
|
+
"benchmark:epic": "bun scripts/benchmark-epic.ts",
|
|
78
79
|
"benchmark:phase": "bun scripts/benchmark-phase.ts",
|
|
80
|
+
"benchmark:graph": "bun scripts/benchmark-graph.ts",
|
|
79
81
|
"benchmark:push": "bun scripts/benchmark-push.ts",
|
|
80
82
|
"benchmark:archive": "bun scripts/benchmark-archive.ts",
|
|
81
83
|
"benchmark:sync": "bun scripts/benchmark-sync.ts",
|
|
@@ -111,6 +111,35 @@ describe("epic command", () => {
|
|
|
111
111
|
})
|
|
112
112
|
})
|
|
113
113
|
|
|
114
|
+
test("reads each epic document once when listing JSON", async () => {
|
|
115
|
+
await runTestEffect(
|
|
116
|
+
epic({
|
|
117
|
+
subcommand: "create",
|
|
118
|
+
args: ["example"],
|
|
119
|
+
ticketUrl: "https://example.com/epic",
|
|
120
|
+
repos: ["agency:main"],
|
|
121
|
+
cwd: root,
|
|
122
|
+
silent: true,
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
const original = Bun.file
|
|
126
|
+
let reads = 0
|
|
127
|
+
Bun.file = ((path: Parameters<typeof Bun.file>[0], ...args: never[]) => {
|
|
128
|
+
if (String(path).endsWith("/epics/example/EPIC.md")) reads += 1
|
|
129
|
+
return original(path as unknown as string, ...args)
|
|
130
|
+
}) as typeof Bun.file
|
|
131
|
+
try {
|
|
132
|
+
await captureLogs(() =>
|
|
133
|
+
runTestEffect(
|
|
134
|
+
epic({ subcommand: "list", args: [], cwd: root, json: true }),
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
} finally {
|
|
138
|
+
Bun.file = original
|
|
139
|
+
}
|
|
140
|
+
expect(reads).toBe(2)
|
|
141
|
+
})
|
|
142
|
+
|
|
114
143
|
test("renders a readable operational table", async () => {
|
|
115
144
|
await runTestEffect(
|
|
116
145
|
epic({
|
package/src/commands/epic.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
|
+
import { join } from "node:path"
|
|
2
3
|
import type { BaseCommandOptions } from "../utils/command"
|
|
3
4
|
import { EpicService } from "../services/EpicService"
|
|
4
5
|
import { createLoggers } from "../utils/effect"
|
|
@@ -71,7 +72,6 @@ export const epic = (options: EpicOptions, work: StartWork = startWork) =>
|
|
|
71
72
|
}
|
|
72
73
|
|
|
73
74
|
case "list": {
|
|
74
|
-
const records = yield* epics.list(cwd)
|
|
75
75
|
const { epicRows } = yield* getWorkViews({
|
|
76
76
|
cwd,
|
|
77
77
|
statuses: options.statuses,
|
|
@@ -80,14 +80,15 @@ export const epic = (options: EpicOptions, work: StartWork = startWork) =>
|
|
|
80
80
|
blocked: options.blocked,
|
|
81
81
|
pr: options.pr,
|
|
82
82
|
})
|
|
83
|
-
const ordered = epicRows.flatMap((row) => {
|
|
84
|
-
const record = records.find((item) => item.id === row.key)
|
|
85
|
-
return record ? [record] : []
|
|
86
|
-
})
|
|
87
83
|
if (options.json) {
|
|
88
84
|
log(
|
|
89
85
|
JSON.stringify(
|
|
90
|
-
|
|
86
|
+
epicRows.map((row) => ({
|
|
87
|
+
id: row.id,
|
|
88
|
+
path: join(cwd, "epics", row.id, "EPIC.md"),
|
|
89
|
+
revision: row.revision,
|
|
90
|
+
data: row.data,
|
|
91
|
+
})),
|
|
91
92
|
null,
|
|
92
93
|
2,
|
|
93
94
|
),
|
package/src/readiness.ts
CHANGED
|
@@ -24,23 +24,27 @@ export const canTransitionStatus = (from: WorkStatus, to: WorkStatus) =>
|
|
|
24
24
|
export const aggregateProgress = (statuses: readonly WorkStatus[]) => {
|
|
25
25
|
const counts = {
|
|
26
26
|
total: statuses.length,
|
|
27
|
-
open:
|
|
28
|
-
working:
|
|
29
|
-
delegated:
|
|
30
|
-
done:
|
|
31
|
-
dropped:
|
|
32
|
-
terminal:
|
|
27
|
+
open: 0,
|
|
28
|
+
working: 0,
|
|
29
|
+
delegated: 0,
|
|
30
|
+
done: 0,
|
|
31
|
+
dropped: 0,
|
|
32
|
+
terminal: 0,
|
|
33
|
+
}
|
|
34
|
+
for (const status of statuses) {
|
|
35
|
+
counts[status] += 1
|
|
36
|
+
if (isTerminalStatus(status)) counts.terminal += 1
|
|
33
37
|
}
|
|
34
38
|
const status: WorkStatus =
|
|
35
|
-
|
|
39
|
+
counts.total === 0
|
|
36
40
|
? "open"
|
|
37
|
-
:
|
|
41
|
+
: counts.done === counts.total
|
|
38
42
|
? "done"
|
|
39
|
-
:
|
|
43
|
+
: counts.terminal === counts.total
|
|
40
44
|
? "dropped"
|
|
41
|
-
:
|
|
45
|
+
: counts.working > 0
|
|
42
46
|
? "working"
|
|
43
|
-
:
|
|
47
|
+
: counts.delegated > 0
|
|
44
48
|
? "delegated"
|
|
45
49
|
: "open"
|
|
46
50
|
return { status, ...counts }
|
|
@@ -6,7 +6,10 @@ 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 {
|
|
9
|
+
import {
|
|
10
|
+
VersionControlService,
|
|
11
|
+
type VersionControlBackend,
|
|
12
|
+
} from "./VersionControlService"
|
|
10
13
|
|
|
11
14
|
const write = async (root: string, path: string, content: string) => {
|
|
12
15
|
const fullPath = join(root, path)
|
|
@@ -364,6 +367,15 @@ status: open
|
|
|
364
367
|
|
|
365
368
|
const detailed = await getGraph(root, {
|
|
366
369
|
include: ["bodies", "workspace", "git", "pr"],
|
|
370
|
+
backend: {
|
|
371
|
+
kind: "git",
|
|
372
|
+
inspectRepository: () => Effect.succeed(null),
|
|
373
|
+
listWorkspaces: () => Effect.succeed([]),
|
|
374
|
+
resolveRevision: () => Effect.succeed(null),
|
|
375
|
+
workspaceHead: () => Effect.succeed(null),
|
|
376
|
+
workspaceDirty: () => Effect.succeed(null),
|
|
377
|
+
remoteUrl: () => Effect.succeed(null),
|
|
378
|
+
} as unknown as VersionControlBackend,
|
|
367
379
|
})
|
|
368
380
|
expect(detailed.includes).toEqual(["bodies", "git", "pr", "workspace"])
|
|
369
381
|
expect(detailed.workbase.root).toBe(root)
|
|
@@ -386,4 +398,51 @@ status: open
|
|
|
386
398
|
expect(execution?.pr).toEqual({ url: null, state: "none" })
|
|
387
399
|
expect(Schema.decodeUnknownSync(AgencyGraph)(detailed)).toEqual(detailed)
|
|
388
400
|
})
|
|
401
|
+
|
|
402
|
+
test("reuses repository metadata and revision lookups for git details", async () => {
|
|
403
|
+
const root = await createWorkbase()
|
|
404
|
+
roots.push(root)
|
|
405
|
+
const calls = {
|
|
406
|
+
listWorkspaces: 0,
|
|
407
|
+
remoteUrl: 0,
|
|
408
|
+
resolveRevision: new Map<string, number>(),
|
|
409
|
+
}
|
|
410
|
+
const backend = {
|
|
411
|
+
kind: "git",
|
|
412
|
+
inspectRepository: () => Effect.succeed(null),
|
|
413
|
+
listWorkspaces: () =>
|
|
414
|
+
Effect.sync(() => {
|
|
415
|
+
calls.listWorkspaces += 1
|
|
416
|
+
return []
|
|
417
|
+
}),
|
|
418
|
+
remoteUrl: () =>
|
|
419
|
+
Effect.sync(() => {
|
|
420
|
+
calls.remoteUrl += 1
|
|
421
|
+
return null
|
|
422
|
+
}),
|
|
423
|
+
resolveRevision: (_path: string, revision: string) =>
|
|
424
|
+
Effect.sync(() => {
|
|
425
|
+
calls.resolveRevision.set(
|
|
426
|
+
revision,
|
|
427
|
+
(calls.resolveRevision.get(revision) ?? 0) + 1,
|
|
428
|
+
)
|
|
429
|
+
return revision
|
|
430
|
+
}),
|
|
431
|
+
workspaceHead: () => Effect.succeed(null),
|
|
432
|
+
workspaceDirty: () => Effect.succeed(null),
|
|
433
|
+
} as unknown as VersionControlBackend
|
|
434
|
+
|
|
435
|
+
await getGraph(root, { include: ["git"], backend })
|
|
436
|
+
|
|
437
|
+
expect(calls.listWorkspaces).toBe(0)
|
|
438
|
+
expect(calls.remoteUrl).toBe(1)
|
|
439
|
+
expect(calls.resolveRevision).toEqual(
|
|
440
|
+
new Map([
|
|
441
|
+
["feat/prepare", 1],
|
|
442
|
+
["feat/implement", 1],
|
|
443
|
+
["feat/verify", 1],
|
|
444
|
+
["main", 1],
|
|
445
|
+
]),
|
|
446
|
+
)
|
|
447
|
+
})
|
|
389
448
|
})
|
|
@@ -298,22 +298,28 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
298
298
|
}
|
|
299
299
|
const taskDependencies = (taskId: string) =>
|
|
300
300
|
taskDeclarations.get(taskId)?.dependsOn ?? []
|
|
301
|
+
const taskLeafStatusCache = new Map<string, WorkStatus[]>()
|
|
301
302
|
const taskLeafStatuses = (taskId: string): WorkStatus[] => {
|
|
303
|
+
const cached = taskLeafStatusCache.get(taskId)
|
|
304
|
+
if (cached) return cached
|
|
302
305
|
const task = tasks.get(taskId)
|
|
303
306
|
if (!task) return []
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
307
|
+
const statuses =
|
|
308
|
+
"phases" in task.data
|
|
309
|
+
? task.data.phases.map(
|
|
310
|
+
(item) =>
|
|
311
|
+
phases.get(`${taskId}/${item.id}`)?.data.status ?? "open",
|
|
312
|
+
)
|
|
313
|
+
: [task.data.status]
|
|
314
|
+
taskLeafStatusCache.set(taskId, statuses)
|
|
315
|
+
return statuses
|
|
310
316
|
}
|
|
311
|
-
const
|
|
317
|
+
const taskStatusCache = new Map<string, WorkStatus>()
|
|
312
318
|
const taskStatus = (taskId: string) => {
|
|
313
|
-
const cached =
|
|
319
|
+
const cached = taskStatusCache.get(taskId)
|
|
314
320
|
if (cached) return cached
|
|
315
321
|
const status = aggregateProgress(taskLeafStatuses(taskId)).status
|
|
316
|
-
|
|
322
|
+
taskStatusCache.set(taskId, status)
|
|
317
323
|
return status
|
|
318
324
|
}
|
|
319
325
|
const dependencyBlockers = (
|
|
@@ -668,6 +674,40 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
668
674
|
}
|
|
669
675
|
const dependents = (id: string) =>
|
|
670
676
|
[...(reverseDependencies.get(id) ?? [])].sort()
|
|
677
|
+
const repositoryWorkspaces = new Map<
|
|
678
|
+
string,
|
|
679
|
+
ReturnType<VersionControlBackend["listWorkspaces"]>
|
|
680
|
+
>()
|
|
681
|
+
const listWorkspaces = (path: string) => {
|
|
682
|
+
if (!backend) return Effect.succeed([])
|
|
683
|
+
const cached = repositoryWorkspaces.get(path)
|
|
684
|
+
if (cached) return cached
|
|
685
|
+
const workspaces = backend
|
|
686
|
+
.listWorkspaces(path)
|
|
687
|
+
.pipe(Effect.catchAll(() => Effect.succeed([])))
|
|
688
|
+
repositoryWorkspaces.set(path, workspaces)
|
|
689
|
+
return workspaces
|
|
690
|
+
}
|
|
691
|
+
const repositoryRemotes = new Map<string, string | null>()
|
|
692
|
+
const remoteUrl = (path: string, remote: string) =>
|
|
693
|
+
Effect.gen(function* () {
|
|
694
|
+
if (!backend) return null
|
|
695
|
+
const key = `${path}\u0000${remote}`
|
|
696
|
+
if (repositoryRemotes.has(key)) return repositoryRemotes.get(key)!
|
|
697
|
+
const url = yield* backend.remoteUrl(path, remote)
|
|
698
|
+
repositoryRemotes.set(key, url)
|
|
699
|
+
return url
|
|
700
|
+
})
|
|
701
|
+
const resolvedRevisions = new Map<string, string | null>()
|
|
702
|
+
const resolveRevision = (path: string, revision: string) =>
|
|
703
|
+
Effect.gen(function* () {
|
|
704
|
+
if (!backend) return null
|
|
705
|
+
const key = `${path}\u0000${revision}`
|
|
706
|
+
if (resolvedRevisions.has(key)) return resolvedRevisions.get(key)!
|
|
707
|
+
const resolved = yield* backend.resolveRevision(path, revision)
|
|
708
|
+
resolvedRevisions.set(key, resolved)
|
|
709
|
+
return resolved
|
|
710
|
+
})
|
|
671
711
|
|
|
672
712
|
const documentDetails = <T extends Record<string, unknown>>(
|
|
673
713
|
document: Document<T>,
|
|
@@ -691,11 +731,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
691
731
|
Effect.gen(function* () {
|
|
692
732
|
if (!backend) return undefined
|
|
693
733
|
const inspection = yield* backend.inspectRepository(path)
|
|
694
|
-
const workspaces = inspection
|
|
695
|
-
? yield* backend
|
|
696
|
-
.listWorkspaces(path)
|
|
697
|
-
.pipe(Effect.catchAll(() => Effect.succeed([])))
|
|
698
|
-
: []
|
|
734
|
+
const workspaces = inspection ? yield* listWorkspaces(path) : []
|
|
699
735
|
const primary = workspaces.find(
|
|
700
736
|
(workspace) => workspace.path === path,
|
|
701
737
|
)
|
|
@@ -728,15 +764,12 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
728
764
|
}
|
|
729
765
|
if (include.has("git")) {
|
|
730
766
|
if (!backend) return result
|
|
731
|
-
const remote = yield*
|
|
732
|
-
repositoryPath,
|
|
733
|
-
"origin",
|
|
734
|
-
)
|
|
767
|
+
const remote = yield* remoteUrl(repositoryPath, "origin")
|
|
735
768
|
const canonicalCheckoutPath = materialized
|
|
736
769
|
? yield* fs.realPath(checkoutPath)
|
|
737
770
|
: checkoutPath
|
|
738
771
|
const actualBranch = materialized
|
|
739
|
-
? yield*
|
|
772
|
+
? yield* listWorkspaces(repositoryPath).pipe(
|
|
740
773
|
Effect.map(
|
|
741
774
|
(workspaces) =>
|
|
742
775
|
workspaces
|
|
@@ -777,11 +810,11 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
777
810
|
: {
|
|
778
811
|
branch: data.branch,
|
|
779
812
|
base: data.base,
|
|
780
|
-
branchCommit: yield*
|
|
813
|
+
branchCommit: yield* resolveRevision(
|
|
781
814
|
repositoryPath,
|
|
782
815
|
data.branch,
|
|
783
816
|
),
|
|
784
|
-
baseCommit: yield*
|
|
817
|
+
baseCommit: yield* resolveRevision(
|
|
785
818
|
repositoryPath,
|
|
786
819
|
data.base,
|
|
787
820
|
),
|
package/src/work-view.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface WorkViewRow {
|
|
|
34
34
|
readonly pr: string
|
|
35
35
|
readonly worktree: string
|
|
36
36
|
readonly hasPr: boolean
|
|
37
|
+
readonly data: Readonly<Record<string, unknown>>
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
const allowedStatuses = new Set<WorkStatus>([
|
|
@@ -122,6 +123,9 @@ const rowFor = (
|
|
|
122
123
|
hasPr: executions.some(
|
|
123
124
|
(execution) => "pr" in execution.data && Boolean(execution.data.pr),
|
|
124
125
|
),
|
|
126
|
+
data: Object.fromEntries(
|
|
127
|
+
Object.entries(node.data).filter(([key]) => key !== "sha256"),
|
|
128
|
+
),
|
|
125
129
|
}
|
|
126
130
|
}
|
|
127
131
|
|