Commit graph

43 commits

Author SHA1 Message Date
Zhenghua Bao
a49ffb5981
chore(summary): drop --fix-first and --held, which nothing reads (#55)
Both flags survived the cascade that gave them meaning. --held marked a
cheap pass withholding a strong review, and --fix-first let the blocking
count follow the fix-first set on such a run, because block-on need not
contain those severities. There is no withholding and no tier line any
more, so the count has followed block-on alone for some time — while the
two flags went on being parsed, and --fix-first went on being validated,
with neither value read by anything downstream.

fix-first REMAINS an action input. It stops the exhaustive loop early
(action.yml), which is a different question from what the summary counts;
the header now says so, so the name cannot argue its way back into this
count later.

Removing a flag the script no longer understands is safe to ship on its
own: the argument loop has no else branch, so an unrecognised flag and the
value after it are skipped one token at a time without shifting the flags
that follow. The surviving block-on test now passes --fix-first in the
MIDDLE of the argv to hold that property down.

Also scopes the action-inputs reference to this action: the hosted App runs
a different review engine, so fix-first and exhaustive can behave
differently there, and the file now says to answer App questions from the
console instead.

Test suite is unchanged by this: 558 pass / 9 fail before and after, the
failures being a pre-existing CRLF mismatch in the action.yml input tests
on a Windows checkout.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 10:09:40 +08:00
Zhenghua Bao
6d253e0399
feat: raise the diff limits so a large PR gets reviewed instead of refused (#53)
512 KB / 300 files / 20 minutes becomes 5000 KB / 2000 files / 60 minutes.

The old limits were a ceiling on WASTE — chosen so tokens were not spent on
diffs that review badly. The side effect was that a large PR could never be
reviewed at all, and "never" is worse than "expensive". Size alone was never
the right test either: the engine reads the diff through its own tooling rather
than taking it as one prompt, so a 2 MB diff is not automatically beyond it.

What does bound it is the model's context window, which the action cannot see —
it is given a model string, and the actual route is the workspace's recipe. So
this trades a clean, cheap refusal for a real attempt that may end in a
wall-clock timeout or an aborted pass. That is a deliberate trade and it is
written into the input description, including how to get the old behaviour back
(lower the number).

The timeout had to rise with the limits, not after them. At 20 minutes a review
allowed to accept a much larger diff would have been killed halfway — the same
non-review as before, with a worse message. It is a per-pass ceiling and
`exhaustive` makes up to three passes, which is now worth saying out loud
because 3x60 is a different proposition from 3x20.

The two limits are counted separately because they describe different shapes: a
mechanical rename across 900 files is small and shallow, one 4 MB generated
file is large and shallow, and either can be worth reviewing.

Every place that states these numbers moved together — the inputs, the guard's
own fallbacks, the oversized-diff notice posted on the PR (whose example values
were 1024/500, which after this change would have been advice to LOWER the
limit), the example workflow, the skill's workflow template, and the skill's
input and troubleshooting references.

The guard's fallbacks are pinned to the inputs by a test now, because they are
one number in two places and the second one is reachable: action.yml passes
`--max-kb "$MAX_KB"` quoted, so `max-diff-kb: ""` arrives as an empty argument
and lands on the fallback. A stale number there would enforce a limit nobody
documented, silently. The test reads the default out of action.yml rather than
restating it, and is mutation-checked in both directions — editing only the
script reds it, and so does editing only action.yml.

That test also turned up a defect in itself, which is worth recording because
its failure mode was invisible: it anchored on bare "\n" while this repo has no
`.gitattributes` and `core.autocrlf` is the Windows default, so action.yml is
CRLF in every Windows checkout. It would have passed on Linux CI forever and
failed on every developer machine — and CI green reads as "works". Newlines are
normalized on read now, mutation-checked against the CRLF working copy.

567 tests, 558 passing, failure set identical to main's (9 pre-existing and
unrelated). The file-count fixture is ~180 KB, far under the size limit, so it
still tests the count and not the size.
2026-09-09 15:02:50 +08:00
Zhenghua Bao
ff658d1e92
fix: fail closed when the L2 judge is unavailable, and name which gate closed (#52)
* fix: a transient 5xx should not cost a whole review

The judge call had no retry. Every other LLM call in the pipeline retries 5xx
four times; this one fetched once and exited on anything that was not 200. A
single 502 was enough to take it out — and with precision filtering on, that
now takes the review with it. It retries the same statuses, with the same
bounds, and reports the body of the LAST failure so the log names the actual
upstream reason instead of "attempt 4 failed".

L2 is no longer soft-fail, which is the change that makes the retry matter.
L1's output is not a shippable review: it is the engine's raw findings with
mismatched snippets re-homed, still carrying the duplicate and low-value ones
L2 exists to cluster and drop. Publishing it because the judge was unreachable
shipped exactly the noise the filter was turned on to remove, and it arrived
looking like a finished review. `precision-filter: "false"` remains the
supported way to run without a judge — it skips L1 too, which is the honest
version of that choice. The input docs say so now.

The failure messages were all the same sentence. An engine crash, a partial
run, and an unavailable judge printed one "no usable result", so a reader could
not tell which gate closed and picked whichever cause was last in the log —
which is how a run whose judge logged a 502 just before an unrelated failure
got read as the judge throwing the review away. Each cause now names itself in
the summary, and check-result tags its four reasons for anyone parsing.

The report_on failure default was every severity, on the reasoning that a
workspace predating the setting resolved to all four so no single value could
match what it normally sees. That premise expired: the gateway's own default is
P0,P1 now for any workspace that has not set the field, so all-severities
matched nothing and a settings outage published P2/P3 to installs that would
never have been shown them. Which is the wrong direction twice over, because
the outage that opens this up is the one most likely to have degraded the
filtering that keeps those severities useful.

Tests: 6 new, covering retry on each retryable status, the bound (exactly 4
attempts, last body reported), and that a 400 is not retried. Mutation-checked
by forcing attempts to 1 — five of them fail. Full suite goes 526 -> 533
passing with an identical failure set (10 pre-existing, unrelated).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: withdraw the judge retry — the proxy already owns it

The retry added to judge.mjs was wrong, and wrong in a way that would only
have shown up on the bill.

In production OCR_LLM_URL points at the local fact proxy this action starts,
and that proxy already retries 429/502/503/504 four times with backoff. A loop
in judge.mjs does not add resilience, it multiplies: four attempts here times
four there is sixteen upstream requests for a single judge call.

It was also a wider status set than the proxy's, and not by accident on the
proxy's part — it excludes 500 precisely because a 500 can mean the completion
was produced and billed, so replaying it buys the same bill twice. The outer
loop would have re-sent exactly that, bypassing a policy it did not know was
there.

So the 502 that prompted this was never an unretried call. It was the answer
after the proxy had already spent its retries, and no amount of asking again
would have changed it.

What replaces the code is a comment saying why the retry is deliberately
absent, and a test that pins the request count at one — so the next reader
with the same good idea meets a red test instead of a merge.

Also in this round:

- The judge-unavailable marker was written and read but never cleaned up, so
  a second pass in the same job could inherit the first pass's failure and
  report a judge outage that had not happened. It now clears with the other
  per-pass markers in both cleanup paths.

- A result carrying `warnings` is already doomed — check-result fails closed
  on it — so filtering it spent a judge call on output nobody would read, and
  if that call failed, the summary blamed the judge for a run whose real
  defect was a partial engine result. That is the exact mis-attribution the
  reason= tags exist to prevent. Such a result now falls straight through to
  the check, which reports reason=partial and names the warnings.

* fix: read `warnings` the way the check reads it, or the skip publishes unjudged

The skip-the-filter gate added last round tested `(r.warnings||[]).length`.
check-result.mjs tests `Array.isArray(parsed.warnings) ? parsed.warnings : []`.
On any non-array value the two disagree, and they disagree in the one direction
that matters.

A `warnings` of the string "oops" measures 4, so the gate called the result
partial and skipped L1 and L2 — on the reasoning that the check would reject it
anyway. The check normalized the same value to `[]` and published. Skip plus
publish is the engine's raw findings, unclustered and unjudged, shipped under
`precision-filter: true` and arriving as a green review: precisely what the
fail-closed judge branch in this PR exists to prevent, reintroduced by the
optimization meant to support it.

Reproduced before fixing and re-checked after, across every shape a `warnings`
field can take — non-empty array, empty array, absent, null, string, number,
object. Only the genuine non-empty array now skips, and that is the one the
check stops.

This also pins the direction the predicate has to fail in. Skipping is the
branch that can publish unjudged, so it may only be taken when the result is
definitely partial; anything malformed or unrecognized runs the filter.

The regression test extracts the real predicate out of action.yml and runs it,
rather than restating it, so the two cannot drift apart silently again. Its
match is deliberately loose — it does not require the `Array.isArray` spelling
— because a test that only detects a missing token would have gone red for the
wrong reason. Mutation-checked by restoring the old predicate: the string,
number and object cases fail with "filter skipped AND CHECK published".

* fix: name the judge failure instead of claiming it exhausted retries

"the L2 judge was unavailable after retries" was true for one failure class
and false for the rest. The judge sends one request; the proxy behind it retries
only 429/502/503/504. A 400, a 401, an unparseable completion envelope and a
model that answered in prose all reached that same sentence, which sent the
reader to wait out a transient outage that was not happening — the same
wrong-cause reading this branch exists to end, one layer up.

The reason is now carried instead of asserted. The judge's output is captured
rather than only streamed, its first line becomes the marker's content, and the
summary quotes the marker the way the wall-clock branch already did — the
marker was being written and then ignored in favour of a literal, so that write
had no effect at all.

First line, not last: the judge prints "judge did not return JSON:" followed by
a dump of what the model actually said, so a tail would quote a fragment of the
bad completion in place of the reason.

Two cases the log alone could not name:

- The judge exiting 0 while writing nothing was indistinguishable from a failed
  call. It now says so.
- An unreachable gateway had no named class at all. `fetch` rejects rather than
  returning a response when it cannot reach the endpoint, so the process died
  on an unhandled rejection and the first line of stderr was a path inside
  undici — which is what the summary would have quoted as the failure class.
  It is caught and named now, with the cause included, because fetch collapses
  every transport failure into "fetch failed" and hides ECONNREFUSED, DNS
  failures and header timeouts in `cause`.

  This one is a regression from withdrawing the retry: the loop's try/catch had
  been printing a named message, and removing the loop took it with it.

Verified by running all five classes, not by reading. Against a stub gateway,
each failure names itself on the first line: HTTP 400, HTTP 401, bad completion
envelope, judge did not return JSON, and could not reach the LLM (ECONNREFUSED
for a closed port). The action's plumbing was then driven with stub judges for
each class, and the summary line came out distinct and accurate every time,
including "it exited cleanly but wrote no output" and "it exited 9 without a
message".

Both new tests are mutation-checked: dropping the catch fails the unreachable
test on the no-stack-trace assertion, and switching the first-line assertion to
last-line semantics fails the class test.

* fix: the captured judge log needs cleaning, and a green run must not claim it failed

Two misses from the round that added the captured log, both in the same shape:
a new artefact was introduced without updating the place that already handles
every artefact of its kind.

The log itself was never cleaned. The cleanup lists enumerate `result.l1.json`,
`result.l2.json` and both `-extra` variants by name — deliberate and
exhaustive — and `result.l2.log` was simply not added to either. On a reused
self-hosted runner, and especially after a hard cancellation, that leaves judge
diagnostics behind for the next job: finding paths, root-cause text, and on a
malformed-completion failure up to 600 characters of model output.

Verified by running the failure paths and listing what survives: after an HTTP
401 and after a judge that exits 0 without writing, every leftover file is now
named in the cleanup list.

The second is a false claim rather than a leak. `::error:: … failing closed`
was printed by the judge branch itself, but whether a failed judge ends the run
belongs to the caller: on the mandatory pass it is fatal, on a best-effort
exhaustive pass `run_review` warns, keeps the findings already in hand, and the
job goes green. So an exhaustive pass-2 judge failure produced a green run
carrying a red annotation announcing a failure that had not happened — the same
register of wrong claim this whole pass exists to remove.

The branch now states the fact as a plain line inside the group the operator is
already reading, and both callers keep owning the consequence. This is what the
wall-clock marker directly above it already does.

The downgrade also carries the reason now. "exhaustive pass 2 produced no
usable result" is what an engine crash reads as too, and the reason is the only
thing that tells an operator whether to re-run or fix a key.

Verified by extracting the real `run_review` and driving it with a judge that
fails on the mandatory pass and then on the extra pass: the first yields
`::error:: … failing closed` and a nonzero exit, the second only
`:⚠️: … the L2 judge failed (HTTP 401 …) — keeping the findings so far`
and exit 0. No `::error::` is emitted on the green path any more.

* fix: the body read belongs inside the transport catch too

The catch added last round covered `fetch` and stopped there. `fetch` resolves
as soon as response headers arrive, so a connection that dies mid-body rejects
at `res.text()` instead — outside the catch, from a different undici frame,
still an unhandled rejection.

Which the summary then quotes as the failure class, because it lifts the first
line of the judge's stderr. So the operator is handed
`node:internal/deps/undici/undici:12141` as the reason their review failed.

Not hypothetical. fact-proxy relays headers first and destroys the connection
on a mid-stream upstream failure — "the headers are out, so destroy the
connection" is its own comment — and the proxy is what serves this call in
production. So this is the transport failure the judge is MOST likely to meet,
and it was the one still uncovered.

Reproduced against a server that writes headers and then destroys the socket:
`TypeError: terminated`, uncaught, first line inside undici. With the body read
inside the try it reports `could not complete the LLM request: terminated
(UND_ERR_SOCKET)`.

The wording moves from "reach" to "complete" because by the time a mid-body
drop lands there the request did reach the gateway; the cause code separates the
two, ECONNREFUSED never arrived against UND_ERR_SOCKET arrived and was cut off.

All six failure classes re-verified end to end after the change: HTTP 400,
HTTP 401, bad completion envelope, judge did not return JSON, ECONNREFUSED, and
the mid-body drop. Each names itself on the first line.

Mutation-checked by moving the body read back outside the try: exactly the new
test fails, and the unreachable-endpoint test stays green — they cover different
frames, which is the reason the first fix looked complete.

* fix: a malformed 200 must name itself, not report a line number

The summary lifts the first line of the judge's stderr as the L2 failure
reason. A response that parses but has the wrong shape threw outside every
handler, so that first line was `judge.mjs:195` — a source location, which
names nothing and implies no fix.

Two entrances, both reproduced before fixing:

- `content` present but not a string. Extraction succeeds and the `.replace`
  after it throws. Typed now where the raw response is still in hand to quote.
- `groups` truthy and not an array. A string iterates by character and an
  object is not iterable at all, so this threw a few lines down. It fails now
  rather than falling open: falling open keeps every finding unjudged, the one
  outcome the judge exists to prevent. The falsy cases are unchanged — absent
  or null means the judge classified nothing, which the fail-open pass handles
  deliberately, and a test pins that the new guard does not sweep them up.

Enumerating malformed shapes is always one shape short, so there is also a
backstop: an uncaught throw or unhandled rejection prints one stable summary
line and then the stack. Nothing is lost from the log, because the action echoes
all of it and promotes only the first line. Verified against both a synchronous
throw and a top-level-await rejection.

All five reported shapes now name themselves: content null / number / object,
groups string / object / number.

Mutation-checked three ways — removing the content guard reds exactly the three
content tests, removing the groups guard reds exactly the three groups tests,
and removing both plus the backstop reds all six.

Also: the compatibility note beside `report_on` said an omitted field "behaves
as before". That stopped being true when the default moved from all four
severities to P0,P1, and the sentence was left standing. It now states what
actually happens, including that the report filter shows report_on UNION
block_on and block_on defaults to P0,P1 too, so P2/P3 are not recovered
elsewhere. The value is unchanged and deliberate: the gateway sets P0,P1 itself
for any workspace that has not chosen one, so an omitted field means "never
set", not "server too old to have the field".

* fix: name what a valid `groups` IS, because the reject-list had falsy holes

The guard added last round tested `parsed.groups && !Array.isArray(...)`.
`false`, `0` and `""` are falsy AND non-arrays, so they walked straight past it
into `|| []`: every finding came out unclassified, the fail-open pass kept all
of them, and the judge exited 0.

Which means a response that violated the schema published every finding as
JUDGED, under `precision-filter: true`, as a green review. That is the exact
outcome this branch fails closed to prevent — reintroduced by the guard that
looked like it closed it.

The test now names what is ALLOWED instead of what is rejected: `groups` may be
absent or null, both meaning "the judge classified nothing" and handled by the
fail-open pass on purpose, and anything else must be an array. An accept-list
cannot have this shape of hole — a value that is neither exemption nor an array
has nowhere to go but the failure branch, whatever type someone invents next.

That framing is the actual fix here. This is the third time in this branch that
enumerating bad values came up one value short, and each time the miss landed on
the publishing side.

The new tests assert more than the exit code, because the failure mode WAS a
successful exit: they also assert that no output file is written. Mutation-
checked by restoring the truthiness form, which reds exactly the three falsy
cases and nothing else.

Audited the sibling of the same shape rather than assuming: the precision gate
in action.yml counts `(r.comments||[]).length`, so a string `comments` reads as
"has findings". Ran every non-array value through the gate and the check
together — string, number, object, boolean, null, absent — and check-result
requires `Array.isArray(parsed.comments)`, so all six fail closed. A string
spends a judge call before being rejected, which is wasteful and safe; the
publishing side is sound and left alone.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 14:13:25 +08:00
AnKaifeng
5dae0d66b5
feat: review locally with your own agent as the engine (#51)
* feat: review locally with your own agent as the engine

`review plan` prints everything a reviewer needs — files in scope with the
reasons for exclusions, per-language checklists, the P0-P3 rubric, the output
shape, the project's conventions — and `review submit` verifies positions,
dedupes, applies the gate and reports, in text, markdown or JSON. Nothing in
this path talks to OrcaRouter or needs a key; the thinking is the agent's.

- `--pr <n>` reviews a pull request by number without checking it out
- `--lang` follows the user's language through the plan, findings and report
- file selection is Open Code Review's own rules, vendored as data under
  vendor/open-code-review (Apache-2.0) with a JS port of its matcher — no
  `ocr` binary to install
- `.orcacode-review.json` holds a repo's block_on / language / exclude / rules;
  `review config` shows what applies and `review config init` writes it; the
  plan offers to create it once, after the first review
- exit 0 for a review that ran; `--fail-on-block` for hooks that want a 1;
  2 for an unusable result is never suppressed

Skills are renamed to orca-review and orca-review-action; installing the new
names retires our old ones beside them. The installer now asks how you will
use it — local, Action, or both — and `--mode` answers that for scripts.
rules/output-shape.md shows a wrong/right title pair, because a real PR got a
sixteen-word imperative title with a semicolon.

* docs: three demos — install, Action setup, local review — and a README that shows each

Recorded with vhs against a throwaway repo (docs/tapes/README.md says how).
Every prompt takes its recommended answer. The old combined demo is retired;
the two features get their own GIF, with the 3x-speed mp4 linked beside each.

* docs: README talks to the person, not the terminal

Drop the '3x · mp4' captions under the GIFs, and the menus of npx subcommands
and flags. The product is the two skills; you tell your agent what to review
and it drives the CLI. The one command that stays is the install. Flags and
the review contract are documented where a scripter looks, in --help and
skills/orca-review/references/contract.md.

* release: 2.1.0

package.json, plugin.json and marketplace.json move together, as the publish
workflow insists. The tarball check now also names the local-review files —
harness, selection, config, the vendored rules, both skills — so a files:
regression in package.json fails the release instead of shipping a plan with
no rubric.
2026-09-03 10:27:13 +08:00
Zhenghua Bao
5ecfbf339c
chore: current weights in the pasteable recipe (#50)
Reviewer deepseek/deepseek-v4-flash -> deepseek/deepseek-v4-flash-0731, judge
deepseek/deepseek-v4-pro -> z-ai/glm-5.3, matching the control plane's
provisioning constant. Both verified present and in the `default` group on the
live gateway's /api/pricing. The request named "0730"; 0731 is the dated weight.

Also unpins two docs from a specific model that is no longer the one used: the
judge-model note in action.yml and judge.mjs's usage line, which showed
deepseek-v4-pro as its example and so read as a default. The usage line now
shows the router alias, which is what the action actually passes.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 13:48:45 +08:00
Zhenghua Bao
7b24e7048c
fix: the shipped recipe kept a bare default, so a pasted copy lost the judge (#49)
The judge rule went into the control plane's provisioning constant and its own
pasteable copy, and not into this one — the file a BYO consumer actually pastes.
So setup produced a correct recipe while the documented one sent the judge to
`default:`, i.e. the reviewer's own model. A judge scoring work its own model
produced agrees with it and still reports the pass as successful, so a workspace
following the docs got a silently inert L2.

Caught by a reader, which is the third time a recipe has drifted that way: the
fact contract still said none|cheap|strong after one value was ever sent, the
per-angle rules outlived the path that could reach them, and now this.

The cause is structural and stays: the authority for what setup writes is a Go
constant in another repository, and nothing here can see it. Cross-repo
agreement is not assertable. So the new suite pins the properties that make a
recipe self-consistent instead, which is what each of those three failures
actually violated:

- every rule names a model, and a default exists
- every `when:` keys on a fact the action actually sends, so a rule cannot
  describe a policy that never applies
- the Action's recipe carries a judge rule
- that rule does not name the default's model
- it carries no per-angle rule, which no Action call can reach
- it names the router by the alias action.yml defaults to, so a rename cannot
  leave the paste target pointing at a name that no longer resolves

Verified the way it will be relied on: removing the judge rule turns two of
them red.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 10:08:40 +08:00
AnKaifeng
60eeeecfff
release: 2.0.0 — the Action is the only install path, and the CLI drops itself (#44)
Cuts the release that carries #41 (App mode gone from the skill) and #42
(reactions on the default token, judge model from the recipe), and fixes a
dependency the package should never have had.

THE PACKAGE DEPENDED ON ITSELF. 1.1.0 added `@orcarouter/code-review: ^1.0.2`
to its own `dependencies` and five releases carried it. Every `npx` therefore
downloaded a second, older copy of the CLI into node_modules before running the
one it came for — latency on the first thing a new user does, and RELEASE.md
already said the CLI has no dependencies. Nothing failed, which is why it
survived: the bin resolves from the top level, so the nested copy is dead
weight rather than a wrong entry point. A self-reference is also a registry
dependent, and npm reads dependents when deciding whether a version may be
withdrawn.

Pinned by a test rather than a note — `dependencies`, `peerDependencies` and
`optionalDependencies` must all be empty. The invariant was already documented
in prose and still broken for five releases.

2.0.0, not 1.5.1. 1.5.0 shipped the App-mode removal as a minor, and the
1.x line is being unpublished inside npm's 72-hour window, so 2.0.0 is the
first version on the registry that a user can actually install — the major is
where the break belongs, and a lone `1.5.1` would imply a history the packument
no longer has.

RELEASE.md records what went with the withdrawn versions, because a packument
with one version reads as a truncated upload. It also says plainly that
unpublishing was defensible only for a two-day-old line with no known
consumers, and that deprecation is the default everywhere else.

Three versions move together (gate 1): package.json, plugin.json,
marketplace.json.
2026-08-26 21:33:25 +08:00
Zhenghua Bao
5ff702cfc7
fix: react on the default token, and let the recipe pick the judge model (#42)
Two things, both measured on a real run rather than reasoned about.

REACTIONS NEVER HAPPENED. The gate read `gh api user --jq .type` and declined
unless it saw "Bot". That endpoint needs a user-scoped token, and github-token
defaults to ${{ github.token }} — an installation token, for which it returns no
user. The `|| echo "Bot"` fallback was meant to cover that and did not: the call
can exit 0 with no `.type`, so ME_TYPE came back empty, `!= "Bot"` held, and both
the 👀 and the settle step skipped — for every consumer on the default token,
which is the case the feature exists to serve. A run logged "github-token
belongs to a user, not an app" while the reaction already on the PR was authored
by github-actions[bot].

Inverted to fail towards acting: a PAT identifies itself as type User and
declines; 403, empty and Bot all react. That also unsticks the 👀 left behind by
runs that predate the settle step, since the clear now runs.

THE JUDGE MODEL COMES FROM THE RECIPE. judge-model defaulted to a concrete
model, so the L2 call named it directly and never reached the router — the one
routing decision in the product that a workspace could not make for itself. Its
default is now empty, which sends the router alias, and judge.mjs stamps
`x-cr-lens: judge` so the recipe's judge rule chooses.

No model change: the shipped recipe routes that rule to deepseek-v4-pro, which
is what judge-model used to name. Setting judge-model explicitly still pins a
model outright, regardless of the recipe.

The header goes out unconditionally, including when --model names a concrete
model: nothing resolves an alias then, so nothing reads it, and "is this an
alias" is the gateway's judgement rather than this script's.

ORDER: OrcaRouter-O2#1433 puts the judge rule in the provisioned recipe and has
to be deployed first. Without it the judge lands on the recipe's default — the
model it is scoring — which agrees with itself while still reporting success.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 19:59:38 +08:00
AnKaifeng
9f7e019101
feat!: drop GitHub App mode from the skill (#41)
BREAKING for anyone who asked the skill for App mode: it no longer offers
it. One install path — the Action.

Removed: the mode-choice step, the whole App-mode section, the
"do not install both" warning, and the App trigger phrases in the
frontmatter description. The install flow starts at Preflight again and the
steps renumber 1-8.

App mode was always the weaker half. Installing a GitHub App is a permission
grant that GitHub requires a human to approve on a web page, so the agent
could only ever hand over a link and wait — and on an org repo the person
running the skill usually cannot approve it anyway. The Action needs write
access and the agent finishes it.

The double-review symptom stays in troubleshooting, reworded. The App still
exists at github.com/apps/orcacode-review and somebody may install it
directly, so a repo can still end up with two reviewers; the entry now says
plainly that this skill is not what put it there.

Tests swapped rather than deleted: three that assert App mode is gone, that
the install flow opens on Preflight with no mode question, and that the step
numbers are contiguous 1-8 — removing a step is exactly where an off-by-one
would hide, and a skill that skips a number reads as a truncated file.

README alt text no longer claims the demo covers App mode. The recording
itself still shows it and is not re-cut here.

151 lines out of SKILL.md.
2026-08-26 19:56:19 +08:00
AnKaifeng
f9630633d1
feat(cli): add Japanese and Korean (#34)
Four languages now: en, zh, ja, ko. Picked from the locale, overridable with
--lang or ORCACODE_LANG.

Adding a language used to be more places than it looked. It is now three:

  - append the code to LANGUAGES
  - add its locale prefixes to LOCALE_PREFIX
  - add the table

The language picker was a hardcoded two-element array, so a new table would
have shipped with no way to select it. It is generated from LANGUAGES now,
and every table must carry a `lang.<code>` label for every language so each
one can name the others — a test enforces that.

Traditional Chinese locales (zh-TW, zh-HK) resolve to English on purpose.
The vocabulary diverges enough from Simplified that serving zh reads worse
than not translating, and a test pins that so nobody "fixes" it later.

The parity tests only compared Chinese against English, so this change could
have shipped half-translated with a green suite. They now run over every
non-English table: missing keys, extra keys, function/string mismatches, and
differing argument counts. The literals check — flags, gh commands, workflow
input names, which must never be translated because the reader still has to
type them — also runs per language now.

411 tests.
2026-08-26 16:29:16 +08:00
AnKaifeng
60e988f1fd
docs(cli): --help called the wrong command the default, for two releases (#33)
1.2.0 made the bare invocation install the skill. --help kept saying `init`
was the default through 1.3.1 — so the one place a user looks to find out
what `npx @orcarouter/code-review` does told them the wrong thing.

  init             Write .github/workflows/orca-code-review.yml yourself
  skill install    Install the agent skill (36 platforms) — the default

Adds a line under Usage saying what the bare command does, since that is the
question --help is being asked. Examples now use the scoped package name;
they still said `npx orcacode-review`, which stopped resolving when the
package moved under the org.

The real fix is the test. Nothing connected the help text to the routing, so
the two drifted for two releases without a complaint. It now reads the
fallback out of main() — `argv._[0] ?? "skill"` — and asserts that exactly
one line in the Commands block is marked default and that it is that
command, in both languages. Verified by putting "(default)" back on init:
red.

Writing it turned up an overlap worth noting: the Options block also says
"default", because the --lang row explains its own. Counting those would
fail the test for a reason unrelated to routing, so it reads the Commands
block only.
2026-08-26 15:29:17 +08:00
AnKaifeng
5fb95e95f6
docs: drop the tier label from docs that outlived it (#32)
There is no add- or remove-label step anywhere in action.yml. Three files
still described one, and the one that mattered most is the copy written into
every user's repo.

#28 retired the review cascade and updated `workflows/orca-code-review.yml`,
but the skill keeps its own copy of that workflow in `assets/`. The example
was fixed; the template users actually receive was not.

Corrected:

  - skills/.../assets/workflow.yml — "tier state label + clean/fallback PR
    comments" -> "clean/fallback PR comments", matching the example
  - references/inputs.md — github-token no longer "manages the tier label";
    "Models | per tier" is now one model, because there is one tier
  - action.yml — the input description those two were copied from

`issues: write` stays. It is still required, just for a different reason
than the comment claimed: PR comments, and the emoji reaction on a
/orcacode-review command. Only the justification was wrong.

Left alone because they are still true: the x-cr-prev-tier fact the proxy
injects (kept deliberately so a future size-based routing policy needs no
Action change), and the `tier` field in the run report — now noted in
inputs.md as always "standard", so nobody reads it as varying.

Five tests close the gap that caused this. The example workflow and the
skill template must agree, comment-stripped, on `permissions:`, `on:` and
`concurrency:`, and neither may mention a tier label. Verified by reverting
the fix: the tier-label test goes red.
2026-08-26 15:22:31 +08:00
Zhenghua Bao
9b484d0fd9
fix: point the action at the router's current name (#29)
* fix: point the action at the router's current name

The `router` input defaulted to `orcarouter/code-review`. That router is
provisioned as `orcacode-review` — the old name is renamed on setup, and the
gateway addresses a router by its current name with no fallback at resolution
time, so the default named an alias that a provisioned workspace does not have.
The worker path already used the current name, so the two review paths pointed
at different aliases.

A workspace still holding the old name gets `model router 'code-review' not
found` once this ships, and the fix is to run setup, which renames it. That is
also what the console already tells them: the router card reads red for a
workspace on the pre-rename name, deliberately, because the relay cannot
address it.

Renamed the recipe file alongside it so the pasteable artifact matches the
router it is pasted into, and the header now says what to do when a review
fails with the not-found error.

The recipe's default model is now `deepseek/deepseek-v4-flash`, which is what
setup provisions — so this file and a freshly created router agree. It said
`openai/gpt-5.5` and called it validated, which meant the documented recipe and
the one we actually write disagreed on the one line that decides review cost
and quality. The note above it now says plainly that this is the line to edit
and that it is the highest-leverage change available in this file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: the not-found error names the alias that was requested

The migration note had both upgrade states backwards. `resolveOrcaRouterModel`
formats the error from the REQUESTED name, so a workspace still on the old name
fails with `orcacode-review` not found, and a `code-review` not found means
something is still explicitly asking for the old alias — a `router:` override or
a recipe pasted into a router of that name. Setup fixes the first and cannot fix
the second, and the note pointed each at the other's remedy.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 15:02:41 +08:00
AnKaifeng
e7bb3d1097
feat(skill): offer Action or GitHub App mode, and check the permission first (#31)
The App exists at github.com/apps/orcacode-review but the skill only knew
about the Action. Now it asks which, and routes.

The ordering is the point. It probes the user's actual rights BEFORE
offering the choice:

    gh api /repos/<o>/<n> --jq .permissions.admin
    gh api /orgs/<o>/memberships/<user> --jq .role

Installing a GitHub App is a permission grant, so GitHub requires a human to
approve it on an authorization page — there is no REST endpoint that does it
on someone's behalf, by design. Offering App mode to a plain org member
walks them to a page that stops them. Verified against this very repo: the
account running it is `member`, `admin: false`, and could not complete it.

App mode therefore hands over a link rather than pretending to automate:

    https://github.com/apps/orcacode-review/installations/new

It prints the URL and only optionally opens it. A tool that silently calls
`open` in an SSH session, a container or CI leaves the user waiting on a
browser that will never appear.

Verification is honest about its limits. /repos/{o}/{r}/installation needs
the App's own JWT, which no user token can produce; /orgs/{o}/installations
needs admin:org, which is not worth asking for just to read a flag. So the
fallback is to open a PR and look for the bot — the thing the user cares
about anyway.

Also documents the failure mode this choice creates: both installed means
two reviews per PR and double spend. Recorded in the skill and in
troubleshooting, with the note that removing the Action is the reversible
half.

Renames Action step 5 from "Enable the app" — it means the OrcaRouter
console, which now reads as the GitHub App and would send people to the
wrong place.
2026-08-26 14:51:37 +08:00
Zhenghua Bao
b4fe7ee66b
feat: one review tier, and a report_on severity filter (#28)
* feat: one review tier, and a report_on severity filter

The recipe routes every branch to one model, and action.yml has hardcoded
PASSES=1 with no promotion for a while now. This drops what was left of the
two-tier machinery and adds the publish-side severity filter the dashboard
already offers.

Single tier
- action.yml: 22 steps -> 21. Gone: the PR-label read (one fewer paginated
  API call per run), the promote step, the second per-tier report step, and
  TIER_STATE / RESULT_CHEAP / PROMOTE / HELD / strong_ran.
- The tier reported to the control plane is `standard`. It used to be
  `strong`, from when a cheap pass could escalate; with one pass the label
  described the reviewer rather than the run.
- RESULT_STRONG -> RESULT_FINAL, result-strong.json -> result-final.json.
- summary-comment.mjs: the `Tier:` line is gone, and the blocking count now
  always follows --block-on. It used to follow --fix-first on a held run,
  which meant the summary and the merge gate could disagree about what
  blocks the PR.

report_on
- New scripts/report-filter.mjs, applied between the engine and the quiet
  filter: result.json -> result-reported.json -> result-posted.json.
- The published set is report_on UNION block_on, so a severity that blocks
  the merge can never be withheld from the timeline.
- Absent report_on (a gateway that predates the field) is pass-through;
  an empty value means "only what blocks".
- The gate and the run report keep reading the unfiltered result. Display
  filtering never moves enforcement or the numbers.

Docs and template
- README, the workflow template and the recipes describe one review per
  push instead of a fast-then-strong escalation.

Tests
- settings.test.mjs: the action.yml wiring assertions now go through a
  sliceStep helper that fails when a boundary step is missing. Two of them
  had been silently matching most of the file since their end-boundary step
  was removed.
- fact-proxy.test.mjs: same fix, same cause.
- report.test.mjs: asserts every report.mjs invocation sits behind the
  `report` input, as a count equality rather than a fixed number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: an empty report_on is a setting, not a missing one

Review round 1 found four real problems, three of them introduced here.

The report_on step guarded --show on `[ -n "$REPORT_ON" ]`, which sent an
empty value down the no-flag branch — and report-filter.mjs reads an absent
flag as "no setting, pass everything through". So a workspace configured as
"publish only what blocks" got every severity published instead: the exact
inversion the comment above it claimed to be preventing. --show now goes out
unconditionally. It is safe to do that because the settings step always
writes a value (the settings-disabled path writes every severity
explicitly), and if that step fails outright it fails the job rather than
leaving the output empty.

report-filter.mjs shipped with no tests, in a repo where every other script
has a .test.mjs, and the bug above sat exactly in the seam that most needed
one. New report-filter.test.mjs covers the three states of --show, the union
with block-on, the shared severity parsing, and the usage exits.
settings.test.mjs pins the unconditional --show so the guard cannot come
back.

The `fix-first` input description promised the severities were sent to the
reviewer to fix first. They are not, and were not: the only consumer is the
exhaustive early-stop loop, so the input does nothing at all on a default
(non-exhaustive) run. Description rewritten to say that. Its env line on the
summary and gate steps is now dead — those consumers went with the held-run
branch — so both are removed; the Review step keeps its copy.

The startup cleanup lost result-cheap.json and result-strong.json when the
filenames changed, but a reused self-hosted runner hard-cancelled under the
older action still has them, holding finding text and source snippets. That
step exists for exactly the files an earlier cleanup never reached, so the
legacy names go back alongside the new ones.

The recipe still documented x-cr-prev-tier as none | cheap | strong while
inviting workspace owners to write rules on it, and it had grown a second
copy of the fact block. One block now, documenting the one value, and saying
plainly that a rule matching cheap or strong will fall through to the
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: report_on in the README, and detach the retired tier label

Review round 2, four findings, all real.

The README promised P2 → Comment unconditionally and did not mention the
Report severities control at all. With report_on narrowed — and the shipped
default for a new workspace IS narrower than every severity — a P2 finding is
counted in the summary and never posted on the diff, with nothing anywhere
explaining the gap. The severity table now has a "Posted inline" column and a
P3 row, and says what the two settings are: merge policy decides what blocks,
Report severities decides what is posted, a blocking severity is always posted
whatever the display setting says, and the summary always counts everything.

The bundled setup skill still taught the cascade in four places. The installer
copies it and agents treat it as the configuration authority, so it was
actively teaching removed behaviour: `fix-first` described as withholding the
strong tier, `exhaustive` as extra passes on the strong tier, and SKILL.md
calling fix-first an escalation control. Fixing SKILL.md also fixed a
pre-existing platforms.test.mjs failure.

The workflow template's fix-first comment carried the same over-promise the
input description had ("ask for a concrete fix on these first"). It is the
exhaustive early-stop set and nothing else.

An older version created the repository label `orca-review:strong` and attached
it to a PR when it was promoted, to carry the tier between runs. Nothing reads
or writes it now, so a PR promoted before the upgrade keeps a label asserting a
tier that no longer exists — state this action created and would otherwise
abandon on someone's PR. New step detaches it: a blind removeLabel swallowing
404, not list-then-remove, because reading the labels is the paginated call this
change deleted and paying it every run to discover whether a migration artifact
is present costs more than the removal. continue-on-error, because cleanup must
never turn a passing review red. Before the gate, because the gate exits
non-zero on a blocking finding and a blocked PR is exactly the kind that was
promoted. Deletable once no open PR predates the release, and the comment says
so.

Also swept two stale comments the earlier commit missed: summary-comment.mjs
still documented the held-run exception as live, and action.yml called
RESULT_FINAL a per-tier snapshot overwritten by the escalation pass. And the
new step landed between the summary and the gate, so the four test slices that
used the gate as the summary's end boundary now stop at the new step instead —
including one that was still a bare indexOf pair.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Revert the retired-label cleanup

Assume no open PR carries `orca-review:strong`. The step was migration
cleanup for a label an older version attached, and it cost an API call on
every run, for ever, to undo a one-time artifact — not worth carrying in the
action for that.

Reverts the step, its test, and the four slice boundaries that had moved to
it.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-26 13:51:58 +08:00
AnKaifeng
cf0459b975
docs(skill): the agent hands the API key to gh and never touches it (#27)
* ci: verify against the packument, the document npm actually installs from

Gate 6 has now failed two correct publishes — 1.1.0 and 1.2.0 — both of
which were public and installable minutes later. A gate that cries wolf two
times out of three is not strict, it is broken.

The timeout was a symptom. The cause is that it polled
`registry.npmjs.org/<name>/<version>`, a route nothing in the install path
touches. `npm install` reads the full packument and picks a version out of
it, and that document goes live far sooner — the per-version route stayed
404 for over three minutes after both publishes.

So it now fetches the packument anonymously and looks for the version key.
Verified against the live registry before shipping: 1.2.0 resolves, a
made-up version does not.

Window widened to five minutes to match, but the point is the document, not
the wait. Still unauthenticated: an authenticated read succeeds against a
restricted package and proves nothing, which is the failure gate 6 exists to
catch and did catch on 1.0.2.

* docs(skill): the agent hands the API key to gh and never touches it

Makes the one rule that must not be softened explicit, and repeats it where
the user will actually act on it.

Step 4 now says plainly that this is the step the agent does NOT perform: it
stops, hands over `gh secret set ORCAROUTER_API_KEY`, and waits. gh prompts
on the user's own terminal, so the key goes keyboard-to-GitHub without
passing through the agent.

It also spells out why there is no careful version of handling it — a pasted
key is in the transcript forever, and a key in argv is readable by every
process on the machine through `ps`. A rule without its reason is a rule the
next editor deletes.

Step 8 now closes on outstanding actions as copy-pasteable commands rather
than prose, with the gh command among them, because it almost always is
outstanding. And it forbids reporting the install as complete while the
secret is missing: the workflow would be in place with every run failing on
auth, so "installed" would be a lie the user discovers on their next PR.

Three tests pin all of it. Prose drifts; these do not.
2026-08-25 20:18:20 +08:00
AnKaifeng
0c5f4f5ddb
feat!: the CLI installs the skill and hands over; it no longer interrogates (#25)
Two problems, one root cause: the CLI was trying to be the product instead
of the delivery mechanism for it.

BREAKING: the bare command now installs the agent skill and stops.
Previously it opened a menu and, if you picked Install, asked four
configuration questions before writing anything.

Everything OrcaCode Review can DO — write the workflow, retune the gate,
diagnose a silent run, uninstall — is described in the skill and carried
out by the user's own agent. So the CLI has no business asking anyone about
severities and diff limits. It detects which agents are in use, installs
the skill, and prints what to say next:

    帮我把这个仓库配置上 OrcaCode Review

The subcommands (init / reconfigure / doctor / uninstall) still exist for
scripting and for anyone who would rather not go through an agent. They are
no longer the front door. The main menu is gone, along with its strings.

The skill now leads with a capability table keyed on what a user actually
says, including "@orcarouter code review" and the Chinese phrasings, and
tells the agent never to punt back to an installer.

Fixes prompts stacking down the screen. Answering one now replaces its
whole frame with a single summary line — five prompts deep, the old
behaviour buried the live question under a wall of options the user had
already dismissed.

Three real bugs surfaced while testing that:

  - `collapse()` did not truncate, so a long question plus a long answer
    wrapped — and the summary is the one line the user reads afterwards.
  - `displayWidth` only stripped SGR sequences. Cursor show/hide and erase
    sequences were counted as visible columns, truncating lines that fit.
  - C0 control characters were counted too; the carriage return in every
    rewind cost a column that does not exist.
2026-08-25 18:07:30 +08:00
AnKaifeng
5a3ecb457f
feat: arrow-key menus instead of typing numbers (#23)
* ci: prove a published version is installable by a stranger

`npm publish` exiting 0 does not mean anyone can install what it shipped.

@orcarouter/code-review@1.0.2 published green — the log read
`+ @orcarouter/code-review@1.0.2` and all five gates passed — while the
registry 404'd and the package page 403'd for everyone outside the org.
Scoped packages default to RESTRICTED and it landed that way despite both
`--access public` on the command and `publishConfig.access` in
package.json.

Two steps after publish:

  - `npm access set status=public` — idempotent, fixes the state rather
    than only reporting it. Soft-fail, because the check below is what
    actually decides.
  - An UNAUTHENTICATED fetch of the exact version, retried for a minute to
    absorb CDN lag. Anonymous on purpose: an authenticated read succeeds
    against a private package and proves nothing. This asks the question a
    user asks — can a stranger install this?

Third time in this series that a green step hid a broken result: a tarball
that was well-formed but did not execute, and now one that published but
could not be fetched. Same shape each time — the exit code was checked and
the observable outcome was not.

* feat: arrow-key menus instead of typing numbers

↑↓ to move, Enter to pick. Multi-select adds space to toggle, a/n for
all/none, and / to filter — which is what makes 36 platforms usable without
scrolling past them. Terminals without raw mode keep the typed fallback.

Written directly against the terminal. orcadub gets this from `huh`; a TUI
dependency here would be latency every `npx` user pays before seeing one
menu, on a package that has none.

Three things that are easy to get wrong and are now tested:

  - Redrawing in place needs an exact line count. A line wider than the
    terminal wraps into two, the rewind is short by one, and every redraw
    eats a line of the user's scrollback. Everything is truncated to the
    terminal width first, measured in DISPLAY columns — CJK labels are
    double-width and ANSI sequences are zero-width.
  - Truncation walks escape sequences instead of counting them as
    characters. Cutting mid-sequence prints garbage; counting one as five
    columns truncates a line that would have fit. A reset is appended when
    the cut lands inside a coloured run.
  - Raw mode is released on every exit path, Ctrl-C included. Raw mode
    swallows SIGINT, so an unhandled Ctrl-C leaves a terminal with no
    cursor and no echo — and `gh secret set` runs with inherited stdio
    immediately after a prompt.

Esc does not clear the filter; ctrl-u does. A lone ESC byte is the start of
every escape sequence, so Node's decoder holds it until the next key
arrives — binding anything to Esc makes the prompt appear to freeze the
moment it is pressed. A test pins that behaviour so the workaround can be
revisited if Node ever changes.

36 new tests drive real keypress events through injected streams (378
total). Two bugs surfaced while writing them: the Ctrl-C branch fell
through into the normal handler, and truncate() counted escape bytes as
visible columns.
2026-08-25 17:45:15 +08:00
AnKaifeng
02442f4eac
feat!: publish under the orcarouter org as @orcarouter/code-review (#21)
BREAKING: the install command changes.

    npx orcacode-review          ->  npx @orcarouter/code-review

The package was published unscoped under a personal account (kaifeng.an).
Moving it under the org scope puts it where the product actually lives, and
now is the cheap moment: scoped packages cannot be re-scoped later, so the
choice is only available while the name is still unscoped.

The BIN stays `orcacode-review`. `npm i -g` names the command after the bin
key, not the package, so a global install still gives a short command that
matches the product and the /orcacode-review PR command.

Adds `publishConfig.access: public`. Scoped packages default to RESTRICTED —
without it a hand-run publish that forgets `--access public` ships private,
and the documented install command 404s for everyone outside the org.

1.0.0 and 1.0.1 stay on the registry under the old name; unpublishing burns
those version numbers permanently. RELEASE.md records the `npm deprecate`
that points the dead name at this one.

Four new tests pin the identity, because a rename is exactly the change that
leaves stale strings behind — this one touched thirteen invocation hints
across two languages:

  - publishConfig.access is public whenever the name is scoped
  - the bin key is still the short command
  - every `npx ...` in bin/i18n.mjs names the real package
  - every `npx ...` in README.md names the real package
2026-08-25 17:23:13 +08:00
AnKaifeng
0aa02d3a0b
fix: run the CLI when npm invokes it through its bin symlink (#20)
1.0.0 is inert. `npx orcacode-review` and any global install exit 0 having
printed nothing.

npm installs a `bin` as a symlink at node_modules/.bin/<name>, so argv[1] is
the link while import.meta.url is the link's target. The entry-point guard
compared the two without realpath, was false for every real install, and
main() never ran. Resolving both sides through realpath fixes it.

Nothing caught this before publishing because every local invocation was
`node bin/orcacode-review.mjs`, which is the one path where argv[1] and
import.meta.url already agree. Two things close that gap:

  - installer.test.mjs now execs the CLI through a symlink, plus by real
    path and via import, so all three entry shapes are pinned. Verified the
    symlink case fails against the old guard.
  - publish.yml installs the packed tarball into a scratch project and runs
    npm's own .bin shim before publishing, asserting the printed version and
    that `skill list` produces the catalog. Gates 1-4 all passed for 1.0.0;
    only an actual install can catch this class of bug.

Bumps package.json, plugin.json, and marketplace.json to 1.0.1 together, as
the version-sync gate requires.
2026-08-25 16:50:28 +08:00
AnKaifeng
cce460494d
feat: one-command installers — npx CLI + Claude Code plugin (#18)
Adds two ways to install OrcaCode Review without hand-writing YAML, plus
the agent skill that drives them conversationally.

`npx orcacode-review` — zero-dependency CLI covering the full lifecycle:
init, reconfigure, doctor, uninstall, and `skill install` across 36 agent
platforms. Zero deps is deliberate: npx downloads the whole tree before
running anything, so every dependency is install-time latency and a
supply-chain edge on a tool whose job is touching CI config.

`.claude-plugin/` — makes this repo its own plugin marketplace, so the
skill ships from main with no release step.

Notable decisions:

- The API key never passes through this process. `gh secret set` runs with
  inherited stdio so the user types it into gh, not into argv (visible in
  `ps`) or an agent transcript.
- Only inputs that differ from their documented default are written. With
  the dashboard authoritative, an input equal to its default changes
  nothing, so writing it implies control the file does not have.
- `uninstall` drops the required check before deleting the workflow. A
  required check whose workflow is gone never reports, and every PR blocks
  forever with no way to clear it.
- `doctor` checks the workflow is on the BASE branch, not just locally —
  pull_request_target reads it from there, so a feature-branch-only file
  produces no runs and no error.
- The platform catalog is a port of orcadub-mcp-server's, IDs and paths
  included, so `--platform codex` means the same directory in both. Two
  detection rules are load-bearing and now tested: never detect on a root
  most repos have (`.github`, `.`), never detect on an executable that
  collides with a stock system binary (Command Code's `cmd`).
- Skill installs compare the whole tree. A half-updated skill — new
  SKILL.md pointing at a prior version's reference file — is worse than
  either version alone, because the agent follows the link and cannot tell.
  An existing differing copy is never overwritten without --force.
- zh/en throughout, from the locale or --lang. Prose only: flags, platform
  IDs, workflow inputs, and shell commands stay verbatim in both, because
  the reader still has to type them.

54 new tests (334 total).
2026-08-25 15:55:12 +08:00
Zhenghua Bao
024fb647bd
fix: stop --pricing from being mistaken for the usage file
A flag value is not a positional argument; fold the two argv scans into one pass that advances past it. Adds 6 tests.
2026-08-19 11:27:45 +08:00
Zhenghua Bao
3e40adb838
test: cover judge.mjs
27 tests over threshold parsing, schema-drift coercion, fail-open for unclassified findings, malformed groups, and transport failures.
2026-08-19 11:20:23 +08:00
ZhenghuaBao
3a1a1b4912 test: cover postfilter.mjs
postfilter.mjs is the L1 deterministic filter and runs on every review
(`precision-filter` defaults to "true"), but had no tests. Both failure
directions cost real review value: too eager drops a genuine defect or
re-homes it onto the wrong file, too timid lets misfiled findings through.

17 tests against a REAL git repo fixture rather than a mocked git -- the
whole point of L1 is what git-grep reports about an actual tree.

Pinned behavior:
- claimed path matches      -> keep, path and lines untouched
- one other file, one line  -> re-home path AND line numbers to the target
- one other file, N lines   -> re-home path, clear the line (a stale line
  would post on an unrelated line or trip GitHub's range validation)
- several files             -> ambiguous, keep as filed
- found nowhere             -> keep on a code path; DROP only when the
  claimed path is clearly non-code

The non-code classification gets its own cases because it gates every DROP:
lockfiles, locale bundles, dist/ output and docs are droppable, while
action.yml, package.json and workflow YAML are reviewable configuration.
Extensionless code (Dockerfile, Makefile) is pinned explicitly -- it used to
fall through this branch and get dropped as if it were a locale file.

Also covers the 12-char locator floor, content dedupe (tag/case/whitespace
insensitive), survival of non-comment top-level keys, and a non-array
comments field yielding an empty result rather than a crash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 11:07:13 +08:00
Zhenghua Bao
e73ffe5197
test: cover check-result.mjs
19 tests over the fail-closed paths (engine exit code, result shape, partial review) and the clean-review success path.
2026-08-19 11:04:02 +08:00
Zhenghua Bao
8e96c09467
test: cover usage-summary.mjs
27 tests over aggregation, best-effort I/O, price-list resolution, and cost math.
2026-08-19 10:14:33 +08:00
Zhenghua Bao
e6a65c48cc
Sync the product name to "OrcaCode Review", accept all four trigger spellings (#12)
* Sync the product name to "OrcaCode Review", accept all four trigger spellings

Two concerns, one release since they both touch action.yml.

NAMING. The Action called itself "Orca-Code-Review" while the App brands
its comments "OrcaCode Review", so one product signed the same PR two
ways. Renames the display name only: action `name`, the `brand` default,
the commit-status description, the summary-comment heading, README/NOTICE
/recipe titles, and source header comments.

Deliberately NOT renamed, because each is an identity rather than a label:
  - the four `<!-- orca-code-review-* -->` upsert markers. The Action finds
    its own previous comments by these strings; renaming them orphans every
    comment already posted in every consumer repo and the next run posts a
    duplicate instead of editing in place.
  - `<!-- orca-cr-summary:start/end -->`, same reason for the PR-description
    region.
  - the repo slug in `uses:` and the `.github/workflows/` filename. Nobody
    reads a repo slug; the visible names above are what users see, and
    changing the slug would rewrite every consumer's workflow for nothing.
The heading is safe to change precisely because nothing matches on it —
upsert goes through the marker and the push counter through
`<!-- orca-cr-state: … -->`.

TRIGGER. The example workflow gated on `/orca-code-review`, a spelling the
App deliberately dropped, so the documented command did nothing on the App
path. Now accepts the full cross of both prefixes and both separators —
`/orcacode-review`, `/orcacode review`, `@orcacode-review`,
`@orcacode review` — matching the App exactly. The cross is the point: a
partial set is a trap, because the reader who writes the one spelling you
left out gets no run, no comment, and no error to explain it.

METERING (was already staged in the tree). `fact-proxy.mjs` gains
`CR_USAGE_FILE` per-call token accounting and `CR_MAX_RPM`, plus
`scripts/usage-summary.mjs` to turn that log into a per-model cost. It
keeps only a bounded tail of each response so SSE stays unbuffered, and
every extraction and append is soft-fail — metering is observability and
must never gate a review. New inputs: `concurrency` (default 24 — the
engine's own default of 8 was never set, which reads as per-file timeouts
on slower models), `max-tools`, and `meter`.

Tests: 176 pass. The 5 failures in settings.test.mjs are a pre-existing
libuv crash on Windows + Node 24, identical on an unmodified tree.

* Fix three metering defects found in review

Round 1 of review follow-ups. Three of four findings were real; the fourth
is declined below with its reason.

1. `model` was read from the same bounded TAIL as `usage`, but an
   OpenAI-shaped body puts `model` near the START. Any non-streaming
   response larger than the tail therefore recorded `model: null`, and
   usage-summary groups those under "(unknown)" and cannot price them —
   which is the entire point of metering. Keep a small bounded head as
   well and prefer it for the model, falling back to the tail.

2. The proxy forwarded `accept-encoding` untouched, so a gzip or Brotli
   body reached the metering tap as compressed bytes and every token field
   came out null. This is the DEFAULT path, not an edge case: the engine is
   a Go binary and Go's net/http adds `Accept-Encoding: gzip` on its own.
   Ask upstream for identity while metering. Left alone when metering is
   off — nothing then justifies giving up compression.

3. Token accounting sat at the end of the review shell, which `exit 1`s on
   wall-clock timeout, unusable engine output, and policy blocks. Those are
   exactly the runs whose spend you want to see, since the tokens were
   spent either way, and the final cleanup deletes cr-usage.jsonl so the
   numbers were unrecoverable. Moved to its own `always()` step ahead of
   cleanup.

DECLINED — cancel queued limiter admissions for disconnected clients. The
mechanism is real: `acquire()` resolves and takes a slot before the callback
notices `clientGone`. But `createRateLimiter` returns a no-op acquire when
`maxRpm <= 0`, and action.yml never sets `CR_MAX_RPM`, so on the shipped
path this code cannot run. Making admission cancellable means restructuring
the limiter to buy nothing on any path we ship. Worth revisiting if and when
a rate ceiling is actually configured.

Tests: 3 added, and each was checked against a reverted fix — the two
fact-proxy tests fail without their fix and the third (accept-encoding
untouched when metering is off) passes either way by design.
fact-proxy.test.mjs is 38/38.
2026-08-18 17:45:49 +08:00
Zhenghua Bao
78274575e7
v1.4.1: address review follow-ups on precision-filter (#10)
scripts/judge.mjs:
- Strict --threshold parsing (parseStrictDecimal); reject NaN and out-of-range.
- Uncovered fail-open confidence set to 1.0 so raising --threshold above 0.5
  does not turn fail-open into fail-closed.
- Guard the kept-set push against a malformed judge group whose
  representative_id resolves to undefined; fall back through member_ids.
- Type-guard confidence before coercion so `true` / `[1]` / stringified
  booleans cannot bypass the threshold as `1`.
- Reject out-of-range numeric confidence outright rather than clamping it
  to the maximum — schema violation drops the group.
- Strictly coerce the group's `keep` flag before applying the threshold
  so a stringified `"false"` does not survive as truthy.

scripts/postfilter.mjs:
- Use `git grep -l` plus a targeted `-n` lookup on rehome so filenames
  containing colons parse unambiguously.
- Carry the actual matched line number into rehomed findings so the
  posted comment lands on the correct line after a rehome.
- Flip the polarity to "known-non-code extension drops" so Dockerfile,
  Makefile, and extensionless scripts are no longer dropped as if they
  were locale files.
- Expand the known-non-code list to cover binary and generated assets
  (source maps, images, fonts, archives, native binaries, wasm, media).
- Narrow the .json / .yml drop to paths that also match a non-reviewable
  convention (lockfiles, locales / i18n / translations, generated build
  output) so real config (action.yml, workflows, package.json, IaC
  manifests) is never dropped based on extension alone.
- On the "single target file, line unresolved" rehome branch, clear
  the now-stale start_line / end_line to avoid posting on an unrelated
  line in the new file.

action.yml:
- Snapshot POLICY_BLOCK CONTENT (not just presence) before the L2 judge
  call and restore it on both success and failure paths, so a sidecar
  guardrail hit cannot overwrite or clear the engine's authoritative
  block.
2026-07-24 17:08:39 +08:00
Zhenghua Bao
326612e571
v1.4.0: precision-filter post-processing between engine and merge gate (#9)
* Add precision post-processing (L1 postfilter + L2 LLM judge)

New pipeline stage between `ocr review` and check-result.mjs, gated by a
new `precision-filter` input (default true). Targets the FP pattern
seen on large PRs where the reviewer copies the same finding text onto
multiple files that do not contain the referenced code (adjudicated at
~23% FP rate on orca-cyber-harness#13; the same pattern reproduced in
bakeoff runs on minimax as well).

L1 — `scripts/postfilter.mjs` (deterministic, no LLM):
  Uses each finding's `existing_code` snippet as a locator. git-greps
  the snippet in the reviewed commit's tree; if found in exactly one
  other file, re-homes the finding there; if found nowhere and the
  claimed path is a non-code file, drops the finding. Also dedupes by
  normalized content.

L2 — `scripts/judge.mjs` (one LLM call, independent vendor):
  Clusters findings by root cause and scores each cluster 0–1 for
  "concrete, correct, high-value defect in this change". Keeps one
  representative per cluster above `judge-threshold` (default 0.7).
  Uses an independent model (default `deepseek/deepseek-v4-pro`); the
  same-vendor guardrail from earlier testing showed a same-family
  judge under-catches its own errors. Configurable via new
  `judge-model` and `judge-threshold` inputs. LLM connection reads
  OCR_LLM_URL / OCR_LLM_TOKEN env vars (production) with a
  ~/.opencodereview/config.json fallback for the local harness.

Both stages are soft-fail: any error keeps the prior stage's findings
and never aborts the review. Also skipped when the engine timed out or
produced no findings — nothing to filter.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* judge: raise max_tokens 8000 -> 32000 (48-finding cluster JSON was truncated)

Observed on the minimax 80bffaa72 bakeoff (48 findings): the JSON response
exceeded 8000 output tokens mid-cluster, parseFail → judge exited 1 → the
action's fail-safe kept the L1 output (47) instead of the intended judge
cut. 32k gives headroom well past 100 findings; measured usage at 47 in
was ~20k. Independent of model — same fix in testbed/judge.mjs.

* docs: generic wording for precision-filter inputs; default judge-threshold 0.5

Scrub the action's precision-filter, judge-model, and judge-threshold
input descriptions (and matching README rows) to plain mechanism-only
language; describe what each input does, not why or how it was tuned.
Adjust judge-threshold default from 0.7 to 0.5.

---------

Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-07-24 10:44:05 +08:00
Zhenghua Bao
317031efb9
v1.2.0: 👀 reaction on requests + rename comment trigger to /orca-code-review (#6)
* Add 👀 reaction to acknowledge review requests

Static one-shot reaction so the requester sees the bot noticed the
trigger. On every event, react on the PR body (issue reactions
endpoint — PRs are issues). For issue_comment triggers
(/orcarouter-review), also react on the trigger comment so a
maintainer sees direct feedback on THEIR comment. Uses gh api with
inputs.github-token (issues: write + pull-requests: write already
granted in the example workflow).

Reaction outages are non-fatal: the review must never be blocked by a
UI-affordance failure. Placed after Resolve PR refs (PR number
available) and before any skip gate, so even a skipped run still
visibly acknowledges receipt. Reactions API POST is idempotent, so
re-runs (synchronize) don't duplicate.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* Rename comment trigger /orcarouter-review to /orca-code-review

Matches the shipped brand (Orca-Code-Review). Legacy /orcarouter-review
was left over from the pre-rebrand naming and confused new consumers
about what command actually re-runs the review.

Applies the rename in the example workflow's issue_comment `if:` gate,
the SECURITY.md threat-model section, the action's input-description
prose, the README, and one comment in the settings unit test. No
runtime code depends on the exact string outside the workflow's `if:`
gate itself, so no gate-logic change is needed.

Consumers with the previous workflow copy must update the trigger
string in their `.github/workflows/*.yml` to /orca-code-review.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-07-21 15:57:51 +08:00
Zhenghua Bao
96a0f874c0
Feed repo conventions (AGENTS.md/CLAUDE.md) to the review engine (#5)
* feat(review-action): feed repo conventions (AGENTS.md/CLAUDE.md) to the engine

Point the review engine at the repo's own AGENTS.md/CLAUDE.md/CONTRIBUTING.md
so it stops flagging deliberate project choices as issues. The directive rides
--background and is gated to same-repo PRs only: a fork's head-checked-out
conventions doc is attacker-controlled, so the directive (which tells the
engine to trust that file) must never apply to forks. Framed defensively — the
doc is untrusted reference data that may only suppress style findings and can
never weaken correctness/security review or alter severity tagging.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(review-action): read repo conventions from the base revision

The conventions directive pointed the engine at the PR head's
AGENTS.md/CLAUDE.md, which a fork PR author controls (Codex P1). Extract
the doc ourselves from the trusted base revision via `git show
"$BASE:<path>"` and inline it into the background, dropping the same_repo
gate (base is trusted regardless of fork). Switch the invocation to
--background-file so a large doc can't hit the argv limit.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(review-action): use inline --background (1.3.13 has no --background-file)

The pinned engine 1.3.13 errors on --background-file (that flag only
exists in newer versions). Pass the assembled background file inline via
--background "$(cat …)", which 1.3.13 supports; the base-revision
conventions extraction is unchanged.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(review-action): gate base conventions on default branch, cap size

Only inline the base-revision conventions doc when the PR targets the
repo's default branch — a PR base is author-chosen, so only the default
branch is a protected ref the author cannot rewrite. Cap the appended
doc at 32 KiB to keep the inline --background argv under E2BIG, and pin
the framing-directive-before-doc order in the test.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-07-17 18:46:47 +08:00
Zhenghua Bao
01edbdf5c5
P3 severity + summary pinned to top of PR description (#4)
* feat(review-action): pin per-push summary to top of PR description

Move the per-push Orca-Code-Review summary out of a chronologically-buried
issue comment and into a marker-delimited region at the top of the PR
description body, the only element GitHub anchors at the top. A new pure
merge primitive (inject-summary.mjs) prepends the region on first push and
replaces it in place afterward, preserving author-written body text. The
driver reads the prior region back for the push counter and Δ column, and
best-effort deletes any legacy summary issue comment on migrated PRs.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* feat(review): add P3 severity, split style nits out of P2

Add a P3 level below P2 to mirror Codex. P2 is redefined as a
real-but-conditional bug (a genuine defect that fires only under a
precondition the code doesn't normally meet); P3 is a pure
style/maintainability nit with no behavioral defect. Both stay
non-blocking and are still posted. SEVERITIES flows the new level through
the gate, run report, and PR summary automatically.

This builds on the P1/P2 calibration + PRECISION rules from #2 (kept
intact); it only adds the P2/P3 split and the P2-vs-P3 boundary note.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(review): handle P3 in Δ state, inline badge, and preserve author body

Address self-review findings on the P3 change:
- summary-comment.mjs stored only p0/p1/p2 in the state line, so on push 2+
  the P3 Δ column subtracted an undefined prev.p3 and rendered NaN. Store p3
  and treat a missing prev.p3 as 0 (back-compat with older state lines).
- action.yml's inline renderer used its own P[012] tag regex + a SEV map
  without P3, so a [P3] finding fell back to a P1 badge — the inline comment
  disagreed with the gate/summary. Recognize P3 (⚪) in the regex, badge map,
  and breakdown.
- inject-summary.mjs trimStart() stripped meaningful leading whitespace from
  the author's PR body on first injection; prepend the body verbatim instead.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

* fix(review): complete P3 across dedup key and control-plane report

The dedup key stripped only [P0]-[P2] tags, so a re-tagged P3 finding
would not merge across exhaustive passes; the report payload omitted the
P3 count entirely. Both are gaps in the new P3 severity level.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
2026-07-16 19:07:27 +08:00
ZhenghuaBao
bc28316975 rebrand: OrcaRouter Code Review → Orca-Code-Review; drop Open Code Review credit line from README
Renames the product to "Orca-Code-Review" across the action name, brand
default, rendered PR-comment header, examples, recipe, and internal comments.
Removes the visible "Review engine powered by Open Code Review" line from the
README; the Apache-2.0 attribution to Alibaba remains in NOTICE (compliant).
LICENSE unchanged.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-07-07 19:58:55 +08:00
Claude
a2bdddb4e4
fix: codex review — author gate applies when settings disabled, policy-block stays fatal in exhaustive mode
- P1: settings:'false' (workflow-authoritative) short-circuited to
  decision=review BEFORE the author gate, so auto-review-authors (a
  workflow input, not a dashboard setting) was bypassed — a fork author
  could trigger paid runs despite the allowlist. Factored gate_decision()
  (auto_review/trigger/draft + author allowlist) and call it on BOTH the
  settings-disabled and settings-enabled paths
- P2: an exhaustive extra pass hitting a guardrail/firewall POLICY_BLOCK
  was downgraded to warn+break, letting the required check go green while
  the surface step posts 'merge blocked' — now a policy block on an extra
  pass fails closed like the primary path; benign tooling/merge failures
  still warn+break and keep findings
- node:test 146 pass (+12); TDD-proven (reverting P2 flips the policy test red)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 12:10:05 +00:00
Claude
dff1518992
fix: round-3 review — real strong-tier model, release-gate doc, input docs
- recipe strong tier z-ai/glm-5.2 -> z-ai/glm-5.1 (glm-5.2 exists in no
  catalog; a clean PR escalating to strong would 404 at the relay)
- RELEASE.md documents the one external blocker: the @v1 tag must be moved
  to the merged HEAD that ships settings.mjs/report.mjs, or the console
  Settings/Analytics tabs are silently inert
- README documents the github-token and engine-version inputs
- report.mjs header describes the control-plane URL precisely (sub-path
  preserved, only the /v1 relay segment stripped)
- node:test 134 pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 11:35:27 +00:00
Claude
6c93d29564
fix: round-2 review — proxy client-disconnect safety, upstream timeout, exhaustive/block-comment robustness, summary hold count
- fact-proxy: client-facing res now has error/close handlers + a
  process-level uncaughtException backstop (CLI only), and an OCR
  disconnect mid-relay cancels the in-flight upstream request (no crash,
  no leaked/billed call); guardrail-400 buffer branch also guarded
- fact-proxy: upstream request timeout on both paths (default 120s,
  CR_UPSTREAM_TIMEOUT_MS) so a black-hole gateway fails fast into the
  existing retry classification instead of hanging until OCR times out
- exhaustive extra-pass merge/parse guarded under set -e (|| warn+break):
  a tooling error ends exhaustion gracefully, keeping findings, instead of
  failing the job and skipping the post/summary/enforce steps
- guardrail-block PR comment now carries a marker and is upserted +
  retired on a clean review (no more one-per-push duplicate block spam)
- summary '❌ N findings block merge' counts over fix-first when HELD
  (--held/--fix-first), fixing the '0 findings block merge' contradiction
  when block_on is empty
- settings.mjs/report.mjs synopsis drop the removed --key flag; author
  allowlist space-trims both sides
- tests: author-gate anchoring (zero→6), quiet report-steps read
  unfiltered snapshots, env-key wiring (no --key); 134 pass (116+18)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 11:12:45 +00:00
Claude
c9eea69595
fix: round-1 review — API key via env not argv, fork-PR auto-review spend gate
- settings.mjs/report.mjs read ORCAROUTER_API_KEY from the environment
  instead of a --key argv flag, which leaked the key via /proc/pid/cmdline
  and ps on shared/self-hosted runners; action.yml drops --key (env already
  set); tests pass the key via spawn env
- new auto-review-authors input: allowlist author associations for
  AUTOMATIC reviews so anonymous fork PRs on public repos can't drain the
  wallet via pull_request_target (empty default preserves review-everyone;
  comment commands stay maintainer-gated); resolve step exposes
  author_association, settings gate enforces the allowlist
- SECURITY.md: 'Public repos & spend' section (budget mandatory + author
  gate); key-hygiene note on env-not-argv; README input row
- node:test 116 pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 10:38:08 +00:00
Claude
8542da1818
fix: control-plane URLs preserve gateway sub-paths (self-hosted deployments)
settings.mjs and report.mjs derived their endpoints from new URL(url).origin,
discarding any path prefix a self-hosted gateway is mounted under —
https://host/orca/v1/... silently sent settings/report calls to
https://host/api/... (404: settings fell back to defaults, reports dropped).
Shared controlPlaneBase() strips only the trailing /v1 relay segment.
node:test: 116 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 10:00:59 +00:00
Claude
7b1d04b107
fix: adversarial-review fixes — proxy crash/hang/idempotency, merge-gate integrity, settings authority
- fact-proxy: retries can never fire after a response began relaying
  (double-writeHead crash); per-attempt settled latch kills duplicate
  retry chains; streaming relay destroys the client on upstream error
  (no more 6h hangs); connection-error retries only when provably
  pre-send (ECONNREFUSED/ENOTFOUND/EAI_AGAIN or pre-flush); bodies over
  8MiB stream through with retries disabled
- oversized-diff skip now FAILS the required check by default
  (on-oversized-diff: fail|pass) — padding a diff no longer bypasses the
  P0/P1 merge gate; diff-guard stats before reading (no more loading a
  300MB diff to decide to skip it)
- summary gate line counts against the configured --block-on set instead
  of hardcoded P0+P1
- exhaustive-merge.mjs de-binarized (NUL bytes → \u0000 escapes: git can
  diff it again); exhaustive extras restricted to the strong tier and
  break early once fix-first findings exist
- new settings input ('false' = workflow file authoritative); settings
  gate widened to all pull_request* events
- stale-file init step (16 run temp files cleared first) kills false
  guardrail-block comments on reused runners; stale skip notices deleted
  when reviews resume; proxy-startup sed race fixed
- severity literals import SEVERITIES from severity.mjs
- node:test: 109 pass (94 + 15 new)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 09:59:15 +00:00
Claude
812cfd3c2b
feat: dashboard-driven settings, quiet mode, rubric override, exhaustive review loop
- settings.mjs fetches effective workspace/repo settings from the gateway
  at run start (5s timeout, one retry, field-wise fail-open to defaults)
- gating: auto_review=false / on_demand / ready_for_review+draft skip with
  a single upserted marker comment; comment commands always proceed
- precedence: explicit non-default with: inputs beat server settings
- quiet mode filters P2 inline comments (gate/report see true counts);
  summary notes it
- exhaustive mode: up to 2 extra engine passes per tier, results merged
  with tag/whitespace-insensitive dedup; loop stops when nothing new
- rubric override from server replaces the bundled severity instruction
- node:test 94 pass (59 wave-1 + 35 new); action.yml bash blocks smoke-run
  against a mock gateway and fake engine

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 08:39:03 +00:00
Claude
f2c72fc88f
feat: retry/backoff proxy, oversized-diff guard, run report-back, edit-in-place summary comment
- fact-proxy buffers bodies and retries 429/502/503/504 + connection errors
  (3 attempts, 1s/2s/4s, Retry-After honored ≤30s), x-cr-retry-count header;
  SSE response streaming unchanged
- diff-guard.mjs: review/skip decision (max-diff-kb 512, max-diff-files 300
  inputs); skip posts an explaining comment and passes the check fail-open
- severity.mjs extracted from gate.mjs (leading-tag + untagged→P1 fail-safe
  shared, gate contract tests unchanged)
- report.mjs: best-effort POST run summary (counts/sha/tier/gate only) to
  <gateway origin>/api/code_review/report; 5s timeout, one retry, never
  fails the job; report:false opt-out input; per-tier wiring in action.yml
- summary-comment.mjs: single upserted PR comment with severity table,
  Δ vs previous push via embedded orca-cr-state JSON, tier + gate lines
- README: new inputs, run-reporting privacy note
- node:test suites: 59 pass

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC1sVtj2Jc9cAX7FENZ1hG
2026-07-04 08:02:21 +00:00
ZhenghuaBao
d4945be57e test: run gate contract tests on node:test (no vitest dep) 2026-06-25 10:33:45 +08:00
ZhenghuaBao
024f71e38d OrcaRouter Code Review — initial public release
AI pull-request review GitHub Action powered by the OrcaRouter gateway:
a stateful cost-tiered cascade (cheap screen → strong final pass), a
P0/P1/P2 severity rubric, inline PR comments, a configurable merge gate,
and guardrail/firewall block surfacing. The review engine is Alibaba's
Open Code Review (Apache-2.0), consumed as a package; see NOTICE.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
2026-06-25 10:23:55 +08:00