@markjaquith/agency 2.15.0 → 2.17.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 +35 -1
- package/cli.ts +40 -0
- package/package.json +1 -1
- package/src/cli-parser.test.ts +34 -0
- package/src/cli-parser.ts +38 -4
- package/src/cli.test.ts +50 -0
- package/src/commands/next.ts +64 -0
- package/src/commands/pr.ts +2 -0
- package/src/commands/read-only.test.ts +2 -0
- package/src/commands/sync.ts +36 -0
- package/src/commands/work.test.ts +84 -0
- package/src/commands/work.ts +25 -2
- package/src/protocol.ts +11 -0
- package/src/services/ClaimService.ts +94 -0
- package/src/services/GraphService.test.ts +36 -0
- package/src/services/GraphService.ts +8 -1
- package/src/services/PullRequestService.test.ts +20 -1
- package/src/services/PullRequestService.ts +14 -1
- package/src/services/ReadinessService.test.ts +223 -0
- package/src/services/ReadinessService.ts +230 -0
- package/src/services/SyncService.test.ts +364 -0
- package/src/services/SyncService.ts +738 -0
- package/src/services/WorktreeService.test.ts +12 -0
- package/src/services/WorktreeService.ts +6 -2
- package/src/test-utils.ts +4 -0
package/src/commands/work.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { EpicService } from "../services/EpicService"
|
|
|
8
8
|
import { TaskService } from "../services/TaskService"
|
|
9
9
|
import { PhaseService } from "../services/PhaseService"
|
|
10
10
|
import { ClaimService } from "../services/ClaimService"
|
|
11
|
+
import { ReadinessService } from "../services/ReadinessService"
|
|
11
12
|
import { createLoggers } from "../utils/effect"
|
|
12
13
|
import { execvp } from "../utils/exec"
|
|
13
14
|
import { createProgress, type Progress } from "../utils/progress"
|
|
@@ -30,6 +31,7 @@ interface WorkOptions extends BaseCommandOptions {
|
|
|
30
31
|
readonly epicId?: string
|
|
31
32
|
readonly opencode?: boolean
|
|
32
33
|
readonly claude?: boolean
|
|
34
|
+
readonly force?: boolean
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
type LaunchAgent = (cli: string, args: readonly string[], cwd: string) => void
|
|
@@ -48,6 +50,16 @@ const launchAgent: LaunchAgent = (cli, args, cwd) => {
|
|
|
48
50
|
execvp(cli, [...args])
|
|
49
51
|
}
|
|
50
52
|
|
|
53
|
+
const targetNodeId = (target: WorkTarget) => {
|
|
54
|
+
if (target.kind === "epic") return `epic:${target.epicId}`
|
|
55
|
+
if (target.kind === "phase") {
|
|
56
|
+
return `execution-unit:phase/${target.taskId}/${target.phaseId}`
|
|
57
|
+
}
|
|
58
|
+
return target.multiPhase
|
|
59
|
+
? `task:${target.taskId}`
|
|
60
|
+
: `execution-unit:task/${target.taskId}`
|
|
61
|
+
}
|
|
62
|
+
|
|
51
63
|
export const work = (
|
|
52
64
|
options: WorkOptions = {},
|
|
53
65
|
launch: LaunchAgent = launchAgent,
|
|
@@ -79,6 +91,7 @@ export const work = (
|
|
|
79
91
|
const tasks = yield* TaskService
|
|
80
92
|
const phases = yield* PhaseService
|
|
81
93
|
const claims = yield* ClaimService
|
|
94
|
+
const readiness = yield* ReadinessService
|
|
82
95
|
const { log, verboseLog } = createLoggers(options)
|
|
83
96
|
const cwd = options.cwd ?? process.cwd()
|
|
84
97
|
const directoryPath = options.directory
|
|
@@ -168,20 +181,29 @@ export const work = (
|
|
|
168
181
|
phaseRecords.push(...(yield* phases.list(task.id, root)))
|
|
169
182
|
}
|
|
170
183
|
}
|
|
171
|
-
const
|
|
184
|
+
const allChoices = buildWorkTargetChoices(
|
|
172
185
|
epicRecords,
|
|
173
186
|
taskRecords,
|
|
174
187
|
phaseRecords,
|
|
175
188
|
)
|
|
189
|
+
const readyTargetIds = options.force
|
|
190
|
+
? null
|
|
191
|
+
: yield* readiness.getReadyWorkTargetIds(root)
|
|
192
|
+
const choices = options.force
|
|
193
|
+
? allChoices
|
|
194
|
+
: allChoices.filter((choice) =>
|
|
195
|
+
readyTargetIds!.has(targetNodeId(choice.target)),
|
|
196
|
+
)
|
|
176
197
|
if (choices.length === 0) {
|
|
177
198
|
return yield* Effect.fail(
|
|
178
|
-
new Error("No
|
|
199
|
+
new Error("No ready work targets found in this workbase"),
|
|
179
200
|
)
|
|
180
201
|
}
|
|
181
202
|
const { config } = yield* workbase.loadConfig(root)
|
|
182
203
|
target = yield* pick(choices, config.chooserCommand)
|
|
183
204
|
if (!target) return
|
|
184
205
|
}
|
|
206
|
+
yield* readiness.guardWorkTarget(targetNodeId(target), root, options.force)
|
|
185
207
|
|
|
186
208
|
let prompt: string
|
|
187
209
|
let launchPath: string
|
|
@@ -334,6 +356,7 @@ Options:
|
|
|
334
356
|
--epic <id> Work on an epic
|
|
335
357
|
--opencode Require OpenCode
|
|
336
358
|
--claude Require Claude Code
|
|
359
|
+
--force Override readiness and terminal-state guards
|
|
337
360
|
--no-input Never open an interactive selector
|
|
338
361
|
|
|
339
362
|
Without interactive input, provide a directory, task ID, or --epic and run the
|
package/src/protocol.ts
CHANGED
|
@@ -111,6 +111,17 @@ const errorMetadata: Readonly<Record<string, ErrorMetadata>> = {
|
|
|
111
111
|
retryable: false,
|
|
112
112
|
remediation: "Correct the workbase graph data or filters and retry.",
|
|
113
113
|
},
|
|
114
|
+
ExecutionGuardError: {
|
|
115
|
+
code: "EXECUTION_BLOCKED",
|
|
116
|
+
retryable: false,
|
|
117
|
+
remediation:
|
|
118
|
+
"Resolve the reported blockers or retry intentionally with --force.",
|
|
119
|
+
},
|
|
120
|
+
SyncError: {
|
|
121
|
+
code: "SYNC_ERROR",
|
|
122
|
+
retryable: false,
|
|
123
|
+
remediation: "Resolve workbase validation errors before reconciling.",
|
|
124
|
+
},
|
|
114
125
|
ProcessError: { code: "PROCESS_ERROR", retryable: true },
|
|
115
126
|
ProtocolOutputError: {
|
|
116
127
|
code: "PROTOCOL_OUTPUT_ERROR",
|
|
@@ -87,6 +87,23 @@ interface FinishInput extends OwnedClaimInput {
|
|
|
87
87
|
readonly outcome: "done" | "dropped"
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
+
interface ExpireClaimInput {
|
|
91
|
+
readonly taskId: string
|
|
92
|
+
readonly phaseId?: string
|
|
93
|
+
readonly revision: string
|
|
94
|
+
readonly now?: Date
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface ReconcileInput {
|
|
98
|
+
readonly taskId: string
|
|
99
|
+
readonly phaseId?: string
|
|
100
|
+
readonly revision: string
|
|
101
|
+
readonly pr?: string
|
|
102
|
+
readonly status?: "done"
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
106
|
+
|
|
90
107
|
type SingleTaskData = Extract<TaskData, { readonly repo: string }>
|
|
91
108
|
type ExecutionData = SingleTaskData | PhaseData
|
|
92
109
|
|
|
@@ -286,6 +303,83 @@ export class ClaimService extends Effect.Service<ClaimService>()(
|
|
|
286
303
|
}
|
|
287
304
|
}),
|
|
288
305
|
|
|
306
|
+
expire: (input: ExpireClaimInput, startPath: string = process.cwd()) =>
|
|
307
|
+
Effect.gen(function* () {
|
|
308
|
+
const service = yield* ClaimService
|
|
309
|
+
const inspected = yield* service.inspect(
|
|
310
|
+
input.taskId,
|
|
311
|
+
input.phaseId,
|
|
312
|
+
startPath,
|
|
313
|
+
)
|
|
314
|
+
return yield* operation(() =>
|
|
315
|
+
updateAtomically(
|
|
316
|
+
inspected.target,
|
|
317
|
+
input.revision,
|
|
318
|
+
(data, now) => {
|
|
319
|
+
if (
|
|
320
|
+
data.claim?.state !== "active" ||
|
|
321
|
+
data.claim.expiresAt === undefined ||
|
|
322
|
+
Date.parse(data.claim.expiresAt) > now.getTime() ||
|
|
323
|
+
(data.status !== "working" && data.status !== "delegated")
|
|
324
|
+
) {
|
|
325
|
+
throw new ClaimError({
|
|
326
|
+
target: inspected.target.label,
|
|
327
|
+
message: `${inspected.target.label} does not have an expired active claim`,
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
const claim: ClaimRecord = {
|
|
331
|
+
...data.claim,
|
|
332
|
+
state: "released",
|
|
333
|
+
releasedAt: now.toISOString(),
|
|
334
|
+
}
|
|
335
|
+
return { data: { ...data, status: "open", claim }, claim }
|
|
336
|
+
},
|
|
337
|
+
input.now ?? new Date(),
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
}),
|
|
341
|
+
|
|
342
|
+
reconcile: (input: ReconcileInput, startPath: string = process.cwd()) =>
|
|
343
|
+
Effect.gen(function* () {
|
|
344
|
+
if (input.pr !== undefined && !PR_URL.test(input.pr)) {
|
|
345
|
+
return yield* new ClaimError({
|
|
346
|
+
message: `Invalid GitHub pull request URL: ${input.pr}`,
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
const service = yield* ClaimService
|
|
350
|
+
const inspected = yield* service.inspect(
|
|
351
|
+
input.taskId,
|
|
352
|
+
input.phaseId,
|
|
353
|
+
startPath,
|
|
354
|
+
)
|
|
355
|
+
return yield* operation(() =>
|
|
356
|
+
updateAtomically(
|
|
357
|
+
inspected.target,
|
|
358
|
+
input.revision,
|
|
359
|
+
(data) => {
|
|
360
|
+
if (input.status === "done" && data.claim?.state === "active") {
|
|
361
|
+
throw new ClaimConflictError({
|
|
362
|
+
target: inspected.target.label,
|
|
363
|
+
currentRevision: input.revision,
|
|
364
|
+
claim: data.claim,
|
|
365
|
+
message: `${inspected.target.label} has an active claim`,
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
return {
|
|
369
|
+
data: {
|
|
370
|
+
...data,
|
|
371
|
+
...(input.pr !== undefined ? { pr: input.pr } : {}),
|
|
372
|
+
...(input.status !== undefined
|
|
373
|
+
? { status: input.status }
|
|
374
|
+
: {}),
|
|
375
|
+
},
|
|
376
|
+
}
|
|
377
|
+
},
|
|
378
|
+
new Date(),
|
|
379
|
+
),
|
|
380
|
+
)
|
|
381
|
+
}),
|
|
382
|
+
|
|
289
383
|
claim: (input: ClaimInput, startPath: string = process.cwd()) =>
|
|
290
384
|
Effect.gen(function* () {
|
|
291
385
|
for (const [label, value] of [
|
|
@@ -292,6 +292,42 @@ status: open
|
|
|
292
292
|
})
|
|
293
293
|
})
|
|
294
294
|
|
|
295
|
+
test("inherits parent epic validation blockers", async () => {
|
|
296
|
+
const root = await createWorkbase()
|
|
297
|
+
roots.push(root)
|
|
298
|
+
await write(
|
|
299
|
+
root,
|
|
300
|
+
"tasks/unlisted/TASK.md",
|
|
301
|
+
`---
|
|
302
|
+
ticketUrl: null
|
|
303
|
+
epic: delivery
|
|
304
|
+
repo: agency
|
|
305
|
+
branch: feat/unlisted
|
|
306
|
+
base: main
|
|
307
|
+
pr: null
|
|
308
|
+
status: open
|
|
309
|
+
---
|
|
310
|
+
|
|
311
|
+
# Unlisted
|
|
312
|
+
`,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
const graph = await getGraph(root)
|
|
316
|
+
const execution = graph.nodes.find(
|
|
317
|
+
(node) => node.id === "execution-unit:task/unlisted",
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
expect(execution?.readiness).toMatchObject({
|
|
321
|
+
ready: false,
|
|
322
|
+
blockers: [
|
|
323
|
+
{
|
|
324
|
+
kind: "validation",
|
|
325
|
+
reason: "Epic does not list child task 'unlisted'",
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
})
|
|
329
|
+
})
|
|
330
|
+
|
|
295
331
|
test("adds body, workspace, git, and PR details only when requested", async () => {
|
|
296
332
|
const root = await createWorkbase()
|
|
297
333
|
roots.push(root)
|
|
@@ -279,6 +279,9 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
279
279
|
const phaseState = (taskId: string, phaseId: string) => {
|
|
280
280
|
const task = tasks.get(taskId)
|
|
281
281
|
const phase = phases.get(`${taskId}/${phaseId}`)
|
|
282
|
+
const parentEpic = task?.data.epic
|
|
283
|
+
? epics.get(task.data.epic)
|
|
284
|
+
: undefined
|
|
282
285
|
const declaration =
|
|
283
286
|
task && "phases" in task.data
|
|
284
287
|
? task.data.phases.find((item) => item.id === phaseId)
|
|
@@ -297,7 +300,7 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
297
300
|
"Parent task",
|
|
298
301
|
),
|
|
299
302
|
...validationBlockers(
|
|
300
|
-
[phase?.path, task?.path]
|
|
303
|
+
[phase?.path, task?.path, parentEpic?.path]
|
|
301
304
|
.filter((path): path is string => Boolean(path))
|
|
302
305
|
.map((path) => relative(root, path)),
|
|
303
306
|
),
|
|
@@ -323,11 +326,15 @@ export class GraphService extends Effect.Service<GraphService>()(
|
|
|
323
326
|
|
|
324
327
|
const taskState = (taskId: string) => {
|
|
325
328
|
const task = tasks.get(taskId)
|
|
329
|
+
const parentEpic = task?.data.epic
|
|
330
|
+
? epics.get(task.data.epic)
|
|
331
|
+
: undefined
|
|
326
332
|
const statuses = taskLeafStatuses(taskId)
|
|
327
333
|
const aggregate = aggregateProgress(statuses)
|
|
328
334
|
const descendantPaths = task
|
|
329
335
|
? [
|
|
330
336
|
relative(root, task.path),
|
|
337
|
+
...(parentEpic ? [relative(root, parentEpic.path)] : []),
|
|
331
338
|
...[...phases.entries()]
|
|
332
339
|
.filter(([key]) => key.startsWith(`${taskId}/`))
|
|
333
340
|
.map(([, phase]) => relative(root, phase.path)),
|
|
@@ -67,11 +67,12 @@ describe("PullRequestService", () => {
|
|
|
67
67
|
taskId = "example",
|
|
68
68
|
phaseId?: string,
|
|
69
69
|
draft = false,
|
|
70
|
+
force = false,
|
|
70
71
|
) =>
|
|
71
72
|
runTestEffect(
|
|
72
73
|
PullRequestService.pipe(
|
|
73
74
|
Effect.flatMap((service) =>
|
|
74
|
-
service.create(taskId, phaseId, draft, root),
|
|
75
|
+
service.create(taskId, phaseId, draft, root, { force }),
|
|
75
76
|
),
|
|
76
77
|
),
|
|
77
78
|
)
|
|
@@ -249,6 +250,24 @@ process.exit(${exitCode})
|
|
|
249
250
|
])
|
|
250
251
|
})
|
|
251
252
|
|
|
253
|
+
test("guards terminal targets before materializing unless forced", async () => {
|
|
254
|
+
await createTask()
|
|
255
|
+
await runTestEffect(
|
|
256
|
+
TaskService.pipe(
|
|
257
|
+
Effect.flatMap((service) => service.setStatus("example", "done", root)),
|
|
258
|
+
),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
await expect(createPullRequest()).rejects.toThrow("Task status is done")
|
|
262
|
+
expect(
|
|
263
|
+
await Bun.file(join(root, "tasks/example/code/agency")).exists(),
|
|
264
|
+
).toBe(false)
|
|
265
|
+
|
|
266
|
+
const url = "https://github.com/example/agency/pull/49"
|
|
267
|
+
await writeFakeGh({ stdout: url })
|
|
268
|
+
expect(await createPullRequest("example", undefined, false, true)).toBe(url)
|
|
269
|
+
})
|
|
270
|
+
|
|
252
271
|
test("updates only PHASE.md for a phase PR", async () => {
|
|
253
272
|
await runTestEffect(
|
|
254
273
|
TaskService.pipe(
|
|
@@ -5,6 +5,7 @@ import { WorktreeService } from "./WorktreeService"
|
|
|
5
5
|
import type { BaseCommandOptions } from "../utils/command"
|
|
6
6
|
import { TaskService } from "./TaskService"
|
|
7
7
|
import { PhaseService } from "./PhaseService"
|
|
8
|
+
import { ReadinessService } from "./ReadinessService"
|
|
8
9
|
import {
|
|
9
10
|
formatMarkdownDocument,
|
|
10
11
|
parseFrontmatter,
|
|
@@ -16,6 +17,10 @@ class PullRequestError extends Data.TaggedError("PullRequestError")<{
|
|
|
16
17
|
|
|
17
18
|
const PR_URL = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+\/?$/
|
|
18
19
|
|
|
20
|
+
interface PullRequestOptions extends BaseCommandOptions {
|
|
21
|
+
readonly force?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
19
24
|
export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
20
25
|
"PullRequestService",
|
|
21
26
|
{
|
|
@@ -60,7 +65,7 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
60
65
|
phaseId?: string,
|
|
61
66
|
draft = false,
|
|
62
67
|
startPath: string = process.cwd(),
|
|
63
|
-
options:
|
|
68
|
+
options: PullRequestOptions = {},
|
|
64
69
|
) =>
|
|
65
70
|
Effect.gen(function* () {
|
|
66
71
|
const service = yield* PullRequestService
|
|
@@ -68,6 +73,14 @@ export class PullRequestService extends Effect.Service<PullRequestService>()(
|
|
|
68
73
|
const tasks = yield* TaskService
|
|
69
74
|
const phases = yield* PhaseService
|
|
70
75
|
const worktrees = yield* WorktreeService
|
|
76
|
+
const readiness = yield* ReadinessService
|
|
77
|
+
yield* readiness.guard(
|
|
78
|
+
"pr",
|
|
79
|
+
taskId,
|
|
80
|
+
phaseId,
|
|
81
|
+
startPath,
|
|
82
|
+
options.force,
|
|
83
|
+
)
|
|
71
84
|
const workspace = yield* worktrees.materialize(
|
|
72
85
|
taskId,
|
|
73
86
|
phaseId,
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test"
|
|
2
|
+
import { Effect } from "effect"
|
|
3
|
+
import { mkdir } from "node:fs/promises"
|
|
4
|
+
import { dirname, join } from "node:path"
|
|
5
|
+
import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
|
|
6
|
+
import { ReadinessService } from "./ReadinessService"
|
|
7
|
+
|
|
8
|
+
const write = async (root: string, path: string, content: string) => {
|
|
9
|
+
const fullPath = join(root, path)
|
|
10
|
+
await mkdir(dirname(fullPath), { recursive: true })
|
|
11
|
+
await Bun.write(fullPath, content)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const execution = (status: string, branch: string) => `---
|
|
15
|
+
repo: agency
|
|
16
|
+
branch: ${branch}
|
|
17
|
+
base: main
|
|
18
|
+
pr: null
|
|
19
|
+
status: ${status}
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Execution
|
|
23
|
+
`
|
|
24
|
+
|
|
25
|
+
const createWorkbase = async () => {
|
|
26
|
+
const root = await createTempDir()
|
|
27
|
+
await write(root, "agency.json", '{"version":2}\n')
|
|
28
|
+
await mkdir(join(root, "repos/agency"), { recursive: true })
|
|
29
|
+
await write(
|
|
30
|
+
root,
|
|
31
|
+
"epics/delivery/EPIC.md",
|
|
32
|
+
`---
|
|
33
|
+
ticketUrl: https://example.com/delivery
|
|
34
|
+
repos:
|
|
35
|
+
- repo: agency
|
|
36
|
+
ref: main
|
|
37
|
+
tasks:
|
|
38
|
+
- id: prepare
|
|
39
|
+
- id: ship
|
|
40
|
+
dependsOn: [prepare]
|
|
41
|
+
- id: deploy
|
|
42
|
+
dependsOn: [ship]
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
# Delivery
|
|
46
|
+
`,
|
|
47
|
+
)
|
|
48
|
+
await write(
|
|
49
|
+
root,
|
|
50
|
+
"tasks/prepare/TASK.md",
|
|
51
|
+
`---
|
|
52
|
+
ticketUrl: null
|
|
53
|
+
epic: delivery
|
|
54
|
+
repo: agency
|
|
55
|
+
branch: feat/prepare
|
|
56
|
+
base: main
|
|
57
|
+
pr: null
|
|
58
|
+
status: done
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# Prepare
|
|
62
|
+
`,
|
|
63
|
+
)
|
|
64
|
+
await write(
|
|
65
|
+
root,
|
|
66
|
+
"tasks/ship/TASK.md",
|
|
67
|
+
`---
|
|
68
|
+
ticketUrl: null
|
|
69
|
+
description: Ship the feature.
|
|
70
|
+
epic: delivery
|
|
71
|
+
phases:
|
|
72
|
+
- id: implement
|
|
73
|
+
- id: verify
|
|
74
|
+
dependsOn: [implement]
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
# Ship
|
|
78
|
+
`,
|
|
79
|
+
)
|
|
80
|
+
await write(
|
|
81
|
+
root,
|
|
82
|
+
"tasks/ship/phases/implement/PHASE.md",
|
|
83
|
+
execution("open", "feat/implement"),
|
|
84
|
+
)
|
|
85
|
+
await write(
|
|
86
|
+
root,
|
|
87
|
+
"tasks/ship/phases/verify/PHASE.md",
|
|
88
|
+
execution("open", "feat/verify"),
|
|
89
|
+
)
|
|
90
|
+
await write(
|
|
91
|
+
root,
|
|
92
|
+
"tasks/abandoned/TASK.md",
|
|
93
|
+
`---
|
|
94
|
+
ticketUrl: null
|
|
95
|
+
repo: agency
|
|
96
|
+
branch: feat/abandoned
|
|
97
|
+
base: main
|
|
98
|
+
pr: null
|
|
99
|
+
status: dropped
|
|
100
|
+
---
|
|
101
|
+
|
|
102
|
+
# Abandoned
|
|
103
|
+
`,
|
|
104
|
+
)
|
|
105
|
+
await write(
|
|
106
|
+
root,
|
|
107
|
+
"tasks/deploy/TASK.md",
|
|
108
|
+
`---
|
|
109
|
+
ticketUrl: null
|
|
110
|
+
epic: delivery
|
|
111
|
+
repo: agency
|
|
112
|
+
branch: feat/deploy
|
|
113
|
+
base: main
|
|
114
|
+
pr: null
|
|
115
|
+
status: open
|
|
116
|
+
---
|
|
117
|
+
|
|
118
|
+
# Deploy
|
|
119
|
+
`,
|
|
120
|
+
)
|
|
121
|
+
return root
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const service = <A>(
|
|
125
|
+
run: (readiness: ReadinessService) => Effect.Effect<A, unknown, any>,
|
|
126
|
+
) => runTestEffect(ReadinessService.pipe(Effect.flatMap(run)))
|
|
127
|
+
|
|
128
|
+
describe("ReadinessService", () => {
|
|
129
|
+
const roots: string[] = []
|
|
130
|
+
|
|
131
|
+
afterEach(async () => {
|
|
132
|
+
await Promise.all(roots.splice(0).map(cleanupTempDir))
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test("ranks ready work and explains every excluded execution unit", async () => {
|
|
136
|
+
const root = await createWorkbase()
|
|
137
|
+
roots.push(root)
|
|
138
|
+
|
|
139
|
+
const result = await service((readiness) => readiness.getNext(root, true))
|
|
140
|
+
|
|
141
|
+
expect(result.ready.map((item) => item.key)).toEqual([
|
|
142
|
+
"phase/ship/implement",
|
|
143
|
+
])
|
|
144
|
+
expect(result.selected).toMatchObject({
|
|
145
|
+
key: "phase/ship/implement",
|
|
146
|
+
parent: { epicId: "delivery", taskId: "ship" },
|
|
147
|
+
priority: { dependentCount: 1 },
|
|
148
|
+
})
|
|
149
|
+
expect(result.excluded.map((item) => item.key)).toEqual([
|
|
150
|
+
"task/prepare",
|
|
151
|
+
"phase/ship/verify",
|
|
152
|
+
"task/abandoned",
|
|
153
|
+
"task/deploy",
|
|
154
|
+
])
|
|
155
|
+
expect(
|
|
156
|
+
result.excluded.find((item) => item.key === "phase/ship/verify"),
|
|
157
|
+
).toMatchObject({
|
|
158
|
+
ready: false,
|
|
159
|
+
terminal: false,
|
|
160
|
+
blockedBy: ["phase:ship/implement"],
|
|
161
|
+
blockers: [
|
|
162
|
+
{
|
|
163
|
+
kind: "dependency",
|
|
164
|
+
status: "open",
|
|
165
|
+
reason: "Phase dependency is open",
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
})
|
|
169
|
+
expect(
|
|
170
|
+
result.excluded.find((item) => item.key === "task/abandoned"),
|
|
171
|
+
).toMatchObject({ status: "dropped", terminal: true })
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("includes cross-task unlocks in final-phase priority", async () => {
|
|
175
|
+
const root = await createWorkbase()
|
|
176
|
+
roots.push(root)
|
|
177
|
+
await Bun.write(
|
|
178
|
+
join(root, "tasks/ship/phases/implement/PHASE.md"),
|
|
179
|
+
execution("done", "feat/implement"),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const result = await service((readiness) => readiness.getNext(root, true))
|
|
183
|
+
|
|
184
|
+
expect(result.selected).toMatchObject({
|
|
185
|
+
key: "phase/ship/verify",
|
|
186
|
+
priority: { dependentCount: 1 },
|
|
187
|
+
})
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
test("uses the same readiness for work and PR guards", async () => {
|
|
191
|
+
const root = await createWorkbase()
|
|
192
|
+
roots.push(root)
|
|
193
|
+
|
|
194
|
+
await service((readiness) =>
|
|
195
|
+
readiness.guard("work", "ship", "implement", root),
|
|
196
|
+
)
|
|
197
|
+
await expect(
|
|
198
|
+
service((readiness) => readiness.guard("work", "ship", "verify", root)),
|
|
199
|
+
).rejects.toThrow("Phase dependency is open")
|
|
200
|
+
await expect(
|
|
201
|
+
service((readiness) => readiness.guard("pr", "ship", "verify", root)),
|
|
202
|
+
).rejects.toThrow("Phase dependency is open")
|
|
203
|
+
await service((readiness) =>
|
|
204
|
+
readiness.guard("work", "ship", "verify", root, true),
|
|
205
|
+
)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test("allows active PR targets but rejects terminal outcomes", async () => {
|
|
209
|
+
const root = await createWorkbase()
|
|
210
|
+
roots.push(root)
|
|
211
|
+
const path = join(root, "tasks/ship/phases/implement/PHASE.md")
|
|
212
|
+
await Bun.write(path, execution("working", "feat/implement"))
|
|
213
|
+
|
|
214
|
+
await service((readiness) =>
|
|
215
|
+
readiness.guard("pr", "ship", "implement", root),
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
await Bun.write(path, execution("done", "feat/implement"))
|
|
219
|
+
await expect(
|
|
220
|
+
service((readiness) => readiness.guard("pr", "ship", "implement", root)),
|
|
221
|
+
).rejects.toThrow("Phase status is done")
|
|
222
|
+
})
|
|
223
|
+
})
|