# Modular Hierarchy Refactors (1 + 2) Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use /ship (recommended) or /executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Extract the cross-file resolver out of `gate.py` into its own path-loaded `resolver.py` (shared plumbing), then extract the duplicated `claude -p` subprocess call into one `run_llm` dispatch fn — both pure refactors, zero behavior change, locked by the existing pytest suite.

**Architecture:** Two sequential pure-move refactors. Wave 1 (pilot) moves the resolver cluster (workspace-alias discovery + import resolution + barrel-follow + dep BFS) verbatim into `orchestrator/resolver.py`; `gate.py` imports the three functions its `main()` uses, and `runner.py`/`mapper.py`/the resolver-unit tests retarget to the new home. Wave 2 collapses the two byte-identical `["claude","-p",...]` call sites (`gate.py:one_roll`, `semantic_merge._call_llm`) onto one `orchestrator/llm_runner.py:run_llm` that returns the raw `CompletedProcess` so each caller keeps its own error policy. No new behavior, no alt LLM backends.

**Tech Stack:** Python 3 stdlib only (no new deps), modules loaded by PATH (`importlib.util.spec_from_file_location`, NO `__init__.py`), pytest via `rtk proxy python3 -m pytest`.

---

## SoT / context

- Spec (source of truth): `docs/specs/2026-06-18-modular-hierarchy-design.md` — refactors 1 + 2 only.
- Refactor 3 (split `mapper.py` → DETECT Opus-orchestrator + the contract-unification work item) is **explicitly deferred to its own spec + plan** by the spec; see "NOT covered / deferred" below. Do NOT implement it here.
- Testing-native vocab ONLY in any code/comment/commit: detector/domain/corpus/cell/canary/band; never module/layer/tier/seam. The design noun `adapter` is licensed (it is the `llm-runner` seam).
- This project is **testing-native: `tests/` IS committed** (prior prevent-band commits added `tests/test_prevent_*.py`). Staging edited/new test files in commit steps is correct here — this overrides the generic "never git add tests/" rule.
- Use RAW git for any cell reconstruction (`command git --no-pager show` / `rtk proxy git show`) — never routed `git show` (RTK truncates). No cell reconstruction is needed for this plan.
- Auto-commit each task. Terse caveman commit messages. **Never co-author.**

## Separability — already MEASURED (do not re-litigate, just verify the grep stays true)

The resolver cluster has **zero outbound reference to `gate.py` runtime state** (no `build_bundle`, `run_oracle_set`, `DEP_OVERRIDE`, `one_roll`, `union_rolls`, `SEV_RANK`, roll/LLM path). Every private helper and parse regex it uses is used **only inside the cluster**. Verified end-to-end against `gate.py`: the five helper bodies (50-122) are pure (stdlib + each other only); the public fns (124-310) reference no gate runtime symbol; **`main()`'s full body INCLUDING the tail (600-628) references only the three imported fns** `find_repo_root`/`build_workspace_aliases`/`collect_deps` from the cluster — the report tail uses only `oracle_results`/`groups`/`deps`/`a.*`/`report`, no moved symbol. Recorded in the spec. Task 1 Step 1 re-confirms with one grep before moving anything.

**The cluster (everything that MOVES to `resolver.py`):**

| Symbol | gate.py line | kind |
|---|---|---|
| `CRITICAL` | 29 | regex (priority-ordering token set) |
| `IDENT` | 34 | regex |
| `IMPORT` | 36 | regex |
| `REEXPORT_NAMED` | 38 | regex |
| `REEXPORT_STAR` | 39 | regex |
| `_read_pnpm_workspace_globs` | 50 | helper |
| `_read_npm_workspaces` | 71 | helper |
| `_exports_target` | 87 | helper |
| `_dist_to_src` | 103 | helper |
| `_existing_source` | 115 | helper |
| `build_workspace_aliases` | 124 | public |
| `find_repo_root` | 166 | public |
| `resolve` | 179 | public |
| `_barrel_named_source` | 202 | helper |
| `_defines_local` | 218 | helper |
| `resolve_through_barrel` | 237 | public |
| `first_party_value_imports` | 282 | public |
| `collect_deps` | 448 | public |

