@markjaquith/agency 3.2.13 → 3.2.15

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.
Files changed (69) hide show
  1. package/package.json +7 -2
  2. package/src/workbase/schemas.ts +12 -0
  3. package/fixtures/protocol/orchestration-recipes.json +0 -53
  4. package/pi-extensions/agency.test.ts +0 -117
  5. package/src/check-commits.test.ts +0 -58
  6. package/src/cli-parser.test.ts +0 -945
  7. package/src/cli.test.ts +0 -1889
  8. package/src/commands/act.test.ts +0 -418
  9. package/src/commands/archive.test.ts +0 -236
  10. package/src/commands/context.test.ts +0 -665
  11. package/src/commands/description.test.ts +0 -103
  12. package/src/commands/doctor.test.ts +0 -185
  13. package/src/commands/epic.test.ts +0 -196
  14. package/src/commands/init.test.ts +0 -117
  15. package/src/commands/integration.test.ts +0 -165
  16. package/src/commands/pr.test.ts +0 -248
  17. package/src/commands/push.test.ts +0 -56
  18. package/src/commands/read-only.test.ts +0 -161
  19. package/src/commands/repo.test.ts +0 -199
  20. package/src/commands/restore.test.ts +0 -106
  21. package/src/commands/status.test.ts +0 -113
  22. package/src/commands/sync.test.ts +0 -200
  23. package/src/commands/task-phase.test.ts +0 -410
  24. package/src/commands/task.test.ts +0 -281
  25. package/src/commands/validate.test.ts +0 -186
  26. package/src/commands/work.test.ts +0 -1375
  27. package/src/commands/workbase.test.ts +0 -170
  28. package/src/graph-schema.test.ts +0 -124
  29. package/src/protocol.test.ts +0 -163
  30. package/src/readiness.test.ts +0 -105
  31. package/src/services/ArchiveBulkService.test.ts +0 -384
  32. package/src/services/ArchiveService.test.ts +0 -1092
  33. package/src/services/EpicService.test.ts +0 -74
  34. package/src/services/FileSystemService.test.ts +0 -81
  35. package/src/services/GraphMutationService.test.ts +0 -486
  36. package/src/services/GraphService.test.ts +0 -494
  37. package/src/services/IntegrationService.test.ts +0 -1091
  38. package/src/services/LifecycleTransaction.test.ts +0 -145
  39. package/src/services/PullRequestService.test.ts +0 -716
  40. package/src/services/PushService.test.ts +0 -408
  41. package/src/services/ReadinessService.test.ts +0 -290
  42. package/src/services/RepositoryService.test.ts +0 -789
  43. package/src/services/ReviewService.test.ts +0 -408
  44. package/src/services/SyncService.test.ts +0 -1377
  45. package/src/services/TaskPhaseService.test.ts +0 -852
  46. package/src/services/VersionControlService.test.ts +0 -62
  47. package/src/services/WorkbaseService.test.ts +0 -910
  48. package/src/services/WorktreeLock.test.ts +0 -205
  49. package/src/services/WorktreePerformance.test.ts +0 -71
  50. package/src/services/WorktreeService.test.ts +0 -2292
  51. package/src/test-utils.ts +0 -110
  52. package/src/usage-log.test.ts +0 -188
  53. package/src/utils/chooser.test.ts +0 -192
  54. package/src/utils/effect.test.ts +0 -30
  55. package/src/utils/interactive.pty.test.ts +0 -128
  56. package/src/utils/interactive.test.tsx +0 -860
  57. package/src/utils/process.test.ts +0 -132
  58. package/src/utils/progress.test.ts +0 -37
  59. package/src/work-view.test.ts +0 -151
  60. package/src/workbase/agent-command.test.ts +0 -128
  61. package/src/workbase/checkout-command.test.ts +0 -62
  62. package/src/workbase/delivery-command.test.ts +0 -180
  63. package/src/workbase/dependency-graph.test.ts +0 -50
  64. package/src/workbase/execution-contract.test.ts +0 -161
  65. package/src/workbase/frontmatter.test.ts +0 -67
  66. package/src/workbase/repository-reference.test.ts +0 -25
  67. package/src/workbase/schemas.test.ts +0 -543
  68. package/src/workbase/work-target.test.ts +0 -108
  69. package/src/workbase/worktree-command.test.ts +0 -61
