Local PR self-review for OpenCode. Intercepts
gh pr create, runs a multi-pass LLM code review against your staged diff, and feeds blocking findings back into the same OpenCode session — before the PR ever leaves your machine.
Prephlight is an OpenCode plugin that brings
"smart reviewer caught what the coder missed" feedback to your local
workstation. When you (or your coder agent) run gh pr create, the plugin:
- Intercepts the command via OpenCode's
tool.execute.beforehook. - Resolves the base branch and loads the full content of every changed file (not just the diff — full files give the reviewer real context).
- Detects the languages in the diff and dispatches one specialist reviewer session per language, plus one cross-cutting architecture reviewer — all running in parallel as child sessions.
- Consolidates findings, sorts by severity, and either:
- Blocks the PR (CRITICAL / HIGH / MEDIUM findings) by throwing,
injecting context into the coder session, appending a TUI prompt nudge,
and writing a markdown report at
.opencode/reviews/latest.md. - Passes through (LOW findings or cap reached) by appending findings
to the PR body and letting
ghproceed.
- Blocks the PR (CRITICAL / HIGH / MEDIUM findings) by throwing,
injecting context into the coder session, appending a TUI prompt nudge,
and writing a markdown report at
- Tracks state so the loop bounds itself: max 3 iterations on the same diff-hash, max 10 iterations on the same branch. After that, findings are appended to the PR body and the PR is allowed through.
What it is NOT:
- Not a CI bot — review happens at PR-creation time, on your machine.
- Not a static linter — it uses LLM reasoning, not rule sets.
- Not telemetry — nothing leaves your workstation except calls to the LLM provider you have already configured in OpenCode.
- Not multi-tenant — single user, single workstation.
Prephlight runs locally on your machine and only calls the LLM provider you have already configured in OpenCode. Two things are worth understanding before you use it:
- Your staged diff and the full content of every changed file are sent to
your configured LLM provider. That is how the review works — the reviewer
needs the code to review it. The diff is not secret-scrubbed before it is
sent, so if your staged changes contain credentials, tokens, or other
sensitive data, that data is included in the request to the LLM provider you
configured in OpenCode. Review your diff before running
gh pr create, and do not use prephlight on changes you would not paste into that provider. - LLM output and on-disk config/state files are treated as semi-trusted
inputs. The plugin assumes the model's response and the contents of
.opencode/review-config.json/.opencode/review-state.jsoncould be hostile, and hardens against it: prototype-pollution key filtering on all JSON merges, an LLM-output sanitiser that neutralises HTML-comment hiding and tag-reconstruction in both the markdown report and the PR body, symlink refusal (O_NOFOLLOW) on file reads/writes, path-traversal allow-lists, and bounded regexes to avoid ReDoS.
Prephlight loads no third-party or attacker-supplied code: reviewers are statically imported and dispatched as data, never resolved by id or path.
Prephlight is installed as a local OpenCode plugin by symlinking the repo into OpenCode's plugin directory.
git clone https://github.com/mguttmann/prephlight.git
cd prephlight
bun install
ln -s "$(pwd)" ~/.config/opencode/plugins/prephlight
# Restart OpenCode (or reload the session) for the plugin to register.Verify the install:
bunx tsc --noEmit # type-check passes
bun test # tests passThen add the plugin to your opencode.json — see
examples/opencode-config-snippet.json.
gh pr create
│
▼
[ tool.execute.before ] ← prephlight hook
│
▼
bypass / config check ── skip? ──▶ pass-through
│
▼
diff + state load
│
▼
counter at cap? ── yes ──▶ append findings to PR body, pass-through
│ no
▼
language detection ──▶ knowledge file loading (3-tier lookup)
│
▼
parallel reviewers (one session per language + 1 architecture)
│
▼
findings consolidation
│
├── blocking? ──▶ markdown + session.prompt + appendPrompt + toast + throw
│
└── non-blocking? ──▶ markdown + append findings to PR body + let gh run
The reviewer prompts are built from three deterministic inputs:
- Severity definitions (CRITICAL / HIGH / MEDIUM / LOW), passed verbatim to every reviewer so severity is applied consistently and never paraphrased.
- Knowledge files — language-specific review heuristics shipped in
knowledge/, with 3-tier override (repo > user > built-in). - The changed files (full content + unified diff) plus PR title and draft body.
Each reviewer returns structured JSON validated by Zod
(FindingsResponseSchema in src/reviewers/findings-parser.ts). Invalid
output is treated as "no findings" with a warning log — we never block the
user on a parser failure.
Prephlight reads four configuration sources, in order of precedence (highest first):
- Environment variables (per-command overrides — see below).
<repo>/.opencode/review-config.json(repo-level).~/.config/opencode/review-config.json(user-global).- Built-in defaults.
A sample repo-level config lives at
examples/review-config-snippet.json.
| Key | Default | Effect |
|---|---|---|
enabled |
true |
Master kill-switch for the plugin in this repo. |
blockingSeverities |
["CRITICAL","HIGH","MEDIUM"] |
Which severities abort gh pr create. |
maxIterationsPerDiff |
3 |
Cap before findings auto-pass-through on the same diff-hash. |
maxIterationsPerBranch |
10 |
Branch-level cap (independent of diff-hash cap; resets on a clean review). |
maxFilesInDiff |
25 |
Soft top-N cap: above this, the lowest-LOC files are reviewed, rest dropped. |
maxLocInDiff |
50000 |
Aggregate LOC ceiling across reviewed files (top-N policy, same as above). |
reviewerTimeoutSeconds |
90 |
Per-reviewer wall-clock timeout. |
skipPatterns |
docs/lockfiles/configs (see schema) | Globs that don't count as reviewable changes. |
includeArchitectureReviewer |
true |
Whether to also spawn the cross-cutting reviewer. |
notifications.macosToast |
true |
Toast notification on blocking findings. |
notifications.macosSound |
true |
Sound chime on CRITICAL only. |
notifications.macosSoundName |
"Funk" |
Built-in macOS system sound used when macosSound fires. |
model.providerID |
"anthropic" |
Reviewer model provider (defaults to Anthropic). |
model.modelID |
"claude-opus-4-8" |
Reviewer model id (temperature is hard-coded to 0 regardless). |
agentName |
"prephlight-reviewer" |
Agent definition used for reviewer sessions. |
languages.aliases |
{} |
Per-repo extension overrides (e.g. {".mjs":"javascript"}) for the detector. |
Three independent ways to skip review, in increasing scope:
PREPHLIGHT_SKIP=1 PREPHLIGHT_SKIP_REASON="docs-only follow-up" \
gh pr create --title "Fix typo in README" --body "..."PREPHLIGHT_SKIP_REASON is mandatory when PREPHLIGHT_SKIP=1. The reason
is logged into a bounded ring buffer in .opencode/review-state.json so you
have an audit trail of every skip you've taken.
{ "enabled": false }in .opencode/review-config.json. Useful for repos where the plugin doesn't
fit (e.g., docs-only repos, archived projects).
{ "skipPatterns": ["docs/**", "*.md", "CHANGELOG*"] }If a PR only touches files matching one of these patterns, review is skipped silently and the PR proceeds.
| Variable | Effect |
|---|---|
PREPHLIGHT_SKIP |
Set to 1 to bypass review for the next gh pr create. Requires reason. |
PREPHLIGHT_SKIP_REASON |
Required when PREPHLIGHT_SKIP=1. Logged into state. |
PREPHLIGHT_DISABLED |
Set to 1 to disable the plugin for the entire OpenCode session. |
PREPHLIGHT_DEBUG |
Set to 1 for verbose logging via client.app.log. |
- Prototype-pollution guard. Configuration and state files are JSON-parsed with a
__proto__/constructor/prototypekey filter to prevent prototype-pollution attacks via a malicious.opencode/review-config.jsonor.opencode/review-state.json. (Side effect: a future config or state schema field namedconstructor/prototype/__proto__would be silently dropped during merge.) - LLM output is treated as untrusted. The sanitiser is a single-pass state machine that strips
<!--and-->delimiters and HTML-encodes</>in both the local markdown report and the GitHub PR body — protecting against tag-reconstruction bypasses (<sc<!---->ript>cannot become<script>), HTML-comment hiding in markdown previews, and regex DoS on prompt-injected payloads. The sanitiser runs overtitle,file,rationale,scenario,suggestion,summary,references[], andcontextNotes[]. --body-filesymlink refusal. Prephlight refuses to read or write through--body-filepaths that resolve to symbolic links. Use a regular file or pipe the body via--body "..."instead.- Free-form field bounds.
rationale,scenario,suggestionare capped at 8 192 bytes;fileat 1 024;references[]andcontextNotes[]items at 2 048;reviewerat 128. Inputs over the cap are dropped with acontextNoteswarning.
- First-message hook miss (OpenCode bug #6862). The
tool.execute.beforehook may not fire on the very first tool call of a fresh session. Re-running the command intercepts normally. - Quoted invocations. A
bash -c "gh pr create ..."wrapper hides the command inside a quoted string. The matcher only catches unquoted invocations. - Editor-mode
gh pr create. Whengh pr createis invoked with no body-providing flag, gh opens$EDITORfor the user. Prephlight bypasses review in this case (it cannot inject findings into an editor that hasn't opened yet).
latest.mdis a regular file, not a symlink. The refresh sequence usesO_NOFOLLOW+O_EXCLon a temp file, lstats the destination, refuses to rename through a symlink, and atomically renames the temp ontolatest.md.- Stale-tmp-file sweep. On every
writeFindingsMarkdownentry, the plugin scans.opencode/reviews/forlatest.md.tmp-<pid>-<nonce>files older than ~1 hour and best-effort deletes them. Younger temp files are left alone in case a concurrent write is in flight. fd.close()errors no longer mask write errors. Everyfd.close()in the output layer is wrapped in.catch(() => {})so the original write/read error survives and the underlying fd cannot leak.languages.aliasesis case-insensitive.{".kt": "Kotlin"}works as expected; the language id is lowercased before the ASCII allow-list regex.
- Counter-on-
gh-failure. The iteration and branch counters are incremented beforegh pr createactually runs. Ifghitself fails (token expired, network down, repo state issue), the counter remains incremented. Re-runninggh pr createafter fixing the unrelated issue may show fewer remaining iterations than expected. - Reviewer timeout default = 90s. A single reviewer hitting the timeout produces a blocking MEDIUM finding. For very large PRs, raise
reviewerTimeoutSecondsin.opencode/review-config.jsonor split the PR.
- Single writer per repo.
.opencode/review-state.jsonis rewritten atomically per review run, but concurrent runs against the same repo can produce a race. The race-merge is best-effort; a narrow TOCTOU window inside the write itself is documented and accepted as a v0.1 limitation. - State reset on schema change. Future prephlight versions may add required state fields. The plugin falls back to fresh state on validation failure and logs a warning — but iteration counters reset to 0.
- Stdin body silently discarded.
gh pr create --body-file -(stdin) gets rewritten to--body "<findings>". The original stdin content is NOT preserved (we cannot read stdin twice from insidetool.execute.before). --fill-firstuses git history. Both--filland--fill-firstusegit logto derive title and body. Prephlight aggregates all commits in the range; gh's exact--fill-firstsemantics may differ in edge cases (multi-line first-commit subjects).
- macOS-only sound notifications. Toast notifications fall back to the TUI on Linux. The
osascript-based sound is macOS-only and silently skipped on other platforms.
- Detached HEAD. PRs from detached HEAD use the commit SHA as the branch identifier. The default 10-iteration branch cap applies per unique SHA.
- Single-reviewer-per-language model. Each language gets one specialist reviewer per PR. Multi-iteration runs spawn fresh reviewers; child sessions are deleted on completion (success or timeout) but if
client.session.deleteis unavailable in the SDK, the sessions may linger until OpenCode restart.
prephlight/
├── src/
│ ├── plugin.ts # entry; hook registration
│ ├── logging.ts # client.app.log wrapper
│ ├── trigger/ # gh pr create detection + bypass
│ ├── analysis/ # base-branch, diff, languages, knowledge
│ ├── reviewers/ # orchestrator + per-role drivers
│ ├── state/ # review-state.json read/write/GC
│ ├── config/ # config merge + Zod schema
│ ├── output/ # markdown, session-feedback, PR-body
│ └── prompts/ # severity + base prompts
├── knowledge/ # default reviewer heuristics per language
├── agent/ # shared reviewer agent definition
├── examples/ # opencode.json + review-config snippets
└── tests/ # unit + integration + manual e2e
Each module has a single responsibility and a clear contract, so the codebase can be navigated by responsibility rather than by file count:
trigger/decides whether a givengh pr createinvocation should be intercepted at all (command matching + the three bypass paths).analysis/resolves the base branch, loads the diff and full changed files, detects languages, and loads the matching knowledge files.reviewers/orchestrates the parallel reviewer sessions, validates each reviewer's JSON, and consolidates findings by severity.state/reads, writes, and garbage-collects the iteration/branch counters and the bypass audit ring buffer.config/performs the four-tier configuration merge against the Zod schema.output/produces the markdown report, injects context into the coder session, and rewrites the PR body for pass-through.
This is an early v0.1.x plugin and APIs may shift. See
CONTRIBUTING.md for the full development setup and
conventions. In short:
- Open an issue describing the change before opening a PR for anything non-trivial. The plugin is small and opinionated; we'd rather discuss shape than redo work.
- All TypeScript code is strict mode, no
any— useunknown+ narrowing. - All module boundaries that accept external input use Zod for validation.
- Tests run with
bun test; type-checking isbunx tsc --noEmit. - Production code paths never write to stdout or stderr — use
src/logging.ts(createLogger(client)).
MIT © 2026 Manuel Guttmann