@markjaquith/agency 2.11.0 → 2.13.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 +14 -4
- package/cli.ts +6 -3
- package/index.ts +1 -0
- package/package.json +1 -1
- package/schemas/agency-graph-v1.schema.json +6 -1
- package/src/cli-parser.test.ts +16 -0
- package/src/cli-parser.ts +29 -4
- package/src/cli.test.ts +70 -0
- package/src/commands/work.test.ts +39 -1
- package/src/commands/work.ts +60 -0
- package/src/graph-schema.test.ts +7 -0
- package/src/graph-schema.ts +2 -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 +55 -1
- package/src/services/GraphService.ts +14 -40
- package/src/services/PhaseService.ts +6 -0
- package/src/services/TaskPhaseService.test.ts +43 -0
- package/src/services/TaskService.ts +6 -0
- package/src/services/WorktreeService.test.ts +82 -7
- package/src/services/WorktreeService.ts +199 -41
- package/src/utils/command.ts +1 -0
|
@@ -154,12 +154,33 @@ describe("GraphService", () => {
|
|
|
154
154
|
)!
|
|
155
155
|
const verify = graph.nodes.find((node) => node.id === "phase:ship/verify")!
|
|
156
156
|
expect(prepare.dependents).toEqual(["task:ship"])
|
|
157
|
-
expect(
|
|
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 })
|
|
158
167
|
expect(implement.dependents).toEqual(["phase:ship/verify"])
|
|
159
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
|
+
})
|
|
160
179
|
expect(verify.readiness).toMatchObject({
|
|
161
180
|
ready: false,
|
|
162
181
|
blocked: true,
|
|
182
|
+
blockedBy: ["phase:ship/implement"],
|
|
183
|
+
terminal: false,
|
|
163
184
|
blockers: [
|
|
164
185
|
{
|
|
165
186
|
kind: "dependency",
|
|
@@ -168,6 +189,8 @@ describe("GraphService", () => {
|
|
|
168
189
|
},
|
|
169
190
|
],
|
|
170
191
|
})
|
|
192
|
+
const epic = graph.nodes.find((node) => node.id === "epic:delivery")!
|
|
193
|
+
expect(epic.aggregate).toMatchObject({ total: 3, done: 1, open: 2 })
|
|
171
194
|
expect(graph.summary).toEqual({
|
|
172
195
|
status: "open",
|
|
173
196
|
total: 3,
|
|
@@ -181,6 +204,36 @@ describe("GraphService", () => {
|
|
|
181
204
|
expect(await getGraph(root)).toEqual(graph)
|
|
182
205
|
})
|
|
183
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
|
+
|
|
184
237
|
test("applies filters after computing graph state", async () => {
|
|
185
238
|
const root = await createWorkbase()
|
|
186
239
|
roots.push(root)
|
|
@@ -235,6 +288,7 @@ status: open
|
|
|
235
288
|
reason: "Unlisted phase 'orphan'",
|
|
236
289
|
},
|
|
237
290
|
],
|
|
291
|
+
blockedBy: ["tasks/ship/TASK.md"],
|
|
238
292
|
})
|
|
239
293
|
})
|
|
240
294
|
|
|
@@ -12,9 +12,13 @@ import {
|
|
|
12
12
|
type GraphNode,
|
|
13
13
|
type GraphNodeKind,
|
|
14
14
|
type GraphPr,
|
|
15
|
-
type GraphProgress,
|
|
16
15
|
type GraphRepositoryGit,
|
|
17
16
|
} from "../graph-schema"
|
|
17
|
+
import {
|
|
18
|
+
aggregateProgress,
|
|
19
|
+
isDependencySatisfied,
|
|
20
|
+
readinessState,
|
|
21
|
+
} from "../readiness"
|
|
18
22
|
import { parseFrontmatter } from "../workbase/frontmatter"
|
|
19
23
|
import {
|
|
20
24
|
EpicFrontmatter,
|
|
@@ -69,33 +73,6 @@ const taskExecutionNodeId = (taskId: string) => `execution-unit:task/${taskId}`
|
|
|
69
73
|
const phaseExecutionNodeId = (taskId: string, phaseId: string) =>
|
|
70
74
|
`execution-unit:phase/${taskId}/${phaseId}`
|
|
71
75
|
|
|
72
|
-
const progress = (statuses: readonly WorkStatus[]): GraphProgress => {
|
|
73
|
-
const counts = {
|
|
74
|
-
total: statuses.length,
|
|
75
|
-
open: statuses.filter((status) => status === "open").length,
|
|
76
|
-
working: statuses.filter((status) => status === "working").length,
|
|
77
|
-
delegated: statuses.filter((status) => status === "delegated").length,
|
|
78
|
-
done: statuses.filter((status) => status === "done").length,
|
|
79
|
-
dropped: statuses.filter((status) => status === "dropped").length,
|
|
80
|
-
terminal: statuses.filter(
|
|
81
|
-
(status) => status === "done" || status === "dropped",
|
|
82
|
-
).length,
|
|
83
|
-
}
|
|
84
|
-
const status: WorkStatus =
|
|
85
|
-
statuses.length === 0
|
|
86
|
-
? "open"
|
|
87
|
-
: statuses.every((value) => value === "done")
|
|
88
|
-
? "done"
|
|
89
|
-
: statuses.every((value) => value === "done" || value === "dropped")
|
|
90
|
-
? "dropped"
|
|
91
|
-
: statuses.includes("working")
|
|
92
|
-
? "working"
|
|
93
|
-
: statuses.includes("delegated")
|
|
94
|
-
? "delegated"
|
|
95
|
-
: "open"
|
|
96
|
-
return { status, ...counts }
|
|
97
|
-
}
|
|
98
|
-
|
|
99
76
|
const hash = (content: string) =>
|
|
100
77
|
new Bun.CryptoHasher("sha256").update(content).digest("hex")
|
|
101
78
|
|
|
@@ -256,7 +233,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
256
233
|
: [task.data.status]
|
|
257
234
|
}
|
|
258
235
|
const taskStatus = (taskId: string) =>
|
|
259
|
-
|
|
236
|
+
aggregateProgress(taskLeafStatuses(taskId)).status
|
|
260
237
|
const dependencyBlockers = (
|
|
261
238
|
dependencies: readonly string[],
|
|
262
239
|
toId: (id: string) => string,
|
|
@@ -265,7 +242,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
265
242
|
): GraphBlocker[] =>
|
|
266
243
|
dependencies.flatMap((dependency) => {
|
|
267
244
|
const value = status(dependency)
|
|
268
|
-
return value
|
|
245
|
+
return isDependencySatisfied(value)
|
|
269
246
|
? []
|
|
270
247
|
: [
|
|
271
248
|
{
|
|
@@ -336,10 +313,9 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
336
313
|
}
|
|
337
314
|
return {
|
|
338
315
|
status,
|
|
339
|
-
aggregate:
|
|
316
|
+
aggregate: aggregateProgress([status]),
|
|
340
317
|
readiness: {
|
|
341
|
-
|
|
342
|
-
blocked: status === "open" && blockers.length > 0,
|
|
318
|
+
...readinessState(status, blockers),
|
|
343
319
|
blockers: uniqueBlockers(blockers),
|
|
344
320
|
},
|
|
345
321
|
}
|
|
@@ -348,7 +324,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
348
324
|
const taskState = (taskId: string) => {
|
|
349
325
|
const task = tasks.get(taskId)
|
|
350
326
|
const statuses = taskLeafStatuses(taskId)
|
|
351
|
-
const aggregate =
|
|
327
|
+
const aggregate = aggregateProgress(statuses)
|
|
352
328
|
const descendantPaths = task
|
|
353
329
|
? [
|
|
354
330
|
relative(root, task.path),
|
|
@@ -410,8 +386,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
410
386
|
status: aggregate.status,
|
|
411
387
|
aggregate,
|
|
412
388
|
readiness: {
|
|
413
|
-
ready,
|
|
414
|
-
blocked: !ready && taskBlockers.length > 0,
|
|
389
|
+
...readinessState(aggregate.status, taskBlockers, ready),
|
|
415
390
|
blockers: taskBlockers,
|
|
416
391
|
},
|
|
417
392
|
}
|
|
@@ -422,7 +397,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
422
397
|
const statuses =
|
|
423
398
|
epic?.data.tasks.flatMap((item) => taskLeafStatuses(item.id)) ??
|
|
424
399
|
[]
|
|
425
|
-
const aggregate =
|
|
400
|
+
const aggregate = aggregateProgress(statuses)
|
|
426
401
|
const paths = epic
|
|
427
402
|
? [
|
|
428
403
|
relative(root, epic.path),
|
|
@@ -467,8 +442,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
467
442
|
status: aggregate.status,
|
|
468
443
|
aggregate,
|
|
469
444
|
readiness: {
|
|
470
|
-
ready,
|
|
471
|
-
blocked: !ready && epicBlockers.length > 0,
|
|
445
|
+
...readinessState(aggregate.status, epicBlockers, ready),
|
|
472
446
|
blockers: epicBlockers,
|
|
473
447
|
},
|
|
474
448
|
}
|
|
@@ -874,7 +848,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
874
848
|
includes,
|
|
875
849
|
nodes: filteredNodes,
|
|
876
850
|
edges: filteredEdges,
|
|
877
|
-
summary:
|
|
851
|
+
summary: aggregateProgress(leafStatuses),
|
|
878
852
|
validation: {
|
|
879
853
|
valid: validation.valid,
|
|
880
854
|
issues: validation.issues,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
formatMarkdownDocument,
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
|
+
import { canTransitionStatus } from "../readiness"
|
|
18
19
|
|
|
19
20
|
class PhaseError extends Data.TaggedError("PhaseError")<{
|
|
20
21
|
readonly message: string
|
|
@@ -340,6 +341,11 @@ export class PhaseService extends Effect.Service<PhaseService>()(
|
|
|
340
341
|
const service = yield* PhaseService
|
|
341
342
|
const validStatus = yield* decodeStatus(status)
|
|
342
343
|
const record = yield* service.show(taskId, id, startPath)
|
|
344
|
+
if (!canTransitionStatus(record.data.status, validStatus)) {
|
|
345
|
+
return yield* new PhaseError({
|
|
346
|
+
message: `Cannot transition phase '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
343
349
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
344
350
|
const data = { ...record.data, status: validStatus }
|
|
345
351
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
@@ -244,6 +244,22 @@ describe("task and phase services", () => {
|
|
|
244
244
|
expect(task.data.status).toBe("delegated")
|
|
245
245
|
expect(task.content).toContain("status: delegated")
|
|
246
246
|
expect(task.content).toContain("Describe the task outcome.")
|
|
247
|
+
await runTestEffect(
|
|
248
|
+
TaskService.pipe(
|
|
249
|
+
Effect.flatMap((service) =>
|
|
250
|
+
service.setStatus("single-status", "done", root),
|
|
251
|
+
),
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
await expect(
|
|
255
|
+
runTestEffect(
|
|
256
|
+
TaskService.pipe(
|
|
257
|
+
Effect.flatMap((service) =>
|
|
258
|
+
service.setStatus("single-status", "dropped", root),
|
|
259
|
+
),
|
|
260
|
+
),
|
|
261
|
+
),
|
|
262
|
+
).rejects.toThrow("reopen it first")
|
|
247
263
|
|
|
248
264
|
await runTestEffect(
|
|
249
265
|
TaskService.pipe(
|
|
@@ -283,6 +299,33 @@ describe("task and phase services", () => {
|
|
|
283
299
|
),
|
|
284
300
|
)
|
|
285
301
|
expect(phase.data.status).toBe("dropped")
|
|
302
|
+
await expect(
|
|
303
|
+
runTestEffect(
|
|
304
|
+
PhaseService.pipe(
|
|
305
|
+
Effect.flatMap((service) =>
|
|
306
|
+
service.setStatus("multi-status", "implementation", "done", root),
|
|
307
|
+
),
|
|
308
|
+
),
|
|
309
|
+
),
|
|
310
|
+
).rejects.toThrow("reopen it first")
|
|
311
|
+
await runTestEffect(
|
|
312
|
+
PhaseService.pipe(
|
|
313
|
+
Effect.flatMap((service) =>
|
|
314
|
+
service.setStatus("multi-status", "implementation", "open", root),
|
|
315
|
+
),
|
|
316
|
+
),
|
|
317
|
+
)
|
|
318
|
+
expect(
|
|
319
|
+
(
|
|
320
|
+
await runTestEffect(
|
|
321
|
+
PhaseService.pipe(
|
|
322
|
+
Effect.flatMap((service) =>
|
|
323
|
+
service.setStatus("multi-status", "implementation", "done", root),
|
|
324
|
+
),
|
|
325
|
+
),
|
|
326
|
+
)
|
|
327
|
+
).data.status,
|
|
328
|
+
).toBe("done")
|
|
286
329
|
await expect(
|
|
287
330
|
runTestEffect(
|
|
288
331
|
TaskService.pipe(
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
formatMarkdownDocument,
|
|
16
16
|
parseFrontmatter,
|
|
17
17
|
} from "../workbase/frontmatter"
|
|
18
|
+
import { canTransitionStatus } from "../readiness"
|
|
18
19
|
|
|
19
20
|
class TaskError extends Data.TaggedError("TaskError")<{
|
|
20
21
|
readonly message: string
|
|
@@ -212,6 +213,11 @@ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
|
|
|
212
213
|
message: `Task '${id}' has multiple phases; set status on a phase instead`,
|
|
213
214
|
})
|
|
214
215
|
}
|
|
216
|
+
if (!canTransitionStatus(record.data.status, validStatus)) {
|
|
217
|
+
return yield* new TaskError({
|
|
218
|
+
message: `Cannot transition task '${id}' from ${record.data.status} to ${validStatus}; reopen it first`,
|
|
219
|
+
})
|
|
220
|
+
}
|
|
215
221
|
const parsed = yield* parseFrontmatter(record.content, record.path)
|
|
216
222
|
const data = { ...record.data, status: validStatus }
|
|
217
223
|
const content = formatMarkdownDocument(data, parsed.body)
|
|
@@ -113,15 +113,17 @@ describe("WorktreeService", () => {
|
|
|
113
113
|
["remote", "set-url", "origin", join(root, "missing")],
|
|
114
114
|
join(root, "repos/agency"),
|
|
115
115
|
)
|
|
116
|
-
await
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
service.materialize("existing", undefined, root),
|
|
121
|
-
),
|
|
116
|
+
const reused = await runTestEffect(
|
|
117
|
+
WorktreeService.pipe(
|
|
118
|
+
Effect.flatMap((service) =>
|
|
119
|
+
service.materialize("existing", undefined, root),
|
|
122
120
|
),
|
|
123
121
|
),
|
|
124
|
-
)
|
|
122
|
+
)
|
|
123
|
+
expect(reused.checkouts).toEqual([
|
|
124
|
+
expect.objectContaining({ repo: "agency", action: "reused" }),
|
|
125
|
+
])
|
|
126
|
+
expect(reused.operations).toEqual([])
|
|
125
127
|
})
|
|
126
128
|
|
|
127
129
|
test("reuses an immutable reference checkout without fetching", async () => {
|
|
@@ -771,6 +773,79 @@ pr: null
|
|
|
771
773
|
"refs/heads/task/removable",
|
|
772
774
|
])
|
|
773
775
|
expect(branch.exitCode).toBe(0)
|
|
776
|
+
expect(workspace.checkouts).toEqual([
|
|
777
|
+
expect.objectContaining({
|
|
778
|
+
repo: "agency",
|
|
779
|
+
kind: "writable",
|
|
780
|
+
action: "created",
|
|
781
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
782
|
+
}),
|
|
783
|
+
expect.objectContaining({
|
|
784
|
+
repo: "effect",
|
|
785
|
+
kind: "reference",
|
|
786
|
+
action: "created",
|
|
787
|
+
resolvedCommit: expect.stringMatching(/^[0-9a-f]{40}$/),
|
|
788
|
+
}),
|
|
789
|
+
])
|
|
790
|
+
})
|
|
791
|
+
|
|
792
|
+
test("reports dry-run fetch and worktree changes without mutating", async () => {
|
|
793
|
+
await runTestEffect(
|
|
794
|
+
TaskService.pipe(
|
|
795
|
+
Effect.flatMap((service) =>
|
|
796
|
+
service.create(
|
|
797
|
+
{
|
|
798
|
+
id: "planned",
|
|
799
|
+
ticketUrl: "https://example.com/task",
|
|
800
|
+
repo: "agency",
|
|
801
|
+
repos: [{ repo: "effect", ref: "main" }],
|
|
802
|
+
branch: "task/planned",
|
|
803
|
+
base: "main",
|
|
804
|
+
},
|
|
805
|
+
root,
|
|
806
|
+
),
|
|
807
|
+
),
|
|
808
|
+
),
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
const workspace = await runTestEffect(
|
|
812
|
+
WorktreeService.pipe(
|
|
813
|
+
Effect.flatMap((service) =>
|
|
814
|
+
service.materialize("planned", undefined, root, { dryRun: true }),
|
|
815
|
+
),
|
|
816
|
+
),
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
expect(workspace.dryRun).toBe(true)
|
|
820
|
+
expect(
|
|
821
|
+
workspace.checkouts.every(({ resolvedCommit }) => resolvedCommit),
|
|
822
|
+
).toBe(true)
|
|
823
|
+
expect(
|
|
824
|
+
workspace.checkouts.map(({ repo, action }) => ({ repo, action })),
|
|
825
|
+
).toEqual([
|
|
826
|
+
{ repo: "agency", action: "created" },
|
|
827
|
+
{ repo: "effect", action: "created" },
|
|
828
|
+
])
|
|
829
|
+
expect(workspace.operations).toEqual(
|
|
830
|
+
expect.arrayContaining([
|
|
831
|
+
expect.objectContaining({ action: "fetch", status: "planned" }),
|
|
832
|
+
expect.objectContaining({
|
|
833
|
+
action: "create-worktree",
|
|
834
|
+
status: "planned",
|
|
835
|
+
}),
|
|
836
|
+
]),
|
|
837
|
+
)
|
|
838
|
+
expect(await Bun.file(workspace.codePath).exists()).toBe(false)
|
|
839
|
+
expect(
|
|
840
|
+
Bun.spawnSync([
|
|
841
|
+
"git",
|
|
842
|
+
"-C",
|
|
843
|
+
join(root, "repos/agency"),
|
|
844
|
+
"show-ref",
|
|
845
|
+
"--verify",
|
|
846
|
+
"refs/heads/task/planned",
|
|
847
|
+
]).exitCode,
|
|
848
|
+
).not.toBe(0)
|
|
774
849
|
})
|
|
775
850
|
|
|
776
851
|
test("refuses to remove a worktree with uncommitted changes", async () => {
|