@@ -1,408 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test"
2
- import { Effect } from "effect"
3
- import { chmod, mkdir } from "node:fs/promises"
4
- import { join, resolve } from "node:path"
5
- import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
- import { errorEnvelope } from "../protocol"
7
- import { PushService } from "./PushService"
8
- import { TaskService } from "./TaskService"
9
- import { WorktreeService } from "./WorktreeService"
10
- import { parseGitCommits } from "./push-validation"
11
-
12
- interface CommandResult {
13
- readonly stdout: string
14
- readonly stderr: string
15
- readonly exitCode: number
16
- }
17
-
18
- const runCommand = async (
19
- args: readonly string[],
20
- cwd?: string,
21
- ): Promise<CommandResult> => {
22
- const child = Bun.spawn([...args], {
23
- cwd,
24
- stdout: "pipe",
25
- stderr: "pipe",
26
- })
27
- const [stdout, stderr, exitCode] = await Promise.all([
28
- new Response(child.stdout).text(),
29
- new Response(child.stderr).text(),
30
- child.exited,
31
- ])
32
- return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }
33
- }
34
-
35
- const requireCommand = async (args: readonly string[], cwd?: string) => {
36
- const result = await runCommand(args, cwd)
37
- if (result.exitCode !== 0) {
38
- throw new Error(`${args.join(" ")} failed: ${result.stderr}`)
39
- }
40
- return result
41
- }
42
-
43
- describe("PushService", () => {
44
- test("parses batched Git commit metadata", () => {
45
- const output = [
46
- "abc123\0Agency Test\0agency@example.com\0First change\0\x1e",
47
- "def456\0Agency Test\0agency@example.com\0Second change\0\x1e",
48
- ].join("\n")
49
- const commits = parseGitCommits(output)
50
-
51
- expect(
52
- commits.map(({ commitId, description }) => ({ commitId, description })),
53
- ).toEqual([
54
- { commitId: "abc123", description: "First change" },
55
- { commitId: "def456", description: "Second change" },
56
- ])
57
- expect(commits.every((commit) => commit.authorEmail.includes("@"))).toBe(
58
- true,
59
- )
60
- })
61
-
62
- const roots: string[] = []
63
-
64
- afterEach(async () => {
65
- await Promise.all(roots.splice(0).map(cleanupTempDir))
66
- })
67
-
68
- const setup = async () => {
69
- const root = await createTempDir()
70
- roots.push(root)
71
- const remote = join(root, "remote.git")
72
- const seed = join(root, "seed")
73
- const repository = join(root, "repos", "agency")
74
- await mkdir(join(root, "repos"), { recursive: true })
75
- await Bun.write(join(root, "agency.json"), '{"version":2,"vcs":"git"}\n')
76
- await requireCommand([
77
- "git",
78
- "init",
79
- "--bare",
80
- "--initial-branch=main",
81
- remote,
82
- ])
83
- await requireCommand(["git", "init", "--initial-branch=main", seed])
84
- await requireCommand(["git", "config", "user.name", "Agency Test"], seed)
85
- await requireCommand(
86
- ["git", "config", "user.email", "agency@example.com"],
87
- seed,
88
- )
89
- await Bun.write(join(seed, "README.md"), "# Test repository\n")
90
- await requireCommand(["git", "add", "README.md"], seed)
91
- await requireCommand(["git", "commit", "-m", "Initial commit"], seed)
92
- await requireCommand(["git", "remote", "add", "origin", remote], seed)
93
- await requireCommand(["git", "push", "-u", "origin", "main"], seed)
94
- await requireCommand(["git", "clone", "--bare", remote, repository])
95
-
96
- await runTestEffect(
97
- TaskService.pipe(
98
- Effect.flatMap((service) =>
99
- service.create(
100
- {
101
- id: "example",
102
- ticketUrl: null,
103
- repo: "agency",
104
- branch: "task/example",
105
- base: "main",
106
- },
107
- root,
108
- ),
109
- ),
110
- ),
111
- )
112
- await runTestEffect(
113
- TaskService.pipe(
114
- Effect.flatMap((service) =>
115
- service.setStatus("example", "working", root),
116
- ),
117
- ),
118
- )
119
- const workspace = await runTestEffect(
120
- WorktreeService.pipe(
121
- Effect.flatMap((service) =>
122
- service.materialize("example", undefined, root),
123
- ),
124
- ),
125
- )
126
- return {
127
- root,
128
- remote,
129
- checkout: workspace.writablePath!,
130
- taskPath: join(root, "tasks", "example"),
131
- }
132
- }
133
-
134
- const publish = (
135
- taskPath: string,
136
- options: {
137
- fetchTimeoutMs?: number
138
- pushTimeoutMs?: number
139
- retryDelayMs?: number
140
- } = {},
141
- ) =>
142
- runTestEffect(
143
- PushService.pipe(
144
- Effect.flatMap((service) => service.publish(taskPath, options)),
145
- ),
146
- )
147
-
148
- const remoteBranch = async (remote: string) =>
149
- (
150
- await requireCommand([
151
- "git",
152
- "--git-dir",
153
- remote,
154
- "rev-parse",
155
- "refs/heads/task/example",
156
- ])
157
- ).stdout
158
-
159
- const configureAuthor = async (checkout: string) => {
160
- await requireCommand(
161
- ["git", "config", "user.name", "Agency Test"],
162
- checkout,
163
- )
164
- await requireCommand(
165
- ["git", "config", "user.email", "agency@example.com"],
166
- checkout,
167
- )
168
- }
169
-
170
- const prePushHook = async (checkout: string) =>
171
- resolve(
172
- checkout,
173
- (
174
- await requireCommand(
175
- ["git", "rev-parse", "--git-path", "hooks/pre-push"],
176
- checkout,
177
- )
178
- ).stdout,
179
- )
180
-
181
- test("publishes a clean Git HEAD and establishes upstream tracking", async () => {
182
- const fixture = await setup()
183
- await configureAuthor(fixture.checkout)
184
- await Bun.write(join(fixture.checkout, "feature.txt"), "published\n")
185
- await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
186
- await requireCommand(
187
- ["git", "commit", "-m", "Add published feature"],
188
- fixture.checkout,
189
- )
190
-
191
- const result = await publish(fixture.taskPath)
192
- expect(result).toMatchObject({
193
- vcs: "git",
194
- branch: "task/example",
195
- base: "main",
196
- remote: "origin",
197
- })
198
- expect(await remoteBranch(fixture.remote)).toBe(result.tip)
199
- expect(
200
- (
201
- await requireCommand(
202
- ["git", "rev-parse", "--abbrev-ref", "@{upstream}"],
203
- fixture.checkout,
204
- )
205
- ).stdout,
206
- ).toBe("origin/task/example")
207
- })
208
-
209
- test("rejects dirty, mismatched, and undescribed Git publication", async () => {
210
- const dirty = await setup()
211
- await Bun.write(join(dirty.checkout, "dirty.txt"), "dirty\n")
212
- await expect(publish(dirty.taskPath)).rejects.toThrow("dirty Git worktree")
213
-
214
- const mismatched = await setup()
215
- await requireCommand(
216
- ["git", "branch", "-m", "wrong-branch"],
217
- mismatched.checkout,
218
- )
219
- await expect(publish(mismatched.taskPath)).rejects.toThrow(
220
- "does not match checked-out Git branch",
221
- )
222
-
223
- const undescribed = await setup()
224
- await configureAuthor(undescribed.checkout)
225
- await requireCommand(
226
- ["git", "commit", "--allow-empty", "--allow-empty-message", "-m", ""],
227
- undescribed.checkout,
228
- )
229
- await expect(publish(undescribed.taskPath)).rejects.toThrow(
230
- "has an empty message",
231
- )
232
- })
233
-
234
- test("rejects Git remote divergence after refreshing remote state", async () => {
235
- const fixture = await setup()
236
- await configureAuthor(fixture.checkout)
237
- await Bun.write(join(fixture.checkout, "feature.txt"), "first\n")
238
- await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
239
- await requireCommand(
240
- ["git", "commit", "-m", "First change"],
241
- fixture.checkout,
242
- )
243
- await publish(fixture.taskPath)
244
-
245
- const other = join(fixture.root, "other")
246
- await requireCommand(["git", "clone", fixture.remote, other])
247
- await requireCommand(["git", "checkout", "task/example"], other)
248
- await requireCommand(["git", "config", "user.name", "Other"], other)
249
- await requireCommand(
250
- ["git", "config", "user.email", "other@example.com"],
251
- other,
252
- )
253
- await Bun.write(join(other, "remote.txt"), "remote\n")
254
- await requireCommand(["git", "add", "remote.txt"], other)
255
- await requireCommand(["git", "commit", "-m", "Remote change"], other)
256
- await requireCommand(["git", "push", "origin", "task/example"], other)
257
-
258
- await Bun.write(join(fixture.checkout, "local.txt"), "local\n")
259
- await requireCommand(["git", "add", "local.txt"], fixture.checkout)
260
- await requireCommand(
261
- ["git", "commit", "-m", "Local change"],
262
- fixture.checkout,
263
- )
264
- await expect(publish(fixture.taskPath)).rejects.toThrow(
265
- "refusing a non-fast-forward update",
266
- )
267
- })
268
-
269
- test("requires working status and the declared Git base in history", async () => {
270
- const open = await setup()
271
- await runTestEffect(
272
- TaskService.pipe(
273
- Effect.flatMap((service) =>
274
- service.setStatus("example", "open", open.root),
275
- ),
276
- ),
277
- )
278
- await expect(publish(open.taskPath)).rejects.toThrow(
279
- "status 'open'; status must be working",
280
- )
281
-
282
- const unrelated = await setup()
283
- await configureAuthor(unrelated.checkout)
284
- await requireCommand(
285
- ["git", "checkout", "--orphan", "unrelated"],
286
- unrelated.checkout,
287
- )
288
- await requireCommand(["git", "rm", "-rf", "."], unrelated.checkout)
289
- await Bun.write(join(unrelated.checkout, "unrelated.txt"), "unrelated\n")
290
- await requireCommand(["git", "add", "unrelated.txt"], unrelated.checkout)
291
- await requireCommand(
292
- ["git", "commit", "-m", "Unrelated history"],
293
- unrelated.checkout,
294
- )
295
- await requireCommand(
296
- ["git", "branch", "-M", "task/example"],
297
- unrelated.checkout,
298
- )
299
- await expect(publish(unrelated.taskPath)).rejects.toThrow(
300
- "is not an ancestor of Git HEAD",
301
- )
302
- })
303
-
304
- test("rejects invalid Git authors", async () => {
305
- const fixture = await setup()
306
- await configureAuthor(fixture.checkout)
307
- await requireCommand(
308
- [
309
- "git",
310
- "commit",
311
- "--allow-empty",
312
- "--author",
313
- "Bad <bad>",
314
- "-m",
315
- "Invalid author",
316
- ],
317
- fixture.checkout,
318
- )
319
- await expect(publish(fixture.taskPath)).rejects.toThrow(
320
- "has an invalid author",
321
- )
322
- })
323
-
324
- test("bounds a stalled pre-push hook and confirms non-publication", async () => {
325
- const fixture = await setup()
326
- await configureAuthor(fixture.checkout)
327
- await Bun.write(join(fixture.checkout, "feature.txt"), "timeout\n")
328
- await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
329
- await requireCommand(
330
- ["git", "commit", "-m", "Add timed publication"],
331
- fixture.checkout,
332
- )
333
- const hook = await prePushHook(fixture.checkout)
334
- await Bun.write(hook, "#!/bin/sh\nsleep 30\n")
335
- await chmod(hook, 0o755)
336
-
337
- const startedAt = performance.now()
338
- const failure = await publish(fixture.taskPath, {
339
- pushTimeoutMs: 25,
340
- }).catch((error) => error)
341
- expect(performance.now() - startedAt).toBeLessThan(1_000)
342
- expect(errorEnvelope(failure).error).toMatchObject({
343
- code: "PUSH_TIMEOUT",
344
- fields: { category: "timeout", stage: "publish" },
345
- retryable: true,
346
- })
347
- await expect(remoteBranch(fixture.remote)).rejects.toThrow()
348
- })
349
-
350
- test("classifies hook rejection separately from transport failure", async () => {
351
- const fixture = await setup()
352
- await configureAuthor(fixture.checkout)
353
- await Bun.write(join(fixture.checkout, "feature.txt"), "rejected\n")
354
- await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
355
- await requireCommand(
356
- ["git", "commit", "-m", "Add rejected publication"],
357
- fixture.checkout,
358
- )
359
- const hook = await prePushHook(fixture.checkout)
360
- await Bun.write(
361
- hook,
362
- "#!/bin/sh\necho 'pre-push hook declined' >&2\nexit 1\n",
363
- )
364
- await chmod(hook, 0o755)
365
-
366
- const failure = await publish(fixture.taskPath).catch((error) => error)
367
- expect(errorEnvelope(failure).error).toMatchObject({
368
- code: "PUSH_HOOK_REJECTED",
369
- fields: { category: "hook_rejection", stage: "publish" },
370
- retryable: false,
371
- })
372
- })
373
-
374
- test("retries one transient fetch with non-interactive authentication", async () => {
375
- const fixture = await setup()
376
- await configureAuthor(fixture.checkout)
377
- await Bun.write(join(fixture.checkout, "feature.txt"), "retried\n")
378
- await requireCommand(["git", "add", "feature.txt"], fixture.checkout)
379
- await requireCommand(
380
- ["git", "commit", "-m", "Add retried publication"],
381
- fixture.checkout,
382
- )
383
- const attempts = join(fixture.root, "upload-pack-attempts")
384
- const uploadPack = join(fixture.root, "upload-pack")
385
- await Bun.write(
386
- uploadPack,
387
- `#!/bin/sh
388
- echo x >> ${JSON.stringify(attempts)}
389
- test "$GIT_TERMINAL_PROMPT" = 0 || exit 2
390
- test "$GCM_INTERACTIVE" = Never || exit 2
391
- if test "$(wc -l < ${JSON.stringify(attempts)})" -eq 1; then
392
- echo 'Connection reset by peer' >&2
393
- exit 1
394
- fi
395
- exec git-upload-pack "$@"
396
- `,
397
- )
398
- await chmod(uploadPack, 0o755)
399
- await requireCommand(
400
- ["git", "config", "remote.origin.uploadpack", uploadPack],
401
- fixture.checkout,
402
- )
403
-
404
- const result = await publish(fixture.taskPath, { retryDelayMs: 0 })
405
- expect(result.tip).toBe(await remoteBranch(fixture.remote))
406
- expect((await Bun.file(attempts).text()).trim().split("\n")).toHaveLength(2)
407
- })
408
- })
@@ -1,290 +0,0 @@
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("returns ready target IDs and guards structural work targets", async () => {
191
- const root = await createWorkbase()
192
- roots.push(root)
193
-
194
- const readyIds = await service((readiness) =>
195
- readiness.getReadyWorkTargetIds(root),
196
- )
197
- expect([...readyIds].sort()).toEqual([
198
- "epic:delivery",
199
- "execution-unit:phase/ship/implement",
200
- "phase:ship/implement",
201
- "task:ship",
202
- ])
203
- await service((readiness) =>
204
- readiness.guardWorkTarget("phase:ship/implement", root),
205
- )
206
- expect(
207
- [
208
- ...(await service((readiness) => readiness.getWorkTargetIds(root))),
209
- ].sort(),
210
- ).toEqual([...readyIds].sort())
211
-
212
- await write(
213
- root,
214
- "tasks/ship/phases/implement/PHASE.md",
215
- execution("working", "feat/implement"),
216
- )
217
- const resumableIds = await service((readiness) =>
218
- readiness.getWorkTargetIds(root),
219
- )
220
- expect(resumableIds).toContain("epic:delivery")
221
- expect(resumableIds).toContain("task:ship")
222
- expect(resumableIds).toContain("execution-unit:phase/ship/implement")
223
- await service((readiness) =>
224
- readiness.guardWorkTarget("execution-unit:phase/ship/implement", root),
225
- )
226
-
227
- const blocked = await service((readiness) =>
228
- Effect.either(readiness.guardWorkTarget("phase:ship/verify", root)),
229
- )
230
- expect(blocked).toMatchObject({
231
- _tag: "Left",
232
- left: {
233
- _tag: "ExecutionGuardError",
234
- action: "work",
235
- target: "phase:ship/verify",
236
- status: "open",
237
- blockedBy: ["phase:ship/implement"],
238
- },
239
- })
240
-
241
- const missing = await service((readiness) =>
242
- Effect.either(readiness.guardWorkTarget("task:missing", root)),
243
- )
244
- expect(missing).toMatchObject({
245
- _tag: "Left",
246
- left: {
247
- _tag: "ExecutionGuardError",
248
- target: "task:missing",
249
- blockers: [],
250
- },
251
- })
252
- await service((readiness) =>
253
- readiness.guardWorkTarget("task:missing", root, true),
254
- )
255
- })
256
-
257
- test("uses the same readiness for work and PR guards", async () => {
258
- const root = await createWorkbase()
259
- roots.push(root)
260
-
261
- await service((readiness) =>
262
- readiness.guard("work", "ship", "implement", root),
263
- )
264
- await expect(
265
- service((readiness) => readiness.guard("work", "ship", "verify", root)),
266
- ).rejects.toThrow("Phase dependency is open")
267
- await expect(
268
- service((readiness) => readiness.guard("pr", "ship", "verify", root)),
269
- ).rejects.toThrow("Phase dependency is open")
270
- await service((readiness) =>
271
- readiness.guard("work", "ship", "verify", root, true),
272
- )
273
- })
274
-
275
- test("allows active PR targets but rejects terminal outcomes", async () => {
276
- const root = await createWorkbase()
277
- roots.push(root)
278
- const path = join(root, "tasks/ship/phases/implement/PHASE.md")
279
- await Bun.write(path, execution("working", "feat/implement"))
280
-
281
- await service((readiness) =>
282
- readiness.guard("pr", "ship", "implement", root),
283
- )
284
-
285
- await Bun.write(path, execution("done", "feat/implement"))
286
- await expect(
287
- service((readiness) => readiness.guard("pr", "ship", "implement", root)),
288
- ).rejects.toThrow("Phase status is done")
289
- })
290
- })