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

@@ -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);
});
});