Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@ All notable changes to the claude-plugins project will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Entries are listed newest-first; each plugin section is treated as released when merged to `main`.

### code-review v3.6.1

#### Fixed
- **Local branch review no longer bases its diff on a stale ref, which folded unrelated commits into the review.** `resolve-scope` hardcoded the diff scope to `main...HEAD`. That `main` is the local branch ref, which every worktree of a clone shares, so a worktree inherits whatever commit the primary checkout last left it on. Because `A...B` diffs from `merge-base(A, B)`, a local ref sitting behind the branch's fork point dragged the merge base backwards and pulled every commit that landed on the base in between into the diff — reviewing other people's work as if it were the branch's. The base is now chosen per run between the local ref and `origin/<base>`: both merge bases are ancestors of HEAD along the base branch, so the ref producing the later one is the true fork point. This is correct under either kind of staleness — a local ref behind the fork point, or an `origin/<base>` behind it because the base has unpushed local commits (where preferring the remote ref would fold those commits in instead) — and under both at once, when the local ref and `origin/<base>` have diverged: whichever tip the branch was cut from yields the deeper merge base, so it is selected regardless of which ref that is. A repo with no remote has no second view and keeps its local ref, so remote-less reviews are unchanged.
- The base branch is now detected — `origin/HEAD`, then `main`/`master` remotely, then locally — rather than assumed to be `main`, so repositories whose default branch is `master` (or any name `origin/HEAD` reports) resolve their scope correctly instead of failing against a nonexistent `main`. `--base-ref-override` runs through the same selection, and falls back to the local branch when the named base has no remote-tracking ref rather than emitting an `origin/<ref>` that git cannot resolve.
- `fetch-intent` now reads branch commit subjects from the same fork point instead of the raw local base ref, so intent classification and injection detection no longer receive commits that landed on the base branch and were never part of the change under review.

### code-review v3.6.0

