@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,910 +0,0 @@
1
- import { afterEach, beforeEach, describe, expect, test } from "bun:test"
2
- import { Effect } from "effect"
3
- import { mkdir, realpath, rm, symlink } from "node:fs/promises"
4
- import { dirname, join } from "node:path"
5
- import { cleanupTempDir, createTempDir, runTestEffect } from "../test-utils"
6
- import { FileSystemService } from "./FileSystemService"
7
- import { WorkbaseService } from "./WorkbaseService"
8
-
9
- const write = async (root: string, path: string, content: string) => {
10
- const fullPath = join(root, path)
11
- await mkdir(dirname(fullPath), { recursive: true })
12
- await Bun.write(fullPath, content)
13
- }
14
-
15
- describe("WorkbaseService", () => {
16
- let root: string
17
-
18
- beforeEach(async () => {
19
- root = await createTempDir()
20
- })
21
-
22
- afterEach(async () => {
23
- await cleanupTempDir(root)
24
- })
25
-
26
- test("discovers a workbase from a nested directory", async () => {
27
- await write(root, "agency.json", '{"version":2}\n')
28
- await mkdir(join(root, "nested/repository/src"), { recursive: true })
29
-
30
- const discovered = await runTestEffect(
31
- WorkbaseService.pipe(
32
- Effect.flatMap((service) =>
33
- service.discover(join(root, "nested/repository/src")),
34
- ),
35
- ),
36
- )
37
-
38
- expect(discovered).toBe(root)
39
- expect(await Bun.file(join(root, "AGENTS.md")).exists()).toBe(false)
40
- expect(
41
- await Bun.file(join(root, ".opencode/opencode.jsonc")).exists(),
42
- ).toBe(false)
43
- })
44
-
45
- test("loads and validates the global agent", async () => {
46
- const configDirectory = join(root, "config")
47
- await write(
48
- configDirectory,
49
- "agency/agency.json",
50
- JSON.stringify({ agent: "pi" }),
51
- )
52
-
53
- const config = await runTestEffect(
54
- WorkbaseService.pipe(
55
- Effect.flatMap((service) => service.loadGlobalConfig(configDirectory)),
56
- ),
57
- )
58
- expect(config).toEqual({ agent: "pi" })
59
-
60
- await write(
61
- configDirectory,
62
- "agency/agency.json",
63
- JSON.stringify({ agent: "codex" }),
64
- )
65
- await expect(
66
- runTestEffect(
67
- WorkbaseService.pipe(
68
- Effect.flatMap((service) =>
69
- service.loadGlobalConfig(configDirectory),
70
- ),
71
- ),
72
- ),
73
- ).rejects.toThrow("Invalid global Agency configuration")
74
- })
75
-
76
- test("treats declared but missing repositories as valid aliases", async () => {
77
- await write(
78
- root,
79
- "agency.json",
80
- JSON.stringify({
81
- version: 2,
82
- repositories: {
83
- agency: { remote: "https://example.com/agency.git" },
84
- },
85
- }),
86
- )
87
- await write(
88
- root,
89
- "tasks/portable/TASK.md",
90
- `---
91
- ticketUrl: null
92
- repo: agency
93
- branch: task/portable
94
- base: main
95
- pr: null
96
- ---
97
- `,
98
- )
99
-
100
- const report = await runTestEffect(
101
- WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
102
- )
103
-
104
- expect(report.valid).toBe(true)
105
- expect(report.issues).toEqual([])
106
- })
107
-
108
- test("reads configuration and documents once during validation", async () => {
109
- await write(
110
- root,
111
- "agency.json",
112
- JSON.stringify({
113
- version: 2,
114
- repositories: {
115
- agency: { remote: "https://example.com/agency.git" },
116
- },
117
- }),
118
- )
119
- await write(
120
- root,
121
- "tasks/example/TASK.md",
122
- `---
123
- ticketUrl: null
124
- repo: agency
125
- branch: task/example
126
- base: main
127
- pr: null
128
- ---
129
- `,
130
- )
131
-
132
- const fs = await Effect.runPromise(
133
- FileSystemService.pipe(Effect.provide(FileSystemService.Default)),
134
- )
135
- const reads: string[] = []
136
- const exists: string[] = []
137
- const trackedFs = {
138
- ...fs,
139
- readFile: (path: string) => {
140
- reads.push(path)
141
- return fs.readFile(path)
142
- },
143
- exists: (path: string) => {
144
- exists.push(path)
145
- return fs.exists(path)
146
- },
147
- }
148
-
149
- const report = await Effect.runPromise(
150
- WorkbaseService.pipe(
151
- Effect.flatMap((service) => service.validate(root)),
152
- Effect.provide(WorkbaseService.Default),
153
- Effect.provideService(FileSystemService, trackedFs),
154
- ) as Effect.Effect<unknown, unknown, never>,
155
- )
156
-
157
- expect(report).toMatchObject({ valid: true, taskCount: 1 })
158
- expect(
159
- reads.filter((path) => path === join(root, "agency.json")),
160
- ).toHaveLength(2)
161
- expect(
162
- reads.filter((path) => path === join(root, "tasks/example/TASK.md")),
163
- ).toHaveLength(1)
164
- expect(exists).not.toContain(join(root, "tasks/example/TASK.md"))
165
- })
166
-
167
- test("propagates document read failures instead of reporting them as missing", async () => {
168
- await write(root, "agency.json", '{"version":2}\n')
169
- await mkdir(join(root, "tasks/example/TASK.md"), { recursive: true })
170
-
171
- await expect(
172
- runTestEffect(
173
- WorkbaseService.pipe(
174
- Effect.flatMap((service) => service.validate(root)),
175
- ),
176
- ),
177
- ).rejects.toThrow(
178
- `Failed to read file: ${join(root, "tasks/example/TASK.md")}`,
179
- )
180
- })
181
-
182
- test("validates non-PR completion invariants without rejecting legacy done work", async () => {
183
- await write(
184
- root,
185
- "agency.json",
186
- JSON.stringify({
187
- version: 2,
188
- repositories: {
189
- agency: { remote: "https://example.com/agency.git" },
190
- },
191
- }),
192
- )
193
- await write(
194
- root,
195
- "tasks/invalid/TASK.md",
196
- `---
197
- ticketUrl: null
198
- repo: agency
199
- branch: task/invalid
200
- base: main
201
- pr: https://github.com/example/agency/pull/1
202
- status: working
203
- completion:
204
- mode: non-pr
205
- completedAt: 2026-07-23T18:00:00.000Z
206
- summary: Investigation completed.
207
- ---
208
- `,
209
- )
210
- await write(
211
- root,
212
- "tasks/legacy/TASK.md",
213
- `---
214
- ticketUrl: null
215
- repo: agency
216
- branch: task/legacy
217
- base: main
218
- pr: null
219
- status: done
220
- ---
221
- `,
222
- )
223
-
224
- const report = await runTestEffect(
225
- WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
226
- )
227
- expect(report.issues).toContainEqual({
228
- path: "tasks/invalid/TASK.md",
229
- message: "Non-PR completion requires status 'done'",
230
- })
231
- expect(report.issues).toContainEqual({
232
- path: "tasks/invalid/TASK.md",
233
- message: "Non-PR completion cannot have a recorded pull request",
234
- })
235
- expect(
236
- report.issues.some((issue) => issue.path === "tasks/legacy/TASK.md"),
237
- ).toBe(false)
238
- })
239
-
240
- test("registers canonical workbase paths without duplicates", async () => {
241
- const workbaseRoot = join(root, "workbase")
242
- const nested = join(workbaseRoot, "nested")
243
- const configDirectory = join(root, "config")
244
- await write(workbaseRoot, "agency.json", '{"version":2}\n')
245
- await mkdir(nested, { recursive: true })
246
-
247
- const first = await runTestEffect(
248
- WorkbaseService.pipe(
249
- Effect.flatMap((service) => service.register(nested, configDirectory)),
250
- ),
251
- )
252
- await runTestEffect(
253
- WorkbaseService.pipe(
254
- Effect.flatMap((service) =>
255
- service.register(workbaseRoot, configDirectory),
256
- ),
257
- ),
258
- )
259
- const registered = await runTestEffect(
260
- WorkbaseService.pipe(
261
- Effect.flatMap((service) => service.listRegistered(configDirectory)),
262
- ),
263
- )
264
-
265
- expect(first.path).toBe(await realpath(workbaseRoot))
266
- expect(registered).toEqual([first.path])
267
- expect(
268
- await Bun.file(join(configDirectory, "agency/workbases.json")).json(),
269
- ).toEqual({ version: 2, workbases: [first] })
270
- })
271
-
272
- test("resolves names and stable IDs and manages the default", async () => {
273
- const workbaseRoot = join(root, "workbase")
274
- const configDirectory = join(root, "config")
275
- await write(workbaseRoot, "agency.json", '{"version":2}\n')
276
-
277
- const registered = await runTestEffect(
278
- WorkbaseService.pipe(
279
- Effect.flatMap((service) =>
280
- service.register(workbaseRoot, configDirectory, "primary"),
281
- ),
282
- ),
283
- )
284
- const result = await runTestEffect(
285
- WorkbaseService.pipe(
286
- Effect.flatMap((service) =>
287
- Effect.gen(function* () {
288
- yield* service.setDefault("primary", configDirectory)
289
- return {
290
- byName: yield* service.resolveRegistered(
291
- "primary",
292
- configDirectory,
293
- ),
294
- byId: yield* service.resolveRegistered(
295
- registered.id,
296
- configDirectory,
297
- ),
298
- defaultWorkbase: yield* service.getDefault(configDirectory),
299
- }
300
- }),
301
- ),
302
- ),
303
- )
304
-
305
- expect(result.byName).toBe(registered.path)
306
- expect(result.byId).toBe(registered.path)
307
- expect(result.defaultWorkbase).toEqual(registered)
308
- })
309
-
310
- test("shows, names, clears names, and defaults by path", async () => {
311
- const workbaseRoot = join(root, "workbase")
312
- const configDirectory = join(root, "config")
313
- await write(workbaseRoot, "agency.json", '{"version":2}\n')
314
- const registered = await runTestEffect(
315
- WorkbaseService.pipe(
316
- Effect.flatMap((service) =>
317
- service.register(workbaseRoot, configDirectory),
318
- ),
319
- ),
320
- )
321
-
322
- const result = await runTestEffect(
323
- WorkbaseService.pipe(
324
- Effect.flatMap((service) =>
325
- Effect.gen(function* () {
326
- const named = yield* service.nameRegistered(
327
- workbaseRoot,
328
- "primary",
329
- configDirectory,
330
- )
331
- const shown = yield* service.showRegistered(
332
- "primary",
333
- configDirectory,
334
- )
335
- const defaultWorkbase = yield* service.setDefault(
336
- workbaseRoot,
337
- configDirectory,
338
- )
339
- const unnamed = yield* service.nameRegistered(
340
- registered.id,
341
- null,
342
- configDirectory,
343
- )
344
- return { named, shown, defaultWorkbase, unnamed }
345
- }),
346
- ),
347
- ),
348
- )
349
-
350
- expect(result.named.name).toBe("primary")
351
- expect(result.shown).toEqual(result.named)
352
- expect(result.defaultWorkbase?.id).toBe(registered.id)
353
- expect(result.unnamed).toEqual({ id: registered.id, path: registered.path })
354
- })
355
-
356
- test("rejects names that collide with stable IDs", async () => {
357
- const firstRoot = join(root, "first")
358
- const secondRoot = join(root, "second")
359
- const configDirectory = join(root, "config")
360
- await write(firstRoot, "agency.json", '{"version":2}\n')
361
- await write(secondRoot, "agency.json", '{"version":2}\n')
362
- const first = await runTestEffect(
363
- WorkbaseService.pipe(
364
- Effect.flatMap((service) =>
365
- service.register(firstRoot, configDirectory),
366
- ),
367
- ),
368
- )
369
-
370
- await expect(
371
- runTestEffect(
372
- WorkbaseService.pipe(
373
- Effect.flatMap((service) =>
374
- service.register(secondRoot, configDirectory, first.id),
375
- ),
376
- ),
377
- ),
378
- ).rejects.toThrow("already registered")
379
- })
380
-
381
- test("rejects invalid workbase names before writing the registry", async () => {
382
- const workbaseRoot = join(root, "workbase")
383
- const configDirectory = join(root, "config")
384
- await write(workbaseRoot, "agency.json", '{"version":2}\n')
385
-
386
- for (const name of ["bad/name", ""]) {
387
- await expect(
388
- runTestEffect(
389
- WorkbaseService.pipe(
390
- Effect.flatMap((service) =>
391
- service.register(workbaseRoot, configDirectory, name),
392
- ),
393
- ),
394
- ),
395
- ).rejects.toThrow("Invalid workbase name")
396
- }
397
- expect(
398
- await Bun.file(join(configDirectory, "agency/workbases.json")).exists(),
399
- ).toBe(false)
400
- })
401
-
402
- test("removes a registration by an equivalent symlink path", async () => {
403
- const workbaseRoot = join(root, "workbase")
404
- const linkedRoot = join(root, "linked")
405
- const configDirectory = join(root, "config")
406
- await write(workbaseRoot, "agency.json", '{"version":2}\n')
407
- await symlink(workbaseRoot, linkedRoot)
408
- await runTestEffect(
409
- WorkbaseService.pipe(
410
- Effect.flatMap((service) =>
411
- service.register(workbaseRoot, configDirectory),
412
- ),
413
- ),
414
- )
415
-
416
- const removed = await runTestEffect(
417
- WorkbaseService.pipe(
418
- Effect.flatMap((service) =>
419
- service.removeRegistered(linkedRoot, configDirectory),
420
- ),
421
- ),
422
- )
423
- expect(removed.path).toBe(await realpath(workbaseRoot))
424
- })
425
-
426
- test("migrates legacy registrations and prunes stale paths", async () => {
427
- const workbaseRoot = join(root, "workbase")
428
- const staleRoot = join(root, "stale")
429
- const configDirectory = join(root, "config")
430
- const registryPath = join(configDirectory, "agency/workbases.json")
431
- await write(workbaseRoot, "agency.json", '{"version":2}\n')
432
- await write(staleRoot, "agency.json", '{"version":2}\n')
433
- await write(
434
- configDirectory,
435
- "agency/workbases.json",
436
- JSON.stringify({ version: 1, workbases: [workbaseRoot, staleRoot] }),
437
- )
438
- await rm(staleRoot, { recursive: true })
439
-
440
- const result = await runTestEffect(
441
- WorkbaseService.pipe(
442
- Effect.flatMap((service) =>
443
- Effect.gen(function* () {
444
- const before = yield* service.listRegistrations(configDirectory)
445
- const removed = yield* service.pruneRegistered(configDirectory)
446
- return { before, removed }
447
- }),
448
- ),
449
- ),
450
- )
451
-
452
- expect(result.before.workbases).toHaveLength(2)
453
- expect(result.before.workbases[0]?.id).toStartWith("wb-")
454
- expect(result.removed.map((entry) => entry.path)).toEqual([staleRoot])
455
- expect(await Bun.file(registryPath).json()).toEqual({
456
- version: 2,
457
- workbases: [result.before.workbases[0]],
458
- })
459
- })
460
-
461
- test("rejects an invalid worktree command template", async () => {
462
- await write(
463
- root,
464
- "agency.json",
465
- JSON.stringify({
466
- version: 2,
467
- worktreeCreateCommand: ["tool", "{repo}"],
468
- }),
469
- )
470
-
471
- await expect(
472
- runTestEffect(
473
- WorkbaseService.pipe(
474
- Effect.flatMap((service) => service.discover(root)),
475
- ),
476
- ),
477
- ).rejects.toThrow("{worktree}")
478
- })
479
-
480
- test("rejects an unknown post-checkout command placeholder", async () => {
481
- await write(
482
- root,
483
- "agency.json",
484
- JSON.stringify({
485
- version: 2,
486
- repositories: {
487
- agency: {
488
- remote: "https://example.com/agency.git",
489
- postCheckoutCommand: ["tool", "{unknown}"],
490
- },
491
- },
492
- }),
493
- )
494
-
495
- await expect(
496
- runTestEffect(
497
- WorkbaseService.pipe(
498
- Effect.flatMap((service) => service.discover(root)),
499
- ),
500
- ),
501
- ).rejects.toThrow("Repository 'agency'")
502
- })
503
-
504
- test("rejects an unknown agent command placeholder", async () => {
505
- await write(
506
- root,
507
- "agency.json",
508
- JSON.stringify({
509
- version: 2,
510
- agents: { custom: { command: ["agent", "{unknown}"] } },
511
- }),
512
- )
513
-
514
- await expect(
515
- runTestEffect(
516
- WorkbaseService.pipe(
517
- Effect.flatMap((service) => service.discover(root)),
518
- ),
519
- ),
520
- ).rejects.toThrow("{unknown}")
521
- })
522
-
523
- test("validates a workbase with an epic and multi-phase task", async () => {
524
- await write(root, "agency.json", '{"version":2}\n')
525
- await mkdir(join(root, "repos/agency"), { recursive: true })
526
- await mkdir(join(root, "repos/effect"), { recursive: true })
527
- await write(
528
- root,
529
- "epics/example/EPIC.md",
530
- `---
531
- ticketUrl: https://example.com/epics/example
532
- repos:
533
- - repo: agency
534
- ref: main
535
- tasks:
536
- - id: example-task
537
- ---
538
-
539
- # Example
540
- `,
541
- )
542
- await write(
543
- root,
544
- "tasks/example-task/TASK.md",
545
- `---
546
- ticketUrl: https://example.com/tasks/example
547
- epic: example
548
- phases:
549
- - id: implementation
550
- ---
551
-
552
- # Example task
553
- `,
554
- )
555
- await write(
556
- root,
557
- "tasks/example-task/phases/implementation/PHASE.md",
558
- `---
559
- repo: agency
560
- repos:
561
- - repo: effect
562
- ref: main
563
- branch: task/example
564
- base: main
565
- pr: null
566
- ---
567
-
568
- # Implementation
569
- `,
570
- )
571
-
572
- const report = await runTestEffect(
573
- WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
574
- )
575
-
576
- expect(report.valid).toBe(true)
577
- expect(report.issues).toEqual([])
578
- expect(report.epicCount).toBe(1)
579
- expect(report.taskCount).toBe(1)
580
- expect(report.phaseCount).toBe(1)
581
- })
582
-
583
- test("reports schema, alias, backlink, and dependency errors", async () => {
584
- await write(root, "agency.json", '{"version":2}\n')
585
- await mkdir(join(root, "repos/agency"), { recursive: true })
586
- await write(
587
- root,
588
- "epics/example/EPIC.md",
589
- `---
590
- ticketUrl: https://example.com/epics/example
591
- repos:
592
- - repo: missing
593
- ref: main
594
- tasks:
595
- - id: example-task
596
- dependsOn:
597
- - absent-task
598
- ---
599
-
600
- # Example
601
- `,
602
- )
603
- await write(
604
- root,
605
- "tasks/example-task/TASK.md",
606
- `---
607
- ticketUrl: https://example.com/tasks/example
608
- repo: agency
609
- repos:
610
- - repo: agency
611
- ref: main
612
- branch: task/example
613
- base: main
614
- pr: null
615
- ---
616
-
617
- # Example task
618
- `,
619
- )
620
- await write(
621
- root,
622
- "tasks/bad-schema/TASK.md",
623
- `---
624
- ticketUrl: https://example.com/tasks/bad-schema
625
- repo: agency
626
- branch: task/bad-schema
627
- base: main
628
- pr: not-a-url
629
- ---
630
-
631
- # Bad schema
632
- `,
633
- )
634
-
635
- const report = await runTestEffect(
636
- WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
637
- )
638
-
639
- expect(report.valid).toBe(false)
640
- expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
641
- "Unknown repository alias 'missing'",
642
- )
643
- expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
644
- "Unknown task dependency 'absent-task'",
645
- )
646
- expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
647
- "Task must reference parent epic 'example'",
648
- )
649
- expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
650
- "Repository 'agency' cannot also be a reference",
651
- )
652
- expect(
653
- report.issues.some(
654
- (issue) => issue.path.endsWith("bad-schema/TASK.md") && issue.message,
655
- ),
656
- ).toBe(true)
657
- expect(report.issues.some((issue) => issue.path.endsWith("TASK.md"))).toBe(
658
- true,
659
- )
660
- })
661
-
662
- test("reports duplicate writable branch ownership", async () => {
663
- await write(root, "agency.json", '{"version":2}\n')
664
- await mkdir(join(root, "repos/agency"), { recursive: true })
665
- for (const id of ["first", "second"]) {
666
- await write(
667
- root,
668
- `tasks/${id}/TASK.md`,
669
- `---
670
- ticketUrl: https://example.com/tasks/${id}
671
- repo: agency
672
- branch: task/shared
673
- base: main
674
- pr: null
675
- ---
676
- `,
677
- )
678
- }
679
-
680
- const report = await runTestEffect(
681
- WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
682
- )
683
-
684
- expect(report.valid).toBe(false)
685
- expect(report.issues).toContainEqual({
686
- path: "tasks/second/TASK.md",
687
- message:
688
- "Writable branch 'task/shared' for repository 'agency' is also owned by tasks/first/TASK.md",
689
- })
690
- })
691
-
692
- test("reports high-risk structural validation failures", async () => {
693
- const fixtures: readonly {
694
- name: string
695
- files: Readonly<Record<string, string>>
696
- directories?: readonly string[]
697
- expected: readonly string[]
698
- }[] = [
699
- {
700
- name: "missing-documents",
701
- files: {},
702
- directories: [
703
- "epics/missing-epic",
704
- "tasks/missing-task",
705
- "tasks/missing-task/phases/missing-phase",
706
- ],
707
- expected: [
708
- "epics/missing-epic/EPIC.md: Required document is missing",
709
- "tasks/missing-task/TASK.md: Required document is missing",
710
- "tasks/missing-task/phases/missing-phase/PHASE.md: Required document is missing",
711
- ],
712
- },
713
- {
714
- name: "duplicates",
715
- files: {
716
- "epics/duplicate/EPIC.md": `---
717
- ticketUrl: https://example.com/epics/duplicate
718
- repos:
719
- - repo: agency
720
- ref: main
721
- - repo: agency
722
- ref: release
723
- tasks:
724
- - id: child
725
- - id: child
726
- ---
727
- `,
728
- "tasks/child/TASK.md": `---
729
- ticketUrl: https://example.com/tasks/child
730
- epic: duplicate
731
- phases:
732
- - id: implementation
733
- - id: implementation
734
- ---
735
- `,
736
- "tasks/child/phases/implementation/PHASE.md": `---
737
- repo: agency
738
- branch: task/duplicate
739
- base: main
740
- pr: null
741
- ---
742
- `,
743
- },
744
- expected: [
745
- "Repository references must be unique",
746
- "Epic task IDs must be unique",
747
- "Task phase IDs must be unique",
748
- ],
749
- },
750
- {
751
- name: "backlinks",
752
- files: {
753
- "epics/parent/EPIC.md": `---
754
- ticketUrl: https://example.com/epics/parent
755
- repos:
756
- - repo: agency
757
- ref: main
758
- tasks:
759
- - id: missing-backlink
760
- ---
761
- `,
762
- "tasks/missing-backlink/TASK.md": `---
763
- ticketUrl: https://example.com/tasks/missing-backlink
764
- repo: agency
765
- branch: task/missing-backlink
766
- base: main
767
- pr: null
768
- ---
769
- `,
770
- "tasks/unlisted/TASK.md": `---
771
- ticketUrl: https://example.com/tasks/unlisted
772
- epic: parent
773
- repo: agency
774
- branch: task/unlisted
775
- base: main
776
- pr: null
777
- ---
778
- `,
779
- },
780
- expected: [
781
- "Task must reference parent epic 'parent'",
782
- "Epic does not list child task 'unlisted'",
783
- ],
784
- },
785
- {
786
- name: "dependency-cycles",
787
- files: {
788
- "epics/cycles/EPIC.md": `---
789
- ticketUrl: https://example.com/epics/cycles
790
- repos:
791
- - repo: agency
792
- ref: main
793
- tasks:
794
- - id: first
795
- dependsOn: [second]
796
- - id: second
797
- dependsOn: [first]
798
- ---
799
- `,
800
- "tasks/first/TASK.md": `---
801
- ticketUrl: https://example.com/tasks/first
802
- epic: cycles
803
- phases:
804
- - id: contract
805
- dependsOn: [delivery]
806
- - id: delivery
807
- dependsOn: [contract]
808
- ---
809
- `,
810
- "tasks/first/phases/contract/PHASE.md": `---
811
- repo: agency
812
- branch: task/contract
813
- base: main
814
- pr: null
815
- ---
816
- `,
817
- "tasks/first/phases/delivery/PHASE.md": `---
818
- repo: agency
819
- branch: task/delivery
820
- base: main
821
- pr: null
822
- ---
823
- `,
824
- "tasks/second/TASK.md": `---
825
- ticketUrl: https://example.com/tasks/second
826
- epic: cycles
827
- repo: agency
828
- branch: task/second
829
- base: main
830
- pr: null
831
- ---
832
- `,
833
- },
834
- expected: [
835
- "Task dependency cycle includes 'first'",
836
- "Phase dependency cycle includes 'contract'",
837
- ],
838
- },
839
- {
840
- name: "phase-layout",
841
- files: {
842
- "tasks/multi/TASK.md": `---
843
- ticketUrl: https://example.com/tasks/multi
844
- phases:
845
- - id: listed
846
- ---
847
- `,
848
- "tasks/multi/phases/listed/PHASE.md": `---
849
- repo: agency
850
- branch: task/listed
851
- base: main
852
- pr: null
853
- ---
854
- `,
855
- "tasks/multi/phases/unlisted/PHASE.md": `---
856
- repo: agency
857
- branch: task/unlisted
858
- base: main
859
- pr: null
860
- ---
861
- `,
862
- "tasks/single/TASK.md": `---
863
- ticketUrl: https://example.com/tasks/single
864
- repo: agency
865
- branch: task/single
866
- base: main
867
- pr: null
868
- ---
869
- `,
870
- "tasks/single/phases/unexpected/PHASE.md": `---
871
- repo: agency
872
- branch: task/unexpected
873
- base: main
874
- pr: null
875
- ---
876
- `,
877
- },
878
- expected: [
879
- "Unlisted phase 'unlisted'",
880
- "Single-phase task cannot contain phase directories",
881
- ],
882
- },
883
- ]
884
-
885
- for (const fixture of fixtures) {
886
- const fixtureRoot = join(root, fixture.name)
887
- await write(fixtureRoot, "agency.json", '{"version":2}\n')
888
- await mkdir(join(fixtureRoot, "repos/agency"), { recursive: true })
889
- for (const directory of fixture.directories ?? []) {
890
- await mkdir(join(fixtureRoot, directory), { recursive: true })
891
- }
892
- for (const [path, content] of Object.entries(fixture.files)) {
893
- await write(fixtureRoot, path, content)
894
- }
895
-
896
- const report = await runTestEffect(
897
- WorkbaseService.pipe(
898
- Effect.flatMap((service) => service.validate(fixtureRoot)),
899
- ),
900
- )
901
- const messages = report.issues
902
- .map((issue) => `${issue.path}: ${issue.message}`)
903
- .join("\n")
904
- expect(report.valid, fixture.name).toBe(false)
905
- for (const expected of fixture.expected) {
906
- expect(messages, fixture.name).toContain(expected)
907
- }
908
- }
909
- })
910
- })