diff --git a/CHANGELOG.md b/CHANGELOG.md
index 17a6bfe..530b6a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,17 @@
### Added
+- Added the **unified-review** skill that fuses CRG graph context with the
+ ai-code-review three-layer scoring methodology and the gstack-review
+ fix-first workflow. The skill is read-only: every finding waits for a
+ manual fix decision. Ships with language / manual-review / specialist
+ checklists under `skills/unified-review/references/`.
+- Added three MCP tools backing the unified-review workflow:
+ `score_review_tool` (objective Layer-2 metrics with good/warn/fail grades),
+ `dedupe_findings_tool` (fingerprint dedup, multi-source confidence boost,
+ PR quality score), and `generate_report_tool` (standalone
+ `code-review-report.html`). See `code_review_graph/scoring.py`.
+
- Added a Voyage AI embedding provider (`--provider voyage`, key from
`VOYAGE_API_KEY`, opt-in request throttling via
`CRG_VOYAGE_MIN_INTERVAL_SEC`). Embeddings are now persisted after each
diff --git a/code_review_graph/assets/report-template.html b/code_review_graph/assets/report-template.html
new file mode 100644
index 0000000..696dadb
--- /dev/null
+++ b/code_review_graph/assets/report-template.html
@@ -0,0 +1,124 @@
+
+
+
+
+
+Code Review Report
+
+
+
+
+
+
+
diff --git a/code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md b/code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md
index 2d4a4e1..e3e27d7 100644
--- a/code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md
+++ b/code_review_graph/docs/LLM-OPTIMIZED-REFERENCE.md
@@ -24,10 +24,19 @@ Fetch PR diff -> detect_changes_tool -> get_affected_flows_tool -> structured re
Never include full files unless explicitly asked.
+
+Full three-layer review: 1) get_minimal_context_tool + build_or_update_graph_tool + get_review_context_tool + detect_changes_tool for graph context; 2) Layer-1 chain decomposition across 8 categories + gstack CRITICAL sub-pass; 3) score_review_tool for objective Layer-2 metrics; 4) specialist subagents (diff >= 50 lines); 5) dedupe_findings_tool to merge; 6) READ-ONLY manual adjudication per severity; 7) generate_report_tool to write code-review-report.html. Read .code-review.yaml for tier (fast/standard/strict). Target: <=8 tool calls, <=1200 tokens.
+
+
+
+score_review_tool returns objective metrics (sql_risk, exception_coverage, redundancy_rate, high_risk_density, vulnerability_risk) with good/warn/fail grades + llm_judged list. dedupe_findings_tool merges findings by path:line:category fingerprint, boosts multi-source confidence (+1 cap 10), computes PR quality score. generate_report_tool writes the standalone HTML report.
+
+
Core MCP tools: get_minimal_context_tool, detect_changes_tool, get_review_context_tool, get_impact_radius_tool, query_graph_tool, semantic_search_nodes_tool, get_architecture_overview_tool, get_affected_flows_tool, list_flows_tool, list_communities_tool, refactor_tool, build_or_update_graph_tool, run_postprocess_tool, embed_graph_tool, list_graph_stats_tool, get_docs_section_tool
+Unified-review MCP tools: score_review_tool, dedupe_findings_tool, generate_report_tool
MCP prompts (5): review_changes, architecture_map, debug_issue, onboard_developer, pre_merge_check
-Skills: build-graph, debug-issue, explore-codebase, refactor-safely, review-changes, review-delta, review-pr
+Skills: build-graph, debug-issue, explore-codebase, refactor-safely, review-changes, review-delta, review-pr, unified-review
CLI: code-review-graph [install|init|build|update|status|watch|visualize|serve|mcp|wiki|detect-changes|postprocess|embed|register|unregister|repos|eval|daemon]
Token efficiency: Prefer detail_level="minimal" where available. Always call get_minimal_context_tool first. Some review/context tools return compact estimated context_savings metadata.
diff --git a/code_review_graph/main.py b/code_review_graph/main.py
index 5c2ed92..a3e4e1d 100644
--- a/code_review_graph/main.py
+++ b/code_review_graph/main.py
@@ -33,9 +33,11 @@ from .tools import (
apply_refactor_func,
build_or_update_graph,
cross_repo_search_func,
+ dedupe_findings_func,
detect_changes_func,
embed_graph,
find_large_functions,
+ generate_report_func,
generate_wiki_func,
get_affected_flows_func,
get_architecture_overview_func,
@@ -58,6 +60,7 @@ from .tools import (
query_graph,
refactor_func,
run_postprocess,
+ score_review_func,
semantic_search_nodes,
traverse_graph_func,
with_provenance,
@@ -686,6 +689,108 @@ async def detect_changes_tool(
return await coro
+@mcp.tool()
+async def score_review_tool(
+ changed_files: Optional[list[str]] = None,
+ base: str = "HEAD~1",
+ include_churn: bool = True,
+ repo_root: Optional[str] = None,
+ detail_level: str = "standard",
+) -> dict:
+ """Compute objective Layer-2 review metrics for changed files.
+
+ Runs the git-history / graph risk factors plus the heuristic metrics
+ (SQL risk, exception coverage, redundancy, high-risk density,
+ vulnerability) that back the unified-review scoring. LLM-judged
+ metrics (requirement coverage, logic alignment, trust boundaries) are
+ reported in ``llm_judged`` for the calling agent to fill in.
+
+ Offloaded to a thread via ``asyncio.to_thread`` — runs `git log`
+ subprocesses and graph queries that can take several seconds.
+
+ Args:
+ changed_files: Files to score (auto-detected from git diff if
+ omitted).
+ base: Git ref to diff against. Default: HEAD~1.
+ include_churn: Include git-churn risk factors. Default: True.
+ repo_root: Repository root path. Auto-detected if omitted.
+ detail_level: "standard" for full output, "minimal" for
+ token-efficient summary. Default: standard.
+ """
+ root = _resolve_repo_root(repo_root)
+
+ def _run() -> dict:
+ return with_provenance(score_review_func(
+ changed_files=changed_files, base=base,
+ include_churn=include_churn, repo_root=root,
+ detail_level=detail_level,
+ ), root)
+
+ return await asyncio.to_thread(_run)
+
+
+@mcp.tool()
+def dedupe_findings_tool(
+ findings: list,
+ suppress_prior: Optional[list] = None,
+ repo_root: Optional[str] = None,
+) -> dict:
+ """Merge review findings by fingerprint and boost multi-source confidence.
+
+ Findings with the same ``path:line:category`` fingerprint are merged
+ (highest confidence wins); findings confirmed by more than one source
+ (main review + specialist subagents) get confidence +1 (cap 10).
+ Low-confidence findings are routed to the appendix or suppressed, and a
+ PR quality score is computed as ``max(0, 10 - (critical*2 +
+ informational*0.5))``.
+
+ Args:
+ findings: Raw finding dicts with ``path``, ``category``,
+ ``severity``, ``confidence`` and optional ``source``/``line``.
+ suppress_prior: Previously user-skipped findings (from a prior
+ review-log) to suppress when their file has not changed.
+ repo_root: Repository root path. Auto-detected if omitted.
+ """
+ return dedupe_findings_func(
+ findings=findings, suppress_prior=suppress_prior,
+ repo_root=repo_root,
+ )
+
+
+@mcp.tool()
+async def generate_report_tool(
+ review_data: dict,
+ output_path: Optional[str] = None,
+ repo_root: Optional[str] = None,
+) -> dict:
+ """Generate a self-contained HTML code review report.
+
+ Injects ``review_data`` (output of ``score_review_tool`` plus
+ ``dedupe_findings_tool`` results and free-form verdict/tier/scope) into
+ the bundled ``report-template.html`` as ``{{REPORT_DATA}}`` and writes
+ the standalone file (default ``repo_root/code-review-report.html``).
+
+ Offloaded to a thread via ``asyncio.to_thread`` — rendering loads the
+ template asset and writes the output file.
+
+ Args:
+ review_data: Review data dict (metrics, findings, verdict, tier,
+ scope, files, baseline, timestamp, manual_review).
+ output_path: Output file path. Defaults to
+ ``/code-review-report.html``.
+ repo_root: Repository root path. Auto-detected if omitted.
+ """
+ root = _resolve_repo_root(repo_root)
+
+ def _run() -> dict:
+ return with_provenance(generate_report_func(
+ review_data=review_data, output_path=output_path,
+ repo_root=root,
+ ), root)
+
+ return await asyncio.to_thread(_run)
+
+
@mcp.tool()
def refactor_tool(
mode: str = "rename",
diff --git a/code_review_graph/scoring.py b/code_review_graph/scoring.py
new file mode 100644
index 0000000..b39c801
--- /dev/null
+++ b/code_review_graph/scoring.py
@@ -0,0 +1,658 @@
+"""Quantitative code-review scoring for the unified-review workflow.
+
+Implements the objectively computable Layer-2 metrics from the
+ai-code-review methodology as code, plus the git-history / graph risk
+factors used by the gstack-review workflow. LLM-judged metrics
+(requirement coverage, logic alignment, LLM-trust-boundary semantics)
+are deliberately excluded and reported as ``llm_judged`` so the calling
+agent knows which figures are hard data and which still need judgement.
+
+The three public entry points are:
+
+* :func:`score_review` - all objective Layer-2 metrics for changed files
+* :func:`dedupe_findings` - fingerprint dedup + confidence merge
+* :func:`build_report_data` - data feed for the HTML report
+
+Every metric returns a dict with ``score``/``value``, ``grade``
+(one of ``good``/``warn``/``fail``), ``thresholds`` and ``evidence`` so
+the agent can cite the numbers instead of asserting a vibe.
+"""
+
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from typing import Any
+
+from .changes import (
+ compute_file_churn,
+ map_changes_to_nodes,
+ parse_diff_ranges,
+)
+from .constants import SECURITY_KEYWORDS
+from .graph import GraphStore
+from .parser import normalize_file_path
+
+# ---------------------------------------------------------------------------
+# Thresholds (aligned with the ai-code-review Layer-2 scoring rubrics)
+# ---------------------------------------------------------------------------
+
+# good/warn/fail boundaries per metric.
+THRESHOLDS: dict[str, dict[str, float]] = {
+ "redundancy_rate": {"good_max": 10.0, "warn_max": 20.0}, # % duplicate lines
+ "exception_coverage": {"warn_min": 50.0, "good_min": 30.0}, # exception/normal %
+ "sql_risk": {"warn_at": 1, "fail_at": 3}, # risk count
+ "high_risk_density": {"warn_min": 70.0, "good_min": 90.0}, # covered % (0..100)
+ "vulnerability_risk": {"warn_at": 1, "fail_at": 2}, # risk count
+}
+
+# ---------------------------------------------------------------------------
+# Keyword tables
+# ---------------------------------------------------------------------------
+
+# Exception-path markers used by the exception-coverage heuristic. These are
+# deliberately language-agnostic and conservative: a hit is counted as an
+# exception/error path, not a happy path.
+_EXCEPTION_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"\btry\b"),
+ re.compile(r"\bexcept\b"),
+ re.compile(r"\bcatch\s*\("),
+ re.compile(r"\braise\b"),
+ re.compile(r"\bthrow\b"),
+ re.compile(r"\?\.\.\."),
+ re.compile(r"\bif\s+err\s*!=\s*nil\b"),
+ re.compile(r"\bif\s+.*\berror\b"),
+ re.compile(r"\.catch\s*\("),
+ re.compile(r"\belse\s*\{?\s*return\s+(?:false|None|null|nil|0)\b"),
+)
+
+# SQL injection / unparameterized-query markers. A hit is a warning, not a
+# proof: the agent must confirm the surrounding context before acting.
+_SQL_RISK_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"[\"'](?:SELECT|INSERT|UPDATE|DELETE)\s+.*?\+\s*[\"']", re.I | re.S),
+ re.compile(r"\bf?\".*?(?:SELECT|INSERT|UPDATE|DELETE).*?(?:\$\{|\{)\w", re.I | re.S),
+ re.compile(r"\bexec(?:ute)?\s*\(\s*[\"'].*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
+ re.compile(r"WHERE\s+.*?=\s*[\"']\s*\+\s*\w", re.I | re.S),
+ re.compile(r"\.format\s*\(.*\).*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
+ re.compile(r"%\s*[\"']\s*%\s*\(?.*?(?:SELECT|INSERT|UPDATE|DELETE)", re.I | re.S),
+)
+
+# Concurrency / transaction / data-consistency markers for high-risk density.
+_HIGH_RISK_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"\b(?:async|await)\b"),
+ re.compile(r"\b(?:thread|goroutine|mutex|lock|unlock|semaphore)\b"),
+ re.compile(r"\b(?:transaction|commit|rollback|begin)\b", re.I),
+ re.compile(r"\b(?:atomic|concurrent|race)\b", re.I),
+ re.compile(r"\b(?:SELECT|INSERT|UPDATE|DELETE)\b", re.I),
+ re.compile(r"\b(?:lock|with_lock|select_for_update)\b", re.I),
+ re.compile(r"\b(?:cache|invalidate|evict)\b", re.I),
+)
+
+# OWASP / secret markers for the vulnerability heuristic.
+_VULNERABILITY_PATTERNS: tuple[re.Pattern[str], ...] = (
+ re.compile(r"\b(?:password|passwd|pwd)\s*=\s*[\"'][^\"']{1,32}[\"']", re.I),
+ re.compile(r"\b(?:api[_-]?key|secret|token)\s*=\s*[\"'][^\"']+[\"']", re.I),
+ re.compile(r"\b(?:eval|exec)\s*\(", re.I),
+ re.compile(r"\bsubprocess\s*\(.*shell\s*=\s*True", re.I | re.S),
+ re.compile(r"\b(?:innerHTML|dangerouslySetInnerHTML|html\.safe|mark_safe|v-html)\b", re.I),
+ re.compile(r"\b(?:
+