**Stays in `gate.py`** (uses none of the moved code except by calling the imported public fns): `FINDING`/`SEV`/`IMPERATIVE`/`SEV_RANK` regexes, `oracle_status`, `run_oracle`, `run_oracle_set`, `one_roll`, `parse_findings`, `norm`, `union_rolls`, `BUNDLE_HEADER`, `DEP_OVERRIDE`, `_read_text`, `build_bundle`, `write_bundle_file`, `main`.

**Consumers of the moved code (the rewires):**

| Consumer | Today | After |
|---|---|---|
| `gate.py:main` (545/546/552) | `find_repo_root`, `build_workspace_aliases`, `collect_deps` defined locally | `from resolver import find_repo_root, build_workspace_aliases, collect_deps` |
| `prevent/runner.py` (73/82) | `_gate.build_workspace_aliases`, `_gate.collect_deps` | `_resolver.build_workspace_aliases`, `_resolver.collect_deps` (drop the now-unused `_gate` load) |
| `orchestrator/mapper.py` (207) | `_load("sg_gate_for_critical", gate.py).CRITICAL` | `_load("sg_resolver_for_critical", resolver.py).CRITICAL` |
| `tests/test_gate.py`, `tests/test_oracle_xfile.py` | call `gate.{build_workspace_aliases,resolve,resolve_through_barrel,first_party_value_imports,collect_deps}` | retarget those to `resolver.X`; keep `gate.{run_oracle,run_oracle_set,build_bundle}` (those stay in gate.py and prove gate↔resolver wiring) |

**Design decision (no shim, no dead imports):** `gate.py` imports ONLY the three resolver functions `main()` actually calls (`find_repo_root`, `build_workspace_aliases`, `collect_deps`). The other three public resolver fns (`resolve`, `resolve_through_barrel`, `first_party_value_imports`) are internal to the resolver and are tested at their real home (`resolver.X`). No backward-compat re-export from `gate.py` — re-exporting names `gate.py` does not use, only to avoid editing tests, is the work-dodging quick-win this project forbids.

---

## Wave Plan

| Wave | Tasks | Files touched | Safe to parallelize? |
|------|-------|---------------|----------------------|
| 1 | Task 1 (resolver extraction) | orchestrator/resolver.py (create), orchestrator/gate.py, orchestrator/mapper.py, prevent/runner.py, tests/test_gate.py, tests/test_oracle_xfile.py | single task |
| 2 | Task 2 (llm_runner extraction) | orchestrator/llm_runner.py (create), orchestrator/gate.py, orchestrator/semantic_merge.py, tests/test_llm_runner.py (create) | single task |

Wave 2 is **blocked by** Wave 1 (both touch `gate.py`; pilot-before-fanout requires Wave 1 green first). One task per wave → no intra-wave file overlap to check.

---

## Task 1: Extract the cross-file resolver into `resolver.py`

**Wave:** 1
**Blocks:** Task 2
**Blocked by:** —

**Files:**
- Create: `orchestrator/resolver.py`
- Modify: `orchestrator/gate.py` (delete the moved cluster lines 29-39 + 50-163 + 166-310 + 448-472; add one import; drop now-dead imports)
- Modify: `prevent/runner.py:26,73,82`
- Modify: `orchestrator/mapper.py:207`
- Test: `tests/test_gate.py`, `tests/test_oracle_xfile.py` (retarget resolver calls)

- [ ] **Step 1: Re-confirm separability (the grep must stay true before moving anything)**

Run:
```bash
cd ~/Projects/security-gate && command grep -n "build_bundle\|run_oracle_set\|DEP_OVERRIDE\|one_roll\|union_rolls\|SEV_RANK\|parse_findings" orchestrator/gate.py | command grep -nE "^(29|3[0-9]|[5-9][0-9]|1[0-6][0-9]|2[0-9][0-9]|4[4-7][0-9]):"
```
Expected: **no output** (none of the staying-symbols appear inside the cluster line ranges). If any line prints, STOP — the cluster is not cleanly separable; re-read the spec's separability section before proceeding.

