diff --git a/.taskmaster/tasks/tasks.json b/.taskmaster/tasks/tasks.json index 0ec808b..5307eda 100644 --- a/.taskmaster/tasks/tasks.json +++ b/.taskmaster/tasks/tasks.json @@ -2669,9 +2669,9 @@ "dependencies": [ "117" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.155Z" }, { "id": "124", @@ -2683,9 +2683,9 @@ "dependencies": [ "123" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.165Z" }, { "id": "125", @@ -2697,9 +2697,9 @@ "dependencies": [ "117" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.172Z" }, { "id": "126", @@ -2712,9 +2712,9 @@ "124", "125" ], - "status": "pending", + "status": "done", "subtasks": [], - "updatedAt": "2026-08-08T08:55:58.627Z" + "updatedAt": "2026-08-08T10:05:28.178Z" }, { "id": "127", @@ -3251,9 +3251,9 @@ ], "metadata": { "version": "1.0.0", - "lastModified": "2026-08-08T09:36:21.139Z", + "lastModified": "2026-08-08T10:05:28.178Z", "taskCount": 162, - "completedCount": 107, + "completedCount": 111, "tags": [ "master" ] diff --git a/src/mcpd/src/main.ts b/src/mcpd/src/main.ts index fa6fc33..91e4955 100644 --- a/src/mcpd/src/main.ts +++ b/src/mcpd/src/main.ts @@ -106,6 +106,7 @@ import { import { registerPromptRoutes } from './routes/prompts.js'; import { registerRevisionRoutes } from './routes/revisions.js'; import { registerProposalRoutes } from './routes/proposals.js'; +import { registerEpicRoutes } from './routes/epics.js'; import { registerSkillRoutes } from './routes/skills.js'; import { registerGitBackupRoutes } from './routes/git-backup.js'; import { PromptService } from './services/prompt.service.js'; @@ -113,7 +114,9 @@ import { ResourceRevisionRepository } from './repositories/resource-revision.rep import { ResourceProposalRepository } from './repositories/resource-proposal.repository.js'; import { ResourceRevisionService } from './services/resource-revision.service.js'; import { ResourceProposalService } from './services/resource-proposal.service.js'; +import { EpicRepository } from './repositories/epic.repository.js'; import { SkillRepository } from './repositories/skill.repository.js'; +import { EpicService } from './services/epic.service.js'; import { SkillService } from './services/skill.service.js'; import { GitBackupService } from './services/backup/git-backup.service.js'; import type { BackupKind } from './services/backup/yaml-serializer.js'; @@ -221,6 +224,8 @@ function mapUrlToPermission(method: string, url: string): PermissionCheck { // Personalities inherit the agent's RBAC: managing a personality // requires view/edit/create/delete on the `agents` resource. 'personalities': 'agents', + // ── Agentic teams ── + 'epics': 'epics', }; const resource = resourceMap[segment]; @@ -427,6 +432,9 @@ async function main(): Promise { const userRepo = new UserRepository(prisma); const groupRepo = new GroupRepository(prisma); const mcpTokenRepo = new McpTokenRepository(prisma); + // Declared here (not with the other agentic-teams wiring below) because + // nameResolvers needs it for CUID→name RBAC resolution. + const epicRepo = new EpicRepository(prisma); // SecretBackend bootstrap: ensure a `plaintext` default row exists and any // pre-existing `Secret` rows are pointed at it. Idempotent per run. @@ -443,6 +451,7 @@ async function main(): Promise { mcptokens: mcpTokenRepo, llms: llmRepo, agents: agentRepo, + epics: epicRepo, // Resolve user CUID → email so name-scoped `edit:users:` bindings // match on PUT /api/v1/users/:id/password (admin reset). users: { findById: async (id: string) => { @@ -545,6 +554,11 @@ async function main(): Promise { const skillService = new SkillService(skillRepo, projectRepo, agentRepo); skillService.setRevisionService(resourceRevisionService); skillService.setProposalService(resourceProposalService); + + // Agentic teams: the Epic is the versioned vision every task walks up to. + // Revision-tracked so scope creep is as visible as under-delivery. + const epicService = new EpicService(epicRepo, projectRepo); + epicService.setRevisionService(resourceRevisionService); const personalityRepo = new PersonalityRepository(prisma); const personalityService = new PersonalityService(personalityRepo, agentRepo, promptRepo); const agentService = new AgentService(agentRepo, llmService, projectService, personalityRepo); @@ -753,6 +767,7 @@ async function main(): Promise { registerRevisionRoutes(app, { revisionService: resourceRevisionService, promptService }); registerProposalRoutes(app, { proposalService: resourceProposalService, projectRepo, agentRepo }); registerSkillRoutes(app, skillService, projectRepo, agentRepo); + registerEpicRoutes(app, epicService, projectRepo); // ── Git-based backup ── const gitBackup = new GitBackupService(prisma); diff --git a/src/mcpd/src/repositories/epic.repository.ts b/src/mcpd/src/repositories/epic.repository.ts new file mode 100644 index 0000000..b756550 --- /dev/null +++ b/src/mcpd/src/repositories/epic.repository.ts @@ -0,0 +1,91 @@ +import type { PrismaClient, Prisma, Epic, EpicStatus } from '@prisma/client'; + +/** + * Epic repository. Unlike Prompt/Skill, an Epic is always project-scoped — + * there is no global epic — so the nullable-FK compound-key workaround does not + * apply here and `name_projectId` can be used directly. + */ + +export interface EpicCreateInput { + name: string; + content: string; + description?: string; + doneCriteria?: Prisma.InputJsonValue; + acceptedTradeoffs?: Prisma.InputJsonValue; + projectId: string; + ownerId: string; + semver?: string; +} + +export interface EpicUpdateInput { + content?: string; + description?: string; + doneCriteria?: Prisma.InputJsonValue; + acceptedTradeoffs?: Prisma.InputJsonValue; + status?: EpicStatus; + semver?: string; + currentRevisionId?: string | null; +} + +export interface IEpicRepository { + findAll(projectId?: string): Promise; + findById(id: string): Promise; + findByName(name: string): Promise; + findByNameAndProject(name: string, projectId: string): Promise; + create(data: EpicCreateInput): Promise; + update(id: string, data: EpicUpdateInput): Promise; + delete(id: string): Promise; +} + +const EPIC_INCLUDE = { project: { select: { name: true } } } as const; + +export class EpicRepository implements IEpicRepository { + constructor(private readonly prisma: PrismaClient) {} + + async findAll(projectId?: string): Promise { + return this.prisma.epic.findMany({ + ...(projectId !== undefined ? { where: { projectId } } : {}), + include: EPIC_INCLUDE, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + return this.prisma.epic.findUnique({ where: { id }, include: EPIC_INCLUDE }); + } + + /** + * Resolve by bare name. Epic names are unique per project, not globally, so + * this returns the first match by name — used by the RBAC CUID→name resolver, + * which only needs *a* name for the binding comparison. Callers that care + * about a specific project must use findByNameAndProject. + */ + async findByName(name: string): Promise { + return this.prisma.epic.findFirst({ where: { name }, include: EPIC_INCLUDE }); + } + + async findByNameAndProject(name: string, projectId: string): Promise { + return this.prisma.epic.findUnique({ + where: { name_projectId: { name, projectId } }, + include: EPIC_INCLUDE, + }); + } + + async create(data: EpicCreateInput): Promise { + return this.prisma.epic.create({ data, include: EPIC_INCLUDE }); + } + + async update(id: string, data: EpicUpdateInput): Promise { + // Optimistic-concurrency counter is bumped in the repository, unconditionally + // and on every write — same convention as AgentRepository. + return this.prisma.epic.update({ + where: { id }, + data: { ...data, version: { increment: 1 } }, + include: EPIC_INCLUDE, + }); + } + + async delete(id: string): Promise { + await this.prisma.epic.delete({ where: { id } }); + } +} diff --git a/src/mcpd/src/repositories/resource-revision.repository.ts b/src/mcpd/src/repositories/resource-revision.repository.ts index 2731423..9e0b994 100644 --- a/src/mcpd/src/repositories/resource-revision.repository.ts +++ b/src/mcpd/src/repositories/resource-revision.repository.ts @@ -12,7 +12,7 @@ import type { PrismaClient, Prisma, ResourceRevision } from '@prisma/client'; * service layer to keep them in sync with real IDs. */ -export type ResourceType = 'prompt' | 'skill'; +export type ResourceType = 'prompt' | 'skill' | 'epic'; export interface CreateRevisionInput { resourceType: ResourceType; diff --git a/src/mcpd/src/routes/epics.ts b/src/mcpd/src/routes/epics.ts new file mode 100644 index 0000000..0ecf7b4 --- /dev/null +++ b/src/mcpd/src/routes/epics.ts @@ -0,0 +1,107 @@ +import type { Epic } from '@prisma/client'; +import type { FastifyInstance } from 'fastify'; + +import type { IProjectRepository } from '../repositories/project.repository.js'; +import type { EpicService } from '../services/epic.service.js'; +import { EpicService as EpicSvc } from '../services/epic.service.js'; + +/** Attach computed completeness so clients never re-derive the rule. */ +function withCompleteness(epic: Epic): Epic & { completeness: ReturnType } { + return { ...epic, completeness: EpicSvc.completeness(epic) }; +} + +export function registerEpicRoutes( + app: FastifyInstance, + service: EpicService, + projectRepo: IProjectRepository, +): void { + // ── List ── + + app.get<{ Querystring: { project?: string; projectId?: string } }>( + '/api/v1/epics', + async (request) => { + const { project, projectId } = request.query; + let epics: Epic[]; + if (project !== undefined) { + const proj = await projectRepo.findByName(project); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${project}`), { statusCode: 404 }); + } + epics = await service.listEpics(proj.id); + } else if (projectId !== undefined) { + epics = await service.listEpics(projectId); + } else { + epics = await service.listEpics(); + } + return epics.map(withCompleteness); + }, + ); + + app.get<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => { + return withCompleteness(await service.getEpic(request.params.id)); + }); + + // ── Create / Update / Delete ── + + app.post('/api/v1/epics', async (request, reply) => { + const body = request.body as Record; + const resolved: Record = { ...body }; + + // Accept `project` by name, same as skills/prompts do. + if (typeof body['project'] === 'string') { + const proj = await projectRepo.findByName(body['project']); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${body['project']}`), { statusCode: 404 }); + } + resolved['projectId'] = proj.id; + delete resolved['project']; + } + + const ownerId = request.userId ?? 'system'; + const epic = await service.createEpic(resolved, ownerId); + reply.code(201); + return withCompleteness(epic); + }); + + app.put<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => { + return withCompleteness(await service.updateEpic(request.params.id, request.body)); + }); + + app.delete<{ Params: { id: string } }>('/api/v1/epics/:id', async (request, reply) => { + await service.deleteEpic(request.params.id); + reply.code(204); + }); + + // ── The honest-done gate ── + // Separate from PUT so the rule cannot be bypassed by a plain field write. + + app.post<{ Params: { id: string } }>('/api/v1/epics/:id/status', async (request) => { + return withCompleteness(await service.setStatus(request.params.id, request.body)); + }); + + app.post<{ Params: { id: string } }>('/api/v1/epics/:id/tradeoffs', async (request, reply) => { + const epic = await service.addTradeoff(request.params.id, request.body, request.userId); + reply.code(201); + return withCompleteness(epic); + }); + + app.post<{ Params: { id: string; criterionId: string }; Body: { met?: boolean } }>( + '/api/v1/epics/:id/criteria/:criterionId', + async (request) => { + const met = request.body?.met ?? true; + return withCompleteness( + await service.setCriterionMet(request.params.id, request.params.criterionId, met), + ); + }, + ); + + // ── Project-scoped view ── + + app.get<{ Params: { name: string } }>('/api/v1/projects/:name/epics', async (request) => { + const proj = await projectRepo.findByName(request.params.name); + if (proj === null) { + throw Object.assign(new Error(`Project not found: ${request.params.name}`), { statusCode: 404 }); + } + return (await service.listEpics(proj.id)).map(withCompleteness); + }); +} diff --git a/src/mcpd/src/services/epic.service.ts b/src/mcpd/src/services/epic.service.ts new file mode 100644 index 0000000..d948bb2 --- /dev/null +++ b/src/mcpd/src/services/epic.service.ts @@ -0,0 +1,279 @@ +import type { Epic, EpicStatus, Prisma } from '@prisma/client'; + +import type { IEpicRepository } from '../repositories/epic.repository.js'; +import type { IProjectRepository } from '../repositories/project.repository.js'; +import { + CreateEpicSchema, + UpdateEpicSchema, + SetEpicStatusSchema, + AddTradeoffSchema, + type AcceptedTradeoff, + type DoneCriterion, +} from '../validation/epic.schema.js'; +import { bumpSemver, type BumpKind } from '../utils/semver.js'; + +import { NotFoundError } from './mcp-server.service.js'; +import type { ResourceRevisionService } from './resource-revision.service.js'; + +export interface EpicCompleteness { + total: number; + met: number; + /** 0-100, rounded. 100 with zero criteria means "nothing claimed yet". */ + percent: number; + unmet: DoneCriterion[]; +} + +function badRequest(message: string): Error { + return Object.assign(new Error(message), { statusCode: 400 }); +} + +function readCriteria(epic: Epic): DoneCriterion[] { + return (epic.doneCriteria ?? []) as unknown as DoneCriterion[]; +} + +function readTradeoffs(epic: Epic): AcceptedTradeoff[] { + return (epic.acceptedTradeoffs ?? []) as unknown as AcceptedTradeoff[]; +} + +export class EpicService { + private revisionService: ResourceRevisionService | null = null; + + constructor( + private readonly epicRepo: IEpicRepository, + private readonly projectRepo: IProjectRepository, + ) {} + + setRevisionService(service: ResourceRevisionService): void { + this.revisionService = service; + } + + // ── Completeness ── + + /** + * How much of the vision is actually delivered. Both `outcome` and `quality` + * criteria count — grading only the mechanical half is precisely how you end + * up with every task done and no car. + */ + static completeness(epic: Epic): EpicCompleteness { + const criteria = readCriteria(epic); + const met = criteria.filter((c) => c.met).length; + const total = criteria.length; + return { + total, + met, + percent: total === 0 ? 0 : Math.round((met / total) * 100), + unmet: criteria.filter((c) => !c.met), + }; + } + + // ── CRUD ── + + async listEpics(projectId?: string): Promise { + return this.epicRepo.findAll(projectId); + } + + async getEpic(id: string): Promise { + const epic = await this.epicRepo.findById(id); + if (epic === null) throw new NotFoundError(`Epic not found: ${id}`); + return epic; + } + + async createEpic(input: unknown, ownerId: string): Promise { + const data = CreateEpicSchema.parse(input); + + const project = await this.projectRepo.findById(data.projectId); + if (project === null) throw new NotFoundError(`Project not found: ${data.projectId}`); + + const duplicate = await this.epicRepo.findByNameAndProject(data.name, data.projectId); + if (duplicate !== null) { + throw Object.assign( + new Error(`Epic '${data.name}' already exists in project '${project.name}'`), + { statusCode: 409 }, + ); + } + + this.assertCriteriaIdsUnique(data.doneCriteria); + + const epic = await this.epicRepo.create({ + name: data.name, + content: data.content, + projectId: data.projectId, + ownerId, + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.doneCriteria !== undefined + ? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue } + : {}), + ...(data.acceptedTradeoffs !== undefined + ? { acceptedTradeoffs: data.acceptedTradeoffs as unknown as Prisma.InputJsonValue } + : {}), + ...(data.semver !== undefined ? { semver: data.semver } : {}), + }); + + if (this.revisionService) { + this.recordRevision(epic, epic.semver, 'created').catch(() => {}); + } + return epic; + } + + async updateEpic(id: string, input: unknown): Promise { + const data = UpdateEpicSchema.parse(input); + if (data.semver !== undefined && data.bump !== undefined) { + throw badRequest('Pass semver or bump, not both'); + } + const existing = await this.getEpic(id); + this.assertCriteriaIdsUnique(data.doneCriteria); + + const contentChanged = + data.content !== undefined || data.description !== undefined || data.doneCriteria !== undefined; + + let newSemver = existing.semver; + if (data.semver !== undefined) { + newSemver = data.semver; + } else if (data.bump !== undefined) { + newSemver = bumpSemver(existing.semver, data.bump as BumpKind); + } else if (contentChanged) { + // Scope changes are versioned so creep is as visible as under-delivery. + newSemver = bumpSemver(existing.semver, 'patch'); + } + + const epic = await this.epicRepo.update(id, { + ...(data.content !== undefined ? { content: data.content } : {}), + ...(data.description !== undefined ? { description: data.description } : {}), + ...(data.doneCriteria !== undefined + ? { doneCriteria: data.doneCriteria as unknown as Prisma.InputJsonValue } + : {}), + ...(newSemver !== existing.semver ? { semver: newSemver } : {}), + }); + + if (this.revisionService && (contentChanged || data.bump !== undefined || data.semver !== undefined)) { + this.recordRevision(epic, newSemver, data.note ?? null).catch(() => {}); + } + return epic; + } + + async deleteEpic(id: string): Promise { + await this.getEpic(id); // 404 if missing + await this.epicRepo.delete(id); + } + + // ── The honest-done rule ── + + /** + * Move an Epic to a terminal status. + * + * An Epic is done ONLY when 100% of its criteria are met, OR the user + * explicitly accepted the gap. Anything else is a stick with wheels being + * called a car, so: + * + * - `delivered` requires every criterion met, and no recorded tradeoffs + * (if you accepted tradeoffs, the honest status names them). + * - `delivered_with_tradeoffs` requires at least one recorded tradeoff — + * otherwise it is just `delivered` wearing a disguise. + * - `cancelled` and `open` are always allowed; abandoning work needs no + * ceremony, and reopening is how a gap gets fixed. + */ + async setStatus(id: string, input: unknown): Promise { + const { status, note } = SetEpicStatusSchema.parse(input); + const epic = await this.getEpic(id); + + if (status === 'delivered') { + const { unmet, total, met } = EpicService.completeness(epic); + if (total === 0) { + throw badRequest( + 'Cannot mark an Epic delivered with no done-criteria — there is nothing to have delivered', + ); + } + if (unmet.length > 0) { + const names = unmet.slice(0, 5).map((c) => c.id).join(', '); + const more = unmet.length > 5 ? ` (+${String(unmet.length - 5)} more)` : ''; + throw badRequest( + `Cannot mark delivered: ${String(met)}/${String(total)} criteria met. ` + + `Unmet: ${names}${more}. Meet them, or record the accepted tradeoffs and use delivered_with_tradeoffs.`, + ); + } + if (readTradeoffs(epic).length > 0) { + throw badRequest( + 'This Epic has accepted tradeoffs recorded — use delivered_with_tradeoffs so "done" stays honest', + ); + } + } + + if (status === 'delivered_with_tradeoffs' && readTradeoffs(epic).length === 0) { + throw badRequest( + 'Cannot mark delivered_with_tradeoffs without recording at least one accepted tradeoff', + ); + } + + const updated = await this.epicRepo.update(id, { status: status as EpicStatus }); + + if (this.revisionService) { + this.recordRevision(updated, updated.semver, note ?? `status → ${status}`).catch(() => {}); + } + return updated; + } + + /** Append an accepted tradeoff. Append-only — the record is the audit trail. */ + async addTradeoff(id: string, input: unknown, who?: string): Promise { + const parsed = AddTradeoffSchema.parse(input); + const epic = await this.getEpic(id); + + const entry: AcceptedTradeoff = { + ...parsed, + ...(parsed.who === undefined && who !== undefined ? { who } : {}), + at: parsed.at ?? new Date().toISOString(), + }; + const next = [...readTradeoffs(epic), entry]; + + const updated = await this.epicRepo.update(id, { + acceptedTradeoffs: next as unknown as Prisma.InputJsonValue, + }); + + if (this.revisionService) { + this.recordRevision(updated, updated.semver, `accepted tradeoff: ${entry.what}`).catch(() => {}); + } + return updated; + } + + /** Flip one criterion's met flag without rewriting the whole array. */ + async setCriterionMet(id: string, criterionId: string, met: boolean): Promise { + const epic = await this.getEpic(id); + const criteria = readCriteria(epic); + const idx = criteria.findIndex((c) => c.id === criterionId); + if (idx === -1) throw new NotFoundError(`Done-criterion not found on epic: ${criterionId}`); + + const next = criteria.map((c, i) => (i === idx ? { ...c, met } : c)); + return this.epicRepo.update(id, { doneCriteria: next as unknown as Prisma.InputJsonValue }); + } + + // ── Internals ── + + private assertCriteriaIdsUnique(criteria?: DoneCriterion[]): void { + if (criteria === undefined) return; + const seen = new Set(); + for (const c of criteria) { + if (seen.has(c.id)) { + throw badRequest(`Duplicate done-criterion id: ${c.id}`); + } + seen.add(c.id); + } + } + + /** Best-effort revision write — same shape as PromptService / SkillService. */ + private async recordRevision(epic: Epic, semver: string, note: string | null): Promise { + if (this.revisionService === null) return; + const { revision } = await this.revisionService.record({ + resourceType: 'epic', + resourceId: epic.id, + semver, + body: { + content: epic.content, + description: epic.description, + doneCriteria: epic.doneCriteria, + acceptedTradeoffs: epic.acceptedTradeoffs, + status: epic.status, + }, + ...(note !== null ? { note } : {}), + }); + await this.epicRepo.update(epic.id, { currentRevisionId: revision.id }); + } +} diff --git a/src/mcpd/src/validation/epic.schema.ts b/src/mcpd/src/validation/epic.schema.ts new file mode 100644 index 0000000..7f6030b --- /dev/null +++ b/src/mcpd/src/validation/epic.schema.ts @@ -0,0 +1,67 @@ +import { z } from 'zod'; + +const SEMVER_RE = /^\d+\.\d+\.\d+$/; +const NAME_RE = /^[a-z0-9-]+$/; + +/** + * A single checkable outcome on the Epic. `kind` separates the two halves of + * the two-tier Definition of Done: + * + * - `outcome` — mechanically checkable ("the CLI can create a task"). + * - `quality` — the intent/feel attributes that decide whether the delivered + * whole is a car or a stick with wheels ("the roadmap reads at a glance"). + * + * Both count toward completeness. Dropping `quality` from the count is exactly + * the failure the Epic exists to prevent. + */ +export const DoneCriterionSchema = z.object({ + id: z.string().min(1).max(100), + text: z.string().min(1).max(2000), + kind: z.enum(['outcome', 'quality']).default('outcome'), + met: z.boolean().default(false), +}); + +/** + * A tradeoff the user explicitly accepted. Recorded rather than inferred so + * "done" stays honest and auditable: who de-scoped what, and why. + */ +export const AcceptedTradeoffSchema = z.object({ + what: z.string().min(1).max(2000), + why: z.string().min(1).max(2000), + who: z.string().min(1).max(200).optional(), + deScopedBy: z.string().min(1).max(200).optional(), + at: z.string().datetime().optional(), +}); + +export const CreateEpicSchema = z.object({ + name: z.string().min(1).max(100).regex(NAME_RE, 'Name must be lowercase alphanumeric with hyphens'), + content: z.string().min(1).max(200_000), + description: z.string().max(500).optional(), + doneCriteria: z.array(DoneCriterionSchema).max(200).optional(), + acceptedTradeoffs: z.array(AcceptedTradeoffSchema).max(200).optional(), + projectId: z.string().min(1), + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), +}); + +export const UpdateEpicSchema = z.object({ + content: z.string().min(1).max(200_000).optional(), + description: z.string().max(500).optional(), + doneCriteria: z.array(DoneCriterionSchema).max(200).optional(), + // Status is deliberately NOT settable here — reaching a terminal status goes + // through EpicService.setStatus, which enforces the honest-done rule. + semver: z.string().regex(SEMVER_RE, 'Semver must be MAJOR.MINOR.PATCH').optional(), + bump: z.enum(['major', 'minor', 'patch']).optional(), + note: z.string().max(500).optional(), +}); + +export const SetEpicStatusSchema = z.object({ + status: z.enum(['open', 'delivered', 'delivered_with_tradeoffs', 'cancelled']), + note: z.string().max(500).optional(), +}); + +export const AddTradeoffSchema = AcceptedTradeoffSchema; + +export type CreateEpicInput = z.infer; +export type UpdateEpicInput = z.infer; +export type DoneCriterion = z.infer; +export type AcceptedTradeoff = z.infer; diff --git a/src/mcpd/tests/epic-routes.test.ts b/src/mcpd/tests/epic-routes.test.ts new file mode 100644 index 0000000..1cc0c27 --- /dev/null +++ b/src/mcpd/tests/epic-routes.test.ts @@ -0,0 +1,222 @@ +import type { Epic, Project } from '@prisma/client'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { describe, it, expect, vi, afterEach } from 'vitest'; + +import { errorHandler } from '../src/middleware/error-handler.js'; +import type { IEpicRepository } from '../src/repositories/epic.repository.js'; +import type { IProjectRepository } from '../src/repositories/project.repository.js'; +import { registerEpicRoutes } from '../src/routes/epics.js'; +import { EpicService } from '../src/services/epic.service.js'; + +let app: FastifyInstance; + +function makeEpic(overrides: Partial = {}): Epic { + return { + id: 'epic-1', + name: 'the-car', + description: '', + content: 'build a car', + doneCriteria: [], + acceptedTradeoffs: [], + projectId: 'proj-1', + status: 'open', + semver: '0.1.0', + currentRevisionId: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Epic; +} + +const makeProject = (o: Partial = {}): Project => + ({ id: 'proj-1', name: 'mcpctl', ...o } as Project); + +const criterion = (id: string, met: boolean) => + ({ id, text: `criterion ${id}`, kind: 'outcome' as const, met }); + +async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promise<{ + app: FastifyInstance; + epicRepo: IEpicRepository & { update: ReturnType }; +}> { + let current = epic; + const update = vi.fn(async (_id: string, data: Record) => { + current = { ...(current as Epic), ...data } as Epic; + return current; + }); + const epicRepo = { + findAll: vi.fn(async () => (current ? [current] : [])), + findById: vi.fn(async () => current), + findByName: vi.fn(async () => current), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (data: Record) => makeEpic(data as Partial)), + update, + delete: vi.fn(async () => {}), + } as unknown as IEpicRepository & { update: ReturnType }; + + const projectRepo = { + findById: vi.fn(async () => makeProject()), + findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? makeProject() : null)), + } as unknown as IProjectRepository; + + const instance = Fastify(); + instance.setErrorHandler(errorHandler); + // Stand in for the auth middleware, which normally sets request.userId. + if (userId !== undefined) { + instance.addHook('onRequest', async (req) => { req.userId = userId; }); + } + registerEpicRoutes(instance, new EpicService(epicRepo, projectRepo), projectRepo); + await instance.ready(); + return { app: instance, epicRepo }; +} + +afterEach(async () => { + if (app) await app.close(); +}); + +describe('epic routes', () => { + it('attaches computed completeness to list and get', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }), + ); + app = built.app; + + const list = await app.inject({ method: 'GET', url: '/api/v1/epics' }); + expect(list.statusCode).toBe(200); + expect(list.json()[0].completeness).toMatchObject({ total: 2, met: 1, percent: 50 }); + + const one = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1' }); + expect(one.json().completeness.unmet).toHaveLength(1); + }); + + it('creates from a project name and stamps the caller as owner', async () => { + const built = await buildApp(null, 'user-42'); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics', + payload: { name: 'the-car', content: 'build a car', project: 'mcpctl' }, + }); + + expect(res.statusCode).toBe(201); + expect(built.epicRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ projectId: 'proj-1', ownerId: 'user-42' }), + ); + }); + + it('404s on an unknown project name', async () => { + const built = await buildApp(null); + app = built.app; + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics', + payload: { name: 'x', content: 'c', project: 'ghost' }, + }); + expect(res.statusCode).toBe(404); + }); + + it('400s a bad payload rather than 500ing', async () => { + const built = await buildApp(null); + app = built.app; + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics', + payload: { name: 'Not Kebab', content: 'c', project: 'mcpctl' }, + }); + expect(res.statusCode).toBe(400); + }); + + it('400s an unhonest delivered, and explains what is missing', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }), + ); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/status', + payload: { status: 'delivered' }, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error ?? res.json().message).toMatch(/1\/2 criteria met/); + }); + + it('accepts delivered once everything is met', async () => { + const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', true)] as never })); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/status', + payload: { status: 'delivered' }, + }); + expect(res.statusCode).toBe(200); + expect(res.json().status).toBe('delivered'); + expect(res.json().completeness.percent).toBe(100); + }); + + it('records a tradeoff and returns 201', async () => { + const built = await buildApp(makeEpic(), 'user-42'); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/tradeoffs', + payload: { what: 'no dark mode', why: 'out of budget' }, + }); + + expect(res.statusCode).toBe(201); + const written = built.epicRepo.update.mock.calls[0]?.[1] as { + acceptedTradeoffs: Array<{ what: string; who?: string }>; + }; + expect(written.acceptedTradeoffs[0]).toMatchObject({ what: 'no dark mode', who: 'user-42' }); + }); + + it('flips a single criterion, defaulting met to true', async () => { + const built = await buildApp( + makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }), + ); + app = built.app; + + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/criteria/b', + payload: {}, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().completeness).toMatchObject({ met: 1, total: 2 }); + }); + + it('404s an unknown criterion', async () => { + const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + app = built.app; + const res = await app.inject({ + method: 'POST', + url: '/api/v1/epics/epic-1/criteria/ghost', + payload: { met: true }, + }); + expect(res.statusCode).toBe(404); + }); + + it('deletes with 204', async () => { + const built = await buildApp(); + app = built.app; + const res = await app.inject({ method: 'DELETE', url: '/api/v1/epics/epic-1' }); + expect(res.statusCode).toBe(204); + }); + + it('serves the project-scoped view', async () => { + const built = await buildApp(); + app = built.app; + const res = await app.inject({ method: 'GET', url: '/api/v1/projects/mcpctl/epics' }); + expect(res.statusCode).toBe(200); + expect(res.json()).toHaveLength(1); + + const missing = await app.inject({ method: 'GET', url: '/api/v1/projects/ghost/epics' }); + expect(missing.statusCode).toBe(404); + }); +}); diff --git a/src/mcpd/tests/services/epic-service.test.ts b/src/mcpd/tests/services/epic-service.test.ts new file mode 100644 index 0000000..f13a7bb --- /dev/null +++ b/src/mcpd/tests/services/epic-service.test.ts @@ -0,0 +1,283 @@ +import type { Epic, Project } from '@prisma/client'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { IEpicRepository } from '../../src/repositories/epic.repository.js'; +import type { IProjectRepository } from '../../src/repositories/project.repository.js'; +import { EpicService } from '../../src/services/epic.service.js'; + +function makeEpic(overrides: Partial = {}): Epic { + return { + id: 'epic-1', + name: 'the-car', + description: '', + content: 'build a car', + doneCriteria: [], + acceptedTradeoffs: [], + projectId: 'proj-1', + status: 'open', + semver: '0.1.0', + currentRevisionId: null, + ownerId: 'user-1', + version: 1, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + } as Epic; +} + +const criterion = (id: string, met: boolean, kind: 'outcome' | 'quality' = 'outcome') => + ({ id, text: `criterion ${id}`, kind, met }); + +function mockRepos(epic: Epic | null = makeEpic()) { + let current = epic; + const update = vi.fn(async (_id: string, data: Record) => { + current = { ...(current as Epic), ...data } as Epic; + return current; + }); + const epicRepo = { + findAll: vi.fn(async () => (current ? [current] : [])), + findById: vi.fn(async () => current), + findByName: vi.fn(async () => current), + findByNameAndProject: vi.fn(async () => null), + create: vi.fn(async (data: Record) => makeEpic(data as Partial)), + update, + delete: vi.fn(async () => {}), + } as unknown as IEpicRepository & { update: typeof update }; + + const projectRepo = { + findById: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)), + } as unknown as IProjectRepository; + + return { epicRepo, projectRepo, service: new EpicService(epicRepo, projectRepo) }; +} + +describe('EpicService.completeness', () => { + it('is 0% when nothing is claimed yet', () => { + expect(EpicService.completeness(makeEpic()).percent).toBe(0); + }); + + it('counts quality criteria, not just mechanical outcomes', () => { + // The whole point of the Epic: grading only the checkable half is how you + // end up with every task done and no car. + const epic = makeEpic({ + doneCriteria: [criterion('a', true), criterion('feels-finished', false, 'quality')] as never, + }); + const c = EpicService.completeness(epic); + expect(c.total).toBe(2); + expect(c.met).toBe(1); + expect(c.percent).toBe(50); + expect(c.unmet.map((u) => u.id)).toEqual(['feels-finished']); + }); + + it('rounds to whole percent', () => { + const epic = makeEpic({ + doneCriteria: [criterion('a', true), criterion('b', false), criterion('c', false)] as never, + }); + expect(EpicService.completeness(epic).percent).toBe(33); + }); +}); + +describe('EpicService.setStatus — the honest-done rule', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('refuses delivered while criteria are unmet, and names them', async () => { + const { service } = mockRepos( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', false)] as never }), + ); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow( + /1\/2 criteria met.*Unmet: b/s, + ); + }); + + it('truncates a long unmet list rather than dumping all of it', async () => { + const many = Array.from({ length: 9 }, (_, i) => criterion(`c${String(i)}`, false)); + const { service } = mockRepos(makeEpic({ doneCriteria: many as never })); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow(/\+4 more/); + }); + + it('refuses delivered when no criteria exist at all', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [] as never })); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow( + /nothing to have delivered/, + ); + }); + + it('allows delivered when every criterion is met', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ doneCriteria: [criterion('a', true), criterion('b', true)] as never }), + ); + const result = await service.setStatus('epic-1', { status: 'delivered' }); + expect(result.status).toBe('delivered'); + expect(epicRepo.update).toHaveBeenCalledWith('epic-1', { status: 'delivered' }); + }); + + it('refuses plain delivered when tradeoffs were accepted — the status must name them', async () => { + const { service } = mockRepos( + makeEpic({ + doneCriteria: [criterion('a', true)] as never, + acceptedTradeoffs: [{ what: 'no dark mode', why: 'budget' }] as never, + }), + ); + await expect(service.setStatus('epic-1', { status: 'delivered' })).rejects.toThrow( + /use delivered_with_tradeoffs/, + ); + }); + + it('refuses delivered_with_tradeoffs when none were recorded', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await expect( + service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' }), + ).rejects.toThrow(/without recording at least one accepted tradeoff/); + }); + + it('allows delivered_with_tradeoffs even with unmet criteria, once the gap is recorded', async () => { + const { service } = mockRepos( + makeEpic({ + doneCriteria: [criterion('a', true), criterion('b', false)] as never, + acceptedTradeoffs: [{ what: 'b', why: 'deferred to next quarter' }] as never, + }), + ); + const result = await service.setStatus('epic-1', { status: 'delivered_with_tradeoffs' }); + expect(result.status).toBe('delivered_with_tradeoffs'); + }); + + it('always allows cancelled and reopening', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await expect(service.setStatus('epic-1', { status: 'cancelled' })).resolves.toBeDefined(); + await expect(service.setStatus('epic-1', { status: 'open' })).resolves.toBeDefined(); + }); + + it('rejects an unknown status', async () => { + const { service } = mockRepos(); + await expect(service.setStatus('epic-1', { status: 'shipped' })).rejects.toThrow(); + }); +}); + +describe('EpicService.addTradeoff', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('appends rather than replacing — the record is the audit trail', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ acceptedTradeoffs: [{ what: 'first', why: 'x', at: '2026-01-01T00:00:00.000Z' }] as never }), + ); + await service.addTradeoff('epic-1', { what: 'second', why: 'y' }); + + const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ what: string }> }; + expect(written.acceptedTradeoffs.map((t) => t.what)).toEqual(['first', 'second']); + }); + + it('stamps who from the caller and when from the clock', async () => { + const { service, epicRepo } = mockRepos(); + await service.addTradeoff('epic-1', { what: 'no dark mode', why: 'budget' }, 'user-42'); + + const written = epicRepo.update.mock.calls[0]?.[1] as { + acceptedTradeoffs: Array<{ who?: string; at?: string }>; + }; + expect(written.acceptedTradeoffs[0]?.who).toBe('user-42'); + expect(written.acceptedTradeoffs[0]?.at).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('does not overwrite an explicitly supplied who', async () => { + const { service, epicRepo } = mockRepos(); + await service.addTradeoff('epic-1', { what: 'x', why: 'y', who: 'the-cto' }, 'user-42'); + const written = epicRepo.update.mock.calls[0]?.[1] as { acceptedTradeoffs: Array<{ who?: string }> }; + expect(written.acceptedTradeoffs[0]?.who).toBe('the-cto'); + }); + + it('requires both what and why', async () => { + const { service } = mockRepos(); + await expect(service.addTradeoff('epic-1', { what: 'x' })).rejects.toThrow(); + await expect(service.addTradeoff('epic-1', { why: 'y' })).rejects.toThrow(); + }); +}); + +describe('EpicService.setCriterionMet', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('flips one flag and leaves the rest alone', async () => { + const { service, epicRepo } = mockRepos( + makeEpic({ doneCriteria: [criterion('a', false), criterion('b', false)] as never }), + ); + await service.setCriterionMet('epic-1', 'b', true); + + const written = epicRepo.update.mock.calls[0]?.[1] as { + doneCriteria: Array<{ id: string; met: boolean }>; + }; + expect(written.doneCriteria).toEqual([ + expect.objectContaining({ id: 'a', met: false }), + expect.objectContaining({ id: 'b', met: true }), + ]); + }); + + it('404s on an unknown criterion', async () => { + const { service } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await expect(service.setCriterionMet('epic-1', 'nope', true)).rejects.toThrow(/not found/i); + }); +}); + +describe('EpicService create/update', () => { + beforeEach(() => { vi.clearAllMocks(); }); + + it('rejects duplicate criterion ids', async () => { + const { service } = mockRepos(null); + await expect( + service.createEpic( + { + name: 'x', content: 'c', projectId: 'proj-1', + doneCriteria: [criterion('dup', false), criterion('dup', true)], + }, + 'user-1', + ), + ).rejects.toThrow(/Duplicate done-criterion id: dup/); + }); + + it('409s on a duplicate epic name within the project', async () => { + const { service, epicRepo } = mockRepos(null); + (epicRepo.findByNameAndProject as ReturnType).mockResolvedValueOnce(makeEpic()); + await expect( + service.createEpic({ name: 'the-car', content: 'c', projectId: 'proj-1' }, 'user-1'), + ).rejects.toThrow(/already exists in project 'mcpctl'/); + }); + + it('404s when the project does not exist', async () => { + const { service, projectRepo } = mockRepos(null); + (projectRepo.findById as ReturnType).mockResolvedValueOnce(null); + await expect( + service.createEpic({ name: 'x', content: 'c', projectId: 'ghost' }, 'user-1'), + ).rejects.toThrow(/Project not found/); + }); + + it('rejects a name that is not lowercase-kebab', async () => { + const { service } = mockRepos(null); + await expect( + service.createEpic({ name: 'The Car', content: 'c', projectId: 'proj-1' }, 'user-1'), + ).rejects.toThrow(); + }); + + it('patch-bumps semver when scope changes, so creep is versioned', async () => { + const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' })); + await service.updateEpic('epic-1', { doneCriteria: [criterion('new', false)] }); + expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.2.4' }); + }); + + it('honours an explicit bump', async () => { + const { service, epicRepo } = mockRepos(makeEpic({ semver: '1.2.3' })); + await service.updateEpic('epic-1', { content: 'rewritten', bump: 'minor' }); + expect(epicRepo.update.mock.calls[0]?.[1]).toMatchObject({ semver: '1.3.0' }); + }); + + it('refuses semver and bump together', async () => { + const { service } = mockRepos(); + await expect( + service.updateEpic('epic-1', { content: 'x', semver: '2.0.0', bump: 'major' }), + ).rejects.toThrow(/not both/); + }); + + it('does not let a plain update set status — that gate is setStatus only', async () => { + const { service, epicRepo } = mockRepos(makeEpic({ doneCriteria: [criterion('a', false)] as never })); + await service.updateEpic('epic-1', { content: 'x', status: 'delivered' } as never); + const written = epicRepo.update.mock.calls[0]?.[1] as Record; + expect(written['status']).toBeUndefined(); + }); +});