Skip to content

Commit d08d61f

Browse files
eduralphclaude
andcommitted
feat(reviewer): ground on the per-cycle worktree, not a stale sibling checkout (#120)
The reviewer grounded citations on $PDCA_TARGET = the human's sibling working checkout, which can lag origin/<base> (→ a false-blocking "patch cannot apply" C4, although the patch applied cleanly to the base the gates actually used) or be sandbox-unreadable (→ no target grounding). The gates and builder already run off a fresh per-cycle worktree pinned to origin/<base>; the reviewer didn't. Make _reviewer_target prefer the per-cycle worktree (#94) — it is fetched + pinned to <base_remote>/<base> and carries the patch, so the reviewer grounds on the SAME base the gates ran against. When worktree isolation is off / the target isn't a git checkout, fall back to the sibling checkout but `git fetch` it first so a lagging sibling can't drift the grounding — refs only, NEVER reset/checkout the human's working tree. Both the main reviewer and the advisory leaves (#64) route through _reviewer_target, so both are fixed. Tests (ReviewerTargetAccess): prefers the worktree when present; the sibling fallback fetches without mutating the working tree (real git — uncommitted + untracked work preserved). Full offline suite: 197 OK. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: Eduard Ralph <15236434+eduralph@users.noreply.github.com>
1 parent 4228b3b commit d08d61f

2 files changed

Lines changed: 52 additions & 7 deletions

File tree

template/src/pdca_harness/leaves.py

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -386,21 +386,34 @@ def reviewer_input_paths(d: Path) -> list[Path]:
386386

387387

388388
def _reviewer_target(d: Path, cfg: Config) -> Path | None:
389-
"""The local target checkout the reviewer grounds its citations on, or None (#75).
389+
"""The local target checkout the reviewer grounds its citations on, or None (#75/#120).
390390
391-
Single-sourced from the brief's "Repo + branch target" via the same resolution
392-
publish uses (``_checkout_path`` — configured ``[publisher.checkouts]`` or the
393-
sibling convention). Returned only if it exists on disk; the reviewer is told to
394-
ground against ``$PDCA_TARGET`` and not to wander into other checkouts. Best-effort:
395-
any failure (no target, unresolved) yields None and the reviewer falls back to the diff.
391+
Prefer the per-cycle **worktree** (#94): it is fetched + pinned to
392+
``<base_remote>/<base>`` and carries the patch, so the reviewer grounds on the *same*
393+
base the gates ran against — not the human's sibling working checkout, which can lag
394+
``origin/<base>`` (a false "patch cannot apply" C4) or be sandbox-unreadable (#120).
395+
396+
When no worktree exists (isolation off / non-git target), fall back to the resolved
397+
sibling checkout — but first ``git fetch`` it so grounding sees the current base. The
398+
fetch is **non-destructive** (refs only): never ``reset``/``checkout`` the human's
399+
working tree. Best-effort: any failure yields None and the reviewer grounds on the diff.
396400
"""
401+
wt = worktree.path(d, cfg)
402+
if wt is not None:
403+
return wt
397404
from . import publish # lazy: publish imports leaves, avoid an import cycle
398405
try:
399406
repo_spec, _base, _slug = publish._resolve_target(d)
400407
if not repo_spec:
401408
return None
402409
p = publish._checkout_path(cfg, repo_spec)
403-
return p if p.exists() else None
410+
if not p.exists():
411+
return None
412+
# Refresh refs so a lagging sibling doesn't drift the reviewer's grounding; do NOT
413+
# touch the working tree (it is the human's checkout). Best-effort.
414+
subprocess.run(["git", "-C", str(p), "fetch", cfg.base_remote],
415+
capture_output=True, text=True)
416+
return p
404417
except Exception: # noqa: BLE001 — grounding access is best-effort, never fatal
405418
return None
406419

template/tests/test_driver_slice.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
import tempfile
1616
import unittest
1717
from pathlib import Path
18+
from unittest import mock
1819

1920
from pdca_harness import act, assemble, driver, gates, publish, queue, leaves, signoff, state
2021
from pdca_harness.config import DEFAULT_CLOSE_DISPOSITIONS, Config, LeafConfig
@@ -256,6 +257,37 @@ def test_review_prompt_grounds_on_pdca_target(self) -> None:
256257
self.assertIn("$PDCA_TARGET", leaves._REVIEW_PROMPT)
257258
self.assertIn("do NOT search other checkouts", leaves._REVIEW_PROMPT)
258259

260+
def test_prefers_worktree_over_sibling(self) -> None:
261+
# When a per-cycle worktree exists it is the grounding target — pinned to the gate
262+
# base + patch applied — never the human's (possibly stale) sibling checkout (#120).
263+
wt = self.tmp / "checkout.pdca-wt"
264+
wt.mkdir()
265+
with mock.patch.object(leaves.worktree, "path", return_value=wt):
266+
self.assertEqual(leaves._reviewer_target(self.d, self.cfg), wt)
267+
268+
def test_sibling_fallback_fetches_without_touching_working_tree(self) -> None:
269+
# Worktree off: ground on the sibling, but only `git fetch` it — NEVER reset or
270+
# checkout the human's working tree (#120). Real git, no network.
271+
self.cfg.worktree = False
272+
self.cfg.base_remote = "origin"
273+
origin = self.tmp / "origin.git"
274+
repo = self.tmp / "myrepo"
275+
subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True)
276+
subprocess.run(["git", "clone", "-q", str(origin), str(repo)], check=True)
277+
run = lambda *a: subprocess.run(["git", "-C", str(repo), *a], check=True,
278+
capture_output=True)
279+
run("config", "user.email", "t@e.com"); run("config", "user.name", "T")
280+
(repo / "f.txt").write_text("base\n", encoding="utf-8")
281+
run("add", "-A"); run("commit", "-q", "-m", "base"); run("branch", "-M", "main")
282+
run("push", "-q", "-u", "origin", "main")
283+
(repo / "f.txt").write_text("human edit\n", encoding="utf-8") # uncommitted work
284+
(repo / "untracked.txt").write_text("x\n", encoding="utf-8")
285+
self.cfg.repo_checkouts = {"org/myrepo": str(repo)}
286+
got = leaves._reviewer_target(self.d, self.cfg)
287+
self.assertEqual(got, repo)
288+
self.assertEqual((repo / "f.txt").read_text(encoding="utf-8"), "human edit\n")
289+
self.assertTrue((repo / "untracked.txt").exists()) # fetch is refs-only
290+
259291

260292
class AdvisoryReviewResilience(unittest.TestCase):
261293
"""A failed/interrupted reviewer must degrade to a §6 NEEDS-HUMAN, never crash

0 commit comments

Comments
 (0)