ADLC
Toolkit

backlog-groom

Verifies each open issue's premise against the code at HEAD, clusters and ranks the backlog, and emits a versioned groomed set. Read-only by default; --apply writes gated, floored conclusions.

backlog-groom

ADLC phase: maintenance / cross-phase · Gate: an adversarial reviewer and the autonomy floor, on --apply only. Without --apply it writes nothing to GitHub.

What it defends against

A backlog's labels are stamped once at filing and then rot. Nothing re-checks whether an issue is still true, so a mature backlog fills with issues whose premise the code has already outgrown — and lane selection re-derives the same code-grounded analysis every time it picks work. backlog-groom verifies each issue's premise against the code at HEAD, clusters issues by the package their verified locations sit in, ranks them from what it learned, and emits a versioned set.

Invoke it as adlc backlog-groom, or through the package's own backlog-groom binary.

By default it is read-only: it reads the backlog and the code and emits a groomed set. --apply --set <groomed.json> is the write path, described below; it closes and relabels issues, and only with ADLC_MANIFEST_KEY present.

Routes and verdicts

An issue is routed by the evidence its body carries: mechanical (a parseable code reference), model (a checkable claim with nothing parseable to check), or unverifiable (no claim about code at all). unverifiable is a first-class outcome — collapsing it into "still valid" is the specific false green this tool exists to detect.

Verdicts are valid, fixed, moved, unverifiable, and unverified for the model route. Three rules keep fixed honest, because it is the verdict that leads to a close:

  • The cited line is a hint, never an identity. Any unrelated edit above a citation shifts every line below it, so a line-anchored comparison reports fixed for live code.
  • An elided excerpt is still a citation. Bodies routinely quote non-contiguous lines, so matching is an in-order subsequence — and partial survival is unverifiable, because changed is not fixed.
  • A path git has never tracked is not a deleted file, it is prose shaped like a path.
  • A cited path must be a file: a directory at the revision is unverifiable, because git would show a listing that no excerpt matches.

Clusters and area relabels use only the citations whose own check was valid or fixed; a sibling citation naming a path that never existed says nothing about where the work is.

Across several citations the precedence is moved > valid > unverifiable > fixed, so every tie-break fails towards not closing. unverifiable outranking fixed is the subtle one: an issue with one citation gone and another that could not be checked has not been shown fixed, and closing it would act on incomplete evidence.

Profile

.claude/backlog-groom-profile.json carries the repo-specific facts — units, frozen paths, label conventions, the autonomy floor the write path reads, and providers.decider. It fails closed: an unrecognised key at any depth is an operational error, for the same reason model-router's floor refuses a value outside its range rather than clamping it. A missing profile is fine; the defaults are complete and conservative.

Incrementality and honesty

The cache is keyed on (updatedAt, contentHash) and is gitignored. An issue with no contentHash — no referenced paths, or any cited path unreadable at the revision — is only ever cached as unverifiable or unverified: such a key can never be invalidated by a code change, so a cached valid or fixed would outlive the fix or the revert.

Every run leads with its route distribution, and a truncated fetch says so loudly. Relation candidates come from a similarity filter that is never evidence; each run reports how many pairs it excluded, since those were never judged and they bound what the run could have found.

What fixed does and does not mean

fixed means every line an issue cited is gone from the file it cited. It does not mean the defect was fixed: a refactor can preserve the same wrong behaviour while rewriting every quoted line, and code can survive verbatim while the logic around it stops reaching it.

That is why fixed produces a close proposal rather than a close, why the evidence carries the revision it was computed against, and why the write path puts every proposal through a fresh-context reviewer and the autonomy floor first. The verdict is a strong triage signal, not a proof, and nothing in the design treats it as one. The evidence field is named lastCommitTouchingPath rather than "the removing commit" for the same reason — that is what it actually is.

The cache is a local, gitignored performance artifact and not a trust boundary: anyone who can write it can also edit the code that produces verdicts, so it grants no privilege, and a cached verdict is never evidence. --no-cache forces full re-verification when a run must stand on its own.

The write path

--apply is the only mode that writes to GitHub, and it is a separate branch rather than a flag threaded through the read run — the read path must stay reachable with no possibility of a write, so the two never share a code path a flag could flip.

adlc backlog-groom --json --out groomed.json     # read-only, writes nothing to GitHub
adlc backlog-groom --apply --set groomed.json    # gated, floored, comment-first

Three things stand between a verdict and a closed issue, and they answer different questions.

The gate — is this conclusion sound? Each proposed action is reviewed once, in artifact mode, at --min-confidence 0.3 rather than the tool's 0.5 default: artifact-mode grounding halves every finding's confidence, so at 0.5 material findings fall below the gate and the run returns a hollow approve. The reviewer must be a provider distinct from providers.decider; without both declared, the rule is unenforceable and every action demotes to a proposal.

