Skip to content
Closed
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
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.1.0",
"version": "3.1.1",
"author": {
"name": "ClosedLoop",
"email": "support@closedloop.ai"
Expand Down
4 changes: 2 additions & 2 deletions 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 origin/main...HEAD 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 Expand Up @@ -344,7 +344,7 @@ These notes annotate the run-plan stages with anything not obvious from the plan

- **stage_01_setup**: already executed in stage 0b (which captured stdout and wrote `setup.json` itself). The walker treats this as a no-op; the run plan's `stdout` field is `None` for this stage because no shell redirect is correct here.
- **stage_02_prep_assets**: copies `shared_prompt.txt` and `bha_suffix.txt` from `<PLUGIN_ROOT>/tools/prompts/` to `<CR_DIR>`. Both cache and non-cache paths use these assets.
- **stage_03_resolve_scope**: writes `<CR_DIR>/scope.json` with `diff_scope`, `base_ref`, `head_ref`, `review_branch`, `diff_tip`, `pr_number`, `path_filter`, `scope_kind`, `pr_auto_detected`, `head_sha`, `review_root`, `worktree_path`. After this stage, run `finalize-cache` to populate `<CR_DIR>/cache_config.json`. The walker uses these for token resolution downstream. **PR-head worktree (local PR review):** for `MODE=local` PR reviews (`scope_kind == "pr"`, not hygiene-only) resolve-scope isolates source reads so reviewer/verifier agents read the PR head, not the operator's working tree. The diff is computed from the fetched remote refs (`origin/<base>...origin/<head>`); reading the working tree is safe **only** when it already IS the PR head with a clean tree. Otherwise resolve-scope materializes a detached git worktree at the PR head SHA under `<CR_DIR>/pr_head_worktree` and records its absolute path in `review_root`/`worktree_path`; reviewer prompts and verifier inputs then read **source** under `<REVIEW_ROOT>`. **Fail-closed:** if isolation is required but cannot be established (PR head unresolvable, or `git worktree add` fails), resolve-scope returns non-zero and the run aborts (`on_failure: abort`) rather than silently review the wrong branch — the operator is told to check out the PR branch or fix the git error. `review_root` is empty (agents read the working tree) only for the already-at-head-and-clean case, staged/file/branch scope, hygiene-only, and GitHub mode (where the runner already checks out the head). **Worktree lifecycle:** resolve-scope runs a startup GC (`_gc_stale_pr_head_worktrees`) that reclaims orphaned `cr-*/pr_head_worktree` checkouts from prior runs that aborted before teardown; `stage_30_footer` tears down the current run's worktree (validating the path equals the canonical `<CR_DIR>/pr_head_worktree` before the destructive removal). Because the walker can abort before the footer, the next run's startup GC is the backstop — a leaked worktree is never silently reused. Graph-aware reviewers run grep-only (`GRAPH_PROJECT=""`) whenever `review_root` is set, since the knowledge graph indexes the operator checkout, not the PR head.
- **stage_03_resolve_scope**: writes `<CR_DIR>/scope.json` with `diff_scope`, `base_ref`, `head_ref`, `review_branch`, `diff_tip`, `pr_number`, `path_filter`, `scope_kind`, `pr_auto_detected`, `head_sha`, `review_root`, `worktree_path`. After this stage, run `finalize-cache` to populate `<CR_DIR>/cache_config.json`. The walker uses these for token resolution downstream. **Local branch/file base:** for `MODE=local` branch and file-path scope (no PR), resolve-scope bases the diff on the live remote-tracking ref `origin/<default>` (default branch auto-detected via `origin/HEAD`), not the local default branch — in a linked worktree the local `main` is frozen at workspace-creation time and would otherwise inflate the diff to every PR merged since. When the local default branch has drifted behind its origin counterpart it prints a `⚠️ local '<branch>' is N commits behind …` warning to stderr. The PR-detected, staged, `--base` override, and GitHub paths are unaffected. **PR-head worktree (local PR review):** for `MODE=local` PR reviews (`scope_kind == "pr"`, not hygiene-only) resolve-scope isolates source reads so reviewer/verifier agents read the PR head, not the operator's working tree. The diff is computed from the fetched remote refs (`origin/<base>...origin/<head>`); reading the working tree is safe **only** when it already IS the PR head with a clean tree. Otherwise resolve-scope materializes a detached git worktree at the PR head SHA under `<CR_DIR>/pr_head_worktree` and records its absolute path in `review_root`/`worktree_path`; reviewer prompts and verifier inputs then read **source** under `<REVIEW_ROOT>`. **Fail-closed:** if isolation is required but cannot be established (PR head unresolvable, or `git worktree add` fails), resolve-scope returns non-zero and the run aborts (`on_failure: abort`) rather than silently review the wrong branch — the operator is told to check out the PR branch or fix the git error. `review_root` is empty (agents read the working tree) only for the already-at-head-and-clean case, staged/file/branch scope, hygiene-only, and GitHub mode (where the runner already checks out the head). **Worktree lifecycle:** resolve-scope runs a startup GC (`_gc_stale_pr_head_worktrees`) that reclaims orphaned `cr-*/pr_head_worktree` checkouts from prior runs that aborted before teardown; `stage_30_footer` tears down the current run's worktree (validating the path equals the canonical `<CR_DIR>/pr_head_worktree` before the destructive removal). Because the walker can abort before the footer, the next run's startup GC is the backstop — a leaked worktree is never silently reused. Graph-aware reviewers run grep-only (`GRAPH_PROJECT=""`) whenever `review_root` is set, since the knowledge graph indexes the operator checkout, not the PR head.
- **stage_07_auto_incremental**: runs **before** `stage_05_parse_diff` (its array position is between `stage_04_finalize_cache` and `stage_05_parse_diff`). This ordering matters: any `diff_scope` override must be applied to the cached `<DIFF_SCOPE>` token BEFORE parse-diff and extract-patches materialize `diff_data.json` and `patches_all.txt`, otherwise downstream stages see full-PR diff data alongside a narrowed token. The stage retains its `_07_` id as a stable label; execution order follows array position. Writes `<CR_DIR>/auto_incremental.json` with optional `diff_scope` (override) and `review_mode_line`. If `diff_scope` is non-null, update the cached `<DIFF_SCOPE>` token. Print `review_mode_line` (always) and, if `pr_auto_detected` was true in `scope.json`, print `"Auto-detected PR #<PR_NUMBER> for branch <REVIEW_BRANCH>."`.
- **stage_08_fetch_intent**: the helper writes `intent_context.json` into `cr_dir` itself; its stdout is a small `{path, source}` summary that the walker discards. The run plan's `stdout` field is `None` here because redirecting stdout to `intent_context.json` would corrupt the file by overwriting the helper's structured payload with the summary.
- **stage_09_detect_injection** (PLN-720): scores PR title/body/commits against the canonical 9-pattern catalogue and writes `<CR_DIR>/injection_report.json`. On severity ≥ Medium (score ≥ 30), rewrites `<CR_DIR>/intent_context.json` in place with `quarantine: true` and redacted fields. On severity ≥ High (score ≥ 70), also writes `<CR_DIR>/agent_injection-detector.json` containing a canonical `InjectionAttempt` finding — the `agent_*.json` naming makes `cmd_collect_findings` pick it up via the standard glob with no extra wiring. Always appends one JSONL entry to `.closedloop-ai/injection-log.jsonl` (90-day TTL, swept on read). `on_failure: continue` is intentional — a detector crash must never abort the pipeline.
Expand Down
70 changes: 67 additions & 3 deletions plugins/code-review/tools/python/code_review_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,52 @@ def _git_rev_parse(ref: str) -> str | None:
return None


def _detect_default_branch() -> str:
"""Repo default branch name (e.g. "main"), via origin/HEAD; "main" fallback."""
out = subprocess.run(
["git", "symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"],
capture_output=True, text=True,
)
ref = out.stdout.strip() # e.g. "origin/main"
return ref.split("/", 1)[1] if ref.startswith("origin/") else "main"


def _resolve_local_base(default: str) -> str:
"""Base ref to use in ``<base>...HEAD`` for local branch/file scope.

Prefer the remote-tracking ref ``origin/<default>`` over the local branch. In
linked worktrees (e.g. Conductor) the local default branch is frozen at
workspace-creation time and silently drifts hundreds of commits behind,
inflating the review diff to every PR merged since. A best-effort fetch keeps
``origin/<default>`` current; fall back to the local ref when offline / when
the remote ref does not resolve (e.g. no remote configured).
"""
subprocess.run(
["git", "fetch", "origin", default],
capture_output=True, text=True, # best-effort; ignore failure
)
return f"origin/{default}" if _git_rev_parse(f"origin/{default}") else default


def _warn_if_base_drifted(default_branch: str, scope_base: str) -> None:
"""Warn on stderr when local *default_branch* is behind its origin ref.

Defense-in-depth: surfaces the worktree stale-base situation that base
resolution silently corrects, so a wrong/inflated diff is visible instead of
being a silent surprise in the review output.
"""
behind = subprocess.run(
["git", "rev-list", "--count", f"{default_branch}..origin/{default_branch}"],
capture_output=True, text=True,
).stdout.strip()
if behind.isdigit() and int(behind) > 0:
print(
f"⚠️ local '{default_branch}' is {behind} commits behind "
f"origin/{default_branch}; reviewing against {scope_base}",
file=sys.stderr,
)


# 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 @@ -4817,6 +4863,24 @@ def cmd_resolve_scope(args: argparse.Namespace) -> int:
)

