@markjaquith/agency 2.10.0 → 2.11.0
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/README.md +32 -4
- package/cli.ts +32 -2
- package/index.ts +1 -0
- package/package.json +4 -1
- package/schemas/agency-graph-v1.schema.json +519 -0
- package/skills/agency/SKILL.md +6 -0
- package/src/cli-parser.test.ts +45 -0
- package/src/cli-parser.ts +60 -0
- package/src/cli.test.ts +83 -1
- package/src/commands/graph.ts +103 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/graph-schema.test.ts +61 -0
- package/src/graph-schema.ts +255 -0
- package/src/protocol.ts +5 -0
- package/src/services/GraphService.test.ts +277 -0
- package/src/services/GraphService.ts +886 -0
- package/src/test-utils.ts +2 -0
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Schema } from "@effect/schema"
|
|
3
|
+
import { Effect } from "effect"
|
|
4
|
+
import { mkdir } from "node:fs/promises"
|
|
5
|
+
import { dirname, join } from "node:path"
|
|
6
|
+
import { AgencyGraph } from "../graph-schema"
|
|
7
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
8
|
+
import { GraphService } from "./GraphService"
|
|
9
|
+
|
|
10
|
+
const write = async (root: string, path: string, content: string) => {
|
|
11
|
+
const fullPath = join(root, path)
|
|
12
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
13
|
+
await Bun.write(fullPath, content)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const createWorkbase = async () => {
|
|
17
|
+
const root = await createTempDir()
|
|
18
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
19
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
20
|
+
await write(
|
|
21
|
+
root,
|
|
22
|
+
"epics/delivery/EPIC.md",
|
|
23
|
+
`---
|
|
24
|
+
ticketUrl: https://example.com/delivery
|
|
25
|
+
repos:
|
|
26
|
+
- repo: agency
|
|
27
|
+
ref: main
|
|
28
|
+
tasks:
|
|
29
|
+
- id: prepare
|
|
30
|
+
- id: ship
|
|
31
|
+
dependsOn: [prepare]
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# Delivery
|
|
35
|
+
Deliver the graph.
|
|
36
|
+
`,
|
|
37
|
+
)
|
|
38
|
+
await write(
|
|
39
|
+
root,
|
|
40
|
+
"tasks/prepare/TASK.md",
|
|
41
|
+
`---
|
|
42
|
+
ticketUrl: null
|
|
43
|
+
epic: delivery
|
|
44
|
+
repo: agency
|
|
45
|
+
branch: feat/prepare
|
|
46
|
+
base: main
|
|
47
|
+
pr: null
|
|
48
|
+
status: done
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
# Prepare
|
|
52
|
+
`,
|
|
53
|
+
)
|
|
54
|
+
await write(
|
|
55
|
+
root,
|
|
56
|
+
"tasks/ship/TASK.md",
|
|
57
|
+
`---
|
|
58
|
+
ticketUrl: null
|
|
59
|
+
epic: delivery
|
|
60
|
+
phases:
|
|
61
|
+
- id: implement
|
|
62
|
+
- id: verify
|
|
63
|
+
dependsOn: [implement]
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
# Ship
|
|
67
|
+
`,
|
|
68
|
+
)
|
|
69
|
+
await write(
|
|
70
|
+
root,
|
|
71
|
+
"tasks/ship/phases/implement/PHASE.md",
|
|
72
|
+
`---
|
|
73
|
+
repo: agency
|
|
74
|
+
branch: feat/implement
|
|
75
|
+
base: main
|
|
76
|
+
pr: null
|
|
77
|
+
status: open
|
|
78
|
+
---
|
|
79
|
+
|
|
80
|
+
# Implement
|
|
81
|
+
`,
|
|
82
|
+
)
|
|
83
|
+
await write(
|
|
84
|
+
root,
|
|
85
|
+
"tasks/ship/phases/verify/PHASE.md",
|
|
86
|
+
`---
|
|
87
|
+
repo: agency
|
|
88
|
+
branch: feat/verify
|
|
89
|
+
base: main
|
|
90
|
+
pr: null
|
|
91
|
+
status: open
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
# Verify
|
|
95
|
+
`,
|
|
96
|
+
)
|
|
97
|
+
return root
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const getGraph = (root: string, options: Record<string, unknown> = {}) =>
|
|
101
|
+
runTestEffect(
|
|
102
|
+
GraphService.pipe(
|
|
103
|
+
Effect.flatMap((service) => service.get({ cwd: root, ...options })),
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
describe("GraphService", () => {
|
|
108
|
+
const roots: string[] = []
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test("exports deterministic nodes, typed edges, and computed state", async () => {
|
|
115
|
+
const root = await createWorkbase()
|
|
116
|
+
roots.push(root)
|
|
117
|
+
const graph = await getGraph(root)
|
|
118
|
+
|
|
119
|
+
expect(Schema.decodeUnknownSync(AgencyGraph)(graph)).toEqual(graph)
|
|
120
|
+
expect(graph.nodes.map((node) => node.id)).toEqual([
|
|
121
|
+
"epic:delivery",
|
|
122
|
+
"execution-unit:phase/ship/implement",
|
|
123
|
+
"execution-unit:phase/ship/verify",
|
|
124
|
+
"execution-unit:task/prepare",
|
|
125
|
+
"phase:ship/implement",
|
|
126
|
+
"phase:ship/verify",
|
|
127
|
+
"repository:agency",
|
|
128
|
+
"task:prepare",
|
|
129
|
+
"task:ship",
|
|
130
|
+
])
|
|
131
|
+
expect(graph.edges).toContainEqual({
|
|
132
|
+
id: "depends_on:task:ship->task:prepare",
|
|
133
|
+
kind: "depends_on",
|
|
134
|
+
from: "task:ship",
|
|
135
|
+
to: "task:prepare",
|
|
136
|
+
})
|
|
137
|
+
expect(graph.edges).toContainEqual({
|
|
138
|
+
id: "owns:phase:ship/verify->execution-unit:phase/ship/verify",
|
|
139
|
+
kind: "owns",
|
|
140
|
+
from: "phase:ship/verify",
|
|
141
|
+
to: "execution-unit:phase/ship/verify",
|
|
142
|
+
})
|
|
143
|
+
expect(graph.edges).toContainEqual({
|
|
144
|
+
id: "writes:execution-unit:task/prepare->repository:agency",
|
|
145
|
+
kind: "writes",
|
|
146
|
+
from: "execution-unit:task/prepare",
|
|
147
|
+
to: "repository:agency",
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
const prepare = graph.nodes.find((node) => node.id === "task:prepare")!
|
|
151
|
+
const ship = graph.nodes.find((node) => node.id === "task:ship")!
|
|
152
|
+
const implement = graph.nodes.find(
|
|
153
|
+
(node) => node.id === "phase:ship/implement",
|
|
154
|
+
)!
|
|
155
|
+
const verify = graph.nodes.find((node) => node.id === "phase:ship/verify")!
|
|
156
|
+
expect(prepare.dependents).toEqual(["task:ship"])
|
|
157
|
+
expect(ship.readiness).toMatchObject({ ready: true, blocked: false })
|
|
158
|
+
expect(implement.dependents).toEqual(["phase:ship/verify"])
|
|
159
|
+
expect(implement.readiness).toMatchObject({ ready: true, blocked: false })
|
|
160
|
+
expect(verify.readiness).toMatchObject({
|
|
161
|
+
ready: false,
|
|
162
|
+
blocked: true,
|
|
163
|
+
blockers: [
|
|
164
|
+
{
|
|
165
|
+
kind: "dependency",
|
|
166
|
+
id: "phase:ship/implement",
|
|
167
|
+
status: "open",
|
|
168
|
+
},
|
|
169
|
+
],
|
|
170
|
+
})
|
|
171
|
+
expect(graph.summary).toEqual({
|
|
172
|
+
status: "open",
|
|
173
|
+
total: 3,
|
|
174
|
+
open: 2,
|
|
175
|
+
working: 0,
|
|
176
|
+
delegated: 0,
|
|
177
|
+
done: 1,
|
|
178
|
+
dropped: 0,
|
|
179
|
+
terminal: 1,
|
|
180
|
+
})
|
|
181
|
+
expect(await getGraph(root)).toEqual(graph)
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
test("applies filters after computing graph state", async () => {
|
|
185
|
+
const root = await createWorkbase()
|
|
186
|
+
roots.push(root)
|
|
187
|
+
|
|
188
|
+
const ready = await getGraph(root, {
|
|
189
|
+
ready: true,
|
|
190
|
+
kinds: ["execution-unit"],
|
|
191
|
+
repositories: ["agency"],
|
|
192
|
+
statuses: ["open"],
|
|
193
|
+
})
|
|
194
|
+
expect(ready.nodes.map((node) => node.id)).toEqual([
|
|
195
|
+
"execution-unit:phase/ship/implement",
|
|
196
|
+
])
|
|
197
|
+
expect(ready.edges).toEqual([])
|
|
198
|
+
|
|
199
|
+
const blocked = await getGraph(root, { blocked: true, kinds: ["phase"] })
|
|
200
|
+
expect(blocked.nodes.map((node) => node.id)).toEqual(["phase:ship/verify"])
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test("does not report structurally invalid phases as ready", async () => {
|
|
204
|
+
const root = await createWorkbase()
|
|
205
|
+
roots.push(root)
|
|
206
|
+
await write(
|
|
207
|
+
root,
|
|
208
|
+
"tasks/ship/phases/orphan/PHASE.md",
|
|
209
|
+
`---
|
|
210
|
+
repo: agency
|
|
211
|
+
branch: feat/orphan
|
|
212
|
+
base: main
|
|
213
|
+
pr: null
|
|
214
|
+
status: open
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
# Orphan
|
|
218
|
+
`,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
const graph = await getGraph(root, { kinds: ["phase"], ready: true })
|
|
222
|
+
expect(graph.nodes.map((node) => node.id)).not.toContain(
|
|
223
|
+
"phase:ship/orphan",
|
|
224
|
+
)
|
|
225
|
+
const complete = await getGraph(root)
|
|
226
|
+
expect(
|
|
227
|
+
complete.nodes.find((node) => node.id === "phase:ship/orphan")?.readiness,
|
|
228
|
+
).toMatchObject({
|
|
229
|
+
ready: false,
|
|
230
|
+
blocked: true,
|
|
231
|
+
blockers: [
|
|
232
|
+
{
|
|
233
|
+
kind: "validation",
|
|
234
|
+
id: "tasks/ship/TASK.md",
|
|
235
|
+
reason: "Unlisted phase 'orphan'",
|
|
236
|
+
},
|
|
237
|
+
],
|
|
238
|
+
})
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test("adds body, workspace, git, and PR details only when requested", async () => {
|
|
242
|
+
const root = await createWorkbase()
|
|
243
|
+
roots.push(root)
|
|
244
|
+
const baseline = await getGraph(root)
|
|
245
|
+
for (const node of baseline.nodes) {
|
|
246
|
+
expect("body" in node).toBe(false)
|
|
247
|
+
expect("workspace" in node).toBe(false)
|
|
248
|
+
expect("git" in node).toBe(false)
|
|
249
|
+
expect("pr" in node).toBe(false)
|
|
250
|
+
}
|
|
251
|
+
expect(baseline.workbase.root).toBeUndefined()
|
|
252
|
+
|
|
253
|
+
const detailed = await getGraph(root, {
|
|
254
|
+
include: ["bodies", "workspace", "git", "pr"],
|
|
255
|
+
})
|
|
256
|
+
expect(detailed.includes).toEqual(["bodies", "git", "pr", "workspace"])
|
|
257
|
+
expect(detailed.workbase.root).toBe(root)
|
|
258
|
+
const epic = detailed.nodes.find((node) => node.id === "epic:delivery")
|
|
259
|
+
const repository = detailed.nodes.find(
|
|
260
|
+
(node) => node.id === "repository:agency",
|
|
261
|
+
)
|
|
262
|
+
const execution = detailed.nodes.find(
|
|
263
|
+
(node) => node.id === "execution-unit:phase/ship/implement",
|
|
264
|
+
)
|
|
265
|
+
if (epic?.kind !== "epic") throw new Error("Missing epic node")
|
|
266
|
+
if (repository?.kind !== "repository") {
|
|
267
|
+
throw new Error("Missing repository node")
|
|
268
|
+
}
|
|
269
|
+
if (execution?.kind !== "execution-unit") {
|
|
270
|
+
throw new Error("Missing execution-unit node")
|
|
271
|
+
}
|
|
272
|
+
expect(epic?.body).toContain("Deliver the graph")
|
|
273
|
+
expect(repository?.git).toMatchObject({ kind: null })
|
|
274
|
+
expect(execution?.pr).toEqual({ url: null, state: "none" })
|
|
275
|
+
expect(Schema.decodeUnknownSync(AgencyGraph)(detailed)).toEqual(detailed)
|
|
276
|
+
})
|
|
277
|
+
})
|