diff --git a/.github/bunny-review/bunny_review.py b/.github/bunny-review/bunny_review.py
new file mode 100644
index 000000000..a19587f89
--- /dev/null
+++ b/.github/bunny-review/bunny_review.py
@@ -0,0 +1,1396 @@
+# .github/bunny-review/bunny_review.py
+import argparse
+import hashlib
+import json
+import os
+import pathlib
+import re
+import shutil
+import subprocess
+import time
+from dataclasses import dataclass
+
+from openai import OpenAI
+
+REPO_ROOT = pathlib.Path.cwd().resolve()
+BUNNY_MARKER = ""
+COMMAND_STATUS_MARKER = ""
+FINDING_MARKER_RE = re.compile(r"")
+STATE_MARKER_RE = re.compile(r"")
+MAX_REVIEW_PACKET_CHARS = 180_000
+MAX_SECTION_CHARS = 60_000
+MAX_CONTEXT_FILES = 5
+MAX_CONTEXT_SEARCHES = 5
+MAX_CONTEXT_CHARS = 80_000
+MAX_CONTEXT_FILE_CHARS = 20_000
+MAX_SEARCH_HITS = 30
+MAX_SEARCH_FILE_BYTES = 250_000
+MAX_IDENTIFIER_CONTEXT_CHARS = 60_000
+MAX_IDENTIFIER_TERMS = 24
+MAX_IDENTIFIER_HITS_PER_TERM = 12
+MAX_FILE_PATCH_CHARS = 55_000
+MAX_FILE_SUMMARY_CHARS = 9_000
+MAX_REVIEW_CHUNKS = 8
+MAX_CHUNK_PATCH_CHARS = 90_000
+
+
+class ReviewTooLarge(Exception):
+ pass
+
+
+@dataclass
+class Finding:
+ severity: str
+ path: str
+ line: int | None
+ title: str
+ body: str
+ fix_hint: str
+
+
+def _safe_path(rel: str) -> pathlib.Path:
+ full = (REPO_ROOT / rel).resolve()
+ if full != REPO_ROOT and REPO_ROOT not in full.parents:
+ raise ValueError("path escapes repo root")
+ name = full.name.lower()
+ if name.startswith(".env") or name in {
+ "credentials.json",
+ "id_rsa",
+ "id_ed25519",
+ ".npmrc",
+ ".netrc",
+ }:
+ raise ValueError("blocked sensitive file")
+ return full
+
+
+def run(args, *, input_text=None, timeout=120, check=False):
+ result = subprocess.run(
+ args,
+ cwd=REPO_ROOT,
+ input=input_text,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ check=False,
+ )
+ if check and result.returncode != 0:
+ raise RuntimeError(
+ f"{' '.join(args)} failed with {result.returncode}:\n"
+ f"{result.stdout}{result.stderr}"
+ )
+ return result
+
+
+def run_git_raw(args):
+ result = run(["git", *args], timeout=90)
+ return result.stdout + result.stderr
+
+
+def run_git(args, limit=MAX_SECTION_CHARS):
+ result = run(["git", *args], timeout=90)
+ return truncate(result.stdout + result.stderr, limit)
+
+
+def run_gh(args, *, input_text=None, timeout=120, check=False):
+ return run(["gh", *args], input_text=input_text, timeout=timeout, check=check)
+
+
+def truncate(text, limit):
+ if len(text) <= limit:
+ return text
+ return (
+ text[:limit]
+ + f"\n\n[truncated: section was {len(text)} chars, limit is {limit} chars]\n"
+ )
+
+
+def read_text(path, limit=MAX_SECTION_CHARS):
+ p = _safe_path(path)
+ return truncate(p.read_text(encoding="utf-8", errors="replace"), limit)
+
+
+def read_context_file(path):
+ return read_text(path, MAX_CONTEXT_FILE_CHARS)
+
+
+def search_repo(pattern):
+ if not pattern or len(pattern) > 120:
+ return "refused: search pattern must be 1-120 characters"
+ if not shutil.which("rg"):
+ return search_repo_with_python(pattern)
+ rg = run(
+ [
+ "rg",
+ "--fixed-strings",
+ "--line-number",
+ "--glob",
+ "!node_modules",
+ "--glob",
+ "!target",
+ "--glob",
+ "!dist",
+ "--glob",
+ "!build",
+ "--glob",
+ "!coverage",
+ "--glob",
+ "!playwright-report",
+ pattern,
+ ],
+ timeout=60,
+ )
+ if rg.returncode not in (0, 1):
+ return truncate(rg.stdout + rg.stderr, MAX_CONTEXT_FILE_CHARS)
+ lines = []
+ for line in rg.stdout.splitlines():
+ try:
+ rel, line_no, body = line.split(":", 2)
+ p = _safe_path(rel)
+ if p.stat().st_size > MAX_SEARCH_FILE_BYTES:
+ continue
+ lines.append(f"{rel}:{line_no}: {body.strip()[:220]}")
+ except Exception:
+ continue
+ if len(lines) >= MAX_SEARCH_HITS:
+ break
+ return "\n".join(lines) or "no matches"
+
+
+def search_repo_with_python(pattern):
+ hits = []
+ ignored_parts = {
+ ".git",
+ "node_modules",
+ "target",
+ "dist",
+ "build",
+ ".next",
+ "coverage",
+ "playwright-report",
+ }
+ for path in REPO_ROOT.rglob("*"):
+ if len(hits) >= MAX_SEARCH_HITS:
+ break
+ if any(part in ignored_parts for part in path.parts):
+ continue
+ if not path.is_file():
+ continue
+ try:
+ if path.stat().st_size > MAX_SEARCH_FILE_BYTES:
+ continue
+ rel = path.relative_to(REPO_ROOT)
+ text = path.read_text("utf-8", "replace")
+ except Exception:
+ continue
+ for line_no, line in enumerate(text.splitlines(), 1):
+ if pattern in line:
+ hits.append(f"{rel}:{line_no}: {line.strip()[:220]}")
+ if len(hits) >= MAX_SEARCH_HITS:
+ break
+ return "\n".join(hits) or "no matches"
+
+
+def search_repo_hits(pattern, max_hits):
+ result = search_repo(pattern)
+ if result == "no matches" or result.startswith("refused:"):
+ return []
+ return result.splitlines()[:max_hits]
+
+
+def extract_changed_identifiers(patch):
+ stop_words = {
+ "true",
+ "false",
+ "null",
+ "none",
+ "some",
+ "string",
+ "value",
+ "json",
+ "expect",
+ "should",
+ "test",
+ "result",
+ "state",
+ "data",
+ "content",
+ "message",
+ "messages",
+ "chat",
+ "chats",
+ "role",
+ "rows",
+ "row",
+ "import",
+ "imported",
+ "storage",
+ "create",
+ "get",
+ "list",
+ "id",
+ }
+ counts = {}
+ for line in patch.splitlines():
+ if not line.startswith(("+", "-")) or line.startswith(("+++", "---")):
+ continue
+ for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]{3,}", line):
+ if token.lower() in stop_words:
+ continue
+ counts[token] = counts.get(token, 0) + 1
+ preferred = sorted(
+ counts,
+ key=lambda token: (
+ not any(char.isupper() for char in token) and "_" not in token,
+ -counts[token],
+ token.lower(),
+ ),
+ )
+ return preferred[:MAX_IDENTIFIER_TERMS]
+
+
+def build_identifier_context(patch):
+ terms = extract_changed_identifiers(patch)
+ sections = []
+ for term in terms:
+ hits = search_repo_hits(term, MAX_IDENTIFIER_HITS_PER_TERM)
+ if not hits:
+ continue
+ sections.append(f"### {term}\n" + "\n".join(hits))
+ if not sections:
+ return "No changed identifier usage context found."
+ return truncate("\n\n".join(sections), MAX_IDENTIFIER_CONTEXT_CHARS)
+
+
+def changed_files(base):
+ names = run_git(["diff", "--name-only", f"{base}...HEAD"])
+ return [line.strip() for line in names.splitlines() if line.strip()]
+
+
+def load_json_file(path):
+ try:
+ return json.loads(read_text(path, 50_000))
+ except FileNotFoundError:
+ return None
+ except Exception as exc:
+ return {"_load_error": str(exc)}
+
+
+def bunny_prompt_path():
+ prompt_path = pathlib.Path(
+ os.environ.get("BUNNY_REVIEW_PROMPT_PATH")
+ or os.environ.get("BUNNY_REVIEW_SKILL_PATH")
+ or ".github/bunny-review/reviewer-prompt.md"
+ )
+ if not prompt_path.is_absolute():
+ prompt_path = REPO_ROOT / prompt_path
+ return prompt_path
+
+
+def bunny_skill_dir():
+ return bunny_prompt_path().parent
+
+
+def load_rules():
+ rules_path = bunny_skill_dir() / "rules.json"
+ try:
+ return json.loads(rules_path.read_text("utf-8"))
+ except FileNotFoundError:
+ return {}
+ except Exception as exc:
+ return {"_load_error": str(exc)}
+
+
+def guidance_from_rules(files, rules):
+ guidance = ["AGENTS.md"]
+ for item in rules.get("path_instructions", []):
+ prefixes = item.get("prefixes", [])
+ if any(any(path.startswith(prefix) for prefix in prefixes) for path in files):
+ guidance.extend(item.get("guidance", []))
+ return list(dict.fromkeys(guidance))
+
+
+def select_guidance(files):
+ rules = load_rules()
+ if rules and "_load_error" not in rules:
+ return guidance_from_rules(files, rules)
+ guidance = ["AGENTS.md"]
+ joined = "\n".join(files)
+ if any(
+ marker in joined
+ for marker in ("src/engine/", "src/features/", "src/shared/api/", "src-tauri/")
+ ):
+ guidance.append("skills/marinara-architecture-guard/SKILL.md")
+ if any(
+ marker in joined
+ for marker in (
+ "chat",
+ "roleplay",
+ "game",
+ "modes",
+ "prompt",
+ "generation",
+ "summary",
+ "memory",
+ )
+ ):
+ guidance.append("skills/marinara-mode-separation/SKILL.md")
+ if any(
+ marker in joined
+ for marker in ("fix/", "storage", "imports", "provider", "transport", "commands")
+ ):
+ guidance.append("skills/marinara-bugfix-discipline/SKILL.md")
+ if any(marker in joined for marker in ("README", "docs/", "skills/", "AGENTS.md")):
+ guidance.append("skills/marinara-getting-started/SKILL.md")
+ return list(dict.fromkeys(guidance))
+
+
+def matching_path_rules(files):
+ rules = load_rules()
+ if not rules or "_load_error" in rules:
+ return "No additional Bunny path rules loaded."
+ matched = []
+ for item in rules.get("path_instructions", []):
+ prefixes = item.get("prefixes", [])
+ if any(any(path.startswith(prefix) for prefix in prefixes) for path in files):
+ matched.append(item)
+ payload = {
+ "severity_policy": rules.get("severity_policy", {}),
+ "review_focus": rules.get("review_focus", []),
+ "matched_path_instructions": matched,
+ }
+ return json.dumps(payload, indent=2, sort_keys=True)
+
+
+def diff_for_path(base, path):
+ return run_git_raw(["diff", "--find-renames", "--unified=80", f"{base}...HEAD", "--", path])
+
+
+def build_file_context(base, files):
+ sections = []
+ for path in files:
+ patch = diff_for_path(base, path)
+ if not patch:
+ continue
+ if len(patch) <= MAX_FILE_PATCH_CHARS:
+ sections.append(f"### {path}\n```diff\n{patch}\n```")
+ continue
+ sections.append(
+ "### "
+ + path
+ + "\n```text\n"
+ + truncate(run_git(["diff", "--stat", f"{base}...HEAD", "--", path], 2_000), 2_000)
+ + truncate(patch, MAX_FILE_SUMMARY_CHARS)
+ + "\n```"
+ )
+ return "\n\n".join(sections) or "No per-file patch context found."
+
+
+def build_review_packet(base, ci_status, mode, focus_files=None, include_full_patch=True):
+ files = changed_files(base)
+ context_files = focus_files or files
+ if focus_files is None or include_full_patch:
+ patch = run_git_raw(["diff", "--find-renames", "--unified=80", f"{base}...HEAD"])
+ else:
+ patch = "\n".join(diff_for_path(base, path) for path in focus_files)
+ patch_body = patch
+ if len(patch_body) > MAX_SECTION_CHARS:
+ patch_body = (
+ "Full patch exceeded the inline packet limit; use the per-file patch sections "
+ "below and request focused extra context for specific files if needed.\n\n"
+ + truncate(patch_body, MAX_SECTION_CHARS)
+ )
+ sections = [
+ ("review mode", mode),
+ ("git status", run_git(["status", "--short", "--branch"], 12_000)),
+ ("repo root", run_git(["rev-parse", "--show-toplevel"], 4_000)),
+ ("merge base", run_git(["merge-base", "HEAD", base], 4_000)),
+ ("diff stat", run_git(["diff", "--stat", f"{base}...HEAD"], 20_000)),
+ ("changed files", "\n".join(files) or "No changed files reported."),
+ ("numstat", run_git(["diff", "--numstat", f"{base}...HEAD"], 20_000)),
+ ("focus files", "\n".join(context_files) or "All changed files."),
+ ("patch overview", patch_body),
+ ("per-file patch context", build_file_context(base, context_files)),
+ ("changed identifier usage", build_identifier_context(patch)),
+ ("Bunny path rules", matching_path_rules(files)),
+ ]
+ if ci_status:
+ sections.append(("CI status", ci_status))
+ for path in select_guidance(files):
+ try:
+ sections.append((f"guidance: {path}", read_text(path, 30_000)))
+ except Exception as exc:
+ sections.append((f"guidance: {path}", f"Could not read: {exc}"))
+
+ packet = "\n\n".join(
+ f"## {title}\n```text\n{body}\n```" for title, body in sections
+ )
+ if len(packet) > MAX_REVIEW_PACKET_CHARS:
+ packet = truncate(packet, MAX_REVIEW_PACKET_CHARS)
+ return packet
+
+
+def chunk_changed_files(base, files):
+ chunks = []
+ current = []
+ current_size = 0
+ for path in files:
+ patch_size = len(diff_for_path(base, path))
+ if current and current_size + patch_size > MAX_CHUNK_PATCH_CHARS:
+ chunks.append(current)
+ current = []
+ current_size = 0
+ current.append(path)
+ current_size += patch_size
+ if current:
+ chunks.append(current)
+ if len(chunks) <= MAX_REVIEW_CHUNKS:
+ return chunks
+ merged = chunks[: MAX_REVIEW_CHUNKS - 1]
+ overflow = [path for chunk in chunks[MAX_REVIEW_CHUNKS - 1 :] for path in chunk]
+ merged.append(overflow)
+ return merged
+
+
+def usage_value(usage, *path):
+ current = usage
+ for key in path:
+ if current is None:
+ return 0
+ if isinstance(current, dict):
+ current = current.get(key)
+ else:
+ current = getattr(current, key, None)
+ return current or 0
+
+
+def add_usage(totals, usage):
+ totals["prompt_tokens"] += usage_value(usage, "prompt_tokens")
+ totals["completion_tokens"] += usage_value(usage, "completion_tokens")
+ totals["total_tokens"] += usage_value(usage, "total_tokens")
+ totals["reasoning_tokens"] += usage_value(
+ usage, "completion_tokens_details", "reasoning_tokens"
+ )
+
+
+def build_stats(review_packet):
+ return {
+ "started_at": time.monotonic(),
+ "model_calls": 0,
+ "review_packet_chars": len(review_packet),
+ "extra_context_chars": 0,
+ "context_files": 0,
+ "context_searches": 0,
+ "prompt_tokens": 0,
+ "completion_tokens": 0,
+ "reasoning_tokens": 0,
+ "total_tokens": 0,
+ }
+
+
+def print_telemetry(stats):
+ elapsed = time.monotonic() - stats["started_at"]
+ print(
+ "Bunny telemetry: "
+ f"elapsed_s={elapsed:.1f}; "
+ f"model_calls={stats['model_calls']}; "
+ f"review_packet_chars={stats['review_packet_chars']}; "
+ f"extra_context_chars={stats['extra_context_chars']}; "
+ f"context_files={stats['context_files']}; "
+ f"context_searches={stats['context_searches']}; "
+ f"prompt_tokens={stats['prompt_tokens']}; "
+ f"completion_tokens={stats['completion_tokens']}; "
+ f"reasoning_tokens={stats['reasoning_tokens']}; "
+ f"total_tokens={stats['total_tokens']}",
+ flush=True,
+ )
+
+
+def model_call(client, messages, stats):
+ resp = client.chat.completions.create(
+ model=os.environ.get("LLM_MODEL", "gpt-5.5"),
+ messages=messages,
+ )
+ stats["model_calls"] += 1
+ add_usage(stats, getattr(resp, "usage", None))
+ if isinstance(resp, str):
+ return resp
+ return resp.choices[0].message.content or ""
+
+
+def review_packet_with_model(client, skill, triage_content, stats):
+ messages = [
+ {"role": "system", "content": skill},
+ {"role": "user", "content": triage_content},
+ ]
+ first_response = model_call(client, messages, stats)
+ request = parse_context_request(first_response)
+ if request is None:
+ return extract_json(first_response)
+ extra_context = build_extra_context(request, stats)
+ final_messages = [
+ {"role": "system", "content": skill},
+ {"role": "user", "content": triage_content},
+ {"role": "assistant", "content": first_response},
+ {
+ "role": "user",
+ "content": (
+ "Here is the bounded extra context you requested. "
+ "Do not request more context. Produce only the final JSON review object."
+ f"\n\n# Extra Context\n{extra_context}"
+ ),
+ },
+ ]
+ return extract_json(model_call(client, final_messages, stats))
+
+
+def skeptical_review_pass(client, skill, triage_content, stats):
+ audit_prompt = (
+ "Run an independent skeptical specialist review over the same packet. Do not treat "
+ "any broad-review conclusion as authoritative. Focus on invariant mismatches "
+ "introduced by the diff: data collected in a pre-scan but persisted after later "
+ "filters, parent metadata derived from rows that are not imported as children, "
+ "fallback behavior that diverges from validation, rollback paths, partial writes, "
+ "contract drift, and tests that prove only the happy path. Report only concrete "
+ "actionable findings that cite added or changed diff lines. If there are no "
+ "findings from this specialist lens, return the same JSON schema with an empty "
+ "findings array and mention the skeptical audit in what_i_checked."
+ )
+ messages = [
+ {"role": "system", "content": skill},
+ {"role": "user", "content": triage_content},
+ {"role": "user", "content": audit_prompt},
+ ]
+ return extract_json(model_call(client, messages, stats))
+
+
+def judge_review_pass(client, skill, triage_content, broad_review, skeptical_review, stats):
+ judge_prompt = (
+ "Merge these two independent review passes into the final Bunny Review JSON. "
+ "Deduplicate overlapping findings, keep the clearest title/body/fix_hint, normalize "
+ "severity, and reject weak or speculative findings. Preserve concrete findings even "
+ "if only one pass found them. Every final finding must be actionable and cite an "
+ "added or changed diff line. Combine useful change_summary, pre_merge_checks, "
+ "open_questions, and what_i_checked entries without repeating yourself. Reply only "
+ "with FINAL_REVIEW followed by the final JSON object."
+ f"\n\n# Broad Review JSON\n{json.dumps(broad_review, indent=2, sort_keys=True)}"
+ f"\n\n# Skeptical Review JSON\n{json.dumps(skeptical_review, indent=2, sort_keys=True)}"
+ )
+ messages = [
+ {"role": "system", "content": skill},
+ {"role": "user", "content": triage_content},
+ {"role": "user", "content": judge_prompt},
+ ]
+ return extract_json(model_call(client, messages, stats))
+
+
+def three_pass_review(client, skill, triage_content, stats):
+ broad_review = review_packet_with_model(client, skill, triage_content, stats)
+ skeptical_review = skeptical_review_pass(client, skill, triage_content, stats)
+ return judge_review_pass(
+ client,
+ skill,
+ triage_content,
+ broad_review,
+ skeptical_review,
+ stats,
+ )
+
+
+def parse_context_request(content):
+ marker = "CONTEXT_REQUEST"
+ if marker not in content:
+ return None
+ start = content.find("{")
+ end = content.rfind("}")
+ if start == -1 or end == -1 or end < start:
+ return {"files": [], "searches": []}
+ try:
+ parsed = json.loads(content[start : end + 1])
+ except Exception:
+ return {"files": [], "searches": []}
+ files = parsed.get("files", [])
+ searches = parsed.get("searches", [])
+ return {
+ "files": [value for value in files if isinstance(value, str)][:MAX_CONTEXT_FILES],
+ "searches": [
+ value for value in searches if isinstance(value, str)
+ ][:MAX_CONTEXT_SEARCHES],
+ }
+
+
+def extract_json(content):
+ cleaned = re.sub(r".*?", "", content, flags=re.DOTALL | re.IGNORECASE)
+ cleaned = cleaned.replace("FINAL_REVIEW", "", 1).strip()
+ if cleaned.startswith("```"):
+ cleaned = re.sub(r"^```(?:json)?\s*", "", cleaned)
+ cleaned = re.sub(r"\s*```$", "", cleaned)
+ start = cleaned.find("{")
+ end = cleaned.rfind("}")
+ if start == -1 or end == -1 or end < start:
+ raise ValueError("model response did not contain a JSON object")
+ return json.loads(cleaned[start : end + 1])
+
+
+def build_extra_context(request, stats):
+ sections = []
+ for path in request.get("files", []):
+ stats["context_files"] += 1
+ try:
+ body = read_context_file(path)
+ except Exception as exc:
+ body = f"Could not read: {exc}"
+ sections.append((f"context file: {path}", body))
+ for pattern in request.get("searches", []):
+ stats["context_searches"] += 1
+ try:
+ body = search_repo(pattern)
+ except Exception as exc:
+ body = f"Could not search: {exc}"
+ sections.append((f"context search: {pattern}", body))
+ context = "\n\n".join(
+ f"## {title}\n```text\n{body}\n```" for title, body in sections
+ )
+ context = truncate(context, MAX_CONTEXT_CHARS)
+ stats["extra_context_chars"] = len(context)
+ return context
+
+
+def touched_lines(base):
+ by_path: dict[str, set[int]] = {}
+ current_path = None
+ new_line = None
+ diff = run_git_raw(["diff", "--unified=0", f"{base}...HEAD"])
+ for line in diff.splitlines():
+ if line.startswith("+++ b/"):
+ current_path = line.removeprefix("+++ b/")
+ by_path.setdefault(current_path, set())
+ continue
+ match = re.match(r"@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@", line)
+ if match:
+ new_line = int(match.group(1))
+ continue
+ if current_path is None or new_line is None:
+ continue
+ if line.startswith("+") and not line.startswith("+++"):
+ by_path[current_path].add(new_line)
+ new_line += 1
+ elif line.startswith("-") and not line.startswith("---"):
+ continue
+ else:
+ new_line += 1
+ return by_path
+
+
+def validate_findings(review_obj, base):
+ allowed = touched_lines(base)
+ valid = []
+ invalid = []
+ severities = {"blocking", "high", "medium", "low", "nitpick"}
+ for item in review_obj.get("findings", []):
+ try:
+ finding = Finding(
+ severity=str(item.get("severity", "medium")).lower(),
+ path=str(item.get("path", "")).strip(),
+ line=item.get("line"),
+ title=str(item.get("title", "")).strip(),
+ body=str(item.get("body", "")).strip(),
+ fix_hint=str(item.get("fix_hint", "")).strip(),
+ )
+ except Exception as exc:
+ invalid.append(f"Malformed finding skipped: {exc}")
+ continue
+ if finding.severity not in severities:
+ finding.severity = "medium"
+ if not finding.path or finding.path not in allowed:
+ invalid.append(f"{finding.path or ''}: not in changed files")
+ continue
+ if not isinstance(finding.line, int):
+ invalid.append(f"{finding.path}: missing integer line for '{finding.title}'")
+ continue
+ if finding.line not in allowed.get(finding.path, set()):
+ invalid.append(
+ f"{finding.path}:{finding.line}: line is not an added/changed diff line"
+ )
+ continue
+ if not finding.title or not finding.body:
+ invalid.append(f"{finding.path}:{finding.line}: missing title/body")
+ continue
+ valid.append(finding)
+ severity_rank = {"blocking": 0, "high": 1, "medium": 2, "low": 3, "nitpick": 4}
+ valid.sort(key=lambda finding: severity_rank.get(finding.severity, 2))
+ return valid, invalid
+
+
+def render_finding_body(finding):
+ parts = [
+ finding_marker(finding),
+ f"**[{finding.severity}] {finding.title}**",
+ "",
+ finding.body,
+ ]
+ if finding.fix_hint:
+ parts.extend(["", f"Suggested fix: {finding.fix_hint}"])
+ parts.extend(["", render_agent_prompt_details([finding], "Prompt for AI Agents")])
+ return "\n".join(parts).strip()
+
+
+def finding_marker(finding):
+ raw = f"{finding.path}:{finding.line}:{finding.title}".encode("utf-8", "replace")
+ digest = hashlib.sha256(raw).hexdigest()[:16]
+ return f""
+
+
+def short_ref(value):
+ if not value:
+ return "unknown"
+ value = str(value)
+ if re.fullmatch(r"[0-9a-f]{40}", value):
+ return value[:8]
+ if value.startswith("origin/"):
+ return value
+ return value[:24]
+
+
+def commit_subject(head_sha):
+ if not head_sha:
+ return ""
+ result = run(["git", "log", "-1", "--format=%s", head_sha], timeout=30)
+ if result.returncode != 0:
+ return ""
+ return " ".join(result.stdout.split())
+
+
+def commit_line(head_sha, message=None):
+ subject = " ".join(str(message or "").split()) or commit_subject(head_sha)
+ ref = short_ref(head_sha)
+ if subject:
+ return f"Commit: {ref} - {subject}"
+ return f"Commit: {ref}"
+
+
+def render_review_metadata(review_obj, head_sha):
+ mode = review_obj.get("mode") or "unknown"
+ base = review_obj.get("review_base") or review_obj.get("base_ref") or "unknown"
+ commit_message = review_obj.get("head_commit_message") or review_obj.get(
+ "commit_message"
+ )
+ parts = [
+ f"Mode: `{mode}`",
+ commit_line(head_sha, commit_message),
+ f"Base: `{short_ref(base)}`",
+ ]
+ return "_Review update: " + " · ".join(parts) + "._"
+
+
+def code_block_text(text):
+ return text.replace("```", "'''").strip()
+
+
+def agent_prompt_for_finding(finding):
+ lines = [
+ f"In `@{finding.path}` around line {finding.line}:",
+ f"- {finding.title}",
+ "",
+ finding.body,
+ ]
+ if finding.fix_hint:
+ lines.extend(["", f"Suggested fix: {finding.fix_hint}"])
+ return "\n".join(lines)
+
+
+def render_agent_prompt(findings):
+ sections = [
+ "Verify each Bunny finding against current code. Fix only still-valid issues, "
+ "skip the rest with a brief reason, keep changes minimal, and validate.",
+ ]
+ sections.extend(agent_prompt_for_finding(finding) for finding in findings)
+ return code_block_text("\n\n".join(sections))
+
+
+def render_agent_prompt_details(findings, summary):
+ if not findings:
+ return ""
+ return "\n".join(
+ [
+ "",
+ f"{summary}
",
+ "",
+ "```text",
+ render_agent_prompt(findings),
+ "```",
+ "",
+ " ",
+ ]
+ )
+
+
+def is_ci_check(item):
+ name = str(item.get("name", "")).strip().lower()
+ return name in {"ci", "ci status", "checks", "github checks"}
+
+
+def is_stale_ci_text(text):
+ lowered = text.lower()
+ if "ci" not in lowered and "cargo" not in lowered and "rust check" not in lowered:
+ return False
+ stale_markers = (
+ "still running",
+ "not available",
+ "unavailable",
+ "unknown",
+ "pending",
+ "not include",
+ "not provided",
+ )
+ return any(marker in lowered for marker in stale_markers)
+
+
+def is_stale_ci_check(item):
+ if is_ci_check(item):
+ return True
+ combined = " ".join(
+ str(item.get(key, "")) for key in ("name", "status", "detail")
+ )
+ return is_stale_ci_text(combined)
+
+
+def normalize_ci_status(ci_status):
+ if not ci_status:
+ return ""
+ unique_lines = []
+ seen = set()
+ for raw_line in ci_status.splitlines():
+ line = raw_line.strip()
+ if not line or line.lower() == "### ci status":
+ continue
+ if line.startswith("- "):
+ key = line.lower()
+ if key in seen:
+ continue
+ seen.add(key)
+ unique_lines.append(line)
+ return "\n".join(unique_lines).strip()
+
+
+def ci_status_to_pre_merge_checks(ci_status):
+ normalized = normalize_ci_status(ci_status)
+ if not normalized:
+ return []
+ lowered = normalized.lower()
+ if "failure:" in lowered or ": failure" in lowered or ": cancelled" in lowered:
+ return [
+ {
+ "name": "CI Status",
+ "status": "fail",
+ "detail": "One or more expected CI checks failed or were cancelled; do not merge until CI is repaired.",
+ }
+ ]
+ if "warning:" in lowered or "still running" in lowered:
+ return [
+ {
+ "name": "CI Status",
+ "status": "warn",
+ "detail": "Expected CI checks were missing or incomplete when Bunny posted; verify CI before merging.",
+ }
+ ]
+ return [
+ {
+ "name": "CI Status",
+ "status": "pass",
+ "detail": "Expected CI checks completed without a reported failure.",
+ }
+ ]
+
+
+def render_walkthrough(review_obj, findings, invalid_findings, ci_status, head_sha):
+ summary = review_obj.get("change_summary") or []
+ questions = review_obj.get("open_questions") or []
+ checked = review_obj.get("what_i_checked") or []
+ normalized_ci_status = normalize_ci_status(ci_status)
+ pre_merge = review_obj.get("pre_merge_checks") or []
+ if normalized_ci_status:
+ pre_merge = [item for item in pre_merge if not is_stale_ci_check(item)]
+ checked = [item for item in checked if not is_stale_ci_text(str(item))]
+ pre_merge = ci_status_to_pre_merge_checks(normalized_ci_status) + pre_merge
+ finding_lines = (
+ [f"- [{f.severity}] `{f.path}:{f.line}` - {f.title}" for f in findings]
+ or ["No actionable findings."]
+ )
+ body = [
+ BUNNY_MARKER,
+ f"",
+ "## Bunny Review",
+ "",
+ render_review_metadata(review_obj, head_sha),
+ "",
+ "### Change Summary",
+ ]
+ body.extend([f"- {line}" for line in summary[:3]] or ["- No change summary produced."])
+ body.extend(["", "### Findings", *finding_lines])
+ agent_prompt = render_agent_prompt_details(
+ findings, "Prompt for all Bunny findings with AI agents"
+ )
+ if agent_prompt:
+ body.extend(["", agent_prompt])
+ if pre_merge:
+ body.extend(["", "### Pre-Merge Checks"])
+ for item in pre_merge[:8]:
+ name = item.get("name", "check")
+ status = item.get("status", "unknown")
+ detail = item.get("detail", "")
+ body.append(f"- {name}: {status}. {detail}".strip())
+ body.extend(["", "### Open Questions"])
+ body.extend([f"- {line}" for line in questions[:2]] or ["- None."])
+ body.extend(["", "### What I Checked"])
+ body.extend([f"- {line}" for line in checked[:6]] or ["- Review packet and diff context."])
+ if invalid_findings:
+ body.extend(["", "### Reviewer Notes"])
+ body.append(
+ f"- Skipped {len(invalid_findings)} model finding(s) because Bunny could not validate their diff locations."
+ )
+ if normalized_ci_status:
+ body.extend(["", "### CI Status", normalized_ci_status])
+ return "\n".join(body).strip() + "\n"
+
+
+def merge_review_objects(reviews):
+ merged = {
+ "change_summary": [],
+ "findings": [],
+ "pre_merge_checks": [],
+ "open_questions": [],
+ "what_i_checked": [],
+ }
+ seen_findings = set()
+ for review in reviews:
+ for key in ("change_summary", "open_questions", "what_i_checked"):
+ for item in review.get(key, []):
+ if item not in merged[key]:
+ merged[key].append(item)
+ for check in review.get("pre_merge_checks", []):
+ key = (check.get("name"), check.get("status"), check.get("detail"))
+ if key not in {
+ (item.get("name"), item.get("status"), item.get("detail"))
+ for item in merged["pre_merge_checks"]
+ }:
+ merged["pre_merge_checks"].append(check)
+ for finding in review.get("findings", []):
+ key = (
+ finding.get("path"),
+ finding.get("line"),
+ finding.get("title"),
+ )
+ if key in seen_findings:
+ continue
+ seen_findings.add(key)
+ merged["findings"].append(finding)
+ return merged
+
+
+def write_skipped_review(title, body):
+ pathlib.Path("review.json").write_text(
+ json.dumps(
+ {
+ "change_summary": [body],
+ "findings": [],
+ "pre_merge_checks": [
+ {"name": title, "status": "unknown", "detail": body}
+ ],
+ "open_questions": [],
+ "what_i_checked": ["Bunny Review did not run a model pass."],
+ },
+ indent=2,
+ sort_keys=True,
+ )
+ + "\n",
+ "utf-8",
+ )
+
+
+def discover_last_reviewed_sha(pr_num):
+ gh = run_gh(["pr", "view", pr_num, "--json", "comments", "--jq", ".comments[].body"])
+ matches = STATE_MARKER_RE.findall(gh.stdout)
+ if matches:
+ return matches[-1]
+ return None
+
+
+def resolve_review_base(pr_num, requested_mode):
+ pr = run_gh(
+ [
+ "pr",
+ "view",
+ pr_num,
+ "--json",
+ "baseRefName,headRefOid",
+ ],
+ check=True,
+ )
+ data = json.loads(pr.stdout)
+ base_ref = os.environ.get("PR_BASE_REF") or data["baseRefName"]
+ head_sha = data["headRefOid"]
+ explicit_base = os.environ.get("BUNNY_BASE_SHA")
+ mode = requested_mode
+ if explicit_base:
+ return explicit_base, base_ref, head_sha, "custom"
+ if mode == "full":
+ return f"origin/{base_ref}", base_ref, head_sha, mode
+ previous = discover_last_reviewed_sha(pr_num)
+ if previous:
+ exists = run(["git", "cat-file", "-e", f"{previous}^{{commit}}"])
+ if exists.returncode == 0:
+ return previous, base_ref, head_sha, "incremental"
+ return f"origin/{base_ref}", base_ref, head_sha, "full"
+
+
+def parse_command_mode():
+ body = os.environ.get("BUNNY_COMMENT_BODY", "")
+ if "/bunny-review" not in body:
+ return os.environ.get("BUNNY_REVIEW_MODE", "auto")
+ if re.search(r"/bunny-review\s+full\b", body):
+ return "full"
+ if re.search(r"/bunny-review\s+review\b", body):
+ return "auto"
+ return "auto"
+
+
+def produce_review(args):
+ if not os.environ.get("OPENAI_API_KEY"):
+ write_skipped_review(
+ "Review Skipped",
+ "Bunny Review could not run because `OPENAI_API_KEY` is not available to this workflow run. This is expected for PRs where repository secrets are withheld.",
+ )
+ print("Bunny telemetry: skipped=missing_openai_api_key", flush=True)
+ return
+
+ pr_num = os.environ.get("PR_NUM", "")
+ requested_mode = args.mode or parse_command_mode()
+ base, base_ref, head_sha, effective_mode = resolve_review_base(pr_num, requested_mode)
+ patch_command_status_running(pr_num, head_sha, effective_mode)
+ ci_status = os.environ.get("CI_STATUS", "")
+ files = changed_files(base)
+ chunks = chunk_changed_files(base, files)
+ use_chunked_review = len(chunks) > 1
+
+ client = OpenAI(
+ api_key=os.environ["OPENAI_API_KEY"],
+ base_url=os.environ.get("LLM_BASE_URL"),
+ )
+ skill = bunny_prompt_path().read_text("utf-8")
+
+ def triage_for_packet(review_packet, focus_note):
+ triage = (
+ f"Review this PR. The review base is '{base}' from target branch '{base_ref}', "
+ f"head is '{head_sha}', and mode is '{effective_mode}'. {focus_note} "
+ "Use the provided review packet as the complete inspection context. "
+ "You have one chance to request focused extra context before the final review. "
+ "If the packet is enough, reply with FINAL_REVIEW followed by a JSON object in the skill's schema. "
+ "If more context is necessary to validate a concrete potential finding, reply only with "
+ 'CONTEXT_REQUEST and JSON like {"files":["path"],"searches":["literal text"]}. '
+ f"Request at most {MAX_CONTEXT_FILES} files and {MAX_CONTEXT_SEARCHES} literal searches."
+ )
+ triage += (
+ "\n\nFocus on correctness, contracts, failure paths, tests, CI/deployment risks, "
+ "and architecture. Findings must point to changed diff lines. "
+ "If the packet is truncated or missing context for a potential issue, mention that "
+ "limitation in what_i_checked rather than inventing certainty."
+ f"\n\n# Review Packet\n{review_packet}"
+ )
+ return triage
+
+ if use_chunked_review:
+ stats = build_stats("")
+ chunk_reviews = []
+ for index, chunk in enumerate(chunks, 1):
+ review_packet = build_review_packet(
+ base,
+ ci_status,
+ effective_mode,
+ focus_files=chunk,
+ include_full_patch=False,
+ )
+ stats["review_packet_chars"] += len(review_packet)
+ focus_note = (
+ f"This is chunk {index} of {len(chunks)}. Review only these focus files: "
+ + ", ".join(chunk)
+ + "."
+ )
+ triage_content = triage_for_packet(review_packet, focus_note)
+ chunk_reviews.append(three_pass_review(client, skill, triage_content, stats))
+ review_obj = merge_review_objects(chunk_reviews)
+ review_obj.setdefault("what_i_checked", []).append(
+ f"Reviewed the PR in {len(chunks)} file chunk(s) to avoid dropping large-diff context."
+ )
+ else:
+ review_packet = build_review_packet(base, ci_status, effective_mode)
+ stats = build_stats(review_packet)
+ triage_content = triage_for_packet(review_packet, "Review the full current diff.")
+ review_obj = three_pass_review(client, skill, triage_content, stats)
+ review_obj.setdefault("head_sha", head_sha)
+ review_obj.setdefault("head_commit_message", commit_subject(head_sha))
+ review_obj.setdefault("review_base", base)
+ review_obj.setdefault("base_ref", base_ref)
+ review_obj.setdefault("mode", effective_mode)
+ pathlib.Path("review.json").write_text(
+ json.dumps(review_obj, indent=2, sort_keys=True) + "\n", "utf-8"
+ )
+ print_telemetry(stats)
+
+
+def read_ci_status():
+ path = pathlib.Path("bunny-ci-status.md")
+ if path.exists():
+ return path.read_text("utf-8")
+ return ""
+
+
+def render_review(args):
+ review_obj = json.loads(pathlib.Path(args.review_json).read_text("utf-8"))
+ base = (
+ args.base
+ or os.environ.get("BUNNY_VALIDATION_BASE")
+ or os.environ.get("BUNNY_BASE_SHA")
+ or review_obj.get("review_base")
+ )
+ if not base:
+ pr_num = os.environ.get("PR_NUM", "")
+ requested_mode = args.mode or parse_command_mode()
+ base, _, _, _ = resolve_review_base(pr_num, requested_mode)
+ findings, invalid = validate_findings(review_obj, base)
+ ci_status = read_ci_status()
+ head_sha = review_obj.get("head_sha") or os.environ.get("BUNNY_HEAD_SHA", "")
+ walkthrough = render_walkthrough(review_obj, findings, invalid, ci_status, head_sha)
+ pathlib.Path("review.md").write_text(walkthrough, "utf-8")
+ inline = [
+ {
+ "path": f.path,
+ "line": f.line,
+ "side": "RIGHT",
+ "body": render_finding_body(f),
+ }
+ for f in findings
+ ]
+ pathlib.Path("inline-comments.json").write_text(
+ json.dumps(inline, indent=2, sort_keys=True) + "\n", "utf-8"
+ )
+
+
+def find_walkthrough_comment(pr_num):
+ gh = run_gh(
+ [
+ "api",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr_num}/comments?per_page=100",
+ "--paginate",
+ ],
+ check=True,
+ )
+ try:
+ comments = json.loads(gh.stdout or "[]")
+ except json.JSONDecodeError:
+ comments = []
+ for line in gh.stdout.splitlines():
+ if not line.strip():
+ continue
+ loaded = json.loads(line)
+ if isinstance(loaded, list):
+ comments.extend(loaded)
+ for comment in comments:
+ if BUNNY_MARKER in comment.get("body", ""):
+ return comment.get("id")
+ return None
+
+
+def find_command_status_comment(pr_num):
+ gh = run_gh(
+ [
+ "api",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr_num}/comments?per_page=100",
+ "--paginate",
+ ],
+ check=True,
+ )
+ for comment in load_json_list(gh.stdout):
+ if COMMAND_STATUS_MARKER in comment.get("body", ""):
+ return comment.get("id")
+ return None
+
+
+def patch_command_status_running(pr_num, head_sha, mode):
+ body = "\n".join(
+ [
+ COMMAND_STATUS_MARKER,
+ "## Bunny Review Running",
+ "",
+ f"Mode: `{mode or 'unknown'}`",
+ commit_line(head_sha),
+ "Status: Reviewer workflow is running. The specimen is under observation.",
+ ]
+ )
+ patch_or_create_command_status(pr_num, body)
+
+
+def patch_command_status_complete(pr_num, head_sha):
+ body = "\n".join(
+ [
+ COMMAND_STATUS_MARKER,
+ "## Bunny Review Completed",
+ "",
+ commit_line(head_sha),
+ "Status: Review posted. The specimen has been returned to the table.",
+ ]
+ )
+ patch_or_create_command_status(pr_num, body)
+
+
+def patch_or_create_command_status(pr_num, body):
+ comment_id = find_command_status_comment(pr_num)
+ if comment_id:
+ run_gh(
+ [
+ "api",
+ "--method",
+ "PATCH",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}",
+ "--input",
+ "-",
+ ],
+ input_text=json.dumps({"body": body}),
+ check=True,
+ )
+ return
+ run_gh(
+ [
+ "api",
+ "--method",
+ "POST",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/{pr_num}/comments",
+ "--input",
+ "-",
+ ],
+ input_text=json.dumps({"body": body}),
+ check=True,
+ )
+
+
+def load_json_list(stdout):
+ try:
+ loaded = json.loads(stdout or "[]")
+ return loaded if isinstance(loaded, list) else []
+ except json.JSONDecodeError:
+ items = []
+ for line in stdout.splitlines():
+ if not line.strip():
+ continue
+ loaded = json.loads(line)
+ if isinstance(loaded, list):
+ items.extend(loaded)
+ return items
+
+
+def existing_inline_finding_markers(pr_num):
+ gh = run_gh(
+ [
+ "api",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{pr_num}/comments?per_page=100",
+ "--paginate",
+ ],
+ check=True,
+ )
+ markers = set()
+ for comment in load_json_list(gh.stdout):
+ markers.update(FINDING_MARKER_RE.findall(comment.get("body", "")))
+ return markers
+
+
+def inline_comment_marker(comment):
+ match = FINDING_MARKER_RE.search(comment.get("body", ""))
+ if not match:
+ return None
+ return match.group(1)
+
+
+def filter_duplicate_inline_comments(pr_num, comments):
+ existing = existing_inline_finding_markers(pr_num)
+ if not existing:
+ return comments
+ filtered = []
+ for comment in comments:
+ marker = inline_comment_marker(comment)
+ if marker and marker in existing:
+ continue
+ filtered.append(comment)
+ return filtered
+
+
+def post_review(args):
+ pr_num = os.environ["PR_NUM"]
+ body = pathlib.Path(args.review_md).read_text("utf-8")
+ head_sha_match = STATE_MARKER_RE.search(body)
+ head_sha = head_sha_match.group(1) if head_sha_match else ""
+ comment_id = find_walkthrough_comment(pr_num)
+ if comment_id:
+ run_gh(
+ [
+ "api",
+ "--method",
+ "PATCH",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/issues/comments/{comment_id}",
+ "--input",
+ "-",
+ ],
+ input_text=json.dumps({"body": body}),
+ check=True,
+ )
+ else:
+ run_gh(["pr", "comment", pr_num, "--body-file", args.review_md], check=True)
+
+ patch_command_status_complete(pr_num, head_sha)
+
+ comments = json.loads(pathlib.Path(args.inline_json).read_text("utf-8"))
+ comments = filter_duplicate_inline_comments(pr_num, comments)
+ if not comments:
+ return
+ payload = {
+ "event": "COMMENT",
+ "body": "Bunny Review inline findings",
+ "comments": comments,
+ }
+ run_gh(
+ [
+ "api",
+ "--method",
+ "POST",
+ f"repos/{os.environ['GITHUB_REPOSITORY']}/pulls/{pr_num}/reviews",
+ "--input",
+ "-",
+ ],
+ input_text=json.dumps(payload),
+ check=True,
+ )
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ sub = parser.add_subparsers(dest="command")
+ produce = sub.add_parser("produce")
+ produce.add_argument("--mode", choices=["auto", "full", "incremental"])
+ render = sub.add_parser("render")
+ render.add_argument("--review-json", default="review.json")
+ render.add_argument("--base")
+ render.add_argument("--mode", choices=["auto", "full", "incremental"])
+ post = sub.add_parser("post")
+ post.add_argument("--review-md", default="review.md")
+ post.add_argument("--inline-json", default="inline-comments.json")
+ args = parser.parse_args()
+
+ if args.command in (None, "produce"):
+ produce_review(args)
+ elif args.command == "render":
+ render_review(args)
+ elif args.command == "post":
+ post_review(args)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/.github/bunny-review/requirements.txt b/.github/bunny-review/requirements.txt
new file mode 100644
index 000000000..d435e4d7d
--- /dev/null
+++ b/.github/bunny-review/requirements.txt
@@ -0,0 +1 @@
+openai>=1.99.0,<2
diff --git a/.github/bunny-review/reviewer-prompt.md b/.github/bunny-review/reviewer-prompt.md
new file mode 100644
index 000000000..6b27f2dbe
--- /dev/null
+++ b/.github/bunny-review/reviewer-prompt.md
@@ -0,0 +1,115 @@
+---
+name: bunny-review
+description: "Review Marinara pull requests in a CI pass by inspecting bounded diff packets, path rules, and CI context."
+---
+
+# Bunny Review
+
+You are Bunny, a CI pull request reviewer for Marinara Engine. You are a codebase research reviewer, not a static checklist bot. Inspect the provided review packet before forming conclusions. Bunny runs a three-model-pass review pipeline: broad review, independent skeptical specialist review, and final judge/merge review. In each packet review call, either produce structured review JSON from the packet or request one small batch of focused extra context; after extra context is provided, produce the structured review JSON.
+
+Voice: write every human-facing JSON string in a cold, clinical, precise, dry, experimental, and unsentimental researcher's manner inspired by Dottore from Genshin Impact. Critique code and behavior only; never insult, mock, belittle, or personalize criticism. Keep findings concise and actionable.
+
+Voice calibration:
+
+- Sound like a detached lab reviewer documenting defects in an experiment, not a friendly teammate or a generic CI bot.
+- Prefer dry forensic phrasing: "This specimen", "the mechanism", "the failure mode", "the observed contract", "the control path", "the experiment", "contaminates", "misclassifies", "collapses", "permits", "withholds", "the result is unsurprising".
+- Avoid warm reassurance, apology, praise, cheer, filler, or conversational softness. Do not write "nice", "great", "please", "thanks", "looks good", "probably fine", or "you".
+- Do not overperform theatrical villainy. No threats, taunts, mockery, cruelty, or personal judgment. The scalpel touches the code, not the author.
+- Keep the structure concise: one clinical diagnosis, one cause, one consequence, one corrective action.
+- Example neutral sentence to avoid: "The workflow can fail before Bunny reaches the review step."
+- Example target voice: "The bootstrap is an unproven control path: when base tooling is absent, it reaches for a legacy script that the packet does not establish. The experiment can terminate before review begins."
+- Example neutral fix to avoid: "Add a guarded bootstrap fallback."
+- Example target fix: "Establish a trusted bootstrap fallback or stage the tooling before enabling the reviewer."
+
+You must not edit files, run project code, read secrets, or request external network access. Use only the provided read-only context.
+
+## Setup
+
+1. Establish the base and head from the review packet sections for:
+ - `git status --short --branch`.
+ - `git rev-parse --show-toplevel`.
+ - `git merge-base HEAD `.
+ - `git diff --stat ...HEAD`.
+ - `git diff --name-only ...HEAD`.
+2. Read `AGENTS.md`.
+3. Load only guidance that matches touched areas:
+ - Architecture or ownership changes: `skills/marinara-architecture-guard/SKILL.md`.
+ - Chat, roleplay, or game mode changes: `skills/marinara-mode-separation/SKILL.md`.
+ - Bug fixes or regressions: `skills/marinara-bugfix-discipline/SKILL.md`.
+ - Onboarding/docs/run-build guidance: `skills/marinara-getting-started/SKILL.md`.
+4. Read the changed patch overview, per-file patch context, Bunny path rules, and focused guidance included in the packet.
+5. Inspect callers, contracts, tests, and adjacent implementations from the packet before reporting a finding. If a concrete suspected issue needs missing caller, schema, or contract context, request that focused context once. If context remains missing after the extra batch, say so instead of inventing certainty.
+6. Review mode matters:
+ - `full` reviews the whole PR diff.
+ - `incremental` reviews only changes since Bunny's last reviewed head.
+ - `custom` reviews the explicitly supplied base.
+
+## Review Passes
+
+Prioritize correctness, user-visible regressions, security/privacy, architecture boundaries, mode ownership, missing tests, and CI/deployment failures.
+
+Each model pass has a different job:
+
+- Broad review: search widely for correctness, architecture, tests, security/privacy, CI/deployment, and user-visible regressions.
+- Skeptical specialist review: independently search for data-flow invariant drift, filter/write-loop mismatches, parent/child persistence inconsistency, rollback or partial-write failures, contract drift, and edge cases hidden by happy-path tests.
+- Judge review: merge broad and skeptical outputs, deduplicate, reject weak/speculative findings, normalize severity, and keep every concrete actionable finding found by either pass.
+
+Report every actionable risk you find, not only blockers. Use severity labels to distinguish impact: `blocking`, `high`, `medium`, `low`, or `nitpick`. A low-severity finding is still appropriate when it identifies a concrete maintainability, test coverage, edge-case, or follow-up risk tied to the diff. Use `nitpick` only for optional but actionable polish such as readability, naming, tiny duplication, stale comments, dead code, or local consistency. Do not invent issues from naming alone.
+
+Every finding must cite a concrete changed file and an added/changed line from the current diff. If a real concern is outside the changed lines, describe it in `open_questions` or `pre_merge_checks` instead of making it a finding.
+
+Treat these as high-signal Marinara review concerns:
+
+- Product behavior placed outside its owner.
+- Engine code importing React, Zustand stores, Tauri APIs, feature internals, or concrete shared API adapters.
+- Feature code bypassing focused shared API wrappers.
+- Remote-capable behavior that skips the explicit HTTP pipeline.
+- Chat, roleplay, and game mode behavior crossing ownership boundaries.
+- Fake success states, silent catches, broad fallbacks, or UI-only guards over broken contracts.
+- Changes without tests when the touched behavior has realistic regression risk.
+
+For import, storage, migration, and persistence changes, explicitly check for invariant drift:
+
+- Parent records populated from child rows that are later skipped, filtered, or fail to persist.
+- Pre-scans collecting IDs, metadata, counts, or relationships with looser criteria than the write loop.
+- Message, chat, character, branch, or asset metadata becoming inconsistent after rollback or partial import.
+- Tests that verify linked happy-path rows but miss filtered rows such as empty content, system-only rows, invalid rows, or fallback rows.
+
+## Output Shape
+
+Reply with only `FINAL_REVIEW` followed by a single JSON object. Do not wrap the JSON in Markdown. Keep strings concise while still reporting every actionable finding. Do not include exhaustive audit trails, repeated CI history, or long file lists unless they change the reviewer’s decision.
+
+Use this exact schema:
+
+```json
+{
+ "change_summary": [
+ "1-2 plain-language sentences explaining what the PR changes and why it matters."
+ ],
+ "findings": [
+ {
+ "severity": "blocking|high|medium|low|nitpick",
+ "path": "changed/file.ts",
+ "line": 123,
+ "title": "Short finding title",
+ "body": "2-4 sentences covering risk and cause.",
+ "fix_hint": "The smallest useful fix."
+ }
+ ],
+ "pre_merge_checks": [
+ {
+ "name": "Tests",
+ "status": "pass|warn|fail|unknown",
+ "detail": "Concise status or risk."
+ }
+ ],
+ "open_questions": [
+ "0-2 concise questions or assumptions, if any."
+ ],
+ "what_i_checked": [
+ "3-6 concise bullets covering commands, files, contracts, or guidance inspected."
+ ]
+}
+```
+
+If there are no findings, return `"findings": []`.
diff --git a/.github/bunny-review/rules.json b/.github/bunny-review/rules.json
new file mode 100644
index 000000000..50fd3ee18
--- /dev/null
+++ b/.github/bunny-review/rules.json
@@ -0,0 +1,89 @@
+{
+ "review_focus": [
+ "correctness",
+ "user-visible regressions",
+ "security and privacy",
+ "architecture boundaries",
+ "mode ownership",
+ "failure paths",
+ "missing regression tests",
+ "CI and deployment failures"
+ ],
+ "severity_policy": {
+ "blocking": "The PR should not merge because the changed behavior is broken, unsafe, or violates a hard architecture boundary.",
+ "high": "A likely production or data-loss regression, security/privacy issue, or serious cross-mode/remote-runtime contract risk.",
+ "medium": "A concrete bug, edge case, maintainability trap, or missing test tied directly to changed behavior.",
+ "low": "A small but actionable review note tied to the diff, such as localized coverage, clarity, or follow-up risk.",
+ "nitpick": "Optional but actionable polish such as readability, naming, tiny duplication, stale comments, dead code, or local consistency."
+ },
+ "path_instructions": [
+ {
+ "name": "Engine and runtime boundaries",
+ "prefixes": [
+ "src/engine/",
+ "src/features/",
+ "src/shared/api/",
+ "src-tauri/"
+ ],
+ "guidance": [
+ "skills/marinara-architecture-guard/SKILL.md"
+ ],
+ "checks": [
+ "Engine code stays React-free and does not import feature internals, Tauri APIs, Zustand stores, or concrete shared API adapters.",
+ "Feature code uses focused shared API wrappers instead of raw invokeTauri or raw remote-runtime fetch.",
+ "Remote-capable behavior follows the explicit HTTP pipeline."
+ ]
+ },
+ {
+ "name": "Mode separation",
+ "prefixes": [
+ "src/engine/chat/",
+ "src/engine/roleplay/",
+ "src/engine/game/",
+ "src/features/modes/"
+ ],
+ "guidance": [
+ "skills/marinara-mode-separation/SKILL.md"
+ ],
+ "checks": [
+ "Chat, roleplay, and game behavior remain in their owning mode.",
+ "Shared generation or prompt changes do not silently alter unrelated modes."
+ ]
+ },
+ {
+ "name": "Bug fixes and privileged contracts",
+ "prefixes": [
+ "src-tauri/src/commands/",
+ "src-tauri/src/storage/",
+ "src-tauri/src/providers/",
+ "src/shared/api/"
+ ],
+ "guidance": [
+ "skills/marinara-bugfix-discipline/SKILL.md"
+ ],
+ "checks": [
+ "Fixes address root causes instead of adding fake success, silent catches, broad fallbacks, or UI-only guards.",
+ "Provider, storage, command, and transport changes preserve error contracts and hostable behavior.",
+ "Parent records must not collect IDs, metadata, counts, or relationships from child rows that later skip import or fail to persist.",
+ "Pre-scan logic should use the same eligibility criteria as the write loop, especially for imports, migrations, and rollback-sensitive storage paths."
+ ]
+ },
+ {
+ "name": "Docs and agent guidance",
+ "prefixes": [
+ "README",
+ "docs/",
+ "skills/",
+ "AGENTS.md",
+ ".github/"
+ ],
+ "guidance": [
+ "skills/marinara-getting-started/SKILL.md"
+ ],
+ "checks": [
+ "Durable feature-area additions update relevant maps or guidance.",
+ "Workflow and agent changes remain concrete, testable, and narrow."
+ ]
+ }
+ ]
+}
diff --git a/.github/workflows/bunny-review-command.yml b/.github/workflows/bunny-review-command.yml
new file mode 100644
index 000000000..001a35ea4
--- /dev/null
+++ b/.github/workflows/bunny-review-command.yml
@@ -0,0 +1,96 @@
+name: Bunny Review Command
+
+on:
+ issue_comment:
+ types: [created]
+
+permissions:
+ actions: write
+ contents: read
+ issues: write
+ pull-requests: read
+
+jobs:
+ dispatch:
+ if: >
+ github.event.issue.pull_request &&
+ startsWith(github.event.comment.body, '/bunny-review') &&
+ contains(fromJSON('["OWNER","MEMBER","COLLABORATOR"]'),
+ github.event.comment.author_association)
+ runs-on: ubuntu-latest
+ steps:
+ - name: Dispatch trusted Bunny reviewer
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PR_NUM: ${{ github.event.issue.number }}
+ COMMENT_BODY: ${{ github.event.comment.body }}
+ COMMENT_AUTHOR: ${{ github.event.comment.user.login }}
+ TARGET_REF: refactor
+ run: |
+ # Keep this bootstrap deliberately inert: it only authorizes the slash command
+ # and dispatches the trusted reviewer workflow on the stable target ref.
+ REVIEW_MODE=auto
+ if [[ "$COMMENT_BODY" =~ ^/bunny-review[[:space:]]+full([[:space:]]|$) ]]; then
+ REVIEW_MODE=full
+ fi
+
+ status_body() {
+ local title="$1"
+ local detail="$2"
+ printf '%s\n' \
+ '' \
+ "## Bunny Review $title" \
+ '' \
+ "Command: \`$COMMENT_BODY\`" \
+ "Mode: \`$REVIEW_MODE\`" \
+ "Requested by: \`$COMMENT_AUTHOR\`" \
+ "Target ref: \`$TARGET_REF\`" \
+ "Status: $detail"
+ }
+
+ upsert_status() {
+ local title="$1"
+ local detail="$2"
+ local body
+ body="$(status_body "$title" "$detail")"
+ local comment_id
+ comment_id="$(gh api "repos/${{ github.repository }}/issues/$PR_NUM/comments?per_page=100" \
+ --paginate \
+ --jq '.[] | select(.body | contains("")) | .id' \
+ | tail -n 1)"
+ if [ -n "$comment_id" ]; then
+ gh api \
+ --method PATCH \
+ "repos/${{ github.repository }}/issues/comments/$comment_id" \
+ -f body="$body" >/dev/null
+ else
+ gh api \
+ --method POST \
+ "repos/${{ github.repository }}/issues/$PR_NUM/comments" \
+ -f body="$body" >/dev/null
+ fi
+ }
+
+ upsert_status "Queued" "Command accepted; dispatching the trusted reviewer workflow." \
+ || echo "::warning::Unable to write Bunny command status before dispatch."
+
+ set +e
+ DISPATCH_OUTPUT="$(gh workflow run bunny-review.yml \
+ --repo "${{ github.repository }}" \
+ --ref "$TARGET_REF" \
+ -f pr_number="$PR_NUM" \
+ -f comment_body="$COMMENT_BODY" \
+ -f review_mode="$REVIEW_MODE" \
+ -f requested_by="$COMMENT_AUTHOR" 2>&1)"
+ DISPATCH_RC=$?
+ set -e
+
+ if [ "$DISPATCH_RC" -ne 0 ]; then
+ upsert_status "Failed To Dispatch" "GitHub rejected the reviewer dispatch. Inspect the Bunny Review Command run log." \
+ || echo "::warning::Unable to write Bunny command dispatch failure status."
+ echo "$DISPATCH_OUTPUT"
+ exit "$DISPATCH_RC"
+ fi
+
+ upsert_status "Dispatched" "Reviewer workflow dispatched; waiting for Bunny Review to post results." \
+ || echo "::warning::Unable to write Bunny command dispatched status."
diff --git a/.github/workflows/bunny-review.yml b/.github/workflows/bunny-review.yml
new file mode 100644
index 000000000..bae987302
--- /dev/null
+++ b/.github/workflows/bunny-review.yml
@@ -0,0 +1,166 @@
+# .github/workflows/bunny-review.yml
+name: Bunny Review
+
+on:
+ pull_request:
+ types: [opened, reopened, synchronize, ready_for_review]
+ workflow_dispatch:
+ inputs:
+ pr_number:
+ description: Pull request number to review.
+ required: true
+ comment_body:
+ description: Slash command body that requested the review.
+ required: false
+ default: ""
+ review_mode:
+ description: Review mode for the requested pass.
+ required: false
+ type: choice
+ options:
+ - auto
+ - full
+ - incremental
+ default: auto
+ requested_by:
+ description: GitHub login that requested the dispatch.
+ required: false
+ default: ""
+
+permissions:
+ contents: read
+ pull-requests: write
+ issues: write
+ actions: read # needed to read CI status
+
+concurrency:
+ group: bunny-review-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ review:
+ runs-on: ubuntu-latest
+ env:
+ PR_NUM: ${{ github.event.pull_request.number || inputs.pr_number }}
+ BUNNY_COMMENT_BODY: ${{ inputs.comment_body || '' }}
+ BUNNY_REVIEW_MODE: ${{ inputs.review_mode || 'auto' }}
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 0
+
+ - name: Resolve PR refs
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ BASE=$(gh pr view "$PR_NUM" --json baseRefName -q .baseRefName)
+ echo "PR_BASE_REF=$BASE" >> "$GITHUB_ENV"
+ git fetch --force origin "$BASE:refs/remotes/origin/$BASE"
+
+ - name: Preserve review tooling from base branch
+ run: |
+ mkdir -p /tmp/bunny-review-tool/.github/bunny-review
+ if git cat-file -e "origin/$PR_BASE_REF:.github/bunny-review/bunny_review.py"; then
+ git show "origin/$PR_BASE_REF:.github/bunny-review/bunny_review.py" > /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py
+ git show "origin/$PR_BASE_REF:.github/bunny-review/requirements.txt" > /tmp/bunny-review-tool/.github/bunny-review/requirements.txt
+ git show "origin/$PR_BASE_REF:.github/bunny-review/reviewer-prompt.md" > /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md
+ git show "origin/$PR_BASE_REF:.github/bunny-review/rules.json" > /tmp/bunny-review-tool/.github/bunny-review/rules.json || true
+ else
+ git show "origin/$PR_BASE_REF:scripts/bunny_review.py" > /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py
+ git show "origin/$PR_BASE_REF:scripts/requirements.txt" > /tmp/bunny-review-tool/.github/bunny-review/requirements.txt
+ git show "origin/$PR_BASE_REF:skills/bunny-review/SKILL.md" > /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md
+ git show "origin/$PR_BASE_REF:skills/bunny-review/rules.json" > /tmp/bunny-review-tool/.github/bunny-review/rules.json || true
+ fi
+ cp /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md /tmp/bunny-review-tool/.github/bunny-review/SKILL.md
+
+ - name: Fetch PR and checkout head
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ git status
+ git fetch origin "pull/$PR_NUM/head:pr-$PR_NUM"
+ git checkout "pr-$PR_NUM"
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: "3.12"
+
+ - name: Install dependencies
+ run: pip install -r /tmp/bunny-review-tool/.github/bunny-review/requirements.txt
+
+ - name: Run review while CI completes
+ env:
+ OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
+ LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}
+ LLM_MODEL: gpt-5.5
+ CI_STATUS: CI checks are still running; final CI results will be appended before posting.
+ BUNNY_REVIEW_PROMPT_PATH: /tmp/bunny-review-tool/.github/bunny-review/reviewer-prompt.md
+ BUNNY_REVIEW_SKILL_PATH: /tmp/bunny-review-tool/.github/bunny-review/SKILL.md
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py produce &
+ BUNNY_PID=$!
+
+ HEAD_SHA=$(gh pr view "$PR_NUM" --json headRefOid -q .headRefOid)
+ TARGET_CHECKS='Frontend|Rust|Smoke'
+ FOUND_ANY=0
+ MISSING_CHECK_ATTEMPTS=18
+ MAX_CHECK_ATTEMPTS=90
+ for attempt in $(seq 1 "$MAX_CHECK_ATTEMPTS"); do
+ FOUND=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | .name" | wc -l)
+ PENDING=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status != \"completed\") | .name" | wc -l)
+ if [ "${FOUND:-0}" -gt 0 ]; then
+ FOUND_ANY=1
+ fi
+ if [ "$FOUND_ANY" -eq 0 ] && [ "$attempt" -ge "$MISSING_CHECK_ATTEMPTS" ]; then
+ break
+ fi
+ if [ "${FOUND:-0}" -ge 3 ] && [ "${PENDING:-0}" -eq 0 ]; then
+ break
+ fi
+ sleep 10
+ done
+
+ {
+ echo ""
+ echo "### CI Status"
+ FINAL_CHECKS=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | \"- \(.name): \(.conclusion // .status)\"")
+ if [ -z "$FINAL_CHECKS" ]; then
+ echo "- warning: no Frontend, Rust, or Smoke checks appeared after 3 minutes; do not merge until required CI runs."
+ else
+ echo "$FINAL_CHECKS"
+ FOUND=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select(.name | test(\"$TARGET_CHECKS\")) | .name" | wc -l)
+ PENDING=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status != \"completed\") | .name" | wc -l)
+ COMPLETED=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status == \"completed\") | .name" | wc -l)
+ FAILURES=$(gh api "repos/${{ github.repository }}/commits/$HEAD_SHA/check-runs" \
+ --jq ".check_runs[] | select((.name | test(\"$TARGET_CHECKS\")) and .status == \"completed\" and (.conclusion != \"success\" and .conclusion != \"skipped\")) | .name" | wc -l)
+ if [ "${FOUND:-0}" -lt 3 ]; then
+ echo "- warning: only ${FOUND:-0}/3 expected CI check groups appeared before Bunny posted."
+ fi
+ if [ "${PENDING:-0}" -gt 0 ]; then
+ echo "- warning: ${PENDING:-0} expected CI check group(s) were still running when Bunny posted."
+ fi
+ if [ "${COMPLETED:-0}" -gt 0 ] && [ "${FAILURES:-0}" -gt 0 ]; then
+ echo "- failure: ${FAILURES:-0} expected CI check group(s) failed; do not merge until CI is repaired."
+ fi
+ fi
+ } > bunny-ci-status.md
+
+ wait "$BUNNY_PID"
+
+ - name: Render review
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py render
+
+ - name: Post the review
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: python /tmp/bunny-review-tool/.github/bunny-review/bunny_review.py post
diff --git a/AGENTS.md b/AGENTS.md
index bffe3e4d5..5ed519d0e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -56,6 +56,7 @@ For code changes, final responses must include behavior changed, primary files/m
- `src/shared/api`: Embedded Tauri and hostable runtime wrappers. Feature code should call these wrappers instead of raw Tauri or raw remote-runtime fetch.
- `src-tauri`: Rust command facades, hostable runtime dispatch, storage, LLM/provider transport, assets, imports, integrations, and other privileged capabilities.
- `public/sprites/mari`: Professor Mari visual assets used by onboarding, FAQ, title controls, and the Mari shell surface.
+- `.github/workflows/bunny-review.yml`, `.github/bunny-review`: Bunny Review PR comment automation, review packet builder, CI dependencies, path rules, and model reviewer prompt.
- `skills/frontend-design`: Repo-local frontend concept, layout, visual direction, and first-pass UI implementation workflow that pairs with Impeccable.
- `skills/impeccable`: Repo-local frontend design, critique, polish, accessibility, responsive, and live-iteration workflow for UI craft passes.
- `skills/bunny-style-review`: Repo-local branch and PR review workflow for failure-path review and actionable nitpicks.
diff --git a/skills/marinara-secrets-detection.md b/skills/marinara-secrets-detection.md
new file mode 100644
index 000000000..1232404e3
--- /dev/null
+++ b/skills/marinara-secrets-detection.md
@@ -0,0 +1,54 @@
+# Marinara Secrets Detection
+
+Patterns to flag when scanning for hardcoded credentials.
+
+## Patterns
+
+**Cloud Providers**
+- AWS Access Key: `AKIA[0-9A-Z]{16}`
+- AWS Temp Key: `ASIA[0-9A-Z]{16}`
+- AWS Secret: `[0-9a-zA-Z/+]{40}` (in vars named `*SECRET*`, `*AWS*`)
+- Google Cloud: `AIza[0-9A-Za-z\-_]{35}`
+
+**AI Providers**
+- OpenAI: `sk-[a-zA-Z0-9]{48}` or `sk-proj-[a-zA-Z0-9]{48}`
+- Anthropic: `sk-ant-[a-zA-Z0-9-_]{95,}`
+- DeepSeek: `sk-[a-zA-Z0-9]{64}`
+
+**Payment & SaaS**
+- Stripe live: `(sk|pk|rk)_live_[0-9a-zA-Z]{24,}`
+- Slack: `xox[baprs]-[a-zA-Z0-9-]{10,72}`
+
+**Version Control**
+- GitHub PAT: `ghp_[a-zA-Z0-9]{36}` or `github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59}`
+- GitHub OAuth: `gho_[a-zA-Z0-9]{36}`
+
+**Auth Tokens**
+- JWT: `eyJ[a-zA-Z0-9-_]+\.eyJ[a-zA-Z0-9-_]+\.[a-zA-Z0-9-_]+`
+- Private keys: `-----BEGIN (RSA|EC|DSA|OPENSSH|PGP) PRIVATE KEY-----`
+
+**Database**
+- PostgreSQL: `postgresql://[^:]+:[^@]+@(?!localhost|127\.0\.0\.1|example\.com)[^/]+`
+- MySQL: `mysql://[^:]+:[^@]+@(?!localhost|127\.0\.0\.1|example\.com)[^/]+`
+- MongoDB: `mongodb(\+srv)?://[^:]+:[^@]+@(?!localhost|127\.0\.0\.1|example\.com)[^/]+`
+
+**Generic High-Entropy**
+- Vars named `*SECRET*`, `*KEY*`, `*TOKEN*`, `*PASSWORD*`, `*AUTH*`, `*CREDENTIAL*`, `*API_KEY*` assigned strings >20 chars with mixed case/numbers
+
+## Ignore
+
+**Files**: `.env.example`, `.env.template`, `*.test.ts`, `*.spec.ts`, `__tests__/*`, `__mocks__/*`, `*.md`, `docs/*`
+
+**Values**: starts with `your_`, `enter_`, `my_`, `test_`, `demo_`, `example_`, or wrapped in ``, `{YOUR_*}`, `[YOUR_*]`, or equals `xxx`, `12345`, `test`, `demo`, `sample`
+
+**Hosts**: `localhost`, `127.0.0.1`, `0.0.0.0`, `example.com`, `test.com`
+
+**Public keys**: starts with `ssh-rsa`, `ssh-ed25519`, `ssh-dss`, or ends in `.pub`
+
+**Hashes**: SHA256/SHA512/MD5 for integrity, git commit hashes, package lock hashes
+
+## Severity
+
+- **Blocking**: Live production keys (AWS, Stripe live, real API keys), production DB credentials, production cert private keys
+- **High**: Dev/staging credentials, test keys for paid services, high-entropy secrets of unknown origin
+- **Medium**: Rotated credentials still in code, dev credentials in non-example files, weak secrets
\ No newline at end of file