elif mode == "local":
# Detect the repo default branch (not hardcoded "main") and keep
# base_ref as the plain name for the <BASE_REF> token / STATE_KEY.
default_branch = _detect_default_branch()
base_ref = default_branch

def _branch_base() -> str:
"""Live base ref for branch/file scope, warning on local drift.

Prefer origin/<default> over the possibly-frozen local default
branch (see _resolve_local_base). Resolved only when the diff is
actually based on the local default — never on the PR-detected or
staged paths, so neither does a needless fetch nor a misleading
drift warning.
"""
sb = _resolve_local_base(default_branch)
_warn_if_base_drifted(default_branch, sb)
return sb

if not scope_args or scope_args.strip() in ("", "branch"):
# Try auto-detecting an open PR for the current branch
detected_pr = _detect_open_pr()
Expand Down Expand Up @@ -4845,18 +4909,18 @@ def cmd_resolve_scope(args: argparse.Namespace) -> int:
# Any failure: fall back to branch scope
pr_number = None
pr_auto_detected = False
diff_scope = "main...HEAD"
diff_scope = f"{_branch_base()}...HEAD"
scope_kind = "branch"
else:
diff_scope = "main...HEAD"
diff_scope = f"{_branch_base()}...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"{_branch_base()}...HEAD -- {files}"
path_filter = f"-- {files}"
scope_kind = "file_paths"

Expand Down
Loading
Loading