From bd148c4b505773b08ea4980af0df84a9bcd38f2f Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 23:25:58 -0400 Subject: [PATCH 1/7] test: live-review smoke marker Co-Authored-By: Claude Fable 5 --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index e647057..be59e40 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,5 @@ path instead of the host-port URL.) ``` npm test # node --test: JSON extractor, verdict derivation, trailer parser ``` + + From e3ad9c25fcab81403731a9ad56795740126eb29e Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 23:27:50 -0400 Subject: [PATCH 2/7] test: retrigger review webhooks Co-Authored-By: Claude Fable 5 From 74222a1631caa0a3727132938d5244ebc634043b Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 23:35:12 -0400 Subject: [PATCH 3/7] fix: handle OpenAI-style router responses + log unparseable model output The router's response shape varies by upstream: some backends return .message.content, some choices[0].message.content, some only top-level .content. Prefer them in that order. Record the actually-served model (_shre.model) in the ledger and log a snippet of unparseable output for diagnosis. Co-Authored-By: Claude Fable 5 --- src/lib/review.js | 16 ++++++++++------ src/lib/router.js | 9 +++++++-- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/src/lib/review.js b/src/lib/review.js index 3c5139a..20e13b2 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -55,18 +55,22 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { const messages = buildReviewPrompt({ repo: fullRepo, ref: job.ref, sha, prTitle: job.prTitle, conventions, diff, truncated }); - let review = null, llmError = null; + let review = null, llmError = null, servedModel = null; await withGlobal(async () => { for (let attempt = 1; attempt <= 2; attempt++) { try { - const content = await chat({ + const out = await chat({ routerUrl: cfg.routerUrl, model: cfg.model, messages, agentId: 'code-reviewer', - sessionId: `review-${owner}-${repo}-${sha.slice(0, 12)}`, + sessionId: `review-${owner}-${repo}-${sha.slice(0, 12)}-a${attempt}`, tenantId: cfg.tenantId, timeoutMs: cfg.routerTimeoutMs }); - const raw = extractJson(content); - if (!raw) throw new Error(`no JSON object in model output (attempt ${attempt})`); + servedModel = out.servedModel; + const raw = extractJson(out.content); + if (!raw) { + log(`[${fullRepo}@${sha.slice(0, 8)}] unparseable model output (served=${servedModel}): ${JSON.stringify(out.content.slice(0, 400))}`); + throw new Error(`no JSON object in model output (attempt ${attempt})`); + } review = normalizeReview(raw); if (!review) throw new Error('normalization failed'); llmError = null; @@ -111,7 +115,7 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { axes: review.axes, overall: review.overall, grade: review.grade, verdict: review.verdict, findings: review.findings, statusState: state, error: null, diffBytes, truncated, - model: cfg.model, durationMs: Date.now() - t0 + model: servedModel || cfg.model, durationMs: Date.now() - t0 }); log(`[${fullRepo}@${sha.slice(0, 8)}] reviewed: ${review.verdict} grade=${review.grade} overall=${review.overall} findings=${review.findings.length} in ${Date.now() - t0}ms`); return { ok: true, verdict: review.verdict }; diff --git a/src/lib/router.js b/src/lib/router.js index f9d0ee8..dda0a6b 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -14,9 +14,14 @@ export async function chat({ routerUrl, model, messages, agentId, sessionId, ten throw new Error(`router -> ${res.status}: ${text.slice(0, 300)}`); } const data = await res.json(); - const content = data?.message?.content ?? data?.content; + // Response shape varies by upstream: prefer .message.content, then + // OpenAI-style choices, then top-level .content (which can carry an + // upstream-down apology — hence last). + const content = data?.message?.content + ?? data?.choices?.[0]?.message?.content + ?? data?.content; if (typeof content !== 'string' || !content.trim()) { throw new Error('router returned empty content'); } - return content; + return { content, servedModel: data?._shre?.model ?? null }; } From ed4b347caba62205af9a819f6f33c51239470eb4 Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 23:41:09 -0400 Subject: [PATCH 4/7] fix: send tools:false to the router so skill fast-paths cannot hijack reviews The router's current-info briefing fast-path (enableToolsEffective gate) was intercepting review prompts whose diffs mention GitHub-like terms and returning a trending-repos digest instead of the model's review. Reviews need pure model reasoning; body.tools=false disables the tool layer. Co-Authored-By: Claude Fable 5 --- src/lib/router.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/router.js b/src/lib/router.js index dda0a6b..b23de2b 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -6,7 +6,10 @@ export async function chat({ routerUrl, model, messages, agentId, sessionId, ten const res = await fetch(`${routerUrl.replace(/\/$/, '')}/v1/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false }), + // tools:false — reviews need pure model reasoning; it also keeps the + // router's tool/skill fast-paths (e.g. current-info briefing) from + // hijacking prompts whose diffs mention GitHub/news-like terms. + body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false, tools: false }), signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) { From 7416df88c93308aba256ba8e9e1ef1ede52e154e Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Tue, 18 Aug 2026 23:52:43 -0400 Subject: [PATCH 5/7] feat: pull PR commits for Co-Authored-By attribution on PR reviews PR webhooks only carry title/body; fetch /pulls/{i}/commits so the trailer ledger and per-agent digests attribute PR reviews correctly. Co-Authored-By: Claude Fable 5 --- src/lib/gitea.js | 4 ++++ src/lib/review.js | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/src/lib/gitea.js b/src/lib/gitea.js index 0e69b5c..7a8fcd1 100644 --- a/src/lib/gitea.js +++ b/src/lib/gitea.js @@ -65,6 +65,10 @@ export class GiteaClient { }); } + getPrCommits(owner, repo, index) { + return this.req('GET', `/api/v1/repos/${owner}/${repo}/pulls/${index}/commits?limit=50`); + } + getCommit(owner, repo, sha) { return this.req('GET', `/api/v1/repos/${owner}/${repo}/git/commits/${sha}?stat=false&verification=false&files=false`); } diff --git a/src/lib/review.js b/src/lib/review.js index 20e13b2..8af01b1 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -19,6 +19,18 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { const dashboardUrl = `${cfg.publicBaseUrl}/review/${owner}/${repo}`; const t0 = Date.now(); + // For PR events the webhook only carries title/body; pull the PR's commits + // so Co-Authored-By trailer attribution sees real commit messages. + if (job.prIndex) { + const commits = await safe(log, 'pr-commits', () => gitea.getPrCommits(owner, repo, job.prIndex)); + if (Array.isArray(commits)) { + job.commitMessages = job.commitMessages.concat( + commits.map(c => c?.commit?.message || '').filter(Boolean)); + const names = commits.map(c => c?.commit?.author?.name || c?.author?.login).filter(Boolean); + job.authors = [...new Set(job.authors.concat(names))]; + } + } + // 0. pending status (best-effort) await safe(log, 'pending-status', () => gitea.postStatus(owner, repo, sha, { From f7b56297a81e0010ee73871bcc8a4f82908ad1fa Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Wed, 19 Aug 2026 00:00:37 -0400 Subject: [PATCH 6/7] fix: raw:true bypass for router keyword fast-paths + c-suite fallback agent Two live-observed router hijack modes on diff content: (1) retail/store fast-paths (store-resolver, arm:sales) intercepting prompts whose diffs mention sales/store-ish words, (2) domain preflight 400 agent_mismatch when the intent classifier confidently misclassifies a diff. raw:true (_bypassToolRouting) disables the keyword intercepts; attempt 2 retries as the configurable c-suite fallback agent (architect) which bypasses the tier-gated domain preflight. Co-Authored-By: Claude Fable 5 --- src/config.js | 2 ++ src/lib/review.js | 6 +++++- src/lib/router.js | 12 ++++++++---- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/config.js b/src/config.js index b8bb879..9454c7b 100644 --- a/src/config.js +++ b/src/config.js @@ -11,6 +11,8 @@ const DEFAULTS = { webhookSecret: '', routerUrl: 'http://localhost:5497', model: 'anthropic/claude-sonnet-4-6', + agentId: 'code-reviewer', + fallbackAgentId: 'architect', tenantId: 'nirlab', publicBaseUrl: 'http://localhost:5498', dbPath: join(CONFIG_DIR, 'reviews.db'), diff --git a/src/lib/review.js b/src/lib/review.js index 8af01b1..b8cc669 100644 --- a/src/lib/review.js +++ b/src/lib/review.js @@ -71,9 +71,13 @@ export async function runReview({ cfg, gitea, db, job, withGlobal, log }) { await withGlobal(async () => { for (let attempt = 1; attempt <= 2; attempt++) { try { + // Attempt 1 uses the review agent. Attempt 2 falls back to the + // c-suite agent (bypasses the router's domain preflight), covering + // classifier false-positives on diff content: agent_mismatch 400s + // and tool fast-path hijacks that return non-JSON. const out = await chat({ routerUrl: cfg.routerUrl, model: cfg.model, messages, - agentId: 'code-reviewer', + agentId: attempt === 1 ? cfg.agentId : cfg.fallbackAgentId, sessionId: `review-${owner}-${repo}-${sha.slice(0, 12)}-a${attempt}`, tenantId: cfg.tenantId, timeoutMs: cfg.routerTimeoutMs }); diff --git a/src/lib/router.js b/src/lib/router.js index b23de2b..bb65144 100644 --- a/src/lib/router.js +++ b/src/lib/router.js @@ -6,10 +6,14 @@ export async function chat({ routerUrl, model, messages, agentId, sessionId, ten const res = await fetch(`${routerUrl.replace(/\/$/, '')}/v1/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - // tools:false — reviews need pure model reasoning; it also keeps the - // router's tool/skill fast-paths (e.g. current-info briefing) from - // hijacking prompts whose diffs mention GitHub/news-like terms. - body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false, tools: false }), + // tools:false + raw:true — reviews need pure model reasoning. raw:true is + // the router's sanctioned _bypassToolRouting flag: without it, keyword + // fast-paths (current-info briefing, store-resolver, retail dispatch) + // hijack prompts whose DIFF CONTENT mentions github/sales/store-ish terms + // and return non-review text. The domain preflight (agent_mismatch 400) + // still applies per agent tier; the pipeline's attempt-2 fallback agent + // covers that. + body: JSON.stringify({ model, messages, agentId, sessionId, tenantId, stream: false, tools: false, raw: true }), signal: AbortSignal.timeout(timeoutMs) }); if (!res.ok) { From 7d7b69e4e4434278f9667f3f69548226935fc3e4 Mon Sep 17 00:00:00 2001 From: Nirav Patel Date: Wed, 19 Aug 2026 00:01:05 -0400 Subject: [PATCH 7/7] test: final e2e verification run Co-Authored-By: Claude Fable 5