One shot, enforced in the core, per checkout. The verdict is recorded against (issue, contentHash) and a second review of the same revision is refused. A wrapper told "review once" will reword the artifact and ask again until it gets an approve — that is the failure the rule exists to prevent, and instruction text does not stop it. A changed artifact for unchanged content is itself the tell. The record lives in .adlc/backlog-groom-ledger.json, which is gitignored per-checkout state: removing it, or running from a fresh checkout or a new worktree, forgets every spent review. The ledger key is the boundary against a caller who does that — only a key-holder can write at all. An issue with no contentHash has no revision to bind a verdict to, so it cannot be gated at all and is never actioned.

Only an explicit approve licenses a write. The reviewer's contract is exit 0 approve, 2 needs-attention, 1 error. A reviewer error, a timeout and a crash all demote: "the review could not complete" and "the review found nothing" are opposite facts, and a truthiness check on the exit code would merge them.

The floor — is anyone allowed to act on this at all? autonomyFloor lists action classes that always require a human regardless of the verdict, so a sufficiently confident reviewer cannot talk past an operator's policy. Omitting the key yields ["close"]; only a deliberate [] empties the floor; an unknown class is an operational error rather than a silently dropped entry, because "clsoe" must not quietly leave closing unguarded.

Widening the floor is privileged, and is measured against the merge base, not the working copy. A check that reads only the checked-out profile validates the claim of whoever is widening it. An unreadable base floor refuses rather than assuming an empty one — otherwise deleting the profile at the base would be the cheapest possible widening.

The rest of the policy is the merge base's too. providers, labels and units decide who reviews and which labels a relabel may target, so a working copy that changes them is refused until the change lands on the default branch. For the same reason --apply refuses --profile: the baseline is read from git at the profile's path, and a path of the caller's choosing is a baseline of the caller's choosing. The commit it is read at comes from the REMOTE — ls-remote --symref origin HEAD, which answers with the remote's own default branch and its head, with the merge base required to be reachable from it — so a local git update-ref cannot move the comparison point either, and a remote that cannot be reached refuses the run instead of falling back. What this does not cover: origin is whatever .git/config names, and a caller who can rewrite that file can point it at a repository holding a widened floor. Against that caller the ledger key is the boundary — without it nothing is written. A relabel's field is exactly priority or area, it names the label it replaces, and an area target must be the unit the issue's verified locations sit in. An approval covers the artifact the reviewer read and nothing else: the same issue and revision with another target or other evidence is not licensed by it.

Execution is idempotent by design

Every action comments its evidence and a durable marker first, then acts. Never the reverse: the trail must be on the issue before it goes quiet, so a wrong action is self-documenting and easy to challenge.

The two writes are not atomic. A comment can land and the action then fail — rate limit, revoked token, a human closing it first — leaving a "closing because…" comment on an open issue. A re-run detects its own marker for that (issue, contentHash) and resumes at the action, rather than stacking a second identical rationale with every retry. A marker from a different revision does not suppress the comment, because stale evidence is not this decision's trail.

A mid-sweep failure therefore leaves a resumable state, never a half-applied one that a retry compounds.

Stated limits

Two things this design does not defend against, recorded here because a limit nobody wrote down is indistinguishable from an oversight.

The gate ledger is signed (#1035). Each entry in .adlc/backlog-groom-ledger.json carries an HMAC over its whole content, keyed by ADLC_MANIFEST_KEY and domain-separated from every other signed artifact, and ledgerApproves refuses an entry whose signature is missing, wrong, or no longer matches what it covers. Hand-writing an approve no longer buys a write: it is the one thing in that file a caller cannot compute, because every other field — artifactDigest included — is derivable from the action.

Two consequences worth stating plainly:

  • Writing is a key-holder act. With no key present nothing can be sealed, so every action demotes to a proposal, the run still reports what it would have done, and it exits 0. That is the designed posture rather than a failure: an unattended agent proposes, and closing an issue takes the key.
  • applied is signed too. It decides whether a write is skipped and reported as already done, so an unsigned one would let a caller have the tool announce a close it never performed — and suppress the retry that would have performed it.

What signing does not do is make the ledger a substitute for the floor. An approval still only licenses the specific action it names, at the revision it was computed against, and the autonomy floor still outranks the reviewer.

Relations are bounded by the candidate filter's recall. A pair the similarity pass never surfaces is a relation the tool cannot report. The filter's threshold and miss rate are printed with every run so the ceiling is visible rather than implied.

Read those numbers and choose --threshold from them. Relations need a judgment per surfaced pair, so the only useful threshold is one whose pairsSurfaced will actually be worked through — and the count collapses steeply. Measured on this repo at 381 open issues (72,390 pairs): 0.2 (the default) surfaces 23,327, 0.3 surfaces 360, 0.35 surfaces 47, 0.4 surfaces 13, 0.5 surfaces 1, and 0.6 and above surface none. 0.4 is a reasonable starting point; the default is not, because an unjudged pair is not a relation. These figures scale with backlog size and title similarity, so re-measure rather than treating them as constants — every run prints them.

On this page