- [ ] **Step 2: Create `orchestrator/resolver.py` with header + imports**

Create `orchestrator/resolver.py` with exactly this top:
```python
#!/usr/bin/env python3
"""resolver.py — cross-file scope resolution (extracted from gate.py; modular-hierarchy refactor 1).

Pure, deterministic, stdlib-only. Answers ONE question: given a target TS/TSX file, which first-party
files are in scope (the target + its first-party import bodies) — resolving pnpm/npm/yarn workspace
aliases and following re-export barrels to definition sites. NO LLM, NO bundling, NO oracle — those
stay in gate.py. This file has ZERO dependency on gate.py (the cluster references no gate.py runtime
symbol). Loaded by PATH (spec_from_file_location) per project convention — no __init__.py.

Public surface: build_workspace_aliases, find_repo_root, resolve, resolve_through_barrel,
first_party_value_imports, collect_deps. CRITICAL is the priority-ordering regex (a hint, NOT a gate)."""
import glob, json, os, re, sys
```

- [ ] **Step 3: Move the cluster verbatim into `resolver.py`**

Cut from `orchestrator/gate.py` and paste — **verbatim, do not rewrite a single line** — into `resolver.py` below the imports, in this order:
1. The five regexes `CRITICAL` (lines 29-33), `IDENT` (34), `IMPORT` (36), `REEXPORT_NAMED` (38), `REEXPORT_STAR` (39). **Keep the `# #36 ...` comment block (lines 26-28) with `CRITICAL`.** Do NOT move `FINDING`/`SEV`/`IMPERATIVE` (41-47) — they stay in gate.py.
2. The helpers `_read_pnpm_workspace_globs` (50-70), `_read_npm_workspaces` (71-86), `_exports_target` (87-102), `_dist_to_src` (103-114), `_existing_source` (115-122).
3. `build_workspace_aliases` (124-163), `find_repo_root` (166-177), `resolve` (179-199), `_barrel_named_source` (202-215), `_defines_local` (218-234), `resolve_through_barrel` (237-279), `first_party_value_imports` (282-310).
4. `collect_deps` (448-472).

After cutting, `gate.py` must no longer contain any of those `def`s or those five regex assignments. Do NOT touch `oracle_status`/`run_oracle`/`run_oracle_set` (313-351) or anything from `one_roll` (352) onward except `collect_deps` (already cut) — they stay.

- [ ] **Step 4: Write the failing resolver test (RED)**

Create `tests/test_resolver.py`:
```python
"""test_resolver.py — the cross-file resolver at its real home (refactor 1). Loads resolver.py by PATH
(project convention — no package import, no __init__.py)."""
import importlib.util, os, tempfile

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

resolver = _load("sg_resolver", os.path.join(ROOT, "orchestrator", "resolver.py"))

def test_resolver_exposes_public_surface_and_resolves_a_local_relative_import():
    for name in ("build_workspace_aliases", "find_repo_root", "resolve",
                 "resolve_through_barrel", "first_party_value_imports", "collect_deps"):
        assert hasattr(resolver, name), f"resolver missing {name}"
    d = tempfile.mkdtemp()
    open(os.path.join(d, "dep.ts"), "w").write("export function helper() { return 1 }\n")
    open(os.path.join(d, "main.ts"), "w").write("import { helper } from './dep'\nhelper()\n")
    got = resolver.first_party_value_imports(os.path.join(d, "main.ts"), [])
    assert (os.path.join(d, "dep.ts"), "helper") in got
```

Run: `rtk proxy python3 -m pytest tests/test_resolver.py -q`
Expected: **FAIL** — `ModuleNotFoundError`/exec error if Step 2/3 not done, or import error. (If Steps 2-3 are already done it will PASS; that is acceptable — this step exists so the resolver has a test at its real home.)

- [ ] **Step 5: Make the resolver test pass (GREEN)**

Run: `rtk proxy python3 -m pytest tests/test_resolver.py -q`
Expected: **PASS** (2 assertions / 1 test). If FAIL, the move in Step 3 dropped a symbol or a helper — fix the move, do not patch the test.

