feat(epics): reconcile claimed completeness against delivered work

An Epic could report 0% while every Task under it was merged. That is not a
cosmetic gap — it is exactly the blind spot the Epic exists to remove
(docs/agentic-teams.md §1b: "we completed all the tasks and got a stick with
wheels instead of a car"). An Epic that cannot see its own delivered work
cannot detect that, because `completeness()` reads only `doneCriteria[].met`
and nothing flips those except a human hitting the criteria endpoint.

So read the same question a second time, from the delivery side, and report
where the two readings disagree — in both directions:

  - deliveredButUnclaimed — unmet criteria whose owning roadmap node has
    merged work. Somebody delivered and nobody ticked the box.
  - claimedButUndelivered — criteria marked met with nothing merged
    underneath. The box is ticked and nothing shipped. The dangerous
    direction, and the one that is easy to forget to look for.
  - unowned — criteria no roadmap node claims at all. They cannot appear in
    either list above, so without a bucket of their own they would vanish
    inside a clean-looking reconciliation.

Nothing is written. `met` stays exactly as a human or agent set it: deriving
it from task state would assert that finishing the tasks means delivering the
vision, which is the assumption under audit. The mismatch is reported, not
silently resolved.

Attribution is by criterion id, because that is the only link the schema
offers — EpicTask.doneCriteria is documented as "the decomposed subset of the
Epic's doneCriteria this node owns" and carries the same ids, but nothing
enforces it. Rather than guess an owner by name similarity or ordering, the
convention is treated as a claim and its failures are surfaced as `unowned`.
A foreign key from criterion to epictask would end the ambiguity; this reports
the ambiguity instead of hiding it.

Judgement calls, made explicit so they can be argued with:
  - `merged` alone counts as delivered. `done` only leaves the developer's
    loop and `awaiting_review` still needs a human; `reopened` means a merge
    did not hold, so it counts as outstanding.
  - `cancelled` is neither delivered nor outstanding. Counting it as owed
    would leave a node permanently undelivered because someone abandoned a
    task; counting it as delivered is a lie. It is reported on its own.
  - a node with no tasks is undelivered, not exempt — a met criterion over an
    undecomposed node is still a claim with nothing behind it. The empty task
    rollup is what distinguishes it from "built but not merged".
  - an epictask's own `status` is another claim, not evidence, so it is
    carried for context and never counted as delivery.

Surfaced on GET /api/v1/epics/:id next to the completeness it contradicts —
a mismatch is only useful where the misleading percentage is already on
screen — and standalone at GET /api/v1/epics/:id/reconciliation for callers
that do not want a 200KB Epic body with it. Deliberately kept off the list
route, which would pay a query per roadmap node per Epic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Michal
2026-08-09 22:05:46 +01:00
parent 5f5efd1144
commit 00373d2b27
6 changed files with 610 additions and 10 deletions

View File

@@ -587,7 +587,7 @@ async function main(): Promise<void> {
// 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);
const epicService = new EpicService(epicRepo, projectRepo, epicTaskRepo, workTaskRepo);
epicService.setRevisionService(resourceRevisionService);
const epicTaskService = new EpicTaskService(epicTaskRepo, epicRepo);
const workTaskService = new TaskService(workTaskRepo, epicTaskRepo);

View File

@@ -37,8 +37,24 @@ export function registerEpicRoutes(
},
);
// Reconciliation rides along with the single Epic read, next to the
// completeness it contradicts. Kept off the list route on purpose: it costs a
// query per roadmap node, and a mismatch nobody is looking at is only useful
// where the misleading percentage is already on screen.
app.get<{ Params: { id: string } }>('/api/v1/epics/:id', async (request) => {
return withCompleteness(await service.getEpic(request.params.id));
const epic = await service.getEpic(request.params.id);
return {
...withCompleteness(epic),
reconciliation: await service.reconciliation(epic.id),
};
});
/**
* Claimed vs delivered, on its own — for callers that want the mismatch
* without dragging along an Epic body that can run to 200KB of content.
*/
app.get<{ Params: { id: string } }>('/api/v1/epics/:id/reconciliation', async (request) => {
return service.reconciliation(request.params.id);
});
// ── Create / Update / Delete ──

View File

@@ -1,7 +1,9 @@
import type { Epic, EpicStatus, Prisma } from '@prisma/client';
import type { Epic, EpicStatus, EpicTask, EpicTaskStatus, Prisma, Task } from '@prisma/client';
import type { IEpicRepository } from '../repositories/epic.repository.js';
import type { IEpicTaskRepository } from '../repositories/epic-task.repository.js';
import type { IProjectRepository } from '../repositories/project.repository.js';
import type { ITaskRepository } from '../repositories/task.repository.js';
import {
CreateEpicSchema,
UpdateEpicSchema,
@@ -23,12 +25,102 @@ export interface EpicCompleteness {
unmet: DoneCriterion[];
}
// ── Reconciliation types ──
/** How much work sits under something, split by whether it actually landed. */
export interface TaskRollup {
/** Tasks in `merged` — the only state that means it is in the product. */
merged: number;
/**
* Neither merged nor cancelled: still owed. `reopened` counts here, because a
* post-merge failure means the delivery did not hold.
*/
outstanding: number;
/** Abandoned. Counted apart: not delivered, but no longer owed either. */
cancelled: number;
}
/** A roadmap node that claims a criterion, plus what it has actually landed. */
export interface OwningEpicTask extends TaskRollup {
id: string;
name: string;
/** The node's own claim about itself. Recorded, never used as evidence. */
status: EpicTaskStatus;
}
/** One Epic criterion set against the work delivered under the nodes that own it. */
export interface CriterionDelivery extends TaskRollup {
id: string;
text: string;
kind: DoneCriterion['kind'];
/** The claim — set by a human or agent. Never inferred from task state. */
met: boolean;
/** Nodes owning this criterion. Empty means nothing can deliver it. */
epicTasks: OwningEpicTask[];
}
/**
* Claimed completeness measured against delivered work.
*
* Both mismatch directions are reported and neither is resolved: a criterion's
* `met` flag is a judgement, and inferring it from task state would launder the
* exact assumption this comparison exists to challenge.
*/
export interface EpicReconciliation {
epicId: string;
/**
* Unmet criteria whose owning node has merged work. Somebody delivered and
* nobody ticked the box, so the Epic under-reports itself.
*/
deliveredButUnclaimed: CriterionDelivery[];
/**
* Criteria marked met with nothing merged underneath. The box is ticked and
* nothing shipped — the dangerous direction, and the easy one to forget.
*/
claimedButUndelivered: CriterionDelivery[];
/**
* Criteria no roadmap node claims. They cannot appear in either list above
* because nothing is positioned to deliver them; reporting them separately
* keeps that gap from hiding inside a clean-looking reconciliation.
*/
unowned: CriterionDelivery[];
/** Every task under the Epic, so the mismatch has a denominator. */
tasks: TaskRollup & { total: number };
/** True only when all three lists are empty. */
consistent: boolean;
}
/**
* The fields the reconciliation rule actually reads. Structural rather than the
* full Prisma rows so the pure function stays callable with a small fixture.
*/
export type ReconcileNode = Pick<EpicTask, 'id' | 'name' | 'status'> & { doneCriteria: unknown };
export type ReconcileTask = Pick<Task, 'epicTaskId' | 'state'>;
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 readCriteria(source: { doneCriteria: unknown }): DoneCriterion[] {
return (source.doneCriteria ?? []) as DoneCriterion[];
}
function rollup(tasks: ReconcileTask[]): TaskRollup {
let merged = 0;
let cancelled = 0;
for (const task of tasks) {
if (task.state === 'merged') merged += 1;
else if (task.state === 'cancelled') cancelled += 1;
}
return { merged, outstanding: tasks.length - merged - cancelled, cancelled };
}
function sumRollup(parts: TaskRollup[]): TaskRollup {
return {
merged: parts.reduce((n, p) => n + p.merged, 0),
outstanding: parts.reduce((n, p) => n + p.outstanding, 0),
cancelled: parts.reduce((n, p) => n + p.cancelled, 0),
};
}
function readTradeoffs(epic: Epic): AcceptedTradeoff[] {
@@ -41,6 +133,11 @@ export class EpicService {
constructor(
private readonly epicRepo: IEpicRepository,
private readonly projectRepo: IProjectRepository,
// Reconciliation reads down the whole chain — Epic → EpicTask → Task — so
// the two lower repositories are required, not injected later. An Epic that
// cannot see its own delivered work is the blind spot, not a nice-to-have.
private readonly epicTaskRepo: IEpicTaskRepository,
private readonly taskRepo: ITaskRepository,
) {}
setRevisionService(service: ResourceRevisionService): void {
@@ -66,6 +163,116 @@ export class EpicService {
};
}
// ── Reconciliation: what was claimed vs what was delivered ──
/**
* Compare the Epic's claimed completeness against the work actually merged
* under its roadmap.
*
* `completeness()` above counts only `met` flags, and nothing flips those
* automatically — so an Epic whose every Task is merged still reads 0%. That
* is precisely the blind spot the Epic exists to remove, hence this second
* reading of the same question from the delivery side.
*
* **How a criterion is attributed to a node.** By criterion id, because that
* is the only link the schema offers: `EpicTask.doneCriteria` is documented
* as "the decomposed subset of the Epic's doneCriteria this node owns" and
* carries the same ids. Nothing enforces it — an id can be missing, shared by
* several nodes, or invented — so this treats the convention as a claim and
* reports where it does not hold (`unowned`) rather than guessing an owner by
* name similarity or ordering. A real foreign key would end the ambiguity.
*
* **Nothing is written.** `met` stays exactly as a human or agent set it;
* deriving it from task state would assert that finishing the tasks means
* delivering the vision, which is the assumption under audit.
*/
static reconcile(
epic: { id: string; doneCriteria: unknown },
nodes: ReconcileNode[],
tasks: ReconcileTask[],
): EpicReconciliation {
const tasksByNode = new Map<string, ReconcileTask[]>();
for (const task of tasks) {
tasksByNode.set(task.epicTaskId, [...(tasksByNode.get(task.epicTaskId) ?? []), task]);
}
const ownersByCriterion = new Map<string, OwningEpicTask[]>();
for (const node of nodes) {
const owner: OwningEpicTask = {
id: node.id,
name: node.name,
status: node.status,
...rollup(tasksByNode.get(node.id) ?? []),
};
// Deduplicated: nothing stops a node from listing the same id twice, and
// that must not double-count its tasks against the criterion.
for (const ownedId of new Set(readCriteria(node).map((c) => c.id))) {
ownersByCriterion.set(ownedId, [...(ownersByCriterion.get(ownedId) ?? []), owner]);
}
}
const deliveredButUnclaimed: CriterionDelivery[] = [];
const claimedButUndelivered: CriterionDelivery[] = [];
const unowned: CriterionDelivery[] = [];
for (const criterion of readCriteria(epic)) {
const epicTasks = ownersByCriterion.get(criterion.id) ?? [];
const entry: CriterionDelivery = {
id: criterion.id,
text: criterion.text,
// Restored backups write doneCriteria as raw JSON without going through
// the zod defaults, so neither field can be trusted to be present.
kind: criterion.kind ?? 'outcome',
met: criterion.met === true,
epicTasks,
...sumRollup(epicTasks),
};
if (epicTasks.length === 0) {
// No node owns it, so no amount of merged work can speak for it. Note
// this catches a *met* orphan too — a box ticked with nothing on the
// roadmap even nominally responsible for it.
unowned.push(entry);
} else if (entry.met && entry.merged === 0) {
claimedButUndelivered.push(entry);
} else if (!entry.met && entry.merged > 0) {
deliveredButUnclaimed.push(entry);
}
}
return {
epicId: epic.id,
deliveredButUnclaimed,
claimedButUndelivered,
unowned,
tasks: { ...rollup(tasks), total: tasks.length },
consistent:
deliveredButUnclaimed.length === 0 &&
claimedButUndelivered.length === 0 &&
unowned.length === 0,
};
}
/**
* Load an Epic's roadmap and every task under it, then reconcile.
*
* One query per node rather than a join: a roadmap holds a handful of nodes,
* `ITaskRepository` already filters by `epicTaskId`, and widening that
* interface to save a round-trip on a single-Epic read is not worth the
* churn across its call sites.
*/
async reconciliation(id: string): Promise<EpicReconciliation> {
const epic = await this.getEpic(id);
const nodes = await this.epicTaskRepo.findAll(epic.id);
const tasks: Task[] = [];
for (const node of nodes) {
tasks.push(...(await this.taskRepo.findAll({ epicTaskId: node.id })));
}
return EpicService.reconcile(epic, nodes, tasks);
}
// ── CRUD ──
async listEpics(projectId?: string): Promise<Epic[]> {

View File

@@ -1,10 +1,12 @@
import type { Epic, Project } from '@prisma/client';
import type { Epic, EpicTask, Project, Task } 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 { IEpicTaskRepository } from '../src/repositories/epic-task.repository.js';
import type { IProjectRepository } from '../src/repositories/project.repository.js';
import type { ITaskRepository, TaskFilters } from '../src/repositories/task.repository.js';
import { registerEpicRoutes } from '../src/routes/epics.js';
import { EpicService } from '../src/services/epic.service.js';
@@ -36,7 +38,19 @@ const makeProject = (o: Partial<Project> = {}): 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<{
/** Roadmap node + work item, trimmed to the fields the reconciliation reads. */
const node = (id: string, criterionIds: string[]): EpicTask =>
({ id, name: id, status: 'todo', doneCriteria: criterionIds.map((c) => criterion(c, false)) } as unknown as EpicTask);
const workTask = (epicTaskId: string, state: Task['state']): Task =>
({ epicTaskId, state } as Task);
async function buildApp(
epic: Epic | null = makeEpic(),
userId?: string,
nodes: EpicTask[] = [],
tasks: Task[] = [],
): Promise<{
app: FastifyInstance;
epicRepo: IEpicRepository & { update: ReturnType<typeof vi.fn> };
}> {
@@ -60,13 +74,24 @@ async function buildApp(epic: Epic | null = makeEpic(), userId?: string): Promis
findByName: vi.fn(async (n: string) => (n === 'mcpctl' ? makeProject() : null)),
} as unknown as IProjectRepository;
const epicTaskRepo = { findAll: vi.fn(async () => nodes) } as unknown as IEpicTaskRepository;
const taskRepo = {
findAll: vi.fn(async (filters: TaskFilters = {}) =>
tasks.filter((t) => t.epicTaskId === filters.epicTaskId),
),
} as unknown as ITaskRepository;
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);
registerEpicRoutes(
instance,
new EpicService(epicRepo, projectRepo, epicTaskRepo, taskRepo),
projectRepo,
);
await instance.ready();
return { app: instance, epicRepo };
}
@@ -90,6 +115,54 @@ describe('epic routes', () => {
expect(one.json().completeness.unmet).toHaveLength(1);
});
it('puts the delivered-vs-claimed mismatch next to the misleading percentage', async () => {
// 0% claimed, everything merged — reading one without the other is the bug.
const built = await buildApp(
makeEpic({ doneCriteria: [criterion('wheels', false)] as never }),
undefined,
[node('n1', ['wheels'])],
[workTask('n1', 'merged')],
);
app = built.app;
const res = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1' });
expect(res.statusCode).toBe(200);
expect(res.json().completeness.percent).toBe(0);
expect(res.json().reconciliation.deliveredButUnclaimed.map((c: { id: string }) => c.id))
.toEqual(['wheels']);
expect(res.json().reconciliation.consistent).toBe(false);
});
it('serves the reconciliation on its own, without the Epic body', async () => {
const built = await buildApp(
makeEpic({ doneCriteria: [criterion('engine', true)] as never }),
undefined,
[node('n1', ['engine'])],
[],
);
app = built.app;
const res = await app.inject({ method: 'GET', url: '/api/v1/epics/epic-1/reconciliation' });
expect(res.statusCode).toBe(200);
expect(res.json().content).toBeUndefined();
expect(res.json().claimedButUndelivered.map((c: { id: string }) => c.id)).toEqual(['engine']);
});
it('404s the reconciliation of an unknown Epic', async () => {
const built = await buildApp(null);
app = built.app;
const res = await app.inject({ method: 'GET', url: '/api/v1/epics/ghost/reconciliation' });
expect(res.statusCode).toBe(404);
});
it('keeps the list route free of reconciliation — one query per node is not list work', async () => {
const built = await buildApp(makeEpic({ doneCriteria: [criterion('a', false)] as never }));
app = built.app;
const res = await app.inject({ method: 'GET', url: '/api/v1/epics' });
expect(res.json()[0].completeness).toBeDefined();
expect(res.json()[0].reconciliation).toBeUndefined();
});
it('creates from a project name and stamps the caller as owner', async () => {
const built = await buildApp(null, 'user-42');
app = built.app;

View File

@@ -2,8 +2,11 @@ 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 { IEpicTaskRepository } from '../../src/repositories/epic-task.repository.js';
import type { IProjectRepository } from '../../src/repositories/project.repository.js';
import type { ITaskRepository, TaskFilters } from '../../src/repositories/task.repository.js';
import { EpicService } from '../../src/services/epic.service.js';
import type { ReconcileNode, ReconcileTask } from '../../src/services/epic.service.js';
function makeEpic(overrides: Partial<Epic> = {}): Epic {
return {
@@ -28,7 +31,27 @@ function makeEpic(overrides: Partial<Epic> = {}): Epic {
const criterion = (id: string, met: boolean, kind: 'outcome' | 'quality' = 'outcome') =>
({ id, text: `criterion ${id}`, kind, met });
function mockRepos(epic: Epic | null = makeEpic()) {
/** A roadmap node, trimmed to what the reconciliation rule reads. */
const node = (
id: string,
criterionIds: string[],
overrides: Partial<ReconcileNode> = {},
): ReconcileNode => ({
id,
name: id,
status: 'todo',
doneCriteria: criterionIds.map((c) => criterion(c, false)),
...overrides,
});
const task = (epicTaskId: string, state: ReconcileTask['state']): ReconcileTask =>
({ epicTaskId, state });
function mockRepos(
epic: Epic | null = makeEpic(),
nodes: ReconcileNode[] = [],
tasks: ReconcileTask[] = [],
) {
let current = epic;
const update = vi.fn(async (_id: string, data: Record<string, unknown>) => {
current = { ...(current as Epic), ...data } as Epic;
@@ -49,7 +72,25 @@ function mockRepos(epic: Epic | null = makeEpic()) {
findByName: vi.fn(async () => ({ id: 'proj-1', name: 'mcpctl' } as Project)),
} as unknown as IProjectRepository;
return { epicRepo, projectRepo, service: new EpicService(epicRepo, projectRepo) };
const epicTaskRepo = { findAll: vi.fn(async () => nodes) };
const taskRepo = {
findAll: vi.fn(async (filters: TaskFilters = {}) =>
tasks.filter((t) => t.epicTaskId === filters.epicTaskId),
),
};
return {
epicRepo,
projectRepo,
epicTaskRepo,
taskRepo,
service: new EpicService(
epicRepo,
projectRepo,
epicTaskRepo as unknown as IEpicTaskRepository,
taskRepo as unknown as ITaskRepository,
),
};
}
describe('EpicService.completeness', () => {
@@ -78,6 +119,219 @@ describe('EpicService.completeness', () => {
});
});
describe('EpicService.reconcile — claimed vs delivered', () => {
const ids = (list: Array<{ id: string }>) => list.map((c) => c.id);
it('flags work that was delivered and never claimed', () => {
// The motivating bug: every task merged, the Epic still reads 0%.
const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', false)] };
const result = EpicService.reconcile(
epic,
[node('n1', ['wheels'])],
[task('n1', 'merged'), task('n1', 'merged')],
);
expect(ids(result.deliveredButUnclaimed)).toEqual(['wheels']);
expect(result.deliveredButUnclaimed[0]?.merged).toBe(2);
expect(result.claimedButUndelivered).toEqual([]);
expect(result.consistent).toBe(false);
});
it('flags a criterion claimed with nothing merged under it', () => {
const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] };
const result = EpicService.reconcile(
epic,
[node('n1', ['engine'])],
[task('n1', 'awaiting_review')],
);
expect(ids(result.claimedButUndelivered)).toEqual(['engine']);
expect(result.claimedButUndelivered[0]).toMatchObject({ merged: 0, outstanding: 1 });
});
it('flags a claim over a node that has no tasks at all', () => {
// Not-yet-decomposed is still nothing shipped — same bucket, and the empty
// rollup is what tells the two apart.
const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] };
const result = EpicService.reconcile(epic, [node('n1', ['engine'])], []);
expect(ids(result.claimedButUndelivered)).toEqual(['engine']);
expect(result.claimedButUndelivered[0]).toMatchObject({
merged: 0,
outstanding: 0,
cancelled: 0,
});
});
it('reports both directions from one pass over a mixed Epic', () => {
const epic = {
id: 'epic-1',
doneCriteria: [
criterion('wheels', false), // delivered, unclaimed
criterion('engine', true), // claimed, nothing merged
criterion('paint', true), // claimed and delivered — agrees
criterion('seats', false), // neither — just not done yet
],
};
const result = EpicService.reconcile(
epic,
[node('n1', ['wheels']), node('n2', ['engine']), node('n3', ['paint']), node('n4', ['seats'])],
[task('n1', 'merged'), task('n2', 'in_progress'), task('n3', 'merged'), task('n4', 'todo')],
);
expect(ids(result.deliveredButUnclaimed)).toEqual(['wheels']);
expect(ids(result.claimedButUndelivered)).toEqual(['engine']);
expect(result.unowned).toEqual([]);
expect(result.tasks).toEqual({ merged: 2, outstanding: 2, cancelled: 0, total: 4 });
});
it('reports criteria no roadmap node owns, instead of silently dropping them', () => {
// There is no foreign key from criterion to epictask — only the id
// convention. When it does not hold, say so rather than guessing an owner.
const epic = {
id: 'epic-1',
doneCriteria: [criterion('orphan', false), criterion('claimed-orphan', true)],
};
const result = EpicService.reconcile(epic, [node('n1', ['something-else'])], [task('n1', 'merged')]);
expect(ids(result.unowned)).toEqual(['orphan', 'claimed-orphan']);
// A met criterion nothing is even nominally responsible for must not be
// laundered into the "agrees" pile by having no owner to contradict it.
expect(result.unowned[1]?.met).toBe(true);
expect(result.deliveredButUnclaimed).toEqual([]);
expect(result.claimedButUndelivered).toEqual([]);
expect(result.consistent).toBe(false);
});
it('treats cancelled tasks as neither delivered nor outstanding', () => {
// Counting them as owed would leave an epictask permanently undelivered
// because somebody abandoned a task; counting them as delivered is a lie.
const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] };
const result = EpicService.reconcile(
epic,
[node('n1', ['engine'])],
[task('n1', 'cancelled'), task('n1', 'cancelled')],
);
expect(ids(result.claimedButUndelivered)).toEqual(['engine']);
expect(result.tasks).toEqual({ merged: 0, outstanding: 0, cancelled: 2, total: 2 });
});
it('does not count a reopened task as delivered — the merge did not hold', () => {
const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] };
const result = EpicService.reconcile(epic, [node('n1', ['engine'])], [task('n1', 'reopened')]);
expect(ids(result.claimedButUndelivered)).toEqual(['engine']);
expect(result.tasks.outstanding).toBe(1);
});
it('sums across every node that claims the same criterion', () => {
const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', false)] };
const result = EpicService.reconcile(
epic,
[node('n1', ['wheels']), node('n2', ['wheels'])],
[task('n1', 'todo'), task('n2', 'merged')],
);
// One entry, not two, and delivered because one owner landed work.
expect(result.deliveredButUnclaimed).toHaveLength(1);
expect(result.deliveredButUnclaimed[0]?.epicTasks.map((n) => n.id)).toEqual(['n1', 'n2']);
expect(result.deliveredButUnclaimed[0]).toMatchObject({ merged: 1, outstanding: 1 });
});
it('ignores an epictask status claim — only merged tasks count as delivery', () => {
// The node says done. Nothing merged. That is the mismatch, not evidence.
const epic = { id: 'epic-1', doneCriteria: [criterion('engine', true)] };
const result = EpicService.reconcile(
epic,
[node('n1', ['engine'], { status: 'done' })],
[task('n1', 'awaiting_review')],
);
expect(ids(result.claimedButUndelivered)).toEqual(['engine']);
expect(result.claimedButUndelivered[0]?.epicTasks[0]?.status).toBe('done');
});
it('never flips met — it reports the gap and leaves the judgement alone', () => {
const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', false)] };
const result = EpicService.reconcile(epic, [node('n1', ['wheels'])], [task('n1', 'merged')]);
expect(result.deliveredButUnclaimed[0]?.met).toBe(false);
expect(epic.doneCriteria[0]?.met).toBe(false);
});
it('is consistent only when nothing disagrees', () => {
const epic = { id: 'epic-1', doneCriteria: [criterion('wheels', true)] };
const result = EpicService.reconcile(epic, [node('n1', ['wheels'])], [task('n1', 'merged')]);
expect(result.consistent).toBe(true);
expect(result.deliveredButUnclaimed).toEqual([]);
expect(result.claimedButUndelivered).toEqual([]);
expect(result.unowned).toEqual([]);
});
it('is consistent on an Epic with no criteria and no work', () => {
expect(EpicService.reconcile({ id: 'epic-1', doneCriteria: [] }, [], []).consistent).toBe(true);
});
it('survives criteria restored as raw JSON without kind or met', () => {
// upsertByName writes doneCriteria straight from a backup document, so the
// zod defaults never ran.
const epic = { id: 'epic-1', doneCriteria: [{ id: 'wheels', text: 'has wheels' }] };
const result = EpicService.reconcile(
epic,
[{ id: 'n1', name: 'n1', status: 'todo', doneCriteria: [{ id: 'wheels' }] }],
[task('n1', 'merged')],
);
expect(result.deliveredButUnclaimed[0]).toMatchObject({ kind: 'outcome', met: false });
});
it('tolerates a null doneCriteria column on either side', () => {
const result = EpicService.reconcile(
{ id: 'epic-1', doneCriteria: null },
[{ id: 'n1', name: 'n1', status: 'todo', doneCriteria: null }],
[task('n1', 'merged')],
);
expect(result.consistent).toBe(true);
expect(result.tasks.merged).toBe(1);
});
});
describe('EpicService.reconciliation — loading the chain', () => {
beforeEach(() => { vi.clearAllMocks(); });
it('walks Epic → epictasks → tasks and reconciles what it finds', async () => {
const { service, epicTaskRepo, taskRepo } = mockRepos(
makeEpic({ doneCriteria: [criterion('wheels', false)] as never }),
[node('n1', ['wheels'])],
[task('n1', 'merged')],
);
const result = await service.reconciliation('epic-1');
expect(epicTaskRepo.findAll).toHaveBeenCalledWith('epic-1');
expect(taskRepo.findAll).toHaveBeenCalledWith({ epicTaskId: 'n1' });
expect(result.deliveredButUnclaimed.map((c) => c.id)).toEqual(['wheels']);
});
it('writes nothing — reconciliation is a reading, not a repair', async () => {
const { service, epicRepo } = mockRepos(
makeEpic({ doneCriteria: [criterion('wheels', false)] as never }),
[node('n1', ['wheels'])],
[task('n1', 'merged')],
);
await service.reconciliation('epic-1');
expect(epicRepo.update).not.toHaveBeenCalled();
});
it('404s on an unknown Epic', async () => {
const { service } = mockRepos(null);
await expect(service.reconciliation('ghost')).rejects.toThrow(/Epic not found/);
});
});
describe('EpicService.setStatus — the honest-done rule', () => {
beforeEach(() => { vi.clearAllMocks(); });

View File

@@ -169,4 +169,54 @@ describe('agentic teams smoke', () => {
expect(withNote.code, withNote.stderr || withNote.stdout).toBe(0);
expect(withNote.stdout).toMatch(/rejected → in_progress/);
});
it('reports work delivered but never claimed, and does not tick the box itself', () => {
if (!up) return;
// Give the roadmap node the Epic criterion it owns. There is no
// `create epictask --criterion` flag, so apply is the only way in.
const path = join(workDir as string, 'epictask-criteria.yaml');
writeFileSync(
path,
`kind: epictask\nname: ${EPICTASK}\nepic: ${EPIC}\n` +
'doneCriteria:\n - id: builds\n text: it builds\n kind: outcome\n met: false\n',
'utf-8',
);
const applied = run(`apply -f ${path}`);
expect(applied.code, applied.stderr || applied.stdout).toBe(0);
// Drive the rejected task all the way to merged. The QA verdict recorded
// earlier still stands, so the risk gate is already satisfied.
for (const state of ['building', 'self_testing', 'done', 'awaiting_review']) {
const r = run(`tasks advance ${TASK} ${state}`);
expect(r.code, `${state}: ${r.stderr || r.stdout}`).toBe(0);
}
const accepted = run(`tasks accept ${TASK} --note "ship it"`);
expect(accepted.code, accepted.stderr || accepted.stdout).toBe(0);
const json = run(`get epic ${EPIC} -o json`);
expect(json.code, json.stderr).toBe(0);
const [epic] = JSON.parse(json.stdout) as Array<{
completeness: { percent: number; met: number };
reconciliation: {
deliveredButUnclaimed: Array<{ id: string; met: boolean; merged: number }>;
unowned: Array<{ id: string }>;
consistent: boolean;
};
}>;
// The blind spot, stated out loud: everything merged, still 0% claimed.
expect(epic?.completeness.percent).toBe(0);
expect(epic?.reconciliation.deliveredButUnclaimed.map((c) => c.id)).toEqual(['builds']);
expect(epic?.reconciliation.deliveredButUnclaimed[0]?.merged).toBe(1);
expect(epic?.reconciliation.consistent).toBe(false);
// The quality criterion no node claimed is reported as unowned rather than
// quietly counted as either delivered or outstanding.
expect(epic?.reconciliation.unowned).toHaveLength(1);
// And the judgement is still the human's: reporting the gap must not close it.
expect(epic?.completeness.met).toBe(0);
expect(epic?.reconciliation.deliveredButUnclaimed[0]?.met).toBe(false);
});
});