@markjaquith/agency 2.10.0 → 2.12.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 +36 -5
- package/cli.ts +32 -2
- package/index.ts +2 -0
- package/package.json +4 -1
- package/schemas/agency-graph-v1.schema.json +524 -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 +68 -0
- package/src/graph-schema.ts +257 -0
- package/src/protocol.ts +5 -0
- package/src/readiness.test.ts +47 -0
- package/src/readiness.ts +58 -0
- package/src/services/ContextService.ts +6 -32
- package/src/services/GraphService.test.ts +331 -0
- package/src/services/GraphService.ts +860 -0
- package/src/services/PhaseService.ts +6 -0
- package/src/services/TaskPhaseService.test.ts +43 -0
- package/src/services/TaskService.ts +6 -0
- package/src/test-utils.ts +2 -0
|
@@ -4,6 +4,7 @@ import { join, relative, resolve, sep } from "node:path"
|
|
|
4
4
|
import { FileSystemService } from "./FileSystemService"
|
|
5
5
|
import { WorkbaseService } from "./WorkbaseService"
|
|
6
6
|
import { RepositoryService } from "./RepositoryService"
|
|
7
|
+
import { aggregateProgress, readinessState } from "../readiness"
|
|
7
8
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
8
9
|
import {
|
|
9
10
|
EpicFrontmatter,
|
|
@@ -63,29 +64,6 @@ interface ReferenceCheckout extends CheckoutInspection {
|
|
|
63
64
|
readonly resolvedCommit: string | null
|
|
64
65
|
}
|
|
65
66
|
|
|
66
|
-
const statusCounts = (statuses: readonly WorkStatus[]) => ({
|
|
67
|
-
total: statuses.length,
|
|
68
|
-
open: statuses.filter((status) => status === "open").length,
|
|
69
|
-
working: statuses.filter((status) => status === "working").length,
|
|
70
|
-
delegated: statuses.filter((status) => status === "delegated").length,
|
|
71
|
-
done: statuses.filter((status) => status === "done").length,
|
|
72
|
-
dropped: statuses.filter((status) => status === "dropped").length,
|
|
73
|
-
terminal: statuses.filter(
|
|
74
|
-
(status) => status === "done" || status === "dropped",
|
|
75
|
-
).length,
|
|
76
|
-
})
|
|
77
|
-
|
|
78
|
-
const aggregateStatus = (statuses: readonly WorkStatus[]): WorkStatus => {
|
|
79
|
-
if (statuses.length === 0) return "open"
|
|
80
|
-
if (statuses.every((status) => status === "done")) return "done"
|
|
81
|
-
if (statuses.every((status) => status === "done" || status === "dropped")) {
|
|
82
|
-
return "dropped"
|
|
83
|
-
}
|
|
84
|
-
if (statuses.includes("working")) return "working"
|
|
85
|
-
if (statuses.includes("delegated")) return "delegated"
|
|
86
|
-
return "open"
|
|
87
|
-
}
|
|
88
|
-
|
|
89
67
|
const decode = <S extends Schema.Schema.AnyNoContext>(
|
|
90
68
|
schema: S,
|
|
91
69
|
input: unknown,
|
|
@@ -337,13 +315,13 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
337
315
|
return (record.data as PhaseData).status
|
|
338
316
|
const data = record.data as TaskData
|
|
339
317
|
if (!("phases" in data)) return data.status
|
|
340
|
-
return
|
|
318
|
+
return aggregateProgress(
|
|
341
319
|
data.phases.map(
|
|
342
320
|
(item) =>
|
|
343
321
|
phaseDocuments.get(`${record.taskId}/${item.id}`)?.data
|
|
344
322
|
.status ?? "open",
|
|
345
323
|
),
|
|
346
|
-
)
|
|
324
|
+
).status
|
|
347
325
|
}
|
|
348
326
|
|
|
349
327
|
const taskDependencyEntries = (
|
|
@@ -551,7 +529,7 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
551
529
|
)
|
|
552
530
|
: [child.data.status]
|
|
553
531
|
})
|
|
554
|
-
targetStatus =
|
|
532
|
+
targetStatus = aggregateProgress(aggregateStatuses).status
|
|
555
533
|
descendantsReady = epic.data.tasks.some((item: Dependency) =>
|
|
556
534
|
taskReady(item.id),
|
|
557
535
|
)
|
|
@@ -832,14 +810,10 @@ export class ContextService extends Effect.Service<ContextService>()(
|
|
|
832
810
|
dependencies,
|
|
833
811
|
dependents,
|
|
834
812
|
readiness: {
|
|
835
|
-
ready,
|
|
836
|
-
blocked: !ready && blockers.length > 0,
|
|
813
|
+
...readinessState(targetStatus, blockers, ready),
|
|
837
814
|
blockers,
|
|
838
815
|
},
|
|
839
|
-
aggregate:
|
|
840
|
-
status: aggregateStatus(aggregateStatuses),
|
|
841
|
-
...statusCounts(aggregateStatuses),
|
|
842
|
-
},
|
|
816
|
+
aggregate: aggregateProgress(aggregateStatuses),
|
|
843
817
|
},
|
|
844
818
|
authority: {
|
|
845
819
|
mode: executionData ? "execution" : "orchestration",
|
|
@@ -0,0 +1,331 @@
|
|
|
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(prepare.readiness).toMatchObject({
|
|
158
|
+
ready: false,
|
|
159
|
+
terminal: true,
|
|
160
|
+
})
|
|
161
|
+
expect(ship.readiness).toMatchObject({
|
|
162
|
+
ready: true,
|
|
163
|
+
blocked: false,
|
|
164
|
+
terminal: false,
|
|
165
|
+
})
|
|
166
|
+
expect(ship.aggregate).toMatchObject({ total: 2, open: 2, terminal: 0 })
|
|
167
|
+
expect(implement.dependents).toEqual(["phase:ship/verify"])
|
|
168
|
+
expect(implement.readiness).toMatchObject({ ready: true, blocked: false })
|
|
169
|
+
expect(
|
|
170
|
+
graph.nodes.find(
|
|
171
|
+
(node) => node.id === "execution-unit:phase/ship/implement",
|
|
172
|
+
)?.readiness,
|
|
173
|
+
).toMatchObject({
|
|
174
|
+
ready: true,
|
|
175
|
+
blocked: false,
|
|
176
|
+
blockedBy: [],
|
|
177
|
+
terminal: false,
|
|
178
|
+
})
|
|
179
|
+
expect(verify.readiness).toMatchObject({
|
|
180
|
+
ready: false,
|
|
181
|
+
blocked: true,
|
|
182
|
+
blockedBy: ["phase:ship/implement"],
|
|
183
|
+
terminal: false,
|
|
184
|
+
blockers: [
|
|
185
|
+
{
|
|
186
|
+
kind: "dependency",
|
|
187
|
+
id: "phase:ship/implement",
|
|
188
|
+
status: "open",
|
|
189
|
+
},
|
|
190
|
+
],
|
|
191
|
+
})
|
|
192
|
+
const epic = graph.nodes.find((node) => node.id === "epic:delivery")!
|
|
193
|
+
expect(epic.aggregate).toMatchObject({ total: 3, done: 1, open: 2 })
|
|
194
|
+
expect(graph.summary).toEqual({
|
|
195
|
+
status: "open",
|
|
196
|
+
total: 3,
|
|
197
|
+
open: 2,
|
|
198
|
+
working: 0,
|
|
199
|
+
delegated: 0,
|
|
200
|
+
done: 1,
|
|
201
|
+
dropped: 0,
|
|
202
|
+
terminal: 1,
|
|
203
|
+
})
|
|
204
|
+
expect(await getGraph(root)).toEqual(graph)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test("never reports claimed or terminal execution units as ready", async () => {
|
|
208
|
+
const root = await createWorkbase()
|
|
209
|
+
roots.push(root)
|
|
210
|
+
const path = "tasks/ship/phases/implement/PHASE.md"
|
|
211
|
+
|
|
212
|
+
for (const status of ["working", "delegated", "done", "dropped"]) {
|
|
213
|
+
await write(
|
|
214
|
+
root,
|
|
215
|
+
path,
|
|
216
|
+
`---
|
|
217
|
+
repo: agency
|
|
218
|
+
branch: feat/implement
|
|
219
|
+
base: main
|
|
220
|
+
pr: null
|
|
221
|
+
status: ${status}
|
|
222
|
+
---
|
|
223
|
+
|
|
224
|
+
# Implement
|
|
225
|
+
`,
|
|
226
|
+
)
|
|
227
|
+
const graph = await getGraph(root, {
|
|
228
|
+
kinds: ["execution-unit"],
|
|
229
|
+
ready: true,
|
|
230
|
+
})
|
|
231
|
+
expect(graph.nodes.map((node) => node.key)).not.toContain(
|
|
232
|
+
"phase/ship/implement",
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
test("applies filters after computing graph state", async () => {
|
|
238
|
+
const root = await createWorkbase()
|
|
239
|
+
roots.push(root)
|
|
240
|
+
|
|
241
|
+
const ready = await getGraph(root, {
|
|
242
|
+
ready: true,
|
|
243
|
+
kinds: ["execution-unit"],
|
|
244
|
+
repositories: ["agency"],
|
|
245
|
+
statuses: ["open"],
|
|
246
|
+
})
|
|
247
|
+
expect(ready.nodes.map((node) => node.id)).toEqual([
|
|
248
|
+
"execution-unit:phase/ship/implement",
|
|
249
|
+
])
|
|
250
|
+
expect(ready.edges).toEqual([])
|
|
251
|
+
|
|
252
|
+
const blocked = await getGraph(root, { blocked: true, kinds: ["phase"] })
|
|
253
|
+
expect(blocked.nodes.map((node) => node.id)).toEqual(["phase:ship/verify"])
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
test("does not report structurally invalid phases as ready", async () => {
|
|
257
|
+
const root = await createWorkbase()
|
|
258
|
+
roots.push(root)
|
|
259
|
+
await write(
|
|
260
|
+
root,
|
|
261
|
+
"tasks/ship/phases/orphan/PHASE.md",
|
|
262
|
+
`---
|
|
263
|
+
repo: agency
|
|
264
|
+
branch: feat/orphan
|
|
265
|
+
base: main
|
|
266
|
+
pr: null
|
|
267
|
+
status: open
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
# Orphan
|
|
271
|
+
`,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
const graph = await getGraph(root, { kinds: ["phase"], ready: true })
|
|
275
|
+
expect(graph.nodes.map((node) => node.id)).not.toContain(
|
|
276
|
+
"phase:ship/orphan",
|
|
277
|
+
)
|
|
278
|
+
const complete = await getGraph(root)
|
|
279
|
+
expect(
|
|
280
|
+
complete.nodes.find((node) => node.id === "phase:ship/orphan")?.readiness,
|
|
281
|
+
).toMatchObject({
|
|
282
|
+
ready: false,
|
|
283
|
+
blocked: true,
|
|
284
|
+
blockers: [
|
|
285
|
+
{
|
|
286
|
+
kind: "validation",
|
|
287
|
+
id: "tasks/ship/TASK.md",
|
|
288
|
+
reason: "Unlisted phase 'orphan'",
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
blockedBy: ["tasks/ship/TASK.md"],
|
|
292
|
+
})
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
test("adds body, workspace, git, and PR details only when requested", async () => {
|
|
296
|
+
const root = await createWorkbase()
|
|
297
|
+
roots.push(root)
|
|
298
|
+
const baseline = await getGraph(root)
|
|
299
|
+
for (const node of baseline.nodes) {
|
|
300
|
+
expect("body" in node).toBe(false)
|
|
301
|
+
expect("workspace" in node).toBe(false)
|
|
302
|
+
expect("git" in node).toBe(false)
|
|
303
|
+
expect("pr" in node).toBe(false)
|
|
304
|
+
}
|
|
305
|
+
expect(baseline.workbase.root).toBeUndefined()
|
|
306
|
+
|
|
307
|
+
const detailed = await getGraph(root, {
|
|
308
|
+
include: ["bodies", "workspace", "git", "pr"],
|
|
309
|
+
})
|
|
310
|
+
expect(detailed.includes).toEqual(["bodies", "git", "pr", "workspace"])
|
|
311
|
+
expect(detailed.workbase.root).toBe(root)
|
|
312
|
+
const epic = detailed.nodes.find((node) => node.id === "epic:delivery")
|
|
313
|
+
const repository = detailed.nodes.find(
|
|
314
|
+
(node) => node.id === "repository:agency",
|
|
315
|
+
)
|
|
316
|
+
const execution = detailed.nodes.find(
|
|
317
|
+
(node) => node.id === "execution-unit:phase/ship/implement",
|
|
318
|
+
)
|
|
319
|
+
if (epic?.kind !== "epic") throw new Error("Missing epic node")
|
|
320
|
+
if (repository?.kind !== "repository") {
|
|
321
|
+
throw new Error("Missing repository node")
|
|
322
|
+
}
|
|
323
|
+
if (execution?.kind !== "execution-unit") {
|
|
324
|
+
throw new Error("Missing execution-unit node")
|
|
325
|
+
}
|
|
326
|
+
expect(epic?.body).toContain("Deliver the graph")
|
|
327
|
+
expect(repository?.git).toMatchObject({ kind: null })
|
|
328
|
+
expect(execution?.pr).toEqual({ url: null, state: "none" })
|
|
329
|
+
expect(Schema.decodeUnknownSync(AgencyGraph)(detailed)).toEqual(detailed)
|
|
330
|
+
})
|
|
331
|
+
})
|