- [ ] **Step 6: Rewire `gate.py` — import the three fns `main()` uses + drop dead imports**

In `orchestrator/gate.py`, immediately after the existing `from semantic_merge import merge_groups` line (line 24), add:
```python
from resolver import find_repo_root, build_workspace_aliases, collect_deps  # cross-file scope (extracted to resolver.py)
```
(`gate.py` already does `sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))` at line 23, so the bare `from resolver import` resolves — same idiom as the `semantic_merge` import.)

Then check for now-dead imports left behind by the move:
```bash
cd ~/Projects/security-gate && for m in glob json re; do echo "== $m =="; command grep -n "\b$m\." orchestrator/gate.py | command grep -v "^2[0-9]:import"; done
```
- `glob` was used ONLY by `build_workspace_aliases` (moved) → if the grep shows no `glob.` use, remove `glob` from the `import argparse, glob, json, os, re, subprocess, sys, tempfile, concurrent.futures as cf` line.
- `json`/`re` are still used by the staying code (report/`parse_findings`) → keep them if the grep shows uses. Keep `os`, `subprocess`, `sys`, `tempfile`, `argparse`, `concurrent.futures` (all still used).
Make the import line reflect exactly what remains used. No dead imports (caveman: no ignored signals).

- [ ] **Step 7: Run full pytest — expect the resolver-unit tests in the OLD files to fail (proves the move bit)**

Run: `rtk proxy python3 -m pytest -q`
Expected: `test_gate.py` and `test_oracle_xfile.py` **FAIL** with `AttributeError: module 'sg_gate' has no attribute 'resolve'` — and the same for **`resolve_through_barrel`** and **`first_party_value_imports`** ONLY. These three are the resolver fns `gate.py` does NOT import (Step 6 imports only the three `main()` uses). **`gate.build_workspace_aliases` and `gate.collect_deps` do NOT raise** — Step 6's `from resolver import ... build_workspace_aliases, collect_deps` binds them as attributes of the `gate` module, so those test sites stay green even before retarget. Everything else green. The three AttributeErrors are expected and fixed in Step 8 (it retargets ALL resolver calls to `resolver.` regardless, so the tests stop depending on gate's re-export) — they confirm `resolve`/`resolve_through_barrel`/`first_party_value_imports` genuinely left `gate.py`.

- [ ] **Step 8: Retarget the resolver calls in the two existing test files**

In BOTH `tests/test_gate.py` and `tests/test_oracle_xfile.py`, add a resolver handle right after the existing `gate = _load("sg_gate", ...)` line:
```python
resolver = _load("sg_resolver", os.path.join(ROOT, "orchestrator", "resolver.py"))
```
Then replace every resolver call with the `resolver.` prefix (word-boundary safe — `gate.resolve\b` does NOT match `gate.resolve_through_barrel`):
```bash
cd ~/Projects/security-gate && for f in tests/test_gate.py tests/test_oracle_xfile.py; do
  sed -i -E 's/\bgate\.(build_workspace_aliases|resolve_through_barrel|first_party_value_imports|collect_deps)\b/resolver.\1/g; s/\bgate\.resolve\b/resolver.resolve/g' "$f"
done
command grep -n "gate\.\(build_workspace_aliases\|resolve\|resolve_through_barrel\|first_party_value_imports\|collect_deps\)" tests/test_gate.py tests/test_oracle_xfile.py
```
Expected from the grep: **no output** (all resolver calls now say `resolver.`). Leave `gate.run_oracle`, `gate.run_oracle_set`, `gate.build_bundle` untouched — those stay in `gate.py` and their tests prove `gate.py` still wires the resolver end-to-end.

- [ ] **Step 9: Rewire `prevent/runner.py`**

In `prevent/runner.py`:
1. Replace line 26 `_gate = _load("sg_gate", os.path.join(ROOT, "orchestrator", "gate.py"))` with:
```python
_resolver = _load("sg_resolver", os.path.join(ROOT, "orchestrator", "resolver.py"))
```
2. Line 73: `aliases = _gate.build_workspace_aliases(repo_root)` → `aliases = _resolver.build_workspace_aliases(repo_root)`
3. Line 82: `deps, dropped = _gate.collect_deps(abs_cf, aliases, DEPTH, MAX_SCOPE)` → `deps, dropped = _resolver.collect_deps(abs_cf, aliases, DEPTH, MAX_SCOPE)`

