@markjaquith/agency 1.11.1 → 2.1.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 (132) hide show
  1. package/README.md +313 -117
  2. package/cli.ts +124 -420
  3. package/index.ts +1 -1
  4. package/package.json +12 -17
  5. package/skills/agency/SKILL.md +266 -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 +326 -399
  24. package/src/commands/work.ts +153 -153
  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/work-target.test.ts +77 -0
  53. package/src/workbase/work-target.ts +180 -0
  54. package/src/workbase/worktree-command.test.ts +61 -0
  55. package/src/workbase/worktree-command.ts +52 -0
  56. package/src/commands/base.test.ts +0 -198
  57. package/src/commands/base.ts +0 -198
  58. package/src/commands/clean.test.ts +0 -299
  59. package/src/commands/clean.ts +0 -274
  60. package/src/commands/completions.test.ts +0 -51
  61. package/src/commands/completions.ts +0 -308
  62. package/src/commands/emit.integration.test.ts +0 -549
  63. package/src/commands/emit.test.ts +0 -576
  64. package/src/commands/emit.ts +0 -700
  65. package/src/commands/emitted.test.ts +0 -226
  66. package/src/commands/emitted.ts +0 -57
  67. package/src/commands/loop.test.ts +0 -621
  68. package/src/commands/loop.ts +0 -406
  69. package/src/commands/merge.integration.test.ts +0 -195
  70. package/src/commands/merge.test.ts +0 -246
  71. package/src/commands/merge.ts +0 -244
  72. package/src/commands/pull.test.ts +0 -395
  73. package/src/commands/pull.ts +0 -205
  74. package/src/commands/push.test.ts +0 -641
  75. package/src/commands/push.ts +0 -482
  76. package/src/commands/rebase.test.ts +0 -521
  77. package/src/commands/rebase.ts +0 -232
  78. package/src/commands/save.test.ts +0 -247
  79. package/src/commands/save.ts +0 -162
  80. package/src/commands/source.test.ts +0 -195
  81. package/src/commands/source.ts +0 -72
  82. package/src/commands/switch.test.ts +0 -194
  83. package/src/commands/switch.ts +0 -84
  84. package/src/commands/task-branching.test.ts +0 -633
  85. package/src/commands/task-continue.test.ts +0 -311
  86. package/src/commands/task-edit.test.ts +0 -235
  87. package/src/commands/task-main.test.ts +0 -1066
  88. package/src/commands/task-squash.test.ts +0 -266
  89. package/src/commands/tasks.test.ts +0 -391
  90. package/src/commands/tasks.ts +0 -161
  91. package/src/commands/template-delete.test.ts +0 -178
  92. package/src/commands/template-delete.ts +0 -98
  93. package/src/commands/template-list.test.ts +0 -135
  94. package/src/commands/template-list.ts +0 -87
  95. package/src/commands/template-view.test.ts +0 -158
  96. package/src/commands/template-view.ts +0 -86
  97. package/src/commands/template.test.ts +0 -32
  98. package/src/commands/template.ts +0 -96
  99. package/src/commands/use.test.ts +0 -87
  100. package/src/commands/use.ts +0 -97
  101. package/src/constants.ts +0 -10
  102. package/src/errors.ts +0 -17
  103. package/src/schemas.ts +0 -33
  104. package/src/services/AgencyMetadataService.ts +0 -311
  105. package/src/services/ClaudeService.test.ts +0 -184
  106. package/src/services/ClaudeService.ts +0 -91
  107. package/src/services/ConfigService.ts +0 -115
  108. package/src/services/FilterRepoService.ts +0 -164
  109. package/src/services/FormatterService.test.ts +0 -432
  110. package/src/services/FormatterService.ts +0 -219
  111. package/src/services/GitService.ts +0 -1187
  112. package/src/services/MockFilterRepoService.ts +0 -140
  113. package/src/services/OpencodeService.ts +0 -263
  114. package/src/services/PromptService.ts +0 -183
  115. package/src/services/TemplateService.ts +0 -81
  116. package/src/types/native-exec.d.ts +0 -8
  117. package/src/utils/colors.ts +0 -178
  118. package/src/utils/git-path.ts +0 -6
  119. package/src/utils/glob.test.ts +0 -161
  120. package/src/utils/glob.ts +0 -78
  121. package/src/utils/paths.ts +0 -51
  122. package/src/utils/pr-branch.test.ts +0 -376
  123. package/src/utils/pr-branch.ts +0 -508
  124. package/src/utils/spinner.test.ts +0 -45
  125. package/src/utils/spinner.ts +0 -96
  126. package/src/utils/task-parser.test.ts +0 -172
  127. package/src/utils/task-parser.ts +0 -93
  128. package/templates/AGENCY.md +0 -22
  129. package/templates/AGENTS.md +0 -11
  130. package/templates/CLAUDE.md +0 -3
  131. package/templates/TASK.md +0 -5
  132. package/templates/opencode.json +0 -4
