@markjaquith/agency 1.11.0 → 2.0.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.
Files changed (130) hide show
  1. package/README.md +302 -113
  2. package/cli.ts +123 -415
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +258 -0
  6. package/src/cli.test.ts +290 -0
  7. package/src/commands/description.test.ts +103 -0
  8. package/src/commands/epic.test.ts +103 -0
  9. package/src/commands/epic.ts +101 -0
  10. package/src/commands/init.test.ts +42 -290
  11. package/src/commands/init.ts +15 -123
  12. package/src/commands/phase.ts +120 -0
  13. package/src/commands/pr.test.ts +19 -170
  14. package/src/commands/pr.ts +23 -179
  15. package/src/commands/repo.test.ts +80 -0
  16. package/src/commands/repo.ts +83 -0
  17. package/src/commands/status.test.ts +35 -477
  18. package/src/commands/status.ts +24 -213
  19. package/src/commands/task-phase.test.ts +206 -0
  20. package/src/commands/task.ts +83 -1285
  21. package/src/commands/validate.test.ts +91 -0
  22. package/src/commands/validate.ts +50 -0
  23. package/src/commands/work.test.ts +172 -419
  24. package/src/commands/work.ts +47 -166
  25. package/src/services/EpicService.test.ts +71 -0
  26. package/src/services/EpicService.ts +135 -0
  27. package/src/services/FileSystemService.ts +61 -92
  28. package/src/services/PhaseService.ts +323 -0
  29. package/src/services/PullRequestService.test.ts +386 -0
  30. package/src/services/PullRequestService.ts +143 -0
  31. package/src/services/RepositoryService.test.ts +106 -0
  32. package/src/services/RepositoryService.ts +159 -0
  33. package/src/services/TaskPhaseService.test.ts +217 -0
  34. package/src/services/TaskService.ts +192 -0
  35. package/src/services/WorkbaseService.test.ts +446 -0
  36. package/src/services/WorkbaseService.ts +493 -0
  37. package/src/services/WorktreeService.test.ts +599 -0
  38. package/src/services/WorktreeService.ts +357 -0
  39. package/src/test-utils.ts +41 -438
  40. package/src/types.ts +1 -215
  41. package/src/utils/command.ts +1 -5
  42. package/src/utils/effect.test.ts +24 -0
  43. package/src/utils/effect.ts +3 -273
  44. package/src/utils/process.test.ts +0 -15
  45. package/src/utils/process.ts +1 -60
  46. package/src/workbase/frontmatter.test.ts +50 -0
  47. package/src/workbase/frontmatter.ts +77 -0
  48. package/src/workbase/repository-reference.test.ts +25 -0
  49. package/src/workbase/repository-reference.ts +18 -0
  50. package/src/workbase/schemas.test.ts +146 -0
  51. package/src/workbase/schemas.ts +78 -0
  52. package/src/workbase/worktree-command.test.ts +61 -0
  53. package/src/workbase/worktree-command.ts +52 -0
  54. package/src/commands/base.test.ts +0 -198
  55. package/src/commands/base.ts +0 -198
  56. package/src/commands/clean.test.ts +0 -299
  57. package/src/commands/clean.ts +0 -274
  58. package/src/commands/completions.test.ts +0 -51
  59. package/src/commands/completions.ts +0 -308
  60. package/src/commands/emit.integration.test.ts +0 -549
  61. package/src/commands/emit.test.ts +0 -576
  62. package/src/commands/emit.ts +0 -700
  63. package/src/commands/emitted.test.ts +0 -226
  64. package/src/commands/emitted.ts +0 -57
  65. package/src/commands/loop.test.ts +0 -621
  66. package/src/commands/loop.ts +0 -406
  67. package/src/commands/merge.integration.test.ts +0 -195
  68. package/src/commands/merge.test.ts +0 -246
  69. package/src/commands/merge.ts +0 -244
  70. package/src/commands/pull.test.ts +0 -395
  71. package/src/commands/pull.ts +0 -205
  72. package/src/commands/push.test.ts +0 -518
  73. package/src/commands/push.ts +0 -434
  74. package/src/commands/rebase.test.ts +0 -521
  75. package/src/commands/rebase.ts +0 -232
  76. package/src/commands/save.test.ts +0 -247
  77. package/src/commands/save.ts +0 -162
  78. package/src/commands/source.test.ts +0 -195
  79. package/src/commands/source.ts +0 -72
  80. package/src/commands/switch.test.ts +0 -194
  81. package/src/commands/switch.ts +0 -84
  82. package/src/commands/task-branching.test.ts +0 -633
  83. package/src/commands/task-continue.test.ts +0 -311
  84. package/src/commands/task-edit.test.ts +0 -235
  85. package/src/commands/task-main.test.ts +0 -1066
  86. package/src/commands/task-squash.test.ts +0 -266
  87. package/src/commands/tasks.test.ts +0 -391
  88. package/src/commands/tasks.ts +0 -161
  89. package/src/commands/template-delete.test.ts +0 -178
  90. package/src/commands/template-delete.ts +0 -98
  91. package/src/commands/template-list.test.ts +0 -135
  92. package/src/commands/template-list.ts +0 -87
  93. package/src/commands/template-view.test.ts +0 -158
  94. package/src/commands/template-view.ts +0 -86
  95. package/src/commands/template.test.ts +0 -32
  96. package/src/commands/template.ts +0 -96
  97. package/src/commands/use.test.ts +0 -87
  98. package/src/commands/use.ts +0 -97
  99. package/src/constants.ts +0 -10
  100. package/src/errors.ts +0 -17
  101. package/src/schemas.ts +0 -33
  102. package/src/services/AgencyMetadataService.ts +0 -311
  103. package/src/services/ClaudeService.test.ts +0 -184
  104. package/src/services/ClaudeService.ts +0 -91
  105. package/src/services/ConfigService.ts +0 -115
  106. package/src/services/FilterRepoService.ts +0 -164
  107. package/src/services/FormatterService.test.ts +0 -432
  108. package/src/services/FormatterService.ts +0 -219
  109. package/src/services/GitService.ts +0 -1183
  110. package/src/services/MockFilterRepoService.ts +0 -140
  111. package/src/services/OpencodeService.ts +0 -263
  112. package/src/services/PromptService.ts +0 -183
  113. package/src/services/TemplateService.ts +0 -81
  114. package/src/types/native-exec.d.ts +0 -8
  115. package/src/utils/colors.ts +0 -178
  116. package/src/utils/git-path.ts +0 -6
  117. package/src/utils/glob.test.ts +0 -161
  118. package/src/utils/glob.ts +0 -78
  119. package/src/utils/paths.ts +0 -51
  120. package/src/utils/pr-branch.test.ts +0 -376
  121. package/src/utils/pr-branch.ts +0 -508
  122. package/src/utils/spinner.test.ts +0 -45
  123. package/src/utils/spinner.ts +0 -96
  124. package/src/utils/task-parser.test.ts +0 -172
  125. package/src/utils/task-parser.ts +0 -93
  126. package/templates/AGENCY.md +0 -22
  127. package/templates/AGENTS.md +0 -11
  128. package/templates/CLAUDE.md +0 -3
  129. package/templates/TASK.md +0 -5
  130. package/templates/opencode.json +0 -4