Verify no other `_gate.` reference remains:
```bash
cd ~/Projects/security-gate && command grep -n "_gate" prevent/runner.py
```
Expected: **no output** (the `_gate` load is fully replaced; `runner.py` never used `gate.py`'s LLM path, only the resolver helpers).

- [ ] **Step 10: Rewire `orchestrator/mapper.py`**

In `orchestrator/mapper.py`, the `_critical()` function (line ~205-207), replace:
```python
    return _load("sg_gate_for_critical", os.path.join(_HERE, "gate.py")).CRITICAL
```
with:
```python
    return _load("sg_resolver_for_critical", os.path.join(_HERE, "resolver.py")).CRITICAL
```
Update its docstring line to say "Reuse resolver.py's CRITICAL regex" instead of "gate.py's".

- [ ] **Step 11: Add the gate.py CLI-wiring smoke (locks `main()`), then run the full suite green**

The unit tests call the resolver fns directly but **never run `gate.py:main()`** — the characteristic failure of an extraction is a moved symbol still referenced in `main()`'s body that isn't in the 3-import set → `NameError` at CLI runtime while pytest stays green. Lock it with a subprocess smoke that drives the three moved fns end-to-end. `--dry-run` returns at gate.py:561 **before** the oracle and LLM, so no `claude` is invoked.

Append to `tests/test_resolver.py` (and add `import subprocess, sys` to its import line — change `import importlib.util, os, tempfile` → `import importlib.util, os, subprocess, sys, tempfile`):
```python
def test_gate_cli_dry_run_drives_moved_resolver_symbols():
    """Locks gate.py main()'s CLI wiring to the moved resolver fns: find_repo_root ->
    build_workspace_aliases -> collect_deps. --dry-run returns before oracle/LLM (gate.py:561),
    so this runs without `claude`. An empty .git/ anchors find_repo_root at the tmpdir
    deterministically (gate.py:171 matches .git dir); resolve() handles the './dep' relative
    import with no alias (gate.py:191)."""
    d = tempfile.mkdtemp()
    os.mkdir(os.path.join(d, ".git"))  # anchor find_repo_root at d (deterministic root)
    open(os.path.join(d, "dep.ts"), "w").write("export function helper() { return 1 }\n")
    open(os.path.join(d, "main.ts"), "w").write("import { helper } from './dep'\nhelper()\n")
    tmpl = os.path.join(d, "t.prompt.txt")
    open(tmpl, "w").write("review {{MODULE_PATH}}\n")  # default --template is read at gate.py:547 before the dry-run check; pass our own to stay hermetic
    r = subprocess.run(
        [sys.executable, os.path.join(ROOT, "orchestrator", "gate.py"),
         os.path.join(d, "main.ts"), "--dry-run", "--template", tmpl],
        capture_output=True, text=True, timeout=60,
    )
    assert r.returncode == 0, f"gate.py --dry-run crashed (main() wiring broke):\n{r.stderr}"
    assert "dep.ts" in r.stdout, f"dep.ts not pulled into scope by collect_deps:\n{r.stdout}"
```

Run: `rtk proxy python3 -m pytest -q`
Expected: **all PASS** (every prior test + `test_resolver.py`'s 2 tests). If the new smoke fails with `NameError` in `r.stderr`, a symbol `main()` uses was not imported in Step 6 — fix the import, do not weaken the test. If `test_mapper.py` fails on CRITICAL, re-check Step 10's path. If `test_gate.py`/`test_oracle_xfile.py` fail, re-check Step 8's retarget. Zero failures, zero errors before committing.

- [ ] **Step 12: Commit**

```bash
cd ~/Projects/security-gate
command git add orchestrator/resolver.py orchestrator/gate.py orchestrator/mapper.py prevent/runner.py tests/test_resolver.py tests/test_gate.py tests/test_oracle_xfile.py
command git commit -m "refactor: extract cross-file resolver from gate.py into resolver.py

pure move (verified zero outbound ref to gate runtime state); gate imports the 3
fns main uses; runner+mapper+resolver-unit tests retarget to resolver.py; oracle/
bundle tests stay on gate.X and prove the wiring. pytest green."
```
Then verify the commit landed:
```bash
command git log --oneline -1
```
Expected: the refactor commit SHA prints.

---

## Task 2: Extract the `claude -p` call into one `run_llm` dispatch fn

**Wave:** 2
**Blocks:** —
**Blocked by:** Task 1

**Files:**
- Create: `orchestrator/llm_runner.py`
- Create: `tests/test_llm_runner.py`
- Modify: `orchestrator/gate.py:352-366` (`one_roll`)
- Modify: `orchestrator/semantic_merge.py:55-69` (`_call_llm`) + its import header

**Why `CompletedProcess`, not text:** `one_roll` returns `stdout` **regardless of return code** (it tolerates a nonzero exit and returns partial output), while `_call_llm` **raises** `MergeError` on nonzero. A dispatch fn that imposed an error policy would change one of them. So `run_llm` returns the raw `CompletedProcess` and each caller keeps its own policy → byte-identical behavior. A text-returning adapter abstraction belongs with the deferred alt-backend work, not here (YAGNI).

- [ ] **Step 1: Write the failing dispatch-fn test (RED)**

Create `tests/test_llm_runner.py`:
```python
"""test_llm_runner.py — the single LLM dispatch fn (refactor 2). Loaded by PATH. Does NOT invoke claude;
monkeypatches subprocess.run to assert the exact argv/env/kwargs the dispatch builds."""
import importlib.util, os

ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))

def _load(name, path):
    spec = importlib.util.spec_from_file_location(name, path)
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod

llm = _load("sg_llm_runner", os.path.join(ROOT, "orchestrator", "llm_runner.py"))

class _Captured:
    def __init__(self): self.args = None; self.kwargs = None
    def __call__(self, *a, **k):
        self.args, self.kwargs = a, k
        class R: stdout = "ok"; returncode = 0; stderr = ""
        return R()

def test_run_llm_builds_exact_claude_argv_and_passes_prompt(monkeypatch):
    cap = _Captured()
    monkeypatch.setattr(llm.subprocess, "run", cap)
    r = llm.run_llm("PROMPT", model="sonnet", effort="medium")
    assert cap.args[0] == ["claude", "-p", "--model", "sonnet", "--effort", "medium",
                           "--dangerously-skip-permissions"]
    assert cap.kwargs["input"] == "PROMPT"
    assert cap.kwargs["timeout"] == 600
    assert cap.kwargs["capture_output"] is True and cap.kwargs["text"] is True
    assert r.stdout == "ok"

def test_run_llm_sets_config_dir_env_only_when_given(monkeypatch):
    cap = _Captured()
    monkeypatch.setattr(llm.subprocess, "run", cap)
    llm.run_llm("P", config_dir="/tmp/sg_cfg")
    assert cap.kwargs["env"]["CLAUDE_CONFIG_DIR"] == "/tmp/sg_cfg"
    cap2 = _Captured()
    monkeypatch.setattr(llm.subprocess, "run", cap2)
    llm.run_llm("P")
    assert "CLAUDE_CONFIG_DIR" not in cap2.kwargs["env"]
```

Run: `rtk proxy python3 -m pytest tests/test_llm_runner.py -q`
Expected: **FAIL** — `llm_runner.py` does not exist yet.

- [ ] **Step 2: Create `orchestrator/llm_runner.py` (GREEN)**

Create `orchestrator/llm_runner.py`:
```python
#!/usr/bin/env python3
"""llm_runner.py — the single LLM-backend dispatch point (the `adapter` seam; modular-hierarchy refactor 2).

DRY: gate.py:one_roll and semantic_merge._call_llm both shelled out an identical
`claude -p --model M --effort E --dangerously-skip-permissions` with the same env/timeout. That call
lives here ONCE. Backend = claude-cli today; alt backends (the deferred elusive-dice phase) extend this
fn, not the call sites.

Returns the raw CompletedProcess so each caller keeps its OWN error policy: one_roll tolerates a nonzero
exit and returns partial stdout; semantic_merge raises MergeError on nonzero. An error-imposing /
text-returning abstraction would change one of them -> not built here (YAGNI). Loaded by PATH."""
import os, subprocess


def run_llm(prompt, model="sonnet", effort="medium", config_dir=None, cwd=None, timeout=600):
    """Run the LLM backend on `prompt`. config_dir -> CLAUDE_CONFIG_DIR (blind catch-test isolation);
    cwd -> working dir (the review bundle lives in /tmp, cwd is the repo). Raises on subprocess failure
    (timeout / ENOENT); the caller decides what a nonzero return code means."""
    env = dict(os.environ)
    if config_dir:
        env["CLAUDE_CONFIG_DIR"] = config_dir
    return subprocess.run(
        ["claude", "-p", "--model", model, "--effort", effort, "--dangerously-skip-permissions"],
        input=prompt, capture_output=True, text=True, timeout=timeout, cwd=cwd, env=env,
    )
```

Run: `rtk proxy python3 -m pytest tests/test_llm_runner.py -q`
Expected: **PASS** (2 tests).

- [ ] **Step 3: Rewire `gate.py:one_roll` to call `run_llm`**

First add the import. After the `from resolver import ...` line added in Task 1 Step 6, add:
```python
from llm_runner import run_llm
```
Then replace the body of `one_roll` (gate.py lines 352-366) with — preserving its exact behavior (returns stdout regardless of return code; returns a roll-labelled string on exception):
```python
def one_roll(path, template, config_dir, idx, cwd=None, model="sonnet", effort="medium"):
    prompt = template.replace("{{MODULE_PATH}}", path)
    try:
        r = run_llm(prompt, model=model, effort=effort, config_dir=config_dir,
                    cwd=cwd or os.path.dirname(path))
        return r.stdout
    except Exception as e:  # noqa: BLE001
        return f"(roll {idx} error: {e})"
```

- [ ] **Step 4: Rewire `semantic_merge._call_llm` to call `run_llm`**

In `orchestrator/semantic_merge.py`, after its `import argparse, json, os, re, subprocess, sys` line (line 21), add the path-load preamble + import (mirrors gate.py; lets the `__main__` self-test resolve it standalone):
```python
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from llm_runner import run_llm  # noqa: E402
```
Then replace the body of `_call_llm` (lines 55-69) with — preserving its exact behavior (raises `MergeError` on subprocess failure AND on nonzero return code; no cwd, so it inherits the process cwd as before):
```python
def _call_llm(prompt, config_dir, model, effort):
    try:
        r = run_llm(prompt, model=model, effort=effort, config_dir=config_dir)
    except Exception as e:  # noqa: BLE001
        raise MergeError(f"claude -p failed: {e}") from e
    if r.returncode != 0:
        raise MergeError(f"claude -p exit {r.returncode}: {r.stderr.strip()[:200]}")
    return r.stdout
```
Keep `subprocess` imported in `semantic_merge.py` only if still used elsewhere — check and drop if dead:
```bash
cd ~/Projects/security-gate && command grep -n "subprocess\." orchestrator/semantic_merge.py
```
If the grep shows no `subprocess.` use remains, remove `subprocess` from the line-21 import. No dead imports.

- [ ] **Step 5: Verify both call sites still self-test, then run the full suite**

Run the semantic_merge built-in self-test (it monkeypatches `_call_llm`, which now wraps `run_llm` — the patch still applies at `_call_llm`, so it must still pass):
```bash
cd ~/Projects/security-gate && rtk proxy python3 orchestrator/semantic_merge.py
```
Expected: its `_unit_checks()` / self-test prints its OK summary, exit 0.

Then the full suite:
```bash
rtk proxy python3 -m pytest -q
```
Expected: **all PASS** (prior tests + `test_resolver.py` + `test_llm_runner.py`). Zero failures.

- [ ] **Step 6: Commit**

```bash
cd ~/Projects/security-gate
command git add orchestrator/llm_runner.py orchestrator/gate.py orchestrator/semantic_merge.py tests/test_llm_runner.py
command git commit -m "refactor: one run_llm dispatch fn for the claude -p call (gate + semantic_merge)

DRY the two byte-identical claude -p call sites onto llm_runner.run_llm; returns raw
CompletedProcess so one_roll keeps nonzero-tolerance and semantic_merge keeps MergeError.
no behavior change, no alt backends. pytest green."
```
Then:
```bash
command git log --oneline -1
```
Expected: the refactor commit SHA prints.

---

## NOT covered / deferred (honest no-ops)

- **Refactor 3 — split `mapper.py` → DETECT Opus-orchestrator + the contract-unification work item.** The spec explicitly assigns this its OWN spec + plan (it is the large DETECT build: 1 Opus orchestrator triaging the attack-surface map to per-target subagents at k=1, then hammering the elusive set; plus mapping the DETECT group-findings `{title, sev, rolls}` → `contract.finding()`). Do NOT touch `mapper.py`'s dispatch half or `gate.py`'s union/report path here beyond the CRITICAL load-target rewire in Task 1 Step 10.
- **Alt LLM backends** (codex / cursor / agy / vibeflare-api) and the "which LLM best/cheapest" comparison — the deferred elusive-1% dice-rolling phase. `run_llm` is backend-parametrizable but ships claude-cli ONLY. No adapter registry, no text-returning abstraction built now.
- **`deps-backend` / `workspace-backend` adapters** (npm/yarn) — WATCH (1-donor each), per the spec. Not built.
- **`build_report`/`build_emit_dict` path rendering** — both render paths via `os.path.relpath(target)` against the runtime cwd, so a target outside the repo (or a deeply nested cwd) yields a filesystem-depth-dependent `../` climb. Display-only today: the ratchet keys on file-**suffix** (`is_ratcheted` `ffile.endswith(cfile)`), so cwd-dependence does NOT affect blocking, and the golden test was made hermetic by using a relative synthetic target (commit `906ebbe`). Since Refactor 1 extracts `find_repo_root` into `resolver.py`, the natural — but SEPARATE, behavior-CHANGING — follow-up is to render report paths repo-relative via `find_repo_root` (absolute fallback outside a repo). NOT part of Refactor 1's pure move; do not fold it in. Deferred.

---

## Self-Review

**1. Spec coverage.** Refactor 1 (extract resolver) → Task 1. Refactor 2 (extract `run_llm`) → Task 2. Refactor 3 + alt backends → "NOT covered / deferred" (spec assigns them their own plan). All in-scope spec items have a task. ✓

**2. Placeholder scan.** No TBD/TODO/"handle edge cases"/"similar to". Every code step pastes complete code or a verbatim-move instruction with exact line ranges; every command has an expected result. ✓

**3. Type/name consistency.** `run_llm(prompt, model="sonnet", effort="medium", config_dir=None, cwd=None, timeout=600)` — defined in Task 2 Step 2, called identically in Steps 3 (`config_dir`, `cwd`) and 4 (`config_dir`, no cwd). Resolver handle named `resolver` in tests, `_resolver` in runner/mapper (matches each file's existing `_gate`/`_load` convention). `CRITICAL` referenced as `resolver.CRITICAL` post-move. ✓

**4. Wave plan.** Two single-task waves; Wave 2 blocked-by Wave 1 (both touch `gate.py` + pilot-before-fanout). No intra-wave file overlap (one task each). Task 1 must be fully green (Step 11) and committed (Step 12) before Task 2 starts. ✓

**5. Test-commit allow-list.** This project commits `tests/` (prior prevent-band commits did). Commit steps stage the exact edited/created test files. ✓

**6. `main()` CLI-wiring lock (the extraction's real risk).** Unit tests hit the resolver fns directly but never run `gate.py:main()`; a moved symbol still referenced in `main()` (esp. the unread-then-read tail 600-628) would `NameError` at CLI runtime while pytest stayed green. Step 11's subprocess `--dry-run` smoke drives `find_repo_root → build_workspace_aliases → collect_deps` end-to-end without `claude`, committed in Step 12 as a permanent lock. So "pytest green" now means "the gate still runs." ✓
