import { Schema, Data, Effect, Result, Context, Layer } from "effect" import { homedir } from "node:os" import { dirname, join, relative, resolve } from "node:path" import { FileSystemService } from "./FileSystemService" import { parseFrontmatter } from "../workbase/frontmatter" import { EntityId, EpicFrontmatter, GlobalConfig, LegacyWorkbaseRegistry, PhaseFrontmatter, TaskFrontmatter, WorkbaseConfig, WorkbaseRegistry, type EpicFrontmatter as EpicData, type PhaseFrontmatter as PhaseData, type TaskFrontmatter as TaskData, type WorkbaseRegistry as WorkbaseRegistryData, type WorkbaseRegistration, } from "../workbase/schemas" import { validateWorktreeCommand } from "../workbase/worktree-command" import { validatePostCheckoutCommand } from "../workbase/checkout-command" import { validateBranchNameCommand } from "../workbase/branch-name-template" import { validateAgents } from "../workbase/agent-command" import { findDependencyCycles } from "../workbase/dependency-graph" import { validateDelivery } from "../workbase/delivery-command" import { documentRevision } from "../workbase/document-revision" import { documentLoadConcurrency } from "../workbase/document-loading" class WorkbaseNotFoundError extends Data.TaggedError("WorkbaseNotFoundError")<{ readonly message: string }> {} class WorkbaseConfigError extends Data.TaggedError("WorkbaseConfigError")<{ readonly message: string readonly path: string readonly cause?: unknown }> {} class WorkbaseRegistryError extends Data.TaggedError("WorkbaseRegistryError")<{ readonly message: string readonly path: string readonly cause?: unknown }> {} interface ValidationIssue { readonly path: string readonly message: string } export interface ValidationReport { readonly root: string readonly issues: readonly ValidationIssue[] readonly epicCount: number readonly taskCount: number readonly phaseCount: number readonly valid: boolean readonly documents?: ValidationDocuments } interface DocumentRecord { readonly id: string readonly path: string readonly content: string readonly revision: string readonly data: T } interface ValidationDocuments { readonly epics: readonly DocumentRecord[] readonly tasks: readonly DocumentRecord[] readonly phasesByTask: ReadonlyMap< string, readonly DocumentRecord[] > } type DecodeResult = | { readonly success: true; readonly value: T } | { readonly success: false; readonly error: string } const decode = >( schema: S, input: unknown, ): DecodeResult => { const result = Schema.decodeUnknownResult(schema, { errors: "all", onExcessProperty: "error", })(input) return Result.isFailure(result) ? { success: false, error: result.failure.message } : { success: true, value: result.success } } const registryPath = (configDirectory?: string) => join( configDirectory || process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "agency", "workbases.json", ) export const globalConfigPath = (configDirectory?: string) => join( configDirectory || process.env.XDG_CONFIG_HOME || join(homedir(), ".config"), "agency", "agency.json", ) const registrationId = (path: string) => `wb-${new Bun.CryptoHasher("sha256").update(path).digest("hex").slice(0, 12)}` const emptyRegistry: WorkbaseRegistryData = { version: 2, workbases: [] } const readRegistry = (configDirectory?: string) => Effect.gen(function* () { const fs = yield* FileSystemService const path = registryPath(configDirectory) if (!(yield* fs.exists(path))) { return { path, registry: emptyRegistry } } const content = yield* fs.readFile(path) let input: unknown try { input = JSON.parse(content) } catch (cause) { return yield* new WorkbaseRegistryError({ path, message: `Invalid JSON in workbase registry ${path}`, cause, }) } const decoded = decode(WorkbaseRegistry, input) if (decoded.success) return { path, registry: decoded.value } const legacy = decode(LegacyWorkbaseRegistry, input) if (!legacy.success) { return yield* new WorkbaseRegistryError({ path, message: `Invalid workbase registry in ${path}:\n${decoded.error}`, }) } const registry: WorkbaseRegistryData = { version: 2 as const, workbases: legacy.value.workbases.map((workbasePath) => ({ id: registrationId(workbasePath), path: workbasePath, })), } return { path, registry } }) const writeRegistry = ( path: string, registry: { readonly version: 2 readonly workbases: readonly WorkbaseRegistration[] readonly defaultId?: string }, ) => Effect.gen(function* () { const fs = yield* FileSystemService yield* fs.createDirectory(dirname(path)) yield* fs.writeJSON(path, registry) }) const findRegistration = ( selector: string, configDirectory?: string, basePath: string = process.cwd(), ) => Effect.gen(function* () { const fs = yield* FileSystemService const { path, registry } = yield* readRegistry(configDirectory) const candidatePath = resolve(basePath, selector) const canonicalCandidate = (yield* fs.exists(candidatePath)) ? yield* fs.realPath(candidatePath) : candidatePath const entry = registry.workbases.find((item) => item.id === selector) ?? registry.workbases.find((item) => item.name === selector) ?? registry.workbases.find((item) => item.path === canonicalCandidate) if (!entry) { return yield* new WorkbaseRegistryError({ path, message: `Unknown workbase selector '${selector}'`, }) } return { path, registry, entry } }) export class WorkbaseService extends Context.Service()( "WorkbaseService", { make: Effect.sync(() => ({ loadGlobalConfig: (configDirectory?: string) => Effect.gen(function* () { const fs = yield* FileSystemService const path = globalConfigPath(configDirectory) if (!(yield* fs.exists(path))) return {} const content = yield* fs.readFile(path) let input: unknown try { input = JSON.parse(content) } catch (cause) { return yield* new WorkbaseConfigError({ path, message: `Invalid JSON in global Agency configuration ${path}`, cause, }) } const decoded = decode(GlobalConfig, input) if (!decoded.success) { return yield* new WorkbaseConfigError({ path, message: `Invalid global Agency configuration in ${path}:\n${decoded.error}`, }) } return decoded.value }), initialize: (path: string = process.cwd()) => Effect.gen(function* () { const fs = yield* FileSystemService const root = resolve(path) const configPath = join(root, "agency.json") if (yield* fs.exists(configPath)) { return yield* new WorkbaseConfigError({ path: configPath, message: `Agency configuration already exists: ${configPath}`, }) } yield* fs.createDirectory(root) yield* Effect.all( [ fs.writeJSON(configPath, { version: 2, vcs: "git", }), ...["repos", "epics", "tasks"].map((directory) => fs.createDirectory(join(root, directory)), ), ], { concurrency: "unbounded" }, ) const ignorePath = join(root, ".gitignore") const requiredPatterns = [ "/repos/", "/tasks/*/code/", "/tasks/*/phases/*/code/", ] const existing = (yield* fs.exists(ignorePath)) ? yield* fs.readFile(ignorePath) : "" const existingLines = new Set(existing.split(/\r?\n/)) const missing = requiredPatterns.filter( (pattern) => !existingLines.has(pattern), ) if (missing.length > 0) { const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : "" yield* fs.writeFile( ignorePath, `${existing}${prefix}${missing.join("\n")}\n`, ) } return root }), discover: (startPath: string = process.cwd()) => Effect.gen(function* () { const fs = yield* FileSystemService let current = resolve(startPath) if (!(yield* fs.isDirectory(current))) { current = dirname(current) } while (true) { const configPath = join(current, "agency.json") const content = yield* Effect.option(fs.readFile(configPath)) if (content._tag === "Some") { let input: unknown try { input = JSON.parse(content.value) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid JSON in ${configPath}`, cause, }) } if ( typeof input === "object" && input !== null && "version" in input && input.version === 2 ) { const decoded = decode(WorkbaseConfig, input) if (!decoded.success) { return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`, }) } for (const setting of [ "worktreeCreateCommand", "worktreeRemoveCommand", ] as const) { const command = decoded.value[setting] if (!command) continue try { validateWorktreeCommand(setting, command) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: cause instanceof Error ? cause.message : `Invalid ${setting}`, }) } } if (decoded.value.branchNameCommand) { try { validateBranchNameCommand(decoded.value.branchNameCommand) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: cause instanceof Error ? cause.message : "Invalid branchNameCommand", }) } } for (const [alias, repository] of Object.entries( decoded.value.repositories ?? {}, )) { if (!repository.postCheckoutCommand) continue try { validatePostCheckoutCommand(repository.postCheckoutCommand) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: `Repository '${alias}': ${ cause instanceof Error ? cause.message : "Invalid postCheckoutCommand" }`, }) } } try { validateAgents(decoded.value.agents) validateDelivery(decoded.value.delivery) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: cause instanceof Error ? cause.message : "Invalid agent configuration", }) } return current } } const parent = dirname(current) if (parent === current) { return yield* new WorkbaseNotFoundError({ message: `No Agency workbase found from ${resolve(startPath)}`, }) } current = parent } }), loadConfig: (startPath: string = process.cwd()) => Effect.gen(function* () { const service = yield* WorkbaseService const fs = yield* FileSystemService const root = yield* service.discover(startPath) const configPath = join(root, "agency.json") const content = yield* fs.readFile(configPath) let input: unknown try { input = JSON.parse(content) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid JSON in ${configPath}`, cause, }) } const decoded = decode(WorkbaseConfig, input) if (!decoded.success) { return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid workbase configuration in ${configPath}:\n${decoded.error}`, }) } return { root, config: decoded.value } }), setAutoArchive: (enabled: boolean, startPath: string = process.cwd()) => Effect.gen(function* () { const service = yield* WorkbaseService const fs = yield* FileSystemService const { root, config } = yield* service.loadConfig(startPath) const updated = { ...config, autoArchive: enabled } yield* fs.writeFile( join(root, "agency.json"), `${JSON.stringify(updated, null, 2)}\n`, ) return { root, autoArchive: enabled } }), /** * Point the workbase triage prompt at a file relative to its root. * Only `triage.promptFile` changes, keeping key order and indentation; * the write is atomic and refused if agency.json no longer matches * `expected`, the text the caller based its decision on. */ setTriagePromptFile: ( root: string, promptFile: string, expected: string, ) => Effect.gen(function* () { const fs = yield* FileSystemService const configPath = join(root, "agency.json") const raw = yield* fs.readFile(configPath) if (raw !== expected) return yield* new WorkbaseConfigError({ path: configPath, message: `${configPath} changed since it was read; triage.promptFile was not set`, }) let parsed: unknown try { parsed = JSON.parse(raw) } catch (cause) { return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid JSON in ${configPath}`, cause, }) } if ( typeof parsed !== "object" || parsed === null || Array.isArray(parsed) ) return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid workbase configuration in ${configPath}`, }) const config = parsed as Record const triage = config.triage if ( typeof triage === "object" && triage !== null && !Array.isArray(triage) ) (triage as Record).promptFile = promptFile else config.triage = { promptFile } const indent = raw.match(/^([ \t]+)"/m)?.[1] ?? " " yield* fs.writeFileAtomic( configPath, `${JSON.stringify(config, null, indent)}${raw.endsWith("\n") ? "\n" : ""}`, ) }), repositoryAliases: (startPath: string = process.cwd()) => Effect.gen(function* () { const service = yield* WorkbaseService const fs = yield* FileSystemService const { root, config } = yield* service.loadConfig(startPath) const aliases = new Set(Object.keys(config.repositories ?? {})) const reposPath = join(root, "repos") if (yield* fs.isDirectory(reposPath)) { for (const entry of yield* fs.readDirectory(reposPath)) { if (!entry.name.startsWith(".agency-")) aliases.add(entry.name) } } return [...aliases].sort() }), hasRepositoryAlias: (alias: string, startPath: string = process.cwd()) => Effect.gen(function* () { const service = yield* WorkbaseService const aliases = yield* service.repositoryAliases(startPath) return aliases.includes(alias) }), register: (startPath: string, configDirectory?: string, name?: string) => Effect.gen(function* () { const service = yield* WorkbaseService const fs = yield* FileSystemService const discovered = yield* service.discover(startPath) const root = yield* fs.realPath(discovered) const { path, registry } = yield* readRegistry(configDirectory) if (name !== undefined) { const decodedName = decode(EntityId, name) if (!decodedName.success) { return yield* new WorkbaseRegistryError({ path, message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`, }) } } const named = name ? registry.workbases.find( (entry) => (entry.name === name && entry.path !== root) || entry.id === name, ) : undefined if (named) { return yield* new WorkbaseRegistryError({ path, message: `Workbase name '${name}' is already registered for ${named.path}`, }) } const existing = registry.workbases.find( (entry) => entry.path === root, ) const registration = { id: existing?.id ?? registrationId(root), ...(name ? { name } : existing?.name ? { name: existing.name } : {}), path: root, } const idNameCollision = registry.workbases.find( (entry) => entry.name === registration.id && entry.path !== root, ) if (idNameCollision) { return yield* new WorkbaseRegistryError({ path, message: `Workbase ID '${registration.id}' conflicts with the registered name for ${idNameCollision.path}`, }) } const workbases = existing ? registry.workbases.map((entry) => entry.id === existing.id ? registration : entry, ) : [...registry.workbases, registration] yield* writeRegistry(path, { ...registry, workbases }) return registration }), listRegistered: (configDirectory?: string) => readRegistry(configDirectory).pipe( Effect.map(({ registry }) => registry.workbases.map((entry) => entry.path), ), ), listRegistrations: (configDirectory?: string) => readRegistry(configDirectory).pipe( Effect.map(({ registry }) => ({ workbases: registry.workbases, defaultId: registry.defaultId, })), ), resolveRegistered: ( selector: string, configDirectory?: string, basePath: string = process.cwd(), ) => Effect.gen(function* () { const service = yield* WorkbaseService const fs = yield* FileSystemService const { path, registry } = yield* readRegistry(configDirectory) const candidatePath = resolve(basePath, selector) const direct = registry.workbases.find((entry) => entry.id === selector) ?? registry.workbases.find((entry) => entry.name === selector) ?? registry.workbases.find((entry) => entry.path === candidatePath) if (direct) return direct.path if (yield* fs.exists(candidatePath)) return yield* service.discover(candidatePath) return yield* new WorkbaseRegistryError({ path, message: `Unknown workbase selector '${selector}'`, }) }), removeRegistered: ( selector: string, configDirectory?: string, basePath: string = process.cwd(), ) => Effect.gen(function* () { const { path, registry, entry } = yield* findRegistration( selector, configDirectory, basePath, ) const workbases = registry.workbases.filter( (item) => item.id !== entry.id, ) const next = { version: 2 as const, workbases, ...(registry.defaultId && registry.defaultId !== entry.id ? { defaultId: registry.defaultId } : {}), } yield* writeRegistry(path, next) return entry }), showRegistered: ( selector: string, configDirectory?: string, basePath: string = process.cwd(), ) => findRegistration(selector, configDirectory, basePath).pipe( Effect.map(({ entry }) => entry), ), nameRegistered: ( selector: string, name: string | null, configDirectory?: string, basePath: string = process.cwd(), ) => Effect.gen(function* () { const { path, registry, entry } = yield* findRegistration( selector, configDirectory, basePath, ) if (name !== null) { const decodedName = decode(EntityId, name) if (!decodedName.success) { return yield* new WorkbaseRegistryError({ path, message: `Invalid workbase name '${name}': names must contain only letters, numbers, dots, underscores, and hyphens`, }) } const collision = registry.workbases.find( (item) => item.id !== entry.id && (item.id === name || item.name === name), ) if (collision) { return yield* new WorkbaseRegistryError({ path, message: `Workbase name '${name}' is already registered for ${collision.path}`, }) } } const updated = { id: entry.id, ...(name === null ? {} : { name }), path: entry.path, } yield* writeRegistry(path, { ...registry, workbases: registry.workbases.map((item) => item.id === entry.id ? updated : item, ), }) return updated }), pruneRegistered: (configDirectory?: string) => Effect.gen(function* () { const fs = yield* FileSystemService const { path, registry } = yield* readRegistry(configDirectory) const kept: WorkbaseRegistration[] = [] const removed: WorkbaseRegistration[] = [] for (const entry of registry.workbases) { if (yield* fs.exists(join(entry.path, "agency.json"))) kept.push(entry) else removed.push(entry) } const defaultId = kept.some( (entry) => entry.id === registry.defaultId, ) ? registry.defaultId : undefined yield* writeRegistry(path, { version: 2, workbases: kept, ...(defaultId ? { defaultId } : {}), }) return removed }), setDefault: ( selector: string | null, configDirectory?: string, basePath: string = process.cwd(), ) => Effect.gen(function* () { const { path, registry } = yield* readRegistry(configDirectory) if (selector === null) { yield* writeRegistry(path, { version: 2, workbases: registry.workbases, }) return null } const { entry } = yield* findRegistration( selector, configDirectory, basePath, ) yield* writeRegistry(path, { version: 2, workbases: registry.workbases, defaultId: entry.id, }) return entry }), getDefault: (configDirectory?: string) => readRegistry(configDirectory).pipe( Effect.map(({ registry }) => registry.workbases.find((entry) => entry.id === registry.defaultId), ), ), validate: ( startPath: string = process.cwd(), options: { readonly includeDocuments?: boolean } = {}, ) => Effect.gen(function* () { const service = yield* WorkbaseService const fs = yield* FileSystemService const root = yield* service.discover(startPath) const configPath = join(root, "agency.json") const configInput = JSON.parse( yield* fs.readFile(configPath), ) as unknown const configResult = decode(WorkbaseConfig, configInput) if (!configResult.success) { return yield* new WorkbaseConfigError({ path: configPath, message: `Invalid workbase configuration in ${configPath}:\n${configResult.error}`, }) } const config = configResult.value const issues: ValidationIssue[] = [] const epics = new Map>() const tasks = new Map>() const phases = new Map>() const phaseIdsByTask = new Map>() const issue = (path: string, message: string) => { issues.push({ path: relative(root, path) || ".", message }) } const readDirectories = (path: string) => fs.readDirectory(path).pipe( Effect.map((entries) => entries .filter((entry) => entry.isDirectory) .map((entry) => entry.name) .sort(), ), Effect.catchTag("FileNotFoundError", () => Effect.succeed([])), ) const aliases = new Set(Object.keys(config.repositories ?? {})) const reposPath = join(root, "repos") if (yield* fs.isDirectory(reposPath)) { for (const entry of yield* fs.readDirectory(reposPath)) { if (!entry.name.startsWith(".agency-")) aliases.add(entry.name) } } const readDocument = >( path: string, schema: S, ) => Effect.gen(function* () { const content = yield* fs .readFile(path) .pipe( Effect.catchTag("FileNotFoundError", () => Effect.succeed(null), ), ) if (content === null) { issue(path, "Required document is missing") return null } const documentContent = content const parsed = yield* Effect.result( parseFrontmatter(documentContent, path), ) if (Result.isFailure(parsed)) { issue(path, parsed.failure.message) return null } const decoded = decode(schema, parsed.success.data) if (!decoded.success) { issue(path, decoded.error) return null } return { content: documentContent, revision: documentRevision(documentContent), data: decoded.value, } }) const epicIds = yield* readDirectories(join(root, "epics")) const epicDocuments = yield* Effect.all( epicIds.map((id) => Effect.gen(function* () { const path = join(root, "epics", id, "EPIC.md") const document = yield* readDocument(path, EpicFrontmatter) return document ? { id, path, ...document } : null }), ), { concurrency: documentLoadConcurrency }, ) for (const document of epicDocuments) { if (document) epics.set(document.id, document) } const taskIds = yield* readDirectories(join(root, "tasks")) const taskDocuments = yield* Effect.all( taskIds.map((id) => Effect.gen(function* () { const taskPath = join(root, "tasks", id) const path = join(taskPath, "TASK.md") const document = yield* readDocument(path, TaskFrontmatter) const phaseIds = yield* readDirectories( join(taskPath, "phases"), ) if (phaseIds.length > 0) { phaseIdsByTask.set(id, new Set(phaseIds)) } const taskPhases = yield* Effect.all( phaseIds.map((phaseId) => Effect.gen(function* () { const phasePath = join( taskPath, "phases", phaseId, "PHASE.md", ) const phase = yield* readDocument( phasePath, PhaseFrontmatter, ) return phase ? { id: phaseId, taskId: id, path: phasePath, ...phase, } : null }), ), { concurrency: documentLoadConcurrency }, ) return { id, task: document ? { id, path, ...document } : null, phases: taskPhases, } }), ), { concurrency: documentLoadConcurrency }, ) for (const documents of taskDocuments) { if (documents.task) tasks.set(documents.id, documents.task) for (const phase of documents.phases) { if (phase) phases.set(`${documents.id}/${phase.id}`, phase) } } const validateRepositories = ( record: DocumentRecord, ) => { const data = record.data const writable = "repo" in data ? data.repo : undefined const references = "repos" in data ? (data.repos ?? []) : [] const referenceAliases = references.map( (reference) => reference.repo, ) if (writable && referenceAliases.includes(writable)) { issue( record.path, `Repository '${writable}' cannot also be a reference`, ) } const reviewAlias = "review" in data ? data.review.repo : undefined const all = [writable, reviewAlias, ...referenceAliases].filter( (alias): alias is string => alias !== undefined, ) for (const alias of new Set(all)) { if (!aliases.has(alias)) { issue(record.path, `Unknown repository alias '${alias}'`) } } if (new Set(referenceAliases).size !== referenceAliases.length) { issue(record.path, "Repository references must be unique") } } const branchOwners = new Map< string, DocumentRecord >() const validateBranchOwnership = ( record: DocumentRecord, ) => { if (!("repo" in record.data)) return const key = `${record.data.repo}\u0000${record.data.branch}` const owner = branchOwners.get(key) if (owner) { issue( record.path, `Writable branch '${record.data.branch}' for repository '${record.data.repo}' is also owned by ${relative(root, owner.path)}`, ) } else { branchOwners.set(key, record) } } const validateCompletion = ( record: DocumentRecord, ) => { if (!("repo" in record.data) || !record.data.completion) return if (record.data.status !== "done") { issue(record.path, "Non-PR completion requires status 'done'") } if (record.data.pr !== null) { issue( record.path, "Non-PR completion cannot have a recorded pull request", ) } } for (const epic of epics.values()) { validateRepositories(epic) const ids = new Set(epic.data.tasks.map((task) => task.id)) if (ids.size !== epic.data.tasks.length) { issue(epic.path, "Epic task IDs must be unique") } for (const task of epic.data.tasks) { const child = tasks.get(task.id) if (!child) { issue(epic.path, `Unknown child task '${task.id}'`) } else if (child.data.epic !== epic.id) { issue( child.path, `Task must reference parent epic '${epic.id}'`, ) } for (const dependency of task.dependsOn ?? []) { if (!ids.has(dependency)) { issue(epic.path, `Unknown task dependency '${dependency}'`) } } } for (const cycle of findDependencyCycles(epic.data.tasks)) { issue(epic.path, `Task dependency cycle includes '${cycle}'`) } } for (const task of tasks.values()) { validateRepositories(task) validateBranchOwnership(task) validateCompletion(task) if (task.data.handoff && task.data.purpose !== "implementation") { issue( task.path, "Task handoff provenance requires purpose 'implementation'", ) } if (task.data.purpose === "implementation" && !task.data.handoff) { issue( task.path, "Implementation-purpose tasks require handoff provenance", ) } if ( task.data.handoff?.source.kind === "task" && task.data.handoff.source.taskId === task.id ) { issue( task.path, "Task handoff provenance cannot reference itself", ) } if ( task.data.handoff?.source.kind === "phase" && task.data.handoff.source.taskId === task.id ) { issue( task.path, "Task handoff provenance cannot reference one of its own phases", ) } if (task.data.epic) { const parent = epics.get(task.data.epic) if (!parent) { issue(task.path, `Unknown parent epic '${task.data.epic}'`) } else if ( !parent.data.tasks.some((child) => child.id === task.id) ) { issue(parent.path, `Epic does not list child task '${task.id}'`) } } const actualPhaseIds = phaseIdsByTask.get(task.id) ?? new Set() if ("phases" in task.data) { const declaredIds = new Set( task.data.phases.map((phase) => phase.id), ) if (declaredIds.size !== task.data.phases.length) { issue(task.path, "Task phase IDs must be unique") } for (const phase of task.data.phases) { if (!phases.has(`${task.id}/${phase.id}`)) { issue(task.path, `Missing phase '${phase.id}'`) } for (const dependency of phase.dependsOn ?? []) { if (!declaredIds.has(dependency)) { issue(task.path, `Unknown phase dependency '${dependency}'`) } } } for (const phaseId of actualPhaseIds) { if (!declaredIds.has(phaseId)) { issue(task.path, `Unlisted phase '${phaseId}'`) } } for (const cycle of findDependencyCycles(task.data.phases)) { issue(task.path, `Phase dependency cycle includes '${cycle}'`) } } else if (actualPhaseIds.size > 0) { issue( task.path, "Single-phase task cannot contain phase directories", ) } } for (const phase of phases.values()) { validateRepositories(phase) validateBranchOwnership(phase) validateCompletion(phase) } issues.sort((a, b) => a.path === b.path ? a.message.localeCompare(b.message) : a.path.localeCompare(b.path), ) const phasesByTask = new Map[]>() for (const [key, phase] of phases) { const taskId = key.slice(0, key.indexOf("/")) const records = phasesByTask.get(taskId) ?? [] records.push(phase) phasesByTask.set(taskId, records) } return { root, issues, epicCount: epics.size, taskCount: tasks.size, phaseCount: phases.size, valid: issues.length === 0, ...(options.includeDocuments ? { documents: { epics: [...epics.values()], tasks: [...tasks.values()], phasesByTask, }, } : {}), } satisfies ValidationReport }), })), }, ) { static readonly layer = Layer.effect(this, this.make) }