@@ -0,0 +1,493 @@
1
+ import { Schema } from "@effect/schema"
2
+ import { TreeFormatter } from "@effect/schema"
3
+ import { Data, Effect, Either } from "effect"
4
+ import { dirname, join, relative, resolve } from "node:path"
5
+ import { FileSystemService } from "./FileSystemService"
6
+ import { parseFrontmatter } from "../workbase/frontmatter"
7
+ import {
8
+ EpicFrontmatter,
9
+ PhaseFrontmatter,
10
+ TaskFrontmatter,
11
+ WorkbaseConfig,
12
+ type Dependency,
13
+ type EpicFrontmatter as EpicData,
14
+ type PhaseFrontmatter as PhaseData,
15
+ type TaskFrontmatter as TaskData,
16
+ } from "../workbase/schemas"
17
+ import { validateWorktreeCreateCommand } from "../workbase/worktree-command"
18
+
19
+ class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{
20
+ readonly message: string
21
+ }> {}
22
+
23
+ class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{
24
+ readonly message: string
25
+ readonly path: string
26
+ readonly cause?: unknown
27
+ }> {}
28
+
29
+ interface ValidationIssue {
30
+ readonly path: string
31
+ readonly message: string
32
+ }
33
+
34
+ interface ValidationReport {
35
+ readonly root: string
36
+ readonly issues: readonly ValidationIssue[]
37
+ readonly epicCount: number
38
+ readonly taskCount: number
39
+ readonly phaseCount: number
40
+ readonly valid: boolean
41
+ }
42
+
43
+ interface DocumentRecord<T> {
44
+ readonly id: string
45
+ readonly path: string
46
+ readonly data: T
47
+ }
48
+
49
+ type DecodeResult<T> =
50
+ | { readonly success: true; readonly value: T }
51
+ | { readonly success: false; readonly error: string }
52
+
53
+ const decode = <S extends Schema.Schema.AnyNoContext>(
54
+ schema: S,
55
+ input: unknown,
56
+ ): DecodeResult<Schema.Schema.Type<S>> => {
57
+ const result = Schema.decodeUnknownEither(schema, {
58
+ errors: "all",
59
+ onExcessProperty: "error",
60
+ })(input)
61
+
62
+ return Either.isLeft(result)
63
+ ? { success: false, error: TreeFormatter.formatErrorSync(result.left) }
64
+ : { success: true, value: result.right }
65
+ }
66
+
67
+ const findCycles = (nodes: readonly Dependency[]): readonly string[] => {
68
+ const dependencies = new Map(
69
+ nodes.map((node) => [node.id, [...(node.dependsOn ?? [])]]),
70
+ )
71
+ const visiting = new Set<string>()
72
+ const visited = new Set<string>()
73
+ const cycles = new Set<string>()
74
+
75
+ const visit = (id: string) => {
76
+ if (visiting.has(id)) {
77
+ cycles.add(id)
78
+ return
79
+ }
80
+ if (visited.has(id)) {
81
+ return
82
+ }
83
+
84
+ visiting.add(id)
85
+ for (const dependency of dependencies.get(id) ?? []) {
86
+ if (dependencies.has(dependency)) {
87
+ visit(dependency)
88
+ }
89
+ }
90
+ visiting.delete(id)
91
+ visited.add(id)
92
+ }
93
+
94
+ for (const id of dependencies.keys()) {
95
+ visit(id)
96
+ }
97
+
98
+ return [...cycles].sort()
99
+ }
100
+
101
+ export class WorkbaseService extends Effect.Service<WorkbaseService>()(
102
+ "WorkbaseService",
103
+ {
104
+ sync: () => ({
105
+ initialize: (path: string = process.cwd()) =>
106
+ Effect.gen(function* () {
107
+ const fs = yield* FileSystemService
108
+ const root = resolve(path)
109
+ const configPath = join(root, "agency.json")
110
+
111
+ if (yield* fs.exists(configPath)) {
112
+ return yield* new WorkbaseConfigError({
113
+ path: configPath,
114
+ message: `Agency configuration already exists: ${configPath}`,
115
+ })
116
+ }
117
+
118
+ yield* fs.createDirectory(root)
119
+ yield* fs.writeJSON(configPath, { version: 2 })
120
+ for (const directory of ["repos", "epics", "tasks"]) {
121
+ yield* fs.createDirectory(join(root, directory))
122
+ }
123
+
124
+ const ignorePath = join(root, ".gitignore")
125
+ const requiredPatterns = [
126
+ "/repos/",
127
+ "/tasks/*/code/",
128
+ "/tasks/*/phases/*/code/",
129
+ ]
130
+ const existing = (yield* fs.exists(ignorePath))
131
+ ? yield* fs.readFile(ignorePath)
132
+ : ""
133
+ const existingLines = new Set(existing.split(/\r?\n/))
134
+ const missing = requiredPatterns.filter(
135
+ (pattern) => !existingLines.has(pattern),
136
+ )
137
+ if (missing.length > 0) {
138
+ const prefix =
139
+ existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""
140
+ yield* fs.writeFile(
141
+ ignorePath,
142
+ `${existing}${prefix}${missing.join("\n")}\n`,
143
+ )
144
+ }
145
+
146
+ return root
147
+ }),
148
+
149
+ discover: (startPath: string = process.cwd()) =>
150
+ Effect.gen(function* () {
151
+ const fs = yield* FileSystemService
152
+ let current = resolve(startPath)
153
+
154
+ if (!(yield* fs.isDirectory(current))) {
155
+ current = dirname(current)
156
+ }
157
+
158
+ while (true) {
159
+ const configPath = join(current, "agency.json")
160
+ if (yield* fs.exists(configPath)) {
161
+ const content = yield* fs.readFile(configPath)
162
+ let input: unknown
163
+ try {
164
+ input = JSON.parse(content)
165
+ } catch (cause) {
166
+ return yield* new WorkbaseConfigError({
167
+ path: configPath,
168
+ message: `Invalid JSON in ${configPath}`,
169
+ cause,
170
+ })
171
+ }
172
+
173
+ if (
174
+ typeof input === "object" &&
175
+ input !== null &&
176
+ "version" in input &&
177
+ input.version === 2
178
+ ) {
179
+ const decoded = decode(WorkbaseConfig, input)
180
+ if (!decoded.success) {
181
+ return yield* new WorkbaseConfigError({
182
+ path: configPath,
183
+ message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`,
184
+ })
185
+ }
186
+ if (decoded.value.worktreeCreateCommand) {
187
+ try {
188
+ validateWorktreeCreateCommand(
189
+ decoded.value.worktreeCreateCommand,
190
+ )
191
+ } catch (cause) {
192
+ return yield* new WorkbaseConfigError({
193
+ path: configPath,
194
+ message:
195
+ cause instanceof Error
196
+ ? cause.message
197
+ : "Invalid worktreeCreateCommand",
198
+ })
199
+ }
200
+ }
201
+ return current
202
+ }
203
+ }
204
+
205
+ const parent = dirname(current)
206
+ if (parent === current) {
207
+ return yield* new WorkbaseNotFoundError({
208
+ message: `No Agency workbase found from ${resolve(startPath)}`,
209
+ })
210
+ }
211
+ current = parent
212
+ }
213
+ }),
214
+
215
+ loadConfig: (startPath: string = process.cwd()) =>
216
+ Effect.gen(function* () {
217
+ const service = yield* WorkbaseService
218
+ const fs = yield* FileSystemService
219
+ const root = yield* service.discover(startPath)
220
+ const configPath = join(root, "agency.json")
221
+ const content = yield* fs.readFile(configPath)
222
+ let input: unknown
223
+ try {
224
+ input = JSON.parse(content)
225
+ } catch (cause) {
226
+ return yield* new WorkbaseConfigError({
227
+ path: configPath,
228
+ message: `Invalid JSON in ${configPath}`,
229
+ cause,
230
+ })
231
+ }
232
+ const decoded = decode(WorkbaseConfig, input)
233
+ if (!decoded.success) {
234
+ return yield* new WorkbaseConfigError({
235
+ path: configPath,
236
+ message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`,
237
+ })
238
+ }
239
+ return { root, config: decoded.value }
240
+ }),
241
+
242
+ validate: (startPath: string = process.cwd()) =>
243
+ Effect.gen(function* () {
244
+ const service = yield* WorkbaseService
245
+ const fs = yield* FileSystemService
246
+ const root = yield* service.discover(startPath)
247
+ const issues: ValidationIssue[] = []
248
+ const epics = new Map<string, DocumentRecord<EpicData>>()
249
+ const tasks = new Map<string, DocumentRecord<TaskData>>()
250
+ const phases = new Map<string, DocumentRecord<PhaseData>>()
251
+
252
+ const issue = (path: string, message: string) => {
253
+ issues.push({ path: relative(root, path) || ".", message })
254
+ }
255
+
256
+ const readDirectories = (path: string) =>
257
+ Effect.gen(function* () {
258
+ if (!(yield* fs.isDirectory(path))) {
259
+ return []
260
+ }
261
+ const entries = yield* fs.readDirectory(path)
262
+ return entries
263
+ .filter((entry) => entry.isDirectory)
264
+ .map((entry) => entry.name)
265
+ .sort()
266
+ })
267
+
268
+ const readAliases = Effect.gen(function* () {
269
+ const reposPath = join(root, "repos")
270
+ if (!(yield* fs.isDirectory(reposPath))) {
271
+ return new Set<string>()
272
+ }
273
+ const entries = yield* fs.readDirectory(reposPath)
274
+ return new Set(
275
+ entries
276
+ .filter((entry) => entry.isDirectory || entry.isSymlink)
277
+ .map((entry) => entry.name),
278
+ )
279
+ })
280
+
281
+ const aliases = yield* readAliases
282
+
283
+ const readDocument = <S extends Schema.Schema.AnyNoContext>(
284
+ path: string,
285
+ schema: S,
286
+ ) =>
287
+ Effect.gen(function* () {
288
+ if (!(yield* fs.exists(path))) {
289
+ issue(path, "Required document is missing")
290
+ return null
291
+ }
292
+
293
+ const content = yield* fs.readFile(path)
294
+ const parsed = yield* Effect.either(
295
+ parseFrontmatter(content, path),
296
+ )
297
+ if (Either.isLeft(parsed)) {
298
+ issue(path, parsed.left.message)
299
+ return null
300
+ }
301
+
302
+ const decoded = decode(schema, parsed.right.data)
303
+ if (!decoded.success) {
304
+ issue(path, decoded.error)
305
+ return null
306
+ }
307
+ return decoded.value
308
+ })
309
+
310
+ for (const id of yield* readDirectories(join(root, "epics"))) {
311
+ const path = join(root, "epics", id, "EPIC.md")
312
+ const data = yield* readDocument(path, EpicFrontmatter)
313
+ if (data) {
314
+ epics.set(id, { id, path, data })
315
+ }
316
+ }
317
+
318
+ for (const id of yield* readDirectories(join(root, "tasks"))) {
319
+ const taskPath = join(root, "tasks", id)
320
+ const path = join(taskPath, "TASK.md")
321
+ const data = yield* readDocument(path, TaskFrontmatter)
322
+ if (data) {
323
+ tasks.set(id, { id, path, data })
324
+ }
325
+
326
+ for (const phaseId of yield* readDirectories(
327
+ join(taskPath, "phases"),
328
+ )) {
329
+ const phasePath = join(taskPath, "phases", phaseId, "PHASE.md")
330
+ const phase = yield* readDocument(phasePath, PhaseFrontmatter)
331
+ if (phase) {
332
+ phases.set(`${id}/${phaseId}`, {
333
+ id: phaseId,
334
+ path: phasePath,
335
+ data: phase,
336
+ })
337
+ }
338
+ }
339
+ }
340
+
341
+ const validateRepositories = (
342
+ record: DocumentRecord<EpicData | TaskData | PhaseData>,
343
+ ) => {
344
+ const data = record.data
345
+ const writable = "repo" in data ? data.repo : undefined
346
+ const references = "repos" in data ? (data.repos ?? []) : []
347
+ const referenceAliases = references.map(
348
+ (reference) => reference.repo,
349
+ )
350
+
351
+ if (writable && referenceAliases.includes(writable)) {
352
+ issue(
353
+ record.path,
354
+ `Repository '${writable}' cannot also be a reference`,
355
+ )
356
+ }
357
+
358
+ const all = [writable, ...referenceAliases].filter(
359
+ (alias): alias is string => alias !== undefined,
360
+ )
361
+ for (const alias of new Set(all)) {
362
+ if (!aliases.has(alias)) {
363
+ issue(record.path, `Unknown repository alias '${alias}'`)
364
+ }
365
+ }
366
+
367
+ if (new Set(referenceAliases).size !== referenceAliases.length) {
368
+ issue(record.path, "Repository references must be unique")
369
+ }
370
+ }
371
+
372
+ const branchOwners = new Map<
373
+ string,
374
+ DocumentRecord<TaskData | PhaseData>
375
+ >()
376
+ const validateBranchOwnership = (
377
+ record: DocumentRecord<TaskData | PhaseData>,
378
+ ) => {
379
+ if (!("repo" in record.data)) return
380
+ const key = `${record.data.repo}\u0000${record.data.branch}`
381
+ const owner = branchOwners.get(key)
382
+ if (owner) {
383
+ issue(
384
+ record.path,
385
+ `Writable branch '${record.data.branch}' for repository '${record.data.repo}' is also owned by ${relative(root, owner.path)}`,
386
+ )
387
+ } else {
388
+ branchOwners.set(key, record)
389
+ }
390
+ }
391
+
392
+ for (const epic of epics.values()) {
393
+ validateRepositories(epic)
394
+ const ids = new Set(epic.data.tasks.map((task) => task.id))
395
+ if (ids.size !== epic.data.tasks.length) {
396
+ issue(epic.path, "Epic task IDs must be unique")
397
+ }
398
+ for (const task of epic.data.tasks) {
399
+ const child = tasks.get(task.id)
400
+ if (!child) {
401
+ issue(epic.path, `Unknown child task '${task.id}'`)
402
+ } else if (child.data.epic !== epic.id) {
403
+ issue(
404
+ child.path,
405
+ `Task must reference parent epic '${epic.id}'`,
406
+ )
407
+ }
408
+ for (const dependency of task.dependsOn ?? []) {
409
+ if (!ids.has(dependency)) {
410
+ issue(epic.path, `Unknown task dependency '${dependency}'`)
411
+ }
412
+ }
413
+ }
414
+ for (const cycle of findCycles(epic.data.tasks)) {
415
+ issue(epic.path, `Task dependency cycle includes '${cycle}'`)
416
+ }
417
+ }
418
+
419
+ for (const task of tasks.values()) {
420
+ validateRepositories(task)
421
+ validateBranchOwnership(task)
422
+ if (task.data.epic) {
423
+ const parent = epics.get(task.data.epic)
424
+ if (!parent) {
425
+ issue(task.path, `Unknown parent epic '${task.data.epic}'`)
426
+ } else if (
427
+ !parent.data.tasks.some((child) => child.id === task.id)
428
+ ) {
429
+ issue(parent.path, `Epic does not list child task '${task.id}'`)
430
+ }
431
+ }
432
+
433
+ const phasePrefix = `${task.id}/`
434
+ const actualPhaseIds = [...phases.keys()]
435
+ .filter((key) => key.startsWith(phasePrefix))
436
+ .map((key) => key.slice(phasePrefix.length))
437
+
438
+ if ("phases" in task.data) {
439
+ const declaredIds = new Set(
440
+ task.data.phases.map((phase) => phase.id),
441
+ )
442
+ if (declaredIds.size !== task.data.phases.length) {
443
+ issue(task.path, "Task phase IDs must be unique")
444
+ }
445
+ for (const phase of task.data.phases) {
446
+ if (!phases.has(`${task.id}/${phase.id}`)) {
447
+ issue(task.path, `Missing phase '${phase.id}'`)
448
+ }
449
+ for (const dependency of phase.dependsOn ?? []) {
450
+ if (!declaredIds.has(dependency)) {
451
+ issue(task.path, `Unknown phase dependency '${dependency}'`)
452
+ }
453
+ }
454
+ }
455
+ for (const phaseId of actualPhaseIds) {
456
+ if (!declaredIds.has(phaseId)) {
457
+ issue(task.path, `Unlisted phase '${phaseId}'`)
458
+ }
459
+ }
460
+ for (const cycle of findCycles(task.data.phases)) {
461
+ issue(task.path, `Phase dependency cycle includes '${cycle}'`)
462
+ }
463
+ } else if (actualPhaseIds.length > 0) {
464
+ issue(
465
+ task.path,
466
+ "Single-phase task cannot contain phase directories",
467
+ )
468
+ }
469
+ }
470
+
471
+ for (const phase of phases.values()) {
472
+ validateRepositories(phase)
473
+ validateBranchOwnership(phase)
474
+ }
475
+
476
+ issues.sort((a, b) =>
477
+ a.path === b.path
478
+ ? a.message.localeCompare(b.message)
479
+ : a.path.localeCompare(b.path),
480
+ )
481
+
482
+ return {
483
+ root,
484
+ issues,
485
+ epicCount: epics.size,
486
+ taskCount: tasks.size,
487
+ phaseCount: phases.size,
488
+ valid: issues.length === 0,
489
+ } satisfies ValidationReport
490
+ }),
491
+ }),
492
+ },
493
+ ) {}