#### Fixed
Expand Down
2 changes: 1 addition & 1 deletion plugins/code-review/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "code-review",
"description": "Code review plugin",
"version": "3.6.0",
"version": "3.6.1",
"author": {
"name": "ClosedLoop",
"email": "support@closedloop.ai"
Expand Down
29 changes: 23 additions & 6 deletions plugins/code-review/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,27 @@ plugins/code-review/
code-review-worker-graph.md Graph-aware variant for the cross-file and design reviewers (Impact Analyzer, Bug Hunter B, fast-path, Design Critic); adds read-only codebase-memory-mcp tools — cross-file usage discovery for the cross-file roles, project-structure/dependency-graph analysis (get_architecture, query_graph) for the Design Critic
commands/
start.md Main /start command (orchestrator)
shallow.md /shallow wrapper — `/start --depth shallow`
deep.md /deep wrapper — `/start --depth deep`
cost.md /cost command — token-cost attribution from session transcripts
skills/
spawn-reviewers/SKILL.md Reviewer-fleet spawn/collection contract at stage_20_spawn_reviewers
verify-findings/SKILL.md Finding-verifier fleet dispatch at stage_23_verify_findings (PLN-722)
singleton-dispatch/SKILL.md Single-agent dispatch for stage_11_extract_signals / stage_15_coverage_critic (PLN-725)
present-local/SKILL.md Local-mode presenter at stage_29_present
fix/SKILL.md Verifies and fixes BLOCKING/HIGH findings from a prior review session
prompts/
github-review.md GitHub-mode constraints and output steps (loaded conditionally)
scripts/
dist/cost-report.mjs Bundled Node cost analyzer for /cost (sources at tools/code-review-cost/)
tools/
prompts/shared_prompt.txt Shared reviewer constraints injected into every agent prompt
prompts/bha_suffix.txt Bug Hunter A reviewer persona and focus areas
prompts/design_critic_suffix.txt Design Critic reviewer role (software-design craftsmanship; always-on at deep tier)
prompts/impact_analyzer_prompt.txt Impact Analyzer reviewer role (FEA-1401 cross-file blast radius; deep tier, signal-gated)
prompts/coverage_critic_prompt.txt Coverage critic role (standard/deep tiers)
prompts/signal_extraction_prompt.txt Signal extraction role (standard/deep tiers)
prompts/verifier_prompt.txt Finding-verifier role (falsify-oriented; PLN-722)
python/code_review_schema.py Canonical Finding + ResultEnvelope schema + validators (PLN-719)
python/test_code_review_schema.py Schema tests + round-trips
python/code_review_helpers.py Deterministic helper CLI (parse-diff, hygiene, partition, route, validate, cache, finalize-result, arbitrate-budget, prepare-run, etc.)
Expand Down Expand Up @@ -90,34 +105,36 @@ Runs a comprehensive code review. Invokes the full pipeline: diff parsing, hygie

| Argument | Behavior |
|---|---|
| _(none)_ | Diff current branch vs `main` |
| _(none)_ | Review the open PR's diff for the current branch; with no open PR, diff the current branch from its fork point off the default branch |
| `staged` | Diff only staged (index) changes |
| `file1 file2 ...` | Diff specific files against `main` |
| `file1 file2 ...` | Diff specific files from the fork point off the default branch |
| `123` | Use PR #123's diff (local output, no posting) |

**Base ref resolution.** The default base branch is *detected*, not assumed: the helper reads the `origin/HEAD` symbolic ref, then probes `origin/main` / `origin/master`, then the same names locally, falling back to `main` only when nothing resolves. The diff runs from the fork point rather than a fixed ref — a clone holds both a local `<base>` and an `origin/<base>`, and either can lag the other (a stale local checkout, or unpushed local base commits). The helper takes the merge base of each against `HEAD` and uses whichever ref yields the *later* one, since that is the true fork point. This keeps commits that landed on the base branch after the fork out of the review diff under either kind of staleness.

**Mode flags:**

| Flag | Description |
|---|---|
| `--github` | GitHub CI mode: auto-detect PR from branch or accept explicit PR number, post inline comments via file-based handoff |
| `--github 123` | GitHub CI mode: review PR #123 specifically |
| `--hygiene-only` | Run only the deterministic hygiene checks. Zero LLM tokens consumed. Fast. |
| `--base <ref>` | Override the base branch for diffing (default: `main`) |
| `--base <ref>` | Override the base branch for diffing (default: the repository's detected default branch) |
| `--since-last-review` | Review only commits added since the last successful review (local mode only) |
| `--full-review` | Force a full diff even when auto-incremental mode would narrow the scope |
| `--depth shallow\|standard\|deep` | Reviewer-fleet tier. Default `standard`. See **Depth Tiers** below |

**Examples:**

```bash
/start # All changes on current branch vs main
/start # Open PR diff, else changes on current branch since its fork point
/start staged # Only staged changes
/start src/auth.ts src/user.ts # Specific files
/start 123 # PR #123 diff locally
/start --github # CI: auto-detect PR, post comments
/start --github 123 # CI: PR #123, post comments
/start --hygiene-only # Hygiene checks only
/start --base develop # Diff against develop instead of main
/start --base develop # Diff against develop instead of the default branch
/start --since-last-review # Only new commits since last review
/start --full-review # Disable incremental narrowing
```
Expand Down Expand Up @@ -309,7 +326,7 @@ Overrides survive across runs while the file content matches and the 90-day TTL

**Re-assert is best-effort against finding_id drift.** Finding IDs are assigned as `<reviewer>_f<index>` where `<index>` is the reviewer's emission position. Across re-runs the LLM may reorder or drop findings, so an override written against `bha_f3` on run N may map to a different finding (or no finding) on run N+1. The content-hash anchor prevents promoting an unrelated finding at a different line — but the common drift case is the override silently no-ops. Two mitigations: (1) re-assert and re-run immediately so the override is honored against the same emission set, and (2) inspect the verify-prepare manifest for `override_hits` / `override_invalidated` to confirm the override landed.

The presenter (local mode `start.md`, GitHub mode `code-review-verifier-stats.md`) surfaces:
The presenter (local mode: the `present-local` skill, GitHub mode: `github-review.md` Step 6e, which writes `.closedloop-ai/code-review-verifier-stats.md`) surfaces:

- Per-reviewer FP rate (`stats.verification.by_reviewer[*].fp_rate`)
- Override count per reviewer (`stats.verification.by_reviewer[*].re_asserted`)
Expand Down
2 changes: 1 addition & 1 deletion plugins/code-review/commands/start.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Run a multi-agent code review with partitioned deep review, deterministic hygien
## Usage

```
/start # Review open PR diff for current branch, or main...HEAD if no PR
/start # Review open PR diff for current branch, or the diff since the branch forked from the default branch if no PR
/start staged # Review only staged changes
/start file1 file2 # Review specific files
/start 123 # Review PR #123 diff locally (no posting)
Expand Down
115 changes: 102 additions & 13 deletions plugins/code-review/tools/python/code_review_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -244,8 +244,8 @@ def _resolve_pr_scope(
"""Resolve diff scope fields for a given PR number.

When *allow_guess_fallback* is ``True`` (explicit ``--pr-number``), a
``CalledProcessError`` from ``gh pr view`` falls back to
``base_ref="main"`` / ``head_ref=current_branch``. When ``False``
``CalledProcessError`` from ``gh pr view`` falls back to the repo's
default branch / ``head_ref=current_branch``. When ``False``
(auto-detect path), errors propagate so the caller can revert to branch
scope.
"""
Expand All @@ -256,12 +256,12 @@ def _resolve_pr_scope(
capture_output=True, text=True, check=True,
)
lines = result.stdout.strip().splitlines()
base_ref = lines[0].strip() if len(lines) > 0 else "main"
base_ref = lines[0].strip() if len(lines) > 0 else _resolve_default_base_ref()
head_ref = lines[1].strip() if len(lines) > 1 else current_branch
except subprocess.CalledProcessError:
if not allow_guess_fallback:
raise
base_ref = "main"
base_ref = _resolve_default_base_ref()
head_ref = current_branch

return {
Expand Down Expand Up @@ -295,6 +295,92 @@ def _git_rev_parse(ref: str) -> str | None:
return None


def _resolve_default_base_ref() -> str:
"""Return the repository's default branch *name* (e.g. ``main``, ``master``).

Probes, in order: the ``origin/HEAD`` symbolic ref (what the remote
reports as its default), then well-known remote branches, then the
same names locally for repos with no ``origin``. Falls back to
``main`` when nothing resolves, preserving the historical default.

Returns a bare branch name, not a ref — callers pair it with
:func:`_base_rev` to get the revision to diff against. ``base_ref``
travels through ``scope.json`` as a name because consumers such as
``compute-hashes`` origin-qualify it themselves.
"""
try:
symbolic = _run_git(
["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
).strip()
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
symbolic = ""
if symbolic:
# "origin/main" -> "main"
_, _, name = symbolic.partition("/")
if name:
return name
for candidate in ("main", "master"):
if _git_rev_parse(f"origin/{candidate}"):
return candidate
for candidate in ("main", "master"):
if _git_rev_parse(candidate):
return candidate
return "main"


def _merge_base(a: str, b: str) -> str | None:
"""Return the merge base of *a* and *b*, or ``None`` if there isn't one.

``None`` covers both "no common ancestor" and "a ref does not resolve",
which callers treat alike: neither yields a usable fork point.
"""
try:
return _run_git(["merge-base", a, b]).strip() or None
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return None


def _is_ancestor(commit: str, descendant: str) -> bool:
"""Whether *commit* is *descendant* or one of its ancestors."""
try:
_run_git(["merge-base", "--is-ancestor", commit, descendant])
return True
except (subprocess.CalledProcessError, FileNotFoundError, OSError):
return False


def _base_rev(base_ref: str, head_rev: str = "HEAD") -> str:
"""Return the revision to diff *head_rev* against for branch *base_ref*.

``<base>...<head>`` diffs from ``merge-base(<base>, <head>)``, so the base
ref matters only through the fork point it produces. A clone holds two
views of the same base branch, and either one can lag the other:

* The local ``<base_ref>`` is shared by every worktree of a clone, so it
carries whatever commit the primary checkout last left it on. When it
sits behind the fork point, the merge base walks backwards and folds
every commit that landed on the base in between into the review diff.
* ``origin/<base_ref>`` lags whenever the base has unpushed local commits.
Branch off those and the fork point is ahead of the remote ref, which
folds the unpushed base commits into the diff instead.

Both merge bases are ancestors of *head_rev* along the base branch, so the
later of the two is the true fork point — take the ref that produces it,
which is correct under either kind of staleness. Falls back to whichever
ref resolves when only one does (e.g. a remote-less repo, where the local
ref is the only truth).
"""
remote_rev = f"origin/{base_ref}"
local_mb = _merge_base(base_ref, head_rev)
remote_mb = _merge_base(remote_rev, head_rev)
if remote_mb is None:
return base_ref
if local_mb is None:
return remote_rev
# Equal bases resolve to the remote ref; the two ranges are identical.
return remote_rev if _is_ancestor(local_mb, remote_mb) else base_ref


# Startup-GC age guard: a PR-head worktree directory is reclaimed as an
# abort-orphan only once it is older than this. Set far above any real
# review wall-time (runs are minutes, not hours) so a concurrent in-flight
Expand Down Expand Up @@ -4791,7 +4877,7 @@ def cmd_resolve_scope(args: argparse.Namespace) -> int:
current_branch = "HEAD"

diff_scope = ""
base_ref = "main"
base_ref = _resolve_default_base_ref()
head_ref = current_branch
review_branch = current_branch
diff_tip = "HEAD"
Expand Down Expand Up @@ -4844,21 +4930,23 @@ def cmd_resolve_scope(args: argparse.Namespace) -> int:
pr_auto_detected = True
except (subprocess.CalledProcessError, FileNotFoundError,
OSError, ValueError):
# Any failure: fall back to branch scope
# Any failure: fall back to branch scope. base_ref is
# untouched here — every pr_scope assignment below the
# fetch is unreachable once either raise-point fires.
pr_number = None
pr_auto_detected = False
diff_scope = "main...HEAD"
diff_scope = f"{_base_rev(base_ref)}...HEAD"
scope_kind = "branch"
else:
diff_scope = "main...HEAD"
diff_scope = f"{_base_rev(base_ref)}...HEAD"
scope_kind = "branch"
elif scope_args.strip() == "staged":
diff_scope = "--cached"
scope_kind = "staged"
else:
# Treat scope_args as file paths
files = scope_args.strip()
diff_scope = f"main...HEAD -- {files}"
diff_scope = f"{_base_rev(base_ref)}...HEAD -- {files}"
path_filter = f"-- {files}"
scope_kind = "file_paths"

Expand All @@ -4870,11 +4958,12 @@ def cmd_resolve_scope(args: argparse.Namespace) -> int:
# Apply base-ref override if provided
if base_ref_override:
if scope_kind == "pr":
diff_scope = f"origin/{base_ref_override}...origin/{head_ref}"
override_head = f"origin/{head_ref}"
diff_scope = f"{_base_rev(base_ref_override, override_head)}...{override_head}"
elif path_filter:
diff_scope = f"origin/{base_ref_override}...HEAD {path_filter}"
diff_scope = f"{_base_rev(base_ref_override)}...HEAD {path_filter}"
else:
diff_scope = f"origin/{base_ref_override}...HEAD"
diff_scope = f"{_base_rev(base_ref_override)}...HEAD"
base_ref = base_ref_override

# Worktree isolation for local PR review. The diff is computed from the
Expand Down Expand Up @@ -4987,7 +5076,7 @@ def cmd_fetch_intent(args: argparse.Namespace) -> int:
elif scope_kind == "branch":
try:
result = subprocess.run(
["git", "log", f"{base_ref}..{diff_tip}",
["git", "log", f"{_base_rev(base_ref, diff_tip)}..{diff_tip}",
"--oneline", "--no-merges", "--format=%s"],
capture_output=True, text=True, check=True,
)
Expand Down
Loading
Loading