@@ -0,0 +1,192 @@
1
+ import { Schema, TreeFormatter } from "@effect/schema"
2
+ import { Data, Effect, Either } from "effect"
3
+ import { join } from "node:path"
4
+ import { FileSystemService } from "./FileSystemService"
5
+ import { WorkbaseService } from "./WorkbaseService"
6
+ import { EpicService, type EpicRecord } from "./EpicService"
7
+ import {
8
+ EntityId,
9
+ TaskFrontmatter,
10
+ type RepositoryReference,
11
+ type TaskFrontmatter as TaskData,
12
+ } from "../workbase/schemas"
13
+ import {
14
+ formatMarkdownDocument,
15
+ parseFrontmatter,
16
+ } from "../workbase/frontmatter"
17
+
18
+ class TaskError extends Data.TaggedError("TaskError")<{
19
+ readonly message: string
20
+ }> {}
21
+
22
+ interface TaskRecord {
23
+ readonly id: string
24
+ readonly path: string
25
+ readonly content: string
26
+ readonly data: TaskData
27
+ }
28
+
29
+ export interface CreateTaskInput {
30
+ readonly id: string
31
+ readonly ticketUrl: string
32
+ readonly description?: string
33
+ readonly epic?: string
34
+ readonly multiPhase?: boolean
35
+ readonly repo?: string
36
+ readonly repos?: readonly RepositoryReference[]
37
+ readonly branch?: string
38
+ readonly base?: string
39
+ }
40
+
41
+ const decodeTask = (input: unknown) => {
42
+ const result = Schema.decodeUnknownEither(TaskFrontmatter, {
43
+ errors: "all",
44
+ onExcessProperty: "error",
45
+ })(input)
46
+ return Either.isLeft(result)
47
+ ? Effect.fail(
48
+ new TaskError({ message: TreeFormatter.formatErrorSync(result.left) }),
49
+ )
50
+ : Effect.succeed(result.right)
51
+ }
52
+
53
+ const decodeId = (id: string) => {
54
+ const result = Schema.decodeUnknownEither(EntityId)(id)
55
+ return Either.isLeft(result)
56
+ ? Effect.fail(new TaskError({ message: `Invalid task ID '${id}'` }))
57
+ : Effect.succeed(result.right)
58
+ }
59
+
60
+ export class TaskService extends Effect.Service<TaskService>()("TaskService", {
61
+ sync: () => ({
62
+ create: (input: CreateTaskInput, startPath: string = process.cwd()) =>
63
+ Effect.gen(function* () {
64
+ const fs = yield* FileSystemService
65
+ const workbase = yield* WorkbaseService
66
+ const epics = yield* EpicService
67
+ const root = yield* workbase.discover(startPath)
68
+ const id = yield* decodeId(input.id)
69
+ const directory = join(root, "tasks", id)
70
+ const path = join(directory, "TASK.md")
71
+
72
+ if (yield* fs.exists(directory)) {
73
+ return yield* new TaskError({
74
+ message: `Task '${id}' already exists`,
75
+ })
76
+ }
77
+
78
+ let data: TaskData
79
+ if (input.multiPhase) {
80
+ data = yield* decodeTask({
81
+ ticketUrl: input.ticketUrl,
82
+ ...(input.description !== undefined
83
+ ? { description: input.description }
84
+ : {}),
85
+ ...(input.epic ? { epic: input.epic } : {}),
86
+ phases: [],
87
+ })
88
+ } else {
89
+ if (!input.repo || !input.branch || !input.base) {
90
+ return yield* new TaskError({
91
+ message: "Single-phase tasks require repo, branch, and base",
92
+ })
93
+ }
94
+ data = yield* decodeTask({
95
+ ticketUrl: input.ticketUrl,
96
+ ...(input.description !== undefined
97
+ ? { description: input.description }
98
+ : {}),
99
+ ...(input.epic ? { epic: input.epic } : {}),
100
+ repo: input.repo,
101
+ ...(input.repos?.length ? { repos: input.repos } : {}),
102
+ branch: input.branch,
103
+ base: input.base,
104
+ pr: null,
105
+ })
106
+ }
107
+
108
+ const referencedRepos =
109
+ "repo" in data
110
+ ? [
111
+ data.repo,
112
+ ...(data.repos ?? []).map((reference) => reference.repo),
113
+ ]
114
+ : []
115
+ for (const alias of referencedRepos) {
116
+ if (!(yield* fs.exists(join(root, "repos", alias)))) {
117
+ return yield* new TaskError({
118
+ message: `Unknown repository alias '${alias}'`,
119
+ })
120
+ }
121
+ }
122
+
123
+ let parentEpic: EpicRecord | undefined
124
+ if (input.epic) {
125
+ parentEpic = yield* epics.show(input.epic, root)
126
+ }
127
+
128
+ const title = id
129
+ .split("-")
130
+ .map((part) => part[0]?.toUpperCase() + part.slice(1))
131
+ .join(" ")
132
+ const content = formatMarkdownDocument(
133
+ data,
134
+ `# ${title}\n\nDescribe the task outcome.`,
135
+ )
136
+ yield* fs.createDirectory(directory)
137
+ yield* fs.writeFile(path, content)
138
+
139
+ if (input.epic && parentEpic) {
140
+ const parsed = yield* parseFrontmatter(
141
+ parentEpic.content,
142
+ parentEpic.path,
143
+ )
144
+ const epicData = {
145
+ ...parentEpic.data,
146
+ tasks: [...parentEpic.data.tasks, { id }],
147
+ }
148
+ const updated = formatMarkdownDocument(epicData, parsed.body)
149
+ yield* fs.writeFile(parentEpic.path, updated)
150
+ }
151
+
152
+ return { id, path, content, data } satisfies TaskRecord
153
+ }),
154
+
155
+ list: (startPath: string = process.cwd()) =>
156
+ Effect.gen(function* () {
157
+ const fs = yield* FileSystemService
158
+ const workbase = yield* WorkbaseService
159
+ const root = yield* workbase.discover(startPath)
160
+ const directory = join(root, "tasks")
161
+ if (!(yield* fs.isDirectory(directory))) return [] as TaskRecord[]
162
+ const entries = (yield* fs.readDirectory(directory))
163
+ .filter((entry) => entry.isDirectory)
164
+ .sort((a, b) => a.name.localeCompare(b.name))
165
+ const records: TaskRecord[] = []
166
+ for (const entry of entries) {
167
+ const path = join(directory, entry.name, "TASK.md")
168
+ if (!(yield* fs.exists(path))) continue
169
+ const content = yield* fs.readFile(path)
170
+ const parsed = yield* parseFrontmatter(content, path)
171
+ const data = yield* decodeTask(parsed.data)
172
+ records.push({ id: entry.name, path, content, data })
173
+ }
174
+ return records
175
+ }),
176
+
177
+ show: (id: string, startPath: string = process.cwd()) =>
178
+ Effect.gen(function* () {
179
+ const service = yield* TaskService
180
+ const validId = yield* decodeId(id)
181
+ const record = (yield* service.list(startPath)).find(
182
+ (task) => task.id === validId,
183
+ )
184
+ if (!record) {
185
+ return yield* new TaskError({
186
+ message: `Task '${validId}' does not exist`,
187
+ })
188
+ }
189
+ return record
190
+ }),
191
+ }),
192
+ }) {}
@@ -0,0 +1,446 @@
1
+ import { afterEach, beforeEach, 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 { WorkbaseService } from "./WorkbaseService"
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
+ describe("WorkbaseService", () => {
15
+ let root: string
16
+
17
+ beforeEach(async () => {
18
+ root = await createTempDir()
19
+ })
20
+
21
+ afterEach(async () => {
22
+ await cleanupTempDir(root)
23
+ })
24
+
25
+ test("discovers a workbase from a nested directory", async () => {
26
+ await write(root, "agency.json", '{"version":2}\n')
27
+ await mkdir(join(root, "nested/repository/src"), { recursive: true })
28
+
29
+ const discovered = await runTestEffect(
30
+ WorkbaseService.pipe(
31
+ Effect.flatMap((service) =>
32
+ service.discover(join(root, "nested/repository/src")),
33
+ ),
34
+ ),
35
+ )
36
+
37
+ expect(discovered).toBe(root)
38
+ })
39
+
40
+ test("rejects an invalid worktree command template", async () => {
41
+ await write(
42
+ root,
43
+ "agency.json",
44
+ JSON.stringify({
45
+ version: 2,
46
+ worktreeCreateCommand: ["tool", "{repo}"],
47
+ }),
48
+ )
49
+
50
+ await expect(
51
+ runTestEffect(
52
+ WorkbaseService.pipe(
53
+ Effect.flatMap((service) => service.discover(root)),
54
+ ),
55
+ ),
56
+ ).rejects.toThrow("{worktree}")
57
+ })
58
+
59
+ test("validates a workbase with an epic and multi-phase task", async () => {
60
+ await write(root, "agency.json", '{"version":2}\n')
61
+ await mkdir(join(root, "repos/agency"), { recursive: true })
62
+ await mkdir(join(root, "repos/effect"), { recursive: true })
63
+ await write(
64
+ root,
65
+ "epics/example/EPIC.md",
66
+ `---
67
+ ticketUrl: https://example.com/epics/example
68
+ repos:
69
+ - repo: agency
70
+ ref: main
71
+ tasks:
72
+ - id: example-task
73
+ ---
74
+
75
+ # Example
76
+ `,
77
+ )
78
+ await write(
79
+ root,
80
+ "tasks/example-task/TASK.md",
81
+ `---
82
+ ticketUrl: https://example.com/tasks/example
83
+ epic: example
84
+ phases:
85
+ - id: implementation
86
+ ---
87
+
88
+ # Example task
89
+ `,
90
+ )
91
+ await write(
92
+ root,
93
+ "tasks/example-task/phases/implementation/PHASE.md",
94
+ `---
95
+ repo: agency
96
+ repos:
97
+ - repo: effect
98
+ ref: main
99
+ branch: task/example
100
+ base: main
101
+ pr: null
102
+ ---
103
+
104
+ # Implementation
105
+ `,
106
+ )
107
+
108
+ const report = await runTestEffect(
109
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
110
+ )
111
+
112
+ expect(report.valid).toBe(true)
113
+ expect(report.issues).toEqual([])
114
+ expect(report.epicCount).toBe(1)
115
+ expect(report.taskCount).toBe(1)
116
+ expect(report.phaseCount).toBe(1)
117
+ })
118
+
119
+ test("reports schema, alias, backlink, and dependency errors", async () => {
120
+ await write(root, "agency.json", '{"version":2}\n')
121
+ await mkdir(join(root, "repos/agency"), { recursive: true })
122
+ await write(
123
+ root,
124
+ "epics/example/EPIC.md",
125
+ `---
126
+ ticketUrl: https://example.com/epics/example
127
+ repos:
128
+ - repo: missing
129
+ ref: main
130
+ tasks:
131
+ - id: example-task
132
+ dependsOn:
133
+ - absent-task
134
+ ---
135
+
136
+ # Example
137
+ `,
138
+ )
139
+ await write(
140
+ root,
141
+ "tasks/example-task/TASK.md",
142
+ `---
143
+ ticketUrl: https://example.com/tasks/example
144
+ repo: agency
145
+ repos:
146
+ - repo: agency
147
+ ref: main
148
+ branch: task/example
149
+ base: main
150
+ pr: null
151
+ ---
152
+
153
+ # Example task
154
+ `,
155
+ )
156
+ await write(
157
+ root,
158
+ "tasks/bad-schema/TASK.md",
159
+ `---
160
+ ticketUrl: https://example.com/tasks/bad-schema
161
+ repo: agency
162
+ branch: task/bad-schema
163
+ base: main
164
+ pr: not-a-url
165
+ ---
166
+
167
+ # Bad schema
168
+ `,
169
+ )
170
+
171
+ const report = await runTestEffect(
172
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
173
+ )
174
+
175
+ expect(report.valid).toBe(false)
176
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
177
+ "Unknown repository alias 'missing'",
178
+ )
179
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
180
+ "Unknown task dependency 'absent-task'",
181
+ )
182
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
183
+ "Task must reference parent epic 'example'",
184
+ )
185
+ expect(report.issues.map((issue) => issue.message).join("\n")).toContain(
186
+ "Repository 'agency' cannot also be a reference",
187
+ )
188
+ expect(
189
+ report.issues.some(
190
+ (issue) => issue.path.endsWith("bad-schema/TASK.md") && issue.message,
191
+ ),
192
+ ).toBe(true)
193
+ expect(report.issues.some((issue) => issue.path.endsWith("TASK.md"))).toBe(
194
+ true,
195
+ )
196
+ })
197
+
198
+ test("reports duplicate writable branch ownership", async () => {
199
+ await write(root, "agency.json", '{"version":2}\n')
200
+ await mkdir(join(root, "repos/agency"), { recursive: true })
201
+ for (const id of ["first", "second"]) {
202
+ await write(
203
+ root,
204
+ `tasks/${id}/TASK.md`,
205
+ `---
206
+ ticketUrl: https://example.com/tasks/${id}
207
+ repo: agency
208
+ branch: task/shared
209
+ base: main
210
+ pr: null
211
+ ---
212
+ `,
213
+ )
214
+ }
215
+
216
+ const report = await runTestEffect(
217
+ WorkbaseService.pipe(Effect.flatMap((service) => service.validate(root))),
218
+ )
219
+
220
+ expect(report.valid).toBe(false)
221
+ expect(report.issues).toContainEqual({
222
+ path: "tasks/second/TASK.md",
223
+ message:
224
+ "Writable branch 'task/shared' for repository 'agency' is also owned by tasks/first/TASK.md",
225
+ })
226
+ })
227
+
228
+ test("reports high-risk structural validation failures", async () => {
229
+ const fixtures: readonly {
230
+ name: string
231
+ files: Readonly<Record<string, string>>
232
+ directories?: readonly string[]
233
+ expected: readonly string[]
234
+ }[] = [
235
+ {
236
+ name: "missing-documents",
237
+ files: {},
238
+ directories: [
239
+ "epics/missing-epic",
240
+ "tasks/missing-task",
241
+ "tasks/missing-task/phases/missing-phase",
242
+ ],
243
+ expected: [
244
+ "epics/missing-epic/EPIC.md: Required document is missing",
245
+ "tasks/missing-task/TASK.md: Required document is missing",
246
+ "tasks/missing-task/phases/missing-phase/PHASE.md: Required document is missing",
247
+ ],
248
+ },
249
+ {
250
+ name: "duplicates",
251
+ files: {
252
+ "epics/duplicate/EPIC.md": `---
253
+ ticketUrl: https://example.com/epics/duplicate
254
+ repos:
255
+ - repo: agency
256
+ ref: main
257
+ - repo: agency
258
+ ref: release
259
+ tasks:
260
+ - id: child
261
+ - id: child
262
+ ---
263
+ `,
264
+ "tasks/child/TASK.md": `---
265
+ ticketUrl: https://example.com/tasks/child
266
+ epic: duplicate
267
+ phases:
268
+ - id: implementation
269
+ - id: implementation
270
+ ---
271
+ `,
272
+ "tasks/child/phases/implementation/PHASE.md": `---
273
+ repo: agency
274
+ branch: task/duplicate
275
+ base: main
276
+ pr: null
277
+ ---
278
+ `,
279
+ },
280
+ expected: [
281
+ "Repository references must be unique",
282
+ "Epic task IDs must be unique",
283
+ "Task phase IDs must be unique",
284
+ ],
285
+ },
286
+ {
287
+ name: "backlinks",
288
+ files: {
289
+ "epics/parent/EPIC.md": `---
290
+ ticketUrl: https://example.com/epics/parent
291
+ repos:
292
+ - repo: agency
293
+ ref: main
294
+ tasks:
295
+ - id: missing-backlink
296
+ ---
297
+ `,
298
+ "tasks/missing-backlink/TASK.md": `---
299
+ ticketUrl: https://example.com/tasks/missing-backlink
300
+ repo: agency
301
+ branch: task/missing-backlink
302
+ base: main
303
+ pr: null
304
+ ---
305
+ `,
306
+ "tasks/unlisted/TASK.md": `---
307
+ ticketUrl: https://example.com/tasks/unlisted
308
+ epic: parent
309
+ repo: agency
310
+ branch: task/unlisted
311
+ base: main
312
+ pr: null
313
+ ---
314
+ `,
315
+ },
316
+ expected: [
317
+ "Task must reference parent epic 'parent'",
318
+ "Epic does not list child task 'unlisted'",
319
+ ],
320
+ },
321
+ {
322
+ name: "dependency-cycles",
323
+ files: {
324
+ "epics/cycles/EPIC.md": `---
325
+ ticketUrl: https://example.com/epics/cycles
326
+ repos:
327
+ - repo: agency
328
+ ref: main
329
+ tasks:
330
+ - id: first
331
+ dependsOn: [second]
332
+ - id: second
333
+ dependsOn: [first]
334
+ ---
335
+ `,
336
+ "tasks/first/TASK.md": `---
337
+ ticketUrl: https://example.com/tasks/first
338
+ epic: cycles
339
+ phases:
340
+ - id: contract
341
+ dependsOn: [delivery]
342
+ - id: delivery
343
+ dependsOn: [contract]
344
+ ---
345
+ `,
346
+ "tasks/first/phases/contract/PHASE.md": `---
347
+ repo: agency
348
+ branch: task/contract
349
+ base: main
350
+ pr: null
351
+ ---
352
+ `,
353
+ "tasks/first/phases/delivery/PHASE.md": `---
354
+ repo: agency
355
+ branch: task/delivery
356
+ base: main
357
+ pr: null
358
+ ---
359
+ `,
360
+ "tasks/second/TASK.md": `---
361
+ ticketUrl: https://example.com/tasks/second
362
+ epic: cycles
363
+ repo: agency
364
+ branch: task/second
365
+ base: main
366
+ pr: null
367
+ ---
368
+ `,
369
+ },
370
+ expected: [
371
+ "Task dependency cycle includes 'first'",
372
+ "Phase dependency cycle includes 'contract'",
373
+ ],
374
+ },
375
+ {
376
+ name: "phase-layout",
377
+ files: {
378
+ "tasks/multi/TASK.md": `---
379
+ ticketUrl: https://example.com/tasks/multi
380
+ phases:
381
+ - id: listed
382
+ ---
383
+ `,
384
+ "tasks/multi/phases/listed/PHASE.md": `---
385
+ repo: agency
386
+ branch: task/listed
387
+ base: main
388
+ pr: null
389
+ ---
390
+ `,
391
+ "tasks/multi/phases/unlisted/PHASE.md": `---
392
+ repo: agency
393
+ branch: task/unlisted
394
+ base: main
395
+ pr: null
396
+ ---
397
+ `,
398
+ "tasks/single/TASK.md": `---
399
+ ticketUrl: https://example.com/tasks/single
400
+ repo: agency
401
+ branch: task/single
402
+ base: main
403
+ pr: null
404
+ ---
405
+ `,
406
+ "tasks/single/phases/unexpected/PHASE.md": `---
407
+ repo: agency
408
+ branch: task/unexpected
409
+ base: main
410
+ pr: null
411
+ ---
412
+ `,
413
+ },
414
+ expected: [
415
+ "Unlisted phase 'unlisted'",
416
+ "Single-phase task cannot contain phase directories",
417
+ ],
418
+ },
419
+ ]
420
+
421
+ for (const fixture of fixtures) {
422
+ const fixtureRoot = join(root, fixture.name)
423
+ await write(fixtureRoot, "agency.json", '{"version":2}\n')
424
+ await mkdir(join(fixtureRoot, "repos/agency"), { recursive: true })
425
+ for (const directory of fixture.directories ?? []) {
426
+ await mkdir(join(fixtureRoot, directory), { recursive: true })
427
+ }
428
+ for (const [path, content] of Object.entries(fixture.files)) {
429
+ await write(fixtureRoot, path, content)
430
+ }
431
+
432
+ const report = await runTestEffect(
433
+ WorkbaseService.pipe(
434
+ Effect.flatMap((service) => service.validate(fixtureRoot)),
435
+ ),
436
+ )
437
+ const messages = report.issues
438
+ .map((issue) => `${issue.path}: ${issue.message}`)
439
+ .join("\n")
440
+ expect(report.valid, fixture.name).toBe(false)
441
+ for (const expected of fixture.expected) {
442
+ expect(messages, fixture.name).toContain(expected)
443
+ }
444
+ }
445
+ })
446
+ })